|
| 1 | +"""Hexagon utilities. |
| 2 | +
|
| 3 | +This module started as the Python implementation of the hexagon utilities |
| 4 | +from Red Blob Games. |
| 5 | +
|
| 6 | +See: https://www.redblobgames.com/grids/hexagons/ |
| 7 | +
|
| 8 | +CC0 -- No Rights Reserved |
| 9 | +""" |
| 10 | + |
| 11 | +import math |
| 12 | +from dataclasses import dataclass |
| 13 | +from math import isclose |
| 14 | +from typing import Literal, NamedTuple, cast |
| 15 | + |
| 16 | +from pyglet.math import Vec2 |
| 17 | + |
| 18 | +_EVEN: Literal[1] = 1 |
| 19 | +_ODD: Literal[-1] = -1 |
| 20 | + |
| 21 | +offset_system = Literal["odd-r", "even-r", "odd-q", "even-q"] |
| 22 | + |
| 23 | + |
| 24 | +class _Orientation(NamedTuple): |
| 25 | + """Helper class to store forward and inverse matrix for hexagon conversion. |
| 26 | +
|
| 27 | + Also stores the start angle for hexagon corners. |
| 28 | + """ |
| 29 | + |
| 30 | + f0: float |
| 31 | + f1: float |
| 32 | + f2: float |
| 33 | + f3: float |
| 34 | + b0: float |
| 35 | + b1: float |
| 36 | + b2: float |
| 37 | + b3: float |
| 38 | + start_angle: float |
| 39 | + |
| 40 | + |
| 41 | +pointy_orientation = _Orientation( |
| 42 | + math.sqrt(3.0), |
| 43 | + math.sqrt(3.0) / 2.0, |
| 44 | + 0.0, |
| 45 | + 3.0 / 2.0, |
| 46 | + math.sqrt(3.0) / 3.0, |
| 47 | + -1.0 / 3.0, |
| 48 | + 0.0, |
| 49 | + 2.0 / 3.0, |
| 50 | + 0.5, |
| 51 | +) |
| 52 | +flat_orientation = _Orientation( |
| 53 | + 3.0 / 2.0, |
| 54 | + 0.0, |
| 55 | + math.sqrt(3.0) / 2.0, |
| 56 | + math.sqrt(3.0), |
| 57 | + 2.0 / 3.0, |
| 58 | + 0.0, |
| 59 | + -1.0 / 3.0, |
| 60 | + math.sqrt(3.0) / 3.0, |
| 61 | + 0.0, |
| 62 | +) |
| 63 | + |
| 64 | + |
| 65 | +class Layout(NamedTuple): |
| 66 | + """Helper class to store hexagon layout information.""" |
| 67 | + |
| 68 | + orientation: _Orientation |
| 69 | + size: Vec2 |
| 70 | + origin: Vec2 |
| 71 | + |
| 72 | + |
| 73 | +# TODO: should this be a np.array? |
| 74 | +# TODO: should this be in rust? |
| 75 | +# TODO: should this be cached/memoized? |
| 76 | +# TODO: benchmark |
| 77 | +@dataclass(frozen=True) |
| 78 | +class Hex: |
| 79 | + """A hexagon in cube coordinates.""" |
| 80 | + |
| 81 | + q: float |
| 82 | + r: float |
| 83 | + s: float |
| 84 | + |
| 85 | + def __post_init__(self) -> None: |
| 86 | + """Create a hexagon in cube coordinates.""" |
| 87 | + cube_sum = self.q + self.r + self.s |
| 88 | + assert isclose(0, cube_sum, abs_tol=1e-14), f"q + r + s must be 0, is {cube_sum}" |
| 89 | + |
| 90 | + def __eq__(self, other: object) -> bool: |
| 91 | + """Check if two hexagons are equal.""" |
| 92 | + result = self.q == other.q and self.r == other.r and self.s == other.s # type: ignore[attr-defined] |
| 93 | + assert isinstance(result, bool) |
| 94 | + return result |
| 95 | + |
| 96 | + def __add__(self, other: "Hex") -> "Hex": |
| 97 | + """Add two hexagons.""" |
| 98 | + return Hex(self.q + other.q, self.r + other.r, self.s + other.s) |
| 99 | + |
| 100 | + def __sub__(self, other: "Hex") -> "Hex": |
| 101 | + """Subtract two hexagons.""" |
| 102 | + return Hex(self.q - other.q, self.r - other.r, self.s - other.s) |
| 103 | + |
| 104 | + def __mul__(self, k: int) -> "Hex": |
| 105 | + """Multiply a hexagon by a scalar.""" |
| 106 | + return Hex(self.q * k, self.r * k, self.s * k) |
| 107 | + |
| 108 | + def __neg__(self) -> "Hex": |
| 109 | + """Negate a hexagon.""" |
| 110 | + return Hex(-self.q, -self.r, -self.s) |
| 111 | + |
| 112 | + def __round__(self) -> "Hex": |
| 113 | + """Round a hexagon.""" |
| 114 | + qi = round(self.q) |
| 115 | + ri = round(self.r) |
| 116 | + si = round(self.s) |
| 117 | + q_diff = abs(qi - self.q) |
| 118 | + r_diff = abs(ri - self.r) |
| 119 | + s_diff = abs(si - self.s) |
| 120 | + if q_diff > r_diff and q_diff > s_diff: |
| 121 | + qi = -ri - si |
| 122 | + elif r_diff > s_diff: |
| 123 | + ri = -qi - si |
| 124 | + else: |
| 125 | + si = -qi - ri |
| 126 | + return Hex(qi, ri, si) |
| 127 | + |
| 128 | + def rotate_left(self) -> "Hex": |
| 129 | + """Rotate a hexagon to the left.""" |
| 130 | + return Hex(-self.s, -self.q, -self.r) |
| 131 | + |
| 132 | + def rotate_right(self) -> "Hex": |
| 133 | + """Rotate a hexagon to the right.""" |
| 134 | + return Hex(-self.r, -self.s, -self.q) |
| 135 | + |
| 136 | + @staticmethod |
| 137 | + def direction(direction: int) -> "Hex": |
| 138 | + """Return a relative hexagon in a given direction.""" |
| 139 | + hex_directions = [ |
| 140 | + Hex(1, 0, -1), |
| 141 | + Hex(1, -1, 0), |
| 142 | + Hex(0, -1, 1), |
| 143 | + Hex(-1, 0, 1), |
| 144 | + Hex(-1, 1, 0), |
| 145 | + Hex(0, 1, -1), |
| 146 | + ] |
| 147 | + return hex_directions[direction] |
| 148 | + |
| 149 | + def neighbor(self, direction: int) -> "Hex": |
| 150 | + """Return the neighbor in a given direction.""" |
| 151 | + return self + self.direction(direction) |
| 152 | + |
| 153 | + def neighbors(self) -> list["Hex"]: |
| 154 | + """Return the neighbors of a hexagon.""" |
| 155 | + return [self.neighbor(i) for i in range(6)] |
| 156 | + |
| 157 | + def diagonal_neighbor(self, direction: int) -> "Hex": |
| 158 | + """Return the diagonal neighbor in a given direction.""" |
| 159 | + hex_diagonals = [ |
| 160 | + Hex(2, -1, -1), |
| 161 | + Hex(1, -2, 1), |
| 162 | + Hex(-1, -1, 2), |
| 163 | + Hex(-2, 1, 1), |
| 164 | + Hex(-1, 2, -1), |
| 165 | + Hex(1, 1, -2), |
| 166 | + ] |
| 167 | + return self + hex_diagonals[direction] |
| 168 | + |
| 169 | + def length(self) -> int: |
| 170 | + """Return the length of a hexagon.""" |
| 171 | + return int((abs(self.q) + abs(self.r) + abs(self.s)) // 2) |
| 172 | + |
| 173 | + def distance_to(self, other: "Hex") -> float: |
| 174 | + """Return the distance between self and another Hex.""" |
| 175 | + return (self - other).length() |
| 176 | + |
| 177 | + def line_to(self, other: "Hex") -> list["Hex"]: |
| 178 | + """Return a list of hexagons between self and another Hex.""" |
| 179 | + return line(self, other) |
| 180 | + |
| 181 | + def lerp_between(self, other: "Hex", t: float) -> "Hex": |
| 182 | + """Perform a linear interpolation between self and another Hex.""" |
| 183 | + return lerp(self, other, t) |
| 184 | + |
| 185 | + def to_pixel(self, layout: Layout) -> Vec2: |
| 186 | + """Convert a hexagon to pixel coordinates.""" |
| 187 | + return hex_to_pixel(self, layout) |
| 188 | + |
| 189 | + def to_offset(self, system: offset_system) -> "OffsetCoord": |
| 190 | + """Convert a hexagon to offset coordinates.""" |
| 191 | + if system == "odd-r": |
| 192 | + return roffset_from_cube(self, _ODD) |
| 193 | + if system == "even-r": |
| 194 | + return roffset_from_cube(self, _EVEN) |
| 195 | + if system == "odd-q": |
| 196 | + return qoffset_from_cube(self, _ODD) |
| 197 | + if system == "even-q": |
| 198 | + return qoffset_from_cube(self, _EVEN) |
| 199 | + |
| 200 | + msg = "system must be odd-r, even-r, odd-q, or even-q" |
| 201 | + raise ValueError(msg) |
| 202 | + |
| 203 | + |
| 204 | +def lerp(a: Hex, b: Hex, t: float) -> Hex: |
| 205 | + """Perform a linear interpolation between two hexagons.""" |
| 206 | + return Hex( |
| 207 | + a.q * (1.0 - t) + b.q * t, |
| 208 | + a.r * (1.0 - t) + b.r * t, |
| 209 | + a.s * (1.0 - t) + b.s * t, |
| 210 | + ) |
| 211 | + |
| 212 | + |
| 213 | +def distance(a: Hex, b: Hex) -> int: |
| 214 | + """Return the distance between two hexagons.""" |
| 215 | + return (a - b).length() |
| 216 | + |
| 217 | + |
| 218 | +def line(a: Hex, b: Hex) -> list[Hex]: |
| 219 | + """Return a list of hexagons between two hexagons.""" |
| 220 | + n = distance(a, b) |
| 221 | + # epsilon to nudge points by to falling on an edge |
| 222 | + a_nudge = Hex(a.q + 1e-06, a.r + 1e-06, a.s - 2e-06) |
| 223 | + b_nudge = Hex(b.q + 1e-06, b.r + 1e-06, b.s - 2e-06) |
| 224 | + step = 1.0 / max(n, 1) |
| 225 | + return [round(lerp(a_nudge, b_nudge, step * i)) for i in range(n + 1)] |
| 226 | + |
| 227 | + |
| 228 | +def hex_to_pixel(h: Hex, layout: Layout) -> Vec2: |
| 229 | + """Convert axial hexagon coordinates to pixel coordinates.""" |
| 230 | + M = layout.orientation # noqa: N806 |
| 231 | + size = layout.size |
| 232 | + origin = layout.origin |
| 233 | + x = (M.f0 * h.q + M.f1 * h.r) * size.x |
| 234 | + y = (M.f2 * h.q + M.f3 * h.r) * size.y |
| 235 | + return Vec2(x + origin.x, y + origin.y) |
| 236 | + |
| 237 | + |
| 238 | +def pixel_to_hex( |
| 239 | + p: Vec2, |
| 240 | + layout: Layout, |
| 241 | +) -> Hex: |
| 242 | + """Convert pixel coordinates to cubic hexagon coordinates.""" |
| 243 | + M = layout.orientation # noqa: N806 |
| 244 | + size = layout.size |
| 245 | + origin = layout.origin |
| 246 | + pt = Vec2((p.x - origin.x) / size.x, (p.y - origin.y) / size.y) |
| 247 | + q = M.b0 * pt.x + M.b1 * pt.y |
| 248 | + r = M.b2 * pt.x + M.b3 * pt.y |
| 249 | + return Hex(q, r, -q - r) |
| 250 | + |
| 251 | + |
| 252 | +def hex_corner_offset(corner: int, layout: Layout) -> Vec2: |
| 253 | + """Return the offset of a hexagon corner.""" |
| 254 | + # Hexagons have 6 corners |
| 255 | + assert 0 <= corner < 6 # noqa: PLR2004 |
| 256 | + M = layout.orientation # noqa: N806 |
| 257 | + size = layout.size |
| 258 | + angle = 2.0 * math.pi * (M.start_angle - corner) / 6.0 |
| 259 | + return Vec2(size.x * math.cos(angle), size.y * math.sin(angle)) |
| 260 | + |
| 261 | + |
| 262 | +hex_corners = tuple[Vec2, Vec2, Vec2, Vec2, Vec2, Vec2] |
| 263 | + |
| 264 | + |
| 265 | +def polygon_corners(h: Hex, layout: Layout) -> hex_corners: |
| 266 | + """Return the corners of a hexagon in a list of pixels.""" |
| 267 | + corners = [] |
| 268 | + center = hex_to_pixel(h, layout) |
| 269 | + for i in range(6): |
| 270 | + offset = hex_corner_offset(i, layout) |
| 271 | + corners.append(Vec2(center.x + offset.x, center.y + offset.y)) |
| 272 | + result = tuple(corners) |
| 273 | + # Hexagons have 6 corners |
| 274 | + assert len(result) == 6 # noqa: PLR2004 |
| 275 | + return cast("hex_corners", result) |
| 276 | + |
| 277 | + |
| 278 | +@dataclass(frozen=True) |
| 279 | +class OffsetCoord: |
| 280 | + """Offset coordinates.""" |
| 281 | + |
| 282 | + col: float |
| 283 | + row: float |
| 284 | + |
| 285 | + def to_cube(self, system: offset_system) -> Hex: |
| 286 | + """Convert offset coordinates to cube coordinates.""" |
| 287 | + if system == "odd-r": |
| 288 | + return roffset_to_cube(self, _ODD) |
| 289 | + if system == "even-r": |
| 290 | + return roffset_to_cube(self, _EVEN) |
| 291 | + if system == "odd-q": |
| 292 | + return qoffset_to_cube(self, _ODD) |
| 293 | + if system == "even-q": |
| 294 | + return qoffset_to_cube(self, _EVEN) |
| 295 | + |
| 296 | + msg = "system must be EVEN (+1) or ODD (-1)" |
| 297 | + raise ValueError(msg) |
| 298 | + |
| 299 | + |
| 300 | +def qoffset_from_cube(h: Hex, offset: Literal[-1, 1]) -> OffsetCoord: |
| 301 | + """Convert a hexagon in cube coordinates to q offset coordinates.""" |
| 302 | + if offset not in (_EVEN, _ODD): |
| 303 | + msg = "offset must be EVEN (+1) or ODD (-1)" |
| 304 | + raise ValueError(msg) |
| 305 | + |
| 306 | + col = h.q |
| 307 | + row = h.r + (h.q + offset * (h.q & 1)) // 2 # type: ignore[operator] |
| 308 | + return OffsetCoord(col, row) |
| 309 | + |
| 310 | + |
| 311 | +def qoffset_to_cube(h: OffsetCoord, offset: Literal[-1, 1]) -> Hex: |
| 312 | + """Convert a hexagon in q offset coordinates to cube coordinates.""" |
| 313 | + if offset not in (_EVEN, _ODD): |
| 314 | + msg = "offset must be EVEN (+1) or ODD (-1)" |
| 315 | + raise ValueError(msg) |
| 316 | + |
| 317 | + q = h.col |
| 318 | + r = h.row - (h.col + offset * (h.col & 1)) // 2 # type: ignore[operator] |
| 319 | + s = -q - r |
| 320 | + return Hex(q, r, s) |
| 321 | + |
| 322 | + |
| 323 | +def roffset_from_cube(h: Hex, offset: Literal[-1, 1]) -> OffsetCoord: |
| 324 | + """Convert a hexagon in cube coordinates to r offset coordinates.""" |
| 325 | + if offset not in (_EVEN, _ODD): |
| 326 | + msg = "offset must be EVEN (+1) or ODD (-1)" |
| 327 | + raise ValueError(msg) |
| 328 | + |
| 329 | + col = h.q + (h.r + offset * (h.r & 1)) // 2 # type: ignore[operator] |
| 330 | + row = h.r |
| 331 | + return OffsetCoord(col, row) |
| 332 | + |
| 333 | + |
| 334 | +def roffset_to_cube(h: OffsetCoord, offset: Literal[-1, 1]) -> Hex: |
| 335 | + """Convert a hexagon in r offset coordinates to cube coordinates.""" |
| 336 | + if offset not in (_EVEN, _ODD): |
| 337 | + msg = "offset must be EVEN (+1) or ODD (-1)" |
| 338 | + raise ValueError(msg) |
| 339 | + |
| 340 | + q = h.col - (h.row + offset * (h.row & 1)) // 2 # type: ignore[operator] |
| 341 | + r = h.row |
| 342 | + s = -q - r |
| 343 | + return Hex(q, r, s) |
0 commit comments