Merge branch 'snap-to-grid' into ours
This commit is contained in:
commit
5c89cf116f
15 changed files with 1197 additions and 240 deletions
|
@ -13,7 +13,7 @@ docker volume create c3nav-redis
|
||||||
# Start only postgres and redis first (no build since we pre-built)
|
# Start only postgres and redis first (no build since we pre-built)
|
||||||
docker compose up -d postgres redis
|
docker compose up -d postgres redis
|
||||||
|
|
||||||
sleep 5
|
sleep 10
|
||||||
cat ./db/auth_user.sql | docker exec -i local_run-postgres-1 su - postgres -c 'psql c3nav'
|
cat ./db/auth_user.sql | docker exec -i local_run-postgres-1 su - postgres -c 'psql c3nav'
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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.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.geometries import get_level_geometries_result, get_space_geometries_result
|
||||||
from c3nav.editor.api.schemas import EditorGeometriesElemSchema, EditorID, GeometryStylesSchema, UpdateCacheKey, \
|
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.editor.views.base import editor_etag_func, accesses_mapdata
|
||||||
from c3nav.mapdata.api.base import api_etag
|
from c3nav.mapdata.api.base import api_etag
|
||||||
from c3nav.mapdata.models import Source
|
from c3nav.mapdata.models import Source
|
||||||
|
@ -146,3 +146,28 @@ def beacons_lookup(request):
|
||||||
wifi_beacons=wifi_beacons,
|
wifi_beacons=wifi_beacons,
|
||||||
ibeacons=ibeacons,
|
ibeacons=ibeacons,
|
||||||
).model_dump(mode="json")
|
).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")
|
|
@ -125,3 +125,27 @@ class EditorBeaconsLookup(BaseSchema):
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
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
|
|
@ -567,36 +567,48 @@ label.theme-color-label {
|
||||||
.leaflet-control-snap {
|
.leaflet-control-snap {
|
||||||
background-color: white;
|
background-color: white;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border: 2px solid rgba(0,0,0,0.2);
|
|
||||||
background-clip: padding-box;
|
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, a.snap-to-90-toggle {
|
||||||
|
background-size: 30px 30px;
|
||||||
display: block;
|
display: block;
|
||||||
width: 30px;
|
width: 30px;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
line-height: 26px;
|
|
||||||
text-align: center;
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 18px;
|
|
||||||
background-color: white;
|
background-color: white;
|
||||||
color: #666;
|
color: #666;
|
||||||
border-radius: 2px;
|
border-radius: 4px;
|
||||||
transition: all 0.2s ease;
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: #f4f4f4;
|
background-color: #a7a7a7;
|
||||||
color: #333;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
background-color: #4CAF50;
|
background-color: #b0ecb2;
|
||||||
|
border: 2px solid green;
|
||||||
color: white;
|
color: white;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: #45a049;
|
background-color: #7ac27d;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a.snap-to-90-toggle {
|
||||||
|
background-color: yellow !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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");
|
||||||
|
}
|
||||||
|
a.snap-to-90-toggle {
|
||||||
|
background-image: url("/static/img/snap-to-90-icon.svg");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Snap indicator styles */
|
/* Snap indicator styles */
|
||||||
|
@ -608,70 +620,30 @@ label.theme-color-label {
|
||||||
pointer-events: none;
|
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 styles for snap-to-edges */
|
||||||
.edge-highlight {
|
.edge-highlight {
|
||||||
z-index: 999;
|
z-index: 999;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
animation: edge-fade-in 0.2s ease-in;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.original-edge-highlight {
|
.original-edge-highlight {
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
pointer-events: none;
|
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 snap indicators */
|
||||||
.right-angle-reference {
|
.right-angle-reference {
|
||||||
z-index: 998;
|
z-index: 998;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
animation: edge-fade-in 0.2s ease-in;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.right-angle-line {
|
.right-angle-line {
|
||||||
z-index: 1001;
|
z-index: 1001;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
animation: right-angle-pulse 2s infinite;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.right-angle-square {
|
.right-angle-square {
|
||||||
z-index: 1002;
|
z-index: 1002;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
animation: right-angle-pulse 2s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes right-angle-pulse {
|
|
||||||
0% {
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -163,6 +163,7 @@ editor = {
|
||||||
},
|
},
|
||||||
_sidebar_unload: function () {
|
_sidebar_unload: function () {
|
||||||
// unload the sidebar. called on sidebar_get and form submit.
|
// unload the sidebar. called on sidebar_get and form submit.
|
||||||
|
editor._hide_original_geometry();
|
||||||
editor._level_control.disable();
|
editor._level_control.disable();
|
||||||
editor._sublevel_control.disable();
|
editor._sublevel_control.disable();
|
||||||
|
|
||||||
|
@ -946,6 +947,12 @@ editor = {
|
||||||
editor._geometries_layer.addTo(editor.map);
|
editor._geometries_layer.addTo(editor.map);
|
||||||
editor._highlight_layer.addTo(editor.map);
|
editor._highlight_layer.addTo(editor.map);
|
||||||
editor._loading_geometry = false;
|
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._bounds_layer === null && editor._geometries_layer.getLayers().length) editor._bounds_layer = editor._geometries_layer;
|
||||||
if (editor._next_zoom && editor._bounds_layer !== null) {
|
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()], {
|
editor.map.flyToBounds((editor._bounds_layer.getBounds !== undefined) ? editor._bounds_layer.getBounds() : [editor._bounds_layer.getLatLng(), editor._bounds_layer.getLatLng()], {
|
||||||
|
@ -1674,7 +1681,9 @@ editor = {
|
||||||
},
|
},
|
||||||
|
|
||||||
// Snap-to-edges functionality
|
// Snap-to-edges functionality
|
||||||
_snap_enabled: true,
|
_snap_enabled: false,
|
||||||
|
_snap_to_original_enabled: false,
|
||||||
|
_snap_to_90_enable: false,
|
||||||
_snap_distance: 30, // pixels
|
_snap_distance: 30, // pixels
|
||||||
_extension_area_multiplier: 4, // Extension area = snap_distance * this multiplier
|
_extension_area_multiplier: 4, // Extension area = snap_distance * this multiplier
|
||||||
_snap_to_base_map: false,
|
_snap_to_base_map: false,
|
||||||
|
@ -1690,12 +1699,15 @@ editor = {
|
||||||
editor._add_snap_controls();
|
editor._add_snap_controls();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
_add_snap_controls: function() {
|
_add_snap_controls: function() {
|
||||||
|
|
||||||
|
// add snap to edge toggle
|
||||||
var snapControl = L.control({position: 'topleft'});
|
var snapControl = L.control({position: 'topleft'});
|
||||||
snapControl.onAdd = function() {
|
snapControl.onAdd = function() {
|
||||||
var container = L.DomUtil.create('div', 'leaflet-bar leaflet-control leaflet-control-snap');
|
var container = L.DomUtil.create('div', 'leaflet-bar leaflet-control leaflet-control-snap');
|
||||||
container.innerHTML = '<a href="#" title="Toggle Snap to Edges" class="snap-toggle ' +
|
container.innerHTML = '<a href="#" title="Toggle Snap to Edges" class="snap-toggle ' +
|
||||||
(editor._snap_enabled ? 'active' : '') + '">⚡</a>';
|
(editor._snap_enabled ? 'active' : '') + '"</a>';
|
||||||
|
|
||||||
L.DomEvent.on(container.querySelector('.snap-toggle'), 'click', function(e) {
|
L.DomEvent.on(container.querySelector('.snap-toggle'), 'click', function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
@ -1706,8 +1718,43 @@ editor = {
|
||||||
return container;
|
return container;
|
||||||
};
|
};
|
||||||
snapControl.addTo(editor.map);
|
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 = '<a href="#" title="Show Original Geometry" class="snap-to-original-toggle ' +
|
||||||
|
(editor._snap_to_original_enabled ? 'active' : '') + '"></a>';
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// 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 = '<a href="#" title="[UNSTABLE] Toggle Snap to 90°" class="snap-to-90-toggle ' +
|
||||||
|
(editor._snap_to_90_enabled ? 'active' : '') + '"></a>';
|
||||||
|
|
||||||
|
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);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
_toggle_snap: function() {
|
_toggle_snap: function() {
|
||||||
editor._snap_enabled = !editor._snap_enabled;
|
editor._snap_enabled = !editor._snap_enabled;
|
||||||
var toggle = document.querySelector('.snap-toggle');
|
var toggle = document.querySelector('.snap-toggle');
|
||||||
|
@ -1718,6 +1765,63 @@ editor = {
|
||||||
editor._clear_snap_indicators();
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show/hide original geometry
|
||||||
|
if (editor._snap_to_original_enabled) {
|
||||||
|
editor._show_original_geometry();
|
||||||
|
} else {
|
||||||
|
editor._hide_original_geometry();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_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;
|
||||||
|
|
||||||
|
// 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) {
|
_handle_snap_during_draw: function(e) {
|
||||||
if (!editor._snap_enabled || !editor._creating) return;
|
if (!editor._snap_enabled || !editor._creating) return;
|
||||||
|
|
||||||
|
@ -1750,14 +1854,17 @@ editor = {
|
||||||
var mapPoint = editor.map.latLngToContainerPoint(latlng);
|
var mapPoint = editor.map.latLngToContainerPoint(latlng);
|
||||||
var candidates = [];
|
var candidates = [];
|
||||||
|
|
||||||
// check for right-angle snap to current shape vertices
|
// ADD THIS: check for 90° axis snap
|
||||||
var rightAngleSnap = editor._find_right_angle_snap(latlng, mapPoint);
|
if (editor._snap_to_90_enabled) {
|
||||||
if (rightAngleSnap) {
|
var ninetyDegreeSnap = editor._find_90_degree_snap(latlng, mapPoint);
|
||||||
candidates.push(rightAngleSnap);
|
if (ninetyDegreeSnap) {
|
||||||
|
candidates.push(ninetyDegreeSnap);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// find snap candidates from existing geometries with area-limited infinite extension
|
// find snap candidates from existing geometries with area-limited infinite extension
|
||||||
editor._geometries_layer.eachLayer(function(layer) {
|
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
|
if (layer === editor._editing_layer) return; // don't snap to self
|
||||||
|
|
||||||
// check if layer is within the area limit for infinite extension
|
// check if layer is within the area limit for infinite extension
|
||||||
|
@ -1779,7 +1886,7 @@ editor = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the closest candidate
|
// find closest candidate
|
||||||
if (candidates.length > 0) {
|
if (candidates.length > 0) {
|
||||||
candidates.sort(function(a, b) { return a.distance - b.distance; });
|
candidates.sort(function(a, b) { return a.distance - b.distance; });
|
||||||
var best = candidates[0];
|
var best = candidates[0];
|
||||||
|
@ -1873,6 +1980,89 @@ editor = {
|
||||||
return distance <= radius;
|
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) {
|
_find_closest_point_on_geometry: function(layer, targetLatLng, targetMapPoint, allowInfiniteExtension) {
|
||||||
if (!layer.getLatLngs) return null;
|
if (!layer.getLatLngs) return null;
|
||||||
|
|
||||||
|
@ -1983,116 +2173,14 @@ editor = {
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
_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) {
|
_show_snap_indicator: function(latlng, snapInfo) {
|
||||||
editor._clear_snap_indicators();
|
editor._clear_snap_indicators();
|
||||||
|
|
||||||
var size = 0.001; // adjust this to control square size
|
var size = 0.001;
|
||||||
|
|
||||||
var bounds = [
|
var bounds = [
|
||||||
[latlng.lat - size, latlng.lng - size],
|
[latlng.lat - size, latlng.lng - size],
|
||||||
[latlng.lat + size, latlng.lng + size]
|
[latlng.lat + size, latlng.lng + size]
|
||||||
];
|
];
|
||||||
|
|
||||||
var indicator = L.rectangle(bounds, {
|
var indicator = L.rectangle(bounds, {
|
||||||
color: '#666',
|
color: '#666',
|
||||||
weight: 2,
|
weight: 2,
|
||||||
|
@ -2103,20 +2191,62 @@ editor = {
|
||||||
|
|
||||||
editor._snap_indicator.addLayer(indicator);
|
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);
|
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: '<div style="color: #00aaff; font-weight: bold; font-size: 16px;">' + axisText + '</div>',
|
||||||
|
className: '90-degree-axis-indicator',
|
||||||
|
iconSize: [20, 20],
|
||||||
|
iconAnchor: [10, 10]
|
||||||
|
})
|
||||||
|
});
|
||||||
|
editor._snap_indicator.addLayer(textMarker);
|
||||||
|
},
|
||||||
|
|
||||||
_show_edge_highlight: function(snapInfo) {
|
_show_edge_highlight: function(snapInfo) {
|
||||||
if (!snapInfo.edgeStart || !snapInfo.edgeEnd) return;
|
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 startPoint = snapInfo.edgeStart;
|
||||||
var endPoint = snapInfo.edgeEnd;
|
var endPoint = snapInfo.edgeEnd;
|
||||||
var extendedStart, extendedEnd;
|
var extendedStart, extendedEnd;
|
||||||
|
@ -2177,63 +2307,6 @@ editor = {
|
||||||
editor._snap_indicator.addLayer(originalEdge);
|
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() {
|
_clear_snap_indicators: function() {
|
||||||
if (editor._snap_indicator) {
|
if (editor._snap_indicator) {
|
||||||
editor._snap_indicator.clearLayers();
|
editor._snap_indicator.clearLayers();
|
||||||
|
@ -2667,6 +2740,354 @@ OverlayControl = L.Control.extend({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Clone Floor Functionality
|
||||||
|
editor.cloneFloor = {
|
||||||
|
selectedItems: [],
|
||||||
|
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');
|
||||||
|
$('#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);
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
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('<i class="glyphicon glyphicon-remove"></i> Cancel Selection').removeClass('btn-info').addClass('btn-warning');
|
||||||
|
$('#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) {
|
||||||
|
var layerCount = 0;
|
||||||
|
var supportedCount = 0;
|
||||||
|
editor._geometries_layer.eachLayer(function(layer) {
|
||||||
|
if (layer.feature && layer.feature.properties) {
|
||||||
|
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', supportedCount, 'out of', layerCount, 'geometries selectable (supported types only)');
|
||||||
|
} 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('<i class="glyphicon glyphicon-copy"></i> Clone to Floor').removeClass('btn-warning').addClass('btn-info');
|
||||||
|
$('#clone-floor-selector').hide();
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// Reset to original style
|
||||||
|
layer.setStyle(editor._get_geometry_style(layer.feature));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-enable map editing
|
||||||
|
editor.map.doubleClickZoom.enable();
|
||||||
|
},
|
||||||
|
|
||||||
|
onItemClick: function(e) {
|
||||||
|
if (!editor.cloneFloor.isSelectionMode) return;
|
||||||
|
|
||||||
|
// Prevent default behavior and stop propagation
|
||||||
|
if (e.originalEvent) {
|
||||||
|
e.originalEvent.stopPropagation();
|
||||||
|
e.originalEvent.preventDefault();
|
||||||
|
}
|
||||||
|
L.DomEvent.stopPropagation(e);
|
||||||
|
L.DomEvent.preventDefault(e);
|
||||||
|
|
||||||
|
var layer = e.target;
|
||||||
|
var feature = layer.feature;
|
||||||
|
|
||||||
|
console.log('Clone floor: Item clicked', feature);
|
||||||
|
|
||||||
|
if (!feature || !feature.properties) {
|
||||||
|
console.log('Clone floor: No feature or properties found');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
var existingIndex = editor.cloneFloor.selectedItems.findIndex(
|
||||||
|
function(item) { return 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();
|
||||||
|
|
||||||
|
return false; // Prevent further event propagation
|
||||||
|
},
|
||||||
|
|
||||||
|
updateSelectedCount: function() {
|
||||||
|
$('#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;
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
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({
|
||||||
|
stroke: true,
|
||||||
|
color: '#ff0000',
|
||||||
|
weight: 4,
|
||||||
|
opacity: 1,
|
||||||
|
fillOpacity: 0.7,
|
||||||
|
fillColor: '#ff0000'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
executeClone: function() {
|
||||||
|
var targetLevelId = $('#target-level-select').val();
|
||||||
|
var 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
|
||||||
|
var 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('<i class="glyphicon glyphicon-refresh"></i> Cloning...');
|
||||||
|
|
||||||
|
// Prepare request data
|
||||||
|
var requestData = {
|
||||||
|
source_level_id: currentLevelId,
|
||||||
|
target_level_id: parseInt(targetLevelId),
|
||||||
|
items: editor.cloneFloor.selectedItems,
|
||||||
|
keep_sync: keepSync
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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() {
|
||||||
|
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);
|
||||||
|
console.log('Clone floor: API response type:', typeof data);
|
||||||
|
console.log('Clone floor: API response keys:', Object.keys(data));
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function(error) {
|
||||||
|
console.error('Clone floor: Error details:', error);
|
||||||
|
alert('Clone failed: ' + error.message);
|
||||||
|
})
|
||||||
|
.finally(function() {
|
||||||
|
$('#execute-clone-btn').prop('disabled', false).html('<i class="glyphicon glyphicon-ok"></i> Clone Items');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if ($('#sidebar').length) {
|
if ($('#sidebar').length) {
|
||||||
editor.init();
|
editor.init();
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,6 +16,9 @@
|
||||||
{% trans 'Level' as model_title %}
|
{% trans 'Level' as model_title %}
|
||||||
<i class="glyphicon glyphicon-pencil"></i> {% blocktrans %}Edit {{ model_title }}{% endblocktrans %}
|
<i class="glyphicon glyphicon-pencil"></i> {% blocktrans %}Edit {{ model_title }}{% endblocktrans %}
|
||||||
</a>
|
</a>
|
||||||
|
<button id="clone-floor-btn" class="btn btn-info btn-xs" style="margin-left: 5px;" title="Clone selected items to another floor">
|
||||||
|
<i class="glyphicon glyphicon-copy"></i> Clone to Floor
|
||||||
|
</button>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
{% if level.on_top_of == None %}
|
{% if level.on_top_of == None %}
|
||||||
|
@ -25,6 +28,45 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<!-- Clone Floor Interface -->
|
||||||
|
<div id="clone-floor-selector" style="display: none; margin: 10px 0; padding: 10px; border: 1px solid #ddd; border-radius: 4px; background-color: #f9f9f9;">
|
||||||
|
<h4><i class="glyphicon glyphicon-copy"></i> Clone Selected Items</h4>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="target-level-select">Target Level:</label>
|
||||||
|
<select id="target-level-select" class="form-control">
|
||||||
|
<option value="">Select target level...</option>
|
||||||
|
{% for l in levels %}
|
||||||
|
{% if l.pk != level.pk %}
|
||||||
|
<option value="{{ l.pk }}">{{ l.title }}</option>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="checkbox">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" id="keep-sync-checkbox"> Keep items synchronized across levels
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<button id="execute-clone-btn" class="btn btn-primary btn-sm">
|
||||||
|
<i class="glyphicon glyphicon-ok"></i> Clone Items
|
||||||
|
</button>
|
||||||
|
<button id="select-all-btn" class="btn btn-success btn-sm" style="margin-left: 5px;">
|
||||||
|
<i class="glyphicon glyphicon-check"></i> Select All
|
||||||
|
</button>
|
||||||
|
<button id="clear-selection-btn" class="btn btn-warning btn-sm" style="margin-left: 5px;">
|
||||||
|
<i class="glyphicon glyphicon-unchecked"></i> Clear Selection
|
||||||
|
</button>
|
||||||
|
<button id="cancel-clone-btn" class="btn btn-default btn-sm" style="margin-left: 5px;">
|
||||||
|
<i class="glyphicon glyphicon-remove"></i> Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="selected-items-info" class="alert alert-info" style="margin-bottom: 0;">
|
||||||
|
<strong>Selected Items: <span id="selected-count">0</span></strong>
|
||||||
|
<p style="margin: 5px 0 0 0; font-size: 12px;">Click on map items to select them for cloning. Selected items will be highlighted in red.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% url 'editor.levels.graph' level=level.pk as graph_url %}
|
{% url 'editor.levels.graph' level=level.pk as graph_url %}
|
||||||
{% url 'editor.levels.overlays' level=level.pk as overlays_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 %}
|
{% include 'editor/fragment_child_models.html' with graph_url=graph_url overlays_url=overlays_url %}
|
||||||
|
|
|
@ -55,3 +55,274 @@ class SpaceChildEditUtils(DefaultEditUtils):
|
||||||
@property
|
@property
|
||||||
def _geometry_url(self):
|
def _geometry_url(self):
|
||||||
return '/api/v2/editor/geometries/space/'+str(self.space.pk) # todo: resolve correctly
|
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
|
||||||
|
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
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
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 - 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"Final 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}")
|
||||||
|
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()
|
||||||
|
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")
|
||||||
|
|
|
@ -8,3 +8,4 @@ class MapdataConfig(AppConfig):
|
||||||
from c3nav.mapdata.utils.cache.changes import register_signals
|
from c3nav.mapdata.utils.cache.changes import register_signals
|
||||||
register_signals()
|
register_signals()
|
||||||
import c3nav.mapdata.metrics # noqa
|
import c3nav.mapdata.metrics # noqa
|
||||||
|
import c3nav.mapdata.signals.sync # noqa
|
||||||
|
|
34
src/c3nav/mapdata/migrations/0139_add_cloned_item_sync.py
Normal file
34
src/c3nav/mapdata/migrations/0139_add_cloned_item_sync.py
Normal file
|
@ -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')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
|
@ -9,3 +9,4 @@ from c3nav.mapdata.models.source import Source # noqa
|
||||||
from c3nav.mapdata.models.graph import GraphNode, WayType, GraphEdge # noqa
|
from c3nav.mapdata.models.graph import GraphNode, WayType, GraphEdge # noqa
|
||||||
from c3nav.mapdata.models.theme import Theme # noqa
|
from c3nav.mapdata.models.theme import Theme # noqa
|
||||||
from c3nav.mapdata.models.overlay import DataOverlay, DataOverlayFeature # noqa
|
from c3nav.mapdata.models.overlay import DataOverlay, DataOverlayFeature # noqa
|
||||||
|
from c3nav.mapdata.models.sync import ClonedItemSync # noqa
|
47
src/c3nav/mapdata/models/sync.py
Normal file
47
src/c3nav/mapdata/models/sync.py
Normal file
|
@ -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}"
|
95
src/c3nav/mapdata/signals/sync.py
Normal file
95
src/c3nav/mapdata/signals/sync.py
Normal file
|
@ -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
|
|
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
3
src/c3nav/static/img/snap-to-edges-icon.svg
Normal file
3
src/c3nav/static/img/snap-to-edges-icon.svg
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" style="background: transparent; background-color: transparent; color-scheme: light dark;" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="101px" height="126px" viewBox="-0.5 -0.5 101 126"><defs/><g><g data-cell-id="0"><g data-cell-id="1"><g data-cell-id="pwFeD8oKGOq7wNB7DZdw-4"><g><path d="M 60 22 L 100 52 L 60 82 Z" fill="none" stroke="#000000" style="stroke: rgb(0, 0, 0);" stroke-miterlimit="10" pointer-events="all"/></g></g><g data-cell-id="pwFeD8oKGOq7wNB7DZdw-6"><g><rect x="0" y="32" width="60" height="60" fill="none" stroke="#000000" style="stroke: rgb(0, 0, 0);" pointer-events="all"/></g></g><g data-cell-id="pwFeD8oKGOq7wNB7DZdw-3"><g><path d="M 60 2 L 60 122" fill="none" stroke="#000000" style="stroke: rgb(0, 0, 0);" stroke-width="5" stroke-miterlimit="10" stroke-dasharray="15 15" pointer-events="stroke"/></g></g></g></g></g></svg>
|
After Width: | Height: | Size: 1 KiB |
3
src/c3nav/static/img/snap-to-original-icon.svg
Normal file
3
src/c3nav/static/img/snap-to-original-icon.svg
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" style="background: transparent; background-color: transparent; color-scheme: light;" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="114px" height="124px" viewBox="-0.5 -0.5 114 124"><defs/><g><g data-cell-id="0"><g data-cell-id="1"><g data-cell-id="pwFeD8oKGOq7wNB7DZdw-7"><g><path d="M 11 71 L 71 1" fill="none" stroke="#000000" style="stroke: rgb(0, 0, 0);" stroke-width="2" stroke-miterlimit="10" stroke-dasharray="6 6" pointer-events="stroke"/></g></g><g data-cell-id="pwFeD8oKGOq7wNB7DZdw-8"><g><path d="M 81 101 L 11 71" fill="none" stroke="#000000" style="stroke: rgb(0, 0, 0);" stroke-width="2" stroke-miterlimit="10" stroke-dasharray="6 6" pointer-events="stroke"/></g></g><g data-cell-id="pwFeD8oKGOq7wNB7DZdw-9"><g><path d="M 81 101 L 71 1" fill="none" stroke="#000000" style="stroke: rgb(0, 0, 0);" stroke-width="2" stroke-miterlimit="10" stroke-dasharray="6 6" pointer-events="stroke"/></g></g><g data-cell-id="pwFeD8oKGOq7wNB7DZdw-10"><g><path d="M 1 71 L 31 1" fill="none" stroke="#000000" style="stroke: rgb(0, 0, 0);" stroke-width="3" stroke-miterlimit="10" pointer-events="stroke"/></g></g><g data-cell-id="pwFeD8oKGOq7wNB7DZdw-11"><g><path d="M 111 121 L 1 71" fill="none" stroke="#000000" style="stroke: rgb(0, 0, 0);" stroke-width="3" stroke-miterlimit="10" pointer-events="stroke"/></g></g><g data-cell-id="pwFeD8oKGOq7wNB7DZdw-12"><g><path d="M 111 121 L 31 1" fill="none" stroke="#000000" style="stroke: rgb(0, 0, 0);" stroke-width="3" stroke-miterlimit="10" pointer-events="stroke"/></g></g></g></g></g></svg>
|
After Width: | Height: | Size: 1.7 KiB |
Loading…
Add table
Add a link
Reference in a new issue