3d-base 0.1.0__py3-none-any.whl
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.1.0.dist-info/METADATA +104 -0
- 3d_base-0.1.0.dist-info/RECORD +10 -0
- 3d_base-0.1.0.dist-info/WHEEL +5 -0
- 3d_base-0.1.0.dist-info/entry_points.txt +2 -0
- 3d_base-0.1.0.dist-info/licenses/LICENSE +21 -0
- 3d_base-0.1.0.dist-info/top_level.txt +1 -0
- base3d/__init__.py +6 -0
- base3d/cli.py +48 -0
- base3d/mesh.py +87 -0
- base3d/show.py +294 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: 3d-base
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Headless pictures of 3D meshes with correct occlusion and smooth shading, and a health check for exported STL files
|
|
5
|
+
Author: Konstantinos Georgiou
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/drkostas/3d-base
|
|
8
|
+
Keywords: 3d,mesh,stl,render,headless,trimesh,3d-printing,cad
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Topic :: Multimedia :: Graphics :: 3D Rendering
|
|
11
|
+
Requires-Python: >=3.9
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Requires-Dist: numpy>=1.22
|
|
15
|
+
Requires-Dist: trimesh>=3.20
|
|
16
|
+
Requires-Dist: scipy>=1.8
|
|
17
|
+
Provides-Extra: test
|
|
18
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# 3d-base
|
|
22
|
+
|
|
23
|
+
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
|
+
|
|
25
|
+

|
|
26
|
+
|
|
27
|
+
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
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install 3d-base
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
In Python it is imported as `base3d`, because a Python name cannot start with a digit.
|
|
36
|
+
|
|
37
|
+
## Use
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
3d-base render part.stl -o part.png # one view, from the front right
|
|
41
|
+
3d-base render part.stl --view below -o below.png
|
|
42
|
+
3d-base render part.stl --azim 30 --elev 20 -o x.png
|
|
43
|
+
3d-base render part.stl --view sheet -o sheet.png # six views in a grid
|
|
44
|
+
3d-base check part.stl # faces, bodies, closed cavities, volume
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`3d-base check` exits with 1 when the file has a closed cavity, so it can stop a script before a print.
|
|
48
|
+
|
|
49
|
+
From Python:
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
import trimesh
|
|
53
|
+
from base3d import frame_on, render, save_png, sheet
|
|
54
|
+
|
|
55
|
+
mesh = trimesh.load("part.stl")
|
|
56
|
+
eye, target = frame_on(mesh, azim=-45, elev=25)
|
|
57
|
+
save_png(render(mesh, eye, target, size=(1400, 1000)), "part.png")
|
|
58
|
+
save_png(sheet(mesh, views=("front", "top", "below")), "views.png")
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`render` takes `face_colors` (one RGB colour per face, values from 0 to 1), so parts can have their own colours. It returns an RGB array, so the picture can also go to Pillow, matplotlib or anything else.
|
|
62
|
+
|
|
63
|
+
## Why not matplotlib
|
|
64
|
+
|
|
65
|
+
Matplotlib's 3D axes sort whole triangles from back to front and then draw them. When parts overlap or pass through each other, triangles come out in the wrong order, and a correct model looks broken. 3d-base keeps a depth value for every pixel, so the nearest surface always wins.
|
|
66
|
+
|
|
67
|
+
## Shading
|
|
68
|
+
|
|
69
|
+
A few choices came from looking at real pictures and finding them misleading.
|
|
70
|
+
|
|
71
|
+
- The light comes partly from the camera (`HEADLIGHT`). With only a fixed lamp, a view from below or from behind came out close to black (about a fifth of full brightness), and those views are often the ones you need.
|
|
72
|
+
- Curved surfaces are shaded smoothly, and real edges stay sharp. With one normal per triangle, a sphere looks like a quilt of flat facets even when the mesh is within a few microns of a true sphere, which reads as a defect that is not in the model. Normals are averaged only over neighbouring faces within 32 degrees (`CREASE_DEG`), so box corners and cut edges keep their own normal.
|
|
73
|
+
- The average for a corner is taken over faces close to that face's own direction, not over all faces at the vertex. The simpler version bends the normals at the rim of a cut toward the cut's walls and draws a bright spike that is not there. `corner_normals(mesh, naive=True)` returns the simpler version, so you can see the difference.
|
|
74
|
+
- Colour is flat per face and only the light changes across a triangle, so a colour boundary in the picture is a face boundary in the mesh.
|
|
75
|
+
|
|
76
|
+
`display_mesh(mesh)` returns the mesh with these same normals stored on its vertices. A GLB exported by trimesh carries no normals, so a web viewer such as three.js shows it unlit with smooth shading on. Exporting `display_mesh(mesh)` instead makes the viewer and the pictures agree.
|
|
77
|
+
|
|
78
|
+
## Checking an exported STL
|
|
79
|
+
|
|
80
|
+
OpenCascade (and tools built on it, such as build123d and CadQuery) writes zero-area triangles at every sphere pole. A plain sphere then reports `is_watertight=False` and two or three extra bodies. `load_clean` removes them, and after that a second body is real. A body with negative volume is a closed cavity inside the part (a socket that never reached the surface, for example).
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from base3d import load_clean, voids
|
|
84
|
+
mesh, bodies = load_clean("part.stl")
|
|
85
|
+
assert not voids(bodies)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`surface_points(mesh, n)` samples points on the surface with a fixed seed, so a step that places things from those points gives the same answer on every run.
|
|
89
|
+
|
|
90
|
+
## Speed
|
|
91
|
+
|
|
92
|
+
It is plain numpy with a loop over triangles. A part with 225,721 faces renders at 700 by 500 in 6 to 9 seconds on an M1 Max, and a part with 40,960 faces in about 1.5 seconds. For an interactive viewer use something else. For pictures in a script, a test or a report it is fast enough.
|
|
93
|
+
|
|
94
|
+
## Development
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
python -m venv .venv && .venv/bin/pip install -e '.[test]'
|
|
98
|
+
.venv/bin/pytest
|
|
99
|
+
python examples/make_example.py # rebuilds docs/example.png
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## License
|
|
103
|
+
|
|
104
|
+
MIT
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
3d_base-0.1.0.dist-info/licenses/LICENSE,sha256=y2yT4XOJ5hjMzENqKJShtqLiwI-IE1PnWqMxbhhL5r8,1078
|
|
2
|
+
base3d/__init__.py,sha256=g5MR_ocOXbM7Frob0y8-kjewb_nYHk5_jWsMjR2vX5s,343
|
|
3
|
+
base3d/cli.py,sha256=h98eA_e8YZrL_1wpUCwg1Ep5KWUhAoTDbAoWl6L6MrI,1691
|
|
4
|
+
base3d/mesh.py,sha256=44DZAKxlppiuDwUyX8zfuqX9P3Jo4nloEqVwWeJpPpA,3384
|
|
5
|
+
base3d/show.py,sha256=7uw2Ev5UnRnKOuuke3C-l5h7TEAyGFldtBBKQ1DOT-Q,13679
|
|
6
|
+
3d_base-0.1.0.dist-info/METADATA,sha256=yMTdKUzubV7M4f0OD92nNKPGeCIE4uXVSxUnT3HfESE,5271
|
|
7
|
+
3d_base-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
8
|
+
3d_base-0.1.0.dist-info/entry_points.txt,sha256=5i_ZaQnITWllqzRW-Ka9UwJBT_awnhNI8LL_typmkvQ,44
|
|
9
|
+
3d_base-0.1.0.dist-info/top_level.txt,sha256=TY3ZUX-6EPMJ-grHwU5WEgKJMBECahoPRphxcxZs8V4,7
|
|
10
|
+
3d_base-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Konstantinos Georgiou
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
base3d
|
base3d/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Headless pictures of 3D meshes, and an honest health check for exported STL files."""
|
|
2
|
+
from .mesh import load_clean, report, surface_points, voids
|
|
3
|
+
from .show import frame_on, render, save_png, sheet, tile
|
|
4
|
+
|
|
5
|
+
__version__ = "0.1.0"
|
|
6
|
+
__all__ = ["frame_on", "load_clean", "render", "report", "save_png", "sheet", "surface_points", "tile", "voids"]
|
base3d/cli.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""3d-base command line."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
from .mesh import load_clean, report
|
|
10
|
+
from .show import SHEET_BG, VIEWS, frame_on, render, save_png, sheet
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main(argv=None) -> int:
|
|
14
|
+
p = argparse.ArgumentParser(prog="3d-base", description="Pictures of a 3D mesh, and a health check.")
|
|
15
|
+
p.add_argument("--version", action="version", version=f"3d-base {__version__}")
|
|
16
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
17
|
+
|
|
18
|
+
r = sub.add_parser("render", help="render one view, or a sheet of views, to PNG")
|
|
19
|
+
r.add_argument("mesh")
|
|
20
|
+
r.add_argument("-o", "--out", default="render.png")
|
|
21
|
+
r.add_argument("--view", default="front-right", help=f"one of {', '.join(VIEWS)}, or 'sheet'")
|
|
22
|
+
r.add_argument("--azim", type=float)
|
|
23
|
+
r.add_argument("--elev", type=float)
|
|
24
|
+
r.add_argument("--size", default="1400x1000")
|
|
25
|
+
|
|
26
|
+
c = sub.add_parser("check", help="faces, bodies, closed cavities and volume, after dropping slivers")
|
|
27
|
+
c.add_argument("mesh")
|
|
28
|
+
|
|
29
|
+
a = p.parse_args(argv)
|
|
30
|
+
if a.cmd == "check":
|
|
31
|
+
rep = report(a.mesh)
|
|
32
|
+
print(json.dumps(rep, indent=1))
|
|
33
|
+
return 1 if rep["voids"] else 0
|
|
34
|
+
m, _ = load_clean(a.mesh)
|
|
35
|
+
w, h = (int(x) for x in a.size.lower().split("x"))
|
|
36
|
+
if a.view == "sheet":
|
|
37
|
+
img = sheet(m, size=(w // 2, h // 2))
|
|
38
|
+
else:
|
|
39
|
+
azim, elev = (a.azim, a.elev) if a.azim is not None and a.elev is not None else VIEWS[a.view]
|
|
40
|
+
eye, target = frame_on(m, azim, elev)
|
|
41
|
+
img = render(m, eye, target, size=(w, h), bg=SHEET_BG)
|
|
42
|
+
save_png(img, a.out)
|
|
43
|
+
print(a.out)
|
|
44
|
+
return 0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
if __name__ == "__main__":
|
|
48
|
+
sys.exit(main())
|
base3d/mesh.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
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
|
+
}
|
base3d/show.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""A small software renderer for judging a 3D model from pictures, with numpy and nothing else.
|
|
2
|
+
|
|
3
|
+
Matplotlib's 3D axes sort whole polygons from back to front and then draw them, so faces that
|
|
4
|
+
overlap or pass through each other come out in the wrong order. A limb shows through a body, and a
|
|
5
|
+
model that is correct looks broken. A depth buffer decides for each pixel, which is the only way to
|
|
6
|
+
get that right.
|
|
7
|
+
|
|
8
|
+
The picture is the mesh itself, shaded by its own normals. Nothing is smoothed or simplified for
|
|
9
|
+
the picture, so if a part looks wrong here, the part is wrong (with one exception, the shading of
|
|
10
|
+
smooth surfaces, described at CREASE_DEG).
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import struct
|
|
15
|
+
import zlib
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
import trimesh
|
|
19
|
+
|
|
20
|
+
# How much of the light comes from the camera. This term keeps every view readable: a face you can
|
|
21
|
+
# see is a face that faces the camera, so it always receives some light. Without it, views from
|
|
22
|
+
# below and from behind come out close to black with a fixed key light.
|
|
23
|
+
HEADLIGHT = 0.60
|
|
24
|
+
|
|
25
|
+
# Smooth shading with real edges kept sharp.
|
|
26
|
+
#
|
|
27
|
+
# Shading with one normal per triangle makes every sphere look like a quilt of flat facets, even
|
|
28
|
+
# when the mesh is within a few microns of a true sphere (much less than one pixel, and much less
|
|
29
|
+
# than one printed layer). That reads as a modelling defect that does not exist. So the normal is
|
|
30
|
+
# interpolated across each triangle from its corners, but only where the surface is really smooth.
|
|
31
|
+
# A neighbouring face that turns more than CREASE_DEG away is on the other side of a real edge and
|
|
32
|
+
# is left out of the average, so box corners, cuts and seams stay crisp.
|
|
33
|
+
CREASE_DEG = 32.0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def look_at(eye, target, up=(0.0, 0.0, 1.0)):
|
|
37
|
+
"""A 4x4 view matrix for a camera at `eye` looking at `target`."""
|
|
38
|
+
eye, target = np.asarray(eye, float), np.asarray(target, float)
|
|
39
|
+
f = target - eye
|
|
40
|
+
f /= np.linalg.norm(f)
|
|
41
|
+
u = np.asarray(up, float)
|
|
42
|
+
s = np.cross(f, u)
|
|
43
|
+
if np.linalg.norm(s) < 1e-9: # looking straight along `up`
|
|
44
|
+
s = np.cross(f, [0.0, 1.0, 0.0])
|
|
45
|
+
s /= np.linalg.norm(s)
|
|
46
|
+
u = np.cross(s, f)
|
|
47
|
+
M = np.eye(4)
|
|
48
|
+
M[0, :3], M[1, :3], M[2, :3] = s, u, -f
|
|
49
|
+
M[:3, 3] = -M[:3, :3] @ eye
|
|
50
|
+
return M
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def display_mesh(mesh, crease_deg=None):
|
|
54
|
+
"""The same mesh with per-vertex normals baked in, for viewers that shade from stored normals.
|
|
55
|
+
|
|
56
|
+
A GLB exported by trimesh carries positions and colours but no normals, and a web viewer such as
|
|
57
|
+
three.js then renders it unlit when smooth shading is on. These are the same crease-aware
|
|
58
|
+
normals this renderer uses, so the viewer and the pictures agree. Vertices are split per corner
|
|
59
|
+
and then merged back where both position and normal agree, so only real edges duplicate.
|
|
60
|
+
"""
|
|
61
|
+
deg = CREASE_DEG if crease_deg is None else float(crease_deg)
|
|
62
|
+
F = np.asarray(mesh.faces)
|
|
63
|
+
cn = corner_normals(mesh, deg).reshape(-1, 3)
|
|
64
|
+
V = np.asarray(mesh.vertices, float)[F].reshape(-1, 3)
|
|
65
|
+
key = np.hstack([np.round(V, 5), np.round(cn, 4)])
|
|
66
|
+
_, first, inverse = np.unique(key, axis=0, return_index=True, return_inverse=True)
|
|
67
|
+
out = trimesh.Trimesh(V[first], inverse.reshape(-1, 3), process=False)
|
|
68
|
+
out.vertex_normals = cn[first]
|
|
69
|
+
return out
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def corner_normals(mesh, crease_deg=CREASE_DEG, naive=False):
|
|
73
|
+
"""A normal for each corner of each face, shape (faces, 3, 3).
|
|
74
|
+
|
|
75
|
+
For each corner, the normals of the faces at that vertex are averaged (weighted by area), but
|
|
76
|
+
only those within `crease_deg` of THIS face. Comparing each face to the plain average of all
|
|
77
|
+
faces at the vertex looks similar and is wrong at the rim of a cut, because there the average is
|
|
78
|
+
pulled toward the cut's walls. A rim triangle is then smoothed as if it faced the light and
|
|
79
|
+
shows as a bright spike that is not in the model.
|
|
80
|
+
|
|
81
|
+
`naive=True` returns that wrong version. It is kept so a test can show the difference.
|
|
82
|
+
|
|
83
|
+
The result is cached on the mesh, keyed by the angle and by the mesh's hash. The hash changes
|
|
84
|
+
when the mesh is moved or rotated, so a transformed mesh is never shaded with stale normals.
|
|
85
|
+
"""
|
|
86
|
+
if naive:
|
|
87
|
+
n0 = np.asarray(mesh.face_normals, float)
|
|
88
|
+
F0 = np.asarray(mesh.faces)
|
|
89
|
+
vn = np.asarray(mesh.vertex_normals, float)[F0]
|
|
90
|
+
fn3 = np.repeat(n0[:, None, :], 3, axis=1)
|
|
91
|
+
dot = np.clip((vn * fn3).sum(axis=2), -1.0, 1.0)
|
|
92
|
+
cn0 = np.where((dot < np.cos(np.radians(crease_deg)))[..., None], fn3, vn)
|
|
93
|
+
ln0 = np.linalg.norm(cn0, axis=2, keepdims=True)
|
|
94
|
+
return cn0 / np.where(ln0 < 1e-9, 1.0, ln0)
|
|
95
|
+
key = "_base3d_corner_normals"
|
|
96
|
+
ck = (round(float(crease_deg), 1), hash(mesh))
|
|
97
|
+
got = getattr(mesh, key, None)
|
|
98
|
+
if got is not None and got[0] == ck:
|
|
99
|
+
return got[1]
|
|
100
|
+
n = np.asarray(mesh.face_normals, float)
|
|
101
|
+
F = np.asarray(mesh.faces)
|
|
102
|
+
try:
|
|
103
|
+
# Which faces touch each vertex. trimesh's `vertex_faces` raises on meshes with degenerate
|
|
104
|
+
# faces, so it is built here directly: sort the (vertex, face) pairs by vertex and put each
|
|
105
|
+
# vertex's faces in its own row, padded with -1.
|
|
106
|
+
F_flat = F.ravel()
|
|
107
|
+
f_of = np.repeat(np.arange(len(F)), 3)
|
|
108
|
+
order = np.argsort(F_flat, kind="stable")
|
|
109
|
+
vs, fs = F_flat[order], f_of[order]
|
|
110
|
+
V = int(F.max()) + 1
|
|
111
|
+
counts = np.bincount(vs, minlength=V)
|
|
112
|
+
start = np.concatenate([[0], np.cumsum(counts)[:-1]])
|
|
113
|
+
vf = np.full((V, int(counts.max())), -1, dtype=np.int64)
|
|
114
|
+
vf[vs, np.arange(len(vs)) - start[vs]] = fs
|
|
115
|
+
area = np.asarray(mesh.area_faces, float)
|
|
116
|
+
cosc = np.cos(np.radians(crease_deg))
|
|
117
|
+
cn = np.empty((len(F), 3, 3), float)
|
|
118
|
+
# In blocks of faces, so memory stays bounded on large meshes (the full neighbour array for
|
|
119
|
+
# a few hundred thousand faces is several gigabytes).
|
|
120
|
+
step = max(1, int(4_000_000 // max(vf.shape[1] * 3, 1)))
|
|
121
|
+
for a in range(0, len(F), step):
|
|
122
|
+
b = min(a + step, len(F))
|
|
123
|
+
nb = vf[F[a:b]] # (B, 3, max valence)
|
|
124
|
+
valid = nb >= 0
|
|
125
|
+
nbi = np.where(valid, nb, 0)
|
|
126
|
+
nbn = n[nbi] # (B, 3, max valence, 3)
|
|
127
|
+
d = np.einsum("fkmc,fc->fkm", nbn, n[a:b])
|
|
128
|
+
w = area[nbi] * (valid & (d >= cosc))
|
|
129
|
+
acc = np.einsum("fkmc,fkm->fkc", nbn, w)
|
|
130
|
+
ln = np.linalg.norm(acc, axis=2, keepdims=True)
|
|
131
|
+
flat = ln < 1e-9
|
|
132
|
+
cn[a:b] = np.where(flat, np.repeat(n[a:b, None, :], 3, axis=1),
|
|
133
|
+
acc / np.where(flat, 1.0, ln))
|
|
134
|
+
except Exception as e: # noqa: BLE001
|
|
135
|
+
# Say so. A silent fallback to flat shading would look like a faceted model.
|
|
136
|
+
print(f"3d-base: smooth normals failed ({type(e).__name__}: {e}), this mesh is shaded flat")
|
|
137
|
+
cn = np.repeat(n[:, None, :], 3, axis=1)
|
|
138
|
+
try:
|
|
139
|
+
setattr(mesh, key, (ck, cn))
|
|
140
|
+
except Exception: # noqa: BLE001
|
|
141
|
+
pass
|
|
142
|
+
return cn
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def render(mesh, eye, target, size=(1400, 1000), fov=26.0, up=(0, 0, 1),
|
|
146
|
+
light=(0.4, -0.7, 0.75), base=(0.94, 0.94, 0.91), bg=(1.0, 1.0, 1.0),
|
|
147
|
+
face_colors=None, ao=0.35, headlight=None, naive_normals=False):
|
|
148
|
+
"""Render one mesh with a depth buffer and return an RGB image (H, W, 3) of uint8.
|
|
149
|
+
|
|
150
|
+
The light is a fixed key light, a soft light from the sky (+Z), and a headlight from the
|
|
151
|
+
camera. The key light gives the shapes their form. The headlight keeps faces turned away from
|
|
152
|
+
the key light readable, which matters for views from below and from behind. Measured without
|
|
153
|
+
it, the model pixels in a view from below averaged about a fifth of full brightness.
|
|
154
|
+
|
|
155
|
+
`face_colors` is an (F, 3) array of RGB values from 0 to 1, one colour per face. `headlight`
|
|
156
|
+
overrides HEADLIGHT for this call. `ao` is accepted for compatibility and not used.
|
|
157
|
+
"""
|
|
158
|
+
W, H = size
|
|
159
|
+
V = np.asarray(mesh.vertices, float)
|
|
160
|
+
F = np.asarray(mesh.faces)
|
|
161
|
+
M = look_at(eye, target, up)
|
|
162
|
+
cam = (M @ np.c_[V, np.ones(len(V))].T).T[:, :3]
|
|
163
|
+
|
|
164
|
+
f = 1.0 / np.tan(np.radians(fov) / 2.0)
|
|
165
|
+
z = -cam[:, 2]
|
|
166
|
+
z = np.where(z < 1e-6, 1e-6, z)
|
|
167
|
+
sx = (cam[:, 0] * f / z) * (H / 2) + W / 2
|
|
168
|
+
sy = (-cam[:, 1] * f / z) * (H / 2) + H / 2
|
|
169
|
+
P = np.column_stack([sx, sy, z])
|
|
170
|
+
|
|
171
|
+
n = np.asarray(mesh.face_normals, float)
|
|
172
|
+
cn = corner_normals(mesh, CREASE_DEG, naive=naive_normals)
|
|
173
|
+
L = np.asarray(light, float)
|
|
174
|
+
L /= np.linalg.norm(L)
|
|
175
|
+
lam = np.clip(cn @ L, 0, 1)
|
|
176
|
+
sky = np.clip(cn[:, :, 2] * 0.5 + 0.5, 0, 1)
|
|
177
|
+
# Read at call time, not as a default argument, so changing render.HEADLIGHT takes effect.
|
|
178
|
+
k = HEADLIGHT if headlight is None else float(headlight)
|
|
179
|
+
view = np.asarray(eye, float) - np.asarray(target, float)
|
|
180
|
+
vn = float(np.linalg.norm(view))
|
|
181
|
+
head = np.clip(cn @ (view / vn), 0, 1) if vn > 1e-9 else np.zeros((len(n), 3))
|
|
182
|
+
shade = np.clip(0.14 + 0.46 * lam + 0.22 * sky + k * head, 0, 1.25) # (F, 3), per corner
|
|
183
|
+
if face_colors is None:
|
|
184
|
+
col = np.tile(np.asarray(base, float), (len(F), 1))
|
|
185
|
+
else:
|
|
186
|
+
col = np.asarray(face_colors, float)
|
|
187
|
+
col = np.clip(col, 0, 1) # colour is flat per face, only the light varies
|
|
188
|
+
|
|
189
|
+
img = np.tile(np.asarray(bg, float), (H, W, 1))
|
|
190
|
+
depth = np.full((H, W), np.inf)
|
|
191
|
+
|
|
192
|
+
tri = P[F]
|
|
193
|
+
a, b, c = tri[:, 0], tri[:, 1], tri[:, 2]
|
|
194
|
+
area2 = (b[:, 0] - a[:, 0]) * (c[:, 1] - a[:, 1]) - (c[:, 0] - a[:, 0]) * (b[:, 1] - a[:, 1])
|
|
195
|
+
keep = np.abs(area2) > 1e-9 # skip faces seen exactly edge-on
|
|
196
|
+
tri, col, area2, shade = tri[keep], col[keep], area2[keep], shade[keep]
|
|
197
|
+
|
|
198
|
+
order = np.argsort(-tri[:, :, 2].min(axis=1)) # far to near
|
|
199
|
+
for i in order:
|
|
200
|
+
t, cc, sh = tri[i], col[i], shade[i]
|
|
201
|
+
x0 = max(int(np.floor(t[:, 0].min())), 0)
|
|
202
|
+
x1 = min(int(np.ceil(t[:, 0].max())) + 1, W)
|
|
203
|
+
y0 = max(int(np.floor(t[:, 1].min())), 0)
|
|
204
|
+
y1 = min(int(np.ceil(t[:, 1].max())) + 1, H)
|
|
205
|
+
if x1 <= x0 or y1 <= y0:
|
|
206
|
+
continue
|
|
207
|
+
xs = np.arange(x0, x1) + 0.5
|
|
208
|
+
ys = np.arange(y0, y1) + 0.5
|
|
209
|
+
gx, gy = np.meshgrid(xs, ys)
|
|
210
|
+
d = area2[i]
|
|
211
|
+
w0 = ((t[1, 0] - t[0, 0]) * (gy - t[0, 1]) - (gx - t[0, 0]) * (t[1, 1] - t[0, 1])) / d
|
|
212
|
+
w1 = ((gx - t[0, 0]) * (t[2, 1] - t[0, 1]) - (t[2, 0] - t[0, 0]) * (gy - t[0, 1])) / d
|
|
213
|
+
inside = (w0 >= -1e-9) & (w1 >= -1e-9) & (w0 + w1 <= 1 + 1e-9)
|
|
214
|
+
if not inside.any():
|
|
215
|
+
continue
|
|
216
|
+
zz = t[0, 2] + w1 * (t[1, 2] - t[0, 2]) + w0 * (t[2, 2] - t[0, 2])
|
|
217
|
+
sub = depth[y0:y1, x0:x1]
|
|
218
|
+
hit = inside & (zz < sub)
|
|
219
|
+
if not hit.any():
|
|
220
|
+
continue
|
|
221
|
+
sub[hit] = zz[hit]
|
|
222
|
+
# the same barycentric weights as the depth, so the light varies across the triangle
|
|
223
|
+
ss = sh[0] + w1 * (sh[1] - sh[0]) + w0 * (sh[2] - sh[0])
|
|
224
|
+
img[y0:y1, x0:x1][hit] = np.clip(cc[None, :] * ss[hit][:, None], 0, 1)
|
|
225
|
+
return (np.clip(img, 0, 1) * 255).astype(np.uint8)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def frame_on(mesh, azim, elev, pad=1.22):
|
|
229
|
+
"""(eye, target) that puts the whole mesh in frame from a direction in degrees.
|
|
230
|
+
|
|
231
|
+
`azim` turns around +Z starting from +X, and `elev` is the angle above the XY plane.
|
|
232
|
+
"""
|
|
233
|
+
c = mesh.bounds.mean(axis=0)
|
|
234
|
+
r = float(np.linalg.norm(mesh.bounds[1] - mesh.bounds[0])) / 2.0
|
|
235
|
+
a, e = np.radians(azim), np.radians(elev)
|
|
236
|
+
d = np.array([np.cos(e) * np.cos(a), np.cos(e) * np.sin(a), np.sin(e)])
|
|
237
|
+
return c + d * r * 3.4 * pad, c
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def tile(images, cols=None, gap=16, bg=255, titles=None, title_h=0):
|
|
241
|
+
"""Lay out images of equal size in a grid. `bg` is one grey value or an RGB tuple, and `title_h` leaves that many pixels above each one."""
|
|
242
|
+
cols = cols or len(images)
|
|
243
|
+
rows = int(np.ceil(len(images) / cols))
|
|
244
|
+
h, w = images[0].shape[:2]
|
|
245
|
+
out = np.full((rows * (h + title_h) + (rows + 1) * gap,
|
|
246
|
+
cols * w + (cols + 1) * gap, 3), bg, np.uint8)
|
|
247
|
+
for i, im in enumerate(images):
|
|
248
|
+
r, c = divmod(i, cols)
|
|
249
|
+
y = gap + r * (h + title_h + gap) + title_h
|
|
250
|
+
x = gap + c * (w + gap)
|
|
251
|
+
out[y:y + h, x:x + w] = im
|
|
252
|
+
return out
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
VIEWS = {
|
|
256
|
+
"front": (-90, 10), "back": (90, 10), "left": (180, 10), "right": (0, 10),
|
|
257
|
+
"top": (-90, 89), "below": (-90, -60), "front-right": (-45, 25), "back-left": (135, 25),
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
SHEET_BG = (0.83, 0.85, 0.88)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def sheet(mesh, views=("front", "right", "back", "left", "top", "below"), size=(600, 450), cols=3, **kw):
|
|
265
|
+
"""Render the mesh from several named views (see VIEWS) and lay them out in a grid.
|
|
266
|
+
|
|
267
|
+
The background is a light grey (SHEET_BG) unless `bg` is given, because the brightest faces of a
|
|
268
|
+
light model are drawn close to white and would disappear against a white background.
|
|
269
|
+
"""
|
|
270
|
+
kw.setdefault("bg", SHEET_BG)
|
|
271
|
+
imgs = []
|
|
272
|
+
for v in views:
|
|
273
|
+
azim, elev = VIEWS[v] if isinstance(v, str) else v
|
|
274
|
+
eye, target = frame_on(mesh, azim, elev)
|
|
275
|
+
imgs.append(render(mesh, eye, target, size=size, **kw))
|
|
276
|
+
return tile(imgs, cols=cols, bg=tuple(int(255 * c) for c in kw["bg"]))
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def save_png(img, path):
|
|
280
|
+
"""Write an (H, W, 3) uint8 image as PNG with the standard library only."""
|
|
281
|
+
img = np.ascontiguousarray(img, dtype=np.uint8)
|
|
282
|
+
h, w = img.shape[:2]
|
|
283
|
+
raw = b"".join(b"\x00" + img[y].tobytes() for y in range(h))
|
|
284
|
+
|
|
285
|
+
def chunk(kind, data):
|
|
286
|
+
return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data) & 0xFFFFFFFF)
|
|
287
|
+
png = (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
|
|
288
|
+
+ chunk(b"IDAT", zlib.compress(raw, 6)) + chunk(b"IEND", b""))
|
|
289
|
+
with open(path, "wb") as f:
|
|
290
|
+
f.write(png)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# The name used before this was a package.
|
|
294
|
+
_corner_normals = corner_normals
|