knowledgecomplex 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,786 @@
1
+ """knowledgecomplex.viz — NetworkX export and visualization helpers.
2
+
3
+ Two complementary views of a knowledge complex are provided:
4
+
5
+ **Hasse diagram** (``plot_hasse``, ``plot_hasse_star``, ``plot_hasse_skeleton``)
6
+ Every element (vertex, edge, face) becomes a graph node. Directed edges
7
+ represent the boundary operator, pointing from each element to its boundary
8
+ elements (higher dimension → lower dimension). Faces have out-degree 3
9
+ and in-degree 0; edges have out-degree 2; vertices have out-degree 0.
10
+ Nodes are colored by type and sized by dimension.
11
+
12
+ **Geometric realization** (``plot_geometric``, ``plot_geometric_interactive``)
13
+ Only KC vertices become points in 3D space. KC edges become line segments
14
+ connecting their two boundary vertices. KC faces become filled,
15
+ semi-transparent triangular patches spanning their three boundary vertices.
16
+ This is the classical geometric realization of the abstract simplicial
17
+ complex — the view a topologist would draw.
18
+
19
+ ``to_networkx`` exports a ``DiGraph`` that backs the Hasse plots.
20
+ ``verify_networkx`` validates that a DiGraph satisfies simplicial complex
21
+ cardinality and closure invariants at runtime.
22
+
23
+ Requires optional dependencies::
24
+
25
+ pip install knowledgecomplex[viz] # matplotlib + networkx
26
+ pip install knowledgecomplex[viz-interactive] # + plotly for interactive 3D
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import warnings
31
+ from typing import TYPE_CHECKING, Any
32
+
33
+ if TYPE_CHECKING:
34
+ from knowledgecomplex.graph import KnowledgeComplex
35
+
36
+ _DIM_BY_KIND = {"vertex": 0, "edge": 1, "face": 2}
37
+ _SIZE_BY_DIM = {0: 400, 1: 200, 2: 100}
38
+
39
+ _INSTALL_HINT = (
40
+ "networkx and matplotlib are required for visualization.\n"
41
+ "Install them with: pip install knowledgecomplex[viz]"
42
+ )
43
+
44
+ _PLOTLY_HINT = (
45
+ "plotly is required for interactive 3D visualization.\n"
46
+ "Install it with: pip install knowledgecomplex[viz-interactive]"
47
+ )
48
+
49
+
50
+ def _require_nx():
51
+ try:
52
+ import networkx as nx
53
+ return nx
54
+ except ImportError:
55
+ raise ImportError(_INSTALL_HINT) from None
56
+
57
+
58
+ def _require_mpl():
59
+ try:
60
+ import matplotlib
61
+ import matplotlib.pyplot as plt
62
+ return matplotlib, plt
63
+ except ImportError:
64
+ raise ImportError(_INSTALL_HINT) from None
65
+
66
+
67
+ def _require_plotly():
68
+ try:
69
+ import plotly.graph_objects as go
70
+ return go
71
+ except ImportError:
72
+ raise ImportError(_PLOTLY_HINT) from None
73
+
74
+
75
+ # ── NetworkX export ─────────────────────────────────────────────────────────
76
+
77
+
78
+ def to_networkx(kc: "KnowledgeComplex") -> Any:
79
+ """Convert a KnowledgeComplex to a directed networkx DiGraph.
80
+
81
+ Every element (vertex, edge, face) becomes a node. Directed edges
82
+ represent the boundary operator ``kc:boundedBy``, pointing **from each
83
+ element to its boundary elements** (higher dimension → lower dimension).
84
+
85
+ In the resulting DiGraph:
86
+
87
+ - **Face** nodes have out-degree 3 (→ 3 boundary edges) and in-degree 0.
88
+ - **Edge** nodes have out-degree 2 (→ 2 boundary vertices).
89
+ - **Vertex** nodes have out-degree 0 (empty boundary).
90
+
91
+ Each node carries attributes:
92
+
93
+ - ``type``: element type name (e.g. ``"Node"``, ``"Link"``)
94
+ - ``kind``: ``"vertex"``, ``"edge"``, or ``"face"``
95
+ - ``dim``: 0, 1, or 2
96
+ - ``uri``: file URI if present, else ``None``
97
+ - All model-namespace attributes from the element
98
+
99
+ Parameters
100
+ ----------
101
+ kc : KnowledgeComplex
102
+
103
+ Returns
104
+ -------
105
+ networkx.DiGraph
106
+ """
107
+ nx = _require_nx()
108
+ G = nx.DiGraph(name=kc._schema._namespace)
109
+
110
+ for elem_id in kc.element_ids():
111
+ elem = kc.element(elem_id)
112
+ type_name = elem.type
113
+ kind = kc._schema._types.get(type_name, {}).get("kind", "vertex")
114
+ attrs = {
115
+ "type": type_name,
116
+ "kind": kind,
117
+ "dim": _DIM_BY_KIND.get(kind, 0),
118
+ "uri": elem.uri,
119
+ **elem.attrs,
120
+ }
121
+ G.add_node(elem_id, **attrs)
122
+
123
+ # Directed boundary edges: element → boundary element (high dim → low dim)
124
+ for elem_id in kc.element_ids():
125
+ for boundary_id in kc.boundary(elem_id):
126
+ G.add_edge(elem_id, boundary_id)
127
+
128
+ return G
129
+
130
+
131
+ # ── Verification ────────────────────────────────────────────────────────────
132
+
133
+
134
+ def verify_networkx(G: Any) -> bool:
135
+ """Validate that a DiGraph satisfies simplicial complex invariants.
136
+
137
+ Checks cardinality constraints and boundary closure:
138
+
139
+ - Every node has ``kind`` and ``dim`` attributes.
140
+ - **Vertices** (dim=0): out-degree = 0.
141
+ - **Edges** (dim=1): out-degree = 2, both targets are vertices (dim=0).
142
+ - **Faces** (dim=2): out-degree = 3, all targets are edges (dim=1).
143
+ - **Closed-triangle**: for each face, the 3 boundary edges share exactly
144
+ 3 distinct vertices (forming a closed triangle, not an open fan).
145
+
146
+ Parameters
147
+ ----------
148
+ G : networkx.DiGraph
149
+ A DiGraph produced by :func:`to_networkx`.
150
+
151
+ Returns
152
+ -------
153
+ bool
154
+ ``True`` if all invariants hold.
155
+
156
+ Raises
157
+ ------
158
+ ValueError
159
+ On the first invariant violation, with a descriptive message.
160
+ TypeError
161
+ If *G* is not a ``DiGraph``.
162
+ """
163
+ nx = _require_nx()
164
+ if not isinstance(G, nx.DiGraph):
165
+ raise TypeError(f"Expected nx.DiGraph, got {type(G).__name__}")
166
+
167
+ for node in G.nodes:
168
+ data = G.nodes[node]
169
+ if "kind" not in data or "dim" not in data:
170
+ raise ValueError(f"Node '{node}' missing 'kind' or 'dim' attribute")
171
+
172
+ dim = data["dim"]
173
+ out_deg = G.out_degree(node)
174
+ successors = list(G.successors(node))
175
+
176
+ if dim == 0: # vertex
177
+ if out_deg != 0:
178
+ raise ValueError(
179
+ f"Vertex '{node}' has out-degree {out_deg}, expected 0"
180
+ )
181
+
182
+ elif dim == 1: # edge
183
+ if out_deg != 2:
184
+ raise ValueError(
185
+ f"Edge '{node}' has out-degree {out_deg}, expected 2"
186
+ )
187
+ for s in successors:
188
+ if G.nodes[s].get("dim") != 0:
189
+ raise ValueError(
190
+ f"Edge '{node}' boundary target '{s}' is not a vertex "
191
+ f"(dim={G.nodes[s].get('dim')})"
192
+ )
193
+
194
+ elif dim == 2: # face
195
+ if out_deg != 3:
196
+ raise ValueError(
197
+ f"Face '{node}' has out-degree {out_deg}, expected 3"
198
+ )
199
+ for s in successors:
200
+ if G.nodes[s].get("dim") != 1:
201
+ raise ValueError(
202
+ f"Face '{node}' boundary target '{s}' is not an edge "
203
+ f"(dim={G.nodes[s].get('dim')})"
204
+ )
205
+ # Closed-triangle: 3 edges must share exactly 3 distinct vertices
206
+ face_vertices = set()
207
+ for edge_node in successors:
208
+ for v in G.successors(edge_node):
209
+ face_vertices.add(v)
210
+ if len(face_vertices) != 3:
211
+ raise ValueError(
212
+ f"Face '{node}' boundary edges span {len(face_vertices)} "
213
+ f"distinct vertices, expected 3 (closed triangle)"
214
+ )
215
+
216
+ return True
217
+
218
+
219
+ # ── Color mapping ──────────────────────────────────────────────────────────
220
+
221
+
222
+ def type_color_map(kc: "KnowledgeComplex") -> dict[str, str]:
223
+ """Build a type-name to hex-color mapping from the schema's type registry.
224
+
225
+ Uses matplotlib's ``tab10`` colormap (or ``tab20`` if > 10 types)
226
+ for distinct, visually separable colors.
227
+
228
+ Parameters
229
+ ----------
230
+ kc : KnowledgeComplex
231
+
232
+ Returns
233
+ -------
234
+ dict[str, str]
235
+ Mapping from type name to hex color string.
236
+ """
237
+ _, plt = _require_mpl()
238
+ import matplotlib.colors as mcolors
239
+
240
+ type_names = sorted(kc._schema._types.keys())
241
+ cmap_name = "tab10" if len(type_names) <= 10 else "tab20"
242
+ cmap = plt.get_cmap(cmap_name)
243
+
244
+ colors = {}
245
+ for i, name in enumerate(type_names):
246
+ colors[name] = mcolors.to_hex(cmap(i % cmap.N))
247
+ return colors
248
+
249
+
250
+ # ── Hasse diagram helpers ──────────────────────────────────────────────────
251
+
252
+
253
+ def _prepare_ax(ax, figsize):
254
+ """Create or reuse a matplotlib Axes."""
255
+ _, plt = _require_mpl()
256
+ if ax is None:
257
+ fig, ax = plt.subplots(1, 1, figsize=figsize)
258
+ else:
259
+ fig = ax.get_figure()
260
+ return fig, ax
261
+
262
+
263
+ def _layout(G):
264
+ """Choose a 2D layout for the graph (converts DiGraph to undirected)."""
265
+ nx = _require_nx()
266
+ if len(G) == 0:
267
+ return {}
268
+ undirected = G.to_undirected() if G.is_directed() else G
269
+ try:
270
+ return nx.kamada_kawai_layout(undirected)
271
+ except Exception:
272
+ return nx.spring_layout(undirected, seed=42)
273
+
274
+
275
+ # ── Hasse diagram plots ───────────────────────────────────────────────────
276
+
277
+
278
+ def plot_hasse(
279
+ kc: "KnowledgeComplex",
280
+ *,
281
+ ax: Any = None,
282
+ figsize: tuple[float, float] = (10, 8),
283
+ with_labels: bool = True,
284
+ node_size_by_dim: bool = True,
285
+ ) -> tuple[Any, Any]:
286
+ """Plot the Hasse diagram of the complex with type-based color coding.
287
+
288
+ Every element (vertex, edge, face) is drawn as a node. Directed arrows
289
+ represent the boundary operator, pointing from each element to its
290
+ boundary elements (higher dimension → lower dimension). Nodes are colored
291
+ by type and sized by dimension (vertices largest, faces smallest).
292
+
293
+ This is **not** a geometric picture of the complex — it is the partially
294
+ ordered set of simplices. For a geometric view where vertices are points,
295
+ edges are line segments, and faces are filled triangles, see
296
+ :func:`plot_geometric`.
297
+
298
+ Parameters
299
+ ----------
300
+ kc : KnowledgeComplex
301
+ ax : matplotlib Axes, optional
302
+ Axes to draw on. Created if not provided.
303
+ figsize : tuple
304
+ Figure size if creating a new figure.
305
+ with_labels : bool
306
+ Show element ID labels on nodes.
307
+ node_size_by_dim : bool
308
+ Scale node size by dimension (vertex=large, face=small).
309
+
310
+ Returns
311
+ -------
312
+ (fig, ax)
313
+ The matplotlib Figure and Axes.
314
+ """
315
+ nx = _require_nx()
316
+ _, plt = _require_mpl()
317
+
318
+ G = to_networkx(kc)
319
+ fig, ax = _prepare_ax(ax, figsize)
320
+ pos = _layout(G)
321
+ colors = type_color_map(kc)
322
+
323
+ if len(G) == 0:
324
+ ax.set_title("Empty complex")
325
+ ax.axis("off")
326
+ return fig, ax
327
+
328
+ node_colors = [colors.get(G.nodes[n].get("type", ""), "#999999") for n in G]
329
+ if node_size_by_dim:
330
+ node_sizes = [_SIZE_BY_DIM.get(G.nodes[n].get("dim", 0), 200) for n in G]
331
+ else:
332
+ node_sizes = 300
333
+
334
+ nx.draw_networkx_edges(
335
+ G, pos, ax=ax, edge_color="#cccccc", width=1.5,
336
+ arrows=True, arrowstyle="-|>", arrowsize=12,
337
+ connectionstyle="arc3,rad=0.05",
338
+ )
339
+ nx.draw_networkx_nodes(
340
+ G, pos, ax=ax,
341
+ node_color=node_colors,
342
+ node_size=node_sizes,
343
+ edgecolors="#333333",
344
+ linewidths=0.5,
345
+ )
346
+ if with_labels:
347
+ nx.draw_networkx_labels(G, pos, ax=ax, font_size=8)
348
+
349
+ # Legend
350
+ from matplotlib.lines import Line2D
351
+ legend_handles = []
352
+ for type_name in sorted(colors):
353
+ kind = kc._schema._types.get(type_name, {}).get("kind", "?")
354
+ legend_handles.append(
355
+ Line2D([0], [0], marker="o", color="w",
356
+ markerfacecolor=colors[type_name], markersize=10,
357
+ label=f"{type_name} ({kind})")
358
+ )
359
+ if legend_handles:
360
+ ax.legend(handles=legend_handles, loc="best", fontsize=8)
361
+
362
+ ax.set_title(f"Hasse Diagram: {kc._schema._namespace}")
363
+ ax.axis("off")
364
+ return fig, ax
365
+
366
+
367
+ def plot_hasse_star(
368
+ kc: "KnowledgeComplex",
369
+ id: str,
370
+ *,
371
+ ax: Any = None,
372
+ figsize: tuple[float, float] = (10, 8),
373
+ with_labels: bool = True,
374
+ ) -> tuple[Any, Any]:
375
+ """Plot the Hasse diagram with the star of an element highlighted.
376
+
377
+ Elements in ``St(id)`` are drawn in full color with directed arrows;
378
+ all other elements are dimmed to light gray. This is the Hasse-diagram
379
+ view — see :func:`plot_hasse` for details on what that means.
380
+
381
+ Parameters
382
+ ----------
383
+ kc : KnowledgeComplex
384
+ id : str
385
+ Element whose star to highlight.
386
+ ax : matplotlib Axes, optional
387
+ figsize : tuple
388
+ with_labels : bool
389
+
390
+ Returns
391
+ -------
392
+ (fig, ax)
393
+ """
394
+ nx = _require_nx()
395
+ _, plt = _require_mpl()
396
+
397
+ G = to_networkx(kc)
398
+ fig, ax = _prepare_ax(ax, figsize)
399
+ pos = _layout(G)
400
+ colors = type_color_map(kc)
401
+ star_ids = kc.star(id)
402
+
403
+ highlighted = [n for n in G if n in star_ids]
404
+ dimmed = [n for n in G if n not in star_ids]
405
+
406
+ if dimmed:
407
+ nx.draw_networkx_nodes(
408
+ G, pos, nodelist=dimmed, ax=ax,
409
+ node_color="#dddddd", node_size=150,
410
+ edgecolors="#cccccc", linewidths=0.5,
411
+ )
412
+
413
+ star_edges = [(u, v) for u, v in G.edges() if u in star_ids and v in star_ids]
414
+ dim_edges = [(u, v) for u, v in G.edges() if (u, v) not in set(star_edges)]
415
+ if dim_edges:
416
+ nx.draw_networkx_edges(
417
+ G, pos, edgelist=dim_edges, ax=ax, edge_color="#eeeeee", width=1.0,
418
+ arrows=True, arrowstyle="-|>", arrowsize=8,
419
+ )
420
+ if star_edges:
421
+ nx.draw_networkx_edges(
422
+ G, pos, edgelist=star_edges, ax=ax, edge_color="#666666", width=2.0,
423
+ arrows=True, arrowstyle="-|>", arrowsize=14,
424
+ )
425
+
426
+ if highlighted:
427
+ h_colors = [colors.get(G.nodes[n].get("type", ""), "#999999") for n in highlighted]
428
+ h_sizes = [_SIZE_BY_DIM.get(G.nodes[n].get("dim", 0), 200) for n in highlighted]
429
+ nx.draw_networkx_nodes(
430
+ G, pos, nodelist=highlighted, ax=ax,
431
+ node_color=h_colors, node_size=h_sizes,
432
+ edgecolors="#333333", linewidths=1.0,
433
+ )
434
+
435
+ if with_labels:
436
+ nx.draw_networkx_labels(G, pos, ax=ax, font_size=8)
437
+
438
+ ax.set_title(f"Hasse Star({id})")
439
+ ax.axis("off")
440
+ return fig, ax
441
+
442
+
443
+ def plot_hasse_skeleton(
444
+ kc: "KnowledgeComplex",
445
+ k: int,
446
+ *,
447
+ ax: Any = None,
448
+ figsize: tuple[float, float] = (10, 8),
449
+ with_labels: bool = True,
450
+ ) -> tuple[Any, Any]:
451
+ """Plot the Hasse diagram of the k-skeleton only.
452
+
453
+ Shows only elements of dimension ≤ k, with directed boundary arrows.
454
+ This is the Hasse-diagram view — see :func:`plot_hasse` for details.
455
+
456
+ k=0: vertices only, k=1: vertices + edges, k=2: everything.
457
+
458
+ Parameters
459
+ ----------
460
+ kc : KnowledgeComplex
461
+ k : int
462
+ Maximum dimension (0, 1, or 2).
463
+ ax : matplotlib Axes, optional
464
+ figsize : tuple
465
+ with_labels : bool
466
+
467
+ Returns
468
+ -------
469
+ (fig, ax)
470
+ """
471
+ nx = _require_nx()
472
+ _, plt = _require_mpl()
473
+
474
+ G = to_networkx(kc)
475
+ skel_ids = kc.skeleton(k)
476
+ subG = G.subgraph(skel_ids).copy()
477
+
478
+ fig, ax = _prepare_ax(ax, figsize)
479
+ pos = _layout(subG)
480
+ colors = type_color_map(kc)
481
+
482
+ if len(subG) == 0:
483
+ ax.set_title(f"Hasse Skeleton({k}) — empty")
484
+ ax.axis("off")
485
+ return fig, ax
486
+
487
+ node_colors = [colors.get(subG.nodes[n].get("type", ""), "#999999") for n in subG]
488
+ node_sizes = [_SIZE_BY_DIM.get(subG.nodes[n].get("dim", 0), 200) for n in subG]
489
+
490
+ nx.draw_networkx_edges(
491
+ subG, pos, ax=ax, edge_color="#cccccc", width=1.5,
492
+ arrows=True, arrowstyle="-|>", arrowsize=12,
493
+ )
494
+ nx.draw_networkx_nodes(
495
+ subG, pos, ax=ax,
496
+ node_color=node_colors,
497
+ node_size=node_sizes,
498
+ edgecolors="#333333",
499
+ linewidths=0.5,
500
+ )
501
+ if with_labels:
502
+ nx.draw_networkx_labels(subG, pos, ax=ax, font_size=8)
503
+
504
+ ax.set_title(f"Hasse Skeleton({k})")
505
+ ax.axis("off")
506
+ return fig, ax
507
+
508
+
509
+ # ── Deprecated aliases ─────────────────────────────────────────────────────
510
+
511
+
512
+ def plot_complex(kc, **kwargs):
513
+ """Deprecated: use :func:`plot_hasse` instead."""
514
+ warnings.warn("plot_complex is deprecated, use plot_hasse", DeprecationWarning, stacklevel=2)
515
+ return plot_hasse(kc, **kwargs)
516
+
517
+
518
+ def plot_star(kc, id, **kwargs):
519
+ """Deprecated: use :func:`plot_hasse_star` instead."""
520
+ warnings.warn("plot_star is deprecated, use plot_hasse_star", DeprecationWarning, stacklevel=2)
521
+ return plot_hasse_star(kc, id, **kwargs)
522
+
523
+
524
+ def plot_skeleton(kc, k, **kwargs):
525
+ """Deprecated: use :func:`plot_hasse_skeleton` instead."""
526
+ warnings.warn("plot_skeleton is deprecated, use plot_hasse_skeleton", DeprecationWarning, stacklevel=2)
527
+ return plot_hasse_skeleton(kc, k, **kwargs)
528
+
529
+
530
+ # ── Geometric realization helpers ──────────────────────────────────────────
531
+
532
+
533
+ def _face_vertices(kc: "KnowledgeComplex", face_id: str) -> list[str]:
534
+ """Get the 3 vertices of a face by walking boundary twice.
535
+
536
+ boundary(face) → 3 edges → boundary(each edge) → deduplicate → 3 vertices.
537
+ """
538
+ verts: set[str] = set()
539
+ for edge_id in kc.boundary(face_id):
540
+ verts |= kc.boundary(edge_id)
541
+ return sorted(verts)
542
+
543
+
544
+ def _vertex_positions_3d(
545
+ kc: "KnowledgeComplex",
546
+ ) -> dict[str, tuple[float, float, float]]:
547
+ """Compute 3D positions for KC vertices using force-directed layout.
548
+
549
+ Builds a networkx graph of only KC vertices, with an edge between
550
+ vertices that share a KC edge, then runs spring_layout in 3D.
551
+ """
552
+ nx = _require_nx()
553
+ G = nx.Graph()
554
+
555
+ # Add vertex nodes
556
+ vertex_ids = list(kc.skeleton(0))
557
+ for vid in vertex_ids:
558
+ G.add_node(vid)
559
+
560
+ # Connect vertices that share a KC edge
561
+ edge_ids = kc.skeleton(1) - kc.skeleton(0)
562
+ for eid in edge_ids:
563
+ boundary = list(kc.boundary(eid))
564
+ if len(boundary) == 2:
565
+ G.add_edge(boundary[0], boundary[1])
566
+
567
+ if len(G) == 0:
568
+ return {}
569
+
570
+ pos = nx.spring_layout(G, dim=3, seed=42)
571
+ return {vid: tuple(pos[vid]) for vid in pos}
572
+
573
+
574
+ # ── Geometric realization: matplotlib ──────────────────────────────────────
575
+
576
+
577
+ def plot_geometric(
578
+ kc: "KnowledgeComplex",
579
+ *,
580
+ ax: Any = None,
581
+ figsize: tuple[float, float] = (10, 8),
582
+ with_labels: bool = True,
583
+ ) -> tuple[Any, Any]:
584
+ """Plot the geometric realization of the complex in 3D.
585
+
586
+ KC vertices become points in 3D space (positioned by force-directed
587
+ layout). KC edges become line segments connecting their two boundary
588
+ vertices. KC faces become filled, semi-transparent triangular patches
589
+ spanning their three boundary vertices.
590
+
591
+ This is the classical geometric realization — the view a topologist
592
+ would draw. For the Hasse diagram where every element is a node and
593
+ boundary relations are directed edges, see :func:`plot_hasse`.
594
+
595
+ Parameters
596
+ ----------
597
+ kc : KnowledgeComplex
598
+ ax : matplotlib Axes3D, optional
599
+ A 3D axes to draw on. Created if not provided.
600
+ figsize : tuple
601
+ Figure size if creating a new figure.
602
+ with_labels : bool
603
+ Show vertex ID labels.
604
+
605
+ Returns
606
+ -------
607
+ (fig, ax)
608
+ The matplotlib Figure and Axes3D.
609
+ """
610
+ _, plt = _require_mpl()
611
+ from mpl_toolkits.mplot3d.art3d import Poly3DCollection
612
+
613
+ colors = type_color_map(kc)
614
+ pos = _vertex_positions_3d(kc)
615
+
616
+ if ax is None:
617
+ fig = plt.figure(figsize=figsize)
618
+ ax = fig.add_subplot(111, projection="3d")
619
+ else:
620
+ fig = ax.get_figure()
621
+
622
+ if not pos:
623
+ ax.set_title("Empty complex")
624
+ return fig, ax
625
+
626
+ # Draw faces as filled triangles
627
+ face_ids = kc.skeleton(2) - kc.skeleton(1)
628
+ for fid in face_ids:
629
+ verts = _face_vertices(kc, fid)
630
+ if len(verts) == 3 and all(v in pos for v in verts):
631
+ tri = [pos[v] for v in verts]
632
+ face_type = kc.element(fid).type
633
+ color = colors.get(face_type, "#999999")
634
+ poly = Poly3DCollection([tri], alpha=0.25, facecolor=color,
635
+ edgecolor=color, linewidths=0.5)
636
+ ax.add_collection3d(poly)
637
+
638
+ # Draw edges as line segments
639
+ edge_ids = kc.skeleton(1) - kc.skeleton(0)
640
+ for eid in edge_ids:
641
+ boundary = list(kc.boundary(eid))
642
+ if len(boundary) == 2 and all(v in pos for v in boundary):
643
+ p0, p1 = pos[boundary[0]], pos[boundary[1]]
644
+ edge_type = kc.element(eid).type
645
+ color = colors.get(edge_type, "#999999")
646
+ ax.plot3D(
647
+ [p0[0], p1[0]], [p0[1], p1[1]], [p0[2], p1[2]],
648
+ color=color, linewidth=2,
649
+ )
650
+
651
+ # Draw vertices as scatter points
652
+ vertex_ids = list(kc.skeleton(0))
653
+ for vid in vertex_ids:
654
+ if vid in pos:
655
+ x, y, z = pos[vid]
656
+ vtype = kc.element(vid).type
657
+ color = colors.get(vtype, "#999999")
658
+ ax.scatter3D(x, y, z, color=color, s=80, edgecolors="#333333",
659
+ linewidths=0.5, zorder=5, depthshade=False)
660
+
661
+ # Labels
662
+ if with_labels:
663
+ for vid in vertex_ids:
664
+ if vid in pos:
665
+ x, y, z = pos[vid]
666
+ ax.text(x, y, z, f" {vid}", fontsize=7)
667
+
668
+ # Legend
669
+ from matplotlib.lines import Line2D
670
+ legend_handles = []
671
+ for type_name in sorted(colors):
672
+ kind = kc._schema._types.get(type_name, {}).get("kind", "?")
673
+ legend_handles.append(
674
+ Line2D([0], [0], marker="o", color="w",
675
+ markerfacecolor=colors[type_name], markersize=8,
676
+ label=f"{type_name} ({kind})")
677
+ )
678
+ if legend_handles:
679
+ ax.legend(handles=legend_handles, loc="best", fontsize=7)
680
+
681
+ ax.set_title(f"Geometric Realization: {kc._schema._namespace}")
682
+ return fig, ax
683
+
684
+
685
+ # ── Geometric realization: plotly ──────────────────────────────────────────
686
+
687
+
688
+ def plot_geometric_interactive(
689
+ kc: "KnowledgeComplex",
690
+ ) -> Any:
691
+ """Plot an interactive 3D geometric realization of the complex.
692
+
693
+ Same geometry as :func:`plot_geometric` — KC vertices are points, KC edges
694
+ are line segments, KC faces are filled triangles — but rendered with
695
+ Plotly for interactive rotation, zoom, and hover inspection.
696
+
697
+ Requires plotly::
698
+
699
+ pip install knowledgecomplex[viz-interactive]
700
+
701
+ Parameters
702
+ ----------
703
+ kc : KnowledgeComplex
704
+
705
+ Returns
706
+ -------
707
+ plotly.graph_objects.Figure
708
+ Call ``.show()`` to display or ``.write_html("file.html")`` to save.
709
+ """
710
+ go = _require_plotly()
711
+
712
+ colors = type_color_map(kc)
713
+ pos = _vertex_positions_3d(kc)
714
+ fig = go.Figure()
715
+
716
+ if not pos:
717
+ fig.update_layout(title="Empty complex")
718
+ return fig
719
+
720
+ # Faces as Mesh3d triangles
721
+ face_ids = kc.skeleton(2) - kc.skeleton(1)
722
+ for fid in face_ids:
723
+ verts = _face_vertices(kc, fid)
724
+ if len(verts) == 3 and all(v in pos for v in verts):
725
+ xs = [pos[v][0] for v in verts]
726
+ ys = [pos[v][1] for v in verts]
727
+ zs = [pos[v][2] for v in verts]
728
+ face_type = kc.element(fid).type
729
+ color = colors.get(face_type, "#999999")
730
+ fig.add_trace(go.Mesh3d(
731
+ x=xs, y=ys, z=zs,
732
+ i=[0], j=[1], k=[2],
733
+ color=color, opacity=0.3,
734
+ hoverinfo="text",
735
+ hovertext=f"{fid} ({face_type})",
736
+ showlegend=False,
737
+ ))
738
+
739
+ # Edges as line segments
740
+ edge_ids = kc.skeleton(1) - kc.skeleton(0)
741
+ for eid in edge_ids:
742
+ boundary = list(kc.boundary(eid))
743
+ if len(boundary) == 2 and all(v in pos for v in boundary):
744
+ p0, p1 = pos[boundary[0]], pos[boundary[1]]
745
+ edge_type = kc.element(eid).type
746
+ color = colors.get(edge_type, "#999999")
747
+ fig.add_trace(go.Scatter3d(
748
+ x=[p0[0], p1[0]], y=[p0[1], p1[1]], z=[p0[2], p1[2]],
749
+ mode="lines",
750
+ line=dict(color=color, width=4),
751
+ hoverinfo="text",
752
+ hovertext=f"{eid} ({edge_type})",
753
+ showlegend=False,
754
+ ))
755
+
756
+ # Vertices as markers
757
+ vertex_ids = [v for v in kc.skeleton(0) if v in pos]
758
+ xs = [pos[v][0] for v in vertex_ids]
759
+ ys = [pos[v][1] for v in vertex_ids]
760
+ zs = [pos[v][2] for v in vertex_ids]
761
+ vtypes = [kc.element(v).type for v in vertex_ids]
762
+ vcolors = [colors.get(t, "#999999") for t in vtypes]
763
+ hover = [f"{vid} ({vt})" for vid, vt in zip(vertex_ids, vtypes)]
764
+
765
+ fig.add_trace(go.Scatter3d(
766
+ x=xs, y=ys, z=zs,
767
+ mode="markers+text",
768
+ marker=dict(size=6, color=vcolors, line=dict(width=1, color="#333333")),
769
+ text=vertex_ids,
770
+ textposition="top center",
771
+ textfont=dict(size=8),
772
+ hoverinfo="text",
773
+ hovertext=hover,
774
+ showlegend=False,
775
+ ))
776
+
777
+ fig.update_layout(
778
+ title=f"Geometric Realization: {kc._schema._namespace}",
779
+ scene=dict(
780
+ xaxis=dict(showgrid=False, zeroline=False, showticklabels=False, title=""),
781
+ yaxis=dict(showgrid=False, zeroline=False, showticklabels=False, title=""),
782
+ zaxis=dict(showgrid=False, zeroline=False, showticklabels=False, title=""),
783
+ ),
784
+ showlegend=False,
785
+ )
786
+ return fig