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:
parent
aa851cfc72
commit
91bae0dd83
4 changed files with 761 additions and 8 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
|
||||
|
|
@ -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
BIN
tools/blender/tests/fixtures/tripo-hull.glb
(Stored with Git LFS)
vendored
Normal file
Binary file not shown.
478
tools/blender/tests/smoke_bake.py
Normal file
478
tools/blender/tests/smoke_bake.py
Normal 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)
|
||||
Loading…
Reference in a new issue