Skip to main content

geop_ops_rasterize/
topology_debug.rs

1//! A verbose, id-labelled debug rasterization of a [`Model`]'s raw topology
2//! — as opposed to [`super::rasterize_model`], which only cares about the
3//! final geometric shape. Draws every vertex, edge (with `0.1..0.9`
4//! direction-arrow markers, plus one at its midpoint), coedge (as a trim
5//! curve pulled slightly inward, mitered back at each end so neighboring
6//! coedges' trim curves don't overlap at the shared corner, with its own
7//! `0.1..0.9` direction-arrow markers and one at its midpoint) and face
8//! (semi-transparent, so everything behind it stays visible, plus a normal
9//! arrow), each labelled with its id — useful for debugging the euler
10//! operators themselves, where *which* coedge/edge is which matters.
11//!
12//! Coedges are drawn in two colours: orange (with cyan direction markers) for
13//! those on a face's outer loop, purple (with olive markers) for those on a
14//! hole. Which of the two a loop is drives every restructuring in
15//! `splice_edge_into_face` and the Euler operators, and is invisible from
16//! geometry alone — an outer loop and a hole are both just rings.
17
18use geop_core_geometry::nurb_surface::NurbSurface3D;
19use geop_core_math::{
20    geop_error::{GeopError, GeopResult, WithContext},
21    primitives::{Color10, Line, PrimitiveScene, TriangleFace},
22    scalars::Scalar,
23    vector::Vector3,
24};
25use geop_core_topology::{Coedge, CoedgeId, Model, boundary::BoundaryType};
26
27/// How far inward (in 3-D world units) a coedge's trim curve and its
28/// `0.1..0.9` markers are pulled off of the true boundary curve.
29const COEDGE_INSET: f64 = 0.02;
30
31/// Fraction of an edge's/coedge's own chord length used for its direction
32/// arrow's "wingspan".
33const ARROW_SIZE_FRACTION: f64 = 0.15 / 12.0;
34
35/// World-space length of each face's normal arrow.
36const FACE_NORMAL_LENGTH: f64 = 0.1;
37
38/// Never miter-trim more than this fraction of a coedge's own parameter
39/// range off of *each* end — so a very sharp corner shortens the visible
40/// trim curve a lot without ever inverting it.
41const MAX_TRIM_FRACTION: f64 = 0.45;
42
43const VERTEX_COLOR: Color10 = Color10::Red;
44const EDGE_COLOR: Color10 = Color10::Gray;
45const EDGE_MARKER_COLOR: Color10 = Color10::Pink;
46/// Coedges are coloured by which kind of boundary they belong to, since that
47/// is the distinction the face topology turns on and the one that is
48/// otherwise invisible in a render: a loop that bounds the material looks
49/// exactly like one that removes from it.
50const COEDGE_OUTER_COLOR: Color10 = Color10::Orange;
51const COEDGE_OUTER_MARKER_COLOR: Color10 = Color10::Cyan;
52const COEDGE_HOLE_COLOR: Color10 = Color10::Purple;
53const COEDGE_HOLE_MARKER_COLOR: Color10 = Color10::Olive;
54const FACE_COLOR: Color10 = Color10::Blue;
55const FACE_OPACITY: f64 = 0.35;
56const FACE_NORMAL_COLOR: Color10 = Color10::Green;
57
58/// Radius of the world coordinate system's axis cylinders.
59const AXIS_RADIUS: f64 = 0.01 / 3.0;
60/// Spacing, along each axis, of the small perpendicular step ticks.
61const AXIS_STEP: f64 = 0.1;
62/// Length of each step tick (centered on the axis).
63const AXIS_STEP_TICK_LENGTH: f64 = 0.03;
64const AXIS_X_COLOR: Color10 = Color10::Red;
65const AXIS_Y_COLOR: Color10 = Color10::Green;
66const AXIS_Z_COLOR: Color10 = Color10::Blue;
67
68/// `coedge`'s raw (non-normalized — its length is the local parameter
69/// speed `|dP/dt|`) 3-D tangent at `t`, via the chain rule through the
70/// pcurve's own 2-D tangent and the surface's partial derivatives.
71fn coedge_tangent_3d<S: Scalar>(
72    coedge: &Coedge<S>,
73    surface: &NurbSurface3D<S>,
74    t: S,
75) -> GeopResult<Vector3<S>> {
76    let ctx = |e: GeopError| {
77        let (t0, t1) = coedge.pcurve.domain();
78        e.with_context(format!(
79            "coedge_tangent_3d(t={t:?}): pcurve domain=({t0:?}, {t1:?}), pcurve degree={}, pcurve knot_vector={:?}, pcurve control_points={:?}",
80            coedge.pcurve.degree, coedge.pcurve.knot_vector, coedge.pcurve.control_points
81        ))
82    };
83
84    let uv = coedge.pcurve.evaluate(t).with_context(&ctx)?;
85    let (ds_du, ds_dv) = surface.derivatives(uv[0], uv[1]).with_context(&ctx)?;
86    let d_uv = coedge.pcurve.tangent(t).with_context(&ctx)?;
87    Ok(ds_du.prod_scalar(d_uv[0]).add(&ds_dv.prod_scalar(d_uv[1])))
88}
89
90/// The 3-D point `coedge`'s pcurve reaches at `t`, pulled `inset` inward
91/// (toward the face's interior) along `face_normal × tangent` — or, for a
92/// degenerate coedge (e.g. a revolve pole's own zero-length self-loop,
93/// whose tangent has no well-defined direction to inset along), just the
94/// raw, un-inset point.
95fn coedge_inset_point<S: Scalar>(
96    coedge: &Coedge<S>,
97    surface: &NurbSurface3D<S>,
98    t: S,
99    inset: S,
100) -> GeopResult<Vector3<S>> {
101    let uv = coedge.pcurve.evaluate(t)?;
102    let point = surface.evaluate(uv[0], uv[1])?;
103    let Ok(tangent) = coedge_tangent_3d(coedge, surface, t)?.normalize() else {
104        return Ok(point);
105    };
106    let Ok(normal) = surface.normal(uv[0], uv[1]) else {
107        return Ok(point);
108    };
109    let Ok(offset) = normal.prod_cross(&tangent).normalize() else {
110        return Ok(point);
111    };
112    Ok(point.add(&offset.prod_scalar(inset)))
113}
114
115/// `coedge`'s unit tangent at `t`, or `None` for a degenerate coedge whose
116/// tangent has no well-defined direction (e.g. a revolve pole's own
117/// zero-length self-loop).
118fn try_tangent<S: Scalar>(
119    coedge: &Coedge<S>,
120    surface: &NurbSurface3D<S>,
121    t: S,
122) -> GeopResult<Option<Vector3<S>>> {
123    Ok(coedge_tangent_3d(coedge, surface, t)?.normalize().ok())
124}
125
126/// How far to pull `t_this` (one end of `coedge`'s own parameter range) back
127/// from the shared corner vertex, so its inset trim curve meets `neighbor`'s
128/// (the coedge on the other side of that corner, sharing `coedge`'s loop via
129/// `next`/`prev`) roughly at the corner's angle bisector instead of
130/// overlapping it — the same "miter join" setback used for offset strokes:
131/// `inset / tan(interior_angle / 2)` along the curve, converted from arc
132/// length to a parameter delta via `coedge`'s own local speed at `t_this`.
133/// `away_this`/`away_neighbor` are each curve's unit tangent pointing *away*
134/// from the shared vertex (so `interior_angle` is the angle between them).
135/// Returns `0` (no trim) if any of the geometry needed is degenerate.
136#[allow(clippy::too_many_arguments)]
137fn miter_trim_delta_t<S: Scalar>(
138    coedge: &Coedge<S>,
139    surface: &NurbSurface3D<S>,
140    t_this: S,
141    away_this: Vector3<S>,
142    neighbor: &Coedge<S>,
143    neighbor_surface: &NurbSurface3D<S>,
144    t_neighbor: S,
145    away_neighbor: Vector3<S>,
146    inset: S,
147) -> GeopResult<S> {
148    let speed = coedge_tangent_3d(coedge, surface, t_this)?.norm();
149    if speed.could_be_equal(S::ZERO) {
150        return Ok(S::ZERO);
151    }
152    // Only used to keep `neighbor`/`neighbor_surface` meaningfully paired
153    // with `t_neighbor`/`away_neighbor` in the caller — the angle itself
154    // only needs the two (already-computed) "away" directions.
155    let _ = (neighbor, neighbor_surface, t_neighbor);
156
157    let cos_theta = away_this.prod_dot(&away_neighbor).to_f64().clamp(-1.0, 1.0);
158    let theta = cos_theta.acos();
159    let half_tan = (theta / 2.0).tan();
160    if half_tan.abs() < 1e-6 {
161        // Degenerate (near-0 interior angle, i.e. a hairpin turn) — trim
162        // heavily; the caller's `MAX_TRIM_FRACTION` clamp keeps this sane.
163        return Ok(S::from_f64(f64::MAX));
164    }
165    let setback = S::from_f64(inset.to_f64() / half_tan);
166    setback.div(speed)
167}
168
169/// Draw a small 2-segment chevron at `tip`, pointing along (unit) `dir`,
170/// with `size` "wingspan" — an arrow marking a curve's traversal direction.
171fn add_direction_arrow<S: Scalar>(
172    scene: &mut PrimitiveScene<S>,
173    tip: Vector3<S>,
174    dir: Vector3<S>,
175    size: S,
176    color: Color10,
177) -> GeopResult<()> {
178    let up = Vector3::from_array([S::ZERO, S::ZERO, S::ONE]);
179    let raw = dir.prod_cross(&up);
180    let perp = if raw.norm_sq().could_be_equal(S::ZERO) {
181        dir.prod_cross(&Vector3::from_array([S::ONE, S::ZERO, S::ZERO]))
182            .normalize()?
183    } else {
184        raw.normalize()?
185    };
186
187    let half = S::from_f64(0.5);
188    let back = tip.sub(&dir.prod_scalar(size));
189    let left = back.add(&perp.prod_scalar(size.mul(half)));
190    let right = back.sub(&perp.prod_scalar(size.mul(half)));
191
192    if let Ok(l) = Line::try_new(left, tip) {
193        scene.add_line(l, color);
194    }
195    if let Ok(l) = Line::try_new(right, tip) {
196        scene.add_line(l, color);
197    }
198    Ok(())
199}
200
201/// Draw a full arrow (shaft + head) from `base` along (unit) `dir`, `length`
202/// long — unlike [`add_direction_arrow`], which only draws the chevron head
203/// (meant to sit on top of an already-drawn curve serving as its shaft).
204fn add_arrow<S: Scalar>(
205    scene: &mut PrimitiveScene<S>,
206    base: Vector3<S>,
207    dir: Vector3<S>,
208    length: S,
209    color: Color10,
210) -> GeopResult<()> {
211    let tip = base.add(&dir.prod_scalar(length));
212    if let Ok(l) = Line::try_new(base, tip) {
213        scene.add_line(l, color);
214    }
215    add_direction_arrow(
216        scene,
217        tip,
218        dir,
219        length.mul(S::from_f64(ARROW_SIZE_FRACTION)),
220        color,
221    )
222}
223
224/// Draw a world coordinate system at the origin — X/Y/Z axes as solid
225/// `AXIS_RADIUS`-wide cylinders (red/green/blue), long enough to cover
226/// `model`'s own extent, each with a small perpendicular tick every
227/// `AXIS_STEP` units so distances are easy to read off at a glance.
228fn add_coordinate_system<S: Scalar>(
229    scene: &mut PrimitiveScene<S>,
230    model: &Model<S>,
231) -> GeopResult<()> {
232    let axis_length = model
233        .vertices
234        .values()
235        .flat_map(|v| {
236            [
237                v.point[0].to_f64().abs(),
238                v.point[1].to_f64().abs(),
239                v.point[2].to_f64().abs(),
240            ]
241        })
242        .fold(1.0_f64, f64::max);
243
244    let origin = Vector3::from_array([S::ZERO, S::ZERO, S::ZERO]);
245    // Each axis, paired with the direction its own step ticks point along
246    // (one of the other two axes, picked arbitrarily but consistently).
247    let axes: [(Vector3<S>, Vector3<S>, Color10); 3] = [
248        (
249            Vector3::from_array([S::from_f64(axis_length), S::ZERO, S::ZERO]),
250            Vector3::from_array([S::ZERO, S::ZERO, S::ONE]),
251            AXIS_X_COLOR,
252        ),
253        (
254            Vector3::from_array([S::ZERO, S::from_f64(axis_length), S::ZERO]),
255            Vector3::from_array([S::ONE, S::ZERO, S::ZERO]),
256            AXIS_Y_COLOR,
257        ),
258        (
259            Vector3::from_array([S::ZERO, S::ZERO, S::from_f64(axis_length)]),
260            Vector3::from_array([S::ZERO, S::ONE, S::ZERO]),
261            AXIS_Z_COLOR,
262        ),
263    ];
264
265    for (end, tick_dir, color) in axes {
266        scene.add_cylinder(origin, end, AXIS_RADIUS, color);
267
268        let dir = end.normalize()?;
269        let half_tick = S::from_f64(AXIS_STEP_TICK_LENGTH / 2.0);
270        let steps = (axis_length / AXIS_STEP).floor() as usize;
271        for step in 1..=steps {
272            let center = dir.prod_scalar(S::from_f64(step as f64 * AXIS_STEP));
273            let tick_start = center.sub(&tick_dir.prod_scalar(half_tick));
274            let tick_end = center.add(&tick_dir.prod_scalar(half_tick));
275            scene.add_cylinder(tick_start, tick_end, AXIS_RADIUS, color);
276        }
277    }
278    Ok(())
279}
280
281/// Rasterize `model`'s raw topology (as opposed to just its final shape —
282/// see [`super::rasterize_model`]): every vertex, edge, coedge and face,
283/// each labelled with its id, `n` samples per curve/coedge.
284pub fn rasterize_topology<S: Scalar>(model: &Model<S>, n: usize) -> GeopResult<PrimitiveScene<S>> {
285    rasterize_topology_inner(model, n)
286        .with_context(&|e: GeopError| e.with_context(format!("rasterize_topology(n={n})")))
287}
288
289fn rasterize_topology_inner<S: Scalar>(
290    model: &Model<S>,
291    n: usize,
292) -> GeopResult<PrimitiveScene<S>> {
293    let mut scene = PrimitiveScene::new();
294
295    add_coordinate_system(&mut scene, model)?;
296
297    // ── Vertices ──────────────────────────────────────────────────────────
298    for (&id, vertex) in &model.vertices {
299        scene.add_point(vertex.point, VERTEX_COLOR);
300        scene.add_label(vertex.point, format!("V{}", id.0), VERTEX_COLOR);
301    }
302
303    // ── Edges (curve + 0.1..0.9 markers + direction arrow + label) ───────
304    for (&id, edge) in &model.edges {
305        let edge_ctx = |e: GeopError| {
306            let (t0, t1) = edge.curve.domain();
307            e.with_context(format!(
308                "rasterize_topology: edge {id}, domain=({t0:?}, {t1:?}), degree={}, knot_vector={:?}",
309                edge.curve.degree, edge.curve.knot_vector
310            ))
311        };
312
313        let (t0, t1) = edge.curve.domain();
314        scene
315            .add_curve(&edge.curve, t0, t1, EDGE_COLOR, n)
316            .with_context(&edge_ctx)?;
317
318        let length = edge
319            .curve
320            .evaluate(t1)
321            .with_context(&edge_ctx)?
322            .sub(&edge.curve.evaluate(t0).with_context(&edge_ctx)?)
323            .norm();
324        let marker_size = length.mul(S::from_f64(ARROW_SIZE_FRACTION));
325        for tenth in 1..10 {
326            let frac = S::from_f64(tenth as f64 / 10.0);
327            let t = t0.add(t1.sub(t0).mul(frac));
328            let tenth_ctx =
329                |e: GeopError| e.with_context(format!("tenth={tenth}, frac={frac:?}, t={t:?}"));
330            let p = edge
331                .curve
332                .evaluate(t)
333                .with_context(&edge_ctx)
334                .with_context(&tenth_ctx)?;
335            if let Ok(dir) = edge
336                .curve
337                .tangent(t)
338                .with_context(&edge_ctx)
339                .with_context(&tenth_ctx)?
340                .normalize()
341            {
342                add_direction_arrow(&mut scene, p, dir, marker_size, EDGE_MARKER_COLOR)?;
343            }
344        }
345
346        let mid = t0.add(t1.sub(t0).mul(S::from_f64(0.5)));
347        let mid_point = edge.curve.evaluate(mid).with_context(&edge_ctx)?;
348        if let Ok(dir) = edge.curve.tangent(mid).with_context(&edge_ctx)?.normalize() {
349            add_direction_arrow(&mut scene, mid_point, dir, marker_size, EDGE_COLOR)?;
350        }
351        scene.add_label(mid_point, format!("E{}", id.0), EDGE_COLOR);
352    }
353
354    // Which coedges sit on a *hole* rather than on a face's outer loop,
355    // gathered up front: asking per coedge would re-walk its whole ring each
356    // time. Each ring walk is capped, because this renderer is deliberately
357    // pointed at models that may be broken — the sweep renders scenes whose
358    // remesh failed — and a corrupted `next` chain must not hang the render.
359    let mut hole_coedges: std::collections::HashSet<CoedgeId> = std::collections::HashSet::new();
360    let cap = model.coedges.len() + 1;
361    for face in model.faces.values() {
362        for hole in &face.holes {
363            if let BoundaryType::Loop(anchor) = hole {
364                hole_coedges.extend(model.iterate_loop_coedges(*anchor).take(cap));
365            }
366        }
367    }
368
369    // ── Coedges (mitered inset trim curve + 0.1..0.9 markers + direction
370    // arrow + label) ──────────────────────────────────────────────────────
371    for (&id, coedge) in &model.coedges {
372        let Some(face) = model.faces.get(&coedge.face) else {
373            continue;
374        };
375        let surface = &face.surface;
376
377        let (coedge_color, marker_color) = if hole_coedges.contains(&id) {
378            (COEDGE_HOLE_COLOR, COEDGE_HOLE_MARKER_COLOR)
379        } else {
380            (COEDGE_OUTER_COLOR, COEDGE_OUTER_MARKER_COLOR)
381        };
382
383        (|| -> GeopResult<()> {
384            let (t0, t1) = coedge.pcurve.domain();
385            let inset = S::from_f64(COEDGE_INSET);
386            let max_trim = t1.sub(t0).mul(S::from_f64(MAX_TRIM_FRACTION));
387
388            // Trim each end back toward the shared corner's angle bisector so
389            // this coedge's trim curve doesn't run past it and overlap the
390            // neighboring coedge's own trim curve (see `miter_trim_delta_t`).
391            let (t0_trim, t1_trim) = {
392                let neg_one = S::from_f64(-1.0);
393                let prev = model.get_coedge(coedge.prev)?;
394                let away_this_start = try_tangent(coedge, surface, t0)?;
395                let away_prev = try_tangent(prev, surface, prev.pcurve.domain().1)?
396                    .map(|t| t.prod_scalar(neg_one));
397                let delta_start = match (away_this_start, away_prev) {
398                    (Some(away_this_start), Some(away_prev)) => miter_trim_delta_t(
399                        coedge,
400                        surface,
401                        t0,
402                        away_this_start,
403                        prev,
404                        surface,
405                        prev.pcurve.domain().1,
406                        away_prev,
407                        inset,
408                    )?,
409                    // Degenerate coedge on one side of the corner (no
410                    // well-defined tangent) — nothing to miter against.
411                    _ => S::ZERO,
412                };
413
414                let next = model.get_coedge(coedge.next)?;
415                let away_this_end =
416                    try_tangent(coedge, surface, t1)?.map(|t| t.prod_scalar(neg_one));
417                let away_next = try_tangent(next, surface, next.pcurve.domain().0)?;
418                let delta_end = match (away_this_end, away_next) {
419                    (Some(away_this_end), Some(away_next)) => miter_trim_delta_t(
420                        coedge,
421                        surface,
422                        t1,
423                        away_this_end,
424                        next,
425                        surface,
426                        next.pcurve.domain().0,
427                        away_next,
428                        inset,
429                    )?,
430                    _ => S::ZERO,
431                };
432
433                let delta_start = if delta_start.definitely_greater(max_trim) {
434                    max_trim
435                } else {
436                    delta_start
437                };
438                let delta_end = if delta_end.definitely_greater(max_trim) {
439                    max_trim
440                } else {
441                    delta_end
442                };
443                (t0.add(delta_start), t1.sub(delta_end))
444            };
445
446            let mut trim_points = Vec::with_capacity(n);
447            for i in 0..n {
448                let frac = S::from_ratio(i as i64, (n - 1) as i64)?;
449                let t = t0_trim.add(t1_trim.sub(t0_trim).mul(frac));
450                trim_points.push(coedge_inset_point(coedge, surface, t, inset)?);
451            }
452            scene.add_polyline(&trim_points, coedge_color);
453
454            let length = trim_points
455                .last()
456                .unwrap()
457                .sub(trim_points.first().unwrap())
458                .norm();
459            let marker_size = length.mul(S::from_f64(ARROW_SIZE_FRACTION));
460            for tenth in 1..10 {
461                let frac = S::from_f64(tenth as f64 / 10.0);
462                let t = t0.add(t1.sub(t0).mul(frac));
463                let p = coedge_inset_point(coedge, surface, t, inset)?;
464                if let Ok(dir) = coedge_tangent_3d(coedge, surface, t)?.normalize() {
465                    add_direction_arrow(&mut scene, p, dir, marker_size, marker_color)?;
466                }
467            }
468
469            let mid_t = t0_trim.add(t1_trim.sub(t0_trim).mul(S::from_f64(0.5)));
470            let mid_point = coedge_inset_point(coedge, surface, mid_t, inset)?;
471            if let Ok(dir) = coedge_tangent_3d(coedge, surface, mid_t)?.normalize() {
472                add_direction_arrow(&mut scene, mid_point, dir, marker_size, coedge_color)?;
473            }
474            scene.add_label(mid_point, format!("C{}", id.0), coedge_color);
475            Ok(())
476        })()
477        .with_context(&|e: GeopError| {
478            let (t0, t1) = coedge.pcurve.domain();
479            e.with_context(format!(
480                "rasterize_topology: coedge {id}, pcurve domain=({t0:?}, {t1:?}), pcurve degree={}, pcurve knot_vector={:?}, pcurve control_points={:?}",
481                coedge.pcurve.degree, coedge.pcurve.knot_vector, coedge.pcurve.control_points
482            ))
483        })?;
484    }
485
486    // ── Faces (transparent fill + label) ─────────────────────────────────
487    for (&id, face) in &model.faces {
488        let has_loop = face
489            .boundaries()
490            .any(|b| matches!(b, geop_core_topology::boundary::BoundaryType::Loop(_)));
491        if !has_loop {
492            continue;
493        }
494
495        for (uv_a, uv_b, uv_c) in super::face_triangles_uv(model, face, n)? {
496            let a = face.surface.evaluate(uv_a[0], uv_a[1])?;
497            let b = face.surface.evaluate(uv_b[0], uv_b[1])?;
498            let c = face.surface.evaluate(uv_c[0], uv_c[1])?;
499            if let Ok(t) = TriangleFace::try_new(a, b, c) {
500                scene.add_triangle_transparent(t, FACE_COLOR, FACE_OPACITY);
501            }
502        }
503
504        let (u0, u1) = face.surface.domain_u();
505        let (v0, v1) = face.surface.domain_v();
506        let mid_u = u0.add(u1.sub(u0).mul(S::from_f64(0.5)));
507        let mid_v = v0.add(v1.sub(v0).mul(S::from_f64(0.5)));
508        if let Ok(label_point) = face.surface.evaluate(mid_u, mid_v) {
509            scene.add_label(label_point, format!("F{}", id.0), FACE_COLOR);
510            if let Ok(normal) = face.surface.normal(mid_u, mid_v) {
511                add_arrow(
512                    &mut scene,
513                    label_point,
514                    normal,
515                    S::from_f64(FACE_NORMAL_LENGTH),
516                    FACE_NORMAL_COLOR,
517                )?;
518            }
519        }
520    }
521
522    Ok(scene)
523}