# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Felix Faber /
# Otto von Guericke University Magdeburg, Thermal Process Engineering
import json
import math
import random
from pathlib import Path
from typing import NamedTuple, Self, cast
import bpy
import numpy as np
from mathutils import Matrix, Vector
from rich import progress
from rich.console import Console
from porescene import image
from porescene.color import Color
from porescene.config import AxesConfiguration, ImageConfiguration, SceneConfiguration
from porescene.utility import suppress_stdout
import bmesh # isort:skip
def _radians(x: float, y: float, z: float) -> tuple[float, float, float]:
"""Converts an Euler rotation given in degrees to radians."""
return (math.radians(x), math.radians(y), math.radians(z))
class _AxisSpec(NamedTuple):
"""Per-axis parameters for drawing major ticks, minor ticks and labels."""
prefix: str
ticks: object
positions: object
precision: int
aspect: float
enable_minor: bool
enable_labels: bool
base: list[float]
dim: int
tick_rotation: tuple[float, float, float]
label_rotation: tuple[float, float, float]
label_dim: int
align_first: str
align_last: str
first_gated: bool
minor_special: bool
hide_first_label: bool = False
[docs]
class Scene:
def __init__(self, extent: np.ndarray) -> None:
"""
Creates an empty :class:`Scene`, wiping Blender's default scene contents and
setting up a camera, lights and Cycles render settings. Use :meth:`from_json`
to create a scene sized and configured from a PoreScene JSON file instead.
"""
self.config_axes = AxesConfiguration(extent)
self.config_image = ImageConfiguration()
self.config_scene = SceneConfiguration()
self.size_bounding_box = 10
self.n_segments = 48
self.size = (10, 10, 10)
self.shift = (0, 0, 0)
self.scale = 1e5
self._ang_azimuth = 0.0
self._boundary_cylinder = {
"left": False,
"right": False,
"front": False,
"back": False,
"bottom": False,
"top": False,
}
self.has_axes = False
self.has_clusters = False
self.has_cylinders = False
self.has_lights = False
self.has_solid = False
self.has_spheres = False
self.has_void = False
# remove default objects from scene
self.remove_defaults()
# add camera and lights
self.create_camera("3D")
self.create_lights()
# create collection for scene layers and add it to the scene
self._layers = bpy.data.collections.new("Layers")
bpy.context.scene.collection.children.link(self._layers)
# cycles configuration
bpy.context.scene.render.engine = "CYCLES"
prefs = bpy.context.preferences.addons["cycles"].preferences
prefs.compute_device_type = "OPTIX" # falls back to CUDA if no RTX-capable device
with suppress_stdout():
prefs.get_devices()
for device in prefs.devices:
device.use = device.type in {"OPTIX", "CUDA"}
bpy.context.scene.cycles.device = "GPU"
bpy.context.scene.render.resolution_percentage = 100
bpy.context.scene.render.film_transparent = True
bpy.context.scene.render.use_persistent_data = True
bpy.context.scene.render.image_settings.color_mode = "RGBA"
bpy.context.scene.render.image_settings.color_depth = "16"
bpy.context.scene.cycles.samples = 1024
bpy.context.scene.cycles.use_adaptive_sampling = True
bpy.context.scene.cycles.adaptive_threshold = 0.01
bpy.context.scene.cycles.adaptive_min_samples = 32
bpy.context.scene.cycles.use_denoising = True
bpy.context.scene.cycles.denoiser = "OPENIMAGEDENOISE"
bpy.context.scene.cycles.transmission_bounces = 12
bpy.context.scene.cycles.transparent_max_bounces = 64
self.scale = self.size_bounding_box / max(extent)
self.shift = (self.size_bounding_box - extent * self.scale) / 2
self.aspect = extent / max(extent)
[docs]
@classmethod
def from_json(cls, extent: np.ndarray, pth_config: Path) -> Self:
"""
Creates a :class:`Scene` instance sized to the given physical domain
dimensions, configured from a PoreScene JSON configuration file.
Parameters
----------
extent : np.ndarray
Physical extent of the domain along x, y and z, used to derive the
scene's :attr:`scale`, :attr:`shift` and :attr:`aspect`.
pth_config : Path
Path to the PoreScene JSON configuration file. Its optional ``"axes"``
and ``"image"`` sections populate :attr:`config_axes` and
:attr:`config_image`, respectively.
Returns
-------
Self
:class:`Scene` instance created from the given dimensions and
configuration file.
"""
ins = cls(extent)
with pth_config.open(mode="r", encoding="UTF-8") as json_data:
config = json.load(json_data)
if "axes" in config:
ins.config_axes = AxesConfiguration.from_dict(extent, config["axes"])
if "image" in config:
ins.config_image = ImageConfiguration.from_dict(config["image"])
return ins
[docs]
def apply_colors(
self,
name_mesh: str,
colors: list[Color],
frame: int = 0,
) -> Self:
"""
Sets the colors for all items in a collection for given frame.
"""
color_array = np.array([c.lnrgba for c in colors], dtype=np.float32)
# Single meshes
mesh = bpy.data.meshes.get(name_mesh)
if mesh is not None:
color_attr = mesh.attributes.get("color")
color_attr.data.foreach_set("color", color_array.ravel())
return self
# Collection of individual objects
col = bpy.data.collections.get(name_mesh)
if col is not None:
def label_no(name: str) -> int:
parts = name.split("_")
return int(parts[1]) if parts[0] == "label" and parts[1].isdigit() else 0
for idx, obj in enumerate(
sorted(col.objects, key=lambda o: label_no(o.name))
):
if idx >= len(color_array):
break
obj_mesh = cast(bpy.types.Mesh, obj.data)
color_attr = obj_mesh.attributes.get("cluster_color")
if color_attr is None:
continue
color_attr.data.foreach_set(
"color", np.tile(color_array[idx], len(obj_mesh.vertices))
)
return self
raise Exception(f"There is no mesh or collection named '{name_mesh}'")
[docs]
def create_axes(self) -> Self:
"""
Adds axes with ticks and labels to the scene.
"""
cfg = self.config_axes
lw = cfg.line_width
d = cfg.distance
sbb = self.size_bounding_box
sx, sy, sz = self.shift
ax, ay, az = self.aspect
mat_axis = self.get_material("AXES", "axes")
font = bpy.data.fonts.load(str(cfg.font_family), check_existing=True)
col = bpy.data.collections.new("Axes")
# axis lines. The outermost ticks are centered on the ends of an axis and so
# stick out by half a line width; the axes are stretched by that same amount
# at either end to meet them flush.
mesh_axis = self._add_line("axis", lw, sbb)
axis_lines = [
("axis_x", (sx - 0.5 * lw, sbb - sy + d + lw, sz), _radians(0, 0, -90), ax),
("axis_y", (sbb - sx + d, sy - 0.5 * lw, sz), _radians(0, 0, 0), ay),
("axis_z", (sbb - sx + d, sy, sz - 0.5 * lw), _radians(90, 0, 0), az),
]
for name, location, rotation, aspect in axis_lines:
axis = bpy.data.objects.new(name, mesh_axis)
col.objects.link(axis)
axis.location = location
axis.rotation_euler = rotation
axis.scale = (1, (sbb * aspect + lw) / sbb, 1)
# axis labels
axis_labels = [
(
cfg.label_x,
[sbb / 2, sbb - sy + 2 * d + lw, sz],
1,
self._clearance_ticks(0),
_radians(0, 0, 180),
"scale x",
"axis label x",
),
(
cfg.label_y,
[sbb - sx + 2 * d + lw, sbb / 2, sz],
0,
self._clearance_ticks(1),
_radians(0, 0, 90),
"scale y",
"axis label y",
),
(
cfg.label_z,
[sbb - sx + 2 * d + lw, sy, sbb / 2],
0,
self._clearance_ticks(2),
_radians(0, 90, 90),
"scale z",
"axis label z",
),
]
for body, loc, bump, clearance, rotation, curve, name in axis_labels:
if not body:
continue
loc[bump] += clearance
self._add_text(
col,
font,
name,
body,
cfg.font_size_labels,
"CENTER",
tuple(loc),
rotation,
curve_name=curve,
)
# ticks with labels
if any(cfg.enable_ticks):
mesh_tick = self._add_line("tick", lw, cfg.tick_length)
specs = [
_AxisSpec(
prefix="x",
ticks=cfg.ticks_x,
positions=getattr(cfg, "position_tick_x", None),
precision=cfg.precision[0],
aspect=ax,
enable_minor=cfg.enable_ticks_minor[0],
enable_labels=cfg.enable_labels_ticks[0],
base=[sx - 0.5 * lw, sbb - sy + d + lw, sz],
dim=0,
tick_rotation=_radians(0, 0, 0),
label_rotation=_radians(0, 0, 180),
label_dim=1,
align_first="RIGHT",
align_last="LEFT",
first_gated=True,
minor_special=False,
),
_AxisSpec(
prefix="y",
ticks=cfg.ticks_y,
positions=getattr(cfg, "position_tick_y", None),
precision=cfg.precision[1],
aspect=ay,
enable_minor=cfg.enable_ticks_minor[1],
enable_labels=cfg.enable_labels_ticks[1],
base=[sbb - sx + d + lw, sy + 0.5 * lw, sz],
dim=1,
tick_rotation=_radians(0, 0, -90),
label_rotation=_radians(0, 0, 90),
label_dim=0,
align_first="LEFT",
align_last="RIGHT",
first_gated=False,
minor_special=True,
),
_AxisSpec(
prefix="z",
ticks=cfg.ticks_z,
positions=getattr(cfg, "position_tick_z", None),
precision=cfg.precision[2],
aspect=az,
enable_minor=cfg.enable_ticks_minor[2],
enable_labels=cfg.enable_labels_ticks[2],
base=[sbb - sx + d + lw, sy, sz + 0.5 * lw],
dim=2,
tick_rotation=_radians(90, 90, 0),
label_rotation=_radians(0, 90, 90),
label_dim=0,
align_first="RIGHT",
align_last="LEFT",
first_gated=False,
minor_special=False,
hide_first_label=True,
),
]
for enabled, spec in zip(cfg.enable_ticks, specs):
if enabled:
self._draw_axis_ticks_major(col, font, mesh_tick, spec)
# apply axis material
for obj in col.objects:
obj.data.materials.clear()
obj.data.materials.append(mat_axis)
obj.visible_shadow = False
# add collection into context
bpy.context.scene.collection.children.link(col)
self.has_axes = True
return self
def _clearance_ticks(self, dim: int) -> float:
"""
Returns the distance the label of the axis ``dim`` (0 = x, 1 = y, 2 = z) is
pushed outwards, so that it clears the ticks drawn on that axis. Ticks without
labels only need room for the tick marks themselves.
"""
cfg = self.config_axes
ticks = (cfg.ticks_x, cfg.ticks_y, cfg.ticks_z)[dim]
if not cfg.enable_ticks[dim] or len(ticks) == 0:
return 0
if not cfg.enable_labels_ticks[dim]:
return cfg.tick_length
return cfg.tick_length + cfg.font_size_ticks
def _add_line(self, name: str, width: float, height: float) -> bpy.types.Mesh:
"""Creates a single rectangular face in the xy-plane."""
mesh = bpy.data.meshes.new(name)
mesh.from_pydata(
[(0, 0, 0), (width, 0, 0), (width, height, 0), (0, height, 0)],
[],
[(0, 1, 2, 3)],
)
mesh.update()
return mesh
def _add_text(
self,
col: bpy.types.Collection,
font: bpy.types.VectorFont,
name: str,
body: str,
size: float,
align_x: str,
location: tuple[float, float, float],
rotation: tuple[float, float, float],
curve_name: str | None = None,
) -> bpy.types.Object:
"""Creates a text object from a font curve and links it to ``col``."""
curve = bpy.data.curves.new(type="FONT", name=curve_name or name)
curve.body = body
curve.size = size
curve.align_x = align_x
curve.align_y = "TOP"
obj = bpy.data.objects.new(name=name, object_data=curve)
col.objects.link(obj)
obj.data.font = font
obj.location = location
obj.rotation_euler = rotation
return obj
def _draw_axis_ticks_major(
self,
col: bpy.types.Collection,
font: bpy.types.VectorFont,
mesh_tick: bpy.types.Mesh,
spec: _AxisSpec,
) -> None:
"""Draws major ticks, minor ticks and tick labels for a single axis."""
cfg = self.config_axes
lw = cfg.line_width
sbb = self.size_bounding_box
n = len(spec.ticks)
for idx, value in enumerate(spec.ticks):
loc = list(spec.base)
loc[spec.dim] += sbb * spec.aspect * spec.positions[idx]
align = "CENTER"
if idx == 0 and (cfg.indent_ticks or not spec.first_gated):
loc[spec.dim] += 0.5 * lw
align = spec.align_first
if idx == n - 1 and cfg.indent_ticks:
loc[spec.dim] -= 0.5 * lw
align = spec.align_last
tick = bpy.data.objects.new(f"{spec.prefix} tick {idx}", mesh_tick)
col.objects.link(tick)
tick.location = tuple(loc)
tick.rotation_euler = spec.tick_rotation
if spec.enable_minor:
self._draw_axis_ticks_minor(col, mesh_tick, spec, idx)
if not spec.enable_labels:
continue
if idx == 0 and spec.hide_first_label:
continue
label = str(round(value, spec.precision)).rstrip("0").rstrip(".")
label_loc = list(loc)
label_loc[spec.label_dim] += cfg.distance + cfg.tick_length
self._add_text(
col,
font,
f"{spec.prefix} tick label {idx}",
label,
cfg.font_size_ticks,
align,
tuple(label_loc),
spec.label_rotation,
)
def _draw_axis_ticks_minor(
self,
col: bpy.types.Collection,
mesh_tick: bpy.types.Mesh,
spec: _AxisSpec,
idx: int,
) -> None:
"""Draws the minor ticks that follow the major tick at ``idx``."""
cfg = self.config_axes
sbb = self.size_bounding_box
pos = spec.positions
if spec.minor_special and cfg.num_ticks_minor == 1:
spacing = pos[1] - pos[0] / 2
else:
spacing = (pos[1] - pos[0]) / (cfg.num_ticks_minor + 1)
if idx + 1 == len(spec.ticks):
n_minor = math.floor((1 - pos[-1]) / spacing)
else:
n_minor = cfg.num_ticks_minor
for j in range(n_minor):
loc = list(spec.base)
loc[spec.dim] += sbb * spec.aspect * (pos[idx] + spacing * (j + 1))
tick = bpy.data.objects.new(f"{spec.prefix} tick minor {idx}", mesh_tick)
col.objects.link(tick)
tick.location = tuple(loc)
tick.rotation_euler = spec.tick_rotation
tick.scale = (1, 0.5, 1)
[docs]
def create_camera(self, view: str = "3D") -> Self:
"""
Brings camera in position.
"""
if view == "3D":
bpy.data.objects["Camera"].location = (25, 40, 20)
bpy.data.objects["Camera"].rotation_euler = (
math.radians(90),
0,
math.radians(150),
)
elif view == "2D":
bpy.data.objects["Camera"].location = (5, 40, 15)
bpy.data.objects["Camera"].rotation_euler = (
math.radians(90),
0,
math.radians(180),
)
bpy.data.cameras["Camera"].shift_y = -0.55
bpy.data.cameras["Camera"].lens = 50
# track the camera's true azimuth around the bounding box's pivot, so
# that rotate_azimuth's corner-snapping starts from where the camera
# actually is instead of assuming it starts centered on a corner
center = self.size_bounding_box / 2
x, y, _ = bpy.data.objects["Camera"].location
self._ang_azimuth = math.degrees(math.atan2(y - center, x - center))
return self
[docs]
def create_cells(
self,
pos: np.ndarray,
vtx_cv: list[np.ndarray],
p_selection: np.ndarray | None = None,
merge_dist: float = 0,
style="CELL_DEFAULT",
) -> Self:
col = bpy.data.collections.get("Cells")
mat = self.get_material(style, "default")
if p_selection is None:
p_selection = np.array(range(len(vtx_cv)))
if not col:
col = bpy.data.collections.new("Cells")
bpy.context.scene.collection.children.link(col)
for p in progress.track(p_selection, description="Creating cells"):
# select current cell vertex list
verts = pos[vtx_cv[p], :] * self.scale + self.shift
# create point cloud mesh and object of cell vertices
mesh = bpy.data.meshes.new(f"cell {p}")
mesh.from_pydata(verts, [], [])
obj = bpy.data.objects.new(f"cell {p}", mesh)
# compute convex hull from point cloud
bm = bmesh.new()
bm.from_mesh(mesh)
bmesh.ops.convex_hull(bm, input=bm.verts)
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=merge_dist)
bm.to_mesh(mesh)
bm.free()
#
# vtx_boundary = []
# for ind, vtx in enumerate(obj.data.vertices):
# if abs(vtx.co.x - (10 - self.shift[0])) < 1e-2:
# vtx_boundary.append(ind)
# if abs(vtx.co.y - (10 - self.shift[1])) < 1e-2:
# vtx_boundary.append(ind)
# if abs(vtx.co.z - (10 - self.shift[2])) < 1e-2:
# vtx_boundary.append(ind)
# grp = obj.vertex_groups.new(name="Boundary")
# grp.add(vtx_boundary, 1.0, 'REPLACE')
# mod = obj.modifiers.new(name=f"Decimate {p}", type='DECIMATE')
# mod.decimate_type = 'UNSUBDIV'
# mod.use_dissolve_boundaries = True
# mod = obj.modifiers.new(name=f"Bevel {p}", type='BEVEL')
# mod.offset_type = 'WIDTH'
# mod.width = 0.006
# mod.segments = 16
# mod.use_clamp_overlap = False
# mod.vmesh_method = 'CUTOFF'
# mod.limit_method = 'ANGLE'
# mod.angle_limit = math.radians(50)
# mod.vertex_group = "Boundary"
# append object to collection
col.objects.link(obj)
# apply material
obj.data.materials.append(mat.copy())
obj.data.materials[0].name = obj.name
return self
[docs]
def create_clusters(self, pth: Path, style: str = "CLUSTER_DEFAULT") -> Self:
"""
Adds pore clusters to the scene.
"""
def handler(n: str):
parts = n.split("_")
if parts[0] == "label" and parts[1].isdigit():
return int(parts[1])
else:
return 0
with _get_spinner(f"[cyan]Loading object: {pth.name}") as p:
p.add_task("load", total=None)
_import_object(pth)
mat = self.get_material(style, "clusters")
if not any(n.type == "ATTRIBUTE" for n in mat.node_tree.nodes):
self._apply_object_color(mat)
col = bpy.data.collections.new("Clusters")
col_default = bpy.data.collections.get("Collection")
for obj in progress.track(
sorted(bpy.data.objects, key=lambda c: handler(c.name)),
description="Creating cluster geometries",
):
if "label_" in obj.name and obj.name != "label_0":
obj.rotation_euler = (0, 0, 0)
obj.scale = (self.scale, self.scale, self.scale)
obj.location = self.shift
col_default.objects.unlink(obj)
col.objects.link(obj)
default_color = np.array(Color("#58646E").lnrgba, dtype=np.float32)
for obj in progress.track(col.objects, description="Creating cluster materials"):
mesh = cast(bpy.types.Mesh, obj.data)
color_attr = mesh.attributes.new(
name="cluster_color", type="FLOAT_COLOR", domain="POINT"
)
color_attr.data.foreach_set(
"color", np.tile(default_color, len(mesh.vertices))
)
mesh.materials.append(mat)
bpy.context.scene.collection.children.link(col)
self.has_clusters = True
return self
[docs]
def create_cylinders(
self,
pos: np.ndarray,
r: np.ndarray,
name: str = "Cylinders",
style: str = "STRUCTURE_DEFAULT",
) -> Self:
"""
Places a cylinder for each throat in the scene.
"""
pos1 = pos[:, 0:3] * self.scale
pos2 = pos[:, 3:6] * self.scale
d = pos2 - pos1
r = r * self.scale
dist = np.linalg.norm(d, axis=1)
theta = np.arccos(d[:, 2] / dist)
phi = np.arctan2(d[:, 1], d[:, 0])
loc = d / 2 + pos1 + self.shift
mesh = bpy.data.meshes.new(name)
mesh.from_pydata(loc.tolist(), [], [])
mesh.update()
rotation = np.zeros((len(r), 3), dtype=np.float32)
rotation[:, 1] = theta
rotation[:, 2] = phi
rot_attr = mesh.attributes.new(
name="rotation", type="FLOAT_VECTOR", domain="POINT"
)
rot_attr.data.foreach_set("vector", rotation.ravel())
iscale = np.column_stack([r, r, dist]).astype(np.float32)
scale_attr = mesh.attributes.new(
name="iscale", type="FLOAT_VECTOR", domain="POINT"
)
scale_attr.data.foreach_set("vector", iscale.ravel())
mesh.attributes.new(name="color", type="FLOAT_COLOR", domain="POINT")
obj = bpy.data.objects.new(name, mesh)
self._layers.objects.link(obj)
obj_template = self._get_cylinder_template(style)
mod = obj.modifiers.new("CylinderInstances", type="NODES")
mod.node_group = self._get_node_group(obj_template)
self.has_cylinders = True
return self
def _get_cylinder_template(self, style: str) -> bpy.types.Object:
"""
Returns a unit cylinder (radius=1, depth=1).
"""
name = f"Cylinder_{style}"
obj_tmpl = bpy.data.objects.get(name)
if obj_tmpl is not None:
return obj_tmpl
bpy.ops.mesh.primitive_cylinder_add(vertices=self.n_segments, radius=1, depth=1)
obj_tmpl = bpy.context.view_layer.objects.active
obj_tmpl.name = name
obj_tmpl.location = (0, 0, 0)
obj_tmpl.rotation_euler = (0, 0, 0)
obj_tmpl.scale = (1, 1, 1)
obj_tmpl.data.polygons.foreach_set(
"use_smooth", [True] * len(obj_tmpl.data.polygons)
)
mat = self.get_material(style, "default").copy()
obj_tmpl.data.materials.append(mat)
self._apply_instance_color(mat)
templates_col = bpy.data.collections.get("Templates")
if not templates_col:
templates_col = bpy.data.collections.new("Templates")
bpy.context.scene.collection.children.link(templates_col)
templates_col.hide_render = True
templates_col.hide_viewport = True
for c in list(obj_tmpl.users_collection):
c.objects.unlink(obj_tmpl)
templates_col.objects.link(obj_tmpl)
return obj_tmpl
def _apply_instance_color(self, mat: bpy.types.Material) -> None:
"""
Connects material base color to instance attribute.
"""
tree = mat.node_tree
principled = next(n for n in tree.nodes if n.type == "BSDF_PRINCIPLED")
attr_node = tree.nodes.new("ShaderNodeAttribute")
attr_node.attribute_type = "INSTANCER"
attr_node.attribute_name = "color"
tree.links.new(attr_node.outputs["Color"], principled.inputs["Base Color"])
def _apply_object_color(self, mat: bpy.types.Material) -> None:
"""
Connects material base color to the per-object "color" mesh attribute.
Object counterpart of :meth:`_apply_instance_color`: used for real
objects (e.g. clusters) that carry their own "color" attribute rather
than being instanced through geometry nodes.
"""
tree = mat.node_tree
principled = next(n for n in tree.nodes if n.type == "BSDF_PRINCIPLED")
attr_node = tree.nodes.new("ShaderNodeAttribute")
attr_node.attribute_type = "GEOMETRY"
attr_node.attribute_name = "cluster_color"
tree.links.new(attr_node.outputs["Color"], principled.inputs["Base Color"])
def _get_node_group(self, template: bpy.types.Object) -> bpy.types.NodeTree:
"""
Builds geometry nodes tree that instances :arg:``template`` onto the points of
the modified object using the per-point "rotation", "iscale", and "color"
attributes.
"""
name = f"Instancer_{template.name}"
group = bpy.data.node_groups.get(name)
if group is not None:
return group
group = bpy.data.node_groups.new(name, "GeometryNodeTree")
group.interface.new_socket(
"Geometry", in_out="INPUT", socket_type="NodeSocketGeometry"
)
group.interface.new_socket(
"Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry"
)
n_in = group.nodes.new("NodeGroupInput")
n_out = group.nodes.new("NodeGroupOutput")
n_color_attr = group.nodes.new("GeometryNodeInputNamedAttribute")
n_color_attr.data_type = "FLOAT_COLOR"
n_color_attr.inputs["Name"].default_value = "color"
n_capture = group.nodes.new("GeometryNodeCaptureAttribute")
n_capture.domain = "POINT"
n_capture.capture_items.new(socket_type="RGBA", name="color")
n_to_points = group.nodes.new("GeometryNodeMeshToPoints")
n_rot = group.nodes.new("GeometryNodeInputNamedAttribute")
n_rot.data_type = "FLOAT_VECTOR"
n_rot.inputs["Name"].default_value = "rotation"
n_scale = group.nodes.new("GeometryNodeInputNamedAttribute")
n_scale.data_type = "FLOAT_VECTOR"
n_scale.inputs["Name"].default_value = "iscale"
n_obj_info = group.nodes.new("GeometryNodeObjectInfo")
n_obj_info.inputs["Object"].default_value = template
n_obj_info.inputs["As Instance"].default_value = True
n_instance = group.nodes.new("GeometryNodeInstanceOnPoints")
n_store_color = group.nodes.new("GeometryNodeStoreNamedAttribute")
n_store_color.domain = "INSTANCE"
n_store_color.data_type = "FLOAT_COLOR"
n_store_color.inputs["Name"].default_value = "color"
group.links.new(n_in.outputs["Geometry"], n_capture.inputs["Geometry"])
group.links.new(n_color_attr.outputs["Attribute"], n_capture.inputs["color"])
group.links.new(n_capture.outputs["Geometry"], n_to_points.inputs["Mesh"])
group.links.new(n_to_points.outputs["Points"], n_instance.inputs["Points"])
group.links.new(n_obj_info.outputs["Geometry"], n_instance.inputs["Instance"])
group.links.new(n_rot.outputs["Attribute"], n_instance.inputs["Rotation"])
group.links.new(n_scale.outputs["Attribute"], n_instance.inputs["Scale"])
group.links.new(n_instance.outputs["Instances"], n_store_color.inputs["Geometry"])
group.links.new(n_capture.outputs["color"], n_store_color.inputs["Value"])
group.links.new(n_store_color.outputs["Geometry"], n_out.inputs["Geometry"])
return group
[docs]
def create_lights(self) -> Self:
"""
Adds all nessecary lightnings to the scene.
"""
col = bpy.data.collections.get("Lights")
if not col:
col = bpy.data.collections.new("Lights")
bpy.context.scene.collection.children.link(col)
def add_sun(name, energy, angle_deg, rot_deg):
light_data = bpy.data.lights.new(name=name, type="SUN")
light_data.energy = energy
light_data.angle = math.radians(angle_deg)
obj = bpy.data.objects.new(name, light_data)
obj.rotation_euler = tuple(math.radians(a) for a in rot_deg)
col.objects.link(obj)
return obj
add_sun("Key_Light", energy=4.0, angle_deg=15, rot_deg=(-55, 0, -70))
add_sun("Fill_Light", energy=2.5, angle_deg=30, rot_deg=(-50, 0, 110))
add_sun("Rim_Light", energy=2.0, angle_deg=10, rot_deg=(140, 0, 20))
world = bpy.context.scene.world
if world is None:
world = bpy.data.worlds.new("World")
bpy.context.scene.world = world
world.use_nodes = True
bg = world.node_tree.nodes.get("Background")
if bg:
bg.inputs["Color"].default_value = (1.0, 1.0, 1.0, 1.0)
bg.inputs["Strength"].default_value = 0.15
self.has_lights = True
return self
def _orbit(self, obj, angle: float) -> None:
"""
Rotates an object's location and yaw around the scene's vertical
(Z) axis, through the scene's center, by ``angle`` radians.
"""
center = self.size_bounding_box / 2
cos_a, sin_a = math.cos(angle), math.sin(angle)
x, y, z = obj.location
x, y = x - center, y - center
obj.location = (
x * cos_a - y * sin_a + center,
x * sin_a + y * cos_a + center,
z,
)
rx, ry, rz = obj.rotation_euler
obj.rotation_euler = (rx, ry, rz + angle)
[docs]
def create_solid(self, pth: Path, style: str = "SOLID_DEFAULT", name: str = "solid"):
"""
Adds the solid into the scene.
"""
with _get_spinner(f"[cyan]Loading object: {pth.name}") as p:
p.add_task("load", total=None)
_import_object(pth)
selected = bpy.context.selected_objects
assert selected
obj = selected[0]
mesh = cast(bpy.types.Mesh, obj.data)
obj.name = name
mesh.name = name
obj.rotation_euler = (math.radians(0), 0, math.radians(0))
obj.scale = (self.scale, self.scale, self.scale)
obj.location = self.shift
mesh.materials.clear()
mesh.materials.append(self.get_material(style, "solid"))
self.has_solid = True
return self
[docs]
def create_spheres(
self,
pos: np.ndarray,
r: np.ndarray,
name: str = "Spheres",
style: str = "STRUCTURE_DEFAULT",
) -> Self:
"""
Places a sphere for each pore in the scene.
"""
loc = pos * self.scale + self.shift
r = r * self.scale
mesh = bpy.data.meshes.new(name)
mesh.from_pydata(loc.tolist(), [], [])
mesh.update()
iscale = np.column_stack([r, r, r]).astype(np.float32)
scale_attr = mesh.attributes.new(
name="iscale", type="FLOAT_VECTOR", domain="POINT"
)
scale_attr.data.foreach_set("vector", iscale.ravel())
mesh.attributes.new(name="color", type="FLOAT_COLOR", domain="POINT")
obj = bpy.data.objects.new(name, mesh)
self._layers.objects.link(obj)
template = self._get_sphere_template(style)
mod = obj.modifiers.new("SphereInstances", type="NODES")
mod.node_group = self._get_node_group(template)
self.has_spheres = True
return self
def _get_sphere_template(self, style: str) -> bpy.types.Object:
"""
Returns a unit sphere (radius=1)
"""
name = f"Sphere_{style}"
obj_tmpl = bpy.data.objects.get(name)
if obj_tmpl is not None:
return obj_tmpl
bpy.ops.mesh.primitive_uv_sphere_add(
segments=self.n_segments, ring_count=self.n_segments, radius=1
)
obj_tmpl = bpy.context.view_layer.objects.active
obj_tmpl.name = name
obj_tmpl.location = (0, 0, 0)
obj_tmpl.rotation_euler = (0, 0, 0)
obj_tmpl.scale = (1, 1, 1)
obj_tmpl.data.polygons.foreach_set(
"use_smooth", [True] * len(obj_tmpl.data.polygons)
)
mat = self.get_material(style, "default").copy()
obj_tmpl.data.materials.append(mat)
self._apply_instance_color(mat)
templates_col = bpy.data.collections.get("Templates")
if not templates_col:
templates_col = bpy.data.collections.new("Templates")
bpy.context.scene.collection.children.link(templates_col)
templates_col.hide_render = True
templates_col.hide_viewport = True
for c in list(obj_tmpl.users_collection):
c.objects.unlink(obj_tmpl)
templates_col.objects.link(obj_tmpl)
return obj_tmpl
[docs]
def create_void(self, pth: Path, style: str = "ICE", name: str = "void") -> Self:
"""
Adds a 3D object of the void space to the scene.
"""
with _get_spinner(f"[cyan]Loading object: {pth.name}") as p:
p.add_task("load", total=None)
_import_object(pth)
selected = bpy.context.selected_objects
assert selected
obj = selected[0]
mesh = cast(bpy.types.Mesh, obj.data)
obj.name = name
mesh.name = name
obj.rotation_euler = (0, 0, 0)
obj.scale = (self.scale, self.scale, self.scale)
obj.location = self.shift
mesh.materials.clear()
mesh.materials.append(self.get_material(style, name))
self.has_void = True
return self
[docs]
def get_material(self, style: str, name: str):
"""
Creates a new material instance for the given style.
"""
mat = bpy.data.materials.get(name)
if mat:
return mat
else:
mat = bpy.data.materials.new(name)
mat.use_nodes = True
nodes = mat.node_tree.nodes
output = nodes.get("Material Output")
links = mat.node_tree.links
n_pr = nodes.get("Principled BSDF")
n_pr.subsurface_method = "BURLEY"
links.new(n_pr.outputs["BSDF"], output.inputs["Surface"])
if style in ["STRUCTURE_DEFAULT", "CLUSTER_DEFAULT"]:
n_pr.inputs["Base Color"].default_value = Color("#58646E").lnrgba
n_pr.inputs["Metallic"].default_value = 0.4
n_pr.inputs["Roughness"].default_value = 0.5
n_pr.inputs["Subsurface Weight"].default_value = 0.1
n_pr.inputs["Specular IOR Level"].default_value = 0.1
n_pr.inputs["Sheen Weight"].default_value = 0.1
n_pr.inputs["Sheen Roughness"].default_value = 0
elif style == "VOID_FROZEN":
# links.clear()
# node_mix = nodes.new("ShaderNodeMixShader")
# node_mix.inputs[0].default_value = 0.5
# links.new(node_mix.outputs[0], output.inputs[0])
# node_trans = nodes.new("ShaderNodeBsdfTransparent")
# links.new(node_trans.outputs[0], node_mix.inputs[1])
# links.new(node_princ.outputs[0], node_mix.inputs[2])
n_pr.inputs["Base Color"].default_value = Color("#0E86C7").lnrgba
n_pr.inputs["Metallic"].default_value = 0.4
n_pr.inputs["Roughness"].default_value = 0.5
n_pr.inputs["Specular IOR Level"].default_value = 0.1
n_pr.inputs["Sheen Weight"].default_value = 0.1
n_pr.inputs["Sheen Roughness"].default_value = 0
elif style == "VOID_DRY":
links.clear()
node_mix = nodes.new("ShaderNodeMixShader")
node_mix.inputs["Fac"].default_value = 0.25
links.new(node_mix.outputs[0], output.inputs["Surface"])
node_trans = nodes.new("ShaderNodeBsdfTransparent")
links.new(node_trans.outputs["BSDF"], node_mix.inputs[1])
links.new(n_pr.outputs["BSDF"], node_mix.inputs[2])
n_pr.inputs["Base Color"].default_value = Color("#E5BE0F").lnrgba
n_pr.inputs["Metallic"].default_value = 0.4
n_pr.inputs["Roughness"].default_value = 0.5
n_pr.inputs["Specular IOR Level"].default_value = 0.1
n_pr.inputs["Sheen Weight"].default_value = 0.1
n_pr.inputs["Sheen Roughness"].default_value = 0
elif style == "CELL_DEFAULT":
n_bevel = nodes.new("ShaderNodeBevel")
links.new(n_bevel.outputs["Normal"], n_pr.inputs["Normal"])
n_bevel.inputs["Radius"].default_value = 0.025 * 2
n_pr.inputs["Base Color"].default_value = Color("#947C5E").lnrgba
n_pr.inputs["Metallic"].default_value = 0.2
n_pr.inputs["Roughness"].default_value = 0.8
n_pr.inputs["Alpha"].default_value = 0.8
elif style == "CELL_TRANSPARENT":
links.clear()
node_mix = nodes.new("ShaderNodeMixShader")
node_mix.inputs["Fac"].default_value = 0.5
links.new(node_mix.outputs[0], output.inputs["Surface"])
node_trans = nodes.new("ShaderNodeBsdfTransparent")
links.new(node_trans.outputs["BSDF"], node_mix.inputs[1])
links.new(n_pr.outputs["BSDF"], node_mix.inputs[2])
n_bevel = nodes.new("ShaderNodeBevel")
links.new(n_bevel.outputs["Normal"], n_pr.inputs["Normal"])
n_bevel.inputs["Radius"].default_value = 0.025 * 5
n_pr.inputs["Base Color"].default_value = Color("#947C5E").lnrgba
n_pr.inputs["Metallic"].default_value = 0.2
n_pr.inputs["Roughness"].default_value = 0.8
n_pr.inputs["Alpha"].default_value = 0.8
elif style == "SOLID_DEFAULT":
# bevel node
n_bevel = nodes.new("ShaderNodeBevel")
n_bevel.inputs["Radius"].default_value = 0.02
# ambient occlusion node
n_ao = nodes.new("ShaderNodeAmbientOcclusion")
n_ao.only_local = True
n_ao.inputs["Distance"].default_value = 0.5
# AO mixing node
n_ao_mix = nodes.new("ShaderNodeMixRGB")
n_ao_mix.blend_type = "MULTIPLY"
n_ao_mix.inputs["Factor"].default_value = 0.3
n_ao_mix.inputs["Color1"].default_value = Color("#C6A87A").lnrgba
# principal node
n_pr.inputs["Metallic"].default_value = 0
n_pr.inputs["Roughness"].default_value = 0.5
n_pr.inputs["Specular IOR Level"].default_value = 0.5
n_pr.inputs["Sheen Weight"].default_value = 0.1
n_pr.inputs["Sheen Roughness"].default_value = 0.3
n_pr.inputs["Subsurface Weight"].default_value = 0.12
n_pr.inputs["Subsurface Scale"].default_value = 0.15
links.new(n_bevel.outputs["Normal"], n_pr.inputs["Normal"])
links.new(n_ao.outputs["Color"], n_ao_mix.inputs["Color2"])
links.new(n_ao_mix.outputs["Color"], n_pr.inputs["Base Color"])
elif style == "SOLID_TRANSPARENT":
links.clear()
node_mix = nodes.new("ShaderNodeMixShader")
node_mix.inputs["Fac"].default_value = 0.25
links.new(node_mix.outputs[0], output.inputs["Surface"])
node_trans = nodes.new("ShaderNodeBsdfTransparent")
links.new(node_trans.outputs["BSDF"], node_mix.inputs[1])
links.new(n_pr.outputs["BSDF"], node_mix.inputs[2])
n_pr.inputs["Base Color"].default_value = Color("#947C5E").lnrgba
n_pr.inputs["Metallic"].default_value = 0
n_pr.inputs["Roughness"].default_value = 1
n_pr.inputs["Specular IOR Level"].default_value = 0.5
elif style == "BACTERIA_GREEN":
n_pr.inputs["Base Color"].default_value = Color("#3F901A").lnrgba
n_pr.inputs["Metallic"].default_value = 0.2
n_pr.inputs["Roughness"].default_value = 1
n_pr.inputs["Specular IOR Level"].default_value = 0
elif style.startswith("ICE"):
if "ICE_TILED" == style:
color = random.choice(
["#7891e3", "#92b2e8", "#95c5e8", "#b7d2e7", "#d6edff"]
)
n_pr.inputs["Base Color"].default_value = Color(color).lnrgba
else:
n_pr.inputs["Base Color"].default_value = Color("#85A2E6").lnrgba
n_pr.inputs["Metallic"].default_value = 0
n_pr.inputs["Roughness"].default_value = 1
n_pr.inputs["Subsurface Weight"].default_value = 0.1
n_pr.inputs["Specular IOR Level"].default_value = 1
n_pr.inputs["Sheen Weight"].default_value = 0
n_pr.inputs["Sheen Roughness"].default_value = 1
elif style == "AXES":
n_pr.inputs["Base Color"].default_value = Color("#0001").lnrgba
n_pr.inputs["Metallic"].default_value = 0
n_pr.inputs["Roughness"].default_value = 1
n_pr.inputs["Specular IOR Level"].default_value = 0
return mat
[docs]
def hide_axes(self) -> Self:
"""
Hides axes from render scene."""
col = bpy.data.collections.get("Axes")
if col:
col.hide_render = True
return self
[docs]
def hide_clusters(self) -> Self:
"""
Hides all spheres from render scene."""
col = bpy.data.collections.get("Clusters")
if col:
col.hide_render = True
return self
[docs]
def hide_cylinders(self) -> Self:
"""Hides all cylinders from render scene."""
obj = bpy.data.objects.get("Cylinders")
if obj:
obj.hide_render = True
return self
[docs]
def hide_solid(self) -> Self:
"""
Hides the solid from render scene."""
obj = bpy.data.objects.get("solid")
if obj:
obj.hide_render = True
return self
[docs]
def hide_spheres(self) -> Self:
"""Hides all spheres from render scene."""
obj = bpy.data.objects.get("Spheres")
if obj:
obj.hide_render = True
return self
[docs]
def hide_void(self) -> Self:
"""
Hides the void from render scene."""
obj = bpy.data.objects.get("void")
if obj:
obj.hide_render = True
return self
[docs]
def remove_axes(self) -> Self:
"""
Removes the axes from the scene.
"""
col = bpy.data.collections.get("Axes")
if col:
obs = [o for o in col.objects if o.users == 1]
while obs:
bpy.data.objects.remove(obs.pop())
bpy.data.collections.remove(col)
return self
[docs]
def remove_defaults(self) -> Self:
"""Deletes the default cube and light of the blender scene."""
names = ["Cube", "Light"]
for name in names:
obj = bpy.data.objects.get(name)
if obj:
bpy.data.objects.remove(obj)
return self
[docs]
def remove_void(self, names: list[str] = ["void"]) -> Self:
"""
Removes any void from the scene.
"""
for name in names:
obj = bpy.data.objects.get(name)
if obj:
bpy.data.objects.remove(obj)
obj = bpy.data.meshes.get(name)
if obj:
bpy.data.meshes.remove(obj)
return self
[docs]
def remove_solid(self) -> Self:
"""
Removes any solid from the scene.
"""
obj = bpy.data.objects.get("solid")
if obj:
bpy.data.objects.remove(obj)
obj = bpy.data.meshes.get("solid")
if obj:
bpy.data.meshes.remove(obj)
return self
[docs]
def render(self, pth_render: Path, trim: bool = True) -> Path:
"""
Renders the current scene to an image file.
The render uses Blender's *Cycles* engine at the width and height taken from
the scene's image configuration (``config_image``) and writes the result to
``pth_render``.
Parameters
----------
pth_render : Path
Output path for the rendered image. Blender appends the file-format
extension when the path has none.
trim : bool, optional
If true, the saved image is cropped to its content, removing the surrounding
empty margins, by default True.
Returns
-------
Path
The output path the render was written to (``pth_render``).
"""
bpy.context.scene.render.resolution_x = self.config_image.width
bpy.context.scene.render.resolution_y = self.config_image.height
bpy.context.scene.render.filepath = str(pth_render)
with _get_spinner(f"[green]Rendering: {pth_render.name}") as p:
p.add_task("render", total=None)
with suppress_stdout():
bpy.ops.render.render(write_still=True)
if trim:
image.img_trim(pth_render)
return pth_render
[docs]
def rotate_azimuth(self, ang_rot: float) -> Self:
"""
Rotates the camera and all lights around the scene's vertical (Z)
axis by the given azimuth angle ``ang_rot`` (in degrees), orbiting
them around the scene's center while keeping the camera aimed there.
The axes are literal rulers built along fixed edges of the
(stationary) bounding box, so they can't simply follow the camera's
continuous rotation without drifting off the box they are meant to
measure. The x and y rulers each stay on whichever of their two
parallel edges currently faces the camera -- the x ruler flips
between the front/back edge, the y ruler between the left/right
edge -- by mirroring to that edge and turning their ticks and
labels 180 degrees, so ticks keep pointing outward and label text
stays legible, rather than rotating the whole ruler (which would
swap their x/y labeling, see the fixed bug this replaced). The
main ruler bar itself is left unrotated since it's symmetric along
its own length. The z ruler sits at the corner shared by the
current x and y edges, so it is snapped there in 90-degree steps
the same way the whole assembly used to move.
"""
self._orbit(bpy.data.objects["Camera"], math.radians(ang_rot))
for obj in bpy.data.collections["Lights"].objects:
rx, ry, rz = obj.rotation_euler
obj.rotation_euler = (rx, ry, rz + math.radians(ang_rot))
if self.has_axes:
def _nearest_corner(deg: float) -> float:
return math.floor((deg - 45) / 90 + 0.5) * 90 + 45
def _side(deg: float, fn) -> bool:
return fn(math.radians(deg)) >= 0
ang_before = self._ang_azimuth
corner_before = _nearest_corner(ang_before)
self._ang_azimuth += ang_rot
ang_after = self._ang_azimuth
corner_after = _nearest_corner(ang_after)
corner_delta = math.radians(corner_after - corner_before)
flips = {
"y": _side(ang_before, math.cos) != _side(ang_after, math.cos),
"x": _side(ang_before, math.sin) != _side(ang_after, math.sin),
}
def _axis_of(name: str) -> str | None:
for axis in ("x", "y", "z"):
if name == f"axis_{axis}" or name == f"axis label {axis}":
return axis
if name.startswith(f"{axis} "):
return axis
return None
if corner_delta or any(flips.values()):
sbb = self.size_bounding_box
for obj in bpy.data.collections["Axes"].objects:
axis = _axis_of(obj.name)
if axis is None:
continue
if axis == "z":
if corner_delta:
self._orbit(obj, corner_delta)
continue
if not flips[axis]:
continue
# record the object's own bounding-box center in world space
# before anything changes, to correct its position afterwards
# (see below)
mat_before = Matrix.LocRotScale(
obj.location, obj.rotation_euler.to_quaternion(), obj.scale
)
local_center = sum((Vector(c) for c in obj.bound_box), Vector()) / 8
center_before = mat_before @ local_center
x, y, z = obj.location
if axis == "y":
x = sbb - x
else:
y = sbb - y
obj.location = (x, y, z)
if obj.name == f"axis_{axis}":
sx, sy, sz = obj.scale
obj.scale = (-sx, sy, sz)
else:
obj.rotation_euler.z += math.radians(180)
mat_after = Matrix.LocRotScale(
obj.location, obj.rotation_euler.to_quaternion(), obj.scale
)
center_after = mat_after @ local_center
desired = Vector(center_before)
if axis == "y":
desired.x = sbb - desired.x
else:
desired.y = sbb - desired.y
obj.location = Vector(obj.location) + (desired - center_after)
else:
self._ang_azimuth += ang_rot
return self
[docs]
def save(self, pth: Path) -> Self:
"""
Saves the current scene as BLEND file.
"""
with _get_spinner(f"Saving scene: {str(pth.name)}") as p:
p.add_task("save", total=None)
bpy.ops.wm.save_as_mainfile(filepath=str(pth))
return self
[docs]
def load(self, pth: Path) -> Self:
"""
Loads the current scene from BLEND file.
"""
with _get_spinner(f"Loading scene: {str(pth.name)}") as p:
p.add_task("load", total=None)
bpy.ops.wm.open_mainfile(filepath=str(pth))
return self
[docs]
def show_axes(self) -> Self:
"""
Hides axes from render scene."""
col = bpy.data.collections.get("Axes")
if col:
col.hide_render = False
return self
[docs]
def show_clusters(self) -> Self:
"""
Hides all spheres from render scene."""
col = bpy.data.collections.get("Clusters")
if col:
col.hide_render = False
return self
[docs]
def show_cylinders(self) -> Self:
"""Shows all cylinders in the render scene."""
obj = bpy.data.objects.get("Cylinders")
if obj:
obj.hide_render = False
return self
[docs]
def show_solid(self) -> Self:
"""
Hides the solid from render scene."""
obj = bpy.data.objects.get("solid")
if obj:
obj.hide_render = False
return self
[docs]
def show_spheres(self) -> Self:
"""Shows all spheres in the render scene."""
obj = bpy.data.objects.get("Spheres")
if obj:
obj.hide_render = False
return self
[docs]
def show_void(self) -> Self:
"""
Hides the void from render scene."""
obj = bpy.data.objects.get("void")
if obj:
obj.hide_render = False
return self
@property
def aspect(self) -> tuple[float, float, float]:
"""
Relative proportions of the domain along x, y and z, each normalized by the
largest of the three so the longest axis is ``1``. Set by :meth:`from_json`
from the given physical ``dims``.
"""
return self._aspect
@aspect.setter
def aspect(self, arg: tuple[float, float, float]):
self._aspect = arg
@property
def config_axes(self) -> AxesConfiguration:
"""
Configuration controlling how :meth:`create_axes` draws axis lines, ticks and
labels.
"""
return self._config_axes
@config_axes.setter
def config_axes(self, arg: AxesConfiguration):
self._config_axes = arg
@property
def config_image(self) -> ImageConfiguration:
"""
Configuration controlling how rendered images are post-processed.
"""
return self._config_image
@config_image.setter
def config_image(self, arg: ImageConfiguration):
self._config_image = arg
@property
def config_scene(self) -> SceneConfiguration:
"""
Configuration controlling general scene appearance, such as which items are
rendered and their styling.
"""
return self._config_scene
@config_scene.setter
def config_scene(self, arg: SceneConfiguration):
self._config_scene = arg
@property
def has_axes(self) -> bool:
"""
Whether axes with ticks and labels have been added to the scene, via
:meth:`create_axes`.
"""
return self._has_axes
@has_axes.setter
def has_axes(self, arg: bool):
self._has_axes = arg
@property
def has_clusters(self) -> bool:
"""
Whether pore clusters have been added to the scene, via
:meth:`create_clusters`.
"""
return self._has_clusters
@has_clusters.setter
def has_clusters(self, arg: bool):
self._has_clusters = arg
@property
def has_cylinders(self) -> bool:
"""
Whether throat cylinders have been added to the scene, via
:meth:`create_cylinders`.
"""
return self._has_cylinders
@has_cylinders.setter
def has_cylinders(self, arg: bool):
self._has_cylinders = arg
@property
def has_lights(self) -> bool:
"""
Whether lights have been added to the scene, via :meth:`create_lights`.
"""
return self._has_lights
@has_lights.setter
def has_lights(self, arg: bool):
self._has_lights = arg
@property
def has_solid(self) -> bool:
"""
Whether the solid object has been added to the scene, via
:meth:`create_solid`.
"""
return self._has_solid
@has_solid.setter
def has_solid(self, arg: bool):
self._has_solid = arg
@property
def has_spheres(self) -> bool:
"""
Whether pore spheres have been added to the scene, via
:meth:`create_spheres`.
"""
return self._has_spheres
@has_spheres.setter
def has_spheres(self, arg: bool):
self._has_spheres = arg
@property
def has_void(self) -> bool:
"""
Whether the void-space object has been added to the scene, via
:meth:`create_void`.
"""
return self._has_void
@has_void.setter
def has_void(self, arg: bool):
self._has_void = arg
def _get_spinner(text: str) -> progress.Progress:
return progress.Progress(
progress.SpinnerColumn(style="white"),
progress.TextColumn(text),
progress.TimeElapsedColumn(),
console=Console(stderr=True),
)
# maps file suffixes to their Blender import operator
_IMPORTERS: dict[str, tuple[str, str]] = {
".obj": ("wm", "obj_import"),
".ply": ("wm", "ply_import"),
".stl": ("wm", "stl_import"),
".abc": ("wm", "alembic_import"),
".usd": ("wm", "usd_import"),
".usda": ("wm", "usd_import"),
".usdc": ("wm", "usd_import"),
".fbx": ("import_scene", "fbx"),
".glb": ("import_scene", "gltf"),
".gltf": ("import_scene", "gltf"),
}
def _import_object(pth: Path) -> None:
"""
Imports a 3D object file using the Blender importer matching its file extension.
"""
suffix = pth.suffix.lower()
try:
namespace, operator = _IMPORTERS[suffix]
except KeyError:
raise ValueError(
f"Unsupported file format {suffix!r} for {pth.name}. "
f"Supported formats: {', '.join(sorted(_IMPORTERS))}"
) from None
import_op = getattr(getattr(bpy.ops, namespace), operator)
with suppress_stdout():
import_op(filepath=str(pth))