Source code for zooui.objects.mediaobjects.mediaobject
## ZooUI - Zooming User Interface
## Copyright (C) 2009 David Roberts <d@vidr.cc>
##
## This program is free software; you can redistribute it and/or
## modify it under the terms of the GNU General Public License
## as published by the Free Software Foundation; either version 3
## of the License, or (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License
## along with this program; if not, see <https://www.gnu.org/licenses/>.
"""Media to be displayed in the ZUI (abstract base class)."""
import math
from typing import Any
## Performance optimization note:
## Phase 2 optimizations replace 2**x with math.exp2(x) (1.85x faster)
## and math.log(x, 2) with math.log2(x) (2x faster) throughout the codebase.
## These changes are performance-critical for zoom operations.
from zooui.objects.physicalobject import PhysicalObject
[docs]
class MediaObject(PhysicalObject):
transparent: bool = False # Set True by subclasses that support transparency
"""
Constructor :
MediaObject(media_id, scene)
Parameters :
media_id['string'], scene['Scene']
Media_object(media_id, scene) --> PhysicalObject
MediaObject objects are used to represent media that can be rendered in
the ZUI.
Screen view it's fixed unless user change mainwindow size on the screen.
Both scene and MediaObjects have their own reference systems so that zooms
can be applied bot to Scene and Mediaobject indipendently trough their
reference system transformation.
You can think about it as fixed window looking at a scene that can strecth or
shrink beneath it, with this stretch or shrink always having it's origin
at the Scene center for scene zoom and MediaObject center for the mediobject
zoom as at the same time individual mediaobject can also strecth
or shrink. The fixed window can then move on the 2d plane bringing objects
into view.
World::
--------------------------------------->
| Scene
| @ ------------------------------+--->
| | ViewPort MediaObj |
| | (Screen View) *-------+--> |
| | | & | |
| | % +-------" |
| | | |
| | ∨ |
| | |
| +-------------------------------#
| |
| ∨
∨
Legend::
(All attributes are relative to screen view)
* -> MediaObject.topleft()
" -> MediaObject.bottomright()
& -> MediaObject.center()
# -> Scene.viewport_size()
% -> Scene.center()
@ -> Scene.origin()
MediaObject topleft coordinates relative to screen view are given by::
MediaObject.topleft[0-1] = self._scene.origin[0-1] + self.pos[0-1] * (2 ** self._scene.zoomlevel)
Where *self.pos[0-1]* it's MediaObject position relative to Scene reference
system wich get's scaled by math.exp2() of scene zoom level
MediaObject centre coordinates relative to screen view are given firstly by
calculating image center coordinates relative to Scene reference coordinates::
C_s[0-1] = self.pos[0-1] + self._centre[0-1] * math.exp2(self._z)
Where *self._centre[0-1]* are center coordinates relative to the mediaobject
frame of reference and *self._z* it's MediaObject reference frame scaling
(zoom).
Take note that self.pos[0-1] dosen't get to be scaled by self._z as that
position it's relative to Scene reference frame.
Then we can calculate MediaObject centre coordinates relative to screen view as::
MediaObject.centre[0-1] = self._scene.origin[0] + C_s[0-1] * math.exp2(self._scene.zoomlevel)
"""
def __init__(self, media_id: str, scene: Any) -> None:
"""
Create a new MediaObject from the media identified by media_id,
and the parent Scene referenced by scene.
Initializes PhysicalObject center, position and velocity attributes,
then sets the _media_id and _scene instance variables.
"""
# initialize mediobject centre, position and velocity
PhysicalObject.__init__(self)
self._media_id: str = media_id
self._scene: Any = scene
[docs]
def render(self, painter: Any, mode: int) -> None:
"""
Method :
MediaObject.render(painter, mode)
Parameters :
painter : QPainter
mode : int
MediaObject.render(painter, mode) --> None
Render the media using the given `painter` and rendering `mode`.
Precondition: `mode` is equal to one of the constants defined in
:class:`RenderMode`
"""
pass
[docs]
def is_size_visible(self, mode: int) -> bool:
"""
Check if object is visible based on size and render mode.
Args:
mode: RenderMode (Invisible, Draft, or HighQuality)
Returns:
True if object should be rendered, False if too small,
too large, or mode is Invisible.
Base implementation returns True unless mode is Invisible.
Subclasses should override to add size-based visibility checks.
"""
return mode != RenderMode.Invisible
[docs]
def move(self, dx: float, dy: float) -> None:
"""
Method :
MediaObject.move(dx, dy)
Parameters :
dx : float
dy : float
MediaObject.move(dx, dy) --> None
Move the image relative to the scene, where (`dx`,`dy`) is given as
an on-screen distance.
"""
# self._x and self._y correspond to self.pos[0] and self.pos[1], but
# mediaobject.pos dosen't support += operation
self._x += dx * (2**-self._scene.zoomlevel)
self._y += dy * (2**-self._scene.zoomlevel)
[docs]
def zoom(self, amount: float) -> None:
"""
Method :
MediaObject.zoom(amount)
Parameters :
amount : float
MediaObject.zoom(amount) --> None
Zoom by the given `amount` with the centre maintaining its position
on the screen.
"""
## C_s is the scene coordinates of the centre
## C_i is the image coordinates of the centre
## P is the onscreen position of the centre
## zoomlevel_i' = zoomlevel_i + amount
## P = scene.origin + C_s * math.exp2(zoomlevel_s)
## => C_s = (P - scene.origin) * math.exp2(-zoomlevel_s)
## C_s = self.pos + C_i * math.exp2(zoomlevel_i)
## => C_i = (C_s - self.pos) * math.exp2(-zoomlevel_i)
## C_s' = self.pos' + C_i * math.exp2(zoomlevel_i')
## = self.pos' + (C_s - self.pos)
## * math.exp2(zoomlevel_i'-zoomlevel_i)
## solving for C_s = C_s' yields:
## self.pos' = C_s - (C_s - self.pos) * math.exp2(amount)
# Px, Py = self.centre
# C_sx = (Px - self._scene.origin[0]) * math.exp2(-self._scene.zoomlevel)
# C_sy = (Py - self._scene.origin[1]) * math.exp2(-self._scene.zoomlevel)
C_ix: float
C_iy: float
C_ix, C_iy = self._centre
C_sx: float = self._x + C_ix * math.exp2(self._z)
C_sy: float = self._y + C_iy * math.exp2(self._z)
# Calculate new zoomlevel and validate it
new_zoomlevel = self._z + amount
if self._zoom_manager:
new_zoomlevel = self._zoom_manager.validate(new_zoomlevel)
# Recalculate amount after clamping
amount = new_zoomlevel - self._z
self._x = C_sx - (C_sx - self._x) * math.exp2(amount)
self._y = C_sy - (C_sy - self._y) * math.exp2(amount)
self._z = new_zoomlevel
[docs]
def hides(self, other: "MediaObject") -> bool:
"""
Method :
MediaObject.hides(other)
Parameters :
other : MediaObject
MediaObject.hides(other) --> bool
Returns True iff `other` is completely hidden behind `self` on the
screen.
"""
if self.transparent:
## nothing can be hidden behind a transparent object
return False
viewport_size: tuple[float, float] = self._scene.viewport_size
s_left: float
s_top: float
s_left, s_top = self.topleft
s_right: float
s_bottom: float
s_right, s_bottom = self.bottomright
## clamp values
s_left = max(0, min(s_left, viewport_size[0]))
s_top = max(0, min(s_top, viewport_size[1]))
s_right = max(0, min(s_right, viewport_size[0]))
s_bottom = max(0, min(s_bottom, viewport_size[1]))
o_left: float
o_top: float
o_left, o_top = other.topleft
o_right: float
o_bottom: float
o_right, o_bottom = other.bottomright
## clamp values
o_left = max(0, min(o_left, viewport_size[0]))
o_top = max(0, min(o_top, viewport_size[1]))
o_right = max(0, min(o_right, viewport_size[0]))
o_bottom = max(0, min(o_bottom, viewport_size[1]))
return o_left >= s_left and o_top >= s_top and o_right <= s_right and o_bottom <= s_bottom
[docs]
def fit(self, bbox: tuple[float, float, float, float]) -> None:
"""
Method :
MediaObject.fit(bbox)
Parameters :
bbox : Tuple[float, float, float, float]
MediaObject.fit(bbox) --> None
Move and resize the image such that it is the greatest size possible
whilst fitting inside and centred in the onscreen bounding box `bbox`
(x1,y1,x2,y2).
"""
box_x: float
box_y: float
box_x2: float
box_y2: float
box_x, box_y, box_x2, box_y2 = list(map(float, bbox))
box_w: float = box_x2 - box_x
box_h: float = box_y2 - box_y
# print('box_y',box_y)
w: float
h: float
w, h = self.onscreen_size
# Apply minimum safe values to prevent division by zero
w = max(w, 1.0) # Minimum 1 pixel width
h = max(h, 1.0) # Minimum 1 pixel height
scale: float
target_x: float
target_y: float
if w / h > box_w / box_h:
## need to fit width
scale = box_w / w
target_x = box_x
target_y = box_y + box_h / 2 - (h * scale) / 2
else:
## need to fit height
scale = box_h / h
target_x = box_x + box_w / 2 - (w * scale) / 2
target_y = box_y
# Safe log calculation with bounds checking
try:
if scale <= 0:
scale = 0.001 # Minimum safe scale (0.1%)
self.zoomlevel += math.log2(scale)
except (ValueError, ZeroDivisionError):
# Fallback: don't change zoomlevel if scale is invalid
pass
self._x = (target_x - self._scene.origin[0]) * (2**-self._scene.zoomlevel)
self._y = (target_y - self._scene.origin[1]) * (2**-self._scene.zoomlevel)
def __cmp__(self, other: "MediaObject") -> int:
"""
Method :
MediaObject.__cmp__(other)
Parameters :
other : MediaObject
MediaObject.__cmp__(other) --> int
Compare two MediaObject instances based on their onscreen area.
Returns 0 if self is other, -1 if self has smaller onscreen area,
and 1 if self has larger onscreen area.
"""
if self is other:
return 0
elif self.onscreen_area < other.onscreen_area:
return -1
else:
return 1
@property
def media_id(self) -> str:
"""
Property :
MediaObject.media_id
Parameters :
None
MediaObject.media_id --> str
The object's media_id.
"""
return self._media_id
@property
def scale(self) -> float:
"""
Property :
MediaObject.scale
Parameters :
None
MediaObject.scale --> float
The factor by which each dimension of the image should be scaled
when rendering it to the screen.
"""
return float(2 ** (self._scene.zoomlevel + self.zoomlevel))
@property
def topleft(self) -> tuple[float, float]:
"""
Property :
MediaObject.topleft
Parameters :
None
MediaObject.topleft --> Tuple[float, float]
The on-screen position of the top-left corner of the image.
self._scene.origin -> the world-space X coordinate of the top-left of the
screen view (the camera/view origin)
self.pos -> the object's position inside the view before applying zoom
(a coordinate in the camera's internal coordinate system)
here self.pos() is mediaobject
"""
x: float = self._scene.origin[0] + self.pos[0] * (2**self._scene.zoomlevel)
y: float = self._scene.origin[1] + self.pos[1] * (2**self._scene.zoomlevel)
return (x, y)
@property
def onscreen_size(self) -> tuple[float, float]:
"""
Property :
MediaObject.onscreen_size
Parameters :
None
MediaObject.onscreen_size --> Tuple[float, float]
The on-screen size of the image.
This gets inherited by higher order classes (StringMediaObject,
TiledMediaObject, etc.)
"""
pass # type: ignore[empty-body,return]
@property
def bottomright(self) -> tuple[float, float]:
"""
Property :
MediaObject.bottomright
Parameters :
None
MediaObject.bottomright --> Tuple[float, float]
The on-screen position of the bottom-right corner of the image.
"""
o: tuple[float, float] = self.topleft
s: tuple[float, float] = self.onscreen_size
x: float = o[0] + s[0]
y: float = o[1] + s[1]
return (x, y)
@property
def onscreen_area(self) -> float:
"""
Property :
MediaObject.onscreen_area
Parameters :
None
MediaObject.onscreen_area --> float
The number of pixels the image occupies on the screen.
"""
w: float
h: float
w, h = self.onscreen_size
return w * h
def __get_pos(self) -> tuple[float, float]:
"""
Method :
__get_pos
Parameters :
None
__get_pos --> Tuple[float, float]
Return MediaObject position (_x, _y).
"""
return (self._x, self._y)
def __set_pos(self, pos: tuple[float, float]) -> None:
"""
Method :
__set_pos(pos)
Parameters :
pos : Tuple[float, float]
__set_pos --> None
Set self._x, self._y variables to MediaObject position.
"""
self._x, self._y = pos
pos = property(__get_pos, __set_pos)
"""Creating MediaObject.pos property with __get_pos as
getter and __set_pos as setter"""
def __get_centre(self) -> tuple[float, float]:
"""
Method :
__get_centre
Parameters :
None
__get_centre --> Tuple[float, float]
Get the on-screen position of the MediaObject center.
Converts image-coordinate C_i to screen-coordinate P through
scene-coordinate C_s using the formulas::
P = scene.origin + C_s * math.exp2(zoomlevel_s)
C_s = self.pos + C_i * math.exp2(zoomlevel_i)
"""
## we need to convert image-coordinate C_i to
## screen-coordinate P (through scene-coordinate C_s):
## P = scene.origin + C_s * math.exp2(zoomlevel_s)
## C_s = self.pos + C_i * math.exp2(zoomlevel_i)
# This are the image coordinates relative to scene coordinates.
C_s: tuple[float, float] = (
self.pos[0] + self._centre[0] * math.exp2(self._z),
self.pos[1] + self._centre[1] * math.exp2(self._z),
)
#
return (
self._scene.origin[0] + C_s[0] * math.exp2(self._scene.zoomlevel),
self._scene.origin[1] + C_s[1] * math.exp2(self._scene.zoomlevel),
)
def __set_centre(self, centre: tuple[float, float]) -> None:
"""
Method :
__set_centre(centre)
Parameters :
centre : Tuple[float, float]
__set_centre --> None
Set the on-screen position of the MediaObject center.
Converts screen-coordinate P to image-coordinate C_i through
scene-coordinate C_s using the formulas::
P = scene.origin + C_s * math.exp2(zoomlevel_s)
C_s = self.pos + C_i * math.exp2(zoomlevel_i)
"""
## we need to convert screen-coordinate P to
## image-coordinate C_i (through scene-coordinate C_s):
## P = scene.origin + C_s * math.exp2(zoomlevel_s)
## => C_s = (P - scene.origin) * math.exp2(-zoomlevel_s)
## C_s = self.pos + C_i * math.exp2(zoomlevel_i)
## => C_i = (C_s - self.pos) * math.exp2(-zoomlevel_i)
C_s: tuple[float, float] = (
(centre[0] - self._scene.origin[0]) * math.exp2(-self._scene.zoomlevel),
(centre[1] - self._scene.origin[1]) * math.exp2(-self._scene.zoomlevel),
)
self._centre = ((C_s[0] - self._x) * math.exp2(-self._z), (C_s[1] - self._y) * math.exp2(-self._z))
centre = property(__get_centre, __set_centre)
"""Creating MediaObject.centre property with __get_centre as
getter and __set_centre as setter"""
def __str__(self) -> str:
"""
Method :
MediaObject.__str__()
Parameters :
None
MediaObject.__str__() --> str
Return a string representation of the MediaObject.
Format: ClassName(media_id)
"""
return f"{type(self).__name__}({self._media_id})"
def __repr__(self) -> str:
"""
Method :
MediaObject.__repr__()
Parameters :
None
MediaObject.__repr__() --> str
Return a detailed string representation of the MediaObject.
Format: ClassName(repr(media_id))
"""
return f"{type(self).__name__}({self._media_id!r})"
[docs]
def to_dict(self) -> dict[str, Any]:
"""
Method :
MediaObject.to_dict()
Parameters :
None
MediaObject.to_dict() --> Dict[str, Any]
Serialize object to dictionary for copying.
"""
return {
"type": self.__class__.__name__,
"media_id": self._media_id,
"position": (self._x, self._y, self._z),
"velocity": (self.vx, self.vy, self.vz),
}
[docs]
@classmethod
def from_dict(cls, data: dict[str, Any], scene: Any) -> "MediaObject":
"""
Method :
MediaObject.from_dict(data, scene)
Parameters :
data : Dict[str, Any]
scene : Any
MediaObject.from_dict(data, scene) --> MediaObject
Create MediaObject from serialized data.
Subclasses must override this method.
"""
raise NotImplementedError("Subclasses must implement from_dict")
[docs]
class LoadError(Exception):
"""
Exception :
LoadError
LoadError exception is raised when there is an error loading
or processing media content. This can occur during file loading,
format conversion, or media initialization.
"""
pass
[docs]
class RenderMode:
"""
Class :
RenderMode
RenderMode is a namespace class that defines constants used to
indicate the rendering mode for MediaObjects.
Constants:
Invisible : int = 0
MediaObject should not be rendered at all
Draft : int = 1
MediaObject should be rendered in draft/fast mode
HighQuality : int = 2
MediaObject should be rendered in high quality mode
"""
Invisible: int = 0
Draft: int = 1
HighQuality: int = 2