3d-base 0.2.0__tar.gz → 0.3.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.
- {3d_base-0.2.0/src/3d_base.egg-info → 3d_base-0.3.0}/PKG-INFO +14 -3
- {3d_base-0.2.0 → 3d_base-0.3.0}/README.md +2 -2
- {3d_base-0.2.0 → 3d_base-0.3.0}/pyproject.toml +5 -3
- {3d_base-0.2.0 → 3d_base-0.3.0/src/3d_base.egg-info}/PKG-INFO +14 -3
- {3d_base-0.2.0 → 3d_base-0.3.0}/src/3d_base.egg-info/SOURCES.txt +3 -1
- 3d_base-0.3.0/src/3d_base.egg-info/requires.txt +19 -0
- {3d_base-0.2.0 → 3d_base-0.3.0}/src/base3d/cli.py +37 -0
- 3d_base-0.3.0/src/base3d/mesh.py +258 -0
- {3d_base-0.2.0 → 3d_base-0.3.0}/src/base3d/show.py +140 -0
- {3d_base-0.2.0 → 3d_base-0.3.0}/src/base3d/skill/SKILL.md +81 -0
- 3d_base-0.3.0/src/base3d/thickness.py +192 -0
- 3d_base-0.3.0/tests/test_mesh.py +153 -0
- {3d_base-0.2.0 → 3d_base-0.3.0}/tests/test_show.py +42 -0
- 3d_base-0.3.0/tests/test_thickness.py +154 -0
- 3d_base-0.2.0/src/3d_base.egg-info/requires.txt +0 -6
- 3d_base-0.2.0/src/base3d/mesh.py +0 -87
- 3d_base-0.2.0/tests/test_mesh.py +0 -69
- {3d_base-0.2.0 → 3d_base-0.3.0}/LICENSE +0 -0
- {3d_base-0.2.0 → 3d_base-0.3.0}/setup.cfg +0 -0
- {3d_base-0.2.0 → 3d_base-0.3.0}/src/3d_base.egg-info/dependency_links.txt +0 -0
- {3d_base-0.2.0 → 3d_base-0.3.0}/src/3d_base.egg-info/entry_points.txt +0 -0
- {3d_base-0.2.0 → 3d_base-0.3.0}/src/3d_base.egg-info/top_level.txt +0 -0
- {3d_base-0.2.0 → 3d_base-0.3.0}/src/base3d/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: 3d-base
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Headless pictures of 3D meshes with correct occlusion and smooth shading, and a health check for exported STL files
|
|
5
5
|
Author: Konstantinos Georgiou
|
|
6
6
|
License-Expression: MIT
|
|
@@ -14,16 +14,27 @@ License-File: LICENSE
|
|
|
14
14
|
Requires-Dist: numpy>=1.22
|
|
15
15
|
Requires-Dist: trimesh>=3.20
|
|
16
16
|
Requires-Dist: scipy>=1.8
|
|
17
|
+
Requires-Dist: rtree>=1.0
|
|
18
|
+
Provides-Extra: captions
|
|
19
|
+
Requires-Dist: pillow>=10.1; extra == "captions"
|
|
20
|
+
Provides-Extra: sections
|
|
21
|
+
Requires-Dist: shapely>=2.0; extra == "sections"
|
|
22
|
+
Requires-Dist: mapbox-earcut>=1.0; extra == "sections"
|
|
23
|
+
Requires-Dist: networkx>=3.0; extra == "sections"
|
|
17
24
|
Provides-Extra: test
|
|
18
25
|
Requires-Dist: pytest>=8; extra == "test"
|
|
26
|
+
Requires-Dist: pillow>=10.1; extra == "test"
|
|
27
|
+
Requires-Dist: shapely>=2.0; extra == "test"
|
|
28
|
+
Requires-Dist: mapbox-earcut>=1.0; extra == "test"
|
|
29
|
+
Requires-Dist: networkx>=3.0; extra == "test"
|
|
19
30
|
Dynamic: license-file
|
|
20
31
|
|
|
32
|
+

|
|
33
|
+
|
|
21
34
|
# 3d-base
|
|
22
35
|
|
|
23
36
|
3d-base draws pictures of 3D meshes from Python with no display, no OpenGL and no browser. It needs numpy, trimesh and scipy. I use it to check models for 3D printing from a script, and to give an AI assistant pictures it can judge a design from.
|
|
24
37
|
|
|
25
|
-

|
|
26
|
-
|
|
27
38
|
It also loads exported STL files and reports what is really wrong with them, after removing the zero-area triangles that CAD exporters write.
|
|
28
39
|
|
|
29
40
|
## Install
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
+

|
|
2
|
+
|
|
1
3
|
# 3d-base
|
|
2
4
|
|
|
3
5
|
3d-base draws pictures of 3D meshes from Python with no display, no OpenGL and no browser. It needs numpy, trimesh and scipy. I use it to check models for 3D printing from a script, and to give an AI assistant pictures it can judge a design from.
|
|
4
6
|
|
|
5
|
-

|
|
6
|
-
|
|
7
7
|
It also loads exported STL files and reports what is really wrong with them, after removing the zero-area triangles that CAD exporters write.
|
|
8
8
|
|
|
9
9
|
## Install
|
|
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "3d-base"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Headless pictures of 3D meshes with correct occlusion and smooth shading, and a health check for exported STL files"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
11
11
|
license = "MIT"
|
|
12
12
|
authors = [{ name = "Konstantinos Georgiou" }]
|
|
13
|
-
dependencies = ["numpy>=1.22", "trimesh>=3.20", "scipy>=1.8"]
|
|
13
|
+
dependencies = ["numpy>=1.22", "trimesh>=3.20", "scipy>=1.8", "rtree>=1.0"]
|
|
14
14
|
keywords = ["3d", "mesh", "stl", "render", "headless", "trimesh", "3d-printing", "cad"]
|
|
15
15
|
classifiers = ["Programming Language :: Python :: 3", "Topic :: Multimedia :: Graphics :: 3D Rendering"]
|
|
16
16
|
|
|
@@ -21,7 +21,9 @@ Homepage = "https://github.com/drkostas/3d-base"
|
|
|
21
21
|
3d-base = "base3d.cli:main"
|
|
22
22
|
|
|
23
23
|
[project.optional-dependencies]
|
|
24
|
-
|
|
24
|
+
captions = ["pillow>=10.1"]
|
|
25
|
+
sections = ["shapely>=2.0", "mapbox-earcut>=1.0", "networkx>=3.0"]
|
|
26
|
+
test = ["pytest>=8", "pillow>=10.1", "shapely>=2.0", "mapbox-earcut>=1.0", "networkx>=3.0"]
|
|
25
27
|
|
|
26
28
|
[tool.setuptools.packages.find]
|
|
27
29
|
where = ["src"]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: 3d-base
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Headless pictures of 3D meshes with correct occlusion and smooth shading, and a health check for exported STL files
|
|
5
5
|
Author: Konstantinos Georgiou
|
|
6
6
|
License-Expression: MIT
|
|
@@ -14,16 +14,27 @@ License-File: LICENSE
|
|
|
14
14
|
Requires-Dist: numpy>=1.22
|
|
15
15
|
Requires-Dist: trimesh>=3.20
|
|
16
16
|
Requires-Dist: scipy>=1.8
|
|
17
|
+
Requires-Dist: rtree>=1.0
|
|
18
|
+
Provides-Extra: captions
|
|
19
|
+
Requires-Dist: pillow>=10.1; extra == "captions"
|
|
20
|
+
Provides-Extra: sections
|
|
21
|
+
Requires-Dist: shapely>=2.0; extra == "sections"
|
|
22
|
+
Requires-Dist: mapbox-earcut>=1.0; extra == "sections"
|
|
23
|
+
Requires-Dist: networkx>=3.0; extra == "sections"
|
|
17
24
|
Provides-Extra: test
|
|
18
25
|
Requires-Dist: pytest>=8; extra == "test"
|
|
26
|
+
Requires-Dist: pillow>=10.1; extra == "test"
|
|
27
|
+
Requires-Dist: shapely>=2.0; extra == "test"
|
|
28
|
+
Requires-Dist: mapbox-earcut>=1.0; extra == "test"
|
|
29
|
+
Requires-Dist: networkx>=3.0; extra == "test"
|
|
19
30
|
Dynamic: license-file
|
|
20
31
|
|
|
32
|
+

|
|
33
|
+
|
|
21
34
|
# 3d-base
|
|
22
35
|
|
|
23
36
|
3d-base draws pictures of 3D meshes from Python with no display, no OpenGL and no browser. It needs numpy, trimesh and scipy. I use it to check models for 3D printing from a script, and to give an AI assistant pictures it can judge a design from.
|
|
24
37
|
|
|
25
|
-

|
|
26
|
-
|
|
27
38
|
It also loads exported STL files and reports what is really wrong with them, after removing the zero-area triangles that CAD exporters write.
|
|
28
39
|
|
|
29
40
|
## Install
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
numpy>=1.22
|
|
2
|
+
trimesh>=3.20
|
|
3
|
+
scipy>=1.8
|
|
4
|
+
rtree>=1.0
|
|
5
|
+
|
|
6
|
+
[captions]
|
|
7
|
+
pillow>=10.1
|
|
8
|
+
|
|
9
|
+
[sections]
|
|
10
|
+
shapely>=2.0
|
|
11
|
+
mapbox-earcut>=1.0
|
|
12
|
+
networkx>=3.0
|
|
13
|
+
|
|
14
|
+
[test]
|
|
15
|
+
pytest>=8
|
|
16
|
+
pillow>=10.1
|
|
17
|
+
shapely>=2.0
|
|
18
|
+
mapbox-earcut>=1.0
|
|
19
|
+
networkx>=3.0
|
|
@@ -31,6 +31,22 @@ def main(argv=None) -> int:
|
|
|
31
31
|
c = sub.add_parser("check", help="faces, bodies, closed cavities and volume, after dropping slivers")
|
|
32
32
|
c.add_argument("mesh")
|
|
33
33
|
|
|
34
|
+
t = sub.add_parser("thickness", help="the thinnest walls, and every place thinner than a bar")
|
|
35
|
+
t.add_argument("mesh")
|
|
36
|
+
t.add_argument("--bar", type=float, default=0.8, help="the thinnest wall that prints, in mm (default 0.8)")
|
|
37
|
+
t.add_argument("-n", type=int, default=4000, help="sample points on the surface (default 4000)")
|
|
38
|
+
t.add_argument("--printed", action="store_true",
|
|
39
|
+
help="judge each wall as the part lies, +z up: two lines across a layer, two layers through a flat plate")
|
|
40
|
+
|
|
41
|
+
so = sub.add_parser("sound", help="whether a part is sound to print, and every reason it is not")
|
|
42
|
+
so.add_argument("mesh")
|
|
43
|
+
so.add_argument("--bar", type=float, default=0.8, help="the thinnest wall that prints, in mm (default 0.8)")
|
|
44
|
+
|
|
45
|
+
sc = sub.add_parser("sections", help="the part cut in half three ways, coloured by wall thickness")
|
|
46
|
+
sc.add_argument("mesh")
|
|
47
|
+
sc.add_argument("-o", "--out", default="sections.png")
|
|
48
|
+
sc.add_argument("--bar", type=float, default=0.8, help="the thinnest wall that prints, in mm (default 0.8)")
|
|
49
|
+
|
|
34
50
|
a = p.parse_args(argv)
|
|
35
51
|
if a.cmd == "skill":
|
|
36
52
|
dest = Path(a.dir).expanduser() / "3d-base"
|
|
@@ -42,6 +58,27 @@ def main(argv=None) -> int:
|
|
|
42
58
|
rep = report(a.mesh)
|
|
43
59
|
print(json.dumps(rep, indent=1))
|
|
44
60
|
return 1 if rep["voids"] else 0
|
|
61
|
+
if a.cmd == "sections":
|
|
62
|
+
from .show import sections
|
|
63
|
+
m, _ = load_clean(a.mesh)
|
|
64
|
+
save_png(sections(m, bar_mm=a.bar), a.out)
|
|
65
|
+
print(a.out)
|
|
66
|
+
return 0
|
|
67
|
+
if a.cmd == "sound":
|
|
68
|
+
from .mesh import sound
|
|
69
|
+
m, _ = load_clean(a.mesh)
|
|
70
|
+
rep = sound(m, bar_mm=a.bar)
|
|
71
|
+
print(json.dumps(rep, indent=1))
|
|
72
|
+
return 1 if rep["faults"] else 0
|
|
73
|
+
if a.cmd == "thickness":
|
|
74
|
+
from .thickness import print_thin_spots, thin_spots, wall_thickness
|
|
75
|
+
m, _ = load_clean(a.mesh)
|
|
76
|
+
_pts, _nrm, mm = wall_thickness(m, n=a.n)
|
|
77
|
+
finite = [float(x) for x in mm if x != float("inf")]
|
|
78
|
+
spots = print_thin_spots(m, line_mm=a.bar, n=a.n) if a.printed else thin_spots(m, a.bar, n=a.n)
|
|
79
|
+
print(json.dumps({"thinnest_mm": min(finite) if finite else None, "bar_mm": a.bar,
|
|
80
|
+
"thin_spots": spots}, indent=1))
|
|
81
|
+
return 1 if spots else 0
|
|
45
82
|
m, _ = load_clean(a.mesh)
|
|
46
83
|
w, h = (int(x) for x in a.size.lower().split("x"))
|
|
47
84
|
if a.view == "sheet":
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Load an exported STL and tell real defects from tessellation noise.
|
|
2
|
+
|
|
3
|
+
OpenCascade (and CAD tools built on it, such as build123d and CadQuery) writes zero-area triangles at
|
|
4
|
+
every sphere pole when it exports STL. They make a correct solid report `is_watertight=False` with
|
|
5
|
+
two or three extra bodies, so a plain `Sphere(20)` already looks broken. The slivers are an
|
|
6
|
+
artefact of the tessellation and it is correct to drop them.
|
|
7
|
+
|
|
8
|
+
Dropping them is also what makes the body count useful. Once the slivers are gone, a second body is
|
|
9
|
+
real. A shell with negative volume is a sealed cavity (a socket that never reached the surface, for
|
|
10
|
+
example), and that is a design defect worth catching before printing.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
import trimesh
|
|
16
|
+
|
|
17
|
+
SLIVER_AREA = 1e-9
|
|
18
|
+
|
|
19
|
+
# Any fixed value. What matters is that it does not change between runs.
|
|
20
|
+
SAMPLE_SEED = 11
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def clean(mesh: trimesh.Trimesh, sliver_area: float = SLIVER_AREA) -> trimesh.Trimesh:
|
|
24
|
+
"""Drop zero-area faces and merge the vertices they leave behind. Changes the mesh in place."""
|
|
25
|
+
areas = mesh.area_faces
|
|
26
|
+
if (areas <= sliver_area).any():
|
|
27
|
+
mesh.update_faces(areas > sliver_area)
|
|
28
|
+
mesh.remove_unreferenced_vertices()
|
|
29
|
+
mesh.merge_vertices()
|
|
30
|
+
return mesh
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load_clean(path):
|
|
34
|
+
"""Load a mesh file, drop tessellation slivers, and return (mesh, bodies).
|
|
35
|
+
|
|
36
|
+
`bodies` is the list of connected pieces. After cleaning, more than one means more than one
|
|
37
|
+
real piece.
|
|
38
|
+
"""
|
|
39
|
+
m = trimesh.load(str(path))
|
|
40
|
+
if isinstance(m, trimesh.Scene): # a GLB or OBJ with several parts
|
|
41
|
+
m = m.to_geometry() if hasattr(m, "to_geometry") else m.dump(concatenate=True)
|
|
42
|
+
clean(m)
|
|
43
|
+
bodies = m.split(only_watertight=False)
|
|
44
|
+
return m, bodies
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def voids(bodies):
|
|
48
|
+
"""Pieces with negative volume, which are closed cavities inside another piece."""
|
|
49
|
+
return [b for b in bodies if b.volume < 0]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def surface_points(mesh, n=4000, seed=SAMPLE_SEED):
|
|
53
|
+
"""Points on the surface of a mesh, the same points on every run.
|
|
54
|
+
|
|
55
|
+
An unseeded sample makes a design change between two builds of the same input whenever the
|
|
56
|
+
points decide something (where a support or a contact goes, for example).
|
|
57
|
+
"""
|
|
58
|
+
rng = np.random.default_rng(seed)
|
|
59
|
+
try:
|
|
60
|
+
return mesh.sample(n, seed=rng)
|
|
61
|
+
except TypeError:
|
|
62
|
+
# Older trimesh has no seed argument. Sampling without one would break the promise above,
|
|
63
|
+
# so sample here: pick faces by area, then a uniform point inside each.
|
|
64
|
+
return _seeded_sample(mesh, n, rng)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _seeded_sample(mesh, n, rng):
|
|
68
|
+
cum = np.cumsum(np.asarray(mesh.area_faces, float))
|
|
69
|
+
face = np.searchsorted(cum, rng.random(n) * cum[-1])
|
|
70
|
+
tri = np.asarray(mesh.triangles, float)[face]
|
|
71
|
+
r = rng.random((n, 2))
|
|
72
|
+
over = r.sum(axis=1) > 1
|
|
73
|
+
r[over] = 1 - r[over] # fold the square onto the triangle
|
|
74
|
+
return tri[:, 0] + r[:, :1] * (tri[:, 1] - tri[:, 0]) + r[:, 1:] * (tri[:, 2] - tri[:, 0])
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def report(path) -> dict:
|
|
78
|
+
"""A short health report for a mesh file."""
|
|
79
|
+
m, bodies = load_clean(path)
|
|
80
|
+
return {
|
|
81
|
+
"faces": int(len(m.faces)),
|
|
82
|
+
"bodies": len(bodies),
|
|
83
|
+
"voids": len(voids(bodies)),
|
|
84
|
+
"watertight": bool(m.is_watertight),
|
|
85
|
+
"volume": float(m.volume) if m.is_watertight else None,
|
|
86
|
+
"extents": [float(x) for x in m.extents],
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _pieces(mesh, inverse=None, count=None):
|
|
91
|
+
"""The pieces of `mesh` joined face to face (across edges shared by exactly two faces), largest
|
|
92
|
+
first. Counted here rather than by `split`, which fills holes first and needs networkx."""
|
|
93
|
+
from scipy.sparse import coo_matrix
|
|
94
|
+
from scipy.sparse.csgraph import connected_components
|
|
95
|
+
if inverse is None:
|
|
96
|
+
edges = np.sort(np.asarray(mesh.edges), axis=1)
|
|
97
|
+
_u, inverse, count = np.unique(edges, axis=0, return_inverse=True, return_counts=True)
|
|
98
|
+
inverse = np.asarray(inverse).ravel()
|
|
99
|
+
face = np.asarray(mesh.edges_face)
|
|
100
|
+
order = np.argsort(inverse, kind="stable")
|
|
101
|
+
inv, fac = inverse[order], face[order]
|
|
102
|
+
same = (inv[1:] == inv[:-1]) & (count[inv[1:]] == 2)
|
|
103
|
+
n = len(mesh.faces)
|
|
104
|
+
adj = coo_matrix((np.ones(int(same.sum())), (fac[:-1][same], fac[1:][same])), shape=(n, n))
|
|
105
|
+
k, label = connected_components(adj, directed=False)
|
|
106
|
+
pieces = [trimesh.Trimesh(mesh.vertices, mesh.faces[label == i], process=False) for i in range(k)]
|
|
107
|
+
pieces.sort(key=lambda q: -abs(float(q.volume)))
|
|
108
|
+
return pieces
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def sound(mesh: trimesh.Trimesh, bar_mm: float = 0.8, n: int = 4000, size_mm: float = 3.0,
|
|
112
|
+
loose_mm3: float = 0.5) -> dict:
|
|
113
|
+
"""One call for whether a part is sound to print, and every reason it is not.
|
|
114
|
+
|
|
115
|
+
`faults` lists, in plain words, each of: not closed; more than one piece of real volume; a
|
|
116
|
+
sealed void (a closed piece of negative volume inside); the file reading back with a hole or a
|
|
117
|
+
loose piece (`as_written`); and a wall thinner than `bar_mm` over more than `size_mm`, by its
|
|
118
|
+
middle reading (`thickness.thin_spots`). An empty list is sound. The other keys carry the
|
|
119
|
+
readings behind them, so a geometry function can end with `assert not sound(m)["faults"]`.
|
|
120
|
+
"""
|
|
121
|
+
from .thickness import thin_spots
|
|
122
|
+
faults = []
|
|
123
|
+
pieces = _pieces(mesh)
|
|
124
|
+
vols = [float(q.volume) for q in pieces]
|
|
125
|
+
bodies = sum(1 for v in vols if v > loose_mm3)
|
|
126
|
+
voids = sum(1 for v in vols if v < -loose_mm3)
|
|
127
|
+
closed = bool(mesh.is_watertight)
|
|
128
|
+
if not closed:
|
|
129
|
+
faults.append("not closed")
|
|
130
|
+
if bodies > 1:
|
|
131
|
+
faults.append(f"{bodies} pieces of real volume")
|
|
132
|
+
if voids:
|
|
133
|
+
faults.append(f"{voids} sealed void(s)")
|
|
134
|
+
written = as_written(mesh, loose_mm3=loose_mm3)
|
|
135
|
+
if not written["ok"]:
|
|
136
|
+
faults.append(f"reads back from a file with {written['holes']} holed edge(s) and "
|
|
137
|
+
f"{written['loose_mm3']:.2f} mm3 loose")
|
|
138
|
+
thin = [t for t in thin_spots(mesh, bar_mm, n=n) if t["size_mm"] >= size_mm]
|
|
139
|
+
thinnest = min((t["typical_mm"] for t in thin), default=None)
|
|
140
|
+
if thin:
|
|
141
|
+
worst = min(thin, key=lambda t: t["typical_mm"])
|
|
142
|
+
faults.append(f"a wall {worst['typical_mm']:.2f} mm thick over {worst['size_mm']:.1f} mm "
|
|
143
|
+
f"(needs {bar_mm})")
|
|
144
|
+
return {"faults": faults, "closed": closed, "bodies": bodies, "voids": voids,
|
|
145
|
+
"as_written": written, "thinnest_wall_mm": thinnest}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def as_written(mesh: trimesh.Trimesh, file_type: str = "stl", loose_mm3: float = 0.5) -> dict:
|
|
149
|
+
"""The mesh as a slicer will read it: written to `file_type`, read back, and counted.
|
|
150
|
+
|
|
151
|
+
A boolean engine can leave two vertices a hundred millionth of a millimetre apart, kept separate
|
|
152
|
+
only by their index, so a mesh can be one closed body in memory. An STL stores 32-bit coordinates
|
|
153
|
+
and has no indices, and a reader joins vertices by position, so those pairs weld together. Read
|
|
154
|
+
back, the mesh can have holes, extra pieces, or edges shared by four faces.
|
|
155
|
+
|
|
156
|
+
Returns `bodies` (the pieces joined face to face; two closed sheets that only touch along an
|
|
157
|
+
edge are two pieces), `holes` (edges with an odd number of faces, where a slicer has to guess),
|
|
158
|
+
`touches` (edges with four faces, two closed sheets touching, which still slice as they are),
|
|
159
|
+
`loose_mm3` (the volume of every piece outside the largest one; a piece whose centre is inside
|
|
160
|
+
it overlaps it, and a slicer prints overlapping shells as one solid) and `ok`, which is no hole
|
|
161
|
+
and nothing loose beyond `loose_mm3`: a boolean's slivers pass, a real second piece does not.
|
|
162
|
+
"""
|
|
163
|
+
import io
|
|
164
|
+
buf = io.BytesIO()
|
|
165
|
+
mesh.export(buf, file_type=file_type)
|
|
166
|
+
buf.seek(0)
|
|
167
|
+
back = trimesh.load(buf, file_type=file_type, force="mesh")
|
|
168
|
+
edges = np.sort(np.asarray(back.edges), axis=1)
|
|
169
|
+
_uniq, inverse, count = np.unique(edges, axis=0, return_inverse=True, return_counts=True)
|
|
170
|
+
inverse = np.asarray(inverse).ravel()
|
|
171
|
+
holes = int((count % 2 == 1).sum())
|
|
172
|
+
touches = int(((count > 2) & (count % 2 == 0)).sum())
|
|
173
|
+
pieces = _pieces(back, inverse, count)
|
|
174
|
+
bodies = len(pieces)
|
|
175
|
+
loose = 0.0
|
|
176
|
+
for q in pieces[1:]:
|
|
177
|
+
# a closed piece whose centre lies inside the largest piece overlaps it, and a slicer
|
|
178
|
+
# prints overlapping shells as one solid; a piece outside the largest one is loose
|
|
179
|
+
inside = False
|
|
180
|
+
if holes == 0:
|
|
181
|
+
try:
|
|
182
|
+
inside = bool(pieces[0].contains([np.asarray(q.centroid, float)])[0])
|
|
183
|
+
except Exception:
|
|
184
|
+
inside = False
|
|
185
|
+
if not inside:
|
|
186
|
+
loose += abs(float(q.volume))
|
|
187
|
+
return {"ok": holes == 0 and loose <= loose_mm3, "bodies": int(bodies), "holes": holes,
|
|
188
|
+
"touches": touches, "loose_mm3": float(loose)}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def solid_from_skin(mesh: trimesh.Trimesh, centre, reach: float, lean: float = 0.3) -> trimesh.Trimesh:
|
|
192
|
+
"""A hollow part made solid from its outer skin, by removing its inner surface.
|
|
193
|
+
|
|
194
|
+
The inner surface is taken as the one connected region of faces that point toward `centre`
|
|
195
|
+
(the cosine of the angle between the face normal and the direction to `centre` is above
|
|
196
|
+
`lean`), lie within `reach` of it, and are joined to the face nearest to it. Removing them leaves
|
|
197
|
+
the skin open along one or more loops (the inner edge of a rim, the root of a brim), and each
|
|
198
|
+
loop is closed with a fan of triangles to `centre`. The result has the same outside and no inside,
|
|
199
|
+
so a cavity cut into it afterwards (a bore for a part it sits on) is the only inside it has.
|
|
200
|
+
|
|
201
|
+
This exists because filling a hollow part by uniting a fill with it leaves air wherever the fill
|
|
202
|
+
meets the inner surface at a grazing angle, which a slicer prints as tunnels. With no inner
|
|
203
|
+
surface there is nothing for air to lie against. Raises ValueError when the skin cannot be closed.
|
|
204
|
+
"""
|
|
205
|
+
import scipy.sparse as sp
|
|
206
|
+
from scipy.sparse.csgraph import connected_components
|
|
207
|
+
centre = np.asarray(centre, float)
|
|
208
|
+
c = np.asarray(mesh.triangles_center, float)
|
|
209
|
+
n = np.asarray(mesh.face_normals, float)
|
|
210
|
+
to_c = centre - c
|
|
211
|
+
dist = np.linalg.norm(to_c, axis=1)
|
|
212
|
+
to_c = to_c / np.maximum(dist, 1e-12)[:, None]
|
|
213
|
+
cand = (dist < float(reach)) & (np.einsum("ij,ij->i", n, to_c) > lean)
|
|
214
|
+
if not cand.any():
|
|
215
|
+
raise ValueError("no face points toward the centre within reach")
|
|
216
|
+
adj = np.asarray(mesh.face_adjacency)
|
|
217
|
+
both = cand[adj[:, 0]] & cand[adj[:, 1]]
|
|
218
|
+
graph = sp.coo_matrix((np.ones(int(both.sum())), (adj[both, 0], adj[both, 1])), shape=(len(c), len(c)))
|
|
219
|
+
_k, label = connected_components(graph, directed=False)
|
|
220
|
+
seed = int(np.argmin(np.where(cand, dist, np.inf)))
|
|
221
|
+
inner = cand & (label == label[seed])
|
|
222
|
+
faces = np.asarray(mesh.faces)[~inner]
|
|
223
|
+
skin = trimesh.Trimesh(np.asarray(mesh.vertices, float), faces, process=False)
|
|
224
|
+
edges = np.sort(np.asarray(skin.edges), axis=1)
|
|
225
|
+
uniq, count = np.unique(edges, axis=0, return_counts=True)
|
|
226
|
+
rim = uniq[count == 1]
|
|
227
|
+
V = np.vstack([skin.vertices, centre[None, :]])
|
|
228
|
+
apex = len(V) - 1
|
|
229
|
+
fan = [[a, b, apex] for loop in _loops(rim) for a, b in zip(loop, loop[1:] + loop[:1])]
|
|
230
|
+
solid = trimesh.Trimesh(V, np.vstack([faces, np.asarray(fan, int).reshape(-1, 3)]), process=False)
|
|
231
|
+
solid.merge_vertices()
|
|
232
|
+
if not solid.is_watertight:
|
|
233
|
+
raise ValueError("the skin could not be closed")
|
|
234
|
+
if solid.volume < 0:
|
|
235
|
+
solid.invert()
|
|
236
|
+
return solid
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _loops(edges):
|
|
240
|
+
"""Closed loops in a set of boundary edges, as vertex lists."""
|
|
241
|
+
nxt = {}
|
|
242
|
+
for a, b in np.asarray(edges).tolist():
|
|
243
|
+
nxt.setdefault(a, []).append(b)
|
|
244
|
+
nxt.setdefault(b, []).append(a)
|
|
245
|
+
seen, loops = set(), []
|
|
246
|
+
for start in nxt:
|
|
247
|
+
if start in seen:
|
|
248
|
+
continue
|
|
249
|
+
loop, prev, cur = [start], None, start
|
|
250
|
+
while True:
|
|
251
|
+
seen.add(cur)
|
|
252
|
+
after = [v for v in nxt[cur] if v != prev and v not in seen]
|
|
253
|
+
if not after:
|
|
254
|
+
break
|
|
255
|
+
prev, cur = cur, after[0]
|
|
256
|
+
loop.append(cur)
|
|
257
|
+
loops.append(loop)
|
|
258
|
+
return loops
|
|
@@ -292,3 +292,143 @@ def save_png(img, path):
|
|
|
292
292
|
|
|
293
293
|
# The name used before this was a package.
|
|
294
294
|
_corner_normals = corner_normals
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def caption(text, width, size=28, floor=12, pad=14, gap=6, bg=(20, 20, 20), fg=(226, 232, 238),
|
|
298
|
+
font=None):
|
|
299
|
+
"""A caption drawn into an image `width` wide, as an RGB array to stack with pictures.
|
|
300
|
+
|
|
301
|
+
The words are wrapped into lines that fit the width at `size`, so a long caption makes the band
|
|
302
|
+
taller instead of the type smaller, and every caption on a sheet reads at one size. Only a
|
|
303
|
+
single word wider than the band shrinks the type, down to `floor`, and a word that does not fit
|
|
304
|
+
even then raises, because a caption that is written and not visible is the same fault as a
|
|
305
|
+
number printed to a log. `font` is a path to a TrueType file; without one, Pillow's own face is
|
|
306
|
+
used. Needs Pillow (`pip install '3d-base[captions]'`).
|
|
307
|
+
"""
|
|
308
|
+
try:
|
|
309
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
310
|
+
except ImportError as e:
|
|
311
|
+
raise ImportError("caption needs Pillow: pip install '3d-base[captions]'") from e
|
|
312
|
+
words = text.split()
|
|
313
|
+
if not words:
|
|
314
|
+
raise ValueError("an empty caption")
|
|
315
|
+
|
|
316
|
+
def face(pt):
|
|
317
|
+
if font:
|
|
318
|
+
try:
|
|
319
|
+
return ImageFont.truetype(font, pt)
|
|
320
|
+
except OSError:
|
|
321
|
+
pass
|
|
322
|
+
try:
|
|
323
|
+
return ImageFont.load_default(size=pt)
|
|
324
|
+
except TypeError: # an older Pillow, one size only
|
|
325
|
+
return ImageFont.load_default()
|
|
326
|
+
|
|
327
|
+
probe = ImageDraw.Draw(Image.new("RGB", (8, 8)))
|
|
328
|
+
room = width - 2 * pad
|
|
329
|
+
pt = size
|
|
330
|
+
while True:
|
|
331
|
+
f = face(pt)
|
|
332
|
+
widest = max(probe.textlength(w, font=f) for w in words)
|
|
333
|
+
if widest <= room or pt <= floor:
|
|
334
|
+
break
|
|
335
|
+
pt -= 1
|
|
336
|
+
if widest > room:
|
|
337
|
+
raise ValueError(f"a word of {text!r} does not fit {width} px even at {floor} pt")
|
|
338
|
+
lines, line = [], ""
|
|
339
|
+
for w in words:
|
|
340
|
+
trial = (line + " " + w).strip()
|
|
341
|
+
if probe.textlength(trial, font=f) <= room:
|
|
342
|
+
line = trial
|
|
343
|
+
else:
|
|
344
|
+
lines.append(line)
|
|
345
|
+
line = w
|
|
346
|
+
lines.append(line)
|
|
347
|
+
try:
|
|
348
|
+
ascent, descent = f.getmetrics()
|
|
349
|
+
except Exception:
|
|
350
|
+
ascent, descent = pt, pt // 4
|
|
351
|
+
line_h = ascent + descent + gap
|
|
352
|
+
height = pad + len(lines) * line_h - gap
|
|
353
|
+
im = Image.new("RGB", (width, height), tuple(bg))
|
|
354
|
+
draw = ImageDraw.Draw(im)
|
|
355
|
+
y = pad // 2
|
|
356
|
+
for ln in lines:
|
|
357
|
+
draw.text((pad, y), ln, font=f, fill=tuple(fg))
|
|
358
|
+
y += line_h
|
|
359
|
+
out = np.asarray(im)
|
|
360
|
+
if int(out.max()) <= max(bg) + 10:
|
|
361
|
+
raise ValueError(f"the caption drew no pixels: {text!r}")
|
|
362
|
+
return out
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
CUT = (0.20, 0.55, 0.62)
|
|
366
|
+
THIN, SHORT, THICK = (0.86, 0.22, 0.20), (0.95, 0.60, 0.18), (0.80, 0.80, 0.77)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def sections(mesh, axes=None, bar_mm=0.8, one_line_mm=0.45, n=6000, size=(420, 420), bg=(0.09, 0.10, 0.11)):
|
|
370
|
+
"""The part cut in half three ways, each half coloured by its wall thickness, as one image.
|
|
371
|
+
|
|
372
|
+
Look at this before changing a part's geometry. The cuts are through the part's centre, across
|
|
373
|
+
each of `axes` (by default the part's own principal directions, from surface points). The
|
|
374
|
+
face of each cut is CUT blue, the walls are THIN red under `one_line_mm`, SHORT orange under
|
|
375
|
+
`bar_mm` and THICK grey from there, read with `thickness.wall_thickness`. The camera looks
|
|
376
|
+
straight at each cut face. Needs shapely and mapbox-earcut (`pip install '3d-base[sections]'`).
|
|
377
|
+
"""
|
|
378
|
+
try:
|
|
379
|
+
import networkx # noqa: F401 (trimesh assembles the cut's polygons with it)
|
|
380
|
+
import shapely # noqa: F401 (and caps and triangulates with shapely and earcut or triangle)
|
|
381
|
+
except ImportError as e:
|
|
382
|
+
raise ImportError("sections needs shapely, networkx and a triangulator: pip install '3d-base[sections]'") from e
|
|
383
|
+
from scipy.spatial import cKDTree
|
|
384
|
+
from .mesh import surface_points
|
|
385
|
+
from .thickness import face_thickness, wall_thickness
|
|
386
|
+
pts, _nrm, mm = wall_thickness(mesh, n=n)
|
|
387
|
+
ft = face_thickness(mesh, pts, mm)
|
|
388
|
+
cols = np.where(ft[:, None] < one_line_mm, THIN, np.where(ft[:, None] < bar_mm, SHORT, THICK))
|
|
389
|
+
P = np.asarray(surface_points(mesh, 6000), float)
|
|
390
|
+
c = P.mean(axis=0)
|
|
391
|
+
if axes is None:
|
|
392
|
+
axes = np.linalg.svd(P - c, full_matrices=False)[2]
|
|
393
|
+
axes = [np.asarray(a, float) / max(float(np.linalg.norm(a)), 1e-12) for a in axes]
|
|
394
|
+
r = float(np.linalg.norm(mesh.extents)) * 1.15
|
|
395
|
+
tiles = []
|
|
396
|
+
for a in axes:
|
|
397
|
+
half = trimesh.intersections.slice_mesh_plane(mesh, a, c, cap=False)
|
|
398
|
+
if half is None or not len(half.faces):
|
|
399
|
+
continue
|
|
400
|
+
fc = cols[cKDTree(mesh.triangles_center).query(half.triangles_center)[1]]
|
|
401
|
+
face_v, face_f = cut_face(mesh, a, c)
|
|
402
|
+
if len(face_f):
|
|
403
|
+
n0 = len(half.vertices)
|
|
404
|
+
half = trimesh.Trimesh(np.vstack([half.vertices, face_v]), np.vstack([half.faces, face_f + n0]),
|
|
405
|
+
process=False)
|
|
406
|
+
fc = np.vstack([fc, np.tile(CUT, (len(face_f), 1))])
|
|
407
|
+
up = (0.0, 0.0, 1.0) if abs(float(a[2])) < 0.9 else (0.0, 1.0, 0.0)
|
|
408
|
+
tiles.append(render(half, c - a * r, c, size=size, fov=34, up=up, face_colors=fc, bg=bg))
|
|
409
|
+
return tile(tiles, cols=len(tiles), gap=8, bg=20)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def cut_face(mesh, normal, origin, min_area=0.5):
|
|
413
|
+
"""The face of a cut through `mesh` at the plane, as (vertices, faces) in 3D: every polygon of the
|
|
414
|
+
cut's outline with its holes, triangulated, polygons under `min_area` left out.
|
|
415
|
+
|
|
416
|
+
trimesh's own capping of a cut (`slice_mesh_plane(cap=True)`) comes back open when the outline
|
|
417
|
+
carries a zero-area loop, which a mesh with grazing seams can, and the picture then shows a cut
|
|
418
|
+
through a hollow part as a solid disc. Drawn from the outline's full polygons, a hole stays a hole.
|
|
419
|
+
"""
|
|
420
|
+
sec = mesh.section(plane_origin=origin, plane_normal=normal)
|
|
421
|
+
if sec is None:
|
|
422
|
+
return np.zeros((0, 3)), np.zeros((0, 3), int)
|
|
423
|
+
planar, back = sec.to_planar()
|
|
424
|
+
V, F = [], []
|
|
425
|
+
for poly in planar.polygons_full:
|
|
426
|
+
if poly.area < min_area:
|
|
427
|
+
continue
|
|
428
|
+
v2, f = trimesh.creation.triangulate_polygon(poly)
|
|
429
|
+
v3 = trimesh.transform_points(np.c_[v2, np.zeros(len(v2))], back)
|
|
430
|
+
F.append(np.asarray(f, int) + sum(len(x) for x in V))
|
|
431
|
+
V.append(v3)
|
|
432
|
+
if not V:
|
|
433
|
+
return np.zeros((0, 3)), np.zeros((0, 3), int)
|
|
434
|
+
return np.vstack(V), np.vstack(F)
|