"""Image-side polyhedral-mesh body rendering.
The mesh primitives live in the shared :mod:`spindoctor.sim.mesh_geometry`
module so the rendered mesh and the navigator's predicted mesh are the same
shape by construction. On top of that shared geometry this image side adds
its truth-key upgrades, none of which the navigator's prediction consumes:
- ``shading`` selects the shared rasterizer's mode for the RENDERED image
('flat' default, 'gouraud' per-vertex smooth shading). The rasterizer
capability is shared; each side chooses its own mode, and the navigator's
predicted mesh keeps flat shading because the key is truth-side.
- ``limb_relief_rms`` / ``limb_relief_corr_deg`` apply the same relief-field
machinery the ellipsoid path uses, here as a per-vertex radial
perturbation of the unit mesh (its own seeded 'relief' stream). The
field is sampled in body-fixed spherical coordinates -- mesh terrain is
attached to the body and rotates with the pose -- so the commanded
limb-slice statistics apply to whichever great circle the pose turns
toward the observer.
- ``pose_scatter`` draws a seeded per-frame Gaussian perturbation (sigma_deg
per Euler axis, its own 'pose_scatter' stream) added to the rendered
pose only. The navigator predicts the catalog pose, so the drawn
rotation is a known-wrong rotation state; the draw is recorded in the
render truth as ``pose_scatter_drawn_deg``.
"""
import dataclasses
from functools import lru_cache
from typing import Any
import numpy as np
from spindoctor.sim.forward.body import finish_single_body
from spindoctor.sim.forward.relief import ReliefField, synthesize_relief_field
from spindoctor.sim.mesh_geometry import (
Mesh,
MeshBodySpec,
make_irregular_mesh,
mesh_spec_from_params,
render_polyhedral_body,
)
from spindoctor.sim.seeds import derive_effect_seed, stable_param_seed
from spindoctor.support.types import NDArrayBoolType, NDArrayFloatType
__all__ = ['render_single_mesh_body']
def _perturb_mesh_radially(mesh: Mesh, field: ReliefField) -> Mesh:
"""Scale each vertex radially by ``1 + h(lat, lon)`` of the relief field.
Coordinates are body-fixed spherical: latitude from the body's xy plane
toward +z, longitude around +z from +x. The perturbation moves vertices
along their radius only, so the mesh stays star-shaped and the face
winding stays outward for the relief amplitudes the schema admits.
Parameters:
mesh: The unit-radius base mesh.
field: The relief-field realization (fractional heights).
Returns:
A new perturbed :class:`Mesh` sharing the face array.
"""
vertices = mesh.vertices
radius = np.linalg.norm(vertices, axis=1)
safe_radius = np.maximum(radius, 1e-12)
lat = np.arcsin(np.clip(vertices[:, 2] / safe_radius, -1.0, 1.0))
lon = np.mod(np.arctan2(vertices[:, 1], vertices[:, 0]), 2.0 * np.pi)
scale = 1.0 + field.sample(lat, lon)
return Mesh(vertices=vertices * scale[:, np.newaxis], faces=mesh.faces)
@lru_cache(maxsize=30)
def _render_mesh_shape_cached(
size_v: int,
size_u: int,
axis1: float,
*,
axis2: float,
axis3: float,
spec: MeshBodySpec,
illumination_angle: float,
phase_angle: float,
anti_aliasing: float,
shading: str,
relief_rms: float,
relief_corr_deg: float,
relief_seed: int,
) -> NDArrayFloatType:
"""Cache an irregular mesh body shape at the reference (image) centre.
The truth-side upgrades (shading mode, relief field) are part of the
cache key; at their defaults ('flat', relief off) this renders exactly
the shared ``render_mesh_body_image`` result the navigator predicts.
"""
mesh = make_irregular_mesh(
n_lat=spec.n_lat,
n_lon=spec.n_lon,
lumpiness=spec.lumpiness,
seed=spec.seed,
detail_octaves=spec.detail_octaves,
)
if relief_rms > 0.0:
field = synthesize_relief_field(relief_rms, relief_corr_deg, relief_seed)
if field.h_max > field.h_min:
mesh = _perturb_mesh_radially(mesh, field)
return render_polyhedral_body(
size=(size_v, size_u),
center=(size_v / 2.0, size_u / 2.0),
mesh=mesh,
semi_axes_px=(axis1 / 2.0, axis2 / 2.0, axis3 / 2.0),
pose_euler_deg=spec.pose_euler_deg,
illumination_angle=illumination_angle,
phase_angle=phase_angle,
anti_aliasing=anti_aliasing,
shading=shading,
)
def _mesh_identity_seed(
body_params: dict[str, Any],
*,
seed: int | None,
body_index: int,
body_name: str,
axis1: float,
axis2: float,
axis3: float,
) -> int:
"""The per-body identity seed the mesh truth streams derive from.
Mirrors the ellipsoid path's chain exactly: the scene sub-seed mixed
with the body's stable identity when available, else the explicit
per-body seed, else a geometry-derived process-stable seed.
"""
if seed is not None:
return derive_effect_seed(seed, f'body:{body_index}:{body_name}')
if body_params.get('seed') is not None:
return int(body_params['seed']) & 0x7FFFFFFF
return stable_param_seed(axis1, axis2, axis3, body_index, body_name) & 0x7FFFFFFF
[docs]
def render_single_mesh_body(
img: NDArrayFloatType,
body_params: dict[str, Any],
body_name: str,
*,
center_v: float,
center_u: float,
axis1: float,
axis2: float,
axis3: float,
illumination_angle: float,
phase_angle: float,
anti_aliasing: float,
ref_center_v: float,
ref_center_u: float,
seed: int | None = None,
body_index: int = 0,
) -> tuple[NDArrayBoolType, dict[str, Any]]:
"""Render one polyhedral-mesh body into the image in place.
Parameters:
img: Image array to modify in-place.
body_params: Body parameters dictionary (mesh keys are parsed by
``mesh_spec_from_params``; the truth keys ``shading``,
``limb_relief_*``, and ``pose_scatter`` are read here). The
mapping is never mutated: when a pose scatter is drawn, the
returned body info's ``params`` is a copy of this mapping with
``pose_scatter_drawn_deg`` added (render truth metadata).
body_name: Upper-cased body name for the inventory keys.
center_v: Body center V in the image (offset already applied).
center_u: Body center U in the image (offset already applied).
axis1: Full width of ellipsoidal-envelope axis 1 in pixels.
axis2: Full width of ellipsoidal-envelope axis 2 in pixels.
axis3: Full width of ellipsoidal-envelope axis 3 in pixels.
illumination_angle: Image-plane light azimuth in radians.
phase_angle: Phase angle in radians.
anti_aliasing: Limb supersampling control.
ref_center_v: Reference center V for shape caching.
ref_center_u: Reference center U for shape caching.
seed: Scene-level sub-seed the per-body truth streams derive from.
body_index: Stable index of this body in the scene's body list.
Returns:
Tuple of (body_mask, body_info_dict) matching the ellipsoid path.
"""
size_v, size_u = img.shape
spec = mesh_spec_from_params(body_params)
shading = str(body_params.get('shading', 'flat'))
relief_rms = float(body_params.get('limb_relief_rms', 0.0))
relief_corr_deg = float(body_params.get('limb_relief_corr_deg', 15.0))
scatter = body_params.get('pose_scatter') or {}
scatter_sigma_deg = float(scatter.get('sigma_deg', 0.0))
# The relief terrain and the pose scatter each draw from their own named
# stream of the per-body identity seed, mirroring the ellipsoid path, so
# they are independent of each other and of any crater draws. The drawn
# scatter travels to the render truth through the returned body info's
# params copy; body_params itself stays untouched (the caller's scene
# mapping must remain schema-valid, and pose_scatter_drawn_deg is not a
# schema key).
truth_params = body_params
relief_seed = 0
if relief_rms > 0.0 or scatter_sigma_deg > 0.0:
identity_seed = _mesh_identity_seed(
body_params,
seed=seed,
body_index=body_index,
body_name=body_name,
axis1=axis1,
axis2=axis2,
axis3=axis3,
)
if relief_rms > 0.0:
relief_seed = derive_effect_seed(identity_seed, 'relief')
if scatter_sigma_deg > 0.0:
rng = np.random.default_rng(derive_effect_seed(identity_seed, 'pose_scatter'))
drawn = rng.normal(0.0, scatter_sigma_deg, size=3)
truth_params = {**body_params, 'pose_scatter_drawn_deg': [float(d) for d in drawn]}
spec = dataclasses.replace(
spec,
pose_euler_deg=(
spec.pose_euler_deg[0] + float(drawn[0]),
spec.pose_euler_deg[1] + float(drawn[1]),
spec.pose_euler_deg[2] + float(drawn[2]),
),
)
body_shape = _render_mesh_shape_cached(
size_v,
size_u,
axis1,
axis2=axis2,
axis3=axis3,
spec=spec,
illumination_angle=illumination_angle,
phase_angle=phase_angle,
anti_aliasing=anti_aliasing,
shading=shading,
relief_rms=relief_rms,
relief_corr_deg=relief_corr_deg,
relief_seed=relief_seed,
)
# A mesh body's pose is an arbitrary 3D rotation, so fall back to the
# bounding sphere of the ellipsoidal envelope for both image axes.
mesh_half_extent = max(axis1, axis2, axis3) / 2.0
return finish_single_body(
img,
body_shape,
truth_params,
body_name=body_name,
center_v=center_v,
center_u=center_u,
half_extent_v=mesh_half_extent,
half_extent_u=mesh_half_extent,
ref_center_v=ref_center_v,
ref_center_u=ref_center_u,
)