Repository: BradyAJohnston/MolecularNodes
Pull request #851: Add Configurable Styles Classes
Author: login: zachcp
Created: 2025-04-30T17:28:41Z | Merged: 2025-05-21T08:52:20Z

This PR adds support for modifying styles when adding styles to MN. Some initial discussion in #850 

```python
%load_ext autoreload
%autoreload 2


import bpy
import molecularnodes as mn
from molecularnodes.scene.base import Canvas
from molecularnodes.nodes.styles import StyleBallandStick, StyleRibbon, StyleSticks


can = Canvas()
mn.register()
can.scene_reset()

try:
    cube_object = bpy.data.objects.get("Cube")
    bpy.data.objects.remove(cube_object, do_unlink=True)
    print("Default Cube deleted successfully!")
except:
    pass

structure = mn.Molecule.fetch("1EMA", centre="centroid")
# structure.add_style("spheres")
structure.add_style(StyleRibbon())
can.frame_object(structure)
can.snapshot()
```
![image](https://github.com/user-attachments/assets/2b957309-3031-4878-bb40-73618467028d)


```python
# same as above except
structure = mn.Molecule.fetch("1EMA", centre="centroid")
structure.add_style(StyleRibbon(backbone_radius=3, quality=5))
```

![image](https://github.com/user-attachments/assets/1f81edc5-83f2-425c-b785-34349976a4ad)




Diff:
diff --git a/molecularnodes/entities/molecule/base.py b/molecularnodes/entities/molecule/base.py
index 760c2f68f..9ac69df0f 100644
--- a/molecularnodes/entities/molecule/base.py
+++ b/molecularnodes/entities/molecule/base.py
@@ -17,6 +17,9 @@
     add_style_branch,
     style_interfaces_from_tree,
 )
+from ...nodes.styles import (
+    StyleBase,
+)
 from ..base import EntityType, MolecularEntity
 from ..utilities import create_object
 from . import pdb, pdbx, sdf, selections
@@ -307,7 +310,7 @@ def n_models(self):
 
     def add_style(
         self,
-        style: bpy.types.GeometryNodeTree | str = "spheres",
+        style: StyleBase | str = "spheres",
         color: str | None = "common",
         selection: "str | MoleculeSelector | None" = None,
         assembly: bool = False,
diff --git a/molecularnodes/nodes/geometry.py b/molecularnodes/nodes/geometry.py
index f0cd52dfe..9b6d4f6bd 100644
--- a/molecularnodes/nodes/geometry.py
+++ b/molecularnodes/nodes/geometry.py
@@ -19,6 +19,7 @@
     insert_before,
     loc_between,
 )
+from .styles import StyleBase
 
 
 def insert_set_color(
@@ -77,7 +78,7 @@ def insert_animate_frames(
 
 def add_style_branch(
     tree: bpy.types.GeometryNodeTree,
-    style: str | bpy.types.GeometryNodeTree,
+    style: str | bpy.types.GeometryNodeTree | StyleBase,
     color: str | None = None,
     selection: str | None = None,
     material: bpy.types.Material | str | None = None,
@@ -98,6 +99,9 @@ def add_style_branch(
     if isinstance(style, str):
         style_name = nodes.styles_mapping[style]
         nodes.append(style_name)
+    elif isinstance(style, StyleBase):
+        style_name = nodes.styles_mapping[style.style]
+        nodes.append(style_name)
     elif isinstance(style, bpy.types.GeometryNodeTree):
         style_name = style.name
     else:
@@ -136,6 +140,9 @@ def add_style_branch(
 
     arrange_tree(tree)
 
+    if isinstance(style, StyleBase):
+        style.update_style_node(node_style)
+
 
 def get_final_style_nodes(
     tree: bpy.types.GeometryNodeTree,
diff --git a/molecularnodes/nodes/styles.py b/molecularnodes/nodes/styles.py
new file mode 100644
index 000000000..e6938b92b
--- /dev/null
+++ b/molecularnodes/nodes/styles.py
@@ -0,0 +1,281 @@
+"""
+Style Classes
+
+MolecularNodes uses Geometry Nodes to define molecule representations within blender. However,
+it is desireable to make these nodes accessible for scripting. The classes defined in this file
+provide a way to script against the GeometryNode nodetrees. When invoked on a style these classes
+will simply override the base styles.
+
+Because the nodetrees in the MN_data_file_{version}.blend are the source of truth, we add
+function to parity between the nodetrees and the class representations.
+
+Each style uses a Socket dataclass to define the mapping between class attributes and
+Blender node inputs, making it easier to maintain and extend the styles. The Socket
+dataclass establishes a clear relationship between Python attribute names and the corresponding
+Blender node input socket names, providing type safety and better IDE support than the
+previous dictionary-based approach.
+"""
+
+from dataclasses import dataclass
+from typing import List, Optional, Tuple
+from bpy.types import GeometryNodeGroup
+
+__all__ = [
+    "StyleBallandStick",
+    "StyleCartoon",
+    "StyleRibbon",
+    "StyleSpheres",
+    "StyleSticks",
+    "StyleSurface",
+    "StyleBase",
+]
+
+
+@dataclass
+class Socket:
+    """Represents a mapping between a class attribute and a Blender node input socket.
+
+    Attributes:
+        name: The name of the attribute in the Style class
+        blendername: The corresponding name of the input socket in the Blender node
+    """
+
+    name: str
+    blendername: Optional[str] = None
+
+
+SocketInfo = List[Socket]
+
+
+class StyleBase:
+    style: str
+    socketdata: SocketInfo = []
+
+    def update_style_node(self, node_style: GeometryNodeGroup):
+        """Update the Blender node inputs with values from this style's attributes.
+
+        Args:
+            node_style: The Blender GeometryNodeGroup to update
+        """
+        for input in node_style.inputs:
+            if input.type != "GEOMETRY":
+                for arg in self.socketdata:
+                    if input.name == arg.blendername:
+                        input.default_value = getattr(self, arg.name)
+
+
+class StyleBallandStick(StyleBase):
+    style = "ball_and_stick"
+    socketdata: SocketInfo = [
+        Socket(name="quality", blendername="Quality"),
+        Socket(name="geometry", blendername="Sphere Geometry"),
+        Socket(name="sphere_radii", blendername="Sphere Radii"),
+        Socket(name="bond_split", blendername="Bond Split"),
+        Socket(name="bond_find", blendername="Bond Find"),
+        Socket(name="bond_radius", blendername="Bond Radius"),
+        Socket(name="color_blur", blendername="Color Blur"),
+        Socket(name="shade_smooth", blendername="Shade Smooth"),
+    ]
+
+    def __init__(
+        self,
+        quality: int = 2,
+        geometry: str = "Instance",  # enum
+        sphere_radii: float = 0.3,
+        bond_split: bool = False,
+        bond_find: bool = False,
+        bond_radius: float = 0.3,
+        color_blur: bool = False,
+        shade_smooth: bool = True,
+    ):
+        self.quality = quality
+        self.geometry = geometry
+        self.sphere_radii = sphere_radii
+        self.bond_split = bond_split
+        self.bond_find = bond_find
+        self.bond_radius = bond_radius
+        self.color_blur = color_blur
+        self.shade_smooth = shade_smooth
+
+
+class StyleCartoon(StyleBase):
+    style = "cartoon"
+    socketdata: SocketInfo = [
+        Socket(name="quality", blendername="Quality"),
+        Socket(name="dssp", blendername="Peptide DSSP"),
+        Socket(name="cylinders", blendername="Peptide Cylinders"),
+        Socket(name="arrows", blendername="Peptide Arrows"),
+        Socket(name="rounded", blendername="Peptide Rounded"),
+        Socket(name="thickness", blendername="Peptide Thickness"),
+        Socket(name="width", blendername="Peptide Width"),
+        Socket(name="loop_radius", blendername="Peptide Loop Radius"),
+        Socket(name="smoothing", blendername="Peptide Smoothing"),
+        Socket(name="backbone_shape", blendername="Backbone Shape"),
+        Socket(name="backbone_radius", blendername="Backbone Radius"),
+        Socket(name="base_shape", blendername="Base Shape"),
+        Socket(name="base_realize", blendername="Base Realize"),
+        Socket(name="color_blur", blendername="Color Blur"),
+        Socket(name="shade_smooth", blendername="Shade Smooth"),
+    ]
+
+    def __init__(
+        self,
+        quality: int = 2,
+        dssp: bool = False,
+        cylinders: bool = False,
+        arrows: bool = True,
+        rounded: bool = False,
+        thickness: float = 0.6,
+        width: float = 2.2,
+        loop_radius: float = 0.3,
+        smoothing: float = 0.5,
+        backbone_shape: str = "Cylinder",  # enum?
+        backbone_radius: float = 2.0,
+        base_shape: str = "Rectangle",  # enum?
+        base_realize: bool = False,
+        color_blur: bool = True,
+        shade_smooth: bool = True,
+    ):
+        self.quality = quality
+        self.dssp = dssp
+        self.cylinders = cylinders
+        self.arrows = arrows
+        self.rounded = rounded
+        self.thickness = thickness
+        self.width = width
+        self.loop_radius = loop_radius
+        self.smoothing = smoothing
+        self.backbone_shape = backbone_shape
+        self.backbone_radius = backbone_radius
+        self.base_shape = base_shape
+        self.base_realize = base_realize
+        self.color_blur = color_blur
+        self.shade_smooth = shade_smooth
+
+
+class StyleRibbon(StyleBase):
+    style = "ribbon"
+    socketdata: SocketInfo = [
+        Socket(name="quality", blendername="Quality"),
+        Socket(name="color_blur", blendername="Color Blur"),
+        Socket(name="shade_smooth", blendername="Shade Smooth"),
+        Socket(name="backbone_smoothing", blendername="Backbone Smoothing"),
+        Socket(name="backbone_threshold", blendername="Backbone Threshold"),
+        Socket(name="backbone_radius", blendername="Backbone Radius"),
+        Socket(name="backbone_shape", blendername="Backbone Shape"),
+        Socket(name="base_scale", blendername="Base Scale"),
+        Socket(name="base_resolution", blendername="Base Resolution"),
+        Socket(name="base_realize", blendername="Base Realize"),
+        Socket(name="uv_map", blendername="UV Map"),
+        Socket(name="u_component", blendername="U Component"),
+    ]
+
+    def __init__(
+        self,
+        quality: int = 3,
+        color_blur: bool = False,
+        shade_smooth: bool = False,
+        backbone_smoothing: float = 0.5,
+        backbone_threshold: float = 4.5,
+        base_scale: Tuple[float, float, float] = (2.5, 0.5, 7.0),
+        backbone_radius: float = 2.0,
+        backbone_shape: str = "Cylinder",
+        base_resolution: int = 4,
+        base_realize: bool = False,
+        uv_map: bool = False,
+        u_component=None,
+        # u_component_factor=None,
+    ):
+        self.quality = quality
+        self.color_blur = color_blur
+        self.shade_smooth = shade_smooth
+        self.backbone_smoothing = backbone_smoothing
+        self.backbone_threshold = backbone_threshold
+        self.backbone_radius = backbone_radius
+        self.backbone_shape = backbone_shape  # enum?
+        self.base_scale = base_scale
+        self.base_resolution = base_resolution
+        self.base_realize = base_realize
+        self.uv_map = uv_map
+        self.u_component = u_component
+
+
+class StyleSpheres(StyleBase):
+    style = "spheres"
+    socketdata: SocketInfo = [
+        Socket(name="geometry", blendername="Sphere Geometry"),
+        Socket(name="radii", blendername="Sphere Radii"),
+        Socket(name="sphere_subdivisions", blendername="Sphere Subdivisions"),
+        Socket(name="shade_smooth", blendername="Shade Smooth"),
+    ]
+
+    def __init__(
+        self,
+        geometry: str = "Point",  # enum: "Point" (Point Cloud), "Instances" (Instances of a mesh Icosphere), or "Mesh" (realised Mesh)
+        radii: float = 0.8,
+        sphere_subdivisions: int = 2,
+        shade_smooth: bool = True,
+    ):
+        self.geometry = geometry
+        self.radii = radii
+        self.sphere_subdivisions = sphere_subdivisions
+        self.shade_smooth = shade_smooth
+
+
+class StyleSticks(StyleBase):
+    style = "sticks"
+    socketdata: SocketInfo = [
+        Socket(name="quality", blendername="Quality"),
+        Socket(name="radius", blendername="Radius"),
+        Socket(name="color_blur", blendername="Color Blur"),
+        Socket(name="shade_smooth", blendername="Shade Smooth"),
+    ]
+
+    def __init__(
+        self,
+        quality: int = 3,
+        radius: float = 0.2,
+        color_blur: bool = False,
+        shade_smooth: bool = True,
+    ):
+        self.quality = quality
+        self.radius = radius
+        self.color_blur = color_blur
+        self.shade_smooth = shade_smooth
+
+
+class StyleSurface(StyleBase):
+    style = "surface"
+    socketdata: SocketInfo = [
+        Socket(name="quality", blendername="Quality"),
+        Socket(name="scale_radii", blendername="Scale Radii"),
+        Socket(name="probe_size", blendername="Probe Size"),
+        Socket(name="relaxation_steps", blendername="Relaxation Steps"),
+        Socket(name="separate", blendername="Separate By"),
+        Socket(name="group_id", blendername="Group ID"),
+        Socket(name="color_source", blendername="Color Source"),
+        Socket(name="blur", blendername="Color Blur"),
+        Socket(name="shade_smooth", blendername="Shade Smooth"),
+    ]
+
+    def __init__(
+        self,
+        quality: int = 3,
+        scale_radii: float = 1.5,
+        probe_size: float = 1.0,
+        relaxation_steps: int = 10,
+        separate: str = "chain_id",  # enum?
+        group_id: int = 0,
+        color_source: str = "Alpha Carbon",  # enum?
+        blur: int = 2,
+        shade_smooth: bool = True,
+    ):
+        self.quality = quality
+        self.scale_radii = scale_radii
+        self.probe_size = probe_size
+        self.relaxation_steps = relaxation_steps
+        self.separate = separate
+        self.group_id = group_id
+        self.color_source = color_source
+        self.blur = blur
+        self.shade_smooth = shade_smooth
diff --git a/tests/test_nodes.py b/tests/test_nodes.py
index 3425bee38..a3c1294d6 100644
--- a/tests/test_nodes.py
+++ b/tests/test_nodes.py
@@ -1,4 +1,3 @@
-# import bpy
 import random
 import bpy
 import numpy as np
diff --git a/tests/test_styles.py b/tests/test_styles.py
new file mode 100644
index 000000000..29d492059
--- /dev/null
+++ b/tests/test_styles.py
@@ -0,0 +1,77 @@
+import math
+import bpy
+import molecularnodes as mn
+from molecularnodes.nodes.nodes import get_style_node
+from molecularnodes.nodes.styles import (
+    StyleBallandStick,
+    StyleCartoon,
+    StyleSpheres,
+    StyleSticks,
+    StyleSurface,
+)
+
+DONT_COMPARE = {"Atoms", "Selection", "Material"}
+
+
+def assess_node_equivalency(name, style):
+    """
+    1. Loads Node from MN file via classic route
+    2. Directly loads class
+    3. Compares the fields and defaults for each
+    """
+
+    # get the names and default values of the blender style ndoes
+    mol = mn.Molecule.fetch("4ozs").add_style(name)
+
+    style_node = get_style_node(mol.object)
+    blender_inputs = [
+        [input.name, input.default_value]
+        for input in style_node.inputs
+        if input.type != "GEOMETRY" and input.name not in DONT_COMPARE
+    ]
+    blender_names = set(name for [name, _] in blender_inputs)
+
+    # get the style class name
+    style_class = style()
+    style_class_bnames = set(sc.blendername for sc in style_class.socketdata)
+
+    # check names bidirectionally
+    for bname in blender_names:
+        assert bname in style_class_bnames, (
+            f"MN Style {name} has field {bname} that is not found in the styles class"
+        )
+    for sname in style_class_bnames:
+        assert sname in blender_names, (
+            f"Internal Style class for {name} has field {sname} that is not found in the upstream MN Style Node"
+        )
+
+    for [bname, bvalue] in blender_inputs:
+        for pdata in style_class.socketdata:
+            if pdata.blendername == bname:
+                local_name = pdata.name
+                local_val = getattr(style_class, local_name)
+                # floats come from C++ and are artificially long
+                if isinstance(bvalue, float):
+                    assert math.isclose(bvalue, local_val, rel_tol=0.1, abs_tol=0.1), (
+                        f"( Checking Floats ) In style {name}, field {local_name}: Values {bvalue} and {local_val} are not equivalent"
+                    )
+                else:
+                    assert local_val == bvalue, (
+                        f"In style {name}, field {local_name}: Values {bvalue} and {local_val} are not equivalent"
+                    )
+
+    # equivalency check.
+    assert style_class_bnames.difference(blender_names) == set()
+    assert blender_names.difference(style_class_bnames) == set()
+
+    bpy.data.objects.remove(mol.object)
+
+
+def test_styles():
+    assess_node_equivalency("ball+stick", StyleBallandStick)
+    assess_node_equivalency("cartoon", StyleCartoon)
+    # note backbone radius of ribbon seems to switch between 1.6 and 2.0
+    # assess_node_equivalency("ribbon", StyleRibbon)
+    assess_node_equivalency("spheres", StyleSpheres)
+    assess_node_equivalency("sticks", StyleSticks)
+    assess_node_equivalency("surface", StyleSurface)


Reviews:
user: login: BradyAJohnston
body: I think we should change the node definitions to use dataclasses for individual sockets rather than dictionaries.
state: CHANGES_REQUESTED
author_association: OWNER
submitted_at: 2025-05-15T04:03:10Z
user: login: BradyAJohnston
body: Just some minor things but then I think we are good to go!
state: CHANGES_REQUESTED
author_association: OWNER
submitted_at: 2025-05-19T14:12:41Z
user: login: BradyAJohnston
state: COMMENTED
author_association: OWNER
submitted_at: 2025-05-19T14:12:59Z
user: login: zachcp
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T14:18:03Z
user: login: zachcp
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T14:18:13Z
user: login: BradyAJohnston
body: Again will review and merge when in the morning after some sleep but LGTM!
state: APPROVED
author_association: OWNER
submitted_at: 2025-05-19T14:19:44Z

Review comments:
diff_hunk: @@ -0,0 +1,258 @@
+"""
+Style Classes
+
+MolecularNodes uses Geometry Nodes to define molecule representations within blender. However,
+it is desireable to make these nodes accessible for scripting. The classes defined in this file
+provide a way to script against the GeometryNode nodetrees. When invoked on a style these classes
+will simply override the base styles.
+
+Because the nodetrees in the MN_data_file_{version}.blend are the source of truth, we add
+function to parity between the nodetrees and the class representations.
+
+"""
+
+from typing import List, Tuple, Union, Any, Dict
+from bpy.types import GeometryNodeGroup
+
+__all__ = [
+    "StyleBallandStick",
+    "StyleCartoon",
+    "StyleRibbon",
+    "StyleSpheres",
+    "StyleSticks",
+    "StyleSurface",
+    "StyleBase",
+]
+
+PortDataEntry = Dict[str, Any]
path: molecularnodes/nodes/styles.py
user: login: BradyAJohnston
body: Unsure about the naming here, why "Port"? Inside of blender they are reference as sockets, unless you are meaning something else.
created_at: 2025-05-15T03:57:13Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,258 @@
+"""
+Style Classes
+
+MolecularNodes uses Geometry Nodes to define molecule representations within blender. However,
+it is desireable to make these nodes accessible for scripting. The classes defined in this file
+provide a way to script against the GeometryNode nodetrees. When invoked on a style these classes
+will simply override the base styles.
+
+Because the nodetrees in the MN_data_file_{version}.blend are the source of truth, we add
+function to parity between the nodetrees and the class representations.
+
+"""
+
+from typing import List, Tuple, Union, Any, Dict
+from bpy.types import GeometryNodeGroup
+
+__all__ = [
+    "StyleBallandStick",
+    "StyleCartoon",
+    "StyleRibbon",
+    "StyleSpheres",
+    "StyleSticks",
+    "StyleSurface",
+    "StyleBase",
+]
+
+PortDataEntry = Dict[str, Any]
path: molecularnodes/nodes/styles.py
user: login: BradyAJohnston
body: Additionally, I think these should be dataclass instead of dictionaries. Reduces keystrokes and potential for spelling mistakes.
created_at: 2025-05-15T03:58:15Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,281 @@
+"""
+Style Classes
+
+MolecularNodes uses Geometry Nodes to define molecule representations within blender. However,
+it is desireable to make these nodes accessible for scripting. The classes defined in this file
+provide a way to script against the GeometryNode nodetrees. When invoked on a style these classes
+will simply override the base styles.
+
+Because the nodetrees in the MN_data_file_{version}.blend are the source of truth, we add
+function to parity between the nodetrees and the class representations.
+
+Each style uses a Socket dataclass to define the mapping between class attributes and
+Blender node inputs, making it easier to maintain and extend the styles. The Socket
+dataclass establishes a clear relationship between Python attribute names and the corresponding
+Blender node input socket names, providing type safety and better IDE support than the
+previous dictionary-based approach.
+"""
+
+from dataclasses import dataclass
+from typing import List, Optional, Tuple
+from bpy.types import GeometryNodeGroup
+
+__all__ = [
+    "StyleBallandStick",
+    "StyleCartoon",
+    "StyleRibbon",
+    "StyleSpheres",
+    "StyleSticks",
+    "StyleSurface",
+    "StyleBase",
+]
+
+
+@dataclass
+class Socket:
+    """Represents a mapping between a class attribute and a Blender node input socket.
+
+    Attributes:
+        name: The name of the attribute in the Style class
+        blendername: The corresponding name of the input socket in the Blender node
+    """
+
+    name: str
+    blendername: Optional[str] = None
+
+
+SocketInfo = List[Socket]
+
+
+class StyleBase:
+    style: str
+    portdata: SocketInfo = []
+
+    def update_style_node(self, node_style: GeometryNodeGroup):
+        """Update the Blender node inputs with values from this style's attributes.
+
+        Args:
+            node_style: The Blender GeometryNodeGroup to update
+        """
+        for input in node_style.inputs:
+            if input.type != "GEOMETRY":
+                for arg in self.portdata:
+                    if input.name == arg.blendername:
+                        input.default_value = getattr(self, arg.name)
+
+
+class StyleBallandStick(StyleBase):
+    style = "ball_and_stick"
+    portdata: SocketInfo = [
path: molecularnodes/nodes/styles.py
user: login: BradyAJohnston
body: rename from port to socket
created_at: 2025-05-19T14:09:58Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,281 @@
+"""
+Style Classes
+
+MolecularNodes uses Geometry Nodes to define molecule representations within blender. However,
+it is desireable to make these nodes accessible for scripting. The classes defined in this file
+provide a way to script against the GeometryNode nodetrees. When invoked on a style these classes
+will simply override the base styles.
+
+Because the nodetrees in the MN_data_file_{version}.blend are the source of truth, we add
+function to parity between the nodetrees and the class representations.
+
+Each style uses a Socket dataclass to define the mapping between class attributes and
+Blender node inputs, making it easier to maintain and extend the styles. The Socket
+dataclass establishes a clear relationship between Python attribute names and the corresponding
+Blender node input socket names, providing type safety and better IDE support than the
+previous dictionary-based approach.
+"""
+
+from dataclasses import dataclass
+from typing import List, Optional, Tuple
+from bpy.types import GeometryNodeGroup
+
+__all__ = [
+    "StyleBallandStick",
+    "StyleCartoon",
+    "StyleRibbon",
+    "StyleSpheres",
+    "StyleSticks",
+    "StyleSurface",
+    "StyleBase",
+]
+
+
+@dataclass
+class Socket:
+    """Represents a mapping between a class attribute and a Blender node input socket.
+
+    Attributes:
+        name: The name of the attribute in the Style class
+        blendername: The corresponding name of the input socket in the Blender node
+    """
+
+    name: str
+    blendername: Optional[str] = None
+
+
+SocketInfo = List[Socket]
+
+
+class StyleBase:
+    style: str
+    portdata: SocketInfo = []
+
+    def update_style_node(self, node_style: GeometryNodeGroup):
+        """Update the Blender node inputs with values from this style's attributes.
+
+        Args:
+            node_style: The Blender GeometryNodeGroup to update
+        """
+        for input in node_style.inputs:
+            if input.type != "GEOMETRY":
+                for arg in self.portdata:
+                    if input.name == arg.blendername:
+                        input.default_value = getattr(self, arg.name)
+
+
+class StyleBallandStick(StyleBase):
+    style = "ball_and_stick"
+    portdata: SocketInfo = [
+        Socket(name="quality", blendername="Quality"),
+        Socket(name="geometry", blendername="Sphere Geometry"),
+        Socket(name="sphere_radii", blendername="Sphere Radii"),
+        Socket(name="bond_split", blendername="Bond Split"),
+        Socket(name="bond_find", blendername="Bond Find"),
+        Socket(name="bond_radius", blendername="Bond Radius"),
+        Socket(name="color_blur", blendername="Color Blur"),
+        Socket(name="shade_smooth", blendername="Shade Smooth"),
+    ]
+
+    def __init__(
+        self,
+        quality: int = 2,
+        geometry: str = "Instance",  # enum
+        sphere_radii: float = 0.3,
+        bond_split: bool = False,
+        bond_find: bool = False,
+        bond_radius: float = 0.3,
+        color_blur: bool = False,
+        shade_smooth: bool = True,
+    ):
+        self.quality = quality
+        self.geometry = geometry
+        self.sphere_radii = sphere_radii
+        self.bond_split = bond_split
+        self.bond_find = bond_find
+        self.bond_radius = bond_radius
+        self.color_blur = color_blur
+        self.shade_smooth = shade_smooth
+
+
+class StyleCartoon(StyleBase):
+    style = "cartoon"
+    portdata: SocketInfo = [
+        Socket(name="quality", blendername="Quality"),
+        Socket(name="dssp", blendername="Peptide DSSP"),
+        Socket(name="cylinders", blendername="Peptide Cylinders"),
+        Socket(name="arrows", blendername="Peptide Arrows"),
+        Socket(name="rounded", blendername="Peptide Rounded"),
+        Socket(name="thickness", blendername="Peptide Thickness"),
+        Socket(name="width", blendername="Peptide Width"),
+        Socket(name="loop_radius", blendername="Peptide Loop Radius"),
+        Socket(name="smoothing", blendername="Peptide Smoothing"),
+        Socket(name="backbone_shape", blendername="Backbone Shape"),
+        Socket(name="backbone_radius", blendername="Backbone Radius"),
+        Socket(name="base_shape", blendername="Base Shape"),
+        Socket(name="base_realize", blendername="Base Realize"),
+        Socket(name="color_blur", blendername="Color Blur"),
+        Socket(name="shade_smooth", blendername="Shade Smooth"),
+    ]
+
+    def __init__(
+        self,
+        quality: int = 2,
+        dssp: bool = False,
+        cylinders: bool = False,
+        arrows: bool = True,
+        rounded: bool = False,
+        thickness: float = 0.6,
+        width: float = 2.2,
+        loop_radius: float = 0.3,
+        smoothing: float = 0.5,
+        backbone_shape: str = "Cylinder",  # enum?
+        backbone_radius: float = 2.0,
+        base_shape: str = "Rectangle",  # enum?
+        base_realize: bool = False,
+        color_blur: bool = True,
+        shade_smooth: bool = True,
+    ):
+        self.quality = quality
+        self.dssp = dssp
+        self.cylinders = cylinders
+        self.arrows = arrows
+        self.rounded = rounded
+        self.thickness = thickness
+        self.width = width
+        self.loop_radius = loop_radius
+        self.smoothing = smoothing
+        self.backbone_shape = backbone_shape
+        self.backbone_radius = backbone_radius
+        self.base_shape = base_shape
+        self.base_realize = base_realize
+        self.color_blur = color_blur
+        self.shade_smooth = shade_smooth
+
+
+class StyleRibbon(StyleBase):
+    style = "ribbon"
+    portdata: SocketInfo = [
+        Socket(name="quality", blendername="Quality"),
+        Socket(name="color_blur", blendername="Color Blur"),
+        Socket(name="shade_smooth", blendername="Shade Smooth"),
+        Socket(name="backbone_smoothing", blendername="Backbone Smoothing"),
+        Socket(name="backbone_threshold", blendername="Backbone Threshold"),
+        Socket(name="backbone_radius", blendername="Backbone Radius"),
+        Socket(name="backbone_shape", blendername="Backbone Shape"),
+        Socket(name="base_scale", blendername="Base Scale"),
+        Socket(name="base_resolution", blendername="Base Resolution"),
+        Socket(name="base_realize", blendername="Base Realize"),
+        Socket(name="uv_map", blendername="UV Map"),
+        Socket(name="u_component", blendername="U Component"),
+    ]
+
+    def __init__(
+        self,
+        quality: int = 3,
+        color_blur: bool = False,
+        shade_smooth: bool = False,
+        backbone_smoothing: float = 0.5,
+        backbone_threshold: float = 4.5,
+        base_scale: Tuple[float, float, float] = (2.5, 0.5, 7.0),
+        backbone_radius: float = 2.0,
+        backbone_shape: str = "Cylinder",
+        base_resolution: int = 4,
+        base_realize: bool = False,
+        uv_map: bool = False,
+        u_component=None,
+        # u_component_factor=None,
+    ):
+        self.quality = quality
+        self.color_blur = color_blur
+        self.shade_smooth = shade_smooth
+        self.backbone_smoothing = backbone_smoothing
+        self.backbone_threshold = backbone_threshold
+        self.backbone_radius = backbone_radius
+        self.backbone_shape = backbone_shape  # enum?
+        self.base_scale = base_scale
+        self.base_resolution = base_resolution
+        self.base_realize = base_realize
+        self.uv_map = uv_map
+        self.u_component = u_component
+
+
+class StyleSpheres(StyleBase):
+    style = "spheres"
+    portdata: SocketInfo = [
+        Socket(name="geometry", blendername="Sphere Geometry"),
+        Socket(name="radii", blendername="Sphere Radii"),
+        Socket(name="sphere_subdivisions", blendername="Sphere Subdivisions"),
+        Socket(name="shade_smooth", blendername="Shade Smooth"),
+    ]
+
+    def __init__(
+        self,
+        geometry: str = "Point",  # enum: "Point" (Point Cloud), "Instances" (Instances of a mesh Icosphere), or "Mesh" (realised Mesh)
+        radii: float = 0.8,
+        sphere_subdivisions: int = 2,
+        shade_smooth: bool = True,
+    ):
+        self.geometry = geometry
+        self.radii = radii
+        self.sphere_subdivisions = sphere_subdivisions
+        self.shade_smooth = shade_smooth
+
+
+class StyleSticks(StyleBase):
+    style = "sticks"
+    portdata: SocketInfo = [
+        Socket(name="quality", blendername="Quality"),
+        Socket(name="radius", blendername="Radius"),
+        Socket(name="color_blur", blendername="Color Blur"),
+        Socket(name="shade_smooth", blendername="Shade Smooth"),
+    ]
+
+    def __init__(
+        self,
+        quality: int = 3,
+        radius: float = 0.20000000298023224,
path: molecularnodes/nodes/styles.py
user: login: BradyAJohnston
body: I know inside of blender it's technically the floating point version but in code we can just have it be 0.2
created_at: 2025-05-19T14:12:07Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,281 @@
+"""
+Style Classes
+
+MolecularNodes uses Geometry Nodes to define molecule representations within blender. However,
+it is desireable to make these nodes accessible for scripting. The classes defined in this file
+provide a way to script against the GeometryNode nodetrees. When invoked on a style these classes
+will simply override the base styles.
+
+Because the nodetrees in the MN_data_file_{version}.blend are the source of truth, we add
+function to parity between the nodetrees and the class representations.
+
+Each style uses a Socket dataclass to define the mapping between class attributes and
+Blender node inputs, making it easier to maintain and extend the styles. The Socket
+dataclass establishes a clear relationship between Python attribute names and the corresponding
+Blender node input socket names, providing type safety and better IDE support than the
+previous dictionary-based approach.
+"""
+
+from dataclasses import dataclass
+from typing import List, Optional, Tuple
+from bpy.types import GeometryNodeGroup
+
+__all__ = [
+    "StyleBallandStick",
+    "StyleCartoon",
+    "StyleRibbon",
+    "StyleSpheres",
+    "StyleSticks",
+    "StyleSurface",
+    "StyleBase",
+]
+
+
+@dataclass
+class Socket:
+    """Represents a mapping between a class attribute and a Blender node input socket.
+
+    Attributes:
+        name: The name of the attribute in the Style class
+        blendername: The corresponding name of the input socket in the Blender node
+    """
+
+    name: str
+    blendername: Optional[str] = None
+
+
+SocketInfo = List[Socket]
+
+
+class StyleBase:
+    style: str
+    portdata: SocketInfo = []
+
+    def update_style_node(self, node_style: GeometryNodeGroup):
+        """Update the Blender node inputs with values from this style's attributes.
+
+        Args:
+            node_style: The Blender GeometryNodeGroup to update
+        """
+        for input in node_style.inputs:
+            if input.type != "GEOMETRY":
+                for arg in self.portdata:
+                    if input.name == arg.blendername:
+                        input.default_value = getattr(self, arg.name)
+
+
+class StyleBallandStick(StyleBase):
+    style = "ball_and_stick"
+    portdata: SocketInfo = [
path: molecularnodes/nodes/styles.py
user: login: BradyAJohnston
body: this should be renamed very time it shows up
created_at: 2025-05-19T14:12:59Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,281 @@
+"""
+Style Classes
+
+MolecularNodes uses Geometry Nodes to define molecule representations within blender. However,
+it is desireable to make these nodes accessible for scripting. The classes defined in this file
+provide a way to script against the GeometryNode nodetrees. When invoked on a style these classes
+will simply override the base styles.
+
+Because the nodetrees in the MN_data_file_{version}.blend are the source of truth, we add
+function to parity between the nodetrees and the class representations.
+
+Each style uses a Socket dataclass to define the mapping between class attributes and
+Blender node inputs, making it easier to maintain and extend the styles. The Socket
+dataclass establishes a clear relationship between Python attribute names and the corresponding
+Blender node input socket names, providing type safety and better IDE support than the
+previous dictionary-based approach.
+"""
+
+from dataclasses import dataclass
+from typing import List, Optional, Tuple
+from bpy.types import GeometryNodeGroup
+
+__all__ = [
+    "StyleBallandStick",
+    "StyleCartoon",
+    "StyleRibbon",
+    "StyleSpheres",
+    "StyleSticks",
+    "StyleSurface",
+    "StyleBase",
+]
+
+
+@dataclass
+class Socket:
+    """Represents a mapping between a class attribute and a Blender node input socket.
+
+    Attributes:
+        name: The name of the attribute in the Style class
+        blendername: The corresponding name of the input socket in the Blender node
+    """
+
+    name: str
+    blendername: Optional[str] = None
+
+
+SocketInfo = List[Socket]
+
+
+class StyleBase:
+    style: str
+    portdata: SocketInfo = []
+
+    def update_style_node(self, node_style: GeometryNodeGroup):
+        """Update the Blender node inputs with values from this style's attributes.
+
+        Args:
+            node_style: The Blender GeometryNodeGroup to update
+        """
+        for input in node_style.inputs:
+            if input.type != "GEOMETRY":
+                for arg in self.portdata:
+                    if input.name == arg.blendername:
+                        input.default_value = getattr(self, arg.name)
+
+
+class StyleBallandStick(StyleBase):
+    style = "ball_and_stick"
+    portdata: SocketInfo = [
path: molecularnodes/nodes/styles.py
user: login: zachcp
body: done
created_at: 2025-05-19T14:18:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,281 @@
+"""
+Style Classes
+
+MolecularNodes uses Geometry Nodes to define molecule representations within blender. However,
+it is desireable to make these nodes accessible for scripting. The classes defined in this file
+provide a way to script against the GeometryNode nodetrees. When invoked on a style these classes
+will simply override the base styles.
+
+Because the nodetrees in the MN_data_file_{version}.blend are the source of truth, we add
+function to parity between the nodetrees and the class representations.
+
+Each style uses a Socket dataclass to define the mapping between class attributes and
+Blender node inputs, making it easier to maintain and extend the styles. The Socket
+dataclass establishes a clear relationship between Python attribute names and the corresponding
+Blender node input socket names, providing type safety and better IDE support than the
+previous dictionary-based approach.
+"""
+
+from dataclasses import dataclass
+from typing import List, Optional, Tuple
+from bpy.types import GeometryNodeGroup
+
+__all__ = [
+    "StyleBallandStick",
+    "StyleCartoon",
+    "StyleRibbon",
+    "StyleSpheres",
+    "StyleSticks",
+    "StyleSurface",
+    "StyleBase",
+]
+
+
+@dataclass
+class Socket:
+    """Represents a mapping between a class attribute and a Blender node input socket.
+
+    Attributes:
+        name: The name of the attribute in the Style class
+        blendername: The corresponding name of the input socket in the Blender node
+    """
+
+    name: str
+    blendername: Optional[str] = None
+
+
+SocketInfo = List[Socket]
+
+
+class StyleBase:
+    style: str
+    portdata: SocketInfo = []
+
+    def update_style_node(self, node_style: GeometryNodeGroup):
+        """Update the Blender node inputs with values from this style's attributes.
+
+        Args:
+            node_style: The Blender GeometryNodeGroup to update
+        """
+        for input in node_style.inputs:
+            if input.type != "GEOMETRY":
+                for arg in self.portdata:
+                    if input.name == arg.blendername:
+                        input.default_value = getattr(self, arg.name)
+
+
+class StyleBallandStick(StyleBase):
+    style = "ball_and_stick"
+    portdata: SocketInfo = [
+        Socket(name="quality", blendername="Quality"),
+        Socket(name="geometry", blendername="Sphere Geometry"),
+        Socket(name="sphere_radii", blendername="Sphere Radii"),
+        Socket(name="bond_split", blendername="Bond Split"),
+        Socket(name="bond_find", blendername="Bond Find"),
+        Socket(name="bond_radius", blendername="Bond Radius"),
+        Socket(name="color_blur", blendername="Color Blur"),
+        Socket(name="shade_smooth", blendername="Shade Smooth"),
+    ]
+
+    def __init__(
+        self,
+        quality: int = 2,
+        geometry: str = "Instance",  # enum
+        sphere_radii: float = 0.3,
+        bond_split: bool = False,
+        bond_find: bool = False,
+        bond_radius: float = 0.3,
+        color_blur: bool = False,
+        shade_smooth: bool = True,
+    ):
+        self.quality = quality
+        self.geometry = geometry
+        self.sphere_radii = sphere_radii
+        self.bond_split = bond_split
+        self.bond_find = bond_find
+        self.bond_radius = bond_radius
+        self.color_blur = color_blur
+        self.shade_smooth = shade_smooth
+
+
+class StyleCartoon(StyleBase):
+    style = "cartoon"
+    portdata: SocketInfo = [
+        Socket(name="quality", blendername="Quality"),
+        Socket(name="dssp", blendername="Peptide DSSP"),
+        Socket(name="cylinders", blendername="Peptide Cylinders"),
+        Socket(name="arrows", blendername="Peptide Arrows"),
+        Socket(name="rounded", blendername="Peptide Rounded"),
+        Socket(name="thickness", blendername="Peptide Thickness"),
+        Socket(name="width", blendername="Peptide Width"),
+        Socket(name="loop_radius", blendername="Peptide Loop Radius"),
+        Socket(name="smoothing", blendername="Peptide Smoothing"),
+        Socket(name="backbone_shape", blendername="Backbone Shape"),
+        Socket(name="backbone_radius", blendername="Backbone Radius"),
+        Socket(name="base_shape", blendername="Base Shape"),
+        Socket(name="base_realize", blendername="Base Realize"),
+        Socket(name="color_blur", blendername="Color Blur"),
+        Socket(name="shade_smooth", blendername="Shade Smooth"),
+    ]
+
+    def __init__(
+        self,
+        quality: int = 2,
+        dssp: bool = False,
+        cylinders: bool = False,
+        arrows: bool = True,
+        rounded: bool = False,
+        thickness: float = 0.6,
+        width: float = 2.2,
+        loop_radius: float = 0.3,
+        smoothing: float = 0.5,
+        backbone_shape: str = "Cylinder",  # enum?
+        backbone_radius: float = 2.0,
+        base_shape: str = "Rectangle",  # enum?
+        base_realize: bool = False,
+        color_blur: bool = True,
+        shade_smooth: bool = True,
+    ):
+        self.quality = quality
+        self.dssp = dssp
+        self.cylinders = cylinders
+        self.arrows = arrows
+        self.rounded = rounded
+        self.thickness = thickness
+        self.width = width
+        self.loop_radius = loop_radius
+        self.smoothing = smoothing
+        self.backbone_shape = backbone_shape
+        self.backbone_radius = backbone_radius
+        self.base_shape = base_shape
+        self.base_realize = base_realize
+        self.color_blur = color_blur
+        self.shade_smooth = shade_smooth
+
+
+class StyleRibbon(StyleBase):
+    style = "ribbon"
+    portdata: SocketInfo = [
+        Socket(name="quality", blendername="Quality"),
+        Socket(name="color_blur", blendername="Color Blur"),
+        Socket(name="shade_smooth", blendername="Shade Smooth"),
+        Socket(name="backbone_smoothing", blendername="Backbone Smoothing"),
+        Socket(name="backbone_threshold", blendername="Backbone Threshold"),
+        Socket(name="backbone_radius", blendername="Backbone Radius"),
+        Socket(name="backbone_shape", blendername="Backbone Shape"),
+        Socket(name="base_scale", blendername="Base Scale"),
+        Socket(name="base_resolution", blendername="Base Resolution"),
+        Socket(name="base_realize", blendername="Base Realize"),
+        Socket(name="uv_map", blendername="UV Map"),
+        Socket(name="u_component", blendername="U Component"),
+    ]
+
+    def __init__(
+        self,
+        quality: int = 3,
+        color_blur: bool = False,
+        shade_smooth: bool = False,
+        backbone_smoothing: float = 0.5,
+        backbone_threshold: float = 4.5,
+        base_scale: Tuple[float, float, float] = (2.5, 0.5, 7.0),
+        backbone_radius: float = 2.0,
+        backbone_shape: str = "Cylinder",
+        base_resolution: int = 4,
+        base_realize: bool = False,
+        uv_map: bool = False,
+        u_component=None,
+        # u_component_factor=None,
+    ):
+        self.quality = quality
+        self.color_blur = color_blur
+        self.shade_smooth = shade_smooth
+        self.backbone_smoothing = backbone_smoothing
+        self.backbone_threshold = backbone_threshold
+        self.backbone_radius = backbone_radius
+        self.backbone_shape = backbone_shape  # enum?
+        self.base_scale = base_scale
+        self.base_resolution = base_resolution
+        self.base_realize = base_realize
+        self.uv_map = uv_map
+        self.u_component = u_component
+
+
+class StyleSpheres(StyleBase):
+    style = "spheres"
+    portdata: SocketInfo = [
+        Socket(name="geometry", blendername="Sphere Geometry"),
+        Socket(name="radii", blendername="Sphere Radii"),
+        Socket(name="sphere_subdivisions", blendername="Sphere Subdivisions"),
+        Socket(name="shade_smooth", blendername="Shade Smooth"),
+    ]
+
+    def __init__(
+        self,
+        geometry: str = "Point",  # enum: "Point" (Point Cloud), "Instances" (Instances of a mesh Icosphere), or "Mesh" (realised Mesh)
+        radii: float = 0.8,
+        sphere_subdivisions: int = 2,
+        shade_smooth: bool = True,
+    ):
+        self.geometry = geometry
+        self.radii = radii
+        self.sphere_subdivisions = sphere_subdivisions
+        self.shade_smooth = shade_smooth
+
+
+class StyleSticks(StyleBase):
+    style = "sticks"
+    portdata: SocketInfo = [
+        Socket(name="quality", blendername="Quality"),
+        Socket(name="radius", blendername="Radius"),
+        Socket(name="color_blur", blendername="Color Blur"),
+        Socket(name="shade_smooth", blendername="Shade Smooth"),
+    ]
+
+    def __init__(
+        self,
+        quality: int = 3,
+        radius: float = 0.20000000298023224,
path: molecularnodes/nodes/styles.py
user: login: zachcp
body: good catch
created_at: 2025-05-19T14:18:13Z
author_association: CONTRIBUTOR

PR comments:
user: login: BradyAJohnston
created_at: 2025-05-01T02:02:20Z
body: Thanks for having a crack at this. I think it mostly does what I was thinking. 

I don't know what the best approach would be, but I'm conscious of having "multiple sources of truth". Ideally I think we would generate these classes, or at least the `.py` that defines the classes from the data within the `.blend` file, so that if something is updated for the styles then these definitions stay in sync. Doesn't have to be this way, open to what you think about best approaches.

Would be more ideal I know to not have styles defined in a `.blend` but that's not really an option at the moment.
author_association: OWNER
user: login: zachcp
created_at: 2025-05-01T02:34:18Z
body: A couple of things come to mind:

1.Lightest weight is just a test suite that tests for deviations both ways. E.g. in fields or default values change

2. Codegen is possible but adds some build complexity. Let me think about it overnight.




author_association: CONTRIBUTOR
user: login: zachcp
created_at: 2025-05-01T14:36:06Z
body: @BradyAJohnston 

I've added a set of tests that acts as a linter to detect when the internal and external classes deviate from one another in their field composition or default values.  If these fields don't change very often I think this is a very reasonable approach and solves the programability problem today. 
author_association: CONTRIBUTOR
user: login: BradyAJohnston
created_at: 2025-05-02T01:24:10Z
body: The styles should remain static for patch releases, so they should only have to be updated every few months if at all. I think the test that you have setup should be sufficient. I'll have a more proper look over the PR in a few days when I've got some time, but I think this is basically the approach we want to take.
author_association: OWNER
user: login: zachcp
created_at: 2025-05-07T01:23:33Z
body: ( ping )
author_association: CONTRIBUTOR
user: login: BradyAJohnston
created_at: 2025-05-07T12:35:22Z
body: Sorry should have some time to look at them tomorrow.
author_association: OWNER
user: login: zachcp
created_at: 2025-05-17T13:48:55Z
body: - i've changed the names from `Port` to `Socket`
-  I am providing examples on how tab completion works. 
    - this method  allows you to complete on the function parameters. This is how you overload during creation as per your original design request.
    - because the return value is an instance of the MN nodeset from the MN file, you also get normal tab completion.
    

```python
import molecularnodes as mn
from molecularnodes.nodes.styles import StyleRibbon

# the function call parameters can be auto completed
StyleRibbon(backbone_radius=2)

# the return value is the same as a MN instance from the blend file
# so you can get all those completions as well
StyleRibbon().
```
![Screenshot 2025-05-17 at 9 43 14 AM](https://github.com/user-attachments/assets/21b6dc1f-f54f-49cd-ae23-1b702c9bcc61)
![Screenshot 2025-05-17 at 9 44 20 AM](https://github.com/user-attachments/assets/faa5f85c-ab20-47a2-b39c-345ce294af26)


author_association: CONTRIBUTOR
user: login: zachcp
created_at: 2025-05-17T14:11:47Z
body: Upon reflection I think the second image is slightly misleading.  The primary reason I needed to use dictionaries is to allow me to map between the python names which don't have spaces and the internal blender property names which do. The mapping and port data is not intended to be used / visible to end users. But I don't think the second completion style will update the values where the first one definitely will (e.g. on fn call we override the values in the MN base ). 

So I think this is the lightest touch implementation.  However,  would a  Data Class improve the behavior? You still woudl only be able to cleanly override/set the node properties at initialization. But maybe you could set the properties more easily before you do?  For a DataClass I would Imagine it would look like this:


```python

# the call chain for creating the style nodes

@dataclass
class StyleRibbon:
    backbone_radius: 1
    ...


# MN add_style
    def add_style(
        self,
        style: bpy.types.GeometryNodeTree | str = "spheres",
        color: str | None = "common",
        selection: "str | MoleculeSelector | None" = None,
        assembly: bool = False,
        material: bpy.types.Material | str | None = None,
    ):

# add style branch 
def add_style_branch(
    tree: bpy.types.GeometryNodeTree,
    style: str | bpy.types.GeometryNodeTree,
    color: str | None = None,
    selection: str | None = None,
    material: bpy.types.Material | str | None = None,
    frames: bpy.types.Collection | str | None = None,
) -> None:
```


So the implementation more or less depends on when you convert to the BPY Geometry Node. In the current PR, I am converting in the add_style call. Then we are passing a `GeometryNodeTree` to subsequent build calls. If you want to use DataClasses I think you have two options -> convert in the `add_style` call or a little further down in the add_style_branch call.  I think that could work. 



```python
ribbon_style =. StyleRibbon()
ribbon_style.backbone_radius = 3

mol = mn.fetch("1FAP").add_style(style= ribbon_style.init()).  // <-- realize here? not ergonomic

mol = mn.fetch("1FAP").add_style(style= ribbon_style) //   // <-- realize lower in the stack
```

If you like the last style I could: 

1. convert the classes to data classes.
2. allow both functions to accept style classes in their method call
3. handle the conversion to MN nodes deeper in the call stack.


author_association: CONTRIBUTOR
user: login: BradyAJohnston
created_at: 2025-05-19T06:54:56Z
body: I was meaning more using a dataclass when supplying socket mappings, rather than a vector of dictionaries: 

```py
    portdata: PortDataList = [
        {"name": "quality", "blendername": "Quality"},
        {"name": "dssp", "blendername": "Peptide DSSP"},
        {"name": "cylinders", "blendername": "Peptide Cylinders"},
```

```py
[
Socket(name="quality", blendername="Quality"
...
]
```
author_association: OWNER
user: login: BradyAJohnston
created_at: 2025-05-19T06:57:59Z
body: The current approach of delaying converting to an actual node group until the `add_style_branch()` is the way to go. Outside of working inside the particular node tree we are working in we can't customise it otherwise so keeping as a custom python class until last minute when we are actually adding the node is likely the best approach.
author_association: OWNER
user: login: zachcp
created_at: 2025-05-19T12:47:20Z
body: Ok. great . Now its clear whats needed. I've updated accordingly.
author_association: CONTRIBUTOR
user: login: zachcp
created_at: 2025-05-21T13:38:30Z
body: Wonderful!  Think you can issue a new release?
author_association: CONTRIBUTOR