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

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.

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

BytesMeaningChecked
0–3the magic number glTFit is that, or it is not a .glb
4–7the format versionmust be 2
8–11the total file lengthmust 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 and Sculpt from an Image — one place, so the three pages cannot drift into stating different caps.

RuleValue
Acceptedbinary glTF (.glb), version 2
Size cap32 MB
Written to diskNever
Sent to a third partyNever

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: if a file reports clips here, that page's controls will drive them. Textures answers the question 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 is KHR_materials_variants, and it appears in this row when a file carries it.


Source: /model-upload

Note for AI agents: This is the static, prerendered view of an interactive Dash application served because we detected a non-JS user agent. Full prose docs: