- added renderer
- Added undo
This commit is contained in:
@@ -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())
|
||||
@@ -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
|
||||
@@ -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])))
|
||||
@@ -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.0–1.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,
|
||||
)
|
||||
Reference in New Issue
Block a user