kwabs22 Claude Opus 5.5 commited on
Commit
e1d8e60
·
1 Parent(s): 92d401f

Lay papercraft nets out on A4 pages, scaled to fit by default

Browse files

Each piece turns to its narrowest orientation and is shelf-packed onto A4
pages (one SVG per page). Strips longer than a page are split, so the model
size is limited by band width and cap size rather than strip length. With
height_cm blank the model is the tallest whose every piece fits a page
(a cat-sized ellipsoid: 13.8 cm, was 4.8 cm before splitting long strips).
The report adds real_height_cm, max_height_cm_for_a4 and fits_a4.

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

Files changed (3) hide show
  1. SPEC.md +9 -3
  2. app.py +34 -23
  3. vli/papercraft.py +103 -29
SPEC.md CHANGED
@@ -100,7 +100,7 @@ Verbs: `sat/stood/lay/rested` → `on` (ground implied), `shone/glowed` → `lig
100
  | `translate_to_scene` | sentence, primitives, seed, html → ledger, PNG, GLB, mini HTML file, mini HTML text |
101
  | `refine_scene` | ledger, changes, seed, html → ledger, PNG, GLB, mini HTML file, mini HTML text |
102
  | `simplify_item` | ledger, item id, html, rings, samples → report, rung PNGs, rung GLBs, mini HTML file, mini HTML text |
103
- | `papercraft_rings` | ledger, item id, rings, samples, height_cm → pieces report, SVG net (mm) |
104
 
105
  `check_words` also returns `maps_to` per noun (the asset it would get), so a word
106
  that exists but maps to the wrong thing shows up before submitting.
@@ -131,8 +131,14 @@ that would curl past ~120° on paper overlaps itself, so it is split into parts
131
  Every joint has glue tabs on one side only, labelled with the piece they meet (band top
132
  edge → next band or top cap, seam end → own start or next part, bottom cap rim → band 1).
133
  Caps under 4 mm are dropped and the band closes to a point; tabs under 2 mm are dropped.
134
- Cut solid lines, fold dashed lines. Pieces are shelf-packed in 180 mm rows; a piece
135
- wider than that widens the sheet (use `height_cm` to print on A4).
 
 
 
 
 
 
136
 
137
  ### Mini HTML
138
 
 
100
  | `translate_to_scene` | sentence, primitives, seed, html → ledger, PNG, GLB, mini HTML file, mini HTML text |
101
  | `refine_scene` | ledger, changes, seed, html → ledger, PNG, GLB, mini HTML file, mini HTML text |
102
  | `simplify_item` | ledger, item id, html, rings, samples → report, rung PNGs, rung GLBs, mini HTML file, mini HTML text |
103
+ | `papercraft_rings` | ledger, item id, rings, samples, height_cm → pieces report, one SVG per A4 page (mm) |
104
 
105
  `check_words` also returns `maps_to` per noun (the asset it would get), so a word
106
  that exists but maps to the wrong thing shows up before submitting.
 
131
  Every joint has glue tabs on one side only, labelled with the piece they meet (band top
132
  edge → next band or top cap, seam end → own start or next part, bottom cap rim → band 1).
133
  Caps under 4 mm are dropped and the band closes to a point; tabs under 2 mm are dropped.
134
+ Cut solid lines, fold dashed lines.
135
+
136
+ Output is A4 pages (190 × 277 mm inside 10 mm margins). Each piece is turned to its
137
+ narrowest orientation and shelf-packed; strips longer than a page are split like curled
138
+ ones. With `height_cm` blank the model is the tallest whose every piece fits a page,
139
+ which is set by band width and cap size, not strip length. The report gives
140
+ `real_height_cm`, `max_height_cm_for_a4` and `fits_a4`; a piece too big for A4 at a
141
+ requested height gets a page of its own size.
142
 
143
  ### Mini HTML
144
 
app.py CHANGED
@@ -230,8 +230,8 @@ 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, str]:
234
- """Make a printable papercraft net of one object from its rings rung (see simplify_item).
235
 
236
  Every band between two rings unrolls exactly into one paper strip; bands that would
237
  curl past ~120 degrees on paper are split into parts (2a, 2b, ...). Glue tabs sit on
@@ -243,10 +243,11 @@ def papercraft_rings(ledger_json: str, item_id: str, rings: str = "8", samples:
243
  item_id: The object item to make, e.g. "n1".
244
  rings: Number of horizontal sections (2-64). Fewer rings = fewer, simpler pieces.
245
  samples: Points around each section (3-128). Fewer samples = fewer folds.
246
- height_cm: Height of the paper model in cm; blank = the object's real size.
 
247
 
248
  Returns:
249
- (report JSON string with the pieces, sheet size and what glues to what, SVG net file in mm).
250
  """
251
  try:
252
  ledger = _json(ledger_json, "ledger_json")
@@ -265,17 +266,26 @@ def papercraft_rings(ledger_json: str, item_id: str, rings: str = "8", samples:
265
  _, profile = simplify.rings(assets.settle(item), count, n)
266
  if len(profile) < 2:
267
  raise ValueError(f"{item_id!r} has fewer than 2 sections; try more rings")
268
- pieces, scale = papercraft.net(profile, n, h)
269
- text, sheet = papercraft.svg(pieces, f"{item['word']}: {len(profile)} rings x {n}")
270
- path = os.path.join(tempfile.mkdtemp(prefix="vli_paper_"), re.sub(r"\W+", "_", item["word"]) + "_net.svg")
271
- with open(path, "w", encoding="utf-8") as f:
272
- f.write(text)
 
 
 
 
 
 
 
 
273
  report = {"item": item_id, "word": item["word"], "rings": len(profile), "samples": n,
274
  "model_height_cm": round((profile[-1]["y"] - profile[0]["y"]) * scale / 10, 1),
275
- "sheet_mm": sheet, "pieces": [{"name": pc["name"],
276
- "glues_to": sorted({t["label"] for t in pc["tabs"] if t["label"]})}
277
- for pc in pieces]}
278
- return json.dumps(report, indent=1), path
 
279
  except Exception as e:
280
  _fail(e)
281
 
@@ -315,14 +325,15 @@ def _use_primitive(primitives_json, word, shape):
315
  return json.dumps(prims)
316
 
317
 
318
- def _svg_preview(path):
319
- """Show the SVG net scaled to the panel width."""
320
- if not path:
321
- return ""
322
- with open(path, encoding="utf-8") as f:
323
- text = f.read()
324
- text = text.replace("<svg ", '<svg style="width:100%;height:auto" ', 1)
325
- return f'<div style="background:#fff;padding:8px;border-radius:8px">{text}</div>'
 
326
 
327
 
328
  def _html_preview(path):
@@ -393,10 +404,10 @@ with gr.Blocks(title="VisualLearningIdea") as demo:
393
  ladder_report = gr.Code(label="Report", language="json")
394
  ladder_files = gr.File(label="GLB per rung", file_count="multiple")
395
  with gr.Accordion("Papercraft net (rings)", open=False):
396
- paper_height = gr.Textbox(label="Model height in cm (blank = real size)", value="")
397
  paper = gr.Button("Make net")
398
  paper_preview = gr.HTML()
399
- paper_file = gr.File(label="Download .svg (mm)")
400
  paper_report = gr.Code(label="Pieces", language="json")
401
 
402
  gr.on([sentence.change, primitives.change], check_words, [sentence, primitives], words,
 
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
237
  curl past ~120 degrees on paper are split into parts (2a, 2b, ...). Glue tabs sit on
 
243
  item_id: The object item to make, e.g. "n1".
244
  rings: Number of horizontal sections (2-64). Fewer rings = fewer, simpler pieces.
245
  samples: Points around each section (3-128). Fewer samples = fewer folds.
246
+ height_cm: Height of the paper model in cm; blank = the largest model whose biggest
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")
 
266
  _, profile = simplify.rings(assets.settle(item), count, n)
267
  if len(profile) < 2:
268
  raise ValueError(f"{item_id!r} has fewer than 2 sections; try more rings")
269
+ real_cm = (profile[-1]["y"] - profile[0]["y"]) * 100
270
+ a4_cm = papercraft.a4_height_cm(profile, n)
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": pc["name"], "glues_to": sorted({t["label"] for t in pc["tabs"] if t["label"]})}
287
+ for pc in pieces]}
288
+ return json.dumps(report, indent=1), files
289
  except Exception as e:
290
  _fail(e)
291
 
 
325
  return json.dumps(prims)
326
 
327
 
328
+ def _svg_preview(files):
329
+ """Show the SVG pages side by side, scaled to the panel."""
330
+ pages = []
331
+ for path in files or []:
332
+ with open(path, encoding="utf-8") as f:
333
+ text = f.read()
334
+ pages.append(text.replace("<svg ", '<svg style="width:100%;height:auto;border:1px solid #ccc" ', 1))
335
+ cells = "".join(f'<div style="flex:1 1 260px;max-width:420px">{p}</div>' for p in pages)
336
+ return f'<div style="display:flex;flex-wrap:wrap;gap:8px">{cells}</div>' if pages else ""
337
 
338
 
339
  def _html_preview(path):
 
404
  ladder_report = gr.Code(label="Report", language="json")
405
  ladder_files = gr.File(label="GLB per rung", file_count="multiple")
406
  with gr.Accordion("Papercraft net (rings)", open=False):
407
+ paper_height = gr.Textbox(label="Model height in cm (blank = largest that fits A4)", value="")
408
  paper = gr.Button("Make net")
409
  paper_preview = gr.HTML()
410
+ paper_file = gr.File(label="A4 pages (.svg, mm)", file_count="multiple")
411
  paper_report = gr.Code(label="Pieces", language="json")
412
 
413
  gr.on([sentence.change, primitives.change], check_words, [sentence, primitives], words,
vli/papercraft.py CHANGED
@@ -7,13 +7,18 @@ Each joint gets glue tabs on exactly one side, labelled with the piece it meets:
7
  band i seam end -> its own seam start (or the next part, when a band is split)
8
  bottom cap rim -> band 1 bottom edge
9
  A band that would curl past ~120 degrees on paper is split into parts (2a, 2b, ...).
10
- Output is an SVG in millimetres, cut lines solid, fold lines dashed.
 
 
11
  """
12
  import numpy as np
13
 
14
  TAB_MM = 6.0
15
  GAP_MM = 8.0
16
- SHEET_MM = 180.0 # row width for laying out pieces (A4 printable width)
 
 
 
17
  MIN_CAP_MM = 4.0
18
  MIN_TAB_MM = 2.0
19
  LABEL_TAB_MM = 8.0
@@ -26,6 +31,12 @@ def _ring_points(p, dirs):
26
  return np.c_[cx + r[:, 0] * dirs[:, 0], np.full(len(dirs), p["y"]), cz + r[:, 0] * dirs[:, 1]]
27
 
28
 
 
 
 
 
 
 
29
  def _place(a2, c2, da, dc, away2):
30
  """The 2D point at distance da from a2 and dc from c2, on the side away from away2."""
31
  d = np.linalg.norm(c2 - a2)
@@ -79,15 +90,17 @@ def _tab(p, q, inside, label):
79
  "at": (p + q) / 2 + n * w / 2}
80
 
81
 
82
- def _chunks(B, max_turn_deg=120):
83
- """Split a strip where its bottom edge has turned too far: a strip that curls
84
- past a half circle overlaps itself on paper (bands near a pole)."""
 
85
  S = len(B) - 1
86
  edges = np.diff(B, axis=0)
87
  heading = np.unwrap(np.arctan2(edges[:, 1], edges[:, 0]))
 
88
  cuts, start = [0], 0
89
  for j in range(1, S):
90
- if abs(heading[j] - heading[start]) > np.radians(max_turn_deg):
91
  cuts.append(j)
92
  start = j
93
  return list(zip(cuts, cuts[1:] + [S]))
@@ -98,18 +111,16 @@ def net(profile, samples, height_cm=None):
98
 
99
  Caps under MIN_CAP_MM across are left out: the band there closes to a point.
100
  """
101
- ang = np.linspace(0, 2 * np.pi, samples, endpoint=False)
102
- dirs = np.c_[np.cos(ang), np.sin(ang)]
103
  real_h = profile[-1]["y"] - profile[0]["y"]
104
  scale = 1000.0 if not height_cm else 10.0 * float(height_cm) / max(real_h, 1e-6)
105
- rings = [_ring_points(p, dirs) * scale for p in profile]
106
  caps = {k: 2 * max(profile[i]["radii"]) * scale >= MIN_CAP_MM for k, i in (("bottom", 0), ("top", -1))}
107
  n = len(rings) - 1
108
  pieces = []
109
  for i in range(n):
110
  B, T, _ = _unroll_band(rings[i], rings[i + 1])
111
  above = f"band {i + 2}" if i + 1 < n else ("top cap" if caps["top"] else None)
112
- parts = _chunks(B)
113
  names = [f"band {i + 1}" + (chr(ord("a") + k) if len(parts) > 1 else "") for k in range(len(parts))]
114
  for k, (j0, j1) in enumerate(parts):
115
  mid = (B[j0:j1 + 1].mean(axis=0) + T[j0:j1 + 1].mean(axis=0)) / 2
@@ -131,44 +142,107 @@ def net(profile, samples, height_cm=None):
131
  return pieces, scale
132
 
133
 
134
- def _bounds(piece):
135
- pts = np.array(list(piece["outline"]) + [p for t in piece["tabs"] for p in t["poly"]])
136
- return pts.min(axis=0), pts.max(axis=0)
137
 
138
 
139
- def svg(pieces, title):
140
- """Shelf-pack the pieces into rows and draw them. Units are millimetres."""
141
- x = y = row_h = 0.0
142
- width = max(SHEET_MM, max(float((hi - lo)[0]) for lo, hi in map(_bounds, pieces)))
143
- placed = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
  for pc in pieces:
145
- lo, hi = _bounds(pc)
146
- w, h = hi - lo
147
- if x > 0 and x + w > width:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
  x, y, row_h = 0.0, y + row_h + GAP_MM, 0.0
149
- placed.append((pc, np.array([x, y + 10.0]) - lo))
 
 
 
150
  x, row_h = x + w + GAP_MM, max(row_h, h)
151
- height = y + row_h + 10.0 + GAP_MM
 
 
 
 
 
 
 
 
 
152
 
153
  def pts(poly, off):
154
- return " ".join(f"{p[0] + off[0]:.1f},{p[1] + off[1]:.1f}" for p in poly)
 
 
 
155
 
156
  out = [f'<svg xmlns="http://www.w3.org/2000/svg" width="{width:.0f}mm" height="{height:.0f}mm" '
157
  f'viewBox="0 0 {width:.1f} {height:.1f}" font-family="sans-serif">',
158
- f'<text x="0" y="5" font-size="4">{_esc(title)}: cut solid, fold dashed, glue tabs to the named piece</text>']
 
 
159
  for pc, off in placed:
160
  for t in pc["tabs"]:
161
  out.append(f'<polygon points="{pts(t["poly"], off)}" fill="#eee" stroke="#000" stroke-width="0.2"/>')
162
  if not t["label"]:
163
  continue
164
- a = t["at"] + off
165
  out.append(f'<text x="{a[0]:.1f}" y="{a[1]:.1f}" font-size="2" text-anchor="middle" '
166
  f'dominant-baseline="middle">{_esc(t["label"])}</text>')
167
  out.append(f'<polygon points="{pts(pc["outline"], off)}" fill="#fff" stroke="#000" stroke-width="0.3"/>')
168
  for p, q in pc["folds"]:
169
- out.append(f'<line x1="{p[0] + off[0]:.1f}" y1="{p[1] + off[1]:.1f}" x2="{q[0] + off[0]:.1f}" '
170
- f'y2="{q[1] + off[1]:.1f}" stroke="#888" stroke-width="0.2" stroke-dasharray="1.5,1"/>')
171
- c = np.mean(pc["outline"], axis=0) + off
 
172
  out.append(f'<text x="{c[0]:.1f}" y="{c[1]:.1f}" font-size="4" text-anchor="middle" '
173
  f'dominant-baseline="middle">{_esc(pc["name"])}</text>')
174
  out.append("</svg>")
 
7
  band i seam end -> its own seam start (or the next part, when a band is split)
8
  bottom cap rim -> band 1 bottom edge
9
  A band that would curl past ~120 degrees on paper is split into parts (2a, 2b, ...).
10
+ Output is A4 pages of SVG in millimetres, cut lines solid, fold lines dashed. Each piece
11
+ is turned to its narrowest orientation; fit_factor gives the scale at which the largest
12
+ piece fills a page.
13
  """
14
  import numpy as np
15
 
16
  TAB_MM = 6.0
17
  GAP_MM = 8.0
18
+ PAGE_MM = (190.0, 277.0) # A4 inside 10 mm margins
19
+ 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
 
31
  return np.c_[cx + r[:, 0] * dirs[:, 0], np.full(len(dirs), p["y"]), cz + r[:, 0] * dirs[:, 1]]
32
 
33
 
34
+ def _rings(profile, samples):
35
+ ang = np.linspace(0, 2 * np.pi, samples, endpoint=False)
36
+ dirs = np.c_[np.cos(ang), np.sin(ang)]
37
+ return [_ring_points(p, dirs) for p in profile]
38
+
39
+
40
  def _place(a2, c2, da, dc, away2):
41
  """The 2D point at distance da from a2 and dc from c2, on the side away from away2."""
42
  d = np.linalg.norm(c2 - a2)
 
90
  "at": (p + q) / 2 + n * w / 2}
91
 
92
 
93
+ def _chunks(B, max_turn_deg=120, max_len=np.inf):
94
+ """Split a strip where its bottom edge has turned too far (a strip that curls
95
+ past a half circle overlaps itself on paper: bands near a pole) or has grown
96
+ longer than `max_len` mm (so a long band still fits a page)."""
97
  S = len(B) - 1
98
  edges = np.diff(B, axis=0)
99
  heading = np.unwrap(np.arctan2(edges[:, 1], edges[:, 0]))
100
+ length = np.r_[0, np.cumsum(np.linalg.norm(edges, axis=1))]
101
  cuts, start = [0], 0
102
  for j in range(1, S):
103
+ if abs(heading[j] - heading[start]) > np.radians(max_turn_deg) or length[j + 1] - length[start] > max_len:
104
  cuts.append(j)
105
  start = j
106
  return list(zip(cuts, cuts[1:] + [S]))
 
111
 
112
  Caps under MIN_CAP_MM across are left out: the band there closes to a point.
113
  """
 
 
114
  real_h = profile[-1]["y"] - profile[0]["y"]
115
  scale = 1000.0 if not height_cm else 10.0 * float(height_cm) / max(real_h, 1e-6)
116
+ rings = [r * scale for r in _rings(profile, samples)]
117
  caps = {k: 2 * max(profile[i]["radii"]) * scale >= MIN_CAP_MM for k, i in (("bottom", 0), ("top", -1))}
118
  n = len(rings) - 1
119
  pieces = []
120
  for i in range(n):
121
  B, T, _ = _unroll_band(rings[i], rings[i + 1])
122
  above = f"band {i + 2}" if i + 1 < n else ("top cap" if caps["top"] else None)
123
+ parts = _chunks(B, max_len=STRIP_MAX_MM)
124
  names = [f"band {i + 1}" + (chr(ord("a") + k) if len(parts) > 1 else "") for k in range(len(parts))]
125
  for k, (j0, j1) in enumerate(parts):
126
  mid = (B[j0:j1 + 1].mean(axis=0) + T[j0:j1 + 1].mean(axis=0)) / 2
 
142
  return pieces, scale
143
 
144
 
145
+ def _points(piece):
146
+ return np.array(list(piece["outline"]) + [p for t in piece["tabs"] for p in t["poly"]])
 
147
 
148
 
149
+ def _upright(piece):
150
+ """Rotate a piece to its narrowest orientation, long side vertical (fits a portrait page)."""
151
+ pts = _points(piece)
152
+ best = None
153
+ for deg in range(0, 180, 3):
154
+ a = np.radians(deg)
155
+ rot = np.array([[np.cos(a), -np.sin(a)], [np.sin(a), np.cos(a)]])
156
+ w, h = np.ptp(pts @ rot.T, axis=0)
157
+ if w > h:
158
+ continue
159
+ if best is None or w < best[0]:
160
+ best = (w, rot)
161
+ rot = best[1] if best else np.eye(2)
162
+
163
+ def r(p):
164
+ return rot @ p
165
+ return {"name": piece["name"], "outline": [r(p) for p in piece["outline"]],
166
+ "folds": [(r(p), r(q)) for p, q in piece["folds"]],
167
+ "tabs": [{**t, "poly": [r(p) for p in t["poly"]], "at": r(t["at"])} for t in piece["tabs"]]}
168
+
169
+
170
+ def fit_factor(pieces):
171
+ """How much the pieces can grow (or must shrink) so the largest fits an A4 page.
172
+ Tabs keep their size when the net is rescaled, so they are left out of the fit."""
173
+ f = np.inf
174
  for pc in pieces:
175
+ w, h = np.ptp(np.array(_upright({**pc, "tabs": []})["outline"]), axis=0)
176
+ f = min(f, (PAGE_MM[0] - 2 * TAB_MM) / max(w, 1e-6), (PAGE_MM[1] - 2 * TAB_MM - HEADER_MM) / max(h, 1e-6))
177
+ return float(f)
178
+
179
+
180
+ def a4_height_cm(profile, samples):
181
+ """The tallest model whose every piece fits an A4 page. Long strips are split, so
182
+ what limits it is how wide a band is and how big the caps are."""
183
+ real_cm = (profile[-1]["y"] - profile[0]["y"]) * 100
184
+ rings = _rings(profile, samples)
185
+ widest = max([np.linalg.norm(a - b, axis=1).max() for a, b in zip(rings, rings[1:])]
186
+ + [2 * max(profile[i]["radii"]) for i in (0, -1)])
187
+ h = real_cm * min(PAGE_MM) * 0.9 / max(widest * 1000, 1e-6)
188
+ for _ in range(6): # curved strips can still be wider than their band: shrink until all fit
189
+ f = fit_factor(net(profile, samples, h)[0])
190
+ if f >= 1:
191
+ break
192
+ h *= 0.98 * f
193
+ return h
194
+
195
+
196
+ def pages(pieces):
197
+ """Shelf-pack upright pieces onto A4 pages. A piece too big for a page gets a page of its own size."""
198
+ out, cur, x, y, row_h = [], [], 0.0, HEADER_MM, 0.0
199
+ for pc in map(_upright, pieces):
200
+ pts = _points(pc)
201
+ lo = pts.min(axis=0)
202
+ w, h = np.ptp(pts, axis=0)
203
+ if x > 0 and x + w > PAGE_MM[0]:
204
  x, y, row_h = 0.0, y + row_h + GAP_MM, 0.0
205
+ if cur and y + h > PAGE_MM[1]:
206
+ out.append(cur)
207
+ cur, x, y, row_h = [], 0.0, HEADER_MM, 0.0
208
+ cur.append((pc, np.array([x, y]) - lo))
209
  x, row_h = x + w + GAP_MM, max(row_h, h)
210
+ if cur:
211
+ out.append(cur)
212
+ return out
213
+
214
+
215
+ def svg(placed, title):
216
+ """One A4 page (millimetres, 10 mm margins) of placed pieces."""
217
+ pts_all = np.concatenate([_points(pc) + off for pc, off in placed])
218
+ width = max(PAGE_MM[0], float(pts_all[:, 0].max())) + 2 * MARGIN_MM
219
+ height = max(PAGE_MM[1], float(pts_all[:, 1].max())) + 2 * MARGIN_MM
220
 
221
  def pts(poly, off):
222
+ return " ".join(f"{p[0] + off[0] + MARGIN_MM:.1f},{p[1] + off[1] + MARGIN_MM:.1f}" for p in poly)
223
+
224
+ def xy(p, off):
225
+ return p[0] + off[0] + MARGIN_MM, p[1] + off[1] + MARGIN_MM
226
 
227
  out = [f'<svg xmlns="http://www.w3.org/2000/svg" width="{width:.0f}mm" height="{height:.0f}mm" '
228
  f'viewBox="0 0 {width:.1f} {height:.1f}" font-family="sans-serif">',
229
+ '<rect width="100%" height="100%" fill="#fff"/>',
230
+ f'<text x="{MARGIN_MM}" y="{MARGIN_MM + 4}" font-size="4">{_esc(title)}: cut solid, fold dashed, '
231
+ f'glue tabs to the named piece</text>']
232
  for pc, off in placed:
233
  for t in pc["tabs"]:
234
  out.append(f'<polygon points="{pts(t["poly"], off)}" fill="#eee" stroke="#000" stroke-width="0.2"/>')
235
  if not t["label"]:
236
  continue
237
+ a = xy(t["at"], off)
238
  out.append(f'<text x="{a[0]:.1f}" y="{a[1]:.1f}" font-size="2" text-anchor="middle" '
239
  f'dominant-baseline="middle">{_esc(t["label"])}</text>')
240
  out.append(f'<polygon points="{pts(pc["outline"], off)}" fill="#fff" stroke="#000" stroke-width="0.3"/>')
241
  for p, q in pc["folds"]:
242
+ (x1, y1), (x2, y2) = xy(p, off), xy(q, off)
243
+ out.append(f'<line x1="{x1:.1f}" y1="{y1:.1f}" x2="{x2:.1f}" y2="{y2:.1f}" '
244
+ f'stroke="#888" stroke-width="0.2" stroke-dasharray="1.5,1"/>')
245
+ c = xy(np.mean(pc["outline"], axis=0), off)
246
  out.append(f'<text x="{c[0]:.1f}" y="{c[1]:.1f}" font-size="4" text-anchor="middle" '
247
  f'dominant-baseline="middle">{_esc(pc["name"])}</text>')
248
  out.append("</svg>")