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..52c1681eb 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 @@ -617,9 +618,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 +853,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 +1096,263 @@ 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 ValueError(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 ValueError("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 ValueError("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 ValueError("Select the render meshes to bake onto") + 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 _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 _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] + try: + bpy.ops.object.bake( + use_selected_to_active=True, cage_extrusion=extrusion, max_ray_distance=max_distance, use_clear=index == 0, + **settings) + except RuntimeError: # Blender's text names objects and images + traceback.print_exc() + raise RuntimeError("The bake failed; its message is in the system console") from None + + +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 ValueError("Selected meshes already have baked images; confirm the replace") + 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) + _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() + for image in replaced.values(): + if image.users == 0: + bpy.data.images.remove(image) + for kind, image in images.items(): + image.name = f"{ship_id}.{kind}" + + +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 _EXPECTED: + 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: + _bake_from_source(context, _ship_collection(context), self.replace) + self.report({"INFO"}, "Baked the albedo and normal maps from Source") + return {"FINISHED"} + except _EXPECTED as exc: + return _refuse(self, exc) + + 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 +1549,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 +1624,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..f782da7c5 --- /dev/null +++ b/tools/blender/tests/smoke_bake.py @@ -0,0 +1,478 @@ +"""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. +""" + +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 +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 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 and z in [0.75, 0.95].""" + 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 FailingBake: + """Stands in for bpy.ops.object.bake: the n-th call raises, the others run the real bake.""" + + def __init__(self, real, fail_on): + self.real, self.fail_on, self.calls = real, fail_on, 0 + + def __call__(self, **arguments): + self.calls += 1 + if self.calls == self.fail_on: + raise RuntimeError("probe: the bake failed") + return self.real(**arguments) + + +def failing_bake_pass(aetheria_ships, label, targets, materials, sources, replace): + """A bake that raises on its second call (the normal map's first target) leaves the Source, the materials, the images, + the scene settings and the selection as they were.""" + real_bpy = aetheria_ships.bpy + patched = FailingBake(bpy.ops.object.bake, 2) + proxy = {name: getattr(bpy, name) for name in dir(bpy) if not name.startswith("__")} + proxy["ops"] = types.SimpleNamespace(object=types.SimpleNamespace(bake=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]) + + before = state() + aetheria_ships.bpy = types.SimpleNamespace(**proxy) + try: + result, message = run(bpy.ops.aetheria.bake_from_source, replace=replace) + finally: + aetheria_ships.bpy = real_bpy + require(patched.calls == 2, f"{label}: the forced failure did not land on the second bake call ({patched.calls})") + require(result == {"CANCELLED"}, f"{label}: a failing bake was not refused") + after = state() + for part, name in enumerate(("wiring", "Source materials", "scene settings and selection", "images", "material slots")): + require(before[part] == after[part], f"{label}: a failed bake left the {name} changed") + + +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) + 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") + 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") + 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_bake_pass(aetheria_ships, "Pass 1 failing", [hull], [hull_material], sources, replace=False) + 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() + + 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}") + 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]) + 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]) + 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_bake_pass(aetheria_ships, "Pass 3 failing replace", renders, materials, sources, replace=True) + 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") + + +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)