""" 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']+width=["\']([0-9.]+)', svg_str) h = re.search(r']+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 ... .""" m = re.search(r']*>(.*)', 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'', ] # Defs (filtres shadow si nécessaire) svg_lines.append('') svg_lines.append( '' '' '' ) # 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'' f'' f'' ) svg_lines.append('') # Fond if self.background: svg_lines.append( f'' ) # 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'' ) 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'' ) # Fond de la feuille (avec shading) paper_color = shade_color(paper.paper_color, normal, ambient=0.7) svg_lines.append( f'' ) # Contenu SVG de la feuille self._embed_svg_on_paper( svg_lines, paper, corners_2d, paper_idx ) svg_lines.append('') 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'' f'' + inner + f'' )