File size: 11,095 Bytes
430d623
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
---
license: apache-2.0
base_model:
- Qwen/Qwen3.5-4B
base_model_relation: finetune
datasets:
- townbox/lowpoly-coder-4b-data
library_name: transformers
pipeline_tag: image-text-to-text
language:
- en
tags:
- threejs
- low-poly
- 3d
- code-generation
- glb
- javascript
---

# LowPoly Coder 4B

LowPoly Coder 4B turns a one-sentence request, such as *"Make a low-poly okapi."*, into JavaScript for [Three.js](https://threejs.org) r180 that builds that object as a small low-poly 3D asset. The script [`export_glb.mjs`](export_glb.mjs) in this repo turns the reply into a `.glb` file.

It is a fine-tune of [Qwen3.5-4B](https://huggingface.co/Qwen/Qwen3.5-4B), and it is often wrong. In a blinded test on objects it had never seen, **about 1 in 4 outputs was recognisable** as the requested object (24.0%). DeepSeek V4.1 Flash, which wrote the model's first-stage training examples, scored 93.7% on the same test. Sample several attempts per request and keep the best one.

![Six rendered views (front, back, left, right, front-left, back-right) of one output for "I need a low-poly lunar rover.": a cream chassis with a dark navy cab, four chunky black wheels, a brass antenna and a rust-coloured disc at the back. Mean rater score 2.0 of 3.](images/six-views-lunar-rover.png)

| | |
|---|---|
| **Input** | A one-sentence text request, e.g. "Build a low-poly washing machine." |
| **Output** | A 4-line spec, then one JavaScript block defining `buildAsset(THREE)` |
| **Asset** | A `THREE.Group` of named, flat-shaded meshes, with colours only (no textures) |
| **Size** | The model picks its own triangle budget: 250 to 1,100 on the test set, median 550 |
| **Quality** | 24% of outputs recognisable; under 1% rated "good" |
| **Weights** | Qwen3.5-4B with LoRA adapters merged in; bf16, 2 safetensors shards, about 9.3 GB |
| **Licence** | Apache-2.0 |

## What the model writes

A reply has two parts:

1. A short spec, which the model fills in itself:
   ```
   Asset: …
   Triangle budget: …
   Brief: …
   Required named parts: …
   ```
2. One `javascript` code block that defines `function buildAsset(THREE)`. It returns a `THREE.Group` of named meshes, each with one flat-shaded `MeshStandardMaterial`.

- **Conventions:** Y is up, the object rests on y = 0, the front faces +Z, and scale is free.
- **Style:** always the same hand-crafted "woodland collection" look, with a fixed palette: oak, darkWood, ivory, iron, brass, sage, navy, black and rust.

The full output contract is in [`system_prompt.md`](system_prompt.md).

## How to use

### 1. Generate replies

- **System prompt:** send the exact contents of [`system_prompt.md`](system_prompt.md) as the system message. The model was trained with it.
- **User message:** just the request.
- **Thinking:** off (`enable_thinking=False` in the chat template).
- **Sampling:** temperature 0.7, top_p 0.8, top_k 20, max_tokens 8192.

This is the tested setup (vLLM 0.33.0). Loading the model by repo id this way reproduced the evaluation outputs exactly.

```python
from huggingface_hub import hf_hub_download
from vllm import LLM, SamplingParams
REPO = "townbox/lowpoly-coder-4b"
llm = LLM(model=REPO, max_model_len=12288, limit_mm_per_prompt={"image": 0, "video": 0})
system = open(hf_hub_download(REPO, "system_prompt.md")).read()
msgs = [{"role": "system", "content": system}, {"role": "user", "content": "Build a low-poly washing machine."}]
prompt = llm.get_tokenizer().apply_chat_template(msgs, tokenize=False, add_generation_prompt=True, enable_thinking=False)
out = llm.generate([prompt], SamplingParams(temperature=0.7, top_p=0.8, top_k=20, max_tokens=8192, n=4))
for i, o in enumerate(out[0].outputs):
    open(f"reply_{i}.txt", "w").write(o.text)
```

The model keeps Qwen3.5-4B's architecture (`Qwen3_5ForConditionalGeneration`), but it is used text-only, so the tested setup switches image and video inputs off.

### 2. Turn a reply into a .glb file

```bash
npm install three@0.180.0
node export_glb.mjs reply_0.txt asset.glb
```

[`export_glb.mjs`](export_glb.mjs) extracts the code block, runs `buildAsset(THREE)` in a `node:vm` context with a 10-second limit, and writes a GLB with one node per named mesh, flat-shaded, with base colours. In testing, all 12 real replies converted. The files passed the Khronos glTF validator with 0 errors and 0 warnings, and their mesh and triangle counts matched the evaluation runtime exactly.

> **Safety:** the generated code is untrusted, and `node:vm` is not a security boundary. Run the export in a container or VM with no network access and no secrets.

### 3. Sample several and keep the best

A single attempt is often weak or broken. Sample several, for example 4 to 8 (the snippet above draws 4), and keep the best one. The snippet's own request, "Build a low-poly washing machine.", was also in the test set. One of its outputs is in the [typical outputs](#typical-outputs) below, with a mean score of 1.0 of 3.

Earlier tests of closely related models found that picking the best of 8 with a vision model (GPT-6.1 Terra scoring rendered views) raised the recognisable rate by about 23 to 28 points. **This was not measured for this exact model.**

## Results

### How it was tested

- **Requests:** 90 requests for objects whose names appear nowhere in the training data, in 10 categories. The model made 2 attempts at each, giving 180 outputs.
- **Renders:** every output was rendered from 6 views: front, back, left, right, front-left and back-right.
- **Raters:** 5 independent blinded raters, all AI models (3 Claude Opus 5.5 instances, Gemini 3.8 Flash and Claude Sonnet 5.5), scored every output from 0 to 3:
  - 0: wrong, broken or empty;
  - 1: weak, the object can be guessed;
  - 2: clearly the requested object, an acceptable asset;
  - 3: good.

  Agreement between raters was Krippendorff's α = 0.81. A score of 2 or more counts as recognisable.

### Main result

| | LowPoly Coder 4B | DeepSeek V4.1 Flash (wrote the first-stage training examples) |
|---|---:|---:|
| Recognisable (score ≥ 2) | **24.0%** (95% CI 18.0–30.2%) | 93.7% |

Score 3 ("good") is under 1% of outputs.

### Technical checks

| Check | Outputs (of 180) |
|---|---:|
| Ran and rendered | 172 |
| Passed every check (closed parts, within its budget, all parts connected) | 116 |
| Had detached, floating parts | 51 |

### By category

| Category | Recognisable |
|---|---:|
| birds, sea creatures and bugs | 56% |
| animals | 47% |
| containers and food | 29% |
| props | 20% |
| architecture | 17% |
| fantasy and game | 17% |
| vehicles | 14% |
| furniture | 14% |
| plants | 14% |
| tools and gear | 6% |

Each category has 10 to 20 outputs, so these rates are noisy.

### What the outputs look like

These are real test-set outputs, shown from the front-left view and cropped from the evaluation renders. Each label gives the request, the mean of the 5 raters' scores (0 to 3), how many of the 5 raters scored it 2 or more, and the result of the technical checks.

#### Best outputs

![Six of the best test outputs: a bungalow, an omelette, an oriole, a canary, a quokka and a flower in a pot, with mean rater scores from 2.0 to 2.6 of 3.](images/gallery-best.png)

These are among the highest-scoring outputs. They include a brown bungalow with a gable roof, chimney, door and windows (which still has detached parts), a yellow omelette, an orange-and-black oriole, a yellow canary, a round-eared brown quokka and a red flower in a pot (the request asked for a vase). Even the best outputs are simple shapes in flat colours.

#### Typical outputs

![Six typical test outputs: a washing machine, an okapi, a cement mixer truck, a typewriter, a bowl of ramen and a dugong, with mean rater scores from 1.0 to 1.4 of 3.](images/gallery-typical.png)

About 3 in 4 outputs fall below the recognisable bar, like these. They are often the right kind of thing but not clearly the requested object:
- the washing machine is a beige cabinet with a knob and a curved line, but no round door;
- the okapi is a plain brown, deer-like animal with no stripes, and has detached parts;
- the cement mixer's drum lies sideways across the truck;
- the typewriter is a dark block with a row of keys and a paper bar on top;
- the ramen is a bowl of red broth with a few sticks in it;
- the dugong is a thin, dark, torpedo-like shape.

#### Failures

![Six failed test outputs, all with a mean rater score of 0.2 of 3: a radiator, a playpen, a troika sleigh and a stockade fort made of loose floating pieces, an upside-down pyramid for a teepee, and an empty plate for a steak.](images/gallery-failures.png)

These are among the lowest-scoring outputs that rendered:
- the radiator, playpen, troika sleigh and stockade are loose pieces floating in the air;
- the teepee is an upside-down pyramid;
- "steak on a plate" is an empty plate.

The teepee and the plate passed every technical check, so passing the checks does not mean the object is right. Outputs that did not run or render at all are not shown; 172 of the 180 outputs ran and rendered.

## Limitations

- **Low success rate:** about 3 in 4 outputs are not recognisable, and under 1% are rated "good". Plan to sample several attempts per request and pick the best by eye or with a vision model.
- **Broken geometry is common:** only 116 of 180 test outputs passed every technical check, and 51 had detached, floating parts.
- **Uneven across categories:** birds, sea creatures and bugs (56%) and animals (47%) do best. Tools and gear (6%) and furniture, plants and vehicles (14% each) do worst.
- **Not a scene generator:** when asked for scenes with hundreds of objects (aquarium, reef, pond, forest, village, city block; 24 attempts), the model builds one small asset of about 1,000 triangles with roughly 11 to 25 objects. A small village block worked best, and the aquarium tanks came out as opaque boxes.
- **One look:** every output uses the same flat-shaded "woodland collection" style and fixed palette, with no textures.
- **Untrusted code:** the model writes code that you then execute. Always run it in an isolated container or VM, as described above.

## Training

The model was fine-tuned on request → spec + code examples in two stages: 7,599 written by DeepSeek V4.1 Flash, then 4,245 of its own outputs that a vision model (GPT-6.1 Terra) rated as recognisable, all kept only if they passed the technical checks. Both stages are published, with the held-out validation rows, in [townbox/lowpoly-coder-4b-data](https://huggingface.co/datasets/townbox/lowpoly-coder-4b-data).

## Files

| File | What it is |
|---|---|
| `*.safetensors` (2 shards) | Merged bf16 weights, about 9.3 GB |
| [`system_prompt.md`](system_prompt.md) | The exact system prompt the model was trained with; always send it |
| [`export_glb.mjs`](export_glb.mjs) | Converts a model reply into a `.glb` file |
| `images/` | The render images on this card |

## Licence

Apache-2.0, inherited from Qwen3.5-4B. The training data contains code written by `deepseek/deepseek-v4.1-flash-1017`, so that model's terms may also apply to the data.