diff --git a/src/c3nav/mapdata/migrations/0038_level_render_data.py b/src/c3nav/mapdata/migrations/0038_level_render_data.py new file mode 100644 index 00000000..b268eb6c --- /dev/null +++ b/src/c3nav/mapdata/migrations/0038_level_render_data.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.6 on 2017-10-20 19:43 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('mapdata', '0037_level_geoms_cache'), + ] + + operations = [ + migrations.RenameField( + model_name='level', + old_name='geoms_cache', + new_name='render_data', + ), + migrations.AlterField( + model_name='level', + name='render_data', + field=models.BinaryField(null=True), + ), + ] diff --git a/src/c3nav/mapdata/models/level.py b/src/c3nav/mapdata/models/level.py index dc9d2065..a3c9079a 100644 --- a/src/c3nav/mapdata/models/level.py +++ b/src/c3nav/mapdata/models/level.py @@ -19,7 +19,7 @@ from c3nav.mapdata.utils.svg import SVGImage class LevelManager(models.Manager): def get_queryset(self, *args, **kwargs): - return super().get_queryset(*args, **kwargs).defer('geoms_cache') + return super().get_queryset(*args, **kwargs).defer('render_data') class Level(SpecificLocation, models.Model): @@ -31,7 +31,7 @@ class Level(SpecificLocation, models.Model): on_top_of = models.ForeignKey('mapdata.Level', null=True, on_delete=models.CASCADE, related_name='levels_on_top', verbose_name=_('on top of')) - geoms_cache = models.BinaryField() + render_data = models.BinaryField(null=True) objects = LevelManager() diff --git a/src/c3nav/mapdata/models/update.py b/src/c3nav/mapdata/models/update.py index 93d7b560..5652359e 100644 --- a/src/c3nav/mapdata/models/update.py +++ b/src/c3nav/mapdata/models/update.py @@ -50,9 +50,9 @@ class MapUpdate(models.Model): raise TypeError from c3nav.mapdata.models import AltitudeArea - from c3nav.mapdata.render.base import LevelGeometries + from c3nav.mapdata.render.base import LevelRenderData AltitudeArea.recalculate() - LevelGeometries.rebuild() + LevelRenderData.rebuild() super().save(**kwargs) cache.set('mapdata:last_update', (self.pk, self.datetime), 900) delete_old_cached_tiles.apply_async(countdown=5) diff --git a/src/c3nav/mapdata/render/base.py b/src/c3nav/mapdata/render/base.py index c2d697c7..cbbb3c8c 100644 --- a/src/c3nav/mapdata/render/base.py +++ b/src/c3nav/mapdata/render/base.py @@ -2,19 +2,130 @@ import pickle from django.core.cache import cache from django.db import transaction -from django.db.models import Q from shapely.ops import unary_union from c3nav.mapdata.models import Level, MapUpdate class AltitudeAreaGeometries: - def __init__(self, altitudearea, colors): - self.geometry = altitudearea.geometry - self.altitude = altitudearea.altitude + def __init__(self, altitudearea=None, colors=None): + if altitudearea is not None: + self.geometry = altitudearea.geometry + self.altitude = altitudearea.altitude + else: + self.geometry = None + self.altitude = None self.colors = colors +class FakeCropper: + @staticmethod + def intersection(other): + return other + + +class LevelRenderData: + def __init__(self): + self.levels = [] + self.access_restriction_affected = None + + @staticmethod + def rebuild(): + levels = tuple(Level.objects.prefetch_related('altitudeareas', 'buildings', 'doors', 'spaces', + 'spaces__holes', 'spaces__columns', 'spaces__locationgroups')) + + single_level_geoms = {level.pk: LevelGeometries.build_for_level(level) for level in levels} + + for i, level in enumerate(levels): + if level.on_top_of_id is not None: + continue + + level_crop_to = {} + + # choose a crop area for each level. non-intermediate levels (not on_top_of) below the one that we are + # currently rendering will be cropped to only render content that is visible through holes indoors in the + # levels above them. + crop_to = None + primary_level_count = 0 + for sublevel in reversed(levels[:i + 1]): + geoms = single_level_geoms[sublevel.pk] + + if geoms.holes is not None: + primary_level_count += 1 + + # set crop area if we area on the second primary layer from top or below + level_crop_to[sublevel.pk] = crop_to if primary_level_count > 1 else FakeCropper + + if geoms.holes is not None: + if crop_to is None: + crop_to = geoms.holes + else: + crop_to = crop_to.intersection(geoms.holes) + + render_data = LevelRenderData() + render_data.access_restriction_affected = {} + + for sublevel in levels[:i + 1]: + old_geoms = single_level_geoms[sublevel.pk] + crop_to = level_crop_to[sublevel.pk] + + new_geoms = LevelGeometries() + new_geoms.doors = crop_to.intersection(old_geoms.doors) + new_geoms.walls = crop_to.intersection(old_geoms.walls) + + for altitudearea in old_geoms.altitudeareas: + new_geometry = crop_to.intersection(altitudearea.geometry) + if new_geometry.is_empty: + continue + + new_altitudearea = AltitudeAreaGeometries() + new_altitudearea.geometry = new_geometry + new_altitudearea.altitude = altitudearea.altitude + + new_colors = {} + for color, areas in altitudearea.colors.items(): + new_areas = {} + for access_restriction, area in areas.items(): + new_area = new_geometry.intersection(area) + if not new_area.is_empty: + new_areas[access_restriction] = new_area + if new_areas: + new_colors[color] = new_areas + + new_altitudearea.colors = new_colors + new_geoms.altitudeareas.append(new_altitudearea) + + for access_restriction, area in old_geoms.restricted_spaces_indoors.items(): + new_area = crop_to.intersection(area) + if not new_area.is_empty: + render_data.access_restriction_affected.setdefault(access_restriction, []).append(new_area) + + new_geoms.restricted_spaces_indoors = {} + for access_restriction, area in old_geoms.restricted_spaces_indoors.items(): + new_area = crop_to.intersection(area) + if not new_area.is_empty: + new_geoms.restricted_spaces_indoors[access_restriction] = new_area + + new_geoms.restricted_spaces_outdoors = {} + for access_restriction, area in old_geoms.restricted_spaces_outdoors.items(): + new_area = crop_to.intersection(area) + if not new_area.is_empty: + new_geoms.restricted_spaces_outdoors[access_restriction] = new_area + + render_data.levels.append((new_geoms, sublevel.default_height)) + + render_data.access_restriction_affected = { + access_restriction: unary_union(areas) + for access_restriction, areas in render_data.access_restriction_affected.items() + } + + level.render_data = pickle.dumps(render_data) + + with transaction.atomic(): + for level in levels: + level.save() + + class LevelGeometries: def __init__(self): self.altitudeareas = [] @@ -26,99 +137,86 @@ class LevelGeometries: self.restricted_spaces_outdoors = None @staticmethod - def crop(self, geometry, crop_to): - if crop_to is None: - return geometry - return geometry.intersection(crop_to) + def build_for_level(level): + geoms = LevelGeometries() + buildings_geom = unary_union([b.geometry for b in level.buildings.all()]) - @staticmethod - def rebuild(): - levels = Level.objects.prefetch_related('altitudeareas', 'buildings', 'doors', 'spaces', - 'spaces__holes', 'spaces__columns', 'spaces__locationgroups') - for level in levels: - geoms = LevelGeometries() - buildings_geom = unary_union([b.geometry for b in level.buildings.all()]) + # remove columns and holes from space areas + for space in level.spaces.all(): + if space.outside: + space.geometry = space.geometry.difference(buildings_geom) + space.geometry = space.geometry.difference(unary_union([c.geometry for c in space.columns.all()])) + space.holes_geom = unary_union([h.geometry for h in space.holes.all()]) + space.walkable_geom = space.geometry.difference(space.holes_geom) - # remove columns and holes from space areas - for space in level.spaces.all(): - if space.outside: - space.geometry = space.geometry.difference(buildings_geom) - space.geometry = space.geometry.difference(unary_union([c.geometry for c in space.columns.all()])) - space.holes_geom = unary_union([h.geometry for h in space.holes.all()]) - space.walkable_geom = space.geometry.difference(space.holes_geom) + spaces_geom = unary_union([s.geometry for s in level.spaces.all()]) + doors_geom = unary_union([d.geometry for d in level.doors.all()]) + walkable_spaces_geom = unary_union([s.walkable_geom for s in level.spaces.all()]) + geoms.doors = doors_geom.difference(walkable_spaces_geom) + walkable_geom = walkable_spaces_geom.union(geoms.doors) + if level.on_top_of_id is None: + geoms.holes = spaces_geom.difference(walkable_geom) - spaces_geom = unary_union([s.geometry for s in level.spaces.all()]) - doors_geom = unary_union([d.geometry for d in level.doors.all()]) - walkable_spaces_geom = unary_union([s.walkable_geom for s in level.spaces.all()]) - geoms.doors = doors_geom.difference(walkable_spaces_geom) - walkable_geom = walkable_spaces_geom.union(geoms.doors) - if level.on_top_of_id is None: - geoms.holes = spaces_geom.difference(walkable_geom) + # keep track which areas are affected by access restrictions + access_restriction_affected = {} - # keep track which areas are affected by access restrictions - access_restriction_affected = {} + # keep track wich spaces to hide + restricted_spaces_indoors = {} + restricted_spaces_outdoors = {} - # keep track wich spaces to hide - restricted_spaces_indoors = {} - restricted_spaces_outdoors = {} + # ground colors + colors = {} - # ground colors - colors = {} + # go through spaces and their areas for access control and ground colors + for space in level.spaces.all(): + access_restriction = space.access_restriction_id + if access_restriction is not None: + access_restriction_affected.setdefault(access_restriction, []).append(space.geometry) + buffered = space.geometry.buffer(0.01).union(unary_union( + tuple(door.geometry for door in level.doors.all() if door.geometry.intersects(space.geometry)) + ).difference(walkable_spaces_geom)) + if buffered.intersects(buildings_geom): + restricted_spaces_indoors.setdefault(access_restriction, []).append( + buffered.intersection(buildings_geom) + ) + if not buffered.within(buildings_geom): + restricted_spaces_outdoors.setdefault(access_restriction, []).append( + buffered.difference(buildings_geom) + ) - # go through spaces and their areas for access control and ground colors - for space in level.spaces.all(): - access_restriction = space.access_restriction_id + colors.setdefault(space.get_color(), {}).setdefault(access_restriction, []).append(space.geometry) + + for area in space.areas.all(): + access_restriction = area.access_restriction_id or space.access_restriction_id if access_restriction is not None: - access_restriction_affected.setdefault(access_restriction, []).append(space.geometry) - buffered = space.geometry.buffer(0.01).union(unary_union( - tuple(door.geometry for door in level.doors.all() if door.geometry.intersects(space.geometry)) - ).difference(walkable_spaces_geom)) - if buffered.intersects(buildings_geom): - restricted_spaces_indoors.setdefault(access_restriction, []).append( - buffered.intersection(buildings_geom) - ) - if not buffered.within(buildings_geom): - restricted_spaces_outdoors.setdefault(access_restriction, []).append( - buffered.difference(buildings_geom) - ) + access_restriction_affected.setdefault(access_restriction, []).append(area.geometry) + colors.setdefault(area.get_color(), {}).setdefault(access_restriction, []).append(area.geometry) + colors.pop(None, None) - colors.setdefault(space.get_color(), {}).setdefault(access_restriction, []).append(space.geometry) + # merge ground colors + for color, color_group in colors.items(): + for access_restriction, areas in tuple(color_group.items()): + color_group[access_restriction] = unary_union(areas) - for area in space.areas.all(): - access_restriction = area.access_restriction_id or space.access_restriction_id - if access_restriction is not None: - access_restriction_affected.setdefault(access_restriction, []).append(area.geometry) - colors.setdefault(area.get_color(), {}).setdefault(access_restriction, []).append(area.geometry) - colors.pop(None, None) + # add altitudegroup geometries and split ground colors into them + for altitudearea in level.altitudeareas.all(): + altitudearea_colors = {color: {access_restriction: area.intersection(altitudearea.geometry) + for access_restriction, area in areas.items() + if area.intersects(altitudearea.geometry)} + for color, areas in colors.items()} + altitudearea_colors = {color: areas for color, areas in altitudearea_colors.items() if areas} + geoms.altitudeareas.append(AltitudeAreaGeometries(altitudearea, altitudearea_colors)) - # merge ground colors - for color, color_group in colors.items(): - for access_restriction, areas in tuple(color_group.items()): - color_group[access_restriction] = unary_union(areas) + # merge access restrictions + geoms.access_restriction_affected = {access_restriction: unary_union(areas) + for access_restriction, areas in access_restriction_affected.items()} + geoms.restricted_spaces_indoors = {access_restriction: unary_union(spaces) + for access_restriction, spaces in restricted_spaces_indoors.items()} + geoms.restricted_spaces_outdoors = {access_restriction: unary_union(spaces) + for access_restriction, spaces in restricted_spaces_outdoors.items()} - # add altitudegroup geometries and split ground colors into them - for altitudearea in level.altitudeareas.all(): - altitudearea_colors = {color: {access_restriction: area.intersection(altitudearea.geometry) - for access_restriction, area in areas.items() - if area.intersects(altitudearea.geometry)} - for color, areas in colors.items()} - altitudearea_colors = {color: areas for color, areas in altitudearea_colors.items() if areas} - geoms.altitudeareas.append(AltitudeAreaGeometries(altitudearea, altitudearea_colors)) - - # merge access restrictions - geoms.access_restriction_affected = {access_restriction: unary_union(areas) - for access_restriction, areas in access_restriction_affected.items()} - geoms.restricted_spaces_indoors = {access_restriction: unary_union(spaces) - for access_restriction, spaces in restricted_spaces_indoors.items()} - geoms.restricted_spaces_outdoors = {access_restriction: unary_union(spaces) - for access_restriction, spaces in restricted_spaces_outdoors.items()} - - geoms.walls = buildings_geom.difference(spaces_geom).difference(doors_geom) - level.geoms_cache = pickle.dumps(geoms) - - with transaction.atomic(): - for level in levels: - level.save() + geoms.walls = buildings_geom.difference(spaces_geom).difference(doors_geom) + return geoms def get_level_render_data(level): @@ -129,13 +227,10 @@ def get_level_render_data(level): return result if isinstance(level, Level): - level_pk, level_base_altitude = level.pk, level.base_altitude + result = pickle.loads(level.render_data) else: - level_pk, level_base_altitude = Level.objects.filter(pk=level).values_list('pk', 'base_altitude')[0] + result = pickle.loads(Level.objects.filter(pk=level).values_list('render_data', flat=True)[0]) - levels = Level.objects.filter(Q(on_top_of=level_pk) | Q(base_altitude__lte=level_base_altitude)) - result = tuple((pickle.loads(geoms_cache), default_height) - for geoms_cache, default_height in levels.values_list('geoms_cache', 'default_height')) cache.set(cache_key, result, 900) return result diff --git a/src/c3nav/mapdata/render/svg.py b/src/c3nav/mapdata/render/svg.py index 48c6b626..a6b0baf7 100644 --- a/src/c3nav/mapdata/render/svg.py +++ b/src/c3nav/mapdata/render/svg.py @@ -29,12 +29,8 @@ class SVGRenderer: @cached_property def affected_access_restrictions(self): - access_restrictions = set() - for geoms, default_height in self.level_render_data: - for access_restriction, area in geoms.access_restriction_affected.items(): - if access_restriction not in access_restrictions and area.intersects(self.bbox): - access_restrictions.add(access_restriction) - return access_restrictions + return set(ar for ar, area in self.level_render_data.access_restriction_affected.items() + if area.intersects(self.bbox)) @cached_property def unlocked_access_restrictions(self): @@ -52,28 +48,8 @@ class SVGRenderer: # add no access restriction to “unlocked“ access restrictions so lookup gets easier unlocked_access_restrictions = self.unlocked_access_restrictions | set([None]) - # choose a crop area for each level. non-intermediate levels (not on_top_of) below the one that we are - # currently rendering will be cropped to only render content that is visible through holes indoors in the - # levels above them. - crop_to = None - primary_level_count = 0 - for geoms, default_height in reversed(self.level_render_data): - if geoms.holes is not None: - primary_level_count += 1 - - # set crop area if we area on the second primary layer from top or below - geoms.crop_to = crop_to if primary_level_count > 1 else None - - if geoms.holes is not None: - if crop_to is None: - crop_to = geoms.holes - else: - crop_to = crop_to.intersection(geoms.holes) - - for geoms, default_height in self.level_render_data: + for geoms, default_height in self.level_render_data.levels: crop_to = self.bbox - if geoms.crop_to is not None: - crop_to = crop_to.intersection(geoms.crop_to) # hide indoor and outdoor rooms if their access restriction was not unlocked add_walls = unary_union(tuple(area for access_restriction, area in geoms.restricted_spaces_indoors.items() @@ -97,10 +73,12 @@ class SVGRenderer: svg.add_geometry(crop_to.intersection(unary_union(areas)), fill_color=color) # add walls, stroke_px makes sure that all walls are at least 1px thick on all zoom levels, - svg.add_geometry(crop_to.intersection(geoms.walls.union(add_walls)), - fill_color='#aaaaaa', stroke_px=0.5, stroke_color='#aaaaaa', elevation=default_height) + if not add_walls.is_empty or not geoms.walls.is_empty: + svg.add_geometry(crop_to.intersection(geoms.walls.union(add_walls)), + fill_color='#aaaaaa', stroke_px=0.5, stroke_color='#aaaaaa', elevation=default_height) - svg.add_geometry(crop_to.intersection(geoms.doors.difference(add_walls)), - fill_color='#ffffff', stroke_px=0.5, stroke_color='#ffffff') + if not geoms.doors.is_empty: + svg.add_geometry(crop_to.intersection(geoms.doors.difference(add_walls)), + fill_color='#ffffff', stroke_px=0.5, stroke_color='#ffffff') return svg