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/__init__.py +18 -0
- pycodecad/__main__.py +3 -0
- pycodecad/api.py +105 -0
- pycodecad/app.py +34 -0
- pycodecad/cad.py +261 -0
- pycodecad/camera.py +136 -0
- pycodecad/cli.py +241 -0
- pycodecad/context.py +178 -0
- pycodecad/editor.py +256 -0
- pycodecad/embed.py +16 -0
- pycodecad/examples/assembly.py +7 -0
- pycodecad/examples/assets/logo.svg +1 -0
- pycodecad/examples/assets/pyramid.stl +44 -0
- pycodecad/examples/embedded_app.py +77 -0
- pycodecad/examples/gear.py +6 -0
- pycodecad/examples/gearbox.py +30 -0
- pycodecad/examples/gears_turning.py +11 -0
- pycodecad/examples/import_files.py +13 -0
- pycodecad/examples/parts.py +46 -0
- pycodecad/examples/tray.py +28 -0
- pycodecad/files.py +262 -0
- pycodecad/icons/LICENSE +43 -0
- pycodecad/icons/__init__.py +31 -0
- pycodecad/icons/lucide.ttf +0 -0
- pycodecad/imgui_backend.py +269 -0
- pycodecad/params.py +177 -0
- pycodecad/renderer.py +327 -0
- pycodecad/runner.py +404 -0
- pycodecad/sidecar.py +86 -0
- pycodecad/textedit.py +290 -0
- pycodecad/ui.py +677 -0
- pycodecad/viewcube.py +120 -0
- pycodecad/viewer.py +70 -0
- pycodecad/window.py +129 -0
- pycodecad/workspace.py +545 -0
- pycodecad-1.0.0.dist-info/METADATA +117 -0
- pycodecad-1.0.0.dist-info/RECORD +40 -0
- pycodecad-1.0.0.dist-info/WHEEL +4 -0
- pycodecad-1.0.0.dist-info/entry_points.txt +2 -0
- pycodecad-1.0.0.dist-info/licenses/LICENSE +21 -0
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
|