diff --git a/src/main/java/com/mythlane/gravityflip/viz/ParticleEdgeEmitter.java b/src/main/java/com/mythlane/gravityflip/viz/ParticleEdgeEmitter.java new file mode 100644 index 0000000..4a6a083 --- /dev/null +++ b/src/main/java/com/mythlane/gravityflip/viz/ParticleEdgeEmitter.java @@ -0,0 +1,123 @@ +package com.mythlane.gravityflip.viz; + +import com.hypixel.hytale.math.shape.Box; +import com.hypixel.hytale.math.vector.Vector3d; + +import java.util.ArrayList; +import java.util.List; + +/** + * Plan 03-06 Task 2 — helper pur qui génère la liste des points d'émission de + * particules le long des 12 arêtes d'une {@link Box} AABB, sans aucune + * dépendance sur {@code World} (testable en JVM standard). + * + *

Contract: {@link #edgePoints(Box, double)} retourne une liste de + * {@link Vector3d} répartis uniformément sur les 12 arêtes de la box, avec les + * 8 coins inclus exactement une fois (déduplication inter-arêtes). Les 4 arêtes + * verticales, les 4 arêtes du plancher (Y=minY) et les 4 arêtes du plafond + * (Y=maxY) sont couvertes — aucune diagonale, aucun point intérieur. + * + *

Density: particules par mètre. Pour chaque arête de longueur {@code L} + * on émet {@code max(2, ceil(L * density))} points uniformément espacés, + * endpoints inclus. {@code density} est clampé à {@code [0.1, 10.0]} pour borner + * la charge réseau (threat : valeur pathologique type 10 000 → DoS clients). + * + *

Dédup des coins : les 12 arêtes partagent leurs endpoints ; on + * émet chaque coin exactement 1 fois en excluant l'endpoint "fin" de chaque + * arête et en n'émettant les 8 coins qu'une fois via un parcours explicite. + */ +public final class ParticleEdgeEmitter { + + /** Plancher de densité (particules/m) — évite density ≤ 0 qui produirait l'ensemble vide. */ + public static final double MIN_DENSITY = 0.1; + /** Plafond de densité (particules/m) — borne la charge réseau par box. */ + public static final double MAX_DENSITY = 10.0; + + private ParticleEdgeEmitter() {} + + /** + * @param box la box dont on veut matérialiser les 12 arêtes. + * @param density particules/mètre (clampé à {@code [0.1, 10.0]}). + * @return liste de points uniformément répartis sur les 12 arêtes, 8 coins + * dédupliqués. Jamais null. Taille ≥ 8. + */ + public static List edgePoints(Box box, double density) { + double d = clamp(density, MIN_DENSITY, MAX_DENSITY); + + double x0 = box.min.x, y0 = box.min.y, z0 = box.min.z; + double x1 = box.max.x, y1 = box.max.y, z1 = box.max.z; + + List out = new ArrayList<>(); + + // 1) Émettre explicitement les 8 coins (dédupliqués par construction). + double[][] corners = new double[][] { + {x0, y0, z0}, {x1, y0, z0}, {x0, y0, z1}, {x1, y0, z1}, + {x0, y1, z0}, {x1, y1, z0}, {x0, y1, z1}, {x1, y1, z1}, + }; + for (double[] c : corners) { + out.add(new Vector3d(c[0], c[1], c[2])); + } + + // 2) Pour chaque arête, émettre les points INTÉRIEURS (sans endpoints). + // 4 arêtes bas (Y=y0) : varient sur X ou Z. + addInteriorLineX(out, x0, x1, y0, z0, d); + addInteriorLineX(out, x0, x1, y0, z1, d); + addInteriorLineZ(out, x0, y0, z0, z1, d); + addInteriorLineZ(out, x1, y0, z0, z1, d); + // 4 arêtes haut (Y=y1). + addInteriorLineX(out, x0, x1, y1, z0, d); + addInteriorLineX(out, x0, x1, y1, z1, d); + addInteriorLineZ(out, x0, y1, z0, z1, d); + addInteriorLineZ(out, x1, y1, z0, z1, d); + // 4 arêtes verticales (varient sur Y). + addInteriorLineY(out, x0, y0, y1, z0, d); + addInteriorLineY(out, x1, y0, y1, z0, d); + addInteriorLineY(out, x0, y0, y1, z1, d); + addInteriorLineY(out, x1, y0, y1, z1, d); + + return out; + } + + /** Points intérieurs (sans endpoints) d'une arête parallèle à X. */ + private static void addInteriorLineX(List out, + double xMin, double xMax, + double y, double z, double density) { + int n = pointCount(xMax - xMin, density); + // n = total points incl. endpoints ; intérieurs = n-2. + for (int i = 1; i < n - 1; i++) { + double t = (double) i / (double) (n - 1); + out.add(new Vector3d(xMin + t * (xMax - xMin), y, z)); + } + } + + private static void addInteriorLineY(List out, + double x, double yMin, double yMax, + double z, double density) { + int n = pointCount(yMax - yMin, density); + for (int i = 1; i < n - 1; i++) { + double t = (double) i / (double) (n - 1); + out.add(new Vector3d(x, yMin + t * (yMax - yMin), z)); + } + } + + private static void addInteriorLineZ(List out, + double x, double y, + double zMin, double zMax, double density) { + int n = pointCount(zMax - zMin, density); + for (int i = 1; i < n - 1; i++) { + double t = (double) i / (double) (n - 1); + out.add(new Vector3d(x, y, zMin + t * (zMax - zMin))); + } + } + + /** {@code max(2, ceil(length * density))} — 2 endpoints garantis. */ + static int pointCount(double length, double density) { + double l = Math.max(0.0, length); + int n = (int) Math.ceil(l * density); + return Math.max(2, n); + } + + static double clamp(double v, double lo, double hi) { + return v < lo ? lo : (v > hi ? hi : v); + } +} diff --git a/src/test/java/com/mythlane/gravityflip/viz/ParticleEdgeEmitterTest.java b/src/test/java/com/mythlane/gravityflip/viz/ParticleEdgeEmitterTest.java new file mode 100644 index 0000000..30ea361 --- /dev/null +++ b/src/test/java/com/mythlane/gravityflip/viz/ParticleEdgeEmitterTest.java @@ -0,0 +1,100 @@ +package com.mythlane.gravityflip.viz; + +import com.hypixel.hytale.math.shape.Box; +import com.hypixel.hytale.math.vector.Vector3d; +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for {@link ParticleEdgeEmitter}. Verifies the 12-edge AABB emission + * contract — no diagonals, no interior points, corner-dedup, density clamping. + */ +class ParticleEdgeEmitterTest { + + private static final double EPS = 1e-9; + + @Test + void unitBox_density1_returnsExactly8Corners() { + // 1x1x1 box, density=1 → each edge of length 1 → ceil(1*1)=1 → max(2,1)=2 + // points per edge (endpoints only), dedup → 8 corners total. + Box b = new Box(new Vector3d(0, 0, 0), new Vector3d(1, 1, 1)); + List pts = ParticleEdgeEmitter.edgePoints(b, 1.0); + assertEquals(8, pts.size(), "unit box at density=1 should emit exactly 8 corner points"); + + // All 8 canonical corners present. + Set expected = new HashSet<>(); + for (double x : new double[]{0, 1}) + for (double y : new double[]{0, 1}) + for (double z : new double[]{0, 1}) + expected.add(key(x, y, z)); + Set actual = new HashSet<>(); + for (Vector3d p : pts) actual.add(key(p.x, p.y, p.z)); + assertEquals(expected, actual); + } + + @Test + void largeBox_density1_allPointsOnBoxSurfaceAndOnEdges() { + // 10x10x10 box, density=1 → 11 points per edge (incl. endpoints). + Box b = new Box(new Vector3d(0, 0, 0), new Vector3d(10, 10, 10)); + List pts = ParticleEdgeEmitter.edgePoints(b, 1.0); + + // Edge membership: each point must lie on ≥ 2 of the 6 box planes + // (i.e. at least 2 of its coords are on {min, max} of their axis). + for (Vector3d p : pts) { + int onPlane = 0; + if (approx(p.x, 0) || approx(p.x, 10)) onPlane++; + if (approx(p.y, 0) || approx(p.y, 10)) onPlane++; + if (approx(p.z, 0) || approx(p.z, 10)) onPlane++; + assertTrue(onPlane >= 2, + "point " + p + " must be on ≥ 2 box planes (edge membership), was on " + onPlane); + } + + // Sanity: no duplicate points (corners must be deduped). + Set keys = new HashSet<>(); + for (Vector3d p : pts) { + assertTrue(keys.add(key(p.x, p.y, p.z)), + "duplicate point " + p + " — corners should be dedup'd"); + } + + // Expected count: ceil(10*1) = 10 points/edge (incl. endpoints) → 8 interior/edge. + // Total = 8 corners + 12 edges * 8 interior = 8 + 96 = 104. + assertEquals(104, pts.size()); + } + + @Test + void density_zeroClampedToMin_density1000ClampedToMax() { + Box b = new Box(new Vector3d(0, 0, 0), new Vector3d(1, 1, 1)); + + // density=0 → clamp to 0.1 → per-edge ceil(1*0.1)=1 → max(2,1)=2 endpoints only. + List lo = ParticleEdgeEmitter.edgePoints(b, 0.0); + assertEquals(8, lo.size(), "density=0 should clamp to MIN_DENSITY, yielding 8 corners on unit box"); + + // density=1000 → clamp to 10 → per-edge ceil(1*10)=10 → 10 total points/edge. + // 8 corners + 12 * 8 interior = 8 + 96 = 104. + List hi = ParticleEdgeEmitter.edgePoints(b, 1000.0); + assertEquals(104, hi.size(), "density=1000 should clamp to MAX_DENSITY=10"); + } + + @Test + void density_negativeAlsoClamped() { + Box b = new Box(new Vector3d(0, 0, 0), new Vector3d(1, 1, 1)); + List pts = ParticleEdgeEmitter.edgePoints(b, -5.0); + assertEquals(8, pts.size()); + } + + // ---------- helpers ---------- + + private static boolean approx(double a, double b) { + return Math.abs(a - b) < EPS; + } + + private static String key(double x, double y, double z) { + // Round to 6 decimals to avoid floating-point noise in the dedup check. + return String.format("%.6f,%.6f,%.6f", x, y, z); + } +}