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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-08 19:35:46 +02:00
commit 922928ebe7
2 changed files with 212 additions and 0 deletions

View file

@ -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

View file

@ -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()