# Model Upload

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

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


---



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