diff --git a/src/main/java/com/mythlane/chainlightning/chain/ParticleTrail.java b/src/main/java/com/mythlane/chainlightning/chain/ParticleTrail.java
new file mode 100644
index 0000000..99944a0
--- /dev/null
+++ b/src/main/java/com/mythlane/chainlightning/chain/ParticleTrail.java
@@ -0,0 +1,56 @@
+package com.mythlane.chainlightning.chain;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Pure-Java sampler for the electric trail between two endpoints.
+ *
+ * Produces evenly-spaced interpolated points strictly between {@code from} and
+ * {@code to}; both endpoints are excluded by construction (the parametric
+ * interpolation parameter {@code t} never reaches 0 or 1).
+ *
+ * Density is expressed in particles per block. The sample count for a given
+ * segment is {@code ceil(distance * density)}.
+ *
+ * This class has ZERO Hytale imports — it lives behind the Phase 2 sealed
+ * frontier so it can be unit-tested without runtime dependencies.
+ */
+public final class ParticleTrail {
+
+ /** Default trail density in particles per block. */
+ public static final double TRAIL_DENSITY = 4.0;
+
+ private ParticleTrail() {
+ // utility class — no instances
+ }
+
+ /**
+ * Sample interpolated points strictly between {@code from} and {@code to}.
+ * Endpoints are NOT included in the returned list. The list is freshly
+ * allocated on each call.
+ *
+ * @param from start endpoint (excluded from output)
+ * @param to end endpoint (excluded from output)
+ * @param density particles per block
+ * @return newly-allocated list of interpolated points; empty if the
+ * endpoints coincide or {@code density} produces a non-positive
+ * count.
+ */
+ public static List sample(Vec3 from, Vec3 to, double density) {
+ double dist = from.distance(to);
+ if (dist <= 0) {
+ return List.of();
+ }
+ int count = (int) Math.ceil(dist * density);
+ if (count <= 0) {
+ return List.of();
+ }
+ List out = new ArrayList<>(count);
+ for (int i = 1; i <= count; i++) {
+ double t = (double) i / (count + 1);
+ out.add(from.lerp(to, t));
+ }
+ return out;
+ }
+}
diff --git a/src/main/java/com/mythlane/chainlightning/chain/Vec3.java b/src/main/java/com/mythlane/chainlightning/chain/Vec3.java
index 1ebcacd..4b91bcc 100644
--- a/src/main/java/com/mythlane/chainlightning/chain/Vec3.java
+++ b/src/main/java/com/mythlane/chainlightning/chain/Vec3.java
@@ -22,4 +22,13 @@ public record Vec3(double x, double y, double z) {
public double distance(Vec3 other) {
return Math.sqrt(distanceSquared(other));
}
+
+ /** Linear interpolation: t=0 returns this, t=1 returns other. */
+ public Vec3 lerp(Vec3 other, double t) {
+ return new Vec3(
+ this.x + (other.x - this.x) * t,
+ this.y + (other.y - this.y) * t,
+ this.z + (other.z - this.z) * t
+ );
+ }
}
diff --git a/src/test/java/com/mythlane/chainlightning/chain/ParticleTrailTest.java b/src/test/java/com/mythlane/chainlightning/chain/ParticleTrailTest.java
new file mode 100644
index 0000000..852bc93
--- /dev/null
+++ b/src/test/java/com/mythlane/chainlightning/chain/ParticleTrailTest.java
@@ -0,0 +1,82 @@
+package com.mythlane.chainlightning.chain;
+
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+final class ParticleTrailTest {
+
+ private static final double EPS = 1e-9;
+
+ @Test
+ void zeroDistance() {
+ Vec3 p = new Vec3(2, 3, 4);
+ List out = ParticleTrail.sample(p, p, 4.0);
+ assertTrue(out.isEmpty());
+ }
+
+ @Test
+ void subBlockDistance() {
+ // distance 0.5 along X with density 4 → ceil(2.0) = 2
+ Vec3 from = new Vec3(0, 0, 0);
+ Vec3 to = new Vec3(0.5, 0, 0);
+ List out = ParticleTrail.sample(from, to, 4.0);
+ assertEquals(2, out.size());
+ // Both points strictly between endpoints
+ for (Vec3 v : out) {
+ assertTrue(from.distanceSquared(v) > EPS, "sample equals from");
+ assertTrue(to.distanceSquared(v) > EPS, "sample equals to");
+ }
+ }
+
+ @Test
+ void integerDistanceCount() {
+ Vec3 from = new Vec3(0, 0, 0);
+ Vec3 to = new Vec3(5, 0, 0);
+ List out = ParticleTrail.sample(from, to, 4.0);
+ assertEquals(20, out.size());
+ }
+
+ @Test
+ void fractionalDistanceCount() {
+ Vec3 from = new Vec3(0, 0, 0);
+ Vec3 to = new Vec3(3.7, 0, 0);
+ List out = ParticleTrail.sample(from, to, 4.0);
+ // ceil(3.7 * 4) = ceil(14.8) = 15
+ assertEquals(15, out.size());
+ }
+
+ @Test
+ void endpointsExcluded() {
+ Vec3 from = new Vec3(1, 2, 3);
+ Vec3 to = new Vec3(7, 8, 9);
+ List out = ParticleTrail.sample(from, to, 4.0);
+ assertTrue(out.size() > 0);
+ for (Vec3 v : out) {
+ assertTrue(from.distanceSquared(v) > EPS, "sample equals from endpoint");
+ assertTrue(to.distanceSquared(v) > EPS, "sample equals to endpoint");
+ }
+ }
+
+ @Test
+ void uniformSpacing() {
+ Vec3 from = new Vec3(0, 0, 0);
+ Vec3 to = new Vec3(5, 0, 0);
+ List out = ParticleTrail.sample(from, to, 4.0);
+ assertEquals(20, out.size());
+ double total = from.distance(to);
+ double expectedSpacing = total / (out.size() + 1);
+
+ // from -> first
+ assertEquals(expectedSpacing, from.distance(out.get(0)), EPS);
+ // last -> to
+ assertEquals(expectedSpacing, out.get(out.size() - 1).distance(to), EPS);
+ // consecutive
+ for (int i = 1; i < out.size(); i++) {
+ assertEquals(expectedSpacing, out.get(i - 1).distance(out.get(i)), EPS);
+ }
+ }
+}
diff --git a/src/test/java/com/mythlane/chainlightning/chain/Vec3Test.java b/src/test/java/com/mythlane/chainlightning/chain/Vec3Test.java
index 85542f1..b3f18d1 100644
--- a/src/test/java/com/mythlane/chainlightning/chain/Vec3Test.java
+++ b/src/test/java/com/mythlane/chainlightning/chain/Vec3Test.java
@@ -41,4 +41,34 @@ final class Vec3Test {
assertEquals(12.0, a.distanceSquared(b), 1e-9);
assertTrue(a.distance(b) > 3.4 && a.distance(b) < 3.5);
}
+
+ @Test
+ void lerpAtZeroReturnsFrom() {
+ Vec3 from = Vec3.ZERO;
+ Vec3 to = new Vec3(10, 10, 10);
+ Vec3 result = from.lerp(to, 0.0);
+ assertEquals(from.x(), result.x(), 1e-9);
+ assertEquals(from.y(), result.y(), 1e-9);
+ assertEquals(from.z(), result.z(), 1e-9);
+ }
+
+ @Test
+ void lerpAtOneReturnsTo() {
+ Vec3 from = Vec3.ZERO;
+ Vec3 to = new Vec3(10, 10, 10);
+ Vec3 result = from.lerp(to, 1.0);
+ assertEquals(to.x(), result.x(), 1e-9);
+ assertEquals(to.y(), result.y(), 1e-9);
+ assertEquals(to.z(), result.z(), 1e-9);
+ }
+
+ @Test
+ void lerpAtHalfReturnsMidpoint() {
+ Vec3 from = new Vec3(0, 0, 0);
+ Vec3 to = new Vec3(10, 0, 0);
+ Vec3 result = from.lerp(to, 0.5);
+ assertEquals(5.0, result.x(), 1e-9);
+ assertEquals(0.0, result.y(), 1e-9);
+ assertEquals(0.0, result.z(), 1e-9);
+ }
}