Repository: JuliaGeometry/Meshes.jl
Pull request #1183: Refactor `adjsort` into `adjsortperm`
Author: login: halleysfifthinc
Created: 2025-04-07T22:29:14Z | Merged: 2025-05-21T18:13:45Z

***Benchmarks have been updated!***

Using BenchmarkTools, I measure the following speed and memory improvements in `adjsortperm` over `adjsort`.

<details><summary>Benchmarking setup</summary>
<p>

Using the two meshes from [this gist](https://gist.github.com/halleysfifthinc/5b0d45a44f4dd451daca097aa35b9846).

```julia
quads = GeoIO.load("quads.obj").geometry;
mixed = GeoIO.load("quads_tris.obj").geometry;
quads_connec = collect(faces(topology(quads), 2))
mixed_connec = collect(faces(topology(mixed), 2))
```

</p>
</details> 

Comparison of `adjsortperm` (PR) vs `adjsort` (master) for a 72 tri, 36 quad torus:

```julia
BenchmarkTools.TrialJudgement: 
  time:   -97.80% => improvement (5.00% tolerance)
  memory: -98.38% => improvement (1.00% tolerance)
```

<details><summary>Details</summary>
<p>

<details><summary><code>adjsort</code> master</summary>
<p>

```julia
julia> @benchmark adjsort(faces) setup=(faces=shuffle(mixed_connec)) seconds=15 evals=1 samples=15000
```
```
BenchmarkTools.Trial: 15000 samples with 1 evaluation per sample.
 Range (min … max):  788.378 μs …  21.293 ms  ┊ GC (min … max): 0.00% … 95.07%
 Time  (median):     868.029 μs               ┊ GC (median):    0.00%
 Time  (mean ± σ):   926.702 μs ± 501.305 μs  ┊ GC (mean ± σ):  4.58% ±  7.58%

   ▁▂▃▄▆▇██▇▆▅▄▃▃▂▂▂▁▁▁▁▁▁▁     ▁▂▁                             ▃
  ▇█████████████████████████████████▆▆▆▆▅▅▆▅▆▇▆▆▆▆▅▆▆▄▄▅▄▄▁▅▁▁▃ █
  788 μs        Histogram: log(frequency) by time       1.38 ms <

 Memory estimate: 451.41 KiB, allocs estimate: 8673.
```

</p>
</details> 

<details><summary><code>adjsortperm</code> PR</summary>
<p>

```julia
julia> @benchmark adjsortperm(faces) setup=(faces=shuffle(mixed_connec)) seconds=15 evals=1 samples=15000
```
```
BenchmarkTools.Trial: 15000 samples with 1 evaluation per sample.
 Range (min … max):  14.086 μs …  9.780 ms  ┊ GC (min … max): 0.00% … 99.40%
 Time  (median):     19.307 μs              ┊ GC (median):    0.00%
 Time  (mean ± σ):   20.389 μs ± 79.752 μs  ┊ GC (mean ± σ):  3.18% ±  0.81%

                ▁▄▄▅▇███▇▆▅▅▄▃▁▁                               
  ▁▁▁▁▁▁▂▂▂▃▃▅▆██████████████████▆▆▅▅▄▄▄▄▃▃▃▂▂▂▂▂▂▂▁▁▁▁▁▁▁▁▁▁ ▄
  14.1 μs         Histogram: frequency by time        27.7 μs <

 Memory estimate: 7.32 KiB, allocs estimate: 25.
```

</p>
</details>

</p>
</details> 

Comparison of `adjsortperm` (PR) vs `adjsort` (master) for a 72 quad torus:

```julia
BenchmarkTools.TrialJudgement: 
  time:   -91.12% => improvement (5.00% tolerance)
  memory: -95.41% => improvement (1.00% tolerance)
```

<details><summary>Details</summary>
<p>

<details><summary><code>adjsort</code> master</summary>
<p>

```julia
julia> @benchmark adjsort(faces) setup=(faces=shuffle(quads_connec)) seconds=15 evals=1 samples=15000
```
```
BenchmarkTools.Trial: 15000 samples with 1 evaluation per sample.
 Range (min … max):  117.852 μs …  13.738 ms  ┊ GC (min … max): 0.00% … 97.99%
 Time  (median):     125.267 μs               ┊ GC (median):    0.00%
 Time  (mean ± σ):   144.715 μs ± 270.531 μs  ┊ GC (mean ± σ):  8.43% ±  4.73%

  ▂▇██▇▆▅▄▄▄▃▃▂▁           ▁▂▂▁▁▁ ▁▂▂▂▂▁▁                       ▃
  ████████████████▇▇▇▇▆▅▇▇███████████████▇▇▇▇▆▅▅▄▅▅▃▁▃▃▁▃▅▆▆▅▆▆ █
  118 μs        Histogram: log(frequency) by time        242 μs <

 Memory estimate: 147.16 KiB, allocs estimate: 2691.
```

</p>
</details> 

<details><summary><code>adjsortperm</code> PR</summary>
<p>

```julia
julia> @benchmark adjsortperm(faces) setup=(faces=shuffle(quads_connec)) seconds=15 evals=1 samples=15000
```
```
BenchmarkTools.Trial: 15000 samples with 1 evaluation per sample.
 Range (min … max):   9.238 μs …  6.570 ms  ┊ GC (min … max): 0.00% … 99.14%
 Time  (median):     12.273 μs              ┊ GC (median):    0.00%
 Time  (mean ± σ):   12.852 μs ± 53.564 μs  ┊ GC (mean ± σ):  3.38% ±  0.81%

                ▁▂▅▆▆▇▆█▆▆▅▅▃▂▁                                
  ▁▁▁▁▁▁▁▂▂▃▃▄▆▇███████████████▇▇▅▅▄▃▂▂▂▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ▃
  9.24 μs         Histogram: frequency by time        17.5 μs <

 Memory estimate: 6.76 KiB, allocs estimate: 24.
```

</p>
</details> 

</p>
</details> 


Diff:
diff --git a/src/topologies/halfedge.jl b/src/topologies/halfedge.jl
index 5cd4a81e1..f7a00357d 100644
--- a/src/topologies/halfedge.jl
+++ b/src/topologies/halfedge.jl
@@ -144,9 +144,8 @@ function HalfEdgeTopology(elems::AbstractVector{<:Connectivity}; sort=true)
 
   # sort elements to make sure that they
   # are traversed in adjacent-first order
-  elemsort = sort ? adjsort(elems) : elems
-  eleminds = sort ? indexin(elemsort, elems) : 1:length(elems)
-  adjelems::Vector{Vector{Int}} = map(collect ∘ indices, elemsort)
+  eleminds = sort ? adjsortperm(elems) : eachindex(elems)
+  adjelems::Vector{Vector{Int}} = map(collect ∘ indices ∘ Base.Fix1(getindex, elems), eleminds)
 
   # start assuming that all elements are
   # oriented consistently (e.g. CCW)
@@ -178,7 +177,7 @@ function HalfEdgeTopology(elems::AbstractVector{<:Connectivity}; sort=true)
       elem = eleminds[other]
       inds = adjelems[other]
       n = length(inds)
-      if anyhalf(inds, half4pair) || disconnected
+      if anyhalf(half4pair, inds) || disconnected
         # at least one half-edge has been found, so we can assess
         # the orientation w.r.t. previously added elements/edges
         added = true
@@ -191,7 +190,7 @@ function HalfEdgeTopology(elems::AbstractVector{<:Connectivity}; sort=true)
 
       # if the other element is already claimed by any half-edge
       # then the element must be reversed before further updates
-      isreversed[other] = anyhalfclaimed(inds, half4pair)
+      isreversed[other] = anyhalfclaimed(half4pair, inds)
 
       # insert half-edges in consistent orientation
       if isreversed[other]
@@ -332,45 +331,87 @@ Base.convert(::Type{HalfEdgeTopology}, t::Topology) = HalfEdgeTopology(collect(e
 # HELPER FUNCTIONS
 # -----------------
 
-function adjsort(elems::AbstractVector{<:Connectivity})
-  # initialize list of adjacent elements
-  # with first element from original list
-  list = indices.(elems)
-  adjs = Tuple[popfirst!(list)]
-
-  # the loop will terminate if the mesh
-  # is manifold, and that is always true
-  # with half-edge topology
-  while !isempty(list)
-    # lookup all elements that share at least
-    # one vertex with the last adjacent element
-    found = false
-    vinds = last(adjs)
-    for i in vinds
-      einds = findall(e -> i ∈ e, list)
-      if !isempty(einds)
-        # lookup all elements that share at
-        # least two vertices (i.e. edge)
-        for j in sort(einds, rev=true)
-          if length(vinds ∩ list[j]) > 1
-            found = true
-            push!(adjs, popat!(list, j))
-          end
-        end
+# permutation of elements in adjacent-first order
+function adjsortperm(elems::AbstractVector{<:Connectivity})
+  reduce(vcat, conneccomps(elems))
+end
+
+# connected components from list of elements
+function conneccomps(elems::AbstractVector{<:Connectivity})
+  # initialize list of connected components
+  comps = [[firstindex(elems)]]
+
+  # initialize list of seen vertices
+  seen = Set{Int}()
+  for v in indices(first(elems))
+    push!(seen, v)
+  end
+
+  # remaining elements to process
+  remaining = collect(eachindex(elems)[2:end])
+
+  added = false
+  while !isempty(remaining)
+    iter = 1
+    while iter ≤ length(remaining)
+      elem = elems[remaining[iter]]
+
+      # manually union-split two most common polytopes
+      # for type stability and maximum performance
+      isadjacent = if elem isa Connectivity{Triangle,3}
+        adjelem!(seen, elem)
+      elseif elem isa Connectivity{Quadrangle,4}
+        adjelem!(seen, elem)
+      else
+        adjelem!(seen, elem)
+      end
+
+      if isadjacent
+        push!(last(comps), popat!(remaining, iter))
+        added = true
+      else
+        iter += 1
       end
     end
 
-    if !found && !isempty(list)
-      # we are done with this connected component
-      # pop a new element from the original list
-      push!(adjs, popfirst!(list))
+    if added
+      # new vertices were "seen" while iterating `remaining`, so
+      # we need to iterate again because there may be elements
+      # which are now adjacent with the newly "seen" vertices
+      added = false
+    elseif !isempty(remaining)
+      # there are more elements, but none are adjacent to
+      # previously seen elements; pop a new element from
+      # the original list to start a new connected component
+      push!(comps, Int[])
+      push!(last(comps), popfirst!(remaining))
+
+      # a disconnected component means that ≥n-1 vertices in
+      # the newest element haven't been "seen"; its possible
+      # the new component is connected by a single vertex
+      for v in indices(elems[last(last(comps))])
+        push!(seen, v)
+      end
     end
   end
 
-  connect.(adjs)
+  comps
+end
+
+# update seen vertices if there are ≥2 common indices
+function adjelem!(seen, elem)
+  inds = indices(elem)
+  if count(∈(seen), inds) > 1
+    for v in inds
+      push!(seen, v)
+    end
+    return true
+  end
+  return false
 end
 
-function anyhalf(inds, half4pair)
+# true if the half-edges already contain the indices
+function anyhalf(half4pair, inds)
   n = length(inds)
   for i in eachindex(inds)
     uv = (inds[i], inds[mod1(i + 1, n)])
@@ -381,7 +422,8 @@ function anyhalf(inds, half4pair)
   return false
 end
 
-function anyhalfclaimed(inds, half4pair)
+# true if the half-edges already have elements assigned to the indices
+function anyhalfclaimed(half4pair, inds)
   n = length(inds)
   for i in eachindex(inds)
     uv = (inds[i], inds[mod1(i + 1, n)])
@@ -392,5 +434,6 @@ function anyhalfclaimed(inds, half4pair)
   return false
 end
 
+# integer addition mod1
 add0(i, n) = i
 add1(i, n) = mod1(i + 1, n)
diff --git a/test/topologies.jl b/test/topologies.jl
index 45ddc6c95..4d040de29 100644
--- a/test/topologies.jl
+++ b/test/topologies.jl
@@ -512,8 +512,8 @@ end
   g = GridTopology(10, 10)
   t = convert(HalfEdgeTopology, g)
   @test t[begin] == connect((1, 2, 13, 12), Quadrangle)
-  @test t[end] == connect((120, 109, 110, 121), Quadrangle)
-  @test t[10] == connect((22, 21, 10, 11), Quadrangle)
+  @test t[end] == connect((109, 110, 121, 120), Quadrangle)
+  @test t[10] == connect((21, 10, 11, 22), Quadrangle)
   @test length(t) == 100
   @test eltype(t) == Connectivity{Quadrangle,4}
   for e in t
diff --git a/test/toporelations.jl b/test/toporelations.jl
index 1afbe48e9..49aaf48ad 100644
--- a/test/toporelations.jl
+++ b/test/toporelations.jl
@@ -488,26 +488,26 @@ end
   @test ∂(4) == (1, 5, 3)
   ∂ = Boundary{2,1}(t)
   @test ∂(1) == (1, 2, 3, 4)
-  @test ∂(2) == (2, 7, 8)
-  @test ∂(3) == (3, 8, 9, 5)
-  @test ∂(4) == (4, 5, 6)
+  @test ∂(2) == (2, 5, 6)
+  @test ∂(3) == (3, 6, 7, 8)
+  @test ∂(4) == (4, 8, 9)
   ∂ = Boundary{1,0}(t)
   @test ∂(1) == (1, 2)
   @test ∂(2) == (2, 6)
   @test ∂(3) == (6, 5)
   @test ∂(4) == (5, 1)
-  @test ∂(5) == (5, 3)
-  @test ∂(6) == (3, 1)
-  @test ∂(7) == (2, 4)
-  @test ∂(8) == (4, 6)
-  @test ∂(9) == (4, 3)
+  @test ∂(5) == (2, 4)
+  @test ∂(6) == (4, 6)
+  @test ∂(7) == (4, 3)
+  @test ∂(8) == (3, 5)
+  @test ∂(9) == (3, 1)
   𝒞 = Coboundary{0,1}(t)
-  @test 𝒞(1) == (1, 4, 6)
-  @test 𝒞(2) == (7, 2, 1)
-  @test 𝒞(3) == (6, 5, 9)
-  @test 𝒞(4) == (9, 8, 7)
-  @test 𝒞(5) == (3, 5, 4)
-  @test 𝒞(6) == (2, 8, 3)
+  @test 𝒞(1) == (1, 4, 9)
+  @test 𝒞(2) == (5, 2, 1)
+  @test 𝒞(3) == (9, 8, 7)
+  @test 𝒞(4) == (7, 6, 5)
+  @test 𝒞(5) == (3, 8, 4)
+  @test 𝒞(6) == (2, 6, 3)
   𝒞 = Coboundary{0,2}(t)
   @test 𝒞(1) == (1, 4)
   @test 𝒞(2) == (2, 1)
@@ -520,11 +520,11 @@ end
   @test 𝒞(2) == (1, 2)
   @test 𝒞(3) == (1, 3)
   @test 𝒞(4) == (1, 4)
-  @test 𝒞(5) == (4, 3)
-  @test 𝒞(6) == (4,)
-  @test 𝒞(7) == (2,)
-  @test 𝒞(8) == (2, 3)
-  @test 𝒞(9) == (3,)
+  @test 𝒞(5) == (2,)
+  @test 𝒞(6) == (2, 3)
+  @test 𝒞(7) == (3,)
+  @test 𝒞(8) == (3, 4)
+  @test 𝒞(9) == (4,)
   𝒜 = Adjacency{0}(t)
   @test 𝒜(1) == (2, 5, 3)
   @test 𝒜(2) == (4, 6, 1)


Reviews:
user: login: juliohm
body: Hi @halleysfifthinc ! Thank you for trying to improve the performance of this constructor. That is super welcome.

Could you please comment on the need to enforce type annotations? Do they really contribute to the performance gains you are seeing? If we could avoid type annotations as much as possible, that would be ideal.

I left some review comments in a first round of review. Happy to review the other parts of the code later.
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-08T11:15:50Z
user: login: halleysfifthinc
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T17:11:54Z
user: login: juliohm
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-08T17:15:15Z
user: login: halleysfifthinc
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T17:16:40Z
user: login: halleysfifthinc
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T17:16:53Z
user: login: juliohm
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-08T17:34:05Z
user: login: halleysfifthinc
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T18:03:37Z
user: login: halleysfifthinc
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T21:01:59Z
user: login: juliohm
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-09T18:17:31Z
user: login: juliohm
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-11T12:46:08Z
user: login: halleysfifthinc
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T16:18:14Z
user: login: juliohm
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-15T14:04:08Z
user: login: halleysfifthinc
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T16:26:01Z
user: login: juliohm
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-15T16:33:30Z
user: login: halleysfifthinc
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T19:25:06Z
user: login: juliohm
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-15T19:54:24Z
user: login: juliohm
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-21T13:19:49Z
user: login: juliohm
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-21T13:21:13Z
user: login: juliohm
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-21T13:23:35Z
user: login: halleysfifthinc
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T16:24:55Z
user: login: halleysfifthinc
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T16:27:18Z
user: login: juliohm
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-21T17:02:55Z
user: login: juliohm
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-21T17:03:20Z
user: login: juliohm
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-21T17:04:02Z
user: login: halleysfifthinc
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T17:44:14Z
user: login: juliohm
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-21T17:47:53Z
user: login: juliohm
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-21T17:51:36Z
user: login: halleysfifthinc
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T17:52:48Z
user: login: juliohm
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-21T17:53:46Z
user: login: juliohm
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-21T17:54:15Z
user: login: halleysfifthinc
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T17:55:05Z
user: login: halleysfifthinc
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T18:00:42Z
user: login: juliohm
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-21T18:03:07Z
user: login: juliohm
body: Another amazing speedup 💯 🚀 
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-21T18:11:07Z

Review comments:
diff_hunk: @@ -253,7 +251,7 @@ function adjsort(elems::AbstractVector{<:Connectivity})
     end
   end
 
-  connect.(adjs)
+  return map(connect, adjs)
path: src/topologies/halfedge.jl
user: login: juliohm
body: ```suggestion
  map(connect, adjs)
```
created_at: 2025-04-08T11:05:14Z
author_association: MEMBER
diff_hunk: @@ -134,7 +133,7 @@ function HalfEdgeTopology(elems::AbstractVector{<:Connectivity}; sort=true)
 
   # sort elements to make sure that they
   # are traversed in adjacent-first order
-  adjelems = sort ? adjsort(elems) : elems
+  adjelems = sort ? adjsort(elems)::typeof(elems) : elems
path: src/topologies/halfedge.jl
user: login: juliohm
body: ```suggestion
  adjelems = sort ? adjsort(elems) : elems
```
created_at: 2025-04-08T11:08:26Z
author_association: MEMBER
diff_hunk: @@ -144,7 +143,7 @@ function HalfEdgeTopology(elems::AbstractVector{<:Connectivity}; sort=true)
   # initialize with first element
   half4pair = Dict{Tuple{Int,Int},HalfEdge}()
   elem = first(adjelems)
-  inds = collect(indices(elem))
+  inds::Vector{Int} = collect(indices(elem))
path: src/topologies/halfedge.jl
user: login: juliohm
body: ```suggestion
  inds = collect(indices(elem))
```
created_at: 2025-04-08T11:08:38Z
author_association: MEMBER
diff_hunk: @@ -184,8 +183,8 @@ function HalfEdgeTopology(elems::AbstractVector{<:Connectivity}; sort=true)
 
   # add missing pointers
   for (e, elem) in Iterators.enumerate(adjelems)
-    inds = CCW[e] ? indices(elem) : reverse(indices(elem))
-    v = CircularVector(collect(inds))
+    inds = CCW[e] ? collect(indices(elem)) : reverse(collect(indices(elem)))
+    v = CircularVector(inds)
path: src/topologies/halfedge.jl
user: login: juliohm
body: Does this change the performance? The original code is more readable.
created_at: 2025-04-08T11:11:25Z
author_association: MEMBER
diff_hunk: @@ -221,8 +219,9 @@ end
 function adjsort(elems::AbstractVector{<:Connectivity})
   # initialize list of adjacent elements
   # with first element from original list
-  list = indices.(elems)
-  adjs = Tuple[popfirst!(list)]
+  list = map(indices, elems)
+  adjs = similar(list, 0)
+  push!(adjs, popfirst!(list))
path: src/topologies/halfedge.jl
user: login: juliohm
body: Nice code pattern. First time I see `similar` used for empty initialization.
created_at: 2025-04-08T11:13:24Z
author_association: MEMBER
diff_hunk: @@ -232,17 +231,16 @@ function adjsort(elems::AbstractVector{<:Connectivity})
     # one vertex with the last adjacent element
     found = false
     vinds = last(adjs)
-    for i in vinds
-      einds = findall(e -> i ∈ e, list)
-      if !isempty(einds)
-        # lookup all elements that share at
-        # least two vertices (i.e. edge)
-        for j in sort(einds, rev=true)
-          if length(vinds ∩ list[j]) > 1
-            found = true
-            push!(adjs, popat!(list, j))
-          end
-        end
+    for i::Int in vinds
path: src/topologies/halfedge.jl
user: login: juliohm
body: ```suggestion
    for i in vinds
```
created_at: 2025-04-08T11:13:42Z
author_association: MEMBER
diff_hunk: @@ -134,7 +133,7 @@ function HalfEdgeTopology(elems::AbstractVector{<:Connectivity}; sort=true)
 
   # sort elements to make sure that they
   # are traversed in adjacent-first order
-  adjelems = sort ? adjsort(elems) : elems
+  adjelems = sort ? adjsort(elems)::typeof(elems) : elems
path: src/topologies/halfedge.jl
user: login: halleysfifthinc
body: The compiler struggles with the return type of `adjsort` when `elems` is a mix of `<:Ngon` connectivities (Cthulhu shows the return type as just `AbstractVector`, but we know that the return type of `adjsort` should be the same as the type of `elems`.
created_at: 2025-04-08T17:11:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -134,7 +133,7 @@ function HalfEdgeTopology(elems::AbstractVector{<:Connectivity}; sort=true)
 
   # sort elements to make sure that they
   # are traversed in adjacent-first order
-  adjelems = sort ? adjsort(elems) : elems
+  adjelems = sort ? adjsort(elems)::typeof(elems) : elems
path: src/topologies/halfedge.jl
user: login: juliohm
body: What if we replace `connect.(adjs)` by `map(connect, adjs)` in the last line of `adjsort`? Does it help inference?
created_at: 2025-04-08T17:15:15Z
author_association: MEMBER
diff_hunk: @@ -144,7 +143,7 @@ function HalfEdgeTopology(elems::AbstractVector{<:Connectivity}; sort=true)
   # initialize with first element
   half4pair = Dict{Tuple{Int,Int},HalfEdge}()
   elem = first(adjelems)
-  inds = collect(indices(elem))
+  inds::Vector{Int} = collect(indices(elem))
path: src/topologies/halfedge.jl
user: login: halleysfifthinc
body: `indices` is type-unstable (since the length is part of the `Tuple` type), so the `indices` return type when `elem` can be different `Ngon` subtypes is fairly wide (`NTuple{N, Int64} where N`) which leads to the `inds` type estimate(?) being `Union{Vector{Union{}}, Vector{Int64}}`. However, we know that N will never be less than 1, so the return type should always be `Vector{Int}`.  Immediately `collect`ing keeps the same type for `inds` throughout the function, and the left-hand-side type annotation is stronger (makes a non `Vector{Int}` assignment to `inds` an error).

(There's no obvious way to annotate the `indices` return type any more specifically due to its inherent type instability.)
created_at: 2025-04-08T17:16:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -184,8 +183,8 @@ function HalfEdgeTopology(elems::AbstractVector{<:Connectivity}; sort=true)
 
   # add missing pointers
   for (e, elem) in Iterators.enumerate(adjelems)
-    inds = CCW[e] ? indices(elem) : reverse(indices(elem))
-    v = CircularVector(collect(inds))
+    inds = CCW[e] ? collect(indices(elem)) : reverse(collect(indices(elem)))
+    v = CircularVector(inds)
path: src/topologies/halfedge.jl
user: login: halleysfifthinc
body: This is necessary to go along with the earlier `inds` type-annotation, but also improves the type stability here even without the earlier annotation, as explained above.
created_at: 2025-04-08T17:16:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -134,7 +133,7 @@ function HalfEdgeTopology(elems::AbstractVector{<:Connectivity}; sort=true)
 
   # sort elements to make sure that they
   # are traversed in adjacent-first order
-  adjelems = sort ? adjsort(elems) : elems
+  adjelems = sort ? adjsort(elems)::typeof(elems) : elems
path: src/topologies/halfedge.jl
user: login: juliohm
body: Are you saying that the inference issue occurs even with `map`?
created_at: 2025-04-08T17:34:05Z
author_association: MEMBER
diff_hunk: @@ -232,17 +231,16 @@ function adjsort(elems::AbstractVector{<:Connectivity})
     # one vertex with the last adjacent element
     found = false
     vinds = last(adjs)
-    for i in vinds
-      einds = findall(e -> i ∈ e, list)
-      if !isempty(einds)
-        # lookup all elements that share at
-        # least two vertices (i.e. edge)
-        for j in sort(einds, rev=true)
-          if length(vinds ∩ list[j]) > 1
-            found = true
-            push!(adjs, popat!(list, j))
-          end
-        end
+    for i::Int in vinds
path: src/topologies/halfedge.jl
user: login: halleysfifthinc
body: Just removed this annotation for an earlier/superseding annotation on `adjs`.
created_at: 2025-04-08T18:03:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -134,7 +133,7 @@ function HalfEdgeTopology(elems::AbstractVector{<:Connectivity}; sort=true)
 
   # sort elements to make sure that they
   # are traversed in adjacent-first order
-  adjelems = sort ? adjsort(elems) : elems
+  adjelems = sort ? adjsort(elems)::typeof(elems) : elems
path: src/topologies/halfedge.jl
user: login: halleysfifthinc
body: Yes; the inferred return type of `adjsort` is still poor even with `map`.
created_at: 2025-04-08T21:01:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -98,11 +98,23 @@ struct HalfEdgeTopology <: Topology
 end
 
 function HalfEdgeTopology(halves::AbstractVector{Tuple{HalfEdge,HalfEdge}})
-  # make sure that first half-edge is in the interior
-  ordered = [isnothing(h₁.elem) ? (h₂, h₁) : (h₁, h₂) for (h₁, h₂) in halves]
+  halfedges = Vector{HalfEdge}(undef, 2 * length(halves))
+  edge4pair = Dict{Tuple{Int,Int},Int}()
+  sizehint!(edge4pair, length(halves))
 
   # flatten pairs of half-edges into a vector
-  halfedges = [half for pair in ordered for half in pair]
+  for (i, (h₁, h₂)) in enumerate(halves)
+    # make sure that first half-edge is in the interior
+    (h₁, h₂) = isnothing(h₁.elem) ? (h₂, h₁) : (h₁, h₂)
+
+    j = (i - 1) * 2 + 1
+    halfedges[j] = h₁
+    halfedges[j + 1] = h₂
+
+    # map pair of vertices to an edge (i.e. two halves)
+    u, v = h₁.head, h₂.head
+    edge4pair[minmax(u, v)] = i
+  end
path: src/topologies/halfedge.jl
user: login: juliohm
body: Nice! This change replaces 3 loops by 1 👏🏽 
created_at: 2025-04-09T18:17:30Z
author_association: MEMBER
diff_hunk: @@ -134,7 +133,7 @@ function HalfEdgeTopology(elems::AbstractVector{<:Connectivity}; sort=true)
 
   # sort elements to make sure that they
   # are traversed in adjacent-first order
-  adjelems = sort ? adjsort(elems) : elems
+  adjelems = sort ? adjsort(elems)::typeof(elems) : elems
path: src/topologies/halfedge.jl
user: login: juliohm
body: Hi @halleysfifthinc , after looking into the code more carefully, I believe we can completely erase the type instabilities if we refactor the `adjsort` function to return a `Vector{Vector{Int}}` instead of a `Vector{<:Connectivity}`. The algorithm that follows only consumes the indices.

In the case where `sort=false`, we also need to process the unsorted `elems` into a `Vector{Vector{Int}}` using a simple `map(collect ∘ indices, elems)` call.

I believe that after that everything will be type stable.
created_at: 2025-04-11T12:46:08Z
author_association: MEMBER
diff_hunk: @@ -134,7 +133,7 @@ function HalfEdgeTopology(elems::AbstractVector{<:Connectivity}; sort=true)
 
   # sort elements to make sure that they
   # are traversed in adjacent-first order
-  adjelems = sort ? adjsort(elems) : elems
+  adjelems = sort ? adjsort(elems)::typeof(elems) : elems
path: src/topologies/halfedge.jl
user: login: halleysfifthinc
body: :+1:  I will look into that approach.
created_at: 2025-04-11T16:18:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -208,52 +206,60 @@ function HalfEdgeTopology(elems::AbstractVector{<:Connectivity}; sort=true)
   halves = Vector{Tuple{HalfEdge,HalfEdge}}()
   visited = Set{Tuple{Int,Int}}()
   for ((u, v), he) in half4pair
-    if (u, v) ∉ visited
+    if minmax(u, v) ∉ visited
       push!(halves, (he, he.half))
-      push!(visited, (u, v))
-      push!(visited, (v, u))
+      push!(visited, minmax(u, v))
     end
   end
 
   HalfEdgeTopology(halves)
 end
 
-function adjsort(elems::AbstractVector{<:Connectivity})
+function adjsortperm(elems::AbstractVector{<:Connectivity})
   # initialize list of adjacent elements
   # with first element from original list
-  list = indices.(elems)
-  adjs = Tuple[popfirst!(list)]
-
-  # the loop will terminate if the mesh
-  # is manifold, and that is always true
-  # with half-edge topology
-  while !isempty(list)
-    # lookup all elements that share at least
-    # one vertex with the last adjacent element
-    found = false
-    vinds = last(adjs)
-    for i in vinds
-      einds = findall(e -> i ∈ e, list)
-      if !isempty(einds)
-        # lookup all elements that share at
-        # least two vertices (i.e. edge)
-        for j in sort(einds, rev=true)
-          if length(vinds ∩ list[j]) > 1
-            found = true
-            push!(adjs, popat!(list, j))
-          end
+  einds = Int[1]
+  sizehint!(einds, length(elems))
+
+  # remaining list of elements to process
+  oinds = collect(2:length(elems))
+
+  # lookup all elements that share at least
+  # two vertices (i.e., edge) with the last
+  # adjacent element
+  found = false
+  while !isempty(oinds)
+    lelem = elems[last(einds)]
+    vinds = indices(lelem)
+    for v in vinds
+      # vertices that are not `v`
+      v! = filter(!=(v), vinds)
+
+      # iteratively test other elements
+      iter = 1
+      while iter ≤ length(oinds)
+        oelem = elems[oinds[iter]]
+        vinds′ = indices(oelem)
+        if any(==(v), vinds′) && !isdisjoint(v!, vinds′)
+          found = true
+          push!(einds, popat!(oinds, iter))
+        else
+          found = false
+          iter += 1
+          continue
path: src/topologies/halfedge.jl
user: login: juliohm
body: This `continue` has effect? Given that it is the last statement in the `while` loop, I think we can erase it?
created_at: 2025-04-15T14:04:07Z
author_association: MEMBER
diff_hunk: @@ -220,49 +216,50 @@ function HalfEdgeTopology(elems::AbstractVector{<:Connectivity}; sort=true)
 end
 
 function adjsortperm(elems::AbstractVector{<:Connectivity})
-  listi = collect(eachindex(elems)[2:end])
   # initialize list of adjacent elements
   # with first element from original list
-  adjs = Int[1]
-  sizehint!(adjs, length(elems))
+  einds = Int[1]
+  sizehint!(einds, length(elems))
+
+  # remaining list of elements to process
+  oinds = collect(2:length(elems))
path: src/topologies/halfedge.jl
user: login: halleysfifthinc
body: This is less safe than `collect(eachindex(elems)[2:end])`, because the `elems::AbstractVector` type parameter allows a vector with a `firstindex !== 1`.

```suggestion
  oinds = collect(eachindex(elems)[2:end])
```

created_at: 2025-04-15T16:26:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -220,49 +216,50 @@ function HalfEdgeTopology(elems::AbstractVector{<:Connectivity}; sort=true)
 end
 
 function adjsortperm(elems::AbstractVector{<:Connectivity})
-  listi = collect(eachindex(elems)[2:end])
   # initialize list of adjacent elements
   # with first element from original list
-  adjs = Int[1]
-  sizehint!(adjs, length(elems))
+  einds = Int[1]
+  sizehint!(einds, length(elems))
+
+  # remaining list of elements to process
+  oinds = collect(2:length(elems))
path: src/topologies/halfedge.jl
user: login: juliohm
body: Isn't the algorithm written in terms of a local enumeration already? We initialize `einds = Int[1]` to mean the first element in the vector of elements, and then enumerate the remaining elements as `2:N`? Later on, we index `oinds[iter]` where `iter` starts as `1` too.
created_at: 2025-04-15T16:33:30Z
author_association: MEMBER
diff_hunk: @@ -220,49 +216,50 @@ function HalfEdgeTopology(elems::AbstractVector{<:Connectivity}; sort=true)
 end
 
 function adjsortperm(elems::AbstractVector{<:Connectivity})
-  listi = collect(eachindex(elems)[2:end])
   # initialize list of adjacent elements
   # with first element from original list
-  adjs = Int[1]
-  sizehint!(adjs, length(elems))
+  einds = Int[1]
+  sizehint!(einds, length(elems))
+
+  # remaining list of elements to process
+  oinds = collect(2:length(elems))
path: src/topologies/halfedge.jl
user: login: halleysfifthinc
body: Ah good catch, properly `einds` should be initialized as `einds = Int[firstindex(elems)]` along with the above. All the other indexing is into `oinds` which is a regular `Vector` (i.e. starts at `1`) or indexing into `elems` using (ultimately) values from `oinds` which, with the above, is filled with guaranteed inbounds values produced by `eachindex`.
created_at: 2025-04-15T19:25:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -220,49 +216,50 @@ function HalfEdgeTopology(elems::AbstractVector{<:Connectivity}; sort=true)
 end
 
 function adjsortperm(elems::AbstractVector{<:Connectivity})
-  listi = collect(eachindex(elems)[2:end])
   # initialize list of adjacent elements
   # with first element from original list
-  adjs = Int[1]
-  sizehint!(adjs, length(elems))
+  einds = Int[1]
+  sizehint!(einds, length(elems))
+
+  # remaining list of elements to process
+  oinds = collect(2:length(elems))
path: src/topologies/halfedge.jl
user: login: juliohm
body: Good point. Feel free to adjust 👍🏽 
created_at: 2025-04-15T19:54:24Z
author_association: MEMBER
diff_hunk: @@ -332,45 +331,84 @@ Base.convert(::Type{HalfEdgeTopology}, t::Topology) = HalfEdgeTopology(collect(e
 # HELPER FUNCTIONS
 # -----------------
 
-function adjsort(elems::AbstractVector{<:Connectivity})
-  # initialize list of adjacent elements
-  # with first element from original list
-  list = indices.(elems)
-  adjs = Tuple[popfirst!(list)]
-
-  # the loop will terminate if the mesh
-  # is manifold, and that is always true
-  # with half-edge topology
-  while !isempty(list)
-    # lookup all elements that share at least
-    # one vertex with the last adjacent element
-    found = false
-    vinds = last(adjs)
-    for i in vinds
-      einds = findall(e -> i ∈ e, list)
-      if !isempty(einds)
-        # lookup all elements that share at
-        # least two vertices (i.e. edge)
-        for j in sort(einds, rev=true)
-          if length(vinds ∩ list[j]) > 1
-            found = true
-            push!(adjs, popat!(list, j))
-          end
-        end
+# permutation of elements in adjacent-first order
+function adjsortperm(elems::AbstractVector{<:Connectivity})
+  reduce(vcat, conneccomps(elems))
+end
+
+# connected components from list of elements
+function conneccomps(elems::AbstractVector{<:Connectivity})
+  # initialize list of connected components
+  comps = [Int[firstindex(elems)]]
path: src/topologies/halfedge.jl
user: login: juliohm
body: @halleysfifthinc could we drop `Int` here or Cthulu.jl is telling you that this is important?
created_at: 2025-05-21T13:19:48Z
author_association: MEMBER
diff_hunk: @@ -332,45 +331,84 @@ Base.convert(::Type{HalfEdgeTopology}, t::Topology) = HalfEdgeTopology(collect(e
 # HELPER FUNCTIONS
 # -----------------
 
-function adjsort(elems::AbstractVector{<:Connectivity})
-  # initialize list of adjacent elements
-  # with first element from original list
-  list = indices.(elems)
-  adjs = Tuple[popfirst!(list)]
-
-  # the loop will terminate if the mesh
-  # is manifold, and that is always true
-  # with half-edge topology
-  while !isempty(list)
-    # lookup all elements that share at least
-    # one vertex with the last adjacent element
-    found = false
-    vinds = last(adjs)
-    for i in vinds
-      einds = findall(e -> i ∈ e, list)
-      if !isempty(einds)
-        # lookup all elements that share at
-        # least two vertices (i.e. edge)
-        for j in sort(einds, rev=true)
-          if length(vinds ∩ list[j]) > 1
-            found = true
-            push!(adjs, popat!(list, j))
-          end
-        end
+# permutation of elements in adjacent-first order
+function adjsortperm(elems::AbstractVector{<:Connectivity})
+  reduce(vcat, conneccomps(elems))
+end
+
+# connected components from list of elements
+function conneccomps(elems::AbstractVector{<:Connectivity})
+  # initialize list of connected components
+  comps = [Int[firstindex(elems)]]
+
+  # initialize list of seen vertices
+  seen = Set(indices(first(elems)))
path: src/topologies/halfedge.jl
user: login: juliohm
body: I tried to simplify the code in a few places without looking into Cthulhu.jl outputs. Please feel free to comment if you see opportunities to improve performance further.
created_at: 2025-05-21T13:21:13Z
author_association: MEMBER
diff_hunk: @@ -332,45 +331,84 @@ Base.convert(::Type{HalfEdgeTopology}, t::Topology) = HalfEdgeTopology(collect(e
 # HELPER FUNCTIONS
 # -----------------
 
-function adjsort(elems::AbstractVector{<:Connectivity})
-  # initialize list of adjacent elements
-  # with first element from original list
-  list = indices.(elems)
-  adjs = Tuple[popfirst!(list)]
-
-  # the loop will terminate if the mesh
-  # is manifold, and that is always true
-  # with half-edge topology
-  while !isempty(list)
-    # lookup all elements that share at least
-    # one vertex with the last adjacent element
-    found = false
-    vinds = last(adjs)
-    for i in vinds
-      einds = findall(e -> i ∈ e, list)
-      if !isempty(einds)
-        # lookup all elements that share at
-        # least two vertices (i.e. edge)
-        for j in sort(einds, rev=true)
-          if length(vinds ∩ list[j]) > 1
-            found = true
-            push!(adjs, popat!(list, j))
-          end
-        end
+# permutation of elements in adjacent-first order
+function adjsortperm(elems::AbstractVector{<:Connectivity})
+  reduce(vcat, conneccomps(elems))
+end
+
+# connected components from list of elements
+function conneccomps(elems::AbstractVector{<:Connectivity})
path: src/topologies/halfedge.jl
user: login: juliohm
body: I wonder if this new function for connected components is too specific for 2D manifold meshes or if it could be generalized to 3D meshes of any kind given the definition of adjacency here (>=2 shared vertices). If we believe that it should work in general, we could promote it in a separate PR with a dedicated docstring.
created_at: 2025-05-21T13:23:35Z
author_association: MEMBER
diff_hunk: @@ -332,45 +331,84 @@ Base.convert(::Type{HalfEdgeTopology}, t::Topology) = HalfEdgeTopology(collect(e
 # HELPER FUNCTIONS
 # -----------------
 
-function adjsort(elems::AbstractVector{<:Connectivity})
-  # initialize list of adjacent elements
-  # with first element from original list
-  list = indices.(elems)
-  adjs = Tuple[popfirst!(list)]
-
-  # the loop will terminate if the mesh
-  # is manifold, and that is always true
-  # with half-edge topology
-  while !isempty(list)
-    # lookup all elements that share at least
-    # one vertex with the last adjacent element
-    found = false
-    vinds = last(adjs)
-    for i in vinds
-      einds = findall(e -> i ∈ e, list)
-      if !isempty(einds)
-        # lookup all elements that share at
-        # least two vertices (i.e. edge)
-        for j in sort(einds, rev=true)
-          if length(vinds ∩ list[j]) > 1
-            found = true
-            push!(adjs, popat!(list, j))
-          end
-        end
+# permutation of elements in adjacent-first order
+function adjsortperm(elems::AbstractVector{<:Connectivity})
+  reduce(vcat, conneccomps(elems))
+end
+
+# connected components from list of elements
+function conneccomps(elems::AbstractVector{<:Connectivity})
+  # initialize list of connected components
+  comps = [Int[firstindex(elems)]]
path: src/topologies/halfedge.jl
user: login: halleysfifthinc
body: The `Int` there could be removed if you want. I think the code is clearer/more readable with the explicit type there, instead of needing to think through the code to know what the inner vector will be.
created_at: 2025-05-21T16:24:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -332,45 +331,84 @@ Base.convert(::Type{HalfEdgeTopology}, t::Topology) = HalfEdgeTopology(collect(e
 # HELPER FUNCTIONS
 # -----------------
 
-function adjsort(elems::AbstractVector{<:Connectivity})
-  # initialize list of adjacent elements
-  # with first element from original list
-  list = indices.(elems)
-  adjs = Tuple[popfirst!(list)]
-
-  # the loop will terminate if the mesh
-  # is manifold, and that is always true
-  # with half-edge topology
-  while !isempty(list)
-    # lookup all elements that share at least
-    # one vertex with the last adjacent element
-    found = false
-    vinds = last(adjs)
-    for i in vinds
-      einds = findall(e -> i ∈ e, list)
-      if !isempty(einds)
-        # lookup all elements that share at
-        # least two vertices (i.e. edge)
-        for j in sort(einds, rev=true)
-          if length(vinds ∩ list[j]) > 1
-            found = true
-            push!(adjs, popat!(list, j))
-          end
-        end
+# permutation of elements in adjacent-first order
+function adjsortperm(elems::AbstractVector{<:Connectivity})
+  reduce(vcat, conneccomps(elems))
+end
+
+# connected components from list of elements
+function conneccomps(elems::AbstractVector{<:Connectivity})
path: src/topologies/halfedge.jl
user: login: halleysfifthinc
body: :man_shrugging: I'm not sure; I'm not yet familiar with 3D meshes.
created_at: 2025-05-21T16:27:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -331,91 +331,84 @@ Base.convert(::Type{HalfEdgeTopology}, t::Topology) = HalfEdgeTopology(collect(e
 # HELPER FUNCTIONS
 # -----------------
 
+# permutation of elements in adjacent-first order
 function adjsortperm(elems::AbstractVector{<:Connectivity})
-  reduce(vcat, connected_components(elems))
+  reduce(vcat, conneccomps(elems))
 end
 
-function _update_adjacency!(seen, inds, in_seen=(∈(seen)))
-  # add elements that have at least two previously seen vertices
-  if count(in_seen, inds) > 1
-    for v in inds
-      push!(seen, v)
-    end
-    return true
-  end
-  return false
-end
+# connected components from list of elements
+function conneccomps(elems::AbstractVector{<:Connectivity})
path: src/topologies/halfedge.jl
user: login: halleysfifthinc
body: IMO, this function is not used often enough to make an abbreviated name worth the cost of being less clear.
created_at: 2025-05-21T16:37:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -331,91 +331,84 @@ Base.convert(::Type{HalfEdgeTopology}, t::Topology) = HalfEdgeTopology(collect(e
 # HELPER FUNCTIONS
 # -----------------
 
+# permutation of elements in adjacent-first order
 function adjsortperm(elems::AbstractVector{<:Connectivity})
-  reduce(vcat, connected_components(elems))
+  reduce(vcat, conneccomps(elems))
 end
 
-function _update_adjacency!(seen, inds, in_seen=(∈(seen)))
-  # add elements that have at least two previously seen vertices
-  if count(in_seen, inds) > 1
-    for v in inds
-      push!(seen, v)
-    end
-    return true
-  end
-  return false
-end
+# connected components from list of elements
+function conneccomps(elems::AbstractVector{<:Connectivity})
+  # initialize list of connected components
+  comps = [Int[firstindex(elems)]]
 
-function connected_components(elems::AbstractVector{<:Connectivity})
-  # remaining list of elements to process
-  oinds = collect(eachindex(elems)[2:end])
+  # initialize list of seen vertices
+  seen = Set(indices(first(elems)))
 
-  # initialize list of adjacent elements
-  # with first element from original list
-  einds = Vector{Vector{Int}}(undef, 0)
-  push!(einds, Int[firstindex(elems)])
+  # remaining elements to process
+  remaining = collect(eachindex(elems)[2:end])
 
-  seen = Set{Int}()
-  in_seen = ∈(seen)
-
-  # initialize seen with vertices in first element
-  for v in indices(elems[firstindex(elems)])
-    push!(seen, v)
-  end
-
-  found = false
-  while !isempty(oinds)
+  added = false
+  while !isempty(remaining)
     iter = 1
-    # this loop only exits when oinds is empty, or if we have iterated through all elements
-    # and none are adjacent to >1 "seen" elements
-    while iter ≤ length(oinds)
-      lelem = elems[oinds[iter]]
-
-      # manually union-split two most common connectivities for max type stability and speed
-      adjacent = if lelem isa Connectivity{Triangle,3}
-        _update_adjacency!(seen, indices(lelem), in_seen)
-      elseif lelem isa Connectivity{Quadrangle,4}
-        _update_adjacency!(seen, indices(lelem), in_seen)
+    while iter ≤ length(remaining)
+      elem = elems[remaining[iter]]
+      inds = indices(elem)
+
+      # manually union-split two most common polytopes
+      # for type stability and maximum performance
+      isadjacent = if pltype(elem) <: Triangle
+        adjelem!(seen, inds)
+      elseif pltype(elem) <: Quadrangle
+        adjelem!(seen, inds)
       else
-        _update_adjacency!(seen, indices(lelem), in_seen)
+        adjelem!(seen, inds)
       end
path: src/topologies/halfedge.jl
user: login: halleysfifthinc
body: The change here in 5dacc16f negates the manual union-splitting. `elem` is not type stable for a vector of mixed connectivity types, so any functions called on it will need runtime dispatch. The "union-splitting" makes the contents of the first two branches type-stable (and therefore no runtime dispatch), because Julia knows that e.g. in the first branch `elem isa Connectivity{Triangle,3}`, so within that branch, any use of `elem` can now be inferred, so the `indices` and `adjelem!` calls are also type stable.

Calling `pltype` on the still unstable `elem` will be type unstable. Additionally, `inds` is now unstable, since it is defined outside the union-split branches.

Without union-splitting (for the mixed quads & tris torus):
```
BenchmarkTools.Trial: 10000 samples with 1 evaluation per sample.
 Range (min … max):  194.245 μs …   7.735 ms  ┊ GC (min … max): 0.00% … 0.00%
 Time  (median):     294.418 μs               ┊ GC (median):    0.00%
 Time  (mean ± σ):   299.659 μs ± 107.799 μs  ┊ GC (mean ± σ):  0.39% ± 1.59%

                 ▁▂▄▅▅▆▇▇███▇█▆▅▄▃▂▁                             
  ▁▁▁▁▁▁▂▂▂▃▄▅▅▆▇████████████████████▇▆▅▅▄▃▃▃▂▂▂▂▂▂▁▁▁▁▁▁▁▁▁▁▁▁ ▄
  194 μs           Histogram: frequency by time          437 μs <

 Memory estimate: 21.63 KiB, allocs estimate: 456.
```

With (functioning) union-splitting:
```
BenchmarkTools.Trial: 10000 samples with 1 evaluation per sample.
 Range (min … max):  19.136 μs …  10.355 ms  ┊ GC (min … max): 0.00% … 99.16%
 Time  (median):     26.008 μs               ┊ GC (median):    0.00%
 Time  (mean ± σ):   28.500 μs ± 110.980 μs  ┊ GC (mean ± σ):  5.00% ±  1.40%

             ▂▃▆▆██▇▇▆▄▃▂▁                                      
  ▁▁▁▂▂▂▃▄▅▇███████████████▇▆▆▅▅▄▄▅▄▄▄▄▄▃▃▃▃▃▃▂▂▂▂▂▂▂▂▁▂▂▁▁▁▁▁ ▄
  19.1 μs         Histogram: frequency by time         40.6 μs <

 Memory estimate: 14.02 KiB, allocs estimate: 212.

BenchmarkTools.TrialJudgement: 
  time:   -90.50% => improvement (5.00% tolerance)
  memory: -35.45% => improvement (1.00% tolerance)
```
created_at: 2025-05-21T17:02:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -332,45 +331,84 @@ Base.convert(::Type{HalfEdgeTopology}, t::Topology) = HalfEdgeTopology(collect(e
 # HELPER FUNCTIONS
 # -----------------
 
-function adjsort(elems::AbstractVector{<:Connectivity})
-  # initialize list of adjacent elements
-  # with first element from original list
-  list = indices.(elems)
-  adjs = Tuple[popfirst!(list)]
-
-  # the loop will terminate if the mesh
-  # is manifold, and that is always true
-  # with half-edge topology
-  while !isempty(list)
-    # lookup all elements that share at least
-    # one vertex with the last adjacent element
-    found = false
-    vinds = last(adjs)
-    for i in vinds
-      einds = findall(e -> i ∈ e, list)
-      if !isempty(einds)
-        # lookup all elements that share at
-        # least two vertices (i.e. edge)
-        for j in sort(einds, rev=true)
-          if length(vinds ∩ list[j]) > 1
-            found = true
-            push!(adjs, popat!(list, j))
-          end
-        end
+# permutation of elements in adjacent-first order
+function adjsortperm(elems::AbstractVector{<:Connectivity})
+  reduce(vcat, conneccomps(elems))
+end
+
+# connected components from list of elements
+function conneccomps(elems::AbstractVector{<:Connectivity})
+  # initialize list of connected components
+  comps = [Int[firstindex(elems)]]
path: src/topologies/halfedge.jl
user: login: juliohm
body: We try to omit types as much as possible as part of our code style. In this context the type is pretty clear too.
created_at: 2025-05-21T17:02:55Z
author_association: MEMBER
diff_hunk: @@ -332,45 +331,84 @@ Base.convert(::Type{HalfEdgeTopology}, t::Topology) = HalfEdgeTopology(collect(e
 # HELPER FUNCTIONS
 # -----------------
 
-function adjsort(elems::AbstractVector{<:Connectivity})
-  # initialize list of adjacent elements
-  # with first element from original list
-  list = indices.(elems)
-  adjs = Tuple[popfirst!(list)]
-
-  # the loop will terminate if the mesh
-  # is manifold, and that is always true
-  # with half-edge topology
-  while !isempty(list)
-    # lookup all elements that share at least
-    # one vertex with the last adjacent element
-    found = false
-    vinds = last(adjs)
-    for i in vinds
-      einds = findall(e -> i ∈ e, list)
-      if !isempty(einds)
-        # lookup all elements that share at
-        # least two vertices (i.e. edge)
-        for j in sort(einds, rev=true)
-          if length(vinds ∩ list[j]) > 1
-            found = true
-            push!(adjs, popat!(list, j))
-          end
-        end
+# permutation of elements in adjacent-first order
+function adjsortperm(elems::AbstractVector{<:Connectivity})
+  reduce(vcat, conneccomps(elems))
+end
+
+# connected components from list of elements
+function conneccomps(elems::AbstractVector{<:Connectivity})
+  # initialize list of connected components
+  comps = [Int[firstindex(elems)]]
path: src/topologies/halfedge.jl
user: login: juliohm
body: ```suggestion
  comps = [[firstindex(elems)]]
```
created_at: 2025-05-21T17:03:19Z
author_association: MEMBER
diff_hunk: @@ -332,45 +331,84 @@ Base.convert(::Type{HalfEdgeTopology}, t::Topology) = HalfEdgeTopology(collect(e
 # HELPER FUNCTIONS
 # -----------------
 
-function adjsort(elems::AbstractVector{<:Connectivity})
-  # initialize list of adjacent elements
-  # with first element from original list
-  list = indices.(elems)
-  adjs = Tuple[popfirst!(list)]
-
-  # the loop will terminate if the mesh
-  # is manifold, and that is always true
-  # with half-edge topology
-  while !isempty(list)
-    # lookup all elements that share at least
-    # one vertex with the last adjacent element
-    found = false
-    vinds = last(adjs)
-    for i in vinds
-      einds = findall(e -> i ∈ e, list)
-      if !isempty(einds)
-        # lookup all elements that share at
-        # least two vertices (i.e. edge)
-        for j in sort(einds, rev=true)
-          if length(vinds ∩ list[j]) > 1
-            found = true
-            push!(adjs, popat!(list, j))
-          end
-        end
+# permutation of elements in adjacent-first order
+function adjsortperm(elems::AbstractVector{<:Connectivity})
+  reduce(vcat, conneccomps(elems))
+end
+
+# connected components from list of elements
+function conneccomps(elems::AbstractVector{<:Connectivity})
path: src/topologies/halfedge.jl
user: login: juliohm
body: No worries. Let's keep this utility function in halfedge.jl and then look into it again in the future if a 3D applications shows up.
created_at: 2025-05-21T17:04:02Z
author_association: MEMBER
diff_hunk: @@ -332,45 +331,84 @@ Base.convert(::Type{HalfEdgeTopology}, t::Topology) = HalfEdgeTopology(collect(e
 # HELPER FUNCTIONS
 # -----------------
 
-function adjsort(elems::AbstractVector{<:Connectivity})
-  # initialize list of adjacent elements
-  # with first element from original list
-  list = indices.(elems)
-  adjs = Tuple[popfirst!(list)]
-
-  # the loop will terminate if the mesh
-  # is manifold, and that is always true
-  # with half-edge topology
-  while !isempty(list)
-    # lookup all elements that share at least
-    # one vertex with the last adjacent element
-    found = false
-    vinds = last(adjs)
-    for i in vinds
-      einds = findall(e -> i ∈ e, list)
-      if !isempty(einds)
-        # lookup all elements that share at
-        # least two vertices (i.e. edge)
-        for j in sort(einds, rev=true)
-          if length(vinds ∩ list[j]) > 1
-            found = true
-            push!(adjs, popat!(list, j))
-          end
-        end
+# permutation of elements in adjacent-first order
+function adjsortperm(elems::AbstractVector{<:Connectivity})
+  reduce(vcat, conneccomps(elems))
+end
+
+# connected components from list of elements
+function conneccomps(elems::AbstractVector{<:Connectivity})
+  # initialize list of connected components
+  comps = [Int[firstindex(elems)]]
+
+  # initialize list of seen vertices
+  seen = Set(indices(first(elems)))
path: src/topologies/halfedge.jl
user: login: halleysfifthinc
body: The previous manual `seen` initialization is faster vs `seen = Set(indices(first(elems)))`:
```
BenchmarkTools.TrialJudgement: 
  time:   -17.02% => improvement (5.00% tolerance)
  memory: -0.21% => invariant (1.00% tolerance)
```
created_at: 2025-05-21T17:39:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -331,91 +331,84 @@ Base.convert(::Type{HalfEdgeTopology}, t::Topology) = HalfEdgeTopology(collect(e
 # HELPER FUNCTIONS
 # -----------------
 
+# permutation of elements in adjacent-first order
 function adjsortperm(elems::AbstractVector{<:Connectivity})
-  reduce(vcat, connected_components(elems))
+  reduce(vcat, conneccomps(elems))
 end
 
-function _update_adjacency!(seen, inds, in_seen=(∈(seen)))
-  # add elements that have at least two previously seen vertices
-  if count(in_seen, inds) > 1
-    for v in inds
-      push!(seen, v)
-    end
-    return true
-  end
-  return false
-end
+# connected components from list of elements
+function conneccomps(elems::AbstractVector{<:Connectivity})
path: src/topologies/halfedge.jl
user: login: juliohm
body: It is just our code style. We avoid underscores as much as possible, even if it is an internal function. I added a comment right above to make it clear what the function does.
created_at: 2025-05-21T17:47:53Z
author_association: MEMBER
diff_hunk: @@ -331,91 +331,84 @@ Base.convert(::Type{HalfEdgeTopology}, t::Topology) = HalfEdgeTopology(collect(e
 # HELPER FUNCTIONS
 # -----------------
 
+# permutation of elements in adjacent-first order
 function adjsortperm(elems::AbstractVector{<:Connectivity})
-  reduce(vcat, connected_components(elems))
+  reduce(vcat, conneccomps(elems))
 end
 
-function _update_adjacency!(seen, inds, in_seen=(∈(seen)))
-  # add elements that have at least two previously seen vertices
-  if count(in_seen, inds) > 1
-    for v in inds
-      push!(seen, v)
-    end
-    return true
-  end
-  return false
-end
+# connected components from list of elements
+function conneccomps(elems::AbstractVector{<:Connectivity})
+  # initialize list of connected components
+  comps = [Int[firstindex(elems)]]
 
-function connected_components(elems::AbstractVector{<:Connectivity})
-  # remaining list of elements to process
-  oinds = collect(eachindex(elems)[2:end])
+  # initialize list of seen vertices
+  seen = Set(indices(first(elems)))
 
-  # initialize list of adjacent elements
-  # with first element from original list
-  einds = Vector{Vector{Int}}(undef, 0)
-  push!(einds, Int[firstindex(elems)])
+  # remaining elements to process
+  remaining = collect(eachindex(elems)[2:end])
 
-  seen = Set{Int}()
-  in_seen = ∈(seen)
-
-  # initialize seen with vertices in first element
-  for v in indices(elems[firstindex(elems)])
-    push!(seen, v)
-  end
-
-  found = false
-  while !isempty(oinds)
+  added = false
+  while !isempty(remaining)
     iter = 1
-    # this loop only exits when oinds is empty, or if we have iterated through all elements
-    # and none are adjacent to >1 "seen" elements
-    while iter ≤ length(oinds)
-      lelem = elems[oinds[iter]]
-
-      # manually union-split two most common connectivities for max type stability and speed
-      adjacent = if lelem isa Connectivity{Triangle,3}
-        _update_adjacency!(seen, indices(lelem), in_seen)
-      elseif lelem isa Connectivity{Quadrangle,4}
-        _update_adjacency!(seen, indices(lelem), in_seen)
+    while iter ≤ length(remaining)
+      elem = elems[remaining[iter]]
+      inds = indices(elem)
+
+      # manually union-split two most common polytopes
+      # for type stability and maximum performance
+      isadjacent = if pltype(elem) <: Triangle
+        adjelem!(seen, inds)
+      elseif pltype(elem) <: Quadrangle
+        adjelem!(seen, inds)
       else
-        _update_adjacency!(seen, indices(lelem), in_seen)
+        adjelem!(seen, inds)
       end
path: src/topologies/halfedge.jl
user: login: juliohm
body: That is super interesting. So we can't use `pltype` nor `indices` outside the branches because `elem` itself is not a concrete type. I will refactor the code once again, thanks for sharing.

Learning a lot about low-level compiler limitations in these last PRs.
created_at: 2025-05-21T17:51:36Z
author_association: MEMBER
diff_hunk: @@ -331,91 +331,84 @@ Base.convert(::Type{HalfEdgeTopology}, t::Topology) = HalfEdgeTopology(collect(e
 # HELPER FUNCTIONS
 # -----------------
 
+# permutation of elements in adjacent-first order
 function adjsortperm(elems::AbstractVector{<:Connectivity})
-  reduce(vcat, connected_components(elems))
+  reduce(vcat, conneccomps(elems))
 end
 
-function _update_adjacency!(seen, inds, in_seen=(∈(seen)))
-  # add elements that have at least two previously seen vertices
-  if count(in_seen, inds) > 1
-    for v in inds
-      push!(seen, v)
-    end
-    return true
-  end
-  return false
-end
+# connected components from list of elements
+function conneccomps(elems::AbstractVector{<:Connectivity})
+  # initialize list of connected components
+  comps = [Int[firstindex(elems)]]
 
-function connected_components(elems::AbstractVector{<:Connectivity})
-  # remaining list of elements to process
-  oinds = collect(eachindex(elems)[2:end])
+  # initialize list of seen vertices
+  seen = Set(indices(first(elems)))
 
-  # initialize list of adjacent elements
-  # with first element from original list
-  einds = Vector{Vector{Int}}(undef, 0)
-  push!(einds, Int[firstindex(elems)])
+  # remaining elements to process
+  remaining = collect(eachindex(elems)[2:end])
 
-  seen = Set{Int}()
-  in_seen = ∈(seen)
-
-  # initialize seen with vertices in first element
-  for v in indices(elems[firstindex(elems)])
-    push!(seen, v)
-  end
-
-  found = false
-  while !isempty(oinds)
+  added = false
+  while !isempty(remaining)
     iter = 1
-    # this loop only exits when oinds is empty, or if we have iterated through all elements
-    # and none are adjacent to >1 "seen" elements
-    while iter ≤ length(oinds)
-      lelem = elems[oinds[iter]]
-
-      # manually union-split two most common connectivities for max type stability and speed
-      adjacent = if lelem isa Connectivity{Triangle,3}
-        _update_adjacency!(seen, indices(lelem), in_seen)
-      elseif lelem isa Connectivity{Quadrangle,4}
-        _update_adjacency!(seen, indices(lelem), in_seen)
+    while iter ≤ length(remaining)
+      elem = elems[remaining[iter]]
+      inds = indices(elem)
+
+      # manually union-split two most common polytopes
+      # for type stability and maximum performance
+      isadjacent = if pltype(elem) <: Triangle
+        adjelem!(seen, inds)
+      elseif pltype(elem) <: Quadrangle
+        adjelem!(seen, inds)
       else
-        _update_adjacency!(seen, indices(lelem), in_seen)
+        adjelem!(seen, inds)
       end
path: src/topologies/halfedge.jl
user: login: halleysfifthinc
body: (I already pushed fixes for this.)
created_at: 2025-05-21T17:52:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -332,45 +331,87 @@ Base.convert(::Type{HalfEdgeTopology}, t::Topology) = HalfEdgeTopology(collect(e
 # HELPER FUNCTIONS
 # -----------------
 
-function adjsort(elems::AbstractVector{<:Connectivity})
-  # initialize list of adjacent elements
-  # with first element from original list
-  list = indices.(elems)
-  adjs = Tuple[popfirst!(list)]
-
-  # the loop will terminate if the mesh
-  # is manifold, and that is always true
-  # with half-edge topology
-  while !isempty(list)
-    # lookup all elements that share at least
-    # one vertex with the last adjacent element
-    found = false
-    vinds = last(adjs)
-    for i in vinds
-      einds = findall(e -> i ∈ e, list)
-      if !isempty(einds)
-        # lookup all elements that share at
-        # least two vertices (i.e. edge)
-        for j in sort(einds, rev=true)
-          if length(vinds ∩ list[j]) > 1
-            found = true
-            push!(adjs, popat!(list, j))
-          end
-        end
+# permutation of elements in adjacent-first order
+function adjsortperm(elems::AbstractVector{<:Connectivity})
+  reduce(vcat, conneccomps(elems))
+end
+
+# connected components from list of elements
+function conneccomps(elems::AbstractVector{<:Connectivity})
+  # initialize list of connected components
+  comps = [[firstindex(elems)]]
+
+  # initialize list of seen vertices
+  seen = Set{Int}()
+  for v in indices(first(elems))
+    push!(seen, v)
+  end
path: src/topologies/halfedge.jl
user: login: juliohm
body: Is this explicit loop needed or the `Set(indices(first(elems))` would be equivalent compiler-wise? (I didn't have time to dive into Cthulhu.jl docs yet).
created_at: 2025-05-21T17:53:46Z
author_association: MEMBER
diff_hunk: @@ -332,45 +331,87 @@ Base.convert(::Type{HalfEdgeTopology}, t::Topology) = HalfEdgeTopology(collect(e
 # HELPER FUNCTIONS
 # -----------------
 
-function adjsort(elems::AbstractVector{<:Connectivity})
-  # initialize list of adjacent elements
-  # with first element from original list
-  list = indices.(elems)
-  adjs = Tuple[popfirst!(list)]
-
-  # the loop will terminate if the mesh
-  # is manifold, and that is always true
-  # with half-edge topology
-  while !isempty(list)
-    # lookup all elements that share at least
-    # one vertex with the last adjacent element
-    found = false
-    vinds = last(adjs)
-    for i in vinds
-      einds = findall(e -> i ∈ e, list)
-      if !isempty(einds)
-        # lookup all elements that share at
-        # least two vertices (i.e. edge)
-        for j in sort(einds, rev=true)
-          if length(vinds ∩ list[j]) > 1
-            found = true
-            push!(adjs, popat!(list, j))
-          end
-        end
+# permutation of elements in adjacent-first order
+function adjsortperm(elems::AbstractVector{<:Connectivity})
+  reduce(vcat, conneccomps(elems))
+end
+
+# connected components from list of elements
+function conneccomps(elems::AbstractVector{<:Connectivity})
+  # initialize list of connected components
+  comps = [[firstindex(elems)]]
+
+  # initialize list of seen vertices
+  seen = Set{Int}()
+  for v in indices(first(elems))
+    push!(seen, v)
+  end
+
+  # remaining elements to process
+  remaining = collect(eachindex(elems)[2:end])
+
+  added = false
+  while !isempty(remaining)
+    iter = 1
+    while iter ≤ length(remaining)
+      elem = elems[remaining[iter]]
+
+      # manually union-split two most common polytopes
+      # for type stability and maximum performance
+      isadjacent = if elem isa Connectivity{Triangle,3}
+        adjelem!(seen, elem)
+      elseif elem isa Connectivity{Quadrangle,4}
path: src/topologies/halfedge.jl
user: login: juliohm
body: ```suggestion
      isadjacent = if elem isa Connectivity{Triangle}
        adjelem!(seen, elem)
      elseif elem isa Connectivity{Quadrangle}
```

Can we omit the second type parameter?
created_at: 2025-05-21T17:54:15Z
author_association: MEMBER
diff_hunk: @@ -332,45 +331,87 @@ Base.convert(::Type{HalfEdgeTopology}, t::Topology) = HalfEdgeTopology(collect(e
 # HELPER FUNCTIONS
 # -----------------
 
-function adjsort(elems::AbstractVector{<:Connectivity})
-  # initialize list of adjacent elements
-  # with first element from original list
-  list = indices.(elems)
-  adjs = Tuple[popfirst!(list)]
-
-  # the loop will terminate if the mesh
-  # is manifold, and that is always true
-  # with half-edge topology
-  while !isempty(list)
-    # lookup all elements that share at least
-    # one vertex with the last adjacent element
-    found = false
-    vinds = last(adjs)
-    for i in vinds
-      einds = findall(e -> i ∈ e, list)
-      if !isempty(einds)
-        # lookup all elements that share at
-        # least two vertices (i.e. edge)
-        for j in sort(einds, rev=true)
-          if length(vinds ∩ list[j]) > 1
-            found = true
-            push!(adjs, popat!(list, j))
-          end
-        end
+# permutation of elements in adjacent-first order
+function adjsortperm(elems::AbstractVector{<:Connectivity})
+  reduce(vcat, conneccomps(elems))
+end
+
+# connected components from list of elements
+function conneccomps(elems::AbstractVector{<:Connectivity})
+  # initialize list of connected components
+  comps = [[firstindex(elems)]]
+
+  # initialize list of seen vertices
+  seen = Set{Int}()
+  for v in indices(first(elems))
+    push!(seen, v)
+  end
path: src/topologies/halfedge.jl
user: login: halleysfifthinc
body: That has nothing to do with Cthulhu. The explicit loop is 17% faster; c.f. the benchmark I shared in the comment.
created_at: 2025-05-21T17:55:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -332,45 +331,87 @@ Base.convert(::Type{HalfEdgeTopology}, t::Topology) = HalfEdgeTopology(collect(e
 # HELPER FUNCTIONS
 # -----------------
 
-function adjsort(elems::AbstractVector{<:Connectivity})
-  # initialize list of adjacent elements
-  # with first element from original list
-  list = indices.(elems)
-  adjs = Tuple[popfirst!(list)]
-
-  # the loop will terminate if the mesh
-  # is manifold, and that is always true
-  # with half-edge topology
-  while !isempty(list)
-    # lookup all elements that share at least
-    # one vertex with the last adjacent element
-    found = false
-    vinds = last(adjs)
-    for i in vinds
-      einds = findall(e -> i ∈ e, list)
-      if !isempty(einds)
-        # lookup all elements that share at
-        # least two vertices (i.e. edge)
-        for j in sort(einds, rev=true)
-          if length(vinds ∩ list[j]) > 1
-            found = true
-            push!(adjs, popat!(list, j))
-          end
-        end
+# permutation of elements in adjacent-first order
+function adjsortperm(elems::AbstractVector{<:Connectivity})
+  reduce(vcat, conneccomps(elems))
+end
+
+# connected components from list of elements
+function conneccomps(elems::AbstractVector{<:Connectivity})
+  # initialize list of connected components
+  comps = [[firstindex(elems)]]
+
+  # initialize list of seen vertices
+  seen = Set{Int}()
+  for v in indices(first(elems))
+    push!(seen, v)
+  end
+
+  # remaining elements to process
+  remaining = collect(eachindex(elems)[2:end])
+
+  added = false
+  while !isempty(remaining)
+    iter = 1
+    while iter ≤ length(remaining)
+      elem = elems[remaining[iter]]
+
+      # manually union-split two most common polytopes
+      # for type stability and maximum performance
+      isadjacent = if elem isa Connectivity{Triangle,3}
+        adjelem!(seen, elem)
+      elseif elem isa Connectivity{Quadrangle,4}
path: src/topologies/halfedge.jl
user: login: halleysfifthinc
body: Benchmarks indicate they are very much necessary. I expect it is related to the fact that `Connectivity{Triangle}` is a `UnionAll` type (incompletely specified).
created_at: 2025-05-21T18:00:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -332,45 +331,87 @@ Base.convert(::Type{HalfEdgeTopology}, t::Topology) = HalfEdgeTopology(collect(e
 # HELPER FUNCTIONS
 # -----------------
 
-function adjsort(elems::AbstractVector{<:Connectivity})
-  # initialize list of adjacent elements
-  # with first element from original list
-  list = indices.(elems)
-  adjs = Tuple[popfirst!(list)]
-
-  # the loop will terminate if the mesh
-  # is manifold, and that is always true
-  # with half-edge topology
-  while !isempty(list)
-    # lookup all elements that share at least
-    # one vertex with the last adjacent element
-    found = false
-    vinds = last(adjs)
-    for i in vinds
-      einds = findall(e -> i ∈ e, list)
-      if !isempty(einds)
-        # lookup all elements that share at
-        # least two vertices (i.e. edge)
-        for j in sort(einds, rev=true)
-          if length(vinds ∩ list[j]) > 1
-            found = true
-            push!(adjs, popat!(list, j))
-          end
-        end
+# permutation of elements in adjacent-first order
+function adjsortperm(elems::AbstractVector{<:Connectivity})
+  reduce(vcat, conneccomps(elems))
+end
+
+# connected components from list of elements
+function conneccomps(elems::AbstractVector{<:Connectivity})
+  # initialize list of connected components
+  comps = [[firstindex(elems)]]
+
+  # initialize list of seen vertices
+  seen = Set{Int}()
+  for v in indices(first(elems))
+    push!(seen, v)
+  end
path: src/topologies/halfedge.jl
user: login: juliohm
body: Got it. It is a bit unexpected to see that the constructor is 17% slower in this simple setup.
created_at: 2025-05-21T18:03:06Z
author_association: MEMBER

PR comments:
user: login: codecov[bot]
created_at: 2025-04-07T22:46:42Z
body: ## [Codecov](https://app.codecov.io/gh/JuliaGeometry/Meshes.jl/pull/1183?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=JuliaGeometry) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 88.43%. Comparing base [(`6f1cd35`)](https://app.codecov.io/gh/JuliaGeometry/Meshes.jl/commit/6f1cd35559ab8c5343a7d193169c675aee3a2d6a?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=JuliaGeometry) to head [(`b4e2e15`)](https://app.codecov.io/gh/JuliaGeometry/Meshes.jl/commit/b4e2e159506317fd0838d941ddcf6b7596273c62?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=JuliaGeometry).
> Report is 3 commits behind head on master.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master    #1183      +/-   ##
==========================================
+ Coverage   88.39%   88.43%   +0.04%     
==========================================
  Files         196      196              
  Lines        6133     6155      +22     
==========================================
+ Hits         5421     5443      +22     
  Misses        712      712              
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/JuliaGeometry/Meshes.jl/pull/1183?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=JuliaGeometry).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=JuliaGeometry).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE
user: login: halleysfifthinc
created_at: 2025-04-08T18:02:50Z
body: The type-annotations were added based on investigation with Cthulhu.jl. The type-annotations benefit the later code by allowing the compiler to work with more specific types[^1]. Justification for each annotation given in the review comments.

In Blender, I created a test mesh (torus, 72 quads), and the benchmarks vs no type annotations is:

<details><summary>All quads, no type-annots benchmark</summary>
<p>

```julia
BenchmarkTools.Trial: 10000 samples with 1 evaluation per sample.
 Range (min … max):  626.260 μs …  32.464 ms  ┊ GC (min … max): 0.00% … 97.20%
 Time  (median):     674.720 μs               ┊ GC (median):    0.00%
 Time  (mean ± σ):   762.418 μs ± 731.223 μs  ┊ GC (mean ± σ):  7.88% ±  8.53%

   ▃▅▇██▇▆▅▅▄▄▃▂▂▂▁▁▁      ▁▃▄▃▂▁▁▁                             ▂
  ███████████████████████▇████████████▇▆▄▆▅▅▅▆▅▆▄▆▄▅▃▅▃▅▅▅▃▆▅▆▃ █
  626 μs        Histogram: log(frequency) by time       1.11 ms <

 Memory estimate: 573.52 KiB, allocs estimate: 14665.
```

</p>
</details> 

```julia
BenchmarkTools.TrialJudgement: 
  time:   +323.22% => regression (5.00% tolerance)
  memory: +271.06% => regression (1.00% tolerance)
```

I then split half the quads into tris (36 quads, 72 tris) and got:

<details><summary>Quads and tris; no type-annots benchmark</summary>
<p>

```julia
BenchmarkTools.Trial: 7528 samples with 1 evaluation per sample.
 Range (min … max):  3.641 ms …  35.721 ms  ┊ GC (min … max): 0.00% … 87.22%
 Time  (median):     3.837 ms               ┊ GC (median):    0.00%
 Time  (mean ± σ):   3.971 ms ± 970.236 μs  ┊ GC (mean ± σ):  2.81% ±  7.91%

  ▆█▆▂  ▂▁                                                    ▁
  █████▇██▆▅▆▄▅▅▅▄▄▁▄▃▄▅▃▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▆▆▇ █
  3.64 ms      Histogram: log(frequency) by time      10.7 ms <

 Memory estimate: 1.32 MiB, allocs estimate: 39578.
```

</p>
</details> 

```julia
BenchmarkTools.TrialJudgement: 
  time:   +348.65% => regression (5.00% tolerance)
  memory: +216.15% => regression (1.00% tolerance)
```

[^1]: It seems that the type annotations alone (without the other changes) might actually make things slower, but when combined, enhances the benefit of the other changes.
author_association: CONTRIBUTOR
user: login: juliohm
created_at: 2025-04-08T21:14:26Z
body: That is very nice. Could you please share a MWE so that I can reproduce benchmark results and test the PR locally? :)
author_association: MEMBER
user: login: halleysfifthinc
created_at: 2025-04-08T22:09:12Z
body: [Quads and mixed faces meshes here](https://gist.github.com/halleysfifthinc/5b0d45a44f4dd451daca097aa35b9846).

```julia
using Meshes, GeoIO, BenchmarkTools

quads = GeoIO.load("quads.obj").geometry
quads_tris = GeoIO.load("quads_tris.obj").geometry

@benchmark topoconvert(HalfEdgeTopology, quads)
@benchmark topoconvert(HalfEdgeTopology, quads_tris)
```

`adjsort` is responsible for ~85% of the runtime and ~50% of allocations (count), but the `HalfEdgeTopology(::AbstractVector{Tuple{HalfEdge,HalfEdge}})` constructor contributes ~80% of total allocated amount (from the `Dict`s). So although [47fa3f4](https://github.com/JuliaGeometry/Meshes.jl/pull/1183/commits/47fa3f4d64001f4c4366dce9bcad90a534cbd2e2) only removes a few individual allocations, they are larger allocations and save a decent amount of memory.
author_association: CONTRIBUTOR
user: login: halleysfifthinc
created_at: 2025-04-14T23:56:01Z
body: I reimplemented `adjsort` as `adjsortperm`, which now returns a sort permutation (faster, and removes the need for the `indexin` call, which was a non-negligible runtime contributor). Before/after benchmarks have been updated in the top comment (its quite a bit faster).

Along the way, I found a weird mesh/topo that is manifold, however, the old `adjsort` led to one face being missing from the final converted topology. Despite my best efforts, I could not determine what/where/why the problem was. [This gist](https://gist.github.com/halleysfifthinc/27236babe51bd01a204b093a70e33c8d) contains two meshes which are identical save for the triangle orders and orientations (same vertices). I am happy to include the bad mesh as a test if you wish.
author_association: CONTRIBUTOR
user: login: halleysfifthinc
created_at: 2025-04-14T23:57:13Z
body: [Failed CI](https://github.com/JuliaGeometry/Meshes.jl/actions/runs/14457935044/job/40544973505?pr=1183#step:6:652) doesn't seem related.
author_association: CONTRIBUTOR
user: login: juliohm
created_at: 2025-04-15T14:14:05Z
body: @halleysfifthinc thank you for refactoring the code.

I refactored it a bit more to make sure the new algorithm is easy to read. Do we still need to reintroduce type annotations to improve performance? Appreciate it if you could take another look. If type annotations are necessary, we could start with annotating the return type of `adjsortperm`, by typing `einds::Vector{Vector{Int}}` at the last line of the function.

If we could encapsulate these type annotations in the `adjsortperm` function, that would decrease our chances to break type instability in the future.
author_association: MEMBER
user: login: juliohm
created_at: 2025-04-15T14:15:01Z
body: > Along the way, I found a weird mesh/topo that is manifold, however, the old `adjsort` led to one face being missing from the final converted topology.

Do you mean that the old implementation had a bug? Is it present in the new implementation in this PR?
author_association: MEMBER
user: login: halleysfifthinc
created_at: 2025-04-17T02:17:13Z
body: Just pushed yet another refactor, this version is much faster still  (and as an unintentional bonus, now actually properly separates connected components, which as previously discussed, is something I need).

I converted to a draft, as I am still unsatisfied with some behaviors, but can't continue spending time on this for the moment.

I will update the benchmarks when I get a chance.
author_association: CONTRIBUTOR
user: login: juliohm
created_at: 2025-04-17T11:20:23Z
body: That is really amazing @halleysfifthinc. The new connected components function is quite useful in other contexts. We should probably convert it to a utility function after this PR is merged. Looking forward to it!
author_association: MEMBER
user: login: halleysfifthinc
created_at: 2025-04-17T17:01:27Z
body: Benchmarks updated (now specifically for `adjsort`/`adjsortperm`)!

Re: `connected_components` utility, I agree. I'd like to see it exported, or `public` at least, so I can use it and avoid the Graphs.jl dependency.

> Do you mean that the old implementation had a bug? Is it present in the new implementation in this PR?

Yes, the old `adjsort` was leading to one face not being added/recognized in the final `HalfEdgeTopology`. That specific problem is no longer present with the new `adjsortperm` implementation.

However, the old `adjsort` was only part of the problem, I believe the main `HalfEdgeTopology(::Vector{<:Connectivity}` function is incorrect/unreliable for certain mixes of face orientations. I have a mesh[^1] which has inconsistencies after being converted to a `HalfEdgeTopology` (`next` or `prev` edges not matching the current `elem`). I have been able to determine that the bug is related to inconsistent orientations among the faces, but I am struggling to find any logical errors in the existing orientation handling code here and above https://github.com/JuliaGeometry/Meshes.jl/blob/28c75552ef2bf3f8da9050a853f0a7025bed9217/src/topologies/halfedge.jl#L177-L182

[^1]:  I can't share the mesh, and I don't understand the cause of the bug well enough to simplify the mesh like I did for the meshes attached to [my earlier comment](https://github.com/JuliaGeometry/Meshes.jl/pull/1183#issuecomment-2803353936))
author_association: CONTRIBUTOR
user: login: juliohm
created_at: 2025-04-21T11:42:41Z
body: > Benchmarks updated (now specifically for adjsort/adjsortperm)!

🚀 

Please let us know when the PR is ready for review @halleysfifthinc 🙂 
author_association: MEMBER
user: login: halleysfifthinc
created_at: 2025-04-29T19:23:24Z
body: **tl;dr**: The face ordering with the new `connected_components`/`adjsortperm` isn't adjacently sorted under the strictest definition (subsequent faces share at least one already existing edge). Obviously, it is your call whether this is acceptable, but the updated `HalfEdgeTopology(::Vector{<:Connectivity})` function handles the current (and previous) behavior. (Although nearly all of the performance gains are from the refactored adjacency sort.)

I determined that the bug I was hunting is because adjacency is now functionally defined as any face that has >2 previously observed vertices. This allows a face (triangle 4 in the photo) to be added, even if all of its edges are new/previously unobserved. The orientation of this triangle cannot be checked against existing faces, and we can end up with irreparable inconsistencies when the "hole" (triangle 5 in the photo) is filled (if the orientation of triangle 4 is ultimately inconsistent with the correct orientation of triangle 5).
<img src="https://github.com/user-attachments/assets/f343b6bb-7e6b-438e-8695-8ae24d8f5f8d" width="436" height="500" />

That bug is not present in the original code on `master`.
author_association: CONTRIBUTOR
user: login: juliohm
created_at: 2025-04-29T19:38:35Z
body: Thank you for the update @halleysfifthinc. Before I spend time reviewing the minor details in the diff, could you please confirm the nature of the changes in this PR?

Is it correct to say that there are

1. Changes in the `HalfEdgeTopology` constructor with vector of `Connectivity` to improve allocations and type stability
2. Changes in the `adjsortperm` to improve runtime in terms of a new `connected_components` function

and nothing else?

What about the behavior change you mentioned? Where is it coming from? You mean that the new `adjsortperm` implementation is not equivalent to the current implementation in `master`? What is the new definition of adjacency if not >2 vertices in common?

I wonder if we could split this PR into smaller PRs that are easier to review and test. The `HalfEdgeTopology` is used in various topological relations and algorithms, and we should do our best to avoid introducing new bugs.
author_association: MEMBER
user: login: halleysfifthinc
created_at: 2025-04-29T19:56:25Z
body: > Is it correct to say that there are

Yes that is a correct summary. I'm happy to split this up for easier review if you would like.

> You mean that the new `adjsortperm` implementation is not equivalent to the current implementation in `master`?

Yes, the new `adjsortperm` does not produce identical sorts to the current implementation in `master`. This is partially by design (the current `adjsort` is not stable due to the reverse iteration order, which means that sorting a sorted `Vector{<:Connectivity}` still changes the face order).

> What is the new definition of adjacency if not >2 vertices in common?

The old definition was theoretically defined by >2 previously observed vertices, but due to the way `adjsort` was implemented, the actual functioning definition was >1 previously observed *edge* (defined by a pair of previously observed vertices). The distinction is subtle (and therefore took me a while to realize), but you can have a face that shares 2 previously observed vertices without those vertices having been previously observed as an edge/pair (e.g. triangle 4 in the above photo diagram).

I won't have the time to investigate the new test failures for a few days.
author_association: CONTRIBUTOR
user: login: juliohm
created_at: 2025-04-29T20:06:14Z
body: > I'm happy to split this up for easier review if you would like.

That would be super appreciated. Smaller PRs with all tests passing can be quickly merged and shared with all users of the package via patch releases. If you can identify subsets of changes that just improve performance, without behavior change, we can quickly review and approve.
author_association: MEMBER
user: login: juliohm
created_at: 2025-05-09T15:41:49Z
body: @halleysfifthinc did you have a chance to split this PR into smaller ones? 🙂 
author_association: MEMBER
user: login: juliohm
created_at: 2025-05-12T20:51:39Z
body: #1193 merged 💯
author_association: MEMBER
user: login: halleysfifthinc
created_at: 2025-05-12T21:28:26Z
body: Before I split this anymore, I want to run it past you: the only remaining logical split I see in these changes is to separate the changes to `adjsort`/`adjsortperm` and the changes to `HalfEdgeTopology(::Vector{<:Connectivity})`, which can mostly be independently separated from the changes to `adjsort`. Is this PR still larger than you'd like to review/merge (once tests are passing of course), or would you like me to split as described above?
author_association: CONTRIBUTOR
user: login: juliohm
created_at: 2025-05-12T23:11:23Z
body: The more self-contained the changes, the better. If it is not too much to ask, of course. With the split we can also add more tests for the bug you identified while working on it.
author_association: MEMBER
user: login: halleysfifthinc
created_at: 2025-05-16T23:18:52Z
body: Once #1194 is merged, I have a rebased version of this ready to push.

Besides the architectural change of returning a sort permutation instead of a sorted copy, there are now 2 algorithmic differences which cause differences in sorted order between the PR and `master` algorithms:

1. Iterative search for the next adjacent element is done in a forward direction in this PR, which results in a more stable sort:
    - For example, each of the latter 3 elements here `connect.([(1, 2, 3), (3, 4, 2), (2, 1, 5), (6, 3, 1)])` are adjacent to the first element. So, any permutation of the latter 3 elements after the first is a valid adjacent sort, including the current order. The reverse iteration order by `adjsort` on `master` will always result in these elements being reordered (e.g. as `connect.([(1, 2, 3), (6, 3, 1), (2, 1, 5), (3, 4, 2)])` in the above example), despite already having a valid adjacent order.
2. "Adjacent" is now (technically more loosely) defined as "an element which contains more than 1 previously observed index". This differs from the stricter (but more computationally intense) definition of "an element which shares an edge (2 indices) with the current element".

(These two changes mean that there are some expected edge indices in the tests which I will need to update.)

In combination with #1194, which enables the `HalfEdgeTopology` construction to tolerate unsorted connectivities, and since `adjsort` is not public and was only used in `HalfEdgeTopology` construction, I think this slightly looser adjacency definition should be acceptable.
author_association: CONTRIBUTOR
user: login: juliohm
created_at: 2025-05-20T20:53:17Z
body: Now that #1194 is merged, I wonder if we still need `adjsort`. Would it be correct to assume that the new algorithm to convert vector of connectivity into vector of half-edges is robust to any order of elements?

Originally, I introduced `adjsort` to workaround incosistent orientation in "most cases", but now that the algorithm is refactored, `adjsort` might not be needed. If we can have an idea of average runtime and correctness with and without `adjsort`, that would be awesome.
author_association: MEMBER
user: login: halleysfifthinc
created_at: 2025-05-21T01:15:06Z
body: > Would it be correct to assume that the new algorithm to convert vector of connectivity into vector of half-edges is robust to any order of elements?

As far as I can logically determine, that assumption is correct.

> If we can have an idea of average runtime and correctness with and without `adjsort`, that would be awesome.

Definitely. There is still a benefit to pre-sorting the elements. I compared a pre-shuffled connectivity vector to a pre-sorted connectivity vector (using `sort=false` for both cases), and I found:

```julia
# all quad torus
BenchmarkTools.TrialJudgement: 
  time:   -18.08% => improvement (5.00% tolerance)
  memory: -6.09% => improvement (1.00% tolerance)

# quads and tris torus
BenchmarkTools.TrialJudgement: 
  time:   -40.78% => improvement (5.00% tolerance)
  memory: -36.61% => improvement (1.00% tolerance)
```

As far as "correctness", the only differences between sorted or not will be the order of the edges. I don't have a strong opinion, but I think the possibility of multiple equally valid sort permutations (e.g. for `connect.([(1, 2, 3), (3, 4, 2), (2, 1, 5), (6, 3, 1)])`) makes a reasonable case against making a strong (or any) commitment for edge index stability.
author_association: CONTRIBUTOR
user: login: juliohm
created_at: 2025-05-21T17:14:32Z
body: All test changes are correct ✅ 

I will benchmark the PR against master in a few minutes.
author_association: MEMBER
user: login: juliohm
created_at: 2025-05-21T18:17:16Z
body: Patch released: https://github.com/JuliaRegistries/General/pull/131456
author_association: MEMBER