{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "exploded-view",
  "type": "registry:block",
  "title": "Exploded View",
  "description": "A camera lens built from lathed parts that comes apart along its optical axis as you scroll or drag, on a spring you can reverse mid-flight. Numbered leader labels track each part and fade in as it separates; metal, clay and x-ray looks.",
  "docs": "Exploded View was added to public/vividsites/exploded-view.html. Paste its markup, <style> and <script> where you want it on the page (or open the file to see it alone). Customise it with the CSS variables at the top. Docs and live demo: https://vividsites.app/c/exploded-view",
  "categories": [
    "3d",
    "3d-objects"
  ],
  "files": [
    {
      "path": "components/vividsites/exploded-view.html",
      "type": "registry:file",
      "target": "public/vividsites/exploded-view.html",
      "content": "<!--\n  vividsites · exploded-view\n  A machined camera lens, built in code from lathed parts, that comes apart\n  along its optical axis: front element, name ring, knurled focus ring, a\n  cemented doublet, a nine-blade iris, the rear groups, the helicoid and the\n  bayonet mount. Scroll, drag or use the arrow keys over it to pull it apart;\n  a spring carries every part, so you can reverse mid-flight. Each part gets a\n  leader line and a numbered label that tracks it on screen and fades in as it\n  separates. The lens turns a little toward the pointer.\n\n  Customise (attributes, read live):\n    data-explode     0–1, the target separation (default 0.62)\n    data-material    metal | clay | xray\n    data-labels      \"on\" | \"off\"\n    data-wheel       remove it to stop the wheel driving the explode (at 0 and 1 the page scrolls on anyway)\n  CSS:\n    --vs-exploded-view-paper    ground colour (default #eef0ee)\n    --vs-exploded-view-grid     grid line colour (a faint 24px drafting grid)\n    --vs-exploded-view-ink      label and leader colour\n    --vs-exploded-view-accent   the lens's accent ring and the x-ray tint\n    --vs-exploded-view-height   component height (default 100svh)\n  JS (on the element): el.vsExplodedView\n    .set({ explode: 0..1, material: 'metal'|'clay'|'xray', labels: true|false })\n    .explode        current target\n    .destroy()\n  Needs: three@0.170.0 + three/addons (importmap below), IBM Plex Mono for the labels.\n-->\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\"><link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link rel=\"stylesheet\" href=\"https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&display=swap\">\n<div class=\"vs-exploded-view\" data-explode=\"0.62\" data-material=\"metal\" data-labels=\"on\" data-wheel tabindex=\"0\" role=\"img\"\n     aria-label=\"An exploded view of a camera lens. Scroll, drag or use the arrow keys to pull the parts apart.\">\n  <canvas class=\"vs-exploded-view-canvas\"></canvas>\n  <svg class=\"vs-exploded-view-lines\" aria-hidden=\"true\"></svg>\n  <div class=\"vs-exploded-view-labels\" aria-hidden=\"true\"></div>\n</div>\n\n<style>\n  .vs-exploded-view {\n    --vs-exploded-view-paper: #eef0ee;\n    --vs-exploded-view-grid: rgba(28, 40, 36, .07);\n    --vs-exploded-view-ink: #1b2320;\n    --vs-exploded-view-accent: #e0452b;\n    --vs-exploded-view-height: 100svh;\n    position: relative; width: 100%; height: var(--vs-exploded-view-height); min-height: 420px; overflow: hidden; isolation: isolate;\n    background-color: var(--vs-exploded-view-paper);\n    background-image: linear-gradient(var(--vs-exploded-view-grid) 1px, transparent 1px), linear-gradient(90deg, var(--vs-exploded-view-grid) 1px, transparent 1px);\n    background-size: 24px 24px; background-position: center center;\n    outline: none; cursor: grab; touch-action: pan-y; -webkit-tap-highlight-color: transparent; user-select: none; -webkit-user-select: none;\n  }\n  .vs-exploded-view[data-dragging] { cursor: grabbing; }\n  .vs-exploded-view:focus-visible { box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--vs-exploded-view-ink) 45%, transparent); }\n  .vs-exploded-view-canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; opacity: 0; transition: opacity 1s cubic-bezier(.16,1,.3,1); }\n  .vs-exploded-view[data-vs-state=\"ready\"] .vs-exploded-view-canvas { opacity: 1; }\n  .vs-exploded-view-lines { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; overflow: visible; }\n  .vs-exploded-view-lines path { fill: none; stroke: var(--vs-exploded-view-ink); stroke-width: .75; stroke-linejoin: round; }\n  .vs-exploded-view-lines circle { fill: var(--vs-exploded-view-paper); stroke: var(--vs-exploded-view-ink); stroke-width: 1; }\n  .vs-exploded-view-labels { position: absolute; inset: 0; pointer-events: none; }\n  .vs-exploded-view-label { position: absolute; left: 0; top: 0; white-space: nowrap; will-change: transform, opacity;\n    font: 400 11px/1.25 \"IBM Plex Mono\", ui-monospace, \"SF Mono\", Menlo, monospace; letter-spacing: .01em; color: var(--vs-exploded-view-ink); }\n  .vs-exploded-view-label b { font-weight: 500; margin-right: 8px; color: var(--vs-exploded-view-accent); }\n  .vs-exploded-view-label i { display: block; font-style: normal; font-size: 9.5px; letter-spacing: .04em; opacity: .5; margin-top: 3px; }\n  .vs-exploded-view[data-labels=\"off\"] .vs-exploded-view-lines, .vs-exploded-view[data-labels=\"off\"] .vs-exploded-view-labels { opacity: 0; }\n  .vs-exploded-view-lines, .vs-exploded-view-labels { transition: opacity .4s cubic-bezier(.16,1,.3,1); }\n  /* no WebGL: a drawn cross-section stands in */\n  .vs-exploded-view[data-vs-state=\"nogl\"] .vs-exploded-view-canvas { opacity: 1; background:\n      radial-gradient(closest-side, transparent 58%, var(--vs-exploded-view-ink) 59% 61%, transparent 62%) 30% 50% / 22% 44% no-repeat,\n      radial-gradient(closest-side, transparent 50%, var(--vs-exploded-view-ink) 51% 53%, transparent 54%) 50% 50% / 20% 40% no-repeat,\n      radial-gradient(closest-side, transparent 58%, var(--vs-exploded-view-ink) 59% 61%, transparent 62%) 70% 50% / 18% 36% no-repeat; }\n  @media (prefers-reduced-motion: reduce) { .vs-exploded-view-canvas, .vs-exploded-view-lines, .vs-exploded-view-labels { transition: none; } }\n</style>\n\n<script type=\"importmap\">{ \"imports\": { \"three\": \"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js\", \"three/addons/\": \"https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/\" } }</script>\n<script type=\"module\">\nimport * as THREE from 'three';\nimport { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';\n\nconst css = (el, n, f) => getComputedStyle(el).getPropertyValue(n).trim() || f;\nconst TAU = Math.PI * 2;\n\n// ---------- geometry helpers (all built around +Y, the optical axis; the front of the lens is +Y)\nconst lathe = (pts, seg = 128) => new THREE.LatheGeometry(pts.map(([x, y]) => new THREE.Vector2(x, y)), seg);\n// a machined ring: rectangle section with small chamfers\nfunction ring(rIn, rOut, h, c = 0.018, seg = 128) {\n  return lathe([[rIn, h / 2], [rIn, -h / 2], [rOut - c, -h / 2], [rOut, -h / 2 + c], [rOut, h / 2 - c], [rOut - c, h / 2], [rIn + 0.001, h / 2]], seg);\n}\n// a lens: front surface bulges by k1 (negative = concave), back by k2, edge thickness e\nfunction lens(r, e, k1, k2) {\n  const g = new THREE.Group(), n = 24;\n  const front = [], back = [];\n  for (let i = 0; i <= n; i++) { const p = (i / n) * r, q = 1 - (p / r) ** 2; front.push([p, e / 2 + k1 * q]); back.push([r - p, -e / 2 - k2 * (1 - ((r - p) / r) ** 2)]); }\n  front[0][0] = 0.0001; back[n][0] = 0.0001;\n  return { faces: [lathe(front.reverse().reverse(), 96), lathe([[r, e / 2], [r, -e / 2]], 96), lathe(back, 96)], group: g };\n}\n// knurled grip: a cylinder whose radius carries N sharp ridges\nfunction knurl(r, h, depth, N) {\n  const g = new THREE.CylinderGeometry(r, r, h, N * 4, 1, true);\n  const p = g.attributes.position;\n  for (let i = 0; i < p.count; i++) {\n    const x = p.getX(i), z = p.getZ(i), a = Math.atan2(z, x);\n    const ridge = Math.abs(((a / TAU) * N) % 1 - 0.5) * 2;     // 0..1 triangle wave\n    const rr = r - depth + depth * ridge;\n    p.setX(i, Math.cos(a) * rr); p.setZ(i, Math.sin(a) * rr);\n  }\n  g.computeVertexNormals();\n  return g;\n}\n// helicoid barrel: a tube with a saw-tooth thread cut along its length\nfunction helicoid(rIn, rOut, h, threads) {\n  const pts = [[rIn, -h / 2], [rOut - 0.03, -h / 2]];\n  for (let i = 0; i <= threads; i++) { const y = -h / 2 + 0.04 + (i / threads) * (h - 0.08); pts.push([rOut, y - 0.012], [rOut - 0.035, y + 0.01]); }\n  pts.push([rOut - 0.03, h / 2], [rIn, h / 2]);\n  return lathe(pts, 128);\n}\n// the iris: a disc whose fragment shader cuts a nine-sided opening and draws the blade seams, on top of the lit material\nconst IRIS_PARS = `uniform float uOpen; varying vec2 vIris;\nfloat irisHash(float n){ return fract(sin(n * 91.7) * 43758.5453); }`;\nfunction irisify(mat, U) {\n  mat = mat.clone();\n  mat.onBeforeCompile = (sh) => {\n    sh.uniforms.uOpen = U;\n    sh.vertexShader = 'varying vec2 vIris;\\n' + sh.vertexShader.replace('#include <begin_vertex>', '#include <begin_vertex>\\n vIris = position.xz;');\n    sh.fragmentShader = IRIS_PARS + '\\n' + sh.fragmentShader.replace('#include <color_fragment>', `#include <color_fragment>\n      {\n        float r = length(vIris), a = atan(vIris.y, vIris.x);\n        float seg = 6.2831853 / 9.0, m = mod(a, seg) - seg * 0.5;\n        float hole = uOpen * cos(3.14159265 / 9.0) / cos(m);\n        float fw = fwidth(r) * 1.2;\n        diffuseColor.a *= smoothstep(hole - fw, hole + fw, r);\n        float s = a / seg - (r - hole) * 3.2;\n        float id = floor(s), f = fract(s);\n        diffuseColor.rgb *= 0.7 + 0.6 * irisHash(mod(id, 9.0));\n        diffuseColor.rgb *= mix(0.3, 1.0, smoothstep(0.0, 0.06, f)) * mix(1.25, 1.0, smoothstep(0.0, 0.2, f));\n        diffuseColor.rgb *= mix(1.6, 1.0, smoothstep(0.0, 0.035, r - hole));   // a bright machined edge on the opening\n      }`);\n  };\n  mat.alphaToCoverage = true; mat.side = THREE.DoubleSide;\n  mat.customProgramCacheKey = () => 'vs-iris';\n  return mat;\n}\n\n// ---------- the parts, front to back. y = assembled position on the axis; r = label radius; x = explode slot\nconst PARTS = [\n  { id: 'front',   name: 'Front element',       note: 'Ø58 · multi-coated' },\n  { id: 'name',    name: 'Name ring',           note: 'anodised, 49 mm thread' },\n  { id: 'focus',   name: 'Focus ring',          note: 'knurled, 270° throw' },\n  { id: 'doublet', name: 'Cemented doublet',    note: 'group II · 2 elements' },\n  { id: 'iris',    name: 'Aperture',            note: '9 rounded blades' },\n  { id: 'g3',      name: 'Element group III',   note: 'biconvex · ED glass' },\n  { id: 'heli',    name: 'Helicoid barrel',     note: 'triple-start thread' },\n  { id: 'rear',    name: 'Rear element',        note: 'meniscus' },\n  { id: 'mount',   name: 'Bayonet mount',       note: 'brass, 4 screws' },\n];\n\nfunction mount(el) {\n  const canvas = el.querySelector('.vs-exploded-view-canvas');\n  const svg = el.querySelector('.vs-exploded-view-lines');\n  const labelLayer = el.querySelector('.vs-exploded-view-labels');\n  const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;\n  let renderer;\n  try { renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false, powerPreference: 'high-performance' }); } catch (e) { el.dataset.vsState = 'nogl'; return null; }\n  if (!renderer.getContext()) { el.dataset.vsState = 'nogl'; return null; }\n  renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.05;\n\n  const scene = new THREE.Scene();\n  const pmrem = new THREE.PMREMGenerator(renderer);\n  const envRT = pmrem.fromScene(new RoomEnvironment(), 0.035);\n  scene.environment = envRT.texture; scene.environmentIntensity = 0.95;\n  const key = new THREE.DirectionalLight(0xffffff, 1.6); key.position.set(-3, 5, 4); scene.add(key);\n  const rim = new THREE.DirectionalLight(0xdfe9ff, 1.1); rim.position.set(4, 1, -5); scene.add(rim);\n\n  // ---- the paper, drawn in the scene so the glass has something to refract; the grid matches the CSS one\n  const paper = new THREE.Color(css(el, '--vs-exploded-view-paper', '#eef0ee'));\n  const paperU = { uPaper: { value: paper }, uDpr: { value: 1 }, uRes: { value: new THREE.Vector2(1, 1) }, uGrid: { value: 0.07 } };\n  const bgMesh = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), new THREE.ShaderMaterial({ uniforms: paperU, depthWrite: false, depthTest: false, toneMapped: false,\n    vertexShader: 'void main(){ gl_Position = vec4(position.xy, 0.9999, 1.0); }',\n    fragmentShader: `uniform vec3 uPaper; uniform float uDpr, uGrid; uniform vec2 uRes;\n      void main(){\n        vec2 p = (gl_FragCoord.xy - uRes * 0.5) / uDpr;       // css px from the centre, matching background-position: center\n        vec2 g = abs(fract(p / 24.0 + 0.5) - 0.5) * 24.0;\n        float line = 1.0 - smoothstep(0.0, 1.0 / uDpr + 0.5, min(g.x, g.y));\n        vec3 c = mix(uPaper, uPaper * 0.72, line * uGrid * 1.4);\n        gl_FragColor = vec4(c, 1.0);\n        #include <colorspace_fragment>\n      }` }));\n  bgMesh.frustumCulled = false; bgMesh.renderOrder = -10; scene.add(bgMesh);\n\n  // ---- materials by role, for each look\n  const accent = new THREE.Color(css(el, '--vs-exploded-view-accent', '#e0452b'));\n  const M = {\n    metal: {\n      black:  new THREE.MeshPhysicalMaterial({ color: 0x1b1c1f, metalness: 0.55, roughness: 0.36, clearcoat: 0.4, clearcoatRoughness: 0.3 }),\n      satin:  new THREE.MeshPhysicalMaterial({ color: 0x2a2b2f, metalness: 0.8, roughness: 0.42 }),\n      rubber: new THREE.MeshStandardMaterial({ color: 0x141416, metalness: 0, roughness: 0.78 }),\n      alu:    new THREE.MeshPhysicalMaterial({ color: 0xc9ccd1, metalness: 1, roughness: 0.3 }),\n      chrome: new THREE.MeshPhysicalMaterial({ color: 0xeceff2, metalness: 1, roughness: 0.1 }),\n      brass:  new THREE.MeshPhysicalMaterial({ color: 0xd9b373, metalness: 1, roughness: 0.25 }),\n      accent: new THREE.MeshPhysicalMaterial({ color: accent, metalness: 0.2, roughness: 0.35, clearcoat: 0.6 }),\n      glass:  new THREE.MeshPhysicalMaterial({ color: 0xffffff, metalness: 0, roughness: 0.02, transmission: 1, thickness: 0.35, ior: 1.52,\n        attenuationColor: new THREE.Color(0xc6ede0), attenuationDistance: 1.2, iridescence: 0.85, iridescenceIOR: 1.35, iridescenceThicknessRange: [180, 420],\n        specularIntensity: 1, envMapIntensity: 1.0, side: THREE.DoubleSide }),\n    },\n    clay: (() => {\n      const c = new THREE.MeshStandardMaterial({ color: 0xe8e3da, roughness: 0.92, metalness: 0 });\n      const d = new THREE.MeshStandardMaterial({ color: 0xd5cfc4, roughness: 0.9, metalness: 0 });\n      const g = new THREE.MeshPhysicalMaterial({ color: 0xe9efed, roughness: 0.35, transmission: 0.6, thickness: 0.4, ior: 1.3 });\n      const a = new THREE.MeshStandardMaterial({ color: accent, roughness: 0.8 });\n      return { black: c, satin: d, rubber: d, alu: c, chrome: c, brass: c, accent: a, glass: g };\n    })(),\n  };\n  const xrayMat = (op) => new THREE.ShaderMaterial({ transparent: true, depthWrite: false, side: THREE.DoubleSide, uniforms: { uC: { value: accent }, uO: { value: op } },\n    vertexShader: 'varying vec3 vN; varying vec3 vV; void main(){ vec4 mv = modelViewMatrix * vec4(position, 1.0); vN = normalize(normalMatrix * normal); vV = normalize(-mv.xyz); gl_Position = projectionMatrix * mv; }',\n    fragmentShader: 'uniform vec3 uC; uniform float uO; varying vec3 vN; varying vec3 vV; void main(){ float f = 1.0 - abs(dot(normalize(vN), normalize(vV))); f = f * f; gl_FragColor = vec4(uC * 0.8, (0.05 + f * 0.6) * uO); }' });\n  const xr = xrayMat(1), xrGlass = xrayMat(0.7);\n  M.xray = { black: xr, satin: xr, rubber: xr, alu: xr, chrome: xr, brass: xr, accent: xr, glass: xrGlass };\n  const irisOpen = { value: 0.3 };\n  M.metal.iris = irisify(new THREE.MeshPhysicalMaterial({ color: 0x3a3b40, metalness: 0.75, roughness: 0.38 }), irisOpen); M.clay.iris = irisify(M.clay.satin, irisOpen); M.xray.iris = irisify(new THREE.MeshBasicMaterial({ color: accent, transparent: true, opacity: 0.25, depthWrite: false, side: THREE.DoubleSide }), irisOpen);\n  const edgeMat = new THREE.LineBasicMaterial({ color: accent, transparent: true, opacity: 0.42, depthWrite: false });\n\n  // ---- build\n  const assembly = new THREE.Group();      // turns with the pointer; its +Y is the optical axis\n  const holder = new THREE.Group(); holder.add(assembly);   // maps the axis onto the screen (across, or down on portrait)\n  const turn = new THREE.Group(); turn.add(holder); scene.add(turn);   // the 3/4 view and the pointer orbit, in world space\n  const meshes = [];\n  const part = (id) => { const g = new THREE.Group(); g.userData.id = id; assembly.add(g); return g; };\n  const add = (g, geo, role, edges = true) => {\n    const m = new THREE.Mesh(geo, M.metal[role]); m.userData.role = role; g.add(m); meshes.push(m);\n    if (edges) { const e = new THREE.LineSegments(new THREE.EdgesGeometry(geo, 32), edgeMat); e.visible = false; e.userData.edge = true; m.add(e); }\n    return m;\n  };\n  const addLens = (g, L) => L.faces.forEach((f, i) => add(g, f, i === 1 ? 'black' : 'glass', i === 1));   // lens edges are blackened, as on real glass\n\n  // 01 front element\n  let p = part('front'); addLens(p, lens(0.8, 0.12, 0.2, -0.1));\n  // 02 name ring with the accent line\n  p = part('name'); add(p, ring(0.82, 0.96, 0.22), 'black'); add(p, ring(0.955, 0.972, 0.03, 0.004), 'accent', false).position.y = 0.05;\n  add(p, ring(0.8, 0.84, 0.3, 0.008), 'satin');\n  // 03 focus ring: rubber knurl between two metal lips\n  p = part('focus'); add(p, knurl(1.02, 0.52, 0.03, 96), 'rubber', false); add(p, ring(0.9, 1.0, 0.62), 'black');\n  add(p, ring(0.97, 1.03, 0.05, 0.01), 'black').position.y = 0.285; add(p, ring(0.97, 1.03, 0.05, 0.01), 'black').position.y = -0.285;\n  // 04 cemented doublet: a positive and a negative element glued, in a thin cell\n  p = part('doublet'); { const a = lens(0.62, 0.08, 0.16, 0.0); a.faces.forEach((f, i) => { f.translate(0, 0.08, 0); add(p, f, i === 1 ? 'black' : 'glass', i === 1); });\n    const b = lens(0.62, 0.06, 0.0, -0.1); b.faces.forEach((f, i) => { f.translate(0, -0.04, 0); add(p, f, i === 1 ? 'black' : 'glass', i === 1); }); add(p, ring(0.62, 0.7, 0.26), 'satin'); }\n  // 05 iris: nine blades in a housing, with a lever tab\n  p = part('iris'); add(p, ring(0.62, 0.72, 0.1), 'black'); add(p, ring(0.72, 0.75, 0.04, 0.006), 'alu', false);\n  { const g = new THREE.CircleGeometry(0.64, 96); g.rotateX(-Math.PI / 2); const m = add(p, g, 'iris', false); m.position.y = 0.012;\n    const g2 = g.clone(); g2.rotateX(Math.PI); add(p, g2, 'iris', false).position.y = -0.012; }\n  { const tab = new THREE.Mesh(new THREE.BoxGeometry(0.08, 0.05, 0.08), M.metal.alu); tab.userData.role = 'alu'; tab.position.set(0, 0, 0.74); meshes.push(tab); p.add(tab); }\n  // 06 element group III, biconvex in a cell\n  p = part('g3'); addLens(p, lens(0.5, 0.06, 0.14, 0.14)); add(p, ring(0.5, 0.58, 0.2), 'satin');\n  // 07 helicoid barrel\n  p = part('heli'); add(p, helicoid(0.8, 0.9, 1.08, 12), 'alu', false); add(p, ring(0.76, 0.82, 1.1, 0.01), 'satin');\n  // 08 rear element\n  p = part('rear'); addLens(p, lens(0.44, 0.07, -0.06, 0.16)); add(p, ring(0.44, 0.52, 0.18), 'satin');\n  // 09 bayonet mount: chrome flange, three lugs, four screws\n  p = part('mount'); add(p, ring(0.56, 0.84, 0.08), 'chrome'); add(p, ring(0.6, 0.66, 0.28, 0.01), 'chrome').position.y = -0.14;\n  for (let i = 0; i < 3; i++) { const a0 = (i / 3) * TAU + 0.3; const lug = new THREE.RingGeometry(0.66, 0.76, 24, 1, a0, 0.7); lug.rotateX(Math.PI / 2);\n    const s = new THREE.Mesh(new THREE.ExtrudeGeometry(new THREE.Shape().absarc(0, 0, 0.76, a0, a0 + 0.7, false).lineTo(Math.cos(a0 + 0.7) * 0.66, Math.sin(a0 + 0.7) * 0.66).absarc(0, 0, 0.66, a0 + 0.7, a0, true), { depth: 0.04, bevelEnabled: false, curveSegments: 16 }), M.metal.chrome);\n    s.rotation.x = Math.PI / 2; s.position.y = -0.26; s.userData.role = 'chrome'; meshes.push(s); p.add(s); }\n  for (let i = 0; i < 4; i++) { const a = (i / 4) * TAU + Math.PI / 4; const sc = new THREE.Mesh(new THREE.CylinderGeometry(0.028, 0.028, 0.02, 16), M.metal.alu); sc.userData.role = 'alu';\n    sc.position.set(Math.cos(a) * 0.72, 0.045, Math.sin(a) * 0.72); meshes.push(sc); p.add(sc); }\n  add(p, ring(0.5, 0.56, 0.06, 0.01), 'brass').position.y = 0.02;\n\n  // assembled positions (y), label radius (r), and footprint along the axis (for the exploded spacing)\n  const LAYOUT = { front: [1.3, 0.8, 0.14], name: [1.25, 0.96, 0.15], focus: [0.72, 1.03, 0.31], doublet: [0.62, 0.7, 0.14], iris: [0.26, 0.75, 0.05], g3: [0.02, 0.58, 0.12], heli: [-0.22, 0.9, 0.55], rear: [-0.62, 0.52, 0.1], mount: [-0.92, 0.84, 0.22] };\n  const groups = assembly.children;\n  groups.forEach((g, i) => { const [y, r, hl] = LAYOUT[g.userData.id]; g.userData.y0 = y; g.userData.r = r; g.userData.hl = hl; g.userData.i = i; g.position.y = y; });\n\n  // shadow on the paper: a soft ellipse that lengthens as the parts spread\n  const shadowU = { uLen: { value: 1 }, uO: { value: 0.3 } };\n  const shadow = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), new THREE.ShaderMaterial({ transparent: true, depthWrite: false, uniforms: shadowU,\n    vertexShader: 'varying vec2 vU; void main(){ vU = uv * 2.0 - 1.0; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }',\n    fragmentShader: 'uniform float uO; varying vec2 vU; void main(){ float d = length(vU); float a = exp(-d * d * 4.5) * uO; gl_FragColor = vec4(0.06, 0.09, 0.08, a); }' }));\n  shadow.rotation.x = -Math.PI / 2; scene.add(shadow);\n\n  const camera = new THREE.PerspectiveCamera(28, 1, 0.1, 100);\n  const target = (el) => THREE.MathUtils.clamp(parseFloat(el.dataset.explode ?? 0.62), 0, 1);\n  const S = { e: 0, v: 0, to: target(el), raf: 0, last: 0, t: 0, visible: true, portrait: false, rx: 0, ry: 0, hx: 0, hy: 0, drag: null, labelsOn: el.dataset.labels !== 'off', material: el.dataset.material || 'metal', w: 1, h: 1 };\n  if (reduced) S.e = S.to;\n\n  // ---- labels\n  const NS = 'http://www.w3.org/2000/svg';\n  const L = PARTS.map((pp, i) => {\n    const line = document.createElementNS(NS, 'path'), dot = document.createElementNS(NS, 'circle'); dot.setAttribute('r', '2.5');\n    svg.append(line, dot);\n    const lab = document.createElement('div'); lab.className = 'vs-exploded-view-label';\n    lab.innerHTML = `<b>${String(i + 1).padStart(2, '0')}</b>${pp.name}<i>${pp.note}</i>`; labelLayer.appendChild(lab);\n    return { line, dot, lab, w: 0 };\n  });\n  const measure = () => L.forEach((l) => { l.w = l.lab.offsetWidth; l.hgt = l.lab.offsetHeight; });\n\n  function setMaterial(name) {\n    if (!M[name]) return; S.material = name; el.dataset.material = name;\n    meshes.forEach((m) => { m.material = M[name][m.userData.role]; m.children.forEach((c) => { if (c.userData.edge) c.visible = name === 'xray'; }); });\n    shadowU.uO.value = name === 'xray' ? 0.08 : 0.3;\n    kick();\n  }\n  function set(o = {}) {\n    if (o.explode != null) { S.to = THREE.MathUtils.clamp(+o.explode, 0, 1); el.dataset.explode = S.to.toFixed(3); if (reduced) S.e = S.to; }\n    if (o.material) setMaterial(o.material);\n    if (o.labels != null) { S.labelsOn = !!o.labels; el.dataset.labels = S.labelsOn ? 'on' : 'off'; }\n    kick();\n  }\n\n  function resize() {\n    const w = el.clientWidth, h = el.clientHeight; if (!w || !h) return;\n    const dpr = Math.min(devicePixelRatio, 2); renderer.setPixelRatio(dpr); renderer.setSize(w, h, false);\n    paperU.uDpr.value = dpr; paperU.uRes.value.set(w * dpr, h * dpr);\n    camera.aspect = w / h; camera.updateProjectionMatrix(); S.w = w; S.h = h;\n    S.portrait = w / h < 0.85;\n    // landscape: the axis runs across the frame, front element on the left; portrait: it runs down, front on top\n    holder.rotation.set(0, 0, S.portrait ? 0 : Math.PI / 2);\n    measure();\n  }\n\n  const v3 = new THREE.Vector3(), c3 = new THREE.Vector3(), up = new THREE.Vector3(), rt = new THREE.Vector3();\n  const proj = (v) => { v.project(camera); return [(v.x * 0.5 + 0.5) * S.w, (-v.y * 0.5 + 0.5) * S.h]; };\n\n  function layout(e) {\n    // spacing along the axis when exploded; the iris opens and the focus ring turns as they leave\n    // exploded slots: the parts packed front to back by their own length, with an even gap between them\n    const gap = S.portrait ? 0.5 : 0.56;\n    let total = -gap; groups.forEach((g) => { total += g.userData.hl * 2 + gap; });\n    let y = total / 2;\n    groups.forEach((g, i) => {\n      const slot = y - g.userData.hl; y -= g.userData.hl * 2 + gap;\n      const st = THREE.MathUtils.clamp(e * 1.18 - Math.abs(i - 4) * 0.02, 0, 1.2);\n      g.position.y = THREE.MathUtils.lerp(g.userData.y0, slot, st);\n    });\n    const focus = groups[2]; focus.rotation.y = e * 0.9;\n    const open = THREE.MathUtils.clamp(e, 0, 1);\n    irisOpen.value = 0.12 + open * 0.4;\n    groups[4].rotation.y = -e * 0.35;\n  }\n\n  function frame(now) {\n    S.raf = 0;\n    const dt = Math.min(0.04, (now - (S.last || now)) / 1000); S.last = now; S.t += dt;\n    // spring on the separation: slightly under-damped, so parts overshoot a hair and settle like a mechanism\n    const k = 70, c = 2 * Math.sqrt(k) * 0.72;\n    if (!reduced) { S.v += (k * (S.to - S.e) - c * S.v) * dt; S.e += S.v * dt; } else S.e = S.to;\n    const e = S.e;\n    layout(e);\n    // pointer orbit on a soft spring, plus a slow idle drift\n    const kk = 1 - Math.exp(-dt * 3.2);\n    S.rx += (S.hy * 0.22 - S.rx) * kk; S.ry += (S.hx * 0.38 - S.ry) * kk;\n    const idle = reduced ? 0 : Math.sin(S.t * 0.35) * 0.05;\n    if (S.portrait) turn.rotation.set(0.42 + S.hy * 0.0 + S.rx, -0.35 + S.ry + idle, 0, 'YXZ');\n    else turn.rotation.set(0.12 + S.rx, 0.58 + S.ry + idle, 0, 'YXZ');\n    // the camera pulls back as the parts spread, so the whole lens fits at any separation\n    const ee = THREE.MathUtils.clamp(e, -0.05, 1.1);\n    const fitLen = 3.2 + ee * 6.4, fitDia = 2.2;\n    const vfov = THREE.MathUtils.degToRad(camera.fov), hfov = 2 * Math.atan(Math.tan(vfov / 2) * camera.aspect);\n    const along = S.portrait ? fitLen / (2 * Math.tan(vfov / 2) * 0.8) : fitLen / (2 * Math.tan(hfov / 2) * 0.74);\n    const across = S.portrait ? fitDia / (2 * Math.tan(hfov / 2) * 0.5) : fitDia / (2 * Math.tan(vfov / 2) * 0.6);\n    const dist = Math.max(along, across) * 1.0 + 1.2;\n    const shift = S.portrait ? 2 * dist * Math.tan(hfov / 2) * 0.2 : 0;\n    camera.position.set(shift, S.portrait ? 0.6 : dist * 0.16, dist); camera.lookAt(shift, 0, 0);\n    shadow.visible = !S.portrait; shadow.scale.set(fitLen * 1.1, 2.2, 1); shadow.position.set(0, -1.5, 0);\n    renderer.render(scene, camera);\n    // ---- leaders: project each part's centre and an edge point, lay labels out in two rows (or two columns)\n    camera.updateMatrixWorld();\n    up.set(0, 1, 0).applyQuaternion(camera.quaternion); rt.set(1, 0, 0).applyQuaternion(camera.quaternion);\n    const vis = S.labelsOn ? 1 : 0;\n    const pts = groups.map((g, i) => {\n      g.getWorldPosition(c3);\n      // landscape: 02, 04, 06, 08 above and the rest below; portrait: every label in one column on the right\n      const side = S.portrait ? 1 : (i % 2 === 1 ? 1 : -1);\n      const dir = S.portrait ? rt : up;\n      v3.copy(c3).addScaledVector(dir, g.userData.r * side * 1.02);\n      const [x, y] = proj(v3);\n      return { x, y, side, i };\n    });\n    const show = (q, o) => { const l = L[q.i]; l.lab.style.opacity = o; l.line.style.opacity = l.dot.style.opacity = o; };\n    const fade = (i) => vis * THREE.MathUtils.smoothstep(e, 0.18 + i * 0.02, 0.4 + i * 0.02);\n    if (S.portrait) {\n      // one column: leaders run right to a shared x, labels stack top to bottom and never overlap\n      const colX = Math.max(...pts.map((q) => q.x)) + 22;\n      let prev = -1e9;\n      pts.slice().sort((a, b) => a.y - b.y).forEach((q) => {\n        const l = L[q.i]; const ly = Math.max(q.y - 8, prev + 6); prev = ly + l.hgt;\n        const ty = ly + 8;\n        l.line.setAttribute('d', `M${q.x.toFixed(1)} ${q.y.toFixed(1)}H${(colX - 10).toFixed(1)}L${colX.toFixed(1)} ${ty.toFixed(1)}`);\n        l.dot.setAttribute('cx', q.x); l.dot.setAttribute('cy', q.y);\n        l.lab.style.transform = `translate3d(${(colX + 6).toFixed(1)}px, ${ly.toFixed(1)}px, 0)`;\n        show(q, fade(q.i));\n      });\n    } else {\n      // two rows: a vertical leader to the row, then an elbow if the label had to slide right to clear its neighbour\n      for (const sd of [1, -1]) {\n        const row = pts.filter((q) => q.side === sd).sort((a, b) => a.x - b.x);\n        const rowY = sd > 0 ? Math.min(...row.map((q) => q.y)) - 34 : Math.max(...row.map((q) => q.y)) + 34;\n        let end = -1e9;\n        row.forEach((q) => {\n          const l = L[q.i]; const lx = Math.max(q.x - 3, end + 18); end = lx + l.w;\n          const ey = rowY;\n          const bend = lx + 3 - q.x;\n          const d = bend > 1 ? `M${q.x.toFixed(1)} ${q.y.toFixed(1)}V${(ey + (sd > 0 ? 10 : -10)).toFixed(1)}L${(q.x + Math.min(10, bend)).toFixed(1)} ${ey.toFixed(1)}H${(lx + 3).toFixed(1)}`\n                             : `M${q.x.toFixed(1)} ${q.y.toFixed(1)}V${ey.toFixed(1)}`;\n          l.line.setAttribute('d', d);\n          l.dot.setAttribute('cx', q.x); l.dot.setAttribute('cy', q.y);\n          const ly = sd > 0 ? ey - l.hgt - 6 : ey + 6;\n          l.lab.style.transform = `translate3d(${lx.toFixed(1)}px, ${ly.toFixed(1)}px, 0)`;\n          show(q, fade(q.i));\n        });\n      }\n    }\n    if (el.dataset.vsState !== 'ready') el.dataset.vsState = 'ready';\n    const moving = Math.abs(S.to - S.e) > 0.0005 || Math.abs(S.v) > 0.0005 || Math.abs(S.hx * 0.38 - S.ry) > 0.001 || Math.abs(S.hy * 0.22 - S.rx) > 0.001 || S.drag;\n    if (S.visible && !document.hidden && (moving || !reduced)) S.raf = requestAnimationFrame(frame);\n  }\n  const kick = () => { if (!S.raf && S.visible) { S.last = performance.now(); S.raf = requestAnimationFrame(frame); } };\n\n  // ---- input: wheel, drag along the axis, arrows; hover turns the lens\n  const onWheel = (e) => {\n    if (!('wheel' in el.dataset)) return;\n    const d = e.deltaY * (e.deltaMode === 1 ? 16 : 1);\n    if ((d > 0 && S.to >= 1) || (d < 0 && S.to <= 0)) return;     // at either end the page scrolls on\n    e.preventDefault(); set({ explode: S.to + d * 0.0011 });\n  };\n  const pos = (e) => { const r = el.getBoundingClientRect(); return [e.clientX - r.left, e.clientY - r.top]; };\n  const onMove = (e) => {\n    const [x, y] = pos(e);\n    S.hx = (x / S.w) * 2 - 1; S.hy = (y / S.h) * 2 - 1;\n    if (S.drag) { const d = S.portrait ? (y - S.drag.y) / (S.h * 0.55) : (x - S.drag.x) / (S.w * 0.45); set({ explode: S.drag.e + d }); }\n    kick();\n  };\n  const onDown = (e) => { if (e.button > 0) return; const [x, y] = pos(e); S.drag = { x, y, e: S.to }; el.dataset.dragging = ''; try { el.setPointerCapture(e.pointerId); } catch (_) {} kick(); };\n  const onUp = () => { S.drag = null; delete el.dataset.dragging; };\n  const onLeave = (e) => { if (e.pointerType === 'mouse' && !S.drag) { S.hx = S.hy = 0; kick(); } };\n  const onKey = (e) => {\n    if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); set({ explode: S.to + 0.1 }); }\n    else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); set({ explode: S.to - 0.1 }); }\n    else if (e.key === 'Home') set({ explode: 0 }); else if (e.key === 'End') set({ explode: 1 });\n  };\n  el.addEventListener('wheel', onWheel, { passive: false }); el.addEventListener('pointermove', onMove); el.addEventListener('pointerdown', onDown);\n  el.addEventListener('pointerup', onUp); el.addEventListener('pointercancel', onUp); el.addEventListener('pointerleave', onLeave); el.addEventListener('keydown', onKey);\n\n  const ro = new ResizeObserver(() => { resize(); kick(); }); ro.observe(el);\n  const io = new IntersectionObserver(([en]) => { S.visible = en.isIntersecting; if (S.visible) kick(); }, { rootMargin: '80px' }); io.observe(el);\n  const onVis = () => { if (!document.hidden) kick(); }; document.addEventListener('visibilitychange', onVis);\n  const onLost = (e) => { e.preventDefault(); cancelAnimationFrame(S.raf); S.raf = 0; S.visible = false; };\n  const onRestored = () => { destroy(); el.__vsRemount(); };\n  canvas.addEventListener('webglcontextlost', onLost); canvas.addEventListener('webglcontextrestored', onRestored);\n  if (document.fonts) document.fonts.ready.then(() => { measure(); kick(); });\n  function destroy() {\n    cancelAnimationFrame(S.raf); ro.disconnect(); io.disconnect(); document.removeEventListener('visibilitychange', onVis);\n    el.removeEventListener('wheel', onWheel); el.removeEventListener('pointermove', onMove); el.removeEventListener('pointerdown', onDown);\n    el.removeEventListener('pointerup', onUp); el.removeEventListener('pointercancel', onUp); el.removeEventListener('pointerleave', onLeave); el.removeEventListener('keydown', onKey);\n    canvas.removeEventListener('webglcontextlost', onLost); canvas.removeEventListener('webglcontextrestored', onRestored);\n    scene.traverse((o) => { if (o.geometry) o.geometry.dispose(); });\n    envRT.dispose(); pmrem.dispose(); renderer.dispose();\n    svg.innerHTML = ''; labelLayer.innerHTML = '';\n  }\n  resize(); setMaterial(S.material); kick();\n  return { set, destroy, get explode() { return S.to; } };\n}\n\ndocument.querySelectorAll('.vs-exploded-view:not([data-vs-mounted])').forEach((el) => {\n  el.dataset.vsMounted = '';\n  const go = () => { const api = mount(el); if (api) el.vsExplodedView = api; };\n  el.__vsRemount = go; go();\n});\n</script>\n"
    }
  ],
  "meta": {
    "category": "3d",
    "group": "3D objects",
    "tags": [
      "exploded view",
      "product",
      "lens",
      "labels",
      "scroll",
      "drag",
      "three.js",
      "technical"
    ],
    "tech": [
      "three.js",
      "GLSL",
      "JS",
      "CSS"
    ],
    "deps": [
      "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js",
      "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/environments/RoomEnvironment.js"
    ],
    "source": null,
    "free": true,
    "accent": "#e0452b",
    "added": "2026-09-30",
    "demo": "https://vividsites.app/api/component/exploded-view/index.html",
    "thumbnail": "https://vividsites.app/api/component/exploded-view/thumb.jpg",
    "video": "https://vividsites.app/api/component/exploded-view/thumb.mp4",
    "page": "https://vividsites.app/c/exploded-view"
  }
}