Skip to main content

geop_core_rasterize/debug/
topology.rs

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