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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: 3d-base
3
- Version: 0.3.1
3
+ Version: 0.3.2
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "3d-base"
7
- version = "0.3.1"
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"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: 3d-base
3
- Version: 0.3.1
3
+ Version: 0.3.2
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
@@ -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 = 2000):
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
- Rays are cast `batch` at a time, which keeps memory flat on large meshes.
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
- c = np.asarray(mesh.triangles_center, float)[which]
269
- d = c - centre
270
- d /= np.maximum(np.linalg.norm(d, axis=1), 1e-12)[:, None]
271
- seen = np.zeros(len(which), bool)
272
- for i in range(0, len(which), batch):
273
- k = which[i:i + batch]
274
- first = mesh.ray.intersects_first(np.repeat(centre[None, :], len(k), axis=0), d[i:i + batch])
275
- seen[i:i + batch] = np.asarray(first) == k
276
- return seen
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