Add Chromium-only Blender WebEngine parity work
This commit is contained in:
57
blender-5.2.0/tests/python/modules/colored_print.py
Normal file
57
blender-5.2.0/tests/python/modules/colored_print.py
Normal 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()
|
||||
82
blender-5.2.0/tests/python/modules/global_report.py
Executable file
82
blender-5.2.0/tests/python/modules/global_report.py
Executable 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}">⬤</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)
|
||||
29
blender-5.2.0/tests/python/modules/gpu_info.py
Normal file
29
blender-5.2.0/tests/python/modules/gpu_info.py
Normal 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)
|
||||
101
blender-5.2.0/tests/python/modules/imbuf_test.py
Normal file
101
blender-5.2.0/tests/python/modules/imbuf_test.py
Normal 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
|
||||
975
blender-5.2.0/tests/python/modules/io_report.py
Normal file
975
blender-5.2.0/tests/python/modules/io_report.py
Normal 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 = " "
|
||||
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
|
||||
1000
blender-5.2.0/tests/python/modules/mesh_test.py
Normal file
1000
blender-5.2.0/tests/python/modules/mesh_test.py
Normal file
File diff suppressed because it is too large
Load Diff
764
blender-5.2.0/tests/python/modules/render_report.py
Normal file
764
blender-5.2.0/tests/python/modules/render_report.py
Normal 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)
|
||||
85
blender-5.2.0/tests/python/modules/test_utils.py
Executable file
85
blender-5.2.0/tests/python/modules/test_utils.py
Executable 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
|
||||
Reference in New Issue
Block a user