Merge cut ships-addon-guards (Soul verdict cut-ships-addon-guards.s2)
This commit is contained in:
commit
b53d071ef6
5 changed files with 350 additions and 23 deletions
|
|
@ -612,8 +612,14 @@ class AETHERIA_AP_preferences(bpy.types.AddonPreferences):
|
|||
description="The folder whose GameData holds Aetheria.cc and Mods (the install folder); its ModTools holds "
|
||||
"AetherDb. Empty: the nearest folder above the bound .cc whose GameData holds Aetheria.cc")
|
||||
|
||||
max_texture: bpy.props.IntProperty(
|
||||
name="Max texture", default=2048, min=64, soft_max=8192,
|
||||
description="The longest side, in pixels, a material image keeps in ship.glb; larger images are exported "
|
||||
"scaled to it and the .blend's own images are left as they are")
|
||||
|
||||
def draw(self, context):
|
||||
self.layout.prop(self, "game_folder")
|
||||
self.layout.prop(self, "max_texture")
|
||||
|
||||
|
||||
GAME_FOLDER_REFUSAL = ("Set the add-on's Game folder preference to the folder whose GameData holds Aetheria.cc "
|
||||
|
|
@ -684,6 +690,59 @@ def _anchors(collection):
|
|||
return rows
|
||||
|
||||
|
||||
DEFAULT_MAX_TEXTURE = 2048
|
||||
|
||||
|
||||
def _max_texture(context):
|
||||
addon = context.preferences.addons.get(__package__)
|
||||
return addon.preferences.max_texture if addon and addon.preferences else DEFAULT_MAX_TEXTURE
|
||||
|
||||
|
||||
def _image_nodes(tree, seen=None):
|
||||
"""Every Image Texture node of a node tree, node groups included."""
|
||||
seen = set() if seen is None else seen
|
||||
if tree is None or tree.name_full in seen:
|
||||
return
|
||||
seen.add(tree.name_full)
|
||||
for node in tree.nodes:
|
||||
if node.type == "TEX_IMAGE" and node.image is not None:
|
||||
yield node
|
||||
elif node.type == "GROUP":
|
||||
yield from _image_nodes(node.node_tree, seen)
|
||||
|
||||
|
||||
def _swap_in_scaled_images(objects, limit, swaps):
|
||||
"""Points each Image Texture node of the objects' materials that holds an image longer than limit at a scaled copy,
|
||||
and appends each (node, original, copy) to swaps for _restore_images, so a failure part way still restores. The
|
||||
originals are never modified."""
|
||||
copies = {}
|
||||
for obj in objects:
|
||||
for slot in obj.material_slots:
|
||||
for node in _image_nodes(slot.material.node_tree if slot.material else None):
|
||||
original = node.image
|
||||
longest = max(original.size)
|
||||
if longest <= limit:
|
||||
continue
|
||||
if original not in copies:
|
||||
copy = original.copy()
|
||||
try:
|
||||
copy.scale(max(1, round(original.size[0] * limit / longest)),
|
||||
max(1, round(original.size[1] * limit / longest)))
|
||||
except BaseException:
|
||||
bpy.data.images.remove(copy) # not yet in swaps, so _restore_images cannot find it
|
||||
raise
|
||||
copies[original] = copy
|
||||
node.image = copies[original]
|
||||
swaps.append((node, original, copies[original]))
|
||||
|
||||
|
||||
def _restore_images(swaps):
|
||||
for node, original, _ in swaps:
|
||||
node.image = original
|
||||
for copy in {id(copy): copy for _, _, copy in swaps}.values():
|
||||
bpy.data.images.remove(copy)
|
||||
|
||||
|
||||
def _export_glb(context, collection, filepath):
|
||||
"""Exports the collection's objects with custom properties as node extras: Generated's anchors are exported; Grease
|
||||
Pencil, Source and the Grid are not."""
|
||||
|
|
@ -694,14 +753,17 @@ def _export_glb(context, collection, filepath):
|
|||
previous_layer = view_layer.active_layer_collection
|
||||
previous_selection = [obj for obj in view_layer.objects if obj.select_get()]
|
||||
previous_active = view_layer.objects.active
|
||||
swaps = []
|
||||
try:
|
||||
view_layer.active_layer_collection = layer
|
||||
for obj in previous_selection:
|
||||
obj.select_set(False)
|
||||
derived = _derived_names(collection)
|
||||
for obj in collection.all_objects:
|
||||
if obj.type != "GREASEPENCIL" and obj.name not in derived and obj.name in view_layer.objects:
|
||||
obj.select_set(True)
|
||||
exported = [obj for obj in collection.all_objects
|
||||
if obj.type != "GREASEPENCIL" and obj.name not in derived and obj.name in view_layer.objects]
|
||||
for obj in exported:
|
||||
obj.select_set(True)
|
||||
_swap_in_scaled_images(exported, _max_texture(context), swaps)
|
||||
bpy.ops.export_scene.gltf(
|
||||
filepath=filepath, export_format="GLB", use_active_collection=True, use_selection=True,
|
||||
export_extras=True, export_yup=True, export_apply=True)
|
||||
|
|
@ -709,6 +771,7 @@ def _export_glb(context, collection, filepath):
|
|||
traceback.print_exc()
|
||||
raise RuntimeError("The GLB export failed; its message is in the system console") from None
|
||||
finally:
|
||||
_restore_images(swaps)
|
||||
for obj in view_layer.objects:
|
||||
obj.select_set(obj in previous_selection)
|
||||
view_layer.objects.active = previous_active
|
||||
|
|
|
|||
|
|
@ -9,11 +9,16 @@ from __future__ import annotations
|
|||
|
||||
import sys
|
||||
import hashlib
|
||||
import tomllib
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, NamedTuple
|
||||
|
||||
# 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"
|
||||
|
||||
SCHEMA = "aetheria.ship_authoring"
|
||||
HULL_SCHEMA = "aetheria.hulldata"
|
||||
|
||||
|
|
@ -62,12 +67,27 @@ def encode_hardpoint(**fields: Any) -> list[Any]:
|
|||
return [fields[name] for name in HARDPOINT_MEMBERS]
|
||||
|
||||
|
||||
def _cultcache_py_version(module: Any) -> str:
|
||||
"""[project].version of the cultcache-py checkout the imported module lives in (src/cultcache_py/__init__.py sits
|
||||
two levels under it). A module with no file or no readable pyproject has no version: ""."""
|
||||
try:
|
||||
with (Path(module.__file__).parents[2] / "pyproject.toml").open("rb") as handle:
|
||||
return str(tomllib.load(handle)["project"]["version"])
|
||||
except (AttributeError, TypeError, IndexError, OSError, tomllib.TOMLDecodeError, KeyError):
|
||||
return ""
|
||||
|
||||
|
||||
def _libraries(cultlib_packages: str):
|
||||
source = Path(cultlib_packages) / "cultcache-py" / "src"
|
||||
if source.exists() and str(source) not in sys.path:
|
||||
sys.path.insert(0, str(source))
|
||||
import cultcache_py # type: ignore
|
||||
import msgpack # type: ignore
|
||||
found = _cultcache_py_version(cultcache_py)
|
||||
if found != TESTED_CULTCACHE_PY:
|
||||
raise ValueError(f"the imported cultcache-py (version {found or 'unreadable'}; CultLib packages {cultlib_packages}) "
|
||||
f"is not the tested {TESTED_CULTCACHE_PY}; the add-on writes raw envelopes and refuses a "
|
||||
f"CultLib whose write path may have moved")
|
||||
return cultcache_py, msgpack
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -35,7 +35,9 @@ REPO = Path(__file__).resolve().parents[3]
|
|||
PACKAGES = os.environ.get("CULTLIB_PACKAGES") or sys.exit("Set CULTLIB_PACKAGES to CultLib's packages directory")
|
||||
SHIP_ID = "smoke.mounts"
|
||||
ODD_ID = "smoke.mounts.odd"
|
||||
ODD_LENGTH = 7
|
||||
# (ship id, length in cells, the source sphere's Y scale, wide, grid width parity) of passes 6 to 8; the id's mod directory
|
||||
# is removed at the end.
|
||||
WIDTH_PASSES = ((ODD_ID, 7, 0.6, False, 1), (ODD_ID + ".wide-even", 14, 1.0, True, 0), (ODD_ID + ".wide-odd", 15, 1.0, True, 1))
|
||||
GAME = Path(os.environ.get("SMOKE_GAME_FOLDER") or sys.exit("Set SMOKE_GAME_FOLDER to a folder holding GameData/Aetheria.cc and ModTools/AetherDb"))
|
||||
MODS = GAME / "GameData" / "Mods"
|
||||
COLLECTION = "Smoke Mounts"
|
||||
|
|
@ -412,30 +414,40 @@ def main():
|
|||
twin.parent = root
|
||||
print("PASS 3 package derives ok")
|
||||
|
||||
# Pass 6: the same mirror matrix on a ship whose grid is an odd number of cells wide.
|
||||
for obj in list(collection.all_objects): # the first ship's objects hold the names the matrix looks mounts up by
|
||||
# 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
|
||||
# looks mounts up by.
|
||||
previous = collection
|
||||
for number, (ship_id, length, y_scale, wide, parity) in enumerate(WIDTH_PASSES, start=6):
|
||||
previous = matrix_pass(scene, previous, ship_id, length, y_scale, wide, parity)
|
||||
print(f"PASS {number} mirror matrix on a {scene.aetheria_layout.width}-wide grid ok")
|
||||
|
||||
|
||||
def matrix_pass(scene, previous, ship_id, length, y_scale, wide, parity):
|
||||
for obj in list(previous.all_objects):
|
||||
bpy.data.objects.remove(obj)
|
||||
odd = bpy.data.collections.new(COLLECTION + " Odd")
|
||||
scene.collection.children.link(odd)
|
||||
odd_mesh = bpy.data.meshes.new("Odd Sphere")
|
||||
ship = bpy.data.collections.new(f"{COLLECTION} {ship_id}")
|
||||
scene.collection.children.link(ship)
|
||||
mesh = bpy.data.meshes.new(f"{ship_id} sphere")
|
||||
bm = bmesh.new()
|
||||
bmesh.ops.create_uvsphere(bm, u_segments=48, v_segments=24, radius=0.5)
|
||||
bm.to_mesh(odd_mesh)
|
||||
bm.to_mesh(mesh)
|
||||
bm.free()
|
||||
odd_sphere = new_object(odd, "Odd Source Sphere", odd_mesh)
|
||||
odd_sphere.scale = (1, 0.6, 0.4)
|
||||
select(odd_sphere)
|
||||
scene.aetheria_new_ship_id, scene.aetheria_new_ship_name = ODD_ID, "Smoke Mounts Odd"
|
||||
scene.aetheria_new_ship_like, scene.aetheria_new_ship_length = "Djinni", ODD_LENGTH
|
||||
sphere = new_object(ship, f"{ship_id} Source Sphere", mesh)
|
||||
sphere.scale = (1, y_scale, 0.4)
|
||||
select(sphere)
|
||||
scene.aetheria_new_ship_id, scene.aetheria_new_ship_name = ship_id, ship_id
|
||||
scene.aetheria_new_ship_like, scene.aetheria_new_ship_length = "Djinni", length
|
||||
result, message = run(bpy.ops.aetheria.new_ship)
|
||||
require(result == {"FINISHED"}, f"New Ship (odd) was refused: {message}")
|
||||
require(result == {"FINISHED"}, f"New Ship ({ship_id}) was refused: {message}")
|
||||
result, message = run(bpy.ops.aetheria.save_ship_layout)
|
||||
require(result == {"FINISHED"}, f"Save (odd) was refused: {message}")
|
||||
odd_root = aetheria_ships._ship_root(odd)
|
||||
odd_origin = odd["aetheria.grid_origin"]
|
||||
require(scene.aetheria_layout.width % 2 == 1, f"The odd ship's grid is {scene.aetheria_layout.width} wide")
|
||||
mirror_matrix(scene, odd_root, (odd_origin[0], odd_origin[1]), odd_sphere)
|
||||
print("PASS 6 mirror matrix on an odd grid ok")
|
||||
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)
|
||||
return ship
|
||||
|
||||
|
||||
|
||||
created_mods = not MODS.exists()
|
||||
|
|
@ -456,7 +468,8 @@ finally:
|
|||
if not existed:
|
||||
shutil.rmtree(MODS / SHIP_ID, ignore_errors=True)
|
||||
if not existed:
|
||||
shutil.rmtree(MODS / ODD_ID, ignore_errors=True)
|
||||
for pass_id, *_ in WIDTH_PASSES:
|
||||
shutil.rmtree(MODS / pass_id, ignore_errors=True)
|
||||
if created_mods and not existed:
|
||||
shutil.rmtree(MODS, ignore_errors=True)
|
||||
sys.stdout.flush()
|
||||
|
|
|
|||
|
|
@ -20,9 +20,12 @@ unsaved .blend stores the absolute path, which still resolves after Save As to t
|
|||
the .blend is saved there before the final bind, so the bind crosses drives and the package passes prove it resolves.
|
||||
"""
|
||||
|
||||
import array
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import tempfile
|
||||
import traceback
|
||||
|
|
@ -61,6 +64,145 @@ def tagged(collection, name, data, role=None, anchor_id=None, location=(0, 0, 0)
|
|||
return obj
|
||||
|
||||
|
||||
def textured(obj, name, width, height):
|
||||
"""Gives the object a material whose base colour is a generated width x height image; returns the image."""
|
||||
image = bpy.data.images.new(name, width, height)
|
||||
image.pixels[0] = 0.5 # a dirty generated image is what the exporter encodes
|
||||
obj.data.uv_layers.new(name="UVMap")
|
||||
material = bpy.data.materials.new(name)
|
||||
material.use_nodes = True
|
||||
tree = material.node_tree
|
||||
texture = tree.nodes.new("ShaderNodeTexImage")
|
||||
texture.image = image
|
||||
tree.links.new(texture.outputs["Color"], tree.nodes["Principled BSDF"].inputs["Base Color"])
|
||||
obj.data.materials.append(material)
|
||||
return image
|
||||
|
||||
|
||||
def glb_image_sizes(glb):
|
||||
"""The (width, height) of each PNG the GLB embeds, read from the PNG headers in its binary chunk."""
|
||||
data = Path(glb).read_bytes()
|
||||
json_length, _ = struct.unpack_from("<II", data, 12)
|
||||
document = json.loads(data[20:20 + json_length])
|
||||
bin_start = 20 + json_length + 8
|
||||
sizes = []
|
||||
for image in document.get("images", []):
|
||||
view = document["bufferViews"][image["bufferView"]]
|
||||
start = bin_start + view.get("byteOffset", 0)
|
||||
png = data[start:start + view["byteLength"]]
|
||||
if image.get("mimeType") != "image/png" or png[:8] != b"\x89PNG\r\n\x1a\n":
|
||||
raise SystemExit("A GLB image is not a PNG")
|
||||
sizes.append(struct.unpack_from(">II", png, 16))
|
||||
return sorted(sizes)
|
||||
|
||||
|
||||
def texture_cap_pass(addon, hull_object, albedo, detail, limit, expected, glb):
|
||||
"""Package at the Max texture preference limit; the GLB holds exactly the expected image sizes, and the .blend's own
|
||||
images are untouched and no scaled copy is left in bpy.data.images."""
|
||||
bpy.context.preferences.addons["aetheria_ships"].preferences.max_texture = limit
|
||||
names = sorted(image.name for image in bpy.data.images)
|
||||
result, report = package(addon)
|
||||
if result != {"FINISHED"}:
|
||||
raise SystemExit(f"Texture cap {limit}: Package was refused")
|
||||
if glb_image_sizes(glb) != sorted(expected):
|
||||
raise SystemExit(f"Texture cap {limit}: the GLB images are {glb_image_sizes(glb)}, not {sorted(expected)}")
|
||||
if tuple(albedo.size) != (4096, 1000) or tuple(detail.size) != (256, 256):
|
||||
raise SystemExit(f"Texture cap {limit}: Package changed a .blend image to {tuple(albedo.size)}, {tuple(detail.size)}")
|
||||
nodes = [node.image for slot in hull_object.material_slots for node in slot.material.node_tree.nodes
|
||||
if node.type == "TEX_IMAGE"]
|
||||
if nodes != [albedo, detail]:
|
||||
raise SystemExit(f"Texture cap {limit}: a material node does not hold its original image")
|
||||
if sorted(image.name for image in bpy.data.images) != names:
|
||||
raise SystemExit(f"Texture cap {limit}: a scaled copy is left in bpy.data.images")
|
||||
|
||||
|
||||
def expected_cap_size(width, height, limit):
|
||||
"""The longest side at the cap, the other side scaled by the same ratio and rounded half up; untouched at or under the
|
||||
cap. Integer arithmetic, so it shares nothing with the add-on's float rounding."""
|
||||
longest = max(width, height)
|
||||
if longest <= limit:
|
||||
return (width, height)
|
||||
return (max(1, (2 * width * limit + longest) // (2 * longest)), max(1, (2 * height * limit + longest) // (2 * longest)))
|
||||
|
||||
|
||||
def texture_boundary_cases(addon, hull_object, glb):
|
||||
"""Each case gives the hull one material whose image is width x height (generated, file-backed, packed, or inside a
|
||||
node group) and packages at cap 256: the GLB holds exactly the expected size, the image is as it was (size, pixels,
|
||||
node identity), and no copy is left. Cases sit exactly at the cap, one over, on the long side being the height, on
|
||||
sizes where rounding and flooring differ, and on a tall image whose width rounds up."""
|
||||
bpy.context.preferences.addons["aetheria_ships"].preferences.max_texture = limit = 256
|
||||
original_materials = list(hull_object.data.materials)
|
||||
folder = Path(tempfile.mkdtemp(prefix="aetheria-smoke-cap-"))
|
||||
cases = [("generated", 256, 128), ("generated", 257, 125), ("generated", 257, 128), ("generated", 256, 256),
|
||||
("generated", 255, 255), ("generated", 375, 376), ("generated", 125, 513), ("generated", 200, 513), ("generated", 1000, 4097),
|
||||
("file", 513, 400), ("file", 256, 257), ("packed", 513, 400), ("packed", 258, 70), ("group", 513, 400),
|
||||
("group", 125, 513)]
|
||||
try:
|
||||
for kind, width, height in cases:
|
||||
label = f"{kind} {width}x{height}"
|
||||
while hull_object.data.materials:
|
||||
hull_object.data.materials.pop(index=0)
|
||||
image = bpy.data.images.new("CapCase", width, height)
|
||||
image.pixels.foreach_set([((k * 7) % 251) / 251.0 for k in range(width * height * 4)])
|
||||
if kind in ("file", "packed"):
|
||||
image.filepath_raw = str(folder / f"{kind}_{width}_{height}.png")
|
||||
image.file_format = "PNG"
|
||||
image.save()
|
||||
bpy.data.images.remove(image)
|
||||
image = bpy.data.images.load(str(folder / f"{kind}_{width}_{height}.png"))
|
||||
image.name = "CapCase"
|
||||
if kind == "packed":
|
||||
image.pack()
|
||||
material = bpy.data.materials.new("CapCase")
|
||||
material.use_nodes = True
|
||||
tree = material.node_tree
|
||||
if kind == "group":
|
||||
group = bpy.data.node_groups.new("CapGroup", "ShaderNodeTree")
|
||||
group.interface.new_socket("Color", in_out="OUTPUT", socket_type="NodeSocketColor")
|
||||
texture = group.nodes.new("ShaderNodeTexImage")
|
||||
texture.image = image
|
||||
group.links.new(texture.outputs["Color"], group.nodes.new("NodeGroupOutput").inputs[0])
|
||||
holder = tree.nodes.new("ShaderNodeGroup")
|
||||
holder.node_tree = group
|
||||
tree.links.new(holder.outputs[0], tree.nodes["Principled BSDF"].inputs["Base Color"])
|
||||
else:
|
||||
texture = tree.nodes.new("ShaderNodeTexImage")
|
||||
texture.image = image
|
||||
tree.links.new(texture.outputs["Color"], tree.nodes["Principled BSDF"].inputs["Base Color"])
|
||||
hull_object.data.materials.append(material)
|
||||
pixels = bytes(array.array("f", image.pixels[:]).tobytes())
|
||||
names = sorted(i.name for i in bpy.data.images)
|
||||
result, report = package(addon)
|
||||
if result != {"FINISHED"}:
|
||||
raise SystemExit(f"Texture case {label}: Package was refused")
|
||||
if glb_image_sizes(glb) != [expected_cap_size(width, height, limit)]:
|
||||
raise SystemExit(f"Texture case {label}: the GLB image is {glb_image_sizes(glb)}, "
|
||||
f"not {expected_cap_size(width, height, limit)}")
|
||||
if (tuple(image.size) != (width, height) or bytes(array.array("f", image.pixels[:]).tobytes()) != pixels
|
||||
or texture.image != image or sorted(i.name for i in bpy.data.images) != names):
|
||||
raise SystemExit(f"Texture case {label}: the .blend's image changed or a copy is left behind")
|
||||
if kind == "file" and Path(image.filepath_raw).exists() is False:
|
||||
raise SystemExit(f"Texture case {label}: the source file is gone")
|
||||
# An exporter that fails (the GLB's path is a directory) still restores the originals and deletes the copies.
|
||||
names = sorted(i.name for i in bpy.data.images)
|
||||
glb.unlink()
|
||||
glb.mkdir()
|
||||
try:
|
||||
result, report = package(addon)
|
||||
finally:
|
||||
glb.rmdir()
|
||||
if result != {"CANCELLED"}:
|
||||
raise SystemExit("Texture failure case: the export into a directory did not fail")
|
||||
if tuple(image.size) != (width, height) or texture.image != image or sorted(i.name for i in bpy.data.images) != names:
|
||||
raise SystemExit("Texture failure case: a failed export left a scaled copy or a swapped node")
|
||||
finally:
|
||||
while hull_object.data.materials:
|
||||
hull_object.data.materials.pop(index=0)
|
||||
for material in original_materials:
|
||||
hull_object.data.materials.append(material)
|
||||
bpy.context.preferences.addons["aetheria_ships"].preferences.max_texture = 2048
|
||||
|
||||
|
||||
BIND_KEYS = ("aetheria.asset_kind", "aetheria.id", "aetheria.ship_cc")
|
||||
|
||||
|
||||
|
|
@ -196,6 +338,9 @@ def main():
|
|||
ship = bpy.data.collections.new("Smoke Gale")
|
||||
scene.collection.children.link(ship)
|
||||
hull = tagged(ship, "Hull", tetrahedron("Hull"))
|
||||
albedo = textured(hull, "Hull Albedo", 4096, 1000)
|
||||
detail = textured(hull, "Hull Detail", 256, 256)
|
||||
hull.data.polygons[0].material_index = 1 # the exporter writes only materials some face uses
|
||||
tagged(ship, "Map Icon", quad("Map Icon"), "map-icon", "map", (0, 0, -1))
|
||||
tagged(ship, "Hull Collider", tetrahedron("Hull Collider"), "hull-collider", "collider")
|
||||
tagged(ship, "Shield", None, "shield", "shield")
|
||||
|
|
@ -224,6 +369,14 @@ def main():
|
|||
result, report = package(aetheria_ships)
|
||||
if result != {"FINISHED"}:
|
||||
raise SystemExit("Pass 1: the validator refused the primitive ship")
|
||||
# The texture cap: the 4096 x 1000 map exports at 2048 x 500 (the longest side at the cap, the aspect kept), the
|
||||
# 256 x 256 one is under the cap and exports as it is, and the preference decides the cap.
|
||||
glb = Path(path).with_name("ship.glb")
|
||||
texture_cap_pass(aetheria_ships, hull, albedo, detail, 2048, [(2048, 500), (256, 256)], glb)
|
||||
texture_cap_pass(aetheria_ships, hull, albedo, detail, 512, [(512, 125), (256, 256)], glb)
|
||||
bpy.context.preferences.addons["aetheria_ships"].preferences.max_texture = 2048
|
||||
texture_boundary_cases(aetheria_ships, hull, glb)
|
||||
print("TEXTURE_CAP_OK")
|
||||
# A collection with no Ship Root is packaged exactly as before: the frame step makes and moves nothing.
|
||||
if sorted(obj.name for obj in bpy.data.objects) != objects_before or any(
|
||||
child.get("aetheria.frame") for child in ship.children):
|
||||
|
|
|
|||
|
|
@ -568,6 +568,84 @@ class RefusalsNameNoInputTests(ShipFileCase):
|
|||
self.assertNotIn(forbidden, str(caught.exception))
|
||||
|
||||
|
||||
class UntestedCultCacheTests(ShipFileCase):
|
||||
"""_libraries refuses a cultcache-py that is not the version the raw envelope writes were tested against. The version
|
||||
is read from the checkout of the module that actually imports, so each case imports a stub cultcache_py."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
saved_modules = {name: module for name, module in sys.modules.items() if name.split(".")[0] == "cultcache_py"}
|
||||
saved_path = list(sys.path)
|
||||
for name in saved_modules:
|
||||
del sys.modules[name]
|
||||
self.addCleanup(self.restore, saved_modules, saved_path)
|
||||
|
||||
@staticmethod
|
||||
def restore(saved_modules, saved_path):
|
||||
for name in [name for name in sys.modules if name.split(".")[0] == "cultcache_py"]:
|
||||
del sys.modules[name]
|
||||
sys.modules.update(saved_modules)
|
||||
sys.path[:] = saved_path
|
||||
|
||||
def packages(self, version=None, with_source=True):
|
||||
root = Path(tempfile.mkdtemp())
|
||||
self.addCleanup(shutil.rmtree, root, True)
|
||||
checkout = root / "cultcache-py"
|
||||
checkout.mkdir()
|
||||
if version is not None:
|
||||
(checkout / "pyproject.toml").write_text(
|
||||
f'[project]\nname = "cultcache-py"\nversion = "{version}"\n', encoding="utf-8")
|
||||
if with_source:
|
||||
(checkout / "src" / "cultcache_py").mkdir(parents=True)
|
||||
(checkout / "src" / "cultcache_py" / "__init__.py").write_text("", encoding="utf-8")
|
||||
return str(root)
|
||||
|
||||
def test_a_different_version_is_refused_naming_the_version_and_the_packages_path(self):
|
||||
before = self.bytes()
|
||||
# any drift, not only a different major; 0.3.0.1 and 0.3.0rc1 start with the tested version and are not it
|
||||
for version in ("9.9.9", "0.4.0", "0.3.1", "0.3", "0.3.0.1", "0.3.0rc1", "0.3.00"):
|
||||
for call in (lambda stub: ship_cc.read(self.path, stub), lambda stub: ship_cc.read_layout(self.path, stub)):
|
||||
stub = self.packages(version)
|
||||
with self.subTest(version=version), self.assertRaises(ValueError) as caught:
|
||||
call(stub)
|
||||
self.fail("accepted")
|
||||
self.assertIn(version, str(caught.exception))
|
||||
self.assertIn(stub, str(caught.exception))
|
||||
self.restore({}, list(sys.path)) # the next import is fresh
|
||||
self.assertEqual(before, self.bytes())
|
||||
|
||||
def test_a_save_through_an_untested_cultcache_writes_nothing(self):
|
||||
_, _, revision = ship_cc.read_layout(self.path, PACKAGES)
|
||||
before = self.bytes()
|
||||
self.restore({}, list(sys.path))
|
||||
with self.assertRaises(ValueError):
|
||||
ship_cc.replace_layout(self.path, self.packages("9.9.9"), "mod.skiff", revision, [2, 2, [True] * 4], [])
|
||||
self.assertEqual(before, self.bytes())
|
||||
|
||||
def test_a_missing_pyproject_is_refused(self):
|
||||
stub = self.packages()
|
||||
with self.assertRaises(ValueError) as caught:
|
||||
ship_cc.read(self.path, stub)
|
||||
self.assertIn(stub, str(caught.exception))
|
||||
|
||||
def test_the_guard_certifies_the_library_that_is_imported_not_the_path_it_was_given(self):
|
||||
# A pyproject that says 0.3.0 beside a path whose source is not what imports does not vouch for the import.
|
||||
real = self.packages("0.4.0") # imports first, and is the library in use
|
||||
with self.assertRaises(ValueError):
|
||||
ship_cc.read(self.path, real)
|
||||
liar = self.packages("0.3.0", with_source=False) # claims the tested version, supplies no source
|
||||
sys.path.insert(0, str(Path(real) / "cultcache-py" / "src"))
|
||||
with self.assertRaises(ValueError) as caught:
|
||||
ship_cc.read(self.path, liar)
|
||||
self.assertIn("0.4.0", str(caught.exception))
|
||||
|
||||
def test_the_tested_version_is_the_one_the_real_packages_directory_carries(self):
|
||||
self.restore({}, list(sys.path))
|
||||
self.assertEqual("mod.skiff", ship_cc.read(self.path, PACKAGES).ship.body[0])
|
||||
import cultcache_py
|
||||
self.assertEqual(ship_cc.TESTED_CULTCACHE_PY, ship_cc._cultcache_py_version(cultcache_py))
|
||||
|
||||
|
||||
class ShipIdTests(unittest.TestCase):
|
||||
def test_admits_the_ids_the_c_sharp_rule_admits(self):
|
||||
for ship_id in ("probe.a", "a", "0x", "a_b-c", "com10", "com0", "lpt0", "console", "aux2", "a.nul", "x.com1"):
|
||||
|
|
|
|||
Loading…
Reference in a new issue