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:
| Shape | size means |
|---|---|
box | width, height, depth |
sphere | radius |
cylinder | radius, height |
cone | radius, height |
torus | radius, tube thickness |
plane | width, 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:
| Guard | Why |
|---|---|
| ≤ 28 parts | ~1.5 KB of geometry each; "a city" would exceed any data URL |
| every dimension ≤ 4 m | one runaway scale makes everything else invisible |
| within 5 m of origin | a part at z = 900 silently breaks camera framing |
| roughness ≥ 0.05 | 0 is a perfect mirror and reads as a black hole |
unknown shape dropped | with a note, not silently |
| ≤ 3 MB output | the 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:
- The progress store is a file, not a dict in memory. gunicorn's worker
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.
- Streaming must not become charging per part. The spend gate wraps the one
model call, not the assembly loop; a test asserts the streaming layer records no spend of its own.
- The Interval stops when the run ends, on success or failure. A timer left
running is a request every 700 ms for as long as the tab is open.
take()is a seam. It returns what has accumulated and clears it. A
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
- One call per sculpt,
max_tokens=4000,effort="medium"— the
composition reasoning is real but short.
- At ~36 s a request holds a worker for a long time.
render.yamlruns
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.
stop_reasonis checked beforecontentis read.- No key configured means the button explains itself; it never returns a stub
that looks like it worked.
tests/conftest.pyblanksANTHROPIC_API_KEY, so no test run can spend
money.
Where it goes next
The parts list is a scene graph, so the obvious extensions are cheap:
- Variations. Re-run with the same manifest plus "make it taller / colder /
more ruined" and diff the parts.
- Animation. glTF supports node animation; the writer does not emit it yet,
but the node structure is already there.
- Hand-editing. The manifest is JSON. Nothing stops a user tweaking a
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:
- Discovery. The GPT list is fetched from
GET /v1/modelson this host's
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.
- Pricing. A model is offered only if this build can also price 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:
- /generative-3d/llms.txt — LLM-friendly documentation
- /sitemap.xml
- /robots.txt