Programmatically create Python components

@ShynnSup, are you talking about enabling the script input parameter of a script component via the shift-right-click menu, then feeding the source into that? It does work but has some limitations as you allude to.

ScriptParasite is also worth a look.

https://discourse.mcneel.com/t/update-scriptparasite-2-compatibility-with-rhino-8-script-editors/209669

Are you using the same standard components because I don’t see that option here.

@ShynnSup, hold shift when right clicking to reveal additional options.

I think I misread your comment about the C# Scrip components compared to Python Script components, but reading it again made me curious to do a comparison of what is possible with native GH functionality. No plugins.

Here is a demonstration of three approaches to configuring parameters (and other script component metadata like tooltips).

  1. Stock Python and C# RunScript signature functionality. Limited to parameter names and type hints. Manually copy source text from LLM, paste into script component.
  2. Extended functionality that builds automatic-configuration. “Overhead” code that sets parameter names, type hints, icons, component name and description. Also manually copy source text from LLM, paste into script component.
  3. Extended functionality, but leverage the script input parameter to synchronize source on disk to the script component in GH.

The demo files

script-metadata-demo.gh (32.7 KB)

ring-array-stock.py (2.1 KB)

ring-array-stock.cs (1.4 KB)

ring-array-auto-configured.py (8.6 KB)

ring-array-auto-configured.cs (11.4 KB)

Python and C# had different limitations. In particular, C# totally failed when linked via the script input parameter. But the extended functionality of the Python script seemed to function properly and update when parameter names were changed in the source file. I didn’t have time for more extensive testing.

So it may be conceivable to build that extended functionality overhead into every Python script so that it configures the parameters and such, and then link the script component to the file and have your LLM of choice edit the source there.

Script Forge is now open source. New versions will be released on GitHub for the time being.

It is part of the more generic rhino-gh-kit which is also a plugin for Claude. Point Claude to the GitHub URL for the project and it should be able to install it for you.

Two skills give Claude the ability to create scripts compatible with Script Forge’s headers: /write-python-script and /write-csharp-script. The kit also allows you to compile those scripts into plugins.

RhinoAI (McNeel’s MCP) is incorporated so Claude can interact with Script Forge on the GH canvas in conjunction with creating scripts. But an MCP is not required for writing the scripts themselves.

Hi Anthony,
thank you so much! Would love to help support more backward compatibility because the latest version seem to not work with slightly older Rhino build.

My best guess is that Script Forge is compatible back to Rhino 8 SR21 so the latest release (0.4.3-beta) allows installation to that Rhino version and newer. However, I only have a current version of Rhino 8 installed (SR34) and haven’t tested older builds.

Is the installation limitation the problem you ran into, @Jun_Wang? Or did you encounter another problem?

I tried it quickly today and was quite impressed by its use.

But honestly, I think that for some users the bottleneck might have been just moved a bit, not resolved, since the user’s workload now is into writing a proper header. I also noticed that extreme care has to be taken, no syntax errors are admitted, not even wrong case words (I used Tree instead of tree).

I’m using some scripts generated with the help of chatGPT and it already inserted clear comments like these:

AI generated comment section
# INPUTS:
#   points          : Tree
#   value           : Tree
#   boundary_fix    : Boolean / Item
#   wrap            : Boolean / Item
#   damping         : Number / Item
#   factor          : Number / Item
#   max_iterations  : Integer / Item
#
# OUTPUT:
#   value_out       : Tree
#
# value can be either:
#   - scalar numbers
#   - Rhino.Geometry.Vector3d
#
# Structure:
#   1) points and value have identical structure; OR
#   2) points has one branch and every value branch has the same
#      length as that branch. In case 2 points is duplicated
#      internally for every value branch.
#
# ================================================================

The rough header structure and content could be inferred from such comments, since types and references to branches could be enough to guess the right types and access levels.

It would be ideal in next improvements, for it to be able at least to guess and create the header from scratch, so the user can check and fill wrong or missing values. I also see missing the option for the “Name” (optional) allowed for script inputs, which allows to show a user-friendly name not bound to the variable name used in the script.

Thanks for trying out Script Forge and providing the feedback, @dwydd_nu.

Header generation

On generating the header: Have you taken a look at the write-python-script and write-csharp-script skills in rhino-gh-kit? They are built for Claude, but ChatGPT might be able to port them over. They are set up precisely so that the LLM can handle the header information and formatting and you don’t have to think about it. Alternatively, you could directly provide the whole grammar to ChatGPT from header-reference.md.

Script Forge itself is not an AI-enabled tool; it can’t infer anything like an LLM which is why the specific header syntax is required. But you should be able to push ChatGPT further to write a header that contains all the necessary information in the right syntax.

Case sensitivity and unknown keys

Thank you for catching the “Tree” vs “tree” issue. In the latest release every key and every value in the header is matched without regard to case to allow more flexibility. But unknown keys now raise a warning to flag typos rather than being silently ignored.

Input and output names

Regarding the user-friendly “Name (for humans, optional)," this already exists as the name key. But you may want to pair it with variableName.

  • variableName is the identifier your code receives, and it’s also what Grasshopper draws as the param on the script component. It has to be a valid identifier.
  • name is the friendly human name. It shows up in the tooltip that pops up when you hover the param.
  • If you leave variableName out, it falls back to name, and in that case name has to be a valid identifier, too. That could be the substitution that caused the confusion, and I agree it’s a little surprising.

One thing to be aware of: on a forged script component the drawn label is always the variable name. That is Grasshopper’s behavior for script params and isn’t something the Forge chooses. So the friendly name lives in the tooltip only. If you compile the component with the kit’s other tools you can also set the short drawn label with nickname, but this key will do nothing for a forged script component.

Here is an example script based on your AI generated comment section. I used Claude to write it. Try plugging it into the Script Forge to see how the inputs and outputs are created on the script component.

smooth-values.py
"""@component
{
  "name":        "Smooth Values",
  "nickname":    "Smooth",
  "description": "Iteratively smooths a per-point value field along each branch's ordered point sequence. Values may be numbers or vectors.",

  "inputs": [
    { "name": "Points", "variableName": "points", "nickname": "P",
      "type": "Point3d", "access": "tree",
      "description": "One branch per chain, points in order. Either one branch per value branch with matching structure, or a single branch reused for every value branch." },

    { "name": "Values", "variableName": "value", "nickname": "V",
      "type": "object", "access": "tree",
      "description": "The field to smooth, one value per point. Numbers or Vector3d; left unhinted so both pass through unchanged." },

    { "name": "Boundary Fix", "variableName": "boundary_fix", "nickname": "B",
      "type": "bool", "access": "item", "default": true,
      "description": "Pin the first and last value of an open chain so the ends never drift. Ignored when Wrap is on." },

    { "name": "Wrap", "variableName": "wrap", "nickname": "W",
      "type": "bool", "access": "item", "default": false,
      "description": "Treat each branch as a closed loop, so the last point neighbours the first." },

    { "name": "Damping", "variableName": "damping", "nickname": "D",
      "type": "double", "access": "item", "default": 0.5,
      "description": "How far each value moves toward its neighbours per pass, 0 to 1. 0 changes nothing." },

    { "name": "Factor", "variableName": "factor", "nickname": "F",
      "type": "double", "access": "item", "default": 1.0,
      "description": "Extra scale on the smoothing step, applied on top of Damping." },

    { "name": "Max Iterations", "variableName": "max_iterations", "nickname": "I",
      "type": "int", "access": "item", "default": 10,
      "description": "How many smoothing passes to run." }
  ],

  "outputs": [
    { "name": "Smoothed Values", "variableName": "value_out", "nickname": "V",
      "type": "object", "access": "tree",
      "description": "The smoothed field, on the same branch paths as Values." }
  ]
}
"""
import Rhino.Geometry as rg
from Grasshopper import DataTree
from Grasshopper.Kernel.Data import GH_Path

# --- defaults -----------------------------------------------------------------
# An unwired input arrives as None, so every read is guarded. The header's
# `default` seeds the param, but a user can still clear it by hand.
fix_ends = True if boundary_fix is None else boundary_fix
closed = False if wrap is None else wrap
damp = 0.5 if damping is None else damping
fac = 1.0 if factor is None else factor
passes = 10 if max_iterations is None else max_iterations

step = damp * fac


def neighbours(k, n):
    """Index pair either side of k, or None where the chain ends."""
    if closed:
        return (k - 1) % n, (k + 1) % n
    return (k - 1 if k > 0 else None, k + 1 if k < n - 1 else None)


def smooth_branch(pts, vals):
    """One chain: move each value toward its distance-weighted neighbour average."""
    n = len(vals)
    if n < 3 or step == 0.0:
        return list(vals)

    current = list(vals)
    for _ in range(max(passes, 0)):
        nxt = list(current)
        for k in range(n):
            a, b = neighbours(k, n)
            if a is None or b is None:
                # An open chain's endpoint: pin it, or let its one neighbour pull it.
                if fix_ends:
                    continue
                a = a if a is not None else b
                b = a

            # Inverse-distance weights, so unevenly spaced points smooth evenly.
            da = pts[k].DistanceTo(pts[a])
            db = pts[k].DistanceTo(pts[b])
            wa = 1.0 / da if da > 1e-12 else 0.0
            wb = 1.0 / db if db > 1e-12 else 0.0
            if wa + wb == 0.0:
                continue

            # `*` and `+` carry both a float and a Vector3d, which is the whole
            # reason Values is left unhinted.
            avg = (current[a] * wa + current[b] * wb) * (1.0 / (wa + wb))
            nxt[k] = current[k] + (avg - current[k]) * step
        current = nxt
    return current


# --- walk the branches by hand ------------------------------------------------
# Tree access plus an explicit loop is what keeps output paths equal to input
# paths. Implicit iteration would append an index and break the pairing.
out = DataTree[object]()

if points is not None and value is not None:
    broadcast = points.BranchCount == 1

    for i in range(value.BranchCount):
        path = value.Path(i)
        vals = list(value.Branch(i))

        j = 0 if broadcast else i
        pts = list(points.Branch(j)) if j < points.BranchCount else []

        if len(pts) != len(vals):
            # Structure mismatch: pass the branch through untouched rather than
            # emitting a shorter branch and silently shifting everything after it.
            out.AddRange(vals, path)
            continue

        out.AddRange(smooth_branch(pts, vals), path)

value_out = out

Thanks
it is curious, how your test AI generated header+script almost nailed what the original script actually did, which reconnects to the initial post by @Jun_Wang : some functions are apparently missing from any existing plugin out there, but they can be very easily created via AI code generation tools.

the installation is fine, yet it will trigger error upon opening grasshopper while loading the plugin.

What is the error? Can you share a log or screenshot? Are you able to use Grasshopper after the error and can you add a Script Forge component to the canvas or not?

Edit: You’re also welcome to troubleshoot and submit a pull request for a fix to the source code or a GitHub issue, @Jun_Wang.