Skip to content

Commit 494f50c

Browse files
pushfooeinarfFengchiW
authored
Fix point in polygon (#2347)
* split up geometry tests * split up geometry tests * Experimental smoke test tool based on einarf's prototype * Point in polygon fix (#2336) * Fixed get point in poly when getting a horizontal edge * legacy changes * Test cleanup * tests: Point in box with zero width or height * Fix collision code and tests * Include both cc and cw polygons --------- Co-authored-by: Einar Forselv <eforselv@gmail.com> Co-authored-by: Wilson (Fengchi) Wang <wilsonfwang@gmail.com>
1 parent a55c68d commit 494f50c

10 files changed

+439
-133
lines changed

arcade/geometry.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ def is_point_in_polygon(x: float, y: float, polygon: Point2List) -> bool:
199199
# segment 'i-next', then check if it lies
200200
# on segment. If it lies, return true, otherwise false
201201
if get_triangle_orientation(polygon[i], p, polygon[next_item]) == 0:
202-
return not is_point_in_box(
202+
return is_point_in_box(
203203
polygon[i],
204204
p,
205205
polygon[next_item],
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
from __future__ import annotations
2+
3+
import builtins
4+
from typing import TypeVar, Type, Generic, Any, Callable
5+
6+
from pyglet.math import Vec2
7+
8+
import arcade
9+
from arcade import SpriteList, Sprite, SpriteSolidColor, load_texture
10+
from arcade.gui import UIManager, NinePatchTexture, UIInputText, UIWidget, UIBoxLayout
11+
from arcade.types import RGBOrA255, Color
12+
13+
GRID_REGULAR = arcade.color.GREEN.replace(a=128)
14+
GRID_HIGHLIGHT = arcade.color.GREEN
15+
16+
17+
TEX_GREY_PANEL_RAW = load_texture(":resources:gui_basic_assets/window/grey_panel.png")
18+
19+
T = TypeVar('T')
20+
21+
def _tname(t: Any) -> str:
22+
if not isinstance(t, builtins.type):
23+
return t.__class__.__name__
24+
else:
25+
return t.__name__
26+
27+
28+
class TypedTextInput(UIInputText, Generic[T]):
29+
def __init__(
30+
self,
31+
parsed_type: Type[T],
32+
*,
33+
to_str: Callable[[T], str] = repr,
34+
from_str: Callable[[str], T] | None = None,
35+
x: float = 0,
36+
y: float = 0,
37+
width: float = 100,
38+
height: float = 24,
39+
text: str = "",
40+
font_name=("Arial",),
41+
font_size: float = 12,
42+
text_color: RGBOrA255 = (0, 0, 0, 255),
43+
error_color: RGBOrA255 = arcade.color.RED,
44+
multiline=False,
45+
size_hint=None,
46+
size_hint_min=None,
47+
size_hint_max=None,
48+
**kwargs,
49+
):
50+
if not isinstance(type, builtins.type):
51+
raise TypeError(f"Expected a type, but got {type}")
52+
super().__init__(
53+
x=x,
54+
y=y,
55+
width=width,
56+
height=height,
57+
text=text,
58+
font_name=font_name,
59+
font_size=font_size,
60+
text_color=text_color,
61+
multiline=multiline,
62+
caret_color=text_color,
63+
size_hint=size_hint,
64+
size_hint_min=size_hint_min,
65+
size_hint_max=size_hint_max,
66+
**kwargs
67+
)
68+
self._error_color = error_color
69+
self._parsed_type: Type[T] = parsed_type
70+
self._to_str = to_str
71+
self._from_str = from_str or parsed_type
72+
self._parsed_value: T = self._from_str(self.text)
73+
74+
@property
75+
def value(self) -> T:
76+
return self._parsed_value
77+
78+
@value.setter
79+
def value(self, new_value: T) -> None:
80+
if not isinstance(new_value, self._parsed_type):
81+
raise TypeError(
82+
f"This {_tname(self)} is of inner type {_tname(self._parsed_type)}"
83+
f", but got {new_value!r}, a {_tname(new_value)}"
84+
)
85+
try:
86+
self._parsed_value = self._from_str(new_value)
87+
self.doc.text = self._to_str(new_value)
88+
self.color = self._text_color
89+
except Exception as e:
90+
self.color = self._error_color
91+
raise e
92+
93+
self.trigger_full_render()
94+
95+
@property
96+
def color(self) -> Color:
97+
return self._color
98+
99+
@color.setter
100+
def color(self, new_color: RGBOrA255) -> None:
101+
# lol, efficiency
102+
validated = Color.from_iterable(new_color)
103+
if self._color == validated:
104+
return
105+
106+
self.caret.color = validated
107+
self.doc.set_style(
108+
0, len(self.text), dict(color=validated)
109+
)
110+
self.trigger_full_render()
111+
112+
@property
113+
def text(self) -> str:
114+
return self.doc.text
115+
116+
@text.setter
117+
def text(self, new_text: str) -> None:
118+
try:
119+
self.doc.text = new_text
120+
validated: T = self._from_str(new_text)
121+
self._parsed_value = validated
122+
self.color = self._text_color
123+
except Exception as e:
124+
self.color = self._error_color
125+
raise e
126+
127+
128+
129+
def draw_crosshair(
130+
where: tuple[float, float],
131+
color=arcade.color.BLACK,
132+
radius: float = 20.0,
133+
border_width: float = 1.0,
134+
) -> None:
135+
x, y = where
136+
arcade.draw.circle.draw_circle_outline(
137+
x, y,
138+
radius,
139+
color=color,
140+
border_width=border_width
141+
)
142+
arcade.draw.draw_line(
143+
x, y - radius, x, y + radius,
144+
color=color, line_width=border_width)
145+
146+
arcade.draw.draw_line(
147+
x - radius, y, x + radius, y,
148+
color=color, line_width=border_width)
149+
150+
151+
class MyGame(arcade.Window):
152+
153+
def add_field_row(self, label_text: str, widget: UIWidget) -> None:
154+
children = (
155+
arcade.gui.widgets.text.UITextArea(
156+
text=label_text,
157+
width=100,
158+
height=20,
159+
color=arcade.color.BLACK,
160+
font_size=12
161+
),
162+
widget
163+
)
164+
row = UIBoxLayout(vertical=False, space_between=10, children=children)
165+
self.rows.add(row)
166+
167+
def __init__(
168+
self,
169+
width: int = 1280,
170+
height: int = 720,
171+
grid_tile_px: int = 100
172+
):
173+
174+
super().__init__(width, height, "Collision Inspector")
175+
# why does this need a context again?
176+
self.nine_patch = NinePatchTexture(
177+
left=5, right=5, top=5, bottom=5, texture=TEX_GREY_PANEL_RAW)
178+
self.ui = UIManager()
179+
self.spritelist: SpriteList[Sprite] = arcade.SpriteList()
180+
181+
182+
textbox_template = dict(width=40, height=20, text_color=arcade.color.BLACK)
183+
self.cursor_x_field = UIInputText(
184+
text="1.0", **textbox_template).with_background(texture=self.nine_patch)
185+
186+
self.cursor_y_field = UIInputText(
187+
text="1.0", **textbox_template).with_background(texture=self.nine_patch)
188+
189+
self.rows = UIBoxLayout(space_between=20).with_background(color=arcade.color.GRAY)
190+
191+
self.grid_tile_px = grid_tile_px
192+
self.ui.add(self.rows)
193+
194+
self.add_field_row("Cursor Y", self.cursor_y_field)
195+
self.add_field_row("Cursor X", self.cursor_x_field)
196+
self.ui.enable()
197+
198+
# for y in range(8):
199+
# for x in range(12):
200+
# sprite = SpriteSolidColor(grid_tile_px, grid_tile_px, color=arcade.color.WHITE)
201+
# sprite.position = x * 101 + 50, y * 101 + 50
202+
# self.spritelist.append(sprite)
203+
self.build_sprite_grid(8, 12, self.grid_tile_px, Vec2(50, 50))
204+
self.background_color = arcade.color.DARK_GRAY
205+
self.set_mouse_visible(False)
206+
self.cursor = 0, 0
207+
self.from_mouse = True
208+
self.on_widget = False
209+
210+
def build_sprite_grid(
211+
self,
212+
columns: int,
213+
rows: int,
214+
grid_tile_px: int,
215+
offset: tuple[float, float] = (0, 0)
216+
):
217+
offset_x, offset_y = offset
218+
self.spritelist.clear()
219+
220+
for row in range(rows):
221+
x = offset_x + grid_tile_px * row
222+
for column in range(columns):
223+
y = offset_y + grid_tile_px * column
224+
sprite = SpriteSolidColor(grid_tile_px, grid_tile_px, color=arcade.color.WHITE)
225+
sprite.position = x, y
226+
self.spritelist.append(sprite)
227+
228+
def on_update(self, dt: float = 1 / 60):
229+
self.cursor = Vec2(self.mouse["x"], self.mouse["y"])
230+
231+
widgets = list(self.ui.get_widgets_at(self.cursor))
232+
on_widget = bool(len(widgets))
233+
234+
if self.on_widget != on_widget:
235+
self.set_mouse_visible(on_widget)
236+
self.on_widget = on_widget
237+
238+
def on_draw(self):
239+
self.clear()
240+
# Reset color
241+
for sprite in self.spritelist:
242+
sprite.color = arcade.color.WHITE
243+
# sprite.angle += 0.2
244+
245+
# Mark hits
246+
hits = arcade.get_sprites_at_point(self.cursor, self.spritelist)
247+
for hit in hits:
248+
hit.color = arcade.color.BLUE
249+
250+
self.spritelist.draw()
251+
self.spritelist.draw_hit_boxes(color=arcade.color.GREEN)
252+
if hits:
253+
arcade.draw.rect.draw_rect_outline(rect=hits[0].rect, color=arcade.color.RED)
254+
if not self.on_widget:
255+
draw_crosshair(self.cursor)
256+
257+
self.ui.draw()
258+
259+
MyGame().run()

tests/unit/atlas/test_region.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import PIL.Image
44

55
from arcade.texture_atlas.region import AtlasRegion
6-
from arcade.texture.texture import Texture, ImageData
6+
from arcade.texture.texture import ImageData
77
from arcade.texture_atlas.atlas_default import DefaultTextureAtlas
88

99

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from arcade.geometry import are_lines_intersecting
2+
3+
4+
def test_are_lines_intersecting():
5+
line_a = [(0, 0), (50, 50)]
6+
line_b = [(0, 0), (50, 50)]
7+
assert are_lines_intersecting(*line_a, *line_b) is True
8+
9+
# Two lines clearly intersecting
10+
line_a = [(0, 0), (50, 50)]
11+
line_b = [(0, 50), (50, 0)]
12+
assert are_lines_intersecting(*line_a, *line_b) is True
13+
14+
# Two parallel lines clearly not intersecting
15+
line_a = [(0, 0), (50, 0)]
16+
line_b = [(0, 50), (0, 50)]
17+
assert are_lines_intersecting(*line_a, *line_b) is False
18+
19+
# Two lines intersecting at the edge points
20+
line_a = [(0, 0), (50, 0)]
21+
line_b = [(0, -50), (0, 50)]
22+
assert are_lines_intersecting(*line_a, *line_b) is True
23+
24+
# Two perpendicular lines almost intersecting
25+
line_a = [(0, 0), (50, 0)]
26+
line_b = [(-1, -50), (-1, 50)]
27+
assert are_lines_intersecting(*line_a, *line_b) is False
28+
29+
# Twp perpendicular lines almost intersecting
30+
line_a = [(0, 0), (50, 0)]
31+
line_b = [(51, -50), (51, 50)]
32+
assert are_lines_intersecting(*line_a, *line_b) is False
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from arcade.geometry import are_polygons_intersecting
2+
3+
4+
def test_intersecting_clear_case():
5+
"""Two polygons clearly intersecting"""
6+
poly_a = [(0, 0), (0, 50), (50, 50), (50, 0)]
7+
poly_b = [(25, 25), (25, 75), (75, 75), (75, 25)]
8+
assert are_polygons_intersecting(poly_a, poly_b) is True
9+
assert are_polygons_intersecting(poly_b, poly_a) is True
10+
11+
12+
def test_empty_polygons():
13+
"""Two empty polys should never intersect"""
14+
poly_a = []
15+
poly_b = []
16+
assert are_polygons_intersecting(poly_a, poly_b) is False
17+
18+
19+
def test_are_mismatched_polygons_breaking():
20+
"""One empty poly should never intersect with a non-empty poly"""
21+
poly_a = [(0, 0), (0, 50), (50, 50), (50, 0)]
22+
poly_b = []
23+
assert are_polygons_intersecting(poly_a, poly_b) is False
24+
assert are_polygons_intersecting(poly_b, poly_a) is False
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from arcade.geometry import get_triangle_orientation
2+
3+
4+
def test_get_triangle_orientation():
5+
triangle_colinear = [(0, 0), (0, 50), (0, 100)]
6+
assert get_triangle_orientation(*triangle_colinear) == 0
7+
8+
triangle_cw = [(0, 0), (0, 50), (50, 50)]
9+
assert get_triangle_orientation(*triangle_cw) == 1
10+
11+
triangle_ccw = list(reversed(triangle_cw))
12+
assert get_triangle_orientation(*triangle_ccw) == 2
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
from arcade.geometry import is_point_in_box
2+
3+
4+
def test_point_inside_center():
5+
"""Points clearly inside the box"""
6+
assert is_point_in_box((0, 0), (50, 50), (100, 100)) is True
7+
assert is_point_in_box((0, 0), (-50, -50), (-100, -100)) is True
8+
assert is_point_in_box((0, 0), (50, -50), (100, -100)) is True
9+
assert is_point_in_box((0, 0), (-50, 50), (-100, 100)) is True
10+
11+
12+
def test_point_intersecting():
13+
"""Points intersecting the box edges"""
14+
# Test each corner
15+
assert is_point_in_box((0, 0), (0, 0), (100, 100)) is True
16+
assert is_point_in_box((0, 0), (100, 100), (100, 100)) is True
17+
assert is_point_in_box((0, 0), (100, 0), (100, 100)) is True
18+
assert is_point_in_box((0, 0), (0, 100), (100, 100)) is True
19+
20+
21+
def test_point_outside_1px():
22+
"""Points outside the box by one pixel"""
23+
assert is_point_in_box((0, 0), (-1, -1), (100, 100)) is False
24+
assert is_point_in_box((0, 0), (101, 101), (100, 100)) is False
25+
assert is_point_in_box((0, 0), (101, -1), (100, 100)) is False
26+
assert is_point_in_box((0, 0), (-1, 101), (100, 100)) is False
27+
28+
29+
def test_zero_box():
30+
"""
31+
A box selection with zero width or height
32+
33+
The selection area should always be included as a hit.
34+
"""
35+
# 1 x 1 pixel box
36+
assert is_point_in_box((0, 0), (0, 0), (0, 0)) is True
37+
# 1 x 100 pixel box
38+
assert is_point_in_box((0, 0), (50, 0), (100, 0)) is True
39+
# 100 x 1 pixel box
40+
assert is_point_in_box((0, 0), (0, 50), (0, 100)) is True

0 commit comments

Comments
 (0)