diff --git a/flow360/__init__.py b/flow360/__init__.py index 1b6fd8d54..1d827bf7f 100644 --- a/flow360/__init__.py +++ b/flow360/__init__.py @@ -124,12 +124,33 @@ PointArray2D, Slice, ) +from flow360.component.simulation.outputs.output_render_types import ( + AmbientLight, + AnimatedCamera, + ColorKey, + DirectionalLight, + FieldMaterial, + Keyframe, + OrthographicProjection, + PBRMaterial, + PerspectiveProjection, + RenderCameraConfig, + RenderEnvironmentConfig, + RenderLightingConfig, + SkyboxBackground, + SkyboxTexture, + SolidBackground, + StaticCamera, + Transform, +) from flow360.component.simulation.outputs.outputs import ( AeroAcousticOutput, IsosurfaceOutput, MovingStatistic, Observer, ProbeOutput, + RenderOutputGroup, + RenderOutput, SliceOutput, StreamlineOutput, SurfaceIntegralOutput, diff --git a/flow360/component/simulation/outputs/output_entities.py b/flow360/component/simulation/outputs/output_entities.py index 1206ace24..994bff982 100644 --- a/flow360/component/simulation/outputs/output_entities.py +++ b/flow360/component/simulation/outputs/output_entities.py @@ -8,7 +8,7 @@ from flow360.component.simulation.framework.base_model import Flow360BaseModel from flow360.component.simulation.framework.entity_base import EntityBase, generate_uuid from flow360.component.simulation.outputs.output_fields import IsoSurfaceFieldNames -from flow360.component.simulation.unit_system import LengthType +from flow360.component.simulation.unit_system import AngleType, LengthType from flow360.component.simulation.user_code.core.types import ( Expression, UnytQuantity, @@ -21,7 +21,7 @@ solver_variable_to_user_variable, ) from flow360.component.simulation.user_code.core.utils import is_runtime_expression -from flow360.component.types import Axis +from flow360.component.types import Axis, Color, Vector class _OutputItemBase(Flow360BaseModel): diff --git a/flow360/component/simulation/outputs/output_render_types.py b/flow360/component/simulation/outputs/output_render_types.py new file mode 100644 index 000000000..fb620538d --- /dev/null +++ b/flow360/component/simulation/outputs/output_render_types.py @@ -0,0 +1,251 @@ +import abc +from enum import Enum +from typing import Dict, List, Optional, Union + +import pydantic as pd + +import flow360.component.simulation.units as u +from flow360.component.simulation.framework.base_model import Flow360BaseModel +from flow360.component.simulation.unit_system import AngleType, LengthType +from flow360.component.types import Color, Vector + + +class StaticCamera(Flow360BaseModel): + position: LengthType.Point = pd.Field(description="Position of the camera in the scene") + target: LengthType.Point = pd.Field(description="Target point of the camera") + up: Optional[Vector] = pd.Field(default=(0, 1, 0), description="Up vector, if not specified assume Y+") + + +class Keyframe(Flow360BaseModel): + time: pd.confloat(ge=0) = pd.Field(0) + view: StaticCamera = pd.Field() + + +class AnimatedCamera(Flow360BaseModel): + keyframes: List[Keyframe] = pd.Field([]) + + +AllCameraTypes = Union[StaticCamera, AnimatedCamera] + + +class OrthographicProjection(Flow360BaseModel): + type: str = pd.Field(default="orthographic", frozen=True) + width: LengthType = pd.Field() + near: LengthType = pd.Field() + far: LengthType = pd.Field() + + +class PerspectiveProjection(Flow360BaseModel): + type: str = pd.Field(default="perspective", frozen=True) + fov: AngleType = pd.Field() + near: LengthType = pd.Field() + far: LengthType = pd.Field() + + +class RenderCameraConfig(Flow360BaseModel): + view: AllCameraTypes = pd.Field() + projection: Union[OrthographicProjection, PerspectiveProjection] = pd.Field() + + @classmethod + def orthographic(cls, x=1, y=1, z=1, scale=1): + return RenderCameraConfig( + view=StaticCamera( + position=(x * scale, y * scale, z * scale) * u.m, + target=(0, 0, 0) * u.m + ), + projection=OrthographicProjection( + width=scale * u.m, + near=0.01 * u.m, + far=50 * scale * u.m + ) + ) + + @classmethod + def perspective(cls, x=1, y=1, z=1, scale=1): + return RenderCameraConfig( + view=StaticCamera( + position=(x * scale, y * scale, z * scale) * u.m, + target=(0, 0, 0) * u.m + ), + projection=PerspectiveProjection( + fov=60 * u.deg, + near=0.01 * u.m, + far=50 * scale * u.m + ) + ) + + +class AmbientLight(Flow360BaseModel): + intensity: float = pd.Field() + color: Color = pd.Field() + + +class DirectionalLight(Flow360BaseModel): + intensity: float = pd.Field() + color: Color = pd.Field() + direction: Vector = pd.Field() + + +class RenderLightingConfig(Flow360BaseModel): + directional: DirectionalLight = pd.Field() + ambient: Optional[AmbientLight] = pd.Field(None) + + @classmethod + def default(cls): + return RenderLightingConfig( + ambient=AmbientLight( + intensity=0.5, + color=(255, 255, 255) + ), + directional=DirectionalLight( + intensity=1.5, + color=(255, 255, 255), + direction=(-1.0, -1.0, -1.0) + ) + ) + + +class RenderBackgroundBase(Flow360BaseModel, metaclass=abc.ABCMeta): + type: str = pd.Field(default="", frozen=True) + + +class SolidBackground(RenderBackgroundBase): + type: str = pd.Field(default="solid", frozen=True) + color: Color = pd.Field() + + +class SkyboxTexture(str, Enum): + SKY = "sky" + GRADIENT = "gradient" + + +class SkyboxBackground(RenderBackgroundBase): + type: str = pd.Field(default="skybox", frozen=True) + texture: SkyboxTexture = pd.Field(SkyboxTexture.SKY) + + +AllBackgroundTypes = Union[SolidBackground, SkyboxBackground] + + +class RenderEnvironmentConfig(Flow360BaseModel): + background: AllBackgroundTypes = pd.Field() + + @classmethod + def simple(cls): + return RenderEnvironmentConfig( + background=SolidBackground( + color=(207, 226, 230) + ) + ) + + @classmethod + def sky(cls): + return RenderEnvironmentConfig( + background=SkyboxBackground( + texture=SkyboxTexture.SKY + ) + ) + + @classmethod + def gradient(cls): + return RenderEnvironmentConfig( + background=SkyboxBackground( + texture=SkyboxTexture.GRADIENT + ) + ) + + +class RenderMaterialBase(Flow360BaseModel, metaclass=abc.ABCMeta): + type: str = pd.Field(default="", frozen=True) + + +class PBRMaterial(RenderMaterialBase): + color: Color = pd.Field(default=[255, 255, 255]) + alpha: float = pd.Field(default=1) + roughness: float = pd.Field(default=0.5) + f0: Vector = pd.Field(default=(0.03, 0.03, 0.03)) + type: str = pd.Field(default="pbr", frozen=True) + + @classmethod + def metal(cls, shine=0.5, alpha=1.0): + return PBRMaterial( + color=(255, 255, 255), + alpha=alpha, + roughness=1 - shine, + f0=(0.56, 0.56, 0.56) + ) + + @classmethod + def plastic(cls, shine=0.5, alpha=1.0): + return PBRMaterial( + color=(255, 255, 255), + alpha=alpha, + roughness=1 - shine, + f0=(0.03, 0.03, 0.03) + ) + + +class ColorKey(Flow360BaseModel): + color: Color = pd.Field(default=[255, 255, 255]) + value: pd.confloat(ge=0, le=1) = pd.Field(default=0.5) + + +class FieldMaterial(RenderMaterialBase): + alpha: float = pd.Field(default=1) + output_field: str = pd.Field(default="") + min: float = pd.Field(default=0) + max: float = pd.Field(default=1) + colormap: List[ColorKey] = pd.Field() + type: str = pd.Field(default="field", frozen=True) + + @classmethod + def greyscale(cls, field, min=0, max=1, alpha=1): + return FieldMaterial( + alpha=alpha, + output_field=field, + min=min, + max=max, + colormap = [ + ColorKey(color=(0, 0, 0), value=0), + ColorKey(color=(255, 255, 255), value=1.0) + ] + ) + + @classmethod + def hot_cold(cls, field, min=0, max=1, alpha=1): + return FieldMaterial( + alpha=alpha, + output_field=field, + min=min, + max=max, + colormap = [ + ColorKey(color=(0, 0, 255), value=0), + ColorKey(color=(255, 255, 255), value=0.5), + ColorKey(color=(255, 0, 0), value=1.0) + ] + ) + + @classmethod + def rainbow(cls, field, min=0, max=1, alpha=1): + return FieldMaterial( + alpha=alpha, + output_field=field, + min=min, + max=max, + colormap = [ + ColorKey(color=(0, 0, 255), value=0), + ColorKey(color=(0, 255, 255), value=0.25), + ColorKey(color=(0, 255, 0), value=0.5), + ColorKey(color=(255, 255, 0), value=0.75), + ColorKey(color=(255, 0, 0), value=1.0) + ] + ) + + +AnyMaterial = Union[PBRMaterial, FieldMaterial] + + +class Transform(Flow360BaseModel): + translation: LengthType.Point = pd.Field(default=[0, 0, 0]) + rotation: AngleType.Vector = pd.Field(default=[0, 0, 0]) + scale: Vector = pd.Field(default=[1, 1, 1]) diff --git a/flow360/component/simulation/outputs/outputs.py b/flow360/component/simulation/outputs/outputs.py index d5938dfaa..876a95b76 100644 --- a/flow360/component/simulation/outputs/outputs.py +++ b/flow360/component/simulation/outputs/outputs.py @@ -6,7 +6,7 @@ """ # pylint: disable=too-many-lines -from typing import Annotated, List, Literal, Optional, Union, get_args +from typing import Annotated, Dict, List, Literal, Optional, Union, get_args import pydantic as pd @@ -32,6 +32,13 @@ VolumeFieldNames, get_field_values, ) +from flow360.component.simulation.outputs.output_render_types import ( + AnyMaterial, + RenderCameraConfig, + RenderEnvironmentConfig, + RenderLightingConfig, + Transform, +) from flow360.component.simulation.primitives import ( GhostCircularPlane, GhostSphere, @@ -665,6 +672,60 @@ def allow_only_simulation_surfaces_or_imported_surfaces(cls, value): return value +class RenderOutputGroup(Flow360BaseModel): + surfaces: Optional[EntityList[Surface]] = pd.Field( + None, description="List of of :class:`~flow360.Surface` entities." + ) + slices: Optional[EntityList[Slice]] = pd.Field( + None, description="List of of :class:`~flow360.Slice` entities." + ) + isosurfaces: Optional[UniqueItemList[Isosurface]] = pd.Field( + None, description="List of :class:`~flow360.Isosurface` entities." + ) + material: AnyMaterial = pd.Field() + + +class RenderOutput(_AnimationSettings): + """ + + :class:`RenderOutput` class for backend rendered output settings. + + Example + ------- + + Define the :class:`RenderOutput` of :code:`qcriterion` on two isosurfaces: + + >>> fl.RenderOutput( + ... isosurfaces=[ + ... fl.Isosurface( + ... name="Isosurface_T_0.1", + ... iso_value=0.1, + ... field="T", + ... ), + ... fl.Isosurface( + ... name="Isosurface_p_0.5", + ... iso_value=0.5, + ... field="p", + ... ), + ... ], + ... output_field="qcriterion", + ... ) + + ==== + """ + + name: str = pd.Field("Render output", description="Name of the `RenderOutput`.") + groups: List[RenderOutputGroup] = pd.Field("Render groups") + output_fields: UniqueItemList[Union[CommonFieldNames, str]] = pd.Field( + [], description="List of output variables." + ) + camera: RenderCameraConfig = pd.Field(description="Camera settings", default_factory=RenderCameraConfig.orthographic) + lighting: RenderLightingConfig = pd.Field(description="Lighting settings", default_factory=RenderLightingConfig.default) + environment: RenderEnvironmentConfig = pd.Field(description="Environment settings", default_factory=RenderEnvironmentConfig.simple) + transform: Optional[Transform] = pd.Field(None, description="Optional model transform to apply to all entities") + output_type: Literal["RenderOutput"] = pd.Field("RenderOutput", frozen=True) + + class ProbeOutput(_OutputBase): """ :class:`ProbeOutput` class for setting output data probed at monitor points. @@ -1293,6 +1354,7 @@ class TimeAverageStreamlineOutput(StreamlineOutput): AeroAcousticOutput, StreamlineOutput, TimeAverageStreamlineOutput, + RenderOutput, ], pd.Field(discriminator="output_type"), ] diff --git a/flow360/component/simulation/translator/solver_translator.py b/flow360/component/simulation/translator/solver_translator.py index 57a8040a9..4543e1c0c 100644 --- a/flow360/component/simulation/translator/solver_translator.py +++ b/flow360/component/simulation/translator/solver_translator.py @@ -66,6 +66,7 @@ IsosurfaceOutput, MonitorOutputType, ProbeOutput, + RenderOutput, Slice, SliceOutput, StreamlineOutput, @@ -81,7 +82,7 @@ TimeAverageSurfaceProbeOutput, TimeAverageVolumeOutput, UserDefinedField, - VolumeOutput, + VolumeOutput, RenderOutputGroup, ) from flow360.component.simulation.primitives import ( BOUNDARY_FULL_NAME_WHEN_NOT_FOUND, @@ -96,12 +97,14 @@ ) from flow360.component.simulation.simulation_params import SimulationParams from flow360.component.simulation.time_stepping.time_stepping import Steady, Unsteady +from flow360.component.simulation.outputs.output_render_types import FieldMaterial from flow360.component.simulation.translator.user_expression_utils import ( udf_prepending_code, ) from flow360.component.simulation.translator.utils import ( _get_key_name, convert_tuples_to_lists, + get_all_entries_of_type, get_global_setting_from_first_instance, has_instance_in_list, inline_expressions_in_dict, @@ -382,6 +385,14 @@ def get_monitor_locations(entities: EntityList): return monitor_locations +def inject_render_info(entity: Isosurface): + """inject entity info""" + return { + "surfaceField": entity.field, + "surfaceFieldMagnitude": entity.iso_value, + } + + def inject_probe_info(entity: EntityList): """inject entity info""" @@ -565,6 +576,81 @@ def translate_isosurface_output( return translated_output +def translate_render_output( + input_params: SimulationParams, + output_params: list, + isosurface_injection_function, + slice_injection_function +): + """Translate render output settings.""" + + renders = get_all_entries_of_type(output_params, RenderOutput) + + translated_outputs = [] + + for render in renders: + camera = render.camera.model_dump(exclude_none=True, exclude_unset=True, by_alias=True) + lighting = render.lighting.model_dump(exclude_none=True, exclude_unset=True, by_alias=True) + environment = render.environment.model_dump( + exclude_none=True, exclude_unset=True, by_alias=True + ) + + for render_group in render.groups: + material = render_group.material.model_dump(exclude_none=True, exclude_unset=True, by_alias=True) + if "outputField" in material and material["outputField"] not in render.output_fields: + render.output_fields.append(material["outputField"]) + + translated_output = { + "name": render.name, + "animationFrequency": render.frequency, + "animationFrequencyOffset": render.frequency_offset, + "groups": [], + "camera": remove_units_in_dict(camera), + "lighting": remove_units_in_dict(lighting), + "environment": remove_units_in_dict(environment), + "outputFields": translate_output_fields(render)["outputFields"] + } + + for render_group in render.groups: + material = render_group.material.model_dump(exclude_none=True, exclude_unset=True, by_alias=True) + translated_output["groups"].append({ + "surfaces": translate_setting_and_apply_to_all_entities( + [render_group], + RenderOutputGroup, + to_list=False, + entity_type_to_include=Surface, + entity_list_attribute_name="surfaces" + ), + "slices": translate_setting_and_apply_to_all_entities( + [render_group], + RenderOutputGroup, + to_list=False, + entity_injection_func=slice_injection_function, + entity_type_to_include=Slice, + entity_list_attribute_name="slices" + ), + "isoSurfaces": translate_setting_and_apply_to_all_entities( + [render_group], + RenderOutputGroup, + to_list=False, + entity_injection_func=isosurface_injection_function, + entity_type_to_include=Isosurface, + entity_list_attribute_name="isosurfaces", + entity_injection_input_params=input_params, + ), + "material": remove_units_in_dict(material) + }) + if render.transform: + transform = render.transform.model_dump( + exclude_none=True, exclude_unset=True, by_alias=True + ) + translated_output["transform"] = remove_units_in_dict(transform) + + translated_outputs.append(translated_output) + + return translated_outputs + + def translate_surface_slice_output( output_params: list, output_class: Union[SurfaceSliceOutput], @@ -924,7 +1010,16 @@ def translate_output(input_params: SimulationParams, translated: dict): if imported_surface_output_configs["surfaces"]: translated["timeAverageImportedSurfaceOutput"] = imported_surface_output_configs - ##:: Step6: Get translated["monitorOutput"] + ##:: Step6: Get translated["renderOutput"] + if has_instance_in_list(outputs, RenderOutput): + translated["renderOutput"] = translate_render_output( + input_params, + outputs, + inject_isosurface_info, + inject_slice_info + ) + + ##:: Step7: Get translated["monitorOutput"] probe_output = {} probe_output_average = {} integral_output = {} @@ -946,7 +1041,7 @@ def translate_output(input_params: SimulationParams, translated: dict): if probe_output or integral_output: translated["monitorOutput"] = merge_monitor_output(probe_output, integral_output) - ##:: Step6.1: Get translated["surfaceMonitorOutput"] + ##:: Step7.1: Get translated["surfaceMonitorOutput"] surface_monitor_output = {} surface_monitor_output_average = {} if has_instance_in_list(outputs, SurfaceProbeOutput): @@ -968,17 +1063,17 @@ def translate_output(input_params: SimulationParams, translated: dict): surface_monitor_output, surface_monitor_output_average ) - ##:: Step6: Get translated["surfaceSliceOutput"] + ##:: Step8: Get translated["surfaceSliceOutput"] surface_slice_output = {} if has_instance_in_list(outputs, SurfaceSliceOutput): surface_slice_output = translate_surface_slice_output(outputs, SurfaceSliceOutput) translated["surfaceSliceOutput"] = surface_slice_output - ##:: Step7: Get translated["aeroacousticOutput"] + ##:: Step9: Get translated["aeroacousticOutput"] if has_instance_in_list(outputs, AeroAcousticOutput): translated["aeroacousticOutput"] = translate_acoustic_output(outputs) - ##:: Step8: Get translated["streamlineOutput"] + ##:: Step10: Get translated["streamlineOutput"] if has_instance_in_list(outputs, StreamlineOutput): translated["streamlineOutput"] = translate_streamline_output(outputs, StreamlineOutput) @@ -987,7 +1082,7 @@ def translate_output(input_params: SimulationParams, translated: dict): outputs, TimeAverageStreamlineOutput ) - ##:: Step9: Get translated["importedSurfaceIntegralOutput"] + ##:: Step11: Get translated["importedSurfaceIntegralOutput"] if has_instance_in_list(outputs, SurfaceIntegralOutput): imported_surface_integral_output_configs = translate_imported_surface_integral_output( outputs, @@ -995,7 +1090,7 @@ def translate_output(input_params: SimulationParams, translated: dict): if imported_surface_integral_output_configs["surfaces"]: translated["importedSurfaceIntegralOutput"] = imported_surface_integral_output_configs - ##:: Step10: Sort all "output_fields" everywhere + ##:: Step12: Sort all "output_fields" everywhere # Recursively sort all "outputFields" lists in the translated dict def _sort_output_fields_in_dict(d): if isinstance(d, dict): diff --git a/flow360/component/simulation/translator/utils.py b/flow360/component/simulation/translator/utils.py index 87f58e7f6..bf01aeed8 100644 --- a/flow360/component/simulation/translator/utils.py +++ b/flow360/component/simulation/translator/utils.py @@ -8,6 +8,7 @@ from typing import Union import numpy as np +import pydantic as pd import unyt as u from flow360.component.simulation.framework.base_model import snake_to_camel @@ -220,6 +221,17 @@ def getattr_by_path(obj, path: Union[str, list], *args): return obj +def get_all_entries_of_type(obj_list: list, class_type: type[pd.BaseModel]): + entries = [] + + if obj_list is not None: + for obj in obj_list: + if is_exact_instance(obj, class_type): + entries.append(obj) + + return entries + + def get_global_setting_from_first_instance( obj_list: list, class_type, @@ -266,7 +278,7 @@ def _get_key_name(entity: EntityBase): def translate_setting_and_apply_to_all_entities( obj_list: list, class_type, - translation_func, + translation_func=lambda x, **kwargs: {}, to_list: bool = False, entity_injection_func=lambda x, **kwargs: {}, pass_translated_setting_to_entity_injection=False, @@ -275,6 +287,7 @@ def translate_setting_and_apply_to_all_entities( use_instance_name_as_key=False, use_sub_item_as_key=False, entity_type_to_include=None, + entity_list_attribute_name="entities", **kwargs, ): """ @@ -357,23 +370,23 @@ def translate_setting_and_apply_to_all_entities( # pylint: disable=too-many-nested-blocks for obj in obj_list: if class_type and is_exact_instance(obj, class_type): - list_of_entities = [] - if "entities" in obj.__class__.model_fields: - if obj.entities is None or ( - "stored_entities" in obj.entities.__class__.model_fields - and obj.entities.stored_entities is None + if entity_list_attribute_name in obj.__class__.model_fields: + entity_list = getattr(obj, entity_list_attribute_name) + if entity_list is None or ( + "stored_entities" in entity_list.__class__.model_fields + and entity_list.stored_entities is None ): # unique item list does not allow None "items" for now. continue - if isinstance(obj.entities, EntityList): + if isinstance(entity_list, EntityList): list_of_entities = ( - obj.entities.stored_entities + entity_list.stored_entities if lump_list_of_entities is False - else [obj.entities] + else [entity_list] ) - elif isinstance(obj.entities, UniqueItemList): + elif isinstance(entity_list, UniqueItemList): list_of_entities = ( - obj.entities.items if lump_list_of_entities is False else [obj.entities] + entity_list.items if lump_list_of_entities is False else [entity_list] ) elif "entity_pairs" in obj.__class__.model_fields: # Note: This is only used in Periodic BC and lump_list_of_entities is not relavant diff --git a/flow360/component/types.py b/flow360/component/types.py index e15b88dc9..20eb73c98 100644 --- a/flow360/component/types.py +++ b/flow360/component/types.py @@ -14,6 +14,7 @@ List2D = List[List[float]] # we use tuple for fixed length lists, beacause List is a mutable, variable length structure Coordinate = Tuple[float, float, float] +Color = Tuple[int, int, int] class Vector(Coordinate): diff --git a/tests/simulation/translator/test_solver_translator.py b/tests/simulation/translator/test_solver_translator.py index 390b19552..f4d5be576 100644 --- a/tests/simulation/translator/test_solver_translator.py +++ b/tests/simulation/translator/test_solver_translator.py @@ -58,16 +58,30 @@ ThermalState, ) from flow360.component.simulation.outputs.output_entities import ( + Isosurface, Point, PointArray, PointArray2D, Slice, ) +from flow360.component.simulation.outputs.output_render_types import ( + AmbientLight, + DirectionalLight, + OrthographicProjection, + PBRMaterial, + RenderCameraConfig, + RenderEnvironmentConfig, + RenderLightingConfig, + SkyboxBackground, + SkyboxTexture, + StaticCamera, +) from flow360.component.simulation.outputs.outputs import ( Isosurface, IsosurfaceOutput, MovingStatistic, ProbeOutput, + RenderOutput, SliceOutput, StreamlineOutput, SurfaceIntegralOutput, @@ -181,6 +195,7 @@ def get_om6Wing_tutorial_param(): my_wall = Surface(name="1") my_symmetry_plane = Surface(name="2") my_freestream = Surface(name="3") + my_isosurface = Isosurface(name="iso", field="Mach", iso_value=0.5) with SI_unit_system: param = SimulationParams( reference_geometry=ReferenceGeometry( @@ -244,6 +259,31 @@ def get_om6Wing_tutorial_param(): output_format="paraview", output_fields=["Cp"], ), + RenderOutput( + isosurfaces=[my_isosurface], + output_fields=["qcriterion"], + camera=RenderCameraConfig( + view=StaticCamera(position=(20, 20, 20), target=(0, 0, 0)), + projection=OrthographicProjection(width=30, near=0.01, far=100), + ), + lighting=RenderLightingConfig( + ambient=AmbientLight(intensity=0.4, color=(255, 255, 255)), + directional=DirectionalLight( + intensity=1.5, color=(255, 255, 255), direction=(-1.0, -1.0, -1.0) + ), + ), + environment=RenderEnvironmentConfig( + background=SkyboxBackground(texture=SkyboxTexture.SKY) + ), + materials={ + "iso": PBRMaterial( + color=(245, 245, 246), + alpha=1.0, + roughness=0.3, + f0=(0.56, 0.56, 0.56), + ) + } + ), ], ) return param