From d63fe44618f66df2265575ef0570047da78762a1 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 10 May 2026 15:08:16 +0200 Subject: [PATCH] pkg/calc: fix Deltas wrap on rectangular maps + add signed ShortestDelta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- pkg/calc/map.go | 39 ++++++++++++++++++++++-------- pkg/calc/map_test.go | 57 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 10 deletions(-) create mode 100644 pkg/calc/map_test.go diff --git a/pkg/calc/map.go b/pkg/calc/map.go index 77166db..73ebf6f 100644 --- a/pkg/calc/map.go +++ b/pkg/calc/map.go @@ -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 } diff --git a/pkg/calc/map_test.go b/pkg/calc/map_test.go new file mode 100644 index 0000000..4319da7 --- /dev/null +++ b/pkg/calc/map_test.go @@ -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) + } +}