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
'<ship id>.albedo' (sRGB) and '<ship id>.normal' (Non-Color), packed, wired into every material
(a shared '<ship id>.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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-13 00:38:26 +02:00
commit 91bae0dd83
4 changed files with 761 additions and 8 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
@ -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)

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

Binary file not shown.

View file

@ -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
<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.
"""
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", 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 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)