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 produces a model, the model does not produce geometry. It produces this:
{
"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.
# 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:
"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.
{
"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, 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 and Sculpt from an 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) — 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
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:
- /scene-manifest/llms.txt — LLM-friendly documentation
- /sitemap.xml
- /robots.txt