"""Shape factories for the ``QuadMesh`` rung -- the ones owning a *shape model* rather
than being generic over any input."""
from __future__ import annotations
from collections.abc import Mapping, Sequence
from typing import Callable
import numpy as np
from .._typing import (
FloatArray,
IntArray,
Point,
PointArray,
StrArray,
Vec3,
)
from ..core import conform, surfaces
from ..core.fields import gll_nodes, validate_layers
from ..core.interp import coons_grid, coons_grid_fn
from ..core.surfaces import SurfaceCurve, SurfaceMap
from ..linemesh import LineMesh
from ..linemesh.assemble import loft as line_loft
from ..linemesh.assemble import loft_fn as line_loft_fn
from ..linemesh.morph import reverse as line_reverse
from ..linemesh.shape import line
from ..tags import Tags
from ._helpers import Overlay, _check_boundary, _elevate, entities_from_blocks
from .assemble import loft_fn, merge
from .lift import from_grid
from .quadmesh import NO_TAG, QuadMesh
from .query import boundary_edges
from .tag import retag_element, tag_edges
def _named(qm: QuadMesh, element_tags: str | Tags | None) -> QuadMesh:
return qm if element_tags is None else retag_element(qm, element_tags)
#: The four sides of a :func:`structured <nekmeshpy.quadmesh.shape.structured>` patch, in the CCW loop order its ``edges``
#: are consumed in. Both the ``edges`` mapping and ``side_tags`` are keyed by these.
_SIDES = ("bottom", "right", "top", "left")
def _seg_tags(curve: LineMesh) -> list[str] | None:
"""``curve``'s element tags densified to a ``list[str]``, or ``None`` if every
element is untagged (so an untagged curve stays untagged)."""
if not curve.element_tags:
return None
return [str(x) for x in curve.element_tags.to_dense().tolist()]
def _ordered_sides(edges: Sequence[LineMesh] | Mapping[str, LineMesh],
) -> list[LineMesh]:
"""``edges`` as the positional ``[bottom, right, top, left]`` list :func:`structured
<nekmeshpy.quadmesh.shape.structured>` works in, accepting either spelling."""
if isinstance(edges, Mapping):
missing = [s for s in _SIDES if s not in edges]
extra = [s for s in edges if s not in _SIDES]
if missing or extra:
raise ValueError(
"structured edges mapping must have exactly the keys "
"bottom/right/top/left (missing %s, unexpected %s)"
% (missing or "none", extra or "none"))
return [edges[s] for s in _SIDES]
return list(edges)
[docs]
def rectangle(corners: PointArray | Sequence[Point], nx: int, ny: int, *,
x_frac: FloatArray | None = None,
y_frac: FloatArray | None = None,
side_tags: Mapping[str, str] | None = None,
element_tags: str | Tags | None = None,
order: int = 1) -> QuadMesh:
"""Structured quad grid over the rectangle with four CCW corners ``corners = [c0,
c1, c2, c3]``: ``nx`` cells along ``c0->c1`` (bottom/top), ``ny`` along ``c1->c2``
(left/right). ``x_frac`` / ``y_frac`` are read along ``c0->c1`` / ``c1->c2``; the
opposite sides run the other way and take them mirrored, so grading stays aligned."""
c = np.asarray(corners, dtype=float).reshape(-1, 3)
if c.shape[0] != 4:
raise ValueError("rectangle needs exactly 4 corners")
xf = (np.linspace(0.0, 1.0, nx + 1) if x_frac is None
else np.asarray(x_frac, dtype=float).ravel())
yf = (np.linspace(0.0, 1.0, ny + 1) if y_frac is None
else np.asarray(y_frac, dtype=float).ravel())
st = side_tags or {}
xr = xf if x_frac is None else 1.0 - xf[::-1]
yr = yf if y_frac is None else 1.0 - yf[::-1]
specs = (("bottom", c[0], c[1], xf), ("right", c[1], c[2], yf),
("top", c[2], c[3], xr), ("left", c[3], c[0], yr))
edges = [line(a, b, frac, element_tag=st.get(side, ""), order=order)
for side, a, b, frac in specs]
return structured(edges, element_tags=element_tags)
def _chain_intervals(edge: LineMesh, name: str) -> IntArray:
"""``(L,)`` map from the edge's line index to the point interval ``i`` it spans."""
lines: IntArray = np.asarray(edge.lines, dtype=np.int64).reshape(-1, 2)
lo: IntArray = np.minimum(lines[:, 0], lines[:, 1])
hi: IntArray = np.maximum(lines[:, 0], lines[:, 1])
n = edge.points.shape[0] - 1
if (lines.shape[0] != n or not np.array_equal(hi, lo + 1)
or not np.array_equal(np.sort(lo), np.arange(n, dtype=np.int64))):
raise ValueError(
"%s must be a simple consecutive chain (line k joining points k and k+1) "
"-- its point order is what sets the node distribution; build it with "
"LineMesh.loft/line/arc/loft_fn (or merge chains end to end) rather than "
"re-indexing its lines" % name)
return lo
def _grid_quads(ni: int, nj: int) -> IntArray:
"""The ``(ni*nj, 4)`` CCW quad table of an ``(ni+1) x (nj+1)`` structured point grid
numbered ``id(i, j) = i*(nj+1) + j``, in i-major / j-minor element order (quad ``(i,
j)`` is row ``i*nj + j``)."""
row = nj + 1
i: IntArray = np.repeat(np.arange(ni, dtype=np.int64), nj)
j: IntArray = np.tile(np.arange(nj, dtype=np.int64), ni)
return np.stack([i * row + j, (i + 1) * row + j,
(i + 1) * row + j + 1, i * row + j + 1], axis=1)
def _refined_params(n: int, order: int) -> FloatArray:
"""The ``n*order+1`` transfinite parameters of an ``n``-interval edge at order N."""
if order == 1:
return np.linspace(0.0, 1.0, n + 1)
g = gll_nodes(order)
inner: FloatArray = (np.arange(n, dtype=float)[:, None] + g[None, :order]).ravel()
return np.concatenate([inner / n, [1.0]])
def _refined_chain(edge: LineMesh, name: str) -> PointArray:
"""An edge's own nodes in point-index order: ``(n*order+1, 3)``."""
pts: PointArray = edge.points
order = edge.order
if order == 1:
return pts
lo = _chain_intervals(edge, name)
lines: IntArray = np.asarray(edge.lines, dtype=np.int64).reshape(-1, 2)
inner: PointArray = edge.interior # (L, order-1, 3)
out: PointArray = np.empty(((pts.shape[0] - 1) * order + 1, 3), dtype=float)
out[::order] = pts
for k in range(lines.shape[0]):
i = int(lo[k])
blk = inner[k] if lines[k, 0] == i else inner[k][::-1]
out[i * order + 1:i * order + order] = blk
return out
def _straight_interior(pos: PointArray, lines: IntArray,
order: int) -> PointArray:
"""``(L,order-1,3)`` straight GLL interiors of the chain ``lines`` over ``pos``."""
g = gll_nodes(order)[1:order]
a, b = pos[lines[:, 0]], pos[lines[:, 1]]
return a[:, None, :] + g[None, :, None] * (b - a)[:, None, :]
def _blended_ring(pos: PointArray, wall: LineMesh, tau: float,
peri_pos: PointArray, peri_inner: PointArray) -> LineMesh:
"""The O-ring curve at radial fraction ``tau``, as a ``LineMesh`` to overlay."""
lines: IntArray = wall.lines
order = wall.order
if order == 1:
return LineMesh(pos, lines)
inner: PointArray = (1.0 - tau) * peri_inner + tau * wall.interior
drift: PointArray = pos - ((1.0 - tau) * peri_pos + tau * wall.points)
g = gll_nodes(order)[1:order]
d0, d1 = drift[lines[:, 0]], drift[lines[:, 1]]
inner = (inner + (1.0 - g)[None, :, None] * d0[:, None, :]
+ g[None, :, None] * d1[:, None, :])
return LineMesh(pos, lines, interior=inner)
def _ring_overlays(ring_pts: Sequence[PointArray], wall: LineMesh,
radial: FloatArray, peri_pos: PointArray, q0: int,
width: int, outer_side: int) -> list[Overlay]:
"""Both incident copies of every O-ring curve, as ``_elevate`` overlays."""
inner_side = 3 if outer_side == 1 else 1
peri_inner = _straight_interior(peri_pos, wall.lines, wall.order)
ks: IntArray = np.arange(width, dtype=np.int64)
nr = len(ring_pts)
overlays: list[Overlay] = []
for r, pts in enumerate(ring_pts):
ring_lm = _blended_ring(pts, wall, float(radial[r + 1]),
peri_pos, peri_inner)
overlays.append((q0 + r * width + ks, outer_side, ring_lm))
if r + 1 < nr:
overlays.append((q0 + (r + 1) * width + ks, inner_side, ring_lm))
return overlays
def _curve_rows(rows: Sequence[tuple[int, int]], curve: LineMesh,
override: str) -> tuple[IntArray, StrArray]:
"""``((K,2) (quad, side) rows, their names)`` naming one side of a region.
Left in element-local terms because that is what a region fill knows; ``tag_edges``
resolves each row to the shared edge it points at."""
seg = _seg_tags(curve)
names = [override if override else (seg[m] if seg is not None else "")
for m in range(len(rows))]
return (np.asarray(rows, dtype=np.int64).reshape(-1, 2),
np.asarray(names, dtype=np.str_))
def _sub_chain(chain: LineMesh, segs: IntArray, seg2line: IntArray) -> LineMesh:
"""The sub-``LineMesh`` of ``chain`` holding point intervals ``segs``, in that
order."""
idx: IntArray = seg2line[np.asarray(segs, dtype=np.int64)]
return LineMesh(chain.points, chain.lines[idx],
interior=chain.interior[idx])
def _slice_chain(chain: LineMesh, a: int, b: int) -> LineMesh:
"""Points ``[a, b]`` of ``chain`` as their own ``LineMesh`` -- interior nodes and
per-segment tags carried verbatim, nothing resampled. ``chain`` may be an open
chain (``b < len(points)``) or a closed loop being cut at its wrap (``b ==
len(points)``, closing through point 0)."""
pts = chain.points
M = pts.shape[0]
seg = _seg_tags(chain)
if b < M:
p, interior = pts[a:b + 1], chain.interior[a:b]
tags = None if seg is None else seg[a:b]
else:
p = np.vstack([pts[a:M], pts[0:1]])
interior = chain.interior[a:M]
tags = None if seg is None else seg[a:M]
lm = line_loft(p, interior=interior, order=chain.order)
if tags is None:
return lm
return LineMesh(lm.point_mesh, lm.lines, lm.interior, Tags.from_dense(tags))
[docs]
def structured(edges: Sequence[LineMesh] | Mapping[str, LineMesh], *,
side_tags: Mapping[str, str] | None = None,
element_tags: str | Tags | None = None) -> QuadMesh:
"""Transfinite (Coons-patch) quad grid over the surface bounded by four open edge
lines ``edges = [bottom, right, top, left]`` in CCW loop order. The lines must share
corners (form a closed loop)."""
edge_list = _ordered_sides(edges)
if len(edge_list) != 4:
raise ValueError("structured needs exactly 4 edge lines "
"[bottom, right, top, left]")
bottom, right, top, left = edge_list
for nm, e in (("bottom", bottom), ("right", right),
("top", top), ("left", left)):
_check_boundary(e, "structured " + nm + " edge", 2)
order = bottom.order
if any(e.order != order for e in edge_list):
raise ValueError("structured: all four edges must share the same order")
# resolution comes from the edges' own point counts (no resampling)
if bottom.points.shape[0] != top.points.shape[0]:
raise ValueError(
"structured: bottom and top edges must have equal point counts "
"(got %d, %d); resample them to the same nx+1 first"
% (bottom.points.shape[0], top.points.shape[0]))
if left.points.shape[0] != right.points.shape[0]:
raise ValueError(
"structured: left and right edges must have equal point counts "
"(got %d, %d); resample them to the same ny+1 first"
% (left.points.shape[0], right.points.shape[0]))
nx = bottom.points.shape[0] - 1
ny = left.points.shape[0] - 1
# the four edges must close into a loop (share corners) in CCW order
allpts = np.vstack([e.points for e in edge_list])
scale = conform.bbox_scale(allpts)
tol = 1e-6 * scale if scale > 0 else 1e-9
for lbl, p, q in (("bottom->right", bottom.points[-1], right.points[0]),
("right->top", right.points[-1], top.points[0]),
("top->left", top.points[-1], left.points[0]),
("left->bottom", left.points[-1], bottom.points[0])):
gap = float(np.linalg.norm(p - q))
if gap > tol:
raise ValueError(
"structured: edges must form a closed loop in CCW order "
"[bottom, right, top, left] with shared corners; "
"gap %.3g at %s" % (gap, lbl))
# Sample the transfinite map at the order-N lattice: at order 1 that is the plain
# corner grid, at order N the GLL-refined one. Every node -- interior edges and
# element interiors included -- therefore lands on the true Coons surface spanned
# by the four edges, instead of on a straight subdivision of a corner-only grid.
# Both edge families are oriented to run with the lattice: bottom/top c0->c1 and
# c3->c2 along u, left/right c0->c3 and c1->c2 along v, so ``top``/``left`` (stored
# c2->c3 / c3->c0) are reversed.
S = coons_grid(_refined_chain(bottom, "structured bottom edge"),
_refined_chain(top, "structured top edge")[::-1],
_refined_chain(left, "structured left edge")[::-1],
_refined_chain(right, "structured right edge"),
_refined_params(nx, order), _refined_params(ny, order))
points = S[::order, ::order].reshape(-1, 3) # id(i,j) = i*(ny+1) + j
# quads in i-major / j-minor order (i in [0,nx), j in [0,ny))
quads = _grid_quads(nx, ny)
# boundary edges as [quad id, side]; quad id = i*ny + j (i-major). With
# v0=(i,j) v1=(i+1,j) v2=(i+1,j+1) v3=(i,j+1): bottom (j=0) is side 1,
# right (i=nx-1) side 2, top (j=ny-1) side 3, left (i=0) side 4.
side_rows: dict[str, list[tuple[int, int]]] = {
"bottom": [(i * ny + 0, 1) for i in range(nx)],
"right": [((nx - 1) * ny + j, 2) for j in range(ny)],
"top": [(i * ny + (ny - 1), 3) for i in range(nx)],
"left": [(0 * ny + j, 4) for j in range(ny)],
}
side_edges = {"bottom": bottom, "right": right, "top": top, "left": left}
bt = side_tags or {}
for side in bt:
if side not in side_rows:
raise ValueError("structured side_tags side must be one of "
"bottom/right/top/left, got %r" % side)
# each side is named by its edge's uniform element tag; a non-empty
# side_tags[side] overrides, a present-but-empty entry suppresses it.
rows_all: list[tuple[int, int]] = []
names_all: list[str] = []
for side, rows in side_rows.items():
if side in bt:
nm = bt[side]
else:
et = side_edges[side].element_group_tags
nm = et[0] if len(et) == 1 else ""
if not nm: # NO_TAG / "" / untagged -> no row
continue
rows_all.extend(rows)
names_all.extend([nm] * len(rows))
qm = tag_edges(QuadMesh.from_corners(points, quads),
np.asarray(rows_all, dtype=np.int64).reshape(-1, 2),
np.asarray(names_all, dtype=np.str_))
if order > 1:
# Every node already exists in ``S``; cut it into per-element blocks and let
# the B-rep tables fall out. No overlay is needed -- the boundary rows of the
# lattice *are* the edges' own nodes, so the walls are exact for free, and
# unlike a stamp-the-walls-on elevation the interior edges are curved too.
# Quad (i,j) = quad id i*ny + j spans lattice rows i*order..(i+1)*order and
# columns j*order..(j+1)*order; its local (i,j) axes are the global ones.
qi: IntArray = np.repeat(np.arange(nx, dtype=np.int64), ny)
qj: IntArray = np.tile(np.arange(ny, dtype=np.int64), nx)
a: IntArray = np.arange(order + 1, dtype=np.int64)
blk: PointArray = S[(qi[:, None, None] * order + a[None, :, None]),
(qj[:, None, None] * order + a[None, None, :])]
# (Q,a,b,3) -> lexicographic (i fastest) slot b*(order+1) + a
blocks: PointArray = blk.transpose(0, 2, 1, 3).reshape(
quads.shape[0], (order + 1) ** 2, 3)
lm, elem_edges, flip, interior = entities_from_blocks(
blocks, quads, points, order)
# ``quads`` is unchanged, so the rebuilt edge table pairs with the old one
# through the same (quad, side) rows the tags were authored in
qm = tag_edges(QuadMesh(lm, elem_edges, flip, interior, qm.element_tags),
np.asarray(rows_all, dtype=np.int64).reshape(-1, 2),
np.asarray(names_all, dtype=np.str_))
return _named(qm, element_tags)
[docs]
def ogrid(boundary: LineMesh, n_side: int, radial: int | FloatArray, *,
center_scale: float = 0.7, quadrant_scale: float = 0.7,
wall_tag: str = "", element_tags: str | Tags | None = None) -> QuadMesh:
"""O-grid filling the closed ``boundary``: four :func:`quadrant_ogrid
<nekmeshpy.quadmesh.shape.quadrant_ogrid>` quarters meeting at the loop's centroid,
split at the loop's own quarter points. ``center_scale`` places each quarter's own
hub corner; ``quadrant_scale`` places the seam corner shared between neighbours (via
:func:`quadrant_seam_fractions <nekmeshpy.quadmesh.shape.quadrant_seam_fractions>`)
-- see there for what each one does."""
if n_side < 1:
raise ValueError("ogrid needs n_side >= 1")
if n_side % 2 != 0:
raise ValueError(
"ogrid needs an even n_side (it is built from four quadrant_ogrid "
"quarters, each needing a wall midpoint), got n_side=%d" % n_side)
bpts = _check_boundary(boundary, "ogrid boundary", 3)
# wall ring = the boundary loop itself, meshed exactly: it must already carry
# P = 4*n_side points (the caller sizes it, e.g. circle(R, 4*n_side)), split at
# its quarter points into the four quadrant arcs.
P = 4 * n_side
if bpts.shape[0] != P:
raise ValueError(
"ogrid boundary must have exactly 4*n_side = %d points to be meshed "
"exactly (got %d); size the loop to match, e.g. circle(R, %d)"
% (P, bpts.shape[0], P))
centroid = bpts.mean(axis=0)
rad = float(np.mean(np.linalg.norm(bpts - centroid, axis=1)))
if rad <= 0.0:
raise ValueError("ogrid: boundary is degenerate (all points coincide)")
n = n_side // 2
radial = validate_layers(radial)
fr = quadrant_seam_fractions(n, radial, quadrant_scale)
starts = [0, n_side, 2 * n_side, 3 * n_side]
arcs = [_slice_chain(boundary, a, b)
for a, b in zip(starts, starts[1:] + [P])]
seams = [line(centroid, a.points[0], fr, order=boundary.order) for a in arcs]
seams.append(seams[0])
return _named(merge([quadrant_ogrid(arcs[q], seams[q], seams[q + 1], radial,
center_scale=center_scale, wall_tag=wall_tag)
for q in range(4)]), element_tags)
[docs]
def half_ogrid(arc: LineMesh, spine: LineMesh,
radial: int | FloatArray, *, center_scale: float = 0.7,
quadrant_scale: float = 0.7, wall_tag: str = "",
element_tags: str | Tags | None = None) -> QuadMesh:
"""Structured half-circle O-grid over a half-disk split along the ``spine`` line
(A1..A2); the wall ``arc`` (``(4*Ntheta+1, 3)``, arc[0]=A1, arc[-1]=A2) is the open
boundary. Built as two :func:`quadrant_ogrid
<nekmeshpy.quadmesh.shape.quadrant_ogrid>` quarters split at the arc's own apex (its
midpoint, directly opposite the spine) and merged there: the spine's own [north
caps, center fan, south caps] halves become the ``O -> A1`` / ``O -> A2`` seams
verbatim (nothing resampled, same as the rest of ``spine``), and a fresh ``O ->
apex`` seam (via :func:`quadrant_seam_fractions
<nekmeshpy.quadmesh.shape.quadrant_seam_fractions>`) is the only new geometry.
``center_scale``/``quadrant_scale`` are as there."""
apts = _check_boundary(arc, "half_ogrid arc", 5) # (na,3) backing array
na = apts.shape[0]
if (na - 1) % 4 != 0:
raise ValueError("half_ogrid: arc must have 4*Ntheta+1 points (Ntheta >= 1)")
Nt = (na - 1) // 4
radial = validate_layers(radial)
Nr = radial.size - 1
# spine is meshed exactly: its points must be sampled monotonically along the
# diameter, A1 (fraction 0) -> A2 (fraction 1) -- Nr north caps, then the 2Nt+1
# center fan, then Nr south caps (see half_ogrid docstring for the fractions).
sp = _check_boundary(spine, "half_ogrid spine", 2)
n_spine = 2 * Nt + 1 + 2 * Nr
if sp.shape[0] != n_spine:
raise ValueError(
"half_ogrid spine must have exactly 2*Ntheta+1 + 2*Nradial = %d points "
"(got %d); sample the spine curve monotonically A1 -> A2 as [north caps, "
"center fan, south caps] (see half_ogrid docstring)"
% (n_spine, sp.shape[0]))
if spine.order != arc.order:
raise ValueError(
"half_ogrid spine and arc must share an order (got spine order %d, arc "
"order %d); the spine's own nodes are the seam geometry, so a lower-order "
"spine cannot describe a higher-order seam"
% (spine.order, arc.order))
o_idx = Nr + Nt # the spine's own midpoint, O
seam_a1 = line_reverse(_slice_chain(spine, 0, o_idx)) # O -> A1, exact nodes
seam_a2 = _slice_chain(spine, o_idx, sp.shape[0] - 1) # O -> A2, exact nodes
fr = quadrant_seam_fractions(Nt, radial, quadrant_scale)
seam_apex = line(sp[o_idx], apts[2 * Nt], fr, order=arc.order)
arc_a = _slice_chain(arc, 0, 2 * Nt) # A1 -> apex
arc_b = _slice_chain(arc, 2 * Nt, na - 1) # apex -> A2
qa = quadrant_ogrid(arc_a, seam_a1, seam_apex, radial,
center_scale=center_scale, wall_tag=wall_tag)
qb = quadrant_ogrid(arc_b, seam_apex, seam_a2, radial,
center_scale=center_scale, wall_tag=wall_tag)
return _named(merge([qa, qb]), element_tags)
#: The two nameable seams of a :func:`quadrant_ogrid <nekmeshpy.quadmesh.shape.quadrant_ogrid>`, in the order its arguments
#: are taken. ``side_tags`` is keyed by these.
_QUADRANT_SEAMS = ("seam1", "seam2")
[docs]
def quadrant_core(arc: LineMesh, seam1: LineMesh, seam2: LineMesh, *,
center_scale: float = 0.5) -> PointArray:
"""The core quarter of a :func:`quadrant_ogrid
<nekmeshpy.quadmesh.shape.quadrant_ogrid>` as an ``(n+1, n+1, 3)`` grid, indexed
``[i][j]`` with ``i`` running ``O -> M1`` along ``seam1`` and ``j`` running ``O ->
M2`` along ``seam2``."""
apts = _check_boundary(arc, "quadrant_core arc", 3)
na = apts.shape[0]
if na < 3 or (na - 1) % 2 != 0:
raise ValueError(
"quadrant_core arc must have 2*n+1 points (n >= 1), got %d" % na)
n = (na - 1) // 2
if not 0.0 < center_scale < 1.0:
raise ValueError("quadrant_core needs center_scale in (0, 1)")
s1 = _check_boundary(seam1, "quadrant_core seam1", n + 1)
s2 = _check_boundary(seam2, "quadrant_core seam2", n + 1)
o: Point = s1[0]
m1, m2 = s1[n], s2[n]
k: Point = o + center_scale * (apts[n] - o)
t: FloatArray = np.arange(n + 1, dtype=float) / n
return coons_grid(s1[0:n + 1], m2 + t[:, None] * (k - m2), # j = 0 / j = n
s2[0:n + 1], m1 + t[:, None] * (k - m1), t, t) # i = 0 / i = n
[docs]
def quadrant_ogrid(arc: LineMesh, seam1: LineMesh, seam2: LineMesh,
radial: int | FloatArray, *, center_scale: float = 0.5,
wall_tag: str = "", element_tag: str = NO_TAG,
side_tags: Mapping[str, str] | None = None) -> QuadMesh:
"""Quarter-disk O-grid: the 90-degree sibling of :func:`half_ogrid
<nekmeshpy.quadmesh.shape.half_ogrid>`.
Untagged unless ``element_tag`` is given, like every other factory. Naming the
quadrant itself is what lets a disc assembled from four of them hand a *per-quadrant*
cap name to :func:`loft <nekmeshpy.hexmesh.assemble.loft>` or a single patch name to
:func:`hexmesh.tetra <nekmeshpy.hexmesh.shape.tetra>`."""
apts = _check_boundary(arc, "quadrant_ogrid arc", 3)
na = apts.shape[0]
if na < 3 or (na - 1) % 2 != 0:
raise ValueError(
"quadrant_ogrid arc must have 2*n+1 points (n >= 1), got %d" % na)
n = (na - 1) // 2
if not 0.0 < center_scale < 1.0:
raise ValueError("quadrant_ogrid needs center_scale in (0, 1)")
radial = validate_layers(radial)
Nr = radial.size - 1
st = dict(side_tags or {})
extra = [s for s in st if s not in _QUADRANT_SEAMS]
if extra:
raise ValueError(
"quadrant_ogrid side_tags must be keyed seam1/seam2, got unexpected %s"
% extra)
n_seam = n + 1 + Nr
seams = (seam1, seam2)
for which, sm in zip(_QUADRANT_SEAMS, seams):
pts = _check_boundary(sm, "quadrant_ogrid " + which, 2)
if pts.shape[0] != n_seam:
raise ValueError(
"quadrant_ogrid %s must have exactly n+1 + Nradial = %d points "
"ascending from the center O (got %d); it is meshed exactly at the "
"points given, never resampled -- evaluate your radius curve at "
"quadrant_seam_fractions(%d, radial, center_scale)"
% (which, n_seam, pts.shape[0], n))
if sm.order != arc.order:
raise ValueError(
"quadrant_ogrid %s and arc must share an order (got %s order %d, arc "
"order %d); the seam's own nodes are the radius geometry, so a "
"lower-order seam cannot describe a higher-order edge"
% (which, which, sm.order, arc.order))
s1: PointArray = seam1.points
s2: PointArray = seam2.points
tol = conform.entity_tol(np.vstack([apts, s1, s2]))
gap_o = float(np.linalg.norm(s1[0] - s2[0]))
if gap_o > tol:
raise ValueError(
"quadrant_ogrid: seam1 and seam2 must start at the same center point O "
"(gap %.3g > %.3g); build both from one center array" % (gap_o, tol))
for which, sm_pts, corner, name in (("seam1", s1, apts[0], "arc[0] (A1)"),
("seam2", s2, apts[-1], "arc[-1] (A2)")):
gap = float(np.linalg.norm(sm_pts[-1] - corner))
if gap > tol:
raise ValueError(
"quadrant_ogrid: %s must end at %s (gap %.3g > %.3g) -- the arc runs "
"A1 -> A2 and seam1 is the A1 radius; reverse the arc or swap the "
"seams" % (which, name, gap, tol))
core: PointArray = quadrant_core(arc, seam1, seam2,
center_scale=center_scale).reshape(-1, 3)
row = n + 1
def cid(i: int, j: int) -> int:
return i * row + j
cquads: IntArray = _grid_quads(n, n)
# core perimeter, M1 -> K -> M2: 2n+1 points index-paired with arc[0..2n].
peri_ids: IntArray = np.array([cid(n, j) for j in range(row)]
+ [cid(i, n) for i in range(n - 1, -1, -1)],
dtype=np.int64)
peripts: PointArray = core[peri_ids, :]
P = 2 * n + 1
# -- ring band: straight-chord blends out to the wall, with the two ends snapped
# onto the exact seam samples (as half_ogrid does) so neighbours weld bit-exactly.
lid: list[IntArray] = [peri_ids]
layers: list[PointArray] = [core]
ring_pts: list[PointArray] = []
nprev = core.shape[0]
for r in range(Nr):
tau = float(radial[r + 1])
rp: PointArray = (1.0 - tau) * peripts + tau * apts
rp[0, :] = s1[n + 1 + r]
rp[-1, :] = s2[n + 1 + r]
layers.append(rp)
ring_pts.append(rp)
lid.append(nprev + np.arange(P, dtype=np.int64))
nprev += P
points: PointArray = np.vstack(layers)
k: IntArray = np.arange(2 * n, dtype=np.int64)
ring_quads = [np.stack([b[k], b[k + 1], a[k + 1], a[k]], axis=1) # CCW, b outer
for a, b in zip(lid[:-1], lid[1:])]
quads: IntArray = np.vstack([cquads, *ring_quads])
# -- boundary rows. Ring quad [b[k], b[k+1], a[k+1], a[k]] puts side 1 on the
# outer layer and side 3 on the inner (ogrid's winding), so the wall is side 1 of
# the outermost band; side 4 of band quad 0 and side 2 of band quad 2n-1 are the
# seams' ring stations, and the core's j == 0 row / i == 0 column their fans.
q0 = n * n
wall_q0 = q0 + (Nr - 1) * (2 * n)
seam_sides = (
("seam1", seam1, [(i * n, 1) for i in range(n)]
+ [(q0 + r * (2 * n), 4) for r in range(Nr)]),
("seam2", seam2, [(j, 4) for j in range(n)]
+ [(q0 + r * (2 * n) + (2 * n - 1), 2) for r in range(Nr)]),
)
blocks = [_curve_rows([(wall_q0 + m, 1) for m in range(2 * n)], arc, wall_tag)]
blocks += [_curve_rows(rows, sm, st.get(which, ""))
for which, sm, rows in seam_sides]
qm = tag_edges(QuadMesh.from_corners(points, quads),
np.concatenate([r for r, _ in blocks], axis=0),
np.concatenate([n for _, n in blocks]))
qm = QuadMesh(qm.line_mesh, qm.quads, qm.orient, qm.interior,
Tags.full(qm.n_quads, element_tag))
# -- order N. Overlay every O-ring so the wall's curvature blends inward, and both
# seams with their *own* nodes so a bowed radius is meshed exactly rather than
# straight-subdivided between its samples.
order = arc.order
overlays: list[Overlay] = []
if order > 1:
overlays = _ring_overlays(ring_pts, arc, radial, peripts,
q0, 2 * n, 1)
rs: IntArray = np.arange(Nr, dtype=np.int64)
core_fan: IntArray = np.arange(n, dtype=np.int64)
s2l1: IntArray = np.argsort(_chain_intervals(seam1, "quadrant_ogrid seam1"))
s2l2: IntArray = np.argsort(_chain_intervals(seam2, "quadrant_ogrid seam2"))
overlays += [
(core_fan * n, 1, _sub_chain(seam1, core_fan, s2l1)),
(q0 + rs * (2 * n), 4, _sub_chain(seam1, n + rs, s2l1)),
(core_fan, 4, _sub_chain(seam2, core_fan, s2l2)),
(q0 + rs * (2 * n) + (2 * n - 1), 2, _sub_chain(seam2, n + rs, s2l2)),
]
return _elevate(qm, order, overlays)
[docs]
def quadrant_seam_fractions(n_side: int, radial: int | FloatArray,
quadrant_scale: float = 0.7) -> FloatArray:
"""The normalized ``O -> A`` positions of the ``n_side+1 + Nradial`` seam points
that :func:`quadrant_ogrid <nekmeshpy.quadmesh.shape.quadrant_ogrid>` requires,
ascending: the ``n_side+1`` core fan, then the ``Nradial`` ring stations. The seam's
shared corner ``M`` sits at ``quadrant_scale * R`` along ``O -> A`` directly -- an
independent knob from :func:`quadrant_ogrid
<nekmeshpy.quadmesh.shape.quadrant_ogrid>`'s own ``center_scale``, which places its
hub corner ``K`` at ``center_scale * R`` along the arc's *bisector* instead. Unless
``quadrant_scale`` is chosen to land ``M`` exactly on the chord between neighbouring
quadrants' ``K`` corners (``quadrant_scale == center_scale * cos(45deg)``), the
merged core's boundary bows into an octagon rather than sitting flush as a
square."""
ns = int(n_side)
if ns < 1:
raise ValueError("quadrant_seam_fractions needs n_side >= 1, got %d" % ns)
if not 0.0 < quadrant_scale < 1.0:
raise ValueError("quadrant_seam_fractions needs quadrant_scale in (0, 1)")
rad = validate_layers(radial)
s_m = quadrant_scale
fr: FloatArray = np.concatenate([np.linspace(0.0, s_m, ns + 1),
s_m + rad[1:] * (1.0 - s_m)])
return fr
[docs]
def spine_fractions(n_theta: int, radial: int | FloatArray,
quadrant_scale: float = 0.7) -> FloatArray:
"""The normalized ``A1 -> A2`` positions of the ``2*n_theta+1 + 2*Nradial`` spine
points that :func:`half_ogrid <nekmeshpy.quadmesh.shape.half_ogrid>` and
:func:`spined_ogrid <nekmeshpy.quadmesh.shape.spined_ogrid>` require, in ascending
order: ``Nradial`` north caps, the ``2*n_theta+1`` center fan, then ``Nradial``
south caps (see :func:`half_ogrid <nekmeshpy.quadmesh.shape.half_ogrid>` for what
each region is). ``quadrant_scale`` plays the same role here as it does in
:func:`quadrant_seam_fractions <nekmeshpy.quadmesh.shape.quadrant_seam_fractions>`:
it is the fraction of each half (``O -> A1`` and ``O -> A2``) given to the center
fan before the ``Nradial`` O-ring caps take over."""
nt = int(n_theta)
if nt < 1:
raise ValueError("spine_fractions needs n_theta >= 1, got %d" % nt)
if not 0.0 < quadrant_scale < 1.0:
raise ValueError("spine_fractions needs quadrant_scale in (0, 1)")
rad = validate_layers(radial)
s_n, s_s = (1.0 - quadrant_scale) / 2, (1.0 + quadrant_scale) / 2
fr: FloatArray = np.concatenate([((1.0 - rad[1:]) * s_n)[::-1],
np.linspace(s_n, s_s, 2 * nt + 1),
s_s + rad[1:] * (1.0 - s_s)])
return fr
[docs]
def spined_ogrid(boundary: LineMesh, radial: int | FloatArray, *,
spine: LineMesh | None = None, center_scale: float = 0.7,
quadrant_scale: float = 0.7, wall_tag: str = "",
element_tags: str | Tags | None = None) -> QuadMesh:
"""Full-disk O-grid over a closed ``boundary`` split along a spine diameter into two
:func:`half_ogrid <nekmeshpy.quadmesh.shape.half_ogrid>` halves (each in turn built
from two :func:`quadrant_ogrid <nekmeshpy.quadmesh.shape.quadrant_ogrid>` quarters)
welded along the spine -- the clean way to O-grid a disk that has a natural
``A1..A2`` seam (a saddle-split vessel or pipe cross-section), so the caller need not
hand-roll the arc split and merge. ``center_scale``/``quadrant_scale`` are as in
:func:`quadrant_ogrid <nekmeshpy.quadmesh.shape.quadrant_ogrid>`/
:func:`quadrant_seam_fractions <nekmeshpy.quadmesh.shape.quadrant_seam_fractions>`."""
bpts = _check_boundary(boundary, "spined_ogrid boundary", 8)
M = bpts.shape[0]
if M % 8 != 0:
raise ValueError(
"spined_ogrid boundary must have 8*Ntheta points (a closed loop split "
"into two 4*Ntheta+1 arcs); got %d" % M)
nh = M // 2
Nt = nh // 4
radial = validate_layers(radial)
# The spine is meshed exactly at the points given -- nothing is resampled here.
# A caller-supplied spine must already carry the [north caps, center fan, south
# caps] sampling half_ogrid consumes; only the default straight chord is a shape
# this factory owns, so only that one may be placed here.
fr = spine_fractions(Nt, radial, quadrant_scale)
if spine is None:
spine = line(bpts[0, :], bpts[nh, :], fr, order=boundary.order)
elif spine.points.shape[0] != fr.shape[0]:
raise ValueError(
"spined_ogrid spine must have exactly 2*Ntheta+1 + 2*Nradial = %d points "
"ascending A1 -> A2 (got %d); it is meshed exactly at the points given, "
"never resampled -- evaluate your spine curve at "
"spine_fractions(%d, radial, quadrant_scale)"
% (fr.shape[0], spine.points.shape[0], Nt))
# the second half traverses A2 -> A1; ``reverse`` relabels rather than re-placing,
# so both halves see bit-identical seam coordinates (and, at order > 1, carries the
# spine's own interior nodes with it instead of re-subdividing them straight).
spine2 = line_reverse(spine)
# split the loop (and its per-segment tags) into the two half arcs: arc1 runs
# A1 -> A2 over segments [0, nh), arc2 runs A2 -> A1 over segments [nh, M).
arc1 = _slice_chain(boundary, 0, nh)
arc2 = _slice_chain(boundary, nh, M)
h1 = half_ogrid(arc1, spine, radial, center_scale=center_scale,
quadrant_scale=quadrant_scale, wall_tag=wall_tag)
h2 = half_ogrid(arc2, spine2, radial, center_scale=center_scale,
quadrant_scale=quadrant_scale, wall_tag=wall_tag)
return _named(merge([h1, h2]), element_tags)
# the six box faces: outward normal n with right-handed tangents (u x v = n),
# each mapped to its {x,y,z}_{min,max} side key.
_BOX_FACES = [
((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0), "x_max"),
((-1.0, 0.0, 0.0), (0.0, 0.0, 1.0), (0.0, 1.0, 0.0), "x_min"),
((0.0, 1.0, 0.0), (0.0, 0.0, 1.0), (1.0, 0.0, 0.0), "y_max"),
((0.0, -1.0, 0.0), (1.0, 0.0, 0.0), (0.0, 0.0, 1.0), "y_min"),
((0.0, 0.0, 1.0), (1.0, 0.0, 0.0), (0.0, 1.0, 0.0), "z_max"),
((0.0, 0.0, -1.0), (0.0, 1.0, 0.0), (1.0, 0.0, 0.0), "z_min"),
]
# the four upright side faces of a half box: outward normal n, horizontal tangent u,
# vertical tangent +z (u x v = n), so restricting the vertical coordinate to [0, 1]
# keeps the patch in z >= 0 and puts its lower edge on the ground plane.
_HALF_BOX_SIDES = [
((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), "x_max"),
((-1.0, 0.0, 0.0), (0.0, -1.0, 0.0), "x_min"),
((0.0, 1.0, 0.0), (-1.0, 0.0, 0.0), "y_max"),
((0.0, -1.0, 0.0), (1.0, 0.0, 0.0), "y_min"),
]
_VZ: Vec3 = np.array([0.0, 0.0, 1.0])
def _axis_params(half_sizes: float | Sequence[float] | FloatArray,
n: int | Sequence[int] | IntArray,
) -> tuple[FloatArray, tuple[int, int, int]]:
"""Normalize a box-like factory's ``half_sizes`` / ``n`` to a ``(3,)`` float array
of half extents and a ``(nx, ny, nz)`` cell-count triple (each input a scalar or
three values)."""
hs: FloatArray = np.asarray(half_sizes, dtype=float).ravel()
if hs.size == 1:
hs = np.full(3, float(hs[0]))
elif hs.size != 3:
raise ValueError("half_sizes must be a scalar or 3 values (sx, sy, sz)")
na: IntArray = np.asarray(n, dtype=np.int64).ravel()
if na.size == 1:
return hs, (int(na[0]), int(na[0]), int(na[0]))
if na.size == 3:
return hs, (int(na[0]), int(na[1]), int(na[2]))
raise ValueError("n must be a scalar or 3 counts (nx, ny, nz)")
[docs]
def box(half_sizes: float | Sequence[float] | FloatArray,
n: int | Sequence[int] | IntArray, *,
patch_tags: Mapping[str, str] | None = None,
element_tags: str | Tags | None = None,
order: int = 1) -> QuadMesh:
"""Closed box surface centred at the origin: six quad patches welded with
:func:`merge <nekmeshpy.quadmesh.assemble.merge>`. ``half_sizes`` is a scalar (cube)
or ``(sx, sy, sz)``; ``n`` is a scalar or ``(nx, ny, nz)`` cells per axis."""
hs, n_axis = _axis_params(half_sizes, n)
ft = patch_tags or {}
patches: list[QuadMesh] = []
for nrm, u, v, key in _BOX_FACES:
nv: Vec3 = np.asarray(nrm, dtype=float)
uv: Vec3 = np.asarray(u, dtype=float)
vv: Vec3 = np.asarray(v, dtype=float)
au = np.linspace(-1.0, 1.0, n_axis[int(np.argmax(np.abs(uv)))] + 1)
av = np.linspace(-1.0, 1.0, n_axis[int(np.argmax(np.abs(vv)))] + 1)
A: FloatArray
B: FloatArray
A, B = np.meshgrid(au, av, indexing="ij")
face = hs * (nv + A[..., None] * uv + B[..., None] * vv)
patches.append(from_grid(face, element_tag=ft.get(key, ""),
order=order))
return _named(merge(patches), element_tags)
[docs]
def sphere(radius: float, n: int | Sequence[int] | IntArray, *,
element_tag: str = NO_TAG, order: int = 1) -> QuadMesh:
"""Closed cubed-sphere surface of ``radius`` about the origin: a unit :func:`box
<nekmeshpy.quadmesh.shape.box>` projected radially onto the sphere (same
connectivity, so it pairs by index with a same-``n`` box for :func:`HexMesh.annulus
<nekmeshpy.hexmesh.lift.annulus>`).
Untagged unless ``element_tag`` is given, like every other factory: a shape does not
name itself, because the name belongs to the model the caller is assembling."""
from ..linemesh import LineMesh
cube = box(1.0, n, order=order)
def project(a: PointArray) -> PointArray:
"""Push every node of ``a`` (last axis = xyz) radially onto the sphere."""
return radius * a / np.linalg.norm(a, axis=-1, keepdims=True)
etags = Tags.full(cube.n_quads, element_tag)
# the cube's B-rep is reused verbatim (same topology, same edge numbering); only
# the node coordinates move, so there is nothing to re-derive or reconcile.
lines = LineMesh(project(cube.points), cube.line_mesh.lines,
interior=project(cube.line_mesh.interior) if order > 1 else None)
return QuadMesh(lines, cube.quads, cube.orient,
project(cube.interior) if order > 1 else None,
element_tags=etags)
def _tag_rim(qm: QuadMesh, rim_tag: str) -> QuadMesh:
"""Return ``qm`` with every free (single-quad) edge tagged ``rim_tag``."""
if not rim_tag:
return qm
rows = boundary_edges(qm)
return tag_edges(qm, rows, [rim_tag] * rows.shape[0])
[docs]
def half_box(half_sizes: float | Sequence[float] | FloatArray,
n: int | Sequence[int] | IntArray, *,
n_vertical: int | None = None,
patch_tags: Mapping[str, str] | None = None,
rim_tag: str = "",
element_tags: str | Tags | None = None,
order: int = 1) -> QuadMesh:
"""The upper half of a :func:`box <nekmeshpy.quadmesh.shape.box>`: the five patches
of the box surface that bound ``[-sx, sx] x [-sy, sy] x [0, sz]``, welded with
:func:`merge <nekmeshpy.quadmesh.assemble.merge>` and left **open at the ``z = 0``
rim** (the ``z_min`` patch is dropped)."""
hs, n_axis = _axis_params(half_sizes, n)
nv = n_axis[2] if n_vertical is None else int(n_vertical)
if nv < 1:
raise ValueError("half_box needs n_vertical >= 1, got %d" % nv)
ft = patch_tags or {}
patches: list[QuadMesh] = []
b_side = np.linspace(0.0, 1.0, nv + 1) # upper half only
for nrm, u, key in _HALF_BOX_SIDES:
nvv: Vec3 = np.asarray(nrm, dtype=float)
uv: Vec3 = np.asarray(u, dtype=float)
au = np.linspace(-1.0, 1.0, n_axis[int(np.argmax(np.abs(uv)))] + 1)
A: FloatArray
B: FloatArray
A, B = np.meshgrid(au, b_side, indexing="ij")
face = hs * (nvv + A[..., None] * uv + B[..., None] * _VZ)
patches.append(from_grid(face, element_tag=ft.get(key, ""),
order=order))
# the flat top patch at z = sz, spanning x and y in full
ax = np.linspace(-1.0, 1.0, n_axis[0] + 1)
ay = np.linspace(-1.0, 1.0, n_axis[1] + 1)
AX: FloatArray
AY: FloatArray
AX, AY = np.meshgrid(ax, ay, indexing="ij")
top = hs * (_VZ + AX[..., None] * np.array([1.0, 0.0, 0.0])
+ AY[..., None] * np.array([0.0, 1.0, 0.0]))
patches.append(from_grid(top, element_tag=ft.get("z_max", ""),
order=order))
return _named(_tag_rim(merge(patches), rim_tag), element_tags)
[docs]
def hemisphere(radius: float, n: int | Sequence[int] | IntArray, *,
n_vertical: int | None = None,
element_tag: str = NO_TAG,
rim_tag: str = NO_TAG,
order: int = 1) -> QuadMesh:
"""Cubed-**hemisphere** surface of ``radius`` sitting on the ground plane ``z = 0``:
a unit :func:`half_box <nekmeshpy.quadmesh.shape.half_box>` projected radially onto
the sphere. Untagged unless ``element_tag`` is given."""
from ..linemesh import LineMesh
cube = half_box(1.0, n, n_vertical=n_vertical, order=order)
def project(a: PointArray) -> PointArray:
"""Push every node of ``a`` (last axis = xyz) radially onto the sphere."""
return radius * a / np.linalg.norm(a, axis=-1, keepdims=True)
etags = Tags.full(cube.n_quads, element_tag)
lines = LineMesh(project(cube.points), cube.line_mesh.lines,
interior=project(cube.line_mesh.interior) if order > 1 else None)
qm = QuadMesh(lines, cube.quads, cube.orient,
project(cube.interior) if order > 1 else None,
element_tags=etags)
return _tag_rim(qm, rim_tag)
def _patch(fn: Callable[[FloatArray, FloatArray], FloatArray], surface: SurfaceMap,
n: int, order: int, tag: str) -> QuadMesh:
"""One ``n``-by-``n`` patch of a surface, from a parameter-space Coons map --
evaluated on the surface at every node, corners and private interiors alike."""
fr = np.linspace(0.0, 1.0, n + 1)
return loft_fn(
lambda y: line_loft_fn(
lambda x: surface(fn(x, np.full(np.shape(x), y))), fr, order=order),
fr, order=order, element_tags=tag or None)
[docs]
def tri_patch(surface: SurfaceMap, ab: SurfaceCurve, bc: SurfaceCurve,
ca: SurfaceCurve, *, order: int = 1, tip_bias: float = 1.0 / 3.0,
mids: Sequence[FloatArray] | None = None,
element_tag: str = "") -> QuadMesh:
"""The curved triangle bounded by three surface curves, meshed as the **three
quadrilateral patches** about an interior tip -- the all-quad split of a triangle,
and the shape :func:`HexMesh.tetra <nekmeshpy.hexmesh.shape.tetra>` wants for the
curved side of a curvilinear tetrahedron."""
ns = {c.fr.size for c in (ab, bc, ca)}
if len(ns) != 1:
raise ValueError("tri_patch: the three curves must share a node count, got %s"
% sorted(ns))
k = ns.pop()
if k < 3 or k % 2 == 0:
raise ValueError(
"tri_patch: each curve needs an odd node count 2n+1 (at least 3) so it can "
"be split at its own middle node; got %d" % k)
n = (k - 1) // 2
u_ab, u_bc, u_ca = (
[surfaces.node(c, n) for c in (ab, bc, ca)] if mids is None else list(mids))
tip = tri_patch_tip(u_ab, u_bc, u_ca, tip_bias=tip_bias)
seg, spk = surfaces.segment, surfaces.spoke
return merge([
_patch(coons_grid_fn(seg(ab, 0, n), spk(u_ca, tip),
seg(ca, 2 * n, n), spk(u_ab, tip)),
surface, n, order, element_tag),
_patch(coons_grid_fn(seg(ab, 2 * n, n), spk(u_bc, tip),
seg(bc, 0, n), spk(u_ab, tip)),
surface, n, order, element_tag),
_patch(coons_grid_fn(seg(ca, 0, n), spk(u_bc, tip),
seg(bc, 2 * n, n), spk(u_ca, tip)),
surface, n, order, element_tag)])
[docs]
def tri_patch_tip(u_ab: FloatArray, u_bc: FloatArray, u_ca: FloatArray, *,
tip_bias: float = 1.0 / 3.0) -> FloatArray:
"""Where :func:`tri_patch` puts its tip, in surface parameters, given the three
curves' middle nodes."""
return tip_bias * u_ab + (1.0 - tip_bias) * 0.5 * (u_bc + u_ca)
__all__ = [
"box",
"half_box",
"half_ogrid",
"hemisphere",
"ogrid",
"quadrant_core",
"quadrant_ogrid",
"quadrant_seam_fractions",
"rectangle",
"sphere",
"spine_fractions",
"spined_ogrid",
"structured",
"tri_patch",
"tri_patch_tip",
]