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