Ajout du rendu isométrique SVG pour build123d

This commit is contained in:
2026-05-28 02:11:22 +02:00
parent 6dc7de587c
commit c4282bca1d
12 changed files with 1612 additions and 140 deletions
+569
View File
@@ -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>'
)
+280
View File
@@ -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
View File
File diff suppressed because one or more lines are too long
+84
View File
@@ -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
}
+332
View File
@@ -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
View File
@@ -3,8 +3,8 @@
{ {
"metadata": { "metadata": {
"ExecuteTime": { "ExecuteTime": {
"end_time": "2026-05-22T19:37:45.609594692Z", "end_time": "2026-05-23T01:58:20.511642454Z",
"start_time": "2026-05-22T19:37:43.862365665Z" "start_time": "2026-05-23T01:58:19.038575293Z"
} }
}, },
"cell_type": "code", "cell_type": "code",
@@ -126,7 +126,7 @@
"name": "stdout", "name": "stdout",
"output_type": "stream", "output_type": "stream",
"text": [ "text": [
"c++\n" "ccc\n"
] ]
}, },
{ {
@@ -135,12 +135,12 @@
"True" "True"
] ]
}, },
"execution_count": 145, "execution_count": 146,
"metadata": {}, "metadata": {},
"output_type": "execute_result" "output_type": "execute_result"
} }
], ],
"execution_count": 145 "execution_count": 146
}, },
{ {
"metadata": { "metadata": {
+30 -135
View File
@@ -6,8 +6,8 @@
"metadata": { "metadata": {
"collapsed": true, "collapsed": true,
"ExecuteTime": { "ExecuteTime": {
"end_time": "2026-05-20T01:46:48.635159570Z", "end_time": "2026-05-25T22:39:19.639342988Z",
"start_time": "2026-05-20T01:46:33.769195980Z" "start_time": "2026-05-25T22:39:17.795171555Z"
} }
}, },
"source": [ "source": [
@@ -490,51 +490,22 @@
"\n", "\n",
"gap = 50\n", "gap = 50\n",
"\n", "\n",
"show(\n", "# show(\n",
" make_bloc_extrudeur(),\n", "# make_bloc_extrudeur(),\n",
" make_bac_recup().translate((0, 0, -(bac_hauteur / 2 + bloc_extrudeur_ext_z / 2) + emboitement_profondeur)),\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", "# make_couvercle().translate((0, 0, couv_ext_z / 2 + bloc_extrudeur_ext_z / 2 - emboitement_profondeur)),\n",
" names=[\"superieure\", \"inferieure\", \"couvercle\"],\n", "# names=[\"superieure\", \"inferieure\", \"couvercle\"],\n",
" colors=[\"#ff7800\", \"#000000\", \"#000000\"],\n", "# colors=[\"#ff7800\", \"#000000\", \"#000000\"],\n",
")" "# )"
], ],
"outputs": [ "outputs": [],
{ "execution_count": 4
"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
}, },
{ {
"metadata": { "metadata": {
"ExecuteTime": { "ExecuteTime": {
"end_time": "2026-05-18T23:18:32.922490156Z", "end_time": "2026-05-25T22:38:21.604563409Z",
"start_time": "2026-05-18T23:18:32.423433469Z" "start_time": "2026-05-25T22:38:21.584737200Z"
} }
}, },
"cell_type": "code", "cell_type": "code",
@@ -583,108 +554,24 @@
" return p.part\n", " return p.part\n",
"\n", "\n",
"\n", "\n",
"show(make_bac_recup())" "# show(make_bac_recup())"
], ],
"id": "a3d65d64eb54186b", "id": "a3d65d64eb54186b",
"outputs": [ "outputs": [],
{ "execution_count": 2
"name": "stdout",
"output_type": "stream",
"text": [
"+\n"
]
}
],
"execution_count": 164
}, },
{ {
"metadata": { "metadata": {
"ExecuteTime": { "ExecuteTime": {
"end_time": "2026-05-20T01:47:33.197920866Z", "end_time": "2026-05-25T22:39:38.155100030Z",
"start_time": "2026-05-20T01:47:31.765776913Z" "start_time": "2026-05-25T22:39:24.034959895Z"
}
},
"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"
} }
}, },
"cell_type": "code", "cell_type": "code",
"source": [ "source": [
"full = Compound(children=[make_couvercle(), make_bac_recup(), make_bloc_extrudeur()])\n", "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", "id": "810fb3a101c2fccd",
"outputs": [ "outputs": [
@@ -694,12 +581,12 @@
"True" "True"
] ]
}, },
"execution_count": 166, "execution_count": 5,
"metadata": {}, "metadata": {},
"output_type": "execute_result" "output_type": "execute_result"
} }
], ],
"execution_count": 166 "execution_count": 5
}, },
{ {
"metadata": { "metadata": {
@@ -920,6 +807,14 @@
} }
], ],
"execution_count": 17 "execution_count": 17
},
{
"metadata": {},
"cell_type": "code",
"outputs": [],
"execution_count": null,
"source": "",
"id": "9edcf2da938ea094"
} }
], ],
"metadata": { "metadata": {