Skip to main content

geop_ops_rasterize/
lib.rs

1//! Turn a [`Model`] into a renderable [`PrimitiveScene`]: one triangle mesh
2//! (faces), line list (edges), and point list (vertices).
3//!
4//! Every face — trimmed or not — has its outer boundary (and any holes)
5//! sampled from their pcurves into `(u, v)` polygons, then triangulated by
6//! [`grid::triangulate_face`]: a curvature-sized `(u, v)` grid with every
7//! cell clipped exactly against the trim (see that module's doc comment),
8//! so a flat patch renders as a couple of triangles and a curved one gets
9//! whatever resolution its own curvature actually needs — mapped through
10//! `surface.evaluate`, the rendered mesh always both respects the face's
11//! real trim and follows its true curvature.
12
13mod clip;
14mod grid;
15pub mod polygon_triangulate;
16pub mod stl;
17mod topology_debug;
18
19use std::collections::HashMap;
20
21use geop_core_math::{
22    geop_error::{GeopError, GeopResult},
23    primitives::{Color10, PrimitiveScene, TriangleFace},
24    scalars::Scalar,
25    vector::{Vector2, Vector3},
26};
27use geop_core_topology::{EdgeId, Face, FaceId, Model, VertexId};
28
29pub use topology_debug::rasterize_topology;
30
31/// Triangulate `face`'s trimmed region in `(u, v)` space — see
32/// [`grid::triangulate_face`] for the actual (curvature-sized grid, clipped
33/// to the trim) algorithm; this just forwards to it. Kept as its own
34/// name/doc entry point since it's `pub` API several other crates
35/// (`geop-cad-base::pick`, this module's own `rasterize_model`) call by
36/// name.
37pub fn face_triangles_uv<S: Scalar>(
38    model: &Model<S>,
39    face: &Face<S>,
40    n: usize,
41) -> GeopResult<Vec<(Vector2<S>, Vector2<S>, Vector2<S>)>> {
42    grid::triangulate_face(model, face, n)
43}
44
45/// A [`Model`] rasterized once, with every sampled point/polyline/triangle
46/// kept alongside the id of the entity it came from.
47///
48/// This is the single place the crate turns topology into sampled geometry
49/// — [`PrimitiveScene`] rendering (`rasterize_model`) and ray picking
50/// (`geop_cad_base::pick`) both build on top of it, rather than each walking
51/// `Model` and sampling curves/surfaces on their own. That matters beyond
52/// not repeating code: it guarantees a pick can never disagree with what
53/// the viewer actually drew, because both read the same triangles.
54pub struct RasterizedModel<S: Scalar> {
55    pub vertices: HashMap<VertexId, Vector3<S>>,
56    /// Each edge's curve sampled into an `n`-point polyline.
57    pub edges: HashMap<EdgeId, Vec<Vector3<S>>>,
58    /// Each face's trimmed region, triangulated (see [`face_triangles_uv`])
59    /// and mapped through `surface.evaluate` — one face generally maps to
60    /// several triangles.
61    pub faces: HashMap<FaceId, Vec<TriangleFace<S>>>,
62}
63
64/// A curve is first sampled this finely, and refined from there.
65const MIN_EDGE_SEGMENTS: usize = 1;
66/// How many times an edge's sampling may double.
67const MAX_EDGE_DOUBLINGS: u32 = 10;
68
69/// `curve` as a polyline that stays within `quality`'s curvature tolerance
70/// of it: a straight edge is two points, a tight arc gets as many as it
71/// needs. Same rule as [`grid`]'s cells — the tolerance shrinks with
72/// `quality²` because a segment's deviation from the curve does too — so a
73/// model's edges and faces come out equally smooth.
74fn sample_curve<S: Scalar>(
75    curve: &geop_core_geometry::nurb_curve::NurbCurve3D<S>,
76    quality: usize,
77) -> GeopResult<Vec<Vector3<S>>> {
78    let (t0, t1) = curve.domain();
79    let at = |frac: (usize, usize)| -> GeopResult<Vector3<S>> {
80        let f = S::from_ratio(frac.0 as i64, frac.1 as i64)?;
81        curve.evaluate(t0.add(t1.sub(t0).mul(f)))
82    };
83    let points_at = |segments: usize| -> GeopResult<Vec<Vector3<S>>> {
84        (0..=segments).map(|i| at((i, segments))).collect()
85    };
86
87    let mut segments = MIN_EDGE_SEGMENTS;
88    let mut points = points_at(segments)?;
89    let f64_pt = |p: &Vector3<S>| [p[0].to_f64(), p[1].to_f64(), p[2].to_f64()];
90    let size = points
91        .iter()
92        .flat_map(|p| points.iter().map(move |q| dist3(f64_pt(p), f64_pt(q))))
93        .fold(0.0f64, f64::max)
94        .max(1e-9);
95    let tolerance = size / (4.0 * (quality * quality) as f64);
96
97    for _ in 0..MAX_EDGE_DOUBLINGS {
98        // The true point halfway along each segment, against the chord.
99        let mut worst = 0.0f64;
100        for i in 0..segments {
101            let mid = at((2 * i + 1, 2 * segments))?;
102            worst = worst.max(dist_point_to_segment(
103                f64_pt(&mid),
104                f64_pt(&points[i]),
105                f64_pt(&points[i + 1]),
106            ));
107        }
108        if worst <= tolerance {
109            break;
110        }
111        segments *= 2;
112        points = points_at(segments)?;
113    }
114    Ok(points)
115}
116
117fn dist3(a: [f64; 3], b: [f64; 3]) -> f64 {
118    let d = [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
119    (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt()
120}
121
122/// Perpendicular distance from `p` to the segment `a..b`.
123fn dist_point_to_segment(p: [f64; 3], a: [f64; 3], b: [f64; 3]) -> f64 {
124    let ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
125    let len2 = ab[0] * ab[0] + ab[1] * ab[1] + ab[2] * ab[2];
126    let t = if len2 > 1e-18 {
127        (((p[0] - a[0]) * ab[0] + (p[1] - a[1]) * ab[1] + (p[2] - a[2]) * ab[2]) / len2)
128            .clamp(0.0, 1.0)
129    } else {
130        0.0
131    };
132    dist3(p, [a[0] + ab[0] * t, a[1] + ab[1] * t, a[2] + ab[2] * t])
133}
134
135/// Sample every vertex/edge/face of `model` into a [`RasterizedModel`].
136/// `n` is a quality: how finely a curve or surface that actually curves is
137/// approximated (see [`sample_curve`] and [`grid::triangulate_face`]), not
138/// a fixed sample count — a straight edge or flat face stays cheap.
139pub fn rasterize_model_tagged<S: Scalar>(
140    model: &Model<S>,
141    n: usize,
142) -> GeopResult<RasterizedModel<S>> {
143    if n < 2 {
144        return Err(GeopError::new("rasterize_model_tagged: n must be >= 2"));
145    }
146
147    let vertices = model
148        .vertices
149        .iter()
150        .map(|(&id, vertex)| (id, vertex.point))
151        .collect();
152
153    let mut edges = HashMap::with_capacity(model.edges.len());
154    for (&id, edge) in model.edges.iter() {
155        edges.insert(id, sample_curve(&edge.curve, n)?);
156    }
157
158    let mut faces = HashMap::with_capacity(model.faces.len());
159    for (&id, face) in model.faces.iter() {
160        let mut tris = Vec::new();
161        // Grid corners are shared by up to six triangles, and a normal costs
162        // two derivative evaluations, so each `(u, v)` is evaluated once.
163        let mut normals: HashMap<[u64; 2], Option<Vector3<S>>> = HashMap::new();
164        for (uv_a, uv_b, uv_c) in face_triangles_uv(model, face, n)? {
165            let a = face.surface.evaluate(uv_a[0], uv_a[1])?;
166            let b = face.surface.evaluate(uv_b[0], uv_b[1])?;
167            let c = face.surface.evaluate(uv_c[0], uv_c[1])?;
168            let Ok(t) = TriangleFace::try_new(a, b, c) else {
169                continue;
170            };
171            // The surface's own normal at each corner, for smooth shading.
172            // A corner where it does not exist (a pole, where the two
173            // derivatives are parallel) leaves the whole triangle flat.
174            let corner = |uv: Vector2<S>, cache: &mut HashMap<[u64; 2], Option<Vector3<S>>>| {
175                let key = [uv[0].to_f64().to_bits(), uv[1].to_f64().to_bits()];
176                *cache
177                    .entry(key)
178                    .or_insert_with(|| face.surface.normal(uv[0], uv[1]).ok())
179            };
180            let (na, nb, nc) = (
181                corner(uv_a, &mut normals),
182                corner(uv_b, &mut normals),
183                corner(uv_c, &mut normals),
184            );
185            tris.push(match (na, nb, nc) {
186                (Some(na), Some(nb), Some(nc)) => t.with_vertex_normals([na, nb, nc]),
187                _ => t,
188            });
189        }
190        faces.insert(id, tris);
191    }
192
193    Ok(RasterizedModel {
194        vertices,
195        edges,
196        faces,
197    })
198}
199
200/// Rasterize `model` into a [`PrimitiveScene`]: `points` (one per vertex),
201/// `lines` (one per edge), and `triangles` (one mesh per face, `n` samples
202/// per parametric direction / edge_loop segment). Untrimmed (no-hole) faces
203/// are drawn in blue, holed faces in olive.
204pub fn rasterize_model<S: Scalar>(model: &Model<S>, n: usize) -> GeopResult<PrimitiveScene<S>> {
205    rasterize_model_impl(model, n, false, &default_face_color)
206}
207
208/// Like `rasterize_model`, but draws each face as a wireframe of its
209/// triangulation instead of filled/shaded triangles — useful when overlaying
210/// traced intersection curves on top of the faces, since a solid mesh can
211/// occlude or visually blend with the curves.
212pub fn rasterize_model_wireframe<S: Scalar>(
213    model: &Model<S>,
214    n: usize,
215) -> GeopResult<PrimitiveScene<S>> {
216    rasterize_model_impl(model, n, true, &default_face_color)
217}
218
219/// Like `rasterize_model`, but `face_color` picks each face's color
220/// directly (by `FaceId`) instead of the default blue/olive
221/// untrimmed/holed convention — e.g. coloring by which solid a face
222/// belongs to, regardless of whether that face happens to have a hole.
223pub fn rasterize_model_with_face_color<S: Scalar>(
224    model: &Model<S>,
225    n: usize,
226    face_color: impl Fn(FaceId) -> Color10,
227) -> GeopResult<PrimitiveScene<S>> {
228    rasterize_model_impl(model, n, false, &move |id, _face| face_color(id))
229}
230
231fn default_face_color<S: Scalar>(_id: FaceId, _face: &Face<S>) -> Color10 {
232    Color10::Blue
233}
234
235fn rasterize_model_impl<S: Scalar>(
236    model: &Model<S>,
237    n: usize,
238    wireframe: bool,
239    face_color: &dyn Fn(FaceId, &Face<S>) -> Color10,
240) -> GeopResult<PrimitiveScene<S>> {
241    let rasterized = rasterize_model_tagged(model, n)?;
242    let mut scene = PrimitiveScene::new();
243
244    for point in rasterized.vertices.into_values() {
245        scene.add_point(point, Color10::DarkGray);
246    }
247
248    for polyline in rasterized.edges.into_values() {
249        scene.add_polyline(&polyline, Color10::Gray);
250    }
251
252    for (face_id, tris) in rasterized.faces {
253        let face = model.get_face(face_id)?;
254        let color = face_color(face_id, face);
255        for t in tris {
256            if wireframe {
257                for (p, q) in [(t.a, t.b), (t.b, t.c), (t.c, t.a)] {
258                    if let Ok(l) = geop_core_math::primitives::Line::try_new(p, q) {
259                        scene.add_line(l, color);
260                    }
261                }
262            } else {
263                scene.add_triangle(t, color);
264            }
265        }
266    }
267
268    Ok(scene)
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274    use geop_core_math::for_all_scalars;
275    use geop_core_math::primitives::TriangleFace;
276    use geop_core_part::Part;
277    use geop_ops_extrude_revolve::{cube_solid, sphere::sphere_solid};
278
279    /// Rasterize `model`, sanity-check it's a non-empty mesh, and save it to
280    /// `outputs/<name>.html` for visual inspection.
281    fn rasterize_and_save<S: Scalar>(model: &Model<S>, name: &str) {
282        let scene = rasterize_model(model, 8).unwrap();
283        assert!(!scene.points.is_empty());
284        assert!(!scene.lines.is_empty());
285        assert!(!scene.triangles.is_empty());
286        std::fs::create_dir_all("outputs").unwrap();
287        scene.save_to_file(&format!("outputs/{name}.html")).unwrap();
288    }
289
290    fn check_rasterize_cube<S: Scalar>() {
291        let mut part = Part::<S>::new();
292        cube_solid(
293            &mut part,
294            "t1",
295            Vector3::from_array([S::ZERO; 3]),
296            Vector3::from_array([S::ONE; 3]),
297        )
298        .unwrap();
299        let model = part.topology();
300        rasterize_and_save(&model, "cube");
301    }
302    #[test]
303    fn rasterize_cube() {
304        for_all_scalars!(check_rasterize_cube);
305    }
306
307    // `tetrahedron_solid`/`figure8_profile`/`revolve` are currently
308    // unavailable (disabled/removed in `basic_shapes` during the ongoing
309    // euler-op rewrite) — re-add their rasterize tests once they're back.
310
311    /// On a unit sphere the surface normal at a point *is* that point, so a
312    /// mesh carrying the kernel's normals can be checked exactly — and a
313    /// renderer shading with them gets the sphere, not its facets.
314    fn check_sphere_triangles_carry_surface_normals<S: Scalar>() {
315        let mut part = Part::<S>::new();
316        sphere_solid(&mut part, "t3", Vector3::zero(), S::ONE).unwrap();
317        let model = part.topology();
318        let scene = rasterize_model(&model, 24).unwrap();
319        assert!(!scene.triangles.is_empty());
320        let mut with_normals = 0;
321        for (t, _) in &scene.triangles {
322            let Some(normals) = t.vertex_normals else {
323                continue;
324            };
325            with_normals += 1;
326            for (n, p) in normals.iter().zip([t.a, t.b, t.c]) {
327                // Sign follows the winding, so compare the directions.
328                let dot = n.prod_dot(&p).to_f64().abs();
329                assert!(
330                    (dot - 1.0).abs() < 1e-6,
331                    "normal {n:?} is not the sphere's own at {p:?} (|n·p| = {dot})"
332                );
333            }
334        }
335        assert!(
336            with_normals * 20 > scene.triangles.len(),
337            "only {with_normals} of {} triangles carry surface normals",
338            scene.triangles.len()
339        );
340    }
341    #[test]
342    fn sphere_triangles_carry_surface_normals() {
343        for_all_scalars!(check_sphere_triangles_carry_surface_normals);
344    }
345
346    /// A face that curves in one direction only gets triangles only where
347    /// it needs them: a cylinder wall is refined around its circumference
348    /// and left at the coarsest grid along its (straight) axis. Without
349    /// that, a long cylinder spends as many rows up its side as it does
350    /// segments around it, for nothing.
351    fn check_cylinder_wall_is_refined_only_around<S: Scalar>() {
352        let mut part = Part::<S>::new();
353        geop_ops_extrude_revolve::cylinder::revolved_cylinder(
354            &mut part,
355            "t5",
356            Vector3::zero(),
357            S::ONE,
358            S::from_f64(4.0),
359        )
360        .unwrap();
361        let model = part.topology();
362        let rasterized = rasterize_model_tagged(&model, 24).unwrap();
363        // The wall quadrants are the faces whose triangles are all off-axis.
364        let walls: Vec<&Vec<TriangleFace<S>>> = rasterized
365            .faces
366            .values()
367            .filter(|tris| {
368                !tris.is_empty()
369                    && tris.iter().all(|t| {
370                        [t.a, t.b, t.c]
371                            .iter()
372                            .all(|p| p[0].mul(p[0]).add(p[1].mul(p[1])).could_be_equal(S::ONE))
373                    })
374            })
375            .collect();
376        assert_eq!(walls.len(), 4, "a revolved cylinder has 4 wall quadrants");
377        for tris in walls {
378            // 2 rows up the axis x 32 segments around x 2 triangles is the
379            // budget; a square grid would be 16 times that.
380            assert!(
381                tris.len() <= 2 * 2 * 32,
382                "a cylinder wall quadrant came out as {} triangles",
383                tris.len()
384            );
385            assert!(tris.len() >= 2 * 2 * 8, "and it still has to look round");
386        }
387    }
388    #[test]
389    fn cylinder_wall_is_refined_only_around() {
390        for_all_scalars!(check_cylinder_wall_is_refined_only_around);
391    }
392
393    /// A straight edge needs two points; a circular one needs enough to look
394    /// round. Neither is a fixed count any more.
395    fn check_edge_sampling_follows_curvature<S: Scalar>() {
396        let mut part = Part::<S>::new();
397        cube_solid(
398            &mut part,
399            "t2",
400            Vector3::zero(),
401            Vector3::from_array([S::ONE; 3]),
402        )
403        .unwrap();
404        let cube_edges = rasterize_model_tagged(part.topology(), 24).unwrap().edges;
405        for polyline in cube_edges.values() {
406            assert_eq!(polyline.len(), 2, "a straight edge is a single segment");
407        }
408
409        let mut part = Part::<S>::new();
410        sphere_solid(&mut part, "t4", Vector3::zero(), S::ONE).unwrap();
411        let model = part.topology();
412        let sphere_edges = rasterize_model_tagged(&model, 24).unwrap().edges;
413        for polyline in sphere_edges.values() {
414            // A quarter circle of radius 1 within `1 / (4 * 24²)` of the arc
415            // needs 16 segments (its sagitta falls off with the square).
416            assert!(
417                polyline.len() >= 17,
418                "a quarter circle came out as {} points",
419                polyline.len()
420            );
421            for p in polyline {
422                let r = p[0].mul(p[0]).add(p[1].mul(p[1])).add(p[2].mul(p[2]));
423                assert!(r.could_be_equal(S::ONE), "sample off the sphere: {p:?}");
424            }
425        }
426    }
427    #[test]
428    fn edge_sampling_follows_curvature() {
429        for_all_scalars!(check_edge_sampling_follows_curvature);
430    }
431}