Aetheria/tools/blender/tests/test_hull_grid.py
Meta Vens 96b255541c Mirror Mount reflects the footprint's cells about the centre line, derived once from the footprint
hull_grid.mirror_position gives the twin Position as grid_w - a - w, so an even-width footprint mirrors to its true
reflection and the cell lookup's tie rule no longer decides a mirror; the twin's object stands in the cell that Position
puts it in, so Package derives the same Position back. test_hull_grid pins the reflection on odd and even grids and
rows of 4 or more cells; smoke_mounts runs footprint widths 1 to 4 at every cell of an even and an odd grid, the
boundary tie, the armour and arc copy, the muzzle's local X, the centre-line threshold, the grid edge and the parent
check, and keeps a mirrored pair of two-cell footprints through Package and the validator. Every commit builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 04:33:26 +02:00

237 lines
13 KiB
Python

"""Blender-free tests for aetheria_ships/hull_grid.py, the arithmetic behind New Ship, Rasterise and the Grid object.
python -m unittest discover -s tools/blender/tests
hull_grid is loaded by path because the package __init__ imports bpy. Frame: nose toward -Y, starboard toward -X, cell
(x, y) centred on grid_origin + (-2x, -2y) metres.
"""
import importlib.util
import math
import unittest
from pathlib import Path
# The dotted name is the module's path from the repo root, which mutmut keys its mutants by.
_spec = importlib.util.spec_from_file_location(
"tools.blender.aetheria_ships.hull_grid", Path(__file__).resolve().parents[1] / "aetheria_ships" / "hull_grid.py")
hull_grid = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(hull_grid)
def rect(x0, y0, x1, y1):
return [((x0, y0), (x1, y0), (x1, y1)), ((x0, y0), (x1, y1), (x0, y1))]
CORNER = [((1.99, 0.98), (2.0, 0.98), (2.0, 1.0))] # holds the bounding box at x 2 without covering a sample
def occupied(cells, height):
return {(i // height, i % height) for i, cell in enumerate(cells) if cell}
class CellMappingTests(unittest.TestCase):
ORIGIN = (3.0, -5.0)
def test_every_cell_of_a_5x3_grid_maps_back_to_itself(self):
for x in range(5):
for y in range(3):
centre = hull_grid.cell_centre(self.ORIGIN, x, y)
self.assertEqual(hull_grid.cell_at(self.ORIGIN, centre), (x, y))
def test_cell_1_0_is_two_metres_toward_minus_x_and_0_1_toward_minus_y(self):
origin = hull_grid.cell_centre(self.ORIGIN, 0, 0)
self.assertEqual(origin, self.ORIGIN)
self.assertEqual(hull_grid.cell_centre(self.ORIGIN, 1, 0), (1.0, -5.0))
self.assertEqual(hull_grid.cell_centre(self.ORIGIN, 0, 1), (3.0, -7.0))
self.assertEqual(hull_grid.cell_centre(self.ORIGIN, 4, 2), (-5.0, -9.0))
def test_cell_at_takes_the_nearest_centre_and_the_lower_cell_on_a_tie(self):
near = lambda dx, dy: hull_grid.cell_at(self.ORIGIN, (self.ORIGIN[0] - 2 + dx, self.ORIGIN[1] - 4 + dy))
self.assertEqual(near(0.9, -0.9), (1, 2))
self.assertEqual(near(-0.9, 0.9), (1, 2))
self.assertEqual(near(1.1, 0), (0, 2))
self.assertEqual(near(-1.1, 0), (2, 2))
self.assertEqual(near(0, 1.1), (1, 1))
self.assertEqual(near(0, -1.1), (1, 3))
self.assertEqual(near(1.0, 1.0), (0, 1))
self.assertEqual(near(-1.0, -1.0), (1, 2))
class InsideTests(unittest.TestCase):
TRIANGLE = ((0, 0), (1, 0), (0, 1))
def test_inside_points_are_inside_for_both_windings(self):
for triangle in (self.TRIANGLE, self.TRIANGLE[::-1]):
for point in ((0.25, 0.25), (0.5, 0.25), (0.1, 0.8)):
self.assertTrue(hull_grid._inside(triangle, *point), (triangle, point))
def test_points_across_each_edge_are_outside_for_both_windings(self):
outside = ((0.5, -0.25), (0.75, 0.75), (-0.25, 0.5), (-0.5, -0.5), (1.5, 0.0), (0.0, 1.5), (2.0, 2.0))
for triangle in (self.TRIANGLE, self.TRIANGLE[::-1]):
for point in outside:
self.assertFalse(hull_grid._inside(triangle, *point), (triangle, point))
def test_edges_and_corners_are_inside_for_both_windings(self):
on_edge = ((0.5, 0.0), (0.0, 0.5), (0.5, 0.5), (0.0, 0.0), (1.0, 0.0), (0.0, 1.0))
for triangle in (self.TRIANGLE, self.TRIANGLE[::-1]):
for point in on_edge:
self.assertTrue(hull_grid._inside(triangle, *point), (triangle, point))
class RasteriseTests(unittest.TestCase):
def test_eight_of_sixteen_samples_occupy_a_cell_and_seven_do_not(self):
wing = rect(-2, -1, 0, 1) # cell (1, 0) in full; fixes the grid at 2 x 1 cells about the origin
eight = rect(0, -1, 2, 0)
seven = rect(0, -1, 2, -0.5) + rect(0.5, -0.5, 2, 0)
for triangles, expected in ((eight, True), (seven, False)):
width, height, cells, origin = hull_grid.rasterise(wing + triangles)
self.assertEqual((width, height, origin), (2, 1, (1.0, 0.0)))
self.assertEqual(cells, [expected, True])
def test_samples_sit_at_quarter_points_of_the_cell_on_each_axis(self):
wing = rect(-2, -1, 0, 1)
# Cell (0, 0) spans x 0..2, y -1..1; its sample rows are y = 0.75, 0.25, -0.25, -0.75 and columns x = 1.75 .. 0.25.
rows = hull_grid.rasterise(wing + rect(0, -0.3, 2, 0.3))[2] # y = 0.25 and -0.25: 8 samples
columns = hull_grid.rasterise(wing + rect(0.3, -1, 1.0, 1) + CORNER)[2] # x = 0.75 only: 4 samples
self.assertEqual((rows, columns), ([True, True], [False, True]))
def test_a_sample_on_a_triangle_bound_counts(self):
wing = rect(-2, -1, 0, 1)
self.assertEqual(hull_grid.rasterise(wing + rect(0, 0.25, 2, 0.75))[2], [True, True]) # rows 0.75 and 0.25
self.assertEqual(hull_grid.rasterise(wing + rect(0.25, -1, 0.75, 1) + CORNER)[2], [True, True]) # columns 0.75 and 0.25
def test_a_single_triangle_covers_only_its_own_half_of_its_bounding_box(self):
width, height, cells, _ = hull_grid.rasterise([((-2, -2), (2, -2), (-2, 2))])
self.assertEqual((width, height), (2, 2))
self.assertEqual(cells, [False, True, True, True])
def test_a_sample_inside_two_triangles_counts_once(self):
seven = rect(0, -1, 2, -0.5) + rect(0.5, -0.5, 2, 0)
_, _, cells, _ = hull_grid.rasterise(rect(-2, -1, 0, 1) + seven + seven)
self.assertEqual(cells, [False, True])
def test_an_l_shape_gives_the_cells_of_the_l(self):
width, height, cells, origin = hull_grid.rasterise(rect(-2, -2, 2, 0) + rect(0, 0, 2, 2))
self.assertEqual((width, height, origin), (2, 2, (1.0, 1.0)))
self.assertEqual(occupied(cells, height), {(0, 0), (0, 1), (1, 1)})
self.assertEqual(cells, [True, True, False, True])
def test_winding_does_not_matter(self):
flipped = [(a, c, b) for a, b, c in rect(-2, -2, 2, 0) + rect(0, 0, 2, 2)]
self.assertEqual(hull_grid.rasterise(flipped)[2], [True, True, False, True])
def test_the_grid_is_ceil_of_the_extent_over_two_metres_on_each_axis(self):
for extent, cells_across in ((1.0, 1), (2.0, 1), (2.01, 2), (4.0, 2), (4.0000001, 2), (4.0005, 2), (4.002, 3), (4.01, 3), (5.0, 3), (20.0, 10)):
width, height, _, _ = hull_grid.rasterise(rect(0, 0, extent, 6.5))
self.assertEqual((width, height), (cells_across, 4), extent)
def test_the_grid_is_centred_on_the_silhouette(self):
width, height, cells, origin = hull_grid.rasterise(rect(10, 20, 13, 22))
self.assertEqual((width, height), (2, 1))
self.assertEqual(origin, (12.5, 21.0))
self.assertEqual(cells, [True, True])
def test_cell_centres_from_the_origin_lie_in_the_cells_they_name(self):
width, height, cells, origin = hull_grid.rasterise(rect(-2, -2, 2, 0) + rect(0, 0, 2, 2))
for x in range(width):
for y in range(height):
cx, cy = hull_grid.cell_centre(origin, x, y)
self.assertEqual(hull_grid.cell_at(origin, (cx, cy)), (x, y))
inside = any(hull_grid._inside(tri, cx, cy) for tri in rect(-2, -2, 2, 0) + rect(0, 0, 2, 2))
self.assertEqual(inside, cells[x * height + y])
def test_samples_per_axis_and_cell_size_are_parameters(self):
triangles = rect(-1, -1, 1, 0)
self.assertEqual(hull_grid.rasterise(triangles, samples=1)[2], [True])
self.assertEqual(hull_grid.rasterise(triangles, cell_size=1.0)[:2], (2, 1))
def test_thirty_two_cells_are_accepted_and_thirty_three_is_refused_naming_it(self):
width, height, cells, _ = hull_grid.rasterise(rect(0, 0, 2, 64))
self.assertEqual((width, height, len(cells), all(cells)), (1, 32, 32, True))
with self.assertRaises(ValueError) as caught:
hull_grid.rasterise(rect(0, 0, 2, 66))
self.assertEqual(str(caught.exception), "The hull needs 33 cells long; a ship grid holds at most 32")
with self.assertRaises(ValueError) as caught:
hull_grid.rasterise(rect(0, 0, 66, 2))
self.assertEqual(str(caught.exception), "The hull needs 33 cells wide; a ship grid holds at most 32")
def test_a_silhouette_with_no_area_is_refused(self):
for triangles in ([], [((0, 0), (1, 1), (2, 2))]):
with self.assertRaises(ValueError) as caught:
hull_grid.rasterise(triangles)
self.assertEqual(str(caught.exception), "The hull has no triangle with area to rasterise")
def test_a_degenerate_triangle_does_not_widen_the_grid(self):
for line in (((50, 50), (60, 60), (70, 70)), ((51, 50), (53, 51), (55, 52)), ((50, 51), (51, 53), (52, 55))):
_, _, cells, _ = hull_grid.rasterise(rect(0, 0, 2, 2) + [line])
self.assertEqual(cells, [True], line)
class ConvexOutlineTests(unittest.TestCase):
def test_a_square_with_interior_and_duplicate_points_is_its_four_corners_counter_clockwise(self):
points = [(2, 2), (0, 0), (1, 1), (2, 0), (0, 2), (1, 0), (0, 0), (1.5, 0.5), (2, 2)]
self.assertEqual(hull_grid.convex_outline(points), [(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0)])
def test_a_concave_l_keeps_only_its_hull(self):
points = [(0, 0), (4, 0), (4, 1), (1, 1), (1, 4), (0, 4)]
self.assertEqual(hull_grid.convex_outline(points), [(0.0, 0.0), (4.0, 0.0), (4.0, 1.0), (1.0, 4.0), (0.0, 4.0)])
def test_fewer_than_three_non_collinear_points_have_no_outline(self):
for points in ([], [(1, 1)], [(0, 0), (1, 1)], [(0, 0), (1, 1), (2, 2), (3, 3)], [(1, 1), (1, 1), (1, 1)]):
with self.assertRaises(ValueError):
hull_grid.convex_outline(points)
class MountArithmeticTests(unittest.TestCase):
def test_facing_yaw_turns_local_minus_y_to_the_facing(self):
expected = {hull_grid.ROTATION_NONE: (0, -1), hull_grid.ROTATION_COUNTER_CLOCKWISE: (1, 0),
hull_grid.ROTATION_REVERSED: (0, 1), hull_grid.ROTATION_CLOCKWISE: (-1, 0)}
for rotation, (fx, fy) in expected.items():
yaw = hull_grid.facing_yaw(rotation)
x, y = math.sin(yaw), -math.cos(yaw) # R(yaw) applied to (0, -1)
self.assertAlmostEqual(x, fx, places=9, msg=rotation)
self.assertAlmostEqual(y, fy, places=9, msg=rotation)
def test_mirror_rotation_swaps_only_clockwise_and_counter_clockwise(self):
self.assertEqual([hull_grid.mirror_rotation(r) for r in range(4)], [0, 3, 2, 1])
def test_mirror_footprint_reverses_each_row(self):
self.assertEqual(hull_grid.mirror_footprint("##./.#."), ".##/.#.")
self.assertEqual(hull_grid.mirror_footprint("#"), "#")
for footprint, expected in {"#.": ".#", "#..#.": ".#..#", "##..": "..##", "#..../.##..": "....#/..##.",
"#.....": ".....#", "##.#.../......": "...#.##/......"}.items():
self.assertEqual(hull_grid.mirror_footprint(footprint), expected, footprint)
def test_mounted_position_offsets_the_cell_by_the_footprint_centre(self):
for (w, h), expected in {(1, 1): (5, 4), (3, 1): (4, 4), (3, 3): (4, 3), (2, 1): (5, 4), (2, 2): (5, 4),
(4, 2): (4, 4)}.items():
self.assertEqual(hull_grid.mounted_position((5, 4), w, h), expected, (w, h))
def test_mirror_position_reflects_the_footprints_cells_on_odd_and_even_grids(self):
for grid_w in range(1, 10):
for w in range(1, grid_w + 1):
for a in range(0, grid_w - w + 1):
twin = hull_grid.mirror_position((a, 7), w, grid_w)
reflected = sorted(grid_w - 1 - (a + i) for i in range(w)) # each cell across the centre line
self.assertEqual([twin[0] + i for i in range(w)], reflected, (grid_w, w, a))
self.assertEqual(twin[1], 7)
def test_a_mirrored_footprint_keeps_its_picture_in_the_reflected_cells(self):
grid_w, a, footprint = 7, 1, "#.##" # cells 1, 3, 4 filled
twin = hull_grid.mirror_position((a, 0), 4, grid_w)
filled = {twin[0] + i for i, c in enumerate(hull_grid.mirror_footprint(footprint)) if c == "#"}
self.assertEqual(filled, {grid_w - 1 - c for c in (1, 3, 4)})
def test_an_object_in_the_mounted_cell_derives_the_position_back_for_every_width(self):
origin = (5.0, 9.0)
for w in range(1, 7):
for h in range(1, 4):
for position in ((0, 0), (3, 5), (6, 2)):
cell = hull_grid.mounted_cell(position, w, h)
centre = hull_grid.cell_centre(origin, *cell)
self.assertEqual(hull_grid.mounted_position(hull_grid.cell_at(origin, centre), w, h), position,
(w, h, position))
if __name__ == "__main__":
unittest.main()