Skip to content

About

Articraft on a DGX Spark: photo to articulated 3D asset with a local open-weight model

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

 
 

Repository files navigation

Articraft on a DGX Spark

A reference photo in, an articulated 3D asset out, on one NVIDIA DGX Spark with an open-weight model. No API key and no cloud once the weights are downloaded.

Latest — 2026-10-11: clone_and_demo.sh, run from an empty folder

One command, a fresh clone of this repo's main, and the weights already on the box: spark/clone_and_demo.sh was fetched from GitHub and run as ./clone_and_demo.sh qwen3.8-flash-next-nvfp4 ~/.cache/huggingface (the demo prompt). spark/cache.sh found the 135.3 GB of weights and the server image already present — nothing to fetch: 0 bytes — and the server started offline. It passed: 50 turns in 143 minutes, ended on its own final response, then scored and rendered this turntable itself. Its second compile, at turn 27, warned of nine pairs of faces lying on the same plane (185 cm², the kind a renderer flickers between); the model moved them, compiled clean at turns 33 and 48, and spark/flicker.py finds none in the result.

The dresser built by Qwen3.8-Flash-Next from a fresh clone_and_demo.sh run, turning while its nine drawers slide open one after another

The same dresser closed and with every drawer open, from four sides

Articraft is an agent that turns a prompt or a reference photo into a posable 3D object. A language model writes Python against Articraft's CAD SDK; Articraft compiles it, checks it, and exports a USDZ with rigid bodies and joints. Upstream expects a frontier API behind that loop. This fork runs the same loop against a model served on the Spark itself, and the commands below run RadixArk/Qwen3.8-Flash-Next-NVFP4 — the model the results above were measured on. spark/serve.sh builds and starts its server for you.

Two things it costs, before you start, because a page that buries them wastes your afternoon:

  • 135 GB of weights must already be in your Hugging Face cache. spark/cache.sh checks first and aborts rather than fetching; it tells you the exact size and where it would go.
  • The first build pulls a ~20 GB base image (measured: 20,629,124,565 bytes) and patches it locally. It is a public image, fetched anonymously, once.
  • While this server runs it leaves about 12 GiB free on a 121 GB Spark. Budget for it.

Smaller, and it also fits: RedHatAI/Qwen3.6-35B-A3B-NVFP4 (~25 GB, 35B mixture-of-experts with 3B active) is what this fork was first built against, and the first results section below is its. Everything here works with either: the model is one argument.

A nine-drawer dresser built by Qwen3.6 on a DGX Spark from one photo, its drawers opening one after another

Built on the Spark by the local Qwen3.6 through this fork, from the photo and the one-paragraph prompt in spark/bench/dresser/ (see Results).

Why

This is one piece of scenewright, a pipeline on the same Spark that turns a photo or a prompt into a Blender room (not public yet). Every object it reconstructs is a single scanned mesh, so a dresser arrives as one frozen shape. Articraft builds the replacement: the same kind of object as rigid bodies and joints. scenewright can swap one in by name from its Articraft library, which is how the dresser above ends up in its living room with drawers that open. (In the video the dresser's facing was set by hand; turning a swapped asset to face the room automatically is still open work in scenewright.)

Run it

On a DGX Spark with Docker and the NVIDIA container toolkit:

spark/install.sh                                  # uv venv: Python 3.12, this repo, and OpenUSD; re-runnable
spark/cache.sh qwen3.8-flash-next-nvfp4           # what it needs and whether you have it; downloads nothing
spark/serve.sh qwen3.8-flash-next-nvfp4           # clones + builds the patched server, starts it on :8001, waits
spark/demo_dresser.sh qwen3.8-flash-next-nvfp4    # build the nine-drawer dresser from one photo, score it, render it

serve.sh is one command for either model, but they get there differently: Qwen3.6 runs under stock vLLM, and Flash-Next does not fit on one Spark that way (135 GB of weights), so serve.sh hands it to spark/serve_external.sh, which clones blazux/qwen3.8-Flash-DGX at the commit the model file pins, builds its image if you have none, and starts it with the settings the published runs used. That project is driven, never vendored: if your clone is not at the pinned commit, or has been edited, the script stops rather than serving something the numbers do not describe. First start, measured cold on a box with no container and no built image: 781 s, most of it loading ~76 GiB onto the card.

The demo sends the one-paragraph prompt in spark/bench/dresser/prompt_demo.txt and the photo beside it (PROMPT=frozen sends the short benchmark prompt instead), waits for the agent (about an hour on a Spark), then prints a score and, with Blender available (BLENDER=/path/to/blender), writes render/sheet.png and render/turntable.gif next to the run — the four-view sheet, and a turntable like the one at the top of this page, rendered on the same machine in about six minutes (GIF=0 skips it). Every input is checked against SHA256SUMS first, so a changed prompt cannot pass for a better model.

Starting from an empty folder, or reusing weights you already have

spark/clone_and_demo.sh does the clone, install.sh, serve.sh and demo_dresser.sh above in one command, and never downloads weights silently:

./clone_and_demo.sh qwen3.8-flash-next-nvfp4 ~/.cache/huggingface   # <model-key> <hf-cache-dir> [demo|frozen]

It's standalone, so you don't need a checkout first — fetch just the script:

curl -O https://raw.githubusercontent.com/gregcockroft/articraft-spark/main/spark/clone_and_demo.sh
chmod +x clone_and_demo.sh
./clone_and_demo.sh qwen3.8-flash-next-nvfp4 /path/to/your/hf-cache

Run it from an empty folder — it clones this repo into ./articraft-spark under wherever you run it and refuses to run if that folder already exists. The second argument is an HF_CACHE directory (e.g. your existing ~/.cache/huggingface): spark/cache.sh checks it first and aborts before starting anything if the model's weights aren't already there, rather than downloading ~25–135 GB silently — point it at a cache that has them, or run spark/cache.sh <model-key> --fetch yourself first. It also reuses an already-running compatible server on the target port instead of restarting one, and renders automatically (render/sheet.png and render/turntable.gif) if Blender is on PATH, $BLENDER is set, or it finds one in a couple of common local install spots. A third argument picks the demo (default) or frozen prompt; FRESH_USD=1 forces a from-source OpenUSD build instead of reusing one already on the box.

What had to change

A local model behind an OpenAI-compatible server behaves in ways a hosted API hides. The changes, on top of Articraft ac7d688:

commit the problem on the Spark the change
Build on aarch64 Linux usd-core publishes no aarch64 wheel and no sdist at any version, so Articraft would not install spark/build_openusd.sh builds OpenUSD v26.05 from source in ~4 min (no imaging; no sudo)
Drive a local OpenAI-compatible server three separate layers assumed openrouter.ai: the endpoint, a required API key, and a text-only CLI a base URL, a key only where one is needed, and opt-in image input for vision models
Survive a local reasoning model a thinking-only turn was a fatal error; a turn could generate ~49k tokens with no tool call; thinking could not be switched off; images piled up until the server refused the request empty turns flow into the loop's normal handling; an output cap; chat-template arguments (enable_thinking); image-history pruning that always keeps the reference photo
Bound the thinking per turn with thinking on, Qwen reasoned for the full 32k-token cap three turns running without a tool call, and the harness stopped the run; vLLM's own thinking_token_budget is accepted and ignored on this server the client closes the reasoning after N tokens (8,192 here) and asks for the action, and the model acts on the plan it has
Keep the last clean compile at the turn limit the local model keeps revising until the turn ceiling, and a run that had compiled a good revision recorded nothing the last clean revision is kept and the record says it was salvaged (max_turns_last_clean)
Send one configuration per run every generating turn carries the run's chat-template arguments; the one call that compacts the conversation did not, so it went out at the template's own default — reasoning_effort: xhigh for Qwen3.8 — and the call least able to survive a reasoning-only reply was the only one nobody configured. One 88-minute run ended on it the summary request carries the same arguments a turn does. The failure is intermittent and this is not claimed as a measured fix: a later run on the unmodified code compacted normally. The argument is that one run should send one model one configuration; a rate would need several compactions per arm
Edit, do not rewrite the model replaced a whole 300-line file to change a few lines, turn after turn; five such write calls were most of one run's output growth, and every recorded draw had at least one write refuses a small rewrite (under 40 % of lines changed) of a file this run already wrote and points at edit; measured on a Flash-Next pair, 2 of 2 coherent, the model complied on the next turn both times
Let a hair-thin triangle stop a finished object mesh_health blocks a compile on sliver_faces — any triangle below a quality of 1e-4, which on the bench meant a 9 µm strip along a drawer front. In 5 of the 11 mesh-blocked compiles recorded here it was the only blocker, and two of those were the final compile of an otherwise finished dresser slivers stop blocking only when that same report carries nothing else at all (ARTICRAFT_MESH_SLIVERS_NONBLOCKING, off by default, on in Flash-Next's model file); the warning is still shown to the model. Replayed offline over all 11: the control reproduced 11 of 11, exactly the 4 sliver-only chests flipped, the 6 others and one half-built draft (which also reported overlap) stayed blocked, and all 4 flipped objects were read as coherent. Never yet exercised in a live generation
Nudge a run that edits without compiling — off by default a run can edit for dozens of turns and never compile, and two recorded runs spent a whole 100-turn budget that way, recording nothing the harness can ask for a compile after N consecutive editing turns (ARTICRAFT_COMPILE_GATE_TURNS). N is 0, so the nudge is off: its threshold of 8 was tuned by simulation over recorded runs and never compared live with it off, and the one A/B that exists runs against it (0 of 3 draws finished with it on, 2 of 3 with it off). Set ARTICRAFT_COMPILE_GATE_TURNS=8 to restore it. Turning it off is a judgement, not a measurement: at the shipped reasoning_effort: medium there is no with/without comparison, and the exposure the nudge was added for returns. The end-of-run compile requirement is unaffected — a run still cannot finish with an uncompiled workspace
Compile first, then preview the prompt asked for preview renders before the first compile, and in 8 recorded draws none had a clean compile by turn 40; the same four SDK calls were written wrong run after run the prompt asks for a complete first version and a compile before any preview, and says only compile records the object. The four calls now accept what the model writes: world points and bounds read as .x/.y/.z, metrics take positional arguments, camera presets take their own direction=, and RigidBody.shapes exists. First clean compile at turn 36, 34, 10 and 36 in the first four draws, and by turn 40 in 1 of the 7 draws after them
Tell a run how many turns it has left one draw edited for 27 turns after its last compile, running its own test scripts instead, and reached the 100-turn limit with nothing recorded when 20 and 10 turns remain and the latest workspace has not compiled clean, the harness says so, once each. One live draw, not a rate: the first note was ignored for 14 turns, the second was followed by a compile in 4, and that run's final compile is its result
Leave room for the reply in the context window vLLM counts max_tokens against the window. Compaction waited for the window less 16,384, so a 229,377-token prompt plus the 32,768-token reply cap overflowed 262,144 and ended a run at turn 76. Reasoning the client resends each turn was not counted, and a turn that hit the thinking budget counted its prompt twice compaction also reserves the reply cap and the thinking budget, counts resent reasoning in the tail it keeps, and a thinking-budget turn reports the window it used. Settings refuse a window too small for that reserve. One live draw compacted at 208,590 estimated tokens and continued at 36,706
Keep the last clean compile when a run ends on an error that overflow came one turn after a clean compile, and the error path discarded it a model or compaction failure keeps the last clean revision (error_last_clean) and still records the error. Covered by scripted-agent tests; not yet exercised in a live generation
Warn when two shapes share a visible face a rail built to the carcass's outer width, a foot flush with an apron: two faces on one plane, facing the same way, flicker in any renderer as the camera moves (z-fighting), and no collision check sees it inside one part. 7 of 31 recorded dressers had such a stripe across a side; the median over 12 recent ones was 1,804 cm² of coincident visible face every compile lists the shape pairs that share a visible face, with the area and the plane, as a warning that never blocks. spark/flicker.py reads the same from a USDZ. Three finished live draws ended with 16, 0 and 20 cm²

Every new setting is off by default. The last five rows are not settings and apply to every provider; none of them blocks a compile. The tests are in tests/test_openrouter_local.py, tests/test_agent_turn_budget.py, tests/test_compaction.py and tests/test_testing_sdk.py.

Check the results without a Spark

The numbers below come from a small number of runs on one machine. That is a reason to make them checkable, not a reason to trust them. Each recorded run in spark/results/ carries the USDZ it produced, so its geometry claims can be re-derived from files in this repository — no GPU, no model, no server, no network, about ten seconds:

spark/verify_results.sh          # after spark/install.sh

It checks each sample against its SHA256SUMS, then re-runs spark/score.py and spark/handles.py on the committed USDZ and requires them to reproduce the committed score.txt and handles.txt line for line. Two lines are excluded on both sides, both provenance rather than geometry: file, which names the path the run happened at, and record, present only when the scorer was pointed at a run directory. If a published number is wrong, the script says so and exits non-zero.

One run also publishes its turn-by-turn log — conversation.jsonl, 147 messages as the harness wrote them, with every tool call and every turn's token usage. spark/verify_run.py re-derives the turn count, both token totals, the peak turn and the compile turn from that log and checks them against the stats.json the tooling wrote. All six agree: 64 turns, 109,283 output tokens, 6,127,645 input, peak 199,782, one compile, at turn 62.

What none of it checks is whether the object looks like the photo. No script here does; that is what the sheets and the human reads are for, and it is the limit that decided four runs above.

First results, Qwen3.6, 2026-09-10

These are the first runs this fork made, on qwen3.6-35b-a3b-nvfp4, and they are kept rather than replaced: they are the line everything since is measured against, including the two failure modes that shaped the harness. The Flash-Next results at the top of this page are newer and were measured the same way.

Measured on one DGX Spark (GB10, 128 GB unified memory) on 2026-09-10. Warm decode on this server is about 27 tok/s at a 57k-token prompt.

The demo run

The dresser from the demo run, closed and with every drawer open, from four sides

This fork, thinking on with the 8,192-token budget, the one-paragraph prompt: 67 turns in 53 minutes, finished on its own final response. Revision 0002 has nine prismatic joints, every slide horizontal and opening out of the front (spark/score.py: 9/9 and 9/9), and every handle standing proud of its drawer front (spark/handles.py: 9/9). The run's last revision, 0004, thickened the fronts past the handles, so 0002 is the sample; its directory has the details.

The benchmark prompt

The short prompt in spark/bench/dresser/prompt.txt is the one every earlier run was given, and the one the rows below share. Do not compare them with the demo run above: the prompts differ.

The joint count is not the object. Articraft's checks and the bench's joint ask both passed on objects whose parts do not fit together. With thinking switched off, the local model is fast (turns of 10 to 20 seconds) and hits the ask on three of five objects, but none of the five holds together: in the dresser the drawers slide vertically and a side panel sits in the middle of the carcass. So spark/score.py also checks every drawer in world space: its slide axis has to be horizontal and opening it has to move it out of the carcass, not through it.

The same prompt, two harness versions: a coherent chest, and one that passes the joint count with its drawers sliding vertically

dresser run harness thinking turns wall joints (ask: 9 prismatic) score.py geometry
earlier Articraft mattzh72/articraft 959f1455 on 65, finished on its own 50 min 9 prismatic, met pass: 9/9 horizontal, 9/9 open outward
current, thinking off this fork off 100, salvaged 27 min 9 prismatic, met fail: 0/9 horizontal
current, thinking on this fork on 4, stopped by the harness 36 min never compiled none
current, thinking on, budget this fork on, 8,192 budget 60, finished on its own 22 min 9 joints, 0 prismatic, not met none to check

With thinking on and no budget, turns 2, 3 and 4 each reasoned for the full 32,768-token cap without calling a tool, and after three empty turns in a row the harness stopped the run. With the budget the runaway is gone: the run finished on its own, but from the short prompt the drawers came out as removable parts rather than slides. The demo prompt, which names the slides, the handles and every carcass part, is what produced the dresser above.

Try another model

Each model is one file in spark/models/: the vLLM image digest, the checkpoint and revision, the tool-call and reasoning parsers, and the client settings Articraft sends. To try one:

cp spark/models/qwen3.6-35b-a3b-nvfp4.env spark/models/my-model.env   # edit it
spark/serve.sh my-model && spark/demo_dresser.sh my-model

What decides whether a model can drive Articraft at all is whether its tool calls parse, and whether it acts before its output cap. The files here:

key weights what it has done
qwen3.8-flash-next-nvfp4 ~135 GB + a ~20 GB image the results at the top of this page. Needs the patched server spark/serve_external.sh builds
qwen3.6-35b-a3b-nvfp4 ~25 GB the first results section. Stock vLLM
qwen3.8-27b-inferact ~26 GB runs on the pinned upstream image with no patched build, decodes at ~8.5 tok/s — and of its four recorded draws, a person called one coherent and rated three defective, while every automated check passed all four. The one that worked is spark/results/dresser-qwen3.8-27b-medium/. It is the cheapest thing here to run and the least likely to give you an object you would keep
qwen3.8-27b ~54 GB, unpinned a slot for the BF16 release. Untested, and it resolves main at the hub rather than a revision

How it fits together

flowchart LR
  photo[reference photo + prompt] --> agent
  subgraph spark[DGX Spark]
    agent[Articraft agent loop] -- OpenAI chat API --> vllm[vLLM on GB10<br/>Qwen3.8-Flash-Next NVFP4<br/>or Qwen3.6-35B-A3B]
    vllm -- tool calls --> agent
    agent -- writes main.py --> sdk[Articraft CAD SDK<br/>build123d]
    sdk --> compile[compile + checks]
    compile -- errors, previews --> agent
    compile --> usdz[USDZ: rigid bodies + joints]
    usdz --> score[spark/score.py<br/>joints + where drawers move]
    usdz --> render[spark/render<br/>Blender sheet and video]
  end
Loading

Limits

  • Thinking needs a budget. Off, the model is quick and the geometry did not hold together in these runs. On with no budget, it reasoned to the 32k-token cap three turns running without acting. On with an 8,192-token budget, it thinks and then acts. vLLM's own thinking_token_budget has no effect on this server (MTP-2), so the budget is enforced by the client.
  • The prompt carries a lot. The same model on the same code built nine sliding drawers from the demo prompt and none from the short one. Both prompts are in spark/bench/dresser/.
  • The last clean revision is not always the best. In the demo run the final revision moved the drawer fronts past the handles; the checks in spark/ are how the good revision was picked.
  • The geometry checks are narrow, and nothing here checks resemblance. score.py checks sliding joints, handles.py checks handles, and spark/verify_results.sh re-derives every published number from the committed USDZ — and none of it looks at whether the object resembles the photo, or whether a drawer is a box rather than a solid block. Six draws have passed every automated check; a person rated four of the six defective. Hinges, and resemblance, need a person looking at the render.
  • Flash-Next needs the weights already cached and a ~20 GB image pull. 135 GB in your Hugging Face cache before you start (spark/cache.sh aborts rather than fetching), plus the base image, once. On a 121 GB Spark this server alone leaves about 12 GiB free, so budget for anything else on the box.

Credits

Articraft is by its authors at articraftresearch/Articraft, Apache-2.0; its README is kept as ARTICRAFT.md, and NOTICE lists what this fork changed. Qwen3.6 and Qwen3.8-Flash-Next are by the Qwen team; their NVFP4 checkpoints are by Red Hat AI and RadixArk; serving is vLLM, and fitting Flash-Next onto one Spark is blazux/qwen3.8-Flash-DGX.

About

Articraft on a DGX Spark: photo to articulated 3D asset with a local open-weight model

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages