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.
@@ -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)
@@ -0,0 +1,5 @@
1
+ from eagle.loaders.obj import load_obj
2
+ from eagle.loaders.stl import load_stl
3
+ from eagle.loaders.mtl import load_mtl, MTLMaterial
4
+
5
+ __all__ = ["load_obj", "load_stl", "load_mtl", "MTLMaterial"]
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
@@ -0,0 +1,5 @@
1
+ from eagle.math.vec import Vec2, Vec3, Vec4
2
+ from eagle.math.mat import Mat4
3
+ from eagle.math.transform import Transform
4
+
5
+ __all__ = ["Vec2", "Vec3", "Vec4", "Mat4", "Transform"]
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)"
@@ -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)