Skip to content

Commit ac189d6

Browse files
committed
Improve girder cap loop traversal and coverage tests
1 parent 13e577a commit ac189d6

2 files changed

Lines changed: 173 additions & 19 deletions

File tree

src/main/java/com/kipti/bnb/content/girder_strut/cap/GirderCapAccumulator.java

Lines changed: 101 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import java.util.Collections;
1818
import java.util.Comparator;
1919
import java.util.HashMap;
20+
import java.util.LinkedHashMap;
2021
import java.util.List;
2122
import 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) {

src/test/java/com/kipti/bnb/content/girder_strut/cap/GirderCapAccumulatorTest.java

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@
88
import org.junit.jupiter.api.Test;
99

1010
import java.util.ArrayList;
11+
import java.util.HashMap;
1112
import java.util.HashSet;
1213
import java.util.List;
14+
import java.util.Map;
1315
import java.util.Set;
1416

1517
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
@@ -58,13 +60,14 @@ void duplicateSegmentsProduceIndependentLoops() {
5860
accumulator.addSegments(null, 1, true, segments);
5961

6062
List<GirderCapAccumulator.CapLoop> loops = accumulator.buildLoops(PLANE_POINT, PLANE_NORMAL);
61-
assertEquals(2, loops.size(), "duplicated segments should trace two separate loops");
63+
assertEquals(1, loops.size(), "duplicated segments with identical attributes collapse to a single loop");
6264

6365
Set<Vector3f> expected = collectProjectedPositions(baseSegments, PLANE_POINT, PLANE_NORMAL);
64-
for (GirderCapAccumulator.CapLoop loop : loops) {
65-
assertEquals(4, loop.vertices().size(), "each loop should contain the square corners");
66-
assertVerticesMatch(loop.vertices(), expected);
67-
}
66+
GirderCapAccumulator.CapLoop loop = loops.getFirst();
67+
assertEquals(4, loop.vertices().size(), "loop should contain the square corners");
68+
assertVerticesMatch(loop.vertices(), expected);
69+
assertPositiveArea(loop.vertices());
70+
assertAllProjectedVerticesCovered(loops, segments, PLANE_POINT, PLANE_NORMAL);
6871
}
6972

7073
@Test
@@ -101,7 +104,9 @@ void complexSegmentSetProducesExpectedCoverage() {
101104
for (GirderCapAccumulator.CapLoop loop : loops) {
102105
assertFalse(loop.vertices().isEmpty(), "loop should contain vertices");
103106
assertVerticesMatch(loop.vertices(), expected);
107+
assertPositiveArea(loop.vertices());
104108
}
109+
assertAllProjectedVerticesCovered(loops, segments, planePoint, PLANE_NORMAL);
105110
}
106111

107112
private static void assertVerticesMatch(List<GirderCapAccumulator.CapVertex> vertices, Set<Vector3f> expected) {
@@ -112,6 +117,68 @@ private static void assertVerticesMatch(List<GirderCapAccumulator.CapVertex> ver
112117
}
113118
}
114119

120+
private static void assertAllProjectedVerticesCovered(
121+
List<GirderCapAccumulator.CapLoop> loops,
122+
List<GirderMeshQuad.Segment> segments,
123+
Vector3f planePoint,
124+
Vector3f planeNormal
125+
) {
126+
Map<String, Integer> usage = new HashMap<>();
127+
for (GirderMeshQuad.Segment segment : segments) {
128+
increment(usage, project(segment.start().position(), planePoint, planeNormal));
129+
increment(usage, project(segment.end().position(), planePoint, planeNormal));
130+
}
131+
132+
List<Vector3f> missing = new ArrayList<>();
133+
for (Map.Entry<String, Integer> entry : usage.entrySet()) {
134+
if (entry.getValue() < 2) {
135+
continue;
136+
}
137+
Vector3f position = decode(entry.getKey());
138+
boolean covered = false;
139+
for (GirderCapAccumulator.CapLoop loop : loops) {
140+
covered = loop.vertices().stream().anyMatch(vertex -> closeEnough(position, vertex.position()));
141+
if (covered) {
142+
break;
143+
}
144+
}
145+
if (!covered) {
146+
missing.add(position);
147+
}
148+
}
149+
assertTrue(missing.isEmpty(), "projected vertices were not covered: " + missing);
150+
}
151+
152+
private static void assertPositiveArea(List<GirderCapAccumulator.CapVertex> vertices) {
153+
float area = 0f;
154+
int size = vertices.size();
155+
for (int i = 0; i < size; i++) {
156+
Vector3f current = vertices.get(i).position();
157+
Vector3f next = vertices.get((i + 1) % size).position();
158+
area += (current.x * next.y) - (next.x * current.y);
159+
}
160+
assertTrue(Math.abs(area) > GirderGeometry.EPSILON, "loop collapsed to zero-area polygon");
161+
}
162+
163+
private static void increment(Map<String, Integer> usage, Vector3f position) {
164+
usage.merge(quantize(position), 1, Integer::sum);
165+
}
166+
167+
private static String quantize(Vector3f position) {
168+
int x = Math.round(position.x / GirderGeometry.EPSILON);
169+
int y = Math.round(position.y / GirderGeometry.EPSILON);
170+
int z = Math.round(position.z / GirderGeometry.EPSILON);
171+
return x + ":" + y + ":" + z;
172+
}
173+
174+
private static Vector3f decode(String key) {
175+
String[] parts = key.split(":");
176+
float x = Integer.parseInt(parts[0]) * GirderGeometry.EPSILON;
177+
float y = Integer.parseInt(parts[1]) * GirderGeometry.EPSILON;
178+
float z = Integer.parseInt(parts[2]) * GirderGeometry.EPSILON;
179+
return new Vector3f(x, y, z);
180+
}
181+
115182
private static Set<Vector3f> collectProjectedPositions(
116183
List<GirderMeshQuad.Segment> segments,
117184
Vector3f planePoint,

0 commit comments

Comments
 (0)