# dash-model-viewer — interactive 3D models and AR for Dash — full corpus

## Access policy

- Terms: these documents are free to fetch. A free account unlocks any gated document.
- Identity: agents may present a key by appending `?key=<value>` to any document URL. Get one: https://2plot.ai
- Rate: prefer ONE `/llms-full.txt` fetch over N per-page fetches. On 429, honour `Retry-After` and back off exponentially.
- Coordination: start at https://2plot.dev/llms.txt — one index enumerates every site; do not rediscover the network by crawling it.
- Crawler policy (mirrors /robots.txt): allowed: GPTBot, ClaudeBot, CCBot, Google-Extended, FacebookBot, Omgili, ByteSpider, Amazonbot, Applebot-Extended, meta-externalagent, AI2Bot, Diffbot, Timpibot, ImagesiftBot, ChatGPT-User, Claude-User, Claude-SearchBot, PerplexityBot, OAI-SearchBot, Perplexity-User, Googlebot, Bingbot, Slurp, DuckDuckBot, GoogleOther, Google-InspectionTool, Storebot-Google, AdsBot-Google.
- Accounting: every document read is logged with the requesting vendor (verified against published IP ranges where the operator publishes them). See https://2plot.dev/llms.txt

> dash-model-viewer — interactive 3D models and AR for Dash. Google's model-viewer vendored in the wheel: no CDN, works offline. By Pip Install Python.

Generated from 22 pages. The page index lives at https://modelviewer.2plot.dev/llms.txt and a compact briefing at https://modelviewer.2plot.dev/llms-small.txt; each page also serves its own document at `<page>/llms.txt`.

---

<!-- / — https://modelviewer.2plot.dev/llms.txt -->

# dash-model-viewer — interactive 3D models and AR for Dash

> **`dash-model-viewer` — embed interactive 3D models directly into your Dash applications with Augmented Reality (AR) support.** By [Pip Install Python](https://2plot.dev).

A Dash wrapper around Google's [`model-viewer`](https://modelviewer.dev/). Drag
the model above — then note that reading its camera, its dimensions, or a click
on it takes a normal Dash callback and no JavaScript at all.

```bash
pip install dash-model-viewer
```

```python
from dash import Dash, html
import dash_model_viewer as dmv

app = Dash(__name__)
app.layout = html.Div([
    dmv.ModelViewer(
        id="viewer",
        src="/assets/astronaut.glb",
        alt="A 3D model of an astronaut",
        style={"width": "100%", "height": "480px"},
    )
])
```

That is the whole setup. Importing the package installs the runtime.

---

## What is different in 1.0.0

This release is a rebuild, and it fixes two things that had been broken for the
package's entire published life.

### AR actually works out of the box

`ar_modes` used to default to `"basic_annotations scene-viewer quick-look"`.
`basic_annotations` is not an AR mode — it was the name of a folder in the
repository, copy-pasted into the default. `webxr` was therefore missing, so
in-page WebXR AR never ran unless you happened to override the prop. Nothing
errored; the flagship feature was just quietly degraded everywhere.

The default is now `"webxr scene-viewer quick-look"`, and the test suite
asserts it. → [Augmented reality](/augmented-reality)

### Events reach Python

0.0.1 had **no output props**. Its single `setProps` call was commented out, so
nothing the model did could reach your callbacks. Anything interactive needed a
JavaScript file in `assets/` and a `clientside_callback` to reach it.

The canonical camera-presets example was **230 lines** because of that. It is
now **47 lines of Python**, with no JavaScript.
→ [Camera and views](/camera-and-views)

| Prop | Updates when |
| :-- | :-- |
| `camera` | the user moves the camera (debounced, echo-suppressed) |
| `model_state` | loading, loaded, failed |
| `model_info` | on load — dimensions in metres, variants, animations |
| `ar_status` / `ar_tracking` | an AR session changes state |
| `scene_point` | the model is clicked, with `pick_on_click=True` |
| `Slot.n_clicks` | a hotspot is clicked |

---

## The bundle ships in the wheel

`model-viewer` used to be fetched at runtime from a hard-coded
`ajax.googleapis.com` URL pinned to 3.5.0 — not a declared dependency, not
pinnable by you, and unavailable offline, behind a corporate egress proxy, or
under a strict `script-src` Content-Security-Policy.

Version 4.3.1 is now vendored inside the package and injected by a Dash hook.
It costs about 1 MB per page, which is a real number and is stated plainly on
the [quick start](/quick-start) rather than buried. `configure(use_cdn=...)`
opts back out.

---

## Full upstream parity, permanently

`<model-viewer>` has ~70 attributes and gains more each release. This package
names about twenty and gives you two escape hatches that reach all of the rest
— including attributes that do not exist yet:

```python
dmv.ModelViewer(
    id="viewer", src=..., alt=...,
    mv_environment_image="neutral",          # -> environment-image
    attributes={"orientation": "0deg 0deg 15deg"},
)
```

No regeneration, no release, no waiting.
→ [Attributes and parity](/attributes-and-parity)

---

## Where to go next

| Page | What it covers |
| :-- | :-- |
| [Quick start](/quick-start) | Install, first viewer, and why the script order matters |
| [Events and callbacks](/events-and-callbacks) | Every output prop, and `camera_change_debounce` |
| [Camera and views](/camera-and-views) | Presets, flight, framing an unknown model |
| [Slots and hotspots](/slots-and-hotspots) | Anchoring any Dash component to the geometry |
| [Attributes and parity](/attributes-and-parity) | `attributes` and `mv_*` |
| [Augmented reality](/augmented-reality) | The three AR modes, and the fixed default |
| [Model switching](/model-switching) | Runtime `src` swaps and GLTF variants |
| [Scene Director](/scene-director) | **Generative.** Describe a shot; Claude stages it, grounded in the model's measured geometry |
| [Generative 3D art](/generative-3d) | **Generative.** Describe a sculpture; get a real `.glb` built from primitives |
| [Image to 3D](/image-to-3d) | **Generative.** Upload a picture; Claude reads it and code carves a relief |
| [Benchmark](/benchmark) | One prompt, several models or settings, sculptures side by side |
| [API reference](/api-reference) | Every prop, every payload |
| [Migrating from 0.0.1](/migrating) | Prop-by-prop map |

---

## Credits

Built on Google's [`model-viewer`](https://modelviewer.dev/) (Apache-2.0),
vendored at 4.3.1. Demo models are Google's own `model-viewer` shared assets
and the Khronos Group's glTF Sample Assets. The CC BY 4.0 models require
attribution, so: Materials Variants Shoe © 2021 Shopify; Glam Velvet Sofa
© 2021 Wayfair, LLC (Eric Chadwick); Glass Hurricane Candle Holder © 2021
Wayfair, LLC (Eric Chadwick). Sheen Chair (© 2020 Wayfair, LLC) and
Scattering Skull (© 2025 Vladimir Petkovic) are CC0 1.0.

[Source on GitHub](https://github.com/pip-install-python/dash-model-viewer) ·
[PyPI](https://pypi.org/project/dash-model-viewer/)

---

<!-- /animation — https://modelviewer.2plot.dev/animation/llms.txt -->

# Animation

> Play a model's built-in animation clips, switch between them with a crossfade, and pause — entirely through attributes, with the clip list reported by the model itself.

---



### Clips come from the model, not from the page

A `.glb` can carry animation clips, and `model_info["animations"]` lists the
ones it has. Robot Expressive carries fourteen — `Dance`, `Death`, `Idle`,
`Jump`, `No`, `Punch`, `Running`, `Sitting`, `Standing`, `ThumbsUp`, `Walking`,
`WalkJump`, `Wave`, `Yes`.

The dropdown is filled from that prop, so this page would work unchanged if you
pointed `src` at a different animated model. A hardcoded list of those fourteen
names would have looked identical today and broken silently the first time
somebody swapped the model.



```python
# File: docs/animation/animation.py

from dash import Input, Output, State, callback, html, no_update
import dash_mantine_components as dmc

import dash_model_viewer as dmv
from lib.demo_models import ROBOT

#: Lighting that makes a moving figure readable. A matte grey character against
#: a flat background reads as a bug report; this reads as a character.
BASE_ATTRS = {
    "environment-image": "neutral",
    "shadow-softness": "0.6",
}

#: Milliseconds of blend when switching clips. 0 is a hard cut and is the
#: default most people expect to be wrong — see the page.
DEFAULT_CROSSFADE = 300

component = html.Div(
    [
        dmc.Group(
            [
                dmc.Select(
                    id="an-name",
                    label="Animation",
                    # Filled from the model itself, not hardcoded — see below.
                    data=[],
                    w=220,
                    allowDeselect=False,
                ),
                dmc.NumberInput(
                    id="an-crossfade",
                    label="Crossfade (ms)",
                    value=DEFAULT_CROSSFADE,
                    min=0,
                    max=2000,
                    step=100,
                    w=150,
                ),
                dmc.Switch(
                    id="an-playing",
                    label="Playing",
                    checked=True,
                    mt="xl",
                ),
            ],
            mb="sm",
            align="flex-end",
        ),
        dmv.ModelViewer(
            id="an-viewer",
            src=ROBOT,
            alt="An expressive cartoon robot playing one of its built-in animation clips",
            camera_controls=True,
            shadow_intensity=1,
            # `autoplay` starts the first clip. Without it the model loads in
            # its bind pose and looks broken rather than idle.
            attributes={**BASE_ATTRS, "autoplay": ""},
            style={"width": "100%", "height": "460px"},
        ),
        dmc.Text(id="an-status", size="sm", c="dimmed", mt="xs"),
    ]
)


@callback(
    Output("an-name", "data"),
    Output("an-name", "value"),
    Output("an-status", "children"),
    Input("an-viewer", "model_info"),
    State("an-name", "value"),
)
def list_animations(info, current):
    """The viewer reports the clips; the page never hardcodes them.

    `model_info["animations"]` is the same prop /model-switching uses for
    material variants, and it arrives with the `load` event. So this picker
    works for ANY model the src is pointed at — which is the point, and is
    what a hardcoded list of Robot Expressive's fourteen clips would have
    quietly broken the moment somebody changed the model.
    """
    if not info:
        return [], None, "Loading the model…"
    clips = info.get("animations") or []
    if not clips:
        return [], None, "This model carries no animation clips."
    chosen = current if current in clips else clips[0]
    return clips, chosen, f"{len(clips)} clips: {', '.join(clips)}"


@callback(
    Output("an-viewer", "attributes"),
    Input("an-name", "value"),
    Input("an-crossfade", "value"),
    Input("an-playing", "checked"),
)
def drive_animation(name, crossfade, playing):
    """Everything here is an ATTRIBUTE, not a method call.

    1.0.0 ships no imperative surface — no `play()`, no `pause()` — so the
    whole of this page runs through the `attributes` escape hatch. That is the
    honest demonstration of what the hatch is for.

    `paused` is a boolean attribute: its PRESENCE pauses. So resuming means
    leaving the key out of the dict entirely rather than setting it false, and
    the shim removes any attribute that disappears between renders.
    """
    if not name:
        return no_update
    attrs = {
        **BASE_ATTRS,
        "autoplay": "",
        "animation-name": name,
        "animation-crossfade-duration": str(crossfade or 0),
    }
    if not playing:
        attrs["paused"] = ""
    return attrs
```


It is the same prop that drives the variant dropdown on
[Model Switching](/model-switching) — one read-only payload, two features.

---

### Everything here is an attribute

There is no `animation_name` prop on `ModelViewer`, and that is deliberate:
1.0.0 ships **no imperative surface** — no `play()`, no `pause()`, no
`activateAR()`. So this entire page runs through the `attributes` escape hatch,
which makes it the most honest demonstration on the site of what that hatch is
for.

| Attribute | Effect |
| :-- | :-- |
| `autoplay` | Start playing on load. Without it the model loads in its bind pose, which reads as broken rather than as idle. |
| `animation-name` | Which clip. Changing it switches clip. |
| `animation-crossfade-duration` | Milliseconds to blend between clips. |
| `paused` | **Presence** pauses. |

```python
dmv.ModelViewer(
    id="viewer",
    src=ROBOT,
    alt="A robot, mid-wave",
    attributes={"autoplay": "", "animation-name": "Wave"},
)
```


    Resuming means **leaving the key out of the dict**, not setting it to
    `False` or `"false"`. An HTML boolean attribute is true by presence, so
    `paused="false"` is still paused.

    The shim removes any attribute that disappears between renders, so building
    a fresh dict each time — as the callback above does — is the pattern that
    works. `False` and `None` also remove, if you prefer to be explicit.

---

### Crossfade is the setting worth playing with

Set it to `0` and switching clips is a hard cut: the robot teleports from one
pose into the next. At `300` ms the poses blend and it reads as a character
changing its mind.

The default in `<model-viewer>` is `300`, and it is one of the few upstream
defaults this page leaves alone — it is already the right answer, which is
worth knowing before you reach for it.

---

### What is not here

Honest about the edges, because the gap is in the package rather than in this
page:

- **No scrubbing and no speed control.** `currentTime` and `timeScale` are
  JavaScript properties with no attribute equivalent, so they are unreachable
  from Python in 1.0.0. A clientside callback can drive them — see the one on
  [Texture Upload](/texture-upload) for the shape — but there is no prop.
- **No "animation finished" event.** `<model-viewer>` fires no such event, so
  there is nothing for the shim to forward.
- **No `appendAnimation`.** Composing clips from several files is an imperative
  API.

If you need any of those today, the escape hatch is JavaScript. If enough
people need them, they are props — which is the better answer and is not in
this release.

---

### Models that actually carry clips

Of the demo models this site uses, **only Robot Expressive has animations** —
measured by reading each `.glb`'s JSON chunk, not assumed. The astronaut, the
shoe, the sofa, the chair, the skull and the glass all have none, which is why
this page does not offer them: a dropdown that is empty for six of seven
choices is how [Model Switching](/model-switching) used to read before its
models were chosen for the feature it documents.


---

*Source: /animation*

---

<!-- /api — https://modelviewer.2plot.dev/api/llms.txt -->

# API reference

## dash_model_viewer

### ModelViewer

An interactive 3D model with Augmented Reality support.

| prop | type | default | description |
|---|---|---|---|
| `id` | string \| dict |  | Component id for callbacks. |
| `alt` * | string |  | Accessible description of the model. Required, and not decorative: it is the only thing a screen-reader user gets. |
| `ar` | boolean | True | Enable AR and show the AR affordance where supported. |
| `ar_modes` | string | webxr scene-viewer quick-look | Space-separated AR back-ends, most preferred first. |
| `ar_scale` | 'auto' \| 'fixed' | auto | Whether AR may rescale the model. |
| `ar_status` | string |  | `session-started`, `object-placed`, `failed`, ... |
| `ar_tracking` | string |  | `tracking` or `not-tracking`. |
| `attributes` | dict |  | Raw kebab-case model-viewer attributes.  Read-only props, updated by the component: |
| `camera` | dict |  | `{"orbit", "target", "field_of_view"}`. Only user interaction is reported — programmatic changes are suppressed, or a callback that writes `camera_orbit` would re-trigger itself. |
| `camera_change_debounce` | number | 100 | Milliseconds to coalesce `camera-change` events before updating `camera`. **Do not set this to 0 casually** — the event fires at frame rate, so 0 means a callback per frame per viewer. |
| `camera_controls` | boolean | True | Let the user orbit, zoom and pan. |
| `camera_orbit` | string |  | `"theta phi radius"`, e.g. `"45deg 70deg 2.5m"`. Two-way: also reported back through `camera`. |
| `camera_target` | string |  | `"X Y Z"` in metres, e.g. `"0m 1m 0m"`. |
| `children` | list of components |  | `Slot` components. Hotspots, a custom AR button, a poster, a progress bar — every one of those is a named slot. |
| `class_name` | string |  | CSS class for the viewer element. |
| `field_of_view` | string |  | e.g. `"30deg"`.  - min_field_of_view / max_field_of_view (string; optional): Zoom limits.  - min_camera_orbit / max_camera_orbit (string; optional): Orbit limits, `"auto auto auto"` for none. |
| `interpolation_decay` | number |  | Camera transition speed. Lower is slower; `0` is instant. This is what makes a programmatic camera move read as a flight rather than a jump-cut. |
| `max_camera_orbit` | Optional[str] |  |  |
| `max_field_of_view` | Optional[str] |  |  |
| `min_camera_orbit` | Optional[str] |  |  |
| `min_field_of_view` | Optional[str] |  |  |
| `model_info` | dict |  | `{"dimensions": {"x","y","z"}, "variants": [...], "animations": [...]}`, set on load. Dimensions are in metres — this is the prop that removes the bounding-box maths from user JS. |
| `model_state` | dict |  | `{"status": "loading" \| "loaded" \| "error", "progress": float}`. |
| `pick_on_click` | boolean | False | When True, clicking the model reports the 3D surface point under the cursor through `scene_point`. |
| `poster` | string |  | Image shown until the model is ready. |
| `scene_point` | dict |  | `{"position", "normal", "uv"}` for the last picked point, when `pick_on_click` is set. |
| `shadow_intensity` | number |  | 0 to 1. |
| `src` * | string |  | URL of the `.glb` / `.gltf` model. |
| `style` | dict |  | CSS for the viewer element. It has no intrinsic size — give it one. |
| `tone_mapping` | string | neutral | `neutral`, `aces`, `agx`, `commerce`, ... |
| `touch_action` | 'pan-y' \| 'pan-x' \| 'none' | pan-y | Which touch gestures the page keeps rather than the model. |
| `variant_name` | string |  | GLTF material variant. `None` (or `"default"`) selects the model's default. |

### Slot

A named slot inside a :class:`ModelViewer`.

| prop | type | default | description |
|---|---|---|---|
| `id` | string \| dict |  | Component id for callbacks. |
| `children` | list of components |  | Slot content. Any Dash component. |
| `class_name` | string |  | Added alongside the built-in `dmv-slot` class. |
| `n_clicks` | number | 0 | Increments on click. Use as a callback `Input` — this is how a hotspot drives a camera move without any clientside JavaScript. |
| `normal` | string |  | `"X Y Z"` surface normal; controls hotspot occlusion behaviour. |
| `position` | string |  | `"X Y Z"` in model space. Required for hotspots. |
| `slot` * | string |  | The slot name, e.g. `"hotspot-1"`, `"ar-button"`, `"poster"`. |
| `style` | dict |  | CSS for the slot element. |

---

<!-- /api-reference — https://modelviewer.2plot.dev/api-reference/llms.txt -->

# API Reference

> Every prop on ModelViewer and Slot, every event payload, and the precedence rules.

---



### `dmv.ModelViewer`

#### Required

**Enforced.** Omitting either raises a `TypeError` at construction naming the
missing prop — the same error, in the same words, that every Dash component
raises for a required argument. Passing the keyword explicitly as `None` fails
the same way, because `alt=None` is not an accessible description.

| Prop | Type | Notes |
| :-- | :-- | :-- |
| `src` | `str` | URL of the `.glb` / `.gltf`. Relative paths resolve against `assets/`. |
| `alt` | `str` | Accessible description. Not decorative — it is the entire experience for a screen-reader user. |

#### Layout

| Prop | Type | Default | Notes |
| :-- | :-- | :-- | :-- |
| `id` | `str \| dict` | — | Callback id. Pattern-matching dicts work. |
| `style` | `dict` | — | **Give it a height.** The element has no intrinsic size. |
| `class_name` | `str` | — | CSS class on the viewer element, e.g. `class_name="hero-viewer"`. Styles it from `assets/`; the element itself is the styling target, not a wrapper. |
| `children` | list of `Slot` | — | Hotspots, AR button, poster, progress bar. |

#### Camera

| Prop | Type | Default | Notes |
| :-- | :-- | :-- | :-- |
| `camera_controls` | `bool` | `True` | Orbit, zoom, pan. |
| `touch_action` | `'pan-y' \| 'pan-x' \| 'none'` | `'pan-y'` | Which gestures the page keeps. The default lets a phone scroll the page vertically *through* the viewer; `touch_action="none"` gives every gesture to the model and is right only for a full-screen viewer. |
| `camera_orbit` | `str` | — | `"theta phi radius"`. Two-way. |
| `camera_target` | `str` | — | `"X Y Z"` in metres. |
| `field_of_view` | `str` | — | e.g. `"30deg"`. |
| `min_field_of_view` / `max_field_of_view` | `str` | — | Zoom limits, e.g. `min_field_of_view="15deg"`, `max_field_of_view="45deg"` to stop the user zooming to a blank close-up or losing the model in the distance. |
| `min_camera_orbit` / `max_camera_orbit` | `str` | — | Orbit limits as `"theta phi radius"`; `"auto auto auto"` for none. `max_camera_orbit="auto 90deg auto"` is the common one — it stops the camera dropping below the floor plane. |
| `interpolation_decay` | `float` | — | Camera easing. Lower is slower; `0` is a jump cut. |
| `camera_change_debounce` | `float` | `100` | Milliseconds. **Do not set to 0 without meaning it.** |

#### Rendering

| Prop | Type | Default | Notes |
| :-- | :-- | :-- | :-- |
| `poster` | `str` | — | Image shown until the model is ready, e.g. `poster="/assets/astronaut.png"`. Worth setting for a large `.glb`: without one the viewer is an empty box for the whole download. |
| `tone_mapping` | `str` | `'neutral'` | |
| `shadow_intensity` | `float` | — | |
| `variant_name` | `str \| None` | `None` | |

#### Augmented reality

| Prop | Type | Default |
| :-- | :-- | :-- |
| `ar` | `bool` | `True` |
| `ar_modes` | `str` | `'webxr scene-viewer quick-look'` |
| `ar_scale` | `'auto' \| 'fixed'` | `'auto'` |

#### Escape hatches

| Prop | Type | Notes |
| :-- | :-- | :-- |
| `attributes` | `dict[str, str]` | Raw kebab-case attributes. |
| `mv_*` | `str` | Wildcard. `mv_environment_image` → `environment-image`. |

Precedence: **named prop > `mv_*` > `attributes`**.

#### Read-only (set by the component)

| Prop | Payload |
| :-- | :-- |
| `camera` | `{"orbit": str, "target": str, "field_of_view": str}` — only for user interaction |
| `model_state` | `{"status": "loading" \| "loaded" \| "error", "progress": float, "detail": str?}` |
| `model_info` | `{"dimensions": {"x": float, "y": float, "z": float}, "variants": [str], "animations": [str]}` |
| `ar_status` | `"not-presenting" \| "session-started" \| "object-placed" \| "failed"` |
| `ar_tracking` | `"tracking" \| "not-tracking"` |
| `scene_point` | `{"position": str, "normal": str, "uv": [float, float]}` or `None` |

#### Input-only

| Prop | Type | Default | Notes |
| :-- | :-- | :-- | :-- |
| `pick_on_click` | `bool` | `False` | Arms `scene_point`. |

---

### `dmv.Slot`

| Prop | Type | Default | Notes |
| :-- | :-- | :-- | :-- |
| `slot` | `str` | — | **Required.** `"hotspot-*"`, `"ar-button"`, `"ar-prompt"`, `"ar-failure"`, `"poster"`, `"progress-bar"`. |
| `children` | components | — | Any Dash component. |
| `position` | `str` | — | `"X Y Z"` in model space. Required for hotspots. |
| `normal` | `str` | — | `"X Y Z"` surface normal; drives occlusion. |
| `id` | `str \| dict` | — | Callback id. |
| `style` | `dict` | — | |
| `class_name` | `str` | — | Added alongside the built-in `dmv-slot`, e.g. `class_name="hotspot-dot"` — the built-in class stays, so a rule on `.dmv-slot` still applies. |
| `n_clicks` | `int` | `0` | Increments on click. Use as an `Input`. |

---

### Module level

| Name | Type | Notes |
| :-- | :-- | :-- |
| `dmv.__version__` | `str` | Read from installed metadata, not from a bundled JSON file. |
| `dmv.MODEL_VIEWER_VERSION` | `str` | The vendored `<model-viewer>` version — `"4.3.1"`. |
| `dmv.DEFAULT_AR_MODES` | `str` | `"webxr scene-viewer quick-look"`. |
| `dmv.DEFAULT_CAMERA_CHANGE_DEBOUNCE` | `int` | `100`. |
| `dmv.configure(use_cdn=...)` | function | See below. |

#### `configure(use_cdn=False)`

```python
dmv.configure(use_cdn=False)                        # default: vendored (in-wheel)
dmv.configure(use_cdn=True)                         # public jsDelivr, pinned version
dmv.configure(use_cdn="https://cdn.example/mv.js")  # your own mirror
```

Must run **before the first request is served**. Dash reads the hook's resource
list while generating the index page, so a call from inside a callback or a
lazily-imported page module may or may not take effect. Put it at module scope.

---

### Not in 1.0.0

Stated so you do not go looking:

- **Animation playback props.** Use `attributes={"autoplay": "", "animation-name": ...}`.
- **Imperative methods** — no `play()`, `pause()`, or programmatic `activateAR()`.
- **`ios_src` as a named prop.** Use `mv_ios_src`.
- **Annotation/label auto-layout.** `Slot` positions are yours to compute.


---

*Source: /api-reference*

---

<!-- /attribute-tour — https://modelviewer.2plot.dev/attribute-tour/llms.txt -->

# Attribute Tour

> The model-viewer attributes with no named prop — loading, reveal, scale, skybox, pan and tap locks, and the AR-only ones — each shown or honestly marked as unshowable.

---



### Why this page exists

[Attributes and parity](/attributes-and-parity) argues that the `attributes`
dict and `mv_*` wildcards reach every attribute `<model-viewer>` has. This page
is the evidence: it drives eleven attributes that have **no named prop on
`ModelViewer`**, and says plainly which of them you cannot see from a desktop
browser.

It was written from an audit against `modelviewer.dev`'s own example pages.
Those eleven were reachable from this package on the day it shipped and
demonstrated nowhere — which is a documentation gap rather than a capability
one, and the kind the 1.0.0 review kept finding.



```python
# File: docs/attribute-tour/attribute_tour.py

from dash import Input, Output, State, callback, html
import dash_mantine_components as dmc

import dash_model_viewer as dmv
from lib.demo_models import ASTRONAUT, MOON_HDR

#: Always on, so the model is lit and the ground reads as ground.
BASE = {"environment-image": "neutral", "shadow-softness": "0.6"}

SCALES = {"1 1 1": "actual size", "0.5 0.5 0.5": "half", "2 2 2": "double"}


def build_attrs(no_pan, no_tap, no_prompt, scale, skybox, skybox_height,
                loading, reveal):
    """The attribute dict, from the controls. Pure, so it can be tested."""
    attrs = dict(BASE)
    attrs["scale"] = scale
    attrs["loading"] = loading
    attrs["reveal"] = reveal

    # Boolean attributes: present means on. OMITTED when off, never "false" —
    # "false" is a present value and would leave them on. See the page.
    if no_pan:
        attrs["disable-pan"] = ""
    if no_tap:
        attrs["disable-tap"] = ""
    if no_prompt:
        attrs["interaction-prompt"] = "none"
    if skybox:
        attrs["skybox-image"] = MOON_HDR
        # Only meaningful with a skybox; sending it alone does nothing, which
        # is why it is nested rather than listed beside the others.
        if skybox_height:
            attrs["skybox-height"] = f"{skybox_height}m"
    return attrs


def viewer(attrs):
    return dmv.ModelViewer(
        id="at-viewer",
        src=ASTRONAUT,
        alt="An astronaut used to demonstrate model-viewer attributes that have no named prop",
        camera_controls=True,
        shadow_intensity=1,
        attributes=attrs,
        style={"width": "100%", "height": "460px"},
    )


CONTROLS = dmc.Stack(
    gap="sm",
    children=[
        dmc.Text("What the user may do", size="xs", fw=700),
        dmc.Switch(id="at-pan", label="disable-pan", checked=False),
        dmc.Switch(id="at-tap", label="disable-tap", checked=False),
        dmc.Switch(id="at-prompt", label="interaction-prompt: none", checked=False),
        dmc.Divider(),
        dmc.Text("The scene", size="xs", fw=700),
        dmc.Select(
            id="at-scale", label="scale",
            data=[{"value": k, "label": f"{k} — {v}"} for k, v in SCALES.items()],
            value="1 1 1", allowDeselect=False,
        ),
        dmc.Switch(id="at-skybox", label="skybox-image", checked=False),
        dmc.NumberInput(
            id="at-skybox-height", label="skybox-height (m)",
            value=0, min=0, max=10, step=1,
        ),
        dmc.Divider(),
        dmc.Text("When it loads", size="xs", fw=700),
        dmc.Select(
            id="at-loading", label="loading",
            data=["auto", "lazy", "eager"], value="auto", allowDeselect=False,
        ),
        dmc.Select(
            id="at-reveal", label="reveal",
            data=["auto", "interaction"], value="auto", allowDeselect=False,
        ),
        dmc.Button(
            "Reload the model",
            id="at-reload",
            variant="light",
            mt="xs",
        ),
        dmc.Text(
            "loading and reveal only act while a model is loading. "
            "Press this to see them.",
            size="xs", c="dimmed",
        ),
    ],
)

component = html.Div(
    [
        dmc.Grid(
            gutter="md",
            children=[
                dmc.GridCol(CONTROLS, span={"base": 12, "md": 4}),
                dmc.GridCol(
                    # The viewer is HERE, in the initial layout, and updates in
                    # place. An earlier version rebuilt it from a callback on
                    # every control, which reloaded the model and reset the
                    # camera each time — so `disable-pan` and `disable-tap` did
                    # work and were impossible to SEE, and the remount reset the
                    # idle timer so `interaction-prompt: none` made the prompt
                    # reappear. The reload is now an explicit button.
                    html.Div(id="at-mount", children=viewer(build_attrs(
                        False, False, False, "1 1 1", False, 0, "auto", "auto"
                    ))),
                    span={"base": 12, "md": 8},
                ),
            ],
        ),
        dmc.Code(id="at-readout", block=True, mt="sm"),
    ]
)

_CONTROL_STATE = (
    Input("at-pan", "checked"),
    Input("at-tap", "checked"),
    Input("at-prompt", "checked"),
    Input("at-scale", "value"),
    Input("at-skybox", "checked"),
    Input("at-skybox-height", "value"),
    Input("at-loading", "value"),
    Input("at-reveal", "value"),
)


@callback(
    Output("at-viewer", "attributes"),
    Output("at-readout", "children"),
    *_CONTROL_STATE,
)
def apply_attributes(*values):
    """Update the live element IN PLACE — no reload, no camera reset.

    That is what makes `disable-pan`, `disable-tap` and `interaction-prompt`
    observable: you can toggle one and immediately try the gesture against the
    same view. The shim diffs the dict and removes whatever disappeared.
    """
    attrs = build_attrs(*values)
    rendered = "\n".join(f'{k}="{v}"' if v else k for k, v in sorted(attrs.items()))
    return attrs, rendered


@callback(
    Output("at-mount", "children"),
    Input("at-reload", "n_clicks"),
    *[State(dep.component_id, dep.component_property) for dep in _CONTROL_STATE],
    prevent_initial_call=True,
)
def reload_model(_clicks, *values):
    """Remount, and ONLY on request.

    `loading` and `reveal` do nothing to a model that has already loaded, so
    they need a fresh mount to be observable at all. Doing that on every
    control change — as this page first did — obscured every other attribute
    on it. A button makes the reload deliberate and leaves the rest alone.
    """
    return viewer(build_attrs(*values))
```


The block under the viewer is the attribute dict as it reaches the element, so
you can read what each control actually sent.

---

### Reload is a button, not a side effect

Most controls here update the live element **in place** — toggle `disable-pan`
and you can immediately try a two-finger drag against the same view, with the
same camera, and see the difference.

`loading` and `reveal` are the exception: they only act **while a model is
loading**, so they do nothing to a viewer that has already loaded. That is what
the *Reload the model* button is for.


    The first version rebuilt the viewer on *every* control change, so that
    `loading` and `reveal` would always be observable. It made everything else
    worse: toggling `disable-pan` reloaded the model and reset the camera, so
    the attribute worked and there was no way to *see* that it had — and the
    remount reset the idle timer, so `interaction-prompt: none` caused the
    prompt to **reappear** rather than stop.

    Reported from testing, and the fix is the split above. It is a good example
    of a demo page being wrong in a way the code is not: every attribute
    reached the element correctly the whole time.

---

### What you can see from a laptop

| Attribute | Values | What to look for |
| :-- | :-- | :-- |
| `scale` | `"1 1 1"`, `"2 2 2"` | The model gets bigger. Note the shadow scales with it. |
| `disable-pan` | present | Two-finger drag (or right-drag) no longer slides the model sideways. |
| `disable-tap` | present | A single tap no longer recentres the camera on the tapped point. |
| `interaction-prompt` | `none` | The animated hand stops appearing after a few idle seconds. |
| `skybox-image` | an `.hdr` URL | The grey background becomes the environment, and the model is lit by it. |
| `skybox-height` | e.g. `2m` | Only with a skybox: lifts the horizon so the model sits *in* the scene rather than floating in a sphere. Alone it does nothing, which is why the control is nested under the skybox toggle. |
| `loading` | `auto`, `lazy`, `eager` | `lazy` defers until the viewer is near the viewport; `eager` fetches immediately. Watch the network panel, not the picture. |
| `reveal` | `auto`, `interaction` | `interaction` holds the poster until you click. |

`reveal="manual"` is deliberately absent from the control. It holds the model
until `dismissPoster()` is called — an **imperative method**, and 1.0.0 ships
no imperative surface, so there is no way to dismiss it from Python. Offering
the value would produce a viewer that never reveals, which is a trap rather
than a demonstration.

---

### What you cannot see from a laptop

These three are real, reachable and **not demonstrable here**. Listing them
with their values beats pretending the page covers them:

| Attribute | Values | Needs |
| :-- | :-- | :-- |
| `ar-placement` | `floor`, `wall` | A phone in AR. `wall` anchors the model to a vertical surface — the right choice for a picture frame or a television. |
| `xr-environment` | present | A WebXR session on Android. Lights the model with the room's estimated lighting instead of the `environment-image`. |
| `ios-src` | a `.usdz` URL | iOS Safari. Quick Look cannot read `.glb`, so an iOS AR path needs a second file. There is no named prop; use `mv_ios_src` or `attributes`. |

```python
dmv.ModelViewer(
    id="viewer", src="/assets/chair.glb", alt="A chair",
    mv_ios_src="/assets/chair.usdz",
    attributes={"ar-placement": "floor", "xr-environment": ""},
)
```

Walk those on [Augmented reality](/augmented-reality) with a real device. A
desktop browser will report AR as unavailable and tell you nothing about
whether the attributes landed.

---

### Boolean attributes, once more

`disable-pan`, `disable-tap` and `xr-environment` are true **by presence**. So:

```python
attributes={"disable-pan": ""}     # panning is off
attributes={}                      # panning is on
attributes={"disable-pan": "false"}  # STILL OFF — "false" is a present value
```

The shim removes any attribute that disappears between renders, so building a
fresh dict each time — as the callback on this page does — is the pattern that
works. `False` and `None` also remove, if you would rather be explicit than
rely on absence.

The same trap as `paused` on [Animation](/animation), and it is worth meeting
twice: it is the single most common way an `attributes` dict does the opposite
of what it reads like.


---

*Source: /attribute-tour*

---

<!-- /attributes-and-parity — https://modelviewer.2plot.dev/attributes-and-parity/llms.txt -->

# Attributes and Parity

> Reach every model-viewer attribute — including ones added upstream after this release — without waiting for a new version of this package.

---



### The problem this solves

`<model-viewer>` has roughly seventy attributes and gains more with every
release. A wrapper that hand-lists them is out of date the day upstream ships a
new one, and every addition costs a regeneration, a release, and an upgrade on
your side.

`dash-model-viewer` names about twenty of them and gives you two escape hatches
that reach **all** of the rest — including attributes that do not exist yet.

| Family | Looks like | Use it for |
| :-- | :-- | :-- |
| Named props | `camera_controls=True` | The common ones. Validated, documented, autocompleted. |
| `mv_*` wildcards | `mv_environment_image="neutral"` | Anything else, one at a time, at the call site. |
| `attributes` dict | `attributes={"exposure": "1.2"}` | Anything else, as data — from a callback, a config file, or a model. |

Precedence when the same attribute is set twice: **named prop > `mv_*` >
`attributes`**.

---

### Live

Every attribute driven by this control is one `dash-model-viewer` has no named
prop for. Nothing was regenerated to make them work.



```python
# File: docs/attributes-and-parity/parity.py

from dash import Input, Output, callback, html
import dash_mantine_components as dmc

import dash_model_viewer as dmv
from lib.demo_models import CANDLE_GLASS, MOON_HDR

# Not one of these is a named prop on ModelViewer. They work anyway.
PRESETS = {
    "Studio": {"environment-image": "neutral", "exposure": "1"},
    "Moonlight": {"environment-image": MOON_HDR, "exposure": "1.4"},
    "Spinning": {"auto-rotate": "", "auto-rotate-delay": "0", "rotation-per-second": "20deg"},
    "Tilted": {"orientation": "0deg 0deg 20deg", "exposure": "1.1"},
}

component = html.Div(
    [
        dmc.SegmentedControl(
            id="ap-preset",
            data=list(PRESETS),
            value="Studio",
            fullWidth=True,
            mb="sm",
        ),
        dmv.ModelViewer(
            id="ap-viewer",
            # Glass, not the astronaut: this page is about lighting attributes,
            # and physically-based transparency and refraction respond to
            # `environment-image` and `exposure` far more visibly than a matte
            # spacesuit does. No camera props here — model-viewer auto-frames,
            # so swapping the model needs no hand-tuned orbit.
            src=CANDLE_GLASS,
            alt="A glass candle holder re-lit by attributes the package has no named prop for",
            shadow_intensity=1,
            style={"width": "100%", "height": "380px"},
        ),
        dmc.Code(id="ap-readout", block=True, mt="sm"),
    ]
)


@callback(
    Output("ap-viewer", "attributes"),
    Output("ap-readout", "children"),
    Input("ap-preset", "value"),
)
def apply_preset(name):
    attrs = PRESETS[name]
    rendered = "\n".join(f'{k}="{v}"' for k, v in attrs.items())
    return attrs, rendered
```


---

### `mv_*` — the ergonomic form

Python `snake_case` becomes kebab-case attributes:

```python
dmv.ModelViewer(
    id="viewer", src=..., alt=...,
    mv_environment_image="neutral",     # environment-image="neutral"
    mv_auto_rotate_delay="0",           # auto-rotate-delay="0"
    mv_disable_tap="",                  # disable-tap  (bare attribute)
)
```

Pass `""` for boolean-style attributes whose meaning is presence, not value.

---

### `attributes` — the data form

Use this when the attribute set is computed rather than typed:

```python
@callback(Output("viewer", "attributes"), Input("theme", "value"))
def relight(theme):
    return {
        "environment-image": "neutral" if theme == "light" else MOON_HDR,
        "exposure": "1.0" if theme == "light" else "1.4",
        "shadow-softness": "0.8",
    }
```

Because it is an ordinary dict, it can come from anywhere — a database, a user
preference, a JSON config, or a language model's structured output. That last
one is the interesting case: the output space of "configure this viewer" is the
entire `<model-viewer>` attribute surface, forever, with no allow-list to
maintain.


    The shim diffs each render against the previously applied set and removes
    anything that has gone away. Returning a dict without a key you previously
    set removes that attribute — you do not need a sentinel value. It also
    means the shim never re-writes an attribute that has not changed, so
    `model-viewer` is never interrupted mid-animation.

---

### When to ask for a named prop instead

The escape hatches are complete, not equal. A named prop earns its place when
the attribute:

- needs validation (a typo in `attributes` is silent — the browser ignores
  unknown attributes);
- has a non-obvious default worth documenting;
- is two-way, like `camera_orbit`;
- or needs a Python-side type that is not a string.

If you find yourself writing the same `attributes` entry in every project,
that is a good argument for a named prop —
[open an issue](https://github.com/pip-install-python/dash-model-viewer/issues).

---

### The full attribute list

Upstream, and always current:
[modelviewer.dev/docs](https://modelviewer.dev/docs/). This package vendors
`model-viewer` **4.3.1**; `dmv.MODEL_VIEWER_VERSION` reports it at runtime, so
you can check the docs against what you are actually running.


---

*Source: /attributes-and-parity*

---

<!-- /augmented-reality — https://modelviewer.2plot.dev/augmented-reality/llms.txt -->

# Augmented Reality

> Place a model in a real room — and the default that silently disabled WebXR on Android for the whole life of 0.0.1.

---



### The bug this release exists to fix

`ar_modes` in 0.0.1 defaulted to:

```
"basic_annotations scene-viewer quick-look"
```

**`basic_annotations` is not an AR mode.** It is the name of a folder in the
repository's `usage_tests/` directory, copy-pasted into the default value.

The consequence was not a warning or an error. `<model-viewer>` reads the list,
does not recognise the first entry, ignores it, and falls through to the rest —
so AR still worked via Scene Viewer on Android and Quick Look on iOS. What was
missing was `webxr`, the mode that gives you in-page AR with your own UI and
the only one that supports custom AR prompts and placement.

So the package's flagship feature was degraded in every default installation,
for its entire published life, and nothing looked broken. The hub documentation
had always listed the *correct* value, so the docs and the code never agreed.

1.0.0 defaults to:

```
"webxr scene-viewer quick-look"
```

`tests/test_components.py` asserts both that `webxr` is present and that
`basic_annotations` is absent.

---

### AR in practice



```python
# File: docs/augmented-reality/ar_viewer.py

from dash import Input, Output, callback, html
import dash_mantine_components as dmc

import dash_model_viewer as dmv
from lib.demo_models import ASTRONAUT

component = html.Div(
    [
        dmv.ModelViewer(
            id="ar-viewer",
            src=ASTRONAUT,
            alt="An astronaut, placeable in your room with AR",
            ar=True,
            # This is the default. Shown explicitly because the equivalent
            # line in 0.0.1 read "basic_annotations scene-viewer quick-look".
            ar_modes="webxr scene-viewer quick-look",
            ar_scale="auto",
            shadow_intensity=1,
            # Two AR attributes with no named prop. They are set HERE rather
            # than on /attribute-tour because only a phone in a real AR session
            # can show whether they did anything — a desktop toggle for either
            # would be a control that demonstrates nothing.
            #
            #   ar-placement="floor"  — anchor to the floor. "wall" is the other
            #     value and is right for a frame or a television.
            #   xr-environment        — present: light the model from the room's
            #     estimated lighting instead of `environment-image`.
            #
            # `ios-src` is deliberately absent: Quick Look cannot read `.glb`,
            # so it needs a second `.usdz` file that this repo does not ship.
            # Setting it to a file that does not exist would break iOS AR to
            # document an attribute.
            attributes={"ar-placement": "floor", "xr-environment": ""},
            style={"width": "100%", "height": "400px"},
            children=[
                dmv.Slot(
                    slot="ar-button",
                    children=dmc.Button("View in your space", variant="filled"),
                ),
                dmv.Slot(
                    slot="ar-failure",
                    children=dmc.Alert("AR lost tracking — try more light.",
                                       color="red"),
                ),
            ],
        ),
        dmc.Text(id="ar-readout", size="sm", mt="sm"),
    ]
)


@callback(
    Output("ar-readout", "children"),
    Input("ar-viewer", "ar_status"),
    Input("ar-viewer", "ar_tracking"),
)
def report(status, tracking):
    if not status:
        return "On a phone, tap the button above. On desktop, nothing happens — by design."
    return f"ar_status: {status} · ar_tracking: {tracking or 'n/a'}"
```



    AR needs a phone. On a desktop browser the AR button does not appear at
    all, `ar_status` never fires, and everything above is inert — which is
    correct behaviour, not a broken example.

    Open **modelviewer.2plot.dev/augmented-reality** on an Android or iOS
    device to try it.

---

### The three AR modes

| Mode | Platform | What you get |
| :-- | :-- | :-- |
| `webxr` | Android, Chrome | In-page AR. Your own UI overlays the camera feed; `ar-prompt` and `ar-failure` slots work; placement and scale are reported back. |
| `scene-viewer` | Android | Hands off to Google's system AR viewer. Reliable, but it leaves your page. |
| `quick-look` | iOS, Safari | Hands off to Apple's AR Quick Look. Requires a USDZ — `<model-viewer>` generates one, or supply `ios-src`. |

Order matters: it is a preference list, and the first supported mode wins.
`"webxr scene-viewer quick-look"` means "in-page if you can, system viewer
otherwise".

---

### Reacting to the session

```python
@callback(Output("cart", "disabled"), Input("viewer", "ar_status"))
def in_ar(status):
    return status == "session-started"
```

`ar_status` values: `not-presenting`, `session-started`, `object-placed`,
`failed`. `ar_tracking` is `tracking` or `not-tracking`, and is how you know to
tell the user their room is too dark.

---

### iOS needs a USDZ

Quick Look does not read glTF. `<model-viewer>` converts on the fly, but the
conversion is lossy for complex materials and costs a round trip. For anything
you care about, supply your own:

```python
dmv.ModelViewer(
    id="viewer", src="/assets/chair.glb", alt="A chair",
    mv_ios_src="/assets/chair.usdz",
)
```

---

### `ar_scale`

`"auto"` (default) lets AR place the model at real-world scale, derived from
the glTF's units. `"fixed"` keeps the model's own scene units regardless.

Use `"auto"` for anything a user might want to check the size of — furniture,
appliances, equipment. Use `"fixed"` when the model is not a real object, or
when its units are not trustworthy.


    WebXR requires a secure context. On `http://` — including a plain
    `localhost` tunnel to a phone — the AR button will not appear, and there
    will be no error explaining why.


---

*Source: /augmented-reality*

---

<!-- /benchmark — https://modelviewer.2plot.dev/benchmark/llms.txt -->

# Benchmark

> Run one prompt across several models, efforts or token budgets at once and compare the sculptures side by side.

---



### Overview

[Generative 3D art](/generative-3d) answers *can it build this?* This page
answers the question you have immediately afterwards: **what do I give up by
turning the knob down?**

It sends one prompt to several settings at once and puts the sculptures next to
each other. Part counts, triangle counts and token totals rank the runs; looking
at them tells you whether the extra tokens actually bought anything — which is
not the same question, and is why every cell renders a real `<model-viewer>`
rather than a row in a table.

---

### Live demo


---

### What the four axes mean

**Model** is the biggest lever, and not in the direction you would guess. See
the measured sweep below.

**Effort** is thinking depth. It is only sent to models that accept
`output_config.effort`; varying it across a model that rejects the parameter
would run N identical calls and present them as a comparison, so the page
refuses that combination rather than producing a confident-looking result.

**Max tokens** bounds thinking **and** response text together on Opus 5, so it
is not a safety net — it is a quality dial with a cliff. Set it too low and the
JSON is truncated mid-object and the variant fails outright. When that happens
the panel says `stop=max_tokens`, because "the model did not return usable
JSON" is a misleading way to describe running out of budget.

**Prompt version** is the newest axis and the only one that varies *this
site's own instructions* rather than the model's settings. **v1** is what every
other page uses. **v2** teaches the model version 2 of the
[scene manifest](/scene-manifest) — defining a shape once and placing it
several times — and adds one further instruction: that parts **may and should
interpenetrate where they join**, because a lamp sits *into* the top of its
tower rather than balancing on it.

v2 is not the default anywhere a sculpture is generated, and that is
deliberate. Its entire claim is "it produces better sculptures", and only model
runs can show that. Making it the default on an argument would be exactly the
mistake this page exists to prevent, so v2 runs **here and nowhere else** until
a sweep says it earns the promotion.

Every result panel carries the number that claim turns into: of the part pairs
close enough to read as joined, how many actually **blend** — interpenetrate —
rather than merely touching. It is computed from the manifest by geometry
alone, with no rendering and no second call, so it costs nothing and cannot
flatter the run. Pick the prompt axis, keep everything else fixed, and the
difference in that percentage is attributable to the instructions and nothing
else.

Only one axis varies per run. Two moving variables make a comparison
unreadable, and a full grid is a combinatorial bill.

---

### A measured sweep, and what it suggests

One prompt — *"a desert observatory, sandstone and brass, dish pointed at the
sky"* — at `effort=low`, 4,000 tokens, all three models, run concurrently:

| Model | Time | Parts | **Colours** | Triangles | Out tokens | Cost |
| :-- | --: | --: | --: | --: | --: | --: |
| Haiku 4.5 | 15.7 s | 19 | **8** | 4,146 | 2,450 | $0.014 |
| Sonnet 5 | 22.6 s | 19 | 13 | 6,842 | 2,447 | $0.042 |
| Opus 5 | 30.3 s | 22 | 12 | 5,850 | 2,837 | $0.079 |

*32 s wall clock against 69 s if run one at a time. $0.135 total.*

Read that carefully, because it is not the shape people expect:

- **Haiku produced the same part count as Sonnet** at a third of the cost and
  half the latency.
- **Haiku followed the palette rule best.** The system prompt asks for *three or
  four colours reused deliberately*. Haiku used 8; Sonnet used 13; Opus used 12.
  None of them obeyed, but the cheapest model came closest — and palette
  discipline is the single thing that most decides whether the output reads as a
  sculpture rather than a test scene.
- **Opus bought 3 extra parts for 5.8× the price.**

That does not mean "always use Haiku". It means the default on
[/generative-3d](/generative-3d) — Opus 5 at `medium` — is a choice worth
re-examining against your own prompts, which is exactly what this page is for.


    Everything above is a single run per cell. Model output varies between
    runs, and a three-colour difference is well inside that variance. Repeat a
    comparison before you act on it. The table is here to show what the page
    produces, not to settle the question.

---

### Why colour count is the quality metric

Part count is the obvious number and it is nearly useless: a busier sculpture is
not a better one, and 25 parts of noise scores higher than 12 parts of
composition.

Palette size is better *for this task specifically*, because the prompt makes an
explicit, checkable demand:

> Pick a deliberate palette of three or four colours and reuse them. A different
> colour per part looks like a test scene, not a sculpture.

So `distinct(baseColorFactor)` measures **instruction adherence on the one
instruction that most affects whether the result looks composed.** It is
computed from the built `.glb` rather than the model's own claims, so it cannot
be gamed by a manifest that says one thing and builds another.

It is still a proxy. The viewers are there to be looked at.

---

### Concurrency is the whole reason this is usable

A sculpt takes about 30 seconds. Four run serially is two minutes, and nobody
runs a second sweep after waiting two minutes.

```python
with concurrent.futures.ThreadPoolExecutor(max_workers=len(variants)) as pool:
    for index, result in pool.map(one, list(enumerate(variants))):
        results.append((index, result))
```

Bounded by the variant count, which `MAX_VARIANTS` already caps at 4. The status
line reports both numbers — wall time and the serial equivalent — so the saving
is visible rather than asserted.

**Four, not six.** The excalidraw benchmark this borrows from allows six.
Here each cell carries its entire `.glb` inline as a data URL, a few hundred KB
apiece, so the callback response grows with the matrix in a way a list of
drawing elements does not.

---

### Spending

This page can spend four times per click, on a public host, with no sign-in.
`tier: auth` does not cover that: with no Clerk keys configured — the default,
and what the free deployment runs — every tier except `hidden` degrades to
`public`. That degradation is correct for *reading documentation* and useless as
a spend gate, because it fails open.

So the ceiling lives in `lib/spend.py` and does not depend on identifying
anyone:

| Limit | Default | Env var |
| :-- | :-- | :-- |
| Calls per rolling window | 40 / hour | `MODEL_MAX_CALLS_PER_WINDOW`, `MODEL_WINDOW_SECONDS` |
| Cumulative estimated spend | $5.00 | `MODEL_MAX_SPEND_USD` |

Both are checked **before** the matrix runs, priced pessimistically — every
variant costed as if it used its whole output budget. The remaining allowance is
shown next to the button, so a refusal is never a surprise.

Two honest limitations:

- **It is a blast-radius limit, not an accounting system.** The dollar figures
  are local estimates from a static price table, and ignore cache-write premiums
  and any discount. The real bill comes from Anthropic.
- **The counters are process-local.** With the single gunicorn worker
  `render.yaml` runs on the free plan that is the whole deployment; add workers
  and the effective ceiling multiplies by the worker count. A shared limit needs
  Redis or a database, which is a real dependency for a docs site to carry.

---

### Source


```python
# File: docs/benchmark/benchmark.py

"""Run one prompt across several settings at once and compare the sculptures.

/generative-3d answers "can it build this?". This page answers the question you
have immediately afterwards: **what do I give up by turning the knob down?**
— which needs the same prompt built several ways, side by side, with the cost
of each.

FOUR THINGS THAT SHAPE THE DESIGN

1. Variants run CONCURRENTLY. They are network-bound, so a four-cell matrix
   costs roughly one sculpt's wall time instead of four. A sculpt is ~30s;
   run serially, a sweep takes two minutes and nobody runs a second one.

2. The cost is shown BEFORE you press the button, not after. An estimate that
   arrives with the results is an apology rather than a decision.

3. Every cell renders its own `<model-viewer>`. The numbers rank the runs;
   only looking at them tells you whether the extra tokens bought anything.

4. MAX_VARIANTS is 4, not 6 as on the excalidraw page this borrows from. Each
   cell carries its entire `.glb` inline as a data URL — a few hundred KB —
   so the callback response grows with the matrix in a way a list of drawing
   elements does not.
"""

from __future__ import annotations

import concurrent.futures
import time
import uuid

import dash_mantine_components as dmc
from dash import Input, Output, State, callback, dcc, html, no_update

import dash_model_viewer as dmv
from lib import model_picker, overlap, spend
from lib.sculptor import (
    DEFAULT_PROMPT_VERSION, MAX_TOKENS, PROMPT_VERSION_LABELS,
    PROMPT_VERSIONS, sculpt,
)

MAX_VARIANTS = 4

EFFORT_CHOICES = spend.EFFORTS
BUDGET_CHOICES = ["2000", "4000", "8000", "16000"]

VIEWER_ATTRS = {
    "environment-image": "neutral",
    "exposure": "1.1",
    "shadow-softness": "0.7",
}


def _panel(result, viewer_id: str):
    """One cell: its settings, its numbers, and its sculpture."""
    if not result.ok:
        return dmc.GridCol(
            dmc.Paper(
                withBorder=True, p="sm",
                children=dmc.Stack(gap=4, children=[
                    dmc.Badge(result_label(result), color="red", variant="light"),
                    dmc.Text(result.reason, size="xs", c="red"),
                ]),
            ),
            span={"base": 12, "md": 6},
        )

    return dmc.GridCol(
        dmc.Paper(
            withBorder=True, p="sm",
            children=dmc.Stack(gap="xs", children=[
                dmc.Group(gap=6, children=[
                    dmc.Badge(result_label(result), variant="light"),
                    dmc.Badge(f"{result.part_count} parts", color="violet", variant="light"),
                    # The palette count is the interesting one — see the .md.
                    dmc.Badge(f"{result.palette} colours", color="grape", variant="light"),
                    dmc.Badge(f"{result.seconds:.0f}s", color="gray", variant="light"),
                    dmc.Badge(f"~${result.usd:.3f}", color="teal", variant="light"),
                ]),
                # G7a's column. Placed with the badges rather than buried in
                # the small print because it is the number the prompt axis
                # exists to move.
                dmc.Text(blend(result), size="xs", c="indigo"),
                dmc.Text(
                    f"{result.output_tokens:,} out / {result.input_tokens:,} in"
                    f"  ·  {result.triangles:,} triangles"
                    f"  ·  {len(result.glb) / 1024:.0f} KB"
                    + (f"  ·  stop={result.stop_reason}"
                       if result.stop_reason not in ("end_turn", "") else "")
                    + (f"  ·  {len(result.notes)} clamped" if result.notes else ""),
                    size="xs", c="dimmed",
                ),
                dmv.ModelViewer(
                    id=viewer_id,
                    src=result.data_url,
                    alt=f"Generated sculpture: {result.manifest.get('name', 'untitled')}",
                    camera_controls=True,
                    shadow_intensity=1,
                    attributes=VIEWER_ATTRS,
                    style={"width": "100%", "height": "300px"},
                ),
            ]),
        ),
        span={"base": 12, "md": 6},
    )


def result_label(result) -> str:
    short = result.model.replace("claude-", "")
    return (f"{short} · {result.effort} · {result.max_tokens:,} tok · "
            f"prompt {result.prompt_version}")


def blend(result) -> str:
    """G7a's interpenetration rate for one cell, from geometry alone.

    THE NUMBER THE PROMPT AXIS EXISTS TO MOVE. The owner's words were that the
    output is "a lot of rigid shapes ... able to blend better together", and
    v2's joint guidance is the attempt at it. Of the part pairs close enough to
    read as joined, what fraction actually interpenetrate rather than merely
    touching? Computed from the manifest the model returned — no rendering, no
    second call — so it costs nothing to show and cannot flatter the run.
    """
    if not result.ok or not result.manifest:
        return ""
    try:
        report = overlap.report(result.manifest)
    except Exception:                                     # noqa: BLE001
        return ""
    if report["rate"] is None:
        return f"{report['parts']} parts, no joints"
    return (f"{report['overlapping']}/{report['joints']} joints blend "
            f"({report['rate']:.0%})")


component = dmc.Stack(
    gap="md",
    children=[
        dmc.Alert(
            title="This page spends real API credits",
            color="yellow",
            children=(
                "Each variant is a separate paid model call. The estimate below "
                "updates as you change the matrix — check it before running. "
                "This host also enforces a rate limit and a spend ceiling "
                "(lib/spend.py)."
            ),
        ),
        dmc.Paper(withBorder=True, p="md", children=dmc.Stack(gap="sm", children=[
            dmc.SegmentedControl(
                id="bm-axis",
                data=[
                    {"value": "effort", "label": "Vary effort"},
                    {"value": "budget", "label": "Vary max tokens"},
                    {"value": "model", "label": "Vary model"},
                    {"value": "prompt", "label": "Vary prompt version"},
                ],
                value="model",
                fullWidth=True,
            ),
            # Only one axis varies at a time. Two moving variables make a
            # comparison unreadable, and a full grid is a combinatorial bill.
            dmc.CheckboxGroup(
                id="bm-efforts",
                label="Effort levels to compare",
                value=["low", "high"],
                children=dmc.Group([dmc.Checkbox(label=e, value=e)
                                    for e in EFFORT_CHOICES], gap="md"),
            ),
            dmc.CheckboxGroup(
                id="bm-budgets",
                label="Max-token budgets to compare",
                value=["2000", "8000"],
                children=dmc.Group([dmc.Checkbox(label=f"{int(b):,}", value=b)
                                    for b in BUDGET_CHOICES], gap="md"),
            ),
            dmc.CheckboxGroup(
                id="bm-models",
                label="Models to compare",
                value=["claude-haiku-4-5", "claude-opus-5"],
                # Children filled on first render — see lib/model_picker.py.
                # Built at import, this froze the Anthropic-only list because
                # pages are imported before run.py warms OpenAI discovery.
                children=dmc.Group(id="bm-model-boxes", gap="md"),
            ),
            dmc.CheckboxGroup(
                id="bm-prompts",
                label="Prompt versions to compare",
                description=(
                    "v1 is what every other page uses. v2 teaches defs/ref/group "
                    "and tells the model parts may interpenetrate where they "
                    "join — it is measured here before it is believed anywhere."
                ),
                value=list(PROMPT_VERSIONS),
                children=dmc.Group([
                    dmc.Checkbox(label=PROMPT_VERSION_LABELS[v], value=v)
                    for v in PROMPT_VERSIONS
                ], gap="md"),
            ),
            dcc.Interval(id="bm-model-init", interval=150, max_intervals=1),
            dmc.Text(id="bm-model-status", size="xs", c="dimmed"),
            dmc.Grid(gutter="md", children=[
                dmc.GridCol(dmc.Select(
                    id="bm-fixed-model", label="Fixed model",
                    data=[], value="claude-opus-5",
                ), span={"base": 12, "sm": 4}),
                dmc.GridCol(dmc.Select(
                    id="bm-fixed-effort", label="Fixed effort",
                    data=[{"value": e, "label": e} for e in EFFORT_CHOICES],
                    value="low",
                ), span={"base": 12, "sm": 4}),
                dmc.GridCol(dmc.NumberInput(
                    id="bm-fixed-budget", label="Fixed max tokens",
                    value=MAX_TOKENS, min=1000, max=32000, step=1000,
                ), span={"base": 12, "sm": 4}),
                dmc.GridCol(dmc.Select(
                    id="bm-fixed-prompt", label="Fixed prompt version",
                    data=[{"value": v, "label": PROMPT_VERSION_LABELS[v]}
                          for v in PROMPT_VERSIONS],
                    value=DEFAULT_PROMPT_VERSION,
                ), span={"base": 12, "sm": 4}),
            ]),
            dmc.Textarea(
                id="bm-prompt",
                label="Prompt",
                description="The same prompt goes to every variant — that is what makes them comparable",
                value="a desert observatory, sandstone and brass, dish pointed at the sky",
                autosize=True, minRows=2,
            ),
            dmc.Group([
                dmc.Button("Run benchmark", id="bm-run"),
                dmc.Text(id="bm-estimate", size="sm", c="dimmed"),
            ], justify="space-between"),
        ])),
        dmc.Alert(id="bm-status", title="Status", color="gray", children="Ready."),
        dmc.Text(
            "Running every variant concurrently — about as long as one sculpt.",
            id="bm-working", size="sm", c="dimmed", display="none",
        ),
        dcc.Loading(html.Div(id="bm-results"), type="dot"),
    ],
)


def _variants(axis, efforts, budgets, models, f_model, f_effort, f_budget,
              prompts=None, f_prompt=DEFAULT_PROMPT_VERSION):
    """(model, effort, max_tokens, prompt_version) per cell. One axis moves."""
    f_budget = int(f_budget or MAX_TOKENS)
    f_prompt = f_prompt or DEFAULT_PROMPT_VERSION
    if axis == "effort":
        return [(f_model, e, f_budget, f_prompt)
                for e in (efforts or [])[:MAX_VARIANTS]]
    if axis == "budget":
        return [(f_model, f_effort, int(b), f_prompt)
                for b in (budgets or [])[:MAX_VARIANTS]]
    if axis == "prompt":
        # The reason this axis exists: the same model, the same budget, the
        # same words — only the system prompt differs, so a difference in the
        # result is attributable to the prompt and nothing else.
        return [(f_model, f_effort, f_budget, v)
                for v in (prompts or [])[:MAX_VARIANTS]]
    return [(m, f_effort, f_budget, f_prompt)
            for m in (models or [])[:MAX_VARIANTS]]


@callback(
    Output("bm-estimate", "children"),
    Input("bm-axis", "value"),
    Input("bm-efforts", "value"),
    Input("bm-budgets", "value"),
    Input("bm-models", "value"),
    Input("bm-fixed-model", "value"),
    Input("bm-fixed-effort", "value"),
    Input("bm-fixed-budget", "value"),
    Input("bm-prompts", "value"),
    Input("bm-fixed-prompt", "value"),
)
def _estimate(axis, efforts, budgets, models, f_model, f_effort, f_budget,
              prompts, f_prompt):
    """Price the matrix BEFORE it runs. See the module docstring."""
    variants = _variants(axis, efforts, budgets, models, f_model, f_effort,
                         f_budget, prompts, f_prompt)
    if not variants:
        return "Select at least one variant."
    total = sum(spend.estimate_usd(m, t) for m, _e, t, _v in variants)
    left = spend.remaining()
    return (f"{len(variants)} variant{'s' if len(variants) != 1 else ''} · "
            f"up to ~${total:.2f} if every one uses its full budget · "
            f"{left.calls_left} calls / ${left.usd_left:.2f} left on this host")


@callback(
    Output("bm-results", "children"),
    Output("bm-status", "children"),
    Output("bm-status", "color"),
    Input("bm-run", "n_clicks"),
    State("bm-axis", "value"),
    State("bm-efforts", "value"),
    State("bm-budgets", "value"),
    State("bm-models", "value"),
    State("bm-fixed-model", "value"),
    State("bm-fixed-effort", "value"),
    State("bm-fixed-budget", "value"),
    State("bm-prompt", "value"),
    State("bm-prompts", "value"),
    State("bm-fixed-prompt", "value"),
    running=[
        (Output("bm-run", "loading"), True, False),
        (Output("bm-run", "disabled"), True, False),
        (Output("bm-working", "display"), "block", "none"),
    ],
    prevent_initial_call=True,
)
def _run(_clicks, axis, efforts, budgets, models, f_model, f_effort, f_budget,
         prompt, prompts, f_prompt):
    if not (prompt or "").strip():
        return no_update, "Write a prompt first.", "yellow"

    variants = _variants(axis, efforts, budgets, models, f_model, f_effort,
                         f_budget, prompts, f_prompt)
    if not variants:
        return no_update, "Select at least one variant to compare.", "yellow"

    # Varying effort across a model that ignores the parameter would run N
    # identical calls and present them as a comparison — worse than an error,
    # because the output looks like a result.
    if axis == "effort" and f_model not in spend.EFFORT_CAPABLE:
        return (no_update,
                f"{f_model} does not accept the effort parameter, so every variant "
                "would be identical. Pick another model, or vary a different axis.",
                "red")
    # One gate for the whole matrix, priced pessimistically.
    estimate = sum(spend.estimate_usd(m, t) for m, _e, t, _v in variants)
    verdict = spend.check(len(variants), estimate)
    if not verdict.allowed:
        return no_update, verdict.reason, "red"

    run_id = uuid.uuid4().hex[:8]
    started = time.monotonic()

    def one(item):
        index, (model, effort, budget, version) = item
        began = time.monotonic()
        try:
            # The per-call budget gate is already satisfied for the matrix as a
            # whole; re-checking per variant would fail the tail of a run that
            # was approved, which is worse than approving it once.
            result = sculpt(prompt.strip(), model=model, effort=effort,
                            max_tokens=budget, enforce_budget=False,
                            prompt_version=version)
        except Exception as exc:  # one bad variant must not lose the others
            from lib.sculptor import SculptResult
            result = SculptResult(ok=False, reason=f"{type(exc).__name__}: {exc}",
                                  model=model, effort=effort, max_tokens=budget,
                                  prompt_version=version)
        if not result.seconds:
            result.seconds = time.monotonic() - began
        return index, result

    results = []
    with concurrent.futures.ThreadPoolExecutor(max_workers=len(variants)) as pool:
        for index, result in pool.map(one, list(enumerate(variants))):
            results.append((index, result))
    results = [r for _, r in sorted(results, key=lambda x: x[0])]

    elapsed = time.monotonic() - started
    ok = [r for r in results if r.ok]
    total_cost = sum(r.usd for r in results)
    serial = sum(r.seconds for r in results)

    status = (f"{len(ok)}/{len(results)} variants in {elapsed:.0f}s "
              f"(≈{serial:.0f}s if run one at a time) · ~${total_cost:.3f} total")
    if axis == "model" and any(m not in spend.EFFORT_CAPABLE for m, _e, _t in variants):
        status += "  ·  effort not sent to models that reject it"

    grid = dmc.Grid(gutter="md", children=[
        _panel(r, f"bm-view-{run_id}-{i}") for i, r in enumerate(results)
    ])
    return grid, status, "green" if len(ok) == len(results) else "yellow"


@callback(
    Output("bm-model-boxes", "children"),
    Output("bm-fixed-model", "data"),
    Output("bm-model-status", "children"),
    Output("bm-run", "disabled"),
    Input("bm-model-init", "n_intervals"),
)
def _fill_models(_n):
    """Fill both model controls when the page is VIEWED.

    Page modules are imported while Dash registers pages, which is BEFORE
    run.py calls openai_client.warm(). Reading the list at import froze the
    Anthropic-only set into this page and no OpenAI key could ever change it.
    """
    options = spend.model_options()
    boxes = [dmc.Checkbox(label=m["label"], value=m["value"]) for m in options]
    # Disabled with no provider key. This page spends up to four times per
    # click, so an enabled button on a keyless host is the worst of the three.
    return boxes, options, model_picker.status_line(), not spend.any_provider_available()
```

    :defaultExpanded: false
    :withExpandedButton: true

---

### This site runs without provider keys

The AI demos on this page are **off on modelviewer.2plot.dev**, deliberately.
This is a documentation site; it carries no provider keys and does no
production spend (owner's decision, 2026-09-12).

So on the public site you will see the model picker empty, the generate
control disabled, and a line saying so. **That is the expected state, not a
fault** — please do not file it.

To try it, run the site locally with your own keys:

```bash
git clone https://github.com/pip-install-python/dash-model-viewer
cd dash-model-viewer
printf 'ANTHROPIC_API_KEY=sk-ant-...\nCHATGPT_API_KEY=sk-...\n' > .env
pip install -r requirements.txt && python run.py
```

Either key alone is enough — the picker offers whichever provider it finds.
`lib/spend.py`'s ceiling then applies locally: a rolling call limit and a
cumulative dollar estimate, so an accident costs a few cents rather than a
weekend.

Everything on this page that does **not** need a key still works and is worth
reading for it: the JSON schema, the clamps, the sRGB-to-linear conversion and
the dependency-free glTF writer in `lib/glb.py` are all plain Python.


---

*Source: /benchmark*

---

<!-- /camera-and-views — https://modelviewer.2plot.dev/camera-and-views/llms.txt -->

# Camera and Views

> Camera presets, flight between them, and the 230-line clientside example that became 47 lines of Python.

---



### 230 lines to 47

This exact example — an astronaut with clickable camera presets — was the
canonical `dash-model-viewer` demo. Under 0.0.1 it was **230 lines**, almost
all of it a `clientside_callback` and a hand-written JavaScript file in
`assets/`, because the component could neither report its camera nor accept a
click.

It is now **47 lines of Python**, no JavaScript, no `assets/` file.



```python
# File: docs/camera-and-views/camera_views.py

from dash import ALL, Input, Output, callback, ctx, html
import dash_mantine_components as dmc

import dash_model_viewer as dmv
from lib.demo_models import ASTRONAUT

# Each preset is one camera pose. Clicking its hotspot flies the camera there.
VIEWS = {
    "Helmet": {"orbit": "0deg 75deg 1.2m", "target": "0m 1.7m 0m", "fov": "30deg"},
    "Torso": {"orbit": "-30deg 85deg 2m", "target": "0m 1.1m 0m", "fov": "40deg"},
    "Boots": {"orbit": "45deg 100deg 1.6m", "target": "0m 0.2m 0m", "fov": "35deg"},
    "Full": {"orbit": "0deg 80deg 4m", "target": "0m 1m 0m", "fov": "45deg"},
}

component = html.Div(
    [
        dmv.ModelViewer(
            id="cv-viewer",
            src=ASTRONAUT,
            alt="An astronaut, with camera presets for the helmet, torso and boots",
            camera_orbit="0deg 80deg 4m",
            camera_target="0m 1m 0m",
            interpolation_decay=120,
            style={"width": "100%", "height": "420px"},
        ),
        dmc.Group(
            [
                dmc.Button(name, id={"type": "cv-view", "name": name},
                           size="xs", variant="light")
                for name in VIEWS
            ],
            mt="sm",
        ),
    ]
)


@callback(
    Output("cv-viewer", "camera_orbit"),
    Output("cv-viewer", "camera_target"),
    Output("cv-viewer", "field_of_view"),
    Input({"type": "cv-view", "name": ALL}, "n_clicks"),
    prevent_initial_call=True,
)
def fly_to(_):
    view = VIEWS[ctx.triggered_id["name"]]
    return view["orbit"], view["target"], view["fov"]
```


The whole mechanism is a pattern-matching callback returning three props. There
is nothing clever left in it, which is the point.

---

### The camera props

| Prop | Format | Notes |
| :-- | :-- | :-- |
| `camera_orbit` | `"theta phi radius"` | e.g. `"45deg 75deg 2.5m"`. `phi` is measured from +Y, so `0deg` is directly overhead and `90deg` is level with the model. |
| `camera_target` | `"X Y Z"` | Metres. The point the camera looks at and orbits around. |
| `field_of_view` | `"30deg"` | Smaller is more telephoto — and more flattering for a product shot. |
| `min_camera_orbit` / `max_camera_orbit` | `"auto auto auto"` | Per-component limits; `auto` means "no limit on this axis". |
| `min_field_of_view` / `max_field_of_view` | `"25deg"` / `"auto"` | Zoom limits. |
| `interpolation_decay` | number | **The one that makes it feel good.** |

---

### `interpolation_decay` is what turns a cut into a flight

Set the camera props with no `interpolation_decay` and the view *jumps*. The
model appears to teleport, and users read that as a bug even when it is not.

`interpolation_decay` is the time constant of the camera's easing. Lower is
slower and more cinematic; higher snaps harder; `0` is instant.

```python
dmv.ModelViewer(..., interpolation_decay=120)   # unhurried, used above
dmv.ModelViewer(..., interpolation_decay=50)    # brisk
dmv.ModelViewer(..., interpolation_decay=0)     # jump cut
```

This is the single highest-return prop on the page for perceived quality, and
it costs nothing at runtime.

---

### Reading and writing the camera in the same callback

This is safe, and it is the pattern that was impossible in 0.0.1:

```python
@callback(
    Output("viewer", "camera_orbit"),
    Input("viewer", "camera"),
    Input("snap-to-front", "n_clicks"),
)
def maybe_snap(camera, _):
    if ctx.triggered_id == "snap-to-front":
        return "0deg 80deg 3m"
    ...
```

Writing `camera_orbit` moves the camera, which fires `camera-change`, which
would normally update `camera` and re-enter the callback — a loop bounded only
by network latency. The shim suppresses it: only events whose
`event.detail.source` is `user-interaction` are reported. Programmatic moves
are silent by construction, so you do not need `prevent_initial_call` gymnastics
or a `dcc.Store` guard flag.

See [Events and callbacks](/events-and-callbacks) for `camera_change_debounce`,
the other half of not melting your server.

---

### Framing a model you have never seen

Hard-coded orbits assume you know the model's size. When users supply their own
files, read the bounding box instead — `model_info` gives it to you in metres:

```python
@callback(
    Output("viewer", "camera_orbit"),
    Output("viewer", "camera_target"),
    Input("viewer", "model_info"),
)
def frame_it(info):
    if not info or not info.get("dimensions"):
        raise PreventUpdate
    d = info["dimensions"]
    radius = max(d["x"], d["y"], d["z"]) * 1.8
    return f"0deg 78deg {radius:.2f}m", f"0m {d['y'] / 2:.2f}m 0m"
```

Grounding a camera in the model's *measured* size, rather than in a number
somebody typed once, is also the prerequisite for letting anything else choose
the angle for you.


---

*Source: /camera-and-views*

---

<!-- /changelog — https://modelviewer.2plot.dev/changelog/llms.txt -->

# Changelog

> Version history of dash-model-viewer. The timeline on this page is rendered from `CHANGELOG.md`, reproduced below.

---

All notable changes to `dash-model-viewer` are documented here.
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

Nothing yet.

## [1.0.1] — unreleased

### Fixed

- **The vendored `<model-viewer>` no longer prints debug lines to every
  visitor's console.** Upstream 4.3.1 itself shipped three `console.log`
  calls in `lib/model-viewer-base.js` — `IntersectionObserver fired!
  isIntersecting: …` on every visibility change, and `[$updateSource]
  called! …` / `[$updateSource] BAILING OUT EARLY!` on every source update —
  so every page with a viewer logged them in production. They are Google's,
  not added in the 1.0.0 rebuild: the vendored file was upstream's
  byte-for-byte less the sourcemap comment, checked against the npm
  tarball's integrity hash. `scripts/vendor_model_viewer.py` now removes
  exactly those three calls as its second documented transform, each an
  exact match that must occur once, so a future version fails the re-vendor
  loudly instead of shipping the logs or a half-edited bundle. Behaviour is
  unchanged: the bail-out is upstream's lazy-loading and stays, as a bare
  `return;`. The bundle is 243 bytes smaller (1,071,622 → 1,071,379) and
  nothing else in it changed. **Not covered:** `configure(use_cdn=True)`
  serves jsDelivr's unmodified upstream copy, which still logs.

### Site

These change the documentation site only; the wheel is unaffected.

- **A tab left open across a deploy now says it is out of date, instead of
  silently doing nothing.** Three times on this host a control appeared
  broken while the code was fine — a switch, then the downloads. Dash fetches
  the callback map once per tab, but page layouts arrive fresh on every
  navigation, so after a restart or deploy new controls rendered against a map
  that had never heard of them. The shell now records a fingerprint in tab
  memory on first load and compares it on every in-app navigation: `/healthz`'s
  `build` key (~150–300 B) where the platform sets one, and otherwise a sorted
  fingerprint of the served `_dash-dependencies`. On a difference it shows the
  poll guard's sentence, "This page is out of date — reload it." One small GET
  per navigation; a probe that fails or reads empty says nothing. It cannot
  rescue a tab older than itself. New in `lib/stale_tab.py`; ops' ruling,
  SYNC-1.6.46 item 12's third half. The first spec ("baked at registration")
  was measured unbuildable: 2 callbacks exist when the shell registers, 61 in
  the served graph. `tests/test_callback_wiring.py` also now catches the
  duplicate case its sweep was blind to — two callbacks with an identical
  qualified id overwrite each other in `callback_map`, removing the evidence,
  so the registration list and the map must now be the same length.

## [1.0.0] — 2026-08-21

A clean break. The package is rebuilt around a Dash hook; the old
`DashModelViewer` component and its generated build are gone. See **Migrating**
at the end of this entry.

### Fixed

- **A generated sculpture's manifest was refused by its own importer, and the
  page said nothing.** `lib/sculptor` CLAMPED every field while building —
  emissive strength to 0–1, roughness to 0.05–1.0, size and position to their
  bounds, and it accepted a colour written without its `#` — while
  `manifest.from_scene` stored the model's RAW values, which `lib/manifest`
  then refused. A sculpture containing a flame at `emissive_strength: 3.0` (a
  value the system prompt's own worked example used) drew perfectly and
  produced a manifest every consumer of the store rejected. **One swallowed
  `ManifestError` produced three unrelated-looking symptoms**: the texture
  switch fell back to the untextured render *under a note reading "Draped on
  screen only"*, the `.glb` button did nothing, and the manifest button did
  nothing. The clamps now live in exactly one place — `sculptor.normalise_part`
  — which the builder reads its numbers from and which `from_scene` stores, so
  a stored manifest is valid by construction; a test renders both paths and
  compares bytes for every out-of-range value a model can emit. And nothing is
  swallowed: a refused render or download now shows the importer's own message,
  naming the field and the bound it broke, instead of returning `no_update`
  (which is indistinguishable from a button that does nothing). The same
  surfacing is applied to [Generative 3D](/generative-3d), which had the
  identical silence.

- **A stale tab no longer polls a 500 for ever.** Observed on this host: a tab
  left open on `/sculpt-from-image` across a restart posted a callback id the
  running server no longer had, and Dash answered `500` to every tick — at
  700 ms, indefinitely, behind a spinner that never stopped. It read as a hung
  build. The cause was benign (the tab predated the commit that widened that
  callback's output list, and Dash's callback id hashes only the INPUTS, so its
  `@hash` matched while the output list did not); the consequence was not,
  because every deploy that changes a callback's outputs puts every open tab in
  that state. Both generating pages now bound the timer with `max_intervals`,
  derived from the progress store's TTL rather than chosen, and carry a
  CLIENTSIDE stale guard — the server writes the tick it last answered on, and
  the browser says "This page is out of date — reload it" and stops the timer
  after five ticks of silence. Clientside because it has to run in exactly the
  state where server callbacks cannot. It cannot rescue a tab older than
  itself; what it prevents is the next deploy doing it again. New in
  `lib/poll_guard.py`.

- **`/generative-3d` recorded the wrong provenance in every file it exported.**
  Its poll declared `State("g3-model")` then `State("g3-prompt")` and received
  them transposed, so each saved manifest carried the model id under `prompt`
  and the prompt text under `model`. The sculpture was unaffected; only the
  record of how it was made was wrong — the half a file keeps after the session
  is gone. The suite missed it because the tests called the function in ITS
  parameter order rather than through the wiring, which cancelled the
  transposition out. `tests/test_callback_wiring.py` now compares every
  callback's parameter order against the order Dash wires into it, and flags a
  parameter that matches a DIFFERENT dependency of the same callback — a rule
  that fires on one of this app's 43 callbacks and fired on this bug.

- **WebXR AR now works out of the box on Android.** `arModes` defaulted to
  `"basic_annotations scene-viewer quick-look"`. `basic_annotations` is not an
  AR mode — it is the name of a folder in `usage_tests/`, copy-pasted into the
  default. The effect was that `webxr` was absent from the AR mode list in
  every default configuration, so the package's flagship feature had never
  worked without the user discovering and overriding the prop. The hub
  documentation has always shown the correct value, so the code and the docs
  have disagreed since the first release. The default is now
  `"webxr scene-viewer quick-look"`.

- **Events reach Python.** The `setProps` call was commented out, so the
  component had no output props at all — every interaction required a
  `clientside_callback`. Camera changes, load and progress state, AR status and
  tracking, hotspot clicks, and scene-point picking are now Dash props.

- **Event listeners no longer accumulate.** `removeEventListener` was called
  with a freshly-created closure on every render, so it removed nothing and
  listeners piled up for the lifetime of the page. Unmount cleanup removed only
  the injected script tag.

### Changed

- **`@google/model-viewer` is a real dependency, vendored at 4.3.1.** It was
  previously fetched at runtime from a hard-coded
  `ajax.googleapis.com` URL pinned to 3.5.0, injected into `document.body` by
  every component instance. That made the package unusable offline, behind a
  corporate egress proxy, or under a strict `script-src` CSP, and left the
  version outside the consumer's control. The bundle now ships in the wheel and
  is emitted by `dash.hooks.script()`. Opt back into a CDN — or point at an
  internal mirror — with `dash_model_viewer.configure(use_cdn=...)`, which must
  run before `Dash()` is constructed.

- **Hotspots are components, not dictionaries.** `hotspots=[{...}]` becomes
  `children=[Slot(...)]`. `Slot` accepts arbitrary Dash children, which the old
  list-of-dicts could not — that limitation existed only because
  `dash.html.Div` has no `slot` prop. `ar_button_text`, `custom_ar_prompt` and
  `custom_ar_failure` are absorbed into `Slot` and removed.

- **Props are `snake_case`** (`camera_controls`, not `cameraControls`).

- **Arbitrary model-viewer attributes are supported permanently.** The
  `attributes` dict and `mv_*` wildcard props pass through any attribute
  model-viewer supports — including ones added upstream after this release —
  with no regeneration and no new version of this package.

### Added

- **A prompt-version axis on [Benchmark](/benchmark).** The page's fourth
  axis varies *this site's own instructions* rather than the model's settings:
  **v1**, which every other page uses, against **v2**, which teaches the model
  scene-manifest version 2 and adds one further instruction — that parts may
  and should interpenetrate where they join, because a lamp sits *into* the top
  of its tower rather than balancing on it. Every other setting is held, so a
  difference is attributable to the prompt. Each result panel now carries the
  number that claim turns into: of the part pairs close enough to read as
  joined, how many actually **blend** rather than merely touching — computed
  from the returned manifest by geometry alone, with no rendering and no second
  call. **v2 is the default nowhere.** Its whole claim is that it produces
  better sculptures, and only model runs can show that, so it runs here and
  nowhere else until a sweep earns the promotion; a test fails if any page that
  generates sculptures ever selects it.

- **Scene manifest version 2 — define a thing once and place it.** Three
  additive entries: a `defs` block naming sub-assemblies, a `ref` that places
  one, and a `group` that gathers parts under a shared transform. Nesting is
  allowed to four levels; the 28-part ceiling now counts LEAVES after
  expansion, because a `ref` costs its def's part count every time it is
  placed. A def deliberately has no `position` — it describes a thing, a `ref`
  says where a copy goes — so a ref cannot restyle or resize what it places and
  every placement provably shares **one mesh and one material**. The bundled
  `cart.json` draws 8 parts from 4 entries in 4 meshes and is **69% smaller**
  than `cart-flat.json`, the same sculpture placed by hand; a test asserts the
  two put every node in exactly the same position and rotation. Rotations
  compose as quaternions rather than by adding Euler angles, which do not add.
  **Version 1 is unaffected**, asserted by SHA-256 on the three original
  samples. The exported `.glb` remains a flat scene — shared meshes, one node
  per part — so the two shipped readers that assume a mesh's transform is its
  world transform (`lib/overlap.py`, `lib/texture.py`) keep working; a test
  asserts neither can tell the nested cart from the flat one. Making the
  authored grouping survive export is a later, separate change.

- **[Model Upload](/model-upload) — render your own `.glb`.** Every other page
  hands `<model-viewer>` a model this site chose; this one takes a `dcc.Upload`
  and renders the visitor's file, beside a readout of what is actually in it:
  nodes, meshes, triangles, materials, textures, animation clips and the
  extensions it declares. The file is identified by its CONTENT rather than the
  media type the browser guessed — a `.glb` is recognised by its magic number
  and version field, and its declared length is checked against the bytes
  received, so a truncated transfer is named instead of failing silently in the
  viewer. A `.gltf` JSON file is refused with the reason it cannot work: it
  points at buffers and textures that were not uploaded with it. Reading is
  header-and-JSON-chunk only, so describing a 30 MB model costs what describing
  a 30 KB one costs. Rules live in `lib/uploads.py` beside the image ones; the
  reader is `lib/glb.summarize()`.

- **Drape the uploaded image over the sculpture it produced.**
  [Sculpt from an Image](/sculpt-from-image) gains a three-state texture
  switch: **Off** (the generated colours), **Preview** (draped on screen, the
  `.glb` still downloads plain, and the button relabels itself to say so) and
  **Include** (baked into the download as `…-textured.glb`). The UVs are a
  front planar projection computed from *world* position after the parts are
  placed, so the parts together carry one picture rather than each wearing its
  own copy — the unit-square-per-primitive alternative is what makes a
  28-part sculpture look like 28 small photographs. Draped parts take a white
  base colour, go fully non-metallic and take a roughness floor — glTF
  multiplies base colour into the texture, and in metallic-roughness PBR the
  diffuse term is `baseColor x (1 - metallic)`, so a part left at the
  `metallic: 0.9` the prompt asks for on anything gold or polished showed
  almost none of the picture. `emissive` is deliberately left alone, because it
  adds light rather than replacing albedo, so a glowing part still glows. Switching back to
  Off is byte-identical to never having textured. The manifest stays
  textureless — draping happens at render time — so a saved manifest is the
  same file either way, and the whole switch works on a host with **no API
  key**: a bundled sample plus an uploaded picture bakes and downloads. New in
  `lib/texture.py`; embedded images are now deduplicated by content in
  `lib/glb.py`, without which a realistic 800 KB photograph across 28 parts
  would come to ~23 MB against the 3 MB ceiling.

- `camera_change_debounce` (default `100` ms), a **required** guard rather than
  an optimisation. `camera-change` and `progress` fire at frame rate, so
  unthrottled two-way camera props are a callback storm; and because
  `camera_orbit` is two-way, a callback that writes it re-triggers itself. The
  shim suppresses the echo via `event.detail.source`.

- **`src` and `alt` are enforced, not merely documented.** The docstring,
  `api_metadata.json` and the API reference had all called them required while
  nothing checked — `ModelViewer()` with neither constructed silently. Omitting
  either now raises the standard Dash `TypeError` naming the prop, and an
  explicit `None` fails the same way. `alt` is the entire experience for a
  screen-reader user; a wrapper that lets you forget it makes the inaccessible
  case the easy one. 0.0.1's generated metadata declared `id`/`src`/`alt`
  required, so this restores two of the three; `id` stays optional, because a
  viewer with no callbacks needs none.

- **An animation page.** `/animation` plays a model's built-in clips, switches
  between them with a crossfade and pauses — with the clip list read from
  `model_info["animations"]` rather than hardcoded, so it works for any
  animated model. Entirely through the `attributes` escape hatch, since 1.0.0
  ships no imperative surface; the page states what that rules out (`currentTime`
  and `timeScale` have no attribute equivalent, so no scrubbing and no speed
  control) rather than leaving anyone hunting for a prop.

- **An attribute tour.** `/attribute-tour` drives the eleven `<model-viewer>`
  attributes that have no named prop — `scale`, `loading`, `reveal`,
  `disable-pan`, `disable-tap`, `interaction-prompt`, `skybox-image`,
  `skybox-height` — and documents the three only a phone can show.
  `ar-placement` and `xr-environment` are set on `/augmented-reality` instead,
  where a device walk can verify them; `ios-src` stays documentation-only
  because Quick Look needs a `.usdz` this repo does not ship, and pointing it
  at a missing file would break iOS AR in order to document an attribute. The
  page remounts its viewer on every change, because `loading` and `reveal` do
  nothing to a model that has already loaded. Written from an audit against
  modelviewer.dev's own examples; `effects` and the postprocessing page are
  deliberately out of scope, needing a separate ES-module addon that the
  classic-script rule in `.claude/ARCHITECTURE.md` forbids.

- **A scene manifest you can export, edit and re-render.** The JSON a
  generated sculpture is made of is now a documented, versioned format
  (`/scene-manifest`) with an importer, a byte-stable exporter and a `.glb`
  download on both generating pages. `lib/glb.py` is deterministic, so a
  manifest re-renders byte-identically — which makes the file a way back to the
  object rather than a souvenir, and means editing a sculpture costs nothing
  where asking a model to change it costs a call. `provenance` records the
  prompt, model, cost and date and is never read by the renderer. Nothing is
  written to disk: imports are validated in memory and refused with the field's
  path named, and downloads are bytes handed straight to the response. The
  round trip works on a host with **no API key at all** — three committed
  sample manifests demonstrate it, one of them at exactly the 28-part limit.

- **A picking example.** `pick_on_click` and `scene_point` were documented from
  the start and demonstrated nowhere, so nothing would have noticed if the
  shim's click path broke. `/events-and-callbacks` now runs one.

- **`examples/manual_walk.py`** — a standalone app, dependent on `dash` and this
  package alone, covering the eight props no documentation page exercises
  (`poster`, `class_name` on both components, the four camera bounds,
  `touch_action`) and a viewer whose `src` 404s, so `model_state`'s error
  payload can be observed rather than merely wired.

- **A release lane.** `.github/workflows/release.yml`: a `v*` tag verifies that
  the tag matches `pyproject`'s version and that the CHANGELOG documents it,
  builds, runs the package's own suites against the built wheel, asserts the
  wheel carries the vendored runtime, then publishes to PyPI by OIDC trusted
  publishing with no stored token. The wheel-content gate is the one that
  matters here: the JavaScript is committed rather than built, so a wheel
  missing `vendor/model-viewer-umd.min.js` would be dead on arrival and a
  version cannot be re-uploaded to replace it.

### Removed

- **The build.** No `package.json`, webpack, babel, `dash-generate-components`,
  `metadata.json`, or React source. The three layers — vendored bundle,
  hand-authored shim, hand-written Python components — are each the source of
  record. A test asserts the generator stays gone, because a stale dev
  environment on dash 2.18 could otherwise regenerate the Python *backwards*.

- **R and Julia bindings** (`R/`, `man/`, `deps/`, `inst/`, `NAMESPACE`,
  `DESCRIPTION`, `Project.toml`). Generated, never used, and ~550 MB of the
  repository.

- `DashModelViewer`. The module is now `_components.py`; the old filename is
  retired so a stale copy cannot shadow the new one.

- **Provider keys from the deployed site.** The documentation host carries no
  `ANTHROPIC_API_KEY` and no `CHATGPT_API_KEY` (owner's decision,
  2026-09-12): the sites are documentation and do no production spend. The
  generative pages therefore ship an empty model picker, a disabled generate
  control and a line explaining why, on every deployment. That is the expected
  production state rather than a misconfiguration, and it is said on the pages
  so nobody files it as a bug. Run the site locally with a `.env` to use them;
  `lib/spend.py`'s rolling call limit and dollar ceiling then apply there.

- **`camera["source"]`.** The shim reports camera movement only for user
  interaction — that is the echo suppression above — so the key could only ever
  hold the single string `"user-interaction"`. A field with one possible value
  tells a reader nothing and implies another value is reachable. The payload is
  `{"orbit", "target", "field_of_view"}`. The suppression is unchanged.

### Migrating from 0.0.1

```python
# 0.0.1
from dash_model_viewer import DashModelViewer

DashModelViewer(
    id="v", src="/assets/shoe.glb", alt="A shoe",
    cameraControls=True,
    arModes="webxr scene-viewer quick-look",   # you had to know to set this
    hotspots=[{"slot": "hotspot-1", "position": "0 1 0", "text": "Sole"}],
    arButtonText="View in your space",
)
```

```python
# 1.0.0
import dash_model_viewer as dmv
from dash import html

dmv.ModelViewer(
    id="v", src="/assets/shoe.glb", alt="A shoe",
    camera_controls=True,
    # ar_modes now defaults to "webxr scene-viewer quick-look"
    children=[
        dmv.Slot(slot="hotspot-1", position="0 1 0", children="Sole"),
        dmv.Slot(slot="ar-button", children=html.Button("View in your space")),
    ],
)
```

| 0.0.1 | 1.0.0 |
|---|---|
| `cameraControls`, `cameraOrbit`, … | `camera_controls`, `camera_orbit`, … |
| `hotspots=[{...}]` | `children=[Slot(...)]` |
| `arButtonText=` | `Slot(slot="ar-button", …)` |
| `customArPrompt=` | `Slot(slot="ar-prompt", …)` |
| `customArFailure=` | `Slot(slot="ar-failure", …)` |
| `arModes` default silently disabled WebXR | correct by default |
| clientside callbacks for every event | `camera`, `model_info`, `ar_status`, … |
| *(no equivalent)* | `attributes={...}` / `mv_*` for full upstream parity |

## [0.0.1] — 2025-05-01

Initial release.

[Unreleased]: https://github.com/pip-install-python/dash-model-viewer/compare/main...HEAD
[1.0.0]: https://github.com/pip-install-python/dash-model-viewer/releases/tag/v1.0.0
[0.0.1]: https://github.com/pip-install-python/dash-model-viewer/releases/tag/v0.0.1
</content>

---

<!-- /events-and-callbacks — https://modelviewer.2plot.dev/events-and-callbacks/llms.txt -->

# Events and Callbacks

> The camera, load state, model dimensions, AR status and hotspot clicks arrive as ordinary Dash props — no clientside callbacks.

---



### The headline change in 1.0.0

Version 0.0.1 had **no output props at all**. The single `setProps` call in the
component was commented out, so nothing the model did could reach Python. Every
interaction — reading the camera, reacting to a load, measuring the model —
required hand-written JavaScript in `assets/` and a `clientside_callback` to
reach it.

That is why the previous camera-views example was 231 lines.

Everything below is now an ordinary `Input`.

| Prop | Updates when | Shape |
| :-- | :-- | :-- |
| `camera` | the user moves the camera | `{"orbit", "target", "field_of_view"}` |
| `model_state` | loading, loaded, or failed | `{"status", "progress"}` |
| `model_info` | on load | `{"dimensions", "variants", "animations"}` |
| `ar_status` | an AR session changes state | `str` |
| `ar_tracking` | AR gains or loses tracking | `str` |
| `scene_point` | the model is clicked, with `pick_on_click=True` | `{"position", "normal", "uv"}` |
| `Slot.n_clicks` | a slot is clicked | `int` |

---

### Reading the camera



```python
# File: docs/events-and-callbacks/camera_readout.py

from dash import Input, Output, callback, html
import dash_mantine_components as dmc

import dash_model_viewer as dmv
from lib.demo_models import ROBOT

component = html.Div(
    [
        dmv.ModelViewer(
            id="ev-camera-viewer",
            src=ROBOT,
            alt="An expressive cartoon robot",
            camera_change_debounce=120,
            style={"width": "100%", "height": "360px"},
        ),
        dmc.Code(
            "Drag the model.",
            id="ev-camera-readout",
            block=True,
            mt="sm",
        ),
    ]
)


@callback(
    Output("ev-camera-readout", "children"),
    Input("ev-camera-viewer", "camera"),
)
def show_camera(camera):
    if not camera:
        return "Drag the model."
    return (
        f"orbit  {camera['orbit']}\n"
        f"target {camera['target']}\n"
        f"fov    {camera['field_of_view']}"
    )
```


Eleven lines of callback for something that used to need a JavaScript file, a
`dcc.Store`, and a `ClientsideFunction`.

---

### `camera_change_debounce` — the prop you must not set to 0

`camera-change` fires **at frame rate**. Unthrottled, a single viewer in a
single browser tab is 60 server round-trips per second.

The default is `100` ms. Setting it to `0` is permitted, documented, and means
exactly what it sounds like.

```python
dmv.ModelViewer(..., camera_change_debounce=120)   # coalesce for 120 ms
dmv.ModelViewer(..., camera_change_debounce=0)     # you are asking for the storm
```


    `camera_orbit` is two-way. Naively, a callback that *writes* `camera_orbit`
    causes `camera-change` to fire, which updates `camera`, which re-triggers
    the callback — forever, as fast as the browser can manage.

    The shim suppresses this by checking `event.detail.source` and reporting
    **only** `user-interaction` events. Programmatic camera moves — from a
    callback, from a hotspot, from the generative demo — never echo back. You
    can safely make `camera` an `Input` and `camera_orbit` an `Output` of the
    same callback.

---

### Load progress and real dimensions

`model_info` carries the model's **measured bounding box in metres**, plus its
GLTF material variants and animation clips. This is the prop that deletes the
most user code: getting a model's size previously meant reaching into the
element's JavaScript API and marshalling the result back through a store.



```python
# File: docs/events-and-callbacks/load_state.py

from dash import Input, Output, callback, html
import dash_mantine_components as dmc

import dash_model_viewer as dmv
from lib.demo_models import ODD_SHAPE

component = html.Div(
    [
        dmv.ModelViewer(
            id="ev-load-viewer",
            src=ODD_SHAPE,
            alt="A labelled irregular solid used to show measured dimensions",
            camera_controls=True,
            style={"width": "100%", "height": "320px"},
        ),
        dmc.Progress(id="ev-load-progress", value=0, mt="sm", animated=True),
        dmc.Text(id="ev-load-status", size="sm", mt="xs"),
        dmc.Code(id="ev-load-dims", block=True, mt="xs"),
    ]
)


@callback(
    Output("ev-load-progress", "value"),
    Output("ev-load-status", "children"),
    Input("ev-load-viewer", "model_state"),
)
def show_progress(state):
    if not state:
        return 0, "Waiting for the model…"
    pct = round((state.get("progress") or 0) * 100)
    return pct, f"{state['status']} — {pct}%"


@callback(
    Output("ev-load-dims", "children"),
    Input("ev-load-viewer", "model_info"),
)
def show_dimensions(info):
    if not info or not info.get("dimensions"):
        return "Dimensions arrive with the `load` event."
    d = info["dimensions"]
    return (
        f"width  {d['x']:.3f} m\n"
        f"height {d['y']:.3f} m\n"
        f"depth  {d['z']:.3f} m\n"
        f"variants   {info['variants'] or '(none)'}\n"
        f"animations {info['animations'] or '(none)'}"
    )
```


`model_state["status"]` is one of `loading`, `loaded` or `error`. Note that
`progress` events stop at 1.0 — completion is reported once, by `load`, so a
callback keyed on `model_state` does not fire twice at the end of every load.

---

### Picking a point on the surface

With `pick_on_click=True`, clicking the model reports the 3D position and
surface normal under the cursor — the raw material for placing a hotspot where
the user pointed.



```python
# File: docs/events-and-callbacks/picking.py

from dash import Input, Output, callback, html
import dash_mantine_components as dmc

import dash_model_viewer as dmv
from lib.demo_models import ASTRONAUT

component = html.Div(
    [
        dmv.ModelViewer(
            id="ev-pick-viewer",
            src=ASTRONAUT,
            alt="An astronaut model; clicking its surface reports the point under the cursor",
            camera_controls=True,
            # Without this the click listener returns immediately and
            # `scene_point` never updates.
            pick_on_click=True,
            style={"width": "100%", "height": "320px"},
        ),
        dmc.Text("Click the astronaut.", id="ev-pick-status", size="sm", mt="sm"),
        dmc.Code(id="ev-pick-readout", block=True, mt="xs"),
    ]
)


@callback(
    Output("ev-pick-status", "children"),
    Output("ev-pick-readout", "children"),
    Input("ev-pick-viewer", "scene_point"),
)
def show_point(point):
    # `scene_point` is None both before the first click and whenever a click
    # misses the mesh — which is the common case near the silhouette, so the
    # miss is a real state to render rather than an error to hide.
    if not point:
        return "Click the astronaut.", "No point yet — a click that misses the mesh reports None."
    uv = point.get("uv")
    return (
        "Picked.",
        f"position {point['position']}\n"
        f"normal   {point['normal']}\n"
        f"uv       {f'{uv[0]:.3f}, {uv[1]:.3f}' if uv else '(none — model has no UVs)'}",
    )
```


`scene_point` is `None` both before the first click and whenever a click misses
the mesh — the common case near the silhouette — so check it before use. `uv`
is `None` for a model with no texture coordinates; `position` and `normal` are
always present on a hit.

---

### What is deliberately *not* here

There is no imperative command surface — no `play()`, no `pause()`, no
`animation_name` yet. Animation control is the obvious next addition and it is
not in 1.0.0. Use `attributes={"autoplay": "", "animation-name": "Wave"}` in
the meantime; see [Attributes and parity](/attributes-and-parity).


---

*Source: /events-and-callbacks*

---

<!-- /generative-3d — https://modelviewer.2plot.dev/generative-3d/llms.txt -->

# Generative 3D Art

> Describe a sculpture and get a real .glb — with Claude as a scene compiler, not a mesh generator, and every triangle built by deterministic Python.

---



### The bet

Ask a language model for a mesh and you get plausible nonsense — vertex lists it
cannot see, winding orders it cannot check, normals it cannot verify. There is
no feedback loop, so there is no way for it to be right except by accident.

Ask it *"what shape is a lighthouse"* and you get **a tall weathered cylinder, a
red cone on top, a small glowing sphere inside, a dark ring around the
gallery** — which is a thing code can build exactly.

So the model never emits geometry. It emits a **parts list**, and
`lib/glb.py` — a dependency-free glTF 2.0 writer — turns that into a real
`.glb`. The model supplies judgement; Python supplies triangles.

That split is why this is cheap, inspectable, reproducible, and needs no
third-party 3D service at all.

---

### Try it



```python
# File: docs/generative-3d/sculptor.py

import json
import threading
from datetime import date

from dash import ALL, Input, Output, State, callback, ctx, dcc, html, no_update
import dash_mantine_components as dmc

import dash_model_viewer as dmv
from lib import (build_stream, manifest, model_picker, poll_guard, sculptor,
                 spend)

IDEAS = [
    "a brutalist lighthouse at dusk, weathered concrete and one warm light",
    "a desert observatory, sandstone and brass, dish pointed at the sky",
    "a bonsai on a stone plinth, copper pot, moss",
    "a cathedral of stacked glass cubes lit from inside",
]

# A neutral studio environment plus a real shadow — generated art looks flat
# and grey without image-based lighting, which reads as "broken" rather than
# "dark". This is the one place the demo needs opinionated defaults.
VIEWER_ATTRS = {
    "environment-image": "neutral",
    "exposure": "1.1",
    "shadow-softness": "0.7",
}

component = html.Div(
    [
        # The run id, and the timer that reads it. `dcc.Interval` is the
        # POLLING collector: it drains `build_stream.take()`, which is the same
        # seam a websocket collector would read on an event loop. Choosing the
        # other transport later replaces this component and nothing else.
        #
        # The run id lives in a per-tab `dcc.Store`, so a run belongs to the
        # tab that started it — two tabs sculpting at once do not read each
        # other's parts.
        dcc.Store(id="g3-run"),
        dcc.Store(id="g3-manifest"),
        dcc.Download(id="g3-dl-json"),
        dcc.Download(id="g3-dl-glb"),
        *poll_guard.components("g3"),
        dmc.Group(
            model_picker.components("g3", sculptor.MODEL, w=260),
            mb="xs",
        ),
        dmc.Text(id="g3-model-status", size="xs", c="dimmed"),
        dmc.Text(id="g3-estimate", size="xs", c="dimmed", mb="xs"),
        dmc.Group(
            [
                dmc.TextInput(
                    id="g3-prompt",
                    placeholder="Describe a sculpture…",
                    value=IDEAS[0],
                    style={"flex": 1},
                ),
                dmc.Button("Sculpt", id="g3-go", variant="filled"),
            ],
            mb="xs",
            align="flex-end",
        ),
        dmc.Group(
            [
                dmc.Badge(idea.split(",")[0], id={"type": "g3-idea", "i": i},
                          variant="light", style={"cursor": "pointer"})
                for i, idea in enumerate(IDEAS)
            ],
            gap="xs",
            mb="sm",
        ),
        # The sculpt call takes several seconds. Without a visible busy state
        # the page looks broken — you click, nothing moves, and there is no way
        # to tell a slow call from a dead one. The overlay sits over the viewer
        # rather than replacing it, so the previous sculpture stays on screen
        # while the next one is composed.
        dmc.Box(
            pos="relative",
            children=[
                dmc.LoadingOverlay(
                    id="g3-busy",
                    visible=False,
                    zIndex=10,
                    overlayProps={"radius": "md", "blur": 2},
                    loaderProps={"type": "bars", "color": "indigo"},
                ),
                dmv.ModelViewer(
                    id="g3-viewer",
                    # Placeholder until the first sculpt: one primitive built by
                    # the same writer, so the page is never an empty box.
                    src=sculptor.to_data_url(
                        sculptor.build(
                            {
                                "parts": [
                                    {"shape": "torus", "name": "seed",
                                     "size": {"x": 1.2, "y": 0.1, "z": 0.24},
                                     "position": {"x": 0, "y": 0.6, "z": 0},
                                     "rotation": {"x": 90, "y": 0, "z": 0},
                                     "color": "#4C6EF5", "metallic": 0.9,
                                     "roughness": 0.25, "emissive_strength": 0.0},
                                ]
                            }
                        )[0]
                    ),
                    alt="A generated 3D sculpture",
                    camera_controls=True,
                    shadow_intensity=1,
                    interpolation_decay=90,
                    attributes=VIEWER_ATTRS,
                    style={"width": "100%", "height": "440px"},
                ),
            ],
        ),
        dmc.Text(
            # Measured, not guessed: a sculpt runs ~35s (effort="medium", and the
        # composition reasoning is the slow part). An estimate that is too
        # low is worse than none — the user concludes it has hung.
        "Composing — this takes about 30 to 45 seconds.",
            id="g3-working", size="sm", c="dimmed", mt="xs", display="none",
        ),
        dmc.Alert(id="g3-status", mt="sm", color="indigo", hide=True),
        dmc.Spoiler(
            id="g3-spoiler",
            showLabel="Show the parts list",
            hideLabel="Hide",
            maxHeight=0,
            children=dmc.Code(id="g3-json", block=True),
            mt="xs",
        ),
        dmc.Group(
            [
                dmc.Button("Save the manifest", id="g3-save-json",
                           variant="light", size="xs", disabled=True),
                dmc.Button("Download .glb", id="g3-save-glb",
                           variant="light", size="xs", disabled=True),
            ],
            gap="xs", mt="xs",
        ),
    ]
)


@callback(
    Output("g3-prompt", "value"),
    Input({"type": "g3-idea", "i": ALL}, "n_clicks"),
    prevent_initial_call=True,
)
def use_idea(clicks):
    if not any(clicks or []):
        return no_update
    return IDEAS[ctx.triggered_id["i"]]


@callback(
    Output("g3-run", "data"),
    Output("g3-poll", "disabled"),
    Output("g3-status", "hide"),
    Output("g3-working", "display"),
    Output("g3-go", "loading"),
    Output("g3-prompt", "disabled"),
    Output("g3-poll", "n_intervals"),
    Output("g3-alive", "data"),
    Input("g3-go", "n_clicks"),
    State("g3-prompt", "value"),
    State("g3-model", "value"),
    prevent_initial_call=True,
)
def start_sculpt(_, prompt, model):
    """Start the build and return immediately.

    This used to be the whole thing: one callback that blocked for ~35 seconds
    behind a loading overlay and then produced a finished object. The build now
    runs on a background thread and the poller reads parts as they assemble,
    which is the change the owner asked for.

    A plain thread suffices: the owner reports one gunicorn worker, and the
    store is file-backed regardless, so a later WEB_CONCURRENCY change on the
    dashboard cannot silently break the read side.
    """
    run_id = build_stream.new_run()
    threading.Thread(
        target=sculptor.sculpt_streaming,
        args=(run_id, prompt),
        kwargs={"model": model or sculptor.MODEL},
        daemon=True,
    ).start()
    # n_intervals back to 0 re-arms the capped Interval, so the ceiling
    # bounds THIS build rather than the tab's whole lifetime.
    return run_id, False, True, "block", True, True, 0, poll_guard.tick_value(0)


@callback(
    Output("g3-viewer", "src"),
    Output("g3-viewer", "alt"),
    Output("g3-status", "children"),
    Output("g3-status", "color"),
    Output("g3-status", "hide", allow_duplicate=True),
    Output("g3-json", "children"),
    Output("g3-working", "children"),
    Output("g3-poll", "disabled", allow_duplicate=True),
    Output("g3-working", "display", allow_duplicate=True),
    Output("g3-go", "loading", allow_duplicate=True),
    Output("g3-prompt", "disabled", allow_duplicate=True),
    Output("g3-manifest", "data"),
    Output("g3-save-json", "disabled"),
    Output("g3-save-glb", "disabled"),
    Output("g3-alive", "data", allow_duplicate=True),
    Input("g3-poll", "n_intervals"),
    State("g3-run", "data"),
    State("g3-model", "value"),
    State("g3-prompt", "value"),
    prevent_initial_call=True,
)
def poll(tick, run_id, model, prompt):
    """Drain the seam and render whatever has arrived.

    Every `part` event carries a COMPLETE `.glb` of the parts so far, so the
    viewer is re-pointed at each in turn and the sculpture assembles on screen.
    `take()` clears as it reads, so this never redraws what it already drew.

    The Interval stops the moment the run ends — on the `done` event, or on a
    `done` flag with no event, which is how a failed build reports itself.

    The parameter ORDER follows the State order above — `g3-model` then
    `g3-prompt`. It did not: the two were transposed, so the provenance
    written into every exported manifest had the model id under "prompt"
    and the prompt text under "model". The suite missed it because the
    tests call this function directly, in ITS order, never through the
    wiring — `tests/test_callback_wiring.py` now compares the two.
    """
    alive = poll_guard.tick_value(tick)
    idle = (no_update,) * 14 + (alive,)
    if not run_id:
        return idle

    state = build_stream.take(run_id)

    latest_src = no_update
    progress = no_update
    final = None
    for event in state["events"]:
        phase = event.get("phase")
        if phase == "part":
            latest_src = event["data_url"]
            progress = f"Building — part {event['index']} of {event['total']}"
        elif phase == "assembling":
            progress = (
                f"Composed in {event.get('seconds', 0)}s — "
                f"assembling {event['total']} parts"
            )
        elif phase == "done":
            final = event

    if final is not None:
        # NOT named `manifest` — that is the module, imported above, and
        # shadowing it here would turn `manifest.from_scene` into a dict lookup.
        scene = final.get("manifest") or {}
        note = f"{scene.get('name', 'Untitled')} — {scene.get('notes', '')}"
        if final.get("notes"):
            note += "  ·  " + "; ".join(final["notes"])
        note += (
            f"  ·  {final.get('part_count', 0)} parts  ·  "
            + spend.actual_line(final.get("usd", 0.0), final.get("seconds", 0.0))
        )
        return (
            final.get("data_url") or latest_src,
            f"A generated 3D sculpture: {scene.get('name', prompt)}",
            note, "indigo", False,
            json.dumps(scene, indent=2),
            "", True, "none", False, False,
            manifest.from_scene(scene, {
                "prompt": prompt, "model": model,
                "usd": final.get("usd", 0.0),
                "generated": date.today().isoformat(),
            }), False, False, alive,
        )

    if state["done"]:
        return (
            no_update, no_update,
            state["reason"] or "The sculpt did not complete.",
            "yellow", False, no_update,
            "", True, "none", False, False,
            no_update, no_update, no_update, alive,
        )

    return (
        latest_src, no_update, no_update, no_update, no_update, no_update,
        progress, no_update, no_update, no_update, no_update,
        no_update, no_update, no_update, alive,
    )


model_picker.register("g3", action_ids=["g3-go"])
poll_guard.register("g3", "g3-status", resets=[
    ("g3-working", "display", "none"),
    ("g3-go", "loading", False),
    ("g3-prompt", "disabled", False),
])


@callback(
    Output("g3-estimate", "children"),
    Input("g3-model", "value"),
)
def show_estimate(model):
    """Priced BEFORE the button, and re-priced when the model changes.

    A visitor choosing Opus over Haiku is choosing a 5x bill, and the only
    moment that fact is useful is before the click.
    """
    return spend.estimate_line(model or sculptor.MODEL, sculptor.MAX_TOKENS)


@callback(
    Output("g3-dl-json", "data"),
    Output("g3-status", "children", allow_duplicate=True),
    Output("g3-status", "hide", allow_duplicate=True),
    Input("g3-save-json", "n_clicks"),
    State("g3-manifest", "data"),
    prevent_initial_call=True,
)
def save_manifest(_clicks, stored):
    """The manifest is the valuable half.

    Re-importing it on [Scene Manifest](/scene-manifest) re-renders the same
    sculpture for free, and editing it costs nothing — which is the whole point
    of keeping it rather than only the `.glb`.
    """
    if not stored:
        return no_update, no_update, no_update
    try:
        m = manifest.validate(stored)
    except manifest.ManifestError as exc:
        # A button that does nothing is the worst available report — see
        # /sculpt-from-image, where this silence hid a real defect for a day.
        return no_update, f"Cannot save this manifest — {exc}", False
    return ({"content": manifest.dumps(m),
             "filename": manifest.filename(m, "json")},
            no_update, no_update)


@callback(
    Output("g3-dl-glb", "data"),
    Output("g3-status", "children", allow_duplicate=True),
    Output("g3-status", "hide", allow_duplicate=True),
    Input("g3-save-glb", "n_clicks"),
    State("g3-manifest", "data"),
    prevent_initial_call=True,
)
def save_glb(_clicks, stored):
    """Rebuilt from the stored manifest on demand, not carried as bytes.

    `lib/glb.py` is deterministic, so this is the same file the viewer is
    showing — and it keeps a megabyte of binary out of the browser's store.
    Nothing is written to disk at any point.

    Two callbacks rather than one dispatching on `ctx.triggered_id`: a callback
    that reads the context cannot be called from a test, and these two are
    worth testing.
    """
    if not stored:
        return no_update, no_update, no_update
    try:
        m = manifest.validate(stored)
        data, _notes, _used = manifest.render(m)
    except (manifest.ManifestError, ValueError) as exc:
        return no_update, f"Cannot build this .glb — {exc}", False
    return dcc.send_bytes(data, manifest.filename(m, "glb")), no_update, no_update
```


Orbit it. Put it in AR on a phone. It is a real glTF file, not a picture of one.

---

### The vocabulary

Six primitives, and nothing else:

| Shape | `size` means |
| :-- | :-- |
| `box` | width, height, depth |
| `sphere` | radius |
| `cylinder` | radius, height |
| `cone` | radius, height |
| `torus` | radius, tube thickness |
| `plane` | width, depth (a ground card) |

Each part carries a position, a rotation, and a PBR material — base colour,
metallic, roughness, emissive strength. That is the entire surface the model
writes to.

Constraining the vocabulary this hard is what makes the output reliable. There
is no syntax to get wrong, no topology to corrupt, and every field has a
meaningful clamp.

---

### The prompt does the artistic work

The schema decides what is *possible*; the system prompt decides whether the
result is *any good*. Three sections earn their place:

**Coordinates, stated as a physical fact.** `+Y` is up, the sculpture stands on
`y=0`, and `position` is the *centre* of a part — so a 1.4 m cylinder resting on
the ground has `position.y = 0.7`, not `0`. Without that last sentence, half the
parts sink through the floor, because "put it on the ground" and "centre it at
zero" are the same instruction to something that has never stood on a floor.

**Composition rules with a stated reason.**

> Between 5 and 28 parts. Fewer than 5 reads as a diagram; more than ~25 reads
> as noise at a glance. Vary scale deliberately: a few large masses that carry
> the silhouette, then smaller parts for detail. Rotation is free and underused
> — tilt, lean and offset parts rather than stacking everything axis-aligned.

**A palette rule, which is the single highest-return line:**

> Pick a deliberate palette of three or four colours and reuse them. A
> different colour per part looks like a test scene, not a sculpture.

Without it you reliably get twenty parts in twenty colours. It is the
difference between a sculpture and a bar chart.


    The palette rule is a direct descendant of the **COLOR LOCK** in the
    SailsBoard object generator, which measures the actual colours from a
    reference image and splits them by *role* — body colours versus outline
    ink. Naming raw hex values alone backfired there: the outline navy was a
    legitimately dominant colour, so told "build from these colours" the model
    rendered whole unseen faces in it.

    The lesson generalises past pixel art: **tell the model what each colour is
    FOR, not just which colours exist.**

---

### Shape is not sanity

Structured output guarantees `size.x` is a number. It does not guarantee the
number is sane. Everything is re-checked in `lib/sculptor.py`:

| Guard | Why |
| :-- | :-- |
| ≤ 28 parts | ~1.5 KB of geometry each; "a city" would exceed any data URL |
| every dimension ≤ 4 m | one runaway scale makes everything else invisible |
| within 5 m of origin | a part at `z = 900` silently breaks camera framing |
| roughness ≥ 0.05 | 0 is a perfect mirror and reads as a black hole |
| unknown `shape` dropped | with a note, not silently |
| ≤ 3 MB output | the practical data-URL ceiling |

Each clamp reports itself in the status line rather than being applied quietly.

#### The one that is not a clamp

```python
# glTF baseColorFactor is LINEAR, not sRGB.
return tuple(c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
             for c in srgb)
```

The model returns `#A8A29A` because that is how humans write colour. glTF
expects linear values. Passing the sRGB numbers straight through renders every
palette visibly washed out — and it looks like a *lighting* problem, so it
sends you off tuning `exposure` for an hour.

---

### No upload store, on purpose

The finished `.glb` is handed to `ModelViewer` as a **`data:` URL**:

```python
src = "data:model/gltf-binary;base64," + base64.b64encode(glb).decode()
```

`<model-viewer>` accepts it — three.js's `FileLoader` carries an explicit
`/^data:.*,.*$/` branch, which you can find in the vendored bundle.

That is a design decision, not a shortcut. The obvious alternative is a server
route backed by a dict of generated files, and an in-memory store with no cap,
no TTL and no auth is a memory-growth vector the moment the site is public. The
data URL has no store to grow, nothing to expire, and nothing to clean up. The
cost is a size ceiling, which is exactly why the part budget is small.

---

### It takes about 35 seconds, and it has to say so

Measured on this page: **~36 s** for a sculpt, against ~8.5 s for the image
page's vision call and ~4 s for the [Scene Director](/scene-director). The
composition reasoning is genuinely the slow part — deciding twenty-five parts,
their placement and a coherent palette is not a lookup.

That length changes what the UI owes the user. The first version had no busy
state at all: you clicked *Sculpt*, nothing moved, and there was no way to tell
a slow call from a dead one. The second version added Dash's `running=` — a
spinner and an overlay for the duration of the callback — which is honest but
is still thirty-five seconds of watching a spinner.

**This page now shows the sculpture being built instead.**

Clicking *Sculpt* starts the build on a background thread and returns
immediately. A `dcc.Interval` then polls a small progress store, and the viewer
is re-pointed at each partial model as it arrives, so the piece assembles in
front of you.

The part worth understanding: **there is nothing to stream out of the model
call.** One request returns the entire parts list. What streams is the
*assembly* — `lib/sculptor.py` builds the parts list up one part at a time and
emits a complete `.glb` at each step:

```python
for index in range(1, len(parts) + 1):
    data, _, _ = build({**manifest, "parts": parts[:index]})
    build_stream.emit(run_id, {"phase": "part", "index": index,
                               "data_url": to_data_url(data)})
```

Each of those is a real model, not a frame of a video, so you can orbit a
half-finished sculpture.

Details that are easy to get wrong:

- **The progress store is a file, not a dict in memory.** gunicorn's worker
  count comes from `WEB_CONCURRENCY` in the environment, so a per-process
  buffer polled by an `Interval` hangs intermittently the moment there is more
  than one worker — half the polls land on a worker that never saw the run.
- **Streaming must not become charging per part.** The spend gate wraps the one
  model call, not the assembly loop; a test asserts the streaming layer records
  no spend of its own.
- **The Interval stops when the run ends**, on success or failure. A timer left
  running is a request every 700 ms for as long as the tab is open.
- **`take()` is a seam.** It returns what has accumulated and clears it. A
  websocket collector would call the same function from an event loop — which
  is why the transport can change without touching the producer.

The estimate shown while composing is measured, not guessed. An estimate that
is too low is worse than none: at twenty seconds of "10 to 20 seconds" the user
concludes it has hung and clicks again.

### Cost

- **One call per sculpt**, `max_tokens=4000`, `effort="medium"` — the
  composition reasoning is real but short.
- At ~36 s a request holds a worker for a long time. `render.yaml` runs
  gunicorn with `--timeout 120` and `--threads 4`, so a sculpt cannot wedge the
  single free-tier worker; dropping either of those would make it possible.
- `stop_reason` is checked before `content` is read.
- No key configured means the button explains itself; it never returns a stub
  that looks like it worked.
- `tests/conftest.py` blanks `ANTHROPIC_API_KEY`, so no test run can spend
  money.

---

### Where it goes next

The parts list is a *scene graph*, so the obvious extensions are cheap:

- **Variations.** Re-run with the same manifest plus "make it taller / colder /
  more ruined" and diff the parts.
- **Animation.** glTF supports node animation; the writer does not emit it yet,
  but the node structure is already there.
- **Hand-editing.** The manifest is JSON. Nothing stops a user tweaking a
  colour and rebuilding — the model is not in that loop at all.

For turning an image into geometry rather than a description, see
[Image to 3D](/image-to-3d).

---

### Choosing the model

The dropdown offers the Claude models, plus the GPT models when
`CHATGPT_API_KEY` is set on the host. The line under it always says which you
are getting and why — an absent model needs explaining, or it reads as a broken
page.

Two things decide what appears:

- **Discovery.** The GPT list is fetched from `GET /v1/models` on this host's
  own key at boot, not written into the source. A model id that has been
  retired would otherwise be an outage the first time somebody selected it.
- **Pricing.** A model is offered only if this build can also *price* it.
  `lib/spend.py` prices an unknown model at `$0.00`, so an unpriced model would
  pass the budget ceiling as though it were free rather than failing against
  it. Discovery decides what exists; pricing decides what is safe to meter.

The list is filled when the page is **viewed**, not when it is imported. Page
modules are imported while Dash registers pages — before `run.py` warms
discovery — so a layout that read the list at import froze the Claude-only set
permanently, whatever key was set. See `lib/model_picker.py`.

---

### What it costs, before and after

The estimate sits under the model picker and re-prices itself when you change
the model — choosing Opus over Haiku is choosing a 5x bill, and the only moment
that is useful is *before* the click. It also shows what is left of this shared
host's hourly ceiling.

That figure is an **upper bound, not a guess**: `lib/spend.py` prices every
call as though it used its whole output budget. The number reported after the
run is measured from the provider's own token counts and is nearly always
lower.

The result line then says what the run *actually* cost, to four decimal places
— two would render most sculpts as `$0.00`, which reads as free rather than as
cheap.

---

### This site runs without provider keys

The AI demos on this page are **off on modelviewer.2plot.dev**, deliberately.
This is a documentation site; it carries no provider keys and does no
production spend (owner's decision, 2026-09-12).

So on the public site you will see the model picker empty, the generate
control disabled, and a line saying so. **That is the expected state, not a
fault** — please do not file it.

To try it, run the site locally with your own keys:

```bash
git clone https://github.com/pip-install-python/dash-model-viewer
cd dash-model-viewer
printf 'ANTHROPIC_API_KEY=sk-ant-...\nCHATGPT_API_KEY=sk-...\n' > .env
pip install -r requirements.txt && python run.py
```

Either key alone is enough — the picker offers whichever provider it finds.
`lib/spend.py`'s ceiling then applies locally: a rolling call limit and a
cumulative dollar estimate, so an accident costs a few cents rather than a
weekend.

Everything on this page that does **not** need a key still works and is worth
reading for it: the JSON schema, the clamps, the sRGB-to-linear conversion and
the dependency-free glTF writer in `lib/glb.py` are all plain Python.


---

*Source: /generative-3d*

---

<!-- /image-to-3d — https://modelviewer.2plot.dev/image-to-3d/llms.txt -->

# Image to 3D

> Upload a picture and get a real .glb — Claude reads the image and chooses the carving parameters, and deterministic code cuts the relief.

---



### What this is, stated plainly

This is **not** photogrammetry and not neural reconstruction. One photograph
does not contain the back of an object, and nothing here pretends otherwise.

It carves a **relief** — a bas-relief slab, the way a coin or a carved panel is
3D. The image's brightness becomes height, the image itself becomes the surface
texture, and the result is a solid object you can orbit, light and place in AR.

For faces, logos, leaves, coins, lettering and hand-drawn shapes that is
genuinely the right answer. It works on any image, and the geometry costs
nothing to produce.

---

### Try it



```python
# File: docs/image-to-3d/relief_page.py

import base64
import json
from pathlib import Path

from dash import Input, Output, callback, dcc, html, no_update
import dash_mantine_components as dmc

import dash_model_viewer as dmv
from lib import relief

VIEWER_ATTRS = {
    "environment-image": "neutral",
    "exposure": "1.15",
    "shadow-softness": "0.6",
}

# The page opens on a relief of this site's own logo, carved with DEFAULT
# parameters and zero API calls — so the geometry half is demonstrably working
# before anyone spends anything, and the page is never an empty box.
#
# Carved at IMPORT, and therefore wrapped. Dash imports every page module while
# constructing the app, so an exception raised out here is not a broken page —
# it is a site that will not boot, with a traceback naming a docs example as
# the cause. That is exactly what happened on the first deploy: Pillow was
# missing from requirements.txt, `carve()` raised ModuleNotFoundError, and all
# ten pages went down over one sample image. Pillow is declared now
# (requirements.txt) and tests/test_requirements.py keeps it declared; this
# guard is the second line, for every other reason a carve might fail on a
# machine we have not thought of yet. A degraded example beats a dead site.
_SAMPLE = Path("assets/logo.png")
try:
    _sample_glb, _sample_stats = relief.carve(
        _SAMPLE.read_bytes(),
        {"depth_profile": "punchy", "depth_scale": 0.16, "invert": False,
         "background": "alpha", "metallic": 0.15, "roughness": 0.55},
    )
    _sample_error = None
except Exception as exc:  # noqa: BLE001 — a docs example may not kill the boot
    _sample_glb, _sample_stats = None, {}
    _sample_error = f"{type(exc).__name__}: {exc}"
    print(f"[image-to-3d] WARNING: the sample relief could not be carved "
          f"({_sample_error}). The page will render without its opening "
          f"model; uploads will fail the same way until this is fixed.")

component = html.Div(
    [
        dcc.Upload(
            id="i3-upload",
            children=dmc.Paper(
                dmc.Stack(
                    [
                        dmc.Text("Drop a PNG or JPEG here", fw=600),
                        dmc.Text("or click to choose · 6 MB max", size="xs", c="dimmed"),
                    ],
                    gap=2,
                    align="center",
                ),
                withBorder=True,
                radius="md",
                p="lg",
                style={"borderStyle": "dashed", "cursor": "pointer"},
            ),
            multiple=False,
            accept="image/*",
        ),
        dmc.Space(h="sm"),
        # There is no button here — the upload itself is the trigger — so the
        # busy state is the ONLY signal that anything is happening. A vision
        # call plus a 29k-triangle carve is several seconds of nothing.
        dmc.Box(
            pos="relative",
            children=[
                dmc.LoadingOverlay(
                    id="i3-busy",
                    visible=False,
                    zIndex=10,
                    overlayProps={"radius": "md", "blur": 2},
                    loaderProps={"type": "bars", "color": "indigo"},
                ),
                dmv.ModelViewer(
                    id="i3-viewer",
                    src=relief.to_data_url(_sample_glb) if _sample_glb else None,
                    alt="A bas-relief carved from the dash-model-viewer logo",
                    camera_controls=True,
                    camera_orbit="20deg 72deg 0.6m",
                    shadow_intensity=1,
                    interpolation_decay=90,
                    attributes=VIEWER_ATTRS,
                    style={"width": "100%", "height": "430px"},
                ),
            ],
        ),
        dmc.Text(
            # Measured: ~8.5s for the vision call, ~0.2s for the carve.
        "Reading the image and carving — about 10 seconds.",
            id="i3-working", size="sm", c="dimmed", mt="xs", display="none",
        ),
        dmc.Alert(id="i3-status", mt="sm", color="indigo", hide=True),
        dmc.Spoiler(
            id="i3-spoiler",
            showLabel="Show the carving parameters",
            hideLabel="Hide",
            maxHeight=0,
            children=dmc.Code(id="i3-json", block=True),
            mt="xs",
        ),
    ]
)


@callback(
    Output("i3-viewer", "src"),
    Output("i3-viewer", "alt"),
    Output("i3-status", "children"),
    Output("i3-status", "color"),
    Output("i3-status", "hide"),
    Output("i3-json", "children"),
    Input("i3-upload", "contents"),
    running=[
        (Output("i3-busy", "visible"), True, False),
        (Output("i3-upload", "disabled"), True, False),
        (Output("i3-working", "display"), "block", "none"),
    ],
    prevent_initial_call=True,
)
def carve_upload(contents):
    if not contents:
        return (no_update,) * 6

    # dcc.Upload hands back "data:<media-type>;base64,<payload>".
    try:
        header, payload = contents.split(",", 1)
        media_type = header.split(";")[0].removeprefix("data:") or "image/png"
        raw = base64.b64decode(payload)
    except Exception:
        return no_update, no_update, "That upload could not be decoded.", "red", False, no_update

    result = relief.relief_from_image(raw, media_type)
    if not result.ok:
        return no_update, no_update, result.reason, "yellow", False, no_update

    note = f"{result.title} — {result.params.get('notes', '')}"
    if result.notes:
        note += "  ·  " + "; ".join(result.notes)
    s = result.stats
    note += (f"  ·  {s['grid']} grid, {s['triangles']:,} triangles, "
             f"{s['glb_bytes'] / 1024:.0f} KB")

    return (
        result.data_url,
        result.alt,
        note,
        "indigo",
        False,
        json.dumps(result.params, indent=2),
    )
```


The page opens on a relief of this site's own logo, carved with **default
parameters and zero API calls** — so the geometry half is visibly working
before anything is spent.

---

### Where the model earns its place

The geometry is pure arithmetic. The *parameters* are a judgement call, and the
wrong ones produce a puddle or a cliff — so Claude looks at the image and
chooses them. Vision in, structured parameters out, code does everything after.

| Parameter | What it decides |
| :-- | :-- |
| `background` | whether a flat backdrop is cut away — **the one that matters most** |
| `depth_profile` | `linear` / `soft` / `punchy` / `stepped` — how brightness maps to height |
| `depth_scale` | relief depth as a fraction of width; a coin is ~0.04, a carved panel ~0.12 |
| `invert` | whether the subject's *dark* tones should stand proud (ink, engraving) |
| `metallic` / `roughness` | the material of the finished carving, not of the thing photographed |
| `alt` | one sentence for a screen-reader user — `alt` is a required prop |

On a stylised astronaut poster it returned:

> `soft` · `depth_scale 0.12` · `background cut_light` · matte
>
> *"Soft profile keeps the bright white suit from spiking while the flat light
> backdrop is cut away so the figure stands proud."*

On flat blue line-art it returned `punchy` with `invert: true` — because there
the dark strokes are the subject and everything else is paper.

That distinction is not something a fixed heuristic gets right, and it is not
something the user should have to know. It is exactly the shape of judgement a
vision model is good at.

---

### The bug that shaped the design

The first working version rendered every photograph as **a blank slab**.

Brightness becomes height. A subject photographed against white therefore makes
the *background* the tallest part of the carving, and sinks the subject into it.
The output is a flat plateau with a subject-shaped dent — and from the front it
looks like nothing happened at all.

Neither `invert` nor a different profile fixes it: inverting a photo raises the
shadows instead, which is a different wrong answer. The fix is a separate
decision — **is there a flat backdrop, and should it be cut away?**

```python
border = [edge pixels of the downsampled image]
reference = median(border)          # measured, not assumed
def is_backdrop(i):
    if mode == "cut_light":
        return lum[i] >= reference - tolerance and reference > 140
```

The reference tone is *sampled from the image's own border* rather than assumed
to be pure white, so "light" means whatever this particular image's edges
actually are — which survives JPEG noise, a soft vignette and an off-white
studio sweep.

The prompt then spends more words on this one parameter than on all the others
together, because it is the one that silently produces a plausible-looking
failure:

> Choosing `keep` on a white-background photo makes the BACKGROUND the tallest
> part of the carving and sinks the subject into it, so the whole thing reads as
> a blank slab. If the corners are all one flat colour, CUT.

---

### It degrades, it does not break

Every failure in the analysis step falls back to defaults and **still carves**:

| What went wrong | What happens |
| :-- | :-- |
| No `ANTHROPIC_API_KEY` | carved with defaults, noted in the status line |
| API error or timeout | carved with defaults, noted |
| Safety decline | carved with defaults, noted |
| Unparseable JSON | carved with defaults, noted |

The user always gets an object. That is deliberate: the model is choosing
*settings*, not doing the work, so its absence should cost quality rather than
the whole feature.

---

### Verifying geometry without a browser

A relief is 18 mm of depth on a 300 mm panel. Flat-shaded from the front it
looks exactly like a blank rectangle whether it worked or not, which makes
"looks fine" a useless check.

Two measurements settle it instead — and both are worth stealing for any
generated-geometry work:

```
z min/max        : -0.0046 → 0.01841        (back plate → peak relief)
distinct levels  : 185
vertices at z=0  : 3,875                    (the transparent background, flat)
```

...and rendering the height field itself as a greyscale image, where the
astronaut is immediately legible: suit proud, visor and gloves recessed,
background at exactly zero.

**Render the data, not the scene.** A picture of the parameter you care about
beats a picture of the object every time.

---

### Full 360° is a different pipeline

To get all sides you have to *generate* the ones the camera never saw. The
approach that works is the one in the SailsBoard object generator: the front
view goes in, five more orthographic views come out, each conditioned on the
front as an **identity lock** plus the already-approved views for consistency —
and those six views are exactly the six faces of a textured box.

Its hard-won lessons transfer intact:

- **"If any later reference disagrees with Reference 1, Reference 1 wins."**
  Explicit precedence, because consistency chains drift.
- **A measured COLOR LOCK split by role** — body colours versus outline ink.
  Naming raw hex values alone backfired: the outline shade was legitimately
  dominant, so told "build from these colours" the model rendered whole unseen
  faces in it.
- **Explicit relative proportions** — *"the front face is 3 tiles wide, but this
  side profile is 1 tile wide"* — because a model told only "draw the side"
  draws the front's proportions and then squashes them.
- **One rule per observed failure.** No scene, no isometric drift, no contact
  sheets. Each line kills something that actually happened.

That costs one image-generation call per view. This page deliberately does the
zero-generation half — and for the subjects it suits, the relief is not a
compromise but the correct object.

For geometry from a description rather than a picture, see
[Generative 3D art](/generative-3d).


---

*Source: /image-to-3d*

---

<!-- /migrating — https://modelviewer.2plot.dev/migrating/llms.txt -->

# Migrating from 0.0.1

> A prop-by-prop map from the 0.0.1 API to 1.0.0, and an honest account of why it is a clean break.

---



### Why a clean break

1.0.0 is not a compatible upgrade. `DashModelViewer` is gone; there is no
shim that keeps old code running.

That is a deliberate decision rather than an oversight, and the argument for
the other choice was real: the package had roughly 253 downloads a month, and a
deprecating wrapper would have cost little. What tipped it was that the parts
most in need of changing — hotspots, AR defaults, and the complete absence of
output props — could not be fixed *while* preserving the old surface. A
compatibility layer would have frozen the exact shapes that made the component
hard to use.

So: rename, and say so loudly.

---

### Side by side

```python
# 0.0.1
from dash_model_viewer import DashModelViewer

DashModelViewer(
    id="viewer",
    src="/assets/shoe.glb",
    alt="A shoe",
    cameraControls=True,
    arModes="webxr scene-viewer quick-look",   # you had to know to set this
    arButtonText="View in your space",
    hotspots=[
        {"slot": "hotspot-1", "position": "0 1 0", "text": "Sole",
         "children_classname": "label"},
    ],
)
```

```python
# 1.0.0
import dash_model_viewer as dmv
from dash import html

dmv.ModelViewer(
    id="viewer",
    src="/assets/shoe.glb",
    alt="A shoe",
    camera_controls=True,
    # ar_modes now defaults to "webxr scene-viewer quick-look"
    children=[
        dmv.Slot(slot="hotspot-1", position="0 1 0",
                 class_name="label", children="Sole"),
        dmv.Slot(slot="ar-button",
                 children=html.Button("View in your space")),
    ],
)
```

---

### Prop map

| 0.0.1 | 1.0.0 | Notes |
| :-- | :-- | :-- |
| `DashModelViewer` | `ModelViewer` | Import name unchanged; class renamed. |
| `cameraControls` | `camera_controls` | All props are `snake_case` now. |
| `touchAction` | `touch_action` | |
| `cameraOrbit` / `cameraTarget` | `camera_orbit` / `camera_target` | Now also **readable** via `camera`. |
| `fieldOfView`, `minFieldOfView`, `maxFieldOfView` | `field_of_view`, … | |
| `minCameraOrbit` / `maxCameraOrbit` | `min_camera_orbit` / `max_camera_orbit` | |
| `interpolationDecay` | `interpolation_decay` | |
| `toneMapping` | `tone_mapping` | |
| `shadowIntensity` | `shadow_intensity` | |
| `arModes` | `ar_modes` | **Default fixed** — see below. |
| `arScale` | `ar_scale` | |
| `variantName` | `variant_name` | |
| `hotspots=[{...}]` | `children=[Slot(...)]` | Now takes any Dash component. |
| `arButtonText="…"` | `Slot(slot="ar-button", children=…)` | |
| `customArPrompt=…` | `Slot(slot="ar-prompt", children=…)` | |
| `customArFailure=…` | `Slot(slot="ar-failure", children=…)` | |
| `loading_state` | *(none)* | Not this package's removal — Dash 4 dropped `loading_state` from components generally. Use `dcc.Loading`, or `model_state` for this component's own progress. |
| `id`, `src`, `alt` *(declared required)* | `src`, `alt` **enforced** | 0.0.1's generated metadata declared all three required; nothing in 1.0.0 checked until now. `src` and `alt` now raise at construction if missing. `id` stays optional — a viewer with no callbacks needs none. |
| *(none)* | `camera`, `model_state`, `model_info`, `ar_status`, `ar_tracking`, `scene_point` | The half that never worked. `camera` is `{"orbit", "target", "field_of_view"}` — see below. |
| *(none)* | `attributes`, `mv_*` | Full upstream parity. |
| *(none)* | `camera_change_debounce` | Mandatory guard. |
| *(none)* | `pick_on_click` | Arms `scene_point`. |

---

### Hotspot dictionaries → `Slot`

| Old key | New |
| :-- | :-- |
| `slot` | `Slot(slot=...)` |
| `position` | `Slot(position=...)` |
| `normal` | `Slot(normal=...)` |
| `text` | `Slot(children="...")` — or any component |
| `children_classname` | `Slot(class_name=...)` |
| `orbit` / `target` / `fov` | A callback on `Slot.n_clicks` writing the camera props |

The last row is the significant one. Camera-preset hotspots used to be declared
*inside* the hotspot dict and handled invisibly by the component's JavaScript.
They are now an ordinary callback, which means you can log them, animate them,
gate them on auth, or compute them — see
[Camera and views](/camera-and-views).

---

### Delete your clientside callbacks

If you copied the 0.0.1 examples you will have an `assets/model_viewer_clientside.js`
and a `clientside_callback` for anything the component could not report. All of
it is replaceable:

| You were doing this in JS | Now |
| :-- | :-- |
| Reading `getCameraOrbit()` into a `dcc.Store` | `Input("viewer", "camera")` |
| `getDimensions()` for a bounding box | `Input("viewer", "model_info")` |
| `availableVariants` for a dropdown | `model_info["variants"]` |
| Listening for `load` / `progress` | `Input("viewer", "model_state")` |
| Listening for `ar-status` / `ar-tracking` | `ar_status` / `ar_tracking` |
| `positionAndNormalFromPoint()` on click | `pick_on_click=True` → `scene_point` |
| Wiring hotspot clicks | `Slot.n_clicks` |

---

### The AR default

Worth calling out separately because it changes behaviour silently rather than
loudly. 0.0.1 shipped:

```python
arModes = "basic_annotations scene-viewer quick-look"
```

`basic_annotations` is not an AR mode — it was a folder name in `usage_tests/`.
`webxr` was therefore absent, and in-page WebXR AR never ran in a default
configuration. If your code explicitly set `arModes`, you were unaffected; if
it did not, you were running without WebXR and had no way to know.

You can now delete any explicit `ar_modes="webxr scene-viewer quick-look"` —
that is the default. See [Augmented reality](/augmented-reality).

---

### Things that no longer exist

- **`camera["source"]`** — removed in 1.0.0 before release. The shim reports
  camera movement *only* for user interaction (that is the echo suppression
  which stops a callback writing `camera_orbit` from re-triggering itself), so
  `source` could only ever hold the single string `"user-interaction"`. A key
  with one possible value tells a reader nothing, and reading it invited the
  belief that some other value was reachable. The payload is now
  `{"orbit", "target", "field_of_view"}`. The suppression itself is unchanged.
- `dash_model_viewer.DashModelViewer` — renamed.
- The generated R and Julia bindings — removed; they were generated and unused.
- `package-info.json` inside the package — `__version__` now comes from
  installed metadata.
- The runtime CDN fetch of `model-viewer` 3.5.0 — the bundle ships in the wheel
  at 4.3.1. If you relied on the CDN behaviour, `dmv.configure(use_cdn=True)`
  restores it.


---

*Source: /migrating*

---

<!-- /model-switching — https://modelviewer.2plot.dev/model-switching/llms.txt -->

# Model Switching and Variants

> Swap the model at runtime, and drive GLTF material variants from a dropdown the viewer populates itself.

---



### Swapping models

`src` is an ordinary prop. Change it from a callback and the viewer loads the
new file, keeping the camera where the user left it.



```python
# File: docs/model-switching/switching.py

from dash import Input, Output, callback, html
import dash_mantine_components as dmc

import dash_model_viewer as dmv
from lib.demo_models import GLAM_SOFA, MODELS_WITH_VARIANTS

MODELS = MODELS_WITH_VARIANTS

component = html.Div(
    [
        dmc.Group(
            [
                dmc.SegmentedControl(id="ms-model", data=list(MODELS), value="Sofa"),
                dmc.Select(
                    id="ms-variant",
                    placeholder="Variant",
                    data=[],
                    w=220,
                    clearable=True,
                ),
            ],
            mb="sm",
        ),
        dmv.ModelViewer(
            id="ms-viewer",
            src=GLAM_SOFA,
            alt="A velvet sofa whose material variants can be switched at runtime",
            camera_controls=True,
            shadow_intensity=1,
            style={"width": "100%", "height": "380px"},
        ),
        dmc.Text(id="ms-status", size="sm", c="dimmed", mt="xs"),
    ]
)


@callback(Output("ms-viewer", "src"), Input("ms-model", "value"))
def swap_model(name):
    return MODELS[name]


@callback(
    Output("ms-variant", "data"),
    Output("ms-variant", "value"),
    Output("ms-variant", "disabled"),
    Output("ms-status", "children"),
    Input("ms-viewer", "model_info"),
)
def list_variants(info):
    """The viewer tells us which variants the file actually contains.

    The empty case is rendered as a visible, disabled state rather than an
    enabled dropdown with nothing in it — a control that looks operable and
    does nothing reads as a broken page, which is exactly how this one read
    when two of its three models carried no variants.
    """
    if not info:
        # Not loaded yet. Saying "no variants" here would be a lie for the
        # first second of every page view, and an alarming one on a page whose
        # whole subject is variants.
        return [], None, True, "Loading the model…"
    variants = info.get("variants") or []
    if not variants:
        return [], None, True, "This model carries no material variants."
    plural = "s" if len(variants) != 1 else ""
    return (
        variants,
        None,
        False,
        f"{len(variants)} variant{plural}: {', '.join(variants)}",
    )


@callback(
    Output("ms-viewer", "variant_name"),
    Input("ms-variant", "value"),
)
def choose_variant(value):
    """Clearing the dropdown must CLEAR the prop, not leave the old value.

    This callback used to `return no_update` when nothing was selected, which
    looked harmless and was not: switching models leaves `ms-variant.value`
    None, so the viewer kept the variant chosen on the *previous* model and
    applied a name the new file does not contain. `"default"` is the GLTF
    default material — the shim drops the attribute entirely for that value,
    which is how model-viewer expresses "no variant".
    """
    return value or "default"
```


---

### Variants populate themselves

The interesting part is the second callback. The variant dropdown is **not**
hard-coded — it is filled from `model_info["variants"]`, which the viewer
reports after the file loads.

```python
@callback(
    Output("ms-variant", "data"),
    Input("ms-viewer", "model_info"),
)
def list_variants(info):
    return (info or {}).get("variants") or []
```

Switch between the three and the dropdown refills itself each time — five
names for the sofa, two for the chair, three for the shoe. Nothing on the
server knows anything about any of those files.

This is the shape of every "user uploads their own model" feature, and it was
not possible in 0.0.1 — the variant list lived in the browser and there was no
way to get it out.


    The third callback returns `value or "default"` rather than
    `no_update`. That looks like a detail and is not: switching models leaves
    the dropdown empty, and a callback that declines to update leaves
    `variant_name` holding the variant you picked on the **previous** model —
    a name the new file does not contain. The viewer then renders the new
    model with a stale variant request.

    All three models here carry variants, which is deliberate. Only five of
    the fifteen Khronos sample models do, and a page named for the feature
    should not offer models that cannot show it.

---

### `variant_name`

| Value | Effect |
| :-- | :-- |
| `None` | The GLTF's default variant. |
| `"default"` | Also the default — accepted for readability. |
| `"Midnight"` | That named variant, if present. |

An unknown name is ignored by `<model-viewer>` rather than raising, so validate
against `model_info["variants"]` if it matters.

---

### Loading state during a swap

A large model swap is not instant, and an unstyled viewer shows the *old* model
until the new one is ready. Use `model_state` to say so:

```python
@callback(Output("overlay", "style"), Input("viewer", "model_state"))
def spinner(state):
    loading = (state or {}).get("status") == "loading"
    return {"display": "flex" if loading else "none"}
```

A `poster` image is the cheaper version of the same idea — it covers the first
load, though not subsequent swaps:

```python
dmv.ModelViewer(..., poster="/assets/preview.webp")
```


    `src` is a URL, so the browser caches it. If your models are user-uploaded
    and can change at the same URL, append a version or hash — otherwise a
    re-upload shows the previous mesh and looks like the upload failed.

---

### Animations

`model_info["animations"]` lists the clips in the file. Playback control is not
a named prop in 1.0.0; drive it through
[attributes](/attributes-and-parity) meanwhile:

```python
@callback(Output("viewer", "attributes"), Input("clip", "value"))
def play(clip):
    return {"autoplay": "", "animation-name": clip}
```


---

*Source: /model-switching*

---

<!-- /model-upload — https://modelviewer.2plot.dev/model-upload/llms.txt -->

# Model Upload

> Drop a .glb file onto the page and render it immediately, with a readout of what is actually inside the file.

---



## Open your own model

Every other page here hands `<model-viewer>` a model *this site* chose. This one
hands it yours. Choose a `.glb` and it renders — and the panel beside it says
what the file actually contains, which is usually the thing you wanted to know.



```python
# File: docs/model-upload/model_upload.py

import dash_mantine_components as dmc
from dash import Input, Output, State, callback, dcc, html, no_update

import dash_model_viewer as dmv
from lib import demo_models, glb, uploads

VIEWER_ATTRS = {
    "environment-image": "neutral",
    "exposure": "1.05",
    "shadow-softness": "0.7",
    # Bare boolean attributes are passed as the empty string — `auto-rotate`
    # has no named prop, and the shim writes presence rather than a value.
    "auto-rotate": "",
    "auto-rotate-delay": "1200",
}

#: Rows are (label, key, formatter). Declared once so the table and its test
#: read the same list — a stats panel that drifts from what it measures is
#: worse than no stats panel.
ROWS = [
    ("Format", "version", lambda v: f"glTF {v}"),
    ("Written by", "generator", str),
    ("Nodes", "nodes", "{:,}".format),
    ("Meshes", "meshes", "{:,}".format),
    ("Triangles", "triangles", "{:,}".format),
    ("Materials", "materials", "{:,}".format),
    ("Textures", "textures", "{:,}".format),
    ("Animations", "animations", "{:,}".format),
    ("Size", "bytes", lambda v: f"{v / 1048576:.2f} MB"),
]


def _table(summary):
    body = [
        dmc.TableTr([dmc.TableTd(label), dmc.TableTd(fmt(summary[key]))])
        for label, key, fmt in ROWS
    ]
    extras = []
    if summary["animation_names"]:
        named = ", ".join(n for n in summary["animation_names"] if n) or "unnamed"
        extras.append(dmc.TableTr([dmc.TableTd("Clips"), dmc.TableTd(named)]))
    if summary["extensions"]:
        extras.append(dmc.TableTr([
            dmc.TableTd("Extensions"),
            dmc.TableTd(", ".join(summary["extensions"])),
        ]))
    return dmc.Table(
        striped=True, withTableBorder=True, verticalSpacing="4px",
        children=[dmc.TableTbody(body + extras)],
    )


component = html.Div(
    [
        dcc.Store(id="mu-summary"),
        dmc.Group(
            [
                dcc.Upload(
                    id="mu-upload",
                    accept=".glb,model/gltf-binary",
                    multiple=False,
                    children=dmc.Button("Choose a .glb file"),
                ),
                dmc.Text(id="mu-status", size="sm", c="dimmed"),
            ],
            mb="xs",
            align="center",
        ),
        dmc.Grid(
            gutter="md",
            children=[
                dmc.GridCol(
                    dmv.ModelViewer(
                        id="mu-viewer",
                        src=demo_models.ASTRONAUT,
                        alt="An uploaded glTF binary model",
                        camera_controls=True,
                        shadow_intensity=1,
                        attributes=VIEWER_ATTRS,
                        style={"width": "100%", "height": "460px"},
                    ),
                    span={"base": 12, "md": 7},
                ),
                dmc.GridCol(
                    html.Div(id="mu-facts"),
                    span={"base": 12, "md": 5},
                ),
            ],
        ),
    ]
)


@callback(
    Output("mu-viewer", "src"),
    Output("mu-viewer", "alt"),
    Output("mu-status", "children"),
    Output("mu-facts", "children"),
    Output("mu-summary", "data"),
    Input("mu-upload", "contents"),
    State("mu-upload", "filename"),
    prevent_initial_call=True,
)
def show_model(contents, filename):
    """Validate the upload, describe it, and hand it to the viewer.

    THE FILE IS IDENTIFIED BY ITS CONTENT, not by the media type the browser
    guessed: a `.glb` is recognised by its magic number and version field, so a
    renamed ZIP is refused with a sentence rather than reaching the viewer and
    silently failing to draw. The rules live in `lib/uploads.py` beside the
    image ones, so the two cannot drift into different caps.

    Nothing is written to disk. The bytes are validated, measured, and handed
    straight back as the `data:` URL the viewer reads.
    """
    raw, message = uploads.decode_model(contents, filename)
    if raw is None:
        return no_update, no_update, message, no_update, no_update

    try:
        summary = glb.summarize(raw)
    except (ValueError, KeyError, IndexError):
        return (no_update, no_update,
                f"{filename or 'that file'} is a glTF container this page "
                f"cannot read — its JSON chunk is malformed.",
                no_update, no_update)

    name = filename or "the uploaded model"
    return (
        contents,
        f"{name}, an uploaded glTF binary model",
        f"{name} — {message}",
        _table(summary),
        summary,
    )
```


---

## Why `.glb` and not `.gltf`

They are the same format with different packaging, and only one of them can
survive being uploaded on its own.

A `.gltf` file is JSON that **points at** its buffers and textures —
`scene.bin`, `colour.png`, a folder of images. Upload the `.gltf` by itself and
those neighbours stay on your machine, so the viewer gets a description of a
model whose geometry is missing. A `.glb` is the same data with the JSON and
every buffer packed into one binary container, which is exactly what makes it
uploadable.

That is also why the demo models across this site are `.glb`: one request, one
file, nothing to lose.

---

## The file is identified by its contents

A browser's idea of a file's type comes from its extension, and it is often
`application/octet-stream` — or nothing. So the type is not trusted here. The
first twelve bytes of a binary glTF are a fixed structure, and they cannot be
wrong about what the file is:

| Bytes | Meaning | Checked |
| :-- | :-- | :-- |
| 0–3 | the magic number `glTF` | it is that, or it is not a `.glb` |
| 4–7 | the format version | must be `2` |
| 8–11 | the total file length | must equal the bytes actually received |

The third check is the useful one: a file that says it is larger than it is was
truncated in transit, and the viewer would fail to draw it with no explanation.
Here it is named.

Refusals say which rule was broken and what was seen, because "invalid file"
tells you nothing about how to succeed next time. The rules themselves live in
`lib/uploads.py` alongside the image rules used by
[Texture Upload](/texture-upload) and [Sculpt from an Image](/sculpt-from-image)
— one place, so the three pages cannot drift into stating different caps.

| Rule | Value |
| :-- | :-- |
| Accepted | binary glTF (`.glb`), version 2 |
| Size cap | 32 MB |
| Written to disk | Never |
| Sent to a third party | Never |

The cap is larger than the 3 MB ceiling on the sculptures this site *generates*,
because a real exported or scanned asset is routinely tens of megabytes. It is
still a ceiling: the bytes make a round trip and come back as a `data:` URL in
the page.

---

## Reading the file rather than only drawing it

The panel is produced by `lib/glb.summarize()`, which parses the header and the
JSON chunk **only** — no buffers are decoded, so describing a 30 MB model costs
the same as describing a 30 KB one.

Triangle count is the number worth knowing. It is summed from the accessor
behind each primitive's indices, so it is the count the GPU will actually draw
rather than a figure from the exporter's dialog.

The **Animations** row pairs with [Animation](/animation): if a file reports
clips here, that page's controls will drive them. **Textures** answers the
question [Texture Upload](/texture-upload) raises — whether a model arrived
already carrying its own imagery, or whether what you are seeing is flat
material colour.

**Extensions** is worth a glance on somebody else's export. `<model-viewer>`
implements a subset of glTF's extensions; one that is listed here and not
supported is the usual reason a model renders but looks wrong — the variants
machinery behind [Model Switching](/model-switching) is
`KHR_materials_variants`, and it appears in this row when a file carries it.


---

*Source: /model-upload*

---

<!-- /quick-start — https://modelviewer.2plot.dev/quick-start/llms.txt -->

# Quick Start

> Install the package, put a 3D model on the page, and understand what got injected.

---



### Install

```bash
pip install dash-model-viewer
```

One dependency: `dash>=4.1`. Google's `model-viewer` bundle is **inside the
wheel** — there is no npm step, no CDN request at runtime, and nothing to add
to `external_scripts`.

---

### Your first viewer

Two props are required — `src` and `alt` — and one is strongly advised:
`style`, because the element has no intrinsic size and will collapse to nothing
without a height.



```python
# File: docs/quick-start/basic_viewer.py

from dash import html

import dash_model_viewer as dmv
from lib.demo_models import ASTRONAUT

component = html.Div(
    dmv.ModelViewer(
        id="qs-viewer",
        src=ASTRONAUT,
        alt="A 3D model of an astronaut in a white spacesuit",
        style={"width": "100%", "height": "420px"},
    )
)
```


Drag to orbit. Scroll to zoom. On a phone, the AR button in the corner opens
the model in your room.

---

### What just happened

Importing `dash_model_viewer` registers a Dash **hook**, which injects two
scripts into every page:

| Script | Size | What it is |
| :-- | :-- | :-- |
| `vendor/model-viewer-umd.min.js` | ~1 MB | Google's `model-viewer` 4.3.1, vendored verbatim |
| `dash_model_viewer.js` | ~13 KB | The Dash ↔ custom-element shim |

Both are served by *your* server from the installed wheel, so the component
works offline, behind a corporate egress proxy, and under a strict
`script-src` Content-Security-Policy. The version is pinned by your lockfile
rather than by whatever a CDN is serving today.


    The bundle loads on every page that imports the package, eagerly and
    synchronously. That is the price of having the custom element defined
    before Dash mounts — see [Why it loads that way](#why-it-loads-that-way).
    If your app has one 3D page out of thirty, import the package in that page
    module rather than in `app.py`.

---

### Serving the bundle from elsewhere

To use a CDN or an internal mirror instead of the vendored copy:

```python
import dash_model_viewer as dmv

dmv.configure(use_cdn=True)                         # public jsDelivr
dmv.configure(use_cdn="https://cdn.example/mv.js")  # your own mirror

app = Dash(__name__)
```

Call it at module scope. Dash reads the hook's resource list while generating
the index page, so a `configure()` buried in a callback or a lazily-imported
page module may or may not take effect depending on which request arrives
first — which is worse than never working at all.

---

### Why it loads that way

Not a detail, and worth knowing before you "optimise" it.

`<model-viewer>` is a custom element. If its definition has not executed by the
time Dash mounts your layout, the browser renders an unknown, unsized element
and you get a blank box — sometimes, depending on network timing. Version
0.0.1 fought this with `customElements.whenDefined()` and a runtime-injected
`<script type="module">`.

The fix is ordering, not defensive code. Dash's index ends:

```html
<footer>
    {%config%}
    {%scripts%}     <!-- every <script src>, hook scripts last -->
    {%renderer%}    <!-- inline: new DashRenderer(...) -->
</footer>
```

`{%renderer%}` is the inline statement that actually mounts the app. A
**classic** script in `{%scripts%}` runs before the parser reaches it, so the
element is always defined in time. A `type="module"` script is deferred until
after document parse — i.e. after `new DashRenderer(...)` has already run — and
the race comes straight back.

So the package vendors the **UMD** build, and never sets `type`, `async` or
`defer`. All three are asserted by the test suite, because none of them fail
loudly — they fail as "sometimes the model doesn't render, on someone else's
machine".

---

### Next

- [Events and callbacks](/events-and-callbacks) — the camera, load state and model
  dimensions as ordinary Dash props.
- [Slots and hotspots](/slots-and-hotspots) — labels anchored to the geometry.
- [Augmented reality](/augmented-reality) — including the default that was
  broken until 1.0.0.


---

*Source: /quick-start*

---

<!-- /scene-director — https://modelviewer.2plot.dev/scene-director/llms.txt -->

# Scene Director

> Describe a shot in plain language and let Claude stage it — grounded in the model's measured geometry, then clamped before anything reaches the browser.

---



### Why this demo and not text-to-3D

The obvious AI demo for a 3D component is *type a prompt, get a model*. It is
the wrong one to build first, for a reason that has nothing to do with taste:
**it would work just as well on the broken 0.0.1.** It only needs `src`.

This one cannot. It needs the component to report what it is looking at, and to
accept a whole scene back as props — and 0.0.1 had no output props at all.

That makes it the end-to-end regression test for the entire rebuild wearing a
user interface. If the Scene Director works, the event plumbing works.

---

### Try it



```python
# File: docs/scene-director/scene_director.py

import json

from dash import ALL, Input, Output, State, callback, ctx, html, no_update
import dash_mantine_components as dmc

import dash_model_viewer as dmv
from lib import scene_director
from lib.demo_models import SHOE

PROMPTS = [
    "dramatic low three-quarter angle, focus on the sole",
    "clean straight-on product shot for a shop listing",
    "top-down, tight, soft shadow",
    "slowly rotating hero shot, warm and premium",
]

component = html.Div(
    [
        dmc.Group(
            [
                dmc.TextInput(
                    id="sd-prompt",
                    placeholder="Describe the shot you want…",
                    value=PROMPTS[0],
                    style={"flex": 1},
                ),
                dmc.Button("Direct", id="sd-go", variant="filled"),
            ],
            mb="xs",
            align="flex-end",
        ),
        dmc.Group(
            [
                dmc.Badge(p, id={"type": "sd-preset", "i": i},
                          variant="light", style={"cursor": "pointer"})
                for i, p in enumerate(PROMPTS)
            ],
            gap="xs",
            mb="sm",
        ),
        dmc.Box(
            pos="relative",
            children=[
                dmc.LoadingOverlay(
                    id="sd-busy",
                    visible=False,
                    zIndex=10,
                    overlayProps={"radius": "md", "blur": 2},
                    loaderProps={"type": "bars", "color": "indigo"},
                ),
                dmv.ModelViewer(
                    id="sd-viewer",
                    src=SHOE,
                    alt="A shoe, restaged from a natural-language description",
                    camera_controls=True,
                    interpolation_decay=90,
                    shadow_intensity=1,
                    style={"width": "100%", "height": "400px"},
                ),
            ],
        ),
        dmc.Text(
            # Measured: ~4s (effort="low", short reasoning).
        "Directing — about 5 seconds.",
            id="sd-working", size="sm", c="dimmed", mt="xs", display="none",
        ),
        dmc.Alert(id="sd-status", mt="sm", color="indigo", hide=True),
        dmc.Code(id="sd-json", block=True, mt="xs"),
    ]
)


@callback(
    Output("sd-prompt", "value"),
    Input({"type": "sd-preset", "i": ALL}, "n_clicks"),
    prevent_initial_call=True,
)
def use_preset(clicks):
    if not any(clicks or []):
        return no_update
    return PROMPTS[ctx.triggered_id["i"]]


@callback(
    Output("sd-viewer", "camera_orbit"),
    Output("sd-viewer", "camera_target"),
    Output("sd-viewer", "field_of_view"),
    Output("sd-viewer", "tone_mapping"),
    Output("sd-viewer", "shadow_intensity"),
    Output("sd-viewer", "variant_name"),
    Output("sd-viewer", "attributes"),
    Output("sd-status", "children"),
    Output("sd-status", "color"),
    Output("sd-status", "hide"),
    Output("sd-json", "children"),
    Input("sd-go", "n_clicks"),
    State("sd-prompt", "value"),
    # The grounding. `model_info` is what the viewer measured; `camera` is where
    # the user left it. Both are State — this runs on the button, not on every
    # camera nudge.
    State("sd-viewer", "model_info"),
    State("sd-viewer", "camera"),
    running=[
        (Output("sd-busy", "visible"), True, False),
        (Output("sd-go", "loading"), True, False),
        (Output("sd-prompt", "disabled"), True, False),
        (Output("sd-working", "display"), "block", "none"),
    ],
    prevent_initial_call=True,
)
def direct(_, prompt, model_info, camera):
    result = scene_director.generate(prompt, model_info=model_info, camera=camera)

    if not result.ok:
        return (*(no_update,) * 7, result.reason, "yellow", False, no_update)

    p = result.props
    note = result.rationale
    if result.adjustments:
        note += "  —  " + "; ".join(result.adjustments)

    return (
        p.get("camera_orbit", no_update),
        p.get("camera_target", no_update),
        p.get("field_of_view", no_update),
        p.get("tone_mapping", no_update),
        p.get("shadow_intensity", no_update),
        p.get("variant_name"),
        p.get("attributes", {}),
        note,
        "indigo",
        False,
        json.dumps(p, indent=2),
    )
```



    If this host has no `ANTHROPIC_API_KEY`, the button returns a plain "the
    director is off" message rather than a stub that looks like it worked. The
    interesting parts — the grounding, the schema and the clamping — are in
    `lib/scene_director.py` and need no key to read.

---

### The loop

```
model_info + camera  ──►  grounded prompt  ──►  Claude (structured output)
                                                      │
                      props  ◄──  clamp + sanitise  ◄──┘
```

Two of those four steps do the real work, and neither is the API call.

#### 1. Grounding — the step that decides whether it works

The prompt carries the model's **measured** bounding box, in metres, straight
from `model_info`:

```
THIS MODEL, measured (metres):
  width(x)=0.294  height(y)=0.148  depth(z)=0.104
  available material variants: ['midnight', 'beach', 'street']

CURRENT CAMERA:
  orbit=15deg 78deg 0.42m  target=0m 0.07m 0m  fov=45deg

RULES — these are geometry, not style:
- Keep phi between 10deg and 170deg…
- Keep radius between 0.24m and 0.88m. Below that the camera is inside the mesh.
```

Without those numbers the model returns `"2.5m"` — a completely reasonable
radius for a *chair*, and about six metres outside a shoe. It is confidently
wrong, because nothing in the request says how big the object is.

This is the general lesson, not a model-viewer one: **an LLM writing
parameters for a physical system needs that system's measurements in the
prompt.** The component is what supplies them.

#### 2. Structured output

The schema is passed with the request, so the response is JSON of the right
shape or the call fails — no parsing, no retry loop, no "please respond with
only JSON" incantation:

```python
response = client.messages.create(
    model="claude-opus-5",
    max_tokens=1500,
    system=_system_prompt(model_info, camera),
    output_config={"effort": "low", "format": {"type": "json_schema", "schema": SCHEMA}},
    messages=[{"role": "user", "content": request}],
)
```

`effort="low"` because the reasoning here is short, and a docs-site demo should
not cost more than it needs to.


    The obvious schema for `attributes` is an object with
    `additionalProperties: {"type": "string"}`. The API rejects it outright:

    ```
    400 invalid_request_error — output_config.format.schema: For 'object' type,
    'additionalProperties: object' is not supported. Please set
    'additionalProperties' to false
    ```

    Every object in a structured-output schema must be closed. An **open key
    space therefore has to be modelled as a list of `{name, value}` pairs** and
    folded back into a dict afterwards. Enumerating the allowed attribute names
    instead would have thrown away the whole point of the parity hatch.

#### 3. Clamping — shape is not sanity

Structured output guarantees `camera_orbit` is a *string*. It does not
guarantee the string puts the camera outside the mesh. Every number is
re-checked against the geometry, and every adjustment is reported in the UI
rather than applied silently:

```python
if not 10 <= phi <= 170:
    phi = min(170.0, max(10.0, phi))
    notes.append(f"phi clamped to {phi:g}deg (pole roll)")
if not lo <= radius <= hi:
    radius = min(hi, max(lo, radius))
    notes.append(f"radius clamped to {radius:.2f}m for this model")
```

Unparseable values are discarded, not guessed at. A `variant_name` the file
does not contain falls back to the default.

#### 4. Sanitising — model output becomes DOM attributes

This one is a security boundary, and it is the part most worth copying.

The `attributes` dict is deliberately open — that is what makes the output
space the *entire* `<model-viewer>` attribute surface, including attributes
added upstream after this release. But open means the model could return
`src`, and `src` decides what the browser downloads.

```python
BLOCKED_ATTRIBUTES = frozenset(
    {"src", "ios-src", "poster", "environment-image", "skybox-image"}
)
```

**Never let model output set an attribute that fetches.** Everything on that
list points a user's browser at a URL. The rest of the surface — `exposure`,
`auto-rotate`, `orientation`, `shadow-softness` — changes only how the thing
already loaded is drawn, and is safe to hand over.

---

### What it actually returns

Two real responses for the shoe above, whose measured box is
0.294 x 0.148 x 0.104 m — so the clamp window for the radius is 0.24 m to
0.88 m:

> **"dramatic low three-quarter angle, focus on the sole"**
>
> ```json
> {
>   "camera_orbit": "40deg 118deg 0.36m",
>   "camera_target": "0m 0.05m 0m",
>   "field_of_view": "34deg",
>   "tone_mapping": "aces",
>   "shadow_intensity": 1.0,
>   "variant_name": null,
>   "attributes": {"exposure": "0.95", "shadow-softness": "0.4"}
> }
> ```
>
> *"I dropped the camera below the horizon at a 40° three-quarter turn so the
> sole tilts into view, with a tighter FOV and punchy ACES contrast for drama."*

`phi=118deg` is genuinely below the horizon, and `0.36m` sits inside the window
without needing a clamp — because the window was in the prompt.

> **"top-down, tight, soft shadow, midnight colourway"**
>
> ```json
> {
>   "camera_orbit": "0deg 15deg 0.32m",
>   "field_of_view": "28deg",
>   "shadow_intensity": 0.6,
>   "variant_name": "midnight",
>   "attributes": {"shadow-softness": "1", "exposure": "1.05"}
> }
> ```

That second one is the whole argument in one field. **`variant_name: "midnight"`
was only selectable because `model_info["variants"]` told the prompt the file
contained it.** Nothing on the server knows anything about that shoe. Point the
page at a different `.glb` and the available variants change with it.

---

### What makes the parity hatch interesting here

`attributes` is not a convenience for this page; it is what makes the feature
open-ended. The model can reach for `rotation-per-second`, `auto-rotate-delay`
or `orientation` without any of them being named props, and a `<model-viewer>`
5.x attribute nobody has written yet will work the same way.

The alternative — an enum of blessed props — would need extending every time
either upstream or the prompt got more ambitious. See
[Attributes and parity](/attributes-and-parity).

---

### Cost and posture

- **One call per click.** `max_tokens=1500`, `effort="low"`.
- **`stop_reason` is checked before `content` is read.** A safety decline
  returns HTTP 200 with an empty content list; indexing `content[0]` would
  raise instead of explaining.
- **The key is read at call time**, never cached at import, so rotating it does
  not need a redeploy.
- **The test suite blanks `ANTHROPIC_API_KEY`** in `conftest.py`, so no test
  run can ever spend money — `lib/backend.py` calls `load_dotenv()`, and a
  developer with a real key in `.env` would otherwise be billed by their own
  test suite.
- On a public host this page belongs behind an authenticated tier so spend is
  bounded by sign-ups rather than by traffic.

---

### Where this goes next

The same loop, pointed at a different output shape, is a **tour composer**:
ordered stops, each with a camera pose, a `Slot` hotspot anchored to real
geometry, and a caption. That produces a shareable artifact rather than a
single view, and it is what product configurators, museum pieces and technical
documentation all hand-write today.

The grounding, the clamping and the block-list are unchanged. Only the schema
grows.


---

*Source: /scene-director*

---

<!-- /scene-manifest — https://modelviewer.2plot.dev/scene-manifest/llms.txt -->

# Scene Manifest

> The versioned JSON a generated sculpture is made of — a complete, deterministic description you can export, edit by hand, and render again without paying a model.

---



### The manifest *is* the sculpture

When [Generative 3D](/generative-3d) produces a model, the model does not
produce geometry. It produces this:

```json
{
  "version": 1,
  "name": "Lighthouse",
  "notes": "a tapered tower with a warm lamp",
  "parts": [
    {
      "color": "#E8E4DC",
      "emissive_strength": 0.0,
      "metallic": 0.0,
      "name": "tower",
      "position": {
        "x": 0,
        "y": 1.0,
        "z": 0
      },
      "rotation": {
        "x": 0,
        "y": 0,
        "z": 0
      },
      "roughness": 0.8,
      "shape": "cylinder",
      "size": {
        "x": 0.4,
        "y": 2.0,
        "z": 0.4
      }
    },
    {
      "color": "#FFC15E",
      "emissive_strength": 1.0,
      "metallic": 0.1,
      "name": "lamp",
      "position": {
        "x": 0,
        "y": 2.1,
        "z": 0
      },
      "rotation": {
        "x": 0,
        "y": 0,
        "z": 0
      },
      "roughness": 0.3,
      "shape": "sphere",
      "size": {
        "x": 0.18,
        "y": 0.18,
        "z": 0.18
      }
    }
  ],
  "provenance": {
    "generated": "2026-09-12",
    "model": "hand-authored",
    "prompt": "a brutalist lighthouse at dusk",
    "usd": 0.0
  }
}
```

### Try the round trip

Pick a sample, edit it, render it. Nothing here calls a model — the manifests
are committed JSON, so this works on a host with no API key.



```python
# File: docs/scene-manifest/round_trip.py

import pathlib

from dash import Input, Output, State, callback, dcc, html, no_update
import dash_mantine_components as dmc

import dash_model_viewer as dmv
from lib import manifest

SAMPLES = pathlib.Path(__file__).parent / "samples"

#: The three valid samples, plus the refusal fixture kept deliberately apart.
#: All four are committed JSON — nothing on this page calls a model, which is
#: the point: the round trip works on a host with no API key.
SAMPLE_FILES = {
    "Lighthouse (2 parts)": "lighthouse.json",
    "Colonnade (28 parts — the limit)": "colonnade.json",
    "Brazier (emissive)": "brazier.json",
    "Cart (v2 — a wheel placed four times)": "cart.json",
    "Cart, written flat (v1 — the same sculpture)": "cart-flat.json",
}
FIXTURE = "INVALID-fixture.json"


def _read(name):
    return (SAMPLES / name).read_text(encoding="utf-8")


_FIRST = _read("lighthouse.json")
_INITIAL_GLB, _, _ = manifest.render(manifest.loads(_FIRST))

component = html.Div(
    [
        dcc.Download(id="sm-download-json"),
        dcc.Download(id="sm-download-glb"),
        dmc.Grid(
            gutter="md",
            children=[
                dmc.GridCol(
                    dmc.Stack(
                        gap="xs",
                        children=[
                            dmc.Select(
                                id="sm-sample",
                                label="Load a sample",
                                data=list(SAMPLE_FILES),
                                value=next(iter(SAMPLE_FILES)),
                                allowDeselect=False,
                            ),
                            dmc.Textarea(
                                id="sm-text",
                                label="Manifest",
                                value=_FIRST,
                                autosize=False,
                                minRows=14,
                                styles={"input": {"fontFamily": "monospace",
                                                  "fontSize": "11px"}},
                            ),
                            dmc.Group(
                                [
                                    dmc.Button("Render", id="sm-render"),
                                    dmc.Button("Export JSON", id="sm-export",
                                               variant="light"),
                                    dmc.Button("Download .glb", id="sm-glb",
                                               variant="light"),
                                    dmc.Button("Load the invalid fixture",
                                               id="sm-break", variant="subtle",
                                               color="orange"),
                                ],
                                gap="xs",
                            ),
                        ],
                    ),
                    span={"base": 12, "md": 6},
                ),
                dmc.GridCol(
                    [
                        dmv.ModelViewer(
                            id="sm-viewer",
                            src=manifest.sculptor.to_data_url(_INITIAL_GLB),
                            alt="A sculpture rendered from a committed scene manifest",
                            camera_controls=True,
                            shadow_intensity=1,
                            attributes={"environment-image": "neutral",
                                        "shadow-softness": "0.6"},
                            style={"width": "100%", "height": "420px"},
                        ),
                        dmc.Alert(id="sm-status", mt="xs", color="indigo",
                                  children="Rendered from the committed sample. "
                                           "No model was called."),
                    ],
                    span={"base": 12, "md": 6},
                ),
            ],
        ),
    ]
)


@callback(
    Output("sm-text", "value"),
    Input("sm-sample", "value"),
    Input("sm-break", "n_clicks"),
    prevent_initial_call=True,
)
def load_file(label, _break_clicks):
    from dash import ctx

    if ctx.triggered_id == "sm-break":
        return _read(FIXTURE)
    return _read(SAMPLE_FILES[label])


@callback(
    Output("sm-viewer", "src"),
    Output("sm-viewer", "alt"),
    Output("sm-status", "children"),
    Output("sm-status", "color"),
    Input("sm-render", "n_clicks"),
    State("sm-text", "value"),
    prevent_initial_call=True,
)
def render(_clicks, text):
    """Import, validate, render. Nothing is stored.

    The refusal message is the importer's own — it names the field and its path
    — rather than a sentence written for the page. That is why the invalid
    fixture is a committed file: a typed-out error message could say anything.
    """
    try:
        parsed = manifest.loads(text or "")
        data, notes, used = manifest.render(parsed)
    except manifest.ManifestError as exc:
        return no_update, no_update, f"Refused — {exc}", "yellow"

    name = parsed.get("name", "untitled")
    detail = f"{used} parts, {len(data) / 1024:.0f} KB"
    if notes:
        detail += "  ·  " + "; ".join(notes)
    return (
        manifest.sculptor.to_data_url(data),
        f"A sculpture rendered from a scene manifest: {name}",
        f"Rendered {name} — {detail}. No model was called.",
        "indigo",
    )


@callback(
    Output("sm-download-json", "data"),
    Input("sm-export", "n_clicks"),
    State("sm-text", "value"),
    prevent_initial_call=True,
)
def export_json(_clicks, text):
    """Export the NORMALISED manifest — byte-stable, so two exports of the same
    scene are identical and a diff shows only what you changed.

    The bytes are already in hand; there is no server-side path and nothing is
    written to disk.
    """
    try:
        parsed = manifest.loads(text or "")
    except manifest.ManifestError:
        return no_update
    return {
        "content": manifest.dumps(parsed),
        "filename": manifest.filename(parsed, "json"),
    }


@callback(
    Output("sm-download-glb", "data"),
    Input("sm-glb", "n_clicks"),
    State("sm-text", "value"),
    prevent_initial_call=True,
)
def download_glb(_clicks, text):
    """Hand over the rendered bytes.

    Built in memory and written straight into the response buffer: there is no
    server-side path, no store, no temp file and nothing to clean up. That is
    the same reasoning that keeps the viewer's own `src` a `data:` URL.
    """
    try:
        parsed = manifest.loads(text or "")
        data, _notes, _used = manifest.render(parsed)
    except manifest.ManifestError:
        return no_update
    return dcc.send_bytes(data, manifest.filename(parsed, "glb"))
```


*Load the invalid fixture* shows the importer's **own** refusal message against
a committed broken file, rather than an error message typed into this page.

---

`lib/glb.py` turns that into a real glTF. Every triangle is deterministic
Python: the same manifest produces **byte-identical** `.glb` output. That is
what makes the round trip below an identity rather than an approximation.

A generated sculpture is therefore not a one-off image you either keep or lose.
It is a short, readable document you can save, edit in a text editor, hand to
someone else, and render again for free.

---

### A manifest is sufficient on its own

**The renderer never needs the prompt.** Everything required to reproduce the
`.glb` is in `parts`; nothing in the manifest refers to a model, a key, or the
sentence that produced it.

That matters for the obvious reason — you can render one on a host with no API
key, which is what this site does — and for a less obvious one: it means the
format has no dependency on a provider's output staying stable.

Where the sentence *is* worth keeping, it goes in an optional `provenance`
object that the renderer reads **not at all**:

```json
"provenance": {
  "prompt": "a brutalist lighthouse at dusk",
  "model": "claude-opus-5",
  "usd": 0.0871,
  "generated": "2026-09-12"
}
```

Delete it and the sculpture is unchanged. Keep it and you know what a file cost
and where it came from.

---

### Units and frame, stated once

| | |
| :-- | :-- |
| Length | **metres** |
| Angles | **degrees**, not radians |
| Handedness | right-handed |
| Up | **+Y** |
| Away from the viewer | −Z |
| Ground | the sculpture stands on `y = 0` |
| Scene bound | nothing further than **5 m** from the origin; no single dimension over **4 m** |

`position` is the **centre** of a part. A 1.4 m cylinder standing on the ground
has `position.y = 0.7`, not `0` — which is the single most common thing to get
wrong by hand, because "put it on the ground" and "centre it at zero" sound like
the same instruction.

---

### Top level

| Field | Type | Required | Notes |
| :-- | :-- | :-- | :-- |
| `version` | integer | **yes, first key** | `1`. See **Versioning**. |
| `parts` | array | **yes** | The sculpture. `[]` is valid and renders nothing. |
| `name` | string | no | The viewer's `alt` text, and what the download filename is **derived from** — slugged, length-capped and given a fixed extension, never used raw. `"../../etc/passwd"` becomes `etc-passwd.glb`. |
| `notes` | string | no | One sentence about the idea. Nothing reads it. |
| `provenance` | object | no | Never read by the renderer. See above. |

**Unknown keys are rejected.** That is deliberate and it is the point of the
version number: if version 1 quietly ignored a key it did not know, a version 2
manifest using part groups would be *accepted* by a version 1 reader and render
without them — a sculpture missing pieces, with nothing said. Strictness is what
lets the version mean something.

---

### A part

Every field is required. Ranges are enforced, not advisory.

| Field | Type | Range | Notes |
| :-- | :-- | :-- | :-- |
| `name` | string | any | For your benefit. Not rendered. |
| `shape` | string | `box`, `sphere`, `cylinder`, `cone`, `torus`, `plane` | Anything else is refused by name. |
| `size` | `{x, y, z}` numbers | each `0.01`–`4` | Which components matter depends on the shape — see the table below. |
| `position` | `{x, y, z}` numbers | within 5 m of origin | The **centre** of the part. |
| `rotation` | `{x, y, z}` numbers | `-360`–`360` | Degrees. Applied X, then Y, then Z — see **Rotation order**. |
| `color` | string | `#RRGGBB` | Converted sRGB → linear into the glTF. |
| `metallic` | number | `0`–`1` | |
| `roughness` | number | `0.05`–`1` | `0` is a perfect mirror and reads as a black hole, so it is floored. |
| `emissive_strength` | number | `0`–`1` | Above `0` the part glows. `1` is the ceiling, not a soft one. |

And the whole scene: **at most 28 parts**, output **at most 3 MB**.

#### What `size` means, per shape

Only some components are read. The rest are ignored, which is worth knowing
before you spend time tuning one that does nothing.

| `shape` | `size.x` | `size.y` | `size.z` |
| :-- | :-- | :-- | :-- |
| `box` | width | height | depth |
| `sphere` | **diameter** | ignored | ignored |
| `cylinder` | **diameter** | height | ignored |
| `cone` | base **diameter** | height | ignored |
| `torus` | ring **diameter** | ignored | tube **diameter** |
| `plane` | width | ignored | depth |

**Diameter, not radius.** `lib/sculptor.py` passes `size.x / 2` to the
builders, so a sphere with `size.x = 0.4` is 0.4 m across. Reading it as a
radius gives you a model twice the size you meant, which is the kind of error
that looks like a units bug.

**The torus is the exception worth measuring.** Its overall width is
`size.x + size.z` — the ring diameter plus the tube diameter — because the tube
sticks out on both sides. A torus with `size.x = 3.0` and `size.z = 0.2` is
3.2 m across, which matters when the scene bound is 5 m. An earlier version of
this row called `size.x` the *outer* diameter, which was wrong by the tube.

---

### Rotation order

Degrees, applied **X first, then Y, then Z, about the fixed world axes** —
extrinsic XYZ, equivalently intrinsic Z-Y-X. The composed rotation is
`Rz · Ry · Rx`.

This is stated because it is not guessable and it is not reproducible without
it: the same three numbers in a different order give a different object. It was
measured from `lib/glb.py`'s `_euler_to_quat`, not assumed, and a test pins the
resulting quaternion so the order cannot change silently.

```
rotation {"x": 90, "y": 0, "z": 0}  ->  quaternion (0.7071, 0, 0, 0.7071)
rotation {"x": 0, "y": 90, "z": 0}  ->  quaternion (0, 0.7071, 0, 0.7071)
```

---

### What a refusal looks like

A manifest is validated before anything is built, and a refusal **names the
field**. "Invalid manifest" tells you nothing you can act on.

| What is wrong | What you are told |
| :-- | :-- |
| `"version": 2` | the version, and that this build reads 1 |
| `"shape": "dodecahedron"` | the part index and the unrecognised shape |
| `"size": {"x": 40, …}` | the field, the value, and the 4 m bound |
| 29 parts | the count and the limit |
| `"metallic": "shiny"` | the field and that a number was expected |
| an unknown key | the key **and its path** — `parts[3].colour`, `provenance.cost` |
| `"emissive_strength": 3.0` | the field, the value and the `0`–`1` range |

---

### Versioning

- **`"version"`**, an integer, the first key on export. This build reads
  **1 and 2**. A version it does not know is refused whole, with the number in
  the message, and is **not partially read** — a newer manifest may use shapes
  this build cannot draw, and rendering the parts it recognises would hand you
  a sculpture with pieces missing and say nothing.
- **A generated sculpture is stamped version 1**, not 2. A model answers a flat
  schema, so version 1 expresses its output exactly and stays readable by
  anything that only knows version 1. The version written is the lowest one
  that can express the sculpture, not the highest this build knows.
- **Version 1 did not change meaning**, which was the promise the number
  existed to make. The three samples above render byte-for-byte what they
  rendered before version 2 existed, and a test asserts it by SHA-256.

---

### Version 2: defining a thing once and placing it

Version 2 adds three entries, and they compose: a `defs` block that names
sub-assemblies, a `ref` that places one, and a `group` that gathers parts under
a shared transform.

| Entry | Keys | Means |
| :-- | :-- | :-- |
| a part | as version 1 | draw this here |
| **`ref`** | `ref`, `position`, *`rotation`*, *`name`* | place the named def here |
| **`group`** | `group`, `position`, `children`, *`rotation`* | move these together |
| **`defs.<name>`** | a part **without `position`**, or `{"children": [...]}` | a thing worth placing more than once |

*Italic keys are optional.*

**A def has no `position`, and that is the load-bearing rule.** A def describes
a *thing*; a `ref` says where a copy of it goes. Because a ref carries only a
name, a position and a rotation, it cannot restyle or resize what it places —
so every placement of a def provably shares **one mesh and one material**. That
is where the saving comes from, and it is a fact about the schema rather than
the result of comparing parts and hoping.

```json
{
  "version": 2,
  "defs": {
    "wheel": {
      "name": "wheel", "shape": "torus",
      "size": {"x": 0.5, "y": 0.5, "z": 0.12},
      "rotation": {"x": 90, "y": 0, "z": 0},
      "color": "#3B2F2A", "metallic": 0.1,
      "roughness": 0.85, "emissive_strength": 0.0
    }
  },
  "parts": [
    {"ref": "wheel", "position": {"x": -0.42, "y": 0.25, "z": -0.26}},
    {"ref": "wheel", "position": {"x": 0.42, "y": 0.25, "z": -0.26}}
  ]
}
```

Load **`cart.json`** in the round trip above to see it whole. Eight parts are
drawn from four entries; the file holds four meshes rather than eight, and it is
**69% smaller** than `cart-flat.json`, which is the same sculpture with every
part placed by hand. Both are bundled, and a test asserts they put every node in
exactly the same place — that is what makes them the same sculpture rather than
two similar ones.

| | `cart.json` | `cart-flat.json` |
| :-- | --: | --: |
| Entries written | 4 | 8 |
| Parts drawn | 8 | 8 |
| Meshes in the file | **4** | 8 |
| Size | **44 KB** | 143 KB |

**The limits count what is drawn.** A `ref` costs its def's leaf count every
time it is placed, so the 28-part ceiling applies *after* expansion — four refs
to a five-part assembly are twenty parts, because that is what the viewer
carries. Groups nest at most **4** deep and `defs` holds at most **8** entries;
a def that contains itself is refused by name rather than by running out of
stack.

**Rotations compose; they do not add.** A group turned about X holding a part
turned about Y is not the same as one part turned about both — Euler angles do
not add, and the manifest is expanded through quaternions so that the sculpture
you wrote is the sculpture you get.

**What version 2 does not change:** the exported `.glb` is still a flat scene —
shared meshes, one node per part, no parent nodes. Grouping is an authoring
convenience and a size saving, not something a consumer of the file sees. Making
the authored structure survive export is a separate change and is not in this
release.

---

### Export is byte-stable

Export writes sorted keys, fixed float precision and a trailing newline, so:

```
export(import(m)) == m          for every manifest this build accepts
import(export(scene))           renders byte-identical .glb output
```

Both are tests, not intentions. Byte-stability is also what makes two manifests
diffable — an unstable writer would show a diff on every export and hide the
one change you made.

---

### Importing obeys the upload rules

A pasted manifest is untrusted input from a visitor, so it takes the same path
as an image on [Texture Upload](/texture-upload), through the shared rulebook in
`lib/uploads.py`:

- **Size-capped**, with the cap stated on the page.
- **Validated against this schema** before anything is built.
- **Never written to disk.** No manifest store, no temp directory, no cleanup
  job — the same reasoning that keeps generated `.glb` output in a `data:` URL
  rather than a server-side store, and a test asserts neither exists.

A manifest cannot execute anything; it is primitives and numbers. Validation is
protecting you from a file that renders nothing and does not say why.

---

### Keeping what you made

Both generating pages — [Generative 3D](/generative-3d) and
[Sculpt from an Image](/sculpt-from-image) — offer two downloads once a build
finishes:

| Download | Why |
| :-- | :-- |
| **the manifest** | The valuable half. Re-import it here and the same sculpture renders for free; edit it first and it costs nothing either. It carries the prompt, model, cost and date in `provenance`. |
| **the `.glb`** | The object itself, for a game engine, a 3D print, or anywhere that reads glTF. |

The `.glb` is **rebuilt from the stored manifest on demand** rather than carried
around as bytes. `lib/glb.py` is deterministic, so it is the same file the
viewer is showing — and it keeps a megabyte of binary out of the browser.

Neither download touches the disk. The bytes are built in memory and handed to
the response: no server-side path, no store, no temp file and nothing to clean
up, which is the same reasoning that keeps the viewer's `src` a `data:` URL.

The filename is **derived** from `name` — slugged, capped and given a fixed
extension — so a model that names a sculpture `../../etc/passwd` produces
`etc-passwd.glb`.

---

### Where these samples came from

Three samples and one deliberately broken fixture, all committed as JSON in
`docs/scene-manifest/samples/`:

| File | Why it exists |
| :-- | :-- |
| `lighthouse.json` | The example at the top of this page, byte for byte. Two parts. |
| `colonnade.json` | **Exactly 28 parts** — the inclusive limit, proven here rather than only in a test. |
| `brazier.json` | `emissive_strength` at its ceiling of `1.0`. |
| `INVALID-fixture.json` | Refused on purpose. Named so nobody mistakes it for a sample. |

**They are hand-authored, not model output**, and that is a deliberate change
from the original plan. Two reasons: a model will not produce *exactly* 28 parts
on request, and the limit is the thing worth proving; and a hand-authored file
costs nothing to regenerate when the schema gains a version 2. Their
`provenance.model` says `hand-authored` rather than naming a model that did not
write them.

This site carries no provider keys — it is documentation and does no production
spend ([why](/generative-3d)) — so nothing on this page calls a model.

That is the useful half of the format rather than a limitation to apologise for:
**the round trip works with no key at all.** Import a manifest, render it,
download the `.glb`. The model was only ever the manifest's author, and it has
already done its part.


---

*Source: /scene-manifest*

---

<!-- /sculpt-from-image — https://modelviewer.2plot.dev/sculpt-from-image/llms.txt -->

# Sculpt from an Image

> Upload a photograph and a vision model composes a sculpture that evokes it — a generated scene in the spirit of the picture, not a reconstruction of it.

---



### What this does, and what it does not

Upload a picture. A vision model looks at it and composes a sculpture out of
the same six primitives the rest of this site uses, and you watch it assemble.

**It is an interpretation, not a reconstruction.** Nothing here measures your
photograph. There is no depth estimation, no photogrammetry, no mesh fitting
and no point cloud. The model reads the image for its masses, proportions and
palette, and writes a parts list; `lib/glb.py` builds the triangles.

So a photo of your car returns something car-shaped, in your car's colours,
with your car's stance — and it is **not your car**. If that is not what you
want, nothing on this page will get you there, and it is better to know that
before you spend a call on it.


    [Image to 3D](/image-to-3d) turns an image into geometry **without a model
    at all**: luminance becomes displacement and the result is a carved relief
    of your actual picture, pixel for pixel. It needs no API key, costs
    nothing, and is exact where this page is interpretive.

    Use that one when you want *your image, in relief*. Use this one when you
    want *a sculpture the image suggests*. They are different tools and the
    difference is not a matter of quality.

---

### Try it



```python
# File: docs/sculpt-from-image/sculpt_from_image.py

import json
import threading
from datetime import date

from dash import Input, Output, State, callback, dcc, html, no_update
import dash_mantine_components as dmc

import dash_model_viewer as dmv
from lib import (build_stream, manifest, model_picker, poll_guard, sculptor,
                 spend, texture, uploads)

#: 4 MB, the same cap /texture-upload states, from the same module. A vision
#: model resizes anything larger anyway, so a bigger allowance would buy a
#: slower upload and no more detail.
MAX_IMAGE_BYTES = 4 * 1024 * 1024

VIEWER_ATTRS = {
    "environment-image": "neutral",
    "exposure": "1.1",
    "shadow-softness": "0.7",
}

component = html.Div(
    [
        dcc.Store(id="si-image"),
        dcc.Store(id="si-run"),
        dcc.Store(id="si-manifest"),
        dcc.Download(id="si-dl-json"),
        dcc.Download(id="si-dl-glb"),
        # The bounded timer and its liveness store. Capped and stale-guarded
        # in one place — see lib/poll_guard.py for the 500-loop it ends.
        *poll_guard.components("si"),
        dmc.Group(
            model_picker.components("si", sculptor.MODEL, w=260),
            mb="xs",
        ),
        dmc.Text(id="si-model-status", size="xs", c="dimmed"),
        dmc.Text(id="si-estimate", size="xs", c="dimmed", mb="xs"),
        dmc.Group(
            [
                dcc.Upload(
                    id="si-upload",
                    accept="image/png,image/jpeg",
                    multiple=False,
                    children=dmc.Button("Choose an image (PNG or JPEG)"),
                ),
                dmc.TextInput(
                    id="si-hint",
                    placeholder="Optional: a nudge, e.g. 'emphasise the arches'",
                    style={"flex": 1},
                ),
                dmc.Button("Sculpt it", id="si-go", variant="filled", disabled=True),
            ],
            mb="xs",
            align="flex-end",
        ),
        dmc.Text(id="si-upload-status", size="sm", c="dimmed", mb="xs"),
        dmc.Grid(
            gutter="md",
            children=[
                dmc.GridCol(
                    dmc.Paper(
                        withBorder=True,
                        p="xs",
                        children=[
                            dmc.Text("Your image", size="xs", c="dimmed", mb=4),
                            html.Img(
                                id="si-preview",
                                style={"width": "100%", "borderRadius": "6px"},
                            ),
                        ],
                    ),
                    span={"base": 12, "md": 5},
                ),
                dmc.GridCol(
                    dmv.ModelViewer(
                        id="si-viewer",
                        src=sculptor.to_data_url(
                            sculptor.build(
                                {
                                    "parts": [
                                        {"shape": "box", "name": "seed",
                                         "size": {"x": 0.9, "y": 0.9, "z": 0.9},
                                         "position": {"x": 0, "y": 0.45, "z": 0},
                                         "rotation": {"x": 0, "y": 25, "z": 0},
                                         "color": "#4C6EF5", "metallic": 0.8,
                                         "roughness": 0.3, "emissive_strength": 0.0},
                                    ]
                                }
                            )[0]
                        ),
                        alt="A sculpture generated from an uploaded image",
                        camera_controls=True,
                        shadow_intensity=1,
                        interpolation_decay=90,
                        attributes=VIEWER_ATTRS,
                        style={"width": "100%", "height": "420px"},
                    ),
                    span={"base": 12, "md": 7},
                ),
            ],
        ),
        dmc.Text(id="si-working", size="sm", c="dimmed", mt="xs", display="none"),
        dmc.Alert(id="si-status", mt="sm", color="indigo", hide=True),
        dmc.Spoiler(
            id="si-spoiler",
            showLabel="Show the parts list",
            hideLabel="Hide",
            maxHeight=0,
            children=dmc.Code(id="si-json", block=True),
            mt="xs",
        ),
        dmc.Group(
            [
                dmc.Text("Texture", size="xs", c="dimmed"),
                dmc.SegmentedControl(
                    id="si-texture",
                    value="off",
                    size="xs",
                    data=[
                        {"label": "Off", "value": "off"},
                        {"label": "Preview", "value": "preview"},
                        {"label": "Include", "value": "include"},
                    ],
                ),
                dmc.Text(id="si-texture-note", size="xs", c="dimmed"),
            ],
            gap="xs", mt="sm", align="center",
        ),
        dmc.Group(
            [
                dmc.Button("Save the manifest", id="si-save-json",
                           variant="light", size="xs", disabled=True),
                dmc.Button("Download .glb", id="si-save-glb",
                           variant="light", size="xs", disabled=True),
            ],
            gap="xs", mt="xs",
        ),
    ]
)


@callback(
    Output("si-image", "data"),
    Output("si-upload-status", "children"),
    Output("si-preview", "src"),
    Output("si-go", "disabled"),
    Input("si-upload", "contents"),
    State("si-upload", "filename"),
    prevent_initial_call=True,
)
def accept_image(contents, filename):
    """Validate, preview, and enable the button. Nothing reaches disk."""
    raw, _media_type, message = uploads.decode_image(
        contents, filename, max_bytes=MAX_IMAGE_BYTES
    )
    if raw is None:
        return None, message, no_update, True
    return contents, f"{message} — ready.", contents, False


@callback(
    Output("si-run", "data"),
    Output("si-poll", "disabled"),
    Output("si-status", "hide"),
    Output("si-working", "display"),
    Output("si-go", "loading"),
    Output("si-poll", "n_intervals"),
    Output("si-alive", "data"),
    Input("si-go", "n_clicks"),
    State("si-image", "data"),
    State("si-hint", "value"),
    State("si-model", "value"),
    prevent_initial_call=True,
)
def start(_, image, hint, model):
    if not image:
        return (no_update,) * 7
    run_id = build_stream.new_run()
    threading.Thread(
        target=sculptor.sculpt_image_streaming,
        args=(run_id, image, hint or ""),
        kwargs={"model": model or sculptor.MODEL},
        daemon=True,
    ).start()
    # n_intervals back to 0 re-arms the capped Interval (the gate is
    # `n_intervals >= max_intervals`, re-evaluated on update), so the ceiling
    # bounds THIS build rather than the tab's whole lifetime.
    return run_id, False, True, "block", True, 0, poll_guard.tick_value(0)


@callback(
    Output("si-viewer", "src"),
    Output("si-viewer", "alt"),
    Output("si-status", "children"),
    Output("si-status", "color"),
    Output("si-status", "hide", allow_duplicate=True),
    Output("si-json", "children"),
    Output("si-working", "children"),
    Output("si-poll", "disabled", allow_duplicate=True),
    Output("si-working", "display", allow_duplicate=True),
    Output("si-go", "loading", allow_duplicate=True),
    Output("si-manifest", "data"),
    Output("si-save-json", "disabled"),
    Output("si-save-glb", "disabled"),
    Output("si-alive", "data", allow_duplicate=True),
    Input("si-poll", "n_intervals"),
    State("si-run", "data"),
    State("si-model", "value"),
    State("si-hint", "value"),
    prevent_initial_call=True,
)
def poll(tick, run_id, model, hint):
    """Same seam, same collector as /generative-3d — see lib/build_stream.py.

    EVERY return path advances `si-alive`, including the ones that report no
    progress: it records that the SERVER ANSWERED, not that something
    changed. A store advanced only when a part arrives would trip the stale
    guard during any build whose first model call runs long.
    """
    alive = poll_guard.tick_value(tick)
    idle = (no_update,) * 13 + (alive,)
    if not run_id:
        return idle

    state = build_stream.take(run_id)
    latest_src = no_update
    progress = no_update
    final = None
    for event in state["events"]:
        phase = event.get("phase")
        if phase == "part":
            latest_src = event["data_url"]
            progress = f"Building — part {event['index']} of {event['total']}"
        elif phase == "assembling":
            progress = (
                f"Read the image in {event.get('seconds', 0)}s — "
                f"assembling {event['total']} parts"
            )
        elif phase == "done":
            final = event

    if final is not None:
        # NOT named `manifest` — see the note in /generative-3d's poll.
        scene = final.get("manifest") or {}
        note = f"{scene.get('name', 'Untitled')} — {scene.get('notes', '')}"
        if final.get("notes"):
            note += "  ·  " + "; ".join(final["notes"])
        note += (
            f"  ·  {final.get('part_count', 0)} parts  ·  "
            + spend.actual_line(final.get("usd", 0.0), final.get("seconds", 0.0))
        )
        return (
            final.get("data_url") or latest_src,
            f"A sculpture evoking the uploaded image: {scene.get('name', 'untitled')}",
            note, "indigo", False,
            json.dumps(scene, indent=2),
            "", True, "none", False,
            manifest.from_scene(scene, {
                "prompt": hint or "(no hint — the image alone)",
                "model": model,
                "usd": final.get("usd", 0.0),
                "generated": date.today().isoformat(),
            }), False, False, alive,
        )

    if state["done"]:
        return (
            no_update, no_update,
            state["reason"] or "The sculpt did not complete.",
            "yellow", False, no_update,
            "", True, "none", False,
            no_update, no_update, no_update, alive,
        )

    return (
        latest_src, no_update, no_update, no_update, no_update, no_update,
        progress, no_update, no_update, no_update,
        no_update, no_update, no_update, alive,
    )


@callback(
    Output("si-estimate", "children"),
    Input("si-model", "value"),
)
def show_estimate(model):
    """Priced BEFORE the button. A vision call is not free and the visitor is
    spending the owner's credits, so the number belongs where the decision is."""
    return spend.estimate_line(model or sculptor.MODEL, sculptor.MAX_TOKENS)


model_picker.register("si", action_ids=["si-go"])
poll_guard.register("si", "si-status", resets=[
    ("si-working", "display", "none"),
    ("si-go", "loading", False),
])


#: What each switch position means, in one place, so the note under the
#: control, the button label and the download cannot disagree about it.
TEXTURE_MODES = ("off", "preview", "include")


def texture_png(image_contents):
    """The uploaded image, decoded and downscaled for baking — or None.

    Reuses `lib/uploads.py`'s rules rather than a second set: this is the same
    upload the vision model read, and two answers to "is this acceptable" is
    how one page silently gets a different cap from the other.
    """
    raw, _media_type, _message = uploads.decode_image(
        image_contents, max_bytes=MAX_IMAGE_BYTES
    )
    if raw is None:
        return None
    try:
        return texture.prepare(raw)
    except Exception:                                     # noqa: BLE001
        # Pillow raises a family of its own errors for a truncated or hostile
        # raster. A texture that cannot be prepared must not take the page
        # down — the sculpture is still there to look at untextured.
        return None


def _note(mode, has_image, has_manifest):
    if mode == "off":
        return "The sculpture keeps its generated colours."
    if not has_manifest:
        return "Sculpt something first."
    if not has_image:
        return "Upload an image to drape it."
    if mode == "preview":
        return "Draped on screen only — the .glb downloads untextured."
    return "Draped, and baked into the .glb you download."


@callback(
    Output("si-viewer", "src", allow_duplicate=True),
    Output("si-texture-note", "children"),
    Output("si-save-glb", "children"),
    Input("si-texture", "value"),
    Input("si-manifest", "data"),
    State("si-image", "data"),
    prevent_initial_call=True,
)
def retexture(mode, stored, image):
    """Re-render the stored sculpture at the chosen texture setting.

    NO MODEL IS CALLED. The manifest is already in hand and `lib/glb.py` is
    deterministic, so switching costs a rebuild and nothing else — which is the
    reason the switch can exist at all, and the reason it is a switch rather
    than a checkbox you set before paying for a sculpt.

    `si-manifest` is an Input, not a State, so a fresh sculpt comes out at
    whatever setting is currently chosen instead of silently reverting to Off.
    """
    mode = mode if mode in TEXTURE_MODES else "off"
    label = "Download .glb (untextured)" if mode == "preview" else "Download .glb"
    if not stored:
        return no_update, _note(mode, bool(image), False), label

    png = texture_png(image) if mode in ("preview", "include") else None
    try:
        data, _notes, _used = manifest.render(stored, texture_png=png)
    except (manifest.ManifestError, ValueError) as exc:
        # NEVER SILENTLY. This branch used to return the ordinary note, so a
        # refused render left the previous sculpture on screen under the words
        # "Draped on screen only" — the page claiming something it had not
        # done. The importer's message names the field and its bound, so it is
        # shown verbatim rather than summarised away.
        return no_update, f"Could not render this sculpture — {exc}", label
    if mode in ("preview", "include") and image and png is None:
        # An image WAS uploaded and could not be prepared. "Upload an image"
        # would send the reader to do the thing they already did.
        return (sculptor.to_data_url(data),
                "That image could not be read, so nothing was draped — "
                "PNG or JPEG, under the size cap.",
                label)
    return (sculptor.to_data_url(data),
            _note(mode, png is not None, True),
            label)


@callback(
    Output("si-dl-json", "data"),
    Output("si-status", "children", allow_duplicate=True),
    Output("si-status", "hide", allow_duplicate=True),
    Input("si-save-json", "n_clicks"),
    State("si-manifest", "data"),
    prevent_initial_call=True,
)
def save_manifest(_clicks, stored):
    """The manifest is the valuable half.

    Re-importing it on [Scene Manifest](/scene-manifest) re-renders the same
    sculpture for free, and editing it costs nothing — which is the whole point
    of keeping it rather than only the `.glb`.
    """
    if not stored:
        return no_update, no_update, no_update
    try:
        m = manifest.validate(stored)
    except manifest.ManifestError as exc:
        # A button that does nothing is the worst available report. The
        # importer's message names the field; show it.
        return no_update, f"Cannot save this manifest — {exc}", False
    return ({"content": manifest.dumps(m),
             "filename": manifest.filename(m, "json")},
            no_update, no_update)


@callback(
    Output("si-dl-glb", "data"),
    Output("si-status", "children", allow_duplicate=True),
    Output("si-status", "hide", allow_duplicate=True),
    Input("si-save-glb", "n_clicks"),
    State("si-manifest", "data"),
    State("si-texture", "value"),
    State("si-image", "data"),
    prevent_initial_call=True,
)
def save_glb(_clicks, stored, mode, image):
    """Rebuilt from the stored manifest on demand, not carried as bytes.

    `lib/glb.py` is deterministic, so this is the same file the viewer is
    showing — and it keeps a megabyte of binary out of the browser's store.
    Nothing is written to disk at any point.

    ONLY `include` BAKES. Under `preview` the download is deliberately the
    plain sculpture, and the button says so, because a file that quietly
    differs from what is on screen is worse than one that plainly does not
    match. `off` and `preview` therefore produce the same bytes as before the
    feature existed.

    Two callbacks rather than one dispatching on `ctx.triggered_id`: a callback
    that reads the context cannot be called from a test, and these two are
    worth testing.
    """
    if not stored:
        return no_update, no_update, no_update
    png = texture_png(image) if mode == "include" else None
    try:
        m = manifest.validate(stored)
        data, _notes, _used = manifest.render(m, texture_png=png)
    except (manifest.ManifestError, ValueError) as exc:
        return no_update, f"Cannot build this .glb — {exc}", False
    stem = manifest.filename(m, "glb")
    if png is not None:
        stem = stem[:-4] + "-textured.glb"
    return dcc.send_bytes(data, stem), no_update, no_update
```


---

### What happens to your upload

| Rule | Value |
| :-- | :-- |
| Accepted types | PNG, JPEG |
| Size cap | 4 MB decoded |
| Written to disk | Never |
| Retained after the tab closes | No |
| Sent to a third party | **Yes — to the model provider, to be looked at** |

That last row is the one that differs from
[Texture Upload](/texture-upload), and it is the reason it is in the table
rather than in a footnote. Painting an image onto a model happens entirely in
your browser. Sculpting *from* an image cannot: the picture is sent to
Anthropic or OpenAI, because looking at it is the entire feature.

The rules themselves live in `lib/uploads.py` and are shared with the texture
page, so the two cannot drift into stating different caps.

---

### Wearing the picture: the texture switch

The sculpture reads the image as *masses, proportions and palette*. The switch
under the viewer does the other thing — it lays the photograph itself over the
form, as a slide projector would.

| Setting | On screen | What the `.glb` button gives you |
| :-- | :-- | :-- |
| **Off** | the generated colours | the plain sculpture |
| **Preview** | draped | **the plain sculpture** — the button says so |
| **Include** | draped | the image baked in, as `…-textured.glb` |

Preview exists so that trying the texture costs nothing and commits to nothing.
A file that quietly differed from what was on screen would be worse than one
that plainly does not match, so the button relabels itself rather than
guessing.

**What draping changes, exactly.** glTF multiplies a material's base colour
into its texture, so a part keeping its generated colour would stain its patch
of the photo — a brown crate would tint that region brown. Draped parts
therefore take a white base colour and the picture shows true. **Nothing else
about the surface changes**: metallic, roughness and emissive stay per part, so
a glowing part still glows through the texture. Switching back to Off restores
the palette exactly — the untextured file is byte-for-byte what it was before
the feature existed.

**One picture, projected once.** The UVs are computed from *world* position
after the parts are placed, across the model's front-facing rectangle, so the
parts together carry one image rather than each wearing its own copy. The
alternative — the unit square on every primitive — is what makes a
twenty-eight-part sculpture look like twenty-eight small photographs. It also
means the projection does not care what the parts are or how they got there.

The image is embedded **once** and shared by every part. That is what keeps it
affordable: a realistic photograph at the 640 px cap is around 800 KB, and
embedding it per part would put a 28-part sculpture near 23 MB against a 3 MB
ceiling.

**The manifest never mentions the texture.** Draping happens when the sculpture
is rendered, not when it is described, so a saved manifest says the same thing
either way and stays a stable, diffable record. It also means this switch works
on a host with **no API key at all** — load a sample on
[Scene Manifest](/scene-manifest), upload a picture here, and it bakes.

---

### It is the same pipeline as the text sculptor

Everything downstream of the model is identical to
[Generative 3D](/generative-3d): the same JSON schema, the same clamps on size
and part count, the same sRGB-to-linear colour conversion, the same glTF
writer, the same `data:` URL delivery with no upload store.

The only difference is the input. Where that page sends a sentence, this one
sends a picture — and one extra paragraph of system prompt telling the model
to read masses, proportions and palette, and explicitly **not** to attempt
text, faces or fine surface detail. Those are what make an interpretation look
like a failed copy rather than a deliberate one.

Because the pipeline is shared, so is the spend gate: one call per sculpt,
metered against the same hourly ceiling as every other generating page on this
site. Streaming the assembly adds no model calls — the picture is read once.

---

### Watching it build

The build runs on a background thread and the viewer is re-pointed at a
complete `.glb` after each part, so the sculpture appears piece by piece. That
is the same `lib/build_stream.py` seam `/generative-3d` uses; see
[Generative 3D](/generative-3d#it-takes-about-35-seconds-and-it-has-to-say-so)
for why the progress store is a file rather than a dict, and why streaming per
part must never become charging per part.

---

### Choosing the model

The dropdown offers the Claude models, plus the GPT models when
`CHATGPT_API_KEY` is set on the host. The line under it always says which you
are getting and why — an absent model needs explaining, or it reads as a broken
page.

Two things decide what appears:

- **Discovery.** The GPT list is fetched from `GET /v1/models` on this host's
  own key at boot, not written into the source. A model id that has been
  retired would otherwise be an outage the first time somebody selected it.
- **Pricing.** A model is offered only if this build can also *price* it.
  `lib/spend.py` prices an unknown model at `$0.00`, so an unpriced model would
  pass the budget ceiling as though it were free rather than failing against
  it. Discovery decides what exists; pricing decides what is safe to meter.

The list is filled when the page is **viewed**, not when it is imported. Page
modules are imported while Dash registers pages — before `run.py` warms
discovery — so a layout that read the list at import froze the Claude-only set
permanently, whatever key was set. See `lib/model_picker.py`.

---

### What it costs, before and after

The estimate sits under the model picker and re-prices itself when you change
the model — choosing Opus over Haiku is choosing a 5x bill, and the only moment
that is useful is *before* the click. It also shows what is left of this shared
host's hourly ceiling.

That figure is an **upper bound, not a guess**: `lib/spend.py` prices every
call as though it used its whole output budget. The number reported after the
run is measured from the provider's own token counts and is nearly always
lower.

The result line then says what the run *actually* cost, to four decimal places
— two would render most sculpts as `$0.00`, which reads as free rather than as
cheap.

---

### This site runs without provider keys

The AI demos on this page are **off on modelviewer.2plot.dev**, deliberately.
This is a documentation site; it carries no provider keys and does no
production spend (owner's decision, 2026-09-12).

So on the public site you will see the model picker empty, the generate
control disabled, and a line saying so. **That is the expected state, not a
fault** — please do not file it.

To try it, run the site locally with your own keys:

```bash
git clone https://github.com/pip-install-python/dash-model-viewer
cd dash-model-viewer
printf 'ANTHROPIC_API_KEY=sk-ant-...\nCHATGPT_API_KEY=sk-...\n' > .env
pip install -r requirements.txt && python run.py
```

Either key alone is enough — the picker offers whichever provider it finds.
`lib/spend.py`'s ceiling then applies locally: a rolling call limit and a
cumulative dollar estimate, so an accident costs a few cents rather than a
weekend.

Everything on this page that does **not** need a key still works and is worth
reading for it: the JSON schema, the clamps, the sRGB-to-linear conversion and
the dependency-free glTF writer in `lib/glb.py` are all plain Python.


---

*Source: /sculpt-from-image*

---

<!-- /slots-and-hotspots — https://modelviewer.2plot.dev/slots-and-hotspots/llms.txt -->

# Slots and Hotspots

> Anchor any Dash component to a point on the model — and why hotspots used to be a list of dictionaries.

---



### Anchored labels

A hotspot is a `Slot` whose name begins with `hotspot-` and which carries a
`position` in model space. `<model-viewer>` projects it onto the model, keeps
it attached as the camera moves, and hides it when the geometry occludes it.



```python
# File: docs/slots-and-hotspots/hotspots.py

from dash import Input, Output, callback, html
import dash_mantine_components as dmc

import dash_model_viewer as dmv
from lib.demo_models import ASTRONAUT

component = html.Div(
    [
        dmv.ModelViewer(
            id="sh-viewer",
            src=ASTRONAUT,
            alt="An astronaut with labelled hotspots on the visor and backpack",
            camera_orbit="15deg 80deg 3.5m",
            camera_target="0m 1.1m 0m",
            style={"width": "100%", "height": "420px"},
            children=[
                dmv.Slot(
                    id="sh-visor",
                    slot="hotspot-visor",
                    position="0 1.75 0.35",
                    normal="0 0 1",
                    children=dmc.Badge("Visor", color="indigo", variant="filled"),
                ),
                dmv.Slot(
                    id="sh-pack",
                    slot="hotspot-pack",
                    position="0 1.4 -0.35",
                    normal="0 0 -1",
                    children=dmc.Badge("Life support", color="teal", variant="filled"),
                ),
            ],
        ),
        dmc.Text("Click a badge on the model.", id="sh-readout", size="sm", mt="sm"),
    ]
)


@callback(
    Output("sh-readout", "children"),
    Input("sh-visor", "n_clicks"),
    Input("sh-pack", "n_clicks"),
)
def describe(visor, pack):
    return f"Visor clicked {visor or 0}x · Life support clicked {pack or 0}x"
```


The children are a real `dmc.Badge`. Not a string, not a class name, not a
config dict — an actual Dash component, with all of its props.

---

### Why this was a list of dictionaries

In 0.0.1 hotspots looked like this:

```python
DashModelViewer(
    hotspots=[
        {"slot": "hotspot-visor", "position": "0 1.75 0.35",
         "text": "Visor", "children_classname": "label"},
    ],
)
```

Content was limited to a `text` string and a CSS class. That was not a design
choice — it was a workaround.

`<model-viewer>` places its extras into named shadow-DOM slots, which requires
a child element carrying a `slot` attribute. **`dash.html.Div` has no `slot`
prop**, and neither does any other core Dash component. There was no way to put
an arbitrary Dash component into a slot, so the component rendered its own
plain `<div>`s from a serialisable description of them, and the description was
the only thing a user could reach.

`Slot` is a component that *does* have `slot`. Everything downstream follows —
arbitrary children, real callbacks, `n_clicks`.

---

### Every slot, not just hotspots

`Slot` is the general mechanism. The AR button, the AR prompt, the failure
message, the poster and the progress bar are all just named slots, so the three
separate props 0.0.1 had for them (`arButtonText`, `customArPrompt`,
`customArFailure`) collapse into one component:

```python
dmv.ModelViewer(
    id="viewer", src=..., alt=...,
    children=[
        dmv.Slot(slot="ar-button",  children=dmc.Button("View in your space")),
        dmv.Slot(slot="ar-prompt",  children=dmc.Loader(size="sm")),
        dmv.Slot(slot="ar-failure", children=dmc.Alert("AR lost tracking", color="red")),
        dmv.Slot(slot="poster",     children=html.Img(src="/assets/poster.webp")),
        dmv.Slot(slot="progress-bar", children=html.Div(className="my-bar")),
    ],
)
```

The full list of slot names is in
[model-viewer's own documentation](https://modelviewer.dev/docs/#loading-slots).

---

### `n_clicks` is the point

Each `Slot` has its own `n_clicks`, so a hotspot is a callback `Input` like any
button:

```python
@callback(Output("viewer", "camera_orbit"), Input("sh-visor", "n_clicks"))
def look_at_visor(_):
    return "0deg 70deg 1.2m"
```

For many hotspots, use pattern-matching ids — see
[Camera and views](/camera-and-views), which drives four presets from one
callback.

---

### Styling

Slots render with the class `dmv-slot`, plus anything you pass as `class_name`.
They are ordinary DOM, so ordinary CSS reaches them:

```css
.dmv-slot {
    --min-hotspot-opacity: 0;      /* fade out when occluded */
}

.dmv-slot[slot^="hotspot-"] {
    background: rgba(0, 0, 0, .65);
    border-radius: 999px;
    padding: 4px 10px;
    color: white;
    cursor: pointer;
}
```


    `position="0 1.75 0.35"` is metres in the model's own coordinate system, so
    the numbers that work for one model are meaningless for another. To place
    hotspots on a model you have not measured, set `pick_on_click=True` and read
    the coordinates back from `scene_point` — see
    [Events and callbacks](/events-and-callbacks).


---

*Source: /slots-and-hotspots*

---

<!-- /texture-upload — https://modelviewer.2plot.dev/texture-upload/llms.txt -->

# Texture Upload

> Upload a PNG or JPEG and have it replace the model's base-colour texture in the 3D scene, not as a flat overlay.

---



### Painting an upload onto the model

Upload an image and it becomes the model's surface — wrapped onto the
geometry, lit by the scene, and still there when you orbit. Not a poster and
not a 2D preview beside the viewer.



```python
# File: docs/texture-upload/texture_upload.py

from dash import Input, Output, clientside_callback, callback, dcc, html
import dash_mantine_components as dmc

import dash_model_viewer as dmv
from lib import uploads
from lib.demo_models import ASTRONAUT

#: 4 MiB of decoded image. A base-colour texture larger than this is a slow
#: upload for a demo and, past roughly 4096x4096, more detail than the GPU will
#: show on a 420px viewer anyway. Stated here, stated on the page, and pinned
#: by tests/test_texture_upload.py — a cap that is documented and not enforced
#: is the shape of defect this package spent 1.0.0 removing.
MAX_TEXTURE_BYTES = 4 * 1024 * 1024

#: Raster formats `model-viewer`'s `createTexture` accepts and a browser will
#: decode without a plugin. SVG is deliberately absent: it is a scriptable
#: document, not an image, and this one is handed straight to the DOM.
ACCEPTED_TYPES = uploads.IMAGE_TYPES


def validate_texture(contents, filename=None):
    """Check a ``dcc.Upload`` value. Returns ``(data_url_or_None, message)``.

    The rules themselves live in ``lib/uploads.py``, shared with
    /sculpt-from-image: two pages taking an image from a visitor need the same
    answers about type, size and what happens to the bytes, and two copies of
    those answers is two places for them to drift.

    Pure, so it can be tested without a browser or a running app. The bytes are
    decoded only to MEASURE them — nothing here writes to disk, and the file
    never leaves the request that carried it.
    """
    raw, _media_type, message = uploads.decode_image(
        contents, filename, max_bytes=MAX_TEXTURE_BYTES, accepted=ACCEPTED_TYPES
    )
    if raw is None:
        return None, message
    return contents, f"{message} — applied below."


component = html.Div(
    [
        dcc.Store(id="tx-store"),
        dmc.Group(
            [
                dcc.Upload(
                    id="tx-upload",
                    accept="image/png,image/jpeg",
                    multiple=False,
                    children=dmc.Button("Upload a texture (PNG or JPEG)"),
                ),
                dmc.Text(id="tx-status", size="sm", c="dimmed"),
            ],
            mb="sm",
        ),
        dmv.ModelViewer(
            id="tx-viewer",
            src=ASTRONAUT,
            alt="An astronaut whose base-colour texture is replaced by an uploaded image",
            camera_controls=True,
            shadow_intensity=1,
            style={"width": "100%", "height": "420px"},
        ),
        dmc.Text(id="tx-applied", size="sm", c="dimmed", mt="xs"),
    ]
)


@callback(
    Output("tx-store", "data"),
    Output("tx-status", "children"),
    Input("tx-upload", "contents"),
    Input("tx-upload", "filename"),
    prevent_initial_call=True,
)
def accept_upload(contents, filename):
    return validate_texture(contents, filename)


# Swapping a material's texture is an imperative call on the element — there is
# no prop for it, and 1.0.0 deliberately ships no imperative surface. So this
# is one of the few places the escape hatch is a clientside callback rather
# than `attributes` / `mv_*`.
#
# It reports back how many materials it touched, because the failure that
# matters is silent: a material with no base-colour texture slot has nothing
# to swap, and the upload would otherwise appear to do nothing at all.
clientside_callback(
    """
    async function (dataUrl) {
        if (!dataUrl) { return window.dash_clientside.no_update; }
        const viewer = document.getElementById('tx-viewer');
        if (!viewer || !viewer.model) {
            return 'The model is still loading — try again in a moment.';
        }
        try {
            const texture = await viewer.createTexture(dataUrl);
            const materials = viewer.model.materials || [];
            let applied = 0;
            materials.forEach(function (material) {
                const slot = material.pbrMetallicRoughness.baseColorTexture;
                if (slot) { slot.setTexture(texture); applied += 1; }
            });
            if (!applied) {
                return 'This model has no base-colour texture slot to replace.';
            }
            return 'Applied to ' + applied + ' of ' + materials.length + ' materials.';
        } catch (err) {
            console.error('texture upload failed', err);
            return 'The browser could not decode that image.';
        }
    }
    """,
    Output("tx-applied", "children"),
    Input("tx-store", "data"),
    prevent_initial_call=True,
)
```


---

### What happens to your upload

Worth stating plainly, because "upload" usually means "we keep it":

- The image travels with the callback request and is **never written to
  disk**. It is decoded once, in memory, only to measure its size.
- Nothing stores it. It lives in a `dcc.Store` in **your own browser** and is
  gone when you close the tab.
- It is not sent to any third party, and no AI model sees it. This page makes
  no network call of its own.

| Rule | Value |
| :-- | :-- |
| Accepted types | PNG, JPEG |
| Size cap | 4 MB decoded |
| Written to disk | Never |
| Retained after the tab closes | No |

SVG is rejected on purpose. It is a scriptable document rather than an image,
and this one is handed straight to the DOM.

---

### Why this one needs a clientside callback

Most of this site exists to show that `<model-viewer>` does **not** need
hand-written JavaScript any more — the camera, load state, dimensions, AR
status and hotspot clicks are all ordinary Dash props now.

Swapping a material's texture is the exception, and the reason is worth
knowing. `createTexture()` and `setTexture()` are **imperative calls on the
element**, and 1.0.0 deliberately ships no imperative surface — no `play()`,
no `pause()`, no programmatic `activateAR()`. There is no prop to set, so this
is one of the few places where the escape hatch is JavaScript rather than
`attributes` or `mv_*`.

The validation is not in that JavaScript. Type and size are checked in Python,
in a plain function with no Dash imports, so the rules are testable without a
browser:

```python
validate_texture(contents, filename) -> (data_url_or_None, message)
```

---

### The failure that would otherwise be silent

A material only accepts a base-colour texture if it already **has** one. Feed
this page a model whose materials carry no `baseColorTexture` slot and the
upload succeeds, the texture is created, and absolutely nothing changes on
screen.

So the clientside half counts what it touched and says so —
`Applied to 2 of 3 materials` — rather than returning quietly. An upload that
appears to do nothing is indistinguishable from a broken page, and this page
would have been the second kind of bug the component review spent its time
on: a feature that is wired, documented, and unobservable.


---

*Source: /texture-upload*
