refactor: Big changes to data flow and deps loading

main
Sofus Albert Høgsbro Rose 2024-04-19 16:53:24 +02:00
parent ff5d71aeff
commit 9960cd3480
Signed by: so-rose
GPG Key ID: AD901CB0F3701434
98 changed files with 2555 additions and 1336 deletions

View File

@ -9,3 +9,4 @@ trim_trailing_whitespace = false
[*.yml]
indent_style = space
indent_size = 2

View File

@ -1,10 +1,44 @@
####################
# - Project Config
####################
project:
type: website
output-dir: _site
# Website Configuration
format:
html:
toc: true
filters:
- interlinks
interlinks:
sources:
numpy:
url: https://numpy.org/doc/stable/
matplotlib:
url: https://matplotlib.org/stable/
python:
url: https://docs.python.org/3/
metadata-files:
# Sidebar for /pydocs Paths
- pydocs/_sidebar.yml
####################
# - Website Config
####################
website:
title: "Blender Maxwell"
description: "A Blender-based design and analysis tool for electromagnetic simulations"
page-footer: "Copyright 2024, Sofus Albert Høgsbro Rose"
repo-url: https://github.com/so-rose/blender_maxwell/
repo-actions: [issue]
page-navigation: true
navbar:
background: primary
pinned: true
search: true
left:
- file: index.qmd
text: Home
@ -18,19 +52,12 @@ website:
- text: Report a Bug
url: https://github.com/so-rose/blender_maxwell/issues/new/choose
# Auto-Generated Metadata
metadata-files:
# Sidebar for /pydocs Paths
- pydocs/_sidebar.yml
####################
# - quartodoc - Autogenerated Python Docs
# - Quartodoc Config
####################
quartodoc:
# Output
dir: pydocs
#out_index: _api_index.qmd
sidebar: pydocs/_sidebar.yml
# Python Package
@ -43,8 +70,13 @@ quartodoc:
title: "Blender Maxwell"
# Options
renderer:
style: markdown
#show_signature: true
show_signature_annotations: false
display_name: name
options:
include_private: true
#include_private: true
include_empty: true
include_attributes: true
signature_name: "short"
@ -57,16 +89,11 @@ quartodoc:
desc: Build/packaging scripts for developing and publishing the addon.
package: scripts
contents:
- name: info
children: embedded
- name: pack
children: embedded
- name: dev
children: embedded
- name: bl_delete_addon
children: embedded
- name: bl_install_addon
children: embedded
- info
- pack
- dev
- bl_delete_addon
- bl_install_addon
####################
# - bl_maxwell
@ -74,54 +101,39 @@ quartodoc:
- title: "`bl_maxwell`"
desc: Root package for the addon.
contents:
- name: info
children: embedded
- name: preferences
children: embedded
- name: registration
children: embedded
- preferences
- registration
- subtitle: "`bl_maxwell.assets`"
desc: Blender assets bundled w/Blender Maxwell
contents:
- name: assets
children: embedded
- name: assets.import_geonodes
children: embedded
- assets
- assets.import_geonodes
- subtitle: "`bl_maxwell.nodeps`"
desc: No-Dependency
contents:
- name: operators
children: embedded
- operators
- subtitle: "`bl_maxwell.utils`"
desc: Utilities wo/shared global state.
contents:
- name: utils.analyze_geonodes
children: embedded
- name: utils.blender_type_enum
children: embedded
- name: utils.extra_sympy_units
children: embedded
- name: utils.logger
children: embedded
- name: utils.pydantic_sympy
children: embedded
- utils.analyze_geonodes
- utils.blender_type_enum
- utils.extra_sympy_units
- utils.logger
- utils.pydantic_sympy
- subtitle: "`bl_maxwell.services`"
desc: Utilities w/shared global state.
contents:
- name: services.tdcloud
children: embedded
- services.tdcloud
- subtitle: "`bl_maxwell.operators`"
desc: General Blender operators.
contents:
- name: operators.bl_append
children: embedded
- name: operators.connect_viewer
children: embedded
- operators.bl_append
- operators.connect_viewer
####################
# - ..maxwell_sim_nodes
@ -130,73 +142,49 @@ quartodoc:
desc: Maxwell Simulation Design/Viz Node Tree.
package: blender_maxwell.node_trees.maxwell_sim_nodes
contents:
- name: bl_socket_map
children: embedded
- name: categories
children: embedded
- name: bl_cache
children: embedded
- name: node_tree
children: embedded
- bl_socket_map
- categories
- bl_cache
- node_tree
- subtitle: "`contracts`"
desc: Constants and interfaces for identifying resources.
package: blender_maxwell.node_trees.maxwell_sim_nodes.contracts
contents:
# General
- name: bl
children: embedded
- name: data_flows
children: embedded
- name: icons
children: embedded
- flow_kinds
- flow_kinds.FlowKind
- flow_kinds.LazyValueFuncFlow
- icons
- name: trees
children: embedded
- tree_types
# Managed Objects
- name: managed_obj_type
children: embedded
- mobj_types
# Nodes
- name: node_types
children: embedded
- name: node_cats
children: embedded
- name: node_cat_labels
children: embedded
- node_types
- category_types
- category_labels
# Sockets
- name: socket_types
children: embedded
- name: socket_colors
children: embedded
- name: socket_from_bl_desc
children: embedded
- name: socket_from_bl_direct
children: embedded
- name: socket_shapes
children: embedded
- name: socket_units
children: embedded
- socket_types
- socket_colors
- bl_socket_types
- bl_socket_desc_map
- socket_units
- name: unit_systems
children: embedded
- unit_systems
- subtitle: "`managed_objs`"
desc: Maxwell Simulation Design/Viz Node Tree
package: blender_maxwell.node_trees.maxwell_sim_nodes.managed_objs
contents:
- name: managed_bl_collection
children: embedded
- name: managed_bl_empty
children: embedded
- name: managed_bl_image
children: embedded
- name: managed_bl_mesh
children: embedded
- name: managed_bl_modifier
children: embedded
- managed_bl_collection
- managed_bl_empty
- managed_bl_image
- managed_bl_mesh
- managed_bl_modifier
####################
# - ..maxwell_sim_nodes.nodes
@ -205,10 +193,8 @@ quartodoc:
desc: Maxwell Simulation Node Sockets
package: blender_maxwell.node_trees.maxwell_sim_nodes.sockets
contents:
- name: base
children: embedded
- name: scan_socket_defs
children: embedded
- base
- scan_socket_defs
####################
# - ..maxwell_sim_nodes.nodes
@ -217,7 +203,5 @@ quartodoc:
desc: Maxwell Simulation Nodes
package: blender_maxwell.node_trees.maxwell_sim_nodes.nodes
contents:
- name: base
children: embedded
- name: events
children: embedded
- base
- events

View File

View File

@ -107,6 +107,7 @@ ignore = [
"B008", # FastAPI uses this for Depends(), Security(), etc. .
"E701", # class foo(Parent): pass or if simple: return are perfectly elegant
"ERA001", # 'Commented-out code' seems to be just about anything to ruff
"F722", # jaxtyping uses type annotations that ruff sees as "syntax error"
# Line Length - Controversy Incoming
## Hot Take: Let the Formatter Worry about Line Length

View File

@ -1,13 +1,44 @@
"""A Blender-based system for electromagnetic simulation design and analysis, with deep Tidy3D integration.
# `bl_info`
`bl_info` declares information about the addon to Blender.
However, it is not _dynamically_ read: Blender traverses it using `ast.parse`.
This makes it difficult to synchronize `bl_info` with the project's `pyproject.toml`.
As a workaround, **the addon zip-packer will replace `bl_info` entries**.
The following `bl_info` entries are currently replaced when the ZIP is built:
- `description`: To match the description in `pyproject.toml`.
- `version`: To match the version in `pyproject.toml`.
For more information, see `scripts.pack.BL_INFO_REPLACEMENTS`.
**NOTE**: The find/replace procedure is "dumb" (aka. no regex, no `ast` traversal, etc.).
This is surprisingly robust, so long as use of the deterministic code-formatter `ruff fmt` is enforced.
Still. Be careful around `bl_info`.
Attributes:
bl_info: Information about the addon declared to Blender.
BL_REGISTER_BEFORE_DEPS: Blender classes to register before dependencies are verified as installed.
BL_HOTKEYS: Blender keymap item defs to register before dependencies are verified as installed.
"""
from pathlib import Path
from . import info
import bpy
from . import contracts as ct
from .nodeps.utils import simple_logger
simple_logger.sync_bootstrap_logging(
console_level=info.BOOTSTRAP_LOG_LEVEL,
console_level=ct.addon.BOOTSTRAP_LOG_LEVEL,
)
from . import nodeps, preferences, registration # noqa: E402
from . import preferences, registration # noqa: E402
from .nodeps import operators as nodeps_operators # noqa: E402
from .nodeps.utils import pydeps # noqa: E402
log = simple_logger.get(__name__)
@ -15,9 +46,6 @@ log = simple_logger.get(__name__)
####################
# - Addon Information
####################
# The following parameters are replaced when packing the addon ZIP
## - description
## - version
bl_info = {
'name': 'Maxwell PDE Sim and Viz',
'blender': (4, 1, 0),
@ -28,24 +56,36 @@ bl_info = {
'wiki_url': 'https://git.sofus.io/dtu-courses/bsc_thesis',
'tracker_url': 'https://git.sofus.io/dtu-courses/bsc_thesis/issues',
}
## bl_info MUST readable via. ast.parse
## See scripts/pack.py::BL_INFO_REPLACEMENTS for active replacements
## The mechanism is a 'dumb' - output of 'ruff fmt' MUST be basis for replacing
####################
# - Load and Register Addon
####################
log.info('Loading Before-Deps BL_REGISTER')
BL_REGISTER__BEFORE_DEPS = [
*nodeps.operators.BL_REGISTER,
BL_REGISTER_BEFORE_DEPS: list[ct.BLClass] = [
*nodeps_operators.BL_REGISTER,
*preferences.BL_REGISTER,
]
## TODO: BL_HANDLERS and BL_SOCKET_DEFS
def BL_REGISTER__AFTER_DEPS(path_deps: Path):
log.info('Loading After-Deps BL_REGISTER')
with pydeps.importable_addon_deps(path_deps):
BL_HOTKEYS_BEFORE_DEPS: list[ct.KeymapItemDef] = [
*nodeps_operators.BL_HOTKEYS,
]
def load_main_blclasses(path_pydeps: Path) -> list[ct.BLClass]:
"""Imports all addon classes that rely on Python dependencies.
Notes:
`sys.path` is modified while executing this function.
Parameters:
path_pydeps: The path to the Python dependencies.
Returns:
An ordered list of Blender classes to register.
"""
with pydeps.importable_addon_deps(path_pydeps):
from . import assets, node_trees, operators
return [
*operators.BL_REGISTER,
@ -54,64 +94,114 @@ def BL_REGISTER__AFTER_DEPS(path_deps: Path):
]
log.info('Loading Before-Deps BL_KEYMAP_ITEM_DEFS')
BL_KEYMAP_ITEM_DEFS__BEFORE_DEPS = [
*nodeps.operators.BL_KEYMAP_ITEM_DEFS,
]
def load_main_blhotkeys(path_deps: Path) -> list[ct.KeymapItemDef]:
"""Imports all keymap item defs that rely on Python dependencies.
Notes:
`sys.path` is modified while executing this function.
def BL_KEYMAP_ITEM_DEFS__AFTER_DEPS(path_deps: Path):
log.info('Loading After-Deps BL_KEYMAP_ITEM_DEFS')
Parameters:
path_pydeps: The path to the Python dependencies.
Returns:
An ordered list of Blender keymap item defs to register.
"""
with pydeps.importable_addon_deps(path_deps):
from . import assets, operators
return [
*operators.BL_KEYMAP_ITEM_DEFS,
*assets.BL_KEYMAP_ITEM_DEFS,
*operators.BL_HOTKEYS,
*assets.BL_HOTKEYS,
]
####################
# - Registration
####################
def register():
"""Register the Blender addon."""
log.info('Starting %s Registration', info.ADDON_NAME)
@bpy.app.handlers.persistent
def manage_pydeps(*_):
# ct.addon.operator(
# ct.OperatorType.ManagePyDeps,
# 'INVOKE_DEFAULT',
# path_addon_pydeps='',
# path_addon_reqs='',
# )
ct.addon.prefs().on_addon_pydeps_changed(show_popup_if_deps_invalid=True)
# Register Barebones Addon (enough for PyDeps Installability)
registration.register_classes(BL_REGISTER__BEFORE_DEPS)
registration.register_keymap_items(BL_KEYMAP_ITEM_DEFS__BEFORE_DEPS)
# Retrieve PyDeps Path from Addon Preferences
if (addon_prefs := info.addon_prefs()) is None:
unregister()
msg = f'Addon preferences not found; aborting registration of {info.ADDON_NAME}'
raise RuntimeError(msg)
def register() -> None:
"""Implements a multi-stage addon registration, which accounts for Python dependency management.
# Retrieve PyDeps Path
path_pydeps = addon_prefs.pydeps_path
log.info('Loaded PyDeps Path from Addon Prefs: %s', path_pydeps)
# Multi-Stage Registration
The trouble is that many classes in our addon might require Python dependencies.
if pydeps.check_pydeps(path_pydeps):
log.info('PyDeps Satisfied: Loading Addon %s', info.ADDON_NAME)
addon_prefs.sync_addon_logging()
registration.register_classes(BL_REGISTER__AFTER_DEPS(path_pydeps))
registration.register_keymap_items(BL_KEYMAP_ITEM_DEFS__AFTER_DEPS(path_pydeps))
else:
log.info(
'PyDeps Invalid: Delaying Addon Registration of %s',
info.ADDON_NAME,
## Stage 1: Barebones Addon
Many classes in our addon might require Python dependencies.
However, they may not yet be installed.
To solve this bootstrapping problem in a streamlined manner, we only **guarantee** the registration of a few key classes, including:
- `AddonPreferences`: The addon preferences provide an interface for the user to fix Python dependency problems, thereby triggering subsequent stages.
- `InstallPyDeps`: An operator that installs missing Python dependencies, using Blender's embeded `pip`.
- `UninstallPyDeps`: An operator that uninstalls Python dependencies.
**These classes provide just enough interface to help the user install the missing Python dependencies**.
## Stage 2: Declare Delayed Registration
We may not be able to register any classes that rely on Python dependencies.
However, we can use `registration.delay_registration()` to **delay the registration until it is determined that the Python dependencies are satisfied**.`
For now, we just pass a callback that will import + return a list of classes to register (`load_main_blclasses()`) when the time comes.
## Stage 3: Trigger "PyDeps Changed"
The addon preferences is responsible for storing (and exposing to the user) the path to the Python dependencies.
Thus, the addon preferences method `on_addon_pydeps_changed()` has the responsibility for checking when the dependencies are valid, and running the delayed registrations (and any other delayed setup) in response.
In general, `on_addon_pydeps_changed()` runs whenever the PyDeps path is changed, but it can also be run manually.
As the last part of this process, that's exactly what `register()` does: Runs `on_addon_pydeps_changed()` manually.
Depending on the addon preferences (which persist), one of two things can happen:
1. **Deps Satisfied**: The addon will load without issue: The just-declared "delayed registrations" will run immediately, and all is well.
2. **Deps Not Satisfied**: The user must take action to fix the conflicts due to Python dependencies, before the addon can load. **A popup will show to help the user do so.
Notes:
Called by Blender when enabling the addon.
"""
log.info('Commencing Registration of Addon: %s', ct.addon.NAME)
bpy.app.handlers.load_post.append(manage_pydeps)
# Register Barebones Addon
## Contains all no-dependency BLClasses:
## - Contains AddonPreferences.
## Contains all BLClasses from 'nodeps'.
registration.register_classes(BL_REGISTER_BEFORE_DEPS)
registration.register_hotkeys(BL_HOTKEYS_BEFORE_DEPS)
# Delay Complete Registration until DEPS_SATISFIED
registration.delay_registration_until(
registration.BLRegisterEvent.DepsSatisfied,
then_register_classes=load_main_blclasses,
then_register_hotkeys=load_main_blhotkeys,
)
registration.delay_registration(
registration.EVENT__DEPS_SATISFIED,
classes_cb=BL_REGISTER__AFTER_DEPS,
keymap_item_defs_cb=BL_KEYMAP_ITEM_DEFS__AFTER_DEPS,
)
## TODO: bpy Popup to Deal w/Dependency Errors
# Trigger PyDeps Check
## Deps ARE OK: Delayed registration will trigger.
## Deps NOT OK: User must fix the pydeps, then trigger this method.
ct.addon.prefs().on_addon_pydeps_changed()
def unregister():
"""Unregister the Blender addon."""
log.info('Starting %s Unregister', info.ADDON_NAME)
def unregister() -> None:
"""Unregisters anything that was registered by the addon.
Notes:
Run by Blender when disabling the addon.
This doesn't clean `sys.modules`.
To fully revert to Blender's state before the addon was in use (especially various import-related caches in the Python process), Blender must be restarted.
"""
log.info('Starting %s Unregister', ct.addon.NAME)
registration.unregister_classes()
registration.unregister_keymap_items()
log.info('Finished %s Unregister', info.ADDON_NAME)
registration.unregister_hotkeys()
registration.clear_delayed_registrations()
log.info('Finished %s Unregister', ct.addon.NAME)

View File

@ -4,11 +4,11 @@ BL_REGISTER = [
*import_geonodes.BL_REGISTER,
]
BL_KEYMAP_ITEM_DEFS = [
*import_geonodes.BL_KEYMAP_ITEM_DEFS,
BL_HOTKEYS = [
*import_geonodes.BL_HOTKEYS,
]
__all__ = [
'BL_REGISTER',
'BL_KEYMAP_ITEM_DEFS',
'BL_HOTKEYS',
]

View File

@ -8,8 +8,6 @@ import bpy
from blender_maxwell import contracts as ct
from blender_maxwell.utils import logger
from .. import info
log = logger.get(__name__)
@ -75,7 +73,7 @@ class GeoNodes(enum.StrEnum):
# GeoNodes Paths
## Internal
GN_INTERNAL_PATH = info.PATH_ASSETS / 'internal' / 'primitives'
GN_INTERNAL_PATH = ct.addon.PATH_ASSETS / 'internal' / 'primitives'
GN_INTERNAL_INPUTS_PATH = GN_INTERNAL_PATH / 'input'
GN_INTERNAL_SOURCES_PATH = GN_INTERNAL_PATH / 'source'
GN_INTERNAL_STRUCTURES_PATH = GN_INTERNAL_PATH / 'structure'
@ -83,7 +81,7 @@ GN_INTERNAL_MONITORS_PATH = GN_INTERNAL_PATH / 'monitor'
GN_INTERNAL_SIMULATIONS_PATH = GN_INTERNAL_PATH / 'simulation'
## Structures
GN_STRUCTURES_PATH = info.PATH_ASSETS / 'structures'
GN_STRUCTURES_PATH = ct.addon.PATH_ASSETS / 'structures'
GN_STRUCTURES_PRIMITIVES_PATH = GN_STRUCTURES_PATH / 'primitives'
GN_PARENT_PATHS: dict[GeoNodes, Path] = {
@ -377,14 +375,14 @@ class AppendGeoNodes(bpy.types.Operator):
asset_libraries = bpy.context.preferences.filepaths.asset_libraries
if (
asset_library_idx := asset_libraries.find('Blender Maxwell')
) != -1 and asset_libraries['Blender Maxwell'].path != str(info.PATH_ASSETS):
) != -1 and asset_libraries['Blender Maxwell'].path != str(ct.addon.PATH_ASSETS):
bpy.ops.preferences.asset_library_remove(asset_library_idx)
if 'Blender Maxwell' not in asset_libraries:
bpy.ops.preferences.asset_library_add()
asset_library = asset_libraries[-1] ## Since the operator adds to the end
asset_library.name = 'Blender Maxwell'
asset_library.path = str(info.PATH_ASSETS)
asset_library.path = str(ct.addon.PATH_ASSETS)
bpy.types.WindowManager.active_asset_list = bpy.props.CollectionProperty(
type=bpy.types.AssetHandle
@ -398,7 +396,7 @@ BL_REGISTER = [
AppendGeoNodes,
]
BL_KEYMAP_ITEM_DEFS = [
BL_HOTKEYS = [
# {
# '_': [
# AppendGeoNodes.bl_idname,

View File

@ -9,11 +9,19 @@ from .bl import (
BLModifierType,
BLNodeTreeInterfaceID,
BLOperatorStatus,
BLRegionType,
BLSpaceType,
KeymapItemDef,
ManagedObjName,
PresetName,
SocketName,
)
from .operator_types import (
OperatorType,
)
from .panel_types import (
PanelType,
)
__all__ = [
'addon',
@ -26,8 +34,12 @@ __all__ = [
'BLModifierType',
'BLNodeTreeInterfaceID',
'BLOperatorStatus',
'BLRegionType',
'BLSpaceType',
'KeymapItemDef',
'ManagedObjName',
'PresetName',
'SocketName',
'OperatorType',
'PanelType',
]

View File

@ -1,7 +1,9 @@
import random
import tomllib
from pathlib import Path
import bpy
import bpy_restrict_state
PATH_ADDON_ROOT = Path(__file__).resolve().parent.parent
with (PATH_ADDON_ROOT / 'pyproject.toml').open('rb') as f:
@ -32,11 +34,47 @@ ADDON_CACHE.mkdir(exist_ok=True)
####################
# - Addon Prefs Info
# - Dynamic Addon Information
####################
def is_loading() -> bool:
"""Checks whether the addon is currently loading.
While an addon is loading, `bpy.context` is temporarily very limited.
For example, operators can't run while the addon is loading.
By checking whether `bpy.context` is limited like this, we can determine whether the addon is currently loading.
Notes:
Since `bpy_restrict_state._RestrictContext` is a very internal thing, this function may be prone to breakage on Blender updates.
**Keep an eye out**!
Returns:
Whether the addon has been fully loaded, such that `bpy.context` is fully accessible.
"""
return isinstance(bpy.context, bpy_restrict_state._RestrictContext)
def operator(name: str, *operator_args, **operator_kwargs) -> None:
# Parse Operator Name
operator_namespace, operator_name = name.split('.')
if operator_namespace != NAME:
msg = f'Tried to call operator {operator_name}, but addon operators may only use the addon operator namespace "{operator_namespace}.<name>"'
raise RuntimeError(msg)
# Addon Not Loading: Run Operator
if not is_loading():
operator = getattr(getattr(bpy.ops, NAME), operator_name)
operator(*operator_args, **operator_kwargs)
else:
msg = f'Tried to call operator "{operator_name}" while addon is loading'
raise RuntimeError(msg)
def prefs() -> bpy.types.AddonPreferences | None:
if (addon := bpy.context.preferences.addons.get(NAME)) is None:
return None
msg = 'Addon is not installed'
raise RuntimeError(msg)
return addon.preferences

View File

@ -1,30 +1,18 @@
import typing as typ
import bpy
import pydantic as pyd
import typing_extensions as typx
####################
# - Blender Strings
####################
BLEnumID = typx.Annotated[
str,
pyd.StringConstraints(
pattern=r'^[A-Z_]+$',
),
]
SocketName = typx.Annotated[
str,
pyd.StringConstraints(
pattern=r'^[a-zA-Z0-9_]+$',
),
]
BLEnumID = str
SocketName = str
####################
# - Blender Enums
####################
BLImportMethod: typ.TypeAlias = typx.Literal['append', 'link']
BLModifierType: typ.TypeAlias = typx.Literal['NODES', 'ARRAY']
BLImportMethod: typ.TypeAlias = typ.Literal['append', 'link']
BLModifierType: typ.TypeAlias = typ.Literal['NODES', 'ARRAY']
BLNodeTreeInterfaceID: typ.TypeAlias = str
BLIconSet: frozenset[str] = frozenset(
@ -49,30 +37,60 @@ BLClass: typ.TypeAlias = (
BLKeymapItem: typ.TypeAlias = typ.Any ## TODO: Better Type
BLColorRGBA = tuple[float, float, float, float]
####################
# - Operators
####################
BLSpaceType: typ.TypeAlias = typ.Literal[
'EMPTY',
'VIEW_3D',
'IMAGE_EDITOR',
'NODE_EDITOR',
'SEQUENCE_EDITOR',
'CLIP_EDITOR',
'DOPESHEET_EDITOR',
'GRAPH_EDITOR',
'NLA_EDITOR',
'TEXT_EDITOR',
'CONSOLE',
'INFO',
'TOPBAR',
'STATUSBAR',
'OUTLINER',
'PROPERTIES',
'FILE_BROWSER',
'SPREADSHEET',
'PREFERENCES',
]
BLRegionType: typ.TypeAlias = typ.Literal[
'WINDOW',
'HEADER',
'CHANNELS',
'TEMPORARY',
'UI',
'TOOLS',
'TOOL_PROPS',
'ASSET_SHELF',
'ASSET_SHELF_HEADER',
'PREVIEW',
'HUD',
'NAVIGATION_BAR',
'EXECUTE',
'FOOTER',
'TOOL_HEADER',
'XR',
]
BLOperatorStatus: typ.TypeAlias = set[
typx.Literal['RUNNING_MODAL', 'CANCELLED', 'FINISHED', 'PASS_THROUGH', 'INTERFACE']
typ.Literal['RUNNING_MODAL', 'CANCELLED', 'FINISHED', 'PASS_THROUGH', 'INTERFACE']
]
####################
# - Addon Types
####################
KeymapItemDef: typ.TypeAlias = typ.Any ## TODO: Better Type
ManagedObjName = typx.Annotated[
str,
pyd.StringConstraints(
pattern=r'^[a-z_]+$',
),
]
ManagedObjName = str
####################
# - Blender Strings
####################
PresetName = typx.Annotated[
str,
pyd.StringConstraints(
pattern=r'^[a-zA-Z0-9_]+$',
),
]
PresetName = str

View File

@ -0,0 +1,15 @@
"""Defines Operator Types as an enum, making it easy for any part of the addon to refer to any operator."""
import enum
from ..nodeps.utils import blender_type_enum
from .addon import NAME as ADDON_NAME
@blender_type_enum.prefix_values_with(f'{ADDON_NAME}.')
class OperatorType(enum.StrEnum):
"""Identifiers for addon-defined `bpy.types.Operator`."""
InstallPyDeps = enum.auto()
UninstallPyDeps = enum.auto()
ManagePyDeps = enum.auto()

View File

@ -0,0 +1,12 @@
"""Defines Panel Types as an enum, making it easy for any part of the addon to refer to any panel."""
import enum
from blender_maxwell.nodeps.utils import blender_type_enum
from .addon import NAME as ADDON_NAME
@blender_type_enum.prefix_values_with(f'{ADDON_NAME.upper()}_PT_')
class PanelType(enum.StrEnum):
"""Identifiers for addon-defined `bpy.types.Panel`."""

View File

@ -1,9 +1,3 @@
import sympy as sp
sp.printing.str.StrPrinter._default_settings['abbrev'] = True
## In this tree, all Sympy unit printing must be abbreviated.
## By configuring this in __init__.py, we guarantee it for all subimports.
## (Unless, elsewhere, this setting is changed. Be careful!)
from . import categories, node_tree, nodes, sockets

View File

@ -7,8 +7,12 @@ from blender_maxwell.contracts import (
BLModifierType,
BLNodeTreeInterfaceID,
BLOperatorStatus,
BLRegionType,
BLSpaceType,
KeymapItemDef,
ManagedObjName,
OperatorType,
PanelType,
PresetName,
SocketName,
addon,
@ -25,7 +29,7 @@ from .flow_kinds import (
FlowKind,
InfoFlow,
LazyArrayRangeFlow,
LazyValueFlow,
LazyValueFuncFlow,
ParamsFlow,
ValueFlow,
)
@ -48,8 +52,12 @@ __all__ = [
'BLModifierType',
'BLNodeTreeInterfaceID',
'BLOperatorStatus',
'BLRegionType',
'BLSpaceType',
'KeymapItemDef',
'ManagedObjName',
'OperatorType',
'PanelType',
'PresetName',
'SocketName',
'addon',
@ -74,7 +82,7 @@ __all__ = [
'FlowKind',
'InfoFlow',
'LazyArrayRangeFlow',
'LazyValueFlow',
'LazyValueFuncFlow',
'ParamsFlow',
'ValueFlow',
]

View File

@ -1,7 +1,5 @@
import enum
import typing_extensions as typx
from blender_maxwell.utils.staticproperty import staticproperty
@ -48,7 +46,7 @@ class FlowEvent(enum.StrEnum):
# Properties
@staticproperty
def flow_direction() -> typx.Literal['input', 'output']:
def flow_direction() -> typ.Literal['input', 'output']:
"""Describes the direction in which the event should flow.
Doesn't include `FlowEvent`s that aren't meant to be triggered:

View File

@ -6,10 +6,10 @@ from types import MappingProxyType
import jax
import jax.numpy as jnp
import jaxtyping as jtyp
import numba
import sympy as sp
import sympy.physics.units as spu
import typing_extensions as typx
from blender_maxwell.utils import extra_sympy_units as spux
@ -48,7 +48,7 @@ class FlowKind(enum.StrEnum):
Array = enum.auto()
# Lazy
LazyValue = enum.auto()
LazyValueFunc = enum.auto()
LazyArrayRange = enum.auto()
# Auxiliary
@ -107,14 +107,14 @@ class ArrayFlow:
None if unitless.
"""
values: jax.Array
values: jtyp.Shaped[jtyp.Array, '...']
unit: spu.Quantity | None = None
def correct_unit(self, real_unit: spu.Quantity) -> typ.Self:
def correct_unit(self, corrected_unit: spu.Quantity) -> typ.Self:
if self.unit is not None:
return ArrayFlow(values=self.values, unit=real_unit)
return ArrayFlow(values=self.values, unit=corrected_unit)
msg = f'Tried to correct unit of unitless LazyDataValueRange "{real_unit}"'
msg = f'Tried to correct unit of unitless LazyDataValueRange "{corrected_unit}"'
raise ValueError(msg)
def rescale_to_unit(self, unit: spu.Quantity) -> typ.Self:
@ -137,28 +137,119 @@ LazyFunction: typ.TypeAlias = typ.Callable[[typ.Any, ...], ValueFlow]
@dataclasses.dataclass(frozen=True, kw_only=True)
class LazyValueFuncFlow:
r"""Encapsulates a lazily evaluated data value as a composable function with bound and free arguments.
r"""Wraps a composable function, providing useful information and operations.
- **Bound Args**: Arguments that are realized when **defining** the lazy value.
Both positional values and keyword values are supported.
- **Free Args**: Arguments that are specified when evaluating the lazy value.
Both positional values and keyword values are supported.
# Data Flow as Function Composition
When using nodes to do math, it can be a good idea to express a **flow of data as the composition of functions**.
The **root function** is encapsulated using `from_function`, and must accept arguments in the following order:
Each node creates a new function, which uses the still-unknown (aka. **lazy**) output of the previous function to plan some calculations.
Some new arguments may also be added, of course.
## Root Function
Of course, one needs to select a "bottom" function, which has no previous function as input.
Thus, the first step is to define this **root function**:
$$
f_0:\ \ \ \ (\underbrace{b_1, b_2, ...}_{\text{Bound}}\ ,\ \underbrace{r_1, r_2, ...}_{\text{Free}}) \to \text{output}_0
f_0:\ \ \ \ \biggl(
\underbrace{a_1, a_2, ..., a_p}_{\texttt{args}},\
\underbrace{
\begin{bmatrix} k_1 \\ v_1\end{bmatrix},
\begin{bmatrix} k_2 \\ v_2\end{bmatrix},
...,
\begin{bmatrix} k_q \\ v_q\end{bmatrix}
}_{\texttt{kwargs}}
\biggr) \to \text{output}_0
$$
Subsequent **composed functions** are encapsulated from the _root function_, and are created with `root_function.compose`.
They must accept arguments in the following order:
We'll express this simple snippet like so:
```python
# Presume 'A0', 'KV0' contain only the args/kwargs for f_0
## 'A0', 'KV0' are of length 'p' and 'q'
def f_0(*args, **kwargs): ...
lazy_value_func_0 = LazyValueFuncFlow(
func=f_0,
func_args=[(a_i, type(a_i)) for a_i in A0],
func_kwargs={k: v for k,v in KV0},
)
output_0 = lazy_value_func.func(*A0_computed, **KV0_computed)
```
So far so good.
But of course, nothing interesting has really happened yet.
## Composing Functions
The key thing is the next step: The function that uses the result of $f_0$!
$$
f_k:\ \ \ \ (\underbrace{b_1, b_2, ...}_{\text{Bound}}\ ,\ \text{output}_{k-1} ,\ \underbrace{r_p, r_{p+1}, ...}_{\text{Free}}) \to \text{output}_k
f_1:\ \ \ \ \biggl(
f_0(...),\ \
\underbrace{\{a_i\}_p^{p+r}}_{\texttt{args[p:]}},\
\underbrace{\biggl\{
\begin{bmatrix} k_i \\ v_i\end{bmatrix}
\biggr\}_q^{q+s}}_{\texttt{kwargs[p:]}}
\biggr) \to \text{output}_1
$$
Notice that _$f_1$ needs the arguments of both $f_0$ and $f_1$_.
Tracking arguments is already getting out of hand; we already have to use `...` to keep it readeable!
But doing so with `LazyValueFunc` is not so complex:
```python
# Presume 'A1', 'K1' contain only the args/kwarg names for f_1
## 'A1', 'KV1' are therefore of length 'r' and 's'
def f_1(output_0, *args, **kwargs): ...
lazy_value_func_1 = lazy_value_func_0.compose_within(
enclosing_func=f_1,
enclosing_func_args=[(a_i, type(a_i)) for a_i in A1],
enclosing_func_kwargs={k: type(v) for k,v in K1},
)
A_computed = A0_computed + A1_computed
KW_computed = KV0_computed + KV1_computed
output_1 = lazy_value_func_1.func(*A_computed, **KW_computed)
```
We only need the arguments to $f_1$, and `LazyValueFunc` figures out how to make one function with enough arguments to call both.
## Isn't Laying Functions Slow/Hard?
Imagine that each function represents the action of a node, each of which performs expensive calculations on huge `numpy` arrays (**as one does when processing electromagnetic field data**).
At the end, a node might run the entire procedure with all arguments:
```python
output_n = lazy_value_func_n.func(*A_all, **KW_all)
```
It's rough: Most non-trivial pipelines drown in the time/memory overhead of incremental `numpy` operations - individually fast, but collectively iffy.
The killer feature of `LazyValueFuncFlow` is a sprinkle of black magic:
```python
func_n_jax = lazy_value_func_n.func_jax
output_n = func_n_jax(*A_all, **KW_all) ## Runs on your GPU
```
What happened was, **the entire pipeline** was compiled and optimized for high performance on not just your CPU, _but also (possibly) your GPU_.
All the layered function calls and inefficient incremental processing is **transformed into a high-performance program**.
Thank `jax` - specifically, `jax.jit` (https://jax.readthedocs.io/en/latest/_autosummary/jax.jit.html#jax.jit), which internally enables this magic with a single function call.
## Other Considerations
**Auto-Differentiation**: Incredibly, `jax.jit` isn't the killer feature of `jax`. The function that comes out of `LazyValueFuncFlow` can also be differentiated with `jax.grad` (read: high-performance Jacobians for optimizing input parameters).
Though designed for machine learning, there's no reason other fields can't enjoy their inventions!
**Impact of Independent Caching**: JIT'ing can be slow.
That's why `LazyValueFuncFlow` has its own `FlowKind` "lane", which means that **only changes to the processing procedures will cause recompilation**.
Generally, adjustable values that affect the output will flow via the `Param` "lane", which has its own incremental caching, and only meets the compiled function when it's "plugged in" for final evaluation.
The effect is a feeling of snappiness and interactivity, even as the volume of data grows.
Attributes:
function: The function to be lazily evaluated.
func: The function that the object encapsulates.
bound_args: Arguments that will be packaged into function, which can't be later modifier.
func_kwargs: Arguments to be specified by the user at the time of use.
supports_jax: Whether the contained `self.function` can be compiled with JAX's JIT compiler.
@ -166,37 +257,29 @@ class LazyValueFuncFlow:
"""
func: LazyFunction
func_kwargs: dict[str, type]
func_args: list[tuple[str, type]] = MappingProxyType({})
func_kwargs: dict[str, type] = MappingProxyType({})
supports_jax: bool = False
supports_numba: bool = False
@staticmethod
def from_func(
func: LazyFunction,
supports_jax: bool = False,
supports_numba: bool = False,
**func_kwargs: dict[str, type],
) -> typ.Self:
return LazyValueFuncFlow(
func=func,
func_kwargs=func_kwargs,
supports_jax=supports_jax,
supports_numba=supports_numba,
)
# Composition
def compose_within(
self,
enclosing_func: LazyFunction,
enclosing_func_args: list[tuple[str, type]] = (),
enclosing_func_kwargs: dict[str, type] = MappingProxyType({}),
supports_jax: bool = False,
supports_numba: bool = False,
**enclosing_func_kwargs: dict[str, type],
) -> typ.Self:
return LazyValueFuncFlow(
function=lambda **kwargs: enclosing_func(
self.func(**{k: v for k, v in kwargs if k in self.func_kwargs}),
function=lambda *args, **kwargs: enclosing_func(
self.func(
*list(args[len(self.func_args) :]),
**{k: v for k, v in kwargs.items() if k in self.func_kwargs},
),
**kwargs,
),
func_args=self.func_args + enclosing_func_args,
func_kwargs=self.func_kwargs | enclosing_func_kwargs,
supports_jax=self.supports_jax and supports_jax,
supports_numba=self.supports_numba and supports_numba,
@ -224,89 +307,295 @@ class LazyValueFuncFlow:
####################
@dataclasses.dataclass(frozen=True, kw_only=True)
class LazyArrayRangeFlow:
symbols: set[sp.Symbol]
r"""Represents a linearly/logarithmically spaced array using symbolic boundary expressions, with support for units and lazy evaluation.
start: sp.Basic
stop: sp.Basic
# Advantages
Whenever an array can be represented like this, the advantages over an `ArrayFlow` are numerous.
## Memory
`ArrayFlow` generally has a memory scaling of $O(n)$.
Naturally, `LazyArrayRangeFlow` is always constant, since only the boundaries and steps are stored.
## Symbolic
Both boundary points are symbolic expressions, within which pre-defined `sp.Symbol`s can participate in a constrained manner (ex. an integer symbol).
One need not know the value of the symbols immediately - such decisions can be deferred until later in the computational flow.
## Performant Unit-Aware Operations
While `ArrayFlow`s are also unit-aware, the time-cost of _any_ unit-scaling operation scales with $O(n)$.
`LazyArrayRangeFlow`, by contrast, scales as $O(1)$.
As a result, more complicated operations (like symbolic or unit-based) that might be difficult to perform interactively in real-time on an `ArrayFlow` will work perfectly with this object, even with added complexity
## High-Performance Composition and Gradiant
With `self.as_func`, a `jax` function is produced that generates the array according to the symbolic `start`, `stop` and `steps`.
There are two nice things about this:
- **Gradient**: The gradient of the output array, with respect to any symbols used to define the input bounds, can easily be found using `jax.grad` over `self.as_func`.
- **JIT**: When `self.as_func` is composed with other `jax` functions, and `jax.jit` is run to optimize the entire thing, the "cost of array generation" _will often be optimized away significantly or entirely_.
Thus, as part of larger computations, the performance properties of `LazyArrayRangeFlow` is extremely favorable.
## Numerical Properties
Since the bounds support exact (ex. rational) calculations and symbolic manipulations (_by virtue of being symbolic expressions_), the opportunities for certain kinds of numerical instability are mitigated.
Attributes:
start: An expression generating a scalar, unitless, complex value for the array's lower bound.
_Integer, rational, and real values are also supported._
stop: An expression generating a scalar, unitless, complex value for the array's upper bound.
_Integer, rational, and real values are also supported._
steps: The amount of steps (**inclusive**) to generate from `start` to `stop`.
scaling: The method of distributing `step` values between the two endpoints.
Generally, the linear default is sufficient.
unit: The unit of the generated array values
int_symbols: Set of integer-valued variables from which `start` and/or `stop` are determined.
real_symbols: Set of real-valued variables from which `start` and/or `stop` are determined.
complex_symbols: Set of complex-valued variables from which `start` and/or `stop` are determined.
"""
start: spux.ScalarUnitlessComplexExpr
stop: spux.ScalarUnitlessComplexExpr
steps: int
scaling: typx.Literal['lin', 'geom', 'log'] = 'lin'
scaling: typ.Literal['lin', 'geom', 'log'] = 'lin'
unit: spu.Quantity | None = False
unit: spux.Unit | None = None
def correct_unit(self, real_unit: spu.Quantity) -> typ.Self:
int_symbols: set[spux.IntSymbol] = frozenset()
real_symbols: set[spux.RealSymbol] = frozenset()
complex_symbols: set[spux.ComplexSymbol] = frozenset()
@functools.cached_property
def symbols(self) -> list[sp.Symbol]:
"""Retrieves all symbols by concatenating int, real, and complex symbols, and sorting them by name.
The order is guaranteed to be **deterministic**.
Returns:
All symbols valid for use in the expression.
"""
return sorted(
self.int_symbols | self.real_symbols | self.complex_symbols,
key=lambda sym: sym.name,
)
####################
# - Units
####################
def correct_unit(self, corrected_unit: spux.Unit) -> typ.Self:
"""Replaces the unit without rescaling the unitless bounds.
Parameters:
corrected_unit: The unit to replace the current unit with.
Returns:
A new `LazyArrayRangeFlow` with replaced unit.
Raises:
ValueError: If the existing unit is `None`, indicating that there is no unit to correct.
"""
if self.unit is not None:
return LazyArrayRangeFlow(
symbols=self.symbols,
unit=real_unit,
start=self.start,
stop=self.stop,
steps=self.steps,
scaling=self.scaling,
unit=corrected_unit,
int_symbols=self.int_symbols,
real_symbols=self.real_symbols,
complex_symbols=self.complex_symbols,
)
msg = f'Tried to correct unit of unitless LazyDataValueRange "{real_unit}"'
msg = f'Tried to correct unit of unitless LazyDataValueRange "{corrected_unit}"'
raise ValueError(msg)
def rescale_to_unit(self, unit: spu.Quantity) -> typ.Self:
def rescale_to_unit(self, unit: spux.Unit) -> typ.Self:
"""Replaces the unit, **with** rescaling of the bounds.
Parameters:
unit: The unit to convert the bounds to.
Returns:
A new `LazyArrayRangeFlow` with replaced unit.
Raises:
ValueError: If the existing unit is `None`, indicating that there is no unit to correct.
"""
if self.unit is not None:
return LazyArrayRangeFlow(
symbols=self.symbols,
unit=unit,
start=spu.convert_to(self.start, unit),
stop=spu.convert_to(self.stop, unit),
steps=self.steps,
scaling=self.scaling,
unit=unit,
symbols=self.symbols,
int_symbols=self.int_symbols,
real_symbols=self.real_symbols,
complex_symbols=self.complex_symbols,
)
msg = f'Tried to rescale unitless LazyDataValueRange to unit {unit}'
raise ValueError(msg)
####################
# - Bound Operations
####################
def rescale_bounds(
self,
bound_cb: typ.Callable[[sp.Expr], sp.Expr],
scaler: typ.Callable[
[spux.ScalarUnitlessComplexExpr], spux.ScalarUnitlessComplexExpr
],
reverse: bool = False,
) -> typ.Self:
"""Call a function on both bounds (start and stop), creating a new `LazyDataValueRange`."""
"""Apply a function to the bounds, effectively rescaling the represented array.
Notes:
**It is presumed that the bounds are scaled with the same factor**.
Breaking this presumption may have unexpected results.
The scalar, unitless, complex-valuedness of the bounds must also be respected; additionally, new symbols must not be introduced.
Parameters:
scaler: The function that scales each bound.
reverse: Whether to reverse the bounds after running the `scaler`.
Returns:
A rescaled `LazyArrayRangeFlow`.
"""
return LazyArrayRangeFlow(
symbols=self.symbols,
unit=self.unit,
start=spu.convert_to(
bound_cb(self.start if not reverse else self.stop), self.unit
scaler(self.start if not reverse else self.stop), self.unit
),
stop=spu.convert_to(
bound_cb(self.stop if not reverse else self.start), self.unit
scaler(self.stop if not reverse else self.start), self.unit
),
steps=self.steps,
scaling=self.scaling,
unit=self.unit,
int_symbols=self.int_symbols,
real_symbols=self.real_symbols,
complex_symbols=self.complex_symbols,
)
def realize(
self, symbol_values: dict[sp.Symbol, ValueFlow] = MappingProxyType({})
) -> ArrayFlow:
# Realize Symbols
if self.unit is None:
start = spux.sympy_to_python(self.start.subs(symbol_values))
stop = spux.sympy_to_python(self.stop.subs(symbol_values))
else:
start = spux.sympy_to_python(
spux.scale_to_unit(self.start.subs(symbol_values), self.unit)
####################
# - Lazy Representation
####################
@functools.cached_property
def array_generator(
self,
) -> typ.Callable[
[int | float | complex, int | float | complex, int],
jtyp.Inexact[jtyp.Array, ' steps'],
]:
"""Compute the correct `jnp.*space` array generator, where `*` is one of the supported scaling methods.
Returns:
A `jax` function that takes a valid `start`, `stop`, and `steps`, and returns a 1D `jax` array.
"""
jnp_nspace = {
'lin': jnp.linspace,
'geom': jnp.geomspace,
'log': jnp.logspace,
}.get(self.scaling)
if jnp_nspace is None:
msg = f'ArrayFlow scaling method {self.scaling} is unsupported'
raise RuntimeError(msg)
return jnp_nspace
@functools.cached_property
def as_func(
self,
) -> typ.Callable[[int | float | complex, ...], jtyp.Inexact[jtyp.Array, ' steps']]:
"""Create a function that can compute the non-lazy output array as a function of the symbols in the expressions for `start` and `stop`.
Notes:
The ordering of the symbols is identical to `self.symbols`, which is guaranteed to be a deterministically sorted list of symbols.
Returns:
A `LazyValueFuncFlow` that, given the input symbols defined in `self.symbols`,
"""
# Compile JAX Functions for Start/End Expressions
## FYI, JAX-in-JAX works perfectly fine.
start_jax = sp.lambdify(self.symbols, self.start, 'jax')
stop_jax = sp.lambdify(self.symbols, self.stop, 'jax')
# Compile ArrayGen Function
def gen_array(
*args: list[int | float | complex],
) -> jtyp.Inexact[jtyp.Array, ' steps']:
return self.array_generator(start_jax(*args), stop_jax(*args), self.steps)
# Return ArrayGen Function
return gen_array
@functools.cached_property
def as_lazy_value_func(self) -> LazyValueFuncFlow:
"""Creates a `LazyValueFuncFlow` using the output of `self.as_func`.
This is useful for ex. parameterizing the first array in the node graph, without binding an entire computed array.
Notes:
The the function enclosed in the `LazyValueFuncFlow` is identical to the one returned by `self.as_func`.
Returns:
A `LazyValueFuncFlow` containing `self.as_func`, as well as appropriate supporting settings.
"""
return LazyValueFuncFlow(
func=self.as_func,
func_args=[
(sym.name, spux.sympy_to_python_type(sym)) for sym in self.symbols
],
supports_jax=True,
)
stop = spux.sympy_to_python(
spux.scale_to_unit(self.stop.subs(symbol_values), self.unit)
####################
# - Realization
####################
def realize(
self,
symbol_values: dict[spux.Symbol, ValueFlow] = MappingProxyType({}),
kind: typ.Literal[FlowKind.Array, FlowKind.LazyValueFunc] = FlowKind.Array,
) -> ArrayFlow | LazyValueFuncFlow:
"""Apply a function to the bounds, effectively rescaling the represented array.
Notes:
**It is presumed that the bounds are scaled with the same factor**.
Breaking this presumption may have unexpected results.
The scalar, unitless, complex-valuedness of the bounds must also be respected; additionally, new symbols must not be introduced.
Parameters:
scaler: The function that scales each bound.
reverse: Whether to reverse the bounds after running the `scaler`.
Returns:
A rescaled `LazyArrayRangeFlow`.
"""
if not set(self.symbols).issubset(set(symbol_values.keys())):
msg = f'Provided symbols ({set(symbol_values.keys())}) do not provide values for all expression symbols ({self.symbols}) that may be found in the boundary expressions (start={self.start}, end={self.end})'
raise ValueError(msg)
# Realize Symbols
realized_start = spux.sympy_to_python(
self.start.subs({sym: symbol_values[sym.name] for sym in self.symbols})
)
realized_stop = spux.sympy_to_python(
self.stop.subs({sym: symbol_values[sym.name] for sym in self.symbols})
)
# Return Linspace / Logspace
if self.scaling == 'lin':
return ArrayFlow(
values=jnp.linspace(start, stop, self.steps), unit=self.unit
)
if self.scaling == 'geom':
return ArrayFlow(jnp.geomspace(start, stop, self.steps), self.unit)
if self.scaling == 'log':
return ArrayFlow(jnp.logspace(start, stop, self.steps), self.unit)
def gen_array() -> jtyp.Inexact[jtyp.Array, ' steps']:
return self.array_generator(realized_start, realized_stop, self.steps)
msg = f'ArrayFlow scaling method {self.scaling} is unsupported'
raise RuntimeError(msg)
if kind == FlowKind.Array:
return ArrayFlow(values=gen_array(), unit=self.unit)
if kind == FlowKind.LazyValueFunc:
return LazyValueFuncFlow(func=gen_array, supports_jax=True)
msg = f'Invalid kind: {kind}'
raise TypeError(msg)
####################
@ -318,4 +607,35 @@ ParamsFlow: typ.TypeAlias = dict[str, typ.Any]
####################
# - Lazy Value Func
####################
InfoFlow: typ.TypeAlias = dict[str, typ.Any]
@dataclasses.dataclass(frozen=True, kw_only=True)
class InfoFlow:
func_args: list[tuple[str, type]] = MappingProxyType({})
func_kwargs: dict[str, type] = MappingProxyType({})
# Dimension Information
has_ndims: bool = False
dim_names: list[str] = ()
dim_idx: dict[str, ArrayFlow | LazyArrayRangeFlow] = MappingProxyType({})
## TODO: Validation, esp. length of dims. Pydantic?
def compose_within(
self,
enclosing_func_args: list[tuple[str, type]] = (),
enclosing_func_kwargs: dict[str, type] = MappingProxyType({}),
) -> typ.Self:
return InfoFlow(
func_args=self.func_args + enclosing_func_args,
func_kwargs=self.func_kwargs | enclosing_func_kwargs,
)
def call_lazy_value_func(
self,
lazy_value_func: LazyValueFuncFlow,
*args: list[typ.Any],
**kwargs: dict[str, typ.Any],
) -> tuple[list[typ.Any], dict[str, typ.Any]]:
if lazy_value_func.supports_jax:
lazy_value_func.func_jax(*args, **kwargs)
lazy_value_func.func(*args, **kwargs)

View File

@ -1,6 +1,6 @@
import enum
from blender_maxwell.blender_type_enum import BlenderTypeEnum
from blender_maxwell.utils.blender_type_enum import BlenderTypeEnum
class ManagedObjType(BlenderTypeEnum):

View File

@ -30,12 +30,12 @@ class NodeType(BlenderTypeEnum):
## Inputs / File Importers
Tidy3DFileImporter = enum.auto()
## Inputs / Constants
ExprConstant = enum.auto()
ScientificConstant = enum.auto()
NumberConstant = enum.auto()
PhysicalConstant = enum.auto()
BlenderConstant = enum.auto()
# Outputs
Viewer = enum.auto()
## Outputs / File Exporters

View File

@ -7,7 +7,6 @@ import jax.numpy as jnp
import matplotlib
import matplotlib.axis as mpl_ax
import numpy as np
import typing_extensions as typx
from blender_maxwell.utils import logger
@ -123,8 +122,8 @@ class ManagedBLImage(base.ManagedObj):
self,
width_px: int,
height_px: int,
color_model: typx.Literal['RGB', 'RGBA'],
dtype: typx.Literal['uint8', 'float32'],
color_model: typ.Literal['RGB', 'RGBA'],
dtype: typ.Literal['uint8', 'float32'],
):
"""Returns the managed blender image.

View File

@ -2,8 +2,9 @@ import typing as typ
import bpy
import jax.numpy as jnp
import sympy.physics.units as spu
from blender_maxwell.utils import logger
from blender_maxwell.utils import bl_cache, logger
from ... import contracts as ct
from ... import sockets
@ -11,11 +12,9 @@ from .. import base, events
log = logger.get(__name__)
CACHE_SIM_DATA = {}
class ExtractDataNode(base.MaxwellSimNode):
"""Node for extracting data from other objects."""
"""Node for extracting data from particular objects."""
node_type = ct.NodeType.ExtractData
bl_label = 'Extract'
@ -30,239 +29,196 @@ class ExtractDataNode(base.MaxwellSimNode):
}
####################
# - Properties: Sim Data
# - Properties
####################
sim_data__monitor_name: bpy.props.EnumProperty(
name='Sim Data Monitor Name',
description='Monitor to extract from the attached SimData',
items=lambda self, context: self.search_monitors(context),
update=lambda self, context: self.sync_prop('sim_data__monitor_name', context),
extract_filter: bpy.props.EnumProperty(
name='Extract Filter',
description='Data to extract from the input',
search=lambda self, _, edit_text: self.search_extract_filters(edit_text),
update=lambda self, context: self.on_prop_changed('extract_filter', context),
)
cache__num_monitors: bpy.props.StringProperty(default='')
cache__monitor_names: bpy.props.StringProperty(default='')
cache__monitor_types: bpy.props.StringProperty(default='')
# Sim Data
sim_data_monitor_nametype: dict[str, str] = bl_cache.BLField({})
def search_monitors(self, _: bpy.types.Context) -> list[tuple[str, str, str]]:
"""Search the linked simulation data for monitors."""
# No Linked Sim Data: Return 'None'
if not self.inputs.get('Sim Data') or not self.inputs['Sim Data'].is_linked:
return [('NONE', 'None', 'No monitors')]
# Field Data
field_data_components: set[str] = bl_cache.BLField(set())
# Return Monitor Names
## Special Case for No Monitors
monitor_names = (
self.cache__monitor_names.split(',') if self.cache__monitor_names else []
)
monitor_types = (
self.cache__monitor_types.split(',') if self.cache__monitor_types else []
)
if len(monitor_names) == 0:
return [('NONE', 'None', 'No monitors')]
def search_extract_filters(
self, _: bpy.types.Context
) -> list[tuple[str, str, str]]:
# Sim Data
if self.active_socket_set == 'Sim Data' and self.inputs['Sim Data'].is_linked:
return [
(
monitor_name,
f'{monitor_name}',
f'Monitor "{monitor_name}" ({monitor_type}) recorded by the Sim',
)
for monitor_name, monitor_type in zip(
monitor_names, monitor_types, strict=False
)
for monitor_name, monitor_type in self.sim_data_monitor_nametype.items()
]
def draw_props__sim_data(
self, _: bpy.types.Context, col: bpy.types.UILayout
) -> None:
col.prop(self, 'sim_data__monitor_name', text='')
# Field Data
if self.active_socket_set == 'Field Data' and self.inputs['Sim Data'].is_linked:
return [
([('Ex', 'Ex', 'Ex')] if 'Ex' in self.field_data_components else [])
+ ([('Ey', 'Ey', 'Ey')] if 'Ey' in self.field_data_components else [])
+ ([('Ez', 'Ez', 'Ez')] if 'Ez' in self.field_data_components else [])
+ ([('Hx', 'Hx', 'Hx')] if 'Hx' in self.field_data_components else [])
+ ([('Hy', 'Hy', 'Hy')] if 'Hy' in self.field_data_components else [])
+ ([('Hz', 'Hz', 'Hz')] if 'Hz' in self.field_data_components else [])
]
def draw_info__sim_data(
self, _: bpy.types.Context, col: bpy.types.UILayout
) -> None:
if self.sim_data__monitor_name != 'NONE':
# Flux Data
## Nothing to extract.
# Fallback
return []
####################
# - UI
####################
def draw_props(self, _: bpy.types.Context, col: bpy.types.UILayout) -> None:
col.prop(self, 'extract_filter', text='')
def draw_info(self, _: bpy.types.Context, col: bpy.types.UILayout) -> None:
if self.active_socket_set == 'Sim Data' and self.inputs['Sim Data'].is_linked:
# Header
row = col.row()
row.alignment = 'CENTER'
row.label(text=f'{self.cache__num_monitors} Monitors')
# Monitor Info
if int(self.cache__num_monitors) > 0:
for monitor_name, monitor_type in zip(
self.cache__monitor_names.split(','),
self.cache__monitor_types.split(','),
strict=False,
):
if len(self.sim_data_monitor_nametype) > 0:
for (
monitor_name,
monitor_type,
) in self.sim_data_monitor_nametype.items():
col.label(text=f'{monitor_name}: {monitor_type}')
####################
# - Events: Sim Data
# - Events
####################
@events.on_value_changed(
socket_name='Sim Data',
input_sockets={'Sim Data'},
input_sockets_optional={'Sim Data': True},
)
def on_sim_data_changed(self):
# SimData Cache Hit and SimData Input Unlinked
## Delete Cache Entry
if (
CACHE_SIM_DATA.get(self.instance_id) is not None
and not self.inputs['Sim Data'].is_linked
):
CACHE_SIM_DATA.pop(self.instance_id, None) ## Both member-check
self.cache__num_monitors = ''
self.cache__monitor_names = ''
self.cache__monitor_types = ''
# SimData Cache Miss and Linked SimData
if (
CACHE_SIM_DATA.get(self.instance_id) is None
and self.inputs['Sim Data'].is_linked
):
sim_data = self._compute_input('Sim Data')
## Create Cache Entry
CACHE_SIM_DATA[self.instance_id] = {
'sim_data': sim_data,
'monitor_names': list(sim_data.monitor_data.keys()),
'monitor_types': [
monitor_data.type for monitor_data in sim_data.monitor_data.values()
],
def on_sim_data_changed(self, input_sockets: dict):
if input_sockets['Sim Data'] is not None:
self.sim_data_monitor_nametype = {
monitor_name: monitor_data.type
for monitor_name, monitor_data in input_sockets[
'Sim Data'
].monitor_data.items()
}
cache = CACHE_SIM_DATA[self.instance_id]
self.cache__num_monitors = str(len(cache['monitor_names']))
self.cache__monitor_names = ','.join(cache['monitor_names'])
self.cache__monitor_types = ','.join(cache['monitor_types'])
####################
# - Properties: Field Data
####################
field_data__component: bpy.props.EnumProperty(
name='Field Data Component',
description='Field monitor component to extract from the attached Field Data',
items=lambda self, context: self.search_field_data_components(context),
update=lambda self, context: self.sync_prop('field_data__component', context),
)
cache__components: bpy.props.StringProperty(default='')
def search_field_data_components(
self, _: bpy.types.Context
) -> list[tuple[str, str, str]]:
if not self.inputs.get('Field Data') or not self.inputs['Field Data'].is_linked:
return [('NONE', 'None', 'No data')]
if not self.cache__components:
return [('NONE', 'Loading...', 'Loading data...')]
components = [
tuple(component_str.split(','))
for component_str in self.cache__components.split('|')
]
if len(components) == 0:
return [('NONE', 'None', 'No components')]
return components
def draw_props__field_data(
self, _: bpy.types.Context, col: bpy.types.UILayout
) -> None:
col.prop(self, 'field_data__component', text='')
def draw_info__field_data(
self, _: bpy.types.Context, col: bpy.types.UILayout
) -> None:
pass
####################
# - Events: Field Data
####################
@events.on_value_changed(
socket_name='Field Data',
)
def on_field_data_changed(self):
if self.inputs['Field Data'].is_linked and not self.cache__components:
field_data = self._compute_input('Field Data')
components = [
*([('Ex', 'Ex', 'Ex')] if field_data.Ex is not None else []),
*([('Ey', 'Ey', 'Ey')] if field_data.Ey is not None else []),
*([('Ez', 'Ez', 'Ez')] if field_data.Ez is not None else []),
*([('Hx', 'Hx', 'Hx')] if field_data.Hx is not None else []),
*([('Hy', 'Hy', 'Hy')] if field_data.Hy is not None else []),
*([('Hz', 'Hz', 'Hz')] if field_data.Hz is not None else []),
]
self.cache__components = '|'.join(
[','.join(component) for component in components]
)
elif not self.inputs['Field Data'].is_linked and self.cache__components:
self.cache__components = ''
####################
# - Flux Data
####################
def draw_props__flux_data(
self, _: bpy.types.Context, col: bpy.types.UILayout
) -> None:
pass
def draw_info__flux_data(
self, _: bpy.types.Context, col: bpy.types.UILayout
) -> None:
pass
####################
# - Global
####################
def draw_props(self, context: bpy.types.Context, col: bpy.types.UILayout) -> None:
if self.active_socket_set == 'Sim Data':
self.draw_props__sim_data(context, col)
if self.active_socket_set == 'Field Data':
self.draw_props__field_data(context, col)
if self.active_socket_set == 'Flux Data':
self.draw_props__flux_data(context, col)
def draw_info(self, context: bpy.types.Context, col: bpy.types.UILayout) -> None:
if self.active_socket_set == 'Sim Data':
self.draw_info__sim_data(context, col)
if self.active_socket_set == 'Field Data':
self.draw_info__field_data(context, col)
if self.active_socket_set == 'Flux Data':
self.draw_info__flux_data(context, col)
@events.computes_output_socket(
'Data',
props={'sim_data__monitor_name', 'field_data__component'},
input_sockets={'Field Data'},
input_sockets_optional={'Field Data': True},
)
def on_field_data_changed(self, input_sockets: dict):
if input_sockets['Field Data'] is not None:
self.field_data_components = (
{'Ex'}
if input_sockets['Field Data'].Ex is not None
else set() | {'Ey'}
if input_sockets['Field Data'].Ey is not None
else set() | {'Ez'}
if input_sockets['Field Data'].Ez is not None
else set() | {'Hx'}
if input_sockets['Field Data'].Hx is not None
else set() | {'Hy'}
if input_sockets['Field Data'].Hy is not None
else set() | {'Hz'}
if input_sockets['Field Data'].Hz is not None
else set()
)
####################
# - Output: Value
####################
@events.computes_output_socket(
'Data',
kind=ct.FlowKind.Value,
props={'active_socket_set', 'extract_filter'},
input_sockets={'Sim Data', 'Field Data', 'Flux Data'},
input_sockets_optional={
'Sim Data': True,
'Field Data': True,
'Flux Data': True,
},
)
def compute_extracted_data(self, props: dict, input_sockets: dict):
if self.active_socket_set == 'Sim Data':
if (
CACHE_SIM_DATA.get(self.instance_id) is None
and self.inputs['Sim Data'].is_linked
):
self.on_sim_data_changed()
if props['active_socket_set'] == 'Sim Data':
return input_sockets['Sim Data'].monitor_data[props['extract_filter']]
sim_data = CACHE_SIM_DATA[self.instance_id]['sim_data']
return sim_data.monitor_data[props['sim_data__monitor_name']]
if props['active_socket_set'] == 'Field Data':
return getattr(input_sockets['Field Data'], props['extract_filter'])
elif self.active_socket_set == 'Field Data': # noqa: RET505
xarr = getattr(input_sockets['Field Data'], props['field_data__component'])
if props['active_socket_set'] == 'Flux Data':
return input_sockets['Flux Data']
#return jarray.JArray.from_xarray(
# xarr,
# dim_units={
# 'x': spu.um,
# 'y': spu.um,
# 'z': spu.um,
# 'f': spu.hertz,
# },
#)
msg = f'Tried to get a "FlowKind.Value" from socket set {props["active_socket_set"]} in "{self.bl_label}"'
raise RuntimeError(msg)
elif self.active_socket_set == 'Flux Data':
flux_data = self._compute_input('Flux Data')
return jnp.array(flux_data.flux)
####################
# - Output: LazyValueFunc
####################
@events.computes_output_socket(
'Data',
kind=ct.FlowKind.LazyValueFunc,
props={'active_socket_set'},
output_sockets={'Data'},
output_socket_kinds={'Data': ct.FlowKind.Value},
)
def compute_extracted_data_lazy(self, props: dict, output_sockets: dict):
if self.active_socket_set in {'Field Data', 'Flux Data'}:
data = jnp.array(output_sockets['Data'].data)
return ct.LazyValueFuncFlow(func=lambda: data, supports_jax=True)
msg = f'Tried to get data from unknown output socket in "{self.bl_label}"'
msg = f'Tried to get a "FlowKind.LazyValueFunc" from socket set {props["active_socket_set"]} in "{self.bl_label}"'
raise RuntimeError(msg)
####################
# - Output: Info
####################
@events.computes_output_socket(
'Data',
kind=ct.FlowKind.Info,
props={'active_socket_set'},
output_sockets={'Data'},
output_socket_kinds={'Data': ct.FlowKind.Value},
)
def compute_extracted_data_info(self, props: dict, output_sockets: dict):
if props['active_socket_set'] == 'Field Data':
xarr = output_sockets['Data']
return ct.InfoFlow(
dim_names=['x', 'y', 'z', 'f'],
dim_idx={
axis: ct.ArrayFlow(values=xarr.get_index(axis).values, unit=spu.um)
for axis in ['x', 'y', 'z']
}
| {
'f': ct.ArrayFlow(
values=xarr.get_index('f').values, unit=spu.hertz
),
},
)
if props['active_socket_set'] == 'Flux Data':
xarr = output_sockets['Data']
return ct.InfoFlow(
dim_names=['f'],
dim_idx={
'f': ct.ArrayFlow(
values=xarr.get_index('f').values, unit=spu.hertz
),
},
)
msg = f'Tried to get a "FlowKind.Info" from socket set {props["active_socket_set"]} in "{self.bl_label}"'
raise RuntimeError(msg)

View File

@ -58,7 +58,7 @@ class FilterMathNode(base.MaxwellSimNode):
name='Op',
description='Operation to reduce the input axis with',
items=lambda self, _: self.search_operations(),
update=lambda self, context: self.sync_prop('operation', context),
update=lambda self, context: self.on_prop_changed('operation', context),
)
def search_operations(self) -> list[tuple[str, str, str]]:

View File

@ -43,7 +43,7 @@ class MapMathNode(base.MaxwellSimNode):
name='Op',
description='Operation to apply to the input',
items=lambda self, _: self.search_operations(),
update=lambda self, context: self.sync_prop('operation', context),
update=lambda self, context: self.on_prop_changed('operation', context),
)
def search_operations(self) -> list[tuple[str, str, str]]:
@ -101,9 +101,13 @@ class MapMathNode(base.MaxwellSimNode):
####################
@events.computes_output_socket(
'Data',
kind=ct.FlowKind.LazyValueFunc,
props={'active_socket_set', 'operation'},
input_sockets={'Data', 'Mapper'},
input_socket_kinds={'Mapper': ct.FlowKind.LazyValue},
input_socket_kinds={
'Data': ct.FlowKind.LazyValueFunc,
'Mapper': ct.FlowKind.LazyValueFunc,
},
input_sockets_optional={'Mapper': True},
)
def compute_data(self, props: dict, input_sockets: dict):
@ -150,8 +154,9 @@ class MapMathNode(base.MaxwellSimNode):
}[props['active_socket_set']][props['operation']]
# Compose w/Lazy Root Function Data
return input_sockets['Data'].compose(
function=mapping_func,
return input_sockets['Data'].compose_within(
mapping_func,
supports_jax=True,
)

View File

@ -42,7 +42,7 @@ class OperateMathNode(base.MaxwellSimNode):
name='Op',
description='Operation to apply to the two inputs',
items=lambda self, _: self.search_operations(),
update=lambda self, context: self.sync_prop('operation', context),
update=lambda self, context: self.on_prop_changed('operation', context),
)
def search_operations(self) -> list[tuple[str, str, str]]:

View File

@ -44,7 +44,7 @@ class ReduceMathNode(base.MaxwellSimNode):
name='Op',
description='Operation to reduce the input axis with',
items=lambda self, _: self.search_operations(),
update=lambda self, context: self.sync_prop('operation', context),
update=lambda self, context: self.on_prop_changed('operation', context),
)
def search_operations(self) -> list[tuple[str, str, str]]:
@ -79,24 +79,14 @@ class ReduceMathNode(base.MaxwellSimNode):
####################
@events.computes_output_socket(
'Data',
props={'operation'},
props={'active_socket_set', 'operation'},
input_sockets={'Data', 'Axis', 'Reducer'},
input_socket_kinds={'Reducer': ct.FlowKind.LazyValue},
input_socket_kinds={'Reducer': ct.FlowKind.LazyValueFunc},
input_sockets_optional={'Reducer': True},
)
def compute_data(self, props: dict, input_sockets: dict):
if not hasattr(input_sockets['Data'], 'shape'):
msg = 'Input socket "Data" must be an N-D Array (with a "shape" attribute)'
raise ValueError(msg)
if self.active_socket_set == 'Axis Expr':
ufunc = jnp.ufunc(input_sockets['Reducer'], nin=2, nout=1)
return ufunc.reduce(input_sockets['Data'], axis=input_sockets['Axis'])
if self.active_socket_set == 'By Axis':
## Dimension Reduction
# ('SQUEEZE', 'Squeeze', '(*, 1, *) -> (*, *)'),
# Accumulation
if props['active_socket_set'] == 'By Axis':
# Simple Accumulation
if props['operation'] == 'SUM':
return jnp.sum(input_sockets['Data'], axis=input_sockets['Axis'])
if props['operation'] == 'PROD':
@ -122,6 +112,10 @@ class ReduceMathNode(base.MaxwellSimNode):
if props['operation'] == 'SQUEEZE':
return jnp.squeeze(input_sockets['Data'], axis=input_sockets['Axis'])
if props['active_socket_set'] == 'Expr':
ufunc = jnp.ufunc(input_sockets['Reducer'], nin=2, nout=1)
return ufunc.reduce(input_sockets['Data'], axis=input_sockets['Axis'])
msg = 'Operation invalid'
raise ValueError(msg)

View File

@ -43,7 +43,7 @@ class VizNode(base.MaxwellSimNode):
('GRAYSCALE', 'Grayscale', 'Barebones'),
],
default='VIRIDIS',
update=lambda self, context: self.sync_prop('colormap', context),
update=lambda self, context: self.on_prop_changed('colormap', context),
)
#####################

View File

@ -10,13 +10,12 @@ from types import MappingProxyType
import bpy
import sympy as sp
import typing_extensions as typx
from blender_maxwell.utils import logger
from blender_maxwell.utils import bl_cache, logger
from .. import bl_cache, sockets
from .. import contracts as ct
from .. import managed_objs as _managed_objs
from .. import sockets
from . import events
from . import presets as _presets
@ -102,12 +101,12 @@ class MaxwellSimNode(bpy.types.Node):
Parameters:
name: The name of the property to set.
prop: The `bpy.types.Property` to instantiate and attach..
no_update: Don't attach a `self.sync_prop()` callback to the property's `update`.
no_update: Don't attach a `self.on_prop_changed()` callback to the property's `update`.
"""
_update_with_name = prop_name if update_with_name is None else update_with_name
extra_kwargs = (
{
'update': lambda self, context: self.sync_prop(
'update': lambda self, context: self.on_prop_changed(
_update_with_name, context
),
}
@ -316,7 +315,7 @@ class MaxwellSimNode(bpy.types.Node):
# - Socket Accessors
####################
def _bl_sockets(
self, direc: typx.Literal['input', 'output']
self, direc: typ.Literal['input', 'output']
) -> bpy.types.NodeInputs:
"""Retrieve currently visible Blender sockets on the node, by-direction.
@ -335,7 +334,7 @@ class MaxwellSimNode(bpy.types.Node):
def _active_socket_set_socket_defs(
self,
direc: typx.Literal['input', 'output'],
direc: typ.Literal['input', 'output'],
) -> dict[ct.SocketName, sockets.base.SocketDef]:
"""Retrieve all socket definitions for sockets that should be defined, according to the `self.active_socket_set`.
@ -361,7 +360,7 @@ class MaxwellSimNode(bpy.types.Node):
return socket_sets.get(self.active_socket_set, {})
def active_socket_defs(
self, direc: typx.Literal['input', 'output']
self, direc: typ.Literal['input', 'output']
) -> dict[ct.SocketName, sockets.base.SocketDef]:
"""Retrieve all socket definitions for sockets that should be defined.
@ -664,6 +663,9 @@ class MaxwellSimNode(bpy.types.Node):
Notes:
This can be an unpredictably heavy function, depending on the node graph topology.
Doesn't currently accept `LinkChanged` (->Output) events; rather, these propagate as `DataChanged` events.
**This may change** if it becomes important for the node to differentiate between "change in data" and "change in link".
Parameters:
event: The event to report forwards/backwards along the node tree.
socket_name: The input socket that was altered, if any, in order to trigger this event.
@ -714,7 +716,7 @@ class MaxwellSimNode(bpy.types.Node):
####################
# - Property Event: On Update
####################
def sync_prop(self, prop_name: str, _: bpy.types.Context) -> None:
def on_prop_changed(self, prop_name: str, _: bpy.types.Context) -> None:
"""Report that a particular property has changed, which may cause certain caches to regenerate.
Notes:

View File

@ -1,14 +1,16 @@
# from . import scientific_constant
# from . import physical_constant
from . import blender_constant, number_constant, scientific_constant
from . import blender_constant, expr_constant, number_constant, scientific_constant
BL_REGISTER = [
*expr_constant.BL_REGISTER,
*scientific_constant.BL_REGISTER,
*number_constant.BL_REGISTER,
# *physical_constant.BL_REGISTER,
*blender_constant.BL_REGISTER,
]
BL_NODES = {
**expr_constant.BL_NODES,
**scientific_constant.BL_NODES,
**number_constant.BL_NODES,
# **physical_constant.BL_NODES,

View File

@ -0,0 +1,41 @@
import typing as typ
from .... import contracts as ct
from .... import sockets
from ... import base, events
class ExprConstantNode(base.MaxwellSimNode):
node_type = ct.NodeType.ExprConstant
bl_label = 'Expr Constant'
input_sockets: typ.ClassVar = {
'Expr': sockets.ExprSocketDef(),
}
output_sockets: typ.ClassVar = {
'Expr': sockets.ExprSocketDef(),
}
## TODO: Symbols (defined w/props?)
## - Currently expr constant isn't excessively useful, since there are no variables.
## - We'll define the #, type, name with props.
## - We'll add loose-socket inputs as int/real/complex/physical socket (based on type) for Param.
## - We the output expr would support `Value` (just the expression), `LazyValueFunc` (evaluate w/symbol support), `Param` (example values for symbols).
####################
# - Callbacks
####################
@events.computes_output_socket(
'Expr', kind=ct.FlowKind.Value, input_sockets={'Expr'}
)
def compute_value(self, input_sockets: dict) -> typ.Any:
return input_sockets['Expr']
####################
# - Blender Registration
####################
BL_REGISTER = [
ExprConstantNode,
]
BL_NODES = {ct.NodeType.ExprConstant: (ct.NodeCategory.MAXWELLSIM_INPUTS_CONSTANTS)}

View File

@ -56,7 +56,7 @@ class ScientificConstantNode(base.MaxwellSimNode):
self.cache__units = ''
self.cache__uncertainty = ''
self.sync_prop('sci_constant', context)
self.on_prop_changed('sci_constant', context)
####################
# - UI

View File

@ -74,7 +74,7 @@ class Tidy3DFileImporterNode(base.MaxwellSimNode):
),
],
default='SIMULATION_DATA',
update=lambda self, context: self.sync_prop('tidy3d_type', context),
update=lambda self, context: self.on_prop_changed('tidy3d_type', context),
)
disp_fit__min_poles: bpy.props.IntProperty(

View File

@ -28,7 +28,7 @@ class WaveConstantNode(base.MaxwellSimNode):
name='Range',
description='Whether to use a wavelength/frequency range',
default=False,
update=lambda self, context: self.sync_prop('use_range', context),
update=lambda self, context: self.on_prop_changed('use_range', context),
)
def draw_props(self, _: bpy.types.Context, col: bpy.types.UILayout):
@ -74,7 +74,7 @@ class WaveConstantNode(base.MaxwellSimNode):
@events.computes_output_socket(
'WL',
kind=ct.FlowKind.LazyValueRange,
kind=ct.FlowKind.LazyArrayRange,
# Data
input_sockets={'WL', 'Freq'},
input_sockets_optional={'WL': True, 'Freq': True},
@ -93,12 +93,12 @@ class WaveConstantNode(base.MaxwellSimNode):
@events.computes_output_socket(
'Freq',
kind=ct.FlowKind.LazyValueRange,
kind=ct.FlowKind.LazyArrayRange,
# Data
input_sockets={'WL', 'Freq'},
input_socket_kinds={
'WL': ct.FlowKind.LazyValueRange,
'Freq': ct.FlowKind.LazyValueRange,
'WL': ct.FlowKind.LazyArrayRange,
'Freq': ct.FlowKind.LazyArrayRange,
},
input_sockets_optional={'WL': True, 'Freq': True},
)

View File

@ -3,7 +3,6 @@ from pathlib import Path
from blender_maxwell.utils import logger
from ...... import info
from ......services import tdcloud
from .... import contracts as ct
from .... import sockets
@ -18,8 +17,8 @@ def _sim_data_cache_path(task_id: str) -> Path:
Arguments:
task_id: The ID of the Tidy3D cloud task.
"""
(info.ADDON_CACHE / task_id).mkdir(exist_ok=True)
return info.ADDON_CACHE / task_id / 'sim_data.hdf5'
(ct.addon.ADDON_CACHE / task_id).mkdir(exist_ok=True)
return ct.addon.ADDON_CACHE / task_id / 'sim_data.hdf5'
####################

View File

@ -54,7 +54,7 @@ class LibraryMediumNode(base.MaxwellSimNode):
if mat_key != 'graphene' ## For some reason, it's unique...
],
default='Au',
update=(lambda self, context: self.sync_prop('material', context)),
update=(lambda self, context: self.on_prop_changed('material', context)),
)
@property

View File

@ -68,7 +68,7 @@ class EHFieldMonitorNode(base.MaxwellSimNode):
'Freqs',
},
input_socket_kinds={
'Freqs': ct.FlowKind.LazyValueRange,
'Freqs': ct.FlowKind.LazyArrayRange,
},
unit_systems={'Tidy3DUnits': ct.UNITS_TIDY3D},
scale_input_sockets={

View File

@ -68,7 +68,7 @@ class PowerFluxMonitorNode(base.MaxwellSimNode):
'Direction',
},
input_socket_kinds={
'Freqs': ct.FlowKind.LazyValueRange,
'Freqs': ct.FlowKind.LazyArrayRange,
},
unit_systems={'Tidy3DUnits': ct.UNITS_TIDY3D},
scale_input_sockets={

View File

@ -60,14 +60,14 @@ class ViewerNode(base.MaxwellSimNode):
name='Auto-Plot',
description='Whether to auto-plot anything plugged into the viewer node',
default=False,
update=lambda self, context: self.sync_prop('auto_plot', context),
update=lambda self, context: self.on_prop_changed('auto_plot', context),
)
auto_3d_preview: bpy.props.BoolProperty(
name='Auto 3D Preview',
description="Whether to auto-preview anything 3D, that's plugged into the viewer node",
default=True,
update=lambda self, context: self.sync_prop('auto_3d_preview', context),
update=lambda self, context: self.on_prop_changed('auto_3d_preview', context),
)
####################

View File

@ -190,7 +190,7 @@ class Tidy3DWebExporterNode(base.MaxwellSimNode):
else:
self.trigger_event(ct.FlowEvent.DisableLock)
self.sync_prop('lock_tree', context)
self.on_prop_changed('lock_tree', context)
def sync_tracked_task_id(self, context):
# Select Tracked Task
@ -212,7 +212,7 @@ class Tidy3DWebExporterNode(base.MaxwellSimNode):
self.inputs['Cloud Task'].sync_prepare_new_task()
self.inputs['Cloud Task'].locked = False
self.sync_prop('tracked_task_id', context)
self.on_prop_changed('tracked_task_id', context)
####################
# - Output Socket Callbacks

View File

@ -42,7 +42,7 @@ class PointDipoleSourceNode(base.MaxwellSimNode):
('EZ', 'Ez', 'Electric field in z-dir'),
],
default='EX',
update=(lambda self, context: self.sync_prop('pol_axis', context)),
update=(lambda self, context: self.on_prop_changed('pol_axis', context)),
)
####################

View File

@ -52,13 +52,13 @@ class GaussianPulseTemporalShapeNode(base.MaxwellSimNode):
name='Plot Time Start (ps)',
description='The instance ID of a particular MaxwellSimNode instance, used to index caches',
default=0.0,
update=(lambda self, context: self.sync_prop('plot_time_start', context)),
update=(lambda self, context: self.on_prop_changed('plot_time_start', context)),
)
plot_time_end: bpy.props.FloatProperty(
name='Plot Time End (ps)',
description='The instance ID of a particular MaxwellSimNode instance, used to index caches',
default=5,
update=(lambda self, context: self.sync_prop('plot_time_start', context)),
update=(lambda self, context: self.on_prop_changed('plot_time_start', context)),
)
####################

View File

@ -69,7 +69,7 @@ class CombineNode(base.MaxwellSimNode):
default=1,
min=1,
# max=MAX_AMOUNT,
update=lambda self, context: self.sync_prop('amount', context),
update=lambda self, context: self.on_prop_changed('amount', context),
)
####################

View File

@ -5,8 +5,8 @@ import typing as typ
import bpy
import pydantic as pyd
import sympy as sp
import typing_extensions as typx
from blender_maxwell.utils import extra_sympy_units as spux
from blender_maxwell.utils import logger, serialize
from .. import contracts as ct
@ -100,7 +100,7 @@ class MaxwellSimSocket(bpy.types.NodeSocket):
bl_label: str
# Style
display_shape: typx.Literal[
display_shape: typ.Literal[
'CIRCLE',
'SQUARE',
'DIAMOND',
@ -144,12 +144,12 @@ class MaxwellSimSocket(bpy.types.NodeSocket):
Parameters:
name: The name of the property to set.
prop: The `bpy.types.Property` to instantiate and attach..
no_update: Don't attach a `self.sync_prop()` callback to the property's `update`.
no_update: Don't attach a `self.on_prop_changed()` callback to the property's `update`.
"""
_update_with_name = prop_name if update_with_name is None else update_with_name
extra_kwargs = (
{
'update': lambda self, context: self.sync_prop(
'update': lambda self, context: self.on_prop_changed(
_update_with_name, context
),
}
@ -185,7 +185,7 @@ class MaxwellSimSocket(bpy.types.NodeSocket):
# Configure Use of Units
if cls.use_units:
if not (socket_units := ct.SOCKET_UNITS.get(cls.socket_type)):
msg = f'Tried to define "use_units" on socket {cls.bl_label} socket, but there is no unit for {cls.socket_type} defined in "contracts.SOCKET_UNITS"'
msg = f'{cls.socket_type}: Tried to define "use_units", but there is no unit for {cls.socket_type} defined in "contracts.SOCKET_UNITS"'
raise RuntimeError(msg)
cls.set_prop(
@ -193,7 +193,7 @@ class MaxwellSimSocket(bpy.types.NodeSocket):
bpy.props.EnumProperty,
name='Unit',
items=[
(unit_name, str(unit_value), str(unit_value))
(unit_name, spux.sp_to_str(unit_value), sp.srepr(unit_value))
for unit_name, unit_value in socket_units['values'].items()
],
default=socket_units['default'],
@ -204,6 +204,49 @@ class MaxwellSimSocket(bpy.types.NodeSocket):
default=socket_units['default'],
)
####################
# - Units
####################
# TODO: Refactor
@functools.cached_property
def possible_units(self) -> dict[str, sp.Expr]:
if not self.use_units:
msg = "Tried to get possible units for socket {self}, but socket doesn't `use_units`"
raise ValueError(msg)
return ct.SOCKET_UNITS[self.socket_type]['values']
@property
def unit(self) -> sp.Expr:
return self.possible_units[self.active_unit]
@property
def prev_unit(self) -> sp.Expr:
return self.possible_units[self.prev_active_unit]
@unit.setter
def unit(self, value: str | sp.Expr) -> None:
# Retrieve Unit by String
if isinstance(value, str) and value in self.possible_units:
self.active_unit = self.possible_units[value]
return
# Retrieve =1 Matching Unit Name
matching_unit_names = [
unit_name
for unit_name, unit_sympy in self.possible_units.items()
if value == unit_sympy
]
if len(matching_unit_names) == 0:
msg = f"Tried to set unit for socket {self} with value {value}, but it is not one of possible units {''.join(self.possible_units.values())} for this socket (as defined in `contracts.SOCKET_UNITS`)"
raise ValueError(msg)
if len(matching_unit_names) > 1:
msg = f"Tried to set unit for socket {self} with value {value}, but multiple possible matching units {''.join(self.possible_units.values())} for this socket (as defined in `contracts.SOCKET_UNITS`); there may only be one"
raise RuntimeError(msg)
self.active_unit = matching_unit_names[0]
####################
# - Property Event: On Update
####################
@ -215,7 +258,8 @@ class MaxwellSimSocket(bpy.types.NodeSocket):
"""
self.display_shape = (
'SQUARE'
if self.active_kind in {ct.FlowKind.LazyValue, ct.FlowKind.LazyValueRange}
if self.active_kind
in {ct.FlowKind.LazyValueFunc, ct.FlowKind.LazyValueRange}
else 'CIRCLE'
) + ('_DOT' if self.use_units else '')
@ -241,12 +285,12 @@ class MaxwellSimSocket(bpy.types.NodeSocket):
self.prev_unit
).rescale_to_unit(self.unit)
else:
msg = f'Active kind {self.active_kind} has no way of scaling units (from {self.prev_active_unit} to {self.active_unit}). Please check the node definition'
msg = f'Socket {self.bl_label} ({self.socket_type}): Active kind {self.active_kind} declares no method of scaling units from {self.prev_active_unit} to {self.active_unit})'
raise RuntimeError(msg)
self.prev_active_unit = self.active_unit
def sync_prop(self, prop_name: str, _: bpy.types.Context) -> None:
def on_prop_changed(self, prop_name: str, _: bpy.types.Context) -> None:
"""Called when a property has been updated.
Contrary to `node.on_prop_changed()`, socket-specific callbacks are baked into this function:
@ -269,7 +313,7 @@ class MaxwellSimSocket(bpy.types.NodeSocket):
# Undefined Properties
else:
msg = f'Property {prop_name} not defined on socket {self}'
msg = f'Property {prop_name} not defined on socket {self.bl_label} ({self.socket_type})'
raise RuntimeError(msg)
####################
@ -298,7 +342,7 @@ class MaxwellSimSocket(bpy.types.NodeSocket):
"""
# Output Socket Check
if self.is_output:
msg = 'Tried to ask output socket for consent to add link'
msg = f'Socket {self.bl_label} {self.socket_type}): Tried to ask output socket for consent to add link'
raise RuntimeError(msg)
# Lock Check
@ -361,7 +405,7 @@ class MaxwellSimSocket(bpy.types.NodeSocket):
"""
# Output Socket Check
if self.is_output:
msg = "Tried to sync 'link add' on output socket"
msg = f'Socket {self.bl_label} {self.socket_type}): Tried to ask output socket for consent to remove link'
raise RuntimeError(msg)
# Lock Check
@ -389,29 +433,38 @@ class MaxwellSimSocket(bpy.types.NodeSocket):
self,
event: ct.FlowEvent,
) -> None:
"""Recursively triggers an event along the node tree, depending on whether the socket is an input or output socket.
"""Responds to and triggers subsequent events along the node tree.
- **Locking**: `EnableLock` or `DisableLock` will always affect this socket's lock.
- **Input Socket -> Input**: Trigger event on `from_socket`s along input links.
- **Input Socket -> Output**: Trigger event on node (w/`socket_name`).
- **Output Socket -> Input**: Trigger event on node (w/`socket_name`).
- **Output Socket -> Output**: Trigger event on `to_socket`s along output links.
Notes:
This can be an unpredictably heavy function, depending on the node graph topology.
A `LinkChanged` (->Output) event will trigger a `DataChanged` event on the node.
**This may change** if it becomes important for the node to differentiate between "change in data" and "change in link".
Parameters:
event: The event to report along the node tree.
The value of `ct.FlowEvent.flow_direction[event]` must match either `input` or `output`, depending on whether the socket is input/output.
The value of `ct.FlowEvent.flow_direction[event]` (`input` or `output`) determines the direction that an event flows.
"""
flow_direction = ct.FlowEvent.flow_direction[event]
# Input Socket | Input Flow
if not self.is_output and flow_direction == 'input':
# Locking
if event in [ct.FlowEvent.EnableLock, ct.FlowEvent.DisableLock]:
self.locked = event == ct.FlowEvent.EnableLock
# Input Socket | Input Flow
if not self.is_output and flow_direction == 'input':
for link in self.links:
link.from_socket.trigger_event(event)
# Input Socket | Output Flow
if not self.is_output and flow_direction == 'output':
## THIS IS A WORKAROUND (bc Node only understands DataChanged)
## TODO: Handle LinkChanged on the node.
if event == ct.FlowEvent.LinkChanged:
self.node.trigger_event(ct.FlowEvent.DataChanged, socket_name=self.name)
@ -419,9 +472,6 @@ class MaxwellSimSocket(bpy.types.NodeSocket):
# Output Socket | Input Flow
if self.is_output and flow_direction == 'input':
if event in [ct.FlowEvent.EnableLock, ct.FlowEvent.DisableLock]:
self.locked = event == ct.FlowEvent.EnableLock
self.node.trigger_event(event, socket_name=self.name)
# Output Socket | Output Flow
@ -435,6 +485,11 @@ class MaxwellSimSocket(bpy.types.NodeSocket):
# Capabilities
@property
def capabilities(self) -> None:
"""By default, the socket is linkeable with any other socket of the same type and active kind.
Notes:
See `ct.FlowKind` for more information.
"""
return ct.DataCapabilities(
socket_type=self.socket_type,
active_kind=self.active_kind,
@ -443,57 +498,164 @@ class MaxwellSimSocket(bpy.types.NodeSocket):
# Value
@property
def value(self) -> ct.ValueFlow:
raise NotImplementedError
"""Throws a descriptive error.
Notes:
See `ct.FlowKind` for more information.
Raises:
NotImplementedError: When used without being overridden.
"""
msg = f'Socket {self.bl_label} {self.socket_type}): Tried to get "ct.FlowKind.Value", but socket does not define it'
raise NotImplementedError(msg)
@value.setter
def value(self, value: ct.ValueFlow) -> None:
raise NotImplementedError
"""Throws a descriptive error.
Notes:
See `ct.FlowKind` for more information.
Raises:
NotImplementedError: When used without being overridden.
"""
msg = f'Socket {self.bl_label} {self.socket_type}): Tried to set "ct.FlowKind.Value", but socket does not define it'
raise NotImplementedError(msg)
# ValueArray
@property
def array(self) -> ct.ArrayFlow:
## TODO: Single-element list when value exists.
raise NotImplementedError
"""Throws a descriptive error.
Notes:
See `ct.FlowKind` for more information.
Raises:
NotImplementedError: When used without being overridden.
"""
msg = f'Socket {self.bl_label} {self.socket_type}): Tried to get "ct.FlowKind.Array", but socket does not define it'
raise NotImplementedError(msg)
@array.setter
def array(self, value: ct.ArrayFlow) -> None:
raise NotImplementedError
"""Throws a descriptive error.
# LazyValue
Notes:
See `ct.FlowKind` for more information.
Raises:
NotImplementedError: When used without being overridden.
"""
msg = f'Socket {self.bl_label} {self.socket_type}): Tried to set "ct.FlowKind.Array", but socket does not define it'
raise NotImplementedError(msg)
# LazyValueFunc
@property
def lazy_value(self) -> ct.LazyValueFlow:
raise NotImplementedError
def lazy_value_func(self) -> ct.LazyValueFuncFlow:
"""Throws a descriptive error.
@lazy_value.setter
def lazy_value(self, lazy_value: ct.LazyValueFlow) -> None:
raise NotImplementedError
Notes:
See `ct.FlowKind` for more information.
Raises:
NotImplementedError: When used without being overridden.
"""
msg = f'Socket {self.bl_label} {self.socket_type}): Tried to get "ct.FlowKind.LazyValueFunc", but socket does not define it'
raise NotImplementedError(msg)
@lazy_value_func.setter
def lazy_value_func(self, lazy_value_func: ct.LazyValueFuncFlow) -> None:
"""Throws a descriptive error.
Notes:
See `ct.FlowKind` for more information.
Raises:
NotImplementedError: When used without being overridden.
"""
msg = f'Socket {self.bl_label} {self.socket_type}): Tried to set "ct.FlowKind.LazyValueFunc", but socket does not define it'
raise NotImplementedError(msg)
# LazyArrayRange
@property
def lazy_array_range(self) -> ct.LazyArrayRangeFlow:
raise NotImplementedError
"""Throws a descriptive error.
Notes:
See `ct.FlowKind` for more information.
Raises:
NotImplementedError: When used without being overridden.
"""
msg = f'Socket {self.bl_label} {self.socket_type}): Tried to get "ct.FlowKind.LazyArrayRange", but socket does not define it'
raise NotImplementedError(msg)
@lazy_array_range.setter
def lazy_array_range(self, value: tuple[ct.DataValue, ct.DataValue, int]) -> None:
raise NotImplementedError
"""Throws a descriptive error.
Notes:
See `ct.FlowKind` for more information.
Raises:
NotImplementedError: When used without being overridden.
"""
msg = f'Socket {self.bl_label} {self.socket_type}): Tried to set "ct.FlowKind.LazyArrayRange", but socket does not define it'
raise NotImplementedError(msg)
# Param
@property
def param(self) -> ct.ParamsFlow:
raise NotImplementedError
"""Throws a descriptive error.
Notes:
See `ct.FlowKind` for more information.
Raises:
NotImplementedError: When used without being overridden.
"""
msg = f'Socket {self.bl_label} {self.socket_type}): Tried to get "ct.FlowKind.Param", but socket does not define it'
raise NotImplementedError(msg)
@param.setter
def param(self, value: tuple[ct.DataValue, ct.DataValue, int]) -> None:
raise NotImplementedError
"""Throws a descriptive error.
Notes:
See `ct.FlowKind` for more information.
Raises:
NotImplementedError: When used without being overridden.
"""
msg = f'Socket {self.bl_label} {self.socket_type}): Tried to set "ct.FlowKind.Param", but socket does not define it'
raise NotImplementedError(msg)
# Info
@property
def info(self) -> ct.ParamsFlow:
raise NotImplementedError
"""Throws a descriptive error.
Notes:
See `ct.FlowKind` for more information.
Raises:
NotImplementedError: When used without being overridden.
"""
msg = f'Socket {self.bl_label} {self.socket_type}): Tried to get "ct.FlowKind.Info", but socket does not define it'
raise NotImplementedError(msg)
@info.setter
def info(self, value: tuple[ct.DataValue, ct.DataValue, int]) -> None:
raise NotImplementedError
"""Throws a descriptive error.
Notes:
See `ct.FlowKind` for more information.
Raises:
NotImplementedError: When used without being overridden.
"""
msg = f'Socket {self.bl_label} {self.socket_type}): Tried to set "ct.FlowKind.Info", but socket does not define it'
raise NotImplementedError(msg)
####################
# - Data Chain Computation
@ -502,32 +664,50 @@ class MaxwellSimSocket(bpy.types.NodeSocket):
self,
kind: ct.FlowKind = ct.FlowKind.Value,
) -> typ.Any:
"""Computes the internal data of this socket, ONLY.
"""Low-level method to computes the data contained within this socket, for a particular `ct.FlowKind`.
**NOTE**: Low-level method. Use `compute_data` instead.
Notes:
Not all `ct.FlowKind`s are meant to be computed; namely, `Capabilities` should be directly referenced.
Raises:
ValueError: When referencing a socket that's meant to be directly referenced.
"""
return {
kind_data_map = {
ct.FlowKind.Value: lambda: self.value,
ct.FlowKind.ValueArray: lambda: self.value_array,
ct.FlowKind.LazyValue: lambda: self.lazy_value,
ct.FlowKind.LazyValueFunc: lambda: self.lazy_value,
ct.FlowKind.LazyArrayRange: lambda: self.lazy_array_range,
ct.FlowKind.Params: lambda: self.params,
ct.FlowKind.Info: lambda: self.info,
}[kind]()
}
if kind in kind_data_map:
return kind_data_map[kind]()
msg = f'socket._compute_data was called with invalid kind "{kind}"'
raise RuntimeError(msg)
## TODO: Reflect this constraint in the type
msg = f'Socket {self.bl_label} ({self.socket_type}): Kind {kind} cannot be computed within a socket "compute_data", as it is meant to be referenced directly'
raise ValueError(msg)
def compute_data(
self,
kind: ct.FlowKind = ct.FlowKind.Value,
):
"""Computes the value of this socket, including all relevant factors.
) -> typ.Any:
"""Computes internal or link-sourced data represented by this socket.
- **Input Socket | Unlinked**: Use socket's own data, by calling `_compute_data`.
- **Input Socket | Linked**: Call `compute_data` on the linked `from_socket`.
- **Output Socket**: Use the node's output data, by calling `node.compute_output()`.
Notes:
- If input socket, and unlinked, compute internal data.
- If input socket, and linked, compute linked socket data.
- If output socket, ask node for data.
This can be an unpredictably heavy function, depending on the node graph topology.
Parameters:
kind: The `ct.FlowKind` to reference when retrieving the data.
Returns:
The computed data, whever it came from.
Raises:
NotImplementedError: If multi-input sockets are used (no support yet as of Blender 4.1).
"""
# Compute Output Socket
if self.is_output:
@ -538,62 +718,17 @@ class MaxwellSimSocket(bpy.types.NodeSocket):
if not self.is_linked:
return self._compute_data(kind)
## Linked: Check Capabilities
for link in self.links:
if not link.from_socket.capabilities.is_compatible_with(self.capabilities):
msg = f'Output socket "{link.from_socket.bl_label}" is linked to input socket "{self.bl_label}" with incompatible capabilities (caps_out="{link.from_socket.capabilities}", caps_in="{self.capabilities}")'
raise ValueError(msg)
## ...and Compute Data on Linked Socket
## Linked: Compute Data on Linked Socket
## -> Capabilities are guaranteed compatible by 'allow_link_add'.
## -> There is no point in rechecking every time data flows.
linked_values = [link.from_socket.compute_data(kind) for link in self.links]
# Return Single Value / List of Values
## Preparation for multi-input sockets.
if len(linked_values) == 1:
return linked_values[0]
return linked_values
####################
# - Unit Properties
####################
@functools.cached_property
def possible_units(self) -> dict[str, sp.Expr]:
if not self.use_units:
msg = "Tried to get possible units for socket {self}, but socket doesn't `use_units`"
raise ValueError(msg)
return ct.SOCKET_UNITS[self.socket_type]['values']
@property
def unit(self) -> sp.Expr:
return self.possible_units[self.active_unit]
@property
def prev_unit(self) -> sp.Expr:
return self.possible_units[self.prev_active_unit]
@unit.setter
def unit(self, value: str | sp.Expr) -> None:
# Retrieve Unit by String
if isinstance(value, str) and value in self.possible_units:
self.active_unit = self.possible_units[value]
return
# Retrieve =1 Matching Unit Name
matching_unit_names = [
unit_name
for unit_name, unit_sympy in self.possible_units.items()
if value == unit_sympy
]
if len(matching_unit_names) == 0:
msg = f"Tried to set unit for socket {self} with value {value}, but it is not one of possible units {''.join(self.possible_units.values())} for this socket (as defined in `contracts.SOCKET_UNITS`)"
raise ValueError(msg)
if len(matching_unit_names) > 1:
msg = f"Tried to set unit for socket {self} with value {value}, but multiple possible matching units {''.join(self.possible_units.values())} for this socket (as defined in `contracts.SOCKET_UNITS`); there may only be one"
raise RuntimeError(msg)
self.active_unit = matching_unit_names[0]
msg = f'Socket {self.bl_label} ({self.socket_type}): Multi-input sockets are not yet supported'
return NotImplementedError(msg)
####################
# - Theme
@ -611,7 +746,7 @@ class MaxwellSimSocket(bpy.types.NodeSocket):
return cls.socket_color
####################
# - UI Methods
# - UI
####################
def draw(
self,
@ -724,7 +859,7 @@ class MaxwellSimSocket(bpy.types.NodeSocket):
{
ct.FlowKind.Value: self.draw_value,
ct.FlowKind.Array: self.draw_value_array,
ct.FlowKind.LazyValue: self.draw_lazy_value,
ct.FlowKind.LazyValueFunc: self.draw_lazy_value,
ct.FlowKind.LazyValueRange: self.draw_lazy_value_range,
}[self.active_kind](col)
@ -791,7 +926,7 @@ class MaxwellSimSocket(bpy.types.NodeSocket):
"""Draws the socket lazy value on its own line.
Notes:
Should be overriden by individual socket classes, if they have an editable `FlowKind.LazyValue`.
Should be overriden by individual socket classes, if they have an editable `FlowKind.LazyValueFunc`.
Parameters:
col: Target for defining UI elements.

View File

@ -18,7 +18,7 @@ class BoolBLSocket(base.MaxwellSimSocket):
name='Boolean',
description='Represents a boolean value',
default=False,
update=(lambda self, context: self.sync_prop('raw_value', context)),
update=(lambda self, context: self.on_prop_changed('raw_value', context)),
)
####################

View File

@ -1,10 +1,12 @@
import typing as typ
import bpy
import pydantic as pyd
import sympy as sp
from blender_maxwell.utils import bl_cache
from blender_maxwell.utils import extra_sympy_units as spux
from blender_maxwell.utils.pydantic_sympy import SympyExpr
from ... import bl_cache
from ... import contracts as ct
from .. import base
@ -20,12 +22,26 @@ class ExprBLSocket(base.MaxwellSimSocket):
name='Expr',
description='Represents a symbolic expression',
default='',
update=(lambda self, context: self.sync_prop('raw_value', context)),
update=(lambda self, context: self.on_prop_changed('raw_value', context)),
)
symbols: list[sp.Symbol] = bl_cache.BLField([])
## TODO: Way of assigning assumptions to symbols.
## TODO: Dynamic add/remove of symbols
int_symbols: set[spux.IntSymbol] = bl_cache.BLField([])
real_symbols: set[spux.RealSymbol] = bl_cache.BLField([])
complex_symbols: set[spux.ComplexSymbol] = bl_cache.BLField([])
@property
def symbols(self) -> list[spux.Symbol]:
"""Retrieves all symbols by concatenating int, real, and complex symbols, and sorting them by name.
The order is guaranteed to be **deterministic**.
Returns:
All symbols valid for use in the expression.
"""
return sorted(
self.int_symbols | self.real_symbols | self.complex_symbols,
key=lambda sym: sym.name,
)
####################
# - Socket UI
@ -38,21 +54,30 @@ class ExprBLSocket(base.MaxwellSimSocket):
####################
@property
def value(self) -> sp.Expr:
return sp.sympify(
expr = sp.sympify(
self.raw_value,
locals={sym.name: sym for sym in self.symbols},
strict=False,
convert_xor=True,
).subs(spux.ALL_UNIT_SYMBOLS)
if not expr.free_symbols.issubset(self.symbols):
msg = f'Expression "{expr}" (symbols={self.expr.free_symbols}) has invalid symbols (valid symbols: {self.symbols})'
raise ValueError(msg)
return expr
@value.setter
def value(self, value: str) -> None:
self.raw_value = str(value)
self.raw_value = sp.sstr(value)
@property
def lazy_value(self) -> sp.Expr:
return ct.LazyDataValue.from_function(
sp.lambdify(self.symbols, self.value, 'jax'),
free_args=(tuple(str(sym) for sym in self.symbols), frozenset()),
def lazy_value_func(self) -> ct.LazyValueFuncFlow:
return ct.LazyValueFuncFlow(
func=sp.lambdify(self.symbols, self.value, 'jax'),
func_args=[
(sym.name, spux.sympy_to_python_type(sym)) for sym in self.symbols
],
supports_jax=True,
)
@ -64,8 +89,35 @@ class ExprSocketDef(base.SocketDef):
socket_type: ct.SocketType = ct.SocketType.Expr
_x = sp.Symbol('x', real=True)
symbols: list[SympyExpr] = [_x]
default_expr: SympyExpr = _x
int_symbols: list[spux.IntSymbol] = []
real_symbols: list[spux.RealSymbol] = [_x]
complex_symbols: list[spux.ComplexSymbol] = []
# Expression
default_expr: spux.SympyExpr = _x
allow_units: bool = True
@pyd.model_validator(mode='after')
def check_default_expr_follows_unit_allowance(self) -> typ.Self:
"""Checks that `self.default_expr` only uses units if `self.allow_units` is defined.
Raises:
ValueError: If the expression uses symbols not defined in `self.symbols`.
"""
if not spux.uses_units(self.default_expr):
msg = f'Expression symbols ({self.default_expr.free_symbol}) are not a strict subset of defined symbols ({self.symbols})'
raise ValueError(msg)
@pyd.model_validator(mode='after')
def check_default_expr_uses_allowed_symbols(self) -> typ.Self:
"""Checks that `self.default_expr` only uses symbols defined in `self.symbols`.
Raises:
ValueError: If the expression uses symbols not defined in `self.symbols`.
"""
if not self.default_expr.free_symbols.issubset(self.symbols):
msg = f'Expression symbols ({self.default_expr.free_symbol}) are not a strict subset of defined symbols ({self.symbols})'
raise ValueError(msg)
def init(self, bl_socket: ExprBLSocket) -> None:
bl_socket.value = self.default_expr

View File

@ -20,7 +20,7 @@ class FilePathBLSocket(base.MaxwellSimSocket):
name='File Path',
description='Represents the path to a file',
subtype='FILE_PATH',
update=(lambda self, context: self.sync_prop('raw_value', context)),
update=(lambda self, context: self.on_prop_changed('raw_value', context)),
)
####################

View File

@ -18,7 +18,7 @@ class StringBLSocket(base.MaxwellSimSocket):
name='String',
description='Represents a string',
default='',
update=(lambda self, context: self.sync_prop('raw_value', context)),
update=(lambda self, context: self.on_prop_changed('raw_value', context)),
)
####################

View File

@ -18,7 +18,7 @@ class BlenderCollectionBLSocket(base.MaxwellSimSocket):
name='Blender Collection',
description='A Blender collection',
type=bpy.types.Collection,
update=(lambda self, context: self.sync_prop('raw_value', context)),
update=(lambda self, context: self.on_prop_changed('raw_value', context)),
)
####################

View File

@ -21,7 +21,7 @@ class BlenderMaxwellResetGeoNodesSocket(bpy.types.Operator):
socket = node.inputs[self.socket_name]
# Report as though the GeoNodes Tree Changed
socket.sync_prop('raw_value', context)
socket.on_prop_changed('raw_value', context)
return {'FINISHED'}
@ -41,7 +41,7 @@ class BlenderGeoNodesBLSocket(base.MaxwellSimSocket):
description='Represents a Blender GeoNodes Tree',
type=bpy.types.NodeTree,
poll=(lambda self, obj: obj.bl_idname == 'GeometryNodeTree'),
update=(lambda self, context: self.sync_prop('raw_value', context)),
update=(lambda self, context: self.on_prop_changed('raw_value', context)),
)
####################

View File

@ -18,7 +18,7 @@ class BlenderImageBLSocket(base.MaxwellSimSocket):
name='Blender Image',
description='Represents a Blender Image',
type=bpy.types.Image,
update=(lambda self, context: self.sync_prop('raw_value', context)),
update=(lambda self, context: self.on_prop_changed('raw_value', context)),
)
####################

View File

@ -15,7 +15,7 @@ class BlenderMaterialBLSocket(base.MaxwellSimSocket):
name='Blender Material',
description='Represents a Blender material',
type=bpy.types.Material,
update=(lambda self, context: self.sync_prop('raw_value', context)),
update=(lambda self, context: self.on_prop_changed('raw_value', context)),
)
####################

View File

@ -39,7 +39,7 @@ class BlenderObjectBLSocket(base.MaxwellSimSocket):
name='Blender Object',
description='Represents a Blender object',
type=bpy.types.Object,
update=(lambda self, context: self.sync_prop('raw_value', context)),
update=(lambda self, context: self.on_prop_changed('raw_value', context)),
)
####################

View File

@ -18,7 +18,7 @@ class BlenderTextBLSocket(base.MaxwellSimSocket):
name='Blender Text',
description='Represents a Blender text datablock',
type=bpy.types.Text,
update=(lambda self, context: self.sync_prop('raw_value', context)),
update=(lambda self, context: self.on_prop_changed('raw_value', context)),
)
####################

View File

@ -1,6 +1,5 @@
import bpy
import tidy3d as td
import typing_extensions as typx
from ... import contracts as ct
from .. import base
@ -23,7 +22,7 @@ class MaxwellBoundCondBLSocket(base.MaxwellSimSocket):
('PERIODIC', 'Periodic', 'Infinitely periodic layer'),
],
default='PML',
update=(lambda self, context: self.sync_prop('default_choice', context)),
update=(lambda self, context: self.on_prop_changed('default_choice', context)),
)
####################
@ -45,7 +44,7 @@ class MaxwellBoundCondBLSocket(base.MaxwellSimSocket):
}[self.default_choice]
@value.setter
def value(self, value: typx.Literal['PML', 'PEC', 'PMC', 'PERIODIC']) -> None:
def value(self, value: typ.Literal['PML', 'PEC', 'PMC', 'PERIODIC']) -> None:
self.default_choice = value
@ -55,7 +54,7 @@ class MaxwellBoundCondBLSocket(base.MaxwellSimSocket):
class MaxwellBoundCondSocketDef(base.SocketDef):
socket_type: ct.SocketType = ct.SocketType.MaxwellBoundCond
default_choice: typx.Literal['PML', 'PEC', 'PMC', 'PERIODIC'] = 'PML'
default_choice: typ.Literal['PML', 'PEC', 'PMC', 'PERIODIC'] = 'PML'
def init(self, bl_socket: MaxwellBoundCondBLSocket) -> None:
bl_socket.value = self.default_choice

View File

@ -29,7 +29,7 @@ class MaxwellBoundCondsBLSocket(base.MaxwellSimSocket):
name='Show Bounds Definition',
description='Toggle to show bound faces',
default=False,
update=(lambda self, context: self.sync_prop('show_definition', context)),
update=(lambda self, context: self.on_prop_changed('show_definition', context)),
)
x_pos: bpy.props.EnumProperty(
@ -37,42 +37,42 @@ class MaxwellBoundCondsBLSocket(base.MaxwellSimSocket):
description='+x choice of default boundary face',
items=BOUND_FACE_ITEMS,
default='PML',
update=(lambda self, context: self.sync_prop('x_pos', context)),
update=(lambda self, context: self.on_prop_changed('x_pos', context)),
)
x_neg: bpy.props.EnumProperty(
name='-x Bound Face',
description='-x choice of default boundary face',
items=BOUND_FACE_ITEMS,
default='PML',
update=(lambda self, context: self.sync_prop('x_neg', context)),
update=(lambda self, context: self.on_prop_changed('x_neg', context)),
)
y_pos: bpy.props.EnumProperty(
name='+y Bound Face',
description='+y choice of default boundary face',
items=BOUND_FACE_ITEMS,
default='PML',
update=(lambda self, context: self.sync_prop('y_pos', context)),
update=(lambda self, context: self.on_prop_changed('y_pos', context)),
)
y_neg: bpy.props.EnumProperty(
name='-y Bound Face',
description='-y choice of default boundary face',
items=BOUND_FACE_ITEMS,
default='PML',
update=(lambda self, context: self.sync_prop('y_neg', context)),
update=(lambda self, context: self.on_prop_changed('y_neg', context)),
)
z_pos: bpy.props.EnumProperty(
name='+z Bound Face',
description='+z choice of default boundary face',
items=BOUND_FACE_ITEMS,
default='PML',
update=(lambda self, context: self.sync_prop('z_pos', context)),
update=(lambda self, context: self.on_prop_changed('z_pos', context)),
)
z_neg: bpy.props.EnumProperty(
name='-z Bound Face',
description='-z choice of default boundary face',
items=BOUND_FACE_ITEMS,
default='PML',
update=(lambda self, context: self.sync_prop('z_neg', context)),
update=(lambda self, context: self.on_prop_changed('z_neg', context)),
)
####################

View File

@ -3,7 +3,7 @@ import scipy as sc
import sympy.physics.units as spu
import tidy3d as td
from blender_maxwell.utils.pydantic_sympy import ConstrSympyExpr
from blender_maxwell.utils import extra_sympy_units as spux
from ... import contracts as ct
from .. import base
@ -25,7 +25,7 @@ class MaxwellMediumBLSocket(base.MaxwellSimSocket):
default=500.0,
precision=4,
step=50,
update=(lambda self, context: self.sync_prop('wl', context)),
update=(lambda self, context: self.on_prop_changed('wl', context)),
)
rel_permittivity: bpy.props.FloatVectorProperty(
@ -34,7 +34,9 @@ class MaxwellMediumBLSocket(base.MaxwellSimSocket):
size=2,
default=(1.0, 0.0),
precision=2,
update=(lambda self, context: self.sync_prop('rel_permittivity', context)),
update=(
lambda self, context: self.on_prop_changed('rel_permittivity', context)
),
)
####################
@ -72,7 +74,7 @@ class MaxwellMediumBLSocket(base.MaxwellSimSocket):
@value.setter
def value(
self, value: tuple[ConstrSympyExpr(allow_variables=False), complex]
self, value: tuple[spux.ConstrSympyExpr(allow_variables=False), complex]
) -> None:
_wl, rel_permittivity = value

View File

@ -19,7 +19,7 @@ class MaxwellSimGridBLSocket(base.MaxwellSimSocket):
min=0.01,
# step=10,
precision=2,
update=(lambda self, context: self.sync_prop('min_steps_per_wl', context)),
update=(lambda self, context: self.on_prop_changed('min_steps_per_wl', context)),
)
####################

View File

@ -3,7 +3,7 @@ import typing as typ
import bpy
import sympy as sp
from blender_maxwell.utils.pydantic_sympy import SympyExpr
from blender_maxwell.utils import extra_sympy_units as spux
from ... import contracts as ct
from .. import base
@ -24,8 +24,7 @@ class ComplexNumberBLSocket(base.MaxwellSimSocket):
description='Represents a complex number (real, imaginary)',
size=2,
default=(0.0, 0.0),
subtype='NONE',
update=(lambda self, context: self.sync_prop('raw_value', context)),
update=(lambda self, context: self.on_prop_changed('raw_value', context)),
)
coord_sys: bpy.props.EnumProperty(
name='Coordinate System',
@ -47,58 +46,20 @@ class ComplexNumberBLSocket(base.MaxwellSimSocket):
),
],
default='CARTESIAN',
update=lambda self, context: self._sync_coord_sys(context),
update=lambda self, context: self.on_coord_sys_changed(context),
)
####################
# - Socket UI
# - Event Methods
####################
def draw_value(self, col: bpy.types.UILayout) -> None:
"""Draw the value of the complex number, including a toggle for
specifying the active coordinate system.
def on_coord_sys_changed(self, context: bpy.types.Context):
r"""Transforms values when the coordinate system changes.
Notes:
Cartesian coordinates with $y=0$ has no corresponding $\theta$
Therefore, we manually set $\theta=0$.
"""
col_row = col.row()
col_row.prop(self, 'raw_value', text='')
col.prop(self, 'coord_sys', text='')
####################
# - Computation of Default Value
####################
@property
def value(self) -> SympyExpr:
"""Return the complex number as a sympy expression, of a form
determined by the coordinate system.
- Cartesian: a,b -> a + ib
- Polar: r,t -> re^(it)
Returns:
The sympy expression representing the complex number.
"""
v1, v2 = self.raw_value
return {
'CARTESIAN': v1 + sp.I * v2,
'POLAR': v1 * sp.exp(sp.I * v2),
}[self.coord_sys]
@value.setter
def value(self, value: SympyExpr) -> None:
"""Set the complex number from a sympy expression, using an internal
representation determined by the coordinate system.
- Cartesian: a,b -> a + ib
- Polar: r,t -> re^(it)
"""
self.raw_value = {
'CARTESIAN': (sp.re(value), sp.im(value)),
'POLAR': (sp.Abs(value), sp.arg(value)),
}[self.coord_sys]
####################
# - Internal Update Methods
####################
def _sync_coord_sys(self, context: bpy.types.Context):
if self.coord_sys == 'CARTESIAN':
r, theta_rad = self.raw_value
self.raw_value = (
@ -109,11 +70,58 @@ class ComplexNumberBLSocket(base.MaxwellSimSocket):
x, y = self.raw_value
cart_value = x + sp.I * y
self.raw_value = (
sp.Abs(cart_value),
sp.arg(cart_value) if y != 0 else 0,
float(sp.Abs(cart_value)),
float(sp.arg(cart_value)) if y != 0 else float(0),
)
self.sync_prop('coord_sys', context)
self.on_prop_changed('coord_sys', context)
####################
# - Socket UI
####################
def draw_value(self, col: bpy.types.UILayout) -> None:
"""Draw the value of the complex number, including a toggle for specifying the active coordinate system."""
# Value Row
row = col.row()
row.prop(self, 'raw_value', text='')
# Coordinate System Dropdown
col.prop(self, 'coord_sys', text='')
####################
# - Computation of Default Value
####################
@property
def value(self) -> spux.Complex:
"""Return the complex number as a sympy expression, of a form determined by the coordinate system.
- **Cartesian**: $(a,b) -> a + ib$
- **Polar**: $(r,t) -> re^(it)$
Returns:
The complex number as a `sympy` type.
"""
v1, v2 = self.raw_value
return {
'CARTESIAN': v1 + sp.I * v2,
'POLAR': v1 * sp.exp(sp.I * v2),
}[self.coord_sys]
@value.setter
def value(self, value: spux.Complex) -> None:
"""Set the complex number from a sympy expression, by numerically simplifying it into coordinate-system determined components.
- **Cartesian**: $(a,b) -> a + ib$
- **Polar**: $(r,t) -> re^(it)$
Parameters:
value: The complex number as a `sympy` type.
"""
self.raw_value = {
'CARTESIAN': (float(sp.re(value)), float(sp.im(value))),
'POLAR': (float(sp.Abs(value)), float(sp.arg(value))),
}[self.coord_sys]
####################
@ -122,7 +130,7 @@ class ComplexNumberBLSocket(base.MaxwellSimSocket):
class ComplexNumberSocketDef(base.SocketDef):
socket_type: ct.SocketType = ct.SocketType.ComplexNumber
default_value: SympyExpr = sp.S(0 + 0j)
default_value: spux.Complex = sp.S(0)
coord_sys: typ.Literal['CARTESIAN', 'POLAR'] = 'CARTESIAN'
def init(self, bl_socket: ComplexNumberBLSocket) -> None:

View File

@ -18,7 +18,7 @@ class IntegerNumberBLSocket(base.MaxwellSimSocket):
name='Integer',
description='Represents an integer',
default=0,
update=(lambda self, context: self.sync_prop('raw_value', context)),
update=(lambda self, context: self.on_prop_changed('raw_value', context)),
)
####################

View File

@ -23,7 +23,7 @@ class RationalNumberBLSocket(base.MaxwellSimSocket):
size=2,
default=(1, 1),
subtype='NONE',
update=(lambda self, context: self.sync_prop('raw_value', context)),
update=(lambda self, context: self.on_prop_changed('raw_value', context)),
)
####################

View File

@ -21,7 +21,7 @@ class RealNumberBLSocket(base.MaxwellSimSocket):
description='Represents a real number',
default=0.0,
precision=6,
update=(lambda self, context: self.sync_prop('raw_value', context)),
update=(lambda self, context: self.on_prop_changed('raw_value', context)),
)
####################

View File

@ -23,7 +23,7 @@ class PhysicalAccelScalarBLSocket(base.MaxwellSimSocket):
description='Represents the unitless part of the acceleration',
default=0.0,
precision=6,
update=(lambda self, context: self.sync_prop('raw_value', context)),
update=(lambda self, context: self.on_prop_changed('raw_value', context)),
)
####################

View File

@ -23,7 +23,7 @@ class PhysicalAngleBLSocket(base.MaxwellSimSocket):
description='Represents the unitless part of the acceleration',
default=0.0,
precision=4,
update=(lambda self, context: self.sync_prop('raw_value', context)),
update=(lambda self, context: self.on_prop_changed('raw_value', context)),
)
####################

View File

@ -20,7 +20,7 @@ class PhysicalAreaBLSocket(base.MaxwellSimSocket):
description='Represents the unitless part of the area',
default=0.0,
precision=6,
update=(lambda self, context: self.sync_prop('raw_value', context)),
update=(lambda self, context: self.on_prop_changed('raw_value', context)),
)
####################

View File

@ -23,7 +23,7 @@ class PhysicalForceScalarBLSocket(base.MaxwellSimSocket):
description='Represents the unitless part of the force',
default=0.0,
precision=6,
update=(lambda self, context: self.sync_prop('raw_value', context)),
update=(lambda self, context: self.on_prop_changed('raw_value', context)),
)
####################

View File

@ -27,7 +27,7 @@ class PhysicalFreqBLSocket(base.MaxwellSimSocket):
description='Represents the unitless part of the frequency',
default=0.0,
precision=6,
update=(lambda self, context: self.sync_prop('raw_value', context)),
update=(lambda self, context: self.on_prop_changed('raw_value', context)),
)
min_freq: bpy.props.FloatProperty(
@ -35,20 +35,20 @@ class PhysicalFreqBLSocket(base.MaxwellSimSocket):
description='Lowest frequency',
default=0.0,
precision=4,
update=(lambda self, context: self.sync_prop('min_freq', context)),
update=(lambda self, context: self.on_prop_changed('min_freq', context)),
)
max_freq: bpy.props.FloatProperty(
name='Max Frequency',
description='Highest frequency',
default=0.0,
precision=4,
update=(lambda self, context: self.sync_prop('max_freq', context)),
update=(lambda self, context: self.on_prop_changed('max_freq', context)),
)
steps: bpy.props.IntProperty(
name='Frequency Steps',
description='# of steps between min and max',
default=2,
update=(lambda self, context: self.sync_prop('steps', context)),
update=(lambda self, context: self.on_prop_changed('steps', context)),
)
####################
@ -74,10 +74,9 @@ class PhysicalFreqBLSocket(base.MaxwellSimSocket):
self.raw_value = spux.sympy_to_python(spux.scale_to_unit(value, self.unit))
@property
def lazy_value_range(self) -> ct.LazyDataValueRange:
return ct.LazyDataValueRange(
def lazy_array_range(self) -> ct.LazyArrayRange:
return ct.LazyArrayRange(
symbols=set(),
has_unit=True,
unit=self.unit,
start=sp.S(self.min_freq) * self.unit,
stop=sp.S(self.max_freq) * self.unit,
@ -85,9 +84,8 @@ class PhysicalFreqBLSocket(base.MaxwellSimSocket):
scaling='lin',
)
@lazy_value_range.setter
def lazy_value_range(self, value: tuple[sp.Expr, sp.Expr, int]) -> None:
log.debug('Lazy Value Range: %s', str(value))
@lazy_array_range.setter
def lazy_array_range(self, value: ct.LazyArrayRangeFlow) -> None:
self.min_freq = spux.sympy_to_python(spux.scale_to_unit(value[0], self.unit))
self.max_freq = spux.sympy_to_python(spux.scale_to_unit(value[1], self.unit))
self.steps = value[2]
@ -112,7 +110,7 @@ class PhysicalFreqSocketDef(base.SocketDef):
bl_socket.value = self.default_value
if self.is_array:
bl_socket.active_kind = ct.FlowKind.LazyValueRange
bl_socket.active_kind = ct.FlowKind.LazyArrayRange
bl_socket.lazy_value_range = (self.min_freq, self.max_freq, self.steps)

View File

@ -28,7 +28,7 @@ class PhysicalLengthBLSocket(base.MaxwellSimSocket):
description='Represents the unitless part of the length',
default=0.0,
precision=6,
update=(lambda self, context: self.sync_prop('raw_value', context)),
update=(lambda self, context: self.on_prop_changed('raw_value', context)),
)
min_len: bpy.props.FloatProperty(
@ -36,20 +36,20 @@ class PhysicalLengthBLSocket(base.MaxwellSimSocket):
description='Lowest length',
default=0.0,
precision=4,
update=(lambda self, context: self.sync_prop('min_len', context)),
update=(lambda self, context: self.on_prop_changed('min_len', context)),
)
max_len: bpy.props.FloatProperty(
name='Max Length',
description='Highest length',
default=0.0,
precision=4,
update=(lambda self, context: self.sync_prop('max_len', context)),
update=(lambda self, context: self.on_prop_changed('max_len', context)),
)
steps: bpy.props.IntProperty(
name='Length Steps',
description='# of steps between min and max',
default=2,
update=(lambda self, context: self.sync_prop('steps', context)),
update=(lambda self, context: self.on_prop_changed('steps', context)),
)
####################
@ -75,10 +75,9 @@ class PhysicalLengthBLSocket(base.MaxwellSimSocket):
self.raw_value = spux.sympy_to_python(spux.scale_to_unit(value, self.unit))
@property
def lazy_value_range(self) -> ct.LazyDataValueRange:
return ct.LazyDataValueRange(
def lazy_array_range(self) -> ct.LazyArrayRange:
return ct.LazyArrayRange(
symbols=set(),
has_unit=True,
unit=self.unit,
start=sp.S(self.min_len) * self.unit,
stop=sp.S(self.max_len) * self.unit,
@ -86,7 +85,7 @@ class PhysicalLengthBLSocket(base.MaxwellSimSocket):
scaling='lin',
)
@lazy_value_range.setter
@lazy_array_range.setter
def lazy_value_range(self, value: tuple[sp.Expr, sp.Expr, int]) -> None:
self.min_len = spux.sympy_to_python(spux.scale_to_unit(value[0], self.unit))
self.max_len = spux.sympy_to_python(spux.scale_to_unit(value[1], self.unit))
@ -113,7 +112,7 @@ class PhysicalLengthSocketDef(base.SocketDef):
bl_socket.value = self.default_value
if self.is_array:
bl_socket.active_kind = ct.FlowKind.LazyValueRange
bl_socket.active_kind = ct.FlowKind.LazyArrayRange
bl_socket.lazy_value_range = (self.min_len, self.max_len, self.steps)

View File

@ -23,7 +23,7 @@ class PhysicalMassBLSocket(base.MaxwellSimSocket):
description='Represents the unitless part of mass',
default=0.0,
precision=6,
update=(lambda self, context: self.sync_prop('raw_value', context)),
update=(lambda self, context: self.on_prop_changed('raw_value', context)),
)
####################

View File

@ -24,7 +24,7 @@ class PhysicalPoint3DBLSocket(base.MaxwellSimSocket):
size=3,
default=(0.0, 0.0, 0.0),
precision=4,
update=(lambda self, context: self.sync_prop('raw_value', context)),
update=(lambda self, context: self.on_prop_changed('raw_value', context)),
)
####################

View File

@ -39,7 +39,7 @@ class PhysicalPolBLSocket(base.MaxwellSimSocket):
('STOKES', 'Stokes', 'Linear x-pol of field'),
],
default='UNPOL',
update=(lambda self, context: self.sync_prop('model', context)),
update=(lambda self, context: self.on_prop_changed('model', context)),
)
## Lin Ang
@ -47,7 +47,7 @@ class PhysicalPolBLSocket(base.MaxwellSimSocket):
name='Pol. Angle',
description='Angle to polarize linearly along',
default=0.0,
update=(lambda self, context: self.sync_prop('lin_ang', context)),
update=(lambda self, context: self.on_prop_changed('lin_ang', context)),
)
## Circ
circ: bpy.props.EnumProperty(
@ -58,7 +58,7 @@ class PhysicalPolBLSocket(base.MaxwellSimSocket):
('RCP', 'RCP', "'Right Circular Polarization'"),
],
default='LCP',
update=(lambda self, context: self.sync_prop('circ', context)),
update=(lambda self, context: self.on_prop_changed('circ', context)),
)
## Jones
jones_psi: bpy.props.FloatProperty(
@ -66,14 +66,14 @@ class PhysicalPolBLSocket(base.MaxwellSimSocket):
description='Angle of the ellipse to the x-axis',
default=0.0,
precision=2,
update=(lambda self, context: self.sync_prop('jones_psi', context)),
update=(lambda self, context: self.on_prop_changed('jones_psi', context)),
)
jones_chi: bpy.props.FloatProperty(
name='Jones Major-Axis-Adjacent Angle',
description='Angle of adjacent to the ellipse major axis',
default=0.0,
precision=2,
update=(lambda self, context: self.sync_prop('jones_chi', context)),
update=(lambda self, context: self.on_prop_changed('jones_chi', context)),
)
## Stokes
@ -82,28 +82,28 @@ class PhysicalPolBLSocket(base.MaxwellSimSocket):
description='Angle of the ellipse to the x-axis',
default=0.0,
precision=2,
update=(lambda self, context: self.sync_prop('stokes_psi', context)),
update=(lambda self, context: self.on_prop_changed('stokes_psi', context)),
)
stokes_chi: bpy.props.FloatProperty(
name='Stokes Major-Axis-Adjacent Angle',
description='Angle of adjacent to the ellipse major axis',
default=0.0,
precision=2,
update=(lambda self, context: self.sync_prop('stokes_chi', context)),
update=(lambda self, context: self.on_prop_changed('stokes_chi', context)),
)
stokes_p: bpy.props.FloatProperty(
name='Stokes Polarization Degree',
description='The degree of polarization',
default=0.0,
precision=2,
update=(lambda self, context: self.sync_prop('stokes_p', context)),
update=(lambda self, context: self.on_prop_changed('stokes_p', context)),
)
stokes_I: bpy.props.FloatProperty(
name='Stokes Field Intensity',
description='The intensity of the polarized field',
default=0.0,
precision=2,
update=(lambda self, context: self.sync_prop('stokes_I', context)),
update=(lambda self, context: self.on_prop_changed('stokes_I', context)),
) ## TODO: Units?
####################

View File

@ -22,7 +22,7 @@ class PhysicalSize3DBLSocket(base.MaxwellSimSocket):
size=3,
default=(1.0, 1.0, 1.0),
precision=4,
update=(lambda self, context: self.sync_prop('raw_value', context)),
update=(lambda self, context: self.on_prop_changed('raw_value', context)),
)
####################

View File

@ -23,7 +23,7 @@ class PhysicalSpeedBLSocket(base.MaxwellSimSocket):
description='Represents the unitless part of the speed',
default=0.0,
precision=6,
update=(lambda self, context: self.sync_prop('raw_value', context)),
update=(lambda self, context: self.on_prop_changed('raw_value', context)),
)
####################

View File

@ -25,7 +25,7 @@ class PhysicalTimeBLSocket(base.MaxwellSimSocket):
description='Represents the unitless part of time',
default=0.0,
precision=4,
update=(lambda self, context: self.sync_prop('raw_value', context)),
update=(lambda self, context: self.on_prop_changed('raw_value', context)),
)
####################

View File

@ -46,7 +46,7 @@ class PhysicalUnitSystemBLSocket(base.MaxwellSimSocket):
name='Show Unit System Definition',
description='Toggle to show unit system definition',
default=False,
update=(lambda self, context: self.sync_prop('show_definition', context)),
update=(lambda self, context: self.on_prop_changed('show_definition', context)),
)
unit_time: bpy.props.EnumProperty(
@ -54,7 +54,7 @@ class PhysicalUnitSystemBLSocket(base.MaxwellSimSocket):
description='Unit of time',
items=contract_units_to_items(ST.PhysicalTime),
default=default_unit_key_for(ST.PhysicalTime),
update=(lambda self, context: self.sync_prop('unit_time', context)),
update=(lambda self, context: self.on_prop_changed('unit_time', context)),
)
unit_angle: bpy.props.EnumProperty(
@ -62,7 +62,7 @@ class PhysicalUnitSystemBLSocket(base.MaxwellSimSocket):
description='Unit of angle',
items=contract_units_to_items(ST.PhysicalAngle),
default=default_unit_key_for(ST.PhysicalAngle),
update=(lambda self, context: self.sync_prop('unit_angle', context)),
update=(lambda self, context: self.on_prop_changed('unit_angle', context)),
)
unit_length: bpy.props.EnumProperty(
@ -70,21 +70,21 @@ class PhysicalUnitSystemBLSocket(base.MaxwellSimSocket):
description='Unit of length',
items=contract_units_to_items(ST.PhysicalLength),
default=default_unit_key_for(ST.PhysicalLength),
update=(lambda self, context: self.sync_prop('unit_length', context)),
update=(lambda self, context: self.on_prop_changed('unit_length', context)),
)
unit_area: bpy.props.EnumProperty(
name='Area Unit',
description='Unit of area',
items=contract_units_to_items(ST.PhysicalArea),
default=default_unit_key_for(ST.PhysicalArea),
update=(lambda self, context: self.sync_prop('unit_area', context)),
update=(lambda self, context: self.on_prop_changed('unit_area', context)),
)
unit_volume: bpy.props.EnumProperty(
name='Volume Unit',
description='Unit of time',
items=contract_units_to_items(ST.PhysicalVolume),
default=default_unit_key_for(ST.PhysicalVolume),
update=(lambda self, context: self.sync_prop('unit_volume', context)),
update=(lambda self, context: self.on_prop_changed('unit_volume', context)),
)
unit_point_2d: bpy.props.EnumProperty(
@ -92,14 +92,14 @@ class PhysicalUnitSystemBLSocket(base.MaxwellSimSocket):
description='Unit of 2D points',
items=contract_units_to_items(ST.PhysicalPoint2D),
default=default_unit_key_for(ST.PhysicalPoint2D),
update=(lambda self, context: self.sync_prop('unit_point_2d', context)),
update=(lambda self, context: self.on_prop_changed('unit_point_2d', context)),
)
unit_point_3d: bpy.props.EnumProperty(
name='Point3D Unit',
description='Unit of 3D points',
items=contract_units_to_items(ST.PhysicalPoint3D),
default=default_unit_key_for(ST.PhysicalPoint3D),
update=(lambda self, context: self.sync_prop('unit_point_3d', context)),
update=(lambda self, context: self.on_prop_changed('unit_point_3d', context)),
)
unit_size_2d: bpy.props.EnumProperty(
@ -107,14 +107,14 @@ class PhysicalUnitSystemBLSocket(base.MaxwellSimSocket):
description='Unit of 2D sizes',
items=contract_units_to_items(ST.PhysicalSize2D),
default=default_unit_key_for(ST.PhysicalSize2D),
update=(lambda self, context: self.sync_prop('unit_size_2d', context)),
update=(lambda self, context: self.on_prop_changed('unit_size_2d', context)),
)
unit_size_3d: bpy.props.EnumProperty(
name='Size3D Unit',
description='Unit of 3D sizes',
items=contract_units_to_items(ST.PhysicalSize3D),
default=default_unit_key_for(ST.PhysicalSize3D),
update=(lambda self, context: self.sync_prop('unit_size_3d', context)),
update=(lambda self, context: self.on_prop_changed('unit_size_3d', context)),
)
unit_mass: bpy.props.EnumProperty(
@ -122,7 +122,7 @@ class PhysicalUnitSystemBLSocket(base.MaxwellSimSocket):
description='Unit of mass',
items=contract_units_to_items(ST.PhysicalMass),
default=default_unit_key_for(ST.PhysicalMass),
update=(lambda self, context: self.sync_prop('unit_mass', context)),
update=(lambda self, context: self.on_prop_changed('unit_mass', context)),
)
unit_speed: bpy.props.EnumProperty(
@ -130,35 +130,35 @@ class PhysicalUnitSystemBLSocket(base.MaxwellSimSocket):
description='Unit of speed',
items=contract_units_to_items(ST.PhysicalSpeed),
default=default_unit_key_for(ST.PhysicalSpeed),
update=(lambda self, context: self.sync_prop('unit_speed', context)),
update=(lambda self, context: self.on_prop_changed('unit_speed', context)),
)
unit_accel_scalar: bpy.props.EnumProperty(
name='Accel Unit',
description='Unit of acceleration',
items=contract_units_to_items(ST.PhysicalAccelScalar),
default=default_unit_key_for(ST.PhysicalAccelScalar),
update=(lambda self, context: self.sync_prop('unit_accel_scalar', context)),
update=(lambda self, context: self.on_prop_changed('unit_accel_scalar', context)),
)
unit_force_scalar: bpy.props.EnumProperty(
name='Force Scalar Unit',
description='Unit of scalar force',
items=contract_units_to_items(ST.PhysicalForceScalar),
default=default_unit_key_for(ST.PhysicalForceScalar),
update=(lambda self, context: self.sync_prop('unit_force_scalar', context)),
update=(lambda self, context: self.on_prop_changed('unit_force_scalar', context)),
)
unit_accel_3d: bpy.props.EnumProperty(
name='Accel3D Unit',
description='Unit of 3D vector acceleration',
items=contract_units_to_items(ST.PhysicalAccel3D),
default=default_unit_key_for(ST.PhysicalAccel3D),
update=(lambda self, context: self.sync_prop('unit_accel_3d', context)),
update=(lambda self, context: self.on_prop_changed('unit_accel_3d', context)),
)
unit_force_3d: bpy.props.EnumProperty(
name='Force3D Unit',
description='Unit of 3D vector force',
items=contract_units_to_items(ST.PhysicalForce3D),
default=default_unit_key_for(ST.PhysicalForce3D),
update=(lambda self, context: self.sync_prop('unit_force_3d', context)),
update=(lambda self, context: self.on_prop_changed('unit_force_3d', context)),
)
unit_freq: bpy.props.EnumProperty(
@ -166,7 +166,7 @@ class PhysicalUnitSystemBLSocket(base.MaxwellSimSocket):
description='Unit of frequency',
items=contract_units_to_items(ST.PhysicalFreq),
default=default_unit_key_for(ST.PhysicalFreq),
update=(lambda self, context: self.sync_prop('unit_freq', context)),
update=(lambda self, context: self.on_prop_changed('unit_freq', context)),
)
####################

View File

@ -20,7 +20,7 @@ class PhysicalVolumeBLSocket(base.MaxwellSimSocket):
description='Represents the unitless part of the area',
default=0.0,
precision=6,
update=(lambda self, context: self.sync_prop('raw_value', context)),
update=(lambda self, context: self.on_prop_changed('raw_value', context)),
)
####################

View File

@ -88,13 +88,13 @@ class Tidy3DCloudTaskBLSocket(base.MaxwellSimSocket):
name='Folder of Cloud Tasks',
description='An existing folder on the Tidy3D Cloud',
items=lambda self, _: self.retrieve_folders(),
update=(lambda self, context: self.sync_prop('existing_folder_id', context)),
update=(lambda self, context: self.on_prop_changed('existing_folder_id', context)),
)
existing_task_id: bpy.props.EnumProperty(
name='Existing Cloud Task',
description='An existing task on the Tidy3D Cloud, within the given folder',
items=lambda self, _: self.retrieve_tasks(),
update=(lambda self, context: self.sync_prop('existing_task_id', context)),
update=(lambda self, context: self.on_prop_changed('existing_task_id', context)),
)
# (Potential) New Task
@ -102,7 +102,7 @@ class Tidy3DCloudTaskBLSocket(base.MaxwellSimSocket):
name='New Cloud Task Name',
description='Name of a new task to submit to the Tidy3D Cloud',
default='',
update=(lambda self, context: self.sync_prop('new_task_name', context)),
update=(lambda self, context: self.on_prop_changed('new_task_name', context)),
)
####################
@ -114,7 +114,7 @@ class Tidy3DCloudTaskBLSocket(base.MaxwellSimSocket):
self.existing_task_id = folder_task_ids[0][0]
## There's guaranteed to at least be one element, even if it's "NONE".
self.sync_prop('existing_folder_id', context)
self.on_prop_changed('existing_folder_id', context)
def retrieve_folders(self) -> list[tuple]:
folders = tdcloud.TidyCloudFolders.folders()

View File

@ -30,7 +30,7 @@ class Integer3DVectorBLSocket(base.MaxwellSimSocket):
description='Represents an integer 3D (coordinate) vector',
size=3,
default=(0, 0, 0),
update=(lambda self, context: self.sync_prop('raw_value', context)),
update=(lambda self, context: self.on_prop_changed('raw_value', context)),
)
####################

View File

@ -31,7 +31,7 @@ class Real2DVectorBLSocket(base.MaxwellSimSocket):
size=2,
default=(0.0, 0.0),
precision=4,
update=(lambda self, context: self.sync_prop('raw_value', context)),
update=(lambda self, context: self.on_prop_changed('raw_value', context)),
)
####################

View File

@ -1,19 +1,11 @@
import bpy
import sympy as sp
from blender_maxwell.utils.pydantic_sympy import ConstrSympyExpr
import blender_maxwell.utils.extra_sympy_units as spux
from ... import contracts as ct
from .. import base
Real3DVector = ConstrSympyExpr(
allow_variables=False,
allow_units=False,
allowed_sets={'integer', 'rational', 'real'},
allowed_structures={'matrix'},
allowed_matrix_shapes={(3, 1)},
)
####################
# - Blender Socket
@ -31,7 +23,7 @@ class Real3DVectorBLSocket(base.MaxwellSimSocket):
size=3,
default=(0.0, 0.0, 0.0),
precision=4,
update=(lambda self, context: self.sync_prop('raw_value', context)),
update=(lambda self, context: self.on_prop_changed('raw_value', context)),
)
####################
@ -44,11 +36,11 @@ class Real3DVectorBLSocket(base.MaxwellSimSocket):
# - Computation of Default Value
####################
@property
def value(self) -> Real3DVector:
def value(self) -> spux.Real3DVector:
return sp.Matrix(tuple(self.raw_value))
@value.setter
def value(self, value: Real3DVector) -> None:
def value(self, value: spux.Real3DVector) -> None:
self.raw_value = tuple(value)
@ -58,7 +50,7 @@ class Real3DVectorBLSocket(base.MaxwellSimSocket):
class Real3DVectorSocketDef(base.SocketDef):
socket_type: ct.SocketType = ct.SocketType.Real3DVector
default_value: Real3DVector = sp.Matrix([0.0, 0.0, 0.0])
default_value: spux.Real3DVector = sp.Matrix([0.0, 0.0, 0.0])
def init(self, bl_socket: Real3DVectorBLSocket) -> None:
bl_socket.value = self.default_value

View File

@ -1,3 +1 @@
from . import operators, utils
__all__ = ['operators', 'utils']

View File

@ -1,14 +1,13 @@
from . import install_deps, uninstall_deps
from . import install_deps, uninstall_deps, manage_pydeps
BL_REGISTER = [
*install_deps.BL_REGISTER,
*uninstall_deps.BL_REGISTER,
*manage_pydeps.BL_REGISTER,
]
BL_KEYMAP_ITEM_DEFS = [
*install_deps.BL_KEYMAP_ITEM_DEFS,
*uninstall_deps.BL_KEYMAP_ITEM_DEFS,
BL_HOTKEYS = [
*install_deps.BL_HOTKEYS,
*uninstall_deps.BL_HOTKEYS,
*manage_pydeps.BL_HOTKEYS,
]
__all__ = []

View File

@ -4,46 +4,64 @@ from pathlib import Path
import bpy
from blender_maxwell.utils import pydeps, simple_logger
from ... import contracts as ct
from ... import registration
from ..utils import pydeps, simple_logger
log = simple_logger.get(__name__)
class InstallPyDeps(bpy.types.Operator):
bl_idname = 'blender_maxwell.nodeps__install_py_deps'
bl_idname = ct.OperatorType.InstallPyDeps
bl_label = 'Install BLMaxwell Python Deps'
path_addon_pydeps: bpy.props.StringProperty(
name='Path to Addon Python Dependencies',
default='',
)
path_addon_reqs: bpy.props.StringProperty(
name='Path to Addon Python Dependencies',
default='',
)
@classmethod
def poll(cls, _: bpy.types.Context):
return not pydeps.DEPS_OK
def execute(self, _: bpy.types.Context):
if self.path_addon_pydeps == '' or self.path_addon_reqs == '':
msg = f"A path for operator {self.bl_idname} isn't set"
raise ValueError(msg)
####################
# - Property: PyDeps Path
####################
bl__pydeps_path: bpy.props.StringProperty(
default='',
)
path_addon_pydeps = Path(self.path_addon_pydeps)
path_addon_reqs = Path(self.path_addon_reqs)
@property
def pydeps_path(self):
return Path(bpy.path.abspath(self.bl__pydeps_path))
@pydeps_path.setter
def pydeps_path(self, path: Path) -> None:
self.bl__pydeps_path = str(path.resolve())
####################
# - Property: requirements.lock
####################
bl__pydeps_reqlock_path: bpy.props.StringProperty(
default='',
)
@property
def pydeps_reqlock_path(self):
return Path(bpy.path.abspath(self.bl__pydeps_reqlock_path))
@pydeps_reqlock_path.setter
def pydeps_reqlock_path(self, path: Path) -> None:
self.bl__pydeps_reqlock_path = str(path.resolve())
####################
# - Execution
####################
def execute(self, _: bpy.types.Context):
log.info(
'Running Install PyDeps w/requirements.txt (%s) to path: %s',
path_addon_reqs,
path_addon_pydeps,
self.pydeps_reqlock_path,
self.pydeps_path,
)
# Create the Addon-Specific Folder (if Needed)
## It MUST, however, have a parent already
path_addon_pydeps.mkdir(parents=False, exist_ok=True)
self.pydeps_path.mkdir(parents=False, exist_ok=True)
# Determine Path to Blender's Bundled Python
## bpy.app.binary_path_python was deprecated in 2.91.
@ -59,9 +77,9 @@ class InstallPyDeps(bpy.types.Operator):
'pip',
'install',
'-r',
str(path_addon_reqs),
str(self.pydeps_reqlock_path),
'--target',
str(path_addon_pydeps),
str(self.pydeps_path),
]
log.info(
'Running pip w/cmdline: %s',
@ -72,10 +90,8 @@ class InstallPyDeps(bpy.types.Operator):
log.exception('Failed to install PyDeps')
return {'CANCELLED'}
registration.run_delayed_registration(
registration.EVENT__DEPS_SATISFIED,
path_addon_pydeps,
)
# Report PyDeps Changed
ct.addon.prefs().on_addon_pydeps_changed()
return {'FINISHED'}
@ -85,4 +101,4 @@ class InstallPyDeps(bpy.types.Operator):
BL_REGISTER = [
InstallPyDeps,
]
BL_KEYMAP_ITEM_DEFS = []
BL_HOTKEYS = []

View File

@ -0,0 +1,129 @@
from pathlib import Path
import bpy
from blender_maxwell import contracts as ct
from ..utils import pydeps, simple_logger
log = simple_logger.get(__name__)
class ManagePyDeps(bpy.types.Operator):
bl_idname = ct.OperatorType.ManagePyDeps
bl_label = 'Blender Maxwell Python Dependency Manager'
bl_options = {'REGISTER'}
show_pydeps_conflicts: bpy.props.BoolProperty(
name='Show Conflicts',
description='Show the conflicts between installed and required packages.',
default=False,
)
####################
# - Property: PyDeps Path
####################
bl__pydeps_path: bpy.props.StringProperty(
default='',
)
@property
def pydeps_path(self):
return Path(bpy.path.abspath(self.bl__pydeps_path))
@pydeps_path.setter
def pydeps_path(self, path: Path) -> None:
self.bl__pydeps_path = str(path.resolve())
####################
# - Property: requirements.lock
####################
bl__pydeps_reqlock_path: bpy.props.StringProperty(
default='',
)
@property
def pydeps_reqlock_path(self):
return Path(bpy.path.abspath(self.bl__pydeps_reqlock_path))
@pydeps_reqlock_path.setter
def pydeps_reqlock_path(self, path: Path) -> None:
self.bl__pydeps_reqlock_path = str(path.resolve())
####################
# - UI
####################
def draw(self, _: bpy.types.Context) -> None:
layout = self.layout
## Row: Toggle Default PyDeps Path
row = layout.row()
row.alignment = 'CENTER'
row.label(
text="Blender Maxwell relies on Python dependencies that aren't currently satisfied."
)
row.prop(
self,
'show_pydeps_conflicts',
text=f'Show Conflicts ({len(pydeps.DEPS_ISSUES)})',
toggle=True,
)
## Grid: Issues Panel
if self.show_pydeps_conflicts:
grid = layout.grid_flow()
grid.alignment = 'CENTER'
for issue in pydeps.DEPS_ISSUES:
grid.label(text=issue)
# Install Deps
row = layout.row(align=True)
op = row.operator(
ct.OperatorType.InstallPyDeps,
text='Install Python Dependencies (requires internet)',
)
op.bl__pydeps_path = str(self.pydeps_path)
op.bl__pydeps_reqlock_path = str(self.bl__pydeps_reqlock_path)
## Row: Toggle Default PyDeps Path
row = layout.row()
row.alignment = 'CENTER'
row.label(
text='After installation, the addon is ready to use. For more details, please refer to the addon preferences.'
)
####################
# - Execute
####################
def invoke(self, context: bpy.types.Context, event: bpy.types.Event):
if not bpy.app.background:
# Force-Move Mouse Cursor to Window Center
## This forces the popup dialog to spawn in the center of the screen.
context.window.cursor_warp(
context.window.width // 2,
context.window.height // 2 + 2 * bpy.context.preferences.system.dpi,
)
# Spawn Popup Dialogue
return context.window_manager.invoke_props_dialog(
self, width=8 * bpy.context.preferences.system.dpi
)
log.info('Skipping ManagePyDeps popup, since Blender is running without a GUI')
return {'INTERFACE'}
def execute(self, _: bpy.types.Context):
if not pydeps.DEPS_OK:
self.report(
{'ERROR'},
f'Python Dependencies for "{ct.addon.NAME}" were not installed. Please refer to the addon preferences.',
)
return {'FINISHED'}
####################
# - Blender Registration
####################
BL_REGISTER = [
ManagePyDeps,
]
BL_HOTKEYS = []

View File

@ -1,86 +0,0 @@
import subprocess
import sys
from pathlib import Path
import bpy
from blender_maxwell.utils import logger as _logger
from .. import registration
log = _logger.get(__name__)
class InstallPyDeps(bpy.types.Operator):
bl_idname = 'blender_maxwell.nodeps__addon_install_popup'
bl_label = 'Popup to Install BLMaxwell Python Deps'
path_addon_pydeps: bpy.props.StringProperty(
name='Path to Addon Python Dependencies',
default='',
)
path_addon_reqs: bpy.props.StringProperty(
name='Path to Addon Python Dependencies',
default='',
)
# TODO: poll()
def execute(self, _: bpy.types.Context):
if self.path_addon_pydeps == '' or self.path_addon_reqs == '':
msg = f"A path for operator {self.bl_idname} isn't set"
raise ValueError(msg)
path_addon_pydeps = Path(self.path_addon_pydeps)
path_addon_reqs = Path(self.path_addon_reqs)
log.info(
'Running Install PyDeps w/requirements.txt (%s) to path: %s',
path_addon_reqs,
path_addon_pydeps,
)
# Create the Addon-Specific Folder (if Needed)
## It MUST, however, have a parent already
path_addon_pydeps.mkdir(parents=False, exist_ok=True)
# Determine Path to Blender's Bundled Python
## bpy.app.binary_path_python was deprecated in 2.91.
## sys.executable points to the correct bundled Python.
## See <https://developer.blender.org/docs/release_notes/2.91/python_api/>
python_exec = Path(sys.executable)
# Install Deps w/Bundled pip
try:
cmdline = [
str(python_exec),
'-m',
'pip',
'install',
'-r',
str(path_addon_reqs),
'--target',
str(path_addon_pydeps),
]
log.info(
'Running pip w/cmdline: %s',
' '.join(cmdline),
)
subprocess.check_call(cmdline)
except subprocess.CalledProcessError:
log.exception('Failed to install PyDeps')
return {'CANCELLED'}
registration.run_delayed_registration(
registration.EVENT__DEPS_SATISFIED,
path_addon_pydeps,
)
return {'FINISHED'}
####################
# - Blender Registration
####################
BL_REGISTER = [
InstallPyDeps,
]
BL_KEYMAP_ITEM_DEFS = []

View File

@ -3,30 +3,47 @@ from pathlib import Path
import bpy
from blender_maxwell.utils import pydeps
from blender_maxwell import contracts as ct
from ..utils import pydeps
class UninstallPyDeps(bpy.types.Operator):
bl_idname = 'blender_maxwell.nodeps__uninstall_py_deps'
bl_idname = ct.OperatorType.UninstallPyDeps
bl_label = 'Uninstall BLMaxwell Python Deps'
path_addon_pydeps: bpy.props.StringProperty(
name='Path to Addon Python Dependencies'
)
@classmethod
def poll(cls, _: bpy.types.Context):
return pydeps.DEPS_OK
####################
# - Property: PyDeps Path
####################
bl__pydeps_path: bpy.props.StringProperty(
default='',
)
@property
def pydeps_path(self):
return Path(bpy.path.abspath(self.bl__pydeps_path))
@pydeps_path.setter
def pydeps_path(self, path: Path) -> None:
self.bl__pydeps_path = str(path.resolve())
####################
# - Execution
####################
def execute(self, _: bpy.types.Context):
path_addon_pydeps = Path(self.path_addon_pydeps)
path_addon_pydeps = Path(self.pydeps_path)
if (
pydeps.check_pydeps()
and self.path_addon_pydeps.exists()
and self.path_addon_pydeps.is_dir()
and path_addon_pydeps.exists()
and path_addon_pydeps.is_dir()
):
# CAREFUL!!
shutil.rmtree(self.path_addon_pydeps)
raise NotImplementedError
# TODO: CAREFUL!!
# shutil.rmtree(self.path_addon_pydeps)
else:
msg = "Can't uninstall pydeps"
raise RuntimeError(msg)
@ -40,4 +57,4 @@ class UninstallPyDeps(bpy.types.Operator):
BL_REGISTER = [
UninstallPyDeps,
]
BL_KEYMAP_ITEM_DEFS = []
BL_HOTKEYS = []

View File

@ -0,0 +1,73 @@
import enum
####################
# - StrEnum
####################
def prefix_values_with(prefix: str) -> type[enum.Enum]:
"""`StrEnum` class decorator that prepends `prefix` to all class member values.
Parameters:
name: The name to prepend behind all `StrEnum` member values.
Returns:
A new StrEnum class with altered member values.
"""
def _decorator(cls: enum.StrEnum):
new_members = {
member_name: prefix + member_value
for member_name, member_value in cls.__members__.items()
}
new_cls = enum.StrEnum(cls.__name__, new_members)
new_cls.__doc__ = cls.__doc__
new_cls.__module__ = cls.__module__
return new_cls
return _decorator
####################
# - BlenderTypeEnum
####################
## TODO: Migrate everyone to simple StrEnums
class BlenderTypeEnum(str, enum.Enum):
"""Homegrown `str` enum for Blender types."""
def _generate_next_value_(name, *_):
return name
def append_cls_name_to_values(cls) -> type[enum.Enum]:
"""Enum class decorator that appends the class name to all values."""
# Construct Set w/Modified Member Names
new_members = {
name: f'{name}{cls.__name__}' for name, member in cls.__members__.items()
}
# Dynamically Declare New Enum Class w/Modified Members
new_cls = enum.Enum(cls.__name__, new_members, type=BlenderTypeEnum)
new_cls.__doc__ = cls.__doc__
new_cls.__module__ = cls.__module__
# Return New (Replacing) Enum Class
return new_cls
def wrap_values_in_MT(cls) -> type[enum.Enum]:
"""Enum class decorator that prepends "BLENDER_MAXWELL_MT_" to all values."""
# Construct Set w/Modified Member Names
new_members = {
name: f'BLENDER_MAXWELL_MT_{name}' for name, member in cls.__members__.items()
}
# Dynamically Declare New Enum Class w/Modified Members
new_cls = enum.Enum(cls.__name__, new_members, type=BlenderTypeEnum)
new_cls.__doc__ = cls.__doc__
new_cls.__module__ = cls.__module__
new_cls.get_tree = cls.get_tree ## TODO: This is wildly specific...
# Return New (Replacing) Enum Class
return new_cls

View File

@ -1,3 +1,5 @@
"""Tools for fearless managemenet of addon-specific Python dependencies."""
import contextlib
import importlib.metadata
import os
@ -13,8 +15,8 @@ log = simple_logger.get(__name__)
####################
# - Globals
####################
DEPS_OK: bool | None = None
DEPS_ISSUES: list[str] | None = None
DEPS_OK: bool = False ## Presume no (but we don't know yet)
DEPS_ISSUES: list[str] = [] ## No known issues (yet)
####################
@ -22,6 +24,15 @@ DEPS_ISSUES: list[str] | None = None
####################
@contextlib.contextmanager
def importable_addon_deps(path_deps: Path):
"""Temporarily modifies `sys.path` with a light touch and minimum of side-effects.
Warnings:
There are a lot of gotchas with the import system, and this is an enormously imperfect "solution".
Parameters:
path_deps:
Corresponds to the directory into which `pip install --target` was used to install packages.
"""
os_path = os.fspath(path_deps)
if os_path not in sys.path:
@ -30,9 +41,10 @@ def importable_addon_deps(path_deps: Path):
try:
yield
finally:
pass
# TODO: Re-add
# log.info('Removing Path from sys.path: %s', str(os_path))
# sys.path.remove(os_path)
pass
else:
try:
yield
@ -42,38 +54,63 @@ def importable_addon_deps(path_deps: Path):
@contextlib.contextmanager
def syspath_from_bpy_prefs() -> bool:
import bpy
"""Temporarily modifies `sys.path` using the dependencies found in addon preferences.
addon_prefs = bpy.context.preferences.addons[ct.addon.NAME].preferences
if hasattr(addon_prefs, 'path_addon_pydeps'):
log.info('Retrieved PyDeps Path from Addon Prefs')
path_pydeps = addon_prefs.path_addon_pydeps
with importable_addon_deps(path_pydeps):
yield True
else:
log.info("Couldn't PyDeps Path from Addon Prefs")
yield False
Warnings:
There are a lot of gotchas with the import system, and this is an enormously imperfect "solution".
####################
# - Check PyDeps
####################
def _check_pydeps(
path_requirementslock: Path,
path_deps: Path,
) -> dict[str, tuple[str, str]]:
"""Check if packages defined in a 'requirements.lock' file are currently installed.
Returns a list of any issues (if empty, then all dependencies are correctly satisfied).
Parameters:
path_deps: Path to the directory where Python modules can be found.
Corresponds to the directory into which `pip install --target` was used to install packages.
"""
with importable_addon_deps(ct.addon.prefs().pydeps_path):
log.info('Retrieved PyDeps Path from Addon Prefs')
yield True
def conform_pypi_package_deplock(deplock: str):
"""Conforms a <package>==<version> de-lock to match if pypi considers them the same (PyPi is case-insensitive and considers -/_ to be the same).
See <https://peps.python.org/pep-0426/#name>
####################
# - Passive PyDeps Checkers
####################
def conform_pypi_package_deplock(deplock: str) -> str:
"""Conforms a "deplock" string (`<package>==<version>`) so that comparing it with other "deplock" strings will conform to PyPi's matching rules.
- **Case Sensitivity**: PyPi considers packages with non-matching cases to be the same. _Therefore, we cast all deplocks to lowercase._
- **Special Characters**: PyPi considers `-` and `_` to be the same character. _Therefore, we replace `_` with `-`_.
See <https://peps.python.org/pep-0426/#name> for the specification.
Parameters:
deplock: The string formatted like `<package>==<version>`.
Returns:
The conformed deplock string.
"""
return deplock.lower().replace('_', '-')
def deplock_conflicts(
path_requirementslock: Path,
path_deps: Path,
) -> list[str]:
"""Check if packages defined in a 'requirements.lock' file are **strictly** realized by a particular dependency path.
**Strict** means not only that everything is satisfied, but that _the exact versions_ are satisfied, and that _no extra packages_ are installed either.
Parameters:
path_requirementslock: Path to the `requirements.lock` file.
Generally, one would use `ct.addon.PATH_REQS` to use the `requirements.lock` file shipped with the addon.
path_deps: Path to the directory where Python modules can be found.
Corresponds to the directory into which `pip install --target` was used to install packages.
Returns:
A list of messages explaining mismatches between the currently installed dependencies, and the given `requirements.lock` file.
There are three kinds of conflicts:
- **Version**: The wrong version of something is installed.
- **Missing**: Something should be installed that isn't.
- **Superfluous**: Something is installed that shouldn't be.
"""
# DepLocks: Required
with path_requirementslock.open('r') as file:
required_depslock = {
conform_pypi_package_deplock(line)
@ -81,18 +118,15 @@ def _check_pydeps(
if (line := raw_line.strip()) and not line.startswith('#')
}
# Investigate Issues
installed_deps = importlib.metadata.distributions(
path=[str(path_deps.resolve())] ## resolve() is just-in-case
)
# DepLocks: Installed
installed_depslock = {
conform_pypi_package_deplock(
f'{dep.metadata["Name"]}=={dep.metadata["Version"]}'
)
for dep in installed_deps
for dep in importlib.metadata.distributions(path=[str(path_deps.resolve())])
}
# Determine Missing/Superfluous/Conflicting
# Determine Diff of Required vs. Installed
req_not_inst = required_depslock - installed_depslock
inst_not_req = installed_depslock - required_depslock
conflicts = {
@ -102,7 +136,6 @@ def _check_pydeps(
if req.split('==')[0] == inst.split('==')[0]
}
# Assemble and Return Issues
return (
[
f'{name}: Have {inst_ver}, Need {req_ver}'
@ -122,20 +155,48 @@ def _check_pydeps(
####################
# - Refresh PyDeps
# - Passive PyDeps Checker
####################
def check_pydeps(path_deps: Path):
def check_pydeps(path_requirementslock: Path, path_deps: Path):
"""Check if all dependencies are satisfied without `deplock_conflicts()` conflicts, and update globals in response.
Notes:
Use of the globals `DEPS_OK` and `DEPS_ISSUES` should be preferred in general, since they are very fast to access.
**Only**, use `check_pydeps()` after any operation something that might have changed the dependency status; both to check the result, but also to update the globals.
Parameters:
path_requirementslock: Path to the `requirements.lock` file.
Generally, one would use `ct.addon.PATH_REQS` to use the `requirements.lock` file shipped with the addon.
path_deps: Path to the directory where Python modules can be found.
Corresponds to the directory into which `pip install --target` was used to install packages.
Returns:
A list of messages explaining mismatches between the currently installed dependencies, and the given `requirements.lock` file.
There are three kinds of conflicts:
- **Version**: The wrong version of something is installed.
- **Missing**: Something should be installed that isn't.
- **Superfluous**: Something is installed that shouldn't be.
"""
global DEPS_OK # noqa: PLW0603
global DEPS_ISSUES # noqa: PLW0603
if len(issues := _check_pydeps(ct.addon.PATH_REQS, path_deps)) > 0:
log.info('PyDeps Check Failed')
log.info(
'Analyzing PyDeps at: %s',
str(path_deps),
)
if len(issues := deplock_conflicts(path_requirementslock, path_deps)) > 0:
log.info(
'PyDeps Check Failed - adjust Addon Preferences for: %s', ct.addon.NAME
)
log.debug('%s', ', '.join(issues))
log.debug('PyDeps Conflicts: %s', ', '.join(issues))
DEPS_OK = False
DEPS_ISSUES = issues
else:
log.info('PyDeps Check Succeeded')
log.info('PyDeps Check Succeeded - DEPS_OK and DEPS_ISSUES have been updated')
DEPS_OK = True
DEPS_ISSUES = []

View File

@ -179,7 +179,7 @@ def sync_bootstrap_logging(
file_path=file_path,
file_level=file_level,
)
logger_logger.info('Bootstrapped Logging w/Settings %s', str(CACHE))
logger_logger.info('Bootstrapped Simple Logging w/Settings %s', str(CACHE))
def sync_all_loggers(

View File

@ -3,6 +3,6 @@ from . import connect_viewer
BL_REGISTER = [
*connect_viewer.BL_REGISTER,
]
BL_KEYMAP_ITEM_DEFS = [
*connect_viewer.BL_KEYMAP_ITEM_DEFS,
BL_HOTKEYS = [
*connect_viewer.BL_HOTKEYS,
]

View File

@ -59,7 +59,7 @@ BL_REGISTER = [
ConnectViewerNode,
]
BL_KEYMAP_ITEM_DEFS = [
BL_HOTKEYS = [
{
'_': (
ConnectViewerNode.bl_idname,

View File

@ -3,13 +3,11 @@ from pathlib import Path
import bpy
from . import info, registration
from . import contracts as ct
from . import registration
from .nodeps.operators import install_deps, uninstall_deps
from .nodeps.utils import pydeps, simple_logger
####################
# - Constants
####################
log = simple_logger.get(__name__)
@ -17,46 +15,66 @@ log = simple_logger.get(__name__)
# - Preferences
####################
class BLMaxwellAddonPrefs(bpy.types.AddonPreferences):
"""Manages user preferences and settings for the Blender Maxwell addon."""
"""Manages user preferences and settings for the Blender Maxwell addon.
bl_idname = info.ADDON_NAME ## MUST match addon package name
Unfortunately, many of the niceities based on dependencies (ex. `bl_cache.BLField`) aren't available here.
Attributes:
bl_idname: Matches `ct.addon.NAME`.
use_default_pydeps_path: Whether to use the default PyDeps path
"""
bl_idname = ct.addon.NAME
####################
# - Properties
####################
# Use of Default PyDeps Path
# PyDeps Default Path
use_default_pydeps_path: bpy.props.BoolProperty(
name='Use Default PyDeps Path',
description='Whether to use the default PyDeps path',
default=True,
update=lambda self, context: self.sync_use_default_pydeps_path(context),
)
cache__pydeps_path_while_using_default: bpy.props.StringProperty(
name='Cached Addon PyDeps Path',
default=(_default_pydeps_path := str(info.DEFAULT_PATH_DEPS)),
update=lambda self, context: self.on_addon_pydeps_changed(context),
)
# Custom PyDeps Path
# PyDeps Path
bl__pydeps_path: bpy.props.StringProperty(
name='Addon PyDeps Path',
description='Path to Addon Python Dependencies',
subtype='FILE_PATH',
default=_default_pydeps_path,
update=lambda self, _: self.sync_pydeps_path(),
)
cache__backup_pydeps_path: bpy.props.StringProperty(
name='Previous Addon PyDeps Path',
default=_default_pydeps_path,
default=str(ct.addon.DEFAULT_PATH_DEPS),
update=lambda self, _: self.on_addon_pydeps_changed(),
)
# Log Settings
cache__backup_pydeps_path: bpy.props.StringProperty(
default=str(ct.addon.DEFAULT_PATH_DEPS),
)
@property
def pydeps_path(self) -> Path:
if self.use_default_pydeps_path:
return ct.addon.DEFAULT_PATH_DEPS
return Path(bpy.path.abspath(self.bl__pydeps_path))
@pydeps_path.setter
def pydeps_path(self, path: Path) -> None:
if not self.use_default_pydeps_path:
self.bl__pydeps_path = str(path.resolve())
else:
msg = f'Can\'t set "pydeps_path" to {path} while "use_default_pydeps_path" is "True"'
raise ValueError(msg)
# Logging
## Console Logging
use_log_console: bpy.props.BoolProperty(
name='Log to Console',
description='Whether to use the console for addon logging',
default=True,
update=lambda self, _: self.sync_addon_logging(),
update=lambda self, _: self.on_addon_logging_changed(),
)
bl__log_level_console: bpy.props.EnumProperty(
log_level_console: bpy.props.EnumProperty(
name='Console Log Level',
description='Level of addon logging to expose in the console',
items=[
@ -66,24 +84,18 @@ class BLMaxwellAddonPrefs(bpy.types.AddonPreferences):
('ERROR', 'Error', 'Error'),
('CRITICAL', 'Critical', 'Critical'),
],
default='DEBUG',
update=lambda self, _: self.sync_addon_logging(),
default='INFO',
update=lambda self, _: self.on_addon_logging_changed(),
)
## File Logging
use_log_file: bpy.props.BoolProperty(
name='Log to File',
description='Whether to use a file for addon logging',
default=True,
update=lambda self, _: self.sync_addon_logging(),
update=lambda self, _: self.on_addon_logging_changed(),
)
bl__log_file_path: bpy.props.StringProperty(
name='Log Path',
description='Path to the Addon Log File',
subtype='FILE_PATH',
default=str(info.DEFAULT_LOG_PATH),
update=lambda self, _: self.sync_addon_logging(),
)
bl__log_level_file: bpy.props.EnumProperty(
log_level_file: bpy.props.EnumProperty(
name='File Log Level',
description='Level of addon logging to expose in the file',
items=[
@ -93,61 +105,60 @@ class BLMaxwellAddonPrefs(bpy.types.AddonPreferences):
('ERROR', 'Error', 'Error'),
('CRITICAL', 'Critical', 'Critical'),
],
default='DEBUG',
update=lambda self, _: self.sync_addon_logging(),
default='INFO',
update=lambda self, _: self.on_addon_logging_changed(),
)
# TODO: LOGGING SETTINGS
####################
# - Property Methods
####################
@property
def pydeps_path(self) -> Path:
return Path(bpy.path.abspath(self.bl__pydeps_path))
@pydeps_path.setter
def pydeps_path(self, value: Path) -> None:
self.bl__pydeps_path = str(value.resolve())
bl__log_file_path: bpy.props.StringProperty(
name='Log Path',
description='Path to the Addon Log File',
subtype='FILE_PATH',
default=str(ct.addon.DEFAULT_LOG_PATH),
update=lambda self, _: self.on_addon_logging_changed(),
)
@property
def log_path(self) -> Path:
def log_file_path(self) -> Path:
return Path(bpy.path.abspath(self.bl__log_file_path))
@pydeps_path.setter
def log_file_path(self, path: Path) -> None:
self.bl__log_file_path = str(path.resolve())
####################
# - Property Sync
# - Events: Properties Changed
####################
def sync_addon_logging(self, logger_to_setup: logging.Logger | None = None) -> None:
def on_addon_logging_changed(
self, single_logger_to_setup: logging.Logger | None = None
) -> None:
"""Configure one, or all, active addon logger(s).
Parameters:
logger_to_setup:
When set to None, all addon loggers will be configured
single_logger_to_setup: When set, only this logger will be setup.
Otherwise, **all addon loggers will be setup**.
"""
if pydeps.DEPS_OK:
log.info('Getting Logger (DEPS_OK = %s)', str(pydeps.DEPS_OK))
with pydeps.importable_addon_deps(self.pydeps_path):
from blender_maxwell.utils import logger
else:
log.info('Getting Simple Logger (DEPS_OK = %s)', str(pydeps.DEPS_OK))
logger = simple_logger
# Retrieve Configured Log Levels
log_level_console = logger.LOG_LEVEL_MAP[self.bl__log_level_console]
log_level_file = logger.LOG_LEVEL_MAP[self.bl__log_level_file]
log_level_console = logger.LOG_LEVEL_MAP[self.log_level_console]
log_level_file = logger.LOG_LEVEL_MAP[self.log_level_file]
log_setup_kwargs = {
'console_level': log_level_console if self.use_log_console else None,
'file_path': self.log_path if self.use_log_file else None,
'file_path': self.log_file_path if self.use_log_file else None,
'file_level': log_level_file,
}
# Sync Single Logger / All Loggers
if logger_to_setup is not None:
if single_logger_to_setup is not None:
logger.setup_logger(
logger.console_handler,
logger.file_handler,
logger_to_setup,
single_logger_to_setup,
**log_setup_kwargs,
)
else:
@ -158,77 +169,55 @@ class BLMaxwellAddonPrefs(bpy.types.AddonPreferences):
**log_setup_kwargs,
)
def sync_use_default_pydeps_path(self, _: bpy.types.Context):
# Switch to Default
if self.use_default_pydeps_path:
log.info(
'Switching to Default PyDeps Path %s',
str(info.DEFAULT_PATH_DEPS.resolve()),
)
self.cache__pydeps_path_while_using_default = self.bl__pydeps_path
self.bl__pydeps_path = str(info.DEFAULT_PATH_DEPS.resolve())
def on_addon_pydeps_changed(self, show_popup_if_deps_invalid: bool = False) -> None:
"""Checks if the Python dependencies are valid, and runs any delayed setup (inclusing `ct.BLClass` registrations) in response.
# Switch from Default
else:
log.info(
'Switching from Default PyDeps Path %s to Cached PyDeps Path %s',
str(info.DEFAULT_PATH_DEPS.resolve()),
self.cache__pydeps_path_while_using_default,
)
self.bl__pydeps_path = self.cache__pydeps_path_while_using_default
self.cache__pydeps_path_while_using_default = ''
Notes:
**The addon does not load until this method allows it**.
def sync_pydeps_path(self):
if self.cache__backup_pydeps_path != self.bl__pydeps_path:
log.info(
'Syncing PyDeps Path from/to: %s => %s',
self.cache__backup_pydeps_path,
self.bl__pydeps_path,
)
else:
log.info(
'Syncing PyDeps Path In-Place @ %s',
str(self.bl__pydeps_path),
)
Parameters:
show_popup_if_deps_invalid: If True, a failed dependency check will `invoke()` the operator `ct.OperatorType.ManagePyDeps`, which is a popup that guides the user through
**NOTE**: Must be called after addon registration.
# Error: Default Path in Use
if self.use_default_pydeps_path:
self.bl__pydeps_path = self.cache__backup_pydeps_path
msg = "Can't update pydeps path while default path is being used"
raise ValueError(msg)
# Error: PyDeps Already Installed
if pydeps.DEPS_OK:
self.bl__pydeps_path = self.cache__backup_pydeps_path
msg = "Can't update pydeps path while dependencies are installed"
raise ValueError(msg)
# Re-Check PyDeps
log.info(
'Checking PyDeps of New Path %s',
str(self.pydeps_path),
)
if pydeps.check_pydeps(self.pydeps_path):
Notes:
Run by `__init__.py` after registering a barebones addon (including this class), and after queueing a delayed registration.
"""
if pydeps.check_pydeps(ct.addon.PATH_REQS, self.pydeps_path):
# Re-Sync Loggers
## We can now upgrade to the fancier loggers.
self.sync_addon_logging()
## We can now upgrade all loggers to the fancier loggers.
for _log in simple_logger.simple_loggers:
log.debug('Upgrading Logger (%s)', str(_log))
self.on_addon_logging_changed(single_logger_to_setup=_log)
# Run Delayed Registrations
# Run Registrations Waiting on DEPS_SATISFIED
## Since the deps are OK, we can now register the whole addon.
if (
registration.BLRegisterEvent.DepsSatisfied
in registration.DELAYED_REGISTRATIONS
):
registration.run_delayed_registration(
registration.EVENT__DEPS_SATISFIED,
registration.BLRegisterEvent.DepsSatisfied,
self.pydeps_path,
)
# Backup New PyDeps Path
self.cache__backup_pydeps_path = self.bl__pydeps_path
elif show_popup_if_deps_invalid:
ct.addon.operator(
ct.OperatorType.ManagePyDeps,
'INVOKE_DEFAULT',
bl__pydeps_path=str(self.pydeps_path),
bl__pydeps_reqlock_path=str(ct.addon.PATH_REQS),
)
## TODO: else:
## TODO: Can we 'downgrade' the loggers back to simple loggers?
## TODO: Can we undo the delayed registration?
## TODO: Do we need the fancy pants sys.modules handling for all this?
####################
# - UI
####################
def draw(self, _: bpy.types.Context) -> None:
layout = self.layout
num_pydeps_issues = len(pydeps.DEPS_ISSUES) if pydeps.DEPS_ISSUES else 0
num_pydeps_issues = len(pydeps.DEPS_ISSUES)
# Box w/Split: Log Level
box = layout.box()
@ -244,7 +233,7 @@ class BLMaxwellAddonPrefs(bpy.types.AddonPreferences):
row = col.row()
row.enabled = self.use_log_console
row.prop(self, 'bl__log_level_console')
row.prop(self, 'log_level_console')
## Split Col: File Logging
col = split.column()
@ -257,7 +246,7 @@ class BLMaxwellAddonPrefs(bpy.types.AddonPreferences):
row = col.row()
row.enabled = self.use_log_file
row.prop(self, 'bl__log_level_file')
row.prop(self, 'log_level_file')
# Box: Dependency Status
box = layout.box()
@ -296,8 +285,8 @@ class BLMaxwellAddonPrefs(bpy.types.AddonPreferences):
install_deps.InstallPyDeps.bl_idname,
text='Install PyDeps',
)
op.path_addon_pydeps = str(self.pydeps_path)
op.path_addon_reqs = str(info.PATH_REQS)
op.bl__pydeps_path = str(self.pydeps_path)
op.bl__pydeps_reqlock_path = str(ct.addon.PATH_REQS)
## Row: Uninstall
row = box.row(align=True)
@ -305,7 +294,7 @@ class BLMaxwellAddonPrefs(bpy.types.AddonPreferences):
uninstall_deps.UninstallPyDeps.bl_idname,
text='Uninstall PyDeps',
)
op.path_addon_pydeps = str(self.pydeps_path)
op.bl__pydeps_path = str(self.pydeps_path)
####################

View File

@ -1,12 +1,14 @@
"""Manages the registration of Blender classes, including delayed registrations that require access to Python dependencies.
Attributes:
BL_KEYMAP: Addon-specific keymap used to register operator hotkeys. REG__CLASSES: Currently registered Blender classes.
REG__KEYMAP_ITEMS: Currently registered Blender keymap items.
_ADDON_KEYMAP: Addon-specific keymap used to register operator hotkeys.
DELAYED_REGISTRATIONS: Currently pending registration operations, which can be realized with `run_delayed_registration()`.
EVENT__DEPS_SATISFIED: A constant representing a semantic choice of key for `DELAYED_REGISTRATIONS`.
REG__CLASSES: Currently registered Blender classes.
_REGISTERED_HOTKEYS: Currently registered Blender keymap items.
"""
import enum
import typing as typ
from pathlib import Path
@ -17,38 +19,36 @@ from .nodeps.utils import simple_logger
log = simple_logger.get(__name__)
DelayedRegKey: typ.TypeAlias = str
####################
# - Globals
####################
BL_KEYMAP: bpy.types.KeyMap | None = None
_REGISTERED_CLASSES: list[ct.BLClass] = []
_ADDON_KEYMAP: bpy.types.KeyMap | None = None
_REGISTERED_HOTKEYS: list[ct.BLKeymapItem] = []
REG__CLASSES: list[ct.BLClass] = []
REG__KEYMAP_ITEMS: list[ct.BLKeymapItem] = []
DELAYED_REGISTRATIONS: dict[DelayedRegKey, typ.Callable[[Path], None]] = {}
####################
# - Delayed Registration Keys
# - Delayed Registration
####################
EVENT__DEPS_SATISFIED: DelayedRegKey = 'on_deps_satisfied'
class BLRegisterEvent(enum.StrEnum):
DepsSatisfied = enum.auto()
DELAYED_REGISTRATIONS: dict[BLRegisterEvent, typ.Callable[[Path], None]] = {}
####################
# - Class Registration
####################
def register_classes(bl_register: list[ct.BLClass]) -> None:
"""Registers a Blender class, allowing it to hook into relevant Blender features.
Caches registered classes in the module global `REG__CLASSES`.
"""Registers a list of Blender classes.
Parameters:
bl_register: List of Blender classes to register.
"""
log.info('Registering %s Classes', len(bl_register))
for cls in bl_register:
if cls.bl_idname in REG__CLASSES:
if cls.bl_idname in _REGISTERED_CLASSES:
msg = f'Skipping register of {cls.bl_idname}'
log.info(msg)
continue
@ -58,45 +58,46 @@ def register_classes(bl_register: list[ct.BLClass]) -> None:
repr(cls),
)
bpy.utils.register_class(cls)
REG__CLASSES.append(cls)
_REGISTERED_CLASSES.append(cls)
def unregister_classes() -> None:
"""Unregisters all previously registered Blender classes.
All previously registered Blender classes can be found in the module global variable `REG__CLASSES`.
"""
log.info('Unregistering %s Classes', len(REG__CLASSES))
for cls in reversed(REG__CLASSES):
"""Unregisters all previously registered Blender classes."""
log.info('Unregistering %s Classes', len(_REGISTERED_CLASSES))
for cls in reversed(_REGISTERED_CLASSES):
log.debug(
'Unregistering Class %s',
repr(cls),
)
bpy.utils.unregister_class(cls)
REG__CLASSES.clear()
_REGISTERED_CLASSES.clear()
####################
# - Keymap Registration
####################
def register_keymap_items(keymap_item_defs: list[dict]):
def register_hotkeys(hotkey_defs: list[dict]):
"""Registers a list of Blender hotkey definitions.
Parameters:
hotkey_defs: List of Blender hotkey definitions to register.
"""
# Lazy-Load BL_NODE_KEYMAP
global BL_KEYMAP # noqa: PLW0603
if BL_KEYMAP is None:
BL_KEYMAP = bpy.context.window_manager.keyconfigs.addon.keymaps.new(
name='Node Editor',
space_type='NODE_EDITOR',
global _ADDON_KEYMAP # noqa: PLW0603
if _ADDON_KEYMAP is None:
_ADDON_KEYMAP = bpy.context.window_manager.keyconfigs.addon.keymaps.new(
name=f'{ct.addon.NAME} Keymap',
)
log.info(
'Registered Keymap %s',
str(BL_KEYMAP),
'Registered Addon Keymap (Base for Keymap Items): %s',
str(_ADDON_KEYMAP),
)
# Register Keymaps
log.info('Registering %s Keymap Items', len(keymap_item_defs))
for keymap_item_def in keymap_item_defs:
keymap_item = BL_KEYMAP.keymap_items.new(
log.info('Registering %s Keymap Items', len(hotkey_defs))
for keymap_item_def in hotkey_defs:
keymap_item = _ADDON_KEYMAP.keymap_items.new(
*keymap_item_def['_'],
ctrl=keymap_item_def['ctrl'],
shift=keymap_item_def['shift'],
@ -107,38 +108,39 @@ def register_keymap_items(keymap_item_defs: list[dict]):
repr(keymap_item),
keymap_item_def,
)
REG__KEYMAP_ITEMS.append(keymap_item)
_REGISTERED_HOTKEYS.append(keymap_item)
def unregister_keymap_items():
global BL_KEYMAP # noqa: PLW0603
def unregister_hotkeys():
"""Unregisters all Blender hotkeys associated with the addon."""
global _ADDON_KEYMAP # noqa: PLW0603
# Unregister Keymaps
log.info('Unregistering %s Keymap Items', len(REG__KEYMAP_ITEMS))
for keymap_item in reversed(REG__KEYMAP_ITEMS):
log.info('Unregistering %s Keymap Items', len(_REGISTERED_HOTKEYS))
for keymap_item in reversed(_REGISTERED_HOTKEYS):
log.debug(
'Unregistered Keymap Item %s',
repr(keymap_item),
)
BL_KEYMAP.keymap_items.remove(keymap_item)
_ADDON_KEYMAP.keymap_items.remove(keymap_item)
# Lazy-Unload BL_NODE_KEYMAP
if BL_KEYMAP is not None:
if _ADDON_KEYMAP is not None:
log.info(
'Unregistered Keymap %s',
repr(BL_KEYMAP),
repr(_ADDON_KEYMAP),
)
REG__KEYMAP_ITEMS.clear()
BL_KEYMAP = None
_REGISTERED_HOTKEYS.clear()
_ADDON_KEYMAP = None
####################
# - Delayed Registration Semantics
####################
def delay_registration(
delayed_reg_key: DelayedRegKey,
classes_cb: typ.Callable[[Path], list[ct.BLClass]],
keymap_item_defs_cb: typ.Callable[[Path], list[ct.KeymapItemDef]],
def delay_registration_until(
delayed_reg_key: BLRegisterEvent,
then_register_classes: typ.Callable[[Path], list[ct.BLClass]],
then_register_hotkeys: typ.Callable[[Path], list[ct.KeymapItemDef]],
) -> None:
"""Delays the registration of Blender classes that depend on certain Python dependencies, for which neither the location nor validity is yet known.
@ -147,10 +149,9 @@ def delay_registration(
Parameters:
delayed_reg_key: The identifier with which to index the registration callback.
Module-level constants like `EVENT__DEPS_SATISFIED` are a good choice.
classes_cb: A function that takes a `sys.path`-compatible path to Python dependencies needed by the Blender classes in question, and returns a list of Blender classes to import.
`register_classes()` will be used to actually register the returned Blender classes.
keymap_item_defs_cb: Similar, except for addon keymap items.
hotkey_defs_cb: Similar, except for addon keymap items.
Returns:
A function that takes a `sys.path`-compatible path to the Python dependencies needed to import the given Blender classes.
@ -161,17 +162,19 @@ def delay_registration(
def register_cb(path_pydeps: Path):
log.info(
'Running Delayed Registration (key %s) with PyDeps: %s',
'Delayed Registration (key %s) with PyDeps Path: %s',
delayed_reg_key,
path_pydeps,
)
register_classes(classes_cb(path_pydeps))
register_keymap_items(keymap_item_defs_cb(path_pydeps))
register_classes(then_register_classes(path_pydeps))
register_hotkeys(then_register_hotkeys(path_pydeps))
DELAYED_REGISTRATIONS[delayed_reg_key] = register_cb
def run_delayed_registration(delayed_reg_key: DelayedRegKey, path_pydeps: Path) -> None:
def run_delayed_registration(
delayed_reg_key: BLRegisterEvent, path_pydeps: Path
) -> None:
"""Run a delayed registration, by using `delayed_reg_key` to lookup the correct path, passing `path_pydeps` to the registration.
Parameters:
@ -179,5 +182,9 @@ def run_delayed_registration(delayed_reg_key: DelayedRegKey, path_pydeps: Path)
Must match the parameter with which the delayed registration was first declared.
path_pydeps: The `sys.path`-compatible path to the Python dependencies that the classes need to have available in order to register.
"""
register_cb = DELAYED_REGISTRATIONS.pop(delayed_reg_key)
register_cb(path_pydeps)
DELAYED_REGISTRATIONS.pop(delayed_reg_key)(path_pydeps)
def clear_delayed_registrations() -> None:
"""Dequeue all queued delayed registrations."""
DELAYED_REGISTRATIONS.clear()

View File

@ -1,7 +1,6 @@
from ..nodeps.utils import pydeps
from ..nodeps.utils import blender_type_enum, pydeps
from . import (
analyze_geonodes,
blender_type_enum,
extra_sympy_units,
logger,
pydantic_sympy,

View File

@ -1,5 +1,6 @@
import typing as typ
import bpy
import typing_extensions as typx
INVALID_BL_SOCKET_TYPES = {
'NodeSocketGeometry',
@ -8,7 +9,7 @@ INVALID_BL_SOCKET_TYPES = {
def interface(
geonodes: bpy.types.GeometryNodeTree, ## TODO: bpy type
direc: typx.Literal['INPUT', 'OUTPUT'],
direc: typ.Literal['INPUT', 'OUTPUT'],
):
"""Returns 'valid' GeoNodes interface sockets.

View File

@ -430,7 +430,7 @@ class BLField:
Parameters:
default_value: The default value to use if the value is read before it's set.
triggers_prop_update: Whether to run `bl_instance.sync_prop(attr_name)` whenever value is set.
triggers_prop_update: Whether to run `bl_instance.on_prop_changed(attr_name)` whenever value is set.
"""
log.debug(

View File

@ -1,35 +0,0 @@
import enum
class BlenderTypeEnum(str, enum.Enum):
def _generate_next_value_(name, *_):
return name
def append_cls_name_to_values(cls):
# Construct Set w/Modified Member Names
new_members = {
name: f'{name}{cls.__name__}' for name, member in cls.__members__.items()
}
# Dynamically Declare New Enum Class w/Modified Members
new_cls = enum.Enum(cls.__name__, new_members, type=BlenderTypeEnum)
new_cls.__module__ = cls.__module__
# Return New (Replacing) Enum Class
return new_cls
def wrap_values_in_MT(cls):
# Construct Set w/Modified Member Names
new_members = {
name: f'BLENDER_MAXWELL_MT_{name}' for name, member in cls.__members__.items()
}
# Dynamically Declare New Enum Class w/Modified Members
new_cls = enum.Enum(cls.__name__, new_members, type=BlenderTypeEnum)
new_cls.__module__ = cls.__module__
new_cls.get_tree = cls.get_tree ## TODO: This is wildly specific...
# Return New (Replacing) Enum Class
return new_cls

View File

@ -1,75 +1,53 @@
import functools
"""Declares useful sympy units and functions, to make it easier to work with `sympy` as the basis for a unit-aware system.
Attributes:
ALL_UNIT_SYMBOLS: Maps all abbreviated Sympy symbols to their corresponding Sympy unit.
This is essential for parsing string expressions that use units, since a pure parse of ex. `a*m + m` would not otherwise be able to differentiate between `sp.Symbol(m)` and `spu.meter`.
SympyType: A simple union of valid `sympy` types, used to check whether arbitrary objects should be handled using `sympy` functions.
For simple `isinstance` checks, this should be preferred, as it is most performant.
For general use, `SympyExpr` should be preferred.
SympyExpr: A `SympyType` that is compatible with `pydantic`, including serialization/deserialization.
Should be used via the `ConstrSympyExpr`, which also adds expression validation.
"""
import itertools
import typing as typ
import pydantic as pyd
import sympy as sp
import sympy.physics.units as spu
import typing_extensions as typx
from pydantic_core import core_schema as pyd_core_schema
SympyType = sp.Basic | sp.Expr | sp.MatrixBase | spu.Quantity
SympyType = sp.Basic | sp.Expr | sp.MatrixBase | sp.MutableDenseMatrix | spu.Quantity
####################
# - Useful Methods
####################
def uses_units(expression: sp.Expr) -> bool:
## TODO: An LFU cache could do better than an LRU.
"""Checks if an expression uses any units (`Quantity`)."""
for arg in sp.preorder_traversal(expression):
if isinstance(arg, spu.Quantity):
return True
return False
# Function to return a set containing all units used in the expression
def get_units(expression: sp.Expr):
## TODO: An LFU cache could do better than an LRU.
"""Gets all the units of an expression (as `Quantity`)."""
return {
arg
for arg in sp.preorder_traversal(expression)
if isinstance(arg, spu.Quantity)
}
####################
# - Time
# - Units
####################
femtosecond = fs = spu.Quantity('femtosecond', abbrev='fs')
femtosecond.set_global_relative_scale_factor(spu.femto, spu.second)
####################
# - Length
####################
# Length
femtometer = fm = spu.Quantity('femtometer', abbrev='fm')
femtometer.set_global_relative_scale_factor(spu.femto, spu.meter)
####################
# - Lum Flux
####################
# Lum Flux
lumen = lm = spu.Quantity('lumen', abbrev='lm')
lumen.set_global_relative_scale_factor(1, spu.candela * spu.steradian)
####################
# - Force
####################
# Newton
nanonewton = nN = spu.Quantity('nanonewton', abbrev='nN')
# Force
nanonewton = nN = spu.Quantity('nanonewton', abbrev='nN') # noqa: N816
nanonewton.set_global_relative_scale_factor(spu.nano, spu.newton)
micronewton = uN = spu.Quantity('micronewton', abbrev='μN')
micronewton = uN = spu.Quantity('micronewton', abbrev='μN') # noqa: N816
micronewton.set_global_relative_scale_factor(spu.micro, spu.newton)
millinewton = mN = spu.Quantity('micronewton', abbrev='mN')
millinewton = mN = spu.Quantity('micronewton', abbrev='mN') # noqa: N816
micronewton.set_global_relative_scale_factor(spu.milli, spu.newton)
####################
# - Frequency
####################
# Hertz
kilohertz = kHz = spu.Quantity('kilohertz', abbrev='kHz')
# Frequency
kilohertz = KHz = spu.Quantity('kilohertz', abbrev='KHz')
kilohertz.set_global_relative_scale_factor(spu.kilo, spu.hertz)
megahertz = MHz = spu.Quantity('megahertz', abbrev='MHz')
@ -87,30 +65,120 @@ petahertz.set_global_relative_scale_factor(spu.peta, spu.hertz)
exahertz = EHz = spu.Quantity('exahertz', abbrev='EHz')
exahertz.set_global_relative_scale_factor(spu.exa, spu.hertz)
####################
# - Sympy Printer
####################
_SYMPY_EXPR_PRINTER_STR = sp.printing.str.StrPrinter(
settings={
'abbrev': True,
}
)
def sp_to_str(sp_obj: SympyType) -> str:
"""Converts a sympy object to an output-oriented string (w/abbreviated units), using a dedicated StrPrinter.
This should be used whenever a **string for UI use** is needed from a `sympy` object.
Notes:
This should **NOT** be used in cases where the string will be `sp.sympify()`ed back into a sympy expression.
For such cases, rely on `sp.srepr()`, which uses an _explicit_ representation.
Parameters:
sp_obj: The `sympy` object to convert to a string.
Returns:
A string representing the expression for human use.
_The string is not re-encodable to the expression._
"""
return _SYMPY_EXPR_PRINTER_STR.doprint(sp_obj)
####################
# - Expr Analysis: Units
####################
## TODO: Caching w/srepr'ed expression.
## TODO: An LFU cache could do better than an LRU.
def uses_units(expr: sp.Expr) -> bool:
"""Determines if an expression uses any units.
Notes:
The expression graph is traversed depth-first with `sp.postorder_traversal`, to search for `sp.Quantity` elements.
Depth-first was chosen since `sp.Quantity`s are likelier to be found among individual symbols, rather than complete subexpressions.
The **worst-case** runtime is when there are no units, in which case the **entire expression graph will be traversed**.
Parameters:
expr: The sympy expression that may contain units.
Returns:
Whether or not there are units used within the expression.
"""
return any(
isinstance(subexpr, spu.Quantity) for subexpr in sp.postorder_traversal(expr)
)
## TODO: Caching w/srepr'ed expression.
## TODO: An LFU cache could do better than an LRU.
def get_units(expr: sp.Expr) -> set[spu.Quantity]:
"""Finds all units used by the expression, and returns them as a set.
No information about _the relationship between units_ is exposed.
For example, compound units like `spu.meter / spu.second` would be mapped to `{spu.meter, spu.second}`.
Notes:
The expression graph is traversed depth-first with `sp.postorder_traversal`, to search for `sp.Quantity` elements.
The performance is comparable to the performance of `sp.postorder_traversal`, since the **entire expression graph will always be traversed**, with the added overhead of one `isinstance` call per expression-graph-node.
Parameters:
expr: The sympy expression that may contain units.
Returns:
All units (`spu.Quantity`) used within the expression.
"""
return {
subexpr
for subexpr in sp.postorder_traversal(expr)
if isinstance(subexpr, spu.Quantity)
}
####################
# - Sympy Expression Typing
####################
ALL_UNIT_SYMBOLS = {
unit.abbrev: unit
for unit in spu.__dict__.values()
if isinstance(unit, spu.Quantity)
} | {unit.abbrev: unit for unit in globals().values() if isinstance(unit, spu.Quantity)}
@functools.lru_cache(maxsize=4096)
def parse_abbrev_symbols_to_units(expr: sp.Basic) -> sp.Basic:
return expr.subs(ALL_UNIT_SYMBOLS)
ALL_UNIT_SYMBOLS: dict[sp.Symbol, spu.Quantity] = {
unit.name: unit for unit in spu.__dict__.values() if isinstance(unit, spu.Quantity)
} | {unit.name: unit for unit in globals().values() if isinstance(unit, spu.Quantity)}
####################
# - Units <-> Scalars
####################
def scaling_factor(unit_from: spu.Quantity, unit_to: spu.Quantity) -> sp.Basic:
if unit_from.dimension == unit_to.dimension:
return spu.convert_to(unit_from, unit_to) / unit_to
def scale_to_unit(expr: sp.Expr, unit: spu.Quantity) -> sp.Expr:
"""Convert an expression that uses units to a different unit, then strip all units.
This is used whenever the unitless part of an expression is needed, but guaranteed expressed in a particular unit, aka. **unit system normalization**.
def scale_to_unit(expr: sp.Expr, unit: spu.Quantity) -> typ.Any:
Notes:
The unitless output is still an `sp.Expr`, which may contain ex. symbols.
If you know that the output **should** work as a corresponding Python type (ex. `sp.Integer` vs. `int`), but it doesn't, you can use `sympy_to_python()` to produce a pure-Python type.
In this way, with a little care, broad compatiblity can be bridged between the `sympy.physics.units` unit system and the wider Python ecosystem.
Parameters:
expr: The unit-containing expression to convert.
unit_to: The unit that is converted to.
Returns:
The unitless part of `expr`, after scaling the entire expression to `unit`.
Raises:
ValueError: If the result of unit-conversion and -stripping still has units, as determined by `uses_units()`.
"""
## TODO: An LFU cache could do better than an LRU.
unitless_expr = spu.convert_to(expr, unit) / unit
if not uses_units(unitless_expr):
@ -120,9 +188,50 @@ def scale_to_unit(expr: sp.Expr, unit: spu.Quantity) -> typ.Any:
raise ValueError(msg)
def scaling_factor(unit_from: spu.Quantity, unit_to: spu.Quantity) -> sp.Number:
"""Compute the numerical scaling factor imposed on the unitless part of the expression when converting from one unit to another.
Parameters:
unit_from: The unit that is converted from.
unit_to: The unit that is converted to.
Returns:
The numerical scaling factor between the two units.
Raises:
ValueError: If the two units don't share a common dimension.
"""
if unit_from.dimension == unit_to.dimension:
return scale_to_unit(unit_from, unit_to)
msg = f"Dimension of unit_from={unit_from} ({unit_from.dimension}) doesn't match the dimension of unit_to={unit_to} ({unit_to.dimension}); therefore, there is no scaling factor between them"
raise ValueError(msg)
####################
# - Sympy <-> Scalars
# - Sympy -> Python
####################
## TODO: Integrate SympyExpr for constraining to the output types.
def sympy_to_python_type(sym: sp.Symbol) -> type:
"""Retrieve the Python type that is implied by a scalar `sympy` symbol.
Arguments:
sym: A scalar sympy symbol.
Returns:
A pure Python type.
"""
if sym.is_integer:
return int
if sym.is_rational or sym.is_real:
return float
if sym.is_complex:
return complex
msg = f'Cannot find Python type for sympy symbol "{sym}". Check the assumptions on the expr (current expr assumptions: "{sym._assumptions}")' # noqa: SLF001
raise ValueError(msg)
def sympy_to_python(scalar: sp.Basic) -> int | float | complex | tuple | list:
"""Convert a scalar sympy expression to the directly corresponding Python type.
@ -133,9 +242,6 @@ def sympy_to_python(scalar: sp.Basic) -> int | float | complex | tuple | list:
Returns:
A pure Python type that directly corresponds to the input scalar expression.
"""
## TODO: If there are symbols, we could simplify.
## - Someone has to do it somewhere, might as well be here.
## - ...Since we have all the information we need.
if isinstance(scalar, sp.MatrixBase):
list_2d = [[sympy_to_python(el) for el in row] for row in scalar.tolist()]
@ -154,3 +260,278 @@ def sympy_to_python(scalar: sp.Basic) -> int | float | complex | tuple | list:
msg = f'Cannot convert sympy scalar expression "{scalar}" to a Python type. Check the assumptions on the expr (current expr assumptions: "{scalar._assumptions}")' # noqa: SLF001
raise ValueError(msg)
####################
# - Pydantic-Validated SympyExpr
####################
class _SympyExpr:
"""Low-level `pydantic`, schema describing how to serialize/deserialize fields that have a `SympyType` (like `sp.Expr`), so we can cleanly use `sympy` types in `pyd.BaseModel`.
Notes:
You probably want to use `SympyExpr`.
Examples:
To be usable as a type annotation on `pyd.BaseModel`, attach this to `SympyType` using `typx.Annotated`:
```python
SympyExpr = typx.Annotated[SympyType, _SympyExpr]
class Spam(pyd.BaseModel):
line: SympyExpr = sp.Eq(sp.y, 2*sp.Symbol(x, real=True) - 3)
```
"""
@classmethod
def __get_pydantic_core_schema__(
cls,
_source_type: SympyType,
_handler: pyd.GetCoreSchemaHandler,
) -> pyd_core_schema.CoreSchema:
"""Compute a schema that allows `pydantic` to validate a `sympy` type."""
def validate_from_str(sp_str: str | typ.Any) -> SympyType | typ.Any:
"""Parse and validate a string expression.
Parameters:
sp_str: A stringified `sympy` object, that will be parsed to a sympy type.
Before use, `isinstance(expr_str, str)` is checked.
If the object isn't a string, then the validation will be skipped.
Returns:
Either a `sympy` object, if the input is parseable, or the same untouched object.
Raises:
ValueError: If `sp_str` is a string, but can't be parsed into a `sympy` expression.
"""
# Constrain to String
if not isinstance(sp_str, str):
return sp_str
# Parse String -> Sympy
try:
expr = sp.sympify(sp_str)
except ValueError as ex:
msg = f'String {sp_str} is not a valid sympy expression'
raise ValueError(msg) from ex
# Substitute Symbol -> Quantity
return expr.subs(ALL_UNIT_SYMBOLS)
# def validate_from_expr(sp_obj: SympyType) -> SympyType:
# """Validate that a `sympy` object is a `SympyType`.
# In the static sense, this is a dummy function.
# Parameters:
# sp_obj: A `sympy` object.
# Returns:
# The `sympy` object.
# Raises:
# ValueError: If `sp_obj` is not a `sympy` object.
# """
# if not (isinstance(sp_obj, SympyType)):
# msg = f'Value {sp_obj} is not a `sympy` expression'
# raise ValueError(msg)
# return sp_obj
sympy_expr_schema = pyd_core_schema.chain_schema(
[
pyd_core_schema.no_info_plain_validator_function(validate_from_str),
# pyd_core_schema.no_info_plain_validator_function(validate_from_expr),
pyd_core_schema.is_instance_schema(SympyType),
]
)
return pyd_core_schema.json_or_python_schema(
json_schema=sympy_expr_schema,
python_schema=sympy_expr_schema,
serialization=pyd_core_schema.plain_serializer_function_ser_schema(
lambda sp_obj: sp.srepr(sp_obj)
),
)
SympyExpr = typx.Annotated[
SympyType,
_SympyExpr,
]
def ConstrSympyExpr( # noqa: N802, PLR0913
# Features
allow_variables: bool = True,
allow_units: bool = True,
# Structures
allowed_sets: set[typ.Literal['integer', 'rational', 'real', 'complex']]
| None = None,
allowed_structures: set[typ.Literal['scalar', 'matrix']] | None = None,
# Element Class
max_symbols: int | None = None,
allowed_symbols: set[sp.Symbol] | None = None,
allowed_units: set[spu.Quantity] | None = None,
# Shape Class
allowed_matrix_shapes: set[tuple[int, int]] | None = None,
) -> SympyType:
"""Constructs a `SympyExpr` type, which will validate `sympy` types when used in a `pyd.BaseModel`.
Relies on the `sympy` assumptions system.
See <https://docs.sympy.org/latest/guides/assumptions.html#predicates>
Parameters (TBD):
Returns:
A type that represents a constrained `sympy` expression.
"""
def validate_expr(expr: SympyType):
if not (isinstance(expr, SympyType),):
msg = f"expr '{expr}' is not an allowed Sympy expression ({SympyType})"
raise ValueError(msg)
msgs = set()
# Validate Feature Class
if (not allow_variables) and (len(expr.free_symbols) > 0):
msgs.add(
f'allow_variables={allow_variables} does not match expression {expr}.'
)
if (not allow_units) and uses_units(expr):
msgs.add(f'allow_units={allow_units} does not match expression {expr}.')
# Validate Structure Class
if (
allowed_sets
and isinstance(expr, sp.Expr)
and not any(
{
'integer': expr.is_integer,
'rational': expr.is_rational,
'real': expr.is_real,
'complex': expr.is_complex,
}[allowed_set]
for allowed_set in allowed_sets
)
):
msgs.add(
f"allowed_sets={allowed_sets} does not match expression {expr} (remember to add assumptions to symbols, ex. `x = sp.Symbol('x', real=True))"
)
if allowed_structures and not any(
{
'matrix': isinstance(expr, sp.MatrixBase),
}[allowed_set]
for allowed_set in allowed_structures
if allowed_structures != 'scalar'
):
msgs.add(
f"allowed_structures={allowed_structures} does not match expression {expr} (remember to add assumptions to symbols, ex. `x = sp.Symbol('x', real=True))"
)
# Validate Element Class
if max_symbols and len(expr.free_symbols) > max_symbols:
msgs.add(f'max_symbols={max_symbols} does not match expression {expr}')
if allowed_symbols and expr.free_symbols.issubset(allowed_symbols):
msgs.add(
f'allowed_symbols={allowed_symbols} does not match expression {expr}'
)
if allowed_units and get_units(expr).issubset(allowed_units):
msgs.add(f'allowed_units={allowed_units} does not match expression {expr}')
# Validate Shape Class
if (
allowed_matrix_shapes and isinstance(expr, sp.MatrixBase)
) and expr.shape not in allowed_matrix_shapes:
msgs.add(
f'allowed_matrix_shapes={allowed_matrix_shapes} does not match expression {expr} with shape {expr.shape}'
)
# Error or Return
if msgs:
raise ValueError(str(msgs))
return expr
return typx.Annotated[
SympyType,
_SympyExpr,
pyd.AfterValidator(validate_expr),
]
####################
# - Common ConstrSympyExpr
####################
# Expression
ScalarUnitlessRealExpr: typ.TypeAlias = ConstrSympyExpr(
allow_variables=False,
allow_units=False,
allowed_structures={'scalar'},
allowed_sets={'integer', 'rational', 'real'},
)
ScalarUnitlessComplexExpr: typ.TypeAlias = ConstrSympyExpr(
allow_variables=False,
allow_units=False,
allowed_structures={'scalar'},
allowed_sets={'integer', 'rational', 'real', 'complex'},
)
# Symbol
IntSymbol: typ.TypeAlias = ConstrSympyExpr(
allow_variables=True,
allow_units=False,
allowed_sets={'integer'},
max_symbols=1,
)
RealSymbol: typ.TypeAlias = ConstrSympyExpr(
allow_variables=True,
allow_units=False,
allowed_sets={'integer', 'rational', 'real'},
max_symbols=1,
)
ComplexSymbol: typ.TypeAlias = ConstrSympyExpr(
allow_variables=True,
allow_units=False,
allowed_sets={'integer', 'rational', 'real', 'complex'},
max_symbols=1,
)
Symbol: typ.TypeAlias = IntSymbol | RealSymbol | ComplexSymbol
# Unit
## Technically a "unit expression", which includes compound types.
## Support for this is the killer feature compared to spu.Quantity.
Unit: typ.TypeAlias = ConstrSympyExpr(
allow_variables=False,
allow_units=True,
allowed_structures={'scalar'},
)
# Number
IntNumber: typ.TypeAlias = ConstrSympyExpr(
allow_variables=False,
allow_units=False,
allowed_sets={'integer'},
allowed_structures={'scalar'},
)
RealNumber: typ.TypeAlias = ConstrSympyExpr(
allow_variables=False,
allow_units=False,
allowed_sets={'integer', 'rational', 'real'},
allowed_structures={'scalar'},
)
ComplexNumber: typ.TypeAlias = ConstrSympyExpr(
allow_variables=False,
allow_units=False,
allowed_sets={'integer', 'rational', 'real', 'complex'},
allowed_structures={'scalar'},
)
Number: typ.TypeAlias = IntNumber | RealNumber | ComplexNumber
# Vector
Real3DVector: typ.TypeAlias = ConstrSympyExpr(
allow_variables=False,
allow_units=False,
allowed_sets={'integer', 'rational', 'real'},
allowed_structures={'matrix'},
allowed_matrix_shapes={(3, 1)},
)

View File

@ -5,7 +5,8 @@ import rich.console
import rich.logging
import rich.traceback
from .. import info
from blender_maxwell import contracts as ct
from ..nodeps.utils import simple_logger
from ..nodeps.utils.simple_logger import (
LOG_LEVEL_MAP, # noqa: F401
@ -56,10 +57,7 @@ def get(module_name):
logger = logging.getLogger(module_name)
# Setup Logger from Addon Preferences
if (addon_prefs := info.addon_prefs()) is None:
msg = 'Addon preferences not defined'
raise RuntimeError(msg)
addon_prefs.sync_addon_logging(logger_to_setup=logger)
ct.addon.prefs().on_addon_logging_changed(single_logger_to_setup=logger)
return logger

View File

@ -60,7 +60,7 @@ class _SympyExpr:
json_schema=sympy_expr_schema,
python_schema=sympy_expr_schema,
serialization=pyd_core_schema.plain_serializer_function_ser_schema(
lambda instance: str(instance)
lambda instance: sp.srepr(instance)
),
)
@ -79,9 +79,9 @@ def ConstrSympyExpr(
allow_variables: bool = True,
allow_units: bool = True,
# Structure Class
allowed_sets: set[typx.Literal['integer', 'rational', 'real', 'complex']]
allowed_sets: set[typ.Literal['integer', 'rational', 'real', 'complex']]
| None = None,
allowed_structures: set[typx.Literal['scalar', 'matrix']] | None = None,
allowed_structures: set[typ.Literal['scalar', 'matrix']] | None = None,
# Element Class
allowed_symbols: set[sp.Symbol] | None = None,
allowed_units: set[spu.Quantity] | None = None,

View File

@ -78,7 +78,7 @@ if __name__ == '__main__':
print(f'\tBlender: Install & Enable "{info.ADDON_NAME}"')
else:
print(f'\tBlender: "{info.ADDON_NAME}" Not Installed')
print(output)
print(*output, sep='')
sys.exit(1)
# Run Addon