# SPDX-FileCopyrightText: 2025 Blender Authors # # SPDX-License-Identifier: Apache-2.0 """ Compare textual dump of imported data against reference versions and generate a HTML report showing the differences, for regression testing. """ import bpy import bpy_extras.node_shader_utils import difflib import json import os import pathlib from . import global_report from io import StringIO from mathutils import Matrix from typing import Callable def fmtf(f: float) -> str: # ensure tiny numbers are 0.0, # and not "-0.0" for example if abs(f) < 0.0005: return "0.000" return f"{f:.3f}" def fmtrot(f: float) -> str: str = fmtf(f) # rotation by -PI is the same as by +PI, due to platform # precision differences we might get one or another. Make # sure to emit consistent value. if str == "-3.142": str = "3.142" return str def is_approx_identity(mat: Matrix, tol=0.001): identity = Matrix.Identity(4) return all(abs(mat[i][j] - identity[i][j]) <= tol for i in range(4) for j in range(4)) class Report: __slots__ = ( 'title', 'output_dir', 'global_dir', 'input_dir', 'reference_dir', 'tested_count', 'failed_list', 'passed_list', 'updated_list', 'failed_html', 'passed_html', 'update_templates', ) def __init__( self, title: str, output_dir: pathlib.Path, input_dir: pathlib.Path, reference_dir: pathlib.Path, ): self.title = title self.output_dir = output_dir self.global_dir = os.path.dirname(output_dir) self.input_dir = input_dir self.reference_dir = reference_dir self.tested_count = 0 self.failed_list = [] self.passed_list = [] self.updated_list = [] self.failed_html = "" self.passed_html = "" os.makedirs(output_dir, exist_ok=True) self.update_templates = os.getenv('BLENDER_TEST_UPDATE', "0").strip() == "1" if self.update_templates: os.makedirs(self.reference_dir, exist_ok=True) # write out dummy html in case test crashes if not self.update_templates: filename = "report.html" filepath = os.path.join(self.output_dir, filename) pathlib.Path(filepath).write_text( 'Report not generated yet. Crashed during tests?') @staticmethod def _navigation_item(title, href, active): if active: return """""" % title else: return """""" % (href, title) def _navigation_html(self): html = """""" return html def finish(self, test_suite_name: str) -> None: """ Finishes the report: short summary to the console, generates full report as HTML. """ print(f"\n============") if self.update_templates: print( f"{self.tested_count} input files tested, " f"{len(self.updated_list)} references updated to new results" ) for test in self.updated_list: print(f"UPDATED {test}") else: self._write_html(test_suite_name) print(f"{self.tested_count} input files tested, {len(self.passed_list)} passed") if len(self.failed_list): print(f"FAILED {len(self.failed_list)} tests:") for test in self.failed_list: print(f"FAILED {test}") def _write_html(self, test_suite_name: str): tests_html = self.failed_html + self.passed_html menu = self._navigation_html() failed = len(self.failed_html) > 0 if failed: message = """""" message += f"Tested files: {self.tested_count}, failed: {len(self.failed_list)}" else: message = f"Tested files: {self.tested_count}" title = self.title + " Test Report" columns_html = "NameNewReferenceDiff" html = f""" {title}

{title}

{menu} {message} {columns_html} {tests_html}

""" filename = "report.html" filepath = os.path.join(self.output_dir, filename) pathlib.Path(filepath).write_text(html) print(f"Report saved to: {pathlib.Path(filepath).as_uri()}") # Update global report global_failed = failed global_report.add(self.global_dir, "IO", self.title, filepath, global_failed) def _relative_url(self, filepath): relpath = os.path.relpath(filepath, self.output_dir) return pathlib.Path(relpath).as_posix() @staticmethod def _colored_diff(a: str, b: str): a_lines = a.splitlines() b_lines = b.splitlines() diff = difflib.unified_diff(a_lines, b_lines, lineterm='') html = [] for line in diff: if line.startswith('+++') or line.startswith('---'): pass elif line.startswith('@@'): html.append(f'{line}') elif line.startswith('-'): html.append(f'{line}') elif line.startswith('+'): html.append(f'{line}') else: html.append(line) return '\n'.join(html) def _add_test_result(self, testname: str, got_desc: str, ref_desc: str): error = got_desc != ref_desc status = "FAILED" if error else "" table_style = """ class="table-danger" """ if error else "" cell_class = "text_cell text_cell_larger" if error else "text_cell" diff_text = " " if error: diff_text = Report._colored_diff(ref_desc, got_desc) test_html = f""" {testname}
{status}
{got_desc}
{ref_desc}
{diff_text}
""" if error: self.failed_html += test_html else: self.passed_html += test_html @staticmethod def _val_to_str(val) -> str: if isinstance(val, bpy.types.BoolAttributeValue): return f"{1 if val.value else 0}" if isinstance(val, (bpy.types.IntAttributeValue, bpy.types.ByteIntAttributeValue)): return f"{val.value}" if isinstance(val, bpy.types.FloatAttributeValue): return f"{fmtf(val.value)}" if isinstance(val, bpy.types.FloatVectorAttributeValue): return f"({fmtf(val.vector[0])}, {fmtf(val.vector[1])}, {fmtf(val.vector[2])})" if isinstance(val, bpy.types.Float2AttributeValue): return f"({fmtf(val.vector[0])}, {fmtf(val.vector[1])})" if isinstance(val, bpy.types.FloatColorAttributeValue) or isinstance(val, bpy.types.ByteColorAttributeValue): return f"({val.color[0]:.3f}, {val.color[1]:.3f}, {val.color[2]:.3f}, {val.color[3]:.3f})" if isinstance(val, bpy.types.Int2AttributeValue) or isinstance(val, bpy.types.Short2AttributeValue): return f"({val.value[0]}, {val.value[1]})" if isinstance(val, bpy.types.ID): return f"'{val.name}'" if isinstance(val, bpy.types.MeshLoop): return f"{val.vertex_index}" if isinstance(val, bpy.types.MeshEdge): return f"{min(val.vertices[0], val.vertices[1])}/{max(val.vertices[0], val.vertices[1])}" if isinstance(val, bpy.types.MaterialSlot): return f"('{val.name}', {val.link})" if isinstance(val, bpy.types.VertexGroup): return f"'{val.name}'" if isinstance(val, bpy.types.Keyframe): res = f"({fmtf(val.co[0])}, {fmtf(val.co[1])})" res += f" lh:({fmtf(val.handle_left[0])}, {fmtf(val.handle_left[1])} {val.handle_left_type})" res += f" rh:({fmtf(val.handle_right[0])}, {fmtf(val.handle_right[1])} {val.handle_right_type})" if val.interpolation != 'LINEAR': res += f" int:{val.interpolation}" if val.easing != 'AUTO': res += f" ease:{val.easing}" return res if isinstance(val, bpy.types.SplinePoint): return f"({fmtf(val.co[0])}, {fmtf(val.co[1])}, {fmtf(val.co[2])}) w:{fmtf(val.weight)}" return str(val) # single-line dump of head/tail @staticmethod def _write_collection_single(col, desc: StringIO) -> None: desc.write(f" - ") side_to_print = 5 if len(col) <= side_to_print * 2: for val in col: desc.write(f"{Report._val_to_str(val)} ") else: for val in col[:side_to_print]: desc.write(f"{Report._val_to_str(val)} ") desc.write(f"... ") for val in col[-side_to_print:]: desc.write(f"{Report._val_to_str(val)} ") desc.write(f"\n") # multi-line dump of head/tail @staticmethod def _write_collection_multi(col, desc: StringIO) -> None: side_to_print = 3 if len(col) <= side_to_print * 2: for val in col: desc.write(f" - {Report._val_to_str(val)}\n") else: for val in col[:side_to_print]: desc.write(f" - {Report._val_to_str(val)}\n") desc.write(f" ...\n") for val in col[-side_to_print:]: desc.write(f" - {Report._val_to_str(val)}\n") @staticmethod def _write_attr(attr: bpy.types.Attribute, desc: StringIO) -> None: if len(attr.data) == 0: return desc.write(f" - attr '{attr.name}' {attr.data_type} {attr.domain}\n") if isinstance( attr, (bpy.types.BoolAttribute, bpy.types.IntAttribute, bpy.types.ByteIntAttribute, bpy.types.FloatAttribute)): Report._write_collection_single(attr.data, desc) else: Report._write_collection_multi(attr.data, desc) @staticmethod def _write_custom_props(bid, desc: StringIO, prefix='') -> None: items = bid.items() if not items: return rna_properties = {prop.identifier for prop in bid.bl_rna.properties if prop.is_runtime} had_any = False for k, v in sorted(items, key=lambda it: it[0]): if k in rna_properties: continue if not had_any: desc.write(f"{prefix} - props:") had_any = True if isinstance(v, str): if k != "cycles": desc.write(f" str:{k}='{v}'") else: desc.write(f" str:{k}=") elif isinstance(v, int): desc.write(f" int:{k}={v}") elif isinstance(v, float): desc.write(f" fl:{k}={fmtf(v)}") elif len(v) == 2: desc.write(f" f2:{k}=({fmtf(v[0])}, {fmtf(v[1])})") elif len(v) == 3: desc.write(f" f3:{k}=({fmtf(v[0])}, {fmtf(v[1])}, {fmtf(v[2])})") elif len(v) == 4: desc.write(f" f4:{k}=({fmtf(v[0])}, {fmtf(v[1])}, {fmtf(v[2])}, {fmtf(v[3])})") else: desc.write(f" o:{k}={str(v)}") if had_any: desc.write(f"\n") def _node_shader_image_desc(self, tex: bpy_extras.node_shader_utils.ShaderImageTextureWrapper) -> str: if not tex or not tex.image: return "" # Get relative path of the image tex_path = pathlib.Path(tex.image.filepath) if tex.image.filepath.startswith('//'): # already relative rel_path = tex.image.filepath.replace('\\', '/') elif tex_path.root == '': rel_path = tex_path.as_posix() # use just the filename else: try: # note: we can't use Path.relative_to since walk_up parameter is only since Python 3.12 rel_path = pathlib.Path(os.path.relpath(tex_path, self.input_dir)).as_posix() except ValueError: rel_path = f"" if rel_path.startswith('../../..'): # if relative path is too high up, just emit filename rel_path = tex_path.name desc = f" tex:'{tex.image.name}' ({rel_path}) a:{tex.use_alpha}" if str(tex.colorspace_is_data) == "True": # unset value is "Ellipsis" desc += f" data" if str(tex.colorspace_name) != "Ellipsis": desc += f" {tex.colorspace_name}" if tex.texcoords != 'UV': desc += f" uv:{tex.texcoords}" if tex.extension != 'REPEAT': desc += f" ext:{tex.extension}" if tuple(tex.translation) != (0.0, 0.0, 0.0): desc += f" tr:({tex.translation[0]:.3f}, {tex.translation[1]:.3f}, {tex.translation[2]:.3f})" if tuple(tex.rotation) != (0.0, 0.0, 0.0): desc += f" rot:({tex.rotation[0]:.3f}, {tex.rotation[1]:.3f}, {tex.rotation[2]:.3f})" if tuple(tex.scale) != (1.0, 1.0, 1.0): desc += f" scl:({tex.scale[0]:.3f}, {tex.scale[1]:.3f}, {tex.scale[2]:.3f})" return desc @staticmethod def _write_animdata_desc(adt: bpy.types.AnimData, desc: StringIO) -> None: if adt: if adt.action: desc.write(f" - anim act:{adt.action.name}") if adt.action_slot: desc.write(f" slot:{adt.action_slot.identifier}") desc.write(f" blend:{adt.action_blend_type} drivers:{len(adt.drivers)}\n") def generate_main_data_desc(self) -> str: """Generates textual description of the current state of the Blender main data.""" desc = StringIO() # meshes if len(bpy.data.meshes): desc.write(f"==== Meshes: {len(bpy.data.meshes)}\n") for mesh in bpy.data.meshes: # mesh overview desc.write( f"- Mesh '{mesh.name}' " f"vtx:{len(mesh.vertices)} " f"face:{len(mesh.polygons)} " f"loop:{len(mesh.loops)} " f"edge:{len(mesh.edges)}\n" ) if len(mesh.loops) > 0: Report._write_collection_single(mesh.loops, desc) if len(mesh.edges) > 0: Report._write_collection_single(mesh.edges, desc) # attributes for attr in mesh.attributes: if not attr.is_internal: Report._write_attr(attr, desc) # skinning / vertex groups has_skinning = any(v.groups for v in mesh.vertices) if has_skinning: desc.write(f" - vertex groups:\n") for vtx in mesh.vertices[:5]: desc.write(f" -") # emit vertex group weights in decreasing weight order sorted_groups = sorted(vtx.groups, key=lambda g: g.weight, reverse=True) for grp in sorted_groups: desc.write(f" {grp.group}={grp.weight:.3f}") desc.write(f"\n") # materials if mesh.materials: desc.write(f" - {len(mesh.materials)} materials\n") Report._write_collection_single(mesh.materials, desc) # blend shapes key = mesh.shape_keys if key: for kb in key.key_blocks: desc.write(f" - shape key '{kb.name}' w:{kb.value:.3f} vgrp:'{kb.vertex_group}'") # print first several deltas that are not zero from the key count = 0 idx = 0 for pt in kb.points: if pt.co.length_squared > 0: desc.write(f" {idx}:({pt.co[0]:.3f}, {pt.co[1]:.3f}, {pt.co[2]:.3f})") count += 1 if count >= 3: break idx += 1 desc.write(f"\n") Report._write_animdata_desc(mesh.animation_data, desc) Report._write_custom_props(mesh, desc) desc.write(f"\n") # curves if len(bpy.data.curves): desc.write(f"==== Curves: {len(bpy.data.curves)}\n") for curve in bpy.data.curves: # overview desc.write( f"- Curve '{curve.name}' " f"dim:{curve.dimensions} " f"resu:{curve.resolution_u} " f"resv:{curve.resolution_v} " f"splines:{len(curve.splines)}\n" ) for spline in curve.splines[:5]: desc.write( f" - spline type:{spline.type} " f"pts:{spline.point_count_u}x{spline.point_count_v} " f"order:{spline.order_u}x{spline.order_v} " f"cyclic:{spline.use_cyclic_u},{spline.use_cyclic_v} " f"endp:{spline.use_endpoint_u},{spline.use_endpoint_v}\n" ) Report._write_collection_multi(spline.points, desc) # materials if curve.materials: desc.write(f" - {len(curve.materials)} materials\n") Report._write_collection_single(curve.materials, desc) Report._write_animdata_desc(curve.animation_data, desc) Report._write_custom_props(curve, desc) desc.write(f"\n") # curves(new) / hair if len(bpy.data.hair_curves): desc.write(f"==== Curves(new): {len(bpy.data.hair_curves)}\n") for curve in bpy.data.hair_curves: # overview desc.write( f"- Curve '{curve.name}' " f"splines:{len(curve.curves)} " f"control-points:{len(curve.points)}\n" ) # attributes for attr in sorted(curve.attributes, key=lambda x: x.name): if not attr.is_internal: Report._write_attr(attr, desc) # materials if curve.materials: desc.write(f" - {len(curve.materials)} materials\n") Report._write_collection_single(curve.materials, desc) Report._write_animdata_desc(curve.animation_data, desc) Report._write_custom_props(curve, desc) desc.write(f"\n") # objects if len(bpy.data.objects): desc.write(f"==== Objects: {len(bpy.data.objects)}\n") for obj in bpy.data.objects: desc.write(f"- Obj '{obj.name}' {obj.type}") if obj.data: desc.write(f" data:'{obj.data.name}'") if obj.parent: desc.write(f" par:'{obj.parent.name}'") if obj.parent_type != 'OBJECT': desc.write(f" par_type:{obj.parent_type}") if obj.parent_type == 'BONE': desc.write(f" par_bone:'{obj.parent_bone}'") desc.write(f"\n") mtx = obj.matrix_parent_inverse if not is_approx_identity(mtx): desc.write(f" - matrix_parent_inverse:\n") desc.write(f" {fmtf(mtx[0][0])} {fmtf(mtx[0][1])} {fmtf(mtx[0][2])} {fmtf(mtx[0][3])}\n") desc.write(f" {fmtf(mtx[1][0])} {fmtf(mtx[1][1])} {fmtf(mtx[1][2])} {fmtf(mtx[1][3])}\n") desc.write(f" {fmtf(mtx[2][0])} {fmtf(mtx[2][1])} {fmtf(mtx[2][2])} {fmtf(mtx[2][3])}\n") desc.write(f" - pos {fmtf(obj.location[0])}, {fmtf(obj.location[1])}, {fmtf(obj.location[2])}\n") desc.write( f" - rot {fmtrot(obj.rotation_euler[0])}, " f"{fmtrot(obj.rotation_euler[1])}, " f"{fmtrot(obj.rotation_euler[2])} " f"({obj.rotation_mode})\n" ) desc.write(f" - scl {obj.scale[0]:.3f}, {obj.scale[1]:.3f}, {obj.scale[2]:.3f}\n") if obj.vertex_groups: desc.write(f" - {len(obj.vertex_groups)} vertex groups\n") Report._write_collection_single(obj.vertex_groups, desc) if obj.material_slots: has_object_link = any(slot.link == 'OBJECT' for slot in obj.material_slots if slot.link) if has_object_link: desc.write(f" - {len(obj.material_slots)} object materials\n") Report._write_collection_single(obj.material_slots, desc) if obj.modifiers: desc.write(f" - {len(obj.modifiers)} modifiers\n") for mod in obj.modifiers: desc.write(f" - {mod.type} '{mod.name}'") if isinstance(mod, bpy.types.SubsurfModifier): desc.write( f" levels:{mod.levels}/{mod.render_levels} " f"type:{mod.subdivision_type} " f"crease:{mod.use_creases}" ) desc.write(f"\n") # for a pose, only print bones that either have non-identity pose matrix, or custom properties if obj.pose: bones = sorted(obj.pose.bones, key=lambda b: b.name) for bone in bones: mtx = bone.matrix_basis mtx_identity = is_approx_identity(mtx) desc_props = StringIO() Report._write_custom_props(bone, desc_props, ' ') props_str = desc_props.getvalue() if not mtx_identity or len(props_str) > 0: desc.write(f" - posed bone '{bone.name}'\n") if not mtx_identity: desc.write( f" {fmtf(mtx[0][0])} {fmtf(mtx[0][1])} {fmtf(mtx[0][2])} {fmtf(mtx[0][3])}\n" ) desc.write( f" {fmtf(mtx[1][0])} {fmtf(mtx[1][1])} {fmtf(mtx[1][2])} {fmtf(mtx[1][3])}\n" ) desc.write( f" {fmtf(mtx[2][0])} {fmtf(mtx[2][1])} {fmtf(mtx[2][2])} {fmtf(mtx[2][3])}\n" ) if len(props_str) > 0: desc.write(props_str) Report._write_animdata_desc(obj.animation_data, desc) Report._write_custom_props(obj, desc) desc.write(f"\n") # cameras if len(bpy.data.cameras): desc.write(f"==== Cameras: {len(bpy.data.cameras)}\n") for cam in bpy.data.cameras: desc.write( f"- Cam '{cam.name}' " f"{cam.type} " f"lens:{cam.lens:.1f} " f"{cam.lens_unit} " f"near:{cam.clip_start:.3f} " f"far:{cam.clip_end:.1f} " f"orthosize:{cam.ortho_scale:.1f}\n" ) desc.write( f" - fov {cam.angle:.3f} " f"(h {cam.angle_x:.3f} v {cam.angle_y:.3f})\n" ) desc.write( f" - sensor {cam.sensor_width:.1f}x{cam.sensor_height:.1f} " f"shift {cam.shift_x:.3f},{cam.shift_y:.3f}\n" ) if cam.dof.use_dof: desc.write( f" - dof dist:{cam.dof.focus_distance:.3f} " f"fstop:{cam.dof.aperture_fstop:.1f} " f"blades:{cam.dof.aperture_blades}\n" ) Report._write_animdata_desc(cam.animation_data, desc) Report._write_custom_props(cam, desc) desc.write(f"\n") # lights if len(bpy.data.lights): desc.write(f"==== Lights: {len(bpy.data.lights)}\n") for light in bpy.data.lights: desc.write( f"- Light '{light.name}' " f"{light.type} " f"col:({light.color[0]:.3f}, {light.color[1]:.3f}, {light.color[2]:.3f}) " f"energy:{light.energy:.3f}" ) if light.exposure != 0: desc.write(f" exposure:{fmtf(light.exposure)}") if light.use_temperature: desc.write( f" temp:{fmtf(light.temperature)}") if not light.normalize: desc.write(f" normalize_off") desc.write(f"\n") if isinstance(light, bpy.types.SpotLight): desc.write(f" - spot {light.spot_size:.3f} blend {light.spot_blend:.3f}\n") Report._write_animdata_desc(light.animation_data, desc) Report._write_custom_props(light, desc) desc.write(f"\n") # materials if len(bpy.data.materials): desc.write(f"==== Materials: {len(bpy.data.materials)}\n") for mat in bpy.data.materials: desc.write(f"- Mat '{mat.name}'\n") wrap = bpy_extras.node_shader_utils.PrincipledBSDFWrapper(mat) desc.write( f" - base color (" f"{wrap.base_color[0]:.3f}, " f"{wrap.base_color[1]:.3f}, " f"{wrap.base_color[2]:.3f})" f"{self._node_shader_image_desc(wrap.base_color_texture)}\n" ) desc.write( f" - specular ior {wrap.specular:.3f}{self._node_shader_image_desc(wrap.specular_texture)}\n") desc.write( f" - specular tint (" f"{wrap.specular_tint[0]:.3f}, " f"{wrap.specular_tint[1]:.3f}, " f"{wrap.specular_tint[2]:.3f})" f"{self._node_shader_image_desc(wrap.specular_tint_texture)}\n" ) desc.write( f" - roughness {wrap.roughness:.3f}{self._node_shader_image_desc(wrap.roughness_texture)}\n") desc.write( f" - metallic {wrap.metallic:.3f}{self._node_shader_image_desc(wrap.metallic_texture)}\n") desc.write(f" - ior {wrap.ior:.3f}{self._node_shader_image_desc(wrap.ior_texture)}\n") if wrap.transmission > 0.0 or (wrap.transmission_texture and wrap.transmission_texture.image): desc.write( f" - transmission {wrap.transmission:.3f}" f"{self._node_shader_image_desc(wrap.transmission_texture)}\n" ) if wrap.alpha < 1.0 or (wrap.alpha_texture and wrap.alpha_texture.image): desc.write( f" - alpha {wrap.alpha:.3f}{self._node_shader_image_desc(wrap.alpha_texture)}\n") if ( wrap.emission_strength > 0.0 and wrap.emission_color[0] > 0.0 and wrap.emission_color[1] > 0.0 and wrap.emission_color[2] > 0.0 ) or ( wrap.emission_strength_texture and wrap.emission_strength_texture.image ): desc.write( f" - emission color " f"({wrap.emission_color[0]:.3f}, " f"{wrap.emission_color[1]:.3f}, " f"{wrap.emission_color[2]:.3f})" f"{self._node_shader_image_desc(wrap.emission_color_texture)}\n" ) desc.write( f" - emission strength {wrap.emission_strength:.3f}" f"{self._node_shader_image_desc(wrap.emission_strength_texture)}\n" ) if (wrap.normalmap_texture and wrap.normalmap_texture.image): desc.write( f" - normalmap {wrap.normalmap_strength:.3f}" f"{self._node_shader_image_desc(wrap.normalmap_texture)}\n" ) if mat.alpha_threshold != 0.5: desc.write(f" - alpha_threshold {fmtf(mat.alpha_threshold)}\n") if mat.surface_render_method != 'DITHERED': desc.write(f" - surface_render_method {mat.surface_render_method}\n") if mat.displacement_method != 'BUMP': desc.write(f" - displacement {mat.displacement_method}\n") desc.write( " - viewport diffuse (" f"{fmtf(mat.diffuse_color[0])}, " f"{fmtf(mat.diffuse_color[1])}, " f"{fmtf(mat.diffuse_color[2])}, " f"{fmtf(mat.diffuse_color[3])})\n" ) desc.write( " - viewport specular (" f"{fmtf(mat.specular_color[0])}, " f"{fmtf(mat.specular_color[1])}, " f"{fmtf(mat.specular_color[2])}), " f"intensity {fmtf(mat.specular_intensity)}\n" ) desc.write( " - viewport " f"metallic {fmtf(mat.metallic)}, " f"roughness {fmtf(mat.roughness)}\n" ) desc.write( f" - backface {mat.use_backface_culling} " f"probe {mat.use_backface_culling_lightprobe_volume} " f"shadow {mat.use_backface_culling_shadow}\n" ) Report._write_animdata_desc(mat.animation_data, desc) Report._write_custom_props(mat, desc) desc.write(f"\n") # actions if len(bpy.data.actions): desc.write(f"==== Actions: {len(bpy.data.actions)}\n") for act in sorted(bpy.data.actions, key=lambda a: a.name): layers = sorted(act.layers, key=lambda l: l.name) desc.write( f"- Action '{act.name}' " f"curverange:({act.curve_frame_range[0]:.1f} .. {act.curve_frame_range[1]:.1f}) " f"layers:{len(layers)}\n" ) for layer in layers: desc.write(f"- ActionLayer {layer.name} strips:{len(layer.strips)}\n") for strip in layer.strips: if strip.type == 'KEYFRAME': desc.write(f" - Keyframe strip channelbags:{len(strip.channelbags)}\n") for chbag in strip.channelbags: curves = sorted(chbag.fcurves, key=lambda c: f"{c.data_path}[{c.array_index}]") desc.write(f" - Channelbag ") if chbag.slot: desc.write(f"slot '{chbag.slot.identifier}' ") desc.write(f"curves:{len(curves)}\n") for fcu in curves[:15]: grp = '' if fcu.group: grp = f" grp:'{fcu.group.name}'" desc.write( f" - fcu '{fcu.data_path}[{fcu.array_index}]' " f"smooth:{fcu.auto_smoothing} " f"extra:{fcu.extrapolation} " f"keyframes:{len(fcu.keyframe_points)}{grp}\n" ) Report._write_collection_multi(fcu.keyframe_points, desc) Report._write_custom_props(act, desc) desc.write(f"\n") # armatures if len(bpy.data.armatures): desc.write(f"==== Armatures: {len(bpy.data.armatures)}\n") for arm in bpy.data.armatures: bones = sorted(arm.bones, key=lambda b: b.name) desc.write(f"- Armature '{arm.name}' {len(bones)} bones") if arm.display_type != 'OCTAHEDRAL': desc.write(f" display:{arm.display_type}") desc.write("\n") for bone in bones: desc.write(f" - bone '{bone.name}'") if bone.parent: desc.write(f" parent:'{bone.parent.name}'") desc.write( f" h:({fmtf(bone.head[0])}, {fmtf(bone.head[1])}, {fmtf(bone.head[2])}) " f"t:({fmtf(bone.tail[0])}, {fmtf(bone.tail[1])}, {fmtf(bone.tail[2])})" ) if bone.use_connect: desc.write(f" connect") if not bone.use_deform: desc.write(f" no-deform") if bone.inherit_scale != 'FULL': desc.write(f" inh_scale:{bone.inherit_scale}") if bone.head_radius > 0.0 or bone.tail_radius > 0.0: desc.write(f" radius h:{bone.head_radius:.3f} t:{bone.tail_radius:.3f}") desc.write(f"\n") mtx = bone.matrix_local desc.write(f" {fmtf(mtx[0][0])} {fmtf(mtx[0][1])} {fmtf(mtx[0][2])} {fmtf(mtx[0][3])}\n") desc.write(f" {fmtf(mtx[1][0])} {fmtf(mtx[1][1])} {fmtf(mtx[1][2])} {fmtf(mtx[1][3])}\n") desc.write(f" {fmtf(mtx[2][0])} {fmtf(mtx[2][1])} {fmtf(mtx[2][2])} {fmtf(mtx[2][3])}\n") # mtx[3] is always 0,0,0,1, not worth printing it Report._write_custom_props(bone, desc) Report._write_animdata_desc(arm.animation_data, desc) Report._write_custom_props(arm, desc) desc.write(f"\n") # images if len(bpy.data.images): desc.write(f"==== Images: {len(bpy.data.images)}\n") for img in bpy.data.images: desc.write(f"- Image '{img.name}' {img.size[0]}x{img.size[1]} {img.depth}bpp\n") Report._write_custom_props(img, desc) desc.write(f"\n") text = desc.getvalue() desc.close() return text def import_and_check(self, input_file: pathlib.Path, import_func: Callable[[str, dict], None]) -> bool: """ Imports a single file using the provided import function, and checks whether it matches with expected template, returns comparison result. If there is a .json file next to the input file, the parameters from that one file will be passed as extra parameters to the import function. When working in template update mode (environment variable BLENDER_TEST_UPDATE=1), updates the template with new result and always returns true. """ self.tested_count += 1 input_basename = pathlib.Path(input_file).stem print(f"Importing {input_file}...", flush=True) # load json parameters if they exist params = {} input_params_file = input_file.with_suffix(".json") if input_params_file.exists(): try: with input_params_file.open('r', encoding='utf-8') as file: params = json.load(file) except: pass # import try: import_func(str(input_file), params) got_desc = self.generate_main_data_desc() except RuntimeError as ex: got_desc = f"Error during import: {ex}" ref_path: pathlib.Path = self.reference_dir / f"{input_basename}.txt" if ref_path.exists(): ref_desc = ref_path.read_text(encoding="utf-8").replace("\r\n", "\n") else: ref_desc = "" ok = True if self.update_templates: # write out newly got result as reference if ref_desc != got_desc: ref_path.write_text(got_desc, encoding="utf-8", newline="\n") self.updated_list.append(input_basename) else: # compare result with expected reference self._add_test_result(input_basename, got_desc, ref_desc) if ref_desc == got_desc: self.passed_list.append(input_basename) else: self.failed_list.append(input_basename) ok = False return ok