Building digital Twin 2D and 3D
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Ekaropolus 2025-09-03 11:30:29 -06:00
parent aad3123121
commit 7d1d4a43bd
16 changed files with 55168 additions and 0 deletions

View File

@ -50,6 +50,7 @@ INSTALLED_APPS = [
"pxy_dashboard.apps", "pxy_dashboard.apps",
"pxy_dashboard.components", "pxy_dashboard.components",
"pxy_dashboard.layouts", "pxy_dashboard.layouts",
"pxy_building_digital_twins",
# Third-party apps # Third-party apps
"crispy_forms", "crispy_forms",

View File

@ -32,6 +32,7 @@ urlpatterns = [
path('pxy_whatsapp/', include('pxy_whatsapp.urls')), path('pxy_whatsapp/', include('pxy_whatsapp.urls')),
path('bots/', include('pxy_bots.urls')), path('bots/', include('pxy_bots.urls')),
path('pxy_meta_pages/', include('pxy_meta_pages.urls', namespace='pxy_meta_pages')), path('pxy_meta_pages/', include('pxy_meta_pages.urls', namespace='pxy_meta_pages')),
path("building/", include("pxy_building_digital_twins.urls")),
] ]

View File

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 PxyBuildingDigitalTwinsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'pxy_building_digital_twins'

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,131 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Building Twin — Random Stadium (Backend)</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" rel="stylesheet" crossorigin="">
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
<style>
html, body { height:100%; margin:0; background:#0b1220; }
#map { position:fixed; inset:0; }
#legend {
position: fixed; top: 12px; left: 12px; z-index: 1000;
background: #0f172a; color: #e5e7eb; padding: 10px 12px; border-radius: 10px;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
box-shadow: 0 10px 30px rgba(0,0,0,.35); font-size: 12px; line-height: 1.25;
}
.pill{display:inline-block; padding:2px 6px; border-radius:999px; margin-right:6px}
.p-field{background:#16a34a;color:#052e16}
.p-stands{background:#64748b;color:#0b1220}
.p-conc{background:#94a3b8;color:#0b1220}
.k-org{background:#0ea5e9} .k-rec{background:#10b981}
.k-lf{background:#8b5cf6} .k-sp{background:#b45309}
.k-dt{background:#7c3aed}
.btn { background:#1f2937; border:1px solid #334155; color:#e5e7eb;
padding:6px 10px; border-radius:8px; cursor:pointer }
.btn:hover { background:#111827; }
</style>
</head>
<body>
<div id="legend">
<div style="font-weight:600; margin-bottom:6px;">Random Stadium (backend)</div>
<div style="margin-bottom:6px">
<span class="pill p-field">Field</span>
<span class="pill p-stands">Stands</span>
<span class="pill p-conc">Concourse</span>
</div>
<div>Bins:
<span class="pill k-rec">recyclable</span>
<span class="pill k-org">organic</span>
<span class="pill k-lf">landfill</span>
<span class="pill k-sp">special</span>
<span class="pill k-dt">dry_toilet</span>
</div>
<div style="opacity:.7; margin-top:6px">
Tip: add <code>?seed=123</code> to make it deterministic.
</div>
<div style="margin-top:8px">
<button id="btnBabylon" class="btn">Open 3D Banorte Stadium</button>
</div>
</div>
<div id="map"></div>
<script>
// Map (local flat plane)
const map = L.map('map', { crs: L.CRS.Simple, minZoom:-5, maxZoom:5, zoomSnap:0.25 }).setView([0,0], 0);
// Faint grid background
const grid = L.gridLayer();
grid.createTile = function(){
const c = document.createElement('canvas'); c.width=256; c.height=256;
const ctx=c.getContext('2d'); ctx.strokeStyle='#1f2937'; ctx.lineWidth=1;
for(let i=0;i<=256;i+=32){ ctx.beginPath(); ctx.moveTo(i,0); ctx.lineTo(i,256); ctx.stroke();
ctx.beginPath(); ctx.moveTo(0,i); ctx.lineTo(256,i); ctx.stroke(); }
return c;
};
grid.addTo(map);
const spacesLayer = L.geoJSON(null, {
style: f => {
const t = (f.properties?.type || "").toLowerCase();
const fill =
t === "field" ? "#16a34a" :
t === "concourse" ? "#94a3b8" :
t === "stands" ? "#64748b" : "#9ca3af";
return { color:"#0b1220", weight:0.6, fillColor:fill, fillOpacity:0.35 };
}
}).addTo(map);
const binsLayer = L.geoJSON(null, {
pointToLayer: (f, ll) => {
const k = (f.properties?.kind || "").toLowerCase();
const fill =
k==="organic" ? "#0ea5e9" :
k==="recyclable" ? "#10b981" :
k==="landfill" ? "#8b5cf6" :
k==="special" ? "#b45309" :
k==="dry_toilet" ? "#7c3aed" :
"#e5e7eb";
return L.circleMarker(ll, { radius:6, color:"#0b1220", weight:1, fillColor:fill, fillOpacity:0.95 });
},
onEachFeature: (f, layer) => {
const p = f.properties || {};
layer.bindPopup(`<b>${p.kind || "bin"}</b><br/>id: ${p.id || "-"}<br/>capacity: ${p.capacity_l ?? "-"} L`);
}
}).addTo(map);
function fetchStadium() {
const seed = new URLSearchParams(location.search).get("seed") || Date.now();
fetch(`/building/api/random/stadium/?seed=${seed}`)
.then(r => r.json())
.then(data => {
spacesLayer.clearLayers().addData(data.spaces);
binsLayer.clearLayers().addData(data.bins);
const group = L.featureGroup([spacesLayer, binsLayer]);
try { map.fitBounds(group.getBounds().pad(0.12)); } catch(e){}
})
.catch(err => console.error("Failed to fetch stadium:", err));
}
// Auto-generate on load
fetchStadium();
// Open Babylon 3D viewer, preserving current query params
document.getElementById('btnBabylon').onclick = () => {
const params = new URLSearchParams(location.search);
if (!params.has('seed')) params.set('seed', Date.now()); // keep deterministic if you passed one
// Change the path below if your Babylon route differs
location.href = `/building/twin/wire/babylon/?${params.toString()}`;
};
</script>
</body>
</html>

View File

@ -0,0 +1,262 @@
{% load static %}
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Building Twin — Wireframe Stadium (Real Size + Smart Can)</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- A-Frame -->
<script src="https://aframe.io/releases/1.4.1/aframe.min.js"></script>
<style>
html, body { height:100%; margin:0; background:#0b1220; color:#e5e7eb; font-family: system-ui, sans-serif; }
#hud {
position: fixed; top: 10px; left: 10px; z-index: 1000;
background:#0f172a; padding:10px 12px; border-radius:10px; box-shadow:0 10px 30px rgba(0,0,0,.35);
font-size:12px; line-height:1.25; max-width: 560px;
}
code { background:#111827; padding:0 4px; border-radius:4px }
</style>
</head>
<body>
<div id="hud">
<div style="font-weight:600; margin-bottom:6px">Wireframe Stadium (real meters)</div>
<div>Use <code>?scale=2</code> (double), <code>?scale=0.5</code> (half). Field defaults to 105×68 m (override with <code>?field_w=&field_h=</code>).</div>
<div>Smart can params: <code>?can_units=m|cm|mm</code>, <code>?can_scale=1</code>, <code>?can_angle=45</code>, <code>?can_y=0</code>, <code>?can_face_center=1</code>, optional <code>?can_tri=1</code> to download triangles JSON.</div>
<div style="opacity:.75; margin-top:6px">WASD to move, drag to look. Enter VR to feel the 1:1 scale.</div>
</div>
<a-scene background="color: #0b1220"
renderer="antialias:false; powerPreference:high-performance; physicallyCorrectLights:false; logarithmicDepthBuffer:true">
<!-- Assets (GLB fast path; OBJ fallback) -->
<a-assets timeout="30000">
<a-asset-item id="smartcan-glb" src="{% static 'pxy_building_digital_twins/models/smartcan.glb' %}"></a-asset-item>
<a-asset-item id="smartcan-obj" src="{% static 'pxy_building_digital_twins/models/smartcan.obj' %}"></a-asset-item>
</a-assets>
<!-- Lights -->
<a-entity light="type: ambient; intensity: 0.6; color: #ffffff"></a-entity>
<a-entity light="type: directional; intensity: 0.7; color: #ffffff" position="50 80 40"></a-entity>
<!-- Camera rig at human eye height -->
<a-entity id="rig" position="0 1.6 180">
<a-entity camera look-controls wasd-controls="acceleration: 40" position="0 0 0"></a-entity>
</a-entity>
<!-- Ground grid (reference) -->
<a-entity id="grid"></a-entity>
<!-- Stadium parts -->
<a-entity id="stadium"></a-entity>
<a-entity id="bins"></a-entity>
</a-scene>
<script>
// ---------- URL params ----------
const q = new URLSearchParams(location.search);
const SCALE = Math.max(0.1, Math.min(5, parseFloat(q.get('scale') || '1'))); // 0.1x .. 5x
const FIELD_W = parseFloat(q.get('field_w') || '105'); // meters (FIFA)
const FIELD_H = parseFloat(q.get('field_h') || '68');
// ---------- Seeded RNG (deterministic with ?seed=) ----------
function mulberry32(a){ return function(){ var t=a+=0x6D2B79F5; t=Math.imul(t^t>>>15,t|1);
t^=t+Math.imul(t^t>>>7,t|61); return ((t^t>>>14)>>>0)/4294967296; } }
const urlSeed = q.get("seed");
const seedNum = urlSeed ? Array.from(String(urlSeed)).reduce((s,ch)=>s+ch.charCodeAt(0),0) : Math.floor(Math.random()*1e9);
const rand = mulberry32(seedNum);
const randf = (a,b)=> a + (b-a)*rand();
const randi = (a,b)=> Math.floor(randf(a,b+1));
// ---------- A-Frame helpers ----------
AFRAME.registerComponent('wire-ellipse', {
schema: { rx:{type:'number'}, ry:{type:'number'}, y:{type:'number',default:0},
seg:{type:'int',default:256}, color:{type:'color',default:'#93a3b8'}, width:{type:'number',default:1} },
init: function () {
const THREE = AFRAME.THREE, d = this.data;
const geom = new THREE.BufferGeometry(); const pts = [];
for(let i=0;i<d.seg;i++){ const a = i/d.seg * Math.PI*2; pts.push(d.rx*Math.cos(a), d.y, d.ry*Math.sin(a)); }
pts.push(pts[0], pts[1], pts[2]); // close
geom.setAttribute('position', new THREE.BufferAttribute(new Float32Array(pts), 3));
const mat = new THREE.LineBasicMaterial({ color: d.color, linewidth: d.width });
this.el.setObject3D('mesh', new THREE.Line(geom, mat));
},
remove: function(){ const o=this.el.getObject3D('mesh'); if(o){ o.geometry.dispose(); o.material.dispose(); this.el.removeObject3D('mesh'); } }
});
function addWireBox(parent, w, h, t, y, color){ // w=width (x), h=depth (z), t=thickness (y)
const e = document.createElement('a-box');
e.setAttribute('width', w); e.setAttribute('depth', h); e.setAttribute('height', t);
e.setAttribute('position', `0 ${y} 0`);
e.setAttribute('material', `wireframe: true; color: ${color}; opacity: 0.65; transparent: true`);
parent.appendChild(e); return e;
}
function addBin(parent, x, y, z, color='#10b981'){
const s = document.createElement('a-sphere');
s.setAttribute('radius', 0.6 * SCALE); // ~60cm glowing puck
s.setAttribute('position', `${x} ${y} ${z}`);
s.setAttribute('material', `color: ${color}; emissive: ${color}; emissiveIntensity: 0.9`);
parent.appendChild(s); return s;
}
// Smart can loader (GLB first, OBJ fallback)
function addSmartCan(parent, x, y, z, scale=1, rotY=0){
const e = document.createElement('a-entity');
if (document.getElementById('smartcan-glb')) {
e.setAttribute('gltf-model', '#smartcan-glb'); // FAST PATH
} else if (document.getElementById('smartcan-obj')) {
e.setAttribute('obj-model', 'obj: #smartcan-obj'); // fallback (no MTL)
e.setAttribute('material', 'color: #9ca3af; metalness: 0.2; roughness: 0.6');
} else {
// last-resort placeholder
e.setAttribute('geometry','primitive:cylinder; radius:0.35; height:1.0');
e.setAttribute('material','color:#9ca3af');
}
e.setAttribute('position', `${x} ${y} ${z}`);
e.setAttribute('rotation', `0 ${rotY} 0`);
e.setAttribute('scale', `${scale} ${scale} ${scale}`);
parent.appendChild(e);
return e;
}
// Optional: extract triangles from loaded model (works for GLB & OBJ)
AFRAME.registerComponent('extract-triangles', {
schema: { download:{type:'boolean', default:true}, log:{type:'boolean', default:true} },
init() {
this.onLoaded = () => {
const root = this.el.getObject3D('mesh'); if (!root) return;
const tris = [];
root.traverse(n => {
if (!n.isMesh || !n.geometry) return;
let g = n.geometry;
if (g.index) g = g.toNonIndexed();
const pos = g.getAttribute('position'); if (!pos) return;
const a = pos.array;
for (let i=0;i<a.length;i+=9){
tris.push([[a[i],a[i+1],a[i+2]],[a[i+3],a[i+4],a[i+5]],[a[i+6],a[i+7],a[i+8]]]);
}
});
if (this.data.log) console.log(`extract-triangles: ${tris.length} triangles`, tris);
if (this.data.download){
const blob = new Blob([JSON.stringify({model:'smartcan',triangles:tris})],{type:'application/json'});
const url = URL.createObjectURL(blob);
const a = Object.assign(document.createElement('a'), {href:url, download:'smartcan_triangles.json'});
document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(url), 2000);
}
};
this.el.addEventListener('model-loaded', this.onLoaded);
},
remove(){ this.el.removeEventListener('model-loaded', this.onLoaded); }
});
// Grid in meters
(function makeGrid(){
const e = document.querySelector('#grid');
const size=500*SCALE, step=5*SCALE, y=0;
for(let x=-size; x<=size; x+=step){ const l=document.createElement('a-entity');
l.setAttribute('line', `start: ${x} ${y} ${-size}; end: ${x} ${y} ${size}; color: #1f2937`); e.appendChild(l); }
for(let z=-size; z<=size; z+=step){ const l=document.createElement('a-entity');
l.setAttribute('line', `start: ${-size} ${y} ${z}; end: ${size} ${y} ${z}; color: #1f2937`); e.appendChild(l); }
})();
// ---------- Build at real scale (meters) ----------
(function build(){
// Clean previous geometry if re-running
const stadium = document.querySelector('#stadium');
const bins = document.querySelector('#bins');
while (stadium.firstChild) stadium.removeChild(stadium.firstChild);
while (bins.firstChild) bins.removeChild(bins.firstChild);
// Heights (meters)
const Y_FIELD = 0.20*SCALE; // field mesh lift
const Y_CONC = 8.0*SCALE; // concourse level
const Y_STAND = 22.0*SCALE; // upper stands level (wire)
const EYE_H = 1.6*SCALE; // human eye height
// Field (exact meters)
const fieldW = FIELD_W * SCALE;
const fieldH = FIELD_H * SCALE;
// Derive rings from field size (meters)
const safeX = (FIELD_W/2 + randf(6,10)) * SCALE;
const safeZ = (FIELD_H/2 + randf(6,10)) * SCALE;
const standsInRX = safeX + randf(12,18)*SCALE;
const standsInRZ = safeZ + randf(12,18)*SCALE;
const concInRX = standsInRX + randf(4,6)*SCALE; // concourse corridor inside
const concInRZ = standsInRZ + randf(4,6)*SCALE;
const concOutRX = concInRX + randf(6,10)*SCALE; // concourse corridor outside
const concOutRZ = concInRZ + randf(6,10)*SCALE;
const standsOutRX = standsInRX + randf(35,45)*SCALE;
const standsOutRZ = standsInRZ + randf(35,45)*SCALE;
// Field (wireframe box)
addWireBox(stadium, fieldW, fieldH, 0.4*SCALE, Y_FIELD, '#16a34a');
// Concourse inner/outer
const concInner = document.createElement('a-entity');
concInner.setAttribute('wire-ellipse', { rx: concInRX, ry: concInRZ, y: Y_CONC, color:'#22d3ee' });
const concOuter = document.createElement('a-entity');
concOuter.setAttribute('wire-ellipse', { rx: concOutRX, ry: concOutRZ, y: Y_CONC, color:'#38bdf8' });
stadium.appendChild(concInner); stadium.appendChild(concOuter);
// Stands inner/outer (higher)
const standInner = document.createElement('a-entity');
standInner.setAttribute('wire-ellipse', { rx: standsInRX, ry: standsInRZ, y: Y_STAND, color:'#a78bfa' });
const standOuter = document.createElement('a-entity');
standOuter.setAttribute('wire-ellipse', { rx: standsOutRX, ry: standsOutRZ, y: Y_STAND, color:'#60a5fa' });
stadium.appendChild(standInner); stadium.appendChild(standOuter);
// Ribs
const ribs = randi(14, 22);
for(let i=0;i<ribs;i++){
const a = i/ribs * Math.PI*2 + randf(-0.02,0.02);
const x1 = concOutRX * Math.cos(a), z1 = concOutRZ * Math.sin(a);
const x2 = standsInRX * Math.cos(a), z2 = standsInRZ * Math.sin(a);
const rib = document.createElement('a-entity');
rib.setAttribute('line', `start: ${x1} ${Y_CONC} ${z1}; end: ${x2} ${Y_STAND} ${z2}; color: #14b8a6`);
stadium.appendChild(rib);
}
// Bins around concourse midline
const midRX = (concInRX + concOutRX)/2, midRZ = (concInRZ + concOutRZ)/2;
const binCount = randi(44, 64);
const colors = ['#10b981', '#0ea5e9', '#8b5cf6', '#f59e0b'];
for(let i=0;i<binCount;i++){
const a = i/binCount * Math.PI*2 + randf(-0.03,0.03);
const x = midRX * Math.cos(a), z = midRZ * Math.sin(a);
addBin(bins, x, Y_CONC+0.25*SCALE, z, colors[i % colors.length]);
}
// --- Smart can (GLB fast path; OBJ fallback) ---
const units = (q.get('can_units') || 'm').toLowerCase(); // m|cm|mm
const canScaleUser = parseFloat(q.get('can_scale') || '1');
const canAngleDeg = parseFloat(q.get('can_angle') || (Math.random()*360).toFixed(1));
const canYOffset = parseFloat(q.get('can_y') || '0'); // meters
const faceCenter = (q.get('can_face_center') || '1') !== '0';
const unitToMeter = units==='cm' ? 0.01 : units==='mm' ? 0.001 : 1;
const canScaleFinal = SCALE * unitToMeter * canScaleUser;
const ang = canAngleDeg * Math.PI/180;
const canX = midRX * Math.cos(ang);
const canZ = midRZ * Math.sin(ang);
const rotY = faceCenter ? -canAngleDeg : 0;
const can = addSmartCan(stadium, canX, Y_CONC + canYOffset, canZ, canScaleFinal, rotY);
// Optional triangle dump: add ?can_tri=1 to URL
if (q.get('can_tri') === '1') {
can.setAttribute('extract-triangles', 'download:true; log:true');
}
// Start the rig at eye height just outside the outer stands, facing center
const startZ = Math.max(standsOutRX, standsOutRZ) + 15*SCALE;
document.querySelector('#rig').setAttribute('position', `0 ${EYE_H} ${startZ}`);
})();
</script>
</body>
</html>

View File

@ -0,0 +1,392 @@
{% load static %}
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Building Twin — Babylon Wireframe Stadium + Smart Can</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- Babylon core + loaders (for GLB/GLTF/OBJ) -->
<script src="https://cdn.babylonjs.com/babylon.js"></script>
<script src="https://cdn.babylonjs.com/loaders/babylonjs.loaders.min.js"></script>
<style>
html, body { height:100%; margin:0; background:#0b1220; font-family:system-ui, sans-serif; }
#c {
position: fixed;
left: 0; top: 0;
width: 100vw; height: 100vh; /* fill viewport */
display: block;
}
#hud {
position: fixed; bottom: 10px; left: 10px; z-index: 10; color: #e5e7eb;
background:#0f172a; padding:10px 12px; border-radius:10px; box-shadow:0 10px 30px rgba(0,0,0,.35);
font-size:12px; line-height:1.25; width: 300px;
}
.row { display:flex; gap:8px; margin-top:6px; }
.row > * { flex:1; }
label { display:flex; align-items:center; gap:6px; cursor:pointer; }
button { width:100%; background:#1f2937; color:#e5e7eb; border:1px solid #334155;
border-radius:8px; padding:6px 8px; cursor:pointer; }
button:hover { background:#111827; }
.muted { opacity:.75 }
</style>
</head>
<body>
<canvas id="c"></canvas>
<div id="hud">
<div style="font-weight:600; margin-bottom:6px;">Wireframe Stadium (Babylon) + Smart Can</div>
<div class="muted">Orbit with mouse/touch. Drag bins on the floor.</div>
<div class="row" style="margin-top:8px;">
<label><input type="checkbox" id="chkL1" checked> L1 Concourse</label>
<label><input type="checkbox" id="chkL2" checked> L2 Mezzanine</label>
</div>
<div class="row">
<label><input type="checkbox" id="chkL3" checked> L3 Upper Stands</label>
<label><input type="checkbox" id="chkBins" checked> Bins</label>
</div>
<div class="row" style="margin-top:8px;">
<button id="btnRegen">Regenerate</button>
<button id="btnResetCam">Reset Camera</button>
<button id="btnViewer">Open 2D Banorte Stadium</button>
</div>
<div class="muted" style="margin-top:6px;">
URL knobs: <code>?seed=123&can_units=m|cm|mm&can_scale=1&can_angle=45&can_y=0</code>
</div>
</div>
<script>
// ---------- Params ----------
const params = new URLSearchParams(location.search);
const canUnits = (params.get('can_units') || 'm').toLowerCase(); // m|cm|mm
const canScaleUser = parseFloat(params.get('can_scale') || '1');
const canAngleDeg = parseFloat(params.get('can_angle') || (Math.random()*360).toFixed(1));
const canYOffset = parseFloat(params.get('can_y') || '0');
const unitToMeter = canUnits === 'cm' ? 0.01 : canUnits === 'mm' ? 0.001 : 1;
const CAN_GLb_URL = "{% static 'pxy_building_digital_twins/models/smartcan.glb' %}";
const CAN_OBJ_URL = "{% static 'pxy_building_digital_twins/models/smartcan.obj' %}";
// ---------- Engine / Scene ----------
const canvas = document.getElementById('c');
const engine = new BABYLON.Engine(canvas, true, { antialias:true, preserveDrawingBuffer:true, stencil:true });
const scene = new BABYLON.Scene(engine);
function fitCanvas(){ canvas.style.width='100vw'; canvas.style.height='100vh'; canvas.width=canvas.clientWidth; canvas.height=canvas.clientHeight; engine.resize(); }
fitCanvas(); window.addEventListener('resize', fitCanvas);
scene.clearColor = new BABYLON.Color4(0.043,0.071,0.125,1.0);
// Camera
const camera = new BABYLON.ArcRotateCamera("cam", Math.PI*1.25, 1.05, 220, new BABYLON.Vector3(0,10,0), scene);
camera.attachControl(canvas, true);
camera.lowerRadiusLimit = 40; camera.upperRadiusLimit = 2000;
// Lights
const hemi = new BABYLON.HemisphericLight("h", new BABYLON.Vector3(0,1,0), scene);
hemi.intensity = 0.9;
const dir = new BABYLON.DirectionalLight("d", new BABYLON.Vector3(-1,-2,-1), scene);
dir.position = new BABYLON.Vector3(120,180,120); dir.intensity = 0.7;
// Axes
const SHOW_AXES = false;
if (SHOW_AXES){
(function axes(len=30){
const mk=(a,b,c,col)=>{const l=BABYLON.MeshBuilder.CreateLines("ax",{points:[a,b,c]},scene); l.color=col; l.isPickable=false; return l;};
mk(new BABYLON.Vector3(0,0,0), new BABYLON.Vector3(len,0,0), new BABYLON.Vector3(len,0,0), new BABYLON.Color3(1,0.2,0.2));
mk(new BABYLON.Vector3(0,0,0), new BABYLON.Vector3(0,len,0), new BABYLON.Vector3(0,len,0), new BABYLON.Color3(0.2,1,0.2));
mk(new BABYLON.Vector3(0,0,0), new BABYLON.Vector3(0,0,len), new BABYLON.Vector3(0,0,len), new BABYLON.Color3(0.3,0.7,1));
})();
}
// Subtle grid
(function grid(){
const size=1000, step=10, y=0, col=new BABYLON.Color3(0.18,0.22,0.29);
for(let x=-size; x<=size; x+=step){
const l=BABYLON.MeshBuilder.CreateLines("gx",{points:[new BABYLON.Vector3(x,y,-size),new BABYLON.Vector3(x,y,size)]},scene);
l.color=col; l.isPickable=false;
}
for(let z=-size; z<=size; z+=step){
const l=BABYLON.MeshBuilder.CreateLines("gz",{points:[new BABYLON.Vector3(-size,y,z),new BABYLON.Vector3(size,y,z)]},scene);
l.color=col; l.isPickable=false;
}
})();
// ---------- RNG ----------
function mulberry32(a){ return function(){ var t=a+=0x6D2B79F5; t=Math.imul(t^t>>>15,t|1); t^=t+Math.imul(t^t>>>7,t|61); return ((t^t>>>14)>>>0)/4294967296; } }
function makeRand(seedStr){
const s = seedStr ? Array.from(String(seedStr)).reduce((acc,ch)=>acc+ch.charCodeAt(0),0) : Math.floor(Math.random()*1e9);
const r = mulberry32(s); return { f:(a,b)=>a+(b-a)*r(), i:(a,b)=>Math.floor(a+(b-a+1)*r()), s };
}
const querySeed = new URLSearchParams(location.search).get("seed");
// ---------- Helpers ----------
function wireRect(name, w, d, y, color3){
const hw = w/2, hd = d/2;
const pts = [
new BABYLON.Vector3(-hw, y, -hd),
new BABYLON.Vector3( hw, y, -hd),
new BABYLON.Vector3( hw, y, hd),
new BABYLON.Vector3(-hw, y, hd),
new BABYLON.Vector3(-hw, y, -hd) // close loop
];
const ln = BABYLON.MeshBuilder.CreateLines(name, { points: pts }, scene);
ln.color = color3; ln.isPickable = false;
return ln;
}
function wireBox(name,w,d,h,y,color3){
const box=BABYLON.MeshBuilder.CreateBox(name,{width:w,depth:d,height:h},scene);
const mat=new BABYLON.StandardMaterial(name+"_m",scene); mat.wireframe=true; mat.diffuseColor=color3; mat.specularColor=BABYLON.Color3.Black();
box.material=mat; box.position.y=y; box.isPickable=false; return box;
}
function wireEllipse(name,rx,rz,y,seg,color3){
const pts=[]; for(let i=0;i<=seg;i++){const a=(i/seg)*Math.PI*2; pts.push(new BABYLON.Vector3(rx*Math.cos(a),y,rz*Math.sin(a))); }
const ln=BABYLON.MeshBuilder.CreateLines(name,{points:pts,updatable:false},scene); ln.color=color3; ln.isPickable=false; return ln;
}
function addRib(name,x1,y1,z1,x2,y2,z2,color3){
const ln=BABYLON.MeshBuilder.CreateLines(name,{points:[new BABYLON.Vector3(x1,y1,z1),new BABYLON.Vector3(x2,y2,z2)]},scene);
ln.color=color3; ln.isPickable=false; return ln;
}
function addBin(name,x,y,z,color3){
const s=BABYLON.MeshBuilder.CreateSphere(name,{diameter:2.8,segments:10},scene);
const mat=new BABYLON.StandardMaterial(name+"_m",scene); mat.emissiveColor=color3; mat.diffuseColor=color3.scale(0.35); s.material=mat;
s.position.set(x,y,z);
const drag=new BABYLON.PointerDragBehavior({dragPlaneNormal:new BABYLON.Vector3(0,1,0)}); s.addBehavior(drag);
return s;
}
// Groups
const L1=new BABYLON.TransformNode("L1_Concourse",scene);
const L2=new BABYLON.TransformNode("L2_Mezzanine",scene);
const L3=new BABYLON.TransformNode("L3_Upper",scene);
const BINS=new BABYLON.TransformNode("Bins",scene);
const CAN_PARENT=new BABYLON.TransformNode("SmartCan",scene);
// Camera framing
function frameCamera(maxRadius,yCenter){ camera.alpha=Math.PI*1.25; camera.beta=1.05; camera.radius=Math.max(120,maxRadius*2.2); camera.setTarget(new BABYLON.Vector3(0,yCenter,0)); }
// ---------- Smart Can loader ----------
function splitURL(u){ const i=u.lastIndexOf('/'); return {root:u.slice(0,i+1), file:u.slice(i+1)}; }
function addMarker(x, y, z) {
// vertical neon line + torus ring
const ring = BABYLON.MeshBuilder.CreateTorus("canRing", {diameter: 8, thickness: 0.6, tessellation: 32}, scene);
const matR = new BABYLON.StandardMaterial("canRingM", scene); matR.emissiveColor = new BABYLON.Color3(1, 0.5, 0.1);
ring.material = matR; ring.position.set(x, y, z);
const line = BABYLON.MeshBuilder.CreateLines("canLine", {points:[
new BABYLON.Vector3(x, y, z),
new BABYLON.Vector3(x, y + 10, z)
]}, scene);
line.color = new BABYLON.Color3(1, 0.8, 0.2);
line.isPickable = false;
return ring;
}
async function loadSmartCanAt(x, y, z, yawDeg, scaleMeters){
const qs = new URLSearchParams(location.search);
const desiredH = parseFloat(qs.get("can_h") || "1.1"); // meters
const wireOn = (qs.get("can_wire") ?? "1") !== "0";
// NEW: manual rotation overrides (degrees)
const rxDeg = parseFloat(qs.get("can_rx") || "90");
const ryDeg = parseFloat(qs.get("can_ry") || "0");
const rzDeg = parseFloat(qs.get("can_rz") || "0");
const urls = [
"{% static 'pxy_building_digital_twins/models/smartcan.glb' %}",
"{% static 'pxy_building_digital_twins/models/smartcan.obj' %}"
];
async function tryUrl(url){
try { return (await BABYLON.SceneLoader.ImportMeshAsync("", "", url, scene)).meshes; }
catch {
const i = url.lastIndexOf("/"); const root = url.slice(0,i+1), file = url.slice(i+1);
try { return (await BABYLON.SceneLoader.ImportMeshAsync("", root, file, scene)).meshes; } catch { return null; }
}
}
let loaded=null;
for (const u of urls){ loaded = await tryUrl(u); if (loaded && loaded.length) break; }
if (!loaded){ console.error("[smartcan] FAILED to load"); return; }
// Parent & visible
loaded.forEach(m => { if (!m.parent) m.parent = CAN_PARENT; if (!m.material){ const t=new BABYLON.StandardMaterial("canWhite",scene); t.emissiveColor=new BABYLON.Color3(0.95,0.95,0.95); t.diffuseColor=t.emissiveColor; m.material=t; } });
// Helper to compute bounds in current world transform
const bounds = () => {
let min=new BABYLON.Vector3(+Infinity,+Infinity,+Infinity),
max=new BABYLON.Vector3(-Infinity,-Infinity,-Infinity);
loaded.forEach(m => {
const bi = m.getBoundingInfo?.(); if (!bi) return;
min = BABYLON.Vector3.Minimize(min, bi.boundingBox.minimumWorld);
max = BABYLON.Vector3.Maximize(max, bi.boundingBox.maximumWorld);
});
return {min, max, size: max.subtract(min)};
};
// Reset transforms
CAN_PARENT.position.set(0,0,0);
CAN_PARENT.rotationQuaternion = BABYLON.Quaternion.Identity();
CAN_PARENT.scaling.setAll(1);
// Auto-orient to make the tallest axis become Y
const cands = [
BABYLON.Quaternion.Identity(),
BABYLON.Quaternion.FromEulerAngles(Math.PI/2, 0, 0),
BABYLON.Quaternion.FromEulerAngles(-Math.PI/2, 0, 0),
BABYLON.Quaternion.FromEulerAngles(0, 0, Math.PI/2),
BABYLON.Quaternion.FromEulerAngles(0, 0, -Math.PI/2),
BABYLON.Quaternion.FromEulerAngles(Math.PI, 0, 0),
BABYLON.Quaternion.FromEulerAngles(0, 0, Math.PI)
];
let bestQ = cands[0], bestH = -1;
for (const q of cands){
CAN_PARENT.rotationQuaternion = q;
const b = bounds();
if (b.size.y > bestH){ bestH = b.size.y; bestQ = q; }
}
CAN_PARENT.rotationQuaternion = bestQ;
// Auto-scale height to desiredH, then apply user scaleMeters
let b0 = bounds();
const scaleAuto = b0.size.y > 0 ? desiredH / b0.size.y : 1;
const finalScale = scaleMeters * scaleAuto;
CAN_PARENT.scaling.setAll(finalScale);
// NEW: apply manual Euler corrections (degrees) BEFORE yaw
const rFix = BABYLON.Quaternion.FromEulerAngles(
BABYLON.Angle.FromDegrees(rxDeg).radians(),
BABYLON.Angle.FromDegrees(ryDeg).radians(),
BABYLON.Angle.FromDegrees(rzDeg).radians()
);
CAN_PARENT.rotationQuaternion = CAN_PARENT.rotationQuaternion.multiply(rFix);
// Apply yaw facing
const yawQ = BABYLON.Quaternion.FromEulerAngles(0, BABYLON.Angle.FromDegrees(yawDeg).radians(), 0);
CAN_PARENT.rotationQuaternion = CAN_PARENT.rotationQuaternion.multiply(yawQ);
// Recompute bounds AFTER all rotations+scaling, then sit the base on the floor
const b1 = bounds();
const lift = -b1.min.y; // already in world units now
CAN_PARENT.position.set(x, y + lift, z);
// Optional wireframe overlay
if (wireOn){
loaded.forEach(m => {
if (!m.geometry) return;
const wf = m.clone(m.name+"_wf");
const mat = new BABYLON.StandardMaterial("wfM", scene);
mat.wireframe = true; mat.emissiveColor = new BABYLON.Color3(0.05,0.05,0.05);
wf.material = mat; wf.isPickable = false;
wf.parent = m.parent;
wf.scaling = m.scaling.multiply(new BABYLON.Vector3(1.001,1.001,1.001));
wf.rotationQuaternion = m.rotationQuaternion?.clone() || BABYLON.Quaternion.Identity();
wf.position = m.position.clone();
});
}
}
// ---------- Builder ----------
function buildStadium(seedStr){
[L1,L2,L3,BINS].forEach(n => n.getChildren().slice().forEach(ch => ch.dispose()));
CAN_PARENT.getChildren().slice().forEach(ch => ch.dispose());
const R=makeRand(seedStr), rf=R.f, ri=R.i;
// Heights
const Y_FIELD=0.2, Y_CONC=2.0, Y_MEZZ=8.0, Y_STAND=14.0;
// Field
const FIELD_W=rf(100,110), FIELD_D=rf(64,72);
wireRect("field", FIELD_W, FIELD_D, Y_FIELD, BABYLON.Color3.FromHexString("#16a34a"));
// Rings
const CONC_RX_IN=rf(62,68), CONC_RZ_IN=rf(56,64);
const CONC_RX_OUT=CONC_RX_IN+rf(6,10), CONC_RZ_OUT=CONC_RZ_IN+rf(6,10);
const MEZZ_RX_IN=CONC_RX_OUT+rf(2,4), MEZZ_RZ_IN=CONC_RZ_OUT+rf(2,4);
const MEZZ_RX_OUT=MEZZ_RX_IN+rf(8,12), MEZZ_RZ_OUT=MEZZ_RZ_IN+rf(8,12);
const STANDS_RX_IN=MEZZ_RX_OUT+rf(3,5), STANDS_RZ_IN=MEZZ_RZ_OUT+rf(3,5);
const STANDS_RX_OUT=STANDS_RX_IN+rf(18,26), STANDS_RZ_OUT=STANDS_RZ_IN+rf(18,26);
wireEllipse("conc_in", CONC_RX_IN, CONC_RZ_IN, Y_CONC, 256, new BABYLON.Color3(0.10,0.94,0.86)).parent=L1;
wireEllipse("conc_out", CONC_RX_OUT, CONC_RZ_OUT, Y_CONC, 256, new BABYLON.Color3(0.00,0.75,1.00)).parent=L1;
wireEllipse("mezz_in", MEZZ_RX_IN, MEZZ_RZ_IN, Y_MEZZ, 256, new BABYLON.Color3(0.98,0.84,0.22)).parent=L2;
wireEllipse("mezz_out", MEZZ_RX_OUT, MEZZ_RZ_OUT, Y_MEZZ, 256, new BABYLON.Color3(1.00,0.66,0.10)).parent=L2;
wireEllipse("stand_in", STANDS_RX_IN, STANDS_RZ_IN, Y_STAND, 256, new BABYLON.Color3(0.80,0.52,1.00)).parent=L3;
wireEllipse("stand_out", STANDS_RX_OUT, STANDS_RZ_OUT, Y_STAND, 256, new BABYLON.Color3(0.62,0.82,1.00)).parent=L3;
// Ribs
const ribCol=new BABYLON.Color3(0.10,0.85,0.75);
const ribs1=ri(12,18);
for(let i=0;i<ribs1;i++){
const a=i/ribs1*Math.PI*2+rf(-0.03,0.03);
const x1=CONC_RX_OUT*Math.cos(a), z1=CONC_RZ_OUT*Math.sin(a);
const x2=MEZZ_RX_IN*Math.cos(a), z2=MEZZ_RZ_IN*Math.sin(a);
const ln=addRib("rib12_"+i, x1,Y_CONC,z1, x2,Y_MEZZ,z2, ribCol); ln.parent=L1;
}
const ribs2=ri(12,18);
for(let i=0;i<ribs2;i++){
const a=i/ribs2*Math.PI*2+rf(-0.03,0.03);
const x1=MEZZ_RX_OUT*Math.cos(a), z1=MEZZ_RZ_OUT*Math.sin(a);
const x2=STANDS_RX_IN*Math.cos(a), z2=STANDS_RZ_IN*Math.sin(a);
const ln=addRib("rib23_"+i, x1,Y_MEZZ,z1, x2,Y_STAND,z2, ribCol); ln.parent=L2;
}
// Bins
const midRX=(CONC_RX_IN+CONC_RX_OUT)/2, midRZ=(CONC_RZ_IN+CONC_RZ_OUT)/2;
const colors=[new BABYLON.Color3(0.20,1.00,0.65), new BABYLON.Color3(0.10,0.80,1.00), new BABYLON.Color3(0.95,0.55,1.00), new BABYLON.Color3(1.00,0.70,0.25)];
const count=ri(40,60);
for(let i=0;i<count;i++){
const a=i/count*Math.PI*2+rf(-0.05,0.05);
const x=midRX*Math.cos(a), z=midRZ*Math.sin(a);
const s=addBin("bin_"+i, x, Y_CONC+0.5, z, colors[i%colors.length]); s.parent=BINS;
}
const angleRad = canAngleDeg * Math.PI/180;
const canX = midRX * Math.cos(angleRad);
const canZ = midRZ * Math.sin(angleRad);
const canScaleMeters = unitToMeter * canScaleUser;
// 🔶 drop a neon marker where the can should be (so you know the spot)
addMarker(canX, Y_CONC + canYOffset, canZ);
// log what were about to do
console.log("[smartcan] pos=", {x: canX, y: Y_CONC + canYOffset, z: canZ},
"yawDeg=", -canAngleDeg, "scaleMeters=", canScaleMeters);
// load the model there
loadSmartCanAt(canX, Y_CONC + canYOffset, canZ, -canAngleDeg, canScaleMeters);
// Frame camera
const maxR=Math.max(STANDS_RX_OUT, STANDS_RZ_OUT);
frameCamera(maxR, (Y_CONC + Y_STAND)/2);
}
// Build + UI
buildStadium(new URLSearchParams(location.search).get("seed"));
const $=q=>document.querySelector(q);
$("#chkL1").onchange=e=>L1.setEnabled(e.target.checked);
$("#chkL2").onchange=e=>L2.setEnabled(e.target.checked);
$("#chkL3").onchange=e=>L3.setEnabled(e.target.checked);
$("#chkBins").onchange=e=>BINS.setEnabled(e.target.checked);
$("#btnRegen").onclick=()=>buildStadium(Math.floor(Math.random()*1e9).toString());
$("#btnResetCam").onclick=()=>frameCamera(200,10);
engine.runRenderLoop(()=>scene.render());
window.addEventListener('resize',()=>engine.resize());
// Open Leaflet viewer, preserving current query params
document.getElementById('btnViewer').onclick = () => {
const params = new URLSearchParams(location.search);
if (!params.has('seed')) params.set('seed', Date.now()); // keep determinism if you provided one
location.href = `/building/twin/viewer/?${params.toString()}`;
};
</script>
</body>
</html>

View File

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

View File

@ -0,0 +1,10 @@
# pxy_building_digital_twins/urls.py
from django.urls import path
from . import views
urlpatterns = [
path("twin/viewer/", views.viewer, name="building_twin_viewer"),
path("api/random/stadium/", views.api_random_stadium, name="api_random_stadium"),
path("twin/wire/", views.wire_viewer, name="building_twin_wire"),
path("twin/wire/babylon/", views.wire_babylon, name="building_twin_wire_babylon"),
]

View File

@ -0,0 +1,110 @@
from django.shortcuts import render
from django.http import JsonResponse
import random, math
def viewer(request):
return render(request, "pxy_building_digital_twins/viewer.html")
def wire_viewer(request):
return render(request, "pxy_building_digital_twins/wire.html")
def wire_babylon(request):
return render(request, "pxy_building_digital_twins/wire_babylon.html")
# ---------- geometry helpers ----------
def rect_poly(cx, cy, w, h):
x1, x2 = cx - w/2, cx + w/2
y1, y2 = cy - h/2, cy + h/2
return {"type":"Polygon","coordinates":[[[x1,y1],[x2,y1],[x2,y2],[x1,y2],[x1,y1]]]}
def ept(cx, cy, rx, ry, a):
return [cx + rx*math.cos(a), cy + ry*math.sin(a)]
def ellipse_ring(cx, cy, rx_out, ry_out, rx_in, ry_in, segments=128):
outer = [ept(cx, cy, rx_out, ry_out, i/segments*2*math.pi) for i in range(segments)]
inner = [ept(cx, cy, rx_in, ry_in, i/segments*2*math.pi) for i in range(segments-1, -1, -1)]
return {"type":"Polygon","coordinates":[outer, inner]}
def bins_on_ellipse(cx, cy, rx, ry, count, start_rad=0.0, jitter_r=0.0):
feats=[]
kinds = ["recyclable","organic","landfill","special"]
for i in range(count):
a = start_rad + (i/count)*2*math.pi + random.uniform(-0.03, 0.03)
rxf = rx + random.uniform(-jitter_r, jitter_r)
ryf = ry + random.uniform(-jitter_r, jitter_r)
x,y = ept(cx, cy, rxf, ryf, a)
kind = kinds[i % len(kinds)]
cap = random.choice([40,60,80])
feats.append({
"type":"Feature",
"geometry":{"type":"Point","coordinates":[x,y]},
"properties":{"id":f"BIN_{i+1}","kind":kind,"capacity_l":cap}
})
return feats
# ---------- API ----------
def api_random_stadium(request):
"""
Returns a random stadium (spaces + bins) as GeoJSON FeatureCollections.
Query params (optional):
- seed: int (deterministic output for a given seed)
- concourse_bins: int (default random 42..66)
- dry_toilets: int (default random 4..8)
"""
seed = request.GET.get("seed")
if seed is not None:
try:
random.seed(int(seed))
except ValueError:
random.seed(seed) # allow string seeds too
concourse_bins = int(request.GET.get("concourse_bins", random.randint(42,66)))
dry_toilets = int(request.GET.get("dry_toilets", random.randint(4,8)))
# Field sizes (soccer-ish)
FIELD_W = random.uniform(100, 110)
FIELD_H = random.uniform(64, 72)
# Ellipse radii for concourse / stands
CONC_RX_IN = random.uniform(62, 68); CONC_RY_IN = random.uniform(56, 64)
CONC_RX_OUT = CONC_RX_IN + random.uniform(6,10); CONC_RY_OUT = CONC_RY_IN + random.uniform(6,10)
STANDS_RX_IN = CONC_RX_OUT + random.uniform(3,5); STANDS_RY_IN = CONC_RY_OUT + random.uniform(3,5)
STANDS_RX_OUT = STANDS_RX_IN + random.uniform(18,26); STANDS_RY_OUT = STANDS_RY_IN + random.uniform(18,26)
# Build spaces
spaces = {"type":"FeatureCollection","features":[]}
spaces["features"].append({
"type":"Feature",
"geometry": rect_poly(0,0, FIELD_W, FIELD_H),
"properties": {"type":"field","name":"Field"}
})
spaces["features"].append({
"type":"Feature",
"geometry": ellipse_ring(0,0, CONC_RX_OUT, CONC_RY_OUT, CONC_RX_IN, CONC_RY_IN, 128),
"properties": {"type":"concourse","name":"Main Concourse"}
})
spaces["features"].append({
"type":"Feature",
"geometry": ellipse_ring(0,0, STANDS_RX_OUT, STANDS_RY_OUT, STANDS_RX_IN, STANDS_RY_IN, 160),
"properties": {"type":"stands","name":"Stands"}
})
# Bins on concourse + outside dry toilets
RX_MID = (CONC_RX_IN + CONC_RX_OUT)/2
RY_MID = (CONC_RY_IN + CONC_RY_OUT)/2
bins = {"type":"FeatureCollection","features":[]}
bins["features"].extend(bins_on_ellipse(0,0, RX_MID, RY_MID, concourse_bins, random.uniform(0, math.pi/3), jitter_r=1.5))
OUT_RX = STANDS_RX_OUT + random.uniform(10,16)
OUT_RY = STANDS_RY_OUT + random.uniform(10,16)
for i in range(dry_toilets):
a = (i/dry_toilets)*2*math.pi + random.uniform(-0.05,0.05)
x,y = ept(0,0, OUT_RX, OUT_RY, a)
bins["features"].append({
"type":"Feature",
"geometry":{"type":"Point","coordinates":[x,y]},
"properties":{"id":f"DT_{i+1}","kind":"dry_toilet","capacity_l":0}
})
return JsonResponse({"spaces": spaces, "bins": bins})

BIN
pxy_city_digital_twins.zip Normal file

Binary file not shown.