diff --git a/build.gradle b/build.gradle index 0d62f5ae..e49c2187 100644 --- a/build.gradle +++ b/build.gradle @@ -100,6 +100,10 @@ dependencies { compileOnly("dev.engine-room.flywheel:flywheel-neoforge-api-${minecraft_version}:${flywheel_version}") runtimeOnly("dev.engine-room.flywheel:flywheel-neoforge-${minecraft_version}:${flywheel_version}") implementation("com.tterrag.registrate:Registrate:${registrate_version}") + + testImplementation(platform("org.junit:junit-bom:5.10.2")) + testImplementation("org.junit.jupiter:junit-jupiter-api") + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine") } // This block of code expands all declared replace properties in the specified resource targets. @@ -145,6 +149,10 @@ tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' // Use the UTF-8 charset for Java compilation } +tasks.withType(Test).configureEach { + useJUnitPlatform() +} + // IDEA no longer automatically downloads sources/javadoc jars for dependencies, so we need to explicitly enable the behavior. idea { module { diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/src/main/java/com/kipti/bnb/content/girder_strut/cap/GirderCapAccumulator.java b/src/main/java/com/kipti/bnb/content/girder_strut/cap/GirderCapAccumulator.java index 8a422fe2..2237bd17 100644 --- a/src/main/java/com/kipti/bnb/content/girder_strut/cap/GirderCapAccumulator.java +++ b/src/main/java/com/kipti/bnb/content/girder_strut/cap/GirderCapAccumulator.java @@ -1,21 +1,40 @@ package com.kipti.bnb.content.girder_strut.cap; +import com.kipti.bnb.CreateBitsnBobs; import com.kipti.bnb.content.girder_strut.geometry.GirderGeometry; import com.kipti.bnb.content.girder_strut.geometry.GirderVertex; import com.kipti.bnb.content.girder_strut.mesh.GirderMeshQuad; +import net.createmod.catnip.theme.Color; import net.minecraft.client.Minecraft; import net.minecraft.client.renderer.block.model.BakedQuad; import net.minecraft.client.renderer.texture.TextureAtlasSprite; import net.minecraft.core.Direction; import net.minecraft.resources.ResourceLocation; import net.minecraft.world.inventory.InventoryMenu; +import org.joml.Vector2f; import org.joml.Vector3f; import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; public final class GirderCapAccumulator { + private static final float POSITION_TOLERANCE = 1.0e-4f; + private static final boolean DEBUG_OUTLINES = true; + private static final AtomicInteger DEBUG_SEQUENCE = new AtomicInteger(); + private static final String DEBUG_KEY_ROOT = "girderCap"; + private static final Color SEGMENT_COLOR = new Color(245, 168, 66); + private static final Color LOOP_COLOR = new Color(201, 86, 228); + private static final Color PROJECTED_COLOR = new Color(86, 203, 228); + private static final Color FINAL_COLOR = new Color(114, 228, 86); + private static final Color PLANE_NORMAL_COLOR = new Color(255, 255, 255); + private final ResourceLocation stoneLocation; private final List segments = new ArrayList<>(); @@ -36,226 +55,158 @@ public void addSegments(TextureAtlasSprite sourceSprite, int tintIndex, boolean } public void emitCaps(Vector3f planePoint, Vector3f planeNormal, List consumer) { - if (segments.isEmpty()) { + int debugId = 0; + if (DEBUG_OUTLINES) { + debugId = DEBUG_SEQUENCE.incrementAndGet(); + debugPlane(debugId, planePoint, planeNormal); + debugSegments(debugId); + } + + List loops = buildLoops(planePoint, planeNormal); + if (DEBUG_OUTLINES) { + debugLoopInputs(debugId, loops); + } + if (loops.isEmpty()) { return; } + + TextureAtlasSprite stoneSprite = Minecraft.getInstance().getTextureAtlas(InventoryMenu.BLOCK_ATLAS).apply(stoneLocation); + + for (int i = 0; i < loops.size(); i++) { + CapLoop loop = loops.get(i); + emitLoop(loop.vertices(), planeNormal, planePoint, stoneSprite, loop.key().tintIndex(), loop.key().shade(), consumer, debugId, i); + } + + segments.clear(); + } + + List buildLoops(Vector3f planePoint, Vector3f planeNormal) { + if (segments.isEmpty()) { + return List.of(); + } + Vector3f normal = new Vector3f(planeNormal); if (normal.lengthSquared() <= GirderGeometry.EPSILON) { - return; + return List.of(); } normal.normalize(); - Vector3f point = new Vector3f(planePoint); - - TextureAtlasSprite stoneSprite = Minecraft.getInstance().getTextureAtlas(InventoryMenu.BLOCK_ATLAS).apply(stoneLocation); - // Build unique vertex list and edge list - List uniqueVertices = new ArrayList<>(); - List edges = new ArrayList<>(); + Vector3f point = new Vector3f(planePoint); + Vector3f uAxis = buildPerpendicular(normal); + Vector3f vAxis = new Vector3f(normal).cross(uAxis); + if (vAxis.lengthSquared() > GirderGeometry.EPSILON) { + vAxis.normalize(); + } - System.out.println("=== Cap Accumulator Debug ==="); - System.out.println("Total segments: " + segments.size()); - + Map> grouped = new HashMap<>(); for (CapSegment segment : segments) { - int startIndex = indexFor(uniqueVertices, segment.start()); - int endIndex = indexFor(uniqueVertices, segment.end()); - System.out.println("Segment: " + startIndex + " -> " + endIndex + - " (start=" + segment.start().position() + ", end=" + segment.end().position() + ")"); - if (startIndex == endIndex) { - System.out.println(" -> Skipped (degenerate)"); - continue; - } - edges.add(new LoopEdge(startIndex, endIndex, segment.tintIndex(), segment.shade())); + grouped.computeIfAbsent(new LoopKey(segment.tintIndex(), segment.shade()), key -> new ArrayList<>()).add(segment); } - - System.out.println("Unique vertices: " + uniqueVertices.size()); - System.out.println("Edges: " + edges.size()); - - // Build closed loops from edges - emit each loop separately - int loopCount = 0; - while (true) { - LoopEdge startEdge = findUnusedEdge(edges); - if (startEdge == null) { - break; - } - List loop = new ArrayList<>(); - loop.add(startEdge.start()); - loop.add(startEdge.end()); - startEdge.markUsed(); - - int tintIndex = startEdge.tintIndex(); - boolean shade = startEdge.shade(); - int current = startEdge.end(); - boolean closed = false; - - System.out.println("Building loop " + loopCount + " starting from edge " + startEdge.start() + " -> " + startEdge.end()); - - // Keep walking edges until we can't find the next edge or we close the loop - int maxSteps = edges.size() + 1; // Prevent infinite loops - int steps = 0; - while (steps < maxSteps) { - steps++; - if (current == loop.get(0)) { - // We've returned to the start - loop is closed - System.out.println(" Loop closed after " + steps + " steps with " + loop.size() + " vertices"); - closed = true; - break; + CreateBitsnBobs.LOGGER.debug("[GirderCap] building loops: {} segment groups", grouped.size()); + + List loops = new ArrayList<>(); + for (Map.Entry> entry : grouped.entrySet()) { + LoopKey key = entry.getKey(); + List groupSegments = entry.getValue(); + CreateBitsnBobs.LOGGER.debug( + "[GirderCap] tracing group tint={} shade={} segments={}", + key.tintIndex(), + key.shade(), + groupSegments.size() + ); + + List edges = new ArrayList<>(); + Map> outgoing = new HashMap<>(); + + for (CapSegment segment : groupSegments) { + CapVertex start = segment.start().copy(); + CapVertex end = segment.end().copy(); + if (GirderGeometry.positionsEqual(start.position(), end.position())) { + continue; } - - LoopEdge nextEdge = findAndUseEdge(edges, current); - if (nextEdge == null) { - // Can't close the loop, abandon it - System.out.println(" Loop abandoned - no edge from vertex " + current); - break; + + Vector2f startUv = planarCoordinates(start.position(), point, uAxis, vAxis); + Vector2f endUv = planarCoordinates(end.position(), point, uAxis, vAxis); + Vector2f delta = new Vector2f(endUv).sub(startUv); + if (delta.lengthSquared() <= GirderGeometry.EPSILON) { + continue; } - - int nextVertex = nextEdge.other(current); - loop.add(nextVertex); - current = nextVertex; - } - if (closed && loop.size() > 2) { - // Remove the duplicate closing vertex - loop.remove(loop.size() - 1); - System.out.println(" Emitting loop with " + loop.size() + " vertices"); - emitLoop(loop, uniqueVertices, tintIndex, shade, normal, point, stoneSprite, consumer); - loopCount++; - } else { - System.out.println(" Loop rejected: closed=" + closed + ", size=" + loop.size()); - } - } - - System.out.println("Total loops emitted: " + loopCount); - System.out.println("=== End Debug ==="); - -// for (CapSegment segment : segments) { -// int startIndex = indexFor(uniqueVertices, segment.start()); -// int endIndex = indexFor(uniqueVertices, segment.end()); -// if (startIndex == endIndex) { -// continue; -// } -// edges.add(new LoopEdge(startIndex, endIndex, segment.tintIndex(), segment.shade())); -// } -// -// while (true) { -// LoopEdge startEdge = findUnusedEdge(edges); -// if (startEdge == null) { -// break; -// } -// -// List loop = new ArrayList<>(); -// loop.add(startEdge.start()); -// loop.add(startEdge.end()); -// startEdge.markUsed(); -// -// int tintIndex = startEdge.tintIndex(); -// boolean shade = startEdge.shade(); -// int current = startEdge.end(); -// boolean closed = false; -// -// while (current != loop.get(0)) { -// LoopEdge nextEdge = findAndUseEdge(edges, current); -// if (nextEdge == null) { -// loop.clear(); -// break; -// } -// int nextVertex = nextEdge.other(current); -// loop.add(nextVertex); -// current = nextVertex; -// if (current == loop.get(0)) { -// closed = true; -// } -// } -// -// if (closed && loop.size() > 2) { -// loop.remove(loop.size() - 1); -// emitLoop(loop, uniqueVertices, tintIndex, shade, normal, point, stoneSprite, consumer); -// } -// } + VertexKey startKey = VertexKey.from(start.position()); + VertexKey endKey = VertexKey.from(end.position()); - segments.clear(); - } + float forwardAngle = (float) Math.atan2(delta.y, delta.x); + float reverseAngle = (float) Math.atan2(-delta.y, -delta.x); - private int indexFor(List vertices, CapVertex vertex) { - for (int i = 0; i < vertices.size(); i++) { - if (positionsClose(vertices.get(i).position(), vertex.position())) { - return i; - } - } - vertices.add(vertex.copy()); - return vertices.size() - 1; - } + CapEdge forward = new CapEdge(start, end, startKey, endKey, forwardAngle); + CapEdge backward = new CapEdge(end.copy(), start.copy(), endKey, startKey, reverseAngle); + forward.setTwin(backward); + backward.setTwin(forward); - /** - * Compare two positions using a very relaxed tolerance to merge vertices that lie on - * the same edge of the clipping plane, even if they come from different quads. - * This is necessary because each quad generates its own clipped vertices independently. - */ - private static boolean positionsClose(org.joml.Vector3f a, org.joml.Vector3f b) { - float dx = a.x - b.x; - float dy = a.y - b.y; - float dz = a.z - b.z; - // Use a larger tolerance to merge vertices on the same plane edge - float tol = 0.01f; // 1 centimeter in block units - return dx * dx + dy * dy + dz * dz <= tol * tol; - } + edges.add(forward); - private LoopEdge findUnusedEdge(List edges) { - for (LoopEdge edge : edges) { - if (!edge.used()) { - return edge; + registerEdge(outgoing, forward); + registerEdge(outgoing, backward); } - } - return null; - } - private LoopEdge findAndUseEdge(List edges, int vertexIndex) { - for (LoopEdge edge : edges) { - if (edge.used()) { - continue; + for (List star : outgoing.values()) { + star.sort(Comparator.comparingDouble(CapEdge::angle)); } - if (edge.start() == vertexIndex || edge.end() == vertexIndex) { - edge.markUsed(); - return edge; + + int loopCount = 0; + for (CapEdge edge : edges) { + if (edge.used()) { + continue; + } + List traced = traceLoop(edge, outgoing); + if (traced.size() >= 3) { + loops.add(new CapLoop(key, traced)); + loopCount++; + } } + + CreateBitsnBobs.LOGGER.debug( + "[GirderCap] traced {} loops for tint={} shade={} (edges={})", + loopCount, + key.tintIndex(), + key.shade(), + edges.size() + ); } - return null; + + List deduped = dedupeLoops(loops); + if (deduped.size() != loops.size()) { + CreateBitsnBobs.LOGGER.debug( + "[GirderCap] removed {} duplicate loops", loops.size() - deduped.size() + ); + } + return Collections.unmodifiableList(deduped); } private void emitLoop( - List loopIndices, - List vertices, - int tintIndex, - boolean shade, + List loopVertices, Vector3f planeNormal, Vector3f planePoint, TextureAtlasSprite stoneSprite, - List consumer + int tintIndex, + boolean shade, + List consumer, + int debugId, + int loopIndex ) { - // Use the cut-facing normal (flip the supplied plane normal) so the cap - // quads face into the cut, not towards the surface. Vector3f normalizedPlane = new Vector3f(planeNormal); if (normalizedPlane.lengthSquared() > GirderGeometry.EPSILON) { normalizedPlane.normalize(); } Vector3f faceNormal = new Vector3f(normalizedPlane).negate(); - List loopVertices = new ArrayList<>(loopIndices.size()); - for (int index : loopIndices) { - CapVertex data = vertices.get(index); - // Project the vertex onto the clipping plane - Vector3f projectedPosition = new Vector3f(data.position()); - float distance = GirderGeometry.signedDistance(projectedPosition, normalizedPlane, planePoint); - if (Math.abs(distance) > GirderGeometry.EPSILON) { - projectedPosition.sub(new Vector3f(normalizedPlane).mul(distance)); - } - - // Use proper UV mapping based on position - // Create a coordinate system on the plane for UV mapping + List loop = new ArrayList<>(loopVertices.size()); + for (CapVertex data : loopVertices) { + Vector3f projectedPosition = projectOntoPlane(data.position(), normalizedPlane, planePoint); float remappedU = GirderGeometry.remapU(data.u(), data.sourceSprite(), stoneSprite); float remappedV = GirderGeometry.remapV(data.v(), data.sourceSprite(), stoneSprite); - - loopVertices.add(new GirderVertex( + loop.add(new GirderVertex( projectedPosition, new Vector3f(faceNormal), remappedU, @@ -265,12 +216,23 @@ private void emitLoop( )); } - List cleaned = GirderGeometry.dedupeLoopVertices(loopVertices); + if (DEBUG_OUTLINES) { + debugProjectedLoop(debugId, loopIndex, loop, PROJECTED_COLOR, 1f / 64f, "projected"); + } + + List cleaned = GirderGeometry.dedupeLoopVertices(loop); if (cleaned.size() < 3) { + CreateBitsnBobs.LOGGER.debug("[GirderCap] loop dropped after dedupe - insufficient vertices {}", cleaned.size()); + if (DEBUG_OUTLINES) { + debugProjectedLoop(debugId, loopIndex, cleaned, FINAL_COLOR, 1f / 96f, "final"); + } return; } - // Check winding order and reverse if needed + if (DEBUG_OUTLINES) { + debugProjectedLoop(debugId, loopIndex, cleaned, FINAL_COLOR, 1f / 96f, "final"); + } + Vector3f polygonNormal = GirderGeometry.computePolygonNormal(cleaned); if (polygonNormal.lengthSquared() > GirderGeometry.EPSILON && polygonNormal.dot(faceNormal) < 0f) { java.util.Collections.reverse(cleaned); @@ -280,10 +242,243 @@ private void emitLoop( GirderGeometry.emitPolygon(cleaned, stoneSprite, face, tintIndex, shade, consumer); } + private static Vector3f projectOntoPlane(Vector3f position, Vector3f normal, Vector3f point) { + Vector3f projected = new Vector3f(position); + float distance = GirderGeometry.signedDistance(projected, normal, point); + if (Math.abs(distance) > GirderGeometry.EPSILON) { + projected.sub(new Vector3f(normal).mul(distance)); + } + return projected; + } + + private static void registerEdge(Map> outgoing, CapEdge edge) { + outgoing.computeIfAbsent(edge.startKey(), keyIgnored -> new ArrayList<>()).add(edge); + } + + private static List traceLoop(CapEdge start, Map> outgoing) { + List loop = new ArrayList<>(); + CapEdge current = start; + int guard = 0; + while (true) { + if (current.used()) { + CreateBitsnBobs.LOGGER.debug("[GirderCap] aborting loop trace - encountered used edge mid trace"); + return List.of(); + } + current.setUsed(true); + loop.add(current.start().copy()); + + CapEdge next = findNextEdge(current, outgoing, start); + if (next == null) { + CreateBitsnBobs.LOGGER.debug("[GirderCap] loop trace failed - no exit from vertex {}", VertexKey.from(current.end().position())); + return List.of(); + } + + if (next == start) { + break; + } + + current = next; + guard++; + if (guard > 4096) { + CreateBitsnBobs.LOGGER.warn("[GirderCap] aborting loop trace - guard triggered"); + return List.of(); + } + } + return loop; + } + + private static CapEdge findNextEdge(CapEdge current, Map> outgoing, CapEdge start) { + VertexKey endKey = current.endKey(); + List star = outgoing.get(endKey); + if (star == null || star.isEmpty()) { + return null; + } + + float baseAngle = current.twin() != null ? current.twin().normalizedAngle() : CapEdge.normalizeAngle(current.angle() + (float) Math.PI); + + CapEdge best = selectNextEdge(star, baseAngle, start); + if (best != null) { + return best; + } + + // If every unused edge lies exactly on the base direction we may have a degenerate wedge. + // Fall back to any available edge to avoid abandoning the loop outright. + for (CapEdge candidate : star) { + if (!candidate.used() || candidate == start) { + return candidate; + } + } + + return null; + } + + private static List dedupeLoops(List loops) { + Map unique = new LinkedHashMap<>(); + for (CapLoop loop : loops) { + LoopSignature signature = LoopSignature.from(loop); + unique.putIfAbsent(signature, loop); + } + return new ArrayList<>(unique.values()); + } + + private static CapEdge selectNextEdge(List star, float baseAngle, CapEdge start) { + CapEdge best = null; + float bestDelta = Float.POSITIVE_INFINITY; + for (CapEdge candidate : star) { + if (candidate.used() && candidate != start) { + continue; + } + float delta = angleDelta(baseAngle, candidate.normalizedAngle()); + if (delta < bestDelta - GirderGeometry.EPSILON) { + bestDelta = delta; + best = candidate; + } + } + return best; + } + + private static float angleDelta(float from, float to) { + float delta = to - from; + while (delta <= 0f) { + delta += (float) (Math.PI * 2.0); + } + return delta; + } + + private void debugPlane(int debugId, Vector3f planePoint, Vector3f planeNormal) { + if (!DEBUG_OUTLINES) { + return; + } + Vector3f normal = new Vector3f(planeNormal); + if (normal.lengthSquared() <= GirderGeometry.EPSILON) { + return; + } + normal.normalize(); + Vector3f start = new Vector3f(planePoint); + Vector3f end = new Vector3f(planePoint).add(new Vector3f(normal).mul(0.5f)); + showLine(debugKey(debugId, "plane", 0, 0), start, end, PLANE_NORMAL_COLOR, 1f / 32f); + } + + private void debugSegments(int debugId) { + if (!DEBUG_OUTLINES || segments.isEmpty()) { + return; + } + int index = 0; + for (CapSegment segment : segments) { + Vector3f start = segment.start().position(); + Vector3f end = segment.end().position(); + if (GirderGeometry.positionsEqual(start, end)) { + continue; + } + showLine(debugKey(debugId, "segments", index, 0), start, end, SEGMENT_COLOR, 1f / 24f); + index++; + } + } + + private void debugLoopInputs(int debugId, List loops) { + if (!DEBUG_OUTLINES || loops.isEmpty()) { + return; + } + for (int i = 0; i < loops.size(); i++) { + CapLoop loop = loops.get(i); + List positions = new ArrayList<>(loop.vertices().size()); + for (CapVertex vertex : loop.vertices()) { + positions.add(new Vector3f(vertex.position())); + } + showPolyline(debugId, "loops", i, positions, LOOP_COLOR, 1f / 48f, true); + } + } + + private void debugProjectedLoop(int debugId, int loopIndex, List vertices, Color color, float width, String stage) { + if (!DEBUG_OUTLINES || vertices.isEmpty()) { + return; + } + List positions = new ArrayList<>(vertices.size()); + for (GirderVertex vertex : vertices) { + positions.add(new Vector3f(vertex.position())); + } + showPolyline(debugId, stage, loopIndex, positions, color, width, true); + } + + private static void showPolyline(int debugId, String stage, int groupIndex, List positions, Color color, float width, boolean closed) { + if (positions.size() < 2) { + return; + } + int segmentIndex = 0; + for (int i = 0; i < positions.size() - 1; i++) { + Vector3f start = positions.get(i); + Vector3f end = positions.get(i + 1); + if (GirderGeometry.positionsEqual(start, end)) { + continue; + } + showLine(debugKey(debugId, stage, groupIndex, segmentIndex++), start, end, color, width); + } + if (closed) { + Vector3f start = positions.get(positions.size() - 1); + Vector3f end = positions.get(0); + if (!GirderGeometry.positionsEqual(start, end)) { + showLine(debugKey(debugId, stage, groupIndex, segmentIndex), start, end, color, width); + } + } + } + + private static void showLine(String key, Vector3f from, Vector3f to, Color color, float width) { + GirderCapDebugOutlines.queueLine(key, from, to, color, width); + } + + private static String debugKey(int debugId, String stage, int groupIndex, int segmentIndex) { + return DEBUG_KEY_ROOT + '/' + debugId + '/' + stage + '/' + groupIndex + '/' + segmentIndex; + } + private record CapSegment(CapVertex start, CapVertex end, int tintIndex, boolean shade) { } - private static final class CapVertex { + record CapLoop(LoopKey key, List vertices) { + } + + private record LoopKey(int tintIndex, boolean shade) { + } + + private record LoopSignature(LoopKey key, String geometry) { + + static LoopSignature from(CapLoop loop) { + return new LoopSignature(loop.key(), canonicalGeometry(loop)); + } + + private static String canonicalGeometry(CapLoop loop) { + List keys = new ArrayList<>(loop.vertices().size()); + for (CapVertex vertex : loop.vertices()) { + keys.add(VertexKey.from(vertex.position())); + } + String forward = canonicalOrientation(keys); + List reversed = new ArrayList<>(keys); + java.util.Collections.reverse(reversed); + String backward = canonicalOrientation(reversed); + return forward.compareTo(backward) <= 0 ? forward : backward; + } + + private static String canonicalOrientation(List keys) { + if (keys.isEmpty()) { + return ""; + } + int size = keys.size(); + String best = null; + for (int offset = 0; offset < size; offset++) { + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < size; i++) { + VertexKey key = keys.get((offset + i) % size); + builder.append(key.x()).append(',').append(key.y()).append(',').append(key.z()).append(';'); + } + String candidate = builder.toString(); + if (best == null || candidate.compareTo(best) < 0) { + best = candidate; + } + } + return best; + } + } + + static final class CapVertex { private final Vector3f position; private final float u; @@ -334,47 +529,100 @@ CapVertex copy() { } } - private static final class LoopEdge { + private Vector3f buildPerpendicular(Vector3f normal) { + Vector3f basis = Math.abs(normal.x) < 0.9f ? new Vector3f(1f, 0f, 0f) : new Vector3f(0f, 1f, 0f); + Vector3f perpendicular = new Vector3f(normal).cross(basis); + if (perpendicular.lengthSquared() <= GirderGeometry.EPSILON) { + perpendicular = new Vector3f(normal).cross(new Vector3f(0f, 0f, 1f)); + } + if (perpendicular.lengthSquared() > GirderGeometry.EPSILON) { + perpendicular.normalize(); + } + return perpendicular; + } + + private record VertexKey(int x, int y, int z) { + + static VertexKey from(Vector3f position) { + return new VertexKey( + Math.round(position.x / POSITION_TOLERANCE), + Math.round(position.y / POSITION_TOLERANCE), + Math.round(position.z / POSITION_TOLERANCE) + ); + } + } + + private static final class CapEdge { - private final int start; - private final int end; - private final int tintIndex; - private final boolean shade; + private final CapVertex start; + private final CapVertex end; + private final VertexKey startKey; + private final VertexKey endKey; + private final float angle; + private final float normalizedAngle; + private CapEdge twin; private boolean used; - LoopEdge(int start, int end, int tintIndex, boolean shade) { + CapEdge(CapVertex start, CapVertex end, VertexKey startKey, VertexKey endKey, float angle) { this.start = start; this.end = end; - this.tintIndex = tintIndex; - this.shade = shade; + this.startKey = startKey; + this.endKey = endKey; + this.angle = angle; + this.normalizedAngle = normalizeAngle(angle); } - int start() { + CapVertex start() { return start; } - int end() { + CapVertex end() { return end; } - int tintIndex() { - return tintIndex; + VertexKey startKey() { + return startKey; } - boolean shade() { - return shade; + VertexKey endKey() { + return endKey; + } + + float angle() { + return angle; + } + + float normalizedAngle() { + return normalizedAngle; } boolean used() { return used; } - void markUsed() { - used = true; + void setUsed(boolean used) { + this.used = used; + } + + CapEdge twin() { + return twin; } - int other(int vertex) { - return vertex == start ? end : start; + void setTwin(CapEdge twin) { + this.twin = twin; } + + static float normalizeAngle(float angle) { + float normalized = angle % (float) (Math.PI * 2.0); + if (normalized < 0f) { + normalized += (float) (Math.PI * 2.0); + } + return normalized; + } + } + + private static Vector2f planarCoordinates(Vector3f position, Vector3f planePoint, Vector3f uAxis, Vector3f vAxis) { + Vector3f relative = new Vector3f(position).sub(planePoint); + return new Vector2f(relative.dot(uAxis), relative.dot(vAxis)); } } diff --git a/src/main/java/com/kipti/bnb/content/girder_strut/cap/GirderCapDebugOutlines.java b/src/main/java/com/kipti/bnb/content/girder_strut/cap/GirderCapDebugOutlines.java new file mode 100644 index 00000000..82d5fe4e --- /dev/null +++ b/src/main/java/com/kipti/bnb/content/girder_strut/cap/GirderCapDebugOutlines.java @@ -0,0 +1,53 @@ +package com.kipti.bnb.content.girder_strut.cap; + +import net.createmod.catnip.outliner.Outliner; +import net.createmod.catnip.theme.Color; +import net.minecraft.world.phys.Vec3; +import org.joml.Vector3f; + +import java.util.Objects; +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; + +public final class GirderCapDebugOutlines { + + private GirderCapDebugOutlines() { + } + + private static final Queue QUEUE = new ConcurrentLinkedQueue<>(); + + static void queueLine(String key, Vector3f from, Vector3f to, Color color, float width) { + if (key == null || from == null || to == null || color == null) { + return; + } + QUEUE.add(new Line(key, toVec3(from), toVec3(to), color, width)); + } + + public static void flush() { + if (QUEUE.isEmpty()) { + return; + } + Outliner outliner = Outliner.getInstance(); + Line line; + while ((line = QUEUE.poll()) != null) { + outliner + .showLine(line.key(), line.start(), line.end()) + .lineWidth(line.width()) + .colored(line.color()); + } + } + + private static Vec3 toVec3(Vector3f vector) { + return new Vec3(vector.x, vector.y, vector.z); + } + + private record Line(String key, Vec3 start, Vec3 end, Color color, float width) { + + private Line { + Objects.requireNonNull(key, "key"); + Objects.requireNonNull(start, "start"); + Objects.requireNonNull(end, "end"); + Objects.requireNonNull(color, "color"); + } + } +} diff --git a/src/main/java/com/kipti/bnb/foundation/ClientEvents.java b/src/main/java/com/kipti/bnb/foundation/ClientEvents.java index 0ccaae51..79fea6e4 100644 --- a/src/main/java/com/kipti/bnb/foundation/ClientEvents.java +++ b/src/main/java/com/kipti/bnb/foundation/ClientEvents.java @@ -1,6 +1,7 @@ package com.kipti.bnb.foundation; import com.kipti.bnb.content.girder_strut.GirderStrutPlacementEffects; +import com.kipti.bnb.content.girder_strut.cap.GirderCapDebugOutlines; import com.kipti.bnb.content.weathered_girder.WeatheredGirderWrenchBehaviour; import net.minecraft.client.Minecraft; import net.neoforged.api.distmarker.Dist; @@ -14,6 +15,7 @@ public class ClientEvents { @SubscribeEvent public static void onTickPost(ClientTickEvent.Post event) { WeatheredGirderWrenchBehaviour.tick(); + GirderCapDebugOutlines.flush(); } @SubscribeEvent diff --git a/src/test/java/com/kipti/bnb/content/girder_strut/cap/GirderCapAccumulatorTest.java b/src/test/java/com/kipti/bnb/content/girder_strut/cap/GirderCapAccumulatorTest.java new file mode 100644 index 00000000..9434cf12 --- /dev/null +++ b/src/test/java/com/kipti/bnb/content/girder_strut/cap/GirderCapAccumulatorTest.java @@ -0,0 +1,229 @@ +package com.kipti.bnb.content.girder_strut.cap; + +import com.kipti.bnb.content.girder_strut.geometry.GirderGeometry; +import com.kipti.bnb.content.girder_strut.geometry.GirderVertex; +import com.kipti.bnb.content.girder_strut.mesh.GirderMeshQuad; +import net.minecraft.resources.ResourceLocation; +import org.joml.Vector3f; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class GirderCapAccumulatorTest { + + private static final Vector3f PLANE_POINT = new Vector3f(0f, 0f, 0f); + private static final Vector3f PLANE_NORMAL = new Vector3f(0f, 0f, 1f); + + @Test + void singleRectangleProducesLoopWithProjectedVertices() { + GirderCapAccumulator accumulator = new GirderCapAccumulator(ResourceLocation.fromNamespaceAndPath("test", "stone")); + List segments = List.of( + new GirderMeshQuad.Segment(vertex(0f, 0f, 0f), vertex(1f, 0f, 0f)), + new GirderMeshQuad.Segment(vertex(1f, 0f, 0f), vertex(1f, 1f, 0f)), + new GirderMeshQuad.Segment(vertex(1f, 1f, 0f), vertex(0f, 1f, 0f)), + new GirderMeshQuad.Segment(vertex(0f, 1f, 0f), vertex(0f, 0f, 0f)) + ); + accumulator.addSegments(null, 0, false, segments); + + List loops = accumulator.buildLoops(PLANE_POINT, PLANE_NORMAL); + assertEquals(1, loops.size(), "expected a single loop for the rectangle"); + + GirderCapAccumulator.CapLoop loop = loops.getFirst(); + assertEquals(4, loop.vertices().size(), "loop should contain each corner"); + + Set expected = collectProjectedPositions(segments, PLANE_POINT, PLANE_NORMAL); + assertVerticesMatch(loop.vertices(), expected); + } + + @Test + void duplicateSegmentsProduceIndependentLoops() { + GirderCapAccumulator accumulator = new GirderCapAccumulator(ResourceLocation.fromNamespaceAndPath("test", "stone")); + List baseSegments = List.of( + new GirderMeshQuad.Segment(vertex(0.25f, 0.25f, 0f), vertex(0.75f, 0.25f, 0f)), + new GirderMeshQuad.Segment(vertex(0.75f, 0.25f, 0f), vertex(0.75f, 0.75f, 0f)), + new GirderMeshQuad.Segment(vertex(0.75f, 0.75f, 0f), vertex(0.25f, 0.75f, 0f)), + new GirderMeshQuad.Segment(vertex(0.25f, 0.75f, 0f), vertex(0.25f, 0.25f, 0f)) + ); + List segments = new ArrayList<>(); + segments.addAll(baseSegments); + segments.addAll(baseSegments); + accumulator.addSegments(null, 1, true, segments); + + List loops = accumulator.buildLoops(PLANE_POINT, PLANE_NORMAL); + assertEquals(1, loops.size(), "duplicated segments with identical attributes collapse to a single loop"); + + Set expected = collectProjectedPositions(baseSegments, PLANE_POINT, PLANE_NORMAL); + GirderCapAccumulator.CapLoop loop = loops.getFirst(); + assertEquals(4, loop.vertices().size(), "loop should contain the square corners"); + assertVerticesMatch(loop.vertices(), expected); + assertPositiveArea(loop.vertices()); + assertAllProjectedVerticesCovered(loops, segments, PLANE_POINT, PLANE_NORMAL); + } + + @Test + void complexSegmentSetProducesExpectedCoverage() { + GirderCapAccumulator accumulator = new GirderCapAccumulator(ResourceLocation.fromNamespaceAndPath("test", "stone")); + List segments = List.of( + segment(0.3750f, 1.0300f, 1.0010f, 0.3750f, 0.6768f, 1.0010f), + segment(0.3750f, 0.6768f, 1.0010f, 0.6250f, 0.6768f, 1.0010f), + segment(0.6250f, 0.6768f, 1.0010f, 0.6250f, 1.0300f, 1.0010f), + segment(0.6250f, 1.0300f, 1.0010f, 0.3750f, 1.0300f, 1.0010f), + segment(0.6250f, 0.9786f, 1.0010f, 0.6250f, 0.2714f, 1.0010f), + segment(0.3750f, 0.9786f, 1.0010f, 0.3750f, 0.2714f, 1.0010f), + segment(0.7500f, 0.09467f, 1.0010f, 0.2500f, 0.09467f, 1.0010f), + segment(0.7500f, 0.2714f, 1.0010f, 0.2500f, 0.2714f, 1.0010f), + segment(0.2500f, 0.6768f, 1.0010f, 0.2500f, 0.5884f, 1.0010f), + segment(0.2500f, 0.5884f, 1.0010f, 0.7500f, 0.5884f, 1.0010f), + segment(0.7500f, 0.5884f, 1.0010f, 0.7500f, 0.6768f, 1.0010f), + segment(0.7500f, 0.6768f, 1.0010f, 0.2500f, 0.6768f, 1.0010f), + segment(0.7500f, 0.2714f, 1.0010f, 0.7500f, 0.09467f, 1.0010f), + segment(0.2500f, 0.2714f, 1.0010f, 0.2500f, 0.09467f, 1.0010f), + segment(0.7500f, 0.9786f, 1.0010f, 0.2500f, 0.9786f, 1.0010f), + segment(0.2500f, 1.0820f, 1.0010f, 0.7500f, 1.0820f, 1.0010f), + segment(0.7500f, 1.0820f, 1.0010f, 0.7500f, 0.9786f, 1.0010f), + segment(0.2500f, 0.9786f, 1.0010f, 0.2500f, 1.0820f, 1.0010f) + ); + + assertDoesNotThrow(() -> accumulator.addSegments(null, 2, true, segments)); + List loops = accumulator.buildLoops(new Vector3f(0f, 0f, 1.001f), PLANE_NORMAL); + + assertEquals(4, loops.size(), "expected four discrete loops for the girder cap"); + + Vector3f planePoint = new Vector3f(0f, 0f, 1.001f); + Set expected = collectProjectedPositions(segments, planePoint, PLANE_NORMAL); + for (GirderCapAccumulator.CapLoop loop : loops) { + assertFalse(loop.vertices().isEmpty(), "loop should contain vertices"); + assertVerticesMatch(loop.vertices(), expected); + assertPositiveArea(loop.vertices()); + } + assertAllProjectedVerticesCovered(loops, segments, planePoint, PLANE_NORMAL); + } + + private static void assertVerticesMatch(List vertices, Set expected) { + assertFalse(vertices.isEmpty(), "no vertices emitted for loop"); + for (GirderCapAccumulator.CapVertex vertex : vertices) { + boolean match = expected.stream().anyMatch(candidate -> closeEnough(candidate, vertex.position())); + assertTrue(match, "vertex %s not matched against expected set %s".formatted(vertex.position(), expected)); + } + } + + private static void assertAllProjectedVerticesCovered( + List loops, + List segments, + Vector3f planePoint, + Vector3f planeNormal + ) { + Map usage = new HashMap<>(); + for (GirderMeshQuad.Segment segment : segments) { + increment(usage, project(segment.start().position(), planePoint, planeNormal)); + increment(usage, project(segment.end().position(), planePoint, planeNormal)); + } + + List missing = new ArrayList<>(); + for (Map.Entry entry : usage.entrySet()) { + if (entry.getValue() < 2) { + continue; + } + Vector3f position = decode(entry.getKey()); + boolean covered = false; + for (GirderCapAccumulator.CapLoop loop : loops) { + covered = loop.vertices().stream().anyMatch(vertex -> closeEnough(position, vertex.position())); + if (covered) { + break; + } + } + if (!covered) { + missing.add(position); + } + } + assertTrue(missing.isEmpty(), "projected vertices were not covered: " + missing); + } + + private static void assertPositiveArea(List vertices) { + float area = 0f; + int size = vertices.size(); + for (int i = 0; i < size; i++) { + Vector3f current = vertices.get(i).position(); + Vector3f next = vertices.get((i + 1) % size).position(); + area += (current.x * next.y) - (next.x * current.y); + } + assertTrue(Math.abs(area) > GirderGeometry.EPSILON, "loop collapsed to zero-area polygon"); + } + + private static void increment(Map usage, Vector3f position) { + usage.merge(quantize(position), 1, Integer::sum); + } + + private static String quantize(Vector3f position) { + int x = Math.round(position.x / GirderGeometry.EPSILON); + int y = Math.round(position.y / GirderGeometry.EPSILON); + int z = Math.round(position.z / GirderGeometry.EPSILON); + return x + ":" + y + ":" + z; + } + + private static Vector3f decode(String key) { + String[] parts = key.split(":"); + float x = Integer.parseInt(parts[0]) * GirderGeometry.EPSILON; + float y = Integer.parseInt(parts[1]) * GirderGeometry.EPSILON; + float z = Integer.parseInt(parts[2]) * GirderGeometry.EPSILON; + return new Vector3f(x, y, z); + } + + private static Set collectProjectedPositions( + List segments, + Vector3f planePoint, + Vector3f planeNormal + ) { + Set projected = new HashSet<>(); + for (GirderMeshQuad.Segment segment : segments) { + projected.add(project(segment.start().position(), planePoint, planeNormal)); + projected.add(project(segment.end().position(), planePoint, planeNormal)); + } + return projected; + } + + private static GirderMeshQuad.Segment segment( + float sx, + float sy, + float sz, + float ex, + float ey, + float ez + ) { + return new GirderMeshQuad.Segment(vertex(sx, sy, sz), vertex(ex, ey, ez)); + } + + private static Vector3f project(Vector3f position, Vector3f planePoint, Vector3f planeNormal) { + Vector3f projected = new Vector3f(position); + float distance = GirderGeometry.signedDistance(projected, planeNormal, planePoint); + if (Math.abs(distance) > GirderGeometry.EPSILON) { + projected.sub(new Vector3f(planeNormal).mul(distance)); + } + return projected; + } + + private static GirderVertex vertex(float x, float y, float z) { + return new GirderVertex( + new Vector3f(x, y, z), + new Vector3f(0f, 0f, -1f), + 0f, + 0f, + GirderGeometry.DEFAULT_COLOR, + GirderGeometry.DEFAULT_LIGHT + ); + } + + private static boolean closeEnough(Vector3f a, Vector3f b) { + return new Vector3f(a).sub(b).lengthSquared() <= GirderGeometry.EPSILON * GirderGeometry.EPSILON * 4f; + } +}