Building digital Twin 2D and 3D
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
parent
aad3123121
commit
7d1d4a43bd
@ -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",
|
||||||
|
@ -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")),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
0
pxy_building_digital_twins/__init__.py
Normal file
0
pxy_building_digital_twins/__init__.py
Normal file
3
pxy_building_digital_twins/admin.py
Normal file
3
pxy_building_digital_twins/admin.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
# Register your models here.
|
6
pxy_building_digital_twins/apps.py
Normal file
6
pxy_building_digital_twins/apps.py
Normal 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'
|
0
pxy_building_digital_twins/migrations/__init__.py
Normal file
0
pxy_building_digital_twins/migrations/__init__.py
Normal file
3
pxy_building_digital_twins/models.py
Normal file
3
pxy_building_digital_twins/models.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
# Create your models here.
|
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@ -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>
|
@ -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>
|
@ -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 we’re 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>
|
3
pxy_building_digital_twins/tests.py
Normal file
3
pxy_building_digital_twins/tests.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
10
pxy_building_digital_twins/urls.py
Normal file
10
pxy_building_digital_twins/urls.py
Normal 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"),
|
||||||
|
]
|
110
pxy_building_digital_twins/views.py
Normal file
110
pxy_building_digital_twins/views.py
Normal 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
BIN
pxy_city_digital_twins.zip
Normal file
Binary file not shown.
Loading…
x
Reference in New Issue
Block a user