Package moves no row: delete the schematic-from-model derivation
Remove _plan_mounts/_apply_mounts, _derived_position, CENTRELINE, hull_grid.cell_at, mounted_position/mounted_cell and their tests (ruling remove-package-schematic-derivation). Add Weapon makes a default-Position row; Mirror Mount mirrors each space within itself. smoke_mounts rewritten (not run: bpy smokes are held off Starfire). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
5acbaa1b2f
commit
eaf7b799d4
4 changed files with 107 additions and 220 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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 <game folder>/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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue