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.
- quadmesh-0.1.0/PKG-INFO +77 -0
- quadmesh-0.1.0/README.md +60 -0
- quadmesh-0.1.0/pyproject.toml +30 -0
- quadmesh-0.1.0/quadmesh/__init__.py +30 -0
- quadmesh-0.1.0/quadmesh/_topology.py +100 -0
- quadmesh-0.1.0/quadmesh/_tri_removal.py +202 -0
- quadmesh-0.1.0/quadmesh/cleanup_boundary_quads.py +142 -0
- quadmesh-0.1.0/quadmesh/cli.py +75 -0
- quadmesh-0.1.0/quadmesh/create_quad_domain.py +54 -0
- quadmesh-0.1.0/quadmesh/doublet_collapse.py +80 -0
- quadmesh-0.1.0/quadmesh/identify_edges.py +209 -0
- quadmesh-0.1.0/quadmesh/pipeline.py +49 -0
- quadmesh-0.1.0/quadmesh/post_process.py +101 -0
- quadmesh-0.1.0/quadmesh/quad_vertex_merge.py +84 -0
- quadmesh-0.1.0/quadmesh/quality_report.py +37 -0
- quadmesh-0.1.0/quadmesh/remove_unused.py +25 -0
- quadmesh-0.1.0/quadmesh/repair.py +478 -0
- quadmesh-0.1.0/quadmesh/tri2quad.py +97 -0
- quadmesh-0.1.0/quadmesh/validation/__init__.py +18 -0
- quadmesh-0.1.0/quadmesh/validation/broadphase.py +64 -0
- quadmesh-0.1.0/quadmesh/validation/fixtures.py +160 -0
- quadmesh-0.1.0/quadmesh/validation/predicates.py +172 -0
- quadmesh-0.1.0/quadmesh/validation/types.py +34 -0
- quadmesh-0.1.0/quadmesh/validation/validator.py +361 -0
- quadmesh-0.1.0/quadmesh.egg-info/PKG-INFO +77 -0
- quadmesh-0.1.0/quadmesh.egg-info/SOURCES.txt +40 -0
- quadmesh-0.1.0/quadmesh.egg-info/dependency_links.txt +1 -0
- quadmesh-0.1.0/quadmesh.egg-info/entry_points.txt +2 -0
- quadmesh-0.1.0/quadmesh.egg-info/requires.txt +10 -0
- quadmesh-0.1.0/quadmesh.egg-info/top_level.txt +1 -0
- quadmesh-0.1.0/setup.cfg +4 -0
- quadmesh-0.1.0/tests/test_cleanup_bq.py +145 -0
- quadmesh-0.1.0/tests/test_cli.py +35 -0
- quadmesh-0.1.0/tests/test_identify_edges.py +37 -0
- quadmesh-0.1.0/tests/test_parity.py +110 -0
- quadmesh-0.1.0/tests/test_pipeline.py +61 -0
- quadmesh-0.1.0/tests/test_quality.py +33 -0
- quadmesh-0.1.0/tests/test_repair.py +134 -0
- quadmesh-0.1.0/tests/test_smoother.py +20 -0
- quadmesh-0.1.0/tests/test_topology.py +89 -0
- quadmesh-0.1.0/tests/test_tri2quad_smoke.py +38 -0
- quadmesh-0.1.0/tests/test_tri_removal.py +292 -0
quadmesh-0.1.0/PKG-INFO
ADDED
|
@@ -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.
|
quadmesh-0.1.0/README.md
ADDED
|
@@ -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))
|