Skip to content

operators

pleat.conway.operators

Conway operator classes: topological substitution and geometric variant.

TopologicalConwayOperator

TopologicalConwayOperator(
    graph: HalfEdgeGraph, v1: Vertex, vf: Vertex, v2: Vertex
)

Apply a Conway operator to a half-edge graph by substituting a fundamental domain into each face triangle.

The fundamental domain is a small half-edge graph with three marked vertices (v1, vf, v2) corresponding to a vertex, face center, and adjacent vertex of each triangle.

Store the fundamental domain and its three corner vertices.

Parameters:

Name Type Description Default
graph HalfEdgeGraph

Half-edge graph encoding the fundamental domain.

required
v1 Vertex

Corner mapped to h.dest of each triangle.

required
vf Vertex

Corner mapped to the face midpoint of each triangle.

required
v2 Vertex

Corner mapped to h.orig of each triangle.

required
Source code in pleat/conway/operators.py
def __init__(self, graph: HalfEdgeGraph, v1: Vertex, vf: Vertex, v2: Vertex) -> None:
    """Store the fundamental domain and its three corner vertices.

    Args:
        graph: Half-edge graph encoding the fundamental domain.
        v1: Corner mapped to ``h.dest`` of each triangle.
        vf: Corner mapped to the face midpoint of each triangle.
        v2: Corner mapped to ``h.orig`` of each triangle.
    """
    self.graph = graph
    self.v1 = v1
    self.vf = vf
    self.v2 = v2
    assert all(v in graph.vertices for v in (v1, vf, v2))

show

show() -> None

Render the fundamental domain graph for visualization.

Source code in pleat/conway/operators.py
def show(self) -> None:
    """Render the fundamental domain graph for visualization."""
    self.graph.show(scale=300, line_width=0.03, render_faces=False)

get_tri

get_tri(h: HalfEdge) -> 'np.ndarray | None'

Return the triangle for half-edge h, or None for purely topological operators.

Source code in pleat/conway/operators.py
def get_tri(self, h: HalfEdge) -> "np.ndarray | None":
    """Return the triangle for half-edge ``h``, or None for purely topological operators."""
    return None

generate_graph_and_corners

generate_graph_and_corners(
    tri: "np.ndarray | None", h: HalfEdge | None = None
) -> tuple[HalfEdgeGraph, tuple[Vertex, Vertex, Vertex]]

Return a copy of the fundamental-domain graph and its three corner vertices.

Parameters:

Name Type Description Default
tri 'np.ndarray | None'

Optional reference triangle for geometric subclasses.

required
h HalfEdge | None

Optional original half-edge; if given, every interior vertex of the copy gets pre_conway = h for traceability.

None

Returns:

Type Description
tuple[HalfEdgeGraph, tuple[Vertex, Vertex, Vertex]]

Tuple (graph_copy, (v1, vf, v2)).

Source code in pleat/conway/operators.py
def generate_graph_and_corners(
    self, tri: "np.ndarray | None", h: HalfEdge | None = None
) -> tuple[HalfEdgeGraph, tuple[Vertex, Vertex, Vertex]]:
    """Return a copy of the fundamental-domain graph and its three corner vertices.

    Args:
        tri: Optional reference triangle for geometric subclasses.
        h: Optional original half-edge; if given, every interior vertex of
            the copy gets ``pre_conway = h`` for traceability.

    Returns:
        Tuple ``(graph_copy, (v1, vf, v2))``.
    """
    graph, (v_map, _, _) = self.graph.copy(deepcopy_attributes=False, return_mappings=True)
    if h is not None:
        for v in graph.vertices:
            if not v.on_border():  # todo: also assign 'pre_conway' to border vertices, as a set
                v["pre_conway"] = h
    return graph, (v_map[self.v1], v_map[self.vf], v_map[self.v2])

__call__

__call__(
    graph: HalfEdgeGraph,
    *,
    faces: "set[Face] | Callable[[Face], bool] | None" = None,
    delete_on_border: bool = True,
    delete_inner_border: bool = False,
    inplace: bool = False
) -> HalfEdgeGraph

Apply the operator to graph, optionally restricted to faces.

Parameters:

Name Type Description Default
graph HalfEdgeGraph

Input half-edge graph; left untouched unless inplace=True.

required
faces 'set[Face] | Callable[[Face], bool] | None'

Faces to apply the operator to. Either an explicit set of faces, or a callable face -> bool used to filter graph.faces. Defaults to all faces.

None
delete_on_border bool

If True, delete faces whose original border edge was marked for deletion.

True
delete_inner_border bool

If True, also clear the delete flag on inner border edges.

False
inplace bool

If True, mutate graph directly. If False (default), operate on a deep-ish copy and preserve pre_conway references back to the original objects.

False

Returns:

Type Description
HalfEdgeGraph

The transformed graph (the input itself if inplace=True, otherwise a copy).

Source code in pleat/conway/operators.py
def __call__(
    self,
    graph: HalfEdgeGraph,
    *,
    faces: "set[Face] | Callable[[Face], bool] | None" = None,
    delete_on_border: bool = True,
    delete_inner_border: bool = False,
    inplace: bool = False,
) -> HalfEdgeGraph:
    """Apply the operator to ``graph``, optionally restricted to ``faces``.

    Args:
        graph: Input half-edge graph; left untouched unless ``inplace=True``.
        faces: Faces to apply the operator to. Either an explicit set of
            faces, or a callable ``face -> bool`` used to filter
            ``graph.faces``. Defaults to all faces.
        delete_on_border: If True, delete faces whose original border edge
            was marked for deletion.
        delete_inner_border: If True, also clear the ``delete`` flag on
            inner border edges.
        inplace: If True, mutate ``graph`` directly. If False (default),
            operate on a deep-ish copy and preserve ``pre_conway`` references
            back to the original objects.

    Returns:
        The transformed graph (the input itself if ``inplace=True``, otherwise a copy).
    """
    if not inplace:
        graph, (v_map, h_map, f_map) = graph.copy(return_mappings=True)
        # An explicit ``faces`` collection still references the input graph; translate
        # those Face objects into the copy. (A callable filter is applied later, after
        # ``graph`` already points at the copy, so it needs no remapping.)
        if faces is not None and not callable(faces):
            faces = {f_map[f] for f in faces}
        v_map, h_map, f_map = [invert_mapping(m) for m in (v_map, h_map, f_map)]
        obj_map = dict()
        obj_map.update(v_map)
        obj_map.update(h_map)
        obj_map.update(f_map)
        for obj in obj_map.keys():
            if obj is not None and "pre_conway" in obj:
                del obj["pre_conway"]

    # apply the operator to a set of halfedges in a graph
    assert isinstance(graph, HalfEdgeGraph)
    if faces is None:
        faces = graph.faces
    elif callable(faces):
        faces = {f for f in graph.faces if faces(f)}
    halfedges = [h for f in faces for h in f.halfedge_iter()]
    assert all(isinstance(h, HalfEdge) for h in halfedges)
    affected_faces = {h.face for h in halfedges}
    assert None not in affected_faces, "Cannot apply Conway operator to boundary edge"  # Or can we?
    old_halfedges = frozenset(graph.halfedges)
    v1_out_lookup = dict()
    v2_out_lookup = dict()
    vf_lookup = dict()
    vf_set = set()

    graphs_and_corners = [self.generate_graph_and_corners(self.get_tri(h), h) for h in halfedges]

    for gc, h in zip(graphs_and_corners, halfedges):
        orig_face = h.face
        con_graph, (v1, vf, v2) = gc

        # add reference to old face/vertex to new face/vertex
        for new_vertex, old_obj in [(v1, h.dest), (vf, h.face), (v2, h.orig)]:
            if new_vertex.attributes.get("delete", False):
                new_vertex.get_outgoing_border().rev.face["pre_conway"] = old_obj
            else:
                new_vertex["pre_conway"] = old_obj

        # glue v1, v2 to h.dest, h.orig
        graph.add_graph(con_graph)
        v1_out = v1.get_outgoing_border()
        v2_out = v2.get_outgoing_border()
        graph.glue_v2v(v1_out=h.nex, v2_out=v1_out)
        graph.glue_v2v(v1_out=h, v2_out=v2_out)
        inbetween_face = Face(any_side=h)
        h.face = inbetween_face
        k = v2_out
        orig_face.any_side = k
        while k.orig is not h.dest:
            k.face = orig_face
            k = k.nex
        k = v1_out
        while k.orig is not h.orig:
            k.face = inbetween_face
            k = k.nex
        graph.add_face(inbetween_face)

        v1_out_lookup[h] = v1_out
        v2_out_lookup[h] = v2_out
        vf_lookup[h] = vf
        vf_set.add(vf)

    # handle the insides of faces
    for h in halfedges:
        v2_out = v2_out_lookup[h]
        if v2_out.pre in old_halfedges:
            # v2_out.pre was not in the set of halfedges that the operator was applied to
            raise NotImplementedError
        current = v2_out
        while current.dest not in vf_set:
            next = current.nex
            HalfEdgeGraph.glue_e2e(graph, current, current.pre)
            current = next
        HalfEdgeGraph.glue_e2e(graph, current, current.pre)

    # handle the original edges
    to_process = copy(halfedges)
    while to_process:
        h = to_process.pop()
        if h.rev in halfedges:
            # delete old edge, then zip
            to_process.remove(h.rev)
            HalfEdgeGraph.delete_edge(graph, h)
            current = v1_out_lookup[h]
            f = current.face
            while f in graph.faces:
                next = current.nex
                HalfEdgeGraph.glue_e2e(graph, current, current.pre)
                current = next
        else:
            if True:  # not delete_on_border or not h.rev.on_border(): #Fixme
                for k in h.face.halfedge_iter():
                    if (not delete_inner_border) or h.rev.on_border():
                        k["delete"] = False
                        k.rev["delete"] = False
                    k.rev["border_delete"] = True

            if h.rev.on_border():
                graph.delete_face(h.face)
            else:
                HalfEdgeGraph.delete_edge(graph, h)
            # TODO: if on border, delete. else, do not delete adjacent. for now: delete in neither case

    # TODO: keep a record of which edges to delete in the end..
    # and a record of all affected vertices to update angles

    to_delete = set()
    to_process = copy(graph.halfedges)  # this is bad for performance: make everything work locally!
    to_keep = set()
    while to_process:
        h = to_process.pop()
        to_process.remove(h.rev)
        if h.attributes.get("delete", False):
            # only delete edges if their reverse also wants to be deleted
            if h.rev.attributes.get("delete", False):
                to_delete.update({h, h.rev})
                continue
            else:
                pass
                # del h.attributes['delete']
        to_keep.update({h, h.rev})

    assert not to_keep.intersection(to_delete)
    assert (
        to_keep.union(to_delete) == graph.halfedges
    ), f"{graph.halfedges.difference(to_keep.union(to_delete))}, {to_keep.union(to_delete).difference(graph.halfedges)}"

    faces_to_keep = set()
    faces_to_maybe_remove = set()
    while to_keep:
        # find the new faces
        h = to_keep.pop()
        initial = h
        f = h.face  # what about border?
        if f is not None:
            f.any_side = h
            faces_to_keep.add(f)
        while True:  # walk around the new face until cycle closes
            nex = h.nex
            while nex in to_delete:
                nex = nex.rev.nex
            h.nex = nex
            nex.pre = h
            h.dest.any_outgoing = nex
            if nex is initial:
                break
            else:
                faces_to_maybe_remove.add(nex.face)
                nex.face = f
                to_keep.remove(nex)
            h = nex

    graph.halfedges.difference_update(to_delete)
    graph.faces.difference_update(faces_to_maybe_remove.difference(faces_to_keep))

    graph.vertices.difference_update([v for v in (h.orig for h in to_delete) if v.any_outgoing in to_delete])

    if delete_on_border:
        for e in list(graph.border_edges()):
            if e in graph.halfedges:
                if e.rev.attributes.get("border_delete", False):
                    graph.delete_face(e.rev.face)
        # delete dangling faces
        while True:
            deleted_any = False
            for f in list(graph.faces):
                if not any(f.face_iter()):
                    graph.delete_face(f)
                    deleted_any = True
            if not deleted_any:
                break
    for e in graph.halfedges:
        if "border_delete" in e.attributes:
            del e["border_delete"]

    to_join = {v for v in graph.vertices if v.attributes.get("join", False)}
    for v in to_join:
        if v.order() == 2:  # TODO: check this beforehand
            HalfEdgeGraph.join_vertex(graph, v)

    if not inplace:
        for objs in (graph.vertices, graph.halfedges, graph.faces):
            for obj in objs:
                if "pre_conway" in obj.attributes:
                    obj["pre_conway"] = obj_map[obj["pre_conway"]]
    return graph

GeometricConwayOperator

GeometricConwayOperator(
    *super_args: object, **super_kwargs: object
)

Bases: TopologicalConwayOperator

Conway operator that assigns new vertex positions using barycentric coordinate interpolation.

Store the fundamental domain and convert its positions to barycentric coordinates.

After construction, every vertex in self.graph carries barycentric coordinates relative to the corner triangle (v1, vf, v2). They are re-projected to Euclidean coordinates per target triangle inside :meth:generate_graph_and_corners.

Source code in pleat/conway/operators.py
def __init__(self, *super_args: object, **super_kwargs: object) -> None:
    """Store the fundamental domain and convert its positions to barycentric coordinates.

    After construction, every vertex in ``self.graph`` carries barycentric
    coordinates relative to the corner triangle ``(v1, vf, v2)``. They are
    re-projected to Euclidean coordinates per target triangle inside
    :meth:`generate_graph_and_corners`.
    """
    super(GeometricConwayOperator, self).__init__(*super_args, **super_kwargs)
    # convert euclidean to barycentric coordinates
    to_barycentric = euclidean_to_barycentric_map(np.array([self.v1["pos"], self.vf["pos"], self.v2["pos"]]))
    for v in self.graph.vertices:
        v["pos"] = to_barycentric(v["pos"])
    self.geometry = None

get_tri

get_tri(h: HalfEdge) -> np.ndarray

Return the triangle (h.dest, face midpoint, h.orig) for half-edge h.

Source code in pleat/conway/operators.py
def get_tri(self, h: HalfEdge) -> np.ndarray:
    """Return the triangle ``(h.dest, face midpoint, h.orig)`` for half-edge ``h``."""
    midpoint = h.face.get(
        "midpoint", self.geometry.center_of_mass(np.stack([v["pos"] for v in h.face.vertex_iter()]))
    )
    return np.array([h.dest["pos"], midpoint, h.orig["pos"]])

generate_graph_and_corners

generate_graph_and_corners(
    tri: ndarray, h: HalfEdge | None = None
) -> tuple[HalfEdgeGraph, tuple[Vertex, Vertex, Vertex]]

Return a copy of the domain with positions mapped from barycentric to Euclidean coordinates.

Source code in pleat/conway/operators.py
def generate_graph_and_corners(
    self, tri: np.ndarray, h: HalfEdge | None = None
) -> tuple[HalfEdgeGraph, tuple[Vertex, Vertex, Vertex]]:
    """Return a copy of the domain with positions mapped from barycentric to Euclidean coordinates."""
    result, corners = super(GeometricConwayOperator, self).generate_graph_and_corners(tri, h)

    to_euclidean = self.geometry.barycentric_to_euclidean_map(tri)
    # this could be vectorized
    for v in result.vertices:
        v["pos"] = to_euclidean(v["pos"])
    return result, corners

__call__

__call__(
    graph: GeometricHEG,
    recompute_lengths_and_angles: bool = True,
    **kwargs: object
) -> GeometricHEG

Apply the geometric operator to graph.

Parameters:

Name Type Description Default
graph GeometricHEG

Geometric half-edge graph to operate on.

required
recompute_lengths_and_angles bool

If True, recompute edge lengths and interior angles after substitution.

True
**kwargs object

Forwarded to :meth:TopologicalConwayOperator.__call__.

{}

Returns:

Type Description
GeometricHEG

The transformed graph.

Source code in pleat/conway/operators.py
def __call__(
    self,
    graph: GeometricHEG,
    recompute_lengths_and_angles: bool = True,
    **kwargs: object,
) -> GeometricHEG:
    """Apply the geometric operator to ``graph``.

    Args:
        graph: Geometric half-edge graph to operate on.
        recompute_lengths_and_angles: If True, recompute edge lengths and
            interior angles after substitution.
        **kwargs: Forwarded to :meth:`TopologicalConwayOperator.__call__`.

    Returns:
        The transformed graph.
    """
    assert isinstance(graph, GeometricHEG)
    self.geometry = graph.geometry
    result = super().__call__(graph, **kwargs)
    if recompute_lengths_and_angles:
        result.recompute_lengths_and_angles()
    return result

get_fundamental_domain_graph_to_render

get_fundamental_domain_graph_to_render(
    delete_color: tuple[float, float, float] = (
        0.85,
        0.15,
        0.15,
    ),
    join_color: tuple[float, float, float] = (
        0.15,
        0.65,
        0.2,
    ),
    keep_color: tuple[float, float, float] = (
        0.3,
        0.3,
        0.3,
    ),
) -> HalfEdgeGraph

Build a copy of the fundamental-domain graph, colouring elements by their role.

Vertices and edges flagged as delete are coloured delete_color (default: red), and edges become dashed via the existing renderer behaviour; vertices flagged as join are coloured join_color (default: green); everything else uses keep_color.

Parameters:

Name Type Description Default
delete_color tuple[float, float, float]

RGB triple for delete-marked elements.

(0.85, 0.15, 0.15)
join_color tuple[float, float, float]

RGB triple for join-marked vertices.

(0.15, 0.65, 0.2)
keep_color tuple[float, float, float]

RGB triple for retained elements.

(0.3, 0.3, 0.3)

Returns:

Type Description
HalfEdgeGraph

A Euclidean-projected copy of the graph with color_key attributes set, ready to render.

Source code in pleat/conway/operators.py
def get_fundamental_domain_graph_to_render(
    self,
    delete_color: tuple[float, float, float] = (0.85, 0.15, 0.15),
    join_color: tuple[float, float, float] = (0.15, 0.65, 0.20),
    keep_color: tuple[float, float, float] = (0.30, 0.30, 0.30),
) -> HalfEdgeGraph:
    """Build a copy of the fundamental-domain graph, colouring elements by their role.

    Vertices and edges flagged as ``delete`` are coloured *delete_color*
    (default: red), and edges become dashed via the existing renderer behaviour;
    vertices flagged as ``join`` are coloured *join_color* (default: green); everything else uses *keep_color*.

    Args:
        delete_color: RGB triple for delete-marked elements.
        join_color: RGB triple for join-marked vertices.
        keep_color: RGB triple for retained elements.

    Returns:
        A Euclidean-projected copy of the graph with ``color_key`` attributes set, ready to render.
    """
    from ..half import EuclideanGeometry

    # Project barycentric vertex positions back to a canonical Euclidean triangle.
    graph_copy, (_, _, _) = self.graph.copy(deepcopy_attributes=False, return_mappings=True)
    graph_copy.geometry = EuclideanGeometry
    to_euclidean = EuclideanGeometry.barycentric_to_euclidean_map(self._SHOW_REFERENCE_TRIANGLE)
    for v in graph_copy.vertices:
        v["pos"] = to_euclidean(v["pos"])

    # Assign colours based on role.
    for v in graph_copy.vertices:
        if v.attributes.get("delete", False):
            v["color_key"] = delete_color
        elif v.attributes.get("join", False):
            v["color_key"] = join_color
        else:
            v["color_key"] = keep_color
    for h in graph_copy.halfedges:
        if h.attributes.get("delete", False) or h.rev.attributes.get("delete", False):
            h["color_key"] = delete_color
        elif "color_key" not in h.attributes:
            h["color_key"] = keep_color
    return graph_copy

render

render(**show_kwargs: object) -> Rendering

Render the fundamental domain graph with styling from :meth:get_fundamental_domain_graph_to_render.

Source code in pleat/conway/operators.py
def render(self, **show_kwargs: object) -> Rendering:
    """Render the fundamental domain graph with styling from :meth:`get_fundamental_domain_graph_to_render`."""

    fundamental_domain_graph = self.get_fundamental_domain_graph_to_render()
    kwargs = dict(
        line_width="50%",
        face_inset=0,
    )
    kwargs.update(show_kwargs)
    return fundamental_domain_graph.render(**kwargs)

show

show(**kwargs: object) -> None

Render the fundamental domain graph with styling from :meth:get_fundamental_domain_graph_to_render and display it.

Source code in pleat/conway/operators.py
def show(self, **kwargs: object) -> None:
    """Render the fundamental domain graph with styling from :meth:`get_fundamental_domain_graph_to_render` and display it."""
    rendering = self.render(**kwargs)
    rendering.show()