Ajout du rendu isométrique SVG pour build123d
This commit is contained in:
+569
@@ -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",
|
||||
)
|
||||
+312
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
|
||||
}
|
||||
+5
-5
@@ -3,8 +3,8 @@
|
||||
{
|
||||
"metadata": {
|
||||
"ExecuteTime": {
|
||||
"end_time": "2026-05-22T19:37:45.609594692Z",
|
||||
"start_time": "2026-05-22T19:37:43.862365665Z"
|
||||
"end_time": "2026-05-23T01:58:20.511642454Z",
|
||||
"start_time": "2026-05-23T01:58:19.038575293Z"
|
||||
}
|
||||
},
|
||||
"cell_type": "code",
|
||||
@@ -126,7 +126,7 @@
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"c++\n"
|
||||
"ccc\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -135,12 +135,12 @@
|
||||
"True"
|
||||
]
|
||||
},
|
||||
"execution_count": 145,
|
||||
"execution_count": 146,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"execution_count": 145
|
||||
"execution_count": 146
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
|
||||
+30
-135
@@ -6,8 +6,8 @@
|
||||
"metadata": {
|
||||
"collapsed": true,
|
||||
"ExecuteTime": {
|
||||
"end_time": "2026-05-20T01:46:48.635159570Z",
|
||||
"start_time": "2026-05-20T01:46:33.769195980Z"
|
||||
"end_time": "2026-05-25T22:39:19.639342988Z",
|
||||
"start_time": "2026-05-25T22:39:17.795171555Z"
|
||||
}
|
||||
},
|
||||
"source": [
|
||||
@@ -490,51 +490,22 @@
|
||||
"\n",
|
||||
"gap = 50\n",
|
||||
"\n",
|
||||
"show(\n",
|
||||
" make_bloc_extrudeur(),\n",
|
||||
" make_bac_recup().translate((0, 0, -(bac_hauteur / 2 + bloc_extrudeur_ext_z / 2) + emboitement_profondeur)),\n",
|
||||
" make_couvercle().translate((0, 0, couv_ext_z / 2 + bloc_extrudeur_ext_z / 2 - emboitement_profondeur)),\n",
|
||||
" names=[\"superieure\", \"inferieure\", \"couvercle\"],\n",
|
||||
" colors=[\"#ff7800\", \"#000000\", \"#000000\"],\n",
|
||||
")"
|
||||
"# show(\n",
|
||||
"# make_bloc_extrudeur(),\n",
|
||||
"# make_bac_recup().translate((0, 0, -(bac_hauteur / 2 + bloc_extrudeur_ext_z / 2) + emboitement_profondeur)),\n",
|
||||
"# make_couvercle().translate((0, 0, couv_ext_z / 2 + bloc_extrudeur_ext_z / 2 - emboitement_profondeur)),\n",
|
||||
"# names=[\"superieure\", \"inferieure\", \"couvercle\"],\n",
|
||||
"# colors=[\"#ff7800\", \"#000000\", \"#000000\"],\n",
|
||||
"# )"
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"+"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "stderr",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"UserWarning: Unknown collapse value from viewer: none\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"+c+\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "stderr",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"CameraWarning: Object may extend beyond view (484.5% of previous size). Skip warnings with `ignore_camera_warnings()`\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"execution_count": 10
|
||||
"outputs": [],
|
||||
"execution_count": 4
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"ExecuteTime": {
|
||||
"end_time": "2026-05-18T23:18:32.922490156Z",
|
||||
"start_time": "2026-05-18T23:18:32.423433469Z"
|
||||
"end_time": "2026-05-25T22:38:21.604563409Z",
|
||||
"start_time": "2026-05-25T22:38:21.584737200Z"
|
||||
}
|
||||
},
|
||||
"cell_type": "code",
|
||||
@@ -583,108 +554,24 @@
|
||||
" return p.part\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"show(make_bac_recup())"
|
||||
"# show(make_bac_recup())"
|
||||
],
|
||||
"id": "a3d65d64eb54186b",
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"+\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"execution_count": 164
|
||||
"outputs": [],
|
||||
"execution_count": 2
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"ExecuteTime": {
|
||||
"end_time": "2026-05-20T01:47:33.197920866Z",
|
||||
"start_time": "2026-05-20T01:47:31.765776913Z"
|
||||
}
|
||||
},
|
||||
"cell_type": "code",
|
||||
"source": [
|
||||
"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\")"
|
||||
],
|
||||
"id": "ff26d94e85b6c380",
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"c\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"True"
|
||||
]
|
||||
},
|
||||
"execution_count": 11,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"execution_count": 11
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"ExecuteTime": {
|
||||
"end_time": "2026-05-18T23:37:55.903337866Z",
|
||||
"start_time": "2026-05-18T23:37:52.010830090Z"
|
||||
"end_time": "2026-05-25T22:39:38.155100030Z",
|
||||
"start_time": "2026-05-25T22:39:24.034959895Z"
|
||||
}
|
||||
},
|
||||
"cell_type": "code",
|
||||
"source": [
|
||||
"full = Compound(children=[make_couvercle(), make_bac_recup(), make_bloc_extrudeur()])\n",
|
||||
"export_step(full, \"/tmp/tractotine.step\")"
|
||||
"export_step(full, \"/tmp/tractotine.step\")\n",
|
||||
"export_gltf(full, \"/tmp/tractotine.gltf\")"
|
||||
],
|
||||
"id": "810fb3a101c2fccd",
|
||||
"outputs": [
|
||||
@@ -694,12 +581,12 @@
|
||||
"True"
|
||||
]
|
||||
},
|
||||
"execution_count": 166,
|
||||
"execution_count": 5,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"execution_count": 166
|
||||
"execution_count": 5
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
@@ -920,6 +807,14 @@
|
||||
}
|
||||
],
|
||||
"execution_count": 17
|
||||
},
|
||||
{
|
||||
"metadata": {},
|
||||
"cell_type": "code",
|
||||
"outputs": [],
|
||||
"execution_count": null,
|
||||
"source": "",
|
||||
"id": "9edcf2da938ea094"
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
|
||||
Reference in New Issue
Block a user