← back to Ventura Bus 3d
public/models-preview.html
125 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Ventura Bus 3D — Model Preview</title>
<style>
:root { --bg:#0b0d12; --fg:#e6eaf2; --muted:#8a93a6; --card:#141821; --accent:#ff8c00; }
*{box-sizing:border-box} html,body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.4 -apple-system,system-ui,sans-serif}
header{padding:18px 22px;border-bottom:1px solid #20242f;display:flex;align-items:baseline;gap:14px;flex-wrap:wrap}
h1{margin:0;font-size:18px;letter-spacing:.02em}
header .meta{color:var(--muted);font-size:12px}
header a{color:var(--accent);text-decoration:none}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:14px;padding:18px}
.card{background:var(--card);border:1px solid #20242f;border-radius:8px;overflow:hidden;display:flex;flex-direction:column}
.stage{aspect-ratio:1/1;background:linear-gradient(180deg,#161a24 0%,#0d1018 100%);position:relative}
.stage canvas{display:block;width:100%;height:100%}
.meta-row{padding:10px 12px;border-top:1px solid #20242f;display:flex;justify-content:space-between;align-items:baseline}
.name{font-weight:600}
.role{color:var(--muted);font-size:12px}
.size{font-variant-numeric:tabular-nums;color:var(--muted);font-size:12px}
.credit{padding:6px 12px 12px;font-size:11px;color:var(--muted)}
.credit a{color:#7eb1ff;text-decoration:none}
.controls{position:absolute;bottom:6px;right:8px;font-size:10px;color:var(--muted);user-select:none}
</style>
</head>
<body>
<header>
<h1>Ventura Bus 3D — sourced vehicle models</h1>
<div class="meta">8 glTF binary models · all CC-BY 3.0 · drag to orbit · scroll to zoom</div>
<div class="meta">↩ <a href="/">main scene</a> · <a href="/models/ATTRIBUTION.md">ATTRIBUTION.md</a></div>
</header>
<div class="grid" id="grid"></div>
<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';
const MODELS = [
{ file:'bus-metro-articulated.glb', name:'bus-metro-articulated', role:'Metro orange articulated (stand-in)', credit:'Bus by Poly by Google', url:'https://poly.pizza/m/9Rj7a89ypPQ' },
{ file:'bus-ladot-dash.glb', name:'bus-ladot-dash', role:'LADOT DASH (stand-in)', credit:'Montreal Bus by Nick Ladd', url:'https://poly.pizza/m/fFCCghvRImG' },
{ file:'bus-school.glb', name:'bus-school', role:'School bus', credit:'Schoolbus by Poly by Google', url:'https://poly.pizza/m/8xacyNqdJ3t' },
{ file:'car-sedan.glb', name:'car-sedan', role:'Sedan', credit:'Car by Quaternius', url:'https://poly.pizza/m/Cz6yDaUcM9' },
{ file:'car-suv.glb', name:'car-suv', role:'SUV', credit:'SUV by Quaternius', url:'https://poly.pizza/m/xsMtZhBkxL' },
{ file:'car-sports.glb', name:'car-sports', role:'Sports car', credit:'Sports Car by Quaternius', url:'https://poly.pizza/m/1mkmFkAz5v' },
{ file:'car-pickup.glb', name:'car-pickup', role:'Pickup truck', credit:'Pickup Truck by Quaternius', url:'https://poly.pizza/m/qn4grQgHm8' },
{ file:'car-delivery-van.glb', name:'car-delivery-van', role:'Delivery van', credit:'Van by Poly by Google', url:'https://poly.pizza/m/aT_24cDaW1a' },
];
const grid = document.getElementById('grid');
const loader = new GLTFLoader();
const fmtKB = b => `${(b/1024).toFixed(0)} KB`;
for (const m of MODELS) {
const card = document.createElement('div'); card.className = 'card';
const stage = document.createElement('div'); stage.className = 'stage';
const ctrl = document.createElement('div'); ctrl.className = 'controls'; ctrl.textContent = 'orbit · zoom';
stage.appendChild(ctrl);
const meta = document.createElement('div'); meta.className = 'meta-row';
meta.innerHTML = `<div><div class="name">${m.name}</div><div class="role">${m.role}</div></div><div class="size" data-size>—</div>`;
const credit = document.createElement('div'); credit.className = 'credit';
credit.innerHTML = `${m.credit} · <a href="${m.url}" target="_blank" rel="noopener">source</a> · <a href="https://creativecommons.org/licenses/by/3.0/" target="_blank" rel="noopener">CC-BY 3.0</a>`;
card.append(stage, meta, credit);
grid.appendChild(card);
// Per-card Three.js scene
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x10141d);
const camera = new THREE.PerspectiveCamera(40, 1, 0.05, 200);
camera.position.set(6, 4.5, 7);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
const sizeObs = new ResizeObserver(() => {
const r = stage.getBoundingClientRect();
renderer.setSize(r.width, r.height, false);
camera.aspect = r.width / r.height; camera.updateProjectionMatrix();
});
sizeObs.observe(stage);
stage.appendChild(renderer.domElement);
scene.add(new THREE.HemisphereLight(0xffffff, 0x223044, 0.9));
const sun = new THREE.DirectionalLight(0xffe7b3, 1.2); sun.position.set(5, 8, 4); scene.add(sun);
const grid2 = new THREE.GridHelper(20, 20, 0x2a2f3d, 0x1a1d27); grid2.position.y = -0.001; scene.add(grid2);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true; controls.target.set(0, 0.6, 0);
// Track byte size via fetch HEAD
fetch(`/models/${m.file}`, { method: 'HEAD' }).then(r => {
const len = r.headers.get('content-length');
if (len) meta.querySelector('[data-size]').textContent = fmtKB(Number(len));
});
loader.load(`/models/${m.file}`, (gltf) => {
const root = gltf.scene;
const box = new THREE.Box3().setFromObject(root);
const size = new THREE.Vector3(); box.getSize(size);
const center = new THREE.Vector3(); box.getCenter(center);
const maxDim = Math.max(size.x, size.y, size.z) || 1;
const scale = 4 / maxDim;
root.scale.setScalar(scale);
root.position.sub(center.multiplyScalar(scale));
root.position.y += (size.y * scale) / 2; // sit on grid
scene.add(root);
}, undefined, (err) => {
stage.style.background = '#3a1a1a';
ctrl.textContent = 'load error: ' + err.message;
});
let raf = 0;
function tick(){ controls.update(); renderer.render(scene, camera); raf = requestAnimationFrame(tick); }
tick();
}
</script>
</body>
</html>