# SPDX-License-Identifier: LGPL-2.1-or-later # *************************************************************************** # * Copyright (c) 2024 Ondsel * # * Copyright (c) 2025 Billy Huddleston * # * * # * 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 * # * * # *************************************************************************** from PySide import QtGui, QtCore import FreeCAD import FreeCADGui import Path.Log import os import tempfile if False: Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) Path.Log.trackModule(Path.Log.thisModule()) else: Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) class ImageBuilder: def __init__(self, file_path): self.file_path = file_path def build_image(self, obj, image_name, as_bytes=False, view="default"): raise NotImplementedError("Subclass must implement abstract method") def save_image(self, image): raise NotImplementedError("Subclass must implement abstract method") class ImageBuilderFactory: @staticmethod def get_image_builder(file_path, **kwargs): # return DummyImageBuilder(file_path, **kwargs) if FreeCAD.GuiUp: return GuiImageBuilder(file_path, **kwargs) else: return DummyImageBuilder(file_path, **kwargs) # return NonGuiImageBuilder(file_path, **kwargs) class DummyImageBuilder(ImageBuilder): def __init__(self, file_path): Path.Log.debug("Initializing dummyimagebuilder") super().__init__(file_path) def build_image(self, obj, imageName, as_bytes=False, view="default"): if as_bytes: return b"" return self.file_path class GuiImageBuilder(ImageBuilder): """ A class for generating images of 3D objects in a FreeCAD GUI environment. """ def __init__(self, file_path): super().__init__(file_path) Path.Log.debug("Initializing GuiImageBuilder") self.file_path = file_path self.currentCamera = FreeCADGui.ActiveDocument.ActiveView.getCameraType() self.doc = FreeCADGui.ActiveDocument def __del__(self): Path.Log.debug("Destroying GuiImageBuilder") self.restore_visibility() def prepare_view(self, obj, view="default"): # Create a new view Path.Log.debug("CAM - Preparing view\n") mw = FreeCADGui.getMainWindow() num_windows = len(mw.getWindows()) # Create and configure the view view_obj = FreeCADGui.ActiveDocument.createView("Gui::View3DInventor") view_obj.setAnimationEnabled(False) view_obj.setCameraType("Orthographic") if view == "headon": view_obj.viewFront() else: view_obj.viewIsometric() # Resize the window mdi = mw.findChild(QtGui.QMdiArea) view_window = mdi.activeSubWindow() view_window.resize(500, 500) view_window.showMaximized() # First make everything invisible self.record_visibility() # Then make only our target object visible and select it obj.Visibility = True FreeCADGui.Selection.clearSelection() FreeCADGui.Selection.addSelection(obj) FreeCADGui.Selection.clearSelection() # Clear so the selection highlight does not appear in the image # Get the active view and fit to selection a_view = FreeCADGui.activeDocument().activeView() try: a_view.fitAll() # First fit all to ensure the object is in view FreeCADGui.updateGui() a_view.fitSelection() # Then try to fit to the selection except Exception: # If fitSelection fails, we already called fitAll pass # Return the index of the new window (= old number of windows) return num_windows def record_visibility(self): self.visible = [o for o in self.doc.Document.Objects if o.Visibility] for o in self.doc.Document.Objects: o.Visibility = False def destroy_view(self, idx): Path.Log.debug("CAM - destroying view\n") mw = FreeCADGui.getMainWindow() windows = mw.getWindows() mw.removeWindow(windows[idx]) def restore_visibility(self): Path.Log.debug("CAM - Restoring visibility\n") for o in self.visible: o.Visibility = True def build_image(self, obj, image_name, as_bytes=False, view="default"): Path.Log.debug("CAM - Building image\n") """ Makes an image of the target object. Returns either the image as bytes or a filename. """ idx = self.prepare_view(obj, view=view) if as_bytes: # Capture directly to memory without writing to disk img_bytes = self.capture_image_to_bytes() self.destroy_view(idx) return img_bytes else: # Write to disk as before file_path = os.path.join(self.file_path, image_name) self.capture_image(file_path) self.destroy_view(idx) result = f"{file_path}_t.png" Path.Log.debug(f"Image saved to: {result}") return result def capture_image(self, file_path): FreeCADGui.updateGui() Path.Log.debug("CAM - capture image to file\n") a_view = FreeCADGui.activeDocument().activeView() # Generate higher resolution images - 800x800 pixels for better quality on high-DPI displays a_view.saveImage(file_path + ".png", 800, 800, "Current") a_view.saveImage(file_path + "_t.png", 800, 800, "Transparent") a_view.setAnimationEnabled(True) def capture_image_to_bytes(self): """Capture the current view directly to bytes without writing to disk""" FreeCADGui.updateGui() Path.Log.debug("CAM - capture image to bytes\n") a_view = FreeCADGui.activeDocument().activeView() try: # Use FreeCAD's built-in method for getting a QImage directly # This approach is based on the same method the viewport uses internally qimg = a_view.grabFramebuffer() # Convert QImage to bytes using QBuffer (purely in memory) buffer = QtCore.QBuffer() buffer.open(QtCore.QIODevice.WriteOnly) qimg.save(buffer, "PNG") img_bytes = buffer.data().data() buffer.close() a_view.setAnimationEnabled(True) return img_bytes except Exception as e: # Fallback to temporary file approach if the direct method fails Path.Log.debug(f"Direct image capture failed: {e}, using fallback method") with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as temp: temp_path = temp.name # saveImage doesn't write to memory directly, so we need to use a temporary file # Generate higher resolution images - 800x800 pixels for better quality on high-DPI displays a_view.saveImage(temp_path, 800, 800, "Transparent") # Read the temporary file into memory with open(temp_path, "rb") as f: img_bytes = f.read() # Clean up the temporary file try: os.unlink(temp_path) except Exception: # Ignore errors during temporary file cleanup, as failure to delete is non-critical pass a_view.setAnimationEnabled(True) return img_bytes class NonGuiImageBuilder(ImageBuilder): def __init__(self, file_path): super().__init__(file_path) Path.Log.debug("nonguiimagebuilder") def build_image(self, obj, image_name, as_bytes=False, view="default"): """ Generates a headless picture of a 3D object and saves it as a PNG and optionally a PostScript file. Args: - obj: The 3D object to generate an image for. - image_name: Base name for the output image file without extension. Returns: - A boolean indicating the success of the operation. """ # Ensure the 'Part' and 'coin' modules are available, along with necessary attributes/methods. if not hasattr(obj, "Shape") or not hasattr(obj.Shape, "writeInventor"): Path.Log.debug("Object does not have the required attributes.") return False try: # Generate Inventor data from the object's shape iv = obj.Shape.writeInventor() # Prepare Inventor data for rendering inp = coin.SoInput() inp.setBuffer(iv) data = coin.SoDB.readAll(inp) if data is None: Path.Log.debug("Failed to read Inventor data.") return False # Setup the scene base = coin.SoBaseColor() base.rgb.setValue(0.6, 0.7, 1.0) data.insertChild(base, 0) root = coin.SoSeparator() light = coin.SoDirectionalLight() cam = coin.SoOrthographicCamera() root.addChild(cam) root.addChild(light) root.addChild(data) # Camera and rendering setup axo = coin.SbRotation(-0.353553, -0.146447, -0.353553, -0.853553) viewport = coin.SbViewportRegion(400, 400) cam.orientation.setValue(axo) cam.viewAll(root, viewport) off = coin.SoOffscreenRenderer(viewport) root.ref() ret = off.render(root) root.unref() if as_bytes: qimg = off.getQImage() buffer = QtCore.QBuffer() buffer.open(QtCore.QIODevice.WriteOnly) qimg.save(buffer, "PNG") return buffer.data().data() else: if off.isWriteSupported("PNG"): file_path = f"{self.file_path}{os.path.sep}{image_name}.png" off.writeToFile(file_path, "PNG") return file_path else: Path.Log.debug("PNG format is not supported.") return False except Exception as e: print(f"An error occurred: {e}") return False