Compare commits

...
3 Commits
Author SHA1 Message Date
Leon Zielinski 0743798e47 perspektivische Projektion 2026-10-20 17:13:14 +02:00
Leon Zielinski 1f4d735bbf orthogonale Projektion 2026-10-20 16:46:39 +02:00
Leon Zielinski e4b83bd77d Paketstruktur + rot_3D 2026-10-20 16:25:53 +02:00
7 changed files with 369 additions and 1 deletions

No files matched your search

+4
View File
@@ -2,3 +2,7 @@
- Code nach PEP 8, Zeilenlänge maximal 88 Zeichen.
- Jede Funktion und Methode hat einen Docstring und Type Hints.
- Vor jedem Commit: `ruff check` und `ruff format`.
## Projektstruktur
- Quellcode liegt unter "src/matrixmania/", Tests unter "test/test_matrixmania/"
- Testpakete, Testdateien und Testfunktionen beginnen mit "test_"
+146
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env python3
"""Installiert die Abhängigkeiten aus pyproject.toml in die aktuelle Python-Umgebung.
Ersatz für "Install requirements" aus PyCharm, wenn das Projekt eine pyproject.toml
statt einer requirements.txt hat. Kein Build, keine besondere Projektstruktur nötig,
nur Standardbibliothek (Python >= 3.11).
Benutzung:
In PyCharm: Rechtsklick auf diese Datei -> Run 'install_dependencies'
Im Terminal (venv aktiv): python install_dependencies.py
Ohne Parameter wird ALLES installiert:
[project].dependencies + alle [project.optional-dependencies] + alle [dependency-groups]
Abweichendes Verhalten:
python install_dependencies.py --extra dev nur Basis + Extra "dev"
python install_dependencies.py --group test nur Basis + Gruppe "test"
python install_dependencies.py --no-main --group dev
python install_dependencies.py --list nur anzeigen, nichts installieren
python install_dependencies.py --write requirements.txt schreiben statt installieren
python install_dependencies.py --write -o reqs.txt
"""
from __future__ import annotations
import sys
if sys.version_info < (3, 11):
sys.exit("Dieses Skript braucht Python 3.11 oder neuer (tomllib). "
f"Aktuell: {sys.version.split()[0]}")
import argparse
import subprocess
import tempfile
import tomllib
from pathlib import Path
PROJECT_DIR = Path(__file__).resolve().parent
def resolve_group(groups: dict, name: str, seen: set[str] | None = None) -> list[str]:
"""Löst eine Dependency-Group inkl. {include-group = "..."} rekursiv auf."""
seen = set() if seen is None else seen
if name in seen:
raise ValueError(f"Zyklische include-group: {name}")
if name not in groups:
raise KeyError(f"Dependency-Group '{name}' nicht gefunden")
seen.add(name)
result: list[str] = []
for entry in groups[name]:
if isinstance(entry, str):
result.append(entry)
elif isinstance(entry, dict) and "include-group" in entry:
result.extend(resolve_group(groups, entry["include-group"], seen))
return result
def collect(data: dict, extras: list[str], groups: list[str], no_main: bool) -> list[str]:
project = data.get("project", {})
optional = project.get("optional-dependencies", {})
dep_groups = data.get("dependency-groups", {})
deps: list[str] = []
if not no_main:
deps.extend(project.get("dependencies", []))
for extra in extras:
if extra not in optional:
raise KeyError(f"Optional-Dependency '{extra}' nicht gefunden")
deps.extend(optional[extra])
for group in groups:
deps.extend(resolve_group(dep_groups, group))
# Duplikate entfernen, Reihenfolge behalten
return list(dict.fromkeys(d.strip() for d in deps if d.strip()))
def main() -> int:
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("-p", "--pyproject", type=Path, default=PROJECT_DIR / "pyproject.toml",
help="Pfad zur pyproject.toml (Standard: neben diesem Skript)")
p.add_argument("--extra", action="append", default=None,
help="nur diese optional-dependencies (mehrfach möglich)")
p.add_argument("--group", action="append", default=None,
help="nur diese dependency-groups (mehrfach möglich)")
p.add_argument("--no-main", action="store_true", help="[project].dependencies weglassen")
p.add_argument("--list", action="store_true", help="Abhängigkeiten nur anzeigen")
p.add_argument("--write", action="store_true", help="requirements-Datei schreiben statt installieren")
p.add_argument("-o", "--output", type=Path, default=PROJECT_DIR / "requirements.txt",
help="Zieldatei für --write")
args = p.parse_args()
if not args.pyproject.exists():
print(f"{args.pyproject} nicht gefunden", file=sys.stderr)
return 1
data = tomllib.loads(args.pyproject.read_text(encoding="utf-8"))
all_extras = list(data.get("project", {}).get("optional-dependencies", {}))
all_groups = list(data.get("dependency-groups", {}))
# Ohne --extra/--group: alles. Mit einem davon: nur das Gewählte.
selective = args.extra is not None or args.group is not None
extras = (args.extra or []) if selective else all_extras
groups = (args.group or []) if selective else all_groups
try:
deps = collect(data, extras, groups, args.no_main)
except (KeyError, ValueError) as e:
print(f"Fehler: {e.args[0]}", file=sys.stderr)
print(f" Vorhandene --extra: {', '.join(all_extras) or '(keine)'}", file=sys.stderr)
print(f" Vorhandene --group: {', '.join(all_groups) or '(keine)'}", file=sys.stderr)
return 1
parts = ([] if args.no_main else ["Basis"]) + [f"extra:{e}" for e in extras] + [f"group:{g}" for g in groups]
print(f"Auswahl: {', '.join(parts) or '(nichts)'}")
if args.list:
print("\n".join(deps) or "(keine Abhängigkeiten)")
return 0
content = f"# Automatisch erzeugt aus {args.pyproject.name} – nicht von Hand bearbeiten\n" + "\n".join(deps) + "\n"
if args.write:
args.output.write_text(content, encoding="utf-8")
print(f"{len(deps)} Abhängigkeiten -> {args.output}")
return 0
if not deps:
print("Keine Abhängigkeiten gefunden – nichts zu tun.")
return 0
if sys.prefix == sys.base_prefix:
print("WARNUNG: Es ist kein virtuelles Environment aktiv – es wird in die globale "
f"Python-Installation installiert:\n {sys.prefix}", file=sys.stderr)
if sys.stdin.isatty() and input("Trotzdem fortfahren? [j/N] ").strip().lower() not in ("j", "ja", "y", "yes"):
return 1
with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False, encoding="utf-8") as tmp:
tmp.write(content)
tmp_path = Path(tmp.name)
try:
print(f"Installiere {len(deps)} Abhängigkeiten in {sys.prefix}\n")
return subprocess.call([sys.executable, "-m", "pip", "install", "-r", str(tmp_path)])
finally:
tmp_path.unlink(missing_ok=True)
if __name__ == "__main__":
raise SystemExit(main())
+6
View File
@@ -2,3 +2,9 @@
name = "matrixmania"
version = "0.1.0"
dependencies = ["tabulate", "ruff"]
[project.optional-dependencies]
dev = ["pytest", "ruff", "pytest-cov"]
[tool.pytest.ini_options]
pythonpath = "src"
View File
Whitespace-only changes.
+160 -1
View File
@@ -1,4 +1,3 @@
import math
from tabulate import tabulate
@@ -163,6 +162,146 @@ def rot_2D(angle: float) -> list[list[float]]:
return [[c, -s], [s, c]]
def rot_3D(angle: float, axis: str) -> list[list[float]]:
"""Creates a 3×3 rotation matrix around a coordinate axis.
The rotation matrices follow the right‑hand rule and read::
R_x(θ) = [[1, 0, 0 ],
[0, cos θ, -sin θ ],
[0, sin θ, cos θ ]]
R_y(θ) = [[ cos θ, 0, sin θ],
[0, 1, 0 ],
[-sin θ, 0, cos θ]]
R_z(θ) = [[cos θ, -sin θ, 0],
[sin θ, cos θ, 0],
[0, 0, 1]]
Parameters
----------
angle : float
Rotation angle in radians (right‑hand rule).
axis : str
Rotation axis, one of ``"x"``, ``"y"`` or ``"z"``.
Returns
-------
list[list[float]]
The 3×3 rotation matrix for the requested axis.
Raises
------
ValueError
If ``angle`` is not a real number or if ``axis`` is not one of
``"x"``, ``"y"``, ``"z"``.
Notes
-----
* The implementation uses pure Python lists and does not depend on
external libraries such as NumPy.
"""
# Validate input is a real number
if not isinstance(angle, (int, float)) or isinstance(angle, bool):
raise ValueError("Angle must be a real number (radians).")
# Validate axis – only the three coordinate axes are allowed
if axis not in ("x", "y", "z"):
raise ValueError('Axis must be one of "x", "y" or "z".')
c = math.cos(angle)
s = math.sin(angle)
if axis == "x":
return [[1.0, 0.0, 0.0], [0.0, c, -s], [0.0, s, c]]
if axis == "y":
return [[c, 0.0, s], [0.0, 1.0, 0.0], [-s, 0.0, c]]
return [[c, -s, 0.0], [s, c, 0.0], [0.0, 0.0, 1.0]]
def project_ortho(
point: tuple[float, float, float], scale: float = 1
) -> tuple[float, float]:
"""Orthogonally projects a 3D point onto the 2D plane.
The viewer looks head‑on onto the plane spanned by the x‑ and
y‑axis, so the z‑coordinate (the distance of the point) is simply
ignored. The remaining coordinates are multiplied with ``scale``,
e.g. to map model coordinates onto screen pixels::
project_ortho((0.5, -0.5, 10), scale=200) # -> (100, -100)
Parameters
----------
point : tuple[float, float, float]
3D point ``(x, y, z)`` in model coordinates.
scale : float, optional
Factor the projected x‑ and y‑coordinates are multiplied with
(default ``1``, i.e. no scaling).
Returns
-------
tuple[float, float]
2D point ``(x * scale, y * scale)`` without the z‑coordinate.
Notes
-----
* Orthogonal projection means there is no perspective foreshortening:
distant points have the same size as nearby ones.
"""
x, y, _z = point
return (x * scale, y * scale)
def project_perspective(
point: tuple[float, float, float],
focal_length: float = 1.0,
scale: float = 1.0,
camera_distance: float = 4.0,
) -> tuple[float, float]:
"""Perspectively projects a 3D point onto the 2D plane.
The x‑ and y‑coordinates are divided by the depth of the point,
so farther away points appear smaller than nearby ones::
x' = x * focal_length / (z + camera_distance) * scale
y' = y * focal_length / (z + camera_distance) * scale
Parameters
----------
point : tuple[float, float, float]
3D point ``(x, y, z)`` in model coordinates.
focal_length : float, optional
How strongly the perspective is visible, like the focal
length of a camera (default ``1.0``).
scale : float, optional
Factor the projected coordinates are multiplied with
(default ``1.0``, i.e. no scaling).
camera_distance : float, optional
Offset added to the z‑coordinate, i.e. the distance of the
camera from the scene (default ``4.0``).
Returns
-------
tuple[float, float]
2D point ``(x', y')`` after perspective projection.
Raises
------
ZeroDivisionError
If ``z + camera_distance`` is ``0`` (point on the camera plane).
Notes
-----
* A larger ``focal_length`` exaggerates the perspective; distant
points shrink, nearby points grow.
"""
x, y, z = point
depth = z + camera_distance
return (x * focal_length / depth * scale, y * focal_length / depth * scale)
if __name__ == "__main__":
# Beispiel 1: 2x3 × 3x2
a = [[1, 2, 3], [4, 5, 6]]
@@ -199,6 +338,26 @@ if __name__ == "__main__":
# Matrix in Tabelle ausgeben
print(tabulate(rot_2D(1.0)))
# Beispiel 5: 3D-Rotationsmatrix (90° um die z-Achse) und Probe
r3 = rot_3D(math.pi / 2, "z")
print("\nBeispiel 5 3D-Rotationsmatrix (90°, z-Achse):")
for row in r3:
print(row)
print("Beispiel 5 Vektor (1, 0, 0) nach 90° um z:")
for row in matmul(r3, [[1.0], [0.0], [0.0]]):
print([round(v, 10) for v in row])
# Beispiel 6: Orthogonale Projektion eines 3D-Punktes auf 2D
print("\nBeispiel 6 Projektion (0.5, -0.5, 10) mit scale=200:")
print(project_ortho((0.5, -0.5, 10), scale=200))
# Beispiel 7: Perspektivische Projektion (ferne Punkte wirken kleiner)
print("\nBeispiel 7 Perspektivische Projektion von (1, 2, 3):")
print(project_perspective((1.0, 2.0, 3.0)))
print(project_perspective((1.0, 2.0, 3.0), focal_length=2, scale=100))
print("ferner Punkt (1, 2, 20) wirkt kleiner:")
print(project_perspective((1.0, 2.0, 20.0)))
# Docstring und Type Hints sind vorhanden und stimmen mit dem Code überein.
# Fehlerfälle werden behandelt
# Namen sind Aussagekräftig
View File
Whitespace-only changes.
+53
View File
@@ -0,0 +1,53 @@
import math
import pytest
from matrixmania.compute import matmul, rot_2D, rot_3D
def assert_matrix(actual: list[list[float]], expected: list[list[float]]) -> None:
"""Compares two matrices element‑wise with a float tolerance."""
assert len(actual) == len(expected)
for actual_row, expected_row in zip(actual, expected, strict=True):
assert actual_row == pytest.approx(expected_row)
def test_rot_2D_identity() -> None:
assert_matrix(rot_2D(0.0), [[1.0, 0.0], [0.0, 1.0]])
def test_rot_3D_x_90_degrees() -> None:
matrix = rot_3D(math.pi / 2, "x")
assert_matrix(matrix, [[1.0, 0.0, 0.0], [0.0, 0.0, -1.0], [0.0, 1.0, 0.0]])
def test_rot_3D_y_90_degrees() -> None:
matrix = rot_3D(math.pi / 2, "y")
assert_matrix(matrix, [[0.0, 0.0, 1.0], [0.0, 1.0, 0.0], [-1.0, 0.0, 0.0]])
def test_rot_3D_z_90_degrees() -> None:
matrix = rot_3D(math.pi / 2, "z")
assert_matrix(matrix, [[0.0, -1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]])
def test_rot_3D_zero_angle_is_identity() -> None:
identity = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]
for axis in ("x", "y", "z"):
assert_matrix(rot_3D(0.0, axis), identity)
def test_rot_3D_rotates_vector() -> None:
rotated = matmul(rot_3D(math.pi / 2, "z"), [[1.0], [0.0], [0.0]])
assert_matrix(rotated, [[0.0], [1.0], [0.0]])
def test_rot_3D_invalid_axis() -> None:
for axis in ("w", "", "X", 1, None):
with pytest.raises(ValueError):
rot_3D(1.0, axis)
def test_rot_3D_invalid_angle() -> None:
with pytest.raises(ValueError):
rot_3D("not a number", "z")