brepkit_render/lib.rs
1//! Offscreen GPU renderer for brepkit B-Rep solids.
2//!
3//! [`render_solid_offscreen`] tessellates a [`Solid`](brepkit_topology::solid)
4//! and rasterizes it to images with [`wgpu`], entirely off-screen (render to
5//! texture + read back). No window or display is required, so it runs in
6//! headless CI given any wgpu adapter — a real GPU or a software fallback
7//! (e.g. Mesa lavapipe via the Vulkan backend).
8//!
9//! # Outputs
10//!
11//! Each render produces a shaded color image plus a parallel face-id buffer:
12//! every pixel carries the [`FaceId`](brepkit_topology::face::FaceId) of the
13//! face drawn there (`0` = background). Use [`RenderOutput::face_id_at`] for
14//! pixel picking.
15//!
16//! # Precision (render-relative-to-center)
17//!
18//! Geometry is tessellated in f64. To keep f32 GPU coordinates accurate even
19//! for models far from the origin, vertex positions are uploaded relative to
20//! the model's AABB center; the f64 center is folded into the camera's view
21//! matrix on the CPU.
22//!
23//! # Example
24//!
25//! ```no_run
26//! use brepkit_render::{Camera, RenderOpts, render_solid_offscreen};
27//! use brepkit_math::vec::{Point3, Vec3};
28//! use brepkit_topology::Topology;
29//!
30//! let mut topo = Topology::new();
31//! let solid = brepkit_operations::primitives::make_box(&mut topo, 20.0, 20.0, 20.0)?;
32//! let cam = Camera {
33//! eye: Point3::new(60.0, 50.0, 70.0),
34//! target: Point3::new(10.0, 10.0, 10.0),
35//! up: Vec3::new(0.0, 0.0, 1.0),
36//! fov_y: 45.0_f64.to_radians(),
37//! aspect: 1.0,
38//! near: 0.1,
39//! far: 1000.0,
40//! };
41//! let opts = RenderOpts::new(512, 512);
42//! let out = render_solid_offscreen(&topo, solid, &cam, &opts)?;
43//! out.color.save("box.png")?;
44//! # Ok::<(), Box<dyn std::error::Error>>(())
45//! ```
46
47mod camera;
48mod compute_mesh;
49mod error;
50mod mesh;
51mod pipeline;
52#[cfg(feature = "window")]
53mod viewer;
54
55pub use camera::Camera;
56pub use compute_mesh::{
57 CylinderDescriptor, DEFAULT_TARGET_PX, TessFactor, extract_cylinder_descriptor,
58 render_cylinder_compute_offscreen, render_cylinder_compute_screen_lod,
59 screen_space_tess_factor,
60};
61pub use error::RenderError;
62pub use pipeline::probe_adapter;
63#[cfg(feature = "window")]
64pub use viewer::{ViewOpts, view_solid};
65
66use brepkit_topology::Topology;
67use brepkit_topology::solid::SolidId;
68
69/// Default linear chord tolerance used when [`RenderOpts`] does not override it.
70pub const DEFAULT_DEFLECTION: f64 = 0.05;
71
72/// Options controlling an offscreen render.
73#[derive(Debug, Clone, Copy)]
74pub struct RenderOpts {
75 /// Output width in pixels (must be non-zero).
76 pub width: u32,
77 /// Output height in pixels (must be non-zero).
78 pub height: u32,
79 /// Draw topological edges as crisp dark lines over the shaded mesh.
80 pub edges: bool,
81 /// Background clear color as linear RGBA in `[0, 1]`.
82 pub background: [f32; 4],
83 /// Ambient light fraction in `[0, 1]` added to the Lambert headlight term.
84 pub ambient: f32,
85 /// Linear chord tolerance for tessellation (smaller = finer mesh).
86 pub deflection: f64,
87}
88
89impl RenderOpts {
90 /// Create options for a `width` x `height` render with sensible defaults
91 /// (edges on, light-gray background, modest ambient term).
92 #[must_use]
93 pub fn new(width: u32, height: u32) -> Self {
94 Self {
95 width,
96 height,
97 edges: true,
98 background: [0.11, 0.12, 0.14, 1.0],
99 ambient: 0.25,
100 deflection: DEFAULT_DEFLECTION,
101 }
102 }
103}
104
105/// The result of an offscreen render.
106pub struct RenderOutput {
107 /// Shaded color image (sRGB, RGBA8).
108 pub color: image::RgbaImage,
109 /// Per-pixel face id, row-major (`width * height` entries). `0` is
110 /// background; otherwise the value is `FaceId.index() + 1`.
111 pub id_buffer: Vec<u32>,
112 /// Image width in pixels.
113 pub width: u32,
114 /// Image height in pixels.
115 pub height: u32,
116}
117
118impl RenderOutput {
119 /// Face id at pixel `(x, y)`, or `None` for background or out-of-bounds.
120 ///
121 /// The returned value is `FaceId.index() + 1` (the same encoding stored in
122 /// [`RenderOutput::id_buffer`]); `0`/background maps to `None`.
123 #[must_use]
124 pub fn face_id_at(&self, x: u32, y: u32) -> Option<u32> {
125 if x >= self.width || y >= self.height {
126 return None;
127 }
128 let idx = (y * self.width + x) as usize;
129 match self.id_buffer.get(idx).copied() {
130 Some(0) | None => None,
131 Some(v) => Some(v),
132 }
133 }
134}
135
136/// Render a solid offscreen to a shaded color image and a face-id buffer.
137///
138/// Tessellates `solid`, sets up a wgpu device (trying a real GPU first, then a
139/// software fallback), rasterizes the mesh (and edges, if
140/// [`RenderOpts::edges`]) into off-screen targets, and reads them back.
141///
142/// # Errors
143///
144/// - [`RenderError::InvalidSize`] if `opts.width` or `opts.height` is zero.
145/// - [`RenderError::NoAdapter`] if no wgpu adapter (GPU or software) exists.
146/// - [`RenderError::DeviceRequest`] / [`RenderError::BufferMap`] /
147/// [`RenderError::Poll`] on GPU setup or readback failure.
148/// - [`RenderError::Operations`] if tessellating the solid fails.
149pub fn render_solid_offscreen(
150 topo: &Topology,
151 solid: SolidId,
152 cam: &Camera,
153 opts: &RenderOpts,
154) -> Result<RenderOutput, RenderError> {
155 if opts.width == 0 || opts.height == 0 {
156 return Err(RenderError::InvalidSize {
157 width: opts.width,
158 height: opts.height,
159 });
160 }
161 let render_mesh = mesh::RenderMesh::build(topo, solid, opts.deflection)?;
162 pipeline::render(&render_mesh, cam, opts)
163}