Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 81 additions & 49 deletions arcade/texture/texture.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import hashlib
from typing import Any, Dict, Optional, Tuple, Type, Union, TYPE_CHECKING
from pathlib import Path
from weakref import WeakSet

import PIL.Image
import PIL.ImageOps
Expand All @@ -28,6 +29,7 @@
if TYPE_CHECKING:
from arcade.sprite import Sprite
from arcade.sprite_list import SpriteList
from arcade import TextureAtlas

LOG = logging.getLogger(__name__)

Expand Down Expand Up @@ -143,6 +145,7 @@ class Texture:
"_file_path",
"_crop_values",
"_properties",
"_atlas_refs",
"__weakref__",
)
def __init__(
Expand Down Expand Up @@ -190,6 +193,9 @@ def __init__(
self._update_cache_names()
self._hit_box_points: PointList = hit_box_points or self._calculate_hit_box_points()

# Track what atlases the image is in
self._atlas_refs: WeakSet["TextureAtlas"] = WeakSet()

# Optional filename for debugging
self._file_path: Optional[Path] = None
self._crop_values: Optional[Tuple[int, int, int, int]] = None
Expand Down Expand Up @@ -484,14 +490,7 @@ def create_empty(
hit_box_algorithm=hitbox.algo_bounding_box,
)

def remove_from_cache(self, ignore_error: bool = True) -> None:
"""
Remove this texture from the cache.

:param bool ignore_error: If True, ignore errors if the texture is not in the cache
:return: None
"""
_cache.texture_cache.delete(self)
# ----- Transformations -----

def flip_left_right(self) -> "Texture":
"""
Expand Down Expand Up @@ -656,22 +655,6 @@ def transform(
texture._update_cache_names()
return texture

@staticmethod
def validate_crop(image: PIL.Image.Image, x: int, y: int, width: int, height: int) -> None:
"""
Validate the crop values for a given image.
"""
if x < 0 or y < 0 or width < 0 or height < 0:
raise ValueError(f"crop values must be positive: {x}, {y}, {width}, {height}")
if x >= image.width:
raise ValueError(f"x position is outside of texture: {x}")
if y >= image.height:
raise ValueError(f"y position is outside of texture: {y}")
if x + width - 1 >= image.width:
raise ValueError(f"width is outside of texture: {width + x}")
if y + height - 1 >= image.height:
raise ValueError(f"height is outside of texture: {height + y}")

def crop(
self,
x: int,
Expand Down Expand Up @@ -713,36 +696,53 @@ def crop(
texture.crop_values = (x, y, width, height)
return texture

# ------------------------------------------------------------
# Comparison and hash functions so textures can work with sets
# A texture's uniqueness is simply based on the name
# ------------------------------------------------------------
def __hash__(self) -> int:
return hash(self.cache_name)
# ------ Atlas functions ------

def __eq__(self, other) -> bool:
if other is None:
return False
if not isinstance(other, self.__class__):
return False
return self.cache_name == other.cache_name
def remove_from_atlases(self) -> None:
"""
Remove this texture from all atlases.
"""
for atlas in self._atlas_refs:
atlas.remove(self)

def __ne__(self, other) -> bool:
if other is None:
return True
if not isinstance(other, self.__class__):
return True
return self.cache_name != other.cache_name
def add_atlas_ref(self, atlas: "TextureAtlas") -> None:
"""
Add a reference to an atlas that this texture is in.
"""
self._atlas_refs.add(atlas)

def __repr__(self) -> str:
cache_name = getattr(self, "cache_name", None)
return f"<Texture cache_name={cache_name}>"
def remove_atlas_ref(self, atlas: "TextureAtlas") -> None:
"""
Remove a reference to an atlas that this texture is in.
"""
self._atlas_refs.remove(atlas)

def __del__(self):
pass
# print("DELETE", self)
# ----- Utility functions -----

# ------------------------------------------------------------
def remove_from_cache(self, ignore_error: bool = True) -> None:
"""
Remove this texture from the cache.

:param bool ignore_error: If True, ignore errors if the texture is not in the cache
:return: None
"""
_cache.texture_cache.delete(self)

@staticmethod
def validate_crop(image: PIL.Image.Image, x: int, y: int, width: int, height: int) -> None:
"""
Validate the crop values for a given image.
"""
if x < 0 or y < 0 or width < 0 or height < 0:
raise ValueError(f"crop values must be positive: {x}, {y}, {width}, {height}")
if x >= image.width:
raise ValueError(f"x position is outside of texture: {x}")
if y >= image.height:
raise ValueError(f"y position is outside of texture: {y}")
if x + width - 1 >= image.width:
raise ValueError(f"width is outside of texture: {width + x}")
if y + height - 1 >= image.height:
raise ValueError(f"height is outside of texture: {height + y}")

def _calculate_hit_box_points(self) -> PointList:
"""
Expand All @@ -762,6 +762,8 @@ def _calculate_hit_box_points(self) -> PointList:

return points

# ----- Drawing functions -----

def _create_cached_sprite(self):
from arcade.sprite import Sprite
from arcade.sprite_list import SpriteList
Expand Down Expand Up @@ -835,3 +837,33 @@ def draw_scaled(
self._sprite.angle = angle
self._sprite.alpha = alpha
self._sprite_list.draw()

# ------------------------------------------------------------
# Comparison and hash functions so textures can work with sets
# A texture's uniqueness is simply based on the name
# ------------------------------------------------------------
def __hash__(self) -> int:
return hash(self.cache_name)

def __eq__(self, other) -> bool:
if other is None:
return False
if not isinstance(other, self.__class__):
return False
return self.cache_name == other.cache_name

def __ne__(self, other) -> bool:
if other is None:
return True
if not isinstance(other, self.__class__):
return True
return self.cache_name != other.cache_name

def __repr__(self) -> str:
cache_name = getattr(self, "cache_name", None)
return f"<Texture cache_name={cache_name}>"

def __del__(self):
if getattr(self, "_atlas_refs", None) is not None:
for atlas in self._atlas_refs:
atlas.remove(self)
9 changes: 8 additions & 1 deletion arcade/texture_atlas/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,10 @@ def dec_ref(self, image_data: "ImageData") -> None:
def get_refs(self, image_data: "ImageData") -> int:
return self._data.get(image_data.hash, 0)

def count_refs(self) -> int:
"""Helper function to count the total number of references."""
return sum(self._data.values())

def __len__(self) -> int:
return len(self._data)

Expand Down Expand Up @@ -465,6 +469,7 @@ def add(self, texture: "Texture") -> Tuple[int, AtlasRegion]:
self.write_image(texture.image_data.image, x, y)

self._image_ref_count.inc_ref(texture.image_data)
texture.add_atlas_ref(self)
return self._allocate_texture(texture)

def _allocate_texture(self, texture: "Texture") -> Tuple[int, AtlasRegion]:
Expand Down Expand Up @@ -530,7 +535,9 @@ def allocate(self, image_data: "ImageData") -> Tuple[int, int, int, AtlasRegion]
)
except AllocatorException:
raise AllocatorException(
f"No more space for image {image_data.hash} size={image.size}"
f"No more space for image {image_data.hash} size={image.size}. "
f"Curr size: {self._size}. "
f"Max size: {self._max_size}"
)

LOG.debug("Allocated new space for image %s : %s %s", image_data.hash, x, y)
Expand Down
21 changes: 8 additions & 13 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import os
from pathlib import Path

import arcade
Expand All @@ -24,16 +23,18 @@ def prepare_window(window: arcade.Window):

window.switch_to()
ctx = window.ctx
ctx._default_atlas = None # Clear the global atlas
ctx._atlas = None # Clear the global atlas
arcade.cleanup_texture_cache() # Clear the global texture cache
window.hide_view() # Disable views if any is active
window.dispatch_pending_events()

# Reset context (various states)
ctx.reset()
window.set_vsync(False)
window.flip()
window.clear()
ctx.gc_mode = "context_gc"
ctx.gc()

# Ensure no old functions are lingering
window.on_draw = lambda: None
Expand All @@ -50,11 +51,8 @@ def ctx():
"""
window = create_window()
arcade.set_window(window)
try:
prepare_window(window)
yield window.ctx
finally:
window.flip()
prepare_window(window)
return window.ctx


@pytest.fixture(scope="session")
Expand All @@ -67,7 +65,7 @@ def ctx_static():
window = create_window()
arcade.set_window(window)
prepare_window(window)
yield window.ctx
return window.ctx


@pytest.fixture(scope="function")
Expand All @@ -81,11 +79,8 @@ def window():
"""
window = create_window()
arcade.set_window(window)
try:
prepare_window(window)
yield window
finally:
window.flip()
prepare_window(window)
return window


class Fixtures:
Expand Down
32 changes: 32 additions & 0 deletions tests/unit/atlas/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import pytest
import arcade


@pytest.fixture
def common():
return Common


class Common:

@staticmethod
def check_internals(atlas: arcade.TextureAtlas, *, num_textures = 0, num_images = 0):
# Images
assert len(atlas._images) == num_images
assert len(atlas._image_uv_slots) == num_images
assert len(atlas._image_uv_slots_free) == atlas._num_image_slots - num_images
assert len(atlas._image_regions) == num_images

# Textures
assert len(atlas._textures) == num_textures
assert len(atlas._texture_uv_slots) == num_textures
assert len(atlas._texture_uv_slots_free) == atlas._num_texture_slots - num_textures
assert len(atlas._texture_regions) == num_textures

# Misc
assert len(atlas._image_ref_count) == num_images
# the number of image refs should be the same as the number of textures
assert atlas._image_ref_count.count_refs() == num_textures
# TODO: Check the size of these when when texture row allocation is fixed
# atlas._image_uv_data
# atlas._texture_uv_data
Loading