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 %}
+
+
+
Clone Selected Items
+
+
+
+
+
+
+
+
+
+
+
+
+
Selected Items: 0
+
Click on map items to select them for cloning. Selected items will be highlighted in red.
+
+
+
{% 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 %}
+
+
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 %}
-