Ignore backup folder in pxy_city_digital_twins

This commit is contained in:
Ekaropolus 2025-05-13 04:16:26 -06:00
parent eeeb5d8ed2
commit f9b6f0b50d
21 changed files with 942 additions and 1 deletions

1
.gitignore vendored
View File

@ -26,3 +26,4 @@ db.sqlite3
# System
.DS_Store
pxy_city_digital_twins/__backup__/

@ -1 +0,0 @@
Subproject commit bbce8d7a011fb0d58801f9de5fd022f13da87bc9

View File

@ -0,0 +1,75 @@
# Polisplexity Digital Twin Viewer
This application is a Django-based 3D digital twin city renderer using A-Frame and real-world OpenStreetMap (OSM) data. It allows visualization of buildings, fiber paths, cell towers, and other urban infrastructure in a simulated, interactive WebVR environment.
## ✨ Features
- 🔲 **Building extrusion from OSM**: Downloads building footprints with geometry and height/levels metadata and extrudes them into 3D blocks.
- 🛰️ **Street network rendering**: Downloads local driving network and represents it visually as 3D fiber links.
- 🏙️ **Recentered city layout**: All elements are normalized to a `(0,0)` coordinate center and scaled down to allow a birds-eye view or giant-perspective simulation.
- 📡 **A-Frame-based environment**: Uses `aframe-environment-component` for sky, lighting, ground, and interactions.
- 🎯 **Status gauges**: Each building displays a status gauge with a rotating ring and transparent glass core, labeled with mock status data.
- 🧠 **Per-entity click interaction**: Clicking on a gauge changes its color and toggles the status (mocked).
- 🌐 **Dynamic generation by coordinates**: Any city view can be created dynamically via URL parameters like `lat`, `long`, and `scale`.
## 🏗️ Stack
| Component | Technology |
|--------------------|-----------------------------|
| Backend | Django 5.x |
| Mapping API | `osmnx`, `shapely`, `geopandas` |
| Frontend (3D) | A-Frame 1.7.0 |
| Visualization Libs | `aframe-environment-component` |
| Deployment Ready? | Yes, via Docker + Gunicorn |
## 🔌 Example Usage
To load a city block from Centro Histórico, Mexico City:
```
[http://localhost:8001/city/digital/twin/osm\_city/?lat=19.391097\&long=-99.157815\&scale=0.1](http://localhost:8001/city/digital/twin/osm_city/?lat=19.391097&long=-99.157815&scale=0.1)
````
## 🧪 Directory Highlights
- `pxy_city_digital_twins/views.py`: Request handler that decides which generator to use (`osm_city`, `random_city`, etc.)
- `services/osm_city.py`: Main generator for real-world urban geometry based on lat/lon.
- `templates/pxy_city_digital_twins/city_digital_twin.html`: A-Frame scene renderer.
- `templates/pxy_city_digital_twins/_status_gauge.html`: UI fragment for interactive gauges on city elements.
## 📦 Dependencies
Add these to `requirements.txt`:
```txt
osmnx>=1.9.3
shapely
geopandas
````
Optional (for better performance in prod):
```txt
gunicorn
dj-database-url
```
## 🚧 To-Do
* [ ] Load `status` from a real database or agent simulation
* [ ] Add 3D models (e.g., trees, street furniture)
* [ ] Support texture-mapped facades
* [ ] Add time-based simulation / animation
* [ ] Integrate sensor/IoT mock data stream
## 👀 Screenshot
> *Coming soon* — consider generating A-Frame scene screenshots automatically using headless browser tools.
---
**Maintained by [Hadox Research Labs](https://hadox.org)**

@ -0,0 +1 @@
Subproject commit ec7de54fe9464aef7cad6895d7f87df4e3239cda

View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class PxyCityDigitalTwinsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "pxy_city_digital_twins"

View File

@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

View File

@ -0,0 +1,155 @@
import random
from .network import compute_mst_fiber_paths, compute_network_summary
GRID_SIZE = 5
SPACING = 15 # Distance between objects
def generate_com_con_city_data(lat, long):
"""
Generate a digital twin for a real-world city (e.g., Concepción).
Returns towers, fiber paths, wifi hotspots, and a summary.
"""
random.seed(f"{lat},{long}")
center_x = lat
center_z = long
towers = generate_towers(center_x, center_z)
fiber_paths = compute_mst_fiber_paths(towers)
wifi_hotspots = generate_wifi_hotspots(center_x, center_z)
summary = compute_network_summary(towers, fiber_paths, wifi_hotspots)
return {
'towers': towers,
'fiber_paths': fiber_paths,
'wifi_hotspots': wifi_hotspots,
'network_summary': summary,
}
def generate_towers(center_x, center_z, mode="streets"):
"""
Generate towers either in a 'grid' or at realistic 'streets' (mocked).
mode: "grid" | "streets"
"""
if mode == "streets":
return generate_street_corner_towers(center_x, center_z)
else:
return generate_grid_towers(center_x, center_z)
import osmnx as ox
def generate_street_corner_towers(center_x, center_z, min_towers=10):
"""
Get real intersections from OSM and convert them to local x/z positions
relative to center_x / center_z (in meters). Fallbacks to mocked layout if needed.
"""
print("📍 Starting generate_street_corner_towers()")
print(f"→ center_x: {center_x}, center_z: {center_z}")
point = (center_x, center_z)
print(f"→ Using real lat/lon: {point}")
try:
for dist in [100, 200, 500, 1000]:
print(f"🛰️ Trying OSM download at radius: {dist} meters...")
G = ox.graph_from_point(point, dist=dist, network_type='all')
G_undirected = G.to_undirected()
degrees = dict(G_undirected.degree())
intersections = [n for n, d in degrees.items() if d >= 3]
print(f" ✅ Found {len(intersections)} valid intersections.")
if len(intersections) >= min_towers:
break
else:
raise ValueError("No sufficient intersections found.")
nodes, _ = ox.graph_to_gdfs(G)
origin_lon = nodes.loc[intersections]['x'].mean()
origin_lat = nodes.loc[intersections]['y'].mean()
print(f"📌 Using origin_lon: {origin_lon:.6f}, origin_lat: {origin_lat:.6f} for local projection")
def latlon_to_sim(lon, lat):
dx = (lon - origin_lon) * 111320
dz = (lat - origin_lat) * 110540
return center_x + dx, center_z + dz
towers = []
for i, node_id in enumerate(intersections):
row = nodes.loc[node_id]
x_sim, z_sim = latlon_to_sim(row['x'], row['y'])
print(f" 🗼 Tower #{i+1} at sim position: x={x_sim:.2f}, z={z_sim:.2f}")
towers.append(make_tower(x_sim, z_sim, i + 1))
print(f"✅ Done. Total towers returned: {len(towers)}\n")
return towers
except Exception as e:
print(f"❌ OSM tower generation failed: {e}")
print("⚠️ Falling back to mocked tower layout.")
# Return 3x3 fixed grid as fallback
offsets = [(-30, -30), (-30, 0), (-30, 30),
(0, -30), (0, 0), (0, 30),
(30, -30), (30, 0), (30, 30)]
towers = []
for i, (dx, dz) in enumerate(offsets):
x = center_x + dx
z = center_z + dz
towers.append(make_tower(x, z, i + 1))
print(f"✅ Fallback returned {len(towers)} towers.\n")
return towers
def generate_grid_towers(center_x, center_z):
"""Generates a 5×5 grid of towers around the city center."""
towers = []
for i in range(GRID_SIZE):
for j in range(GRID_SIZE):
x = center_x + (i - GRID_SIZE // 2) * SPACING
z = center_z + (j - GRID_SIZE // 2) * SPACING
towers.append({
'id': len(towers) + 1,
'status': 'Active' if random.random() > 0.2 else 'Inactive',
'position_x': x,
'position_y': 0,
'position_z': z,
'height': random.randint(40, 60),
'range': random.randint(500, 1000),
'color': '#ff4500'
})
return towers
def generate_wifi_hotspots(center_x, center_z):
"""Places 10 Wi-Fi hotspots randomly around the city center."""
hotspots = []
bound = SPACING * GRID_SIZE / 2
for i in range(10):
x = center_x + random.uniform(-bound, bound)
z = center_z + random.uniform(-bound, bound)
hotspots.append({
'id': i + 1,
'position_x': x,
'position_y': 1.5,
'position_z': z,
'status': 'Online' if random.random() > 0.2 else 'Offline',
'radius': random.randint(1, 3),
'color': '#32cd32'
})
return hotspots
def make_tower(x, z, id):
return {
'id': id,
'status': 'Active',
'position_x': x,
'position_y': 0,
'position_z': z,
'height': 50,
'range': 1000,
'color': '#ff4500'
}

View File

@ -0,0 +1,45 @@
import math
def rectangular_layout(num_elements, max_dimension):
grid_size = int(math.sqrt(num_elements))
spacing = max_dimension // grid_size
return [
{
'position_x': (i % grid_size) * spacing,
'position_z': (i // grid_size) * spacing
}
for i in range(num_elements)
]
def circular_layout(num_elements, radius):
return [
{
'position_x': radius * math.cos(2 * math.pi * i / num_elements),
'position_z': radius * math.sin(2 * math.pi * i / num_elements)
}
for i in range(num_elements)
]
def diagonal_layout(num_elements, max_position):
return [
{
'position_x': i * max_position // num_elements,
'position_z': i * max_position // num_elements
}
for i in range(num_elements)
]
def triangular_layout(num_elements):
positions = []
row_length = 1
while num_elements > 0:
for i in range(row_length):
if num_elements <= 0:
break
positions.append({
'position_x': i * 10 - (row_length - 1) * 5, # Spread out each row symmetrically
'position_z': row_length * 10
})
num_elements -= 1
row_length += 1
return positions

View File

@ -0,0 +1,63 @@
import networkx as nx
import math
def compute_distance(t1, t2):
"""
Compute Euclidean distance between two towers in the horizontal plane.
"""
dx = t1['position_x'] - t2['position_x']
dz = t1['position_z'] - t2['position_z']
return math.sqrt(dx**2 + dz**2)
def compute_mst_fiber_paths(towers):
"""
Given a list of tower dictionaries, compute a Minimum Spanning Tree (MST)
and return a list of fiber paths connecting the towers.
"""
G = nx.Graph()
# Add towers as nodes
for tower in towers:
G.add_node(tower['id'], **tower)
# Add edges: compute pairwise distances
n = len(towers)
for i in range(n):
for j in range(i+1, n):
d = compute_distance(towers[i], towers[j])
G.add_edge(towers[i]['id'], towers[j]['id'], weight=d)
# Compute MST
mst = nx.minimum_spanning_tree(G)
fiber_paths = []
for edge in mst.edges(data=True):
id1, id2, data = edge
# Find towers corresponding to these IDs
tower1 = next(t for t in towers if t['id'] == id1)
tower2 = next(t for t in towers if t['id'] == id2)
fiber_paths.append({
'id': len(fiber_paths) + 1,
'start_x': tower1['position_x'],
'start_z': tower1['position_z'],
'end_x': tower2['position_x'],
'end_z': tower2['position_z'],
'mid_x': (tower1['position_x'] + tower2['position_x']) / 2,
'mid_y': 0.1, # Slightly above the ground
'mid_z': (tower1['position_z'] + tower2['position_z']) / 2,
'length': data['weight'],
# Optionally, compute the angle in degrees if needed:
'angle': math.degrees(math.atan2(tower2['position_x'] - tower1['position_x'],
tower2['position_z'] - tower1['position_z'])),
'status': 'Connected',
'color': '#4682b4'
})
return fiber_paths
def compute_network_summary(towers, fiber_paths, wifi_hotspots):
total_fiber = sum(fiber['length'] for fiber in fiber_paths)
return {
'num_towers': len(towers),
'total_fiber_length': total_fiber,
'num_wifi': len(wifi_hotspots),
}

View File

@ -0,0 +1,77 @@
import osmnx as ox
import shapely
import random
import uuid
import networkx as nx
from matplotlib import cm
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"]
# ————— 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")
# ————— BUILDINGS —————
tags = {"building": True}
gdf = ox.features_from_point((lat, lon), tags=tags, dist=dist)
gdf = gdf[gdf.geometry.type.isin(["Polygon", "MultiPolygon"])].to_crs(epsg=3857)
gdf["centroid"] = gdf.geometry.centroid
raw_buildings = []
for i, row in gdf.iterrows():
centroid = row["centroid"]
polygon = row["geometry"]
building_id = f"BLD-{uuid.uuid4().hex[:6].upper()}"
status = random.choice(status_options)
try:
height = float(row.get("height", None))
except:
height = float(row.get("building:levels", 3)) * 3.2 if row.get("building:levels") else 10.0
try:
node = ox.distance.nearest_nodes(G, X=centroid.x, Y=centroid.y)
node_degree = degree.get(node, 0)
except:
node_degree = 0
norm_value = node_degree / max_degree
rgba = color_map(norm_value)
hex_color = '#%02x%02x%02x' % tuple(int(c * 255) for c in rgba[:3])
raw_buildings.append({
"id": building_id,
"raw_x": centroid.x,
"raw_z": centroid.y,
"width": polygon.bounds[2] - polygon.bounds[0],
"depth": polygon.bounds[3] - polygon.bounds[1],
"height": height,
"color": hex_color,
"status": status,
})
# ————— 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)
buildings = [{
"id": b['id'],
"position_x": (b['raw_x'] - avg_x) * scale_factor,
"position_z": (b['raw_z'] - avg_z) * scale_factor,
"width": b['width'] * scale_factor,
"depth": b['depth'] * scale_factor,
"height": b['height'] * scale_factor,
"color": b['color'],
"status": b['status'],
} for b in raw_buildings]
else:
buildings = []
return {"buildings": buildings}

View File

@ -0,0 +1,25 @@
def get_environment_preset(lat, long):
"""
Determines the A-Frame environment preset based on latitude and longitude.
You can adjust the logic to suit your needs.
"""
# Example logic: adjust these thresholds as needed
if lat >= 60 or lat <= -60:
return 'snow' # Polar regions: snow environment
elif lat >= 30 or lat <= -30:
return 'forest' # Mid-latitudes: forest environment
elif long >= 100:
return 'goldmine' # Arbitrary example: for far east longitudes, a 'goldmine' preset
else:
return 'desert' # Default to desert for lower latitudes and moderate longitudes
def get_environment_by_lat(lat):
if lat > 60 or lat < -60:
return 'yeti'
elif 30 < lat < 60 or -30 > lat > -60:
return 'forest'
else:
return 'desert'

View File

@ -0,0 +1,81 @@
import random
from .layouts import rectangular_layout, circular_layout, diagonal_layout, triangular_layout
def generate_random_city_data(innovation_pct=100, technology_pct=100, science_pct=100, max_position=100, radius=50):
num_buildings = random.randint(5, 35)
num_lamps = random.randint(5, 100)
num_trees = random.randint(5, 55)
# Buildings layout distribution
num_rectangular_buildings = int(num_buildings * innovation_pct / 100)
num_circular_buildings = (num_buildings - num_rectangular_buildings) // 2
num_triangular_buildings = num_buildings - num_rectangular_buildings - num_circular_buildings
building_positions = rectangular_layout(num_rectangular_buildings, max_position) + \
circular_layout(num_circular_buildings, radius) + \
triangular_layout(num_triangular_buildings)
# Lamps layout distribution
num_triangular_lamps = int(num_lamps * technology_pct / 100)
num_circular_lamps = (num_lamps - num_triangular_lamps) // 2
num_diagonal_lamps = num_lamps - num_triangular_lamps - num_circular_lamps
lamp_positions = triangular_layout(num_triangular_lamps) + \
circular_layout(num_circular_lamps, radius) + \
diagonal_layout(num_diagonal_lamps, max_position)
# Trees layout distribution
num_circular_trees = int(num_trees * science_pct / 100)
num_triangular_trees = (num_trees - num_circular_trees) // 2
num_diagonal_trees = num_trees - num_circular_trees - num_triangular_trees
tree_positions = circular_layout(num_circular_trees, radius) + \
triangular_layout(num_triangular_trees) + \
diagonal_layout(num_diagonal_trees, max_position)
buildings = [
{
'id': i + 1,
'status': random.choice(['Occupied', 'Vacant', 'Under Construction']),
'position_x': pos['position_x'],
'position_z': pos['position_z'],
'height': random.randint(10, 50),
'width': random.randint(5, 20),
'depth': random.randint(5, 20),
'color': random.choice(['#8a2be2', '#5f9ea0', '#ff6347', '#4682b4']),
'file': ''
} for i, pos in enumerate(building_positions)
]
lamps = [
{
'id': i + 1,
'status': random.choice(['Functional', 'Non-functional']),
'position_x': pos['position_x'],
'position_z': pos['position_z'],
'height': random.randint(3, 10),
'color': random.choice(['#ffff00', '#ff0000', '#00ff00']),
} for i, pos in enumerate(lamp_positions)
]
trees = [
{
'id': i + 1,
'status': random.choice(['Healthy', 'Diseased', 'Wilting']),
'position_x': pos['position_x'],
'position_z': pos['position_z'],
'height': random.randint(5, 30),
'radius_bottom': random.uniform(0.1, 0.5),
'radius_top': random.uniform(0.5, 2.0),
'color_trunk': '#8b4513',
'color_leaves': random.choice(['#228b22', '#90ee90', '#8b4513']),
} for i, pos in enumerate(tree_positions)
]
return {
'buildings': buildings,
'lamps': lamps,
'trees': trees,
}

View File

@ -0,0 +1,38 @@
<a-entity
class="status-gauge"
gauge-click-toggle
position="0 {{ offset_y|default:'3' }} 0"
scale="0.3 0.3 0.3">
<!-- Glass core -->
<a-circle
radius="0.6"
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="0.7"
radius-outer="0.9"
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>
</a-entity>

View File

@ -0,0 +1,218 @@
{% load static %}
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Digital Twin City</title>
<!-- A-Frame 1.7.0 & 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>
<!-- 1) Simple “look-at” component to face the camera -->
<script>
AFRAME.registerComponent('billboard', {
schema: {type: 'selector'},
tick: function () {
if (!this.data) return;
// Make this entity face the camera each frame
this.el.object3D.lookAt(this.data.object3D.position);
}
});
</script>
<script>
AFRAME.registerComponent('gauge-click-toggle', {
init: function () {
const colors = ["#00FF00", "#FFFF00", "#FF0000", "#00FFFF"];
const statuses = ["OK", "Warning", "Critical", "Offline"];
let i = 0;
const ring = this.el.querySelector('.gauge-ring');
const label = this.el.querySelector('.gauge-label');
this.el.addEventListener('click', () => {
i = (i + 1) % colors.length;
if (ring) ring.setAttribute('color', colors[i]);
if (label) {
const current = label.getAttribute('value');
const idLine = current.split("\n")[0]; // Preserve ID line
label.setAttribute('value', `${idLine}\n${statuses[i]}`);
}
});
}
});
</script>
</head>
<body>
<a-scene environment="preset: forest; 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">
<a-cursor color="#FF0000"></a-cursor>
</a-entity>
<!-- Optional: Transparent ground plane (comment out if you want only environment ground) -->
<a-plane position="0 -0.1 0" rotation="-90 0 0"
width="200" height="200"
color="#444" opacity="0.3">
</a-plane>
<!-- Buildings -->
{% for building in city_data.buildings %}
<a-entity id="{{ building.id }}" status="{{ building.status }}">
<!-- Building geometry -->
<a-box position="{{ building.position_x }} 1 {{ building.position_z }}"
width="{{ building.width }}" height="{{ building.height }}" depth="{{ building.depth }}"
color="{{ building.color }}">
</a-box>
<a-entity
position="{{ building.position_x }} {{ building.height }} {{ building.position_z }}">
{% include "pxy_city_digital_twins/_status_gauge.html" with ring_color="#00FFFF" offset_y="1.50" status=building.status id=building.id %}
</a-entity>
</a-entity>
{% endfor %}
<!-- Lamps -->
{% for lamp in city_data.lamps %}
<a-entity id="{{ lamp.id }}" status="{{ lamp.status }}" position="{{ lamp.position_x }} 1 {{ lamp.position_z }}">
<!-- Lamp geometry -->
<a-cone radius-bottom="0.1" radius-top="0.5"
height="{{ lamp.height }}" color="{{ lamp.color }}">
</a-cone>
<a-sphere radius="0.2" color="#FFFFFF"
position="0 {{ lamp.height }} 0">
</a-sphere>
<!-- Label: billboard to camera -->
<a-entity position="0 3 0" billboard="#mainCamera">
<a-plane width="4" height="1.2" color="#000" opacity="0.5"></a-plane>
<a-text value="Status: {{ lamp.status }}"
width="4"
align="center"
color="#FFF"
position="0 0 0.01">
</a-text>
</a-entity>
</a-entity>
{% endfor %}
<!-- Trees -->
{% for tree in city_data.trees %}
<a-entity id="{{ tree.id }}" status="{{ tree.status }}" position="{{ tree.position_x }} 1 {{ tree.position_z }}">
<!-- Tree trunk & leaves -->
<a-cone radius-bottom="{{ tree.radius_bottom }}"
radius-top="{{ tree.radius_top }}"
height="{{ tree.height }}"
color="{{ tree.color_trunk }}">
</a-cone>
<a-sphere radius="{{ tree.radius_top }}"
color="{{ tree.color_leaves }}"
position="0 {{ tree.height }} 0">
</a-sphere>
<!-- Label: billboard to camera -->
<a-entity position="0 3 0" billboard="#mainCamera">
<a-plane width="4" height="1.2" color="#000" opacity="0.5"></a-plane>
<a-text value="Status: {{ tree.status }}"
width="4"
align="center"
color="#FFF"
position="0 0 0.01">
</a-text>
</a-entity>
</a-entity>
{% endfor %}
<!-- Cell Towers -->
{% for tower in city_data.towers %}
<a-entity id="tower{{ tower.id }}"
position="{{ tower.position_x }} {{ tower.position_y }} {{ tower.position_z }}">
<!-- Base tower cylinder -->
<a-cylinder height="{{ tower.height }}" radius="1" color="{{ tower.color }}"></a-cylinder>
<!-- Animated signal ring near top -->
<a-ring color="#FF0000"
radius-inner="2"
radius-outer="2.5"
position="0 {{ tower.height|add:'1' }} 0"
rotation="-90 0 0"
animation="property: scale; to: 1.5 1.5 1.5; dir: alternate; dur: 1000; loop: true">
</a-ring>
<!-- Tower label: billboard to camera -->
<a-entity position="0 -5 0" billboard="#mainCamera">
<a-plane width="4" height="1.2" color="#000" opacity="0.5"></a-plane>
<a-text value="📡 Tower {{ tower.id }} - {{ tower.status }}"
width="4"
align="center"
color="#FFF"
position="0 0 0.01">
</a-text>
</a-entity>
</a-entity>
{% endfor %}
<!-- Fiber Paths (Cylinders) -->
{% for fiber in city_data.fiber_paths %}
<a-entity>
<a-cylinder position="{{ fiber.mid_x }} {{ fiber.mid_y }} {{ fiber.mid_z }}"
height="{{ fiber.length }}"
radius="0.1"
rotation="90 {{ fiber.angle }} 0"
color="{{ fiber.color }}">
</a-cylinder>
<!-- Fiber label: billboard to camera -->
<a-entity position="{{ fiber.start_x }} 3 {{ fiber.start_z }}" billboard="#mainCamera">
<a-plane width="4" height="1.2" color="#000" opacity="0.5"></a-plane>
<a-text value="🔗 Fiber Path {{ fiber.id }} - {{ fiber.status }}"
width="4"
align="center"
color="{{ fiber.color }}"
position="0 0 0.01">
</a-text>
</a-entity>
</a-entity>
{% endfor %}
<!-- Wi-Fi Hotspots -->
{% for wifi in city_data.wifi_hotspots %}
<a-entity id="wifi{{ wifi.id }}"
position="{{ wifi.position_x }} {{ wifi.position_y }} {{ wifi.position_z }}">
<!-- Hotspot sphere (animated) -->
<a-sphere radius="{{ wifi.radius }}" color="{{ wifi.color }}"
animation="property: scale; to: 1.5 1.5 1.5; dir: alternate; dur: 1500; loop: true">
</a-sphere>
<!-- Coverage area (fixed or dynamic) -->
<a-sphere radius="5"
color="#00FFFF"
opacity="0.2"
position="0 {{ wifi.radius }} 0">
</a-sphere>
<!-- Wi-Fi label: billboard to camera -->
<a-entity position="0 3 0" billboard="#mainCamera">
<a-plane width="4" height="1.2" color="#000" opacity="0.5"></a-plane>
<a-text value="📶 WiFi {{ wifi.id }} - {{ wifi.status }}"
width="4"
align="center"
color="#FFF"
position="0 0 0.01">
</a-text>
</a-entity>
</a-entity>
{% endfor %}
</a-scene>
</body>
</html>

View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@ -0,0 +1,11 @@
from django.urls import path
from . import views
urlpatterns = [
# Pattern to accept UUIDs
path('city/digital/twin/<uuid:city_id>/', views.city_digital_twin, name='city_digital_twin_uuid'),
# Pattern to accept string words
path('city/digital/twin/<str:city_id>/', views.city_digital_twin, name='city_digital_twin_str'),
]

View File

@ -0,0 +1,137 @@
from django.shortcuts import render, get_object_or_404
from django.http import Http404
import random
import math
from .services.presets import get_environment_preset
import networkx as nx
from .services.layouts import (
rectangular_layout,
circular_layout,
diagonal_layout,
triangular_layout,
)
from .services.random_city import generate_random_city_data
from .services.com_con_city import generate_com_con_city_data
from .services.osm_city import generate_osm_city_data
def city_digital_twin(request, city_id, innovation_pct=None, technology_pct=None, science_pct=None):
try:
lat = float(request.GET.get('lat', 0))
long = float(request.GET.get('long', 0))
scale = float(request.GET.get('scale', 1.0)) # default to 1.0 (normal scale)
if city_id == "osm_city":
city_data = generate_osm_city_data(lat, long,scale=scale)
elif city_id == "com_con":
city_data = generate_com_con_city_data(lat, long)
elif city_id == "random_city":
city_data = generate_random_city_data()
elif city_id == "dream":
innovation_pct = innovation_pct or request.GET.get('innovation', 0)
technology_pct = technology_pct or request.GET.get('technology', 0)
science_pct = science_pct or request.GET.get('science', 0)
innovation_pct = int(innovation_pct)
technology_pct = int(technology_pct)
science_pct = int(science_pct)
city_data = generate_random_city_data(innovation_pct, technology_pct, science_pct)
else:
city_data = get_city_data(city_id)
if not city_data:
city_data = get_example_data()
preset = get_environment_preset(lat, long)
context = {
'city_data': city_data,
'environment_preset': preset,
'lat': lat,
'long': long,
}
return render(request, 'pxy_city_digital_twins/city_digital_twin.html', context)
except (ValueError, TypeError):
raise Http404("Invalid data provided.")
def get_city_data(city_id):
# Implement fetching logic here
# This is a mock function to demonstrate fetching logic
if str(city_id) == "1" or str(city_id) == "123e4567-e89b-12d3-a456-426614174000":
return {
# Real data retrieval logic goes here
}
return None
def get_example_data():
return {
'buildings': [
{
'id': 1,
'status': 'Occupied',
'position_x': 0,
'height': 10,
'position_z': 0,
'width': 5,
'depth': 5,
'color': '#8a2be2',
'file': '', # No file for a simple box representation
},
{
'id': 2,
'status': 'Vacant',
'position_x': 10,
'height': 15,
'position_z': 10,
'width': 7,
'depth': 7,
'color': '#5f9ea0',
'file': '', # No file for a simple box representation
}
],
'lamps': [
{
'id': 1,
'status': 'Functional',
'position_x': 3,
'position_z': 3,
'height': 4,
'color': '#ffff00',
},
{
'id': 2,
'status': 'Broken',
'position_x': 8,
'position_z': 8,
'height': 4,
'color': '#ff0000',
}
],
'trees': [
{
'id': 1,
'status': 'Healthy',
'position_x': 5,
'position_z': 5,
'height': 6,
'radius_bottom': 0.2,
'radius_top': 1,
'color_trunk': '#8b4513',
'color_leaves': '#228b22',
},
{
'id': 2,
'status': 'Diseased',
'position_x': 15,
'position_z': 15,
'height': 6,
'radius_bottom': 0.2,
'radius_top': 1,
'color_trunk': '#a0522d',
'color_leaves': '#6b8e23',
}
]
}