Skip to content

Commit 8d9d6a4

Browse files
committed
updated to fix various issued
1 parent ce55a6f commit 8d9d6a4

8 files changed

Lines changed: 229 additions & 43 deletions

File tree

src/ngl/first_person_camera.py

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,7 @@ def __init__(self, eye: Vec3, look: Vec3, up: Vec3, fov: float) -> None:
5858
self.aspect: float = 1.2
5959
self.fov: float = fov
6060
self._update_camera_vectors()
61-
self.projection: Mat4 = self.set_projection(
62-
self.fov, self.aspect, self.near, self.far
63-
)
61+
self.projection: Mat4 = self.set_projection(self.fov, self.aspect, self.near, self.far)
6462
from .util import look_at
6563

6664
self.view: Mat4 = look_at(self.eye, self.eye + self.front, self.up)
@@ -71,9 +69,7 @@ def __str__(self) -> str:
7169
def __repr__(self) -> str:
7270
return f"Camera {self.eye} {self.look} {self.world_up} {self.fov}"
7371

74-
def process_mouse_movement(
75-
self, diffx: float, diffy: float, _constrain_pitch: bool = True
76-
) -> None:
72+
def process_mouse_movement(self, diffx: float, diffy: float, _constrain_pitch: bool = True) -> None:
7773
"""
7874
Process mouse movement to update the camera's direction vectors.
7975
@@ -117,9 +113,7 @@ def _update_camera_vectors(self) -> None:
117113

118114
self.view = look_at(self.eye, self.eye + self.front, self.up)
119115

120-
def set_projection(
121-
self, fov: float, aspect: float, near: float, far: float
122-
) -> Mat4:
116+
def set_projection(self, fov: float, aspect: float, near: float, far: float) -> Mat4:
123117
"""
124118
Set the projection matrix for the camera.
125119
@@ -157,3 +151,18 @@ def get_vp(self) -> Mat4:
157151
Mat4: The view-projection matrix.
158152
"""
159153
return self.projection @ self.view
154+
155+
def process_mouse_scroll(self, y_offset: float) -> None:
156+
"""
157+
Process mouse scroll events.
158+
159+
Args:
160+
_yoffset (float): The scroll offset.
161+
"""
162+
if self.zoom >= 1.0 and self.zoom <= 45.0:
163+
self.zoom -= y_offset
164+
if self.zoom <= 1.0:
165+
self.zoom = 1.0
166+
if self.zoom >= 45.0:
167+
self.zoom = 45.0
168+
self.projection = perspective(self.zoom, self.aspect, self.near, self.far)

src/ngl/image.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
class ImageModes(Enum):
1313
RGB = "RGB"
1414
RGBA = "RGBA"
15+
GRAY = "L"
1516

1617

1718
class Image:
@@ -22,15 +23,18 @@ def __init__(
2223
height: int = 0,
2324
mode: ImageModes = None,
2425
):
25-
logger.debug(f"Creating Image from file {filename} or {width}x{height}")
2626
if filename:
2727
self.load(filename)
28+
logger.debug(f"Creating Image from file {filename} ")
2829
else:
2930
self._width = width
3031
self._height = height
3132
self._mode = mode
3233
if mode:
33-
self._data = np.zeros((height, width, len(mode.value)), dtype=np.uint8)
34+
if mode == ImageModes.GRAY:
35+
self._data = np.zeros((height, width), dtype=np.uint8)
36+
else:
37+
self._data = np.zeros((height, width, len(mode.value)), dtype=np.uint8)
3438
else:
3539
self._data = None
3640

@@ -47,7 +51,16 @@ def load(self, filename: str) -> bool:
4751
with PILImage.open(filename) as img:
4852
self._width = img.width
4953
self._height = img.height
50-
self._mode = ImageModes(img.mode)
54+
try:
55+
self._mode = ImageModes(img.mode)
56+
except ValueError:
57+
logger.warning(f"Image mode {img.mode} not supported, converting")
58+
if img.mode == "I;16":
59+
img = img.convert("L")
60+
else:
61+
img = img.convert("RGB")
62+
self._mode = ImageModes(img.mode)
63+
5164
self._data = np.array(img)
5265
return True
5366
except Exception as e:

src/ngl/mat2.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,16 @@ def __init__(self, m=None):
2828
else:
2929
self.m = m
3030

31+
@classmethod
32+
def from_list(cls, m: list[float]):
33+
"""
34+
Initialize a 2x2 matrix from a flat list.
35+
36+
Args:
37+
m (list[float]): A flat list representing the matrix.
38+
"""
39+
return cls([m[0:2], m[2:4]])
40+
3141
def get_matrix(self) -> list[float]:
3242
"""
3343
Get the current matrix representation as a flat list in column-major order.

src/ngl/pyside_event_handling_mixin.py

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -298,17 +298,15 @@ def set_camera_state(self, state: dict) -> None:
298298
self.spin_y_face = state.get("spin_y_face", 0)
299299

300300
pos = state.get("model_position", [0, 0, 0])
301-
self.model_position.set(pos[0], pos[1], pos[2])
302-
303-
self.rotation_sensitivity = state.get(
304-
"rotation_sensitivity", self.DEFAULT_ROTATION_SENSITIVITY
305-
)
306-
self.translation_sensitivity = state.get(
307-
"translation_sensitivity", self.DEFAULT_TRANSLATION_SENSITIVITY
308-
)
309-
self.zoom_sensitivity = state.get(
310-
"zoom_sensitivity", self.DEFAULT_ZOOM_SENSITIVITY
311-
)
301+
# Handle cases where pos might have fewer than 3 elements
302+
x = pos[0] if len(pos) > 0 else 0
303+
y = pos[1] if len(pos) > 1 else 0
304+
z = pos[2] if len(pos) > 2 else 0
305+
self.model_position.set(x, y, z)
306+
307+
self.rotation_sensitivity = state.get("rotation_sensitivity", self.DEFAULT_ROTATION_SENSITIVITY)
308+
self.translation_sensitivity = state.get("translation_sensitivity", self.DEFAULT_TRANSLATION_SENSITIVITY)
309+
self.zoom_sensitivity = state.get("zoom_sensitivity", self.DEFAULT_ZOOM_SENSITIVITY)
312310

313311
# # Sync legacy attributes
314312
# self.sync_legacy_attributes()

src/ngl/random.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -128,9 +128,7 @@ def get_random_normalized_vec2() -> Vec2:
128128
return v
129129

130130
@staticmethod
131-
def get_random_point(
132-
x_range: float = 1.0, y_range: float = 1.0, z_range: float = 1.0
133-
) -> Vec3:
131+
def get_random_point(x_range: float = 1.0, y_range: float = 1.0, z_range: float = 1.0) -> Vec3:
134132
"""get a random point in 3D space defaults to +/- 1 else user defined range
135133
Args:
136134
x_range (float): the +/-x range

src/ngl/texture.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,19 @@ def format(self) -> int:
2828
return gl.GL_RGB
2929
elif self._image.mode.value == "RGBA":
3030
return gl.GL_RGBA
31+
elif self._image.mode.value == "L":
32+
return gl.GL_RED
33+
return 0
34+
35+
@property
36+
def internal_format(self) -> int:
37+
if self._image.mode:
38+
if self._image.mode.value == "RGB":
39+
return gl.GL_RGB8
40+
elif self._image.mode.value == "RGBA":
41+
return gl.GL_RGBA8
42+
elif self._image.mode.value == "L":
43+
return gl.GL_R8
3144
return 0
3245

3346
def load_image(self, filename: str) -> bool:
@@ -46,7 +59,7 @@ def set_texture_gl(self) -> int:
4659
gl.glTexImage2D(
4760
gl.GL_TEXTURE_2D,
4861
0,
49-
self.format,
62+
self.internal_format,
5063
self.width,
5164
self.height,
5265
0,

tests/test_pyside_event_handling_mixin.py

Lines changed: 146 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,7 @@
1010
import pytest
1111
from PySide6.QtCore import QPointF, Qt
1212

13-
from ngl import Vec3
14-
from ngl.pyside_event_handling_mixin import PySideEventHandlingMixin
13+
from ngl import PySideEventHandlingMixin, Vec3
1514

1615

1716
class MockEventHandlingWindow(PySideEventHandlingMixin):
@@ -350,10 +349,10 @@ def test_get_camera_state(event_window):
350349
expected = {
351350
"spin_x_face": 45,
352351
"spin_y_face": 90,
353-
"model_position": [1, 2, 3],
352+
"model_position": [pytest.approx(1), pytest.approx(2), pytest.approx(3)],
354353
"rotation_sensitivity": pytest.approx(0.8),
355354
"translation_sensitivity": pytest.approx(0.02),
356-
"zoom_sensitivity": pytest.approx(0.15) ,
355+
"zoom_sensitivity": pytest.approx(0.15),
357356
}
358357

359358
assert state == expected
@@ -374,9 +373,9 @@ def test_set_camera_state(event_window):
374373

375374
assert event_window.spin_x_face == 30
376375
assert event_window.spin_y_face == 60
377-
assert event_window.model_position.x == 5
378-
assert event_window.model_position.y == 10
379-
assert event_window.model_position.z == 15
376+
assert event_window.model_position.x == pytest.approx(5)
377+
assert event_window.model_position.y == pytest.approx(10)
378+
assert event_window.model_position.z == pytest.approx(15)
380379
assert event_window.rotation_sensitivity == pytest.approx(0.75)
381380
assert event_window.translation_sensitivity == pytest.approx(0.05)
382381
assert event_window.zoom_sensitivity == pytest.approx(0.25)
@@ -393,9 +392,9 @@ def test_set_camera_state_partial(event_window):
393392

394393
assert event_window.spin_x_face == 15
395394
assert event_window.spin_y_face == 0 # Default
396-
assert event_window.model_position.x == 2
397-
assert event_window.model_position.y == 4
398-
assert event_window.model_position.z == 6
395+
assert event_window.model_position.x == pytest.approx(2)
396+
assert event_window.model_position.y == pytest.approx(4)
397+
assert event_window.model_position.z == pytest.approx(6)
399398
assert event_window.rotation_sensitivity == PySideEventHandlingMixin.DEFAULT_ROTATION_SENSITIVITY
400399
assert event_window.translation_sensitivity == PySideEventHandlingMixin.DEFAULT_TRANSLATION_SENSITIVITY
401400
assert event_window.zoom_sensitivity == PySideEventHandlingMixin.DEFAULT_ZOOM_SENSITIVITY
@@ -408,9 +407,9 @@ def test_set_camera_state_empty_dict(event_window):
408407
# Should use all defaults
409408
assert event_window.spin_x_face == 0
410409
assert event_window.spin_y_face == 0
411-
assert event_window.model_position.x == 0
412-
assert event_window.model_position.y == 0
413-
assert event_window.model_position.z == 0
410+
assert event_window.model_position.x == pytest.approx(0)
411+
assert event_window.model_position.y == pytest.approx(0)
412+
assert event_window.model_position.z == pytest.approx(0)
414413
assert event_window.rotation_sensitivity == PySideEventHandlingMixin.DEFAULT_ROTATION_SENSITIVITY
415414
assert event_window.translation_sensitivity == PySideEventHandlingMixin.DEFAULT_TRANSLATION_SENSITIVITY
416415
assert event_window.zoom_sensitivity == PySideEventHandlingMixin.DEFAULT_ZOOM_SENSITIVITY
@@ -439,9 +438,9 @@ def test_camera_state_round_trip(event_window):
439438
# Check values are restored
440439
assert event_window.spin_x_face == 25
441440
assert event_window.spin_y_face == 50
442-
assert event_window.model_position.x == 3
443-
assert event_window.model_position.y == 6
444-
assert event_window.model_position.z == 9
441+
assert event_window.model_position.x == pytest.approx(3)
442+
assert event_window.model_position.y == pytest.approx(6)
443+
assert event_window.model_position.z == pytest.approx(9)
445444
assert event_window.rotation_sensitivity == pytest.approx(0.6)
446445
assert event_window.translation_sensitivity == pytest.approx(0.03)
447446
assert event_window.zoom_sensitivity == pytest.approx(0.12)
@@ -509,3 +508,134 @@ def test_event_handling_target_protocol():
509508
assert hasattr(window, "close")
510509
assert callable(window.update)
511510
assert callable(window.close)
511+
512+
513+
def test_mouseMoveEvent_rotation_without_left_button(event_window):
514+
"""Test mouse movement during rotation mode but without left button pressed"""
515+
# Setup rotation state
516+
event_window.rotate = True
517+
event_window.original_x_rotation = 100
518+
event_window.original_y_rotation = 200
519+
520+
event = Mock()
521+
event.buttons.return_value = Qt.NoButton # No button pressed
522+
event.position.return_value = QPointF(120, 180)
523+
524+
event_window.mouseMoveEvent(event)
525+
526+
# Should not update rotation values
527+
assert event_window.spin_x_face == 0
528+
assert event_window.spin_y_face == 0
529+
assert event_window.update_called is False
530+
531+
532+
def test_mouseMoveEvent_translation_without_right_button(event_window):
533+
"""Test mouse movement during translation mode but without right button pressed"""
534+
# Setup translation state
535+
event_window.translate = True
536+
event_window.original_x_pos = 100
537+
event_window.original_y_pos = 200
538+
539+
event = Mock()
540+
event.buttons.return_value = Qt.NoButton # No button pressed
541+
event.position.return_value = QPointF(110, 190)
542+
543+
event_window.mouseMoveEvent(event)
544+
545+
# Should not update position
546+
assert event_window.model_position.x == 0
547+
assert event_window.model_position.y == 0
548+
assert event_window.update_called is False
549+
550+
551+
def test_mousePressEvent_middle_button(event_window):
552+
"""Test middle mouse button press (should be ignored)"""
553+
event = Mock()
554+
event.button.return_value = Qt.MiddleButton
555+
event.position.return_value = QPointF(100, 200)
556+
557+
event_window.mousePressEvent(event)
558+
559+
# Should not change any state
560+
assert event_window.rotate is False
561+
assert event_window.translate is False
562+
563+
564+
def test_mouseReleaseEvent_middle_button(event_window):
565+
"""Test middle mouse button release (should be ignored)"""
566+
event_window.rotate = True
567+
event_window.translate = True
568+
569+
event = Mock()
570+
event.button.return_value = Qt.MiddleButton
571+
572+
event_window.mouseReleaseEvent(event)
573+
574+
# Should not change state
575+
assert event_window.rotate is True
576+
assert event_window.translate is True
577+
578+
579+
def test_setup_event_handling_with_none_position():
580+
"""Test setup_event_handling with None initial position"""
581+
window = MockEventHandlingWindow()
582+
window.setup_event_handling(initial_position=None)
583+
584+
assert window.model_position.x == 0
585+
assert window.model_position.y == 0
586+
assert window.model_position.z == 0
587+
588+
589+
def test_wheelEvent_priority_y_over_x(event_window):
590+
"""Test that y delta takes priority over x delta when both are non-zero"""
591+
event_window.zoom_sensitivity = 0.5
592+
initial_z = event_window.model_position.z
593+
594+
event = Mock()
595+
angle_delta = Mock()
596+
angle_delta.y.return_value = 120 # Positive y delta
597+
angle_delta.x.return_value = -120 # Negative x delta
598+
event.angleDelta.return_value = angle_delta
599+
600+
event_window.wheelEvent(event)
601+
602+
# Should use y delta (120), not x delta (-120)
603+
assert event_window.model_position.z == initial_z + 0.5
604+
assert event_window.update_called is True
605+
606+
607+
def test_set_camera_state_partial_model_position():
608+
"""Test set_camera_state with partial model position data"""
609+
window = MockEventHandlingWindow()
610+
window.setup_event_handling()
611+
612+
state = {
613+
"spin_x_face": 25,
614+
"model_position": [7, 8], # Only 2 values instead of 3
615+
}
616+
617+
# This should handle the IndexError gracefully
618+
window.set_camera_state(state)
619+
620+
assert window.spin_x_face == 25
621+
assert window.model_position.x == pytest.approx(7)
622+
assert window.model_position.y == pytest.approx(8)
623+
# z should remain default
624+
assert window.model_position.z == pytest.approx(0)
625+
626+
627+
def test_set_camera_state_empty_model_position():
628+
"""Test set_camera_state with empty model position list"""
629+
window = MockEventHandlingWindow()
630+
window.setup_event_handling()
631+
632+
state = {
633+
"model_position": [], # Empty list
634+
}
635+
636+
# This should handle gracefully and use defaults
637+
window.set_camera_state(state)
638+
639+
assert window.model_position.x == pytest.approx(0)
640+
assert window.model_position.y == pytest.approx(0)
641+
assert window.model_position.z == pytest.approx(0)

0 commit comments

Comments
 (0)