Skip to content

io

pleat.io

File I/O for the .heg half-edge graph format (YAML-based) and the CirclePack .p format.

CirclePackData dataclass

CirclePackData(
    nodecount: int,
    geometry: str | None,
    alpha: int | None,
    beta: int | None,
    gamma: int | None,
    flowers: dict[int, list[int]],
    radii: ndarray | None,
    centers: ndarray | None,
)

Structured contents of a CirclePack .p file.

All vertex indices are 0-indexed internally even though the file format stores them 1-indexed. Use :func:parse_p_file to read and :func:write_p_file to write this dataclass verbatim.

graph_to_dict

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.

Parameters:

Name Type Description Default
G HalfEdgeGraph

The graph to serialise.

required
attributes_to_save tuple[str, ...]

Attribute keys to copy onto each element. Other attributes are dropped.

('pos', 'length', 'in_angle', 'color_key')

Returns:

Type Description
dict

{'vertices': ..., 'halfedges': ..., 'faces': ...}, suitable for

dict

func:yaml.dump.

Source code in pleat/io.py
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

dict_to_graph

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).

Source code in pleat/io.py
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

save_graph

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.

Parameters:

Name Type Description Default
filename str

Output path; .heg is appended if missing.

required
graph HalfEdgeGraph

The graph to save.

required
overwrite bool

If False and the file already exists, raise instead of overwriting.

False
extra_attributes_to_save str | tuple[str, ...] | None

Convenience: attribute key(s) to save in addition to attributes_to_save.

None
attributes_to_save tuple[str, ...]

Attribute keys to persist (see :func:graph_to_dict).

('pos', 'length', 'in_angle', 'color_key')
Source code in pleat/io.py
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))

load_graph

load_graph(
    filename: str,
) -> pleat.half.EuclideanPositionHEG

Load a graph previously saved by :func:save_graph.

Source code in pleat/io.py
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)

parse_p_file

parse_p_file(path: str) -> CirclePackData

Parse a CirclePack .p file into a :class:CirclePackData dataclass.

Source code in pleat/io.py
def parse_p_file(path: str) -> CirclePackData:
    """Parse a CirclePack `.p` file into a :class:`CirclePackData` dataclass."""
    with open(path) as f:
        lines = [ln.strip() for ln in f if ln.strip()]
    if lines[-1] != "END":
        raise ValueError(f"{path}: expected final line 'END', got {lines[-1]!r}")
    lines = lines[:-1]

    sections: dict[str, list[str]] = {}
    current: list[str] | None = None
    name = ""
    for line in lines:
        if ":" in line:
            if current is not None:
                sections[name] = current
            head, tail = line.split(":", 1)
            name = head.strip()
            current = []
            tail = tail.strip()
            if tail:
                current.append(tail)
        else:
            if current is None:
                raise ValueError(f"Unexpected line without section: {line}")
            current.append(line)
    if current is not None:
        sections[name] = current

    nodecount = int(sections["NODECOUNT"][0])
    geometry = sections.get("GEOMETRY", [None])[0]

    abg = sections.get("ALPHA/BETA/GAMMA")
    alpha = beta = gamma = None
    if abg:
        parts = abg[0].split()
        alpha = int(parts[0])
        beta = int(parts[1])
        gamma = int(parts[2])

    flowers: dict[int, list[int]] = {}
    if "FLOWERS" in sections:
        for line in sections["FLOWERS"]:
            parts = line.split()
            center = int(parts[0]) - 1
            degree = int(parts[1])
            neighbors = [int(x) - 1 for x in parts[2:]]
            if len(neighbors) != degree + 1:
                raise ValueError(f"FLOWERS line {line!r}: expected {degree + 1} neighbors, got {len(neighbors)}")
            flowers[center] = neighbors

    radii: np.ndarray | None = None
    if "RADII" in sections:
        nums: list[float] = []
        for line in sections["RADII"]:
            nums.extend(float(x) for x in line.split())
        radii = np.array(nums)
        if len(radii) != nodecount:
            raise ValueError(f"RADII has {len(radii)} entries, expected {nodecount}")

    centers: np.ndarray | None = None
    if "CENTERS" in sections:
        nums = []
        for line in sections["CENTERS"]:
            nums.extend(float(x) for x in line.split())
        centers = np.array(nums).reshape(-1, 2)
        if len(centers) != nodecount:
            raise ValueError(f"CENTERS has {len(centers)} entries, expected {nodecount}")

    return CirclePackData(
        nodecount=nodecount,
        geometry=geometry,
        alpha=alpha,
        beta=beta,
        gamma=gamma,
        flowers=flowers,
        radii=radii,
        centers=centers,
    )

write_p_file

write_p_file(
    path: str,
    data: CirclePackData,
    *,
    overwrite: bool = False
) -> None

Write a :class:CirclePackData verbatim to a CirclePack .p file.

Vertex labels and the per-vertex neighbor list order in data.flowers are preserved exactly, so :func:parse_p_file ∘ :func:write_p_file round-trips losslessly (modulo float precision).

Source code in pleat/io.py
def write_p_file(path: str, data: CirclePackData, *, overwrite: bool = False) -> None:
    """Write a :class:`CirclePackData` verbatim to a CirclePack `.p` file.

    Vertex labels and the per-vertex neighbor list order in ``data.flowers``
    are preserved exactly, so :func:`parse_p_file` ∘ :func:`write_p_file`
    round-trips losslessly (modulo float precision).
    """
    if not overwrite and os.path.exists(path):
        raise FileExistsError(f"File exists: {path}. Set overwrite=True to overwrite.")

    sections: list[str] = []
    sections.append(f"NODECOUNT: {data.nodecount}")
    if data.geometry is not None:
        sections.append(f"GEOMETRY: {data.geometry}")
    if data.alpha is not None and data.beta is not None and data.gamma is not None:
        sections.append(f"ALPHA/BETA/GAMMA: {data.alpha} {data.beta} {data.gamma}")

    flower_lines = ["FLOWERS:"]
    for i, neighbors in data.flowers.items():
        degree = len(neighbors) - 1
        flower_lines.append(f"{i + 1} {degree}   " + " ".join(str(n + 1) for n in neighbors))
    sections.append("\n".join(flower_lines))

    if data.radii is not None:
        radii_lines = ["RADII:"]
        rs = np.asarray(data.radii).ravel()
        for j in range(0, len(rs), 4):
            chunk = rs[j : j + 4]
            radii_lines.append("   ".join(f"{float(r):.16e}" for r in chunk))
        sections.append("\n".join(radii_lines))

    if data.centers is not None:
        center_lines = ["CENTERS:"]
        cs = np.asarray(data.centers).reshape(-1, 2)
        for j in range(0, len(cs), 2):
            chunk = cs[j : j + 2]
            parts = [f"{float(c[0]):.16e} {float(c[1]):.16e}" for c in chunk]
            center_lines.append("  ".join(parts))
        sections.append("\n".join(center_lines))

    sections.append("END")
    text = "\n\n".join(sections) + "\n"
    with open(path, "w") as f:
        f.write(text)

load_circlepack

load_circlepack(path: str) -> EuclideanPositionHEG

Load a CirclePack .p file into an :class:EuclideanPositionHEG.

Populates v['pos'] and v['radius'] on every vertex when the file provides RADII (and CENTERS, where applicable). For hyperbolic packings the file's RADII are interpreted as x-radii; the returned graph stores the corresponding euclidean (Poincaré-disk) center and radius, matching :func:pleat.circle_packing.pack_hyperbolic's output convention. If a hyperbolic file omits CENTERS, the layout is computed via :func:pleat.circle_packing._layout_hyperbolic.

The returned graph's geometry is set to :class:EuclideanGeometry or :class:PoincareDiskModel based on the file's GEOMETRY line; unknown or missing values default to :class:EuclideanGeometry.

Source code in pleat/io.py
def load_circlepack(path: str) -> EuclideanPositionHEG:
    """Load a CirclePack `.p` file into an :class:`EuclideanPositionHEG`.

    Populates ``v['pos']`` and ``v['radius']`` on every vertex when the file
    provides RADII (and CENTERS, where applicable). For hyperbolic packings
    the file's RADII are interpreted as x-radii; the returned graph stores
    the corresponding euclidean (Poincaré-disk) center and radius, matching
    :func:`pleat.circle_packing.pack_hyperbolic`'s output convention. If a
    hyperbolic file omits CENTERS, the layout is computed via
    :func:`pleat.circle_packing._layout_hyperbolic`.

    The returned graph's ``geometry`` is set to :class:`EuclideanGeometry` or
    :class:`PoincareDiskModel` based on the file's GEOMETRY line; unknown or
    missing values default to :class:`EuclideanGeometry`.
    """
    data = parse_p_file(path)
    G, idx_to_v = _build_heg_from_data(data)

    is_hyperbolic = data.geometry == "hyperbolic"
    G.geometry = PoincareDiskModel if is_hyperbolic else EuclideanGeometry

    if is_hyperbolic:
        if data.radii is None:
            return G  # nothing to populate
        if data.centers is not None:
            for i in range(data.nodecount):
                c = complex(float(data.centers[i, 0]), float(data.centers[i, 1]))
                x = float(data.radii[i])
                idx_to_v[i]["pos"] = c
                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

            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)
            beta = idx_to_v[data.beta - 1] if data.beta is not None else _choose_beta(alpha)
            centers, eucl_radii = _layout_hyperbolic(G, x_radii, alpha, beta)
            for v in G.vertices:
                v["pos"] = centers[v]
                v["radius"] = eucl_radii[v]
    else:
        if data.centers is not None:
            for i in range(data.nodecount):
                idx_to_v[i]["pos"] = np.array([float(data.centers[i, 0]), float(data.centers[i, 1])])
        if data.radii is not None:
            for i in range(data.nodecount):
                idx_to_v[i]["radius"] = float(data.radii[i])

    return G

save_circlepack

save_circlepack(
    path: str,
    G: EuclideanPositionHEG,
    *,
    overwrite: bool = False
) -> None

Save an :class:EuclideanPositionHEG to a CirclePack .p file.

Emits FLOWERS for the triangulation, plus RADII / CENTERS if every vertex has radius / pos attributes, plus GEOMETRY based on G.geometry. Hyperbolic packings (G.geometry is PoincareDiskModel) are emitted with x-radii in the RADII section, matching CirclePack's convention.

Source code in pleat/io.py
def save_circlepack(path: str, G: EuclideanPositionHEG, *, overwrite: bool = False) -> None:
    """Save an :class:`EuclideanPositionHEG` to a CirclePack `.p` file.

    Emits FLOWERS for the triangulation, plus RADII / CENTERS if every vertex
    has ``radius`` / ``pos`` attributes, plus GEOMETRY based on ``G.geometry``.
    Hyperbolic packings (``G.geometry is PoincareDiskModel``) are emitted with
    x-radii in the RADII section, matching CirclePack's convention.
    """
    data = _graph_to_circlepack_data(G)
    write_p_file(path, data, overwrite=overwrite)