Aetheria/tools/blender/aetheria_ships/hull_grid.py
soul 0a70e0cf93 Blender ships: hull_grid.grid_size; ship_cc reads HullData.HullType, pinned to C#
grid_size is rasterise's grid arithmetic without its MAX_CELLS refusal, so a
check can judge the bound; rasterise calls the same private filter and sizing.
ship_cc gains HULL_TYPE_SLOT (Key 25), HULL_TYPE_NAMES and read_hull_type;
ShipSchemaPinTests ties both to HullData.HullType and the HullType enum.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 01:00:06 +02:00

150 lines
6.9 KiB
Python

"""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 _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 _with_area(triangles_xy):
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")
return triangles
def _size(triangles, cell_size):
xs = [p[0] for tri in triangles for p in tri]
ys = [p[1] for tri in triangles for p in tri]
return _cells_across(max(xs) - min(xs), cell_size), _cells_across(max(ys) - min(ys), cell_size), xs, ys
def grid_size(triangles_xy, cell_size=CELL_SIZE):
"""(width, height) in cells of the grid rasterise would lay over these triangles, without refusing a grid over
MAX_CELLS: the caller judges the bound. Raises ValueError when no triangle has area."""
width, height, _, _ = _size(_with_area(triangles_xy), cell_size)
return width, height
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 = _with_area(triangles_xy)
width, height, xs, ys = _size(triangles, 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
def convex_outline(points_xy):
"""The convex hull of 2D points, counter-clockwise from the lowest-then-leftmost point (monotone chain). Duplicate
and interior points drop out. Fewer than three non-collinear points have no outline: ValueError."""
points = sorted({(float(x), float(y)) for x, y in points_xy})
def half(sequence):
chain = []
for point in sequence:
while len(chain) >= 2 and ((chain[-1][0] - chain[-2][0]) * (point[1] - chain[-2][1])
- (chain[-1][1] - chain[-2][1]) * (point[0] - chain[-2][0])) <= 0:
chain.pop()
chain.append(point)
return chain[:-1]
outline = half(points) + half(reversed(points))
if len(outline) < 3:
raise ValueError("The points have no outline: they are fewer than three or collinear")
return outline
# ItemRotation values, in ship_cc.ROTATION_NAMES order.
ROTATION_NONE, ROTATION_COUNTER_CLOCKWISE, ROTATION_REVERSED, ROTATION_CLOCKWISE = 0, 1, 2, 3
_FACING_YAW = {
ROTATION_NONE: 0.0,
ROTATION_COUNTER_CLOCKWISE: math.pi / 2,
ROTATION_REVERSED: math.pi,
ROTATION_CLOCKWISE: -math.pi / 2,
}
def mirror_position(position, footprint_w, grid_w):
"""The Position of the mirror image of a footprint across the grid's centre line: a footprint on cells [a, a + w) of
a grid_w-wide grid mirrors to cells [grid_w - a - w, grid_w - a). It depends on the footprint's extent alone."""
return (grid_w - position[0] - footprint_w, position[1])
def facing_yaw(rotation):
"""Radians about Z that turn a mount empty so its local -Y points along the hardpoint Rotation's facing: None the
nose (-Y), CounterClockwise port (+X), Reversed aft (+Y), Clockwise starboard (-X)."""
return _FACING_YAW[rotation]
def mirror_rotation(rotation):
"""The Rotation of a hardpoint mirrored across the centreline: Clockwise and CounterClockwise swap."""
if rotation == ROTATION_CLOCKWISE:
return ROTATION_COUNTER_CLOCKWISE
if rotation == ROTATION_COUNTER_CLOCKWISE:
return ROTATION_CLOCKWISE
return rotation
def mirror_footprint(footprint):
"""A footprint text ('#' and '.' rows separated by '/') mirrored across the centreline: each row reversed."""
return "/".join(row[::-1] for row in footprint.split("/"))