From 91bae0dd8353427be9a1cd168efac0fbe18b9cbb Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 13 Oct 2026 00:38:26 +0200 Subject: [PATCH 1/5] Bake From Source: Cycles Selected-to-Active from Source onto her UV0, one albedo and normal pair per ship Adds aetheria.bake_from_source: bakes the Source meshes onto the selected render meshes as '.albedo' (sRGB) and '.normal' (Non-Color), packed, wired into every material (a shared '.hull' material for a mesh with none). One routine serves both maps and every target. The Source's Metallic and Transmission Weight are neutralised for the bake (the glTF importer sets Metallic 1.0, which bakes black); engine, samples, device, selection and Source are restored in a finally, and a failure removes everything made and puts every changed link back. Three bake preferences, a panel button, the tripo-hull.glb LFS fixture and smoke_bake.py (4 passes). _refuse_loose_meshes now shares the select-offenders refusal with the bake. All commits build; no C# touched. Co-Authored-By: Claude Opus 5.5 --- .gitattributes | 1 + tools/blender/aetheria_ships/__init__.py | 287 +++++++++++- tools/blender/tests/fixtures/tripo-hull.glb | 3 + tools/blender/tests/smoke_bake.py | 478 ++++++++++++++++++++ 4 files changed, 761 insertions(+), 8 deletions(-) create mode 100644 tools/blender/tests/fixtures/tripo-hull.glb create mode 100644 tools/blender/tests/smoke_bake.py 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) From 39713df0977e01d324384d68962c2cd2b00f2942 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 13 Oct 2026 00:40:20 +0200 Subject: [PATCH 2/5] smoke_bake: fixture gaps found by the hand mutants: commit-step rollback, bare-mesh rollback, linked Metallic, bake preferences The Source gets a linked Metallic and a Transmission Weight of 1, the bake is forced to fail at the wiring and replace steps and with meshes that have no material, and the cage extrusion and max ray distance preferences must move the normal map. All commits build; no C# touched. Co-Authored-By: Claude Opus 5.5 --- tools/blender/tests/smoke_bake.py | 77 +++++++++++++++++++++++-------- 1 file changed, 57 insertions(+), 20 deletions(-) diff --git a/tools/blender/tests/smoke_bake.py b/tools/blender/tests/smoke_bake.py index f782da7c5..face54528 100644 --- a/tools/blender/tests/smoke_bake.py +++ b/tools/blender/tests/smoke_bake.py @@ -223,42 +223,50 @@ def glb_image_sizes(path): return sorted(sizes) -class FailingBake: - """Stands in for bpy.ops.object.bake: the n-th call raises, the others run the real bake.""" +class FailingCall: + """Stands in for a function: the n-th call raises, the others run the real one.""" def __init__(self, real, fail_on): self.real, self.fail_on, self.calls = real, fail_on, 0 - def __call__(self, **arguments): + def __call__(self, *args, **arguments): self.calls += 1 if self.calls == self.fail_on: - raise RuntimeError("probe: the bake failed") - return self.real(**arguments) + raise RuntimeError("probe: forced failure") + return self.real(*args, **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 failing_pass(aetheria_ships, label, targets, materials, sources, replace, site, fail_on): + """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.""" + real_bpy, real_site = aetheria_ships.bpy, getattr(aetheria_ships, site, None) + if site == "bake": + patched = FailingCall(bpy.ops.object.bake, fail_on) + 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) + 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]) + [len(t.data.materials) for t in targets], sorted(m.name for m in bpy.data.materials)) before = state() - aetheria_ships.bpy = types.SimpleNamespace(**proxy) + 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 - 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") + 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") 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") + 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") def refusal(label, expected, snapshot, offenders=None): @@ -300,6 +308,12 @@ 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 @@ -322,7 +336,8 @@ def passes_one_to_three(aetheria_ships, scene): 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) + 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) @@ -342,6 +357,24 @@ def passes_one_to_three(aetheria_ships, scene): 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") + require(z_tight > z_open + 0.03 and z_short < z_open - 0.02, "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): @@ -354,6 +387,9 @@ def passes_one_to_three(aetheria_ships, scene): 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) @@ -416,7 +452,8 @@ def passes_one_to_three(aetheria_ships, scene): 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) + 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) From 07852e63134a6b0d3c6e3f8c289521c1c3988321 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 13 Oct 2026 00:45:59 +0200 Subject: [PATCH 3/5] smoke_bake: pin the link restore, the offender selection, Blender's text staying out of the report, and the preference-to-bake relation Found by hand mutants that survived: a failed bake restored a Base Color link only when a prior non-image link existed (the smoke now has one), the offenders were already the selection, the bake's RuntimeError text was not checked, and the cage/ray windows allowed a constant offset. All commits build; no C# touched. Co-Authored-By: Claude Opus 5.5 --- tools/blender/tests/smoke_bake.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tools/blender/tests/smoke_bake.py b/tools/blender/tests/smoke_bake.py index face54528..98a9113ad 100644 --- a/tools/blender/tests/smoke_bake.py +++ b/tools/blender/tests/smoke_bake.py @@ -264,6 +264,8 @@ def failing_pass(aetheria_ships, label, targets, materials, sources, replace, si 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") + if site == "bake": + require("The bake failed" in message and "probe" not in message, f"{label}: Blender's own text reached the report ({message!r})") 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") @@ -331,6 +333,8 @@ def passes_one_to_three(aetheria_ships, scene): 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] @@ -374,7 +378,9 @@ def passes_one_to_three(aetheria_ships, scene): 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") - require(z_tight > z_open + 0.03 and z_short < z_open - 0.02, "Pass 2: the cage extrusion and max ray distance do not decide the bake") + # 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): @@ -412,7 +418,7 @@ def passes_one_to_three(aetheria_ships, scene): def snapshot(): return (len(bpy.data.images), wiring(materials), scene.render.engine, scene.cycles.samples, source_state(sources)) - select([hull]) + select([hull, wing], active=hull) layer = hull.data.uv_layers[0] uv_name = layer.name hull.data.uv_layers.remove(layer) @@ -429,7 +435,7 @@ def passes_one_to_three(aetheria_ships, scene): for obj in renders: obj.select_set(False) refusal("Nothing selected", "Select the render meshes", snapshot) - select([hull]) + 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() From 4cea604b85e084248d1af8842b76c1e3c85ace55 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 13 Oct 2026 01:10:34 +0200 Subject: [PATCH 4/5] Bake From Source: the bake never rewrites what Source owns, bakes onto UV0, keeps one pair under its own names, reports Blender errors as fixed text Shared material (shared-material-source-rewritten): a target slot holding a material a Source mesh uses is given a copy before the bake; a replaced image is removed only when unused. UV0 (bake-follows-active-uv-not-uv0): every target's active UV layer is held at 0 for the bake and put back. Relief (normal-tolerance-accepts-near-flat): smoke_bake bounds the normal's median tilt at 3 degrees (copy of Source 0.32, hull 16.59). Pair names (pair-names-suffixed): a name held by an image the bake would not free refuses with fixed text. Fixed text (refusal-passes-plain-runtimeerror-text): any foreign exception inside the bake becomes BakeFailed with the console pointer. All builds; smoke_bake.py passes on Blender 5.2.2. Co-Authored-By: Claude Opus 5.5 --- tools/blender/aetheria_ships/__init__.py | 57 ++++++- tools/blender/tests/smoke_bake.py | 184 ++++++++++++++++++++++- 2 files changed, 232 insertions(+), 9 deletions(-) diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py index 52c1681eb..086463cd7 100644 --- a/tools/blender/aetheria_ships/__init__.py +++ b/tools/blender/aetheria_ships/__init__.py @@ -126,10 +126,14 @@ def _load_layout(context, collection, path): _EXPECTED = (OSError, ValueError, RuntimeError, ImportError, KeyError) +class BakeFailed(RuntimeError): + """The one error a bake raises past its rollback. Its message is fixed text; whatever Blender raised is on the console.""" + + 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, BakeFailed): return str(exc) traceback.print_exception(exc) return f"Stopped by {type(exc).__name__}; see the system console" @@ -1159,6 +1163,28 @@ 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))) @@ -1224,6 +1250,18 @@ def _link_into(material, node, input_name, source, undo): 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 already holding a name must + be one this bake replaces and frees, or the new image would take a numbered name beside it.""" + materials = _target_materials(targets) + for kind, _, _ in BAKE_MAPS: + image = bpy.data.images.get(f"{ship_id}.{kind}") + wired = [node for node in (_wired_image(material, kind) for material in materials) if node is not None and node.image == image] + if image is not None and (image.use_fake_user or image.users != len(wired)): + raise ValueError("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") + + 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 @@ -1240,7 +1278,7 @@ def _bake_map(context, sources, targets, settings, extrusion, max_distance, node **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 + raise BakeFailed("The bake failed; its message is in the system console") from None def _bake_from_source(context, collection, replace): @@ -1251,6 +1289,7 @@ def _bake_from_source(context, collection, replace): 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") + _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()] @@ -1262,6 +1301,9 @@ def _bake_from_source(context, collection, replace): _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: @@ -1308,9 +1350,12 @@ def _bake_from_source(context, collection, replace): 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: + except BaseException as exc: for step in reversed(undo): step() + if isinstance(exc, Exception) and not isinstance(exc, BakeFailed): # Blender's text names objects and images + traceback.print_exception(exc) + raise BakeFailed("The bake failed; its message is in the system console") from None raise finally: for step in reversed(restore): @@ -1320,6 +1365,7 @@ def _bake_from_source(context, collection, replace): bpy.data.images.remove(image) for kind, image in images.items(): image.name = f"{ship_id}.{kind}" + return copied class AETHERIA_OT_bake_from_source(bpy.types.Operator): @@ -1346,8 +1392,9 @@ class AETHERIA_OT_bake_from_source(bpy.types.Operator): 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") + copied = _bake_from_source(context, _ship_collection(context), self.replace) + self.report({"INFO"}, "Baked the albedo and normal maps from Source" + ( + f"; {copied} materials shared with Source were copied for the render meshes" if copied else "")) return {"FINISHED"} except _EXPECTED as exc: return _refuse(self, exc) diff --git a/tools/blender/tests/smoke_bake.py b/tools/blender/tests/smoke_bake.py index 98a9113ad..0386211fd 100644 --- a/tools/blender/tests/smoke_bake.py +++ b/tools/blender/tests/smoke_bake.py @@ -16,7 +16,10 @@ settings and selection as they were; a bake that fails on its second call leaves 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. +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 @@ -42,6 +45,7 @@ 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] @@ -78,9 +82,20 @@ 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 and z in [0.75, 0.95].""" + 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}") @@ -264,8 +279,7 @@ def failing_pass(aetheria_ships, label, targets, materials, sources, replace, si 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") - if site == "bake": - require("The bake failed" in message and "probe" not in message, f"{label}: Blender's own text reached the report ({message!r})") + require("The bake failed" in message and "probe" not in message, f"{label}: Blender's own text reached the report ({message!r})") 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") @@ -302,6 +316,7 @@ def main(): 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) @@ -507,6 +522,167 @@ def pass_four(aetheria_ships): 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") + result, message = run(bpy.ops.aetheria.bake_from_source, replace=True) + require(result == {"FINISHED"}, f"Shared material: the bake was refused: {message}") + 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) + 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]) + print("PASS 5 pair names 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: From bc88f43a19b6c6519eafe9abe26319fe198c032b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 13 Oct 2026 01:47:23 +0200 Subject: [PATCH 5/5] Bake From Source: one pair under its own names even beside orphans, linked mesh data refused, one error-text site in the operator Orphan images holding the pair's names are freed at the commit instead of forcing .001; a bake never repoints a slot Source shares through mesh data; the operator's execute is the only place a bake error becomes report text (Refusal marks the module's own fixed text, everything else goes to the console); the copied-material count is asserted. Builds (Blender add-on, no compile step). Co-Authored-By: Claude Opus 5.5 --- tools/blender/aetheria_ships/__init__.py | 83 ++++++++++++++---------- tools/blender/tests/smoke_bake.py | 74 ++++++++++++++++++--- 2 files changed, 113 insertions(+), 44 deletions(-) diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py index 086463cd7..380fdef32 100644 --- a/tools/blender/aetheria_ships/__init__.py +++ b/tools/blender/aetheria_ships/__init__.py @@ -126,14 +126,14 @@ def _load_layout(context, collection, path): _EXPECTED = (OSError, ValueError, RuntimeError, ImportError, KeyError) -class BakeFailed(RuntimeError): - """The one error a bake raises past its rollback. Its message is fixed text; whatever Blender raised is on the console.""" +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, BakeFailed): + 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" @@ -521,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") @@ -1130,18 +1130,18 @@ def _select_only(context, objects): 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) + 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 ValueError("Switch to Object mode before baking") + 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 ValueError("Bake From Source needs a Ship Root and a mesh in Source; run New Ship first") + 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: @@ -1149,7 +1149,11 @@ def _bake_inputs(context, collection): 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") + 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") @@ -1251,15 +1255,21 @@ def _link_into(material, node, input_name, source, undo): 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 already holding a name must - be one this bake replaces and frees, or the new image would take a numbered name beside it.""" + """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 is not None and (image.use_fake_user or image.users != len(wired)): - raise ValueError("The ship's baked image names are held by images this bake does not replace; select every " + 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): @@ -1272,13 +1282,19 @@ def _bake_map(context, sources, targets, settings, extrusion, max_distance, node 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 BakeFailed("The bake failed; its message is in the system console") from None + 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): @@ -1288,8 +1304,8 @@ def _bake_from_source(context, collection, replace): 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") - _refuse_taken_names(ship_id, targets) + 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()] @@ -1350,21 +1366,14 @@ def _bake_from_source(context, collection, replace): 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 as exc: + except BaseException: for step in reversed(undo): step() - if isinstance(exc, Exception) and not isinstance(exc, BakeFailed): # Blender's text names objects and images - traceback.print_exception(exc) - raise BakeFailed("The bake failed; its message is in the system console") from None 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}" + _settle_images(ship_id, images, [*replaced.values(), *holders]) return copied @@ -1382,7 +1391,7 @@ class AETHERIA_OT_bake_from_source(bpy.types.Operator): def invoke(self, context, event): try: _, _, targets = _bake_inputs(context, _ship_collection(context)) - except _EXPECTED: + 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 @@ -1394,10 +1403,14 @@ class AETHERIA_OT_bake_from_source(bpy.types.Operator): try: copied = _bake_from_source(context, _ship_collection(context), self.replace) self.report({"INFO"}, "Baked the albedo and normal maps from Source" + ( - f"; {copied} materials shared with Source were copied for the render meshes" if copied else "")) + f"; materials shared with Source, copied for the render meshes: {copied}" if copied else "")) return {"FINISHED"} - except _EXPECTED as exc: - return _refuse(self, exc) + 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): diff --git a/tools/blender/tests/smoke_bake.py b/tools/blender/tests/smoke_bake.py index 0386211fd..a51c4601b 100644 --- a/tools/blender/tests/smoke_bake.py +++ b/tools/blender/tests/smoke_bake.py @@ -241,27 +241,28 @@ def glb_image_sizes(path): class FailingCall: """Stands in for a function: the n-th call raises, the others run the real one.""" - def __init__(self, real, fail_on): - self.real, self.fail_on, self.calls = real, fail_on, 0 + 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 RuntimeError("probe: forced failure") + raise self.error("probe: forced failure") return self.real(*args, **arguments) -def failing_pass(aetheria_ships, label, targets, materials, sources, replace, site, fail_on): +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.""" + 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) + 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) + patched = FailingCall(real_site, fail_on, error) replacement = {site: patched} def state(): @@ -280,11 +281,23 @@ def failing_pass(aetheria_ships, label, targets, materials, sources, replace, si 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) @@ -596,8 +609,11 @@ def pass_five(aetheria_ships): 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") - result, message = run(bpy.ops.aetheria.bake_from_source, replace=True) - require(result == {"FINISHED"}, f"Shared material: the bake was refused: {message}") + 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") @@ -652,6 +668,8 @@ def pass_five(aetheria_ships): 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") @@ -663,8 +681,46 @@ def pass_five(aetheria_ships): 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)