Repository: networkx/networkx

Issue:
Modifying the edges of a PlannarEmbedding graph invalidates its structure.
<!-- If you have a general question about NetworkX, please use the discussions tab to create a new discussion -->

<!--- Provide a general summary of the issue in the Title above -->

If an edge is removed and another one added to a `PlanarEmbedding`, it becomes invalid even if the modifications result in a planar embedding (see notebook below).

### Current Behavior

An error is produced upon calling `PlanarEmbedding.check_structure()`.

### Expected Behavior

No error (provided the graph is still a planar embedding).

### Steps to Reproduce

```python
import networkx as nx

edges = ((0, 1), (1, 2), (2, 3), (3, 0), (0, 2))
pos = ((0, 0), (1, 0), (1, 1), (0, 1))

Gmwe = nx.Graph(((0, 1),
                 (1, 2),
                 (2, 3),
                 (3, 0),
                 (0, 2)))

cert, Pmwe = nx.check_planarity(Gmwe)
cert
```
Output:
    True

```python
type(Pmwe)
```
Output:
    networkx.algorithms.planarity.PlanarEmbedding

```python
nx.draw(Pmwe, with_labels=True, pos=pos)
```
![output_5_0](https://github.com/networkx/networkx/assets/856825/ea8589c7-6d20-46f3-bb12-2d8641f28bdf)
```python
list(Pmwe.neighbors_cw_order(0))
```
Output:
    [1, 3, 2]
```python
list(Pmwe.neighbors_cw_order(2))
```
Output:
    [1, 0, 3]

Now we remove the edge `(0, 2)` and add `(3, 1)`, inserting each half-edge in the proper position.
```python
Pmwe.remove_edges_from(((0, 2), (2, 0)))
Pmwe.add_half_edge_ccw(1, 3, 2)
Pmwe.add_half_edge_cw(3, 1, 2)
```

```python
nx.draw(Pmwe, with_labels=True, pos=pos)
```
![output_10_0](https://github.com/networkx/networkx/assets/856825/20adc934-4c37-46bf-a433-c786bf8b3cca)

Why is the modified PlanarEmbedding not structurally sound anymore?

```python
Pmwe.check_structure()
```
Output:

    ---------------------------------------------------------------------------

    KeyError                                  Traceback (most recent call last)

    File ~\programs\miniconda\envs\interarray\lib\site-packages\networkx\algorithms\planarity.py:938, in PlanarEmbedding.check_structure(self)
        937 try:
    --> 938     sorted_nbrs = set(self.neighbors_cw_order(v))
        939 except KeyError as err:
    

    File ~\programs\miniconda\envs\interarray\lib\site-packages\networkx\algorithms\planarity.py:915, in PlanarEmbedding.neighbors_cw_order(self, v)
        914 yield current_node
    --> 915 current_node = self[v][current_node]["cw"]
    

    File ~\programs\miniconda\envs\interarray\lib\site-packages\networkx\classes\coreviews.py:53, in AtlasView.__getitem__(self, key)
         52 def __getitem__(self, key):
    ---> 53     return self._atlas[key]
    

    KeyError: 2

    
    The above exception was the direct cause of the following exception:
    

    NetworkXException                         Traceback (most recent call last)

    Cell In[12], line 1
    ----> 1 Pmwe.check_structure()
    

    File ~\programs\miniconda\envs\interarray\lib\site-packages\networkx\algorithms\planarity.py:941, in PlanarEmbedding.check_structure(self)
        939 except KeyError as err:
        940     msg = f"Bad embedding. Missing orientation for a neighbor of {v}"
    --> 941     raise nx.NetworkXException(msg) from err
        943 unsorted_nbrs = set(self[v])
        944 if sorted_nbrs != unsorted_nbrs:
    

    NetworkXException: Bad embedding. Missing orientation for a neighbor of 0


The `check_planarity()` function has no problems with it:

```python
cert, Nmwe = nx.check_planarity(Pmwe)
cert
```
Output:
    True

```python
nx.draw(Nmwe, with_labels=True, pos=pos)
```
![output_15_0](https://github.com/networkx/networkx/assets/856825/a7bfc515-0bd7-4c47-a158-543af6203f63)
 
```python
nx.difference(Pmwe, Nmwe).edges
```
Output:
    OutEdgeView([])


### Environment

Python version: 3.10.12
NetworkX version: 3.1

### Additional context

<!--- Add any other context about the problem here, screenshots, etc. -->



Discussion:
After experimenting a bit, I figured that `PlanarEmbedding.remove_edge()` is not changing the values of keys `'cw'` and `'ccw'` in the data of the edges  surrounding the removed edge (i.e. they keep referencing the removed edge). I think this is a bug.
Yes -- it sounds like that is a bug. 
Thanks for tracking down the `remove_edge` location for the bug. Are you able to open a PR to fix it? If not we will get to it, but we'd love a PR -- or even a description/patch of what changes are needed. 
Thanks!!

Resolving patch:
diff --git a/networkx/algorithms/planarity.py b/networkx/algorithms/planarity.py
--- a/networkx/algorithms/planarity.py
+++ b/networkx/algorithms/planarity.py
@@ -855,6 +855,22 @@ class PlanarEmbedding(nx.DiGraph):
 
     """
 
+    def __init__(self, incoming_graph_data=None, **attr):
+        super().__init__(incoming_graph_data=incoming_graph_data, **attr)
+        self.add_edge = self.__forbidden
+        self.add_edges_from = self.__forbidden
+        self.add_weighted_edges_from = self.__forbidden
+
+    def __forbidden(self, *args, **kwargs):
+        """Forbidden operation
+
+        Any edge additions to a PlanarEmbedding should be done using
+        method `add_half_edge`.
+        """
+        raise NotImplementedError(
+            "Use `add_half_edge` method to add edges to a PlanarEmbedding."
+        )
+
     def get_data(self):
         """Converts the adjacency structure into a better readable structure.
 
@@ -896,6 +912,75 @@ def set_data(self, data):
                 self.add_half_edge(v, w, cw=ref)
                 ref = w
 
+    def remove_node(self, n):
+        """Remove node n.
+
+        Removes the node n and all adjacent edges, updating the
+        PlanarEmbedding to account for any resulting edge removal.
+        Attempting to remove a non-existent node will raise an exception.
+
+        Parameters
+        ----------
+        n : node
+           A node in the graph
+
+        Raises
+        ------
+        NetworkXError
+           If n is not in the graph.
+
+        See Also
+        --------
+        remove_nodes_from
+
+        """
+        try:
+            for u in self._pred[n]:
+                succs_u = self._succ[u]
+                un_cw = succs_u[n]["cw"]
+                un_ccw = succs_u[n]["ccw"]
+                del succs_u[n]
+                del self._pred[u][n]
+                if n != un_cw:
+                    succs_u[un_cw]["ccw"] = un_ccw
+                    succs_u[un_ccw]["cw"] = un_cw
+            del self._node[n]
+            del self._succ[n]
+            del self._pred[n]
+        except KeyError as err:  # NetworkXError if n not in self
+            raise nx.NetworkXError(
+                f"The node {n} is not in the planar embedding."
+            ) from err
+
+    def remove_nodes_from(self, nodes):
+        """Remove multiple nodes.
+
+        Parameters
+        ----------
+        nodes : iterable container
+            A container of nodes (list, dict, set, etc.).  If a node
+            in the container is not in the graph it is silently ignored.
+
+        See Also
+        --------
+        remove_node
+
+        Notes
+        -----
+        When removing nodes from an iterator over the graph you are changing,
+        a `RuntimeError` will be raised with message:
+        `RuntimeError: dictionary changed size during iteration`. This
+        happens when the graph's underlying dictionary is modified during
+        iteration. To avoid this error, evaluate the iterator into a separate
+        object, e.g. by using `list(iterator_of_nodes)`, and pass this
+        object to `G.remove_nodes_from`.
+
+        """
+        for n in nodes:
+            if n in self._node:
+                self.remove_node(n)
+            # silently skip non-existing nodes
+
     def neighbors_cw_order(self, v):
         """Generator for the neighbors of v in clockwise order.
 
@@ -940,6 +1025,7 @@ def add_half_edge(self, start_node, end_node, *, cw=None, ccw=None):
             End node of reference edge.
             Omit or pass `None` if adding the first out-half-edge of `start_node`.
 
+
         Raises
         ------
         NetworkXException
@@ -962,7 +1048,7 @@ def add_half_edge(self, start_node, end_node, *, cw=None, ccw=None):
                 if ccw is not None:
                     raise nx.NetworkXError("Only one of cw/ccw can be specified.")
                 ref_ccw = succs[cw]["ccw"]
-                self.add_edge(start_node, end_node, cw=cw, ccw=ref_ccw)
+                super().add_edge(start_node, end_node, cw=cw, ccw=ref_ccw)
                 succs[ref_ccw]["cw"] = end_node
                 succs[cw]["ccw"] = end_node
                 # when (cw == leftmost_nbr), the newly added neighbor is
@@ -973,7 +1059,7 @@ def add_half_edge(self, start_node, end_node, *, cw=None, ccw=None):
                 if ccw not in succs:
                     raise nx.NetworkXError("Invalid counterclockwise reference node.")
                 ref_cw = succs[ccw]["cw"]
-                self.add_edge(start_node, end_node, cw=ref_cw, ccw=ccw)
+                super().add_edge(start_node, end_node, cw=ref_cw, ccw=ccw)
                 succs[ref_cw]["ccw"] = end_node
                 succs[ccw]["cw"] = end_node
                 move_leftmost_nbr_to_end = True
@@ -986,11 +1072,12 @@ def add_half_edge(self, start_node, end_node, *, cw=None, ccw=None):
                 # we keep track of the leftmost neighbor, which we accomplish
                 # by keeping it as the last key in dict self._succ[start_node]
                 succs[leftmost_nbr] = succs.pop(leftmost_nbr)
+
         else:
             if cw is not None or ccw is not None:
                 raise nx.NetworkXError("Invalid reference node.")
             # adding the first edge out of start_node
-            self.add_edge(start_node, end_node, ccw=end_node, cw=end_node)
+            super().add_edge(start_node, end_node, ccw=end_node, cw=end_node)
 
     def check_structure(self):
         """Runs without exceptions if this object is valid.
@@ -1107,6 +1194,79 @@ def add_half_edge_cw(self, start_node, end_node, reference_neighbor):
         """
         self.add_half_edge(start_node, end_node, ccw=reference_neighbor)
 
+    def remove_edge(self, u, v):
+        """Remove the edge between u and v.
+
+        Parameters
+        ----------
+        u, v : nodes
+        Remove the half-edges (u, v) and (v, u) and update the
+        edge ordering around the removed edge.
+
+        Raises
+        ------
+        NetworkXError
+        If there is not an edge between u and v.
+
+        See Also
+        --------
+        remove_edges_from : remove a collection of edges
+        """
+        try:
+            succs_u = self._succ[u]
+            succs_v = self._succ[v]
+            uv_cw = succs_u[v]["cw"]
+            uv_ccw = succs_u[v]["ccw"]
+            vu_cw = succs_v[u]["cw"]
+            vu_ccw = succs_v[u]["ccw"]
+            del succs_u[v]
+            del self._pred[v][u]
+            del succs_v[u]
+            del self._pred[u][v]
+            if v != uv_cw:
+                succs_u[uv_cw]["ccw"] = uv_ccw
+                succs_u[uv_ccw]["cw"] = uv_cw
+            if u != vu_cw:
+                succs_v[vu_cw]["ccw"] = vu_ccw
+                succs_v[vu_ccw]["cw"] = vu_cw
+        except KeyError as err:
+            raise nx.NetworkXError(
+                f"The edge {u}-{v} is not in the planar embedding."
+            ) from err
+
+    def remove_edges_from(self, ebunch):
+        """Remove all edges specified in ebunch.
+
+        Parameters
+        ----------
+        ebunch: list or container of edge tuples
+            Each pair of half-edges between the nodes given in the tuples
+            will be removed from the graph. The nodes can be passed as:
+
+                - 2-tuples (u, v) half-edges (u, v) and (v, u).
+                - 3-tuples (u, v, k) where k is ignored.
+
+        See Also
+        --------
+        remove_edge : remove a single edge
+
+        Notes
+        -----
+        Will fail silently if an edge in ebunch is not in the graph.
+
+        Examples
+        --------
+        >>> G = nx.path_graph(4)  # or DiGraph, MultiGraph, MultiDiGraph, etc
+        >>> ebunch = [(1, 2), (2, 3)]
+        >>> G.remove_edges_from(ebunch)
+        """
+        for e in ebunch:
+            u, v = e[:2]  # ignore edge data
+            # assuming that the PlanarEmbedding is valid, if the half_edge
+            # (u, v) is in the graph, then so is half_edge (v, u)
+            if u in self._succ and v in self._succ[u]:
+                self.remove_edge(u, v)
+
     def connect_components(self, v, w):
         """Adds half-edges for (v, w) and (w, v) at some position.
 


Test patch:
diff --git a/networkx/algorithms/tests/test_planarity.py b/networkx/algorithms/tests/test_planarity.py
--- a/networkx/algorithms/tests/test_planarity.py
+++ b/networkx/algorithms/tests/test_planarity.py
@@ -277,6 +277,20 @@ def test_counterexample_planar_recursive(self):
             G.add_node(1)
             get_counterexample_recursive(G)
 
+    def test_edge_removal_from_planar_embedding(self):
+        # PlanarEmbedding.check_structure() must succeed after edge removal
+        edges = ((0, 1), (1, 2), (2, 3), (3, 4), (4, 0), (0, 2), (0, 3))
+        G = nx.Graph(edges)
+        cert, P = nx.check_planarity(G)
+        assert cert is True
+        P.remove_edge(0, 2)
+        self.check_graph(P, is_planar=True)
+        P.add_half_edge_ccw(1, 3, 2)
+        P.add_half_edge_cw(3, 1, 2)
+        self.check_graph(P, is_planar=True)
+        P.remove_edges_from(((0, 3), (1, 3)))
+        self.check_graph(P, is_planar=True)
+
 
 def check_embedding(G, embedding):
     """Raises an exception if the combinatorial embedding is not correct
@@ -410,19 +424,51 @@ def test_get_data(self):
         data_cmp = {0: [3, 2, 1], 1: [0], 2: [0], 3: [0]}
         assert data == data_cmp
 
-    def test_missing_edge_orientation(self):
+    def test_edge_removal(self):
         embedding = nx.PlanarEmbedding()
-        embedding.add_edge(1, 2)
-        embedding.add_edge(2, 1)
+        embedding.set_data(
+            {
+                1: [2, 5, 7],
+                2: [1, 3, 4, 5],
+                3: [2, 4],
+                4: [3, 6, 5, 2],
+                5: [7, 1, 2, 4],
+                6: [4, 7],
+                7: [6, 1, 5],
+            }
+        )
+        # remove_edges_from() calls remove_edge(), so both are tested here
+        embedding.remove_edges_from(((5, 4), (1, 5)))
+        embedding.check_structure()
+        embedding_expected = nx.PlanarEmbedding()
+        embedding_expected.set_data(
+            {
+                1: [2, 7],
+                2: [1, 3, 4, 5],
+                3: [2, 4],
+                4: [3, 6, 2],
+                5: [7, 2],
+                6: [4, 7],
+                7: [6, 1, 5],
+            }
+        )
+        assert nx.utils.graphs_equal(embedding, embedding_expected)
+
+    def test_missing_edge_orientation(self):
+        embedding = nx.PlanarEmbedding({1: {2: {}}, 2: {1: {}}})
         with pytest.raises(nx.NetworkXException):
             # Invalid structure because the orientation of the edge was not set
             embedding.check_structure()
 
     def test_invalid_edge_orientation(self):
-        embedding = nx.PlanarEmbedding()
-        embedding.add_half_edge(1, 2)
-        embedding.add_half_edge(2, 1)
-        embedding.add_edge(1, 3)
+        embedding = nx.PlanarEmbedding(
+            {
+                1: {2: {"cw": 2, "ccw": 2}},
+                2: {1: {"cw": 1, "ccw": 1}},
+                1: {3: {}},
+                3: {1: {}},
+            }
+        )
         with pytest.raises(nx.NetworkXException):
             embedding.check_structure()
 
@@ -461,12 +507,23 @@ def test_successful_face_traversal(self):
         assert face == [1, 2]
 
     def test_unsuccessful_face_traversal(self):
-        embedding = nx.PlanarEmbedding()
-        embedding.add_edge(1, 2, ccw=2, cw=3)
-        embedding.add_edge(2, 1, ccw=1, cw=3)
+        embedding = nx.PlanarEmbedding(
+            {1: {2: {"cw": 3, "ccw": 2}}, 2: {1: {"cw": 3, "ccw": 1}}}
+        )
         with pytest.raises(nx.NetworkXException):
             embedding.traverse_face(1, 2)
 
+    def test_forbidden_methods(self):
+        embedding = nx.PlanarEmbedding()
+        embedding.add_node(42)  # no exception
+        embedding.add_nodes_from([(23, 24)])  # no exception
+        with pytest.raises(NotImplementedError):
+            embedding.add_edge(1, 3)
+        with pytest.raises(NotImplementedError):
+            embedding.add_edges_from([(0, 2), (1, 4)])
+        with pytest.raises(NotImplementedError):
+            embedding.add_weighted_edges_from([(0, 2, 350), (1, 4, 125)])
+
     @staticmethod
     def get_star_embedding(n):
         embedding = nx.PlanarEmbedding()