Aetheria/tools/blender/tests/smoke_bake.py
Meta Vens bc88f43a19 Bake From Source: one pair under its own names even beside orphans, linked mesh data refused, one error-text site in the operator
Orphan images holding the pair's names are freed at the commit instead of forcing .001; a bake never repoints a slot Source shares through mesh data; the operator's execute is the only place a bake error becomes report text (Refusal marks the module's own fixed text, everything else goes to the console); the copied-material count is asserted. Builds (Blender add-on, no compile step).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 01:47:23 +02:00

753 lines
40 KiB
Python

"""Headless Blender smoke for Bake From Source, end to end on a real Tripo GLB.
blender --background --factory-startup --python-use-system-env --python tools/blender/tests/smoke_bake.py
Blender 5.2 is required: the tolerances below were measured on 5.2.2 (Cycles on the CPU, 256 x 256, one sample).
Environment as smoke_package.py's header says: CULTLIB_PACKAGES and SMOKE_GAME_FOLDER (a folder holding
GameData/Aetheria.cc and the published AetherDb under ModTools, no dotnet). The ship is created under
<game folder>/GameData/Mods/smoke.bake, which this smoke removes when it ends.
The Source is tests/fixtures/tripo-hull.glb imported unchanged: the glTF importer gives its material Metallic 1.0, which
bakes black unless the add-on neutralises it for the bake. The render mesh is a 10% Decimate copy with Smart UV Project.
Pass 1 bakes the render mesh and requires the albedo to match the texture's mean (it would be black from Metallic 1.0) and
the normal to be a tangent-space map, both packed in their colour spaces and wired, with the Source material, scene
settings and selection as they were; a bake that fails on its second call leaves everything as it was. Pass 2 bakes the
render mesh under a Bevel and Weighted Normal stack, then three render meshes that share one atlas, two without a material.
Pass 3 requires every refusal to name no input, change nothing and select the offenders, and a bake over wired images to
need replace. Pass 4 requires the images to survive Save As and reopen and ride Package into ship.glb at the Max texture
cap. Pass 5 runs on the reopened file from a clean set of render meshes: a mesh sharing Source's material bakes on a copy and leaves
Source's material and Tripo image as they were, a replaced image another material uses survives, the bake lands on UV0 whichever
layer is active, the ship keeps one pair under its own names or the bake is refused, and the normal's relief bound rejects a copy
of Source as the target.
"""
import hashlib
import json
import os
import shutil
import struct
import sys
import tempfile
import traceback
import types
from pathlib import Path
import addon_utils
import bpy
import numpy
REPO = Path(__file__).resolve().parents[3]
FIXTURE = REPO / "tools" / "blender" / "tests" / "fixtures" / "tripo-hull.glb"
PACKAGES = os.environ.get("CULTLIB_PACKAGES") or sys.exit("Set CULTLIB_PACKAGES to CultLib's packages directory")
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"))
SHIP_ID = "smoke.bake"
COLLECTION = "Smoke Bake"
SIZE = [256] # the bake size the images are expected at
TEXTURE_MEAN = (0.0754, 0.0692, 0.0746) # the fixture's base colour texture, linear, measured in Blender 5.2.2
RELIEF_MIN = 3.0 # degrees of median tilt: the 10% hull measures 16.6, a 50% hull 5.2, a copy of Source as the target 0.3
NAMES_NOT_ECHOED = [SHIP_ID, COLLECTION, "Hull", "Wing", "Fin", "Tripo Source", str(GAME), GAME.name]
def require(condition, message):
if not condition:
raise SystemExit(message)
def run(operator, **arguments):
"""The operator's result and, for one that reported an error, the report (bpy.ops raises it)."""
try:
return operator(**arguments), ""
except RuntimeError as exc:
print(f"OPERATOR error: {exc}")
return {"CANCELLED"}, str(exc)
def pixels(image):
flat = numpy.empty(len(image.pixels), dtype=numpy.float32)
image.pixels.foreach_get(flat)
return flat.reshape(-1, 4)
def written(image):
"""Texels with any colour: the bake wrote them (an unwritten texel is black)."""
return pixels(image)[:, :3].max(axis=1) > 0
def digest(image):
return hashlib.sha256(pixels(image).tobytes()).hexdigest()
def bake_images():
return bpy.data.images.get(f"{SHIP_ID}.albedo"), bpy.data.images.get(f"{SHIP_ID}.normal")
def relief(albedo, normal):
"""The median angle, in degrees, between the normal map's vector and the surface normal over the texels the albedo bake
wrote: a flat or near-flat bake sits near 0 whatever its mean z is."""
vectors = pixels(normal)[written(albedo)][:, :3] * 2 - 1
vectors /= numpy.maximum(numpy.linalg.norm(vectors, axis=1, keepdims=True), 1e-6)
return float(numpy.median(numpy.degrees(numpy.arccos(numpy.clip(vectors[:, 2], -1, 1)))))
def check_maps(label, albedo, normal):
"""The tolerances measured on Blender 5.2.2: the albedo is the texture's linear mean (Image.pixels is linear) to 0.015 on at least 80% of the
texels, the tangent normal has x and y at 0.5 to 0.02, z in [0.75, 0.95] and a median tilt of at least RELIEF_MIN degrees."""
tilt = relief(albedo, normal)
print(f"{label} normal median tilt {tilt:.2f} degrees")
require(tilt >= RELIEF_MIN, f"{label}: the normal map has no relief (median tilt {tilt:.2f} degrees)")
mean = pixels(albedo)[:, :3].mean(axis=0)
covered = written(albedo).mean()
print(f"{label} albedo mean {tuple(round(float(v), 4) for v in mean)} written {covered:.3f}")
require(all(abs(float(m) - e) < 0.015 for m, e in zip(mean, TEXTURE_MEAN)), f"{label}: the albedo mean is {mean}, not {TEXTURE_MEAN}")
require(covered >= 0.8, f"{label}: the albedo wrote only {covered:.2f} of the texels")
x, y, z = pixels(normal)[:, :3].mean(axis=0)
print(f"{label} normal mean {float(x):.4f} {float(y):.4f} {float(z):.4f}")
require(abs(x - 0.5) < 0.02 and abs(y - 0.5) < 0.02 and 0.75 <= z <= 0.95, f"{label}: the normal mean is {(x, y, z)}")
def check_images(label, material_names):
"""Both images packed at the bake size in their colour spaces, wired as specified in every material."""
albedo, normal = bake_images()
require(albedo is not None and normal is not None, f"{label}: the baked images are missing")
for image, space in ((albedo, "sRGB"), (normal, "Non-Color")):
require(image.packed_file is not None, f"{label}: {image.name} is not packed")
require(tuple(image.size) == (SIZE[0], SIZE[0]), f"{label}: {image.name} is {tuple(image.size)}")
require(image.colorspace_settings.name == space, f"{label}: {image.name} is {image.colorspace_settings.name}")
for material in (bpy.data.materials[name] for name in material_names):
bsdf = next(node for node in material.node_tree.nodes if node.type == "BSDF_PRINCIPLED")
base = bsdf.inputs["Base Color"].links[0].from_node
require(base.type == "TEX_IMAGE" and base.image == albedo, f"{label}: Base Color is not the albedo")
mapper = bsdf.inputs["Normal"].links[0].from_node
require(mapper.type == "NORMAL_MAP", f"{label}: Normal does not come from a Normal Map node")
texture = mapper.inputs["Color"].links[0].from_node
require(texture.type == "TEX_IMAGE" and texture.image == normal, f"{label}: the Normal Map is not fed the normal")
return albedo, normal
def wiring(materials):
"""Every node and link of the materials, with the image each Image Texture holds."""
rows = []
for material in materials:
tree = material.node_tree
rows.append((material.name,
sorted((node.name, node.image.session_uid if node.type == "TEX_IMAGE" and node.image else 0) for node in tree.nodes),
sorted((link.from_node.name, link.from_socket.identifier, link.to_node.name, link.to_socket.identifier,
link.is_muted) for link in tree.links)))
return rows
def source_state(sources):
"""Each Source material's Principled Metallic and Transmission Weight and every link into them."""
rows = []
for obj in sources:
for slot in obj.material_slots:
for node in slot.material.node_tree.nodes:
if node.type == "BSDF_PRINCIPLED":
for name in ("Metallic", "Transmission Weight"):
socket = node.inputs[name]
rows.append((slot.material.name, name, socket.default_value,
[(link.from_node.name, link.is_muted) for link in socket.links]))
return rows
def scene_state(scene):
layer = bpy.context.view_layer
return (scene.render.engine, scene.cycles.samples, scene.cycles.device,
sorted(obj.name for obj in layer.objects if obj.select_get()), layer.objects.active.name if layer.objects.active else None)
def select(objects, active=None):
layer = bpy.context.view_layer
for obj in layer.objects:
obj.select_set(obj in objects)
layer.objects.active = active or objects[0]
def import_fixture():
"""The fixture's one mesh, alone in a new collection and parentless, at the transform the importer gave it."""
scene = bpy.context.scene
collection = bpy.data.collections.new(COLLECTION)
scene.collection.children.link(collection)
layer = bpy.context.view_layer
layer.active_layer_collection = layer.layer_collection.children[COLLECTION]
before = set(bpy.data.objects)
bpy.ops.import_scene.gltf(filepath=str(FIXTURE))
new = [obj for obj in bpy.data.objects if obj not in before]
meshes = [obj for obj in new if obj.type == "MESH"]
require(len(meshes) == 1, f"The fixture imported {len(meshes)} meshes")
mesh = meshes[0]
world = mesh.matrix_world.copy()
mesh.parent = None
mesh.matrix_world = world
for obj in new:
if obj is not mesh:
bpy.data.objects.remove(obj, do_unlink=True)
for other in list(mesh.users_collection):
if other != collection:
other.objects.unlink(mesh)
if mesh.name not in collection.objects:
collection.objects.link(mesh)
mesh.name = "Tripo Source"
return collection, mesh
def make_render(collection, root, source, name):
obj = source.copy()
obj.data = source.data.copy()
obj.name, obj.data.name = name, name
obj.data.materials.clear()
collection.objects.link(obj)
obj.parent = root
obj.matrix_world = source.matrix_world.copy()
return obj
def place_uv(obj, du, dv, su, sv):
layer = obj.data.uv_layers[0]
uv = numpy.empty(len(layer.data) * 2, dtype=numpy.float32)
layer.data.foreach_get("uv", uv)
uv = uv.reshape(-1, 2)
uv[:, 0] = du + uv[:, 0] * su
uv[:, 1] = dv + uv[:, 1] * sv
layer.data.foreach_set("uv", uv.ravel())
def with_material(obj, name):
material = bpy.data.materials.new(name)
material.use_nodes = True
obj.data.materials.append(material)
return material
def glb_document(path):
data = Path(path).read_bytes()
json_length, _ = struct.unpack_from("<II", data, 12)
return data, json.loads(data[20:20 + json_length]), 20 + json_length + 8
def glb_image_sizes(path):
data, document, bin_start = glb_document(path)
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"]]
require(png[:8] == b"\x89PNG\r\n\x1a\n", "A GLB image is not a PNG")
sizes.append(struct.unpack_from(">II", png, 16))
return sorted(sizes)
class FailingCall:
"""Stands in for a function: the n-th call raises, the others run the real one."""
def __init__(self, real, fail_on, error=RuntimeError):
self.real, self.fail_on, self.calls, self.error = real, fail_on, 0, error
def __call__(self, *args, **arguments):
self.calls += 1
if self.calls == self.fail_on:
raise self.error("probe: forced failure")
return self.real(*args, **arguments)
def failing_pass(aetheria_ships, label, targets, materials, sources, replace, site, fail_on, error=RuntimeError, rolled_back=True):
"""A run whose fail_on-th call of site raises (the Cycles bake, or one of the add-on's own commit steps) leaves the
Source, the materials, the images, the scene settings and the selection as they were, and reports fixed text whatever the
type of the error and wherever it was raised. rolled_back=False is for a failure after the commit: only the text is checked."""
real_bpy, real_site = aetheria_ships.bpy, getattr(aetheria_ships, site, None)
if site == "bake":
patched = FailingCall(bpy.ops.object.bake, fail_on, error)
proxy = {name: getattr(bpy, name) for name in dir(bpy) if not name.startswith("__")}
proxy["ops"] = types.SimpleNamespace(object=types.SimpleNamespace(bake=patched))
replacement = {"bpy": types.SimpleNamespace(**proxy)}
else:
patched = FailingCall(real_site, fail_on, error)
replacement = {site: patched}
def state():
return (wiring(materials), source_state(sources), scene_state(bpy.context.scene), sorted(i.session_uid for i in bpy.data.images),
[len(t.data.materials) for t in targets], sorted(m.name for m in bpy.data.materials))
before = state()
for name, value in replacement.items():
setattr(aetheria_ships, name, value)
try:
result, message = run(bpy.ops.aetheria.bake_from_source, replace=replace)
finally:
aetheria_ships.bpy = real_bpy
if site != "bake":
setattr(aetheria_ships, site, real_site)
require(patched.calls >= fail_on, f"{label}: the forced failure never landed ({patched.calls} calls)")
require(result == {"CANCELLED"}, f"{label}: a failing run was not refused")
require("The bake failed" in message and "probe" not in message, f"{label}: Blender's own text reached the report ({message!r})")
if not rolled_back:
return
after = state()
for part, name in enumerate(("wiring", "Source materials", "scene settings and selection", "images", "material slots", "materials")):
require(before[part] == after[part], f"{label}: a failed run left the {name} changed")
class Reports:
"""Stands in for the operator instance: the real execute runs against it and its reports are kept."""
def __init__(self, replace):
self.replace, self.sent = replace, []
def report(self, kinds, text):
self.sent.append((kinds, text))
def refusal(label, expected, snapshot, offenders=None):
before = snapshot()
result, message = run(bpy.ops.aetheria.bake_from_source)
require(result == {"CANCELLED"}, f"{label}: was not refused")
require(expected in message, f"{label}: the report is not the fixed text ({message!r})")
for name in NAMES_NOT_ECHOED:
require(name not in message, f"{label}: the report echoes an input ({name})")
require(snapshot() == before, f"{label}: a refusal changed the scene")
if offenders is not None:
chosen = sorted(obj.name for obj in bpy.context.view_layer.objects if obj.select_get())
require(chosen == sorted(obj.name for obj in offenders), f"{label}: the offenders are not the selection ({chosen})")
def main():
require(bpy.app.version[:2] == (5, 2), f"This smoke is for Blender 5.2; this is {bpy.app.version_string}")
require(FIXTURE.is_file() and FIXTURE.stat().st_size > 1_000_000, "tripo-hull.glb is missing or an LFS pointer")
bpy.data.objects.remove(bpy.data.objects["Cube"], do_unlink=True)
sys.path.insert(0, str(REPO / "tools" / "blender"))
addon_utils.enable("aetheria_ships", default_set=True, handle_error=None)
import aetheria_ships
aetheria_ships._brokkr_cultlib = lambda context: PACKAGES
preferences = bpy.context.preferences.addons["aetheria_ships"].preferences
preferences.game_folder = str(GAME)
require(preferences.bake_size == 2048 and abs(preferences.bake_cage_extrusion - 0.05) < 1e-6
and preferences.bake_max_ray_distance == 0.0, "The bake preference defaults moved")
preferences.bake_size = SIZE[0]
ship_folder = GAME / "GameData" / "Mods" / SHIP_ID
require(not ship_folder.exists(), f"{ship_folder} exists; remove it first")
try:
passes_one_to_three(aetheria_ships, bpy.context.scene)
pass_four(aetheria_ships)
pass_five(aetheria_ships)
finally:
shutil.rmtree(ship_folder, ignore_errors=True)
def passes_one_to_three(aetheria_ships, scene):
collection, source = import_fixture()
bsdf = next(node for node in source.material_slots[0].material.node_tree.nodes if node.type == "BSDF_PRINCIPLED")
require(bsdf.inputs["Metallic"].default_value == 1.0, "The importer no longer gives the fixture Metallic 1.0")
# A linked Metallic and a Transmission Weight darken the bake just as the value does; both are neutralised and restored.
source_tree = source.material_slots[0].material.node_tree
one = source_tree.nodes.new("ShaderNodeValue")
one.outputs[0].default_value = 1.0
source_tree.links.new(one.outputs[0], bsdf.inputs["Metallic"])
bsdf.inputs["Transmission Weight"].default_value = 1.0
select([source])
scene.aetheria_new_ship_id, scene.aetheria_new_ship_name = SHIP_ID, "Smoke Bake"
scene.aetheria_new_ship_like, scene.aetheria_new_ship_length = "Djinni", 10
result, message = run(bpy.ops.aetheria.new_ship)
require(result == {"FINISHED"}, f"New Ship was refused: {message}")
root = bpy.data.objects["Ship Root"]
require(source.name in aetheria_ships._child(collection, "Source").all_objects, "The fixture did not land in Source")
hull = make_render(collection, root, source, "Hull")
hull.modifiers.new("Decimate", "DECIMATE").ratio = 0.1
select([hull])
bpy.ops.object.modifier_apply(modifier="Decimate")
bpy.ops.object.mode_set(mode="EDIT")
bpy.ops.mesh.select_all(action="SELECT")
bpy.ops.uv.smart_project()
bpy.ops.object.mode_set(mode="OBJECT")
hull_material = with_material(hull, "Hull Paint")
paint = hull_material.node_tree.nodes.new("ShaderNodeRGB") # her own Base Color link, which a failed bake puts back
hull_material.node_tree.links.new(paint.outputs[0], hull_material.node_tree.nodes["Principled BSDF"].inputs["Base Color"])
scene.render.engine, scene.cycles.samples, scene.cycles.device = "BLENDER_EEVEE", 77, "GPU"
select([hull])
sources = [source]
# Pass 1: the albedo is not black, the normal is tangent space, and a failed bake changes nothing.
failing_pass(aetheria_ships, "Pass 1 failing bake", [hull], [hull_material], sources, False, "bake", 2)
failing_pass(aetheria_ships, "Pass 1 failing wiring", [hull], [hull_material], sources, False, "_link_into", 2)
require(bake_images() == (None, None), "Pass 1: a failed bake left images behind")
state_source, state_scene = source_state(sources), scene_state(scene)
result, message = run(bpy.ops.aetheria.bake_from_source)
require(result == {"FINISHED"}, f"Pass 1: Bake From Source was refused: {message}")
albedo, normal = check_images("Pass 1", [hull_material.name])
check_maps("Pass 1", albedo, normal)
require(source_state(sources) == state_source and bsdf.inputs["Metallic"].default_value == 1.0,
"Pass 1: the Source material was left changed")
require(scene_state(scene) == state_scene, "Pass 1: the scene settings or selection were left changed")
print("PASS 1 tripo bake ok")
# Pass 2: the evaluated render mesh bakes; three meshes of one atlas bake into one pair.
hull.modifiers.new("Bevel", "BEVEL").width = 0.05
hull.modifiers.new("Weighted Normal", "WEIGHTED_NORMAL")
result, message = run(bpy.ops.aetheria.bake_from_source, replace=True)
require(result == {"FINISHED"}, f"Pass 2: the modifier stack bake was refused: {message}")
albedo, normal = check_images("Pass 2 evaluated", [hull_material.name])
check_maps("Pass 2 evaluated", albedo, normal)
hull.modifiers.clear()
z_default = float(pixels(normal)[:, 2].mean())
preferences = bpy.context.preferences.addons["aetheria_ships"].preferences
preferences.bake_cage_extrusion = 0.02
result, message = run(bpy.ops.aetheria.bake_from_source, replace=True)
require(result == {"FINISHED"}, f"Pass 2: the 0.02 cage bake was refused: {message}")
z_tight = float(pixels(bake_images()[1])[:, 2].mean())
preferences.bake_cage_extrusion = 0.1
result, message = run(bpy.ops.aetheria.bake_from_source, replace=True)
require(result == {"FINISHED"}, f"Pass 2: the 0.1 cage bake was refused: {message}")
z_open = float(pixels(bake_images()[1])[:, 2].mean())
preferences.bake_max_ray_distance = 1.0
result, message = run(bpy.ops.aetheria.bake_from_source, replace=True)
require(result == {"FINISHED"}, f"Pass 2: the 1 m ray bake was refused: {message}")
z_short = float(pixels(bake_images()[1])[:, 2].mean())
preferences.bake_cage_extrusion, preferences.bake_max_ray_distance = 0.05, 0.0
print(f"PASS 2 preferences: normal z {z_tight:.4f} at cage 0.02, {z_default:.4f} at 0.05 (evaluated), {z_open:.4f} at 0.1, "
f"{z_short:.4f} at 0.1 with a 1 m ray")
# Measured on Blender 5.2.2 at 256: 0.908 at 0.02 m, 0.833 at 0.1 m, 0.547 with the 1 m ray; windows of 0.02 either side.
require(abs(z_tight - 0.908) < 0.02 and abs(z_open - 0.833) < 0.02 and z_short < z_open - 0.1,
"Pass 2: the cage extrusion and max ray distance do not decide the bake")
wing, fin = make_render(collection, root, source, "Wing"), make_render(collection, root, source, "Fin")
for obj in (wing, fin):
obj.data = hull.data.copy()
obj.data.name = obj.name
obj.data.materials.clear()
place_uv(hull, 0.0, 0.0, 0.5, 1.0)
place_uv(wing, 0.5, 0.0, 0.5, 0.5)
place_uv(fin, 0.5, 0.5, 0.5, 0.5)
select([hull])
result, message = run(bpy.ops.aetheria.bake_from_source, replace=True)
require(result == {"FINISHED"}, f"Pass 2: the left-half bake was refused: {message}")
select([hull, wing, fin], active=hull)
failing_pass(aetheria_ships, "Pass 2 failing with bare meshes", [hull, wing, fin], [hull_material], sources, True, "bake", 2)
select([hull])
alone = written(bake_images()[0]).reshape(SIZE[0], SIZE[0]) # rows are v, columns are u
select([hull, wing, fin], active=hull)
result, message = run(bpy.ops.aetheria.bake_from_source, replace=True)
require(result == {"FINISHED"}, f"Pass 2: the shared bake was refused: {message}")
shared = wing.data.materials[0]
require(shared.name == f"{SHIP_ID}.hull" and fin.data.materials[0] == shared
and len([m for m in bpy.data.materials if m.name.startswith(f"{SHIP_ID}.hull")]) == 1,
"Pass 2: the meshes without a material did not share one hull material")
albedo, normal = check_images("Pass 2 shared", [hull_material.name, shared.name])
both = written(albedo).reshape(SIZE[0], SIZE[0])
left_alone, left_both = int(alone[:, :SIZE[0] // 2].sum()), int(both[:, :SIZE[0] // 2].sum())
print(f"PASS 2 shared: left half {left_alone} alone, {left_both} with the others; total {int(alone.sum())} to {int(both.sum())}")
require(both.sum() >= 1.3 * alone.sum(), "Pass 2: the second and third meshes did not add texels")
require(left_both >= 0.98 * left_alone, "Pass 2: a later mesh cleared the first mesh's texels")
print("PASS 2 evaluated and shared ok")
# Pass 3: refusals name no input, change nothing and select the offenders; wired images need replace.
renders, materials = [hull, wing, fin], [hull_material, shared]
def snapshot():
return (len(bpy.data.images), wiring(materials), scene.render.engine, scene.cycles.samples, source_state(sources))
select([hull, wing], active=hull)
layer = hull.data.uv_layers[0]
uv_name = layer.name
hull.data.uv_layers.remove(layer)
refusal("No UV layer", "have no UV layer", snapshot, offenders=[hull])
hull.data.uv_layers.new(name=uv_name)
select([hull])
bpy.ops.object.mode_set(mode="EDIT")
refusal("Edit mode", "Object mode", snapshot)
bpy.ops.object.mode_set(mode="OBJECT")
source.hide_set(True)
refusal("Hidden Source", "Source meshes are hidden", snapshot)
source.hide_set(False)
select([hull])
for obj in renders:
obj.select_set(False)
refusal("Nothing selected", "Select the render meshes", snapshot)
select([hull, wing], active=hull)
tree = hull_material.node_tree
principled = next(node for node in tree.nodes if node.type == "BSDF_PRINCIPLED")
position = principled.location.copy()
tree.nodes.remove(principled) # a material without a Principled BSDF
refusal("No Principled BSDF", "without a Principled BSDF", lambda: (len(bpy.data.images), len(tree.nodes), scene.render.engine),
offenders=[hull])
principled = tree.nodes.new("ShaderNodeBsdfPrincipled")
principled.location = position
tree.links.new(next(n for n in tree.nodes if n.type == "TEX_IMAGE" and n.image == bake_images()[0]).outputs["Color"],
principled.inputs["Base Color"])
tree.links.new(next(n for n in tree.nodes if n.type == "NORMAL_MAP").outputs["Normal"], principled.inputs["Normal"])
tree.links.new(principled.outputs["BSDF"], next(n for n in tree.nodes if n.type == "OUTPUT_MATERIAL").inputs["Surface"])
select(renders, active=hull)
old_albedo, old_normal = bake_images()
old_digest = (digest(old_albedo), digest(old_normal))
old_uids = (old_albedo.session_uid, old_normal.session_uid)
before = wiring(materials)
result, message = run(bpy.ops.aetheria.bake_from_source)
require(result == {"CANCELLED"} and "confirm the replace" in message, f"Pass 3: a bake over wired images was not refused: {message}")
require(wiring(materials) == before and (digest(old_albedo), digest(old_normal)) == old_digest and bake_images() == (old_albedo, old_normal),
"Pass 3: a refused replace changed the images or the wiring")
failing_pass(aetheria_ships, "Pass 3 failing replace bake", renders, materials, sources, True, "bake", 2)
failing_pass(aetheria_ships, "Pass 3 failing replace wiring", renders, materials, sources, True, "_remove_node", 1)
require(bake_images() == (old_albedo, old_normal) and (digest(old_albedo), digest(old_normal)) == old_digest,
"Pass 3: a failed replace changed the old images")
result, message = run(bpy.ops.aetheria.bake_from_source, replace=True)
require(result == {"FINISHED"}, f"Pass 3: replace was refused: {message}")
new_albedo, new_normal = check_images("Pass 3 replaced", [m.name for m in materials])
require((new_albedo.session_uid, new_normal.session_uid) != old_uids, "Pass 3: replace kept the old datablocks")
require(not any(image.session_uid in old_uids for image in bpy.data.images), "Pass 3: the replaced images are still in bpy.data")
require(len([i for i in bpy.data.images if i.name.startswith(SHIP_ID)]) == 2, "Pass 3: the ship has other than two baked images")
print("PASS 3 refusals, replace and rollback ok")
def pass_four(aetheria_ships):
preferences = bpy.context.preferences.addons["aetheria_ships"].preferences
SIZE[0] = preferences.bake_size = 512
preferences.max_texture = 256
result, message = run(bpy.ops.aetheria.bake_from_source, replace=True)
require(result == {"FINISHED"}, f"Pass 4: the 512 bake was refused: {message}")
materials = ["Hull Paint", f"{SHIP_ID}.hull"]
check_images("Pass 4", materials)
mesh = bpy.data.meshes.new("Hull Collider") # the validator wants one hull-collider anchor
mesh.from_pydata([(0, 0, 0), (1, 0, 0), (0, 1, 0), (0, 0, 1)], [], [(0, 2, 1), (0, 1, 3), (0, 3, 2), (1, 2, 3)])
collider = bpy.data.objects.new("Hull Collider", mesh)
collider["aetheria.role"], collider["aetheria.id"] = "hull-collider", "collider"
bpy.data.collections[COLLECTION].objects.link(collider)
collider.parent = bpy.data.objects["Ship Root"]
result, message = run(bpy.ops.aetheria.save_ship_layout)
require(result == {"FINISHED"}, f"Pass 4: Save Layout was refused: {message}")
blend = Path(tempfile.mkdtemp(prefix="aetheria-smoke-bake-")) / "ship.blend"
bpy.ops.wm.save_as_mainfile(filepath=str(blend))
bpy.ops.wm.open_mainfile(filepath=str(blend))
scene = bpy.context.scene
check_images("Pass 4 reopened", materials)
result, message = run(bpy.ops.aetheria.package_ship)
report = scene.aetheria_package_report
require(result == {"FINISHED"}, f"Pass 4: Package was refused: {report}")
glb = GAME / "GameData" / "Mods" / SHIP_ID / "ship.glb"
require(glb_image_sizes(glb) == [(256, 256), (256, 256)], f"Pass 4: the GLB images are {glb_image_sizes(glb)}")
_, document, _ = glb_document(glb)
textured = [m for m in document["materials"] if "baseColorTexture" in m.get("pbrMetallicRoughness", {}) and "normalTexture" in m]
require(len(textured) == 2, f"Pass 4: {len(textured)} GLB materials hold both textures")
node_names = {node.get("name") for node in document["nodes"]}
require("Tripo Source" not in node_names and "Grid" not in node_names, "Pass 4: the GLB holds Source or the Grid")
for name in (f"{SHIP_ID}.albedo", f"{SHIP_ID}.normal"):
require(tuple(bpy.data.images[name].size) == (512, 512), "Pass 4: Package changed the .blend's own image")
print("PASS 4 save, reopen and package ok")
def pass_five(aetheria_ships):
"""On the reopened file, from a clean set of render meshes: a render mesh sharing Source's material is baked on a copy
and Source keeps its material, wiring and Tripo image; a replaced image another material uses survives; the bake lands
on UV0 whichever layer is active; one pair per ship holds under its own names or the bake is refused; the relief bound
rejects a copy of Source as the target."""
preferences = bpy.context.preferences.addons["aetheria_ships"].preferences
SIZE[0] = preferences.bake_size = 256
collection, root, source = bpy.data.collections[COLLECTION], bpy.data.objects["Ship Root"], bpy.data.objects["Tripo Source"]
sources = [source]
source_material = source.material_slots[0].material
tripo = next(n for n in source_material.node_tree.nodes if n.type == "BSDF_PRINCIPLED").inputs["Base Color"].links[0].from_node.image
def owned():
return (wiring([source_material]), digest(tripo), tripo.name, source_state(sources), source_material.name)
kept = owned()
def source_kept(label):
require(bpy.data.materials.get(kept[4]) == source_material and bpy.data.images.get(kept[2]) == tripo,
f"{label}: Source's material or Tripo image is gone")
require(owned() == kept, f"{label}: a bake changed what Source owns")
def purge():
for obj in [o for o in bpy.data.objects if o.type == "MESH" and o not in sources and o.name != "Hull Collider"]:
bpy.data.objects.remove(obj, do_unlink=True)
for material in [m for m in bpy.data.materials if m != source_material]:
bpy.data.materials.remove(material)
for image in [i for i in bpy.data.images if i.name.startswith(SHIP_ID) or i.name in ("InUse", "Free")]:
image.use_fake_user = False
bpy.data.images.remove(image)
def new_render(name, ratio=0.1):
obj = make_render(collection, root, source, name)
if ratio < 1:
obj.modifiers.new("Decimate", "DECIMATE").ratio = ratio
select([obj])
bpy.ops.object.modifier_apply(modifier="Decimate")
select([obj])
bpy.ops.object.mode_set(mode="EDIT")
bpy.ops.mesh.select_all(action="SELECT")
bpy.ops.uv.smart_project()
bpy.ops.object.mode_set(mode="OBJECT")
return obj
def uv_hashes(obj):
rows = []
for layer in obj.data.uv_layers:
uv = numpy.empty(len(layer.data) * 2, dtype=numpy.float32)
layer.data.foreach_get("uv", uv)
rows.append((layer.name, hashlib.sha256(uv.tobytes()).hexdigest()))
return rows
pair_names = sorted([f"{SHIP_ID}.albedo", f"{SHIP_ID}.normal"])
def one_pair(label):
require(sorted(i.name for i in bpy.data.images if i.name.startswith(SHIP_ID)) == pair_names,
f"{label}: the ship does not hold exactly one pair under its own names")
def image_state():
return (len(bpy.data.images), sorted(i.name for i in bpy.data.images))
# A render mesh that shares Source's material is baked onto a material of its own.
purge()
wing, fin = new_render("Wing"), new_render("Fin")
for obj in (wing, fin):
obj.data.materials.append(source_material)
select([wing, fin])
result, message = run(bpy.ops.aetheria.bake_from_source)
require(result == {"CANCELLED"} and "confirm the replace" in message,
f"Shared material: a bake over wired images was not refused: {message}")
failing_pass(aetheria_ships, "Shared material failing wiring", [wing, fin], [source_material], sources, True, "_link_into", 1)
require(wing.data.materials[0] == source_material and fin.data.materials[0] == source_material,
"Shared material: a failed run left a copy on the mesh")
source_kept("Shared material, failed run")
operator = Reports(replace=True)
result = aetheria_ships.AETHERIA_OT_bake_from_source.execute(operator, bpy.context)
require(result == {"FINISHED"}, f"Shared material: the bake was refused: {operator.sent}")
require(len(operator.sent) == 1 and operator.sent[0][0] == {"INFO"} and operator.sent[0][1].endswith("copied for the render meshes: 1"),
f"Shared material: the report does not name the one material copied ({operator.sent})")
own = wing.data.materials[0]
require(own != source_material and fin.data.materials[0] == own, "Shared material: the meshes did not take one material of their own")
source_kept("Shared material")
check_images("Shared material", [own.name])
one_pair("Shared material")
print("PASS 5 shared material ok")
# A replaced image another material still uses survives.
purge()
target = new_render("Wing")
material = with_material(target, "Own")
shared_image = bpy.data.images.new("InUse", 8, 8)
elsewhere = bpy.data.materials.new("Elsewhere")
elsewhere.use_nodes = True
elsewhere.use_fake_user = True
for owner in (material, elsewhere):
node = owner.node_tree.nodes.new("ShaderNodeTexImage")
node.image = shared_image
if owner is material:
owner.node_tree.links.new(node.outputs["Color"], owner.node_tree.nodes["Principled BSDF"].inputs["Base Color"])
select([target])
result, message = run(bpy.ops.aetheria.bake_from_source, replace=True)
require(result == {"FINISHED"}, f"Image in use: the bake was refused: {message}")
require(bpy.data.images.get("InUse") == shared_image and any(n.type == "TEX_IMAGE" and n.image == shared_image for n in elsewhere.node_tree.nodes),
"Image in use: Replace removed an image another material uses")
print("PASS 5 image in use ok")
# The bake lands on UV0 whichever layer is active.
purge()
target = new_render("Wing")
layers = target.data.uv_layers
layers.new(name="Scratch")
squeezed = numpy.empty(len(layers[0].data) * 2, dtype=numpy.float32)
layers[0].data.foreach_get("uv", squeezed)
layers[1].data.foreach_set("uv", (squeezed * 0.5).tolist())
layers.active_index = 1
before = uv_hashes(target)
select([target])
result, message = run(bpy.ops.aetheria.bake_from_source)
require(result == {"FINISHED"}, f"UV0: the bake was refused: {message}")
covered = float(written(bake_images()[0]).mean())
print(f"PASS 5 UV0 coverage {covered:.3f} with the second layer active")
require(covered >= 0.9, f"UV0: the bake covers {covered:.3f} of UV0's layout")
require(layers.active_index == 1, "UV0: the active layer was not put back")
require(uv_hashes(target) == before, "UV0: a bake changed the UV layers")
# One pair per ship under its own names: a name held by a free image or by a pair this bake would leave behind refuses.
purge()
first, second = new_render("Wing"), new_render("Fin")
free = bpy.data.images.new(f"{SHIP_ID}.albedo", 4, 4)
free.use_fake_user = True
select([first])
refusal("Free image holds the name", "held by images this bake does not replace", image_state)
bpy.data.images.remove(free)
for kind in ("albedo", "normal"): # images nothing uses, no fake user: the bake takes the names
require(bpy.data.images.new(f"{SHIP_ID}.{kind}", 4, 4).users == 0, "Names: an orphan has users")
result, message = run(bpy.ops.aetheria.bake_from_source)
require(result == {"FINISHED"}, f"Names: the first bake was refused: {message}")
one_pair("Names, first bake")
select([second])
refusal("Pair still wired elsewhere", "held by images this bake does not replace", image_state)
select([first, second])
for _ in range(2):
result, message = run(bpy.ops.aetheria.bake_from_source, replace=True)
require(result == {"FINISHED"}, f"Names: the shared bake was refused: {message}")
one_pair("Names, shared bake")
check_images("Names", [first.data.materials[0].name, second.data.materials[0].name])
gone = {first.data.materials[0].name, second.data.materials[0].name} # she deletes the meshes and their materials
for obj in (first, second):
bpy.data.objects.remove(obj, do_unlink=True)
for name in gone:
bpy.data.materials.remove(bpy.data.materials[name])
require(all(bpy.data.images[name].users == 0 for name in pair_names), "Names: the old pair is still used")
again = new_render("Wing")
select([again])
result, message = run(bpy.ops.aetheria.bake_from_source)
require(result == {"FINISHED"}, f"Names: the re-bake was refused: {message}")
one_pair("Names, re-bake")
check_images("Names, re-bake", [again.data.materials[0].name])
print("PASS 5 pair names ok")
# A target that shares Source's mesh data is refused: the slot belongs to the data, so Source's own would be repointed.
purge()
linked = new_render("Wing")
linked.data = source.data
select([linked])
refusal("Linked mesh data", "share their mesh data with a Source mesh", lambda: (owned(), image_state()), [linked])
source_kept("Linked mesh data")
print("PASS 5 linked mesh data ok")
# Whatever Blender raises, wherever in the operator, the report is fixed text and the run is undone (a failure after the
# commit has nothing to undo).
purge()
target = new_render("Wing")
material = with_material(target, "Own")
select([target])
for site, error, rolled_back in (("_refuse_taken_names", RuntimeError, True), ("_bake_inputs", RuntimeError, True),
("bake", ValueError, True), ("_settle_images", RuntimeError, False)):
failing_pass(aetheria_ships, f"Text at {site}", [target], [material], sources, False, site, 1, error, rolled_back)
purge_pair = [i for i in bpy.data.images if i.name.startswith(SHIP_ID)]
for image in purge_pair:
bpy.data.images.remove(image)
for wired in [n for n in material.node_tree.nodes if n.type in ("TEX_IMAGE", "NORMAL_MAP")]:
material.node_tree.nodes.remove(wired)
select([target])
print("PASS 5 error text ok")
# The relief bound rejects a copy of Source as the target and accepts a real hull.
purge()
flat = new_render("Fin", ratio=1.0)
select([flat])
result, message = run(bpy.ops.aetheria.bake_from_source)
require(result == {"FINISHED"}, f"Relief: the copy of Source was refused: {message}")
flat_tilt = relief(*bake_images())
purge()
real = new_render("Wing")
select([real])
result, message = run(bpy.ops.aetheria.bake_from_source)
require(result == {"FINISHED"}, f"Relief: the hull was refused: {message}")
real_tilt = relief(*bake_images())
print(f"PASS 5 relief: median tilt {flat_tilt:.2f} degrees for a copy of Source, {real_tilt:.2f} for the hull, bound {RELIEF_MIN}")
require(flat_tilt < RELIEF_MIN <= real_tilt, "Relief: the bound does not separate a copy of Source from a real hull")
check_maps("Relief hull", *bake_images())
try:
main()
except SystemExit as stop:
print(f"SMOKE_BAKE_FAILED: {stop}", flush=True)
os._exit(1)
except Exception:
traceback.print_exc()
print("SMOKE_BAKE_FAILED", flush=True)
os._exit(1)
print("SMOKE_BAKE_OK")
sys.stdout.flush()
os._exit(0)