Skip to main content

brep_render/
thumbnail.rs

1//! Part thumbnails: a small CPU rasterizer over the [`RenderScene`]'s display
2//! meshes, the one picture producer that runs the same on native, in the
3//! browser and in the headless bake worker (which has no GPU device at all).
4//!
5//! The picture is the ViewCube's ISO view (from +X+Y+Z, +Y up), orthographic,
6//! fit to what is drawn, square, on a transparent background: Lambert shading
7//! under a fixed light, a depth buffer, each body's viewport colour (a face's
8//! colour, else its solid's, else the name-hashed colour), and the display
9//! edges drawn over it. It is supersampled `SUPERSAMPLE`× per axis and
10//! averaged down, so silhouettes are anti-aliased.
11//!
12//! [`capture`] copies what the picture needs out of the scene (cheap, on the
13//! caller's thread); [`render_png`] does the rest and is `Send`-safe, so a
14//! native host can run it on a thread of its own. The PNG carries no text or
15//! time chunks: the same scene gives the same bytes.
16
17use crate::color::solid_color_srgb;
18use crate::scene::RenderScene;
19
20pub mod line_art;
21
22/// The renderer's version, stored beside each thumbnail on the server. Bump it
23/// whenever the picture for the same document would change, so a server can
24/// tell a thumbnail made by an older renderer.
25pub const RENDERER: &str = "brep-thumb/1";
26
27/// The default thumbnail edge, in pixels.
28pub const SIZE: u32 = 256;
29
30/// Samples per pixel per axis.
31const SUPERSAMPLE: u32 = 2;
32
33/// The fraction of the picture the model's projected extent fills.
34const FILL: f64 = 0.9;
35
36/// Edge colour (sRGB 0..1).
37const EDGE: [f32; 3] = [0.10, 0.11, 0.13];
38
39/// One body's triangles, ready to draw.
40#[derive(Debug, Clone, Default)]
41pub struct Body {
42    pub positions: Vec<[f32; 3]>,
43    pub normals: Vec<[f32; 3]>,
44    pub indices: Vec<u32>,
45    /// sRGB colour per triangle.
46    pub colors: Vec<[f32; 3]>,
47    /// World polylines of the body's display edges.
48    pub edges: Vec<Vec<[f32; 3]>>,
49}
50
51/// What [`render_png`] draws: an owned copy of the visible scene.
52#[derive(Debug, Clone, Default)]
53pub struct Capture {
54    pub bodies: Vec<Body>,
55}
56
57impl Capture {
58    pub fn triangles(&self) -> usize {
59        self.bodies.iter().map(|b| b.indices.len() / 3).sum()
60    }
61
62    pub fn is_empty(&self) -> bool {
63        self.triangles() == 0
64    }
65}
66
67/// Copy the visible bodies out of `scene`: every visible solid and committed
68/// sketch sheet, hidden faces left out, in the viewport's colours.
69pub fn capture(scene: &RenderScene) -> Capture {
70    Capture { bodies: scene.solids().iter().filter(|s| s.visible).filter_map(body_of).collect() }
71}
72
73/// One display's body as [`capture`] copies it, whatever its visibility:
74/// hidden faces left out, in the viewport's colours. `None` when no face is
75/// left to draw.
76pub fn body_of(solid: &crate::scene::SolidDisplay) -> Option<Body> {
77    let mesh = &solid.mesh;
78    let base = solid.color_override.unwrap_or_else(|| solid_color_srgb(&solid.name).map(|c| c as f32));
79    let mut body = Body { positions: mesh.positions.clone(), normals: mesh.normals.clone(), ..Body::default() };
80    for (tri, idx) in mesh.indices.chunks_exact(3).enumerate() {
81        let face_index = mesh.face_ids.get(tri).map(|&f| f as usize);
82        if face_index.is_some_and(|f| !solid.visibility.is_face_visible(f)) {
83            continue;
84        }
85        let face = face_index.and_then(|f| solid.faces.get(f));
86        body.indices.extend_from_slice(idx);
87        body.colors.push(face.and_then(|f| f.color_override).unwrap_or(base));
88    }
89    body.edges = solid
90        .edges
91        .iter()
92        .enumerate()
93        .filter(|(i, e)| !e.centerline && solid.visibility.is_edge_visible(*i))
94        .map(|(_, e)| e.polyline.clone())
95        .collect();
96    (!body.indices.is_empty()).then_some(body)
97}
98
99/// A view's orthonormal basis: right, up, and the direction from the model
100/// toward the eye.
101pub type Basis = ([f64; 3], [f64; 3], [f64; 3]);
102
103/// The ISO view's orthonormal basis: right, up, and the direction from the
104/// model toward the eye.
105fn iso_basis() -> Basis {
106    let k = 1.0 / 3f64.sqrt();
107    let back = [k, k, k];
108    let up_world = [0.0, 1.0, 0.0];
109    let right = normalize(cross(up_world, back));
110    let up = cross(back, right);
111    (right, up, back)
112}
113
114use crate::geometry3d::cross3 as cross;
115
116use crate::geometry3d::dot3 as dot;
117
118fn normalize(v: [f64; 3]) -> [f64; 3] {
119    let n = dot(v, v).sqrt();
120    if n > 0.0 { [v[0] / n, v[1] / n, v[2] / n] } else { v }
121}
122
123fn f64s(p: [f32; 3]) -> [f64; 3] {
124    [p[0] as f64, p[1] as f64, p[2] as f64]
125}
126
127/// World → the supersampled raster: `(x, y, depth)`, x right, y down, depth
128/// growing away from the eye.
129struct Projection {
130    right: [f64; 3],
131    up: [f64; 3],
132    back: [f64; 3],
133    center: [f64; 3],
134    scale: f64,
135    half: f64,
136}
137
138impl Projection {
139    fn fit(capture: &Capture, pixels: u32, basis: Basis) -> Self {
140        // Fit the projected extent of every vertex, not the bbox's corners, so
141        // a long thin part fills the picture.
142        let points = capture.bodies.iter().flat_map(|body| body.indices.iter().map(|&i| f64s(body.positions[i as usize])));
143        Self::fit_points(points, pixels, basis, FILL)
144    }
145
146    /// Fit `points` so their projected extent fills `fill` of the picture.
147    fn fit_points(points: impl Iterator<Item = [f64; 3]>, pixels: u32, (right, up, back): Basis, fill: f64) -> Self {
148        let (mut lo, mut hi) = ([f64::INFINITY; 2], [f64::NEG_INFINITY; 2]);
149        for p in points {
150            let (x, y) = (dot(p, right), dot(p, up));
151            lo = [lo[0].min(x), lo[1].min(y)];
152            hi = [hi[0].max(x), hi[1].max(y)];
153        }
154        if !(lo[0] <= hi[0]) {
155            lo = [0.0; 2];
156            hi = [0.0; 2];
157        }
158        let extent = (hi[0] - lo[0]).max(hi[1] - lo[1]).max(1e-12);
159        let mid = [(lo[0] + hi[0]) * 0.5, (lo[1] + hi[1]) * 0.5];
160        let center = [
161            right[0] * mid[0] + up[0] * mid[1],
162            right[1] * mid[0] + up[1] * mid[1],
163            right[2] * mid[0] + up[2] * mid[1],
164        ];
165        Self { right, up, back, center, scale: pixels as f64 * fill / extent, half: pixels as f64 * 0.5 }
166    }
167
168    fn project(&self, p: [f64; 3]) -> [f64; 3] {
169        let d = [p[0] - self.center[0], p[1] - self.center[1], p[2] - self.center[2]];
170        [self.half + dot(d, self.right) * self.scale, self.half - dot(d, self.up) * self.scale, -dot(d, self.back) * self.scale]
171    }
172}
173
174/// Render `capture` as a `size`×`size` RGBA PNG. `None` when nothing is drawn
175/// (an empty document gets no thumbnail; consumers show their placeholder).
176pub fn render_png(capture: &Capture, size: u32) -> Option<Vec<u8>> {
177    let rgba = render_rgba(capture, size)?;
178    encode_png(&rgba, size, size).ok()
179}
180
181/// [`render_png`] from another direction: `basis` (right, up, toward the
182/// eye) in place of the ISO view's, fit and lit the same way.
183pub fn render_png_from(capture: &Capture, size: u32, basis: Basis) -> Option<Vec<u8>> {
184    let rgba = render_rgba_from(capture, size, basis)?;
185    encode_png(&rgba, size, size).ok()
186}
187
188/// [`render_png`]'s pixels, before encoding: `size`² RGBA, row-major.
189pub fn render_rgba(capture: &Capture, size: u32) -> Option<Vec<u8>> {
190    render_rgba_from(capture, size, iso_basis())
191}
192
193/// [`render_png_from`]'s pixels, before encoding.
194pub fn render_rgba_from(capture: &Capture, size: u32, basis: Basis) -> Option<Vec<u8>> {
195    if capture.is_empty() || size == 0 {
196        return None;
197    }
198    let n = size * SUPERSAMPLE;
199    let w = n as usize;
200    let projection = Projection::fit(capture, n, basis);
201    let mut depth = vec![f32::INFINITY; w * w];
202    let mut color = vec![[0f32; 4]; w * w];
203    // The key light, from the upper left of the view and toward the viewer; a
204    // fill from the opposite side keeps faces turned away from it readable.
205    let (right, up, back) = (projection.right, projection.up, projection.back);
206    let key = normalize([
207        -0.45 * right[0] + 0.6 * up[0] + 0.66 * back[0],
208        -0.45 * right[1] + 0.6 * up[1] + 0.66 * back[1],
209        -0.45 * right[2] + 0.6 * up[2] + 0.66 * back[2],
210    ]);
211    for body in &capture.bodies {
212        let screen: Vec<[f64; 3]> = body.positions.iter().map(|&p| projection.project(f64s(p))).collect();
213        for (tri, idx) in body.indices.chunks_exact(3).enumerate() {
214            let [a, b, c] = [idx[0] as usize, idx[1] as usize, idx[2] as usize];
215            let (pa, pb, pc) = (screen[a], screen[b], screen[c]);
216            let normal_at = |i: usize| -> [f64; 3] {
217                match body.normals.get(i) {
218                    Some(&n) if n != [0.0; 3] => normalize(f64s(n)),
219                    _ => {
220                        let (wa, wb, wc) = (f64s(body.positions[a]), f64s(body.positions[b]), f64s(body.positions[c]));
221                        normalize(cross([wb[0] - wa[0], wb[1] - wa[1], wb[2] - wa[2]], [wc[0] - wa[0], wc[1] - wa[1], wc[2] - wa[2]]))
222                    }
223                }
224            };
225            let shade = |n: [f64; 3]| -> f32 {
226                // Two-sided: a normal facing away from the eye is flipped.
227                let n = if dot(n, back) < 0.0 { [-n[0], -n[1], -n[2]] } else { n };
228                (0.34 + 0.56 * dot(n, key).max(0.0) + 0.10 * dot(n, back).max(0.0)) as f32
229            };
230            let (sa, sb, sc) = (shade(normal_at(a)), shade(normal_at(b)), shade(normal_at(c)));
231            let rgb = body.colors[tri];
232            fill_triangle(&mut depth, &mut color, w, [pa, pb, pc], [sa, sb, sc], rgb);
233        }
234    }
235    // Edges over the faces, depth-tested with a bias of a couple of samples so
236    // an edge lying on its face wins.
237    let bias = 2.5f32;
238    for body in &capture.bodies {
239        for line in &body.edges {
240            for seg in line.windows(2) {
241                let (p, q) = (projection.project(f64s(seg[0])), projection.project(f64s(seg[1])));
242                draw_segment(&mut depth, &mut color, w, p, q, bias);
243            }
244        }
245    }
246    // Box-filter down to the output size.
247    let s = SUPERSAMPLE as usize;
248    let out_w = size as usize;
249    let mut out = vec![0u8; out_w * out_w * 4];
250    let inv = 1.0 / (s * s) as f32;
251    for y in 0..out_w {
252        for x in 0..out_w {
253            let mut acc = [0f32; 4];
254            for dy in 0..s {
255                for dx in 0..s {
256                    let c = color[(y * s + dy) * w + x * s + dx];
257                    // Premultiplied accumulation.
258                    for k in 0..3 {
259                        acc[k] += c[k] * c[3];
260                    }
261                    acc[3] += c[3];
262                }
263            }
264            let alpha = acc[3] * inv;
265            let o = (y * out_w + x) * 4;
266            if acc[3] > 0.0 {
267                for k in 0..3 {
268                    out[o + k] = ((acc[k] / acc[3]).clamp(0.0, 1.0) * 255.0).round() as u8;
269                }
270            }
271            out[o + 3] = (alpha.clamp(0.0, 1.0) * 255.0).round() as u8;
272        }
273    }
274    Some(out)
275}
276
277/// Fill one screen triangle with Gouraud-interpolated shade, depth-tested.
278fn fill_triangle(depth: &mut [f32], color: &mut [[f32; 4]], w: usize, p: [[f64; 3]; 3], shade: [f32; 3], rgb: [f32; 3]) {
279    let area = (p[1][0] - p[0][0]) * (p[2][1] - p[0][1]) - (p[2][0] - p[0][0]) * (p[1][1] - p[0][1]);
280    if area.abs() < 1e-12 {
281        return;
282    }
283    let min_x = p.iter().map(|q| q[0]).fold(f64::INFINITY, f64::min).floor().max(0.0) as usize;
284    let max_x = (p.iter().map(|q| q[0]).fold(f64::NEG_INFINITY, f64::max).ceil() as i64).min(w as i64 - 1);
285    let min_y = p.iter().map(|q| q[1]).fold(f64::INFINITY, f64::min).floor().max(0.0) as usize;
286    let max_y = (p.iter().map(|q| q[1]).fold(f64::NEG_INFINITY, f64::max).ceil() as i64).min(w as i64 - 1);
287    if max_x < 0 || max_y < 0 {
288        return;
289    }
290    let inv_area = 1.0 / area;
291    for y in min_y..=max_y as usize {
292        let cy = y as f64 + 0.5;
293        for x in min_x..=max_x as usize {
294            let cx = x as f64 + 0.5;
295            let w0 = ((p[1][0] - cx) * (p[2][1] - cy) - (p[2][0] - cx) * (p[1][1] - cy)) * inv_area;
296            let w1 = ((p[2][0] - cx) * (p[0][1] - cy) - (p[0][0] - cx) * (p[2][1] - cy)) * inv_area;
297            let w2 = 1.0 - w0 - w1;
298            if w0 < 0.0 || w1 < 0.0 || w2 < 0.0 {
299                continue;
300            }
301            let z = (w0 * p[0][2] + w1 * p[1][2] + w2 * p[2][2]) as f32;
302            let i = y * w + x;
303            if z >= depth[i] {
304                continue;
305            }
306            depth[i] = z;
307            let s = w0 as f32 * shade[0] + w1 as f32 * shade[1] + w2 as f32 * shade[2];
308            color[i] = [rgb[0] * s, rgb[1] * s, rgb[2] * s, 1.0];
309        }
310    }
311}
312
313/// Draw one edge segment, about 1.5 output pixels wide, where it is not hidden.
314fn draw_segment(depth: &mut [f32], color: &mut [[f32; 4]], w: usize, p: [f64; 3], q: [f64; 3], bias: f32) {
315    let steps = ((q[0] - p[0]).abs().max((q[1] - p[1]).abs()).ceil() as usize).max(1);
316    for i in 0..=steps {
317        let t = i as f64 / steps as f64;
318        let (x, y, z) = (p[0] + (q[0] - p[0]) * t, p[1] + (q[1] - p[1]) * t, (p[2] + (q[2] - p[2]) * t) as f32);
319        for (dx, dy) in [(0i64, 0i64), (1, 0), (0, 1), (1, 1)] {
320            let (px, py) = (x.floor() as i64 + dx, y.floor() as i64 + dy);
321            if px < 0 || py < 0 || px >= w as i64 || py >= w as i64 {
322                continue;
323            }
324            let k = py as usize * w + px as usize;
325            if z - bias <= depth[k] {
326                color[k] = [EDGE[0], EDGE[1], EDGE[2], 1.0];
327            }
328        }
329    }
330}
331
332/// 8-bit RGBA PNG, no ancillary chunks.
333fn encode_png(rgba: &[u8], width: u32, height: u32) -> Result<Vec<u8>, String> {
334    brep_gizmos::raster::encode_png(rgba, width, height, png::ColorType::Rgba)
335}
336
337/// Portable preview carried by native CAD JSON. The image has no external URL.
338pub fn embedded_png(png: &[u8], size: u32, status: &str) -> serde_json::Value {
339    use base64::Engine;
340    serde_json::json!({
341        "mimeType": "image/png", "data": base64::engine::general_purpose::STANDARD.encode(png),
342        "width": size, "height": size, "renderer": RENDERER, "status": status,
343    })
344}
345
346/// Empty documents and edits still rebuilding have an honest empty preview.
347pub fn empty_embedded(status: &str) -> serde_json::Value {
348    static EMPTY: std::sync::OnceLock<Vec<u8>> = std::sync::OnceLock::new();
349    let png = EMPTY.get_or_init(|| encode_png(&vec![0; (SIZE * SIZE * 4) as usize], SIZE, SIZE)
350        .expect("a fixed-size in-memory PNG can be encoded"));
351    embedded_png(png, SIZE, status)
352}
353