- added renderer

- Added undo
This commit is contained in:
bklronin
2026-07-12 22:21:43 +02:00
parent 210e3cfb5d
commit 9f1387fe68
8 changed files with 3419 additions and 0 deletions
+156
View File
@@ -0,0 +1,156 @@
"""Material presets for the render backend.
Each preset is a RenderMaterial with physically-plausible values.
"""
from __future__ import annotations
from typing import Dict, List
from .render_backend import RenderMaterial
# ── Preset library ──────────────────────────────────────────────────────
# Note: Mitsuba pip installs don't include spectral metal data files,
# so metal_preset is not used. Instead, metals use material="none" with
# specular_reflectance set to the metal color.
PRESETS: Dict[str, RenderMaterial] = {
# ── Metals ──────────────────────────────────────────────────────
"Brushed Steel": RenderMaterial(
name="Brushed Steel",
color=(0.65, 0.67, 0.72),
metallic=0.9,
roughness=0.35,
bsdf_type="roughconductor",
),
"Polished Chrome": RenderMaterial(
name="Polished Chrome",
color=(0.8, 0.8, 0.8),
metallic=1.0,
roughness=0.05,
bsdf_type="roughconductor",
),
"Brushed Aluminum": RenderMaterial(
name="Brushed Aluminum",
color=(0.75, 0.75, 0.75),
metallic=0.85,
roughness=0.25,
bsdf_type="roughconductor",
),
"Copper": RenderMaterial(
name="Copper",
color=(0.95, 0.64, 0.54),
metallic=0.95,
roughness=0.15,
bsdf_type="roughconductor",
),
"Gold": RenderMaterial(
name="Gold",
color=(1.0, 0.76, 0.33),
metallic=1.0,
roughness=0.1,
bsdf_type="roughconductor",
),
"Blackened Steel": RenderMaterial(
name="Blackened Steel",
color=(0.15, 0.15, 0.17),
metallic=0.8,
roughness=0.4,
bsdf_type="roughconductor",
),
# ── Plastics ────────────────────────────────────────────────────
"Matte Plastic": RenderMaterial(
name="Matte Plastic",
color=(0.2, 0.5, 0.8),
metallic=0.0,
roughness=0.6,
bsdf_type="plastic",
int_ior=1.5,
),
"Glossy Plastic": RenderMaterial(
name="Glossy Plastic",
color=(0.2, 0.5, 0.8),
metallic=0.0,
roughness=0.1,
bsdf_type="plastic",
int_ior=1.5,
),
"White Nylon": RenderMaterial(
name="White Nylon",
color=(0.85, 0.85, 0.83),
metallic=0.0,
roughness=0.45,
bsdf_type="plastic",
int_ior=1.53,
),
"Black ABS": RenderMaterial(
name="Black ABS",
color=(0.05, 0.05, 0.05),
metallic=0.0,
roughness=0.35,
bsdf_type="plastic",
int_ior=1.54,
),
"Red PA12": RenderMaterial(
name="Red PA12",
color=(0.75, 0.08, 0.08),
metallic=0.0,
roughness=0.4,
bsdf_type="plastic",
int_ior=1.53,
),
# ── Other ───────────────────────────────────────────────────────
"Rubber": RenderMaterial(
name="Rubber",
color=(0.1, 0.1, 0.1),
metallic=0.0,
roughness=0.9,
bsdf_type="diffuse",
),
"Ceramic White": RenderMaterial(
name="Ceramic White",
color=(0.92, 0.91, 0.88),
metallic=0.0,
roughness=0.15,
bsdf_type="dielectric",
int_ior=1.55,
),
"Glass": RenderMaterial(
name="Glass",
color=(0.95, 0.95, 0.95),
metallic=0.0,
roughness=0.0,
bsdf_type="dielectric",
int_ior=1.52,
),
"Wood": RenderMaterial(
name="Wood",
color=(0.6, 0.4, 0.2),
metallic=0.0,
roughness=0.7,
bsdf_type="diffuse",
),
}
def get_preset(name: str) -> RenderMaterial:
"""Get a material preset by name. Falls back to default if not found."""
if name in PRESETS:
return PRESETS[name]
return default_material()
def default_material() -> RenderMaterial:
"""Return the default grey material."""
return RenderMaterial(
name="Default",
color=(0.7, 0.7, 0.7),
metallic=0.0,
roughness=0.5,
bsdf_type="diffuse",
)
def preset_names() -> List[str]:
"""Return sorted list of available preset names."""
return sorted(PRESETS.keys())
+345
View File
@@ -0,0 +1,345 @@
"""Mitsuba 3 photorealistic render backend.
Requires: ``pip install mitsuba``
"""
from __future__ import annotations
import logging
import os
import tempfile
from typing import Callable, Optional
import numpy as np
from .render_backend import RenderBackend, RenderCamera, RenderMaterial, RenderSettings
logger = logging.getLogger(__name__)
class MitsubaBackend(RenderBackend):
"""Render backend using Mitsuba 3 path tracer."""
def name(self) -> str:
return "Mitsuba 3"
def is_available(self) -> bool:
import sys
import io
old_stderr = sys.stderr
sys.stderr = io.StringIO()
try:
import mitsuba # noqa: F401
return True
except ImportError:
return False
finally:
sys.stderr = old_stderr
# ── Scene construction ──────────────────────────────────────────
def _build_scene_dict(
self,
mesh_path: str,
material: RenderMaterial,
camera: RenderCamera,
settings: RenderSettings,
) -> dict:
"""Build a Mitsuba scene dictionary from our data classes.
Uses a 3-point lighting setup (key + fill + rim) plus an ambient
environment emitter for soft fill, giving well-balanced shading on
all faces of the model. Lighting intensities and colors come from
``settings.lighting``; ground plane comes from ``settings.ground_plane``.
"""
import mitsuba as mi
lighting = settings.lighting
ground = settings.ground_plane
# Map our BSDF types to Mitsuba BSDF dicts
bsdf = self._make_bsdf(material)
# Determine mesh file type from extension
ext = os.path.splitext(mesh_path)[1].lower()
shape_type = "ply" if ext == ".ply" else "obj"
# Build camera-to-world transform using the Python API
cam_to_world = mi.ScalarTransform4f.look_at(
origin=list(camera.origin),
target=list(camera.target),
up=list(camera.up),
)
scene = {
"type": "scene",
# Integrator
"integrator": {
"type": "path",
"max_depth": settings.max_depth,
},
# Camera
"sensor": {
"type": "perspective",
"fov": camera.fov,
"to_world": cam_to_world,
"film": {
"type": "hdrfilm",
"width": settings.width,
"height": settings.height,
"rfilter": {"type": "gaussian"},
},
"sampler": {
"type": "independent",
"sample_count": settings.spp,
},
},
# Ambient environment fill
"emitter": {
"type": "constant",
"radiance": {
"type": "rgb",
"value": [
lighting.ambient_intensity,
lighting.ambient_intensity * 0.97,
lighting.ambient_intensity * 0.94,
],
},
},
# Shape
"shape": {
"type": shape_type,
"filename": mesh_path,
"bsdf": bsdf,
},
}
# ── 3-point lighting (colors and intensities from config) ───
key_rgb = [
c * lighting.key_intensity for c in lighting.key_color
]
key_to_world = mi.ScalarTransform4f.look_at(
origin=[1.0, -0.8, 1.2],
target=[0.0, 0.0, 0.0],
up=[0.0, 0.0, 1.0],
)
scene["key_light"] = {
"type": "directional",
"to_world": key_to_world,
"irradiance": {"type": "rgb", "value": key_rgb},
}
fill_rgb = [
c * lighting.fill_intensity for c in lighting.fill_color
]
fill_to_world = mi.ScalarTransform4f.look_at(
origin=[-1.0, 0.6, 0.8],
target=[0.0, 0.0, 0.0],
up=[0.0, 0.0, 1.0],
)
scene["fill_light"] = {
"type": "directional",
"to_world": fill_to_world,
"irradiance": {"type": "rgb", "value": fill_rgb},
}
rim_rgb = [
c * lighting.rim_intensity for c in lighting.rim_color
]
rim_to_world = mi.ScalarTransform4f.look_at(
origin=[-0.3, 1.2, -0.8],
target=[0.0, 0.0, 0.0],
up=[0.0, 0.0, 1.0],
)
scene["rim_light"] = {
"type": "directional",
"to_world": rim_to_world,
"irradiance": {"type": "rgb", "value": rim_rgb},
}
# ── Ground plane (optional) ─────────────────────────────────
if ground.enabled:
scene["ground_plane"] = {
"type": "rectangle",
"size": [500.0, 500.0],
"to_world": mi.ScalarTransform4f.translate(
[0.0, 0.0, -ground.distance_below]
)
@ mi.ScalarTransform4f.rotate_about_z(90),
"bsdf": {
"type": "diffuse",
"reflectance": {
"type": "rgb",
"value": list(ground.color),
},
},
}
return scene
def _make_bsdf(self, material: RenderMaterial) -> dict:
"""Convert a RenderMaterial to a Mitsuba BSDF dict."""
mt = material.bsdf_type
if mt == "roughconductor":
# Use material="none" with specular_reflectance set to the
# metal color. The pip-installed Mitsuba doesn't include
# spectral metal data files (iron.spd, copper.spd, etc.).
return {
"type": "roughconductor",
"material": "none",
"alpha": max(material.roughness, 0.01),
"specular_reflectance": {
"type": "rgb",
"value": list(material.color),
},
}
if mt == "roughdielectric":
return {
"type": "roughdielectric",
"int_ior": material.int_ior,
"ext_ior": 1.0,
"alpha": max(material.roughness, 0.01),
}
if mt == "dielectric":
return {
"type": "dielectric",
"int_ior": material.int_ior,
"ext_ior": 1.0,
}
if mt == "plastic":
return {
"type": "plastic",
"diffuse_reflectance": {
"type": "rgb",
"value": list(material.color),
},
"int_ior": material.int_ior,
}
# Default: diffuse
return {
"type": "diffuse",
"reflectance": {
"type": "rgb",
"value": list(material.color),
},
}
# ── Rendering ───────────────────────────────────────────────────
def render(
self,
mesh_path: str,
material: RenderMaterial,
camera: RenderCamera,
settings: RenderSettings,
progress_callback: Optional[Callable[[float], None]] = None,
) -> np.ndarray:
"""Render a mesh file and return (H, W, 3) float32 RGB array."""
self._set_variant()
import mitsuba as mi
scene_dict = self._build_scene_dict(mesh_path, material, camera, settings)
scene = mi.load_dict(scene_dict)
logger.info(
f"Rendering {settings.width}x{settings.height} @ {settings.spp} spp"
)
# Render
image = mi.render(scene, spp=settings.spp, seed=settings.seed or 0)
if progress_callback:
progress_callback(1.0)
# Convert to numpy (H, W, 3)
arr = np.array(image, dtype=np.float32)
# Apply approximate sRGB tonemapping
arr = np.clip(arr, 0.0, None)
arr = np.power(arr, 1.0 / 2.2) # gamma
arr = np.clip(arr, 0.0, 1.0)
return arr
def render_preview(
self,
mesh_path: str,
material: RenderMaterial,
camera: RenderCamera,
settings: RenderSettings,
) -> np.ndarray:
"""Quick low-quality preview (4x fewer spp)."""
preview_settings = RenderSettings(
width=settings.width // 2,
height=settings.height // 2,
spp=max(settings.spp // 4, 16),
max_depth=min(settings.max_depth, 4),
seed=settings.seed,
)
return self.render(mesh_path, material, camera, preview_settings)
# ── Export ──────────────────────────────────────────────────────
def export_image(self, image: np.ndarray, path: str) -> None:
"""Save a rendered image to PNG or EXR."""
from PIL import Image
ext = os.path.splitext(path)[1].lower()
if ext == ".exr":
# Save as EXR (HDR) — no tonemapping
try:
import OpenEXR
import Imath
h, w = image.shape[:2]
header = OpenEXR.Header(w, h)
header["channels"] = {
"R": Imath.PixelType(Imath.PixelType.FLOAT),
"G": Imath.PixelType(Imath.PixelType.FLOAT),
"B": Imath.PixelType(Imath.PixelType.FLOAT),
}
exr = OpenEXR.OutputFile(path, header)
exr.write(
{
"R": image[:, :, 0].tobytes(),
"G": image[:, :, 1].tobytes(),
"B": image[:, :, 2].tobytes(),
}
)
exr.close()
except ImportError:
# Fallback: save as 16-bit PNG
logger.warning("OpenEXR not available, saving as 16-bit PNG")
img = Image.fromarray((image * 65535).astype(np.uint16), "RGB")
img.save(path)
else:
# PNG / JPEG — already tonemapped
img = Image.fromarray((image * 255).astype(np.uint8), "RGB")
img.save(path)
logger.info(f"Exported render to {path}")
# ── Helpers ─────────────────────────────────────────────────────
def _set_variant(self) -> None:
"""Set the Mitsuba variant (called once)."""
import sys
import io
# Suppress the harmless "LLVM API initialization failed" warning
# that drjit emits on macOS ARM when scalar variant is used.
old_stderr = sys.stderr
sys.stderr = io.StringIO()
try:
import mitsuba as mi
mi.set_variant("scalar_rgb")
finally:
sys.stderr = old_stderr
+277
View File
@@ -0,0 +1,277 @@
"""Convert OCC BRep shapes to mesh files for render backends.
Outputs PLY files (preferred by Mitsuba) or STL files.
"""
from __future__ import annotations
import logging
import os
import tempfile
from typing import List, Optional, Tuple
import numpy as np
logger = logging.getLogger(__name__)
def occ_shape_to_ply(
shape,
output_path: Optional[str] = None,
linear_deflection: float = 0.1,
angular_deflection: float = 0.15,
) -> str:
"""Tessellate an OCC TopoDS_Shape and write as PLY.
Returns the path to the written PLY file.
"""
from OCP.BRepMesh import BRepMesh_IncrementalMesh
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_FACE
from OCP.TopoDS import TopoDS
from OCP.BRep import BRep_Tool
from OCP.TopLoc import TopLoc_Location
# Tessellate
tess = BRepMesh_IncrementalMesh(
shape, linear_deflection, False, angular_deflection, True
)
tess.Perform()
# Extract triangulation from all faces
all_vertices: List[List[float]] = []
all_faces: List[List[int]] = []
vertex_offset = 0
explorer = TopExp_Explorer(shape, TopAbs_FACE)
while explorer.More():
face = TopoDS.Face_s(explorer.Current())
location = TopLoc_Location()
triangulation = BRep_Tool.Triangulation_s(face, location)
if triangulation is None:
explorer.Next()
continue
# Transform
trsf = location.Transformation()
# Extract vertices (apply location transform to positions)
nb_nodes = triangulation.NbNodes()
for i in range(1, nb_nodes + 1):
node = triangulation.Node(i)
pnt = node.Transformed(trsf)
all_vertices.append([pnt.X(), pnt.Y(), pnt.Z()])
# Extract triangles
nb_triangles = triangulation.NbTriangles()
for i in range(1, nb_triangles + 1):
tri = triangulation.Triangle(i)
n1, n2, n3 = tri.Get()
all_faces.append([
n1 - 1 + vertex_offset,
n2 - 1 + vertex_offset,
n3 - 1 + vertex_offset,
])
vertex_offset += nb_nodes
explorer.Next()
if not all_vertices:
raise ValueError("Tessellation produced no vertices")
vertices = np.array(all_vertices, dtype=np.float32)
faces = np.array(all_faces, dtype=np.uint32)
logger.info(
f"Tessellation: {len(vertices)} vertices, {len(faces)} triangles"
)
# Compute outward-facing vertex normals from triangle geometry.
# This ensures consistent lighting even when OCC triangulation winding
# is inconsistent across faces (e.g. after location transforms).
normals = _compute_outward_normals(vertices, faces, shape)
# Write PLY with normals
if output_path is None:
fd, output_path = tempfile.mkstemp(suffix=".ply", prefix="fluency_render_")
os.close(fd)
_write_ply(output_path, vertices, faces, normals)
logger.info(f"Wrote PLY: {output_path}")
return output_path
def _compute_outward_normals(
vertices: np.ndarray,
faces: np.ndarray,
shape,
) -> np.ndarray:
"""Compute outward-facing vertex normals.
1. Compute per-face normals from cross product of triangle edges.
2. Determine correct orientation by checking face normals against the
shape centroid (outward = away from center).
3. Flip triangles with inward normals before accumulating to vertices.
4. Average and normalize per-vertex normals.
"""
n_verts = len(vertices)
v_normals = np.zeros((n_verts, 3), dtype=np.float64)
# Compute shape centroid for outward direction reference.
# Use OCC bounding box if available, otherwise fall back to vertex bounds.
if shape is not None:
from OCP.Bnd import Bnd_Box
from OCP.BRepBndLib import BRepBndLib
bbox = Bnd_Box()
BRepBndLib.Add_s(shape, bbox)
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
else:
vmin = vertices.min(axis=0).astype(np.float64)
vmax = vertices.max(axis=0).astype(np.float64)
xmin, ymin, zmin = vmin
xmax, ymax, zmax = vmax
centroid = np.array(
[(xmin + xmax) / 2, (ymin + ymax) / 2, (zmin + zmax) / 2],
dtype=np.float64,
)
# Ensure faces is 2D (numpy creates (3,) for single-face meshes)
if faces.ndim == 1:
faces = faces.reshape(1, -1)
# Compute face normals from triangle geometry
v0 = vertices[faces[:, 0]]
v1 = vertices[faces[:, 1]]
v2 = vertices[faces[:, 2]]
edge1 = v1 - v0
edge2 = v2 - v0
face_normals = np.cross(edge1, edge2)
# Triangle centroids to test direction from shape center
tri_centers = (v0 + v1 + v2) / 3.0
to_tri = tri_centers - centroid
# Dot product: positive means normal points away from centroid (outward)
dots = np.sum(face_normals * to_tri, axis=1)
# Faces with negative dot have inward normals — swap columns 1 and 2
flip_mask = dots < 0
corrected_faces = faces.copy()
col1 = corrected_faces[:, 1]
col2 = corrected_faces[:, 2]
corrected_faces[flip_mask, 1] = col2[flip_mask]
corrected_faces[flip_mask, 2] = col1[flip_mask]
# Recompute face normals after correction
v0c = vertices[corrected_faces[:, 0]]
v1c = vertices[corrected_faces[:, 1]]
v2c = vertices[corrected_faces[:, 2]]
fn = np.cross(v1c - v0c, v2c - v0c)
# Normalize face normals
lengths = np.linalg.norm(fn, axis=1, keepdims=True)
lengths[lengths < 1e-10] = 1.0
fn /= lengths
# Accumulate to vertices
for i in range(len(corrected_faces)):
idx = corrected_faces[i]
v_normals[idx[0]] += fn[i]
v_normals[idx[1]] += fn[i]
v_normals[idx[2]] += fn[i]
# Normalize vertex normals
v_lengths = np.linalg.norm(v_normals, axis=1, keepdims=True)
v_lengths[v_lengths < 1e-10] = 1.0
v_normals /= v_lengths
return v_normals.astype(np.float32)
def occ_shape_to_stl(
shape,
output_path: Optional[str] = None,
linear_deflection: float = 0.1,
) -> str:
"""Tessellate an OCC TopoDS_Shape and write as binary STL.
Returns the path to the written STL file.
"""
from OCP.BRepMesh import BRepMesh_IncrementalMesh
from OCP.StlAPI import StlAPI_Writer
# Tessellate
tess = BRepMesh_IncrementalMesh(shape, linear_deflection, False, 0.5, True)
tess.Perform()
if output_path is None:
fd, output_path = tempfile.mkstemp(suffix=".stl", prefix="fluency_render_")
os.close(fd)
writer = StlAPI_Writer()
writer.SetASCIIMode(False)
writer.Write(shape, output_path)
logger.info(f"Wrote STL: {output_path}")
return output_path
def occ_shape_bounds(shape) -> Tuple[Tuple[float, float, float], Tuple[float, float, float]]:
"""Return (min_xyz, max_xyz) bounding box of an OCC shape."""
from OCP.Bnd import Bnd_Box
from OCP.BRepBndLib import BRepBndLib
bbox = Bnd_Box()
BRepBndLib.Add_s(shape, bbox)
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
return (xmin, ymin, zmin), (xmax, ymax, zmax)
def _write_ply(
path: str,
vertices: np.ndarray,
faces: np.ndarray,
normals: Optional[np.ndarray] = None,
) -> None:
"""Write a binary PLY file (little-endian) with optional vertex normals."""
import struct
n_verts = len(vertices)
n_faces = len(faces)
has_normals = normals is not None and len(normals) == n_verts
with open(path, "wb") as f:
# Header
header_lines = [
"ply",
"format binary_little_endian 1.0",
f"element vertex {n_verts}",
"property float x",
"property float y",
"property float z",
]
if has_normals:
header_lines.extend([
"property float nx",
"property float ny",
"property float nz",
])
header_lines.append(f"element face {n_faces}")
header_lines.append("property list uchar int vertex_indices")
header_lines.append("end_header")
f.write(("\n".join(header_lines) + "\n").encode("ascii"))
# Vertex positions (+ normals if available)
for i in range(n_verts):
f.write(struct.pack("<fff", vertices[i, 0], vertices[i, 1], vertices[i, 2]))
if has_normals:
f.write(struct.pack("<fff", normals[i, 0], normals[i, 1], normals[i, 2]))
# Faces
for face in faces:
f.write(struct.pack("<B", 3))
f.write(struct.pack("<iii", int(face[0]), int(face[1]), int(face[2])))
+141
View File
@@ -0,0 +1,141 @@
"""Abstract render backend interface.
Any photorealistic renderer (Mitsuba, Blender, Cycles, ...) implements
:class:`RenderBackend`. The UI only talks to this ABC so backends can be
swapped by changing one import.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class RenderMaterial:
"""PBR material description for the render backend."""
name: str = "Default"
color: tuple[float, float, float] = (0.7, 0.7, 0.7)
metallic: float = 0.0 # 0.0 = dielectric, 1.0 = metal
roughness: float = 0.5 # 0.0 = mirror, 1.0 = fully rough
bsdf_type: str = "diffuse" # diffuse | roughconductor | roughdielectric | plastic
# Optional: named metal preset (copper, aluminium, gold, chrome, steel)
metal_preset: Optional[str] = None
# For dielectric / plastic
int_ior: float = 1.5
@dataclass
class RenderCamera:
"""Camera parameters for the render."""
origin: tuple[float, float, float] = (100.0, 100.0, 100.0)
target: tuple[float, float, float] = (0.0, 0.0, 0.0)
up: tuple[float, float, float] = (0.0, 0.0, 1.0)
fov: float = 60.0 # vertical field of view in degrees
@dataclass
class LightingConfig:
"""Lighting configuration for the render scene."""
ambient_intensity: float = 0.3 # constant environment fill [0..1]
key_color: tuple[float, float, float] = (1.0, 0.98, 0.95) # RGB key light color
key_intensity: float = 3.5 # key light irradiance multiplier
fill_color: tuple[float, float, float] = (0.92, 0.94, 1.0) # RGB fill light color
fill_intensity: float = 1.5 # fill light irradiance multiplier
rim_color: tuple[float, float, float] = (1.0, 0.98, 0.96) # RGB rim light color
rim_intensity: float = 1.2 # rim light irradiance multiplier
@dataclass
class GroundPlaneConfig:
"""Ground plane configuration for the render scene."""
enabled: bool = False
color: tuple[float, float, float] = (0.5, 0.5, 0.5) # RGB diffuse color
roughness: float = 0.8 # surface roughness [0..1]
distance_below: float = 0.0 # mm below origin (positive = below)
@dataclass
class RenderSettings:
"""Quality / resolution settings."""
width: int = 1920
height: int = 1080
spp: int = 256 # samples per pixel
max_depth: int = 8 # max bounces for path tracer
seed: int = 0 # random seed (0 = auto)
lighting: LightingConfig = field(default_factory=LightingConfig)
ground_plane: GroundPlaneConfig = field(default_factory=GroundPlaneConfig)
class RenderBackend(ABC):
"""Abstract photorealistic renderer.
Implementations live in separate modules so backends can be swapped
without touching the UI. Typical call::
backend = MitsubaBackend()
image = backend.render(obj_path, material, camera, settings)
"""
@abstractmethod
def name(self) -> str:
"""Human-readable backend name (shown in UI)."""
@abstractmethod
def is_available(self) -> bool:
"""Return True if this backend's dependencies are installed."""
@abstractmethod
def render(
self,
mesh_path: str,
material: RenderMaterial,
camera: RenderCamera,
settings: RenderSettings,
progress_callback=None,
) -> "np.ndarray":
"""Render a mesh file and return an (H, W, 3) float32 RGB array.
*mesh_path* is an STL or OBJ file on disk.
*progress_callback(fraction)* is called with 0.01.0 progress.
"""
@abstractmethod
def render_preview(
self,
mesh_path: str,
material: RenderMaterial,
camera: RenderCamera,
settings: RenderSettings,
) -> "np.ndarray":
"""Quick low-quality preview (fewer spp)."""
@abstractmethod
def export_image(self, image: "np.ndarray", path: str) -> None:
"""Save a rendered image to PNG / EXR."""
def default_camera_from_bounds(
self, bounds_min: tuple[float, float, float], bounds_max: tuple[float, float, float]
) -> RenderCamera:
"""Compute a sensible default camera looking at the bbox centre."""
import numpy as np
mn = np.asarray(bounds_min, dtype=float)
mx = np.asarray(bounds_max, dtype=float)
centre = (mn + mx) / 2.0
diag = float(np.linalg.norm(mx - mn))
# Place camera at iso-ish position, far enough to see everything.
eye = centre + np.array([0.7, -0.7, 0.5]) * diag * 0.8
return RenderCamera(
origin=tuple(eye.tolist()),
target=tuple(centre.tolist()),
up=(0.0, 0.0, 1.0),
fov=45.0,
)