diff --git a/.gitattributes b/.gitattributes index 08d566094..d530ed421 100644 --- a/.gitattributes +++ b/.gitattributes @@ -56,6 +56,7 @@ GameData/*.cc filter=lfs diff=lfs merge=lfs -text GameData/Mods/**/*.cc filter=lfs diff=lfs merge=lfs -text GameData/Mods/**/*.glb filter=lfs diff=lfs merge=lfs -text +tools/blender/tests/fixtures/*.glb filter=lfs diff=lfs merge=lfs -text *.a filter=lfs diff=lfs merge=lfs -text *AkSoundEngine filter=lfs diff=lfs merge=lfs -text *AkWaapiClient filter=lfs diff=lfs merge=lfs -text diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py index 7df050681..380fdef32 100644 --- a/tools/blender/aetheria_ships/__init__.py +++ b/tools/blender/aetheria_ships/__init__.py @@ -15,6 +15,7 @@ import shutil import subprocess import sys import traceback +from functools import partial from pathlib import Path import bpy @@ -125,10 +126,14 @@ def _load_layout(context, collection, path): _EXPECTED = (OSError, ValueError, RuntimeError, ImportError, KeyError) +class Refusal(ValueError): + """A refusal Bake From Source raises itself: its message is fixed text that names no input. Blender's ValueError is not one.""" + + def _failure_text(exc): """What an operator reports for exc. This module's own ValueError and RuntimeError messages are fixed text that names no input; any other error is reported by its type, and its text goes to the system console.""" - if type(exc) in (ValueError, RuntimeError): + if type(exc) in (ValueError, RuntimeError, Refusal): return str(exc) traceback.print_exception(exc) return f"Stopped by {type(exc).__name__}; see the system console" @@ -516,13 +521,13 @@ def _ship_collection(context, unbound_ok=False): active object's only collection when no ship collection holds it.""" obj = context.active_object if obj is None: - raise ValueError("Select an object in the ship collection") + raise Refusal("Select an object in the ship collection") ships = [collection for collection in bpy.data.collections if _is_ship(collection) and obj.name in collection.all_objects] if len(ships) == 1: return ships[0] if unbound_ok and not ships and len(obj.users_collection) == 1: return obj.users_collection[0] - raise ValueError("Select an object in exactly one ship collection" if unbound_ok + raise Refusal("Select an object in exactly one ship collection" if unbound_ok else "Selected object must belong to exactly one bound ship collection") @@ -617,9 +622,26 @@ class AETHERIA_AP_preferences(bpy.types.AddonPreferences): 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") + bake_size: bpy.props.IntProperty( + name="Bake size", default=2048, min=64, soft_max=8192, + description="The side, in pixels, of the square albedo and normal images Bake From Source makes") + + bake_cage_extrusion: bpy.props.FloatProperty( + name="Bake cage extrusion", default=0.05, min=0.0, unit="LENGTH", + description="How far, in metres, Bake From Source pushes each render mesh surface out along its normals before " + "casting rays at Source. Too small misses Source where it rises above the render mesh; too large " + "catches parts across a gap") + + bake_max_ray_distance: bpy.props.FloatProperty( + name="Bake max ray distance", default=0.0, min=0.0, unit="LENGTH", + description="The longest ray, in metres, Bake From Source casts from the extruded surface. 0 means no limit") + def draw(self, context): self.layout.prop(self, "game_folder") self.layout.prop(self, "max_texture") + self.layout.prop(self, "bake_size") + self.layout.prop(self, "bake_cage_extrusion") + self.layout.prop(self, "bake_max_ray_distance") GAME_FOLDER_REFUSAL = ("Set the add-on's Game folder preference to the folder whose GameData holds Aetheria.cc " @@ -835,14 +857,8 @@ def _loose_meshes(collection): def _refuse_loose_meshes(context, collection): """Refuses while meshes sit outside Ship Root, selecting them so the author can parent them.""" loose = _loose_meshes(collection) - if not loose: - return - for obj in context.view_layer.objects: - obj.select_set(False) - for obj in loose: - if obj.name in context.view_layer.objects: - obj.select_set(True) - raise ValueError(f"{len(loose)} meshes in the ship collection are not under Ship Root; parent them to it") + if loose: + _refuse_selecting(context, loose, f"{len(loose)} meshes in the ship collection are not under Ship Root; parent them to it") def _frame_inputs(context, collection, state): @@ -1084,6 +1100,319 @@ class AETHERIA_OT_flip_nose(bpy.types.Operator): return _refuse(self, exc) +BAKE_SAMPLES = 1 # one sample per texel: the maps are a starting point and the smoke runs on the CPU +BAKE_MAPS = (("albedo", "sRGB", {"type": "DIFFUSE", "pass_filter": {"COLOR"}}), + ("normal", "Non-Color", {"type": "NORMAL", "normal_space": "TANGENT"})) + + +def _principled(material): + tree = material.node_tree if material else None + return next((node for node in tree.nodes if node.type == "BSDF_PRINCIPLED"), None) if tree else None + + +def _wired_image(material, kind): + """The Image Texture node already feeding the material's Principled Base Color (albedo) or, through a Normal Map + node, its Normal.""" + socket = _principled(material).inputs["Base Color" if kind == "albedo" else "Normal"] + node = socket.links[0].from_node if socket.is_linked else None + if kind == "normal" and node is not None and node.type == "NORMAL_MAP": + colour = node.inputs["Color"] + node = colour.links[0].from_node if colour.is_linked else None + return node if node is not None and node.type == "TEX_IMAGE" else None + + +def _select_only(context, objects): + keep = {obj.name for obj in objects} + for obj in context.view_layer.objects: + obj.select_set(obj.name in keep) + + +def _refuse_selecting(context, offenders, message): + """Selects the offenders and refuses with message, which names a count and never an object.""" + _select_only(context, offenders) + raise Refusal(message) + + +def _bake_inputs(context, collection): + """The ship ID, the Source meshes and the selected render meshes Bake From Source works on, or the refusal that + stops it before anything changes.""" + if context.mode != "OBJECT": + raise Refusal("Switch to Object mode before baking") + source = _child(collection, SOURCE) + sources = _meshes(source.all_objects) if source else [] + if _ship_root(collection) is None or not sources: + raise Refusal("Bake From Source needs a Ship Root and a mesh in Source; run New Ship first") + layer = context.view_layer + hidden = [obj for obj in sources if obj.name not in layer.objects or obj.hide_get()] + if hidden: + _refuse_selecting(context, [obj for obj in hidden if obj.name in layer.objects], + f"{len(hidden)} Source meshes are hidden or outside the view layer; show them to bake") + targets = [obj for obj in _own_meshes(collection) if obj.name in layer.objects and obj.select_get()] + if not targets: + raise Refusal("Select the render meshes to bake onto") + shared = {obj.data for obj in sources} + linked = [obj for obj in targets if obj.data in shared] + if linked: # a material slot belongs to the mesh data, so the bake would repoint Source's own slot + _refuse_selecting(context, linked, f"{len(linked)} selected meshes share their mesh data with a Source mesh; make them single user first") + flat = [obj for obj in targets if not obj.data.uv_layers] + if flat: + _refuse_selecting(context, flat, f"{len(flat)} selected meshes have no UV layer; unwrap them first") + bare = [obj for obj in targets if any(_principled(slot.material) is None for slot in obj.material_slots)] + if bare: + _refuse_selecting(context, bare, f"{len(bare)} selected meshes have a material without a Principled BSDF") + return collection["aetheria.id"], sources, targets + + +def _target_materials(targets): + return list({slot.material.name: slot.material for obj in targets for slot in obj.material_slots}.values()) + + +def _give_own_materials(sources, targets, undo): + """Gives each target slot that holds a material a Source mesh also uses a copy of its own, so the bake rewires nothing + Source owns; returns how many materials were copied. undo gets the steps that put the slots back.""" + owned = {slot.material.name for obj in sources for slot in obj.material_slots if slot.material} + copies = {} + for obj in targets: + for index, slot in enumerate(obj.material_slots): + original = slot.material + if original is None or original.name not in owned: + continue + if original.name not in copies: + copies[original.name] = original.copy() + undo.append(partial(_remove_material, copies[original.name].name)) + undo.append(partial(_set_slot_material, obj.name, index, original.name)) + slot.material = copies[original.name] + return len(copies) + + +def _set_slot_material(object_name, index, material_name): + bpy.data.objects[object_name].material_slots[index].material = bpy.data.materials[material_name] + + +def _hold(owner, name, value, restore): + """Sets owner.name to value for the bake; restore gets the step that puts the old value back.""" + restore.append(partial(setattr, owner, name, getattr(owner, name))) + setattr(owner, name, value) + + +def _isolate_sources(sources, restore): + """The Source materials as the bake should see them: Metallic and Transmission Weight at 0, links into them muted.""" + seen = set() + for obj in sources: + for slot in obj.material_slots: + for node in (slot.material.node_tree.nodes if slot.material and slot.material.node_tree else ()): + if node.type != "BSDF_PRINCIPLED" or (slot.material.name, node.name) in seen: + continue + seen.add((slot.material.name, node.name)) + for name in ("Metallic", "Transmission Weight"): + socket = node.inputs[name] + for link in socket.links: + _hold(link, "is_muted", True, restore) + _hold(socket, "default_value", 0.0, restore) + + +def _remove_image(name): + image = bpy.data.images.get(name) + if image: + bpy.data.images.remove(image) + + +def _remove_node(material_name, node_name): + material = bpy.data.materials.get(material_name) + node = material.node_tree.nodes.get(node_name) if material and material.node_tree else None + if node: + material.node_tree.nodes.remove(node) + + +def _remove_material(name): + material = bpy.data.materials.get(name) + if material: + bpy.data.materials.remove(material) + + +def _clear_materials(mesh_name): + bpy.data.meshes[mesh_name].materials.clear() + + +def _restore_link(material_name, node_name, input_name, previous): + tree = bpy.data.materials[material_name].node_tree + socket = tree.nodes[node_name].inputs[input_name] + for link in list(socket.links): + tree.links.remove(link) + if previous: + tree.links.new(next(out for out in tree.nodes[previous[0]].outputs if out.identifier == previous[1]), socket) + + +def _set_node_image(material_name, node_name, image_name): + bpy.data.materials[material_name].node_tree.nodes[node_name].image = bpy.data.images.get(image_name) + + +def _link_into(material, node, input_name, source, undo): + socket = node.inputs[input_name] + previous = (socket.links[0].from_node.name, socket.links[0].from_socket.identifier) if socket.is_linked else None + material.node_tree.links.new(source, socket) + undo.append(partial(_restore_link, material.name, node.name, input_name, previous)) + + +def _refuse_taken_names(ship_id, targets): + """The ship's pair is {ship_id}.albedo and {ship_id}.normal and there is one pair. An image holding a name must be one + this bake replaces (used only by the selected meshes' wiring) or one nothing uses; returns those images, which the bake + frees before it names its own. Any other holder refuses.""" + materials = _target_materials(targets) + holders = [] + for kind, _, _ in BAKE_MAPS: + image = bpy.data.images.get(f"{ship_id}.{kind}") + if image is None: + continue + wired = [node for node in (_wired_image(material, kind) for material in materials) if node is not None and node.image == image] + if image.use_fake_user or image.users != len(wired): + raise Refusal("The ship's baked image names are held by images this bake does not replace; select every " + "mesh that uses them, or rename the other images") + holders.append(image) + return holders + + +def _bake_map(context, sources, targets, settings, extrusion, max_distance, nodes): + """Bakes one map from the Source meshes onto every target, the first target clearing the image and the later ones + adding to it. nodes maps a material's name to the Image Texture node that holds this map's image. This is the add-on's + only bake.""" + layer = context.view_layer + for index, target in enumerate(targets): + _select_only(context, sources + [target]) + layer.objects.active = target + for slot in target.material_slots: + slot.material.node_tree.nodes.active = nodes[slot.material.name] + bpy.ops.object.bake( + use_selected_to_active=True, cage_extrusion=extrusion, max_ray_distance=max_distance, use_clear=index == 0, + **settings) + + +def _settle_images(ship_id, images, stale): + """The commit's last step: frees the images the bake replaced and the unused ones that held the pair's names, then + names the new images {ship_id}.albedo and {ship_id}.normal.""" + for image in {image.name: image for image in stale}.values(): # named before any is removed + if image.users == 0: + bpy.data.images.remove(image) + for kind, image in images.items(): + image.name = f"{ship_id}.{kind}" + + +def _bake_from_source(context, collection, replace): + """Bakes the Source meshes onto the selected render meshes' UV0 as one albedo and one normal image per ship, and wires + them into every material of those meshes. The images are new and are wired only after both bakes finish; any failure + puts every changed value back and removes what was made. The Source materials, scene settings and selection are as they + were afterwards.""" + ship_id, sources, targets = _bake_inputs(context, collection) + if not replace and any(_wired_image(material, kind) for material in _target_materials(targets) for kind, _, _ in BAKE_MAPS): + raise Refusal("Selected meshes already have baked images; confirm the replace") + holders = _refuse_taken_names(ship_id, targets) + addon = context.preferences.addons[__package__].preferences + scene, layer = context.scene, context.view_layer + previous_selection = [obj for obj in layer.objects if obj.select_get()] + previous_active = layer.objects.active + restore, undo, replaced = [], [], {} + restore.append(lambda: ([obj.select_set(obj in previous_selection) for obj in layer.objects], + setattr(layer.objects, "active", previous_active))) + try: + _hold(scene.render, "engine", "CYCLES", restore) + _hold(scene.cycles, "samples", BAKE_SAMPLES, restore) + _hold(scene.cycles, "device", "CPU", restore) + copied = _give_own_materials(sources, targets, undo) + for obj in targets: # her unwrap is UV0, whichever layer is active + _hold(obj.data.uv_layers, "active_index", 0, restore) + _isolate_sources(sources, restore) + images = {} + for kind, space, _ in BAKE_MAPS: + image = bpy.data.images.new(f"{ship_id}.{kind}", addon.bake_size, addon.bake_size, alpha=False) + undo.append(partial(_remove_image, image.name)) + image.colorspace_settings.name = space + images[kind] = image + hull = None + for obj in targets: + if not obj.data.materials: + if hull is None: + hull = bpy.data.materials.new(f"{ship_id}.hull") + hull.use_nodes = True + undo.append(partial(_remove_material, hull.name)) + obj.data.materials.append(hull) + undo.append(partial(_clear_materials, obj.data.name)) + materials = _target_materials(targets) + held = {kind: {} for kind, _, _ in BAKE_MAPS} + for material in materials: + for kind, _, _ in BAKE_MAPS: + node = material.node_tree.nodes.new("ShaderNodeTexImage") + node.image, node.label = images[kind], f"Baked {kind}" + undo.append(partial(_remove_node, material.name, node.name)) + held[kind][material.name] = node + for kind, _, settings in BAKE_MAPS: + _bake_map(context, sources, targets, settings, addon.bake_cage_extrusion, addon.bake_max_ray_distance, held[kind]) + for image in images.values(): + image.pack() + for material in materials: # the commit: every step is in undo + bsdf = _principled(material) + for kind, _, _ in BAKE_MAPS: + node = held[kind][material.name] + old = _wired_image(material, kind) + if old is not None: + if old.image: + replaced[old.image.name] = old.image + undo.append(partial(_set_node_image, material.name, old.name, old.image.name if old.image else None)) + old.image = images[kind] + _remove_node(material.name, node.name) + elif kind == "albedo": + _link_into(material, bsdf, "Base Color", node.outputs["Color"], undo) + else: + normal = material.node_tree.nodes.new("ShaderNodeNormalMap") + undo.append(partial(_remove_node, material.name, normal.name)) + material.node_tree.links.new(node.outputs["Color"], normal.inputs["Color"]) + _link_into(material, bsdf, "Normal", normal.outputs["Normal"], undo) + except BaseException: + for step in reversed(undo): + step() + raise + finally: + for step in reversed(restore): + step() + _settle_images(ship_id, images, [*replaced.values(), *holders]) + return copied + + +class AETHERIA_OT_bake_from_source(bpy.types.Operator): + bl_idname = "aetheria.bake_from_source" + bl_label = "Bake From Source" + bl_description = ("Bake the Source meshes' colour and normal onto the selected render meshes' UV0 as the ship's albedo " + "and normal maps, and wire them into their materials. Nothing rebakes unless you press this") + bl_options = {"REGISTER", "UNDO"} + + replace: bpy.props.BoolProperty( + name="Replace", default=False, options={"SKIP_SAVE"}, + description="Replace the images the selected meshes' materials already wire into Base Color and Normal") + + def invoke(self, context, event): + try: + _, _, targets = _bake_inputs(context, _ship_collection(context)) + except Exception: + return self.execute(context) # the same refusal, reported + if any(_wired_image(material, kind) for material in _target_materials(targets) for kind, _, _ in BAKE_MAPS): + self.replace = True + return context.window_manager.invoke_confirm( + self, event, message="The selected meshes already have baked images. Replace them?") + return self.execute(context) + + def execute(self, context): + try: + copied = _bake_from_source(context, _ship_collection(context), self.replace) + self.report({"INFO"}, "Baked the albedo and normal maps from Source" + ( + f"; materials shared with Source, copied for the render meshes: {copied}" if copied else "")) + return {"FINISHED"} + except Exception as exc: # the one place a bake's error becomes report text + if type(exc) is Refusal: + return _refuse(self, exc) + traceback.print_exception(exc) # whatever else Blender raised names objects and images + self.report({"ERROR"}, "The bake failed; its message is in the system console") + return {"CANCELLED"} + + def _new_ship(context, ship_id, name, reference, length): """Sets the active collection up as a pending ship: binds it, moves its meshes into Source under a Ship Root, turns them nose to -Y, sizes them to length cells, fills the layout buffer and draws the Grid. The meshes keep their data: @@ -1280,6 +1609,7 @@ class AETHERIA_PT_ship(bpy.types.Panel): row.operator("aetheria.load_ship_layout", icon="IMPORT") row.operator("aetheria.flip_nose", icon="LOOP_BACK") row.operator("aetheria.rasterise_hull", icon="MESH_GRID") + layout.operator("aetheria.bake_from_source", icon="RENDER_RESULT") if not state.ship_id: self._draw_package(context, layout) return @@ -1354,6 +1684,7 @@ classes = (AETHERIA_PG_cell, AETHERIA_PG_hardpoint, AETHERIA_PG_layout, AETHERIA_OT_add_weapon, AETHERIA_OT_add_muzzle, AETHERIA_OT_mirror_mount, AETHERIA_OT_remove_mount, AETHERIA_OT_save_layout, AETHERIA_OT_bind_ship_collection, AETHERIA_OT_capture_ship_lines, AETHERIA_OT_new_ship, AETHERIA_OT_flip_nose, + AETHERIA_OT_bake_from_source, AETHERIA_OT_rasterise_hull, AETHERIA_OT_package_ship, AETHERIA_AP_preferences, AETHERIA_PT_ship) diff --git a/tools/blender/tests/fixtures/tripo-hull.glb b/tools/blender/tests/fixtures/tripo-hull.glb new file mode 100644 index 000000000..d71225c4b --- /dev/null +++ b/tools/blender/tests/fixtures/tripo-hull.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e2cade52149b0227ea770829542b3c4b81343a24a7218f217cfe5f73da52014f +size 1796820 diff --git a/tools/blender/tests/smoke_bake.py b/tools/blender/tests/smoke_bake.py new file mode 100644 index 000000000..a51c4601b --- /dev/null +++ b/tools/blender/tests/smoke_bake.py @@ -0,0 +1,753 @@ +"""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 +/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", 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)