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 files

QA: 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>

Files changed (3) hide show
  1. SPEC.md +4 -2
  2. app.py +11 -19
  3. 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, one SVG per A4 page (mm) |
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). Each piece is turned to its
 
 
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 SVG file per A4 page in mm).
 
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
- text, size = papercraft.svg(placed, f"{item['word']}: {len(profile)} rings x {n}, page {k}/{len(sheets)}")
 
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
- paper_preview = gr.HTML()
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") .then(_svg_preview, paper_file, paper_preview, api_visibility="private")
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
- pts_all = np.concatenate([_points(pc) + off for pc, off in placed])
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)}: cut solid, fold dashed, '
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