# XWUI3DProductViewer

Orbit-controlled 3D object viewer with studio lighting. A three-point rig (key /
fill / rim) sits on top of a generated gradient environment, so a polished
clearcoat metal actually has something to reflect; `OrbitControls` gives damped
drag-to-orbit with an idle auto-spin.

With **no data at all** it renders a clearcoat torus knot in the active accent
colour against a soft studio sweep - the zero-config case is a finished shot, not
an empty box. Give it `data.src` and it loads a glTF/glb instead.

## Not a backdrop

Its siblings (`XWUI3DHeroBG`, `XWUI3DParticleField`, `XWUI3DAuroraBackground`) are
pointer-transparent, `aria-hidden` layers with your content re-homed above them.
This one **is** the content:

- the canvas keeps its pointer events - that is how you orbit it;
- it carries `role="img"` + `data.alt` instead of `aria-hidden`;
- it does **not** set `preservesChildren` - there is no layer above the object.

Set `interactive: false` and the canvas goes pointer-transparent again, so a click
reaches whatever wraps the tile (a card link, typically).

## Lazy loading, two levels deep

three.js and `OrbitControls` load in the scene module's own chunk (dynamic import
inside `mountScene()`). `GLTFLoader` is imported one level deeper still - inside
`loadModel()` - so a viewer that only ever shows the primitive placeholder never
fetches the loader at all.

## Lifecycle

Rendering stops off-screen (`IntersectionObserver`) and on a hidden tab
(`visibilitychange`). Under `prefers-reduced-motion: reduce` the auto-spin and the
damping momentum are both switched off and no rAF loop is opened - the object
stays fully draggable and redraws on demand from OrbitControls' own `change`
event.

## Config

| Option | Type | Default | Notes |
|---|---|---|---|
| `autoRotate` | boolean | `true` | Idle spin. Dropped under reduced motion. |
| `autoRotateSpeed` | number | `1.4` | OrbitControls units; delta-corrected, so 144Hz and 60Hz spin alike. |
| `enableZoom` | boolean | `true` | Wheel / pinch. |
| `enablePan` | boolean | `false` | Off by default - this is not a free-cam. |
| `interactive` | boolean | `true` | Controls enabled **and** canvas takes pointer events. |
| `color` | string | `--accent-primary` | Placeholder tint. A `--custom-property` resolves from the theme. |
| `metalness` | number | `0.88` | Placeholder material. |
| `roughness` | number | `0.18` | Placeholder material. |
| `clearcoat` | number | `1` | The wet-lacquer layer over the base metal. |
| `envIntensity` | number | `2.4` | On a metal the environment *is* the colour - the main brightness lever. |
| `shape` | `'torusKnot' \| 'icosahedron' \| 'sphere'` | `'torusKnot'` | Placeholder primitive. |
| `background` | `'transparent' \| 'studio' \| 'env'` | `'studio'` | `transparent` still reflects the env, it just does not draw it. |
| `quality` | `'low' \| 'medium' \| 'high'` | `'high'` | Pixel ratio, env-map size, subdivision, AA. **Rebuilds the scene.** |
| `showHint` | boolean | `false` | Built-in "drag to rotate" caption. |
| `hintText` | string | - | Replaces the hint's contents (HTML permitted). |
| `height` | string | `'100%'` | Host height as a CSS length. |
| `className` | string | - | Extra class on the root. |

Data: `src?: string` (glTF/glb URL - omit for the placeholder), `alt?: string`
(accessible name for the canvas).

## Usage

```ts
import { XWUI3DProductViewer } from '@exonware/xwui/basic';

// Zero-config: clearcoat torus knot, studio sweep, auto-spin.
const viewer = new XWUI3DProductViewer(document.getElementById('shot')!);

viewer.logic.on('ready', (e) =>
  console.log(`${e.detail.triangles} tris from the ${e.detail.source}`)
);

// A real product, no zoom, pinned framing.
const product = new XWUI3DProductViewer(
  document.getElementById('product')!,
  { src: '/models/headphones.glb', alt: 'Studio headphones, rotatable' },
  { enableZoom: false, autoRotateSpeed: 0.8, background: 'transparent', showHint: true }
);

product.resetCamera();
```

A model that fails to load is **recoverable**: `failed` fires and the primitive
placeholder is revealed, so the host never shows an empty frame.

As a custom element:

```html
<xwui-3d-product-viewer
  src="/models/headphones.glb"
  alt="Studio headphones"
  background="studio"
  height="420px"
></xwui-3d-product-viewer>
```
