422 lines
20 KiB
Python
422 lines
20 KiB
Python
"""Agent-built hulls: reads a hull.scad through OpenSCAD and judges it against its faction's form language.
|
|
|
|
No bpy import. A hull source is a GameData/Mods/<id>/hull.scad that includes its faction's language file (and, for a
|
|
Pirate hull, its donor's) from factions/. Everything the add-on needs is echoed by one part of that file. The faction's language is owned by its own file in
|
|
factions/: the checks read it from there, by the faction the brief names, and the hull's own `lang` echo is only compared
|
|
with it (a hull that redefines or shadows an included language fails a check):
|
|
|
|
brief ECHO brief = [faction, donor, role, [length_min, length_max], [tris_min, tris_max], reads]
|
|
lang ECHO lang = [faction, [[zone, [r, g, b]], ...], body zone, glow, radiator, proportion, symmetry, maker]
|
|
(maker: the faction's Faction record key in GameData/Aetheria.cc, the eighth element)
|
|
(every included language file echoes its own, and so does factions/<faction>.scad run alone; a Pirate
|
|
language has no body zone and no proportion)
|
|
anchors ECHO anchor = [id, role, [x, y, z], yaw, parent] one line per anchor, yaw 0 nose, 90 port
|
|
<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() 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
|
|
import re
|
|
import subprocess
|
|
import tempfile
|
|
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
|
|
SILHOUETTE_CELL = 0.25 # metres, the top-view raster the symmetry check compares
|
|
ANCHOR_ROLES = ("weapon-mount", "weapon-muzzle", "thruster-emitter", "radiator-mesh")
|
|
MESH_ROLES = {"thruster-emitter": "emit:", "radiator-mesh": "rad:"}
|
|
ECHO_PARTS = ("brief", "lang", "anchors") # parts that make no geometry; their ECHO lines are the answer
|
|
LANGUAGE_NAME = re.compile(r"[a-z][a-z0-9_]*")
|
|
ECHO_LINE = re.compile(r"^ECHO: (\w+) = (.*)$", re.MULTILINE)
|
|
|
|
# A runner answers one part of the hull source with (OFF text, ECHO text). OFF text is "" when the part made no geometry.
|
|
Runner = Callable[[str], "tuple[str, str]"]
|
|
|
|
|
|
class HullSourceError(ValueError):
|
|
"""The hull source could not be read. The message is fixed text."""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Mesh:
|
|
vertices: tuple
|
|
triangles: tuple
|
|
|
|
def bounds(self):
|
|
"""((min x, y, z), (max x, y, z))."""
|
|
columns = list(zip(*self.vertices))
|
|
return tuple(min(c) for c in columns), tuple(max(c) for c in columns)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Language:
|
|
faction: str
|
|
zones: tuple # ((name, (r, g, b)), ...)
|
|
body: str # the zone whose proportions the language judges; "" for a language with no body of its own
|
|
glow: tuple
|
|
radiator: tuple
|
|
proportion: Optional[tuple] # ((h/w min, max), (w/l min, max)); None for a language that takes its donor's
|
|
symmetry: tuple # ("bilateral", minimum mirror overlap) or ("asymmetric", maximum mirror overlap)
|
|
maker: str # the Faction record key a product of this language's hulls names as its Manufacturer
|
|
|
|
def zone_names(self):
|
|
return [name for name, _ in self.zones]
|
|
|
|
def colour(self, zone):
|
|
return dict(self.zones)[zone]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Brief:
|
|
faction: str
|
|
donor: str
|
|
role: str
|
|
length: tuple
|
|
triangles: tuple
|
|
reads: tuple
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Anchor:
|
|
id: str
|
|
role: str
|
|
position: tuple
|
|
yaw: float
|
|
parent: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Check:
|
|
name: str
|
|
ok: bool
|
|
detail: str = ""
|
|
|
|
|
|
@dataclass
|
|
class HullBuild:
|
|
brief: Brief
|
|
languages: dict # faction -> Language
|
|
zones: dict # zone key ("primary", "rnd:primary") -> Mesh
|
|
colours: dict # zone key -> (r, g, b)
|
|
anchors: list
|
|
anchor_meshes: dict # anchor id -> Mesh
|
|
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):
|
|
return [check for check in self.checks if not check.ok]
|
|
|
|
|
|
def parse_off(text):
|
|
"""(vertices, triangles) of an OFF file, faces fanned into triangles. An empty or absent text is an empty mesh."""
|
|
lines = [line.split() for line in text.splitlines() if line.strip() and not line.lstrip().startswith("#")]
|
|
if not lines or lines[0][0] != "OFF":
|
|
return Mesh((), ())
|
|
try:
|
|
header = lines[1] if len(lines[0]) == 1 else lines[0][1:]
|
|
vertex_count, face_count = int(header[0]), int(header[1])
|
|
first = 2 if len(lines[0]) == 1 else 1
|
|
vertices = tuple(tuple(float(v) for v in lines[first + i][:3]) for i in range(vertex_count))
|
|
triangles = []
|
|
for row in lines[first + vertex_count:first + vertex_count + face_count]:
|
|
ring = [int(v) for v in row[1:1 + int(row[0])]]
|
|
triangles += [(ring[0], ring[i], ring[i + 1]) for i in range(1, len(ring) - 1)]
|
|
if any(not 0 <= v < vertex_count for tri in triangles for v in tri):
|
|
raise ValueError
|
|
except (ValueError, IndexError):
|
|
raise HullSourceError("The hull source made geometry that is not a readable OFF file") from None
|
|
return Mesh(vertices, tuple(triangles))
|
|
|
|
|
|
def parse_echoes(text, name):
|
|
"""The values of every 'ECHO: <name> = <value>' line. OpenSCAD prints JSON-compatible values."""
|
|
values = []
|
|
for key, raw in ECHO_LINE.findall(text):
|
|
if key != name:
|
|
continue
|
|
try:
|
|
values.append(json.loads(raw))
|
|
except ValueError:
|
|
raise HullSourceError(f"The hull source echoed a {name} that is not plain numbers, strings and lists") from None
|
|
return values
|
|
|
|
|
|
def fingerprint(*values):
|
|
"""A digest of numbers, strings and the lists of them, floats to five decimals: equal exactly when the values are."""
|
|
digest = hashlib.sha256()
|
|
|
|
def add(value):
|
|
if isinstance(value, (list, tuple)):
|
|
digest.update(b"[")
|
|
for item in value:
|
|
add(item)
|
|
digest.update(b"]")
|
|
else:
|
|
digest.update((f"{round(value, 5) + 0.0:.5f};" if isinstance(value, float) else f"{value!r};").encode())
|
|
|
|
add(values)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def _vector(value, size, numbers=True):
|
|
if not isinstance(value, list) or len(value) != size or (numbers and not all(_number(v) for v in value)):
|
|
raise HullSourceError("The hull source echoed a vector of the wrong shape")
|
|
return tuple(value)
|
|
|
|
|
|
def _number(value):
|
|
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
|
|
|
|
def _text(value):
|
|
if not isinstance(value, str):
|
|
raise HullSourceError("The hull source echoed a name that is not a string")
|
|
return value
|
|
|
|
|
|
def read_brief(text):
|
|
(raw,) = _exactly_one(parse_echoes(text, "brief"), "brief")
|
|
if not isinstance(raw, list) or len(raw) != 6 or not isinstance(raw[5], list):
|
|
raise HullSourceError("The hull source's brief has the wrong shape")
|
|
return Brief(_text(raw[0]), _text(raw[1]), _text(raw[2]), _vector(raw[3], 2), _vector(raw[4], 2),
|
|
tuple(_text(r) for r in raw[5]))
|
|
|
|
|
|
def _proportion(raw):
|
|
if raw == []:
|
|
return None
|
|
if not isinstance(raw, list) or len(raw) != 2:
|
|
raise HullSourceError("A faction language has the wrong shape")
|
|
return _vector(raw[0], 2), _vector(raw[1], 2)
|
|
|
|
|
|
def read_languages(text):
|
|
"""Every language echoed in text, in echo order."""
|
|
languages = []
|
|
for raw in parse_echoes(text, "lang"):
|
|
if not isinstance(raw, list) or len(raw) != 8 or not isinstance(raw[1], list):
|
|
raise HullSourceError("A faction language has the wrong shape")
|
|
zones = tuple((_text(z[0]), _vector(z[1], 3)) for z in raw[1] if isinstance(z, list) and len(z) == 2)
|
|
if len(zones) != len(raw[1]):
|
|
raise HullSourceError("A faction language has the wrong shape")
|
|
proportion = _proportion(raw[5])
|
|
symmetry = _vector(raw[6], 2, numbers=False)
|
|
if symmetry[0] not in ("bilateral", "asymmetric") or not _number(symmetry[1]):
|
|
raise HullSourceError("A faction language names a symmetry rule it does not have")
|
|
maker = _text(raw[7])
|
|
if not maker:
|
|
raise HullSourceError("A faction language names no maker")
|
|
language = Language(_text(raw[0]), zones, _text(raw[2]), _vector(raw[3], 3), _vector(raw[4], 3), proportion,
|
|
symmetry, maker)
|
|
if language.body and language.body not in language.zone_names():
|
|
raise HullSourceError("A faction language's body zone is not one of its zones")
|
|
languages.append(language)
|
|
return languages
|
|
|
|
|
|
def read_anchors(text):
|
|
anchors = []
|
|
for raw in parse_echoes(text, "anchor"):
|
|
if not isinstance(raw, list) or len(raw) != 5 or not _number(raw[3]):
|
|
raise HullSourceError("An anchor has the wrong shape")
|
|
anchors.append(Anchor(_text(raw[0]), _text(raw[1]), _vector(raw[2], 3), float(raw[3]), _text(raw[4])))
|
|
return anchors
|
|
|
|
|
|
def _exactly_one(values, what):
|
|
if len(values) != 1:
|
|
raise HullSourceError(f"The hull source must echo exactly one {what}")
|
|
return values
|
|
|
|
|
|
def mirror_overlap(triangles_xy, cell=SILHOUETTE_CELL):
|
|
"""Intersection over union of the top-view silhouette with its reflection across x = 0. The silhouette is every raster
|
|
cell whose centre a triangle covers (a feature thinner than a cell may not show)."""
|
|
cells = set()
|
|
for (ax, ay), (bx, by), (cx, cy) in triangles_xy:
|
|
det = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy)
|
|
if abs(det) < 1e-12:
|
|
continue
|
|
for i in range(math.floor(min(ax, bx, cx) / cell), math.floor(max(ax, bx, cx) / cell) + 1):
|
|
px = (i + 0.5) * cell
|
|
for j in range(math.floor(min(ay, by, cy) / cell), math.floor(max(ay, by, cy) / cell) + 1):
|
|
py = (j + 0.5) * cell
|
|
u = ((by - cy) * (px - cx) + (cx - bx) * (py - cy)) / det
|
|
v = ((cy - ay) * (px - cx) + (ax - cx) * (py - cy)) / det
|
|
if u >= 0 and v >= 0 and u + v <= 1:
|
|
cells.add((i, j))
|
|
mirrored = {(-i - 1, j) for i, j in cells}
|
|
union = len(cells | mirrored)
|
|
return len(cells & mirrored) / union if union else 1.0
|
|
|
|
|
|
def _triangles_xy(mesh):
|
|
return [tuple(mesh.vertices[v][:2] for v in tri) for tri in mesh.triangles]
|
|
|
|
|
|
def _language(library, faction):
|
|
"""The language of factions/<faction>.scad, which owns it: exactly one echo, naming that faction."""
|
|
(language,) = _exactly_one(read_languages(library(faction)), "language")
|
|
if language.faction != faction:
|
|
raise HullSourceError("A faction language file echoes the language of another faction")
|
|
return language
|
|
|
|
|
|
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). 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")
|
|
included = read_languages(text)
|
|
own = _language(library, brief.faction)
|
|
donor = _language(library, brief.donor) if brief.donor else None
|
|
owned = [own] + ([donor] if donor else [])
|
|
zone_keys = list(own.zone_names()) + [f"{donor.faction}:{z}" for z in (donor.zone_names() if donor else ())]
|
|
colours = {z: own.colour(z) for z in own.zone_names()}
|
|
colours.update({f"{donor.faction}:{z}": donor.colour(z) for z in (donor.zone_names() if donor else ())})
|
|
zones = {key: parse_off(runner(key)[0]) for key in zone_keys}
|
|
_, text = runner("anchors")
|
|
anchors = read_anchors(text)
|
|
ids = [a.id for a in anchors]
|
|
if len(set(ids)) != len(ids) or any(a.role not in ANCHOR_ROLES for a in anchors):
|
|
raise HullSourceError("The hull source's anchors repeat an id or name a role a hull source does not make")
|
|
anchor_meshes = {a.id: parse_off(runner(MESH_ROLES[a.role] + a.id)[0]) for a in anchors if a.role in MESH_ROLES}
|
|
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, 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), "")]
|
|
result.checks += _checks(result, own, judge)
|
|
return result
|
|
|
|
|
|
def _checks(hull, own, judge):
|
|
brief, checks = hull.brief, []
|
|
empty = [key for key, mesh in hull.zones.items() if not mesh.triangles]
|
|
checks.append(Check("every zone of the language has geometry", not empty, f"{len(empty)} empty"))
|
|
missing = [a.id for a in hull.anchors if a.role in MESH_ROLES and not hull.anchor_meshes[a.id].triangles]
|
|
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 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"))
|
|
(hw_lo, hw_hi), (wl_lo, wl_hi) = judge.proportion
|
|
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}"))
|
|
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:
|
|
overlap = mirror_overlap([t for m in every for t in _triangles_xy(m)])
|
|
rule, bound = own.symmetry
|
|
checks.append(Check(f"the top view obeys the language's {rule} rule",
|
|
overlap >= bound if rule == "bilateral" else overlap <= bound, f"mirror overlap {overlap:.2f}"))
|
|
ys = [v[1] for m in every for v in m.vertices]
|
|
length = max(ys) - min(ys)
|
|
checks.append(Check("the length lies inside the brief's range", brief.length[0] <= length <= brief.length[1],
|
|
f"{length:.1f} m"))
|
|
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}"))
|
|
by_id = {a.id: a for a in hull.anchors}
|
|
unmuzzled = [a.id for a in hull.anchors if a.role == "weapon-mount"
|
|
and not any(m.role == "weapon-muzzle" and m.parent == a.id for m in hull.anchors)]
|
|
orphans = [m.id for m in hull.anchors if m.role == "weapon-muzzle"
|
|
and (m.parent not in by_id or by_id[m.parent].role != "weapon-mount")]
|
|
checks.append(Check("every weapon-mount has a muzzle and no muzzle is orphaned",
|
|
not (unmuzzled or orphans),
|
|
f"{len(unmuzzled)} without a muzzle, {len(orphans)} orphaned"))
|
|
return checks
|
|
|
|
|
|
def openscad_runner(executable, source, library=FACTIONS_DIR, on_start=None):
|
|
"""A Runner that runs OpenSCAD (Manifold backend, full render) on source, one process at a time. on_start(pid) is
|
|
called with each process's id. OPENSCADPATH is the language folder, so a hull source includes <zhestokost.scad>."""
|
|
env = dict(os.environ, OPENSCADPATH=str(library))
|
|
|
|
def run(part):
|
|
with tempfile.TemporaryDirectory() as folder:
|
|
target = Path(folder) / ("part.echo" if part in ECHO_PARTS else "part.off")
|
|
command = [str(executable), "--backend", "Manifold", "--render", "all", "-D", f'part="{part}"',
|
|
"-o", str(target), str(source)]
|
|
try:
|
|
process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=env)
|
|
except OSError:
|
|
raise HullSourceError("OpenSCAD could not be started") from None
|
|
if on_start:
|
|
on_start(process.pid)
|
|
try:
|
|
output, _ = process.communicate(timeout=OPENSCAD_TIMEOUT)
|
|
except subprocess.TimeoutExpired:
|
|
process.kill()
|
|
process.communicate()
|
|
raise HullSourceError("OpenSCAD did not finish a part in time") from None
|
|
if process.returncode != 0:
|
|
raise HullSourceError("OpenSCAD refused a part of the hull source")
|
|
written = target.read_text(encoding="utf-8", errors="replace") if target.exists() else ""
|
|
return ("", written + output) if part in ECHO_PARTS else (written, output)
|
|
|
|
return run
|
|
|
|
|
|
def language_library(executable, library=FACTIONS_DIR, on_start=None):
|
|
"""The `library` build() reads languages from: faction -> the ECHO text of library/<faction>.scad run alone, so a hull
|
|
source (or a file beside it) cannot stand in for the faction's own file."""
|
|
def read(faction):
|
|
file = library / f"{faction}.scad"
|
|
if not LANGUAGE_NAME.fullmatch(faction) or not file.is_file():
|
|
raise HullSourceError("The hull source names a faction that has no language file")
|
|
return openscad_runner(executable, file, library=library, on_start=on_start)("lang")[1]
|
|
|
|
return read
|