thinskin 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.
- thinskin-0.1.0/.gitignore +29 -0
- thinskin-0.1.0/LICENSE +21 -0
- thinskin-0.1.0/PKG-INFO +145 -0
- thinskin-0.1.0/README.md +108 -0
- thinskin-0.1.0/pyproject.toml +63 -0
- thinskin-0.1.0/src/thinskin/__init__.py +28 -0
- thinskin-0.1.0/src/thinskin/__main__.py +6 -0
- thinskin-0.1.0/src/thinskin/cli.py +204 -0
- thinskin-0.1.0/src/thinskin/core.py +161 -0
- thinskin-0.1.0/src/thinskin/report.py +300 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.egg-info/
|
|
6
|
+
.eggs/
|
|
7
|
+
build/
|
|
8
|
+
dist/
|
|
9
|
+
wheels/
|
|
10
|
+
.venv/
|
|
11
|
+
venv/
|
|
12
|
+
env/
|
|
13
|
+
|
|
14
|
+
# Packaging / tooling
|
|
15
|
+
.pytest_cache/
|
|
16
|
+
.mypy_cache/
|
|
17
|
+
.ruff_cache/
|
|
18
|
+
.coverage
|
|
19
|
+
htmlcov/
|
|
20
|
+
|
|
21
|
+
# OS / editor
|
|
22
|
+
.DS_Store
|
|
23
|
+
.idea/
|
|
24
|
+
.vscode/
|
|
25
|
+
|
|
26
|
+
# Generated analysis outputs
|
|
27
|
+
*_narrow.stl
|
|
28
|
+
*_narrow_coloured.obj
|
|
29
|
+
*_annotated_views.png
|
thinskin-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SemiQuant (Jason Limberis)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
thinskin-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: thinskin
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Identify and annotate thin (narrow) regions in STL meshes.
|
|
5
|
+
Project-URL: Homepage, https://github.com/SemiQuant/ThinSkin
|
|
6
|
+
Project-URL: Repository, https://github.com/SemiQuant/ThinSkin
|
|
7
|
+
Project-URL: Issues, https://github.com/SemiQuant/ThinSkin/issues
|
|
8
|
+
Author-email: SemiQuant <JasonLimberis@ucsf.edu>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: 3d-printing,cad,mesh,stl,thickness,trimesh
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Manufacturing
|
|
15
|
+
Classifier: Intended Audience :: Science/Research
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Topic :: Multimedia :: Graphics :: 3D Modeling
|
|
24
|
+
Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
25
|
+
Requires-Python: >=3.9
|
|
26
|
+
Requires-Dist: click>=8.0
|
|
27
|
+
Requires-Dist: matplotlib>=3.5
|
|
28
|
+
Requires-Dist: numpy>=1.21
|
|
29
|
+
Requires-Dist: rtree>=1.0
|
|
30
|
+
Requires-Dist: scipy>=1.7
|
|
31
|
+
Requires-Dist: trimesh>=3.20
|
|
32
|
+
Provides-Extra: dev
|
|
33
|
+
Requires-Dist: build; extra == 'dev'
|
|
34
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
35
|
+
Requires-Dist: twine; extra == 'dev'
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
|
|
38
|
+
# ThinSkin
|
|
39
|
+
|
|
40
|
+
[](https://pypi.org/project/thinskin/)
|
|
41
|
+
[](https://pypi.org/project/thinskin/)
|
|
42
|
+
[](LICENSE)
|
|
43
|
+
|
|
44
|
+
**ThinSkin** analyses an STL mesh and identifies regions where the local
|
|
45
|
+
geometry is **thinner than a specified diameter threshold** — handy for
|
|
46
|
+
checking 3D-print wall thickness, minimum feature sizes, and structurally
|
|
47
|
+
fragile areas before manufacturing.
|
|
48
|
+
|
|
49
|
+
## How it works
|
|
50
|
+
|
|
51
|
+
For every vertex in the mesh, ThinSkin estimates the local *inscribed
|
|
52
|
+
diameter* using two complementary approaches and takes the **minimum** of the
|
|
53
|
+
two as the effective local diameter:
|
|
54
|
+
|
|
55
|
+
1. **Ray-cast thickness** — casts a ray inward along the inverted vertex
|
|
56
|
+
normal and measures the distance to the nearest opposing surface.
|
|
57
|
+
2. **Local curvature radius** — derived from the mean curvature at each vertex
|
|
58
|
+
via the cotangent-weighted Laplacian.
|
|
59
|
+
|
|
60
|
+
Vertices whose effective diameter falls below the threshold are flagged as
|
|
61
|
+
*narrow*.
|
|
62
|
+
|
|
63
|
+
## Installation
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pip install thinskin
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
ThinSkin uses [`trimesh`](https://trimesh.org/) for ray casting, which relies
|
|
70
|
+
on [`rtree`](https://pypi.org/project/Rtree/) (bundled `libspatialindex`). If
|
|
71
|
+
you hit native-library issues, install via conda/mamba:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
micromamba install -c conda-forge rtree
|
|
75
|
+
pip install thinskin
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### From source
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
git clone https://github.com/SemiQuant/ThinSkin.git
|
|
82
|
+
cd ThinSkin
|
|
83
|
+
pip install -e ".[dev]"
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Usage
|
|
87
|
+
|
|
88
|
+
### Command line
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
thinskin model.stl --diameter 2.0
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Options:
|
|
95
|
+
|
|
96
|
+
| Option | Alias | Default | Description |
|
|
97
|
+
| --- | --- | --- | --- |
|
|
98
|
+
| `--diameter` | `-d` | `2.0` | Minimum diameter threshold (mm). |
|
|
99
|
+
| `--output` | `-o` | `<input>_narrow.stl` | Output STL path. |
|
|
100
|
+
| `--version` | `-V` | | Print version and exit. |
|
|
101
|
+
| `--help` | `-h` | | Show help and exit. |
|
|
102
|
+
|
|
103
|
+
You can also run it as a module:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
python -m thinskin model.stl -d 1.5
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Outputs
|
|
110
|
+
|
|
111
|
+
For an input `model.stl`, ThinSkin writes (when narrow regions are found):
|
|
112
|
+
|
|
113
|
+
| File | Description |
|
|
114
|
+
| --- | --- |
|
|
115
|
+
| `model_narrow.stl` | Mesh containing only the flagged (narrow) faces — overlay in MeshLab. |
|
|
116
|
+
| `model_narrow_coloured.obj` | Full mesh with per-vertex colours (red = narrow, green = OK). |
|
|
117
|
+
| `model_narrow_annotated_views.png` | CAD-style 2×2 multi-view annotated report. |
|
|
118
|
+
|
|
119
|
+
The console also prints mesh stats, a results summary, and a diameter
|
|
120
|
+
distribution histogram as tables.
|
|
121
|
+
|
|
122
|
+
### Python API
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
import thinskin
|
|
126
|
+
|
|
127
|
+
mesh = thinskin.load_mesh("model.stl")
|
|
128
|
+
eff_diam, thickness, curv_radius = thinskin.compute_effective_diameter(mesh)
|
|
129
|
+
|
|
130
|
+
threshold = 2.0
|
|
131
|
+
narrow = eff_diam < threshold
|
|
132
|
+
print(f"{int(narrow.sum())} narrow vertices below {threshold} mm")
|
|
133
|
+
|
|
134
|
+
# Render the annotated report
|
|
135
|
+
thinskin.generate_annotated_report(mesh, eff_diam, threshold, "model_narrow.stl")
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Requirements
|
|
139
|
+
|
|
140
|
+
- Python ≥ 3.9
|
|
141
|
+
- `click`, `numpy`, `scipy`, `trimesh`, `rtree`, `matplotlib`
|
|
142
|
+
|
|
143
|
+
## License
|
|
144
|
+
|
|
145
|
+
[MIT](LICENSE) © SemiQuant (Jason Limberis)
|
thinskin-0.1.0/README.md
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# ThinSkin
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/thinskin/)
|
|
4
|
+
[](https://pypi.org/project/thinskin/)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
**ThinSkin** analyses an STL mesh and identifies regions where the local
|
|
8
|
+
geometry is **thinner than a specified diameter threshold** — handy for
|
|
9
|
+
checking 3D-print wall thickness, minimum feature sizes, and structurally
|
|
10
|
+
fragile areas before manufacturing.
|
|
11
|
+
|
|
12
|
+
## How it works
|
|
13
|
+
|
|
14
|
+
For every vertex in the mesh, ThinSkin estimates the local *inscribed
|
|
15
|
+
diameter* using two complementary approaches and takes the **minimum** of the
|
|
16
|
+
two as the effective local diameter:
|
|
17
|
+
|
|
18
|
+
1. **Ray-cast thickness** — casts a ray inward along the inverted vertex
|
|
19
|
+
normal and measures the distance to the nearest opposing surface.
|
|
20
|
+
2. **Local curvature radius** — derived from the mean curvature at each vertex
|
|
21
|
+
via the cotangent-weighted Laplacian.
|
|
22
|
+
|
|
23
|
+
Vertices whose effective diameter falls below the threshold are flagged as
|
|
24
|
+
*narrow*.
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install thinskin
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
ThinSkin uses [`trimesh`](https://trimesh.org/) for ray casting, which relies
|
|
33
|
+
on [`rtree`](https://pypi.org/project/Rtree/) (bundled `libspatialindex`). If
|
|
34
|
+
you hit native-library issues, install via conda/mamba:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
micromamba install -c conda-forge rtree
|
|
38
|
+
pip install thinskin
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### From source
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
git clone https://github.com/SemiQuant/ThinSkin.git
|
|
45
|
+
cd ThinSkin
|
|
46
|
+
pip install -e ".[dev]"
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Usage
|
|
50
|
+
|
|
51
|
+
### Command line
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
thinskin model.stl --diameter 2.0
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Options:
|
|
58
|
+
|
|
59
|
+
| Option | Alias | Default | Description |
|
|
60
|
+
| --- | --- | --- | --- |
|
|
61
|
+
| `--diameter` | `-d` | `2.0` | Minimum diameter threshold (mm). |
|
|
62
|
+
| `--output` | `-o` | `<input>_narrow.stl` | Output STL path. |
|
|
63
|
+
| `--version` | `-V` | | Print version and exit. |
|
|
64
|
+
| `--help` | `-h` | | Show help and exit. |
|
|
65
|
+
|
|
66
|
+
You can also run it as a module:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
python -m thinskin model.stl -d 1.5
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Outputs
|
|
73
|
+
|
|
74
|
+
For an input `model.stl`, ThinSkin writes (when narrow regions are found):
|
|
75
|
+
|
|
76
|
+
| File | Description |
|
|
77
|
+
| --- | --- |
|
|
78
|
+
| `model_narrow.stl` | Mesh containing only the flagged (narrow) faces — overlay in MeshLab. |
|
|
79
|
+
| `model_narrow_coloured.obj` | Full mesh with per-vertex colours (red = narrow, green = OK). |
|
|
80
|
+
| `model_narrow_annotated_views.png` | CAD-style 2×2 multi-view annotated report. |
|
|
81
|
+
|
|
82
|
+
The console also prints mesh stats, a results summary, and a diameter
|
|
83
|
+
distribution histogram as tables.
|
|
84
|
+
|
|
85
|
+
### Python API
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
import thinskin
|
|
89
|
+
|
|
90
|
+
mesh = thinskin.load_mesh("model.stl")
|
|
91
|
+
eff_diam, thickness, curv_radius = thinskin.compute_effective_diameter(mesh)
|
|
92
|
+
|
|
93
|
+
threshold = 2.0
|
|
94
|
+
narrow = eff_diam < threshold
|
|
95
|
+
print(f"{int(narrow.sum())} narrow vertices below {threshold} mm")
|
|
96
|
+
|
|
97
|
+
# Render the annotated report
|
|
98
|
+
thinskin.generate_annotated_report(mesh, eff_diam, threshold, "model_narrow.stl")
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Requirements
|
|
102
|
+
|
|
103
|
+
- Python ≥ 3.9
|
|
104
|
+
- `click`, `numpy`, `scipy`, `trimesh`, `rtree`, `matplotlib`
|
|
105
|
+
|
|
106
|
+
## License
|
|
107
|
+
|
|
108
|
+
[MIT](LICENSE) © SemiQuant (Jason Limberis)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "thinskin"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Identify and annotate thin (narrow) regions in STL meshes."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "SemiQuant", email = "JasonLimberis@ucsf.edu" },
|
|
14
|
+
]
|
|
15
|
+
keywords = ["stl", "mesh", "3d-printing", "thickness", "cad", "trimesh"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Environment :: Console",
|
|
19
|
+
"Intended Audience :: Manufacturing",
|
|
20
|
+
"Intended Audience :: Science/Research",
|
|
21
|
+
"License :: OSI Approved :: MIT License",
|
|
22
|
+
"Operating System :: OS Independent",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Programming Language :: Python :: 3.9",
|
|
25
|
+
"Programming Language :: Python :: 3.10",
|
|
26
|
+
"Programming Language :: Python :: 3.11",
|
|
27
|
+
"Programming Language :: Python :: 3.12",
|
|
28
|
+
"Topic :: Scientific/Engineering :: Visualization",
|
|
29
|
+
"Topic :: Multimedia :: Graphics :: 3D Modeling",
|
|
30
|
+
]
|
|
31
|
+
dependencies = [
|
|
32
|
+
"click>=8.0",
|
|
33
|
+
"numpy>=1.21",
|
|
34
|
+
"scipy>=1.7",
|
|
35
|
+
"trimesh>=3.20",
|
|
36
|
+
"rtree>=1.0",
|
|
37
|
+
"matplotlib>=3.5",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[project.optional-dependencies]
|
|
41
|
+
dev = [
|
|
42
|
+
"build",
|
|
43
|
+
"twine",
|
|
44
|
+
"pytest",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
[project.urls]
|
|
48
|
+
Homepage = "https://github.com/SemiQuant/ThinSkin"
|
|
49
|
+
Repository = "https://github.com/SemiQuant/ThinSkin"
|
|
50
|
+
Issues = "https://github.com/SemiQuant/ThinSkin/issues"
|
|
51
|
+
|
|
52
|
+
[project.scripts]
|
|
53
|
+
thinskin = "thinskin.cli:main"
|
|
54
|
+
|
|
55
|
+
[tool.hatch.build.targets.wheel]
|
|
56
|
+
packages = ["src/thinskin"]
|
|
57
|
+
|
|
58
|
+
[tool.hatch.build.targets.sdist]
|
|
59
|
+
include = [
|
|
60
|
+
"src/thinskin",
|
|
61
|
+
"README.md",
|
|
62
|
+
"LICENSE",
|
|
63
|
+
]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""ThinSkin - identify thin (narrow) regions in STL meshes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__version__ = "0.1.0"
|
|
6
|
+
|
|
7
|
+
from .core import (
|
|
8
|
+
AnalysisResult,
|
|
9
|
+
compute_effective_diameter,
|
|
10
|
+
diameter_breakdown,
|
|
11
|
+
estimate_curvature_radius,
|
|
12
|
+
estimate_thickness_raycasting,
|
|
13
|
+
export_coloured_obj,
|
|
14
|
+
load_mesh,
|
|
15
|
+
)
|
|
16
|
+
from .report import generate_annotated_report
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"__version__",
|
|
20
|
+
"AnalysisResult",
|
|
21
|
+
"compute_effective_diameter",
|
|
22
|
+
"diameter_breakdown",
|
|
23
|
+
"estimate_curvature_radius",
|
|
24
|
+
"estimate_thickness_raycasting",
|
|
25
|
+
"export_coloured_obj",
|
|
26
|
+
"load_mesh",
|
|
27
|
+
"generate_annotated_report",
|
|
28
|
+
]
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Command-line interface for ThinSkin (built on click)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .core import (
|
|
12
|
+
AnalysisResult,
|
|
13
|
+
compute_effective_diameter,
|
|
14
|
+
diameter_breakdown,
|
|
15
|
+
export_coloured_obj,
|
|
16
|
+
load_mesh,
|
|
17
|
+
)
|
|
18
|
+
from .report import generate_annotated_report
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# -- small table helper --------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
def _render_table(rows, headers=None, aligns=None):
|
|
24
|
+
"""Render a simple, dependency-free aligned text table as a string."""
|
|
25
|
+
cols = list(zip(*([headers] + rows))) if headers else list(zip(*rows))
|
|
26
|
+
widths = [max(len(str(c)) for c in col) for col in cols]
|
|
27
|
+
aligns = aligns or [">"] * len(widths)
|
|
28
|
+
|
|
29
|
+
def fmt_row(cells):
|
|
30
|
+
return " ".join(
|
|
31
|
+
f"{str(c):{a}{w}}" for c, w, a in zip(cells, widths, aligns)
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
lines = []
|
|
35
|
+
if headers:
|
|
36
|
+
lines.append(fmt_row(headers))
|
|
37
|
+
lines.append(" ".join("-" * w for w in widths))
|
|
38
|
+
for r in rows:
|
|
39
|
+
lines.append(fmt_row(r))
|
|
40
|
+
return "\n".join(" " + ln for ln in lines)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _echo_table(rows, headers=None, aligns=None):
|
|
44
|
+
click.echo(_render_table(rows, headers=headers, aligns=aligns))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# -- analysis orchestration ----------------------------------------------------
|
|
48
|
+
|
|
49
|
+
def run_analysis(input_path: str, diameter_threshold: float,
|
|
50
|
+
output_path: str) -> AnalysisResult:
|
|
51
|
+
"""Run the full pipeline, emitting click progress/table output."""
|
|
52
|
+
click.echo()
|
|
53
|
+
click.secho("=" * 60, fg="cyan")
|
|
54
|
+
click.secho(" ThinSkin - STL Narrow Region Analyser", fg="cyan", bold=True)
|
|
55
|
+
click.secho("=" * 60, fg="cyan")
|
|
56
|
+
_echo_table(
|
|
57
|
+
[["Input", input_path], ["Threshold", f"\u00d8 {diameter_threshold} mm"]],
|
|
58
|
+
aligns=["<", "<"],
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
with click.progressbar(length=1, label="Loading mesh ") as bar:
|
|
62
|
+
mesh = load_mesh(input_path)
|
|
63
|
+
bar.update(1)
|
|
64
|
+
|
|
65
|
+
click.echo()
|
|
66
|
+
click.secho(" Mesh info", bold=True)
|
|
67
|
+
bbox = mesh.bounding_box.extents
|
|
68
|
+
_echo_table(
|
|
69
|
+
[
|
|
70
|
+
["Vertices", f"{len(mesh.vertices):,}"],
|
|
71
|
+
["Faces", f"{len(mesh.faces):,}"],
|
|
72
|
+
["Bbox (mm)", f"{bbox[0]:.2f} x {bbox[1]:.2f} x {bbox[2]:.2f}"],
|
|
73
|
+
["Watertight", str(mesh.is_watertight)],
|
|
74
|
+
],
|
|
75
|
+
aligns=["<", "<"],
|
|
76
|
+
)
|
|
77
|
+
click.echo()
|
|
78
|
+
|
|
79
|
+
with click.progressbar(length=2, label="Estimating thickness") as bar:
|
|
80
|
+
from .core import estimate_thickness_raycasting, estimate_curvature_radius
|
|
81
|
+
thickness = estimate_thickness_raycasting(mesh)
|
|
82
|
+
bar.update(1)
|
|
83
|
+
curv_radius = estimate_curvature_radius(mesh)
|
|
84
|
+
bar.update(1)
|
|
85
|
+
eff_diam = np.minimum(thickness, 2.0 * curv_radius)
|
|
86
|
+
|
|
87
|
+
narrow_mask = eff_diam < diameter_threshold
|
|
88
|
+
n_narrow = int(narrow_mask.sum())
|
|
89
|
+
pct = 100.0 * n_narrow / len(mesh.vertices)
|
|
90
|
+
finite_vals = eff_diam[np.isfinite(eff_diam)]
|
|
91
|
+
|
|
92
|
+
result = AnalysisResult(
|
|
93
|
+
mesh=mesh, eff_diam=eff_diam, thickness=thickness,
|
|
94
|
+
curv_radius=curv_radius, threshold=diameter_threshold,
|
|
95
|
+
narrow_mask=narrow_mask, n_narrow=n_narrow, pct_narrow=pct,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
click.echo()
|
|
99
|
+
click.secho(" Results", bold=True)
|
|
100
|
+
res_rows = [["Flagged vertices", f"{n_narrow:,} ({pct:.1f}%)"]]
|
|
101
|
+
if finite_vals.size:
|
|
102
|
+
res_rows += [
|
|
103
|
+
["Min \u00d8", f"{finite_vals.min():.3f} mm"],
|
|
104
|
+
["Mean \u00d8", f"{finite_vals.mean():.3f} mm"],
|
|
105
|
+
["Max \u00d8", f"{finite_vals.max():.3f} mm"],
|
|
106
|
+
]
|
|
107
|
+
_echo_table(res_rows, aligns=["<", "<"])
|
|
108
|
+
|
|
109
|
+
# -- STL / OBJ export ------------------------------------------------------
|
|
110
|
+
if n_narrow == 0:
|
|
111
|
+
click.echo()
|
|
112
|
+
click.secho(f" \u2713 No regions below {diameter_threshold} mm found.",
|
|
113
|
+
fg="green")
|
|
114
|
+
else:
|
|
115
|
+
face_flags = narrow_mask[mesh.faces]
|
|
116
|
+
narrow_faces = np.where(face_flags.all(axis=1))[0]
|
|
117
|
+
if len(narrow_faces) == 0:
|
|
118
|
+
narrow_faces = np.where(face_flags.any(axis=1))[0]
|
|
119
|
+
highlight = mesh.submesh([narrow_faces], append=True)
|
|
120
|
+
highlight.export(output_path)
|
|
121
|
+
result.stl_path = output_path
|
|
122
|
+
result.written.append(output_path)
|
|
123
|
+
|
|
124
|
+
obj_path = output_path.replace(".stl", "_coloured.obj")
|
|
125
|
+
export_coloured_obj(mesh, eff_diam, diameter_threshold, obj_path)
|
|
126
|
+
result.obj_path = obj_path
|
|
127
|
+
result.written.append(obj_path)
|
|
128
|
+
|
|
129
|
+
click.echo()
|
|
130
|
+
click.secho(
|
|
131
|
+
f" \u2713 Highlight STL \u2192 {output_path} "
|
|
132
|
+
f"({len(narrow_faces):,} faces)", fg="green")
|
|
133
|
+
click.secho(f" \u2713 Colour OBJ \u2192 {obj_path}", fg="green")
|
|
134
|
+
|
|
135
|
+
# -- annotated PNG ---------------------------------------------------------
|
|
136
|
+
click.echo()
|
|
137
|
+
with click.progressbar(length=4, label="Rendering views ") as bar:
|
|
138
|
+
png_path = generate_annotated_report(
|
|
139
|
+
mesh, eff_diam, diameter_threshold, output_path,
|
|
140
|
+
progress_each=lambda: bar.update(1),
|
|
141
|
+
)
|
|
142
|
+
result.png_path = png_path
|
|
143
|
+
result.written.append(png_path)
|
|
144
|
+
click.secho(f" \u2713 Annotated PNG \u2192 {png_path}", fg="green")
|
|
145
|
+
|
|
146
|
+
# -- diameter histogram ----------------------------------------------------
|
|
147
|
+
rows = diameter_breakdown(eff_diam, diameter_threshold)
|
|
148
|
+
if rows:
|
|
149
|
+
click.echo()
|
|
150
|
+
click.secho(" Diameter distribution (mm)", bold=True)
|
|
151
|
+
total = max(1, int(np.isfinite(eff_diam).sum()))
|
|
152
|
+
table_rows = []
|
|
153
|
+
for lbl, count, is_narrow in rows:
|
|
154
|
+
bar_len = min(40, count // max(1, total // 40)) if total else 0
|
|
155
|
+
bar_str = "\u2588" * bar_len
|
|
156
|
+
marker = " <- NARROW" if is_narrow else ""
|
|
157
|
+
table_rows.append([f"{lbl} mm", f"{count:,}", bar_str + marker])
|
|
158
|
+
_echo_table(table_rows, headers=["range", "count", "histogram"],
|
|
159
|
+
aligns=[">", ">", "<"])
|
|
160
|
+
|
|
161
|
+
click.echo()
|
|
162
|
+
click.secho("=" * 60, fg="cyan")
|
|
163
|
+
click.echo()
|
|
164
|
+
return result
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# -- click entry point ---------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
@click.command(context_settings={"help_option_names": ["-h", "--help"]})
|
|
170
|
+
@click.argument("input_stl", type=click.Path(exists=True, dir_okay=False))
|
|
171
|
+
@click.option("--diameter", "-d", "diameter", type=float, default=2.0,
|
|
172
|
+
show_default=True,
|
|
173
|
+
help="Minimum diameter threshold in mm.")
|
|
174
|
+
@click.option("--output", "-o", "output", type=click.Path(dir_okay=False),
|
|
175
|
+
default=None,
|
|
176
|
+
help="Output STL path (default: <input>_narrow.stl).")
|
|
177
|
+
@click.version_option(__version__, "-V", "--version", prog_name="thinskin")
|
|
178
|
+
def main(input_stl, diameter, output):
|
|
179
|
+
"""Analyse an STL and highlight regions thinner than a given diameter.
|
|
180
|
+
|
|
181
|
+
\b
|
|
182
|
+
Outputs:
|
|
183
|
+
*_narrow.stl Mesh of only the flagged faces
|
|
184
|
+
*_narrow_coloured.obj Full mesh with red=narrow / green=OK colours
|
|
185
|
+
*_annotated_views.png CAD-style multi-view annotated report
|
|
186
|
+
"""
|
|
187
|
+
if output is None:
|
|
188
|
+
for ext in (".stl", ".STL"):
|
|
189
|
+
if input_stl.endswith(ext):
|
|
190
|
+
output = input_stl[: -len(ext)] + "_narrow.stl"
|
|
191
|
+
break
|
|
192
|
+
else:
|
|
193
|
+
output = input_stl + "_narrow.stl"
|
|
194
|
+
if os.path.abspath(output) == os.path.abspath(input_stl):
|
|
195
|
+
output = input_stl + "_narrow.stl"
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
run_analysis(input_stl, diameter, output)
|
|
199
|
+
except RuntimeError as e:
|
|
200
|
+
raise click.ClickException(str(e))
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
if __name__ == "__main__":
|
|
204
|
+
main()
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core analysis for ThinSkin
|
|
3
|
+
==========================
|
|
4
|
+
Analyses an STL file and identifies regions where the local geometry is
|
|
5
|
+
thinner than a specified diameter threshold.
|
|
6
|
+
|
|
7
|
+
Method
|
|
8
|
+
------
|
|
9
|
+
For every vertex in the mesh we estimate the local "inscribed diameter"
|
|
10
|
+
using two complementary approaches:
|
|
11
|
+
1. Ray-cast thickness - cast rays inward along the inverted vertex normal
|
|
12
|
+
and find the nearest opposing surface hit.
|
|
13
|
+
2. Local curvature radius - computed from the mean curvature at each
|
|
14
|
+
vertex via the cotangent-weighted Laplacian.
|
|
15
|
+
|
|
16
|
+
The minimum of both estimates is used as the effective local diameter.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import sys
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from typing import Optional
|
|
24
|
+
|
|
25
|
+
import numpy as np
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
import trimesh
|
|
29
|
+
except ImportError: # pragma: no cover
|
|
30
|
+
sys.exit("ERROR: trimesh not found. Run: pip install trimesh numpy scipy rtree")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# -- thickness / curvature -----------------------------------------------------
|
|
34
|
+
|
|
35
|
+
def estimate_thickness_raycasting(mesh: "trimesh.Trimesh") -> np.ndarray:
|
|
36
|
+
"""Estimate per-vertex wall thickness by casting rays inward."""
|
|
37
|
+
vertices = mesh.vertices
|
|
38
|
+
normals = mesh.vertex_normals
|
|
39
|
+
thickness = np.full(len(vertices), np.inf)
|
|
40
|
+
ray_origins = vertices - normals * 1e-4
|
|
41
|
+
ray_directions = -normals
|
|
42
|
+
|
|
43
|
+
locations, index_ray, _ = mesh.ray.intersects_location(
|
|
44
|
+
ray_origins=ray_origins,
|
|
45
|
+
ray_directions=ray_directions,
|
|
46
|
+
multiple_hits=False,
|
|
47
|
+
)
|
|
48
|
+
if len(index_ray):
|
|
49
|
+
dists = np.linalg.norm(locations - ray_origins[index_ray], axis=1)
|
|
50
|
+
thickness[index_ray] = dists
|
|
51
|
+
return thickness
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def estimate_curvature_radius(mesh: "trimesh.Trimesh") -> np.ndarray:
|
|
55
|
+
"""Estimate per-vertex curvature radius via the cotangent Laplacian."""
|
|
56
|
+
vertices = mesh.vertices
|
|
57
|
+
faces = mesh.faces
|
|
58
|
+
n = len(vertices)
|
|
59
|
+
area = np.zeros(n)
|
|
60
|
+
Lv = np.zeros((n, 3))
|
|
61
|
+
|
|
62
|
+
for i in range(3):
|
|
63
|
+
j = (i + 1) % 3
|
|
64
|
+
k = (i + 2) % 3
|
|
65
|
+
vi = vertices[faces[:, i]]
|
|
66
|
+
vj = vertices[faces[:, j]]
|
|
67
|
+
vk = vertices[faces[:, k]]
|
|
68
|
+
u = vi - vk
|
|
69
|
+
v = vj - vk
|
|
70
|
+
cos_k = np.einsum('ij,ij->i', u, v)
|
|
71
|
+
cross_k = np.cross(u, v)
|
|
72
|
+
sin_k = np.linalg.norm(cross_k, axis=1)
|
|
73
|
+
cot_k = np.where(sin_k > 1e-10, cos_k / sin_k, 0.0)
|
|
74
|
+
face_area = 0.5 * sin_k
|
|
75
|
+
for fi in range(3):
|
|
76
|
+
np.add.at(area, faces[:, fi], face_area / 3.0)
|
|
77
|
+
np.add.at(Lv, faces[:, i], cot_k[:, None] * (vi - vj))
|
|
78
|
+
np.add.at(Lv, faces[:, j], cot_k[:, None] * (vj - vi))
|
|
79
|
+
|
|
80
|
+
safe_area = np.where(area > 1e-12, area, 1e-12)
|
|
81
|
+
H = np.linalg.norm(Lv, axis=1) / (2.0 * safe_area)
|
|
82
|
+
return np.where(H > 1e-6, 1.0 / H, np.inf)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# -- OBJ export ----------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
def export_coloured_obj(mesh, eff_diam, threshold, path):
|
|
88
|
+
"""Write the full mesh with red=narrow / green=OK vertex colours."""
|
|
89
|
+
verts = mesh.vertices
|
|
90
|
+
faces = mesh.faces
|
|
91
|
+
normals = mesh.vertex_normals
|
|
92
|
+
d_norm = np.clip(eff_diam / threshold, 0.0, 1.0)
|
|
93
|
+
with open(path, "w") as f:
|
|
94
|
+
f.write("# ThinSkin - colour-coded OBJ\n")
|
|
95
|
+
f.write(f"# Red vertices are below {threshold} mm effective diameter\n\n")
|
|
96
|
+
for v, d in zip(verts, d_norm):
|
|
97
|
+
r = 1.0 - d
|
|
98
|
+
g = d
|
|
99
|
+
b = 0.0
|
|
100
|
+
f.write(f"v {v[0]:.6f} {v[1]:.6f} {v[2]:.6f} {r:.3f} {g:.3f} {b:.3f}\n")
|
|
101
|
+
for vn in normals:
|
|
102
|
+
f.write(f"vn {vn[0]:.6f} {vn[1]:.6f} {vn[2]:.6f}\n")
|
|
103
|
+
for face in faces:
|
|
104
|
+
i0, i1, i2 = face + 1
|
|
105
|
+
f.write(f"f {i0}//{i0} {i1}//{i1} {i2}//{i2}\n")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# Backwards-compatible private alias (kept so existing imports keep working).
|
|
109
|
+
_export_coloured_obj = export_coloured_obj
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def diameter_breakdown(eff_diam, threshold):
|
|
113
|
+
"""Return a list of (label, count, is_narrow) rows for the histogram."""
|
|
114
|
+
finite = eff_diam[np.isfinite(eff_diam)]
|
|
115
|
+
rows = []
|
|
116
|
+
if not len(finite):
|
|
117
|
+
return rows
|
|
118
|
+
bins = [0, 0.5, 1.0, 1.5, 2.0, 3.0, 5.0, 10.0, np.inf]
|
|
119
|
+
labels = ["<0.5", "0.5-1", "1-1.5", "1.5-2", "2-3", "3-5", "5-10", ">10"]
|
|
120
|
+
for lo, hi, lbl in zip(bins[:-1], bins[1:], labels):
|
|
121
|
+
count = int(((finite >= lo) & (finite < hi)).sum())
|
|
122
|
+
rows.append((lbl, count, hi <= threshold))
|
|
123
|
+
return rows
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# -- result container ----------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
@dataclass
|
|
129
|
+
class AnalysisResult:
|
|
130
|
+
"""Container for the outputs of :func:`analyse_mesh`."""
|
|
131
|
+
mesh: "trimesh.Trimesh"
|
|
132
|
+
eff_diam: np.ndarray
|
|
133
|
+
thickness: np.ndarray
|
|
134
|
+
curv_radius: np.ndarray
|
|
135
|
+
threshold: float
|
|
136
|
+
narrow_mask: np.ndarray
|
|
137
|
+
n_narrow: int
|
|
138
|
+
pct_narrow: float
|
|
139
|
+
stl_path: Optional[str] = None
|
|
140
|
+
obj_path: Optional[str] = None
|
|
141
|
+
png_path: Optional[str] = None
|
|
142
|
+
written: list = field(default_factory=list)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def load_mesh(input_path: str) -> "trimesh.Trimesh":
|
|
146
|
+
"""Load and validate an STL into a single Trimesh."""
|
|
147
|
+
try:
|
|
148
|
+
mesh = trimesh.load_mesh(input_path)
|
|
149
|
+
except Exception as e: # noqa: BLE001
|
|
150
|
+
raise RuntimeError(f"loading STL: {e}") from e
|
|
151
|
+
if not isinstance(mesh, trimesh.Trimesh):
|
|
152
|
+
raise RuntimeError("loaded object is not a single Trimesh.")
|
|
153
|
+
return mesh
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def compute_effective_diameter(mesh: "trimesh.Trimesh"):
|
|
157
|
+
"""Return (eff_diam, thickness, curv_radius) per vertex."""
|
|
158
|
+
thickness = estimate_thickness_raycasting(mesh)
|
|
159
|
+
curv_radius = estimate_curvature_radius(mesh)
|
|
160
|
+
eff_diam = np.minimum(thickness, 2.0 * curv_radius)
|
|
161
|
+
return eff_diam, thickness, curv_radius
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Annotated CAD-style report rendering for ThinSkin.
|
|
3
|
+
|
|
4
|
+
Produces a 2x2 grid of annotated orthographic views saved as a PNG:
|
|
5
|
+
*_annotated_views.png - CAD-style multi-view annotated report image
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
import matplotlib
|
|
16
|
+
matplotlib.use("Agg")
|
|
17
|
+
import matplotlib.pyplot as plt
|
|
18
|
+
import matplotlib.patches as mpatches
|
|
19
|
+
except ImportError: # pragma: no cover
|
|
20
|
+
sys.exit("ERROR: matplotlib not found. Run: pip install matplotlib")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# -- projection helpers --------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
def project_vertices(vertices, view: str) -> np.ndarray:
|
|
26
|
+
"""Return (N,2) 2-D projection for a named orthographic view."""
|
|
27
|
+
x, y, z = vertices[:, 0], vertices[:, 1], vertices[:, 2]
|
|
28
|
+
if view == "top":
|
|
29
|
+
return np.column_stack([x, y])
|
|
30
|
+
if view == "front":
|
|
31
|
+
return np.column_stack([x, z])
|
|
32
|
+
if view == "side":
|
|
33
|
+
return np.column_stack([y, z])
|
|
34
|
+
if view == "isometric":
|
|
35
|
+
a = np.radians(30)
|
|
36
|
+
xi = x * np.cos(a) - y * np.cos(a)
|
|
37
|
+
yi = x * np.sin(a) + y * np.sin(a) + z
|
|
38
|
+
return np.column_stack([xi, yi])
|
|
39
|
+
raise ValueError(f"Unknown view: {view}")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _face_projected_depth(vertices, faces, view):
|
|
43
|
+
"""Return per-face depth for painter's-algorithm sorting."""
|
|
44
|
+
tri = vertices[faces]
|
|
45
|
+
cx, cy, cz = tri[:, :, 0].mean(1), tri[:, :, 1].mean(1), tri[:, :, 2].mean(1)
|
|
46
|
+
if view == "top":
|
|
47
|
+
return -cy
|
|
48
|
+
if view == "front":
|
|
49
|
+
return -cy
|
|
50
|
+
if view == "side":
|
|
51
|
+
return cx
|
|
52
|
+
if view == "isometric":
|
|
53
|
+
return -(cx + cy - cz)
|
|
54
|
+
return np.zeros(len(faces))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# -- annotated view renderer ---------------------------------------------------
|
|
58
|
+
|
|
59
|
+
VIEWS = ["isometric", "top", "front", "side"]
|
|
60
|
+
VIEW_LABELS = {
|
|
61
|
+
"isometric": "Isometric",
|
|
62
|
+
"top": "Top (XY)",
|
|
63
|
+
"front": "Front (XZ)",
|
|
64
|
+
"side": "Side (YZ)",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
# CAD-style colour palette
|
|
68
|
+
BG_COLOR = "#dff0f7"
|
|
69
|
+
PANEL_BG = "#e8f4f8"
|
|
70
|
+
MESH_OK = "#c8860a"
|
|
71
|
+
MESH_NARROW = "#cc2222"
|
|
72
|
+
EDGE_COLOR = "#7a5500"
|
|
73
|
+
GRID_COLOR = "#b0cdd8"
|
|
74
|
+
ANNOT_BOX_BG = "#ffffcc"
|
|
75
|
+
ANNOT_BOX_ED = "#333333"
|
|
76
|
+
AXIS_X = "#cc2222"
|
|
77
|
+
AXIS_Y = "#228822"
|
|
78
|
+
AXIS_Z = "#2255cc"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _render_view(ax, mesh, eff_diam, threshold, view, show_grid=True):
|
|
82
|
+
"""Render one orthographic view onto a matplotlib Axes."""
|
|
83
|
+
verts = mesh.vertices
|
|
84
|
+
faces = mesh.faces
|
|
85
|
+
|
|
86
|
+
proj = project_vertices(verts, view)
|
|
87
|
+
depths = _face_projected_depth(verts, faces, view)
|
|
88
|
+
order = np.argsort(depths)
|
|
89
|
+
|
|
90
|
+
narrow_v = eff_diam < threshold
|
|
91
|
+
|
|
92
|
+
polys_ok = []
|
|
93
|
+
polys_narrow = []
|
|
94
|
+
for fi in order:
|
|
95
|
+
tri2d = proj[faces[fi]]
|
|
96
|
+
is_narrow = narrow_v[faces[fi]].any()
|
|
97
|
+
if is_narrow:
|
|
98
|
+
polys_narrow.append(tri2d)
|
|
99
|
+
else:
|
|
100
|
+
polys_ok.append(tri2d)
|
|
101
|
+
|
|
102
|
+
from matplotlib.collections import PolyCollection
|
|
103
|
+
|
|
104
|
+
if polys_ok:
|
|
105
|
+
pc_ok = PolyCollection(polys_ok, facecolor=MESH_OK,
|
|
106
|
+
edgecolor=EDGE_COLOR, linewidth=0.15, alpha=0.85)
|
|
107
|
+
ax.add_collection(pc_ok)
|
|
108
|
+
|
|
109
|
+
if polys_narrow:
|
|
110
|
+
pc_n = PolyCollection(polys_narrow, facecolor=MESH_NARROW,
|
|
111
|
+
edgecolor="#880000", linewidth=0.2, alpha=0.95, zorder=3)
|
|
112
|
+
ax.add_collection(pc_n)
|
|
113
|
+
|
|
114
|
+
ax.set_facecolor(BG_COLOR)
|
|
115
|
+
if show_grid:
|
|
116
|
+
ax.grid(True, color=GRID_COLOR, linewidth=0.5, linestyle="--", alpha=0.6)
|
|
117
|
+
|
|
118
|
+
pad = (proj.max() - proj.min()) * 0.12 + 0.5
|
|
119
|
+
ax.set_xlim(proj[:, 0].min() - pad, proj[:, 0].max() + pad)
|
|
120
|
+
ax.set_ylim(proj[:, 1].min() - pad, proj[:, 1].max() + pad)
|
|
121
|
+
ax.set_aspect("equal")
|
|
122
|
+
ax.tick_params(labelsize=6, color=GRID_COLOR)
|
|
123
|
+
for spine in ax.spines.values():
|
|
124
|
+
spine.set_edgecolor(GRID_COLOR)
|
|
125
|
+
|
|
126
|
+
_draw_axis_indicator(ax, view)
|
|
127
|
+
_annotate_narrow_regions(ax, proj, narrow_v, eff_diam, threshold, view)
|
|
128
|
+
|
|
129
|
+
ax.set_title(VIEW_LABELS[view], fontsize=8, fontweight="bold",
|
|
130
|
+
color="#223344", pad=3)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _draw_axis_indicator(ax, view):
|
|
134
|
+
"""Draw a small WCS triad in the bottom-left corner."""
|
|
135
|
+
xl, xr = ax.get_xlim()
|
|
136
|
+
yb, yt = ax.get_ylim()
|
|
137
|
+
ox = xl + (xr - xl) * 0.07
|
|
138
|
+
oy = yb + (yt - yb) * 0.07
|
|
139
|
+
L = (xr - xl) * 0.08
|
|
140
|
+
|
|
141
|
+
labels = {
|
|
142
|
+
"top": [("X", L, 0, AXIS_X), ("Y", 0, L, AXIS_Y)],
|
|
143
|
+
"front": [("X", L, 0, AXIS_X), ("Z", 0, L, AXIS_Z)],
|
|
144
|
+
"side": [("Y", L, 0, AXIS_Y), ("Z", 0, L, AXIS_Z)],
|
|
145
|
+
"isometric": [("X", L * 0.87, -L * 0.5, AXIS_X),
|
|
146
|
+
("Y", -L * 0.87, -L * 0.5, AXIS_Y),
|
|
147
|
+
("Z", 0, L, AXIS_Z)],
|
|
148
|
+
}
|
|
149
|
+
arrs = labels.get(view, [])
|
|
150
|
+
for lbl, dx, dy, col in arrs:
|
|
151
|
+
ax.annotate("", xy=(ox + dx, oy + dy), xytext=(ox, oy),
|
|
152
|
+
arrowprops=dict(arrowstyle="-|>", color=col, lw=1.2),
|
|
153
|
+
zorder=10)
|
|
154
|
+
ax.text(ox + dx * 1.25, oy + dy * 1.25, lbl, fontsize=5, color=col,
|
|
155
|
+
ha="center", va="center", fontweight="bold", zorder=10)
|
|
156
|
+
ax.plot(ox, oy, "o", color="#334455", markersize=2, zorder=11)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# module-level stash so cluster fn can access it
|
|
160
|
+
_eff_diam_global = None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def eff_diam_for_cluster(indices):
|
|
164
|
+
if _eff_diam_global is None:
|
|
165
|
+
return 0.0
|
|
166
|
+
vals = _eff_diam_global[indices]
|
|
167
|
+
finite = vals[np.isfinite(vals)]
|
|
168
|
+
return float(finite.min()) if len(finite) else 0.0
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _cluster_narrow_points(proj_narrow, max_annotations=6):
|
|
172
|
+
"""Group nearby narrow vertices into clusters so we don't spam annotations."""
|
|
173
|
+
if len(proj_narrow) == 0:
|
|
174
|
+
return []
|
|
175
|
+
from scipy.spatial import cKDTree
|
|
176
|
+
tree = cKDTree(proj_narrow)
|
|
177
|
+
radius = (proj_narrow.max() - proj_narrow.min()) * 0.08 + 0.5
|
|
178
|
+
visited = np.zeros(len(proj_narrow), bool)
|
|
179
|
+
clusters = []
|
|
180
|
+
for i in range(len(proj_narrow)):
|
|
181
|
+
if visited[i]:
|
|
182
|
+
continue
|
|
183
|
+
idxs = tree.query_ball_point(proj_narrow[i], radius)
|
|
184
|
+
for idx in idxs:
|
|
185
|
+
visited[idx] = True
|
|
186
|
+
clusters.append(idxs)
|
|
187
|
+
clusters.sort(key=len, reverse=True)
|
|
188
|
+
result = []
|
|
189
|
+
for cl in clusters[:max_annotations]:
|
|
190
|
+
pts = proj_narrow[cl]
|
|
191
|
+
result.append((pts[:, 0].mean(), pts[:, 1].mean(),
|
|
192
|
+
len(cl), eff_diam_for_cluster(cl)))
|
|
193
|
+
return result
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _annotate_narrow_regions(ax, proj, narrow_v, eff_diam, threshold, view):
|
|
197
|
+
"""Place leader-line callouts on each cluster of narrow vertices."""
|
|
198
|
+
global _eff_diam_global
|
|
199
|
+
_eff_diam_global = eff_diam
|
|
200
|
+
|
|
201
|
+
proj_narrow = proj[narrow_v]
|
|
202
|
+
if len(proj_narrow) == 0:
|
|
203
|
+
return
|
|
204
|
+
|
|
205
|
+
clusters = _cluster_narrow_points(proj_narrow)
|
|
206
|
+
if not clusters:
|
|
207
|
+
return
|
|
208
|
+
|
|
209
|
+
xl, xr = ax.get_xlim()
|
|
210
|
+
yb, yt = ax.get_ylim()
|
|
211
|
+
w = xr - xl
|
|
212
|
+
h = yt - yb
|
|
213
|
+
|
|
214
|
+
offsets = [
|
|
215
|
+
(0.22, 0.22), (-0.22, 0.22),
|
|
216
|
+
(0.22, -0.22), (-0.22, -0.22),
|
|
217
|
+
(0.30, 0.05), (-0.30, 0.05),
|
|
218
|
+
]
|
|
219
|
+
|
|
220
|
+
for idx, (cx, cy, count, min_d) in enumerate(clusters):
|
|
221
|
+
odx, ody = offsets[idx % len(offsets)]
|
|
222
|
+
tx = cx + w * odx
|
|
223
|
+
ty = cy + h * ody
|
|
224
|
+
|
|
225
|
+
tx = np.clip(tx, xl + w * 0.05, xr - w * 0.05)
|
|
226
|
+
ty = np.clip(ty, yb + h * 0.05, yt - h * 0.05)
|
|
227
|
+
|
|
228
|
+
label = f"\u00d8 {min_d:.2f} mm"
|
|
229
|
+
|
|
230
|
+
ax.annotate(
|
|
231
|
+
label,
|
|
232
|
+
xy=(cx, cy),
|
|
233
|
+
xytext=(tx, ty),
|
|
234
|
+
fontsize=6.5,
|
|
235
|
+
fontweight="bold",
|
|
236
|
+
color="#111111",
|
|
237
|
+
bbox=dict(boxstyle="round,pad=0.3", fc=ANNOT_BOX_BG,
|
|
238
|
+
ec=ANNOT_BOX_ED, lw=0.8, alpha=0.92),
|
|
239
|
+
arrowprops=dict(
|
|
240
|
+
arrowstyle="-|>",
|
|
241
|
+
color="#cc2222",
|
|
242
|
+
lw=1.0,
|
|
243
|
+
connectionstyle="arc3,rad=0.15",
|
|
244
|
+
),
|
|
245
|
+
zorder=20,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
ax.plot(cx, cy, "o", color="#cc2222", markersize=3,
|
|
249
|
+
markeredgecolor="#880000", markeredgewidth=0.5, zorder=21)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# -- main annotated report -----------------------------------------------------
|
|
253
|
+
|
|
254
|
+
def generate_annotated_report(mesh, eff_diam, threshold, base_path: str,
|
|
255
|
+
progress_each=None):
|
|
256
|
+
"""Produce a 2x2 grid of annotated orthographic views saved as a PNG.
|
|
257
|
+
|
|
258
|
+
``progress_each`` is an optional callable invoked once per rendered view,
|
|
259
|
+
used by the CLI to drive a progress bar without coupling to ``click``.
|
|
260
|
+
"""
|
|
261
|
+
fig = plt.figure(figsize=(16, 12), facecolor=PANEL_BG)
|
|
262
|
+
fig.patch.set_facecolor(PANEL_BG)
|
|
263
|
+
|
|
264
|
+
narrow_count = int((eff_diam < threshold).sum())
|
|
265
|
+
finite = eff_diam[np.isfinite(eff_diam)]
|
|
266
|
+
min_d = float(finite.min()) if len(finite) else 0.0
|
|
267
|
+
title = (f"ThinSkin Narrow Region Analysis - threshold: \u00d8 {threshold:.2f} mm "
|
|
268
|
+
f"| {narrow_count:,} flagged vertices | min \u00d8 {min_d:.3f} mm")
|
|
269
|
+
fig.suptitle(title, fontsize=11, fontweight="bold", color="#223344", y=0.97)
|
|
270
|
+
|
|
271
|
+
axes_positions = [
|
|
272
|
+
(0.02, 0.50, 0.46, 0.44),
|
|
273
|
+
(0.52, 0.50, 0.46, 0.44),
|
|
274
|
+
(0.02, 0.04, 0.46, 0.44),
|
|
275
|
+
(0.52, 0.04, 0.46, 0.44),
|
|
276
|
+
]
|
|
277
|
+
|
|
278
|
+
for view, pos in zip(VIEWS, axes_positions):
|
|
279
|
+
ax = fig.add_axes(pos)
|
|
280
|
+
_render_view(ax, mesh, eff_diam, threshold, view)
|
|
281
|
+
if progress_each is not None:
|
|
282
|
+
progress_each()
|
|
283
|
+
|
|
284
|
+
legend_ax = fig.add_axes([0.02, 0.945, 0.96, 0.03])
|
|
285
|
+
legend_ax.set_axis_off()
|
|
286
|
+
legend_ax.set_facecolor(PANEL_BG)
|
|
287
|
+
|
|
288
|
+
handles = [
|
|
289
|
+
mpatches.Patch(color=MESH_OK, label=f"OK (\u00d8 \u2265 {threshold:.2f} mm)"),
|
|
290
|
+
mpatches.Patch(color=MESH_NARROW, label=f"NARROW (\u00d8 < {threshold:.2f} mm)"),
|
|
291
|
+
]
|
|
292
|
+
legend_ax.legend(handles=handles, loc="center", ncol=2,
|
|
293
|
+
fontsize=8, frameon=True,
|
|
294
|
+
facecolor=ANNOT_BOX_BG, edgecolor=ANNOT_BOX_ED)
|
|
295
|
+
|
|
296
|
+
png_path = base_path.replace(".stl", "_annotated_views.png")
|
|
297
|
+
fig.savefig(png_path, dpi=150, bbox_inches="tight",
|
|
298
|
+
facecolor=PANEL_BG, edgecolor="none")
|
|
299
|
+
plt.close(fig)
|
|
300
|
+
return png_path
|