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:
| 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 and Sculpt from an 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: 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:
- /model-upload/llms.txt — LLM-friendly documentation
- /sitemap.xml
- /robots.txt