12 changed files with 1612 additions and 140 deletions
@ -0,0 +1,569 @@
|
||||
""" |
||||
iso_render.py |
||||
============= |
||||
Rendu isométrique SVG pour build123d. |
||||
|
||||
Usage rapide |
||||
------------ |
||||
from build123d import * |
||||
from iso_render import IsoRenderer, PaperSheet, Scene |
||||
|
||||
model = Box(40, 30, 20) |
||||
|
||||
sheet = PaperSheet( |
||||
svg_content=open("content.svg").read(), # SVG couleur |
||||
width=80, height=110, # dimensions réelles (mm) |
||||
position=(0, 0, 0), # centre de la feuille |
||||
rotation_x=0, rotation_y=0, rotation_z=0 # rotations en degrés |
||||
) |
||||
|
||||
scene = Scene() |
||||
scene.add_shape(model, base_color="#5B8DB8", position=(0, 0, 10)) |
||||
scene.add_paper(sheet) |
||||
|
||||
renderer = IsoRenderer(width=800, height=600, iso_angle=30) |
||||
renderer.render(scene, "output.svg") |
||||
""" |
||||
|
||||
import math |
||||
import re |
||||
import xml.etree.ElementTree as ET |
||||
from dataclasses import dataclass, field |
||||
from typing import Optional |
||||
import numpy as np |
||||
|
||||
|
||||
# ───────────────────────────────────────────── |
||||
# Projection isométrique |
||||
# ───────────────────────────────────────────── |
||||
|
||||
def iso_matrix(angle_deg: float = 30) -> np.ndarray: |
||||
""" |
||||
Matrice de projection isométrique 3D → 2D. |
||||
angle_deg : angle d'inclinaison vertical (30° = iso classique) |
||||
La rotation horizontale est fixée à 45°. |
||||
""" |
||||
a = math.radians(45) # rotation autour Y (azimut) |
||||
b = math.radians(angle_deg) # inclinaison |
||||
|
||||
# Rotation Y (azimut 45°) |
||||
Ry = np.array([ |
||||
[ math.cos(a), 0, math.sin(a)], |
||||
[ 0, 1, 0], |
||||
[-math.sin(a), 0, math.cos(a)], |
||||
]) |
||||
# Rotation X (inclinaison) |
||||
Rx = np.array([ |
||||
[1, 0, 0], |
||||
[0, math.cos(b), -math.sin(b)], |
||||
[0, math.sin(b), math.cos(b)], |
||||
]) |
||||
M = Rx @ Ry |
||||
# On ne garde que les 2 premières lignes (projection sur le plan écran) |
||||
return M[:2, :] # shape (2, 3) |
||||
|
||||
|
||||
def project(points: np.ndarray, M: np.ndarray) -> np.ndarray: |
||||
"""points: (N,3) → projected: (N,2)""" |
||||
return (M @ points.T).T |
||||
|
||||
|
||||
# ───────────────────────────────────────────── |
||||
# Shading |
||||
# ───────────────────────────────────────────── |
||||
|
||||
# Direction de la lumière (normalisée) |
||||
_LIGHT = np.array([0.6, 0.8, 1.0]) |
||||
_LIGHT /= np.linalg.norm(_LIGHT) |
||||
|
||||
|
||||
def shade_color(base_hex: str, normal: np.ndarray, ambient: float = 0.35) -> str: |
||||
"""Applique un shading lambertien à une couleur hex.""" |
||||
n = np.array(normal, dtype=float) |
||||
nn = np.linalg.norm(n) |
||||
if nn < 1e-9: |
||||
return base_hex |
||||
n /= nn |
||||
diff = max(0.0, float(np.dot(n, _LIGHT))) |
||||
intensity = ambient + (1 - ambient) * diff |
||||
|
||||
r = int(base_hex[1:3], 16) |
||||
g = int(base_hex[3:5], 16) |
||||
b = int(base_hex[5:7], 16) |
||||
r2 = min(255, int(r * intensity)) |
||||
g2 = min(255, int(g * intensity)) |
||||
b2 = min(255, int(b * intensity)) |
||||
return f"#{r2:02x}{g2:02x}{b2:02x}" |
||||
|
||||
|
||||
def hex_to_rgb(h: str) -> tuple: |
||||
h = h.lstrip("#") |
||||
return tuple(int(h[i:i+2], 16) for i in (0, 2, 4)) |
||||
|
||||
|
||||
# ───────────────────────────────────────────── |
||||
# Structures de données |
||||
# ───────────────────────────────────────────── |
||||
|
||||
@dataclass |
||||
class PaperSheet: |
||||
""" |
||||
Feuille de papier positionnée dans la scène. |
||||
|
||||
Paramètres |
||||
---------- |
||||
svg_content : str |
||||
Contenu SVG brut (chaîne). Sera intégré tel quel dans le rendu. |
||||
width, height : float |
||||
Dimensions de la feuille en unités scène (mm conseillé). |
||||
position : tuple (x, y, z) |
||||
Centre de la feuille dans l'espace 3D. |
||||
rotation_x/y/z : float |
||||
Rotations en degrés autour des axes X, Y, Z (dans cet ordre). |
||||
paper_color : str |
||||
Couleur de fond de la feuille (blanc par défaut). |
||||
shadow : bool |
||||
Ajouter une légère ombre portée sous la feuille. |
||||
""" |
||||
svg_content: str |
||||
width: float = 80.0 |
||||
height: float = 110.0 |
||||
position: tuple = (0.0, 0.0, 0.0) |
||||
rotation_x: float = 0.0 |
||||
rotation_y: float = 0.0 |
||||
rotation_z: float = 0.0 |
||||
paper_color: str = "#FAFAF8" |
||||
shadow: bool = True |
||||
|
||||
def corners_3d(self) -> np.ndarray: |
||||
"""Retourne les 4 coins de la feuille dans l'espace 3D.""" |
||||
hw, hh = self.width / 2, self.height / 2 |
||||
# Coins locaux : bas-gauche, bas-droite, haut-droite, haut-gauche |
||||
local = np.array([ |
||||
[-hw, -hh, 0], |
||||
[ hw, -hh, 0], |
||||
[ hw, hh, 0], |
||||
[-hw, hh, 0], |
||||
], dtype=float) |
||||
# Rotations |
||||
for angle, axis in [ |
||||
(self.rotation_x, 'x'), |
||||
(self.rotation_y, 'y'), |
||||
(self.rotation_z, 'z'), |
||||
]: |
||||
local = _rotate(local, angle, axis) |
||||
# Translation |
||||
px, py, pz = self.position |
||||
local += np.array([px, py, pz]) |
||||
return local |
||||
|
||||
def normal_3d(self) -> np.ndarray: |
||||
"""Normale de la feuille après rotations.""" |
||||
n = np.array([0.0, 0.0, 1.0]) |
||||
n = _rotate(n[np.newaxis], self.rotation_x, 'x')[0] |
||||
n = _rotate(n[np.newaxis], self.rotation_y, 'y')[0] |
||||
n = _rotate(n[np.newaxis], self.rotation_z, 'z')[0] |
||||
return n |
||||
|
||||
|
||||
def _rotate(pts: np.ndarray, angle_deg: float, axis: str) -> np.ndarray: |
||||
if angle_deg == 0: |
||||
return pts |
||||
a = math.radians(angle_deg) |
||||
c, s = math.cos(a), math.sin(a) |
||||
if axis == 'x': |
||||
R = np.array([[1,0,0],[0,c,-s],[0,s,c]]) |
||||
elif axis == 'y': |
||||
R = np.array([[c,0,s],[0,1,0],[-s,0,c]]) |
||||
else: |
||||
R = np.array([[c,-s,0],[s,c,0],[0,0,1]]) |
||||
return (R @ pts.T).T |
||||
|
||||
|
||||
@dataclass |
||||
class SceneShape: |
||||
"""Un solide build123d dans la scène.""" |
||||
shape: object # build123d Shape |
||||
base_color: str = "#5B8DB8" |
||||
position: tuple = (0.0, 0.0, 0.0) |
||||
rotation_x: float = 0.0 |
||||
rotation_y: float = 0.0 |
||||
rotation_z: float = 0.0 |
||||
tessellation_tolerance: float = 0.5 |
||||
|
||||
|
||||
@dataclass |
||||
class Scene: |
||||
"""Conteneur de la scène.""" |
||||
_shapes: list = field(default_factory=list) |
||||
_papers: list = field(default_factory=list) |
||||
|
||||
def add_shape(self, shape, base_color: str = "#5B8DB8", |
||||
position=(0, 0, 0), |
||||
rotation_x=0.0, rotation_y=0.0, rotation_z=0.0, |
||||
tessellation_tolerance=0.5): |
||||
self._shapes.append(SceneShape( |
||||
shape=shape, |
||||
base_color=base_color, |
||||
position=position, |
||||
rotation_x=rotation_x, |
||||
rotation_y=rotation_y, |
||||
rotation_z=rotation_z, |
||||
tessellation_tolerance=tessellation_tolerance, |
||||
)) |
||||
|
||||
def add_paper(self, paper: PaperSheet): |
||||
self._papers.append(paper) |
||||
|
||||
|
||||
# ───────────────────────────────────────────── |
||||
# Extraction des polygones depuis build123d |
||||
# ───────────────────────────────────────────── |
||||
|
||||
def _extract_face_polygons(scene_shape: SceneShape): |
||||
""" |
||||
Retourne une liste de dicts : |
||||
{ 'pts': np.ndarray (N,3), 'normal': np.ndarray (3,), 'color_base': str } |
||||
""" |
||||
shape = scene_shape.shape |
||||
px, py, pz = scene_shape.position |
||||
tol = scene_shape.tessellation_tolerance |
||||
polys = [] |
||||
|
||||
for face in shape.faces(): |
||||
verts, tris = face.tessellate(tol) |
||||
|
||||
# Normale de la face |
||||
try: |
||||
n = face.normal_at() |
||||
normal = np.array([n.X, n.Y, n.Z], dtype=float) |
||||
except Exception: |
||||
normal = np.array([0.0, 0.0, 1.0]) |
||||
|
||||
# Appliquer les rotations de l'objet |
||||
for angle, axis in [ |
||||
(scene_shape.rotation_x, 'x'), |
||||
(scene_shape.rotation_y, 'y'), |
||||
(scene_shape.rotation_z, 'z'), |
||||
]: |
||||
normal = _rotate(normal[np.newaxis], angle, axis)[0] |
||||
|
||||
# Grouper les triangles en polygone (ici chaque tri est un poly) |
||||
for tri in tris: |
||||
pts = np.array([[verts[i].X, verts[i].Y, verts[i].Z] |
||||
for i in tri], dtype=float) |
||||
# Appliquer les rotations |
||||
for angle, axis in [ |
||||
(scene_shape.rotation_x, 'x'), |
||||
(scene_shape.rotation_y, 'y'), |
||||
(scene_shape.rotation_z, 'z'), |
||||
]: |
||||
pts = _rotate(pts, angle, axis) |
||||
# Translation |
||||
pts += np.array([px, py, pz]) |
||||
|
||||
polys.append({ |
||||
'pts': pts, |
||||
'normal': normal, |
||||
'color_base': scene_shape.base_color, |
||||
}) |
||||
|
||||
return polys |
||||
|
||||
|
||||
# ───────────────────────────────────────────── |
||||
# Painter's algorithm (tri par profondeur) |
||||
# ───────────────────────────────────────────── |
||||
|
||||
def _centroid_depth(pts: np.ndarray, M: np.ndarray) -> float: |
||||
"""Profondeur moyenne d'un polygone projeté (composante Z de la vue).""" |
||||
# On utilise la 3ème ligne de la matrice complète Rx@Ry pour Z |
||||
return float(np.mean(pts[:, 2])) # approximation simple : Z monde |
||||
|
||||
|
||||
# ───────────────────────────────────────────── |
||||
# Manipulation SVG |
||||
# ───────────────────────────────────────────── |
||||
|
||||
def _parse_svg_viewbox(svg_str: str) -> Optional[tuple]: |
||||
"""Extrait (min_x, min_y, width, height) depuis le viewBox.""" |
||||
m = re.search(r'viewBox=["\']([^"\']+)["\']', svg_str) |
||||
if m: |
||||
parts = re.split(r'[\s,]+', m.group(1).strip()) |
||||
if len(parts) == 4: |
||||
return tuple(float(p) for p in parts) |
||||
# Essai width/height |
||||
w = re.search(r'<svg[^>]+width=["\']([0-9.]+)', svg_str) |
||||
h = re.search(r'<svg[^>]+height=["\']([0-9.]+)', svg_str) |
||||
if w and h: |
||||
return (0, 0, float(w.group(1)), float(h.group(1))) |
||||
return None |
||||
|
||||
|
||||
def _svg_inner_content(svg_str: str) -> str: |
||||
"""Extrait le contenu entre les balises <svg> ... </svg>.""" |
||||
m = re.search(r'<svg[^>]*>(.*)</svg>', svg_str, re.DOTALL) |
||||
return m.group(1) if m else svg_str |
||||
|
||||
|
||||
def _strip_svg_defs_ids(content: str, prefix: str) -> str: |
||||
"""Préfixe tous les ids pour éviter les collisions.""" |
||||
# Remplace id="xxx" et url(#xxx) avec un préfixe unique |
||||
content = re.sub(r'\bid="([^"]+)"', lambda m: f'id="{prefix}_{m.group(1)}"', content) |
||||
content = re.sub(r'url\(#([^)]+)\)', lambda m: f'url(#{prefix}_{m.group(1)})', content) |
||||
content = re.sub(r'href="#([^"]+)"', lambda m: f'href="#{prefix}_{m.group(1)}"', content) |
||||
return content |
||||
|
||||
|
||||
# ───────────────────────────────────────────── |
||||
# Renderer principal |
||||
# ───────────────────────────────────────────── |
||||
|
||||
class IsoRenderer: |
||||
""" |
||||
Renderer isométrique → SVG. |
||||
|
||||
Paramètres |
||||
---------- |
||||
width, height : int |
||||
Dimensions du SVG de sortie en pixels. |
||||
iso_angle : float |
||||
Angle d'inclinaison isométrique (30° = iso classique, 35.26° = cavalière). |
||||
scale : float ou None |
||||
Échelle pixels/unité. Si None, ajustement automatique. |
||||
background : str ou None |
||||
Couleur de fond (ex: "#F0EDE8"). None = transparent. |
||||
stroke_width : float |
||||
Épaisseur des contours. |
||||
stroke_color : str |
||||
Couleur des contours. |
||||
""" |
||||
|
||||
def __init__(self, |
||||
width: int = 800, |
||||
height: int = 600, |
||||
iso_angle: float = 30.0, |
||||
scale: Optional[float] = None, |
||||
background: Optional[str] = "#F5F3EF", |
||||
stroke_width: float = 0.4, |
||||
stroke_color: str = "#1a1a1a"): |
||||
self.width = width |
||||
self.height = height |
||||
self.iso_angle = iso_angle |
||||
self.scale = scale |
||||
self.background = background |
||||
self.stroke_width = stroke_width |
||||
self.stroke_color = stroke_color |
||||
self.M = iso_matrix(iso_angle) |
||||
|
||||
def render(self, scene: Scene, output_path: str): |
||||
"""Génère le fichier SVG.""" |
||||
|
||||
# 1. Collecter tous les polygones (shapes + papers) |
||||
all_polys = [] |
||||
|
||||
for ss in scene._shapes: |
||||
for poly in _extract_face_polygons(ss): |
||||
all_polys.append(('shape', poly, None)) |
||||
|
||||
for i, paper in enumerate(scene._papers): |
||||
corners = paper.corners_3d() |
||||
normal = paper.normal_3d() |
||||
all_polys.append(('paper', { |
||||
'pts': corners, |
||||
'normal': normal, |
||||
'color_base': paper.paper_color, |
||||
}, (i, paper))) |
||||
|
||||
if not all_polys: |
||||
raise ValueError("La scène est vide.") |
||||
|
||||
# 2. Projeter tous les points pour calculer les bounds |
||||
all_pts_3d = np.vstack([p[1]['pts'] for p in all_polys]) |
||||
all_pts_2d = project(all_pts_3d, self.M) |
||||
|
||||
min_x, min_y = all_pts_2d.min(axis=0) |
||||
max_x, max_y = all_pts_2d.max(axis=0) |
||||
span_x = max_x - min_x or 1 |
||||
span_y = max_y - min_y or 1 |
||||
|
||||
padding = 40 |
||||
if self.scale is None: |
||||
scale = min( |
||||
(self.width - 2 * padding) / span_x, |
||||
(self.height - 2 * padding) / span_y, |
||||
) |
||||
else: |
||||
scale = self.scale |
||||
|
||||
# Offset pour centrer |
||||
cx = (self.width - span_x * scale) / 2 - min_x * scale |
||||
cy = (self.height - span_y * scale) / 2 - min_y * scale |
||||
|
||||
def to_screen(pts_3d): |
||||
p2d = project(pts_3d, self.M) |
||||
sx = p2d[:, 0] * scale + cx |
||||
sy = p2d[:, 1] * scale + cy |
||||
# SVG : Y vers le bas, on inverse |
||||
sy = self.height - sy |
||||
return np.stack([sx, sy], axis=1) |
||||
|
||||
# 3. Trier par profondeur (painter's algorithm) |
||||
def depth_key(item): |
||||
_, poly, _ = item |
||||
return _centroid_depth(poly['pts'], self.M) |
||||
|
||||
all_polys.sort(key=depth_key) |
||||
|
||||
# 4. Construire le SVG |
||||
svg_lines = [ |
||||
f'<svg xmlns="http://www.w3.org/2000/svg" ' |
||||
f'xmlns:xlink="http://www.w3.org/1999/xlink" ' |
||||
f'width="{self.width}" height="{self.height}" ' |
||||
f'viewBox="0 0 {self.width} {self.height}">', |
||||
] |
||||
|
||||
# Defs (filtres shadow si nécessaire) |
||||
svg_lines.append('<defs>') |
||||
svg_lines.append( |
||||
'<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">' |
||||
'<feDropShadow dx="3" dy="4" stdDeviation="4" flood-color="#00000033"/>' |
||||
'</filter>' |
||||
) |
||||
# clipPath pour chaque feuille |
||||
for i, paper in enumerate(scene._papers): |
||||
corners_2d = to_screen(paper.corners_3d()) |
||||
pts_str = " ".join(f"{x:.2f},{y:.2f}" for x, y in corners_2d) |
||||
svg_lines.append( |
||||
f'<clipPath id="clip_paper_{i}">' |
||||
f'<polygon points="{pts_str}"/>' |
||||
f'</clipPath>' |
||||
) |
||||
svg_lines.append('</defs>') |
||||
|
||||
# Fond |
||||
if self.background: |
||||
svg_lines.append( |
||||
f'<rect width="{self.width}" height="{self.height}" fill="{self.background}"/>' |
||||
) |
||||
|
||||
# 5. Dessiner les polygones dans l'ordre |
||||
paper_indices_done = set() |
||||
|
||||
for kind, poly, extra in all_polys: |
||||
pts_2d = to_screen(poly['pts']) |
||||
pts_str = " ".join(f"{x:.2f},{y:.2f}" for x, y in pts_2d) |
||||
normal = poly['normal'] |
||||
|
||||
if kind == 'shape': |
||||
# Face 3D avec shading |
||||
# Backface culling léger : on garde toutes les faces visibles |
||||
color = shade_color(poly['color_base'], normal) |
||||
svg_lines.append( |
||||
f'<polygon points="{pts_str}" ' |
||||
f'fill="{color}" ' |
||||
f'stroke="{self.stroke_color}" ' |
||||
f'stroke-width="{self.stroke_width}" ' |
||||
f'stroke-linejoin="round"/>' |
||||
) |
||||
|
||||
elif kind == 'paper': |
||||
paper_idx, paper = extra |
||||
if paper_idx in paper_indices_done: |
||||
continue |
||||
paper_indices_done.add(paper_idx) |
||||
|
||||
corners_2d = to_screen(paper.corners_3d()) |
||||
pts_str_paper = " ".join(f"{x:.2f},{y:.2f}" for x, y in corners_2d) |
||||
|
||||
# Ombre portée |
||||
if paper.shadow: |
||||
svg_lines.append( |
||||
f'<polygon points="{pts_str_paper}" ' |
||||
f'fill="#00000022" filter="url(#shadow)" />' |
||||
) |
||||
|
||||
# Fond de la feuille (avec shading) |
||||
paper_color = shade_color(paper.paper_color, normal, ambient=0.7) |
||||
svg_lines.append( |
||||
f'<polygon points="{pts_str_paper}" ' |
||||
f'fill="{paper_color}" ' |
||||
f'stroke="{self.stroke_color}" ' |
||||
f'stroke-width="{self.stroke_width * 1.2:.2f}" ' |
||||
f'stroke-linejoin="round"/>' |
||||
) |
||||
|
||||
# Contenu SVG de la feuille |
||||
self._embed_svg_on_paper( |
||||
svg_lines, paper, corners_2d, paper_idx |
||||
) |
||||
|
||||
svg_lines.append('</svg>') |
||||
svg_str = "\n".join(svg_lines) |
||||
|
||||
with open(output_path, 'w', encoding='utf-8') as f: |
||||
f.write(svg_str) |
||||
print(f"✓ Rendu sauvegardé : {output_path}") |
||||
|
||||
def _embed_svg_on_paper(self, svg_lines, paper, corners_2d, paper_idx): |
||||
""" |
||||
Intègre le contenu SVG de la feuille en appliquant |
||||
une transformation perspective affine (homographie 2D approximée |
||||
par une transformation affine sur les 3 premiers coins). |
||||
""" |
||||
vb = _parse_svg_viewbox(paper.svg_content) |
||||
if vb is None: |
||||
# Fallback : pas de viewBox trouvé, on essaie quand même |
||||
vb = (0, 0, paper.width, paper.height) |
||||
|
||||
vb_x, vb_y, vb_w, vb_h = vb |
||||
|
||||
# Coins de destination dans l'écran (ordre : BG, BD, HD, HG) |
||||
dst = corners_2d # shape (4,2) |
||||
|
||||
# Coins source dans l'espace SVG original |
||||
src = np.array([ |
||||
[vb_x, vb_y + vb_h], # bas-gauche |
||||
[vb_x + vb_w, vb_y + vb_h], # bas-droite |
||||
[vb_x + vb_w, vb_y], # haut-droite |
||||
[vb_x, vb_y], # haut-gauche |
||||
], dtype=float) |
||||
|
||||
# Transformation affine approchée (3 points) |
||||
# On résout : dst[0:3] = A @ src[0:3] (en coordonnées homogènes) |
||||
S = np.array([ |
||||
[src[0,0], src[0,1], 1, 0, 0, 0], |
||||
[0, 0, 0, src[0,0], src[0,1], 1], |
||||
[src[1,0], src[1,1], 1, 0, 0, 0], |
||||
[0, 0, 0, src[1,0], src[1,1], 1], |
||||
[src[2,0], src[2,1], 1, 0, 0, 0], |
||||
[0, 0, 0, src[2,0], src[2,1], 1], |
||||
], dtype=float) |
||||
D = np.array([ |
||||
dst[0,0], dst[0,1], |
||||
dst[1,0], dst[1,1], |
||||
dst[2,0], dst[2,1], |
||||
], dtype=float) |
||||
|
||||
try: |
||||
params = np.linalg.solve(S, D) |
||||
a, b, c, d, e, f_ = params |
||||
# Matrice affine SVG : matrix(a,d,b,e,c,f) |
||||
transform = f"matrix({a:.6f},{d:.6f},{b:.6f},{e:.6f},{c:.6f},{f_:.6f})" |
||||
except np.linalg.LinAlgError: |
||||
# Fallback trivial |
||||
transform = "matrix(1,0,0,1,0,0)" |
||||
|
||||
# Préfixer les IDs pour éviter les collisions |
||||
prefix = f"p{paper_idx}" |
||||
inner = _svg_inner_content(paper.svg_content) |
||||
inner = _strip_svg_defs_ids(inner, prefix) |
||||
|
||||
pts_clip = " ".join(f"{x:.2f},{y:.2f}" for x, y in corners_2d) |
||||
|
||||
svg_lines.append( |
||||
f'<g clip-path="url(#clip_paper_{paper_idx})">' |
||||
f'<g transform="{transform}">' |
||||
+ inner + |
||||
f'</g></g>' |
||||
) |
||||
@ -0,0 +1,280 @@
|
||||
""" |
||||
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", |
||||
) |
||||
File diff suppressed because one or more lines are too long
@ -0,0 +1,84 @@
|
||||
{ |
||||
"cells": [ |
||||
{ |
||||
"cell_type": "code", |
||||
"execution_count": null, |
||||
"id": "initial_id", |
||||
"metadata": { |
||||
"collapsed": true |
||||
}, |
||||
"outputs": [], |
||||
"source": [ |
||||
"from build123d import *\n", |
||||
"from ocp_vscode import show, set_port\n", |
||||
"\n", |
||||
"pattes_largueur = 5\n", |
||||
"pattes_profondeur = 5\n", |
||||
"bras_patte_dist = 8\n", |
||||
"bloc_hauteur_totale = 10\n", |
||||
"bloc_hauteur = 6\n", |
||||
"pourtour_hauteur = bloc_hauteur_totale - bloc_hauteur\n", |
||||
"raiser_dim_x = 30\n", |
||||
"raiser_dim_y = 18\n", |
||||
"raiser_dim_z = 15\n", |
||||
"cav_bloc_dim_x = 24\n", |
||||
"cav_bloc_dim_y = 14\n", |
||||
"\n", |
||||
"with BuildPart() as bp:\n", |
||||
" raiser = Box(raiser_dim_x, raiser_dim_y, raiser_dim_z)\n", |
||||
" raiser_faces = raiser.faces()\n", |
||||
" bottom_face = faces().filter_by(Plane.top).sort_by(Axis.Z)[0]\n", |
||||
" with BuildSketch(Plane(bottom_face).reverse()):\n", |
||||
" Rectangle(cav_bloc_dim_x, cav_bloc_dim_y)\n", |
||||
" extrude(amount=(bloc_hauteur + 1), mode=Mode.SUBTRACT)\n", |
||||
"\n", |
||||
" for face in raiser_faces.filter_by(lambda f: abs(f.normal_at().Z) < 0.01):\n", |
||||
" plane = Plane(face)\n", |
||||
" offset_plane = plane.offset(bras_patte_dist)\n", |
||||
" with BuildSketch(offset_plane):\n", |
||||
" with Locations((bottom_face.center().Z,)):\n", |
||||
" Rectangle(2, 5, align=(Align.MIN, Align.CENTER))\n", |
||||
" with BuildSketch(face):\n", |
||||
" add(face.rotate(Axis.Y, 90)) # ← utilise directement la face comme sketch\n", |
||||
" loft()\n", |
||||
"\n", |
||||
" patte_plane = Plane(\n", |
||||
" origin=offset_plane.origin,\n", |
||||
" x_dir=-offset_plane.y_dir,\n", |
||||
" z_dir=(0, 0, 1)\n", |
||||
" ).offset(bottom_face.center().Z)\n", |
||||
" with BuildSketch(patte_plane):\n", |
||||
" Rectangle(5, bras_patte_dist - 1, align=(Align.CENTER, Align.MIN))\n", |
||||
" with BuildSketch(patte_plane.offset(-pourtour_hauteur)):\n", |
||||
" Rectangle(5, 5, align=(Align.CENTER, Align.MIN))\n", |
||||
"\n", |
||||
" loft()\n", |
||||
"\n", |
||||
"set_port(3939)\n", |
||||
"show(bp.part)\n", |
||||
"export_step(bp.part, \"/tmp/raiser.step\")" |
||||
] |
||||
} |
||||
], |
||||
"metadata": { |
||||
"kernelspec": { |
||||
"display_name": "Python 3", |
||||
"language": "python", |
||||
"name": "python3" |
||||
}, |
||||
"language_info": { |
||||
"codemirror_mode": { |
||||
"name": "ipython", |
||||
"version": 2 |
||||
}, |
||||
"file_extension": ".py", |
||||
"mimetype": "text/x-python", |
||||
"name": "python", |
||||
"nbconvert_exporter": "python", |
||||
"pygments_lexer": "ipython2", |
||||
"version": "2.7.6" |
||||
} |
||||
}, |
||||
"nbformat": 4, |
||||
"nbformat_minor": 5 |
||||
} |
||||
@ -0,0 +1,332 @@
|
||||
{ |
||||
"cells": [ |
||||
{ |
||||
"cell_type": "code", |
||||
"id": "initial_id", |
||||
"metadata": { |
||||
"collapsed": true, |
||||
"ExecuteTime": { |
||||
"end_time": "2026-05-25T22:35:40.534794354Z", |
||||
"start_time": "2026-05-25T22:35:37.180066505Z" |
||||
} |
||||
}, |
||||
"source": [ |
||||
"\"\"\"\n", |
||||
"ocp_screenshot.py\n", |
||||
"=================\n", |
||||
"Screenshot d'un modèle build123d via OCP (OCCT), fond transparent.\n", |
||||
"\n", |
||||
"Dépendances : build123d, Pillow, numpy\n", |
||||
"Nécessite : Xvfb (sudo apt install xvfb)\n", |
||||
"\n", |
||||
"Lancement\n", |
||||
"---------\n", |
||||
" # Directement (si DISPLAY est déjà dispo) :\n", |
||||
" python ocp_screenshot.py\n", |
||||
"\n", |
||||
" # Headless (sans serveur X) :\n", |
||||
" xvfb-run -a python ocp_screenshot.py\n", |
||||
"\n", |
||||
"API\n", |
||||
"---\n", |
||||
" from ocp_screenshot import screenshot\n", |
||||
"\n", |
||||
" screenshot(\n", |
||||
" shape, # build123d Shape / Compound / Part\n", |
||||
" \"output.png\",\n", |
||||
" width=800, height=600,\n", |
||||
" orientation=\"ZupRight\", # voir ORIENTATIONS ci-dessous\n", |
||||
" fit_padding=0.05, # marge autour du modèle (0–1)\n", |
||||
" background=None, # None = transparent, ou \"#RRGGBB\"\n", |
||||
" color=\"#A8C4DC\", # couleur du solide\n", |
||||
" line_color=\"#1a1a1a\", # couleur des arêtes\n", |
||||
" show_edges=True,\n", |
||||
" )\n", |
||||
"\n", |
||||
"Orientations disponibles\n", |
||||
"------------------------\n", |
||||
" ZupRight → isométrique Z-haut, côté droit (défaut, standard build123d)\n", |
||||
" ZupLeft → isométrique Z-haut, côté gauche\n", |
||||
" YupRight → isométrique Y-haut, côté droit\n", |
||||
" YupLeft → isométrique Y-haut, côté gauche\n", |
||||
" Top / Bottom / Front / Back / Left / Right\n", |
||||
"\"\"\"\n", |
||||
"\n", |
||||
"import subprocess, sys, os, tempfile\n", |
||||
"import numpy as np\n", |
||||
"from PIL import Image\n", |
||||
"\n", |
||||
"# ── Orientations ────────────────────────────────────────────────────────────\n", |
||||
"from OCP.V3d import V3d_TypeOfOrientation as _Ori\n", |
||||
"\n", |
||||
"ORIENTATIONS = {\n", |
||||
" \"ZupRight\" : _Ori.V3d_TypeOfOrientation_Zup_AxoRight,\n", |
||||
" \"ZupLeft\" : _Ori.V3d_TypeOfOrientation_Zup_AxoLeft,\n", |
||||
" \"YupRight\" : _Ori.V3d_TypeOfOrientation_Yup_AxoRight,\n", |
||||
" \"YupLeft\" : _Ori.V3d_TypeOfOrientation_Yup_AxoLeft,\n", |
||||
" \"Top\" : _Ori.V3d_Zpos,\n", |
||||
" \"Bottom\" : _Ori.V3d_Zneg,\n", |
||||
" \"Front\" : _Ori.V3d_Xpos,\n", |
||||
" \"Back\" : _Ori.V3d_Xneg,\n", |
||||
" \"Left\" : _Ori.V3d_Ypos,\n", |
||||
" \"Right\" : _Ori.V3d_Yneg,\n", |
||||
"}\n", |
||||
"\n", |
||||
"\n", |
||||
"def _hex_to_occ_color(hex_str: str):\n", |
||||
" \"\"\"Convertit '#RRGGBB' en Quantity_Color OCC.\"\"\"\n", |
||||
" from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB\n", |
||||
" h = hex_str.lstrip(\"#\")\n", |
||||
" r, g, b = (int(h[i:i+2], 16) / 255.0 for i in (0, 2, 4))\n", |
||||
" return Quantity_Color(r, g, b, Quantity_TOC_RGB)\n", |
||||
"\n", |
||||
"\n", |
||||
"_OCC_SESSION: dict = {} # conn + driver + viewer réutilisés dans le même process\n", |
||||
"\n", |
||||
"\n", |
||||
"def _make_viewer(width: int, height: int):\n", |
||||
" \"\"\"\n", |
||||
" Crée (ou réutilise) un viewer OCC offscreen.\n", |
||||
"\n", |
||||
" Aspect_DisplayConnection ne peut être instancié qu'une fois par process\n", |
||||
" sous Xvfb — on conserve donc la connexion et le driver en cache de module.\n", |
||||
" Chaque appel crée en revanche une nouvelle View (fenêtre offscreen).\n", |
||||
" \"\"\"\n", |
||||
" from OCP.Aspect import Aspect_DisplayConnection\n", |
||||
" from OCP.OpenGl import OpenGl_GraphicDriver\n", |
||||
" from OCP.V3d import V3d_Viewer\n", |
||||
" from OCP.Xw import Xw_Window\n", |
||||
"\n", |
||||
" if \"viewer\" not in _OCC_SESSION:\n", |
||||
" conn = Aspect_DisplayConnection()\n", |
||||
" driver = OpenGl_GraphicDriver(conn, False)\n", |
||||
" viewer = V3d_Viewer(driver)\n", |
||||
" viewer.SetDefaultLights()\n", |
||||
" viewer.SetLightOn()\n", |
||||
" _OCC_SESSION[\"conn\"] = conn\n", |
||||
" _OCC_SESSION[\"driver\"] = driver\n", |
||||
" _OCC_SESSION[\"viewer\"] = viewer\n", |
||||
"\n", |
||||
" viewer = _OCC_SESSION[\"viewer\"]\n", |
||||
" conn = _OCC_SESSION[\"conn\"]\n", |
||||
"\n", |
||||
" view = viewer.CreateView()\n", |
||||
" win = Xw_Window(conn, \"ocp_screenshot\", 0, 0, width, height)\n", |
||||
" view.SetWindow(win)\n", |
||||
" win.Map()\n", |
||||
"\n", |
||||
" return viewer, view\n", |
||||
"\n", |
||||
"\n", |
||||
"def _render_to_array(view, shape_occ, ctx, width: int, height: int,\n", |
||||
" bg_color, orientation, fit_padding: float) -> np.ndarray:\n", |
||||
" \"\"\"Configure la vue, lance le rendu, retourne un array HxWx3 uint8.\"\"\"\n", |
||||
" from OCP.Image import Image_AlienPixMap\n", |
||||
" from OCP.TCollection import TCollection_AsciiString\n", |
||||
" from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB\n", |
||||
"\n", |
||||
" view.SetBackgroundColor(bg_color)\n", |
||||
" view.SetProj(orientation)\n", |
||||
" view.FitAll(fit_padding)\n", |
||||
" view.Redraw()\n", |
||||
"\n", |
||||
" pix = Image_AlienPixMap()\n", |
||||
" view.ToPixMap(pix, width, height)\n", |
||||
"\n", |
||||
" # Sauvegarde temporaire puis lecture Pillow\n", |
||||
" with tempfile.NamedTemporaryFile(suffix=\".png\", delete=False) as f:\n", |
||||
" tmp = f.name\n", |
||||
" pix.Save(TCollection_AsciiString(tmp))\n", |
||||
" arr = np.array(Image.open(tmp).convert(\"RGB\"), dtype=np.float32)\n", |
||||
" os.unlink(tmp)\n", |
||||
" return arr\n", |
||||
"\n", |
||||
"\n", |
||||
"def _double_render_transparent(view, width, height, orientation,\n", |
||||
" fit_padding) -> np.ndarray:\n", |
||||
" \"\"\"\n", |
||||
" Technique double-render blanc/noir → canal alpha propre.\n", |
||||
"\n", |
||||
" Principe : sur fond blanc Cw = C·α + 1·(1−α) = C·α + 1 − α\n", |
||||
" sur fond noir Cb = C·α\n", |
||||
" donc α = 1 − (Cw − Cb) et C = Cb / α\n", |
||||
" \"\"\"\n", |
||||
" from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB\n", |
||||
"\n", |
||||
" white = Quantity_Color(1.0, 1.0, 1.0, Quantity_TOC_RGB)\n", |
||||
" black = Quantity_Color(0.0, 0.0, 0.0, Quantity_TOC_RGB)\n", |
||||
"\n", |
||||
" w = _render_to_array(view, None, None, width, height,\n", |
||||
" white, orientation, fit_padding)\n", |
||||
" b = _render_to_array(view, None, None, width, height,\n", |
||||
" black, orientation, fit_padding)\n", |
||||
"\n", |
||||
" # Alpha (par canal, on prend le max pour être robuste aux artefacts)\n", |
||||
" alpha = 1.0 - (w - b) / 255.0\n", |
||||
" alpha = np.clip(alpha.max(axis=2), 0.0, 1.0)\n", |
||||
"\n", |
||||
" # Couleur pré-multipliée → non-pré-multipliée\n", |
||||
" a3 = alpha[..., np.newaxis]\n", |
||||
" rgb = np.where(a3 > 0.01, b / np.maximum(a3, 0.01), 0.0)\n", |
||||
" rgb = np.clip(rgb, 0, 255).astype(np.uint8)\n", |
||||
"\n", |
||||
" return np.dstack([rgb, (alpha * 255).astype(np.uint8)]) # RGBA\n", |
||||
"\n", |
||||
"\n", |
||||
"def screenshot(\n", |
||||
" shape,\n", |
||||
" output_path: str,\n", |
||||
" width: int = 800,\n", |
||||
" height: int = 600,\n", |
||||
" orientation: str = \"ZupRight\",\n", |
||||
" fit_padding: float = 0.05,\n", |
||||
" background: \"str | None\" = None,\n", |
||||
" color: str = \"#A8C4DC\",\n", |
||||
" line_color: str = \"#1a1a1a\",\n", |
||||
" show_edges: bool = True,\n", |
||||
"):\n", |
||||
" \"\"\"\n", |
||||
" Génère un screenshot PNG du modèle build123d.\n", |
||||
"\n", |
||||
" Parameters\n", |
||||
" ----------\n", |
||||
" shape : build123d Shape (Solid, Compound, Part…)\n", |
||||
" output_path : chemin de sortie (.png)\n", |
||||
" width/height : dimensions en pixels\n", |
||||
" orientation : voir ORIENTATIONS (défaut \"ZupRight\")\n", |
||||
" fit_padding : marge relative autour du modèle (0.05 = 5 %)\n", |
||||
" background : None → fond transparent | \"#RRGGBB\" → fond coloré\n", |
||||
" color : couleur du solide (hex)\n", |
||||
" line_color : couleur des arêtes (hex)\n", |
||||
" show_edges : afficher les arêtes\n", |
||||
" \"\"\"\n", |
||||
" from OCP.AIS import AIS_InteractiveContext, AIS_Shape\n", |
||||
" from OCP.Prs3d import Prs3d_Drawer\n", |
||||
" from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB\n", |
||||
" from OCP.Aspect import Aspect_TypeOfLine\n", |
||||
"\n", |
||||
" if orientation not in ORIENTATIONS:\n", |
||||
" raise ValueError(\n", |
||||
" f\"Orientation '{orientation}' inconnue. \"\n", |
||||
" f\"Choisir parmi : {list(ORIENTATIONS.keys())}\"\n", |
||||
" )\n", |
||||
" ori = ORIENTATIONS[orientation]\n", |
||||
"\n", |
||||
" # Extraire le TopoDS_Shape depuis build123d\n", |
||||
" occ_shape = shape.wrapped\n", |
||||
"\n", |
||||
" viewer, view = _make_viewer(width, height)\n", |
||||
" ctx = AIS_InteractiveContext(viewer)\n", |
||||
"\n", |
||||
" # Couleur du solide\n", |
||||
" ais = AIS_Shape(occ_shape)\n", |
||||
" ais.SetColor(_hex_to_occ_color(color))\n", |
||||
" ais.SetTransparency(0.0)\n", |
||||
"\n", |
||||
" if show_edges:\n", |
||||
" # Configurer les arêtes dans le drawer\n", |
||||
" drawer = ais.Attributes()\n", |
||||
" drawer.SetFaceBoundaryDraw(True)\n", |
||||
" drawer.FaceBoundaryAspect().SetColor(_hex_to_occ_color(line_color))\n", |
||||
" drawer.FaceBoundaryAspect().SetWidth(1.0)\n", |
||||
"\n", |
||||
" ctx.Display(ais, True)\n", |
||||
" view.SetProj(ori)\n", |
||||
" view.FitAll(fit_padding)\n", |
||||
"\n", |
||||
" if background is None:\n", |
||||
" # Fond transparent via double-render\n", |
||||
" rgba = _double_render_transparent(view, width, height, ori, fit_padding)\n", |
||||
" Image.fromarray(rgba, \"RGBA\").save(output_path)\n", |
||||
" else:\n", |
||||
" # Fond coloré direct\n", |
||||
" from OCP.Image import Image_AlienPixMap\n", |
||||
" from OCP.TCollection import TCollection_AsciiString\n", |
||||
" view.SetBackgroundColor(_hex_to_occ_color(background))\n", |
||||
" view.Redraw()\n", |
||||
" pix = Image_AlienPixMap()\n", |
||||
" view.ToPixMap(pix, width, height)\n", |
||||
" pix.Save(TCollection_AsciiString(output_path))\n", |
||||
"\n", |
||||
" print(f\"✓ {output_path} ({width}×{height}px, orientation={orientation})\")\n", |
||||
"\n", |
||||
"\n", |
||||
"# ─────────────────────────────────────────────\n", |
||||
"# Exemple d'utilisation\n", |
||||
"# ─────────────────────────────────────────────\n", |
||||
"if __name__ == \"__main__\":\n", |
||||
" from build123d import *\n", |
||||
"\n", |
||||
" # Modèle simple : boîte + cylindre (Compound)\n", |
||||
" with BuildPart() as p:\n", |
||||
" Box(40, 30, 15)\n", |
||||
" with Locations((0, 0, 15)):\n", |
||||
" Cylinder(8, 20)\n", |
||||
"\n", |
||||
" # Fond transparent\n", |
||||
" screenshot(\n", |
||||
" p.part,\n", |
||||
" \"model_transparent.png\",\n", |
||||
" width=800, height=600,\n", |
||||
" orientation=\"ZupRight\",\n", |
||||
" color=\"#5B8DB8\",\n", |
||||
" )\n", |
||||
"\n", |
||||
" # Fond coloré\n", |
||||
" screenshot(\n", |
||||
" p.part,\n", |
||||
" \"model_bg.png\",\n", |
||||
" width=800, height=600,\n", |
||||
" orientation=\"ZupRight\",\n", |
||||
" background=\"#F0EDE6\",\n", |
||||
" color=\"#5B8DB8\",\n", |
||||
" )\n", |
||||
"\n", |
||||
" # Quelques orientations\n", |
||||
" for ori in [\"Top\", \"Front\", \"ZupLeft\"]:\n", |
||||
" screenshot(\n", |
||||
" p.part,\n", |
||||
" f\"model_{ori.lower()}.png\",\n", |
||||
" width=600, height=450,\n", |
||||
" orientation=ori,\n", |
||||
" color=\"#C0392B\",\n", |
||||
" )" |
||||
], |
||||
"outputs": [ |
||||
{ |
||||
"name": "stdout", |
||||
"output_type": "stream", |
||||
"text": [ |
||||
"✓ model_transparent.png (800×600px, orientation=ZupRight)\n", |
||||
"✓ model_bg.png (800×600px, orientation=ZupRight)\n", |
||||
"✓ model_top.png (600×450px, orientation=Top)\n", |
||||
"✓ model_front.png (600×450px, orientation=Front)\n", |
||||
"✓ model_zupleft.png (600×450px, orientation=ZupLeft)\n" |
||||
] |
||||
} |
||||
], |
||||
"execution_count": 1 |
||||
} |
||||
], |
||||
"metadata": { |
||||
"kernelspec": { |
||||
"display_name": "Python 3", |
||||
"language": "python", |
||||
"name": "python3" |
||||
}, |
||||
"language_info": { |
||||
"codemirror_mode": { |
||||
"name": "ipython", |
||||
"version": 2 |
||||
}, |
||||
"file_extension": ".py", |
||||
"mimetype": "text/x-python", |
||||
"name": "python", |
||||
"nbconvert_exporter": "python", |
||||
"pygments_lexer": "ipython2", |
||||
"version": "2.7.6" |
||||
} |
||||
}, |
||||
"nbformat": 4, |
||||
"nbformat_minor": 5 |
||||
} |
||||
Loading…
Reference in new issue