Waste Urban Digital Twin
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Ekaropolus 2025-05-21 12:28:12 -06:00
parent cf5239a476
commit 60df25b39a
9 changed files with 801 additions and 1 deletions

View File

@ -0,0 +1,52 @@
import json
import polyline
from django.conf import settings
from pxy_dashboard.apps.models import OptScenario
def get_dispatch_data_for(subdivision):
"""
Load the last OptScenario, decode its VROOM JSON for this subdivision,
and return a list of routes, each with 'route_id', 'coords', and 'steps'.
"""
scenario = OptScenario.objects.last()
if not scenario or not scenario.dispatch_json:
return None
# load & slice
with open(scenario.dispatch_json.path, encoding='utf-8') as f:
raw = json.load(f)
raw_subdiv = raw.get(subdivision)
if not raw_subdiv:
return None
dispatch_data = []
for route in raw_subdiv.get('routes', []):
vehicle = route.get('vehicle')
# decode polyline geometry → [ [lon, lat], … ]
coords = []
if route.get('geometry'):
pts = polyline.decode(route['geometry'])
coords = [[lng, lat] for lat, lng in pts]
# build steps
steps = []
for step in route.get('steps', []):
lon, lat = step['location']
popup = (
f"{step.get('type','job').title()}<br>"
f"ID: {step.get('id','')}<br>"
f"Load: {step.get('load',[0])[0]} kg"
)
steps.append({
'position': [lon, lat],
'popup': popup,
'step_type': step.get('type','job'),
})
dispatch_data.append({
'route_id': str(vehicle),
'coords': coords,
'steps': steps,
})
return dispatch_data

View File

@ -0,0 +1,31 @@
<!-- Glass core -->
<a-circle
radius="1"
class="glass-core"
color="{{ ring_color|default:'#00FFFF' }}"
material="shader: standard; transparent: true; opacity: 0.3; metalness: 0.8; roughness: 0.1; side: double"
rotation="0 90 0">
</a-circle>
<!-- Animated outer ring -->
<a-ring
class="gauge-ring"
radius-inner="1.2"
radius-outer="1.4"
color="{{ ring_color|default:'#00FFFF' }}"
material="shader: flat; opacity: 0.8; side: double"
rotation="0 90 0"
animation="property: rotation; to: 0 90 0; loop: true; dur: 2000; easing: linear">
</a-ring>
<!-- Dynamic Text -->
<a-text
class="gauge-label"
value="ID: {{ id }}\n{{ status }}"
align="center"
color="#FFFFFF"
width="2"
side="double"
position="0 0 0.02"
rotation="0 90 0">
</a-text>

View File

@ -0,0 +1,131 @@
{% load static %}
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Waste Route Visualization</title>
<!-- A-Frame core -->
<script src="https://aframe.io/releases/1.3.0/aframe.min.js"></script>
<script src="https://raw.githack.com/AR-js-org/AR.js/3.4.5/aframe/build/aframe-ar.js"></script>
</head>
<body>
<a-scene id="scene">
<!-- Camera & cursor -->
<a-entity
id="mainCamera"
camera look-controls wasd-controls
position="0 5 10">
<a-cursor color="#ffffff"></a-cursor>
</a-entity>
<!-- Invisible ground-plane fallback -->
<a-plane id="basePlane"
rotation="-90 0 0"
color="#444" opacity="0.0">
</a-plane>
<!-- 1) Draw city buildings from your OSM data -->
{% for building in city_data.buildings %}
<a-entity id="{{ building.id }}" status="{{ building.status }}">
<a-box position="{{ building.position_x }} {{ building.height }} {{ building.position_z }}"
width="{{ building.width }}"
height="{{ building.height }}"
depth="{{ building.depth }}"
color="{{ building.color }}"
opacity="0.8">
</a-box>
<a-entity position="{{ building.position_x }} {{ building.height }} {{ building.position_z }}">
</a-entity>
</a-entity>
{% endfor %}
<!-- 2) Draw each job-sphere & gauge from step_positions -->
{% for pos in step_positions %}
<a-entity position="{{ pos.x }} 1 {{ pos.z }}">
<a-sphere radius="10"
color="#FF4136"
emissive="#FF4136"
opacity="0.7">
</a-sphere>
<!-- gauge above the sphere -->
<a-entity class="status-gauge" gauge-click-toggle position="11 3 0">
{% include "pxy_city_digital_twins/_status_gauge_waste.html" with ring_color="#FF4136" status=pos.step.step_type id=pos.step.id %}
</a-entity>
</a-entity>
{% endfor %}
</a-scene>
<script>
document.querySelector('#scene').addEventListener('loaded', () => {
const sceneEl = document.getElementById('scene');
// 3) rel_coords was passed in context
const rel = {{ rel_coords|safe }};
// Compute bounding box & stageSize
const xs = rel.map(r => r[0]), zs = rel.map(r => r[1]);
const minX = Math.min(...xs), maxX = Math.max(...xs);
const minZ = Math.min(...zs), maxZ = Math.max(...zs);
const width = maxX - minX, height = maxZ - minZ;
const stageSize = Math.ceil(Math.max(width, height) * 1.2);
// Update invisible base-plane
const base = document.getElementById('basePlane');
base.setAttribute('width', width * 1.5);
base.setAttribute('height', height * 1.5);
base.setAttribute('position',
`${(minX+maxX)/2} 0 ${(minZ+maxZ)/2}`);
// Add environment
const envEl = document.createElement('a-entity');
envEl.setAttribute('environment', `
preset: forest;
stageSize: ${stageSize};
ground: flat;
grid: true;
gridColor: #00ffff;
skyColor: #000000;
fog: 0.3;
lighting: subtle;
`);
sceneEl.appendChild(envEl);
// Draw & animate each segment
const pulseDuration = 600;
rel.forEach(([x1,z1], i) => {
if (i + 1 >= rel.length) return;
const [x2,z2] = rel[i+1];
const seg = document.createElement('a-entity');
seg.setAttribute('line', `
start: ${x1.toFixed(2)} 1 ${z1.toFixed(2)};
end: ${x2.toFixed(2)} 1 ${z2.toFixed(2)};
color: #0077FF;
opacity: 0.6
`);
seg.setAttribute('animation__color', `
property: line.color;
from: #0077FF;
to: #FF4136;
dur: ${pulseDuration};
delay: ${i * pulseDuration};
dir: alternate;
loop: true
`);
sceneEl.appendChild(seg);
});
// Position camera at first step
if (rel.length) {
const [fx,fz] = rel[0];
const cam = document.getElementById('mainCamera');
cam.setAttribute('position', `${fx.toFixed(2)} 6 ${fz.toFixed(2)+6}`);
cam.setAttribute('look-at', `${fx.toFixed(2)} 1 ${fz.toFixed(2)}`);
}
});
</script>
</body>
</html>
<!--
-->

View File

@ -45,7 +45,7 @@
</head>
<body>
<a-scene environment="preset: forest; groundTexture: walk; dressing: trees; fog: 0.7">
<a-scene environment="preset: tron; groundTexture: walk; dressing: trees; fog: 0.7">
<!-- Camera & Controls (give it an id for look-at) -->
<a-entity id="mainCamera" camera look-controls wasd-controls position="0 2 5">

View File

@ -0,0 +1,131 @@
{% load static %}
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Waste Route Visualization</title>
<!-- A-Frame core -->
<script src="https://aframe.io/releases/1.7.0/aframe.min.js"></script>
<!-- A-Frame environment effects -->
<script src="https://unpkg.com/aframe-environment-component/dist/aframe-environment-component.min.js"></script>
</head>
<body>
<a-scene id="scene">
<!-- Camera & cursor -->
<a-entity
id="mainCamera"
camera look-controls wasd-controls
position="0 5 10">
<a-cursor color="#ffffff"></a-cursor>
</a-entity>
<!-- Invisible ground-plane fallback -->
<a-plane id="basePlane"
rotation="-90 0 0"
color="#444" opacity="0.0">
</a-plane>
<!-- 1) Draw city buildings from your OSM data -->
{% for building in city_data.buildings %}
<a-entity id="{{ building.id }}" status="{{ building.status }}">
<a-box position="{{ building.position_x }} {{ building.height }} {{ building.position_z }}"
width="{{ building.width }}"
height="{{ building.height }}"
depth="{{ building.depth }}"
color="{{ building.color }}"
opacity="0.8">
</a-box>
<a-entity position="{{ building.position_x }} {{ building.height }} {{ building.position_z }}">
</a-entity>
</a-entity>
{% endfor %}
<!-- 2) Draw each job-sphere & gauge from step_positions -->
{% for pos in step_positions %}
<a-entity position="{{ pos.x }} 1 {{ pos.z }}">
<a-sphere radius="10"
color="#FF4136"
emissive="#FF4136"
opacity="0.7">
</a-sphere>
<!-- gauge above the sphere -->
<a-entity class="status-gauge" gauge-click-toggle position="11 3 0">
{% include "pxy_city_digital_twins/_status_gauge_waste.html" with ring_color="#FF4136" status=pos.step.step_type id=pos.step.id %}
</a-entity>
</a-entity>
{% endfor %}
</a-scene>
<script>
document.querySelector('#scene').addEventListener('loaded', () => {
const sceneEl = document.getElementById('scene');
// 3) rel_coords was passed in context
const rel = {{ rel_coords|safe }};
// Compute bounding box & stageSize
const xs = rel.map(r => r[0]), zs = rel.map(r => r[1]);
const minX = Math.min(...xs), maxX = Math.max(...xs);
const minZ = Math.min(...zs), maxZ = Math.max(...zs);
const width = maxX - minX, height = maxZ - minZ;
const stageSize = Math.ceil(Math.max(width, height) * 1.2);
// Update invisible base-plane
const base = document.getElementById('basePlane');
base.setAttribute('width', width * 1.5);
base.setAttribute('height', height * 1.5);
base.setAttribute('position',
`${(minX+maxX)/2} 0 ${(minZ+maxZ)/2}`);
// Add environment
const envEl = document.createElement('a-entity');
envEl.setAttribute('environment', `
preset: forest;
stageSize: ${stageSize};
ground: flat;
grid: true;
gridColor: #00ffff;
skyColor: #000000;
fog: 0.3;
lighting: subtle;
`);
sceneEl.appendChild(envEl);
// Draw & animate each segment
const pulseDuration = 600;
rel.forEach(([x1,z1], i) => {
if (i + 1 >= rel.length) return;
const [x2,z2] = rel[i+1];
const seg = document.createElement('a-entity');
seg.setAttribute('line', `
start: ${x1.toFixed(2)} 1 ${z1.toFixed(2)};
end: ${x2.toFixed(2)} 1 ${z2.toFixed(2)};
color: #0077FF;
opacity: 0.6
`);
seg.setAttribute('animation__color', `
property: line.color;
from: #0077FF;
to: #FF4136;
dur: ${pulseDuration};
delay: ${i * pulseDuration};
dir: alternate;
loop: true
`);
sceneEl.appendChild(seg);
});
// Position camera at first step
if (rel.length) {
const [fx,fz] = rel[0];
const cam = document.getElementById('mainCamera');
cam.setAttribute('position', `${fx.toFixed(2)} 6 ${fz.toFixed(2)+6}`);
cam.setAttribute('look-at', `${fx.toFixed(2)} 1 ${fz.toFixed(2)}`);
}
});
</script>
</body>
</html>
<!--
-->

View File

@ -0,0 +1,190 @@
{% load static %}
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Waste Route Debug</title>
<style>
body { font-family: sans-serif; padding: 2rem; }
h1, h2 { margin-bottom: 0.5rem; }
pre { background: #f6f6f6; padding: 1rem; overflow-x: auto; }
ol { margin-left: 1.5rem; }
li { margin-bottom: 0.5rem; }
/* map container */
#route-map {
width: 600px; height: 400px;
border: 1px solid #ccc;
margin-bottom: 2rem;
}
#route-map svg {
width: 100%; height: 100%; display: block;
background: #f0f8ff;
}
.route-line {
fill: none;
stroke: #0074D9;
stroke-width: 2;
}
.coord-point {
fill: #0074D9;
opacity: 0.6;
}
.step-point {
fill: #FF4136;
stroke: #fff;
stroke-width: 1;
}
.building-point {
fill: #2ECC40;
stroke: #fff;
stroke-width: 1;
}
table {
border-collapse: collapse;
margin-top: 1rem;
width: 100%;
}
th, td {
border: 1px solid #aaa;
padding: 0.5rem;
text-align: left;
}
th { background: #ddd; }
</style>
</head>
<body>
<h1>Waste Route Debug</h1>
<p><strong>Subdivision:</strong> {{ subdivision }}</p>
<p><strong>Vehicle:</strong> {{ vehicle }}</p>
<div id="route-map">
<svg></svg>
</div>
<div id="dump"></div>
<h2>Buildings</h2>
{% if city_data.buildings %}
<table>
<thead>
<tr>
<th>ID</th>
<th>Status</th>
<th>Position X</th>
<th>Position Z</th>
<th>Width</th>
<th>Depth</th>
<th>Height</th>
</tr>
</thead>
<tbody>
{% for b in city_data.buildings %}
<tr>
<td>{{ b.id }}</td>
<td>{{ b.status }}</td>
<td>{{ b.position_x|floatformat:2 }}</td>
<td>{{ b.position_z|floatformat:2 }}</td>
<td>{{ b.width|floatformat:2 }}</td>
<td>{{ b.depth|floatformat:2 }}</td>
<td>{{ b.height|floatformat:2 }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p><em>No buildings generated.</em></p>
{% endif %}
<script>
const route = JSON.parse('{{ selected_route_json|escapejs }}');
const coords = route.coords || [];
// 1) Draw SVG route + steps
if (coords.length > 1) {
const svg = document.querySelector('#route-map svg');
const W = svg.clientWidth, H = svg.clientHeight, M = 20;
const lons = coords.map(c=>c[0]), lats = coords.map(c=>c[1]);
const minX = Math.min(...lons), maxX = Math.max(...lons);
const minY = Math.min(...lats), maxY = Math.max(...lats);
const scaleX = (W - 2*M)/(maxX - minX || 1);
const scaleY = (H - 2*M)/(maxY - minY || 1);
function project([lon, lat]) {
const x = M + (lon - minX)*scaleX;
const y = H - (M + (lat - minY)*scaleY);
return [x,y];
}
// route line
const pts = coords.map(project).map(p=>p.join(',')).join(' ');
const line = document.createElementNS('http://www.w3.org/2000/svg','polyline');
line.setAttribute('points', pts);
line.setAttribute('class', 'route-line');
svg.appendChild(line);
// decoded-coord dots
coords.forEach(c => {
const [x,y] = project(c);
const dot = document.createElementNS('http://www.w3.org/2000/svg','circle');
dot.setAttribute('cx', x);
dot.setAttribute('cy', y);
dot.setAttribute('r', 2);
dot.setAttribute('class', 'coord-point');
svg.appendChild(dot);
});
// step markers
(route.steps||[]).forEach(s => {
const [x,y] = project(s.position);
const mark = document.createElementNS('http://www.w3.org/2000/svg','circle');
mark.setAttribute('cx', x);
mark.setAttribute('cy', y);
mark.setAttribute('r', 5);
mark.setAttribute('class', 'step-point');
svg.appendChild(mark);
});
// OPTIONAL: if you ever pass raw building lon/lat into city_data,
// you could plot them here as green circles:
/*
({{ city_data.buildings|length }} && city_data.buildings.forEach(b => {
const geo = [b.lon, b.lat];
const [x,y] = project(geo);
const bp = document.createElementNS('http://www.w3.org/2000/svg','circle');
bp.setAttribute('cx', x);
bp.setAttribute('cy', y);
bp.setAttribute('r', 4);
bp.setAttribute('class', 'building-point');
svg.appendChild(bp);
}))
*/
}
// 2) Dump coords & steps below
let out = '';
out += '<h2>Coordinates</h2>';
out += '<pre>' + JSON.stringify(coords, null, 2) + '</pre>';
out += '<h2>Steps</h2>';
if (route.steps && route.steps.length) {
out += '<ol>';
route.steps.forEach((s,i) => {
out += '<li>';
out += `<strong>Step #${i+1}</strong><br>`;
out += `<strong>Type:</strong> ${s.step_type}<br>`;
out += `<strong>Position:</strong> ${JSON.stringify(s.position)}<br>`;
out += `<strong>Popup HTML:</strong> <code>${s.popup
.replace(/</g,'&lt;')
.replace(/>/g,'&gt;')}</code>`;
out += '</li>';
});
out += '</ol>';
} else {
out += '<p><em>(no steps)</em></p>';
}
document.getElementById('dump').innerHTML = out;
</script>
</body>
</html>

View File

@ -0,0 +1,57 @@
{% load static %}
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Ruta No Encontrada</title>
<!-- A-Frame & environment component -->
<script src="https://aframe.io/releases/1.7.0/aframe.min.js"></script>
<script src="https://unpkg.com/aframe-environment-component/dist/aframe-environment-component.min.js"></script>
</head>
<body>
<a-scene environment="preset: yavapai; skyType: atmosphere; skyColor: #001840; fog: 0.6; groundColor: #444; groundTexture: walk; dressing: none;">
<!-- Camera & Controls -->
<a-entity id="mainCamera" camera look-controls wasd-controls position="0 2 4">
<a-cursor color="#FF0000"></a-cursor>
</a-entity>
<!-- Ground plane -->
<a-plane position="0 -0.1 0" rotation="-90 0 0" width="200" height="200" color="#444" opacity="0.3"></a-plane>
<!-- Animated box as placeholder for a happy truck -->
<a-box color="#FF4136" depth="1" height="0.5" width="2" position="0 0.25 -3"
animation="property: rotation; to: 0 360 0; loop: true; dur: 3000">
</a-box>
<!-- Message -->
<a-text value="Ruta no encontrada"
align="center"
position="0 1 -3"
color="#FFF"
width="4">
</a-text>
</a-scene>
<!-- Sugerencia de URL si aplica -->
{% if first_subdivision %}
<div style="
position: absolute;
bottom: 20px;
left: 20px;
background: rgba(0,0,0,0.6);
color: #fff;
padding: 8px 12px;
border-radius: 4px;
font-family: sans-serif;
">
Quizá quieras ver la ruta por defecto en
<a href="{% url 'waste_route' first_subdivision %}{% if first_route %}?route={{ first_route }}{% endif %}"
style="color: #ffd700; text-decoration: underline;">
{{ first_subdivision }}{% if first_route %} (ruta {{ first_route }}){% endif %}
</a>
</div>
{% endif %}
</body>
</html>

View File

@ -9,4 +9,9 @@ urlpatterns = [
# Augmented Digital Twin
path('city/augmented/digital/twin/<uuid:city_id>/', views.city_augmented_digital_twin, name='city_augmented_digital_twin_uuid'),
path('city/augmented/digital/twin/<str:city_id>/', views.city_augmented_digital_twin, name='city_augmented_digital_twin_str'),
path('city/virtual/reality/digital/twin/waste/<str:subdivision>/<str:vehicle>/', views.waste_route, name='waste_route'),
path('city/digital/twin/waste/debug/<str:subdivision>/<str:vehicle>/', views.waste_route_debug, name='waste_route_debug'),
path('city/augmented/reality/digital/twin/waste/<str:subdivision>/<str:vehicle>/', views.augmented_waste_route, name='waste_route'),
]

View File

@ -165,3 +165,206 @@ def city_augmented_digital_twin(request, city_id):
except (ValueError, TypeError):
raise Http404("Invalid parameters provided.")
from django.shortcuts import render
from django.http import Http404
from .services.waste_routes import get_dispatch_data_for
import json
def waste_route_debug(request, subdivision, vehicle):
# 1) load all routes for this subdivision
dispatch_data = get_dispatch_data_for(subdivision)
if not dispatch_data:
return render(request, 'pxy_city_digital_twins/waste_route_default.html', {
'subdivision': subdivision
})
# 2) pick your route by the required `vehicle` path-param
try:
selected = next(r for r in dispatch_data if r['route_id'] == vehicle)
except StopIteration:
return render(request, 'pxy_city_digital_twins/waste_route_not_found.html', {
'subdivision': subdivision
})
# 3) derive a “center” latitude/longitude from that route:
coords = selected.get('coords', [])
if coords:
avg_lon = sum(pt[0] for pt in coords) / len(coords)
avg_lat = sum(pt[1] for pt in coords) / len(coords)
else:
steps = selected.get('steps', [])
avg_lon = sum(s['position'][0] for s in steps) / len(steps)
avg_lat = sum(s['position'][1] for s in steps) / len(steps)
# 4) generate your OSMbased city around that center
city_data = generate_osm_city_data(avg_lat, avg_lon)
# 5) sanitize building heights (replace NaN or missing with default 10.0)
default_height = 10.0
for b in city_data.get('buildings', []):
h = b.get('height')
if not isinstance(h, (int, float)) or (isinstance(h, float) and math.isnan(h)):
b['height'] = default_height
# 6) render the VR template
return render(request, 'pxy_city_digital_twins/waste_route_debug.html', {
'subdivision': subdivision,
'vehicle': vehicle,
'selected_route_json': json.dumps(selected),
'city_data': city_data,
})
import math
import json
from django.shortcuts import render
from pyproj import Transformer
from .services.osm_city import generate_osm_city_data
from .services.waste_routes import get_dispatch_data_for
def waste_route(request, subdivision, vehicle):
"""
URL: /waste/<subdivision>/<vehicle>/
Renders a single vehicle's wastecollection route in VR,
overlaid on an OSMgenerated city around the route center.
"""
# 1) load all routes for this subdivision
dispatch_data = get_dispatch_data_for(subdivision)
if not dispatch_data:
return render(request, 'pxy_city_digital_twins/waste_route_default.html', {
'subdivision': subdivision
})
# 2) pick your route by the required `vehicle` path-param
try:
selected = next(r for r in dispatch_data if r['route_id'] == vehicle)
except StopIteration:
return render(request, 'pxy_city_digital_twins/waste_route_not_found.html', {
'subdivision': subdivision
})
# 3) derive center lon/lat from coords (or fallback to steps)
coords = selected.get('coords', [])
if coords:
avg_lon = sum(pt[0] for pt in coords) / len(coords)
avg_lat = sum(pt[1] for pt in coords) / len(coords)
else:
steps = selected.get('steps', [])
avg_lon = sum(s['position'][0] for s in steps) / len(steps)
avg_lat = sum(s['position'][1] for s in steps) / len(steps)
# 4) generate your OSMbased city around that center
city_data = generate_osm_city_data(avg_lat, avg_lon)
# 5) sanitize building heights (replace NaN or missing with default 10.0)
default_height = 10.0
for b in city_data.get('buildings', []):
h = b.get('height')
if not isinstance(h, (int, float)) or (isinstance(h, float) and math.isnan(h)):
b['height'] = default_height
# 6) project all coords (and steps) to Web Mercator, recenter them
transformer = Transformer.from_crs("EPSG:4326", "EPSG:3857", always_xy=True)
# route
merc = [transformer.transform(lon, lat) for lon, lat in coords]
if not merc:
merc = [transformer.transform(*s['position']) for s in selected.get('steps', [])]
avg_x = sum(x for x, _ in merc) / len(merc)
avg_z = sum(z for _, z in merc) / len(merc)
rel_coords = [[x - avg_x, z - avg_z] for x, z in merc]
# steps
step_positions = []
for step in selected.get('steps', []):
lon, lat = step['position']
x, z = transformer.transform(lon, lat)
step_positions.append({
'x': x - avg_x,
'z': z - avg_z,
'step': step
})
# 7) render
return render(request, 'pxy_city_digital_twins/virtual_waste_route.html', {
'subdivision': subdivision,
'vehicle': vehicle,
'selected_route_json': json.dumps(selected),
'city_data': city_data,
'rel_coords': rel_coords,
'step_positions': step_positions,
})
def augmented_waste_route(request, subdivision, vehicle):
"""
URL: /waste/<subdivision>/<vehicle>/
Renders a single vehicle's wastecollection route in VR,
overlaid on an OSMgenerated city around the route center.
"""
# 1) load all routes for this subdivision
dispatch_data = get_dispatch_data_for(subdivision)
if not dispatch_data:
return render(request, 'pxy_city_digital_twins/waste_route_default.html', {
'subdivision': subdivision
})
# 2) pick your route by the required `vehicle` path-param
try:
selected = next(r for r in dispatch_data if r['route_id'] == vehicle)
except StopIteration:
return render(request, 'pxy_city_digital_twins/waste_route_not_found.html', {
'subdivision': subdivision
})
# 3) derive center lon/lat from coords (or fallback to steps)
coords = selected.get('coords', [])
if coords:
avg_lon = sum(pt[0] for pt in coords) / len(coords)
avg_lat = sum(pt[1] for pt in coords) / len(coords)
else:
steps = selected.get('steps', [])
avg_lon = sum(s['position'][0] for s in steps) / len(steps)
avg_lat = sum(s['position'][1] for s in steps) / len(steps)
# 4) generate your OSMbased city around that center
city_data = generate_osm_city_data(avg_lat, avg_lon)
# 5) sanitize building heights (replace NaN or missing with default 10.0)
default_height = 10.0
for b in city_data.get('buildings', []):
h = b.get('height')
if not isinstance(h, (int, float)) or (isinstance(h, float) and math.isnan(h)):
b['height'] = default_height
# 6) project all coords (and steps) to Web Mercator, recenter them
transformer = Transformer.from_crs("EPSG:4326", "EPSG:3857", always_xy=True)
# route
merc = [transformer.transform(lon, lat) for lon, lat in coords]
if not merc:
merc = [transformer.transform(*s['position']) for s in selected.get('steps', [])]
avg_x = sum(x for x, _ in merc) / len(merc)
avg_z = sum(z for _, z in merc) / len(merc)
rel_coords = [[x - avg_x, z - avg_z] for x, z in merc]
# steps
step_positions = []
for step in selected.get('steps', []):
lon, lat = step['position']
x, z = transformer.transform(lon, lat)
step_positions.append({
'x': x - avg_x,
'z': z - avg_z,
'step': step
})
# 7) render
return render(request, 'pxy_city_digital_twins/augmented_waste_route.html', {
'subdivision': subdivision,
'vehicle': vehicle,
'selected_route_json': json.dumps(selected),
'city_data': city_data,
'rel_coords': rel_coords,
'step_positions': step_positions,
})