File size: 24,267 Bytes
77b3711
 
 
 
7bb2fda
 
f9a8763
77b3711
7bb2fda
77b3711
 
 
 
 
 
065c4f9
77b3711
 
92d401f
f9a8763
77b3711
 
 
 
 
 
 
 
 
 
bbe0bad
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
af80e6b
 
 
 
 
 
 
 
 
 
 
f9a8763
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
065c4f9
 
 
 
 
 
 
 
 
 
77b3711
 
 
 
 
7bb2fda
 
 
 
 
77b3711
 
 
 
 
 
 
7bb2fda
77b3711
 
 
bbe0bad
77b3711
 
 
 
f9a8763
6287002
77b3711
 
 
7bb2fda
 
 
 
77b3711
 
447d1d2
77b3711
 
f9a8763
 
 
6287002
 
 
77b3711
 
f9a8763
77b3711
 
6287002
 
f9a8763
77b3711
 
 
 
f9a8763
 
77b3711
 
 
 
7bb2fda
 
447d1d2
 
77b3711
 
447d1d2
 
5b66adc
 
7bb2fda
 
77b3711
 
 
 
447d1d2
 
f9a8763
77b3711
 
f9a8763
77b3711
 
bbe0bad
447d1d2
f9a8763
 
77b3711
 
 
 
5b66adc
6287002
5b66adc
 
 
 
 
 
 
 
 
 
 
 
 
 
6287002
5b66adc
 
 
 
 
 
6287002
 
5b66adc
 
 
 
 
af80e6b
 
 
77b3711
7bb2fda
 
 
77b3711
af80e6b
 
 
 
77b3711
 
 
f9a8763
 
af80e6b
 
77b3711
 
7bb2fda
f9a8763
77b3711
 
bbe0bad
77b3711
 
 
 
 
af80e6b
77b3711
7bb2fda
77b3711
 
 
 
af80e6b
 
 
 
77b3711
 
f9a8763
 
77b3711
 
 
 
92d401f
2fe7d5d
e1d8e60
92d401f
 
 
 
 
 
 
 
 
 
 
e1d8e60
 
92d401f
 
2fe7d5d
 
92d401f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e1d8e60
 
 
 
 
2fe7d5d
e1d8e60
2fe7d5d
 
e1d8e60
 
 
 
2fe7d5d
e1d8e60
92d401f
 
e1d8e60
 
363fb28
2fe7d5d
92d401f
 
 
 
77b3711
 
 
 
 
 
 
 
 
7bb2fda
77b3711
 
 
 
7bb2fda
77b3711
 
 
 
 
 
 
 
 
 
 
 
 
 
bbe0bad
77b3711
 
 
 
 
f9a8763
 
 
 
 
 
 
 
7bb2fda
 
77b3711
 
d7aa77a
7bb2fda
d7aa77a
 
 
 
 
 
 
7bb2fda
f9a8763
 
d7aa77a
 
77b3711
 
 
d7aa77a
77b3711
447d1d2
bbe0bad
6287002
 
77b3711
 
 
 
 
 
 
 
 
 
 
 
7bb2fda
f9a8763
 
7bb2fda
 
 
77b3711
 
 
 
5b66adc
 
 
77b3711
af80e6b
 
 
 
77b3711
 
7bb2fda
f9a8763
7bb2fda
77b3711
 
92d401f
e1d8e60
92d401f
2fe7d5d
 
92d401f
77b3711
 
 
 
 
 
6287002
f9a8763
 
 
 
 
6287002
5b66adc
 
af80e6b
f9a8763
 
92d401f
2fe7d5d
77b3711
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
"""VisualLearningIdea: translate English into 3D, then see how far 3D can simplify.

One Space for people (this UI) and for models (every public event below is an
MCP tool: demo.launch(mcp_server=True)). Helper events are api_visibility="private".
The Space only visualises; discussion happens in the chat that calls it. Every
picture comes back with a mini HTML page, because HTML is the final destination.
The page is a URL by default: geometry is for the handoff, not the loop.
"""
import html as html_lib
import json
import os
import re
import tempfile
import threading

import spaces  # ZeroGPU: import before any CUDA-touching library
import gradio as gr

from vli import assets, papercraft, pipeline, scene, simplify
from vli import html as html_pages  # `html` is the tools' output-mode argument
from vli.grammar import check_words as _check_words
from vli.lexicon import LEXICON, PRIMITIVE_SHAPES

threading.Thread(target=LEXICON.ensure, daemon=True).start()  # warm the dictionary


def _fail(e):
    raise gr.Error(str(e))


def _json(text, name, default=None):
    """Parse a JSON argument; blank -> default, junk -> a readable error."""
    if not (text or "").strip():
        if default is None:
            raise ValueError(f"{name} is empty; translate a sentence first")
        return default
    try:
        return json.loads(text)
    except json.JSONDecodeError:
        raise ValueError(f"{name} is not valid JSON")


def _seed(value):
    """Seeds arrive as strings (some MCP clients can't send numbers)."""
    try:
        return int(float(str(value).strip() or 0))
    except ValueError:
        raise ValueError(f"seed must be a whole number, got {value!r}")


def _count(value, name, lo, hi):
    """A whole number in [lo, hi], sent as a string like seeds."""
    try:
        n = int(float(str(value).strip()))
    except ValueError:
        raise ValueError(f"{name} must be a whole number, got {value!r}")
    if not lo <= n <= hi:
        raise ValueError(f"{name} must be between {lo} and {hi}, got {n}")
    return n


HTML_MODES = ["url", "inline", "skip"]


def _html_out(page, mode):
    """The mini HTML as (file, text): a file for "url", file and text for "inline", neither for "skip".

    Inline geometry costs thousands of tokens, so a model iterating on the ledger
    gets the file URL and asks for the text only when it builds on the page.
    """
    mode = (mode or "url").strip().lower()
    if mode not in HTML_MODES:
        raise ValueError(f"html must be one of {HTML_MODES}, got {mode!r}")
    if mode == "skip" or not page:
        return None, ""
    path = os.path.join(tempfile.mkdtemp(prefix="vli_html_"), "scene.html")
    with open(path, "w", encoding="utf-8") as f:
        f.write(page)
    return path, page if mode == "inline" else ""


@spaces.GPU(duration=60)
def generate_mesh(prompt):
    """GPU entry point for the generation tier (last in the fallback chain).

    ZeroGPU needs a @spaces.GPU function at startup. Nothing calls this until
    text-to-3D is wired in, so no quota is used.
    """
    raise NotImplementedError("generation tier is not wired up yet")


# ---------------- MCP tools ----------------

def check_words(sentence: str, primitives_json: str = "{}") -> str:
    """Check which words of a sentence exist in the 3D vocabulary, and complete the word being typed.

    A word is available only when a ready asset (SimReady or Objaverse) exists for it;
    "maps_to" names the asset it would get. Words with no asset are listed in "missing":
    rephrase them, or map each one to a primitive shape in primitives_json. Verbs (sat,
    stood, lay, shone, faced) and prepositions (on, under, beside, near, behind, in front
    of, above) are relations between meshes; whatever "shone" becomes the light source.

    Args:
        sentence: The English sentence, complete or partly typed.
        primitives_json: JSON object mapping missing words to shapes, e.g. {"sun": "sphere"}.
            Shapes: box, sphere, cylinder, cone, plane.

    Returns:
        JSON string: {"ok", "words": [{text, kind, available_in, maps_to | primitive}], "missing",
        "primitive_shapes", "completions"}.
    """
    try:
        return json.dumps(_check_words(sentence, _json(primitives_json, "primitives_json", {})))
    except Exception as e:
        _fail(e)


def translate_to_scene(sentence: str, primitives_json: str = "{}", seed: str = "0",
                       html: str = "url", pronouns: str = "recent") -> tuple[str, str, str, str, str]:
    """Translate an English sentence into a 3D scene.

    Produces the serial ledger (tokens in reading order, the items each word commits,
    and the unresolved questions), one configuration of that ledger chosen by `seed`,
    a rendered picture, a GLB model and a mini HTML page. The HTML is a self-contained
    Three.js scene with inline low-poly geometry: read it, edit it, build on it. Words
    with no asset must be mapped to a primitive in primitives_json (see check_words).

    Args:
        sentence: A sentence using available words, e.g. "the cat sat and the lamp glowed".
        primitives_json: JSON object mapping missing words to shapes, e.g. {"sun": "sphere"}.
        seed: Picks one of the configurations that satisfy the ledger (positions, facing, sun).
        html: "url" (default) returns the mini HTML as a file URL only; "inline" also returns
            its text (thousands of tokens: ask for it when building on the page); "skip" omits it.
            Iterate on the ledger and picture; the ledger can always re-create the page.
        pronouns: Default referent for it/he/she/they: "recent" (default) = the most recent
            object; "subject" = the subject of the most recent clause. Either way the choice is
            an implied question with the other objects as alternatives. Kept in the ledger.

    Returns:
        (ledger JSON string, rendered PNG, GLB scene file, mini HTML file, mini HTML text or "").
    """
    try:
        *rest, page = pipeline.translate(sentence, _json(primitives_json, "primitives_json", {}), _seed(seed),
                                         (pronouns or "recent").strip().lower())
        return (*rest, *_html_out(page, html))
    except Exception as e:
        _fail(e)


def refine_scene(ledger_json: str, changes_json: str = "[]", seed: str = "0",
                 html: str = "url") -> tuple[str, str, str, str, str]:
    """Refine a ledger: freeze some decisions and change the others, then re-render.

    Changes are a JSON list of:
        {"id": "n1", "freeze": true}      lock an item (false unlocks)
        {"id": "n1", "use": 2}            swap in alternatives[2]; the old choice takes slot 2,
                                          so using 2 again swaps back (other slots don't move)
        {"id": "n1", "reject": true}      reject the tier: simready -> objaverse -> primitive
                                          (generation is not wired up: a primitive can't be rejected)
        {"id": "n1", "height_m": 0.4}     set the object's height in metres
        {"id": "n1", "shape": "sphere"}   use a primitive (box, sphere, cylinder, cone, plane)
        {"reroll": true}                  every unfrozen object takes its next alternative;
                                          repeated rerolls visit the whole pool, whatever the seed
        {"id": "n12", "bind": "n1"}       n12 is the same thing as n1: merged into it, its relations move
        {"id": "q11", "bind": "n5"}       the word behind reference question q11 ("it", "the cat") means n5
    Frozen items cannot be changed. The configuration (placement, sun angle) is always
    re-chosen from `seed`: change the seed for another valid configuration.

    Args:
        ledger_json: Ledger JSON from translate_to_scene or a previous refine_scene.
        changes_json: JSON list of changes, as above.
        seed: Picks the configuration (placement, sun). Placement scales with each
            asset's size, so swapping an asset shifts offsets even at the same seed.
        html: "url" (default), "inline" or "skip", as in translate_to_scene.

    Returns:
        (ledger JSON string, rendered PNG, GLB scene file, mini HTML file, mini HTML text or "").
    """
    try:
        ledger = _json(ledger_json, "ledger_json")
        pipeline.apply_changes(ledger, _json(changes_json, "changes_json", []))
        *rest, page = pipeline.outputs(ledger, _seed(seed))
        return (*rest, *_html_out(page, html))
    except Exception as e:
        _fail(e)


def extend_scene(ledger_json: str, sentence: str, primitives_json: str = "{}", seed: str = "0",
                 html: str = "url", pronouns: str = "") -> tuple[str, str, str, str, str]:
    """Add the next sentence of a paragraph to an existing scene.

    Paragraphs are sentences plus bindings. "the cat" when a cat is already in the ledger,
    and pronouns (it, he, she, they), refer back to an existing item instead of adding one;
    each such reference is an implied question ("which cat? the one already mentioned",
    "'it' = the cat?") whose `binds` names the item. Rebind with refine_scene
    {"id": "<question id>", "bind": "<item id>"}. "a/an/another cat" always adds a new one.

    Args:
        ledger_json: Ledger JSON from translate_to_scene, refine_scene or a previous extend_scene.
        sentence: The next sentence, e.g. "the apple lay beside it".
        primitives_json: JSON object mapping missing words to shapes, as in translate_to_scene.
        seed: Picks one of the configurations that satisfy the whole ledger.
        html: "url" (default), "inline" or "skip", as in translate_to_scene.
        pronouns: "recent" or "subject", as in translate_to_scene; blank = the ledger's rule.

    Returns:
        (ledger JSON string, rendered PNG, GLB scene file, mini HTML file, mini HTML text or "").
    """
    try:
        ledger = _json(ledger_json, "ledger_json")
        *rest, page = pipeline.extend(ledger, sentence, _json(primitives_json, "primitives_json", {}), _seed(seed),
                                      (pronouns or "").strip().lower() or None)
        return (*rest, *_html_out(page, html))
    except Exception as e:
        _fail(e)


def simplify_item(ledger_json: str, item_id: str, html: str = "url", rings: str = "8",
                  samples: str = "16") -> tuple[str, list, list, str, str]:
    """Build the simplification ladder for one object: full asset -> fewer faces -> rings -> convex hull -> box.

    Look at where the object stops reading as its word: that rung is the least geometry
    that still carries the meaning, and the low rungs are the ones that can be made
    physically (fewer faces = fewer papercraft pieces).

    The rings rung is a second path: `rings` horizontal sections, each outline sampled at
    `samples` angles, joined into bands (one paper strip per band). The report lists every
    ring's height, centre and radii, so each vertex can be checked by hand.

    Args:
        ledger_json: Ledger JSON from translate_to_scene or refine_scene.
        item_id: The object item to simplify, e.g. "n1".
        html: "url" (default), "inline" or "skip", as in translate_to_scene. The ladder page
            holds several rungs of up to 1000 faces each, so inline is especially large.
        rings: Number of horizontal sections for the rings rung (2-64).
        samples: Points sampled around each section (3-128).

    Returns:
        (report JSON string, gallery of rung pictures, GLB file per rung,
        mini HTML file with the low rungs side by side, mini HTML text or "").
    """
    try:
        ledger = _json(ledger_json, "ledger_json")
        item = next((i for i in ledger["items"] if i["id"] == item_id), None)
        if item is None or item["kind"] != "object":
            raise ValueError(f"{item_id!r} is not an object item in this ledger")
        mesh = assets.settle(item)
        work = tempfile.mkdtemp(prefix="vli_ladder_")
        rungs = simplify.ladder(mesh, _count(rings, "rings", 2, 64), _count(samples, "samples", 3, 128))
        report, gallery, files = {"item": item_id, "word": item["word"], "rungs": []}, [], []
        for k, (label, rung) in enumerate(rungs):
            png = scene.render({item_id: rung}, os.path.join(work, f"{k}.png"), title=label)
            slug = re.sub(r"\W+", "_", label)
            glb = os.path.join(work, f"{k}_{slug}.glb")
            rung.export(glb)
            entry = {"rung": label, **simplify.stats(rung)}
            if "rings" in rung.metadata:
                entry["rings"] = rung.metadata["rings"]
            report["rungs"].append(entry)
            gallery.append((png, label))
            files.append(glb)
        return (json.dumps(report, indent=1), gallery, files,
                *_html_out(html_pages.ladder_page(item, rungs), html))
    except Exception as e:
        _fail(e)


def papercraft_rings(ledger_json: str, item_id: str, rings: str = "8", samples: str = "16",
                     height_cm: str = "") -> tuple[str, list, list]:
    """Make a printable papercraft net of one object from its rings rung, on A4 pages (see simplify_item).

    Every band between two rings unrolls exactly into one paper strip; bands that would
    curl past ~120 degrees on paper are split into parts (2a, 2b, ...). Glue tabs sit on
    one side of every joint and name the piece they glue to. Caps under 4 mm are left out
    (the band closes to a point there). Cut solid lines, fold dashed lines.

    Args:
        ledger_json: Ledger JSON from translate_to_scene or refine_scene.
        item_id: The object item to make, e.g. "n1".
        rings: Number of horizontal sections (2-64). Fewer rings = fewer, simpler pieces.
        samples: Points around each section (3-128). Fewer samples = fewer folds.
        height_cm: Height of the paper model in cm; blank = the largest model whose biggest
            piece still fits one A4 page. The report gives that A4 limit either way.

    Returns:
        (report JSON string with the pieces, pages and what glues to what, one PNG picture per A4
        page at 150 dpi to look at, one SVG file per A4 page in mm to print).
    """
    try:
        ledger = _json(ledger_json, "ledger_json")
        item = next((i for i in ledger["items"] if i["id"] == item_id), None)
        if item is None or item["kind"] != "object":
            raise ValueError(f"{item_id!r} is not an object item in this ledger")
        count, n = _count(rings, "rings", 2, 64), _count(samples, "samples", 3, 128)
        h = None
        if str(height_cm).strip():
            try:
                h = float(str(height_cm).strip())
            except ValueError:
                raise ValueError(f"height_cm must be a number, got {height_cm!r}")
            if not 1 <= h <= 200:
                raise ValueError(f"height_cm must be between 1 and 200, got {h}")
        _, profile = simplify.rings(assets.settle(item), count, n)
        if len(profile) < 2:
            raise ValueError(f"{item_id!r} has fewer than 2 sections; try more rings")
        real_cm = (profile[-1]["y"] - profile[0]["y"]) * 100
        a4_cm = papercraft.a4_height_cm(profile, n)
        pieces, scale = papercraft.net(profile, n, h or a4_cm)
        work, slug = tempfile.mkdtemp(prefix="vli_paper_"), re.sub(r"\W+", "_", item["word"])
        sheets = papercraft.pages(pieces)
        files, pictures, page_report = [], [], []
        for k, placed in enumerate(sheets, 1):
            title = f"{item['word']}: {len(profile)} rings x {n}, page {k}/{len(sheets)}"
            text, size = papercraft.svg(placed, title)
            path = os.path.join(work, f"{slug}_net_p{k}.svg")
            with open(path, "w", encoding="utf-8") as f:
                f.write(text)
            files.append(path)
            pictures.append((papercraft.png(placed, title, os.path.join(work, f"{slug}_net_p{k}.png")), f"page {k}"))
            page_report.append({"page": k, "size_mm": size, "pieces": [pc["name"] for pc, _ in placed]})
        report = {"item": item_id, "word": item["word"], "rings": len(profile), "samples": n,
                  "model_height_cm": round((profile[-1]["y"] - profile[0]["y"]) * scale / 10, 1),
                  "real_height_cm": round(real_cm, 1), "max_height_cm_for_a4": round(a4_cm, 1),
                  "fits_a4": bool(h is None or h <= a4_cm), "pages": page_report,
                  "pieces": [{"name": name, **j} for name, j in papercraft.joins(pieces).items()]}
        return json.dumps(report, indent=1), pictures, files
    except Exception as e:
        _fail(e)


# ---------------- UI-only helpers ----------------

def _suggestions(words_json):
    data = json.loads(words_json or "{}")
    marks = []
    for w in data.get("words", []):
        if w["kind"] == "unknown":
            marks.append(f"~~{w['text']}~~")
        elif w["kind"] == "noun":
            marks.append(f"**{w['text']}** → {w.get('maps_to', '?')}")
        elif w["kind"] == "primitive":
            marks.append(f"_{w['text']}_ ({w['primitive']})")
        else:
            marks.append(w["text"])
    status = " · ".join(marks) or "_start typing_"
    if data.get("missing"):
        status += "  \n_Struck-through words have no asset: pick a suggestion, rephrase, or choose a primitive._"
    missing = data.get("missing", [])
    return (status, gr.update(choices=data.get("completions", []), value=None),
            gr.update(choices=missing, value=missing[0] if missing else None))


def _complete(sentence, choice):
    if not choice:
        return sentence
    return re.sub(r"[A-Za-z]*$", "", sentence) + choice + " "


def _use_primitive(primitives_json, word, shape):
    prims = _json(primitives_json, "primitives_json", {})
    if word:
        prims[word] = shape
    return json.dumps(prims)


def _html_preview(path):
    """Live preview (sandboxed iframe) of the mini HTML file."""
    if not path:
        return ""
    with open(path, encoding="utf-8") as f:
        page = f.read()
    return (f'<iframe sandbox="allow-scripts" srcdoc="{html_lib.escape(page, quote=True)}" '
            'style="width:100%;height:460px;border:0;border-radius:8px"></iframe>')


EXAMPLE_CHANGES = '[\n  {"id": "n1", "freeze": true},\n  {"reroll": true}\n]'

DATASETS_MD = """
**Dictionary (public datasets, read-only, no account needed):**
1. [NVIDIA SimReady](https://github.com/NVIDIA-Omniverse/simready-central): metric OpenUSD assets,
   e.g. [nvidia/simready-assets](https://huggingface.co/datasets/nvidia/simready-assets). License per package
   (e.g. CC-BY-4.0). Only geometry and the thumbnail are downloaded, not the ~1 GB of textures.
2. [allenai/objaverse](https://huggingface.co/datasets/allenai/objaverse): the LVIS-labelled subset.
   License per object; non-commercial (NC) and no-derivatives (ND) objects are skipped.
3. Missing words: a primitive you choose (box, sphere, cylinder, cone, plane). Generation comes later.

This Space only visualises and stores no token. Discuss the results in your chat
(e.g. HuggingChat via MCP): every picture comes with a mini HTML page to build on,
as a URL by default (`html="inline"` returns its text).
"""

with gr.Blocks(title="VisualLearningIdea") as demo:
    gr.Markdown("# English → 3D\nType with the words the 3D dictionary has. **Bold** = an object asset; "
                "verbs and prepositions are relations between meshes.")
    gr.Markdown(DATASETS_MD)
    with gr.Row():
        sentence = gr.Textbox(label="Sentence", placeholder="the cat sat and the lamp glowed", scale=4)
        seed = gr.Textbox(label="Configuration seed", value="0", scale=1)
        pronoun_rule = gr.Radio(label="'it' means", choices=["recent", "subject"], value="recent", scale=1,
                                info="recent: last object · subject: last clause's subject")
    status = gr.Markdown()
    suggest = gr.Radio(label="Suggestions", choices=[], interactive=True)
    with gr.Row():
        missing = gr.Dropdown(label="Missing word", choices=[], interactive=True)
        shape = gr.Dropdown(label="Primitive", choices=PRIMITIVE_SHAPES, value="box", interactive=True)
        use_prim = gr.Button("Use primitive")
    primitives = gr.Code(label="Primitives", language="json", value="{}", interactive=True)
    words = gr.Code(label="Word check", language="json", visible=False)
    go = gr.Button("Translate", variant="primary")
    with gr.Row():
        picture = gr.Image(label="Picture", type="filepath")
        model = gr.Model3D(label="3D")
    with gr.Accordion("Mini HTML", open=False):
        html_mode = gr.Radio(label="HTML text in the result", choices=HTML_MODES, value="url",
                             info="url: file only · inline: also the text · skip: no page")
        scene_preview = gr.HTML()
        scene_file = gr.File(label="Download .html")
        scene_html = gr.Code(label="HTML", language="html")
    with gr.Accordion("Ledger: freeze and change (serial side)", open=False):
        ledger = gr.Code(label="Ledger", language="json", interactive=True)
        changes = gr.Code(label="Changes", language="json", value=EXAMPLE_CHANGES, interactive=True)
        refine = gr.Button("Refine")
        with gr.Row():
            next_sentence = gr.Textbox(label="Next sentence (refers back: 'the cat', 'it')", scale=4)
            extend = gr.Button("Add sentence", scale=1)
    with gr.Accordion("Simplification ladder", open=False):
        with gr.Row():
            item_id = gr.Textbox(label="Object item id", value="n1")
            ring_count = gr.Textbox(label="Rings", value="8")
            ring_samples = gr.Textbox(label="Samples per ring", value="16")
        simp = gr.Button("Simplify")
        ladder_pics = gr.Gallery(label="Rungs", columns=4)
        ladder_preview = gr.HTML()
        ladder_file = gr.File(label="Download .html")
        ladder_html = gr.Code(label="HTML", language="html")
        ladder_report = gr.Code(label="Report", language="json")
        ladder_files = gr.File(label="GLB per rung", file_count="multiple")
    with gr.Accordion("Papercraft net (rings)", open=False):
        paper_height = gr.Textbox(label="Model height in cm (blank = largest that fits A4)", value="")
        paper = gr.Button("Make net")
        paper_pages = gr.Gallery(label="A4 pages", columns=3)
        paper_file = gr.File(label="A4 pages to print (.svg, mm)", file_count="multiple")
        paper_report = gr.Code(label="Pieces", language="json")

    gr.on([sentence.change, primitives.change], check_words, [sentence, primitives], words,
          api_name="check_words", trigger_mode="always_last") \
        .then(_suggestions, words, [status, suggest, missing], api_visibility="private")
    suggest.select(_complete, [sentence, suggest], sentence, api_visibility="private")
    use_prim.click(_use_primitive, [primitives, missing, shape], primitives, api_visibility="private")
    go.click(translate_to_scene, [sentence, primitives, seed, html_mode, pronoun_rule],
             [ledger, picture, model, scene_file, scene_html], api_name="translate_to_scene") \
        .then(_html_preview, scene_file, scene_preview, api_visibility="private")
    refine.click(refine_scene, [ledger, changes, seed, html_mode],
                 [ledger, picture, model, scene_file, scene_html], api_name="refine_scene") \
        .then(_html_preview, scene_file, scene_preview, api_visibility="private")
    extend.click(extend_scene, [ledger, next_sentence, primitives, seed, html_mode, pronoun_rule],
                 [ledger, picture, model, scene_file, scene_html], api_name="extend_scene") \
        .then(_html_preview, scene_file, scene_preview, api_visibility="private")
    simp.click(simplify_item, [ledger, item_id, html_mode, ring_count, ring_samples],
               [ladder_report, ladder_pics, ladder_files, ladder_file, ladder_html], api_name="simplify_item") \
        .then(_html_preview, ladder_file, ladder_preview, api_visibility="private")
    paper.click(papercraft_rings, [ledger, item_id, ring_count, ring_samples, paper_height],
                [paper_report, paper_pages, paper_file], api_name="papercraft_rings")

if __name__ == "__main__":
    demo.launch(mcp_server=True)