Skip to content

Index

pleat.intersecting_cylinders

Intersecting-cylinders curved origami crease patterns.

This subpackage builds curved-fold crease patterns from a tiling whose face incircles are mutually tangential. The pattern places a small twist at every face incenter and curved triangles whose flat sides connect adjacent incenters; each curved triangle's apex meets at an original tiling vertex, where (with r = 1) a spike is formed. With 0 < r < 1 a flat polygon (dual to the original vertex) appears instead of the spike, and the curved triangles become curved quadrilaterals reaching down to it.

The high-level entry point is :func:make_intersecting_cylinders; cross-sections are described by :class:Profile (see :func:circular_profile); the flat top-view projection is computed by :func:top_view; an interactive 3D preview is provided by :func:show_3d.

Profile dataclass

Profile(
    t: NDArray[float64],
    l: NDArray[float64],
    y: NDArray[float64],
    shrink_factor: float,
)

Arc-length-parametrised cross-section curve.

The cross-section is the curve (x, fn(x)) for x in [0, 1], where fn(0) = 0 and fn is non-negative. Samples are simplified by the Ramer-Douglas-Peucker algorithm and stored after rescaling so that the total arc length is 1 (and the perpendicular extent is therefore shrink_factor = 1 / unscaled_arc_length <= 1).

Attributes:

Name Type Description
t NDArray[float64]

Perpendicular coordinate, scaled to [0, shrink_factor]. In the 2D pipeline, t = 0 is the vertex-side end of the curved crease and t = shrink_factor is the face-side end (toward the incenter).

l NDArray[float64]

Arc length along the curve, scaled to [0, 1]. Used by the 2D pipeline as the along-edge coordinate of the curved crease.

y NDArray[float64]

Height fn(x), scaled by shrink_factor so it ranges in [0, fn_max * shrink_factor]. Drives the 3D spike depth (after re-orientation by mesh3d._spike_depth_from_profile).

shrink_factor float

1 / total_unscaled_arc_length. Equals the perpendicular extent of the folded fundamental strip relative to the original tile edge length.

from_function classmethod

from_function(
    fn: Callable[[NDArray[float64]], NDArray[float64]],
    n_samples: int = 1000,
    rdp_tol: float = 0.0001,
) -> "Profile"

Build a :class:Profile from a height function fn on [0, 1].

Parameters:

Name Type Description Default
fn Callable[[NDArray[float64]], NDArray[float64]]

Height function with fn(0) == 0. Evaluated on np.linspace(0, 1, n_samples).

required
n_samples int

Number of samples used before RDP simplification.

1000
rdp_tol float

Ramer-Douglas-Peucker tolerance used to simplify the (t, l) polyline.

0.0001

Returns:

Name Type Description
A 'Profile'

class:Profile with the curve normalised so total arc length is 1.

Source code in pleat/intersecting_cylinders/profiles.py
@classmethod
def from_function(
    cls,
    fn: Callable[[NDArray[np.float64]], NDArray[np.float64]],
    n_samples: int = 1000,
    rdp_tol: float = 1e-4,
) -> "Profile":
    """Build a :class:`Profile` from a height function ``fn`` on ``[0, 1]``.

    Args:
        fn: Height function with ``fn(0) == 0``. Evaluated on
            ``np.linspace(0, 1, n_samples)``.
        n_samples: Number of samples used before RDP simplification.
        rdp_tol: Ramer-Douglas-Peucker tolerance used to simplify the
            ``(t, l)`` polyline.

    Returns:
        A :class:`Profile` with the curve normalised so total arc length is 1.
    """
    t_dense = np.linspace(0.0, 1.0, n_samples)
    y_dense = np.asarray(fn(t_dense), dtype=float)

    dy = np.diff(y_dense)
    dt = np.diff(t_dense)
    l_dense = np.concatenate([[0.0], np.cumsum(np.sqrt(dy * dy + dt * dt))])

    # Simplify the (t, l) polyline; pad with a zero column to silence the
    # numpy 2.0 deprecation warning about 2D vectors. ``return_mask`` lets us
    # subset the parallel ``y`` array at the same surviving indices.
    from rdp import rdp  # optional dependency (intersecting_cylinders extra)

    mask = rdp(
        np.stack([t_dense, l_dense, np.zeros_like(t_dense)], axis=-1),
        rdp_tol,
        return_mask=True,
    )
    t = t_dense[mask]
    l = l_dense[mask]
    y = y_dense[mask]

    total_length = float(l[-1])
    if total_length <= 0.0:
        raise ValueError("profile must have positive total arc length")
    shrink_factor = 1.0 / total_length
    return cls(
        t=t * shrink_factor,
        l=l * shrink_factor,
        y=y * shrink_factor,
        shrink_factor=shrink_factor,
    )

plot

plot(ax=None) -> None

Plot the simplified (t, y) polyline.

Source code in pleat/intersecting_cylinders/profiles.py
def plot(self, ax=None) -> None:
    """Plot the simplified ``(t, y)`` polyline."""
    import matplotlib.pyplot as plt

    if ax is None:
        ax = plt.gca()
    ax.plot(self.t, self.y)
    ax.set_aspect(1)

show_3d

show_3d(
    G: "EuclideanPositionHEG",
    profile: Profile,
    r: float = 1.0,
    n_across_edge: int = 8,
    max_profile_samples: int = 30,
    color: str = "lightblue",
    opacity: float = 1.0,
    height: int = 600,
    edge_color: str = "black",
    edge_width: float = 2.0,
    show_edges: bool = True,
) -> "Any"

Return an interactive plotly 3D figure of the folded model.

Requires :mod:plotly (pip install plotly or pip install -e ".[intersecting_cylinders]").

Parameters:

Name Type Description Default
G 'EuclideanPositionHEG'

Input tiling.

required
profile Profile

Cross-section curve.

required
r float

Triangle scaling matching :func:make_intersecting_cylinders.

1.0
n_across_edge int

Mesh resolution across each edge (linear direction).

8
max_profile_samples int

Cap on the number of sample points along the curved (spike-depth) direction; see :func:to_3d_mesh.

30
color str

Surface colour.

'lightblue'
opacity float

Surface opacity in [0, 1].

1.0
height int

Figure height in pixels.

600
edge_color str

Colour of the sharp-fold polylines.

'black'
edge_width float

Line width of the sharp-fold polylines.

2.0
show_edges bool

When True (default), draw the sharp-fold lines where adjacent curved strips meet (and the flat-tip boundaries for r < 1).

True

Returns:

Type Description
'Any'

Plotly Figure ready for fig.show() or rendering in a notebook.

Source code in pleat/intersecting_cylinders/mesh3d.py
def show_3d(
    G: "EuclideanPositionHEG",
    profile: Profile,
    r: float = 1.0,
    n_across_edge: int = 8,
    max_profile_samples: int = 30,
    color: str = "lightblue",
    opacity: float = 1.0,
    height: int = 600,
    edge_color: str = "black",
    edge_width: float = 2.0,
    show_edges: bool = True,
) -> "Any":
    """Return an interactive plotly 3D figure of the folded model.

    Requires :mod:`plotly` (``pip install plotly`` or
    ``pip install -e ".[intersecting_cylinders]"``).

    Args:
        G: Input tiling.
        profile: Cross-section curve.
        r: Triangle scaling matching :func:`make_intersecting_cylinders`.
        n_across_edge: Mesh resolution across each edge (linear direction).
        max_profile_samples: Cap on the number of sample points along the
            curved (spike-depth) direction; see :func:`to_3d_mesh`.
        color: Surface colour.
        opacity: Surface opacity in ``[0, 1]``.
        height: Figure height in pixels.
        edge_color: Colour of the sharp-fold polylines.
        edge_width: Line width of the sharp-fold polylines.
        show_edges: When ``True`` (default), draw the sharp-fold lines where
            adjacent curved strips meet (and the flat-tip boundaries for
            ``r < 1``).

    Returns:
        Plotly ``Figure`` ready for ``fig.show()`` or rendering in a notebook.
    """
    try:
        import plotly.graph_objects as go
    except ImportError as exc:
        raise ImportError("show_3d requires plotly; install with `pip install plotly`.") from exc

    vertices, triangles = to_3d_mesh(
        G,
        profile,
        r=r,
        n_across_edge=n_across_edge,
        max_profile_samples=max_profile_samples,
    )

    mesh = go.Mesh3d(
        x=vertices[:, 0],
        y=vertices[:, 1],
        z=vertices[:, 2],
        i=triangles[:, 0],
        j=triangles[:, 1],
        k=triangles[:, 2],
        color=color,
        opacity=opacity,
        flatshading=False,
        lighting=dict(
            ambient=0.4,
            diffuse=0.6,
            specular=0.3,
            roughness=0.3,
        ),
        lightposition=dict(x=100, y=100, z=300),
    )

    data: list[Any] = [mesh]

    if show_edges:
        # Concatenate all polylines into a single Scatter3d trace, separating
        # individual curves with NaNs (plotly's standard polyline-break trick).
        curves = _fold_curves(G, profile, r=r, max_profile_samples=max_profile_samples)
        if curves:
            xs: list[float] = []
            ys: list[float] = []
            zs: list[float] = []
            for curve in curves:
                xs.extend(curve[:, 0].tolist())
                ys.extend(curve[:, 1].tolist())
                zs.extend(curve[:, 2].tolist())
                xs.append(np.nan)
                ys.append(np.nan)
                zs.append(np.nan)
            data.append(
                go.Scatter3d(
                    x=xs,
                    y=ys,
                    z=zs,
                    mode="lines",
                    line=dict(color=edge_color, width=edge_width),
                    showlegend=False,
                    hoverinfo="skip",
                )
            )

    fig = go.Figure(data=data)
    fig.update_layout(
        scene=dict(
            aspectmode="data",
            xaxis=dict(visible=False),
            yaxis=dict(visible=False),
            zaxis=dict(visible=False),
        ),
        margin=dict(l=0, r=0, t=0, b=0),
        height=height,
    )
    return fig

to_3d_mesh

to_3d_mesh(
    G: "EuclideanPositionHEG",
    profile: Profile,
    r: float = 1.0,
    n_across_edge: int = 8,
    max_profile_samples: int = 30,
) -> tuple[NDArray[np.float64], NDArray[np.int64]]

Build a triangle mesh of the folded intersecting-cylinders surface.

See the module docstring for the geometric construction. Each ortho quad (v, t1, c, t2) is split along its v-c diagonal into two half-triangles (c, v, t); each half-triangle is decomposed into a curved trapezoid {c, c_near_v, t_near_v, t} filling the outer part plus (for r < 1) a flat tip triangle {v, c_near_v, t_near_v} at the vertex. The curved trapezoid is lifted using the profile's cross-section.

Parameters:

Name Type Description Default
G 'EuclideanPositionHEG'

Input tiling whose faces have well-defined (pseudo-)incenters. A working copy is taken; the input is not modified.

required
profile Profile

Cross-section curve.

required
r float

Triangle scaling matching :func:make_intersecting_cylinders.

1.0
n_across_edge int

Number of uniform subdivisions across each half-edge (the c-t direction of the curved trapezoid). The trapezoid is linear in this direction.

8
max_profile_samples int

Cap on the number of sample points used along the curved (spike-depth) direction. The profile's own RDP-simplified samples are used (so the resolution is highest where the curve is steepest); when there are more than this many, they are uniformly subsampled in index space, always keeping the first and last.

30

Returns:

Type Description
tuple[NDArray[float64], NDArray[int64]]

(vertices, triangles) with shapes (N, 3) and (M, 3).

Source code in pleat/intersecting_cylinders/mesh3d.py
def to_3d_mesh(
    G: "EuclideanPositionHEG",
    profile: Profile,
    r: float = 1.0,
    n_across_edge: int = 8,
    max_profile_samples: int = 30,
) -> tuple[NDArray[np.float64], NDArray[np.int64]]:
    """Build a triangle mesh of the folded intersecting-cylinders surface.

    See the module docstring for the geometric construction. Each ortho quad
    ``(v, t1, c, t2)`` is split along its ``v-c`` diagonal into two
    half-triangles ``(c, v, t)``; each half-triangle is decomposed into a
    curved trapezoid ``{c, c_near_v, t_near_v, t}`` filling the outer part
    plus (for ``r < 1``) a flat tip triangle ``{v, c_near_v, t_near_v}`` at
    the vertex. The curved trapezoid is lifted using the profile's
    cross-section.

    Args:
        G: Input tiling whose faces have well-defined (pseudo-)incenters.
            A working copy is taken; the input is not modified.
        profile: Cross-section curve.
        r: Triangle scaling matching :func:`make_intersecting_cylinders`.
        n_across_edge: Number of uniform subdivisions across each half-edge
            (the ``c-t`` direction of the curved trapezoid). The trapezoid is
            linear in this direction.
        max_profile_samples: Cap on the number of sample points used along
            the curved (spike-depth) direction. The profile's own
            RDP-simplified samples are used (so the resolution is highest
            where the curve is steepest); when there are more than this many,
            they are uniformly subsampled in index space, always keeping the
            first and last.

    Returns:
        ``(vertices, triangles)`` with shapes ``(N, 3)`` and ``(M, 3)``.
    """
    if not 0.0 < r <= 1.0:
        raise ValueError("r must lie in (0, 1]")

    G_ortho = _build_ortho_with_tangent_points(G)
    r_v_per_vertex = _vertex_circle_radii(G_ortho)

    spike_bary, spike_depth, scale = _spike_depth_from_profile(profile)

    if r == 1.0:
        apex_inset = 0.0
    else:
        sf = profile.shrink_factor
        apex_inset = (1.0 - r) * sf / (1.0 - (1.0 - r) * (1.0 - sf))
    curved_extent = 1.0 - apex_inset  # fraction of v-c filled by curved patch

    u_samples, depth_samples = _curved_patch_samples(spike_bary, spike_depth, max_profile_samples)
    n_u = u_samples.size  # rows along the spike-depth direction
    n_w = max(2, int(n_across_edge)) + 1  # columns across the c-t direction

    vertices: list[NDArray[np.float64]] = []
    triangles: list[tuple[int, int, int]] = []

    def _add_curved_trapezoid(
        c_pt: NDArray[np.float64],
        c_near_v: NDArray[np.float64],
        t_near_v: NDArray[np.float64],
        t_pt: NDArray[np.float64],
        h_v: float,
    ) -> None:
        """Mesh the curved trapezoid ``{c, c_near_v, t_near_v, t}``.

        ``u`` runs ``0 -> 1`` from the ``c-t`` base (z=0) to the
        ``c_near_v-t_near_v`` top (z=-h_v, the flat-tip depth); ``w`` runs
        ``0 -> 1`` from the c-side to the t-side. The full profile shape is
        rescaled onto the patch's perpendicular extent so the cylinder keeps
        its proportions (the curved patch always lifts to its full natural
        depth ``h_v`` at the apex side; only its perpendicular extent shrinks
        as ``apex_inset`` grows).

        The two adjacent half-triangles in an ortho-quad have opposite 2D
        orientations around ``(c, v, t)``; we detect this here (using the
        cross product of (c -> t) and (c -> c_near_v)) and swap the c/t pair
        to keep every triangle's normal pointing up.
        """
        cross = (c_near_v[0] - c_pt[0]) * (t_pt[1] - c_pt[1]) - (c_near_v[1] - c_pt[1]) * (t_pt[0] - c_pt[0])
        if cross < 0.0:
            c_pt, t_pt = t_pt, c_pt
            c_near_v, t_near_v = t_near_v, c_near_v

        base_offset = len(vertices)
        for iu in range(n_u):
            u = float(u_samples[iu])
            z = -h_v * float(depth_samples[iu]) if scale > 0 else 0.0
            base_u = (1.0 - u) * c_pt + u * c_near_v
            top_u = (1.0 - u) * t_pt + u * t_near_v
            for iw in range(n_w):
                w = iw / (n_w - 1)
                pos = (1.0 - w) * base_u + w * top_u
                vertices.append(np.array([pos[0], pos[1], z]))

        for iu in range(n_u - 1):
            for iw in range(n_w - 1):
                a = base_offset + iu * n_w + iw
                b = base_offset + (iu + 1) * n_w + iw
                c_i = base_offset + iu * n_w + (iw + 1)
                d = base_offset + (iu + 1) * n_w + (iw + 1)
                triangles.append((a, b, c_i))
                triangles.append((b, d, c_i))

    def _add_flat_tip_triangle(
        v_pt: NDArray[np.float64],
        c_near_v: NDArray[np.float64],
        t_near_v: NDArray[np.float64],
        z: float,
    ) -> None:
        """Add the flat tip triangle ``{v, c_near_v, t_near_v}`` at ``z``.

        Detect CCW orientation in 2D and swap so the normal points up.
        """
        cross = (c_near_v[0] - v_pt[0]) * (t_near_v[1] - v_pt[1]) - (c_near_v[1] - v_pt[1]) * (t_near_v[0] - v_pt[0])
        a, b, c_i = v_pt, c_near_v, t_near_v
        if cross < 0.0:
            b, c_i = c_i, b
        idx0 = len(vertices)
        for p in (a, b, c_i):
            vertices.append(np.array([p[0], p[1], z]))
        triangles.append((idx0, idx0 + 1, idx0 + 2))

    for face in G_ortho.faces:
        classified = _classify_ortho_quad(face)
        if classified is None:
            continue
        v_corner, c_corner, t1_corner, t2_corner = classified

        v_orig = v_corner["pre_conway"]
        r_v = r_v_per_vertex.get(v_orig, 0.0)
        if r_v == 0.0:
            continue

        c_pos = np.asarray(c_corner["pos"], dtype=float)
        v_pos = np.asarray(v_corner["pos"], dtype=float)
        # The curved patch always reaches its full natural depth at the apex
        # side; the patch's perpendicular extent shrinks with curved_extent
        # (the c-to-c_near_v segment), and the depth scales proportionally
        # so the cylinder cross-section stays self-similar.
        h_v = r_v * scale * curved_extent
        z_tip = -h_v if scale > 0 else 0.0

        c_near_v = v_pos + apex_inset * (c_pos - v_pos)

        for t_corner in (t1_corner, t2_corner):
            t_pos = np.asarray(t_corner["pos"], dtype=float)
            t_near_v = v_pos + apex_inset * (t_pos - v_pos)

            _add_curved_trapezoid(c_pos, c_near_v, t_near_v, t_pos, h_v)

            if apex_inset > 0.0:
                _add_flat_tip_triangle(v_pos, c_near_v, t_near_v, z_tip)

    return np.asarray(vertices, dtype=float), np.asarray(triangles, dtype=np.int64)

make_intersecting_cylinders

make_intersecting_cylinders(
    G: "EuclideanPositionHEG",
    profile: Profile,
    r: float = 1.0,
) -> "EuclideanPositionHEG"

Construct an intersecting-cylinders crease pattern from a tiling.

Every face of G must have an incenter, and the incircles of adjacent faces should be tangential to each other (touching at the shared edge) for the resulting crease pattern to be foldable.

The pattern places a small triangle twist at every face incenter and curved triangles between adjacent twists: each curved triangle's flat side connects the incenters of two adjacent faces, and its apex meets at the original tiling vertex shared by those two edges. With r = 1 the curved triangles meet at the original vertices to form spikes; with 0 < r < 1 a downscaled copy of every face is preserved and a flat polygon (dual to the original vertex) replaces the spike, turning the curved triangles into curved quadrilaterals.

Parameters:

Name Type Description Default
G 'EuclideanPositionHEG'

Input tiling. Mutated in place to record midpoints, then a working copy is taken before applying Conway operators.

required
profile Profile

Cross-section curve. See :func:~pleat.intersecting_cylinders.profiles.circular_profile.

required
r float

Triangle scaling. r = 1 produces curved triangles meeting at original vertices (spikes); 0 < r < 1 keeps a downscaled copy of the original face and produces curved quadrilaterals instead.

1.0

Returns:

Type Description
'EuclideanPositionHEG'

A new EuclideanPositionHEG whose half-edges carry color_key

'EuclideanPositionHEG'

attributes (red curved creases, blue/black straight creases) and

'EuclideanPositionHEG'

curve_pos arrays describing the curved-fold polylines.

Source code in pleat/intersecting_cylinders/pipeline.py
def make_intersecting_cylinders(
    G: "EuclideanPositionHEG",
    profile: Profile,
    r: float = 1.0,
) -> "EuclideanPositionHEG":
    """Construct an intersecting-cylinders crease pattern from a tiling.

    Every face of ``G`` must have an incenter, and the incircles of adjacent
    faces should be tangential to each other (touching at the shared edge) for
    the resulting crease pattern to be foldable.

    The pattern places a small triangle twist at every face incenter and curved
    triangles between adjacent twists: each curved triangle's flat side
    connects the incenters of two adjacent faces, and its apex meets at the
    original tiling vertex shared by those two edges. With ``r = 1`` the curved
    triangles meet at the original vertices to form *spikes*; with ``0 < r < 1``
    a downscaled copy of every face is preserved and a flat polygon (dual to
    the original vertex) replaces the spike, turning the curved triangles into
    curved quadrilaterals.

    Args:
        G: Input tiling. Mutated in place to record midpoints, then a working
            copy is taken before applying Conway operators.
        profile: Cross-section curve. See
            :func:`~pleat.intersecting_cylinders.profiles.circular_profile`.
        r: Triangle scaling. ``r = 1`` produces curved triangles meeting at
            original vertices (spikes); ``0 < r < 1`` keeps a downscaled copy
            of the original face and produces curved quadrilaterals instead.

    Returns:
        A new ``EuclideanPositionHEG`` whose half-edges carry ``color_key``
        attributes (red curved creases, blue/black straight creases) and
        ``curve_pos`` arrays describing the curved-fold polylines.
    """
    if not 0.0 < r <= 1.0:
        raise ValueError("r must lie in (0, 1]")

    t_arr = profile.t
    l_arr = profile.l
    shrink_factor = profile.shrink_factor

    for v in G.vertices:
        if "pre_conway" in v:
            del v["pre_conway"]

    for f in list(G.faces):
        f["midpoint"] = f.pseudo_incenter()

    if r != 1:
        expand_t = (1 - r) * shrink_factor / (1 - (1 - r) * (1 - shrink_factor))
        G = conway.expand_graph(expand_t)(G, delete_on_border=False)
        fs = [f for f in G.faces if "pre_conway" in f]
        for f in fs:
            f["midpoint"] = f.pseudo_incenter()
    else:
        fs = list(G.faces)

    G, (v_map_f, h_map_f, f_map_f) = G.copy(return_mappings=True)

    v_map = _reverse_mapping(v_map_f)
    h_map = _reverse_mapping(h_map_f)
    # f_map = _reverse_mapping(f_map_f)  # reserved for future use
    del v_map  # currently unused, kept for symmetry / future extensions

    vertex_pairs_to_halfedges = {(h.orig, h.dest): h_map[h] for h in G.halfedges}

    G = conway.lace_graph(0.5, join=True)(
        G,
        faces=[f_map_f[f] for f in fs],
        delete_inner_border=True,
        delete_on_border=False,
        inplace=True,
    )
    G.delete_subset([f for f in G.faces if f.any_side not in G.halfedges])
    G.check_consistency()

    vs = [v for v in G.vertices if _from_pre(v) and isinstance(v["pre_conway"], half.Vertex)]

    for v in vs:
        for h in v.outgoing_iter():
            if h.dest in vs:
                h["color_key"] = h.rev["color_key"] = _BLACK
                continue
            elif h.nex.dest in vs:
                v2 = h.nex.dest
            else:
                h["color_key"] = h.rev["color_key"] = _BLUE
                continue
            try:
                h_orig = vertex_pairs_to_halfedges[(v2["pre_conway"], v["pre_conway"])]
            except KeyError:
                continue

            p = v["pos"]
            p2 = v2["pos"]

            c = h_orig.face.midpoint()
            hc = base.project_to_line(np.stack([p, p2]), c)

            alt = c - hc
            side = hc - p

            curve = (side[:, None] * l_arr + alt[:, None] * t_arr).T + p
            extra_point = base.line_intersection(
                np.stack([p2, c]),
                np.stack([curve[-1], curve[-1] + c - p]),
            )
            curve = np.concatenate([curve, extra_point[None]])

            h.dest["pos"] = (h.dest["pos"] + c) / 2
            h["curve_pos"] = curve
            h.rev["curve_pos"] = curve[::-1]
            h.dest["pos"] = curve[-1]
            h["color_key"] = h.rev["color_key"] = _RED

    for h in G.halfedges:
        if "color_key" not in h:
            h["color_key"] = _BLUE

    return G

top_view

top_view(
    G: "EuclideanPositionHEG", r: float = 1.0
) -> "EuclideanPositionHEG"

Return the flat top-view projection of an intersecting-cylinders model.

The projection of every curved triangle (r = 1) or curved quadrilateral (r < 1) onto the base plane is a flat polygon. For r = 1 these are triangles spanning two adjacent face incenters and the original vertex they share — i.e. the Conway join operator (which inserts a vertex at every face incenter and joins it to the incident original vertices). For r < 1 they are trapezoids reaching from each pair of adjacent face incenters down to the corresponding edge of the down-scaled inner face (the Conway chamfer operator with t = r).

Parameters:

Name Type Description Default
G 'EuclideanPositionHEG'

Input tiling whose faces have well-defined (pseudo-)incenters. A working copy is taken; the input is not modified.

required
r float

Triangle scaling matching the value passed to :func:make_intersecting_cylinders.

1.0

Returns:

Type Description
'EuclideanPositionHEG'

A new EuclideanPositionHEG representing the top-view tessellation.

Source code in pleat/intersecting_cylinders/pipeline.py
def top_view(G: "EuclideanPositionHEG", r: float = 1.0) -> "EuclideanPositionHEG":
    """Return the flat top-view projection of an intersecting-cylinders model.

    The projection of every curved triangle (``r = 1``) or curved quadrilateral
    (``r < 1``) onto the base plane is a flat polygon. For ``r = 1`` these are
    triangles spanning two adjacent face incenters and the original vertex they
    share — i.e. the Conway *join* operator (which inserts a vertex at every
    face incenter and joins it to the incident original vertices). For
    ``r < 1`` they are trapezoids reaching from each pair of adjacent face
    incenters down to the corresponding edge of the down-scaled inner face
    (the Conway *chamfer* operator with ``t = r``).

    Args:
        G: Input tiling whose faces have well-defined (pseudo-)incenters.
            A working copy is taken; the input is not modified.
        r: Triangle scaling matching the value passed to
            :func:`make_intersecting_cylinders`.

    Returns:
        A new ``EuclideanPositionHEG`` representing the top-view tessellation.
    """
    if not 0.0 < r <= 1.0:
        raise ValueError("r must lie in (0, 1]")

    G = G.copy()
    for f in G.faces:
        f["midpoint"] = f.pseudo_incenter()

    if r == 1.0:
        return conway.join_graph()(G, delete_on_border=False)

    # FIXME: this is not the correct top view. Revisit in detail how the crease pattern is generated and accordingly how the top view should be constructed. For now, this is a placeholder which is somewhat similar, at least in the central part of the pattern.
    G = conway.dual_graph()(G, delete_on_border=False)
    return conway.chamfer_graph(r)(G, delete_on_border=False)

circular_profile

circular_profile(
    scale: float = 1.3,
    n_samples: int = 1000,
    rdp_tol: float = 0.0001,
) -> Profile

Quarter-ellipse cross-section y = scale * sqrt(1 - (1 - x)**2).

With scale = 1 this is the canonical quarter-circle and produces intersecting half-cylinders for the platonic 4 and 3 tilings. Larger values of scale make the bumps taller and steeper.

Parameters:

Name Type Description Default
scale float

Height multiplier on the quarter-circle.

1.3
n_samples int

Forwarded to :meth:Profile.from_function.

1000
rdp_tol float

Forwarded to :meth:Profile.from_function.

0.0001
Source code in pleat/intersecting_cylinders/profiles.py
def circular_profile(scale: float = 1.3, n_samples: int = 1000, rdp_tol: float = 1e-4) -> Profile:
    """Quarter-ellipse cross-section ``y = scale * sqrt(1 - (1 - x)**2)``.

    With ``scale = 1`` this is the canonical quarter-circle and produces
    intersecting half-cylinders for the platonic 4 and 3 tilings. Larger
    values of ``scale`` make the bumps taller and steeper.

    Args:
        scale: Height multiplier on the quarter-circle.
        n_samples: Forwarded to :meth:`Profile.from_function`.
        rdp_tol: Forwarded to :meth:`Profile.from_function`.
    """
    return Profile.from_function(
        lambda x: scale * np.sqrt(np.clip(1.0 - (1.0 - x) ** 2, 0.0, 1.0)),
        n_samples=n_samples,
        rdp_tol=rdp_tol,
    )

parabolic_profile

parabolic_profile(
    scale: float = 1.3,
    n_samples: int = 1000,
    rdp_tol: float = 0.0001,
) -> Profile

Parabolic cross-section y = scale * (1 - (1 - x)**2).

This is a smoother curve than the circular profile, with zero slope at the start and a more gradual approach to the maximum height. The resulting crease pattern is less spiky and may be easier to fold.

Parameters:

Name Type Description Default
scale float

Height multiplier on the parabola.

1.3
n_samples int

Forwarded to :meth:Profile.from_function.

1000
rdp_tol float

Forwarded to :meth:Profile.from_function.

0.0001
Source code in pleat/intersecting_cylinders/profiles.py
def parabolic_profile(scale: float = 1.3, n_samples: int = 1000, rdp_tol: float = 1e-4) -> Profile:
    """Parabolic cross-section ``y = scale * (1 - (1 - x)**2)``.

    This is a smoother curve than the circular profile, with zero slope at the
    start and a more gradual approach to the maximum height. The resulting
    crease pattern is less spiky and may be easier to fold.

    Args:
        scale: Height multiplier on the parabola.
        n_samples: Forwarded to :meth:`Profile.from_function`.
        rdp_tol: Forwarded to :meth:`Profile.from_function`.
    """
    return Profile.from_function(
        lambda x: scale * (1.0 - (1.0 - x) ** 2),
        n_samples=n_samples,
        rdp_tol=rdp_tol,
    )

build_dual_circle_packings

build_dual_circle_packings(
    G: "EuclideanPositionHEG",
) -> "EuclideanPositionHEG"

Return a styled ortho-graph representing the two dual circle packings.

The returned graph is a fresh copy: the input G is not modified. Every vertex carries a color_key and vertex_radius attribute; circles coming from original faces use red fill, circles coming from original vertices use blue fill, and the tangent-point vertices are drawn as small black dots. Halfedges are coloured black and have line_width set so that only the vertex circles are outlined.

Parameters:

Name Type Description Default
G 'EuclideanPositionHEG'

A 2D Euclidean tiling whose faces have well-defined incenters such that the corresponding incircles are mutually tangential at every edge.

required

Returns:

Type Description
'EuclideanPositionHEG'

A copy of the ortho-Conway graph of G with cosmetic attributes

'EuclideanPositionHEG'

set, ready to pass to :meth:HalfEdgeGraph.show.

Source code in pleat/intersecting_cylinders/show_circle_packings.py
def build_dual_circle_packings(G: "EuclideanPositionHEG") -> "EuclideanPositionHEG":
    """Return a styled ortho-graph representing the two dual circle packings.

    The returned graph is a fresh copy: the input ``G`` is not modified. Every
    vertex carries a ``color_key`` and ``vertex_radius`` attribute; circles
    coming from original faces use red fill, circles coming from original
    vertices use blue fill, and the tangent-point vertices are drawn as small
    black dots. Halfedges are coloured black and have ``line_width`` set so
    that only the vertex circles are outlined.

    Args:
        G: A 2D Euclidean tiling whose faces have well-defined incenters such
            that the corresponding incircles are mutually tangential at every
            edge.

    Returns:
        A copy of the ortho-Conway graph of ``G`` with cosmetic attributes
        set, ready to pass to :meth:`HalfEdgeGraph.show`.
    """
    G_ortho = _build_ortho_with_tangent_points(G)
    G_ortho.normalize_positions()

    for v in G_ortho.vertices.union(G_ortho.faces):
        pre = v.get("pre_conway")
        if isinstance(pre, half.Face):
            v["color_key"] = _FACE_COLOR
            v["vertex_radius"] = float(np.linalg.norm(v["pos"] - v.any_outgoing.dest["pos"]))
            for h in v.outgoing_iter():
                h["line_width"] = 0.0
                h.rev["line_width"] = 0.0
        elif isinstance(pre, half.Vertex):
            v["color_key"] = _VERTEX_COLOR
            v["vertex_radius"] = float(np.linalg.norm(v["pos"] - v.any_outgoing.dest["pos"]))
            for h in v.outgoing_iter():
                h["line_width"] = _VERTEX_CIRCLE_LINE_WIDTH
                h.rev["line_width"] = _VERTEX_CIRCLE_LINE_WIDTH
        else:
            v["vertex_radius"] = _TANGENT_RADIUS
            v["color_key"] = _TANGENT_COLOR

    for h in G_ortho.halfedges:
        h["color_key"] = _TANGENT_COLOR

    return G_ortho

show_dual_circle_packings

show_dual_circle_packings(
    G: "EuclideanPositionHEG", **show_kwargs: Any
) -> None

Render the two dual circle packings of a tiling.

This is a thin convenience wrapper around :func:build_dual_circle_packings that calls :meth:HalfEdgeGraph.show on the resulting styled graph with render_faces=False.

Parameters:

Name Type Description Default
G 'EuclideanPositionHEG'

A 2D Euclidean tiling -- see :func:build_dual_circle_packings for the requirements on G.

required
**show_kwargs Any

Forwarded to :meth:HalfEdgeGraph.show. render_faces defaults to False but can be overridden.

{}
Source code in pleat/intersecting_cylinders/show_circle_packings.py
def show_dual_circle_packings(G: "EuclideanPositionHEG", **show_kwargs: Any) -> None:
    """Render the two dual circle packings of a tiling.

    This is a thin convenience wrapper around :func:`build_dual_circle_packings`
    that calls :meth:`HalfEdgeGraph.show` on the resulting styled graph with
    ``render_faces=False``.

    Args:
        G: A 2D Euclidean tiling -- see :func:`build_dual_circle_packings` for
            the requirements on ``G``.
        **show_kwargs: Forwarded to :meth:`HalfEdgeGraph.show`. ``render_faces``
            defaults to ``False`` but can be overridden.
    """
    show_kwargs.setdefault("render_faces", False)
    G_ortho = build_dual_circle_packings(G)
    G_ortho.show(**show_kwargs)

convert_all_to_triangle_twists

convert_all_to_triangle_twists(
    G: "EuclideanPositionHEG",
) -> None

Replace every interior degree-6 vertex with a flat-foldable triangle twist.

All other vertices in intersecting-cylinder crease patterns are of smaller degree, so this targets exactly the curved hubs.

Source code in pleat/intersecting_cylinders/triangle_twist.py
def convert_all_to_triangle_twists(G: "EuclideanPositionHEG") -> None:
    """Replace every interior degree-6 vertex with a flat-foldable triangle twist.

    All other vertices in intersecting-cylinder crease patterns are of smaller
    degree, so this targets exactly the curved hubs.
    """
    vs = [v for v in G.vertices if v.order() == 6 and not v.on_border()]
    for v in vs:
        convert_to_triangle_twist(G, v)

convert_to_triangle_twist

convert_to_triangle_twist(
    G: "EuclideanPositionHEG", v: "Vertex"
) -> None

Replace a 6-creased vertex with a flat-foldable triangle twist.

v must be an interior vertex where 3 curved (red) and 3 straight creases meet — the typical hub produced by :func:~pleat.intersecting_cylinders.pipeline.make_intersecting_cylinders. The graph G is mutated in place: the curved creases are deleted, three new edges are added forming the boundary of a flat-foldable twist, and the central vertex is removed.

Source code in pleat/intersecting_cylinders/triangle_twist.py
def convert_to_triangle_twist(G: "EuclideanPositionHEG", v: "Vertex") -> None:
    """Replace a 6-creased vertex with a flat-foldable triangle twist.

    ``v`` must be an interior vertex where 3 curved (red) and 3 straight creases
    meet — the typical hub produced by
    :func:`~pleat.intersecting_cylinders.pipeline.make_intersecting_cylinders`.
    The graph ``G`` is mutated in place: the curved creases are deleted, three
    new edges are added forming the boundary of a flat-foldable twist, and the
    central vertex is removed.
    """
    G.delete_subset([h for h in v.outgoing_iter() if h["color_key"] == _RED])

    tasks = []
    for h in list(v.outgoing_iter()):
        v2 = h.rev.nex.nex.dest
        _set_color(_GREEN, v2)
        direction = v["pos"] - h.rev.nex.dest["pos"]
        f = h.rev.face
        pos = base.line_intersection(
            [v2["pos"], v2["pos"] + direction],
            [h.orig["pos"], h.dest["pos"]],
        )
        tasks.append((h, f, v2, pos))

    for h, f, v2, pos in tasks:
        _, v3 = G.subdivide_edge(h, pos=pos, color_key=_YELLOW)
        h2, _ = G.subdivide_face(f, v2, v3)
        _set_color(_RED, h2)

    for h in v.outgoing_iter():
        h2, _ = G.subdivide_face(h.face, h.dest, h.pre.orig)
        _set_color(_BLUE, h2)

    G.delete_subset([v])