From 7c05289f6bf0e9da7a417df45d52061612cfa8c8 Mon Sep 17 00:00:00 2001 From: Ilya Baidakov <90848485+baidakovil@users.noreply.github.com> Date: Tue, 15 Jul 2025 01:25:57 +0300 Subject: [PATCH] Add custom template path support for WebGL export (#21695) * Add custom template path support for WebGL export - Extract hardcoded template into separate file and store in Mod/BIM/Resources/templates - Remove hardcoded template from code - Add new section to Import-Export Preference page ("WebGL"). This introduces two new parameters - Make export() return bool to indicate success or failure for controlled headless export - Add new unit tests for WebGL export to ensure functionality - Update CMakeLists.txt to include the new template resource * Apply suggestions from code review Changes to literals, suggested after DWG review Co-authored-by: Max Wilfinger <6246609+maxwxyz@users.noreply.github.com> * Removed unused import * Removed unused import --------- Co-authored-by: Max Wilfinger <6246609+maxwxyz@users.noreply.github.com> Co-authored-by: Yorik van Havre --- src/Mod/BIM/CMakeLists.txt | 19 + src/Mod/BIM/InitGui.py | 1 + src/Mod/BIM/Resources/Arch.qrc | 1 + .../templates/webgl_export_template.html | 652 +++++++++++++++ src/Mod/BIM/Resources/ui/preferences-webgl.ui | 129 +++ src/Mod/BIM/TestArch.py | 2 +- src/Mod/BIM/TestArchGui.py | 2 + src/Mod/BIM/bimtests/TestWebGLExport.py | 140 ++++ src/Mod/BIM/bimtests/TestWebGLExportGui.py | 181 ++++ src/Mod/BIM/importers/importWebGL.py | 771 +++--------------- src/Mod/Draft/draftutils/params.py | 3 +- 11 files changed, 1252 insertions(+), 649 deletions(-) create mode 100644 src/Mod/BIM/Resources/templates/webgl_export_template.html create mode 100644 src/Mod/BIM/Resources/ui/preferences-webgl.ui create mode 100644 src/Mod/BIM/bimtests/TestWebGLExport.py create mode 100644 src/Mod/BIM/bimtests/TestWebGLExportGui.py diff --git a/src/Mod/BIM/CMakeLists.txt b/src/Mod/BIM/CMakeLists.txt index 890fb97f0b..338ab06294 100644 --- a/src/Mod/BIM/CMakeLists.txt +++ b/src/Mod/BIM/CMakeLists.txt @@ -173,6 +173,10 @@ SET(bimcommands_SRCS bimcommands/__init__.py ) +SET(BIM_templates + Resources/templates/webgl_export_template.html +) + SET(nativeifc_SRCS nativeifc/ifc_commands.py nativeifc/ifc_diff.py @@ -223,6 +227,8 @@ SET(bimtests_SRCS bimtests/TestArchReference.py bimtests/TestArchSchedule.py bimtests/TestArchTruss.py + bimtests/TestWebGLExport.py + bimtests/TestWebGLExportGui.py ) SOURCE_GROUP("" FILES ${Arch_SRCS}) @@ -246,6 +252,7 @@ ADD_CUSTOM_TARGET(BIM ALL ${bimtests_SRCS} ${nativeifc_SRCS} ${BIMGuiIcon_SVG} + ${BIM_templates} ) ADD_CUSTOM_TARGET(ImporterPythonTestData ALL @@ -266,6 +273,12 @@ fc_target_copy_resource(BIM ${Arch_presets} ) +fc_target_copy_resource(BIM + ${CMAKE_SOURCE_DIR}/src/Mod/BIM + ${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_DATADIR}/Mod/BIM + ${BIM_templates} +) + fc_target_copy_resource(ImporterPythonTestData ${CMAKE_SOURCE_DIR}/src/Mod/BIM ${CMAKE_BINARY_DIR}/Mod/BIM @@ -330,3 +343,9 @@ INSTALL( "${CMAKE_INSTALL_DATADIR}/Mod/BIM/Resources/icons" ) +INSTALL( + FILES + ${BIM_templates} + DESTINATION + "${CMAKE_INSTALL_DATADIR}/Mod/BIM/Resources/templates" + ) diff --git a/src/Mod/BIM/InitGui.py b/src/Mod/BIM/InitGui.py index 2a61aaa3f8..96f028c7d9 100644 --- a/src/Mod/BIM/InitGui.py +++ b/src/Mod/BIM/InitGui.py @@ -707,6 +707,7 @@ FreeCADGui.addPreferencePage(":/ui/preferences-ifc.ui", t) FreeCADGui.addPreferencePage(":/ui/preferences-ifc-export.ui", t) FreeCADGui.addPreferencePage(":/ui/preferences-dae.ui", t) FreeCADGui.addPreferencePage(":/ui/preferences-sh3d-import.ui", t) +FreeCADGui.addPreferencePage(":/ui/preferences-webgl.ui", t) # Add unit tests FreeCAD.__unit_test__ += ["TestArchGui"] diff --git a/src/Mod/BIM/Resources/Arch.qrc b/src/Mod/BIM/Resources/Arch.qrc index a202f2480e..0265454ceb 100644 --- a/src/Mod/BIM/Resources/Arch.qrc +++ b/src/Mod/BIM/Resources/Arch.qrc @@ -271,6 +271,7 @@ ui/preferences-ifc-export.ui ui/preferences-ifc.ui ui/preferences-sh3d-import.ui + ui/preferences-webgl.ui ui/preferencesNativeIFC.ui diff --git a/src/Mod/BIM/Resources/templates/webgl_export_template.html b/src/Mod/BIM/Resources/templates/webgl_export_template.html new file mode 100644 index 0000000000..c3498f9205 --- /dev/null +++ b/src/Mod/BIM/Resources/templates/webgl_export_template.html @@ -0,0 +1,652 @@ + + + + $pagetitle + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Mod/BIM/Resources/ui/preferences-webgl.ui b/src/Mod/BIM/Resources/ui/preferences-webgl.ui new file mode 100644 index 0000000000..38c1b698f7 --- /dev/null +++ b/src/Mod/BIM/Resources/ui/preferences-webgl.ui @@ -0,0 +1,129 @@ + + + Gui::Dialog::DlgSettingsArch + + + + 0 + 0 + 555 + 729 + + + + WebGL + + + + 6 + + + 9 + + + + + Export Options + + + + + + A custom WebGL HTML template is used for export. Otherwise, the default template will be used. + +The default template is located at: +<FreeCAD installation directory>/Resources/Mod/BIM/templates/webgl_export_template.html + + + Use custom export template + + + false + + + useCustomWebGLExportTemplate + + + Mod/BIM + + + + + + + + + false + + + Path to template + + + + + + + + false + + + The path to the custom WebGL HTML template + + + WebGLTemplateCustomPath + + + Mod/BIM + + + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + Gui::FileChooser + QWidget +
Gui/FileDialog.h
+
+ + Gui::PrefFileChooser + Gui::FileChooser +
Gui/PrefWidgets.h
+
+
+ + + + checkBox_useCustomWebGLTemplate + toggled(bool) + label_4 + setEnabled(bool) + + + checkBox_useCustomWebGLTemplate + toggled(bool) + gui::preffilechooser_2 + setEnabled(bool) + + +
diff --git a/src/Mod/BIM/TestArch.py b/src/Mod/BIM/TestArch.py index b60b82c874..03288827c6 100644 --- a/src/Mod/BIM/TestArch.py +++ b/src/Mod/BIM/TestArch.py @@ -47,4 +47,4 @@ from bimtests.TestArchReference import TestArchReference from bimtests.TestArchSchedule import TestArchSchedule from bimtests.TestArchTruss import TestArchTruss from bimtests.TestArchComponent import TestArchComponent - +from bimtests.TestWebGLExport import TestWebGLExport diff --git a/src/Mod/BIM/TestArchGui.py b/src/Mod/BIM/TestArchGui.py index a92658ec7c..c631c6bceb 100644 --- a/src/Mod/BIM/TestArchGui.py +++ b/src/Mod/BIM/TestArchGui.py @@ -42,6 +42,8 @@ from draftutils.messages import _msg if App.GuiUp: import FreeCADGui +from bimtests.TestWebGLExportGui import TestWebGLExportGui + class ArchTest(unittest.TestCase): def setUp(self): diff --git a/src/Mod/BIM/bimtests/TestWebGLExport.py b/src/Mod/BIM/bimtests/TestWebGLExport.py new file mode 100644 index 0000000000..b2b01730fb --- /dev/null +++ b/src/Mod/BIM/bimtests/TestWebGLExport.py @@ -0,0 +1,140 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later + +# *************************************************************************** +# * * +# * Copyright (c) 2025 baidakovil * +# * * +# * This file is part of FreeCAD. * +# * * +# * FreeCAD is free software: you can redistribute it and/or modify it * +# * under the terms of the GNU Lesser General Public License as * +# * published by the Free Software Foundation, either version 2.1 of the * +# * License, or (at your option) any later version. * +# * * +# * 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 Lesser General Public * +# * License along with FreeCAD. If not, see * +# * . * +# * * +# *************************************************************************** + + +""" +Unit tests for WebGL export functionality. Gui tests are in `TestWebGLExportGui.py`. +""" + +import os +import tempfile +import unittest +from unittest.mock import patch, mock_open + +from BIM.importers import importWebGL +from .TestArchBase import TestArchBase + + +class TestWebGLExport(TestArchBase): + + def setUp(self): + """Using TestArchBase setUp to initialize the document for convenience, + but also create a temporary directory for tests.""" + super().setUp() + self.test_dir = tempfile.mkdtemp() + self.test_template_content = """ + + + $pagetitle + WebGL Content: $data + + """ + + def tearDown(self): + import shutil + + shutil.rmtree(self.test_dir, ignore_errors=True) + super().tearDown() + + def test_actual_default_template_readable_and_valid(self): + """Test that the actual default template can be read and contains + required placeholders""" + operation = "Testing actual default template reading and validation" + self.printTestMessage(operation) + # Test with real configuration - no mocks + with patch( + "BIM.importers.importWebGL.params.get_param", return_value=False + ): # Disable custom template + result = importWebGL.getHTMLTemplate() + + if result is not None: # Only test if template exists + self.assertIsInstance(result, str) + + # Check for basic HTML structure + self.assertIn(" * +# * * +# * This file is part of FreeCAD. * +# * * +# * FreeCAD is free software: you can redistribute it and/or modify it * +# * under the terms of the GNU Lesser General Public License as * +# * published by the Free Software Foundation, either version 2.1 of the * +# * License, or (at your option) any later version. * +# * * +# * 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 Lesser General Public * +# * License along with FreeCAD. If not, see * +# * . * +# * * +# *************************************************************************** + + +""" +Unit tests for Gui WebGL export functionality. Tests both the template handling and +the main export function. Non-gui tests are in `TestWebGLExport.py`. +""" + +import os +import tempfile +import unittest +from unittest.mock import patch, MagicMock, mock_open + +from BIM.importers import importWebGL +from .TestArchBase import TestArchBase + + +class TestWebGLExportGui(TestArchBase): + + def setUp(self): + """Using TestArchBase setUp to initialize the document for convenience, + but also create a temporary directory for tests.""" + super().setUp() + self.test_dir = tempfile.mkdtemp() + self.test_template_content = """ + + + $pagetitle + WebGL Content: $data + + """ + + def tearDown(self): + import shutil + + shutil.rmtree(self.test_dir, ignore_errors=True) + super().tearDown() + + @patch("BIM.importers.importWebGL.FreeCADGui") + def test_custom_template_not_found_gui_user_accepts_fallback( + self, mock_gui + ): + """Test GUI dialog when custom template not found - + user accepts fallback""" + operation = "Testing GUI custom template not found - user accepts fallback" + self.printTestMessage(operation) + from PySide import QtWidgets + + mock_gui.getMainWindow.return_value = MagicMock() + + with patch( + "BIM.importers.importWebGL.params.get_param" + ) as mock_params: + mock_params.side_effect = lambda param, path=None: { + "useCustomWebGLExportTemplate": True, + "WebGLTemplateCustomPath": "/nonexistent/template.html", + }.get(param, False) + + with patch( + "PySide.QtWidgets.QMessageBox.question", + return_value=QtWidgets.QMessageBox.Yes, + ): + with patch("os.path.isfile", return_value=True): + with patch( + "builtins.open", + mock_open(read_data=self.test_template_content), + ): + result = importWebGL.getHTMLTemplate() + self.assertIsNotNone(result) + + @patch("BIM.importers.importWebGL.FreeCADGui") + def test_custom_template_not_found_gui_user_rejects_fallback( + self, mock_gui + ): + """Test GUI dialog when custom template not found - + user rejects fallback""" + operation = "Testing GUI custom template not found - user rejects fallback" + self.printTestMessage(operation) + from PySide import QtWidgets + + mock_gui.getMainWindow.return_value = MagicMock() + + with patch( + "BIM.importers.importWebGL.params.get_param" + ) as mock_params: + mock_params.side_effect = lambda param, path=None: { + "useCustomWebGLExportTemplate": True, + "WebGLTemplateCustomPath": "/nonexistent/template.html", + }.get(param, False) + + with patch( + "PySide.QtWidgets.QMessageBox.question", + return_value=QtWidgets.QMessageBox.No, + ): + result = importWebGL.getHTMLTemplate() + self.assertIsNone(result) + + def test_export_returns_false_when_no_template(self): + """Test that export function returns False when + no template is available""" + operation = "Testing export returns False when no template available" + self.printTestMessage(operation) + with patch( + "BIM.importers.importWebGL.getHTMLTemplate", return_value=None + ): + with patch("BIM.importers.importWebGL.FreeCADGui") as mock_gui: + # Mock the GUI components that might be accessed + mock_active_doc = MagicMock() + mock_active_doc.ActiveView = MagicMock() + mock_gui.ActiveDocument = mock_active_doc + + result = importWebGL.export( + [], os.path.join(self.test_dir, "test.html") + ) + self.assertFalse(result) + + def test_export_returns_true_when_template_available(self): + """Test that export function returns True when template is available""" + operation = "Testing export returns True when template available" + self.printTestMessage(operation) + mock_template = """ + $pagetitle $version $data $threejs_version + """ + + with patch( + "BIM.importers.importWebGL.getHTMLTemplate", + return_value=mock_template, + ): + with patch( + "BIM.importers.importWebGL.FreeCAD.ActiveDocument" + ) as mock_doc: + mock_doc.Label = "Test Document" + with patch("BIM.importers.importWebGL.FreeCADGui") as mock_gui: + # Mock the GUI components that might be accessed + mock_active_doc = MagicMock() + mock_active_doc.ActiveView = MagicMock() + mock_gui.ActiveDocument = mock_active_doc + + # Mock the functions that populate data to return JSON-serializable values + with patch( + "BIM.importers.importWebGL.populate_camera" + ) as mock_populate_camera: + with patch( + "BIM.importers.importWebGL.Draft.get_group_contents", + return_value=[], + ): + mock_populate_camera.return_value = ( + None # Modifies data dict in place + ) + + result = importWebGL.export( + [], os.path.join(self.test_dir, "test.html") + ) + self.assertTrue(result) + + +if __name__ == "__main__": + # Allow running tests directly + unittest.main(verbosity=2) diff --git a/src/Mod/BIM/importers/importWebGL.py b/src/Mod/BIM/importers/importWebGL.py index 81d1da73a3..22369d24f9 100644 --- a/src/Mod/BIM/importers/importWebGL.py +++ b/src/Mod/BIM/importers/importWebGL.py @@ -4,6 +4,7 @@ # * * # * Copyright (c) 2013 Yorik van Havre * # * Copyright (c) 2020 Travis Apple * +# * Copyright (c) 2025 baidakovil * # * * # * This file is part of FreeCAD. * # * * @@ -44,13 +45,16 @@ # # This module provides tools to export HTML files containing the # exported objects in WebGL format and a simple three.js-based viewer. +# Tests are provided in src/Mod/BIM/bimtests/TestWebGLExport.py. +# The template is provided in src/Mod/BIM/Resources/templates/webgl_export_template.html. """FreeCAD WebGL Exporter""" import json +import os import textwrap from builtins import open as pyopen -from typing import NotRequired, TypedDict +from typing_extensions import NotRequired, TypedDict import numpy as np @@ -59,9 +63,11 @@ import Draft import Mesh import OfflineRenderingUtils import Part +from draftutils import params if FreeCAD.GuiUp: import FreeCADGui + from PySide import QtWidgets from draftutils.translate import translate else: FreeCADGui = None @@ -77,666 +83,137 @@ threejs_version = "0.172.0" def getHTMLTemplate(): - return textwrap.dedent("""\ - - - - $pagetitle - - - - - - - - - - - - - """) + return None def export( exportList, filename: str, colors: dict[str, str] | None = None, camera: str | None = None -): - """Exports objects to an html file""" +) -> bool: + """Exports objects to an html file. + + Returns: + bool: True if export was successful, False if not (particulary, + False if no template was available). + """ + + # Check template availability first, before any processing + html = getHTMLTemplate() + if html is None: + # No template available - export failed + return False global disableCompression, base, baseFloat @@ -894,7 +371,6 @@ def export( data["objects"].append(objdata) - html = getHTMLTemplate() html = html.replace("$pagetitle", FreeCAD.ActiveDocument.Label) version = FreeCAD.Version() html = html.replace("$version", f"{version[0]}.{version[1]}.{version[2]}") @@ -910,6 +386,7 @@ def export( with pyopen(filename, "w", encoding="utf-8") as outfile: outfile.write(html) FreeCAD.Console.PrintMessage(translate("Arch", "Successfully written") + f" {filename}\n") + return True def get_view_properties(obj, label: str, colors: dict[str, str] | None) -> tuple[str, float]: diff --git a/src/Mod/Draft/draftutils/params.py b/src/Mod/Draft/draftutils/params.py index f2046e5c52..2720f4d81d 100644 --- a/src/Mod/Draft/draftutils/params.py +++ b/src/Mod/Draft/draftutils/params.py @@ -643,7 +643,8 @@ def _get_param_dictionary(): ":/ui/preferences-dae.ui", ":/ui/preferences-ifc.ui", ":/ui/preferences-ifc-export.ui", - ":/ui/preferences-sh3d-import.ui",): + ":/ui/preferences-sh3d-import.ui", + ":/ui/preferences-webgl.ui",): # https://stackoverflow.com/questions/14750997/load-txt-file-from-resources-in-python fd = QtCore.QFile(fnm)