You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
569 lines
20 KiB
569 lines
20 KiB
""" |
|
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>' |
|
)
|
|
|