Spaces:
Running on Zero
Running on Zero
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 filesEach 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>
- SPEC.md +9 -3
- app.py +34 -23
- 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
|
| 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.
|
| 135 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 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
|
|
|
|
| 247 |
|
| 248 |
Returns:
|
| 249 |
-
(report JSON string with the pieces,
|
| 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 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
|
|
|
| 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(
|
| 319 |
-
"""Show the SVG
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
|
|
|
| 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 =
|
| 397 |
paper = gr.Button("Make net")
|
| 398 |
paper_preview = gr.HTML()
|
| 399 |
-
paper_file = gr.File(label="
|
| 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
|
|
|
|
|
|
|
| 11 |
"""
|
| 12 |
import numpy as np
|
| 13 |
|
| 14 |
TAB_MM = 6.0
|
| 15 |
GAP_MM = 8.0
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 84 |
-
past a half circle overlaps itself on paper
|
|
|
|
| 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 = [
|
| 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
|
| 135 |
-
|
| 136 |
-
return pts.min(axis=0), pts.max(axis=0)
|
| 137 |
|
| 138 |
|
| 139 |
-
def
|
| 140 |
-
"""
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
for pc in pieces:
|
| 145 |
-
|
| 146 |
-
w,
|
| 147 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
x, y, row_h = 0.0, y + row_h + GAP_MM, 0.0
|
| 149 |
-
|
|
|
|
|
|
|
|
|
|
| 150 |
x, row_h = x + w + GAP_MM, max(row_h, h)
|
| 151 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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"]
|
| 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 |
-
|
| 170 |
-
|
| 171 |
-
|
|
|
|
| 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>")
|