Add Chromium-only Blender WebEngine parity work

This commit is contained in:
mes123456
2026-08-12 04:47:48 -04:00
commit 9fd26010f6
18225 changed files with 11622124 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
# SPDX-FileCopyrightText: 2009-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
"""
Utility modules associated with the bpy module.
"""
__all__ = (
"anim_utils",
"asset_utils",
"object_utils",
"io_utils",
"image_utils",
"keyconfig_utils",
"mesh_utils",
"node_utils",
"view3d_utils",
"id_map_utils",
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,91 @@
# SPDX-FileCopyrightText: 2020-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
"""
Helpers for asset management tasks.
"""
__all__ = (
"AssetBrowserPanel",
"AssetMetaDataPanel",
"SpaceAssetInfo",
)
class SpaceAssetInfo:
"""Utility class for checking if a space is an asset browser."""
@classmethod
def is_asset_browser(cls, space_data):
"""
Check if the given space is an asset browser.
:param space_data: The space to check.
:type space_data: :class:`bpy.types.Space`
:return: True when the space is an asset browser.
:rtype: bool
"""
return space_data and space_data.type == 'FILE_BROWSER' and space_data.browse_mode == 'ASSETS'
@classmethod
def is_asset_browser_poll(cls, context):
"""
Poll whether the active space is an asset browser.
:param context: The context.
:type context: :class:`bpy.types.Context`
:return: True when the active space is an asset browser.
:rtype: bool
"""
return cls.is_asset_browser(context.space_data)
class AssetBrowserPanel:
"""Mixin class for panels that should only show in the asset browser."""
bl_space_type = 'FILE_BROWSER'
@classmethod
def asset_browser_panel_poll(cls, context):
"""
Check if the panel should be shown in the asset browser.
:param context: The context.
:type context: :class:`bpy.types.Context`
:return: True when the panel should be visible.
:rtype: bool
"""
return SpaceAssetInfo.is_asset_browser_poll(context)
@classmethod
def poll(cls, context):
"""
Poll for asset browser visibility.
:param context: The context.
:type context: :class:`bpy.types.Context`
:return: True when the panel should be visible.
:rtype: bool
"""
return cls.asset_browser_panel_poll(context)
class AssetMetaDataPanel:
"""Mixin class for panels that display asset metadata in the asset browser."""
bl_space_type = 'FILE_BROWSER'
bl_region_type = 'TOOL_PROPS'
@classmethod
def poll(cls, context):
"""
Poll for asset browser with active asset metadata.
:param context: The context.
:type context: :class:`bpy.types.Context`
:return: True when the asset browser has active asset data.
:rtype: bool
"""
active_file = context.active_file
return SpaceAssetInfo.is_asset_browser_poll(context) and active_file and active_file.asset_data

View File

@@ -0,0 +1,56 @@
# SPDX-FileCopyrightText: 2022-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
__all__ = (
"bmesh_linked_uv_islands",
)
def match_uv(face, vert, uv, uv_layer):
for loop in face.loops:
if loop.vert == vert:
return uv == loop[uv_layer].uv
return False
def bmesh_linked_uv_islands(bm, uv_layer):
"""
Returns lists of faces connected by UV islands.
For meshes use :class:`bpy.types.Mesh.mesh_linked_uv_islands` instead.
:param bm: the bmesh used to group with.
:type bmesh: :class:`BMesh`
:param uv_layer: the UV layer to source UVs from.
:type bmesh: :class:`BMLayerItem`
:return: list of lists containing polygon indices
:rtype: list[list[int]]
"""
result = []
used = set()
for seed_face in bm.faces:
if seed_face in used:
continue # Face has already been processed.
used.add(seed_face)
island = [seed_face]
stack = [seed_face] # Faces still to consider on this island.
while stack:
current_face = stack.pop()
for loop in current_face.loops:
v = loop.vert
uv = loop[uv_layer].uv
for f in v.link_faces:
if f is current_face or f in used:
continue
if not match_uv(f, v, uv, uv_layer):
continue
# `f` is part of island, add to island and stack
used.add(f)
island.append(f)
stack.append(f)
result.append(island)
return result

View File

@@ -0,0 +1,53 @@
# SPDX-FileCopyrightText: 2022-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
import bpy
__all__ = (
"get_id_reference_map",
"get_all_referenced_ids",
)
def get_id_reference_map():
"""Return a dictionary of direct data-block references for every data-block in the blend file.
:return: Each datablock of the .blend file mapped to the set of IDs they directly reference.
:rtype: dict[bpy.types.ID, set[bpy.types.ID]]
"""
inv_map = {}
for key, values in bpy.data.user_map().items():
for value in values:
if value == key:
# So an object is not considered to be referencing itself.
continue
inv_map.setdefault(value, set()).add(key)
return inv_map
def get_all_referenced_ids(id, ref_map):
"""
Return a set of IDs directly or indirectly referenced by id.
:param id: Datablock whose references we're interested in.
:type id: bpy.types.ID
:param ref_map: The global ID reference map, retrieved from get_id_reference_map()
:type ref_map: dict[bpy.types.ID, set[bpy.types.ID]]
:return: Set of datablocks referenced by `id`.
:rtype: set[bpy.types.ID]
"""
def recursive_helper(ref_map, id, referenced_ids, visited):
if id in visited:
# Avoid infinite recursion from circular references.
return
visited.add(id)
for ref in ref_map.get(id, []):
referenced_ids.add(ref)
recursive_helper(ref_map=ref_map, id=ref, referenced_ids=referenced_ids, visited=visited)
referenced_ids = set()
recursive_helper(ref_map=ref_map, id=id, referenced_ids=referenced_ids, visited=set())
return referenced_ids

View File

@@ -0,0 +1,196 @@
# SPDX-FileCopyrightText: 2009-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
__all__ = (
"load_image",
)
# limited replacement for BPyImage.comprehensiveImageLoad
def load_image(
imagepath,
dirname="",
place_holder=False,
recursive=False,
ncase_cmp=True,
convert_callback=None,
verbose=False,
relpath=None,
check_existing=False,
force_reload=False,
):
"""
Return an image from the file path with options to search multiple paths
and return a placeholder if it's not found.
:param imagepath: The image filename
If a path precedes it, this will be searched as well.
:type imagepath: str
:param dirname: is the directory where the image may be located - any file at
the end will be ignored.
:type dirname: str
:param place_holder: if True a new place holder image will be created.
this is useful so later you can relink the image to its original data.
:type place_holder: bool
:param recursive: If True, directories will be recursively searched.
Be careful with this if you have files in your root directory because
it may take a long time.
:type recursive: bool
:param ncase_cmp: on non windows systems, find the correct case for the file.
:type ncase_cmp: bool
:param convert_callback: a function that takes an existing path and returns
a new one. Use this when loading image formats blender may not support,
the CONVERT_CALLBACK can take the path for a GIF (for example),
convert it to a PNG and return the PNG's path.
For formats blender can read, simply return the path that is given.
:type convert_callback: Callable[[str], str] | None
:param verbose: If True, print extra information when searching for the image.
:type verbose: bool
:param relpath: If not None, make the file relative to this path.
:type relpath: str | None
:param check_existing: If true,
returns already loaded image data-block if possible
(based on file path).
:type check_existing: bool
:param force_reload: If true,
force reloading of image (only useful when ``check_existing``
is also enabled).
:type force_reload: bool
:return: an image or None
:rtype: :class:`bpy.types.Image` | None
"""
import os
import bpy
# -------------------------------------------------------------------------
# Utility Functions
def _image_load_placeholder(path):
name = path
if type(path) is str:
name = name.encode("utf-8", "replace")
name = name.decode("utf-8", "replace")
name = os.path.basename(name)
image = bpy.data.images.new(name, 128, 128)
# allow the path to be resolved later
image.filepath = path
image.source = 'FILE'
return image
def _image_load(path):
import bpy
if convert_callback:
path = convert_callback(path)
# Ensure we're not relying on the 'CWD' to resolve the path.
if not os.path.isabs(path):
path = os.path.abspath(path)
try:
image = bpy.data.images.load(path, check_existing=check_existing)
except RuntimeError:
image = None
if verbose:
if image:
print(" image loaded '{:s}'".format(path))
else:
print(" image load failed '{:s}'".format(path))
# image path has been checked so the path could not be read for some
# reason, so be sure to return a placeholder
if place_holder and image is None:
image = _image_load_placeholder(path)
if image:
if force_reload:
image.reload()
if relpath is not None:
# make relative
from bpy.path import relpath as relpath_fn
# can't always find the relative path
# (between drive letters on windows)
try:
filepath_rel = relpath_fn(path, start=relpath)
except ValueError:
filepath_rel = None
if filepath_rel is not None:
image.filepath_raw = filepath_rel
return image
def _recursive_search(paths, filename_check):
for path in paths:
for dirpath, _dirnames, filenames in os.walk(path):
# skip '.svn'
if dirpath[0] in {".", b'.'}:
continue
for filename in filenames:
if filename_check(filename):
yield os.path.join(dirpath, filename)
# -------------------------------------------------------------------------
imagepath = bpy.path.native_pathsep(imagepath)
if verbose:
print("load_image('{:s}', '{:s}', ...)".format(imagepath, dirname))
if os.path.exists(imagepath):
return _image_load(imagepath)
variants = [imagepath]
if dirname:
variants += [
os.path.join(dirname, imagepath),
os.path.join(dirname, bpy.path.basename(imagepath)),
]
for filepath_test in variants:
if ncase_cmp:
ncase_variants = (
filepath_test,
bpy.path.resolve_ncase(filepath_test),
)
else:
ncase_variants = (filepath_test, )
for nfilepath in ncase_variants:
if os.path.exists(nfilepath):
return _image_load(nfilepath)
if recursive:
search_paths = []
for dirpath_test in (os.path.dirname(imagepath), dirname):
if os.path.exists(dirpath_test):
search_paths.append(dirpath_test)
search_paths[:] = bpy.path.reduce_dirs(search_paths)
imagepath_base = bpy.path.basename(imagepath)
if ncase_cmp:
imagepath_base = imagepath_base.lower()
def image_filter(fn):
return (imagepath_base == fn.lower())
else:
def image_filter(fn):
return (imagepath_base == fn)
nfilepath = next(_recursive_search(search_paths, image_filter), None)
if nfilepath is not None:
return _image_load(nfilepath)
# None of the paths exist so return placeholder
if place_holder:
return _image_load_placeholder(imagepath)
# TODO comprehensiveImageLoad also searched in bpy.config.textureDir
return None

View File

@@ -0,0 +1,725 @@
# SPDX-FileCopyrightText: 2010-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
__all__ = (
"ExportHelper",
"ImportHelper",
"orientation_helper",
"axis_conversion",
"axis_conversion_ensure",
"create_derived_objects",
"poll_file_object_drop",
"unpack_list",
"unpack_face_list",
"path_reference",
"path_reference_copy",
"path_reference_mode",
"unique_name",
)
import bpy
from bpy.props import (
BoolProperty,
EnumProperty,
StringProperty,
)
from bpy.app.translations import (
contexts as i18n_contexts,
pgettext_iface as iface_,
pgettext_data as data_,
)
def _check_axis_conversion(op):
if hasattr(op, "axis_forward") and hasattr(op, "axis_up"):
return axis_conversion_ensure(
op,
"axis_forward",
"axis_up",
)
return False
class ExportHelper:
filepath: StringProperty(
name="File Path",
description="Filepath used for exporting the file",
maxlen=1024,
subtype='FILE_PATH',
)
check_existing: BoolProperty(
name="Check Existing",
description="Check and warn on overwriting existing files",
default=True,
options={'HIDDEN'},
)
# subclasses can override with decorator
# True == use ext, False == no ext, None == do nothing.
check_extension = True
def invoke(self, context, event):
"""
Invoke the file selector for exporting, setting a default filepath
based on the current blend file name.
:param context: The context.
:type context: :class:`bpy.types.Context`
:param event: The window event.
:type event: :class:`bpy.types.Event`
:return: The operator return value.
:rtype: set[str]
"""
del event
import os
if not self.filepath:
blend_filepath = context.blend_data.filepath
if not blend_filepath:
blend_filepath = data_("Untitled")
else:
blend_filepath = os.path.splitext(blend_filepath)[0]
self.filepath = blend_filepath + self.filename_ext
context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'}
def check(self, context):
"""
Validate the filepath and axis conversion settings.
:param context: The context.
:type context: :class:`bpy.types.Context`
:return: True when a property was updated.
:rtype: bool
"""
del context
import os
change_ext = False
change_axis = _check_axis_conversion(self)
check_extension = self.check_extension
if check_extension is not None:
filepath = self.filepath
if os.path.basename(filepath):
if check_extension:
filepath = bpy.path.ensure_ext(
os.path.splitext(filepath)[0],
self.filename_ext,
)
if filepath != self.filepath:
self.filepath = filepath
change_ext = True
return (change_ext or change_axis)
class ImportHelper:
filepath: StringProperty(
name="File Path",
description="Filepath used for importing the file",
maxlen=1024,
subtype='FILE_PATH',
options={'SKIP_PRESET', 'HIDDEN'}
)
def invoke(self, context, event):
"""
Invoke the file selector for importing.
:param context: The context.
:type context: :class:`bpy.types.Context`
:param event: The window event.
:type event: :class:`bpy.types.Event`
:return: The operator return value.
:rtype: set[str]
"""
del event
context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'}
def invoke_popup(self, context, confirm_text=""):
"""
Invoke as a popup confirmation dialog when a filepath is already set,
otherwise fall back to the file selector.
:param context: The context.
:type context: :class:`bpy.types.Context`
:param confirm_text: Label for the confirm button,
defaults to the operator label.
:type confirm_text: str
:return: The operator return value.
:rtype: set[str]
"""
if self.properties.is_property_set("filepath"):
title = self.filepath
if len(self.files) > 1:
title = iface_("Import {:d} files").format(len(self.files))
if confirm_text:
confirm_text = iface_(confirm_text)
else:
# Use the operator's bl_label, extracted with an "Operator" translation context.
confirm_text = iface_(self.bl_label, i18n_contexts.operator_default)
return context.window_manager.invoke_props_dialog(
self,
confirm_text=confirm_text,
title=title,
translate=False,
)
context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'}
def check(self, context):
"""
Validate axis conversion settings.
:param context: The context.
:type context: :class:`bpy.types.Context`
:return: True when a property was updated.
:rtype: bool
"""
del context
return _check_axis_conversion(self)
def orientation_helper(axis_forward='Y', axis_up='Z'):
"""
A decorator for import/export classes, generating properties needed by the axis conversion system and IO helpers,
with specified default values (axes).
:param axis_forward: The default forward axis.
:type axis_forward: Literal['X', 'Y', 'Z', '-X', '-Y', '-Z']
:param axis_up: The default up axis.
:type axis_up: Literal['X', 'Y', 'Z', '-X', '-Y', '-Z']
:return: A class decorator.
:rtype: Callable[[type], type]
"""
def wrapper(cls):
# Python 3.14+ (PEP 649): This workaround is no longer needed because annotations
# are lazily evaluated. Accessing `cls.__annotations__` always returns a dict
# specific to that class (never the parent's), so adding items is safe.
import sys
if sys.version_info < (3, 14):
# Without this, we may end up adding those fields to some **parent** class'
# `__annotations__` property (like the ImportHelper or ExportHelper ones)! See #58772.
if "__annotations__" not in cls.__dict__:
setattr(cls, "__annotations__", {})
def _update_axis_forward(self, _context):
if self.axis_forward[-1] == self.axis_up[-1]:
self.axis_up = (
self.axis_up[0:-1] +
'XYZ'[('XYZ'.index(self.axis_up[-1]) + 1) % 3]
)
cls.__annotations__["axis_forward"] = EnumProperty(
name="Forward",
items=(
('X', "X Forward", ""),
('Y', "Y Forward", ""),
('Z', "Z Forward", ""),
('-X', "-X Forward", ""),
('-Y', "-Y Forward", ""),
('-Z', "-Z Forward", ""),
),
default=axis_forward,
update=_update_axis_forward,
)
def _update_axis_up(self, _context):
if self.axis_up[-1] == self.axis_forward[-1]:
self.axis_forward = (
self.axis_forward[0:-1] +
'XYZ'[('XYZ'.index(self.axis_forward[-1]) + 1) % 3]
)
cls.__annotations__["axis_up"] = EnumProperty(
name="Up",
items=(
('X', "X Up", ""),
('Y', "Y Up", ""),
('Z', "Z Up", ""),
('-X', "-X Up", ""),
('-Y', "-Y Up", ""),
('-Z', "-Z Up", ""),
),
default=axis_up,
update=_update_axis_up,
)
return cls
return wrapper
# Axis conversion function, not pretty LUT
# use lookup table to convert between any axis
_axis_convert_matrix = (
((-1.0, 0.0, 0.0), (0.0, -1.0, 0.0), (0.0, 0.0, 1.0)),
((-1.0, 0.0, 0.0), (0.0, 0.0, -1.0), (0.0, -1.0, 0.0)),
((-1.0, 0.0, 0.0), (0.0, 0.0, 1.0), (0.0, 1.0, 0.0)),
((-1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, -1.0)),
((0.0, -1.0, 0.0), (-1.0, 0.0, 0.0), (0.0, 0.0, -1.0)),
((0.0, 0.0, 1.0), (-1.0, 0.0, 0.0), (0.0, -1.0, 0.0)),
((0.0, 0.0, -1.0), (-1.0, 0.0, 0.0), (0.0, 1.0, 0.0)),
((0.0, 1.0, 0.0), (-1.0, 0.0, 0.0), (0.0, 0.0, 1.0)),
((0.0, -1.0, 0.0), (0.0, 0.0, 1.0), (-1.0, 0.0, 0.0)),
((0.0, 0.0, -1.0), (0.0, -1.0, 0.0), (-1.0, 0.0, 0.0)),
((0.0, 0.0, 1.0), (0.0, 1.0, 0.0), (-1.0, 0.0, 0.0)),
((0.0, 1.0, 0.0), (0.0, 0.0, -1.0), (-1.0, 0.0, 0.0)),
((0.0, -1.0, 0.0), (0.0, 0.0, -1.0), (1.0, 0.0, 0.0)),
((0.0, 0.0, 1.0), (0.0, -1.0, 0.0), (1.0, 0.0, 0.0)),
((0.0, 0.0, -1.0), (0.0, 1.0, 0.0), (1.0, 0.0, 0.0)),
((0.0, 1.0, 0.0), (0.0, 0.0, 1.0), (1.0, 0.0, 0.0)),
((0.0, -1.0, 0.0), (1.0, 0.0, 0.0), (0.0, 0.0, 1.0)),
((0.0, 0.0, -1.0), (1.0, 0.0, 0.0), (0.0, -1.0, 0.0)),
((0.0, 0.0, 1.0), (1.0, 0.0, 0.0), (0.0, 1.0, 0.0)),
((0.0, 1.0, 0.0), (1.0, 0.0, 0.0), (0.0, 0.0, -1.0)),
((1.0, 0.0, 0.0), (0.0, -1.0, 0.0), (0.0, 0.0, -1.0)),
((1.0, 0.0, 0.0), (0.0, 0.0, 1.0), (0.0, -1.0, 0.0)),
((1.0, 0.0, 0.0), (0.0, 0.0, -1.0), (0.0, 1.0, 0.0)),
)
# store args as a single int
# (X Y Z -X -Y -Z) --> (0, 1, 2, 3, 4, 5)
# each value is ((src_forward, src_up), (dst_forward, dst_up))
# where all 4 values are or'd into a single value...
# (i1<<0 | i1<<3 | i1<<6 | i1<<9)
_axis_convert_lut = (
{0x8C8, 0x4D0, 0x2E0, 0xAE8, 0x701, 0x511, 0x119, 0xB29, 0x682, 0x88A,
0x09A, 0x2A2, 0x80B, 0x413, 0x223, 0xA2B, 0x644, 0x454, 0x05C, 0xA6C,
0x745, 0x94D, 0x15D, 0x365},
{0xAC8, 0x8D0, 0x4E0, 0x2E8, 0x741, 0x951, 0x159, 0x369, 0x702, 0xB0A,
0x11A, 0x522, 0xA0B, 0x813, 0x423, 0x22B, 0x684, 0x894, 0x09C, 0x2AC,
0x645, 0xA4D, 0x05D, 0x465},
{0x4C8, 0x2D0, 0xAE0, 0x8E8, 0x681, 0x291, 0x099, 0x8A9, 0x642, 0x44A,
0x05A, 0xA62, 0x40B, 0x213, 0xA23, 0x82B, 0x744, 0x354, 0x15C, 0x96C,
0x705, 0x50D, 0x11D, 0xB25},
{0x2C8, 0xAD0, 0x8E0, 0x4E8, 0x641, 0xA51, 0x059, 0x469, 0x742, 0x34A,
0x15A, 0x962, 0x20B, 0xA13, 0x823, 0x42B, 0x704, 0xB14, 0x11C, 0x52C,
0x685, 0x28D, 0x09D, 0x8A5},
{0x708, 0xB10, 0x120, 0x528, 0x8C1, 0xAD1, 0x2D9, 0x4E9, 0x942, 0x74A,
0x35A, 0x162, 0x64B, 0xA53, 0x063, 0x46B, 0x804, 0xA14, 0x21C, 0x42C,
0x885, 0x68D, 0x29D, 0x0A5},
{0xB08, 0x110, 0x520, 0x728, 0x941, 0x151, 0x359, 0x769, 0x802, 0xA0A,
0x21A, 0x422, 0xA4B, 0x053, 0x463, 0x66B, 0x884, 0x094, 0x29C, 0x6AC,
0x8C5, 0xACD, 0x2DD, 0x4E5},
{0x508, 0x710, 0xB20, 0x128, 0x881, 0x691, 0x299, 0x0A9, 0x8C2, 0x4CA,
0x2DA, 0xAE2, 0x44B, 0x653, 0xA63, 0x06B, 0x944, 0x754, 0x35C, 0x16C,
0x805, 0x40D, 0x21D, 0xA25},
{0x108, 0x510, 0x720, 0xB28, 0x801, 0x411, 0x219, 0xA29, 0x882, 0x08A,
0x29A, 0x6A2, 0x04B, 0x453, 0x663, 0xA6B, 0x8C4, 0x4D4, 0x2DC, 0xAEC,
0x945, 0x14D, 0x35D, 0x765},
{0x748, 0x350, 0x160, 0x968, 0xAC1, 0x2D1, 0x4D9, 0x8E9, 0xA42, 0x64A,
0x45A, 0x062, 0x68B, 0x293, 0x0A3, 0x8AB, 0xA04, 0x214, 0x41C, 0x82C,
0xB05, 0x70D, 0x51D, 0x125},
{0x948, 0x750, 0x360, 0x168, 0xB01, 0x711, 0x519, 0x129, 0xAC2, 0x8CA,
0x4DA, 0x2E2, 0x88B, 0x693, 0x2A3, 0x0AB, 0xA44, 0x654, 0x45C, 0x06C,
0xA05, 0x80D, 0x41D, 0x225},
{0x348, 0x150, 0x960, 0x768, 0xA41, 0x051, 0x459, 0x669, 0xA02, 0x20A,
0x41A, 0x822, 0x28B, 0x093, 0x8A3, 0x6AB, 0xB04, 0x114, 0x51C, 0x72C,
0xAC5, 0x2CD, 0x4DD, 0x8E5},
{0x148, 0x950, 0x760, 0x368, 0xA01, 0x811, 0x419, 0x229, 0xB02, 0x10A,
0x51A, 0x722, 0x08B, 0x893, 0x6A3, 0x2AB, 0xAC4, 0x8D4, 0x4DC, 0x2EC,
0xA45, 0x04D, 0x45D, 0x665},
{0x688, 0x890, 0x0A0, 0x2A8, 0x4C1, 0x8D1, 0xAD9, 0x2E9, 0x502, 0x70A,
0xB1A, 0x122, 0x74B, 0x953, 0x163, 0x36B, 0x404, 0x814, 0xA1C, 0x22C,
0x445, 0x64D, 0xA5D, 0x065},
{0x888, 0x090, 0x2A0, 0x6A8, 0x501, 0x111, 0xB19, 0x729, 0x402, 0x80A,
0xA1A, 0x222, 0x94B, 0x153, 0x363, 0x76B, 0x444, 0x054, 0xA5C, 0x66C,
0x4C5, 0x8CD, 0xADD, 0x2E5},
{0x288, 0x690, 0x8A0, 0x0A8, 0x441, 0x651, 0xA59, 0x069, 0x4C2, 0x2CA,
0xADA, 0x8E2, 0x34B, 0x753, 0x963, 0x16B, 0x504, 0x714, 0xB1C, 0x12C,
0x405, 0x20D, 0xA1D, 0x825},
{0x088, 0x290, 0x6A0, 0x8A8, 0x401, 0x211, 0xA19, 0x829, 0x442, 0x04A,
0xA5A, 0x662, 0x14B, 0x353, 0x763, 0x96B, 0x4C4, 0x2D4, 0xADC, 0x8EC,
0x505, 0x10D, 0xB1D, 0x725},
{0x648, 0x450, 0x060, 0xA68, 0x2C1, 0x4D1, 0x8D9, 0xAE9, 0x282, 0x68A,
0x89A, 0x0A2, 0x70B, 0x513, 0x123, 0xB2B, 0x204, 0x414, 0x81C, 0xA2C,
0x345, 0x74D, 0x95D, 0x165},
{0xA48, 0x650, 0x460, 0x068, 0x341, 0x751, 0x959, 0x169, 0x2C2, 0xACA,
0x8DA, 0x4E2, 0xB0B, 0x713, 0x523, 0x12B, 0x284, 0x694, 0x89C, 0x0AC,
0x205, 0xA0D, 0x81D, 0x425},
{0x448, 0x050, 0xA60, 0x668, 0x281, 0x091, 0x899, 0x6A9, 0x202, 0x40A,
0x81A, 0xA22, 0x50B, 0x113, 0xB23, 0x72B, 0x344, 0x154, 0x95C, 0x76C,
0x2C5, 0x4CD, 0x8DD, 0xAE5},
{0x048, 0xA50, 0x660, 0x468, 0x201, 0xA11, 0x819, 0x429, 0x342, 0x14A,
0x95A, 0x762, 0x10B, 0xB13, 0x723, 0x52B, 0x2C4, 0xAD4, 0x8DC, 0x4EC,
0x285, 0x08D, 0x89D, 0x6A5},
{0x808, 0xA10, 0x220, 0x428, 0x101, 0xB11, 0x719, 0x529, 0x142, 0x94A,
0x75A, 0x362, 0x8CB, 0xAD3, 0x2E3, 0x4EB, 0x044, 0xA54, 0x65C, 0x46C,
0x085, 0x88D, 0x69D, 0x2A5},
{0xA08, 0x210, 0x420, 0x828, 0x141, 0x351, 0x759, 0x969, 0x042, 0xA4A,
0x65A, 0x462, 0xACB, 0x2D3, 0x4E3, 0x8EB, 0x084, 0x294, 0x69C, 0x8AC,
0x105, 0xB0D, 0x71D, 0x525},
{0x408, 0x810, 0xA20, 0x228, 0x081, 0x891, 0x699, 0x2A9, 0x102, 0x50A,
0x71A, 0xB22, 0x4CB, 0x8D3, 0xAE3, 0x2EB, 0x144, 0x954, 0x75C, 0x36C,
0x045, 0x44D, 0x65D, 0xA65},
)
_axis_convert_num = {'X': 0, 'Y': 1, 'Z': 2, '-X': 3, '-Y': 4, '-Z': 5}
def axis_conversion(from_forward='Y', from_up='Z', to_forward='Y', to_up='Z'):
"""
Each argument is an axis
where the first 2 are a source and the second 2 are the target.
:param from_forward: Source forward axis.
:type from_forward: Literal['X', 'Y', 'Z', '-X', '-Y', '-Z']
:param from_up: Source up axis.
:type from_up: Literal['X', 'Y', 'Z', '-X', '-Y', '-Z']
:param to_forward: Target forward axis.
:type to_forward: Literal['X', 'Y', 'Z', '-X', '-Y', '-Z']
:param to_up: Target up axis.
:type to_up: Literal['X', 'Y', 'Z', '-X', '-Y', '-Z']
:return: The conversion matrix.
:rtype: :class:`mathutils.Matrix`
"""
from mathutils import Matrix
from functools import reduce
if from_forward == to_forward and from_up == to_up:
return Matrix().to_3x3()
if from_forward[-1] == from_up[-1] or to_forward[-1] == to_up[-1]:
raise Exception("Invalid axis arguments passed, cannot use up/forward on the same axis")
value = reduce(
int.__or__,
(_axis_convert_num[a] << (i * 3) for i, a in enumerate((
from_forward,
from_up,
to_forward,
to_up,
)))
)
for i, axis_lut in enumerate(_axis_convert_lut):
if value in axis_lut:
return Matrix(_axis_convert_matrix[i])
assert False, "unreachable"
def axis_conversion_ensure(operator, forward_attr, up_attr):
"""
Function to ensure an operator has valid axis conversion settings, intended
to be used from :class:`bpy.types.Operator.check`.
:param operator: the operator to access axis attributes from.
:type operator: :class:`bpy.types.Operator`
:param forward_attr: attribute storing the forward axis
:type forward_attr: str
:param up_attr: attribute storing the up axis
:type up_attr: str
:return: True if the value was modified.
:rtype: bool
"""
def validate(axis_forward, axis_up):
if axis_forward[-1] == axis_up[-1]:
axis_up = axis_up[0:-1] + 'XYZ'[('XYZ'.index(axis_up[-1]) + 1) % 3]
return axis_forward, axis_up
axis = getattr(operator, forward_attr), getattr(operator, up_attr)
axis_new = validate(*axis)
if axis != axis_new:
setattr(operator, forward_attr, axis_new[0])
setattr(operator, up_attr, axis_new[1])
return True
else:
return False
def create_derived_objects(depsgraph, objects):
"""
This function takes a sequence of objects, returning their instances.
:param depsgraph: The evaluated depsgraph.
:type depsgraph: :class:`bpy.types.Depsgraph`
:param objects: A sequence of objects.
:type objects: Sequence[:class:`bpy.types.Object`]
:return: A dictionary where each key is an object from ``objects``,
values are lists of (object, matrix) tuples representing instances.
:rtype: dict[:class:`bpy.types.Object`, list[tuple[:class:`bpy.types.Object`, :class:`mathutils.Matrix`]]]
"""
result = {}
for ob in objects:
ob_parent = ob.parent
if ob_parent and ob_parent.instance_type in {'VERTS', 'FACES'}:
continue
result[ob] = [] if ob.is_instancer else [(ob, ob.matrix_world.copy())]
if result:
for dup in depsgraph.object_instances:
dup_parent = dup.parent
if dup_parent is None:
continue
dup_parent_original = dup_parent.original
if not dup_parent_original.is_instancer:
# The instance has already been added (on assignment).
continue
instance_list = result.get(dup_parent_original)
if instance_list is None:
continue
instance_list.append((dup.instance_object.original, dup.matrix_world.copy()))
return result
def unpack_list(list_of_tuples):
"""
Flatten a sequence of tuples into a single list.
:param list_of_tuples: A sequence of tuples to unpack.
:type list_of_tuples: Sequence[tuple]
:return: A flat list of all values.
:rtype: list
"""
flat_list = []
flat_list_extend = flat_list.extend # a tiny bit faster
for t in list_of_tuples:
flat_list_extend(t)
return flat_list
# same as above except that it adds 0 for triangle faces
def unpack_face_list(list_of_tuples):
"""
Unpack a list of faces (triangles or quads) into a flat list,
padding triangles with a zero to fit into groups of four.
:param list_of_tuples: A sequence of face index tuples (3 or 4 elements each).
:type list_of_tuples: Sequence[tuple[int, ...]]
:return: A flat list of face indices, padded with zeros.
:rtype: list[int]
"""
# allocate the entire list
flat_ls = [0] * (len(list_of_tuples) * 4)
i = 0
for t in list_of_tuples:
if len(t) == 3:
if t[2] == 0:
t = t[1], t[2], t[0]
else: # assume quad
if t[3] == 0 or t[2] == 0:
t = t[2], t[3], t[0], t[1]
flat_ls[i:i + len(t)] = t
i += 4
return flat_ls
def poll_file_object_drop(context):
"""
A default implementation for FileHandler poll_drop methods. Allows for both the 3D Viewport and
the Outliner (in ViewLayer display mode) to be targets for file drag and drop.
:param context: The context.
:type context: :class:`bpy.types.Context`
:return: Whether the drop target is valid.
:rtype: bool
"""
area = context.area
if not area:
return False
is_v3d = area.type == 'VIEW_3D'
is_outliner_view_layer = area.type == 'OUTLINER' and area.spaces.active.display_mode == 'VIEW_LAYER'
return is_v3d or is_outliner_view_layer
path_reference_mode = EnumProperty(
name="Path Mode",
description="Method used to reference paths",
items=(
('AUTO', "Auto", "Use relative paths with subdirectories only"),
('ABSOLUTE', "Absolute", "Always write absolute paths"),
('RELATIVE', "Relative", "Write relative paths where possible"),
('MATCH', "Match", "Match absolute/relative "
"setting with input path"),
('STRIP', "Strip", "Filename only"),
('COPY', "Copy", "Copy the file to the destination path "
"(or subdirectory)"),
),
translation_context=i18n_contexts.editor_filebrowser,
default='AUTO',
)
def path_reference(
filepath,
base_src,
base_dst,
mode='AUTO',
copy_subdir="",
copy_set=None,
library=None,
):
"""
Return a filepath relative to a destination directory, for use with
exporters.
:param filepath: the file path to return,
supporting blenders relative '//' prefix.
:type filepath: str
:param base_src: the directory the *filepath* is relative to
(normally the blend file).
:type base_src: str
:param base_dst: the directory the *filepath* will be referenced from
(normally the export path).
:type base_dst: str
:param mode: the method used to reference the path.
:type mode: Literal['AUTO', 'ABSOLUTE', 'RELATIVE', 'MATCH', 'STRIP', 'COPY']
:param copy_subdir: the subdirectory of *base_dst* to use when mode='COPY'.
:type copy_subdir: str
:param copy_set: collect from/to pairs when mode='COPY',
pass to *path_reference_copy* when exporting is done.
:type copy_set: set[tuple[str, str]] | None
:param library: The library this path is relative to.
:type library: :class:`bpy.types.Library` | None
:return: the new filepath.
:rtype: str
"""
import os
is_relative = filepath.startswith("//")
filepath_abs = bpy.path.abspath(filepath, start=base_src, library=library)
filepath_abs = os.path.normpath(filepath_abs)
if mode in {'ABSOLUTE', 'RELATIVE', 'STRIP'}:
pass
elif mode == 'MATCH':
mode = 'RELATIVE' if is_relative else 'ABSOLUTE'
elif mode == 'AUTO':
mode = (
'RELATIVE' if bpy.path.is_subdir(filepath_abs, base_dst) else
'ABSOLUTE'
)
elif mode == 'COPY':
subdir_abs = os.path.normpath(base_dst)
if copy_subdir:
subdir_abs = os.path.join(subdir_abs, copy_subdir)
filepath_cpy = os.path.join(subdir_abs, os.path.basename(filepath_abs))
copy_set.add((filepath_abs, filepath_cpy))
filepath_abs = filepath_cpy
mode = 'RELATIVE'
else:
raise Exception("invalid mode given {!r}".format(mode))
if mode == 'ABSOLUTE':
return filepath_abs
elif mode == 'RELATIVE':
# can't always find the relative path
# (between drive letters on windows)
try:
return os.path.relpath(filepath_abs, base_dst)
except ValueError:
return filepath_abs
elif mode == 'STRIP':
return os.path.basename(filepath_abs)
def path_reference_copy(copy_set, report=print):
"""
Execute copying files of path_reference
:param copy_set: set of (from, to) pairs to copy.
:type copy_set: set[tuple[str, str]]
:param report: function used for reporting warnings, takes a string argument.
:type report: Callable[[str], None]
"""
if not copy_set:
return
import os
import shutil
for file_src, file_dst in copy_set:
if not os.path.exists(file_src):
report("missing {!r}, not copying".format(file_src))
elif os.path.exists(file_dst) and os.path.samefile(file_src, file_dst):
pass
else:
dir_to = os.path.dirname(file_dst)
try:
os.makedirs(dir_to, exist_ok=True)
except Exception:
import traceback
traceback.print_exc()
try:
shutil.copy(file_src, file_dst)
except Exception:
import traceback
traceback.print_exc()
def unique_name(key, name, name_dict, name_max=-1, clean_func=None, sep="."):
"""
Helper function for storing unique names which may have special characters
stripped and restricted to a maximum length.
:param key: Unique item this name belongs to, name_dict[key] will be reused
when available.
This can be the object, mesh, material, etc instance itself.
Any hashable object associated with the *name*.
:type key: Any
:param name: The name used to create a unique value in *name_dict*.
:type name: str
:param name_dict: This is used to cache namespace to ensure no collisions
occur, this should be an empty dict initially and only modified by this
function.
:type name_dict: dict[Any, str]
:param name_max: Maximum length of the name. When ``-1`` the name is unlimited.
:type name_max: int
:param clean_func: Function to call on *name* before creating a unique value.
:type clean_func: Callable[[str], str] | None
:param sep: Separator to use when between the name and a number when a
duplicate name is found.
:type sep: str
:return: A unique name.
:rtype: str
"""
name_new = name_dict.get(key)
if name_new is None:
count = 1
name_dict_values = name_dict.values()
name_new = name_new_orig = (
name if clean_func is None
else clean_func(name)
)
if name_max == -1:
while name_new in name_dict_values:
name_new = "{:s}{:s}{:03d}".format(
name_new_orig,
sep,
count,
)
count += 1
else:
name_new = name_new[:name_max]
while name_new in name_dict_values:
count_str = "{:03d}".format(count)
name_new = "{:.{:d}s}{:s}{:s}".format(
name_new_orig,
name_max - (len(count_str) + 1),
sep,
count_str,
)
count += 1
name_dict[key] = name_new
return name_new

View File

@@ -0,0 +1,155 @@
# SPDX-FileCopyrightText: 2011-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
__all__ = (
"addon_keymap_register",
"addon_keymap_unregister",
"keyconfig_test",
)
# -----------------------------------------------------------------------------
# Add-on helpers to properly (un)register their own keymaps.
def addon_keymap_register(keymap_data):
"""
Register a set of keymaps for addons using a list of keymaps.
See 'blender_default.py' for examples of the format this takes.
:param keymap_data: A list of keymap definitions to register.
:type keymap_data: list[tuple[str, dict[str, Any], dict[str, Any]]]
"""
import bpy
wm = bpy.context.window_manager
from bl_keymap_utils.io import keymap_init_from_data
kconf = wm.keyconfigs.addon
if not kconf:
return # happens in background mode...
for km_name, km_args, km_content in keymap_data:
km_space_type = km_args["space_type"]
km_region_type = km_args["region_type"]
km_modal = km_args.get("modal", False)
kmap = next(iter(
k for k in kconf.keymaps
if k.name == km_name and
k.region_type == km_region_type and
k.space_type == km_space_type and
k.is_modal == km_modal
), None)
if kmap is None:
kmap = kconf.keymaps.new(km_name, **km_args)
keymap_init_from_data(kmap, km_content["items"], is_modal=km_modal)
def addon_keymap_unregister(keymap_data):
"""
Unregister a set of keymaps for addons.
:param keymap_data: A list of keymap definitions to unregister.
:type keymap_data: list[tuple[str, dict[str, Any], dict[str, Any]]]
"""
# NOTE: We must also clean up user keyconfig, else, if user has customized one of add-on's shortcut, this
# customization remains in memory, and comes back when re-enabling the addon, causing a segfault... :/
import bpy
wm = bpy.context.window_manager
kconfs = wm.keyconfigs
for kconf in (kconfs.user, kconfs.addon):
for km_name, km_args, km_content in keymap_data:
km_space_type = km_args["space_type"]
km_region_type = km_args["region_type"]
km_modal = km_args.get("modal", False)
kmaps = (
k for k in kconf.keymaps
if k.name == km_name and
k.region_type == km_region_type and
k.space_type == km_space_type and
k.is_modal == km_modal
)
for kmap in kmaps:
for kmi_idname, _, _ in km_content["items"]:
for kmi in kmap.keymap_items:
if kmi.idname == kmi_idname:
kmap.keymap_items.remove(kmi)
# NOTE: We won't remove addons keymaps themselves, other addons might also use them!
# -----------------------------------------------------------------------------
# Utility Functions
def keyconfig_test(kc):
"""
Test a key configuration for duplicate key-map item assignments.
:param kc: The key configuration to test.
:type kc: :class:`bpy.types.KeyConfig`
:return: True if any duplicates were found.
:rtype: bool
"""
from bl_keymap_utils.io import kmi_args_as_data
def _kmistr(kmi, is_modal):
if is_modal:
kmi_id = kmi.propvalue
else:
kmi_id = kmi.idname
return "{:s}({:s})".format(kmi_id, kmi_args_as_data(kmi))
def testEntry(kc, entry, src=None, parent=None):
result = False
idname, spaceid, regionid, children = entry
km = kc.keymaps.find(idname, space_type=spaceid, region_type=regionid)
if km:
km = km.active()
is_modal = km.is_modal
if src:
for item in km.keymap_items:
if src.compare(item):
print("===========")
print(parent.name, "[parent]")
print(_kmistr(src, is_modal).strip())
print(km.name, "[child]")
print(_kmistr(item, is_modal).strip())
result = True
for child in children:
if testEntry(kc, child, src, parent):
result = True
else:
for i, src in enumerate(km.keymap_items):
for child in children:
if testEntry(kc, child, src, km):
result = True
for j in range(len(km.keymap_items) - i - 1):
item = km.keymap_items[j + i + 1]
if src.compare(item):
print("===========")
print(km.name, "[self conflict]")
print(_kmistr(src, is_modal).strip())
print(_kmistr(item, is_modal).strip())
result = True
for child in children:
if testEntry(kc, child):
result = True
return result
# -------------------------------------------------------------------------
# Function body
from bl_keymap_utils import keymap_hierarchy
result = False
for entry in keymap_hierarchy.generate():
if testEntry(kc, entry):
result = True
return result

View File

@@ -0,0 +1,475 @@
# SPDX-FileCopyrightText: 2011-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
__all__ = (
"mesh_linked_uv_islands",
"mesh_linked_triangles",
"edge_face_count_dict",
"edge_face_count",
"edge_loops_from_edges",
"ngon_tessellate",
"triangle_random_points",
)
def mesh_linked_uv_islands(mesh):
"""
Returns lists of polygon indices connected by UV islands.
:param mesh: the mesh used to group with.
:type mesh: :class:`bpy.types.Mesh`
:return: list of lists containing polygon indices
:rtype: list[list[int]]
"""
if mesh.polygons and not mesh.uv_layers.active.data:
# Currently, when in edit mode, UV Layer data will always be empty
# when accessed though RNA. This may change in the future.
raise ValueError(
"UV Layers are not currently available from python in Edit Mode. "
"Use bmesh and bpy_extras.bmesh_utils.bmesh_linked_uv_islands instead."
)
uv_loops = [luv.uv[:] for luv in mesh.uv_layers.active.data]
poly_loops = [poly.loop_indices for poly in mesh.polygons]
luv_hash = {}
luv_hash_get = luv_hash.get
luv_hash_ls = [None] * len(uv_loops)
for pi, poly_indices in enumerate(poly_loops):
for li in poly_indices:
uv = uv_loops[li]
uv_hub = luv_hash_get(uv)
if uv_hub is None:
uv_hub = luv_hash[uv] = [pi]
else:
uv_hub.append(pi)
luv_hash_ls[li] = uv_hub
poly_islands = []
# 0 = none, 1 = added, 2 = searched
poly_tag = [0] * len(poly_loops)
while True:
poly_index = -1
for i in range(len(poly_loops)):
if poly_tag[i] == 0:
poly_index = i
break
if poly_index != -1:
island = [poly_index]
poly_tag[poly_index] = 1
poly_islands.append(island)
else:
break # we're done
added = True
while added:
added = False
for poly_index in island[:]:
if poly_tag[poly_index] == 1:
for li in poly_loops[poly_index]:
for poly_index_shared in luv_hash_ls[li]:
if poly_tag[poly_index_shared] == 0:
added = True
poly_tag[poly_index_shared] = 1
island.append(poly_index_shared)
poly_tag[poly_index] = 2
return poly_islands
def mesh_linked_triangles(mesh):
"""
Splits the mesh into connected triangles, use this for separating cubes from
other mesh elements within 1 mesh data-block.
:param mesh: the mesh used to group with.
:type mesh: :class:`bpy.types.Mesh`
:return: Lists of lists containing triangles.
:rtype: list[list[:class:`bpy.types.MeshLoopTriangle`]]
"""
# Build vert face connectivity
vert_tris = [[] for i in range(len(mesh.vertices))]
for t in mesh.loop_triangles:
for v in t.vertices:
vert_tris[v].append(t)
# sort triangles into connectivity groups
tri_groups = [[t] for t in mesh.loop_triangles]
# map old, new tri location
tri_mapping = list(range(len(mesh.loop_triangles)))
# Now clump triangles iteratively
ok = True
while ok:
ok = False
for t in mesh.loop_triangles:
mapped_index = tri_mapping[t.index]
mapped_group = tri_groups[mapped_index]
for v in t.vertices:
for nxt_t in vert_tris[v]:
if nxt_t != t:
nxt_mapped_index = tri_mapping[nxt_t.index]
# We are not a part of the same group
if mapped_index != nxt_mapped_index:
ok = True
# Assign mapping to this group so they
# all map to this group
for grp_t in tri_groups[nxt_mapped_index]:
tri_mapping[grp_t.index] = mapped_index
# Move triangles into this group
mapped_group.extend(tri_groups[nxt_mapped_index])
# remove reference to the list
tri_groups[nxt_mapped_index] = None
# return all tri groups that are not null
# this is all the triangles that are connected in their own lists.
return [tg for tg in tri_groups if tg]
def edge_face_count_dict(mesh):
"""
:param mesh: The mesh to count edges for.
:type mesh: :class:`bpy.types.Mesh`
:return: Dictionary of edge keys with their value set to the number of faces using each edge.
:rtype: dict[tuple[int, int], int]
"""
face_edge_count = {}
loops = mesh.loops
edges = mesh.edges
for poly in mesh.polygons:
for i in poly.loop_indices:
key = edges[loops[i].edge_index].key
try:
face_edge_count[key] += 1
except KeyError:
face_edge_count[key] = 1
return face_edge_count
def edge_face_count(mesh):
"""
:param mesh: The mesh to count edges for.
:type mesh: :class:`bpy.types.Mesh`
:return: list of face users for each item in mesh.edges.
:rtype: list[int]
"""
edge_face_count = edge_face_count_dict(mesh)
get = dict.get
return [get(edge_face_count, ed.key, 0) for ed in mesh.edges]
def edge_loops_from_edges(mesh, edges=None):
"""
Edge loops defined by edges.
Takes mesh.edges or a list of edges and returns the edge loops
as a list of vertex indices.
Closed loops have matching start and end values.
:param mesh: The mesh to extract edge loops from.
:type mesh: :class:`bpy.types.Mesh`
:param edges: Edges to use, or None to use all edges in the mesh.
:type edges: list[:class:`bpy.types.MeshEdge`] | None
:return: A list of edge loops, each a list of vertex indices.
:rtype: list[list[int]]
"""
line_polys = []
# Get edges not used by a face
if edges is None:
edges = mesh.edges
if not hasattr(edges, "pop"):
edges = edges[:]
while edges:
current_edge = edges.pop()
vert_end, vert_start = current_edge.vertices[:]
line_poly = [vert_start, vert_end]
ok = True
while ok:
ok = False
# for i, ed in enumerate(edges):
i = len(edges)
while i:
i -= 1
ed = edges[i]
v1, v2 = ed.vertices
if v1 == vert_end:
line_poly.append(v2)
vert_end = line_poly[-1]
ok = 1
del edges[i]
# break
elif v2 == vert_end:
line_poly.append(v1)
vert_end = line_poly[-1]
ok = 1
del edges[i]
# break
elif v1 == vert_start:
line_poly.insert(0, v2)
vert_start = line_poly[0]
ok = 1
del edges[i]
# break
elif v2 == vert_start:
line_poly.insert(0, v1)
vert_start = line_poly[0]
ok = 1
del edges[i]
# break
line_polys.append(line_poly)
return line_polys
def ngon_tessellate(from_data, indices, fix_loops=True, debug_print=True):
"""
Takes a poly-line of indices (ngon) and returns a list of face
index lists. Designed to be used for importers that need indices for an
ngon to create from existing verts.
:param from_data: Either a mesh, or a list/tuple of 3D vectors.
:type from_data: :class:`bpy.types.Mesh` | list[Sequence[float]] | tuple[Sequence[float]]
:param indices: a list of indices to use.
This list is the ordered closed poly-line to fill, and can be a subset of the data given.
:type indices: list[int]
:param fix_loops: If this is enabled poly-lines
that use loops to make multiple
poly-lines are dealt with correctly.
:type fix_loops: bool
:param debug_print: Print debug information to the console.
:type debug_print: bool
:return: Tessellated faces as a list of triangle index tuples.
:rtype: list[tuple[int, int, int]]
"""
from mathutils.geometry import tessellate_polygon
from mathutils import Vector
vector_to_tuple = Vector.to_tuple
if not indices:
return []
def mlen(co):
# Manhattan length of a vector, faster then length.
return abs(co[0]) + abs(co[1]) + abs(co[2])
def vert_from_vector_with_extra_data(v, i):
# Calculate data per-vector, for reuse.
return v, vector_to_tuple(v, 6), i, mlen(v)
def ed_key_mlen(v1, v2):
if v1[3] > v2[3]:
return v2[1], v1[1]
else:
return v1[1], v2[1]
if not fix_loops:
# Normal single concave loop filling.
if type(from_data) in {tuple, list}:
verts = [Vector(from_data[i]) for ii, i in enumerate(indices)]
else:
verts = [from_data.vertices[i].co for ii, i in enumerate(indices)]
# same as reversed(range(1, len(verts))):
for i in range(len(verts) - 1, 0, -1):
if verts[i][1] == verts[i - 1][0]:
verts.pop(i - 1)
fill = tessellate_polygon([verts])
else:
# Separate this loop into multiple loops be finding edges that are
# used twice. This is used by Light-Wave LWO files a lot.
if type(from_data) in {tuple, list}:
verts = [
vert_from_vector_with_extra_data(Vector(from_data[i]), ii)
for ii, i in enumerate(indices)
]
else:
verts = [
vert_from_vector_with_extra_data(from_data.vertices[i].co, ii)
for ii, i in enumerate(indices)
]
edges = [(i, i - 1) for i in range(len(verts))]
if edges:
edges[0] = (0, len(verts) - 1)
if not verts:
return []
edges_used = set()
edges_doubles = set()
# We need to check if any edges are used twice location based.
for ed in edges:
edkey = ed_key_mlen(verts[ed[0]], verts[ed[1]])
if edkey in edges_used:
edges_doubles.add(edkey)
else:
edges_used.add(edkey)
# Store a list of unconnected loop segments split by double edges.
# will join later
loop_segments = []
v_prev = verts[0]
context_loop = [v_prev]
loop_segments = [context_loop]
for v in verts:
if v != v_prev:
# Are we crossing an edge we removed?
if ed_key_mlen(v, v_prev) in edges_doubles:
context_loop = [v]
loop_segments.append(context_loop)
else:
if context_loop and context_loop[-1][1] == v[1]:
pass
else:
context_loop.append(v)
v_prev = v
# Now join loop segments
def join_seg(s1, s2):
if s2[-1][1] == s1[0][1]:
s1, s2 = s2, s1
elif s1[-1][1] == s2[0][1]:
pass
else:
return False
# If were still here s1 and s2 are 2 segments in the same poly-line.
s1.pop() # remove the last vert from s1
s1.extend(s2) # add segment 2 to segment 1
if s1[0][1] == s1[-1][1]: # remove endpoints double
s1.pop()
del s2[:] # Empty this segment s2 so we don't use it again.
return True
joining_segments = True
while joining_segments:
joining_segments = False
segcount = len(loop_segments)
for j in range(segcount - 1, -1, -1): # reversed(range(segcount)):
seg_j = loop_segments[j]
if seg_j:
for k in range(j - 1, -1, -1): # reversed(range(j)):
if not seg_j:
break
seg_k = loop_segments[k]
if seg_k and join_seg(seg_j, seg_k):
joining_segments = True
loop_list = loop_segments
for verts in loop_list:
while verts and verts[0][1] == verts[-1][1]:
verts.pop()
loop_list = [verts for verts in loop_list if len(verts) > 2]
# DONE DEALING WITH LOOP FIXING
# vert mapping
vert_map = [None] * len(indices)
ii = 0
for verts in loop_list:
if len(verts) > 2:
for i, vert in enumerate(verts):
vert_map[i + ii] = vert[2]
ii += len(verts)
fill = tessellate_polygon([[v[0] for v in loop] for loop in loop_list])
# draw_loops(loop_list)
# raise Exception("done loop")
# map to original indices
fill = [[vert_map[i] for i in f] for f in fill]
if not fill:
if debug_print:
print('Warning Cannot scan-fill, fallback on a triangle fan.')
fill = [[0, i - 1, i] for i in range(2, len(indices))]
else:
# Use real scan-fill.
# See if its flipped the wrong way.
flip = None
for fi in fill:
if flip is not None:
break
for i, vi in enumerate(fi):
if vi == 0 and fi[i - 1] == 1:
flip = False
break
elif vi == 1 and fi[i - 1] == 0:
flip = True
break
if not flip:
for i, fi in enumerate(fill):
fill[i] = tuple(reversed(fi))
return fill
def triangle_random_points(num_points, loop_triangles):
"""
Generates a list of random points over mesh loop triangles.
:param num_points: The number of random points to generate on each triangle.
:type num_points: int
:param loop_triangles: Sequence of the triangles to generate points on.
:type loop_triangles: Sequence[:class:`bpy.types.MeshLoopTriangle`]
:return: List of random points over all triangles.
:rtype: list[:class:`mathutils.Vector`]
"""
from random import random
# For each triangle, generate the required number of random points
sampled_points = [None] * (num_points * len(loop_triangles))
for i, lt in enumerate(loop_triangles):
# Get triangle vertex coordinates
verts = lt.id_data.vertices
ltv = lt.vertices[:]
tv = (verts[ltv[0]].co, verts[ltv[1]].co, verts[ltv[2]].co)
for k in range(num_points):
u1 = random()
u2 = random()
u_tot = u1 + u2
if u_tot > 1:
u1 = 1.0 - u1
u2 = 1.0 - u2
side1 = tv[1] - tv[0]
side2 = tv[2] - tv[0]
p = tv[0] + u1 * side1 + u2 * side2
sampled_points[num_points * i + k] = p
return sampled_points

View File

@@ -0,0 +1,833 @@
# SPDX-FileCopyrightText: 2018-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
from mathutils import Color, Vector
__all__ = (
"PrincipledBSDFWrapper",
)
def _set_check(func):
from functools import wraps
@wraps(func)
def wrapper(self, *args, **kwargs):
if self.is_readonly:
assert not "Trying to set value to read-only shader!"
return
return func(self, *args, **kwargs)
return wrapper
def rgb_to_rgba(rgb):
return list(rgb) + [1.0]
def rgba_to_rgb(rgba):
return Color((rgba[0], rgba[1], rgba[2]))
# All clamping value shall follow Blender's defined min/max (check relevant node definition .c file).
def values_clamp(val, minv, maxv):
if hasattr(val, "__iter__"):
return tuple(max(minv, min(maxv, v)) for v in val)
else:
return max(minv, min(maxv, val))
# TODO: Consider moving node_input_value_set/node_input_value_get into a common utility module if
# more usage merits doing so. If that is done, abstract out the validity check and make it usable
# for node outputs as well. See PR #119354 for details.
def node_input_value_set(node, input, value):
if node is None or input not in node.inputs:
return
node.inputs[input].default_value = value
def node_input_value_get(node, input, default_value=None):
if node is None or input not in node.inputs:
return default_value
return node.inputs[input].default_value
class ShaderWrapper:
"""
Base class with minimal common ground for all types of shader interfaces we may want/need to implement.
"""
# The two mandatory nodes any children class should support.
NODES_LIST = (
"node_out",
"_node_texcoords",
)
__slots__ = (
"is_readonly",
"material",
"_textures",
"_grid_locations",
*NODES_LIST,
)
_col_size = 300
_row_size = 300
def _grid_to_location(self, x, y, dst_node=None, ref_node=None):
if ref_node is not None: # x and y are relative to this node location.
nx = round(ref_node.location.x / self._col_size)
ny = round(ref_node.location.y / self._row_size)
x += nx
y += ny
loc = None
while True:
loc = (x * self._col_size, y * self._row_size)
if loc not in self._grid_locations:
break
loc = (x * self._col_size, (y - 1) * self._row_size)
if loc not in self._grid_locations:
break
loc = (x * self._col_size, (y - 2) * self._row_size)
if loc not in self._grid_locations:
break
x -= 1
self._grid_locations.add(loc)
if dst_node is not None:
dst_node.location = loc
dst_node.width = min(dst_node.width, self._col_size - 20)
return loc
def __init__(self, material, is_readonly=True):
self.is_readonly = is_readonly
self.material = material
self.update()
def update(self): # Should be re-implemented by children classes...
for node in self.NODES_LIST:
setattr(self, node, None)
self._textures = {}
self._grid_locations = set()
def node_texcoords_get(self):
if self._node_texcoords is ...:
# Running only once, trying to find a valid texcoords node.
for n in self.material.node_tree.nodes:
if n.bl_idname == 'ShaderNodeTexCoord':
self._node_texcoords = n
self._grid_to_location(0, 0, ref_node=n)
break
if self._node_texcoords is ...:
self._node_texcoords = None
if self._node_texcoords is None and not self.is_readonly:
tree = self.material.node_tree
nodes = tree.nodes
# links = tree.links
node_texcoords = nodes.new(type='ShaderNodeTexCoord')
node_texcoords.label = "Texture Coords"
self._grid_to_location(-5, 1, dst_node=node_texcoords)
self._node_texcoords = node_texcoords
return self._node_texcoords
node_texcoords = property(node_texcoords_get)
class PrincipledBSDFWrapper(ShaderWrapper):
"""
Hard coded shader setup, based in Principled BSDF.
Should cover most common cases on import, and gives a basic nodal shaders support for export.
Supports basic: diffuse/spec/reflect/transparency/normal, with texturing.
"""
NODES_LIST = (
"node_out",
"node_principled_bsdf",
"_node_normalmap",
"_node_texcoords",
)
__slots__ = (
"is_readonly",
"material",
*NODES_LIST,
)
NODES_LIST = ShaderWrapper.NODES_LIST + NODES_LIST
def __init__(self, material, is_readonly=True):
super(PrincipledBSDFWrapper, self).__init__(material, is_readonly)
def update(self):
super(PrincipledBSDFWrapper, self).update()
tree = self.material.node_tree
nodes = tree.nodes
links = tree.links
# --------------------------------------------------------------------
# Main output and shader.
node_out = None
node_principled = None
for n in nodes:
if n.bl_idname == 'ShaderNodeOutputMaterial' and n.inputs[0].is_linked:
node_out = n
node_principled = n.inputs[0].links[0].from_node
elif n.bl_idname == 'ShaderNodeBsdfPrincipled' and n.outputs[0].is_linked:
node_principled = n
for lnk in n.outputs[0].links:
node_out = lnk.to_node
if node_out.bl_idname == 'ShaderNodeOutputMaterial':
break
if (
node_out is not None and node_principled is not None and
node_out.bl_idname == 'ShaderNodeOutputMaterial' and
node_principled.bl_idname == 'ShaderNodeBsdfPrincipled'
):
break
node_out = node_principled = None # Could not find a valid pair, let's try again
if node_out is not None:
self._grid_to_location(0, 0, ref_node=node_out)
elif not self.is_readonly:
node_out = nodes.new(type='ShaderNodeOutputMaterial')
node_out.label = "Material Out"
node_out.target = 'ALL'
self._grid_to_location(1, 1, dst_node=node_out)
self.node_out = node_out
if node_principled is not None:
self._grid_to_location(0, 0, ref_node=node_principled)
elif not self.is_readonly:
node_principled = nodes.new(type='ShaderNodeBsdfPrincipled')
node_principled.label = "Principled BSDF"
self._grid_to_location(0, 1, dst_node=node_principled)
# Link
links.new(node_principled.outputs["BSDF"], self.node_out.inputs["Surface"])
self.node_principled_bsdf = node_principled
# --------------------------------------------------------------------
# Normal Map, lazy initialization...
self._node_normalmap = ...
# --------------------------------------------------------------------
# Tex Coords, lazy initialization...
self._node_texcoords = ...
def node_normalmap_get(self):
if self.node_principled_bsdf is None:
return None
node_principled = self.node_principled_bsdf
if self._node_normalmap is ...:
# Running only once, trying to find a valid normalmap node.
if node_principled.inputs["Normal"].is_linked:
node_normalmap = node_principled.inputs["Normal"].links[0].from_node
if node_normalmap.bl_idname == 'ShaderNodeNormalMap':
self._node_normalmap = node_normalmap
self._grid_to_location(0, 0, ref_node=node_normalmap)
if self._node_normalmap is ...:
self._node_normalmap = None
if self._node_normalmap is None and not self.is_readonly:
tree = self.material.node_tree
nodes = tree.nodes
links = tree.links
node_normalmap = nodes.new(type='ShaderNodeNormalMap')
node_normalmap.label = "Normal/Map"
self._grid_to_location(-1, -2, dst_node=node_normalmap, ref_node=node_principled)
# Link
links.new(node_normalmap.outputs["Normal"], node_principled.inputs["Normal"])
self._node_normalmap = node_normalmap
return self._node_normalmap
node_normalmap = property(node_normalmap_get)
# --------------------------------------------------------------------
# Base Color.
def base_color_get(self):
if self.node_principled_bsdf is None:
return self.material.diffuse_color
return rgba_to_rgb(self.node_principled_bsdf.inputs["Base Color"].default_value)
@_set_check
def base_color_set(self, color):
color = values_clamp(color, 0.0, 1.0)
color = rgb_to_rgba(color)
self.material.diffuse_color = color
if self.node_principled_bsdf is not None:
self.node_principled_bsdf.inputs["Base Color"].default_value = color
base_color = property(base_color_get, base_color_set)
def base_color_texture_get(self):
if self.node_principled_bsdf is None:
return None
return ShaderImageTextureWrapper(
self, self.node_principled_bsdf,
self.node_principled_bsdf.inputs["Base Color"],
grid_row_diff=1,
)
base_color_texture = property(base_color_texture_get)
# --------------------------------------------------------------------
# Specular.
def specular_get(self):
if self.node_principled_bsdf is None:
return self.material.specular_intensity
return self.node_principled_bsdf.inputs["Specular IOR Level"].default_value
@_set_check
def specular_set(self, value):
value = values_clamp(value, 0.0, 1.0)
self.material.specular_intensity = value
if self.node_principled_bsdf is not None:
self.node_principled_bsdf.inputs["Specular IOR Level"].default_value = value
specular = property(specular_get, specular_set)
# Will only be used as gray-scale one...
def specular_texture_get(self):
if self.node_principled_bsdf is None:
return None
return ShaderImageTextureWrapper(
self, self.node_principled_bsdf,
self.node_principled_bsdf.inputs["Specular IOR Level"],
grid_row_diff=0,
colorspace_name='Non-Color',
)
specular_texture = property(specular_texture_get)
# --------------------------------------------------------------------
# Specular Tint.
def specular_tint_get(self):
if self.node_principled_bsdf is None:
return Color((0.0, 0.0, 0.0))
return rgba_to_rgb(self.node_principled_bsdf.inputs["Specular Tint"].default_value)
@_set_check
def specular_tint_set(self, color):
color = values_clamp(color, 0.0, 1.0)
color = rgb_to_rgba(color)
if self.node_principled_bsdf is not None:
self.node_principled_bsdf.inputs["Specular Tint"].default_value = color
specular_tint = property(specular_tint_get, specular_tint_set)
def specular_tint_texture_get(self):
if self.node_principled_bsdf is None:
return None
return ShaderImageTextureWrapper(
self, self.node_principled_bsdf,
self.node_principled_bsdf.inputs["Specular Tint"],
grid_row_diff=0,
)
specular_tint_texture = property(specular_tint_texture_get)
# --------------------------------------------------------------------
# Roughness (also sort of inverse of specular hardness...).
def roughness_get(self):
if self.node_principled_bsdf is None:
return self.material.roughness
return self.node_principled_bsdf.inputs["Roughness"].default_value
@_set_check
def roughness_set(self, value):
value = values_clamp(value, 0.0, 1.0)
self.material.roughness = value
if self.node_principled_bsdf is not None:
self.node_principled_bsdf.inputs["Roughness"].default_value = value
roughness = property(roughness_get, roughness_set)
# Will only be used as gray-scale one...
def roughness_texture_get(self):
if self.node_principled_bsdf is None:
return None
return ShaderImageTextureWrapper(
self, self.node_principled_bsdf,
self.node_principled_bsdf.inputs["Roughness"],
grid_row_diff=0,
colorspace_name='Non-Color',
)
roughness_texture = property(roughness_texture_get)
# --------------------------------------------------------------------
# Metallic (a.k.a reflection, mirror).
def metallic_get(self):
if self.node_principled_bsdf is None:
return self.material.metallic
return self.node_principled_bsdf.inputs["Metallic"].default_value
@_set_check
def metallic_set(self, value):
value = values_clamp(value, 0.0, 1.0)
self.material.metallic = value
if self.node_principled_bsdf is not None:
self.node_principled_bsdf.inputs["Metallic"].default_value = value
metallic = property(metallic_get, metallic_set)
# Will only be used as gray-scale one...
def metallic_texture_get(self):
if self.node_principled_bsdf is None:
return None
return ShaderImageTextureWrapper(
self, self.node_principled_bsdf,
self.node_principled_bsdf.inputs["Metallic"],
grid_row_diff=0,
colorspace_name="Non-Color",
)
metallic_texture = property(metallic_texture_get)
# --------------------------------------------------------------------
# Transparency settings.
def ior_get(self):
if self.node_principled_bsdf is None:
return 1.0
return self.node_principled_bsdf.inputs["IOR"].default_value
@_set_check
def ior_set(self, value):
value = values_clamp(value, 0.0, 1000.0)
if self.node_principled_bsdf is not None:
self.node_principled_bsdf.inputs["IOR"].default_value = value
ior = property(ior_get, ior_set)
# Will only be used as gray-scale one...
def ior_texture_get(self):
if self.node_principled_bsdf is None:
return None
return ShaderImageTextureWrapper(
self, self.node_principled_bsdf,
self.node_principled_bsdf.inputs["IOR"],
grid_row_diff=-1,
colorspace_name='Non-Color',
)
ior_texture = property(ior_texture_get)
def transmission_get(self):
if self.node_principled_bsdf is None:
return 0.0
return self.node_principled_bsdf.inputs["Transmission Weight"].default_value
@_set_check
def transmission_set(self, value):
value = values_clamp(value, 0.0, 1.0)
if self.node_principled_bsdf is not None:
self.node_principled_bsdf.inputs["Transmission Weight"].default_value = value
transmission = property(transmission_get, transmission_set)
# Will only be used as gray-scale one...
def transmission_texture_get(self):
if self.node_principled_bsdf is None:
return None
return ShaderImageTextureWrapper(
self, self.node_principled_bsdf,
self.node_principled_bsdf.inputs["Transmission Weight"],
grid_row_diff=-1,
colorspace_name='Non-Color',
)
transmission_texture = property(transmission_texture_get)
def alpha_get(self):
if self.node_principled_bsdf is None:
return 1.0
return self.node_principled_bsdf.inputs["Alpha"].default_value
@_set_check
def alpha_set(self, value):
value = values_clamp(value, 0.0, 1.0)
if self.node_principled_bsdf is not None:
self.node_principled_bsdf.inputs["Alpha"].default_value = value
alpha = property(alpha_get, alpha_set)
# Will only be used as gray-scale one...
def alpha_texture_get(self):
if self.node_principled_bsdf is None:
return None
return ShaderImageTextureWrapper(
self, self.node_principled_bsdf,
self.node_principled_bsdf.inputs["Alpha"],
use_alpha=True,
grid_row_diff=-1,
colorspace_name='Non-Color',
)
alpha_texture = property(alpha_texture_get)
# --------------------------------------------------------------------
# Emission color.
def emission_color_get(self):
if self.node_principled_bsdf is None:
return Color((0.0, 0.0, 0.0))
return rgba_to_rgb(self.node_principled_bsdf.inputs["Emission Color"].default_value)
@_set_check
def emission_color_set(self, color):
if self.node_principled_bsdf is not None:
color = values_clamp(color, 0.0, 1000000.0)
color = rgb_to_rgba(color)
self.node_principled_bsdf.inputs["Emission Color"].default_value = color
emission_color = property(emission_color_get, emission_color_set)
def emission_color_texture_get(self):
if self.node_principled_bsdf is None:
return None
return ShaderImageTextureWrapper(
self, self.node_principled_bsdf,
self.node_principled_bsdf.inputs["Emission Color"],
grid_row_diff=1,
)
emission_color_texture = property(emission_color_texture_get)
def emission_strength_get(self):
if self.node_principled_bsdf is None:
return 1.0
return self.node_principled_bsdf.inputs["Emission Strength"].default_value
@_set_check
def emission_strength_set(self, value):
value = values_clamp(value, 0.0, 1000000.0)
if self.node_principled_bsdf is not None:
self.node_principled_bsdf.inputs["Emission Strength"].default_value = value
emission_strength = property(emission_strength_get, emission_strength_set)
def emission_strength_texture_get(self):
if self.node_principled_bsdf is None:
return None
return ShaderImageTextureWrapper(
self, self.node_principled_bsdf,
self.node_principled_bsdf.inputs["Emission Strength"],
grid_row_diff=-1,
colorspace_name='Non-Color',
)
emission_strength_texture = property(emission_strength_texture_get)
# --------------------------------------------------------------------
# Normal map.
def normalmap_strength_get(self):
if self.node_normalmap is None:
return 0.0
return self.node_normalmap.inputs["Strength"].default_value
@_set_check
def normalmap_strength_set(self, value):
value = values_clamp(value, 0.0, 10.0)
if self.node_normalmap is not None:
self.node_normalmap.inputs["Strength"].default_value = value
normalmap_strength = property(normalmap_strength_get, normalmap_strength_set)
def normalmap_texture_get(self):
if self.node_normalmap is None:
return None
return ShaderImageTextureWrapper(
self, self.node_normalmap,
self.node_normalmap.inputs["Color"],
grid_row_diff=-2,
colorspace_is_data=True,
)
normalmap_texture = property(normalmap_texture_get)
class ShaderImageTextureWrapper:
"""
Generic 'image texture'-like wrapper, handling image node, some mapping (texture coordinates transformations),
and texture coordinates source.
"""
# Note: this class assumes we are using nodes, otherwise it should never be used...
NODES_LIST = (
"node_dst",
"socket_dst",
"_node_image",
"_node_mapping",
)
__slots__ = (
"owner_shader",
"is_readonly",
"grid_row_diff",
"use_alpha",
"colorspace_is_data",
"colorspace_name",
*NODES_LIST,
)
def __new__(cls, owner_shader: ShaderWrapper, node_dst, socket_dst, *_args, **_kwargs):
instance = owner_shader._textures.get((node_dst, socket_dst), None)
if instance is not None:
return instance
instance = super(ShaderImageTextureWrapper, cls).__new__(cls)
owner_shader._textures[(node_dst, socket_dst)] = instance
return instance
def __init__(
self, owner_shader: ShaderWrapper, node_dst, socket_dst, grid_row_diff=0,
use_alpha=False, colorspace_is_data=..., colorspace_name=...,
):
self.owner_shader = owner_shader
self.is_readonly = owner_shader.is_readonly
self.node_dst = node_dst
self.socket_dst = socket_dst
self.grid_row_diff = grid_row_diff
self.use_alpha = use_alpha
self.colorspace_is_data = colorspace_is_data
self.colorspace_name = colorspace_name
self._node_image = ...
self._node_mapping = ...
# tree = node_dst.id_data
# nodes = tree.nodes
# links = tree.links
if socket_dst.is_linked:
from_node = socket_dst.links[0].from_node
if from_node.bl_idname == 'ShaderNodeTexImage':
self._node_image = from_node
if self.node_image is not None:
socket_dst = self.node_image.inputs["Vector"]
if socket_dst.is_linked:
from_node = socket_dst.links[0].from_node
if from_node.bl_idname == 'ShaderNodeMapping':
self._node_mapping = from_node
def copy_from(self, tex):
# Avoid generating any node in source texture.
is_readonly_back = tex.is_readonly
tex.is_readonly = True
if tex.node_image is not None:
self.image = tex.image
self.projection = tex.projection
self.texcoords = tex.texcoords
self.copy_mapping_from(tex)
tex.is_readonly = is_readonly_back
def copy_mapping_from(self, tex):
# Avoid generating any node in source texture.
is_readonly_back = tex.is_readonly
tex.is_readonly = True
if tex.node_mapping is None: # Used to actually remove mapping node.
if self.has_mapping_node():
# We assume node_image can never be None in that case...
# Find potential existing link into image's Vector input.
socket_dst = socket_src = None
if self.node_mapping.inputs["Vector"].is_linked:
socket_dst = self.node_image.inputs["Vector"]
socket_src = self.node_mapping.inputs["Vector"].links[0].from_socket
tree = self.owner_shader.material.node_tree
tree.nodes.remove(self.node_mapping)
self._node_mapping = None
# If previously existing, re-link texcoords -> image
if socket_src is not None:
tree.links.new(socket_src, socket_dst)
elif self.node_mapping is not None:
self.translation = tex.translation
self.rotation = tex.rotation
self.scale = tex.scale
tex.is_readonly = is_readonly_back
# --------------------------------------------------------------------
# Image.
def node_image_get(self):
if self._node_image is ...:
# Running only once, trying to find a valid image node.
if self.socket_dst.is_linked:
node_image = self.socket_dst.links[0].from_node
if node_image.bl_idname == 'ShaderNodeTexImage':
self._node_image = node_image
self.owner_shader._grid_to_location(0, 0, ref_node=node_image)
if self._node_image is ...:
self._node_image = None
if self._node_image is None and not self.is_readonly:
tree = self.owner_shader.material.node_tree
node_image = tree.nodes.new(type='ShaderNodeTexImage')
self.owner_shader._grid_to_location(
-1, 0 + self.grid_row_diff,
dst_node=node_image, ref_node=self.node_dst,
)
tree.links.new(node_image.outputs["Alpha" if self.use_alpha else "Color"], self.socket_dst)
if self.use_alpha:
self.owner_shader.material.surface_render_method = 'DITHERED'
self.owner_shader.material.use_transparency_overlap = False
self._node_image = node_image
return self._node_image
node_image = property(node_image_get)
def image_get(self):
return self.node_image.image if self.node_image is not None else None
@_set_check
def image_set(self, image):
if self.colorspace_is_data is not ...:
if image.colorspace_settings.is_data != self.colorspace_is_data and image.users >= 1:
image = image.copy()
image.colorspace_settings.is_data = self.colorspace_is_data
if self.colorspace_name is not ...:
if image.colorspace_settings.name != self.colorspace_name and image.users >= 1:
image = image.copy()
image.colorspace_settings.name = self.colorspace_name
if self.use_alpha:
# Try to be smart, and only use image's alpha output if image actually has alpha data.
tree = self.owner_shader.material.node_tree
if image.channels < 4 or image.depth in {24, 8}:
tree.links.new(self.node_image.outputs["Color"], self.socket_dst)
else:
tree.links.new(self.node_image.outputs["Alpha"], self.socket_dst)
self.node_image.image = image
image = property(image_get, image_set)
def projection_get(self):
return self.node_image.projection if self.node_image is not None else 'FLAT'
@_set_check
def projection_set(self, projection):
self.node_image.projection = projection
projection = property(projection_get, projection_set)
def texcoords_get(self):
if self.node_image is not None:
socket = (self.node_mapping if self.has_mapping_node() else self.node_image).inputs["Vector"]
if socket.is_linked:
return socket.links[0].from_socket.name
return 'UV'
@_set_check
def texcoords_set(self, texcoords):
# Image texture node already defaults to UVs, no extra node needed.
# ONLY in case we do not have any texcoords mapping!!!
if texcoords == 'UV' and not self.has_mapping_node():
return
tree = self.node_image.id_data
links = tree.links
node_dst = self.node_mapping if self.has_mapping_node() else self.node_image
socket_src = self.owner_shader.node_texcoords.outputs[texcoords]
links.new(socket_src, node_dst.inputs["Vector"])
texcoords = property(texcoords_get, texcoords_set)
def extension_get(self):
return self.node_image.extension if self.node_image is not None else 'REPEAT'
@_set_check
def extension_set(self, extension):
self.node_image.extension = extension
extension = property(extension_get, extension_set)
# --------------------------------------------------------------------
# Mapping.
def has_mapping_node(self):
return self._node_mapping not in {None, ...}
def node_mapping_get(self):
if self._node_mapping is ...:
# Running only once, trying to find a valid mapping node.
if self.node_image is None:
return None
if self.node_image.inputs["Vector"].is_linked:
node_mapping = self.node_image.inputs["Vector"].links[0].from_node
if node_mapping.bl_idname == 'ShaderNodeMapping':
self._node_mapping = node_mapping
self.owner_shader._grid_to_location(0, 0 + self.grid_row_diff, ref_node=node_mapping)
if self._node_mapping is ...:
self._node_mapping = None
if self._node_mapping is None and not self.is_readonly:
# Find potential existing link into image's Vector input.
socket_dst = self.node_image.inputs["Vector"]
# If not already existing, we need to create texcoords -> mapping link (from UV).
socket_src = (
socket_dst.links[0].from_socket if socket_dst.is_linked
else self.owner_shader.node_texcoords.outputs['UV']
)
tree = self.owner_shader.material.node_tree
node_mapping = tree.nodes.new(type='ShaderNodeMapping')
node_mapping.vector_type = 'TEXTURE'
self.owner_shader._grid_to_location(-1, 0, dst_node=node_mapping, ref_node=self.node_image)
# Link mapping -> image node.
tree.links.new(node_mapping.outputs["Vector"], socket_dst)
# Link texcoords -> mapping.
tree.links.new(socket_src, node_mapping.inputs["Vector"])
self._node_mapping = node_mapping
return self._node_mapping
node_mapping = property(node_mapping_get)
def translation_get(self):
return node_input_value_get(self.node_mapping, "Location", Vector((0.0, 0.0, 0.0)))
@_set_check
def translation_set(self, translation):
node_input_value_set(self.node_mapping, "Location", translation)
translation = property(translation_get, translation_set)
def rotation_get(self):
if self.node_mapping is None:
return Vector((0.0, 0.0, 0.0))
return self.node_mapping.inputs["Rotation"].default_value
@_set_check
def rotation_set(self, rotation):
self.node_mapping.inputs["Rotation"].default_value = rotation
rotation = property(rotation_get, rotation_set)
def scale_get(self):
if self.node_mapping is None:
return Vector((1.0, 1.0, 1.0))
return self.node_mapping.inputs["Scale"].default_value
@_set_check
def scale_set(self, scale):
self.node_mapping.inputs["Scale"].default_value = scale
scale = property(scale_get, scale_set)

View File

@@ -0,0 +1,111 @@
# SPDX-FileCopyrightText: 2009-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
__all__ = (
"connect_sockets",
"find_base_socket_type",
"find_node_input",
)
def find_base_socket_type(socket):
"""
Find the base class of the socket.
Sockets can have a subtype such as NodeSocketFloatFactor,
but only the base type is allowed, e.g. NodeSocketFloat
:param socket: The socket to find the base type for.
:type socket: :class:`bpy.types.NodeSocket`
:return: The base socket type identifier.
:rtype: str
"""
if socket.type == 'CUSTOM':
# Custom socket types are used directly
return socket.bl_idname
if socket.type == 'VALUE':
return 'NodeSocketFloat'
if socket.type == 'INT':
return 'NodeSocketInt'
if socket.type == 'BOOLEAN':
return 'NodeSocketBool'
if socket.type == 'VECTOR':
return 'NodeSocketVector'
if socket.type == 'ROTATION':
return 'NodeSocketRotation'
if socket.type == 'STRING':
return 'NodeSocketString'
if socket.type == 'RGBA':
return 'NodeSocketColor'
if socket.type == 'SHADER':
return 'NodeSocketShader'
if socket.type == 'OBJECT':
return 'NodeSocketObject'
if socket.type == 'IMAGE':
return 'NodeSocketImage'
if socket.type == 'GEOMETRY':
return 'NodeSocketGeometry'
if socket.type == 'COLLECTION':
return 'NodeSocketCollection'
if socket.type == 'TEXTURE':
return 'NodeSocketTexture'
if socket.type == 'MATERIAL':
return 'NodeSocketMaterial'
def connect_sockets(input, output):
"""
Connect sockets in a node tree.
This is useful because the links created through the normal Python API are
invalid when one of the sockets is a virtual socket (grayed out sockets in
Group Input and Group Output nodes).
It replaces node_tree.links.new(input, output)
:param input: The input socket.
:type input: :class:`bpy.types.NodeSocket`
:param output: The output socket.
:type output: :class:`bpy.types.NodeSocket`
:return: The created link, or ``None`` when the sockets cannot be connected.
:rtype: :class:`bpy.types.NodeLink` | None
"""
import bpy
# Swap sockets if they are not passed in the proper order
if input.is_output and not output.is_output:
input, output = output, input
input_node = output.node
output_node = input.node
if input_node.id_data is not output_node.id_data:
print("Sockets do not belong to the same node tree")
return
if type(input) == type(output) == bpy.types.NodeSocketVirtual:
print("Cannot connect two virtual sockets together")
return
return input_node.id_data.links.new(input, output, handle_dynamic_sockets=True)
def find_node_input(node, name):
"""
Find a node input socket by name.
Note that names are not unique, returns the first match.
:param node: The node to search.
:type node: :class:`bpy.types.Node`
:param name: The name of the input socket.
:type name: str
:return: The input socket or None if not found.
:rtype: :class:`bpy.types.NodeSocket` | None
"""
for input in node.inputs:
if input.name == name:
return input
return None

View File

@@ -0,0 +1,319 @@
# SPDX-FileCopyrightText: 2010-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
from __future__ import annotations
__all__ = (
"add_object_align_init",
"object_data_add",
"AddObjectHelper",
"object_add_grid_scale",
"object_add_grid_scale_apply_operator",
"world_to_camera_view",
"object_report_if_active_shape_key_is_locked",
)
import bpy
from bpy.props import (
FloatVectorProperty,
EnumProperty,
)
def add_object_align_init(context, operator):
"""
Return a matrix using the operator settings and view context.
:param context: The context to use.
:type context: :class:`bpy.types.Context`
:param operator: The operator, checked for location and rotation properties.
:type operator: :class:`bpy.types.Operator` | None
:return: the matrix from the context and settings.
:rtype: :class:`mathutils.Matrix`
"""
from mathutils import Matrix, Vector
properties = operator.properties if operator is not None else None
space_data = context.space_data
if space_data and space_data.type != 'VIEW_3D':
space_data = None
# location
if operator and properties.is_property_set("location"):
location = Matrix.Translation(Vector(properties.location))
else:
location = Matrix.Translation(context.scene.cursor.location)
if operator:
properties.location = location.to_translation()
# rotation
add_align_preference = context.preferences.edit.object_align
if operator:
if not properties.is_property_set("rotation"):
# So one of "align" and "rotation" will be set
properties.align = add_align_preference
if properties.align == 'WORLD':
rotation = properties.rotation.to_matrix().to_4x4()
elif properties.align == 'VIEW':
rotation = space_data.region_3d.view_matrix.to_3x3().inverted()
rotation.resize_4x4()
properties.rotation = rotation.to_euler()
elif properties.align == 'CURSOR':
rotation = context.scene.cursor.matrix
rotation.col[3][0:3] = 0.0, 0.0, 0.0
properties.rotation = rotation.to_euler()
else:
rotation = properties.rotation.to_matrix().to_4x4()
else:
if (add_align_preference == 'VIEW') and space_data:
rotation = space_data.region_3d.view_matrix.to_3x3().inverted()
rotation.resize_4x4()
elif add_align_preference == 'CURSOR':
rotation = context.scene.cursor.rotation_euler.to_matrix().to_4x4()
else:
rotation = Matrix()
return location @ rotation
def object_data_add(context, obdata, operator=None, name=None):
"""
Add an object using the view context and preference to initialize the
location, rotation and layer.
:param context: The context to use.
:type context: :class:`bpy.types.Context`
:param obdata: Valid object data to be used for the new object or None.
:type obdata: :class:`bpy.types.ID` | None
:param operator: The operator, checked for location and rotation properties.
:type operator: :class:`bpy.types.Operator` | None
:param name: Optional name
:type name: str | None
:return: the newly created object in the scene.
:rtype: :class:`bpy.types.Object`
"""
layer = context.view_layer
layer_collection = context.layer_collection or layer.active_layer_collection
scene_collection = layer_collection.collection
for ob in layer.objects:
ob.select_set(False)
if name is None:
name = "Object" if obdata is None else obdata.name
obj_act = layer.objects.active
obj_new = bpy.data.objects.new(name, obdata)
scene_collection.objects.link(obj_new)
obj_new.select_set(True)
obj_new.matrix_world = add_object_align_init(context, operator)
space_data = context.space_data
if space_data and space_data.type != 'VIEW_3D':
space_data = None
if space_data:
if space_data.local_view:
obj_new.local_view_set(space_data, True)
if obj_act and obj_act.mode == 'EDIT' and obj_act.type == obj_new.type:
bpy.ops.mesh.select_all(action='DESELECT')
obj_act.select_set(True)
bpy.ops.object.mode_set(mode='OBJECT')
obj_act.select_set(True)
layer.update() # apply location
# layer.objects.active = obj_new
# Match up UV layers, this is needed so adding an object with UVs
# doesn't create new layers when there happens to be a naming mismatch.
uv_new = obdata.uv_layers.active
if uv_new is not None:
uv_act = obj_act.data.uv_layers.active
if uv_act is not None:
uv_new.name = uv_act.name
# Copy the active object's active material into the primitive with no materials.
if len(obj_act.data.materials) > 0 and len(obdata.materials) == 0:
obdata.materials.append(obj_act.active_material)
bpy.ops.object.join() # join into the active.
if obdata:
bpy.data.meshes.remove(obdata)
bpy.ops.object.mode_set(mode='EDIT')
else:
layer.objects.active = obj_new
if context.preferences.edit.use_enter_edit_mode:
if obdata and obdata.library is None:
obtype = obj_new.type
mode = None
if obtype in {'ARMATURE', 'CURVE', 'CURVES', 'FONT', 'LATTICE', 'MESH', 'META', 'SURFACE'}:
mode = 'EDIT'
elif obtype == 'GPENCIL':
mode = 'EDIT_GPENCIL'
if mode is not None:
bpy.ops.object.mode_set(mode=mode)
return obj_new
class AddObjectHelper:
def align_update_callback(self, context):
"""
Update callback for the align property, resets rotation for world alignment.
:param context: The context.
:type context: :class:`bpy.types.Context`
"""
del context
if self.align == 'WORLD':
self.rotation.zero()
align: EnumProperty(
name="Align",
items=(
('WORLD', "World", "Align the new object to the world"),
('VIEW', "View", "Align the new object to the view"),
('CURSOR', "3D Cursor", "Use the 3D cursor orientation for the new object"),
),
default='WORLD',
update=AddObjectHelper.align_update_callback,
)
location: FloatVectorProperty(
name="Location",
subtype='TRANSLATION',
)
rotation: FloatVectorProperty(
name="Rotation",
subtype='EULER',
)
@classmethod
def poll(cls, context):
"""
Check the scene is not linked from a library.
:param context: The context.
:type context: :class:`bpy.types.Context`
:return: True when the scene is local (not linked from a library).
:rtype: bool
"""
return context.scene.library is None
def object_add_grid_scale(context):
"""
Return scale which should be applied on object
data to align it to grid scale.
:param context: The context.
:type context: :class:`bpy.types.Context`
:return: The grid scale.
:rtype: float
"""
space_data = context.space_data
if space_data and space_data.type == 'VIEW_3D':
return space_data.overlay.grid_scale_unit
return 1.0
def object_add_grid_scale_apply_operator(operator, context):
"""
Scale an operator's distance values by the grid size.
:param operator: The operator to scale.
:type operator: :class:`bpy.types.Operator`
:param context: The context.
:type context: :class:`bpy.types.Context`
"""
# This is a Python version of the C++ function `WM_operator_view3d_unit_defaults`.
grid_scale = object_add_grid_scale(context)
properties = operator.properties
properties_def = properties.bl_rna.properties
for prop_id in properties_def.keys():
if not properties.is_property_set(prop_id, ghost=False):
prop_def = properties_def[prop_id]
if prop_def.unit == 'LENGTH' and prop_def.subtype == 'DISTANCE':
setattr(operator, prop_id,
getattr(operator, prop_id) * grid_scale)
def world_to_camera_view(scene, obj, coord):
"""
Returns the camera space coords for a 3d point.
(also known as: normalized device coordinates - NDC).
Where (0, 0) is the bottom left and (1, 1)
is the top right of the camera frame.
values outside 0-1 are also supported.
A negative 'z' value means the point is behind the camera.
Takes shift-x/y, lens angle and sensor size into account
as well as perspective/ortho projections.
:param scene: Scene to use for frame size.
:type scene: :class:`bpy.types.Scene`
:param obj: Camera object.
:type obj: :class:`bpy.types.Object`
:param coord: World space location.
:type coord: :class:`mathutils.Vector`
:return: a vector where X and Y map to the view plane and
Z is the depth on the view axis.
:rtype: :class:`mathutils.Vector`
"""
from mathutils import Vector
co_local = obj.matrix_world.normalized().inverted() @ coord
z = -co_local.z
camera = obj.data
frame = [v for v in camera.view_frame(scene=scene)[:3]]
if camera.type != 'ORTHO':
if z == 0.0:
return Vector((0.5, 0.5, 0.0))
else:
frame = [-(v / (v.z / z)) for v in frame]
min_x, max_x = frame[2].x, frame[1].x
min_y, max_y = frame[1].y, frame[0].y
x = (co_local.x - min_x) / (max_x - min_x)
y = (co_local.y - min_y) / (max_y - min_y)
return Vector((x, y, z))
def object_report_if_active_shape_key_is_locked(obj, operator):
"""
Checks if the active shape key of the specified object is locked, and reports an error if so.
If the object has no shape keys, there is nothing to lock, and the function returns False.
:param obj: Object to check.
:type obj: :class:`bpy.types.Object`
:param operator: Currently running operator to report the error through. Use None to suppress emitting the message.
:type operator: :class:`bpy.types.Operator` | None
:return: True if the shape key was locked.
:rtype: bool
"""
key = obj.active_shape_key
if key and key.lock_shape:
if operator:
operator.report({'ERROR'}, "The active shape key of {:s} is locked".format(obj.name))
return True
return False

View File

@@ -0,0 +1,182 @@
# SPDX-FileCopyrightText: 2011-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
__all__ = (
"region_2d_to_vector_3d",
"region_2d_to_origin_3d",
"region_2d_to_location_3d",
"location_3d_to_region_2d",
)
def region_2d_to_vector_3d(region, rv3d, coord):
"""
Return a direction vector from the viewport at the specific 2D region
coordinate.
:param region: region of the 3D viewport, typically bpy.context.region.
:type region: :class:`bpy.types.Region`
:param rv3d: 3D region data, typically bpy.context.space_data.region_3d.
:type rv3d: :class:`bpy.types.RegionView3D`
:param coord: 2D coordinates relative to the region:
(event.mouse_region_x, event.mouse_region_y) for example.
:type coord: Sequence[float]
:return: normalized 3D vector.
:rtype: :class:`mathutils.Vector`
"""
from mathutils import Vector
viewinv = rv3d.view_matrix.inverted()
if rv3d.is_perspective:
persinv = rv3d.perspective_matrix.inverted()
out = Vector((
(2.0 * coord[0] / region.width) - 1.0,
(2.0 * coord[1] / region.height) - 1.0,
-0.5
))
w = out.dot(persinv[3].xyz) + persinv[3][3]
view_vector = ((persinv @ out) / w) - viewinv.translation
else:
view_vector = -viewinv.col[2].xyz
view_vector.normalize()
return view_vector
def region_2d_to_origin_3d(region, rv3d, coord, *, clamp=None):
"""
Return the 3D view origin from the region relative 2D coords.
.. note::
Orthographic views have a less obvious origin,
the far clip is used to define the viewport near/far extents.
Since far clip can be a very large value,
the result may have numeric precision issues.
To avoid this problem, you can optionally clamp the far clip to a
smaller value based on the data you're operating on.
:param region: region of the 3D viewport, typically bpy.context.region.
:type region: :class:`bpy.types.Region`
:param rv3d: 3D region data, typically bpy.context.space_data.region_3d.
:type rv3d: :class:`bpy.types.RegionView3D`
:param coord: 2D coordinates relative to the region;
(event.mouse_region_x, event.mouse_region_y) for example.
:type coord: Sequence[float]
:param clamp: Clamp the maximum far-clip value used.
(negative value will move the offset away from the view_location)
:type clamp: float | None
:return: The origin of the viewpoint in 3D space.
:rtype: :class:`mathutils.Vector`
"""
viewinv = rv3d.view_matrix.inverted()
if rv3d.is_perspective:
origin_start = viewinv.translation.copy()
else:
persmat = rv3d.perspective_matrix.copy()
dx = (2.0 * coord[0] / region.width) - 1.0
dy = (2.0 * coord[1] / region.height) - 1.0
persinv = persmat.inverted()
origin_start = (
(persinv.col[0].xyz * dx) +
(persinv.col[1].xyz * dy) +
persinv.translation
)
if clamp != 0.0:
if rv3d.view_perspective != 'CAMERA':
# this value is scaled to the far clip already
origin_offset = persinv.col[2].xyz
if clamp is not None:
if clamp < 0.0:
origin_offset.negate()
clamp = -clamp
if origin_offset.length > clamp:
origin_offset.length = clamp
origin_start -= origin_offset
return origin_start
def region_2d_to_location_3d(region, rv3d, coord, depth_location):
"""
Return a 3D location from the region relative 2D coords, aligned with
*depth_location*.
:param region: region of the 3D viewport, typically bpy.context.region.
:type region: :class:`bpy.types.Region`
:param rv3d: 3D region data, typically bpy.context.space_data.region_3d.
:type rv3d: :class:`bpy.types.RegionView3D`
:param coord: 2D coordinates relative to the region;
(event.mouse_region_x, event.mouse_region_y) for example.
:type coord: Sequence[float]
:param depth_location: the returned vectors depth is aligned with this since
there is no defined depth with a 2D region input.
:type depth_location: :class:`mathutils.Vector`
:return: normalized 3D vector.
:rtype: :class:`mathutils.Vector`
"""
from mathutils import Vector
coord_vec = region_2d_to_vector_3d(region, rv3d, coord)
depth_location = Vector(depth_location)
origin_start = region_2d_to_origin_3d(region, rv3d, coord)
origin_end = origin_start + coord_vec
if rv3d.is_perspective:
from mathutils.geometry import intersect_line_plane
viewinv = rv3d.view_matrix.inverted()
view_vec = viewinv.col[2].copy()
return intersect_line_plane(
origin_start,
origin_end,
depth_location,
view_vec, 1,
)
else:
from mathutils.geometry import intersect_point_line
return intersect_point_line(
depth_location,
origin_start,
origin_end,
)[0]
def location_3d_to_region_2d(region, rv3d, coord, *, default=None):
"""
Return the *region* relative 2D location of a 3D position.
:param region: region of the 3D viewport, typically bpy.context.region.
:type region: :class:`bpy.types.Region`
:param rv3d: 3D region data, typically bpy.context.space_data.region_3d.
:type rv3d: :class:`bpy.types.RegionView3D`
:param coord: 3D world-space location.
:type coord: :class:`mathutils.Vector`
:param default: Return this value if ``coord``
is behind the origin of a perspective view.
:type default: Any
:return: 2D location
:rtype: :class:`mathutils.Vector` | Any
"""
from mathutils import Vector
prj = rv3d.perspective_matrix @ Vector((coord[0], coord[1], coord[2], 1.0))
if prj.w > 0.0:
width_half = region.width / 2.0
height_half = region.height / 2.0
return Vector((
width_half + width_half * (prj.x / prj.w),
height_half + height_half * (prj.y / prj.w),
))
else:
return default

View File

@@ -0,0 +1,160 @@
# SPDX-FileCopyrightText: 2015-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
__all__ = (
"ProgressReport",
"ProgressReportSubstep",
)
import time
class ProgressReport:
"""
A basic 'progress report' using either simple prints in console, or WindowManager's 'progress' API.
This object can be used as a context manager.
It supports multiple levels of 'sub-steps' - you shall always enter at least one sub-step (because level 0
has only one single step, representing the whole 'area' of the progress stuff).
You should give the expected number of sub-steps each time you enter a new one (you may then step more or less then
given number, but this will give incoherent progression).
Leaving a sub-step automatically steps by one the parent level.
with ProgressReport() as progress: # Not giving a WindowManager here will default to console printing.
progress.enter_substeps(10)
for i in range(10):
progress.enter_substeps(100)
for j in range(100):
progress.step()
progress.leave_substeps() # No need to step here, this implicitly does it.
progress.leave_substeps("Finished!") # You may pass some message too.
"""
__slots__ = ("wm", "running", "steps", "curr_step", "start_time")
def __init__(self, wm=None):
self_wm = getattr(self, "wm", None)
if self_wm:
self.finalize()
self.running = False
self.wm = wm
self.steps = [100000]
self.curr_step = [0]
initialize = __init__
def __enter__(self):
self.start_time = [time.time()]
if self.wm:
self.wm.progress_begin(0, self.steps[0])
self.update()
self.running = True
return self
def __exit__(self, _exc_type=None, _exc_value=None, _traceback=None):
self.running = False
if self.wm:
self.wm.progress_end()
self.wm = None
print("\n")
self.steps = [100000]
self.curr_step = [0]
self.start_time = [time.time()]
def start(self):
self.__enter__()
def finalize(self):
self.__exit__()
def update(self, msg=""):
steps = sum(s * cs for (s, cs) in zip(self.steps, self.curr_step))
steps_percent = steps / self.steps[0] * 100.0
tm = time.time()
loc_tm = tm - self.start_time[-1]
tm -= self.start_time[0]
if self.wm and self.running:
self.wm.progress_update(steps)
if msg:
prefix = " " * (len(self.steps) - 1)
print(
prefix + "({:8.4f} sec | {:8.4f} sec) {:s}\nProgress: {:6.2f}%\r".format(
tm, loc_tm, msg, steps_percent,
),
end="",
)
else:
print("Progress: {:6.2f}%\r".format(steps_percent,), end="")
def enter_substeps(self, nbr, msg=""):
if msg:
self.update(msg)
self.steps.append(self.steps[-1] / max(nbr, 1))
self.curr_step.append(0)
self.start_time.append(time.time())
def step(self, msg="", nbr=1):
self.curr_step[-1] += nbr
self.update(msg)
def leave_substeps(self, msg=""):
if (msg):
self.update(msg)
assert len(self.steps) > 1
del self.steps[-1]
del self.curr_step[-1]
del self.start_time[-1]
self.step()
class ProgressReportSubstep:
"""
A sub-step context manager for ProgressReport.
It can be used to generate other sub-step contexts too, and can act as a (limited) proxy of its real ProgressReport.
Its exit method always ensure ProgressReport is back on 'level' it was before entering this context.
This means it is especially useful to ensure a coherent behavior around code that could return/continue/break
from many places, without having to bother to explicitly leave sub-step in each and every possible place!
with ProgressReport() as progress: # Not giving a WindowManager here will default to console printing.
with ProgressReportSubstep(progress, 10, final_msg="Finished!") as subprogress1:
for i in range(10):
with ProgressReportSubstep(subprogress1, 100) as subprogress2:
for j in range(100):
subprogress2.step()
"""
__slots__ = ("progress", "nbr", "msg", "final_msg", "level")
def __init__(self, progress, nbr, msg="", final_msg=""):
# Allows to generate a sub-progress context handler from another one.
progress = getattr(progress, "progress", progress)
self.progress = progress
self.nbr = nbr
self.msg = msg
self.final_msg = final_msg
def __enter__(self):
self.level = len(self.progress.steps)
self.progress.enter_substeps(self.nbr, self.msg)
return self
def __exit__(self, _exc_type, _exc_value, _traceback):
assert len(self.progress.steps) > self.level
while len(self.progress.steps) > self.level + 1:
self.progress.leave_substeps()
self.progress.leave_substeps(self.final_msg)
def enter_substeps(self, nbr, msg=""):
self.progress.enter_substeps(nbr, msg)
def step(self, msg="", nbr=1):
self.progress.step(msg, nbr)
def leave_substeps(self, msg=""):
self.progress.leave_substeps(msg)