diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py index e52f4ffd1..5b640e53b 100644 --- a/tools/blender/aetheria_ships/__init__.py +++ b/tools/blender/aetheria_ships/__init__.py @@ -207,7 +207,7 @@ MOUNT_ROLE = "weapon-mount" MUZZLE_ROLE = "weapon-muzzle" MUZZLE_OFFSET = (0.0, -0.5, 0.0) # in the mount's frame: half a metre along its facing WEAPON_TYPES = tuple(item for item in HARDPOINT_TYPES if item[1] in ("Energy", "Ballistic", "Launcher")) -CENTRELINE = hull_grid.CELL_SIZE / 2 # a mount closer than this to Ship Root's X = 0 has no mirror image +MODEL_TOLERANCE = 1e-3 # metres: a mount whose point is this close to Ship Root's X = 0 has no mirror image def _add_row(state, **fields): @@ -230,19 +230,15 @@ def _muzzles(mount): def _mount_frame(context): - """(collection, layout buffer, Ship Root, grid_origin) for a mount helper: the buffer must be this ship's and the grid - must match it.""" + """(collection, layout buffer, Ship Root) for a mount helper: the buffer must be this ship's.""" collection, path = _layout_path(context) state = context.scene.aetheria_layout if state.ship_id != collection["aetheria.id"] or state.ship_cc != path: raise ValueError("Load this collection's layout first") root = _ship_root(collection) - origin = collection.get("aetheria.grid_origin") - if root is None or origin is None: - raise ValueError("Rasterise first: a mount needs the ship's Ship Root and grid") - if not _grid_current(collection, state): - raise ValueError("Rasterise again: the grid no longer matches the layout") - return collection, state, root, (float(origin[0]), float(origin[1])) + if root is None: + raise ValueError("A mount needs the ship's Ship Root: Rasterise or Build From Hull Source first") + return collection, state, root def _root_local(root, obj): @@ -250,16 +246,6 @@ def _root_local(root, obj): return root.matrix_world.inverted() @ obj.matrix_world.translation -def _derived_position(state, origin, local, footprint): - """The one derivation of a mounted row's Position: the cell at the object's origin (Ship Root frame), offset by the - footprint's centre. Refuses a Position outside the grid.""" - width, height = _parse_footprint(footprint)[0][:2] - position = hull_grid.mounted_position(hull_grid.cell_at(origin, (local[0], local[1])), width, height) - if not (0 <= position[0] < state.width and 0 <= position[1] < state.height): - raise ValueError("The mount lies outside the grid") - return position - - def _selected_mount(context, collection): obj = context.active_object if obj is None or obj.get("aetheria.role") != MOUNT_ROLE or obj.name not in collection.all_objects: @@ -299,29 +285,30 @@ def _make_muzzle(collection, mount, k, local, order): return _make_empty(collection, f"{mount['aetheria.id']}.muzzle.{k}", MUZZLE_ROLE, mount, local, order=order) -def _add_mount(collection, state, root, origin, kind, rotation, local, footprint="#"): - """A weapon-mount object under Ship Root and its row, as a matching pair; returns the mount.""" - position = _derived_position(state, origin, local, footprint) +def _add_mount(collection, state, root, kind, rotation, local, footprint="#", position=None): + """A weapon-mount object under Ship Root and its row, as a matching pair; returns the mount. The row's Position is + the given schematic position, else HardpointData's default: the model point is never mapped to a cell.""" mount_id = _new_mount_id(collection, state) mount = _make_empty(collection, mount_id, MOUNT_ROLE, root, local, hull_grid.facing_yaw(rotation)) - _add_row(state, kind=kind, mount_id=mount_id, rotation=str(rotation), footprint=footprint, - x=position[0], y=position[1]) + fields = {} if position is None else {"x": position[0], "y": position[1]} + _add_row(state, kind=kind, mount_id=mount_id, rotation=str(rotation), footprint=footprint, **fields) return mount class AETHERIA_OT_add_weapon(bpy.types.Operator): bl_idname = "aetheria.add_ship_weapon" bl_label = "Add Weapon" - bl_description = "Add a weapon mount and its first muzzle at the 3D cursor, with a layout row" + bl_description = ("Add a weapon mount and its first muzzle at the 3D cursor, with a layout row at the default Position, " + "to be authored in the layout panel") bl_options = {"REGISTER", "UNDO"} kind: bpy.props.EnumProperty(name="Type", items=WEAPON_TYPES) rotation: bpy.props.EnumProperty(name="Rotation", items=ROTATIONS) def execute(self, context): try: - collection, state, root, origin = _mount_frame(context) + collection, state, root = _mount_frame(context) local = root.matrix_world.inverted() @ context.scene.cursor.location - mount = _add_mount(collection, state, root, origin, self.kind, int(self.rotation), local) + mount = _add_mount(collection, state, root, self.kind, int(self.rotation), local) _make_muzzle(collection, mount, 1, MUZZLE_OFFSET, 1) except _EXPECTED as exc: return _refuse(self, exc) @@ -336,7 +323,7 @@ class AETHERIA_OT_add_muzzle(bpy.types.Operator): def execute(self, context): try: - collection, _, _, _ = _mount_frame(context) + collection, _, _ = _mount_frame(context) mount = _selected_mount(context, collection) existing = _muzzles(mount) names = {child.name for child in existing} @@ -358,21 +345,17 @@ class AETHERIA_OT_mirror_mount(bpy.types.Operator): def execute(self, context): try: - collection, state, root, origin = _mount_frame(context) + collection, state, root = _mount_frame(context) mount = _selected_mount(context, collection) row = _mount_row(state, mount["aetheria.id"]) local = _root_local(root, mount) - if abs(local.x) < CENTRELINE: - raise ValueError("The weapon mount is on the centreline; it has no mirror image") - # The twin's footprint is the reflection of this one's cells; its object stands in the cell that Position - # puts it in (never at -x, whose cell lookup breaks ties one way), so Package derives the Position back. - width, height = _parse_footprint(row.footprint)[0][:2] - position = hull_grid.mirror_position(_derived_position(state, origin, local, row.footprint), width, - state.width) - twin_x = hull_grid.cell_centre(origin, *hull_grid.mounted_cell(position, width, height))[0] - mirrored = _add_mount(collection, state, root, origin, row.kind, - hull_grid.mirror_rotation(int(row.rotation)), (twin_x, local.y, local.z), - hull_grid.mirror_footprint(row.footprint)) + # Each space mirrors within itself: the row in the layout (the author's Position), the object in the model. + width = _parse_footprint(row.footprint)[0][0] + twin = hull_grid.mirror_position((row.x, row.y), width, state.width) + if abs(local.x) < MODEL_TOLERANCE or twin == (row.x, row.y): + raise ValueError("The weapon mount has no mirror image") + mirrored = _add_mount(collection, state, root, row.kind, hull_grid.mirror_rotation(int(row.rotation)), + (-local.x, local.y, local.z), hull_grid.mirror_footprint(row.footprint), twin) new_row = _mount_row(state, mirrored["aetheria.id"]) for name in ("armor", "firing_arc"): # the author's own numbers, copied; the helper invents none setattr(new_row, name, getattr(row, name)) @@ -392,7 +375,7 @@ class AETHERIA_OT_remove_mount(bpy.types.Operator): def execute(self, context): try: - collection, state, _, _ = _mount_frame(context) + collection, state, _ = _mount_frame(context) mount = _selected_mount(context, collection) mount_id = mount["aetheria.id"] for index in reversed(range(len(state.hardpoints))): @@ -406,41 +389,6 @@ class AETHERIA_OT_remove_mount(bpy.types.Operator): return {"FINISHED"} -def _plan_mounts(collection, state, root, path): - """(row, weapon-mount object, Position, yaw) for every buffer row whose Transform names a weapon-mount object: the - Position from the object's placement, the yaw from the row's Rotation. Refuses before anything changes.""" - mounts = _mount_objects(collection) - if not mounts or root is None: # a ship with no Ship Root has no grid to derive from: its rows are the author's - return [] - if state.ship_id != collection["aetheria.id"] or state.ship_cc != path: - raise ValueError("Load Layout first: the weapon mounts need this ship's layout") - paired = [(hp, mounts[hp.mount_id]) for hp in state.hardpoints if hp.mount_id in mounts] - if not paired: - return [] - origin = collection.get("aetheria.grid_origin") - if origin is None: - raise ValueError("Rasterise first: a mount needs the ship's grid") - plan = [] - for hp, obj in paired: - if obj.parent != root: - raise ValueError("A weapon mount must be parented to Ship Root") - position = _derived_position(state, (float(origin[0]), float(origin[1])), _root_local(root, obj), hp.footprint) - plan.append((hp, obj, position, hull_grid.facing_yaw(int(hp.rotation)))) - return plan - - -def _apply_mounts(context, collection, state, plan): - """Writes the planned Positions into the buffer and yaws into the objects, then saves the buffer through - _save_layout, so the buffer and the .cc agree.""" - if not plan: - return - for hp, obj, position, yaw in plan: - hp.x, hp.y = position - obj.rotation_mode = "XYZ" - obj.rotation_euler = (0.0, 0.0, yaw) - _save_layout(context, collection, state) - - def _encode_buffer(state): """The layout buffer as ship_cc stores it: (shape, hardpoint rows).""" shape = [state.width, state.height, [cell.occupied for cell in state.cells]] @@ -1717,10 +1665,8 @@ class AETHERIA_OT_package_ship(bpy.types.Operator): if read(path, cultlib).ship.body[0] != ship_id: raise ValueError("The bound collection ID does not match its .cc ship ID") root = _ship_root(collection) - mounts = _plan_mounts(collection, state, root, path) if root is not None: _frame_ship(context, collection, root, path, saved_shape) - _apply_mounts(context, collection, state, mounts) anchors = _anchors(collection) _export_glb(context, collection, str(Path(path).with_name(MODEL_ASSET))) replace_visual(path, cultlib, ship_id, MODEL_ASSET, anchors) diff --git a/tools/blender/aetheria_ships/hull_grid.py b/tools/blender/aetheria_ships/hull_grid.py index b38770339..e408105be 100644 --- a/tools/blender/aetheria_ships/hull_grid.py +++ b/tools/blender/aetheria_ships/hull_grid.py @@ -20,12 +20,6 @@ def cell_centre(grid_origin, x, y): 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)) @@ -115,23 +109,6 @@ _FACING_YAW = { } -def _centre_offset(n): - """The footprint cell, counted from its origin, that stands for an n-cell extent's centre: the lower of the two - middle cells when n is even.""" - return (n - 1) // 2 - - -def mounted_position(cell, footprint_w, footprint_h): - """The hardpoint Position (its footprint's origin cell) whose w x h footprint is centred on cell: ShipAuthoringStore - places footprint cell (cx, cy) at Position + (cx, cy), and the footprint's centre is ((w - 1) // 2, (h - 1) // 2).""" - return (cell[0] - _centre_offset(footprint_w), cell[1] - _centre_offset(footprint_h)) - - -def mounted_cell(position, footprint_w, footprint_h): - """The cell a mount's object stands in for a hardpoint at position: the inverse of mounted_position.""" - return (position[0] + _centre_offset(footprint_w), position[1] + _centre_offset(footprint_h)) - - 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.""" diff --git a/tools/blender/tests/smoke_mounts.py b/tools/blender/tests/smoke_mounts.py index fd8bb0022..53418abb1 100644 --- a/tools/blender/tests/smoke_mounts.py +++ b/tools/blender/tests/smoke_mounts.py @@ -1,5 +1,5 @@ """Headless Blender smoke for the add-on's weapon mounts, the gunboat path: Add Weapon, Add Muzzle, Mirror Mount, Remove -Mount, and what Package derives from them. +Mount, and that Package moves nothing of them. blender --background --factory-startup --python-use-system-env --python tools/blender/tests/smoke_mounts.py @@ -7,18 +7,20 @@ Environment as smoke_package.py's header says: CULTLIB_PACKAGES and SMOKE_GAME_F ModTools, no dotnet), and msgpack importable. The ship is created under /GameData/Mods/smoke.mounts, which this smoke removes when it ends. -The ship is smoke_frame's sphere, centred on the origin, through New Ship (6 x 10 cells, cell x = 0 at +X). Pass 1 adds a -Ballistic gun on the starboard flank as Clockwise: its row's Rotation is 3, its Position is the cell under the mount, and -its muzzle sits 0.5 m toward -X of it. Mirror Mount makes a CounterClockwise row at the mirrored cell with its muzzle -toward +X, and refuses a mount on the centreline. Pass 2 packages: the Clockwise muzzle node in the GLB turns glTF +Z to -glTF -X, and the mirrored one to +X. Pass 3 moves a weapon two cells by script and sets another's Rotation to Reversed in -the panel state: Package moves the first row's Position by two cells, leaves the internal Reactor row alone, yaws the -second mount to 180 degrees and the first back to its Rotation's yaw, and the validator accepts the ship. Pass 4 removes a -mount with two muzzles: its row, the mount and both muzzles go, and the other rows stay. +The schematic and the model are separate spaces (ruling schematic-and-model-are-separate-spaces): a row's Position is +authored in the layout, a mount's pose in the model, and the two are linked by name alone. The ship is smoke_frame's +sphere, centred on the origin, through New Ship (6 x 10 cells, cell x = 0 at +X). Pass 1 adds a Ballistic gun on the +starboard flank as Clockwise: its row is made at HardpointData's default Position (the model point maps to no cell), the +smoke sets the Position as the panel would, and its muzzle sits 0.5 m toward -X of it. Mirror Mount makes a +CounterClockwise row at the footprint's reflection in the layout and the object's reflection in the model, with its +muzzle toward +X, and refuses a mount on the model's centreline and one whose footprint is its own reflection. Pass 2 +packages: the Clockwise muzzle node in the GLB turns glTF +Z to glTF -X, and the mirrored one to +X. Pass 3 moves a +weapon in the model, hand-sets its yaw and changes another's Rotation: Package leaves every row's Position, in the buffer +and in the .cc, and every mount's yaw as authored. Pass 4 removes a mount with two muzzles: its row, the mount and both +muzzles go, and the other rows stay. """ import json -import math import os import shutil import struct @@ -118,6 +120,17 @@ def set_footprint(scene, mount_id, footprint): next(hp for hp in scene.aetheria_layout.hardpoints if hp.mount_id == mount_id).footprint = footprint +def set_position(scene, mount_id, position): + """The panel's edit: a row's Position is authored in the layout.""" + hp = next(h for h in scene.aetheria_layout.hardpoints if h.mount_id == mount_id) + hp.x, hp.y = position + + +def root_local(root, obj): + """obj's origin in Ship Root's frame.""" + return tuple(root.matrix_world.inverted() @ Vector(world(obj))) + + def world(obj): bpy.context.view_layer.update() return tuple(obj.matrix_world.translation) @@ -167,58 +180,58 @@ def remove_mount(mount_id): require(run(bpy.ops.aetheria.remove_ship_mount)[0] == {"FINISHED"}, "Remove Mount was refused") -def mirror_matrix(scene, root, origin, sphere): - """Mirror Mount against the reflection of the footprint's cells, for every footprint width 1 to 4 at every cell of - the grid: a footprint on cells [a, a + w) of a W-wide grid has its twin on [W - a - w, W - a), whatever the grid's - parity, and a mount within a cell of the centre line has no twin. The expected cells are computed here from the - footprint, never read back from the twin.""" +def mirror_matrix(scene, root, sphere): + """Mirror Mount, each space within itself, for every footprint width 1 to 4 at every authored Position of the grid: + a footprint on cells [a, a + w) of a W-wide grid has its twin row on [W - a - w, W - a), whatever the grid's parity, + and the twin object is the model reflection of the source (-x, y, z), wherever the author put the row. A footprint + that is its own reflection has no twin. The expected cells are computed here from the footprint, never read back + from the twin.""" state = scene.aetheria_layout - grid_w, grid_h = state.width, state.height + grid_w = state.width y_cell = 1 - y = origin[1] - 2.0 * y_cell + model_point = (1.5, -1.0, 0.2) # off the centreline; it names no cell mirrored = refused_count = 0 baseline = rows(scene) for w in (1, 2, 3, 4): - for c in range(grid_w): # the cell the mount's object stands in; a footprint is centred on it - a = c - (w - 1) // 2 - if a < 0 or a + w > grid_w: - continue - x = origin[0] - 2.0 * c - mount_id = new_gun(scene, root, (x, y, 0.0)) + for a in range(grid_w - w + 1): + mount_id = new_gun(scene, root, model_point) set_footprint(scene, mount_id, "#" * w) + set_position(scene, mount_id, (a, y_cell)) select(bpy.data.objects[mount_id]) before = rows(scene) - if abs(x) < 1.0: - refused(bpy.ops.aetheria.mirror_ship_mount, "no mirror image", "Mirror Mount near the centre line") + expected = (grid_w - a - w, y_cell) + if expected == (a, y_cell): + refused(bpy.ops.aetheria.mirror_ship_mount, "no mirror image", "Mirror Mount of a footprint that is its own reflection") require(rows(scene) == before, "A refused Mirror Mount changed a row") refused_count += 1 else: - require(run(bpy.ops.aetheria.mirror_ship_mount)[0] == {"FINISHED"}, f"Mirror Mount was refused (width {w}, cell {c})") + require(run(bpy.ops.aetheria.mirror_ship_mount)[0] == {"FINISHED"}, f"Mirror Mount was refused (width {w}, cell {a})") (twin_id,) = {r[0] for r in rows(scene)} - {r[0] for r in before} twin = row(scene, twin_id) - expected = (grid_w - a - w, y_cell) require(twin[2] == expected, f"A {w}-wide footprint at cell {a} of {grid_w} mirrored to {twin[2]}, not {expected}") require(twin[4] == "#" * w, "The mirrored footprint is not the reflection of the footprint") + reflected = (-model_point[0], model_point[1], model_point[2]) + require(approx(root_local(root, bpy.data.objects[twin_id]), reflected), + f"The twin object is not the model reflection: {root_local(root, bpy.data.objects[twin_id])}") mirrored += 1 remove_mount(twin_id) remove_mount(mount_id) - require(mirrored >= 6 and (grid_w % 2 == 0 or refused_count >= 1), f"The matrix ran too little ({mirrored}, {refused_count})") + require(mirrored >= 6 and refused_count >= 1, f"The matrix ran too little ({mirrored}, {refused_count})") require(rows(scene) == baseline, "The matrix left rows behind") - # A mount exactly on a cell boundary: its twin is the reflection of the cell it was placed in, not a second rounding. - k = grid_w // 2 - mount_id = new_gun(scene, root, (origin[0] - 2.0 * k - 1.0, y, 0.0)) - source = row(scene, mount_id) + # The twin carries the author's armour and arc, rotation mirrored, and the muzzle's reflection. + mount_id = new_gun(scene, root, model_point) + set_position(scene, mount_id, (0, y_cell)) hp = next(h for h in state.hardpoints if h.mount_id == mount_id) hp.armor, hp.firing_arc = 3.0, 45.0 # the author's numbers muzzle = bpy.data.objects[f"{mount_id}.muzzle.1"] muzzle.location.x = 0.3 select(bpy.data.objects[mount_id]) before_ids = {r[0] for r in rows(scene)} - require(run(bpy.ops.aetheria.mirror_ship_mount)[0] == {"FINISHED"}, "Mirror Mount of a boundary mount was refused") + require(run(bpy.ops.aetheria.mirror_ship_mount)[0] == {"FINISHED"}, "Mirror Mount of a muzzled mount was refused") (twin_id,) = {r[0] for r in rows(scene)} - before_ids twin = row(scene, twin_id) - require(twin[2] == (grid_w - 1 - source[2][0], y_cell), f"The boundary mount's twin is at {twin[2]}") + require(twin[2] == (grid_w - 1, y_cell), f"The twin is at {twin[2]}") require(twin[3] == COUNTER_CLOCKWISE and twin[5:] == (3.0, 45.0), f"The twin's rotation or armour and arc are wrong: {twin}") gun_at, twin_at = world(bpy.data.objects[mount_id]), world(bpy.data.objects[twin_id]) source_offset = tuple(m - g for m, g in zip(world(muzzle), gun_at)) @@ -228,22 +241,20 @@ def mirror_matrix(scene, root, origin, sphere): remove_mount(twin_id) remove_mount(mount_id) - # The centre line is a cell wide: half a metre off it has no twin. - mount_id = new_gun(scene, root, (0.5, y, 0.0)) - select(bpy.data.objects[mount_id]) - before = rows(scene) - refused(bpy.ops.aetheria.mirror_ship_mount, "no mirror image", "Mirror Mount of a mount half a metre off the centre line") - require(rows(scene) == before, "A refused Mirror Mount changed a row") - remove_mount(mount_id) - - # A mount whose cell is one past the grid's last is refused, on either axis, and changes nothing. - for edge in ((origin[0] - 2.0 * grid_w, y), (origin[0], origin[1] - 2.0 * grid_h)): - before, objects = rows(scene), len(bpy.data.objects) - scene.cursor.location = root.matrix_world @ Vector((edge[0], edge[1], 0.0)) - select(sphere) - refused(lambda: bpy.ops.aetheria.add_ship_weapon(kind=BALLISTIC, rotation=str(CLOCKWISE)), - "outside the grid", "Add Weapon past the grid's edge") - require(rows(scene) == before and len(bpy.data.objects) == objects, "A refused Add Weapon changed something") + # The model's centreline has no mirror image, whatever the row says; a millimetre is on it, two are not. + for x, ok in ((0.0, False), (5e-4, False), (2e-3, True)): + mount_id = new_gun(scene, root, (x, model_point[1], 0.0)) + set_position(scene, mount_id, (0, y_cell)) + select(bpy.data.objects[mount_id]) + before = rows(scene) + if ok: + require(run(bpy.ops.aetheria.mirror_ship_mount)[0] == {"FINISHED"}, "Mirror Mount just off the centreline was refused") + (twin_id,) = {r[0] for r in rows(scene)} - {r[0] for r in before} + remove_mount(twin_id) + else: + refused(bpy.ops.aetheria.mirror_ship_mount, "no mirror image", f"Mirror Mount of a mount {x} m off the centreline") + require(rows(scene) == before, "A refused Mirror Mount changed a row") + remove_mount(mount_id) def main(): @@ -301,7 +312,9 @@ def main(): gun = bpy.data.objects["weapon.1"] require(gun.get("aetheria.role") == "weapon-mount" and gun.parent == root, "The mount is not a tagged child of Ship Root") gun_row = row(scene, "weapon.1") - require(gun_row == ("weapon.1", 10, (4, 5), CLOCKWISE, "#", 0.0, 0.0), f"The gun's row is wrong: {gun_row}") + require(gun_row == ("weapon.1", 10, (0, 0), CLOCKWISE, "#", 0.0, 0.0), + f"The gun's row is not the default Position (the model point maps to no cell): {gun_row}") + set_position(scene, "weapon.1", (4, 5)) # authored in the layout, as the panel would muzzles = [o for o in bpy.data.objects if o.get("aetheria.role") == "weapon-muzzle"] require([m.get("aetheria.id") for m in muzzles] == ["weapon.1.muzzle.1"] and muzzles[0].parent == gun and muzzles[0].get("aetheria.order") == 1, "Add Weapon did not make its one ordered muzzle") @@ -321,14 +334,14 @@ def main(): require(approx(twin_at, (3.0, -1.0, 0.0)), f"The mirrored mount is not across the centreline: {twin_at}") require(approx(tuple(m - t for m, t in zip(world(twin_muzzle), twin_at)), (0.5, 0.0, 0.0)), "The mirrored muzzle does not point toward +X") - scene.cursor.location = (0.2, 0.0, 0.0) + scene.cursor.location = (0.0, 0.0, 0.0) select(sphere) require(run(lambda: bpy.ops.aetheria.add_ship_weapon(kind=BALLISTIC, rotation="0"))[0] == {"FINISHED"}, "Add Weapon on the centreline was refused") centre = bpy.data.objects["weapon.3"] select(centre) before = rows(scene) - refused(bpy.ops.aetheria.mirror_ship_mount, "no mirror image", "Mirror Mount of a centreline mount") + refused(bpy.ops.aetheria.mirror_ship_mount, "no mirror image", "Mirror Mount of a mount on the model's centreline") require(rows(scene) == before and bpy.data.objects.get("weapon.4") is None, "A refused Mirror Mount changed something") print("PASS 1 add weapon and mirror ok") @@ -346,12 +359,13 @@ def main(): require(rows(scene) == others, "Remove Mount changed a row besides its own") print("PASS 4 remove mount ok") - mirror_matrix(scene, root, (origin[0], origin[1]), sphere) + mirror_matrix(scene, root, sphere) print("PASS 5 mirror matrix ok") # A mirrored pair of two-cell footprints stays where Mirror Mount put it through Package and the validator. pair = new_gun(scene, root, (-1.0, 1.0, 0.0)) set_footprint(scene, pair, "##") + set_position(scene, pair, (3, 4)) select(bpy.data.objects[pair]) require(run(bpy.ops.aetheria.mirror_ship_mount)[0] == {"FINISHED"}, "Mirror Mount of the pair was refused") pair_rows = [r for r in rows(scene) if r[0] not in ("weapon.1", "weapon.2") and r[0].startswith("weapon.")] @@ -372,47 +386,32 @@ def main(): require([r for r in rows(scene) if r in pair_rows] == pair_rows, "Package moved a mirrored pair") print("PASS 2 package and glb ok") - # Pass 3: a moved mount and a changed Rotation; Package derives Position and yaw from them. + # Pass 3: Package moves nothing. A mount moved in the model, a hand-set yaw and a changed Rotation stay as authored. gun, twin = bpy.data.objects["weapon.1"], bpy.data.objects["weapon.2"] - gun.location.y += 4.0 # two cells aft - gun.rotation_euler.z = 0.3 # a hand-set facing: Package overwrites it from the row + gun.location.y += 4.0 # two cells' worth aft in the model; the row is the author's + gun.rotation_euler.z = 0.3 # a hand-set facing + twin_yaw = twin.rotation_euler.z for hp in scene.aetheria_layout.hardpoints: if hp.mount_id == "weapon.2": hp.rotation = str(REVERSED) select(sphere) require(run(bpy.ops.aetheria.save_ship_layout)[0] == {"FINISHED"}, "Save before the second Package was refused") - require(row(scene, "weapon.1")[2] == (4, 5), "The buffer's Position moved before Package") + authored = rows(scene) result, message = run(bpy.ops.aetheria.package_ship) report = scene.aetheria_package_report require(result == {"FINISHED"}, f"Package refused the moved ship: {report}") - require(row(scene, "weapon.1")[2] == (4, 3), f"Package did not move the row two cells: {row(scene, 'weapon.1')[2]}") - require(row(scene, "weapon.2")[2] == (1, 5), "Package moved a mount that did not move") - require(row(scene, "reactor.a") == reactor_before, "Package changed an internal row") - require(abs(twin.rotation_euler.z - math.pi) < 1e-6 or abs(twin.rotation_euler.z + math.pi) < 1e-6, - f"The Reversed mount's yaw is not 180 degrees: {twin.rotation_euler.z}") - require(abs(gun.rotation_euler.z + math.pi / 2) < 1e-6, f"Package kept a hand-set yaw: {gun.rotation_euler.z}") + require(rows(scene) == authored, "Package changed a row in the buffer") + require(row(scene, "weapon.1")[2] == (4, 5) and row(scene, "weapon.2")[2] == (1, 5), "Package moved a weapon's Position") + require(row(scene, "weapon.2")[3] == REVERSED and row(scene, "reactor.a") == reactor_before, "Package changed a Rotation or an internal row") + require(abs(gun.rotation_euler.z - 0.3) < 1e-6, f"Package turned a hand-set yaw: {gun.rotation_euler.z}") + require(abs(twin.rotation_euler.z - twin_yaw) < 1e-6, f"Package turned a mount to its row's Rotation: {twin.rotation_euler.z}") saved = aetheria_ships.ship_cc.read_layout(path, PACKAGES)[1] by_mount = {fields["Transform"]: fields for fields in map(aetheria_ships.ship_cc.decode_hardpoint, saved)} - require(tuple(by_mount["weapon.1"]["Position"]) == (4, 3) and by_mount["weapon.2"]["Rotation"] == REVERSED - and tuple(by_mount["reactor.a"]["Position"]) == (2, 2), "The .cc does not hold what Package derived") - # A framed ship whose layout buffer is not loaded cannot be derived from: Package refuses and changes nothing. - state = scene.aetheria_layout - ship_id, glb_before = state.ship_id, glb.read_bytes() - state.ship_id = "" - result, message = run(bpy.ops.aetheria.package_ship) - require(result == {"CANCELLED"} and "Load Layout first" in scene.aetheria_package_report - and all(name not in scene.aetheria_package_report for name in NAMES_NOT_ECHOED), - f"Package took an unloaded layout: {scene.aetheria_package_report}") - require(glb.read_bytes() == glb_before, "A refused Package wrote the GLB") - state.ship_id = ship_id + require(tuple(by_mount["weapon.1"]["Position"]) == (4, 5) and tuple(by_mount["weapon.2"]["Position"]) == (1, 5) + and by_mount["weapon.2"]["Rotation"] == REVERSED and tuple(by_mount["reactor.a"]["Position"]) == (2, 2), + "The .cc does not hold what the author left") require([r for r in rows(scene) if r in pair_rows] == pair_rows, "The second Package moved a mirrored pair") - twin.parent = None # a mount that is not under Ship Root cannot be derived from - row_before = rows(scene) - result, message = run(bpy.ops.aetheria.package_ship) - require(result == {"CANCELLED"} and "must be parented" in scene.aetheria_package_report and rows(scene) == row_before, - f"Package took a mount outside Ship Root: {scene.aetheria_package_report}") - twin.parent = root - print("PASS 3 package derives ok") + print("PASS 3 package moves nothing ok") # Passes 6 to 8: the same mirror matrix on ships whose grids are odd, wide and even, and wide and odd (10 or more # cells across, where real hulls live). Each pass clears the previous ship's objects, which hold the names the matrix @@ -444,8 +443,7 @@ def matrix_pass(scene, previous, ship_id, length, y_scale, wide, parity): require(result == {"FINISHED"}, f"Save ({ship_id}) was refused: {message}") width = scene.aetheria_layout.width require(width % 2 == parity and (width >= 10 or not wide), f"The {ship_id} grid is {width} wide") - origin = ship["aetheria.grid_origin"] - mirror_matrix(scene, sys.modules["aetheria_ships"]._ship_root(ship), (origin[0], origin[1]), sphere) + mirror_matrix(scene, sys.modules["aetheria_ships"]._ship_root(ship), sphere) return ship diff --git a/tools/blender/tests/test_hull_grid.py b/tools/blender/tests/test_hull_grid.py index ae310a949..06667c0f2 100644 --- a/tools/blender/tests/test_hull_grid.py +++ b/tools/blender/tests/test_hull_grid.py @@ -32,12 +32,6 @@ def occupied(cells, height): 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) @@ -45,18 +39,6 @@ class CellMappingTests(unittest.TestCase): 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)) @@ -136,7 +118,6 @@ class RasteriseTests(unittest.TestCase): 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]) @@ -202,11 +183,6 @@ class MountArithmeticTests(unittest.TestCase): "#.....": ".....#", "##.#.../......": "...#.##/......"}.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): @@ -222,16 +198,6 @@ class MountArithmeticTests(unittest.TestCase): 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()