33from .uniforms import BaseBinding , Binding , UniformBase , ct
44from .utils import read_shader_file , Lock
55
6+ # Shared frustum constants. The camera sits at distance FOCAL from the world
7+ # origin (the view matrix translates z by -FOCAL); content is scaled to ~unit
8+ # size by the transform, so NEAR/FAR comfortably bracket it.
9+ NEAR = 0.1
10+ FAR = 10
11+ FOV = 45
12+ FOCAL = 3
13+
14+
15+ def _projection_matrix (aspect , orthographic = False , zoom = 1.0 ):
16+ """Build the 4x4 projection matrix for the current aspect ratio.
17+
18+ Perspective and orthographic share the same vertical field of view: the
19+ orthographic half-height is the perspective half-height measured at the
20+ focal plane (distance FOCAL from the camera), so both projections render the
21+ centered content at the same on-screen size.
22+ """
23+ if orthographic :
24+ top = FOCAL * np .tan (np .radians (FOV ) / 2 ) * zoom
25+ height = 2 * top
26+ width = aspect * height
27+
28+ x = 2 / width
29+ y = 2 / height
30+ c = - 1 / (FAR - NEAR )
31+ d = - NEAR / (FAR - NEAR )
32+
33+ return np .array (
34+ [
35+ [x , 0 , 0 , 0 ],
36+ [0 , y , 0 , 0 ],
37+ [0 , 0 , c , d ],
38+ [0 , 0 , 0 , 1 ],
39+ ]
40+ )
41+
42+ top = NEAR * (np .tan (np .radians (FOV ) / 2 )) * zoom
43+ height = 2 * top
44+ width = aspect * height
45+ left = - 0.5 * width
46+ right = left + width
47+ bottom = top - height
48+
49+ x = 2 * NEAR / (right - left )
50+ y = 2 * NEAR / (top - bottom )
51+
52+ a = (right + left ) / (right - left )
53+ b = (top + bottom ) / (top - bottom )
54+
55+ c = - FAR / (FAR - NEAR )
56+ d = (- FAR * NEAR ) / (FAR - NEAR )
57+
58+ return np .array (
59+ [
60+ [x , 0 , a , 0 ],
61+ [0 , y , b , 0 ],
62+ [0 , 0 , c , d ],
63+ [0 , 0 , - 1 , 0 ],
64+ ]
65+ )
66+
667
768class CameraUniforms (UniformBase ):
869 """Uniforms class, derived from ctypes.Structure to ensure correct memory layout"""
@@ -21,49 +82,24 @@ class CameraUniforms(UniformBase):
2182 ("dpr" , ct .c_float ),
2283 ]
2384
24- def update (self , transform , canvas , write_buffer = True ):
85+ def update (self , transform , canvas , write_buffer = True , orthographic = False ):
2586 """Recompute projection/model-view matrices from transform and canvas dimensions.
2687
2788 Returns (model_view_proj, model_view) matrices, or (None, None) if canvas is unavailable.
2889
2990 With ``write_buffer`` False the matrices are computed but the GPU buffer
3091 is not uploaded (JS-engine mode, where the browser owns the camera).
92+
93+ With ``orthographic`` True a parallel projection is used instead of the
94+ perspective default.
3195 """
3296 if canvas is None or canvas .height == 0 :
3397 return None , None
3498
35- near = 0.1
36- far = 10
37- fov = 45
3899 aspect = canvas .width / canvas .height
100+ proj_mat = _projection_matrix (aspect , orthographic )
39101
40- zoom = 1.0
41- top = near * (np .tan (np .radians (fov ) / 2 )) * zoom
42- height = 2 * top
43- width = aspect * height
44- left = - 0.5 * width
45- right = left + width
46- bottom = top - height
47-
48- x = 2 * near / (right - left )
49- y = 2 * near / (top - bottom )
50-
51- a = (right + left ) / (right - left )
52- b = (top + bottom ) / (top - bottom )
53-
54- c = - far / (far - near )
55- d = (- far * near ) / (far - near )
56-
57- proj_mat = np .array (
58- [
59- [x , 0 , a , 0 ],
60- [0 , y , b , 0 ],
61- [0 , 0 , c , d ],
62- [0 , 0 , - 1 , 0 ],
63- ]
64- )
65-
66- view_mat = np .array ([[1 , 0 , 0 , 0 ], [0 , 1 , 0 , 0 ], [0 , 0 , 1 , - 3 ], [0 , 0 , 0 , 1 ]])
102+ view_mat = np .array ([[1 , 0 , 0 , 0 ], [0 , 1 , 0 , 0 ], [0 , 0 , 1 , - FOCAL ], [0 , 0 , 0 , 1 ]])
67103 model_view = view_mat @ transform .mat
68104 model_view_proj = proj_mat @ model_view
69105 normal_mat = np .linalg .inv (model_view )
@@ -218,6 +254,7 @@ class Camera:
218254
219255 def __init__ (self ):
220256 self .transform = Transform ()
257+ self .orthographic = False
221258 self ._observers = []
222259 self ._observers_lock = Lock ()
223260 self ._is_moving = False
@@ -226,8 +263,9 @@ def __init__(self):
226263 self ._dblclick_handlers = {}
227264
228265 def __setstate__ (self , state ):
229- """Restore pickled camera state (only the transform )."""
266+ """Restore pickled camera state (transform and projection mode )."""
230267 self .transform = state ["transform" ]
268+ self .orthographic = state .get ("orthographic" , False )
231269 self ._observers = []
232270 self ._observers_lock = Lock ()
233271 self ._is_moving = False
@@ -237,7 +275,18 @@ def __setstate__(self, state):
237275
238276 def __getstate__ (self ):
239277 """Return a minimal picklable representation of the camera."""
240- return {"transform" : self .transform }
278+ return {"transform" : self .transform , "orthographic" : self .orthographic }
279+
280+ def set_orthographic (self , value : bool ):
281+ """Switch between orthographic and perspective projection.
282+
283+ Notifies observers (which pushes the change to the JS engine in
284+ JS-engine mode) only when the mode actually changes.
285+ """
286+ value = bool (value )
287+ if value != self .orthographic :
288+ self .orthographic = value
289+ self ._notify_observers ()
241290
242291 def register_observer (self , callback ):
243292 """Register a callback to be called when the camera transform changes.
0 commit comments