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:
commit
6177ded511
4 changed files with 1099 additions and 11 deletions
1
.gitattributes
vendored
1
.gitattributes
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
BIN
tools/blender/tests/fixtures/tripo-hull.glb
(Stored with Git LFS)
vendored
Normal file
Binary file not shown.
753
tools/blender/tests/smoke_bake.py
Normal file
753
tools/blender/tests/smoke_bake.py
Normal 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)
|
||||
Loading…
Reference in a new issue