pkg/calc: fix Deltas wrap on rectangular maps + add signed ShortestDelta

The pre-existing `Deltas` helper used the height to wrap the x-axis,
which silently produced wrong values on any rectangular galaxy
(`w != h`). Square galaxies — the only configuration the engine
ships today — masked the bug, so it stayed in tree.

`Deltas` is now a thin wrapper around the new `ShortestDelta(a, b,
size)`, which returns the signed per-axis shortest delta on a 1-D
circle (range `(-size/2, size/2]`). The signed flavour is what the
Phase 19 ship-group renderer needs to draw an IncomingGroup
trajectory across the torus seam; `Deltas` continues to return the
pair of absolute deltas for distance computation.

Adds `pkg/calc/map_test.go` with table-driven coverage for both
helpers, including a regression that exercises the rectangular
case the bug was hiding behind, and the half-circumference
tie-break.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-10 15:08:16 +02:00
parent 408097e3aa
commit d63fe44618
2 changed files with 86 additions and 10 deletions
+29 -10
View File
@@ -2,19 +2,38 @@ package calc
import "math"
// shortest distance between points on torus map
// ShortDistance returns the shortest Euclidean distance between two
// points on a torus of size w×h.
func ShortDistance(w, h uint32, x1, y1, x2, y2 float64) float64 {
return math.Hypot(Deltas(w, h, x1, y1, x2, y2))
}
// Deltas returns the per-axis absolute distance between two points on
// a torus of size w×h. Each axis wraps independently: the x-axis
// against width w, the y-axis against height h. The returned values
// are always non-negative; combine via math.Hypot for the Euclidean
// torus distance, or use [ShortestDelta] when the signed direction
// matters (for example when drawing a wrap-aware line).
func Deltas(w, h uint32, x1, y1, x2, y2 float64) (float64, float64) {
dx := math.Abs(x2 - x1)
dy := math.Abs(y2 - y1)
if dx > float64(w/2) {
dx = float64(h) - dx
}
if dy > float64(h/2) {
dy = float64(h) - dy
}
return dx, dy
return math.Abs(ShortestDelta(x1, x2, w)), math.Abs(ShortestDelta(y1, y2, h))
}
// ShortestDelta returns the signed delta (b - a) on a 1-D circle of
// circumference size, picking whichever direction has the shorter
// absolute distance. The result lies in (-size/2, size/2]: at exactly
// half the circumference the function returns +size/2 so the tie-case
// is deterministic regardless of input order.
func ShortestDelta(a, b float64, size uint32) float64 {
if size == 0 {
return b - a
}
s := float64(size)
d := math.Mod(b-a, s)
half := s / 2
if d > half {
d -= s
} else if d <= -half {
d += s
}
return d
}
+57
View File
@@ -0,0 +1,57 @@
package calc_test
import (
"math"
"testing"
"galaxy/calc"
)
func TestShortestDelta(t *testing.T) {
cases := []struct {
name string
a, b float64
size uint32
want float64
}{
{"identity", 5, 5, 100, 0},
{"forward small", 10, 30, 100, 20},
{"backward small", 30, 10, 100, -20},
{"wraps right edge", 95, 5, 100, 10},
{"wraps left edge", 5, 95, 100, -10},
{"exact half wraps positive", 0, 50, 100, 50},
{"slight beyond half goes negative", 0, 51, 100, -49},
{"size zero is degenerate identity", 5, 30, 0, 25},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := calc.ShortestDelta(tc.a, tc.b, tc.size)
if got != tc.want {
t.Fatalf("ShortestDelta(%v, %v, %d) = %v, want %v",
tc.a, tc.b, tc.size, got, tc.want)
}
})
}
}
// TestDeltasRectangularGalaxy guards against the pre-Phase-19 bug
// where the x-axis wrap used the height instead of the width on
// rectangular maps.
func TestDeltasRectangularGalaxy(t *testing.T) {
w, h := uint32(100), uint32(50)
dx, dy := calc.Deltas(w, h, 95, 10, 5, 15)
if dx != 10 {
t.Errorf("dx = %v, want 10 (wrap distance on width 100)", dx)
}
if dy != 5 {
t.Errorf("dy = %v, want 5 (no wrap on height 50)", dy)
}
}
func TestShortDistanceTorus(t *testing.T) {
got := calc.ShortDistance(100, 100, 95, 95, 5, 5)
want := math.Hypot(10, 10)
if math.Abs(got-want) > 1e-9 {
t.Fatalf("ShortDistance through wrap = %v, want %v", got, want)
}
}