From 922928ebe7f8dde67e4528fd8aeeb72fb77a666b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 8 Oct 2026 19:35:46 +0200 Subject: [PATCH] Ship grid: hull_grid.py holds the cell arithmetic and the silhouette rasteriser, importable without Blender cell_centre and cell_at map a cell to and from the Ship Root frame through a stored grid_origin; rasterise turns top-down triangles into column-major cells (4x4 samples, half occupies, 32 cells per axis at most). Co-Authored-By: Claude Opus 5.5 --- tools/blender/aetheria_ships/hull_grid.py | 85 +++++++++++++++ tools/blender/tests/test_hull_grid.py | 127 ++++++++++++++++++++++ 2 files changed, 212 insertions(+) create mode 100644 tools/blender/aetheria_ships/hull_grid.py create mode 100644 tools/blender/tests/test_hull_grid.py diff --git a/tools/blender/aetheria_ships/hull_grid.py b/tools/blender/aetheria_ships/hull_grid.py new file mode 100644 index 000000000..825ccea45 --- /dev/null +++ b/tools/blender/aetheria_ships/hull_grid.py @@ -0,0 +1,85 @@ +"""The ship grid's arithmetic: where a cell is, and which cells a hull's top-down silhouette occupies. Imports without bpy. + +Frame: the Ship Root frame in metres, nose toward -Y and starboard toward -X (map M7). Cell (x, y) is centred on its +integer coordinate, so its centre is grid_origin + (-CELL_SIZE x, -CELL_SIZE y), where grid_origin is cell (0, 0)'s +centre (the Aetheria.Entity.ToWorldPoint rule: schematic +x is starboard, +y is forward). grid_origin is a stored fact +written only by Rasterise; every lookup below takes it as an argument. +""" + +import math + +CELL_SIZE = 2.0 # metres per cell, Settings.SchematicCellSize +MAX_CELLS = 32 # per axis, the layout panel's limit +SAMPLES = 4 # per axis per cell: 4 x 4 coverage samples +_EDGE = 1e-9 # slack for float noise on a sample bound, in sample steps +_TOLERANCE = 1e-3 # metres: mesh coordinates are float32, so an extent a millimetre over a cell boundary is on it + + +def cell_centre(grid_origin, x, y): + """The Ship Root frame position of cell (x, y)'s centre.""" + return (grid_origin[0] - CELL_SIZE * x, grid_origin[1] - CELL_SIZE * y) + + +def cell_at(grid_origin, point): + """The cell whose centre is nearest to point; a point midway between two centres belongs to the lower cell.""" + return (math.ceil((grid_origin[0] - point[0]) / CELL_SIZE - 0.5), + math.ceil((grid_origin[1] - point[1]) / CELL_SIZE - 0.5)) + + +def _cells_across(extent, cell_size): + return max(1, math.ceil((extent - _TOLERANCE) / cell_size)) + + +def _inside(triangle, px, py): + (ax, ay), (bx, by), (cx, cy) = triangle + d1 = (bx - ax) * (py - ay) - (by - ay) * (px - ax) + d2 = (cx - bx) * (py - by) - (cy - by) * (px - bx) + d3 = (ax - cx) * (py - cy) - (ay - cy) * (px - cx) + return not ((d1 < 0 or d2 < 0 or d3 < 0) and (d1 > 0 or d2 > 0 or d3 > 0)) + + +def rasterise(triangles_xy, cell_size=CELL_SIZE, samples=SAMPLES): + """Occupied cells of a top-down silhouette given as triangles of (x, y) points in the Ship Root frame. + + The grid is ceil(extent / cell_size) cells on each axis, centred on the silhouette's bounding box. Each cell is + tested at samples x samples points; it is occupied when at least half of them fall in some triangle. A sample + inside several triangles counts once, and each triangle is tested only at the samples inside its bounding box. + Returns (width, height, cells, grid_origin): cells are column-major, cells[x * height + y], and grid_origin is cell + (0, 0)'s centre.""" + triangles = [tri for tri in triangles_xy if + (tri[1][0] - tri[0][0]) * (tri[2][1] - tri[0][1]) != (tri[1][1] - tri[0][1]) * (tri[2][0] - tri[0][0])] + if not triangles: + raise ValueError("The hull has no triangle with area to rasterise") + xs = [p[0] for tri in triangles for p in tri] + ys = [p[1] for tri in triangles for p in tri] + width = _cells_across(max(xs) - min(xs), cell_size) + height = _cells_across(max(ys) - min(ys), cell_size) + for axis, count in (("wide", width), ("long", height)): + if count > MAX_CELLS: + raise ValueError(f"The hull needs {count} cells {axis}; a ship grid holds at most {MAX_CELLS}") + # Cell (0, 0)'s centre: the far (+X, +Y) corner of the grid, half a cell in. + origin = ((min(xs) + max(xs)) / 2 + (width - 1) * cell_size / 2, (min(ys) + max(ys)) / 2 + (height - 1) * cell_size / 2) + step = cell_size / samples + # Sample (a, b) sits at origin + cell_size/2 - (a + 1/2) step on each axis, so it falls in cell (a // samples, b // samples). + top_x, top_y = origin[0] + cell_size / 2, origin[1] + cell_size / 2 + + def span(low, high, top, count): + first = math.ceil((top - high) / step - 0.5 - _EDGE) + last = math.floor((top - low) / step - 0.5 + _EDGE) + return range(max(first, 0), min(last, count * samples - 1) + 1) + + hits = set() + for tri in triangles: + txs = [p[0] for p in tri] + tys = [p[1] for p in tri] + for a in span(min(txs), max(txs), top_x, width): + px = top_x - (a + 0.5) * step + for b in span(min(tys), max(tys), top_y, height): + if (a, b) not in hits and _inside(tri, px, top_y - (b + 0.5) * step): + hits.add((a, b)) + counts = {} + for a, b in hits: + key = (a // samples, b // samples) + counts[key] = counts.get(key, 0) + 1 + cells = [2 * counts.get((x, y), 0) >= samples * samples for x in range(width) for y in range(height)] + return width, height, cells, origin diff --git a/tools/blender/tests/test_hull_grid.py b/tools/blender/tests/test_hull_grid.py new file mode 100644 index 000000000..ac8ef248f --- /dev/null +++ b/tools/blender/tests/test_hull_grid.py @@ -0,0 +1,127 @@ +"""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 unittest +from pathlib import Path + +_spec = importlib.util.spec_from_file_location( + "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))] + + +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 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_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.assertIn("33", str(caught.exception)) + with self.assertRaises(ValueError) as caught: + hull_grid.rasterise(rect(0, 0, 66, 2)) + self.assertIn("33", str(caught.exception)) + + def test_a_silhouette_with_no_area_is_refused(self): + with self.assertRaises(ValueError): + hull_grid.rasterise([]) + with self.assertRaises(ValueError): + hull_grid.rasterise([((0, 0), (1, 1), (2, 2))]) + + def test_a_degenerate_triangle_does_not_widen_the_grid(self): + _, _, cells, _ = hull_grid.rasterise(rect(0, 0, 2, 2) + [((50, 50), (60, 60), (70, 70))]) + self.assertEqual(cells, [True]) + + +if __name__ == "__main__": + unittest.main()