Skip to main content

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}