""" ocp_screenshot.py ================= Screenshot d'un modèle build123d via OCP (OCCT), fond transparent. Dépendances : build123d, Pillow, numpy Nécessite : Xvfb (sudo apt install xvfb) Lancement --------- # Directement (si DISPLAY est déjà dispo) : python ocp_screenshot.py # Headless (sans serveur X) : xvfb-run -a python ocp_screenshot.py API --- from ocp_screenshot import screenshot screenshot( shape, # build123d Shape / Compound / Part "output.png", width=800, height=600, orientation="ZupRight", # voir ORIENTATIONS ci-dessous fit_padding=0.05, # marge autour du modèle (0–1) background=None, # None = transparent, ou "#RRGGBB" color="#A8C4DC", # couleur du solide line_color="#1a1a1a", # couleur des arêtes show_edges=True, ) Orientations disponibles ------------------------ ZupRight → isométrique Z-haut, côté droit (défaut, standard build123d) ZupLeft → isométrique Z-haut, côté gauche YupRight → isométrique Y-haut, côté droit YupLeft → isométrique Y-haut, côté gauche Top / Bottom / Front / Back / Left / Right """ import subprocess, sys, os, tempfile import numpy as np from PIL import Image # ── Orientations ──────────────────────────────────────────────────────────── from OCP.V3d import V3d_TypeOfOrientation as _Ori ORIENTATIONS = { "ZupRight" : _Ori.V3d_TypeOfOrientation_Zup_AxoRight, "ZupLeft" : _Ori.V3d_TypeOfOrientation_Zup_AxoLeft, "YupRight" : _Ori.V3d_TypeOfOrientation_Yup_AxoRight, "YupLeft" : _Ori.V3d_TypeOfOrientation_Yup_AxoLeft, "Top" : _Ori.V3d_Zpos, "Bottom" : _Ori.V3d_Zneg, "Front" : _Ori.V3d_Xpos, "Back" : _Ori.V3d_Xneg, "Left" : _Ori.V3d_Ypos, "Right" : _Ori.V3d_Yneg, } def _hex_to_occ_color(hex_str: str): """Convertit '#RRGGBB' en Quantity_Color OCC.""" from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB h = hex_str.lstrip("#") r, g, b = (int(h[i:i+2], 16) / 255.0 for i in (0, 2, 4)) return Quantity_Color(r, g, b, Quantity_TOC_RGB) _OCC_SESSION: dict = {} # conn + driver + viewer réutilisés dans le même process def _make_viewer(width: int, height: int): """ Crée (ou réutilise) un viewer OCC offscreen. Aspect_DisplayConnection ne peut être instancié qu'une fois par process sous Xvfb — on conserve donc la connexion et le driver en cache de module. Chaque appel crée en revanche une nouvelle View (fenêtre offscreen). """ from OCP.Aspect import Aspect_DisplayConnection from OCP.OpenGl import OpenGl_GraphicDriver from OCP.V3d import V3d_Viewer from OCP.Xw import Xw_Window if "viewer" not in _OCC_SESSION: conn = Aspect_DisplayConnection() driver = OpenGl_GraphicDriver(conn, False) viewer = V3d_Viewer(driver) viewer.SetDefaultLights() viewer.SetLightOn() _OCC_SESSION["conn"] = conn _OCC_SESSION["driver"] = driver _OCC_SESSION["viewer"] = viewer viewer = _OCC_SESSION["viewer"] conn = _OCC_SESSION["conn"] view = viewer.CreateView() win = Xw_Window(conn, "ocp_screenshot", 0, 0, width, height) view.SetWindow(win) win.Map() return viewer, view def _render_to_array(view, shape_occ, ctx, width: int, height: int, bg_color, orientation, fit_padding: float) -> np.ndarray: """Configure la vue, lance le rendu, retourne un array HxWx3 uint8.""" from OCP.Image import Image_AlienPixMap from OCP.TCollection import TCollection_AsciiString from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB view.SetBackgroundColor(bg_color) view.SetProj(orientation) view.FitAll(fit_padding) view.Redraw() pix = Image_AlienPixMap() view.ToPixMap(pix, width, height) # Sauvegarde temporaire puis lecture Pillow with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f: tmp = f.name pix.Save(TCollection_AsciiString(tmp)) arr = np.array(Image.open(tmp).convert("RGB"), dtype=np.float32) os.unlink(tmp) return arr def _double_render_transparent(view, width, height, orientation, fit_padding) -> np.ndarray: """ Technique double-render blanc/noir → canal alpha propre. Principe : sur fond blanc Cw = C·α + 1·(1−α) = C·α + 1 − α sur fond noir Cb = C·α donc α = 1 − (Cw − Cb) et C = Cb / α """ from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB white = Quantity_Color(1.0, 1.0, 1.0, Quantity_TOC_RGB) black = Quantity_Color(0.0, 0.0, 0.0, Quantity_TOC_RGB) w = _render_to_array(view, None, None, width, height, white, orientation, fit_padding) b = _render_to_array(view, None, None, width, height, black, orientation, fit_padding) # Alpha (par canal, on prend le max pour être robuste aux artefacts) alpha = 1.0 - (w - b) / 255.0 alpha = np.clip(alpha.max(axis=2), 0.0, 1.0) # Couleur pré-multipliée → non-pré-multipliée a3 = alpha[..., np.newaxis] rgb = np.where(a3 > 0.01, b / np.maximum(a3, 0.01), 0.0) rgb = np.clip(rgb, 0, 255).astype(np.uint8) return np.dstack([rgb, (alpha * 255).astype(np.uint8)]) # RGBA def screenshot( shape, output_path: str, width: int = 800, height: int = 600, orientation: str = "ZupRight", fit_padding: float = 0.05, background: "str | None" = None, color: str = "#A8C4DC", line_color: str = "#1a1a1a", show_edges: bool = True, ): """ Génère un screenshot PNG du modèle build123d. Parameters ---------- shape : build123d Shape (Solid, Compound, Part…) output_path : chemin de sortie (.png) width/height : dimensions en pixels orientation : voir ORIENTATIONS (défaut "ZupRight") fit_padding : marge relative autour du modèle (0.05 = 5 %) background : None → fond transparent | "#RRGGBB" → fond coloré color : couleur du solide (hex) line_color : couleur des arêtes (hex) show_edges : afficher les arêtes """ from OCP.AIS import AIS_InteractiveContext, AIS_Shape from OCP.Prs3d import Prs3d_Drawer from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB from OCP.Aspect import Aspect_TypeOfLine if orientation not in ORIENTATIONS: raise ValueError( f"Orientation '{orientation}' inconnue. " f"Choisir parmi : {list(ORIENTATIONS.keys())}" ) ori = ORIENTATIONS[orientation] # Extraire le TopoDS_Shape depuis build123d occ_shape = shape.wrapped viewer, view = _make_viewer(width, height) ctx = AIS_InteractiveContext(viewer) # Couleur du solide ais = AIS_Shape(occ_shape) ais.SetColor(_hex_to_occ_color(color)) ais.SetTransparency(0.0) if show_edges: # Configurer les arêtes dans le drawer drawer = ais.Attributes() drawer.SetFaceBoundaryDraw(True) drawer.FaceBoundaryAspect().SetColor(_hex_to_occ_color(line_color)) drawer.FaceBoundaryAspect().SetWidth(1.0) ctx.Display(ais, True) view.SetProj(ori) view.FitAll(fit_padding) if background is None: # Fond transparent via double-render rgba = _double_render_transparent(view, width, height, ori, fit_padding) Image.fromarray(rgba, "RGBA").save(output_path) else: # Fond coloré direct from OCP.Image import Image_AlienPixMap from OCP.TCollection import TCollection_AsciiString view.SetBackgroundColor(_hex_to_occ_color(background)) view.Redraw() pix = Image_AlienPixMap() view.ToPixMap(pix, width, height) pix.Save(TCollection_AsciiString(output_path)) print(f"✓ {output_path} ({width}×{height}px, orientation={orientation})") # ───────────────────────────────────────────── # Exemple d'utilisation # ───────────────────────────────────────────── if __name__ == "__main__": from build123d import * # Modèle simple : boîte + cylindre (Compound) with BuildPart() as p: Box(40, 30, 15) with Locations((0, 0, 15)): Cylinder(8, 20) # Fond transparent screenshot( p.part, "model_transparent.png", width=800, height=600, orientation="ZupRight", color="#5B8DB8", ) # Fond coloré screenshot( p.part, "model_bg.png", width=800, height=600, orientation="ZupRight", background="#F0EDE6", color="#5B8DB8", ) # Quelques orientations for ori in ["Top", "Front", "ZupLeft"]: screenshot( p.part, f"model_{ori.lower()}.png", width=600, height=450, orientation=ori, color="#C0392B", )