Add texture display bounds for non-uniform sprite content, closes #235

Textures can now specify display_size and display_origin to crop sprite
rendering to a sub-region within each atlas cell. This supports texture
atlases where content doesn't fill the entire cell (e.g., 16x24 sprites
centered in 32x32 cells).

API: Texture("sprites.png", 32, 32, display_size=(16, 24), display_origin=(8, 4))
Properties: display_width, display_height, display_offset_x, display_offset_y

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
John McCardle 2026-04-10 02:57:41 -04:00
commit 9a06ae5d8e
3 changed files with 146 additions and 11 deletions

View file

@ -5,7 +5,8 @@
#include <algorithm>
PyTexture::PyTexture(std::string filename, int sprite_w, int sprite_h)
: source(filename), sprite_width(sprite_w), sprite_height(sprite_h), sheet_width(0), sheet_height(0)
: source(filename), sprite_width(sprite_w), sprite_height(sprite_h), sheet_width(0), sheet_height(0),
display_width(-1), display_height(-1), display_offset_x(0), display_offset_y(0)
{
texture = sf::Texture();
if (!texture.loadFromFile(source)) {
@ -81,9 +82,14 @@ sf::Sprite PyTexture::sprite(int index, sf::Vector2f pos, sf::Vector2f s)
// Return an empty sprite
return sf::Sprite();
}
int tx = index % sheet_width, ty = index / sheet_width;
auto ir = sf::IntRect(tx * sprite_width, ty * sprite_height, sprite_width, sprite_height);
// #235: Apply display bounds within the cell
int dw = getDisplayWidth();
int dh = getDisplayHeight();
auto ir = sf::IntRect(tx * sprite_width + display_offset_x,
ty * sprite_height + display_offset_y,
dw, dh);
auto sprite = sf::Sprite(texture, ir);
sprite.setPosition(pos);
sprite.setScale(s);
@ -138,21 +144,45 @@ Py_hash_t PyTexture::hash(PyObject* obj)
int PyTexture::init(PyTextureObject* self, PyObject* args, PyObject* kwds)
{
static const char* keywords[] = { "filename", "sprite_width", "sprite_height", nullptr };
static const char* keywords[] = { "filename", "sprite_width", "sprite_height",
"display_size", "display_origin", nullptr };
char* filename;
int sprite_width, sprite_height;
if (!PyArg_ParseTupleAndKeywords(args, kwds, "sii", const_cast<char**>(keywords), &filename, &sprite_width, &sprite_height))
PyObject* display_size_obj = nullptr;
PyObject* display_origin_obj = nullptr;
if (!PyArg_ParseTupleAndKeywords(args, kwds, "sii|OO", const_cast<char**>(keywords),
&filename, &sprite_width, &sprite_height, &display_size_obj, &display_origin_obj))
return -1;
// Create the texture object
self->data = std::make_shared<PyTexture>(filename, sprite_width, sprite_height);
// Check if the texture failed to load (sheet dimensions will be 0)
if (self->data->sheet_width == 0 || self->data->sheet_height == 0) {
PyErr_Format(PyExc_IOError, "Failed to load texture from file: %s", filename);
return -1;
}
// #235: Parse optional display bounds
if (display_size_obj && display_size_obj != Py_None) {
int dw, dh;
if (!PyArg_ParseTuple(display_size_obj, "ii", &dw, &dh)) {
PyErr_SetString(PyExc_TypeError, "display_size must be a (width, height) tuple");
return -1;
}
self->data->display_width = dw;
self->data->display_height = dh;
}
if (display_origin_obj && display_origin_obj != Py_None) {
int ox, oy;
if (!PyArg_ParseTuple(display_origin_obj, "ii", &ox, &oy)) {
PyErr_SetString(PyExc_TypeError, "display_origin must be an (x, y) tuple");
return -1;
}
self->data->display_offset_x = ox;
self->data->display_offset_y = oy;
}
return 0;
}
@ -191,6 +221,26 @@ PyObject* PyTexture::get_source(PyTextureObject* self, void* closure)
return PyUnicode_FromString(self->data->source.c_str());
}
PyObject* PyTexture::get_display_width(PyTextureObject* self, void* closure)
{
return PyLong_FromLong(self->data->getDisplayWidth());
}
PyObject* PyTexture::get_display_height(PyTextureObject* self, void* closure)
{
return PyLong_FromLong(self->data->getDisplayHeight());
}
PyObject* PyTexture::get_display_offset_x(PyTextureObject* self, void* closure)
{
return PyLong_FromLong(self->data->display_offset_x);
}
PyObject* PyTexture::get_display_offset_y(PyTextureObject* self, void* closure)
{
return PyLong_FromLong(self->data->display_offset_y);
}
PyGetSetDef PyTexture::getsetters[] = {
{"sprite_width", (getter)PyTexture::get_sprite_width, NULL,
MCRF_PROPERTY(sprite_width, "Width of each sprite in pixels (int, read-only). Specified during texture initialization."), NULL},
@ -204,6 +254,14 @@ PyGetSetDef PyTexture::getsetters[] = {
MCRF_PROPERTY(sprite_count, "Total number of sprites in the texture sheet (int, read-only). Equals sheet_width * sheet_height."), NULL},
{"source", (getter)PyTexture::get_source, NULL,
MCRF_PROPERTY(source, "Source filename path (str, read-only). The path used to load this texture."), NULL},
{"display_width", (getter)PyTexture::get_display_width, NULL,
MCRF_PROPERTY(display_width, "Display width of sprite content within each cell (int, read-only). Defaults to sprite_width."), NULL},
{"display_height", (getter)PyTexture::get_display_height, NULL,
MCRF_PROPERTY(display_height, "Display height of sprite content within each cell (int, read-only). Defaults to sprite_height."), NULL},
{"display_offset_x", (getter)PyTexture::get_display_offset_x, NULL,
MCRF_PROPERTY(display_offset_x, "X offset of sprite content within each cell (int, read-only). Default 0."), NULL},
{"display_offset_y", (getter)PyTexture::get_display_offset_y, NULL,
MCRF_PROPERTY(display_offset_y, "Y offset of sprite content within each cell (int, read-only). Default 0."), NULL},
{NULL} // Sentinel
};

View file

@ -17,10 +17,14 @@ private:
int sheet_width, sheet_height;
// Private default constructor for factory methods
PyTexture() : source("<uninitialized>"), sprite_width(0), sprite_height(0), sheet_width(0), sheet_height(0) {}
PyTexture() : source("<uninitialized>"), sprite_width(0), sprite_height(0), sheet_width(0), sheet_height(0),
display_width(-1), display_height(-1), display_offset_x(0), display_offset_y(0) {}
public:
int sprite_width, sprite_height; // just use them read only, OK?
// #235: Display bounds for non-uniform sprite content within cells
int display_width, display_height; // -1 = same as sprite_width/height
int display_offset_x, display_offset_y; // offset within cell to content area
PyTexture(std::string filename, int sprite_w, int sprite_h);
// #144: Factory method to create texture from rendered content (snapshot)
@ -42,6 +46,10 @@ public:
static int init(PyTextureObject*, PyObject*, PyObject*);
static PyObject* pynew(PyTypeObject* type, PyObject* args=NULL, PyObject* kwds=NULL);
// Effective display dimensions (resolves -1 defaults)
int getDisplayWidth() const { return display_width >= 0 ? display_width : sprite_width; }
int getDisplayHeight() const { return display_height >= 0 ? display_height : sprite_height; }
// Getters for properties
static PyObject* get_sprite_width(PyTextureObject* self, void* closure);
static PyObject* get_sprite_height(PyTextureObject* self, void* closure);
@ -49,6 +57,10 @@ public:
static PyObject* get_sheet_height(PyTextureObject* self, void* closure);
static PyObject* get_sprite_count(PyTextureObject* self, void* closure);
static PyObject* get_source(PyTextureObject* self, void* closure);
static PyObject* get_display_width(PyTextureObject* self, void* closure);
static PyObject* get_display_height(PyTextureObject* self, void* closure);
static PyObject* get_display_offset_x(PyTextureObject* self, void* closure);
static PyObject* get_display_offset_y(PyTextureObject* self, void* closure);
static PyGetSetDef getsetters[];
@ -69,17 +81,22 @@ namespace mcrfpydef {
.tp_hash = PyTexture::hash,
.tp_flags = Py_TPFLAGS_DEFAULT,
.tp_doc = PyDoc_STR(
"Texture(filename: str, sprite_width: int = 0, sprite_height: int = 0)\n\n"
"Texture(filename: str, sprite_width: int = 0, sprite_height: int = 0, "
"display_size: tuple = None, display_origin: tuple = None)\n\n"
"A texture atlas for sprites and tiles.\n\n"
"Args:\n"
" filename: Path to an image file (PNG, BMP, etc.).\n"
" sprite_width: Width of each sprite cell in pixels (0 = full image).\n"
" sprite_height: Height of each sprite cell in pixels (0 = full image).\n\n"
" sprite_height: Height of each sprite cell in pixels (0 = full image).\n"
" display_size: Optional (w, h) actual content size within each cell.\n"
" display_origin: Optional (x, y) content offset within each cell.\n\n"
"Properties:\n"
" sprite_width, sprite_height (int, read-only): Cell dimensions.\n"
" sheet_width, sheet_height (int, read-only): Grid dimensions in cells.\n"
" sprite_count (int, read-only): Total number of sprite cells.\n"
" source (str, read-only): File path used to load this texture.\n"
" display_width, display_height (int, read-only): Content size within cells.\n"
" display_offset_x, display_offset_y (int, read-only): Content offset within cells.\n"
),
.tp_getset = PyTexture::getsetters,
//.tp_base = &PyBaseObject_Type,

View file

@ -0,0 +1,60 @@
"""Test texture display bounds for non-uniform sprite content (#235).
Verifies that Texture accepts display_size and display_origin parameters
to crop sprite rendering to a sub-region within each atlas cell.
"""
import mcrfpy
import sys
def test_default_display_bounds():
"""Without display bounds, display dims equal sprite dims."""
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
assert tex.display_width == 16, f"Expected 16, got {tex.display_width}"
assert tex.display_height == 16, f"Expected 16, got {tex.display_height}"
assert tex.display_offset_x == 0, f"Expected 0, got {tex.display_offset_x}"
assert tex.display_offset_y == 0, f"Expected 0, got {tex.display_offset_y}"
print(" PASS: default display bounds")
def test_custom_display_size():
"""display_size crops sprite content within cells."""
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16,
display_size=(12, 14))
assert tex.display_width == 12, f"Expected 12, got {tex.display_width}"
assert tex.display_height == 14, f"Expected 14, got {tex.display_height}"
assert tex.sprite_width == 16, f"sprite_width should be unchanged: {tex.sprite_width}"
assert tex.sprite_height == 16, f"sprite_height should be unchanged: {tex.sprite_height}"
print(" PASS: custom display size")
def test_custom_display_origin():
"""display_origin offsets content within cells."""
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16,
display_size=(12, 14), display_origin=(2, 1))
assert tex.display_offset_x == 2, f"Expected 2, got {tex.display_offset_x}"
assert tex.display_offset_y == 1, f"Expected 1, got {tex.display_offset_y}"
print(" PASS: custom display origin")
def test_display_bounds_sprite_creation():
"""Sprites created from bounded textures should work in UI elements."""
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16,
display_size=(12, 14), display_origin=(2, 1))
sprite = mcrfpy.Sprite(pos=(10, 10), texture=tex, sprite_index=0)
assert sprite is not None, "Sprite creation with display bounds failed"
print(" PASS: sprite creation with display bounds")
def test_display_bounds_in_grid():
"""Entities using bounded textures should render in grids."""
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16,
display_size=(12, 14), display_origin=(2, 1))
grid = mcrfpy.Grid(grid_size=(10, 10), texture=tex)
entity = mcrfpy.Entity(grid_pos=(3, 3), texture=tex, sprite_index=5, grid=grid)
assert entity is not None, "Entity creation with display bounds failed"
print(" PASS: entity with display bounds in grid")
print("Testing #235: Texture display bounds...")
test_default_display_bounds()
test_custom_display_size()
test_custom_display_origin()
test_display_bounds_sprite_creation()
test_display_bounds_in_grid()
print("All #235 tests passed.")
sys.exit(0)