Merge remote-tracking branch 'origin/master' into eureka/aetheria-gravity-laned-belts
This commit is contained in:
commit
bbb1dc717e
9 changed files with 301 additions and 27 deletions
|
|
@ -22,6 +22,7 @@ public sealed class ShipSchemaPinTests
|
|||
[InlineData("SCHEMATIC_LINES_SLOT", typeof(ShipAuthoring), nameof(ShipAuthoring.SchematicLines))]
|
||||
[InlineData("HULL_SHAPE_SLOT", typeof(HullData), nameof(HullData.Shape))]
|
||||
[InlineData("HULL_HARDPOINTS_SLOT", typeof(HullData), nameof(HullData.Hardpoints))]
|
||||
[InlineData("HULL_TYPE_SLOT", typeof(HullData), nameof(HullData.HullType))]
|
||||
[InlineData("PRODUCT_MANUFACTURER_SLOT", typeof(FactionProductData), nameof(FactionProductData.Manufacturer))]
|
||||
public void PythonSlotConstantsAreTheKeysOfTheMembersTheyNameInTheirComments(string constant, Type owner, string member)
|
||||
{
|
||||
|
|
@ -136,6 +137,7 @@ public sealed class ShipSchemaPinTests
|
|||
{
|
||||
Assert.Equal(EnumNames<HardpointType>(), Strings("HARDPOINT_TYPE_NAMES"));
|
||||
Assert.Equal(EnumNames<ItemRotation>(), Strings("ROTATION_NAMES"));
|
||||
Assert.Equal(EnumNames<HullType>(), Strings("HULL_TYPE_NAMES"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ from mathutils import Matrix, Vector
|
|||
|
||||
from . import hull_grid, hull_source
|
||||
from .ship_cc import (HARDPOINT_MEMBERS, HARDPOINT_TYPE_NAMES, ROTATION_NAMES, capture_grease_pencil, decode_hardpoint,
|
||||
encode_hardpoint, layout_revision, read, read_layout, replace_layout, replace_lines,
|
||||
encode_hardpoint, layout_revision, read, read_hull_type, read_layout, replace_layout, replace_lines,
|
||||
product_makers, replace_visual, valid_ship_id)
|
||||
|
||||
MODEL_ASSET = "ship.glb"
|
||||
|
|
@ -1557,8 +1557,10 @@ def _hull_mesh(name, mesh, material, offset=(0.0, 0.0, 0.0), yaw=0.0):
|
|||
|
||||
def _build_hull_source(context, collection):
|
||||
"""Replaces the objects Build made before with the hull source's zone meshes, anchors and collider under Ship Root.
|
||||
Refuses, before anything changes, when the source cannot be read, breaks a check of its faction's language, or the
|
||||
collection holds render meshes Build did not make (operator art: Build never overwrites it). Writes no layout row."""
|
||||
Refuses, before anything changes, when the source cannot be read, breaks a check of its faction's language (whose
|
||||
proportions judge a hull whose ship.cc HullData.HullType is Ship, and no other), its zones overflow the layout grid,
|
||||
or the collection holds render meshes Build did not make (operator art: Build never overwrites it). Writes no layout
|
||||
row."""
|
||||
source = _hull_source_file(collection)
|
||||
if source is None:
|
||||
raise ValueError("The bound package holds no hull.scad")
|
||||
|
|
@ -1573,7 +1575,9 @@ def _build_hull_source(context, collection):
|
|||
"changed; Build From Hull Source replaces only what it made and nobody touched")
|
||||
addon = context.preferences.addons.get(__package__)
|
||||
executable = addon.preferences.openscad if addon and addon.preferences else DEFAULT_OPENSCAD
|
||||
hull = hull_source.build(hull_source.openscad_runner(executable, source), hull_source.language_library(executable))
|
||||
hull_type = read_hull_type(bpy.path.abspath(collection["aetheria.ship_cc"]), _brokkr_cultlib(context))
|
||||
hull = hull_source.build(hull_source.openscad_runner(executable, source), hull_source.language_library(executable),
|
||||
hull_type)
|
||||
broken = hull.failed()
|
||||
if broken:
|
||||
raise ValueError("The hull source breaks its faction's language or its brief: " + "; ".join(c.name for c in broken))
|
||||
|
|
|
|||
|
|
@ -32,6 +32,27 @@ def _inside(triangle, px, py):
|
|||
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.
|
||||
|
||||
|
|
@ -40,14 +61,8 @@ def rasterise(triangles_xy, cell_size=CELL_SIZE, samples=SAMPLES):
|
|||
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)
|
||||
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}")
|
||||
|
|
|
|||
|
|
@ -14,11 +14,15 @@ with it (a hull that redefines or shadows an included language fails a check):
|
|||
<zone> the zone's geometry, OFF; a donor's zone is named "<donor>:<zone>"
|
||||
emit:<id>, rad:<id> the mesh of a thruster-emitter or radiator-mesh anchor
|
||||
|
||||
Frame: Ship Root, metres, nose -Y, starboard -X, up +Z. build() returns a HullBuild; the caller refuses on any failed
|
||||
check. Messages and check names are fixed text: nothing a hull source says is echoed back.
|
||||
Frame: Ship Root, metres, nose -Y, starboard -X, up +Z. build() also takes the hull's HullData.HullType ("Ship",
|
||||
"Station" or "Turret"), which the caller reads: the add-on from the bound package's ship.cc, the bench from its flag.
|
||||
A faction language's proportions judge Ship hulls only; every hull's zones must fit the layout grid (hull_grid.MAX_CELLS
|
||||
cells of hull_grid.CELL_SIZE a side). The brief's role is prose and decides nothing. build() returns a HullBuild; the
|
||||
caller refuses on any failed check. Messages and check names are fixed text: nothing a hull source says is echoed back.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
|
|
@ -29,6 +33,16 @@ from dataclasses import dataclass, field
|
|||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
if __package__:
|
||||
from . import hull_grid, ship_cc
|
||||
else: # loaded by path (tests, the bench): load the siblings the same way
|
||||
def _sibling(name):
|
||||
spec = importlib.util.spec_from_file_location(name, Path(__file__).with_name(f"{name}.py"))
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
hull_grid, ship_cc = _sibling("hull_grid"), _sibling("ship_cc")
|
||||
|
||||
FACTIONS_DIR = Path(__file__).with_name("factions")
|
||||
HULL_FILE = "hull.scad"
|
||||
OPENSCAD_TIMEOUT = 180 # seconds, per part
|
||||
|
|
@ -113,6 +127,7 @@ class HullBuild:
|
|||
glow: tuple
|
||||
radiator: tuple
|
||||
body_zone: str # the zone key the collider is made from
|
||||
hull_type: str # HullData.HullType of the bound package ("Ship", "Station", "Turret"), as the caller read it
|
||||
checks: list = field(default_factory=list)
|
||||
|
||||
def failed(self):
|
||||
|
|
@ -274,10 +289,14 @@ def _language(library, faction):
|
|||
return language
|
||||
|
||||
|
||||
def build(runner, library):
|
||||
def build(runner, library, hull_type):
|
||||
"""Reads the hull source through runner (see Runner) and judges it against the languages library(faction) answers with
|
||||
(the ECHO text of that faction's own language file). Raises HullSourceError when the source cannot be read at all;
|
||||
otherwise returns a HullBuild whose failed() lists the checks it breaks."""
|
||||
(the ECHO text of that faction's own language file). hull_type is the hull's HullData.HullType name, which the caller
|
||||
reads; only a "Ship" is judged on the language's proportions. Raises HullSourceError, before reading anything, when
|
||||
hull_type is not one of ship_cc.HULL_TYPE_NAMES, and when the source cannot be read at all; otherwise returns a
|
||||
HullBuild whose failed() lists the checks it breaks."""
|
||||
if not (type(hull_type) is str and hull_type in ship_cc.HULL_TYPE_NAMES):
|
||||
raise HullSourceError("The hull type is not a HullData.HullType name (Ship, Station or Turret)")
|
||||
_, text = runner("brief")
|
||||
brief = read_brief(text)
|
||||
_, text = runner("lang")
|
||||
|
|
@ -298,7 +317,7 @@ def build(runner, library):
|
|||
judge = donor or own
|
||||
body_zone = f"{donor.faction}:{donor.body}" if donor else own.body
|
||||
result = HullBuild(brief, {language.faction: language for language in owned}, zones, colours, anchors, anchor_meshes,
|
||||
own.glow, own.radiator, body_zone)
|
||||
own.glow, own.radiator, body_zone, hull_type)
|
||||
by_faction = lambda language: language.faction
|
||||
result.checks = [Check("the hull includes its faction's language files and changes none of them",
|
||||
sorted(included, key=by_faction) == sorted(owned, key=by_faction), "")]
|
||||
|
|
@ -314,8 +333,9 @@ def _checks(hull, own, judge):
|
|||
checks.append(Check("every emitter and radiator anchor has a mesh", not missing, f"{len(missing)} without"))
|
||||
checks.append(Check("a donor is named exactly when the language has no proportions of its own",
|
||||
bool(brief.donor) == (own.proportion is None), ""))
|
||||
# A language's proportions describe its ships; a station or turret is not held to them, and no check is listed.
|
||||
body = hull.zones[hull.body_zone] if hull.body_zone in hull.zones else Mesh((), ())
|
||||
if body.triangles and judge.proportion:
|
||||
if hull.hull_type == "Ship" and body.triangles and judge.proportion:
|
||||
low, high = body.bounds()
|
||||
width, length, height = (high[0] - low[0]), (high[1] - low[1]), (high[2] - low[2])
|
||||
ratios = (height / width if width else float("inf"), width / length if length else float("inf"))
|
||||
|
|
@ -323,7 +343,7 @@ def _checks(hull, own, judge):
|
|||
checks.append(Check("the body's proportions lie inside the language's ranges",
|
||||
hw_lo <= ratios[0] <= hw_hi and wl_lo <= ratios[1] <= wl_hi,
|
||||
f"h/w {ratios[0]:.2f}, w/l {ratios[1]:.2f}"))
|
||||
else:
|
||||
elif hull.hull_type == "Ship":
|
||||
checks.append(Check("the body's proportions lie inside the language's ranges", False, "no body to judge"))
|
||||
every = [m for m in list(hull.zones.values()) + list(hull.anchor_meshes.values()) if m.triangles]
|
||||
if every:
|
||||
|
|
@ -338,6 +358,14 @@ def _checks(hull, own, judge):
|
|||
else:
|
||||
checks.append(Check("the top view obeys the language's symmetry rule", False, "no geometry"))
|
||||
checks.append(Check("the length lies inside the brief's range", False, "no geometry"))
|
||||
# Rasterise reads the zone meshes only, so the grid bound is judged over them, not over anchor meshes.
|
||||
try:
|
||||
width, height = hull_grid.grid_size([t for m in hull.zones.values() for t in _triangles_xy(m)])
|
||||
checks.append(Check("the top view fits the layout grid",
|
||||
width <= hull_grid.MAX_CELLS and height <= hull_grid.MAX_CELLS,
|
||||
f"{width} x {height} cells, at most {hull_grid.MAX_CELLS} a side"))
|
||||
except ValueError:
|
||||
checks.append(Check("the top view fits the layout grid", False, "no geometry"))
|
||||
triangles = sum(len(m.triangles) for m in every)
|
||||
checks.append(Check("the triangle count lies inside the brief's budget",
|
||||
brief.triangles[0] <= triangles <= brief.triangles[1], f"{triangles}"))
|
||||
|
|
|
|||
|
|
@ -16,6 +16,14 @@ from datetime import datetime, timezone
|
|||
from pathlib import Path
|
||||
from typing import Any, NamedTuple
|
||||
|
||||
if __package__:
|
||||
from . import hull_grid
|
||||
else: # loaded by path (tests, hull_source loaded by path): load the sibling the same way
|
||||
import importlib.util
|
||||
_grid_spec = importlib.util.spec_from_file_location("hull_grid", Path(__file__).with_name("hull_grid.py"))
|
||||
hull_grid = importlib.util.module_from_spec(_grid_spec)
|
||||
_grid_spec.loader.exec_module(hull_grid)
|
||||
|
||||
# The cultcache-py this module's raw envelope writes were tested against. cultcache_py has no __version__ and is loaded
|
||||
# from source, so the guard reads [project].version of the checkout the imported module itself came from.
|
||||
TESTED_CULTCACHE_PY = "0.3.0"
|
||||
|
|
@ -34,15 +42,17 @@ ANCHORS_SLOT = 3 # ShipAuthoring.Anchors
|
|||
SCHEMATIC_LINES_SLOT = 4 # ShipAuthoring.SchematicLines
|
||||
HULL_SHAPE_SLOT = 5 # ItemData.Shape
|
||||
HULL_HARDPOINTS_SLOT = 23 # HullData.Hardpoints
|
||||
HULL_TYPE_SLOT = 25 # HullData.HullType
|
||||
# HardpointData member names in slot order; a hardpoint row is one value per name, and any slot past the last
|
||||
# name belongs to a later schema and is carried through untouched.
|
||||
HARDPOINT_MEMBERS = ("Type", "Position", "Shape", "Transform", "Rotation", "Armor", "FiringArc")
|
||||
# ShipAnchor member names in slot order; as for hardpoints, slots past the last name are carried through by anchor Id.
|
||||
ANCHOR_MEMBERS = ("Id", "Role", "ModelNodeId", "ParentId", "Order")
|
||||
# Enum member names in value order (HardpointType, ItemRotation).
|
||||
# Enum member names in value order (HardpointType, ItemRotation, HullType).
|
||||
HARDPOINT_TYPE_NAMES = ("Hull", "Tool", "Thermal", "Thruster", "WarpDrive", "Reactor", "Radiator",
|
||||
"Shield", "Sensors", "Energy", "Ballistic", "Launcher", "ControlModule")
|
||||
ROTATION_NAMES = ("None", "CounterClockwise", "Reversed", "Clockwise")
|
||||
HULL_TYPE_NAMES = ("Ship", "Station", "Turret")
|
||||
|
||||
|
||||
# ShipAuthoringStore.RequireShipId's rule, mirrored so New Ship refuses before it touches anything; ShipSchemaPinTests
|
||||
|
|
@ -208,6 +218,15 @@ def read_layout(path: str, cultlib_packages: str):
|
|||
return hull[HULL_SHAPE_SLOT][0], hull[HULL_HARDPOINTS_SLOT], layout_revision(hull[HULL_SHAPE_SLOT][0], hull[HULL_HARDPOINTS_SLOT])
|
||||
|
||||
|
||||
def read_hull_type(path: str, cultlib_packages: str) -> str:
|
||||
"""The hull's HullData.HullType as its enum member name ("Ship", "Station" or "Turret")."""
|
||||
hull = _hull_body(read(path, cultlib_packages))
|
||||
value = hull[HULL_TYPE_SLOT] if len(hull) > HULL_TYPE_SLOT else None
|
||||
if type(value) is not int or not 0 <= value < len(HULL_TYPE_NAMES):
|
||||
raise ValueError("The ship file's hull has no hull type this add-on knows")
|
||||
return HULL_TYPE_NAMES[value]
|
||||
|
||||
|
||||
def layout_revision(shape: list[Any], hardpoints: list[list[Any]]) -> str:
|
||||
"""The revision of a layout: a hash of its shape ([width, height, cells]) and hardpoint rows as the file stores
|
||||
them. replace_layout returns it, Load records it, and Package compares the layout buffer's against the file's."""
|
||||
|
|
@ -224,8 +243,9 @@ def replace_layout(path: str, cultlib_packages: str, expected_id: str, expected_
|
|||
if layout_revision(hull[HULL_SHAPE_SLOT][0], hull[HULL_HARDPOINTS_SLOT]) != expected_revision:
|
||||
raise ValueError("The .cc layout changed since Load; reload before saving")
|
||||
width, height, cells = shape
|
||||
if not (1 <= width <= 32 and 1 <= height <= 32 and len(cells) == width * height):
|
||||
raise ValueError("Hull grid must be 1..32 cells wide and high with one value per cell")
|
||||
limit = hull_grid.MAX_CELLS
|
||||
if not (1 <= width <= limit and 1 <= height <= limit and len(cells) == width * height):
|
||||
raise ValueError(f"Hull grid must be 1..{limit} cells wide and high with one value per cell")
|
||||
if not all(type(cell) is bool for cell in cells):
|
||||
raise ValueError("Hull grid cells must be booleans")
|
||||
if not any(cells):
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ the hull.scad into the package and runs the panel's operators:
|
|||
changes nothing in the scene. Her art needs no product.
|
||||
Build and Package refuse copies that share an aetheria.id, selecting the original and the copies, and delete nothing.
|
||||
Package moves no row and turns no mount: every layout row is as authored after it, and a hand-turned mount keeps its yaw.
|
||||
The ship.cc HullData.HullType decides whether the language's proportions judge the hull: a body far wider than the
|
||||
language's ships is refused as Ship and built as Station, and a Station wider than the layout grid is refused.
|
||||
"""
|
||||
|
||||
import dataclasses
|
||||
|
|
@ -698,6 +700,66 @@ def agent_guards(scene):
|
|||
return collection
|
||||
|
||||
|
||||
def hull_type_gate(scene):
|
||||
"""The hull's HullData.HullType in ship.cc decides whether the language's proportions judge it; every hull type must
|
||||
fit the layout grid. A body far wider than a Zhestokost ship is refused as Ship, built as Station, and a Station
|
||||
wider than 32 cells is refused on the grid."""
|
||||
fixture, _, extent, length, rows, *_ = HULLS[0]
|
||||
ship_id = "smoke.hull.role"
|
||||
collection, box, package = make_ship(scene, fixture, ship_id, extent, length, rows)
|
||||
ship_module = sys.modules["aetheria_ships.ship_cc"]
|
||||
path = str(package / "ship.cc")
|
||||
source = package / "hull.scad"
|
||||
original = source.read_text()
|
||||
slab = '\nif (part == "primary") translate([-%d, -2, 0]) cube([%d, 4, 1]);\n'
|
||||
source.write_text(original + slab % (15, 30))
|
||||
judged = []
|
||||
real = hull_source.build
|
||||
|
||||
def recorded(*args):
|
||||
judged.append(real(*args))
|
||||
return judged[-1]
|
||||
|
||||
sys.modules["aetheria_ships"].hull_source.build = recorded
|
||||
try:
|
||||
require(ship_module.read_hull_type(path, PACKAGES) == "Ship", "New Ship did not write a Ship hull")
|
||||
select(box)
|
||||
result, message = run(bpy.ops.aetheria.build_hull_source)
|
||||
require(result == {"CANCELLED"} and "the body's proportions lie inside the language's ranges" in message,
|
||||
f"A Ship far wider than its language allows was built: {message!r}")
|
||||
print("PASS 5.1 a body wider than the language's ships is refused as Ship, on proportion")
|
||||
|
||||
store = ship_module.read(path, PACKAGES).store
|
||||
for envelope in store.pull_all():
|
||||
if envelope.type == ship_module.HULL_SCHEMA:
|
||||
values = msgpack.unpackb(envelope.payload, raw=False)
|
||||
values[ship_module.HULL_TYPE_SLOT] = 1
|
||||
store.push(dataclasses.replace(envelope, payload=msgpack.packb(values, use_bin_type=True)))
|
||||
require(ship_module.read_hull_type(path, PACKAGES) == "Station", "The pushed HullType did not read back as Station")
|
||||
select(box)
|
||||
result, message = run(bpy.ops.aetheria.build_hull_source)
|
||||
names = [c.name for c in judged[-1].checks]
|
||||
require(result == {"FINISHED"} and judged[-1].hull_type == "Station"
|
||||
and "the body's proportions lie inside the language's ranges" not in names
|
||||
and "the top view fits the layout grid" in names,
|
||||
f"The same body as Station was refused or judged on proportion: {message!r} {names}")
|
||||
print("PASS 5.2 the same body as Station is built, with no proportion check and a grid check")
|
||||
|
||||
built = set(generated(collection))
|
||||
source.write_text(original + slab % (35, 70))
|
||||
select(box)
|
||||
result, message = run(bpy.ops.aetheria.build_hull_source)
|
||||
require(result == {"CANCELLED"} and "the top view fits the layout grid" in message
|
||||
and "the body's proportions lie inside the language's ranges" not in message
|
||||
and set(generated(collection)) == built,
|
||||
f"A Station 70 m wide was not refused on the layout grid, or the refusal changed the scene: {message!r}")
|
||||
print("PASS 5.3 a Station 70 m wide is refused on the layout grid, not on proportion")
|
||||
finally:
|
||||
sys.modules["aetheria_ships"].hull_source.build = real
|
||||
print("HULL_TYPE_GATE_OK", flush=True)
|
||||
return collection
|
||||
|
||||
|
||||
def main():
|
||||
require(bpy.app.version[:2] == (5, 2), f"This smoke is for Blender 5.2; this is {bpy.app.version_string}")
|
||||
bpy.data.objects.remove(bpy.data.objects["Cube"], do_unlink=True)
|
||||
|
|
@ -733,6 +795,7 @@ def main():
|
|||
previous = one_hull(scene, number, fixture, ship_id, extent, length, rows, body, palette, maker, other)
|
||||
clear(previous)
|
||||
clear(agent_guards(scene))
|
||||
clear(hull_type_gate(scene))
|
||||
never_built(scene)
|
||||
|
||||
|
||||
|
|
@ -750,7 +813,7 @@ else:
|
|||
print("SMOKE_HULL_SOURCE_OK", flush=True)
|
||||
code = 0
|
||||
finally:
|
||||
for _, ship_id, *_ in HULLS + ((None, "smoke.hull.unbuilt"), (None, "smoke.hull.guards")):
|
||||
for _, ship_id, *_ in HULLS + ((None, "smoke.hull.unbuilt"), (None, "smoke.hull.guards"), (None, "smoke.hull.role")):
|
||||
shutil.rmtree(MODS / ship_id, ignore_errors=True)
|
||||
if created:
|
||||
shutil.rmtree(MODS, ignore_errors=True)
|
||||
|
|
|
|||
|
|
@ -136,6 +136,16 @@ class RasteriseTests(unittest.TestCase):
|
|||
hull_grid.rasterise(rect(0, 0, 66, 2))
|
||||
self.assertEqual(str(caught.exception), "The hull needs 33 cells wide; a ship grid holds at most 32")
|
||||
|
||||
def test_grid_size_is_rasterises_size_and_does_not_refuse_an_oversized_grid(self):
|
||||
self.assertEqual(hull_grid.grid_size(rect(0, 0, 2, 64)), hull_grid.rasterise(rect(0, 0, 2, 64))[:2])
|
||||
self.assertEqual(hull_grid.grid_size(rect(-3, -1, 3, 4) + CORNER), hull_grid.rasterise(rect(-3, -1, 3, 4) + CORNER)[:2])
|
||||
self.assertEqual(hull_grid.grid_size(rect(0, 0, 2, 66)), (1, 33))
|
||||
self.assertEqual(hull_grid.grid_size(rect(0, 0, 66, 2)), (33, 1))
|
||||
self.assertEqual(hull_grid.grid_size(rect(0, 0, 4, 4), cell_size=1.0), (4, 4))
|
||||
with self.assertRaises(ValueError) as caught:
|
||||
hull_grid.grid_size([((0, 0), (1, 1), (2, 2))])
|
||||
self.assertEqual(str(caught.exception), "The hull has no triangle with area to rasterise")
|
||||
|
||||
def test_a_silhouette_with_no_area_is_refused(self):
|
||||
for triangles in ([], [((0, 0), (1, 1), (2, 2))]):
|
||||
with self.assertRaises(ValueError) as caught:
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ class Fixture:
|
|||
def __init__(self):
|
||||
self.library = {"zhestokost": language()}
|
||||
self.hull_lang = None
|
||||
self.hull_type = "Ship" # HullData.HullType, which the caller reads from the package
|
||||
# Body 6 wide, 10 long, 2 high (h/w 0.33, w/l 0.6); a bilateral plate on top; one gun with a muzzle.
|
||||
self.parts = {
|
||||
"brief": ("", brief()),
|
||||
|
|
@ -77,7 +78,7 @@ class Fixture:
|
|||
return self.library[faction]
|
||||
|
||||
def build(self):
|
||||
return hs.build(self.runner(), self.read_library)
|
||||
return hs.build(self.runner(), self.read_library, self.hull_type)
|
||||
|
||||
def failed(self):
|
||||
return [c.name for c in self.build().failed()]
|
||||
|
|
@ -128,6 +129,103 @@ class ReadingTests(unittest.TestCase):
|
|||
hs.read_languages(language(body="keel"))
|
||||
|
||||
|
||||
PROPORTION = "the body's proportions lie inside the language's ranges"
|
||||
GRID = "the top view fits the layout grid"
|
||||
|
||||
|
||||
def names(built):
|
||||
return [c.name for c in built.checks]
|
||||
|
||||
|
||||
class HullTypeTests(unittest.TestCase):
|
||||
"""A language's proportions judge Ship hulls only; every other rule, and the layout grid, judges every hull type."""
|
||||
|
||||
@staticmethod
|
||||
def fixture(hull_type, **parts):
|
||||
fixture = Fixture()
|
||||
fixture.hull_type = hull_type
|
||||
fixture.parts.update({k: (v, "") for k, v in parts.items()})
|
||||
return fixture
|
||||
|
||||
def test_a_station_or_turret_is_not_held_to_the_languages_proportions_and_lists_no_proportion_check(self):
|
||||
wide = box(-12, 12, -5, 5, 0, 2) # h/w 0.08, w/l 2.4: outside both of Zhestokost's ranges
|
||||
for hull_type in ("Station", "Turret"):
|
||||
with self.subTest(hull_type):
|
||||
built = self.fixture(hull_type, primary=wide).build()
|
||||
self.assertEqual([c.name for c in built.failed()], [])
|
||||
self.assertNotIn(PROPORTION, names(built))
|
||||
self.assertEqual(built.hull_type, hull_type)
|
||||
self.assertEqual(self.fixture("Ship", primary=wide).failed(), [PROPORTION])
|
||||
|
||||
def test_a_station_with_no_body_lists_no_proportion_check_but_a_ship_fails_it(self):
|
||||
self.assertNotIn(PROPORTION, names(self.fixture("Station", primary="").build()))
|
||||
ship = self.fixture("Ship", primary="").build()
|
||||
self.assertEqual([c.detail for c in ship.checks if c.name == PROPORTION], ["no body to judge"])
|
||||
|
||||
def test_a_ship_lists_the_proportion_check_when_it_passes(self):
|
||||
built = self.fixture("Ship").build()
|
||||
self.assertEqual([c.ok for c in built.checks if c.name == PROPORTION], [True])
|
||||
|
||||
def test_a_station_is_still_judged_on_symmetry_length_and_budget(self):
|
||||
self.assertEqual(self.fixture("Station", secondary=box(2, 6, -1, 1, 2, 3)).failed(),
|
||||
["the top view obeys the language's bilateral rule"])
|
||||
self.assertEqual(self.fixture("Station", primary=box(-3, 3, -10, 10, 0, 2)).failed(),
|
||||
["the length lies inside the brief's range"])
|
||||
station = self.fixture("Station")
|
||||
station.parts["brief"] = ("", brief(triangles=(10, 20)))
|
||||
self.assertEqual(station.failed(), ["the triangle count lies inside the brief's budget"])
|
||||
|
||||
def test_every_hull_type_must_fit_the_layout_grid_at_thirty_two_cells_a_side(self):
|
||||
for hull_type in ("Ship", "Station", "Turret"):
|
||||
with self.subTest(hull_type):
|
||||
fits = self.fixture(hull_type, secondary=box(-32, 32, -1, 1, 2, 3)).build()
|
||||
self.assertEqual([(c.ok, c.detail) for c in fits.checks if c.name == GRID],
|
||||
[(True, "32 x 5 cells, at most 32 a side")])
|
||||
wide = self.fixture(hull_type, secondary=box(-33, 33, -1, 1, 2, 3))
|
||||
self.assertEqual(wide.failed(), [GRID])
|
||||
self.assertEqual([c.detail for c in wide.build().checks if c.name == GRID],
|
||||
["33 x 5 cells, at most 32 a side"])
|
||||
long = self.fixture("Station", secondary=box(-1, 1, -32, 32, 2, 3))
|
||||
long.parts["brief"] = ("", brief(length=(8, 70)))
|
||||
self.assertEqual(long.failed(), [])
|
||||
self.assertEqual([c.detail for c in long.build().checks if c.name == GRID], ["3 x 32 cells, at most 32 a side"])
|
||||
long = self.fixture("Station", secondary=box(-1, 1, -33, 33, 2, 3))
|
||||
long.parts["brief"] = ("", brief(length=(8, 70)))
|
||||
self.assertEqual(long.failed(), [GRID])
|
||||
self.assertEqual([c.detail for c in long.build().checks if c.name == GRID], ["3 x 33 cells, at most 32 a side"])
|
||||
|
||||
def test_the_grid_reads_zone_meshes_only_not_anchor_meshes(self):
|
||||
built = self.fixture("Station", **{"emit:Th.A": box(-35, 35, 5, 5.1, 0.5, 1.5)}).build()
|
||||
self.assertEqual([c.name for c in built.failed()], [])
|
||||
self.assertEqual([c.detail for c in built.checks if c.name == GRID], ["3 x 5 cells, at most 32 a side"])
|
||||
|
||||
def test_a_hull_with_no_zone_geometry_fails_the_grid_check(self):
|
||||
built = self.fixture("Station", primary="", secondary="").build()
|
||||
self.assertEqual([(c.ok, c.detail) for c in built.checks if c.name == GRID], [(False, "no geometry")])
|
||||
|
||||
def test_build_has_no_default_hull_type(self):
|
||||
fixture = Fixture()
|
||||
with self.assertRaises(TypeError):
|
||||
hs.build(fixture.runner(), fixture.read_library)
|
||||
|
||||
def test_build_refuses_a_hull_type_that_is_not_a_hulltype_name_before_reading_the_source(self):
|
||||
for hull_type in ("ship", "SHIP", "Ship ", "", None, "Stations", 0, SECRET):
|
||||
with self.subTest(hull_type=hull_type):
|
||||
fixture, read = Fixture(), []
|
||||
runner = fixture.runner()
|
||||
with self.assertRaises(hs.HullSourceError) as caught:
|
||||
hs.build(lambda part: read.append(part) or runner(part), fixture.read_library, hull_type)
|
||||
self.assertEqual(read, [])
|
||||
self.assertNotIn(SECRET, str(caught.exception))
|
||||
|
||||
def test_build_accepts_each_hulltype_name_and_only_a_ship_lists_the_proportion_check(self):
|
||||
for hull_type in ("Ship", "Station", "Turret"):
|
||||
with self.subTest(hull_type):
|
||||
built = self.fixture(hull_type).build()
|
||||
self.assertEqual([c.name for c in built.failed()], [])
|
||||
self.assertEqual(PROPORTION in names(built), hull_type == "Ship")
|
||||
|
||||
|
||||
class MirrorOverlapTests(unittest.TestCase):
|
||||
@staticmethod
|
||||
def top(text):
|
||||
|
|
@ -381,7 +479,7 @@ class LanguageOwnershipTests(unittest.TestCase):
|
|||
def test_the_library_is_asked_for_the_faction_and_the_donor_the_brief_names(self):
|
||||
asked = []
|
||||
fixture = DonorTests.pirate()
|
||||
hs.build(fixture.runner(), lambda faction: asked.append(faction) or fixture.read_library(faction))
|
||||
hs.build(fixture.runner(), lambda faction: asked.append(faction) or fixture.read_library(faction), "Ship")
|
||||
self.assertEqual(sorted(asked), ["pirate", "rnd"])
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import shutil
|
|||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
|
@ -44,8 +45,9 @@ def hardpoint_row(mount="thruster.port", tail=("future-hardpoint-slot",)):
|
|||
|
||||
|
||||
def hull_body():
|
||||
hull = [None] * (ship_cc.HULL_HARDPOINTS_SLOT + 1)
|
||||
hull = [None] * (ship_cc.HULL_TYPE_SLOT + 1)
|
||||
hull[1] = "Skiff"
|
||||
hull[ship_cc.HULL_TYPE_SLOT] = 0
|
||||
hull[ship_cc.HULL_SHAPE_SLOT] = [shape(2, 2, [True, False, True, False])]
|
||||
hull[ship_cc.HULL_HARDPOINTS_SLOT] = [hardpoint_row()]
|
||||
hull.append("future-hull-slot")
|
||||
|
|
@ -128,6 +130,28 @@ class ReadTests(ShipFileCase):
|
|||
self.assertEqual("Skiff", file.hull.body[1])
|
||||
self.assertEqual(ship_cc.HULL_SCHEMA, file.hull.envelope.type)
|
||||
|
||||
def test_reads_the_hulls_type_by_its_enum_name(self):
|
||||
for value, name in ((0, "Ship"), (1, "Station"), (2, "Turret")):
|
||||
with self.subTest(name):
|
||||
hull = hull_body()
|
||||
hull[ship_cc.HULL_TYPE_SLOT] = value
|
||||
self.write(ship_body(), hull)
|
||||
self.assertEqual(name, ship_cc.read_hull_type(self.path, PACKAGES))
|
||||
|
||||
def test_refuses_a_hull_type_it_does_not_know_without_echoing_the_path(self):
|
||||
short = hull_body()[:ship_cc.HULL_TYPE_SLOT]
|
||||
for label, value in (("past the enum", 3), ("negative", -1), ("bool", True), ("missing", None),
|
||||
("text", "Ship"), ("short body", short)):
|
||||
with self.subTest(label):
|
||||
hull = value if label == "short body" else hull_body()
|
||||
if label != "short body":
|
||||
hull[ship_cc.HULL_TYPE_SLOT] = value
|
||||
self.write(ship_body(), hull)
|
||||
with self.assertRaises(ValueError) as caught:
|
||||
ship_cc.read_hull_type(self.path, PACKAGES)
|
||||
self.assertEqual("The ship file's hull has no hull type this add-on knows", str(caught.exception))
|
||||
self.assertNotIn(self.path, str(caught.exception))
|
||||
|
||||
def test_reads_a_file_whose_hull_also_carries_products(self):
|
||||
self.add_products()
|
||||
file = ship_cc.read(self.path, PACKAGES)
|
||||
|
|
@ -520,6 +544,16 @@ class LayoutTests(ShipFileCase):
|
|||
revision = self.layout()[2]
|
||||
self.assertNotEqual(before, self.bytes())
|
||||
|
||||
def test_the_grid_limit_is_hull_grids_max_cells(self):
|
||||
_, _, revision = self.layout()
|
||||
before = self.bytes()
|
||||
with self.assertRaisesRegex(ValueError, f"1..{ship_cc.hull_grid.MAX_CELLS} cells"):
|
||||
self.edit(revision, width=33, height=1, cells=[True] * 33)
|
||||
self.assertEqual(before, self.bytes())
|
||||
with mock.patch.object(ship_cc.hull_grid, "MAX_CELLS", 33):
|
||||
self.edit(revision, width=33, height=1, cells=[True] * 33, hardpoints=[])
|
||||
self.assertEqual([33, 1, [True] * 33], self.layout()[0])
|
||||
|
||||
def test_a_layout_save_refreshes_the_stored_at_of_the_hull_and_leaves_the_ships(self):
|
||||
stale = "2000-01-01T00:00:00+00:00"
|
||||
envelopes = self.envelopes()
|
||||
|
|
|
|||
Loading…
Reference in a new issue