From e646dd9d835e0b41bf46102913b10fcd49e34843 Mon Sep 17 00:00:00 2001 From: Stypox Date: Sat, 2 Aug 2025 01:09:45 +0200 Subject: [PATCH 01/16] Fix start_db.sh again --- start_db.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/start_db.sh b/start_db.sh index 5a480395..a82e2c5a 100755 --- a/start_db.sh +++ b/start_db.sh @@ -32,8 +32,8 @@ elif [[ $# == 1 ]] && [[ $1 == "run" ]]; then elif [[ $# == 1 ]] && [[ $1 == "run_without_output" ]]; then echo "Processing updates and running server without output" pushd src 2>&1 > /dev/null - python manage.py processupdates 2>&1 | (grep -e "^ERROR" -e "^WARNING" -e "^HTTP" || true) - python manage.py runserver 2>&1 | (grep -e "^ERROR" -e "^WARNING" -e "^HTTP" || true) + python manage.py processupdates 2>&1 | (grep -v "^DEBUG " || true) + python manage.py runserver 2>&1 | (grep -v "^DEBUG " || true) popd 2>&1 > /dev/null elif [[ $# > 0 ]] && [[ $1 == "manage" ]]; then pushd src From 2804fd41044bb73646216d9aa318807fd40f2f0e Mon Sep 17 00:00:00 2001 From: Stypox Date: Sat, 2 Aug 2025 01:20:52 +0200 Subject: [PATCH 02/16] Improve start_db.sh again --- start_db.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/start_db.sh b/start_db.sh index a82e2c5a..3535b0f1 100755 --- a/start_db.sh +++ b/start_db.sh @@ -32,8 +32,8 @@ elif [[ $# == 1 ]] && [[ $1 == "run" ]]; then elif [[ $# == 1 ]] && [[ $1 == "run_without_output" ]]; then echo "Processing updates and running server without output" pushd src 2>&1 > /dev/null - python manage.py processupdates 2>&1 | (grep -v "^DEBUG " || true) - python manage.py runserver 2>&1 | (grep -v "^DEBUG " || true) + python manage.py processupdates 2>&1 | (grep -vE '^(INFO|DEBUG)|__debug__' || true) + python manage.py runserver 2>&1 | (grep -vE '^(INFO|DEBUG)|__debug__' || true) popd 2>&1 > /dev/null elif [[ $# > 0 ]] && [[ $1 == "manage" ]]; then pushd src From 2f12b901acac8d4f82df7b8bd7d57df2a80906db Mon Sep 17 00:00:00 2001 From: Degra02 Date: Sat, 2 Aug 2025 07:54:26 +0200 Subject: [PATCH 03/16] test another snap-to-edge and level clone --- src/c3nav/editor/api/endpoints.py | 29 +- src/c3nav/editor/api/schemas.py | 26 +- .../editor/static/editor/css/editor.scss | 3 - src/c3nav/editor/static/editor/js/editor.js | 268 ++++++++++++++++-- src/c3nav/editor/templates/editor/level.html | 36 +++ src/c3nav/editor/utils.py | 243 ++++++++++++++++ src/c3nav/mapdata/apps.py | 1 + .../migrations/0139_add_cloned_item_sync.py | 34 +++ src/c3nav/mapdata/models/__init__.py | 3 +- src/c3nav/mapdata/models/sync.py | 47 +++ src/c3nav/mapdata/signals/sync.py | 95 +++++++ .../0015_alter_nodemessage_message_type.py | 18 ++ 12 files changed, 774 insertions(+), 29 deletions(-) create mode 100644 src/c3nav/mapdata/migrations/0139_add_cloned_item_sync.py create mode 100644 src/c3nav/mapdata/models/sync.py create mode 100644 src/c3nav/mapdata/signals/sync.py create mode 100644 src/c3nav/mesh/migrations/0015_alter_nodemessage_message_type.py diff --git a/src/c3nav/editor/api/endpoints.py b/src/c3nav/editor/api/endpoints.py index 10734aae..dcc1576e 100644 --- a/src/c3nav/editor/api/endpoints.py +++ b/src/c3nav/editor/api/endpoints.py @@ -8,7 +8,7 @@ from c3nav.api.exceptions import API404 from c3nav.editor.api.base import api_etag_with_update_cache_key from c3nav.editor.api.geometries import get_level_geometries_result, get_space_geometries_result from c3nav.editor.api.schemas import EditorGeometriesElemSchema, EditorID, GeometryStylesSchema, UpdateCacheKey, \ - EditorBeaconsLookup + EditorBeaconsLookup, CloneFloorRequestSchema, CloneFloorResponseSchema from c3nav.editor.views.base import editor_etag_func, accesses_mapdata from c3nav.mapdata.api.base import api_etag from c3nav.mapdata.models import Source @@ -145,4 +145,29 @@ def beacons_lookup(request): return EditorBeaconsLookup( wifi_beacons=wifi_beacons, ibeacons=ibeacons, - ).model_dump(mode="json") \ No newline at end of file + ).model_dump(mode="json") + + +@editor_api_router.post('/clone-floor/', summary="clone floor items", + description="clone selected map items from one floor to another", + response={200: CloneFloorResponseSchema, **API404.dict(), + **auth_permission_responses}, + openapi_extra={"security": [{"APIKeyAuth": ["editor_access", "write"]}]}) +def clone_floor(request, data: CloneFloorRequestSchema): + from c3nav.editor.utils import clone_level_items + + try: + result = clone_level_items( + request=request, + source_level_id=data.source_level_id, + target_level_id=data.target_level_id, + items=data.items, + keep_sync=data.keep_sync + ) + return result + except Exception as e: + return CloneFloorResponseSchema( + success=False, + cloned_items=[], + message=f"Error cloning items: {str(e)}" + ).model_dump(mode="json") \ No newline at end of file diff --git a/src/c3nav/editor/api/schemas.py b/src/c3nav/editor/api/schemas.py index e7abacc0..ace9bbcf 100644 --- a/src/c3nav/editor/api/schemas.py +++ b/src/c3nav/editor/api/schemas.py @@ -124,4 +124,28 @@ class EditorBeaconsLookup(BaseSchema): EditorBeacon ] ] - ] \ No newline at end of file + ] + + +class CloneItemSchema(BaseSchema): + item_type: Annotated[str, APIField(title="geometry type (e.g., 'area', 'obstacle', 'space')")] + item_id: EditorID + + +class CloneFloorRequestSchema(BaseSchema): + source_level_id: EditorID + target_level_id: EditorID + items: list[CloneItemSchema] + keep_sync: Annotated[bool, APIField(default=False, title="keep cloned items synchronized across levels")] + + +class ClonedItemResult(BaseSchema): + item_type: str + original_id: EditorID + cloned_id: EditorID + + +class CloneFloorResponseSchema(BaseSchema): + success: bool + cloned_items: list[ClonedItemResult] + message: str \ No newline at end of file diff --git a/src/c3nav/editor/static/editor/css/editor.scss b/src/c3nav/editor/static/editor/css/editor.scss index 858c2569..14c8cc3d 100644 --- a/src/c3nav/editor/static/editor/css/editor.scss +++ b/src/c3nav/editor/static/editor/css/editor.scss @@ -607,7 +607,6 @@ label.theme-color-label { z-index: 1000; pointer-events: none; } -<<<<<<< HEAD @keyframes snap-pulse { 0% { @@ -676,5 +675,3 @@ label.theme-color-label { opacity: 0.7; } } -======= ->>>>>>> 90d3c9b7f5f567b6bbe37f3432b49f65710bf26c diff --git a/src/c3nav/editor/static/editor/js/editor.js b/src/c3nav/editor/static/editor/js/editor.js index c6255c3a..0e3f5a59 100644 --- a/src/c3nav/editor/static/editor/js/editor.js +++ b/src/c3nav/editor/static/editor/js/editor.js @@ -1759,7 +1759,6 @@ editor = { } }); -<<<<<<< HEAD // check current editing shape with infinite extension enabled if (editor._current_editing_shape) { var currentShapeSnap = editor._find_closest_point_on_geometry( @@ -1771,9 +1770,6 @@ editor = { } // find closest candidate -======= - // Find the closest candidate ->>>>>>> 90d3c9b7f5f567b6bbe37f3432b49f65710bf26c if (candidates.length > 0) { candidates.sort(function(a, b) { return a.distance - b.distance; }); var best = candidates[0]; @@ -1904,13 +1900,8 @@ editor = { var p1 = coordinates[i]; var p2 = coordinates[(i + 1) % coordinates.length]; -<<<<<<< HEAD var snapPoint = editor._find_closest_point_on_edge(p1, p2, targetLatLng, targetMapPoint, allowInfiniteExtension); -======= - - var snapPoint = editor._find_closest_point_on_edge(p1, p2, targetLatLng, targetMapPoint); ->>>>>>> 90d3c9b7f5f567b6bbe37f3432b49f65710bf26c if (snapPoint && snapPoint.distance < closestDistance) { closestDistance = snapPoint.distance; closestPoint = snapPoint; @@ -2084,7 +2075,6 @@ editor = { _show_snap_indicator: function(latlng, snapInfo) { editor._clear_snap_indicators(); -<<<<<<< HEAD // snap point indicator var indicator = L.circleMarker(latlng, { @@ -2092,18 +2082,6 @@ editor = { color: '#ff6b6b', fillColor: '#ff6b6b', fillOpacity: 0.8, -======= - - var size = 0.001; // adjust this to control square size - - var bounds = [ - [latlng.lat - size, latlng.lng - size], - [latlng.lat + size, latlng.lng + size] - ]; - - var indicator = L.rectangle(bounds, { - color: '#666', ->>>>>>> 90d3c9b7f5f567b6bbe37f3432b49f65710bf26c weight: 2, lineCap: "square", fillOpacity: 1., @@ -2532,6 +2510,252 @@ OverlayControl = L.Control.extend({ }, }); +// Clone Floor Functionality +editor.cloneFloor = { + selectedItems: [], + isSelectionMode: false, + + init: function() { + // Check if clone floor elements exist in the template + if ($('#clone-floor-btn').length > 0) { + // Bind click event to the button that's already in the template + $('#clone-floor-btn').click(editor.cloneFloor.toggleSelectionMode); + + // Bind events to the selector elements that are already in the template + $('#execute-clone-btn').click(editor.cloneFloor.executeClone); + $('#cancel-clone-btn').click(editor.cloneFloor.cancelSelection); + + console.log('Clone floor functionality initialized'); + } else { + console.log('Clone floor button not found in template'); + } + }, + + toggleSelectionMode: function() { + if (editor.cloneFloor.isSelectionMode) { + editor.cloneFloor.cancelSelection(); + } else { + editor.cloneFloor.startSelection(); + } + }, + + startSelection: function() { + editor.cloneFloor.isSelectionMode = true; + editor.cloneFloor.selectedItems = []; + + console.log('Clone floor: Starting selection mode'); + + $('#clone-floor-btn').html(' Cancel Selection').removeClass('btn-info').addClass('btn-warning'); + $('#clone-floor-selector').show(); + editor.cloneFloor.updateSelectedCount(); + + // Add click handlers to geometry items + if (editor._geometries_layer) { + let layerCount = 0; + editor._geometries_layer.eachLayer(function(layer) { + if (layer.feature && layer.feature.properties) { + layer.on('click', editor.cloneFloor.onItemClick); + layer.setStyle({cursor: 'pointer'}); + layerCount++; + } + }); + console.log('Clone floor: Added click handlers to', layerCount, 'layers'); + } else { + console.log('Clone floor: No geometries layer found'); + } + + // Disable map editing + editor.map.doubleClickZoom.disable(); + }, + + cancelSelection: function() { + editor.cloneFloor.isSelectionMode = false; + editor.cloneFloor.selectedItems = []; + + $('#clone-floor-btn').html(' Clone to Floor').removeClass('btn-warning').addClass('btn-info'); + $('#clone-floor-selector').hide(); + + // Remove click handlers and reset styles + if (editor._geometries_layer) { + editor._geometries_layer.eachLayer(function(layer) { + if (layer.feature && layer.feature.properties) { + layer.off('click', editor.cloneFloor.onItemClick); + layer.setStyle({cursor: 'default'}); + } + }); + } + + // Re-enable map editing + editor.map.doubleClickZoom.enable(); + + // Reset visual selection + editor.cloneFloor.updateVisualSelection(); + }, + + onItemClick: function(e) { + if (!editor.cloneFloor.isSelectionMode) return; + + e.originalEvent.stopPropagation(); + e.originalEvent.preventDefault(); + + const layer = e.target; + const feature = layer.feature; + + console.log('Clone floor: Item clicked', feature); + + if (!feature || !feature.properties) { + console.log('Clone floor: No feature or properties found'); + return; + } + + const itemId = feature.properties.id; + const itemType = feature.properties.type; + + console.log('Clone floor: Item ID:', itemId, 'Type:', itemType); + + // Check if item is already selected + const existingIndex = editor.cloneFloor.selectedItems.findIndex( + item => item.item_id === itemId && item.item_type === itemType + ); + + if (existingIndex >= 0) { + // Deselect item + editor.cloneFloor.selectedItems.splice(existingIndex, 1); + console.log('Clone floor: Item deselected'); + } else { + // Select item + editor.cloneFloor.selectedItems.push({ + item_id: itemId, + item_type: itemType + }); + console.log('Clone floor: Item selected'); + } + + editor.cloneFloor.updateSelectedCount(); + editor.cloneFloor.updateVisualSelection(); + }, + + updateSelectedCount: function() { + $('#selected-count').text(editor.cloneFloor.selectedItems.length); + }, + + updateVisualSelection: function() { + if (!editor._geometries_layer) return; + + // Reset all styles first + editor._geometries_layer.eachLayer(function(layer) { + if (layer.feature && layer.feature.properties) { + layer.setStyle(editor._get_geometry_style(layer.feature)); + } + }); + + // Highlight selected items + editor._geometries_layer.eachLayer(function(layer) { + if (layer.feature && layer.feature.properties) { + const isSelected = editor.cloneFloor.selectedItems.some( + item => item.item_id === layer.feature.properties.id && + item.item_type === layer.feature.properties.type + ); + + if (isSelected) { + layer.setStyle({ + stroke: true, + color: '#ff0000', + weight: 3, + fillOpacity: 0.7 + }); + } + } + }); + }, + + + executeClone: function() { + const targetLevelId = $('#target-level-select').val(); + const keepSync = $('#keep-sync-checkbox').is(':checked'); + + if (!targetLevelId) { + alert('Please select a target level'); + return; + } + + if (editor.cloneFloor.selectedItems.length === 0) { + alert('Please select items to clone'); + return; + } + + // Get current level ID + const currentLevelId = editor._level_control.current_level_id; + + if (currentLevelId === parseInt(targetLevelId)) { + alert('Source and target levels cannot be the same'); + return; + } + + // Show loading state + $('#execute-clone-btn').prop('disabled', true).html(' Cloning...'); + + // Prepare request data + const requestData = { + source_level_id: currentLevelId, + target_level_id: parseInt(targetLevelId), + items: editor.cloneFloor.selectedItems, + keep_sync: keepSync + }; + + // Make API call + console.log('Clone floor: Making API call with data:', requestData); + + // Use the raw fetch API with better error handling + c3nav_api.authenticated().then(function() { + return fetch(c3nav_api.make_url('editor/clone-floor/'), { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'X-API-Key': c3nav_api.key, + 'Accept': 'application/json' + }, + body: JSON.stringify(requestData) + }); + }) + .then(function(response) { + console.log('Clone floor: API response status:', response.status); + + if (!response.ok) { + // Log the actual response text for debugging + return response.text().then(function(text) { + console.error('Clone floor: API error response:', text); + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + }); + } + + return response.json(); + }) + .then(function(data) { + console.log('Clone floor: API response data:', data); + + if (data.success) { + alert(`Successfully cloned ${data.cloned_items.length} items: ${data.message}`); + editor.cloneFloor.cancelSelection(); + } else { + alert(`Clone failed: ${data.message}`); + } + }) + .catch(function(error) { + console.error('Clone floor: Error details:', error); + alert(`Clone failed: ${error.message}`); + }) + .finally(function() { + $('#execute-clone-btn').prop('disabled', false).html(' Clone Items'); + }); + } +}; + if ($('#sidebar').length) { editor.init(); + // Initialize clone floor functionality after editor is ready + setTimeout(function() { + editor.cloneFloor.init(); + }, 1000); } diff --git a/src/c3nav/editor/templates/editor/level.html b/src/c3nav/editor/templates/editor/level.html index fdd08ee3..91e85297 100644 --- a/src/c3nav/editor/templates/editor/level.html +++ b/src/c3nav/editor/templates/editor/level.html @@ -16,6 +16,9 @@ {% trans 'Level' as model_title %} {% blocktrans %}Edit {{ model_title }}{% endblocktrans %} +

{% if level.on_top_of == None %} @@ -25,6 +28,39 @@ {% endif %}

+ + + {% url 'editor.levels.graph' level=level.pk as graph_url %} {% url 'editor.levels.overlays' level=level.pk as overlays_url %} {% include 'editor/fragment_child_models.html' with graph_url=graph_url overlays_url=overlays_url %} diff --git a/src/c3nav/editor/utils.py b/src/c3nav/editor/utils.py index 7c846405..562f103a 100644 --- a/src/c3nav/editor/utils.py +++ b/src/c3nav/editor/utils.py @@ -55,3 +55,246 @@ class SpaceChildEditUtils(DefaultEditUtils): @property def _geometry_url(self): return '/api/v2/editor/geometries/space/'+str(self.space.pk) # todo: resolve correctly + + +def clone_level_items(request, source_level_id, target_level_id, items, keep_sync=False): + """ + Clone selected map items from one level to another. + + Args: + request: Django request object + source_level_id: ID of the source level + target_level_id: ID of the target level + items: List of items to clone (each with item_type and item_id) + keep_sync: Whether to keep cloned items synchronized + + Returns: + Dictionary with success status, cloned items list, and message + """ + from django.apps import apps + from django.contrib.contenttypes.models import ContentType + from c3nav.mapdata.models import Level + from c3nav.mapdata.models.sync import ClonedItemSync + from c3nav.editor.api.schemas import CloneFloorResponseSchema + + # Get the source and target levels + try: + source_level = Level.objects.get(pk=source_level_id) + target_level = Level.objects.get(pk=target_level_id) + except Level.DoesNotExist: + return CloneFloorResponseSchema( + success=False, + cloned_items=[], + message="Source or target level not found" + ).model_dump(mode="json") + + # Check if user has editor permissions (simplified check for API) + if not hasattr(request, 'user') or not request.user.is_authenticated: + return CloneFloorResponseSchema( + success=False, + cloned_items=[], + message="Authentication required" + ).model_dump(mode="json") + + cloned_items = [] + + # Define supported item types and their model mappings + SUPPORTED_TYPES = { + 'area': 'Area', + 'obstacle': 'Obstacle', + 'lineobstacle': 'LineObstacle', + 'stair': 'Stair', + 'ramp': 'Ramp', + 'hole': 'Hole', + 'column': 'Column', + 'poi': 'POI', + 'altitudemarker': 'AltitudeMarker', + 'space': 'Space', + 'building': 'Building', + 'door': 'Door' + } + + try: + print(f"Starting to process {len(items)} items") + + for i, item in enumerate(items): + item_type = item.item_type.lower() + item_id = item.item_id + + print(f"Processing item {i+1}/{len(items)}: {item_type} with ID {item_id}") + + if item_type not in SUPPORTED_TYPES: + print(f"Item type '{item_type}' not supported. Supported types: {list(SUPPORTED_TYPES.keys())}") + continue + + model_name = SUPPORTED_TYPES[item_type] + Model = apps.get_model('mapdata', model_name) + + print(f"Looking for {model_name} with ID {item_id}") + + # Get the original item + try: + original_item = Model.objects.get(pk=item_id) + print(f"Found original item: {original_item}") + except Model.DoesNotExist: + print(f"{model_name} with ID {item_id} not found") + continue + + # Prepare the clone data + clone_data = {} + print(f"Model fields: {[f.name for f in Model._meta.fields]}") + + # Handle different item types differently + if item_type == 'space': + # For spaces, we need level but no space reference + for field in Model._meta.fields: + if field.name in ['id', 'pk']: + continue + + # Skip auto fields and read-only fields + if (hasattr(field, 'auto_created') and field.auto_created) or \ + (hasattr(field, 'editable') and not field.editable): + continue + + try: + field_value = getattr(original_item, field.name) + except (AttributeError, ValueError): + continue + + # Handle level reference + if field.name == 'level': + clone_data[field.name] = target_level + else: + # Copy other fields + if field_value is not None: + clone_data[field.name] = field_value + + print(f"Space clone data: {clone_data}") + else: + # For space-related items (areas, obstacles, etc.) + space_found = False + for field in Model._meta.fields: + if field.name in ['id', 'pk']: + continue + + # Skip auto fields and read-only fields + if (hasattr(field, 'auto_created') and field.auto_created) or \ + (hasattr(field, 'editable') and not field.editable): + continue + + try: + field_value = getattr(original_item, field.name) + except (AttributeError, ValueError): + continue + + # Handle level reference + if field.name == 'level': + clone_data[field.name] = target_level + # Handle space reference - need to find equivalent space on target level + elif field.name == 'space': + if hasattr(original_item, 'space') and original_item.space: + original_space = original_item.space + # Try to find a space with the same slug/title on target level + try: + target_space = target_level.spaces.filter( + title=original_space.title + ).first() + if target_space: + clone_data[field.name] = target_space + space_found = True + print(f"Found target space: {target_space}") + else: + print(f"No equivalent space found for '{original_space.title}' on target level") + except Exception as e: + print(f"Error finding target space: {e}") + else: + # Copy other fields + if field_value is not None: + clone_data[field.name] = field_value + + # Skip space-related items if no equivalent space found + if 'space' in [f.name for f in Model._meta.fields] and not space_found: + print(f"Skipping {item_type} {item_id} because no equivalent space found") + continue + + print(f"Clone data for {item_type} {item_id}: {clone_data}") + + # Create the cloned item + try: + print(f"Attempting to clone {model_name} with data: {clone_data}") + cloned_item = Model(**clone_data) + cloned_item.save() + print(f"Successfully created cloned item with ID: {cloned_item.pk}") + except Exception as create_error: + print(f"Error creating {model_name}: {create_error}") + print(f"Clone data was: {clone_data}") + # Try a different approach - create empty object and set fields one by one + try: + cloned_item = Model() + for field_name, field_value in clone_data.items(): + try: + setattr(cloned_item, field_name, field_value) + except Exception as field_error: + print(f"Could not set {field_name}={field_value}: {field_error}") + cloned_item.save() + print(f"Successfully created item using setattr approach with ID: {cloned_item.pk}") + except Exception as setattr_error: + print(f"Setattr approach also failed: {setattr_error}") + continue # Skip this item + + # Create sync relationship if requested + if keep_sync: + try: + original_ct = ContentType.objects.get_for_model(Model) + cloned_ct = ContentType.objects.get_for_model(Model) + + # Define fields that should be synchronized for each model type + sync_field_map = { + 'Area': ['title', 'access_restriction', 'slow_down_factor'], + 'Obstacle': ['height', 'altitude'], + 'LineObstacle': ['width', 'height', 'altitude'], + 'Stair': [], # Geometry-only, no additional fields to sync + 'Ramp': [], + 'Hole': [], + 'Column': ['access_restriction'], + 'POI': ['title', 'access_restriction'], + 'AltitudeMarker': ['groundaltitude'], + 'Space': ['title', 'access_restriction', 'outside'], + 'Building': ['title'], + 'Door': ['access_restriction'] + } + + sync_fields = sync_field_map.get(model_name, []) + + ClonedItemSync.objects.create( + original_content_type=original_ct, + original_object_id=original_item.pk, + cloned_content_type=cloned_ct, + cloned_object_id=cloned_item.pk, + sync_fields=sync_fields + ) + except Exception as sync_error: + # Don't fail the entire operation if sync setup fails + print(f"Warning: Could not create sync relationship: {sync_error}") + + cloned_items.append({ + 'item_type': item_type, + 'original_id': item_id, + 'cloned_id': cloned_item.pk + }) + + print(f"Successfully added item {i+1} to cloned_items list") + + print(f"Finished processing. Total cloned items: {len(cloned_items)}") + return CloneFloorResponseSchema( + success=True, + cloned_items=cloned_items, + message=f"Successfully cloned {len(cloned_items)} items" + ).model_dump(mode="json") + + except Exception as e: + return CloneFloorResponseSchema( + success=False, + cloned_items=cloned_items, + message=f"Error during cloning: {str(e)}" + ).model_dump(mode="json") diff --git a/src/c3nav/mapdata/apps.py b/src/c3nav/mapdata/apps.py index 5d47afa1..6bb6e524 100644 --- a/src/c3nav/mapdata/apps.py +++ b/src/c3nav/mapdata/apps.py @@ -8,3 +8,4 @@ class MapdataConfig(AppConfig): from c3nav.mapdata.utils.cache.changes import register_signals register_signals() import c3nav.mapdata.metrics # noqa + import c3nav.mapdata.signals.sync # noqa diff --git a/src/c3nav/mapdata/migrations/0139_add_cloned_item_sync.py b/src/c3nav/mapdata/migrations/0139_add_cloned_item_sync.py new file mode 100644 index 00000000..68a07f23 --- /dev/null +++ b/src/c3nav/mapdata/migrations/0139_add_cloned_item_sync.py @@ -0,0 +1,34 @@ +# Generated by Django 5.1.5 on 2025-08-02 00:38 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('mapdata', '0138_rangingbeacon_max_observed_num_clients_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='ClonedItemSync', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('original_object_id', models.PositiveIntegerField()), + ('cloned_object_id', models.PositiveIntegerField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('is_active', models.BooleanField(default=True)), + ('sync_fields', models.JSONField(default=list, help_text='List of field names to keep synchronized')), + ('cloned_content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cloned_synced_items', to='contenttypes.contenttype')), + ('original_content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='original_synced_items', to='contenttypes.contenttype')), + ], + options={ + 'verbose_name': 'Cloned Item Sync', + 'verbose_name_plural': 'Cloned Item Syncs', + 'indexes': [models.Index(fields=['original_content_type', 'original_object_id'], name='mapdata_clo_origina_62f4ee_idx'), models.Index(fields=['cloned_content_type', 'cloned_object_id'], name='mapdata_clo_cloned__027e07_idx')], + 'unique_together': {('original_content_type', 'original_object_id', 'cloned_content_type', 'cloned_object_id')}, + }, + ), + ] diff --git a/src/c3nav/mapdata/models/__init__.py b/src/c3nav/mapdata/models/__init__.py index 277bd505..34c5cf02 100644 --- a/src/c3nav/mapdata/models/__init__.py +++ b/src/c3nav/mapdata/models/__init__.py @@ -8,4 +8,5 @@ from c3nav.mapdata.models.locations import Location, LocationSlug, LocationGroup from c3nav.mapdata.models.source import Source # noqa from c3nav.mapdata.models.graph import GraphNode, WayType, GraphEdge # noqa from c3nav.mapdata.models.theme import Theme # noqa -from c3nav.mapdata.models.overlay import DataOverlay, DataOverlayFeature # noqa \ No newline at end of file +from c3nav.mapdata.models.overlay import DataOverlay, DataOverlayFeature # noqa +from c3nav.mapdata.models.sync import ClonedItemSync # noqa \ No newline at end of file diff --git a/src/c3nav/mapdata/models/sync.py b/src/c3nav/mapdata/models/sync.py new file mode 100644 index 00000000..01966cfa --- /dev/null +++ b/src/c3nav/mapdata/models/sync.py @@ -0,0 +1,47 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class ClonedItemSync(models.Model): + """ + Tracks relationships between cloned items across different levels + to keep them synchronized when one is modified. + """ + # The original item + original_content_type = models.ForeignKey( + 'contenttypes.ContentType', + on_delete=models.CASCADE, + related_name='original_synced_items' + ) + original_object_id = models.PositiveIntegerField() + + # The cloned item + cloned_content_type = models.ForeignKey( + 'contenttypes.ContentType', + on_delete=models.CASCADE, + related_name='cloned_synced_items' + ) + cloned_object_id = models.PositiveIntegerField() + + # Metadata + created_at = models.DateTimeField(auto_now_add=True) + is_active = models.BooleanField(default=True) + + # Fields to sync (JSON field storing field names to keep in sync) + sync_fields = models.JSONField( + default=list, + help_text=_('List of field names to keep synchronized') + ) + + class Meta: + verbose_name = _('Cloned Item Sync') + verbose_name_plural = _('Cloned Item Syncs') + unique_together = ('original_content_type', 'original_object_id', + 'cloned_content_type', 'cloned_object_id') + indexes = [ + models.Index(fields=['original_content_type', 'original_object_id']), + models.Index(fields=['cloned_content_type', 'cloned_object_id']), + ] + + def __str__(self): + return f"Sync: {self.original_content_type.model}#{self.original_object_id} -> {self.cloned_content_type.model}#{self.cloned_object_id}" \ No newline at end of file diff --git a/src/c3nav/mapdata/signals/sync.py b/src/c3nav/mapdata/signals/sync.py new file mode 100644 index 00000000..80fa92c2 --- /dev/null +++ b/src/c3nav/mapdata/signals/sync.py @@ -0,0 +1,95 @@ +from django.db.models.signals import post_save, post_delete +from django.dispatch import receiver +from django.contrib.contenttypes.models import ContentType + +from c3nav.mapdata.models.sync import ClonedItemSync + + +@receiver(post_save) +def sync_cloned_items_on_save(sender, instance, created, **kwargs): + """ + When a model instance is saved, update any cloned items that should be synchronized. + """ + if created: + return # Only sync on updates, not creation + + # Check if ClonedItemSync table exists (avoid errors during migrations) + try: + from django.db import connection + with connection.cursor() as cursor: + cursor.execute("SELECT 1 FROM information_schema.tables WHERE table_name = 'mapdata_cloneditemsync' LIMIT 1") + if not cursor.fetchone(): + return # Table doesn't exist yet, skip sync + except Exception: + return # Any database error, skip sync + + content_type = ContentType.objects.get_for_model(sender) + + # Find all sync relationships where this item is the original + try: + sync_relationships = ClonedItemSync.objects.filter( + original_content_type=content_type, + original_object_id=instance.pk, + is_active=True + ) + except Exception: + return # ClonedItemSync model not available, skip sync + + for sync_rel in sync_relationships: + try: + # Get the cloned item + cloned_model = sync_rel.cloned_content_type.model_class() + cloned_item = cloned_model.objects.get(pk=sync_rel.cloned_object_id) + + # Update synchronized fields + updated = False + for field_name in sync_rel.sync_fields: + if hasattr(instance, field_name) and hasattr(cloned_item, field_name): + original_value = getattr(instance, field_name) + current_value = getattr(cloned_item, field_name) + + if original_value != current_value: + setattr(cloned_item, field_name, original_value) + updated = True + + if updated: + cloned_item.save() + + except Exception as e: + # Log error but don't break the original save operation + print(f"Error syncing cloned item: {e}") + # Optionally deactivate the sync relationship if it's broken + sync_rel.is_active = False + sync_rel.save() + + +@receiver(post_delete) +def cleanup_sync_on_delete(sender, instance, **kwargs): + """ + When a model instance is deleted, clean up any sync relationships. + """ + # Check if ClonedItemSync table exists (avoid errors during migrations) + try: + from django.db import connection + with connection.cursor() as cursor: + cursor.execute("SELECT 1 FROM information_schema.tables WHERE table_name = 'mapdata_cloneditemsync' LIMIT 1") + if not cursor.fetchone(): + return # Table doesn't exist yet, skip cleanup + except Exception: + return # Any database error, skip cleanup + + try: + content_type = ContentType.objects.get_for_model(sender) + + # Clean up sync relationships where this item is either original or cloned + ClonedItemSync.objects.filter( + original_content_type=content_type, + original_object_id=instance.pk + ).delete() + + ClonedItemSync.objects.filter( + cloned_content_type=content_type, + cloned_object_id=instance.pk + ).delete() + except Exception: + pass # ClonedItemSync model not available, skip cleanup \ No newline at end of file diff --git a/src/c3nav/mesh/migrations/0015_alter_nodemessage_message_type.py b/src/c3nav/mesh/migrations/0015_alter_nodemessage_message_type.py new file mode 100644 index 00000000..90726685 --- /dev/null +++ b/src/c3nav/mesh/migrations/0015_alter_nodemessage_message_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-08-02 00:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('mesh', '0014_remove_meshnode_name'), + ] + + operations = [ + migrations.AlterField( + model_name='nodemessage', + name='message_type', + field=models.CharField(choices=[('NOOP', 'noop'), ('ECHO_REQUEST', 'echo request'), ('ECHO_RESPONSE', 'echo response'), ('MESH_SIGNIN', 'mesh signin'), ('MESH_LAYER_ANNOUNCE', 'mesh layer announce'), ('MESH_ADD_DESTINATION', 'mesh add destination'), ('MESH_REMOVE_DESTINATIONS', 'mesh remove destinations'), ('MESH_ROUTE_REQUEST', 'mesh route request'), ('MESH_ROUTE_RESPONSE', 'mesh route response'), ('MESH_ROUTE_TRACE', 'mesh route trace'), ('MESH_ROUTING_FAILED', 'mesh routing failed'), ('MESH_SIGNIN_CONFIRM', 'mesh signin confirm'), ('MESH_RESET', 'mesh reset'), ('CONFIG_DUMP', 'dump config'), ('CONFIG_HARDWARE', 'hardware config'), ('CONFIG_BOARD', 'board config'), ('CONFIG_FIRMWARE', 'firmware config'), ('CONFIG_UPLINK', 'uplink config'), ('CONFIG_POSITION', 'position config'), ('CONFIG_NODE', 'node config'), ('CONFIG_IBEACON', 'ibeacon config'), ('OTA_STATUS', 'ota status'), ('OTA_REQUEST_STATUS', 'ota request status'), ('OTA_START', 'ota start'), ('OTA_URL', 'ota url'), ('OTA_FRAGMENT', 'ota fragment'), ('OTA_REQUEST_FRAGMENTS', 'ota request fragments'), ('OTA_SETTING', 'ota setting'), ('OTA_APPLY', 'ota apply'), ('OTA_ABORT', 'ota abort'), ('LOCATE_REQUEST_RANGE', 'locate request range'), ('LOCATE_RANGE_RESULTS', 'locate range results'), ('LOCATE_RAW_FTM_RESULTS', 'locate raw ftm results'), ('REBOOT', 'reboot'), ('REPORT_ERROR', 'report error')], db_index=True, max_length=24, verbose_name='message type'), + ), + ] From 9215e3913106b8faecdd155fc12e28a2ae813945 Mon Sep 17 00:00:00 2001 From: Dennis Orlando Date: Sat, 2 Aug 2025 07:59:16 +0200 Subject: [PATCH 04/16] PURGE ANIMATIONS SEEEEEEEEE --- .../editor/static/editor/css/editor.scss | 26 ------------------- 1 file changed, 26 deletions(-) diff --git a/src/c3nav/editor/static/editor/css/editor.scss b/src/c3nav/editor/static/editor/css/editor.scss index 14c8cc3d..c622a7fd 100644 --- a/src/c3nav/editor/static/editor/css/editor.scss +++ b/src/c3nav/editor/static/editor/css/editor.scss @@ -627,51 +627,25 @@ label.theme-color-label { .edge-highlight { z-index: 999; pointer-events: none; - animation: edge-fade-in 0.2s ease-in; } .original-edge-highlight { z-index: 1000; pointer-events: none; - animation: edge-fade-in 0.2s ease-in; -} - -@keyframes edge-fade-in { - 0% { - opacity: 0; - } - 100% { - opacity: 0.6; - } } /* Right-angle snap indicators */ .right-angle-reference { z-index: 998; pointer-events: none; - animation: edge-fade-in 0.2s ease-in; } .right-angle-line { z-index: 1001; pointer-events: none; - animation: right-angle-pulse 2s infinite; } .right-angle-square { z-index: 1002; pointer-events: none; - animation: right-angle-pulse 2s infinite; -} - -@keyframes right-angle-pulse { - 0% { - opacity: 0.7; - } - 50% { - opacity: 1; - } - 100% { - opacity: 0.7; - } } From 1aa724e7fc45298813a2e6dea44332c86316c2b8 Mon Sep 17 00:00:00 2001 From: Dennis Orlando Date: Sat, 2 Aug 2025 08:08:39 +0200 Subject: [PATCH 05/16] fix indicator --- src/c3nav/editor/static/editor/js/editor.js | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/c3nav/editor/static/editor/js/editor.js b/src/c3nav/editor/static/editor/js/editor.js index 0e3f5a59..31b8f921 100644 --- a/src/c3nav/editor/static/editor/js/editor.js +++ b/src/c3nav/editor/static/editor/js/editor.js @@ -2076,17 +2076,18 @@ editor = { _show_snap_indicator: function(latlng, snapInfo) { editor._clear_snap_indicators(); - // snap point indicator - var indicator = L.circleMarker(latlng, { - radius: 4, - color: '#ff6b6b', - fillColor: '#ff6b6b', - fillOpacity: 0.8, + var size = 0.001; + var bounds = [ + [latlng.lat - size, latlng.lng - size], + [latlng.lat + size, latlng.lng + size] + ]; + var indicator = L.rectangle(bounds, { + color: '#666', weight: 2, lineCap: "square", fillOpacity: 1., - className: 'snap-indicator' - }); + className: 'snap-indicator' + }); editor._snap_indicator.addLayer(indicator); From 1e6aba1e7f726456996e739cbaffe644aea8df9a Mon Sep 17 00:00:00 2001 From: Degra02 Date: Sat, 2 Aug 2025 08:12:06 +0200 Subject: [PATCH 06/16] level clone wip --- src/c3nav/editor/static/editor/js/editor.js | 68 +++++++++++++++------ 1 file changed, 49 insertions(+), 19 deletions(-) diff --git a/src/c3nav/editor/static/editor/js/editor.js b/src/c3nav/editor/static/editor/js/editor.js index 0e3f5a59..ec3ad12d 100644 --- a/src/c3nav/editor/static/editor/js/editor.js +++ b/src/c3nav/editor/static/editor/js/editor.js @@ -940,6 +940,12 @@ editor = { editor._geometries_layer.addTo(editor.map); editor._highlight_layer.addTo(editor.map); editor._loading_geometry = false; + + // Initialize clone floor functionality now that geometries are loaded + if (editor.cloneFloor && editor.cloneFloor.init) { + editor.cloneFloor.init(); + } + if (editor._bounds_layer === null && editor._geometries_layer.getLayers().length) editor._bounds_layer = editor._geometries_layer; if (editor._next_zoom && editor._bounds_layer !== null) { editor.map.flyToBounds((editor._bounds_layer.getBounds !== undefined) ? editor._bounds_layer.getBounds() : [editor._bounds_layer.getLatLng(), editor._bounds_layer.getLatLng()], { @@ -2516,8 +2522,14 @@ editor.cloneFloor = { isSelectionMode: false, init: function() { + // This will be called after geometries are loaded // Check if clone floor elements exist in the template if ($('#clone-floor-btn').length > 0) { + // Unbind any existing handlers first + $('#clone-floor-btn').off('click'); + $('#execute-clone-btn').off('click'); + $('#cancel-clone-btn').off('click'); + // Bind click event to the button that's already in the template $('#clone-floor-btn').click(editor.cloneFloor.toggleSelectionMode); @@ -2549,17 +2561,24 @@ editor.cloneFloor = { $('#clone-floor-selector').show(); editor.cloneFloor.updateSelectedCount(); - // Add click handlers to geometry items + // Add click handlers directly to geometry layers if (editor._geometries_layer) { let layerCount = 0; editor._geometries_layer.eachLayer(function(layer) { if (layer.feature && layer.feature.properties) { + // Add click handler for selection layer.on('click', editor.cloneFloor.onItemClick); - layer.setStyle({cursor: 'pointer'}); + + // Make layer visually selectable + const currentStyle = layer.options || {}; + layer.setStyle(Object.assign({}, currentStyle, { + cursor: 'pointer', + opacity: Math.max(currentStyle.opacity || 0, 0.5) + })); layerCount++; } }); - console.log('Clone floor: Added click handlers to', layerCount, 'layers'); + console.log('Clone floor: Made', layerCount, 'geometries selectable'); } else { console.log('Clone floor: No geometries layer found'); } @@ -2575,28 +2594,33 @@ editor.cloneFloor = { $('#clone-floor-btn').html(' Clone to Floor').removeClass('btn-warning').addClass('btn-info'); $('#clone-floor-selector').hide(); - // Remove click handlers and reset styles + // Remove click handlers and reset styles for all geometry layers if (editor._geometries_layer) { editor._geometries_layer.eachLayer(function(layer) { if (layer.feature && layer.feature.properties) { + // Remove click handler layer.off('click', editor.cloneFloor.onItemClick); - layer.setStyle({cursor: 'default'}); + + // Reset to original style + layer.setStyle(editor._get_geometry_style(layer.feature)); } }); } // Re-enable map editing editor.map.doubleClickZoom.enable(); - - // Reset visual selection - editor.cloneFloor.updateVisualSelection(); }, onItemClick: function(e) { if (!editor.cloneFloor.isSelectionMode) return; - e.originalEvent.stopPropagation(); - e.originalEvent.preventDefault(); + // Prevent default behavior and stop propagation + if (e.originalEvent) { + e.originalEvent.stopPropagation(); + e.originalEvent.preventDefault(); + } + L.DomEvent.stopPropagation(e); + L.DomEvent.preventDefault(e); const layer = e.target; const feature = layer.feature; @@ -2605,13 +2629,14 @@ editor.cloneFloor = { if (!feature || !feature.properties) { console.log('Clone floor: No feature or properties found'); - return; + return false; } const itemId = feature.properties.id; const itemType = feature.properties.type; console.log('Clone floor: Item ID:', itemId, 'Type:', itemType); + console.log('Clone floor: Full feature properties:', JSON.stringify(feature.properties, null, 2)); // Check if item is already selected const existingIndex = editor.cloneFloor.selectedItems.findIndex( @@ -2633,6 +2658,8 @@ editor.cloneFloor = { editor.cloneFloor.updateSelectedCount(); editor.cloneFloor.updateVisualSelection(); + + return false; // Prevent further event propagation }, updateSelectedCount: function() { @@ -2661,8 +2688,10 @@ editor.cloneFloor = { layer.setStyle({ stroke: true, color: '#ff0000', - weight: 3, - fillOpacity: 0.7 + weight: 4, + opacity: 1, + fillOpacity: 0.7, + fillColor: '#ff0000' }); } } @@ -2703,8 +2732,11 @@ editor.cloneFloor = { keep_sync: keepSync }; - // Make API call + // Debug: Log detailed request data console.log('Clone floor: Making API call with data:', requestData); + console.log('Clone floor: Selected items details:', JSON.stringify(editor.cloneFloor.selectedItems, null, 2)); + console.log('Clone floor: Source level ID:', currentLevelId); + console.log('Clone floor: Target level ID:', parseInt(targetLevelId)); // Use the raw fetch API with better error handling c3nav_api.authenticated().then(function() { @@ -2734,9 +2766,11 @@ editor.cloneFloor = { }) .then(function(data) { console.log('Clone floor: API response data:', data); + console.log('Clone floor: API response type:', typeof data); + console.log('Clone floor: API response keys:', Object.keys(data)); if (data.success) { - alert(`Successfully cloned ${data.cloned_items.length} items: ${data.message}`); + alert(`Successfully cloned ${data.cloned_items?.length || 0} items: ${data.message}`); editor.cloneFloor.cancelSelection(); } else { alert(`Clone failed: ${data.message}`); @@ -2754,8 +2788,4 @@ editor.cloneFloor = { if ($('#sidebar').length) { editor.init(); - // Initialize clone floor functionality after editor is ready - setTimeout(function() { - editor.cloneFloor.init(); - }, 1000); } From d5b9d8e97df4c84b8b836296de306853a8b269b7 Mon Sep 17 00:00:00 2001 From: Dennis Orlando Date: Sat, 2 Aug 2025 08:33:14 +0200 Subject: [PATCH 07/16] add snap-to-original control, fix styling --- .../editor/static/editor/css/editor.scss | 26 +++------------- src/c3nav/editor/static/editor/js/editor.js | 31 +++++++++++++++++++ 2 files changed, 35 insertions(+), 22 deletions(-) diff --git a/src/c3nav/editor/static/editor/css/editor.scss b/src/c3nav/editor/static/editor/css/editor.scss index c622a7fd..37354b36 100644 --- a/src/c3nav/editor/static/editor/css/editor.scss +++ b/src/c3nav/editor/static/editor/css/editor.scss @@ -569,19 +569,15 @@ label.theme-color-label { border-radius: 4px; border: 2px solid rgba(0,0,0,0.2); background-clip: padding-box; - - .snap-toggle { + + /* watchout for leaflet.css trying to override a:hover with a different height/width */ + a.snap-toggle, a.snap-to-original-toggle { display: block; - width: 30px; + width: 30px; height: 30px; - line-height: 26px; - text-align: center; - text-decoration: none; - font-size: 18px; background-color: white; color: #666; border-radius: 2px; - transition: all 0.2s ease; &:hover { background-color: #f4f4f4; @@ -608,20 +604,6 @@ label.theme-color-label { pointer-events: none; } -@keyframes snap-pulse { - 0% { - opacity: 0.8; - transform: scale(1); - } - 50% { - opacity: 1; - transform: scale(1.2); - } - 100% { - opacity: 0.8; - transform: scale(1); - } -} /* Edge highlight styles for snap-to-edges */ .edge-highlight { diff --git a/src/c3nav/editor/static/editor/js/editor.js b/src/c3nav/editor/static/editor/js/editor.js index 31b8f921..32a4e7f4 100644 --- a/src/c3nav/editor/static/editor/js/editor.js +++ b/src/c3nav/editor/static/editor/js/editor.js @@ -1665,6 +1665,7 @@ editor = { // Snap-to-edges functionality _snap_enabled: true, + _snap_to_original_enabled: true, _snap_distance: 30, // pixels _extension_area_multiplier: 4, // Extension area = snap_distance * this multiplier _snap_to_base_map: false, @@ -1681,6 +1682,8 @@ editor = { }, _add_snap_controls: function() { + + // add snap to edge toggle var snapControl = L.control({position: 'topleft'}); snapControl.onAdd = function() { var container = L.DomUtil.create('div', 'leaflet-bar leaflet-control leaflet-control-snap'); @@ -1696,8 +1699,28 @@ editor = { return container; }; snapControl.addTo(editor.map); + + // add snap to "original edited geometry" toggle + var snapToOriginalControl = L.control({position: 'topleft'}); + snapToOriginalControl.onAdd = function() { + var container = L.DomUtil.create('div', 'leaflet-bar leaflet-control leaflet-control-snap'); + container.innerHTML = 'S'; + + L.DomEvent.on(container.querySelector('.snap-to-original-toggle'), 'click', function(e) { + e.preventDefault(); + editor._toggle_snap_to_original(); + }); + + L.DomEvent.disableClickPropagation(container); + return container; + }; + snapToOriginalControl.addTo(editor.map); + + }, + _toggle_snap: function() { editor._snap_enabled = !editor._snap_enabled; var toggle = document.querySelector('.snap-toggle'); @@ -1708,6 +1731,14 @@ editor = { editor._clear_snap_indicators(); }, + _toggle_snap_to_original: function() { + editor._snap_to_original_enabled = !editor._snap_to_original_enabled; + var toggle = document.querySelector('.snap-to-original-toggle'); + if (toggle) { + toggle.classList.toggle('active', editor._snap_to_original_enabled); + } + }, + _handle_snap_during_draw: function(e) { if (!editor._snap_enabled || !editor._creating) return; From 4158885c11a90f66379b9d73d1d94b8c6e7e16d7 Mon Sep 17 00:00:00 2001 From: Dennis Orlando Date: Sat, 2 Aug 2025 09:13:39 +0200 Subject: [PATCH 08/16] custom icons --- src/c3nav/editor/static/editor/css/editor.scss | 13 +++++++++++-- src/c3nav/editor/static/editor/js/editor.js | 8 ++++---- src/c3nav/static/img/snap-to-edges-icon.svg | 3 +++ src/c3nav/static/img/snap-to-original-icon.svg | 3 +++ 4 files changed, 21 insertions(+), 6 deletions(-) create mode 100644 src/c3nav/static/img/snap-to-edges-icon.svg create mode 100644 src/c3nav/static/img/snap-to-original-icon.svg diff --git a/src/c3nav/editor/static/editor/css/editor.scss b/src/c3nav/editor/static/editor/css/editor.scss index 37354b36..8a0bbc3c 100644 --- a/src/c3nav/editor/static/editor/css/editor.scss +++ b/src/c3nav/editor/static/editor/css/editor.scss @@ -572,6 +572,7 @@ label.theme-color-label { /* watchout for leaflet.css trying to override a:hover with a different height/width */ a.snap-toggle, a.snap-to-original-toggle { + background-size: 30px 30px; display: block; width: 30px; height: 30px; @@ -585,14 +586,22 @@ label.theme-color-label { } &.active { - background-color: #4CAF50; + background-color: #45a049; color: white; &:hover { - background-color: #45a049; + background-color: #b0ecb2; } } } + + /* icons */ + a.snap-toggle { + background-image: url("/static/img/snap-to-edges-icon.svg"); + } + a.snap-to-original-toggle { + background-image: url("/static/img/snap-to-original-icon.svg"); + } } /* Snap indicator styles */ diff --git a/src/c3nav/editor/static/editor/js/editor.js b/src/c3nav/editor/static/editor/js/editor.js index 32a4e7f4..396b3915 100644 --- a/src/c3nav/editor/static/editor/js/editor.js +++ b/src/c3nav/editor/static/editor/js/editor.js @@ -1664,8 +1664,8 @@ editor = { }, // Snap-to-edges functionality - _snap_enabled: true, - _snap_to_original_enabled: true, + _snap_enabled: false, + _snap_to_original_enabled: false, _snap_distance: 30, // pixels _extension_area_multiplier: 4, // Extension area = snap_distance * this multiplier _snap_to_base_map: false, @@ -1688,7 +1688,7 @@ editor = { snapControl.onAdd = function() { var container = L.DomUtil.create('div', 'leaflet-bar leaflet-control leaflet-control-snap'); container.innerHTML = ''; + (editor._snap_enabled ? 'active' : '') + '"'; L.DomEvent.on(container.querySelector('.snap-toggle'), 'click', function(e) { e.preventDefault(); @@ -1705,7 +1705,7 @@ editor = { snapToOriginalControl.onAdd = function() { var container = L.DomUtil.create('div', 'leaflet-bar leaflet-control leaflet-control-snap'); container.innerHTML = 'S'; + (editor._snap_to_original_enabled ? 'active' : '') + '">'; L.DomEvent.on(container.querySelector('.snap-to-original-toggle'), 'click', function(e) { e.preventDefault(); diff --git a/src/c3nav/static/img/snap-to-edges-icon.svg b/src/c3nav/static/img/snap-to-edges-icon.svg new file mode 100644 index 00000000..2bc7039c --- /dev/null +++ b/src/c3nav/static/img/snap-to-edges-icon.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/c3nav/static/img/snap-to-original-icon.svg b/src/c3nav/static/img/snap-to-original-icon.svg new file mode 100644 index 00000000..938f3d3a --- /dev/null +++ b/src/c3nav/static/img/snap-to-original-icon.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file From 593d4179e327632f0035e59cf8d02bccdcabe91e Mon Sep 17 00:00:00 2001 From: Dennis Orlando Date: Sat, 2 Aug 2025 09:25:04 +0200 Subject: [PATCH 09/16] snap to original --- src/c3nav/editor/static/editor/js/editor.js | 43 +++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/c3nav/editor/static/editor/js/editor.js b/src/c3nav/editor/static/editor/js/editor.js index a4448da0..3919f296 100644 --- a/src/c3nav/editor/static/editor/js/editor.js +++ b/src/c3nav/editor/static/editor/js/editor.js @@ -163,6 +163,7 @@ editor = { }, _sidebar_unload: function () { // unload the sidebar. called on sidebar_get and form submit. + editor._hide_original_geometry(); editor._level_control.disable(); editor._sublevel_control.disable(); @@ -1687,6 +1688,8 @@ editor = { editor._add_snap_controls(); }, + + _add_snap_controls: function() { // add snap to edge toggle @@ -1743,6 +1746,45 @@ editor = { if (toggle) { toggle.classList.toggle('active', editor._snap_to_original_enabled); } + + // Show/hide original geometry + if (editor._snap_to_original_enabled) { + editor._show_original_geometry(); + } else { + editor._hide_original_geometry(); + } + }, + + _show_original_geometry: function() { + if (!editor._bounds_layer || editor._original_geometry_layer) return; + + // Create a copy of the original geometry with different styling + var originalFeature = editor._bounds_layer.feature; + if (!originalFeature) return; + + editor._original_geometry_layer = L.geoJSON(originalFeature, { + style: function() { + return { + stroke: true, + color: '#888888', + weight: 2, + opacity: 0.7, + fill: false, + dashArray: '5, 5', + className: 'original-geometry' + }; + }, + pointToLayer: editor._point_to_layer + }); + + editor._original_geometry_layer.addTo(editor.map); + }, + + _hide_original_geometry: function() { + if (editor._original_geometry_layer) { + editor.map.removeLayer(editor._original_geometry_layer); + editor._original_geometry_layer = null; + } }, _handle_snap_during_draw: function(e) { @@ -1785,6 +1827,7 @@ editor = { // find snap candidates from existing geometries with area-limited infinite extension editor._geometries_layer.eachLayer(function(layer) { + if (layer === editor._bounds_layer && !editor._snap_to_original_enabled) return; //don't snap to original if not toggled. if (layer === editor._editing_layer) return; // don't snap to self // check if layer is within the area limit for infinite extension From f99fcb89165c6e551f214ce74a60fca8e388cfa3 Mon Sep 17 00:00:00 2001 From: Dennis Orlando Date: Sat, 2 Aug 2025 09:35:30 +0200 Subject: [PATCH 10/16] fix styling --- src/c3nav/editor/static/editor/css/editor.scss | 16 ++++++++-------- src/c3nav/editor/static/editor/js/editor.js | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/c3nav/editor/static/editor/css/editor.scss b/src/c3nav/editor/static/editor/css/editor.scss index 8a0bbc3c..7985a1cd 100644 --- a/src/c3nav/editor/static/editor/css/editor.scss +++ b/src/c3nav/editor/static/editor/css/editor.scss @@ -567,8 +567,8 @@ label.theme-color-label { .leaflet-control-snap { background-color: white; border-radius: 4px; - border: 2px solid rgba(0,0,0,0.2); background-clip: padding-box; + /* watchout for leaflet.css trying to override a:hover with a different height/width */ a.snap-toggle, a.snap-to-original-toggle { @@ -578,19 +578,19 @@ label.theme-color-label { height: 30px; background-color: white; color: #666; - border-radius: 2px; - + border-radius: 4px; + &:hover { - background-color: #f4f4f4; - color: #333; + background-color: #a7a7a7; } - + &.active { - background-color: #45a049; + background-color: #b0ecb2; + border: 2px solid green; color: white; &:hover { - background-color: #b0ecb2; + background-color: #7ac27d; } } } diff --git a/src/c3nav/editor/static/editor/js/editor.js b/src/c3nav/editor/static/editor/js/editor.js index 3919f296..f92fa62c 100644 --- a/src/c3nav/editor/static/editor/js/editor.js +++ b/src/c3nav/editor/static/editor/js/editor.js @@ -1713,7 +1713,7 @@ editor = { var snapToOriginalControl = L.control({position: 'topleft'}); snapToOriginalControl.onAdd = function() { var container = L.DomUtil.create('div', 'leaflet-bar leaflet-control leaflet-control-snap'); - container.innerHTML = ''; L.DomEvent.on(container.querySelector('.snap-to-original-toggle'), 'click', function(e) { From e893e53151b496cafaf84581a1b0952c1e452952 Mon Sep 17 00:00:00 2001 From: Stypox Date: Sat, 2 Aug 2025 09:46:43 +0200 Subject: [PATCH 11/16] Implement create_staircase page --- src/c3nav/editor/static/editor/js/editor.js | 131 +++++++++++++++++- .../templates/editor/create_staircase.html | 40 ++++++ src/c3nav/editor/templates/editor/list.html | 5 + src/c3nav/editor/urls.py | 4 +- src/c3nav/editor/views/edit.py | 11 +- 5 files changed, 186 insertions(+), 5 deletions(-) create mode 100644 src/c3nav/editor/templates/editor/create_staircase.html diff --git a/src/c3nav/editor/static/editor/js/editor.js b/src/c3nav/editor/static/editor/js/editor.js index cd69262e..312ab026 100644 --- a/src/c3nav/editor/static/editor/js/editor.js +++ b/src/c3nav/editor/static/editor/js/editor.js @@ -185,6 +185,8 @@ editor = { // Clear snap indicators when unloading editor._clear_snap_indicators(); + + editor._destroy_staircase_editing(); }, _fill_level_control: function (level_control, level_list, geometryURLs) { var levels = level_list.find('a'); @@ -1365,7 +1367,7 @@ editor = { } else if (mapitem_type) { // creating a new geometry, already drawn but form was rejected options = editor._get_mapitem_type_style(mapitem_type); - if (mapitem_type === 'area') { + if (mapitem_type === 'area' || mapitem_type === 'staircase') { options.fillOpacity = 0.5; } } @@ -1385,7 +1387,7 @@ editor = { } else if (form.is('[data-new]')) { // create new geometry options = editor._get_mapitem_type_style(mapitem_type); - if (mapitem_type === 'area') { + if (mapitem_type === 'area' || mapitem_type === 'staircase') { options.fillOpacity = 0.5; } form.addClass('creation-lock'); @@ -1434,6 +1436,10 @@ editor = { } startGeomEditing(selected_geomtype); } + + if (mapitem_type === 'staircase') { + editor._setup_staircase_editing() + } } }, _cancel_editing: function () { @@ -1886,6 +1892,127 @@ editor = { if (editor._snap_indicator) { editor._snap_indicator.clearLayers(); } + }, + + _setup_staircase_editing: function() { + editor._staircase_steps_count = 10 + editor._staircase_layer = L.layerGroup().addTo(editor.map); + $('#stairway-steps').on('input', function() { + editor._staircase_steps_count = parseInt($(this).val()) || 10; + editor._update_staircase_preview(); + }); + + editor.map.on('editable:editing', editor._update_staircase_preview); + }, + + _destroy_staircase_editing: function() { + if (editor._staircase_layer !== null) { + editor.map.removeLayer(editor._staircase_layer) + editor._staircase_layer = null + } + editor.map.off('editable:editing', editor._update_staircase_preview) + if (editor._current_editing_shape !== null) { + editor._current_editing_shape.editor.cancelDrawing() + editor._current_editing_shape.remove() + editor._current_editing_shape = null + } + }, + + _transform_point_for_staircase: function(p, p0, cos_a, sin_a) { + return { + x: + (p.x - p0.x) * cos_a + (p.y - p0.y) * sin_a + p0.x, + y: - (p.x - p0.x) * sin_a + (p.y - p0.y) * cos_a + p0.y, + } + }, + + _transform_for_staircase: function(xs, ys, num_stairs) { + let base_length = Math.sqrt((xs[1]-xs[0])**2 + (ys[1]-ys[0])**2) + let cos_a = (xs[1] - xs[0]) / base_length + let sin_a = (ys[1] - ys[0]) / base_length + let p0 = { x: xs[0], y: ys[0] } + + xs = points.map(p => editor._transform_point_for_staircase(p, p0, cos_a, sin_a).x) + ys = points.map(p => editor._transform_point_for_staircase(p, p0, cos_a, sin_a).y) + n = xs.length + + if (Math.abs(Math.max(...ys) - ys[0]) > Math.abs(Math.min(...ys) - ys[0])) { + height = Math.max(...ys) - ys[0] + } else { + height = Math.min(...ys) - ys[0] + } + //console.log(xs, ys, base_length, height) + + lines = [{p1: { x: xs[0], y: ys[0] }, p2: { x: xs[1], y: ys[1] }}] + for (i = 1; i < num_stairs; ++i) { + // intersect line y=y0+height/num_stairs*i with all transformed (xs,ys) + y = ys[0] + height/num_stairs*i + inters_xs = [] + for (j = 0; j < n; ++j) { + y1 = ys[j] + y2 = ys[(j+1)%n] + x1 = xs[j] + x2 = xs[(j+1)%n] + if ((y1 > y && y2 > y) || (y1 < y && y2 < y)) { + //console.log("disconnected", y, j, x1,y1, x2,y2, xs, ys) + continue + } + + if (Math.abs(x2 - x1) < 0.0001) { + // vertical line, m would be infinity + inters_xs.push(x1) + continue + } + + m = (y2 - y1) / (x2 - x1) + q = y2 - m * x2 + //console.log("connected", y, j, x1,y1, x2,y2, m, q, xs, ys) + inters_xs.push((y - q) / m) + } + + //console.log("inters_xs", inters_xs) + if (inters_xs.length < 2) { + continue + } + + min_xs = Math.min(...inters_xs) + max_xs = Math.max(...inters_xs) + lines.push({p1: {x: min_xs-2, y: y}, p2: {x: max_xs+2, y: y}}) + } + + //console.log("untransformed lines", lines) + lines = lines.map(l => ({ + p1: editor._transform_point_for_staircase(l.p1, p0, cos_a, -sin_a), + p2: editor._transform_point_for_staircase(l.p2, p0, cos_a, -sin_a), + })) + + //console.log(lines) + return lines + }, + + _update_staircase_preview: function(e = null) { + if (editor._current_editing_shape === null) { + return + } + points = editor._current_editing_shape._parts[0] || [] + editor._staircase_layer.clearLayers() + //console.log(points) + if (points.length < 3) { + return + } + + xs = points.map(p => p.x) + ys = points.map(p => p.y) + lines = editor._transform_for_staircase(xs, ys, editor._staircase_steps_count) + + lines.forEach(l => { + L.polyline( + [ + editor.map.layerPointToLatLng([l.p1.x, l.p1.y]), + editor.map.layerPointToLatLng([l.p2.x, l.p2.y]), + ], + {color: "red"} + ).addTo(editor._staircase_layer); + }) } }; diff --git a/src/c3nav/editor/templates/editor/create_staircase.html b/src/c3nav/editor/templates/editor/create_staircase.html new file mode 100644 index 00000000..b17e0754 --- /dev/null +++ b/src/c3nav/editor/templates/editor/create_staircase.html @@ -0,0 +1,40 @@ +{% load bootstrap3 %} +{% load i18n %} + +{% include 'editor/fragment_levels.html' %} + +

+ {% blocktrans %}Add staircase{% endblocktrans %} +

+{% bootstrap_messages %} + +
+ {% csrf_token %} + {% bootstrap_form form %} +
+ + +
+ {% buttons %} + + + {% if can_edit %} + {% if not nosave %} + + {% endif %} + {% endif %} + + {% if can_edit %} + {% trans 'Cancel' %} + {% else %} + {% trans 'Back' %} + {% endif %} + + {% endbuttons %} +
diff --git a/src/c3nav/editor/templates/editor/list.html b/src/c3nav/editor/templates/editor/list.html index fc923038..1bd75d0a 100644 --- a/src/c3nav/editor/templates/editor/list.html +++ b/src/c3nav/editor/templates/editor/list.html @@ -20,6 +20,11 @@ {% blocktrans %}New {{ model_title }}{% endblocktrans %} + {% if model_title == "Stair" %} + + {% blocktrans %}New staircase{% endblocktrans %} + + {% endif %} {% endif %} {% if explicit_edit %} diff --git a/src/c3nav/editor/urls.py b/src/c3nav/editor/urls.py index fe4f50ef..4c987ea1 100644 --- a/src/c3nav/editor/urls.py +++ b/src/c3nav/editor/urls.py @@ -4,7 +4,7 @@ from django.views.generic import TemplateView from c3nav.editor.views.account import change_password_view, login_view, logout_view, register_view from c3nav.editor.views.changes import changeset_detail, changeset_edit, changeset_redirect -from c3nav.editor.views.edit import edit, graph_edit, level_detail, list_objects, main_index, sourceimage, space_detail +from c3nav.editor.views.edit import edit, graph_edit, level_detail, list_objects, main_index, staircase_edit, sourceimage, space_detail from c3nav.editor.views.overlays import overlays_list, overlay_features, overlay_feature_edit from c3nav.editor.views.quest import QuestFormView from c3nav.editor.views.users import user_detail, user_redirect @@ -33,7 +33,6 @@ def add_editor_urls(model_name, parent_model_name=None, with_list=True, explicit ]) return result - # todo: custom path converters urlpatterns = [ path('levels//', level_detail, name='editor.levels.detail'), @@ -91,3 +90,4 @@ urlpatterns.extend(add_editor_urls('LeaveDescription', 'Space')) urlpatterns.extend(add_editor_urls('CrossDescription', 'Space')) urlpatterns.extend(add_editor_urls('BeaconMeasurement', 'Space')) urlpatterns.extend(add_editor_urls('RangingBeacon', 'Space')) +urlpatterns.append(path('spaces//staircase', edit, name='editor.stairs.staircase', kwargs={'model': apps.get_model('mapdata', 'Stair')})) diff --git a/src/c3nav/editor/views/edit.py b/src/c3nav/editor/views/edit.py index fc3060c0..28442e52 100644 --- a/src/c3nav/editor/views/edit.py +++ b/src/c3nav/editor/views/edit.py @@ -70,6 +70,12 @@ def main_index(request): }) +@etag(editor_etag_func) +@accesses_mapdata +@sidebar_view +def staircase_edit(request, space): + return render(request, "editor/create_staircase.html") + @etag(editor_etag_func) @accesses_mapdata @sidebar_view @@ -405,7 +411,10 @@ def edit(request, pk=None, model=None, level=None, space=None, on_top_of=None, e "access_restriction_select": True, }) - return render(request, 'editor/edit.html', ctx) + if request.path.endswith("staircase"): + return render(request, 'editor/create_staircase.html', ctx) + else: + return render(request, 'editor/edit.html', ctx) def get_visible_spaces(request): From 2e681dffb4d47f0b9e75128ed472bfb00d680d46 Mon Sep 17 00:00:00 2001 From: Dennis Orlando Date: Sat, 2 Aug 2025 10:58:33 +0200 Subject: [PATCH 12/16] =?UTF-8?q?toggle=20to=2090=C2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../editor/static/editor/css/editor.scss | 9 +- src/c3nav/editor/static/editor/js/editor.js | 339 +++++++++--------- 2 files changed, 175 insertions(+), 173 deletions(-) diff --git a/src/c3nav/editor/static/editor/css/editor.scss b/src/c3nav/editor/static/editor/css/editor.scss index 7985a1cd..93705fff 100644 --- a/src/c3nav/editor/static/editor/css/editor.scss +++ b/src/c3nav/editor/static/editor/css/editor.scss @@ -571,7 +571,7 @@ label.theme-color-label { /* watchout for leaflet.css trying to override a:hover with a different height/width */ - a.snap-toggle, a.snap-to-original-toggle { + a.snap-toggle, a.snap-to-original-toggle, a.snap-to-90-toggle { background-size: 30px 30px; display: block; width: 30px; @@ -595,6 +595,10 @@ label.theme-color-label { } } + a.snap-to-90-toggle { + background-color: yellow !important; + } + /* icons */ a.snap-toggle { background-image: url("/static/img/snap-to-edges-icon.svg"); @@ -602,6 +606,9 @@ label.theme-color-label { a.snap-to-original-toggle { background-image: url("/static/img/snap-to-original-icon.svg"); } + a.snap-to-90-toggle { + background-image: url("/static/img/snap-to-90-icon.svg"); + } } /* Snap indicator styles */ diff --git a/src/c3nav/editor/static/editor/js/editor.js b/src/c3nav/editor/static/editor/js/editor.js index f92fa62c..df52ecf4 100644 --- a/src/c3nav/editor/static/editor/js/editor.js +++ b/src/c3nav/editor/static/editor/js/editor.js @@ -1673,6 +1673,7 @@ editor = { // Snap-to-edges functionality _snap_enabled: false, _snap_to_original_enabled: false, + _snap_to_90_enable: false, _snap_distance: 30, // pixels _extension_area_multiplier: 4, // Extension area = snap_distance * this multiplier _snap_to_base_map: false, @@ -1687,8 +1688,7 @@ editor = { editor._add_snap_controls(); }, - - + _add_snap_controls: function() { @@ -1726,7 +1726,22 @@ editor = { }; snapToOriginalControl.addTo(editor.map); + // add snap to 90° toggle + var snapTo90Control = L.control({position: 'topleft'}); + snapTo90Control.onAdd = function() { + var container = L.DomUtil.create('div', 'leaflet-bar leaflet-control leaflet-control-snap'); + container.innerHTML = ''; + L.DomEvent.on(container.querySelector('.snap-to-90-toggle'), 'click', function(e) { + e.preventDefault(); + editor._toggle_snap_to_90(); + }); + + L.DomEvent.disableClickPropagation(container); + return container; + }; + snapTo90Control.addTo(editor.map); }, @@ -1755,6 +1770,16 @@ editor = { } }, + _toggle_snap_to_90: function() { + editor._snap_to_90_enabled = !editor._snap_to_90_enabled; + var toggle = document.querySelector('.snap-to-90-toggle'); + if (toggle) { + toggle.classList.toggle('active', editor._snap_to_90_enabled); + } + + editor._clear_snap_indicators(); + }, + _show_original_geometry: function() { if (!editor._bounds_layer || editor._original_geometry_layer) return; @@ -1819,10 +1844,12 @@ editor = { var mapPoint = editor.map.latLngToContainerPoint(latlng); var candidates = []; - // check for right-angle snap to current shape vertices - var rightAngleSnap = editor._find_right_angle_snap(latlng, mapPoint); - if (rightAngleSnap) { - candidates.push(rightAngleSnap); + // ADD THIS: check for 90° axis snap + if (editor._snap_to_90_enabled) { + var ninetyDegreeSnap = editor._find_90_degree_snap(latlng, mapPoint); + if (ninetyDegreeSnap) { + candidates.push(ninetyDegreeSnap); + } } // find snap candidates from existing geometries with area-limited infinite extension @@ -1943,6 +1970,89 @@ editor = { return distance <= radius; }, + _find_90_degree_snap: function(targetLatLng, targetMapPoint) { + if (!editor._geometries_layer) return null; + + var bestSnap = null; + var closestDistance = Infinity; + + // Check all geometry vertices for 90° alignment + editor._geometries_layer.eachLayer(function(layer) { + if (layer === editor._editing_layer) return; // don't snap to self + if (!layer.getLatLngs) return; + + try { + var coordinates = []; + if (layer instanceof L.Polygon || layer instanceof L.Polyline) { + coordinates = layer.getLatLngs(); + if (coordinates[0] && Array.isArray(coordinates[0])) { + coordinates = coordinates[0]; + } + } else if (layer instanceof L.Circle || layer instanceof L.CircleMarker) { + coordinates = [layer.getLatLng()]; + } + + // Check each vertex for 90° alignment + for (var i = 0; i < coordinates.length; i++) { + var vertex = coordinates[i]; + var vertexMapPoint = editor.map.latLngToContainerPoint(vertex); + + // Calculate horizontal and vertical snap points + var horizontalSnap = { + x: targetMapPoint.x, + y: vertexMapPoint.y + }; + var verticalSnap = { + x: vertexMapPoint.x, + y: targetMapPoint.y + }; + + // Check horizontal alignment + var horizontalDistance = Math.abs(targetMapPoint.y - vertexMapPoint.y); + if (horizontalDistance < editor._snap_distance) { + var horizontalLatLng = editor.map.containerPointToLatLng(horizontalSnap); + var totalDistance = targetMapPoint.distanceTo(horizontalSnap); + + if (totalDistance < closestDistance && totalDistance < editor._snap_distance) { + closestDistance = totalDistance; + bestSnap = { + latlng: horizontalLatLng, + distance: totalDistance, + snapType: 'horizontal', + referenceVertex: vertex, + isRightAngle: false, + is90Degree: true + }; + } + } + + // Check vertical alignment + var verticalDistance = Math.abs(targetMapPoint.x - vertexMapPoint.x); + if (verticalDistance < editor._snap_distance) { + var verticalLatLng = editor.map.containerPointToLatLng(verticalSnap); + var totalDistance = targetMapPoint.distanceTo(verticalSnap); + + if (totalDistance < closestDistance && totalDistance < editor._snap_distance) { + closestDistance = totalDistance; + bestSnap = { + latlng: verticalLatLng, + distance: totalDistance, + snapType: 'vertical', + referenceVertex: vertex, + isRightAngle: false, + is90Degree: true + }; + } + } + } + } catch (error) { + // Skip problematic layers + } + }); + + return bestSnap; + }, + _find_closest_point_on_geometry: function(layer, targetLatLng, targetMapPoint, allowInfiniteExtension) { if (!layer.getLatLngs) return null; @@ -2052,107 +2162,7 @@ editor = { t: originalT }; }, - - _find_right_angle_snap: function(targetLatLng, targetMapPoint) { - if (!editor._current_editing_shape) return null; - - try { - var coordinates = []; - - if (editor._current_editing_shape.getLatLngs) { - coordinates = editor._current_editing_shape.getLatLngs(); - if (coordinates[0] && Array.isArray(coordinates[0])) { - coordinates = coordinates[0]; // Handle polygon with holes - } - } else { - return null; - } - - if (coordinates.length < 2) return null; - - var bestRightAngleSnap = null; - var closestDistance = Infinity; - - // Check each vertex for potential right-angle formation - for (var i = 0; i < coordinates.length; i++) { - var vertex = coordinates[i]; - var vertexMap = editor.map.latLngToContainerPoint(vertex); - - var distanceToVertex = vertexMap.distanceTo(targetMapPoint); - if (distanceToVertex > editor._snap_distance * 2) continue; // Larger radius for right-angle detection - - var prevVertex = coordinates[(i - 1 + coordinates.length) % coordinates.length]; - var nextVertex = coordinates[(i + 1) % coordinates.length]; - - var rightAngleSnap1 = editor._calculate_right_angle_snap(vertex, prevVertex, targetLatLng, targetMapPoint); - if (rightAngleSnap1 && rightAngleSnap1.distance < closestDistance) { - closestDistance = rightAngleSnap1.distance; - bestRightAngleSnap = rightAngleSnap1; - } - - var rightAngleSnap2 = editor._calculate_right_angle_snap(vertex, nextVertex, targetLatLng, targetMapPoint); - if (rightAngleSnap2 && rightAngleSnap2.distance < closestDistance) { - closestDistance = rightAngleSnap2.distance; - bestRightAngleSnap = rightAngleSnap2; - } - } - - return bestRightAngleSnap; - - } catch (error) { - return null; - } - }, - - _calculate_right_angle_snap: function(vertex, adjacentVertex, targetLatLng, targetMapPoint) { - var vertexMap = editor.map.latLngToContainerPoint(vertex); - var adjacentMap = editor.map.latLngToContainerPoint(adjacentVertex); - - var edgeDx = adjacentMap.x - vertexMap.x; - var edgeDy = adjacentMap.y - vertexMap.y; - var edgeLength = Math.sqrt(edgeDx * edgeDx + edgeDy * edgeDy); - - if (edgeLength === 0) return null; - - var perpDx = -edgeDy / edgeLength; - var perpDy = edgeDx / edgeLength; - - var targetDx = targetMapPoint.x - vertexMap.x; - var targetDy = targetMapPoint.y - vertexMap.y; - var targetLength = Math.sqrt(targetDx * targetDx + targetDy * targetDy); - - if (targetLength === 0) return null; - - var projectionLength = targetDx * perpDx + targetDy * perpDy; - - var rightAngleMapPoint = { - x: vertexMap.x + projectionLength * perpDx, - y: vertexMap.y + projectionLength * perpDy - }; - - var distance = Math.sqrt( - Math.pow(rightAngleMapPoint.x - targetMapPoint.x, 2) + - Math.pow(rightAngleMapPoint.y - targetMapPoint.y, 2) - ); - - if (distance < editor._snap_distance && Math.abs(projectionLength) > 10) { // minimum 10 pixels away from vertex - var rightAngleLatLng = editor.map.containerPointToLatLng(rightAngleMapPoint); - - return { - latlng: rightAngleLatLng, - distance: distance, - edgeStart: vertex, - edgeEnd: rightAngleLatLng, - isInfiniteExtension: false, - isRightAngle: true, - rightAngleVertex: vertex, - adjacentVertex: adjacentVertex - }; - } - - return null; - }, - + _show_snap_indicator: function(latlng, snapInfo) { editor._clear_snap_indicators(); @@ -2171,20 +2181,62 @@ editor = { editor._snap_indicator.addLayer(indicator); - if (snapInfo && snapInfo.edgeStart && snapInfo.edgeEnd) { + if (snapInfo && snapInfo.is90Degree) { + editor._show_90_degree_highlight(snapInfo); + } else if (snapInfo && snapInfo.edgeStart && snapInfo.edgeEnd) { editor._show_edge_highlight(snapInfo); } }, + _show_90_degree_highlight: function(snapInfo) { + var referenceVertex = snapInfo.referenceVertex; + var snapPoint = snapInfo.latlng; + + // Draw line from reference vertex to snap point + var guideLine = L.polyline([referenceVertex, snapPoint], { + color: '#00aaff', + weight: 2, + opacity: 0.8, + dashArray: '4, 4', + className: '90-degree-guide' + }); + editor._snap_indicator.addLayer(guideLine); + + // Highlight the reference vertex + var vertexHighlight = L.circle(referenceVertex, { + radius: 0.05, + color: '#00aaff', + weight: 2, + opacity: 0.8, + fillOpacity: 0.3, + className: '90-degree-vertex' + }); + editor._snap_indicator.addLayer(vertexHighlight); + + // Add axis indicator + var referenceMap = editor.map.latLngToContainerPoint(referenceVertex); + var snapMap = editor.map.latLngToContainerPoint(snapPoint); + + var axisText = snapInfo.snapType === 'horizontal' ? '─' : '│'; + var midPoint = editor.map.containerPointToLatLng({ + x: (referenceMap.x + snapMap.x) / 2, + y: (referenceMap.y + snapMap.y) / 2 + }); + + // Create a small text indicator (you might need to style this with CSS) + var textMarker = L.marker(midPoint, { + icon: L.divIcon({ + html: '
' + axisText + '
', + className: '90-degree-axis-indicator', + iconSize: [20, 20], + iconAnchor: [10, 10] + }) + }); + editor._snap_indicator.addLayer(textMarker); + }, + _show_edge_highlight: function(snapInfo) { if (!snapInfo.edgeStart || !snapInfo.edgeEnd) return; - - // handle right-angle visualization - if (snapInfo.isRightAngle) { - editor._show_right_angle_highlight(snapInfo); - return; - } - var startPoint = snapInfo.edgeStart; var endPoint = snapInfo.edgeEnd; var extendedStart, extendedEnd; @@ -2245,63 +2297,6 @@ editor = { editor._snap_indicator.addLayer(originalEdge); } }, - - _show_right_angle_highlight: function(snapInfo) { - var vertex = snapInfo.rightAngleVertex; - var adjacentVertex = snapInfo.adjacentVertex; - var rightAnglePoint = snapInfo.latlng; - - var referenceEdge = L.polyline([vertex, adjacentVertex], { - color: '#4488ff', - weight: 2, - opacity: 0.6, - className: 'right-angle-reference' - }); - editor._snap_indicator.addLayer(referenceEdge); - - var rightAngleLine = L.polyline([vertex, rightAnglePoint], { - color: '#ff4488', - weight: 3, - opacity: 0.8, - dashArray: '6, 3', - className: 'right-angle-line' - }); - editor._snap_indicator.addLayer(rightAngleLine); - - var vertexMap = editor.map.latLngToContainerPoint(vertex); - var adjacentMap = editor.map.latLngToContainerPoint(adjacentVertex); - var rightAngleMap = editor.map.latLngToContainerPoint(rightAnglePoint); - - var size = 15; // Square size in pixels - var dx1 = adjacentMap.x - vertexMap.x; - var dy1 = adjacentMap.y - vertexMap.y; - var len1 = Math.sqrt(dx1 * dx1 + dy1 * dy1); - - var dx2 = rightAngleMap.x - vertexMap.x; - var dy2 = rightAngleMap.y - vertexMap.y; - var len2 = Math.sqrt(dx2 * dx2 + dy2 * dy2); - - if (len1 > 0 && len2 > 0) { - dx1 = (dx1 / len1) * size; - dy1 = (dy1 / len1) * size; - dx2 = (dx2 / len2) * size; - dy2 = (dy2 / len2) * size; - - var corner1 = editor.map.containerPointToLatLng({x: vertexMap.x + dx1, y: vertexMap.y + dy1}); - var corner2 = editor.map.containerPointToLatLng({x: vertexMap.x + dx1 + dx2, y: vertexMap.y + dy1 + dy2}); - var corner3 = editor.map.containerPointToLatLng({x: vertexMap.x + dx2, y: vertexMap.y + dy2}); - - var rightAngleSquare = L.polyline([vertex, corner1, corner2, corner3, vertex], { - color: '#ff4488', - weight: 2, - opacity: 0.7, - fill: false, - className: 'right-angle-square' - }); - editor._snap_indicator.addLayer(rightAngleSquare); - } - }, - _clear_snap_indicators: function() { if (editor._snap_indicator) { editor._snap_indicator.clearLayers(); From e1fe06a1b114f36fe9f9fc747a5ef032b5152811 Mon Sep 17 00:00:00 2001 From: Stypox Date: Sat, 2 Aug 2025 11:00:11 +0200 Subject: [PATCH 13/16] Make requests to create stairs --- src/c3nav/editor/static/editor/js/editor.js | 64 ++++++++++++++----- .../templates/editor/create_staircase.html | 2 +- src/c3nav/editor/views/edit.py | 1 + 3 files changed, 51 insertions(+), 16 deletions(-) diff --git a/src/c3nav/editor/static/editor/js/editor.js b/src/c3nav/editor/static/editor/js/editor.js index 312ab026..b5a2d93d 100644 --- a/src/c3nav/editor/static/editor/js/editor.js +++ b/src/c3nav/editor/static/editor/js/editor.js @@ -501,6 +501,10 @@ editor = { // listener for form submits in the sidebar. e.preventDefault(); if (editor._loading_geometry || $(this).is('.creation-lock') || $(this).is('.scan-lock')) return; + if (editor._staircase_layer) { + editor._staircase_submit($(this)) + return + } var data = $(this).serialize(); var btn = $(this).data('btn'); if (btn !== undefined && btn !== null) { @@ -1906,12 +1910,12 @@ editor = { }, _destroy_staircase_editing: function() { - if (editor._staircase_layer !== null) { + if (editor._staircase_layer) { editor.map.removeLayer(editor._staircase_layer) editor._staircase_layer = null } editor.map.off('editable:editing', editor._update_staircase_preview) - if (editor._current_editing_shape !== null) { + if (editor._current_editing_shape && editor._current_editing_shape.editor) { editor._current_editing_shape.editor.cancelDrawing() editor._current_editing_shape.remove() editor._current_editing_shape = null @@ -1989,29 +1993,59 @@ editor = { return lines }, - _update_staircase_preview: function(e = null) { - if (editor._current_editing_shape === null) { - return + _get_staircase_lines: function() { + if (!editor._current_editing_shape || !editor._current_editing_shape._parts) { + return [] } points = editor._current_editing_shape._parts[0] || [] - editor._staircase_layer.clearLayers() - //console.log(points) if (points.length < 3) { - return + return [] } xs = points.map(p => p.x) ys = points.map(p => p.y) lines = editor._transform_for_staircase(xs, ys, editor._staircase_steps_count) + lines = lines.map(l => [ + editor.map.layerPointToLatLng([l.p1.x, l.p1.y]), + editor.map.layerPointToLatLng([l.p2.x, l.p2.y]), + ]) + return lines + }, + _update_staircase_preview: function(e = null) { + if (editor._staircase_layer) { + editor._staircase_layer.clearLayers() + } + lines = editor._get_staircase_lines() lines.forEach(l => { - L.polyline( - [ - editor.map.layerPointToLatLng([l.p1.x, l.p1.y]), - editor.map.layerPointToLatLng([l.p2.x, l.p2.y]), - ], - {color: "red"} - ).addTo(editor._staircase_layer); + L.polyline(l, {color: "red"}).addTo(editor._staircase_layer); + }) + }, + + _staircase_submit: function(form) { + csrfmiddlewaretoken = form.find('input[name=csrfmiddlewaretoken]').attr('value') + import_tag = form.find('input[name=import_tag]').val() + space = form.attr('space') + lines = editor._get_staircase_lines() + + console.log("hereeeeeee", csrfmiddlewaretoken, import_tag, space, lines) + Promise.all(lines.map(l => + fetch("/editor/spaces/" + space + "/stairs/create", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", + "X-Requested-With": "XMLHttpRequest", + }, + body: "csrfmiddlewaretoken=" + encodeURIComponent(csrfmiddlewaretoken) + + "&geometry=" + encodeURIComponent(JSON.stringify({ + type: "LineString", + coordinates: [[l[0]["lng"], l[0]["lat"]], [l[1]["lng"], l[1]["lat"]]] + })) + + "&import_tag=" + encodeURIComponent(import_tag) + }) + )).then(() => { + form.remove() + window.location.href = "/editor/spaces/" + space + "/stairs" }) } }; diff --git a/src/c3nav/editor/templates/editor/create_staircase.html b/src/c3nav/editor/templates/editor/create_staircase.html index b17e0754..0c3937ea 100644 --- a/src/c3nav/editor/templates/editor/create_staircase.html +++ b/src/c3nav/editor/templates/editor/create_staircase.html @@ -8,7 +8,7 @@ {% bootstrap_messages %} -
+ {% csrf_token %} {% bootstrap_form form %}
diff --git a/src/c3nav/editor/views/edit.py b/src/c3nav/editor/views/edit.py index 28442e52..3b7aa8c7 100644 --- a/src/c3nav/editor/views/edit.py +++ b/src/c3nav/editor/views/edit.py @@ -412,6 +412,7 @@ def edit(request, pk=None, model=None, level=None, space=None, on_top_of=None, e }) if request.path.endswith("staircase"): + ctx["space"] = space_id return render(request, 'editor/create_staircase.html', ctx) else: return render(request, 'editor/edit.html', ctx) From c4cfb4a4f5424aec55f492b9c10f699077d13939 Mon Sep 17 00:00:00 2001 From: Degra02 Date: Sat, 2 Aug 2025 11:00:19 +0200 Subject: [PATCH 14/16] functional clone feature --- src/c3nav/editor/static/editor/js/editor.js | 136 +++++++++++++++---- src/c3nav/editor/templates/editor/level.html | 8 +- src/c3nav/editor/utils.py | 34 ++++- 3 files changed, 145 insertions(+), 33 deletions(-) diff --git a/src/c3nav/editor/static/editor/js/editor.js b/src/c3nav/editor/static/editor/js/editor.js index a4448da0..bb70f72e 100644 --- a/src/c3nav/editor/static/editor/js/editor.js +++ b/src/c3nav/editor/static/editor/js/editor.js @@ -2561,6 +2561,8 @@ editor.cloneFloor = { $('#clone-floor-btn').off('click'); $('#execute-clone-btn').off('click'); $('#cancel-clone-btn').off('click'); + $('#select-all-btn').off('click'); + $('#clear-selection-btn').off('click'); // Bind click event to the button that's already in the template $('#clone-floor-btn').click(editor.cloneFloor.toggleSelectionMode); @@ -2568,6 +2570,8 @@ editor.cloneFloor = { // Bind events to the selector elements that are already in the template $('#execute-clone-btn').click(editor.cloneFloor.executeClone); $('#cancel-clone-btn').click(editor.cloneFloor.cancelSelection); + $('#select-all-btn').click(editor.cloneFloor.selectAllItems); + $('#clear-selection-btn').click(editor.cloneFloor.clearSelection); console.log('Clone floor functionality initialized'); } else { @@ -2593,24 +2597,34 @@ editor.cloneFloor = { $('#clone-floor-selector').show(); editor.cloneFloor.updateSelectedCount(); + // Define supported item types that can be cloned + var supportedTypes = ['area', 'obstacle', 'lineobstacle', 'stair', 'ramp', 'hole', 'column', 'poi', 'altitudemarker', 'space', 'building', 'door']; + // Add click handlers directly to geometry layers if (editor._geometries_layer) { - let layerCount = 0; + var layerCount = 0; + var supportedCount = 0; editor._geometries_layer.eachLayer(function(layer) { if (layer.feature && layer.feature.properties) { - // Add click handler for selection - layer.on('click', editor.cloneFloor.onItemClick); - - // Make layer visually selectable - const currentStyle = layer.options || {}; - layer.setStyle(Object.assign({}, currentStyle, { - cursor: 'pointer', - opacity: Math.max(currentStyle.opacity || 0, 0.5) - })); layerCount++; + var itemType = layer.feature.properties.type; + + // Only make supported types selectable + if (supportedTypes.indexOf(itemType.toLowerCase()) >= 0) { + // Add click handler for selection + layer.on('click', editor.cloneFloor.onItemClick); + + // Make layer visually selectable + var currentStyle = layer.options || {}; + layer.setStyle(Object.assign({}, currentStyle, { + cursor: 'pointer', + opacity: Math.max(currentStyle.opacity || 0, 0.5) + })); + supportedCount++; + } } }); - console.log('Clone floor: Made', layerCount, 'geometries selectable'); + console.log('Clone floor: Made', supportedCount, 'out of', layerCount, 'geometries selectable (supported types only)'); } else { console.log('Clone floor: No geometries layer found'); } @@ -2654,8 +2668,8 @@ editor.cloneFloor = { L.DomEvent.stopPropagation(e); L.DomEvent.preventDefault(e); - const layer = e.target; - const feature = layer.feature; + var layer = e.target; + var feature = layer.feature; console.log('Clone floor: Item clicked', feature); @@ -2664,15 +2678,23 @@ editor.cloneFloor = { return false; } - const itemId = feature.properties.id; - const itemType = feature.properties.type; + var itemId = feature.properties.id; + var itemType = feature.properties.type; console.log('Clone floor: Item ID:', itemId, 'Type:', itemType); console.log('Clone floor: Full feature properties:', JSON.stringify(feature.properties, null, 2)); + // Define supported item types that can be cloned + var supportedTypes = ['area', 'obstacle', 'lineobstacle', 'stair', 'ramp', 'hole', 'column', 'poi', 'altitudemarker', 'space', 'building', 'door']; + + if (supportedTypes.indexOf(itemType.toLowerCase()) === -1) { + console.log('Clone floor: Item type "' + itemType + '" is not supported for cloning. Supported types:', supportedTypes); + return false; + } + // Check if item is already selected - const existingIndex = editor.cloneFloor.selectedItems.findIndex( - item => item.item_id === itemId && item.item_type === itemType + var existingIndex = editor.cloneFloor.selectedItems.findIndex( + function(item) { return item.item_id === itemId && item.item_type === itemType; } ); if (existingIndex >= 0) { @@ -2698,6 +2720,56 @@ editor.cloneFloor = { $('#selected-count').text(editor.cloneFloor.selectedItems.length); }, + selectAllItems: function() { + if (!editor.cloneFloor.isSelectionMode) { + console.log('Clone floor: Select all called but not in selection mode'); + return; + } + + // Clear current selection + editor.cloneFloor.selectedItems = []; + + // Define supported item types that can be cloned + var supportedTypes = ['area', 'obstacle', 'lineobstacle', 'stair', 'ramp', 'hole', 'column', 'poi', 'altitudemarker', 'space', 'building', 'door']; + + if (editor._geometries_layer) { + var selectedCount = 0; + editor._geometries_layer.eachLayer(function(layer) { + if (layer.feature && layer.feature.properties) { + var itemType = layer.feature.properties.type; + var itemId = layer.feature.properties.id; + + // Only select supported types + if (supportedTypes.indexOf(itemType.toLowerCase()) >= 0) { + editor.cloneFloor.selectedItems.push({ + item_id: itemId, + item_type: itemType + }); + selectedCount++; + } + } + }); + console.log('Clone floor: Selected all', selectedCount, 'supported items'); + } + + editor.cloneFloor.updateSelectedCount(); + editor.cloneFloor.updateVisualSelection(); + }, + + clearSelection: function() { + if (!editor.cloneFloor.isSelectionMode) { + console.log('Clone floor: Clear selection called but not in selection mode'); + return; + } + + // Clear current selection + editor.cloneFloor.selectedItems = []; + console.log('Clone floor: Cleared all selected items'); + + editor.cloneFloor.updateSelectedCount(); + editor.cloneFloor.updateVisualSelection(); + }, + updateVisualSelection: function() { if (!editor._geometries_layer) return; @@ -2711,10 +2783,15 @@ editor.cloneFloor = { // Highlight selected items editor._geometries_layer.eachLayer(function(layer) { if (layer.feature && layer.feature.properties) { - const isSelected = editor.cloneFloor.selectedItems.some( - item => item.item_id === layer.feature.properties.id && - item.item_type === layer.feature.properties.type - ); + var isSelected = false; + for (var i = 0; i < editor.cloneFloor.selectedItems.length; i++) { + var item = editor.cloneFloor.selectedItems[i]; + if (item.item_id === layer.feature.properties.id && + item.item_type === layer.feature.properties.type) { + isSelected = true; + break; + } + } if (isSelected) { layer.setStyle({ @@ -2732,8 +2809,8 @@ editor.cloneFloor = { executeClone: function() { - const targetLevelId = $('#target-level-select').val(); - const keepSync = $('#keep-sync-checkbox').is(':checked'); + var targetLevelId = $('#target-level-select').val(); + var keepSync = $('#keep-sync-checkbox').is(':checked'); if (!targetLevelId) { alert('Please select a target level'); @@ -2746,7 +2823,7 @@ editor.cloneFloor = { } // Get current level ID - const currentLevelId = editor._level_control.current_level_id; + var currentLevelId = editor._level_control.current_level_id; if (currentLevelId === parseInt(targetLevelId)) { alert('Source and target levels cannot be the same'); @@ -2757,7 +2834,7 @@ editor.cloneFloor = { $('#execute-clone-btn').prop('disabled', true).html(' Cloning...'); // Prepare request data - const requestData = { + var requestData = { source_level_id: currentLevelId, target_level_id: parseInt(targetLevelId), items: editor.cloneFloor.selectedItems, @@ -2790,7 +2867,7 @@ editor.cloneFloor = { // Log the actual response text for debugging return response.text().then(function(text) { console.error('Clone floor: API error response:', text); - throw new Error(`HTTP ${response.status}: ${response.statusText}`); + throw new Error('HTTP ' + response.status + ': ' + response.statusText); }); } @@ -2802,15 +2879,16 @@ editor.cloneFloor = { console.log('Clone floor: API response keys:', Object.keys(data)); if (data.success) { - alert(`Successfully cloned ${data.cloned_items?.length || 0} items: ${data.message}`); + var clonedCount = data.cloned_items ? data.cloned_items.length : 0; + alert('Successfully cloned ' + clonedCount + ' items: ' + data.message); editor.cloneFloor.cancelSelection(); } else { - alert(`Clone failed: ${data.message}`); + alert('Clone failed: ' + data.message); } }) .catch(function(error) { console.error('Clone floor: Error details:', error); - alert(`Clone failed: ${error.message}`); + alert('Clone failed: ' + error.message); }) .finally(function() { $('#execute-clone-btn').prop('disabled', false).html(' Clone Items'); diff --git a/src/c3nav/editor/templates/editor/level.html b/src/c3nav/editor/templates/editor/level.html index 91e85297..0082939f 100644 --- a/src/c3nav/editor/templates/editor/level.html +++ b/src/c3nav/editor/templates/editor/level.html @@ -51,7 +51,13 @@ - + +
diff --git a/src/c3nav/editor/utils.py b/src/c3nav/editor/utils.py index 562f103a..6fdb30c8 100644 --- a/src/c3nav/editor/utils.py +++ b/src/c3nav/editor/utils.py @@ -147,6 +147,7 @@ def clone_level_items(request, source_level_id, target_level_id, items, keep_syn # Handle different item types differently if item_type == 'space': # For spaces, we need level but no space reference + print(f"Processing space item with fields: {[f.name for f in Model._meta.fields]}") for field in Model._meta.fields: if field.name in ['id', 'pk']: continue @@ -154,22 +155,43 @@ def clone_level_items(request, source_level_id, target_level_id, items, keep_syn # Skip auto fields and read-only fields if (hasattr(field, 'auto_created') and field.auto_created) or \ (hasattr(field, 'editable') and not field.editable): + print(f"Skipping field {field.name}: auto_created={getattr(field, 'auto_created', False)}, editable={getattr(field, 'editable', True)}") continue try: field_value = getattr(original_item, field.name) - except (AttributeError, ValueError): + print(f"Field {field.name}: {field_value} (type: {type(field_value)})") + except (AttributeError, ValueError) as e: + print(f"Could not get field {field.name}: {e}") continue # Handle level reference if field.name == 'level': clone_data[field.name] = target_level + print(f"Set level to target_level: {target_level}") else: - # Copy other fields + # Copy other fields - but check for special fields that shouldn't be copied + if field.name in ['slug']: + # Don't copy slug directly as it needs to be unique + # Instead, create a new unique slug based on the original + if field_value: + import re + base_slug = re.sub(r'-\d+$', '', field_value) # Remove trailing numbers + new_slug = f"{base_slug}-clone" + # Make sure the new slug is unique + counter = 1 + test_slug = new_slug + while Model.objects.filter(slug=test_slug).exists(): + test_slug = f"{new_slug}-{counter}" + counter += 1 + clone_data[field.name] = test_slug + print(f"Generated unique slug: {test_slug}") + continue if field_value is not None: clone_data[field.name] = field_value + print(f"Copied field {field.name}: {field_value}") - print(f"Space clone data: {clone_data}") + print(f"Final space clone data: {clone_data}") else: # For space-related items (areas, obstacles, etc.) space_found = False @@ -222,18 +244,24 @@ def clone_level_items(request, source_level_id, target_level_id, items, keep_syn # Create the cloned item try: print(f"Attempting to clone {model_name} with data: {clone_data}") + print(f"Creating {model_name} object...") cloned_item = Model(**clone_data) + print(f"Created object, now saving...") cloned_item.save() print(f"Successfully created cloned item with ID: {cloned_item.pk}") except Exception as create_error: print(f"Error creating {model_name}: {create_error}") + print(f"Error type: {type(create_error)}") print(f"Clone data was: {clone_data}") + # Try a different approach - create empty object and set fields one by one try: + print("Trying field-by-field approach...") cloned_item = Model() for field_name, field_value in clone_data.items(): try: setattr(cloned_item, field_name, field_value) + print(f"Set {field_name} = {field_value}") except Exception as field_error: print(f"Could not set {field_name}={field_value}: {field_error}") cloned_item.save() From f94c30c608403ca4ea233920d921b30d5c917f22 Mon Sep 17 00:00:00 2001 From: Stypox Date: Sat, 2 Aug 2025 11:07:38 +0200 Subject: [PATCH 15/16] Remove console.log --- src/c3nav/editor/static/editor/js/editor.js | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/c3nav/editor/static/editor/js/editor.js b/src/c3nav/editor/static/editor/js/editor.js index 06edd3ad..3b831a97 100644 --- a/src/c3nav/editor/static/editor/js/editor.js +++ b/src/c3nav/editor/static/editor/js/editor.js @@ -2286,7 +2286,6 @@ editor = { } else { height = Math.min(...ys) - ys[0] } - //console.log(xs, ys, base_length, height) lines = [{p1: { x: xs[0], y: ys[0] }, p2: { x: xs[1], y: ys[1] }}] for (i = 1; i < num_stairs; ++i) { @@ -2299,7 +2298,6 @@ editor = { x1 = xs[j] x2 = xs[(j+1)%n] if ((y1 > y && y2 > y) || (y1 < y && y2 < y)) { - //console.log("disconnected", y, j, x1,y1, x2,y2, xs, ys) continue } @@ -2311,11 +2309,9 @@ editor = { m = (y2 - y1) / (x2 - x1) q = y2 - m * x2 - //console.log("connected", y, j, x1,y1, x2,y2, m, q, xs, ys) inters_xs.push((y - q) / m) } - //console.log("inters_xs", inters_xs) if (inters_xs.length < 2) { continue } @@ -2325,13 +2321,11 @@ editor = { lines.push({p1: {x: min_xs-2, y: y}, p2: {x: max_xs+2, y: y}}) } - //console.log("untransformed lines", lines) lines = lines.map(l => ({ p1: editor._transform_point_for_staircase(l.p1, p0, cos_a, -sin_a), p2: editor._transform_point_for_staircase(l.p2, p0, cos_a, -sin_a), })) - //console.log(lines) return lines }, @@ -2370,7 +2364,6 @@ editor = { space = form.attr('space') lines = editor._get_staircase_lines() - console.log("hereeeeeee", csrfmiddlewaretoken, import_tag, space, lines) Promise.all(lines.map(l => fetch("/editor/spaces/" + space + "/stairs/create", { method: "POST", From 8938745251660f43629f205feda70b6fce18095d Mon Sep 17 00:00:00 2001 From: Dennis Orlando Date: Sat, 2 Aug 2025 11:35:14 +0200 Subject: [PATCH 16/16] mixing icon --- src/c3nav/static/img/snap-to-90-icon.svg | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 src/c3nav/static/img/snap-to-90-icon.svg diff --git a/src/c3nav/static/img/snap-to-90-icon.svg b/src/c3nav/static/img/snap-to-90-icon.svg new file mode 100644 index 00000000..e54076c8 --- /dev/null +++ b/src/c3nav/static/img/snap-to-90-icon.svg @@ -0,0 +1,3 @@ + + +
90°
90°
\ No newline at end of file