Skip to content

mesh3d

pleat.intersecting_cylinders.mesh3d

Interactive 3D preview of intersecting-cylinders models.

The :func:to_3d_mesh function builds a triangle mesh of the folded surface; :func:show_3d returns a :mod:plotly figure suitable for interactive display in Jupyter notebooks. plotly is an optional dependency installed via the intersecting_cylinders extra.

Geometry

The 3D model is built on top of the ortho Conway operator applied to the input tiling, with edge-midpoint vertices repositioned to the points where the two dual circle packings touch (the same construction used in show_dual_circle_packings in the docs notebook). Each ortho quad has cyclic corners (v, t1, c, t2):

  • v is an original tiling vertex (a spike apex in 3D),
  • c is the incenter of an adjacent face (a flat base point in 3D),
  • t1, t2 are the points where the original edges incident to v are tangent to both the face incircle (around c) and the vertex circle of v.

The vertex-circle radius r_v = |v - t| controls the depth of the spike at v. The model is split into half-triangles (c, v, t) and each half is lifted using the profile's cross-section.

For r = 1 the half-triangle becomes one curved patch with a sharp apex at v (depth -r_v * scale). For r < 1 the cylinder cross-section stays self-similar but is rescaled: the curved patch occupies only the outer fraction curved_extent = 1 - apex_inset of the v-c direction, and the spike depth shrinks proportionally to -r_v * scale * curved_extent. The missing apex is replaced by a flat cap at the original vertex:

  • a curved trapezoid {c, c_near_v, t_near_v, t} filling the outer portion of the half-triangle, with c, t at z = 0 and c_near_v, t_near_v at the flat-tip depth -r_v * scale * curved_extent, and
  • a flat tip triangle {v, c_near_v, t_near_v} at the same depth. Combined across all half-triangles incident to v, these triangles form the closed flat polygon that caps the (proportionally smaller) cylinder at the vertex.

Here c_near_v = v + apex_inset * (c - v) and t_near_v = v + apex_inset * (t - v) -- both close to v. apex_inset = (1 - r) * sf / (1 - (1 - r) * (1 - sf)) (zero for r = 1).

The curved trapezoid is lifted by interpreting the profile as a spike-depth function read from the apex end inwards: zero depth and zero slope at the c-t base (so neighbouring half-triangle patches meet smoothly across c-t) and maximum slope toward the flat tip (so vertices look pointy). See :func:_spike_depth_from_profile.

Sampling

Along the curved direction, the lift uses the profile's own RDP-simplified sample points (which already concentrate samples near the steep apex). When the profile has more than max_profile_samples points they are uniformly subsampled in index space, always keeping the first and last. Across the edge, the mesh uses n_across_edge uniform subdivisions (the trapezoid is linear in that direction).

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)

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