""" renderer.py — Offscreen PNG thumbnail generation. Pipeline (with color): build123d → GLTF export → trimesh scene → pyrender → PNG Pipeline (fallback): build123d → per-solid STL → colored trimesh scene → pyrender → PNG Coordinate convention AFTER STEP→GLTF export→trimesh load: trimesh applies a Z-up→Y-up GLTF convention that swaps STEP's Y and Z axes: X = width (~248mm for MR16) — left/right Y = depth (~41mm for MR16) — front/back; Y_min=screen face, Y_max=back panel Z = height (~459mm for MR16) — tall axis; Z_min=top end, Z_max=bottom end → "front" camera sits at -Y (screen side) looking toward +Y to see the LCD face. → World "up" vector is (0,0,-1) — negative Z = top of display in image. 6 standard views: front, rear, left, right, iso_left, iso_right """ import logging import tempfile from pathlib import Path import numpy as np from .loader import StepModel logger = logging.getLogger("step_processor.renderer") # Camera direction vectors: where the camera is PLACED relative to model center. # Camera always looks toward center from direction * distance. # # Trimesh world axes after GLTF load (STEP Y and Z are swapped by GLTF Y-up conv): # X = width — left/right # Y = depth — Y_min = screen face (LCD), Y_max = back panel (ports) # Z = height — Z_min = TOP end of display, Z_max = BOTTOM end of display # # "front": camera at -Y (screen side) looks toward +Y → sees LCD face. # "rear": camera at +Y (back side) looks toward -Y → sees port panel. # "left": camera at -X looks toward +X → sees left edge. # "right": camera at +X looks toward -X → sees right edge. # iso views: -Y component keeps camera on screen side; -Z = toward top end. VIEW_CAMERAS = { "front": ( 0, -1, 0), # LCD screen face (Y_min side) "rear": ( 0, 1, 0), # back panel/ports (Y_max side) "left": (-1, 0, 0), # left edge (X_min side) "right": ( 1, 0, 0), # right edge (X_max side) "top": ( 0, 0, -1), # top end (Z_min side) "bottom": ( 0, 0, 1), # bottom end (Z_max side) "iso_left": (-1, -1, -0.5), # front-left-above: screen + left edge + top "iso_right": ( 1, -1, -0.5), # front-right-above: screen + right edge + top } DEFAULT_VIEWS = ["front", "rear", "left", "right", "iso_left", "iso_right"] # Color palette for per-part coloring when GLTF has no embedded colors # 20 distinct RGBA colors (alpha=200 for slight transparency on overlaps) PART_COLORS = [ [180, 180, 185, 255], # light steel [ 70, 130, 180, 255], # steel blue [205, 133, 63, 255], # peru / bronze [ 60, 179, 113, 255], # medium sea green [188, 143, 143, 255], # rosy brown [100, 149, 237, 255], # cornflower blue [255, 160, 50, 255], # dark orange [147, 112, 219, 255], # medium purple [ 46, 139, 87, 255], # sea green [205, 92, 92, 255], # indian red [135, 206, 235, 255], # sky blue [244, 164, 96, 255], # sandy brown [106, 90, 205, 255], # slate blue [ 32, 178, 170, 255], # light sea green [220, 20, 60, 255], # crimson [218, 165, 32, 255], # goldenrod [ 72, 61, 139, 255], # dark slate blue [143, 188, 143, 255], # dark sea green [255, 99, 71, 255], # tomato [176, 196, 222, 255], # light steel blue ] DEFAULT_WIDTH = 1024 DEFAULT_HEIGHT = 768 def render_views(model: StepModel, step_path: Path, views=None, width=DEFAULT_WIDTH, height=DEFAULT_HEIGHT) -> dict: """Render PNG views. Returns dict of view_name → output Path.""" views = views or DEFAULT_VIEWS stem = step_path.stem out_dir = step_path.parent results = {} mesh = _get_mesh(model, step_path) if mesh is None: logger.warning("Could not obtain mesh — thumbnails skipped") return results for view_name in views: if view_name not in VIEW_CAMERAS: logger.warning(f"Unknown view '{view_name}' — skipping") continue out_path = out_dir / f"{stem}_{view_name}.png" try: _render_single_view(mesh, VIEW_CAMERAS[view_name], out_path, width, height) results[view_name] = out_path logger.info(f"Rendered: {out_path.name}") except Exception as e: logger.warning(f"Render failed for '{view_name}': {e}") return results def _get_mesh(model: StepModel, step_path: Path): """Get a single assembled trimesh.Trimesh from the model. Always returns a single concatenated mesh (not a Scene) so the camera distance and bounds calculations are correct and parts don't explode. Priority: 1. build123d → GLTF → scene.dump() → concatenate with transforms applied (preserves per-part colors if embedded in STEP) 2. build123d → single STL of full assembly (fallback, monochrome but correct) 3. FreeCAD path → bounding box box mesh """ try: import trimesh except ImportError: logger.warning("trimesh not installed — thumbnails unavailable. pip install trimesh") return None if model.backend == "build123d": # ── GLTF path: color-aware, transforms flattened via scene.dump() ───── with tempfile.NamedTemporaryFile(suffix=".gltf", delete=False) as tmp: gltf_path = Path(tmp.name) try: from build123d import export_gltf import warnings with warnings.catch_warnings(): warnings.simplefilter("ignore") export_gltf(model.shape, str(gltf_path)) scene = trimesh.load(str(gltf_path)) if isinstance(scene, trimesh.Scene) and scene.geometry: # scene.dump() applies the full transform graph → parts at correct positions dumped = scene.dump() if dumped: # Pull baseColorFactor directly from PBR material per mesh. # .to_color() loses this in some trimesh versions. materialized = [] for m in dumped: try: bc = m.visual.material.baseColorFactor if bc is not None: m.visual = trimesh.visual.ColorVisuals( mesh=m, face_colors=np.array(bc, dtype=np.uint8)) else: m.visual = trimesh.visual.ColorVisuals( mesh=m, face_colors=[185, 190, 195, 255]) except Exception: try: m.visual = m.visual.to_color() except Exception: pass materialized.append(m) mesh = trimesh.util.concatenate(materialized) if mesh is not None and len(mesh.faces) > 0: n_colors = len(set( tuple(c) for c in mesh.visual.face_colors[:, :3][::1000] )) if hasattr(mesh.visual, 'face_colors') else 0 logger.info(f"GLTF: assembled {len(mesh.faces)} faces, " f"~{n_colors} distinct colors sampled") return mesh except Exception as e: logger.warning(f"GLTF path failed ({e}) — falling back to STL") finally: try: gltf_path.unlink() except Exception: pass # ── STL fallback: single merged mesh, correct positions, monochrome ─── with tempfile.NamedTemporaryFile(suffix=".stl", delete=False) as tmp: stl_path = Path(tmp.name) try: from build123d import export_stl export_stl(model.shape, str(stl_path)) mesh = trimesh.load(str(stl_path), force="mesh") if mesh is not None and len(mesh.faces) > 0: mesh.visual.face_colors = [185, 190, 195, 255] logger.info(f"STL fallback: {len(mesh.faces)} faces (uniform color)") return mesh except Exception as e: logger.warning(f"STL fallback failed ({e})") finally: try: stl_path.unlink() except Exception: pass # FreeCAD / last resort: bounding box return _bbox_wireframe_mesh(model) def _mesh_has_color(mesh) -> bool: """Return True if the mesh has meaningful (non-gray) face colors.""" try: fc = mesh.visual.face_colors if fc is None or len(fc) == 0: return False # If all faces are within ±25 of [128,128,128] treat as uncolored gray = [128, 128, 128] mean_rgb = fc[:, :3].mean(axis=0) return not all(abs(int(mean_rgb[i]) - gray[i]) < 25 for i in range(3)) except Exception: return False def _bbox_wireframe_mesh(model: StepModel): """Create a simple box mesh from the model's bounding box. Last-resort fallback.""" try: import trimesh bb = model.shape.bounding_box() # _FreeCADShapeProxy stores a _BoundBox if hasattr(bb, "XMin"): extents = [ bb.XMax - bb.XMin, bb.YMax - bb.YMin, bb.ZMax - bb.ZMin, ] center = [ (bb.XMax + bb.XMin) / 2, (bb.YMax + bb.YMin) / 2, (bb.ZMax + bb.ZMin) / 2, ] else: # build123d BoundBox extents = [bb.size.X, bb.size.Y, bb.size.Z] center = [bb.center.X, bb.center.Y, bb.center.Z] mesh = trimesh.creation.box(extents=extents) mesh.apply_translation(center) logger.debug("Using bbox wireframe mesh for rendering") return mesh except Exception as e: logger.warning(f"Bbox wireframe mesh failed: {e}") return None def _render_single_view(mesh, camera_direction: tuple, out_path: Path, width: int, height: int): """Render one view using pyrender offscreen, save to out_path.""" try: import pyrender except ImportError: raise ImportError("pyrender not installed. pip install pyrender") # Normalize camera direction direction = np.array(camera_direction, dtype=float) direction = direction / np.linalg.norm(direction) # Bounding box of the assembled mesh bounds = mesh.bounds # shape (2,3): [[xmin,ymin,zmin],[xmax,ymax,zmax]] center = (bounds[0] + bounds[1]) / 2.0 diag = np.linalg.norm(bounds[1] - bounds[0]) # Camera sits at 2.5× diagonal distance from center, looking at center camera_distance = diag * 2.5 eye = center + direction * camera_distance # World up: (0,0,-1) = negative Z = top of display in trimesh GLTF space. # Fallback to (0,-1,0) for top/bottom end views where direction ≈ ±Z. world_up = np.array([0, 0, -1], dtype=float) if abs(np.dot(direction, world_up)) > 0.9: world_up = np.array([0, -1, 0], dtype=float) # Proven camera frame formula (right-handed, same structure as original code): # right = cross(world_up, direction) [world_up × backward] # cam_up = cross(direction, right) [backward × right] # col2 = direction [camera +Z = backward; looks down -Z] right = np.cross(world_up, direction) if np.linalg.norm(right) < 1e-8: right = np.cross(np.array([1, 0, 0], dtype=float), direction) right = right / np.linalg.norm(right) cam_up = np.cross(direction, right) cam_up = cam_up / np.linalg.norm(cam_up) # 4×4 camera pose: columns = [right, cam_up, backward, eye] camera_pose = np.eye(4) camera_pose[:3, 0] = right camera_pose[:3, 1] = cam_up camera_pose[:3, 2] = direction # camera +Z = backward; pyrender looks down -Z camera_pose[:3, 3] = eye # Build pyrender scene — white background pr_scene = pyrender.Scene(ambient_light=[0.35, 0.35, 0.35], bg_color=[255, 255, 255, 255]) pr_scene.add(pyrender.Mesh.from_trimesh(mesh, smooth=False)) # FOV sized so the model fills ~80% of the frame yfov = 2.0 * np.arctan((diag * 0.5) / camera_distance) * 1.25 camera = pyrender.PerspectiveCamera(yfov=yfov, aspectRatio=width / height) pr_scene.add(camera, pose=camera_pose) # Key light from camera position + fill from above-opposite pr_scene.add(pyrender.DirectionalLight(color=np.ones(3), intensity=4.5), pose=camera_pose) fill_pose = np.eye(4) # Fill light: offset from top of display (-Z) to avoid zero-vector when # direction is parallel to (0,1,0) (e.g. rear view). fill_dir = -direction + np.array([0, 0, -1], dtype=float) fill_norm = np.linalg.norm(fill_dir) if fill_norm < 1e-8: fill_dir = np.array([0, 0, -1], dtype=float) else: fill_dir = fill_dir / fill_norm fill_eye = center - fill_dir * camera_distance fill_pose[:3, 3] = fill_eye pr_scene.add(pyrender.DirectionalLight(color=np.ones(3), intensity=1.8), pose=fill_pose) # Offscreen render r = pyrender.OffscreenRenderer(viewport_width=width, viewport_height=height) try: color, _ = r.render(pr_scene) finally: r.delete() from PIL import Image Image.fromarray(color).save(str(out_path))