Skip to content

circlepack

pleat.io.circlepack

File I/O for 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.

parse_p_file

parse_p_file(path: str) -> CirclePackData

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

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