File: rectangles.py

package info (click to toggle)
python-qpageview 0.6.2-5
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 780 kB
  • sloc: python: 5,215; makefile: 22
file content (297 lines) | stat: -rw-r--r-- 10,624 bytes parent folder | download | duplicates (2)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
# -*- coding: utf-8 -*-
#
# This file is part of the qpageview package.
#
# Copyright (c) 2010 - 2019 by Wilbert Berendsen
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
# See http://www.gnu.org/licenses/ for more information.


"""
Manages lists of rectangular objects and quickly finds them.
"""

import bisect
import operator


Left   = 0
Top    = 1
Right  = 2
Bottom = 3


class Rectangles:
    """
    Manages a list of rectangular objects and quickly finds objects at
    some point, in some rectangle or intersecting some rectangle.

    The implementation uses four lists of the objects sorted on either
    coordinate, so retrieval is fast.

    Bulk adding is done in the constructor or via the bulk_add() method (which
    clears the indexes, that are recreated on first search).  Single objects
    can be added and deleted, keeping the indexes, but that's slower.

    You should inherit from this class and implement the method get_coords(obj)
    to get the rectangle of the object (x, y, x2, y2). These are requested only
    once. x should be < x2 and y should be < y2.

    """
    def __init__(self, objects=None):
        """Initializes the Rectangles object.

        objects should, if given, be an iterable of rectangular objects, and
        bulk_add() is called on those objects.

        """
        self._items = {} # maps object to the result of func(object)
        self._index = {} # maps side to indices, objects (index=coordinate of that side)
        if objects:
            self.bulk_add(objects)

    def get_coords(self, obj):
        """You should implement this method.

        The result should be a four-tuple with the coordinates of the rectangle
        the object represents (x, y, x2, y2). These are requested only once.
        x should be < x2 and y should be < y2.

        """
        return (0, 0, 0, 0)

    def add(self, obj):
        """Adds an object to our list. Keeps the index intact."""
        if obj in self._items:
            return
        self._items[obj] = coords = self.get_coords(obj)
        for side, (indices, objects) in self._index.items():
            i = bisect.bisect_left(indices, coords[side])
            indices.insert(i, coords[side])
            objects.insert(i, obj)

    def bulk_add(self, objects):
        """Adds many new items to the index using the function given in the constructor.

        After this, the index is cleared and recreated on the first search operation.

        """
        self._items.update((obj, self.get_coords(obj)) for obj in objects)
        self._index.clear()

    def remove(self, obj):
        """Removes an object from our list. Keeps the index intact."""
        del self._items[obj]
        for indices, objects in self._index.values():
            i = objects.index(obj)
            del objects[i]
            del indices[i]

    def clear(self):
        """Empties the list of items."""
        self._items.clear()
        self._index.clear()

    def at(self, x, y):
        """Returns a set() of objects that are touched by the given point."""
        return self._test(
            (self._smaller, Top, y),
            (self._larger, Bottom, y),
            (self._smaller, Left, x),
            (self._larger, Right, x))

    def inside(self, left, top, right, bottom):
        """Returns a set() of objects that are fully in the given rectangle."""
        return self._test(
            (self._larger, Top, top),
            (self._smaller, Bottom, bottom),
            (self._larger, Left, left),
            (self._smaller, Right, right))

    def intersecting(self, left, top, right, bottom):
        """Returns a set() of objects intersecting the given rectangle."""
        return self._test(
            (self._smaller, Top, bottom),
            (self._larger, Bottom, top),
            (self._smaller, Left, right),
            (self._larger, Right, left))

    def width(self, obj):
        """Return the width of the specified object.

        This can be used for sorting a set returned by at(), inside() or
        intersecting(). For example::

            for r in sorted(rects.at(10, 20), key=rects.width):
                # ...

        """
        coords = self._items[obj]
        return coords[Right] - coords[Left]

    def height(self, obj):
        """Return the height of the specified object. See also width()."""
        coords = self._items[obj]
        return coords[Bottom] - coords[Top]

    def closest(self, obj, side):
        """Returns the object closest to the given one, going to the given side."""
        coords = self._items[obj]
        pos = coords[side^2]
        lat = (coords[side^1|2] - coords[side^1&2]) / 2.0
        direction = -1 if side < Right else 1
        indices, objects = self._sorted(side^2)
        i = objects.index(obj)
        mindist = indices[-1]
        result = []
        for other in objects[i+direction::direction]:
            coords = self._items[other]
            pos1 = coords[side^2]
            d = abs(pos1 - pos)
            if d > mindist:
                break
            lat1 = (coords[side^1|2] - coords[side^1&2]) / 2.0
            dlat = abs(lat1 - lat)
            if dlat < d:
                dist = dlat + d  # manhattan dist
                result.append((other, dist))
                mindist = min(mindist, dist)
        if result:
            result.sort(key=lambda r: r[1])
            return result[0][0]

    def nearest(self, x, y):
        """Return the object with the shortest distance to the point x, y.

        The point (x, y) is outside the object. Use at() to get objects that
        touch the point (x, y). If there are no objects, None is returned.

        """
        i = self._items

        left = self._larger(Left, x)            # closest one is first
        right = self._smaller(Right, x)         # closest one is last
        top = self._larger(Top, y)              # closest one is first
        bottom = self._smaller(Bottom, y)       # closest one is last

        result = []

        # first find adjacent rectangles. For each side, as soon as one is
        # found, don't look further for that side. Only save rectangles that are
        # closer but not adjacent, they could be closer on another side.
        left_over = 0
        for o in left:
            if o not in top and o not in bottom:
                result.append((i[o][Left] - x, o))
                break
            left_over += 1
        top_over = 0
        for o in top:
            if o not in left and o not in right:
                result.append((i[o][Top] - y, o))
                break
            top_over += 1
        right_over = 0
        for o in right[::-1]:
            if o not in top and o not in bottom:
                result.append((x - i[o][Right], o))
                break
            right_over -= 1
        bottom_over = 0
        for o in bottom[::-1]:
            if o not in left and o not in right:
                result.append((y - i[o][Bottom], o))
                break
            bottom_over -= 1
        # at most 4 rectangles are found, the closest one on each edge.
        # Now look for rectangles that could be closer at the corner.
        if left_over and top_over:
            for o in set(left[:left_over]).intersection(top[:top_over]):
                result.append((i[o][Left] - x + i[o][Top] - y, o))
        if top_over and right_over:
            for o in set(top[:top_over]).intersection(right[right_over:]):
                result.append((i[o][Top] - y + x - i[o][Right], o))
        if left_over and bottom_over:
            for o in set(left[:left_over]).intersection(bottom[bottom_over:]):
                result.append((i[o][Left] - x + y - i[o][Bottom], o))
        if bottom_over and right_over:
            for o in set(bottom[bottom_over:]).intersection(right[right_over:]):
                result.append((y - i[o][Bottom] + x - i[o][Right], o))

        if result:
            return min(result, key=operator.itemgetter(0))[1]

    def __len__(self):
        """Return the number of objects."""
        return len(self._items)

    def __contains__(self, obj):
        """Return True if the object is managed by us."""
        return obj in self._items

    def __bool__(self):
        """Always return True."""
        return True

    def __iter__(self):
        """Iterate over the objects in undefined order."""
        return iter(self._items)

    # private helper methods
    def _test(self, *tests):
        """Performs tests and returns objects that fulfill all of them.

        Every test should be a three tuple(method, side, value).
        Method is either self._smaller or self._larger.
        Returns a (possibly empty) set.

        """
        meth, side, value = tests[0]
        result = set(meth(side, value))
        if result:
            for meth, side, value in tests[1:]:
                result &= set(meth(side, value))
                if not result:
                    break
        return result

    def _smaller(self, side, value):
        """Returns objects for side below value."""
        indices, objects = self._sorted(side)
        i = bisect.bisect_right(indices, value)
        return objects[:i]

    def _larger(self, side, value):
        """Returns objects for side above value."""
        indices, objects = self._sorted(side)
        i = bisect.bisect_left(indices, value)
        return objects[i:]

    def _sorted(self, side):
        """Returns a two-tuple (indices, objects) sorted on index for the given side."""
        try:
            return self._index[side]
        except KeyError:
            if self._items:
                objects = [(coords[side], obj) for obj, coords in self._items.items()]
                objects.sort(key=operator.itemgetter(0))
                result = tuple(map(list, zip(*objects)))
            else:
                result = [], []
            self._index[side] = result
            return result