Source code for porescene.worker

# 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  # type: ignore  # isort:skip

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 (
    Annotation,
    DiscreteGradientAnnotation,
    SegmentedGradientAnnotation,
    SmoothGradientAnnotation,
)
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] = None): if no is None: no = [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) 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: tuple[Color, ...] = (), color_cylinders: tuple[Color, ...] = (), color_clusters: tuple[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 and sc.has_cylinders: sc.show_cylinders() sc.apply_colors("Cylinders", color_cylinders) fname_fragments.append("cylinder" + sep + name_cylinders) if show_spheres and sc.has_spheres: sc.show_spheres() sc.apply_colors("Spheres", color_spheres) fname_fragments.append("sphere" + sep + name_spheres) if show_clusters and sc.has_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"), sc.config_scene["radius"].align, sc.config_scene["radius"].orientation, ) 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, conf, mn, mx, ) # compose rendered scene and colorbar img_add_colorbar( pth_vis, pth_cb.with_suffix(".png"), conf.align, conf.orientation, ) 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 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 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"), conf.align, conf.orientation ) return sc, pth_vis
[docs] def make_gradient_overlay( pth: Path, config: PropertyConfiguration, mn: float, mx: float, /, ticks: tuple[str, ...] = (), **kwargs, ) -> Annotation: if config.gradient_class is SmoothGradient: ovl = SmoothGradientAnnotation(pth) elif config.gradient_class is SegmentedGradient: ovl = SegmentedGradientAnnotation(pth) elif config.gradient_class is DiscreteGradient: ovl = DiscreteGradientAnnotation(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