diff --git a/src/c3nav/editor/api/geometries.py b/src/c3nav/editor/api/geometries.py index f8f7aaa5..5e746680 100644 --- a/src/c3nav/editor/api/geometries.py +++ b/src/c3nav/editor/api/geometries.py @@ -305,8 +305,8 @@ def get_space_geometries_result(request, space_id: int, update_cache_key: str, u space.holes.all().only('geometry', 'space'), space.stairs.all().only('geometry', 'space'), space.ramps.all().only('geometry', 'space'), - space.obstacles.all().only('geometry', 'space', 'color'), - space.lineobstacles.all().only('geometry', 'width', 'space', 'color'), + space.obstacles.all().only('geometry', 'space').prefetch_related('group'), + space.lineobstacles.all().only('geometry', 'width', 'space').prefetch_related('group'), space.columns.all().only('geometry', 'space'), space.altitudemarkers.all().only('geometry', 'space'), space.wifi_measurements.all().only('geometry', 'space'), diff --git a/src/c3nav/editor/forms.py b/src/c3nav/editor/forms.py index 85de54bb..5b188c0f 100644 --- a/src/c3nav/editor/forms.py +++ b/src/c3nav/editor/forms.py @@ -9,10 +9,10 @@ from django.conf import settings from django.core.cache import cache from django.core.exceptions import FieldDoesNotExist from django.core.serializers.json import DjangoJSONEncoder -from django.db.models import Q +from django.db.models import Q, Prefetch from django.forms import (BooleanField, CharField, ChoiceField, DecimalField, Form, JSONField, ModelChoiceField, ModelForm, MultipleChoiceField, Select, ValidationError) -from django.forms.widgets import HiddenInput +from django.forms.widgets import HiddenInput, TextInput from django.utils.translation import get_language from django.utils.translation import gettext_lazy as _ from shapely.geometry.geo import mapping @@ -21,8 +21,10 @@ from pydantic import ValidationError as PydanticValidationError from c3nav.editor.models import ChangeSet, ChangeSetUpdate from c3nav.mapdata.fields import GeometryField from c3nav.mapdata.forms import I18nModelFormMixin -from c3nav.mapdata.models import GraphEdge +from c3nav.mapdata.models import GraphEdge, LocationGroup from c3nav.mapdata.models.access import AccessPermission +from c3nav.mapdata.models.geometry.space import ObstacleGroup +from c3nav.mapdata.models.theme import ThemeLocationGroupBackgroundColor, ThemeObstacleGroupBackgroundColor from c3nav.routing.schemas import LocateRequestPeerSchema @@ -32,6 +34,69 @@ class EditorFormBase(I18nModelFormMixin, ModelForm): super().__init__(*args, **kwargs) creating = not self.instance.pk + if self._meta.model.__name__ == 'Theme': + if creating: + locationgroup_theme_colors = {} + obstaclegroup_theme_colors = {} + else: + locationgroup_theme_colors = { + l.location_group_id: l + for l in self.instance.location_groups.filter(theme_id=self.instance.pk) + } + obstaclegroup_theme_colors = { + o.obstacle_group_id: o + for o in self.instance.obstacle_groups.filter(theme_id=self.instance.pk) + } + + # TODO: can we get the model class via relationships? + for locationgroup in LocationGroup.objects.prefetch_related( + Prefetch('theme_colors', ThemeLocationGroupBackgroundColor.objects.only('fill_color'))).all(): + related = locationgroup_theme_colors.get(locationgroup.pk, None) + value = related.fill_color if related is not None else None + other_themes_colors = { + l.title: l.fill_color + for l in locationgroup.theme_colors.all() + if related is None or l.pk != related.pk + } + if len(other_themes_colors) > 0: + other_themes_colors = json.dumps(other_themes_colors) + else: + other_themes_colors = False + field = CharField(max_length=32, + label=locationgroup.title, + required=False, + initial=value, + widget=TextInput(attrs={ + 'data-themed-color': True, + 'data-color-base-theme': locationgroup.color if locationgroup.color else False, + 'data-colors-other-themes': other_themes_colors, + })) + self.fields[f'locationgroup_{locationgroup.pk}'] = field + + for obstaclegroup in ObstacleGroup.objects.prefetch_related( + Prefetch('theme_colors', ThemeObstacleGroupBackgroundColor.objects.only('fill_color'))).all(): + related = obstaclegroup_theme_colors.get(obstaclegroup.pk, None) + value = related.fill_color if related is not None else None + other_themes_colors = { + o.title: o.fill_color + for o in obstaclegroup.theme_colors.all() + if related is None or o.pk != related.pk + } + if len(other_themes_colors) > 0: + other_themes_colors = json.dumps(other_themes_colors) + else: + other_themes_colors = False + field = CharField(max_length=32, + label=obstaclegroup.title, + required=False, + initial=value, + widget=TextInput(attrs={ + 'data-themed-color': True, + 'data-color-base-theme': obstaclegroup.color if obstaclegroup.color else False, + 'data-colors-other-themes': other_themes_colors, + })) + self.fields[f'obstaclegroup_{obstaclegroup.pk}'] = field + if hasattr(self.instance, 'author_id'): if self.instance.author_id is None: self.instance.author = request.user @@ -309,6 +374,35 @@ class EditorFormBase(I18nModelFormMixin, ModelForm): groups = tuple((int(val) if val.isdigit() else val) for val in groups) self.instance.groups.set(groups) + if self._meta.model.__name__ == 'Theme': + locationgroup_colors = {l.location_group_id: l for l in self.instance.location_groups.all()} + for locationgroup in LocationGroup.objects.all(): + value = self.cleaned_data[f'locationgroup_{locationgroup.pk}'] + if value: + color = locationgroup_colors.get(locationgroup.pk, + ThemeLocationGroupBackgroundColor(theme=self.instance, + location_group=locationgroup)) + color.fill_color = value + color.save() + else: + color = locationgroup_colors.get(locationgroup.pk, None) + if color is not None: + color.delete() + + obstaclegroup_colors = {o.obstacle_group_id: o for o in self.instance.obstacle_groups.all()} + for obstaclegroup in ObstacleGroup.objects.all(): + value = self.cleaned_data[f'obstaclegroup_{obstaclegroup.pk}'] + if value: + color = obstaclegroup_colors.get(obstaclegroup.pk, + ThemeObstacleGroupBackgroundColor(theme=self.instance, + obstacle_group=obstaclegroup)) + color.fill_color = value + color.save() + else: + color = obstaclegroup_colors.get(obstaclegroup.pk) + if color is not None: + color.delete() + def create_editor_form(editor_model): possible_fields = ['slug', 'name', 'title', 'title_plural', 'help_text', 'position_secret', diff --git a/src/c3nav/editor/static/editor/css/editor.scss b/src/c3nav/editor/static/editor/css/editor.scss index 38508eec..624c66e1 100644 --- a/src/c3nav/editor/static/editor/css/editor.scss +++ b/src/c3nav/editor/static/editor/css/editor.scss @@ -459,3 +459,31 @@ body:not(.mobileclient) .wificollector .btn { .wificollector table tr td { color: #666666; } + + +.theme-editor-filter { + body > & { + display: none; + } + + border-top: 1px solid #e7e7e7; + padding-top: 12px; + margin-bottom: 12px; +} + +body > .theme-color-info { + display: none; +} + +label.theme-color-label { + display: block; + > .theme-color-info { + float: right; + text-decoration: underline black dotted; + cursor: help; + } +} + +.theme-color-hidden { + display: none; +} \ No newline at end of file diff --git a/src/c3nav/editor/static/editor/js/editor.js b/src/c3nav/editor/static/editor/js/editor.js index 8b5652e8..11ab0953 100644 --- a/src/c3nav/editor/static/editor/js/editor.js +++ b/src/c3nav/editor/static/editor/js/editor.js @@ -72,6 +72,7 @@ editor = { editor.init_geometries(); editor.init_wificollector(); + editor.sidebar_content_loaded(); }, _inform_mobile_client: function(elem) { if (!window.mobileclient || !elem.length) return; @@ -199,6 +200,67 @@ editor = { } level_control.current_id = parseInt(level_list.attr('data-current-id')); }, + + sidebar_content_loaded: function() { + if (document.querySelector('#sidebar [data-themed-color]')) { + editor.theme_editor_loaded(); + } + }, + theme_editor_loaded: function() { + const filter_show_all = () => { + for (const input of document.querySelectorAll('#sidebar [data-themed-color]')) { + input.parentElement.classList.remove('theme-color-hidden'); + } + }; + const filter_show_base = () => { + for (const input of document.querySelectorAll('#sidebar [data-themed-color]')) { + input.parentElement.classList.toggle('theme-color-hidden', + !('colorBaseTheme' in input.dataset)); + } + }; + const filter_show_any = () => { + for (const input of document.querySelectorAll('#sidebar [data-themed-color]')) { + input.parentElement.classList.toggle('theme-color-hidden', + !('colorBaseTheme' in input.dataset || 'colorsOtherThemes' in input.dataset)); + } + }; + + const filterButtons = document.querySelector('body>.theme-editor-filter').cloneNode(true); + const first_color_input = document.querySelector('#sidebar [data-themed-color]:first-of-type'); + first_color_input.parentElement.before(filterButtons); + filterButtons.addEventListener('click', e => { + const btn = e.target; + if (btn.classList.contains('active')) return; + for (const b of filterButtons.querySelectorAll('button')) { + b.classList.remove('active'); + } + btn.classList.add('active'); + if ('all' in btn.dataset) filter_show_all(); + else if ('baseTheme' in btn.dataset) filter_show_base(); + else if ('anyTheme' in btn.dataset) filter_show_any(); + }); + + const baseInfoElement = document.querySelector('body>.theme-color-info'); + + for (const color_input of document.querySelectorAll('#sidebar [data-themed-color]')) { + let colors = {}; + if ('colorBaseTheme' in color_input.dataset) { + colors.base = color_input.dataset.colorBaseTheme; + } + if ('colorsOtherThemes' in color_input.dataset) { + const other_themes = JSON.parse(color_input.dataset.colorsOtherThemes); + colors = {...colors, ...other_themes}; + } + const titleStr = Object.entries(colors).map(([theme, color]) => `${theme}: ${color}`).join(' '); + if (!titleStr) continue; + const infoElement = baseInfoElement.cloneNode(true); + infoElement.title = titleStr; + const label = color_input.previousElementSibling; + label.classList.add('theme-color-label'); + label.appendChild(infoElement); + } + }, + _in_modal: false, _sidebar_loaded: function(data) { // sidebar was loaded. load the content. check if there are any redirects. call _check_start_editing. @@ -206,6 +268,7 @@ editor = { if (data !== undefined) { var doc = (new DOMParser).parseFromString(data, 'text/html'); content[0].replaceChildren(...doc.body.children); + editor.sidebar_content_loaded(); } var redirect = content.find('span[data-redirect]'); diff --git a/src/c3nav/editor/templates/editor/base.html b/src/c3nav/editor/templates/editor/base.html index 74fffe23..38cd619f 100644 --- a/src/c3nav/editor/templates/editor/base.html +++ b/src/c3nav/editor/templates/editor/base.html @@ -73,6 +73,15 @@
+
+
+ + + +
+
+ {% trans 'Other theme colors' %} + {% include 'site/fragment_fakemobileclient.html' %} {% compress js %} diff --git a/src/c3nav/editor/urls.py b/src/c3nav/editor/urls.py index 84fa8fb9..dd47606b 100644 --- a/src/c3nav/editor/urls.py +++ b/src/c3nav/editor/urls.py @@ -57,6 +57,7 @@ urlpatterns = [ urlpatterns.extend(add_editor_urls('Level', with_list=False, explicit_edit=True)) urlpatterns.extend(add_editor_urls('LocationGroupCategory')) urlpatterns.extend(add_editor_urls('LocationGroup')) +urlpatterns.extend(add_editor_urls('ObstacleGroup')) urlpatterns.extend(add_editor_urls('DynamicLocation')) urlpatterns.extend(add_editor_urls('WayType')) urlpatterns.extend(add_editor_urls('GroundAltitude')) @@ -64,6 +65,7 @@ urlpatterns.extend(add_editor_urls('AccessRestriction')) urlpatterns.extend(add_editor_urls('AccessRestrictionGroup')) urlpatterns.extend(add_editor_urls('Source')) urlpatterns.extend(add_editor_urls('LabelSettings')) +urlpatterns.extend(add_editor_urls('Theme')) urlpatterns.extend(add_editor_urls('Building', 'Level')) urlpatterns.extend(add_editor_urls('Space', 'Level', explicit_edit=True)) urlpatterns.extend(add_editor_urls('Door', 'Level')) diff --git a/src/c3nav/editor/views/edit.py b/src/c3nav/editor/views/edit.py index 887626e9..08a55edf 100644 --- a/src/c3nav/editor/views/edit.py +++ b/src/c3nav/editor/views/edit.py @@ -51,6 +51,7 @@ def main_index(request): 'child_models': [ child_model(request, 'LocationGroupCategory'), child_model(request, 'LocationGroup'), + child_model(request, 'ObstacleGroup'), child_model(request, 'GroundAltitude'), child_model(request, 'DynamicLocation'), child_model(request, 'WayType'), @@ -58,6 +59,7 @@ def main_index(request): child_model(request, 'AccessRestrictionGroup'), child_model(request, 'LabelSettings'), child_model(request, 'Source'), + child_model(request, 'Theme'), ], }, fields=('can_create_level', 'child_models')) diff --git a/src/c3nav/mapdata/migrations/0099_theming.py b/src/c3nav/mapdata/migrations/0099_theming.py new file mode 100644 index 00000000..8701f826 --- /dev/null +++ b/src/c3nav/mapdata/migrations/0099_theming.py @@ -0,0 +1,79 @@ +# Generated by Django 4.2.7 on 2024-01-02 19:57 + +import c3nav.mapdata.fields +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('mapdata', '0098_report_import_tag'), + ] + + operations = [ + migrations.CreateModel( + name='ObstacleGroup', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', c3nav.mapdata.fields.I18nField(blank=True, fallback_any=True, fallback_value='{model} {pk}', plural_name='titles', verbose_name='Title')), + ('color', models.CharField(blank=True, max_length=32, null=True)), + ], + options={ + 'verbose_name': 'Obstacle Group', + 'verbose_name_plural': 'Obstacle Groups', + 'default_related_name': 'groups', + }, + ), + migrations.CreateModel( + name='Theme', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', c3nav.mapdata.fields.I18nField(blank=True, fallback_any=True, fallback_value='{model} {pk}', plural_name='titles', verbose_name='Title')), + ('description', models.TextField()), + ('color_background', models.CharField(max_length=32, verbose_name='background color')), + ('color_wall_fill', models.CharField(max_length=32, verbose_name='wall fill color')), + ('color_wall_border', models.CharField(max_length=32, verbose_name='wall border color')), + ('color_door_fill', models.CharField(max_length=32, verbose_name='door fill color')), + ('color_ground_fill', models.CharField(max_length=32, verbose_name='ground fill color')), + ('color_obstacles_default_fill', models.CharField(max_length=32, verbose_name='default fill color for obstacles')), + ('color_obstacles_default_border', models.CharField(max_length=32, verbose_name='default border color for obstacles')), + ('last_updated', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'Theme', + 'verbose_name_plural': 'Themes', + 'default_related_name': 'themes', + }, + ), + migrations.CreateModel( + name='ThemeObstacleGroupBackgroundColor', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('fill_color', models.CharField(blank=True, max_length=32, null=True)), + ('border_color', models.CharField(blank=True, max_length=32, null=True)), + ('obstacle_group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='theme_colors', to='mapdata.obstaclegroup')), + ('theme', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='obstacle_groups', to='mapdata.theme')), + ], + ), + migrations.CreateModel( + name='ThemeLocationGroupBackgroundColor', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('fill_color', models.CharField(blank=True, max_length=32, null=True)), + ('border_color', models.CharField(blank=True, max_length=32, null=True)), + ('location_group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='theme_colors', to='mapdata.locationgroup')), + ('theme', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='location_groups', to='mapdata.theme')), + ], + ), + migrations.AddField( + model_name='lineobstacle', + name='group', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='mapdata.obstaclegroup'), + ), + migrations.AddField( + model_name='obstacle', + name='group', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='mapdata.obstaclegroup'), + ), + ] diff --git a/src/c3nav/mapdata/migrations/0100_obstaclegroup_color_data.py b/src/c3nav/mapdata/migrations/0100_obstaclegroup_color_data.py new file mode 100644 index 00000000..2872f3d3 --- /dev/null +++ b/src/c3nav/mapdata/migrations/0100_obstaclegroup_color_data.py @@ -0,0 +1,56 @@ +# Generated by Django 4.2.7 on 2024-01-05 10:27 + +from django.db import migrations + +sql_up = ''' +-- create an obstacle group for every distinct obstacle color +insert into mapdata_obstaclegroup (color, titles) +select color as color, + json_build_object('en', concat('Color ', color)) as titles +from + (select distinct color + from mapdata_obstacle + where color is not null + union + select distinct color + from mapdata_lineobstacle + where color is not null) as obstacle_colors; + +-- set the groups for colored obstacles to the previously created group with that color +update mapdata_obstacle as o +set group_id = g.id +from mapdata_obstaclegroup g +where g.color = o.color; + +update mapdata_lineobstacle as o +set group_id = g.id +from mapdata_obstaclegroup g +where g.color = o.color; +''' + +sql_down = ''' +-- set obstacle color from associated group color and remove group +update mapdata_obstacle as o +set color = g.color, group_id = null +from mapdata_obstaclegroup g +where g.id = o.group_id; + +update mapdata_lineobstacle as o +set color = g.color, group_id = null +from mapdata_obstaclegroup g +where g.id = o.group_id; + +-- delete groups +delete from mapdata_obstaclegroup where true; +''' + + +class Migration(migrations.Migration): + + dependencies = [ + ('mapdata', '0099_theming'), + ] + + operations = [ + migrations.RunSQL(sql_up, sql_down) + ] diff --git a/src/c3nav/mapdata/migrations/0101_remove_obstacle_color.py b/src/c3nav/mapdata/migrations/0101_remove_obstacle_color.py new file mode 100644 index 00000000..a32805ac --- /dev/null +++ b/src/c3nav/mapdata/migrations/0101_remove_obstacle_color.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.7 on 2024-01-05 11:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('mapdata', '0100_obstaclegroup_color_data'), + ] + + operations = [ + migrations.RemoveField( + model_name='obstacle', + name='color', + ) + ] diff --git a/src/c3nav/mapdata/models/geometry/level.py b/src/c3nav/mapdata/models/geometry/level.py index 950b08ca..9afb4bbf 100644 --- a/src/c3nav/mapdata/models/geometry/level.py +++ b/src/c3nav/mapdata/models/geometry/level.py @@ -39,7 +39,8 @@ class LevelGeometryMixin(GeometryMixin): result = super().get_geojson_properties(*args, **kwargs) result['level'] = self.level_id if hasattr(self, 'get_color'): - color = self.get_color(instance=instance) + from c3nav.mapdata.render.theme import ColorManager + color = self.get_color(ColorManager.for_theme(None), instance=instance) if color: result['color'] = color if hasattr(self, 'opacity'): diff --git a/src/c3nav/mapdata/models/geometry/space.py b/src/c3nav/mapdata/models/geometry/space.py index 5d54e167..02386d3f 100644 --- a/src/c3nav/mapdata/models/geometry/space.py +++ b/src/c3nav/mapdata/models/geometry/space.py @@ -14,7 +14,7 @@ from c3nav.mapdata.fields import GeometryField, I18nField from c3nav.mapdata.grid import grid from c3nav.mapdata.models import Space from c3nav.mapdata.models.access import AccessRestrictionMixin -from c3nav.mapdata.models.base import SerializableMixin +from c3nav.mapdata.models.base import SerializableMixin, TitledMixin from c3nav.mapdata.models.geometry.base import GeometryMixin from c3nav.mapdata.models.locations import SpecificLocation from c3nav.mapdata.utils.cache.changes import changed_geometries @@ -38,7 +38,8 @@ class SpaceGeometryMixin(GeometryMixin): def get_geojson_properties(self, *args, instance=None, **kwargs) -> dict: result = super().get_geojson_properties(*args, **kwargs) if hasattr(self, 'get_color'): - color = self.get_color(instance=instance) + from c3nav.mapdata.render.theme import ColorManager + color = self.get_color(ColorManager.for_theme(None), instance=instance) if color: result['color'] = color if hasattr(self, 'opacity'): @@ -175,16 +176,42 @@ class Ramp(SpaceGeometryMixin, models.Model): default_related_name = 'ramps' +class ObstacleGroup(TitledMixin, models.Model): + color = models.CharField(max_length=32, null=True, blank=True) + + class Meta: + verbose_name = _('Obstacle Group') + verbose_name_plural = _('Obstacle Groups') + default_related_name = 'groups' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.orig_color = self.color + + def save(self, *args, **kwargs): + if self.pk and (self.orig_color != self.color): + self.register_changed_geometries() + super().save(*args, **kwargs) + + def delete(self, *args, **kwargs): + self.register_changed_geometries() + super().delete(*args, **kwargs) + + def register_changed_geometries(self, do_query=True): + for obj in self.obstacles.select_related('space'): + obj.register_change(force=True) + + class Obstacle(SpaceGeometryMixin, models.Model): """ An obstacle """ + group = models.ForeignKey(ObstacleGroup, null=True, blank=True, on_delete=models.SET_NULL) geometry = GeometryField('polygon') height = models.DecimalField(_('height'), max_digits=6, decimal_places=2, default=0.8, validators=[MinValueValidator(Decimal('0'))]) altitude = models.DecimalField(_('altitude above ground'), max_digits=6, decimal_places=2, default=0, validators=[MinValueValidator(Decimal('0'))]) - color = models.CharField(null=True, blank=True, max_length=32, verbose_name=_('color (optional)')) class Meta: verbose_name = _('Obstacle') @@ -194,22 +221,31 @@ class Obstacle(SpaceGeometryMixin, models.Model): def get_geojson_properties(self, *args, instance=None, **kwargs) -> dict: result = super().get_geojson_properties(*args, **kwargs) - if self.color: - result['color'] = self.color + from c3nav.mapdata.render.theme import ColorManager + color = self.get_color(ColorManager.for_theme(None), instance=instance) + if color: + result['color'] = color return result def _serialize(self, geometry=True, **kwargs): result = super()._serialize(geometry=geometry, **kwargs) result['height'] = float(str(self.height)) result['altitude'] = float(str(self.altitude)) - result['color'] = self.color + from c3nav.mapdata.render.theme import ColorManager + result['color'] = self.get_color(ColorManager.for_theme(None)) return result + def get_color(self, color_manager: 'ThemeColorManager', instance=None): + if instance is None: + instance = self + return color_manager.obstaclegroup_fill_color(instance.group) if instance.group is not None else color_manager.obstacles_default_fill + class LineObstacle(SpaceGeometryMixin, models.Model): """ An obstacle that is a line with a specific width """ + group = models.ForeignKey(ObstacleGroup, null=True, blank=True, on_delete=models.SET_NULL) geometry = GeometryField('linestring') width = models.DecimalField(_('width'), max_digits=4, decimal_places=2, default=0.15) height = models.DecimalField(_('height'), max_digits=6, decimal_places=2, default=0.8, @@ -217,6 +253,7 @@ class LineObstacle(SpaceGeometryMixin, models.Model): altitude = models.DecimalField(_('altitude above ground'), max_digits=6, decimal_places=2, default=0, validators=[MinValueValidator(Decimal('0'))]) color = models.CharField(null=True, blank=True, max_length=32, verbose_name=_('color (optional)')) + # TODO: migrate away from color same as for Obstacle class Meta: verbose_name = _('Line Obstacle') @@ -226,8 +263,10 @@ class LineObstacle(SpaceGeometryMixin, models.Model): def get_geojson_properties(self, *args, instance=None, **kwargs) -> dict: result = super().get_geojson_properties(*args, **kwargs) - if self.color: - result['color'] = self.color + from c3nav.mapdata.render.theme import ColorManager + color = self.get_color(ColorManager.for_theme(None), instance=instance) + if color: + result['color'] = color return result def _serialize(self, geometry=True, **kwargs): @@ -235,11 +274,18 @@ class LineObstacle(SpaceGeometryMixin, models.Model): result['width'] = float(str(self.width)) result['height'] = float(str(self.height)) result['altitude'] = float(str(self.altitude)) - result['color'] = self.color + from c3nav.mapdata.render.theme import ColorManager + result['color'] = self.get_color(ColorManager.for_theme(None)) if geometry: result['buffered_geometry'] = format_geojson(mapping(self.buffered_geometry)) return result + def get_color(self, color_manager: 'ThemeColorManager', instance=None): + if instance is None: + instance = self + # TODO: should line obstacles use border color? + return color_manager.obstaclegroup_fill_color(instance.group) if instance.group is not None else color_manager.obstacles_default_fill + @property def buffered_geometry(self): return self.geometry.buffer(float(self.width / 2), join_style=JOIN_STYLE.mitre, cap_style=CAP_STYLE.flat) diff --git a/src/c3nav/mapdata/models/locations.py b/src/c3nav/mapdata/models/locations.py index 70999e8f..b2576e43 100644 --- a/src/c3nav/mapdata/models/locations.py +++ b/src/c3nav/mapdata/models/locations.py @@ -155,18 +155,19 @@ class Location(LocationSlug, AccessRestrictionMixin, TitledMixin, models.Model): def grid_square(self): return None - def get_color(self, instance=None): + def get_color(self, color_manager: 'ThemeColorManager', instance=None): # dont filter in the query here so prefetch_related works - result = self.get_color_sorted(instance) + result = self.get_color_sorted(color_manager, instance) return None if result is None else result[1] - def get_color_sorted(self, instance=None): + def get_color_sorted(self, color_manager: 'ThemeColorManager', instance=None): # dont filter in the query here so prefetch_related works if instance is None: instance = self for group in instance.groups.all(): - if group.color and getattr(group.category, 'allow_'+self.__class__._meta.default_related_name): - return (0, group.category.priority, group.hierarchy, group.priority), group.color + color = color_manager.locationgroup_fill_color(group) + if color and getattr(group.category, 'allow_'+self.__class__._meta.default_related_name): + return (0, group.category.priority, group.hierarchy, group.priority), color return None def get_icon(self): diff --git a/src/c3nav/mapdata/models/theme.py b/src/c3nav/mapdata/models/theme.py new file mode 100644 index 00000000..ea6246b0 --- /dev/null +++ b/src/c3nav/mapdata/models/theme.py @@ -0,0 +1,58 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from c3nav.mapdata.models import LocationGroup +from c3nav.mapdata.models.base import TitledMixin +from c3nav.mapdata.models.geometry.space import ObstacleGroup + + +class Theme(TitledMixin, models.Model): + """ + A theme + """ + # TODO: when a theme base colors change we need to bust the cache somehow + description = models.TextField() + color_background = models.CharField(max_length=32, verbose_name=_('background color')) + color_wall_fill = models.CharField(max_length=32, verbose_name=_('wall fill color')) + color_wall_border = models.CharField(max_length=32, verbose_name=_('wall border color')) + color_door_fill = models.CharField(max_length=32, verbose_name=_('door fill color')) + color_ground_fill = models.CharField(max_length=32, verbose_name=_('ground fill color')) + color_obstacles_default_fill = models.CharField(max_length=32, verbose_name=_('default fill color for obstacles')) + color_obstacles_default_border = models.CharField(max_length=32, + verbose_name=_('default border color for obstacles')) + + last_updated = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _('Theme') + verbose_name_plural = _('Themes') + default_related_name = 'themes' + + +class ThemeLocationGroupBackgroundColor(models.Model): + """ + A background color for a LocationGroup in a theme + """ + theme = models.ForeignKey(Theme, on_delete=models.CASCADE, related_name="location_groups") + location_group = models.ForeignKey(LocationGroup, on_delete=models.SET_NULL, null=True, blank=True, + related_name="theme_colors") + fill_color = models.CharField(max_length=32, null=True, blank=True) + border_color = models.CharField(max_length=32, null=True, blank=True) + + def save(self, *args, **kwargs): + self.location_group.register_changed_geometries() + super().save(*args, **kwargs) + + def delete(self, *args, **kwargs): + self.location_group.register_changed_geometries() + super().delete(*args, **kwargs) + + +class ThemeObstacleGroupBackgroundColor(models.Model): + """ + A background color for an ObstacleGroup in a theme + """ + theme = models.ForeignKey(Theme, on_delete=models.CASCADE, related_name="obstacle_groups") + obstacle_group = models.ForeignKey(ObstacleGroup, on_delete=models.SET_NULL, null=True, blank=True, related_name="theme_colors") + fill_color = models.CharField(max_length=32, null=True, blank=True) + border_color = models.CharField(max_length=32, null=True, blank=True) diff --git a/src/c3nav/mapdata/render/geometry/level.py b/src/c3nav/mapdata/render/geometry/level.py index 1fd7a94f..c21429cb 100644 --- a/src/c3nav/mapdata/render/geometry/level.py +++ b/src/c3nav/mapdata/render/geometry/level.py @@ -63,7 +63,7 @@ class LevelGeometries: return '' % (self.short_label, self.pk) @classmethod - def build_for_level(cls, level, altitudeareas_above): + def build_for_level(cls, level, color_manager: 'ThemeColorManager', altitudeareas_above): geoms = LevelGeometries() buildings_geom = unary_union([unwrap_geom(b.geometry) for b in level.buildings.all()]) geoms.buildings = buildings_geom @@ -128,7 +128,7 @@ class LevelGeometries: buffered.difference(buildings_geom) ) - colors.setdefault(space.get_color_sorted(), {}).setdefault(access_restriction, []).append( + colors.setdefault(space.get_color_sorted(color_manager), {}).setdefault(access_restriction, []).append( unwrap_geom(space.geometry) ) @@ -137,7 +137,7 @@ class LevelGeometries: area.geometry = area.geometry.intersection(unwrap_geom(space.walkable_geom)) if access_restriction is not None: access_restriction_affected.setdefault(access_restriction, []).append(area.geometry) - colors.setdefault(area.get_color_sorted(), {}).setdefault(access_restriction, []).append(area.geometry) + colors.setdefault(area.get_color_sorted(color_manager), {}).setdefault(access_restriction, []).append(area.geometry) for column in space.columns.all(): access_restriction = column.access_restriction_id @@ -156,14 +156,14 @@ class LevelGeometries: continue obstacles.setdefault( int((obstacle.height+obstacle.altitude)*1000), {} - ).setdefault(obstacle.color, []).append( + ).setdefault(obstacle.get_color(color_manager), []).append( obstacle.geometry.intersection(unwrap_geom(space.walkable_geom)) ) for lineobstacle in space.lineobstacles.all(): if not lineobstacle.height: continue - obstacles.setdefault(int(lineobstacle.height*1000), {}).setdefault(lineobstacle.color, []).append( + obstacles.setdefault(int(lineobstacle.height*1000), {}).setdefault(lineobstacle.get_color(color_manager), []).append( lineobstacle.buffered_geometry.intersection(unwrap_geom(space.walkable_geom)) ) diff --git a/src/c3nav/mapdata/render/renderdata.py b/src/c3nav/mapdata/render/renderdata.py index f818c08a..cbe928a2 100644 --- a/src/c3nav/mapdata/render/renderdata.py +++ b/src/c3nav/mapdata/render/renderdata.py @@ -14,6 +14,7 @@ from shapely.ops import unary_union from shapely.prepared import PreparedGeometry from c3nav.mapdata.models import Level, MapUpdate, Source +from c3nav.mapdata.models.theme import Theme from c3nav.mapdata.render.geometry import AltitudeAreaGeometries, LevelGeometries from c3nav.mapdata.utils.cache import AccessRestrictionAffected, MapHistory from c3nav.mapdata.utils.cache.package import CachePackage @@ -66,290 +67,300 @@ class LevelRenderData: # todo: we should check that levels on top come before their levels as they should - """ - first pass in reverse to collect some data that we need later - """ - # level geometry for every single level - single_level_geoms: dict[int, LevelGeometries] = {} - # interpolator are used to create the 3d mesh - interpolators = {} - last_interpolator: NearestNDInterpolator | None = None - # altitudeareas of levels on top are are collected on the way down to supply to the levelgeometries builder - altitudeareas_above = [] # todo: typing - for render_level in reversed(levels): - # build level geometry for every single level - single_level_geoms[render_level.pk] = LevelGeometries.build_for_level(render_level, altitudeareas_above) - - # ignore intermediate levels in this pass - if render_level.on_top_of_id is not None: - # todo: shouldn't this be cleared or something? - altitudeareas_above.extend(single_level_geoms[render_level.pk].altitudeareas) - altitudeareas_above.sort(key=operator.attrgetter('altitude')) - continue - - # create interpolator to create the pieces that fit multiple 3d layers together - if last_interpolator is not None: - interpolators[render_level.pk] = last_interpolator - - coords = deque() - values = deque() - for area in single_level_geoms[render_level.pk].altitudeareas: - new_coords = np.vstack(tuple(np.array(ring.coords) for ring in get_rings(area.geometry))) - coords.append(new_coords) - values.append(np.full((new_coords.shape[0], 1), fill_value=area.altitude)) - - if coords: - last_interpolator = NearestNDInterpolator(np.vstack(coords), np.vstack(values)) - else: - last_interpolator = NearestNDInterpolator(np.array([[0, 0]]), - np.array([float(render_level.base_altitude)])) - - """ - second pass, forward to create the LevelRenderData for each level - """ - for render_level in levels: - # we don't create render data for on_top_of levels - if render_level.on_top_of_id is not None: - continue - - map_history = MapHistory.open_level(render_level.pk, 'base') - - # collect potentially relevant levels for rendering this level - # these are all levels that are on_top_of this level or below this level - relevant_levels = tuple( - sublevel for sublevel in levels - if sublevel.on_top_of_id == render_level.pk or sublevel.base_altitude <= render_level.base_altitude - ) + themes = [None, *Theme.objects.values_list('pk', flat=True)] + from c3nav.mapdata.render.theme import ColorManager + for theme in themes: + color_manager = ColorManager.for_theme(theme) """ - 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. + first pass in reverse to collect some data that we need later """ - # area to crop each level to, by id - level_crop_to: dict[int, Cropper] = {} - # current remaining area that we're cropping to – None means no cropping - crop_to = None - primary_level_count = 0 - main_level_passed = 0 - lowest_important_level = None - last_lower_bound = None - for level in reversed(relevant_levels): # reversed means we are going down - geoms = single_level_geoms[level.pk] + # level geometry for every single level + single_level_geoms: dict[int, LevelGeometries] = {} + # interpolator are used to create the 3d mesh + interpolators = {} + last_interpolator: NearestNDInterpolator | None = None + # altitudeareas of levels on top are are collected on the way down to supply to the levelgeometries builder + altitudeareas_above = [] # todo: typing + for render_level in reversed(levels): + # build level geometry for every single level + single_level_geoms[render_level.pk] = LevelGeometries.build_for_level(render_level, color_manager, altitudeareas_above) - if geoms.holes is not None: - primary_level_count += 1 + # ignore intermediate levels in this pass + if render_level.on_top_of_id is not None: + # todo: shouldn't this be cleared or something? + altitudeareas_above.extend(single_level_geoms[render_level.pk].altitudeareas) + altitudeareas_above.sort(key=operator.attrgetter('altitude')) + continue - # get lowest intermediate level directly below main level - if not main_level_passed: - if geoms.pk == render_level.pk: - main_level_passed = 1 + # create interpolator to create the pieces that fit multiple 3d layers together + if last_interpolator is not None: + interpolators[render_level.pk] = last_interpolator + + coords = deque() + values = deque() + for area in single_level_geoms[render_level.pk].altitudeareas: + new_coords = np.vstack(tuple(np.array(ring.coords) for ring in get_rings(area.geometry))) + coords.append(new_coords) + values.append(np.full((new_coords.shape[0], 1), fill_value=area.altitude)) + + if coords: + last_interpolator = NearestNDInterpolator(np.vstack(coords), np.vstack(values)) else: - if not level.on_top_of_id: - main_level_passed += 1 - if main_level_passed < 2: - lowest_important_level = level + last_interpolator = NearestNDInterpolator(np.array([[0, 0]]), + np.array([float(render_level.base_altitude)])) - # make upper bounds - if geoms.on_top_of_id is None: - if last_lower_bound is None: - geoms.upper_bound = geoms.max_altitude+geoms.max_height - else: - geoms.upper_bound = last_lower_bound - last_lower_bound = geoms.lower_bound - - # set crop area if we area on the second primary layer from top or below - level_crop_to[level.pk] = Cropper(crop_to if primary_level_count > 1 else None) - - if geoms.holes is not None: # there area holes on this area - if crop_to is None: - crop_to = geoms.holes - else: - crop_to = crop_to.intersection(geoms.holes) - - if crop_to.is_empty: - break - - render_data = LevelRenderData( - base_altitude=render_level.base_altitude, - lowest_important_level=lowest_important_level.pk, - ) - access_restriction_affected = {} - - # go through sublevels, get their level geometries and crop them - lowest_important_level_passed = False - for level in relevant_levels: - try: - crop_to = level_crop_to[level.pk] - except KeyError: + """ + second pass, forward to create the LevelRenderData for each level + """ + for render_level in levels: + # we don't create render data for on_top_of levels + if render_level.on_top_of_id is not None: continue - old_geoms = single_level_geoms[level.pk] + map_history = MapHistory.open_level(render_level.pk, 'base') - if render_data.lowest_important_level == level.pk: - lowest_important_level_passed = True + # collect potentially relevant levels for rendering this level + # these are all levels that are on_top_of this level or below this level + relevant_levels = tuple( + sublevel for sublevel in levels + if sublevel.on_top_of_id == render_level.pk or sublevel.base_altitude <= render_level.base_altitude + ) - if old_geoms.holes and render_data.darken_area is None and lowest_important_level_passed: - render_data.darken_area = old_geoms.holes + """ + 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. + """ + # area to crop each level to, by id + level_crop_to: dict[int, Cropper] = {} + # current remaining area that we're cropping to – None means no cropping + crop_to = None + primary_level_count = 0 + main_level_passed = 0 + lowest_important_level = None + last_lower_bound = None + for level in reversed(relevant_levels): # reversed means we are going down + geoms = single_level_geoms[level.pk] - if crop_to.geometry is not None: - map_history.composite(MapHistory.open_level(level.pk, 'base'), crop_to.geometry) - elif render_level.pk != level.pk: - map_history.composite(MapHistory.open_level(level.pk, 'base'), None) + if geoms.holes is not None: + primary_level_count += 1 - new_geoms = LevelGeometries() - new_geoms.buildings = crop_to.intersection(old_geoms.buildings) - if old_geoms.on_top_of_id is None: - new_geoms.holes = crop_to.intersection(old_geoms.holes) - new_geoms.doors = crop_to.intersection(old_geoms.doors) - new_geoms.walls = crop_to.intersection(old_geoms.walls) - new_geoms.all_walls = crop_to.intersection(old_geoms.all_walls) - new_geoms.short_walls = tuple((altitude, geom) for altitude, geom in tuple( - (altitude, crop_to.intersection(geom)) - for altitude, geom in old_geoms.short_walls - ) if not geom.is_empty) + # get lowest intermediate level directly below main level + if not main_level_passed: + if geoms.pk == render_level.pk: + main_level_passed = 1 + else: + if not level.on_top_of_id: + main_level_passed += 1 + if main_level_passed < 2: + lowest_important_level = level - for altitudearea in old_geoms.altitudeareas: - new_geometry = crop_to.intersection(unwrap_geom(altitudearea.geometry)) - if new_geometry.is_empty: + # make upper bounds + if geoms.on_top_of_id is None: + if last_lower_bound is None: + geoms.upper_bound = geoms.max_altitude+geoms.max_height + else: + geoms.upper_bound = last_lower_bound + last_lower_bound = geoms.lower_bound + + # set crop area if we area on the second primary layer from top or below + level_crop_to[level.pk] = Cropper(crop_to if primary_level_count > 1 else None) + + if geoms.holes is not None: # there area holes on this area + if crop_to is None: + crop_to = geoms.holes + else: + crop_to = crop_to.intersection(geoms.holes) + + if crop_to.is_empty: + break + + render_data = LevelRenderData( + base_altitude=render_level.base_altitude, + lowest_important_level=lowest_important_level.pk, + ) + access_restriction_affected = {} + + # go through sublevels, get their level geometries and crop them + lowest_important_level_passed = False + for level in relevant_levels: + try: + crop_to = level_crop_to[level.pk] + except KeyError: continue - new_geometry_prep = prepared.prep(new_geometry) - new_altitudearea = AltitudeAreaGeometries() - new_altitudearea.geometry = new_geometry - new_altitudearea.altitude = altitudearea.altitude - new_altitudearea.altitude2 = altitudearea.altitude2 - new_altitudearea.point1 = altitudearea.point1 - new_altitudearea.point2 = altitudearea.point2 + old_geoms = single_level_geoms[level.pk] - new_colors = {} - for color, areas in altitudearea.colors.items(): - new_areas = {} - for access_restriction, area in areas.items(): - if not new_geometry_prep.intersects(area): - continue - 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 + if render_data.lowest_important_level == level.pk: + lowest_important_level_passed = True - new_altitudearea_obstacles = {} - for height, height_obstacles in altitudearea.obstacles.items(): - new_height_obstacles = {} - for color, color_obstacles in height_obstacles.items(): - new_color_obstacles = [] - for obstacle in color_obstacles: - if new_geometry_prep.intersects(obstacle): - new_color_obstacles.append( - obstacle.intersection(unwrap_geom(altitudearea.geometry)) - ) - if new_color_obstacles: - new_height_obstacles[color] = new_color_obstacles - if new_height_obstacles: - new_altitudearea_obstacles[height] = new_height_obstacles - new_altitudearea.obstacles = new_altitudearea_obstacles + if old_geoms.holes and render_data.darken_area is None and lowest_important_level_passed: + render_data.darken_area = old_geoms.holes - new_geoms.altitudeareas.append(new_altitudearea) + if crop_to.geometry is not None: + map_history.composite(MapHistory.open_level(level.pk, 'base'), crop_to.geometry) + elif render_level.pk != level.pk: + map_history.composite(MapHistory.open_level(level.pk, 'base'), None) - if new_geoms.walls.is_empty and not new_geoms.altitudeareas: - continue + new_geoms = LevelGeometries() + new_geoms.buildings = crop_to.intersection(old_geoms.buildings) + if old_geoms.on_top_of_id is None: + new_geoms.holes = crop_to.intersection(old_geoms.holes) + new_geoms.doors = crop_to.intersection(old_geoms.doors) + new_geoms.walls = crop_to.intersection(old_geoms.walls) + new_geoms.all_walls = crop_to.intersection(old_geoms.all_walls) + new_geoms.short_walls = tuple((altitude, geom) for altitude, geom in tuple( + (altitude, crop_to.intersection(geom)) + for altitude, geom in old_geoms.short_walls + ) if not geom.is_empty) - new_geoms.ramps = tuple( - ramp for ramp in (crop_to.intersection(unwrap_geom(ramp)) for ramp in old_geoms.ramps) - if not ramp.is_empty - ) + for altitudearea in old_geoms.altitudeareas: + new_geometry = crop_to.intersection(unwrap_geom(altitudearea.geometry)) + if new_geometry.is_empty: + continue + new_geometry_prep = prepared.prep(new_geometry) - new_geoms.heightareas = tuple( - (area, height) for area, height in ((crop_to.intersection(unwrap_geom(area)), height) - for area, height in old_geoms.heightareas) - if not area.is_empty - ) + new_altitudearea = AltitudeAreaGeometries() + new_altitudearea.geometry = new_geometry + new_altitudearea.altitude = altitudearea.altitude + new_altitudearea.altitude2 = altitudearea.altitude2 + new_altitudearea.point1 = altitudearea.point1 + new_altitudearea.point2 = altitudearea.point2 - new_geoms.affected_area = unary_union(( - *(altitudearea.geometry for altitudearea in new_geoms.altitudeareas), - crop_to.intersection(new_geoms.walls.buffer(1)), - *((new_geoms.holes.buffer(1),) if new_geoms.holes else ()), - )) + new_colors = {} + for color, areas in altitudearea.colors.items(): + new_areas = {} + for access_restriction, area in areas.items(): + if not new_geometry_prep.intersects(area): + continue + 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 - for access_restriction, area in old_geoms.access_restriction_affected.items(): - new_area = crop_to.intersection(area) - if not new_area.is_empty: - access_restriction_affected.setdefault(access_restriction, []).append(new_area) + new_altitudearea_obstacles = {} + for height, height_obstacles in altitudearea.obstacles.items(): + new_height_obstacles = {} + for color, color_obstacles in height_obstacles.items(): + new_color_obstacles = [] + for obstacle in color_obstacles: + if new_geometry_prep.intersects(obstacle): + new_color_obstacles.append( + obstacle.intersection(unwrap_geom(altitudearea.geometry)) + ) + if new_color_obstacles: + new_height_obstacles[color] = new_color_obstacles + if new_height_obstacles: + new_altitudearea_obstacles[height] = new_height_obstacles + new_altitudearea.obstacles = new_altitudearea_obstacles - 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.altitudeareas.append(new_altitudearea) - 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 + if new_geoms.walls.is_empty and not new_geoms.altitudeareas: + continue - new_geoms.pk = old_geoms.pk - new_geoms.on_top_of_id = old_geoms.on_top_of_id - new_geoms.short_label = old_geoms.short_label - new_geoms.base_altitude = old_geoms.base_altitude - new_geoms.default_height = old_geoms.default_height - new_geoms.door_height = old_geoms.door_height - new_geoms.min_altitude = (min(area.altitude for area in new_geoms.altitudeareas) - if new_geoms.altitudeareas else new_geoms.base_altitude) - new_geoms.max_altitude = (max(area.altitude for area in new_geoms.altitudeareas) - if new_geoms.altitudeareas else new_geoms.base_altitude) - new_geoms.max_height = (min(height for area, height in new_geoms.heightareas) - if new_geoms.heightareas else new_geoms.default_height) - new_geoms.lower_bound = old_geoms.lower_bound - new_geoms.upper_bound = old_geoms.upper_bound + new_geoms.ramps = tuple( + ramp for ramp in (crop_to.intersection(unwrap_geom(ramp)) for ramp in old_geoms.ramps) + if not ramp.is_empty + ) - new_geoms.build_mesh(interpolators.get(render_level.pk) if level.pk == render_level.pk else None) + new_geoms.heightareas = tuple( + (area, height) for area, height in ((crop_to.intersection(unwrap_geom(area)), height) + for area, height in old_geoms.heightareas) + if not area.is_empty + ) - render_data.levels.append(new_geoms) + new_geoms.affected_area = unary_union(( + *(altitudearea.geometry for altitudearea in new_geoms.altitudeareas), + crop_to.intersection(new_geoms.walls.buffer(1)), + *((new_geoms.holes.buffer(1),) if new_geoms.holes else ()), + )) - access_restriction_affected = { - access_restriction: unary_union(areas) - for access_restriction, areas in access_restriction_affected.items() - } + for access_restriction, area in old_geoms.access_restriction_affected.items(): + new_area = crop_to.intersection(area) + if not new_area.is_empty: + access_restriction_affected.setdefault(access_restriction, []).append(new_area) - access_restriction_affected = AccessRestrictionAffected.build(access_restriction_affected) - access_restriction_affected.save_level(render_level.pk, 'composite') + 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 - map_history.save_level(render_level.pk, 'composite') + 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 - package.add_level(render_level.pk, map_history, access_restriction_affected) + new_geoms.pk = old_geoms.pk + new_geoms.on_top_of_id = old_geoms.on_top_of_id + new_geoms.short_label = old_geoms.short_label + new_geoms.base_altitude = old_geoms.base_altitude + new_geoms.default_height = old_geoms.default_height + new_geoms.door_height = old_geoms.door_height + new_geoms.min_altitude = (min(area.altitude for area in new_geoms.altitudeareas) + if new_geoms.altitudeareas else new_geoms.base_altitude) + new_geoms.max_altitude = (max(area.altitude for area in new_geoms.altitudeareas) + if new_geoms.altitudeareas else new_geoms.base_altitude) + new_geoms.max_height = (min(height for area, height in new_geoms.heightareas) + if new_geoms.heightareas else new_geoms.default_height) + new_geoms.lower_bound = old_geoms.lower_bound + new_geoms.upper_bound = old_geoms.upper_bound - render_data.save(render_level.pk) + new_geoms.build_mesh(interpolators.get(render_level.pk) if level.pk == render_level.pk else None) + + render_data.levels.append(new_geoms) + + access_restriction_affected = { + access_restriction: unary_union(areas) + for access_restriction, areas in access_restriction_affected.items() + } + + access_restriction_affected = AccessRestrictionAffected.build(access_restriction_affected) + access_restriction_affected.save_level(render_level.pk, 'composite') + + map_history.save_level(render_level.pk, 'composite') + + package.add_level(render_level.pk, theme, map_history, access_restriction_affected) + + render_data.save(render_level.pk, theme) package.save_all() cached = LocalContext() @staticmethod - def _level_filename(pk): - return settings.CACHE_ROOT / ('render_data_level_%d.pickle' % pk) + def _level_filename(level_pk, theme_pk): + if theme_pk is None: + name = 'render_data_level_%d.pickle' % level_pk + else: + name = 'render_data_level_%d_theme_%d.pickle' % (level_pk, theme_pk) + return settings.CACHE_ROOT / name @classmethod - def get(cls, level): + def get(cls, level, theme): # get the current render data from local variable if no new processed mapupdate exists. # this is much faster than any other possible cache cache_key = MapUpdate.current_processed_cache_key() - level_pk = str(level.pk if isinstance(level, Level) else level) + level_pk = level.pk if isinstance(level, Level) else level + theme_pk = theme.pk if isinstance(theme, Theme) else theme + key = f'{level_pk}_{theme_pk}' if getattr(cls.cached, 'key', None) != cache_key: cls.cached.key = cache_key cls.cached.data = {} else: - result = cls.cached.data.get(level_pk, None) + result = cls.cached.data.get(key, None) if result is not None: return result - pk = level.pk if isinstance(level, Level) else level - result = pickle.load(open(cls._level_filename(pk), 'rb')) + result = pickle.load(open(cls._level_filename(level_pk, theme_pk), 'rb')) - cls.cached.data[level_pk] = result + cls.cached.data[key] = result return result - def save(self, pk): - return pickle.dump(self, open(self._level_filename(pk), 'wb')) + def save(self, level_pk, theme_pk): + return pickle.dump(self, open(self._level_filename(level_pk, theme_pk), 'wb')) diff --git a/src/c3nav/mapdata/render/renderer.py b/src/c3nav/mapdata/render/renderer.py index 71dc5f43..824af50f 100644 --- a/src/c3nav/mapdata/render/renderer.py +++ b/src/c3nav/mapdata/render/renderer.py @@ -6,16 +6,10 @@ from c3nav.mapdata.models import Level from c3nav.mapdata.render.engines.base import FillAttribs, StrokeAttribs from c3nav.mapdata.render.geometry import hybrid_union from c3nav.mapdata.render.renderdata import LevelRenderData +from c3nav.mapdata.render.theme import ColorManager from c3nav.mapdata.render.utils import get_full_levels, get_min_altitude from c3nav.mapdata.utils.color import color_to_rgb, rgb_to_color -RENDER_COLOR_BACKGROUND = "#DCDCDC" -RENDER_COLOR_WALL_FILL = "#aaaaaa" -RENDER_COLOR_WALL_BORDER = "#666666" -RENDER_COLOR_DOOR_FILL = "#ffffff" -RENDER_COLOR_GROUND_FILL = "#eeeeee" -RENDER_COLOR_OBSTACLES_DEFAULT_FILL = "#b7b7b7" -RENDER_COLOR_OBSTACLES_DEFAULT_BORDER = "#888888" class MapRenderer: @@ -38,16 +32,17 @@ class MapRenderer: def bbox(self): return box(self.minx-1, self.miny-1, self.maxx+1, self.maxy+1) - def render(self, engine_cls, center=True): + def render(self, engine_cls, theme, center=True): + color_manager = ColorManager.for_theme(theme) # add no access restriction to “unlocked“ access restrictions so lookup gets easier access_permissions = self.access_permissions | {None} bbox = prepared.prep(self.bbox) - level_render_data = LevelRenderData.get(self.level) + level_render_data = LevelRenderData.get(self.level, theme) engine = engine_cls(self.width, self.height, self.minx, self.miny, float(level_render_data.base_altitude), - scale=self.scale, buffer=1, background=RENDER_COLOR_BACKGROUND, + scale=self.scale, buffer=1, background=color_manager.background, center=center, min_width=self.min_width) if hasattr(engine, 'custom_render'): @@ -81,17 +76,17 @@ class MapRenderer: ).union(add_walls) if not_full_levels: - engine.add_geometry(geoms.walls_base, fill=FillAttribs(RENDER_COLOR_WALL_FILL), category='walls') + engine.add_geometry(geoms.walls_base, fill=FillAttribs(color_manager.wall_fill), category='walls') engine.add_geometry(geoms.walls_bottom.fit(scale=geoms.min_altitude-min_altitude, offset=min_altitude-int(0.7*1000)), - fill=FillAttribs(RENDER_COLOR_WALL_FILL), category='walls') + fill=FillAttribs(color_manager.wall_fill), category='walls') for i, altitudearea in enumerate(geoms.altitudeareas): base = altitudearea.base.difference(crop_areas) bottom = altitudearea.bottom.difference(crop_areas) - engine.add_geometry(base, fill=FillAttribs(RENDER_COLOR_GROUND_FILL), category='ground', item=i) + engine.add_geometry(base, fill=FillAttribs(color_manager.ground_fill), category='ground', item=i) engine.add_geometry(bottom.fit(scale=geoms.min_altitude - min_altitude, offset=min_altitude - int(0.7 * 1000)), - fill=FillAttribs(RENDER_COLOR_WALL_FILL), category='ground') + fill=FillAttribs(color_manager.wall_fill), category='ground') # render altitude areas in default ground color and add ground colors to each one afterwards # shadows are directly calculated and added by the engine @@ -100,7 +95,7 @@ class MapRenderer: if not_full_levels: geometry = geometry.filter(bottom=False) engine.add_geometry(geometry, altitude=altitudearea.altitude, - fill=FillAttribs(RENDER_COLOR_GROUND_FILL), category='ground', item=i) + fill=FillAttribs(color_manager.ground_fill), category='ground', item=i) j = 0 for (order, color), areas in altitudearea.colors.items(): @@ -131,8 +126,8 @@ class MapRenderer: else: engine.add_geometry( obstacle_geom, - fill=FillAttribs(RENDER_COLOR_OBSTACLES_DEFAULT_FILL), - stroke=StrokeAttribs(RENDER_COLOR_OBSTACLES_DEFAULT_BORDER, 0.05, min_px=0.2), + fill=FillAttribs(color_manager.obstacles_default_fill), + stroke=StrokeAttribs(color_manager.obstacles_default_border, 0.05, min_px=0.2), category='obstacles' ) @@ -147,29 +142,29 @@ class MapRenderer: engine.add_geometry( walls.filter(bottom=not not_full_levels, top=not walls_extended), - height=geoms.default_height, fill=FillAttribs(RENDER_COLOR_WALL_FILL), category='walls' + height=geoms.default_height, fill=FillAttribs(color_manager.wall_fill), category='walls' ) for short_wall in geoms.short_walls: engine.add_geometry(short_wall.filter(bottom=not not_full_levels), - fill=FillAttribs(RENDER_COLOR_WALL_FILL), category='walls') + fill=FillAttribs(color_manager.wall_fill), category='walls') if walls_extended: - engine.add_geometry(geoms.walls_extended, fill=FillAttribs(RENDER_COLOR_WALL_FILL), category='walls') + engine.add_geometry(geoms.walls_extended, fill=FillAttribs(color_manager.wall_fill), category='walls') doors_extended = geoms.doors_extended and full_levels if not geoms.doors.is_empty: engine.add_geometry(geoms.doors.difference(add_walls).filter(top=not doors_extended), - fill=FillAttribs(RENDER_COLOR_DOOR_FILL), - stroke=StrokeAttribs(RENDER_COLOR_DOOR_FILL, 0.05, min_px=0.2), + fill=FillAttribs(color_manager.door_fill), + stroke=StrokeAttribs(color_manager.door_fill, 0.05, min_px=0.2), category='doors') if doors_extended: - engine.add_geometry(geoms.doors_extended, fill=FillAttribs(RENDER_COLOR_WALL_FILL), category='doors') + engine.add_geometry(geoms.doors_extended, fill=FillAttribs(color_manager.wall_fill), category='doors') if walls is not None: engine.add_geometry(walls, - stroke=StrokeAttribs(RENDER_COLOR_WALL_BORDER, 0.1, min_px=1), + stroke=StrokeAttribs(color_manager.wall_border, 0.1, min_px=1), category='walls') if geoms.on_top_of_id is None: diff --git a/src/c3nav/mapdata/render/theme.py b/src/c3nav/mapdata/render/theme.py new file mode 100644 index 00000000..6c28c59e --- /dev/null +++ b/src/c3nav/mapdata/render/theme.py @@ -0,0 +1,91 @@ +from c3nav.mapdata.models import LocationGroup +from c3nav.mapdata.models.geometry.space import ObstacleGroup +from c3nav.mapdata.models.theme import Theme + +RENDER_COLOR_BACKGROUND = "#DCDCDC" +RENDER_COLOR_WALL_FILL = "#aaaaaa" +RENDER_COLOR_WALL_BORDER = "#666666" +RENDER_COLOR_DOOR_FILL = "#ffffff" +RENDER_COLOR_GROUND_FILL = "#eeeeee" +RENDER_COLOR_OBSTACLES_DEFAULT_FILL = "#b7b7b7" +RENDER_COLOR_OBSTACLES_DEFAULT_BORDER = "#888888" + + +class ThemeColorManager: + # TODO: border colors are not implemented yet? + def __init__(self, theme: Theme = None): + if theme is None: + self.background = RENDER_COLOR_BACKGROUND + self.wall_fill = RENDER_COLOR_WALL_FILL + self.wall_border = RENDER_COLOR_WALL_BORDER + self.door_fill = RENDER_COLOR_DOOR_FILL + self.ground_fill = RENDER_COLOR_GROUND_FILL + self.obstacles_default_fill = RENDER_COLOR_OBSTACLES_DEFAULT_FILL + self.obstacles_default_border = RENDER_COLOR_OBSTACLES_DEFAULT_BORDER + self.location_group_border_colors = {} + self.location_group_fill_colors = { + l.pk: l.color + for l in LocationGroup.objects.filter(color__isnull=False).all() + } + self.obstacle_group_border_colors = {} + self.obstacle_group_fill_colors = { + o.pk: o.color + for o in ObstacleGroup.objects.filter(color__isnull=False).all() + } + else: + self.background = theme.color_background + self.wall_fill = theme.color_wall_fill + self.wall_border = theme.color_wall_border + self.door_fill = theme.color_door_fill + self.ground_fill = theme.color_ground_fill + self.obstacles_default_fill = theme.color_obstacles_default_fill + self.obstacles_default_border = theme.color_obstacles_default_border + self.location_group_border_colors = { + l.location_group_id: l.border_color + for l in theme.location_groups.all() + } + self.location_group_fill_colors = { + l.location_group_id: l.fill_color + for l in theme.location_groups.all() + } + self.obstacle_group_border_colors = { + o.obstacle_group_id: o.border_color + for o in theme.obstacle_groups.all() + } + self.obstacle_group_fill_colors = { + o.obstacle_group_id: o.fill_color + for o in theme.obstacle_groups.all() + } + + def locationgroup_border_color(self, location_group: LocationGroup): + return self.location_group_border_colors.get(location_group.pk, None) + + def locationgroup_fill_color(self, location_group: LocationGroup): + return self.location_group_fill_colors.get(location_group.pk, None) + + def obstaclegroup_border_color(self, obstacle_group: ObstacleGroup): + return self.obstacle_group_border_colors.get(obstacle_group.pk, self.obstacles_default_border) + + def obstaclegroup_fill_color(self, obstacle_group: ObstacleGroup): + return self.obstacle_group_fill_colors.get(obstacle_group.pk, self.obstacles_default_fill) + + +class ColorManager: + themes = {} + default_theme = None + + @classmethod + def for_theme(cls, theme): + if theme is None: + if cls.default_theme is None: + cls.default_theme = ThemeColorManager() + return cls.default_theme + if not isinstance(theme, Theme): + theme = Theme.objects.get(pk=theme) + if theme.pk not in cls.themes: + cls.themes[theme.pk] = ThemeColorManager(theme) + return cls.themes[theme.pk] + + @classmethod + def refresh(cls): + cls.themes.clear() diff --git a/src/c3nav/mapdata/urls.py b/src/c3nav/mapdata/urls.py index 91df07d2..ecd8ecea 100644 --- a/src/c3nav/mapdata/urls.py +++ b/src/c3nav/mapdata/urls.py @@ -13,10 +13,10 @@ register_converter(HistoryFileExtConverter, 'h_fileext') register_converter(ArchiveFileExtConverter, 'archive_fileext') urlpatterns = [ - path('///.png', tile, name='mapdata.tile'), + path('////.png', tile, name='mapdata.tile'), path('preview/l/.png', preview_location, name='mapdata.preview.location'), path('preview/r//.png', preview_route, name='mapdata.preview.route'), - path('////.png', tile, name='mapdata.tile'), + path('/////.png', tile, name='mapdata.tile'), path('history//.', map_history, name='mapdata.map_history'), path('cache/package.', get_cache_package, name='mapdata.cache_package'), ] diff --git a/src/c3nav/mapdata/utils/cache/package.py b/src/c3nav/mapdata/utils/cache/package.py index 4bdcd96a..ce36c1da 100644 --- a/src/c3nav/mapdata/utils/cache/package.py +++ b/src/c3nav/mapdata/utils/cache/package.py @@ -23,9 +23,12 @@ class CachePackage: def __init__(self, bounds, levels=None): self.bounds = bounds self.levels = {} if levels is None else levels + self.theme_ids = [] - def add_level(self, level_id: int, history: MapHistory, restrictions: AccessRestrictionAffected): - self.levels[level_id] = CachePackageLevel(history, restrictions) + def add_level(self, level_id: int, theme_id, history: MapHistory, restrictions: AccessRestrictionAffected): + self.levels[(level_id, theme_id)] = CachePackageLevel(history, restrictions) + if theme_id not in self.theme_ids: + self.theme_ids.append(theme_id) def save(self, filename=None, compression=None): if filename is None: @@ -50,9 +53,13 @@ class CachePackage: self._add_bytesio(f, 'bounds', BytesIO(struct.pack('