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.
- {3d_base-0.3.0/src/3d_base.egg-info → 3d_base-0.3.2}/PKG-INFO +1 -1
- {3d_base-0.3.0 → 3d_base-0.3.2}/pyproject.toml +1 -1
- {3d_base-0.3.0 → 3d_base-0.3.2/src/3d_base.egg-info}/PKG-INFO +1 -1
- {3d_base-0.3.0 → 3d_base-0.3.2}/src/base3d/mesh.py +145 -38
- {3d_base-0.3.0 → 3d_base-0.3.2}/src/base3d/skill/SKILL.md +11 -2
- {3d_base-0.3.0 → 3d_base-0.3.2}/tests/test_mesh.py +109 -1
- {3d_base-0.3.0 → 3d_base-0.3.2}/LICENSE +0 -0
- {3d_base-0.3.0 → 3d_base-0.3.2}/README.md +0 -0
- {3d_base-0.3.0 → 3d_base-0.3.2}/setup.cfg +0 -0
- {3d_base-0.3.0 → 3d_base-0.3.2}/src/3d_base.egg-info/SOURCES.txt +0 -0
- {3d_base-0.3.0 → 3d_base-0.3.2}/src/3d_base.egg-info/dependency_links.txt +0 -0
- {3d_base-0.3.0 → 3d_base-0.3.2}/src/3d_base.egg-info/entry_points.txt +0 -0
- {3d_base-0.3.0 → 3d_base-0.3.2}/src/3d_base.egg-info/requires.txt +0 -0
- {3d_base-0.3.0 → 3d_base-0.3.2}/src/3d_base.egg-info/top_level.txt +0 -0
- {3d_base-0.3.0 → 3d_base-0.3.2}/src/base3d/__init__.py +0 -0
- {3d_base-0.3.0 → 3d_base-0.3.2}/src/base3d/cli.py +0 -0
- {3d_base-0.3.0 → 3d_base-0.3.2}/src/base3d/show.py +0 -0
- {3d_base-0.3.0 → 3d_base-0.3.2}/src/base3d/thickness.py +0 -0
- {3d_base-0.3.0 → 3d_base-0.3.2}/tests/test_show.py +0 -0
- {3d_base-0.3.0 → 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,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,
|
|
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
|
|
195
|
-
(the cosine of the angle between the face normal and the direction to `centre` is above
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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 = [[
|
|
230
|
-
solid = trimesh.Trimesh(V, np.vstack([faces,
|
|
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
|
|
240
|
-
"""
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
|
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)
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|