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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: 3d-base
3
- Version: 0.2.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
+ ![3d-base](docs/images/banner.png)
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
- ![Three views of a box, a ball and a rod that pass through each other](docs/example.png)
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
+ ![3d-base](docs/images/banner.png)
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
- ![Three views of a box, a ball and a rod that pass through each other](docs/example.png)
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.2.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
- test = ["pytest>=8"]
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.2.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
+ ![3d-base](docs/images/banner.png)
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
- ![Three views of a box, a ball and a rod that pass through each other](docs/example.png)
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
@@ -11,6 +11,8 @@ src/base3d/__init__.py
11
11
  src/base3d/cli.py
12
12
  src/base3d/mesh.py
13
13
  src/base3d/show.py
14
+ src/base3d/thickness.py
14
15
  src/base3d/skill/SKILL.md
15
16
  tests/test_mesh.py
16
- tests/test_show.py
17
+ tests/test_show.py
18
+ tests/test_thickness.py
@@ -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)