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.
This commit is contained in:
tritao
2026-01-10 18:38:12 +00:00
parent 5008123c91
commit 6b83c62c93
35 changed files with 1601 additions and 35 deletions
+63
View File
@@ -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}
+36 -35
View File
@@ -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",
]
+1214
View File
@@ -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")
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.
+94
View File
@@ -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.
+6
View File
@@ -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:]))