quadmesh 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- quadmesh/__init__.py +30 -0
- quadmesh/_topology.py +100 -0
- quadmesh/_tri_removal.py +202 -0
- quadmesh/cleanup_boundary_quads.py +142 -0
- quadmesh/cli.py +75 -0
- quadmesh/create_quad_domain.py +54 -0
- quadmesh/doublet_collapse.py +80 -0
- quadmesh/identify_edges.py +209 -0
- quadmesh/pipeline.py +49 -0
- quadmesh/post_process.py +101 -0
- quadmesh/quad_vertex_merge.py +84 -0
- quadmesh/quality_report.py +37 -0
- quadmesh/remove_unused.py +25 -0
- quadmesh/repair.py +478 -0
- quadmesh/tri2quad.py +97 -0
- quadmesh/validation/__init__.py +18 -0
- quadmesh/validation/broadphase.py +64 -0
- quadmesh/validation/fixtures.py +160 -0
- quadmesh/validation/predicates.py +172 -0
- quadmesh/validation/types.py +34 -0
- quadmesh/validation/validator.py +361 -0
- quadmesh-0.1.0.dist-info/METADATA +77 -0
- quadmesh-0.1.0.dist-info/RECORD +26 -0
- quadmesh-0.1.0.dist-info/WHEEL +5 -0
- quadmesh-0.1.0.dist-info/entry_points.txt +2 -0
- quadmesh-0.1.0.dist-info/top_level.txt +1 -0
quadmesh/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""QuADMESH+ Python port. Tri-to-quad mesh generator on top of chilmesh."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
__version__ = "0.1.0"
|
|
5
|
+
|
|
6
|
+
from .tri2quad import tri2quad_routine as tri2quad
|
|
7
|
+
from .post_process import post_process_routine as post_process, two_part_smoother
|
|
8
|
+
from .quality_report import compute_quality_stats, format_quality_report
|
|
9
|
+
from .repair import repair_chilmesh, repair_mesh
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"tri2quad",
|
|
13
|
+
"post_process",
|
|
14
|
+
"two_part_smoother",
|
|
15
|
+
"compute_quality_stats",
|
|
16
|
+
"format_quality_report",
|
|
17
|
+
"repair_mesh",
|
|
18
|
+
"repair_chilmesh",
|
|
19
|
+
"__version__",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def __getattr__(name: str):
|
|
24
|
+
if name == "create_quad_domain":
|
|
25
|
+
from .create_quad_domain import create_quad_domain
|
|
26
|
+
return create_quad_domain
|
|
27
|
+
if name == "run_pipeline":
|
|
28
|
+
from .pipeline import run_pipeline
|
|
29
|
+
return run_pipeline
|
|
30
|
+
raise AttributeError(f"module 'quadmesh' has no attribute {name!r}")
|
quadmesh/_topology.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Topology helpers. CCW edge sorting around verts. Merge tri pairs to quads.
|
|
2
|
+
|
|
3
|
+
MATLAB src: CCWEdgesAroundVertsFun.m, mergeTrianglesFun.m.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Iterable, List, Sequence
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def ccw_edges_around_vert(mesh, vert_ids: Sequence[int]) -> List[np.ndarray]:
|
|
14
|
+
"""Sort edges incident to each vert by polar angle, CCW.
|
|
15
|
+
|
|
16
|
+
Port of MATLAB ``CCWEdgesAroundVertsFun``. For each ``v`` in ``vert_ids``,
|
|
17
|
+
list its incident edge IDs ordered counter-clockwise around ``v``.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
mesh: CHILmesh instance.
|
|
21
|
+
vert_ids: Iterable of global vertex IDs.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
List of 1-D arrays (one per input vert) of edge IDs in CCW order.
|
|
25
|
+
"""
|
|
26
|
+
vert_ids = np.asarray(list(vert_ids), dtype=int).ravel()
|
|
27
|
+
edge2vert = mesh.adjacencies["Edge2Vert"]
|
|
28
|
+
points = mesh.points
|
|
29
|
+
|
|
30
|
+
out: List[np.ndarray] = []
|
|
31
|
+
for v in vert_ids:
|
|
32
|
+
eids = np.fromiter(mesh.get_vertex_edges(int(v)), dtype=int)
|
|
33
|
+
if eids.size == 0:
|
|
34
|
+
out.append(eids)
|
|
35
|
+
continue
|
|
36
|
+
e2v = edge2vert[eids]
|
|
37
|
+
# Other endpoint per edge.
|
|
38
|
+
other = np.where(e2v[:, 0] == v, e2v[:, 1], e2v[:, 0])
|
|
39
|
+
dx = points[other, 0] - points[v, 0]
|
|
40
|
+
dy = points[other, 1] - points[v, 1]
|
|
41
|
+
theta = np.arctan2(dy, dx)
|
|
42
|
+
order = np.argsort(theta, kind="stable")
|
|
43
|
+
out.append(eids[order])
|
|
44
|
+
return out
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def merge_tri_pair(mesh, elem_id_a: int, elem_id_b: int) -> np.ndarray:
|
|
48
|
+
"""Merge two tris sharing an edge into one quad. Return 4-vert connectivity.
|
|
49
|
+
|
|
50
|
+
Quads are CCW. Shared edge is removed; opposing vertices form the new diagonal.
|
|
51
|
+
"""
|
|
52
|
+
conn = mesh.connectivity_list
|
|
53
|
+
t1 = conn[elem_id_a, :3].astype(int)
|
|
54
|
+
t2 = conn[elem_id_b, :3].astype(int)
|
|
55
|
+
|
|
56
|
+
shared = np.intersect1d(t1, t2, assume_unique=False)
|
|
57
|
+
if shared.size != 2:
|
|
58
|
+
raise ValueError(
|
|
59
|
+
f"Elems {elem_id_a},{elem_id_b} do not share exactly 2 verts (got {shared.size})"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
unique_b = int(np.setdiff1d(t2, shared, assume_unique=False)[0])
|
|
63
|
+
# Rotate t1 so shared edge sits at positions (0,1); unique-of-t1 ends up at index 2.
|
|
64
|
+
# Then quad = [t1[0], unique_b, t1[1], t1[2]] preserves CCW.
|
|
65
|
+
# Find unique-of-t1.
|
|
66
|
+
unique_a = int(np.setdiff1d(t1, shared, assume_unique=False)[0])
|
|
67
|
+
iu = int(np.where(t1 == unique_a)[0][0])
|
|
68
|
+
rotated = np.roll(t1, -iu) # rotated[0] = unique_a
|
|
69
|
+
# rotated = [unique_a, s1, s2]. Insert unique_b between s1 and s2 to form quad
|
|
70
|
+
# [unique_a, s1, unique_b, s2] which is CCW if both tris were CCW.
|
|
71
|
+
quad = np.array([rotated[0], rotated[1], unique_b, rotated[2]], dtype=int)
|
|
72
|
+
return quad
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def merge_tri_pairs(mesh, pair_elem_ids: np.ndarray) -> np.ndarray:
|
|
76
|
+
"""Vector form. ``pair_elem_ids`` is shape ``(n, 2)``.
|
|
77
|
+
|
|
78
|
+
Returns ``(n, 4)`` quad connectivity (CCW assuming CCW input).
|
|
79
|
+
"""
|
|
80
|
+
pair_elem_ids = np.atleast_2d(np.asarray(pair_elem_ids, dtype=int))
|
|
81
|
+
if pair_elem_ids.shape[1] != 2:
|
|
82
|
+
raise ValueError(f"pair_elem_ids must be (n,2); got {pair_elem_ids.shape}")
|
|
83
|
+
quads = np.empty((pair_elem_ids.shape[0], 4), dtype=int)
|
|
84
|
+
for i, (a, b) in enumerate(pair_elem_ids):
|
|
85
|
+
quads[i] = merge_tri_pair(mesh, int(a), int(b))
|
|
86
|
+
return quads
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def edge2elem_pair(mesh, edge_ids: Iterable[int]) -> np.ndarray:
|
|
90
|
+
"""Return ``(n, 2)`` elem pair per edge. Sentinel for missing neighbour: ``-1``."""
|
|
91
|
+
eids = np.asarray(list(edge_ids), dtype=int)
|
|
92
|
+
return mesh.adjacencies["Edge2Elem"][eids]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def shared_edge(mesh, elem_id_a: int, elem_id_b: int) -> int:
|
|
96
|
+
"""Edge ID shared by two elems. ``-1`` if none."""
|
|
97
|
+
ea = set(mesh.elem2edge(int(elem_id_a)).tolist())
|
|
98
|
+
eb = set(mesh.elem2edge(int(elem_id_b)).tolist())
|
|
99
|
+
common = ea & eb
|
|
100
|
+
return int(next(iter(common))) if common else -1
|
quadmesh/_tri_removal.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Handle leftover tris after tri-pair merge in a layer.
|
|
2
|
+
|
|
3
|
+
Per MATLAB ``removeTrianglesFun``, each leftover tri is routed by:
|
|
4
|
+
|
|
5
|
+
on_mesh_bdy? + n boundary edges → operation
|
|
6
|
+
----------------------------------------------
|
|
7
|
+
False, ≥1 → edge_bisection (case 2)
|
|
8
|
+
True, 0 → edge_insertion (case 1)
|
|
9
|
+
False, 0 → edge_insertion (case 2)
|
|
10
|
+
True, 2 or 3 → edge_insertion (case 3)
|
|
11
|
+
True, 1, can_remove → edge_removal
|
|
12
|
+
True, 1, !can_remove → edge_bisection (case 1)
|
|
13
|
+
|
|
14
|
+
Port focuses on the algorithmic intent. Some MATLAB special-cases (the
|
|
15
|
+
re-triangulation of iLayer-1 in edge_insertion case 2) are non-trivial; we
|
|
16
|
+
keep them but mark precise edge-cases as TODO when they fall outside the
|
|
17
|
+
common path. Behaviour matches MATLAB on typical meshes (Test_Case_1, Block_O).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from typing import List, Optional
|
|
24
|
+
|
|
25
|
+
import numpy as np
|
|
26
|
+
|
|
27
|
+
from chilmesh import CHILmesh
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class WorkingMesh:
|
|
32
|
+
"""Mutable scratch state during tri2quad."""
|
|
33
|
+
|
|
34
|
+
points: np.ndarray # (n_verts, 3)
|
|
35
|
+
quads: List[np.ndarray] # list of (4,) quad connectivity rows.
|
|
36
|
+
|
|
37
|
+
def add_quad(self, quad: np.ndarray) -> int:
|
|
38
|
+
idx = len(self.quads)
|
|
39
|
+
self.quads.append(np.asarray(quad, dtype=int).ravel())
|
|
40
|
+
return idx
|
|
41
|
+
|
|
42
|
+
def add_point(self, xyz: np.ndarray) -> int:
|
|
43
|
+
xyz = np.asarray(xyz, dtype=float).ravel()
|
|
44
|
+
if xyz.size == 2:
|
|
45
|
+
xyz = np.array([xyz[0], xyz[1], 0.0])
|
|
46
|
+
self.points = np.vstack([self.points, xyz])
|
|
47
|
+
return self.points.shape[0] - 1
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def edge_removal(domain: CHILmesh, work: WorkingMesh, tri_elem_id: int,
|
|
51
|
+
bdy_edge_idx_in_tri: int) -> None:
|
|
52
|
+
"""Collapse one boundary edge of a tri. Two boundary verts merge to one.
|
|
53
|
+
|
|
54
|
+
MATLAB ``edgeRemoval``: midpoint of the edge replaces the "side-1" vert;
|
|
55
|
+
every reference to "side-2" vert is rewritten to "side-1". The tri vanishes
|
|
56
|
+
from the mesh — it is not appended to ``work.quads``.
|
|
57
|
+
"""
|
|
58
|
+
edge_ids = domain.elem2edge(tri_elem_id).ravel().astype(int)
|
|
59
|
+
eid = int(edge_ids[bdy_edge_idx_in_tri])
|
|
60
|
+
v_a, v_b = domain.edge2vert(eid).ravel().astype(int).tolist()
|
|
61
|
+
|
|
62
|
+
mid = 0.5 * (domain.points[v_a] + domain.points[v_b])
|
|
63
|
+
# Snap v_a to midpoint; rewrite v_b → v_a everywhere.
|
|
64
|
+
domain.points[v_a] = mid
|
|
65
|
+
cl = domain.connectivity_list
|
|
66
|
+
cl[cl == v_b] = v_a
|
|
67
|
+
# Rewrite any existing quads referencing v_b → v_a.
|
|
68
|
+
for q in work.quads:
|
|
69
|
+
q[q == v_b] = v_a
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def edge_bisection(domain: CHILmesh, work: WorkingMesh, tri_elem_id: int,
|
|
73
|
+
bdy_edge_idx_in_tri: int) -> Optional[int]:
|
|
74
|
+
"""Bisect one tri edge with a new midpoint. Tri becomes a quad.
|
|
75
|
+
|
|
76
|
+
Returns the new vertex ID. The companion retriangulation of the opposite
|
|
77
|
+
tri (MATLAB case 2) is performed by the caller for the layer-interior case
|
|
78
|
+
via ``_split_opposing_tri`` if the opposing elem is given.
|
|
79
|
+
"""
|
|
80
|
+
edge_ids = domain.elem2edge(tri_elem_id).ravel().astype(int)
|
|
81
|
+
eid = int(edge_ids[bdy_edge_idx_in_tri])
|
|
82
|
+
v_a, v_b = domain.edge2vert(eid).ravel().astype(int).tolist()
|
|
83
|
+
|
|
84
|
+
mid = 0.5 * (domain.points[v_a] + domain.points[v_b])
|
|
85
|
+
np_id = work.add_point(mid)
|
|
86
|
+
# Pad domain.points so vertex IDs stay aligned with downstream work.points.
|
|
87
|
+
domain.points = np.vstack([domain.points, mid.reshape(1, -1)])
|
|
88
|
+
|
|
89
|
+
# Build new quad: rotate tri conn so v_a, v_b are adjacent, then insert np_id between.
|
|
90
|
+
conn = domain.connectivity_list[tri_elem_id, :3].astype(int)
|
|
91
|
+
# Find positions of (v_a, v_b) in conn.
|
|
92
|
+
while True:
|
|
93
|
+
ia = np.where(conn == v_a)[0]
|
|
94
|
+
ib = np.where(conn == v_b)[0]
|
|
95
|
+
if ia.size == 0 or ib.size == 0:
|
|
96
|
+
return None
|
|
97
|
+
if (ia[0] + 1) % 3 == ib[0]:
|
|
98
|
+
break
|
|
99
|
+
conn = np.roll(conn, -1)
|
|
100
|
+
# quad = [conn[0], conn[1], np_id, conn[2]]? Not quite. MATLAB inserts
|
|
101
|
+
# np_id between v_a and v_b: [..., v_a, np_id, v_b, ...].
|
|
102
|
+
# Build by walking conn and slotting np_id in between the v_a,v_b pair.
|
|
103
|
+
ia = int(np.where(conn == v_a)[0][0])
|
|
104
|
+
quad = np.array([conn[ia], np_id, conn[(ia + 1) % 3], conn[(ia + 2) % 3]], dtype=int)
|
|
105
|
+
work.add_quad(quad)
|
|
106
|
+
|
|
107
|
+
# Flag tri as consumed by zeroing its connectivity (caller drops zero rows).
|
|
108
|
+
domain.connectivity_list[tri_elem_id, :] = 0
|
|
109
|
+
return np_id
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def edge_insertion(domain: CHILmesh, work: WorkingMesh, tri_elem_id: int,
|
|
113
|
+
bdy_vert_id: int) -> Optional[int]:
|
|
114
|
+
"""Split a triangle through one of its verts by inserting a new edge.
|
|
115
|
+
|
|
116
|
+
Simplified port. New point sits along an interior edge attached to
|
|
117
|
+
``bdy_vert_id``; the tri splits into a quad whose connectivity is added
|
|
118
|
+
to ``work.quads``. Returns the new vertex ID.
|
|
119
|
+
|
|
120
|
+
The MATLAB original additionally retriangulates iLayer-1 to absorb the
|
|
121
|
+
new vertex; we currently do that in a deferred pass after the layer sweep
|
|
122
|
+
completes.
|
|
123
|
+
"""
|
|
124
|
+
conn = domain.connectivity_list[tri_elem_id, :3].astype(int)
|
|
125
|
+
if bdy_vert_id not in conn.tolist():
|
|
126
|
+
# Fall back to first vert.
|
|
127
|
+
bdy_vert_id = int(conn[0])
|
|
128
|
+
|
|
129
|
+
# Pick an interior edge from bdy_vert_id (one whose other endpoint isn't on the layer bdy).
|
|
130
|
+
edges = list(domain.get_vertex_edges(int(bdy_vert_id)))
|
|
131
|
+
if not edges:
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
other_verts = []
|
|
135
|
+
for e in edges:
|
|
136
|
+
u, v = domain.edge2vert(int(e)).ravel().astype(int).tolist()
|
|
137
|
+
other = v if u == bdy_vert_id else u
|
|
138
|
+
if other in conn.tolist() and other != bdy_vert_id:
|
|
139
|
+
other_verts.append(other)
|
|
140
|
+
if not other_verts:
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
# New point at 1/3 of the way along the first opposing edge.
|
|
144
|
+
other = other_verts[0]
|
|
145
|
+
new_xyz = (
|
|
146
|
+
(2.0 / 3.0) * domain.points[bdy_vert_id]
|
|
147
|
+
+ (1.0 / 3.0) * domain.points[other]
|
|
148
|
+
)
|
|
149
|
+
np_id = work.add_point(new_xyz)
|
|
150
|
+
domain.points = np.vstack([domain.points, new_xyz.reshape(1, -1)])
|
|
151
|
+
|
|
152
|
+
# Quad = [bdy_vert_id, np_id, other, third_vert_of_tri]
|
|
153
|
+
third = int([v for v in conn if v not in (bdy_vert_id, other)][0])
|
|
154
|
+
quad = np.array([bdy_vert_id, np_id, other, third], dtype=int)
|
|
155
|
+
work.add_quad(quad)
|
|
156
|
+
|
|
157
|
+
domain.connectivity_list[tri_elem_id, :] = 0
|
|
158
|
+
return np_id
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def route_leftover_tri(
|
|
162
|
+
domain: CHILmesh,
|
|
163
|
+
work: WorkingMesh,
|
|
164
|
+
tri_elem_id: int,
|
|
165
|
+
layer_idx: int,
|
|
166
|
+
on_mesh_boundary: bool,
|
|
167
|
+
can_remove_edges: bool,
|
|
168
|
+
sub_b_edge_set: set,
|
|
169
|
+
sub_b_vert_set: set,
|
|
170
|
+
) -> None:
|
|
171
|
+
"""Apply the right sub-op given tri's boundary-edge count.
|
|
172
|
+
|
|
173
|
+
Mirrors MATLAB ``removeTrianglesFun`` switch. ``sub_b_edge_set`` is the
|
|
174
|
+
set of sub-mesh boundary edge IDs in *parent* indexing; ``sub_b_vert_set``
|
|
175
|
+
likewise for verts.
|
|
176
|
+
"""
|
|
177
|
+
edge_ids = domain.elem2edge(tri_elem_id).ravel().astype(int)
|
|
178
|
+
bdy_edges_local = [
|
|
179
|
+
i for i, e in enumerate(edge_ids) if int(e) in sub_b_edge_set
|
|
180
|
+
]
|
|
181
|
+
n_bdy = len(bdy_edges_local)
|
|
182
|
+
|
|
183
|
+
conn = domain.connectivity_list[tri_elem_id, :3].astype(int)
|
|
184
|
+
bdy_verts_in_tri = [int(v) for v in conn if int(v) in sub_b_vert_set]
|
|
185
|
+
|
|
186
|
+
if not on_mesh_boundary and n_bdy >= 1:
|
|
187
|
+
edge_bisection(domain, work, tri_elem_id, bdy_edges_local[0])
|
|
188
|
+
elif on_mesh_boundary and n_bdy == 0:
|
|
189
|
+
if bdy_verts_in_tri:
|
|
190
|
+
edge_insertion(domain, work, tri_elem_id, bdy_verts_in_tri[0])
|
|
191
|
+
elif not on_mesh_boundary and n_bdy == 0:
|
|
192
|
+
if bdy_verts_in_tri:
|
|
193
|
+
edge_insertion(domain, work, tri_elem_id, bdy_verts_in_tri[0])
|
|
194
|
+
elif on_mesh_boundary and n_bdy in (2, 3):
|
|
195
|
+
if bdy_verts_in_tri:
|
|
196
|
+
edge_insertion(domain, work, tri_elem_id, bdy_verts_in_tri[0])
|
|
197
|
+
elif on_mesh_boundary and n_bdy == 1 and can_remove_edges:
|
|
198
|
+
edge_removal(domain, work, tri_elem_id, bdy_edges_local[0])
|
|
199
|
+
elif on_mesh_boundary and n_bdy == 1 and not can_remove_edges:
|
|
200
|
+
# MATLAB removeTrianglesFun: edgeBisection(1) when canRemoveEdges=false.
|
|
201
|
+
edge_bisection(domain, work, tri_elem_id, bdy_edges_local[0])
|
|
202
|
+
# Else: silently leave as triangle (degenerate, rare).
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Boundary-quad cleanup. Port of MATLAB CleanupBoundaryQuads_v2.m.
|
|
2
|
+
|
|
3
|
+
Two modes:
|
|
4
|
+
collapse (can_remove_edges=True): merge side verts into corner; delete bad quad. MATLAB subroutineCleanupBoundaryQuads.
|
|
5
|
+
shift (can_remove_edges=False): move corner vert inward to reduce angle. v0.2 addition (MATLAB never implemented).
|
|
6
|
+
|
|
7
|
+
Bad quad: two adjacent boundary edges whose shared corner has angle > 134 deg.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from chilmesh import CHILmesh
|
|
16
|
+
|
|
17
|
+
from .remove_unused import remove_unused_vertices
|
|
18
|
+
|
|
19
|
+
ANGLE_THRESHOLD_DEG = 134.0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _angle_deg(p_corner: np.ndarray, p_a: np.ndarray, p_b: np.ndarray) -> float:
|
|
23
|
+
va = np.asarray(p_a, dtype=float) - p_corner
|
|
24
|
+
vb = np.asarray(p_b, dtype=float) - p_corner
|
|
25
|
+
na, nb = np.linalg.norm(va), np.linalg.norm(vb)
|
|
26
|
+
if na == 0 or nb == 0:
|
|
27
|
+
return 0.0
|
|
28
|
+
cos = float(np.dot(va, vb) / (na * nb))
|
|
29
|
+
return math.degrees(math.acos(max(-1.0, min(1.0, cos))))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _shift_to_target_angle(
|
|
33
|
+
p_corner: np.ndarray,
|
|
34
|
+
p_a: np.ndarray,
|
|
35
|
+
p_b: np.ndarray,
|
|
36
|
+
p_opposing: np.ndarray,
|
|
37
|
+
target_deg: float = 90.0,
|
|
38
|
+
) -> np.ndarray:
|
|
39
|
+
"""Binary search: move p_corner toward p_opposing until angle(p_a, corner, p_b) ~= target_deg."""
|
|
40
|
+
p_c = np.asarray(p_corner, dtype=float)
|
|
41
|
+
p_opp = np.asarray(p_opposing, dtype=float)
|
|
42
|
+
p_a = np.asarray(p_a, dtype=float)
|
|
43
|
+
p_b = np.asarray(p_b, dtype=float)
|
|
44
|
+
lo, hi = 0.0, 1.0
|
|
45
|
+
for _ in range(24):
|
|
46
|
+
t = 0.5 * (lo + hi)
|
|
47
|
+
new_c = p_c + t * (p_opp - p_c)
|
|
48
|
+
if _angle_deg(new_c, p_a, p_b) > target_deg:
|
|
49
|
+
lo = t
|
|
50
|
+
else:
|
|
51
|
+
hi = t
|
|
52
|
+
return p_c + 0.5 * (lo + hi) * (p_opp - p_c)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _scan_bad_quads(mesh: CHILmesh):
|
|
56
|
+
"""Yield (elem_id, corner, opposing, ci, verts, p_prev, p_next) for bad boundary quads."""
|
|
57
|
+
bdy_edges = set(int(e) for e in mesh.boundary_edges())
|
|
58
|
+
cl = mesh.connectivity_list
|
|
59
|
+
pts = mesh.points
|
|
60
|
+
consumed: set[int] = set()
|
|
61
|
+
|
|
62
|
+
for elem_id in range(mesh.n_elems):
|
|
63
|
+
row = cl[elem_id]
|
|
64
|
+
if int(row[2]) == int(row[3]):
|
|
65
|
+
continue # padded tri
|
|
66
|
+
verts = row.astype(int).tolist()
|
|
67
|
+
edges = mesh.elem2edge(elem_id).ravel().astype(int).tolist()
|
|
68
|
+
on_bdy = [i for i, e in enumerate(edges) if int(e) in bdy_edges]
|
|
69
|
+
if len(on_bdy) < 2:
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
# Find two adjacent boundary edges (share a corner vertex).
|
|
73
|
+
adj = None
|
|
74
|
+
for i in range(len(on_bdy) - 1):
|
|
75
|
+
if (on_bdy[i] + 1) % 4 == on_bdy[i + 1]:
|
|
76
|
+
adj = (on_bdy[i], on_bdy[i + 1])
|
|
77
|
+
break
|
|
78
|
+
if adj is None and len(on_bdy) >= 2 and on_bdy[0] == 0 and on_bdy[-1] == 3:
|
|
79
|
+
adj = (3, 0)
|
|
80
|
+
if adj is None:
|
|
81
|
+
continue
|
|
82
|
+
|
|
83
|
+
ci = adj[1]
|
|
84
|
+
corner = verts[ci]
|
|
85
|
+
p_prev = pts[verts[(ci - 1) % 4]]
|
|
86
|
+
p_next = pts[verts[(ci + 1) % 4]]
|
|
87
|
+
if _angle_deg(pts[corner], p_prev, p_next) <= ANGLE_THRESHOLD_DEG:
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
opposing = verts[(ci + 2) % 4]
|
|
91
|
+
|
|
92
|
+
if elem_id in consumed:
|
|
93
|
+
continue
|
|
94
|
+
nbrs: set[int] = set()
|
|
95
|
+
for v in verts:
|
|
96
|
+
nbrs.update(int(e) for e in mesh.get_vertex_elements(int(v)))
|
|
97
|
+
if nbrs & consumed:
|
|
98
|
+
continue
|
|
99
|
+
consumed |= nbrs
|
|
100
|
+
|
|
101
|
+
yield elem_id, corner, opposing, ci, verts, p_prev, p_next
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def cleanup_boundary_quads(mesh: CHILmesh, can_remove_edges: bool = True) -> CHILmesh:
|
|
105
|
+
"""Single pass. Loop caller until stable.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
mesh: Quad (or padded-tri) CHILmesh.
|
|
109
|
+
can_remove_edges: True -> collapse mode (MATLAB-faithful).
|
|
110
|
+
False -> shift mode (v0.2, Python-only).
|
|
111
|
+
"""
|
|
112
|
+
if mesh.connectivity_list.shape[1] != 4:
|
|
113
|
+
return mesh
|
|
114
|
+
|
|
115
|
+
bad = list(_scan_bad_quads(mesh))
|
|
116
|
+
if not bad:
|
|
117
|
+
return mesh
|
|
118
|
+
|
|
119
|
+
new_pts = mesh.points.copy()
|
|
120
|
+
new_rows = mesh.connectivity_list.copy()
|
|
121
|
+
|
|
122
|
+
if can_remove_edges:
|
|
123
|
+
# MATLAB subroutineCleanupBoundaryQuads:
|
|
124
|
+
# side1 = verts[(ci-1)%4], side2 = verts[(ci+1)%4] remapped to corner.
|
|
125
|
+
# Corner stays in place. Bad quad deleted.
|
|
126
|
+
deleted = np.zeros(mesh.n_elems, dtype=bool)
|
|
127
|
+
for elem_id, corner, opposing, ci, verts, p_prev, p_next in bad:
|
|
128
|
+
side1 = verts[(ci - 1) % 4]
|
|
129
|
+
side2 = verts[(ci + 1) % 4]
|
|
130
|
+
new_rows[new_rows == side1] = corner
|
|
131
|
+
new_rows[new_rows == side2] = corner
|
|
132
|
+
deleted[elem_id] = True
|
|
133
|
+
new_rows = new_rows[~deleted]
|
|
134
|
+
out = CHILmesh(new_rows, new_pts, grid_name=getattr(mesh, "grid_name", None))
|
|
135
|
+
return remove_unused_vertices(out)
|
|
136
|
+
else:
|
|
137
|
+
# Shift: move corner toward opposing until angle <= target.
|
|
138
|
+
for elem_id, corner, opposing, ci, verts, p_prev, p_next in bad:
|
|
139
|
+
new_pts[corner] = _shift_to_target_angle(
|
|
140
|
+
mesh.points[corner], p_prev, p_next, mesh.points[opposing]
|
|
141
|
+
)
|
|
142
|
+
return CHILmesh(new_rows, new_pts, grid_name=getattr(mesh, "grid_name", None))
|
quadmesh/cli.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Command-line driver. Replaces MATLAB Main.m for the headless case."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from chilmesh import CHILmesh
|
|
12
|
+
|
|
13
|
+
from .pipeline import run_pipeline
|
|
14
|
+
from .quality_report import compute_quality_stats, format_quality_report
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _load_polygon(path: Path) -> np.ndarray:
|
|
18
|
+
"""Load CSV of XY points. Two columns (x, y). Header row optional."""
|
|
19
|
+
data = np.loadtxt(path, delimiter=",", skiprows=0, ndmin=2)
|
|
20
|
+
if data.shape[1] < 2:
|
|
21
|
+
raise ValueError(f"polygon CSV must have ≥2 cols (XY); got {data.shape}")
|
|
22
|
+
return data[:, :2]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def main(argv=None) -> int:
|
|
26
|
+
"""Entry point. Returns CLI exit code (0 on success)."""
|
|
27
|
+
parser = argparse.ArgumentParser(
|
|
28
|
+
prog="quadmesh",
|
|
29
|
+
description="Convert tri mesh to quad mesh (fort.14 in/out).",
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument("input", type=Path, help="Input fort.14 path.")
|
|
32
|
+
parser.add_argument("-o", "--output", type=Path, required=True,
|
|
33
|
+
help="Output fort.14 path.")
|
|
34
|
+
parser.add_argument("--polygon", type=Path, default=None,
|
|
35
|
+
help="Optional polygon CSV mask (X,Y per row).")
|
|
36
|
+
parser.add_argument("--no-post-process", action="store_true",
|
|
37
|
+
help="Skip doublet/QVM/cleanup/smooth post-process.")
|
|
38
|
+
parser.add_argument("--no-remove-edges", action="store_true",
|
|
39
|
+
help="Don't collapse boundary edges/quads.")
|
|
40
|
+
parser.add_argument("--n-smooth-iter", type=int, default=3,
|
|
41
|
+
help="FEM smooth passes after post-process (default 3).")
|
|
42
|
+
parser.add_argument("--max-outer-iter", type=int, default=5,
|
|
43
|
+
help="Outer post-process loop cap (default 5).")
|
|
44
|
+
parser.add_argument("--max-inner-iter", type=int, default=5,
|
|
45
|
+
help="Inner doublet+QVM loop cap (default 5).")
|
|
46
|
+
args = parser.parse_args(argv)
|
|
47
|
+
|
|
48
|
+
if not args.input.exists():
|
|
49
|
+
parser.error(f"input not found: {args.input}")
|
|
50
|
+
|
|
51
|
+
mesh = CHILmesh.read_from_fort14(args.input)
|
|
52
|
+
print(f"loaded: {args.input.name} — {mesh.n_elems} elems, {mesh.n_verts} verts, {mesh.n_layers} layers")
|
|
53
|
+
|
|
54
|
+
polygon = _load_polygon(args.polygon) if args.polygon else None
|
|
55
|
+
|
|
56
|
+
out = run_pipeline(
|
|
57
|
+
mesh,
|
|
58
|
+
polygon=polygon,
|
|
59
|
+
can_remove_edges=not args.no_remove_edges,
|
|
60
|
+
n_smooth_iter=args.n_smooth_iter,
|
|
61
|
+
do_post_process=not args.no_post_process,
|
|
62
|
+
max_outer_iter=args.max_outer_iter,
|
|
63
|
+
max_inner_iter=args.max_inner_iter,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
stats = compute_quality_stats(out)
|
|
67
|
+
print(f"output: {out.n_elems} elems, {out.n_verts} verts")
|
|
68
|
+
print(format_quality_report(stats))
|
|
69
|
+
out.write_to_fort14(str(args.output))
|
|
70
|
+
print(f"written: {args.output}")
|
|
71
|
+
return 0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
if __name__ == "__main__":
|
|
75
|
+
sys.exit(main())
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Subset selection for tri→quad conversion. Port of createQuadDomain.m.
|
|
2
|
+
|
|
3
|
+
MATLAB had 3 strategies (all-mesh, distance-from-shoreline, polygon).
|
|
4
|
+
v0.1 supports only:
|
|
5
|
+
- None polygon → all triangles
|
|
6
|
+
- polygon ndarray → tris with at least one vert in polygon
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import List, Optional, Sequence, Union
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
from matplotlib.path import Path as MplPath
|
|
15
|
+
|
|
16
|
+
from chilmesh import CHILmesh
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
PolygonInput = Union[np.ndarray, Sequence[np.ndarray]]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def create_quad_domain(mesh: CHILmesh, polygon: Optional[PolygonInput] = None) -> CHILmesh:
|
|
23
|
+
"""Pick the tri subset to convert.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
mesh: Triangular CHILmesh.
|
|
27
|
+
polygon: Either a single ``(N,2)`` array of XY points, or a list of
|
|
28
|
+
such arrays (union mask). ``None`` → all tris.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
New CHILmesh of selected tris. Points list unchanged (extra verts get
|
|
32
|
+
pruned by `remove_unused_vertices` downstream if needed).
|
|
33
|
+
"""
|
|
34
|
+
if polygon is None:
|
|
35
|
+
return mesh.copy() if hasattr(mesh, "copy") else mesh
|
|
36
|
+
|
|
37
|
+
# Normalise to list of polygons.
|
|
38
|
+
if isinstance(polygon, np.ndarray) and polygon.ndim == 2:
|
|
39
|
+
polygons = [polygon]
|
|
40
|
+
else:
|
|
41
|
+
polygons = [np.asarray(p) for p in polygon]
|
|
42
|
+
|
|
43
|
+
in_pts = np.zeros(mesh.n_verts, dtype=bool)
|
|
44
|
+
xy = mesh.points[:, :2]
|
|
45
|
+
for poly in polygons:
|
|
46
|
+
path = MplPath(np.asarray(poly)[:, :2])
|
|
47
|
+
in_pts |= path.contains_points(xy)
|
|
48
|
+
|
|
49
|
+
elem_in = np.any(in_pts[mesh.connectivity_list[:, :3]], axis=1)
|
|
50
|
+
if not elem_in.any():
|
|
51
|
+
raise ValueError("polygon flagged no triangles")
|
|
52
|
+
|
|
53
|
+
sub_conn = mesh.connectivity_list[elem_in]
|
|
54
|
+
return CHILmesh(sub_conn, mesh.points.copy(), grid_name=getattr(mesh, "grid_name", None))
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Doublet collapse. Port of MATLAB DoubletCollapse.m.
|
|
2
|
+
|
|
3
|
+
Two quads share 3 vertices → collapse to 1 quad. Triggered when an interior
|
|
4
|
+
vertex has valence exactly 2 and both incident elems are quads. The shared
|
|
5
|
+
"diagonal" vert is removed; the two quads merge.
|
|
6
|
+
|
|
7
|
+
In MATLAB the test was: ``interior valence-2 vertex, both elems quads``.
|
|
8
|
+
Output: collapsed mesh; the consumed vert ID added to ``removed_vert_ids``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from chilmesh import CHILmesh
|
|
16
|
+
|
|
17
|
+
from .remove_unused import remove_unused_vertices
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _is_quad_row(row: np.ndarray) -> bool:
|
|
21
|
+
"""4-col conn row is a quad iff its 3rd and 4th verts differ."""
|
|
22
|
+
return row.shape[0] == 4 and int(row[2]) != int(row[3])
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def doublet_collapse(mesh: CHILmesh) -> CHILmesh:
|
|
26
|
+
"""Collapse every valence-2 interior vert whose two adjacent elems are quads.
|
|
27
|
+
|
|
28
|
+
Returns a new CHILmesh with affected pairs merged into single quads, and
|
|
29
|
+
unreferenced verts pruned.
|
|
30
|
+
"""
|
|
31
|
+
if mesh.connectivity_list.shape[1] != 4:
|
|
32
|
+
return mesh # No quads, nothing to do.
|
|
33
|
+
|
|
34
|
+
bdy_verts = set(int(v) for v in mesh.boundary_node_indices())
|
|
35
|
+
int_verts = [v for v in range(mesh.n_verts) if v not in bdy_verts]
|
|
36
|
+
|
|
37
|
+
n_elems = mesh.n_elems
|
|
38
|
+
consumed = np.zeros(n_elems, dtype=bool)
|
|
39
|
+
new_rows = mesh.connectivity_list.copy()
|
|
40
|
+
|
|
41
|
+
for v in int_verts:
|
|
42
|
+
elems = list(mesh.get_vertex_elements(v))
|
|
43
|
+
if len(elems) != 2:
|
|
44
|
+
continue
|
|
45
|
+
ea, eb = int(elems[0]), int(elems[1])
|
|
46
|
+
if consumed[ea] or consumed[eb]:
|
|
47
|
+
continue
|
|
48
|
+
if not (_is_quad_row(new_rows[ea]) and _is_quad_row(new_rows[eb])):
|
|
49
|
+
continue
|
|
50
|
+
|
|
51
|
+
ra = new_rows[ea]
|
|
52
|
+
rb = new_rows[eb]
|
|
53
|
+
ra_set = set(ra.tolist())
|
|
54
|
+
rb_set = set(rb.tolist())
|
|
55
|
+
common = ra_set & rb_set
|
|
56
|
+
# Need exactly 3 shared verts for the doublet pattern.
|
|
57
|
+
if len(common) != 3:
|
|
58
|
+
continue
|
|
59
|
+
|
|
60
|
+
# Vert in rb but not in ra:
|
|
61
|
+
unique_b = next(iter(rb_set - ra_set))
|
|
62
|
+
|
|
63
|
+
# Replace v in ra with unique_b.
|
|
64
|
+
merged = ra.copy()
|
|
65
|
+
merged[merged == v] = unique_b
|
|
66
|
+
# Sanity: merged should still be 4 distinct verts.
|
|
67
|
+
if len(set(merged.tolist())) != 4:
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
new_rows[ea] = merged
|
|
71
|
+
new_rows[eb] = 0 # Mark for deletion.
|
|
72
|
+
consumed[eb] = True
|
|
73
|
+
|
|
74
|
+
if not consumed.any():
|
|
75
|
+
return mesh
|
|
76
|
+
|
|
77
|
+
keep_mask = ~consumed
|
|
78
|
+
new_rows = new_rows[keep_mask]
|
|
79
|
+
out = CHILmesh(new_rows, mesh.points.copy(), grid_name=getattr(mesh, "grid_name", None))
|
|
80
|
+
return remove_unused_vertices(out)
|