Spaces:
Running on Zero
Running on Zero
kwabs22 Claude Opus 5.5 commited on
Commit ·
2fe7d5d
1
Parent(s): 363fb28
Return papercraft pages as PNG pictures as well as SVG files
Browse filesQA: the A4 SVGs came back with mime_type None, so chat embeds, <img> and
fetchers refused them and they were download-only. Each page is now also
drawn with matplotlib (same pieces, labels, folds and tabs, 150 dpi) and
returned as a gallery of PNGs; the SVGs stay for printing in mm. The UI
panel shows the PNG pages instead of an inline SVG preview.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- SPEC.md +4 -2
- app.py +11 -19
- vli/papercraft.py +41 -5
SPEC.md
CHANGED
|
@@ -111,7 +111,7 @@ Verbs: `sat/stood/lay/rested` → `on` (ground implied), `shone/glowed` → `lig
|
|
| 111 |
| `translate_to_scene` | sentence, primitives, seed, html → ledger, PNG, GLB, mini HTML file, mini HTML text |
|
| 112 |
| `refine_scene` | ledger, changes, seed, html → ledger, PNG, GLB, mini HTML file, mini HTML text |
|
| 113 |
| `simplify_item` | ledger, item id, html, rings, samples → report, rung PNGs, rung GLBs, mini HTML file, mini HTML text |
|
| 114 |
-
| `papercraft_rings` | ledger, item id, rings, samples, height_cm → pieces report,
|
| 115 |
|
| 116 |
`check_words` also returns `maps_to` per noun (the asset it would get), so a word
|
| 117 |
that exists but maps to the wrong thing shows up before submitting.
|
|
@@ -147,7 +147,9 @@ Cut solid lines, fold dashed lines. The report lists, per piece, `glues_to` (the
|
|
| 147 |
carries) and `receives_from` (pieces whose tabs land on it), so a tabless piece such as
|
| 148 |
the top cap still shows its joint.
|
| 149 |
|
| 150 |
-
Output is A4 pages (190 × 277 mm inside 10 mm margins)
|
|
|
|
|
|
|
| 151 |
narrowest orientation and shelf-packed; strips longer than a page are split like curled
|
| 152 |
ones. With `height_cm` blank the model is the tallest whose every piece fits a page,
|
| 153 |
which is set by band width and cap size, not strip length. The report gives
|
|
|
|
| 111 |
| `translate_to_scene` | sentence, primitives, seed, html → ledger, PNG, GLB, mini HTML file, mini HTML text |
|
| 112 |
| `refine_scene` | ledger, changes, seed, html → ledger, PNG, GLB, mini HTML file, mini HTML text |
|
| 113 |
| `simplify_item` | ledger, item id, html, rings, samples → report, rung PNGs, rung GLBs, mini HTML file, mini HTML text |
|
| 114 |
+
| `papercraft_rings` | ledger, item id, rings, samples, height_cm → pieces report, PNG per A4 page (to look at), SVG per A4 page (mm, to print) |
|
| 115 |
|
| 116 |
`check_words` also returns `maps_to` per noun (the asset it would get), so a word
|
| 117 |
that exists but maps to the wrong thing shows up before submitting.
|
|
|
|
| 147 |
carries) and `receives_from` (pieces whose tabs land on it), so a tabless piece such as
|
| 148 |
the top cap still shows its joint.
|
| 149 |
|
| 150 |
+
Output is A4 pages (190 × 277 mm inside 10 mm margins), each as a 150 dpi PNG (chats,
|
| 151 |
+
`<img>` and fetchers show PNGs; SVG files come back without a MIME type) and as an SVG
|
| 152 |
+
in millimetres for printing. Each piece is turned to its
|
| 153 |
narrowest orientation and shelf-packed; strips longer than a page are split like curled
|
| 154 |
ones. With `height_cm` blank the model is the tallest whose every piece fits a page,
|
| 155 |
which is set by band width and cap size, not strip length. The report gives
|
app.py
CHANGED
|
@@ -230,7 +230,7 @@ def simplify_item(ledger_json: str, item_id: str, html: str = "url", rings: str
|
|
| 230 |
|
| 231 |
|
| 232 |
def papercraft_rings(ledger_json: str, item_id: str, rings: str = "8", samples: str = "16",
|
| 233 |
-
height_cm: str = "") -> tuple[str, list]:
|
| 234 |
"""Make a printable papercraft net of one object from its rings rung, on A4 pages (see simplify_item).
|
| 235 |
|
| 236 |
Every band between two rings unrolls exactly into one paper strip; bands that would
|
|
@@ -247,7 +247,8 @@ def papercraft_rings(ledger_json: str, item_id: str, rings: str = "8", samples:
|
|
| 247 |
piece still fits one A4 page. The report gives that A4 limit either way.
|
| 248 |
|
| 249 |
Returns:
|
| 250 |
-
(report JSON string with the pieces, pages and what glues to what, one
|
|
|
|
| 251 |
"""
|
| 252 |
try:
|
| 253 |
ledger = _json(ledger_json, "ledger_json")
|
|
@@ -271,20 +272,22 @@ def papercraft_rings(ledger_json: str, item_id: str, rings: str = "8", samples:
|
|
| 271 |
pieces, scale = papercraft.net(profile, n, h or a4_cm)
|
| 272 |
work, slug = tempfile.mkdtemp(prefix="vli_paper_"), re.sub(r"\W+", "_", item["word"])
|
| 273 |
sheets = papercraft.pages(pieces)
|
| 274 |
-
files, page_report = [], []
|
| 275 |
for k, placed in enumerate(sheets, 1):
|
| 276 |
-
|
|
|
|
| 277 |
path = os.path.join(work, f"{slug}_net_p{k}.svg")
|
| 278 |
with open(path, "w", encoding="utf-8") as f:
|
| 279 |
f.write(text)
|
| 280 |
files.append(path)
|
|
|
|
| 281 |
page_report.append({"page": k, "size_mm": size, "pieces": [pc["name"] for pc, _ in placed]})
|
| 282 |
report = {"item": item_id, "word": item["word"], "rings": len(profile), "samples": n,
|
| 283 |
"model_height_cm": round((profile[-1]["y"] - profile[0]["y"]) * scale / 10, 1),
|
| 284 |
"real_height_cm": round(real_cm, 1), "max_height_cm_for_a4": round(a4_cm, 1),
|
| 285 |
"fits_a4": bool(h is None or h <= a4_cm), "pages": page_report,
|
| 286 |
"pieces": [{"name": name, **j} for name, j in papercraft.joins(pieces).items()]}
|
| 287 |
-
return json.dumps(report, indent=1), files
|
| 288 |
except Exception as e:
|
| 289 |
_fail(e)
|
| 290 |
|
|
@@ -324,17 +327,6 @@ def _use_primitive(primitives_json, word, shape):
|
|
| 324 |
return json.dumps(prims)
|
| 325 |
|
| 326 |
|
| 327 |
-
def _svg_preview(files):
|
| 328 |
-
"""Show the SVG pages side by side, scaled to the panel."""
|
| 329 |
-
pages = []
|
| 330 |
-
for path in files or []:
|
| 331 |
-
with open(path, encoding="utf-8") as f:
|
| 332 |
-
text = f.read()
|
| 333 |
-
pages.append(text.replace("<svg ", '<svg style="width:100%;height:auto;border:1px solid #ccc" ', 1))
|
| 334 |
-
cells = "".join(f'<div style="flex:1 1 260px;max-width:420px">{p}</div>' for p in pages)
|
| 335 |
-
return f'<div style="display:flex;flex-wrap:wrap;gap:8px">{cells}</div>' if pages else ""
|
| 336 |
-
|
| 337 |
-
|
| 338 |
def _html_preview(path):
|
| 339 |
"""Live preview (sandboxed iframe) of the mini HTML file."""
|
| 340 |
if not path:
|
|
@@ -405,8 +397,8 @@ with gr.Blocks(title="VisualLearningIdea") as demo:
|
|
| 405 |
with gr.Accordion("Papercraft net (rings)", open=False):
|
| 406 |
paper_height = gr.Textbox(label="Model height in cm (blank = largest that fits A4)", value="")
|
| 407 |
paper = gr.Button("Make net")
|
| 408 |
-
|
| 409 |
-
paper_file = gr.File(label="A4 pages (.svg, mm)", file_count="multiple")
|
| 410 |
paper_report = gr.Code(label="Pieces", language="json")
|
| 411 |
|
| 412 |
gr.on([sentence.change, primitives.change], check_words, [sentence, primitives], words,
|
|
@@ -424,7 +416,7 @@ with gr.Blocks(title="VisualLearningIdea") as demo:
|
|
| 424 |
[ladder_report, ladder_pics, ladder_files, ladder_file, ladder_html], api_name="simplify_item") \
|
| 425 |
.then(_html_preview, ladder_file, ladder_preview, api_visibility="private")
|
| 426 |
paper.click(papercraft_rings, [ledger, item_id, ring_count, ring_samples, paper_height],
|
| 427 |
-
[paper_report, paper_file], api_name="papercraft_rings")
|
| 428 |
|
| 429 |
if __name__ == "__main__":
|
| 430 |
demo.launch(mcp_server=True)
|
|
|
|
| 230 |
|
| 231 |
|
| 232 |
def papercraft_rings(ledger_json: str, item_id: str, rings: str = "8", samples: str = "16",
|
| 233 |
+
height_cm: str = "") -> tuple[str, list, list]:
|
| 234 |
"""Make a printable papercraft net of one object from its rings rung, on A4 pages (see simplify_item).
|
| 235 |
|
| 236 |
Every band between two rings unrolls exactly into one paper strip; bands that would
|
|
|
|
| 247 |
piece still fits one A4 page. The report gives that A4 limit either way.
|
| 248 |
|
| 249 |
Returns:
|
| 250 |
+
(report JSON string with the pieces, pages and what glues to what, one PNG picture per A4
|
| 251 |
+
page at 150 dpi to look at, one SVG file per A4 page in mm to print).
|
| 252 |
"""
|
| 253 |
try:
|
| 254 |
ledger = _json(ledger_json, "ledger_json")
|
|
|
|
| 272 |
pieces, scale = papercraft.net(profile, n, h or a4_cm)
|
| 273 |
work, slug = tempfile.mkdtemp(prefix="vli_paper_"), re.sub(r"\W+", "_", item["word"])
|
| 274 |
sheets = papercraft.pages(pieces)
|
| 275 |
+
files, pictures, page_report = [], [], []
|
| 276 |
for k, placed in enumerate(sheets, 1):
|
| 277 |
+
title = f"{item['word']}: {len(profile)} rings x {n}, page {k}/{len(sheets)}"
|
| 278 |
+
text, size = papercraft.svg(placed, title)
|
| 279 |
path = os.path.join(work, f"{slug}_net_p{k}.svg")
|
| 280 |
with open(path, "w", encoding="utf-8") as f:
|
| 281 |
f.write(text)
|
| 282 |
files.append(path)
|
| 283 |
+
pictures.append((papercraft.png(placed, title, os.path.join(work, f"{slug}_net_p{k}.png")), f"page {k}"))
|
| 284 |
page_report.append({"page": k, "size_mm": size, "pieces": [pc["name"] for pc, _ in placed]})
|
| 285 |
report = {"item": item_id, "word": item["word"], "rings": len(profile), "samples": n,
|
| 286 |
"model_height_cm": round((profile[-1]["y"] - profile[0]["y"]) * scale / 10, 1),
|
| 287 |
"real_height_cm": round(real_cm, 1), "max_height_cm_for_a4": round(a4_cm, 1),
|
| 288 |
"fits_a4": bool(h is None or h <= a4_cm), "pages": page_report,
|
| 289 |
"pieces": [{"name": name, **j} for name, j in papercraft.joins(pieces).items()]}
|
| 290 |
+
return json.dumps(report, indent=1), pictures, files
|
| 291 |
except Exception as e:
|
| 292 |
_fail(e)
|
| 293 |
|
|
|
|
| 327 |
return json.dumps(prims)
|
| 328 |
|
| 329 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 330 |
def _html_preview(path):
|
| 331 |
"""Live preview (sandboxed iframe) of the mini HTML file."""
|
| 332 |
if not path:
|
|
|
|
| 397 |
with gr.Accordion("Papercraft net (rings)", open=False):
|
| 398 |
paper_height = gr.Textbox(label="Model height in cm (blank = largest that fits A4)", value="")
|
| 399 |
paper = gr.Button("Make net")
|
| 400 |
+
paper_pages = gr.Gallery(label="A4 pages", columns=3)
|
| 401 |
+
paper_file = gr.File(label="A4 pages to print (.svg, mm)", file_count="multiple")
|
| 402 |
paper_report = gr.Code(label="Pieces", language="json")
|
| 403 |
|
| 404 |
gr.on([sentence.change, primitives.change], check_words, [sentence, primitives], words,
|
|
|
|
| 416 |
[ladder_report, ladder_pics, ladder_files, ladder_file, ladder_html], api_name="simplify_item") \
|
| 417 |
.then(_html_preview, ladder_file, ladder_preview, api_visibility="private")
|
| 418 |
paper.click(papercraft_rings, [ledger, item_id, ring_count, ring_samples, paper_height],
|
| 419 |
+
[paper_report, paper_pages, paper_file], api_name="papercraft_rings")
|
| 420 |
|
| 421 |
if __name__ == "__main__":
|
| 422 |
demo.launch(mcp_server=True)
|
vli/papercraft.py
CHANGED
|
@@ -20,6 +20,7 @@ MARGIN_MM = 10.0
|
|
| 20 |
HEADER_MM = 8.0
|
| 21 |
STRIP_MAX_MM = PAGE_MM[1] - HEADER_MM - 2 * TAB_MM # longer strips are split
|
| 22 |
MIN_CAP_MM = 4.0
|
|
|
|
| 23 |
MIN_TAB_MM = 2.0
|
| 24 |
LABEL_TAB_MM = 8.0
|
| 25 |
|
|
@@ -212,9 +213,7 @@ def pages(pieces):
|
|
| 212 |
|
| 213 |
def svg(placed, title):
|
| 214 |
"""One A4 page (millimetres, 10 mm margins) of placed pieces."""
|
| 215 |
-
|
| 216 |
-
width = max(PAGE_MM[0], float(pts_all[:, 0].max())) + 2 * MARGIN_MM
|
| 217 |
-
height = max(PAGE_MM[1], float(pts_all[:, 1].max())) + 2 * MARGIN_MM
|
| 218 |
|
| 219 |
def pts(poly, off):
|
| 220 |
return " ".join(f"{p[0] + off[0] + MARGIN_MM:.1f},{p[1] + off[1] + MARGIN_MM:.1f}" for p in poly)
|
|
@@ -225,8 +224,7 @@ def svg(placed, title):
|
|
| 225 |
out = [f'<svg xmlns="http://www.w3.org/2000/svg" width="{width:.0f}mm" height="{height:.0f}mm" '
|
| 226 |
f'viewBox="0 0 {width:.1f} {height:.1f}" font-family="sans-serif">',
|
| 227 |
'<rect width="100%" height="100%" fill="#fff"/>',
|
| 228 |
-
f'<text x="{MARGIN_MM}" y="{MARGIN_MM + 4}" font-size="4">{_esc(title)}:
|
| 229 |
-
f'glue tabs to the named piece</text>']
|
| 230 |
for pc, off in placed:
|
| 231 |
for t in pc["tabs"]:
|
| 232 |
out.append(f'<polygon points="{pts(t["poly"], off)}" fill="#eee" stroke="#000" stroke-width="0.2"/>')
|
|
@@ -247,6 +245,44 @@ def svg(placed, title):
|
|
| 247 |
return "\n".join(out), (round(width), round(height))
|
| 248 |
|
| 249 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
def joins(pieces):
|
| 251 |
"""Both sides of every joint. Tabs sit on one side only (the last band carries the
|
| 252 |
tabs for the top cap), so a piece lists the tabs it carries and the pieces whose
|
|
|
|
| 20 |
HEADER_MM = 8.0
|
| 21 |
STRIP_MAX_MM = PAGE_MM[1] - HEADER_MM - 2 * TAB_MM # longer strips are split
|
| 22 |
MIN_CAP_MM = 4.0
|
| 23 |
+
LEGEND = "cut solid, fold dashed, glue tabs to the named piece"
|
| 24 |
MIN_TAB_MM = 2.0
|
| 25 |
LABEL_TAB_MM = 8.0
|
| 26 |
|
|
|
|
| 213 |
|
| 214 |
def svg(placed, title):
|
| 215 |
"""One A4 page (millimetres, 10 mm margins) of placed pieces."""
|
| 216 |
+
width, height = _page_size(placed)
|
|
|
|
|
|
|
| 217 |
|
| 218 |
def pts(poly, off):
|
| 219 |
return " ".join(f"{p[0] + off[0] + MARGIN_MM:.1f},{p[1] + off[1] + MARGIN_MM:.1f}" for p in poly)
|
|
|
|
| 224 |
out = [f'<svg xmlns="http://www.w3.org/2000/svg" width="{width:.0f}mm" height="{height:.0f}mm" '
|
| 225 |
f'viewBox="0 0 {width:.1f} {height:.1f}" font-family="sans-serif">',
|
| 226 |
'<rect width="100%" height="100%" fill="#fff"/>',
|
| 227 |
+
f'<text x="{MARGIN_MM}" y="{MARGIN_MM + 4}" font-size="4">{_esc(title)}: {LEGEND}</text>']
|
|
|
|
| 228 |
for pc, off in placed:
|
| 229 |
for t in pc["tabs"]:
|
| 230 |
out.append(f'<polygon points="{pts(t["poly"], off)}" fill="#eee" stroke="#000" stroke-width="0.2"/>')
|
|
|
|
| 245 |
return "\n".join(out), (round(width), round(height))
|
| 246 |
|
| 247 |
|
| 248 |
+
def _page_size(placed):
|
| 249 |
+
pts_all = np.concatenate([_points(pc) + off for pc, off in placed])
|
| 250 |
+
return (max(PAGE_MM[0], float(pts_all[:, 0].max())) + 2 * MARGIN_MM,
|
| 251 |
+
max(PAGE_MM[1], float(pts_all[:, 1].max())) + 2 * MARGIN_MM)
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def png(placed, title, path, dpi=150):
|
| 255 |
+
"""The same page as svg(), rasterised: chats, <img> tags and fetchers show PNGs, not SVG files."""
|
| 256 |
+
import matplotlib
|
| 257 |
+
matplotlib.use("Agg")
|
| 258 |
+
import matplotlib.pyplot as plt
|
| 259 |
+
from matplotlib.patches import Polygon
|
| 260 |
+
|
| 261 |
+
width, height = _page_size(placed)
|
| 262 |
+
fig = plt.figure(figsize=(width / 25.4, height / 25.4), dpi=dpi)
|
| 263 |
+
ax = fig.add_axes([0, 0, 1, 1])
|
| 264 |
+
ax.set_xlim(0, width)
|
| 265 |
+
ax.set_ylim(height, 0)
|
| 266 |
+
ax.axis("off")
|
| 267 |
+
pt = 72 / 25.4 # points per mm
|
| 268 |
+
m = np.array([MARGIN_MM, MARGIN_MM])
|
| 269 |
+
ax.text(MARGIN_MM, MARGIN_MM + 4, f"{title}: {LEGEND}", fontsize=4 * pt, va="baseline")
|
| 270 |
+
for pc, off in placed:
|
| 271 |
+
o = off + m
|
| 272 |
+
for t in pc["tabs"]:
|
| 273 |
+
ax.add_patch(Polygon(np.array(t["poly"]) + o, facecolor="#eee", edgecolor="k", linewidth=0.2 * pt))
|
| 274 |
+
if t["show"]:
|
| 275 |
+
ax.text(*(t["at"] + o), t["label"], fontsize=2 * pt, ha="center", va="center")
|
| 276 |
+
ax.add_patch(Polygon(np.array(pc["outline"]) + o, facecolor="#fff", edgecolor="k", linewidth=0.3 * pt))
|
| 277 |
+
for p, q in pc["folds"]:
|
| 278 |
+
ax.plot([p[0] + o[0], q[0] + o[0]], [p[1] + o[1], q[1] + o[1]], color="#888",
|
| 279 |
+
linewidth=0.2 * pt, linestyle=(0, (1.5 / 0.2, 1 / 0.2)))
|
| 280 |
+
ax.text(*(np.mean(pc["outline"], axis=0) + o), pc["name"], fontsize=4 * pt, ha="center", va="center")
|
| 281 |
+
fig.savefig(path, facecolor="white")
|
| 282 |
+
plt.close(fig)
|
| 283 |
+
return path
|
| 284 |
+
|
| 285 |
+
|
| 286 |
def joins(pieces):
|
| 287 |
"""Both sides of every joint. Tabs sit on one side only (the last band carries the
|
| 288 |
tabs for the top cap), so a piece lists the tabs it carries and the pieces whose
|