From 8d0880cab213ca48176afdab436bc7e2f37468bb Mon Sep 17 00:00:00 2001 From: Roman Remme Date: Sat, 18 Jul 2026 00:40:54 +0200 Subject: [PATCH 01/18] refactor: promote pleat.io to a subpackage (heg + circlepack) Split pleat/io.py into pleat/io/{heg,circlepack}.py with an __init__ that re-exports the existing public names unchanged. Mechanical move, no behavior change; tests/test_io.py passes as-is. Also adds the FOLD/Origami-Simulator design spec and implementation plan under docs/superpowers/. --- .../2026-07-18-fold-origami-simulator.md | 722 ++++++++++++++++++ ...026-07-17-fold-origami-simulator-design.md | 262 +++++++ pleat/io/__init__.py | 25 + pleat/{io.py => io/circlepack.py} | 196 +---- pleat/io/heg.py | 192 +++++ 5 files changed, 1207 insertions(+), 190 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-18-fold-origami-simulator.md create mode 100644 docs/superpowers/specs/2026-07-17-fold-origami-simulator-design.md create mode 100644 pleat/io/__init__.py rename pleat/{io.py => io/circlepack.py} (69%) mode change 100755 => 100644 create mode 100644 pleat/io/heg.py diff --git a/docs/superpowers/plans/2026-07-18-fold-origami-simulator.md b/docs/superpowers/plans/2026-07-18-fold-origami-simulator.md new file mode 100644 index 0000000..370688b --- /dev/null +++ b/docs/superpowers/plans/2026-07-18-fold-origami-simulator.md @@ -0,0 +1,722 @@ +# FOLD export/import + "Open in Origami Simulator" Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add FOLD (v1.2) crease-pattern import/export to pleat and let a crease pattern be opened in [Origami Simulator](https://origamisimulator.org/) from a notebook, a script, or the online docs. + +**Architecture:** Promote `pleat/io.py` to a subpackage `pleat/io/` and add `io/fold.py`. FOLD serialization maps pleat's half-edge graph to/from FOLD's vertex/edge/face arrays. The Origami Simulator launcher writes a self-contained HTML page (an OS iframe + a `postMessage` handshake carrying the FOLD as JSON) and opens it with the stdlib `webbrowser`; a lighter inline-button variant renders in browser Jupyter and the built docs. + +**Tech Stack:** Python (numpy), stdlib `json`/`tempfile`/`webbrowser`, the existing `pleat.half` DCEL, `pleat.overlap` crease constants. No new dependencies. + +## Global Constraints + +- Python `>= 3.10` (project floor). Use `from __future__ import annotations`. +- No new runtime dependencies — stdlib only for the launcher. +- FOLD target version: `file_spec` = `1.2`, `file_creator` = `"pleat"`. +- Crease model (from `pleat.overlap`): `CREASE_ASSIGNMENT = "crease_assignment"`, `MOUNTAIN = 1`, `VALLEY = -1`, unassigned = `0`; assignment is stored on a half-edge and mirrored on its `.rev`. +- FOLD `edges_assignment` letters used: `"M"`, `"V"`, `"B"` (border), `"U"` (unassigned). `edges_foldAngle`: `M → -180.0`, `V → +180.0`, else `null`. +- Scope: **Euclidean 2D crease patterns only.** Non-Euclidean geometries stay on `.heg`; this change adds FOLD *alongside* `.heg`, it does not retire it. +- Deterministic output: order vertices/edges/faces by their `["id"]` attribute so serialization is reproducible. +- Pre-release project: no back-compat shims. The `io/__init__.py` re-exports are the package's public surface, not a shim. + +## File Structure + +``` +pleat/io/ (was pleat/io.py — promoted to a package) + __init__.py re-exports every public name (heg + circlepack + fold) + heg.py MOVED verbatim from io.py: .heg YAML format + circlepack.py MOVED verbatim from io.py: CirclePack .p format + fold.py NEW: FOLD serialization + Origami Simulator launcher +pleat/half.py +1 convenience method on the graph class +pleat/__init__.py +2 top-level re-exports (headline launcher entry points) +tests/test_fold.py NEW: FOLD round-trip + FOLD validity + HTML content +tests/test_io.py UNCHANGED (must keep passing after the split) +docs/notebooks/Saving_and_Exporting.ipynb +section +``` + +--- + +### Task 1: Promote `pleat/io.py` to a subpackage (mechanical split) + +Pure refactor, no behavior change. The existing `tests/test_io.py` is the safety net. + +**Files:** +- Delete: `pleat/io.py` +- Create: `pleat/io/__init__.py`, `pleat/io/heg.py`, `pleat/io/circlepack.py` +- Test: `tests/test_io.py` (existing — must still pass unchanged) + +**Interfaces:** +- Produces: `pleat.io.{graph_to_dict, dict_to_graph, save_graph, load_graph, CirclePackData, parse_p_file, write_p_file, load_circlepack, save_circlepack}` — all importable from `pleat.io` exactly as before. + +- [ ] **Step 1: Baseline the safety net** + +Run: `python -m pytest tests/test_io.py -q` +Expected: PASS (this is the behavior we must preserve). + +- [ ] **Step 2: Create `pleat/io/heg.py`** + +Move the `.heg` YAML section of the old `io.py` here verbatim — the module docstring, imports (`os`, `copy.copy`, `numpy as np`, `yaml`, `import pleat`, and `from ..geometries import EuclideanGeometry, PoincareDiskModel`, `from ..half import EuclideanPositionHEG, Face, HalfEdge, HalfEdgeGraph, Vertex, rotate_by`), and these functions unchanged: `graph_to_dict`, `dict_to_graph`, `save_graph`, `load_graph`. + +Note the relative-import depth changes from `.geometries`/`.half` to `..geometries`/`..half` (one level deeper now). + +- [ ] **Step 3: Create `pleat/io/circlepack.py`** + +Move the CirclePack `.p` section here verbatim: the section-comment banner, `from dataclasses import dataclass`, numpy import, `from ..geometries import EuclideanGeometry, PoincareDiskModel`, `from ..half import EuclideanPositionHEG, Face, HalfEdge, Vertex, rotate_by`, and these unchanged: `CirclePackData`, `parse_p_file`, `write_p_file`, `_build_heg_from_data`, `_r_eucl_from_x_and_center`, `load_circlepack`, `_graph_to_circlepack_data`, `save_circlepack`. (`load_circlepack` calls `_build_heg_from_data` — they stay together here.) Deferred imports inside functions (`from ..circle_packing import ...`) keep working; just verify the `.circle_packing` → `..circle_packing` depth. + +- [ ] **Step 4: Create `pleat/io/__init__.py`** + +```python +"""File I/O for pleat graphs: the ``.heg`` half-edge format, the CirclePack +``.p`` format, and the FOLD crease-pattern format.""" + +from __future__ import annotations + +from .circlepack import ( + CirclePackData, + load_circlepack, + parse_p_file, + save_circlepack, + write_p_file, +) +from .heg import dict_to_graph, graph_to_dict, load_graph, save_graph + +__all__ = [ + "graph_to_dict", + "dict_to_graph", + "save_graph", + "load_graph", + "CirclePackData", + "parse_p_file", + "write_p_file", + "load_circlepack", + "save_circlepack", +] +``` + +(FOLD names get added to this file in Task 3.) + +- [ ] **Step 5: Delete `pleat/io.py`** + +```bash +git rm pleat/io.py +``` + +- [ ] **Step 6: Verify the split preserved behavior** + +Run: `python -m pytest tests/test_io.py -q && python -c "import pleat; pleat.io.load_graph('graphs/irregular2.heg').check_consistency(); print('ok')"` +Expected: PASS, then `ok`. (The second check mirrors `tests/test_intersecting_cylinders.py`'s use of `pleat.io.load_graph`.) + +- [ ] **Step 7: Commit** + +```bash +git add pleat/io/ tests/ +git commit -m "refactor: promote pleat.io to a subpackage (heg + circlepack)" +``` + +--- + +### Task 2: FOLD serialization — `graph_to_fold` / `fold_to_graph` / `save_fold` / `load_fold` + +**Files:** +- Create: `pleat/io/fold.py` +- Test: `tests/test_fold.py` + +**Interfaces:** +- Consumes: `pleat.overlap.{CREASE_ASSIGNMENT, MOUNTAIN, VALLEY}`; `pleat.half.{EuclideanPositionHEG, Vertex, HalfEdge, Face}`. +- Produces: + - `graph_to_fold(G, *, title: str | None = None) -> dict` + - `fold_to_graph(fold: dict) -> EuclideanPositionHEG` + - `save_fold(path: str, G, *, overwrite: bool = False) -> None` (appends `.fold`) + - `load_fold(path: str) -> EuclideanPositionHEG` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_fold.py`: + +```python +"""Tests for pleat.io.fold: FOLD round-trip, FOLD validity, and OS launcher HTML.""" + +from __future__ import annotations + +import numpy as np + +from pleat.example_graphs import rosette +from pleat.half import EuclideanPositionHEG +from pleat.io.fold import fold_to_graph, graph_to_fold, load_fold, save_fold +from pleat.overlap import CREASE_ASSIGNMENT, MOUNTAIN, VALLEY + +VALID_ASSIGNMENTS = {"M", "V", "B", "F", "U"} + + +def _creased_rosette(): + """A hexagonal rosette (6 triangles) with its interior spokes creased M/V.""" + G = EuclideanPositionHEG(other=rosette(n=6)) + interior = [h for h in G.halfedges if not h.on_border() and not h.rev.on_border()] + for i, h in enumerate(interior): + a = MOUNTAIN if i % 2 == 0 else VALLEY + h[CREASE_ASSIGNMENT] = a + h.rev[CREASE_ASSIGNMENT] = a + return G + + +def test_graph_to_fold_is_valid_fold(): + G = _creased_rosette() + fold = graph_to_fold(G) + assert fold["file_spec"] == 1.2 + assert fold["file_creator"] == "pleat" + assert fold["frame_classes"] == ["creasePattern"] + n_v = len(fold["vertices_coords"]) + n_e = len(fold["edges_vertices"]) + assert len(fold["edges_assignment"]) == n_e + assert len(fold["edges_foldAngle"]) == n_e + for a in fold["edges_assignment"]: + assert a in VALID_ASSIGNMENTS + for ang in fold["edges_foldAngle"]: + assert ang is None or -180.0 <= ang <= 180.0 + for u, v in fold["edges_vertices"]: + assert 0 <= u < n_v and 0 <= v < n_v + # every interior spoke is creased, the outer hexagon is border + assert fold["edges_assignment"].count("B") == 6 + assert set(fold["edges_assignment"]) >= {"M", "V", "B"} + + +def test_fold_roundtrip_preserves_topology_and_creases(): + G = _creased_rosette() + fold = graph_to_fold(G) + G2 = fold_to_graph(fold) + G2.check_consistency() + assert (len(G.vertices), len(G.halfedges), len(G.faces)) == ( + len(G2.vertices), + len(G2.halfedges), + len(G2.faces), + ) + # crease assignments survive (as a multiset over undirected edges) + def crease_multiset(g): + seen, out = set(), [] + for h in g.halfedges: + if h in seen: + continue + seen.add(h) + seen.add(h.rev) + out.append(h.attributes.get(CREASE_ASSIGNMENT, 0)) + return sorted(out) + + assert crease_multiset(G) == crease_multiset(G2) + + +def test_save_load_fold_roundtrip(tmp_path): + G = _creased_rosette() + path = str(tmp_path / "rose") + save_fold(path, G) + assert (tmp_path / "rose.fold").exists() + G2 = load_fold(str(tmp_path / "rose.fold")) + G2.check_consistency() + assert len(G.faces) == len(G2.faces) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `python -m pytest tests/test_fold.py -q` +Expected: FAIL with `ModuleNotFoundError: No module named 'pleat.io.fold'`. + +- [ ] **Step 3: Implement the serialization** + +Create `pleat/io/fold.py`: + +```python +"""FOLD (v1.2) crease-pattern I/O and an Origami Simulator launcher. + +FOLD spec: https://github.com/edemaine/fold/blob/main/doc/spec.md +Scope: Euclidean 2D crease patterns. See docs/superpowers/specs for the design. +""" + +from __future__ import annotations + +import json +import os + +import numpy as np + +from ..half import EuclideanPositionHEG, Face, HalfEdge, Vertex +from ..overlap import CREASE_ASSIGNMENT, MOUNTAIN, VALLEY + +_ASSIGN_TO_LETTER = {MOUNTAIN: "M", VALLEY: "V"} +_LETTER_TO_ASSIGN = {"M": MOUNTAIN, "V": VALLEY} +_FOLD_ANGLE = {"M": -180.0, "V": 180.0} + + +def _coords2d(pos) -> list[float]: + """Return a plain ``[x, y]`` from a Euclidean position (2-vector or complex).""" + if np.iscomplexobj(pos) and np.ndim(pos) == 0: + c = complex(pos) + return [c.real, c.imag] + arr = np.asarray(pos, dtype=float).ravel() + return [float(arr[0]), float(arr[1])] + + +def graph_to_fold(G, *, title: str | None = None) -> dict: + """Serialise a Euclidean crease-pattern graph to a FOLD v1.2 dict. + + Undirected edges are the rev-pairs of ``G.halfedges``. Each edge's + assignment comes from :data:`CREASE_ASSIGNMENT` (M/V), or ``"B"`` when either + side is a border half-edge, or ``"U"`` otherwise. Faces are ``G.faces`` (the + outer region is not a Face in pleat), each emitted as its CCW vertex loop. + """ + verts = sorted(G.vertices, key=lambda v: v["id"]) + vidx = {v: i for i, v in enumerate(verts)} + + vertices_coords = [_coords2d(v["pos"]) for v in verts] + + edges_vertices: list[list[int]] = [] + edges_assignment: list[str] = [] + edges_foldAngle: list[float | None] = [] + seen: set = set() + for h in sorted(G.halfedges, key=lambda h: h["id"]): + if h in seen: + continue + seen.add(h) + seen.add(h.rev) + edges_vertices.append([vidx[h.orig], vidx[h.dest]]) + if h.on_border() or h.rev.on_border(): + letter = "B" + else: + letter = _ASSIGN_TO_LETTER.get(h.attributes.get(CREASE_ASSIGNMENT, 0), "U") + edges_assignment.append(letter) + edges_foldAngle.append(_FOLD_ANGLE.get(letter)) + + faces_vertices = [ + [vidx[v] for v in sorted_face_vertices(f)] + for f in sorted(G.faces, key=lambda f: f["id"]) + ] + + fold = { + "file_spec": 1.2, + "file_creator": "pleat", + "file_classes": ["singleModel"], + "frame_classes": ["creasePattern"], + "frame_attributes": ["2D"], + "vertices_coords": vertices_coords, + "edges_vertices": edges_vertices, + "edges_assignment": edges_assignment, + "edges_foldAngle": edges_foldAngle, + "faces_vertices": faces_vertices, + } + if title is not None: + fold["file_title"] = title + return fold + + +def sorted_face_vertices(f: Face) -> list[Vertex]: + """Return the face's boundary vertices in CCW order.""" + return list(f.vertex_iter()) + + +def fold_to_graph(fold: dict) -> EuclideanPositionHEG: + """Reconstruct a Euclidean half-edge graph from a FOLD dict. + + Requires ``faces_vertices`` (needs oriented faces to rebuild the DCEL). + Interior edges are twinned across their two faces; boundary edges get a + border twin (``face=None``) linked into the outer cycle. ``vertices_coords`` + restores positions and ``edges_assignment`` restores M/V creases. + """ + coords = fold["vertices_coords"] + faces_vertices = fold.get("faces_vertices") + if not faces_vertices: + raise ValueError( + "FOLD frame has no faces_vertices; cannot reconstruct a face-based " + "half-edge graph (only creasePattern/foldedForm frames with faces " + "are supported)." + ) + + G = EuclideanPositionHEG() + verts = [Vertex() for _ in coords] + for v, c in zip(verts, coords): + xy = [float(c[0]), float(c[1])] if len(c) >= 2 else [float(c[0]), 0.0] + v["pos"] = np.array(xy) + G.add_vertices(verts) + + # 1. interior half-edges from each face loop + he: dict[tuple[int, int], HalfEdge] = {} + all_halfedges: list[HalfEdge] = [] + for face_vs in faces_vertices: + n = len(face_vs) + loop = [] + for k in range(n): + i, j = face_vs[k], face_vs[(k + 1) % n] + h = HalfEdge(orig=verts[i], dest=verts[j]) + he[(i, j)] = h + loop.append(h) + f = Face(any_side=loop[0]) + for k in range(n): + h = loop[k] + h.nex = loop[(k + 1) % n] + h.pre = loop[(k - 1) % n] + h.face = f + verts[face_vs[k]].any_outgoing = h + all_halfedges.extend(loop) + G.add_halfedges(loop) + G.add_face(f) + + # 2. twin interior edges; create border twins for unmatched (boundary) edges + border: list[HalfEdge] = [] + for (i, j), h in list(he.items()): + if (j, i) in he: + h.rev = he[(j, i)] + elif h.rev is None: + b = HalfEdge(orig=verts[j], dest=verts[i], face=None) + b.rev = h + h.rev = b + he[(j, i)] = b + border.append(b) + verts[j].any_outgoing = verts[j].any_outgoing or b + + # 3. link the border cycle(s): one outgoing border half-edge per boundary vertex + border_out = {b.orig: b for b in border} + for b in border: + nxt = border_out[b.dest] + b.nex = nxt + nxt.pre = b + if border: + G.add_halfedges(border) + + # 4. restore crease assignments + assignment = fold.get("edges_assignment") + edges_vertices = fold["edges_vertices"] + if assignment: + for (i, j), a in zip((tuple(e) for e in edges_vertices), assignment): + val = _LETTER_TO_ASSIGN.get(a) + if val is None: + continue + he[(i, j)][CREASE_ASSIGNMENT] = val + he[(j, i)][CREASE_ASSIGNMENT] = val + + G.check_consistency() + return G + + +def save_fold(path: str, G, *, overwrite: bool = False) -> None: + """Write *G* to a ``.fold`` JSON file (appends ``.fold`` if missing).""" + if not path.endswith(".fold"): + path += ".fold" + if not overwrite and os.path.exists(path): + raise FileExistsError(f"File exists: {path}. Set overwrite=True to overwrite.") + with open(path, "w") as fh: + json.dump(graph_to_fold(G), fh) + + +def load_fold(path: str) -> EuclideanPositionHEG: + """Load a ``.fold`` file into a Euclidean half-edge graph.""" + with open(path) as fh: + return fold_to_graph(json.load(fh)) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `python -m pytest tests/test_fold.py -q` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add pleat/io/fold.py tests/test_fold.py +git commit -m "feat: FOLD v1.2 crease-pattern import/export (pleat.io.fold)" +``` + +--- + +### Task 3: Origami Simulator launcher — HTML template, `open_in_origami_simulator`, `origami_simulator_button` + +**Files:** +- Modify: `pleat/io/fold.py` (append launcher functions) +- Modify: `pleat/io/__init__.py` (re-export the new names) +- Modify: `pleat/half.py` (one convenience method on the graph class) +- Modify: `pleat/__init__.py` (two top-level re-exports) +- Test: `tests/test_fold.py` (append) + +**Interfaces:** +- Consumes: `graph_to_fold` (Task 2). +- Produces: + - `origami_simulator_html(G, *, embed: bool = True) -> str` + - `open_in_origami_simulator(G) -> str` (returns the temp file path; also opens the browser) + - `origami_simulator_button(G) -> _OrigamiSimulatorButton` (has `_repr_html_`) + - graph method `EuclideanPositionHEG.open_in_origami_simulator(self)` + - top-level `pleat.open_in_origami_simulator`, `pleat.origami_simulator_button` + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_fold.py`: + +```python +def test_origami_simulator_html_embeds_fold_and_importfold(): + G = _creased_rosette() + html = __import__("pleat.io.fold", fromlist=["origami_simulator_html"]).origami_simulator_html(G) + assert "importFold" in html + assert "origamisimulator.org" in html + assert '"edges_assignment"' in html # the FOLD JSON is embedded + assert " str: + """FOLD as a JSON string safe to embed inside an HTML """ + + +def open_in_origami_simulator(G) -> str: + """Open *G* in Origami Simulator in the system browser (click-free). + + Writes a temp HTML page (see :func:`origami_simulator_html`) and opens it with + ``webbrowser``. Returns the temp file path and prints a ``file://`` URL (a + manual Ctrl+click fallback if auto-open fails). + + Note: with a remote kernel (SSH/cluster) the browser opens on the server; use + :func:`save_fold` and drag the file into origamisimulator.org instead. + """ + fd, path = tempfile.mkstemp(prefix="pleat-os-", suffix=".html") + with os.fdopen(fd, "w") as fh: + fh.write(origami_simulator_html(G)) + url = "file://" + path + print(f"Opening Origami Simulator: {url}") + webbrowser.open(url) + return path + + +class _OrigamiSimulatorButton: + """A displayable button; on click opens vanilla OS in a new tab with the CP. + + Renders in browser Jupyter and the built docs (its `_repr_html_` output is + captured at docs-build time). Each button only answers the OS window it + itself opened, so multiple buttons on one page don't cross-post. + """ + + def __init__(self, G): + self._html = _origami_simulator_button_html(G) + + def _repr_html_(self) -> str: + return self._html + + +def _origami_simulator_button_html(G) -> str: + fold_json = _fold_json(G) + uid = uuid.uuid4().hex[:8] + return f""" +""" + + +def origami_simulator_button(G) -> _OrigamiSimulatorButton: + """Return a displayable "Open in Origami Simulator" button for *G*. + + Use in a notebook cell (browser Jupyter or the online docs). On click it opens + Origami Simulator in a new tab and imports the crease pattern. + """ + return _OrigamiSimulatorButton(G) +``` + +- [ ] **Step 4: Re-export from `pleat/io/__init__.py`** + +Add after the `from .heg import ...` line: + +```python +from .fold import ( + fold_to_graph, + graph_to_fold, + load_fold, + open_in_origami_simulator, + origami_simulator_button, + origami_simulator_html, + save_fold, +) +``` + +And extend `__all__` with: + +```python + "graph_to_fold", + "fold_to_graph", + "save_fold", + "load_fold", + "origami_simulator_html", + "open_in_origami_simulator", + "origami_simulator_button", +``` + +- [ ] **Step 5: Add the convenience method to the graph class** + +In `pleat/half.py`, find `class EuclideanPositionHEG` and add this method (it defers the import to avoid a cycle, mirroring how `io` imports from `half`): + +```python + def open_in_origami_simulator(self) -> str: + """Open this crease pattern in Origami Simulator (see pleat.io.fold).""" + from .io.fold import open_in_origami_simulator + + return open_in_origami_simulator(self) +``` + +- [ ] **Step 6: Add top-level re-exports in `pleat/__init__.py`** + +After `import pleat.io`, add: + +```python +from pleat.io.fold import open_in_origami_simulator, origami_simulator_button +``` + +- [ ] **Step 7: Run the tests** + +Run: `python -m pytest tests/test_fold.py -q && python -c "import pleat; print(pleat.open_in_origami_simulator, pleat.origami_simulator_button)"` +Expected: PASS (5 tests), then the two function reprs print. + +- [ ] **Step 8: Commit** + +```bash +git add pleat/io/fold.py pleat/io/__init__.py pleat/half.py pleat/__init__.py tests/test_fold.py +git commit -m "feat: open crease patterns in Origami Simulator (iframe launcher + inline button)" +``` + +--- + +### Task 4: Documentation + +**Files:** +- Modify: `docs/notebooks/Saving_and_Exporting.ipynb` + +**Interfaces:** +- Consumes: everything from Tasks 2–3. + +- [ ] **Step 1: Read the notebook's existing structure** + +Run: `python -c "import json; nb=json.load(open('docs/notebooks/Saving_and_Exporting.ipynb')); print(len(nb['cells'])); [print(i, c['cell_type'], ''.join(c['source'])[:70].replace(chr(10),' ')) for i,c in enumerate(nb['cells'])]"` +Expected: prints the cell list so you can see how a CP (`cp`) is built earlier in the notebook and match its variable name / style. + +- [ ] **Step 2: Add a markdown cell and a code cell** + +Add near the end (use `NotebookEdit`, or edit the JSON). Markdown cell: + +```markdown +## FOLD & Origami Simulator + +[FOLD](https://github.com/edemaine/fold) is the standard origami interchange +format. `save_fold` writes a `.fold` file (crease pattern with M/V/B assignments +and fold angles); `load_fold` reads one back. You can also open a crease pattern +straight in [Origami Simulator](https://origamisimulator.org/): + +- `cp.open_in_origami_simulator()` — opens it in your browser (works from a + notebook or a script; needs a local kernel — with a remote kernel, `save_fold` + and drag the file in instead). +- `origami_simulator_button(cp)` — renders a button (works here in the online + docs): click it to open the pattern in Origami Simulator. +``` + +Code cell (match the CP variable used earlier in the notebook — replace `cp` if it differs): + +```python +from pleat.io.fold import save_fold, origami_simulator_button + +save_fold("example", cp, overwrite=True) # writes example.fold +origami_simulator_button(cp) # a clickable button in the docs +``` + +- [ ] **Step 3: Verify the notebook executes (this is what the docs build does)** + +Run: `jupyter nbconvert --to notebook --execute --stdout docs/notebooks/Saving_and_Exporting.ipynb > /dev/null && echo OK` +Expected: `OK` (no execution error — `execute: true` in `mkdocs.yml` runs this at build). + +- [ ] **Step 4: Commit** + +```bash +git add docs/notebooks/Saving_and_Exporting.ipynb +git commit -m "docs: FOLD export and Open-in-Origami-Simulator section" +``` + +--- + +## Self-Review + +**Spec coverage:** +- FOLD v1.2 export mapping (vertices/edges/assignment/foldAngle/faces) → Task 2 `graph_to_fold`. ✓ +- FOLD import / DCEL reconstruction → Task 2 `fold_to_graph`. ✓ +- `.fold` files → Task 2 `save_fold`/`load_fold`. ✓ +- `io` subpackage split → Task 1. ✓ +- Kernel-side `webbrowser` + temp-HTML iframe + pop-out + printed `file://` path → Task 3 `origami_simulator_html`/`open_in_origami_simulator`. ✓ +- Online-docs / inline button surface → Task 3 `origami_simulator_button` + Task 4. ✓ +- Convenience method + re-exports → Task 3 Steps 5–6. ✓ +- Tests: round-trip, FOLD validity, HTML content → Tasks 2–3. ✓ +- `.heg` retained (not retired); Euclidean-only scope → enforced by keeping Task 1 a pure move and FOLD living alongside. ✓ +- Deferred (foldedForm/faceOrders, importSVG, results.show() wiring) → not in any task, as intended. ✓ + +**Placeholder scan:** No TBD/TODO; every code and command step is concrete. + +**Type consistency:** `graph_to_fold`/`fold_to_graph`/`save_fold`/`load_fold`/`origami_simulator_html`/`open_in_origami_simulator`/`origami_simulator_button` names match across the module, `__init__` re-exports, tests, and the graph method. Assignment constants (`MOUNTAIN`/`VALLEY`) and `CREASE_ASSIGNMENT` used consistently. `_fold_json` shared by both the iframe page and the button. diff --git a/docs/superpowers/specs/2026-07-17-fold-origami-simulator-design.md b/docs/superpowers/specs/2026-07-17-fold-origami-simulator-design.md new file mode 100644 index 0000000..ec0f506 --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-fold-origami-simulator-design.md @@ -0,0 +1,262 @@ +# FOLD export/import + "Open in Origami Simulator" + +**Date:** 2026-07-17 +**Status:** Design approved, pending spec review. Implementation to happen in a dedicated worktree. + +## Goal + +Let pleat crease patterns be opened in [Origami Simulator](https://origamisimulator.org/) +directly from a Jupyter notebook or a plain Python script, and add FOLD as a +first-class crease-pattern interchange format (import + export). + +## Background: how the reference button works + +The "Simulate in Origami Simulator" button on +[erikdemaine.org/fonts/maze](https://erikdemaine.org/fonts/maze/) uses a +**browser-to-browser `postMessage` handshake** — no server, no file hosting +(`maze.js`): + +1. Opener opens Origami Simulator (OS): `window.open('https://origamisimulator.org/')`. +2. OS, once loaded, posts back `{from:'OrigamiSimulator', status:'ready'}` — to + `window.parent` if it is embedded in an iframe, else to `window.opener`. +3. Opener waits for `ready`, then posts the crease pattern. + +Origami Simulator's own `js/importer.js` listens for exactly two ops: + +- `{op:'importFold', fold:}` — sets FOLD data directly. +- `{op:'importSVG', svg:, filename, vertTol}` — parses SVG by stroke colour. + +The maze uses `importSVG` only because its renderer emits SVG. We use +**`importFold`**: pleat has the full half-edge topology + M/V assignments, so it +can hand OS an unambiguous FOLD object — no colour round-trip (which even pleat's +own `svg.load_svg` has to reverse heuristically). + +Note: `?model=` in the OS URL only selects *built-in demos* (it reads the +`data-url` of an ``), **not** arbitrary URLs. So the only +automatic import path is `postMessage` from a parent/opener page. OS sets no +`X-Frame-Options`/CSP `frame-ancestors` (verified), so it can be embedded in an +iframe. + +## Launch mechanism (must work in browser Jupyter, JupyterLab, VS Code, and scripts) + +Rendering *inside* notebook output is not portable: VS Code's notebook webview +sandboxes HTML/JS and blocks external iframes and `Javascript` display, and the +three front-ends have different CSP. The one mechanism that works identically +everywhere is **kernel-side**: + +> `graph_to_fold(cp)` → write a self-contained temp `.html` that embeds OS in a +> full-page iframe plus a handshake script carrying the FOLD JSON → +> `webbrowser.open("file://…")`. + +`open_in_origami_simulator(cp)` step by step: + +1. Build the FOLD dict from the graph. +2. Write a temp HTML file containing a full-page ` +""" + + +def open_in_origami_simulator(G) -> str: + """Open *G* in Origami Simulator in the system browser (click-free). + + Writes a temp HTML page (see :func:`origami_simulator_html`) and opens it with + ``webbrowser``. Returns the temp file path and prints a ``file://`` URL (a + manual Ctrl+click fallback if auto-open fails). + + Note: with a remote kernel (SSH/cluster) the browser opens on the server; use + :func:`save_fold` and drag the file into origamisimulator.org instead. + """ + fd, path = tempfile.mkstemp(prefix="pleat-os-", suffix=".html") + with os.fdopen(fd, "w") as fh: + fh.write(origami_simulator_html(G)) + url = "file://" + path + print(f"Opening Origami Simulator: {url}") + webbrowser.open(url) + return path + + +class _OrigamiSimulatorButton: + """A displayable button; on click opens vanilla OS in a new tab with the CP. + + Renders in browser Jupyter and the built docs (its ``_repr_html_`` output is + captured at docs-build time). Each button only answers the OS window it + itself opened, so multiple buttons on one page do not cross-post. + """ + + def __init__(self, G) -> None: + self._html = _origami_simulator_button_html(G) + + def _repr_html_(self) -> str: + return self._html + + +def _origami_simulator_button_html(G) -> str: + fold_json = _fold_json(G) + uid = uuid.uuid4().hex[:8] + return f""" +""" + + +def origami_simulator_button(G) -> _OrigamiSimulatorButton: + """Return a displayable "Open in Origami Simulator" button for *G*. + + Use in a notebook cell (browser Jupyter or the online docs). On click it opens + Origami Simulator in a new tab and imports the crease pattern. + """ + return _OrigamiSimulatorButton(G) diff --git a/tests/test_fold.py b/tests/test_fold.py new file mode 100644 index 0000000..ef6362f --- /dev/null +++ b/tests/test_fold.py @@ -0,0 +1,134 @@ +"""Tests for pleat.io.fold: FOLD round-trip, FOLD validity, and OS launcher HTML.""" + +from __future__ import annotations + +from pleat.example_graphs import rosette +from pleat.half import EuclideanPositionHEG +from pleat.io.fold import ( + fold_to_graph, + graph_to_fold, + load_fold, + open_in_origami_simulator, # noqa: F401 (import-smoke; used in Task 3 tests) + origami_simulator_button, + origami_simulator_html, + save_fold, +) +from pleat.overlap import CREASE_ASSIGNMENT, MOUNTAIN, VALLEY + +VALID_ASSIGNMENTS = {"M", "V", "B", "F", "U"} + + +def _creased_rosette(): + """A hexagonal rosette with every interior edge creased M/V (alternating).""" + G = EuclideanPositionHEG(other=rosette(n=6)) + interior = [h for h in G.halfedges if not h.on_border() and not h.rev.on_border()] + for i, h in enumerate(interior): + a = MOUNTAIN if i % 2 == 0 else VALLEY + h[CREASE_ASSIGNMENT] = a + h.rev[CREASE_ASSIGNMENT] = a + return G + + +def _undirected_counts(G): + """(#border, #interior) undirected edges.""" + seen, border, interior = set(), 0, 0 + for h in G.halfedges: + if h in seen: + continue + seen.add(h) + seen.add(h.rev) + if h.on_border() or h.rev.on_border(): + border += 1 + else: + interior += 1 + return border, interior + + +def _crease_multiset(g): + seen, out = set(), [] + for h in g.halfedges: + if h in seen: + continue + seen.add(h) + seen.add(h.rev) + out.append(h.attributes.get(CREASE_ASSIGNMENT, 0)) + return sorted(out) + + +def test_graph_to_fold_is_valid_fold(): + G = _creased_rosette() + fold = graph_to_fold(G) + n_border, n_interior = _undirected_counts(G) + + assert fold["file_spec"] == 1.2 + assert fold["file_creator"] == "pleat" + assert fold["frame_classes"] == ["creasePattern"] + + n_v = len(fold["vertices_coords"]) + n_e = len(fold["edges_vertices"]) + assert n_e == n_border + n_interior + assert len(fold["edges_assignment"]) == n_e + assert len(fold["edges_foldAngle"]) == n_e + assert len(fold["faces_vertices"]) == len(G.faces) + + for a in fold["edges_assignment"]: + assert a in VALID_ASSIGNMENTS + for ang in fold["edges_foldAngle"]: + assert ang is None or -180.0 <= ang <= 180.0 + for u, v in fold["edges_vertices"]: + assert 0 <= u < n_v and 0 <= v < n_v + + # every interior edge was creased, so no "U"; border edges are all "B" + assert fold["edges_assignment"].count("B") == n_border + assert fold["edges_assignment"].count("M") + fold["edges_assignment"].count("V") == n_interior + assert "U" not in fold["edges_assignment"] + assert set(fold["edges_assignment"]) == {"M", "V", "B"} + + +def test_fold_roundtrip_preserves_topology_and_creases(): + G = _creased_rosette() + G2 = fold_to_graph(graph_to_fold(G)) + G2.check_consistency() + assert (len(G.vertices), len(G.halfedges), len(G.faces)) == ( + len(G2.vertices), + len(G2.halfedges), + len(G2.faces), + ) + assert _crease_multiset(G) == _crease_multiset(G2) + + +def test_save_load_fold_roundtrip(tmp_path): + G = _creased_rosette() + save_fold(str(tmp_path / "rose"), G) + assert (tmp_path / "rose.fold").exists() + G2 = load_fold(str(tmp_path / "rose.fold")) + G2.check_consistency() + assert len(G.faces) == len(G2.faces) + + +def test_fold_to_graph_rejects_faceless_frame(): + try: + fold_to_graph({"vertices_coords": [[0, 0], [1, 0]], "edges_vertices": [[0, 1]]}) + except ValueError: + pass + else: + raise AssertionError("expected ValueError for a FOLD frame without faces_vertices") + + +def test_origami_simulator_html_embeds_fold_and_importfold(): + html = origami_simulator_html(_creased_rosette()) + assert "importFold" in html + assert "origamisimulator.org" in html + assert ") + from pleat.io.fold import _fold_json + + assert " Date: Sat, 18 Jul 2026 00:53:11 +0200 Subject: [PATCH 03/18] docs: FOLD export and Open-in-Origami-Simulator section Add a FOLD & Origami Simulator section to the Saving and Exporting notebook (save_fold + an origami_simulator_button that folds the pattern live in the online docs), and flip the intro note that said FOLD was unsupported. --- docs/notebooks/Saving_and_Exporting.ipynb | 45 +++++++++++++++++++++-- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/docs/notebooks/Saving_and_Exporting.ipynb b/docs/notebooks/Saving_and_Exporting.ipynb index 383ebfc..c8b1f65 100644 --- a/docs/notebooks/Saving_and_Exporting.ipynb +++ b/docs/notebooks/Saving_and_Exporting.ipynb @@ -10,12 +10,11 @@ "Once you have a CP you like, you'll want to send it somewhere — a plotter, a folder simulator, or a 3D printer. This notebook covers the export formats `pleat` ships with:\n", "\n", "- `.heg` — pleat's native YAML serialization.\n", + "- `.fold` — the standard [FOLD](https://github.com/edemaine/fold) interchange format; also opens straight in [Origami Simulator](https://origamisimulator.org/).\n", "- SVG — vector for laser cutters / pen plotters.\n", "- A high-level `overlap.save_results` that writes a whole result directory in one call.\n", "\n", - "STL files can be generated via marching cubes, e.g. for 3D printers. It requires the `[threed]` install extra.\n", - "\n", - "The [FOLD format](https://github.com/edemaine/fold) is currently not supported, but would be nice to have in the future." + "STL files can be generated via marching cubes, e.g. for 3D printers. It requires the `[threed]` install extra." ] }, { @@ -128,6 +127,46 @@ "\n", "If you've gone through `fold_complete` (demonstrated in the [Shrink-Rotate notebook](Shrink_Rotate_Tessellations.ipynb)), `save_results(result, path)` writes the CP, both folded views, a back-lit composite, and a plotter-ready SVG in one call." ] + }, + { + "cell_type": "markdown", + "id": "9", + "metadata": {}, + "source": [ + "## FOLD & Origami Simulator\n", + "\n", + "[FOLD](https://github.com/edemaine/fold) is the standard origami interchange format. `save_fold` writes a `.fold` file (crease pattern with M/V/B assignments and fold angles); `load_fold` reads one back. You can also open a crease pattern straight in [Origami Simulator](https://origamisimulator.org/):\n", + "\n", + "- `cp.open_in_origami_simulator()` — opens it in your browser (works from a notebook or a script; needs a local kernel — with a remote kernel, use `save_fold` and drag the file in instead).\n", + "- `origami_simulator_button(cp)` — renders a button (it works right here in the online docs): click it to fold the pattern in Origami Simulator." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "10", + "metadata": {}, + "outputs": [], + "source": [ + "from pleat.io import save_fold, origami_simulator_button\n", + "from pleat.overlap import CREASE_ASSIGNMENT, MOUNTAIN, VALLEY\n", + "\n", + "# Give the tiling an alternating mountain/valley assignment so there is\n", + "# something to fold (a real origami pipeline sets these for you).\n", + "interior = [h for h in G.halfedges if not h.on_border() and not h.rev.on_border()]\n", + "for k, h in enumerate(interior):\n", + " a = MOUNTAIN if k % 2 == 0 else VALLEY\n", + " h[CREASE_ASSIGNMENT] = h.rev[CREASE_ASSIGNMENT] = a\n", + "\n", + "import tempfile, os\n", + "with tempfile.TemporaryDirectory() as d:\n", + " path = os.path.join(d, 'pattern.fold')\n", + " save_fold(path, G)\n", + " print('wrote', os.path.getsize(path), 'bytes of FOLD')\n", + "\n", + "# A button that folds this crease pattern in Origami Simulator — click it:\n", + "origami_simulator_button(G)" + ] } ], "metadata": { From 200ee39fded62b9fabd3734eed6849a5726c7c63 Mon Sep 17 00:00:00 2001 From: Roman Remme Date: Sat, 18 Jul 2026 00:55:51 +0200 Subject: [PATCH 04/18] test: import _build_heg_from_data from pleat.io.circlepack after io split --- tests/test_circle_packing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_circle_packing.py b/tests/test_circle_packing.py index 0c96b79..5ba5567 100644 --- a/tests/test_circle_packing.py +++ b/tests/test_circle_packing.py @@ -275,7 +275,7 @@ class TestGoldenAgainstCirclePack: @staticmethod def _load(name: str): - from pleat.io import parse_p_file, _build_heg_from_data + from pleat.io.circlepack import parse_p_file, _build_heg_from_data data = parse_p_file(str(FIXTURE_DIR / name)) G, idx2v = _build_heg_from_data(data) From 10cddb81599ca0657a2585dab1e92c9c99fa7c69 Mon Sep 17 00:00:00 2001 From: Roman Remme Date: Sat, 18 Jul 2026 20:33:16 +0200 Subject: [PATCH 05/18] fix: suppress Origami Simulator's default model to end import race OS async-loads its default demo (waterbomb) at init unless a ?model= query is present; that load could finish after our importFold and clobber the pattern. Opening OS with an empty ?model= (as erikdemaine.org's maze does) makes it skip the default entirely, so our pattern is the only thing loaded. --- pleat/io/fold.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pleat/io/fold.py b/pleat/io/fold.py index a094645..24881e7 100644 --- a/pleat/io/fold.py +++ b/pleat/io/fold.py @@ -188,7 +188,10 @@ def load_fold(path: str) -> EuclideanPositionHEG: # FOLD as JSON in a self-contained page and reply to whichever OS window reports # ready, so both the iframe and the popped-out tab import the same pattern. -_OS_URL = "https://origamisimulator.org/" +# The empty ``?model=`` query is load-bearing: it makes Origami Simulator skip +# loading its default demo (the waterbomb), which would otherwise finish loading +# *after* our importFold and clobber it. This mirrors erikdemaine.org's maze tool. +_OS_URL = "https://origamisimulator.org/?model=" def _fold_json(G) -> str: From 66d080e4bd9d6197f8fc6e18efb4df71bea29c8c Mon Sep 17 00:00:00 2001 From: Roman Remme Date: Sat, 18 Jul 2026 20:40:31 +0200 Subject: [PATCH 06/18] =?UTF-8?q?feat:=20origami=5Fsimulator=5Fiframe=20?= =?UTF-8?q?=E2=80=94=20embed=20Origami=20Simulator=20inline=20in=20a=20cel?= =?UTF-8?q?l?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders OS live in notebook output via an iframe whose srcdoc carries the full handshake page, so the import is parent-to-child within the iframe (no popup/ opener link for a sandbox to sever). Complements open_in_origami_simulator (real browser) and origami_simulator_button (static docs). --- pleat/__init__.py | 2 +- pleat/io/__init__.py | 2 ++ pleat/io/fold.py | 34 ++++++++++++++++++++++++++++++++++ tests/test_fold.py | 11 +++++++++++ 4 files changed, 48 insertions(+), 1 deletion(-) diff --git a/pleat/__init__.py b/pleat/__init__.py index 2387c82..5111945 100755 --- a/pleat/__init__.py +++ b/pleat/__init__.py @@ -26,7 +26,7 @@ # I/O and rendering import pleat.io -from pleat.io.fold import open_in_origami_simulator, origami_simulator_button +from pleat.io.fold import open_in_origami_simulator, origami_simulator_button, origami_simulator_iframe # Layout, classification, coloring, search import pleat.layout diff --git a/pleat/io/__init__.py b/pleat/io/__init__.py index 99cbab0..e79e6da 100644 --- a/pleat/io/__init__.py +++ b/pleat/io/__init__.py @@ -17,6 +17,7 @@ open_in_origami_simulator, origami_simulator_button, origami_simulator_html, + origami_simulator_iframe, save_fold, ) from .heg import dict_to_graph, graph_to_dict, load_graph, save_graph @@ -38,4 +39,5 @@ "origami_simulator_html", "open_in_origami_simulator", "origami_simulator_button", + "origami_simulator_iframe", ] diff --git a/pleat/io/fold.py b/pleat/io/fold.py index 24881e7..651de43 100644 --- a/pleat/io/fold.py +++ b/pleat/io/fold.py @@ -6,6 +6,7 @@ from __future__ import annotations +import html import json import os import tempfile @@ -291,3 +292,36 @@ def origami_simulator_button(G) -> _OrigamiSimulatorButton: Origami Simulator in a new tab and imports the crease pattern. """ return _OrigamiSimulatorButton(G) + + +class _OrigamiSimulatorIFrame: + """A displayable that embeds Origami Simulator live in the cell output. + + The whole handshake page (:func:`origami_simulator_html`) is carried inside + the iframe's ``srcdoc``, so the import happens parent-to-child *within* the + iframe — there is no popup/opener link (the part that a sandboxed VS Code + webview severs). Works in classic Notebook and JupyterLab; whether it renders + in VS Code depends on that webview's iframe CSP. + """ + + def __init__(self, G, *, height: int = 600) -> None: + doc = html.escape(origami_simulator_html(G), quote=True) + self._html = ( + f'' + ) + + def _repr_html_(self) -> str: + return self._html + + +def origami_simulator_iframe(G, *, height: int = 600) -> _OrigamiSimulatorIFrame: + """Return a displayable that embeds Origami Simulator inline, folding *G*. + + Renders OS in an iframe in the notebook cell (resizable via *height*). The + embedded "pop out to full tab" button opens it full-screen. Best for live + notebooks; for the static online docs prefer :func:`origami_simulator_button` + (one WebGL instance per result on page load would be heavy). + """ + return _OrigamiSimulatorIFrame(G, height=height) diff --git a/tests/test_fold.py b/tests/test_fold.py index ef6362f..2ed730d 100644 --- a/tests/test_fold.py +++ b/tests/test_fold.py @@ -132,3 +132,14 @@ def test_origami_simulator_button_repr_html(): assert "importFold" in html assert "Open in full tab ↗ + """ @@ -294,34 +297,31 @@ def origami_simulator_button(G) -> _OrigamiSimulatorButton: return _OrigamiSimulatorButton(G) -class _OrigamiSimulatorIFrame: - """A displayable that embeds Origami Simulator live in the cell output. +def _origami_simulator_iframe_html(G, *, height: int = 600) -> str: + """The ``' - ) - - def _repr_html_(self) -> str: - return self._html - - -def origami_simulator_iframe(G, *, height: int = 600) -> _OrigamiSimulatorIFrame: - """Return a displayable that embeds Origami Simulator inline, folding *G*. - - Renders OS in an iframe in the notebook cell (resizable via *height*). The - embedded "pop out to full tab" button opens it full-screen. Best for live - notebooks; for the static online docs prefer :func:`origami_simulator_button` - (one WebGL instance per result on page load would be heavy). + doc = html.escape(origami_simulator_html(G), quote=True) + return ( + f'' + ) + + +def origami_simulator_iframe(G, *, height: int = 600) -> None: + """Display Origami Simulator inline in the current cell, folding *G*. + + Renders OS in a resizable iframe (via *height*) right where it is called, so + it works off the last line of a cell and can be called several times in one + cell to show multiple simulators. The embedded "Fullscreen" button (bottom + right) enlarges it in place. Works in classic Notebook, JupyterLab, and VS + Code. For the static online docs prefer :func:`origami_simulator_button` (one + WebGL instance per result on page load would be heavy). """ - return _OrigamiSimulatorIFrame(G, height=height) + from IPython.display import HTML, display + + display(HTML(_origami_simulator_iframe_html(G, height=height))) diff --git a/tests/test_fold.py b/tests/test_fold.py index 2ed730d..3c24b0f 100644 --- a/tests/test_fold.py +++ b/tests/test_fold.py @@ -134,12 +134,20 @@ def test_origami_simulator_button_repr_html(): assert "origamisimulator.org" in html -def test_origami_simulator_iframe_repr_html(): - from pleat.io.fold import origami_simulator_iframe +def test_origami_simulator_iframe_html(): + from pleat.io.fold import _origami_simulator_iframe_html - html = origami_simulator_iframe(_creased_rosette(), height=555)._repr_html_() + html = _origami_simulator_iframe_html(_creased_rosette(), height=555) assert " Date: Sat, 18 Jul 2026 21:27:51 +0200 Subject: [PATCH 08/18] refactor: split Origami Simulator into pleat.origami_simulator; simplify API OS is a distinct feature from the FOLD format, so move the launcher out of pleat/io/fold.py into its own pleat/origami_simulator.py (it depends on the FOLD serializer). pleat.io.fold is now pure FOLD import/export. Simplify the OS surface to two entry points: - origami_simulator(cp): inline iframe in Jupyter/Lab/VS Code, or opens the system browser from a script (new_tab=True forces the browser); displays via a raw mimebundle so it works off any line, multiple times per cell, and without IPython's HTML-iframe warning. - origami_simulator_button(cp): a button that embeds the simulator inline on click (no popup -> works in VS Code; lazy -> good for the static docs). Drop origami_simulator_html / open_in_origami_simulator / origami_simulator_iframe from the public API; rename the graph method to G.origami_simulator(). Update the Saving_and_Exporting notebook to the new API. --- docs/notebooks/Saving_and_Exporting.ipynb | 27 ++-- pleat/__init__.py | 7 +- pleat/half.py | 8 +- pleat/io/__init__.py | 15 +- pleat/io/fold.py | 156 +-------------------- pleat/origami_simulator.py | 158 ++++++++++++++++++++++ tests/test_fold.py | 50 +------ tests/test_origami_simulator.py | 69 ++++++++++ 8 files changed, 261 insertions(+), 229 deletions(-) create mode 100644 pleat/origami_simulator.py create mode 100644 tests/test_origami_simulator.py diff --git a/docs/notebooks/Saving_and_Exporting.ipynb b/docs/notebooks/Saving_and_Exporting.ipynb index c8b1f65..8ecc3d9 100644 --- a/docs/notebooks/Saving_and_Exporting.ipynb +++ b/docs/notebooks/Saving_and_Exporting.ipynb @@ -135,10 +135,20 @@ "source": [ "## FOLD & Origami Simulator\n", "\n", - "[FOLD](https://github.com/edemaine/fold) is the standard origami interchange format. `save_fold` writes a `.fold` file (crease pattern with M/V/B assignments and fold angles); `load_fold` reads one back. You can also open a crease pattern straight in [Origami Simulator](https://origamisimulator.org/):\n", - "\n", - "- `cp.open_in_origami_simulator()` — opens it in your browser (works from a notebook or a script; needs a local kernel — with a remote kernel, use `save_fold` and drag the file in instead).\n", - "- `origami_simulator_button(cp)` — renders a button (it works right here in the online docs): click it to fold the pattern in Origami Simulator." + "[FOLD](https://github.com/edemaine/fold) is the standard origami interchange\n", + "format. `save_fold` writes a `.fold` file (crease pattern with M/V/B assignments\n", + "and fold angles); `load_fold` reads one back.\n", + "\n", + "`pleat.origami_simulator` opens a crease pattern in\n", + "[Origami Simulator](https://origamisimulator.org/):\n", + "\n", + "- `origami_simulator(cp)` (or `cp.origami_simulator()`) — embeds the simulator\n", + " inline in the notebook (Jupyter, Lab, or VS Code); from a plain script it opens\n", + " your browser instead. Pass `new_tab=True` to force the browser, or `height=` to\n", + " resize. Needs a local kernel — with a remote kernel, use `save_fold` and drag\n", + " the file in.\n", + "- `origami_simulator_button(cp)` — shows a button that embeds the simulator when\n", + " clicked (used here in the online docs, so it loads only on demand)." ] }, { @@ -148,7 +158,8 @@ "metadata": {}, "outputs": [], "source": [ - "from pleat.io import save_fold, origami_simulator_button\n", + "from pleat.io import save_fold\n", + "from pleat.origami_simulator import origami_simulator_button\n", "from pleat.overlap import CREASE_ASSIGNMENT, MOUNTAIN, VALLEY\n", "\n", "# Give the tiling an alternating mountain/valley assignment so there is\n", @@ -164,14 +175,14 @@ " save_fold(path, G)\n", " print('wrote', os.path.getsize(path), 'bytes of FOLD')\n", "\n", - "# A button that folds this crease pattern in Origami Simulator — click it:\n", + "# a button that embeds Origami Simulator inline when clicked (works in these docs)\n", "origami_simulator_button(G)" ] } ], "metadata": { "kernelspec": { - "display_name": "pleat", + "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, @@ -185,7 +196,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.12" + "version": "3.13.8" } }, "nbformat": 4, diff --git a/pleat/__init__.py b/pleat/__init__.py index 5111945..bb454b3 100755 --- a/pleat/__init__.py +++ b/pleat/__init__.py @@ -26,7 +26,12 @@ # I/O and rendering import pleat.io -from pleat.io.fold import open_in_origami_simulator, origami_simulator_button, origami_simulator_iframe + +# Origami Simulator (a distinct feature from the FOLD format; depends on it). +# Access via ``pleat.origami_simulator.origami_simulator`` / ``.origami_simulator_button``, +# ``from pleat.origami_simulator import origami_simulator``, or the ``G.origami_simulator()`` +# method. Not re-exported at the top level: that name is the module itself. +import pleat.origami_simulator # Layout, classification, coloring, search import pleat.layout diff --git a/pleat/half.py b/pleat/half.py index 7cb9563..750e963 100755 --- a/pleat/half.py +++ b/pleat/half.py @@ -1682,11 +1682,11 @@ def __init__(self, **super_kwargs: object) -> None: """Create a Euclidean-geometry half-edge graph.""" super().__init__(geometry=EuclideanGeometry, **super_kwargs) - def open_in_origami_simulator(self) -> str: - """Open this crease pattern in Origami Simulator (see :mod:`pleat.io.fold`).""" - from .io.fold import open_in_origami_simulator + def origami_simulator(self, *, height: int = 600, new_tab: bool = False) -> None: + """Show this crease pattern in Origami Simulator (see :mod:`pleat.origami_simulator`).""" + from .origami_simulator import origami_simulator - return open_in_origami_simulator(self) + origami_simulator(self, height=height, new_tab=new_tab) # ------------------------------------------------ cyclic graph example ------------------------------------------------ diff --git a/pleat/io/__init__.py b/pleat/io/__init__.py index e79e6da..a565b0e 100644 --- a/pleat/io/__init__.py +++ b/pleat/io/__init__.py @@ -10,16 +10,7 @@ save_circlepack, write_p_file, ) -from .fold import ( - fold_to_graph, - graph_to_fold, - load_fold, - open_in_origami_simulator, - origami_simulator_button, - origami_simulator_html, - origami_simulator_iframe, - save_fold, -) +from .fold import fold_to_graph, graph_to_fold, load_fold, save_fold from .heg import dict_to_graph, graph_to_dict, load_graph, save_graph __all__ = [ @@ -36,8 +27,4 @@ "fold_to_graph", "save_fold", "load_fold", - "origami_simulator_html", - "open_in_origami_simulator", - "origami_simulator_button", - "origami_simulator_iframe", ] diff --git a/pleat/io/fold.py b/pleat/io/fold.py index 2a43666..4d116de 100644 --- a/pleat/io/fold.py +++ b/pleat/io/fold.py @@ -1,17 +1,14 @@ -"""FOLD (v1.2) crease-pattern I/O and an Origami Simulator launcher. +"""FOLD (v1.2) crease-pattern import/export. FOLD spec: https://github.com/edemaine/fold/blob/main/doc/spec.md Scope: Euclidean 2D crease patterns. See docs/superpowers/specs for the design. +Opening a pattern in Origami Simulator lives in :mod:`pleat.origami_simulator`. """ from __future__ import annotations -import html import json import os -import tempfile -import uuid -import webbrowser import numpy as np @@ -176,152 +173,3 @@ def load_fold(path: str) -> EuclideanPositionHEG: """Load a ``.fold`` file into a Euclidean half-edge graph.""" with open(path) as fh: return fold_to_graph(json.load(fh)) - - -# =========================================================================== -# Origami Simulator launcher -# =========================================================================== -# -# Origami Simulator (https://origamisimulator.org/) imports a crease pattern via -# a postMessage handshake: it announces ``{from:'OrigamiSimulator', -# status:'ready'}`` to its parent frame (when embedded) or opener (when popped -# out), and then accepts ``{op:'importFold', fold:}``. We embed the -# FOLD as JSON in a self-contained page and reply to whichever OS window reports -# ready, so both the iframe and the popped-out tab import the same pattern. - -# The empty ``?model=`` query is load-bearing: it makes Origami Simulator skip -# loading its default demo (the waterbomb), which would otherwise finish loading -# *after* our importFold and clobber it. This mirrors erikdemaine.org's maze tool. -_OS_URL = "https://origamisimulator.org/?model=" - - -def _fold_json(G) -> str: - """FOLD as a JSON string safe to embed inside an HTML """ - - -def open_in_origami_simulator(G) -> str: - """Open *G* in Origami Simulator in the system browser (click-free). - - Writes a temp HTML page (see :func:`origami_simulator_html`) and opens it with - ``webbrowser``. Returns the temp file path and prints a ``file://`` URL (a - manual Ctrl+click fallback if auto-open fails). - - Note: with a remote kernel (SSH/cluster) the browser opens on the server; use - :func:`save_fold` and drag the file into origamisimulator.org instead. - """ - fd, path = tempfile.mkstemp(prefix="pleat-os-", suffix=".html") - with os.fdopen(fd, "w") as fh: - fh.write(origami_simulator_html(G)) - url = "file://" + path - print(f"Opening Origami Simulator: {url}") - webbrowser.open(url) - return path - - -class _OrigamiSimulatorButton: - """A displayable button; on click opens vanilla OS in a new tab with the CP. - - Renders in browser Jupyter and the built docs (its ``_repr_html_`` output is - captured at docs-build time). Each button only answers the OS window it - itself opened, so multiple buttons on one page do not cross-post. - """ - - def __init__(self, G) -> None: - self._html = _origami_simulator_button_html(G) - - def _repr_html_(self) -> str: - return self._html - - -def _origami_simulator_button_html(G) -> str: - fold_json = _fold_json(G) - uid = uuid.uuid4().hex[:8] - return f""" -""" - - -def origami_simulator_button(G) -> _OrigamiSimulatorButton: - """Return a displayable "Open in Origami Simulator" button for *G*. - - Use in a notebook cell (browser Jupyter or the online docs). On click it opens - Origami Simulator in a new tab and imports the crease pattern. - """ - return _OrigamiSimulatorButton(G) - - -def _origami_simulator_iframe_html(G, *, height: int = 600) -> str: - """The ``' - ) - - -def origami_simulator_iframe(G, *, height: int = 600) -> None: - """Display Origami Simulator inline in the current cell, folding *G*. - - Renders OS in a resizable iframe (via *height*) right where it is called, so - it works off the last line of a cell and can be called several times in one - cell to show multiple simulators. The embedded "Fullscreen" button (bottom - right) enlarges it in place. Works in classic Notebook, JupyterLab, and VS - Code. For the static online docs prefer :func:`origami_simulator_button` (one - WebGL instance per result on page load would be heavy). - """ - from IPython.display import HTML, display - - display(HTML(_origami_simulator_iframe_html(G, height=height))) diff --git a/pleat/origami_simulator.py b/pleat/origami_simulator.py new file mode 100644 index 0000000..31a69d4 --- /dev/null +++ b/pleat/origami_simulator.py @@ -0,0 +1,158 @@ +"""Open a crease pattern in Origami Simulator (https://origamisimulator.org/). + +Origami Simulator imports a crease pattern via a postMessage handshake: it +announces ``{from:'OrigamiSimulator', status:'ready'}`` to its parent frame (when +embedded) or opener (when popped out), then accepts +``{op:'importFold', fold:}``. We embed the pattern as FOLD JSON in a +self-contained page and reply to whichever OS window reports ready. + +Two entry points: + +- :func:`origami_simulator` -- show OS folding a pattern: inline in the cell under + Jupyter (Notebook / Lab / VS Code), or in the system browser from a script. +- :func:`origami_simulator_button` -- a button that embeds OS inline when clicked + (lazy; good for the static docs, where auto-loading many at once would be heavy). +""" + +from __future__ import annotations + +import html +import json +import os +import tempfile +import uuid +import webbrowser + +from .io.fold import graph_to_fold + +__all__ = ["origami_simulator", "origami_simulator_button"] + +# The empty ``?model=`` query is load-bearing: it makes Origami Simulator skip +# loading its default demo (the waterbomb), which would otherwise finish loading +# *after* our importFold and clobber it. This mirrors erikdemaine.org's maze tool. +_OS_URL = "https://origamisimulator.org/?model=" + + +def _fold_json(G) -> str: + """FOLD as a JSON string safe to embed inside an HTML ``""" + + +def _iframe_html(G, *, height: int = 600) -> str: + """``' + ) + + +def _button_html(G, *, height: int = 600) -> str: + """A button that injects the OS iframe inline when clicked (no popup).""" + payload = json.dumps(_iframe_html(G, height=height)).replace(" + + +""" + + +def _open_in_browser(G) -> str: + """Write the page to a temp file and open it in the system browser.""" + fd, path = tempfile.mkstemp(prefix="pleat-os-", suffix=".html") + with os.fdopen(fd, "w") as fh: + fh.write(_page_html(G)) + url = "file://" + path + print(f"Opening Origami Simulator: {url}") + webbrowser.open(url) + return path + + +def _in_notebook() -> bool: + """True inside a Jupyter kernel (Notebook / Lab / VS Code); False in a terminal + or plain script.""" + try: + from IPython import get_ipython + + ip = get_ipython() + return ip is not None and "IPKernelApp" in ip.config + except Exception: + return False + + +def _display_html(markup: str) -> None: + """Display raw HTML inline. The raw mimebundle avoids IPython's ``HTML`` iframe + warning and works off any line / several times per cell.""" + from IPython.display import display + + display({"text/html": markup}, raw=True) + + +def origami_simulator(G, *, height: int = 600, new_tab: bool = False) -> None: + """Show Origami Simulator folding the crease pattern *G*. + + In a Jupyter environment (Notebook, Lab, VS Code) this embeds OS inline in the + cell output (resizable via *height*); it can be called off any line and several + times in one cell. From a plain script it opens OS in the system browser. + + Pass ``new_tab=True`` to force the browser even from a notebook -- useful in VS + Code, where the inline Fullscreen button is blocked by the webview. + + Note: opening the browser needs a local kernel; with a remote kernel (SSH / + cluster) it would open on the server -- use :func:`pleat.io.save_fold` and drag + the file into origamisimulator.org instead. + """ + if new_tab or not _in_notebook(): + _open_in_browser(G) + else: + _display_html(_iframe_html(G, height=height)) + + +def origami_simulator_button(G, *, height: int = 600) -> None: + """Display a button that embeds Origami Simulator inline when clicked. + + Like :func:`origami_simulator` but lazy -- nothing loads until the reader + clicks, so a page with many patterns does not spin up a WebGL instance for each + on load. Works in Notebook, Lab, VS Code, and the static online docs. + """ + _display_html(_button_html(G, height=height)) diff --git a/tests/test_fold.py b/tests/test_fold.py index 3c24b0f..188d122 100644 --- a/tests/test_fold.py +++ b/tests/test_fold.py @@ -1,18 +1,10 @@ -"""Tests for pleat.io.fold: FOLD round-trip, FOLD validity, and OS launcher HTML.""" +"""Tests for pleat.io.fold: FOLD round-trip and FOLD validity.""" from __future__ import annotations from pleat.example_graphs import rosette from pleat.half import EuclideanPositionHEG -from pleat.io.fold import ( - fold_to_graph, - graph_to_fold, - load_fold, - open_in_origami_simulator, # noqa: F401 (import-smoke; used in Task 3 tests) - origami_simulator_button, - origami_simulator_html, - save_fold, -) +from pleat.io.fold import fold_to_graph, graph_to_fold, load_fold, save_fold from pleat.overlap import CREASE_ASSIGNMENT, MOUNTAIN, VALLEY VALID_ASSIGNMENTS = {"M", "V", "B", "F", "U"} @@ -113,41 +105,3 @@ def test_fold_to_graph_rejects_faceless_frame(): pass else: raise AssertionError("expected ValueError for a FOLD frame without faces_vertices") - - -def test_origami_simulator_html_embeds_fold_and_importfold(): - html = origami_simulator_html(_creased_rosette()) - assert "importFold" in html - assert "origamisimulator.org" in html - assert ") - from pleat.io.fold import _fold_json - - assert " no waterbomb race + + +def test_page_enlarges_via_fullscreen_not_popup(): + html = _page_html(_creased_rosette()) + assert "requestFullscreen" in html + assert "window.open" not in html + + +def test_iframe_carries_page_in_srcdoc(): + html = _iframe_html(_creased_rosette(), height=555) + assert " inject iframe + assert "window.open" not in html # no popup (works in a sandboxed webview) + assert "importFold" in html # the iframe payload carries the pattern + + +def test_public_surface(): + import pleat.io + + # the OS feature lives in its own module, exposing exactly two entry points + assert pleat.origami_simulator.__all__ == ["origami_simulator", "origami_simulator_button"] + assert callable(origami_simulator) and callable(origami_simulator_button) + # available as a graph method + assert hasattr(EuclideanPositionHEG, "origami_simulator") + # the OS names are gone from pleat.io (which is now FOLD/heg/circlepack I/O only) + for gone in ( + "origami_simulator", + "origami_simulator_button", + "origami_simulator_html", + "open_in_origami_simulator", + ): + assert gone not in pleat.io.__all__ From ffd9f6d0c869842dbaeb5be004808b263b75c88b Mon Sep 17 00:00:00 2001 From: Roman Remme Date: Sat, 18 Jul 2026 22:49:24 +0200 Subject: [PATCH 09/18] feat: G.save() dispatches by extension; G.origami_simulator() on GeometricHEG - G.save(path) now routes by extension: .heg / .fold (data serialization) in addition to .svg / .png / no-ext (rendered image, unchanged). Kwargs go to the matching backend. Docstring lists the endings up front and in the path arg. - Move G.origami_simulator() up from EuclideanPositionHEG to GeometricHEG so any geometric graph has it (fixes AttributeError on plain GeometricHEG crease patterns). - graph_to_fold raises ValueError on non-Euclidean geometry (hyperbolic/spherical can't be represented in FOLD), so .fold export and .origami_simulator() fail with a clear message rather than emitting garbage coordinates. --- pleat/half.py | 45 ++++++++++++++++++++++++--------- pleat/io/fold.py | 12 +++++++++ tests/test_fold.py | 26 +++++++++++++++++++ tests/test_origami_simulator.py | 6 ++++- 4 files changed, 76 insertions(+), 13 deletions(-) diff --git a/pleat/half.py b/pleat/half.py index 750e963..d4e416d 100755 --- a/pleat/half.py +++ b/pleat/half.py @@ -1646,16 +1646,43 @@ def show(self, **style: object) -> None: """ self.render(**style).show() - def save(self, path: str, **style: object) -> None: - """Render the graph and write it to *path*. + def save(self, path: str, **kwargs: object) -> None: + """Save the graph to *path*; the file **extension selects the format**: - ``path`` with no extension writes both ``path.svg`` and ``path.png``. + - ``.heg`` -- pleat's native half-edge serialization + (:func:`pleat.io.save_graph`). Kwargs: ``overwrite``, ``attributes_to_save``. + - ``.fold`` -- FOLD crease pattern for other origami tools / Origami + Simulator, Euclidean 2D only (:func:`pleat.io.save_fold`). Kwarg: ``overwrite``. + - ``.svg`` / ``.png`` -- a rendered picture; kwargs are forwarded to + :meth:`render` as style. + - no extension -- writes both ``path.svg`` and ``path.png``. Args: - path: Destination path; extension selects the format(s). - **style: Forwarded to :meth:`render`. + path: Destination path. Its extension picks the format -- ``.heg``, + ``.fold``, ``.svg``, ``.png``, or none (writes both ``.svg`` and ``.png``). + **kwargs: Format-specific options (see above): render style for images, + ``overwrite`` / ``attributes_to_save`` for ``.heg`` / ``.fold``. + """ + lower = path.lower() + if lower.endswith(".heg"): + from .io import save_graph + + save_graph(path, self, **kwargs) + elif lower.endswith(".fold"): + from .io import save_fold + + save_fold(path, self, **kwargs) + else: + self.render(**kwargs).save(path) + + def origami_simulator(self, *, height: int = 600, new_tab: bool = False) -> None: + """Show this crease pattern in Origami Simulator (see :mod:`pleat.origami_simulator`). + + Requires a Euclidean 2D crease pattern; raises ``ValueError`` otherwise. """ - self.render(**style).save(path) + from .origami_simulator import origami_simulator + + origami_simulator(self, height=height, new_tab=new_tab) def central_face(self) -> Face: """Return the face whose midpoint is closest to the origin (Euclidean only).""" @@ -1682,12 +1709,6 @@ def __init__(self, **super_kwargs: object) -> None: """Create a Euclidean-geometry half-edge graph.""" super().__init__(geometry=EuclideanGeometry, **super_kwargs) - def origami_simulator(self, *, height: int = 600, new_tab: bool = False) -> None: - """Show this crease pattern in Origami Simulator (see :mod:`pleat.origami_simulator`).""" - from .origami_simulator import origami_simulator - - origami_simulator(self, height=height, new_tab=new_tab) - # ------------------------------------------------ cyclic graph example ------------------------------------------------ diff --git a/pleat/io/fold.py b/pleat/io/fold.py index 4d116de..043c3d4 100644 --- a/pleat/io/fold.py +++ b/pleat/io/fold.py @@ -12,6 +12,7 @@ import numpy as np +from ..geometries import EuclideanGeometry from ..half import EuclideanPositionHEG, Face, HalfEdge, Vertex from ..overlap import CREASE_ASSIGNMENT, MOUNTAIN, VALLEY @@ -36,7 +37,18 @@ def graph_to_fold(G, *, title: str | None = None) -> dict: assignment comes from :data:`CREASE_ASSIGNMENT` (M/V), or ``"B"`` when either side is a border half-edge, or ``"U"`` otherwise. Faces are ``G.faces`` (the outer region is not a Face in pleat), each emitted as its CCW vertex loop. + + Raises: + ValueError: if *G* is not a Euclidean 2D graph (FOLD has no notion of the + hyperbolic/spherical models pleat uses for curved tilings). """ + geometry = getattr(G, "geometry", EuclideanGeometry) + if geometry is not EuclideanGeometry: + name = getattr(geometry, "__name__", geometry) + raise ValueError( + f"FOLD export requires a Euclidean 2D crease pattern; this graph uses " + f"{name} geometry. FOLD cannot represent hyperbolic/spherical models." + ) verts = sorted(G.vertices, key=lambda v: v["id"]) vidx = {v: i for i, v in enumerate(verts)} diff --git a/tests/test_fold.py b/tests/test_fold.py index 188d122..de9227d 100644 --- a/tests/test_fold.py +++ b/tests/test_fold.py @@ -105,3 +105,29 @@ def test_fold_to_graph_rejects_faceless_frame(): pass else: raise AssertionError("expected ValueError for a FOLD frame without faces_vertices") + + +def test_graph_to_fold_rejects_non_euclidean(): + from pleat.example_graphs import from_tiles + from pleat.example_tilesets import curved_platonic + + G = from_tiles(curved_platonic(7, 3), rings=1) # hyperbolic (Poincaré) tiling + try: + graph_to_fold(G) + except ValueError: + pass + else: + raise AssertionError("expected ValueError exporting a non-Euclidean graph to FOLD") + + +def test_g_save_dispatches_by_extension(tmp_path): + from pleat.io import load_fold, load_graph + + G = _creased_rosette() + G.save(str(tmp_path / "r.heg")) + G.save(str(tmp_path / "r.fold")) + assert (tmp_path / "r.heg").exists() and (tmp_path / "r.fold").exists() + load_graph(str(tmp_path / "r.heg")).check_consistency() + G2 = load_fold(str(tmp_path / "r.fold")) + G2.check_consistency() + assert _crease_multiset(G) == _crease_multiset(G2) diff --git a/tests/test_origami_simulator.py b/tests/test_origami_simulator.py index f943840..cb05d12 100644 --- a/tests/test_origami_simulator.py +++ b/tests/test_origami_simulator.py @@ -57,7 +57,11 @@ def test_public_surface(): # the OS feature lives in its own module, exposing exactly two entry points assert pleat.origami_simulator.__all__ == ["origami_simulator", "origami_simulator_button"] assert callable(origami_simulator) and callable(origami_simulator_button) - # available as a graph method + # available as a graph method on GeometricHEG (so every geometric graph has it, + # not only the EuclideanPositionHEG subclass) + from pleat.half import GeometricHEG + + assert "origami_simulator" in vars(GeometricHEG) assert hasattr(EuclideanPositionHEG, "origami_simulator") # the OS names are gone from pleat.io (which is now FOLD/heg/circlepack I/O only) for gone in ( From 1a741baf33d4e202f87b4c4bd5d0805abacc4eb5 Mon Sep 17 00:00:00 2001 From: Roman Remme Date: Sat, 18 Jul 2026 23:06:08 +0200 Subject: [PATCH 10/18] feat: G.save() with no extension writes the whole bundle (svg+png+heg+fold) - No-extension G.save(path) now writes path.svg, path.png, path.heg, and path.fold (the .fold skipped for non-Euclidean graphs). overwrite defaults to True for the data files; other kwargs are render style. - save_results: the CP and CP_for_origami_simulator panels inherit this (they now also get .heg + .fold for reload / interchange), while the folded views and the backlit composite render straight to svg+png (they're pictures, not patterns). - graph_to_fold guards on real 2D positions (rejects complex hyperbolic / 3D spherical) instead of the geometry attribute, which from_tiles doesn't set. - save_fold builds the FOLD dict before opening the file, so a rejected export leaves no empty .fold behind. --- pleat/half.py | 21 ++++++++++++++++++--- pleat/io/fold.py | 28 +++++++++++++++++----------- pleat/overlap.py | 9 ++++++--- tests/test_fold.py | 21 +++++++++++++++++++++ 4 files changed, 62 insertions(+), 17 deletions(-) diff --git a/pleat/half.py b/pleat/half.py index d4e416d..17ea15e 100755 --- a/pleat/half.py +++ b/pleat/half.py @@ -1655,11 +1655,15 @@ def save(self, path: str, **kwargs: object) -> None: Simulator, Euclidean 2D only (:func:`pleat.io.save_fold`). Kwarg: ``overwrite``. - ``.svg`` / ``.png`` -- a rendered picture; kwargs are forwarded to :meth:`render` as style. - - no extension -- writes both ``path.svg`` and ``path.png``. + - no extension -- writes the whole bundle: ``path.svg``, ``path.png``, + ``path.heg``, and ``path.fold`` (the ``.fold`` skipped for non-Euclidean + graphs). ``overwrite`` (default ``True``) applies to the ``.heg`` / ``.fold`` + files; the remaining kwargs are render style. Args: path: Destination path. Its extension picks the format -- ``.heg``, - ``.fold``, ``.svg``, ``.png``, or none (writes both ``.svg`` and ``.png``). + ``.fold``, ``.svg``, ``.png``, or none (writes the whole bundle: + ``.svg`` + ``.png`` + ``.heg`` + ``.fold``). **kwargs: Format-specific options (see above): render style for images, ``overwrite`` / ``attributes_to_save`` for ``.heg`` / ``.fold``. """ @@ -1672,8 +1676,19 @@ def save(self, path: str, **kwargs: object) -> None: from .io import save_fold save_fold(path, self, **kwargs) - else: + elif lower.endswith((".svg", ".png")): self.render(**kwargs).save(path) + else: + # no extension: write the whole bundle + from .io import save_fold, save_graph + + overwrite = bool(kwargs.pop("overwrite", True)) + self.render(**kwargs).save(path) # path.svg + path.png + save_graph(path, self, overwrite=overwrite) + try: + save_fold(path, self, overwrite=overwrite) + except ValueError: + pass # non-Euclidean geometry: FOLD not applicable, skip it def origami_simulator(self, *, height: int = 600, new_tab: bool = False) -> None: """Show this crease pattern in Origami Simulator (see :mod:`pleat.origami_simulator`). diff --git a/pleat/io/fold.py b/pleat/io/fold.py index 043c3d4..e19a7d4 100644 --- a/pleat/io/fold.py +++ b/pleat/io/fold.py @@ -12,7 +12,6 @@ import numpy as np -from ..geometries import EuclideanGeometry from ..half import EuclideanPositionHEG, Face, HalfEdge, Vertex from ..overlap import CREASE_ASSIGNMENT, MOUNTAIN, VALLEY @@ -39,17 +38,23 @@ def graph_to_fold(G, *, title: str | None = None) -> dict: outer region is not a Face in pleat), each emitted as its CCW vertex loop. Raises: - ValueError: if *G* is not a Euclidean 2D graph (FOLD has no notion of the - hyperbolic/spherical models pleat uses for curved tilings). + ValueError: if *G* does not have real 2D vertex positions -- FOLD cannot + represent the hyperbolic (complex Poincaré-disk) or spherical (3D) + coordinates pleat uses for curved tilings. """ - geometry = getattr(G, "geometry", EuclideanGeometry) - if geometry is not EuclideanGeometry: - name = getattr(geometry, "__name__", geometry) - raise ValueError( - f"FOLD export requires a Euclidean 2D crease pattern; this graph uses " - f"{name} geometry. FOLD cannot represent hyperbolic/spherical models." - ) verts = sorted(G.vertices, key=lambda v: v["id"]) + if verts: + sample = np.asarray(verts[0]["pos"]) + if np.iscomplexobj(sample): + raise ValueError( + "FOLD export requires a Euclidean 2D crease pattern, but this graph has " + "complex (hyperbolic / Poincaré-disk) vertex positions." + ) + if sample.ravel().size != 2: + raise ValueError( + f"FOLD export requires 2D vertex positions, but this graph has " + f"{sample.ravel().size}D positions (e.g. a spherical tiling)." + ) vidx = {v: i for i, v in enumerate(verts)} vertices_coords = [_coords2d(v["pos"]) for v in verts] @@ -177,8 +182,9 @@ def save_fold(path: str, G, *, overwrite: bool = False) -> None: path += ".fold" if not overwrite and os.path.exists(path): raise FileExistsError(f"File exists: {path}. Set overwrite=True to overwrite.") + fold = graph_to_fold(G) # build first, so a failure leaves no partial file with open(path, "w") as fh: - json.dump(graph_to_fold(G), fh) + json.dump(fold, fh) def load_fold(path: str) -> EuclideanPositionHEG: diff --git a/pleat/overlap.py b/pleat/overlap.py index ea737a4..d3d0d7f 100755 --- a/pleat/overlap.py +++ b/pleat/overlap.py @@ -1000,12 +1000,15 @@ def save_results( folded_settings = render_settings.copy() folded_settings["line_width"] /= 2 + # folded views and the backlit composite are renderings, not crease patterns: + # render straight to svg+png (render(...).save) rather than G.save's bundle, + # so we don't emit a meaningless top.heg / top.fold for a folded layer. if "folded_view_top" in results: rotate_graph(results["folded_view_top"], folded_angle) - results["folded_view_top"].save(os.path.join(path, "top"), **folded_settings) + results["folded_view_top"].render(**folded_settings).save(os.path.join(path, "top")) if "folded_view_bottom" in results: rotate_graph(results["folded_view_bottom"], folded_angle) - results["folded_view_bottom"].save(os.path.join(path, "bottom"), **folded_settings) + results["folded_view_bottom"].render(**folded_settings).save(os.path.join(path, "bottom")) backlight_settings = copy(render_settings) backlight_settings["render_edges"] = False @@ -1016,7 +1019,7 @@ def save_results( f["color_key"] = [0, 0, 0, 1 - (1 - opacity) ** (len(f["original_faces"]))] else: f["color_key"] = [0, 0, 0, opacity] - results["folded_state"].save(os.path.join(path, "backlit"), **backlight_settings) + results["folded_state"].render(**backlight_settings).save(os.path.join(path, "backlit")) if "CP_for_origami_simulator" in results: optimize_rotation(results["CP_for_origami_simulator"], angle_offset=0) diff --git a/tests/test_fold.py b/tests/test_fold.py index de9227d..b4c9c90 100644 --- a/tests/test_fold.py +++ b/tests/test_fold.py @@ -131,3 +131,24 @@ def test_g_save_dispatches_by_extension(tmp_path): G2 = load_fold(str(tmp_path / "r.fold")) G2.check_consistency() assert _crease_multiset(G) == _crease_multiset(G2) + + +def test_g_save_no_extension_writes_whole_bundle(tmp_path): + from pleat.io import load_fold, load_graph + + G = _creased_rosette() + G.save(str(tmp_path / "bundle"), height=64, width=64) + for ext in ("svg", "png", "heg", "fold"): + assert (tmp_path / f"bundle.{ext}").exists(), f"missing bundle.{ext}" + load_graph(str(tmp_path / "bundle.heg")).check_consistency() + load_fold(str(tmp_path / "bundle.fold")).check_consistency() + + +def test_g_save_no_extension_skips_fold_when_non_euclidean(tmp_path): + from pleat.example_graphs import from_tiles + from pleat.example_tilesets import curved_platonic + + G = from_tiles(curved_platonic(7, 3), rings=1) # hyperbolic + G.save(str(tmp_path / "hyp"), height=64) + assert (tmp_path / "hyp.heg").exists() # .heg works for any geometry + assert not (tmp_path / "hyp.fold").exists() # .fold skipped: non-Euclidean From 58ea9e43168befdb796f79f30e3d7b5649eabd99 Mon Sep 17 00:00:00 2001 From: Roman Remme Date: Sun, 19 Jul 2026 16:07:31 +0200 Subject: [PATCH 11/18] feat: button title + show Origami Simulator button in FoldResult.show() origami_simulator_button gains a 'title' kwarg for the label. FoldResult.show() displays a launch button for the (OS-optimised) crease pattern, gated on the new show_origami_simulator_button flag (which the initial version didn't actually check). --- pleat/origami_simulator.py | 16 ++++++---------- pleat/overlap.py | 11 +++++++++++ 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/pleat/origami_simulator.py b/pleat/origami_simulator.py index 31a69d4..7c8c1fa 100644 --- a/pleat/origami_simulator.py +++ b/pleat/origami_simulator.py @@ -82,12 +82,12 @@ def _iframe_html(G, *, height: int = 600) -> str: ) -def _button_html(G, *, height: int = 600) -> str: +def _button_html(G, *, height: int = 600, title: str = "Load Origami Simulator") -> str: """A button that injects the OS iframe inline when clicked (no popup).""" payload = json.dumps(_iframe_html(G, height=height)).replace(" - + str: - """FOLD as a JSON string safe to embed inside an HTML """ - - -def open_in_origami_simulator(G) -> str: - """Open *G* in Origami Simulator in the system browser (click-free). - - Writes a temp HTML page (see :func:`origami_simulator_html`) and opens it with - ``webbrowser``. Returns the temp file path and prints a ``file://`` URL (a - manual Ctrl+click fallback if auto-open fails). - - Note: with a remote kernel (SSH/cluster) the browser opens on the server; use - :func:`save_fold` and drag the file into origamisimulator.org instead. - """ - fd, path = tempfile.mkstemp(prefix="pleat-os-", suffix=".html") - with os.fdopen(fd, "w") as fh: - fh.write(origami_simulator_html(G)) - url = "file://" + path - print(f"Opening Origami Simulator: {url}") - webbrowser.open(url) - return path - - -class _OrigamiSimulatorButton: - """A displayable button; on click opens vanilla OS in a new tab with the CP. - - Renders in browser Jupyter and the built docs (its `_repr_html_` output is - captured at docs-build time). Each button only answers the OS window it - itself opened, so multiple buttons on one page don't cross-post. - """ - - def __init__(self, G): - self._html = _origami_simulator_button_html(G) - - def _repr_html_(self) -> str: - return self._html - - -def _origami_simulator_button_html(G) -> str: - fold_json = _fold_json(G) - uid = uuid.uuid4().hex[:8] - return f""" -""" - - -def origami_simulator_button(G) -> _OrigamiSimulatorButton: - """Return a displayable "Open in Origami Simulator" button for *G*. - - Use in a notebook cell (browser Jupyter or the online docs). On click it opens - Origami Simulator in a new tab and imports the crease pattern. - """ - return _OrigamiSimulatorButton(G) -``` - -- [ ] **Step 4: Re-export from `pleat/io/__init__.py`** - -Add after the `from .heg import ...` line: - -```python -from .fold import ( - fold_to_graph, - graph_to_fold, - load_fold, - open_in_origami_simulator, - origami_simulator_button, - origami_simulator_html, - save_fold, -) -``` - -And extend `__all__` with: - -```python - "graph_to_fold", - "fold_to_graph", - "save_fold", - "load_fold", - "origami_simulator_html", - "open_in_origami_simulator", - "origami_simulator_button", -``` - -- [ ] **Step 5: Add the convenience method to the graph class** - -In `pleat/half.py`, find `class EuclideanPositionHEG` and add this method (it defers the import to avoid a cycle, mirroring how `io` imports from `half`): - -```python - def open_in_origami_simulator(self) -> str: - """Open this crease pattern in Origami Simulator (see pleat.io.fold).""" - from .io.fold import open_in_origami_simulator - - return open_in_origami_simulator(self) -``` - -- [ ] **Step 6: Add top-level re-exports in `pleat/__init__.py`** - -After `import pleat.io`, add: - -```python -from pleat.io.fold import open_in_origami_simulator, origami_simulator_button -``` - -- [ ] **Step 7: Run the tests** - -Run: `python -m pytest tests/test_fold.py -q && python -c "import pleat; print(pleat.open_in_origami_simulator, pleat.origami_simulator_button)"` -Expected: PASS (5 tests), then the two function reprs print. - -- [ ] **Step 8: Commit** - -```bash -git add pleat/io/fold.py pleat/io/__init__.py pleat/half.py pleat/__init__.py tests/test_fold.py -git commit -m "feat: open crease patterns in Origami Simulator (iframe launcher + inline button)" -``` - ---- - -### Task 4: Documentation - -**Files:** -- Modify: `docs/notebooks/Saving_and_Exporting.ipynb` - -**Interfaces:** -- Consumes: everything from Tasks 2–3. - -- [ ] **Step 1: Read the notebook's existing structure** - -Run: `python -c "import json; nb=json.load(open('docs/notebooks/Saving_and_Exporting.ipynb')); print(len(nb['cells'])); [print(i, c['cell_type'], ''.join(c['source'])[:70].replace(chr(10),' ')) for i,c in enumerate(nb['cells'])]"` -Expected: prints the cell list so you can see how a CP (`cp`) is built earlier in the notebook and match its variable name / style. - -- [ ] **Step 2: Add a markdown cell and a code cell** - -Add near the end (use `NotebookEdit`, or edit the JSON). Markdown cell: - -```markdown -## FOLD & Origami Simulator - -[FOLD](https://github.com/edemaine/fold) is the standard origami interchange -format. `save_fold` writes a `.fold` file (crease pattern with M/V/B assignments -and fold angles); `load_fold` reads one back. You can also open a crease pattern -straight in [Origami Simulator](https://origamisimulator.org/): - -- `cp.open_in_origami_simulator()` — opens it in your browser (works from a - notebook or a script; needs a local kernel — with a remote kernel, `save_fold` - and drag the file in instead). -- `origami_simulator_button(cp)` — renders a button (works here in the online - docs): click it to open the pattern in Origami Simulator. -``` - -Code cell (match the CP variable used earlier in the notebook — replace `cp` if it differs): - -```python -from pleat.io.fold import save_fold, origami_simulator_button - -save_fold("example", cp, overwrite=True) # writes example.fold -origami_simulator_button(cp) # a clickable button in the docs -``` - -- [ ] **Step 3: Verify the notebook executes (this is what the docs build does)** - -Run: `jupyter nbconvert --to notebook --execute --stdout docs/notebooks/Saving_and_Exporting.ipynb > /dev/null && echo OK` -Expected: `OK` (no execution error — `execute: true` in `mkdocs.yml` runs this at build). - -- [ ] **Step 4: Commit** - -```bash -git add docs/notebooks/Saving_and_Exporting.ipynb -git commit -m "docs: FOLD export and Open-in-Origami-Simulator section" -``` - ---- - -## Self-Review - -**Spec coverage:** -- FOLD v1.2 export mapping (vertices/edges/assignment/foldAngle/faces) → Task 2 `graph_to_fold`. ✓ -- FOLD import / DCEL reconstruction → Task 2 `fold_to_graph`. ✓ -- `.fold` files → Task 2 `save_fold`/`load_fold`. ✓ -- `io` subpackage split → Task 1. ✓ -- Kernel-side `webbrowser` + temp-HTML iframe + pop-out + printed `file://` path → Task 3 `origami_simulator_html`/`open_in_origami_simulator`. ✓ -- Online-docs / inline button surface → Task 3 `origami_simulator_button` + Task 4. ✓ -- Convenience method + re-exports → Task 3 Steps 5–6. ✓ -- Tests: round-trip, FOLD validity, HTML content → Tasks 2–3. ✓ -- `.heg` retained (not retired); Euclidean-only scope → enforced by keeping Task 1 a pure move and FOLD living alongside. ✓ -- Deferred (foldedForm/faceOrders, importSVG, results.show() wiring) → not in any task, as intended. ✓ - -**Placeholder scan:** No TBD/TODO; every code and command step is concrete. - -**Type consistency:** `graph_to_fold`/`fold_to_graph`/`save_fold`/`load_fold`/`origami_simulator_html`/`open_in_origami_simulator`/`origami_simulator_button` names match across the module, `__init__` re-exports, tests, and the graph method. Assignment constants (`MOUNTAIN`/`VALLEY`) and `CREASE_ASSIGNMENT` used consistently. `_fold_json` shared by both the iframe page and the button. diff --git a/docs/superpowers/specs/2026-07-17-fold-origami-simulator-design.md b/docs/superpowers/specs/2026-07-17-fold-origami-simulator-design.md deleted file mode 100644 index ec0f506..0000000 --- a/docs/superpowers/specs/2026-07-17-fold-origami-simulator-design.md +++ /dev/null @@ -1,262 +0,0 @@ -# FOLD export/import + "Open in Origami Simulator" - -**Date:** 2026-07-17 -**Status:** Design approved, pending spec review. Implementation to happen in a dedicated worktree. - -## Goal - -Let pleat crease patterns be opened in [Origami Simulator](https://origamisimulator.org/) -directly from a Jupyter notebook or a plain Python script, and add FOLD as a -first-class crease-pattern interchange format (import + export). - -## Background: how the reference button works - -The "Simulate in Origami Simulator" button on -[erikdemaine.org/fonts/maze](https://erikdemaine.org/fonts/maze/) uses a -**browser-to-browser `postMessage` handshake** — no server, no file hosting -(`maze.js`): - -1. Opener opens Origami Simulator (OS): `window.open('https://origamisimulator.org/')`. -2. OS, once loaded, posts back `{from:'OrigamiSimulator', status:'ready'}` — to - `window.parent` if it is embedded in an iframe, else to `window.opener`. -3. Opener waits for `ready`, then posts the crease pattern. - -Origami Simulator's own `js/importer.js` listens for exactly two ops: - -- `{op:'importFold', fold:}` — sets FOLD data directly. -- `{op:'importSVG', svg:, filename, vertTol}` — parses SVG by stroke colour. - -The maze uses `importSVG` only because its renderer emits SVG. We use -**`importFold`**: pleat has the full half-edge topology + M/V assignments, so it -can hand OS an unambiguous FOLD object — no colour round-trip (which even pleat's -own `svg.load_svg` has to reverse heuristically). - -Note: `?model=` in the OS URL only selects *built-in demos* (it reads the -`data-url` of an ``), **not** arbitrary URLs. So the only -automatic import path is `postMessage` from a parent/opener page. OS sets no -`X-Frame-Options`/CSP `frame-ancestors` (verified), so it can be embedded in an -iframe. - -## Launch mechanism (must work in browser Jupyter, JupyterLab, VS Code, and scripts) - -Rendering *inside* notebook output is not portable: VS Code's notebook webview -sandboxes HTML/JS and blocks external iframes and `Javascript` display, and the -three front-ends have different CSP. The one mechanism that works identically -everywhere is **kernel-side**: - -> `graph_to_fold(cp)` → write a self-contained temp `.html` that embeds OS in a -> full-page iframe plus a handshake script carrying the FOLD JSON → -> `webbrowser.open("file://…")`. - -`open_in_origami_simulator(cp)` step by step: - -1. Build the FOLD dict from the graph. -2. Write a temp HTML file containing a full-page `