← 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>