pycodecad 1.0.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.
pycodecad/renderer.py ADDED
@@ -0,0 +1,327 @@
1
+ """The 3D view: draws meshes, edges, a grid and axes into an off-screen texture with ModernGL."""
2
+ from __future__ import annotations
3
+
4
+ import math
5
+ import struct
6
+ import zlib
7
+ from typing import cast
8
+
9
+ import moderngl
10
+ import numpy as np
11
+ from pytypehint import immutable
12
+
13
+ from . import camera as cam
14
+ from .cad import Color, Matrix, Mesh, Shown
15
+ from .camera import Camera
16
+
17
+
18
+ @immutable
19
+ class Display:
20
+ edges: bool = True
21
+ grid: bool = True
22
+ axes: bool = True
23
+
24
+
25
+ _MESH_VERTEX = '''#version 330
26
+ uniform mat4 view; uniform mat4 projection;
27
+ in vec3 position; in vec3 normal;
28
+ out vec3 n; out vec3 eye;
29
+ void main() { vec4 p = view * vec4(position, 1); eye = -p.xyz;
30
+ n = mat3(view) * normal; gl_Position = projection * p; }
31
+ '''
32
+ _MESH_FRAGMENT = '''#version 330
33
+ uniform vec3 color;
34
+ in vec3 n; in vec3 eye; out vec4 frag;
35
+ void main() {
36
+ vec3 N = normalize(n); if (!gl_FrontFacing) N = -N;
37
+ vec3 V = normalize(eye); vec3 L = normalize(vec3(-0.35, 0.65, 1));
38
+ float diffuse = max(dot(N,L),0.0);
39
+ float hemi = 0.5 + 0.5 * N.y;
40
+ float rim = pow(1.0 - max(dot(N,V),0.0), 3.0);
41
+ float spec = pow(max(dot(N,normalize(L+V)),0.0),48.0);
42
+ frag = vec4(color * (0.49 + 0.42*diffuse + 0.12*hemi) + vec3(0.075)*rim + vec3(0.14)*spec,1);
43
+ }
44
+ '''
45
+ _LINE_VERTEX = '''#version 330
46
+ uniform mat4 mvp; uniform float bias;
47
+ in vec3 position; out vec3 world;
48
+ void main() { world = position; gl_Position = mvp * vec4(position,1);
49
+ gl_Position.z -= bias * gl_Position.w; }
50
+ '''
51
+ _LINE_FRAGMENT = '''#version 330
52
+ uniform vec4 color; uniform float fade; uniform vec2 center;
53
+ in vec3 world; out vec4 frag;
54
+ void main() { float a = fade > 0.0 ? exp(-pow(length(world.xy-center)/fade,2.0)) : 1.0;
55
+ frag = vec4(color.rgb,color.a*a); }
56
+ '''
57
+ _BG_VERTEX = '''#version 330
58
+ out vec2 uv;
59
+ void main() { vec2 p = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);
60
+ uv = p; gl_Position = vec4(p*2.0-1.0, 0, 1); }
61
+ '''
62
+ _BG_FRAGMENT = '''#version 330
63
+ in vec2 uv; out vec4 frag;
64
+ void main() { vec3 c = mix(vec3(0.075,0.091,0.12), vec3(0.16,0.19,0.24),uv.y);
65
+ c += 0.012 * (1.0-length(uv-vec2(0.5)));
66
+ frag = vec4(c,1); }
67
+ '''
68
+
69
+
70
+ _Frame = tuple[moderngl.Framebuffer, moderngl.Framebuffer, moderngl.Texture]
71
+
72
+
73
+ def _set_uniforms(program: moderngl.Program, **values: object) -> None:
74
+ """Set a program's uniforms in order: bytes are written raw (matrices), anything else is the value."""
75
+ for name, value in values.items():
76
+ uniform = cast(moderngl.Uniform, program[name]) # our shaders' names are all plain uniforms
77
+ if isinstance(value, bytes):
78
+ uniform.write(value)
79
+ else:
80
+ uniform.value = value
81
+
82
+
83
+ Uploaded = tuple[moderngl.VertexArray | None, moderngl.VertexArray | None, list[moderngl.Buffer]]
84
+
85
+
86
+ class Renderer:
87
+ """Draws the scene into its own texture (4x multisampled when the GPU allows)."""
88
+
89
+ def __init__(self, ctx: moderngl.Context) -> None:
90
+ self.ctx = ctx
91
+ self._mesh_program = ctx.program(vertex_shader=_MESH_VERTEX, fragment_shader=_MESH_FRAGMENT)
92
+ self._line_program = ctx.program(vertex_shader=_LINE_VERTEX, fragment_shader=_LINE_FRAGMENT)
93
+ self._bg_program = ctx.program(vertex_shader=_BG_VERTEX, fragment_shader=_BG_FRAGMENT)
94
+ self._bg = ctx.vertex_array(self._bg_program, [])
95
+ self._objects: list[tuple[Color, np.ndarray | None, Uploaded]] = [] # color, placement, mesh
96
+ self._uploaded: dict[int, tuple[Mesh, Uploaded]] = {} # by id of the mesh: kept while drawn
97
+ self._targets: list = [] # texture and framebuffers of the current size
98
+ self._size = (0, 0)
99
+ # (multisampled framebuffer, resolve framebuffer, its texture) of the current size
100
+ self._frame: _Frame | None = None
101
+ self._grid_key: tuple | None = None
102
+ self._grid: tuple[moderngl.VertexArray, moderngl.Buffer] | None = None # the grid lines
103
+ axis = np.array([[0, 0, 0], [1, 0, 0], [0, 0, 0], [0, 1, 0], [0, 0, 0], [0, 0, 1]], dtype="f4")
104
+ self._axis_buffer = ctx.buffer(axis.tobytes())
105
+ self._axis_vao = ctx.vertex_array(self._line_program, [(self._axis_buffer, "3f", "position")])
106
+
107
+ def set_meshes(self, meshes: list[tuple[Color, Mesh, Matrix | None]]) -> None:
108
+ """Replace what is drawn: (color, mesh, placement) items. A mesh drawn before
109
+ (the same object, e.g. in every frame of an animation) is not uploaded again."""
110
+ kept: dict[int, tuple[Mesh, Uploaded]] = {}
111
+ self._objects = []
112
+ for color, mesh, matrix in meshes:
113
+ key = id(mesh)
114
+ if key not in kept:
115
+ kept[key] = self._uploaded.pop(key, None) or (mesh, self._upload(mesh))
116
+ placement = None if matrix is None else np.array(matrix, dtype="f4").reshape(4, 4)
117
+ self._objects.append((color, placement, kept[key][1]))
118
+ for _, (triangles, edges, buffers) in self._uploaded.values():
119
+ for resource in (triangles, edges, *buffers):
120
+ if resource is not None:
121
+ resource.release()
122
+ self._uploaded = kept
123
+
124
+ def _upload(self, mesh: Mesh) -> Uploaded:
125
+ triangles = edges = None
126
+ buffers = []
127
+ if len(mesh.positions):
128
+ buffers.append(self.ctx.buffer(np.column_stack((mesh.positions, mesh.normals)).tobytes()))
129
+ triangles = self.ctx.vertex_array(self._mesh_program, [(buffers[-1], "3f 3f", "position", "normal")])
130
+ if len(mesh.edges):
131
+ buffers.append(self.ctx.buffer(mesh.edges.tobytes()))
132
+ edges = self.ctx.vertex_array(self._line_program, [(buffers[-1], "3f", "position")])
133
+ return triangles, edges, buffers
134
+
135
+ def _resize(self, width: int, height: int) -> _Frame:
136
+ if self._frame is None or self._size != (width, height):
137
+ for target in reversed(self._targets):
138
+ target.release()
139
+ ctx = self.ctx
140
+ samples = 4 if ctx.max_samples >= 4 else 0
141
+ texture = ctx.texture((width, height), 4)
142
+ texture.filter = (moderngl.LINEAR, moderngl.LINEAR)
143
+ resolve = ctx.framebuffer(color_attachments=[texture])
144
+ color = ctx.renderbuffer((width, height), 4, samples=samples)
145
+ depth = ctx.depth_renderbuffer((width, height), samples=samples)
146
+ fbo = ctx.framebuffer([color], depth)
147
+ self._frame = fbo, resolve, texture
148
+ self._targets = [texture, resolve, color, depth, fbo]
149
+ self._size = width, height
150
+ return self._frame
151
+
152
+ def _draw_grid(self, fbo: moderngl.Framebuffer, mvp: np.ndarray, camera: Camera) -> None:
153
+ desired = max(camera.distance / 18, 1e-9)
154
+ magnitude = 10 ** math.floor(math.log10(desired))
155
+ spacing = next(step * magnitude for step in (1, 2, 5, 10) if step * magnitude >= desired)
156
+ center = np.round(np.asarray(camera.target[:2]) / spacing) * spacing
157
+ key = (spacing, *center)
158
+ extent = spacing * 60
159
+ if self._grid is None or key != self._grid_key: # rebuild the lines only when spacing or center change
160
+ for resource in self._grid or ():
161
+ resource.release()
162
+ points = []
163
+ for i in range(-60, 61):
164
+ p = i * spacing
165
+ points.extend([(center[0]+p, center[1]-extent, 0), (center[0]+p, center[1]+extent, 0),
166
+ (center[0]-extent, center[1]+p, 0), (center[0]+extent, center[1]+p, 0)])
167
+ buffer = self.ctx.buffer(np.asarray(points, dtype='f4').tobytes())
168
+ self._grid = self.ctx.vertex_array(self._line_program, [(buffer, '3f', 'position')]), buffer
169
+ self._grid_key = key
170
+ _set_uniforms(self._line_program, mvp=mvp.tobytes(), bias=0.0, fade=camera.distance * 0.65,
171
+ center=tuple(camera.target[:2]), color=(0.40, 0.47, 0.56, 0.22))
172
+ fbo.depth_mask = False
173
+ self._grid[0].render(moderngl.LINES)
174
+ fbo.depth_mask = True
175
+
176
+ def draw(self, camera: Camera, display: Display, width: int, height: int) -> moderngl.Texture:
177
+ width, height = max(1, int(width)), max(1, int(height))
178
+ fbo, resolve, texture = self._resize(width, height)
179
+ ctx = self.ctx
180
+ fbo.use()
181
+ ctx.viewport = (0, 0, width, height)
182
+ ctx.scissor = None
183
+ ctx.wireframe = False
184
+ fbo.depth_mask = True
185
+ ctx.enable_only(moderngl.NOTHING)
186
+ fbo.clear(0, 0, 0, 1, depth=1)
187
+ self._bg.render(moderngl.TRIANGLES, vertices=3)
188
+ # Column-major tuples read row-major give the transposes this renderer works with.
189
+ view, projection = (np.array(m, dtype='f4').reshape(4, 4)
190
+ for m in cam.matrices(camera, width / height))
191
+ mvp = np.ascontiguousarray(view @ projection)
192
+ _set_uniforms(self._mesh_program, view=view.tobytes(), projection=projection.tobytes())
193
+ ctx.enable(moderngl.DEPTH_TEST)
194
+ ctx.depth_func = '<='
195
+ ctx.enable(moderngl.BLEND)
196
+ ctx.blend_func = (moderngl.SRC_ALPHA, moderngl.ONE_MINUS_SRC_ALPHA)
197
+ ctx.line_width = 1.0
198
+ if display.grid:
199
+ self._draw_grid(fbo, mvp, camera)
200
+ for color, placement, (triangles, _, _) in self._objects:
201
+ if triangles is not None:
202
+ placed = view if placement is None else np.ascontiguousarray(placement.T @ view)
203
+ _set_uniforms(self._mesh_program, view=placed.tobytes(), color=color)
204
+ triangles.render(moderngl.TRIANGLES)
205
+ p = self._line_program
206
+ _set_uniforms(p, mvp=mvp.tobytes(), fade=0.0, bias=0.000015, color=(0.065, 0.055, 0.045, 0.85))
207
+ if display.edges:
208
+ for _, placement, (_, edges, _) in self._objects:
209
+ if edges is not None:
210
+ placed = mvp if placement is None else np.ascontiguousarray(placement.T @ mvp)
211
+ _set_uniforms(p, mvp=placed.tobytes())
212
+ edges.render(moderngl.LINES)
213
+ if display.axes:
214
+ ctx.disable(moderngl.DEPTH_TEST)
215
+ size = min(100, width, height)
216
+ ctx.viewport = (8, 8, size, size)
217
+ matrix = np.eye(4, dtype='f4')
218
+ matrix[:3, :3] = view.T[:3, :3] * 0.66
219
+ matrix[:2, 3] = -0.12
220
+ _set_uniforms(p, mvp=np.ascontiguousarray(matrix.T).tobytes(), bias=0.0)
221
+ ctx.line_width = min(2.0, ctx.info.get('GL_ALIASED_LINE_WIDTH_RANGE', (1, 1))[1])
222
+ for i, color in enumerate(((0.95,0.32,0.29,1), (0.4,0.85,0.48,1), (0.35,0.63,1,1))):
223
+ _set_uniforms(p, color=color)
224
+ self._axis_vao.render(moderngl.LINES, vertices=2, first=i*2)
225
+ ctx.viewport = (0, 0, width, height)
226
+ ctx.line_width = 1.0
227
+ ctx.copy_framebuffer(resolve, fbo)
228
+ return texture
229
+
230
+ def read_image(self) -> np.ndarray:
231
+ """The last drawn frame as a top-down (height, width, 4) uint8 array."""
232
+ assert self._frame is not None, "draw() first"
233
+ width, height = self._size
234
+ pixels = np.frombuffer(self._frame[2].read(alignment=1), dtype=np.uint8).reshape(height, width, 4)
235
+ return np.flipud(pixels)
236
+
237
+ def release(self) -> None:
238
+ """Free the GL resources (while the context is still current)."""
239
+ self.set_meshes([])
240
+ for resource in (*(self._grid or ()), self._axis_vao, self._axis_buffer, self._bg,
241
+ self._mesh_program, self._line_program, self._bg_program, *reversed(self._targets)):
242
+ resource.release()
243
+
244
+
245
+ # --- off-screen pictures (the CLI `render` command) -------------------------
246
+
247
+ MAX_SIZE = 8192 # pixels per side of each view
248
+
249
+
250
+ def check_size(size: tuple[int, int]) -> None:
251
+ if len(size) != 2 or not all(isinstance(side, int) and 1 <= side <= MAX_SIZE for side in size):
252
+ raise ValueError(f"Image size must be two whole numbers of pixels in 1..{MAX_SIZE}, not {size!r}")
253
+
254
+
255
+ def render_views(shown: list[Shown], views: list[str | Camera], size: tuple[int, int]) -> np.ndarray:
256
+ """One image per view (a name like "iso", or a Camera), side by side in a labeled grid when
257
+ there are several. Needs no window: uses an off-screen GL context."""
258
+ check_size(size)
259
+ width, height = size
260
+ ctx = moderngl.create_standalone_context()
261
+ renderer = Renderer(ctx)
262
+ try:
263
+ renderer.set_meshes([(obj.color, obj.mesh, obj.matrix) for obj in shown])
264
+ bbox = cam.scene_bbox([obj.bbox() for obj in shown])
265
+ images = []
266
+ for view in views:
267
+ if isinstance(view, str):
268
+ camera = cam.set_view(Camera(), view)
269
+ camera = cam.fit(camera, bbox) if bbox else camera
270
+ else:
271
+ camera = view
272
+ renderer.draw(camera, Display(), width, height)
273
+ image = renderer.read_image().copy()
274
+ if len(views) > 1:
275
+ draw_label(image, view if isinstance(view, str) else "window")
276
+ images.append(image)
277
+ finally:
278
+ renderer.release()
279
+ ctx.release()
280
+ columns = 1 if len(images) == 1 else 2
281
+ rows = -(-len(images) // columns)
282
+ grid = np.zeros((rows * height, columns * width, 4), dtype=np.uint8)
283
+ grid[..., 3] = 255
284
+ for index, image in enumerate(images):
285
+ row, column = divmod(index, columns)
286
+ grid[row * height:(row + 1) * height, column * width:(column + 1) * width] = image
287
+ return grid
288
+
289
+
290
+ def png_bytes(image: np.ndarray) -> bytes:
291
+ """PNG file of a top-down (height, width, 3 or 4) uint8 image."""
292
+ height, width, channels = image.shape
293
+ rows = b"".join(b"\0" + image[y].tobytes() for y in range(height))
294
+
295
+ def chunk(kind: bytes, payload: bytes) -> bytes:
296
+ return struct.pack("!I", len(payload)) + kind + payload + struct.pack("!I", zlib.crc32(kind + payload))
297
+
298
+ header = struct.pack("!2I5B", width, height, 8, 6 if channels == 4 else 2, 0, 0, 0)
299
+ return b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", header) + chunk(b"IDAT", zlib.compress(rows)) + chunk(b"IEND", b"")
300
+
301
+
302
+ # A 5x7 pixel font for the view names in render grids.
303
+ GLYPHS = dict(
304
+ A="01110100011000111111100011000110001", B="11110100011000111110100011000111110",
305
+ C="01110100011000010000100001000101110", D="11110100011000110001100011000111110",
306
+ E="11111100001000011110100001000011111", F="11111100001000011110100001000010000",
307
+ G="01110100011000010111100011000101111", H="10001100011000111111100011000110001",
308
+ I="01110001000010000100001000010001110", K="10001100101010011000101001001010001",
309
+ L="10000100001000010000100001000011111", M="10001110111010110101100011000110001",
310
+ N="10001110011010110011100011000110001", O="01110100011000110001100011000101110",
311
+ P="11110100011000111110100001000010000", R="11110100011000111110101001001010001",
312
+ S="01111100001000001110000010000111110", T="11111001000010000100001000010000100",
313
+ W="10001100011000110101101011010101010",
314
+ )
315
+
316
+
317
+ def draw_label(image: np.ndarray, text: str, scale: int = 3) -> None:
318
+ """Write uppercase text in the top-left corner of the image, in place."""
319
+ x = y = 12
320
+ for char in text.upper():
321
+ glyph = GLYPHS.get(char)
322
+ if glyph:
323
+ pixels = np.array([int(bit) for bit in glyph], dtype=bool).reshape(7, 5)
324
+ block = np.kron(pixels, np.ones((scale, scale), dtype=bool))
325
+ region = image[y:y + block.shape[0], x:x + block.shape[1]]
326
+ region[block[:region.shape[0], :region.shape[1]]] = (235, 238, 245, 255)
327
+ x += 6 * scale