Source code for pysurfacefun.tri

"""
Triangular-patch utilities for pysurfacefun.

The triangular formulation and fast direct solver framework are introduced in:
A High-Order Fast Direct Solver for Surface PDEs on Triangles
https://arxiv.org/pdf/2604.03097

The reference triangle is the unit simplex with vertices ``(0, 0)``,
``(1, 0)``, and ``(0, 1)``.  Nodes are RecursiveNodes-style simplex nodes
generated from Chebyshev points of the second kind by default.  The polynomial
basis is the Proriol-Koornwinder-Dubiner basis used for strong-form
differentiation on a triangle.
"""

from __future__ import annotations

from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
import struct
from typing import Callable, Iterable
import zlib

import numpy as np

from .core import (
    Parent,
    Patch,
    SurfaceMesh,
    barymat,
    chebpts,
    chebpts2,
    default_merge_idx,
    get_work_array,
    inverse_dense,
    merge_patches,
    parse_pdo,
    quadwts,
    rowscale,
    solve_with_inverse,
)
from .recursivenodes_polynomials import proriolkoornwinderdubinervandermondegrad


Array = np.ndarray


[docs] def shifted_lobatto_nodes(degree: int) -> Array: """Shifted Lobatto-Gauss-Legendre nodes on ``[0, 1]``.""" if degree < 0: raise ValueError("degree must be nonnegative") if degree == 0: return np.array([0.5]) if degree == 1: return np.array([0.0, 1.0]) poly = np.polynomial.legendre.Legendre.basis(degree) roots = np.sort(poly.deriv().roots()) nodes = np.concatenate(([-1.0], roots, [1.0])) return 0.5 * (nodes + 1.0)
def _node_family(max_degree: int, family: str) -> list[Array]: return [node_family_points(degree + 1, family) for degree in range(max_degree + 1)] def _canonical_family_name(family: str) -> str: family = family.lower() aliases = { "cheb": "cheb2", "lgc": "cheb2", "chebyshev2": "cheb2", "gc": "cheb1", "chebyshev1": "cheb1", "leg": "gl", "legendre": "gl", "lob": "lgl", "lobatto": "lgl", "uni": "equi", "uniform": "equi", "lin": "equi", "linspace": "equi", "equispaced": "equi", } family = aliases.get(family, family) if family not in {"cheb2", "cheb1", "gl", "lgl", "equi"}: raise ValueError("unknown triangle node family") return family
[docs] def node_family_points(count: int, family: str = "cheb2") -> Array: """One-dimensional node family on ``[0, 1]`` used by RecursiveNodes.""" if count <= 0: return np.empty(0) family = _canonical_family_name(family) if family == "cheb2": return chebpts(count, 2, (0.0, 1.0)) if family == "cheb1": return chebpts(count, 1, (0.0, 1.0)) if family == "gl": x, _ = np.polynomial.legendre.leggauss(count) return 0.5 * (x + 1.0) if family == "lgl": return shifted_lobatto_nodes(count - 1) if count == 1: return np.array([0.5]) return np.linspace(0.0, 1.0, count)
def _multi_indices(length: int, total: int) -> Iterable[tuple[int, ...]]: if length == 1: yield (total,) return for first in range(total + 1): for rest in _multi_indices(length - 1, total - first): yield (first,) + rest def _insert_zero(v: Array, index: int) -> Array: out = np.zeros(v.size + 1) out[:index] = v[:index] out[index + 1 :] = v[index:] return out def _recursive_barycentric(dim: int, degree: int, alpha: tuple[int, ...], family: list[Array]) -> Array: x_degree = family[degree] if dim == 1: return x_degree[list(alpha)] b = np.zeros(dim + 1) weight = 0.0 for i in range(dim + 1): alpha_without_i = alpha[:i] + alpha[i + 1 :] degree_without_i = degree - alpha[i] w = x_degree[degree_without_i] br = _recursive_barycentric(dim - 1, degree_without_i, alpha_without_i, family) b += w * _insert_zero(br, i) weight += w return b / weight
[docs] def recursive_nodes( dim: int, degree: int, family: str = "cheb2", domain: str = "barycentric", interior: int = 0, ) -> Array: """ Recursive interpolation nodes on a simplex. This follows the definition in the RecursiveNodes documentation. For ``dim=2`` and ``domain='unit'`` the returned columns are the coordinates ``(x, y)`` on the unit reference triangle. """ if dim < 1: raise ValueError("dim must be at least 1") if degree < 0: raise ValueError("degree must be nonnegative") if interior < 0: raise ValueError("interior must be nonnegative") family_nodes = _node_family(degree, family) bary = [] for alpha in _multi_indices(dim + 1, degree): if interior and any(a < interior for a in alpha): continue bary.append(_recursive_barycentric(dim, degree, alpha, family_nodes)) bary_arr = np.asarray(bary) domain = domain.lower() if domain == "barycentric": return bary_arr if domain == "unit": return bary_arr[:, :dim] if domain == "equilateral" and dim == 2: x = bary_arr[:, 0] + 0.5 * bary_arr[:, 1] y = (np.sqrt(3.0) / 2.0) * bary_arr[:, 1] return np.column_stack((x, y)) raise ValueError("domain must be 'barycentric', 'unit', or 'equilateral'")
[docs] def tri_reference_nodes(n: int, family: str = "cheb2") -> tuple[Array, Array]: """ Reference triangle nodes with ``n`` points on each edge. The polynomial degree is ``n - 1`` and the total number of nodes is ``n * (n + 1) / 2``. """ if n < 1: raise ValueError("n must be positive") nodes = recursive_nodes(2, n - 1, family=family, domain="unit") return nodes[:, 0], nodes[:, 1]
[docs] def koornwinder_pkd(degree: int, x: Array | None = None, y: Array | None = None) -> tuple[Array, Array, Array]: """ RecursiveNodes PKD Vandermonde matrix and unit-triangle derivatives. The underlying RecursiveNodes routines evaluate orthonormal Proriol-Koornwinder-Dubiner polynomials on the biunit triangle with coordinates ``(r, s)``. This package uses the unit triangle ``(x, y)``, so this function applies ``r = 2*x - 1, s = 2*y - 1`` and multiplies the returned gradients by ``2``. Thus ``Kx`` and ``Ky`` differentiate with respect to the unit reference coordinates. """ if degree < 0: raise ValueError("degree must be nonnegative") if x is None or y is None: x, y = tri_reference_nodes(degree + 1) x = np.asarray(x, dtype=float).ravel() y = np.asarray(y, dtype=float).ravel() if x.shape != y.shape: raise ValueError("x and y must have the same shape") xy_biunit = np.column_stack((2.0 * x - 1.0, 2.0 * y - 1.0)) K, grad = proriolkoornwinderdubinervandermondegrad(2, degree, xy_biunit, both=True) Kx = 2.0 * grad[:, :, 0] Ky = 2.0 * grad[:, :, 1] return K, Kx, Ky
[docs] def tri_strong_diffmat( degree: int, x: Array | None = None, y: Array | None = None, basis: str = "pkd", ) -> tuple[Array, Array, Array]: """ Strong-form reference-triangle differentiation matrices. ``Du @ f`` and ``Dv @ f`` differentiate nodal values with respect to the reference coordinates ``u=x`` and ``v=y``. """ if x is None or y is None: x, y = tri_reference_nodes(degree + 1) basis = basis.lower() if basis not in {"pkd", "recursive", "recursivenodes"}: raise ValueError("only the RecursiveNodes PKD basis is supported") K, Ku, Kv = koornwinder_pkd(degree, x, y) col_scale = np.linalg.norm(K, axis=0) col_scale[col_scale == 0.0] = 1.0 Ks = K / col_scale Kus = Ku / col_scale Kvs = Kv / col_scale invKs = np.linalg.solve(Ks, np.eye(Ks.shape[0])) return Kus @ invKs, Kvs @ invKs, K
[docs] def trilattice(n: int) -> Array: """Triangle connectivity for the regular lattice ordering.""" if n < 2: return np.zeros((0, 3), dtype=int) triangles: list[list[int]] = [] colstart = 0 for i in range(n - 1): h = n - i - 1 triangles.append([colstart, colstart + 1, colstart + 1 + h]) for ss in range(colstart + 1, colstart + h): triangles.append([ss, ss + h, ss + h + 1]) for ss in range(colstart + 1, colstart + h): triangles.append([ss, ss + 1, ss + h + 1]) colstart += h + 1 return np.asarray(triangles, dtype=int)
[docs] @lru_cache(maxsize=64) def reference_triangle_quadrature_weights(n: int, family: str = "cheb2") -> Array: """Nodal quadrature weights on the reference triangle.""" degree = n - 1 x, y = tri_reference_nodes(n, family=family) K, _, _ = koornwinder_pkd(degree, x, y) basis_integrals = np.zeros(K.shape[1]) basis_integrals[0] = np.sqrt(2.0) / 4.0 return np.linalg.solve(K.T, basis_integrals)
def _triangular_n_from_count(count: int) -> int: n = int((np.sqrt(8 * count + 1) - 1) / 2) if n * (n + 1) // 2 != count: raise ValueError("triangle patches must have n*(n+1)/2 nodes") return n
[docs] @dataclass class TriangleSurfaceMesh: """High-order triangular surface patch mesh.""" x: list[Array] y: list[Array] z: list[Array] family: str = "cheb2" def __post_init__(self) -> None: self.family = _canonical_family_name(self.family) self.x = [np.asarray(v, dtype=float).ravel() for v in self.x] self.y = [np.asarray(v, dtype=float).ravel() for v in self.y] self.z = [np.asarray(v, dtype=float).ravel() for v in self.z] if not (len(self.x) == len(self.y) == len(self.z)): raise ValueError("x, y, z must have the same number of patches") self.n = _triangular_n_from_count(self.x[0].size) self.degree = self.n - 1 self.u, self.v = tri_reference_nodes(self.n, family=self.family) self.Du, self.Dv, self.K = tri_strong_diffmat(self.degree, self.u, self.v, basis="pkd") self.triangles = trilattice(self.n) self.ref_weights = reference_triangle_quadrature_weights(self.n, self.family) self.xu: list[Array] = [] self.xv: list[Array] = [] self.yu: list[Array] = [] self.yv: list[Array] = [] self.zu: list[Array] = [] self.zv: list[Array] = [] self.ux: list[Array] = [] self.vx: list[Array] = [] self.uy: list[Array] = [] self.vy: list[Array] = [] self.uz: list[Array] = [] self.vz: list[Array] = [] self.E: list[Array] = [] self.F: list[Array] = [] self.G: list[Array] = [] self.J: list[Array] = [] self.singular: list[bool] = [] for x, y, z in zip(self.x, self.y, self.z): xu = self.Du @ x xv = self.Dv @ x yu = self.Du @ y yv = self.Dv @ y zu = self.Du @ z zv = self.Dv @ z E = xu * xu + yu * yu + zu * zu G = xv * xv + yv * yv + zv * zv F = xu * xv + yu * yv + zu * zv J = E * G - F * F scl = float(np.max(np.abs(G * xu - F * xv))) singular = bool(np.any(np.abs(J) < 1e-10 * max(scl, 1.0))) self.xu.append(xu) self.xv.append(xv) self.yu.append(yu) self.yv.append(yv) self.zu.append(zu) self.zv.append(zv) if singular: self.ux.append(G * xu - F * xv) self.vx.append(E * xv - F * xu) self.uy.append(G * yu - F * yv) self.vy.append(E * yv - F * yu) self.uz.append(G * zu - F * zv) self.vz.append(E * zv - F * zu) else: self.ux.append((G * xu - F * xv) / J) self.vx.append((E * xv - F * xu) / J) self.uy.append((G * yu - F * yv) / J) self.vy.append((E * yv - F * yu) / J) self.uz.append((G * zu - F * zv) / J) self.vz.append((E * zv - F * zu) / J) self.E.append(E) self.F.append(F) self.G.append(G) self.J.append(J) self.singular.append(singular) @property def npatches(self) -> int: return len(self.x)
[docs] @staticmethod def icosphere(n: int, nref: int = 0, family: str = "cheb2") -> "TriangleSurfaceMesh": vertices, faces = _icosahedron() for _ in range(nref): vertices, faces = _subdivide_icosphere(vertices, faces) faces = _orient_faces_outward(vertices, faces) u, v = tri_reference_nodes(n, family=family) xpatches: list[Array] = [] ypatches: list[Array] = [] zpatches: list[Array] = [] for tri in faces: p0, p1, p2 = vertices[tri] xyz = (1.0 - u - v)[:, None] * p0 + u[:, None] * p1 + v[:, None] * p2 xyz /= np.linalg.norm(xyz, axis=1, keepdims=True) xpatches.append(xyz[:, 0]) ypatches.append(xyz[:, 1]) zpatches.append(xyz[:, 2]) return TriangleSurfaceMesh(xpatches, ypatches, zpatches, family=family)
[docs] def levelset_surface_tri( vertices: Array, faces: Array, phi: Callable, grad_phi: Callable, n: int, family: str = "cheb2", index_base: str | int = "auto", max_iter: int = 30, tol: float = 1.0e-13, damping: float = 1.0, ) -> TriangleSurfaceMesh: """ Build high-order triangular patches by projecting a coarse mesh to ``phi=0``. Parameters ---------- vertices, faces: Coarse triangular mesh. ``vertices`` has shape ``(nv, 3)`` and ``faces`` has shape ``(nf, 3)``. Faces may be zero-based or one-based when ``index_base='auto'``. phi, grad_phi: Level-set function and gradient. Each may accept either one point ``p`` with shape ``(3,)`` or three scalars ``x, y, z``. n: Number of high-order nodes on each triangle edge. Returns ------- TriangleSurfaceMesh Curved triangular patch mesh with every node projected to the smooth implicit surface. """ vertices = np.asarray(vertices, dtype=float) faces = np.asarray(faces, dtype=int) if vertices.ndim != 2 or vertices.shape[1] != 3: raise ValueError("vertices must have shape (nv, 3)") if faces.ndim != 2 or faces.shape[1] != 3: raise ValueError("faces must have shape (nf, 3)") if n < 2: raise ValueError("n must be at least 2") faces0 = normalize_face_indices(faces, vertices.shape[0], index_base) u, v = tri_reference_nodes(n, family=family) weights = np.column_stack((1.0 - u - v, u, v)) xpatches: list[Array] = [] ypatches: list[Array] = [] zpatches: list[Array] = [] for face in faces0: flat_points = weights @ vertices[face, :] projected = project_to_levelset( flat_points, phi, grad_phi, max_iter=max_iter, tol=tol, damping=damping, ) xpatches.append(projected[:, 0]) ypatches.append(projected[:, 1]) zpatches.append(projected[:, 2]) return TriangleSurfaceMesh(xpatches, ypatches, zpatches, family=family)
[docs] def levelset_surface_quad( vertices: Array, cells: Array, phi: Callable, grad_phi: Callable, n: int, index_base: str | int = "auto", max_iter: int = 30, tol: float = 1.0e-13, damping: float = 1.0, ) -> SurfaceMesh: """ Build tensor-product Chebyshev patches by projecting quad cells to ``phi=0``. Each coarse quad is first mapped bilinearly from ``[0,1]^2`` and then every Chebyshev node is projected to the implicit surface. """ vertices = np.asarray(vertices, dtype=float) cells = np.asarray(cells, dtype=int) if vertices.ndim != 2 or vertices.shape[1] != 3: raise ValueError("vertices must have shape (nv, 3)") if cells.ndim != 2 or cells.shape[1] != 4: raise ValueError("quad cells must have shape (nq, 4)") if n < 2: raise ValueError("n must be at least 2") cells0 = normalize_face_indices(cells, vertices.shape[0], index_base) u, v = chebpts2(n, n, [0.0, 1.0, 0.0, 1.0]) weights = np.stack( ( (1.0 - u) * (1.0 - v), u * (1.0 - v), u * v, (1.0 - u) * v, ), axis=-1, ) xpatches: list[Array] = [] ypatches: list[Array] = [] zpatches: list[Array] = [] for cell in cells0: flat_points = np.einsum("ijk,kl->ijl", weights, vertices[cell, :]) projected = project_to_levelset( flat_points, phi, grad_phi, max_iter=max_iter, tol=tol, damping=damping, ) xpatches.append(projected[:, :, 0]) ypatches.append(projected[:, :, 1]) zpatches.append(projected[:, :, 2]) return SurfaceMesh(xpatches, ypatches, zpatches)
[docs] def levelset_surface( mesh, phi: Callable, grad_phi: Callable, n: int, *, family: str = "cheb2", cell_type: str = "auto", vertex_name: str | None = None, face_name: str | None = None, mesh_name: str | None = None, index_base: str | int = "auto", nref: int = 0, orient_outward: bool = False, orientation_center: Array | None = None, max_iter: int = 30, tol: float = 1.0e-13, damping: float = 1.0, ) -> TriangleSurfaceMesh | SurfaceMesh: """ Level-set surface constructor for triangular or quadrilateral coarse meshes. ``mesh`` may be a MAT-file path, a ``(vertices, cells)`` tuple, a dictionary/struct-like loaded mesh, or an object with vertex/face fields. Three-node cells create a ``TriangleSurfaceMesh``; four-node cells create a quadrilateral ``SurfaceMesh``. ``nref`` refines the coarse mesh connectivity before high-order nodes are mapped and projected to the level set. """ vertices, cells = surface_mesh_arrays( mesh, vertex_name=vertex_name, face_name=face_name, mesh_name=mesh_name, index_base=index_base, ) cell_type = _infer_levelset_cell_type(cells, cell_type) if cell_type == "tri": tri_cells = cells if cells.shape[1] == 3 else triangulate_faces(cells) if nref: vertices, tri_cells = refine_tri_mesh(vertices, tri_cells, nref=nref) if orient_outward: tri_cells = orient_tri_faces_outward(vertices, tri_cells, center=orientation_center) return levelset_surface_tri( vertices, tri_cells, phi, grad_phi, n, family=family, index_base=0, max_iter=max_iter, tol=tol, damping=damping, ) if cells.shape[1] != 4: raise ValueError("quad level-set surfaces require four-node cells") if nref: vertices, cells = refine_quad_mesh(vertices, cells, nref=nref) return levelset_surface_quad( vertices, cells, phi, grad_phi, n, index_base=0, max_iter=max_iter, tol=tol, damping=damping, )
[docs] def levelset_surface_tri_from_mat( filename: str | Path, phi: Callable, grad_phi: Callable, n: int, *, vertex_name: str | None = None, face_name: str | None = None, mesh_name: str | None = None, family: str = "cheb2", index_base: str | int = "auto", nref: int = 0, triangulate: bool = True, orient_outward: bool = False, orientation_center: Array | None = None, max_iter: int = 30, tol: float = 1.0e-13, damping: float = 1.0, ) -> TriangleSurfaceMesh: """ Build a level-set triangular surface directly from a MAT-file mesh. The mesh file may store arrays as ``vertices/faces``, ``xs/surfs``, ``V/F``, or inside a struct. Use ``vertex_name`` and ``face_name`` when the file uses different variable names. """ vertices, faces = load_mat_tri_mesh( filename, vertex_name=vertex_name, face_name=face_name, mesh_name=mesh_name, index_base=index_base, ) if triangulate: faces = triangulate_faces( faces, vertices=vertices if orient_outward else None, orient_outward=orient_outward, center=orientation_center, ) if nref: vertices, faces = refine_tri_mesh(vertices, faces, nref=nref) if orient_outward: faces = orient_tri_faces_outward(vertices, faces, center=orientation_center) return levelset_surface_tri( vertices, faces, phi, grad_phi, n, family=family, index_base=0, max_iter=max_iter, tol=tol, damping=damping, )
[docs] def LevelSetSurface( mesh: str | Path | Array, faces_or_phi=None, phi: Callable | None = None, grad_phi: Callable | None = None, n: int | None = None, **kwargs, ) -> TriangleSurfaceMesh | SurfaceMesh: """ Notebook-friendly level-set surface constructor. Examples -------- ``LevelSetSurface("mesh.mat", phi, grad_phi, n=12)`` ``LevelSetSurface((vertices, cells), phi, grad_phi, n=12)`` ``LevelSetSurface(vertices, faces, phi, grad_phi, n=12)`` """ if callable(faces_or_phi): levelset_phi = faces_or_phi levelset_grad = phi degree_n = n if degree_n is None and grad_phi is not None and not callable(grad_phi): degree_n = int(grad_phi) if levelset_phi is None or levelset_grad is None or degree_n is None: raise ValueError("use LevelSetSurface(mesh, phi, grad_phi, n=...)") return levelset_surface(mesh, levelset_phi, levelset_grad, degree_n, **kwargs) if faces_or_phi is None or phi is None or grad_phi is None or n is None: raise ValueError( "use LevelSetSurface(mesh, phi, grad_phi, n=...) or " "LevelSetSurface(vertices, faces, phi, grad_phi, n=...)" ) return levelset_surface((mesh, faces_or_phi), phi, grad_phi, n, **kwargs)
[docs] def load_mat_tri_mesh( filename: str | Path, *, vertex_name: str | None = None, face_name: str | None = None, mesh_name: str | None = None, index_base: str | int = "auto", ) -> tuple[Array, Array]: """Backward-compatible alias for ``load_mat_surface_mesh``.""" return load_mat_surface_mesh( filename, vertex_name=vertex_name, face_name=face_name, mesh_name=mesh_name, index_base=index_base, )
[docs] def load_mat_surface_mesh( filename: str | Path, *, vertex_name: str | None = None, face_name: str | None = None, mesh_name: str | None = None, index_base: str | int = "auto", ) -> tuple[Array, Array]: """ Extract vertices and surface cells from a MAT-file. The returned cell array is zero-based for direct Python use. Supported common names include ``vertices/faces``, ``xs/surfs``, ``V/F``, ``nodes/elements``, and ``p/t``. """ data = _load_mat_file(filename) vertices, cells = extract_tri_mesh_arrays( data, vertex_name=vertex_name, face_name=face_name, mesh_name=mesh_name, ) cells = normalize_face_indices(cells, vertices.shape[0], index_base=index_base) return vertices, cells
[docs] def surface_mesh_arrays( mesh, *, vertex_name: str | None = None, face_name: str | None = None, mesh_name: str | None = None, index_base: str | int = "auto", ) -> tuple[Array, Array]: """Return ``(vertices, cells)`` from a path, tuple, dict, or mesh-like object.""" if isinstance(mesh, (str, Path)): return load_mat_surface_mesh( mesh, vertex_name=vertex_name, face_name=face_name, mesh_name=mesh_name, index_base=index_base, ) if isinstance(mesh, (tuple, list)) and len(mesh) == 2: vertices = _coerce_vertices(mesh[0], "vertices") cells = _coerce_faces(mesh[1], "faces") cells = normalize_face_indices(cells, vertices.shape[0], index_base=index_base) return vertices, cells vertices, cells = extract_tri_mesh_arrays( mesh, vertex_name=vertex_name, face_name=face_name, mesh_name=mesh_name, ) cells = normalize_face_indices(cells, vertices.shape[0], index_base=index_base) return vertices, cells
def _infer_levelset_cell_type(cells: Array, cell_type: str = "auto") -> str: cell_type = cell_type.lower() if cell_type in {"triangle", "triangles"}: cell_type = "tri" if cell_type in {"quadrilateral", "quadrilaterals", "quad", "quads"}: cell_type = "quad" if cell_type not in {"auto", "tri", "quad"}: raise ValueError("cell_type must be 'auto', 'tri', or 'quad'") if cell_type != "auto": return cell_type if cells.shape[1] == 3: return "tri" if cells.shape[1] == 4: return "quad" return "tri"
[docs] def triangulate_faces( faces: Array, *, vertices: Array | None = None, orient_outward: bool = False, center: Array | None = None, ) -> Array: """ Convert triangular or polygonal surface cells to triangle connectivity. Rows with three entries are kept. Rows with more entries are split by a fan from the first vertex. When ``orient_outward=True``, the triangles are flipped so their normals point away from ``center``. If ``center`` is not supplied, the origin is used. """ faces = np.asarray(faces, dtype=int) if faces.ndim != 2 or faces.shape[1] < 3: raise ValueError("faces must have shape (nf, m) with m >= 3") triangles: list[list[int]] = [] for row in faces: valid = row[row >= 0] if valid.size < 3: continue for j in range(1, valid.size - 1): triangles.append([int(valid[0]), int(valid[j]), int(valid[j + 1])]) out = np.asarray(triangles, dtype=int) if orient_outward: if vertices is None: raise ValueError("vertices are required when orient_outward=True") out = orient_tri_faces_outward(vertices, out, center=center) return out
[docs] def orient_tri_faces_outward(vertices: Array, faces: Array, center: Array | None = None) -> Array: """ Orient triangular faces so normals point away from ``center``. This is intended for closed, star-shaped surfaces such as the sphere mesh used in the triangular notebook. """ vertices = np.asarray(vertices, dtype=float) faces = np.asarray(faces, dtype=int) if center is None: center_arr = np.zeros(3) else: center_arr = np.asarray(center, dtype=float).reshape(3) out = faces.copy() for k, (a, b, c) in enumerate(out): p0, p1, p2 = vertices[[a, b, c]] normal = np.cross(p1 - p0, p2 - p0) face_center = (p0 + p1 + p2) / 3.0 if np.dot(normal, face_center - center_arr) < 0.0: out[k, 1], out[k, 2] = out[k, 2], out[k, 1] return out
[docs] def refine_surface_mesh( vertices: Array, cells: Array, nref: int = 1, cell_type: str = "auto", ) -> tuple[Array, Array]: """ Uniformly refine triangular or quadrilateral surface connectivity. This refinement is purely geometric and linear. It is intended to be used before level-set projection: new edge/center vertices are introduced in the coarse mesh, and the subsequent ``LevelSetSurface`` call projects the high-order patch nodes to the smooth surface. """ cell_type = _infer_levelset_cell_type(np.asarray(cells), cell_type) if cell_type == "tri": tri_cells = cells if np.asarray(cells).shape[1] == 3 else triangulate_faces(cells) return refine_tri_mesh(vertices, tri_cells, nref=nref) return refine_quad_mesh(vertices, cells, nref=nref)
[docs] def refine_tri_mesh(vertices: Array, faces: Array, nref: int = 1) -> tuple[Array, Array]: """Split each triangle into four triangles ``nref`` times.""" vertices = np.asarray(vertices, dtype=float) faces = np.asarray(faces, dtype=int) if nref < 0: raise ValueError("nref must be nonnegative") if faces.ndim != 2 or faces.shape[1] != 3: raise ValueError("triangular refinement requires faces with shape (nf, 3)") verts = vertices.copy() tris = faces.copy() for _ in range(nref): vert_list = [v.copy() for v in verts] edge_cache: dict[tuple[int, int], int] = {} def midpoint(i: int, j: int) -> int: key = tuple(sorted((int(i), int(j)))) if key not in edge_cache: edge_cache[key] = len(vert_list) vert_list.append(0.5 * (verts[key[0]] + verts[key[1]])) return edge_cache[key] refined: list[list[int]] = [] for a, b, c in tris: ab = midpoint(a, b) bc = midpoint(b, c) ca = midpoint(c, a) refined.extend( ( [int(a), ab, ca], [ab, int(b), bc], [ca, bc, int(c)], [ab, bc, ca], ) ) verts = np.asarray(vert_list, dtype=float) tris = np.asarray(refined, dtype=int) return verts, tris
[docs] def refine_quad_mesh(vertices: Array, cells: Array, nref: int = 1) -> tuple[Array, Array]: """Split each quadrilateral into four quadrilaterals ``nref`` times.""" vertices = np.asarray(vertices, dtype=float) cells = np.asarray(cells, dtype=int) if nref < 0: raise ValueError("nref must be nonnegative") if cells.ndim != 2 or cells.shape[1] != 4: raise ValueError("quadrilateral refinement requires cells with shape (nq, 4)") verts = vertices.copy() quads = cells.copy() for _ in range(nref): vert_list = [v.copy() for v in verts] edge_cache: dict[tuple[int, int], int] = {} def midpoint(i: int, j: int) -> int: key = tuple(sorted((int(i), int(j)))) if key not in edge_cache: edge_cache[key] = len(vert_list) vert_list.append(0.5 * (verts[key[0]] + verts[key[1]])) return edge_cache[key] refined: list[list[int]] = [] for a, b, c, d in quads: ab = midpoint(a, b) bc = midpoint(b, c) cd = midpoint(c, d) da = midpoint(d, a) center = len(vert_list) vert_list.append(0.25 * (verts[int(a)] + verts[int(b)] + verts[int(c)] + verts[int(d)])) refined.extend( ( [int(a), ab, center, da], [ab, int(b), bc, center], [center, bc, int(c), cd], [da, center, cd, int(d)], ) ) verts = np.asarray(vert_list, dtype=float) quads = np.asarray(refined, dtype=int) return verts, quads
[docs] def extract_tri_mesh_arrays( data, *, vertex_name: str | None = None, face_name: str | None = None, mesh_name: str | None = None, ) -> tuple[Array, Array]: """Extract vertex and face arrays from a loaded mesh dictionary/struct.""" if mesh_name is not None: data = _mat_get(data, mesh_name) if vertex_name is not None: vertices = _coerce_vertices(_mat_get(data, vertex_name), vertex_name) else: vertices = _find_mat_array(data, _VERTEX_FIELD_NAMES, _coerce_vertices, "vertices") if face_name is not None: faces = _coerce_faces(_mat_get(data, face_name), face_name) else: faces = _find_mat_array(data, _FACE_FIELD_NAMES, _coerce_faces, "faces") return vertices, faces
_VERTEX_FIELD_NAMES = ( "vertices", "verts", "xs", "V", "v", "nodes", "Nodes", "points", "Points", "p", ) _FACE_FIELD_NAMES = ( "faces", "surfs", "F", "f", "tri", "tris", "triangles", "Triangles", "elements", "Elements", "cells", "Cells", "t", ) def _load_mat_file(filename: str | Path): filename = Path(filename) try: from scipy.io import loadmat except ImportError as scipy_error: loadmat = None else: try: return loadmat(filename, squeeze_me=True, struct_as_record=False) except (NotImplementedError, ValueError, OSError) as scipy_error: pass try: return _load_mat_v5_numeric(filename) except (OSError, ValueError, zlib.error): pass try: import h5py except ImportError as h5py_error: if loadmat is None: raise ImportError( "Reading MAT-files requires scipy for v7 files or h5py " "for v7.3/HDF5 files. Install one of them, for example " "`python -m pip install scipy`." ) from h5py_error raise ImportError( "scipy could not read this file and h5py is not installed for " "v7.3/HDF5 MAT-files. Install h5py with " "`python -m pip install h5py`." ) from h5py_error try: with h5py.File(filename, "r") as h5: return {key: _read_hdf5_mat_object(h5[key]) for key in h5.keys()} except OSError as h5_error: if loadmat is None: raise ImportError( "Reading v7 MAT-files requires scipy. Install it with " "`python -m pip install scipy`." ) from h5_error try: return loadmat(filename, squeeze_me=True, struct_as_record=False) except Exception as retry_error: raise ImportError( "could not read the mesh file as a scipy or HDF5 MAT-file" ) from retry_error def _load_mat_v5_numeric(filename: Path) -> dict[str, Array]: """ Minimal MAT-file v5 reader for dense real numeric arrays. This keeps mesh loading usable in a small NumPy-only environment. It is deliberately narrow: it is meant for arrays like ``xs`` and ``surfs``. """ buf = filename.read_bytes() if len(buf) < 128 or b"MATLAB 5.0 MAT-file" not in buf[:128]: raise ValueError("not a MAT-file v5 file") endian = buf[126:128] if endian == b"IM": order = "<" elif endian == b"MI": order = ">" else: raise ValueError("unrecognized MAT-file v5 endian marker") arrays: dict[str, Array] = {} pos = 128 while pos + 8 <= len(buf): dtype, nbytes, data_pos, _ = _mat_v5_read_tag(buf, pos, order) if dtype == 0 and nbytes == 0: break if dtype == _MI_COMPRESSED: decompressed = zlib.decompress(buf[data_pos : data_pos + nbytes]) arrays.update(_mat_v5_read_elements(decompressed, order)) pos = data_pos + nbytes elif dtype == _MI_MATRIX: name, value = _mat_v5_read_matrix(buf[pos : data_pos + nbytes], order) arrays[name] = value pos = data_pos + nbytes else: break if not arrays: raise ValueError("no supported MAT-file v5 numeric arrays found") return arrays _MI_INT8 = 1 _MI_UINT8 = 2 _MI_INT16 = 3 _MI_UINT16 = 4 _MI_INT32 = 5 _MI_UINT32 = 6 _MI_SINGLE = 7 _MI_DOUBLE = 9 _MI_INT64 = 12 _MI_UINT64 = 13 _MI_MATRIX = 14 _MI_COMPRESSED = 15 _MAT_V5_DTYPES = { _MI_INT8: "i1", _MI_UINT8: "u1", _MI_INT16: "i2", _MI_UINT16: "u2", _MI_INT32: "i4", _MI_UINT32: "u4", _MI_SINGLE: "f4", _MI_DOUBLE: "f8", _MI_INT64: "i8", _MI_UINT64: "u8", } def _mat_v5_read_elements(buf: bytes, order: str) -> dict[str, Array]: arrays: dict[str, Array] = {} pos = 0 while pos + 8 <= len(buf): dtype, nbytes, data_pos, _ = _mat_v5_read_tag(buf, pos, order) if dtype == _MI_MATRIX: name, value = _mat_v5_read_matrix(buf[pos : data_pos + nbytes], order) arrays[name] = value pos = data_pos + nbytes elif dtype == _MI_COMPRESSED: arrays.update(_mat_v5_read_elements(zlib.decompress(buf[data_pos : data_pos + nbytes]), order)) pos = data_pos + nbytes elif dtype == 0 and nbytes == 0: break else: break return arrays def _mat_v5_read_matrix(buf: bytes, order: str) -> tuple[str, Array]: dtype, nbytes, data_pos, _ = _mat_v5_read_tag(buf, 0, order) if dtype != _MI_MATRIX: raise ValueError("MAT-file element is not a matrix") pos = data_pos _, nb, dpos, small = _mat_v5_read_tag(buf, pos, order) pos = _mat_v5_next_pos(dpos, nb, small) _, nb, dpos, small = _mat_v5_read_tag(buf, pos, order) dims = struct.unpack(order + "i" * (nb // 4), buf[dpos : dpos + nb]) pos = _mat_v5_next_pos(dpos, nb, small) _, nb, dpos, small = _mat_v5_read_tag(buf, pos, order) name = buf[dpos : dpos + nb].decode("latin1") pos = _mat_v5_next_pos(dpos, nb, small) dtype, nb, dpos, _ = _mat_v5_read_tag(buf, pos, order) if dtype not in _MAT_V5_DTYPES: raise ValueError(f"unsupported MAT-file numeric data type {dtype}") np_dtype = np.dtype(order + _MAT_V5_DTYPES[dtype]) values = np.frombuffer(buf[dpos : dpos + nb], dtype=np_dtype).copy() return name, values.reshape(dims, order="F") def _mat_v5_read_tag(buf: bytes, pos: int, order: str) -> tuple[int, int, int, bool]: raw = struct.unpack(order + "I", buf[pos : pos + 4])[0] dtype = raw & 0xFFFF nbytes = (raw >> 16) & 0xFFFF if nbytes: return dtype, nbytes, pos + 4, True dtype, nbytes = struct.unpack(order + "II", buf[pos : pos + 8]) return dtype, nbytes, pos + 8, False def _mat_v5_next_pos(data_pos: int, nbytes: int, small: bool) -> int: if small: return data_pos + 4 return data_pos + ((nbytes + 7) & ~7) def _read_hdf5_mat_object(obj): if hasattr(obj, "keys"): return {key: _read_hdf5_mat_object(obj[key]) for key in obj.keys()} arr = np.asarray(obj) return arr.T if arr.ndim == 2 else arr def _mat_public_items(obj): if isinstance(obj, dict): return [(k, v) for k, v in obj.items() if not str(k).startswith("__")] if hasattr(obj, "_fieldnames"): return [(name, getattr(obj, name)) for name in obj._fieldnames] if isinstance(obj, np.ndarray) and obj.dtype.names: squeezed = np.squeeze(obj) if squeezed.shape == (): return [(name, squeezed[name].item()) for name in obj.dtype.names] if isinstance(obj, np.void) and obj.dtype.names: return [(name, obj[name].item() if np.asarray(obj[name]).shape == () else obj[name]) for name in obj.dtype.names] return [] def _mat_get(obj, name: str): if isinstance(obj, dict): if name in obj: return obj[name] lower = {str(k).lower(): k for k in obj.keys()} if name.lower() in lower: return obj[lower[name.lower()]] if hasattr(obj, name): return getattr(obj, name) if isinstance(obj, np.ndarray) and obj.dtype.names: squeezed = np.squeeze(obj) if name in obj.dtype.names: return squeezed[name].item() if squeezed.shape == () else squeezed[name] if isinstance(obj, np.void) and obj.dtype.names and name in obj.dtype.names: value = obj[name] return value.item() if np.asarray(value).shape == () else value raise KeyError(f"could not find `{name}` in mesh data") def _find_mat_array(data, names: tuple[str, ...], coercer: Callable, label: str): for name in names: try: return coercer(_mat_get(data, name), name) except (KeyError, ValueError, TypeError): pass for _, value in _mat_public_items(data): try: return _find_mat_array(value, names, coercer, label) except ValueError: pass raise ValueError(f"could not find a valid {label} array in mesh data") def _coerce_vertices(value, name: str = "vertices") -> Array: arr = np.asarray(value, dtype=float) arr = np.squeeze(arr) if arr.ndim != 2: raise ValueError(f"`{name}` is not a 2D vertex array") if arr.shape[1] == 3: out = arr elif arr.shape[0] == 3: out = arr.T else: raise ValueError(f"`{name}` must have shape (nv,3) or (3,nv)") return np.asarray(out, dtype=float) def _coerce_faces(value, name: str = "faces") -> Array: arr = np.asarray(value) if arr.ndim > 2: arr = np.squeeze(arr) if arr.ndim == 1 and arr.size >= 3: arr = arr.reshape(1, -1) if arr.ndim != 2: raise ValueError(f"`{name}` is not a 2D face array") if arr.shape[1] >= 3: out = arr elif arr.shape[0] >= 3: out = arr.T else: raise ValueError(f"`{name}` must contain at least three vertex indices per face") out = np.asarray(out, dtype=float) if not np.all(np.isfinite(out)) or not np.allclose(out, np.round(out)): raise ValueError(f"`{name}` must contain integer vertex indices") return np.asarray(np.round(out), dtype=int)
[docs] def project_to_levelset( points: Array, phi: Callable, grad_phi: Callable, max_iter: int = 30, tol: float = 1.0e-13, damping: float = 1.0, ) -> Array: """ Project points to an implicit surface using normal Newton correction. The update is ``p <- p - damping * phi(p) * grad_phi(p) / |grad_phi(p)|^2``. """ pts = np.asarray(points, dtype=float) original_shape = pts.shape pts = pts.reshape(-1, 3).copy() if not (0.0 < damping <= 1.0): raise ValueError("damping must satisfy 0 < damping <= 1") for row in range(pts.shape[0]): p = pts[row, :].copy() converged = False for _ in range(max_iter): value = float(_call_levelset_scalar(phi, p)) grad = np.asarray(_call_levelset_vector(grad_phi, p), dtype=float).reshape(3) denom = float(np.dot(grad, grad)) if denom <= np.finfo(float).eps: raise RuntimeError("level-set projection encountered a near-zero gradient") correction = value * grad / denom if float(np.linalg.norm(correction)) <= tol: converged = True break p = p - damping * correction final_value = float(_call_levelset_scalar(phi, p)) final_grad = np.asarray(_call_levelset_vector(grad_phi, p), dtype=float).reshape(3) final_denom = float(np.dot(final_grad, final_grad)) final_step = abs(final_value) / np.sqrt(max(final_denom, np.finfo(float).eps)) if not converged and final_step > 10 * tol: raise RuntimeError(f"level-set projection did not converge; correction={final_step:.3e}") pts[row, :] = p return pts.reshape(original_shape)
def normalize_face_indices(faces: Array, nvertices: int, index_base: str | int = "auto") -> Array: """Convert zero- or one-based triangle indices to zero-based indices.""" faces = np.asarray(faces, dtype=int) if index_base == "auto": if faces.size == 0: return faces.copy() if np.min(faces) == 1 and np.max(faces) == nvertices: out = faces - 1 else: out = faces.copy() elif index_base in (0, "zero", "0"): out = faces.copy() elif index_base in (1, "one", "1"): out = faces - 1 else: raise ValueError("index_base must be 'auto', 0, or 1") if np.any(out < 0) or np.any(out >= nvertices): raise ValueError("faces contain vertex indices outside the valid range") return out def _call_levelset_scalar(func: Callable, p: Array): try: return func(p) except TypeError: return func(p[0], p[1], p[2]) def _call_levelset_vector(func: Callable, p: Array): try: return func(p) except TypeError: return func(p[0], p[1], p[2])
[docs] @dataclass class TriangleSurfaceFunction: """Scalar function sampled on a ``TriangleSurfaceMesh``.""" domain: TriangleSurfaceMesh vals: list[Array]
[docs] @staticmethod def from_callable(domain: TriangleSurfaceMesh, func: Callable[[Array, Array, Array], Array]) -> "TriangleSurfaceFunction": vals = [np.asarray(func(x, y, z)).ravel() for x, y, z in zip(domain.x, domain.y, domain.z)] return TriangleSurfaceFunction(domain, vals)
[docs] @staticmethod def constant(domain: TriangleSurfaceMesh, value: float | complex) -> "TriangleSurfaceFunction": return TriangleSurfaceFunction(domain, [value * np.ones_like(x) for x in domain.x])
[docs] def norm_inf(self) -> float: return float(max(np.max(np.abs(v)) for v in self.vals))
[docs] def mean2(self) -> float: return tri_integral2(self) / tri_surfacearea(self.domain)
[docs] def remove_mean(self) -> "TriangleSurfaceFunction": return self - self.mean2()
[docs] def copy(self) -> "TriangleSurfaceFunction": return TriangleSurfaceFunction(self.domain, [v.copy() for v in self.vals])
def __add__(self, other: float | "TriangleSurfaceFunction") -> "TriangleSurfaceFunction": if isinstance(other, TriangleSurfaceFunction): return TriangleSurfaceFunction(self.domain, [a + b for a, b in zip(self.vals, other.vals)]) return TriangleSurfaceFunction(self.domain, [a + other for a in self.vals]) __radd__ = __add__ def __sub__(self, other: float | "TriangleSurfaceFunction") -> "TriangleSurfaceFunction": if isinstance(other, TriangleSurfaceFunction): return TriangleSurfaceFunction(self.domain, [a - b for a, b in zip(self.vals, other.vals)]) return TriangleSurfaceFunction(self.domain, [a - other for a in self.vals]) def __rsub__(self, other: float) -> "TriangleSurfaceFunction": return TriangleSurfaceFunction(self.domain, [other - a for a in self.vals]) def __mul__(self, other: float | "TriangleSurfaceFunction") -> "TriangleSurfaceFunction": if isinstance(other, TriangleSurfaceFunction): return TriangleSurfaceFunction(self.domain, [a * b for a, b in zip(self.vals, other.vals)]) return TriangleSurfaceFunction(self.domain, [a * other for a in self.vals]) __rmul__ = __mul__ def __truediv__(self, other: float | "TriangleSurfaceFunction") -> "TriangleSurfaceFunction": if isinstance(other, TriangleSurfaceFunction): return TriangleSurfaceFunction(self.domain, [a / b for a, b in zip(self.vals, other.vals)]) return TriangleSurfaceFunction(self.domain, [a / other for a in self.vals]) def __rtruediv__(self, other: float) -> "TriangleSurfaceFunction": return TriangleSurfaceFunction(self.domain, [other / a for a in self.vals]) def __pow__(self, power: float) -> "TriangleSurfaceFunction": return TriangleSurfaceFunction(self.domain, [a**power for a in self.vals]) def __abs__(self) -> "TriangleSurfaceFunction": return TriangleSurfaceFunction(self.domain, [np.abs(a) for a in self.vals]) def __neg__(self) -> "TriangleSurfaceFunction": return TriangleSurfaceFunction(self.domain, [-a for a in self.vals])
@dataclass class TriangleLeaf(Patch): """Triangular leaf solution operator.""" Aii_inv: Array | None = None ii_idx: Array | None = None normal_d: Array | None = None rhs_scale: Array | None = None _bc_work: Array | None = None def solve(self, bc: Array | Callable | None = None) -> list[Array]: if bc is None: self._bc_work = get_work_array( self._bc_work, (self.S.shape[1], self.u_part.shape[1]), self.u_part.dtype, ) bc_arr = self._bc_work elif callable(bc): vals = bc(self.xyz[:, 0], self.xyz[:, 1], self.xyz[:, 2]) bc_arr = np.asarray(vals).reshape(-1, 1) else: bc_arr = np.asarray(bc) if bc_arr.ndim == 0: bc_arr = np.full((self.S.shape[1], self.u_part.shape[1]), bc_arr.item()) elif bc_arr.ndim == 1: bc_arr = bc_arr.reshape(-1, 1) u = self.S @ bc_arr + self.u_part return [u[:, j].copy() for j in range(u.shape[1])]
[docs] class TriangleSurfaceOp: """Scalar HPS-style operator for triangular surface patches.""" def __init__( self, domain: TriangleSurfaceMesh, op: dict, rhs: TriangleSurfaceFunction | Callable | float = 0.0, merge_idx: list[list[tuple[int, int | None]]] | None = None, merge_strategy: str = "default", ): self.domain = domain self.op = parse_pdo(op) self.rhs = rhs self.rankdef = False self.merge_strategy = merge_strategy.lower() self.merge_idx = default_merge_idx(domain.npatches) if merge_idx is None else merge_idx self._ii_idx = np.where(np.all(np.asarray(list(_multi_indices(3, domain.degree)), dtype=int) > 0, axis=1))[0] self._num_int = int(self._ii_idx.size) self._rhs_work: Array | None = None self.patches: list[Patch] = initialize_triangle_leaves(self.op, self.domain, self.rhs) self._built = False
[docs] def build(self) -> None: if self._built: return patches = self.patches if self.merge_strategy == "adjacent": while len(patches) > 1: pairs = greedy_adjacent_pairs(patches) next_patches: list[Patch] = [] merged_any = False for a_idx, b_idx in pairs: a = patches[a_idx] if b_idx is None: next_patches.append(a) else: top = len(patches) == 2 next_patches.append(merge_patches(a, patches[b_idx], rankdef=(self.rankdef and top))) merged_any = True if not merged_any: raise RuntimeError("could not find adjacent triangular patches to merge") patches = next_patches elif self.merge_strategy == "default": for level, idx in enumerate(self.merge_idx): top = level == len(self.merge_idx) - 1 next_patches = [] for a_idx, b_idx in idx: a = patches[a_idx] b = None if b_idx is None else patches[b_idx] if b is None: next_patches.append(a) else: next_patches.append(merge_patches(a, b, rankdef=(self.rankdef and top))) patches = next_patches else: raise ValueError("merge_strategy must be 'default' or 'adjacent'") self.patches = patches self._built = True
[docs] def solve(self) -> TriangleSurfaceFunction: self.build() vals = self.patches[0].solve(None) return TriangleSurfaceFunction(self.domain, vals)
[docs] def apply(self, rhs: TriangleSurfaceFunction | Callable | float) -> TriangleSurfaceFunction: """Update the right-hand side and immediately solve.""" self.update_rhs(rhs) return self.solve()
[docs] def update_rhs(self, rhs: TriangleSurfaceFunction | Callable | float) -> "TriangleSurfaceOp": """ Update only the right-hand side of an already built triangular operator. The local inverses, Dirichlet-to-Neumann maps, and merge-tree Schur complements are reused. Only the particular solution is recomputed and propagated through the merge tree. """ if not self._built: self.rhs = rhs self.patches = initialize_triangle_leaves(self.op, self.domain, self.rhs) return self rhs_int = evaluate_triangle_rhs(rhs, self.domain, self._ii_idx, self._num_int, self._rhs_work) self._rhs_work = rhs_int for patch in self.patches: update_triangle_patch_rhs(patch, rhs_int) self.rhs = rhs return self
[docs] def tri_surfaceop( dom: TriangleSurfaceMesh, op: dict, rhs: TriangleSurfaceFunction | Callable | float = 0.0, merge_idx: list[list[tuple[int, int | None]]] | None = None, merge_strategy: str = "default", ) -> TriangleSurfaceOp: """Construct a triangular scalar surface operator.""" return TriangleSurfaceOp(dom, op, rhs, merge_idx=merge_idx, merge_strategy=merge_strategy)
def initialize_triangle_leaves(op, dom: TriangleSurfaceMesh, rhs) -> list[Patch]: n = dom.n degree = dom.degree npatch = dom.npatches num_nodes = n * (n + 1) // 2 alpha = np.asarray(list(_multi_indices(3, degree)), dtype=int) ii_mask = np.all(alpha > 0, axis=1) ee_mask = ~ii_mask ii_idx = np.where(ii_mask)[0] ee_idx = np.where(ee_mask)[0] num_int = ii_idx.size num_bdy = ee_idx.size edge_nodes = triangle_edge_node_indices(n) nskel = max(n - 2, 0) S2L, L2S = triangle_edge_interpolation_matrices(n, ee_idx, edge_nodes, dom.family) rhs_int = evaluate_triangle_rhs(rhs, dom, ii_idx, num_int) coeffs, flags = evaluate_triangle_operator_coefficients(op, dom) matrix_dtype = np.result_type(rhs_int, *(c.dtype for c in coeffs.values())) I = np.eye(num_nodes, dtype=matrix_dtype) leaves: list[Patch] = [] tmp_template = np.zeros((num_nodes, num_bdy + rhs_int.shape[1]), dtype=matrix_dtype) tmp_template[np.ix_(ee_idx, np.arange(num_bdy))] = np.eye(num_bdy) left_skel = np.arange(0, nskel) down_skel = np.arange(nskel, 2 * nskel) hypot_skel = np.arange(2 * nskel, 3 * nskel) for k in range(npatch): Dx = rowscale(dom.ux[k], dom.Du) + rowscale(dom.vx[k], dom.Dv) Dy = rowscale(dom.uy[k], dom.Du) + rowscale(dom.vy[k], dom.Dv) Dz = rowscale(dom.uz[k], dom.Du) + rowscale(dom.vz[k], dom.Dv) A = np.zeros((num_nodes, num_nodes), dtype=matrix_dtype) J = dom.J[k].ravel() if dom.singular[k]: DxJ = Dx @ J DyJ = Dy @ J DzJ = Dz @ J if flags["dxx"]: A += rowscale(coeffs["dxx"][:, k], rowscale(J, Dx @ Dx) - rowscale(DxJ, Dx)) if flags["dyy"]: A += rowscale(coeffs["dyy"][:, k], rowscale(J, Dy @ Dy) - rowscale(DyJ, Dy)) if flags["dzz"]: A += rowscale(coeffs["dzz"][:, k], rowscale(J, Dz @ Dz) - rowscale(DzJ, Dz)) if flags["dxy"]: A += rowscale(coeffs["dxy"][:, k], rowscale(J, Dx @ Dy) - rowscale(DxJ, Dy)) if flags["dyx"]: A += rowscale(coeffs["dyx"][:, k], rowscale(J, Dy @ Dx) - rowscale(DyJ, Dx)) if flags["dyz"]: A += rowscale(coeffs["dyz"][:, k], rowscale(J, Dy @ Dz) - rowscale(DyJ, Dz)) if flags["dzy"]: A += rowscale(coeffs["dzy"][:, k], rowscale(J, Dz @ Dy) - rowscale(DzJ, Dy)) if flags["dxz"]: A += rowscale(coeffs["dxz"][:, k], rowscale(J, Dx @ Dz) - rowscale(DxJ, Dz)) if flags["dzx"]: A += rowscale(coeffs["dzx"][:, k], rowscale(J, Dz @ Dx) - rowscale(DzJ, Dx)) if flags["dx"]: A += rowscale(coeffs["dx"][:, k] * J**2, Dx) if flags["dy"]: A += rowscale(coeffs["dy"][:, k] * J**2, Dy) if flags["dz"]: A += rowscale(coeffs["dz"][:, k] * J**2, Dz) if flags["b"]: A += rowscale(coeffs["b"][:, k] * J**3, I) local_rhs_data = (J[ii_idx] ** 3).reshape(-1, 1) * rhs_int[:, :, k] else: if flags["dxx"]: A += rowscale(coeffs["dxx"][:, k], Dx @ Dx) if flags["dyy"]: A += rowscale(coeffs["dyy"][:, k], Dy @ Dy) if flags["dzz"]: A += rowscale(coeffs["dzz"][:, k], Dz @ Dz) if flags["dxy"]: A += rowscale(coeffs["dxy"][:, k], Dx @ Dy) if flags["dyx"]: A += rowscale(coeffs["dyx"][:, k], Dy @ Dx) if flags["dyz"]: A += rowscale(coeffs["dyz"][:, k], Dy @ Dz) if flags["dzy"]: A += rowscale(coeffs["dzy"][:, k], Dz @ Dy) if flags["dxz"]: A += rowscale(coeffs["dxz"][:, k], Dx @ Dz) if flags["dzx"]: A += rowscale(coeffs["dzx"][:, k], Dz @ Dx) if flags["dx"]: A += rowscale(coeffs["dx"][:, k], Dx) if flags["dy"]: A += rowscale(coeffs["dy"][:, k], Dy) if flags["dz"]: A += rowscale(coeffs["dz"][:, k], Dz) if flags["b"]: A += rowscale(coeffs["b"][:, k], I) local_rhs_data = rhs_int[:, :, k] Aii = A[np.ix_(ii_idx, ii_idx)] Aie = A[np.ix_(ii_idx, ee_idx)] local_rhs = np.hstack((-Aie, local_rhs_data)) Aii_inv = inverse_dense(Aii) S_int = solve_with_inverse(Aii_inv, local_rhs) tmp = tmp_template.copy() tmp[ii_idx, :] = S_int S = tmp[:, :num_bdy] @ S2L u_part = tmp[:, num_bdy:] if dom.singular[k]: dx = L2S @ rowscale(J[ee_idx] ** 2, Dx[ee_idx, :]) dy = L2S @ rowscale(J[ee_idx] ** 2, Dy[ee_idx, :]) dz = L2S @ rowscale(J[ee_idx] ** 2, Dz[ee_idx, :]) Jss = L2S @ (J[ee_idx] ** 3) D2N_scl = [Jss[left_skel], Jss[down_skel], Jss[hypot_skel]] else: dx = L2S @ Dx[ee_idx, :] dy = L2S @ Dy[ee_idx, :] dz = L2S @ Dz[ee_idx, :] D2N_scl = [np.ones(nskel), np.ones(nskel), np.ones(nskel)] NN = triangle_conormals_on_skeleton(dom, k, edge_nodes) normal_d = rowscale(NN[:, 0], dx) + rowscale(NN[:, 1], dy) + rowscale(NN[:, 2], dz) D2N = normal_d @ S du_part = normal_d @ u_part edges = triangle_patch_edges(dom, k, edge_nodes, nskel) xyz = L2S @ np.column_stack((dom.x[k][ee_idx], dom.y[k][ee_idx], dom.z[k][ee_idx])) w = triangle_skeleton_weights(dom, k, L2S, ee_idx) leaves.append( TriangleLeaf( domain=dom, ids=[k], S=S, D2N=D2N, D2N_scl=D2N_scl, u_part=u_part, du_part=du_part, edges=edges, xyz=xyz, w=w, Aii_inv=Aii_inv, ii_idx=ii_idx, normal_d=normal_d, rhs_scale=(J[ii_idx] ** 3 if dom.singular[k] else np.ones_like(J[ii_idx])), ) ) return leaves def evaluate_triangle_rhs(rhs, dom: TriangleSurfaceMesh, ii_idx: Array, num_int: int, out: Array | None = None) -> Array: if isinstance(rhs, TriangleSurfaceFunction): vals = [v[ii_idx] for v in rhs.vals] dtype = np.result_type(*vals) if vals else float out = get_work_array(out, (num_int, 1, dom.npatches), dtype) for k, v in enumerate(vals): out[:, 0, k] = v return out if callable(rhs): vals = [] for k in range(dom.npatches): vals.append(np.asarray(rhs(dom.x[k][ii_idx], dom.y[k][ii_idx], dom.z[k][ii_idx])).ravel()) dtype = np.result_type(*vals) if vals else float out = get_work_array(out, (num_int, 1, dom.npatches), dtype) for k, v in enumerate(vals): out[:, 0, k] = v return out out = get_work_array(out, (num_int, 1, dom.npatches), np.asarray(rhs).dtype) out.fill(rhs) return out def update_triangle_patch_rhs(patch: Patch, rhs_int: Array) -> None: """Recursive right-hand-side update for a triangular HPS merge tree.""" if isinstance(patch, TriangleLeaf): patch_id = patch.ids[0] rhs_k = rhs_int[:, :, patch_id] assert patch.Aii_inv is not None assert patch.ii_idx is not None assert patch.normal_d is not None assert patch.rhs_scale is not None local_rhs = patch.rhs_scale.reshape(-1, 1) * rhs_k num_nodes = patch.domain.x[patch_id].size u_part = np.zeros((num_nodes, local_rhs.shape[1]), dtype=np.result_type(patch.Aii_inv, local_rhs)) u_part[patch.ii_idx, :] = solve_with_inverse(patch.Aii_inv, local_rhs) patch.u_part = u_part patch.du_part = patch.normal_d @ patch.u_part return if not isinstance(patch, Parent): raise TypeError("unknown patch type") assert patch.child1 is not None and patch.child2 is not None assert patch.idx1 is not None and patch.idx2 is not None assert patch.flip1 is not None and patch.flip2 is not None assert patch.scl1 is not None and patch.scl2 is not None assert patch.A_inv is not None and patch.M is not None update_triangle_patch_rhs(patch.child1, rhs_int) update_triangle_patch_rhs(patch.child2, rhs_int) i1, s1 = patch.idx1 i2, s2 = patch.idx2 nrhs = patch.child1.u_part.shape[1] if s1.size: z_part = rowscale(patch.scl2, patch.flip1 @ patch.child1.du_part[s1, :]) + rowscale( patch.scl1, patch.flip2 @ patch.child2.du_part[s2, :], ) patch.u_part = solve_with_inverse(patch.A_inv, z_part) else: patch.u_part = np.zeros((0, nrhs), dtype=patch.child1.u_part.dtype) patch.du_part = ( np.vstack((patch.child1.du_part[i1, :], patch.child2.du_part[i2, :])) + patch.M @ patch.u_part ) def evaluate_triangle_operator_coefficients(op, dom: TriangleSurfaceMesh) -> tuple[dict[str, Array], dict[str, bool]]: """Evaluate scalar or spatially varying PDE coefficients on all triangle nodes.""" coeffs: dict[str, Array] = {} flags: dict[str, bool] = {} num_nodes = dom.n * (dom.n + 1) // 2 for name in op.__dataclass_fields__: value = getattr(op, name) vals = triangle_coefficient_values(value, dom, num_nodes) coeffs[name] = vals flags[name] = bool(np.any(vals != 0)) return coeffs, flags def triangle_coefficient_values(value, dom: TriangleSurfaceMesh, num_nodes: int) -> Array: if isinstance(value, TriangleSurfaceFunction): return np.column_stack(value.vals) if callable(value): cols = [] for k in range(dom.npatches): cols.append(np.asarray(value(dom.x[k], dom.y[k], dom.z[k])).ravel()) return np.column_stack(cols) if np.isscalar(value): dtype = np.asarray(value).dtype out = np.empty((num_nodes, dom.npatches), dtype=dtype) out.fill(value) return out arr = np.asarray(value) if arr.shape == (num_nodes, dom.npatches): return arr if arr.shape == (dom.npatches, num_nodes): return arr.T if arr.size == num_nodes: return np.tile(arr.ravel().reshape(-1, 1), (1, dom.npatches)) raise ValueError("coefficient arrays must be nodal values on the triangular mesh") def triangle_edge_node_indices(n: int) -> list[Array]: degree = n - 1 alpha = np.asarray(list(_multi_indices(3, degree)), dtype=int) edge0 = np.where(alpha[:, 0] == 0)[0] edge1 = np.where(alpha[:, 1] == 0)[0] edge2 = np.where(alpha[:, 2] == 0)[0] edge0 = edge0[np.argsort(alpha[edge0, 1])] edge1 = edge1[np.argsort(alpha[edge1, 0])] edge2 = edge2[np.argsort(alpha[edge2, 0])] return [edge0, edge1, edge2] def triangle_edge_interpolation_matrices(n: int, ee_idx: Array, edge_nodes: list[Array], family: str) -> tuple[Array, Array]: nskel = max(n - 2, 0) if nskel == 0: return np.zeros((ee_idx.size, 0)), np.zeros((0, ee_idx.size)) t_full = node_family_points(n, family) t_skel = 0.5 * (chebpts(nskel, 1) + 1.0) B_full_from_skel = barymat(t_full, t_skel) B_skel_from_full = barymat(t_skel, t_full) node_to_boundary = {int(node): row for row, node in enumerate(ee_idx)} S2L = np.zeros((ee_idx.size, 3 * nskel)) counts = np.zeros(ee_idx.size) L2S = np.zeros((3 * nskel, ee_idx.size)) for e, nodes in enumerate(edge_nodes): rows = np.asarray([node_to_boundary[int(node)] for node in nodes]) cols = np.arange(e * nskel, (e + 1) * nskel) S2L[np.ix_(rows, cols)] += B_full_from_skel counts[rows] += 1.0 L2S[np.ix_(cols, rows)] = B_skel_from_full counts[counts == 0.0] = 1.0 S2L /= counts[:, None] return S2L, L2S def triangle_conormals_on_skeleton(dom: TriangleSurfaceMesh, patch_id: int, edge_nodes: list[Array]) -> Array: n = dom.n nskel = max(n - 2, 0) if nskel == 0: return np.zeros((0, 3)) t_full = node_family_points(dom.n, dom.family) t_skel = 0.5 * (chebpts(nskel, 1) + 1.0) B = barymat(t_skel, t_full) xu = np.column_stack((dom.xu[patch_id], dom.yu[patch_id], dom.zu[patch_id])) xv = np.column_stack((dom.xv[patch_id], dom.yv[patch_id], dom.zv[patch_id])) conormals = [] for e, nodes in enumerate(edge_nodes): if e == 0: tangent = xv[nodes, :] raw = -xu[nodes, :] elif e == 1: tangent = xu[nodes, :] raw = -xv[nodes, :] else: tangent = xu[nodes, :] - xv[nodes, :] raw = xu[nodes, :] + xv[nodes, :] tangent = normalize_rows(tangent) normal = raw - tangent * np.sum(raw * tangent, axis=1, keepdims=True) normal = normalize_rows(normal) conormals.append(normalize_rows(B @ normal)) return np.vstack(conormals) def triangle_patch_edges(dom: TriangleSurfaceMesh, patch_id: int, edge_nodes: list[Array], nskel: int) -> Array: rows = [] for nodes in edge_nodes: a = nodes[0] b = nodes[-1] rows.append( [ dom.x[patch_id][a], dom.y[patch_id][a], dom.z[patch_id][a], dom.x[patch_id][b], dom.y[patch_id][b], dom.z[patch_id][b], nskel, ] ) return np.asarray(rows, dtype=float) def triangle_skeleton_weights(dom: TriangleSurfaceMesh, patch_id: int, L2S: Array, ee_idx: Array) -> Array: nskel = max(dom.n - 2, 0) if nskel == 0: return np.zeros(0) w1d = quadwts(nskel, 1) wskel = np.concatenate((0.5 * w1d, 0.5 * w1d, (np.sqrt(2.0) / 2.0) * w1d)) JJ = L2S @ np.sqrt(np.maximum(dom.J[patch_id][ee_idx], 0.0)) return wskel * JJ def normalize_rows(v: Array) -> Array: nrm = np.linalg.norm(v, axis=1, keepdims=True) return v / np.where(nrm > 0.0, nrm, 1.0) def greedy_adjacent_pairs(patches: list[Patch]) -> list[tuple[int, int | None]]: remaining = set(range(len(patches))) pairs: list[tuple[int, int | None]] = [] while remaining: i = min(remaining) remaining.remove(i) partner = None for j in sorted(remaining): if patches_share_edge(patches[i], patches[j]): partner = j break if partner is None: pairs.append((i, None)) else: remaining.remove(partner) pairs.append((i, partner)) return pairs def patches_share_edge(a: Patch, b: Patch) -> bool: if a.edges.size == 0 or b.edges.size == 0: return False scl = max( float(np.max(np.abs(a.edges[:, :6]))), float(np.max(np.abs(b.edges[:, :6]))), 1.0, ) tol = 1e-8 * scl for ea in a.edges: direct = np.sum(np.abs(b.edges[:, :6] - ea[:6]), axis=1) flipped = np.sum(np.abs(b.edges[:, :6] - ea[[3, 4, 5, 0, 1, 2]]), axis=1) if np.any(direct < tol) or np.any(flipped < tol): return True return False
[docs] def icosphere_tri(n: int, nref: int = 0, family: str = "cheb2") -> TriangleSurfaceMesh: """Convenience wrapper for a high-order triangular icosphere.""" return TriangleSurfaceMesh.icosphere(n, nref=nref, family=family)
[docs] def tri_surfacefun( func: Callable[[Array, Array, Array], Array] | float | list[Array], dom: TriangleSurfaceMesh, ) -> TriangleSurfaceFunction: """Construct a scalar triangular surface function.""" if callable(func): return TriangleSurfaceFunction.from_callable(dom, func) if np.isscalar(func): return TriangleSurfaceFunction.constant(dom, func) return TriangleSurfaceFunction(dom, [np.asarray(v).ravel() for v in func])
[docs] def tri_resample_mesh(dom: TriangleSurfaceMesh, n: int, family: str | None = None) -> TriangleSurfaceMesh: """Resample every triangular patch to order ``n``.""" family_name = dom.family if family is None else _canonical_family_name(family) xpatches, ypatches, zpatches, _ = tri_resampled_patch_geometry(dom, nvis=int(n), family=family_name) return TriangleSurfaceMesh( [x.copy() for x in xpatches], [y.copy() for y in ypatches], [z.copy() for z in zpatches], family=family_name, )
[docs] def tri_resample(u: TriangleSurfaceFunction, n: int, family: str | None = None) -> TriangleSurfaceFunction: """Resample a triangular surface function using the PKD interpolation basis.""" n = int(n) family_name = u.domain.family if family is None else _canonical_family_name(family) dom = tri_resample_mesh(u.domain, n, family=family_name) if n == u.domain.n and family_name == u.domain.family: vals = [val.copy() for val in u.vals] else: B = tri_resample_matrix(u.domain.n, n, u.domain.family, family_name) vals = [B @ val for val in u.vals] return TriangleSurfaceFunction(dom, vals)
[docs] def tri_diff(f: TriangleSurfaceFunction, dim: int = 1) -> TriangleSurfaceFunction: """Tangential Cartesian derivative on triangular patches.""" if dim not in {1, 2, 3}: raise ValueError("dim must be 1, 2, or 3") vals: list[Array] = [] for k, uvals in enumerate(f.vals): dfdu = f.domain.Du @ uvals dfdv = f.domain.Dv @ uvals if dim == 1: du, dv = f.domain.ux[k], f.domain.vx[k] elif dim == 2: du, dv = f.domain.uy[k], f.domain.vy[k] else: du, dv = f.domain.uz[k], f.domain.vz[k] vals.append(du * dfdu + dv * dfdv) return TriangleSurfaceFunction(f.domain, vals)
[docs] def tri_lap(f: TriangleSurfaceFunction) -> TriangleSurfaceFunction: """Surface Laplacian by repeated tangential Cartesian differentiation.""" return tri_diff(tri_diff(f, 1), 1) + tri_diff(tri_diff(f, 2), 2) + tri_diff(tri_diff(f, 3), 3)
[docs] def tri_integral2(f: TriangleSurfaceFunction, reduce: bool = True) -> float | Array: """Surface integral on triangular patches.""" dtype = np.result_type(*f.vals) if f.vals else float per_patch = np.zeros(f.domain.npatches, dtype=dtype) w = f.domain.ref_weights for k, vals in enumerate(f.vals): jac = np.sqrt(np.maximum(f.domain.J[k], 0.0)) per_patch[k] = np.sum(w * jac * vals) total = np.sum(per_patch) return float(np.real(total)) if reduce and np.isrealobj(total) else total if reduce else per_patch
[docs] def tri_surfacearea(dom: TriangleSurfaceMesh) -> float: """Area of a triangular surface mesh.""" return tri_integral2(TriangleSurfaceFunction.constant(dom, 1.0))
[docs] def write_tri_vtu(filename: str, u: TriangleSurfaceFunction, point_name: str = "u", nvis: int | None = None) -> None: """Write a triangular surface function to a ParaView VTU file.""" points_arr, faces_arr, values_arr = triangle_vtk_arrays(u, nvis=nvis) write_ascii_vtu(filename, points_arr, faces_arr, {point_name: values_arr})
[docs] def write_tri_vtp(filename: str, u: TriangleSurfaceFunction, point_name: str = "u", nvis: int | None = None) -> None: """Write a triangular surface function to a ParaView VTP PolyData file.""" points_arr, faces_arr, values_arr = triangle_vtk_arrays(u, nvis=nvis) write_ascii_vtp(filename, points_arr, faces_arr, {point_name: values_arr})
[docs] def write_tri_patch_boundaries_vtp(filename: str, dom: TriangleSurfaceMesh, nvis: int | None = None) -> None: """Write high-order triangular patch boundaries as ParaView PolyData lines.""" segments = tri_patch_boundary_segments(dom, nvis=nvis) write_ascii_vtp_lines(filename, segments)
def triangle_vtk_arrays(u: TriangleSurfaceFunction, nvis: int | None = None) -> tuple[Array, Array, Array]: """Return concatenated points, triangle connectivity, and nodal values.""" if nvis is None: xpatches, ypatches, zpatches = u.domain.x, u.domain.y, u.domain.z vals = u.vals triangles = u.domain.triangles else: xpatches, ypatches, zpatches, vals, nplot = tri_resampled_patch_values(u, nvis=nvis) triangles = trilattice(nplot) points = [] values = [] faces = [] offset = 0 for x, y, z, val in zip(xpatches, ypatches, zpatches, vals): points.append(np.column_stack((x, y, z))) values.append(val) faces.append(triangles + offset) offset += x.size points_arr = np.vstack(points) faces_arr = np.vstack(faces).astype(int) values_arr = np.concatenate(values) return points_arr, faces_arr, values_arr
[docs] def triangle_surface_mesh_arrays(dom: TriangleSurfaceMesh, nvis: int | None = None) -> tuple[Array, Array]: """Return concatenated points and triangle connectivity for a triangular surface mesh.""" if nvis is None: xpatches, ypatches, zpatches = dom.x, dom.y, dom.z triangles = dom.triangles else: xpatches, ypatches, zpatches, nplot = tri_resampled_patch_geometry(dom, nvis=nvis) triangles = trilattice(nplot) points = [] faces = [] offset = 0 for x, y, z in zip(xpatches, ypatches, zpatches): points.append(np.column_stack((x, y, z))) faces.append(triangles + offset) offset += x.size return np.vstack(points), np.vstack(faces).astype(int)
[docs] def triangle_meshio_mesh( obj: TriangleSurfaceMesh | TriangleSurfaceFunction, point_name: str = "u", nvis: int | None = None, ): """Convert a triangular surface mesh or function to a ``meshio.Mesh``.""" import meshio if isinstance(obj, TriangleSurfaceFunction): points_arr, faces_arr, values_arr = triangle_vtk_arrays(obj, nvis=nvis) return meshio.Mesh(points_arr, [("triangle", faces_arr)], point_data={point_name: values_arr}) if isinstance(obj, TriangleSurfaceMesh): points_arr, faces_arr = triangle_surface_mesh_arrays(obj, nvis=nvis) return meshio.Mesh(points_arr, [("triangle", faces_arr)]) raise TypeError("triangle_meshio_mesh expects a TriangleSurfaceMesh or TriangleSurfaceFunction")
[docs] def triangle_boundary_meshio_mesh( dom: TriangleSurfaceMesh, nvis: int | None = None, deduplicate: bool = True, ): """Convert high-order triangular patch boundaries to a ``meshio.Mesh`` with line cells.""" import meshio segments = tri_patch_boundary_segments(dom, nvis=nvis, deduplicate=deduplicate) if not segments: return meshio.Mesh(np.zeros((0, 3)), [("line", np.zeros((0, 2), dtype=int))]) points = np.vstack(segments) lines = np.arange(points.shape[0], dtype=int).reshape(-1, 2) return meshio.Mesh(points, [("line", lines)])
[docs] def write_triangle_meshio( filename: str | Path, obj: TriangleSurfaceMesh | TriangleSurfaceFunction, point_name: str = "u", nvis: int | None = None, ) -> None: """Write a triangular surface mesh or function with ``meshio``.""" mesh = triangle_meshio_mesh(obj, point_name=point_name, nvis=nvis) mesh.write(filename)
[docs] def tri_resampled_patch_values( u: TriangleSurfaceFunction, nvis: int | None = None, family: str | None = None, ) -> tuple[list[Array], list[Array], list[Array], list[Array], int]: """Return patch geometry and values sampled on a display triangle grid.""" dom = u.domain xpatches, ypatches, zpatches, nplot = tri_resampled_patch_geometry(dom, nvis=nvis, family=family) if nplot == dom.n and (family is None or _canonical_family_name(family) == dom.family): vals = u.vals else: family_name = dom.family if family is None else _canonical_family_name(family) B = tri_resample_matrix(dom.n, nplot, dom.family, family_name) vals = [B @ val for val in u.vals] return xpatches, ypatches, zpatches, vals, nplot
[docs] def tri_resampled_patch_geometry( dom: TriangleSurfaceMesh, nvis: int | None = None, family: str | None = None, ) -> tuple[list[Array], list[Array], list[Array], int]: """Return patch geometry sampled on a display triangle grid.""" nplot = dom.n if nvis is None else int(nvis) if nplot < 2: raise ValueError("nvis must be at least 2") family = dom.family if family is None else _canonical_family_name(family) if nplot == dom.n and family == dom.family: return dom.x, dom.y, dom.z, dom.n B = tri_resample_matrix(dom.n, nplot, dom.family, family) xpatches = [B @ x for x in dom.x] ypatches = [B @ y for y in dom.y] zpatches = [B @ z for z in dom.z] return xpatches, ypatches, zpatches, nplot
@lru_cache(maxsize=64) def tri_resample_matrix(nsource: int, ntarget: int, source_family: str = "cheb2", target_family: str = "cheb2") -> Array: """Interpolation matrix from one triangular node set to another.""" source_family = _canonical_family_name(source_family) target_family = _canonical_family_name(target_family) usrc, vsrc = tri_reference_nodes(nsource, family=source_family) utgt, vtgt = tri_reference_nodes(ntarget, family=target_family) degree = nsource - 1 Ksrc, _, _ = koornwinder_pkd(degree, usrc, vsrc) Ktgt, _, _ = koornwinder_pkd(degree, utgt, vtgt) return Ktgt @ np.linalg.solve(Ksrc, np.eye(Ksrc.shape[0]))
[docs] def tri_patch_boundary_segments(dom: TriangleSurfaceMesh, nvis: int | None = None, deduplicate: bool = True) -> list[Array]: """Return line segments following the curved high-order patch boundaries.""" nplot = dom.n if nvis is None else int(nvis) if nplot < 2: raise ValueError("nvis must be at least 2") if nplot == dom.n: xpatches, ypatches, zpatches = dom.x, dom.y, dom.z uref, vref = dom.u, dom.v else: B = tri_resample_matrix(dom.n, nplot, dom.family, dom.family) xpatches = [B @ x for x in dom.x] ypatches = [B @ y for y in dom.y] zpatches = [B @ z for z in dom.z] edge_indices = tri_edge_indices(nplot, dom.family) segments: list[Array] = [] seen: set[tuple[tuple[float, ...], tuple[float, ...]]] = set() for x, y, z in zip(xpatches, ypatches, zpatches): points = np.column_stack((x, y, z)) for idx in edge_indices: for a, b in zip(idx[:-1], idx[1:]): segment = points[[a, b], :] if deduplicate: p0 = tuple(np.round(segment[0], 12)) p1 = tuple(np.round(segment[1], 12)) key = tuple(sorted((p0, p1))) if key in seen: continue seen.add(key) segments.append(segment) return segments
[docs] def tri_edge_indices(n: int, family: str = "cheb2") -> tuple[Array, Array, Array]: """Triangle edge node indices determined from reference coordinates.""" u, v = tri_reference_nodes(n, family=family) tol = 100.0 * np.finfo(float).eps e0 = np.flatnonzero(np.abs(u) <= tol) e1 = np.flatnonzero(np.abs(v) <= tol) e2 = np.flatnonzero(np.abs(u + v - 1.0) <= tol) e0 = e0[np.argsort(v[e0])] e1 = e1[np.argsort(u[e1])] e2 = e2[np.argsort(u[e2])] return e0.astype(int), e1.astype(int), e2.astype(int)
[docs] def tri_wireframe_edge_indices(n: int) -> tuple[Array, Array, Array]: """Triangle edge node indices for patch-boundary wireframes.""" e1 = np.arange(n, dtype=int) e2 = np.cumsum(np.r_[1, np.arange(n, 1, -1)]) - 1 e3 = np.cumsum(np.r_[n, np.arange(n - 1, 0, -1)]) - 1 return e1, e2.astype(int), e3.astype(int)
def write_ascii_vtu(filename: str, points: Array, triangles: Array, point_data: dict[str, Array] | None = None) -> None: """Write a minimal ASCII VTK UnstructuredGrid with triangle cells.""" point_data = {} if point_data is None else point_data points = np.asarray(points, dtype=float) triangles = np.asarray(triangles, dtype=int) npoints = points.shape[0] ncells = triangles.shape[0] offsets = 3 * np.arange(1, ncells + 1) cell_types = np.full(ncells, 5, dtype=int) with open(filename, "w", encoding="utf-8") as fid: fid.write('<?xml version="1.0"?>\n') fid.write('<VTKFile type="UnstructuredGrid" version="0.1" byte_order="LittleEndian">\n') fid.write(" <UnstructuredGrid>\n") fid.write(f' <Piece NumberOfPoints="{npoints}" NumberOfCells="{ncells}">\n') scalar_name = next(iter(point_data), "") fid.write(f' <PointData Scalars="{scalar_name}">\n') for name, data in point_data.items(): data = np.asarray(data).ravel() if np.iscomplexobj(data): _write_data_array(fid, f"{name}_real", np.real(data)) _write_data_array(fid, f"{name}_imag", np.imag(data)) _write_data_array(fid, f"{name}_abs", np.abs(data)) else: _write_data_array(fid, name, data) fid.write(" </PointData>\n") fid.write(" <CellData>\n") fid.write(" </CellData>\n") fid.write(" <Points>\n") _write_data_array(fid, "Points", points, ncomp=3) fid.write(" </Points>\n") fid.write(" <Cells>\n") _write_data_array(fid, "connectivity", triangles.ravel(), dtype="Int64") _write_data_array(fid, "offsets", offsets, dtype="Int64") _write_data_array(fid, "types", cell_types, dtype="UInt8") fid.write(" </Cells>\n") fid.write(" </Piece>\n") fid.write(" </UnstructuredGrid>\n") fid.write("</VTKFile>\n") def write_ascii_vtp(filename: str, points: Array, triangles: Array, point_data: dict[str, Array] | None = None) -> None: """Write a minimal ASCII VTK PolyData surface with triangle polygons.""" point_data = {} if point_data is None else point_data points = np.asarray(points, dtype=float) triangles = np.asarray(triangles, dtype=int) npoints = points.shape[0] npolys = triangles.shape[0] offsets = 3 * np.arange(1, npolys + 1) with open(filename, "w", encoding="utf-8") as fid: fid.write('<?xml version="1.0"?>\n') fid.write('<VTKFile type="PolyData" version="0.1" byte_order="LittleEndian">\n') fid.write(" <PolyData>\n") fid.write(f' <Piece NumberOfPoints="{npoints}" NumberOfVerts="0" NumberOfLines="0" NumberOfStrips="0" NumberOfPolys="{npolys}">\n') scalar_name = next(iter(point_data), "") fid.write(f' <PointData Scalars="{scalar_name}">\n') for name, data in point_data.items(): data = np.asarray(data).ravel() if np.iscomplexobj(data): _write_data_array(fid, f"{name}_real", np.real(data)) _write_data_array(fid, f"{name}_imag", np.imag(data)) _write_data_array(fid, f"{name}_abs", np.abs(data)) else: _write_data_array(fid, name, data) fid.write(" </PointData>\n") fid.write(" <CellData>\n") fid.write(" </CellData>\n") fid.write(" <Points>\n") _write_data_array(fid, "Points", points, ncomp=3) fid.write(" </Points>\n") fid.write(" <Polys>\n") _write_data_array(fid, "connectivity", triangles.ravel(), dtype="Int64") _write_data_array(fid, "offsets", offsets, dtype="Int64") fid.write(" </Polys>\n") fid.write(" </Piece>\n") fid.write(" </PolyData>\n") fid.write("</VTKFile>\n") def write_ascii_vtp_lines(filename: str, segments: list[Array]) -> None: """Write line segments to a minimal ASCII VTK PolyData file.""" if segments: points = np.vstack(segments) nlines = len(segments) else: points = np.zeros((0, 3)) nlines = 0 connectivity = np.arange(points.shape[0], dtype=int) offsets = 2 * np.arange(1, nlines + 1) with open(filename, "w", encoding="utf-8") as fid: fid.write('<?xml version="1.0"?>\n') fid.write('<VTKFile type="PolyData" version="0.1" byte_order="LittleEndian">\n') fid.write(" <PolyData>\n") fid.write(f' <Piece NumberOfPoints="{points.shape[0]}" NumberOfVerts="0" NumberOfLines="{nlines}" NumberOfStrips="0" NumberOfPolys="0">\n') fid.write(" <PointData>\n") fid.write(" </PointData>\n") fid.write(" <CellData>\n") fid.write(" </CellData>\n") fid.write(" <Points>\n") _write_data_array(fid, "Points", points, ncomp=3) fid.write(" </Points>\n") fid.write(" <Lines>\n") _write_data_array(fid, "connectivity", connectivity, dtype="Int64") _write_data_array(fid, "offsets", offsets, dtype="Int64") fid.write(" </Lines>\n") fid.write(" </Piece>\n") fid.write(" </PolyData>\n") fid.write("</VTKFile>\n") def _write_data_array(fid, name: str, data: Array, ncomp: int | None = None, dtype: str = "Float64") -> None: data = np.asarray(data) ncomp_attr = "" if ncomp is None else f' NumberOfComponents="{ncomp}"' fid.write(f' <DataArray type="{dtype}" Name="{name}" format="ascii"{ncomp_attr}>\n') if data.ndim == 2: for row in data: fid.write(" " + " ".join(_format_vtu_value(v) for v in row) + "\n") else: flat = data.ravel() width = 9 if dtype == "Float64" else 16 for start in range(0, flat.size, width): chunk = flat[start : start + width] fid.write(" " + " ".join(_format_vtu_value(v) for v in chunk) + "\n") fid.write(" </DataArray>\n") def _format_vtu_value(value) -> str: if isinstance(value, (np.integer, int)): return str(int(value)) return f"{float(value):.17e}"
[docs] def wireframe( dom: TriangleSurfaceMesh | SurfaceMesh, surface: str = "auto", edges: str = "auto", ax=None, color: str = "k", linestyle: str = "-", linewidth: float = 1.0, surface_color: str = "w", shrink: float = 0.005, nvis: int | None = None, line_offset: float = 0.0, deduplicate: bool = True, visible_only: bool = False, view_vector: Array | None = None, **line_kwargs, ): """Plot high-order patch boundaries.""" import matplotlib.pyplot as plt from mpl_toolkits.mplot3d.art3d import Line3DCollection, Poly3DCollection show_surface = _normalize_show_option(surface, "surface") show_edges = _normalize_show_option(edges, "edges") created_axes = ax is None if ax is None: fig = plt.figure(figsize=(7, 6), facecolor="w") ax = fig.add_subplot(111, projection="3d") else: fig = ax.figure if isinstance(dom, TriangleSurfaceMesh): xpatches, ypatches, zpatches, nplot = tri_resampled_patch_geometry(dom, nvis=nvis) triangles = trilattice(nplot) normals = _triangle_patch_normals(dom, xpatches, ypatches, zpatches) segments = [] seen: set[tuple[tuple[float, ...], tuple[float, ...]]] = set() edge_indices = tri_edge_indices(nplot, dom.family) if view_vector is not None: view = np.asarray(view_vector, dtype=float).ravel() view_norm = np.linalg.norm(view) if view_norm == 0.0: raise ValueError("view_vector must be nonzero") view = view / view_norm else: view = None for x, y, z, normal_arr in zip(xpatches, ypatches, zpatches, normals): base_points = np.column_stack((x, y, z)) points = base_points + line_offset * normal_arr for idx in edge_indices: for a, b in zip(idx[:-1], idx[1:]): if visible_only and view is not None: normal_mid = 0.5 * (normal_arr[a] + normal_arr[b]) if float(np.dot(normal_mid, view)) <= -0.05: continue if deduplicate: base_segment = base_points[[a, b], :] p0 = tuple(np.round(base_segment[0], 12)) p1 = tuple(np.round(base_segment[1], 12)) key = tuple(sorted((p0, p1))) if key in seen: continue seen.add(key) segments.append(points[[a, b], :]) if show_edges != "off" and segments: style = dict(colors=color, linewidths=linewidth, linestyles=linestyle, zorder=10) style.update(line_kwargs) ax.add_collection3d(Line3DCollection(segments, **style)) if show_surface == "on" or (show_surface == "auto" and created_axes): faces = [] for x, y, z, normal_arr in zip(xpatches, ypatches, zpatches, normals): points = np.column_stack((x, y, z)) - shrink * normal_arr faces.extend(points[triangles]) ax.add_collection3d( Poly3DCollection( faces, facecolors=surface_color, edgecolors="none", linewidths=0.0, alpha=1.0, ) ) _set_equal_3d_axes(ax, np.concatenate(xpatches), np.concatenate(ypatches), np.concatenate(zpatches)) elif isinstance(dom, SurfaceMesh): segments = _quad_patch_boundary_segments(dom) normals = _quad_patch_normals(dom) if show_edges != "off" and segments: style = dict(colors=color, linewidths=linewidth, linestyles=linestyle) style.update(line_kwargs) ax.add_collection3d(Line3DCollection(segments, **style)) if show_surface == "on" or (show_surface == "auto" and created_axes): for x, y, z, normal_arr in zip(dom.x, dom.y, dom.z, normals): ax.plot_surface( x - shrink * normal_arr[:, :, 0], y - shrink * normal_arr[:, :, 1], z - shrink * normal_arr[:, :, 2], color=surface_color, edgecolor="none", linewidth=0.0, shade=True, antialiased=True, ) _set_equal_3d_axes(ax, np.concatenate([x.ravel() for x in dom.x]), np.concatenate([y.ravel() for y in dom.y]), np.concatenate([z.ravel() for z in dom.z])) else: raise TypeError("dom must be a SurfaceMesh or TriangleSurfaceMesh") if created_axes: ax.view_init(elev=24, azim=-45) ax.grid(True) return fig, ax
[docs] def plot_wireframe(dom: TriangleSurfaceMesh | SurfaceMesh, *args, **kwargs): """Alias for ``wireframe``.""" return wireframe(dom, *args, **kwargs)
def _normalize_show_option(value: str, name: str) -> str: value = str(value).lower() if value not in {"auto", "on", "off"}: raise ValueError(f"{name} must be 'auto', 'on', or 'off'") return value def _triangle_patch_normals( dom: TriangleSurfaceMesh, xpatches: list[Array], ypatches: list[Array], zpatches: list[Array], ) -> list[Array]: if xpatches is dom.x and ypatches is dom.y and zpatches is dom.z: xu, xv = dom.xu, dom.xv yu, yv = dom.yu, dom.yv zu, zv = dom.zu, dom.zv J = dom.J else: nplot = _triangular_n_from_count(xpatches[0].size) uref, vref = tri_reference_nodes(nplot, family=dom.family) Du, Dv, _ = tri_strong_diffmat(nplot - 1, uref, vref, basis="pkd") xu = [Du @ x for x in xpatches] xv = [Dv @ x for x in xpatches] yu = [Du @ y for y in ypatches] yv = [Dv @ y for y in ypatches] zu = [Du @ z for z in zpatches] zv = [Dv @ z for z in zpatches] J = [(a * a + c * c + e * e) * (b * b + d * d + f * f) - (a * b + c * d + e * f) ** 2 for a, b, c, d, e, f in zip(xu, xv, yu, yv, zu, zv)] normals = [] volume = 0.0 weights = reference_triangle_quadrature_weights(_triangular_n_from_count(xpatches[0].size), dom.family) for x, y, z, a, b, c, d, e, f, jac in zip(xpatches, ypatches, zpatches, xu, xv, yu, yv, zu, zv, J): n0 = c * f - e * d n1 = e * b - a * f n2 = a * d - c * b nrm = np.sqrt(np.maximum(n0 * n0 + n1 * n1 + n2 * n2, 1e-300)) normal_arr = np.column_stack((n0 / nrm, n1 / nrm, n2 / nrm)) normals.append(normal_arr) volume += float(np.sum(((x * normal_arr[:, 0] + y * normal_arr[:, 1] + z * normal_arr[:, 2]) / 3.0) * weights * np.sqrt(np.maximum(jac, 0.0)))) if volume < 0.0: normals = [-normal_arr for normal_arr in normals] return normals def _quad_patch_normals(dom: SurfaceMesh) -> list[Array]: normals = [] volume = 0.0 weights = quadwts(dom.n) W = np.outer(weights, weights) for x, y, z, xu, xv, yu, yv, zu, zv, J in zip(dom.x, dom.y, dom.z, dom.xu, dom.xv, dom.yu, dom.yv, dom.zu, dom.zv, dom.J): n0 = yu * zv - zu * yv n1 = zu * xv - xu * zv n2 = xu * yv - yu * xv nrm = np.sqrt(np.maximum(n0 * n0 + n1 * n1 + n2 * n2, 1e-300)) normal_arr = np.stack((n0 / nrm, n1 / nrm, n2 / nrm), axis=2) normals.append(normal_arr) volume += float(np.sum(((x * normal_arr[:, :, 0] + y * normal_arr[:, :, 1] + z * normal_arr[:, :, 2]) / 3.0) * W * np.sqrt(np.maximum(J, 0.0)))) if volume < 0.0: normals = [-normal_arr for normal_arr in normals] return normals def _quad_patch_boundary_segments(dom: SurfaceMesh) -> list[Array]: segments = [] for x, y, z in zip(dom.x, dom.y, dom.z): edge_points = ( np.column_stack((x[:, 0], y[:, 0], z[:, 0])), np.column_stack((x[:, -1], y[:, -1], z[:, -1])), np.column_stack((x[0, :], y[0, :], z[0, :])), np.column_stack((x[-1, :], y[-1, :], z[-1, :])), ) for points in edge_points: segments.extend(points[[i, i + 1], :] for i in range(points.shape[0] - 1)) return segments def _set_equal_3d_axes(ax, x: Array, y: Array, z: Array) -> None: mins = np.array([np.min(x), np.min(y), np.min(z)], dtype=float) maxs = np.array([np.max(x), np.max(y), np.max(z)], dtype=float) center = 0.5 * (mins + maxs) radius = 0.5 * float(np.max(maxs - mins)) if radius == 0.0: radius = 1.0 ax.set_xlim(center[0] - radius, center[0] + radius) ax.set_ylim(center[1] - radius, center[1] + radius) ax.set_zlim(center[2] - radius, center[2] + radius) ax.set_box_aspect((1, 1, 1))
[docs] def plot_tri_surface( u: TriangleSurfaceFunction, title: str = "", vmin: float | None = None, vmax: float | None = None, colorbar: bool = True, ) -> None: """Basic Matplotlib visualization of a triangular surface function.""" import matplotlib.pyplot as plt from matplotlib.cm import ScalarMappable from matplotlib.colors import Normalize from mpl_toolkits.mplot3d.art3d import Poly3DCollection fig = plt.figure(figsize=(7, 6)) ax = fig.add_subplot(111, projection="3d") plot_vals = [np.real(v) if np.iscomplexobj(v) else v for v in u.vals] if vmin is None: vmin = min(float(np.min(v)) for v in plot_vals) if vmax is None: vmax = max(float(np.max(v)) for v in plot_vals) norm_obj = Normalize(vmin=vmin, vmax=vmax) cmap = plt.cm.turbo for x, y, z, val in zip(u.domain.x, u.domain.y, u.domain.z, plot_vals): points = np.column_stack((x, y, z)) faces = points[u.domain.triangles] face_values = np.mean(val[u.domain.triangles], axis=1) collection = Poly3DCollection( faces, facecolors=cmap(norm_obj(face_values)), edgecolors=(0, 0, 0, 0.18), linewidths=0.12, ) ax.add_collection3d(collection) allx = np.concatenate(u.domain.x) ally = np.concatenate(u.domain.y) allz = np.concatenate(u.domain.z) ax.set_xlim(float(np.min(allx)), float(np.max(allx))) ax.set_ylim(float(np.min(ally)), float(np.max(ally))) ax.set_zlim(float(np.min(allz)), float(np.max(allz))) ax.set_title(title) ax.set_axis_off() ax.set_box_aspect((1, 1, 1)) if colorbar: mappable = ScalarMappable(norm=norm_obj, cmap=cmap) mappable.set_array([]) fig.colorbar(mappable, ax=ax, shrink=0.75, pad=0.02) plt.show()
def _icosahedron() -> tuple[Array, Array]: phi = (1.0 + np.sqrt(5.0)) / 2.0 vertices = np.array( [ [-1, phi, 0], [1, phi, 0], [-1, -phi, 0], [1, -phi, 0], [0, -1, phi], [0, 1, phi], [0, -1, -phi], [0, 1, -phi], [phi, 0, -1], [phi, 0, 1], [-phi, 0, -1], [-phi, 0, 1], ], dtype=float, ) vertices /= np.linalg.norm(vertices[0]) faces = np.array( [ [0, 11, 5], [0, 5, 1], [0, 1, 7], [0, 7, 10], [0, 10, 11], [1, 5, 9], [5, 11, 4], [11, 10, 2], [10, 7, 6], [7, 1, 8], [3, 9, 4], [3, 4, 2], [3, 2, 6], [3, 6, 8], [3, 8, 9], [4, 9, 5], [2, 4, 11], [6, 2, 10], [8, 6, 7], [9, 8, 1], ], dtype=int, ) return vertices, faces def _subdivide_icosphere(vertices: Array, faces: Array) -> tuple[Array, Array]: vertices_list = [v.copy() for v in vertices] midpoint_cache: dict[tuple[int, int], int] = {} def midpoint(i: int, j: int) -> int: key = tuple(sorted((int(i), int(j)))) if key in midpoint_cache: return midpoint_cache[key] p = 0.5 * (vertices_list[key[0]] + vertices_list[key[1]]) p /= np.linalg.norm(p) midpoint_cache[key] = len(vertices_list) vertices_list.append(p) return midpoint_cache[key] new_faces = [] for a, b, c in faces: ab = midpoint(a, b) bc = midpoint(b, c) ca = midpoint(c, a) new_faces.extend(([a, ab, ca], [b, bc, ab], [c, ca, bc], [ab, bc, ca])) return np.asarray(vertices_list), np.asarray(new_faces, dtype=int) def _orient_faces_outward(vertices: Array, faces: Array) -> Array: out = faces.copy() for k, (a, b, c) in enumerate(out): p0, p1, p2 = vertices[[a, b, c]] normal = np.cross(p1 - p0, p2 - p0) if np.dot(normal, p0 + p1 + p2) < 0.0: out[k, 1], out[k, 2] = out[k, 2], out[k, 1] return out