phase 0
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
"""
|
||||
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))
|
||||
Reference in New Issue
Block a user