Skewing/Shearing GP using GEO nodes. Bezier handles not editable

What I’m trying to do:

I’m prototyping a Geo Nodes setup to apply shear (skew) to Grease Pencil (GP) data using a shared node stack.

Each selected object is assigned a stack that has these nodes inside of it:

This conversion from GP to curves back to GP is required because Blender does not seem to allow you to set positions of handles with GP layer data.

The current setup works for transforming control points and overall hierarchy, but there’s a key limitation:

There are sometimes missing pieces and fills for thi example, the brown part of the collar is missing from the jacket.

When using GP with Bézier curves, the deformation only affects control points, not the handles. This causes visible artifacts (e.g. wavy lines), since the curve tangents are not being updated.

However, that approach introduces problems:

  • Loss of fills on some layers
  • Added complexity and performance cost from conversion

Question

How can I deform (shear/skew) Bézier handles of Grease Pencil data directly in Geo Nodes, without converting to curves and back? How can I robustly keep the GP data consistent while shearing it?

I’m including a blend file with the character and the shear effect in it.
EinsteinSheer.blend (3.8 MB)

If you open up the blend file and unhide the ‘BrownCollar’ layer you’ll see it kills the fill on the face layer. I’m trying to avoid this issue occurring.
Then if you disabled the modifier on the head object, you’ll see that it appears as it should.

I have a script that sets all this up if you want to test it. This limitation is the wall in the project.

Test script

It will take a selected grease pencil object, and put a skewing modifier on it with the help of geometry nodes, one must Copy and paste the below script into blender’s script editor, select all children of an object, and have the driving object be the actively selected one (last selected/warm orange), then hit script window play button.

import bpy

MAIN_GROUP_NAME = "GN_Shear_XZ_ParentSample_GP_Rot_HandleTypeFix"
HELPER_GROUP_NAME = "GN_ParentFloatSample_GP_HandleTypeFix"
HANDLE_GROUP_NAME = "GN_ShearHandleOffset_GP_HandleTypeFix"
MOD_NAME = "Shear XZ Parent Sample GP Rot"

ATTR_SHEAR_X = "shear_parent_x"
ATTR_SHEAR_Z = "shear_parent_z"


# ------------------------------------------------------------
# basic helpers
# ------------------------------------------------------------

def delete_existing_group(name):
    ng = bpy.data.node_groups.get(name)
    if ng:
        bpy.data.node_groups.remove(ng, do_unlink=True)


def new_node(nodes, ntype, name, x, y):
    node = nodes.new(ntype)
    node.name = name
    node.label = name
    node.location = (x, y)
    return node


def new_node_any(nodes, type_names, name, x, y):
    last_err = None
    for t in type_names:
        try:
            n = nodes.new(t)
            n.name = name
            n.label = name
            n.location = (x, y)
            return n
        except Exception as ex:
            last_err = ex
    raise RuntimeError(f"Could not create node '{name}'. Tried {type_names}. Last error: {last_err}")


def link(links, out_node, out_socket, in_node, in_socket):
    links.new(out_node.outputs[out_socket], in_node.inputs[in_socket])


def iface_socket(iface, name, in_out, socket_type, description=""):
    return iface.new_socket(
        name=name,
        description=description,
        in_out=in_out,
        socket_type=socket_type,
    )


def add_frame(nodes, label, x, y, w=600, h=400):
    frame = nodes.new("NodeFrame")
    frame.label = label
    frame.location = (x, y)
    frame.width = w
    frame.height = h
    return frame


def parent_frame(frame, *nodes_):
    for n in nodes_:
        n.parent = frame


def reroute(nodes, x, y, frame=None):
    rr = nodes.new("NodeReroute")
    rr.location = (x, y)
    if frame:
        rr.parent = frame
    return rr


# ------------------------------------------------------------
# helper group for parent float sampling
# ------------------------------------------------------------

def ensure_helper_group(force_rebuild=True):
    if force_rebuild:
        delete_existing_group(HELPER_GROUP_NAME)

    ng = bpy.data.node_groups.get(HELPER_GROUP_NAME)
    if ng and ng.bl_idname == "GeometryNodeTree":
        return ng

    ng = bpy.data.node_groups.new(HELPER_GROUP_NAME, "GeometryNodeTree")
    ng.is_modifier = False

    nodes = ng.nodes
    links = ng.links
    nodes.clear()

    iface = ng.interface
    iface_socket(iface, "Geometry", 'INPUT', "NodeSocketGeometry", "Geometry source to sample from.")
    iface_socket(iface, "Use Parent", 'INPUT', "NodeSocketBool", "When enabled, sample the named float attribute from the parent geometry. Otherwise output zero.")
    s = iface_socket(iface, "Attr Name", 'INPUT', "NodeSocketString", "Name of the float attribute to sample.")
    s.default_value = ""
    iface_socket(iface, "Value", 'OUTPUT', "NodeSocketFloat", "Sampled float value.")

    ng.interface_update(bpy.context)

    gi = new_node(nodes, "NodeGroupInput", "Group Input", -800, 0)
    go = new_node(nodes, "NodeGroupOutput", "Group Output", 140, 0)

    named_attr = new_node(nodes, "GeometryNodeInputNamedAttribute", "Named Attr", -600, -80)
    named_attr.data_type = 'FLOAT'

    sample = new_node(nodes, "GeometryNodeSampleIndex", "Sample Index", -360, -80)
    sample.data_type = 'FLOAT'
    sample.domain = 'LAYER'
    sample.clamp = True
    sample.inputs["Index"].default_value = 0

    zero = new_node(nodes, "ShaderNodeValue", "Zero", -360, -240)
    zero.outputs[0].default_value = 0.0

    use_switch = new_node(nodes, "GeometryNodeSwitch", "Use Parent", -120, -80)
    use_switch.input_type = 'FLOAT'

    link(links, gi, "Attr Name", named_attr, "Name")
    link(links, gi, "Geometry", sample, "Geometry")
    link(links, named_attr, "Attribute", sample, "Value")

    link(links, gi, "Use Parent", use_switch, "Switch")
    link(links, zero, "Value", use_switch, "False")
    link(links, sample, "Value", use_switch, "True")

    link(links, use_switch, "Output", go, "Value")
    return ng


# ------------------------------------------------------------
# helper group for relative handle offset
# outputs:
#   Offset = extra handle deformation
#   Affect = non-zero flag so zero-shear does not touch handles
# ------------------------------------------------------------

def ensure_handle_group(force_rebuild=True):
    if force_rebuild:
        delete_existing_group(HANDLE_GROUP_NAME)

    ng = bpy.data.node_groups.get(HANDLE_GROUP_NAME)
    if ng and ng.bl_idname == "GeometryNodeTree":
        return ng

    ng = bpy.data.node_groups.new(HANDLE_GROUP_NAME, "GeometryNodeTree")
    ng.is_modifier = False

    nodes = ng.nodes
    links = ng.links
    nodes.clear()

    iface = ng.interface
    iface_socket(iface, "Relative Handle", 'INPUT', "NodeSocketVector", "Handle position relative to control point.")
    iface_socket(iface, "Use Parent", 'INPUT', "NodeSocketBool", "Use parent-aligned space.")
    iface_socket(iface, "Inverse Parent Rotation", 'INPUT', "NodeSocketVector", "Negative parent Euler rotation.")
    iface_socket(iface, "Parent Rotation", 'INPUT', "NodeSocketVector", "Parent Euler rotation.")
    iface_socket(iface, "Shear X", 'INPUT', "NodeSocketFloat", "X shear.")
    iface_socket(iface, "Shear Z", 'INPUT', "NodeSocketFloat", "Z shear.")
    iface_socket(iface, "Offset", 'OUTPUT', "NodeSocketVector", "Additional handle offset.")
    iface_socket(iface, "Affect", 'OUTPUT', "NodeSocketBool", "Whether this handle should be modified.")

    ng.interface_update(bpy.context)

    gi = new_node(nodes, "NodeGroupInput", "Group Input", -1320, 0)
    go = new_node(nodes, "NodeGroupOutput", "Group Output", 760, 0)

    to_parent = new_node(nodes, "ShaderNodeVectorRotate", "To Parent Space", -1040, 0)
    to_parent.rotation_type = 'EULER_XYZ'
    to_parent.invert = False

    use_switch = new_node(nodes, "GeometryNodeSwitch", "Use Parent Space", -800, 0)
    use_switch.input_type = 'VECTOR'

    sep = new_node(nodes, "ShaderNodeSeparateXYZ", "Separate", -560, 0)

    mul_x = new_node(nodes, "ShaderNodeMath", "Z * Shear X", -320, 120)
    mul_x.operation = 'MULTIPLY'

    mul_z = new_node(nodes, "ShaderNodeMath", "X * Shear Z", -320, -80)
    mul_z.operation = 'MULTIPLY'

    raw_offset = new_node(nodes, "ShaderNodeCombineXYZ", "Raw Offset", -60, 20)
    raw_offset.inputs["Y"].default_value = 0.0

    back = new_node(nodes, "ShaderNodeVectorRotate", "Back To Local", 180, 20)
    back.rotation_type = 'EULER_XYZ'
    back.invert = False

    off_switch = new_node(nodes, "GeometryNodeSwitch", "Use Rotated Offset", 420, 20)
    off_switch.input_type = 'VECTOR'

    length = new_node(nodes, "ShaderNodeVectorMath", "Offset Length", 420, -180)
    length.operation = 'LENGTH'

    gt = new_node(nodes, "ShaderNodeMath", "Affect Compare", 620, -180)
    gt.operation = 'GREATER_THAN'
    gt.inputs[1].default_value = 1.0e-8

    link(links, gi, "Relative Handle", to_parent, "Vector")
    link(links, gi, "Inverse Parent Rotation", to_parent, "Rotation")

    link(links, gi, "Use Parent", use_switch, "Switch")
    link(links, gi, "Relative Handle", use_switch, "False")
    link(links, to_parent, "Vector", use_switch, "True")

    link(links, use_switch, "Output", sep, "Vector")

    link(links, sep, "Z", mul_x, 0)
    link(links, gi, "Shear X", mul_x, 1)

    link(links, sep, "X", mul_z, 0)
    link(links, gi, "Shear Z", mul_z, 1)

    link(links, mul_x, "Value", raw_offset, "X")
    link(links, mul_z, "Value", raw_offset, "Z")

    link(links, raw_offset, "Vector", back, "Vector")
    link(links, gi, "Parent Rotation", back, "Rotation")

    link(links, gi, "Use Parent", off_switch, "Switch")
    link(links, raw_offset, "Vector", off_switch, "False")
    link(links, back, "Vector", off_switch, "True")

    link(links, off_switch, "Output", length, 0)
    link(links, length, "Value", gt, 0)

    link(links, off_switch, "Output", go, "Offset")
    link(links, gt, "Value", go, "Affect")
    return ng


# ------------------------------------------------------------
# main group
# ------------------------------------------------------------

def ensure_main_group(force_rebuild=True):
    if force_rebuild:
        delete_existing_group(MAIN_GROUP_NAME)

    helper = ensure_helper_group(force_rebuild=force_rebuild)
    handle_helper = ensure_handle_group(force_rebuild=force_rebuild)

    ng = bpy.data.node_groups.get(MAIN_GROUP_NAME)
    if ng and ng.bl_idname == "GeometryNodeTree":
        return ng

    ng = bpy.data.node_groups.new(MAIN_GROUP_NAME, "GeometryNodeTree")
    ng.is_modifier = True

    nodes = ng.nodes
    links = ng.links
    nodes.clear()

    iface = ng.interface

    iface_socket(iface, "Geometry", 'INPUT', "NodeSocketGeometry", "Geometry to deform.")

    s = iface_socket(iface, "Shear X", 'INPUT', "NodeSocketFloat", "X shear amount.")
    s.default_value = 0.0

    s = iface_socket(iface, "Shear Z", 'INPUT', "NodeSocketFloat", "Z shear amount.")
    s.default_value = 0.0

    s = iface_socket(
        iface,
        "Use Center Parent",
        'INPUT',
        "NodeSocketBool",
        "Use another object's origin and orientation as the shared shear reference."
    )
    s.default_value = False

    s = iface_socket(
        iface,
        "Center Parent",
        'INPUT',
        "NodeSocketObject",
        "Controller object used for pivot, orientation, and inherited shear attributes."
    )
    s.default_value = None

    iface_socket(iface, "Geometry", 'OUTPUT', "NodeSocketGeometry", "Deformed geometry.")
    ng.interface_update(bpy.context)

    gi = new_node(nodes, "NodeGroupInput", "Group Input", -3700, 0)
    go = new_node(nodes, "NodeGroupOutput", "Group Output", 3080, 0)

    fr_inputs = add_frame(nodes, "Inputs / Parent Ref", -3500, -260, 980, 560)
    fr_parent = add_frame(nodes, "Parent Values", -3500, -1120, 980, 420)
    fr_effective = add_frame(nodes, "Effective Values", -2400, -1120, 520, 340)
    fr_space = add_frame(nodes, "Parent Space", -2400, -260, 860, 660)
    fr_shear = add_frame(nodes, "Apply Shear", -1380, -220, 820, 560)
    fr_handles = add_frame(nodes, "Handle Offset Fix", -260, -980, 2460, 1220)
    fr_store = add_frame(nodes, "Export Attrs", 2280, -140, 1040, 380)

    # --------------------------------------------------------
    # point shear path
    # --------------------------------------------------------

    pos = new_node(nodes, "GeometryNodeInputPosition", "Position", -3300, 120)

    rr_center_parent = reroute(nodes, -3460, -60, fr_inputs)
    rr_use_parent = reroute(nodes, -3460, -160, fr_inputs)

    obj_info = new_node(nodes, "GeometryNodeObjectInfo", "Center Parent Info", -3180, -120)
    try:
        obj_info.transform_space = 'RELATIVE'
    except Exception:
        pass

    zero_vec = new_node(nodes, "ShaderNodeCombineXYZ", "Zero Pivot", -2910, -20)
    zero_vec.inputs["X"].default_value = 0.0
    zero_vec.inputs["Y"].default_value = 0.0
    zero_vec.inputs["Z"].default_value = 0.0

    pivot_switch = new_node(nodes, "GeometryNodeSwitch", "Use Parent Pivot", -2640, -40)
    pivot_switch.input_type = 'VECTOR'

    rel_pos = new_node(nodes, "ShaderNodeVectorMath", "Relative Position", -2370, 40)
    rel_pos.operation = 'SUBTRACT'

    parent_frame(fr_inputs, pos, rr_center_parent, rr_use_parent, obj_info, zero_vec, pivot_switch, rel_pos)

    rr_parent_geo = reroute(nodes, -3370, -760, fr_parent)
    rr_parent_use = reroute(nodes, -3370, -660, fr_parent)

    sample_parent_x = new_node(nodes, "GeometryNodeGroup", "Parent Sample X", -3140, -760)
    sample_parent_x.node_tree = helper
    sample_parent_x.inputs["Attr Name"].default_value = ATTR_SHEAR_X

    sample_parent_z = new_node(nodes, "GeometryNodeGroup", "Parent Sample Z", -3140, -980)
    sample_parent_z.node_tree = helper
    sample_parent_z.inputs["Attr Name"].default_value = ATTR_SHEAR_Z

    parent_frame(fr_parent, rr_parent_geo, rr_parent_use, sample_parent_x, sample_parent_z)

    shear_x_switch = new_node(nodes, "GeometryNodeSwitch", "Shear X Source", -2180, -760)
    shear_x_switch.input_type = 'FLOAT'

    shear_z_switch = new_node(nodes, "GeometryNodeSwitch", "Shear Z Source", -2180, -980)
    shear_z_switch.input_type = 'FLOAT'

    parent_frame(fr_effective, shear_x_switch, shear_z_switch)

    rr_parent_rot = reroute(nodes, -2320, -120, fr_space)

    inv_rot = new_node(nodes, "ShaderNodeVectorMath", "Inverse Parent Rotation", -2140, -40)
    inv_rot.operation = 'MULTIPLY'
    inv_rot.inputs[1].default_value = (-1.0, -1.0, -1.0)

    to_parent_space = new_node(nodes, "ShaderNodeVectorRotate", "To Parent Space", -1860, 0)
    to_parent_space.rotation_type = 'EULER_XYZ'
    to_parent_space.invert = False

    rel_switch = new_node(nodes, "GeometryNodeSwitch", "Use Aligned Relative Pos", -1580, 20)
    rel_switch.input_type = 'VECTOR'

    sep_shear_space = new_node(nodes, "ShaderNodeSeparateXYZ", "Separate Shear Space Pos", -1300, 20)

    parent_frame(fr_space, rr_parent_rot, inv_rot, to_parent_space, rel_switch, sep_shear_space)

    mul_x = new_node(nodes, "ShaderNodeMath", "Z * Shear X", -1080, 140)
    mul_x.operation = 'MULTIPLY'

    mul_z = new_node(nodes, "ShaderNodeMath", "X * Shear Z", -1080, -40)
    mul_z.operation = 'MULTIPLY'

    raw_offset = new_node(nodes, "ShaderNodeCombineXYZ", "Raw Offset XZ", -800, 40)
    raw_offset.inputs["Y"].default_value = 0.0

    back_to_local = new_node(nodes, "ShaderNodeVectorRotate", "Back To Local", -520, 40)
    back_to_local.rotation_type = 'EULER_XYZ'
    back_to_local.invert = False

    offset_switch = new_node(nodes, "GeometryNodeSwitch", "Use Rotated Offset", -240, 40)
    offset_switch.input_type = 'VECTOR'

    set_pos = new_node(nodes, "GeometryNodeSetPosition", "Set Position", 40, 40)

    parent_frame(fr_shear, mul_x, mul_z, raw_offset, back_to_local, offset_switch, set_pos)

    # --------------------------------------------------------
    # handle offset path
    # --------------------------------------------------------

    gp_to_curves = new_node(nodes, "GeometryNodeGreasePencilToCurves", "Grease Pencil to Curves", 320, 40)
    realize = new_node(nodes, "GeometryNodeRealizeInstances", "Realize Instances", 580, 40)

    handle_read = new_node_any(
        nodes,
        [
            "GeometryNodeInputCurveHandlePositions",
            "GeometryNodeCurveHandlePositions",
            "GeometryNodeInputCurveHandlePosition",
            "GeometryNodeCurveHandlePosition",
        ],
        "Curve Handle Positions",
        860,
        -180
    )
    
    try:
        handle_read.inputs["Relative"].default_value = True
    except Exception:
        pass

    left_handle_group = new_node(nodes, "GeometryNodeGroup", "Left Handle Offset", 1220, -420)
    left_handle_group.node_tree = handle_helper

    right_handle_group = new_node(nodes, "GeometryNodeGroup", "Right Handle Offset", 1220, -20)
    right_handle_group.node_tree = handle_helper

    affect_or = new_node(nodes, "FunctionNodeBooleanMath", "Handle Affect OR", 1500, -560)
    affect_or.operation = 'OR'
    
    set_handle_type = new_node_any(
        nodes,
        ["GeometryNodeCurveSetHandles"],
        "Set Handle Type Free",
        1500,
        -260
    )
    
    try:
        set_handle_type.mode = 'BOTH'
    except Exception:
        pass
    try:
        set_handle_type.handle_type = 'FREE'
    except Exception:
        pass

    set_left = new_node_any(
        nodes,
        ["GeometryNodeSetCurveHandlePositions", "GeometryNodeSetHandlePositions"],
        "Set Left Handle",
        1780,
        -320
    )
    
    try:
        set_left.mode = 'LEFT'
    except Exception:
        pass

    set_right = new_node_any(
        nodes,
        ["GeometryNodeSetCurveHandlePositions", "GeometryNodeSetHandlePositions"],
        "Set Right Handle",
        2040,
        -320
    )
    
    try:
        set_right.mode = 'RIGHT'
    except Exception:
        pass

    curves_to_gp = new_node_any(
        nodes,
        ["GeometryNodeCurvesToGreasePencil"],
        "Curves to Grease Pencil",
        2300,
        -320
    )
    try:
        curves_to_gp.inputs["Instances as Layers"].default_value = False
    except Exception:
        pass

    parent_frame(
        fr_handles,
        gp_to_curves, realize, handle_read,
        left_handle_group, right_handle_group,
        affect_or, set_handle_type,
        set_left, set_right, curves_to_gp
    )

    # --------------------------------------------------------
    # export attrs
    # --------------------------------------------------------

    rr_use_parent_store = reroute(nodes, 2260, -60, fr_store)

    store_x = new_node(nodes, "GeometryNodeStoreNamedAttribute", "Store Shear X Attr", 2640, 120)
    store_x.data_type = 'FLOAT'
    store_x.domain = 'LAYER'
    store_x.inputs["Name"].default_value = ATTR_SHEAR_X

    store_z = new_node(nodes, "GeometryNodeStoreNamedAttribute", "Store Shear Z Attr", 2940, 120)
    store_z.data_type = 'FLOAT'
    store_z.domain = 'LAYER'
    store_z.inputs["Name"].default_value = ATTR_SHEAR_Z

    export_switch = new_node(nodes, "GeometryNodeSwitch", "Export Or Bypass", 3240, 40)
    export_switch.input_type = 'GEOMETRY'

    parent_frame(fr_store, rr_use_parent_store, store_x, store_z, export_switch)

    # --------------------------------------------------------
    # wiring
    # --------------------------------------------------------

    link(links, gi, "Center Parent", rr_center_parent, 0)
    link(links, gi, "Use Center Parent", rr_use_parent, 0)

    link(links, rr_center_parent, 0, obj_info, "Object")

    link(links, rr_use_parent, 0, pivot_switch, "Switch")
    link(links, zero_vec, "Vector", pivot_switch, "False")
    link(links, obj_info, "Location", pivot_switch, "True")

    link(links, pos, "Position", rel_pos, 0)
    link(links, pivot_switch, "Output", rel_pos, 1)

    link(links, obj_info, "Geometry", rr_parent_geo, 0)
    link(links, rr_use_parent, 0, rr_parent_use, 0)

    link(links, rr_parent_geo, 0, sample_parent_x, "Geometry")
    link(links, rr_parent_use, 0, sample_parent_x, "Use Parent")

    link(links, rr_parent_geo, 0, sample_parent_z, "Geometry")
    link(links, rr_parent_use, 0, sample_parent_z, "Use Parent")

    link(links, rr_use_parent, 0, shear_x_switch, "Switch")
    link(links, gi, "Shear X", shear_x_switch, "False")
    link(links, sample_parent_x, "Value", shear_x_switch, "True")

    link(links, rr_use_parent, 0, shear_z_switch, "Switch")
    link(links, gi, "Shear Z", shear_z_switch, "False")
    link(links, sample_parent_z, "Value", shear_z_switch, "True")

    link(links, obj_info, "Rotation", rr_parent_rot, 0)
    link(links, rr_parent_rot, 0, inv_rot, 0)
    link(links, rr_parent_rot, 0, back_to_local, "Rotation")

    link(links, rel_pos, "Vector", to_parent_space, "Vector")
    link(links, inv_rot, "Vector", to_parent_space, "Rotation")

    link(links, rr_use_parent, 0, rel_switch, "Switch")
    link(links, rel_pos, "Vector", rel_switch, "False")
    link(links, to_parent_space, "Vector", rel_switch, "True")

    link(links, rel_switch, "Output", sep_shear_space, "Vector")

    link(links, sep_shear_space, "Z", mul_x, 0)
    link(links, shear_x_switch, "Output", mul_x, 1)

    link(links, sep_shear_space, "X", mul_z, 0)
    link(links, shear_z_switch, "Output", mul_z, 1)

    link(links, mul_x, "Value", raw_offset, "X")
    link(links, mul_z, "Value", raw_offset, "Z")

    link(links, raw_offset, "Vector", back_to_local, "Vector")

    link(links, rr_use_parent, 0, offset_switch, "Switch")
    link(links, raw_offset, "Vector", offset_switch, "False")
    link(links, back_to_local, "Vector", offset_switch, "True")

    link(links, gi, "Geometry", set_pos, "Geometry")
    set_pos.inputs["Selection"].default_value = True
    link(links, offset_switch, "Output", set_pos, "Offset")

    # GP -> curves
    link(links, set_pos, "Geometry", gp_to_curves, "Grease Pencil")
    link(links, gp_to_curves, "Curves", realize, "Geometry")

    # Common inputs to both handle helpers
    for gnode in (left_handle_group, right_handle_group):
        link(links, rr_use_parent, 0, gnode, "Use Parent")
        link(links, inv_rot, "Vector", gnode, "Inverse Parent Rotation")
        link(links, rr_parent_rot, 0, gnode, "Parent Rotation")
        link(links, shear_x_switch, "Output", gnode, "Shear X")
        link(links, shear_z_switch, "Output", gnode, "Shear Z")

    link(links, handle_read, "Left", left_handle_group, "Relative Handle")
    link(links, handle_read, "Right", right_handle_group, "Relative Handle")

    # Combine affect masks
    link(links, left_handle_group, "Affect", affect_or, 0)
    link(links, right_handle_group, "Affect", affect_or, 1)

    # Force affected handles to FREE first
    link(links, realize, "Geometry", set_handle_type, "Curve")
    link(links, affect_or, "Boolean", set_handle_type, "Selection")

    # Write handle offsets
    link(links, set_handle_type, "Curve", set_left, "Curve")
    link(links, left_handle_group, "Affect", set_left, "Selection")
    link(links, left_handle_group, "Offset", set_left, "Offset")

    link(links, set_left, "Curve", set_right, "Curve")
    link(links, right_handle_group, "Affect", set_right, "Selection")
    link(links, right_handle_group, "Offset", set_right, "Offset")

    link(links, set_right, "Curve", curves_to_gp, "Curves")

    # Export attrs on final GP result
    link(links, rr_use_parent, 0, rr_use_parent_store, 0)

    link(links, curves_to_gp, "Grease Pencil", store_x, "Geometry")
    store_x.inputs["Selection"].default_value = True
    link(links, shear_x_switch, "Output", store_x, "Value")

    link(links, store_x, "Geometry", store_z, "Geometry")
    store_z.inputs["Selection"].default_value = True
    link(links, shear_z_switch, "Output", store_z, "Value")

    link(links, rr_use_parent_store, 0, export_switch, "Switch")
    link(links, store_z, "Geometry", export_switch, "False")
    link(links, curves_to_gp, "Grease Pencil", export_switch, "True")

    link(links, export_switch, "Output", go, "Geometry")

    return ng


# ------------------------------------------------------------
# modifier utilities
# ------------------------------------------------------------

def interface_input_identifiers(node_group):
    ids = {}
    for item in node_group.interface.items_tree:
        if hasattr(item, "in_out") and hasattr(item, "identifier"):
            if item.in_out == 'INPUT':
                ids[item.name] = item.identifier
    return ids


def ensure_modifier(obj, node_group):
    for m in obj.modifiers:
        if m.type == 'NODES' and m.node_group == node_group:
            m.name = MOD_NAME
            return m
    mod = obj.modifiers.new(MOD_NAME, 'NODES')
    mod.node_group = node_group
    return mod


def set_mod_input(mod, id_map, name, value):
    ident = id_map.get(name)
    if ident is None:
        raise KeyError(f"Input '{name}' not found. Available: {list(id_map.keys())}")
    mod[ident] = value


def apply_to_selection():
    ctx = bpy.context
    selected = list(ctx.selected_objects)
    active = ctx.active_object

    if not selected:
        raise RuntimeError("No objects selected.")
    if active is None:
        raise RuntimeError("No active object. Select the controller last so it becomes active.")

    supported = {'GREASEPENCIL'}
    if active.type not in supported:
        raise RuntimeError(f"Active object {active.name} is unsupported type: {active.type}")

    ng = ensure_main_group(force_rebuild=True)
    id_map = interface_input_identifiers(ng)
    print("INPUT IDS:", id_map)

    required = ["Shear X", "Shear Z", "Use Center Parent", "Center Parent"]
    for name in required:
        if name not in id_map:
            raise RuntimeError(f"Missing group input: {name}")

    updated = []
    skipped = []

    for obj in selected:
        if obj.type not in supported:
            skipped.append(f"{obj.name} ({obj.type})")
            continue

        try:
            mod = ensure_modifier(obj, ng)
            if obj == active:
                set_mod_input(mod, id_map, "Use Center Parent", False)
                set_mod_input(mod, id_map, "Center Parent", None)
                updated.append(f"{obj.name} [controller]")
            else:
                set_mod_input(mod, id_map, "Use Center Parent", True)
                set_mod_input(mod, id_map, "Center Parent", active)
                updated.append(f"{obj.name} [child -> {active.name}]")
        except Exception as ex:
            skipped.append(f"{obj.name} ({obj.type}) | {ex}")

    print("\n=== Applied ===")
    print("Controller:", active.name)
    print("Updated:", updated if updated else "None")
    print("Skipped:", skipped if skipped else "None")


apply_to_selection()

For more information, I’m journaling all of this here:

-S

It looks like nobody has a work around or solution to this so it would seem I’ve hit a limitation of Blender’s GEO nodes and GP layers.

Anyone who’s interested in this function, please vote on my Right Click Select post here so we can raise awareness of this requriement.

-S

Falk David has a work around for the gp to curve to gp solution; Realise instances should not be until after the conversion. More info here:

https://projects.blender.org/blender/blender/issues/156332#issuecomment-1885842

-S