# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Felix Faber /
# Otto von Guericke University Magdeburg, Thermal Process Engineering
from pathlib import Path
import bpy
import numpy as np
from mathutils import Matrix, Vector
from porescene.color import Color
from porescene.color.gradient import DiscreteGradient, SegmentedGradient, SmoothGradient
from porescene.config import PropertyConfiguration
from porescene.image import img_add_colorbar
from porescene.layout import (
DiscreteGradientOverlay,
Overlay,
SegmentedGradientOverlay,
SmoothGradientOverlay,
)
from porescene.model import PoreNetwork, PoreNetworkProperty
from porescene.scene import Scene
from porescene.utility import _get_bounds, svg2png
[docs]
def build_structure(
sc: Scene,
pn: PoreNetwork,
*,
left: bool = False,
right: bool = False,
front: bool = False,
back: bool = False,
bottom: bool = False,
top: bool = False,
) -> Scene:
"""
Constructs the cylinder and sphere meshes in the Blender scene based on the given
:class:`PoreNetwork <porescene.model.PoreNetwork>` instance.
Parameters
----------
sc : Scene
The scene to add the objects to.
pn : PoreNetwork
The pore network that is used to calculate cylinder and sphere positions
as well as dimensions
left : bool, optional
If true, boundary pores (as well as their connections into the central network)
at the start of the x-dimension are added into the scene, by default False
right : bool, optional
If true, boundary pores (as well as their connections into the central network)
at the end of the x-dimension are added into the scene, by default False
front : bool, optional
If true, boundary pores (as well as their connections into the central network)
at the start of the y-dimension are added into the scene, by default False
back : bool, optional
If true, boundary pores (as well as their connections into the central network)
at the end of the y-dimension are added into the scene, by default False
bottom : bool, optional
If true, boundary pores (as well as their connections into the central network)
at the start of the z-dimension are added into the scene, by default False
top : bool, optional
If true, boundary pores (as well as their connections into the central network)
at the end of the z-dimension are added into the scene, by default False
Returns
-------
Scene
The scene with added objects.
"""
if (
pn.pore_position is not None
and pn.throat_radius is not None
and pn.tnp is not None
and sc.config_scene.enable_cylinders
):
pos_t = np.hstack(
[
pn.pore_position[pn.tnp[:, 0], :],
pn.pore_position[pn.tnp[:, 1], :],
]
)
r_t = pn.throat_radius
boundaries = {
"left": left,
"right": right,
"front": front,
"back": back,
"bottom": bottom,
"top": top,
}
for b_name, b_value in boundaries.items():
if b_value:
if (
getattr(pn, f"throat_radius_{b_name}") is not None
and getattr(pn, f"pore_position_{b_name}") is not None
):
pos_t = np.vstack(
[
pos_t,
np.hstack(
[
pn.pore_position[getattr(pn, f"pores_{b_name}"), :],
getattr(pn, f"pore_position_{b_name}"),
]
),
]
)
r_t = np.concatenate([r_t, getattr(pn, f"throat_radius_{b_name}")])
else:
raise Exception(
(
"Missing data: make sure that PoreNetwork.throat_radius_"
f"{b_name} and PoreNetwork.pore_position_{b_name} are not "
"empty."
)
)
sc.create_cylinders(pos_t, r_t)
sc._boundary_cylinder = boundaries
if (
pn.pore_position is not None
and pn.pore_radius is not None
and sc.config_scene.enable_spheres
):
sc.create_spheres(pn.pore_position, pn.pore_radius)
sc.hide_cylinders()
sc.hide_spheres()
return sc
[docs]
def make_clusters(pth: Path, sc: Scene, no: list[int] = [0]):
col = bpy.data.collections.get("Clusters")
bb_min = [0, 0, 0]
bb_max = [0, 0, 0]
for obj in col.objects:
obj.hide_render = True
obj.hide_viewport = True
for n in no:
obj = col.objects.get(f"label_{n}")
obj.hide_render = False
obj.hide_viewport = False
for dim in range(3):
bb_min[dim] = min([obj.dimensions[dim], bb_min[dim]])
bb_max[dim] = max([obj.dimensions[dim], bb_max[dim]])
dim = np.array(
(bb_max[0] - bb_min[0], bb_max[1] - bb_min[1], bb_max[2] - bb_min[2]),
)
# sc.config_axes.set_labels(dim[0], dim[1], dim[2])
# sc.scale = sc.size_bounding_box / max(dim)
# sc.shift = (sc.size_bounding_box - dim * sc.scale) / 2
# sc.aspect = dim / max(dim)
# sc.remove_axes()
# sc.create_axes()
for n in no:
obj = col.objects.get(f"label_{n}")
scale = sc.size_bounding_box / max(dim)
scale = (scale, scale, scale)
mw = obj.matrix_world
# Thanks to https://blender.stackexchange.com/questions/179028/scale-object-in-place-keeping-its-origin
bbox = [Vector(b) for b in obj.bound_box]
go = mw @ Vector(sum(bbox, Vector()) / 8)
T = Matrix.Translation(go)
S = Matrix.Diagonal(scale).to_4x4()
T2 = Matrix.Translation(-go)
M = T @ S @ T2
obj.matrix_world = M @ obj.matrix_world
obj.location += Vector((5, 5, 5))
# # scale = (2, 2, 1)
# # bbox = [Vector(b) for b in obj.bound_box]
# # o = sum(bbox, Vector()) / 8
# # T = Matrix.Translation(o)
# # S = Matrix.Diagonal(scale).to_4x4()
# # T2 = Matrix.Translation(-o)
# # M = T @ S @ T2
# # ob.data.transform(M)
# obj.data.update()
if len(no) == 1:
fname = f"label@{no[0]}"
else:
fname = "labels"
fname = sc.render(pth, fname)
img_trim(fname)
return sc, fname
[docs]
def make_img(
dir_img: Path,
sc: Scene,
show_spheres: bool = True,
show_cylinders: bool = True,
show_clusters: bool = True,
color_spheres: list[Color] = [],
color_cylinders: list[Color] = [],
color_clusters: list[Color] = [],
name_spheres: str = "",
name_cylinders: str = "",
name_clusters: str = "",
no_state: int | None = None,
solid: Path | None = None,
void: Path | None = None,
) -> Path:
"""
Populates the scene with the specified components, renders it, and resets the
scene afterwards.
The pore spheres, throat cylinders, and pore clusters are shown (and colored) only
when their respective ``show_*`` flag is set; a solid and/or void object are added
when their paths are given, and axes are shown when enabled in the scene
configuration. The output file name is assembled from the enabled components (e.g.
``sphere-radius+cylinder-radius+axes``), so each rendered combination gets a
distinct, descriptive name. After rendering, all layers are hidden and the solid and
void objects are removed, leaving the scene ready for the next render.
Parameters
----------
dir_img : Path
Directory to save the rendered image at.
sc : Scene
Scene to populate and render.
show_spheres : bool, optional
Whether to show the pore spheres, by default True.
show_cylinders : bool, optional
Whether to show the throat cylinders, by default True.
show_clusters : bool, optional
Whether to show the pore clusters, by default True.
color_spheres : list[Color], optional
Per-pore colors applied to the sphere layer (one :class:`Color` per pore); used
only when ``show_spheres`` is set, by default [].
color_cylinders : list[Color], optional
Per-throat colors applied to the cylinder layer (one :class:`Color` per throat);
used only when ``show_cylinders`` is set, by default [].
color_clusters : list[Color], optional
Per-cluster colors applied to the cluster layer; used only when ``show_clusters``
is set, by default [].
name_spheres : str, optional
Label describing the sphere coloring, embedded in the output file name with
underscores replaced by hyphens (e.g. ``"radius"``), by default "".
name_cylinders : str, optional
Label describing the cylinder coloring, embedded in the output file name with
underscores replaced by hyphens, by default "".
name_clusters : str, optional
Label describing the cluster coloring, embedded in the output file name with
underscores replaced by hyphens, by default "".
no_state : int | None, optional
Index of the network state being rendered; when given, appended to the file name
as ``state@<no_state>``, by default None.
solid : Path | None, optional
Path to a solid-structure object to add to the scene; when given, the solid is
created and ``solid`` is added to the file name, by default None.
void : Path | None, optional
Path to a void-space object to add to the scene; when given, the void is created
and ``void`` is added to the file name, by default None.
Returns
-------
Path
File path to the rendered image.
"""
fname_fragments = []
sep = "-"
if show_cylinders:
sc.show_cylinders()
sc.apply_colors("Cylinders", color_cylinders)
fname_fragments.append("cylinder" + sep + name_cylinders)
if show_spheres:
sc.show_spheres()
sc.apply_colors("Spheres", color_spheres)
fname_fragments.append("sphere" + sep + name_spheres)
if show_clusters:
sc.show_clusters()
sc.apply_colors("Clusters", color_clusters)
fname_fragments.append("cluster" + sep + name_clusters)
if solid is not None:
sc.create_solid(solid)
if void is not None:
sc.create_void(void)
if sc.has_solid:
fname_fragments.append("solid")
if sc.has_void:
fname_fragments.append("void")
if sc.config_scene.enable_axes:
sc.show_axes()
fname_fragments.append("axes")
if no_state is not None:
fname_fragments.append(f"state-{no_state}")
# render image in given config
fname = "+".join(fname_fragments) + ".png"
pth_render = sc.render(dir_img / fname)
# reset scene
sc.hide_cylinders()
sc.hide_spheres()
sc.hide_clusters()
sc.remove_solid()
sc.remove_void()
return pth_render
[docs]
def make_radius(
dir_save: Path,
pn: PoreNetwork,
sc: Scene,
) -> Path:
"""
Renders the pore network with pores and throats colored by their radius and
composites a matching colorbar onto the image.
A smooth color gradient is fitted to the radius range (across pore and throat radii),
the sphere and cylinder layers are colored accordingly via
:func:`make_img`, the rendered image is trimmed, and a colorbar for the gradient is
added. Spheres and cylinders are only colored and shown when enabled in the scene
configuration *and* the corresponding radius data is present.
Parameters
----------
dir_save : Path
Directory to save the rendered image and colorbar at.
pn : PoreNetwork
The pore network providing the pore and throat radii.
sc : Scene
The scene holding the already-built geometry and the ``radius`` property
configuration.
Returns
-------
Path
The file path to the rendered image with colorbar.
Raises
------
Exception
If any boundary throats are included in the scene and matching ``throat_radius_*``
data is missing on the :class:`PoreNetwork` instance.
"""
# check scene components
do_spheres = sc.config_scene.enable_spheres and sc.has_spheres
do_cylinders = sc.config_scene.enable_cylinders and sc.has_cylinders
# create property instance
conf = sc.config_scene["radius"]
prop = PoreNetworkProperty("radius")
# collect throat radiii
if pn.throat_radius is not None:
r_t = pn.throat_radius
for b_name, b_value in sc._boundary_cylinder.items():
if b_value:
if getattr(pn, f"throat_radius_{b_name}") is not None:
r_t = np.concatenate([r_t, getattr(pn, f"throat_radius_{b_name}")])
else:
raise Exception(
(
"Missing data: make sure that PoreNetwork.throat_radius_"
f"{b_name} and PoreNetwork.pore_position_{b_name} are not "
"empty."
)
)
prop.set_data(pn.pore_radius, r_t)
# setup colorbar
mn, mx = _get_bounds(prop.min, prop.max, conf.precision, conf.factor)
grad = SmoothGradient(conf.colors, mn / conf.factor, mx / conf.factor, fit=True)
# render given configuration
pth_vis = make_img(
dir_save,
sc,
do_spheres,
do_cylinders,
False,
grad(prop.pore_values),
grad(prop.throat_values),
[],
"radius",
"radius",
)
# render the colorbar image
pth_cb = dir_save / "colorbar-radius.svg"
make_gradient_overlay(
pth_cb,
sc.config_scene["radius"],
mn,
mx,
)
# compose rendered scene and colorbar
img_add_colorbar(pth_vis, pth_cb.with_suffix(".png"))
return pth_vis
[docs]
def make_coordination_number(dir_img: Path, pn: PoreNetwork, sc: Scene) -> Path:
"""
Renders the pore network with pores, throats, and clusters colored by their
coordination number and composites a matching colorbar onto the image.
A color gradient (the gradient class configured for the ``coordination_number``
property) is fitted to the coordination-number range, the sphere, cylinder, and
cluster layers are colored accordingly via :func:`make_img`, and a colorbar for the
gradient is added. Each layer is only colored and shown when it is enabled in the
scene configuration and the corresponding data is available.
Parameters
----------
dir_img : Path
Directory to save the rendered image and colorbar at.
pn : PoreNetwork
The pore network providing the pore and throat coordination numbers.
sc : Scene
The scene holding the already-built geometry and the ``coordination_number``
property configuration.
Returns
-------
Path
The file path to the rendered image with colorbar.
"""
# check scene components
do_spheres = sc.config_scene.enable_spheres and sc.has_spheres
do_cylinders = sc.config_scene.enable_cylinders and sc.has_cylinders
do_clusters = sc.config_scene.enable_clusters and sc.has_clusters
# create property instance
conf = sc.config_scene["coordination_number"]
prop = PoreNetworkProperty("coordination_number")
prop.set_data(pn.pore_coordination_number, pn.throat_coordination_number)
mn, mx = _get_bounds(prop.min, prop.max, conf.precision, conf.factor)
grad = conf.gradient_class(conf.colors, mn / conf.factor, mx / conf.factor)
pth_vis = make_img(
dir_img,
sc,
do_spheres,
do_cylinders,
do_clusters,
grad(prop.pore_values) if do_spheres else [],
grad(prop.throat_values) if do_cylinders else [],
grad(prop.pore_values) if do_clusters else [],
"coordination-number",
"coordination-number",
"coordination-number",
)
# render the colorbar image
pth_cb = pth_vis.with_name("colorbar_coordination_number.svg")
make_gradient_overlay(
pth_cb,
sc.config_scene["coordination_number"],
mn,
mx,
)
# compose rendered scene and colorbar
img_add_colorbar(pth_vis, pth_cb.with_suffix(".png"))
return pth_vis
[docs]
def make_random(dir_img: Path, pn: PoreNetwork, sc: Scene) -> Path:
"""
Renders the pore network with pores, throats, and clusters assigned random colors.
Each layer is drawn with colors picked at random from the scene's palette, so that
individual pores, throats, and clusters can be told apart visually. A layer is only
colored and shown when it is enabled in the scene configuration *and* has actually
been built in the scene. Unlike the property-based renders, no colorbar is added,
since the random colors carry no scale.
Parameters
----------
dir_img : Path
Directory to save the rendered image at.
pn : PoreNetwork
The pore network providing the pore, throat, and cluster counts used to size the
random color sets.
sc : Scene
The scene holding the already-built geometry and the color palette.
Returns
-------
Path
The file path to the rendered image.
"""
# check scene components
do_spheres = sc.config_scene.enable_spheres and sc.has_spheres
do_cylinders = sc.config_scene.enable_cylinders and sc.has_cylinders
do_clusters = sc.config_scene.enable_clusters and sc.has_clusters
# render scene configuration
pth_img = make_img(
dir_img,
sc,
do_spheres,
do_cylinders,
do_clusters,
sc.config_scene.palette.random(pn.pore_count) if do_spheres else [],
(
sc.config_scene.palette.random(pn.throat_count(**sc._boundary_cylinder))
if do_cylinders
else []
),
sc.config_scene.palette.random(pn.pore_count) if do_clusters else [],
"random",
"random",
"random",
)
return pth_img
[docs]
def make_structure(
dir_img: Path,
pn: PoreNetwork,
sc: Scene,
) -> Path:
"""
Renders the pore network with all pores, throats, and clusters in a uniform gray.
This is the plain structure view: every layer is drawn in a single neutral gray,
without any property-based coloring or colorbar, giving a clean overview of the
network geometry. A layer is only shown when it is enabled in the scene
configuration *and* has actually been built in the scene.
Parameters
----------
dir_img : Path
Directory to save the rendered image at.
pn : PoreNetwork
The pore network providing the pore and throat counts.
sc : Scene
The scene holding the already-built geometry.
Returns
-------
Path
The file path to the rendered image.
"""
do_spheres = sc.config_scene.enable_spheres and sc.has_spheres
do_cylinders = sc.config_scene.enable_cylinders and sc.has_cylinders
do_clusters = sc.config_scene.enable_clusters and sc.has_clusters
color_grey = Color("#7A828C")
N_p = pn.pore_count
N_t = pn.throat_count(**sc._boundary_cylinder)
pth_img = make_img(
dir_img,
sc,
do_spheres,
do_cylinders,
do_clusters,
[color_grey for _ in range(N_p)] if do_spheres else [],
[color_grey for _ in range(N_t)] if do_cylinders else [],
[color_grey for _ in range(N_p)] if do_clusters else [],
"structure",
"structure",
"structure",
)
return pth_img
[docs]
def make_state(pth: Path, pn: PoreNetwork, sc: Scene):
do_spheres = sc.config_scene.enable_spheres and pn.pore_radius is not None
do_cylinders = sc.config_scene.enable_cylinders and pn.throat_radius is not None
do_clusters = sc.config_scene.enable_clusters
grad_dict = {}
for conf in sc.config_scene:
if conf.use_global_boundaries:
mn, mx = _get_bounds(conf.min, conf.max, conf.precision, conf.factor)
grad_dict[conf.name] = conf.gradient_class(
conf.colors, mn / conf.factor, mx / conf.factor
)
for state in pn.states:
for prop in state.properties:
conf = sc.config_scene[prop.name]
if not conf.use_global_boundaries:
if conf.min is None:
mn, _ = _get_bounds(
prop.min,
prop.max,
conf.precision,
conf.factor,
conf.func_transform,
)
else:
mn = conf.min
if conf.max is None:
_, mx = _get_bounds(
prop.min,
prop.max,
conf.precision,
conf.factor,
conf.func_transform,
)
else:
mx = conf.max
grad = conf.gradient_class(
conf.colors, mn / conf.factor, mx / conf.factor
)
else:
grad = grad_dict[conf.name]
mn, mx = _get_bounds(conf.min, conf.max, conf.precision, conf.factor)
pth_vis = make_img(
pth,
sc,
do_spheres,
do_cylinders,
do_clusters,
grad(conf.func_transform(prop.pore_values)) if do_spheres else [],
grad(conf.func_transform(prop.throat_values)) if do_cylinders else [],
grad(conf.func_transform(prop.pore_values)) if do_clusters else [],
prop.name,
prop.name,
prop.name,
no_state=state.no,
)
pth_cb = pth_vis.with_name("colorbar_" + prop.name + ".svg")
make_gradient_overlay(
pth_cb,
sc.config_scene[prop.name],
mn,
mx,
)
img_add_colorbar(pth_vis, pth_cb.with_suffix(".png"))
return sc, pth_vis
[docs]
def make_gradient_overlay(
pth: Path,
config: PropertyConfiguration,
mn: float,
mx: float,
/,
ticks: list[str] = [],
**kwargs,
) -> Overlay:
if config.gradient_class is SmoothGradient:
ovl = SmoothGradientOverlay(pth)
elif config.gradient_class is SegmentedGradient:
ovl = SegmentedGradientOverlay(pth)
elif config.gradient_class is DiscreteGradient:
ovl = DiscreteGradientOverlay(pth)
else:
raise ValueError("Unknown gradient class")
if len(ticks) == 0:
if config.gradient_class is SegmentedGradient:
n_ticks = len(config.colors) + 1
else:
n_ticks = 5
ticks_num: list[float] = list(
np.round(np.linspace(mn, mx, n_ticks), config.precision + 2)
)
if config.precision <= 0:
ticks = [
f"{v:.0f}" if v.is_integer() else f"{v:.2f}".rstrip("0")
for v in ticks_num
]
else:
ticks = [
(
f"{v:.0f}"
if v.is_integer()
else f"{v:.{config.precision + 2}f}".rstrip("0")
)
for v in ticks_num
]
for arg in kwargs.items():
if hasattr(ovl, arg[0]):
setattr(ovl, arg[0], arg[1])
ovl.gradient_colors = config.colors
ovl.ticks = ticks
ovl.heading = config.heading
ovl.subheading = config.subheading
ovl.text = config.text
ovl.align = config.align
ovl.orientation = config.orientation
ovl.color_nan = None # config.color_nan
ovl.save()
svg2png(pth)
return ovl