eagle-engine 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.
eagle/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Eagle Engine — 3D-расширение для PyGame с GPU-рендерингом."""
2
+ __version__ = "0.1.0"
3
+
4
+ from eagle.core import Engine # noqa: F401
5
+
6
+ __all__ = ["Engine", "__version__"]
eagle/assets/logo.png ADDED
Binary file
@@ -0,0 +1,32 @@
1
+ #version 330 core
2
+
3
+ in vec3 v_world_pos;
4
+ in vec3 v_normal;
5
+ in vec2 v_uv;
6
+
7
+ uniform vec3 u_camera_pos;
8
+ uniform vec3 u_light_dir;
9
+ uniform vec3 u_light_color;
10
+ uniform vec3 u_ambient;
11
+ uniform vec3 u_color;
12
+ uniform float u_shininess;
13
+ uniform float u_specular;
14
+ uniform int u_use_texture;
15
+ uniform sampler2D u_texture;
16
+
17
+ out vec4 frag_color;
18
+
19
+ void main() {
20
+ vec3 albedo = u_color;
21
+ if (u_use_texture == 1) {
22
+ albedo *= texture(u_texture, v_uv).rgb;
23
+ }
24
+ vec3 N = normalize(v_normal);
25
+ vec3 L = normalize(-u_light_dir);
26
+ vec3 V = normalize(u_camera_pos - v_world_pos);
27
+ vec3 H = normalize(L + V);
28
+ float diff = max(dot(N, L), 0.0);
29
+ float spec = pow(max(dot(N, H), 0.0), u_shininess) * u_specular;
30
+ vec3 color = albedo * (u_ambient + u_light_color * diff) + u_light_color * spec;
31
+ frag_color = vec4(color, 1.0);
32
+ }
@@ -0,0 +1,22 @@
1
+ #version 330 core
2
+
3
+ in vec3 in_position;
4
+ in vec3 in_normal;
5
+ in vec2 in_uv;
6
+
7
+ uniform mat4 u_model;
8
+ uniform mat4 u_view;
9
+ uniform mat4 u_proj;
10
+ uniform mat3 u_normal_mat;
11
+
12
+ out vec3 v_world_pos;
13
+ out vec3 v_normal;
14
+ out vec2 v_uv;
15
+
16
+ void main() {
17
+ vec4 world = u_model * vec4(in_position, 1.0);
18
+ v_world_pos = world.xyz;
19
+ v_normal = normalize(u_normal_mat * in_normal);
20
+ v_uv = in_uv;
21
+ gl_Position = u_proj * u_view * world;
22
+ }
@@ -0,0 +1,7 @@
1
+ #version 330 core
2
+ in vec2 v_uv;
3
+ uniform sampler2D u_texture;
4
+ out vec4 frag_color;
5
+ void main() {
6
+ frag_color = texture(u_texture, v_uv);
7
+ }
@@ -0,0 +1,8 @@
1
+ #version 330 core
2
+ in vec2 in_position;
3
+ in vec2 in_uv;
4
+ out vec2 v_uv;
5
+ void main() {
6
+ v_uv = in_uv;
7
+ gl_Position = vec4(in_position, 0.0, 1.0);
8
+ }
eagle/core/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from eagle.core.engine import Engine
2
+
3
+ __all__ = ["Engine"]
eagle/core/engine.py ADDED
@@ -0,0 +1,255 @@
1
+ """Engine — окно + GL-контекст + цикл + 2D-оверлей + splash."""
2
+ from __future__ import annotations
3
+ from typing import Callable
4
+
5
+ import numpy as np
6
+ import pygame
7
+ import moderngl
8
+
9
+ from eagle.gfx.shader import shader_from_package, Shader
10
+ from eagle.gfx.texture import Texture
11
+ from eagle.gfx.camera import Camera
12
+ from eagle.gfx.mesh import Mesh
13
+ from eagle.math.mat import Mat4
14
+ from eagle.core.splash import load_logo, make_icon, Splash
15
+
16
+
17
+ class CallbackList:
18
+ """Список подписчиков. Как декоратор — регистрирует, как dispatch — исполняет."""
19
+ __slots__ = ("_subs",)
20
+
21
+ def __init__(self):
22
+ self._subs: list[Callable] = []
23
+
24
+ def __call__(self, fn: Callable) -> Callable:
25
+ self._subs.append(fn)
26
+ return fn
27
+
28
+ def dispatch(self, *args) -> None:
29
+ for fn in self._subs:
30
+ fn(*args)
31
+
32
+ def __len__(self):
33
+ return len(self._subs)
34
+
35
+
36
+ class Engine:
37
+ def __init__(
38
+ self,
39
+ width: int = 800,
40
+ height: int = 600,
41
+ title: str = "Eagle Engine",
42
+ *,
43
+ vsync: bool = True,
44
+ target_fps: int | None = 60,
45
+ logo_path: str | None = None,
46
+ logo: pygame.Surface | None = None,
47
+ splash: bool = True,
48
+ ):
49
+ pygame.init()
50
+ pygame.display.gl_set_attribute(pygame.GL_CONTEXT_MAJOR_VERSION, 3)
51
+ pygame.display.gl_set_attribute(pygame.GL_CONTEXT_MINOR_VERSION, 3)
52
+ pygame.display.gl_set_attribute(pygame.GL_CONTEXT_PROFILE_MASK, pygame.GL_CONTEXT_PROFILE_CORE)
53
+ pygame.display.gl_set_attribute(pygame.GL_DEPTH_SIZE, 24)
54
+ pygame.display.gl_set_attribute(pygame.GL_DOUBLEBUFFER, 1)
55
+ pygame.display.gl_set_attribute(pygame.GL_MULTISAMPLESAMPLES, 4)
56
+
57
+ self.width = width
58
+ self.height = height
59
+ self.screen = pygame.display.set_mode(
60
+ (width, height),
61
+ pygame.OPENGL | pygame.DOUBLEBUF | pygame.RESIZABLE,
62
+ vsync=1 if vsync else 0,
63
+ )
64
+ pygame.display.set_caption(title)
65
+
66
+ # --- логотип и иконка ---
67
+ if logo is not None:
68
+ _logo_surf = logo.convert_alpha()
69
+ elif logo_path is not None:
70
+ _logo_surf = load_logo(logo_path)
71
+ else:
72
+ _logo_surf = load_logo() # встроенный eagle/assets/logo.png
73
+
74
+ pygame.display.set_icon(make_icon(_logo_surf, size=64))
75
+
76
+ self._splash = Splash(_logo_surf, width, height) if splash else None
77
+
78
+ # --- OpenGL / ModernGL ---
79
+ self.ctx = moderngl.create_context(require=330)
80
+ self.ctx.enable(moderngl.DEPTH_TEST | moderngl.CULL_FACE | moderngl.BLEND)
81
+ self.ctx.blend_func = moderngl.SRC_ALPHA, moderngl.ONE_MINUS_SRC_ALPHA
82
+ self.ctx.gc_mode = "auto"
83
+
84
+ self.surface = pygame.Surface((width, height), pygame.SRCALPHA)
85
+
86
+ self._overlay_tex = Texture(self.surface, flip=False)
87
+ self._overlay_tex.build(self.ctx)
88
+ self._overlay_shader = shader_from_package("unlit.vert", "unlit.frag")
89
+ self._overlay_shader.build(self.ctx)
90
+ # CCW-порядок для TRIANGLE_STRIP, v=0 сверху, v=1 снизу.
91
+ quad = np.array([
92
+ -1, -1, 0, 1, # лево-низ
93
+ 1, -1, 1, 1, # право-низ
94
+ -1, 1, 0, 0, # лево-верх
95
+ 1, 1, 1, 0, # право-верх
96
+ ], dtype="f4")
97
+ self._overlay_vao = self.ctx.vertex_array(
98
+ self._overlay_shader.program,
99
+ [(self.ctx.buffer(quad.tobytes()), "2f 2f", "in_position", "in_uv")],
100
+ )
101
+
102
+ self._basic_shader: Shader = shader_from_package("basic.vert", "basic.frag")
103
+ self._basic_shader.build(self.ctx)
104
+
105
+ self.running = True
106
+ self.clock = pygame.time.Clock()
107
+ self.target_fps = target_fps
108
+ self._elapsed = 0.0
109
+ self._last_dt = 0.0
110
+
111
+ # --- колбэки (декораторы) ---
112
+ self.on_update = CallbackList()
113
+ self.on_render_3d = CallbackList()
114
+ self.on_render_2d = CallbackList()
115
+ self.on_event = CallbackList()
116
+
117
+ # --- освещение ---
118
+ self.light_dir = (0.3, -0.7, -0.6)
119
+ self.light_color = (1.0, 1.0, 1.0)
120
+ self.ambient = (0.25, 0.25, 0.28)
121
+
122
+ # ============================================================
123
+ # ЦИКЛ
124
+ # ============================================================
125
+ def tick(self) -> float:
126
+ dt = self.clock.tick(self.target_fps if self.target_fps else 0) / 1000.0
127
+ self._last_dt = dt
128
+ self._elapsed += dt
129
+
130
+ skip_splash = False
131
+ for ev in pygame.event.get():
132
+ if ev.type == pygame.QUIT:
133
+ self.running = False
134
+ elif ev.type == pygame.VIDEORESIZE:
135
+ self.width, self.height = ev.w, ev.h
136
+ self.surface = pygame.Surface((self.width, self.height), pygame.SRCALPHA)
137
+ self.ctx.viewport = (0, 0, self.width, self.height)
138
+ self._overlay_tex.release()
139
+ self._overlay_tex = Texture(self.surface, flip=False)
140
+ self._overlay_tex.build(self.ctx)
141
+ elif ev.type in (pygame.KEYDOWN, pygame.MOUSEBUTTONDOWN):
142
+ skip_splash = True
143
+ self.on_event.dispatch(ev)
144
+
145
+ if self._splash is not None and not self._splash.done:
146
+ self._splash.update(dt)
147
+ if skip_splash:
148
+ self._splash._t = self._splash.duration + self._splash.fade
149
+ self._splash._done = True
150
+ return dt
151
+
152
+ self.on_update.dispatch(dt)
153
+ return dt
154
+
155
+ def clear(self, color=(0.10, 0.10, 0.14, 1.0)):
156
+ self.ctx.clear(*color, depth=1.0)
157
+ self.ctx.viewport = (0, 0, self.width, self.height)
158
+
159
+ # ============================================================
160
+ # РИСОВАНИЕ
161
+ # ============================================================
162
+ def draw(self, obj, camera: Camera, *, model: Mat4 | None = None,
163
+ color=(0.85, 0.85, 0.9), shininess=32.0, specular=0.4, texture=None):
164
+ """obj — Mesh (голая геометрия) или Model (несколько частей с материалами)."""
165
+ from eagle.gfx.model import Model
166
+
167
+ if isinstance(obj, Model):
168
+ for part in obj.parts:
169
+ self._draw_mesh(part.mesh, camera, model=model,
170
+ color=part.color, shininess=part.shininess,
171
+ specular=part.specular, texture=part.texture)
172
+ else:
173
+ self._draw_mesh(obj, camera, model=model, color=color,
174
+ shininess=shininess, specular=specular, texture=texture)
175
+
176
+ def _draw_mesh(self, mesh, camera, *, model, color, shininess, specular, texture):
177
+ if model is None:
178
+ model = Mat4.identity()
179
+ camera.set_aspect(self.width / max(1, self.height))
180
+
181
+ sh = self._basic_shader
182
+ sh["u_model"] = model
183
+ sh["u_view"] = camera.view_matrix()
184
+ sh["u_proj"] = camera.projection_matrix()
185
+ nm = np.linalg.inv(model.array[:3, :3]).T.astype(np.float32)
186
+ sh.program["u_normal_mat"].write(nm.tobytes(order="C"))
187
+ sh["u_camera_pos"] = camera.position.xyz
188
+ sh["u_light_dir"] = self.light_dir
189
+ sh["u_light_color"] = self.light_color
190
+ sh["u_ambient"] = self.ambient
191
+ sh["u_color"] = color
192
+ sh["u_shininess"] = shininess
193
+ sh["u_specular"] = specular
194
+
195
+ if texture is not None:
196
+ if texture._tex is None:
197
+ texture.build(self.ctx)
198
+ texture.use(0)
199
+ sh["u_texture"] = 0
200
+ sh["u_use_texture"] = 1
201
+ else:
202
+ sh["u_use_texture"] = 0
203
+
204
+ sh.use()
205
+ if mesh._vao is None:
206
+ mesh.build(self.ctx, sh.program)
207
+ mesh.render()
208
+
209
+ def present(self):
210
+ # 2D-оверлей: без depth-test, без culling (это плоский квад лицом к камере)
211
+ self.ctx.disable(moderngl.DEPTH_TEST)
212
+ self.ctx.disable(moderngl.CULL_FACE)
213
+ self.ctx.enable(moderngl.BLEND)
214
+ self._overlay_tex.update_from_surface(self.surface)
215
+ self._overlay_shader.use()
216
+ self._overlay_tex.use(0)
217
+ self._overlay_shader["u_texture"] = 0
218
+ self._overlay_vao.render(moderngl.TRIANGLE_STRIP)
219
+ self.ctx.enable(moderngl.DEPTH_TEST)
220
+ self.ctx.enable(moderngl.CULL_FACE)
221
+ pygame.display.flip()
222
+
223
+ # ============================================================
224
+ # ГЛАВНЫЙ ЦИКЛ
225
+ # ============================================================
226
+ def run(self):
227
+ try:
228
+ while self.running:
229
+ self.tick()
230
+ self.clear()
231
+
232
+ if self._splash is not None and not self._splash.done:
233
+ self.surface.fill((0, 0, 0, 0))
234
+ self._splash.draw(self.surface)
235
+ self.present()
236
+ continue
237
+
238
+ self.on_render_3d.dispatch()
239
+ self.surface.fill((0, 0, 0, 0))
240
+ self.on_render_2d.dispatch()
241
+ self.present()
242
+ finally:
243
+ self.shutdown()
244
+
245
+ def shutdown(self):
246
+ self.running = False
247
+ self._overlay_tex.release()
248
+ self._overlay_shader.release()
249
+ self._overlay_vao.release()
250
+ self._basic_shader.release()
251
+ pygame.quit()
252
+
253
+ @property
254
+ def time(self):
255
+ return self._elapsed
eagle/core/splash.py ADDED
@@ -0,0 +1,88 @@
1
+ """Splash-заставка с логотипом Eagle."""
2
+ from __future__ import annotations
3
+ import os
4
+ import pygame
5
+
6
+
7
+ def load_logo(path: str | None = None) -> pygame.Surface:
8
+ """Загружает логотип. Если path=None — встроенный eagle/assets/logo.png."""
9
+ if path is None:
10
+ here = os.path.dirname(os.path.abspath(__file__))
11
+ path = os.path.join(os.path.dirname(here), "assets", "logo.png")
12
+ if not os.path.exists(path):
13
+ # Fallback: пустая поверхность, чтобы не падать
14
+ surf = pygame.Surface((256, 256), pygame.SRCALPHA)
15
+ return surf
16
+ return pygame.image.load(path).convert_alpha()
17
+
18
+
19
+ def make_icon(logo: pygame.Surface, size: int = 64) -> pygame.Surface:
20
+ """Ресайз логотипа под иконку окна. Сохраняет пропорции, паддинг прозрачный."""
21
+ w, h = logo.get_size()
22
+ scale = size / max(w, h)
23
+ nw, nh = max(1, int(w * scale)), max(1, int(h * scale))
24
+ resized = pygame.transform.smoothscale(logo, (nw, nh))
25
+ icon = pygame.Surface((size, size), pygame.SRCALPHA)
26
+ icon.blit(resized, ((size - nw) // 2, (size - nh) // 2))
27
+ return icon
28
+
29
+
30
+ class Splash:
31
+ """Splash-заставка в окне. Показывается duration секунд, потом fade-out за fade секунд."""
32
+
33
+ def __init__(self, logo: pygame.Surface, engine_w: int, engine_h: int,
34
+ duration: float = 2.0, fade: float = 0.6):
35
+ self.duration = duration
36
+ self.fade = fade
37
+ self._t = 0.0
38
+ self._done = False
39
+
40
+ # Размер логотипа на экране: min(0.35 * размер окна)
41
+ target = int(min(engine_w, engine_h) * 0.35)
42
+ w, h = logo.get_size()
43
+ scale = target / max(w, h)
44
+ self._logo = pygame.transform.smoothscale(logo, (int(w * scale), int(h * scale)))
45
+ self._engine_w = engine_w
46
+ self._engine_h = engine_h
47
+
48
+ # Шрифт и текст подписи рендерим один раз — не пересоздаём каждый кадр
49
+ self._font = pygame.font.SysFont("Arial", 18, bold=True)
50
+ self._label = self._font.render("Eagle Engine", True, (240, 240, 240))
51
+
52
+ @property
53
+ def done(self) -> bool:
54
+ return self._done
55
+
56
+ def update(self, dt: float) -> None:
57
+ if self._done:
58
+ return
59
+ self._t += dt
60
+ if self._t >= self.duration + self.fade:
61
+ self._done = True
62
+
63
+ def draw(self, surface: pygame.Surface) -> None:
64
+ if self._done:
65
+ return
66
+
67
+ # Фон — тот же цвет, что у engine.clear
68
+ surface.fill((25, 25, 35, 255))
69
+
70
+ # Прозрачность: 255 → 0 при fade-out
71
+ alpha = 255
72
+ if self._t > self.duration:
73
+ alpha = int(255 * (1.0 - (self._t - self.duration) / self.fade))
74
+ alpha = max(0, min(255, alpha))
75
+
76
+ lw, lh = self._logo.get_size()
77
+ x = (self._engine_w - lw) // 2
78
+ y = (self._engine_h - lh) // 2
79
+
80
+ logo_copy = self._logo.copy()
81
+ logo_copy.set_alpha(alpha)
82
+ surface.blit(logo_copy, (x, y))
83
+
84
+ # Подпись
85
+ label = self._label.copy()
86
+ label.set_alpha(alpha)
87
+ tw = label.get_width()
88
+ surface.blit(label, ((self._engine_w - tw) // 2, y + lh + 20))
eagle/gfx/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ from eagle.gfx.shader import Shader, shader_from_files, shader_from_package
2
+ from eagle.gfx.mesh import Mesh
3
+ from eagle.gfx.model import Model, ModelPart
4
+ from eagle.gfx.camera import Camera
5
+ from eagle.gfx.orbit_camera import OrbitCamera
6
+ from eagle.gfx.texture import Texture
7
+ from eagle.gfx.material import Material
8
+
9
+ __all__ = ["Shader", "shader_from_files", "shader_from_package",
10
+ "Mesh", "Model", "ModelPart", "Camera", "OrbitCamera", "Texture", "Material"]
eagle/gfx/camera.py ADDED
@@ -0,0 +1,41 @@
1
+ """Camera — перспектива / орто + look-at."""
2
+ from __future__ import annotations
3
+ from eagle.math.vec import Vec3
4
+ from eagle.math.mat import Mat4
5
+
6
+
7
+ def _as_vec3(v, default: Vec3) -> Vec3:
8
+ if v is None:
9
+ return default
10
+ if isinstance(v, Vec3):
11
+ return v
12
+ if isinstance(v, (tuple, list)):
13
+ return Vec3.from_array(v)
14
+ raise TypeError(f"Camera: cannot convert {type(v)} to Vec3")
15
+
16
+
17
+ class Camera:
18
+ def __init__(self, position=None, target=None, fov_deg=60.0, near=0.1, far=1000.0):
19
+ self.position = _as_vec3(position, Vec3(0.0, 0.0, 3.0))
20
+ self.target = _as_vec3(target, Vec3.zero())
21
+ self.up = Vec3.up()
22
+ self.fov_deg = fov_deg
23
+ self.near = near
24
+ self.far = far
25
+ self._aspect = 1.0
26
+ self._ortho = None
27
+
28
+ def set_aspect(self, aspect): self._aspect = aspect
29
+ def set_ortho(self, l, r, b, t, near=-1.0, far=1.0): self._ortho = (l, r, b, t, near, far)
30
+ def clear_ortho(self): self._ortho = None
31
+
32
+ def view_matrix(self) -> Mat4:
33
+ return Mat4.look_at(self.position, self.target, self.up)
34
+
35
+ def projection_matrix(self) -> Mat4:
36
+ if self._ortho is not None:
37
+ return Mat4.ortho(*self._ortho)
38
+ return Mat4.perspective(self.fov_deg, self._aspect, self.near, self.far)
39
+
40
+ def view_proj(self) -> Mat4:
41
+ return self.projection_matrix() @ self.view_matrix()
eagle/gfx/material.py ADDED
@@ -0,0 +1,11 @@
1
+ """Material — базовый Blinn-Phong."""
2
+ from __future__ import annotations
3
+ from eagle.gfx.texture import Texture
4
+
5
+
6
+ class Material:
7
+ def __init__(self, color=(0.8, 0.8, 0.85), shininess=32.0, specular=0.4, texture: Texture | None = None):
8
+ self.color = color
9
+ self.shininess = shininess
10
+ self.specular = specular
11
+ self.texture = texture
eagle/gfx/mesh.py ADDED
@@ -0,0 +1,83 @@
1
+ """Mesh — VAO + VBO + (опционально) EBO. Формат вершины: 8 float32."""
2
+ from __future__ import annotations
3
+ import numpy as np
4
+ import moderngl
5
+
6
+
7
+ class Mesh:
8
+ def __init__(self):
9
+ self._vertices: np.ndarray | None = None
10
+ self._indices: np.ndarray | None = None
11
+ self._vao: moderngl.VertexArray | None = None
12
+ self._vbo: moderngl.Buffer | None = None
13
+ self._ibo: moderngl.Buffer | None = None
14
+ self._mode = moderngl.TRIANGLES
15
+
16
+ @classmethod
17
+ def from_arrays(cls, vertices, indices=None):
18
+ m = cls()
19
+ m._vertices = np.asarray(vertices, dtype=np.float32).reshape(-1, 8)
20
+ if indices is not None:
21
+ m._indices = np.asarray(indices, dtype=np.uint32).reshape(-1)
22
+ return m
23
+
24
+ def _fix_winding(self) -> None:
25
+ """Развернуть треугольники, у которых обход не совпадает с нормалями.
26
+ Работает только для TRIANGLES с индексами и нормалями."""
27
+ if self._indices is None or self._mode != moderngl.TRIANGLES:
28
+ return
29
+ tri = self._indices.reshape(-1, 3)
30
+ V = self._vertices
31
+
32
+ p0, p1, p2 = V[tri[:, 0], :3], V[tri[:, 1], :3], V[tri[:, 2], :3]
33
+ n_geo = np.cross(p1 - p0, p2 - p0)
34
+ n_v = (V[tri[:, 0], 3:6] + V[tri[:, 1], 3:6] + V[tri[:, 2], 3:6])
35
+ # dot(n_geo, n_vert) < 0 → обход "наизнанку"
36
+ flip = np.einsum("ij,ij->i", n_geo, n_v) < 0.0
37
+
38
+ if flip.any():
39
+ tri_f = tri.copy()
40
+ tri_f[flip, 1], tri_f[flip, 2] = tri[flip, 2].copy(), tri[flip, 1].copy()
41
+ self._indices = tri_f.flatten().astype(np.uint32)
42
+
43
+ def build(self, ctx: moderngl.Context, prog: moderngl.Program) -> None:
44
+ self.release()
45
+ if self._vertices is None:
46
+ raise RuntimeError("Mesh has no vertices")
47
+ self._fix_winding()
48
+
49
+ self._vbo = ctx.buffer(self._vertices.tobytes(order="C"))
50
+ if self._indices is not None:
51
+ self._ibo = ctx.buffer(self._indices.tobytes(order="C"))
52
+ self._vao = ctx.vertex_array(
53
+ prog,
54
+ [(self._vbo, "3f 3f 2f", "in_position", "in_normal", "in_uv")],
55
+ self._ibo,
56
+ )
57
+ else:
58
+ self._vao = ctx.vertex_array(
59
+ prog,
60
+ [(self._vbo, "3f 3f 2f", "in_position", "in_normal", "in_uv")],
61
+ )
62
+
63
+ def release(self) -> None:
64
+ if self._vao: self._vao.release(); self._vao = None
65
+ if self._vbo: self._vbo.release(); self._vbo = None
66
+ if self._ibo: self._ibo.release(); self._ibo = None
67
+
68
+ def render(self) -> None:
69
+ if self._vao is None:
70
+ raise RuntimeError("Mesh not built: call build(ctx, prog)")
71
+ if self._indices is not None:
72
+ self._vao.render(self._mode)
73
+ else:
74
+ self._vao.render(self._mode, vertices=self._vertices.shape[0])
75
+
76
+ @property
77
+ def vertex_count(self): return 0 if self._vertices is None else self._vertices.shape[0]
78
+ @property
79
+ def index_count(self): return 0 if self._indices is None else self._indices.shape[0]
80
+ @property
81
+ def vertices(self): return self._vertices
82
+ @property
83
+ def indices(self): return self._indices
eagle/gfx/model.py ADDED
@@ -0,0 +1,46 @@
1
+ """Model — контейнер sub-meshes с материалами."""
2
+ from __future__ import annotations
3
+ from eagle.gfx.mesh import Mesh
4
+ from eagle.gfx.texture import Texture
5
+
6
+
7
+ class ModelPart:
8
+ def __init__(self, mesh: Mesh, *, color=(0.8, 0.8, 0.85),
9
+ texture: Texture | None = None,
10
+ shininess: float = 32.0, specular: float = 0.4,
11
+ name: str = ""):
12
+ self.mesh = mesh
13
+ self.color = color
14
+ self.texture = texture
15
+ self.shininess = shininess
16
+ self.specular = specular
17
+ self.name = name
18
+
19
+
20
+ class Model:
21
+ def __init__(self):
22
+ self.parts: list[ModelPart] = []
23
+
24
+ def add_part(self, part: ModelPart) -> "Model":
25
+ self.parts.append(part)
26
+ return self
27
+
28
+ def build_textures(self, ctx) -> None:
29
+ """Строит GL-текстуры для всех частей (после создания Engine)."""
30
+ for p in self.parts:
31
+ if p.texture is not None and p.texture._tex is None:
32
+ p.texture.build(ctx)
33
+
34
+ @property
35
+ def vertices(self):
36
+ """Bounding-box-friendly: склеиваем вершины всех частей (для авто-подгонки камеры)."""
37
+ import numpy as np
38
+ if not self.parts:
39
+ return None
40
+ return np.vstack([p.mesh.vertices for p in self.parts if p.mesh.vertices is not None])
41
+
42
+ def __iter__(self):
43
+ return iter(self.parts)
44
+
45
+ def __len__(self):
46
+ return len(self.parts)