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 +6 -0
- eagle/assets/logo.png +0 -0
- eagle/assets/shaders/basic.frag +32 -0
- eagle/assets/shaders/basic.vert +22 -0
- eagle/assets/shaders/unlit.frag +7 -0
- eagle/assets/shaders/unlit.vert +8 -0
- eagle/core/__init__.py +3 -0
- eagle/core/engine.py +255 -0
- eagle/core/splash.py +88 -0
- eagle/gfx/__init__.py +10 -0
- eagle/gfx/camera.py +41 -0
- eagle/gfx/material.py +11 -0
- eagle/gfx/mesh.py +83 -0
- eagle/gfx/model.py +46 -0
- eagle/gfx/orbit_camera.py +105 -0
- eagle/gfx/shader.py +76 -0
- eagle/gfx/texture.py +49 -0
- eagle/loaders/__init__.py +5 -0
- eagle/loaders/mtl.py +51 -0
- eagle/loaders/obj.py +151 -0
- eagle/loaders/stl.py +55 -0
- eagle/math/__init__.py +5 -0
- eagle/math/mat.py +122 -0
- eagle/math/transform.py +37 -0
- eagle/math/vec.py +132 -0
- eagle/primitives/__init__.py +12 -0
- eagle/primitives/axes.py +17 -0
- eagle/primitives/cone.py +55 -0
- eagle/primitives/cube.py +29 -0
- eagle/primitives/cylinder.py +46 -0
- eagle/primitives/grid.py +22 -0
- eagle/primitives/icosphere.py +50 -0
- eagle/primitives/plane.py +19 -0
- eagle/primitives/sphere.py +29 -0
- eagle/primitives/torus.py +32 -0
- eagle/scene/__init__.py +4 -0
- eagle/scene/object.py +43 -0
- eagle/scene/scene.py +40 -0
- eagle_engine-0.1.0.dist-info/METADATA +88 -0
- eagle_engine-0.1.0.dist-info/RECORD +43 -0
- eagle_engine-0.1.0.dist-info/WHEEL +5 -0
- eagle_engine-0.1.0.dist-info/licenses/LICENSE +21 -0
- eagle_engine-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""OrbitCamera — орбитальная камера с управлением мышью и колесом."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import math
|
|
4
|
+
import pygame
|
|
5
|
+
from eagle.gfx.camera import Camera
|
|
6
|
+
from eagle.math import Vec3
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class OrbitCamera:
|
|
10
|
+
def __init__(
|
|
11
|
+
self,
|
|
12
|
+
target: Vec3 | tuple | None = None,
|
|
13
|
+
distance: float = 5.0,
|
|
14
|
+
*,
|
|
15
|
+
yaw: float = 0.0, # горизонтальный угол (рад), 0 = смотреть с +Z
|
|
16
|
+
pitch: float = 0.25, # вертикальный угол (рад), 0 = горизонтально
|
|
17
|
+
fov_deg: float = 60.0,
|
|
18
|
+
near: float = 0.1,
|
|
19
|
+
far: float = 10000.0,
|
|
20
|
+
sensitivity: float = 0.005,
|
|
21
|
+
zoom_speed: float = 1.1,
|
|
22
|
+
min_distance: float = 0.5,
|
|
23
|
+
max_distance: float = 500.0,
|
|
24
|
+
):
|
|
25
|
+
self.camera = Camera(
|
|
26
|
+
position=Vec3(0, 0, distance),
|
|
27
|
+
target=target if isinstance(target, Vec3) else Vec3.from_array(target or (0, 0, 0)),
|
|
28
|
+
fov_deg=fov_deg, near=near, far=far,
|
|
29
|
+
)
|
|
30
|
+
self.distance = distance
|
|
31
|
+
self.yaw = yaw
|
|
32
|
+
self.pitch = pitch
|
|
33
|
+
self.sensitivity = sensitivity
|
|
34
|
+
self.zoom_speed = zoom_speed
|
|
35
|
+
self.min_distance = min_distance
|
|
36
|
+
self.max_distance = max_distance
|
|
37
|
+
self._grab = False
|
|
38
|
+
self._update_camera()
|
|
39
|
+
|
|
40
|
+
# --- API ---
|
|
41
|
+
@property
|
|
42
|
+
def target(self) -> Vec3:
|
|
43
|
+
return self.camera.target
|
|
44
|
+
|
|
45
|
+
@target.setter
|
|
46
|
+
def target(self, v):
|
|
47
|
+
if isinstance(v, Vec3):
|
|
48
|
+
self.camera.target = v
|
|
49
|
+
else:
|
|
50
|
+
self.camera.target = Vec3.from_array(v)
|
|
51
|
+
self._update_camera()
|
|
52
|
+
|
|
53
|
+
def set_grab(self, enable: bool) -> None:
|
|
54
|
+
"""Захват мыши (relative mode). Toggle по клику или кнопке."""
|
|
55
|
+
self._grab = enable
|
|
56
|
+
pygame.event.set_grab(enable)
|
|
57
|
+
pygame.mouse.set_visible(not enable)
|
|
58
|
+
if enable:
|
|
59
|
+
pygame.mouse.set_pos(pygame.display.get_window_size()[0] // 2,
|
|
60
|
+
pygame.display.get_window_size()[1] // 2)
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def grabbed(self) -> bool:
|
|
64
|
+
return self._grab
|
|
65
|
+
|
|
66
|
+
# --- управление ---
|
|
67
|
+
def handle_event(self, event: pygame.event.Event) -> None:
|
|
68
|
+
"""Вызови из @engine.on_event."""
|
|
69
|
+
if event.type == pygame.MOUSEBUTTONDOWN:
|
|
70
|
+
if event.button == 1: # ЛКМ — захватить
|
|
71
|
+
self.set_grab(True)
|
|
72
|
+
elif event.button == 4: # колесо вверх — приблизить
|
|
73
|
+
self.distance = max(self.min_distance, self.distance / self.zoom_speed)
|
|
74
|
+
self._update_camera()
|
|
75
|
+
elif event.button == 5: # колесо вниз — отдалить
|
|
76
|
+
self.distance = min(self.max_distance, self.distance * self.zoom_speed)
|
|
77
|
+
self._update_camera()
|
|
78
|
+
|
|
79
|
+
elif event.type == pygame.MOUSEMOTION and self._grab:
|
|
80
|
+
dx, dy = event.rel
|
|
81
|
+
self.yaw -= dx * self.sensitivity
|
|
82
|
+
self.pitch -= dy * self.sensitivity
|
|
83
|
+
# ограничиваем pitch, чтобы не перевернуться
|
|
84
|
+
self.pitch = max(-math.pi / 2 + 0.01, min(math.pi / 2 - 0.01, self.pitch))
|
|
85
|
+
self._update_camera()
|
|
86
|
+
|
|
87
|
+
elif event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
|
|
88
|
+
self.set_grab(False)
|
|
89
|
+
|
|
90
|
+
def _update_camera(self) -> None:
|
|
91
|
+
# Сферические координаты: yaw вокруг Y, pitch — вверх/вниз
|
|
92
|
+
cp = math.cos(self.pitch)
|
|
93
|
+
sp = math.sin(self.pitch)
|
|
94
|
+
cy = math.cos(self.yaw)
|
|
95
|
+
sy = math.sin(self.yaw)
|
|
96
|
+
|
|
97
|
+
# Позиция = target + distance * (направление от target к камере)
|
|
98
|
+
offset_x = cp * sy
|
|
99
|
+
offset_y = sp
|
|
100
|
+
offset_z = cp * cy
|
|
101
|
+
self.camera.position = Vec3(
|
|
102
|
+
self.camera.target.x + offset_x * self.distance,
|
|
103
|
+
self.camera.target.y + offset_y * self.distance,
|
|
104
|
+
self.camera.target.z + offset_z * self.distance,
|
|
105
|
+
)
|
eagle/gfx/shader.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Обёртка над ModernGL-программой."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import os
|
|
4
|
+
import moderngl
|
|
5
|
+
from eagle.math.mat import Mat4
|
|
6
|
+
from eagle.math.vec import Vec3
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Shader:
|
|
10
|
+
def __init__(self, vert: str, frag: str, *, vert_path: str | None = None, frag_path: str | None = None):
|
|
11
|
+
self._vert_src = vert
|
|
12
|
+
self._frag_src = frag
|
|
13
|
+
self._vert_path = vert_path
|
|
14
|
+
self._frag_path = frag_path
|
|
15
|
+
self._prog: moderngl.Program | None = None
|
|
16
|
+
self._uniforms: dict[str, object] = {}
|
|
17
|
+
|
|
18
|
+
def build(self, ctx: moderngl.Context) -> None:
|
|
19
|
+
if self._prog is not None:
|
|
20
|
+
self._prog.release()
|
|
21
|
+
self._prog = ctx.program(vertex_shader=self._vert_src, fragment_shader=self._frag_src)
|
|
22
|
+
self._uniforms.clear()
|
|
23
|
+
|
|
24
|
+
def release(self) -> None:
|
|
25
|
+
if self._prog:
|
|
26
|
+
self._prog.release()
|
|
27
|
+
self._prog = None
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def program(self) -> moderngl.Program:
|
|
31
|
+
if self._prog is None:
|
|
32
|
+
raise RuntimeError("Shader not built: call build(ctx)")
|
|
33
|
+
return self._prog
|
|
34
|
+
|
|
35
|
+
def __setitem__(self, name, value):
|
|
36
|
+
self._uniforms[name] = value
|
|
37
|
+
|
|
38
|
+
def _apply(self):
|
|
39
|
+
prog = self.program
|
|
40
|
+
for name, value in self._uniforms.items():
|
|
41
|
+
if name not in prog:
|
|
42
|
+
continue
|
|
43
|
+
if isinstance(value, Mat4):
|
|
44
|
+
prog[name].write(value.gl_bytes)
|
|
45
|
+
elif isinstance(value, Vec3):
|
|
46
|
+
prog[name].value = value.xyz
|
|
47
|
+
elif isinstance(value, (int, float, tuple, list)):
|
|
48
|
+
prog[name].value = value
|
|
49
|
+
|
|
50
|
+
def use(self):
|
|
51
|
+
"""Применить все накопленные uniform'ы. Программа активируется автоматически при записи."""
|
|
52
|
+
self._apply()
|
|
53
|
+
|
|
54
|
+
def reload(self):
|
|
55
|
+
if self._vert_path:
|
|
56
|
+
with open(self._vert_path, "r", encoding="utf-8") as f:
|
|
57
|
+
self._vert_src = f.read()
|
|
58
|
+
if self._frag_path:
|
|
59
|
+
with open(self._frag_path, "r", encoding="utf-8") as f:
|
|
60
|
+
self._frag_src = f.read()
|
|
61
|
+
if self._prog is not None:
|
|
62
|
+
self.build(self._prog.ctx)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def shader_from_files(vert_path: str, frag_path: str) -> Shader:
|
|
66
|
+
with open(vert_path, "r", encoding="utf-8") as f:
|
|
67
|
+
v = f.read()
|
|
68
|
+
with open(frag_path, "r", encoding="utf-8") as f:
|
|
69
|
+
fr = f.read()
|
|
70
|
+
return Shader(v, fr, vert_path=vert_path, frag_path=frag_path)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def shader_from_package(rel_vert: str, rel_frag: str) -> Shader:
|
|
74
|
+
here = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
75
|
+
base = os.path.join(here, "assets", "shaders")
|
|
76
|
+
return shader_from_files(os.path.join(base, rel_vert), os.path.join(base, rel_frag))
|
eagle/gfx/texture.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Texture — из файла / из pygame.Surface."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import pygame
|
|
4
|
+
import moderngl
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Texture:
|
|
8
|
+
def __init__(self, surface: pygame.Surface, *, flip: bool = False):
|
|
9
|
+
# НЕ convert_alpha здесь: display может быть ещё не создан.
|
|
10
|
+
self._surface = surface
|
|
11
|
+
self._flip = flip
|
|
12
|
+
self._tex: moderngl.Texture | None = None
|
|
13
|
+
|
|
14
|
+
@classmethod
|
|
15
|
+
def from_file(cls, path: str, *, flip: bool = True) -> "Texture":
|
|
16
|
+
return cls(pygame.image.load(path), flip=flip)
|
|
17
|
+
|
|
18
|
+
def build(self, ctx: moderngl.Context) -> None:
|
|
19
|
+
self.release()
|
|
20
|
+
# Конвертация — здесь, когда display уже создан.
|
|
21
|
+
surf = self._surface.convert_alpha()
|
|
22
|
+
w, h = surf.get_size()
|
|
23
|
+
data = pygame.image.tostring(surf, "RGBA", self._flip)
|
|
24
|
+
self._tex = ctx.texture((w, h), 4, data)
|
|
25
|
+
self._tex.build_mipmaps()
|
|
26
|
+
self._tex.filter = (moderngl.LINEAR_MIPMAP_LINEAR, moderngl.LINEAR)
|
|
27
|
+
self._tex.anisotropy = 8.0
|
|
28
|
+
|
|
29
|
+
def update_from_surface(self, surface: pygame.Surface) -> None:
|
|
30
|
+
if self._tex is None:
|
|
31
|
+
return
|
|
32
|
+
surf = surface.convert_alpha()
|
|
33
|
+
if surf.get_size() != (self._tex.width, self._tex.height):
|
|
34
|
+
raise ValueError("surface size mismatch")
|
|
35
|
+
self._tex.write(pygame.image.tostring(surf, "RGBA", self._flip))
|
|
36
|
+
|
|
37
|
+
def release(self):
|
|
38
|
+
if self._tex:
|
|
39
|
+
self._tex.release(); self._tex = None
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def gl_texture(self):
|
|
43
|
+
if self._tex is None:
|
|
44
|
+
raise RuntimeError("Texture not built")
|
|
45
|
+
return self._tex
|
|
46
|
+
|
|
47
|
+
def use(self, unit: int = 0):
|
|
48
|
+
if self._tex:
|
|
49
|
+
self._tex.use(unit)
|
eagle/loaders/mtl.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Загрузчик .mtl — материалы для .obj."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import os
|
|
4
|
+
import pygame
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class MTLMaterial:
|
|
8
|
+
def __init__(self):
|
|
9
|
+
self.name = ""
|
|
10
|
+
self.diffuse = (0.8, 0.8, 0.85) # Kd
|
|
11
|
+
self.ambient = (0.2, 0.2, 0.2) # Ka
|
|
12
|
+
self.specular = (0.5, 0.5, 0.5) # Ks
|
|
13
|
+
self.shininess = 32.0 # Ns
|
|
14
|
+
self.diffuse_map: str | None = None
|
|
15
|
+
self.texture_surface: pygame.Surface | None = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def load_mtl(path: str) -> dict[str, MTLMaterial]:
|
|
19
|
+
materials: dict[str, MTLMaterial] = {}
|
|
20
|
+
current: MTLMaterial | None = None
|
|
21
|
+
|
|
22
|
+
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
|
23
|
+
for line in f:
|
|
24
|
+
line = line.strip()
|
|
25
|
+
if not line or line.startswith("#"):
|
|
26
|
+
continue
|
|
27
|
+
parts = line.split()
|
|
28
|
+
tag = parts[0]
|
|
29
|
+
if tag == "newmtl":
|
|
30
|
+
current = MTLMaterial()
|
|
31
|
+
current.name = parts[1]
|
|
32
|
+
materials[current.name] = current
|
|
33
|
+
elif current is None:
|
|
34
|
+
continue
|
|
35
|
+
elif tag == "Kd":
|
|
36
|
+
current.diffuse = (float(parts[1]), float(parts[2]), float(parts[3]))
|
|
37
|
+
elif tag == "Ka":
|
|
38
|
+
current.ambient = (float(parts[1]), float(parts[2]), float(parts[3]))
|
|
39
|
+
elif tag == "Ks":
|
|
40
|
+
current.specular = (float(parts[1]), float(parts[2]), float(parts[3]))
|
|
41
|
+
elif tag == "Ns":
|
|
42
|
+
current.shininess = float(parts[1])
|
|
43
|
+
elif tag == "map_Kd":
|
|
44
|
+
tex_name = " ".join(parts[1:])
|
|
45
|
+
tex_path = os.path.join(os.path.dirname(path), tex_name)
|
|
46
|
+
current.diffuse_map = tex_path
|
|
47
|
+
if os.path.exists(tex_path):
|
|
48
|
+
# НЕ convert_alpha: дисплей может быть ещё не создан.
|
|
49
|
+
# Texture.build() сделает convert_alpha после инициализации.
|
|
50
|
+
current.texture_surface = pygame.image.load(tex_path)
|
|
51
|
+
return materials
|
eagle/loaders/obj.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Загрузчик .obj с разбиением на sub-meshes по материалам."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import os
|
|
4
|
+
import numpy as np
|
|
5
|
+
import pygame
|
|
6
|
+
from eagle.gfx.mesh import Mesh
|
|
7
|
+
from eagle.gfx.model import Model, ModelPart
|
|
8
|
+
from eagle.gfx.texture import Texture
|
|
9
|
+
from eagle.loaders.mtl import load_mtl, MTLMaterial
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def load_obj(path: str) -> Model:
|
|
13
|
+
"""Возвращает Model (несколько sub-meshes, по одному на материал)."""
|
|
14
|
+
positions: list[tuple[float, float, float]] = []
|
|
15
|
+
normals: list[tuple[float, float, float]] = []
|
|
16
|
+
uvs: list[tuple[float, float]] = []
|
|
17
|
+
|
|
18
|
+
# Для каждого материала: { key → (local_index, [vertex_data]) , indices: [] }
|
|
19
|
+
per_mat_verts: dict[int, list[list[float]]] = {}
|
|
20
|
+
per_mat_idx: dict[int, list[int]] = {}
|
|
21
|
+
per_mat_dedup: dict[int, dict[tuple, int]] = {}
|
|
22
|
+
|
|
23
|
+
materials: list[MTLMaterial] = []
|
|
24
|
+
mat_name_to_idx: dict[str, int] = {}
|
|
25
|
+
current_mat_idx = -1
|
|
26
|
+
|
|
27
|
+
def ensure_mat(name: str) -> int:
|
|
28
|
+
if name in mat_name_to_idx:
|
|
29
|
+
return mat_name_to_idx[name]
|
|
30
|
+
m = MTLMaterial(); m.name = name
|
|
31
|
+
materials.append(m)
|
|
32
|
+
idx = len(materials) - 1
|
|
33
|
+
mat_name_to_idx[name] = idx
|
|
34
|
+
return idx
|
|
35
|
+
|
|
36
|
+
mtllib_loaded = False
|
|
37
|
+
|
|
38
|
+
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
|
39
|
+
for line in f:
|
|
40
|
+
line = line.strip()
|
|
41
|
+
if not line or line.startswith("#"):
|
|
42
|
+
continue
|
|
43
|
+
parts = line.split()
|
|
44
|
+
tag = parts[0]
|
|
45
|
+
|
|
46
|
+
if tag == "mtllib" and not mtllib_loaded:
|
|
47
|
+
mtl_name = " ".join(parts[1:])
|
|
48
|
+
mtl_path = os.path.join(os.path.dirname(path), mtl_name)
|
|
49
|
+
if os.path.exists(mtl_path):
|
|
50
|
+
loaded = load_mtl(mtl_path)
|
|
51
|
+
for name, mat in loaded.items():
|
|
52
|
+
if name not in mat_name_to_idx:
|
|
53
|
+
mat_name_to_idx[name] = len(materials)
|
|
54
|
+
materials.append(mat)
|
|
55
|
+
mtllib_loaded = True
|
|
56
|
+
|
|
57
|
+
elif tag == "usemtl":
|
|
58
|
+
name = parts[1]
|
|
59
|
+
current_mat_idx = ensure_mat(name)
|
|
60
|
+
|
|
61
|
+
elif tag == "v":
|
|
62
|
+
positions.append((float(parts[1]), float(parts[2]), float(parts[3])))
|
|
63
|
+
elif tag == "vn":
|
|
64
|
+
normals.append((float(parts[1]), float(parts[2]), float(parts[3])))
|
|
65
|
+
elif tag == "vt":
|
|
66
|
+
uvs.append((float(parts[1]), float(parts[2])))
|
|
67
|
+
|
|
68
|
+
elif tag == "f":
|
|
69
|
+
if current_mat_idx < 0:
|
|
70
|
+
current_mat_idx = ensure_mat("__default__")
|
|
71
|
+
face = []
|
|
72
|
+
for tok in parts[1:]:
|
|
73
|
+
if "/" in tok:
|
|
74
|
+
comp = tok.split("/")
|
|
75
|
+
v_str = comp[0]
|
|
76
|
+
vt_str = comp[1] if len(comp) > 1 and comp[1] else "0"
|
|
77
|
+
vn_str = comp[2] if len(comp) > 2 and comp[2] else "0"
|
|
78
|
+
v = int(v_str) - 1
|
|
79
|
+
vt = int(vt_str) - 1 if vt_str and vt_str != "0" else -1
|
|
80
|
+
vn = int(vn_str) - 1 if vn_str and vn_str != "0" else -1
|
|
81
|
+
else:
|
|
82
|
+
v = int(tok) - 1; vt = -1; vn = -1
|
|
83
|
+
face.append((v, vn, vt))
|
|
84
|
+
|
|
85
|
+
if len(face) >= 3:
|
|
86
|
+
verts_l = per_mat_verts.setdefault(current_mat_idx, [])
|
|
87
|
+
idx_l = per_mat_idx.setdefault(current_mat_idx, [])
|
|
88
|
+
dedup = per_mat_dedup.setdefault(current_mat_idx, {})
|
|
89
|
+
|
|
90
|
+
for i in range(1, len(face) - 1):
|
|
91
|
+
for vert in (face[0], face[i], face[i + 1]):
|
|
92
|
+
v_i, n_i, u_i = vert
|
|
93
|
+
key = (v_i, n_i, u_i)
|
|
94
|
+
if key not in dedup:
|
|
95
|
+
p = positions[v_i]
|
|
96
|
+
n = normals[n_i] if n_i >= 0 and n_i < len(normals) else (0.0, 0.0, 0.0)
|
|
97
|
+
uv = uvs[u_i] if u_i >= 0 and u_i < len(uvs) else (0.0, 0.0)
|
|
98
|
+
dedup[key] = len(verts_l)
|
|
99
|
+
verts_l.append([*p, *n, *uv])
|
|
100
|
+
idx_l.append(dedup[key])
|
|
101
|
+
|
|
102
|
+
model = Model()
|
|
103
|
+
for mat_idx, verts_l in per_mat_verts.items():
|
|
104
|
+
verts_arr = np.array(verts_l, dtype=np.float32).reshape(-1, 8)
|
|
105
|
+
idx_l = per_mat_idx[mat_idx]
|
|
106
|
+
if not normals:
|
|
107
|
+
verts_arr = _compute_flat_normals(verts_arr, idx_l)
|
|
108
|
+
|
|
109
|
+
mesh = Mesh.from_arrays(verts_arr, np.array(idx_l, dtype=np.uint32))
|
|
110
|
+
|
|
111
|
+
mat = materials[mat_idx] if mat_idx < len(materials) else MTLMaterial()
|
|
112
|
+
color = mat.diffuse if mat.diffuse else (0.8, 0.8, 0.85)
|
|
113
|
+
|
|
114
|
+
texture = None
|
|
115
|
+
if mat.texture_surface is not None:
|
|
116
|
+
texture = Texture(mat.texture_surface, flip=True)
|
|
117
|
+
# Built later (engine.ctx нужен)
|
|
118
|
+
|
|
119
|
+
print(f"[obj] part '{mat.name}': verts={len(verts_l)}, indices={len(idx_l)}, "
|
|
120
|
+
f"texture={'YES' if texture else 'NO'}, "
|
|
121
|
+
f"tex_path={mat.diffuse_map}, color={color}")
|
|
122
|
+
|
|
123
|
+
model.add_part(ModelPart(
|
|
124
|
+
mesh, color=color, texture=texture,
|
|
125
|
+
shininess=max(1.0, mat.shininess),
|
|
126
|
+
specular=0.3,
|
|
127
|
+
name=mat.name,
|
|
128
|
+
))
|
|
129
|
+
|
|
130
|
+
if len(model) == 0:
|
|
131
|
+
raise ValueError(f"no geometry found in {path}")
|
|
132
|
+
print(f"[obj] total parts: {len(model)}")
|
|
133
|
+
return model
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _compute_flat_normals(verts: np.ndarray, indices: list[int]) -> np.ndarray:
|
|
137
|
+
V = verts[:, :3]
|
|
138
|
+
idx = np.array(indices, dtype=np.int64).reshape(-1, 3)
|
|
139
|
+
e1 = V[idx[:, 1]] - V[idx[:, 0]]
|
|
140
|
+
e2 = V[idx[:, 2]] - V[idx[:, 0]]
|
|
141
|
+
face_n = np.cross(e1, e2)
|
|
142
|
+
face_n /= np.maximum(np.linalg.norm(face_n, axis=1, keepdims=True), 1e-12)
|
|
143
|
+
n = np.zeros_like(V)
|
|
144
|
+
for k in range(3):
|
|
145
|
+
np.add.at(n, idx[:, k], face_n)
|
|
146
|
+
norm = np.linalg.norm(n, axis=1, keepdims=True)
|
|
147
|
+
norm[norm < 1e-12] = 1
|
|
148
|
+
n /= norm
|
|
149
|
+
out = verts.copy()
|
|
150
|
+
out[:, 3:6] = n.astype(np.float32)
|
|
151
|
+
return out
|
eagle/loaders/stl.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Загрузчик .stl (ASCII и бинарный)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import struct
|
|
4
|
+
import numpy as np
|
|
5
|
+
from eagle.gfx.mesh import Mesh
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def load_stl(path: str) -> Mesh:
|
|
9
|
+
with open(path, "rb") as f:
|
|
10
|
+
head = f.read(80)
|
|
11
|
+
rest = f.read(4)
|
|
12
|
+
size = struct.unpack("<I", rest)[0] if len(rest) == 4 else 0
|
|
13
|
+
is_ascii = head[:5].lower() == b"solid" and size == 0
|
|
14
|
+
|
|
15
|
+
if is_ascii:
|
|
16
|
+
return _load_ascii(path)
|
|
17
|
+
return _load_binary(path)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _load_binary(path: str) -> Mesh:
|
|
21
|
+
with open(path, "rb") as f:
|
|
22
|
+
f.read(80)
|
|
23
|
+
n_tris = struct.unpack("<I", f.read(4))[0]
|
|
24
|
+
data = np.frombuffer(f.read(n_tris * 50), dtype=np.uint8).reshape(n_tris, 50)
|
|
25
|
+
floats = data[:, :48].copy().view("<f4").reshape(n_tris, 12)
|
|
26
|
+
normals = floats[:, :3]
|
|
27
|
+
v0 = floats[:, 3:6]; v1 = floats[:, 6:9]; v2 = floats[:, 9:12]
|
|
28
|
+
tri_pos = np.stack([v0, v1, v2], axis=1).reshape(-1, 3)
|
|
29
|
+
tri_n = np.repeat(normals, 3, axis=0)
|
|
30
|
+
tri_uv = np.zeros((len(tri_pos), 2), dtype=np.float32)
|
|
31
|
+
verts = np.hstack([tri_pos, tri_n, tri_uv]).astype(np.float32)
|
|
32
|
+
indices = np.arange(len(verts), dtype=np.uint32)
|
|
33
|
+
return Mesh.from_arrays(verts, indices)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _load_ascii(path: str) -> Mesh:
|
|
37
|
+
verts_list, normals_list = [], []
|
|
38
|
+
cur_n = (0.0, 0.0, 0.0)
|
|
39
|
+
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
|
40
|
+
for line in f:
|
|
41
|
+
parts = line.strip().split()
|
|
42
|
+
if not parts: continue
|
|
43
|
+
if parts[0] == "facet" and len(parts) >= 5 and parts[1] == "normal":
|
|
44
|
+
cur_n = (float(parts[2]), float(parts[3]), float(parts[4]))
|
|
45
|
+
elif parts[0] == "vertex":
|
|
46
|
+
verts_list.append((float(parts[1]), float(parts[2]), float(parts[3])))
|
|
47
|
+
normals_list.append(cur_n)
|
|
48
|
+
if not verts_list:
|
|
49
|
+
raise ValueError("empty STL file")
|
|
50
|
+
tri_pos = np.array(verts_list, dtype=np.float32)
|
|
51
|
+
tri_n = np.array(normals_list, dtype=np.float32)
|
|
52
|
+
tri_uv = np.zeros((len(tri_pos), 2), dtype=np.float32)
|
|
53
|
+
verts = np.hstack([tri_pos, tri_n, tri_uv]).astype(np.float32)
|
|
54
|
+
indices = np.arange(len(verts), dtype=np.uint32)
|
|
55
|
+
return Mesh.from_arrays(verts, indices)
|
eagle/math/__init__.py
ADDED
eagle/math/mat.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Mat4 — матрица 4x4 float32 (column-major для OpenGL)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import math
|
|
4
|
+
import numpy as np
|
|
5
|
+
from eagle.math.vec import Vec3
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Mat4:
|
|
9
|
+
__slots__ = ("_m",)
|
|
10
|
+
|
|
11
|
+
def __init__(self, m=None):
|
|
12
|
+
if m is None:
|
|
13
|
+
self._m = np.eye(4, dtype=np.float32)
|
|
14
|
+
else:
|
|
15
|
+
self._m = np.asarray(m, dtype=np.float32).reshape(4, 4).copy()
|
|
16
|
+
|
|
17
|
+
@classmethod
|
|
18
|
+
def identity(cls): return cls()
|
|
19
|
+
|
|
20
|
+
@classmethod
|
|
21
|
+
def translation(cls, x, y, z):
|
|
22
|
+
m = cls()
|
|
23
|
+
m._m[0, 3] = x; m._m[1, 3] = y; m._m[2, 3] = z
|
|
24
|
+
return m
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def scale(cls, x, y=None, z=None):
|
|
28
|
+
if y is None: y = x
|
|
29
|
+
if z is None: z = x
|
|
30
|
+
m = cls()
|
|
31
|
+
m._m[0, 0] = x; m._m[1, 1] = y; m._m[2, 2] = z
|
|
32
|
+
return m
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def rotation_x(cls, rad):
|
|
36
|
+
c, s = math.cos(rad), math.sin(rad)
|
|
37
|
+
m = cls()
|
|
38
|
+
m._m[1, 1] = c; m._m[1, 2] = -s
|
|
39
|
+
m._m[2, 1] = s; m._m[2, 2] = c
|
|
40
|
+
return m
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def rotation_y(cls, rad):
|
|
44
|
+
c, s = math.cos(rad), math.sin(rad)
|
|
45
|
+
m = cls()
|
|
46
|
+
m._m[0, 0] = c; m._m[0, 2] = s
|
|
47
|
+
m._m[2, 0] = -s; m._m[2, 2] = c
|
|
48
|
+
return m
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def rotation_z(cls, rad):
|
|
52
|
+
c, s = math.cos(rad), math.sin(rad)
|
|
53
|
+
m = cls()
|
|
54
|
+
m._m[0, 0] = c; m._m[0, 1] = -s
|
|
55
|
+
m._m[1, 0] = s; m._m[1, 1] = c
|
|
56
|
+
return m
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def rotation_axis(cls, axis, rad):
|
|
60
|
+
a = axis.normalized().array
|
|
61
|
+
x, y, z = a
|
|
62
|
+
c, s = math.cos(rad), math.sin(rad)
|
|
63
|
+
C = 1 - c
|
|
64
|
+
m = cls()
|
|
65
|
+
m._m[0, :3] = [x*x*C + c, x*y*C - z*s, x*z*C + y*s]
|
|
66
|
+
m._m[1, :3] = [y*x*C + z*s, y*y*C + c, y*z*C - x*s]
|
|
67
|
+
m._m[2, :3] = [z*x*C - y*s, z*y*C + x*s, z*z*C + c]
|
|
68
|
+
return m
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def perspective(cls, fov_deg, aspect, near, far):
|
|
72
|
+
f = 1.0 / math.tan(math.radians(fov_deg) / 2.0)
|
|
73
|
+
m = cls(np.zeros((4, 4), dtype=np.float32))
|
|
74
|
+
m._m[0, 0] = f / aspect
|
|
75
|
+
m._m[1, 1] = f
|
|
76
|
+
m._m[2, 2] = (far + near) / (near - far)
|
|
77
|
+
m._m[2, 3] = (2 * far * near) / (near - far)
|
|
78
|
+
m._m[3, 2] = -1.0
|
|
79
|
+
return m
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def ortho(cls, left, right, bottom, top, near, far):
|
|
83
|
+
m = cls(np.zeros((4, 4), dtype=np.float32))
|
|
84
|
+
m._m[0, 0] = 2.0 / (right - left)
|
|
85
|
+
m._m[1, 1] = 2.0 / (top - bottom)
|
|
86
|
+
m._m[2, 2] = -2.0 / (far - near)
|
|
87
|
+
m._m[0, 3] = -(right + left) / (right - left)
|
|
88
|
+
m._m[1, 3] = -(top + bottom) / (top - bottom)
|
|
89
|
+
m._m[2, 3] = -(far + near) / (far - near)
|
|
90
|
+
m._m[3, 3] = 1.0
|
|
91
|
+
return m
|
|
92
|
+
|
|
93
|
+
@classmethod
|
|
94
|
+
def look_at(cls, eye, target, up=None):
|
|
95
|
+
if up is None: up = Vec3.up()
|
|
96
|
+
f = (target - eye).normalized()
|
|
97
|
+
s = f.cross(up).normalized()
|
|
98
|
+
u = s.cross(f)
|
|
99
|
+
m = cls()
|
|
100
|
+
m._m[0, :3] = s.array
|
|
101
|
+
m._m[1, :3] = u.array
|
|
102
|
+
m._m[2, :3] = -f.array
|
|
103
|
+
m._m[0, 3] = -s.dot(eye)
|
|
104
|
+
m._m[1, 3] = -u.dot(eye)
|
|
105
|
+
m._m[2, 3] = f.dot(eye)
|
|
106
|
+
return m
|
|
107
|
+
|
|
108
|
+
def __matmul__(self, other):
|
|
109
|
+
if isinstance(other, Mat4):
|
|
110
|
+
return Mat4(self._m @ other._m)
|
|
111
|
+
return self._m @ np.asarray(other, dtype=np.float32)
|
|
112
|
+
|
|
113
|
+
def transpose(self): return Mat4(self._m.T)
|
|
114
|
+
def inverse(self): return Mat4(np.linalg.inv(self._m))
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def array(self): return self._m
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def gl_bytes(self): return self._m.T.tobytes(order="C")
|
|
121
|
+
|
|
122
|
+
def __repr__(self): return f"Mat4(\n{self._m}\n)"
|
eagle/math/transform.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Transform — TRS → матрица модели. Кеш с проверкой изменений."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import numpy as np
|
|
4
|
+
from eagle.math.vec import Vec3
|
|
5
|
+
from eagle.math.mat import Mat4
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Transform:
|
|
9
|
+
def __init__(self):
|
|
10
|
+
object.__setattr__(self, "position", Vec3.zero())
|
|
11
|
+
object.__setattr__(self, "rotation", Vec3.zero()) # Эйлеровы углы в радианах
|
|
12
|
+
object.__setattr__(self, "scale", Vec3.one())
|
|
13
|
+
object.__setattr__(self, "_model", Mat4.identity())
|
|
14
|
+
object.__setattr__(self, "_last_pos", self.position.array.copy())
|
|
15
|
+
object.__setattr__(self, "_last_rot", self.rotation.array.copy())
|
|
16
|
+
object.__setattr__(self, "_last_scl", self.scale.array.copy())
|
|
17
|
+
|
|
18
|
+
def matrix(self) -> Mat4:
|
|
19
|
+
# Проверяем, изменились ли компоненты с прошлого раза
|
|
20
|
+
if (np.array_equal(self.position.array, self._last_pos)
|
|
21
|
+
and np.array_equal(self.rotation.array, self._last_rot)
|
|
22
|
+
and np.array_equal(self.scale.array, self._last_scl)):
|
|
23
|
+
return self._model
|
|
24
|
+
|
|
25
|
+
T = Mat4.translation(*self.position.xyz)
|
|
26
|
+
Rx = Mat4.rotation_x(self.rotation.x)
|
|
27
|
+
Ry = Mat4.rotation_y(self.rotation.y)
|
|
28
|
+
Rz = Mat4.rotation_z(self.rotation.z)
|
|
29
|
+
S = Mat4.scale(*self.scale.xyz)
|
|
30
|
+
object.__setattr__(self, "_model", T @ Rz @ Ry @ Rx @ S)
|
|
31
|
+
object.__setattr__(self, "_last_pos", self.position.array.copy())
|
|
32
|
+
object.__setattr__(self, "_last_rot", self.rotation.array.copy())
|
|
33
|
+
object.__setattr__(self, "_last_scl", self.scale.array.copy())
|
|
34
|
+
return self._model
|
|
35
|
+
|
|
36
|
+
def __setattr__(self, name, value):
|
|
37
|
+
object.__setattr__(self, name, value)
|