3d-base 0.3.0__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.0
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.0"
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.0
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,15 +188,22 @@ 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, reach: float, 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
- 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.
194
+ The inner surface is the largest connected region, by area, of faces that point toward `centre`
195
+ (the cosine of the angle between the face normal and the direction to `centre` is above `lean`)
196
+ and are seen from it: a ray from `centre` to the face's middle meets that face first. A face
197
+ behind a wall (the top of a brim, as seen through the shell) is kept whichever way it faces.
198
+ Removing the inner surface leaves the skin open along one or more loops (the inner edge of a rim,
199
+ the root of a brim), and each loop is closed with a fan of triangles to `centre`. The result has
200
+ the same outside and no inside, so a cavity cut into it afterwards (a bore for a part it sits on)
201
+ is the only inside it has.
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.
200
207
 
201
208
  This exists because filling a hollow part by uniting a fill with it leaves air wherever the fill
202
209
  meets the inner surface at a grazing angle, which a slicer prints as tunnels. With no inner
@@ -210,49 +217,149 @@ def solid_from_skin(mesh: trimesh.Trimesh, centre, reach: float, lean: float = 0
210
217
  to_c = centre - c
211
218
  dist = np.linalg.norm(to_c, axis=1)
212
219
  to_c = to_c / np.maximum(dist, 1e-12)[:, None]
213
- cand = (dist < float(reach)) & (np.einsum("ij,ij->i", n, to_c) > lean)
220
+ cand = np.einsum("ij,ij->i", n, to_c) > lean
221
+ if keep is not None:
222
+ cand &= ~np.asarray(keep, bool)
223
+ if not cand.any():
224
+ raise ValueError("no face points toward the centre")
225
+ which = np.flatnonzero(cand)
226
+ cand[which[~_seen_from(mesh, centre, which)]] = False
214
227
  if not cand.any():
215
- raise ValueError("no face points toward the centre within reach")
228
+ raise ValueError("no face that points toward the centre is seen from it")
216
229
  adj = np.asarray(mesh.face_adjacency)
217
230
  both = cand[adj[:, 0]] & cand[adj[:, 1]]
218
231
  graph = sp.coo_matrix((np.ones(int(both.sum())), (adj[both, 0], adj[both, 1])), shape=(len(c), len(c)))
219
232
  _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]
233
+ # the inner surface is the largest connected region of candidates by area: the one nearest the
234
+ # centre can be a stray pair of faces (a cap's two faces inside its button), and which faces go
235
+ # must not depend on where the part happens to lie
236
+ area = np.asarray(mesh.area_faces, float)
237
+ by_region = np.bincount(label[cand], weights=area[cand], minlength=int(label.max()) + 1)
238
+ inner = cand & (label == int(np.argmax(by_region)))
239
+ # what stays is the one connected skin round the inner surface; an island of faces inside it that
240
+ # the centre cannot see (the side of a groove hidden by its lip) would be fanned into a sealed
241
+ # pocket, so it goes with the inner surface
242
+ kept = ~inner
243
+ both = kept[adj[:, 0]] & kept[adj[:, 1]]
244
+ graph = sp.coo_matrix((np.ones(int(both.sum())), (adj[both, 0], adj[both, 1])), shape=(len(c), len(c)))
245
+ _k, label = connected_components(graph, directed=False)
246
+ by_region = np.bincount(label[kept], weights=area[kept], minlength=int(label.max()) + 1)
247
+ kept &= label == int(np.argmax(by_region))
248
+ faces = np.asarray(mesh.faces)[kept]
223
249
  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]
250
+ # the skin's boundary edges, each in the winding of the face it belongs to, so the fan triangle
251
+ # across it runs the edge the other way and is wound with the skin. Chaining the edges into loops
252
+ # after sorting them lost that direction, and a fan that is not flat then faced the wrong way.
253
+ directed = np.asarray(skin.edges)
254
+ _u, inverse, count = np.unique(np.sort(directed, axis=1), axis=0, return_inverse=True, return_counts=True)
255
+ boundary = directed[count[np.asarray(inverse).reshape(-1)] == 1]
227
256
  V = np.vstack([skin.vertices, centre[None, :]])
228
257
  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)
258
+ fan = np.c_[boundary[:, 1], boundary[:, 0], np.full(len(boundary), apex)]
259
+ solid = trimesh.Trimesh(V, np.vstack([faces, fan.reshape(-1, 3)]), process=False)
231
260
  solid.merge_vertices()
232
- if not solid.is_watertight:
261
+ if not solid.is_watertight or not solid.is_winding_consistent:
233
262
  raise ValueError("the skin could not be closed")
234
263
  if solid.volume < 0:
235
264
  solid.invert()
236
265
  return solid
237
266
 
238
267
 
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
268
+ def _seen_from(mesh: trimesh.Trimesh, centre, which, cells: int = 64, batch: int = 4_000_000):
269
+ """Which of the faces `which` a ray from `centre` to their middles meets first.
270
+
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.
276
+ """
277
+ centre = np.asarray(centre, float)
278
+ V = np.asarray(mesh.vertices, float) - centre
279
+ F = np.asarray(mesh.faces)
280
+ which = np.asarray(which, int)
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)
@@ -265,6 +265,11 @@ for s in spots:
265
265
  part.stl`) draws three cuts through its centre coloured by wall thickness, with the cut face in
266
266
  blue. A ridge, a dip in a floor or a gap that is not where you thought shows there in a minute,
267
267
  where a function written against the wrong shape costs a rebuild per guess.
268
+ - Nothing prints at one line. When a part's thickness runs from plenty at one place down to one
269
+ line at another (the sides of a shell where it sits closest to what it covers, the tips of a
270
+ clamp cut by parallel planes), the thin end is the design error. Change it, by growing the part
271
+ until its thinnest wall reaches the bar or by cutting the feature so it ends square, and do not
272
+ offer "leave it as one line" as a choice. A one-line wall is brittle, whatever the slicer draws.
268
273
  - Before thickening anything, find out which side can grow. A shell that sits on another part (a cap
269
274
  on a head) can only grow outward where there is no gap under it, and growing outward changes the
270
275
  look. Measure the gap first.
@@ -328,9 +333,13 @@ fixed each.
328
333
  - A fill sampled by rays from a centre also reaches surfaces it was not meant for (the underside of
329
334
  a brim far out from the centre). Limit how far it may reach.
330
335
  - Better than any fill: a hollow part that is to sit on another does not need a fill at all.
331
- `mesh.solid_from_skin(mesh, centre, reach)` removes the inner surface and closes the skin, so the
336
+ `mesh.solid_from_skin(mesh, centre)` removes the inner surface and closes the skin, so the
332
337
  cavity cut for the part it sits on is the only inside it has, and there is no surface for air to
333
- lie against. Three fills on one cap each left tunnels; the skin made solid left none.
338
+ lie against. Three fills on one cap each left tunnels; the skin made solid left none. The inner
339
+ surface is what a ray from the centre meets first: do not cut it off at a distance from the
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). Name with `keep` the faces
342
+ the centre sees through the opening (a bill's underside), or the air under them is fanned solid.
334
343
 
335
344
  ## trimesh calls that cost a build each to learn
336
345
 
@@ -147,7 +147,115 @@ def test_a_hollow_dome_becomes_a_solid_one_from_its_skin():
147
147
  if dome.volume < 0:
148
148
  dome.invert()
149
149
  assert dome.is_watertight
150
- solid = solid_from_skin(dome, centre=(0, 0, 0), reach=9.5)
150
+ solid = solid_from_skin(dome, centre=(0, 0, 0))
151
151
  assert solid.is_watertight and solid.euler_number == 2
152
152
  assert abs(solid.volume - 2.0 / 3.0 * np.pi * 1000) < 0.03 * 2094 # a solid half ball
153
153
  assert abs(solid.bounds[1][2] - 10.0) < 1e-6 # the outside is kept
154
+
155
+
156
+ def _bell_profiles():
157
+ """A bell: 1 mm thick at the top, flaring to a 0.4 mm wall at the rim, open at the bottom, with
158
+ its inner surface at the flare further from the centre than at the top, as a cap's is round the
159
+ ears. (outer from the top down to the rim, inner from the rim back up to the top), in (r, z)."""
160
+ t = np.linspace(0, np.pi / 2, 48)
161
+ flare = np.sin(t) ** 4
162
+ outer = np.c_[(10 + 3.0 * flare) * np.sin(t), (10 + 3.0 * flare) * np.cos(t)]
163
+ inner = np.c_[(9 + 3.6 * flare) * np.sin(t), (9 + 3.6 * flare) * np.cos(t)][::-1]
164
+ return outer, inner
165
+
166
+
167
+ def _revolved(profile, sections=96):
168
+ part = trimesh.creation.revolve(profile, sections=sections)
169
+ if part.volume < 0:
170
+ part.invert()
171
+ assert part.is_watertight
172
+ return part
173
+
174
+
175
+ def test_the_whole_inner_surface_goes_where_it_flares_out_and_the_thin_wall_with_it():
176
+ from base3d.mesh import solid_from_skin
177
+ from base3d.thickness import thin_spots
178
+ outer, inner = _bell_profiles()
179
+ bell = _revolved(np.vstack([outer, inner, outer[:1]]))
180
+ solid = solid_from_skin(bell, centre=(0, 0, 0))
181
+ assert solid.is_watertight and solid.is_winding_consistent and solid.euler_number == 2
182
+ thin = [s for s in thin_spots(solid, 0.8) if s["points"] >= 8]
183
+ assert thin == [], "the 0.4 mm wall at the flare is still there"
184
+ full = _revolved(np.vstack([outer, [[0.0, 0.0]], outer[:1]])) # a solid bell
185
+ assert abs(solid.volume - full.volume) < 0.03 * full.volume
186
+
187
+
188
+ def test_a_face_behind_a_wall_is_not_part_of_the_inner_surface():
189
+ from base3d.mesh import solid_from_skin
190
+ from base3d.thickness import thin_spots
191
+ # a dome shell with a brim: seen from a centre 5 mm up, the brim's top faces the centre, but the
192
+ # dome's wall stands between
193
+ t = np.linspace(0, np.pi / 2, 24)
194
+ outer = np.c_[10 * np.sin(t), 10 * np.cos(t)] # top to the rim at (10, 0)
195
+ brim = np.array([[16.0, 0.0], [16.0, -1.0], [9.0, -1.0]]) # out along the rim, under, back in
196
+ inner = np.c_[9 * np.sin(t), 9 * np.cos(t)][::-1] # rim back up to the top
197
+ dome = _revolved(np.vstack([outer, brim, inner, outer[:1]]), sections=64)
198
+ solid = solid_from_skin(dome, centre=(0, 0, 5))
199
+ assert solid.is_watertight and solid.is_winding_consistent and solid.euler_number == 2
200
+ assert [s for s in thin_spots(solid, 0.8) if s["points"] >= 8] == []
201
+ assert abs(solid.bounds[1][0] - 16.0) < 1e-6 # the brim is still there
202
+ # the same solid drawn as one profile: the dome, the brim, and the fan from the brim's inner
203
+ # edge under the rim up to the centre (the brim's inner side faces the centre and is seen)
204
+ want = _revolved(np.vstack([outer, brim, [[0.0, 5.0]], outer[:1]]), sections=64)
205
+ assert abs(solid.volume - want.volume) < 0.03 * want.volume
206
+
207
+
208
+ def test_a_fan_that_is_not_flat_is_wound_with_the_skin():
209
+ from base3d.mesh import solid_from_skin
210
+ # a dome shell made solid about a centre above its rim: the fan is a cone, and the solid's volume
211
+ # is the half ball less that cone only when the cone faces the right way
212
+ t = np.linspace(0, np.pi / 2, 24)
213
+ outer = np.c_[10 * np.sin(t), 10 * np.cos(t)]
214
+ inner = np.c_[9 * np.sin(t), 9 * np.cos(t)][::-1]
215
+ dome = _revolved(np.vstack([outer, inner, outer[:1]]), sections=64)
216
+ solid = solid_from_skin(dome, centre=(0, 0, 4))
217
+ assert solid.is_winding_consistent
218
+ want = 2.0 / 3.0 * np.pi * 1000 - np.pi * 81 * 4 / 3.0
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