Add Chromium-only Blender WebEngine parity work

This commit is contained in:
mes123456
2026-08-12 04:47:48 -04:00
commit 9fd26010f6
18225 changed files with 11622124 additions and 0 deletions

View File

@@ -0,0 +1,57 @@
# SPDX-FileCopyrightText: 2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
import sys
class COLORS_ANSI:
RED = '\033[00;31m'
GREEN = '\033[00;32m'
YELLOW = '\033[00;33m'
ENDC = '\033[0m'
class COLORS_NONE:
RED = ''
GREEN = ''
YELLOW = ''
ENDC = ''
COLORS = COLORS_NONE
def use_message_colors():
global COLORS, COLORS_ANSI
COLORS = COLORS_ANSI
def print_message(message, type=None, status=''):
if type == 'SUCCESS':
print(COLORS.GREEN, end="")
elif type == 'FAILURE':
print(COLORS.RED, end="")
elif type == 'WARNING':
print(COLORS.YELLOW, end="")
if status == "RAW":
print("{}" . format(message))
print(COLORS.ENDC, end="")
sys.stdout.flush()
return
status_text = ...
if status == 'RUN':
status_text = " RUN "
elif status == 'OK':
status_text = " OK "
elif status == 'PASSED':
status_text = " PASSED "
elif status == 'FAILED':
status_text = " FAILED "
else:
status_text = status
if status_text:
print("[{}]" . format(status_text), end="")
print(COLORS.ENDC, end="")
print(" {}" . format(message))
sys.stdout.flush()

View File

@@ -0,0 +1,82 @@
# SPDX-FileCopyrightText: 2019-2022 Blender Authors
#
# SPDX-License-Identifier: Apache-2.0
# Generate a HTML page that links to all test reports.
import glob
import os
import pathlib
def _write_html(output_dir):
combined_reports = ""
# Gather intermediate data for all tests and combine into one HTML file.
categories = sorted(glob.glob(os.path.join(output_dir, "report", "*")))
for category in categories:
category_name = os.path.basename(category)
combined_reports += "<h3>" + category_name + "</h3>\n"
reports = sorted(glob.glob(os.path.join(category, "*.data")))
for filename in reports:
filepath = os.path.join(output_dir, filename)
combined_reports += pathlib.Path(filepath).read_text()
combined_reports += "<br/>\n"
html = """
<html>
<head>
<title>{title}</title>
<style>
.ok {{ color: green; }}
.failed {{ color: red; }}
.none {{ color: #999; }}
</style>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
</head>
<body>
<div class="container">
<br/>
<h1>{title}</h1>
<nav aria-label="breadcrumb"><ol class="breadcrumb"><li class="breadcrumb-item active" aria-current="page">Test Reports</li></ol></nav>
{combined_reports}
<br/>
</div>
</body>
</html>
""" . format(title="Blender Test Reports",
combined_reports=combined_reports)
filepath = os.path.join(output_dir, "report.html")
pathlib.Path(filepath).write_text(html)
def add(output_dir, category, name, filepath, failed=None):
# Write HTML for single test.
if failed is None:
status = "none"
elif failed:
status = "failed"
else:
status = "ok"
relpath = os.path.relpath(filepath, output_dir)
html = """
<span class="{status}">&#11044;</span>
<a href="{relpath}">{name}</a><br/>
""" . format(status=status,
name=name,
relpath=relpath)
dirpath = os.path.join(output_dir, "report", category)
os.makedirs(dirpath, exist_ok=True)
filepath = os.path.join(dirpath, name + ".data")
pathlib.Path(filepath).write_text(html)
# Combined into HTML, each time so we can see intermediate results
# while tests are still running.
_write_html(output_dir)

View File

@@ -0,0 +1,29 @@
# SPDX-FileCopyrightText: 2022 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
"""
Prints GPU back-end information to the console and exits.
Use this script as `blender --background --python gpu_info.py`.
"""
import bpy
import sys
# Render with workbench to initialize the GPU backend otherwise it would fail when running in
# background mode as the GPU backend won't be initialized.
scene = bpy.context.scene
scene.render.resolution_x = 1
scene.render.resolution_y = 1
scene.render.engine = "BLENDER_WORKBENCH"
bpy.ops.render.render(animation=False, write_still=False)
# Import GPU module only after GPU backend has been initialized.
import gpu
print('GPU_VENDOR:' + gpu.platform.vendor_get())
print('GPU_RENDERER:' + gpu.platform.renderer_get())
print('GPU_VERSION:' + gpu.platform.version_get())
print('GPU_DEVICE_TYPE:' + gpu.platform.device_type_get())
sys.exit(0)

View File

@@ -0,0 +1,101 @@
# SPDX-FileCopyrightText: 2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
__all__ = (
"AbstractImBufTest",
)
import os
import pathlib
import shutil
import subprocess
import unittest
from .colored_print import (print_message, use_message_colors)
class AbstractImBufTest(unittest.TestCase):
@classmethod
def init(cls, args):
cls.test_dir = pathlib.Path(args.test_dir)
cls.reference_dir = pathlib.Path(args.test_dir).joinpath("reference")
cls.reference_load_dir = pathlib.Path(args.test_dir).joinpath("reference_load")
cls.output_dir = pathlib.Path(args.output_dir)
cls.diff_dir = pathlib.Path(args.output_dir).joinpath("diff")
cls.oiiotool = pathlib.Path(args.oiiotool)
cls.optional_formats = args.optional_formats
os.makedirs(cls.diff_dir, exist_ok=True)
cls.errors = 0
cls.fail_threshold = 0.016
cls.fail_percent = 1
cls.verbose = os.environ.get("BLENDER_VERBOSE") is not None
cls.update = os.getenv('BLENDER_TEST_UPDATE') is not None
if os.environ.get("BLENDER_TEST_COLOR") is not None:
use_message_colors()
def setUp(self):
self.errors = 0
print_message("")
def tearDown(self):
if self.errors > 0:
self.fail("{} errors encountered" . format(self.errors))
def skip_if_format_missing(self, format):
if self.optional_formats.find(format) < 0:
self.skipTest("format not available")
def call_idiff(self, ref_path, out_path):
ref_filepath = str(ref_path)
out_filepath = str(out_path)
out_name = out_path.name
if os.path.exists(ref_filepath):
# Diff images test with threshold.
command = (
str(self.oiiotool),
ref_filepath,
out_filepath,
"--fail", str(self.fail_threshold),
"--failpercent", str(self.fail_percent),
"--diff",
)
try:
subprocess.check_output(command)
failed = False
except subprocess.CalledProcessError as e:
if self.verbose:
print_message(e.output.decode("utf-8", 'ignore'))
failed = e.returncode != 0
else:
if not self.update:
return False
failed = True
if failed and self.update:
# Update reference image if requested.
shutil.copy(out_filepath, ref_filepath)
failed = False
# Generate diff image (set fail thresholds high to reduce output spam).
diff_img = str(self.diff_dir.joinpath(out_name + ".diff.png"))
command = (
str(self.oiiotool),
ref_filepath,
out_filepath,
"--sub",
"--abs",
"--mulc", "16",
"-o", diff_img,
)
try:
subprocess.check_output(command)
except subprocess.CalledProcessError as e:
if self.verbose:
print_message(e.output.decode("utf-8", 'ignore'))
return not failed

View File

@@ -0,0 +1,975 @@
# 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 html
import json
import os
import pathlib
from . import global_report
from io import StringIO
from mathutils import Matrix
from collections.abc 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',
'generate_data_desc',
'tested_count',
'failed_list',
'passed_list',
'updated_list',
'failed_html',
'passed_html',
'update_templates',
)
context_lines = 3
side_to_print_single_line = 5
side_to_print_multi_line = 3
def __init__(
self,
title: str,
output_dir: pathlib.Path,
input_dir: pathlib.Path,
reference_dir: pathlib.Path,
comparison_func: Callable[[str, dict], None] | None = None,
):
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.generate_data_desc = comparison_func if comparison_func else self.generate_generic_data_desc
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(
'<html><body>Report not generated yet. Crashed during tests?</body></html>')
@staticmethod
def _navigation_item(title, href, active):
if active:
return """<li class="breadcrumb-item active" aria-current="page">%s</li>""" % title
else:
return """<li class="breadcrumb-item"><a href="%s">%s</a></li>""" % (href, title)
def _navigation_html(self):
html = """<nav aria-label="breadcrumb"><ol class="breadcrumb">"""
base_path = os.path.relpath(self.global_dir, self.output_dir)
global_report_path = os.path.join(base_path, "report.html")
html += self._navigation_item("Test Reports", global_report_path, False)
html += self._navigation_item(self.title, "report.html", True)
html += """</ol></nav>"""
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 = """<div class="alert alert-danger" role="alert">"""
message += """<p>Run this command to regenerate reference (ground truth) output:</p>"""
message += """<p><tt>BLENDER_TEST_UPDATE=1 ctest -R %s</tt></p>""" % test_suite_name
message += """<p>The reference output of new and failing tests will be updated. """ \
"""Be sure to commit the new reference """ \
"""files under the tests/files folder afterwards.</p>"""
message += """</div>"""
message += f"Tested files: {self.tested_count}, <b>failed: {len(self.failed_list)}</b>"
else:
message = f"Tested files: {self.tested_count}"
title = self.title + " Test Report"
columns_html = "<tr><th>Name</th><th>New</th><th>Reference</th><th>Diff</th>"
html = f"""
<html>
<head>
<title>{title}</title>
<style>
div.page_container {{ text-align: center; }}
div.page_container div {{ text-align: left; }}
div.page_content {{ display: inline-block; }}
.text_cell {{
max-width: 22.5em;
max-height: 8em;
overflow: auto;
font-family: monospace;
white-space: pre;
font-size: 10pt;
border: 1px solid gray;
}}
.text_cell_larger {{ max-height: 14em; }}
.text_cell_wider {{ max-width: 44em; }}
.added {{ background-color: #d4edda; }}
.removed {{ background-color: #f8d7da; }}
.place {{ color: #808080; font-style: italic; }}
p {{ margin-bottom: 0.5rem; }}
</style>
<link rel="stylesheet" \
href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" \
integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
</head>
<body>
<div class="page_container"><div class="page_content">
<br/>
<h1>{title}</h1>
{menu}
{message}
<table class="table table-striped">
<thead class="thead-dark">{columns_html}</thead>
{tests_html}
</table>
<br/>
</div></div>
</body>
</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='', n=Report.context_lines)
html = []
for line in diff:
if line.startswith('+++') or line.startswith('---'):
pass
elif line.startswith('@@'):
html.append(f'<span class="place">{line}</span>')
elif line.startswith('-'):
html.append(f'<span class="removed">{line}</span>')
elif line.startswith('+'):
html.append(f'<span class="added">{line}</span>')
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 = "&nbsp;"
escaped_got_desc = html.escape(got_desc)
escaped_ref_desc = html.escape(ref_desc)
if error:
diff_text = Report._colored_diff(escaped_ref_desc, escaped_got_desc)
test_html = f"""
<tr>
<td{table_style}><b>{testname}</b><br/>{status}</td>
<td><div class="{cell_class}">{escaped_got_desc}</div></td>
<td><div class="{cell_class}">{escaped_ref_desc}</div></td>
<td><div class="{cell_class} text_cell_wider">{diff_text}</div></td>
</tr>"""
if error:
self.failed_html += test_html
else:
self.passed_html += test_html
return not error
@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.QuaternionAttributeValue):
return f"({val.value[0]:.3f}, {val.value[1]:.3f}, {val.value[2]:.3f}, {val.value[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)}"
if isinstance(val, bpy.types.UDIMTile):
return f"{val.number}"
return str(val)
# single-line dump of head/tail
@staticmethod
def _write_collection_single(col, desc: StringIO, line_prefix=" - ") -> None:
desc.write(line_prefix)
side_to_print = Report.side_to_print_single_line
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 = Report.side_to_print_multi_line
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}=<cyclesval>")
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"<outside of test folder>"
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_generic_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
attr_names = [attr.name for attr in mesh.attributes]
attr_names.sort()
for name in attr_names:
attr = mesh.attributes[name]
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")
# pointclouds
if len(bpy.data.pointclouds):
desc.write(f"==== Point Clouds: {len(bpy.data.pointclouds)}\n")
for pointcloud in bpy.data.pointclouds:
# overview
desc.write(
f"- PointCloud '{pointcloud.name}' "
f"points:{len(pointcloud.points)}\n"
)
# attributes
for attr in sorted(pointcloud.attributes, key=lambda x: x.name):
if not attr.is_internal:
Report._write_attr(attr, desc)
# materials
if pointcloud.materials:
desc.write(f" - {len(pointcloud.materials)} materials\n")
Report._write_collection_single(pointcloud.materials, desc)
Report._write_animdata_desc(pointcloud.animation_data, desc)
Report._write_custom_props(pointcloud, 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")
if len(img.tiles) > 1:
desc.write(f" - {len(img.tiles)} tiles: ")
Report._write_collection_single(img.tiles, desc, "")
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:
return self.generate_and_check(input_file=input_file, generate_func=import_func)
def generate_and_check(
self,
input_file: pathlib.Path,
generate_func: Callable[[str, dict], None],
output_filepath: pathlib.Path | None = 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 generate function.
If there is a .export.json file next to the input file, it is assumed
that this is an export or round-trip test, and the parameters from that
file will be passed as extra export parameters to the generate function.
In this case, output_filepath is expected to be provided as well.
When working in template update mode (environment variable
BLENDER_TEST_UPDATE=1), updates the template with new result
and always returns true.
This function also supports import/export tests (called round-trips),
and exports, where the export parameters are read from a .export.json
file next to the input file, and passed to the generate function as well.
In this case, the output file is expected to be written to a temporary folder.
Here, output_filepath is the name of the output file to read the result from
(absolute name is used here, as it is inside a temporary folder).
"""
self.tested_count += 1
input_basename = pathlib.Path(input_file).stem
print(f"Importing {input_file}...", flush=True)
# load json parameters if they exist, for import
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
# load json parameters if they exist, for export
params_export = {}
output_params_file = input_file.with_suffix(".export.json")
if output_params_file.exists():
try:
with output_params_file.open('r', encoding='utf-8') as file:
params_export = json.load(file)
except:
pass
# Generate (import, export or round-trip)
try:
if not output_filepath:
# Import (check Blender data, so no output file)
generate_func(str(input_file), params)
got_desc = self.generate_data_desc()
else:
# Export or round-trip (check output file)
generate_func(str(input_file), str(output_filepath), params, params_export)
got_desc = self.generate_data_desc(output_filepath)
except RuntimeError as ex:
got_desc = f"Error during import: {ex}".replace(str(input_file), input_file.name)
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
result = self._add_test_result(input_basename, got_desc, ref_desc)
if result:
self.passed_list.append(input_basename)
else:
self.failed_list.append(input_basename)
ok = False
return ok

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,764 @@
# SPDX-FileCopyrightText: 2018-2023 Blender Authors
#
# SPDX-License-Identifier: Apache-2.0
"""
Compare renders or screenshots against reference versions and generate
a HTML report showing the differences, for regression testing.
"""
import glob
import fnmatch
import os
import sys
import pathlib
import shutil
import subprocess
import time
import multiprocessing
import traceback
import re
from pathlib import Path
from . import global_report
from .colored_print import (print_message, use_message_colors)
def blend_list(dirpath, blocklist, filter):
import re
positive_patterns = []
negative_patterns = []
if filter:
if "-" in filter:
positive_filter, negative_filter = filter.split('-', maxsplit=1)
else:
positive_filter = filter
negative_filter = ""
positive_patterns = positive_filter.lower().split(":") if positive_filter else []
negative_patterns = negative_filter.lower().split(":") if negative_filter else []
for root, dirs, files in os.walk(dirpath):
for filename in files:
if not filename.lower().endswith(".blend"):
continue
skip = False
for blocklist_entry in blocklist:
if re.match(blocklist_entry, filename):
skip = True
break
if skip:
continue
name_for_filter = Path(filename).stem.lower()
if positive_patterns:
skip = True
for positive_pattern in positive_patterns:
if fnmatch.fnmatch(name_for_filter, positive_pattern):
skip = False
break
if negative_patterns:
for negative_pattern in negative_patterns:
if fnmatch.fnmatch(name_for_filter, negative_pattern):
skip = True
break
if not skip:
filepath = os.path.join(root, filename)
yield filepath
def test_get_name(filepath):
filename = os.path.basename(filepath)
return os.path.splitext(filename)[0]
def test_get_images(output_dir, filepath, testname, reference_dir, reference_override_dir):
dirpath = os.path.dirname(filepath)
old_dirpath = os.path.join(dirpath, reference_dir)
old_img = os.path.join(old_dirpath, testname + ".png")
if reference_override_dir:
override_dirpath = os.path.join(dirpath, reference_override_dir)
override_img = os.path.join(override_dirpath, testname + ".png")
if os.path.exists(override_img):
old_dirpath = override_dirpath
old_img = override_img
ref_dirpath = os.path.join(output_dir, os.path.basename(dirpath), "ref")
ref_img = os.path.join(ref_dirpath, testname + ".png")
os.makedirs(ref_dirpath, exist_ok=True)
if os.path.exists(old_img):
shutil.copy(old_img, ref_img)
new_dirpath = os.path.join(output_dir, os.path.basename(dirpath))
os.makedirs(new_dirpath, exist_ok=True)
new_img = os.path.join(new_dirpath, testname + ".png")
diff_dirpath = os.path.join(output_dir, os.path.basename(dirpath), "diff")
os.makedirs(diff_dirpath, exist_ok=True)
diff_color_img = os.path.join(diff_dirpath, testname + ".diff_color.png")
diff_alpha_img = os.path.join(diff_dirpath, testname + ".diff_alpha.png")
return old_img, ref_img, new_img, diff_color_img, diff_alpha_img
class TestResult:
def __init__(self, report, filepath, name):
self.filepath = filepath
self.name = name
self.error = None
self.stats = None
self.tmp_out_img_base = os.path.join(report.output_dir, "tmp_" + name)
self.tmp_out_img = self.tmp_out_img_base + '0001.png'
self.old_img, self.ref_img, self.new_img, self.diff_color_img, self.diff_alpha_img = test_get_images(
report.output_dir, filepath, name, report.reference_dir, report.reference_override_dir)
def diff_output(test, oiiotool, fail_threshold, fail_percent, verbose, update):
# Create reference render directory.
old_dirpath = os.path.dirname(test.old_img)
os.makedirs(old_dirpath, exist_ok=True)
# Copy temporary to new image.
if os.path.exists(test.new_img):
os.remove(test.new_img)
if os.path.exists(test.tmp_out_img):
shutil.copy(test.tmp_out_img, test.new_img)
if os.path.exists(test.ref_img):
# Diff images test with threshold.
command = (
oiiotool,
test.ref_img,
test.tmp_out_img,
"--fail", str(fail_threshold),
"--failpercent", str(fail_percent),
"--diff",
)
try:
output = subprocess.check_output(command)
failed = False
except subprocess.CalledProcessError as e:
output = e.output
if verbose:
print_message(output.decode("utf-8", 'ignore'))
failed = e.returncode != 0
try:
output = output.decode("utf-8", 'ignore')
# Only print max error and number of pixels over threshold.
# Max error is not present if the images are a perfect match.
max_error = re.search(r"Max error *= *(\S+)", output)
over_threshold = re.findall(r"\S+ pixels .* over \S+", output)
if max_error or over_threshold:
test.stats = ""
if max_error:
test.stats += "Max error = {:.3f}\n".format(float(max_error.group(1)))
if over_threshold:
test.stats += over_threshold[-1]
except Exception as e:
print("Error parsing oiiotool output: \n", output, "\n", traceback.format_exc())
test.error = "STATS ERROR"
return test
else:
if not update:
test.error = "VERIFY"
return test
failed = True
if failed and update:
# Update reference image if requested.
shutil.copy(test.new_img, test.ref_img)
shutil.copy(test.new_img, test.old_img)
failed = False
# Generate color diff image.
command = (
oiiotool,
test.ref_img,
"--ch", "R,G,B",
test.tmp_out_img,
"--ch", "R,G,B",
"--sub",
"--abs",
"--mulc", "16",
"-o", test.diff_color_img,
)
try:
subprocess.check_output(command, stderr=subprocess.STDOUT)
except subprocess.CalledProcessError as e:
if verbose:
print_message(e.output.decode("utf-8", 'ignore'))
# Generate alpha diff image.
command = (
oiiotool,
test.ref_img,
"--ch", "A",
test.tmp_out_img,
"--ch", "A",
"--sub",
"--abs",
"--mulc", "16",
"-o", test.diff_alpha_img,
)
try:
subprocess.check_output(command, stderr=subprocess.STDOUT)
except subprocess.CalledProcessError as e:
if verbose:
msg = e.output.decode("utf-8", 'ignore')
for line in msg.splitlines():
# Ignore warnings for images without alpha channel.
if "--ch: Unknown channel name" not in line:
print_message(line)
if failed:
test.error = "VERIFY"
else:
test.error = None
return test
def get_gpu_device_vendor(blender):
command = [
blender,
"--background",
"--factory-startup",
"--python",
str(pathlib.Path(__file__).parent / "gpu_info.py")
]
try:
completed_process = subprocess.run(command, stdout=subprocess.PIPE, universal_newlines=True)
for line in completed_process.stdout.splitlines():
if line.startswith("GPU_DEVICE_TYPE:"):
vendor = line.split(':')[1].upper()
return vendor
except Exception:
return None
return None
class Report:
__slots__ = (
'title',
'engine_name',
'output_dir',
'global_dir',
'reference_dir',
'reference_override_dir',
"test_name_suffix",
'oiiotool',
'pixelated',
'fail_threshold',
'fail_percent',
'verbose',
'update',
'filter',
'failed_tests',
'passed_tests',
'compare_tests',
'compare_engine',
'blocklist',
)
def __init__(self, title, output_dir, oiiotool, variation=None, blocklist=[]):
self.title = title
# Normalize the path to avoid output_dir and global_dir being the same when a directory
# ends with a trailing slash.
self.output_dir = os.path.normpath(output_dir)
self.global_dir = os.path.dirname(self.output_dir)
self.reference_dir = 'reference_renders'
self.reference_override_dir = None
self.test_name_suffix = ""
self.oiiotool = oiiotool
self.compare_engine = None
self.fail_threshold = 0.016
self.fail_percent = 1
self.engine_name = self.title.lower().replace(" ", "_")
self.blocklist = [] if os.getenv('BLENDER_TEST_IGNORE_BLOCKLIST') is not None else blocklist
if variation:
self.title = self._engine_title(title, variation)
self.output_dir = self._engine_path(self.output_dir, variation.lower())
self.pixelated = False
self.verbose = os.environ.get("BLENDER_VERBOSE") is not None
self.update = os.getenv('BLENDER_TEST_UPDATE') is not None
self.filter = os.getenv('BLENDER_TEST_FILTER') or ""
if os.environ.get("BLENDER_TEST_COLOR") is not None:
use_message_colors()
self.failed_tests = ""
self.passed_tests = ""
self.compare_tests = ""
os.makedirs(output_dir, exist_ok=True)
def set_pixelated(self, pixelated):
self.pixelated = pixelated
def set_fail_threshold(self, threshold):
self.fail_threshold = threshold
def set_fail_percent(self, percent):
self.fail_percent = percent
def set_reference_dir(self, reference_dir):
self.reference_dir = reference_dir
def set_reference_override_dir(self, reference_override_dir):
self.reference_override_dir = reference_override_dir
def set_compare_engine(self, other_engine, other_variation=None):
self.compare_engine = (other_engine, other_variation)
def set_engine_name(self, engine_name):
self.engine_name = engine_name
def set_test_name_suffix(self, suffix):
self.test_name_suffix = suffix
def run(self, dirpath, blender, arguments_cb, batch=False, fail_silently=False):
# Run tests and output report.
dirname = os.path.basename(dirpath)
ok = self._run_all_tests(dirname, dirpath, blender, arguments_cb, batch, fail_silently)
self._write_data(dirname)
self._write_html()
if self.compare_engine:
self._write_html(comparison=True)
return ok
def _write_data(self, dirname):
# Write intermediate data for single test.
outdir = os.path.join(self.output_dir, dirname)
os.makedirs(outdir, exist_ok=True)
filepath = os.path.join(outdir, "failed.data")
pathlib.Path(filepath).write_text(self.failed_tests)
filepath = os.path.join(outdir, "passed.data")
pathlib.Path(filepath).write_text(self.passed_tests)
if self.compare_engine:
filepath = os.path.join(outdir, "compare.data")
pathlib.Path(filepath).write_text(self.compare_tests)
def _navigation_item(self, title, href, active):
if active:
return """<li class="breadcrumb-item active" aria-current="page">%s</li>""" % title
else:
return """<li class="breadcrumb-item"><a href="%s">%s</a></li>""" % (href, title)
def _engine_title(self, engine, variation):
if variation:
return engine.title() + ' ' + variation
else:
return engine.title()
def _engine_path(self, path, variation):
if variation:
variation = variation.replace(' ', '_')
return os.path.join(path, variation.lower())
else:
return path
def _navigation_html(self, comparison):
html = """<nav aria-label="breadcrumb"><ol class="breadcrumb">"""
base_path = os.path.relpath(self.global_dir, self.output_dir)
global_report_path = os.path.join(base_path, "report.html")
html += self._navigation_item("Test Reports", global_report_path, False)
html += self._navigation_item(self.title, "report.html", not comparison)
if self.compare_engine:
compare_title = "Compare with %s" % self._engine_title(*self.compare_engine)
html += self._navigation_item(compare_title, "compare.html", comparison)
html += """</ol></nav>"""
return html
def _write_html(self, comparison=False):
# Gather intermediate data for all tests.
if comparison:
failed_data = []
passed_data = sorted(glob.glob(os.path.join(self.output_dir, "*/compare.data")))
else:
failed_data = sorted(glob.glob(os.path.join(self.output_dir, "*/failed.data")))
passed_data = sorted(glob.glob(os.path.join(self.output_dir, "*/passed.data")))
failed_tests = ""
passed_tests = ""
for filename in failed_data:
filepath = os.path.join(self.output_dir, filename)
failed_tests += pathlib.Path(filepath).read_text()
for filename in passed_data:
filepath = os.path.join(self.output_dir, filename)
passed_tests += pathlib.Path(filepath).read_text()
tests_html = failed_tests + passed_tests
# Write html for all tests.
if self.pixelated:
image_rendering = 'pixelated'
else:
image_rendering = 'auto'
# Navigation
menu = self._navigation_html(comparison)
failed = len(failed_tests) > 0
if failed:
message = """<div class="alert alert-danger" role="alert">"""
message += """<p>Run this command to regenerate reference (ground truth) images:</p>"""
message += """<p><tt>BLENDER_TEST_UPDATE=1 ctest -R %s</tt></p>""" % self.engine_name
message += """<p>This then happens for new and failing tests; reference images of """ \
"""passing test cases will not be updated. Be sure to commit the new reference """ \
"""images under the tests/files folder afterwards.</p>"""
message += """</div>"""
else:
message = ""
if comparison:
title = self.title + " Test Compare"
engine_self = self.title
engine_other = self._engine_title(*self.compare_engine)
columns_html = "<tr><th>Name</th><th>%s</th><th>%s</th>" % (engine_self, engine_other)
else:
title = self.title + " Test Report"
columns_html = "<tr><th>Name</th><th>New</th><th>Reference</th><th>Diff Color</th><th>Diff Alpha</th>"
html = f"""
<html>
<head>
<title>{title}</title>
<style>
div.page_container {{
text-align: center;
}}
div.page_container div {{
text-align: left;
}}
div.page_content {{
display: inline-block;
}}
img {{ image-rendering: {image_rendering}; width: 256px; background-color: #000; }}
img.render {{
background-color: #fff;
background-image:
-moz-linear-gradient(45deg, #eee 25%, transparent 25%),
-moz-linear-gradient(-45deg, #eee 25%, transparent 25%),
-moz-linear-gradient(45deg, transparent 75%, #eee 75%),
-moz-linear-gradient(-45deg, transparent 75%, #eee 75%);
background-image:
-webkit-gradient(linear, 0 100%, 100% 0, color-stop(.25, #eee), color-stop(.25, transparent)),
-webkit-gradient(linear, 0 0, 100% 100%, color-stop(.25, #eee), color-stop(.25, transparent)),
-webkit-gradient(linear, 0 100%, 100% 0, color-stop(.75, transparent), color-stop(.75, #eee)),
-webkit-gradient(linear, 0 0, 100% 100%, color-stop(.75, transparent), color-stop(.75, #eee));
-moz-background-size:50px 50px;
background-size:50px 50px;
-webkit-background-size:50px 51px; /* Override value for silly webkit. */
background-position:0 0, 25px 0, 25px -25px, 0px 25px;
}}
table td:first-child {{ width: 256px; }}
p {{ margin-bottom: 0.5rem; }}
</style>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
</head>
<body>
<div class="page_container"><div class="page_content">
<br/>
<h1>{title}</h1>
{menu}
{message}
<table class="table table-striped">
<thead class="thead-dark">
{columns_html}
</thead>
{tests_html}
</table>
<br/>
</div></div>
</body>
</html>
"""
filename = "report.html" if not comparison else "compare.html"
filepath = os.path.join(self.output_dir, filename)
pathlib.Path(filepath).write_text(html)
print_message("Report saved to: " + pathlib.Path(filepath).as_uri())
# Update global report
if not comparison:
global_failed = failed if not comparison else None
global_report.add(self.global_dir, "Render", self.title, filepath, global_failed)
def _relative_url(self, filepath):
relpath = os.path.relpath(filepath, self.output_dir)
return pathlib.Path(relpath).as_posix()
def _write_test_html(self, test_category, test_result):
name = test_result.name + self.test_name_suffix
status = "<strong>" + test_result.error + "</strong><br>" if test_result.error else ""
if test_result.stats:
status += "<i>" + "<br>".join(test_result.stats.splitlines()) + "</i>"
tr_style = """ class="table-danger" """ if test_result.error else ""
new_url = self._relative_url(test_result.new_img)
ref_url = self._relative_url(test_result.ref_img)
diff_color_url = self._relative_url(test_result.diff_color_img)
diff_alpha_url = self._relative_url(test_result.diff_alpha_img)
test_html = f"""
<tr{tr_style}>
<td><b>{name}</b><br/>{test_category}<br/>{status}</td>
<td><img src="{new_url}" onmouseover="this.src='{ref_url}';" onmouseout="this.src='{new_url}';" class="render"></td>
<td><img src="{ref_url}" onmouseover="this.src='{new_url}';" onmouseout="this.src='{ref_url}';" class="render"></td>
<td><img src="{diff_color_url}"></td>
<td><img src="{diff_alpha_url}"></td>
</tr>"""
if test_result.error:
self.failed_tests += test_html
else:
self.passed_tests += test_html
if self.compare_engine:
base_path = os.path.relpath(self.global_dir, self.output_dir)
ref_url = os.path.join(base_path, self._engine_path(*self.compare_engine), new_url)
test_html = """
<tr{tr_style}>
<td><b>{name}</b><br/>{testname}<br/>{status}</td>
<td><img src="{new_url}" onmouseover="this.src='{ref_url}';" onmouseout="this.src='{new_url}';" class="render"></td>
<td><img src="{ref_url}" onmouseover="this.src='{new_url}';" onmouseout="this.src='{ref_url}';" class="render"></td>
</tr>""" . format(tr_style=tr_style,
name=name,
testname=test_result.name,
status=status,
new_url=new_url,
ref_url=ref_url)
self.compare_tests += test_html
def _get_render_arguments(self, arguments_cb, filepath, base_output_filepath):
# Each render test can override this method to provide extra functionality.
# See Cycles render tests for an example.
# Do not delete.
return arguments_cb(filepath, base_output_filepath)
def _get_arguments_suffix(self):
# Get command line arguments that need to be provided after all file-specific ones.
# For example the Cycles render device argument needs to be added at the end of
# the argument list, otherwise tests can't be batched together.
#
# Each render test is supposed to override this method.
return []
def _get_filepath_tests(self, filepath):
list_filepath = filepath.replace('.blend', '_permutations.txt')
if os.path.exists(list_filepath):
with open(list_filepath, 'r') as file:
return [TestResult(self, filepath, testname.rstrip('\n')) for testname in file]
else:
testname = test_get_name(filepath)
return [TestResult(self, filepath, testname)]
def _run_tests(self, filepaths, blender, arguments_cb, batch):
# Run multiple tests in a single Blender process since startup can be
# a significant factor. In case of crashes, re-run the remaining tests.
verbose = os.environ.get("BLENDER_VERBOSE") is not None
remaining_filepaths = filepaths[:]
test_results = []
arguments_suffix = self._get_arguments_suffix()
while len(remaining_filepaths) > 0:
command = [blender]
running_tests = []
# On Windows, there is a maximum length of 32,767 characters (including the terminating null character)
# for process command line commands, see:
# https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-createprocessa
command_line_length = len(blender)
for suffix in arguments_suffix:
# Add 3 for taking into account spaces and quotation marks potentially added by Python.
command_line_length += len(suffix) + 3
# Construct output filepaths and command to run
for filepath in remaining_filepaths:
testname = test_get_name(filepath)
base_output_filepath = os.path.join(self.output_dir, "tmp_" + testname)
command_filepath = self._get_render_arguments(arguments_cb, filepath, base_output_filepath)
# Check if we have surpassed the command line limit.
for cmd in command_filepath:
command_line_length += len(cmd) + 3
if sys.platform == 'win32' and command_line_length > 32766 and len(running_tests) > 0:
break
print_message(testname, 'SUCCESS', 'RUN')
running_tests.append(filepath)
command.extend(command_filepath)
output_filepath = base_output_filepath + '0001.png'
if os.path.exists(output_filepath):
os.remove(output_filepath)
# Only chain multiple commands for batch
if not batch:
break
command.extend(arguments_suffix)
# Run process
crash = False
output = None
try:
completed_process = subprocess.run(command, stdout=subprocess.PIPE)
if completed_process.returncode != 0:
crash = True
output = completed_process.stdout
except Exception:
crash = True
if verbose:
def quote_expr_args(cmd):
quoted = []
quote_next = False
for arg in cmd:
if quote_next:
quoted.append('"{}"'.format(arg)) # wrap the expression in quotes
quote_next = False
else:
quoted.append(arg)
if arg == "--python-expr":
quote_next = True
return quoted
print(' '.join(quote_expr_args(command)))
if (verbose or crash) and output:
print(output.decode("utf-8", 'ignore'))
tests_to_check = []
# Detect missing filepaths and consider those errors
for filepath in running_tests:
remaining_filepaths.pop(0)
file_crashed = False
for test in self._get_filepath_tests(filepath):
self.postprocess_test(blender, test)
if not os.path.exists(test.tmp_out_img) or os.path.getsize(test.tmp_out_img) == 0:
if crash:
# In case of crash, stop after missing files and re-render remaining
test.error = "CRASH"
test_results.append(test)
file_crashed = True
break
else:
test.error = "NO OUTPUT"
test_results.append(test)
else:
tests_to_check.append(test)
if file_crashed:
break
pool = multiprocessing.Pool(multiprocessing.cpu_count())
test_results.extend(pool.starmap(diff_output,
[(test, self.oiiotool, self.fail_threshold, self.fail_percent, self.verbose, self.update)
for test in tests_to_check]))
pool.close()
for test in test_results:
if test.error == "CRASH":
print_message("Crash running Blender")
print_message(test.name, 'FAILURE', 'FAILED')
elif test.error == "NO OUTPUT":
print_message("No render result file found")
print_message(test.tmp_out_img, 'FAILURE', 'FAILED')
elif test.error == "VERIFY":
print_message("Render result is different from reference image")
print_message(test.name, 'FAILURE', 'FAILED')
else:
print_message(test.name, 'SUCCESS', 'OK')
if os.path.exists(test.tmp_out_img):
os.remove(test.tmp_out_img)
return test_results
def postprocess_test(self, blender, test):
"""
Post-process test result after the Blender has run.
For example, this function is where conversion from video to a still image suitable for image diffing.
"""
pass
def _run_all_tests(self, dirname, dirpath, blender, arguments_cb, batch, fail_silently):
if self.filter:
print_message(f"Note: Blender Test filter = {self.filter}", type='WARNING', status="RAW")
passed_tests = []
failed_tests = []
silently_failed_tests = []
all_files = list(blend_list(dirpath, self.blocklist, self.filter))
all_files.sort()
if not list(blend_list(dirpath, [], "")):
print_message("No .blend files found in '{}'!".format(dirpath), 'FAILURE', 'FAILED')
return False
print_message("Running {} tests from 1 test case." .
format(len(all_files)),
'SUCCESS', "==========")
time_start = time.time()
test_results = self._run_tests(all_files, blender, arguments_cb, batch)
for test in test_results:
if test.error:
if test.error == "NO_ENGINE":
return False
elif test.error == "NO_START":
return False
if fail_silently and test.error != 'CRASH':
silently_failed_tests.append(test.name)
else:
failed_tests.append(test.name)
else:
passed_tests.append(test.name)
self._write_test_html(dirname, test)
time_end = time.time()
elapsed_ms = int((time_end - time_start) * 1000)
print_message("")
print_message("{} tests from 1 test case ran. ({} ms total)" .
format(len(all_files), elapsed_ms),
'SUCCESS', "==========")
print_message("{} tests." .
format(len(passed_tests)),
'SUCCESS', 'PASSED')
all_failed_tests = silently_failed_tests + failed_tests
if all_failed_tests:
print_message("{} tests, listed below:" .
format(len(all_failed_tests)),
'FAILURE', 'FAILED')
all_failed_tests.sort()
for test in all_failed_tests:
print_message("{}" . format(test), 'FAILURE', "FAILED")
return not bool(failed_tests)

View File

@@ -0,0 +1,85 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2018-2022 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
import functools
import shutil
import pathlib
import subprocess
import tempfile
import unittest
def with_tempdir(wrapped):
"""Creates a temporary directory for the function, cleaning up after it returns normally.
When the wrapped function raises an exception, the contents of the temporary directory
remain available for manual inspection.
The wrapped function is called with an extra positional argument containing
the pathlib.Path() of the temporary directory.
"""
@functools.wraps(wrapped)
def decorator(*args, **kwargs):
dirname = tempfile.mkdtemp(prefix='blender-alembic-test')
try:
retval = wrapped(*args, pathlib.Path(dirname), **kwargs)
except:
print('Exception in %s, not cleaning up temporary directory %s' % (wrapped, dirname))
raise
else:
shutil.rmtree(dirname)
return retval
return decorator
class AbstractBlenderRunnerTest(unittest.TestCase):
"""Base class for all test suites which needs to run Blender"""
# Set in a subclass
blender: pathlib.Path = None
testdir: pathlib.Path = None
def run_blender(self, filepath: str, python_script: str, timeout: int = 300) -> str:
"""Runs Blender by opening a blendfile and executing a script.
Returns Blender's stdout + stderr combined into one string.
:param filepath: taken relative to self.testdir.
:param timeout: in seconds
"""
assert self.blender, "Path to Blender binary is to be set in setUpClass()"
assert self.testdir, "Path to tests binary is to be set in setUpClass()"
blendfile = self.testdir / filepath if filepath else ""
command = [
self.blender,
'--background',
'--factory-startup',
'--enable-autoexec',
'--debug-memory',
'--console-crash-handler',
'--debug-exit-on-error',
]
if blendfile:
command.append(str(blendfile))
command.extend([
'--python-exit-code', '47',
'--python-expr', python_script,
]
)
proc = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
timeout=timeout)
output = proc.stdout.decode('utf8')
if proc.returncode:
self.fail('Error %d running Blender:\n%s' % (proc.returncode, output))
return output