Describe a sculpture and get a real .glb — with Claude as a scene compiler, not a mesh generator, and every triangle built by deterministic Python.

Generative 3D Art

Describe a sculpture and get a real .glb — with Claude as a scene compiler, not a mesh generator, and every triangle built by deterministic Python.


The bet

Ask a language model for a mesh and you get plausible nonsense — vertex lists it cannot see, winding orders it cannot check, normals it cannot verify. There is no feedback loop, so there is no way for it to be right except by accident.

Ask it "what shape is a lighthouse" and you get a tall weathered cylinder, a red cone on top, a small glowing sphere inside, a dark ring around the gallery — which is a thing code can build exactly.

So the model never emits geometry. It emits a parts list, and lib/glb.py — a dependency-free glTF 2.0 writer — turns that into a real .glb. The model supplies judgement; Python supplies triangles.

That split is why this is cheap, inspectable, reproducible, and needs no third-party 3D service at all.


Try it

# File: docs/generative-3d/sculptor.py

import json
import threading
from datetime import date

from dash import ALL, Input, Output, State, callback, ctx, dcc, html, no_update
import dash_mantine_components as dmc

import dash_model_viewer as dmv
from lib import (build_stream, manifest, model_picker, poll_guard, sculptor,
                 spend)

IDEAS = [
    "a brutalist lighthouse at dusk, weathered concrete and one warm light",
    "a desert observatory, sandstone and brass, dish pointed at the sky",
    "a bonsai on a stone plinth, copper pot, moss",
    "a cathedral of stacked glass cubes lit from inside",
]

# A neutral studio environment plus a real shadow — generated art looks flat
# and grey without image-based lighting, which reads as "broken" rather than
# "dark". This is the one place the demo needs opinionated defaults.
VIEWER_ATTRS = {
    "environment-image": "neutral",
    "exposure": "1.1",
    "shadow-softness": "0.7",
}

component = html.Div(
    [
        # The run id, and the timer that reads it. `dcc.Interval` is the
        # POLLING collector: it drains `build_stream.take()`, which is the same
        # seam a websocket collector would read on an event loop. Choosing the
        # other transport later replaces this component and nothing else.
        #
        # The run id lives in a per-tab `dcc.Store`, so a run belongs to the
        # tab that started it — two tabs sculpting at once do not read each
        # other's parts.
        dcc.Store(id="g3-run"),
        dcc.Store(id="g3-manifest"),
        dcc.Download(id="g3-dl-json"),
        dcc.Download(id="g3-dl-glb"),
        *poll_guard.components("g3"),
        dmc.Group(
            model_picker.components("g3", sculptor.MODEL, w=260),
            mb="xs",
        ),
        dmc.Text(id="g3-model-status", size="xs", c="dimmed"),
        dmc.Text(id="g3-estimate", size="xs", c="dimmed", mb="xs"),
        dmc.Group(
            [
                dmc.TextInput(
                    id="g3-prompt",
                    placeholder="Describe a sculpture…",
                    value=IDEAS[0],
                    style={"flex": 1},
                ),
                dmc.Button("Sculpt", id="g3-go", variant="filled"),
            ],
            mb="xs",
            align="flex-end",
        ),
        dmc.Group(
            [
                dmc.Badge(idea.split(",")[0], id={"type": "g3-idea", "i": i},
                          variant="light", style={"cursor": "pointer"})
                for i, idea in enumerate(IDEAS)
            ],
            gap="xs",
            mb="sm",
        ),
        # The sculpt call takes several seconds. Without a visible busy state
        # the page looks broken — you click, nothing moves, and there is no way
        # to tell a slow call from a dead one. The overlay sits over the viewer
        # rather than replacing it, so the previous sculpture stays on screen
        # while the next one is composed.
        dmc.Box(
            pos="relative",
            children=[
                dmc.LoadingOverlay(
                    id="g3-busy",
                    visible=False,
                    zIndex=10,
                    overlayProps={"radius": "md", "blur": 2},
                    loaderProps={"type": "bars", "color": "indigo"},
                ),
                dmv.ModelViewer(
                    id="g3-viewer",
                    # Placeholder until the first sculpt: one primitive built by
                    # the same writer, so the page is never an empty box.
                    src=sculptor.to_data_url(
                        sculptor.build(
                            {
                                "parts": [
                                    {"shape": "torus", "name": "seed",
                                     "size": {"x": 1.2, "y": 0.1, "z": 0.24},
                                     "position": {"x": 0, "y": 0.6, "z": 0},
                                     "rotation": {"x": 90, "y": 0, "z": 0},
                                     "color": "#4C6EF5", "metallic": 0.9,
                                     "roughness": 0.25, "emissive_strength": 0.0},
                                ]
                            }
                        )[0]
                    ),
                    alt="A generated 3D sculpture",
                    camera_controls=True,
                    shadow_intensity=1,
                    interpolation_decay=90,
                    attributes=VIEWER_ATTRS,
                    style={"width": "100%", "height": "440px"},
                ),
            ],
        ),
        dmc.Text(
            # Measured, not guessed: a sculpt runs ~35s (effort="medium", and the
        # composition reasoning is the slow part). An estimate that is too
        # low is worse than none — the user concludes it has hung.
        "Composing — this takes about 30 to 45 seconds.",
            id="g3-working", size="sm", c="dimmed", mt="xs", display="none",
        ),
        dmc.Alert(id="g3-status", mt="sm", color="indigo", hide=True),
        dmc.Spoiler(
            id="g3-spoiler",
            showLabel="Show the parts list",
            hideLabel="Hide",
            maxHeight=0,
            children=dmc.Code(id="g3-json", block=True),
            mt="xs",
        ),
        dmc.Group(
            [
                dmc.Button("Save the manifest", id="g3-save-json",
                           variant="light", size="xs", disabled=True),
                dmc.Button("Download .glb", id="g3-save-glb",
                           variant="light", size="xs", disabled=True),
            ],
            gap="xs", mt="xs",
        ),
    ]
)


@callback(
    Output("g3-prompt", "value"),
    Input({"type": "g3-idea", "i": ALL}, "n_clicks"),
    prevent_initial_call=True,
)
def use_idea(clicks):
    if not any(clicks or []):
        return no_update
    return IDEAS[ctx.triggered_id["i"]]


@callback(
    Output("g3-run", "data"),
    Output("g3-poll", "disabled"),
    Output("g3-status", "hide"),
    Output("g3-working", "display"),
    Output("g3-go", "loading"),
    Output("g3-prompt", "disabled"),
    Output("g3-poll", "n_intervals"),
    Output("g3-alive", "data"),
    Input("g3-go", "n_clicks"),
    State("g3-prompt", "value"),
    State("g3-model", "value"),
    prevent_initial_call=True,
)
def start_sculpt(_, prompt, model):
    """Start the build and return immediately.

    This used to be the whole thing: one callback that blocked for ~35 seconds
    behind a loading overlay and then produced a finished object. The build now
    runs on a background thread and the poller reads parts as they assemble,
    which is the change the owner asked for.

    A plain thread suffices: the owner reports one gunicorn worker, and the
    store is file-backed regardless, so a later WEB_CONCURRENCY change on the
    dashboard cannot silently break the read side.
    """
    run_id = build_stream.new_run()
    threading.Thread(
        target=sculptor.sculpt_streaming,
        args=(run_id, prompt),
        kwargs={"model": model or sculptor.MODEL},
        daemon=True,
    ).start()
    # n_intervals back to 0 re-arms the capped Interval, so the ceiling
    # bounds THIS build rather than the tab's whole lifetime.
    return run_id, False, True, "block", True, True, 0, poll_guard.tick_value(0)


@callback(
    Output("g3-viewer", "src"),
    Output("g3-viewer", "alt"),
    Output("g3-status", "children"),
    Output("g3-status", "color"),
    Output("g3-status", "hide", allow_duplicate=True),
    Output("g3-json", "children"),
    Output("g3-working", "children"),
    Output("g3-poll", "disabled", allow_duplicate=True),
    Output("g3-working", "display", allow_duplicate=True),
    Output("g3-go", "loading", allow_duplicate=True),
    Output("g3-prompt", "disabled", allow_duplicate=True),
    Output("g3-manifest", "data"),
    Output("g3-save-json", "disabled"),
    Output("g3-save-glb", "disabled"),
    Output("g3-alive", "data", allow_duplicate=True),
    Input("g3-poll", "n_intervals"),
    State("g3-run", "data"),
    State("g3-model", "value"),
    State("g3-prompt", "value"),
    prevent_initial_call=True,
)
def poll(tick, run_id, model, prompt):
    """Drain the seam and render whatever has arrived.

    Every `part` event carries a COMPLETE `.glb` of the parts so far, so the
    viewer is re-pointed at each in turn and the sculpture assembles on screen.
    `take()` clears as it reads, so this never redraws what it already drew.

    The Interval stops the moment the run ends — on the `done` event, or on a
    `done` flag with no event, which is how a failed build reports itself.

    The parameter ORDER follows the State order above — `g3-model` then
    `g3-prompt`. It did not: the two were transposed, so the provenance
    written into every exported manifest had the model id under "prompt"
    and the prompt text under "model". The suite missed it because the
    tests call this function directly, in ITS order, never through the
    wiring — `tests/test_callback_wiring.py` now compares the two.
    """
    alive = poll_guard.tick_value(tick)
    idle = (no_update,) * 14 + (alive,)
    if not run_id:
        return idle

    state = build_stream.take(run_id)

    latest_src = no_update
    progress = no_update
    final = None
    for event in state["events"]:
        phase = event.get("phase")
        if phase == "part":
            latest_src = event["data_url"]
            progress = f"Building — part {event['index']} of {event['total']}"
        elif phase == "assembling":
            progress = (
                f"Composed in {event.get('seconds', 0)}s — "
                f"assembling {event['total']} parts"
            )
        elif phase == "done":
            final = event

    if final is not None:
        # NOT named `manifest` — that is the module, imported above, and
        # shadowing it here would turn `manifest.from_scene` into a dict lookup.
        scene = final.get("manifest") or {}
        note = f"{scene.get('name', 'Untitled')} — {scene.get('notes', '')}"
        if final.get("notes"):
            note += "  ·  " + "; ".join(final["notes"])
        note += (
            f"  ·  {final.get('part_count', 0)} parts  ·  "
            + spend.actual_line(final.get("usd", 0.0), final.get("seconds", 0.0))
        )
        return (
            final.get("data_url") or latest_src,
            f"A generated 3D sculpture: {scene.get('name', prompt)}",
            note, "indigo", False,
            json.dumps(scene, indent=2),
            "", True, "none", False, False,
            manifest.from_scene(scene, {
                "prompt": prompt, "model": model,
                "usd": final.get("usd", 0.0),
                "generated": date.today().isoformat(),
            }), False, False, alive,
        )

    if state["done"]:
        return (
            no_update, no_update,
            state["reason"] or "The sculpt did not complete.",
            "yellow", False, no_update,
            "", True, "none", False, False,
            no_update, no_update, no_update, alive,
        )

    return (
        latest_src, no_update, no_update, no_update, no_update, no_update,
        progress, no_update, no_update, no_update, no_update,
        no_update, no_update, no_update, alive,
    )


model_picker.register("g3", action_ids=["g3-go"])
poll_guard.register("g3", "g3-status", resets=[
    ("g3-working", "display", "none"),
    ("g3-go", "loading", False),
    ("g3-prompt", "disabled", False),
])


@callback(
    Output("g3-estimate", "children"),
    Input("g3-model", "value"),
)
def show_estimate(model):
    """Priced BEFORE the button, and re-priced when the model changes.

    A visitor choosing Opus over Haiku is choosing a 5x bill, and the only
    moment that fact is useful is before the click.
    """
    return spend.estimate_line(model or sculptor.MODEL, sculptor.MAX_TOKENS)


@callback(
    Output("g3-dl-json", "data"),
    Output("g3-status", "children", allow_duplicate=True),
    Output("g3-status", "hide", allow_duplicate=True),
    Input("g3-save-json", "n_clicks"),
    State("g3-manifest", "data"),
    prevent_initial_call=True,
)
def save_manifest(_clicks, stored):
    """The manifest is the valuable half.

    Re-importing it on [Scene Manifest](/scene-manifest) re-renders the same
    sculpture for free, and editing it costs nothing — which is the whole point
    of keeping it rather than only the `.glb`.
    """
    if not stored:
        return no_update, no_update, no_update
    try:
        m = manifest.validate(stored)
    except manifest.ManifestError as exc:
        # A button that does nothing is the worst available report — see
        # /sculpt-from-image, where this silence hid a real defect for a day.
        return no_update, f"Cannot save this manifest — {exc}", False
    return ({"content": manifest.dumps(m),
             "filename": manifest.filename(m, "json")},
            no_update, no_update)


@callback(
    Output("g3-dl-glb", "data"),
    Output("g3-status", "children", allow_duplicate=True),
    Output("g3-status", "hide", allow_duplicate=True),
    Input("g3-save-glb", "n_clicks"),
    State("g3-manifest", "data"),
    prevent_initial_call=True,
)
def save_glb(_clicks, stored):
    """Rebuilt from the stored manifest on demand, not carried as bytes.

    `lib/glb.py` is deterministic, so this is the same file the viewer is
    showing — and it keeps a megabyte of binary out of the browser's store.
    Nothing is written to disk at any point.

    Two callbacks rather than one dispatching on `ctx.triggered_id`: a callback
    that reads the context cannot be called from a test, and these two are
    worth testing.
    """
    if not stored:
        return no_update, no_update, no_update
    try:
        m = manifest.validate(stored)
        data, _notes, _used = manifest.render(m)
    except (manifest.ManifestError, ValueError) as exc:
        return no_update, f"Cannot build this .glb — {exc}", False
    return dcc.send_bytes(data, manifest.filename(m, "glb")), no_update, no_update

Orbit it. Put it in AR on a phone. It is a real glTF file, not a picture of one.


The vocabulary

Six primitives, and nothing else:

Shapesize means
boxwidth, height, depth
sphereradius
cylinderradius, height
coneradius, height
torusradius, tube thickness
planewidth, depth (a ground card)

Each part carries a position, a rotation, and a PBR material — base colour, metallic, roughness, emissive strength. That is the entire surface the model writes to.

Constraining the vocabulary this hard is what makes the output reliable. There is no syntax to get wrong, no topology to corrupt, and every field has a meaningful clamp.


The prompt does the artistic work

The schema decides what is possible; the system prompt decides whether the result is any good. Three sections earn their place:

Coordinates, stated as a physical fact. +Y is up, the sculpture stands on y=0, and position is the centre of a part — so a 1.4 m cylinder resting on the ground has position.y = 0.7, not 0. Without that last sentence, half the parts sink through the floor, because "put it on the ground" and "centre it at zero" are the same instruction to something that has never stood on a floor.

Composition rules with a stated reason.

Between 5 and 28 parts. Fewer than 5 reads as a diagram; more than ~25 reads

as noise at a glance. Vary scale deliberately: a few large masses that carry

the silhouette, then smaller parts for detail. Rotation is free and underused

— tilt, lean and offset parts rather than stacking everything axis-aligned.

A palette rule, which is the single highest-return line:

Pick a deliberate palette of three or four colours and reuse them. A

different colour per part looks like a test scene, not a sculpture.

Without it you reliably get twenty parts in twenty colours. It is the difference between a sculpture and a bar chart.

The palette rule is a direct descendant of the COLOR LOCK in the SailsBoard object generator, which measures the actual colours from a reference image and splits them by role — body colours versus outline ink. Naming raw hex values alone backfired there: the outline navy was a legitimately dominant colour, so told "build from these colours" the model rendered whole unseen faces in it.

The lesson generalises past pixel art: tell the model what each colour is FOR, not just which colours exist.


Shape is not sanity

Structured output guarantees size.x is a number. It does not guarantee the number is sane. Everything is re-checked in lib/sculptor.py:

GuardWhy
≤ 28 parts~1.5 KB of geometry each; "a city" would exceed any data URL
every dimension ≤ 4 mone runaway scale makes everything else invisible
within 5 m of origina part at z = 900 silently breaks camera framing
roughness ≥ 0.050 is a perfect mirror and reads as a black hole
unknown shape droppedwith a note, not silently
≤ 3 MB outputthe practical data-URL ceiling

Each clamp reports itself in the status line rather than being applied quietly.

The one that is not a clamp

# glTF baseColorFactor is LINEAR, not sRGB.
return tuple(c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
             for c in srgb)

The model returns #A8A29A because that is how humans write colour. glTF expects linear values. Passing the sRGB numbers straight through renders every palette visibly washed out — and it looks like a lighting problem, so it sends you off tuning exposure for an hour.


No upload store, on purpose

The finished .glb is handed to ModelViewer as a data: URL:

src = "data:model/gltf-binary;base64," + base64.b64encode(glb).decode()

<model-viewer> accepts it — three.js's FileLoader carries an explicit /^data:.*,.*$/ branch, which you can find in the vendored bundle.

That is a design decision, not a shortcut. The obvious alternative is a server route backed by a dict of generated files, and an in-memory store with no cap, no TTL and no auth is a memory-growth vector the moment the site is public. The data URL has no store to grow, nothing to expire, and nothing to clean up. The cost is a size ceiling, which is exactly why the part budget is small.


It takes about 35 seconds, and it has to say so

Measured on this page: ~36 s for a sculpt, against ~8.5 s for the image page's vision call and ~4 s for the Scene Director. The composition reasoning is genuinely the slow part — deciding twenty-five parts, their placement and a coherent palette is not a lookup.

That length changes what the UI owes the user. The first version had no busy state at all: you clicked Sculpt, nothing moved, and there was no way to tell a slow call from a dead one. The second version added Dash's running= — a spinner and an overlay for the duration of the callback — which is honest but is still thirty-five seconds of watching a spinner.

This page now shows the sculpture being built instead.

Clicking Sculpt starts the build on a background thread and returns immediately. A dcc.Interval then polls a small progress store, and the viewer is re-pointed at each partial model as it arrives, so the piece assembles in front of you.

The part worth understanding: there is nothing to stream out of the model call. One request returns the entire parts list. What streams is the assembly — lib/sculptor.py builds the parts list up one part at a time and emits a complete .glb at each step:

for index in range(1, len(parts) + 1):
    data, _, _ = build({**manifest, "parts": parts[:index]})
    build_stream.emit(run_id, {"phase": "part", "index": index,
                               "data_url": to_data_url(data)})

Each of those is a real model, not a frame of a video, so you can orbit a half-finished sculpture.

Details that are easy to get wrong:

count comes from WEB_CONCURRENCY in the environment, so a per-process buffer polled by an Interval hangs intermittently the moment there is more than one worker — half the polls land on a worker that never saw the run.

model call, not the assembly loop; a test asserts the streaming layer records no spend of its own.

running is a request every 700 ms for as long as the tab is open.

websocket collector would call the same function from an event loop — which is why the transport can change without touching the producer.

The estimate shown while composing is measured, not guessed. An estimate that is too low is worse than none: at twenty seconds of "10 to 20 seconds" the user concludes it has hung and clicks again.

Cost

composition reasoning is real but short.

gunicorn with --timeout 120 and --threads 4, so a sculpt cannot wedge the single free-tier worker; dropping either of those would make it possible.

that looks like it worked.

money.


Where it goes next

The parts list is a scene graph, so the obvious extensions are cheap:

more ruined" and diff the parts.

but the node structure is already there.

colour and rebuilding — the model is not in that loop at all.

For turning an image into geometry rather than a description, see Image to 3D.


Choosing the model

The dropdown offers the Claude models, plus the GPT models when CHATGPT_API_KEY is set on the host. The line under it always says which you are getting and why — an absent model needs explaining, or it reads as a broken page.

Two things decide what appears:

own key at boot, not written into the source. A model id that has been retired would otherwise be an outage the first time somebody selected it.

lib/spend.py prices an unknown model at $0.00, so an unpriced model would pass the budget ceiling as though it were free rather than failing against it. Discovery decides what exists; pricing decides what is safe to meter.

The list is filled when the page is viewed, not when it is imported. Page modules are imported while Dash registers pages — before run.py warms discovery — so a layout that read the list at import froze the Claude-only set permanently, whatever key was set. See lib/model_picker.py.


What it costs, before and after

The estimate sits under the model picker and re-prices itself when you change the model — choosing Opus over Haiku is choosing a 5x bill, and the only moment that is useful is before the click. It also shows what is left of this shared host's hourly ceiling.

That figure is an upper bound, not a guess: lib/spend.py prices every call as though it used its whole output budget. The number reported after the run is measured from the provider's own token counts and is nearly always lower.

The result line then says what the run actually cost, to four decimal places — two would render most sculpts as $0.00, which reads as free rather than as cheap.


This site runs without provider keys

The AI demos on this page are off on modelviewer.2plot.dev, deliberately. This is a documentation site; it carries no provider keys and does no production spend (owner's decision, 2026-09-12).

So on the public site you will see the model picker empty, the generate control disabled, and a line saying so. That is the expected state, not a fault — please do not file it.

To try it, run the site locally with your own keys:

git clone https://github.com/pip-install-python/dash-model-viewer
cd dash-model-viewer
printf 'ANTHROPIC_API_KEY=sk-ant-...\nCHATGPT_API_KEY=sk-...\n' > .env
pip install -r requirements.txt && python run.py

Either key alone is enough — the picker offers whichever provider it finds. lib/spend.py's ceiling then applies locally: a rolling call limit and a cumulative dollar estimate, so an accident costs a few cents rather than a weekend.

Everything on this page that does not need a key still works and is worth reading for it: the JSON schema, the clamps, the sRGB-to-linear conversion and the dependency-free glTF writer in lib/glb.py are all plain Python.


Source: /generative-3d

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: