diff --git a/.gitignore b/.gitignore
index 125e2a8..4319204 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,3 +26,4 @@ db.sqlite3
# System
.DS_Store
+pxy_city_digital_twins/__backup__/
diff --git a/pxy_city_digital_twins b/pxy_city_digital_twins
deleted file mode 160000
index bbce8d7..0000000
--- a/pxy_city_digital_twins
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit bbce8d7a011fb0d58801f9de5fd022f13da87bc9
diff --git a/pxy_city_digital_twins/README.md b/pxy_city_digital_twins/README.md
new file mode 100644
index 0000000..d612092
--- /dev/null
+++ b/pxy_city_digital_twins/README.md
@@ -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 birdβs-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)**
+
+
diff --git a/pxy_city_digital_twins/__backup__ b/pxy_city_digital_twins/__backup__
new file mode 160000
index 0000000..ec7de54
--- /dev/null
+++ b/pxy_city_digital_twins/__backup__
@@ -0,0 +1 @@
+Subproject commit ec7de54fe9464aef7cad6895d7f87df4e3239cda
diff --git a/.gitmodules b/pxy_city_digital_twins/__init__.py
similarity index 100%
rename from .gitmodules
rename to pxy_city_digital_twins/__init__.py
diff --git a/pxy_city_digital_twins/admin.py b/pxy_city_digital_twins/admin.py
new file mode 100644
index 0000000..8c38f3f
--- /dev/null
+++ b/pxy_city_digital_twins/admin.py
@@ -0,0 +1,3 @@
+from django.contrib import admin
+
+# Register your models here.
diff --git a/pxy_city_digital_twins/apps.py b/pxy_city_digital_twins/apps.py
new file mode 100644
index 0000000..e3aa222
--- /dev/null
+++ b/pxy_city_digital_twins/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class PxyCityDigitalTwinsConfig(AppConfig):
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "pxy_city_digital_twins"
diff --git a/pxy_city_digital_twins/migrations/__init__.py b/pxy_city_digital_twins/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/pxy_city_digital_twins/models.py b/pxy_city_digital_twins/models.py
new file mode 100644
index 0000000..71a8362
--- /dev/null
+++ b/pxy_city_digital_twins/models.py
@@ -0,0 +1,3 @@
+from django.db import models
+
+# Create your models here.
diff --git a/pxy_city_digital_twins/services/__init__.py b/pxy_city_digital_twins/services/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/pxy_city_digital_twins/services/com_con_city.py b/pxy_city_digital_twins/services/com_con_city.py
new file mode 100644
index 0000000..c3b7237
--- /dev/null
+++ b/pxy_city_digital_twins/services/com_con_city.py
@@ -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'
+ }
+
diff --git a/pxy_city_digital_twins/services/layouts.py b/pxy_city_digital_twins/services/layouts.py
new file mode 100644
index 0000000..3b00306
--- /dev/null
+++ b/pxy_city_digital_twins/services/layouts.py
@@ -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
diff --git a/pxy_city_digital_twins/services/network.py b/pxy_city_digital_twins/services/network.py
new file mode 100644
index 0000000..4419bec
--- /dev/null
+++ b/pxy_city_digital_twins/services/network.py
@@ -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),
+ }
\ No newline at end of file
diff --git a/pxy_city_digital_twins/services/osm_city.py b/pxy_city_digital_twins/services/osm_city.py
new file mode 100644
index 0000000..a6191c4
--- /dev/null
+++ b/pxy_city_digital_twins/services/osm_city.py
@@ -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}
diff --git a/pxy_city_digital_twins/services/presets.py b/pxy_city_digital_twins/services/presets.py
new file mode 100644
index 0000000..f5685a1
--- /dev/null
+++ b/pxy_city_digital_twins/services/presets.py
@@ -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'
diff --git a/pxy_city_digital_twins/services/random_city.py b/pxy_city_digital_twins/services/random_city.py
new file mode 100644
index 0000000..60d5655
--- /dev/null
+++ b/pxy_city_digital_twins/services/random_city.py
@@ -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,
+ }
+
diff --git a/pxy_city_digital_twins/templates/pxy_city_digital_twins/_status_gauge.html b/pxy_city_digital_twins/templates/pxy_city_digital_twins/_status_gauge.html
new file mode 100644
index 0000000..97c4b6f
--- /dev/null
+++ b/pxy_city_digital_twins/templates/pxy_city_digital_twins/_status_gauge.html
@@ -0,0 +1,38 @@
+