# 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.

**Site index:** [https://modelviewer.2plot.dev/llms.txt](https://modelviewer.2plot.dev/llms.txt) — every page on this site, as Markdown.  
**Network index:** [https://2plot.dev/llms.txt](https://2plot.dev/llms.txt) — The 2plot network; start here to discover sibling sites.  
**Sibling sites:** 13 more in The 2plot network — listed in the site index above.  
**Sitemap:** https://modelviewer.2plot.dev/sitemap.xml  


---



### 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*
