3d-base 0.3.1__tar.gz → 0.3.2__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.3.1/src/3d_base.egg-info → 3d_base-0.3.2}/PKG-INFO +1 -1
- {3d_base-0.3.1 → 3d_base-0.3.2}/pyproject.toml +1 -1
- {3d_base-0.3.1 → 3d_base-0.3.2/src/3d_base.egg-info}/PKG-INFO +1 -1
- {3d_base-0.3.1 → 3d_base-0.3.2}/src/base3d/mesh.py +101 -13
- {3d_base-0.3.1 → 3d_base-0.3.2}/src/base3d/skill/SKILL.md +2 -1
- {3d_base-0.3.1 → 3d_base-0.3.2}/tests/test_mesh.py +42 -0
- {3d_base-0.3.1 → 3d_base-0.3.2}/LICENSE +0 -0
- {3d_base-0.3.1 → 3d_base-0.3.2}/README.md +0 -0
- {3d_base-0.3.1 → 3d_base-0.3.2}/setup.cfg +0 -0
- {3d_base-0.3.1 → 3d_base-0.3.2}/src/3d_base.egg-info/SOURCES.txt +0 -0
- {3d_base-0.3.1 → 3d_base-0.3.2}/src/3d_base.egg-info/dependency_links.txt +0 -0
- {3d_base-0.3.1 → 3d_base-0.3.2}/src/3d_base.egg-info/entry_points.txt +0 -0
- {3d_base-0.3.1 → 3d_base-0.3.2}/src/3d_base.egg-info/requires.txt +0 -0
- {3d_base-0.3.1 → 3d_base-0.3.2}/src/3d_base.egg-info/top_level.txt +0 -0
- {3d_base-0.3.1 → 3d_base-0.3.2}/src/base3d/__init__.py +0 -0
- {3d_base-0.3.1 → 3d_base-0.3.2}/src/base3d/cli.py +0 -0
- {3d_base-0.3.1 → 3d_base-0.3.2}/src/base3d/show.py +0 -0
- {3d_base-0.3.1 → 3d_base-0.3.2}/src/base3d/thickness.py +0 -0
- {3d_base-0.3.1 → 3d_base-0.3.2}/tests/test_show.py +0 -0
- {3d_base-0.3.1 → 3d_base-0.3.2}/tests/test_thickness.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "3d-base"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.2"
|
|
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"
|
|
@@ -188,7 +188,7 @@ def as_written(mesh: trimesh.Trimesh, file_type: str = "stl", loose_mm3: float =
|
|
|
188
188
|
"touches": touches, "loose_mm3": float(loose)}
|
|
189
189
|
|
|
190
190
|
|
|
191
|
-
def solid_from_skin(mesh: trimesh.Trimesh, centre, lean: float = 0.3) -> trimesh.Trimesh:
|
|
191
|
+
def solid_from_skin(mesh: trimesh.Trimesh, centre, lean: float = 0.3, keep=None) -> trimesh.Trimesh:
|
|
192
192
|
"""A hollow part made solid from its outer skin, by removing its inner surface.
|
|
193
193
|
|
|
194
194
|
The inner surface is the largest connected region, by area, of faces that point toward `centre`
|
|
@@ -200,6 +200,11 @@ def solid_from_skin(mesh: trimesh.Trimesh, centre, lean: float = 0.3) -> trimesh
|
|
|
200
200
|
the same outside and no inside, so a cavity cut into it afterwards (a bore for a part it sits on)
|
|
201
201
|
is the only inside it has.
|
|
202
202
|
|
|
203
|
+
`keep` names faces that stay whatever they face, as a boolean mask over the faces: a part the
|
|
204
|
+
centre sees through the opening, such as the underside of a cap's bill, which faces the centre
|
|
205
|
+
from outside the shell and would otherwise be taken for inner surface and have the air under it
|
|
206
|
+
fanned solid.
|
|
207
|
+
|
|
203
208
|
This exists because filling a hollow part by uniting a fill with it leaves air wherever the fill
|
|
204
209
|
meets the inner surface at a grazing angle, which a slicer prints as tunnels. With no inner
|
|
205
210
|
surface there is nothing for air to lie against. Raises ValueError when the skin cannot be closed.
|
|
@@ -213,6 +218,8 @@ def solid_from_skin(mesh: trimesh.Trimesh, centre, lean: float = 0.3) -> trimesh
|
|
|
213
218
|
dist = np.linalg.norm(to_c, axis=1)
|
|
214
219
|
to_c = to_c / np.maximum(dist, 1e-12)[:, None]
|
|
215
220
|
cand = np.einsum("ij,ij->i", n, to_c) > lean
|
|
221
|
+
if keep is not None:
|
|
222
|
+
cand &= ~np.asarray(keep, bool)
|
|
216
223
|
if not cand.any():
|
|
217
224
|
raise ValueError("no face points toward the centre")
|
|
218
225
|
which = np.flatnonzero(cand)
|
|
@@ -258,20 +265,101 @@ def solid_from_skin(mesh: trimesh.Trimesh, centre, lean: float = 0.3) -> trimesh
|
|
|
258
265
|
return solid
|
|
259
266
|
|
|
260
267
|
|
|
261
|
-
def _seen_from(mesh: trimesh.Trimesh, centre, which, batch: int =
|
|
268
|
+
def _seen_from(mesh: trimesh.Trimesh, centre, which, cells: int = 64, batch: int = 4_000_000):
|
|
262
269
|
"""Which of the faces `which` a ray from `centre` to their middles meets first.
|
|
263
270
|
|
|
264
|
-
|
|
271
|
+
Every ray starts at the one point, so the triangles are binned by their direction from it on a
|
|
272
|
+
cube map (six faces of `cells` by `cells`), and each ray is tested only against the triangles in
|
|
273
|
+
its own cell, `batch` ray-triangle pairs at a time. Cast through the mesh's own tree instead,
|
|
274
|
+
each ray was tested against every triangle whose box it crossed, thousands for a ray from the
|
|
275
|
+
middle of a shell, and a shell of 70k faces took minutes and more than 8 GB.
|
|
265
276
|
"""
|
|
266
277
|
centre = np.asarray(centre, float)
|
|
278
|
+
V = np.asarray(mesh.vertices, float) - centre
|
|
279
|
+
F = np.asarray(mesh.faces)
|
|
267
280
|
which = np.asarray(which, int)
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
281
|
+
tri = V[F] # (n, 3, 3)
|
|
282
|
+
face_of, u_of, v_of = _cube_cells(tri.reshape(-1, 3), cells) # per vertex of each triangle
|
|
283
|
+
face_of, u_of, v_of = (a.reshape(-1, 3) for a in (face_of, u_of, v_of))
|
|
284
|
+
# a triangle whose three vertices lie on one cube face projects onto that face as the triangle
|
|
285
|
+
# of their projections, inside the box of their cells; one across an edge of the cube is put
|
|
286
|
+
# on each face a vertex of it lies on, in a box widened by its own angular size in cells
|
|
287
|
+
same = (face_of[:, 0] == face_of[:, 1]) & (face_of[:, 0] == face_of[:, 2])
|
|
288
|
+
entries = []
|
|
289
|
+
lo_u, hi_u = u_of.min(axis=1), u_of.max(axis=1)
|
|
290
|
+
lo_v, hi_v = v_of.min(axis=1), v_of.max(axis=1)
|
|
291
|
+
entries.append(_box_entries(np.flatnonzero(same), face_of[same, 0], lo_u[same], hi_u[same], lo_v[same], hi_v[same], cells))
|
|
292
|
+
across = np.flatnonzero(~same)
|
|
293
|
+
if len(across):
|
|
294
|
+
d = tri[across] / np.maximum(np.linalg.norm(tri[across], axis=2), 1e-12)[:, :, None]
|
|
295
|
+
cos = np.minimum.reduce([np.einsum("ij,ij->i", d[:, a], d[:, b]) for a, b in ((0, 1), (1, 2), (0, 2))])
|
|
296
|
+
margin = np.ceil(np.arccos(np.clip(cos, -1, 1)) / (2.0 / cells / 3.0)).astype(int) + 1
|
|
297
|
+
for k in range(3):
|
|
298
|
+
entries.append(_box_entries(across, face_of[across, k], u_of[across, k] - margin, u_of[across, k] + margin,
|
|
299
|
+
v_of[across, k] - margin, v_of[across, k] + margin, cells))
|
|
300
|
+
cell_tri = np.unique(np.vstack(entries), axis=0) # (cell, triangle), sorted by cell
|
|
301
|
+
starts = np.searchsorted(cell_tri[:, 0], np.arange(6 * cells * cells + 1))
|
|
302
|
+
# the rays: one per face in `which`, to its middle, against the triangles of its cell
|
|
303
|
+
mid = tri[which].mean(axis=1)
|
|
304
|
+
dist = np.linalg.norm(mid, axis=1)
|
|
305
|
+
rf, ru, rv = _cube_cells(mid, cells)
|
|
306
|
+
rcell = (rf * cells + ru) * cells + rv
|
|
307
|
+
counts = starts[rcell + 1] - starts[rcell]
|
|
308
|
+
pair_ray = np.repeat(np.arange(len(which)), counts)
|
|
309
|
+
offsets = np.repeat(starts[rcell] - np.cumsum(counts) + counts, counts)
|
|
310
|
+
pair_tri = cell_tri[np.arange(len(pair_ray)) + offsets, 1]
|
|
311
|
+
own = pair_tri == which[pair_ray]
|
|
312
|
+
hidden = np.zeros(len(which), bool)
|
|
313
|
+
for i in range(0, len(pair_ray), batch):
|
|
314
|
+
r, t_idx = pair_ray[i:i + batch], pair_tri[i:i + batch]
|
|
315
|
+
t = _ray_t(mid[r] / dist[r][:, None], tri[t_idx])
|
|
316
|
+
before = (t < dist[r] - 1e-7) & ~own[i:i + batch]
|
|
317
|
+
hidden[np.unique(r[before])] = True
|
|
318
|
+
return ~hidden
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _cube_cells(d, cells):
|
|
322
|
+
"""The cube-map cell of each direction `d`: the face (0..5, the dominant axis and its sign) and
|
|
323
|
+
the cell along the other two axes (0..cells-1)."""
|
|
324
|
+
d = np.asarray(d, float)
|
|
325
|
+
a = np.abs(d)
|
|
326
|
+
ax = np.argmax(a, axis=1)
|
|
327
|
+
idx = np.arange(len(d))
|
|
328
|
+
m = np.maximum(a[idx, ax], 1e-12)
|
|
329
|
+
face = ax * 2 + (d[idx, ax] < 0)
|
|
330
|
+
other = np.array([[1, 2], [0, 2], [0, 1]])[ax]
|
|
331
|
+
u = np.clip(((d[idx, other[:, 0]] / m + 1.0) / 2.0 * cells).astype(int), 0, cells - 1)
|
|
332
|
+
v = np.clip(((d[idx, other[:, 1]] / m + 1.0) / 2.0 * cells).astype(int), 0, cells - 1)
|
|
333
|
+
return face, u, v
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _box_entries(tri_idx, face, lo_u, hi_u, lo_v, hi_v, cells):
|
|
337
|
+
"""(cell, triangle) rows for every cell of each triangle's box on its cube face."""
|
|
338
|
+
lo_u, hi_u = np.clip(lo_u, 0, cells - 1), np.clip(hi_u, 0, cells - 1)
|
|
339
|
+
lo_v, hi_v = np.clip(lo_v, 0, cells - 1), np.clip(hi_v, 0, cells - 1)
|
|
340
|
+
nu, nv = hi_u - lo_u + 1, hi_v - lo_v + 1
|
|
341
|
+
n = nu * nv
|
|
342
|
+
rep = np.repeat(np.arange(len(tri_idx)), n)
|
|
343
|
+
k = np.arange(len(rep)) - np.repeat(np.cumsum(n) - n, n)
|
|
344
|
+
uu = lo_u[rep] + k // nv[rep]
|
|
345
|
+
vv = lo_v[rep] + k % nv[rep]
|
|
346
|
+
cell = (face[rep] * cells + uu) * cells + vv
|
|
347
|
+
return np.c_[cell, tri_idx[rep]]
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _ray_t(d, tri):
|
|
351
|
+
"""Where rays from the origin along `d` (m, 3) meet the triangles `tri` (m, 3, 3), as the
|
|
352
|
+
distance along each ray, infinite where a ray misses its triangle (Moller-Trumbore)."""
|
|
353
|
+
e1 = tri[:, 1] - tri[:, 0]
|
|
354
|
+
e2 = tri[:, 2] - tri[:, 0]
|
|
355
|
+
p = np.cross(d, e2)
|
|
356
|
+
det = np.einsum("ij,ij->i", e1, p)
|
|
357
|
+
ok = np.abs(det) > 1e-12
|
|
358
|
+
inv = np.where(ok, 1.0 / np.where(ok, det, 1.0), 0.0)
|
|
359
|
+
s = -tri[:, 0]
|
|
360
|
+
u = np.einsum("ij,ij->i", s, p) * inv
|
|
361
|
+
q = np.cross(s, e1)
|
|
362
|
+
v = np.einsum("ij,ij->i", d, q) * inv
|
|
363
|
+
t = np.einsum("ij,ij->i", e2, q) * inv
|
|
364
|
+
hit = ok & (u >= -1e-9) & (v >= -1e-9) & (u + v <= 1.0 + 1e-9) & (t > 1e-9)
|
|
365
|
+
return np.where(hit, t, np.inf)
|
|
@@ -338,7 +338,8 @@ fixed each.
|
|
|
338
338
|
lie against. Three fills on one cap each left tunnels; the skin made solid left none. The inner
|
|
339
339
|
surface is what a ray from the centre meets first: do not cut it off at a distance from the
|
|
340
340
|
centre, because the part beyond the cut stays, with the shell's own thin wall, under a floor
|
|
341
|
-
fanned above it (a cap's sides round the ears read 0.46 mm that way).
|
|
341
|
+
fanned above it (a cap's sides round the ears read 0.46 mm that way). Name with `keep` the faces
|
|
342
|
+
the centre sees through the opening (a bill's underside), or the air under them is fanned solid.
|
|
342
343
|
|
|
343
344
|
## trimesh calls that cost a build each to learn
|
|
344
345
|
|
|
@@ -217,3 +217,45 @@ def test_a_fan_that_is_not_flat_is_wound_with_the_skin():
|
|
|
217
217
|
assert solid.is_winding_consistent
|
|
218
218
|
want = 2.0 / 3.0 * np.pi * 1000 - np.pi * 81 * 4 / 3.0
|
|
219
219
|
assert abs(solid.volume - want) < 0.03 * want
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_what_the_centre_sees_matches_a_ray_cast_through_the_tree():
|
|
223
|
+
from base3d.mesh import _seen_from
|
|
224
|
+
t = np.linspace(0, np.pi / 2, 24)
|
|
225
|
+
outer = np.c_[10 * np.sin(t), 10 * np.cos(t)]
|
|
226
|
+
brim = np.array([[16.0, 0.0], [16.0, -1.0], [9.0, -1.0]])
|
|
227
|
+
inner = np.c_[9 * np.sin(t), 9 * np.cos(t)][::-1]
|
|
228
|
+
dome = _revolved(np.vstack([outer, brim, inner, outer[:1]]), sections=64)
|
|
229
|
+
dome.apply_transform(trimesh.transformations.rotation_matrix(0.7, [1, 2, 3])) # off the axes
|
|
230
|
+
centre = dome.centroid + [0.3, -0.2, 2.0]
|
|
231
|
+
which = np.arange(len(dome.faces))
|
|
232
|
+
c = dome.triangles_center
|
|
233
|
+
d = c - centre
|
|
234
|
+
d /= np.linalg.norm(d, axis=1)[:, None]
|
|
235
|
+
first = np.asarray(dome.ray.intersects_first(np.repeat(centre[None, :], len(which), axis=0), d))
|
|
236
|
+
want = first == which
|
|
237
|
+
got = _seen_from(dome, centre, which)
|
|
238
|
+
assert (got == want).all(), f"{int((got != want).sum())} faces differ of {len(which)}"
|
|
239
|
+
assert 0 < got.sum() < len(which)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def test_a_bill_seen_through_the_opening_is_kept_when_named():
|
|
243
|
+
from base3d.mesh import solid_from_skin
|
|
244
|
+
# a dome with a flat bill out to 20 mm, made solid about a centre 8 mm below the rim: the bill's
|
|
245
|
+
# underside faces that centre and is seen from it through the opening, so without being told the
|
|
246
|
+
# fill takes it for inner surface and fans the air under the bill solid
|
|
247
|
+
t = np.linspace(0, np.pi / 2, 24)
|
|
248
|
+
outer = np.c_[10 * np.sin(t), 10 * np.cos(t)]
|
|
249
|
+
bill = np.array([[20.0, 0.0], [20.0, -1.0], [9.0, -1.0]])
|
|
250
|
+
inner = np.c_[9 * np.sin(t), 9 * np.cos(t)][::-1]
|
|
251
|
+
dome = _revolved(np.vstack([outer, bill, inner, outer[:1]]), sections=64)
|
|
252
|
+
under = np.array([[15.0, 0.0, -2.0]])
|
|
253
|
+
c, n = dome.triangles_center, dome.face_normals
|
|
254
|
+
underside = (np.abs(c[:, 2] + 1.0) < 1e-6) & (np.linalg.norm(c[:, :2], axis=1) > 9.5) & (n[:, 2] < -0.9)
|
|
255
|
+
assert underside.sum() > 0
|
|
256
|
+
blind = solid_from_skin(dome, centre=(0, 0, -8))
|
|
257
|
+
assert blind.contains(under)[0], "the air under the bill was not filled, so the test proves nothing"
|
|
258
|
+
told = solid_from_skin(dome, centre=(0, 0, -8), keep=underside)
|
|
259
|
+
assert told.is_watertight and told.is_winding_consistent and told.euler_number == 2
|
|
260
|
+
assert not told.contains(under)[0]
|
|
261
|
+
assert abs(told.bounds[1][0] - 20.0) < 1e-6
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|