Test/Tools: Add Coin node snapshot visual baseline testing
Add a FreeCADCmd-runnable unittest that renders selected Coin nodes offscreen to PNG and (optionally) compares against baselines with tolerance + diff artifacts. Also add helper scripts to generate node snapshots (GUI) and to update/compare baselines by driving the unittest via environment variables.
@@ -18,6 +18,7 @@ SET(Test_SRCS
|
||||
TestPythonSyntax.py
|
||||
TestPerf.py
|
||||
TestTreeSelection.py
|
||||
TestCoinNodeSnapshots.py
|
||||
)
|
||||
|
||||
SET(TestData_SRCS
|
||||
@@ -42,6 +43,68 @@ ADD_CUSTOM_TARGET(Test ALL
|
||||
|
||||
fc_copy_sources(Test "${CMAKE_BINARY_DIR}/Mod/Test" ${Test_SRCS} ${TestData_SRCS})
|
||||
|
||||
# Coin node snapshot visual test baselines (Python unittest `TestCoinNodeSnapshots`).
|
||||
# For out-of-source builds, copy baselines into the build tree so the test can find them
|
||||
# without requiring FC_VISUAL_BASELINE_DIR to be set.
|
||||
set(_coin_node_snapshot_baseline_dir "${CMAKE_SOURCE_DIR}/tests/visual/baselines/coin-nodes")
|
||||
if(EXISTS "${_coin_node_snapshot_baseline_dir}")
|
||||
file(
|
||||
GLOB
|
||||
TestCoinNodeSnapshotBaselines
|
||||
CONFIGURE_DEPENDS
|
||||
"${_coin_node_snapshot_baseline_dir}/*.png"
|
||||
)
|
||||
if(TestCoinNodeSnapshotBaselines)
|
||||
set(_coin_node_snapshot_baseline_outdir "${CMAKE_BINARY_DIR}/tests/visual/baselines/coin-nodes")
|
||||
set(_coin_node_snapshot_baseline_outputs)
|
||||
foreach(_baseline ${TestCoinNodeSnapshotBaselines})
|
||||
get_filename_component(_baseline_name "${_baseline}" NAME)
|
||||
set(_baseline_out "${_coin_node_snapshot_baseline_outdir}/${_baseline_name}")
|
||||
add_custom_command(
|
||||
OUTPUT "${_baseline_out}"
|
||||
COMMAND "${CMAKE_COMMAND}" -E make_directory "${_coin_node_snapshot_baseline_outdir}"
|
||||
COMMAND "${CMAKE_COMMAND}" -E copy "${_baseline}" "${_baseline_out}"
|
||||
DEPENDS "${_baseline}"
|
||||
COMMENT "Copying ${_baseline} to ${_baseline_out}"
|
||||
)
|
||||
list(APPEND _coin_node_snapshot_baseline_outputs "${_baseline_out}")
|
||||
endforeach()
|
||||
add_custom_target(TestCoinNodeSnapshotBaselines ALL DEPENDS ${_coin_node_snapshot_baseline_outputs})
|
||||
add_dependencies(Test TestCoinNodeSnapshotBaselines)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
# Vendored fonts used by visual snapshot tests.
|
||||
# For out-of-source builds, copy them into the build tree so the test can find them
|
||||
# without relying on host-installed fonts.
|
||||
set(_coin_node_snapshot_font_dir "${CMAKE_SOURCE_DIR}/tests/visual/fonts")
|
||||
if(EXISTS "${_coin_node_snapshot_font_dir}")
|
||||
file(
|
||||
GLOB
|
||||
TestCoinNodeSnapshotFonts
|
||||
CONFIGURE_DEPENDS
|
||||
"${_coin_node_snapshot_font_dir}/*"
|
||||
)
|
||||
if(TestCoinNodeSnapshotFonts)
|
||||
set(_coin_node_snapshot_font_outdir "${CMAKE_BINARY_DIR}/tests/visual/fonts")
|
||||
set(_coin_node_snapshot_font_outputs)
|
||||
foreach(_font_file ${TestCoinNodeSnapshotFonts})
|
||||
get_filename_component(_font_name "${_font_file}" NAME)
|
||||
set(_font_out "${_coin_node_snapshot_font_outdir}/${_font_name}")
|
||||
add_custom_command(
|
||||
OUTPUT "${_font_out}"
|
||||
COMMAND "${CMAKE_COMMAND}" -E make_directory "${_coin_node_snapshot_font_outdir}"
|
||||
COMMAND "${CMAKE_COMMAND}" -E copy "${_font_file}" "${_font_out}"
|
||||
DEPENDS "${_font_file}"
|
||||
COMMENT "Copying ${_font_file} to ${_font_out}"
|
||||
)
|
||||
list(APPEND _coin_node_snapshot_font_outputs "${_font_out}")
|
||||
endforeach()
|
||||
add_custom_target(TestCoinNodeSnapshotFonts ALL DEPENDS ${_coin_node_snapshot_font_outputs})
|
||||
add_dependencies(Test TestCoinNodeSnapshotFonts)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
INSTALL(
|
||||
FILES
|
||||
${Test_SRCS}
|
||||
|
||||
@@ -1,35 +1,36 @@
|
||||
# ***************************************************************************
|
||||
# * Copyright (c) 2001,2002 Juergen Riegel <juergen.riegel@web.de> *
|
||||
# * *
|
||||
# * This file is part of the FreeCAD CAx development system. *
|
||||
# * *
|
||||
# * This program is free software; you can redistribute it and/or modify *
|
||||
# * it under the terms of the GNU Lesser General Public License (LGPL) *
|
||||
# * as published by the Free Software Foundation; either version 2 of *
|
||||
# * the License, or (at your option) any later version. *
|
||||
# * for detail see the LICENCE text file. *
|
||||
# * *
|
||||
# * FreeCAD is distributed in the hope that it will be useful, *
|
||||
# * but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||
# * GNU Lesser General Public License for more details. *
|
||||
# * *
|
||||
# * You should have received a copy of the GNU Library General Public *
|
||||
# * License along with FreeCAD; if not, write to the Free Software *
|
||||
# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 *
|
||||
# * USA *
|
||||
# * *
|
||||
# ***************************************************************************/
|
||||
|
||||
# FreeCAD init script of the test module
|
||||
|
||||
# Base system tests
|
||||
FreeCAD.__unit_test__ += [
|
||||
"BaseTests",
|
||||
"UnitTests",
|
||||
"Document",
|
||||
"Metadata",
|
||||
"StringHasher",
|
||||
"UnicodeTests",
|
||||
"TestPythonSyntax",
|
||||
]
|
||||
# ***************************************************************************
|
||||
# * Copyright (c) 2001,2002 Juergen Riegel <juergen.riegel@web.de> *
|
||||
# * *
|
||||
# * This file is part of the FreeCAD CAx development system. *
|
||||
# * *
|
||||
# * This program is free software; you can redistribute it and/or modify *
|
||||
# * it under the terms of the GNU Lesser General Public License (LGPL) *
|
||||
# * as published by the Free Software Foundation; either version 2 of *
|
||||
# * the License, or (at your option) any later version. *
|
||||
# * for detail see the LICENCE text file. *
|
||||
# * *
|
||||
# * FreeCAD is distributed in the hope that it will be useful, *
|
||||
# * but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||
# * GNU Lesser General Public License for more details. *
|
||||
# * *
|
||||
# * You should have received a copy of the GNU Library General Public *
|
||||
# * License along with FreeCAD; if not, write to the Free Software *
|
||||
# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 *
|
||||
# * USA *
|
||||
# * *
|
||||
# ***************************************************************************/
|
||||
|
||||
# FreeCAD init script of the test module
|
||||
|
||||
# Base system tests
|
||||
FreeCAD.__unit_test__ += [
|
||||
"BaseTests",
|
||||
"UnitTests",
|
||||
"Document",
|
||||
"Metadata",
|
||||
"StringHasher",
|
||||
"UnicodeTests",
|
||||
"TestPythonSyntax",
|
||||
"TestCoinNodeSnapshots",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,1214 @@
|
||||
# SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
|
||||
"""
|
||||
Visual snapshot tests for selected Coin/Inventor nodes.
|
||||
|
||||
This test renders a curated set of nodes offscreen and optionally compares the
|
||||
resulting PNGs against checked-in baselines.
|
||||
|
||||
The test is intended to be executed via FreeCAD's test runner:
|
||||
|
||||
FreeCAD -t TestCoinNodeSnapshots
|
||||
|
||||
Environment variables:
|
||||
- FC_VISUAL_OUT_DIR: output directory (writes actual/expected/diff)
|
||||
- FC_VISUAL_BASELINE_DIR: baseline directory override (default: tests/visual/baselines/coin-nodes)
|
||||
- FC_VISUAL_UPDATE_BASELINE: if truthy, overwrite baselines with actual renders
|
||||
- FC_VISUAL_WIDTH / FC_VISUAL_HEIGHT: render size (default: 512x512)
|
||||
- FC_VISUAL_TOLERANCE: per-channel tolerance (default: 8)
|
||||
- FC_VISUAL_MAX_MISMATCH_PCT: mismatch threshold percent (default: 0.20)
|
||||
- FC_VISUAL_IGNORE_ALPHA: ignore alpha differences (default: 1)
|
||||
- FC_VISUAL_NODES: optional comma-separated node list to run
|
||||
"""
|
||||
|
||||
# This file is intentionally tolerant of missing optional bindings and older
|
||||
# FreeCAD/Coin/Pivy environments; it is a test harness rather than library code.
|
||||
# pylint: disable=import-outside-toplevel,broad-exception-caught,deprecated-module
|
||||
# pylint: disable=too-many-branches,too-many-statements,too-many-locals
|
||||
# pylint: disable=too-many-return-statements,too-many-arguments,too-many-positional-arguments
|
||||
|
||||
import importlib
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_BASELINE_REL = Path("tests") / "visual" / "baselines" / "coin-nodes"
|
||||
_FONT_REL = Path("tests") / "visual" / "fonts"
|
||||
_DEFAULT_FONT_FAMILY = "Noto Sans"
|
||||
_DEFAULT_FONT_FILES = ("NotoSans-Regular.ttf",)
|
||||
_DEFAULT_FONT_SIZE = 18
|
||||
|
||||
|
||||
def _repo_root() -> Path | None:
|
||||
here = Path(__file__).resolve()
|
||||
for parent in here.parents:
|
||||
if (parent / ".git").exists():
|
||||
return parent
|
||||
return None
|
||||
|
||||
|
||||
def _find_repo_baseline_dir() -> Path | None:
|
||||
repo_root = _repo_root()
|
||||
if repo_root is not None:
|
||||
candidate = repo_root / _BASELINE_REL
|
||||
if candidate.is_dir() and any(candidate.glob("*.png")):
|
||||
return candidate
|
||||
|
||||
here = Path(__file__).resolve()
|
||||
for parent in here.parents:
|
||||
candidate = parent / _BASELINE_REL
|
||||
if candidate.is_dir() and any(candidate.glob("*.png")):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _baseline_dir(create: bool = False) -> Path:
|
||||
baseline_dir_env = os.environ.get("FC_VISUAL_BASELINE_DIR", "").strip()
|
||||
if baseline_dir_env:
|
||||
p = Path(baseline_dir_env)
|
||||
if create:
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
return p
|
||||
|
||||
found = _find_repo_baseline_dir()
|
||||
if found is not None:
|
||||
if create:
|
||||
found.mkdir(parents=True, exist_ok=True)
|
||||
return found
|
||||
|
||||
raise FileNotFoundError(
|
||||
f"baseline directory not found (expected {_BASELINE_REL}; set FC_VISUAL_BASELINE_DIR to override)"
|
||||
)
|
||||
|
||||
|
||||
def _find_repo_font_dir() -> Path | None:
|
||||
repo_root = _repo_root()
|
||||
if repo_root is not None:
|
||||
candidate = repo_root / _FONT_REL
|
||||
if candidate.is_dir():
|
||||
return candidate
|
||||
|
||||
here = Path(__file__).resolve()
|
||||
for parent in here.parents:
|
||||
candidate = parent / _FONT_REL
|
||||
if candidate.is_dir():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _font_dir() -> Path:
|
||||
found = _find_repo_font_dir()
|
||||
if found is not None:
|
||||
return found
|
||||
|
||||
raise FileNotFoundError(f"font directory not found (expected {_FONT_REL})")
|
||||
|
||||
|
||||
def _font_files() -> list[Path]:
|
||||
base = _font_dir()
|
||||
return [base / n for n in _DEFAULT_FONT_FILES]
|
||||
|
||||
|
||||
def _configure_fontconfig_for_visual_fonts(font_dir: Path):
|
||||
if not sys.platform.startswith("linux"):
|
||||
return
|
||||
|
||||
tmp = Path(tempfile.mkdtemp(prefix="freecad-visual-fontconfig-"))
|
||||
conf = tmp / "fonts.conf"
|
||||
family = _DEFAULT_FONT_FAMILY
|
||||
conf.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
'<?xml version="1.0"?>',
|
||||
"<fontconfig>",
|
||||
f" <dir>{font_dir.as_posix()}</dir>",
|
||||
# Keep caches in a per-run location to avoid host-dependent results.
|
||||
f" <cachedir>{tmp.as_posix()}/cache</cachedir>",
|
||||
" <config>",
|
||||
" <rescan><int>0</int></rescan>",
|
||||
" </config>",
|
||||
" <alias>",
|
||||
" <family>sans-serif</family>",
|
||||
f" <prefer><family>{family}</family></prefer>",
|
||||
" </alias>",
|
||||
"</fontconfig>",
|
||||
"",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
os.environ["FONTCONFIG_FILE"] = str(conf)
|
||||
os.environ["FONTCONFIG_PATH"] = str(tmp)
|
||||
|
||||
|
||||
def _register_visual_fonts():
|
||||
font_dir = _font_dir()
|
||||
font_files = _font_files()
|
||||
missing = [p for p in font_files if not p.is_file()]
|
||||
if missing:
|
||||
raise FileNotFoundError(f"missing font file(s): {', '.join(str(p) for p in missing)}")
|
||||
|
||||
_configure_fontconfig_for_visual_fonts(font_dir)
|
||||
|
||||
# Register for Qt text rendering (e.g. SoStringLabel uses QPainter).
|
||||
from PySide import QtGui # type: ignore
|
||||
|
||||
for p in font_files:
|
||||
QtGui.QFontDatabase.addApplicationFont(str(p))
|
||||
|
||||
# Register for native font backends (important on Windows, where FreeCAD forces Coin
|
||||
# not to use FreeType/fontconfig).
|
||||
if sys.platform.startswith("win"):
|
||||
import ctypes
|
||||
|
||||
FR_PRIVATE = 0x10
|
||||
gdi32 = ctypes.windll.gdi32 # type: ignore[attr-defined]
|
||||
for p in font_files:
|
||||
gdi32.AddFontResourceExW(str(p), FR_PRIVATE, 0)
|
||||
|
||||
elif sys.platform == "darwin":
|
||||
import ctypes
|
||||
import ctypes.util
|
||||
|
||||
coretext = ctypes.CDLL(ctypes.util.find_library("CoreText")) # type: ignore[arg-type]
|
||||
cf = ctypes.CDLL(ctypes.util.find_library("CoreFoundation")) # type: ignore[arg-type]
|
||||
|
||||
CFURLRef = ctypes.c_void_p
|
||||
CFAllocatorRef = ctypes.c_void_p
|
||||
CFErrorRef = ctypes.c_void_p
|
||||
Boolean = ctypes.c_ubyte
|
||||
|
||||
CFURLCreateFromFileSystemRepresentation = cf.CFURLCreateFromFileSystemRepresentation
|
||||
CFURLCreateFromFileSystemRepresentation.argtypes = [
|
||||
CFAllocatorRef,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_long,
|
||||
Boolean,
|
||||
]
|
||||
CFURLCreateFromFileSystemRepresentation.restype = CFURLRef
|
||||
|
||||
CFRelease = cf.CFRelease
|
||||
CFRelease.argtypes = [ctypes.c_void_p]
|
||||
CFRelease.restype = None
|
||||
|
||||
CTFontManagerRegisterFontsForURL = coretext.CTFontManagerRegisterFontsForURL
|
||||
CTFontManagerRegisterFontsForURL.argtypes = [
|
||||
CFURLRef,
|
||||
ctypes.c_uint32,
|
||||
ctypes.POINTER(CFErrorRef),
|
||||
]
|
||||
CTFontManagerRegisterFontsForURL.restype = Boolean
|
||||
|
||||
kCTFontManagerScopeProcess = 1
|
||||
for p in font_files:
|
||||
b = str(p).encode("utf-8")
|
||||
url = CFURLCreateFromFileSystemRepresentation(0, b, len(b), 0)
|
||||
if url:
|
||||
err = CFErrorRef()
|
||||
CTFontManagerRegisterFontsForURL(url, kCTFontManagerScopeProcess, ctypes.byref(err))
|
||||
CFRelease(url)
|
||||
|
||||
|
||||
def _configure_software_gl_for_snapshots():
|
||||
if not sys.platform.startswith("linux"):
|
||||
return
|
||||
|
||||
force = os.environ.get("FC_VISUAL_FORCE_SOFTWARE_GL", "1").strip().lower()
|
||||
if force in ("", "0", "false", "no"):
|
||||
return
|
||||
|
||||
# Make renders deterministic across hosts: software llvmpipe avoids GPU driver differences.
|
||||
os.environ.setdefault("LIBGL_ALWAYS_SOFTWARE", "1")
|
||||
os.environ.setdefault("MESA_LOADER_DRIVER_OVERRIDE", "llvmpipe")
|
||||
|
||||
|
||||
def _require_gui():
|
||||
try:
|
||||
import FreeCAD # type: ignore
|
||||
except Exception as exc: # pragma: no cover
|
||||
raise RuntimeError("FreeCAD module not available") from exc
|
||||
|
||||
_configure_software_gl_for_snapshots()
|
||||
|
||||
# `FreeCADCmd -t 0` runs the base test suite without X11/Wayland. Creating a Qt application
|
||||
# in that environment can abort the whole process (instead of raising a Python exception).
|
||||
# Skip early unless a display (or explicit headless Qt platform) is configured.
|
||||
if sys.platform.startswith("linux"):
|
||||
has_display = bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
|
||||
qpa = os.environ.get("QT_QPA_PLATFORM", "").strip().lower()
|
||||
headless_qpa = {"offscreen", "minimal", "eglfs", "linuxfb", "vnc"}
|
||||
if not has_display and qpa not in headless_qpa:
|
||||
raise unittest.SkipTest(
|
||||
"No display available (run under xvfb-run or set QT_QPA_PLATFORM=offscreen)"
|
||||
)
|
||||
|
||||
try:
|
||||
import FreeCADGui # type: ignore
|
||||
except Exception as exc: # pragma: no cover
|
||||
raise unittest.SkipTest("FreeCADGui not available in this build") from exc
|
||||
|
||||
# Ensure the GUI subsystem is initialized enough for Coin + offscreen GL.
|
||||
# Older setups may not provide this; offscreen rendering can still work.
|
||||
setup_without_gui = getattr(FreeCADGui, "setupWithoutGUI", None)
|
||||
if callable(setup_without_gui):
|
||||
setup_without_gui()
|
||||
|
||||
# Ensure built-in modules (e.g. Part/PartGui) are importable even when the test runner
|
||||
# didn't pick up the build's Mod/ paths.
|
||||
home = Path(FreeCAD.getHomePath())
|
||||
mod_dir = home / "Mod"
|
||||
if mod_dir.is_dir():
|
||||
for p in (mod_dir, mod_dir / "Part"):
|
||||
ps = str(p)
|
||||
if ps not in sys.path:
|
||||
sys.path.insert(0, ps)
|
||||
|
||||
# Ensure a QGuiApplication exists so an OpenGL context can be created.
|
||||
try:
|
||||
from PySide import QtGui # type: ignore
|
||||
|
||||
if QtGui.QGuiApplication.instance() is None:
|
||||
QtGui.QGuiApplication(sys.argv)
|
||||
except Exception as exc: # pragma: no cover
|
||||
raise unittest.SkipTest("Qt (PySide) not available") from exc
|
||||
|
||||
# Register vendored fonts before importing Coin, so Coin's font backend can discover them.
|
||||
try:
|
||||
_register_visual_fonts()
|
||||
except Exception as exc: # pragma: no cover
|
||||
raise unittest.SkipTest(f"visual test font setup failed: {exc}") from exc
|
||||
|
||||
try:
|
||||
from pivy import coin # type: ignore
|
||||
except Exception as exc: # pragma: no cover
|
||||
raise unittest.SkipTest("pivy.coin not available") from exc
|
||||
|
||||
return FreeCAD, FreeCADGui, coin
|
||||
|
||||
|
||||
def _instantiate(coin, type_name: str):
|
||||
t = coin.SoType.fromName(type_name)
|
||||
if t.isBad():
|
||||
raise unittest.SkipTest(f"Coin type not registered: {type_name}")
|
||||
node = t.createInstance()
|
||||
if node is None:
|
||||
raise RuntimeError(f"Failed to instantiate {type_name}")
|
||||
return node
|
||||
|
||||
|
||||
def _make_scene_for_node(coin, type_name: str):
|
||||
root = coin.SoSeparator()
|
||||
|
||||
cam = coin.SoOrthographicCamera()
|
||||
# Isometric-ish.
|
||||
cam.orientation.setValue(coin.SbRotation(-0.353553, -0.146447, -0.353553, -0.853553))
|
||||
root.addChild(cam)
|
||||
|
||||
# Light + base color model so nodes render consistently.
|
||||
light = coin.SoDirectionalLight()
|
||||
root.addChild(light)
|
||||
|
||||
# Make text rendering less dependent on the host: set an explicit font in the Coin scene.
|
||||
font = coin.SoFont()
|
||||
font.name.setValue(_DEFAULT_FONT_FAMILY)
|
||||
font.size.setValue(float(_DEFAULT_FONT_SIZE))
|
||||
root.addChild(font)
|
||||
|
||||
if type_name == "SoDrawingGrid":
|
||||
material = coin.SoMaterial()
|
||||
material.diffuseColor.setValue(0.7, 0.7, 0.75)
|
||||
cube_trans = coin.SoTranslation()
|
||||
cube_trans.translation.setValue(0.0, 0.0, -0.5)
|
||||
cube = coin.SoCube()
|
||||
cube.width = 1.2
|
||||
cube.height = 1.2
|
||||
cube.depth = 1.2
|
||||
root.addChild(material)
|
||||
root.addChild(cube_trans)
|
||||
root.addChild(cube)
|
||||
root.addChild(_instantiate(coin, "SoDrawingGrid"))
|
||||
return root
|
||||
|
||||
if type_name == "SoRegPoint":
|
||||
material = coin.SoMaterial()
|
||||
material.diffuseColor.setValue(0.7, 0.7, 0.75)
|
||||
cube_trans = coin.SoTranslation()
|
||||
cube_trans.translation.setValue(0.0, 0.0, -0.5)
|
||||
cube = coin.SoCube()
|
||||
cube.width = 1.2
|
||||
cube.height = 1.2
|
||||
cube.depth = 1.2
|
||||
root.addChild(material)
|
||||
root.addChild(cube_trans)
|
||||
root.addChild(cube)
|
||||
probe = _instantiate(coin, "SoRegPoint")
|
||||
probe.base.setValue(0.0, 0.0, 0.0)
|
||||
probe.normal.setValue(0.6, 0.7, 0.4)
|
||||
probe.length.setValue(1.2)
|
||||
probe.color.setValue(1.0, 0.45, 0.34)
|
||||
probe.text.setValue("SoRegPoint")
|
||||
root.addChild(probe)
|
||||
return root
|
||||
|
||||
if type_name == "SoDatumLabel":
|
||||
material = coin.SoMaterial()
|
||||
material.diffuseColor.setValue(0.7, 0.7, 0.75)
|
||||
cube_trans = coin.SoTranslation()
|
||||
cube_trans.translation.setValue(0.0, 0.0, -0.5)
|
||||
cube = coin.SoCube()
|
||||
cube.width = 1.2
|
||||
cube.height = 1.2
|
||||
cube.depth = 1.2
|
||||
root.addChild(material)
|
||||
root.addChild(cube_trans)
|
||||
root.addChild(cube)
|
||||
label = _instantiate(coin, "SoDatumLabel")
|
||||
label.string.setValue("SoDatumLabel")
|
||||
label.textColor.setValue(1.0, 0.45, 0.34)
|
||||
label.name.setValue(_DEFAULT_FONT_FAMILY)
|
||||
label.size.setValue(18)
|
||||
label.lineWidth.setValue(2.0)
|
||||
label.sampling.setValue(2.0)
|
||||
# `createInstance()` returns a generic node proxy (not a typed Python class),
|
||||
# so enum constants / helper methods may not be available.
|
||||
# `Gui::SoDatumLabel::Type::DISTANCE == 1`.
|
||||
label.datumtype.setValue(1)
|
||||
label.param1.setValue(0.25)
|
||||
label.param2.setValue(0.0)
|
||||
label.pnts.setValues(
|
||||
0,
|
||||
2,
|
||||
[coin.SbVec3f(-0.5, -0.1, 0.0), coin.SbVec3f(0.5, 0.2, 0.0)],
|
||||
)
|
||||
root.addChild(label)
|
||||
return root
|
||||
|
||||
if type_name == "SoTextLabel":
|
||||
# Ensure the label isn't white-on-white (SoTextLabel background defaults to white and the
|
||||
# inherited material may also be white depending on state defaults).
|
||||
mat = coin.SoMaterial()
|
||||
mat.diffuseColor.setValue(0.05, 0.05, 0.05)
|
||||
root.addChild(mat)
|
||||
|
||||
label = _instantiate(coin, "SoTextLabel")
|
||||
label.string.setValues(0, 2, ["SoTextLabel", "Coin geometry"])
|
||||
label.background.setValue(True)
|
||||
label.backgroundColor.setValue(0.95, 0.95, 0.85)
|
||||
label.frameSize.setValue(8.0)
|
||||
root.addChild(label)
|
||||
return root
|
||||
|
||||
if type_name == "SoStringLabel":
|
||||
label = _instantiate(coin, "SoStringLabel")
|
||||
label.string.setValue("SoStringLabel")
|
||||
label.name.setValue(_DEFAULT_FONT_FAMILY)
|
||||
label.size.setValue(18)
|
||||
# Default SoStringLabel textColor is white, which can become invisible on the white
|
||||
# snapshot background depending on the GL blending / alpha handling.
|
||||
label.textColor.setValue(0.05, 0.05, 0.05)
|
||||
root.addChild(label)
|
||||
return root
|
||||
|
||||
if type_name == "SoFCBackgroundGradient":
|
||||
grad = _instantiate(coin, "SoFCBackgroundGradient")
|
||||
if hasattr(grad, "setColorGradient"):
|
||||
grad.setColorGradient(
|
||||
coin.SbColor(0.2, 0.2, 0.6),
|
||||
coin.SbColor(0.9, 0.9, 1.0),
|
||||
)
|
||||
root.addChild(grad)
|
||||
return root
|
||||
|
||||
if type_name in ("SoNaviCube", "SoNaviCubeTranslucent", "SoNaviCubeHiliteFront"):
|
||||
if type_name == "SoNaviCubeTranslucent":
|
||||
# Provide visible background so translucency can be verified.
|
||||
grad = _instantiate(coin, "SoFCBackgroundGradient")
|
||||
if hasattr(grad, "setColorGradient"):
|
||||
top = coin.SbColor(0.15, 0.15, 0.20)
|
||||
bottom = coin.SbColor(0.45, 0.45, 0.55)
|
||||
grad.setColorGradient(top, bottom)
|
||||
root.addChild(grad)
|
||||
|
||||
cube = _instantiate(coin, "SoNaviCube")
|
||||
cube.size.setValue(1.0)
|
||||
cube.opacity.setValue(0.55 if type_name == "SoNaviCubeTranslucent" else 1.0)
|
||||
cube.borderWidth.setValue(0.02)
|
||||
cube.showCoordinateSystem.setValue(True)
|
||||
cube.cameraIsOrthographic.setValue(True)
|
||||
if type_name == "SoNaviCubeHiliteFront":
|
||||
# Gui::SoNaviCube::PickId::Front (see `src/Gui/Inventor/SoNaviCube.h`).
|
||||
cube.hiliteId.setValue(1)
|
||||
width = float(int(os.environ.get("FC_VISUAL_WIDTH", "512")))
|
||||
height = float(int(os.environ.get("FC_VISUAL_HEIGHT", "512")))
|
||||
|
||||
# Render like a real overlay: small square in the corner.
|
||||
overlay = max(64.0, min(width, height) * 0.60)
|
||||
margin = 8.0
|
||||
cube.viewportRect.setValue(
|
||||
width - overlay - margin,
|
||||
height - overlay - margin,
|
||||
overlay,
|
||||
overlay,
|
||||
)
|
||||
|
||||
# Mimic what the controller does: orient the cube from the viewer camera.
|
||||
cube.cameraOrientation.setValue(cam.orientation.getValue())
|
||||
root.addChild(cube)
|
||||
return root
|
||||
|
||||
if type_name in (
|
||||
"SoBrepEdgeSet",
|
||||
"SoBrepEdgeSetHighlight",
|
||||
"SoBrepEdgeSetSelection",
|
||||
"SoBrepPointSet",
|
||||
"SoBrepPointSetHighlight",
|
||||
"SoBrepPointSetSelection",
|
||||
"SoBrepFaceSet",
|
||||
"SoBrepFaceSetHighlight",
|
||||
"SoBrepFaceSetSelection",
|
||||
"SoFCControlPoints",
|
||||
):
|
||||
# These are provided by PartGui, so ensure it is imported to register the Coin types.
|
||||
if importlib.util.find_spec("PartGui") is not None:
|
||||
importlib.import_module("PartGui")
|
||||
|
||||
if type_name in (
|
||||
"SoPolygon",
|
||||
"SoPolygonOpen",
|
||||
"SoPolygonStartIndex",
|
||||
"SoPolygonNonPlanar",
|
||||
"SoPolygonTriangle",
|
||||
"SoFCIndexedFaceSet",
|
||||
"SoFCIndexedFaceSetPerFaceColor",
|
||||
"SoFCIndexedFaceSetPerVertexColor",
|
||||
"SoFCIndexedFaceSetTranslucent",
|
||||
):
|
||||
# These are provided by MeshGui, so ensure it is imported to register the Coin types.
|
||||
if importlib.util.find_spec("MeshGui") is not None:
|
||||
importlib.import_module("MeshGui")
|
||||
|
||||
if type_name in (
|
||||
"SoPolygon",
|
||||
"SoPolygonOpen",
|
||||
"SoPolygonStartIndex",
|
||||
"SoPolygonNonPlanar",
|
||||
"SoPolygonTriangle",
|
||||
):
|
||||
coords = coin.SoCoordinate3()
|
||||
if type_name == "SoPolygonStartIndex":
|
||||
# Use a non-zero startIndex and a non-trivial vertex slice.
|
||||
coords.point.setValues(
|
||||
0,
|
||||
6,
|
||||
[
|
||||
coin.SbVec3f(-0.9, -0.9, 0.0), # unused (startIndex=1)
|
||||
coin.SbVec3f(-0.6, -0.4, 0.0),
|
||||
coin.SbVec3f(0.7, -0.6, 0.0),
|
||||
coin.SbVec3f(0.6, 0.6, 0.0),
|
||||
coin.SbVec3f(-0.4, 0.7, 0.0),
|
||||
coin.SbVec3f(0.9, 0.9, 0.0), # unused
|
||||
],
|
||||
)
|
||||
elif type_name == "SoPolygonTriangle":
|
||||
coords.point.setValues(
|
||||
0,
|
||||
5,
|
||||
[
|
||||
coin.SbVec3f(-0.7, -0.5, 0.0),
|
||||
coin.SbVec3f(0.8, -0.6, 0.0),
|
||||
coin.SbVec3f(-0.1, 0.8, 0.0),
|
||||
coin.SbVec3f(0.9, 0.9, 0.0), # unused
|
||||
coin.SbVec3f(-0.9, 0.9, 0.0), # unused
|
||||
],
|
||||
)
|
||||
elif type_name == "SoPolygonNonPlanar":
|
||||
coords.point.setValues(
|
||||
0,
|
||||
5,
|
||||
[
|
||||
coin.SbVec3f(-0.7, -0.7, 0.0),
|
||||
coin.SbVec3f(0.7, -0.7, 0.4),
|
||||
coin.SbVec3f(0.8, 0.6, -0.2),
|
||||
coin.SbVec3f(-0.6, 0.8, 0.3),
|
||||
coin.SbVec3f(-0.7, -0.7, 0.0),
|
||||
],
|
||||
)
|
||||
elif type_name == "SoPolygonOpen":
|
||||
coords.point.setValues(
|
||||
0,
|
||||
4,
|
||||
[
|
||||
coin.SbVec3f(-0.8, -0.6, 0.0),
|
||||
coin.SbVec3f(0.6, -0.7, 0.0),
|
||||
coin.SbVec3f(0.8, 0.4, 0.0),
|
||||
coin.SbVec3f(-0.4, 0.7, 0.0),
|
||||
],
|
||||
)
|
||||
else:
|
||||
# Closed loop (last point repeats).
|
||||
coords.point.setValues(
|
||||
0,
|
||||
5,
|
||||
[
|
||||
coin.SbVec3f(-0.7, -0.7, 0.0),
|
||||
coin.SbVec3f(0.7, -0.7, 0.0),
|
||||
coin.SbVec3f(0.7, 0.7, 0.0),
|
||||
coin.SbVec3f(-0.7, 0.7, 0.0),
|
||||
coin.SbVec3f(-0.7, -0.7, 0.0),
|
||||
],
|
||||
)
|
||||
material = coin.SoMaterial()
|
||||
if type_name == "SoPolygonOpen":
|
||||
material.diffuseColor.setValue(0.80, 0.20, 0.10)
|
||||
elif type_name == "SoPolygonStartIndex":
|
||||
material.diffuseColor.setValue(0.10, 0.60, 0.20)
|
||||
elif type_name == "SoPolygonTriangle":
|
||||
material.diffuseColor.setValue(0.95, 0.55, 0.10)
|
||||
elif type_name == "SoPolygonNonPlanar":
|
||||
material.diffuseColor.setValue(0.65, 0.20, 0.75)
|
||||
else:
|
||||
material.diffuseColor.setValue(0.10, 0.25, 0.80)
|
||||
poly = _instantiate(coin, "SoPolygon")
|
||||
if type_name == "SoPolygonStartIndex":
|
||||
poly.startIndex.setValue(1)
|
||||
poly.numVertices.setValue(4)
|
||||
elif type_name == "SoPolygonTriangle":
|
||||
poly.startIndex.setValue(0)
|
||||
poly.numVertices.setValue(3)
|
||||
else:
|
||||
poly.startIndex.setValue(0)
|
||||
poly.numVertices.setValue(coords.point.getNum())
|
||||
poly.render.setValue(True)
|
||||
root.addChild(coords)
|
||||
root.addChild(material)
|
||||
root.addChild(poly)
|
||||
return root
|
||||
|
||||
if type_name in (
|
||||
"SoFCIndexedFaceSet",
|
||||
"SoFCIndexedFaceSetPerFaceColor",
|
||||
"SoFCIndexedFaceSetPerVertexColor",
|
||||
"SoFCIndexedFaceSetTranslucent",
|
||||
):
|
||||
# SoFCIndexedFaceSet test geometry does not provide normals. Without normals, lighting
|
||||
# contributes only ambient, which makes all material variants appear nearly black.
|
||||
# Provide one normal per face (triangle) so PER_FACE/PER_VERTEX material bindings
|
||||
# visibly differ under the default directional light.
|
||||
normals = coin.SoNormal()
|
||||
normals.vector.setValues(
|
||||
0,
|
||||
12,
|
||||
[
|
||||
coin.SbVec3f(0.0, 0.0, -1.0),
|
||||
coin.SbVec3f(0.0, 0.0, -1.0),
|
||||
coin.SbVec3f(0.0, 0.0, 1.0),
|
||||
coin.SbVec3f(0.0, 0.0, 1.0),
|
||||
coin.SbVec3f(0.0, -1.0, 0.0),
|
||||
coin.SbVec3f(0.0, -1.0, 0.0),
|
||||
coin.SbVec3f(1.0, 0.0, 0.0),
|
||||
coin.SbVec3f(1.0, 0.0, 0.0),
|
||||
coin.SbVec3f(0.0, 1.0, 0.0),
|
||||
coin.SbVec3f(0.0, 1.0, 0.0),
|
||||
coin.SbVec3f(-1.0, 0.0, 0.0),
|
||||
coin.SbVec3f(-1.0, 0.0, 0.0),
|
||||
],
|
||||
)
|
||||
normal_bind = coin.SoNormalBinding()
|
||||
normal_bind.value = coin.SoNormalBinding.PER_FACE
|
||||
root.addChild(normals)
|
||||
root.addChild(normal_bind)
|
||||
|
||||
if type_name == "SoFCIndexedFaceSetTranslucent":
|
||||
# Provide visible background so translucency can be verified.
|
||||
grad = _instantiate(coin, "SoFCBackgroundGradient")
|
||||
if hasattr(grad, "setColorGradient"):
|
||||
top = coin.SbColor(0.15, 0.15, 0.20)
|
||||
bottom = coin.SbColor(0.95, 0.95, 1.0)
|
||||
grad.setColorGradient(top, bottom)
|
||||
root.addChild(grad)
|
||||
|
||||
# Simple cube: 12 triangles (each terminated by -1).
|
||||
coords = coin.SoCoordinate3()
|
||||
coords.point.setValues(
|
||||
0,
|
||||
8,
|
||||
[
|
||||
coin.SbVec3f(-0.6, -0.6, -0.6),
|
||||
coin.SbVec3f(0.6, -0.6, -0.6),
|
||||
coin.SbVec3f(0.6, 0.6, -0.6),
|
||||
coin.SbVec3f(-0.6, 0.6, -0.6),
|
||||
coin.SbVec3f(-0.6, -0.6, 0.6),
|
||||
coin.SbVec3f(0.6, -0.6, 0.6),
|
||||
coin.SbVec3f(0.6, 0.6, 0.6),
|
||||
coin.SbVec3f(-0.6, 0.6, 0.6),
|
||||
],
|
||||
)
|
||||
faces = _instantiate(coin, "SoFCIndexedFaceSet")
|
||||
# fmt: off
|
||||
faces.coordIndex.setValues(0, 48, [
|
||||
0, 1, 2, -1, 0, 2, 3, -1, # bottom (-Z)
|
||||
4, 6, 5, -1, 4, 7, 6, -1, # top (+Z)
|
||||
0, 4, 5, -1, 0, 5, 1, -1, # -Y
|
||||
1, 5, 6, -1, 1, 6, 2, -1, # +X
|
||||
2, 6, 7, -1, 2, 7, 3, -1, # +Y
|
||||
3, 7, 4, -1, 3, 4, 0, -1, # -X
|
||||
])
|
||||
# fmt: on
|
||||
material = coin.SoMaterial()
|
||||
if type_name == "SoFCIndexedFaceSetPerFaceColor":
|
||||
# 12 faces (triangles).
|
||||
material.diffuseColor.setValues(
|
||||
0,
|
||||
12,
|
||||
[
|
||||
coin.SbColor(0.90, 0.25, 0.25),
|
||||
coin.SbColor(0.90, 0.55, 0.25),
|
||||
coin.SbColor(0.90, 0.80, 0.25),
|
||||
coin.SbColor(0.65, 0.90, 0.25),
|
||||
coin.SbColor(0.25, 0.90, 0.25),
|
||||
coin.SbColor(0.25, 0.90, 0.65),
|
||||
coin.SbColor(0.25, 0.90, 0.90),
|
||||
coin.SbColor(0.25, 0.65, 0.90),
|
||||
coin.SbColor(0.25, 0.25, 0.90),
|
||||
coin.SbColor(0.65, 0.25, 0.90),
|
||||
coin.SbColor(0.90, 0.25, 0.90),
|
||||
coin.SbColor(0.90, 0.25, 0.65),
|
||||
],
|
||||
)
|
||||
bind = coin.SoMaterialBinding()
|
||||
bind.value = coin.SoMaterialBinding.PER_FACE
|
||||
root.addChild(bind)
|
||||
elif type_name == "SoFCIndexedFaceSetPerVertexColor":
|
||||
material.diffuseColor.setValues(
|
||||
0,
|
||||
8,
|
||||
[
|
||||
coin.SbColor(0.95, 0.25, 0.25),
|
||||
coin.SbColor(0.95, 0.75, 0.25),
|
||||
coin.SbColor(0.25, 0.95, 0.25),
|
||||
coin.SbColor(0.25, 0.95, 0.95),
|
||||
coin.SbColor(0.25, 0.25, 0.95),
|
||||
coin.SbColor(0.95, 0.25, 0.95),
|
||||
coin.SbColor(0.70, 0.70, 0.70),
|
||||
coin.SbColor(0.15, 0.15, 0.15),
|
||||
],
|
||||
)
|
||||
bind = coin.SoMaterialBinding()
|
||||
# IndexedFaceSet colors are bound by index; use coordIndex as the color index
|
||||
# so each corner uses the corresponding material color.
|
||||
bind.value = coin.SoMaterialBinding.PER_VERTEX_INDEXED
|
||||
coord_index_values = list(faces.coordIndex.getValues(0))
|
||||
faces.materialIndex.setValues(
|
||||
0,
|
||||
faces.coordIndex.getNum(),
|
||||
coord_index_values,
|
||||
)
|
||||
root.addChild(bind)
|
||||
else:
|
||||
material.diffuseColor.setValue(0.70, 0.70, 0.75)
|
||||
if type_name == "SoFCIndexedFaceSetTranslucent":
|
||||
material.transparency.setValue(0.55)
|
||||
root.addChild(coords)
|
||||
root.addChild(material)
|
||||
root.addChild(faces)
|
||||
return root
|
||||
|
||||
if type_name in ("SoBrepEdgeSet", "SoBrepEdgeSetHighlight", "SoBrepEdgeSetSelection"):
|
||||
coords = coin.SoCoordinate3()
|
||||
coords.point.setValues(
|
||||
0,
|
||||
5,
|
||||
[
|
||||
coin.SbVec3f(-0.6, -0.6, 0.0),
|
||||
coin.SbVec3f(0.6, -0.6, 0.0),
|
||||
coin.SbVec3f(0.6, 0.6, 0.0),
|
||||
coin.SbVec3f(-0.6, 0.6, 0.0),
|
||||
coin.SbVec3f(0.0, 0.0, 0.0),
|
||||
],
|
||||
)
|
||||
style = coin.SoDrawStyle()
|
||||
style.lineWidth.setValue(3.0)
|
||||
material = coin.SoMaterial()
|
||||
material.diffuseColor.setValue(0.05, 0.05, 0.05)
|
||||
|
||||
edges = _instantiate(coin, "SoBrepEdgeSet")
|
||||
# Square + diagonals.
|
||||
edges.coordIndex.setValues(0, 14, [0, 1, 2, 3, 0, -1, 0, 2, -1, 1, 3, -1, 4, -1])
|
||||
if type_name == "SoBrepEdgeSetHighlight":
|
||||
edges.highlightCoordIndex.setValues(0, 3, [0, 2, -1])
|
||||
edges.highlightColor.setValue(1.0, 0.0, 0.0)
|
||||
elif type_name == "SoBrepEdgeSetSelection":
|
||||
edges.selectionCoordIndex.setValues(0, 3, [1, 3, -1])
|
||||
edges.selectionColor.setValue(0.0, 0.6, 0.0)
|
||||
|
||||
root.addChild(coords)
|
||||
root.addChild(style)
|
||||
root.addChild(material)
|
||||
root.addChild(edges)
|
||||
return root
|
||||
|
||||
if type_name in ("SoBrepPointSet", "SoBrepPointSetHighlight", "SoBrepPointSetSelection"):
|
||||
coords = coin.SoCoordinate3()
|
||||
coords.point.setValues(
|
||||
0,
|
||||
9,
|
||||
[
|
||||
coin.SbVec3f(-0.6, -0.6, 0.0),
|
||||
coin.SbVec3f(0.0, -0.6, 0.0),
|
||||
coin.SbVec3f(0.6, -0.6, 0.0),
|
||||
coin.SbVec3f(-0.6, 0.0, 0.0),
|
||||
coin.SbVec3f(0.0, 0.0, 0.0),
|
||||
coin.SbVec3f(0.6, 0.0, 0.0),
|
||||
coin.SbVec3f(-0.6, 0.6, 0.0),
|
||||
coin.SbVec3f(0.0, 0.6, 0.0),
|
||||
coin.SbVec3f(0.6, 0.6, 0.0),
|
||||
],
|
||||
)
|
||||
style = coin.SoDrawStyle()
|
||||
style.pointSize.setValue(7.0)
|
||||
material = coin.SoMaterial()
|
||||
material.diffuseColor.setValue(0.05, 0.05, 0.05)
|
||||
|
||||
pts = _instantiate(coin, "SoBrepPointSet")
|
||||
pts.startIndex.setValue(0)
|
||||
pts.numPoints.setValue(-1)
|
||||
if type_name == "SoBrepPointSetHighlight":
|
||||
pts.highlightCoordIndex.setValues(0, 1, [4])
|
||||
pts.highlightColor.setValue(1.0, 0.0, 0.0)
|
||||
elif type_name == "SoBrepPointSetSelection":
|
||||
pts.selectionCoordIndex.setValues(0, 4, [0, 2, 6, 8])
|
||||
pts.selectionColor.setValue(0.0, 0.6, 0.0)
|
||||
|
||||
root.addChild(coords)
|
||||
root.addChild(style)
|
||||
root.addChild(material)
|
||||
root.addChild(pts)
|
||||
return root
|
||||
|
||||
if type_name in ("SoBrepFaceSet", "SoBrepFaceSetHighlight", "SoBrepFaceSetSelection"):
|
||||
# Simple cube: 6 parts (faces), 2 triangles each.
|
||||
coords = coin.SoCoordinate3()
|
||||
coords.point.setValues(
|
||||
0,
|
||||
8,
|
||||
[
|
||||
coin.SbVec3f(-0.5, -0.5, -0.5), # 0
|
||||
coin.SbVec3f(0.5, -0.5, -0.5), # 1
|
||||
coin.SbVec3f(0.5, 0.5, -0.5), # 2
|
||||
coin.SbVec3f(-0.5, 0.5, -0.5), # 3
|
||||
coin.SbVec3f(-0.5, -0.5, 0.5), # 4
|
||||
coin.SbVec3f(0.5, -0.5, 0.5), # 5
|
||||
coin.SbVec3f(0.5, 0.5, 0.5), # 6
|
||||
coin.SbVec3f(-0.5, 0.5, 0.5), # 7
|
||||
],
|
||||
)
|
||||
|
||||
material = coin.SoMaterial()
|
||||
material.diffuseColor.setValue(0.75, 0.75, 0.78)
|
||||
|
||||
faces = _instantiate(coin, "SoBrepFaceSet")
|
||||
# Each triangle ends with -1; partIndex counts triangles per part.
|
||||
# fmt: off
|
||||
faces.coordIndex.setValues(0, 48, [
|
||||
# Bottom (z=-0.5)
|
||||
0, 1, 2, -1, 0, 2, 3, -1,
|
||||
# Top (z=+0.5)
|
||||
4, 6, 5, -1, 4, 7, 6, -1,
|
||||
# Front (y=-0.5)
|
||||
0, 5, 1, -1, 0, 4, 5, -1,
|
||||
# Back (y=+0.5)
|
||||
3, 2, 6, -1, 3, 6, 7, -1,
|
||||
# Left (x=-0.5)
|
||||
0, 3, 7, -1, 0, 7, 4, -1,
|
||||
# Right (x=+0.5)
|
||||
1, 5, 6, -1, 1, 6, 2, -1,
|
||||
])
|
||||
# fmt: on
|
||||
faces.partIndex.setValues(0, 6, [2, 2, 2, 2, 2, 2])
|
||||
|
||||
if type_name == "SoBrepFaceSetHighlight":
|
||||
# Highlight the front face (part 2).
|
||||
faces.highlightPartIndex.setValue(2)
|
||||
faces.highlightColor.setValue(1.0, 0.0, 0.0)
|
||||
elif type_name == "SoBrepFaceSetSelection":
|
||||
# Select a couple of faces to exercise the selection overlay.
|
||||
faces.selectionPartIndex.setValues(0, 2, [1, 5])
|
||||
faces.selectionColor.setValue(0.0, 0.6, 0.0)
|
||||
|
||||
root.addChild(coords)
|
||||
root.addChild(material)
|
||||
root.addChild(faces)
|
||||
return root
|
||||
|
||||
if type_name == "SoFCControlPoints":
|
||||
# A small 3x3 pole grid with 2x2 knots appended.
|
||||
coords = coin.SoCoordinate3()
|
||||
pts = []
|
||||
for u in (-0.6, 0.0, 0.6):
|
||||
for v in (-0.6, 0.0, 0.6):
|
||||
pts.append(coin.SbVec3f(u, v, 0.0))
|
||||
for u in (-0.3, 0.3):
|
||||
for v in (-0.3, 0.3):
|
||||
pts.append(coin.SbVec3f(u, v, 0.15))
|
||||
coords.point.setValues(0, len(pts), pts)
|
||||
|
||||
cp = _instantiate(coin, "SoFCControlPoints")
|
||||
cp.numPolesU.setValue(3)
|
||||
cp.numPolesV.setValue(3)
|
||||
cp.numKnotsU.setValue(2)
|
||||
cp.numKnotsV.setValue(2)
|
||||
|
||||
root.addChild(coords)
|
||||
root.addChild(cp)
|
||||
return root
|
||||
|
||||
root.addChild(_instantiate(coin, type_name))
|
||||
return root
|
||||
|
||||
|
||||
def _render_png(FreeCADGui, coin, root, out_path: Path, width: int, height: int) -> None:
|
||||
viewport = coin.SbViewportRegion(width, height)
|
||||
# Tighten the camera to what we're rendering.
|
||||
cam = root.getChild(0)
|
||||
# Some GUI helper nodes (e.g. SoDatumLabel) compute a camera-dependent bounding box.
|
||||
# That makes `SoCamera::viewAll()` unstable and can shift the framing of the snapshot.
|
||||
# For these, frame the camera from the rest of the scene and render the full graph.
|
||||
removed = None
|
||||
dtype = coin.SoType.fromName("SoDatumLabel")
|
||||
if not dtype.isBad():
|
||||
search = coin.SoSearchAction()
|
||||
search.setType(dtype)
|
||||
search.setSearchingAll(False)
|
||||
search.apply(root)
|
||||
path = search.getPath()
|
||||
if path is not None and path.getLength() >= 2:
|
||||
label = path.getTail()
|
||||
parent = path.getNode(path.getLength() - 2)
|
||||
idx = parent.findChild(label)
|
||||
if idx >= 0:
|
||||
# Keep the node alive while it's detached (Coin ref-counting).
|
||||
label.ref()
|
||||
parent.removeChild(idx)
|
||||
removed = (parent, idx, label)
|
||||
|
||||
cam.viewAll(root, viewport)
|
||||
|
||||
if removed is not None:
|
||||
parent, idx, label = removed
|
||||
parent.insertChild(label, idx)
|
||||
label.unref()
|
||||
# `SoCamera::viewAll()` can choose a near plane that clips geometry located near the origin.
|
||||
# This shows up particularly with `SoText2`/`SoTextLabel` (text draws, but gets clipped away).
|
||||
cam.nearDistance.setValue(min(cam.nearDistance.getValue(), 0.1))
|
||||
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
off = FreeCADGui.SoQtOffscreenRenderer(width, height)
|
||||
off.setBackgroundColor(1, 1, 1)
|
||||
root.ref()
|
||||
off.render(root)
|
||||
off.writeToImage(str(out_path))
|
||||
root.unref()
|
||||
|
||||
|
||||
def _non_background_pixel_count(path: Path) -> int:
|
||||
from PySide.QtGui import QImage # type: ignore
|
||||
|
||||
img = QImage(str(path))
|
||||
if img.isNull():
|
||||
return 0
|
||||
white = 0xFFFFFFFF
|
||||
count = 0
|
||||
for y in range(img.height()):
|
||||
for x in range(img.width()):
|
||||
if img.pixel(x, y) != white:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def _compare_images(
|
||||
expected_path: Path,
|
||||
actual_path: Path,
|
||||
diff_path: Path,
|
||||
*,
|
||||
tolerance: int,
|
||||
ignore_alpha: bool,
|
||||
max_mismatched_pixels: int,
|
||||
) -> tuple[bool, str]:
|
||||
from PySide.QtGui import QColor, QImage # type: ignore
|
||||
|
||||
# Structural Similarity Index (SSIM) provides a more robust signal than raw per-pixel diffs
|
||||
# when tiny rasterization differences occur across platforms/drivers.
|
||||
#
|
||||
# This is a lightweight block-SSIM implementation to avoid non-stdlib deps (e.g. numpy).
|
||||
_SSIM_MIN = 0.97
|
||||
_SSIM_BLOCK = 8
|
||||
_SSIM_C1 = (0.01 * 255.0) ** 2
|
||||
_SSIM_C2 = (0.03 * 255.0) ** 2
|
||||
|
||||
expected = QImage(str(expected_path))
|
||||
actual = QImage(str(actual_path))
|
||||
|
||||
if expected.isNull():
|
||||
return False, f"baseline is not a readable image: {expected_path}"
|
||||
if actual.isNull():
|
||||
return False, f"actual is not a readable image: {actual_path}"
|
||||
|
||||
if expected.size() != actual.size():
|
||||
diff_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
diff = QImage(actual.size(), QImage.Format_ARGB32)
|
||||
diff.fill(QColor(255, 0, 255, 255))
|
||||
diff.save(str(diff_path))
|
||||
msg = (
|
||||
f"size mismatch: expected {expected.width()}x{expected.height()} "
|
||||
f"vs actual {actual.width()}x{actual.height()}"
|
||||
)
|
||||
return (
|
||||
False,
|
||||
msg,
|
||||
)
|
||||
|
||||
expected = expected.convertToFormat(QImage.Format_ARGB32)
|
||||
actual = actual.convertToFormat(QImage.Format_ARGB32)
|
||||
|
||||
diff = QImage(actual.size(), QImage.Format_ARGB32)
|
||||
mismatched = 0
|
||||
|
||||
blocks_w = (actual.width() + _SSIM_BLOCK - 1) // _SSIM_BLOCK
|
||||
blocks_h = (actual.height() + _SSIM_BLOCK - 1) // _SSIM_BLOCK
|
||||
blocks_n = blocks_w * blocks_h
|
||||
sum_e = [0.0] * blocks_n
|
||||
sum_a = [0.0] * blocks_n
|
||||
sumsq_e = [0.0] * blocks_n
|
||||
sumsq_a = [0.0] * blocks_n
|
||||
sum_cross = [0.0] * blocks_n
|
||||
count = [0] * blocks_n
|
||||
|
||||
def _luma(r: int, g: int, b: int) -> float:
|
||||
# Integer-ish Rec.709 luma approximation (coeffs sum to 256).
|
||||
return float((54 * r + 183 * g + 19 * b) >> 8)
|
||||
|
||||
for y in range(actual.height()):
|
||||
for x in range(actual.width()):
|
||||
ep = expected.pixel(x, y)
|
||||
ap = actual.pixel(x, y)
|
||||
|
||||
er = (ep >> 16) & 0xFF
|
||||
eg = (ep >> 8) & 0xFF
|
||||
eb = ep & 0xFF
|
||||
ea = (ep >> 24) & 0xFF
|
||||
|
||||
ar = (ap >> 16) & 0xFF
|
||||
ag = (ap >> 8) & 0xFF
|
||||
ab = ap & 0xFF
|
||||
aa = (ap >> 24) & 0xFF
|
||||
|
||||
by = y // _SSIM_BLOCK
|
||||
bx = x // _SSIM_BLOCK
|
||||
bi = by * blocks_w + bx
|
||||
ye = _luma(er, eg, eb)
|
||||
ya = _luma(ar, ag, ab)
|
||||
sum_e[bi] += ye
|
||||
sum_a[bi] += ya
|
||||
sumsq_e[bi] += ye * ye
|
||||
sumsq_a[bi] += ya * ya
|
||||
sum_cross[bi] += ye * ya
|
||||
count[bi] += 1
|
||||
|
||||
dr = abs(er - ar)
|
||||
dg = abs(eg - ag)
|
||||
db = abs(eb - ab)
|
||||
da = abs(ea - aa) if not ignore_alpha else 0
|
||||
|
||||
dmax = max(dr, dg, db, da)
|
||||
if dmax > tolerance:
|
||||
mismatched += 1
|
||||
diff.setPixel(x, y, QColor(255, 0, 0, 255).rgba())
|
||||
else:
|
||||
# Keep a lightly-dimmed version of the actual pixel for context.
|
||||
diff.setPixel(x, y, QColor(ar // 2, ag // 2, ab // 2, 255).rgba())
|
||||
|
||||
if mismatched > 0:
|
||||
diff_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
diff.save(str(diff_path))
|
||||
|
||||
ssim_total = 0.0
|
||||
ssim_blocks = 0
|
||||
for i in range(blocks_n):
|
||||
n = count[i]
|
||||
if n <= 0:
|
||||
continue
|
||||
mu_e = sum_e[i] / float(n)
|
||||
mu_a = sum_a[i] / float(n)
|
||||
var_e = (sumsq_e[i] / float(n)) - (mu_e * mu_e)
|
||||
var_a = (sumsq_a[i] / float(n)) - (mu_a * mu_a)
|
||||
cov = (sum_cross[i] / float(n)) - (mu_e * mu_a)
|
||||
if var_e < 0.0:
|
||||
var_e = 0.0
|
||||
if var_a < 0.0:
|
||||
var_a = 0.0
|
||||
|
||||
num = (2.0 * mu_e * mu_a + _SSIM_C1) * (2.0 * cov + _SSIM_C2)
|
||||
den = (mu_e * mu_e + mu_a * mu_a + _SSIM_C1) * (var_e + var_a + _SSIM_C2)
|
||||
if den == 0.0:
|
||||
# Identical constant blocks.
|
||||
ssim = 1.0 if num == 0.0 else 0.0
|
||||
else:
|
||||
ssim = num / den
|
||||
ssim_total += ssim
|
||||
ssim_blocks += 1
|
||||
|
||||
ssim_avg = ssim_total / float(ssim_blocks or 1)
|
||||
|
||||
if mismatched > max_mismatched_pixels:
|
||||
if ssim_avg >= _SSIM_MIN:
|
||||
return (
|
||||
True,
|
||||
f"mismatched pixels {mismatched} > {max_mismatched_pixels}, "
|
||||
f"but SSIM={ssim_avg:.4f} >= {_SSIM_MIN:.2f}",
|
||||
)
|
||||
|
||||
return (
|
||||
False,
|
||||
f"mismatched pixels {mismatched} > {max_mismatched_pixels} "
|
||||
f"(tolerance={tolerance}, ignore_alpha={ignore_alpha}, SSIM={ssim_avg:.4f} < {_SSIM_MIN:.2f})",
|
||||
)
|
||||
|
||||
return True, f"mismatched pixels {mismatched} <= {max_mismatched_pixels} (SSIM={ssim_avg:.4f})"
|
||||
|
||||
|
||||
class CoinNodeSnapshotTestCase(unittest.TestCase):
|
||||
"""Render Coin nodes offscreen and compare against PNG baselines."""
|
||||
|
||||
def test_coin_node_snapshots(self):
|
||||
"""Render each configured node and compare against baseline images."""
|
||||
if os.environ.get("CI", "").strip() and not sys.platform.startswith("linux"):
|
||||
raise unittest.SkipTest(
|
||||
"Coin node snapshot visual tests are only supported on Linux CI"
|
||||
)
|
||||
|
||||
_, FreeCADGui, coin = _require_gui()
|
||||
|
||||
nodes_env = os.environ.get("FC_VISUAL_NODES", "")
|
||||
if nodes_env.strip():
|
||||
node_types = [n.strip() for n in nodes_env.split(",") if n.strip()]
|
||||
else:
|
||||
node_types = [
|
||||
"SoDrawingGrid",
|
||||
"SoRegPoint",
|
||||
"SoDatumLabel",
|
||||
"SoTextLabel",
|
||||
"SoStringLabel",
|
||||
"SoFCBackgroundGradient",
|
||||
"SoNaviCube",
|
||||
"SoNaviCubeTranslucent",
|
||||
"SoNaviCubeHiliteFront",
|
||||
"SoBrepEdgeSet",
|
||||
"SoBrepEdgeSetHighlight",
|
||||
"SoBrepEdgeSetSelection",
|
||||
"SoBrepPointSet",
|
||||
"SoBrepPointSetHighlight",
|
||||
"SoBrepPointSetSelection",
|
||||
"SoBrepFaceSet",
|
||||
"SoBrepFaceSetHighlight",
|
||||
"SoBrepFaceSetSelection",
|
||||
"SoFCControlPoints",
|
||||
"SoPolygon",
|
||||
"SoPolygonOpen",
|
||||
"SoPolygonStartIndex",
|
||||
"SoPolygonNonPlanar",
|
||||
"SoPolygonTriangle",
|
||||
"SoFCIndexedFaceSet",
|
||||
"SoFCIndexedFaceSetPerFaceColor",
|
||||
"SoFCIndexedFaceSetPerVertexColor",
|
||||
"SoFCIndexedFaceSetTranslucent",
|
||||
]
|
||||
|
||||
width = int(os.environ.get("FC_VISUAL_WIDTH", "512"))
|
||||
height = int(os.environ.get("FC_VISUAL_HEIGHT", "512"))
|
||||
|
||||
out_dir = Path(
|
||||
os.environ.get(
|
||||
"FC_VISUAL_OUT_DIR",
|
||||
os.path.join(tempfile.gettempdir(), "FreeCADTesting", "CoinNodeSnapshots"),
|
||||
)
|
||||
)
|
||||
|
||||
update_baseline = os.environ.get("FC_VISUAL_UPDATE_BASELINE", "").strip() not in (
|
||||
"",
|
||||
"0",
|
||||
"false",
|
||||
"False",
|
||||
)
|
||||
try:
|
||||
baseline_dir = _baseline_dir(create=update_baseline)
|
||||
except FileNotFoundError as exc:
|
||||
raise unittest.SkipTest(str(exc)) from exc
|
||||
|
||||
if not baseline_dir.is_dir():
|
||||
self.fail(
|
||||
f"baseline directory not found: {baseline_dir} "
|
||||
"(set FC_VISUAL_BASELINE_DIR or run with FC_VISUAL_UPDATE_BASELINE=1)"
|
||||
)
|
||||
|
||||
tolerance = int(os.environ.get("FC_VISUAL_TOLERANCE", "8"))
|
||||
tolerance = max(0, min(tolerance, 255))
|
||||
ignore_alpha = os.environ.get("FC_VISUAL_IGNORE_ALPHA", "1").strip() not in (
|
||||
"",
|
||||
"0",
|
||||
"false",
|
||||
"False",
|
||||
)
|
||||
max_mismatch_pct = float(os.environ.get("FC_VISUAL_MAX_MISMATCH_PCT", "0.20"))
|
||||
max_mismatch_pct = max(0.0, min(max_mismatch_pct, 100.0))
|
||||
max_mismatched_pixels = int((width * height) * (max_mismatch_pct / 100.0))
|
||||
|
||||
did_compare = False
|
||||
for type_name in node_types:
|
||||
with self.subTest(node=type_name):
|
||||
root = _make_scene_for_node(coin, type_name)
|
||||
actual_dir = out_dir / "actual"
|
||||
expected_dir = out_dir / "expected"
|
||||
diff_dir = out_dir / "diff"
|
||||
|
||||
actual_path = actual_dir / f"{type_name}.png"
|
||||
_render_png(FreeCADGui, coin, root, actual_path, width, height)
|
||||
self.assertTrue(actual_path.exists(), f"missing snapshot: {actual_path}")
|
||||
self.assertGreater(actual_path.stat().st_size, 0, f"empty snapshot: {actual_path}")
|
||||
self.assertGreater(
|
||||
_non_background_pixel_count(actual_path),
|
||||
10,
|
||||
f"snapshot seems empty (all background): {actual_path}",
|
||||
)
|
||||
|
||||
did_compare = True
|
||||
baseline_path = baseline_dir / f"{type_name}.png"
|
||||
|
||||
if update_baseline:
|
||||
baseline_path.write_bytes(actual_path.read_bytes())
|
||||
continue
|
||||
|
||||
if not baseline_path.exists():
|
||||
self.fail(
|
||||
f"missing baseline: {baseline_path} "
|
||||
"(run with FC_VISUAL_UPDATE_BASELINE=1)"
|
||||
)
|
||||
|
||||
expected_path = expected_dir / f"{type_name}.png"
|
||||
expected_dir.mkdir(parents=True, exist_ok=True)
|
||||
expected_path.write_bytes(baseline_path.read_bytes())
|
||||
|
||||
ok, msg = _compare_images(
|
||||
expected_path,
|
||||
actual_path,
|
||||
diff_dir / f"{type_name}.png",
|
||||
tolerance=tolerance,
|
||||
ignore_alpha=ignore_alpha,
|
||||
max_mismatched_pixels=max_mismatched_pixels,
|
||||
)
|
||||
self.assertTrue(ok, msg)
|
||||
self.assertTrue(did_compare, "No snapshot comparisons were performed")
|
||||
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
@@ -0,0 +1,94 @@
|
||||
Copyright 2018 The Noto Project Authors (github.com/googlei18n/noto-fonts)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License,
|
||||
Version 1.1.
|
||||
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font
|
||||
creation efforts of academic and linguistic communities, and to
|
||||
provide a free and open framework in which fonts may be shared and
|
||||
improved in partnership with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply to
|
||||
any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software
|
||||
components as distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to,
|
||||
deleting, or substituting -- in part or in whole -- any of the
|
||||
components of the Original Version, by changing formats or by porting
|
||||
the Font Software to a new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed,
|
||||
modify, redistribute, and sell modified and unmodified copies of the
|
||||
Font Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components, in
|
||||
Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the
|
||||
corresponding Copyright Holder. This restriction only applies to the
|
||||
primary font name as presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created using
|
||||
the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,6 @@
|
||||
This directory vendors a font used by the visual snapshot tests to make text rendering
|
||||
less dependent on whatever fonts are installed on the host/CI runner.
|
||||
|
||||
- Font: Noto Sans Regular (`NotoSans-Regular.ttf`)
|
||||
- License: SIL Open Font License 1.1 (`OFL.txt`)
|
||||
- Upstream: Google Noto Fonts repository
|
||||
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
|
||||
"""
|
||||
Manage baselines for the Coin node visual snapshot test.
|
||||
|
||||
This script runs the existing FreeCAD unittest `TestCoinNodeSnapshots` via FreeCADCmd,
|
||||
configuring it through environment variables (so we don't depend on FreeCAD forwarding
|
||||
CLI args to Python).
|
||||
|
||||
Examples:
|
||||
|
||||
# Update baselines in-tree (recommended: do this on a controlled setup)
|
||||
tools/rendering/manage_coin_node_baselines.py update \
|
||||
--freecadcmd build/<preset>/bin/FreeCADCmd
|
||||
|
||||
# Compare current renders against baselines (writes actual/expected/diff under --out-dir)
|
||||
tools/rendering/manage_coin_node_baselines.py compare \
|
||||
--out-dir /tmp/FreeCADTesting/CoinNodeSnapshots \
|
||||
--freecadcmd build/<preset>/bin/FreeCADCmd
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# pylint: disable=broad-exception-caught,duplicate-code
|
||||
|
||||
|
||||
def _default_freecadcmd() -> str | None:
|
||||
# Allow explicit override from the environment.
|
||||
for env_var in ("FREECADCMD", "FC_FREECADCMD", "FREECAD_CMD"):
|
||||
val = os.environ.get(env_var, "").strip()
|
||||
if val:
|
||||
return val
|
||||
|
||||
# Common local build layouts.
|
||||
candidates: list[Path] = [
|
||||
Path("build/bin/FreeCADCmd"),
|
||||
*sorted(Path("build").glob("*/bin/FreeCADCmd")),
|
||||
*sorted(Path("build").glob("*/bin/FreeCADCmd.exe")),
|
||||
]
|
||||
for candidate in candidates:
|
||||
if candidate.is_file():
|
||||
return str(candidate)
|
||||
|
||||
return shutil.which("FreeCADCmd")
|
||||
|
||||
|
||||
def _default_baseline_dir() -> str:
|
||||
# This script lives in-tree under `tools/rendering/`.
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
return str(repo_root / "tests" / "visual" / "baselines" / "coin-nodes")
|
||||
|
||||
|
||||
def _parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
sub = parser.add_subparsers(dest="mode", required=True)
|
||||
|
||||
common = argparse.ArgumentParser(add_help=False)
|
||||
common.add_argument(
|
||||
"--freecadcmd",
|
||||
default=_default_freecadcmd(),
|
||||
help=(
|
||||
"Path to FreeCADCmd (default: $FREECADCMD or auto-detect under build/*/bin/FreeCADCmd or PATH)"
|
||||
),
|
||||
)
|
||||
common.add_argument(
|
||||
"--qt-platform",
|
||||
default="",
|
||||
help=(
|
||||
"Value for QT_QPA_PLATFORM. By default this script does not override it; "
|
||||
"if no DISPLAY/WAYLAND is available, it auto-sets QT_QPA_PLATFORM=offscreen."
|
||||
),
|
||||
)
|
||||
common.add_argument(
|
||||
"--baseline-dir",
|
||||
default=_default_baseline_dir(),
|
||||
help="Baseline directory containing/writing *.png files (default: %(default)s)",
|
||||
)
|
||||
common.add_argument(
|
||||
"--out-dir",
|
||||
default=os.path.join("/tmp", "FreeCADTesting", "CoinNodeSnapshots"),
|
||||
help="Artifact output directory (default: %(default)s)",
|
||||
)
|
||||
common.add_argument("--nodes", default="", help="Comma-separated node type list (optional)")
|
||||
common.add_argument("--width", type=int, default=512, help="Image width (default: %(default)s)")
|
||||
common.add_argument(
|
||||
"--height",
|
||||
type=int,
|
||||
default=512,
|
||||
help="Image height (default: %(default)s)",
|
||||
)
|
||||
common.add_argument(
|
||||
"--tolerance",
|
||||
type=int,
|
||||
default=8,
|
||||
help="Per-channel tolerance (default: %(default)s)",
|
||||
)
|
||||
common.add_argument(
|
||||
"--max-mismatch-pct",
|
||||
type=float,
|
||||
default=0.20,
|
||||
help="Allowed mismatch percent (default: %(default)s)",
|
||||
)
|
||||
common.add_argument(
|
||||
"--ignore-alpha",
|
||||
default="1",
|
||||
choices=["0", "1"],
|
||||
help="Ignore alpha channel (default: %(default)s)",
|
||||
)
|
||||
|
||||
sub.add_parser("update", parents=[common], help="Write/update baselines")
|
||||
sub.add_parser("compare", parents=[common], help="Compare against baselines")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
"""Entry point."""
|
||||
args = _parse_args(argv)
|
||||
|
||||
if not args.freecadcmd:
|
||||
print(
|
||||
"ERROR: could not auto-detect FreeCADCmd; pass --freecadcmd "
|
||||
"build/<preset>/bin/FreeCADCmd (or set $FREECADCMD)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
freecadcmd = Path(args.freecadcmd)
|
||||
if not freecadcmd.is_file():
|
||||
print(f"ERROR: FreeCADCmd not found: {freecadcmd}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
baseline_dir = Path(args.baseline_dir)
|
||||
if args.mode == "update":
|
||||
baseline_dir.mkdir(parents=True, exist_ok=True)
|
||||
elif not baseline_dir.is_dir():
|
||||
print(
|
||||
f"ERROR: baseline directory not found: {baseline_dir} "
|
||||
"(pass --baseline-dir or run `update` first)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
env = os.environ.copy()
|
||||
if args.qt_platform.strip():
|
||||
env["QT_QPA_PLATFORM"] = args.qt_platform.strip()
|
||||
else:
|
||||
has_display = bool(env.get("DISPLAY") or env.get("WAYLAND_DISPLAY"))
|
||||
if not has_display and not env.get("QT_QPA_PLATFORM"):
|
||||
env["QT_QPA_PLATFORM"] = "offscreen"
|
||||
env["FC_VISUAL_BASELINE_DIR"] = str(baseline_dir.resolve())
|
||||
env["FC_VISUAL_OUT_DIR"] = str(Path(args.out_dir).resolve())
|
||||
env["FC_VISUAL_WIDTH"] = str(int(args.width))
|
||||
env["FC_VISUAL_HEIGHT"] = str(int(args.height))
|
||||
env["FC_VISUAL_TOLERANCE"] = str(int(args.tolerance))
|
||||
env["FC_VISUAL_MAX_MISMATCH_PCT"] = str(float(args.max_mismatch_pct))
|
||||
env["FC_VISUAL_IGNORE_ALPHA"] = args.ignore_alpha
|
||||
if args.nodes.strip():
|
||||
env["FC_VISUAL_NODES"] = args.nodes
|
||||
|
||||
if args.mode == "update":
|
||||
env["FC_VISUAL_UPDATE_BASELINE"] = "1"
|
||||
cmd = [str(freecadcmd), "-t", "TestCoinNodeSnapshots"]
|
||||
else:
|
||||
env.pop("FC_VISUAL_UPDATE_BASELINE", None)
|
||||
cmd = [str(freecadcmd), "-t", "TestCoinNodeSnapshots"]
|
||||
|
||||
print(f"Running: {' '.join(cmd)}")
|
||||
if "QT_QPA_PLATFORM" in env:
|
||||
print(f" QT_QPA_PLATFORM={env['QT_QPA_PLATFORM']}")
|
||||
print(f" FC_VISUAL_BASELINE_DIR={env['FC_VISUAL_BASELINE_DIR']}")
|
||||
print(f" FC_VISUAL_OUT_DIR={env['FC_VISUAL_OUT_DIR']}")
|
||||
if "FC_VISUAL_NODES" in env:
|
||||
print(f" FC_VISUAL_NODES={env['FC_VISUAL_NODES']}")
|
||||
|
||||
proc = subprocess.run(cmd, env=env, cwd=str(Path.cwd()), check=False)
|
||||
return proc.returncode
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||