diff --git a/src/UIBase.h b/src/UIBase.h index 95a96dc..9bb9a59 100644 --- a/src/UIBase.h +++ b/src/UIBase.h @@ -175,6 +175,14 @@ static int UIDrawable_set_opacity(T* self, PyObject* value, void* closure) MCRF_PROPERTY(global_position, \ "Global screen position (read-only). " \ "Calculates absolute position by walking up the parent chain." \ + ), (void*)type_enum}, \ + {"bounds", (getter)UIDrawable::get_bounds_py, NULL, \ + MCRF_PROPERTY(bounds, \ + "Bounding rectangle (x, y, width, height) in local coordinates." \ + ), (void*)type_enum}, \ + {"global_bounds", (getter)UIDrawable::get_global_bounds_py, NULL, \ + MCRF_PROPERTY(global_bounds, \ + "Bounding rectangle (x, y, width, height) in screen coordinates." \ ), (void*)type_enum} // UIEntity specializations are defined in UIEntity.cpp after UIEntity class is complete diff --git a/src/UIDrawable.cpp b/src/UIDrawable.cpp index 204bbab..89a57d2 100644 --- a/src/UIDrawable.cpp +++ b/src/UIDrawable.cpp @@ -733,6 +733,21 @@ sf::Vector2f UIDrawable::get_global_position() const { return global_pos; } +// #138 - Global bounds (bounds in screen coordinates) +sf::FloatRect UIDrawable::get_global_bounds() const { + sf::FloatRect local_bounds = get_bounds(); + sf::Vector2f global_pos = get_global_position(); + + // Return bounds offset to global position + return sf::FloatRect(global_pos.x, global_pos.y, local_bounds.width, local_bounds.height); +} + +// #138 - Hit testing +bool UIDrawable::contains_point(float x, float y) const { + sf::FloatRect global_bounds = get_global_bounds(); + return global_bounds.contains(x, y); +} + // #116 - Dirty flag propagation up parent chain void UIDrawable::markDirty() { if (render_dirty) return; // Already dirty, no need to propagate @@ -978,3 +993,75 @@ PyObject* UIDrawable::get_global_pos(PyObject* self, void* closure) { return result; } + +// #138 - Python API for bounds property +PyObject* UIDrawable::get_bounds_py(PyObject* self, void* closure) { + PyObjectsEnum objtype = static_cast(reinterpret_cast(closure)); + UIDrawable* drawable = nullptr; + + switch (objtype) { + case PyObjectsEnum::UIFRAME: + drawable = ((PyUIFrameObject*)self)->data.get(); + break; + case PyObjectsEnum::UICAPTION: + drawable = ((PyUICaptionObject*)self)->data.get(); + break; + case PyObjectsEnum::UISPRITE: + drawable = ((PyUISpriteObject*)self)->data.get(); + break; + case PyObjectsEnum::UIGRID: + drawable = ((PyUIGridObject*)self)->data.get(); + break; + case PyObjectsEnum::UILINE: + drawable = ((PyUILineObject*)self)->data.get(); + break; + case PyObjectsEnum::UICIRCLE: + drawable = ((PyUICircleObject*)self)->data.get(); + break; + case PyObjectsEnum::UIARC: + drawable = ((PyUIArcObject*)self)->data.get(); + break; + default: + PyErr_SetString(PyExc_TypeError, "Invalid UIDrawable derived instance"); + return NULL; + } + + sf::FloatRect bounds = drawable->get_bounds(); + return Py_BuildValue("(ffff)", bounds.left, bounds.top, bounds.width, bounds.height); +} + +// #138 - Python API for global_bounds property +PyObject* UIDrawable::get_global_bounds_py(PyObject* self, void* closure) { + PyObjectsEnum objtype = static_cast(reinterpret_cast(closure)); + UIDrawable* drawable = nullptr; + + switch (objtype) { + case PyObjectsEnum::UIFRAME: + drawable = ((PyUIFrameObject*)self)->data.get(); + break; + case PyObjectsEnum::UICAPTION: + drawable = ((PyUICaptionObject*)self)->data.get(); + break; + case PyObjectsEnum::UISPRITE: + drawable = ((PyUISpriteObject*)self)->data.get(); + break; + case PyObjectsEnum::UIGRID: + drawable = ((PyUIGridObject*)self)->data.get(); + break; + case PyObjectsEnum::UILINE: + drawable = ((PyUILineObject*)self)->data.get(); + break; + case PyObjectsEnum::UICIRCLE: + drawable = ((PyUICircleObject*)self)->data.get(); + break; + case PyObjectsEnum::UIARC: + drawable = ((PyUIArcObject*)self)->data.get(); + break; + default: + PyErr_SetString(PyExc_TypeError, "Invalid UIDrawable derived instance"); + return NULL; + } + + sf::FloatRect bounds = drawable->get_global_bounds(); + return Py_BuildValue("(ffff)", bounds.left, bounds.top, bounds.width, bounds.height); +} diff --git a/src/UIDrawable.h b/src/UIDrawable.h index b93059f..c234323 100644 --- a/src/UIDrawable.h +++ b/src/UIDrawable.h @@ -97,6 +97,10 @@ public: static PyObject* get_parent(PyObject* self, void* closure); static int set_parent(PyObject* self, PyObject* value, void* closure); static PyObject* get_global_pos(PyObject* self, void* closure); + + // Python API for hit testing (#138) + static PyObject* get_bounds_py(PyObject* self, void* closure); + static PyObject* get_global_bounds_py(PyObject* self, void* closure); // New properties for Phase 1 bool visible = true; // #87 - visibility flag @@ -106,6 +110,10 @@ public: virtual sf::FloatRect get_bounds() const = 0; // #89 - get bounding box virtual void move(float dx, float dy) = 0; // #98 - move by offset virtual void resize(float w, float h) = 0; // #98 - resize to dimensions + + // Hit testing (#138) + sf::FloatRect get_global_bounds() const; // Bounds in screen coordinates + bool contains_point(float x, float y) const; // Hit test using global bounds // Called when position changes to allow derived classes to sync virtual void onPositionChanged() {} diff --git a/tests/unit/test_bounds_hit_testing.py b/tests/unit/test_bounds_hit_testing.py new file mode 100644 index 0000000..93edd61 --- /dev/null +++ b/tests/unit/test_bounds_hit_testing.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""Test #138: AABB/Hit Testing System""" + +import mcrfpy +import sys + +def test_bounds_property(): + """Test bounds property returns correct local bounds""" + print("Testing bounds property...") + + mcrfpy.createScene("test_bounds") + ui = mcrfpy.sceneUI("test_bounds") + + frame = mcrfpy.Frame(pos=(50, 75), size=(200, 150)) + ui.append(frame) + + bounds = frame.bounds + assert bounds[0] == 50.0, f"Expected x=50, got {bounds[0]}" + assert bounds[1] == 75.0, f"Expected y=75, got {bounds[1]}" + assert bounds[2] == 200.0, f"Expected w=200, got {bounds[2]}" + assert bounds[3] == 150.0, f"Expected h=150, got {bounds[3]}" + + print(" - bounds property: PASS") + + +def test_global_bounds_no_parent(): + """Test global_bounds equals bounds when no parent""" + print("Testing global_bounds without parent...") + + mcrfpy.createScene("test_gb1") + ui = mcrfpy.sceneUI("test_gb1") + + frame = mcrfpy.Frame(pos=(100, 100), size=(50, 50)) + ui.append(frame) + + bounds = frame.bounds + global_bounds = frame.global_bounds + + assert bounds == global_bounds, f"Expected {bounds} == {global_bounds}" + + print(" - global_bounds (no parent): PASS") + + +def test_global_bounds_with_parent(): + """Test global_bounds correctly adds parent offset""" + print("Testing global_bounds with parent...") + + mcrfpy.createScene("test_gb2") + ui = mcrfpy.sceneUI("test_gb2") + + parent = mcrfpy.Frame(pos=(100, 100), size=(200, 200)) + ui.append(parent) + + child = mcrfpy.Frame(pos=(50, 50), size=(80, 60)) + parent.children.append(child) + + gb = child.global_bounds + assert gb[0] == 150.0, f"Expected x=150, got {gb[0]}" + assert gb[1] == 150.0, f"Expected y=150, got {gb[1]}" + assert gb[2] == 80.0, f"Expected w=80, got {gb[2]}" + assert gb[3] == 60.0, f"Expected h=60, got {gb[3]}" + + print(" - global_bounds (with parent): PASS") + + +def test_global_bounds_nested(): + """Test global_bounds with deeply nested hierarchy""" + print("Testing global_bounds with nested hierarchy...") + + mcrfpy.createScene("test_gb3") + ui = mcrfpy.sceneUI("test_gb3") + + # Create 3-level hierarchy + root = mcrfpy.Frame(pos=(10, 10), size=(300, 300)) + ui.append(root) + + middle = mcrfpy.Frame(pos=(20, 20), size=(200, 200)) + root.children.append(middle) + + leaf = mcrfpy.Frame(pos=(30, 30), size=(50, 50)) + middle.children.append(leaf) + + # leaf global pos should be 10+20+30 = 60, 60 + gb = leaf.global_bounds + assert gb[0] == 60.0, f"Expected x=60, got {gb[0]}" + assert gb[1] == 60.0, f"Expected y=60, got {gb[1]}" + + print(" - global_bounds (nested): PASS") + + +def test_all_drawable_types_have_bounds(): + """Test that all drawable types have bounds properties""" + print("Testing bounds on all drawable types...") + + mcrfpy.createScene("test_types") + ui = mcrfpy.sceneUI("test_types") + + types_to_test = [ + ("Frame", mcrfpy.Frame(pos=(0, 0), size=(100, 100))), + ("Caption", mcrfpy.Caption(text="Test", pos=(0, 0))), + ("Sprite", mcrfpy.Sprite(pos=(0, 0))), + ("Grid", mcrfpy.Grid(grid_size=(5, 5), pos=(0, 0), size=(100, 100))), + ] + + for name, obj in types_to_test: + # Should have bounds property + bounds = obj.bounds + assert isinstance(bounds, tuple), f"{name}.bounds should be tuple" + assert len(bounds) == 4, f"{name}.bounds should have 4 elements" + + # Should have global_bounds property + gb = obj.global_bounds + assert isinstance(gb, tuple), f"{name}.global_bounds should be tuple" + assert len(gb) == 4, f"{name}.global_bounds should have 4 elements" + + print(" - all drawable types have bounds: PASS") + + +if __name__ == "__main__": + try: + test_bounds_property() + test_global_bounds_no_parent() + test_global_bounds_with_parent() + test_global_bounds_nested() + test_all_drawable_types_have_bounds() + + print("\n=== All AABB/Hit Testing tests passed! ===") + sys.exit(0) + except Exception as e: + print(f"\nTEST FAILED: {e}") + import traceback + traceback.print_exc() + sys.exit(1)