Digital Twins ver 2.0 with Celium and Roads
Some checks are pending
continuous-integration/drone/push Build is running

This commit is contained in:
Ekaropolus 2026-01-03 01:03:22 -06:00
parent dd430ddecf
commit c8034ee5d2
8 changed files with 674 additions and 26 deletions

View File

@ -179,6 +179,9 @@ NEO4J_PASSWORD = os.getenv("NEO4J_PASSWORD")
# OpenAI
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
# Cesium ion
CESIUM_ION_TOKEN = os.getenv("CESIUM_ION_TOKEN", "")
# CSRF protection for production
CSRF_TRUSTED_ORIGINS = [
"https://app.polisplexity.tech",

BIN
polisplexity_code.zip Normal file

Binary file not shown.

View File

@ -4,18 +4,56 @@ import random
import uuid
import networkx as nx
from matplotlib import cm
import math
from shapely.geometry import LineString, MultiLineString
def generate_osm_city_data(lat, lon, dist=400, scale=1.0):
print(f"🏙️ Fetching OSM buildings and network at ({lat}, {lon})")
scale_factor = scale
status_options = ["OK", "Warning", "Critical", "Offline"]
road_types = {
"motorway",
"trunk",
"primary",
"secondary",
"tertiary",
"residential",
"unclassified",
"service",
"living_street",
}
road_widths = {
"motorway": 8.0,
"trunk": 7.0,
"primary": 6.0,
"secondary": 5.0,
"tertiary": 4.0,
"residential": 3.0,
}
road_colors = {
"motorway": "#00ffff",
"trunk": "#00ff99",
"primary": "#ff00ff",
"secondary": "#ff8800",
"tertiary": "#ffe600",
"residential": "#00aaff",
"unclassified": "#66ffcc",
"service": "#ff66cc",
"living_street": "#66ff66",
}
default_road_width = 3.0
min_segment_len = 1.0
# ————— STREET NETWORK —————
G = ox.graph_from_point((lat, lon), dist=dist, network_type='drive').to_undirected()
degree = dict(G.degree())
max_degree = max(degree.values()) if degree else 1
color_map = cm.get_cmap("plasma")
nodes_gdf, edge_gdf = ox.graph_to_gdfs(G, nodes=True, edges=True)
nodes_gdf = nodes_gdf.to_crs(epsg=3857)
edge_gdf = edge_gdf.to_crs(epsg=3857)
# ————— BUILDINGS —————
tags = {"building": True}
@ -56,11 +94,67 @@ def generate_osm_city_data(lat, lon, dist=400, scale=1.0):
"status": status,
})
raw_roads = []
raw_road_paths = []
for _, row in edge_gdf.iterrows():
hwy = row.get("highway")
if isinstance(hwy, (list, tuple)) and hwy:
hwy = hwy[0]
if hwy and hwy not in road_types:
continue
geom = row.get("geometry")
lines = []
if isinstance(geom, LineString):
lines = [geom]
elif isinstance(geom, MultiLineString):
lines = list(geom.geoms)
else:
u = row.get("u")
v = row.get("v")
if u is not None and v is not None and u in nodes_gdf.index and v in nodes_gdf.index:
nu = nodes_gdf.loc[u].geometry
nv = nodes_gdf.loc[v].geometry
if nu is not None and nv is not None:
lines = [LineString([(nu.x, nu.y), (nv.x, nv.y)])]
if not lines:
continue
width = road_widths.get(hwy, default_road_width)
color = road_colors.get(hwy, "#666666")
for line in lines:
coords = list(line.coords)
if len(coords) >= 2:
raw_road_paths.append({
"coords": coords,
"width": width,
"color": color,
})
for i in range(len(coords) - 1):
x1, z1 = coords[i]
x2, z2 = coords[i + 1]
raw_roads.append({
"raw_x1": x1,
"raw_z1": z1,
"raw_x2": x2,
"raw_z2": z2,
"width": width,
"color": color,
})
# ————— CENTER AND SCALE —————
if raw_buildings:
avg_x = sum(b['raw_x'] for b in raw_buildings) / len(raw_buildings)
avg_z = sum(b['raw_z'] for b in raw_buildings) / len(raw_buildings)
else:
avg_x = 0.0
avg_z = 0.0
if not raw_buildings and raw_roads:
avg_x = sum((r["raw_x1"] + r["raw_x2"]) / 2 for r in raw_roads) / len(raw_roads)
avg_z = sum((r["raw_z1"] + r["raw_z2"]) / 2 for r in raw_roads) / len(raw_roads)
if raw_buildings:
buildings = [{
"id": b['id'],
"position_x": (b['raw_x'] - avg_x) * scale_factor,
@ -74,7 +168,62 @@ def generate_osm_city_data(lat, lon, dist=400, scale=1.0):
else:
buildings = []
return {"buildings": buildings}
roads = []
for r in raw_roads:
x1 = (r["raw_x1"] - avg_x) * scale_factor
z1 = (r["raw_z1"] - avg_z) * scale_factor
x2 = (r["raw_x2"] - avg_x) * scale_factor
z2 = (r["raw_z2"] - avg_z) * scale_factor
dx = x2 - x1
dz = z2 - z1
length = math.hypot(dx, dz)
if length < min_segment_len * scale_factor:
continue
yaw = math.degrees(math.atan2(dz, dx))
roads.append({
"x": (x1 + x2) / 2,
"z": (z1 + z2) / 2,
"x1": x1,
"z1": z1,
"x2": x2,
"z2": z2,
"length": length,
"width": max(r["width"] * scale_factor, 0.2),
"yaw": yaw,
"color": r["color"],
})
road_paths = []
for rp in raw_road_paths:
pts = [{
"x": (x - avg_x) * scale_factor,
"z": (z - avg_z) * scale_factor,
} for x, z in rp["coords"]]
if len(pts) < 2:
continue
if len(pts) > 200:
last_pt = pts[-1]
step = max(1, int(len(pts) / 200))
pts = pts[::step]
if pts[-1] != last_pt:
pts.append(last_pt)
road_paths.append({
"points": pts,
"width": max(rp["width"] * scale_factor, 0.2),
"color": rp["color"],
})
if roads:
xs = [r["x"] for r in roads]
zs = [r["z"] for r in roads]
print(
"🛣️ Roads: segments=%d bbox=(%.1f, %.1f)-(%.1f, %.1f)"
% (len(roads), min(xs), min(zs), max(xs), max(zs))
)
return {"buildings": buildings, "roads": roads, "road_paths": road_paths}
def generate_osm_road_midpoints_only(lat, lon, dist=400, scale=1.0):

View File

@ -43,6 +43,71 @@
});
</script>
<script>
AFRAME.registerComponent('roads-ribbon', {
init: function () {
const dataEl = document.getElementById('road-paths-data');
if (!dataEl) return;
let paths = [];
try {
paths = JSON.parse(dataEl.textContent || '[]');
} catch (e) {
console.warn('roads-ribbon: failed to parse road paths', e);
}
if (!paths.length) return;
const group = new THREE.Group();
const materialCache = {};
function getMaterial(color) {
const key = (color || '#00ffff').toLowerCase();
if (!materialCache[key]) {
const base = new THREE.Color(key);
materialCache[key] = new THREE.MeshStandardMaterial({
color: base,
emissive: base,
emissiveIntensity: 1.1,
transparent: true,
opacity: 0.9,
});
}
return materialCache[key];
}
paths.forEach((p) => {
const pts = p.points || [];
if (pts.length < 2) return;
const curvePts = pts.map((pt) => new THREE.Vector3(pt.x || 0, 0.35, pt.z || 0));
const curve = new THREE.CatmullRomCurve3(curvePts);
const segments = Math.min(Math.max(curvePts.length * 2, 6), 200);
const radius = Math.max((p.width || 0.2) * 0.35, 0.08);
const tubeGeom = new THREE.TubeGeometry(curve, segments, radius, 6, false);
const mesh = new THREE.Mesh(tubeGeom, getMaterial(p.color));
mesh.frustumCulled = false;
group.add(mesh);
});
group.frustumCulled = false;
this.el.setObject3D('mesh', group);
this.group = group;
this.materials = Object.values(materialCache);
this._pulseValue = 1.1;
this._pulseDir = 1;
},
tick: function (time, delta) {
if (!this.materials || !this.materials.length) return;
const step = (delta || 16) / 1000;
this._pulseValue += this._pulseDir * step * 0.6;
if (this._pulseValue > 1.4) this._pulseDir = -1;
if (this._pulseValue < 0.4) this._pulseDir = 1;
for (let i = 0; i < this.materials.length; i++) {
this.materials[i].emissiveIntensity = this._pulseValue;
}
}
});
</script>
</head>
<body>
<a-scene environment="preset: tron; groundTexture: walk; dressing: trees; fog: 0.7">
@ -58,6 +123,14 @@
color="#444" opacity="0.3">
</a-plane>
<!-- Roads -->
{% if city_data.road_paths %}
{{ city_data.road_paths|json_script:"road-paths-data" }}
{% else %}
<script id="road-paths-data" type="application/json">[]</script>
{% endif %}
<a-entity id="roadsRibbon" roads-ribbon></a-entity>
<!-- Buildings -->
{% for building in city_data.buildings %}
<a-entity id="{{ building.id }}" status="{{ building.status }}">

View File

@ -0,0 +1,289 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Cesium Digital Twin</title>
<link
rel="stylesheet"
href="https://cesium.com/downloads/cesiumjs/releases/1.114/Build/Cesium/Widgets/widgets.css"
/>
<script src="https://cesium.com/downloads/cesiumjs/releases/1.114/Build/Cesium/Cesium.js"></script>
<style>
html, body, #cesiumContainer {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
#toolbar {
position: absolute;
top: 12px;
left: 12px;
z-index: 10;
background: rgba(0, 0, 0, 0.65);
color: #fff;
padding: 10px 12px;
border-radius: 6px;
font-family: Arial, sans-serif;
font-size: 13px;
}
#toolbar select {
width: 300px;
}
#toolbar .field {
margin-top: 8px;
}
#toolbar .infoPanel {
margin-top: 8px;
visibility: hidden;
}
#toolbar table {
border-collapse: collapse;
}
#toolbar td {
padding: 4px 6px;
}
</style>
</head>
<body>
<div id="toolbar">
<div class="field">
<select id="basemap">
<option value="osm">OpenStreetMap</option>
<option value="esri">ESRI World Imagery</option>
<option value="carto_light">CARTO Light</option>
<option value="carto_dark">CARTO Dark</option>
</select>
</div>
<div class="field">
<select id="dropdown">
<option value="0">Color By Building Material</option>
<option value="1">Color By Distance To Selected Location</option>
<option value="2">Highlight Residential Buildings</option>
<option value="3">Show Office Buildings Only</option>
<option value="4">Show Apartment Buildings Only</option>
</select>
</div>
<table class="infoPanel">
<tbody>
<tr>
<td>Click a building to set the center point</td>
</tr>
</tbody>
</table>
</div>
<div id="cesiumContainer"></div>
<script>
Cesium.Ion.defaultAccessToken = "{{ ion_token }}";
const viewer = new Cesium.Viewer("cesiumContainer", {
imageryProvider: false,
animation: false,
timeline: false,
baseLayerPicker: false,
geocoder: false,
homeButton: false,
navigationHelpButton: false,
sceneModePicker: false,
infoBox: false,
selectionIndicator: false,
});
(async () => {
const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
let osmBuildingsTileset = null;
const infoPanel = document.querySelector(".infoPanel");
const menu = document.getElementById("dropdown");
const basemap = document.getElementById("basemap");
const basemapLayers = {};
try {
viewer.terrainProvider = await Cesium.createWorldTerrainAsync();
} catch (err) {
console.error("Failed to load world terrain", err);
}
try {
osmBuildingsTileset = await Cesium.createOsmBuildingsAsync();
viewer.scene.primitives.add(osmBuildingsTileset);
} catch (err) {
console.error("Failed to load OSM Buildings", err);
}
const lat = Number("{{ lat }}");
const lon = Number("{{ long }}");
const defaultLat = Number.isNaN(lat) ? 47.62051 : lat;
const defaultLon = Number.isNaN(lon) ? -122.34931 : lon;
if (!Number.isNaN(lat) && !Number.isNaN(lon)) {
viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(lon, lat, 900),
orientation: {
heading: Cesium.Math.toRadians(0),
pitch: Cesium.Math.toRadians(-35),
roll: 0,
},
});
}
function getBasemapProvider(key) {
if (key === "osm") {
return new Cesium.OpenStreetMapImageryProvider({
url: "https://tile.openstreetmap.org/",
});
}
if (key === "esri") {
return new Cesium.UrlTemplateImageryProvider({
url: "https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
credit: "Esri",
});
}
if (key === "carto_light") {
return new Cesium.UrlTemplateImageryProvider({
url: "https://cartodb-basemaps-a.global.ssl.fastly.net/light_all/{z}/{x}/{y}.png",
credit: "CARTO",
});
}
if (key === "carto_dark") {
return new Cesium.UrlTemplateImageryProvider({
url: "https://cartodb-basemaps-a.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png",
credit: "CARTO",
});
}
return null;
}
function setBaseLayer(key) {
let layer = basemapLayers[key];
if (!layer) {
const provider = getBasemapProvider(key);
if (!provider) {
return;
}
layer = viewer.imageryLayers.addImageryProvider(provider);
basemapLayers[key] = layer;
}
Object.keys(basemapLayers).forEach((k) => {
basemapLayers[k].show = k === key;
});
viewer.imageryLayers.raiseToTop(layer);
}
function setTilesetStyle(style) {
if (osmBuildingsTileset) {
osmBuildingsTileset.style = style;
}
}
function colorByMaterial() {
setTilesetStyle(new Cesium.Cesium3DTileStyle({
defines: {
material: "${feature['building:material']}",
},
color: {
conditions: [
["${material} === null", "color('white')"],
["${material} === 'glass'", "color('skyblue', 0.5)"],
["${material} === 'concrete'", "color('grey')"],
["${material} === 'brick'", "color('indianred')"],
["${material} === 'stone'", "color('lightslategrey')"],
["${material} === 'metal'", "color('lightgrey')"],
["${material} === 'steel'", "color('lightsteelblue')"],
["true", "color('white')"],
],
},
}));
}
function highlightAllResidentialBuildings() {
setTilesetStyle(new Cesium.Cesium3DTileStyle({
color: {
conditions: [
[
"${feature['building']} === 'apartments' || ${feature['building']} === 'residential'",
"color('cyan', 0.9)",
],
["true", "color('white')"],
],
},
}));
}
function showByBuildingType(buildingType) {
if (!buildingType) {
return;
}
setTilesetStyle(new Cesium.Cesium3DTileStyle({
show: "${feature['building']} === '" + buildingType + "'",
}));
}
function colorByDistanceToCoordinate(pickedLatitude, pickedLongitude) {
setTilesetStyle(new Cesium.Cesium3DTileStyle({
defines: {
distance:
"distance(vec2(${feature['cesium#longitude']}, ${feature['cesium#latitude']}), " +
"vec2(" + pickedLongitude + "," + pickedLatitude + "))",
},
color: {
conditions: [
["${distance} > 0.014", "color('blue')"],
["${distance} > 0.010", "color('green')"],
["${distance} > 0.006", "color('yellow')"],
["${distance} > 0.0001", "color('red')"],
["true", "color('white')"],
],
},
}));
}
function removeCoordinatePickingOnLeftClick() {
if (infoPanel) {
infoPanel.style.visibility = "hidden";
}
handler.removeInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK);
}
menu.addEventListener("change", () => {
const idx = menu.selectedIndex;
if (idx === 0) {
removeCoordinatePickingOnLeftClick();
colorByMaterial();
} else if (idx === 1) {
if (infoPanel) {
infoPanel.style.visibility = "visible";
}
colorByDistanceToCoordinate(defaultLat, defaultLon);
handler.setInputAction((movement) => {
viewer.selectedEntity = undefined;
const pickedBuilding = viewer.scene.pick(movement.position);
if (pickedBuilding) {
const pickedLatitude = pickedBuilding.getProperty("cesium#latitude");
const pickedLongitude = pickedBuilding.getProperty("cesium#longitude");
if (pickedLatitude != null && pickedLongitude != null) {
colorByDistanceToCoordinate(pickedLatitude, pickedLongitude);
}
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
} else if (idx === 2) {
removeCoordinatePickingOnLeftClick();
highlightAllResidentialBuildings();
} else if (idx === 3) {
removeCoordinatePickingOnLeftClick();
showByBuildingType("office");
} else if (idx === 4) {
removeCoordinatePickingOnLeftClick();
showByBuildingType("apartments");
}
});
basemap.addEventListener("change", () => {
setBaseLayer(basemap.value);
});
setBaseLayer(basemap.value);
colorByMaterial();
})();
</script>
</body>
</html>

View File

@ -0,0 +1,92 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>OSM Roads Debug</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 16px;
}
#meta {
margin-bottom: 12px;
}
#map {
width: 900px;
height: 600px;
border: 1px solid #ccc;
display: block;
}
#empty {
color: #aa0000;
margin-top: 8px;
}
</style>
</head>
<body>
<div id="meta">
<div><strong>lat:</strong> {{ lat }} <strong>long:</strong> {{ long }} <strong>scale:</strong> {{ scale }}</div>
<div><strong>segments:</strong> <span id="count">0</span></div>
</div>
<svg id="map" viewBox="0 0 900 600" xmlns="http://www.w3.org/2000/svg"></svg>
<div id="empty" hidden>No road segments returned. This is a data issue.</div>
<script>
const roads = JSON.parse('{{ roads_json|escapejs }}');
const svg = document.getElementById("map");
const empty = document.getElementById("empty");
document.getElementById("count").textContent = String(roads.length);
if (!roads.length) {
empty.hidden = false;
} else {
const pts = [];
for (const r of roads) {
const rad = (r.yaw || 0) * Math.PI / 180;
const half = (r.length || 0) / 2;
const dx = Math.cos(rad) * half;
const dz = Math.sin(rad) * half;
pts.push([r.x - dx, r.z - dz]);
pts.push([r.x + dx, r.z + dz]);
}
let minX = Infinity, minZ = Infinity, maxX = -Infinity, maxZ = -Infinity;
for (const [x, z] of pts) {
if (x < minX) minX = x;
if (z < minZ) minZ = z;
if (x > maxX) maxX = x;
if (z > maxZ) maxZ = z;
}
const width = maxX - minX || 1;
const height = maxZ - minZ || 1;
const pad = 10;
const scaleX = (900 - pad * 2) / width;
const scaleY = (600 - pad * 2) / height;
const scale = Math.min(scaleX, scaleY);
for (const r of roads) {
const rad = (r.yaw || 0) * Math.PI / 180;
const half = (r.length || 0) / 2;
const dx = Math.cos(rad) * half;
const dz = Math.sin(rad) * half;
const x1 = (r.x - dx - minX) * scale + pad;
const y1 = 600 - ((r.z - dz - minZ) * scale + pad);
const x2 = (r.x + dx - minX) * scale + pad;
const y2 = 600 - ((r.z + dz - minZ) * scale + pad);
const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
line.setAttribute("x1", x1.toFixed(2));
line.setAttribute("y1", y1.toFixed(2));
line.setAttribute("x2", x2.toFixed(2));
line.setAttribute("y2", y2.toFixed(2));
line.setAttribute("stroke", r.color || "#666666");
line.setAttribute("stroke-width", Math.max((r.width || 1) * scale * 0.1, 0.5));
line.setAttribute("stroke-linecap", "round");
svg.appendChild(line);
}
}
</script>
</body>
</html>

View File

@ -4,6 +4,8 @@ from . import views
urlpatterns = [
# Digital Twin (normal)
path('city/digital/twin/<uuid:city_id>/', views.city_digital_twin, name='city_digital_twin_uuid'),
path('city/digital/twin/cesium_ion/', views.city_digital_twin_cesium_ion, name='city_digital_twin_cesium_ion'),
path('city/digital/twin/osm/roads/debug/', views.osm_roads_debug, name='osm_roads_debug'),
path('city/digital/twin/<str:city_id>/', views.city_digital_twin, name='city_digital_twin_str'),
# Augmented Digital Twin

View File

@ -1,7 +1,9 @@
from django.shortcuts import render, get_object_or_404
from django.http import Http404
from django.conf import settings
import random
import math
import json
from .services.presets import get_environment_preset
import networkx as nx
from .services.layouts import (
@ -166,6 +168,44 @@ def city_augmented_digital_twin(request, city_id):
raise Http404("Invalid parameters provided.")
def city_digital_twin_cesium_ion(request):
try:
lat = float(request.GET.get("lat", 0))
long = float(request.GET.get("long", 0))
except (ValueError, TypeError):
raise Http404("Invalid parameters provided.")
context = {
"lat": lat,
"long": long,
"ion_token": 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiJkZmM5ZmE1MS0yNWFlLTQ1YjEtOGZiNy1lMmFlOGYyYTI0MjYiLCJpZCI6Mzc0NDg3LCJpYXQiOjE3Njc0MTAxNzN9.tFBLet8CEmvfCivzZM5ExfHL752iPQIShw6Pqa49Gkw', # settings.CESIUM_ION_TOKEN,
}
return render(
request,
"pxy_city_digital_twins/city_digital_twin_cesium_ion.html",
context,
)
def osm_roads_debug(request):
try:
lat = float(request.GET.get("lat", 0))
long = float(request.GET.get("long", 0))
scale = float(request.GET.get("scale", 1.0))
except (ValueError, TypeError):
raise Http404("Invalid parameters provided.")
city_data = generate_osm_city_data(lat, long, scale=scale)
roads_json = json.dumps(city_data.get("roads", []))
context = {
"lat": lat,
"long": long,
"scale": scale,
"roads_json": roads_json,
}
return render(request, "pxy_city_digital_twins/osm_roads_debug.html", context)
from django.shortcuts import render
from django.http import Http404
from .services.waste_routes import get_dispatch_data_for