Files
step-parse/skill.src/modules/renderer.py
T
Jason Stedwell c1abe36822 phase 0
2026-06-17 16:03:26 -05:00

322 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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))