Merge cut ships-addon-bake (Soul verdict cut-ships-addon-bake.s3)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-13 02:33:01 +02:00
commit 6177ded511
4 changed files with 1099 additions and 11 deletions

1
.gitattributes vendored
View file

@ -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

View file

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

BIN
tools/blender/tests/fixtures/tripo-hull.glb (Stored with Git LFS) vendored Normal file

Binary file not shown.

View file

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