graph
triwarp.graph
Generic graph algorithms on sparse adjacency matrices and edge lists (mesh-agnostic).
edges_to_csr
edges_to_csr(
node_count: int,
edges: Array2dInt32,
weights: array[float32] | None = None,
) -> wps.BsrMatrix[wp.float32]
Undirected adjacency as a 1x1-block warp.sparse.BsrMatrix (CSR form).
Each undirected edge (a, b) contributes directed entries (a, b) and (b, a).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
node_count
|
int
|
Number of vertices |
required |
edges
|
Array2dInt32
|
|
required |
weights
|
array[float32] | None
|
Length- |
None
|
Returns:
| Type | Description |
|---|---|
BsrMatrix
|
Square |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
RuntimeError
|
If |
See Also
Source code in triwarp/graph.py
30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 | |
connected_component_labels
connected_component_labels(
adjacency: BsrMatrix[Scalar],
) -> wp.array[wp.int32]
Per-node connected-component labels from a sparse adjacency matrix.
Uses ECL-CC (init, single-pass CAS hooking with in-kernel retry, intermediate
pointer jumping) on the CSR structure of adjacency (1x1 BSR blocks): one
init launch, one hook launch, one flatten launch — no host-side convergence
loop. Each label is the smallest node id in its component (hooks always point
the larger root at the smaller one); values are not necessarily contiguous in
0 .. k-1 (compare partitions, not raw ids).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
adjacency
|
BsrMatrix[Scalar]
|
Square undirected adjacency in 1x1-block |
required |
Returns:
| Type | Description |
|---|---|
array[int32]
|
Length |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Source code in triwarp/graph.py
104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 | |
connected_component_labels_from_edges
connected_component_labels_from_edges(
edges: Array2dInt32,
node_count: int | None = None,
*,
validate: bool = True,
) -> wp.array[wp.int32]
Per-node connected-component labels from an undirected edge list.
Builds a CSR adjacency via edges_to_csr and delegates to
connected_component_labels.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
edges
|
Array2dInt32
|
|
required |
node_count
|
int | None
|
Number of nodes |
None
|
validate
|
bool
|
When |
True
|
Returns:
| Type | Description |
|---|---|
array[int32]
|
Length |
Raises:
| Type | Description |
|---|---|
TypeError
|
If |
ValueError
|
If |
Warning
validate=False trades a guard for a synchronization
The range check copies the whole (m, 2) edge buffer to the host, so it costs a device
synchronization on a path that otherwise has none. Pass validate=False only when
the caller produced edges itself and knows the bound holds — an adjacency list from
face_adjacency, say. With an out-of-range index the
unchecked path reads and writes out of bounds rather than raising: the scatter kernels
downstream index a node_count-element buffer by the raw endpoint. On a CUDA device that
lands in device memory; on the CPU device a Warp array is host heap, so it overwrites
glibc's allocator metadata and aborts the process later, somewhere unrelated.
See Also
connected_component_labels
connected_component_parity_from_edges
face_connected_component_labels
trimesh.graph.connected_component_labels
Source code in triwarp/graph.py
171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 | |
connected_component_parity_from_edges
connected_component_parity_from_edges(
edges: Array2dInt32,
signs: array[int32],
node_count: int,
*,
validate: bool = True,
) -> tuple[wp.array[wp.int32], wp.array[wp.int32]]
Component labels plus a Z2 potential satisfying the per-edge parity constraints.
Each edge (a, b) carries a sign in {0, 1} demanding parity[a] ^ parity[b] == sign.
On a component where those constraints are consistent this determines parity uniquely once
the component representative is pinned to 0, and this function returns exactly that — the
discrete analog of a potential function, and the reason it can replace an iterative flood fill.
Extends ECL-CC by packing (parent, parity-to-parent) into a single int32 word, so the
union-find still hooks with one wp.atomic_cas: three launches and no host
synchronization whatever the graph's diameter, where propagating the bits edge by edge costs
one launch per graph level.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
edges
|
Array2dInt32
|
|
required |
signs
|
array[int32]
|
Length- |
required |
node_count
|
int
|
Number of nodes |
required |
validate
|
bool
|
When |
True
|
Returns:
| Name | Type | Description |
|---|---|---|
labels |
array[int32]
|
Length |
parity |
array[int32]
|
Length |
Raises:
| Type | Description |
|---|---|
TypeError
|
If |
ValueError
|
If |
RuntimeError
|
If |
Warning
validate=False trades a guard for a synchronization
With an out-of-range endpoint the unchecked path reads and writes out of bounds
rather than raising: ecl_hook_parity indexes a node_count-element buffer by the
raw endpoint. On a CUDA device that lands in device memory; on the CPU device a Warp
array is host heap, so it overwrites glibc's allocator metadata and aborts the process
later, somewhere unrelated. An out-of-range sign is bounded rather than unsafe — the
hook keeps only its low bit — but the answer is then computed for a different constraint
than the one passed. Pass validate=False only when the caller produced edges and
signs itself and knows both bounds hold.
Notes
A component whose constraints are contradictory (an odd-signed cycle — a Möbius band, in
the orientation application) admits no potential at all. No error is raised: the constraints
along whichever spanning tree the union-find happened to build are satisfied and the remaining
edges are left violated, so a caller that needs to know must re-test the edges against the
returned parity. This is the same best-effort contract a flood fill gives.
Source code in triwarp/graph.py
236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 | |
successor_cycles
successor_cycles(
edges: Array2dInt32,
node_count: int,
*,
validate: bool = True,
) -> tuple[
wp.array[wp.int32],
wp.array[wp.int32],
wp.array[wp.int32],
]
Every cycle of a successor graph in traversal order, packed flat plus per-cycle offsets.
The edges of a successor graph — one in which each node has at most one outgoing edge —
decompose into node-disjoint cycles and chains. This orders every node along its cycle,
following the edge direction from the cycle's smallest node index, with no per-cycle Python
and no per-cycle allocation. The ranking is pointer-jumping (Wyllie's list ranking), so the
work is O(k log L) over k cycle nodes with longest cycle L rather than the
quadratic per-node successor walk. A mesh boundary's oriented edges are the motivating input
(see boundary_loops_batched).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
edges
|
Array2dInt32
|
|
required |
node_count
|
int
|
Number of nodes |
required |
validate
|
bool
|
When |
True
|
Returns:
| Name | Type | Description |
|---|---|---|
flat_cycles |
array[int32]
|
Concatenated ordered node indices of every cycle, on |
offsets |
array[int32]
|
Length- |
cycle_sizes |
array[int32]
|
Length- |
Raises:
| Type | Description |
|---|---|
TypeError
|
If |
ValueError
|
If |
Notes
On malformed input — a node with several in-edges, so two chains merge — the cycle ranks can
collide. Colliding nodes overwrite one slot and leave another at 0, which is a valid node
index, so the result stays in-range rather than returning uninitialized garbage; it is the
caller's job to pass a true successor graph if exact cycles are required.
Source code in triwarp/graph.py
364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 | |
bfs
bfs(
adjacency: BsrMatrix[Scalar], source: int
) -> tuple[
wp.array[wp.int32],
wp.array[wp.int32],
wp.array[wp.int32],
]
Single-source breadth-first search over a sparse CSR adjacency matrix.
The discovery order, parent tree, and distances match
scipy.sparse.csgraph.breadth_first_order exactly when the adjacency columns are sorted
ascending per row (as produced by edges_to_csr). This mirrors
igl::bfs, additionally returning the BFS level of each node.
Two engines, chosen by the observed frontier width rather than by any property of the graph known up front. The traversal starts level-synchronous and parallel, and hands over to a single serial thread as soon as its frontier is both narrow and no longer growing: a level costs the same four fixed-size launches whatever it carries, so once the frontier is a handful of nodes the serial walk is cheaper per node than the launches are per level. On a graph that stays wide the handover never fires; on one whose frontier is narrow from the start (a path) it fires almost immediately. Order-exactness survives it by construction — the parallel path builds the same explicit FIFO the serial one drains, so the serial engine just continues from where the queue got to.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
adjacency
|
BsrMatrix[Scalar]
|
Square undirected adjacency in 1x1-block |
required |
source
|
int
|
Start node, in |
required |
Returns:
| Type | Description |
|---|---|
order
|
|
parents
|
Length |
distances
|
Length |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
See Also
bfs_from_edges
bfs_multi_source
connected_component_labels
scipy.sparse.csgraph.breadth_first_order
Notes
A long, narrow graph (e.g. a thin ribbon) hits a ceiling here
Once the frontier narrows, the traversal hands the rest of the walk to a single serial
thread, so a graph that stays narrow for most of its diameter costs close to one thread's
full pointer-chasing walk over it. This is a genuine memory-throughput limit rather than a
tuning gap: the per-node cost is dominated by dependent loads with no independent work left
to hide behind them, and a cooperative block-synchronized rewrite is slower here because
a narrow frontier's per-level barriers cost more than the work they protect. A host (CPU)
fallback is not used either, since it would mean copying the whole CSR structure and the
result back across the bus, a different contract from the one this function has. So on a
very long, narrow graph this function will not beat
scipy.sparse.csgraph.breadth_first_order, whose single-threaded walk has no such
transfer cost.
On a moderately wide frontier the parallel engine's per-level cost is dominated by its fixed launch overhead rather than by graph size, so narrowing the grid is not a useful lever there; the remaining launches per level are already at the minimum the algorithm's synchronization points allow.
Source code in triwarp/graph.py
527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 | |
bfs_from_edges
bfs_from_edges(
edges: Array2dInt32,
source: int,
node_count: int | None = None,
) -> tuple[
wp.array[wp.int32],
wp.array[wp.int32],
wp.array[wp.int32],
]
Single-source BFS from an undirected edge list.
Builds a CSR adjacency via edges_to_csr and delegates
to bfs.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
edges
|
Array2dInt32
|
|
required |
source
|
int
|
Start node, in |
required |
node_count
|
int | None
|
Number of nodes |
None
|
Returns:
| Type | Description |
|---|---|
tuple[array[int32], array[int32], array[int32]]
|
|
Raises:
| Type | Description |
|---|---|
TypeError
|
If |
ValueError
|
If |
See Also
Source code in triwarp/graph.py
740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 | |
bfs_multi_source
bfs_multi_source(
adjacency: BsrMatrix[Scalar], sources: array[int32]
) -> tuple[wp.array[wp.int32], wp.array[wp.int32]]
Reachable sets for many sources, packed as a CSR buffer.
The reachable set of an unbounded traversal is the source's connected component, so this
labels components once
(connected_component_labels)
and emits each source's component from one label-sorted node array. Source sources[k]
owns neighbors[offsets[k] : offsets[k + 1]], listed with the source itself first and
the remaining nodes in ascending index order. There is no reachable-set capacity limit.
For BFS discovery order, parents, and distances of a single source, use
bfs.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
adjacency
|
BsrMatrix[Scalar]
|
Square undirected adjacency in 1x1-block |
required |
sources
|
array[int32]
|
Length- |
required |
Returns:
| Type | Description |
|---|---|
neighbors
|
Flat |
offsets
|
Length- |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
RuntimeError
|
If |
See Also
Source code in triwarp/graph.py
784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 | |
shortest_path_envelope
shortest_path_envelope(
adjacency: BsrMatrix[float32],
values: array[float32],
max_iterations: int = 0,
) -> wp.array[wp.float32]
Lower every node's value onto the shortest-path envelope min_u (values[u] + d(u, v)).
Two readings of one relaxation, and both are worth knowing because they are the same call:
- A shortest-path distance — Dijkstra's answer. Seed
valueswith0on the source nodes and a number larger than any reachable distance elsewhere, and the result is the weighted multi-source shortest-path distance to the nearest source,dbeing the sum ofadjacency's values along the path, agreeing withscipy.sparse.csgraph.dijkstraup to float32 precision. - A Lipschitz cap. Applied to an arbitrary field it enforces
values[i] <= values[j] + w(i, j)on every edge by lowering values only, so every local minimum of the input survives untouched and only peaks that rise too steeply out of them are shaved down. That is MeshLab'sapply_scalar_saturation_per_vertex(VCGUpdateQuality::VertexSaturate) and the standard way to make a raw scalar usable as a sizing field: an adaptive remesher fed an ungraded target-length field produces a band of bad triangles where the field jumps, and this is the projection that removes the jump while respecting the field's small values.
MeshLab's gradientthr is not a parameter here because it is a property of the graph: its
cap is |p_i - p_j| / gradientthr, so dividing the edge lengths by it when building
adjacency reproduces it exactly, and the same weights then serve any other slope.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
adjacency
|
BsrMatrix[float32]
|
Square undirected adjacency in 1x1-block |
required |
values
|
array[float32]
|
Length- |
required |
max_iterations
|
int
|
Cap on relaxation passes. Each pass propagates one edge further, so the number needed is the
graph diameter of the region that violates the bound. |
0
|
Returns:
| Type | Description |
|---|---|
array[float32]
|
Length- |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
RuntimeError
|
If |
Examples:
Geodesic-ish distance from vertex 0 along the mesh's edges — the mesh recipe for both readings, since the weights are what make the envelope geometric:
n_vertices = int(v.shape[0])
edges = tw.edges.edges_unique(f, n_vertices=n_vertices)[0]
lengths = tw.edges.edges_unique_length(v, f, edges)
adjacency = tw.graph.edges_to_csr(n_vertices, edges, lengths)
seed = wp.full(n_vertices, 1.0e6, dtype=wp.float32, device=v.device)
wp.copy(seed[:1], wp.zeros(1, dtype=wp.float32, device=v.device))
print(float(tw.reduce.max(tw.graph.shortest_path_envelope(adjacency, seed))))
Notes
The answer is Dijkstra's; the method is Bellman-Ford. A priority queue is inherently
serial — it processes one node per pop — so this relaxes every node against its neighbours in
parallel and repeats until nothing improves. Shortest-path distances are unique, so the two
agree on the result; what differs is the cost model — O(diameter) launches over the whole
CSR here against O(E log V) sequential work there. The name is the result, per this
package's naming rule, not the algorithm.
One relaxation kernel per pass, so a pass is a pure function of the previous labels and the
answer does not depend on thread interleaving. The pass count is data-dependent; on CUDA the
whole pass loop runs as one device-side conditional graph (wp.capture_while, as
bfs already does), so the convergence check costs no host readback at
all rather than the one-per-pass a naive early exit would need — see the linalg note on
check_every for why that per-pass sync would otherwise be the expensive part. The CPU
backend, which has no conditional-graph capture, still checks with a plain readback per pass.
For distance across a surface rather than along its edges — shorter, and what "geodesic"
usually means — use heat_geodesic. The edge-graph
distance is an upper bound on it.
See Also
edges_to_csr
bfs
heat_geodesic
triwarp.remesh.isotropic_remesh
scipy.sparse.csgraph.dijkstra
Source code in triwarp/graph.py
894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 | |