1717import java .util .Collections ;
1818import java .util .Comparator ;
1919import java .util .HashMap ;
20+ import java .util .LinkedHashMap ;
2021import java .util .List ;
2122import java .util .Map ;
2223
@@ -153,7 +154,13 @@ List<CapLoop> buildLoops(Vector3f planePoint, Vector3f planeNormal) {
153154 );
154155 }
155156
156- return Collections .unmodifiableList (loops );
157+ List <CapLoop > deduped = dedupeLoops (loops );
158+ if (deduped .size () != loops .size ()) {
159+ CreateBitsnBobs .LOGGER .debug (
160+ "[GirderCap] removed {} duplicate loops" , loops .size () - deduped .size ()
161+ );
162+ }
163+ return Collections .unmodifiableList (deduped );
157164 }
158165
159166 private void emitLoop (
@@ -224,9 +231,6 @@ private static List<CapVertex> traceLoop(CapEdge start, Map<VertexKey, List<CapE
224231 return List .of ();
225232 }
226233 current .setUsed (true );
227- if (current .twin () != null ) {
228- current .twin ().setUsed (true );
229- }
230234 loop .add (current .start ().copy ());
231235
232236 CapEdge next = findNextEdge (current , outgoing , start );
@@ -256,18 +260,15 @@ private static CapEdge findNextEdge(CapEdge current, Map<VertexKey, List<CapEdge
256260 return null ;
257261 }
258262
259- int twinIndex = current .twin () == null ? -1 : star .indexOf (current .twin ());
260- if (twinIndex >= 0 ) {
261- int size = star .size ();
262- for (int offset = 1 ; offset <= size ; offset ++) {
263- int index = (twinIndex - offset + size ) % size ;
264- CapEdge candidate = star .get (index );
265- if (!candidate .used () || candidate == start ) {
266- return candidate ;
267- }
268- }
263+ float baseAngle = current .twin () != null ? current .twin ().normalizedAngle () : CapEdge .normalizeAngle (current .angle () + (float ) Math .PI );
264+
265+ CapEdge best = selectNextEdge (star , baseAngle , start );
266+ if (best != null ) {
267+ return best ;
269268 }
270269
270+ // If every unused edge lies exactly on the base direction we may have a degenerate wedge.
271+ // Fall back to any available edge to avoid abandoning the loop outright.
271272 for (CapEdge candidate : star ) {
272273 if (!candidate .used () || candidate == start ) {
273274 return candidate ;
@@ -277,6 +278,39 @@ private static CapEdge findNextEdge(CapEdge current, Map<VertexKey, List<CapEdge
277278 return null ;
278279 }
279280
281+ private static List <CapLoop > dedupeLoops (List <CapLoop > loops ) {
282+ Map <LoopSignature , CapLoop > unique = new LinkedHashMap <>();
283+ for (CapLoop loop : loops ) {
284+ LoopSignature signature = LoopSignature .from (loop );
285+ unique .putIfAbsent (signature , loop );
286+ }
287+ return new ArrayList <>(unique .values ());
288+ }
289+
290+ private static CapEdge selectNextEdge (List <CapEdge > star , float baseAngle , CapEdge start ) {
291+ CapEdge best = null ;
292+ float bestDelta = Float .POSITIVE_INFINITY ;
293+ for (CapEdge candidate : star ) {
294+ if (candidate .used () && candidate != start ) {
295+ continue ;
296+ }
297+ float delta = angleDelta (baseAngle , candidate .normalizedAngle ());
298+ if (delta < bestDelta - GirderGeometry .EPSILON ) {
299+ bestDelta = delta ;
300+ best = candidate ;
301+ }
302+ }
303+ return best ;
304+ }
305+
306+ private static float angleDelta (float from , float to ) {
307+ float delta = to - from ;
308+ while (delta <= 0f ) {
309+ delta += (float ) (Math .PI * 2.0 );
310+ }
311+ return delta ;
312+ }
313+
280314 private record CapSegment (CapVertex start , CapVertex end , int tintIndex , boolean shade ) {
281315 }
282316
@@ -286,6 +320,45 @@ record CapLoop(LoopKey key, List<CapVertex> vertices) {
286320 private record LoopKey (int tintIndex , boolean shade ) {
287321 }
288322
323+ private record LoopSignature (LoopKey key , String geometry ) {
324+
325+ static LoopSignature from (CapLoop loop ) {
326+ return new LoopSignature (loop .key (), canonicalGeometry (loop ));
327+ }
328+
329+ private static String canonicalGeometry (CapLoop loop ) {
330+ List <VertexKey > keys = new ArrayList <>(loop .vertices ().size ());
331+ for (CapVertex vertex : loop .vertices ()) {
332+ keys .add (VertexKey .from (vertex .position ()));
333+ }
334+ String forward = canonicalOrientation (keys );
335+ List <VertexKey > reversed = new ArrayList <>(keys );
336+ java .util .Collections .reverse (reversed );
337+ String backward = canonicalOrientation (reversed );
338+ return forward .compareTo (backward ) <= 0 ? forward : backward ;
339+ }
340+
341+ private static String canonicalOrientation (List <VertexKey > keys ) {
342+ if (keys .isEmpty ()) {
343+ return "" ;
344+ }
345+ int size = keys .size ();
346+ String best = null ;
347+ for (int offset = 0 ; offset < size ; offset ++) {
348+ StringBuilder builder = new StringBuilder ();
349+ for (int i = 0 ; i < size ; i ++) {
350+ VertexKey key = keys .get ((offset + i ) % size );
351+ builder .append (key .x ()).append (',' ).append (key .y ()).append (',' ).append (key .z ()).append (';' );
352+ }
353+ String candidate = builder .toString ();
354+ if (best == null || candidate .compareTo (best ) < 0 ) {
355+ best = candidate ;
356+ }
357+ }
358+ return best ;
359+ }
360+ }
361+
289362 static final class CapVertex {
290363
291364 private final Vector3f position ;
@@ -367,6 +440,7 @@ private static final class CapEdge {
367440 private final VertexKey startKey ;
368441 private final VertexKey endKey ;
369442 private final float angle ;
443+ private final float normalizedAngle ;
370444 private CapEdge twin ;
371445 private boolean used ;
372446
@@ -376,6 +450,7 @@ private static final class CapEdge {
376450 this .startKey = startKey ;
377451 this .endKey = endKey ;
378452 this .angle = angle ;
453+ this .normalizedAngle = normalizeAngle (angle );
379454 }
380455
381456 CapVertex start () {
@@ -398,6 +473,10 @@ float angle() {
398473 return angle ;
399474 }
400475
476+ float normalizedAngle () {
477+ return normalizedAngle ;
478+ }
479+
401480 boolean used () {
402481 return used ;
403482 }
@@ -413,6 +492,14 @@ CapEdge twin() {
413492 void setTwin (CapEdge twin ) {
414493 this .twin = twin ;
415494 }
495+
496+ static float normalizeAngle (float angle ) {
497+ float normalized = angle % (float ) (Math .PI * 2.0 );
498+ if (normalized < 0f ) {
499+ normalized += (float ) (Math .PI * 2.0 );
500+ }
501+ return normalized ;
502+ }
416503 }
417504
418505 private static Vector2f planarCoordinates (Vector3f position , Vector3f planePoint , Vector3f uAxis , Vector3f vAxis ) {
0 commit comments