Skip to content

Commit d57ba40

Browse files
committed
Do area expire based on the actual polygon instead of the bbox
Expire (multi)polygons based on their shape, not the complete bounding box. We use the existing implementation for the boundary expire, to get the boundary right including the configured buffer. For the rest of the area inside the polygon we use a "polygon drawing algorithm" to figure out all tiles which are inside the polygon. This also contains some changes to the BDD test code to allow running the osm2pgsql-expire program and check its output against a file.
1 parent 4fed109 commit d57ba40

File tree

11 files changed

+8284
-59
lines changed

11 files changed

+8284
-59
lines changed

scripts/osm2pgsql-test-style

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,6 @@ class ReplicationServerMock:
9393
numdiffs = int((max_size + 1023)/1024)
9494
return min(self.state_infos[-1].sequence, start_id + numdiffs - 1)
9595

96-
9796
# Replication module is optional
9897
_repfl_spec = importlib.util.spec_from_loader(
9998
'osm2pgsql_replication',
@@ -510,6 +509,30 @@ def execute_osm2pgsql(context, output):
510509
context.osm2pgsql_returncode = proc.returncode
511510
context.osm2pgsql_params = {'-d': context.user_args.test_db}
512511

512+
@when("running osm2pgsql-expire with parameters")
513+
def execute_osm2pgsql_expire(context):
514+
515+
cmdline = [context.user_args.osm2pgsql_binary + '-expire']
516+
517+
test_dir = (context.user_args.test_data_dir or Path('.')).resolve()
518+
def _template(param):
519+
return param.replace('{TEST_DATA_DIR}', str(test_dir))
520+
521+
if context.table:
522+
assert not any('<' in h for h in context.table.headings), \
523+
"Substition in the first line of a table are not supported."
524+
cmdline.extend(_template(h) for h in context.table.headings if h)
525+
for row in context.table:
526+
cmdline.extend(_template(c) for c in row if c)
527+
528+
proc = Popen(cmdline, cwd=str(context.workdir),
529+
stdout=PIPE, stderr=PIPE)
530+
531+
outdata = proc.communicate()
532+
context.osm2pgsql_cmdline = ' '.join(cmdline)
533+
context.osm2pgsql_outdata = [d.decode('utf-8').replace('\\n', '\n') for d in outdata]
534+
context.osm2pgsql_returncode = proc.returncode
535+
513536
@then("execution is successful")
514537
def osm2pgsql_check_success(context):
515538
assert context.osm2pgsql_returncode == 0, \
@@ -541,6 +564,21 @@ def check_program_output(context, kind):
541564
assert line in s,\
542565
f"Output '{line}' not found in {kind} output:\n{s}\n"
543566

567+
@then(r"the (?P<kind>\w+) output matches contents of (?P<result_file>.*)")
568+
def check_program_output_against_file(context, kind, result_file):
569+
if kind == 'error':
570+
s = context.osm2pgsql_outdata[1]
571+
elif kind == 'standard':
572+
s = context.osm2pgsql_outdata[0]
573+
else:
574+
assert not "Expect one of error, standard"
575+
576+
test_dir = (context.user_args.test_data_dir or Path('.')).resolve()
577+
with open(test_dir / result_file, 'r') as fd:
578+
expected = fd.read()
579+
assert s == expected, \
580+
f"Output does not match contents of {result_file}. Actual output:\n{s}"
581+
544582
################### Steps: Running Replication #####################
545583

546584
@given("the replication service at (?P<base_url>.*)")

src/expire-tiles.cpp

Lines changed: 84 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -136,28 +136,90 @@ expire_mode decide_expire_mode(TGEOM const &geom,
136136

137137
} // anonymous namespace
138138

139-
void expire_tiles_t::from_geometry(geom::polygon_t const &geom,
140-
expire_config_t const &expire_config)
139+
void expire_tiles_t::build_tile_list(std::vector<uint32_t> *tile_x_list,
140+
geom::ring_t const &ring, double tile_y)
141141
{
142-
geom::box_t box;
143-
auto const mode = decide_expire_mode(geom, expire_config, &box);
144-
145-
if (mode == expire_mode::boundary_only) {
146-
from_polygon_boundary(geom, expire_config);
147-
return;
142+
assert(!ring.empty());
143+
for (std::size_t i = 1; i < ring.size(); ++i) {
144+
auto const t1 = coords_to_tile(ring[i]);
145+
auto const t2 = coords_to_tile(ring[i - 1]);
146+
147+
if ((t1.y() < tile_y && t2.y() >= tile_y) ||
148+
(t2.y() < tile_y && t1.y() >= tile_y)) {
149+
auto const pos =
150+
(tile_y - t1.y()) / (t2.y() - t1.y()) * (t2.x() - t1.x());
151+
tile_x_list->push_back(static_cast<uint32_t>(std::clamp(
152+
t1.x() + pos, 0.0, static_cast<double>(m_map_width - 1))));
153+
}
148154
}
155+
}
149156

157+
void expire_tiles_t::from_polygon_area(geom::polygon_t const &geom,
158+
geom::box_t box)
159+
{
150160
if (!box.valid()) {
151161
box = geom::envelope(geom);
152162
}
153-
from_bbox(box, expire_config);
163+
164+
// This uses a variation on a simple polygon fill algorithm, for instance
165+
// described on https://alienryderflex.com/polygon_fill/ . For each row
166+
// of tiles we find the intersections with the area boundary and "fill" in
167+
// the tiles between them. Note that we don't need to take particular care
168+
// about the boundary, because we simply use the algorithm we use for
169+
// expiry along a line to do that, which will also take care of the buffer.
170+
171+
// Coordinates are numbered from bottom to top, tiles are numbered from top
172+
// to bottom, so "min" and "max" are switched here.
173+
auto const max_tile_y = static_cast<std::uint32_t>(
174+
m_map_width * (0.5 - box.min().y() / tile_t::EARTH_CIRCUMFERENCE));
175+
auto const min_tile_y = static_cast<std::uint32_t>(
176+
m_map_width * (0.5 - box.max().y() / tile_t::EARTH_CIRCUMFERENCE));
177+
178+
std::vector<uint32_t> tile_x_list;
179+
180+
// Loop through the tile rows from top to bottom
181+
for (std::uint32_t tile_y = min_tile_y; tile_y < max_tile_y; ++tile_y) {
182+
183+
// Build a list of tiles crossed by the area boundary
184+
tile_x_list.clear();
185+
build_tile_list(&tile_x_list, geom.outer(),
186+
static_cast<double>(tile_y));
187+
for (auto const &ring : geom.inners()) {
188+
build_tile_list(&tile_x_list, ring, static_cast<double>(tile_y));
189+
}
190+
191+
// Sort list of tiles from left to right
192+
std::sort(tile_x_list.begin(), tile_x_list.end());
193+
194+
// Add the tiles between entering and leaving the area to expire list
195+
assert(tile_x_list.size() % 2 == 0);
196+
for (std::size_t i = 0; i < tile_x_list.size(); i += 2) {
197+
if (tile_x_list[i] >= static_cast<uint32_t>(m_map_width - 1)) {
198+
break;
199+
}
200+
if (tile_x_list[i + 1] > 0) {
201+
for (std::uint32_t tile_x = tile_x_list[i];
202+
tile_x < tile_x_list[i + 1]; ++tile_x) {
203+
expire_tile(tile_x, tile_y);
204+
}
205+
}
206+
}
207+
}
154208
}
155209

156-
void expire_tiles_t::from_polygon_boundary(geom::multipolygon_t const &geom,
157-
expire_config_t const &expire_config)
210+
void expire_tiles_t::from_geometry(geom::polygon_t const &geom,
211+
expire_config_t const &expire_config)
158212
{
159-
for (auto const &sgeom : geom) {
160-
from_polygon_boundary(sgeom, expire_config);
213+
geom::box_t box;
214+
auto const mode = decide_expire_mode(geom, expire_config, &box);
215+
216+
from_polygon_boundary(geom, expire_config);
217+
218+
// Only need to expire area if in full_area mode. If there is only a
219+
// single tile expired the whole polygon is inside that tile and we
220+
// don't need to do the polygon expire.
221+
if (mode == expire_mode::full_area && m_dirty_tiles.size() > 1) {
222+
from_polygon_area(geom, box);
161223
}
162224
}
163225

@@ -167,15 +229,18 @@ void expire_tiles_t::from_geometry(geom::multipolygon_t const &geom,
167229
geom::box_t box;
168230
auto const mode = decide_expire_mode(geom, expire_config, &box);
169231

170-
if (mode == expire_mode::boundary_only) {
171-
from_polygon_boundary(geom, expire_config);
172-
return;
232+
for (auto const &sgeom : geom) {
233+
from_polygon_boundary(sgeom, expire_config);
173234
}
174235

175-
if (!box.valid()) {
176-
box = geom::envelope(geom);
236+
// Only need to expire area if in full_area mode. If there is only a
237+
// single tile expired the whole polygon is inside that tile and we
238+
// don't need to do the polygon expire.
239+
if (mode == expire_mode::full_area && m_dirty_tiles.size() > 1) {
240+
for (auto const &sgeom : geom) {
241+
from_polygon_area(sgeom, geom::box_t{});
242+
}
177243
}
178-
from_bbox(box, expire_config);
179244
}
180245

181246
// False positive: Apparently clang-tidy can not see through the visit()
@@ -258,35 +323,6 @@ void expire_tiles_t::from_line_segment(geom::point_t const &a,
258323
}
259324
}
260325

261-
/*
262-
* Expire tiles within a bounding box
263-
*/
264-
int expire_tiles_t::from_bbox(geom::box_t const &box,
265-
expire_config_t const &expire_config)
266-
{
267-
/* Convert the box's Mercator coordinates into tile coordinates */
268-
auto const tmp_min = coords_to_tile({box.min_x(), box.max_y()});
269-
int const min_tile_x =
270-
std::clamp(int(tmp_min.x() - expire_config.buffer), 0, m_map_width - 1);
271-
int const min_tile_y =
272-
std::clamp(int(tmp_min.y() - expire_config.buffer), 0, m_map_width - 1);
273-
274-
auto const tmp_max = coords_to_tile({box.max_x(), box.min_y()});
275-
int const max_tile_x =
276-
std::clamp(int(tmp_max.x() + expire_config.buffer), 0, m_map_width - 1);
277-
int const max_tile_y =
278-
std::clamp(int(tmp_max.y() + expire_config.buffer), 0, m_map_width - 1);
279-
280-
for (int iterator_x = min_tile_x; iterator_x <= max_tile_x; ++iterator_x) {
281-
uint32_t const norm_x = normalise_tile_x_coord(iterator_x);
282-
for (int iterator_y = min_tile_y; iterator_y <= max_tile_y;
283-
++iterator_y) {
284-
expire_tile(norm_x, static_cast<uint32_t>(iterator_y));
285-
}
286-
}
287-
return 0;
288-
}
289-
290326
quadkey_list_t expire_tiles_t::get_tiles()
291327
{
292328
quadkey_list_t tiles;

src/expire-tiles.hpp

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,7 @@ class expire_tiles_t
4040
void from_polygon_boundary(geom::polygon_t const &geom,
4141
expire_config_t const &expire_config);
4242

43-
void from_polygon_boundary(geom::multipolygon_t const &geom,
44-
expire_config_t const &expire_config);
43+
void from_polygon_area(geom::polygon_t const &geom, geom::box_t box);
4544

4645
void from_geometry(geom::nullgeom_t const & /*geom*/,
4746
expire_config_t const & /*expire_config*/)
@@ -74,8 +73,6 @@ class expire_tiles_t
7473
void from_geometry_if_3857(geom::geometry_t const &geom,
7574
expire_config_t const &expire_config);
7675

77-
int from_bbox(geom::box_t const &box, expire_config_t const &expire_config);
78-
7976
/**
8077
* Get tiles as a vector of quadkeys and remove them from the expire_tiles_t
8178
* object.
@@ -107,6 +104,9 @@ class expire_tiles_t
107104

108105
uint32_t normalise_tile_x_coord(int x) const;
109106

107+
void build_tile_list(std::vector<uint32_t> *tile_x_list,
108+
geom::ring_t const &ring, double tile_y);
109+
110110
void from_line_segment(geom::point_t const &a, geom::point_t const &b,
111111
expire_config_t const &expire_config);
112112

tests/bdd/expire/expire.feature

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
Feature: Test osm2pgsql-expire
2+
3+
Scenario: Test with invalid options
4+
When running osm2pgsql-expire with parameters
5+
| -z18 | -m | abc | {TEST_DATA_DIR}/expire/test-data.osm |
6+
Then execution fails
7+
Then the error output contains
8+
"""
9+
Value for --mode must be 'boundary_only', 'full_area', or 'hybrid'
10+
"""
11+
12+
Scenario: Test with invalid options
13+
When running osm2pgsql-expire with parameters
14+
| -z18 | -m | full_area | -f | foo | {TEST_DATA_DIR}/expire/test-data.osm |
15+
Then execution fails
16+
Then the error output contains
17+
"""
18+
Value for --format must be 'tiles' or 'geojson'
19+
"""
20+
21+
Scenario: Test expire for various objects on zoom 18 (geojson format)
22+
When running osm2pgsql-expire with parameters
23+
| -z18 | -m | full_area | -f | geojson | {TEST_DATA_DIR}/expire/test-data.osm |
24+
Then execution is successful
25+
Then the standard output matches contents of expire/test-z18-b0.geojson
26+
27+
Scenario: Test expire for various objects on zoom 18 (tiles format)
28+
When running osm2pgsql-expire with parameters
29+
| -z18 | -m | full_area | -f | tiles | {TEST_DATA_DIR}/expire/test-data.osm |
30+
Then execution is successful
31+
Then the standard output matches contents of expire/test-z18-b0.tiles
32+
33+
Scenario: Test expire for various objects on zoom 18 (geojson format)
34+
When running osm2pgsql-expire with parameters
35+
| -z18 | -m | full_area | -f | geojson | -b | 0.5 | {TEST_DATA_DIR}/expire/test-data.osm |
36+
Then execution is successful
37+
Then the standard output matches contents of expire/test-z18-b05.geojson
38+
39+
Scenario: Test expire for various objects on zoom 18 (tiles format)
40+
When running osm2pgsql-expire with parameters
41+
| -z18 | -m | full_area | -f | tiles | -b | 0.5 | {TEST_DATA_DIR}/expire/test-data.osm |
42+
Then execution is successful
43+
Then the standard output matches contents of expire/test-z18-b05.tiles
44+

0 commit comments

Comments
 (0)