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

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


---



### The manifest *is* the sculpture

When [Generative 3D](/generative-3d) produces a model, the model does not
produce geometry. It produces this:

```json
{
  "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.



```python
# 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**:

```json
"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.

```json
{
  "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](/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](/generative-3d) and
[Sculpt from an Image](/sculpt-from-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](/generative-3d)) — 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*
