From e58b44ef82cf36b774812487e6a7765cf657c7b2 Mon Sep 17 00:00:00 2001 From: John McCardle Date: Fri, 10 Apr 2026 01:01:41 -0400 Subject: [PATCH] Add missing markDirty()/markCompositeDirty() to all Python property setters Fixes a systemic bug where Python tp_getset property setters bypassed the render cache dirty flag system (#144). The animation/C++ setProperty() path had correct dirty propagation, but direct Python property assignments (e.g. frame.x = 50, caption.text = "Hello") did not invalidate the parent Frame's render cache when clip_children or cache_subtree was enabled. Changes by file: - UIDrawable.cpp: Add markCompositeDirty() to set_float_member (x/y), set_pos, set_grid_pos; add markDirty() for w/h resize - UICaption.cpp: Add markDirty() to set_text, set_color_member, set_float_member (outline/font_size); markCompositeDirty() for position - UICollection.cpp: Add markContentDirty() on owner in append, remove, pop, insert, extend, setitem, and slice assignment/deletion - UISprite.cpp: Add markDirty() to scale/sprite_index/texture setters; markCompositeDirty() to position setters - UICircle.cpp: Add markDirty() to radius/fill_color/outline_color/outline; markCompositeDirty() to center setter - UILine.cpp: Add markDirty() to start/end/color/thickness setters - UIArc.cpp: Add markDirty() to radius/angles/color/thickness setters; markCompositeDirty() to center setter - UIGrid.cpp: Add markDirty() to center/zoom/camera_rotation/fill_color/ size/perspective/fov setters Closes #288, closes #289, closes #290, closes #291 Co-Authored-By: Claude Opus 4.6 --- src/UIArc.cpp | 6 + src/UICaption.cpp | 20 +- src/UICircle.cpp | 5 + src/UICollection.cpp | 59 +++- src/UIDrawable.cpp | 5 + src/UIGrid.cpp | 18 +- src/UILine.cpp | 4 + src/UISprite.cpp | 25 +- .../issue_288_291_dirty_flags_test.py | 275 ++++++++++++++++++ 9 files changed, 403 insertions(+), 14 deletions(-) create mode 100644 tests/regression/issue_288_291_dirty_flags_test.py diff --git a/src/UIArc.cpp b/src/UIArc.cpp index e8a6add..2f4a7eb 100644 --- a/src/UIArc.cpp +++ b/src/UIArc.cpp @@ -404,6 +404,7 @@ int UIArc::set_center(PyUIArcObject* self, PyObject* value, void* closure) { return -1; } self->data->setCenter(vec->data); + self->data->markCompositeDirty(); // #291: position change return 0; } @@ -417,6 +418,7 @@ int UIArc::set_radius(PyUIArcObject* self, PyObject* value, void* closure) { return -1; } self->data->setRadius(static_cast(PyFloat_AsDouble(value))); + self->data->markDirty(); // #291: visual change return 0; } @@ -430,6 +432,7 @@ int UIArc::set_start_angle(PyUIArcObject* self, PyObject* value, void* closure) return -1; } self->data->setStartAngle(static_cast(PyFloat_AsDouble(value))); + self->data->markDirty(); // #291: visual change return 0; } @@ -443,6 +446,7 @@ int UIArc::set_end_angle(PyUIArcObject* self, PyObject* value, void* closure) { return -1; } self->data->setEndAngle(static_cast(PyFloat_AsDouble(value))); + self->data->markDirty(); // #291: visual change return 0; } @@ -462,6 +466,7 @@ int UIArc::set_color(PyUIArcObject* self, PyObject* value, void* closure) { return -1; } self->data->setColor(color->data); + self->data->markDirty(); // #291: color change return 0; } @@ -475,6 +480,7 @@ int UIArc::set_thickness(PyUIArcObject* self, PyObject* value, void* closure) { return -1; } self->data->setThickness(static_cast(PyFloat_AsDouble(value))); + self->data->markDirty(); // #291: visual change return 0; } diff --git a/src/UICaption.cpp b/src/UICaption.cpp index b3d8690..a066b40 100644 --- a/src/UICaption.cpp +++ b/src/UICaption.cpp @@ -200,14 +200,22 @@ int UICaption::set_float_member(PyUICaptionObject* self, PyObject* value, void* PyErr_SetString(PyExc_TypeError, "Value must be a number (int or float)"); return -1; } - if (member_ptr == 0) //x + if (member_ptr == 0) { //x self->data->text.setPosition(val, self->data->text.getPosition().y); - else if (member_ptr == 1) //y + self->data->markCompositeDirty(); // #289: position change invalidates parent cache + } + else if (member_ptr == 1) { //y self->data->text.setPosition(self->data->text.getPosition().x, val); - else if (member_ptr == 4) //outline + self->data->markCompositeDirty(); // #289: position change invalidates parent cache + } + else if (member_ptr == 4) { //outline self->data->text.setOutlineThickness(val); - else if (member_ptr == 5) // character size + self->data->markDirty(); // #289: content change invalidates own + parent cache + } + else if (member_ptr == 5) { // character size self->data->text.setCharacterSize(val); + self->data->markDirty(); // #289: content change invalidates own + parent cache + } return 0; } @@ -219,6 +227,7 @@ PyObject* UICaption::get_vec_member(PyUICaptionObject* self, void* closure) int UICaption::set_vec_member(PyUICaptionObject* self, PyObject* value, void* closure) { self->data->text.setPosition(PyVector::fromPy(value)); + self->data->markCompositeDirty(); // #289: position change invalidates parent cache return 0; } @@ -289,10 +298,12 @@ int UICaption::set_color_member(PyUICaptionObject* self, PyObject* value, void* if (member_ptr == 0) { self->data->text.setFillColor(sf::Color(r, g, b, a)); + self->data->markDirty(); // #289: color change invalidates own + parent cache } else if (member_ptr == 1) { self->data->text.setOutlineColor(sf::Color(r, g, b, a)); + self->data->markDirty(); // #289: color change invalidates own + parent cache } else { @@ -329,6 +340,7 @@ int UICaption::set_text(PyUICaptionObject* self, PyObject* value, void* closure) Py_DECREF(temp_bytes); } self->data->text.setString(Resources::caption_buffer); + self->data->markDirty(); // #289: text change invalidates own + parent cache return 0; } diff --git a/src/UICircle.cpp b/src/UICircle.cpp index c70f348..7f503ed 100644 --- a/src/UICircle.cpp +++ b/src/UICircle.cpp @@ -337,6 +337,7 @@ int UICircle::set_radius(PyUICircleObject* self, PyObject* value, void* closure) return -1; } self->data->setRadius(static_cast(PyFloat_AsDouble(value))); + self->data->markDirty(); // #291: visual change return 0; } @@ -355,6 +356,7 @@ int UICircle::set_center(PyUICircleObject* self, PyObject* value, void* closure) return -1; } self->data->setCenter(vec->data); + self->data->markCompositeDirty(); // #291: position change return 0; } @@ -382,6 +384,7 @@ int UICircle::set_fill_color(PyUICircleObject* self, PyObject* value, void* clos return -1; } self->data->setFillColor(color); + self->data->markDirty(); // #291: color change return 0; } @@ -409,6 +412,7 @@ int UICircle::set_outline_color(PyUICircleObject* self, PyObject* value, void* c return -1; } self->data->setOutlineColor(color); + self->data->markDirty(); // #291: color change return 0; } @@ -422,6 +426,7 @@ int UICircle::set_outline(PyUICircleObject* self, PyObject* value, void* closure return -1; } self->data->setOutline(static_cast(PyFloat_AsDouble(value))); + self->data->markDirty(); // #291: visual change return 0; } diff --git a/src/UICollection.cpp b/src/UICollection.cpp index 71eecd4..3f57f8c 100644 --- a/src/UICollection.cpp +++ b/src/UICollection.cpp @@ -271,6 +271,11 @@ int UICollection::setitem(PyUICollectionObject* self, Py_ssize_t index, PyObject // #122: Clear the parent before removing (*self->data)[index]->setParent(nullptr); self->data->erase(self->data->begin() + index); + // #288: Invalidate parent Frame's render cache + auto owner_ptr = self->owner.lock(); + if (owner_ptr) { + owner_ptr->markContentDirty(); + } return 0; } @@ -302,6 +307,12 @@ int UICollection::setitem(PyUICollectionObject* self, Py_ssize_t index, PyObject // Mark scene as needing resort after replacing element McRFPy_API::markSceneNeedsSort(); + // #288: Invalidate parent Frame's render cache + auto owner_ptr = self->owner.lock(); + if (owner_ptr) { + owner_ptr->markContentDirty(); + } + return 0; } @@ -494,7 +505,14 @@ int UICollection::ass_subscript(PyUICollectionObject* self, PyObject* key, PyObj // Mark scene as needing resort after slice deletion McRFPy_API::markSceneNeedsSort(); - + // #288: Invalidate parent Frame's render cache + { + auto owner_ptr = self->owner.lock(); + if (owner_ptr) { + owner_ptr->markContentDirty(); + } + } + return 0; } else { // Assignment @@ -564,7 +582,14 @@ int UICollection::ass_subscript(PyUICollectionObject* self, PyObject* key, PyObj // Mark scene as needing resort after slice assignment McRFPy_API::markSceneNeedsSort(); - + // #288: Invalidate parent Frame's render cache + { + auto owner_ptr = self->owner.lock(); + if (owner_ptr) { + owner_ptr->markContentDirty(); + } + } + return 0; } } else { @@ -635,6 +660,12 @@ PyObject* UICollection::append(PyUICollectionObject* self, PyObject* o) // Mark scene as needing resort after adding element McRFPy_API::markSceneNeedsSort(); + // #288: Invalidate parent Frame's render cache + auto owner_ptr = self->owner.lock(); + if (owner_ptr) { + owner_ptr->markContentDirty(); + } + Py_INCREF(Py_None); return Py_None; } @@ -689,7 +720,12 @@ PyObject* UICollection::extend(PyUICollectionObject* self, PyObject* iterable) // Mark scene as needing resort after adding elements McRFPy_API::markSceneNeedsSort(); - + + // #288: Invalidate parent Frame's render cache + if (owner_ptr) { + owner_ptr->markContentDirty(); + } + Py_INCREF(Py_None); return Py_None; } @@ -717,6 +753,11 @@ PyObject* UICollection::remove(PyUICollectionObject* self, PyObject* o) (*it)->setParent(nullptr); vec->erase(it); McRFPy_API::markSceneNeedsSort(); + // #288: Invalidate parent Frame's render cache + auto owner_ptr = self->owner.lock(); + if (owner_ptr) { + owner_ptr->markContentDirty(); + } Py_RETURN_NONE; } } @@ -766,6 +807,12 @@ PyObject* UICollection::pop(PyUICollectionObject* self, PyObject* args) McRFPy_API::markSceneNeedsSort(); + // #288: Invalidate parent Frame's render cache + auto owner_ptr = self->owner.lock(); + if (owner_ptr) { + owner_ptr->markContentDirty(); + } + // Convert to Python object and return return convertDrawableToPython(drawable); } @@ -817,6 +864,12 @@ PyObject* UICollection::insert(PyUICollectionObject* self, PyObject* args) McRFPy_API::markSceneNeedsSort(); + // #288: Invalidate parent Frame's render cache + auto owner_ptr2 = self->owner.lock(); + if (owner_ptr2) { + owner_ptr2->markContentDirty(); + } + Py_RETURN_NONE; } diff --git a/src/UIDrawable.cpp b/src/UIDrawable.cpp index d82dfe3..b349fe9 100644 --- a/src/UIDrawable.cpp +++ b/src/UIDrawable.cpp @@ -545,10 +545,12 @@ int UIDrawable::set_float_member(PyObject* self, PyObject* value, void* closure) case 0: // x drawable->position.x = val; drawable->onPositionChanged(); + drawable->markCompositeDirty(); // #290: position change invalidates parent cache break; case 1: // y drawable->position.y = val; drawable->onPositionChanged(); + drawable->markCompositeDirty(); // #290: position change invalidates parent cache break; case 2: // w case 3: // h @@ -559,6 +561,7 @@ int UIDrawable::set_float_member(PyObject* self, PyObject* value, void* closure) } else { drawable->resize(bounds.width, val); } + drawable->markDirty(); // #290: size change invalidates own + parent cache } break; default: @@ -638,6 +641,7 @@ int UIDrawable::set_pos(PyObject* self, PyObject* value, void* closure) { drawable->position = sf::Vector2f(x, y); drawable->onPositionChanged(); + drawable->markCompositeDirty(); // #290: position change invalidates parent cache return 0; } @@ -873,6 +877,7 @@ int UIDrawable::set_grid_pos(PyObject* self, PyObject* value, void* closure) { drawable->position.x = grid_x * cell_size.x; drawable->position.y = grid_y * cell_size.y; drawable->onPositionChanged(); + drawable->markCompositeDirty(); // #290: position change invalidates parent cache return 0; } diff --git a/src/UIGrid.cpp b/src/UIGrid.cpp index 2deca60..1004a2a 100644 --- a/src/UIGrid.cpp +++ b/src/UIGrid.cpp @@ -1074,7 +1074,8 @@ int UIGrid::set_size(PyUIGridObject* self, PyObject* value, void* closure) { tex_height = std::min(tex_height, 4096u); self->data->renderTexture.create(tex_width, tex_height); - + self->data->markDirty(); // #291: size change + return 0; } @@ -1091,6 +1092,7 @@ int UIGrid::set_center(PyUIGridObject* self, PyObject* value, void* closure) { } self->data->center_x = x; self->data->center_y = y; + self->data->markDirty(); // #291: camera position change return 0; } @@ -1186,6 +1188,14 @@ int UIGrid::set_float_member(PyUIGridObject* self, PyObject* value, void* closur else if (member_ptr == 7) self->view->camera_rotation = val; self->view->position = self->view->box.getPosition(); } + + // #291: Dirty flag propagation for visual property changes + if (member_ptr == 0 || member_ptr == 1) { + self->data->markCompositeDirty(); // position change + } else { + self->data->markDirty(); // content/size change + } + return 0; } // TODO (7DRL Day 2, item 5.) return Texture object @@ -1310,6 +1320,7 @@ int UIGrid::set_fill_color(PyUIGridObject* self, PyObject* value, void* closure) PyColorObject* color = (PyColorObject*)value; self->data->fill_color = color->data; + self->data->markDirty(); // #291: color change return 0; } @@ -1342,6 +1353,7 @@ int UIGrid::set_perspective(PyUIGridObject* self, PyObject* value, void* closure if (value == Py_None) { // Clear perspective but keep perspective_enabled unchanged self->data->perspective_entity.reset(); + self->data->markDirty(); // #291: FOV rendering change return 0; } @@ -1354,6 +1366,7 @@ int UIGrid::set_perspective(PyUIGridObject* self, PyObject* value, void* closure PyUIEntityObject* entity_obj = (PyUIEntityObject*)value; self->data->perspective_entity = entity_obj->data; self->data->perspective_enabled = true; // Enable perspective when entity assigned + self->data->markDirty(); // #291: FOV rendering change return 0; } @@ -1369,6 +1382,7 @@ int UIGrid::set_perspective_enabled(PyUIGridObject* self, PyObject* value, void* return -1; // Error occurred } self->data->perspective_enabled = enabled; + self->data->markDirty(); // #291: FOV rendering toggle return 0; } @@ -1401,6 +1415,7 @@ int UIGrid::set_fov(PyUIGridObject* self, PyObject* value, void* closure) return -1; } self->data->fov_algorithm = algo; + self->data->markDirty(); // #291: FOV algorithm change return 0; } @@ -1425,6 +1440,7 @@ int UIGrid::set_fov_radius(PyUIGridObject* self, PyObject* value, void* closure) return -1; } self->data->fov_radius = (int)radius; + self->data->markDirty(); // #291: FOV radius change return 0; } diff --git a/src/UILine.cpp b/src/UILine.cpp index 9f80377..c02b399 100644 --- a/src/UILine.cpp +++ b/src/UILine.cpp @@ -423,6 +423,7 @@ int UILine::set_start(PyUILineObject* self, PyObject* value, void* closure) { return -1; } self->data->setStart(vec->data); + self->data->markDirty(); // #291: visual change return 0; } @@ -443,6 +444,7 @@ int UILine::set_end(PyUILineObject* self, PyObject* value, void* closure) { return -1; } self->data->setEnd(vec->data); + self->data->markDirty(); // #291: visual change return 0; } @@ -462,6 +464,7 @@ int UILine::set_color(PyUILineObject* self, PyObject* value, void* closure) { return -1; } self->data->setColor(color->data); + self->data->markDirty(); // #291: color change return 0; } @@ -486,6 +489,7 @@ int UILine::set_thickness(PyUILineObject* self, PyObject* value, void* closure) } self->data->setThickness(thickness); + self->data->markDirty(); // #291: visual change return 0; } diff --git a/src/UISprite.cpp b/src/UISprite.cpp index e5ddee4..784f8c7 100644 --- a/src/UISprite.cpp +++ b/src/UISprite.cpp @@ -285,16 +285,26 @@ int UISprite::set_float_member(PyUISpriteObject* self, PyObject* value, void* cl PyErr_SetString(PyExc_TypeError, "Value must be a number (int or float)"); return -1; } - if (member_ptr == 0) //x + if (member_ptr == 0) { //x self->data->setPosition(sf::Vector2f(val, self->data->getPosition().y)); - else if (member_ptr == 1) //y + self->data->markCompositeDirty(); // #291: position change + } + else if (member_ptr == 1) { //y self->data->setPosition(sf::Vector2f(self->data->getPosition().x, val)); - else if (member_ptr == 2) // scale (uniform) + self->data->markCompositeDirty(); // #291: position change + } + else if (member_ptr == 2) { // scale (uniform) self->data->setScale(sf::Vector2f(val, val)); - else if (member_ptr == 3) // scale_x + self->data->markDirty(); // #291: visual change + } + else if (member_ptr == 3) { // scale_x self->data->setScale(sf::Vector2f(val, self->data->getScale().y)); - else if (member_ptr == 4) // scale_y + self->data->markDirty(); // #291: visual change + } + else if (member_ptr == 4) { // scale_y self->data->setScale(sf::Vector2f(self->data->getScale().x, val)); + self->data->markDirty(); // #291: visual change + } return 0; } @@ -339,6 +349,7 @@ int UISprite::set_int_member(PyUISpriteObject* self, PyObject* value, void* clos } self->data->setSpriteIndex(val); + self->data->markDirty(); // #291: sprite content change return 0; } @@ -364,7 +375,8 @@ int UISprite::set_texture(PyUISpriteObject* self, PyObject* value, void* closure // Update the sprite's texture self->data->setTexture(pytexture->data); - + self->data->markDirty(); // #291: texture change + return 0; } @@ -387,6 +399,7 @@ int UISprite::set_pos(PyUISpriteObject* self, PyObject* value, void* closure) return -1; } self->data->setPosition(vec->data); + self->data->markCompositeDirty(); // #291: position change return 0; } diff --git a/tests/regression/issue_288_291_dirty_flags_test.py b/tests/regression/issue_288_291_dirty_flags_test.py new file mode 100644 index 0000000..136959a --- /dev/null +++ b/tests/regression/issue_288_291_dirty_flags_test.py @@ -0,0 +1,275 @@ +"""Test render cache dirty flag propagation for issues #288-#291. + +#288: UICollection mutations don't invalidate parent Frame's render cache +#289: Caption Python property setters don't call markDirty() +#290: UIDrawable base x/y/pos setters don't propagate dirty flags to parent +#291: Audit all Python property setters for missing markDirty() calls + +These tests exercise all property setters that were missing dirty flag calls, +inside a clip_children=True Frame (which uses render caching). The test verifies +that no crashes occur and properties are correctly set after modification. +Visual correctness requires a non-headless render test. +""" +import mcrfpy +import sys + +test_pass = True +test_count = 0 +fail_count = 0 + +def check(condition, msg): + global test_pass, test_count, fail_count + test_count += 1 + if not condition: + print(f" FAIL: {msg}") + test_pass = False + fail_count += 1 + +# Create a scene with a clipped parent frame (uses render caching) +scene = mcrfpy.Scene("test_dirty_flags") +mcrfpy.current_scene = scene + +parent = mcrfpy.Frame(pos=(10, 10), size=(800, 600), + fill_color=mcrfpy.Color(40, 40, 40), + clip_children=True) +scene.children.append(parent) + +# ============================================================ +# Test #290: UIDrawable base x/y/pos setters (all drawable types) +# ============================================================ +print("Testing #290: UIDrawable position setters...") + +frame = mcrfpy.Frame(pos=(10, 10), size=(100, 100), + fill_color=mcrfpy.Color(255, 0, 0)) +parent.children.append(frame) + +# Test x setter +frame.x = 50.0 +check(frame.x == 50.0, "frame.x setter") + +# Test y setter +frame.y = 60.0 +check(frame.y == 60.0, "frame.y setter") + +# Test pos setter (tuple) +frame.pos = (70.0, 80.0) +check(frame.x == 70.0 and frame.y == 80.0, "frame.pos setter (tuple)") + +# Test w/h setters +frame.w = 200.0 +check(frame.w == 200.0, "frame.w setter") +frame.h = 150.0 +check(frame.h == 150.0, "frame.h setter") + +# ============================================================ +# Test #289: Caption property setters +# ============================================================ +print("Testing #289: Caption property setters...") + +cap = mcrfpy.Caption(text="Hello", pos=(100, 100)) +parent.children.append(cap) + +# Text setter +cap.text = "World" +check(cap.text == "World", "caption.text setter") + +# Fill color setter +cap.fill_color = mcrfpy.Color(255, 0, 0) +c = cap.fill_color +check(c.r == 255 and c.g == 0 and c.b == 0, "caption.fill_color setter") + +# Outline color setter +cap.outline_color = mcrfpy.Color(0, 255, 0) +c = cap.outline_color +check(c.r == 0 and c.g == 255 and c.b == 0, "caption.outline_color setter") + +# Outline thickness setter +cap.outline = 2.0 +check(cap.outline == 2.0, "caption.outline setter") + +# Font size setter +cap.font_size = 24 +check(cap.font_size == 24, "caption.font_size setter") + +# ============================================================ +# Test #288: UICollection mutations +# ============================================================ +print("Testing #288: UICollection mutations...") + +# append (already tested above, but test with clip_children parent) +child1 = mcrfpy.Frame(pos=(0, 0), size=(20, 20), + fill_color=mcrfpy.Color(0, 0, 255)) +initial_count = len(parent.children) +parent.children.append(child1) +check(len(parent.children) == initial_count + 1, "collection append") + +# insert +child2 = mcrfpy.Frame(pos=(30, 0), size=(20, 20), + fill_color=mcrfpy.Color(0, 255, 0)) +parent.children.insert(0, child2) +check(len(parent.children) == initial_count + 2, "collection insert") + +# setitem (replace) +child3 = mcrfpy.Frame(pos=(60, 0), size=(20, 20), + fill_color=mcrfpy.Color(255, 255, 0)) +parent.children[0] = child3 +check(len(parent.children) == initial_count + 2, "collection setitem") + +# remove +parent.children.remove(child1) +check(len(parent.children) == initial_count + 1, "collection remove") + +# pop +popped = parent.children.pop() +check(len(parent.children) == initial_count, "collection pop") + +# extend +extras = [ + mcrfpy.Frame(pos=(0, 200), size=(20, 20), fill_color=mcrfpy.Color(128, 128, 128)), + mcrfpy.Frame(pos=(30, 200), size=(20, 20), fill_color=mcrfpy.Color(64, 64, 64)) +] +parent.children.extend(extras) +check(len(parent.children) == initial_count + 2, "collection extend") + +# slice deletion +del parent.children[initial_count:] +check(len(parent.children) == initial_count, "collection slice delete") + +# ============================================================ +# Test #291: UISprite property setters +# ============================================================ +print("Testing #291: UISprite property setters...") + +# Need a texture for sprite tests - use a test texture if available +try: + tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) + sprite = mcrfpy.Sprite(pos=(200, 200), texture=tex, sprite_index=0) + parent.children.append(sprite) + + sprite.scale = 2.0 + check(sprite.scale == 2.0, "sprite.scale setter") + + sprite.sprite_index = 1 + check(sprite.sprite_index == 1, "sprite.sprite_index setter") + + # Texture setter + sprite.texture = tex + check(True, "sprite.texture setter (no crash)") + + # Pos setter + sprite.pos = (210, 210) + check(True, "sprite.pos setter (no crash)") +except Exception as e: + print(f" (Sprite tests skipped - no test texture: {e})") + +# ============================================================ +# Test #291: UICircle property setters +# ============================================================ +print("Testing #291: UICircle property setters...") + +circle = mcrfpy.Circle(radius=25.0, center=(300, 300), + fill_color=mcrfpy.Color(255, 128, 0)) +parent.children.append(circle) + +circle.radius = 30.0 +check(circle.radius == 30.0, "circle.radius setter") + +circle.fill_color = mcrfpy.Color(0, 128, 255) +c = circle.fill_color +check(c.r == 0 and c.g == 128 and c.b == 255, "circle.fill_color setter") + +circle.outline_color = mcrfpy.Color(255, 255, 255) +c = circle.outline_color +check(c.r == 255, "circle.outline_color setter") + +circle.outline = 3.0 +check(circle.outline == 3.0, "circle.outline setter") + +# ============================================================ +# Test #291: UILine property setters +# ============================================================ +print("Testing #291: UILine property setters...") + +line = mcrfpy.Line(start=(10, 400), end=(200, 400), + thickness=2.0, color=mcrfpy.Color(255, 0, 255)) +parent.children.append(line) + +line.start = (20, 410) +check(True, "line.start setter (no crash)") + +line.end = (210, 410) +check(True, "line.end setter (no crash)") + +line.color = mcrfpy.Color(0, 255, 255) +c = line.color +check(c.r == 0 and c.g == 255 and c.b == 255, "line.color setter") + +line.thickness = 4.0 +check(line.thickness == 4.0, "line.thickness setter") + +# ============================================================ +# Test #291: UIArc property setters +# ============================================================ +print("Testing #291: UIArc property setters...") + +arc = mcrfpy.Arc(center=(400, 300), radius=40.0, start_angle=0.0, + end_angle=180.0, color=mcrfpy.Color(128, 0, 255), + thickness=3.0) +parent.children.append(arc) + +arc.radius = 50.0 +check(arc.radius == 50.0, "arc.radius setter") + +arc.start_angle = 45.0 +check(arc.start_angle == 45.0, "arc.start_angle setter") + +arc.end_angle = 270.0 +check(arc.end_angle == 270.0, "arc.end_angle setter") + +arc.color = mcrfpy.Color(255, 128, 128) +c = arc.color +check(c.r == 255 and c.g == 128 and c.b == 128, "arc.color setter") + +arc.thickness = 5.0 +check(arc.thickness == 5.0, "arc.thickness setter") + +# ============================================================ +# Test #291: UIGrid property setters +# ============================================================ +print("Testing #291: UIGrid property setters...") + +try: + grid = mcrfpy.Grid(grid_size=(10, 10), pos=(500, 100), size=(200, 200)) + parent.children.append(grid) + + grid.center_x = 5.0 + check(True, "grid.center_x setter (no crash)") + + grid.center_y = 5.0 + check(True, "grid.center_y setter (no crash)") + + grid.zoom = 2.0 + check(grid.zoom == 2.0, "grid.zoom setter") + + grid.fill_color = mcrfpy.Color(20, 20, 40) + check(True, "grid.fill_color setter (no crash)") +except Exception as e: + print(f" (Grid tests skipped: {e})") + +# ============================================================ +# Trigger a render cycle to exercise dirty flag code paths +# ============================================================ +print("Triggering render cycle...") +mcrfpy.step(0.016) # ~1 frame at 60fps + +# ============================================================ +# Summary +# ============================================================ +print(f"\n{'='*50}") +print(f"Results: {test_count - fail_count}/{test_count} passed") +if test_pass: + print("PASS: All dirty flag propagation tests passed") + sys.exit(0) +else: + print(f"FAIL: {fail_count} test(s) failed") + sys.exit(1)