quadmesh 0.1.0__tar.gz

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.
Files changed (42) hide show
  1. quadmesh-0.1.0/PKG-INFO +77 -0
  2. quadmesh-0.1.0/README.md +60 -0
  3. quadmesh-0.1.0/pyproject.toml +30 -0
  4. quadmesh-0.1.0/quadmesh/__init__.py +30 -0
  5. quadmesh-0.1.0/quadmesh/_topology.py +100 -0
  6. quadmesh-0.1.0/quadmesh/_tri_removal.py +202 -0
  7. quadmesh-0.1.0/quadmesh/cleanup_boundary_quads.py +142 -0
  8. quadmesh-0.1.0/quadmesh/cli.py +75 -0
  9. quadmesh-0.1.0/quadmesh/create_quad_domain.py +54 -0
  10. quadmesh-0.1.0/quadmesh/doublet_collapse.py +80 -0
  11. quadmesh-0.1.0/quadmesh/identify_edges.py +209 -0
  12. quadmesh-0.1.0/quadmesh/pipeline.py +49 -0
  13. quadmesh-0.1.0/quadmesh/post_process.py +101 -0
  14. quadmesh-0.1.0/quadmesh/quad_vertex_merge.py +84 -0
  15. quadmesh-0.1.0/quadmesh/quality_report.py +37 -0
  16. quadmesh-0.1.0/quadmesh/remove_unused.py +25 -0
  17. quadmesh-0.1.0/quadmesh/repair.py +478 -0
  18. quadmesh-0.1.0/quadmesh/tri2quad.py +97 -0
  19. quadmesh-0.1.0/quadmesh/validation/__init__.py +18 -0
  20. quadmesh-0.1.0/quadmesh/validation/broadphase.py +64 -0
  21. quadmesh-0.1.0/quadmesh/validation/fixtures.py +160 -0
  22. quadmesh-0.1.0/quadmesh/validation/predicates.py +172 -0
  23. quadmesh-0.1.0/quadmesh/validation/types.py +34 -0
  24. quadmesh-0.1.0/quadmesh/validation/validator.py +361 -0
  25. quadmesh-0.1.0/quadmesh.egg-info/PKG-INFO +77 -0
  26. quadmesh-0.1.0/quadmesh.egg-info/SOURCES.txt +40 -0
  27. quadmesh-0.1.0/quadmesh.egg-info/dependency_links.txt +1 -0
  28. quadmesh-0.1.0/quadmesh.egg-info/entry_points.txt +2 -0
  29. quadmesh-0.1.0/quadmesh.egg-info/requires.txt +10 -0
  30. quadmesh-0.1.0/quadmesh.egg-info/top_level.txt +1 -0
  31. quadmesh-0.1.0/setup.cfg +4 -0
  32. quadmesh-0.1.0/tests/test_cleanup_bq.py +145 -0
  33. quadmesh-0.1.0/tests/test_cli.py +35 -0
  34. quadmesh-0.1.0/tests/test_identify_edges.py +37 -0
  35. quadmesh-0.1.0/tests/test_parity.py +110 -0
  36. quadmesh-0.1.0/tests/test_pipeline.py +61 -0
  37. quadmesh-0.1.0/tests/test_quality.py +33 -0
  38. quadmesh-0.1.0/tests/test_repair.py +134 -0
  39. quadmesh-0.1.0/tests/test_smoother.py +20 -0
  40. quadmesh-0.1.0/tests/test_topology.py +89 -0
  41. quadmesh-0.1.0/tests/test_tri2quad_smoke.py +38 -0
  42. quadmesh-0.1.0/tests/test_tri_removal.py +292 -0
@@ -0,0 +1,77 @@
1
+ Metadata-Version: 2.4
2
+ Name: quadmesh
3
+ Version: 0.1.0
4
+ Summary: QuADMESH+: Quadrangular ADvanced Mesh generator. Python port of MATLAB QuADMESH library.
5
+ Author: Dominik Mattioli
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: numpy>=1.24
10
+ Requires-Dist: scipy>=1.10
11
+ Requires-Dist: chilmesh>=0.4.0
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest>=7; extra == "dev"
14
+ Requires-Dist: pytest-cov; extra == "dev"
15
+ Provides-Extra: plot
16
+ Requires-Dist: matplotlib>=3.6; extra == "plot"
17
+
18
+ # quadmesh
19
+
20
+ Tri-to-quad mesh generator. Port of MATLAB QuADMESH+. Build on `chilmesh`.
21
+
22
+ ## Pipeline
23
+
24
+ ```
25
+ tri -> create_quad_domain -> tri2quad -> post_process -> quad
26
+ ```
27
+
28
+ 1. `create_quad_domain` — pick tris (whole mesh or polygon mask).
29
+ 2. `tri2quad` — sweep layers outward, merge tri pairs into quads.
30
+ 3. `post_process` — doublet collapse, quad-vert merge, boundary cleanup, smooth.
31
+
32
+ ## Install
33
+
34
+ ```bash
35
+ pip install -e .
36
+ pytest # 25+ tests
37
+ ```
38
+
39
+ ## Use
40
+
41
+ ```python
42
+ from chilmesh import CHILmesh
43
+ from quadmesh import tri2quad, post_process, two_part_smoother
44
+
45
+ tri = CHILmesh.read_from_fort14("mesh.14")
46
+ quad = tri2quad(tri)
47
+ quad = post_process(quad, n_smooth_iter=50)
48
+ ```
49
+
50
+ ## CLI
51
+
52
+ ```bash
53
+ quadmesh mesh.14 -o out.14
54
+ quadmesh mesh.14 -o out.14 --no-remove-edges --n-smooth-iter 100
55
+ quadmesh mesh.14 -o out.14 --no-post-process
56
+ ```
57
+
58
+ ## v0.2 new
59
+
60
+ - `CleanupBoundaryQuads` shift mode (`can_remove_edges=False`). Move corner inward. Before: no-op. Now: work.
61
+ - `two_part_smoother`. Interleave angle + FEM smooth. Port of MATLAB `twoPartSmoother.m`.
62
+ - 25+ tests.
63
+
64
+ ## Numbers
65
+
66
+ | Mesh | Tris | Layers | Pipeline |
67
+ |---|---|---|---|
68
+ | Test_Case_1 | 2417 | 7 | <1 s |
69
+ | Block_O | 5214 | 9 | ~1.2 s |
70
+
71
+ ## Provenance
72
+
73
+ MAT src: `../02_QuADMESH_Library/`. Map: `MAPPING.md`. Spec: `../specs/001-matlab-to-python-port/`.
74
+
75
+ ## Cite
76
+
77
+ Mattioli, D. D. (2017). _QuADMESH+: A Quadrangular ADvanced Mesh Generator_. Master's thesis, OSU.
@@ -0,0 +1,60 @@
1
+ # quadmesh
2
+
3
+ Tri-to-quad mesh generator. Port of MATLAB QuADMESH+. Build on `chilmesh`.
4
+
5
+ ## Pipeline
6
+
7
+ ```
8
+ tri -> create_quad_domain -> tri2quad -> post_process -> quad
9
+ ```
10
+
11
+ 1. `create_quad_domain` — pick tris (whole mesh or polygon mask).
12
+ 2. `tri2quad` — sweep layers outward, merge tri pairs into quads.
13
+ 3. `post_process` — doublet collapse, quad-vert merge, boundary cleanup, smooth.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pip install -e .
19
+ pytest # 25+ tests
20
+ ```
21
+
22
+ ## Use
23
+
24
+ ```python
25
+ from chilmesh import CHILmesh
26
+ from quadmesh import tri2quad, post_process, two_part_smoother
27
+
28
+ tri = CHILmesh.read_from_fort14("mesh.14")
29
+ quad = tri2quad(tri)
30
+ quad = post_process(quad, n_smooth_iter=50)
31
+ ```
32
+
33
+ ## CLI
34
+
35
+ ```bash
36
+ quadmesh mesh.14 -o out.14
37
+ quadmesh mesh.14 -o out.14 --no-remove-edges --n-smooth-iter 100
38
+ quadmesh mesh.14 -o out.14 --no-post-process
39
+ ```
40
+
41
+ ## v0.2 new
42
+
43
+ - `CleanupBoundaryQuads` shift mode (`can_remove_edges=False`). Move corner inward. Before: no-op. Now: work.
44
+ - `two_part_smoother`. Interleave angle + FEM smooth. Port of MATLAB `twoPartSmoother.m`.
45
+ - 25+ tests.
46
+
47
+ ## Numbers
48
+
49
+ | Mesh | Tris | Layers | Pipeline |
50
+ |---|---|---|---|
51
+ | Test_Case_1 | 2417 | 7 | <1 s |
52
+ | Block_O | 5214 | 9 | ~1.2 s |
53
+
54
+ ## Provenance
55
+
56
+ MAT src: `../02_QuADMESH_Library/`. Map: `MAPPING.md`. Spec: `../specs/001-matlab-to-python-port/`.
57
+
58
+ ## Cite
59
+
60
+ Mattioli, D. D. (2017). _QuADMESH+: A Quadrangular ADvanced Mesh Generator_. Master's thesis, OSU.
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "quadmesh"
7
+ version = "0.1.0"
8
+ description = "QuADMESH+: Quadrangular ADvanced Mesh generator. Python port of MATLAB QuADMESH library."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ authors = [
13
+ { name = "Dominik Mattioli" },
14
+ ]
15
+ dependencies = [
16
+ "numpy>=1.24",
17
+ "scipy>=1.10",
18
+ "chilmesh>=0.4.0",
19
+ ]
20
+
21
+ [project.optional-dependencies]
22
+ dev = ["pytest>=7", "pytest-cov"]
23
+ plot = ["matplotlib>=3.6"]
24
+
25
+ [project.scripts]
26
+ quadmesh = "quadmesh.cli:main"
27
+
28
+ [tool.setuptools.packages.find]
29
+ where = ["."]
30
+ include = ["quadmesh*"]
@@ -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}")
@@ -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
@@ -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))