diff --git a/docs/index.ipynb b/docs/index.ipynb index cdc8c64..12955d4 100644 --- a/docs/index.ipynb +++ b/docs/index.ipynb @@ -113,6 +113,26 @@ "cell_type": "markdown", "id": "8", "metadata": {}, + "source": [ + "## Bonus: Open in origami simulator\n", + "\n", + "Use `.origami_simulator()` on a crease pattern to open [Amanda Ghassaeis](https://amandaghassaei.com/) [origami simulator](https://origamisimulator.org/) inline in a jupyter notebook (or in a new tab if called from a script). \n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9", + "metadata": {}, + "outputs": [], + "source": [ + "fold_result.CP.origami_simulator()" + ] + }, + { + "cell_type": "markdown", + "id": "10", + "metadata": {}, "source": [ "## What next?\n", "\n", @@ -126,7 +146,7 @@ }, { "cell_type": "markdown", - "id": "9", + "id": "11", "metadata": {}, "source": [ "## Personal release note\n", @@ -143,7 +163,7 @@ ], "metadata": { "kernelspec": { - "display_name": "pleat_claude", + "display_name": "pleat (3.13.8)", "language": "python", "name": "python3" }, @@ -157,7 +177,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.19" + "version": "3.13.8" } }, "nbformat": 4, diff --git a/docs/notebooks/Alternating_Flagstones.ipynb b/docs/notebooks/Alternating_Flagstones.ipynb index 222c1c2..bf8ee7d 100644 --- a/docs/notebooks/Alternating_Flagstones.ipynb +++ b/docs/notebooks/Alternating_Flagstones.ipynb @@ -254,7 +254,7 @@ "source": [ "## 6. Curved-fold subdivision (Origami Simulator)\n", "\n", - "Each ridge crease, when folded, ought to trace a *circular arc* — both endpoints are equidistant from the apex star vertex. [`subdivide_ridges_for_curved_fold`](../reference/pleat/alternating_flagstones.md#pleat.alternating_flagstones.subdivide_ridges_for_curved_fold) replaces every ridge with `n` short straight edges chosen so that the polyline approximates that arc. The result imports cleanly into [Origami Simulator](https://origamisimulator.org/)." + "Each ridge crease, when folded, ought to trace a curved arc. [`subdivide_ridges_for_curved_fold`](../reference/pleat/alternating_flagstones.md#pleat.alternating_flagstones.subdivide_ridges_for_curved_fold) replaces every ridge with `n` short straight edges chosen so that the polyline approximates that arc. The result could in principle be folded correctly in [Origami Simulator](https://origamisimulator.org/), however in practice it oftentimes struggles. Assigning correct small fold angles to these extra subdividing creases could be a way to fix this in the future." ] }, { @@ -264,16 +264,19 @@ "metadata": {}, "outputs": [], "source": [ - "subdivided = alternating_flagstones.subdivide_ridges_for_curved_fold(CP_cleaned, n_subdivisions=10)\n", + "from pleat.origami_simulator import origami_simulator_button\n", + "\n", + "\n", + "subdivided = alternating_flagstones.subdivide_ridges_for_curved_fold(CP_cleaned, n_subdivisions=5)\n", "\n", "multi_show(\n", " [CP_cleaned, subdivided],\n", - " titles=['straight ridges', 'subdivided ridges (10 segments)'],\n", + " titles=['straight ridges', 'subdivided ridges (5 segments)'],\n", " render_faces=False, render_vertices=False, line_width=0.01,\n", ")\n", "\n", - "# Uncomment below to export for Origami Simulator\n", - "# subdivided.save('alternating_flagstone_subdivided.svg')\n" + "origami_simulator_button(CP_cleaned, title=\"Load Origami Simulator with straight ridges\")\n", + "origami_simulator_button(subdivided, title=\"Load Origami Simulator with subdivided ridges\")\n" ] }, { @@ -311,11 +314,19 @@ "\n", "tempdir.cleanup()" ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "16", + "metadata": {}, + "outputs": [], + "source": [] } ], "metadata": { "kernelspec": { - "display_name": "pleat", + "display_name": "pleat (3.13.8)", "language": "python", "name": "python3" }, @@ -329,7 +340,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.12" + "version": "3.13.8" } }, "nbformat": 4, diff --git a/docs/notebooks/Saving_and_Exporting.ipynb b/docs/notebooks/Saving_and_Exporting.ipynb index 383ebfc..46222af 100644 --- a/docs/notebooks/Saving_and_Exporting.ipynb +++ b/docs/notebooks/Saving_and_Exporting.ipynb @@ -10,12 +10,9 @@ "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", - "- 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." + "- `.fold` — the standard [FOLD](https://github.com/edemaine/fold) interchange format.\n", + "- SVG — In two flavours, one for pretty pictures, one optimized for laser cutters / pen plotters.\n", + "- A high-level `overlap.save_results` that writes a whole result directory in one call." ] }, { @@ -27,12 +24,10 @@ "source": [ "import matplotlib\n", "matplotlib.rcParams['figure.figsize'] = (5, 5)\n", - "from pleat import (\n", - " example_graphs,\n", - " example_tilesets,\n", - " rendering,\n", - ")\n", - "from pleat.rendering import multi_show\n" + "import pleat\n", + "from pleat.rendering import multi_show, CREASE_PATTERN_PRESET\n", + "\n", + "import tempfile, os" ] }, { @@ -42,8 +37,9 @@ "metadata": {}, "outputs": [], "source": [ - "G = example_graphs.from_tiles(example_tilesets.platonic(4), rings=2)\n", - "G.recompute_lengths_and_angles()" + "G = pleat.example_graphs.from_tiles(pleat.example_tilesets.platonic(n=6), rings=2)\n", + "pleat.shrink_rotate.crease_orientation.assign_this_way_from_center(G)\n", + "G = pleat.shrink_rotate.shrink_rotate_pattern(G, alpha=0.6, factor=0.5)\n" ] }, { @@ -65,7 +61,6 @@ "metadata": {}, "outputs": [], "source": [ - "import tempfile, os\n", "from pleat import io\n", "\n", "with tempfile.TemporaryDirectory() as d:\n", @@ -80,6 +75,7 @@ "multi_show(\n", " [G, G2],\n", " titles=['original', 'loaded'],\n", + " **CREASE_PATTERN_PRESET\n", ")\n" ] }, @@ -88,9 +84,9 @@ "id": "5", "metadata": {}, "source": [ - "## SVG export via `G.save(...)`\n", + "## Export via `G.save(...)`\n", "\n", - "`G.save('out')` writes both `out.svg` and `out.png`. `G.show()` displays inline (vector SVG in Jupyter) without writing files. The SVG is the same vector drawing that `CairoRenderer` produced — open it in a browser or Inkscape for the full quality." + "`G.save('out')` writes all of `out.svg`, `out.png`, `out.heg` and `out.fold`. If you only want to save a subset, call with the appropriate file ending, e.g. `G.save('out.svg')`." ] }, { @@ -100,10 +96,9 @@ "metadata": {}, "outputs": [], "source": [ - "import tempfile, os\n", "with tempfile.TemporaryDirectory() as d:\n", " out = os.path.join(d, 'pattern')\n", - " G.save(out, **rendering.CREASE_PATTERN_PRESET)\n", + " G.save(out, **CREASE_PATTERN_PRESET)\n", " print('files in temp dir:', sorted(os.listdir(d)))\n", " print('SVG head:')\n", " print(open(out + '.svg').read()[:200])" @@ -116,23 +111,84 @@ "source": [ "## Plotter-ready SVG via `SvgwriteRenderer`\n", "\n", - "For laser-cutter / pen-plotter pipelines the dedicated `SvgwriteRenderer` produces an SVG split into `{name}_borders.svg` / `{name}_interior.svg` (so you can use different tool heads for cut vs. score)." + "For laser-cutter / pen-plotter pipelines the dedicated `SvgwriteRenderer` produces an SVG split into `{name}_borders.svg` / `{name}_interior.svg` (so you can use different tool heads for cut vs. score).\n", + "\n", + "These can then e.g. be converted to `.hpgl` with inkscape for plotting, see `plot_cp.py`." ] }, { - "cell_type": "markdown", + "cell_type": "code", + "execution_count": null, "id": "8", "metadata": {}, + "outputs": [], + "source": [ + "from pleat.rendering import SvgwriteRenderer\n", + "\n", + "with tempfile.TemporaryDirectory() as d:\n", + " out = os.path.join(d, 'pattern')\n", + " renderer = SvgwriteRenderer() \n", + " renderer.render_graph(out + '.svg', G)\n", + " print('files in temp dir:', sorted(os.listdir(d)))\n", + " print('SVG head:')\n", + " print(open(out + '.svg').read()[:200])\n" + ] + }, + { + "cell_type": "markdown", + "id": "9", + "metadata": {}, "source": [ "## All-in-one with `overlap.save_results`\n", "\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": "10", + "metadata": {}, + "source": [ + "## FOLD format\n", + "\n", + "[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." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "11", + "metadata": {}, + "outputs": [], + "source": [ + "from pleat.io import save_fold, load_fold\n", + "\n", + "\n", + "with tempfile.TemporaryDirectory() as d:\n", + " path = os.path.join(d, 'pattern.fold')\n", + " save_fold(path, G)\n", + " # cp.save(path) # also works\n", + " print('wrote', os.path.getsize(path), 'bytes of FOLD')\n", + " G_loaded = load_fold(path)\n", + "\n", + "\n", + "G_loaded.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "12", + "metadata": {}, + "outputs": [], + "source": [] } ], "metadata": { "kernelspec": { - "display_name": "pleat", + "display_name": "pleat (3.13.8.final.0)", "language": "python", "name": "python3" }, @@ -146,7 +202,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 efb6328..bb454b3 100755 --- a/pleat/__init__.py +++ b/pleat/__init__.py @@ -27,6 +27,12 @@ # I/O and rendering import pleat.io +# 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 import pleat.overlap diff --git a/pleat/half.py b/pleat/half.py index 0ab3416..17ea15e 100755 --- a/pleat/half.py +++ b/pleat/half.py @@ -1646,16 +1646,58 @@ 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*. - - ``path`` with no extension writes both ``path.svg`` and ``path.png``. + def save(self, path: str, **kwargs: object) -> None: + """Save the graph to *path*; the file **extension selects the format**: + + - ``.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 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; 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 the whole bundle: + ``.svg`` + ``.png`` + ``.heg`` + ``.fold``). + **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) + 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`). + + 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).""" diff --git a/pleat/io/__init__.py b/pleat/io/__init__.py new file mode 100644 index 0000000..a565b0e --- /dev/null +++ b/pleat/io/__init__.py @@ -0,0 +1,30 @@ +"""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 .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__ = [ + "graph_to_dict", + "dict_to_graph", + "save_graph", + "load_graph", + "CirclePackData", + "parse_p_file", + "write_p_file", + "load_circlepack", + "save_circlepack", + "graph_to_fold", + "fold_to_graph", + "save_fold", + "load_fold", +] diff --git a/pleat/io.py b/pleat/io/circlepack.py old mode 100755 new mode 100644 similarity index 69% rename from pleat/io.py rename to pleat/io/circlepack.py index fa0e264..de75ed0 --- a/pleat/io.py +++ b/pleat/io/circlepack.py @@ -1,5 +1,4 @@ -"""File I/O for the `.heg` half-edge graph format (YAML-based) and the -CirclePack `.p` format.""" +"""File I/O for the CirclePack ``.p`` format.""" from __future__ import annotations @@ -8,192 +7,9 @@ from dataclasses import dataclass import numpy as np -import yaml -import pleat - -from .geometries import EuclideanGeometry, PoincareDiskModel -from .half import EuclideanPositionHEG, Face, HalfEdge, HalfEdgeGraph, Vertex, rotate_by - - -def graph_to_dict( - G: HalfEdgeGraph, - attributes_to_save: tuple[str, ...] = ("pos", "length", "in_angle", "color_key"), -) -> dict: - """Serialise a half-edge graph to a JSON/YAML-friendly nested dict. - - Vertices, half-edges, and faces are each given an opaque string label - (``v0``, ``h0``, ``f0``, ...). Cross-references are stored by label. - Numpy arrays in attributes are converted to plain Python lists, and numpy - scalars to ``float``. - - Args: - G: The graph to serialise. - attributes_to_save: Attribute keys to copy onto each element. Other - attributes are dropped. - - Returns: - ``{'vertices': ..., 'halfedges': ..., 'faces': ...}``, suitable for - :func:`yaml.dump`. - """ - vertex_labels = {v: f"v{i}" for i, v in enumerate(G.vertices)} - halfedge_labels = {h: f"h{i}" for i, h in enumerate(G.halfedges)} - face_labels = {f: f"f{i}" for i, f in enumerate(G.faces)} - - labels = {None: None} - labels.update(vertex_labels) - labels.update(halfedge_labels) - labels.update(face_labels) - - def represent_attributes(obj): - result = {} - for attr in attributes_to_save: - if attr in obj.attributes: - value = obj[attr] - if isinstance(value, np.ndarray): - value = value.tolist() - if np.isscalar(value): - if np.iscomplexobj(value): - c = complex(value) # type: ignore[arg-type] - value = {"complex": [c.real, c.imag]} - else: - value = float(value) # type: ignore[arg-type] - result[attr] = value - return result - - def add_attributes(func): - def wrapped(obj): - result = func(obj) - attrs = represent_attributes(obj) - if attrs: - result["attributes"] = attrs - return result - - return wrapped - - @add_attributes - def represent_vertex(v): - return dict(any_outgoing=labels[v.any_outgoing]) - - @add_attributes - def represent_halfedge(h): - return dict( - orig=labels[h.orig], - dest=labels[h.dest], - rev=labels[h.rev], - nex=labels[h.nex], - pre=labels[h.pre], - face=labels[h.face], - ) - - @add_attributes - def represent_face(f): - return dict(any_side=labels[f.any_side]) - - vertex_dict = {label: represent_vertex(v) for v, label in vertex_labels.items()} - halfedge_dict = {label: represent_halfedge(h) for h, label in halfedge_labels.items()} - face_dict = {label: represent_face(f) for f, label in face_labels.items()} - - graph_dict = dict(vertices=vertex_dict, halfedges=halfedge_dict, faces=face_dict) - return graph_dict - - -def dict_to_graph(graph_dict: dict) -> pleat.half.EuclideanPositionHEG: - """Inverse of :func:`graph_to_dict`: reconstruct a graph from its serialised dict. - - The returned graph is always an :class:`EuclideanPositionHEG` regardless of - the source graph's class (TODO: persist the class). - """ - - def unwrap_attributes(obj_dict): - result = {} - for key, value in obj_dict.pop("attributes", {}).items(): - if isinstance(value, dict) and set(value) == {"complex"}: - value = np.complex128(complex(*value["complex"])) - elif isinstance(value, list): - try: - value = np.array(value, dtype=np.float64) - except Exception: - pass - result[key] = value - return result - - lookup = {None: None} - # create the halfedges - for label in graph_dict["halfedges"]: - lookup[label] = HalfEdge() - - vs = set() - for label, v_dict in graph_dict["vertices"].items(): - attrs = unwrap_attributes(v_dict) - v_dict["any_outgoing"] = lookup[v_dict["any_outgoing"]] - v = Vertex(**v_dict) - v.attributes = attrs - lookup[label] = v - vs.add(v) - - fs = set() - for label, f_dict in graph_dict["faces"].items(): - attrs = unwrap_attributes(f_dict) - f_dict["any_side"] = lookup[f_dict["any_side"]] - f = Face(**f_dict) - f.attributes = attrs - lookup[label] = f - fs.add(f) - - hs = set() - for label, h_dict in graph_dict["halfedges"].items(): - attrs = unwrap_attributes(h_dict) - h = lookup[label] - for key in ["orig", "dest", "rev", "nex", "pre", "face"]: - setattr(h, key, lookup[h_dict.pop(key, None)]) - h.attributes = attrs - hs.add(h) - - # TODO make it so the class can be specified - G = pleat.half.EuclideanPositionHEG() - G.vertices = vs - G.faces = fs - G.halfedges = hs - return G - - -def save_graph( - filename: str, - graph: HalfEdgeGraph, - overwrite: bool = False, - extra_attributes_to_save: str | tuple[str, ...] | None = None, - attributes_to_save: tuple[str, ...] = ("pos", "length", "in_angle", "color_key"), -) -> None: - """Save *graph* to a ``.heg`` (YAML) file. - - Args: - filename: Output path; ``.heg`` is appended if missing. - graph: The graph to save. - overwrite: If False and the file already exists, raise instead of overwriting. - extra_attributes_to_save: Convenience: attribute key(s) to save in - addition to *attributes_to_save*. - attributes_to_save: Attribute keys to persist (see :func:`graph_to_dict`). - """ - if not filename.endswith(".heg"): - filename += ".heg" - if not overwrite: - assert not os.path.exists(filename), f"File exists: {filename}. Set overwrite=True to overwrite." - if extra_attributes_to_save is not None: - if isinstance(extra_attributes_to_save, str): - extra_attributes_to_save = (extra_attributes_to_save,) - attributes_to_save = tuple(extra_attributes_to_save) + tuple(attributes_to_save) - graph_dict = graph_to_dict(graph, attributes_to_save=attributes_to_save) - with open(filename, "w") as f: - f.write(yaml.dump(graph_dict)) - - -def load_graph(filename: str) -> pleat.half.EuclideanPositionHEG: - """Load a graph previously saved by :func:`save_graph`.""" - with open(filename, "r") as f: - lines = f.read() - graph_dict = yaml.load(lines, Loader=yaml.SafeLoader) - return dict_to_graph(graph_dict) +from ..geometries import EuclideanGeometry, PoincareDiskModel +from ..half import EuclideanPositionHEG, Face, HalfEdge, Vertex, rotate_by # =========================================================================== @@ -498,7 +314,7 @@ def load_circlepack(path: str) -> EuclideanPositionHEG: idx_to_v[i]["radius"] = _r_eucl_from_x_and_center(x, c) else: # No CENTERS — lay out from x-radii. - from .circle_packing import _choose_alpha, _choose_beta, _layout_hyperbolic + from ..circle_packing import _choose_alpha, _choose_beta, _layout_hyperbolic x_radii = {idx_to_v[i]: float(data.radii[i]) for i in range(data.nodecount)} alpha = idx_to_v[data.alpha - 1] if data.alpha is not None else _choose_alpha(G) @@ -552,7 +368,7 @@ def _graph_to_circlepack_data(G: EuclideanPositionHEG) -> CirclePackData: alpha_idx = beta_idx = gamma_idx = None interior = [v for v in vs if not v.on_border()] if interior: - from .circle_packing import _choose_alpha, _choose_beta + from ..circle_packing import _choose_alpha, _choose_beta alpha_v = _choose_alpha(G) beta_v = _choose_beta(alpha_v) @@ -566,7 +382,7 @@ def _graph_to_circlepack_data(G: EuclideanPositionHEG) -> CirclePackData: radii: np.ndarray | None = None if all("radius" in v.attributes for v in vs): if geometry == "hyperbolic": - from .circle_packing import _x_radius_from_euclidean + from ..circle_packing import _x_radius_from_euclidean radii = np.array([_x_radius_from_euclidean(complex(v["pos"]), float(v["radius"])) for v in vs]) else: diff --git a/pleat/io/fold.py b/pleat/io/fold.py new file mode 100644 index 0000000..f39e1d2 --- /dev/null +++ b/pleat/io/fold.py @@ -0,0 +1,201 @@ +"""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 json +import os + +import numpy as np + +from ..half import EuclideanPositionHEG, Face, HalfEdge, Vertex +from ..overlap import CREASE_ASSIGNMENT, MOUNTAIN, VALLEY, color_creases + +_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. + + Raises: + 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. + """ + 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] + + 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)) + + # Emit faces clockwise (pleat's vertex_iter is CCW, hence the reversal). Origami + # Simulator's importFold colors the face's CCW side as the *back* (white) and shows + # it up; feeding CW puts the front (coloured) side up, matching pleat's rendering. + # This only affects which side is coloured, not the fold -- mountain/valley is + # driven by edges_foldAngle, independent of winding. fold_to_graph reverses back. + faces_vertices = [[vidx[v] for v in f.vertex_iter()][::-1] 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 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. graph_to_fold emits faces clockwise + # (for Origami Simulator); reverse back to pleat's CCW convention here. + he: dict[tuple[int, int], HalfEdge] = {} + for raw_face in faces_vertices: + face_vs = raw_face[::-1] + 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 + 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) + + # 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 e, a in zip(edges_vertices, assignment): + val = _LETTER_TO_ASSIGN.get(a) + if val is None: + continue + i, j = int(e[0]), int(e[1]) + he[(i, j)][CREASE_ASSIGNMENT] = val + he[(j, i)][CREASE_ASSIGNMENT] = val + + color_creases(G) # set edge["color_key"] from CREASE_ASSIGNMENT + 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.") + fold = graph_to_fold(G) # build first, so a failure leaves no partial file + with open(path, "w") as fh: + json.dump(fold, 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)) diff --git a/pleat/io/heg.py b/pleat/io/heg.py new file mode 100644 index 0000000..63e2579 --- /dev/null +++ b/pleat/io/heg.py @@ -0,0 +1,194 @@ +"""File I/O for the ``.heg`` half-edge graph format (YAML-based).""" + +from __future__ import annotations + +import os + +import numpy as np +import yaml + +import pleat + +from ..half import Face, HalfEdge, HalfEdgeGraph, Vertex + + +def graph_to_dict( + G: HalfEdgeGraph, + attributes_to_save: tuple[str, ...] = ("pos", "length", "in_angle", "color_key"), +) -> dict: + """Serialise a half-edge graph to a JSON/YAML-friendly nested dict. + + Vertices, half-edges, and faces are each given an opaque string label + (``v0``, ``h0``, ``f0``, ...). Cross-references are stored by label. + Numpy arrays in attributes are converted to plain Python lists, and numpy + scalars to ``float``. + + Args: + G: The graph to serialise. + attributes_to_save: Attribute keys to copy onto each element. Other + attributes are dropped. + + Returns: + ``{'vertices': ..., 'halfedges': ..., 'faces': ...}``, suitable for + :func:`yaml.dump`. + """ + vertex_labels = {v: f"v{i}" for i, v in enumerate(G.vertices)} + halfedge_labels = {h: f"h{i}" for i, h in enumerate(G.halfedges)} + face_labels = {f: f"f{i}" for i, f in enumerate(G.faces)} + + labels = {None: None} + labels.update(vertex_labels) + labels.update(halfedge_labels) + labels.update(face_labels) + + def represent_attributes(obj): + result = {} + for attr in attributes_to_save: + if attr in obj.attributes: + value = obj[attr] + if isinstance(value, np.ndarray): + value = value.tolist() + if np.isscalar(value): + if isinstance(value, (str, bytes, bool)): + pass # e.g. a hex colour_key like "#cc2222" -- keep as-is + elif np.iscomplexobj(value): + c = complex(value) # type: ignore[arg-type] + value = {"complex": [c.real, c.imag]} + else: + value = float(value) # type: ignore[arg-type] + result[attr] = value + return result + + def add_attributes(func): + def wrapped(obj): + result = func(obj) + attrs = represent_attributes(obj) + if attrs: + result["attributes"] = attrs + return result + + return wrapped + + @add_attributes + def represent_vertex(v): + return dict(any_outgoing=labels[v.any_outgoing]) + + @add_attributes + def represent_halfedge(h): + return dict( + orig=labels[h.orig], + dest=labels[h.dest], + rev=labels[h.rev], + nex=labels[h.nex], + pre=labels[h.pre], + face=labels[h.face], + ) + + @add_attributes + def represent_face(f): + return dict(any_side=labels[f.any_side]) + + vertex_dict = {label: represent_vertex(v) for v, label in vertex_labels.items()} + halfedge_dict = {label: represent_halfedge(h) for h, label in halfedge_labels.items()} + face_dict = {label: represent_face(f) for f, label in face_labels.items()} + + graph_dict = dict(vertices=vertex_dict, halfedges=halfedge_dict, faces=face_dict) + return graph_dict + + +def dict_to_graph(graph_dict: dict) -> pleat.half.EuclideanPositionHEG: + """Inverse of :func:`graph_to_dict`: reconstruct a graph from its serialised dict. + + The returned graph is always an :class:`EuclideanPositionHEG` regardless of + the source graph's class (TODO: persist the class). + """ + + def unwrap_attributes(obj_dict): + result = {} + for key, value in obj_dict.pop("attributes", {}).items(): + if isinstance(value, dict) and set(value) == {"complex"}: + value = np.complex128(complex(*value["complex"])) + elif isinstance(value, list): + try: + value = np.array(value, dtype=np.float64) + except Exception: + pass + result[key] = value + return result + + lookup = {None: None} + # create the halfedges + for label in graph_dict["halfedges"]: + lookup[label] = HalfEdge() + + vs = set() + for label, v_dict in graph_dict["vertices"].items(): + attrs = unwrap_attributes(v_dict) + v_dict["any_outgoing"] = lookup[v_dict["any_outgoing"]] + v = Vertex(**v_dict) + v.attributes = attrs + lookup[label] = v + vs.add(v) + + fs = set() + for label, f_dict in graph_dict["faces"].items(): + attrs = unwrap_attributes(f_dict) + f_dict["any_side"] = lookup[f_dict["any_side"]] + f = Face(**f_dict) + f.attributes = attrs + lookup[label] = f + fs.add(f) + + hs = set() + for label, h_dict in graph_dict["halfedges"].items(): + attrs = unwrap_attributes(h_dict) + h = lookup[label] + for key in ["orig", "dest", "rev", "nex", "pre", "face"]: + setattr(h, key, lookup[h_dict.pop(key, None)]) + h.attributes = attrs + hs.add(h) + + # TODO make it so the class can be specified + G = pleat.half.EuclideanPositionHEG() + G.vertices = vs + G.faces = fs + G.halfedges = hs + return G + + +def save_graph( + filename: str, + graph: HalfEdgeGraph, + overwrite: bool = False, + extra_attributes_to_save: str | tuple[str, ...] | None = None, + attributes_to_save: tuple[str, ...] = ("pos", "length", "in_angle", "color_key"), +) -> None: + """Save *graph* to a ``.heg`` (YAML) file. + + Args: + filename: Output path; ``.heg`` is appended if missing. + graph: The graph to save. + overwrite: If False and the file already exists, raise instead of overwriting. + extra_attributes_to_save: Convenience: attribute key(s) to save in + addition to *attributes_to_save*. + attributes_to_save: Attribute keys to persist (see :func:`graph_to_dict`). + """ + if not filename.endswith(".heg"): + filename += ".heg" + if not overwrite and os.path.exists(filename): + raise FileExistsError(f"File exists: {filename}. Set overwrite=True to overwrite.") + if extra_attributes_to_save is not None: + if isinstance(extra_attributes_to_save, str): + extra_attributes_to_save = (extra_attributes_to_save,) + attributes_to_save = tuple(extra_attributes_to_save) + tuple(attributes_to_save) + graph_dict = graph_to_dict(graph, attributes_to_save=attributes_to_save) + with open(filename, "w") as f: + f.write(yaml.dump(graph_dict)) + + +def load_graph(filename: str) -> pleat.half.EuclideanPositionHEG: + """Load a graph previously saved by :func:`save_graph`.""" + with open(filename, "r") as f: + lines = f.read() + graph_dict = yaml.load(lines, Loader=yaml.SafeLoader) + return dict_to_graph(graph_dict) diff --git a/pleat/origami_simulator.py b/pleat/origami_simulator.py new file mode 100644 index 0000000..cf73029 --- /dev/null +++ b/pleat/origami_simulator.py @@ -0,0 +1,147 @@ +"""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 +from .utils import in_notebook + +__all__ = ["origami_simulator", "origami_simulator_button"] + +_OS_ORIGIN = "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 = _OS_ORIGIN + "/?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, 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(" + + +""" + + +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", encoding="utf-8") as fh: + fh.write(_page_html(G)) + url = "file://" + path + print(f"Opening Origami Simulator: {url}") + webbrowser.open(url) + return path + + +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. + """ + 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, title: str = "Load Origami Simulator") -> 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. *title* sets the button label. + """ + _display_html(_button_html(G, height=height, title=title)) diff --git a/pleat/overlap.py b/pleat/overlap.py index ea737a4..4c9298c 100755 --- a/pleat/overlap.py +++ b/pleat/overlap.py @@ -21,10 +21,13 @@ from scipy.cluster.hierarchy import fcluster from tqdm.auto import tqdm +# from pleat.origami_simulator import origami_simulator_button + from .base import orientation from .conversions import EHEG_from_nx from .layout import angle_to_height, min_edge_length, optimize_rotation, rotate_graph from .rendering import BORDER_COLOR, MOUNTAIN_COLOR, VALLEY_COLOR, CairoRenderer, SvgwriteRenderer, multi_show +from .utils import in_notebook if TYPE_CHECKING: from .half import EuclideanPositionHEG, Face @@ -866,6 +869,7 @@ def show( ncols: int | None = 2, suptitle: str | None = None, cell_size: float = 4.0, + show_origami_simulator_button: bool = True, ) -> None: """Display the available result graphs side-by-side via :func:`multi_show`. @@ -881,6 +885,7 @@ def show( ncols: Number of columns in the matplotlib grid. suptitle: Optional figure-level title. cell_size: Per-cell size in matplotlib inches. + show_origami_simulator_button: If True, display a button that launches the Origami Simulator with the crease pattern. """ if render_settings is None: render_settings = dict(face_inset=0, render_vertices=False, render_faces=True, height=512) @@ -943,6 +948,15 @@ def show( **render_settings, ) + # the button needs IPython (notebook display); skip it in scripts so show() + # still works there via matplotlib + if show_origami_simulator_button and in_notebook(): + cp_to_simulate = self.CP_for_origami_simulator if self.CP_for_origami_simulator is not None else self.CP + if cp_to_simulate is not None: + from pleat.origami_simulator import origami_simulator_button + + origami_simulator_button(cp_to_simulate) + def save(self, path: str, **save_results_kwargs): """Convenience wrapper around :func:`save_results`.""" save_results(self, path=path, **save_results_kwargs) @@ -1000,12 +1014,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 +1033,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/pleat/shrink_rotate/pipeline.py b/pleat/shrink_rotate/pipeline.py index c7831af..d8fdaf5 100644 --- a/pleat/shrink_rotate/pipeline.py +++ b/pleat/shrink_rotate/pipeline.py @@ -22,8 +22,7 @@ from ..conway import shrink_rotate_graph from ..flat_foldable import max_kawasaki_sum from ..half import EuclideanPositionHEG, GeometricHEG, HalfEdgeGraph -from ..overlap import BORDER, CREASE_ASSIGNMENT, MOUNTAIN, VALLEY -from ..rendering import BORDER_COLOR, MOUNTAIN_COLOR, VALLEY_COLOR +from ..overlap import CREASE_ASSIGNMENT, MOUNTAIN, VALLEY, color_creases from ..utils import invert_mapping from .crease_orientation import THIS_WAY from .reciprocal_figures import reciprocal_figure @@ -105,14 +104,7 @@ def shrink_rotate_pattern( if assign_creases: assign_shrink_rotate_creases(SRG) - - colors = { - BORDER: BORDER_COLOR, - MOUNTAIN: MOUNTAIN_COLOR, - VALLEY: VALLEY_COLOR, - } - for e in SRG.halfedges: - e["color_key"] = colors[e.attributes.get(CREASE_ASSIGNMENT, BORDER)] + color_creases(SRG) if simplify_boundary: SRG.join_order_2_boundary_vertices() diff --git a/pleat/utils.py b/pleat/utils.py index 7344416..dd84021 100755 --- a/pleat/utils.py +++ b/pleat/utils.py @@ -23,6 +23,18 @@ def invert_mapping(mapping: dict[K, V]) -> dict[V, K]: return {value: key for key, value in mapping.items()} +def in_notebook() -> bool: + """True inside a Jupyter kernel (Notebook / Lab / VS Code); False in a terminal, + a plain script, or when IPython is not installed.""" + try: + from IPython import get_ipython + + ip = get_ipython() + return ip is not None and "IPKernelApp" in ip.config + except Exception: + return False + + def random_directed_set(edges: HalfEdgeGraph | Iterable[HalfEdge]) -> set[HalfEdge]: """Pick exactly one half-edge from each undirected pair. 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) diff --git a/tests/test_fold.py b/tests/test_fold.py new file mode 100644 index 0000000..42c8fdf --- /dev/null +++ b/tests/test_fold.py @@ -0,0 +1,180 @@ +"""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, 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 _signed_area(coords, face): + import numpy as np + + p = np.asarray(coords)[face] + x, y = p[:, 0], p[:, 1] + return 0.5 * float(np.sum(x * np.roll(y, -1) - np.roll(x, -1) * y)) + + +def test_graph_to_fold_emits_clockwise_faces_for_origami_simulator(): + # Origami Simulator's importFold shows the CCW side as the back (white); we emit + # clockwise (negative signed area) so the coloured side faces up. Regression guard. + fold = graph_to_fold(_creased_rosette()) + areas = [_signed_area(fold["vertices_coords"], f) for f in fold["faces_vertices"]] + assert all(a < 0 for a in areas), "exported FOLD faces must be clockwise" + + +def test_fold_roundtrip_preserves_face_winding(): + # export reverses to CW, import reverses back, so pleat's own winding is preserved + G = _creased_rosette() + before = graph_to_fold(G) + after = graph_to_fold(fold_to_graph(before)) + a_before = _signed_area(before["vertices_coords"], before["faces_vertices"][0]) + a_after = _signed_area(after["vertices_coords"], after["faces_vertices"][0]) + assert (a_before < 0) == (a_after < 0) + + +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_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) + + +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 diff --git a/tests/test_io.py b/tests/test_io.py index edd48d9..18caebc 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -47,10 +47,10 @@ def test_save_refuses_overwrite_by_default(tmp_path): save_graph(filename, G) try: save_graph(filename, G) - except AssertionError: + except FileExistsError: pass else: - raise AssertionError("expected AssertionError on second save without overwrite=True") + raise AssertionError("expected FileExistsError on second save without overwrite=True") save_graph(filename, G, overwrite=True) @@ -61,6 +61,22 @@ def test_save_appends_heg_extension(tmp_path): assert (tmp_path / "noext.heg").exists() +def test_save_load_string_and_array_attributes(tmp_path): + # colour_key may be a hex string (e.g. "#cc2222") or an RGBA array; both must + # survive .heg round-trip. Regression for float()-ing a string scalar. + G = EuclideanPositionHEG(other=rosette(n=4)) + hs = list(G.halfedges) + hs[0]["color_key"] = "#cc2222" + hs[1]["color_key"] = np.array([0.0, 0.0, 0.0, 0.15]) + filename = str(tmp_path / "colored.heg") + save_graph(filename, G) + G2 = load_graph(filename) + G2.check_consistency() + keys = [h.attributes.get("color_key") for h in G2.halfedges if "color_key" in h.attributes] + assert any(isinstance(k, str) and k == "#cc2222" for k in keys) + assert any(isinstance(k, np.ndarray) and np.allclose(k, [0, 0, 0, 0.15]) for k in keys) + + def test_roundtrip_curved(tmp_path): """Curved tilings (complex hyperbolic and 3D spherical positions) survive a save/load cycle.""" from pleat.example_graphs import from_tiles diff --git a/tests/test_origami_simulator.py b/tests/test_origami_simulator.py new file mode 100644 index 0000000..cb05d12 --- /dev/null +++ b/tests/test_origami_simulator.py @@ -0,0 +1,73 @@ +"""Tests for pleat.origami_simulator: the launcher HTML and the public surface.""" + +from __future__ import annotations + +import pleat +from pleat.example_graphs import rosette +from pleat.half import EuclideanPositionHEG +from pleat.origami_simulator import ( + _button_html, + _iframe_html, + _page_html, + origami_simulator, + origami_simulator_button, +) +from pleat.overlap import CREASE_ASSIGNMENT, MOUNTAIN, VALLEY + + +def _creased_rosette(): + G = EuclideanPositionHEG(other=rosette(n=6)) + for i, h in enumerate(h for h in G.halfedges if not h.on_border() and not h.rev.on_border()): + a = MOUNTAIN if i % 2 == 0 else VALLEY + h[CREASE_ASSIGNMENT] = h.rev[CREASE_ASSIGNMENT] = a + return G + + +def test_page_embeds_fold_and_suppresses_default(): + html = _page_html(_creased_rosette()) + assert "importFold" in html + assert '"edges_assignment"' in html # the FOLD JSON is embedded + assert "origamisimulator.org/?model=" in html # empty ?model= => 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 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 ( + "origami_simulator", + "origami_simulator_button", + "origami_simulator_html", + "open_in_origami_simulator", + ): + assert gone not in pleat.io.__all__