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.

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

Lengthmetres
Anglesdegrees, not radians
Handednessright-handed
Up+Y
Away from the viewer−Z
Groundthe sculpture stands on y = 0
Scene boundnothing 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

FieldTypeRequiredNotes
versionintegeryes, first key1. See Versioning.
partsarrayyesThe sculpture. [] is valid and renders nothing.
namestringnoThe 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.
notesstringnoOne sentence about the idea. Nothing reads it.
provenanceobjectnoNever 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.

FieldTypeRangeNotes
namestringanyFor your benefit. Not rendered.
shapestringbox, sphere, cylinder, cone, torus, planeAnything else is refused by name.
size{x, y, z} numberseach 0.014Which components matter depends on the shape — see the table below.
position{x, y, z} numberswithin 5 m of originThe centre of the part.
rotation{x, y, z} numbers-360360Degrees. Applied X, then Y, then Z — see Rotation order.
colorstring#RRGGBBConverted sRGB → linear into the glTF.
metallicnumber01
roughnessnumber0.0510 is a perfect mirror and reads as a black hole, so it is floored.
emissive_strengthnumber01Above 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.

shapesize.xsize.ysize.z
boxwidthheightdepth
spherediameterignoredignored
cylinderdiameterheightignored
conebase diameterheightignored
torusring diameterignoredtube diameter
planewidthignoreddepth

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 wrongWhat you are told
"version": 2the 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 partsthe count and the limit
"metallic": "shiny"the field and that a number was expected
an unknown keythe key and its pathparts[3].colour, provenance.cost
"emissive_strength": 3.0the field, the value and the 01 range

Versioning

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.

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.

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.

EntryKeysMeans
a partas version 1draw this here
refref, position, rotation, nameplace the named def here
groupgroup, position, children, rotationmove 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.jsoncart-flat.json
Entries written48
Parts drawn88
Meshes in the file48
Size44 KB143 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:

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:

DownloadWhy
the manifestThe 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 .glbThe 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/:

FileWhy it exists
lighthouse.jsonThe example at the top of this page, byte for byte. Two parts.
colonnade.jsonExactly 28 parts — the inclusive limit, proven here rather than only in a test.
brazier.jsonemissive_strength at its ceiling of 1.0.
INVALID-fixture.jsonRefused 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: