kwabs22 Claude Opus 5.5 commited on
Commit
77b3711
·
1 Parent(s): 7de3810

Add v1: English -> 3D translation Space with MCP tools

Browse files

Dictionary from SimReady and Objaverse assets (primitives only when chosen),
serial ledger with freeze/refine, seeded configurations, simplification
ladder, and back-translation. One Gradio app serves the UI and MCP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Files changed (14) hide show
  1. README.md +1 -1
  2. SPEC.md +115 -0
  3. app.py +253 -0
  4. cat-sun-scene.html +476 -0
  5. requirements.txt +9 -0
  6. visuallearning-handover.html +264 -0
  7. vli/__init__.py +1 -0
  8. vli/assets.py +215 -0
  9. vli/grammar.py +252 -0
  10. vli/lexicon.py +168 -0
  11. vli/pipeline.py +95 -0
  12. vli/scene.py +219 -0
  13. vli/simplify.py +55 -0
  14. vli/vlm.py +27 -0
README.md CHANGED
@@ -11,4 +11,4 @@ pinned: false
11
  short_description: Visualisation and speed of input attempt
12
  ---
13
 
14
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
11
  short_description: Visualisation and speed of input attempt
12
  ---
13
 
14
+ English → 3D translation with a simplification ladder, usable from the UI and as an MCP server. See [SPEC.md](SPEC.md).
SPEC.md ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # VisualLearningIdea: v1 spec
2
+
3
+ Supersedes `visuallearning-handover.html`, which drifted over several HuggingChat
4
+ sessions. `cat-sun-scene.html` stays as the UX reference for the serial reading.
5
+
6
+ ## Idea
7
+
8
+ 3D is a language. The Space **translates English into 3D**, the way you would
9
+ translate English into Japanese. A picture is easier to take in than text, because
10
+ the reader doesn't have to guess the exact picture. Once 3D exists it can be
11
+ materialized (papercraft, origami, …). The **simplification ladder** shows how far
12
+ a mesh can be reduced and still mean its word, and that is a main output.
13
+
14
+ One Gradio Space serves people (UI) and models (`demo.launch(mcp_server=True)`).
15
+ HuggingChat models can't run tools, which is why this Space exists.
16
+
17
+ ## Translation terms
18
+
19
+ | Translation | Here |
20
+ |---|---|
21
+ | Source → target language | English → 3D |
22
+ | Dictionary | Ready assets: SimReady → Objaverse |
23
+ | Grammar words | `the / a / and`: no geometry; a determiner opens a "which?" question |
24
+ | Verbs | Relations between meshes |
25
+ | Word alignment | `tokens[].commits`: reading order differs from build order |
26
+ | Forced choices | `unresolved`: which asset, how big |
27
+ | Back-translation | `describe_scene`: a vision model reads the picture back |
28
+
29
+ ## Dictionary
30
+
31
+ - Tiers, each taken **on rejection**: `simready → objaverse → primitive → generation`.
32
+ - **Available** = a SimReady or Objaverse asset exists (no construction). No asset
33
+ means not available, with no exceptions. The UI offers only available words, so
34
+ the user knows before submitting.
35
+ - A missing word can be used only by **explicitly choosing a primitive** for it
36
+ (`box, sphere, cylinder, cone, plane`). In the UI the user picks one. Over MCP,
37
+ `check_words` returns `missing` and the user's model (e.g. in HuggingChat) decides:
38
+ rephrase, or pass `primitives_json` such as `{"sun": "sphere"}`.
39
+ - Assets that **can't be used** are skipped and recorded in `decision.skipped`:
40
+ no license, NC (non-commercial) or ND (no derivatives; simplification is a
41
+ derivative), or no geometry.
42
+ - SimReady: package list from `NVIDIA-Omniverse/simready-central/submissions/*`.
43
+ Only geometry USD + thumbnail are downloaded, because textures are ~98% of each
44
+ ~1 GB package. Assets are metric, so height comes from the asset.
45
+ - Objaverse: LVIS categories (`lvis-annotations.json.gz`) are the word index; the
46
+ license comes from `metadata/<shard>.json.gz`. No real scale, so height is a
47
+ nominal default (an `unresolved` decision the user can set).
48
+ - Light comes from the verb, not from a special word: whatever "shone" becomes the
49
+ light source and sits in the sky along the light direction.
50
+ - The ground implied by "sat" (with no stated object) is a support plane created by
51
+ the relation. It is not a typed word, so it needs no asset. To exist, a thing needs
52
+ a reality to exist in: reading skips this, but visualising exposes it. So the
53
+ implied ground is also surfaced in `unresolved` ("cat sat on what?", default:
54
+ ground plane), for the discussion to accept or rephrase.
55
+
56
+ ## Serial side vs configuration side
57
+
58
+ - **Serial (ledger)**: tokens in reading order, items each word commits, relations,
59
+ unresolved questions. **Freezing lives here.** Frozen items can't change; the
60
+ others can be swapped, rejected, resized or rerolled.
61
+ - **Configuration**: once the serial side is resolved, any placement satisfying the
62
+ relations is valid. `seed` picks one (positions, yaw, sun direction).
63
+
64
+ ### Ledger shape
65
+
66
+ ```jsonc
67
+ {
68
+ "sentence": "the cat sat and the sun shone",
69
+ "tokens": [{"index": 2, "text": "sat", "kind": "verb", "commits": ["ground", "r2"], "opens": []}],
70
+ "items": [
71
+ {"id": "n1", "kind": "object", "word": "cat", "token": 1, "frozen": false,
72
+ "decision": {"tier": "objaverse", "chosen": {…}, "alternatives": […],
73
+ "rejected_tiers": [], "skipped": [], "label": "cat"},
74
+ "height_m": {"value": 0.3, "source": "default"}, "license": "by"},
75
+ {"id": "ground", "kind": "environment", "role": "plane", "implied_by": 2},
76
+ {"id": "r2", "kind": "relation", "relation": "on", "subject": "n1", "object": "ground"},
77
+ {"id": "n5", "kind": "object", "word": "sun", "emits_light": true,
78
+ "decision": {"tier": "primitive", "chosen": {"source": "primitive", "shape": "sphere"}, …}},
79
+ {"id": "r6", "kind": "relation", "relation": "lights", "subject": "n5", "object": "scene"}
80
+ ],
81
+ "unresolved": [{"id": "q0", "about": "n1", "question": "which cat?", "default_chosen": "cat", "alternatives": […]}],
82
+ "configuration": {"seed": 0, "placements": {"n1": {"position": [0, 0, 0], "yaw_deg": 12}}, "sun": {…}}
83
+ }
84
+ ```
85
+
86
+ Relations: `on, under, beside, near, behind, in_front_of, above, faces, lights`.
87
+ Verbs: `sat/stood/lay/rested` → `on` (ground implied), `shone/glowed` → `lights`
88
+ (shadows only turn on with this coupling), `faced` → `faces`.
89
+
90
+ ## MCP tools
91
+
92
+ | Tool | In → out |
93
+ |---|---|
94
+ | `check_words` | sentence, primitives → per-word status, missing words, completions |
95
+ | `translate_to_scene` | sentence, primitives, seed → ledger, PNG, GLB |
96
+ | `refine_scene` | ledger, changes, seed → ledger, PNG, GLB |
97
+ | `simplify_item` | ledger, item id, check_recognition → report, rung PNGs, rung GLBs |
98
+ | `describe_scene` | ledger, seed → back-translation JSON |
99
+
100
+ Ladder rungs: original → 20k / 5k / 1k / 250 / 60 faces → convex hull → oriented
101
+ box. With `check_recognition`, a vision model names each rung, and
102
+ `simplest_recognized` is the lowest rung that still reads as the word.
103
+
104
+ Secrets: `HF_TOKEN` (vision model calls). Optional env: `VLM_MODEL`, `VLI_CACHE`.
105
+
106
+ ## Known gaps / open questions
107
+
108
+ - **Materialization** (papercraft from a low rung) is not built yet.
109
+ - **Generation tier** is a stub: rejecting a primitive errors out.
110
+ - SimReady names → words is a heuristic: the second `_` part is the head word
111
+ (`appliance_refrigerator_a01` → refrigerator). Some names will mis-index.
112
+ - Objaverse "front" direction is unknown, so `faces` assumes +Z forward.
113
+ - `on` uses the bounding-box top, so objects on irregular tops may float or sink.
114
+ - Renders are matplotlib (headless, CPU): approximate shading and a flat shadow on
115
+ the ground plane only.
app.py ADDED
@@ -0,0 +1,253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """VisualLearningIdea: translate English into 3D, then see how far 3D can simplify.
2
+
3
+ One Space for people (this UI) and for models (every public event below is an
4
+ MCP tool: demo.launch(mcp_server=True)). Helper events are api_visibility="private".
5
+ """
6
+ import json
7
+ import os
8
+ import re
9
+ import tempfile
10
+ import threading
11
+
12
+ import gradio as gr
13
+
14
+ from vli import assets, pipeline, scene, simplify, vlm
15
+ from vli.grammar import check_words as _check_words
16
+ from vli.lexicon import LEXICON, PRIMITIVE_SHAPES
17
+
18
+ threading.Thread(target=LEXICON.ensure, daemon=True).start() # warm the dictionary
19
+
20
+
21
+ def _fail(e):
22
+ raise gr.Error(str(e))
23
+
24
+
25
+ # ---------------- MCP tools ----------------
26
+
27
+ def check_words(sentence: str, primitives_json: str = "{}") -> str:
28
+ """Check which words of a sentence exist in the 3D vocabulary, and complete the word being typed.
29
+
30
+ A word is available only when a ready asset (SimReady or Objaverse) exists for it.
31
+ Words with no asset are listed in "missing": rephrase them, or map each one to a
32
+ primitive shape in primitives_json. Verbs (sat, stood, lay, shone, faced) and
33
+ prepositions (on, under, beside, near, behind, in front of, above) are relations
34
+ between meshes; whatever "shone" becomes the light source.
35
+
36
+ Args:
37
+ sentence: The English sentence, complete or partly typed.
38
+ primitives_json: JSON object mapping missing words to shapes, e.g. {"sun": "sphere"}.
39
+ Shapes: box, sphere, cylinder, cone, plane.
40
+
41
+ Returns:
42
+ JSON string: {"ok", "words": [{text, kind, available_in | primitive}], "missing",
43
+ "primitive_shapes", "completions"}.
44
+ """
45
+ try:
46
+ return json.dumps(_check_words(sentence, json.loads(primitives_json or "{}")))
47
+ except Exception as e:
48
+ _fail(e)
49
+
50
+
51
+ def translate_to_scene(sentence: str, primitives_json: str = "{}", seed: int = 0) -> tuple[str, str, str]:
52
+ """Translate an English sentence into a 3D scene.
53
+
54
+ Produces the serial ledger (tokens in reading order, the items each word commits,
55
+ and the unresolved "which?" questions), one configuration of that ledger chosen by
56
+ `seed`, a rendered picture and a GLB model. Words with no asset must be mapped to a
57
+ primitive in primitives_json (call check_words first to see which are missing).
58
+
59
+ Args:
60
+ sentence: A sentence using available words, e.g. "the cat sat and the sun shone".
61
+ primitives_json: JSON object mapping missing words to shapes, e.g. {"sun": "sphere"}.
62
+ seed: Picks one of the configurations that satisfy the ledger (positions, facing, sun).
63
+
64
+ Returns:
65
+ (ledger JSON string, rendered PNG, GLB scene file).
66
+ """
67
+ try:
68
+ return pipeline.translate(sentence, json.loads(primitives_json or "{}"), int(seed))
69
+ except Exception as e:
70
+ _fail(e)
71
+
72
+
73
+ def refine_scene(ledger_json: str, changes_json: str = "[]", seed: int = 0) -> tuple[str, str, str]:
74
+ """Refine a ledger: freeze some decisions and change the others, then re-render.
75
+
76
+ Changes are a JSON list of:
77
+ {"id": "n1", "freeze": true} lock an item (false unlocks)
78
+ {"id": "n1", "use": 2} pick alternatives[2] as the asset
79
+ {"id": "n1", "reject": true} reject the tier: simready -> objaverse -> primitive -> generation
80
+ {"id": "n1", "height_m": 0.4} set the object's height in metres
81
+ {"id": "n1", "shape": "sphere"} use a primitive (box, sphere, cylinder, cone, plane)
82
+ {"reroll": true} every unfrozen object takes a random alternative
83
+ Frozen items cannot be changed. The configuration (placement) is always re-chosen from `seed`.
84
+
85
+ Args:
86
+ ledger_json: Ledger JSON from translate_to_scene or a previous refine_scene.
87
+ changes_json: JSON list of changes, as above.
88
+ seed: Picks the configuration, and the alternatives for a reroll.
89
+
90
+ Returns:
91
+ (ledger JSON string, rendered PNG, GLB scene file).
92
+ """
93
+ try:
94
+ ledger = json.loads(ledger_json)
95
+ pipeline.apply_changes(ledger, json.loads(changes_json or "[]"), int(seed))
96
+ return pipeline.outputs(ledger, int(seed))
97
+ except Exception as e:
98
+ _fail(e)
99
+
100
+
101
+ def simplify_item(ledger_json: str, item_id: str, check_recognition: bool = False) -> tuple[str, list, list]:
102
+ """Build the simplification ladder for one object: full asset -> fewer faces -> convex hull -> box.
103
+
104
+ With check_recognition, a vision model names each rung's picture; the lowest rung
105
+ still named as the word is the simplest form that keeps its meaning.
106
+
107
+ Args:
108
+ ledger_json: Ledger JSON from translate_to_scene or refine_scene.
109
+ item_id: The object item to simplify, e.g. "n1".
110
+ check_recognition: Ask a vision model to name each rung (needs HF_TOKEN; slower).
111
+
112
+ Returns:
113
+ (report JSON string, gallery of rung pictures, GLB file per rung).
114
+ """
115
+ try:
116
+ ledger = json.loads(ledger_json)
117
+ item = next((i for i in ledger["items"] if i["id"] == item_id), None)
118
+ if item is None or item["kind"] != "object":
119
+ raise ValueError(f"{item_id!r} is not an object item in this ledger")
120
+ mesh = assets.settle(item)
121
+ work = tempfile.mkdtemp(prefix="vli_ladder_")
122
+ report, gallery, files = {"item": item_id, "word": item["word"], "rungs": []}, [], []
123
+ for k, (label, rung) in enumerate(simplify.ladder(mesh)):
124
+ png = scene.render({item_id: rung}, os.path.join(work, f"{k}.png"), title=label)
125
+ slug = re.sub(r"\W+", "_", label)
126
+ glb = os.path.join(work, f"{k}_{slug}.glb")
127
+ rung.export(glb)
128
+ entry = {"rung": label, **simplify.stats(rung)}
129
+ if check_recognition:
130
+ answer = vlm.name_object(png)
131
+ entry["named_as"] = answer
132
+ entry["recognized"] = item["word"].lower() in answer.lower()
133
+ report["rungs"].append(entry)
134
+ gallery.append((png, label))
135
+ files.append(glb)
136
+ if check_recognition:
137
+ kept = [r for r in report["rungs"] if r["recognized"]]
138
+ report["simplest_recognized"] = kept[-1]["rung"] if kept else None
139
+ return json.dumps(report, indent=1), gallery, files
140
+ except Exception as e:
141
+ _fail(e)
142
+
143
+
144
+ def describe_scene(ledger_json: str, seed: int = 0) -> str:
145
+ """Back-translate: render the ledger's scene and have a vision model describe it in English.
146
+
147
+ Comparing the description with the original sentence checks the translation.
148
+
149
+ Args:
150
+ ledger_json: Ledger JSON from translate_to_scene or refine_scene.
151
+ seed: Which configuration to render.
152
+
153
+ Returns:
154
+ JSON string: {"original", "back_translation", "nouns_recovered": {word: bool}}.
155
+ """
156
+ try:
157
+ ledger = json.loads(ledger_json)
158
+ meshes = pipeline.settle_all(ledger)
159
+ config = scene.configure(ledger, meshes, int(seed))
160
+ png = scene.render_scene(ledger, meshes, config, os.path.join(tempfile.mkdtemp(), "scene.png"))
161
+ text = vlm.describe(png)
162
+ nouns = [i["word"] for i in ledger["items"] if i["kind"] == "object"]
163
+ return json.dumps({"original": ledger["sentence"], "back_translation": text,
164
+ "nouns_recovered": {n: n in text.lower() for n in nouns}}, indent=1)
165
+ except Exception as e:
166
+ _fail(e)
167
+
168
+
169
+ # ---------------- UI-only helpers ----------------
170
+
171
+ def _suggestions(words_json):
172
+ data = json.loads(words_json or "{}")
173
+ marks = []
174
+ for w in data.get("words", []):
175
+ if w["kind"] == "unknown":
176
+ marks.append(f"~~{w['text']}~~")
177
+ elif w["kind"] == "noun":
178
+ marks.append(f"**{w['text']}**")
179
+ elif w["kind"] == "primitive":
180
+ marks.append(f"_{w['text']}_ ({w['primitive']})")
181
+ else:
182
+ marks.append(w["text"])
183
+ status = " ".join(marks) or "_start typing_"
184
+ if data.get("missing"):
185
+ status += " \n_Struck-through words have no asset: pick a suggestion, rephrase, or choose a primitive._"
186
+ missing = data.get("missing", [])
187
+ return (status, gr.update(choices=data.get("completions", []), value=None),
188
+ gr.update(choices=missing, value=missing[0] if missing else None))
189
+
190
+
191
+ def _complete(sentence, choice):
192
+ if not choice:
193
+ return sentence
194
+ return re.sub(r"[A-Za-z]*$", "", sentence) + choice + " "
195
+
196
+
197
+ def _use_primitive(primitives_json, word, shape):
198
+ prims = json.loads(primitives_json or "{}")
199
+ if word:
200
+ prims[word] = shape
201
+ return json.dumps(prims)
202
+
203
+
204
+ EXAMPLE_CHANGES = '[\n {"id": "n1", "freeze": true},\n {"reroll": true}\n]'
205
+
206
+ with gr.Blocks(title="VisualLearningIdea") as demo:
207
+ gr.Markdown("# English → 3D\nType with the words the 3D dictionary has. **Bold** = an object asset; "
208
+ "verbs and prepositions are relations between meshes.")
209
+ with gr.Row():
210
+ sentence = gr.Textbox(label="Sentence", placeholder="the cat sat and the sun shone", scale=4)
211
+ seed = gr.Number(label="Configuration seed", value=0, precision=0, scale=1)
212
+ status = gr.Markdown()
213
+ suggest = gr.Radio(label="Suggestions", choices=[], interactive=True)
214
+ with gr.Row():
215
+ missing = gr.Dropdown(label="Missing word", choices=[], interactive=True)
216
+ shape = gr.Dropdown(label="Primitive", choices=PRIMITIVE_SHAPES, value="box", interactive=True)
217
+ use_prim = gr.Button("Use primitive")
218
+ primitives = gr.Code(label="Primitives", language="json", value="{}", interactive=True)
219
+ words = gr.Code(label="Word check", language="json", visible=False)
220
+ go = gr.Button("Translate", variant="primary")
221
+ with gr.Row():
222
+ picture = gr.Image(label="Picture", type="filepath")
223
+ model = gr.Model3D(label="3D")
224
+ with gr.Accordion("Ledger: freeze and change (serial side)", open=False):
225
+ ledger = gr.Code(label="Ledger", language="json", interactive=True)
226
+ changes = gr.Code(label="Changes", language="json", value=EXAMPLE_CHANGES, interactive=True)
227
+ refine = gr.Button("Refine")
228
+ with gr.Accordion("Simplification ladder", open=False):
229
+ with gr.Row():
230
+ item_id = gr.Textbox(label="Object item id", value="n1")
231
+ recog = gr.Checkbox(label="Check recognition with a vision model", value=False)
232
+ simp = gr.Button("Simplify")
233
+ ladder_pics = gr.Gallery(label="Rungs", columns=4)
234
+ ladder_report = gr.Code(label="Report", language="json")
235
+ ladder_files = gr.File(label="GLB per rung", file_count="multiple")
236
+ with gr.Accordion("Back-translation", open=False):
237
+ back = gr.Button("Describe the scene")
238
+ back_out = gr.Code(label="Back-translation", language="json")
239
+
240
+ gr.on([sentence.change, primitives.change], check_words, [sentence, primitives], words,
241
+ api_name="check_words", trigger_mode="always_last") \
242
+ .then(_suggestions, words, [status, suggest, missing], api_visibility="private")
243
+ suggest.select(_complete, [sentence, suggest], sentence, api_visibility="private")
244
+ use_prim.click(_use_primitive, [primitives, missing, shape], primitives, api_visibility="private")
245
+ go.click(translate_to_scene, [sentence, primitives, seed], [ledger, picture, model],
246
+ api_name="translate_to_scene")
247
+ refine.click(refine_scene, [ledger, changes, seed], [ledger, picture, model], api_name="refine_scene")
248
+ simp.click(simplify_item, [ledger, item_id, recog], [ladder_report, ladder_pics, ladder_files],
249
+ api_name="simplify_item")
250
+ back.click(describe_scene, [ledger, seed], back_out, api_name="describe_scene")
251
+
252
+ if __name__ == "__main__":
253
+ demo.launch(mcp_server=True)
cat-sun-scene.html ADDED
@@ -0,0 +1,476 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ <!DOCTYPE html>
3
+ <html lang="en">
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>The cat sat and the sun shone — serial reading + scene</title>
8
+ <style>
9
+ :root{--bg:#0e1116;--panel:#151a22e6;--border:#2a3242;--text:#e8ecf4;--muted:#93a0b4;--accent:#ffc857;--accent2:#7ee0a3;--warn:#f0b429}
10
+ *{box-sizing:border-box;margin:0}
11
+ body{background:var(--bg);color:var(--text);font:14px/1.55 -apple-system,"Segoe UI",Roboto,sans-serif;overflow:hidden}
12
+ #scene{position:fixed;inset:0}
13
+ #panel{position:fixed;top:16px;left:16px;width:352px;max-height:calc(100vh - 32px);overflow-y:auto;background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:14px;backdrop-filter:blur(8px);z-index:10}
14
+ h1{font-size:15px;margin-bottom:6px}
15
+ .sub{color:var(--muted);font-size:12px;margin-bottom:10px}
16
+ .tabs{display:flex;gap:6px;margin-bottom:10px}
17
+ .tab{flex:1;text-align:center;padding:6px 0;border:1px solid var(--border);border-radius:8px;font-size:12.5px;cursor:pointer;color:var(--muted)}
18
+ .tab.active{color:var(--accent);border-color:var(--accent);background:#ffc85714}
19
+ .view{display:none}
20
+ .view.active{display:block}
21
+ .cap{min-height:48px;font-size:12.5px;background:#ffc85710;border:1px solid var(--border);border-radius:8px;padding:8px 10px;margin-bottom:10px}
22
+ .cap b{color:var(--accent)}
23
+ .btnrow{display:flex;gap:8px;margin-bottom:10px}
24
+ button{flex:1;background:var(--panel);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:7px 0;font-size:12.5px;cursor:pointer}
25
+ button:hover{border-color:var(--accent)}
26
+ button.primary{background:#ffc85722;border-color:var(--accent)}
27
+ button:disabled{opacity:.4;cursor:default}
28
+ .section{margin-bottom:10px}
29
+ .label{font-size:11px;letter-spacing:1px;text-transform:uppercase;color:var(--accent2);margin-bottom:6px}
30
+ .ledger{display:flex;flex-direction:column;gap:4px}
31
+ .item{font-size:12px;padding:4px 8px;border-radius:6px;border:1px solid transparent;opacity:.38;transition:all .25s}
32
+ .item b{color:var(--accent)}
33
+ .item .alt{color:var(--muted);font-size:11px}
34
+ .item.done{opacity:1;border-color:var(--border)}
35
+ .item.active{opacity:1;border-color:var(--accent);background:#ffc85714}
36
+ .unres{border-left:3px solid var(--warn);padding-left:10px}
37
+ .unres div{color:var(--muted);font-size:12px;padding:2px 0}
38
+ .ctrl{margin:8px 0}
39
+ .ctrl label{display:flex;justify-content:space-between;font-size:12px;color:var(--muted);margin-bottom:3px}
40
+ input[type=range]{width:100%;accent-color:var(--accent)}
41
+ select{background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:4px 8px;font-size:12px;width:100%}
42
+ .note{font-size:11px;color:var(--muted);border-top:1px solid var(--border);padding-top:8px;margin-top:4px}
43
+ #hud{position:fixed;bottom:12px;right:16px;color:var(--muted);font-size:11px;background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:6px 10px}
44
+ /* ---- serial reading: full-bleed linear timeline, starts right of sidebar ---- */
45
+ #reading{position:fixed;inset:0;display:none;overflow-x:auto;overflow-y:auto;padding:24px 40px 40px 396px}
46
+ #reading.active{display:block}
47
+ .railhead{margin-bottom:16px;color:var(--muted);font-size:12px}
48
+ .railhead b{color:var(--accent2)}
49
+ .rail{display:flex;align-items:flex-start;min-width:max-content}
50
+ .tokwrap{display:flex;flex-direction:column;align-items:center;position:relative;padding:0 16px}
51
+ .tokwrap:not(:last-child)::after{content:'';position:absolute;top:26px;left:calc(50% + 30px);width:calc(100% - 60px);height:2px;background:var(--border)}
52
+ .tokwrap.committed:not(:last-child)::after{background:var(--accent2)}
53
+ .tok{width:52px;height:52px;border-radius:50%;border:2px solid var(--border);display:flex;align-items:center;justify-content:center;font-size:12.5px;color:var(--muted);background:var(--panel);position:relative;z-index:1;transition:all .3s}
54
+ .tok.committed{border-color:var(--accent2);color:var(--text)}
55
+ .tok.active{border-color:var(--accent);color:var(--accent);background:#ffc85722;box-shadow:0 0 18px #ffc85744}
56
+ .tok.q::before{content:'?';position:absolute;top:-8px;right:-8px;background:var(--warn);color:#000;font-size:10px;font-weight:700;width:16px;height:16px;border-radius:50%;display:flex;align-items:center;justify-content:center}
57
+ .model{width:150px;height:112px;margin-top:10px;border:1px solid var(--border);border-radius:10px;background:#10131a;object-fit:cover;display:block}
58
+ .model.qbox{display:flex;align-items:center;justify-content:center;color:var(--warn);font-size:40px;font-weight:700}
59
+ .word{margin-top:8px;font-size:16px;color:var(--muted)}
60
+ .tokwrap.committed .word{color:var(--text)}
61
+ .tokwrap.active .word{color:var(--accent)}
62
+ .map{margin-top:4px;font-size:11.5px;color:var(--accent2);text-align:center;max-width:150px}
63
+ .commits{margin-top:8px;display:flex;flex-direction:column;gap:5px;max-width:150px}
64
+ .chip{font-size:10.5px;border:1px solid var(--border);border-radius:6px;padding:3px 7px;color:var(--muted);text-align:center;opacity:.35;transition:all .3s}
65
+ .chip.on{opacity:1;border-color:var(--accent2);color:var(--text)}
66
+ .chip.rel{border-color:#7eb8e0}
67
+ .chip.q{border-color:var(--warn);color:var(--warn)}
68
+ </style>
69
+ </head>
70
+ <body>
71
+ <div id="scene"></div>
72
+ <div id="reading">
73
+ <div class="railhead"><b>SERIAL READING</b> — linear, no camera. Each word shows the model it commits (snapshotted from the same geometry the Scene tab uses). The sidebar no longer overlaps: the timeline starts to its right.</div>
74
+ <div class="rail" id="rail"></div>
75
+ </div>
76
+
77
+ <div id="panel">
78
+ <h1>“The cat sat and the sun shone”</h1>
79
+ <div class="sub">two independent steppers — serial reading (default) and scene</div>
80
+
81
+ <div class="tabs">
82
+ <div class="tab" id="tabScene">🌐 Scene</div>
83
+ <div class="tab active" id="tabSerial">📏 Serial reading</div>
84
+ </div>
85
+
86
+ <!-- SERIAL VIEW -->
87
+ <div class="view active" id="viewSerial">
88
+ <div class="cap" id="capSerial">Press <b>▶ step</b> — each word commits its models and items in reading order.</div>
89
+ <div class="btnrow"><button id="rPrev">◀</button><button id="rNext" class="primary">▶ step</button><button id="rAuto">⏩ auto</button></div>
90
+ <div class="note">Each word card in the timeline shows the <b>model snapshot</b> its commitment produces. Determiners (“the”) commit nothing — they stay <b style="color:var(--warn)">?</b>. “sat” is the compression star: one word, three commits.</div>
91
+ </div>
92
+
93
+ <!-- SCENE VIEW -->
94
+ <div class="view" id="viewScene">
95
+ <div class="cap" id="capScene">Scene stepper is independent — press <b>▶ step</b> to build the configuration item by item.</div>
96
+ <div class="btnrow"><button id="sPrev">◀</button><button id="sNext" class="primary">▶ step</button><button id="sAuto">⏩ auto</button></div>
97
+ <div class="section">
98
+ <div class="label">Ledger</div>
99
+ <div class="ledger" id="ledger">
100
+ <div class="item" data-i="1"><b>1 · ground_plane</b> — grass (implied by “sat”)</div>
101
+ <div class="item" data-i="2"><b>2 · form: cat</b> — orange tabby, stylized</div>
102
+ <div class="item" data-i="3"><b>3 · pose: sat</b> — haunches down, tail curled</div>
103
+ <div class="item" data-i="4"><b>4 · position</b> — left of center (frame decision)</div>
104
+ <div class="item" data-i="5"><b>5 · orient</b> — facing the sun (derived from 7)</div>
105
+ <div class="item" data-i="6"><b>6 · light_source: sun</b> — disc + warm light</div>
106
+ <div class="item" data-i="7"><b>7 · under-light-of</b> — coupling: shadow computed</div>
107
+ <div class="item" data-i="8"><b>8 · supported-by</b> — contact: haunches + paws</div>
108
+ </div>
109
+ </div>
110
+ <div class="section">
111
+ <div class="label">Unresolved</div>
112
+ <div class="unres">
113
+ <div>· “the (cat)” — <b style="color:var(--warn)">?</b> defaulted: tabby</div>
114
+ <div>· “the (sun)” — <b style="color:var(--warn)">?</b> defaulted: late afternoon</div>
115
+ <div>· ground material? — defaulted: grass</div>
116
+ <div>· aware of the sun? — defaulted: yes</div>
117
+ </div>
118
+ </div>
119
+ <div class="section">
120
+ <div class="label">Refinement (post-serial)</div>
121
+ <div class="ctrl"><label>Sun elevation <span id="elevV">35°</span></label><input type="range" id="elev" min="5" max="85" value="35"></div>
122
+ <div class="ctrl"><label>Sun azimuth <span id="azV">-40°</span></label><input type="range" id="az" min="-180" max="180" value="-40"></div>
123
+ <div class="ctrl"><label>Cat orientation</label>
124
+ <select id="facing">
125
+ <option value="toward">facing the sun</option>
126
+ <option value="away">back to the sun</option>
127
+ <option value="profile">profile to the sun</option>
128
+ </select>
129
+ </div>
130
+ </div>
131
+ </div>
132
+ </div>
133
+
134
+ <div id="hud">drag = orbit · scroll = zoom · ◀ ▶ keys step the active tab</div>
135
+
136
+ <script type="importmap">
137
+ {"imports":{
138
+ "three":"https://unpkg.com/three@0.160.0/build/three.module.js",
139
+ "three/addons/":"https://unpkg.com/three@0.160.0/examples/jsm/"
140
+ }}
141
+ </script>
142
+ <script type="module">
143
+ import * as THREE from 'three';
144
+ import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
145
+
146
+ /* ================= reusable model builders (shared by scene + snapshots) ================= */
147
+ function matFur(){ return new THREE.MeshStandardMaterial({color:0xd9822b, roughness:0.9}); }
148
+ function matDark(){ return new THREE.MeshStandardMaterial({color:0xb5641c, roughness:0.9}); }
149
+ function shadowify(g){ g.traverse(o=>{ if(o.isMesh) o.castShadow = true; }); return g; }
150
+
151
+ function buildGround(){
152
+ const g = new THREE.Mesh(new THREE.CircleGeometry(40, 64),
153
+ new THREE.MeshStandardMaterial({color:0x4d7c3a, roughness:1}));
154
+ g.rotation.x = -Math.PI/2; g.receiveShadow = true; return g;
155
+ }
156
+ function buildGroundSmall(){
157
+ const g = new THREE.Mesh(new THREE.CircleGeometry(3.2, 48),
158
+ new THREE.MeshStandardMaterial({color:0x4d7c3a, roughness:1}));
159
+ g.rotation.x = -Math.PI/2; g.receiveShadow = true; return g;
160
+ }
161
+ function buildForm(){
162
+ const fur = matFur(), furDark = matDark();
163
+ const g = new THREE.Group();
164
+ const body = new THREE.Mesh(new THREE.SphereGeometry(0.75, 20, 16), fur);
165
+ body.scale.set(1, 1.25, 0.9); body.position.y = 0.85; g.add(body);
166
+ const chest = new THREE.Mesh(new THREE.SphereGeometry(0.5, 18, 14), fur);
167
+ chest.scale.set(0.9, 1.05, 0.7); chest.position.set(0, 0.95, 0.25); g.add(chest);
168
+ const head = new THREE.Mesh(new THREE.SphereGeometry(0.42, 20, 16), fur);
169
+ head.scale.set(1, 0.92, 0.95); head.position.set(0, 1.95, 0.18); g.add(head);
170
+ [-0.28, 0.28].forEach(x=>{
171
+ const ear = new THREE.Mesh(new THREE.ConeGeometry(0.13, 0.3, 4), furDark);
172
+ ear.position.set(x, 2.32, 0.15); g.add(ear);
173
+ });
174
+ [-0.16, 0.16].forEach(x=>{
175
+ const eye = new THREE.Mesh(new THREE.SphereGeometry(0.055, 10, 10),
176
+ new THREE.MeshStandardMaterial({color:0x222a18, emissive:0x0a1400}));
177
+ eye.position.set(x, 2.0, 0.52); g.add(eye);
178
+ });
179
+ for(let i=0;i<3;i++){
180
+ const s = new THREE.Mesh(new THREE.TorusGeometry(0.72, 0.035, 6, 24, Math.PI*0.7), furDark);
181
+ s.position.set(0, 0.75+i*0.32, 0); s.rotation.x = Math.PI/2; s.rotation.z = Math.PI*0.15; g.add(s);
182
+ }
183
+ return shadowify(g);
184
+ }
185
+ function buildPose(){
186
+ const fur = matFur(), furDark = matDark();
187
+ const g = new THREE.Group();
188
+ [-0.62, 0.62].forEach(x=>{
189
+ const h = new THREE.Mesh(new THREE.SphereGeometry(0.34, 16, 12), fur);
190
+ h.scale.set(0.8, 1.1, 1.1); h.position.set(x, 0.5, -0.15); g.add(h);
191
+ });
192
+ [-0.28, 0.28].forEach(x=>{
193
+ const paw = new THREE.Mesh(new THREE.CapsuleGeometry(0.13, 0.3, 6, 10), fur);
194
+ paw.position.set(x, 0.16, 0.45); g.add(paw);
195
+ });
196
+ const curve = new THREE.CatmullRomCurve3([
197
+ new THREE.Vector3(0.15,0.55,-0.55), new THREE.Vector3(0.75,0.18,-0.25),
198
+ new THREE.Vector3(0.85,0.1,0.35), new THREE.Vector3(0.35,0.08,0.6)]);
199
+ g.add(new THREE.Mesh(new THREE.TubeGeometry(curve, 32, 0.09, 8, false), furDark));
200
+ return shadowify(g);
201
+ }
202
+ function buildSupport(){
203
+ const m = new THREE.MeshBasicMaterial({color:0x7ee0a3, transparent:true, opacity:0.75});
204
+ const g = new THREE.Group();
205
+ [[-0.28,0.45,0.14,0.2],[0.28,0.45,0.14,0.2],[-0.62,-0.15,0.2,0.27],[0.62,-0.15,0.2,0.27]].forEach(([x,z,r1,r2])=>{
206
+ const ring = new THREE.Mesh(new THREE.RingGeometry(r1, r2, 24), m);
207
+ ring.rotation.x = -Math.PI/2; ring.position.set(x, 0.02, z); g.add(ring);
208
+ });
209
+ return g;
210
+ }
211
+ function buildSunDisc(){
212
+ return new THREE.Mesh(new THREE.SphereGeometry(1.1, 24, 24), new THREE.MeshBasicMaterial({color:0xffd66b}));
213
+ }
214
+
215
+ /* ================= token spec (output format of parse_sentence_to_items) ================= */
216
+ const TOKENS = [
217
+ {word:'the', map:'determiner → ?', unresolved:true, model:'q', commits:[],
218
+ cap:'<b>“the”</b> — commits nothing; opens a <b style="color:var(--warn)">?</b>: WHICH cat? Reading skipped it; the ledger must not.'},
219
+ {word:'cat', map:'cat = model (form)', unresolved:false, model:'cat',
220
+ commits:['form: tabby model','+position default (frame)','unresolved: pose pending'],
221
+ cap:'<b>“cat”</b> — a model enters. Form defaulted: tabby; placed left of center (frame decision riding along). No pose yet — floating form.'},
222
+ {word:'sat', map:'sat → pose + implied surface + support', unresolved:false, model:'sat',
223
+ commits:['ground_plane (implied)','pose: sitting','supported-by: contact'],
224
+ cap:'<b>“sat”</b> — one word, THREE commits: surface exists (implied), pose becomes geometry, contact established. Serial reading exposes this compression.'},
225
+ {word:'and', map:'connector — serial join', unresolved:false, model:'and',
226
+ commits:[], cap:'<b>“and”</b> — no geometry. Its job is serial: binding two clauses into one conversion, promising a second subject.'},
227
+ {word:'the', map:'determiner → ?', unresolved:true, model:'q', commits:[],
228
+ cap:'<b>“the”</b> again — another <b style="color:var(--warn)">?</b>. WHICH sun? A second unresolved decision, symmetric with the first.'},
229
+ {word:'sun', map:'sun = model (light source)', unresolved:false, model:'sun',
230
+ commits:['light_source: disc + warm light'],
231
+ cap:'<b>“sun”</b> — the second model enters: a light source. Light exists — but is not yet <i>related to</i> the cat.'},
232
+ {word:'shone', map:'shone → relation: acting-on-scene', unresolved:false, model:'shone',
233
+ commits:['under-light-of: coupling ON','orient default: facing sun'],
234
+ cap:'<b>“shone”</b> — a verb of relation: the sun ACTS on the scene. Shadow now computed from sun↔cat geometry; cat faces the light. <b>Configuration complete.</b>'}
235
+ ];
236
+
237
+ /* ================= snapshot renderer: static model images per word ================= */
238
+ const snapR = new THREE.WebGLRenderer({antialias:true, preserveDrawingBuffer:true});
239
+ snapR.setSize(300, 224);
240
+ snapR.shadowMap.enabled = true;
241
+ snapR.shadowMap.type = THREE.PCFSoftShadowMap;
242
+
243
+ function snapshot(build, camPos, lookAt){
244
+ const s = new THREE.Scene();
245
+ s.background = new THREE.Color(0x10131a);
246
+ const cam = new THREE.PerspectiveCamera(40, 300/224, 0.1, 200);
247
+ cam.position.set(...camPos); cam.lookAt(...lookAt);
248
+ s.add(new THREE.AmbientLight(0xffffff, 0.6));
249
+ const dl = new THREE.DirectionalLight(0xffffff, 2.2);
250
+ dl.position.set(4, 6, 5); dl.castShadow = true;
251
+ dl.shadow.mapSize.set(512, 512);
252
+ Object.assign(dl.shadow.camera, {left:-6, right:6, top:6, bottom:-6, far:40});
253
+ s.add(dl);
254
+ build(s);
255
+ snapR.render(s, cam);
256
+ return snapR.domElement.toDataURL();
257
+ }
258
+ function addShadowedLight(s, pos, color, intensity, target){
259
+ const dl = new THREE.DirectionalLight(color, intensity);
260
+ dl.position.set(...pos); dl.castShadow = true;
261
+ dl.shadow.mapSize.set(512, 512);
262
+ Object.assign(dl.shadow.camera, {left:-6, right:6, top:6, bottom:-6, far:40});
263
+ if(target) dl.target.position.set(...target);
264
+ s.add(dl); s.add(dl.target);
265
+ }
266
+ const SNAPSHOTS = {
267
+ ground: snapshot(s=>s.add(buildGroundSmall()), [3,4,5], [0,0,0]),
268
+ cat: snapshot(s=>s.add(buildForm()), [2.2,2.2,3.4], [0,1.1,0]),
269
+ sat: snapshot(s=>{
270
+ const d = buildGroundSmall(); s.add(d);
271
+ const c = new THREE.Group(); c.add(buildForm()); c.add(buildPose()); c.add(buildSupport()); s.add(c);
272
+ }, [2.6,2.4,3.8], [0,0.8,0]),
273
+ and: snapshot(s=>{
274
+ const a = new THREE.Mesh(new THREE.SphereGeometry(0.4,16,12), matFur()); a.position.x = -1.1;
275
+ const b = new THREE.Mesh(new THREE.SphereGeometry(0.4,16,12), new THREE.MeshBasicMaterial({color:0xffd66b})); b.position.x = 1.1;
276
+ const link = new THREE.Mesh(new THREE.CylinderGeometry(0.06,0.06,2.2,8), new THREE.MeshStandardMaterial({color:0x93a0b4}));
277
+ link.rotation.z = Math.PI/2;
278
+ s.add(a, b, link);
279
+ }, [0,0.6,5], [0,0,0]),
280
+ sun: snapshot(s=>s.add(buildSunDisc()), [0,0,4], [0,0,0]),
281
+ shone: snapshot(s=>{
282
+ s.add(buildGroundSmall());
283
+ const c = new THREE.Group(); c.add(buildForm()); c.add(buildPose()); c.add(buildSupport()); s.add(c);
284
+ const sun = buildSunDisc(); sun.position.set(4, 2.2, -3); sun.scale.setScalar(0.8); s.add(sun);
285
+ addShadowedLight(s, [4,2.2,-3], 0xffe3a3, 2.5, [0,0.6,0]);
286
+ }, [3.2,2.4,4.6], [0,0.7,0])
287
+ };
288
+
289
+ /* ================= tabs ================= */
290
+ const tabScene = document.getElementById('tabScene'), tabSerial = document.getElementById('tabSerial');
291
+ const viewScene = document.getElementById('viewScene'), viewSerial = document.getElementById('viewSerial');
292
+ function showTab(which){
293
+ tabScene.classList.toggle('active', which==='scene');
294
+ tabSerial.classList.toggle('active', which==='serial');
295
+ viewScene.classList.toggle('active', which==='scene');
296
+ viewSerial.classList.toggle('active', which==='serial');
297
+ document.getElementById('scene').style.visibility = which==='scene' ? 'visible' : 'hidden';
298
+ document.getElementById('reading').classList.toggle('active', which==='serial');
299
+ document.getElementById('hud').style.visibility = which==='scene' ? 'visible' : 'hidden';
300
+ }
301
+ tabScene.onclick = ()=>showTab('scene');
302
+ tabSerial.onclick = ()=>showTab('serial');
303
+
304
+ /* ================= build serial rail with models ================= */
305
+ const rail = document.getElementById('rail');
306
+ const tokEls = [];
307
+ TOKENS.forEach((t,i)=>{
308
+ const wrap = document.createElement('div'); wrap.className='tokwrap';
309
+ const tok = document.createElement('div'); tok.className='tok'+(t.unresolved?' q':''); tok.textContent=i+1;
310
+ const model = document.createElement('div');
311
+ if(t.model === 'q'){ model.className='model qbox'; model.textContent='?'; }
312
+ else { model.className='model'; const img = document.createElement('img'); img.src = SNAPSHOTS[t.model]; img.style.width='100%'; img.style.height='100%'; img.style.objectFit='cover'; img.style.borderRadius='9px'; model.appendChild(img); }
313
+ const word = document.createElement('div'); word.className='word'; word.textContent=t.word;
314
+ const map = document.createElement('div'); map.className='map'; map.textContent=t.map;
315
+ const commits = document.createElement('div'); commits.className='commits';
316
+ t.commits.forEach(c=>{
317
+ const chip = document.createElement('div');
318
+ chip.className='chip'+(c.startsWith('under')||c.startsWith('supported')?' rel':'');
319
+ chip.textContent=c; commits.appendChild(chip);
320
+ });
321
+ wrap.append(tok, model, word, map, commits);
322
+ rail.appendChild(wrap);
323
+ tokEls.push({wrap, tok, chips:[...commits.children]});
324
+ });
325
+
326
+ /* ================= main scene ================= */
327
+ const scene = new THREE.Scene();
328
+ scene.background = new THREE.Color(0x2a3346);
329
+ scene.fog = new THREE.Fog(0x2a3346, 40, 120);
330
+ const camera = new THREE.PerspectiveCamera(50, innerWidth/innerHeight, 0.1, 300);
331
+ camera.position.set(6, 3.4, 8);
332
+ const renderer = new THREE.WebGLRenderer({antialias:true});
333
+ renderer.setSize(innerWidth, innerHeight);
334
+ renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
335
+ renderer.shadowMap.enabled = true;
336
+ renderer.shadowMap.type = THREE.PCFSoftShadowMap;
337
+ document.getElementById('scene').appendChild(renderer.domElement);
338
+ const controls = new OrbitControls(camera, renderer.domElement);
339
+ controls.target.set(0, 0.8, 0);
340
+ controls.enableDamping = true;
341
+
342
+ const ground = buildGround(); ground.visible = false; scene.add(ground);
343
+ const cat = new THREE.Group();
344
+ const formG = buildForm(), poseG = buildPose(), supG = buildSupport();
345
+ formG.visible = false; poseG.visible = false; supG.visible = false;
346
+ cat.add(formG, poseG, supG);
347
+ cat.visible = false;
348
+ scene.add(cat);
349
+
350
+ const sunDisc = buildSunDisc(); sunDisc.scale.setScalar(2); sunDisc.visible = false; scene.add(sunDisc);
351
+ const sunLight = new THREE.DirectionalLight(0xffe3a3, 0);
352
+ sunLight.castShadow = false;
353
+ sunLight.shadow.mapSize.set(2048, 2048);
354
+ Object.assign(sunLight.shadow.camera, {left:-12, right:12, top:12, bottom:-12, far:80});
355
+ scene.add(sunLight);
356
+ const ambient = new THREE.AmbientLight(0x8098c0, 0.85); scene.add(ambient);
357
+
358
+ /* ================= SERIAL stepper (independent) ================= */
359
+ let serialStep = 0; // 0..7 tokens committed
360
+ function applySerial(){
361
+ tokEls.forEach((el,i)=>{
362
+ el.tok.classList.toggle('committed', i < serialStep);
363
+ el.tok.classList.toggle('active', i === serialStep-1);
364
+ el.wrap.classList.toggle('committed', i < serialStep);
365
+ el.wrap.classList.toggle('active', i === serialStep-1);
366
+ el.chips.forEach(c=>c.classList.toggle('on', i < serialStep));
367
+ });
368
+ document.getElementById('capSerial').innerHTML = serialStep === 0
369
+ ? 'Press <b>▶ step</b> — each word commits its models and items in reading order.'
370
+ : TOKENS[serialStep-1].cap;
371
+ document.getElementById('rPrev').disabled = serialStep === 0;
372
+ document.getElementById('rNext').disabled = serialStep === 7;
373
+ }
374
+ function gotoSerial(n){ serialStep = Math.max(0, Math.min(7, n)); applySerial(); }
375
+
376
+ /* ================= SCENE stepper (independent) ================= */
377
+ let sceneStep = 0; // 0..8 items
378
+ const catHome = new THREE.Vector3(0,0,0), catPlaced = new THREE.Vector3(-0.6,0,0);
379
+ const targetPos = catHome.clone();
380
+ let targetRotY = 0;
381
+ const SCENE_CAPS = {
382
+ 0:'Scene stepper is independent — press <b>▶ step</b> to build the configuration item by item.',
383
+ 1:'<b>1 · ground_plane</b> — “sat” forces a surface. Defaulted: grass.',
384
+ 2:'<b>2 · form: cat</b> — a body exists. No pose yet: floating form.',
385
+ 3:'<b>3 · pose: sat</b> — haunches drop, tail curls. “Sat” became geometry.',
386
+ 4:'<b>4 · position</b> — frame decision: left of center.',
387
+ 5:'<b>5 · orient</b> — facing the sun (default derived from item 7).',
388
+ 6:'<b>6 · light_source: sun</b> — light enters, sky turns. Not yet coupled.',
389
+ 7:'<b>7 · under-light-of</b> — coupling ON: shadow computed from relations.',
390
+ 8:'<b>8 · supported-by</b> — contact marked. Configuration complete.'
391
+ };
392
+ function applyScene(){
393
+ ground.visible = sceneStep >= 1;
394
+ cat.visible = sceneStep >= 2;
395
+ formG.visible = sceneStep >= 2;
396
+ poseG.visible = sceneStep >= 3;
397
+ targetPos.copy(sceneStep >= 4 ? catPlaced : catHome);
398
+ const sunOn = sceneStep >= 6;
399
+ sunDisc.visible = sunOn; sunLight.intensity = sunOn ? 3.0 : 0;
400
+ sunLight.castShadow = sceneStep >= 7;
401
+ scene.background.set(sunOn ? 0x87b5e0 : 0x2a3346);
402
+ scene.fog.color.set(sunOn ? 0x87b5e0 : 0x2a3346);
403
+ ambient.intensity = sunOn ? 0.55 : 0.85;
404
+ supG.visible = sceneStep >= 8;
405
+ document.querySelectorAll('#ledger .item[data-i]').forEach(el=>{
406
+ const i = +el.dataset.i;
407
+ el.classList.toggle('done', i <= sceneStep);
408
+ el.classList.toggle('active', i === sceneStep);
409
+ });
410
+ document.getElementById('capScene').innerHTML = SCENE_CAPS[sceneStep];
411
+ document.getElementById('sPrev').disabled = sceneStep === 0;
412
+ document.getElementById('sNext').disabled = sceneStep === 8;
413
+ }
414
+ function gotoScene(n){ sceneStep = Math.max(0, Math.min(8, n)); applyScene(); applyConfiguration(); }
415
+
416
+ /* ================= wiring ================= */
417
+ document.getElementById('rNext').onclick = ()=>gotoSerial(serialStep+1);
418
+ document.getElementById('rPrev').onclick = ()=>gotoSerial(serialStep-1);
419
+ document.getElementById('sNext').onclick = ()=>gotoScene(sceneStep+1);
420
+ document.getElementById('sPrev').onclick = ()=>gotoScene(sceneStep-1);
421
+ let autoS = null, autoR = null;
422
+ document.getElementById('rAuto').onclick = ()=>{
423
+ if(autoR){ clearInterval(autoR); autoR=null; document.getElementById('rAuto').textContent='⏩ auto'; return; }
424
+ if(serialStep >= 7) gotoSerial(0);
425
+ document.getElementById('rAuto').textContent='⏸ stop';
426
+ autoR = setInterval(()=>{ if(serialStep >= 7){ clearInterval(autoR); autoR=null; document.getElementById('rAuto').textContent='⏩ auto'; return; } gotoSerial(serialStep+1); }, 1700);
427
+ };
428
+ document.getElementById('sAuto').onclick = ()=>{
429
+ if(autoS){ clearInterval(autoS); autoS=null; document.getElementById('sAuto').textContent='⏩ auto'; return; }
430
+ if(sceneStep >= 8) gotoScene(0);
431
+ document.getElementById('sAuto').textContent='⏸ stop';
432
+ autoS = setInterval(()=>{ if(sceneStep >= 8){ clearInterval(autoS); autoS=null; document.getElementById('sAuto').textContent='⏩ auto'; return; } gotoScene(sceneStep+1); }, 1400);
433
+ };
434
+ addEventListener('keydown', e=>{
435
+ const active = tabSerial.classList.contains('active') ? 'serial' : 'scene';
436
+ if(e.key==='ArrowRight') active==='serial' ? gotoSerial(serialStep+1) : gotoScene(sceneStep+1);
437
+ if(e.key==='ArrowLeft') active==='serial' ? gotoSerial(serialStep-1) : gotoScene(sceneStep-1);
438
+ });
439
+
440
+ /* refinement controls */
441
+ const DIST = 45;
442
+ function applyConfiguration(){
443
+ const elev = THREE.MathUtils.degToRad(+document.getElementById('elev').value);
444
+ const az = THREE.MathUtils.degToRad(+document.getElementById('az').value);
445
+ const dir = new THREE.Vector3(Math.cos(elev)*Math.sin(az), Math.sin(elev), Math.cos(elev)*Math.cos(az));
446
+ sunDisc.position.copy(dir).multiplyScalar(DIST);
447
+ sunLight.position.copy(dir).multiplyScalar(20);
448
+ sunLight.target.position.set(targetPos.x, 0.5, targetPos.z);
449
+ scene.add(sunLight.target);
450
+ document.getElementById('elevV').textContent = document.getElementById('elev').value + '°';
451
+ document.getElementById('azV').textContent = document.getElementById('az').value + '°';
452
+ }
453
+ const FACINGS = {toward:180, away:0, profile:90};
454
+ function applyFacing(){
455
+ const v = document.getElementById('facing').value;
456
+ targetRotY = THREE.MathUtils.degToRad(FACINGS[v] - document.getElementById('az').value);
457
+ }
458
+ ['elev','az'].forEach(id=>document.getElementById(id).addEventListener('input', applyConfiguration));
459
+ document.getElementById('facing').addEventListener('change', applyFacing);
460
+ applyConfiguration(); applyFacing(); applySerial(); applyScene();
461
+
462
+ addEventListener('resize', ()=>{
463
+ camera.aspect = innerWidth/innerHeight; camera.updateProjectionMatrix();
464
+ renderer.setSize(innerWidth, innerHeight);
465
+ });
466
+
467
+ (function animate(){
468
+ requestAnimationFrame(animate);
469
+ cat.position.lerp(targetPos, 0.08);
470
+ cat.rotation.y += (targetRotY - cat.rotation.y) * 0.08;
471
+ controls.update();
472
+ renderer.render(scene, camera);
473
+ })();
474
+ </script>
475
+ </body>
476
+ </html>
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ gradio[mcp]
2
+ huggingface_hub
3
+ requests
4
+ numpy
5
+ scipy
6
+ trimesh>=4.6
7
+ fast-simplification
8
+ matplotlib
9
+ usd-core
visuallearning-handover.html ADDED
@@ -0,0 +1,264 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ <!DOCTYPE html>
3
+ <html lang="en">
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>VisualLearningIdea — Setup Handover</title>
8
+ <style>
9
+ :root{
10
+ --bg:#0f1117; --panel:#171a23; --panel2:#1d212e; --border:#2a3040;
11
+ --text:#e6e9f0; --muted:#9aa3b5; --accent:#4f9cf9; --accent2:#7ee0a3;
12
+ --warn:#f0b429; --danger:#f26d6d; --code:#12141c;
13
+ }
14
+ *{box-sizing:border-box}
15
+ body{margin:0;background:var(--bg);color:var(--text);font:15px/1.6 -apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;padding:0 0 80px}
16
+ .wrap{max-width:960px;margin:0 auto;padding:32px 24px}
17
+ header{border-bottom:1px solid var(--border);padding-bottom:24px;margin-bottom:32px}
18
+ h1{font-size:26px;margin:0 0 6px}
19
+ .sub{color:var(--muted);font-size:14px}
20
+ .badge{display:inline-block;background:var(--panel2);border:1px solid var(--border);border-radius:6px;padding:2px 10px;font-size:12px;color:var(--muted);margin-right:6px;margin-top:8px}
21
+ h2{font-size:20px;margin:40px 0 12px;padding-bottom:6px;border-bottom:1px solid var(--border)}
22
+ h3{font-size:16px;margin:24px 0 8px;color:var(--accent)}
23
+ p{margin:8px 0}
24
+ .panel{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:16px 20px;margin:16px 0}
25
+ .warn{border-left:4px solid var(--warn)}
26
+ .ok{border-left:4px solid var(--accent2)}
27
+ .info{border-left:4px solid var(--accent)}
28
+ table{width:100%;border-collapse:collapse;margin:14px 0;font-size:14px}
29
+ th,td{text-align:left;border:1px solid var(--border);padding:8px 12px;vertical-align:top}
30
+ th{background:var(--panel2);color:var(--muted);font-weight:600}
31
+ code{background:var(--code);border:1px solid var(--border);border-radius:4px;padding:1px 6px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;color:#a8d3ff}
32
+ pre{background:var(--code);border:1px solid var(--border);border-radius:8px;padding:14px 16px;overflow-x:auto;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;line-height:1.55;color:#d7e3f4}
33
+ pre code{background:none;border:none;padding:0;color:inherit}
34
+ .task{display:flex;gap:10px;align-items:flex-start;padding:8px 0;border-bottom:1px dashed var(--border)}
35
+ .task:last-child{border-bottom:none}
36
+ .task input{margin-top:4px;accent-color:var(--accent2)}
37
+ .task label{cursor:pointer}
38
+ .task .why{color:var(--muted);font-size:13px;display:block}
39
+ .phase{color:var(--muted);font-size:12px;letter-spacing:1px;text-transform:uppercase;margin:20px 0 4px}
40
+ a{color:var(--accent);text-decoration:none}
41
+ a:hover{text-decoration:underline}
42
+ ul{margin:8px 0;padding-left:22px}
43
+ .kicker{color:var(--accent2);font-weight:600}
44
+ .flow{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:14px 0}
45
+ .flow .node{background:var(--panel2);border:1px solid var(--border);border-radius:8px;padding:8px 12px;font-size:13px}
46
+ .flow .arrow{color:var(--muted)}
47
+ footer{margin-top:48px;color:var(--muted);font-size:12px;border-top:1px solid var(--border);padding-top:16px}
48
+ </style>
49
+ </head>
50
+ <body>
51
+ <div class="wrap">
52
+
53
+ <header>
54
+ <h1>🐠 VisualLearningIdea — Setup Handover</h1>
55
+ <div class="sub">Handover doc for Claude Code / Codex · Owner: <b>KwabsHug</b> · Prepared 2026-10-23</div>
56
+ <span class="badge">hf.co/spaces/KwabsHug/VisualLearningIdea</span>
57
+ <span class="badge">SDK: Gradio 6.27.0</span>
58
+ <span class="badge">Python 3.12</span>
59
+ <span class="badge">MCP target</span>
60
+ </header>
61
+
62
+ <h2>1. Mission</h2>
63
+ <div class="panel info">
64
+ <p><span class="kicker">Core idea:</span> treat the <b>morphism between representations as a language</b> — i.e., comprehension. A natural-language sentence goes in; the system returns a <b>serial, ordered list of decision items</b>; that serial form is discussable/refinable in conversation; and it ultimately resolves into a <b>natural 3D configuration</b>.</p>
65
+ <p><span class="kicker">The comprehension gradient (why this matters):</span> take <i>"the cat sat and the sun shone."</i></p>
66
+ <ul>
67
+ <li><b>Reading is decision-free</b> — comprehension happens implicitly. Every mini-decision (how big is the cat? facing where? how high is the sun?) is resolved silently by convention, defaults, and priors. Nothing surfaces.</li>
68
+ <li><b>Drawing forces decisions to surface</b> — you cannot make a mark without committing to position, pose, scale, proportion. The drawing is a ledger of forced decisions.</li>
69
+ <li><b>3D goes further: it allows configurations, not separate images.</b> A 3D scene is a web of relations where decisions propagate. A cat on a ground plane plus a sun in the sky become <i>coupled</i>: the sun's elevation fixes the shadow's direction; the cat's orientation determines the shadow's shape; support and occlusion relations emerge that the sentence never stated and no individual 2D view could contain. The 3D result holds information beyond any single rendering of it.</li>
70
+ </ul>
71
+ <pre><code>Sentence → DECISION LEDGER (serial, ordered, dependency-aware, with unresolved choices surfaced)
72
+ → [discussion / refinement loop: interrogate the unresolved decisions]
73
+ → 3D CONFIGURATION (the UNDERSTANDING PROXY: relational scene — geometry + light +
74
+ support + orientation — not separate images)
75
+ → MATERIALIZATION (the LAST step: physical items for each word / the whole scene —
76
+ papercraft nets, origami crease patterns, printable parts,
77
+ electronic components + wiring, or a mix)</code></pre>
78
+ <p>The serial order is not cosmetic — it is the <b>narrative order the sentence itself imposes</b> (and folding is inherently sequential too). The item list IS the intermediate representation (IR) of the language: a record of the decisions that reading hides. The 3D configuration must be derivable from the serial form alone — and it must encode <b>relations between items</b>, not just items.</p>
79
+ <p><span class="kicker">Materialization is downstream of understanding, not part of it:</span> origami/papercraft is a <b>reality output</b> — one fabrication channel chosen <i>after</i> the 3D proxy has done its job of making comprehension inspectable. The same ledger can materialize differently: pure papercraft (fold a net per item), an Arduino project with papercraft/origami props (electronic components for behavior + folded sheets for form), 3D-printed parts, or a mix. The ledger must therefore stay materialization-agnostic; materialization routes are chosen per item in the final stage.</p>
80
+ </div>
81
+
82
+ <h2>2. Current state (verified 2026-10-23)</h2>
83
+ <table>
84
+ <tr><th>Item</th><th>Status</th></tr>
85
+ <tr><td>Space repo exists</td><td><code>hf://spaces/KwabsHug/VisualLearningIdea</code></td></tr>
86
+ <tr><td>README</td><td><code>sdk: gradio</code>, <code>sdk_version: 6.27.0</code>, <code>python_version: '3.12'</code>, <code>app_file: app.py</code>, pinned: false. Short description: "Visualisation and speed of input attempt" (may be updated).</td></tr>
87
+ <tr><td>app.py</td><td><b>Does not exist yet</b> — repo contains only README + .gitattributes</td></tr>
88
+ <tr><td>Owner account</td><td>PRO account → ZeroGPU available (40 min/day quota), can host up to 10 ZeroGPU Spaces</td></tr>
89
+ <tr><td>Existing assets to reuse</td><td>Sibling Space <code>KwabsHug/GameConfigIdea</code> (Gradio, ZeroGPU, uses gradio_client + InferenceClient + local Kokoro) — reference for patterns that already work on this account</td></tr>
90
+ </table>
91
+
92
+ <h2>3. Target tool surface (MCP)</h2>
93
+ <p>The Space must launch with <code>demo.launch(mcp_server=True)</code> so each public function becomes an MCP tool callable from HuggingChat / Claude / Codex. Tool exposure is controlled by <b>type hints + docstrings</b> (they become the tool description the LLM reads). Set <code>api_name=False</code> on every helper function that should NOT become a tool.</p>
94
+
95
+ <h3>Planned tools (v1 contract)</h3>
96
+ <pre><code>def parse_sentence_to_items(sentence: str) -> str:
97
+ """Decompose a natural-language sentence into a serial, ordered ledger
98
+ (the intermediate representation of the morphism). The output drives BOTH
99
+ views of the reference UI: a flat serial token reading and a 3D scene.
100
+ Structure (shaped by the cat-sun reference build — see section 9):
101
+ tokens: [{word, index, commits: [item_indices], opens: [question_ids]}]
102
+ — per-token attribution. Determiners commit nothing but open
103
+ unresolved "?" entries. One word may commit several items
104
+ (lexical compression: "sat" → surface + pose + support).
105
+ items: [{index, kind, description,
106
+ decision: {chosen, alternatives_considered},
107
+ params, depends_on, stage_direction}]
108
+ — stage_direction is the reveal payload the renderer plays:
109
+ {action: "spawn"|"move"|"orient"|"couple"|"mark",
110
+ target, from, to, couples: [item_indices], ...}.
111
+ Without it the player must be hand-coded per sentence.
112
+ unresolved:[{id, token_index, question, default_chosen, alternatives}]
113
+ — the discussion loop's agenda.
114
+ Args:
115
+ sentence: A plain-English sentence or scene description.
116
+ Returns:
117
+ JSON string with keys: tokens, items, unresolved.
118
+ """
119
+
120
+ def items_to_fold(items_json: str) -> str:
121
+ """Convert serial construction items into a crease pattern in FOLD format.
122
+ Args:
123
+ items_json: JSON string produced by parse_sentence_to_items.
124
+ Returns:
125
+ JSON string: {"fold": {...FOLD document...}, "warnings": [...]}
126
+ """
127
+
128
+ def simulate_fold(fold_json: str) -> str:
129
+ """Simulate folding a FOLD-format crease pattern and export the folded
130
+ 3D state as OBJ/STL (plus a foldability report).
131
+ Args:
132
+ fold_json: FOLD-format JSON (from items_to_fold or user upload URL).
133
+ Returns:
134
+ File path/URL of the folded mesh + foldability diagnostics.
135
+ """
136
+
137
+ def unfold_mesh_to_papercraft(mesh_url: str) -> str:
138
+ """Reverse morphism: unfold a 3D mesh (STL/OBJ/PLY/OFF URL) into a
139
+ 2D papercraft net (SVG files with fold direction markings).
140
+ Args:
141
+ mesh_url: Public http(s) URL to the mesh file.
142
+ Returns:
143
+ File paths/URLs of the generated SVG components.
144
+ """
145
+
146
+ def materialize_scene(items_json: str, route: str = "papercraft") -> str:
147
+ """Materialize a validated scene ledger into physical outputs — the LAST
148
+ step of the morphism: real items for each word / the whole scene.
149
+ Args:
150
+ items_json: JSON string produced by parse_sentence_to_items (or refined
151
+ via the discussion loop).
152
+ route: fabrication route — "papercraft" (unfold nets from the 3D
153
+ configuration and lay out sheets), "origami" (crease patterns),
154
+ "print3d" (STL per item), "electronics" (component list + wiring
155
+ notes for Arduino-type props), or "mixed".
156
+ Returns:
157
+ JSON string: {"parts": [{item_index, route, files: [urls], notes}],
158
+ "bom": [...bill of materials for electronic/physical components...]}
159
+ """</code></pre>
160
+
161
+ <div class="panel warn">
162
+ <b>File I/O rule:</b> MCP clients pass files as <b>public http(s) URLs</b>, never local paths. Every tool must download its input itself and return files (Gradio converts outputs to URLs automatically). Return formats: JSON strings or file outputs — avoid bare numpy arrays.
163
+ </div>
164
+
165
+ <h2>4. Architecture</h2>
166
+ <div class="flow">
167
+ <div class="node">Sentence</div><div class="arrow">→</div>
168
+ <div class="node">parse_sentence_to_items<br><small>LLM (InferenceClient) → serial IR</small></div><div class="arrow">→</div>
169
+ <div class="node">items_to_fold<br><small>deterministic → FOLD doc</small></div><div class="arrow">→</div>
170
+ <div class="node">simulate_fold<br><small>OrigamiSimulator solver → STL/OBJ</small></div>
171
+ </div>
172
+ <div class="flow">
173
+ <div class="node">Mesh (STL/OBJ URL)</div><div class="arrow">→</div>
174
+ <div class="node">unfold_mesh_to_papercraft<br><small>paperfoldmodels → SVGs</small></div>
175
+ </div>
176
+ <p><b>FOLD format is the interchange hub</b> (OrigamiSimulator reads/writes it; rtori converts it; Ori Studio imports/exports it). Every pipeline becomes "X → FOLD → Y".</p>
177
+
178
+ <h2>5. Phased work plan</h2>
179
+
180
+ <div class="phase">Phase 0 — Scaffolding</div>
181
+ <div class="task"><input type="checkbox" id="t1"><label for="t1"><b>Create <code>app.py</code></b> with a trivial Gradio UI + <code>demo.launch(mcp_server=True)</code><span class="why">Verify MCP badge appears on the Space page before adding real logic. Check endpoint: <code>https://kwabshug-visuallearningidea.hf.space/gradio_api/mcp/schema</code></span></label></div>
182
+ <div class="task"><input type="checkbox" id="t2"><label for="t2"><b>Create <code>requirements.txt</code></b>: <code>gradio[mcp]</code>, <code>numpy</code>, <code>networkx</code>, <code>openmesh</code>, <code>huggingface_hub</code><span class="why">openmesh needs CMake + C++ at build time; if pip build fails on Spaces, pin a prebuilt wheel or vendor OpenMesh bindings (see pitfalls)</span></label></div>
183
+ <div class="task"><input type="checkbox" id="t3"><label for="t3"><b>Vendor or pip-install <code>paperfoldmodels</code></b> (felixfeliz/paperfoldmodels, MIT) — copy the module into the repo for reproducibility<span class="why">3 deps only: numpy, OpenMesh, networkx. Algorithm: MST of dual graph → unfold → cut self-intersections</span></label></div>
184
+
185
+ <div class="phase">Phase 1 — Reverse morphism first (easiest, no GPU)</div>
186
+ <div class="task"><input type="checkbox" id="t4"><label for="t4"><b>Implement <code>unfold_mesh_to_papercraft(mesh_url)</code></b><span class="why">Download mesh URL → OpenMesh load → unfold → return SVG file(s). Test input: Stanford bunny STL. Prefer OBJ polygonal input over STL (see pitfalls)</span></label></div>
187
+ <div class="task"><input type="checkbox" id="t5"><label for="t5"><b>Acceptance test:</b> call the tool via MCP from HuggingChat with a bunny OBJ URL; receive valid SVG(s)<span class="why">This alone makes the Space useful and proves the whole MCP chain</span></label></div>
188
+
189
+ <div class="phase">Phase 2 — The language: sentence → serial items</div>
190
+ <div class="task"><input type="checkbox" id="t6"><label for="t6"><b>Define the serial-item IR schema</b> (JSON) — a <b>decision ledger</b>, not an assembly list. Each item: <code>{index, kind, description, decision: {chosen, alternatives_considered}, params, depends_on}</code><span class="why">kinds v1: <code>sheet</code>, <code>fold</code> (mountain/valley, angle), <code>join</code> (glue/tab), <code>attach</code>, <code>repeat</code> — plus <b>scene-configuration kinds</b>: <code>ground_plane</code>, <code>light_source</code> (position/elevation), <code>pose</code>, <code>orient</code>, <code>relation</code> (<code>on</code>, <code>near</code>, <code>facing</code>, <code>under-light-of</code>, <code>supported-by</code>). Keep the vocabulary SMALL — it is the grammar of the language. Every implicit choice the sentence skipped becomes an explicit decision item or an <code>unresolved</code> entry.</span></label></div>
191
+ <div class="task"><input type="checkbox" id="t7"><label for="t7"><b>Implement <code>parse_sentence_to_items</code></b> using <code>InferenceClient</code> (pattern already proven in GameConfigIdea)<span class="why">Prompt the LLM to emit ONLY the IR schema; return <code>unresolved</code> ambiguities so the chat can discuss them — this is the "discussion → more natural 3D" loop entry point</span></label></div>
192
+ <div class="task"><input type="checkbox" id="t8"><label for="t8"><b>Unit-test determinism:</b> same sentence + same seed → identical item list</td></label></div>
193
+ <div class="task"><input type="checkbox" id="t16"><label for="t16"><b>Add a ledger validator step</b> before items_to_fold / scene build<span class="why">Lesson from the reference build: the hand-written ledger missed a propagation decision (sky color stayed daytime-blue when sun elevation dropped to 5°). An LLM-generated ledger will have similar gaps. The validator checks that every parameter affected by a coupling (item 7) is either declared in some item's stage_direction or explicitly marked independent — and fails loudly otherwise</span></label></div>
194
+
195
+ <div class="phase">Phase 3 — Serial items → FOLD → 3D</div>
196
+ <div class="task"><input type="checkbox" id="t9"><label for="t9"><b>Implement <code>items_to_fold</code></b>: deterministic mapper item-kind → FOLD geometry<span class="why">Start with prisms/boxes/waterbomb base; grow the mapping table item-by-item</span></label></div>
197
+ <div class="task"><input type="checkbox" id="t10"><label for="t10"><b>Implement <code>simulate_fold</code></b>: port or wrap the OrigamiSimulator FOLD solver (amandaghassei/OrigamiSimulator, MIT) — or shell out to <code>rtori-cli simulate</code><span class="why">GPU shaders → consider ZeroGPU <code>@spaces.GPU(duration=…)</code> with realistic durations; CPU fallback acceptable for v1</span></label></div>
198
+ <div class="task"><input type="checkbox" id="t11"><label for="t11"><b>Acceptance tests:</b> (a) "make a box with a lid" → items → FOLD → folded OBJ opens in a viewer; (b) <b>"the cat sat and the sun shone"</b> → decision ledger containing: <code>ground_plane</code> (implicit!), cat <code>sheet/form</code> + <code>pose: sat</code> + <code>position</code> + <code>orient/facing</code>, <code>light_source: sun</code> with elevation, and a <code>relation</code> coupling sun↔cat that derives shadow direction in the 3D stage. The <code>unresolved</code> list must name the skipped mini-decisions (which cat form? facing where? sun elevation?) — that list is the discussion loop's agenda<span class="why">Test (b) is the conceptual acceptance test: if the 3D stage can compute shadow direction FROM the ledger's relations without extra prompt information, the configuration idea is working</span></label></div>
199
+
200
+ <div class="phase">Phase 4 — Materialization (reality output)</div>
201
+ <div class="task"><input type="checkbox" id="t13"><label for="t13"><b>Implement <code>materialize_scene</code> route "papercraft" first</b>: take the 3D configuration (from simulate_fold / scene stage), run unfold per item, lay out sheets, return SVGs<span class="why">This reuses Phase 1's unfolding work — but now driven by the scene, not a raw mesh URL. The 3D proxy output feeds the fabricator</span></label></div>
202
+ <div class="task"><input type="checkbox" id="t14"><label for="t14"><b>Extend the IR with a <code>component</code> kind</b> for physical/electronic parts implied by the scene (LED, servo, motor, buzzer…)<span class="why">Enables the Arduino-with-papercraft-props case: the ledger names components ("glowing collar" → LED + battery), materialize_scene emits a BOM + wiring notes, and papercraft props come from the geometry items</span></label></div>
203
+ <div class="task"><input type="checkbox" id="t15"><label for="t15"><b>Acceptance test:</b> "the cat sat and the sun shone" with route "papercraft" → one papercraft sheet-set covering cat + ground plane (+ optional sun disc), with fold markings; route "mixed" additionally returns a BOM if a light-emitting variant is requested</label></div>
204
+
205
+ <div class="phase">Phase 5 — Hub integration</div>
206
+ <div class="task"><input type="checkbox" id="t12"><label for="t12"><b>Add VisualLearningIdea to HF MCP settings</b> (huggingface.co/settings/mcp) and/or a future ToolHub Space alongside <code>GameConfigIdea</code> (gradio_client proxy) and <code>Language-Learn-Idea</code> (Docker — REST proxy)<span class="why">Once registered, chat agents can chain: sentence → items → FOLD → 3D → unfold → papercraft SVG</span></label></div>
207
+
208
+ <h2>6. Known pitfalls (discovered this session)</h2>
209
+ <div class="panel">
210
+ <ul>
211
+ <li><b>Coplanar triangles are NOT merged</b> by paperfoldmodels — an octagon face becomes 6 triangles with useless virtual fold lines (<a href="https://github.com/felixfeliz/paperfoldmodels/issues/4">repo issue #4</a>). <b>Accept OBJ with polygonal facets first</b>; STL suffers most. Coplanar merging is a later enhancement (reference: osresearch/papercraft unfold.c).</li>
212
+ <li><b>No glue tabs</b> — paperfoldmodels only flattens. Tabs are a separate step (Inkscape Tabgen pattern) — plan as a <code>kind: join</code> item in the IR.</li>
213
+ <li><b>OpenMesh build</b> — <code>pip install openmesh</code> requires cmake + g++ + pybind11. If the Space build fails, pin prebuilt binaries (Inkscape MightyScape docs document per-OS workarounds).</li>
214
+ <li><b>Tool flooding</b> — every API endpoint becomes an MCP tool. Explicitly set <code>api_name=False</code> on all internal helpers. Keep the public tool count ≤ 6.</li>
215
+ <li><b>ZeroGPU quota attribution</b> — GPU calls inside the Space consume the caller's quota (PRO = 40 min/day). Declare realistic <code>@spaces.GPU(duration=N)</code> values; quota is reserved per <i>requested</i> duration, not actual runtime. Reset is 24h from first GPU use.</li>
216
+ <li><b>Return JSON strings, not objects</b> — Gradio MCP converts outputs; strings and files are the reliable formats.</li>
217
+ <li><b>Queue</b> — set <code>queue=True</code> on long-running events (progress notifications), <code>queue=False</code> for fast ones (10× throughput).</li>
218
+ </ul>
219
+ </div>
220
+
221
+ <h2>7. Key references</h2>
222
+ <table>
223
+ <tr><th>Resource</th><th>URL</th></tr>
224
+ <tr><td>Gradio MCP server guide</td><td><a href="https://gradio.app/guides/building-mcp-server-with-gradio">gradio.app/guides/building-mcp-server-with-gradio</a></td></tr>
225
+ <tr><td>Spaces as MCP servers (HF docs)</td><td><a href="https://huggingface.co/docs/hub/en/spaces-mcp-servers">huggingface.co/docs/hub/en/spaces-mcp-servers</a></td></tr>
226
+ <tr><td>paperfoldmodels</td><td><a href="https://github.com/felixfeliz/paperfoldmodels">github.com/felixfeliz/paperfoldmodels</a></td></tr>
227
+ <tr><td>OrigamiSimulator (FOLD solver)</td><td><a href="https://github.com/amandaghassaei/OrigamiSimulator/">github.com/amandaghassaei/OrigamiSimulator</a></td></tr>
228
+ <tr><td>osresearch/papercraft (coplanar reference)</td><td><a href="https://github.com/osresearch/papercraft">github.com/osresearch/papercraft</a></td></tr>
229
+ <tr><td>rtori (FOLD convert/simulate CLI)</td><td><a href="https://github.com/origami-code/rtori">github.com/origami-code/rtori</a></td></tr>
230
+ <tr><td>Ori Studio (UX reference)</td><td><a href="https://oristudio.dev/">oristudio.dev</a></td></tr>
231
+ </table>
232
+
233
+ <h2>8. Verification checklist for the coding agent</h2>
234
+ <div class="panel ok">
235
+ <ol>
236
+ <li>Space builds and UI loads at <code>kwabshug-visuallearningidea.hf.space</code></li>
237
+ <li><code>/gradio_api/mcp/schema</code> lists exactly the 4 planned tools (no helper leakage)</li>
238
+ <li><code>unfold_mesh_to_papercraft</code> on a bunny OBJ returns valid SVG(s)</li>
239
+ <li><code>parse_sentence_to_items("make a box with a lid")</code> returns valid serial IR with ≥1 <code>unresolved</code> ambiguity note</li>
240
+ <li><code>items_to_fold</code> on that IR produces a FOLD doc that <code>simulate_fold</code> accepts</li>
241
+ <li>Tool appears in HuggingChat MCP panel after adding the Space in settings</li>
242
+ </ol>
243
+ </div>
244
+
245
+ <h2>9. Lessons from the reference UI build (serial player + scene artifact)</h2>
246
+ <div class="panel">
247
+ <p>The cat-sun demo (serial reading tab + scene tab, Three.js) was built end-to-end in the preview sandbox. These lessons are now folded into the contract above:</p>
248
+ <ul>
249
+ <li><b>The serial reading is the product, not a sidebar.</b> The flat token→item timeline ("the → ?", "cat = model", "sat → pose + surface + support") is the deliverable format of <code>parse_sentence_to_items</code>; the 3D scene is downstream. Both views render from the <b>same ledger</b>; each view has its own independent stepper. Default view: serial.</li>
250
+ <li><b>Every item needs a <code>stage_direction</code>.</b> The demo's choreography was hand-coded — possible only because the sentence was known in advance. A generic player requires each item to carry its reveal payload (spawn / move / orient / couple / mark). Now in the tool contract.</li>
251
+ <li><b>Model builders must be factories, not sculptures.</b> The demo shares <code>buildForm / buildPose / buildSupport</code> between the live scene and per-word snapshot images. The Space equivalent must be data-driven parameterization (item params → geometry). Otherwise every new sentence requires hand-modeling — <b>the biggest current gap</b> and the reason Phase 2's IR schema must be designed for buildability, not just describability.</li>
252
+ <li><b>Per-word model snapshots are cheap and powerful.</b> Static images rendered once via an offscreen renderer give each token card a visible model (form-only cat for "cat", posed cat on ground for "sat", sun disc for "sun", full shadowed configuration for "shone"). The Space can pre-render snapshots server-side, or the static front end renders them client-side from the same builders.</li>
253
+ <li><b>Attribute commits to tokens.</b> "sat" commits three items; determiners commit none but open unresolved "?" entries. Without per-token attribution the serial view is unreadable — this is why <code>tokens</code> is now a first-class output key.</li>
254
+ <li><b>Separate "exists" from "relates".</b> The demo deliberately pauses between "sun exists" (item 6) and "shone → coupling ON" (item 7): light without relation is not a configuration. Keep these as distinct items — the pause is where 2D and 3D thinking diverge.</li>
255
+ <li><b>LLM ledgers will be incomplete → validator needed.</b> The demo's sky color did not follow sun elevation. A schema check catches this deterministically; hence the new validator task.</li>
256
+ <li><b>Unresolved "?" entries are the discussion agenda.</b> Each determiner opened a question with a recorded default and alternatives — that list is what the conversation loop interrogates before trusting the 3D.</li>
257
+ <li><b>Front end is covered; deploy it as <code>sdk: static</code>.</b> The player UI is proven in-browser (Three.js via CDN works in both the preview sandbox and static Spaces, which are free and run no compute). The Space's only job is to emit the items the browser cannot make. See companion doc: General Tool Space handover (artifact: toolhub-handover).</li>
258
+ </ul>
259
+ </div>
260
+
261
+ <footer>Generated from a working session between KwabsHug and HuggingChat · Facts verified against live HF Hub state on 2026-10-23 · Revised after reference UI build · Checkbox state is session-local (reloads reset it)</footer>
262
+ </div>
263
+ </body>
264
+ </html>
vli/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """English -> 3D translation: lexicon, grammar, assets, scene, simplification."""
vli/assets.py ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Target-language lexicon entries: load a ledger item's chosen asset as a mesh.
2
+
3
+ Every mesh comes back normalized the same way, whatever its tier:
4
+ Y-up (glTF convention), metres, centred on x/z, resting on y = 0.
5
+ """
6
+ import glob
7
+ import gzip
8
+ import json
9
+ import os
10
+ from functools import lru_cache
11
+
12
+ import numpy as np
13
+ import trimesh
14
+ from huggingface_hub import hf_hub_download, snapshot_download
15
+
16
+ from .grammar import DEFAULT_HEIGHT_M, NOMINAL_HEIGHT_M, candidate_label
17
+ from .lexicon import LEXICON
18
+
19
+ SCENE_FACE_BUDGET = 20000 # per object, for the configured scene
20
+
21
+
22
+ class UnusableAsset(Exception):
23
+ """License or content rules out this candidate; the next one is tried."""
24
+
25
+
26
+ def license_ok(license_str):
27
+ s = (license_str or "").lower()
28
+ return bool(s) and "nc" not in s.replace("-", " ").split() and "nd" not in s.replace("-", " ").split()
29
+
30
+
31
+ def _normalize(mesh, meters_per_unit=1.0, z_up=False):
32
+ mesh = mesh.copy()
33
+ mesh.apply_scale(meters_per_unit)
34
+ if z_up:
35
+ mesh.apply_transform(trimesh.transformations.rotation_matrix(-np.pi / 2, [1, 0, 0]))
36
+ lo, hi = mesh.bounds
37
+ mesh.apply_translation([-(lo[0] + hi[0]) / 2, -lo[1], -(lo[2] + hi[2]) / 2])
38
+ return mesh
39
+
40
+
41
+ def _flatten(loaded):
42
+ if isinstance(loaded, trimesh.Scene):
43
+ to_geometry = getattr(loaded, "to_geometry", None)
44
+ loaded = to_geometry() if to_geometry else loaded.dump(concatenate=True)
45
+ if hasattr(loaded.visual, "to_color"):
46
+ loaded.visual = loaded.visual.to_color()
47
+ return loaded
48
+
49
+
50
+ # ---------------- simready (OpenUSD, metric) ----------------
51
+
52
+ def _simready_files(cand):
53
+ """Geometry + thumbnail only: textures are ~98% of a package (~1 GB)."""
54
+ name = cand["name"]
55
+ root = snapshot_download(
56
+ repo_id=cand["repo"], repo_type="dataset", revision=cand["revision"],
57
+ allow_patterns=[f"{name}/com.nvidia.simready.packaging.json",
58
+ f"{name}/simready_usd/*.usd", f"{name}/simready_usd/*.usda",
59
+ f"{name}/simready_usd/*.usdc", f"{name}/simready_usd/.thumbs/256x256/*.usd.png"])
60
+ return os.path.join(root, name)
61
+
62
+
63
+ def _usd_mesh(usd_path):
64
+ from pxr import Usd, UsdGeom
65
+
66
+ stage = Usd.Stage.Open(usd_path)
67
+ mpu = UsdGeom.GetStageMetersPerUnit(stage) or 0.01
68
+ z_up = UsdGeom.GetStageUpAxis(stage) == UsdGeom.Tokens.z
69
+ cache = UsdGeom.XformCache()
70
+ parts = []
71
+ for prim in Usd.PrimRange.Stage(stage, Usd.TraverseInstanceProxies()):
72
+ if not prim.IsA(UsdGeom.Mesh):
73
+ continue
74
+ path = str(prim.GetPath()).lower()
75
+ if "collision" in path or "collider" in path:
76
+ continue
77
+ img = UsdGeom.Imageable(prim)
78
+ if img.ComputeVisibility() == UsdGeom.Tokens.invisible or img.ComputePurpose() in (
79
+ UsdGeom.Tokens.guide, UsdGeom.Tokens.proxy):
80
+ continue
81
+ geom = UsdGeom.Mesh(prim)
82
+ points = geom.GetPointsAttr().Get()
83
+ counts = geom.GetFaceVertexCountsAttr().Get()
84
+ indices = geom.GetFaceVertexIndicesAttr().Get()
85
+ if points is None or counts is None or len(points) == 0 or len(counts) == 0:
86
+ continue
87
+ pts = np.asarray(points, dtype=np.float64)
88
+ xf = np.array(cache.GetLocalToWorldTransform(prim), dtype=np.float64) # row-vector convention
89
+ pts = (np.c_[pts, np.ones(len(pts))] @ xf)[:, :3]
90
+ idx = np.asarray(indices, dtype=np.int64)
91
+ counts = np.asarray(counts, dtype=np.int64)
92
+ if np.all(counts == 3):
93
+ faces = idx.reshape(-1, 3)
94
+ else:
95
+ faces, at = [], 0
96
+ for c in counts: # fan-triangulate polygons
97
+ faces.extend((idx[at], idx[at + k], idx[at + k + 1]) for k in range(1, c - 1))
98
+ at += c
99
+ faces = np.asarray(faces, dtype=np.int64)
100
+ parts.append(trimesh.Trimesh(pts, faces, process=False))
101
+ if not parts:
102
+ raise UnusableAsset(f"no visible mesh geometry in {os.path.basename(usd_path)}")
103
+ return _normalize(trimesh.util.concatenate(parts), mpu, z_up)
104
+
105
+
106
+ def load_simready(cand):
107
+ folder = _simready_files(cand)
108
+ with open(os.path.join(folder, "com.nvidia.simready.packaging.json"), encoding="utf-8") as f:
109
+ lic = json.load(f).get("license")
110
+ if not license_ok(lic):
111
+ raise UnusableAsset(f"license {lic!r}")
112
+ roots = sorted(glob.glob(os.path.join(folder, "simready_usd", "*.usd*")))
113
+ if not roots:
114
+ raise UnusableAsset("no root USD file")
115
+ thumbs = glob.glob(os.path.join(folder, "simready_usd", ".thumbs", "256x256", "*.usd.png"))
116
+ return _usd_mesh(roots[0]), {"license": lic, "metric": True, "thumbnail": thumbs[0] if thumbs else None}
117
+
118
+
119
+ # ---------------- objaverse (GLB, no real-world scale) ----------------
120
+
121
+ def _objaverse_license(uid, shard):
122
+ path = hf_hub_download("allenai/objaverse", f"metadata/{shard}.json.gz", repo_type="dataset")
123
+ with gzip.open(path, "rt", encoding="utf-8") as f:
124
+ return json.load(f).get(uid, {}).get("license")
125
+
126
+
127
+ def load_objaverse(cand):
128
+ rel = LEXICON.object_path(cand["uid"]) # glbs/000-023/<uid>.glb
129
+ lic = _objaverse_license(cand["uid"], rel.split("/")[1])
130
+ if not license_ok(lic):
131
+ raise UnusableAsset(f"license {lic!r}")
132
+ path = hf_hub_download("allenai/objaverse", rel, repo_type="dataset")
133
+ mesh = _flatten(trimesh.load(path, force="scene"))
134
+ if len(mesh.faces) == 0:
135
+ raise UnusableAsset("empty mesh")
136
+ return _normalize(mesh), {"license": lic, "metric": False, "thumbnail": None}
137
+
138
+
139
+ # ---------------- construction tiers ----------------
140
+
141
+ def load_primitive(cand, word):
142
+ h = NOMINAL_HEIGHT_M.get(word, DEFAULT_HEIGHT_M)
143
+ shape = cand.get("shape", "box")
144
+ if shape == "sphere":
145
+ mesh = trimesh.creation.icosphere(subdivisions=3, radius=h / 2)
146
+ elif shape in ("cylinder", "cone"):
147
+ make = trimesh.creation.cylinder if shape == "cylinder" else trimesh.creation.cone
148
+ mesh = make(radius=h / 2, height=h, sections=32)
149
+ mesh.apply_transform(trimesh.transformations.rotation_matrix(-np.pi / 2, [1, 0, 0])) # axis z -> y
150
+ elif shape == "plane":
151
+ mesh = trimesh.creation.box([4 * h, 0.01, 4 * h])
152
+ else:
153
+ mesh = trimesh.creation.box([h, h, h])
154
+ return _normalize(mesh), {"license": None, "metric": False, "thumbnail": None}
155
+
156
+
157
+ def load_generation(cand, word):
158
+ raise UnusableAsset("generation tier is not wired up yet")
159
+
160
+
161
+ # ---------------- entry points ----------------
162
+
163
+ @lru_cache(maxsize=64)
164
+ def _load(key):
165
+ cand = json.loads(key)
166
+ word = cand.pop("_word")
167
+ source = cand["source"]
168
+ if source == "simready":
169
+ return load_simready(cand)
170
+ if source == "objaverse":
171
+ return load_objaverse(cand)
172
+ if source == "primitive":
173
+ return load_primitive(cand, word)
174
+ return load_generation(cand, word)
175
+
176
+
177
+ def load_candidate(cand, word):
178
+ mesh, info = _load(json.dumps({**cand, "_word": word}, sort_keys=True))
179
+ return mesh.copy(), info
180
+
181
+
182
+ def settle(item):
183
+ """Make the item's chosen candidate loadable, skipping ones that can't be used.
184
+
185
+ Returns the full-detail mesh. Skips are recorded on the ledger so the
186
+ discussion can see what was passed over and why.
187
+ """
188
+ dec = item["decision"]
189
+ while True:
190
+ try:
191
+ mesh, info = load_candidate(dec["chosen"], item["word"])
192
+ break
193
+ except UnusableAsset as e:
194
+ dec["skipped"].append({"candidate": candidate_label(dec["chosen"]), "reason": str(e)})
195
+ if not dec["alternatives"]:
196
+ raise UnusableAsset(f"no usable {dec['tier']} asset for '{item['word']}' ({e})")
197
+ dec["chosen"] = dec["alternatives"].pop(0)
198
+ item["license"] = info["license"]
199
+ item["thumbnail"] = info["thumbnail"]
200
+ height = item["height_m"]
201
+ native = float(mesh.extents[1]) or 1.0
202
+ if height["source"] == "user":
203
+ pass
204
+ elif info["metric"]:
205
+ height.update(value=round(native, 3), source="asset")
206
+ else:
207
+ height.update(value=NOMINAL_HEIGHT_M.get(item["word"], DEFAULT_HEIGHT_M), source="default")
208
+ mesh.apply_scale(height["value"] / native)
209
+ return mesh
210
+
211
+
212
+ def scene_mesh(mesh):
213
+ """A lighter copy for the configured scene; the ladder keeps full detail."""
214
+ from .simplify import decimate
215
+ return decimate(mesh, SCENE_FACE_BUDGET) if len(mesh.faces) > SCENE_FACE_BUDGET else mesh
vli/grammar.py ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Source-language side: tokenize a controlled English sentence and build the
2
+ serial ledger (the decisions reading hides, in reading order).
3
+
4
+ Nouns come from the lexicon, verbs are relations between meshes, and glue
5
+ words (the, a, and) commit no geometry -- determiners open a "which?" question.
6
+ A word with no asset is not available unless the caller maps it to a primitive.
7
+ """
8
+ import re
9
+
10
+ from .lexicon import ASSET_TIERS, LEXICON, PRIMITIVE_SHAPES, TIERS
11
+
12
+ DETERMINERS = {"the", "a", "an"}
13
+ CONJUNCTIONS = {"and", "then"}
14
+ COPULAS = {"is", "was", "are", "were"}
15
+
16
+ # verb -> (relation, implied object when none is stated)
17
+ VERBS = {}
18
+ for v in ("sat", "sits", "sit", "stood", "stands", "lay", "lies", "rested", "rests"):
19
+ VERBS[v] = ("on", "ground")
20
+ for v in ("shone", "shines", "glowed", "glows"):
21
+ VERBS[v] = ("lights", None) # the subject becomes the light source
22
+ for v in ("faced", "faces"):
23
+ VERBS[v] = ("faces", None)
24
+
25
+ PREPOSITIONS = {
26
+ "in front of": "in_front_of", "next to": "beside", "on top of": "on",
27
+ "on": "on", "under": "under", "beneath": "under", "beside": "beside",
28
+ "near": "near", "behind": "behind", "above": "above",
29
+ }
30
+
31
+ # Nominal heights (m) for assets that carry no real-world scale (Objaverse, primitives).
32
+ NOMINAL_HEIGHT_M = {"cat": 0.3, "dog": 0.55, "chair": 0.9, "table": 0.75, "car": 1.5,
33
+ "tree": 5.0, "person": 1.7, "house": 6.0, "cup": 0.1, "bird": 0.2}
34
+ DEFAULT_HEIGHT_M = 0.5
35
+
36
+
37
+ def grammar_words():
38
+ return sorted(DETERMINERS | CONJUNCTIONS | COPULAS | set(VERBS) | set(PREPOSITIONS))
39
+
40
+
41
+ def parse_primitives(primitives):
42
+ """{"sun": "sphere"} -> validated dict of word -> shape."""
43
+ out = {}
44
+ for word, shape in (primitives or {}).items():
45
+ if shape not in PRIMITIVE_SHAPES:
46
+ raise ValueError(f"unknown primitive {shape!r} for {word!r}; use one of {PRIMITIVE_SHAPES}")
47
+ out[word.lower().strip()] = shape
48
+ return out
49
+
50
+
51
+ def tokenize(sentence, primitives=None):
52
+ """Greedy longest match over words: prepositions and lexicon phrases can span words."""
53
+ primitives = primitives or {}
54
+ words = re.findall(r"[a-z]+", sentence.lower())
55
+ max_n = max(3, LEXICON.max_phrase_words())
56
+ tokens, i = [], 0
57
+ while i < len(words):
58
+ for n in range(min(max_n, len(words) - i), 0, -1):
59
+ text = " ".join(words[i:i + n])
60
+ kind = classify(text) if n == 1 else classify_phrase(text)
61
+ if kind:
62
+ tokens.append({"index": len(tokens), "text": text, "kind": kind})
63
+ i += n
64
+ break
65
+ else:
66
+ kind = "primitive" if words[i] in primitives else "unknown"
67
+ tokens.append({"index": len(tokens), "text": words[i], "kind": kind})
68
+ i += 1
69
+ return tokens
70
+
71
+
72
+ def classify_phrase(text):
73
+ if text in PREPOSITIONS:
74
+ return "preposition"
75
+ if LEXICON.available(text):
76
+ return "noun"
77
+ return None
78
+
79
+
80
+ def classify(word):
81
+ if word in DETERMINERS:
82
+ return "determiner"
83
+ if word in CONJUNCTIONS:
84
+ return "conjunction"
85
+ if word in COPULAS:
86
+ return "copula"
87
+ if word in VERBS:
88
+ return "verb"
89
+ if word in PREPOSITIONS:
90
+ return "preposition"
91
+ if LEXICON.available(word):
92
+ return "noun"
93
+ return None
94
+
95
+
96
+ # ---------------- ledger ----------------
97
+
98
+ def primitive_candidate(shape="box"):
99
+ return {"source": "primitive", "shape": shape}
100
+
101
+
102
+ def _object_decision(word, shape=None):
103
+ """Assets first, in tier order; a primitive only when the caller chose one."""
104
+ if shape:
105
+ return {"tier": "primitive", "chosen": primitive_candidate(shape), "alternatives": [],
106
+ "rejected_tiers": [], "skipped": []}
107
+ found = LEXICON.lookup(word)
108
+ for tier in ASSET_TIERS:
109
+ cands = found.get(tier, [])
110
+ if cands:
111
+ return {"tier": tier, "chosen": cands[0], "alternatives": cands[1:9],
112
+ "rejected_tiers": [], "skipped": []}
113
+ raise ValueError(f"'{word}' has no asset")
114
+
115
+
116
+ def next_tier(decision, word):
117
+ """Rejection: simready -> objaverse -> primitive -> generation."""
118
+ found = LEXICON.lookup(word)
119
+ start = TIERS.index(decision["tier"]) + 1
120
+ for tier in TIERS[start:]:
121
+ if tier in ASSET_TIERS:
122
+ cands = found.get(tier, [])
123
+ if not cands:
124
+ continue
125
+ return {"tier": tier, "chosen": cands[0], "alternatives": cands[1:9]}
126
+ if tier == "primitive":
127
+ return {"tier": tier, "chosen": primitive_candidate(), "alternatives": []}
128
+ return {"tier": tier, "chosen": {"source": "generation", "prompt": word}, "alternatives": []}
129
+ raise ValueError(f"'{word}' has no tier left after {decision['tier']}")
130
+
131
+
132
+ def candidate_label(cand):
133
+ return cand.get("name") or cand.get("category") or cand.get("shape") or cand.get("prompt") or "?"
134
+
135
+
136
+ def build_ledger(sentence, primitives=None):
137
+ primitives = parse_primitives(primitives)
138
+ tokens = tokenize(sentence, primitives)
139
+ unknown = [t["text"] for t in tokens if t["kind"] == "unknown"]
140
+ if unknown:
141
+ raise ValueError(f"Not available (no asset): {', '.join(unknown)}. "
142
+ f"Rephrase, or map each to a primitive: {PRIMITIVE_SHAPES}")
143
+ for t in tokens:
144
+ t["commits"], t["opens"] = [], []
145
+
146
+ items, unresolved = [], []
147
+ by_id = {}
148
+
149
+ def add_item(item, token):
150
+ items.append(item)
151
+ by_id[item["id"]] = item
152
+ token["commits"].append(item["id"])
153
+ return item
154
+
155
+ clauses, current = [], []
156
+ for t in tokens:
157
+ if t["kind"] == "conjunction":
158
+ clauses.append(current)
159
+ current = []
160
+ else:
161
+ current.append(t)
162
+ clauses.append(current)
163
+
164
+ pending_q = None
165
+ for clause in clauses:
166
+ subject, verb_rel, implied_obj, prep_rel, obj, verb_tok = None, None, None, None, None, None
167
+ for t in clause:
168
+ kind = t["kind"]
169
+ if kind == "determiner":
170
+ pending_q = {"id": f"q{t['index']}", "token": t["index"], "about": None}
171
+ t["opens"].append(pending_q["id"])
172
+ unresolved.append(pending_q)
173
+ elif kind in ("noun", "primitive"):
174
+ # height is settled with the asset: metric for simready, nominal otherwise
175
+ item = add_item({"id": f"n{t['index']}", "kind": "object", "word": t["text"],
176
+ "token": t["index"],
177
+ "decision": _object_decision(t["text"], primitives.get(t["text"])),
178
+ "height_m": {"value": None, "source": "pending"},
179
+ "frozen": False}, t)
180
+ if pending_q is not None:
181
+ pending_q["about"] = item["id"]
182
+ pending_q["question"] = f"which {t['text']}?"
183
+ pending_q = None
184
+ if verb_rel is None and prep_rel is None:
185
+ subject = item
186
+ else:
187
+ obj = item
188
+ elif kind == "verb":
189
+ verb_rel, implied_obj = VERBS[t["text"]]
190
+ verb_tok = t
191
+ elif kind == "copula":
192
+ verb_tok = t
193
+ elif kind == "preposition":
194
+ prep_rel = PREPOSITIONS[t["text"]]
195
+ verb_tok = t
196
+ if subject is None:
197
+ raise ValueError("Each clause needs a subject noun")
198
+ relation = prep_rel or verb_rel
199
+ if relation is None:
200
+ continue
201
+ if obj is None and relation == "on" and implied_obj:
202
+ # Existing needs a reality: the verb implies a support the sentence never
203
+ # names. Visualising exposes it, so it is surfaced as a decision too.
204
+ obj = by_id.get(implied_obj)
205
+ if obj is None:
206
+ obj = add_item({"id": implied_obj, "kind": "environment", "role": "plane", "word": implied_obj,
207
+ "token": verb_tok["index"], "implied_by": verb_tok["index"],
208
+ "frozen": False}, verb_tok)
209
+ q = {"id": f"q{verb_tok['index']}i", "token": verb_tok["index"], "about": obj["id"],
210
+ "question": f"{subject['word']} {verb_tok['text']} on what?",
211
+ "default_chosen": "ground plane", "implied": True,
212
+ "alternatives": [f"rephrase: '{verb_tok['text']} on the <noun>'"]}
213
+ verb_tok["opens"].append(q["id"])
214
+ unresolved.append(q)
215
+ if relation == "lights":
216
+ subject["emits_light"] = True
217
+ obj = {"id": "scene"}
218
+ if obj is None:
219
+ raise ValueError(f"'{relation}' needs an object noun")
220
+ add_item({"id": f"r{verb_tok['index']}", "kind": "relation", "relation": relation,
221
+ "subject": subject["id"], "object": obj["id"], "token": verb_tok["index"],
222
+ "frozen": False}, verb_tok)
223
+
224
+ for q in unresolved:
225
+ if q["about"] is None:
226
+ q["question"] = "determiner without a noun"
227
+ return {"version": 1, "sentence": sentence, "primitives": primitives,
228
+ "tokens": tokens, "items": items, "unresolved": unresolved}
229
+
230
+
231
+ def check_words(sentence, primitives=None):
232
+ """Per-word status, the words with no asset, and completions for the word being typed."""
233
+ primitives = parse_primitives(primitives)
234
+ tokens = tokenize(sentence, primitives)
235
+ words = []
236
+ for t in tokens:
237
+ entry = {"text": t["text"], "kind": t["kind"]}
238
+ if t["kind"] == "noun":
239
+ entry["available_in"] = LEXICON.counts(t["text"])
240
+ elif t["kind"] == "primitive":
241
+ entry["primitive"] = primitives[t["text"]]
242
+ words.append(entry)
243
+ completions = []
244
+ if sentence and not sentence[-1].isspace():
245
+ last = re.findall(r"[a-z]+", sentence.lower())
246
+ if last:
247
+ prefix = last[-1]
248
+ completions = [w for w in grammar_words() if w.startswith(prefix)][:6]
249
+ completions += LEXICON.complete(prefix, 12)
250
+ missing = [t["text"] for t in tokens if t["kind"] == "unknown"]
251
+ return {"ok": bool(tokens) and not missing, "words": words, "missing": missing,
252
+ "primitive_shapes": PRIMITIVE_SHAPES, "completions": completions}
vli/lexicon.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The dictionary: which words exist as ready-made assets, and where they live.
2
+
3
+ Tiers, in fallback order (rejecting an item moves it to the next tier):
4
+ simready -> objaverse -> primitive -> generation
5
+ A word is *available* only when a simready or objaverse asset exists for it;
6
+ primitive and generation are construction, so they never make a word available.
7
+ A missing word can still be used if the caller explicitly maps it to a primitive.
8
+ """
9
+ import bisect
10
+ import gzip
11
+ import json
12
+ import os
13
+ import re
14
+ import threading
15
+
16
+ import requests
17
+ from huggingface_hub import hf_hub_download
18
+
19
+ CACHE_DIR = os.environ.get("VLI_CACHE", "cache")
20
+ TIERS = ["simready", "objaverse", "primitive", "generation"]
21
+ ASSET_TIERS = ["simready", "objaverse"]
22
+ PRIMITIVE_SHAPES = ["box", "sphere", "cylinder", "cone", "plane"]
23
+
24
+ SIMREADY_TREE = "https://api.github.com/repos/NVIDIA-Omniverse/simready-central/git/trees/main?recursive=1"
25
+ SIMREADY_RAW = "https://raw.githubusercontent.com/NVIDIA-Omniverse/simready-central/main/"
26
+ SIMREADY_FALLBACK = ["submissions/nvidia/simready-assets"]
27
+ PACKAGE_URL = re.compile(
28
+ r"https://huggingface\.co/datasets/([^/\s]+)/([^/\s]+)/resolve/([0-9a-f]{40})/([^/\s]+)/"
29
+ r"com\.nvidia\.simready\.packaging\.json"
30
+ )
31
+ VARIANT = re.compile(r"[a-z]?\d+") # a01, b02, 01
32
+
33
+
34
+ def _split_part(part):
35
+ """'handDryer' -> ['hand', 'dryer']"""
36
+ return re.sub(r"([a-z])([A-Z])", r"\1 \2", part).lower().split()
37
+
38
+
39
+ def simready_phrases(name):
40
+ """Words an asset name like 'appliance_handDryer_automatic_a01' can be looked up by.
41
+
42
+ SimReady names read category_object_modifiers, so the second part is the
43
+ best match (rank 0); every other part is a weaker match (rank 1).
44
+ """
45
+ parts = [p for p in name.split("_") if p and not VARIANT.fullmatch(p)]
46
+ out = []
47
+ for i, part in enumerate(parts):
48
+ words = _split_part(part)
49
+ if not words:
50
+ continue
51
+ rank = 0 if (i == 1 or len(parts) == 1) else 1
52
+ out.append((" ".join(words), rank))
53
+ if len(words) > 1:
54
+ out.append((words[-1], rank + 1))
55
+ return out
56
+
57
+
58
+ def objaverse_word(category):
59
+ """LVIS category 'bow_(weapon)' -> 'bow'"""
60
+ return re.sub(r"_?\(.*?\)", "", category).replace("_", " ").strip().lower()
61
+
62
+
63
+ class Lexicon:
64
+ def __init__(self):
65
+ self._lock = threading.Lock()
66
+ self._index = None # word -> {tier: [candidate, ...]}
67
+ self._words = []
68
+ self._object_paths = None
69
+
70
+ # ---------- building ----------
71
+ def _cached_json(self, name, build):
72
+ path = os.path.join(CACHE_DIR, name)
73
+ if os.path.exists(path):
74
+ with open(path, encoding="utf-8") as f:
75
+ return json.load(f)
76
+ data = build()
77
+ os.makedirs(CACHE_DIR, exist_ok=True)
78
+ with open(path, "w", encoding="utf-8") as f:
79
+ json.dump(data, f)
80
+ return data
81
+
82
+ def _simready_packages(self):
83
+ try:
84
+ tree = requests.get(SIMREADY_TREE, timeout=30).json()["tree"]
85
+ files = [t["path"] for t in tree if t["type"] == "blob" and t["path"].startswith("submissions/")]
86
+ except Exception:
87
+ files = SIMREADY_FALLBACK
88
+ packages = []
89
+ for f in files:
90
+ try:
91
+ text = requests.get(SIMREADY_RAW + f, timeout=30).text
92
+ except Exception:
93
+ continue
94
+ for ns, ds, rev, name in PACKAGE_URL.findall(text):
95
+ packages.append({"source": "simready", "repo": f"{ns}/{ds}", "revision": rev, "name": name})
96
+ return packages
97
+
98
+ def _objaverse_lvis(self):
99
+ path = hf_hub_download("allenai/objaverse", "lvis-annotations.json.gz", repo_type="dataset")
100
+ with gzip.open(path, "rt", encoding="utf-8") as f:
101
+ return json.load(f)
102
+
103
+ def _build(self):
104
+ index = {}
105
+
106
+ def add(word, tier, cand, rank=0):
107
+ index.setdefault(word, {}).setdefault(tier, []).append((rank, cand))
108
+
109
+ for pkg in self._cached_json("simready_packages.json", self._simready_packages):
110
+ for phrase, rank in simready_phrases(pkg["name"]):
111
+ add(phrase, "simready", pkg, rank)
112
+ for category, uids in self._cached_json("objaverse_lvis.json", self._objaverse_lvis).items():
113
+ for uid in uids:
114
+ add(objaverse_word(category), "objaverse", {"source": "objaverse", "uid": uid, "category": category})
115
+
116
+ for tiers in index.values():
117
+ for tier, ranked in tiers.items():
118
+ ranked.sort(key=lambda rc: (rc[0], len(rc[1].get("name", ""))))
119
+ tiers[tier] = [c for _, c in ranked]
120
+ self._index = index
121
+ self._words = sorted(index)
122
+
123
+ def ensure(self):
124
+ with self._lock:
125
+ if self._index is None:
126
+ self._build()
127
+
128
+ # ---------- queries ----------
129
+ def lookup(self, word):
130
+ """{tier: [candidates]} for a word (plural 's' stripped as a fallback)."""
131
+ self.ensure()
132
+ if word in self._index:
133
+ return self._index[word]
134
+ if word.endswith("s") and word[:-1] in self._index:
135
+ return self._index[word[:-1]]
136
+ return {}
137
+
138
+ def available(self, word):
139
+ return any(self.lookup(word).get(t) for t in ASSET_TIERS)
140
+
141
+ def counts(self, word):
142
+ found = self.lookup(word)
143
+ return {t: len(found.get(t, [])) for t in ASSET_TIERS}
144
+
145
+ def complete(self, prefix, limit=12):
146
+ self.ensure()
147
+ i = bisect.bisect_left(self._words, prefix)
148
+ out = []
149
+ while i < len(self._words) and self._words[i].startswith(prefix) and len(out) < limit:
150
+ out.append(self._words[i])
151
+ i += 1
152
+ return out
153
+
154
+ def max_phrase_words(self):
155
+ self.ensure()
156
+ return max((len(w.split()) for w in self._words), default=1)
157
+
158
+ def object_path(self, uid):
159
+ """Objaverse uid -> 'glbs/000-023/<uid>.glb'"""
160
+ with self._lock:
161
+ if self._object_paths is None:
162
+ path = hf_hub_download("allenai/objaverse", "object-paths.json.gz", repo_type="dataset")
163
+ with gzip.open(path, "rt", encoding="utf-8") as f:
164
+ self._object_paths = json.load(f)
165
+ return self._object_paths[uid]
166
+
167
+
168
+ LEXICON = Lexicon()
vli/pipeline.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ties the stages together: ledger -> settled meshes -> configuration -> files.
2
+
3
+ Refining happens on the serial side: frozen items keep their decisions,
4
+ everything else may change. Configuration is always free (seeded).
5
+ """
6
+ import json
7
+ import os
8
+ import tempfile
9
+
10
+ import numpy as np
11
+
12
+ from . import assets, scene
13
+ from .grammar import build_ledger, candidate_label, next_tier, parse_primitives, primitive_candidate
14
+
15
+
16
+ def _workdir():
17
+ return tempfile.mkdtemp(prefix="vli_")
18
+
19
+
20
+ def settle_all(ledger):
21
+ """Load every object's mesh; unusable candidates are skipped and recorded."""
22
+ meshes = {}
23
+ for it in ledger["items"]:
24
+ if it["kind"] == "object":
25
+ full = assets.settle(it)
26
+ meshes[it["id"]] = assets.scene_mesh(full)
27
+ it["decision"]["label"] = candidate_label(it["decision"]["chosen"])
28
+ for q in ledger["unresolved"]:
29
+ about = next((i for i in ledger["items"] if i["id"] == q.get("about")), None)
30
+ if about and about["kind"] == "object":
31
+ q["default_chosen"] = about["decision"]["label"]
32
+ q["alternatives"] = [candidate_label(c) for c in about["decision"]["alternatives"]]
33
+ return meshes
34
+
35
+
36
+ def outputs(ledger, seed):
37
+ meshes = settle_all(ledger)
38
+ config = scene.configure(ledger, meshes, seed)
39
+ ledger["configuration"] = config
40
+ work = _workdir()
41
+ png = scene.render_scene(ledger, meshes, config, os.path.join(work, "scene.png"))
42
+ glb = scene.export_glb(ledger, meshes, config, os.path.join(work, "scene.glb"))
43
+ return json.dumps(ledger, indent=1), png, glb
44
+
45
+
46
+ def translate(sentence, primitives=None, seed=0):
47
+ return outputs(build_ledger(sentence, primitives), seed)
48
+
49
+
50
+ def apply_changes(ledger, changes, seed=0):
51
+ """changes: list of
52
+ {"id": ..., "freeze": true|false}
53
+ {"id": ..., "use": <index into alternatives>}
54
+ {"id": ..., "reject": true} -> next tier
55
+ {"id": ..., "height_m": 0.4}
56
+ {"id": ..., "shape": "sphere"} -> primitive tier with that shape
57
+ {"reroll": true} -> every unfrozen object takes a random alternative
58
+ """
59
+ by_id = {i["id"]: i for i in ledger["items"]}
60
+ rng = np.random.default_rng(seed)
61
+ for ch in changes:
62
+ if ch.get("reroll"):
63
+ for it in ledger["items"]:
64
+ dec = it.get("decision")
65
+ if it["kind"] == "object" and not it["frozen"] and dec["alternatives"]:
66
+ k = int(rng.integers(len(dec["alternatives"])))
67
+ dec["alternatives"][k], dec["chosen"] = dec["chosen"], dec["alternatives"][k]
68
+ continue
69
+ it = by_id.get(ch.get("id"))
70
+ if it is None:
71
+ raise ValueError(f"no item with id {ch.get('id')!r}")
72
+ if "freeze" in ch:
73
+ it["frozen"] = bool(ch["freeze"])
74
+ continue
75
+ if it["frozen"]:
76
+ raise ValueError(f"{it['id']} is frozen; unfreeze it first")
77
+ if it["kind"] != "object":
78
+ raise ValueError(f"{it['id']} is a {it['kind']}; only object decisions can change")
79
+ dec = it["decision"]
80
+ if "use" in ch:
81
+ k = int(ch["use"])
82
+ dec["alternatives"][k], dec["chosen"] = dec["chosen"], dec["alternatives"][k]
83
+ if ch.get("reject"):
84
+ dec["rejected_tiers"].append(dec["tier"])
85
+ dec.update(next_tier(dec, it["word"]))
86
+ if it["height_m"]["source"] != "user":
87
+ it["height_m"] = {"value": None, "source": "pending"}
88
+ if "shape" in ch:
89
+ shape = parse_primitives({it["word"]: ch["shape"]})[it["word"]]
90
+ if dec["tier"] != "primitive":
91
+ dec["rejected_tiers"].append(dec["tier"])
92
+ dec.update(tier="primitive", chosen=primitive_candidate(shape), alternatives=[])
93
+ if "height_m" in ch:
94
+ it["height_m"] = {"value": float(ch["height_m"]), "source": "user"}
95
+ return ledger
vli/scene.py ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Configuration side: once the serial ledger is resolved, any placement that
2
+ satisfies its relations is valid. The seed picks one configuration.
3
+ """
4
+ import math
5
+
6
+ import matplotlib
7
+
8
+ matplotlib.use("Agg")
9
+ import matplotlib.pyplot as plt # noqa: E402
10
+ import numpy as np # noqa: E402
11
+ import trimesh # noqa: E402
12
+ from mpl_toolkits.mplot3d.art3d import Poly3DCollection # noqa: E402
13
+ from scipy.spatial import ConvexHull # noqa: E402
14
+
15
+ from .simplify import base_color, decimate # noqa: E402
16
+
17
+ GAP = 0.1
18
+ RENDER_FACE_BUDGET = 3000
19
+
20
+
21
+ def _half_width(mesh):
22
+ return 0.5 * max(mesh.extents[0], mesh.extents[2])
23
+
24
+
25
+ def sun_direction(sun):
26
+ el, az = math.radians(sun["elevation_deg"]), math.radians(sun["azimuth_deg"])
27
+ return np.array([math.cos(el) * math.sin(az), math.sin(el), math.cos(el) * math.cos(az)])
28
+
29
+
30
+ def configure(ledger, meshes, seed=0):
31
+ """Place every object so the ledger's relations hold. Returns a JSON-able dict."""
32
+ rng = np.random.default_rng(seed)
33
+ items = ledger["items"]
34
+ relations = [i for i in items if i["kind"] == "relation"]
35
+ lights = [i for i in items if i.get("emits_light")]
36
+ objects = [i for i in items if i["kind"] == "object" and not i.get("emits_light")]
37
+
38
+ place, x = {}, 0.0
39
+ for it in objects: # unrelated objects stand in a loose row
40
+ hw = _half_width(meshes[it["id"]])
41
+ place[it["id"]] = {"position": [x + hw, 0.0, float(rng.uniform(-0.5, 0.5) * hw)],
42
+ "yaw_deg": float(rng.uniform(-180, 180))}
43
+ x += 2 * hw + 4 * GAP
44
+ for p in place.values():
45
+ p["position"][0] -= x / 2
46
+
47
+ sun = None
48
+ if lights:
49
+ sun = {"id": lights[0]["id"], "elevation_deg": float(rng.uniform(20, 70)),
50
+ "azimuth_deg": float(rng.uniform(-150, 150)),
51
+ "shadows": any(r["relation"] == "lights" for r in relations)}
52
+
53
+ def height(oid):
54
+ return float(meshes[oid].extents[1])
55
+
56
+ for _ in range(2): # second pass lets chains (a on b on c) settle
57
+ for r in relations:
58
+ a, b, rel = r["subject"], r["object"], r["relation"]
59
+ if a not in place:
60
+ continue
61
+ pa = place[a]["position"]
62
+ if b not in place: # environment object: ground, sun, scene
63
+ if rel == "on":
64
+ pa[1] = 0.0
65
+ elif rel == "faces" and sun and b == sun["id"]:
66
+ place[a]["yaw_deg"] = sun["azimuth_deg"]
67
+ continue
68
+ pb = place[b]["position"]
69
+ hwa, hwb = _half_width(meshes[a]), _half_width(meshes[b])
70
+ if rel == "on":
71
+ slack = max(0.0, hwb - hwa)
72
+ ang = rng.uniform(0, 2 * math.pi)
73
+ pa[0], pa[2] = pb[0] + slack * math.cos(ang) * 0.5, pb[2] + slack * math.sin(ang) * 0.5
74
+ pa[1] = pb[1] + height(b)
75
+ elif rel == "under":
76
+ pa[0], pa[1], pa[2] = pb[0], 0.0, pb[2]
77
+ elif rel in ("beside", "near"):
78
+ side = rng.choice([-1, 1])
79
+ gap = GAP if rel == "beside" else 3 * GAP + hwa
80
+ pa[0], pa[1], pa[2] = pb[0] + side * (hwa + hwb + gap), pb[1], pb[2]
81
+ elif rel in ("behind", "in_front_of"):
82
+ sign = -1 if rel == "behind" else 1
83
+ pa[0], pa[1], pa[2] = pb[0], pb[1], pb[2] + sign * (hwa + hwb + GAP)
84
+ elif rel == "above":
85
+ pa[0], pa[2] = pb[0], pb[2]
86
+ pa[1] = pb[1] + height(b) + 0.3 * max(height(a), height(b))
87
+ elif rel == "faces":
88
+ place[a]["yaw_deg"] = math.degrees(math.atan2(pb[0] - pa[0], pb[2] - pa[2]))
89
+
90
+ if sun: # whatever shone sits in the sky along the light direction
91
+ reach = max([abs(c) + _half_width(meshes[o]) for o, p in place.items()
92
+ for c in (p["position"][0], p["position"][2])] + [1.0])
93
+ pos = sun_direction(sun) * 1.3 * reach # shadows use the direction; distance is only for the view
94
+ for it in lights:
95
+ place[it["id"]] = {"position": [float(c) for c in pos], "yaw_deg": 0.0, "light": True}
96
+ return {"seed": seed, "placements": place, "sun": sun}
97
+
98
+
99
+ def transform(p):
100
+ t = trimesh.transformations.rotation_matrix(math.radians(p["yaw_deg"]), [0, 1, 0])
101
+ t[:3, 3] = p["position"]
102
+ return t
103
+
104
+
105
+ def _ground(ledger, placed):
106
+ if not any(i["kind"] == "environment" and i["role"] == "plane" for i in ledger["items"]):
107
+ return None
108
+ lights = {i["id"] for i in ledger["items"] if i.get("emits_light")}
109
+ reach = max([np.abs(m.bounds[:, [0, 2]]).max() for oid, m in placed.items() if oid not in lights] + [1.0])
110
+ g = trimesh.creation.cylinder(radius=reach * 1.6, height=0.01, sections=48)
111
+ g.apply_transform(trimesh.transformations.rotation_matrix(math.pi / 2, [1, 0, 0]))
112
+ g.apply_translation([0, -0.005, 0])
113
+ g.visual = trimesh.visual.ColorVisuals(g, face_colors=np.tile([77, 124, 58, 255], (len(g.faces), 1)))
114
+ return g
115
+
116
+
117
+ def placed_meshes(ledger, meshes, config):
118
+ out = {}
119
+ for oid, p in config["placements"].items():
120
+ m = meshes[oid].copy()
121
+ m.apply_transform(transform(p))
122
+ out[oid] = m
123
+ return out
124
+
125
+
126
+ def export_glb(ledger, meshes, config, path):
127
+ placed = placed_meshes(ledger, meshes, config)
128
+ scene = trimesh.Scene()
129
+ for oid, m in placed.items():
130
+ scene.add_geometry(m, node_name=oid)
131
+ ground = _ground(ledger, placed)
132
+ if ground is not None:
133
+ scene.add_geometry(ground, node_name="ground")
134
+ scene.export(path)
135
+ return path
136
+
137
+
138
+ # ---------------- picture (headless, for people and for models) ----------------
139
+
140
+ def _sky(sun):
141
+ if not sun:
142
+ return "#2a3346"
143
+ t = min(1.0, max(0.0, (sun["elevation_deg"] - 5) / 40)) # low sun -> warm sky
144
+ low, high = np.array([0.95, 0.62, 0.38]), np.array([0.53, 0.71, 0.88])
145
+ return tuple(low + (high - low) * t)
146
+
147
+
148
+ def _to_axes(v):
149
+ """Y-up scene coords -> matplotlib Z-up axes (a proper rotation)."""
150
+ return np.stack([v[..., 0], -v[..., 2], v[..., 1]], axis=-1)
151
+
152
+
153
+ def _shade(mesh, light, color):
154
+ n = mesh.face_normals
155
+ k = 0.35 + 0.65 * np.clip(n @ light, 0, 1)
156
+ rgb = np.clip(color[:3] / 255.0 * k[:, None], 0, 1)
157
+ return np.c_[rgb, np.ones(len(rgb))]
158
+
159
+
160
+ def render(meshes_by_id, path, sun=None, ground=None, title=None):
161
+ light = sun_direction(sun) if sun else np.array([0.4, 0.8, 0.45]) / np.linalg.norm([0.4, 0.8, 0.45])
162
+ fig = plt.figure(figsize=(6.4, 4.8), dpi=110)
163
+ fig.patch.set_facecolor(_sky(sun))
164
+ ax = fig.add_subplot(projection="3d", computed_zorder=False)
165
+ ax.set_facecolor(_sky(sun))
166
+ pts = []
167
+
168
+ if ground is not None:
169
+ v = ground.vertices
170
+ top = v[(v[:, 1] > -0.005) & (np.hypot(v[:, 0], v[:, 2]) > 1e-6)] # top cap rim
171
+ ring = top[np.argsort(np.arctan2(top[:, 2], top[:, 0]))]
172
+ ax.add_collection3d(Poly3DCollection([_to_axes(ring)], facecolors=[(0.30, 0.49, 0.23, 1)], zorder=0))
173
+ pts.append(ring)
174
+
175
+ if sun and sun.get("shadows") and light[1] > 0.05:
176
+ for oid, m in meshes_by_id.items():
177
+ if oid == sun["id"]:
178
+ continue
179
+ v = m.vertices if len(m.vertices) < 4000 else m.vertices[:: len(m.vertices) // 4000 + 1]
180
+ flat = v - np.outer(v[:, 1] / light[1], light)
181
+ try:
182
+ hull = ConvexHull(flat[:, [0, 2]])
183
+ except Exception:
184
+ continue
185
+ poly = flat[hull.vertices]
186
+ poly[:, 1] = 0.002
187
+ ax.add_collection3d(Poly3DCollection([_to_axes(poly)], facecolors=[(0, 0, 0, 0.35)], zorder=1))
188
+
189
+ tris, colors = [], []
190
+ for m in meshes_by_id.values():
191
+ color = base_color(m)
192
+ r = decimate(m, RENDER_FACE_BUDGET) if len(m.faces) > RENDER_FACE_BUDGET else m
193
+ tris.append(r.triangles)
194
+ colors.append(_shade(r, light, color))
195
+ pts.append(r.vertices)
196
+ if tris:
197
+ ax.add_collection3d(Poly3DCollection(_to_axes(np.concatenate(tris)), facecolors=np.concatenate(colors),
198
+ linewidths=0, zorder=2))
199
+
200
+ allp = _to_axes(np.concatenate(pts)) if pts else np.zeros((1, 3))
201
+ lo, hi = allp.min(axis=0), allp.max(axis=0)
202
+ span = max((hi - lo).max(), 1e-3)
203
+ mid = (lo + hi) / 2
204
+ ax.set_xlim(mid[0] - span / 2, mid[0] + span / 2)
205
+ ax.set_ylim(mid[1] - span / 2, mid[1] + span / 2)
206
+ ax.set_zlim(max(lo[2], mid[2] - span / 2), max(lo[2], mid[2] - span / 2) + span)
207
+ ax.set_box_aspect((1, 1, 1))
208
+ ax.view_init(elev=22, azim=-60)
209
+ ax.set_axis_off()
210
+ if title:
211
+ ax.set_title(title, color="white", fontsize=10)
212
+ fig.savefig(path, facecolor=fig.get_facecolor(), bbox_inches="tight")
213
+ plt.close(fig)
214
+ return path
215
+
216
+
217
+ def render_scene(ledger, meshes, config, path):
218
+ placed = placed_meshes(ledger, meshes, config)
219
+ return render(placed, path, sun=config["sun"], ground=_ground(ledger, placed))
vli/simplify.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The simplification ladder: how far a mesh can be reduced and still read as its word.
2
+
3
+ Rungs run from the full asset down to an oriented box. The low rungs are the
4
+ ones that can be materialized (fewer faces = fewer papercraft pieces).
5
+ """
6
+ import numpy as np
7
+ import trimesh
8
+
9
+ FACE_LEVELS = [20000, 5000, 1000, 250, 60]
10
+
11
+
12
+ def base_color(mesh):
13
+ """Mean RGBA of a mesh's colours, or neutral grey."""
14
+ vis = getattr(mesh, "visual", None)
15
+ if isinstance(vis, trimesh.visual.ColorVisuals):
16
+ try:
17
+ cols = vis.vertex_colors if vis.kind == "vertex" else vis.face_colors
18
+ if cols is not None and len(cols):
19
+ return np.asarray(cols, dtype=np.float64).mean(axis=0).astype(np.uint8)
20
+ except Exception:
21
+ pass
22
+ return np.array([180, 180, 180, 255], dtype=np.uint8)
23
+
24
+
25
+ def decimate(mesh, faces):
26
+ color = base_color(mesh)
27
+ try:
28
+ out = mesh.simplify_quadric_decimation(face_count=faces)
29
+ except TypeError: # older trimesh signature
30
+ out = mesh.simplify_quadric_decimation(faces)
31
+ out.visual = trimesh.visual.ColorVisuals(out, face_colors=np.tile(color, (len(out.faces), 1)))
32
+ return out
33
+
34
+
35
+ def _recolor(mesh, color):
36
+ mesh.visual = trimesh.visual.ColorVisuals(mesh, face_colors=np.tile(color, (len(mesh.faces), 1)))
37
+ return mesh
38
+
39
+
40
+ def ladder(mesh):
41
+ color = base_color(mesh)
42
+ rungs = [("original", mesh)]
43
+ for n in FACE_LEVELS:
44
+ if n < 0.8 * len(rungs[-1][1].faces):
45
+ rungs.append((f"{n} faces", decimate(mesh, n)))
46
+ rungs.append(("convex hull", _recolor(mesh.convex_hull, color)))
47
+ box = mesh.bounding_box_oriented
48
+ rungs.append(("bounding box", _recolor(trimesh.Trimesh(box.vertices, box.faces), color)))
49
+ return rungs
50
+
51
+
52
+ def stats(mesh):
53
+ return {"faces": int(len(mesh.faces)), "vertices": int(len(mesh.vertices)),
54
+ "watertight": bool(mesh.is_watertight), "convex": bool(mesh.is_convex),
55
+ "surface_area_m2": round(float(mesh.area), 4)}
vli/vlm.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Back-translation: a vision model reads the picture back into English."""
2
+ import base64
3
+ import os
4
+
5
+ from huggingface_hub import InferenceClient
6
+
7
+ VLM_MODEL = os.environ.get("VLM_MODEL", "Qwen/Qwen2.5-VL-7B-Instruct")
8
+
9
+
10
+ def ask(image_path, prompt, max_tokens=80):
11
+ with open(image_path, "rb") as f:
12
+ data = base64.b64encode(f.read()).decode()
13
+ client = InferenceClient(token=os.environ.get("HF_TOKEN"))
14
+ resp = client.chat_completion(
15
+ model=VLM_MODEL, max_tokens=max_tokens,
16
+ messages=[{"role": "user", "content": [
17
+ {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{data}"}},
18
+ {"type": "text", "text": prompt}]}])
19
+ return resp.choices[0].message.content.strip()
20
+
21
+
22
+ def describe(image_path):
23
+ return ask(image_path, "Describe this 3D scene in one short plain sentence.")
24
+
25
+
26
+ def name_object(image_path):
27
+ return ask(image_path, "What single object is this? Answer with one or two words.", max_tokens=10)