2017-08-06 20:06:46 +02:00
|
|
|
from operator import attrgetter, itemgetter
|
|
|
|
|
2017-05-06 17:24:09 +02:00
|
|
|
from django.db import models
|
2017-08-06 19:12:38 +02:00
|
|
|
from django.db.models import F
|
2017-05-06 17:24:09 +02:00
|
|
|
from django.utils.translation import ugettext_lazy as _
|
2017-08-05 15:54:15 +02:00
|
|
|
from shapely.geometry import JOIN_STYLE
|
2017-08-05 14:17:24 +02:00
|
|
|
from shapely.ops import cascaded_union
|
2017-05-06 17:24:09 +02:00
|
|
|
|
2017-05-08 16:05:44 +02:00
|
|
|
from c3nav.mapdata.fields import GeometryField
|
2017-08-05 12:13:15 +02:00
|
|
|
from c3nav.mapdata.models import Level
|
2017-07-13 23:49:00 +02:00
|
|
|
from c3nav.mapdata.models.access import AccessRestrictionMixin
|
2017-05-08 16:40:22 +02:00
|
|
|
from c3nav.mapdata.models.geometry.base import GeometryMixin
|
2017-05-10 18:03:57 +02:00
|
|
|
from c3nav.mapdata.models.locations import SpecificLocation
|
2017-08-06 20:06:46 +02:00
|
|
|
from c3nav.mapdata.utils.geometry import assert_multilinestring, assert_multipolygon, clean_geometry
|
2017-05-06 17:24:09 +02:00
|
|
|
|
|
|
|
|
2017-06-11 14:43:14 +02:00
|
|
|
class LevelGeometryMixin(GeometryMixin):
|
|
|
|
level = models.ForeignKey('mapdata.Level', on_delete=models.CASCADE, verbose_name=_('level'))
|
2017-05-09 12:50:32 +02:00
|
|
|
|
|
|
|
class Meta:
|
|
|
|
abstract = True
|
|
|
|
|
2017-06-16 18:38:41 +02:00
|
|
|
def get_geojson_properties(self, *args, instance=None, **kwargs) -> dict:
|
|
|
|
result = super().get_geojson_properties(*args, **kwargs)
|
2017-06-11 14:43:14 +02:00
|
|
|
result['level'] = self.level_id
|
2017-05-21 23:39:26 +02:00
|
|
|
if hasattr(self, 'get_color'):
|
2017-06-16 18:38:41 +02:00
|
|
|
color = self.get_color(instance=instance)
|
2017-05-21 23:39:26 +02:00
|
|
|
if color:
|
|
|
|
result['color'] = color
|
2017-06-11 13:26:35 +02:00
|
|
|
if hasattr(self, 'opacity'):
|
|
|
|
result['opacity'] = self.opacity
|
2017-05-11 19:36:49 +02:00
|
|
|
return result
|
|
|
|
|
2017-06-11 14:43:14 +02:00
|
|
|
def _serialize(self, level=True, **kwargs):
|
2017-05-11 19:36:49 +02:00
|
|
|
result = super()._serialize(**kwargs)
|
2017-06-11 14:43:14 +02:00
|
|
|
if level:
|
|
|
|
result['level'] = self.level_id
|
2017-05-06 17:24:09 +02:00
|
|
|
return result
|
|
|
|
|
|
|
|
|
2017-06-11 14:43:14 +02:00
|
|
|
class Building(LevelGeometryMixin, models.Model):
|
2017-05-06 17:24:09 +02:00
|
|
|
"""
|
|
|
|
The outline of a building on a specific level
|
|
|
|
"""
|
2017-05-08 16:05:44 +02:00
|
|
|
geometry = GeometryField('polygon')
|
2017-05-06 17:24:09 +02:00
|
|
|
|
|
|
|
class Meta:
|
|
|
|
verbose_name = _('Building')
|
|
|
|
verbose_name_plural = _('Buildings')
|
|
|
|
default_related_name = 'buildings'
|
|
|
|
|
|
|
|
|
2017-06-11 14:43:14 +02:00
|
|
|
class Space(SpecificLocation, LevelGeometryMixin, models.Model):
|
2017-05-06 17:24:09 +02:00
|
|
|
"""
|
2017-06-11 14:43:14 +02:00
|
|
|
An accessible space. Shouldn't overlap with spaces on the same level.
|
2017-05-06 17:24:09 +02:00
|
|
|
"""
|
2017-05-09 09:36:08 +02:00
|
|
|
geometry = GeometryField('polygon')
|
2017-06-11 13:18:25 +02:00
|
|
|
outside = models.BooleanField(default=False, verbose_name=_('only outside of building'))
|
2017-05-06 17:24:09 +02:00
|
|
|
|
|
|
|
class Meta:
|
2017-05-09 13:48:55 +02:00
|
|
|
verbose_name = _('Space')
|
|
|
|
verbose_name_plural = _('Spaces')
|
|
|
|
default_related_name = 'spaces'
|
2017-05-06 17:24:09 +02:00
|
|
|
|
|
|
|
|
2017-07-13 23:49:00 +02:00
|
|
|
class Door(AccessRestrictionMixin, LevelGeometryMixin, models.Model):
|
2017-05-06 17:24:09 +02:00
|
|
|
"""
|
2017-05-09 13:48:55 +02:00
|
|
|
A connection between two spaces
|
2017-05-06 17:24:09 +02:00
|
|
|
"""
|
2017-05-08 16:05:44 +02:00
|
|
|
geometry = GeometryField('polygon')
|
2017-05-06 17:24:09 +02:00
|
|
|
|
|
|
|
class Meta:
|
|
|
|
verbose_name = _('Door')
|
|
|
|
verbose_name_plural = _('Doors')
|
|
|
|
default_related_name = 'doors'
|
2017-08-05 11:56:29 +02:00
|
|
|
|
|
|
|
|
|
|
|
class AltitudeArea(LevelGeometryMixin, models.Model):
|
|
|
|
"""
|
|
|
|
An altitude area
|
|
|
|
"""
|
|
|
|
geometry = GeometryField('polygon')
|
|
|
|
altitude = models.DecimalField(_('altitude'), null=False, max_digits=6, decimal_places=2)
|
|
|
|
|
|
|
|
class Meta:
|
|
|
|
verbose_name = _('Altitude Area')
|
|
|
|
verbose_name_plural = _('Altitude Areas')
|
|
|
|
default_related_name = 'altitudeareas'
|
2017-08-05 14:17:24 +02:00
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def recalculate(cls):
|
2017-08-05 14:35:25 +02:00
|
|
|
# collect location areas
|
|
|
|
all_areas = []
|
2017-08-05 16:45:29 +02:00
|
|
|
space_areas = {}
|
2017-08-06 19:12:38 +02:00
|
|
|
spaces = {}
|
2017-08-05 14:35:25 +02:00
|
|
|
for level in Level.objects.prefetch_related('buildings', 'doors', 'spaces', 'spaces__columns',
|
|
|
|
'spaces__obstacles', 'spaces__lineobstacles', 'spaces__holes',
|
|
|
|
'spaces__stairs', 'spaces__altitudemarkers'):
|
2017-08-05 14:17:24 +02:00
|
|
|
areas = []
|
|
|
|
stairs = []
|
|
|
|
|
2017-08-05 14:35:25 +02:00
|
|
|
# collect all accessible areas on this level
|
2017-08-05 14:17:24 +02:00
|
|
|
buildings_geom = cascaded_union(tuple(building.geometry for building in level.buildings.all()))
|
|
|
|
for space in level.spaces.all():
|
|
|
|
if space.outside:
|
|
|
|
space.geometry = space.geometry.difference(buildings_geom)
|
|
|
|
spaces[space.pk] = space
|
|
|
|
area = space.geometry
|
|
|
|
buffered = space.geometry.buffer(0.0001)
|
|
|
|
remove = cascaded_union(tuple(c.geometry for c in space.columns.all()) +
|
|
|
|
tuple(o.geometry for o in space.obstacles.all()) +
|
|
|
|
tuple(o.buffered_geometry for o in space.lineobstacles.all()) +
|
|
|
|
tuple(h.geometry for h in space.holes.all()))
|
|
|
|
areas.extend(assert_multipolygon(space.geometry.difference(remove)))
|
|
|
|
for stair in space.stairs.all():
|
2017-08-05 14:35:25 +02:00
|
|
|
substairs = tuple(assert_multilinestring(stair.geometry.intersection(buffered).difference(remove)))
|
|
|
|
for substair in substairs:
|
|
|
|
substair.space = space.pk
|
|
|
|
stairs.extend(substairs)
|
2017-08-05 14:17:24 +02:00
|
|
|
|
|
|
|
areas = assert_multipolygon(cascaded_union(areas+list(door.geometry for door in level.doors.all())))
|
|
|
|
areas = [AltitudeArea(geometry=area, level=level) for area in areas]
|
|
|
|
|
2017-08-05 16:45:29 +02:00
|
|
|
space_areas.update({space.pk: [] for space in level.spaces.all()})
|
2017-08-05 14:50:07 +02:00
|
|
|
|
2017-08-05 14:35:25 +02:00
|
|
|
# assign spaces to areas
|
2017-08-05 14:17:24 +02:00
|
|
|
for area in areas:
|
|
|
|
area.spaces = set()
|
|
|
|
area.connected_to = []
|
|
|
|
for space in level.spaces.all():
|
|
|
|
if area.geometry.intersects(space.geometry):
|
|
|
|
area.spaces.add(space.pk)
|
2017-08-05 14:50:07 +02:00
|
|
|
space_areas[space.pk].append(area)
|
2017-08-05 14:17:24 +02:00
|
|
|
|
2017-08-05 14:35:25 +02:00
|
|
|
# divide areas using stairs
|
2017-08-05 14:17:24 +02:00
|
|
|
for stair in stairs:
|
2017-08-05 14:50:07 +02:00
|
|
|
for area in space_areas[stair.space]:
|
2017-08-05 15:16:00 +02:00
|
|
|
if not stair.intersects(area.geometry):
|
2017-08-05 14:17:24 +02:00
|
|
|
continue
|
|
|
|
|
|
|
|
divided = assert_multipolygon(area.geometry.difference(stair.buffer(0.0001)))
|
|
|
|
if len(divided) > 2:
|
|
|
|
raise ValueError
|
|
|
|
area.geometry = divided[0]
|
|
|
|
if len(divided) == 2:
|
|
|
|
new_area = AltitudeArea(geometry=divided[1], level=level)
|
2017-08-05 15:16:00 +02:00
|
|
|
new_area.spaces = set()
|
2017-08-05 14:17:24 +02:00
|
|
|
new_area.connected_to = [area]
|
|
|
|
area.connected_to.append(new_area)
|
|
|
|
areas.append(new_area)
|
2017-08-05 15:16:00 +02:00
|
|
|
original_spaces = area.spaces
|
|
|
|
if len(area.spaces) == 1:
|
|
|
|
new_area.spaces = area.spaces
|
|
|
|
space_areas[next(iter(area.spaces))].append(new_area)
|
|
|
|
else:
|
2017-08-05 15:54:15 +02:00
|
|
|
# update area spaces
|
2017-08-05 15:16:00 +02:00
|
|
|
for subarea in (area, new_area):
|
2017-08-05 14:50:07 +02:00
|
|
|
spaces_before = subarea.spaces
|
2017-08-05 15:16:00 +02:00
|
|
|
subarea.spaces = set(space for space in original_spaces
|
2017-08-05 14:35:25 +02:00
|
|
|
if spaces[space].geometry.intersects(subarea.geometry))
|
2017-08-05 14:50:07 +02:00
|
|
|
for space in spaces_before-subarea.spaces:
|
|
|
|
space_areas[space].remove(subarea)
|
|
|
|
for space in subarea.spaces-spaces_before:
|
|
|
|
space_areas[space].append(subarea)
|
2017-08-05 15:54:15 +02:00
|
|
|
|
|
|
|
# update area connections
|
|
|
|
buffer_area = area.geometry.buffer(0.0005, join_style=JOIN_STYLE.mitre)
|
2017-08-05 16:15:28 +02:00
|
|
|
buffer_new_area = new_area.geometry.buffer(0.0005, join_style=JOIN_STYLE.mitre)
|
|
|
|
remove_area_connected_to = []
|
2017-08-05 15:54:15 +02:00
|
|
|
for other_area in area.connected_to:
|
|
|
|
if not buffer_area.intersects(other_area.geometry):
|
2017-08-05 16:15:28 +02:00
|
|
|
new_area.connected_to.append(other_area)
|
|
|
|
remove_area_connected_to.append(other_area)
|
2017-08-05 15:54:15 +02:00
|
|
|
other_area.connected_to.remove(area)
|
|
|
|
other_area.connected_to.append(new_area)
|
2017-08-05 16:15:28 +02:00
|
|
|
elif other_area != new_area and buffer_new_area.intersects(other_area.geometry):
|
|
|
|
new_area.connected_to.append(other_area)
|
|
|
|
other_area.connected_to.append(new_area)
|
|
|
|
|
|
|
|
for other_area in remove_area_connected_to:
|
2017-08-05 15:54:15 +02:00
|
|
|
area.connected_to.remove(other_area)
|
2017-08-05 14:17:24 +02:00
|
|
|
break
|
2017-08-05 15:17:15 +02:00
|
|
|
else:
|
|
|
|
raise ValueError
|
|
|
|
|
|
|
|
# give altitudes to areas
|
|
|
|
for space in level.spaces.all():
|
|
|
|
for altitudemarker in space.altitudemarkers.all():
|
|
|
|
for area in space_areas[space.pk]:
|
|
|
|
if area.geometry.contains(altitudemarker.geometry):
|
|
|
|
area.altitude = altitudemarker.altitude
|
|
|
|
break
|
|
|
|
else:
|
|
|
|
raise ValueError(space.title)
|
2017-08-05 14:35:25 +02:00
|
|
|
|
|
|
|
all_areas.extend(areas)
|
2017-08-05 16:18:19 +02:00
|
|
|
|
|
|
|
# give temporary ids to all areas
|
2017-08-06 20:06:46 +02:00
|
|
|
for area in areas:
|
|
|
|
area.geometry = clean_geometry(area.geometry)
|
|
|
|
areas = [area for area in all_areas if not area.geometry.is_empty]
|
2017-08-05 16:18:19 +02:00
|
|
|
for i, area in enumerate(areas):
|
|
|
|
area.tmpid = i
|
2017-08-05 16:22:25 +02:00
|
|
|
for area in areas:
|
2017-08-05 16:18:19 +02:00
|
|
|
area.connected_to = set(area.tmpid for area in area.connected_to)
|
2017-08-05 16:22:25 +02:00
|
|
|
for space in space_areas.keys():
|
|
|
|
space_areas[space] = set(area.tmpid for area in space_areas[space])
|
2017-08-06 19:12:38 +02:00
|
|
|
areas_without_altitude = set(area.tmpid for area in areas if area.altitude is None)
|
|
|
|
|
|
|
|
# connect levels
|
|
|
|
from c3nav.mapdata.models import GraphEdge
|
|
|
|
edges = GraphEdge.objects.exclude(from_node__space__level=F('to_node__space__level'))
|
|
|
|
edges = edges.select_related('from_node', 'to_node')
|
|
|
|
node_areas = {}
|
|
|
|
area_connections = {}
|
|
|
|
for edge in edges:
|
|
|
|
for node in (edge.from_node, edge.to_node):
|
|
|
|
if node.pk not in node_areas:
|
|
|
|
tmpid = next(tmpid for tmpid in space_areas[node.space_id]
|
|
|
|
if areas[tmpid].geometry.contains(node.geometry))
|
|
|
|
node_areas[node.pk] = tmpid
|
|
|
|
area_connections.setdefault(node_areas[edge.from_node.pk], set()).add(node_areas[edge.to_node.pk])
|
|
|
|
area_connections.setdefault(node_areas[edge.to_node.pk], set()).add(node_areas[edge.from_node.pk])
|
|
|
|
|
|
|
|
del_keys = tuple(tmpid for tmpid in area_connections.keys() if tmpid not in areas_without_altitude)
|
|
|
|
for tmpid in del_keys:
|
|
|
|
del area_connections[tmpid]
|
|
|
|
|
|
|
|
do_continue = True
|
|
|
|
while do_continue:
|
|
|
|
do_continue = False
|
|
|
|
del_keys = []
|
|
|
|
for tmpid in area_connections.keys():
|
|
|
|
connections = area_connections[tmpid] - areas_without_altitude
|
|
|
|
if connections:
|
|
|
|
area = areas[tmpid]
|
|
|
|
other_area = areas[next(iter(connections))]
|
|
|
|
area.altitude = other_area.altitude
|
|
|
|
areas_without_altitude.remove(tmpid)
|
|
|
|
del_keys.append(tmpid)
|
|
|
|
|
|
|
|
if del_keys:
|
|
|
|
do_continue = True
|
|
|
|
for tmpid in del_keys:
|
|
|
|
del area_connections[tmpid]
|
2017-08-05 16:18:19 +02:00
|
|
|
|
|
|
|
# interpolate altitudes
|
|
|
|
while areas_without_altitude:
|
|
|
|
# find a area without an altitude that is connected
|
|
|
|
# to one with an altitude to start the chain
|
|
|
|
chain = []
|
|
|
|
for tmpid in areas_without_altitude:
|
|
|
|
area = areas[tmpid]
|
|
|
|
connected_with_altitude = area.connected_to-areas_without_altitude
|
|
|
|
if connected_with_altitude:
|
|
|
|
chain = [next(iter(connected_with_altitude)), tmpid]
|
|
|
|
current = area
|
|
|
|
break
|
|
|
|
else:
|
|
|
|
# there are no more chains possible
|
|
|
|
break
|
|
|
|
|
|
|
|
# continue chain as long as possible
|
|
|
|
while True:
|
|
|
|
connected_with_altitude = (current.connected_to-areas_without_altitude).difference(chain)
|
|
|
|
if connected_with_altitude:
|
2017-08-05 16:18:55 +02:00
|
|
|
# interpolate
|
2017-08-05 16:18:19 +02:00
|
|
|
area = areas[next(iter(connected_with_altitude))]
|
|
|
|
from_altitude = areas[chain[0]].altitude
|
|
|
|
delta_altitude = area.altitude-from_altitude
|
|
|
|
for i, tmpid in enumerate(chain[1:], 1):
|
|
|
|
areas[tmpid].altitude = from_altitude+delta_altitude*i/len(chain)
|
|
|
|
areas_without_altitude.difference_update(chain)
|
|
|
|
break
|
|
|
|
|
|
|
|
connected = current.connected_to.difference(chain)
|
|
|
|
if not connected:
|
2017-08-05 16:18:55 +02:00
|
|
|
# end of chain
|
2017-08-05 16:18:19 +02:00
|
|
|
altitude = areas[chain[0]].altitude
|
|
|
|
for i, tmpid in enumerate(chain[1:], 1):
|
|
|
|
areas[tmpid].altitude = altitude
|
|
|
|
areas_without_altitude.difference_update(chain)
|
|
|
|
break
|
|
|
|
|
2017-08-05 16:18:55 +02:00
|
|
|
# continue chain
|
2017-08-05 16:18:19 +02:00
|
|
|
current = areas[next(iter(connected))]
|
|
|
|
chain.append(current.tmpid)
|
|
|
|
|
2017-08-05 16:45:29 +02:00
|
|
|
# remaining areas which belong to a room that has an altitude somewhere
|
|
|
|
for contained_areas in space_areas.values():
|
2017-08-05 16:51:08 +02:00
|
|
|
contained_areas_with_altitude = contained_areas - areas_without_altitude
|
|
|
|
contained_areas_without_altitude = contained_areas - contained_areas_with_altitude
|
|
|
|
if contained_areas_with_altitude and contained_areas_without_altitude:
|
2017-08-05 16:45:29 +02:00
|
|
|
altitude_areas = {}
|
2017-08-05 16:51:08 +02:00
|
|
|
for tmpid in contained_areas_with_altitude:
|
2017-08-05 16:45:29 +02:00
|
|
|
area = areas[tmpid]
|
|
|
|
altitude_areas.setdefault(area.altitude, []).append(area.geometry)
|
|
|
|
|
|
|
|
for altitude in altitude_areas.keys():
|
|
|
|
altitude_areas[altitude] = cascaded_union(altitude_areas[altitude])
|
2017-08-05 16:51:08 +02:00
|
|
|
for tmpid in contained_areas_without_altitude:
|
2017-08-05 16:45:29 +02:00
|
|
|
area = areas[tmpid]
|
|
|
|
area.altitude = min(altitude_areas.items(), key=lambda aa: aa[1].distance(area.geometry))[0]
|
2017-08-05 16:51:08 +02:00
|
|
|
areas_without_altitude.difference_update(contained_areas_without_altitude)
|
2017-08-05 16:45:29 +02:00
|
|
|
|
2017-08-05 16:51:08 +02:00
|
|
|
# last fallback: level base_altitude
|
|
|
|
for tmpid in areas_without_altitude:
|
|
|
|
area = areas[tmpid]
|
|
|
|
area.altitude = area.level.base_altitude
|
2017-08-06 20:06:46 +02:00
|
|
|
|
|
|
|
# save to database
|
|
|
|
from c3nav.mapdata.models import MapUpdate
|
|
|
|
with MapUpdate.lock():
|
|
|
|
areas_to_save = set(range(len(areas)))
|
|
|
|
|
|
|
|
level_areas = {}
|
|
|
|
for area in areas:
|
|
|
|
level_areas.setdefault(area.level, set()).add(area.tmpid)
|
|
|
|
|
|
|
|
all_candidates = AltitudeArea.objects.select_related('level')
|
|
|
|
for candidate in all_candidates:
|
|
|
|
candidate.area = candidate.geometry.area
|
|
|
|
all_candidates = sorted(all_candidates, key=attrgetter('area'), reverse=True)
|
|
|
|
|
|
|
|
num_modified = 0
|
|
|
|
num_deleted = 0
|
|
|
|
num_created = 0
|
|
|
|
|
|
|
|
for candidate in all_candidates:
|
|
|
|
new_area = None
|
|
|
|
for tmpid in level_areas.get(candidate.level, set()):
|
|
|
|
area = areas[tmpid]
|
|
|
|
if area.geometry.almost_equals(candidate.geometry, 1):
|
|
|
|
new_area = area
|
|
|
|
break
|
|
|
|
|
|
|
|
if new_area is None:
|
|
|
|
potential_areas = [(tmpid, areas[tmpid].geometry.intersection(candidate.geometry).area)
|
|
|
|
for tmpid in level_areas.get(candidate.level, set())]
|
|
|
|
potential_areas = [(tmpid, size) for tmpid, size in potential_areas
|
|
|
|
if candidate.area and size/candidate.area > 0.9]
|
|
|
|
if potential_areas:
|
|
|
|
num_modified += 1
|
|
|
|
new_area = areas[max(potential_areas, key=itemgetter(1))[0]]
|
|
|
|
|
|
|
|
if new_area is None:
|
|
|
|
candidate.delete()
|
|
|
|
num_deleted += 1
|
|
|
|
continue
|
|
|
|
|
|
|
|
candidate.geometry = new_area.geometry
|
|
|
|
candidate.altitude = new_area.altitude
|
|
|
|
candidate.save()
|
|
|
|
areas_to_save.discard(new_area.tmpid)
|
|
|
|
level_areas[new_area.level].discard(new_area.tmpid)
|
|
|
|
|
|
|
|
for tmpid in areas_to_save:
|
|
|
|
num_created += 1
|
|
|
|
areas[tmpid].save()
|
|
|
|
|
|
|
|
print(_('%d altitude areas built.') % len(areas))
|
|
|
|
print(_('%d modified, %d deleted, %d created.') % (num_modified, num_deleted, num_created))
|