feat(grid): numpy buffer views for ColorLayer/TileLayer via layer.edit(); closes #335
Implements the #328 bulk-edit convention for grid layers. Writable zero-copy views of layer data are exposed ONLY through a context manager: with layer.edit() as view: # ColorLayer -> (h, w, 4) uint8 np.asarray(view)[...] = ... # TileLayer -> (h, w) int32 (-1 = no tile) # on __exit__ the whole layer is conservatively invalidated (markDirty), # so the edit re-renders. Both layers already stored dense, contiguous, row-major data (std::vector< sf::Color> / std::vector<int>; render caches chunk separately), so the views are true zero-copy aliases. A new internal _LayerEdit type is the buffer exporter (bf_getbuffer) and the context manager (__enter__ returns memoryview(self), __exit__ calls GridLayer::markDirty()); it is not directly instantiable. The conservative whole-layer invalidation on exit means no change-tracking is needed, and it composes with the #351 render early-out (markDirty bumps content_generation). Independent of GridData SoA (#332): these are the render layers, not the logic grid. A contiguous walkable/transparent view now has its storage prerequisite from #332 but remains a separate follow-up. Regression test issue_335_layer_edit_test.py: buffer shape/format/readonly, zero-copy writeback both directions, the -1 tile sentinel, optional numpy aliasing, and a screenshot check that __exit__ actually re-renders. API-surface golden re-baselined (two new edit methods); docs/stubs regenerated; frozen docstring gate 100%. Suite 312/312. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2ceb18e673
commit
ad223b1ac0
9 changed files with 344 additions and 4 deletions
|
|
@ -1,6 +1,6 @@
|
|||
# McRogueFace API Reference
|
||||
|
||||
*Generated on 2026-07-02 20:21:29*
|
||||
*Generated on 2026-07-11 02:02:32*
|
||||
|
||||
*This documentation was dynamically generated from the compiled module.*
|
||||
|
||||
|
|
@ -1276,6 +1276,15 @@ Note:
|
|||
- `discovered`: Color for previously seen cells
|
||||
- `unknown`: Color for never-seen cells
|
||||
|
||||
#### `edit() -> context manager`
|
||||
|
||||
Context manager yielding a zero-copy, writable view of the layer's RGBA data with shape (height, width, 4), dtype uint8. Writes to the view alias the layer's storage (no copy). On exit the whole layer is invalidated and re-rendered.
|
||||
|
||||
Note:
|
||||
Use as `with layer.edit() as view:` -- e.g. np.asarray(view)[...] = ....
|
||||
|
||||
**See also:** [Bulk-edit convention](#328) (docs/api-stability.md)
|
||||
|
||||
#### `fill(color: Color) -> None`
|
||||
|
||||
Fill the entire layer with the specified color.
|
||||
|
|
@ -4526,6 +4535,15 @@ Get the tile index at a cell position. Returns -1 if no tile is set.
|
|||
|
||||
**Raises:** IndexError: If coordinates are out of bounds
|
||||
|
||||
#### `edit() -> context manager`
|
||||
|
||||
Context manager yielding a zero-copy, writable view of the layer's tile indices with shape (height, width), dtype int32 (-1 = no tile). Writes to the view alias the layer's storage (no copy). On exit the whole layer is invalidated and re-rendered.
|
||||
|
||||
Note:
|
||||
Use as `with layer.edit() as view:` -- e.g. np.asarray(view)[...] = ....
|
||||
|
||||
**See also:** [Bulk-edit convention](#328) (docs/api-stability.md)
|
||||
|
||||
#### `fill(index: int) -> None`
|
||||
|
||||
Fill the entire layer with the specified tile index.
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@
|
|||
<body>
|
||||
<div class="container">
|
||||
<h1>McRogueFace API Reference</h1>
|
||||
<p><em>Generated on 2026-07-02 20:21:29</em></p>
|
||||
<p><em>Generated on 2026-07-11 02:02:32</em></p>
|
||||
<p><em>This documentation was dynamically generated from the compiled module.</em></p>
|
||||
<p><strong>Threading:</strong> any access to mcrfpy objects from a non-main thread must happen inside <code>with mcrfpy.lock():</code>; behavior outside the lock is undefined. See <a href="threading-model.md">docs/threading-model.md</a>.</p>
|
||||
|
||||
|
|
@ -1417,6 +1417,15 @@ Note:</p>
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-left: 20px; margin-bottom: 15px;">
|
||||
<h5><code class="method-name">edit() -> context manager</code></h5>
|
||||
<p>Context manager yielding a zero-copy, writable view of the layer's RGBA data with shape (height, width, 4), dtype uint8. Writes to the view alias the layer's storage (no copy). On exit the whole layer is invalidated and re-rendered.
|
||||
|
||||
Note:
|
||||
Use as `with layer.edit() as view:` -- e.g. np.asarray(view)[...] = ....
|
||||
<p class="see-also">See also: <a href="#328">Bulk-edit convention</a></p> (docs/api-stability.md)</p>
|
||||
</div>
|
||||
|
||||
<div style="margin-left: 20px; margin-bottom: 15px;">
|
||||
<h5><code class="method-name">fill(color: Color) -> None</code></h5>
|
||||
<p>Fill the entire layer with the specified color.</p>
|
||||
|
|
@ -4775,6 +4784,15 @@ Note:</p>
|
|||
<p style='margin-left: 20px;'><span class='raises'>Raises:</span> IndexError: If coordinates are out of bounds</p>
|
||||
</div>
|
||||
|
||||
<div style="margin-left: 20px; margin-bottom: 15px;">
|
||||
<h5><code class="method-name">edit() -> context manager</code></h5>
|
||||
<p>Context manager yielding a zero-copy, writable view of the layer's tile indices with shape (height, width), dtype int32 (-1 = no tile). Writes to the view alias the layer's storage (no copy). On exit the whole layer is invalidated and re-rendered.
|
||||
|
||||
Note:
|
||||
Use as `with layer.edit() as view:` -- e.g. np.asarray(view)[...] = ....
|
||||
<p class="see-also">See also: <a href="#328">Bulk-edit convention</a></p> (docs/api-stability.md)</p>
|
||||
</div>
|
||||
|
||||
<div style="margin-left: 20px; margin-bottom: 15px;">
|
||||
<h5><code class="method-name">fill(index: int) -> None</code></h5>
|
||||
<p>Fill the entire layer with the specified tile index.</p>
|
||||
|
|
|
|||
|
|
@ -14,11 +14,11 @@
|
|||
. ftr VB CB
|
||||
. ftr VBI CBI
|
||||
.\}
|
||||
.TH "MCRFPY" "3" "2026-07-02" "McRogueFace 0.2.8-6-g19d80b4" ""
|
||||
.TH "MCRFPY" "3" "2026-07-11" "McRogueFace 0.2.8-32-g2ceb18e" ""
|
||||
.hy
|
||||
.SH McRogueFace API Reference
|
||||
.PP
|
||||
\f[I]Generated on 2026-07-02 20:21:29\f[R]
|
||||
\f[I]Generated on 2026-07-11 02:02:32\f[R]
|
||||
.PP
|
||||
\f[I]This documentation was dynamically generated from the compiled
|
||||
module.\f[R]
|
||||
|
|
@ -1477,6 +1477,18 @@ Note:
|
|||
\f[V]visible\f[R]: Color for currently visible cells -
|
||||
\f[V]discovered\f[R]: Color for previously seen cells -
|
||||
\f[V]unknown\f[R]: Color for never-seen cells
|
||||
.SS \f[V]edit() -> context manager\f[R]
|
||||
.PP
|
||||
Context manager yielding a zero-copy, writable view of the layer\[cq]s
|
||||
RGBA data with shape (height, width, 4), dtype uint8.
|
||||
Writes to the view alias the layer\[cq]s storage (no copy).
|
||||
On exit the whole layer is invalidated and re-rendered.
|
||||
.PP
|
||||
Note: Use as \f[V]with layer.edit() as view:\f[R] \[en]
|
||||
e.g.\ np.asarray(view)[\&...]
|
||||
= \&....
|
||||
.PP
|
||||
\f[B]See also:\f[R] Bulk-edit convention (docs/api-stability.md)
|
||||
.SS \f[V]fill(color: Color) -> None\f[R]
|
||||
.PP
|
||||
Fill the entire layer with the specified color.
|
||||
|
|
@ -4870,6 +4882,18 @@ Vector; or pass x and y separately
|
|||
\f[B]Returns:\f[R] Tile index at the specified cell, or -1 if empty
|
||||
.PP
|
||||
\f[B]Raises:\f[R] IndexError: If coordinates are out of bounds
|
||||
.SS \f[V]edit() -> context manager\f[R]
|
||||
.PP
|
||||
Context manager yielding a zero-copy, writable view of the layer\[cq]s
|
||||
tile indices with shape (height, width), dtype int32 (-1 = no tile).
|
||||
Writes to the view alias the layer\[cq]s storage (no copy).
|
||||
On exit the whole layer is invalidated and re-rendered.
|
||||
.PP
|
||||
Note: Use as \f[V]with layer.edit() as view:\f[R] \[en]
|
||||
e.g.\ np.asarray(view)[\&...]
|
||||
= \&....
|
||||
.PP
|
||||
\f[B]See also:\f[R] Bulk-edit convention (docs/api-stability.md)
|
||||
.SS \f[V]fill(index: int) -> None\f[R]
|
||||
.PP
|
||||
Fill the entire layer with the specified tile index.
|
||||
|
|
|
|||
|
|
@ -683,7 +683,129 @@ void TileLayer::render(sf::RenderTarget& target,
|
|||
// Python API - ColorLayer
|
||||
// =============================================================================
|
||||
|
||||
// ============================================================================
|
||||
// #335 - layer.edit() zero-copy buffer context manager
|
||||
// with layer.edit() as view: # ColorLayer -> (h,w,4) uint8; TileLayer -> (h,w) int32
|
||||
// view[...] = ... # writes alias the layer's data (no copy)
|
||||
// # on __exit__ the whole layer is conservatively invalidated (markDirty),
|
||||
// # so the edit re-renders. Writable views are ONLY reachable via edit() (#328).
|
||||
// ============================================================================
|
||||
int PyGridLayerAPI::LayerEdit_getbuffer(PyObject* exporter, Py_buffer* view, int flags) {
|
||||
PyLayerEditObject* self = (PyLayerEditObject*)exporter;
|
||||
GridLayer* L = self->layer.get();
|
||||
if (!L) {
|
||||
PyErr_SetString(PyExc_RuntimeError, "layer edit view is no longer valid");
|
||||
view->obj = nullptr;
|
||||
return -1;
|
||||
}
|
||||
const Py_ssize_t w = L->grid_x;
|
||||
const Py_ssize_t h = L->grid_y;
|
||||
if (L->type == GridLayerType::Color) {
|
||||
ColorLayer* cl = static_cast<ColorLayer*>(L);
|
||||
view->buf = cl->colors.data(); // sf::Color == 4 contiguous uint8 (r,g,b,a)
|
||||
view->len = w * h * 4;
|
||||
view->itemsize = 1;
|
||||
view->format = (flags & PyBUF_FORMAT) ? const_cast<char*>("B") : nullptr;
|
||||
view->ndim = 3;
|
||||
self->shape[0] = h; self->shape[1] = w; self->shape[2] = 4;
|
||||
self->strides[0] = w * 4; self->strides[1] = 4; self->strides[2] = 1;
|
||||
} else {
|
||||
TileLayer* tl = static_cast<TileLayer*>(L);
|
||||
view->buf = tl->tiles.data();
|
||||
view->len = w * h * (Py_ssize_t)sizeof(int);
|
||||
view->itemsize = sizeof(int);
|
||||
view->format = (flags & PyBUF_FORMAT) ? const_cast<char*>("i") : nullptr; // int32
|
||||
view->ndim = 2;
|
||||
self->shape[0] = h; self->shape[1] = w;
|
||||
self->strides[0] = w * (Py_ssize_t)sizeof(int); self->strides[1] = sizeof(int);
|
||||
}
|
||||
view->obj = exporter;
|
||||
Py_INCREF(exporter);
|
||||
view->readonly = 0;
|
||||
view->shape = self->shape;
|
||||
view->strides = (flags & PyBUF_STRIDES) ? self->strides : nullptr;
|
||||
view->suboffsets = nullptr;
|
||||
view->internal = nullptr;
|
||||
return 0;
|
||||
}
|
||||
|
||||
PyObject* PyGridLayerAPI::LayerEdit_enter(PyObject* self, PyObject* Py_UNUSED(args)) {
|
||||
PyLayerEditObject* e = (PyLayerEditObject*)self;
|
||||
if (!e->layer) {
|
||||
PyErr_SetString(PyExc_RuntimeError, "layer edit view is no longer valid");
|
||||
return nullptr;
|
||||
}
|
||||
e->active = true;
|
||||
return PyMemoryView_FromObject(self); // memoryview holds a ref to self (the exporter)
|
||||
}
|
||||
|
||||
PyObject* PyGridLayerAPI::LayerEdit_exit(PyObject* self, PyObject* args) {
|
||||
(void)args;
|
||||
PyLayerEditObject* e = (PyLayerEditObject*)self;
|
||||
if (e->active && e->layer) {
|
||||
e->layer->markDirty(); // #328 conservative whole-layer invalidation (bumps content_generation, #351)
|
||||
}
|
||||
e->active = false;
|
||||
Py_RETURN_FALSE; // do not suppress exceptions raised inside the with-block
|
||||
}
|
||||
|
||||
void PyGridLayerAPI::LayerEdit_dealloc(PyObject* self) {
|
||||
PyLayerEditObject* e = (PyLayerEditObject*)self;
|
||||
Py_XDECREF(e->layer_pyobj);
|
||||
e->layer.reset();
|
||||
Py_TYPE(self)->tp_free(self);
|
||||
}
|
||||
|
||||
PyMethodDef PyGridLayerAPI::LayerEdit_methods[] = {
|
||||
{"__enter__", (PyCFunction)PyGridLayerAPI::LayerEdit_enter, METH_NOARGS,
|
||||
MCRF_METHOD(_LayerEdit, __enter__,
|
||||
MCRF_SIG("()", "memoryview"),
|
||||
MCRF_DESC("Enter the edit context; returns a writable zero-copy view of the layer data.")
|
||||
)},
|
||||
{"__exit__", (PyCFunction)PyGridLayerAPI::LayerEdit_exit, METH_VARARGS,
|
||||
MCRF_METHOD(_LayerEdit, __exit__,
|
||||
MCRF_SIG("(exc_type, exc_value, traceback)", "bool"),
|
||||
MCRF_DESC("Exit the edit context; conservatively invalidates the whole layer so the edit re-renders.")
|
||||
)},
|
||||
{NULL}
|
||||
};
|
||||
|
||||
PyBufferProcs PyGridLayerAPI::LayerEdit_as_buffer = {
|
||||
.bf_getbuffer = PyGridLayerAPI::LayerEdit_getbuffer,
|
||||
.bf_releasebuffer = nullptr,
|
||||
};
|
||||
|
||||
static PyObject* makeLayerEdit(std::shared_ptr<GridLayer> layer, PyObject* layer_pyobj) {
|
||||
auto* e = (PyLayerEditObject*)mcrfpydef::PyLayerEditType.tp_alloc(&mcrfpydef::PyLayerEditType, 0);
|
||||
if (!e) return nullptr;
|
||||
e->layer = layer; // tp_alloc zero-inits -> valid empty shared_ptr to assign into
|
||||
e->layer_pyobj = layer_pyobj;
|
||||
Py_XINCREF(layer_pyobj);
|
||||
e->active = false;
|
||||
return (PyObject*)e;
|
||||
}
|
||||
|
||||
PyObject* PyGridLayerAPI::ColorLayer_edit(PyColorLayerObject* self, PyObject* Py_UNUSED(args)) {
|
||||
if (!self->data) { PyErr_SetString(PyExc_RuntimeError, "ColorLayer not initialized"); return nullptr; }
|
||||
return makeLayerEdit(self->data, (PyObject*)self);
|
||||
}
|
||||
|
||||
PyObject* PyGridLayerAPI::TileLayer_edit(PyTileLayerObject* self, PyObject* Py_UNUSED(args)) {
|
||||
if (!self->data) { PyErr_SetString(PyExc_RuntimeError, "TileLayer not initialized"); return nullptr; }
|
||||
return makeLayerEdit(self->data, (PyObject*)self);
|
||||
}
|
||||
|
||||
PyMethodDef PyGridLayerAPI::ColorLayer_methods[] = {
|
||||
{"edit", (PyCFunction)PyGridLayerAPI::ColorLayer_edit, METH_NOARGS,
|
||||
MCRF_METHOD(ColorLayer, edit,
|
||||
MCRF_SIG("()", "context manager"),
|
||||
MCRF_DESC("Context manager yielding a zero-copy, writable view of the layer's "
|
||||
"RGBA data with shape (height, width, 4), dtype uint8. Writes to the "
|
||||
"view alias the layer's storage (no copy). On exit the whole layer is "
|
||||
"invalidated and re-rendered.")
|
||||
MCRF_NOTE("Use as `with layer.edit() as view:` -- e.g. np.asarray(view)[...] = ....")
|
||||
MCRF_LINK("docs/api-stability.md", "Bulk-edit convention (#328)")
|
||||
)},
|
||||
{"at", (PyCFunction)PyGridLayerAPI::ColorLayer_at, METH_VARARGS | METH_KEYWORDS,
|
||||
MCRF_METHOD(ColorLayer, at,
|
||||
MCRF_SIG("(pos: tuple | Vector) or (x: int, y: int)", "Color"),
|
||||
|
|
@ -1779,6 +1901,16 @@ PyObject* PyGridLayerAPI::ColorLayer_repr(PyColorLayerObject* self) {
|
|||
// =============================================================================
|
||||
|
||||
PyMethodDef PyGridLayerAPI::TileLayer_methods[] = {
|
||||
{"edit", (PyCFunction)PyGridLayerAPI::TileLayer_edit, METH_NOARGS,
|
||||
MCRF_METHOD(TileLayer, edit,
|
||||
MCRF_SIG("()", "context manager"),
|
||||
MCRF_DESC("Context manager yielding a zero-copy, writable view of the layer's "
|
||||
"tile indices with shape (height, width), dtype int32 (-1 = no tile). "
|
||||
"Writes to the view alias the layer's storage (no copy). On exit the "
|
||||
"whole layer is invalidated and re-rendered.")
|
||||
MCRF_NOTE("Use as `with layer.edit() as view:` -- e.g. np.asarray(view)[...] = ....")
|
||||
MCRF_LINK("docs/api-stability.md", "Bulk-edit convention (#328)")
|
||||
)},
|
||||
{"at", (PyCFunction)PyGridLayerAPI::TileLayer_at, METH_VARARGS | METH_KEYWORDS,
|
||||
MCRF_METHOD(TileLayer, at,
|
||||
MCRF_SIG("(pos: tuple | Vector) or (x: int, y: int)", "int"),
|
||||
|
|
|
|||
|
|
@ -193,6 +193,19 @@ typedef struct {
|
|||
std::shared_ptr<UIGrid> grid;
|
||||
} PyTileLayerObject;
|
||||
|
||||
// #335 - context manager returned by ColorLayer.edit()/TileLayer.edit().
|
||||
// Supports the buffer protocol (zero-copy numpy/memoryview view of the layer's
|
||||
// dense data) and __enter__/__exit__; __exit__ conservatively invalidates the
|
||||
// whole layer via markDirty() (#328). Not directly instantiable from Python.
|
||||
typedef struct {
|
||||
PyObject_HEAD
|
||||
std::shared_ptr<GridLayer> layer;
|
||||
PyObject* layer_pyobj; // strong ref keeping the layer's Python wrapper alive
|
||||
Py_ssize_t shape[3];
|
||||
Py_ssize_t strides[3];
|
||||
bool active;
|
||||
} PyLayerEditObject;
|
||||
|
||||
// Python API classes
|
||||
class PyGridLayerAPI {
|
||||
public:
|
||||
|
|
@ -249,6 +262,17 @@ public:
|
|||
static int TileLayer_subscript_assign(PyTileLayerObject* self, PyObject* key, PyObject* value);
|
||||
static PyMappingMethods TileLayer_mapping_methods;
|
||||
|
||||
// #335 - layer.edit() context manager: zero-copy writable numpy/memoryview
|
||||
// view of layer data; __exit__ conservatively invalidates (markDirty()).
|
||||
static PyObject* ColorLayer_edit(PyColorLayerObject* self, PyObject* Py_UNUSED(args));
|
||||
static PyObject* TileLayer_edit(PyTileLayerObject* self, PyObject* Py_UNUSED(args));
|
||||
static PyObject* LayerEdit_enter(PyObject* self, PyObject* Py_UNUSED(args));
|
||||
static PyObject* LayerEdit_exit(PyObject* self, PyObject* args);
|
||||
static int LayerEdit_getbuffer(PyObject* exporter, Py_buffer* view, int flags);
|
||||
static void LayerEdit_dealloc(PyObject* self);
|
||||
static PyMethodDef LayerEdit_methods[];
|
||||
static PyBufferProcs LayerEdit_as_buffer;
|
||||
|
||||
// Method and getset arrays
|
||||
static PyMethodDef ColorLayer_methods[];
|
||||
static PyGetSetDef ColorLayer_getsetters[];
|
||||
|
|
@ -369,4 +393,24 @@ namespace mcrfpydef {
|
|||
return (PyObject*)self;
|
||||
}
|
||||
};
|
||||
|
||||
// #335 - layer.edit() context manager / buffer exporter
|
||||
inline PyTypeObject PyLayerEditType = {
|
||||
.ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0},
|
||||
.tp_name = "mcrfpy._LayerEdit",
|
||||
.tp_basicsize = sizeof(PyLayerEditObject),
|
||||
.tp_itemsize = 0,
|
||||
.tp_dealloc = (destructor)PyGridLayerAPI::LayerEdit_dealloc,
|
||||
.tp_as_buffer = &PyGridLayerAPI::LayerEdit_as_buffer,
|
||||
.tp_flags = Py_TPFLAGS_DEFAULT,
|
||||
.tp_doc = PyDoc_STR(
|
||||
"Context manager returned by ColorLayer.edit() / TileLayer.edit().\n\n"
|
||||
"Use `with layer.edit() as view:` to obtain a zero-copy, writable\n"
|
||||
"numpy/memoryview of the layer's data (ColorLayer -> (h, w, 4) uint8;\n"
|
||||
"TileLayer -> (h, w) int32). On exit the whole layer is invalidated so\n"
|
||||
"the change is re-rendered. Not directly instantiable."
|
||||
),
|
||||
.tp_methods = PyGridLayerAPI::LayerEdit_methods,
|
||||
.tp_new = NULL, // internal only
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -557,6 +557,9 @@ PyObject* PyInit_mcrfpy()
|
|||
/*game map data - returned by Grid.at() but not directly instantiable*/
|
||||
&PyUIGridPointType,
|
||||
|
||||
/*#335: layer.edit() context manager - returned by ColorLayer/TileLayer.edit(), not instantiable*/
|
||||
&mcrfpydef::PyLayerEditType,
|
||||
|
||||
/*3D navigation grid - returned by Viewport3D.at() but not directly instantiable*/
|
||||
&mcrfpydef::PyVoxelPointType,
|
||||
|
||||
|
|
|
|||
|
|
@ -522,6 +522,9 @@ class ColorLayer:
|
|||
def draw_fov(self, source: tuple, radius: int | None = None, fov: FOV | None = None, visible: Color | None = None, discovered: Color | None = None, unknown: Color | None = None) -> None:
|
||||
"""Paint cells based on field-of-view visibility from a source position."""
|
||||
...
|
||||
def edit(self) -> context manager:
|
||||
"""Context manager yielding a zero-copy, writable view of the layer's RGBA data with shape (height, width, 4), dtype uint8. Writes to the view alias the layer's..."""
|
||||
...
|
||||
def fill(self, color: Color) -> None:
|
||||
"""Fill the entire layer with the specified color."""
|
||||
...
|
||||
|
|
@ -1545,6 +1548,9 @@ class TileLayer:
|
|||
def at(self, *args, **kwargs) -> Any:
|
||||
"""at(pos: tuple | Vector) or (x: int, y: int) -> int"""
|
||||
...
|
||||
def edit(self) -> context manager:
|
||||
"""Context manager yielding a zero-copy, writable view of the layer's tile indices with shape (height, width), dtype int32 (-1 = no tile). Writes to the view al..."""
|
||||
...
|
||||
def fill(self, index: int) -> None:
|
||||
"""Fill the entire layer with the specified tile index."""
|
||||
...
|
||||
|
|
|
|||
93
tests/regression/issue_335_layer_edit_test.py
Normal file
93
tests/regression/issue_335_layer_edit_test.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
"""Regression test for #335 - ColorLayer/TileLayer numpy buffer views via edit().
|
||||
|
||||
Per the #328 bulk-edit convention, writable zero-copy views of layer data are
|
||||
exposed only through `with layer.edit() as view:`. ColorLayer yields an
|
||||
(h, w, 4) uint8 view; TileLayer yields an (h, w) int32 view (-1 = no tile).
|
||||
Writes alias the layer storage (no copy); on __exit__ the whole layer is
|
||||
conservatively invalidated so the edit re-renders.
|
||||
|
||||
Verifies buffer shape/format, zero-copy writeback both directions, the -1
|
||||
sentinel, optional numpy aliasing, and that exiting the context actually
|
||||
re-renders (screenshot diff -- the edit->markDirty->#351 early-out loop).
|
||||
"""
|
||||
import mcrfpy
|
||||
from mcrfpy import automation
|
||||
import sys
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
TMPDIR = tempfile.mkdtemp(prefix="mcrf335_")
|
||||
_n = 0
|
||||
|
||||
|
||||
def shot():
|
||||
global _n
|
||||
p = os.path.join(TMPDIR, "s%d.png" % _n)
|
||||
_n += 1
|
||||
automation.screenshot(p)
|
||||
with open(p, "rb") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def main():
|
||||
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
|
||||
scene = mcrfpy.Scene("t335")
|
||||
scene.activate()
|
||||
|
||||
cl = mcrfpy.ColorLayer(name="fog", z_index=1)
|
||||
tl = mcrfpy.TileLayer(name="terrain", z_index=-2, texture=tex)
|
||||
grid = mcrfpy.Grid(grid_size=(8, 6), pos=(10, 10), size=(256, 192),
|
||||
texture=tex, layers=[tl, cl])
|
||||
scene.children.append(grid)
|
||||
|
||||
# --- ColorLayer: (h, w, 4) uint8, writable, zero-copy both directions ---
|
||||
with cl.edit() as view:
|
||||
assert view.shape == (6, 8, 4), f"color shape {view.shape}"
|
||||
assert view.format == 'B', f"color format {view.format}"
|
||||
assert view.readonly is False
|
||||
view[1, 2, 0] = 200 # y=1, x=2, red channel
|
||||
assert cl.at(2, 1).r == 200, "color view -> layer writeback failed"
|
||||
|
||||
# layer -> view direction
|
||||
cl.set((3, 4), mcrfpy.Color(7, 8, 9, 255)) # x=3, y=4
|
||||
with cl.edit() as view:
|
||||
assert view[4, 3, 0] == 7 and view[4, 3, 2] == 9, "layer -> view read failed"
|
||||
|
||||
# --- TileLayer: (h, w) int32, -1 sentinel ---
|
||||
with tl.edit() as view:
|
||||
assert view.shape == (6, 8), f"tile shape {view.shape}"
|
||||
assert view.format == 'i', f"tile format {view.format}"
|
||||
view[5, 6] = 42 # y=5, x=6
|
||||
view[0, 0] = -1
|
||||
assert tl.at(6, 5) == 42, "tile view -> layer writeback failed"
|
||||
assert tl.at(0, 0) == -1, "tile -1 sentinel round-trip failed"
|
||||
|
||||
# --- Optional numpy zero-copy aliasing ---
|
||||
try:
|
||||
import numpy as np
|
||||
with tl.edit() as view:
|
||||
a = np.asarray(view)
|
||||
assert a.shape == (6, 8) and a.dtype == np.int32, (a.shape, a.dtype)
|
||||
a[3, 3] = 17
|
||||
assert tl.at(3, 3) == 17, "numpy write did not alias TileLayer"
|
||||
print(" numpy zero-copy verified")
|
||||
except ImportError:
|
||||
print(" numpy not bundled; memoryview path verified")
|
||||
|
||||
# --- __exit__ invalidation actually re-renders (edit -> markDirty -> #351) ---
|
||||
before = shot()
|
||||
with cl.edit() as view:
|
||||
# paint a visible opaque block through the view
|
||||
for y in range(6):
|
||||
for x in range(8):
|
||||
view[y, x, 0] = 255
|
||||
view[y, x, 3] = 255
|
||||
after = shot()
|
||||
assert before != after, "layer edit did not re-render on __exit__"
|
||||
|
||||
print("PASS")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -394,6 +394,7 @@ submodule automation
|
|||
meth at :: at(pos: tuple | Vector) or (x: int, y: int) -> Color
|
||||
meth clear_perspective :: clear_perspective() -> None
|
||||
meth draw_fov :: draw_fov(source: tuple, radius: int | None = None, fov: FOV | None = None, visible: Color | None = None, discovered: Color | None = None, unknown: Color | None = None) -> None
|
||||
meth edit :: edit() -> context manager
|
||||
meth fill :: fill(color: Color) -> None
|
||||
meth fill_rect :: fill_rect(pos: tuple, size: tuple, color: Color) -> None
|
||||
meth set :: set(pos, color: Color) -> None
|
||||
|
|
@ -824,6 +825,7 @@ submodule automation
|
|||
meth apply_ranges :: apply_ranges(source: HeightMap, ranges: list) -> TileLayer
|
||||
meth apply_threshold :: apply_threshold(source: HeightMap, range: tuple, tile: int) -> TileLayer
|
||||
meth at :: at(pos: tuple | Vector) or (x: int, y: int) -> int
|
||||
meth edit :: edit() -> context manager
|
||||
meth fill :: fill(index: int) -> None
|
||||
meth fill_rect :: fill_rect(pos: tuple, size: tuple, index: int) -> None
|
||||
meth set :: set(pos, index: int) -> None
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue