Source code for physicsnemo.mesh.tessellation.delaunay

# SPDX-FileCopyrightText: Copyright (c) 2023 - 2026 NVIDIA CORPORATION & AFFILIATES.
# SPDX-FileCopyrightText: All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

r"""Planar quality mesh generation: constrained Delaunay + Ruppert refinement.

The public entry point :func:`delaunay_mesh_2d` meshes a polygonal domain given as
closed loops (one outer boundary plus optional hole loops) into a quality
triangle mesh: a conforming constrained Delaunay triangulation refined until no
triangle is smaller than a minimum-angle bound or larger than a maximum-area
bound, and optionally smoothed. :func:`polygon_interior_point` is a companion
utility that returns a point strictly inside a simple polygon.

This is a from-scratch implementation of the published algorithms:

- Incremental Delaunay insertion with a bounding super-triangle follows
  A. Bowyer, "Computing Dirichlet tessellations", *The Computer Journal* 24(2),
  1981, and D. F. Watson, "Computing the n-dimensional Delaunay tessellation
  with application to Voronoi polytopes", *The Computer Journal* 24(2), 1981
  (the "Bowyer-Watson" cavity algorithm).
- Constrained-edge recovery flips the edges crossing each input segment until
  the segment appears, per S. W. Sloan, "A fast algorithm for generating
  constrained Delaunay triangulations", *Computers & Structures* 47(3), 1993;
  local Delaunayhood is then restored by Lawson's flip criterion
  (C. L. Lawson, "Software for C1 surface interpolation", in *Mathematical
  Software III*, 1977). Constrained edges are never flipped afterwards.
- Quality refinement is J. Ruppert's Delaunay-refinement algorithm, "A Delaunay
  refinement algorithm for quality 2-dimensional mesh generation", *Journal of
  Algorithms* 18(3), 1995: encroached boundary subsegments are bisected at
  their midpoints, and skinny or oversized triangles are fixed by inserting
  their circumcenters -- deferring to subsegment splits whenever a circumcenter
  would encroach upon one.
- Exterior and hole removal is the even-odd parity flood fill over the dual
  graph used by CGAL's ``mark_domain_in_triangulation``: the recovered
  constrained segments tile the closed input loops exactly, so the parity of
  the number of constrained edges crossed on any path from the unbounded
  exterior classifies every triangle, with no interior seed points and no
  geometric predicates.
- Optional smoothing is the optimal-Delaunay-triangulation vertex update of
  L. Chen and J.-c. Xu, "Optimal Delaunay triangulations", *Journal of
  Computational Mathematics* 22(2), 2004 (popularized as "ODT smoothing" by
  L. Chen, "Mesh smoothing schemes based on optimal Delaunay triangulations",
  *13th International Meshing Roundtable*, 2004): each interior vertex moves
  to the area-weighted average of its incident triangles' circumcenters,
  gated so the local minimum angle never decreases, followed by a Lawson
  re-legalization pass -- so the refinement quality bounds survive smoothing.

Robustness model (documented rather than hidden): all geometry is computed in
float64 on coordinates normalized once into the unit box, so the ``orient2d``
and ``incircle`` determinants run at a uniform, well-understood precision.
Sign tests are strict (ties count as "not inside" / "not crossing"), and cavity
retriangulation carries an explicit star-shapedness repair, so near-degenerate
(collinear / cocircular) inputs yield a valid -- if not bitwise-unique --
triangulation instead of a corrupted one. Everything is deterministic: queues
are FIFO, iteration orders are index orders, and there is no randomness, so
identical inputs produce bitwise-identical outputs across runs.

Ruppert's termination guarantee holds for minimum-angle bounds up to
:math:`\arcsin(1/2\sqrt{2}) \approx 20.7^\circ` in theory and to roughly
:math:`33^\circ` in practice (Ruppert 1995, section 5), assuming input segments
meet at angles of at least :math:`60^\circ` -- true of the polygonalized smooth
boundaries this mesher targets, whose adjacent segments turn by only a few
degrees. ``min_angle_degrees`` is therefore capped at 33.

The implementation is generation-time CPU code: internals are pure Python +
NumPy over flat arrays, with torch tensors only at the API boundary (matching
the conventions of :func:`physicsnemo.mesh.tessellation.triangulate`).
"""

from __future__ import annotations

import math
from collections import deque
from collections.abc import Sequence

import numpy as np
import torch
from jaxtyping import Float, Int

#: Half-extent of the bounding super-triangle in normalized (unit-box)
#: coordinates. Large enough that the super-vertices essentially never fall
#: inside the circumcircle of a well-shaped interior triangle (so the interior
#: is genuinely Delaunay), small enough that determinant entries stay ~1e6 and
#: float64 keeps ~10 significant digits in every predicate involving them; a
#: final Lawson pass cleans up any hull-adjacent tie the compromise loses.
_SUPER_HALF_EXTENT: float = 1024.0

#: Iteration caps that convert would-be infinite loops (possible only under
#: broken invariants or inputs violating the documented assumptions) into
#: informative errors. Walk/pipe caps scale with mesh size at the call sites.
_MAX_CAVITY_REPAIR_ROUNDS: int = 64
_MAX_LEGALIZE_PASSES: int = 64

#: Relative threshold on the incircle determinant for Lawson legalization
#: flips: an edge is flipped only when the violation exceeds this fraction of
#: the determinant's own term magnitudes. Exactly-cocircular quads (ubiquitous
#: in symmetric inputs) evaluate to pure roundoff (~1e-16 relative) with a
#: sign that changes when the flip changes the operand order, so a strict
#: zero-threshold flip criterion cycles forever; this margin (~1000x roundoff,
#: yet far below any genuine violation) makes ties stable no-ops.
_INCIRCLE_REL_EPS: float = 1e-13


def delaunay_mesh_2d(
    loops: Sequence[Float[torch.Tensor, "n_i 2"] | np.ndarray],
    *,
    max_area: float | None = None,
    min_angle_degrees: float = 30.0,
    smooth_iterations: int = 0,
) -> tuple[
    Float[torch.Tensor, "n_points 2"],
    Int[torch.Tensor, "n_triangles 3"],
    Int[torch.Tensor, " n_points"],
    Int[torch.Tensor, " n_segments"],
]:
    r"""Quality-mesh a polygonal domain (outer loop plus optional holes).

    Builds the constrained Delaunay triangulation of the input loops
    (Bowyer-Watson insertion + Sloan segment recovery), removes the exterior
    and the holes by even-odd parity flood fill across the recovered
    constrained segments, then applies Ruppert's Delaunay refinement until
    every triangle has minimum angle at least ``min_angle_degrees`` and (if
    given) area at most ``max_area``, optionally followed by
    ``smooth_iterations`` passes of quality-gated ODT smoothing.

    Parameters
    ----------
    loops : sequence of torch.Tensor or numpy.ndarray
        Closed polylines of shape :math:`(N_i, 2)`, :math:`N_i \geq 3`. The
        first loop is the outer boundary; every further loop bounds a hole.
        Loops are closed implicitly (do not repeat the first vertex), must be
        simple, mutually disjoint, and free of duplicate vertices; either
        winding is accepted. Segments of distinct loops must meet at angles of
        at least ~60 degrees for the refinement termination guarantee (any
        polygonalization of smooth curves qualifies).
    max_area : float, optional
        Maximum triangle area. ``None`` (default) disables the area bound.
        For a target interior edge length :math:`h`, pass the equilateral
        area :math:`\sqrt{3}/4 \, h^2`.
    min_angle_degrees : float, default 30.0
        Minimum-angle quality bound in degrees, in :math:`[0, 33]`. ``0``
        disables the angle criterion. Values above 33 are rejected because
        Ruppert refinement is no longer guaranteed to terminate there.
    smooth_iterations : int, default 0
        Number of ODT smoothing passes applied after refinement (``0``
        disables smoothing). Each pass moves every interior (Steiner) vertex
        to the area-weighted average of its incident triangles' circumcenters
        -- the optimal-Delaunay-triangulation update of Chen and Xu -- accepts
        the move only if the smallest angle among those triangles does not
        decrease and no triangle grows beyond ``max_area``, and then restores
        Delaunayhood by Lawson legalization. Boundary vertices never move, so
        the first return value's leading rows stay bit-identical to the
        input. The quality bounds above are preserved exactly: a final
        refinement pass re-splits the rare over-bound triangle a legalization
        flip can produce, so smoothing may add a few Steiner vertices. The
        *typical* angle improves markedly (interiors approach the hexagonal
        ideal); 2 to 5 passes capture most of the benefit.

    Returns
    -------
    points : torch.Tensor
        Vertex coordinates, shape :math:`(N_\text{points}, 2)`, float64. The
        first ``sum(N_i)`` rows are the input loop vertices, bit-identical and
        in input order; refinement (Steiner) vertices follow.
    triangles : torch.Tensor
        Triangle connectivity, shape :math:`(N_\text{triangles}, 3)`, int64,
        counterclockwise (positive signed area).
    vertex_markers : torch.Tensor
        Shape :math:`(N_\text{points},)`, int64: ``1`` for vertices on the
        input boundary polyline (original loop vertices and midpoints inserted
        on segments), ``0`` for interior Steiner vertices.
    boundary_segments : torch.Tensor
        The final constrained subsegments as vertex-index pairs, shape
        :math:`(N_\text{segments}, 2)`, int64. Their union is exactly the
        input polyline: every input segment appears as a chain of these
        subsegments, and every row lies on some input segment. Each row is an
        edge of ``triangles``, directed with the domain interior on its left.

    Raises
    ------
    ValueError
        If ``loops`` is empty, a loop has fewer than 3 vertices or a
        non-``(N, 2)`` shape, coordinates are non-finite, vertices are
        duplicated, all points are coincident, a hole loop is not strictly
        inside the outer boundary loop, a hole loop lies inside another hole,
        ``max_area`` is non-positive, ``min_angle_degrees`` is outside
        :math:`[0, 33]`, ``smooth_iterations`` is negative, or input segments
        cross each other.
    RuntimeError
        If refinement exceeds its insertion budget or an internal geometric
        invariant fails -- both indicate inputs outside the documented
        assumptions (e.g. nearly-touching loops).

    Notes
    -----
    Deterministic: identical inputs give bitwise-identical outputs across
    runs. There is no randomness; insertion and refinement queues are FIFO
    and iterate in index order.

    Examples
    --------
    >>> import torch
    >>> from physicsnemo.mesh.tessellation.delaunay import delaunay_mesh_2d
    >>> square = torch.tensor([[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]])
    >>> points, triangles, markers, segments = delaunay_mesh_2d(
    ...     [square], max_area=0.1, min_angle_degrees=30.0
    ... )
    >>> bool((markers[:4] == 1).all())  # input vertices are boundary-marked
    True
    >>> from math import degrees, acos
    >>> p = points[triangles]  # all angles >= 30 degrees
    >>> e = [p[:, (i + 1) % 3] - p[:, i] for i in range(3)]
    >>> min_cos = max(
    ...     float(
    ...         (
    ...             (e[i] * -e[(i + 2) % 3]).sum(-1)
    ...             / (e[i].norm(dim=-1) * e[(i + 2) % 3].norm(dim=-1))
    ...         ).max()
    ...     )
    ...     for i in range(3)
    ... )
    >>> degrees(acos(min_cos)) >= 30.0 - 1e-9
    True
    """
    loop_arrays = _validate_loops(loops)
    if max_area is not None and not (math.isfinite(max_area) and max_area > 0.0):
        raise ValueError(f"max_area must be finite and positive, got {max_area}")
    if not (math.isfinite(min_angle_degrees) and 0.0 <= min_angle_degrees <= 33.0):
        raise ValueError(
            f"min_angle_degrees must lie in [0, 33], got {min_angle_degrees}. "
            f"Ruppert refinement is not guaranteed to terminate above 33 degrees."
        )
    if smooth_iterations < 0:
        raise ValueError(f"smooth_iterations must be >= 0, got {smooth_iterations}")

    all_points = np.concatenate(loop_arrays, axis=0)
    seen: dict[tuple[float, float], int] = {}
    for index, (x, y) in enumerate(all_points.tolist()):
        if (x, y) in seen:
            raise ValueError(
                f"duplicate vertex at ({x}, {y}): loops must not repeat points "
                f"(vertex {index} coincides with vertex {seen[(x, y)]})"
            )
        seen[(x, y)] = index

    # Containment validation (after the duplicate check, so exactly-shared
    # vertices get the clearer error): loops must not cross -- recovery
    # checks that exactly -- so one misplaced vertex condemns its whole loop.
    for index, hole in enumerate(loop_arrays[1:], start=1):
        if not _points_in_polygon(hole, loop_arrays[0]).all():
            raise ValueError(
                f"loop {index} is not inside the outer boundary loop (loop 0); "
                f"every loop after the first must bound a hole in the domain"
            )
        for other_index, other in enumerate(loop_arrays[1:], start=1):
            if other_index != index and _points_in_polygon(hole[:1], other).any():
                raise ValueError(
                    f"hole loop {index} lies inside hole loop {other_index}; "
                    f"hole loops must be mutually disjoint"
                )

    if min_angle_degrees > 0.0:
        # Ruppert's termination guarantee assumes adjacent input segments
        # meet at >= ~60 degrees; sharper corners make refinement chase
        # itself into the corner, emitting thousands of sub-float32-area
        # triangles near the apex with no error (found in review by
        # melo-gonzo). Validate rather than assume.
        for li, arr in enumerate(loop_arrays):
            prev = np.roll(arr, 1, axis=0) - arr
            nxt = np.roll(arr, -1, axis=0) - arr
            cosang = (prev * nxt).sum(axis=1) / (
                np.linalg.norm(prev, axis=1) * np.linalg.norm(nxt, axis=1)
            )
            ang = np.degrees(np.arccos(np.clip(cosang, -1.0, 1.0)))
            k = int(ang.argmin())
            if ang[k] < 60.0 - 1e-9:
                raise ValueError(
                    f"loop {li} has adjacent segments meeting at "
                    f"{ang[k]:.1f} degrees (vertex {k} at "
                    f"{arr[k].tolist()}); the min-angle refinement "
                    f"guarantee requires input corners of at least ~60 "
                    f"degrees. Pass min_angle_degrees=0.0 for an "
                    f"unrefined constrained triangulation, or blunt the "
                    f"corner geometrically."
                )

    # Normalize into the unit box: every predicate then runs on ~unit-scale
    # float64 operands regardless of the physical coordinate range.
    lower = all_points.min(axis=0)
    scale = float((all_points.max(axis=0) - lower).max())
    if scale <= 0.0:
        raise ValueError("all loop vertices are collinear along an axis or coincident")
    normalized = (all_points - lower) / scale

    tri = _Triangulation()
    tri.insert_points(normalized)

    # Constrained-edge recovery: consecutive loop vertices, loop by loop.
    segments: list[tuple[int, int]] = []
    offset = 0
    for loop in loop_arrays:
        n = loop.shape[0]
        segments.extend((offset + i, offset + (i + 1) % n) for i in range(n))
        offset += n
    for a, b in segments:
        tri.recover_segment(3 + a, 3 + b)  # +3 skips the super-vertices
    tri.legalize_all()

    tri.remove_exterior_and_holes()

    max_area_normalized = None if max_area is None else max_area / (scale * scale)
    sin_min = math.sin(math.radians(min_angle_degrees))
    b_squared = None if min_angle_degrees <= 0.0 else 1.0 / (4.0 * sin_min * sin_min)
    budget = all_points.shape[0] + 100_000
    if max_area_normalized is not None:
        # The unit box bounds the domain area; x16 covers boundary grading.
        budget += int(16.0 / max_area_normalized)
    refine_requested = max_area is not None or min_angle_degrees > 0.0

    if refine_requested:
        tri.refine(max_area_normalized, b_squared, budget)

    if smooth_iterations > 0:
        tri.smooth(smooth_iterations, max_area_normalized)
        if refine_requested:
            # Legalization flips during smoothing can, rarely, merge two
            # near-bound triangles into one over-bound triangle; a final
            # refinement pass restores both bounds exactly (usually a no-op).
            tri.refine(max_area_normalized, b_squared, budget)

    points, triangles, markers, boundary_segments = tri.extract(
        n_input=all_points.shape[0], original=all_points, lower=lower, scale=scale
    )
    return (
        torch.from_numpy(points),
        torch.from_numpy(triangles),
        torch.from_numpy(markers),
        torch.from_numpy(boundary_segments),
    )


[docs] def polygon_interior_point( loop: Float[torch.Tensor, "n 2"] | np.ndarray, ) -> Float[torch.Tensor, " 2"]: """Return a point strictly inside a simple closed polygon. Returns the centroid of the polygon's largest *ear*: a convex vertex whose neighbor-to-neighbor triangle strictly contains no other polygon vertex, which the two-ears theorem guarantees to exist for every simple polygon. Ear triangles lie inside the polygon, so their centroids do too, and taking the largest keeps the point robustly away from the boundary even for polygons with near-degenerate (collinear) vertices. As a final guard against exactly-degenerate inputs, the centroid is verified by an even-odd ray-crossing test before being returned, falling through to the next-largest ear on failure. The scan is vectorized NumPy: O(n) memory and O(n^2) worst-case time, near-linear in practice because candidate ears are tested largest-first. Deterministic: identical inputs return bitwise-identical outputs. Parameters ---------- loop : torch.Tensor or numpy.ndarray Polygon vertices, shape :math:`(N, 2)` with :math:`N \\geq 3`, closed implicitly (do not repeat the first vertex). Must be simple; either winding is accepted. Returns ------- torch.Tensor A point strictly inside the polygon, shape :math:`(2,)`, float64. Raises ------ ValueError If the loop has fewer than 3 vertices, a non-``(N, 2)`` shape, non-finite coordinates, duplicate consecutive vertices, zero area (fully degenerate), or no verifiable ear (the boundary self-intersects). """ (loop_array,) = _validate_loops([loop]) ring = loop_array doubled_area = float( np.sum( ring[:, 0] * (np.roll(ring[:, 1], -1) - np.roll(ring[:, 1], 1)), ) ) if doubled_area < 0.0: ring = ring[::-1] # counterclockwise from here on n = ring.shape[0] # Classify on unit-box-normalized coordinates so the strict sign tests # below run at uniform float64 precision at any physical scale. Distinct # vertices (validated above) guarantee a positive extent. lower = ring.min(axis=0) normalized = (ring - lower) / float((ring.max(axis=0) - lower).max()) previous = np.roll(normalized, 1, axis=0) following = np.roll(normalized, -1, axis=0) # Doubled signed ear area at each vertex; > 0 marks a convex corner. ear_area = (normalized[:, 0] - previous[:, 0]) * ( following[:, 1] - normalized[:, 1] ) - (normalized[:, 1] - previous[:, 1]) * (following[:, 0] - normalized[:, 0]) candidates = np.nonzero(ear_area > 0.0)[0] if candidates.size == 0: raise ValueError( "polygon is degenerate (zero area); cannot find an interior point" ) order = candidates[np.argsort(-ear_area[candidates], kind="stable")] # A candidate is an ear iff no other vertex lies strictly inside its # triangle (vertices exactly on the triangle boundary do not block; the # even-odd verification below covers those exactly-degenerate touches). # Chunked so the (candidates x vertices) sign matrices stay ~memory-flat. chunk_size = max(1, 2_000_000 // n) for start in range(0, order.size, chunk_size): chunk = order[start : start + chunk_size] a = previous[chunk][:, None] b = normalized[chunk][:, None] c = following[chunk][:, None] p = normalized[None] s_ab = (b[..., 0] - a[..., 0]) * (p[..., 1] - a[..., 1]) - ( b[..., 1] - a[..., 1] ) * (p[..., 0] - a[..., 0]) s_bc = (c[..., 0] - b[..., 0]) * (p[..., 1] - b[..., 1]) - ( c[..., 1] - b[..., 1] ) * (p[..., 0] - b[..., 0]) s_ca = (a[..., 0] - c[..., 0]) * (p[..., 1] - c[..., 1]) - ( a[..., 1] - c[..., 1] ) * (p[..., 0] - c[..., 0]) blocked = ((s_ab > 0.0) & (s_bc > 0.0) & (s_ca > 0.0)).any(axis=1) for index in chunk[~blocked]: i = int(index) centroid = (ring[i - 1] + ring[i] + ring[(i + 1) % n]) / 3.0 if _points_in_polygon(centroid[None], ring)[0]: return torch.from_numpy(centroid) raise ValueError("no verifiable ear found; the polygon boundary self-intersects")
def _validate_loops( loops: Sequence[Float[torch.Tensor, "n_i 2"] | np.ndarray], ) -> list[np.ndarray]: """Convert loops to float64 numpy arrays and check basic well-formedness.""" if len(loops) == 0: raise ValueError("loops must contain at least the outer boundary loop") arrays = [] for index, loop in enumerate(loops): if torch.is_tensor(loop): array = loop.detach().cpu().to(torch.float64).numpy() else: array = np.asarray(loop, dtype=np.float64) if array.ndim != 2 or array.shape[1] != 2 or array.shape[0] < 3: raise ValueError( f"loop {index} must have shape (n >= 3, 2), got {tuple(array.shape)}" ) if not np.isfinite(array).all(): raise ValueError(f"loop {index} contains non-finite coordinates") if ( np.any(np.linalg.norm(np.diff(array, axis=0), axis=1) == 0.0) or np.linalg.norm(array[0] - array[-1]) == 0.0 ): raise ValueError(f"loop {index} contains duplicate consecutive points") arrays.append(np.ascontiguousarray(array)) return arrays def _points_in_polygon( points: Float[np.ndarray, "n_query 2"], loop: Float[np.ndarray, "n_loop 2"], ) -> np.ndarray: """Vectorized even-odd (ray-crossing) point-in-polygon test. Casts a ray toward +x from each query point and counts crossings of the implicitly-closed polygon's edges, with the half-open vertical rule (an edge counts only when exactly one endpoint's y is <= the query's) so a ray through a vertex is never double-counted. Points exactly on the boundary classify arbitrarily, which both callers tolerate: containment validation treats a boundary-touching loop as misconfigured either way, and ear verification queries strictly-interior centroids. """ x = points[:, 0:1] y = points[:, 1:2] ax = loop[None, :, 0] ay = loop[None, :, 1] bx = np.roll(loop[:, 0], -1)[None] by = np.roll(loop[:, 1], -1)[None] straddles = (ay <= y) != (by <= y) crosses = x < ax + (y - ay) * (bx - ax) / (by - ay + (ay == by)) return (straddles & crosses).sum(axis=1) % 2 == 1 # --------------------------------------------------------------------------- # Geometric predicates (float64 determinants on normalized coordinates) # --------------------------------------------------------------------------- def _orient2d(ax: float, ay: float, bx: float, by: float, cx: float, cy: float): """Twice the signed area of triangle (a, b, c); > 0 iff counterclockwise. Plain float64 evaluation. On unit-box coordinates its roundoff is a few ulps of the operand products (~1e-16), which the strict sign conventions of the callers absorb: ties and sub-roundoff values classify as "straight" / "not crossing", and cavity construction repairs any resulting non-star-shapedness explicitly instead of trusting the sign. """ return (bx - ax) * (cy - ay) - (by - ay) * (cx - ax) def _incircle_with_magnitude( ax: float, ay: float, bx: float, by: float, cx: float, cy: float, dx: float, dy: float, ) -> tuple[float, float]: """Incircle determinant plus the magnitude scale of its three terms. The magnitude (sum of the terms' absolute values) is the natural yardstick for a *relative* epsilon: float64 roundoff on the determinant is a few ulps of it regardless of how small the triangle is, so callers can distinguish genuine incircle violations from cocircular ties at any scale (see ``_INCIRCLE_REL_EPS``). """ adx = ax - dx ady = ay - dy bdx = bx - dx bdy = by - dy cdx = cx - dx cdy = cy - dy term_a = (adx * adx + ady * ady) * (bdx * cdy - cdx * bdy) term_b = (bdx * bdx + bdy * bdy) * (cdx * ady - adx * cdy) term_c = (cdx * cdx + cdy * cdy) * (adx * bdy - bdx * ady) return ( term_a + term_b + term_c, abs(term_a) + abs(term_b) + abs(term_c), ) # --------------------------------------------------------------------------- # Core triangulation structure # --------------------------------------------------------------------------- class _Triangulation: """Mutable constrained-Delaunay triangulation over normalized points. Flat-list storage tuned for pure-Python speed (this is the hot path of generation-time meshing; numpy scalar indexing would be slower): - ``px`` / ``py``: vertex coordinates. Vertices 0-2 are the bounding super-triangle; real vertices follow in insertion order. - ``marker``: per-vertex boundary marker (1 = on the input polyline). - ``tv``: triangle vertices, 3 slots per triangle, counterclockwise. ``tv[3 t] == -2`` marks a dead (recycled) triangle. - ``tn``: neighbor triangle across the edge *opposite* each local vertex (edge opposite local ``i`` joins locals ``i+1`` and ``i+2``, directed counterclockwise); ``-1`` = no neighbor. - ``tc``: per-edge constrained flags, parallel to ``tn``. Constrained edges are input (sub)segments: never flipped, never crossed by cavities. - ``vt``: for each vertex, some living incident triangle (kept fresh by triangle creation; used to seed rotations and edge lookups). """ def __init__(self) -> None: k = _SUPER_HALF_EXTENT self.px: list[float] = [0.5 - k, 0.5 + k, 0.5] self.py: list[float] = [0.5 - k, 0.5 - k, 0.5 + k] self.marker: list[int] = [0, 0, 0] self.tv: list[int] = [0, 1, 2] self.tn: list[int] = [-1, -1, -1] self.tc: list[bool] = [False, False, False] self.vt: list[int] = [0, 0, 0] self.free: list[int] = [] # -- vertices and triangles ------------------------------------------------ def _add_vertex(self, x: float, y: float, marker: int) -> int: self.px.append(x) self.py.append(y) self.marker.append(marker) self.vt.append(-1) return len(self.px) - 1 def _new_triangle(self, a: int, b: int, c: int) -> int: """Allocate a live counterclockwise triangle (a, b, c).""" tv, tn, tc, vt = self.tv, self.tn, self.tc, self.vt if self.free: t = self.free.pop() base = 3 * t tv[base] = a tv[base + 1] = b tv[base + 2] = c tn[base] = tn[base + 1] = tn[base + 2] = -1 tc[base] = tc[base + 1] = tc[base + 2] = False else: t = len(tv) // 3 tv.extend((a, b, c)) tn.extend((-1, -1, -1)) tc.extend((False, False, False)) vt[a] = t vt[b] = t vt[c] = t return t def _kill(self, t: int) -> None: self.tv[3 * t] = -2 self.free.append(t) def _alive(self, t: int) -> bool: return self.tv[3 * t] >= 0 def _living(self) -> list[int]: tv = self.tv return [t for t in range(len(tv) // 3) if tv[3 * t] >= 0] # -- point location --------------------------------------------------------- def _locate(self, x: float, y: float, start: int) -> int: """Orientation walk to a triangle containing (x, y). Valid only while the super-triangle still bounds everything (before exterior removal), so the walk can never fall off the mesh. Ties (point exactly on an edge) count as inside, so the walk stops at a triangle whose closure contains the point. """ tv, tn, px, py = self.tv, self.tn, self.px, self.py t = start for _ in range(4 * (len(tv) // 3) + 16): base = 3 * t a = tv[base] b = tv[base + 1] c = tv[base + 2] axv = px[a] ayv = py[a] bxv = px[b] byv = py[b] cxv = px[c] cyv = py[c] if (bxv - axv) * (y - ayv) - (byv - ayv) * (x - axv) < 0.0: t = tn[base + 2] # exit across edge (a, b) elif (cxv - bxv) * (y - byv) - (cyv - byv) * (x - bxv) < 0.0: t = tn[base] # exit across edge (b, c) elif (axv - cxv) * (y - cyv) - (ayv - cyv) * (x - cxv) < 0.0: t = tn[base + 1] # exit across edge (c, a) else: return t if t < 0: raise RuntimeError( "point location walked off the mesh; the query point lies " "outside the bounding super-triangle" ) raise RuntimeError("point location did not terminate") # -- Bowyer-Watson insertion ------------------------------------------------- def _grow_cavity( self, x: float, y: float, seed: int ) -> tuple[list[int], dict[int, bool]]: """Depth-first collection of triangles whose circumdisk contains (x, y). The Bowyer-Watson cavity: grown from the containing triangle across non-constrained edges only, with a strict incircle test (ties stay out, keeping the cavity conservative under roundoff). """ tv, tn, tc, px, py = self.tv, self.tn, self.tc, self.px, self.py cavity = [seed] in_cavity = {seed: True} stack = [seed] while stack: t = stack.pop() base = 3 * t for j in (0, 1, 2): n = tn[base + j] if n < 0 or n in in_cavity or tc[base + j]: continue nb = 3 * n a = tv[nb] b = tv[nb + 1] c = tv[nb + 2] adx = px[a] - x ady = py[a] - y bdx = px[b] - x bdy = py[b] - y cdx = px[c] - x cdy = py[c] - y if ( (adx * adx + ady * ady) * (bdx * cdy - cdx * bdy) + (bdx * bdx + bdy * bdy) * (cdx * ady - adx * cdy) + (cdx * cdx + cdy * cdy) * (adx * bdy - bdx * ady) ) > 0.0: in_cavity[n] = True cavity.append(n) stack.append(n) return cavity, in_cavity def _cavity_boundary( self, cavity: list[int], in_cavity: dict[int, bool] ) -> list[tuple[int, int, int, int, bool]]: """Directed boundary edges of a cavity. Each entry is ``(u, v, n, k, constrained)``: the edge runs u -> v with the cavity on its left, ``n`` is the surviving triangle across it (``-1`` if none) with back-reference slot ``k``. """ tv, tn, tc = self.tv, self.tn, self.tc boundary = [] for t in cavity: base = 3 * t for j in (0, 1, 2): n = tn[base + j] if n >= 0 and n in in_cavity: continue u = tv[base + (1, 2, 0)[j]] v = tv[base + (2, 0, 1)[j]] if n >= 0: nb = 3 * n k = 0 if tn[nb] == t else (1 if tn[nb + 1] == t else 2) else: k = -1 boundary.append((u, v, n, k, tc[base + j])) return boundary def _cavity_for_point( self, x: float, y: float, seed: int, split_edge: tuple[int, int] | None = None, ): """Cavity + boundary for inserting (x, y), repaired to be star-shaped. Floating-point incircle tests can leave the cavity non-star-shaped (a boundary edge not strictly visible from the new point), which would produce inverted fan triangles; such edges are repaired by absorbing the triangle across them (Bowyer-Watson with explicit cavity repair). Repair may not cross constrained edges: hitting one means the point essentially lies on a segment, which the caller must resolve. Returns ``("ok", cavity, in_cavity, boundary)`` on success or ``("constrained", (u, v))`` if repair was blocked by constrained / hull edge ``(u, v)``. ``split_edge`` (for midpoint insertion of a constrained subsegment) names the directed boundary edge the point lies on, which is exempt from the visibility requirement. """ px, py = self.px, self.py cavity, in_cavity = self._grow_cavity(x, y, seed) for _ in range(_MAX_CAVITY_REPAIR_ROUNDS): boundary = self._cavity_boundary(cavity, in_cavity) expand = [] for u, v, n, _k, constrained in boundary: if split_edge is not None and u == split_edge[0] and v == split_edge[1]: continue ux = px[u] uy = py[u] if (px[v] - ux) * (y - uy) - (py[v] - uy) * (x - ux) <= 0.0: if n < 0 or constrained: return ("constrained", (u, v)) if n not in in_cavity: expand.append(n) if not expand: # A cavity that is a triangulated polygon with no interior # vertices satisfies len(cavity) == len(boundary) - 2 (Euler); # violating it would silently orphan a vertex. if len(cavity) != len(boundary) - 2: raise RuntimeError( "insertion cavity is not a simple polygon; input " "geometry is too degenerate for float64 predicates" ) return ("ok", cavity, in_cavity, boundary) for n in expand: if n not in in_cavity: in_cavity[n] = True cavity.append(n) raise RuntimeError("cavity star-shapedness repair did not converge") def _build_fan( self, p: int, cavity: list[int], boundary: list[tuple[int, int, int, int, bool]], split_edge: tuple[int, int] | None = None, ) -> list[int]: """Retriangulate a cavity as the fan of its boundary edges around p. For a midpoint insertion (``split_edge`` given) the edge the point lies on emits no (degenerate) triangle; the two dangling fan edges (u, p) and (p, v) become the constrained child subsegments instead. """ tn, tc = self.tn, self.tc open_edges: dict[int, tuple[int, int]] = {} new_triangles = [] for u, v, n, k, constrained in boundary: if split_edge is not None and u == split_edge[0] and v == split_edge[1]: continue t = self._new_triangle(p, u, v) base = 3 * t tn[base] = n tc[base] = constrained if n >= 0: tn[3 * n + k] = t for x, slot in ((u, 2), (v, 1)): # edges (p, u) and (v, p) partner = open_edges.pop(x, None) if partner is None: open_edges[x] = (t, slot) else: t2, s2 = partner tn[base + slot] = t2 tn[3 * t2 + s2] = t new_triangles.append(t) if split_edge is None: if open_edges: raise RuntimeError("insertion cavity boundary was not a closed loop") else: for t2, s2 in open_edges.values(): tc[3 * t2 + s2] = True # child subsegments; tn stays -1 (hull) for t in cavity: self._kill(t) return new_triangles def insert_points(self, points: np.ndarray) -> None: """Bulk-insert points (rows of an ``(n, 2)`` array) in input order. Each point is located by an orientation walk starting from the last insertion's fan, which is nearly O(1) for the spatially coherent orders this module feeds it (loop order, refinement order). """ start = 0 for x, y in points.tolist(): seed = self._locate(x, y, start) status = self._cavity_for_point(x, y, seed) if status[0] != "ok": raise RuntimeError("unconstrained insertion cannot be blocked") _, cavity, _in_cavity, boundary = status p = self._add_vertex(x, y, marker=1) new_triangles = self._build_fan(p, cavity, boundary) start = new_triangles[-1] # -- edge lookup and flips --------------------------------------------------- def _triangles_around(self, v: int) -> list[tuple[int, int]]: """All living triangles incident to v as ``(t, local_index_of_v)``. Rotates counterclockwise from ``vt[v]``; if the star is cut by a hull (post-removal boundary), finishes the remainder clockwise. """ tv, tn = self.tv, self.tn t0 = self.vt[v] out = [] t = t0 while True: base = 3 * t i = 0 if tv[base] == v else (1 if tv[base + 1] == v else 2) out.append((t, i)) t = tn[base + (2, 0, 1)[i]] # cross edge (v, v_next): rotate CCW if t == t0: return out if t < 0: break t = t0 while True: base = 3 * t i = 0 if tv[base] == v else (1 if tv[base + 1] == v else 2) t = tn[base + (1, 2, 0)[i]] # cross edge (v_prev, v): rotate CW if t < 0 or t == t0: return out base = 3 * t i = 0 if tv[base] == v else (1 if tv[base + 1] == v else 2) out.append((t, i)) def _edge_slot(self, u: int, v: int) -> tuple[int, int] | None: """Find edge (u, v) as ``(triangle, slot_opposite_the_edge)``. Searches the star of u; returns ``None`` if u and v are not currently adjacent (e.g. the edge has been split away). Direction-insensitive. """ tv = self.tv for t, i in self._triangles_around(u): base = 3 * t if tv[base + (1, 2, 0)[i]] == v: return (t, (2, 0, 1)[i]) if tv[base + (2, 0, 1)[i]] == v: return (t, (1, 2, 0)[i]) return None def _flip(self, t: int, j: int) -> int: """Flip the edge opposite local vertex j of triangle t. Replaces triangles (A, B, C) and (D, C, B) sharing edge (B, C) with (A, B, D) and (A, D, C) sharing the other diagonal (A, D). The caller guarantees the quad is strictly convex and the edge unconstrained. Returns the neighbor triangle id (which now holds (A, D, C)). """ tv, tn, tc, vt = self.tv, self.tn, self.tc, self.vt bt = 3 * t n = tn[bt + j] j1 = (j + 1) % 3 j2 = (j + 2) % 3 a = tv[bt + j] b = tv[bt + j1] c = tv[bt + j2] bn = 3 * n k = 0 if tn[bn] == t else (1 if tn[bn + 1] == t else 2) k1 = (k + 1) % 3 k2 = (k + 2) % 3 d = tv[bn + k] nb_j1 = tn[bt + j1] c_j1 = tc[bt + j1] nb_j2 = tn[bt + j2] c_j2 = tc[bt + j2] nb_k1 = tn[bn + k1] c_k1 = tc[bn + k1] nb_k2 = tn[bn + k2] c_k2 = tc[bn + k2] tv[bt] = a tv[bt + 1] = b tv[bt + 2] = d tn[bt] = nb_k1 tc[bt] = c_k1 tn[bt + 1] = n tc[bt + 1] = False tn[bt + 2] = nb_j2 tc[bt + 2] = c_j2 tv[bn] = a tv[bn + 1] = d tv[bn + 2] = c tn[bn] = nb_k2 tc[bn] = c_k2 tn[bn + 1] = nb_j1 tc[bn + 1] = c_j1 tn[bn + 2] = t tc[bn + 2] = False if nb_k1 >= 0: nbase = 3 * nb_k1 s = 0 if tn[nbase] == n else (1 if tn[nbase + 1] == n else 2) tn[nbase + s] = t if nb_j1 >= 0: nbase = 3 * nb_j1 s = 0 if tn[nbase] == t else (1 if tn[nbase + 1] == t else 2) tn[nbase + s] = n vt[a] = t vt[b] = t vt[d] = t vt[c] = n return n # -- constrained segment recovery --------------------------------------------- def _mark_if_edge(self, a: int, b: int) -> bool: """If edge (a, b) exists, flag it constrained on both sides.""" found = self._edge_slot(a, b) if found is None: return False t, j = found self.tc[3 * t + j] = True n = self.tn[3 * t + j] if n >= 0: nb = 3 * n k = 0 if self.tn[nb] == t else (1 if self.tn[nb + 1] == t else 2) self.tc[nb + k] = True return True def _first_crossing(self, a: int, b: int): """First obstruction on the way from vertex a toward vertex b. Scans the star of a for the wedge containing the ray a -> b. Returns ``("edge", t, j)`` where the edge opposite ``j`` in ``t`` is the first edge crossed by segment (a, b), or ``("vertex", w)`` if an existing vertex lies exactly on the segment (recovery then splits at w). """ tv, px, py = self.tv, self.px, self.py ax = px[a] ay = py[a] bx = px[b] by = py[b] best: tuple[float, int, int] | None = None for t, i in self._triangles_around(a): base = 3 * t u = tv[base + (1, 2, 0)[i]] v = tv[base + (2, 0, 1)[i]] ou = (bx - ax) * (py[u] - ay) - (by - ay) * (px[u] - ax) ov = (bx - ax) * (py[v] - ay) - (by - ay) * (px[v] - ax) if ou == 0.0 and (px[u] - ax) * (bx - ax) + (py[u] - ay) * (by - ay) > 0.0: return ("vertex", u) if ov == 0.0 and (px[v] - ax) * (bx - ax) + (py[v] - ay) * (by - ay) > 0.0: return ("vertex", v) straddles = (ou >= 0.0 >= ov or ou <= 0.0 <= ov) and ( ou != 0.0 or ov != 0.0 ) if straddles: # The sign pattern of (ou, ov) alone cannot distinguish the # wedge containing the forward ray from the one containing # the backward ray (found in review by melo-gonzo: the # original strict test selected backward wedges, and the # subsequent pipe walk exited the hull, silently dropping # coverage). Require a PROPER segment-segment intersection # of (a, b) with the opposite edge (u, v), and take the # crossing closest to a: the segment may properly cross a # non-convex link polygon several times, and the walk must # start at the first crossing. oa = (px[v] - px[u]) * (ay - py[u]) - (py[v] - py[u]) * (ax - px[u]) ob = (px[v] - px[u]) * (by - py[u]) - (py[v] - py[u]) * (bx - px[u]) if oa * ob < 0.0: t_param = oa / (oa - ob) if best is None or t_param < best[0]: best = (t_param, t, i) if best is not None: return ("edge", best[1], best[2]) raise ValueError( "segment recovery found no crossing toward the segment " "endpoint; input segments most likely cross each other (loops " "must be disjoint simple polylines), or the geometry is " "degenerate beyond float64 predicates" ) def _collect_pipe(self, a: int, b: int): """Edges crossed by segment (a, b), walked in order from a to b. Returns ``("pipe", deque_of_vertex_pairs)``, or ``("vertex", w)`` when a vertex sits exactly on the segment (the caller splits recovery at it). Crossing a constrained edge means two input segments intersect, which is invalid input. """ tv, tn, tc, px, py = self.tv, self.tn, self.tc, self.px, self.py first = self._first_crossing(a, b) if first[0] == "vertex": return first _, t, i = first ax = px[a] ay = py[a] bx = px[b] by = py[b] pipe: deque[tuple[int, int]] = deque() for _ in range(4 * (len(tv) // 3) + 16): base = 3 * t if tc[base + i]: raise ValueError( "input segments cross each other; loops must be disjoint " "simple polygons" ) u = tv[base + (1, 2, 0)[i]] # left of (a, b) by construction v = tv[base + (2, 0, 1)[i]] # right of (a, b) pipe.append((u, v)) n = tn[base + i] if n < 0: # Python's negative indexing would otherwise read garbage # adjacency off the list tails and corrupt the walk. raise RuntimeError( "segment pipe walk exited the triangulation; input " "geometry is too degenerate for float64 predicates" ) nb = 3 * n k = 0 if tn[nb] == t else (1 if tn[nb + 1] == t else 2) w = tv[nb + k] if w == b: return ("pipe", pipe) ow = (bx - ax) * (py[w] - ay) - (by - ay) * (px[w] - ax) if ow == 0.0: return ("vertex", w) # Continue through the far triangle: its slots are (k: w, k+1: v, # k+2: u); the next crossed edge keeps w on the side it fell on. t = n i = (k + 2) % 3 if ow > 0.0 else (k + 1) % 3 raise RuntimeError("segment pipe walk did not terminate") def recover_segment(self, a: int, b: int) -> None: """Force edge (a, b) into the triangulation and mark it constrained. Sloan's algorithm: repeatedly flip the edges crossing the segment, deferring (re-queueing) edges whose surrounding quad is not yet strictly convex, until the segment appears as an edge. Flipped-in diagonals that still cross the segment rejoin the queue. Local Delaunayhood of the neighborhood is restored afterwards by the global :meth:`legalize_all` pass that follows recovery. """ tv, px, py = self.tv, self.px, self.py work = [(a, b)] while work: a, b = work.pop() if self._mark_if_edge(a, b): continue hit = self._collect_pipe(a, b) if hit[0] == "vertex": w = hit[1] work.append((w, b)) work.append((a, w)) continue pipe = hit[1] ax = px[a] ay = py[a] bx = px[b] by = py[b] for _ in range(16 * len(pipe) * (len(pipe) + 8)): if not pipe: break u, v = pipe.popleft() found = self._edge_slot(u, v) if found is None: continue t, j = found bt = 3 * t n = self.tn[bt + j] aa = tv[bt + j] nb = 3 * n k = 0 if self.tn[nb] == t else (1 if self.tn[nb + 1] == t else 2) dd = tv[nb + k] # Flip only strictly convex quads (u, v strictly on opposite # sides of the candidate diagonal (aa, dd)). o1 = (px[dd] - px[aa]) * (py[u] - py[aa]) - (py[dd] - py[aa]) * ( px[u] - px[aa] ) o2 = (px[dd] - px[aa]) * (py[v] - py[aa]) - (py[dd] - py[aa]) * ( px[v] - px[aa] ) if (o1 > 0.0 and o2 < 0.0) or (o1 < 0.0 and o2 > 0.0): self._flip(t, j) if aa != a and aa != b and dd != a and dd != b: oa = (bx - ax) * (py[aa] - ay) - (by - ay) * (px[aa] - ax) od = (bx - ax) * (py[dd] - ay) - (by - ay) * (px[dd] - ax) if (oa > 0.0 > od) or (od > 0.0 > oa): pipe.append((aa, dd)) else: pipe.append((u, v)) # not flippable yet; retry later else: raise RuntimeError("segment recovery flipping did not terminate") if not self._mark_if_edge(a, b): raise RuntimeError( "segment recovery finished flipping but the segment is " "still absent; input geometry is too degenerate" ) def legalize_all(self) -> None: """Lawson flip passes until every unconstrained edge is locally Delaunay. Runs after segment recovery (whose flips are guided by crossings, not by the Delaunay criterion) and doubles as a safety net for hull- adjacent ties of the finite super-triangle. Each flip is gated on an incircle violation exceeding the relative roundoff margin ``_INCIRCLE_REL_EPS`` (exactly-cocircular quads are ties whose determinant sign is pure noise and would otherwise flip forever) *and* on strict convexity of the quad; every accepted flip then genuinely improves the triangulation, and the process terminates by Lawson's classical argument. """ tv, tn, tc, px, py = self.tv, self.tn, self.tc, self.px, self.py for _ in range(_MAX_LEGALIZE_PASSES): flips = 0 for t in range(len(tv) // 3): bt = 3 * t if tv[bt] < 0: continue for j in (0, 1, 2): n = tn[bt + j] if n < t or tc[bt + j]: # each pair once; skips hull (-1) continue a = tv[bt] b = tv[bt + 1] c = tv[bt + 2] nb = 3 * n k = 0 if tn[nb] == t else (1 if tn[nb + 1] == t else 2) d = tv[nb + k] det, magnitude = _incircle_with_magnitude( px[a], py[a], px[b], py[b], px[c], py[c], px[d], py[d] ) if det > _INCIRCLE_REL_EPS * magnitude: aa = tv[bt + j] u = tv[bt + (1, 2, 0)[j]] v = tv[bt + (2, 0, 1)[j]] if ( _orient2d(px[aa], py[aa], px[u], py[u], px[d], py[d]) > 0.0 and _orient2d(px[aa], py[aa], px[d], py[d], px[v], py[v]) > 0.0 ): self._flip(t, j) flips += 1 if flips == 0: return raise RuntimeError("Delaunay legalization did not converge") # -- exterior and hole removal --------------------------------------------- def remove_exterior_and_holes(self) -> None: """Delete triangles outside the outer loop and inside the holes. Even-odd parity flood fill over the dual graph (the classification CGAL ships as ``mark_domain_in_triangulation``): triangles touching a super-vertex are the unbounded exterior at parity 0, and crossing a constrained edge flips parity. The recovered constrained segments tile the closed input loops exactly, so the parity of a triangle is path-independent, and -- with hole containment already validated -- odd parity is precisely the domain interior. Purely topological: no seed points and no geometric predicates, so nearly-touching loops cannot misclassify. """ tv, tn, tc = self.tv, self.tn, self.tc living = self._living() parity: dict[int, int] = {} queue: deque[int] = deque() for t in living: base = 3 * t if tv[base] < 3 or tv[base + 1] < 3 or tv[base + 2] < 3: parity[t] = 0 queue.append(t) while queue: t = queue.popleft() p = parity[t] base = 3 * t for j in (0, 1, 2): n = tn[base + j] if n >= 0 and n not in parity: parity[n] = p ^ (1 if tc[base + j] else 0) queue.append(n) survivors = [] for t in living: if parity.get(t, 0) & 1: survivors.append(t) else: self._kill(t) if not survivors: raise ValueError( "no triangles remain after exterior/hole removal; the outer " "loop is degenerate" ) vt = self.vt for t in survivors: base = 3 * t for j in (0, 1, 2): v = tv[base + j] if v < 3: raise ValueError( "domain interior leaked to the bounding super-triangle;" " the outer loop is not a closed simple polygon" ) vt[v] = t n = tn[base + j] if n >= 0 and not parity.get(n, 0) & 1: tn[base + j] = -1 # -- Ruppert refinement ------------------------------------------------------- def _is_bad(self, t: int, max_area: float | None, b_squared: float | None) -> bool: """Quality test: area above ``max_area`` or minimum angle below bound. The angle test uses the circumradius-to-shortest-edge form of the bound (Ruppert 1995): with :math:`B = 1 / (2 \\sin \\theta_{\\min})`, a triangle has an angle below :math:`\\theta_{\\min}` iff :math:`R / \\ell_{\\min} > B`, evaluated here multiplicatively as :math:`\\ell_1^2 \\ell_2^2 \\ell_3^2 > (2 A)^2 B^2 \\ell_{\\min}^2` (from :math:`R = \\ell_1 \\ell_2 \\ell_3 / 4A`), which needs no square roots or divisions. """ tv, px, py = self.tv, self.px, self.py base = 3 * t a = tv[base] b = tv[base + 1] c = tv[base + 2] ax = px[a] ay = py[a] bx = px[b] by = py[b] cx = px[c] cy = py[c] doubled_area = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax) if max_area is not None and doubled_area > 2.0 * max_area: return True if b_squared is None: return False lab = (bx - ax) * (bx - ax) + (by - ay) * (by - ay) lbc = (cx - bx) * (cx - bx) + (cy - by) * (cy - by) lca = (ax - cx) * (ax - cx) + (ay - cy) * (ay - cy) lmin = lab if lab < lbc else lbc if lca < lmin: lmin = lca return lab * lbc * lca > 4.0 * doubled_area * doubled_area * b_squared * lmin def _circumcenter(self, t: int) -> tuple[float, float]: tv, px, py = self.tv, self.px, self.py base = 3 * t a = tv[base] b = tv[base + 1] c = tv[base + 2] ax = px[a] ay = py[a] dx = px[b] - ax dy = py[b] - ay ex = px[c] - ax ey = py[c] - ay d2 = dx * dx + dy * dy e2 = ex * ex + ey * ey denominator = 2.0 * (dx * ey - dy * ex) if denominator <= 0.0: raise RuntimeError("degenerate triangle has no circumcenter") return ( ax + (ey * d2 - dy * e2) / denominator, ay + (dx * e2 - ex * d2) / denominator, ) def _walk_toward(self, t: int, qx: float, qy: float): """Straight-line walk from triangle t's centroid toward (qx, qy). Crosses exactly the edges the segment crosses, so a constrained edge on the way means (qx, qy) is genuinely not visible from the triangle -- the signal Ruppert refinement needs. Returns ``("found", triangle)`` or ``("blocked", (triangle, slot))`` at the offending edge. """ tv, tn, tc, px, py = self.tv, self.tn, self.tc, self.px, self.py base = 3 * t a = tv[base] b = tv[base + 1] c = tv[base + 2] sx = (px[a] + px[b] + px[c]) / 3.0 sy = (py[a] + py[b] + py[c]) / 3.0 current = t for _ in range(4 * (len(tv) // 3) + 16): base = 3 * current exit_slot = -1 for j in (0, 1, 2): u = tv[base + (1, 2, 0)[j]] v = tv[base + (2, 0, 1)[j]] ux = px[u] uy = py[u] if (px[v] - ux) * (qy - uy) - (py[v] - uy) * (qx - ux) < 0.0: ou = (qx - sx) * (uy - sy) - (qy - sy) * (ux - sx) ov = (qx - sx) * (py[v] - sy) - (qy - sy) * (px[v] - sx) if ou >= 0.0 >= ov: exit_slot = j break if exit_slot < 0: return ("found", current) if tc[base + exit_slot] or tn[base + exit_slot] < 0: return ("blocked", (current, exit_slot)) current = tn[base + exit_slot] raise RuntimeError("circumcenter walk did not terminate") def _split_segment(self, t: int, j: int, seg_queue, tri_queue, budget: int): """Bisect the constrained subsegment opposite slot j of triangle t. The midpoint is a boundary vertex (marker 1); the two child subsegments inherit the constraint and are re-queued for encroachment checks, as are any other constrained edges whose apex changed. """ tv = self.tv base = 3 * t u = tv[base + (1, 2, 0)[j]] v = tv[base + (2, 0, 1)[j]] mx = 0.5 * (self.px[u] + self.px[v]) my = 0.5 * (self.py[u] + self.py[v]) status = self._cavity_for_point(mx, my, t, split_edge=(u, v)) if status[0] != "ok": raise RuntimeError( "subsegment midpoint insertion was blocked by another " "constrained edge; boundary loops nearly touch" ) _, cavity, _in_cavity, boundary = status p = self._add_vertex(mx, my, marker=1) if len(self.px) > budget: raise RuntimeError( "Delaunay refinement exceeded its vertex budget; the input " "violates the >= 60 degree segment-angle assumption" ) new_triangles = self._build_fan(p, cavity, boundary, split_edge=(u, v)) seg_queue.append((u, p, False)) seg_queue.append((p, v, False)) for bu, bv, _n, _k, constrained in boundary: if constrained and not (bu == u and bv == v): seg_queue.append((bu, bv, False)) for tn_ in new_triangles: nb = 3 * tn_ tri_queue.append((tn_, tv[nb], tv[nb + 1], tv[nb + 2])) def refine( self, max_area: float | None, b_squared: float | None, budget: int ) -> None: """Ruppert Delaunay refinement (Ruppert 1995, section 3). Two FIFO queues, processed deterministically with segments taking priority over triangles: 1. A constrained subsegment is *encroached* when a vertex lies strictly inside its diametral circle; encroached subsegments are split at their midpoints. Testing only the apexes of the adjacent triangles suffices in a Delaunay triangulation. 2. A bad triangle (angle/area bound violated) asks for its circumcenter. If the straight path to the circumcenter is blocked by a subsegment, or the circumcenter would encroach upon subsegments on its cavity boundary, those subsegments are split instead and the triangle is re-queued; otherwise the circumcenter is inserted (marker 0). Entries are validated lazily at pop time (the mesh may have changed since they were queued), so stale entries are simply skipped. """ tv, tc, px, py = self.tv, self.tc, self.px, self.py seg_queue: deque[tuple[int, int, bool]] = deque() tri_queue: deque[tuple[int, int, int, int]] = deque() for t in self._living(): base = 3 * t for j in (0, 1, 2): if tc[base + j]: seg_queue.append( (tv[base + (1, 2, 0)[j]], tv[base + (2, 0, 1)[j]], False) ) tri_queue.append((t, tv[base], tv[base + 1], tv[base + 2])) while seg_queue or tri_queue: if seg_queue: u, v, forced = seg_queue.popleft() found = self._edge_slot(u, v) if found is None: continue # already split away t, j = found base = 3 * t if not tc[base + j]: continue if not forced: w = tv[base + j] # apex of the only living side if (px[u] - px[w]) * (px[v] - px[w]) + (py[u] - py[w]) * ( py[v] - py[w] ) >= 0.0: continue # apex on/outside the diametral circle self._split_segment(t, j, seg_queue, tri_queue, budget) continue entry = tri_queue.popleft() t, a, b, c = entry base = 3 * t if tv[base] != a or tv[base + 1] != b or tv[base + 2] != c: continue # stale: triangle was retriangulated if not self._is_bad(t, max_area, b_squared): continue ccx, ccy = self._circumcenter(t) walked = self._walk_toward(t, ccx, ccy) if walked[0] == "blocked": tb, jb = walked[1] nb = 3 * tb seg_queue.append((tv[nb + (1, 2, 0)[jb]], tv[nb + (2, 0, 1)[jb]], True)) tri_queue.append(entry) continue container = walked[1] cb = 3 * container if any(px[tv[cb + j]] == ccx and py[tv[cb + j]] == ccy for j in (0, 1, 2)): continue # circumcenter coincides with an existing vertex status = self._cavity_for_point(ccx, ccy, container) if status[0] == "constrained": su, sv = status[1] seg_queue.append((su, sv, True)) tri_queue.append(entry) continue _, cavity, _in_cavity, boundary = status encroached = [ (bu, bv) for bu, bv, _n, _k, constrained in boundary if constrained and (px[bu] - ccx) * (px[bv] - ccx) + (py[bu] - ccy) * (py[bv] - ccy) < 0.0 ] if encroached: for bu, bv in encroached: seg_queue.append((bu, bv, True)) tri_queue.append(entry) continue p = self._add_vertex(ccx, ccy, marker=0) if len(px) > budget: raise RuntimeError( "Delaunay refinement exceeded its vertex budget; the " "input violates the documented angle assumptions" ) new_triangles = self._build_fan(p, cavity, boundary) for bu, bv, _n, _k, constrained in boundary: if constrained: seg_queue.append((bu, bv, False)) for tn_ in new_triangles: nb = 3 * tn_ tri_queue.append((tn_, tv[nb], tv[nb + 1], tv[nb + 2])) # -- ODT smoothing -------------------------------------------------------------- def _star_min_quality(self, star: list[tuple[int, int]], x: float, y: float): """Minimum squared-sine over all angles of v's star with v at (x, y). For a counterclockwise triangle with doubled area :math:`2A` and squared edge lengths :math:`l_1^2, l_2^2, l_3^2`, the squared sine of the angle between edges 1 and 2 is :math:`(2A)^2 / (l_1^2 l_2^2)`, so the smallest angle's squared sine is :math:`(2A)^2` over the largest pairwise product. A non-positive orientation scores ``-inf``. ``sin^2`` cannot tell an angle from its supplement, but a near-180 corner forces a near-0 corner in the same triangle, so the minimum still detects every degeneracy. Angles at all three corners count -- the star's outer-ring angles at fixed vertices matter just as much as the angles at v. """ tv, px, py = self.tv, self.px, self.py worst = math.inf for t, i in star: base = 3 * t u = tv[base + (1, 2, 0)[i]] w = tv[base + (2, 0, 1)[i]] ux = px[u] uy = py[u] wx = px[w] wy = py[w] doubled = (ux - x) * (wy - y) - (uy - y) * (wx - x) if doubled <= 0.0: return -math.inf l_vu = (ux - x) * (ux - x) + (uy - y) * (uy - y) l_uw = (wx - ux) * (wx - ux) + (wy - uy) * (wy - uy) l_wv = (x - wx) * (x - wx) + (y - wy) * (y - wy) d = l_vu * l_uw if l_vu * l_wv > d: d = l_vu * l_wv if l_uw * l_wv > d: d = l_uw * l_wv quality = doubled * doubled / d if quality < worst: worst = quality return worst def _star_max_doubled_area(self, star: list[tuple[int, int]], x: float, y: float): """Largest doubled triangle area over v's star with v at (x, y).""" tv, px, py = self.tv, self.px, self.py largest = 0.0 for t, i in star: base = 3 * t u = tv[base + (1, 2, 0)[i]] w = tv[base + (2, 0, 1)[i]] doubled = (px[u] - x) * (py[w] - y) - (py[u] - y) * (px[w] - x) if doubled > largest: largest = doubled return largest def smooth(self, iterations: int, max_area: float | None) -> None: """Quality-gated ODT smoothing (Chen and Xu 2004; module docstring). Each pass sweeps the interior (marker-0) vertices in index order, Gauss-Seidel style: the proposed position is the area-weighted average of the incident triangles' circumcenters -- the optimal- Delaunay-triangulation update -- and the move is accepted only when the smallest angle among those triangles does not decrease and (when ``max_area`` is given) no incident triangle grows beyond it, so triangles never invert and the refinement's bounds survive. A Lawson legalization pass then restores Delaunayhood (Delaunay flips never reduce a quad's smallest angle either) before the next sweep. Stops early once a sweep accepts no move. Deterministic: index-order sweeps, no randomness. """ px, py, marker = self.px, self.py, self.marker tv = self.tv for _ in range(iterations): moved = 0 for v in range(3, len(px)): if marker[v] != 0: continue star = self._triangles_around(v) weight_sum = 0.0 weighted_x = 0.0 weighted_y = 0.0 for t, _i in star: base = 3 * t a = tv[base] b = tv[base + 1] c = tv[base + 2] doubled = (px[b] - px[a]) * (py[c] - py[a]) - (py[b] - py[a]) * ( px[c] - px[a] ) center_x, center_y = self._circumcenter(t) weight_sum += doubled weighted_x += doubled * center_x weighted_y += doubled * center_y if weight_sum <= 0.0: continue new_x = weighted_x / weight_sum new_y = weighted_y / weight_sum if new_x == px[v] and new_y == py[v]: continue if self._star_min_quality(star, new_x, new_y) < ( self._star_min_quality(star, px[v], py[v]) ): continue if max_area is not None: # Keep every incident triangle within the area bound; a # star already over the bound (possible transiently after # a legalization flip) may still shrink toward it. new_max = self._star_max_doubled_area(star, new_x, new_y) if new_max > 2.0 * max_area and new_max > ( self._star_max_doubled_area(star, px[v], py[v]) ): continue px[v] = new_x py[v] = new_y moved += 1 if moved == 0: return self.legalize_all() # -- output ------------------------------------------------------------------- def extract( self, n_input: int, original: np.ndarray, lower: np.ndarray, scale: float ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: """Assemble the final arrays in un-normalized coordinates. Input vertices are returned bit-identically from ``original``; Steiner vertices are mapped back through the affine normalization. Triangles and constrained subsegments are emitted in triangle-id order. """ tv, tc = self.tv, self.tc n_vertices = len(self.px) - 3 points = np.empty((n_vertices, 2), dtype=np.float64) points[:n_input] = original added = np.stack( ( np.asarray(self.px[3 + n_input :], dtype=np.float64), np.asarray(self.py[3 + n_input :], dtype=np.float64), ), axis=1, ) points[n_input:] = added * scale + lower markers = np.asarray(self.marker[3:], dtype=np.int64) triangles = [] boundary_segments = [] for t in self._living(): base = 3 * t triangles.append((tv[base] - 3, tv[base + 1] - 3, tv[base + 2] - 3)) boundary_segments.extend( (tv[base + (1, 2, 0)[j]] - 3, tv[base + (2, 0, 1)[j]] - 3) for j in (0, 1, 2) if tc[base + j] ) return ( points, np.asarray(triangles, dtype=np.int64).reshape(-1, 3), markers, np.asarray(boundary_segments, dtype=np.int64).reshape(-1, 2), ) def _delaunay_triangulation(points: np.ndarray) -> np.ndarray: """Unconstrained Delaunay triangulation of a point set (testing hook). Normalizes, runs Bowyer-Watson insertion plus a final legalization pass, strips the super-triangle, and returns the ``(n_triangles, 3)`` int64 connectivity in input-point indices. Exposed for the unit tests that verify the empty-circumcircle property directly. """ points = np.asarray(points, dtype=np.float64) lower = points.min(axis=0) scale = float((points.max(axis=0) - lower).max()) if scale <= 0.0: raise ValueError("points are coincident or collinear along an axis") tri = _Triangulation() tri.insert_points((points - lower) / scale) tri.legalize_all() tv = tri.tv triangles = [] for t in tri._living(): base = 3 * t a, b, c = tv[base], tv[base + 1], tv[base + 2] if a >= 3 and b >= 3 and c >= 3: triangles.append((a - 3, b - 3, c - 3)) return np.asarray(triangles, dtype=np.int64).reshape(-1, 3)