Improved render previews

This commit is contained in:
bklronin
2026-07-18 23:06:42 +02:00
parent 742d06d242
commit d6e829c23d
9 changed files with 2195 additions and 1553 deletions
+111 -68
View File
@@ -39,47 +39,31 @@ class MitsubaBackend(RenderBackend):
# ── Scene construction ──────────────────────────────────────────
def _build_scene_dict(
def _base_scene_dict(
self,
mesh_path: str,
material: RenderMaterial,
camera: RenderCamera,
settings: RenderSettings,
first_mesh_path: Optional[str] = None,
) -> dict:
"""Build a Mitsuba scene dictionary from our data classes.
"""Return a scene dict with everything *except* the shape entries.
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``.
When *first_mesh_path* is given the ground plane / backdrop is sized
from its bounding box; otherwise a large default is used.
"""
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 = {
scene: dict = {
"type": "scene",
# Integrator
"integrator": {
"type": "path",
"max_depth": settings.max_depth,
},
# Camera
"integrator": {"type": "path", "max_depth": settings.max_depth},
"sensor": {
"type": "perspective",
"fov": camera.fov,
@@ -95,7 +79,6 @@ class MitsubaBackend(RenderBackend):
"sample_count": settings.spp,
},
},
# Ambient environment fill
"emitter": {
"type": "constant",
"radiance": {
@@ -107,59 +90,50 @@ class MitsubaBackend(RenderBackend):
],
},
},
# Shape
"shape": {
"type": shape_type,
"filename": mesh_path,
"bsdf": bsdf,
},
}
# ── 3-point lighting (colors and intensities from config) ───
# ── 3-point lighting ──────────────────────────────────────────
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,
"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],
),
"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,
"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],
),
"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,
"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],
),
"irradiance": {"type": "rgb", "value": rim_rgb},
}
# ── Ground plane / backdrop (optional) ─────────────────────
# ── Ground plane / backdrop ───────────────────────────────────
if ground.enabled:
try:
# Load mesh to compute bounds for ground/backdrop placement
mesh_shape = mi.load_dict({"type": shape_type, "filename": mesh_path})
ext = os.path.splitext(first_mesh_path)[1].lower() if first_mesh_path else ""
shape_type = "ply" if ext == ".ply" else "obj"
mesh_shape = mi.load_dict({"type": shape_type, "filename": first_mesh_path})
bbox = mesh_shape.bbox()
bbox_min, bbox_max = bbox[0], bbox[1]
# Ground at model's lowest Z with 0.1% offset
model_height = bbox_max[2] - bbox_min[2]
ground_z = bbox_min[2] - 0.001 * model_height
dx = bbox_max[0] - bbox_min[0]
@@ -167,7 +141,6 @@ class MitsubaBackend(RenderBackend):
dz = bbox_max[2] - bbox_min[2]
diag = float((dx * dx + dy * dy + dz * dz) ** 0.5)
except Exception:
# Fallback: place at origin with large default size
ground_z = -ground.distance_below
diag = 1000.0
@@ -177,19 +150,15 @@ class MitsubaBackend(RenderBackend):
}
if ground.curved_backdrop:
# Photo booth style curved leinwand:
# - Flat floor section in front of the model
# - Curved cylinder behind that sweeps up and over
half_size = diag * 50.0 # huge floor
radius = diag * 3.0 # curvature radius
cyl_height = diag * 20.0 # width of the cylinder (along its axis)
half_size = diag * 50.0
radius = diag * 3.0
cyl_height = diag * 20.0
scene["ground_floor"] = {
"type": "rectangle",
"to_world": mi.ScalarTransform4f.translate([0.0, 0.0, ground_z])
@ mi.ScalarTransform4f.scale([half_size, half_size, 1.0]),
"bsdf": bsdf_ground,
}
# Cylinder: axis along Y, positioned behind model, radius sweeps up
scene["ground_backdrop"] = {
"type": "cylinder",
"radius": radius,
@@ -200,7 +169,6 @@ class MitsubaBackend(RenderBackend):
"bsdf": bsdf_ground,
}
else:
# Simple flat ground plane — very large so edges aren't visible
half_size = diag * 50.0
scene["ground_plane"] = {
"type": "rectangle",
@@ -211,6 +179,48 @@ class MitsubaBackend(RenderBackend):
return scene
def _build_scene_dict(
self,
mesh_path: str,
material: RenderMaterial,
camera: RenderCamera,
settings: RenderSettings,
) -> dict:
"""Build a single-shape Mitsuba scene dictionary."""
ext = os.path.splitext(mesh_path)[1].lower()
shape_type = "ply" if ext == ".ply" else "obj"
scene = self._base_scene_dict(camera, settings, first_mesh_path=mesh_path)
scene["shape"] = {
"type": shape_type,
"filename": mesh_path,
"bsdf": self._make_bsdf(material),
}
return scene
def _build_assembly_scene_dict(
self,
parts: list,
camera: RenderCamera,
settings: RenderSettings,
) -> dict:
"""Build a multi-shape Mitsuba scene dictionary.
*parts* is a list of ``(mesh_path, RenderMaterial)`` tuples.
"""
first_path = parts[0][0] if parts else None
scene = self._base_scene_dict(camera, settings, first_mesh_path=first_path)
for i, (mesh_path, material) in enumerate(parts):
ext = os.path.splitext(mesh_path)[1].lower()
shape_type = "ply" if ext == ".ply" else "obj"
scene[f"shape_{i}"] = {
"type": shape_type,
"filename": mesh_path,
"bsdf": self._make_bsdf(material),
}
return scene
def _make_bsdf(self, material: RenderMaterial) -> dict:
"""Convert a RenderMaterial to a Mitsuba BSDF dict."""
mt = material.bsdf_type
@@ -282,9 +292,8 @@ class MitsubaBackend(RenderBackend):
logger.info(f"Rendering {settings.width}x{settings.height} @ {settings.spp} spp")
# Render
try:
image = mi.render(scene, spp=settings.spp, seed=int(settings.seed or 0)) # type: ignore[arg-type] # Mitsuba accepts int at runtime
image = mi.render(scene, spp=settings.spp, seed=int(settings.seed or 0))
except Exception as e:
logger.error(f"Mitsuba render failed: {e}")
raise
@@ -292,14 +301,48 @@ class MitsubaBackend(RenderBackend):
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.power(arr, 1.0 / 2.2)
arr = np.clip(arr, 0.0, 1.0)
return arr
def render_assembly(
self,
parts: list,
camera: RenderCamera,
settings: RenderSettings,
progress_callback: Optional[Callable[[float], None]] = None,
) -> np.ndarray:
"""Render multiple meshes with individual materials.
*parts* is a list of ``(mesh_path, RenderMaterial)`` tuples.
Returns (H, W, 3) float32 RGB array.
"""
self._set_variant()
import mitsuba as mi
scene_dict = self._build_assembly_scene_dict(parts, camera, settings)
scene = mi.load_dict(scene_dict)
logger.info(
f"Rendering assembly ({len(parts)} parts) "
f"{settings.width}x{settings.height} @ {settings.spp} spp"
)
try:
image = mi.render(scene, spp=settings.spp, seed=int(settings.seed or 0))
except Exception as e:
logger.error(f"Mitsuba assembly render failed: {e}")
raise
if progress_callback:
progress_callback(1.0)
arr = np.array(image, dtype=np.float32)
arr = np.clip(arr, 0.0, None)
arr = np.power(arr, 1.0 / 2.2)
arr = np.clip(arr, 0.0, 1.0)
return arr
def render_preview(
+88 -41
View File
@@ -17,6 +17,86 @@ from .base import Renderer, RenderObject, RenderColor
logger = logging.getLogger(__name__)
def _compute_viewport_aligned_xdir(
normal: Tuple[float, float, float],
view: Any = None,
) -> Tuple[float, float, float]:
"""Compute an in-plane x_dir that appears screen-horizontal.
When *view* (a V3d_View) is provided, derives a "screen-right" direction
from the camera's Eye/At/Up vectors and projects it onto the face plane.
This makes the sketch U-axis align with what the user sees as horizontal
on screen, eliminating the 90° rotation caused by OCC internal edge order.
Falls back to projecting world +X onto the plane when view is None
(headless tests or unavailable camera state).
"""
import numpy as np
n = np.asarray(normal, dtype=float)
n /= np.linalg.norm(n)
# Derive screen-right from camera: cross(view_up, -view_dir) gives the
# direction that appears horizontal on screen.
if view is not None:
try:
eye_obj = view.Eye()
at_obj = view.At()
up_obj = view.Up()
eye = np.array([eye_obj.X(), eye_obj.Y(), eye_obj.Z()], dtype=float)
at = np.array([at_obj.X(), at_obj.Y(), at_obj.Z()], dtype=float)
up = np.array([up_obj.X(), up_obj.Y(), up_obj.Z()], dtype=float)
view_dir = at - eye
vd_norm = np.linalg.norm(view_dir)
if vd_norm > 1e-9:
view_dir /= vd_norm
else:
view_dir = np.array([0.0, 0.0, 1.0])
up_norm = np.linalg.norm(up)
if up_norm < 1e-9:
up = np.array([0.0, 1.0, 0.0])
else:
up /= up_norm
# Screen-right in world coords: cross(view_up, view_dir) gives
# the direction that appears horizontal on screen.
screen_right = np.cross(up, view_dir)
sr_norm = np.linalg.norm(screen_right)
if sr_norm > 1e-9:
screen_right /= sr_norm
else:
screen_right = np.array([1.0, 0.0, 0.0])
except Exception:
screen_right = np.array([1.0, 0.0, 0.0])
else:
# No camera available — use world +X as best guess.
screen_right = np.array([1.0, 0.0, 0.0])
# Project screen-right onto the face plane (remove normal component).
x_dir = screen_right - np.dot(screen_right, n) * n
xn = np.linalg.norm(x_dir)
if xn < 1e-9:
# screen_right is parallel to normal — pick any orthogonal direction.
fallback = np.array([0.0, 1.0, 0.0])
x_dir = fallback - np.dot(fallback, n) * n
xn = np.linalg.norm(x_dir)
if xn > 1e-9:
unit = x_dir / xn
return (float(unit[0]), float(unit[1]), float(unit[2]))
# Last resort: cross with any non-parallel axis.
for ax in (np.array([1, 0, 0]), np.array([0, 1, 0]), np.array([0, 0, 1])):
x_dir = ax - np.dot(ax, n) * n
xn = np.linalg.norm(x_dir)
if xn > 1e-9:
unit = x_dir / xn
return (float(unit[0]), float(unit[1]), float(unit[2]))
# Should never reach here for a valid normal.
return (1.0, 0.0, 0.0)
@dataclass
class OCCRenderObject(RenderObject):
"""Internal object state for the OCC renderer."""
@@ -1046,28 +1126,12 @@ class OCCRenderer(Renderer):
d = (cx - pln_origin.X()) * nx + (cy - pln_origin.Y()) * ny + (cz - pln_origin.Z()) * nz
origin = (cx - d * nx, cy - d * ny, cz - d * nz)
# x_dir: direction of the face's first edge (stable, in-plane).
x_dir = None
try:
from OCP.TopExp import TopExp
from OCP.BRep import BRep_Tool
expl = TopExp_Explorer(face, TopAbs_EDGE)
if expl.More():
edge = TopoDS.Edge_s(expl.Current())
v1 = TopExp.FirstVertex_s(edge, True)
v2 = TopExp.LastVertex_s(edge, True)
p1 = BRep_Tool.Pnt_s(v1)
p2 = BRep_Tool.Pnt_s(v2)
ex, ey, ez = p2.X() - p1.X(), p2.Y() - p1.Y(), p2.Z() - p1.Z()
elen = (ex * ex + ey * ey + ez * ez) ** 0.5
if elen > 1e-9:
x_dir = (ex / elen, ey / elen, ez / elen)
except Exception:
pass
if x_dir is None:
# Fall back to the plane's own X axis.
px = pln.XAxis().Direction()
x_dir = (px.X(), px.Y(), px.Z())
# x_dir: project a "screen-horizontal" direction onto the face plane
# so that the sketch's U-axis appears horizontal in the current 3D
# viewport. This eliminates the 90° rotation that occurred when we
# used the first edge / plane X axis (which follow OCC internal order,
# not the viewer perspective).
x_dir = _compute_viewport_aligned_xdir((nx, ny, nz), self._view)
# Identify the displayed body that owns this face, so the host can
# auto-target it as the cut/union body when the user extrudes the
@@ -1258,25 +1322,8 @@ class OCCRenderer(Renderer):
d = (cx - pln_origin.X()) * nx + (cy - pln_origin.Y()) * ny + (cz - pln_origin.Z()) * nz
origin = (cx - d * nx, cy - d * ny, cz - d * nz)
# x_dir from first edge.
x_dir = None
try:
expl = TopExp_Explorer(face, TopAbs_EDGE_TYPE)
if expl.More():
edge = TopoDS.Edge_s(expl.Current())
v1 = TopExp.FirstVertex_s(edge, True)
v2 = TopExp.LastVertex_s(edge, True)
p1 = BRep_Tool.Pnt_s(v1)
p2 = BRep_Tool.Pnt_s(v2)
ex, ey, ez = p2.X() - p1.X(), p2.Y() - p1.Y(), p2.Z() - p1.Z()
elen = (ex * ex + ey * ey + ez * ez) ** 0.5
if elen > 1e-9:
x_dir = (ex / elen, ey / elen, ez / elen)
except Exception:
pass
if x_dir is None:
px = pln.XAxis().Direction()
x_dir = (px.X(), px.Y(), px.Z())
# x_dir: viewport-aligned so connector gizmo matches screen.
x_dir = _compute_viewport_aligned_xdir((nx, ny, nz), self._view)
return [{
"type": "planar_face",