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.
@@ -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.
@@ -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
+ [![PyPI version](https://img.shields.io/pypi/v/thinskin.svg)](https://pypi.org/project/thinskin/)
41
+ [![Python versions](https://img.shields.io/pypi/pyversions/thinskin.svg)](https://pypi.org/project/thinskin/)
42
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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)
@@ -0,0 +1,108 @@
1
+ # ThinSkin
2
+
3
+ [![PyPI version](https://img.shields.io/pypi/v/thinskin.svg)](https://pypi.org/project/thinskin/)
4
+ [![Python versions](https://img.shields.io/pypi/pyversions/thinskin.svg)](https://pypi.org/project/thinskin/)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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,6 @@
1
+ """Allow ``python -m thinskin`` to invoke the CLI."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -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