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

"""
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>'
)