ammar101's picture
Deploy application code and models
0bb49b0
Raw
History Blame Contribute Delete
31.9 kB
{% extends 'base.html' %}
{% load static %}
{% load i18n %}
{% block title %}{% trans "Your 3D Avatar - Virtual Fitting System" %}{% endblock %}
{% block extra_css %}
<style>
/* Avatar page overrides — dark theme for the 3D viewer area */
.avatar-page {
display: flex;
min-height: calc(100vh - 64px);
}
/* Sidebar */
.avatar-sidebar {
width: 320px;
min-width: 320px;
max-height: calc(100vh - 64px);
background: linear-gradient(160deg, #0f1729 0%, #1B3A6B 60%, #16213e 100%);
border-right: 1px solid rgba(255, 255, 255, 0.07);
overflow-y: auto;
position: relative;
z-index: 10;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.1) transparent;
}
.avatar-sidebar::-webkit-scrollbar {
width: 4px;
}
.avatar-sidebar::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
}
.sidebar-inner {
padding: 24px 20px;
}
/* Canvas area */
.avatar-canvas-area {
flex: 1;
position: relative;
overflow: hidden;
background: linear-gradient(135deg, #0f1729 0%, #1a1a2e 50%, #16213e 100%);
}
.avatar-canvas-area canvas {
display: block;
width: 100% !important;
height: 100% !important;
}
/* Section styling */
.av-section {
margin-bottom: 20px;
animation: avFadeUp 0.5s ease both;
}
.av-section:nth-child(2) {
animation-delay: 0.05s;
}
.av-section:nth-child(3) {
animation-delay: 0.10s;
}
.av-section:nth-child(4) {
animation-delay: 0.15s;
}
.av-section-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.av-section-icon {
width: 28px;
height: 28px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
flex-shrink: 0;
}
.av-section-icon.skin {
background: rgba(201, 169, 110, 0.15);
}
.av-section-icon.shirt {
background: rgba(99, 179, 237, 0.12);
}
.av-section-icon.pants {
background: rgba(154, 117, 255, 0.12);
}
.av-section-title {
font-size: 10px;
font-weight: 500;
letter-spacing: 2.5px;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.45);
}
/* Skin swatches grid */
.av-swatches-skin {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 7px;
}
.av-swatches-clothes {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 7px;
}
.av-swatch {
aspect-ratio: 1;
border-radius: 50%;
cursor: pointer;
border: 2px solid transparent;
transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease;
position: relative;
}
.av-swatch:hover {
transform: scale(1.2);
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.5);
}
.av-swatch.active {
border-color: #fff;
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.25), 0 4px 14px rgba(0, 0, 0, 0.5);
transform: scale(1.1);
}
.av-swatch[data-tip]:hover::after {
content: attr(data-tip);
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.9);
color: #fff;
font-size: 10px;
white-space: nowrap;
padding: 3px 8px;
border-radius: 4px;
pointer-events: none;
z-index: 300;
}
.av-suggest-label {
font-size: 9px;
letter-spacing: 1.5px;
text-transform: uppercase;
color: #c9a96e;
margin-bottom: 7px;
display: flex;
align-items: center;
gap: 5px;
}
.av-suggest-label::before {
content: '';
display: inline-block;
width: 18px;
height: 1px;
background: #c9a96e;
opacity: 0.5;
}
.av-divider {
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.07), transparent);
margin: 4px 0 20px;
}
/* Info cards inside sidebar */
.av-info-card {
background: rgba(255, 255, 255, 0.06);
border-radius: 12px;
padding: 14px 16px;
margin-bottom: 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
}
.av-info-label {
font-size: 9px;
letter-spacing: 2px;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.35);
margin-bottom: 4px;
}
.av-info-value {
color: #fff;
font-weight: 700;
font-size: 24px;
}
.av-info-sub {
color: rgba(255, 255, 255, 0.4);
font-size: 11px;
margin-top: 2px;
}
/* Debug overlay */
#av-debug {
position: absolute;
bottom: 12px;
right: 12px;
background: rgba(0, 0, 0, 0.75);
color: #4eff91;
font-size: 10px;
font-family: monospace;
padding: 10px 14px;
border-radius: 6px;
z-index: 50;
max-width: 460px;
line-height: 1.8;
pointer-events: none;
}
/* Swatch pop animation */
@keyframes avSwatchPop {
from {
opacity: 0;
transform: scale(0.7);
}
to {
opacity: 1;
transform: scale(1);
}
}
.av-swatch.pop {
animation: avSwatchPop 0.22s ease both;
}
@keyframes avFadeUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Back link */
.av-back-link {
display: inline-flex;
align-items: center;
gap: 6px;
color: rgba(255, 255, 255, 0.5);
font-size: 12px;
font-weight: 500;
text-decoration: none;
margin-bottom: 20px;
transition: color 0.2s;
}
.av-back-link:hover {
color: #fff;
}
/* Responsive */
@media (max-width: 768px) {
.avatar-page {
flex-direction: column;
}
.avatar-sidebar {
width: 100%;
min-width: 100%;
max-height: none;
border-right: none;
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
}
.avatar-canvas-area {
min-height: 50vh;
}
}
</style>
{% endblock %}
{% block content %}
<div class="avatar-page">
<!-- ===== SIDEBAR ===== -->
<div class="avatar-sidebar">
<div class="sidebar-inner">
<!-- Back link -->
<a href="{% url 'fitting_system:recommendations' body_scan.session_id %}" class="av-back-link">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
</svg>
{% trans "Back to Recommendations" %}
</a>
<!-- Info Cards -->
<div class="av-info-card">
<div class="av-info-label">{% trans "AI Recommended Size" %}</div>
<div class="av-info-value">{{ recommended_size }}</div>
<div class="av-info-sub">{% trans "Based on your body scan" %}</div>
</div>
<div class="av-info-card" style="display: flex; align-items: center; gap: 12px;">
<div class="av-swatch"
style="background:#{{ skin_tone_hex }}; width: 40px; height: 40px; flex-shrink: 0; cursor: default; border: 2px solid rgba(255,255,255,0.2);">
</div>
<div>
<div class="av-info-label">{% trans "Your Skin Tone" %}</div>
<div style="color: #fff; font-weight: 600; font-size: 15px;">{{ skin_tone_display }}</div>
<div class="av-info-sub">{{ undertone_display }} {% trans "undertone" %}</div>
</div>
</div>
<div class="av-divider"></div>
<!-- SKIN TONE SWATCHES -->
<div class="av-section">
<div class="av-section-header">
<div class="av-section-title">{% trans "Skin Tone" %}</div>
</div>
<div class="av-swatches-skin" id="avSkinSwatches">
<div class="av-swatch" style="background:#fde8d0" data-hex="fde8d0" data-skin="0"
data-tip="{% trans 'Very Light' %}"></div>
<div class="av-swatch" style="background:#f5cba7" data-hex="f5cba7" data-skin="1" data-tip="{% trans 'Light' %}">
</div>
<div class="av-swatch" style="background:#e8a87c" data-hex="e8a87c" data-skin="2"
data-tip="{% trans 'Intermediate' %}">
</div>
<div class="av-swatch" style="background:#c68642" data-hex="c68642" data-skin="3" data-tip="{% trans 'Tan' %}">
</div>
<div class="av-swatch" style="background:#8d5524" data-hex="8d5524" data-skin="4" data-tip="{% trans 'Dark' %}">
</div>
</div>
</div>
<div class="av-divider"></div>
<!-- SHIRT COLORS -->
<div class="av-section">
<div class="av-section-header">
<div class="av-section-title">{% trans "T-Shirt Color" %}</div>
</div>
<div class="av-suggest-label" id="avShirtSuggest">{% trans "Suggested for your skin tone" %}</div>
<div class="av-swatches-clothes" id="avShirtSwatches"></div>
</div>
<div class="av-divider"></div>
<!-- PANTS COLORS -->
<div class="av-section">
<div class="av-section-header">
<div class="av-section-title">{% trans "Pants Color" %}</div>
</div>
<div class="av-suggest-label" id="avPantsSuggest">{% trans "Suggested for your skin tone" %}</div>
<div class="av-swatches-clothes" id="avPantsSwatches"></div>
</div>
<div class="av-divider"></div>
<!-- Footer hint -->
<div style="text-align: center; padding: 8px 0;">
<p style="font-size: 10px; color: rgba(255,255,255,0.2); line-height: 1.7;">
<span style="display: block; color: rgba(255,255,255,0.3); margin-bottom: 2px;">{% trans "Drag to rotate · Scroll to zoom" %}</span>
{% trans "Colors update with your skin tone" %}
</p>
</div>
</div>
</div>
<!-- ===== 3D CANVAS ===== -->
<div class="avatar-canvas-area" id="av-canvas-container">
<div id="av-debug">âڈ³ {% trans "Loading 3D model..." %}</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.160.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
// ── Django context ──
const INITIAL_SKIN_INDEX = {{ skin_tone_index }};
const GENDER = '{{ gender }}';
const MODEL_URL = GENDER === 'women'
? '{% static "avatar/FEMALE.glb" %}'
: '{% static "avatar/MALE.glb" %}';
const dbg = document.getElementById('av-debug');
const container = document.getElementById('av-canvas-container');
// ────────────────────────────────────────────────────────────────
// DYNAMIC PALETTES — injected from Django / color_palettes.py
// ────────────────────────────────────────────────────────────────
const SKIN_TONE_KEYS = ['very_light', 'light', 'intermediate', 'tan', 'dark'];
const PALETTES_BY_KEY = JSON.parse('{{ palettes_json|escapejs }}');
const INITIAL_SKIN_KEY = '{{ skin_tone_key }}';
const REC_SHIRT_HEX = '{{ rec_shirt_hex }}';
const REC_PANTS_HEX = '{{ rec_pants_hex }}';
// Map skin swatch index → skin tone key
const INDEX_TO_KEY = { 0: 'very_light', 1: 'light', 2: 'intermediate', 3: 'tan', 4: 'dark' };
// ────────────────────────────────────────────────────────────────
// THREE.JS SETUP
// ────────────────────────────────────────────────────────────────
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0f1729);
const camera = new THREE.PerspectiveCamera(
36,
Math.max(container.clientWidth, 1) / Math.max(container.clientHeight, 1),
0.1, 100
);
camera.position.set(0, 1.0, 6);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(Math.max(container.clientWidth, 1), Math.max(container.clientHeight, 1));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.1;
container.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 1.0, 0);
controls.enableDamping = true;
controls.dampingFactor = 0.07;
controls.minDistance = 1.0;
controls.maxDistance = 20;
controls.update();
scene.add(new THREE.AmbientLight(0xffffff, 0.6));
const key = new THREE.DirectionalLight(0xfff5e6, 1.4);
key.position.set(3, 6, 4); key.castShadow = true;
key.shadow.mapSize.set(1024, 1024); scene.add(key);
const fill = new THREE.DirectionalLight(0xd0e8ff, 0.6);
fill.position.set(-3, 3, -2); scene.add(fill);
const rim = new THREE.DirectionalLight(0xc9a96e, 0.8);
rim.position.set(0, 4, -4); scene.add(rim);
const floor = new THREE.Mesh(
new THREE.CircleGeometry(2.5, 64),
new THREE.MeshStandardMaterial({ color: 0x16213e, roughness: 0.85, metalness: 0.05, transparent: true, opacity: 0.65 })
);
floor.rotation.x = -Math.PI / 2;
floor.receiveShadow = true;
scene.add(floor);
// ────────────────────────────────────────────────────────────────
// MESH BUCKETS
// ────────────────────────────────────────────────────────────────
const skinMeshes = [];
const shirtMeshes = [];
const pantsMeshes = [];
const lockedMeshes = [];
function avgWorldY(mesh) {
const pos = mesh.geometry.attributes.position;
if (!pos || pos.count === 0) return 0;
let sum = 0;
const v = new THREE.Vector3();
for (let i = 0; i < pos.count; i++) {
v.fromBufferAttribute(pos, i);
v.applyMatrix4(mesh.matrixWorld);
sum += v.y;
}
return sum / pos.count;
}
function getMat(mesh) {
return Array.isArray(mesh.material) ? mesh.material[0] : mesh.material;
}
function applySkinColor(hexStr) {
const hex = parseInt(hexStr, 16);
const r = ((hex >> 16) & 0xff) / 255;
const g = ((hex >> 8) & 0xff) / 255;
const b = (hex & 0xff) / 255;
skinMeshes.forEach(mesh => {
const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
mats.forEach(mat => { mat.color.setRGB(r, g, b); mat.needsUpdate = true; });
});
lockedMeshes.forEach(applyLocked);
}
function applyClothingColor(meshList, hexStr) {
const hex = parseInt(hexStr, 16);
meshList.forEach(mesh => {
const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
mats.forEach(mat => { mat.color.setHex(hex); mat.map = null; mat.needsUpdate = true; });
});
lockedMeshes.forEach(applyLocked);
}
function applyLocked(entry) {
const mats = Array.isArray(entry.mesh.material) ? entry.mesh.material : [entry.mesh.material];
mats.forEach(mat => { mat.color.setHex(entry.hexColor); mat.map = null; mat.needsUpdate = true; });
}
// ────────────────────────────────────────────────────────────────
// PALETTE UI
// ────────────────────────────────────────────────────────────────
let currentShirtHex = null;
let currentPantsHex = null;
function buildClothingSwatches(skinKey, defaultShirtHex, defaultPantsHex) {
const palette = PALETTES_BY_KEY[skinKey];
if (!palette) return;
const shirtGrid = document.getElementById('avShirtSwatches');
const pantsGrid = document.getElementById('avPantsSwatches');
const lightHexes = new Set(['ffffff', 'f5f5f5', 'fffde7', 'e0e0e0', 'cfd8dc', 'e8d5f5', 'b5d8f7', 'f7c5d0', 'bbdefb', 'a5d6a7', 'ef9a9a', 'fff176']);
// ── Shirt swatches ──
shirtGrid.innerHTML = '';
palette.shirts.forEach((item, i) => {
const sw = document.createElement('div');
sw.className = 'av-swatch pop';
sw.style.background = '#' + item.hex;
sw.style.animationDelay = (i * 0.025) + 's';
if (lightHexes.has(item.hex)) sw.style.borderColor = 'rgba(255,255,255,0.3)';
sw.dataset.hex = item.hex;
sw.dataset.tip = item.tip;
sw.addEventListener('click', () => {
shirtGrid.querySelectorAll('.av-swatch').forEach(s => s.classList.remove('active'));
sw.classList.add('active');
currentShirtHex = item.hex;
applyClothingColor(shirtMeshes, item.hex);
});
shirtGrid.appendChild(sw);
});
// ── Pants swatches ──
pantsGrid.innerHTML = '';
palette.pants.forEach((item, i) => {
const sw = document.createElement('div');
sw.className = 'av-swatch pop';
sw.style.background = '#' + item.hex;
sw.style.animationDelay = (i * 0.025) + 's';
if (lightHexes.has(item.hex)) sw.style.borderColor = 'rgba(255,255,255,0.3)';
sw.dataset.hex = item.hex;
sw.dataset.tip = item.tip;
sw.addEventListener('click', () => {
pantsGrid.querySelectorAll('.av-swatch').forEach(s => s.classList.remove('active'));
sw.classList.add('active');
currentPantsHex = item.hex;
applyClothingColor(pantsMeshes, item.hex);
});
pantsGrid.appendChild(sw);
});
// Auto-select: prefer the Gemini-recommended hex, else first swatch
const shirtSw = shirtGrid.querySelectorAll('.av-swatch');
let shirtSelected = false;
if (defaultShirtHex) {
shirtSw.forEach(s => { if (s.dataset.hex === defaultShirtHex) { s.classList.add('active'); shirtSelected = true; } });
}
if (!shirtSelected && shirtSw[0]) shirtSw[0].classList.add('active');
currentShirtHex = defaultShirtHex || palette.shirts[0].hex;
applyClothingColor(shirtMeshes, currentShirtHex);
const pantsSw = pantsGrid.querySelectorAll('.av-swatch');
let pantsSelected = false;
if (defaultPantsHex) {
pantsSw.forEach(s => { if (s.dataset.hex === defaultPantsHex) { s.classList.add('active'); pantsSelected = true; } });
}
if (!pantsSelected && pantsSw[0]) pantsSw[0].classList.add('active');
currentPantsHex = defaultPantsHex || palette.pants[0].hex;
applyClothingColor(pantsMeshes, currentPantsHex);
}
// ────────────────────────────────────────────────────────────────
// LOAD MODEL
// ────────────────────────────────────────────────────────────────
new GLTFLoader().load(
MODEL_URL,
function (gltf) {
const model = gltf.scene;
{
const box = new THREE.Box3().setFromObject(model);
const size = box.getSize(new THREE.Vector3());
model.scale.setScalar(2.0 / Math.max(size.x, size.y, size.z));
model.updateMatrixWorld(true);
const box2 = new THREE.Box3().setFromObject(model);
const cen = box2.getCenter(new THREE.Vector3());
model.position.set(-cen.x, -box2.min.y, -cen.z);
}
scene.add(model);
model.updateMatrixWorld(true);
const wb = new THREE.Box3().setFromObject(model);
const wh = wb.max.y - wb.min.y;
const minY = wb.min.y;
const ankleY = minY + wh * 0.11;
const waistY = minY + wh * 0.47;
const neckY = minY + wh * 0.80;
const midY = minY + wh * 0.50;
const fovRad = THREE.MathUtils.degToRad(camera.fov);
const camDist = (wh / 2 / Math.tan(fovRad / 2)) * 1.65;
camera.position.set(0, midY, camDist);
controls.target.set(0, midY, 0);
controls.minDistance = camDist * 0.25;
controls.maxDistance = camDist * 5;
controls.update();
const allMeshes = [];
model.traverse(child => {
if (!child.isMesh) return;
child.material = Array.isArray(child.material)
? child.material.map(m => m.clone())
: child.material.clone();
child.castShadow = true;
child.receiveShadow = true;
allMeshes.push(child);
});
const lockBlack = ['eye', 'lash', 'iris', 'pupil', 'cornea', 'sclera', 'brow', 'eyebrow', 'hair', 'shoe', 'boot', 'sole', 'lace', 'sneaker', 'eyelid'];
const lockWhite = ['sock'];
const lockDefault = ['teeth', 'tooth', 'tongue', 'lip', 'mouth', 'gum', 'nose', 'nail'];
const pantsWords = ['pant', 'trouser', 'jean', 'denim', 'short', 'skirt', 'bottom', 'lower'];
const shirtWords = ['shirt', 'tee', 'jacket', 'hoodie', 'sweater', 'cloth', 'wear', 'blouse'];
const unclassified = [];
allMeshes.forEach(mesh => {
const n = mesh.name.toLowerCase();
if (lockBlack.some(w => n.includes(w))) { lockedMeshes.push({ mesh, hexColor: 0x0a0a0a }); return; }
if (lockWhite.some(w => n.includes(w))) { lockedMeshes.push({ mesh, hexColor: 0xfafafa }); return; }
if (lockDefault.some(w => n.includes(w))) { return; }
if (pantsWords.some(w => n.includes(w))) { pantsMeshes.push(mesh); return; }
if (shirtWords.some(w => n.includes(w))) { shirtMeshes.push(mesh); return; }
const yc = avgWorldY(mesh);
const mat = getMat(mesh);
const { r, g, b } = mat ? mat.color : { r: 0.5, g: 0.5, b: 0.5 };
const isDark = (r + g + b) < 0.8 || (r < 0.25 && g < 0.25);
if (yc < ankleY) { return; }
if (yc <= waistY) { isDark ? pantsMeshes.push(mesh) : unclassified.push(mesh); return; }
if (yc <= neckY) { isDark ? shirtMeshes.push(mesh) : unclassified.push(mesh); return; }
unclassified.push(mesh);
});
unclassified.forEach(m => { skinMeshes.push(m); });
if (skinMeshes.length === 0) {
const used = new Set([...pantsMeshes, ...shirtMeshes, ...lockedMeshes.map(e => e.mesh)]);
allMeshes.forEach(m => { if (!used.has(m)) skinMeshes.push(m); });
}
lockedMeshes.forEach(applyLocked);
dbg.innerHTML =
`ًں§¬ Skin: <b>${skinMeshes.length}</b> &nbsp;ًں‘• Shirt: <b>${shirtMeshes.length}</b> &nbsp;ًں‘– Pants: <b>${pantsMeshes.length}</b> &nbsp;ًں”’ Locked: <b>${lockedMeshes.length}</b><br>` +
`<span style="opacity:.5;font-size:9px">Model: ${GENDER === 'women' ? 'FEMALE' : 'MALE'}.glb</span>`;
// Auto-select the detected skin tone
const skinSwatches = document.getElementById('avSkinSwatches').querySelectorAll('.av-swatch');
const targetSwatch = skinSwatches[INITIAL_SKIN_INDEX];
if (targetSwatch) {
skinSwatches.forEach(s => s.classList.remove('active'));
targetSwatch.classList.add('active');
applySkinColor(targetSwatch.dataset.hex);
buildClothingSwatches(INITIAL_SKIN_KEY, REC_SHIRT_HEX, REC_PANTS_HEX);
} else {
buildClothingSwatches('intermediate', REC_SHIRT_HEX, REC_PANTS_HEX);
}
},
undefined,
err => { console.error(err); dbg.innerHTML = `<span style="color:#ff6b6b">â‌Œ ${err.message}</span>`; }
);
// ────────────────────────────────────────────────────────────────
// SKIN SWATCH CLICK
// ────────────────────────────────────────────────────────────────
document.getElementById('avSkinSwatches').querySelectorAll('.av-swatch').forEach(sw => {
sw.addEventListener('click', () => {
document.getElementById('avSkinSwatches').querySelectorAll('.av-swatch').forEach(s => s.classList.remove('active'));
sw.classList.add('active');
const skinIndex = parseInt(sw.dataset.skin);
const skinKey = INDEX_TO_KEY[skinIndex] || 'intermediate';
applySkinColor(sw.dataset.hex);
buildClothingSwatches(skinKey, null, null);
});
});
// ── Render loop ──
(function animate() {
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
})();
// ── Resize ──
new ResizeObserver(() => {
const w = Math.max(container.clientWidth, 1);
const h = Math.max(container.clientHeight, 1);
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
}).observe(container);
</script>
{% endblock %}