⏳ This skill is pending AI review.

Scores will appear once the review pipeline completes.

version unknown

procedural-blender-nodes

@gokayfem⭐ 59 stars

>-

Choose how to use this skill

You do not need every option. Choose the path your AI client supports. The stable page stays the same; versioned files are immutable.

1. Native installer

This listing has no registered native installer command. Use the complete package or source fallback below, depending on what your client supports.

Do not guess an installer command or replace an existing version without reviewing the diff.

2. Complete package recommended

Download the ZIP when available. It includes SKILL.md plus the references, security notes and version metadata.

No complete ProSkills package is published for this listing yet.

3. Prompt-only

Copy the prompt above when the agent can read the stable page or when you want to adopt the workflow without installing a skill.

Need only the instruction file?

Download SKILL.md only if your client requires a single file. The complete ZIP is safer for a full installation because it preserves the references and release context.

No path installs or executes anything by itself. Your agent still needs access to the project files. Before updating, compare the installed version and review the diff.

—/10

// RATINGS

⭐GitHub Stars
⭐⭐ 59 on GitHubGitHub ↗

Growing

🟢ProSkills Score
—
📍

Not yet listed on ClawHub or SkillsMP

// README

Video to Blender

Turn a reference video, image or idea into an editable procedural Blender node system. A skill for coding agents (Claude Code, Codex, Cursor, Copilot, Gemini) that builds native Geometry Nodes, Shader Nodes and compositor graphs from Python, with a tested node-group library and validation tools.

The top-level graph: one modifier builds the whole streetOne part opened: the houses, framed block by block
Storybook Street top-level graphHouses graph

Storybook Street (prompts/storybook-street/) rebuilds a 20 s AI-generated village clip as one Geometry Nodes modifier on a path curve: the cobbled road, half-timbered houses, props, bunting moving in the wind, and a walker with planted feet. A camera follows the walker on any path. Every visible part is a node graph: geometry and motion in Geometry Nodes, materials in Shader Nodes, the sky in the world shader, haze and glow in the compositor. Python only builds those graphs and sets up the input curve, sun, camera rig and render settings. Draw a different curve and the same graph builds a different street.

The skill pairs a workflow with nodekit, a Python graph builder and inspector, and a tested node-group library: 39 NK groups in 6 modules, each built, evaluated and numerically checked in Blender. Blender 5.2 or later is required.

Why

Agents writing Blender node scripts work blind: they cannot see the graph or the render, Blender's Python API changed in 5.x, and a script that runs can still leave a broken or unreadable graph. Common results:

  • modifier inputs set as ID properties, which raises in 5.2;
  • a shared node group rebuilt in place, silently unlinking every user;
  • sockets looked up by index, which breaks when a node's mode changes;
  • a single 300-node group with no frames or descriptions;
  • motion baked as keyframes where a function of time would do;
  • a "procedural" asset that only works on the one input it was built for.

This skill closes those gaps:

  • nodekit builds graphs from Python the way you would write them by hand. Sockets are found by name, panels and ranges are declared, blocks are framed with a sentence, and the layout is automatic. It reads the result back as JSON, lints it, and sets modifier inputs the 5.2 way.
  • Tested building blocks you instance instead of re-deriving. Each is checked in Blender with numbers, for example:
    • the curve frame is orthonormal on hills;
    • a bend preserves centreline length;
    • parcel widths sum to the curve length;
    • scatter counts follow density;
    • a planted foot never slides;
    • spring gain matches the analytic formula;
    • premultiplied haze has no halos.
  • A checker and a validator. nodes_tools.py check builds and evaluates every group with zero warnings and zero lint. validate.py reopens a saved .blend and checks warnings, evaluation time and camera clearance.
  • A workflow with a definition of done: artist controls (a Primary panel of macros), presets, sweeps, reuse on other inputs, temporal checks for animation, and honest reporting.
  • Reference-driven reconstruction as one optional prompt (prompts/reference-to-nodes-prompt.md), not a requirement.

Install

Claude Code (plugin marketplace):

claude plugin marketplace add gokayfem/video-to-blender
claude plugin install video-to-blender@video-to-blender

Or copy the skill: cp -R skills/procedural-blender-nodes ~/.claude/skills/.

Codex, Cursor, Gemini CLI, Copilot and other agents (via skills):

npx skills add gokayfem/video-to-blender

Or install by hand:

  • Codex: copy skills/procedural-blender-nodes to ~/.agents/skills/.
  • Cursor: copy it to ~/.cursor/skills/, plus the rule in adapters/cursor/.
  • Copilot: gh skill install gokayfem/video-to-blender procedural-blender-nodes, plus adapters/copilot/.
  • Gemini CLI: gemini extensions install https://github.com/gokayfem/video-to-blender.

claude.ai: zip skills/procedural-blender-nodes and upload it under Settings > Capabilities > Skills.

ChatGPT, Gemini Gems and other chat tools: see adapters/.

You also need Blender 5.2 or later, as blender on PATH, $BLENDER, or the macOS app.

Use

Ask naturally, for example:

  • "Make a Geometry Nodes fence that follows any curve, with controls for post spacing and lean."
  • "Ivy that grows over whatever mesh I give it."
  • "Recreate this clip as an editable Blender setup."
  • "Port my 4.2 node script to Blender 5.2."

The agent routes the request, finds groups, writes a build script that saves a .blend, and checks it:

python3 scripts/nodes_tools.py search curve fence
python3 scripts/nodes_tools.py try -- --module my_effect --root . --png out/try.png
blender -b my_effect.blend --python scripts/validate.py

What the commands do:

  • search ranks library groups by name, summary, tags and uses.
  • try builds one group on a test input, prints its node warnings and lint, and renders a still.
  • validate reopens the saved file and checks warnings, counts, evaluation time and camera clearance.

What's in the library

ModuleGroups
curveNK Curve Frame (s, d, h to a level frame on any curve), NK Curve Bend (bend curve-space geometry onto a path), NK Curve Samples, NK Curve Parcels (random widths that sum exactly, with fan data for bends), NK Curve Ribbon, NK Catenary Point (sagging ropes and cables)
shapesNK Rounded Box, NK Chamfer Box / NK Chamfer Boxes (chamfer size independent of scale), NK Rod, NK Ring, NK Profile Revolve (pots, barrels, vases), NK Leaf, NK Blossom, NK Foliage Mound, NK Grass Tuft, NK Facade Frame (one placement frame per wall face), NK Store Local Coords (per-piece texture space)
scatterNK Scatter On Surface (density, Poisson distance, slope/attribute masks, alignment), NK Scatter Along Curve, NK Clumps
motionNK Damped Spring, NK Gait Step (integrated phase, planted feet), NK Wind Gust, NK Pendulum Sway, NK Time Remap (loop, ping-pong, ease), NK Flicker
materialsshader groups: NK Attribute Tint, NK Stucco, NK Wood Grain, NK Stone, NK Felt, NK Terracotta, NK Stripes, NK Glow Glass, NK Ground, NK Root Tip Gradient, plus make_material()
postcompositor groups: NK Haze Over Sky (mist haze on premultiplied geometry over a sky gradient), NK Soft Glow

The full catalog with inputs and uses is references/library-index.md.

Storybook Street in detail, and the templates

The top of this page is prompts/storybook-street/. It was measured against the reference over time (colour, luma percentiles, image motion) and validated by reopening the file; its README reports the results and the remaining differences. It has a Primary panel of eight macro controls, six presets, and the same graph on an S-bend, a hill and a spiral town:

Reference (top) vs reconstruction (bottom)Variations of one graphThe same graph on other curves
reference vs reconstructionvariationstransfer

Its [workflow](prompts/storybo

// HOW IT'S BUILT

// FILES

43 files shipped with this skill. Inline text is the catalog copy. The upstream link is the source repository.

  • skills/procedural-blender-nodes/SKILL.md8.6 KB6369dfe7628cupstream ↗
    ---
    name: procedural-blender-nodes
    description: >-
      Builds, debugs, and art-directs procedural Blender node systems from Python: Geometry Nodes generators and
      modifiers, Shader Node materials, simulation and repeat zones, analytic animation, and compositor looks, as
      readable node groups with artist controls that work on new input geometry. Ships nodekit (a graph builder and
      inspector), a tested NK node-group library (curve placement, shapes, scatter, motion, materials, post), templates,
      and a checker. Use when the user wants a Geometry Nodes setup, a procedural asset or generator in Blender, a
      bpy script that creates node trees, node-based materials or effects, or wants such a script fixed, ported to
      Blender 5.x, organised, or explained; also for recreating a reference (image, clip or description) as an
      editable node system. Not for Unity/Unreal/Houdini graphs, three.js, or plain mesh modelling without nodes.
    license: MIT
    metadata:
      hermes:
        tags:
          - blender
          - geometry-nodes
          - shader-nodes
          - procedural
          - bpy
        category: development
    ---
    
    # Procedural Blender Nodes
    
    Treat a node system as an explanation of an effect: decide what it must produce and on what inputs, build it as
    named node groups an artist can read, expose the few controls that matter, and prove it by rendering it, changing
    those controls, and running it on other inputs.
    
    ## Workflow
    
    1. **Target.** State the effect: what geometry, materials and motion; the input it runs on (a curve, a mesh, a
       point set, nothing); what an artist should be able to change; the shot or use that judges it.
    2. **Route.** Pick the technique and a starting template from `references/subject-routing.md`: curve-space layout,
       surface scatter, instanced assemblies, mesh modelling, analytic animation, simulation zone, shader-only.
    3. **Assemble from the library.** Find groups with `python3 scripts/nodes_tools.py search <words>` or
       `references/library-index.md`, and instance them (`Graph.group(builder(), ...)`). Do not rebuild what the
       library has; it is tested.
    4. **Build through nodekit.** Write a Python build script: `nodekit.graph.Graph` creates each group (inputs with
       descriptions, ranges and panels; frames around each block), a top-level modifier group composes them, and the
       script creates the scene and saves the .blend. The .blend's node graph is the deliverable; the script makes it
       reproducible. See `references/nodekit-guide.md`.
    5. **First result fast.** One part, its test input, one render: `python3 scripts/nodes_tools.py try -- --module
       <your.module> --root <dir> --png out/try.png`. Look at the image. Then grow part by part, judging the full
       assembly, not parts alone.
    6. **Refine in order:** silhouette and proportions, layout and density, materials, light and look, motion.
       Calibrate against the reference (if any) with measurements over time or across views, not one frame.
    7. **Controls.** A Primary panel with a handful of macro controls; detailed panels per part; presets as JSON.
       Sweep each primary control and look at the results (`scripts/sweep.py`).
    8. **Validate.** `scripts/validate.py` reopens the saved file: zero node warnings, zero lint, evaluation time,
       camera clearance. Run the effect on other inputs. For animation, render a continuous sequence and check it for
       sliding, popping and flicker. Rebuild twice and diff the graph dumps.
    
    Run the scripts from this skill's directory. They need Python 3 and Blender 5.2+ (`$BLENDER`, `blender` on PATH, or
    the macOS app); `nodes_tools.py` starts Blender itself.
    
    ## Conventions
    
    - **Python builds native nodes.** Every tree is built with `Graph` (Geometry, Shader or Compositor). Sockets are
      found by name among enabled sockets (`"Value#1"` for duplicates); set node properties via `props=` before
      inputs, because properties change which sockets exist.
    - **Groups explain.** Name groups for what they make (`NK Curve Parcels`, `My Effect Roof`), describe every group
      and input, frame every block with a sentence (`"Parcels: split each side into houses"`). Split reusable parts
      into sub-groups. `Graph.finish()` lays out frames left to right and gives each frame its own Group Input.
    - **Time.** Animated groups take a `Time` input in seconds. Only the top-level modifier group reads Scene Time
      (with Time Scale and Time Offset controls), so parts stay testable and retimable.
    - **Space.** Model in the input's space (curve space: s along, d left, h up; or object space) and place through
      the library's frame groups, so the effect follows any curve or mesh.
    - **Variation into materials.** Store `nk_color` (FLOAT_COLOR) and `nk_rand` (FLOAT 0–1) per piece; materials read
      them with an Attribute node (type GEOMETRY) after realize.
    - **Shared groups build once** (`@nodegroup` or `nodekit.graph.once`). Rebuilding a group other groups use clears
      its interface and silently unlinks them.
    - **Analytic first.** Periodic or driven motion is a function of `Time`. Use a simulation zone only for state:
      accumulation, growth, collisions, anything whose next frame depends on the last.
    
    ## Hard rules (these break Blender 5.2 scripts)
    
    - Modifier inputs are not ID properties: use `nodekit.inspect.set_modifier_inputs(mod, {"Name": value})`, never
      `mod["Socket_2"]`.
    - Never rebuild a shared node group in place after other groups use it (see Conventions).
    - Mode switches on Transform, Resample and Set Curve Normal, and Remove Named Attribute's pattern mode, are input
      sockets, not node properties. Texture outputs are named "Factor".
    - Field-default sockets (Extrude Mesh `Offset`) ignore constants; link a node.
    - In background mode, drivers update only for the window's scene: set `bpy.context.window.scene` before
      `frame_set`, render with `bpy.ops.render.render(scene=...)`.
    - EEVEE world volumes swallow the sun: do distance haze in the compositor on premultiplied geometry.
    - Always run Blender as `blender -b --factory-startup --python script.py -- args`; pass negative CLI values as
      `--x=-1`.
    - Every input gets a description, every group a description; zero node warnings, zero `check_groups` lint.
    
    The full list with fixes is in `references/blender-api-notes.md`.
    
    ## Reference routing
    
    Read only what the task needs:
    
    | File | Read when |
    |---|---|
    | `references/subject-routing.md` | starting from a subject ("a fence along a path", "ivy over a statue"): technique, groups, template |
    | `references/library-index.md` | choosing groups: every NK group with inputs, summary and uses |
    | `references/nodekit-guide.md` | writing the build script: Graph API, zones, frames, layout, inspect, the scripts |
    | `references/geometry-nodes-patterns.md` | fields and domains, instancing vs realize, attributes into materials, booleans, budgets, per-frame cost |
    | `references/animation-and-simulation.md` | motion: analytic vs simulation zone, integrated phase, planted feet, springs, wind, loops, caches |
    | `references/controls-and-presets.md` | designing the modifier panel, macro controls, presets, sweeps |
    | `references/materials-and-look.md` | Shader Node materials, attribute variation, EEVEE, AgX, light, compositor haze and glow, calibration |
    | `references/validation-and-review.md` | before delivering: determinism, reopen-and-validate, reuse, temporal checks, honest reporting |
    | `references/blender-api-notes.md` | a Blender 5.x API error or surprise |
    | `../../prompts/reference-to-nodes-prompt.md` (repository) | recreating a reference video, image or description as a node system |
    
    Templates in `templates/` are complete, render-verified build scripts (gallery in the repository README); each
    builds a .blend whose top modifier group runs on its own test input and on others.
    
    ## Output expectations
    
    When building an effect:
    
    - Deliver the build script and the .blend it saves. The .blend holds native node groups, not baked geometry or
      image sequences (state any image texture you use and why).
    - The top-level modifier group has a Primary panel, then per-part panels; every group and input is described.
    - End with a short guide: the primary controls, what each part group does, how to run it on new input, and how it
      was validated (check/validate output, renders looked at, inputs tried).
    - Separate what is implemented from what is approximated or out of scope; do not call an analytic sway a cloth
      simulation, or a texture mask modelled geometry.
    
    When fixing or porting an existing node script:
    
    - Identify how it builds trees and where it breaks on Blender 5.2 (modifier inputs, socket names, modes as inputs).
    - Preserve the graph's interface (input names and order) unless asked; call out any change.
    - Fix correctness first, then readability (frames, descriptions, layout), then performance.
    
  • skills/procedural-blender-nodes/scripts/_check_in_blender.py3.3 KB3011cd62f182upstream ↗
    """Run by `nodes_tools.py check` inside Blender: build every library group, lint it, and evaluate every Geometry
    Nodes group on standard test inputs (a curve for curve/'Curve' inputs, an icosphere for other geometry), with
    zero node warnings required. Exits 1 on any problem."""
    import pathlib, sys, time
    SKILL = pathlib.Path(__file__).resolve().parents[1]
    sys.path.insert(0, str(SKILL))
    import bpy
    from nodekit import inspect
    from nodekit.graph import Graph
    from nodekit.library import REGISTRY, build_all
    
    problems = []
    t0 = time.time()
    try:
        trees = build_all()
    except Exception as e:                                   # a builder that raises is a hard failure
        import traceback
        traceback.print_exc()
        sys.exit(1)
    print(f"built {len(trees)} groups in {time.time() - t0:.2f}s")
    
    for name, tree in trees.items():
        if tree.name != name:
            problems.append(f"{name}: builder returned a tree named {tree.name!r}")
    problems += inspect.check_groups("NK ")
    
    # evaluation on standard inputs
    cu = bpy.data.curves.new("NK Test Curve", "CURVE")
    cu.dimensions = "3D"
    sp = cu.splines.new("BEZIER")
    sp.bezier_points.add(3)
    for bp, p in zip(sp.bezier_points, [(0, 0, 0), (4, 6, 0), (-3, 12, 1), (0, 20, 1)]):
        bp.co = p
        bp.handle_left_type = bp.handle_right_type = "AUTO"
    curve_ob = bpy.data.objects.new("NK Test Curve", cu)
    bpy.context.scene.collection.objects.link(curve_ob)
    bpy.ops.mesh.primitive_ico_sphere_add(subdivisions=3, radius=1.0)
    mesh_ob = bpy.context.object
    
    for name, tree in trees.items():
        if tree.bl_idname != "GeometryNodeTree":
            continue
        outs = [it for it in tree.interface.items_tree if it.item_type == "SOCKET" and it.in_out == "OUTPUT"]
        if not any(it.socket_type == "NodeSocketGeometry" for it in outs):
            continue                                          # field-only helper groups are checked by tests
        w = Graph(f"NKT {name}", description="check wrapper", modifier=True)
        ci = w.node("GeometryNodeObjectInfo", props={"transform_space": "RELATIVE"}, Object=curve_ob)
        mi = w.node("GeometryNodeObjectInfo", props={"transform_space": "RELATIVE"}, Object=mesh_ob)
        t = w.node("GeometryNodeInputSceneTime")
        gn = w.group(tree)
        for s in gn.b.inputs:
            if not s.enabled:
                continue
            if s.type == "GEOMETRY":
                w.connect(ci["Geometry"] if "curve" in s.name.lower() or "path" in s.name.lower() else mi["Geometry"], s)
            elif s.name == "Time":
                w.connect(t["Seconds"], s)
        j = w.node("GeometryNodeJoinGeometry")
        for s in gn.b.outputs:
            if s.enabled and s.type == "GEOMETRY":
                w.connect(s, j.i("Geometry"))
        w.output("Geometry", "GEOMETRY", j["Geometry"])
        w.finish()
        me = bpy.data.meshes.new(f"NKT {name}")
        ob = bpy.data.objects.new(f"NKT {name}", me)
        bpy.context.scene.collection.objects.link(ob)
        mod = ob.modifiers.new("check", "NODES")
        mod.node_group = w.nt
        t1 = time.time()
        bpy.context.scene.frame_set(24)
        stats = inspect.evaluated_stats(ob)
        dt = (time.time() - t1) * 1000
        warns = [f"{x.type}: {x.message}" for x in mod.node_warnings]
        print(f"  {name:<32} {stats['verts']:>8} verts {dt:7.1f} ms  {'; '.join(warns)}")
        for x in warns:
            problems.append(f"{name}: node warning on test inputs: {x}")
    
    for p in problems:
        print("ERROR", p)
    print(f"blender: {len(problems)} problem(s)")
    sys.exit(1 if problems else 0)
    
  • skills/procedural-blender-nodes/scripts/compare.py21 KB8c5bb8f19818upstream ↗
    #!/usr/bin/env python3
    """Compare a render with the reference ACROSS TIME, per screen region (see docs/COMPARE.md).
    
        python3 tools/compare.py --ref reference/source/village_source.mp4 --render out/lead/seq \
            --times 0:20:0.5 --out out/compare/run1 [--regions my_regions.json] [--render-offset 0] [--width 640]
        python3 tools/compare.py --dump-regions out/compare/regions.json     # write the default regions to edit
    
    --render is a directory of f_####.png (frame N = time (N-1)/24 s; sparse is fine: missing times become gaps) or a
    video. For every sampled time t, frame i = round(t*fps) and its successor i+1 are read from both sources (scaled to
    the reference's analysis size), and colour, structure and motion metrics are computed per region.
    
    Outputs in --out: metrics.csv (long: time,region,metric,ref,render), metrics.json, summary.md, and plots
    colour.png, structure.png, motion.png, grid_<metric>.png (3x3 screen grid laid out spatially).
    
    Dependencies: ffmpeg and numpy (re-executes under Blender's bundled python if the system python has no numpy).
    """
    import argparse
    import csv
    import json
    import os
    import sys
    
    sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
    import video  # noqa: E402  (also guarantees numpy)
    from video import Source, draw_text, fill_rect, parse_times, text_size, write_png  # noqa: E402
    import numpy as np  # noqa: E402
    
    # Fractional rects [x0, y0, x1, y1] in the 1280x704 reference frame (y down). "exclude" rects are cut out.
    # Tuned on reference/frames/t05.png: facades flank a narrow sky strip with bunting; walker centred at the bottom.
    DEFAULT_REGIONS = {
        "full": {"rect": [0.0, 0.0, 1.0, 1.0]},
        "sky_strip": {"rect": [0.36, 0.0, 0.64, 0.30], "note": "narrow sky between the roofs (contains bunting)"},
        "bunting": {"rect": [0.33, 0.0, 0.70, 0.24], "note": "flag strings across the street; flutter lives here"},
        "left_facade": {"rect": [0.0, 0.05, 0.30, 0.62], "note": "near left wall, streams out to the left"},
        "right_facade": {"rect": [0.70, 0.05, 1.0, 0.62], "note": "near right wall, streams out to the right"},
        "road": {"rect": [0.0, 0.70, 1.0, 1.0], "exclude": [[0.37, 0.44, 0.63, 0.95]],
                 "note": "cobbles, curbs and verges below the horizon, walker cut out"},
        "walker": {"rect": [0.37, 0.44, 0.63, 0.95], "note": "hooded walker box"},
    }
    GRID = ["g%d%d" % (r, c) for r in range(3) for c in range(3)]  # g<row><col>, row 0 = top
    
    COLOUR = ["srgb_r", "srgb_g", "srgb_b", "ok_L", "ok_a", "ok_b", "chroma", "luma_p10", "luma_p50", "luma_p90"]
    STRUCT = ["edge", "edge_frac", "ssim"]
    MOTION = ["mad", "flow_x", "flow_y", "flow_conf", "flutter"]
    METRICS = COLOUR + STRUCT + MOTION
    PAIR_ONLY = {"ssim"}  # one value per time (ref vs render), stored in the "render" column, ref column = 1
    
    UNITS = {"srgb_r": "0-1", "srgb_g": "0-1", "srgb_b": "0-1", "ok_L": "0-1", "ok_a": "", "ok_b": "",
             "chroma": "", "luma_p10": "0-1", "luma_p50": "0-1", "luma_p90": "0-1", "edge": "grad", "edge_frac": "frac",
             "ssim": "", "mad": "luma/frame", "flow_x": "px/frame@1280", "flow_y": "px/frame@1280",
             "flow_conf": "peak", "flutter": "luma/frame"}
    
    
    # ---------------------------------------------------------------------------------------------------------------
    # image maths
    
    def srgb_to_linear(c):
        return np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4)
    
    
    def oklab(rgb):
        lin = srgb_to_linear(rgb)
        m1 = np.array([[0.4122214708, 0.5363325363, 0.0514459929],
                       [0.2119034982, 0.6806995451, 0.1073969566],
                       [0.0883024619, 0.2817188376, 0.6299787005]])
        m2 = np.array([[0.2104542553, 0.7936177850, -0.0040720468],
                       [1.9779984951, -2.4285922050, 0.4505937099],
                       [0.0259040371, 0.7827717662, -0.8086757660]])
        lms = np.cbrt(lin @ m1.T)
        return lms @ m2.T
    
    
    def luma(rgb):
        return rgb @ np.array([0.2126, 0.7152, 0.0722])
    
    
    def sobel_mag(y):
        p = np.pad(y, 1, mode="edge")
        gx = (p[:-2, 2:] + 2 * p[1:-1, 2:] + p[2:, 2:] - p[:-2, :-2] - 2 * p[1:-1, :-2] - p[2:, :-2]) / 4
        gy = (p[2:, :-2] + 2 * p[2:, 1:-1] + p[2:, 2:] - p[:-2, :-2] - 2 * p[:-2, 1:-1] - p[:-2, 2:]) / 4
        return np.hypot(gx, gy)
    
    
    def down(img, f):
        h, w = img.shape[0] // f * f, img.shape[1] // f * f
        img = img[:h, :w]
        return img.reshape(h // f, f, w // f, f, *img.shape[2:]).mean(axis=(1, 3))
    
    
    def box(x, r):
        """Mean filter of size (2r+1)^2 with edge padding, via cumulative sums."""
        p = np.pad(x, r + 1, mode="edge")
        c = p.cumsum(0).cumsum(1)
        k = 2 * r + 1
        s = c[k:, k:] - c[:-k, k:] - c[k:, :-k] + c[:-k, :-k]
        return s[:x.shape[0], :x.shape[1]] / (k * k)
    
    
    def ssim_map(a, b, r=3):
        c1, c2 = 0.01 ** 2, 0.03 ** 2
        ma, mb = box(a, r), box(b, r)
        va = box(a * a, r) - ma * ma
        vb = box(b * b, r) - mb * mb
        cov = box(a * b, r) - ma * mb
        return ((2 * ma * mb + c1) * (2 * cov + c2)) / ((ma * ma + mb * mb + c1) * (va + vb + c2))
    
    
    def phase_corr(a, b, weight):
        """Shift d (dx, dy) such that b(x) ~ a(x - d), with parabolic sub-pixel refinement, plus peak height."""
        a = (a - (a * weight).sum() / max(weight.sum(), 1e-9)) * weight
        b = (b - (b * weight).sum() / max(weight.sum(), 1e-9)) * weight
        A, B = np.fft.fft2(a), np.fft.fft2(b)
        R = B * np.conj(A)
        R /= np.abs(R) + 1e-9
        r = np.fft.ifft2(R).real
        py, px = np.unravel_index(np.argmax(r), r.shape)
        h, w = r.shape
    
        def sub(m, z, p):
            if abs(m - 2 * z + p) < 1e-12:
                return 0.0
            return float(np.clip(0.5 * (m - p) / (m - 2 * z + p), -0.5, 0.5))
        dx = px + sub(r[py, (px - 1) % w], r[py, px], r[py, (px + 1) % w])
        dy = py + sub(r[(py - 1) % h, px], r[py, px], r[(py + 1) % h, px])
        if dx > w / 2:
            dx -= w
        if dy > h / 2:
            dy -= h
        return dx, dy, float(r[py, px])
    
    
    def fourier_shift(img, dx, dy):
        """img sampled at x + d (moves content by -d), circular."""
        h, w = img.shape
        ky = np.fft.fftfreq(h)[:, None]
        kx = np.fft.fftfreq(w)[None, :]
        return np.fft.ifft2(np.fft.fft2(img) * np.exp(2j * np.pi * (kx * dx + ky * dy))).real
    
    
    def region_masks(regions, w, h):
        masks, boxes = {}, {}
        for name, spec in regions.items():
            x0, y0, x1, y1 = spec["rect"]
            bx = (int(round(x0 * w)), int(round(y0 * h)), int(round(x1 * w)), int(round(y1 * h)))
            m = np.zeros((h, w), bool)
            m[bx[1]:bx[3], bx[0]:bx[2]] = True
            for e in spec.get("exclude", []):
                m[int(round(e[1] * h)):int(round(e[3] * h)), int(round(e[0] * w)):int(round(e[2] * w))] = False
            masks[name], boxes[name] = m, bx
        return masks, boxes
    
    
    # ---------------------------------------------------------------------------------------------------------------
    # per-frame metrics
    
    def frame_metrics(fa, fb, masks, boxes, w, fps):
        """Colour/structure from fa; motion from fa -> fb (fb may be None). Returns {region: {metric: value}}."""
        rgb = fa.astype(np.float64) / 255
        lab = oklab(rgb)
        y = luma(rgb)
        chroma = np.hypot(lab[..., 1], lab[..., 2])
        grad = sobel_mag(y)
        yb = luma(fb.astype(np.float64) / 255) if fb is not None else None
        # flow at half the analysis resolution
        y2 = down(y, 2)
        yb2 = down(yb, 2) if yb is not None else None
        px_scale = 1280.0 / (w / 2)  # half-res px -> 1280-wide px
        out = {}
        for name, m in masks.items():
            v = {}
            sel = rgb[m]
            v["srgb_r"], v["srgb_g"], v["srgb_b"] = sel.mean(0)
            L = lab[m]
            v["ok_L"], v["ok_a"], v["ok_b"] = L.mean(0)
            v["chroma"] = chroma[m].mean()
            v["luma_p10"], v["luma_p50"], v["luma_p90"] = np.percentile(y[m], [10, 50, 90])
            v["edge"] = grad[m].mean()
            v["edge_frac"] = (grad[m] > 0.08).mean()
            if yb is None:
                for k in MOTION:
                    v[k] = np.nan
            else:
                v["mad"] = np.abs(yb - y)[m].mean()
                x0, y0, x1, y1 = (c // 2 for c in boxes[name])
                a, b = y2[y0:y1, x0:x1], yb2[y0:y1, x0:x1]
                m2 = down(m.astype(float), 2)[y0:y1, x0:x1]
                win = np.outer(np.hanning(a.shape[0]), np.hanning(a.shape[1])) * m2
                dx, dy, conf = phase_corr(a, b, win)
                v["flow_x"], v["flow_y"], v["flow_conf"] = dx * px_scale, dy * px_scale, conf
                comp = fourier_shift(b, dx, dy)
                mx, my = int(np.ceil(abs(dx))) + 2, int(np.ceil(abs(dy))) + 2
                inner = m2[my:-my or None, mx:-mx or None] > 0.5
                res = np.abs(comp - a)[my:-my or None, mx:-mx or None]
                v["flutter"] = res[inner].mean() if inner.any() else np.nan
            out[name] = v
        return out
    
    
    def ssim_by_region(fa, fb, masks):
        a = down(luma(fa.astype(np.float64) / 255), 4)
        b = down(luma(fb.astype(np.float64) / 255), 4)
        s = ssim_map(a, b)
        return {n: float(s[down(m.astype(float), 4)[:s.shape[0], :s.shape[1]] > 0.5].mean()) for n, m in masks.items()}
    
    
    # ---------------------------------------------------------------------------------------------------------------
    # plotting (numpy raster + bitmap font; no matplotlib)
    
    REF_C, REN_C, GRID_C, AX_C = (40, 80, 170), (230, 110, 20), (225, 225, 225), (90, 90, 90)
    
    
    def nice_ticks(lo, hi, n=4):
        span = hi - lo
        if span <= 0:
            return [lo]
        raw = span / n
        mag = 10 ** np.floor(np.log10(raw))
        step = min((s * mag for s in (1, 2, 2.5, 5, 10) if s * mag >= raw), default=raw)
        t0 = np.ceil(lo / step - 1e-9) * step
        return [t0 + k * step for k in range(int((hi - t0) / step + 1e-9) + 1)]
    
    
    def fmt(v):
        if v == 0:
            return "0"
        a = abs(v)
        if a >= 100:
            return "%.0f" % v
        if a >= 10:
            return "%.1f" % v
        if a >= 1:
            return "%.2f" % v
        if a >= 0.01:
            return ("%.3f" % v).replace("0.", ".", 1)
        if a >= 0.001:
            return ("%.4f" % v).replace("0.", ".", 1)
        return "%.0e" % v
    
    
    def draw_line(img, x0, y0, x1, y1, color, th=2):
        n = int(max(abs(x1 - x0), abs(y1 - y0)) * 2) + 1
        xs = np.linspace(x0, x1, n)
        ys = np.linspace(y0, y1, n)
        h, w = img.shape[:2]
        for ox in range(th):
            for oy in range(th):
                xi = np.clip(np.round(xs).astype(int) + ox - th // 2, 0, w - 1)
                yi = np.clip(np.round(ys).astype(int) + oy - th // 2, 0, h - 1)
                img[yi, xi] = color
    
    
    def draw_panel(img, x, y, w, h, title, times, series, ylabel=""):
        """series: list of (values, color, thickness). NaNs break the line."""
        fill_rect(img, x, y, x + w, y + h, (255, 255, 255))
        draw_text(img, x + 4, y + 4, title, (0, 0, 0), 2)
        if ylabel:
            tw, _ = text_size(ylabel, 1)
            draw_text(img, x + w - tw - 6, y + 8, ylabel, (110, 110, 110), 1)
        px0, py0, px1, py1 = x + 64, y + 26, x + w - 10, y + h - 24
        allv = np.concatenate([np.asarray(v, float) for v, _, _ in series]) if series else np.array([np.nan])
        allv = allv[np.isfinite(allv)]
        if allv.size == 0:
            draw_text(img, px0 + 10, (py0 + py1) // 2, "NO DATA", (150, 150, 150), 2)
            return
        lo, hi = float(allv.min()), float(allv.max())
        if hi - lo < 1e-9 * max(1.0, abs(hi)) or hi - lo < 1e-12:
            lo, hi = lo - max(abs(lo) * 0.05, 1e-3), hi + max(abs(hi) * 0.05, 1e-3)
        pad = (hi - lo) * 0.08
        lo, hi = lo - pad, hi + pad
        t0, t1 = times[0], times[-1] if times[-1] > times[0] else times[0] + 1
    
        def X(t):
            return px0 + (t - t0) / (t1 - t0) * (px1 - px0)
    
        def Y(v):
            return py1 - (v - lo) / (hi - lo) * (py1 - py0)
        for tv in nice_ticks(lo, hi, 3):
            yy = int(round(Y(tv)))
            fill_rect(img, px0, yy, px1, yy + 1, GRID_C)
            s = fmt(tv)
            tw, _ = text_size(s, 2)
            draw_text(img, px0 - tw - 4, yy - 7, s, AX_C, 2)
        for tt in nice_ticks(t0, t1, 5):
            xx = int(round(X(tt)))
            fill_rect(img, xx, py0, xx + 1, py1, GRID_C)
            s = "%g" % round(tt, 2)
            tw, _ = text_size(s, 2)
            draw_text(img, xx - tw // 2, py1 + 6, s, AX_C, 2)
        if lo < 0 < hi:
            yy = int(round(Y(0)))
            fill_rect(img, px0, yy, px1, yy + 1, (170, 170, 170))
        fill_rect(img, px0, py0, px0 + 1, py1 + 1, AX_C)
        fill_rect(img, px0, py1, px1, py1 + 1, AX_C)
        for vals, col, th in series:
            prev = None
            for t, v in zip(times, vals):
                if v is None or not np.isfinite(v):
                    prev = None
                    continue
                pt = (X(t), Y(v))
                if prev:
                    draw_line(img, prev[0], prev[1], pt[0], pt[1], col, th)
                else:
                    fill_rect(img, pt[0] - 1, pt[1] - 1, pt[0] + 2, pt[1] + 2, col)
                prev = pt
    
    
    def figure(path, title, times, cells, ncols, pw=380, ph=190, ref_label="REFERENCE", ren_label="RENDER"):
        """cells: list of (panel title, [(values, color)], unit) or None, laid out row-major with ncols columns."""
        nrows = (len(cells) + ncols - 1) // ncols
        head = 40
        img = np.full((head + nrows * (ph + 6) + 6, ncols * (pw + 6) + 6, 3), 238, np.uint8)
        draw_text(img, 8, 10, title, (0, 0, 0), 2)
        x = 8 + text_size(title + "   ", 2)[0]
        for lab, col in ((ref_label, REF_C), (ren_label, REN_C)):
            fill_rect(img, x, 14, x + 24, 18, col)
            draw_text(img, x + 30, 10, lab, (0, 0, 0), 2)
            x += 30 + text_size(lab + "   ", 2)[0]
        for k, cell in enumerate(cells):
            if cell is None:
                continue
            r, c = divmod(k, ncols)
            t, series, unit = cell
            draw_panel(img, 6 + c * (pw + 6), head + r * (ph + 6), pw, ph, t, times, series, unit)
        write_png(path, img)
    
    
    # ---------------------------------------------------------------------------------------------------------------
    
    def run(args):
        regions = dict(DEFAULT_REGIONS)
        if args.regions:
            with open(args.regions) as f:
                regions = json.load(f)
        for r in range(3):
            for c in range(3):
                regions.setdefault("g%d%d" % (r, c), {"rect": [c / 3, r / 3, (c + 1) / 3, (r + 1) / 3]})
        named = [n for n in regions if n not in GRID]
    
        ref, ren = Source(args.ref), Source(args.render, args.fps)
        times = parse_times(args.times)
        w = args.width
        h = int(round(w * ref.height / ref.width / 4)) * 4
        w = w // 4 * 4
        if abs(ren.width / ren.height - ref.width / ref.height) > 0.01:
            print("warning: aspect differs (ref %dx%d, render %dx%d); render is stretched to the ref aspect"
                  % (ref.width, ref.height, ren.width, ren.height))
        masks, boxes = region_masks(regions, w, h)
    
        def pairs(src, offset):
            res = {}
            for t in times:
                i = src.index(t + offset)
                if src.has(i):
                    res[t] = (i, i + 1 if src.has(i + 1) else None)
            return res
        pr, pq = pairs(ref, 0.0), pairs(ren, args.render_offset)
        print("reading %d ref and %d render frames at %dx%d ..." % (2 * len(pr), 2 * len(pq), w, h))
        fr = ref.read([i for p in pr.values() for i in p if i is not None], (w, h))
        fq = ren.read([i for p in pq.values() for i in p if i is not None], (w, h))
    
        data = {n: {m: {"ref": [], "render": []} for m in METRICS} for n in regions}
        for t in times:
            mr = mq = None
            if t in pr:
                i, j = pr[t]
                mr = frame_metrics(fr[i], fr.get(j), masks, boxes, w, ref.fps)
            if t in pq:
                i, j = pq[t]
                mq = frame_metrics(fq[i], fq.get(j), masks, boxes, w, ren.fps)
            ss = ssim_by_region(fr[pr[t][0]], fq[pq[t][0]], masks) if (mr and mq) else {}
            for n in regions:
                for m in METRICS:
                    if m in PAIR_ONLY:
                        data[n][m]["ref"].append(1.0 if ss else np.nan)
                        data[n][m]["render"].append(ss.get(n, np.nan))
                    else:
                        data[n][m]["ref"].append(float(mr[n][m]) if mr else np.nan)
                        data[n][m]["render"].append(float(mq[n][m]) if mq else np.nan)
    
        os.makedirs(args.out, exist_ok=True)
        with open(os.path.join(args.out, "metrics.csv"), "w", newline="") as f:
            wr = csv.writer(f)
            wr.writerow(["time", "region", "metric", "ref", "render", "diff"])
            for n in regions:
                for m in METRICS:
                    for k, t in enumerate(times):
                        a, b = data[n][m]["ref"][k], data[n][m]["render"][k]
                        wr.writerow([t, n, m, "%.6g" % a, "%.6g" % b, "%.6g" % (b - a)])
    
        def clean(x):
            return None if not np.isfinite(x) else round(float(x), 6)
        with open(os.path.join(args.out, "metrics.json"), "w") as f:
            json.dump({"ref": args.ref, "render": args.render, "render_offset": args.render_offset, "times": times,
                       "analysis_size": [w, h], "regions": regions, "units": UNITS,
                       "metrics": {n: {m: {s: [clean(v) for v in d[s]] for s in d} for m, d in md.items()}
                                   for n, md in data.items()}}, f, indent=1)
    
        # summary: median |render - ref| and median (render - ref) over time
        def stats(n, m):
            a = np.array(data[n][m]["ref"])
            b = np.array(data[n][m]["render"])
            ok = np.isfinite(a) & np.isfinite(b)
            if not ok.any():
                return np.nan, np.nan, np.nan, np.nan
            d = b[ok] - a[ok]
            return np.median(np.abs(d)), np.median(d), np.median(a[ok]), np.median(b[ok])
    
        def table(names, cell):
            lines = ["| metric | unit | " + " | ".join(names) + " |", "|---|---|" + "---|" * len(names)]
            for m in METRICS:
                row = [cell(*stats(n, m), m) for n in names]
                lines.append("| %s | %s | %s |" % (m, UNITS[m], " | ".join(row)))
            return "\n".join(lines)
    
        def mad_cell(mad, bias, a, b, m):
            if not np.isfinite(mad):
                return "-"
            if m in PAIR_ONLY:
                return "%s" % fmt(b)
            return "%s (%s%s)" % (fmt(mad), "+" if bias >= 0 else "", fmt(bias))
    
        def val_cell(mad, bias, a, b, m):
            if not np.isfinite(mad):
                return "-"
            return fmt(b) if m in PAIR_ONLY else "%s / %s" % (fmt(a), fmt(b))
        n_ok = sum(1 for k in range(len(times)) if np.isfinite(data["full"]["ok_L"]["ref"][k])
                   and np.isfinite(data["full"]["ok_L"]["render"][k]))
        md = ["# Compare: %s vs %s" % (args.ref, args.render), "",
              "Times `%s` (%d samples, %d with both frames), render offset %+g s, analysis %dx%d." %
              (args.times, len(times), n_ok, args.render_offset, w, h), "",
              "## Median |render - ref| over time (median signed bias in brackets; ssim: median value)", "",
              table(named, mad_cell), "", "### 3x3 grid (g<row><col>, row 0 = top)", "", table(GRID, mad_cell), "",
              "## Median values over time: ref / render", "", table(named, val_cell), ""]
        with open(os.path.join(args.out, "summary.md"), "w") as f:
            f.write("\n".join(md))
        print("\n".join(md[:7]))
    
        # plots
        rl, ql = "REF", "RENDER " + ren.name + (" %+gS" % args.render_offset if args.render_offset else "")
    
        def cell(n, m):
            d = data[n][m]
            if m in PAIR_ONLY:
                return ("%s: %s" % (n, m), [(d["render"], REN_C, 2)], UNITS[m])
            return ("%s: %s" % (n, m), [(d["ref"], REF_C, 4), (d["render"], REN_C, 2)], UNITS[m])
        sets = {"colour": ["ok_L", "chroma", "ok_a", "ok_b", "luma_p10", "luma_p90"],
                "structure": ["edge", "edge_frac", "ssim"],
                "motion": ["flow_x", "flow_y", "mad", "flutter"]}
        for fname, ms in sets.items():
            cells = [cell(n, m) for n in named for m in ms]
            figure(os.path.join(args.out, fname + ".png"), fname.upper() + " VS TIME (S)", times, cells, len(ms),
                   ref_label=rl, ren_label=ql)
        for m in args.grid_metrics.split(","):
            cells = [cell(g, m) for g in GRID]
            figure(os.path.join(args.out, "grid_%s.png" % m), "%s ON THE 3X3 SCREEN GRID VS TIME (S)" % m.upper(),
                   times, cells, 3, ref_label=rl, ren_label=ql)
        print("wrote", args.out, "(metrics.csv, metrics.json, summary.md, colour/structure/motion/grid_*.png)")
    
    
    def main():
        ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
        ap.add_argument("--ref", default="reference/source/village_source.mp4")
        ap.add_argument("--render")
        ap.add_argument("--times", default="0:20:0.5", help="a:b:step (inclusive) or t1,t2,... in seconds")
        ap.add_argument("--out")
        ap.add_argument("--regions", help="JSON {name: {rect: [x0,y0,x1,y1], exclude: [[...]]}} (fractions, y down)")
        ap.add_argument("--render-offset", type=float, default=0.0, help="render time = ref time + offset (s)")
        ap.add_argument("--fps", type=float, default=24.0, help="fps of a PNG-sequence render")
        ap.add_argument("--width", type=int, default=640, help="analysis width (both sources are scaled to it)")
        ap.add_argument("--grid-metrics", default="ok_L,chroma,edge,flow_x,flow_y,mad")
        ap.add_argument("--dump-regions", metavar="PATH", help="write the default regions JSON and exit")
        args = ap.parse_args()
        if args.dump_regions:
            with open(args.dump_regions, "w") as f:
                json.dump(DEFAULT_REGIONS, f, indent=1)
            print("wrote", args.dump_regions)
            return
        if not (args.render and args.out):
            ap.error("--render and --out are required")
        run(args)
    
    
    if __name__ == "__main__":
        main()
    
  • skills/procedural-blender-nodes/scripts/graph_atlas.py1.6 KBa9272633989cupstream ↗
    """Tile the numbered captures from `graph_shots.py --shots ALL` into one large poster: the whole scene's graphs.
    
        python3 scripts/graph_atlas.py shots/my_effect/ -o my_effect_graphs.jpg [--cols 6] [--tile 1280]
    
    Order is the capture order: the hero group and its sub-groups depth first, then materials, shader groups, world,
    compositor. Each tile keeps the editor header, which names the tree. Needs ffmpeg.
    """
    import argparse, pathlib, subprocess, sys
    
    ap = argparse.ArgumentParser()
    ap.add_argument("shots")
    ap.add_argument("-o", "--out", required=True)
    ap.add_argument("--cols", type=int, default=6)
    ap.add_argument("--tile", type=int, default=1280, help="tile width in pixels")
    a = ap.parse_args()
    
    files = sorted(p for p in pathlib.Path(a.shots).glob("[0-9][0-9][0-9]_*.png"))
    if not files:
        sys.exit(f"no NNN_*.png captures in {a.shots}")
    n, cols = len(files), min(a.cols, len(files))
    ins, chains = [], []
    for i, f in enumerate(files):
        ins += ["-i", str(f)]
        chains.append(f"[{i}]scale={a.tile}:-2,setsar=1[t{i}]")
    layout = "|".join(f"{'+'.join(['w0'] * (i % cols)) or '0'}_{'+'.join(['h0'] * (i // cols)) or '0'}" for i in range(n))
    graph = ";".join(chains) + ";" + "".join(f"[t{i}]" for i in range(n)) + \
        f"xstack=inputs={n}:layout={layout}:fill=0x181818"
    if n == 1:
        graph = chains[0].replace("[t0]", "")
    cmd = ["ffmpeg", "-v", "error", "-y", *ins, "-filter_complex", graph, "-q:v", "3", a.out]
    subprocess.run(cmd, check=True)
    kinds = {}
    for f in files:
        k = f.stem.split("_")[1]
        kinds[k] = kinds.get(k, 0) + 1
    print(f"wrote {a.out}: {n} graphs ({', '.join(f'{v} {k}' for k, v in kinds.items())}), {cols} columns")
    
  • skills/procedural-blender-nodes/scripts/graph_doc.py4.8 KB010b218a802fupstream ↗
    """Generate docs/GRAPH.md and docs/CONTROLS.md from the graph dump written by street/build.py.
    
        python3 scripts/graph_doc.py my_effect.graph.json --top "My Effect" --out docs/
    
    GRAPH.md: the group hierarchy (which group instances which), each group's description, frames (the readable
    "blocks" inside it), node counts and interface. CONTROLS.md: the Storybook Street modifier panel by panel, with
    defaults, ranges and descriptions. Both are regenerated, never edited by hand, so they cannot drift from the
    built graph.
    """
    import collections, json, pathlib, sys
    
    import argparse
    ap = argparse.ArgumentParser()
    ap.add_argument("graph", help="graph dump written by nodekit.inspect.dump_groups")
    ap.add_argument("--top", required=True, help="name of the top-level (modifier) node group")
    ap.add_argument("--out", default="docs", help="directory for GRAPH.md and CONTROLS.md")
    a = ap.parse_args()
    OUTDIR = pathlib.Path(a.out)
    OUTDIR.mkdir(parents=True, exist_ok=True)
    groups = {g["name"]: g for g in json.loads(pathlib.Path(a.graph).read_text())}
    TOP = a.top
    
    
    def fmt(v):
        if isinstance(v, float):
            return f"{v:g}"
        if isinstance(v, list):
            if len(v) == 4 and all(isinstance(x, (int, float)) for x in v):
                r, g, b = (max(0, min(1, x)) for x in v[:3])
                # linear -> sRGB hex for readability
                to = lambda c: round(255 * (12.92 * c if c <= 0.0031308 else 1.055 * c ** (1 / 2.4) - 0.055))
                return f"`#{to(r):02X}{to(g):02X}{to(b):02X}`"
            return "(" + ", ".join(fmt(x) for x in v) + ")"
        return "" if v is None else str(v)
    
    
    def children(g):
        return [n["props"].get("node_tree") for n in g["nodes"] if n["props"].get("node_tree")]
    
    
    # ---- GRAPH.md ---------------------------------------------------------------------------------------------
    lines = ["# Graph", "", "Generated by `scripts/graph_doc.py` from the built file; do not edit by hand.", "",
             "## Hierarchy", "", "Each line is a node group; indented lines are the groups it instances "
             "(×n = instanced n times inside its parent).", "", "```text"]
    seen = set()
    
    
    def tree(name, depth=0, count=1):
        g = groups.get(name)
        if g is None:
            return
        n_nodes = len([n for n in g["nodes"] if n["type"] not in ("NodeGroupInput", "NodeGroupOutput")])
        mult = f" ×{count}" if count > 1 else ""
        again = " (see above)" if name in seen else ""
        lines.append(f"{'  ' * depth}{name}{mult}  [{n_nodes} nodes, {len(g['frames'])} frames]{again}")
        if name in seen:
            return
        seen.add(name)
        for child, c in collections.Counter(children(g)).items():
            tree(child, depth + 1, c)
    
    
    tree(TOP)
    lines += ["```", ""]
    total_nodes = sum(len(g["nodes"]) for g in groups.values() if g["type"] == "GeometryNodeTree")
    lines += [f"{len([g for g in groups.values() if g['type'] == 'GeometryNodeTree'])} Geometry Nodes groups "
              f"({total_nodes} nodes), {len([g for g in groups.values() if g['type'] == 'ShaderNodeTree'])} shader "
              "groups.", "", "## Groups", ""]
    order = [n for n in seen] + sorted(set(groups) - seen)
    for name in sorted(order, key=lambda n: (n != TOP, n)):
        g = groups[name]
        lines += [f"### {name}", "", g["description"] or "_(no description)_", ""]
        if g["frames"]:
            lines += ["Blocks: " + "; ".join(g["frames"]), ""]
        ins = [i for i in g["interface"] if i.get("io") == "INPUT"]
        outs = [i for i in g["interface"] if i.get("io") == "OUTPUT"]
        if name != TOP and ins:
            lines += ["Inputs: " + ", ".join(f"{i['name']}" for i in ins), ""]
        if outs:
            lines += ["Outputs: " + ", ".join(f"{i['name']}" for i in outs), ""]
    (OUTDIR / "GRAPH.md").write_text("\n".join(lines) + "\n")
    
    # ---- CONTROLS.md ------------------------------------------------------------------------------------------
    top = groups[TOP]
    lines = ["# Controls", "", f"The `{TOP}` modifier panel, generated by `scripts/graph_doc.py` from the built "
             "file. Defaults are the reference look. Colours are shown as sRGB.", ""]
    panels = collections.OrderedDict()
    for it in top["interface"]:
        if it.get("io") != "INPUT" or it["type"] == "NodeSocketGeometry":
            continue
        panels.setdefault(it.get("panel") or "Main", []).append(it)
    for panel, items in panels.items():
        lines += [f"## {panel}", "", "| Control | Default | Range | What it does |", "|---|---|---|---|"]
        for it in items:
            rng = ""
            if "min_value" in it and isinstance(it["min_value"], (int, float)) and abs(it["min_value"]) < 1e8:
                rng = f"{fmt(it['min_value'])} – {fmt(it['max_value'])}"
            lines.append(f"| {it['name']} | {fmt(it.get('default_value'))} | {rng} | {it['description']} |")
        lines.append("")
    (OUTDIR / "CONTROLS.md").write_text("\n".join(lines) + "\n")
    print(f"wrote {OUTDIR}/GRAPH.md and {OUTDIR}/CONTROLS.md:", len(panels), "panels,", len(seen), "groups in hierarchy")
    
  • skills/procedural-blender-nodes/scripts/graph_shots.py7.7 KB1f6321ec2629upstream ↗
    """Screenshot node graphs of every kind in a real Blender UI (needs a display, not -b):
    
        blender my_effect.blend --python scripts/graph_shots.py -- --out shots/ \
            --shots "TOP,MAT:*,WORLD,COMP,MODIFIER"   [--prefix my_effect_]
    
    Shot specs (comma separated):
      TOP            the Geometry Nodes group on the scene's hero object (the Nodes modifier with the most nodes)
      <group name>   any node group by name: Geometry, Shader or Compositor group
      MAT:<name>     a material's Shader Nodes; MAT:* = the material with the most nodes
      SGROUP:*       the shader node group (e.g. an NK material group) with the most nodes, opened
      WORLD          the scene's world shader
      COMP           the scene's compositor graph
      MODIFIER       the Properties editor on the hero object's modifier tab
      ALL            every node tree the scene uses, in order: the hero group and its sub-groups depth first, then
                     materials, shader groups, world, compositor; files are numbered NNN_<kind>_<name>.png for
                     scripts/graph_atlas.py
    Each shot turns the largest area into the right editor, frames all nodes and saves <prefix><kind>.png
    (geometry, shader, world, compositor, modifier, or the slugged group name). Specs that do not exist in the
    file are skipped with a message. Quits when done.
    """
    import pathlib, re, sys
    import bpy
    
    argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
    opts = dict(zip(argv[::2], argv[1::2]))
    OUT = pathlib.Path(opts.get("--out", "out/graphs")).resolve()
    OUT.mkdir(parents=True, exist_ok=True)
    PREFIX = opts.get("--prefix", "")
    SPECS = [s.strip() for s in opts.get("--shots", opts.get("--groups", "TOP")).split(",") if s.strip()]
    QUEUE = []
    
    
    def expand_all():
        """Every tree the scene uses, hierarchy first: [(spec, stem)]."""
        _, ob, mod = hero()
        out, seen = [], set()
    
        def walk(ng):
            if ng is None or ng.name in seen:
                return
            seen.add(ng.name)
            out.append((ng.name, "geo_" + slug(ng.name)))
            for n in ng.nodes:
                if getattr(n, "node_tree", None) is not None and n.node_tree.bl_idname == "GeometryNodeTree":
                    walk(n.node_tree)
        walk(mod.node_group if mod else None)
        for m in sorted((m for m in bpy.data.materials if m.node_tree and m.users), key=lambda m: m.name):
            out.append(("MAT:" + m.name, "mat_" + slug(m.name)))
        for g in sorted((g for g in bpy.data.node_groups if g.bl_idname == "ShaderNodeTree" and g.users),
                        key=lambda g: g.name):
            out.append((g.name, "sgroup_" + slug(g.name)))
        out += [("WORLD", "world"), ("COMP", "compositor")]
        return [(spec, f"{i:03d}_{stem}") for i, (spec, stem) in enumerate(out)]
    
    
    STEMS = {}
    for s in SPECS:
        if s == "ALL":
            QUEUE.append("__ALL__")
        else:
            QUEUE.append(s)
    
    
    def slug(s):
        return re.sub(r"[^A-Za-z0-9]+", "_", s).strip("_")
    
    
    def hero():
        sc = bpy.context.window_manager.windows[0].scene
        cands = [(len(m.node_group.nodes), o, m) for o in sc.objects for m in o.modifiers
                 if m.type == "NODES" and m.node_group]
        return max(cands, key=lambda c: c[0]) if cands else (0, None, None)
    
    
    def resolve(spec):
        """-> (ui_type, shader_type or None, node_tree or None, file stem) or None when absent."""
        sc = bpy.context.window_manager.windows[0].scene
        if spec == "TOP":
            _, ob, mod = hero()
            return ("GeometryNodeTree", None, mod.node_group, "geometry") if mod else None
        if spec.startswith("MAT:"):
            name = spec[4:]
            mats = [m for m in bpy.data.materials if m.node_tree and m.users]
            mat = max(mats, key=lambda m: len(m.node_tree.nodes)) if name == "*" and mats else bpy.data.materials.get(name)
            return ("ShaderNodeTree", "OBJECT", mat.node_tree, "shader" if name == "*" else "shader_" + slug(name)) \
                if mat and mat.node_tree else None
        if spec == "SGROUP:*":
            groups = [g for g in bpy.data.node_groups if g.bl_idname == "ShaderNodeTree" and g.users]
            g = max(groups, key=lambda g: len(g.nodes)) if groups else None
            return ("ShaderNodeTree", "OBJECT", g, "shader_group") if g else None
        if spec == "WORLD":
            return ("ShaderNodeTree", "WORLD", sc.world.node_tree, "world") if sc.world and sc.world.node_tree else None
        if spec == "COMP":
            ng = getattr(sc, "compositing_node_group", None)
            return ("CompositorNodeTree", None, ng, "compositor") if ng else None
        ng = bpy.data.node_groups.get(spec)
        if ng is None:
            return None
        ui = {"GeometryNodeTree": "GeometryNodeTree", "ShaderNodeTree": "ShaderNodeTree",
              "CompositorNodeTree": "CompositorNodeTree"}[ng.bl_idname]
        return (ui, "OBJECT" if ui == "ShaderNodeTree" else None, ng, slug(spec))
    
    
    def big_area():
        win = bpy.context.window_manager.windows[0]
        return win, max(win.screen.areas, key=lambda a: a.width * a.height)
    
    
    def shoot():
        if not QUEUE:
            bpy.ops.wm.quit_blender()
            return None
        spec = QUEUE.pop(0)
        if spec == "__ALL__":
            items = expand_all()
            for sp, stem in reversed(items):
                QUEUE.insert(0, sp)
                STEMS.setdefault(sp, stem)
            print("ALL:", len(items), "trees")
            return 0.1
        win, area = big_area()
        if spec == "MODIFIER":
            return shoot_modifier(win, area)
        target = resolve(spec)
        if target is None:
            print("SKIP", spec, "(not in this file)")
            return 0.2
        ui, shader_type, tree, stem = target
        stem = STEMS.get(spec, stem)
        area.ui_type = ui
    
        def set_tree():                                        # the space only exists after the type switch redraws
            space = area.spaces.active
            if shader_type and hasattr(space, "shader_type"):
                space.shader_type = shader_type
            try:
                space.pin = True
                space.node_tree = tree
            except (AttributeError, TypeError, RuntimeError):
                pass                                           # editor shows the scene's own tree (compositor)
            space.show_region_ui = False
            space.show_region_toolbar = False
            return None
    
        def frame_all():
            region = next(r for r in area.regions if r.type == "WINDOW")
            with bpy.context.temp_override(window=win, area=area, region=region):
                bpy.ops.node.select_all(action="DESELECT")
                bpy.ops.node.view_all()
            return None
    
        def capture():
            region = next(r for r in area.regions if r.type == "WINDOW")
            path = OUT / f"{PREFIX}{stem}.png"
            with bpy.context.temp_override(window=win, area=area, region=region):
                bpy.ops.screen.screenshot_area(filepath=str(path))
            print("SHOT", spec, "->", path.name)
            return None
    
        bpy.app.timers.register(set_tree, first_interval=0.4)
        bpy.app.timers.register(frame_all, first_interval=1.0)
        bpy.app.timers.register(frame_all, first_interval=1.6)
        bpy.app.timers.register(capture, first_interval=2.4)
        return 3.2
    
    
    def shoot_modifier(win, area):
        """The Properties editor on the hero object's modifier tab: the artist's control surface."""
        _, ob, _ = hero()
        sc = win.scene
        for o in sc.objects:
            o.select_set(o == ob)
        bpy.context.view_layer.objects.active = ob
        area.ui_type = "PROPERTIES"
    
        def tab():
            area.spaces.active.context = "MODIFIER"
            return None
    
        def capture():
            region = next(r for r in area.regions if r.type == "WINDOW")
            with bpy.context.temp_override(window=win, area=area, region=region):
                bpy.ops.screen.screenshot_area(filepath=str(OUT / f"{PREFIX}modifier.png"))
            print("SHOT MODIFIER")
            return None
        bpy.app.timers.register(tab, first_interval=0.4)
        bpy.app.timers.register(capture, first_interval=1.6)
        return 2.4
    
    
    bpy.app.timers.register(shoot, first_interval=2.0)
    
  • skills/procedural-blender-nodes/scripts/nodes_tools.py7.4 KB3bc78165b2ceupstream ↗
    #!/usr/bin/env python3
    """procedural-blender-nodes command line. Plain Python 3; commands that need Blender start it themselves.
    
        python3 scripts/nodes_tools.py search curve fence        rank library groups by name, summary, tags, uses
        python3 scripts/nodes_tools.py index [--check]           regenerate references/library-index.md
        python3 scripts/nodes_tools.py check                     static rules, then build every group in Blender:
                                                                 lint, node warnings, evaluation on test inputs
        python3 scripts/nodes_tools.py test [-k name]            numeric property tests (tests/ in the repository)
        python3 scripts/nodes_tools.py try  -- <try_group args>  build one group in a test scene (scripts/try_group.py)
    
    Blender is found as $BLENDER, else `blender` on PATH, else the macOS app bundle.
    """
    import ast, os, pathlib, re, shutil, subprocess, sys
    
    SKILL = pathlib.Path(__file__).resolve().parents[1]
    LIB = SKILL / "library"
    REPO = SKILL.parents[1]
    
    
    def blender_exe():
        for cand in (os.environ.get("BLENDER"), shutil.which("blender"),
                     "/Applications/Blender.app/Contents/MacOS/Blender"):
            if cand and pathlib.Path(cand).exists():
                return cand
        sys.exit("Blender not found: set $BLENDER or put `blender` on PATH (Blender 5.2+)")
    
    
    def run_blender(script, *args):
        cmd = [blender_exe(), "-b", "--factory-startup", "--python", str(script), "--", *args]
        return subprocess.run(cmd).returncode
    
    
    # ---- static reading of the library (no Blender) -----------------------------------------------------------
    def module_order():
        tree = ast.parse((LIB / "__init__.py").read_text())
        for node in tree.body:
            if isinstance(node, ast.Assign) and node.targets[0].id == "MODULE_ORDER":
                return [e.value for e in node.value.elts]
        return []
    
    
    def read_library():
        """[{name, kind, tags, use, summary, module, builder, line, inputs}] from @nodegroup decorators."""
        groups = []
        for mod in module_order():
            path = LIB / f"{mod}.py"
            if not path.exists():
                continue
            tree = ast.parse(path.read_text())
            for fn in [n for n in tree.body if isinstance(n, ast.FunctionDef)]:
                for dec in fn.decorator_list:
                    if isinstance(dec, ast.Call) and getattr(dec.func, "id", "") == "nodegroup":
                        meta = {"name": ast.literal_eval(dec.args[0]), "kind": "GEOMETRY", "tags": [], "use": "",
                                "module": mod, "builder": fn.name, "line": fn.lineno}
                        for kw in dec.keywords:
                            meta[kw.arg] = ast.literal_eval(kw.value)
                        doc = ast.get_docstring(fn) or ""
                        meta["summary"] = doc.strip().splitlines()[0] if doc.strip() else ""
                        meta["doc"] = doc
                        meta["inputs"] = re.findall(r'\.input\(\s*"([^"]+)"', ast.get_source_segment(path.read_text(), fn))
                        groups.append(meta)
        return groups
    
    
    def cmd_search(words):
        words = [w.lower() for w in words]
        scored = []
        for g in read_library():
            hay = {"name": g["name"].lower(), "summary": g["summary"].lower(),
                   "tags": " ".join(g["tags"]).lower(), "use": g["use"].lower()}
            score = sum(3 * (w in hay["name"]) + 2 * (w in hay["tags"]) + (w in hay["summary"]) + (w in hay["use"])
                        for w in words)
            if score:
                scored.append((score, g))
        for score, g in sorted(scored, key=lambda x: -x[0])[:15]:
            print(f"{g['name']:<28} {g['kind']:<10} library/{g['module']}.py:{g['line']}  {g['summary']}")
            print(f"{'':28} use: {g['use']}")
        if not scored:
            print("no match; try broader words, or read references/library-index.md")
    
    
    def render_index():
        groups = read_library()
        out = ["# Library index", "",
               "Generated by `python3 scripts/nodes_tools.py index`; do not edit by hand. Build a group with its",
               "builder (`from library.<module> import <builder>; tree = <builder>()`) and instance it with",
               "`Graph.group(tree, ...)`.", ""]
        for mod in module_order():
            gs = [g for g in groups if g["module"] == mod]
            if not gs:
                continue
            out += [f"## {mod}", "", "| Group | Kind | Builder | Inputs | Summary | Use for |", "|---|---|---|---|---|---|"]
            for g in gs:
                out.append(f"| `{g['name']}` | {g['kind'].lower()} | `{g['builder']}()` | "
                           f"{', '.join(g['inputs'][:10])}{' …' if len(g['inputs']) > 10 else ''} | {g['summary']} | "
                           f"{g['use']} |")
            out.append("")
        out.append(f"{len(groups)} groups in {len([m for m in module_order() if (LIB / f'{m}.py').exists()])} modules.")
        return "\n".join(out) + "\n"
    
    
    def cmd_index(check=False):
        path = SKILL / "references" / "library-index.md"
        text = render_index()
        if check:
            if not path.exists() or path.read_text() != text:
                sys.exit("references/library-index.md is stale: run `python3 scripts/nodes_tools.py index`")
            print("library-index.md is up to date")
            return
        path.write_text(text)
        print("wrote", path.relative_to(SKILL))
    
    
    def static_check():
        problems = []
        order = module_order()
        names = {}
        for g in read_library():
            where = f"library/{g['module']}.py:{g['line']}"
            if not g["name"].startswith("NK "):
                problems.append(f"{where}: group name {g['name']!r} must start with 'NK '")
            if g["name"] in names:
                problems.append(f"{where}: duplicate group name {g['name']!r} (also {names[g['name']]})")
            names[g["name"]] = where
            if not g["summary"]:
                problems.append(f"{where}: {g['builder']}() needs a one-line docstring summary")
            if not g["use"]:
                problems.append(f"{where}: {g['name']} needs use=... naming concrete subjects")
        for i, mod in enumerate(order):
            path = LIB / f"{mod}.py"
            if not path.exists():
                continue
            for imp in re.findall(r"^\s*(?:from\s+library\.(\w+)|from\s+library\s+import\s+(\w+))", path.read_text(), re.M):
                dep = imp[0] or imp[1]
                if dep in order and order.index(dep) >= i:
                    problems.append(f"library/{mod}.py imports library.{dep}, which is not earlier in MODULE_ORDER")
        return problems
    
    
    def cmd_check():
        problems = static_check()
        for p in problems:
            print("ERROR", p)
        print(f"static: {len(problems)} problem(s)")
        rc = run_blender(SKILL / "scripts" / "_check_in_blender.py")
        sys.exit(1 if problems or rc else 0)
    
    
    def main():
        if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help"):
            print(__doc__)
            return
        cmd, rest = sys.argv[1], sys.argv[2:]
        if cmd == "search":
            cmd_search(rest)
        elif cmd == "index":
            cmd_index("--check" in rest)
        elif cmd == "check":
            cmd_check()
        elif cmd == "test":
            runner = REPO / "tests" / "run_tests.py"
            if not runner.exists():
                sys.exit("the property tests live in the repository (tests/), not in the installed skill: clone "
                         "https://github.com/gokayfem/video-to-blender and run this from there")
            sys.exit(run_blender(runner, *rest))
        elif cmd == "try":
            sys.exit(run_blender(SKILL / "scripts" / "try_group.py", *[r for r in rest if r != "--"]))
        else:
            sys.exit(f"unknown command {cmd!r}; see --help")
    
    
    if __name__ == "__main__":
        main()
    
  • skills/procedural-blender-nodes/scripts/render.py3.0 KB56350bc58d2dupstream ↗
    """Render stills or sequences from the built .blend, optionally with control overrides (variations).
    
        blender -b my_effect.blend --python tools/render.py -- --scene Reference \
            --frames 1,97,193 --width 640 --samples 8 --out out/lead/stills/f_
        blender -b my_effect.blend --python tools/render.py -- --scene Reference \
            --range 1:481:1 --width 1280 --out out/lead/seq/f_
        # variations: --preset path/to/preset.json (or a name in ./presets/) and/or --set "Width=6" --set "Seed=7"
        # --report prints the evaluated stats and the camera position for each frame instead of rendering
    """
    import argparse, json, pathlib, sys
    ROOT = pathlib.Path(__file__).resolve().parents[1]
    sys.path.insert(0, str(ROOT))
    import bpy
    from nodekit import inspect
    
    argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
    ap = argparse.ArgumentParser()
    ap.add_argument("--scene", default="Reference")
    ap.add_argument("--frames", default="")
    ap.add_argument("--range", default="")
    ap.add_argument("--width", type=int, default=640)
    ap.add_argument("--samples", type=int, default=8)
    ap.add_argument("--out", default="out/lead/r_")
    ap.add_argument("--preset", default="")
    ap.add_argument("--set", action="append", default=[])
    ap.add_argument("--report", action="store_true")
    ap.add_argument("--engine", default="")
    ap.add_argument("--camera", default="", help="camera object name (default: the scene camera)")
    a = ap.parse_args(argv)
    
    sc = bpy.data.scenes[a.scene]
    if bpy.context.window:
        bpy.context.window.scene = sc
    street = next(o for o in sc.objects if o.type == "MESH" and any(m.type == "NODES" for m in o.modifiers))
    mod = next(m for m in street.modifiers if m.type == "NODES")
    values = {}
    if a.preset:
        pp = pathlib.Path(a.preset)
        if not pp.exists():                       # a bare name looks in ./presets/<name>.json
            pp = pathlib.Path("presets") / f"{a.preset}.json"
        values.update(json.loads(pp.read_text()))
    for kv in a.set:
        k, v = kv.split("=", 1)
        values[k] = json.loads(v)
    if values:
        inspect.set_modifier_inputs(mod, values)
    if a.camera:
        sc.camera = bpy.data.objects[a.camera]
    
    r = sc.render
    r.resolution_x = a.width
    r.resolution_y = int(round(a.width * 704 / 1280 / 2)) * 2
    r.resolution_percentage = 100
    if a.engine:
        r.engine = a.engine
    if hasattr(sc, "eevee"):
        sc.eevee.taa_render_samples = a.samples
    if a.range:
        s, e, st = (int(x) for x in a.range.split(":"))
        frames = list(range(s, e + 1, st))
    else:
        frames = [int(f) for f in a.frames.split(",") if f] or [sc.frame_current]
    
    out = pathlib.Path(a.out)
    out.parent.mkdir(parents=True, exist_ok=True)
    for f in frames:
        sc.frame_set(f)
        if a.report:
            cam = sc.camera
            print(f"FRAME {f} cam {tuple(round(x, 3) for x in cam.matrix_world.translation)} "
                  f"stats {inspect.evaluated_stats(street, sc)}")
            continue
        r.filepath = str(out.resolve()) + f"{f:04d}"
        bpy.ops.render.render(write_still=True, scene=sc.name)
        print("RENDERED", r.filepath + ".png")
    
  • skills/procedural-blender-nodes/scripts/smoke_graph.py1.2 KB09bdf922ee3aupstream ↗
    """Smoke test for v2n.graph: blender -b --factory-startup --python tools/smoke_graph.py"""
    import sys, pathlib
    sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
    import bpy
    from nodekit.graph import Graph
    from nodekit import inspect
    
    g = Graph("T Grid", description="Test grid", modifier=True)
    g.input("Geometry", "GEOMETRY", desc="unused")
    w = g.input("Width", "FLOAT", 4.0, min=0.5, max=20, subtype="DISTANCE", panel="Street", desc="Grid width")
    with g.frame("Grid"):
        grid = g.node("GeometryNodeMeshGrid", **{"Size X": w, "Size Y": g.math("MULTIPLY", w, 2.0), "Vertices X": 8})
        pos = g.node("GeometryNodeInputPosition")
        z = g.math("SINE", g.separate(pos)["X"])
        sp = g.node("GeometryNodeSetPosition", Geometry=grid["Mesh"], Offset=g.combine(0, 0, z))
    g.output("Geometry", "GEOMETRY", sp["Geometry"])
    g.finish()
    bpy.ops.mesh.primitive_plane_add()
    o = bpy.context.object
    m = o.modifiers.new("T", "NODES")
    m.node_group = g.nt
    inspect.set_modifier_inputs(m, {"Width": 6.0})
    print("STATS", inspect.evaluated_stats(o), inspect.get_modifier_inputs(m))
    print("CHECK", inspect.check_groups("T "))
    d = inspect.dump_groups("/dev/stdout" if False else "/tmp/v2n_smoke.json", "T ")
    print("NODES", [n["type"] for n in d[0]["nodes"]])
    
  • skills/procedural-blender-nodes/scripts/sweep.py2.1 KB3f2846209e5aupstream ↗
    """Render a control sweep: one moment of the street while a control moves between values (the "edit the graph"
    part of the demo).
    
        blender -b my_effect.blend --python tools/sweep.py -- --control "Timber Amount" \
            --values 0,1.7 --frames 48 --at 169 --out out/demo/sweep/timber_ [--scene Reference] [--width 960]
    
    Values are interpolated with smoothstep (integers round). Each rendered frame is a full re-evaluation of the
    node graph with that control value; nothing is baked.
    """
    import argparse, pathlib, sys
    ROOT = pathlib.Path(__file__).resolve().parents[1]
    sys.path.insert(0, str(ROOT))
    import bpy
    from nodekit import inspect
    
    argv = sys.argv[sys.argv.index("--") + 1:]
    ap = argparse.ArgumentParser()
    ap.add_argument("--control", required=True)
    ap.add_argument("--values", required=True)
    ap.add_argument("--frames", type=int, default=48)
    ap.add_argument("--at", type=int, default=169)
    ap.add_argument("--scene", default="Reference")
    ap.add_argument("--camera", default="")
    ap.add_argument("--width", type=int, default=960)
    ap.add_argument("--samples", type=int, default=8)
    ap.add_argument("--out", required=True)
    a = ap.parse_args(argv)
    
    sc = bpy.data.scenes[a.scene]
    if bpy.context.window:
        bpy.context.window.scene = sc
    if a.camera:
        sc.camera = bpy.data.objects[a.camera]
    street = next(o for o in sc.objects if o.type == "MESH" and any(m.type == "NODES" for m in o.modifiers))
    mod = next(m for m in street.modifiers if m.type == "NODES")
    v0, v1 = (float(x) for x in a.values.split(","))
    is_int = isinstance(inspect.get_modifier_inputs(mod)[a.control], int)
    sc.render.resolution_x = a.width
    sc.render.resolution_y = int(round(a.width * 704 / 1280 / 2)) * 2
    sc.eevee.taa_render_samples = a.samples
    sc.frame_set(a.at)
    out = pathlib.Path(a.out)
    out.parent.mkdir(parents=True, exist_ok=True)
    for i in range(a.frames):
        u = i / max(1, a.frames - 1)
        u = u * u * (3 - 2 * u)
        v = v0 + (v1 - v0) * u
        inspect.set_modifier_inputs(mod, {a.control: int(round(v)) if is_int else v})
        sc.frame_set(a.at)
        sc.render.filepath = str(out.resolve()) + f"{i + 1:04d}"
        bpy.ops.render.render(write_still=True, scene=sc.name)
        print("SWEEP", i + 1, a.control, round(v, 3))
    
  • skills/procedural-blender-nodes/scripts/try_group.py3.6 KB11860a8e460cupstream ↗
    """Build ONE street group in a fresh scene, wire it to a test path, report stats and lint, optionally render.
    
        blender -b --factory-startup --python scripts/try_group.py -- --module groups.my_part --root path/to/project \
            --path reference --frame 1 --png out/try/part.png --cam 0,-6,1.6:0,8,1.0 --width 640
        # --set "Street Width=5" --set "Seed=3"   override group inputs by name
        # --save out/try/part.blend                keep the test file to open in the UI
    
    The module must expose build() returning its top node group. The group's 'Curve' input gets the path curve
    (via Object Info), 'Time' gets scene time in seconds (frame / 24), and other inputs keep their defaults unless
    --set. Extra geometry inputs (e.g. 'Anchors') are left empty unless the module exposes test_inputs(graph) that
    wires them.
    """
    import argparse, importlib, json, pathlib, sys
    ROOT = pathlib.Path(__file__).resolve().parents[1]
    sys.path.insert(0, str(ROOT))
    import bpy
    from nodekit.graph import Graph
    from nodekit import inspect
    from nodekit import scene
    
    argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
    ap = argparse.ArgumentParser()
    ap.add_argument("--module", required=True)
    ap.add_argument("--root", default=".", help="directory the module is imported from (default: cwd)")
    ap.add_argument("--path", default="reference")
    ap.add_argument("--frame", type=int, default=1)
    ap.add_argument("--png")
    ap.add_argument("--save")
    ap.add_argument("--cam", default="0,-6,1.6:0,8,1.0", help="camera loc:target")
    ap.add_argument("--lens", type=float, default=24)
    ap.add_argument("--width", type=int, default=640)
    ap.add_argument("--samples", type=int, default=8)
    ap.add_argument("--set", action="append", default=[])
    a = ap.parse_args(argv)
    sys.path.insert(0, str(pathlib.Path(a.root).resolve()))
    
    scene.reset()
    path = scene.path_curve(a.path)
    mod_py = importlib.import_module(a.module)
    top = mod_py.build()
    
    # wrapper: Object Info(path) -> group, Scene Time -> Time
    w = Graph("TEST Wrapper", description="test wrapper", modifier=True)
    info = w.node("GeometryNodeObjectInfo", props={"transform_space": "RELATIVE"}, Object=path)
    t = w.node("GeometryNodeInputSceneTime")
    gn = w.group(top)
    names = [s.name for s in gn.b.inputs if s.enabled]
    if "Curve" in names:
        w.connect(info["Geometry"], gn.i("Curve"))
    if "Time" in names:
        w.connect(t["Seconds"], gn.i("Time"))
    for kv in a.set:
        k, v = kv.split("=", 1)
        v = json.loads(v)
        w.connect(v, gn.i(k))
    if hasattr(mod_py, "test_inputs"):
        mod_py.test_inputs(w, gn, info)
    outs = [s for s in gn.b.outputs if s.enabled and s.type == "GEOMETRY"]
    j = w.node("GeometryNodeJoinGeometry")
    for s in outs:
        w.connect(s, j.i("Geometry"))
    real = w.node("GeometryNodeRealizeInstances", Geometry=j["Geometry"])
    w.output("Geometry", "GEOMETRY", real["Geometry"])
    w.finish()
    
    ob, mod = scene.holder("Test Street", path, w.nt)
    bpy.context.scene.frame_set(a.frame)
    scene.light_world()
    loc, tgt = [tuple(float(x) for x in p.split(",")) for p in a.cam.split(":")]
    scene.camera(lens=a.lens, loc=loc, target=tgt)
    scene.render_setup(a.width, int(a.width * 704 / 1280) // 2 * 2, a.samples)
    
    print("STATS", json.dumps(inspect.evaluated_stats(ob)))
    print("WARNINGS", [(w_.type, w_.message) for w_ in mod.node_warnings])
    print("LINT", json.dumps(inspect.check_groups("")))
    if a.png:
        pathlib.Path(a.png).parent.mkdir(parents=True, exist_ok=True)
        bpy.context.scene.render.filepath = str(pathlib.Path(a.png).resolve())
        bpy.ops.render.render(write_still=True)
        print("PNG", a.png)
    if a.save:
        pathlib.Path(a.save).parent.mkdir(parents=True, exist_ok=True)
        bpy.ops.wm.save_as_mainfile(filepath=str(pathlib.Path(a.save).resolve()))
    
  • skills/procedural-blender-nodes/scripts/ui_shot.py4.4 KBa8c8fa10a096upstream ↗
    """Screenshot an effect inside Blender's own UI (needs a display, not -b): the Geometry Nodes workspace with the
    viewport looking through the scene camera, the effect's node graph, and its modifier panel.
    
        blender my_effect.blend -p 0 0 1920 1080 --python scripts/ui_shot.py -- --out shots/my_effect_ui.png \
            [--scene NAME] [--frame 100] [--shading MATERIAL|RENDERED|SOLID]
    
    The hero object is the scene's object with a Nodes modifier whose group has the most nodes. Frames are stepped from
    the start (simulation zones need it). The left area shows the most complex material's Shader Nodes. For the node
    graph alone, use scripts/graph_shots.py. Quits when done.
    """
    import pathlib, sys
    import bpy
    
    argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
    opts = dict(zip(argv[::2], argv[1::2]))
    OUT = pathlib.Path(opts["--out"]).resolve()
    OUT.parent.mkdir(parents=True, exist_ok=True)
    SHADING = opts.get("--shading", "MATERIAL")
    state = {"step": 0}
    
    
    def hero(sc):
        cands = [(len(m.node_group.nodes), o) for o in sc.objects for m in o.modifiers
                 if m.type == "NODES" and m.node_group]
        return max(cands, key=lambda c: c[0])[1] if cands else None
    
    
    def setup():
        win = bpy.context.window_manager.windows[0]
        sc = bpy.data.scenes.get(opts.get("--scene", "")) or bpy.data.scenes[0]
        win.scene = sc
        ws = bpy.data.workspaces.get("Geometry Nodes")
        if ws:
            win.workspace = ws
        frame = int(opts.get("--frame", sc.frame_current))
        for f in range(sc.frame_start, frame + 1):             # step: simulation zones need every frame
            sc.frame_set(f)
        ob = hero(sc)
        for o in sc.objects:
            o.select_set(o == ob)
        bpy.context.view_layer.objects.active = ob
        return win, sc, ob
    
    
    def arrange(win, sc, ob):
        areas = sorted(win.screen.areas, key=lambda a: a.width * a.height, reverse=True)
        for area in win.screen.areas:
            if area.type == "SPREADSHEET" or area == state.get("left"):
                # the spreadsheet becomes a Shader Editor on the most complex material (type first, tree next pass)
                state["left"] = area
                mats = [m for m in bpy.data.materials if m.node_tree and m.users]
                if not mats:
                    continue
                if area.ui_type != "ShaderNodeTree":
                    area.ui_type = "ShaderNodeTree"
                    continue
                sp = area.spaces.active
                sp.shader_type = "OBJECT"
                sp.pin = True
                sp.node_tree = max(mats, key=lambda m: len(m.node_tree.nodes)).node_tree
                sp.show_region_ui = False
                sp.show_region_toolbar = False
                with bpy.context.temp_override(window=win, area=area,
                                               region=next(r for r in area.regions if r.type == "WINDOW")):
                    bpy.ops.node.view_all()
                continue
            if area.type == "PROPERTIES":
                area.spaces.active.context = "MODIFIER"
            if area.type == "VIEW_3D":
                sp = area.spaces.active
                sp.shading.type = SHADING
                sp.overlay.show_overlays = False
                sp.show_gizmo = False
                sp.region_3d.view_perspective = "CAMERA"
                with bpy.context.temp_override(window=win, area=area,
                                               region=next(r for r in area.regions if r.type == "WINDOW")):
                    bpy.ops.view3d.view_center_camera()
            if area.type == "NODE_EDITOR" and area != state.get("left"):
                area.ui_type = "GeometryNodeTree"
                sp = area.spaces.active
                sp.show_region_ui = False
                sp.show_region_toolbar = False
                with bpy.context.temp_override(window=win, area=area,
                                               region=next(r for r in area.regions if r.type == "WINDOW")):
                    bpy.ops.node.select_all(action="DESELECT")
                    bpy.ops.node.view_all()
        return areas
    
    
    def tick():
        s = state["step"]
        state["step"] += 1
        if s == 0:
            state["ctx"] = setup()
            return 1.0
        win, sc, ob = state["ctx"]
        if s in (1, 2, 3):
            arrange(win, sc, ob)                                # thrice: editors switch type, get a tree, then frame
            return 2.5 if s == 3 else 1.0
        if s == 4:
            with bpy.context.temp_override(window=win):
                bpy.ops.screen.screenshot(filepath=str(OUT))
            print("UI SHOT", OUT)
            bpy.ops.wm.quit_blender()
        return None
    
    
    bpy.app.timers.register(tick, first_interval=2.0)
    
  • skills/procedural-blender-nodes/scripts/validate.py5.7 KB980f1d687079upstream ↗
    """Validate a saved .blend the way a user meets it: reopen, evaluate every scene, check the cameras, lint graphs.
    
        blender -b my_effect.blend --python scripts/validate.py -- [--step 24] [--out out/validate]
            [--hide "Show Walker"] [--min-clear 0.25] [--prefix "NK "] [--no-cameras]
    
    For every scene with a Geometry Nodes object:
    - node warnings on every Nodes modifier, evaluated vertex/face counts and evaluation time at the first frame;
    - every --step frames, each camera's clearance: distance to the nearest evaluated geometry (BVH built once at
      the first frame from all Nodes objects; animated parts can be switched off for it with --hide <bool input>),
      and whether the camera is inside a closed mesh (odd number of hits on an upward ray);
    - node-group lint (nodekit.inspect.check_groups) for groups whose names start with --prefix (default: all).
    Writes report.json, prints a summary, exits 1 on any failure. Clearance is checked on the curves and cameras in
    the file only; it is evidence, not a proof for every input.
    """
    import argparse, json, pathlib, sys, time
    SKILL = pathlib.Path(__file__).resolve().parents[1]
    sys.path.insert(0, str(SKILL))
    import bpy
    from mathutils import Vector
    from mathutils.bvhtree import BVHTree
    from nodekit import inspect
    
    argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
    ap = argparse.ArgumentParser()
    ap.add_argument("--step", type=int, default=24)
    ap.add_argument("--out", default="out/validate")
    ap.add_argument("--hide", action="append", default=[], help="bool modifier input set False for the BVH")
    ap.add_argument("--min-clear", type=float, default=0.25)
    ap.add_argument("--prefix", default="")
    ap.add_argument("--no-cameras", action="store_true")
    a = ap.parse_args(argv)
    OUT = pathlib.Path(a.out)
    OUT.mkdir(parents=True, exist_ok=True)
    report, failures = {"file": bpy.data.filepath, "scenes": {}}, []
    
    
    def inside(bvh, p):
        hits, origin = 0, p.copy()
        for _ in range(64):
            loc, nrm, idx, dist = bvh.ray_cast(origin, Vector((0, 0, 1)))
            if loc is None:
                break
            hits += 1
            origin = loc + Vector((0, 0, 1e-4))
        return hits % 2 == 1
    
    
    def nodes_objects(sc):
        return [(o, m) for o in sc.objects for m in o.modifiers if m.type == "NODES" and m.node_group]
    
    
    for sc in bpy.data.scenes:
        objs = nodes_objects(sc)
        if not objs:
            continue
        if bpy.context.window:                 # drivers (camera rigs) only update in the window's scene
            bpy.context.window.scene = sc
        t0 = time.time()
        sc.frame_set(sc.frame_start)
        dg = sc.view_layers[0].depsgraph
        dg.update()
        rec = {"objects": {}, "eval_s": None}
        for o, m in objs:
            ev = o.evaluated_get(dg)
            me = ev.to_mesh()
            rec["objects"][o.name] = {"modifier": m.name, "verts": len(me.vertices), "faces": len(me.polygons),
                                      "warnings": [w.message for w in m.node_warnings]}
            ev.to_mesh_clear()
            if rec["objects"][o.name]["warnings"]:
                failures.append(f"{sc.name}/{o.name}: node warnings {rec['objects'][o.name]['warnings']}")
        rec["eval_s"] = round(time.time() - t0, 3)
        cams = [] if a.no_cameras else [o for o in sc.objects if o.type == "CAMERA"]
        if cams:
            hidden = []
            for o, m in objs:
                ids = inspect.modifier_inputs(m)
                vals = {h: False for h in a.hide if h in ids}
                if vals:
                    hidden.append((m, {h: inspect.get_modifier_inputs(m)[h] for h in vals}))
                    inspect.set_modifier_inputs(m, vals)
            dg.update()
            verts, polys = [], []
            for o, m in objs:
                ev = o.evaluated_get(dg)
                me = ev.to_mesh()
                mw = o.matrix_world
                base = len(verts)
                verts += [mw @ v.co for v in me.vertices]
                polys += [tuple(base + i for i in p.vertices) for p in me.polygons]
                ev.to_mesh_clear()
            for m, vals in hidden:
                inspect.set_modifier_inputs(m, vals)
            bvh = BVHTree.FromPolygons(verts, polys) if polys else None
            rec["cameras"] = {}
            for cam in cams:
                samples = []
                for f in range(sc.frame_start, sc.frame_end + 1, a.step):
                    sc.frame_set(f)
                    p = cam.matrix_world.translation.copy()
                    loc, nrm, idx, dist = bvh.find_nearest(p) if bvh else (None, None, None, None)
                    samples.append({"frame": f, "clearance": None if dist is None else round(dist, 3),
                                    "inside": bool(bvh) and inside(bvh, p)})
                clears = [s["clearance"] for s in samples if s["clearance"] is not None]
                c = {"min_clearance": min(clears) if clears else None,
                     "inside_frames": [s["frame"] for s in samples if s["inside"]], "samples": samples}
                rec["cameras"][cam.name] = c
                if c["inside_frames"]:
                    failures.append(f"{sc.name}/{cam.name}: inside geometry at frames {c['inside_frames']}")
                if c["min_clearance"] is not None and c["min_clearance"] < a.min_clear:
                    failures.append(f"{sc.name}/{cam.name}: clearance {c['min_clearance']} < {a.min_clear}")
        report["scenes"][sc.name] = rec
        cam_txt = ", ".join(f"{k} min clear {v['min_clearance']}" for k, v in rec.get("cameras", {}).items())
        print(f"SCENE {sc.name}: " + ", ".join(f"{k} {v['verts']} verts {len(v['warnings'])} warnings"
                                             for k, v in rec["objects"].items()) + f", eval {rec['eval_s']}s; {cam_txt}")
    
    lint = inspect.check_groups(a.prefix)
    report["lint"] = lint
    failures += [f"lint: {x}" for x in lint]
    report["failures"] = failures
    (OUT / "report.json").write_text(json.dumps(report, indent=1))
    print("FAILURES", len(failures))
    for x in failures:
        print("  ", x)
    sys.exit(1 if failures else 0)
    
  • skills/procedural-blender-nodes/scripts/video.py19 KB8043abdeeec3upstream ↗
    #!/usr/bin/env python3
    """Video helpers for comparing the reference clip with our renders (see docs/COMPARE.md).
    
    Dependencies: ffmpeg/ffprobe on PATH and numpy. PIL and matplotlib are NOT needed: images are decoded through
    ffmpeg pipes, PNGs are written with zlib, and labels use a built-in 5x7 bitmap font (this ffmpeg has no drawtext).
    If the running python lacks numpy, the script re-executes itself with Blender's bundled python (or $V2N_PYTHON).
    
    Frame convention: a render sequence is a directory of f_####.png where frame N shows time (N-1)/fps. A sparse
    directory (only some N present) is fine for stills/metrics; videos need a dense run of frames.
    
        python3 tools/video.py extract  --src reference/source/village_source.mp4 --times 0:20:1 --out out/compare/ref_1s
        python3 tools/video.py assemble --seq out/lead/seq --out out/lead/seq.mp4
        python3 tools/video.py sbs      --ref reference/source/village_source.mp4 --render out/lead/seq \
                                        --out out/compare/lead_sbs.mp4 [--stack v] [--start 0 --end 20] [--scale 0.5]
        python3 tools/video.py sheet    --ref reference/source/village_source.mp4 --render out/lead/seq \
                                        --times 0:20:2.5 --out out/compare/lead_sheet.png
    """
    import glob
    import json
    import os
    import re
    import struct
    import subprocess
    import sys
    import zlib
    
    
    def require_numpy():
        """Import numpy, re-executing this script under a python that has it if needed."""
        try:
            import numpy  # noqa: F401
            return
        except ImportError:
            pass
        if os.environ.get("_V2N_REEXEC"):
            sys.exit("numpy not available (tried %s)" % sys.executable)
        cands = [os.environ.get("V2N_PYTHON", "")]
        cands += sorted(glob.glob("/Applications/Blender.app/Contents/Resources/*/python/bin/python3.*[0-9]"),
                        reverse=True)
        for c in cands:
            if c and os.path.isfile(c) and os.access(c, os.X_OK):
                os.environ["_V2N_REEXEC"] = "1"
                os.execv(c, [c, os.path.abspath(sys.argv[0])] + sys.argv[1:])
        sys.exit("numpy not found; set V2N_PYTHON to a python3 with numpy (e.g. Blender's bundled python)")
    
    
    require_numpy()
    import numpy as np  # noqa: E402
    
    SEQ_RE = re.compile(r"f_(\d{4,})\.png$")
    
    
    # ---------------------------------------------------------------------------------------------------------------
    # sources
    
    def probe(path):
        out = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-count_packets", "-show_entries",
                              "stream=width,height,r_frame_rate,nb_read_packets", "-of", "json", path],
                             capture_output=True, text=True, check=True).stdout
        s = json.loads(out)["streams"][0]
        num, den = s["r_frame_rate"].split("/")
        return int(s["width"]), int(s["height"]), float(num) / float(den), int(s.get("nb_read_packets", 0))
    
    
    class Source:
        """An mp4 (or any ffmpeg-readable video) or a directory of f_####.png. Frame index i is 0-based (= N-1)."""
    
        def __init__(self, path, fps=24.0):
            self.path = path
            self.is_dir = os.path.isdir(path)
            if self.is_dir:
                self.files = {}
                for f in os.listdir(path):
                    m = SEQ_RE.match(f)
                    if m:
                        self.files[int(m.group(1)) - 1] = os.path.join(path, f)
                if not self.files:
                    raise SystemExit("no f_####.png frames in %s" % path)
                self.fps = fps
                self.width, self.height, _, _ = probe(self.files[min(self.files)])
                self.n_frames = max(self.files) + 1
            else:
                self.width, self.height, self.fps, self.n_frames = probe(path)
            self.name = os.path.basename(os.path.normpath(path))
    
        def has(self, i):
            return (i in self.files) if self.is_dir else (0 <= i < self.n_frames)
    
        def index(self, t):
            return int(round(t * self.fps))
    
        def read(self, indices, size=None):
            """Return {i: uint8 HxWx3} for the requested frame indices that exist, scaled to size=(w, h)."""
            want = sorted({i for i in indices if self.has(i)})
            w, h = size or (self.width, self.height)
            vf = ["-vf", "scale=%d:%d:flags=area" % (w, h)] if (w, h) != (self.width, self.height) else []
            nbytes = w * h * 3
            out = {}
            if self.is_dir:
                for i in want:
                    raw = subprocess.run(["ffmpeg", "-v", "error", "-i", self.files[i]] + vf +
                                         ["-f", "rawvideo", "-pix_fmt", "rgb24", "-"], capture_output=True,
                                         check=True).stdout
                    out[i] = np.frombuffer(raw[:nbytes], np.uint8).reshape(h, w, 3)
                return out
            if not want:
                return out
            wanted = set(want)
            last = want[-1]
            p = subprocess.Popen(["ffmpeg", "-v", "error", "-i", self.path, "-fps_mode", "passthrough"] + vf +
                                 ["-frames:v", str(last + 1), "-f", "rawvideo", "-pix_fmt", "rgb24", "-"],
                                 stdout=subprocess.PIPE)
            i = 0
            while i <= last:
                buf = p.stdout.read(nbytes)
                if len(buf) < nbytes:
                    break
                if i in wanted:
                    out[i] = np.frombuffer(buf, np.uint8).reshape(h, w, 3)
                i += 1
            p.stdout.close()
            p.wait()
            return out
    
        def stream(self, start, end, fps, size):
            """Yield (t, frame or None) at fps from start to end (exclusive) for videos; dense sequences for dirs."""
            w, h = size
            nbytes = w * h * 3
            n = int(round((end - start) * fps))
            if self.is_dir:
                first = int(round(start * self.fps))
                cmd = ["ffmpeg", "-v", "error", "-framerate", str(self.fps), "-start_number", str(first + 1),
                       "-i", os.path.join(self.path, "f_%04d.png"), "-vf", "fps=%g,scale=%d:%d:flags=area" % (fps, w, h),
                       "-frames:v", str(n), "-f", "rawvideo", "-pix_fmt", "rgb24", "-"]
                if not self.has(first):
                    cmd = None
            else:
                cmd = ["ffmpeg", "-v", "error", "-ss", "%.4f" % start, "-i", self.path,
                       "-vf", "fps=%g,scale=%d:%d:flags=area" % (fps, w, h), "-frames:v", str(n),
                       "-f", "rawvideo", "-pix_fmt", "rgb24", "-"]
            p = subprocess.Popen(cmd, stdout=subprocess.PIPE) if cmd else None
            for k in range(n):
                buf = p.stdout.read(nbytes) if p else b""
                frame = np.frombuffer(buf, np.uint8).reshape(h, w, 3) if len(buf) == nbytes else None
                yield start + k / fps, frame
            if p:
                p.stdout.close()
                p.wait()
    
    
    def parse_times(spec):
        """'a:b:step' (inclusive of b when it lands on the grid) or 'a,b,c'."""
        if ":" in spec:
            a, b, s = (float(x) for x in spec.split(":"))
            n = int(np.floor((b - a) / s + 1e-6)) + 1
            return [round(a + k * s, 6) for k in range(n)]
        return [float(x) for x in spec.split(",") if x.strip()]
    
    
    # ---------------------------------------------------------------------------------------------------------------
    # PNG writing and a tiny bitmap font
    
    def write_png(path, img):
        img = np.ascontiguousarray(img, dtype=np.uint8)
        h, w = img.shape[:2]
        raw = b"".join(b"\x00" + img[y].tobytes() for y in range(h))
    
        def chunk(tag, data):
            return struct.pack(">I", len(data)) + tag + data + struct.pack(">I", zlib.crc32(tag + data) & 0xffffffff)
        d = os.path.dirname(path)
        if d:
            os.makedirs(d, exist_ok=True)
        with open(path, "wb") as f:
            f.write(b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0)) +
                    chunk(b"IDAT", zlib.compress(raw, 4)) + chunk(b"IEND", b""))
    
    
    _GLYPHS = {
        "0": ".###. #...# #..## #.#.# ##..# #...# .###.", "1": "..#.. .##.. ..#.. ..#.. ..#.. ..#.. .###.",
        "2": ".###. #...# ....# ...#. ..#.. .#... #####", "3": "####. ....# ....# .###. ....# ....# ####.",
        "4": "...#. ..##. .#.#. #..#. ##### ...#. ...#.", "5": "##### #.... ####. ....# ....# #...# .###.",
        "6": "..##. .#... #.... ####. #...# #...# .###.", "7": "##### ....# ...#. ..#.. .#... .#... .#...",
        "8": ".###. #...# #...# .###. #...# #...# .###.", "9": ".###. #...# #...# .#### ....# ...#. .##..",
        "A": ".###. #...# #...# ##### #...# #...# #...#", "B": "####. #...# #...# ####. #...# #...# ####.",
        "C": ".###. #...# #.... #.... #.... #...# .###.", "D": "####. #...# #...# #...# #...# #...# ####.",
        "E": "##### #.... #.... ####. #.... #.... #####", "F": "##### #.... #.... ####. #.... #.... #....",
        "G": ".###. #...# #.... #.### #...# #...# .####", "H": "#...# #...# #...# ##### #...# #...# #...#",
        "I": ".###. ..#.. ..#.. ..#.. ..#.. ..#.. .###.", "J": "..### ...#. ...#. ...#. ...#. #..#. .##..",
        "K": "#...# #..#. #.#.. ##... #.#.. #..#. #...#", "L": "#.... #.... #.... #.... #.... #.... #####",
        "M": "#...# ##.## #.#.# #.#.# #...# #...# #...#", "N": "#...# #...# ##..# #.#.# #..## #...# #...#",
        "O": ".###. #...# #...# #...# #...# #...# .###.", "P": "####. #...# #...# ####. #.... #.... #....",
        "Q": ".###. #...# #...# #...# #.#.# #..#. .##.#", "R": "####. #...# #...# ####. #.#.. #..#. #...#",
        "S": ".#### #.... #.... .###. ....# ....# ####.", "T": "##### ..#.. ..#.. ..#.. ..#.. ..#.. ..#..",
        "U": "#...# #...# #...# #...# #...# #...# .###.", "V": "#...# #...# #...# #...# #...# .#.#. ..#..",
        "W": "#...# #...# #...# #.#.# #.#.# #.#.# .#.#.", "X": "#...# #...# .#.#. ..#.. .#.#. #...# #...#",
        "Y": "#...# #...# .#.#. ..#.. ..#.. ..#.. ..#..", "Z": "##### ....# ...#. ..#.. .#... #.... #####",
        " ": "..... ..... ..... ..... ..... ..... .....", ".": "..... ..... ..... ..... ..... .##.. .##..",
        ",": "..... ..... ..... ..... .##.. ..#.. .#...", ":": "..... .##.. .##.. ..... .##.. .##.. .....",
        "-": "..... ..... ..... ##### ..... ..... .....", "+": "..... ..#.. ..#.. ##### ..#.. ..#.. .....",
        "_": "..... ..... ..... ..... ..... ..... #####", "/": "....# ....# ...#. ..#.. .#... #.... #....",
        "(": "...#. ..#.. .#... .#... .#... ..#.. ...#.", ")": ".#... ..#.. ...#. ...#. ...#. ..#.. .#...",
        "%": "##..# ##..# ...#. ..#.. .#... #..## #..##", "=": "..... ..... ##### ..... ##### ..... .....",
        "|": "..#.. ..#.. ..#.. ..#.. ..#.. ..#.. ..#..", "<": "...#. ..#.. .#... #.... .#... ..#.. ...#.",
        ">": ".#... ..#.. ...#. ....# ...#. ..#.. .#...", "[": ".###. .#... .#... .#... .#... .#... .###.",
        "]": ".###. ...#. ...#. ...#. ...#. ...#. .###.", "#": ".#.#. .#.#. ##### .#.#. ##### .#.#. .#.#.",
        "'": "..#.. ..#.. .#... ..... ..... ..... .....", "*": "..... ..#.. #.#.# .###. #.#.# ..#.. .....",
        "@": ".###. #...# #.### #.#.# #.##. #.... .###.",
        "?": ".###. #...# ....# ...#. ..#.. ..... ..#..", "!": "..#.. ..#.. ..#.. ..#.. ..#.. ..... ..#..",
    }
    _GLYPH_BITS = {c: np.array([[ch == "#" for ch in row] for row in g.split()], bool) for c, g in _GLYPHS.items()}
    
    
    def text_size(text, scale=2):
        return len(text) * 6 * scale, 7 * scale
    
    
    def draw_text(img, x, y, text, color=(0, 0, 0), scale=2, bg=None, pad=None):
        """Draw upper-cased text with the 5x7 font; (x, y) is the top-left corner. Clipped to the image."""
        h, w = img.shape[:2]
        if bg is not None:
            p = scale * 2 if pad is None else pad
            tw, th = text_size(text, scale)
            fill_rect(img, x - p, y - p, x + tw + p - scale, y + th + p, bg)
        for k, ch in enumerate(text.upper()):
            bits = _GLYPH_BITS.get(ch, _GLYPH_BITS["?"])
            big = np.kron(bits, np.ones((scale, scale), bool))
            x0, y0 = x + k * 6 * scale, y
            x1, y1 = x0 + big.shape[1], y0 + big.shape[0]
            cx0, cy0, cx1, cy1 = max(x0, 0), max(y0, 0), min(x1, w), min(y1, h)
            if cx0 >= cx1 or cy0 >= cy1:
                continue
            m = big[cy0 - y0:cy1 - y0, cx0 - x0:cx1 - x0]
            img[cy0:cy1, cx0:cx1][m] = color
    
    
    def fill_rect(img, x0, y0, x1, y1, color):
        h, w = img.shape[:2]
        x0, y0, x1, y1 = max(int(x0), 0), max(int(y0), 0), min(int(x1), w), min(int(y1), h)
        if x0 < x1 and y0 < y1:
            img[y0:y1, x0:x1] = color
    
    
    # ---------------------------------------------------------------------------------------------------------------
    # commands
    
    def extract(src, times, out_dir, width=None):
        s = Source(src)
        size = None
        if width:
            size = (width, int(round(width * s.height / s.width / 2)) * 2)
        idx = {t: s.index(t) for t in times}
        frames = s.read(idx.values(), size)
        os.makedirs(out_dir, exist_ok=True)
        for t, i in idx.items():
            if i in frames:
                write_png(os.path.join(out_dir, "f_%04d.png" % (i + 1)), frames[i])
        print("wrote %d frames to %s (f_N.png, N-1 = round(t*%g))" % (len(frames), out_dir, s.fps))
    
    
    def assemble(seq_dir, out, fps=24.0, crf=18):
        s = Source(seq_dir, fps)
        first = min(s.files)
        subprocess.run(["ffmpeg", "-v", "error", "-y", "-framerate", str(fps), "-start_number", str(first + 1),
                        "-i", os.path.join(seq_dir, "f_%04d.png"), "-c:v", "libx264", "-pix_fmt", "yuv420p",
                        "-crf", str(crf), "-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2", out], check=True)
        print("wrote", out)
    
    
    def _pane_size(src_ref, scale):
        w = int(round(src_ref.width * scale / 2)) * 2
        h = int(round(src_ref.height * scale / 2)) * 2
        return w, h
    
    
    def side_by_side(ref, render, out, start=0.0, end=None, stack="h", scale=0.5, fps=24.0,
                     labels=("REFERENCE", None), render_offset=0.0, crf=18):
        """Reference | render (or stacked), synchronized by time, with labels and a timecode drawn in."""
        r, q = Source(ref), Source(render, fps)
        if end is None:
            end = min(r.n_frames / r.fps, q.n_frames / q.fps - render_offset)
        w, h = _pane_size(r, scale)
        lab_r = labels[0]
        lab_q = labels[1] or ("RENDER " + q.name)
        W, H = (2 * w, h) if stack == "h" else (w, 2 * h)
        ts = max(1, int(round(h / 176)))
        enc = subprocess.Popen(["ffmpeg", "-v", "error", "-y", "-f", "rawvideo", "-pix_fmt", "rgb24", "-s",
                                "%dx%d" % (W, H), "-r", str(fps), "-i", "-", "-c:v", "libx264", "-pix_fmt", "yuv420p",
                                "-crf", str(crf), out], stdin=subprocess.PIPE)
        n = 0
        for (t, fa), (_, fb) in zip(r.stream(start, end, fps, (w, h)), q.stream(start + render_offset,
                                                                               end + render_offset, fps, (w, h))):
            canvas = np.zeros((H, W, 3), np.uint8)
            ox, oy = (w, 0) if stack == "h" else (0, h)
            if fa is not None:
                canvas[:h, :w] = fa
            if fb is not None:
                canvas[oy:oy + h, ox:ox + w] = fb
            else:
                draw_text(canvas, ox + w // 2 - 60 * ts, oy + h // 2, "NO FRAME", (200, 60, 60), 2 * ts)
            draw_text(canvas, 6 * ts, 6 * ts, lab_r, (255, 255, 255), ts, bg=(0, 0, 0))
            draw_text(canvas, ox + 6 * ts, oy + 6 * ts, lab_q, (255, 255, 255), ts, bg=(0, 0, 0))
            tc = "T %6.3fS  F %04d" % (t, int(round(t * fps)) + 1)
            tw, th = text_size(tc, ts)
            draw_text(canvas, W - tw - 6 * ts, H - th - 6 * ts, tc, (255, 255, 0), ts, bg=(0, 0, 0))
            enc.stdin.write(canvas.tobytes())
            n += 1
        enc.stdin.close()
        enc.wait()
        print("wrote %s (%d frames, %.2f-%.2f s, %dx%d)" % (out, n, start, end, W, H))
    
    
    def contact_sheet(ref, render, times, out, thumb_width=320, render_offset=0.0, label=None):
        """Reference row over render row at the given times (columns), with time labels."""
        r = Source(ref)
        q = Source(render) if render else None
        th = int(round(thumb_width * r.height / r.width / 2)) * 2
        size = (thumb_width, th)
        fr = r.read([r.index(t) for t in times], size)
        fq = q.read([q.index(t + render_offset) for t in times], size) if q else {}
        gap, top, left = 4, 22, 0
        rows = 2 if q else 1
        img = np.full((top + rows * (th + gap), len(times) * (thumb_width + gap) + gap, 3), 255, np.uint8)
        for k, t in enumerate(times):
            x = gap + k * (thumb_width + gap)
            draw_text(img, x, 4, "T=%.2fS" % t, (0, 0, 0), 2)
            a = fr.get(r.index(t))
            if a is not None:
                img[top:top + th, x:x + thumb_width] = a
            if q:
                b = fq.get(q.index(t + render_offset))
                y = top + th + gap
                if b is not None:
                    img[y:y + th, x:x + thumb_width] = b
                else:
                    fill_rect(img, x, y, x + thumb_width, y + th, (60, 60, 60))
                    draw_text(img, x + 8, y + th // 2, "NO FRAME", (255, 120, 120), 2)
        draw_text(img, gap + 4, top + 4, "REF", (255, 255, 255), 2, bg=(0, 0, 0))
        if q:
            draw_text(img, gap + 4, top + th + gap + 4, label or q.name, (255, 255, 255), 2, bg=(0, 0, 0))
        write_png(out, img)
        print("wrote", out)
    
    
    def main():
        import argparse
        ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
        sub = ap.add_subparsers(dest="cmd", required=True)
        a = sub.add_parser("extract", help="reference (or any video) frames at times -> f_####.png")
        a.add_argument("--src", required=True)
        a.add_argument("--times", required=True, help="a:b:step or t1,t2,...")
        a.add_argument("--out", required=True)
        a.add_argument("--width", type=int)
        a = sub.add_parser("assemble", help="PNG sequence -> H.264 mp4 (yuv420p, crf 18)")
        a.add_argument("--seq", required=True)
        a.add_argument("--out", required=True)
        a.add_argument("--fps", type=float, default=24.0)
        a.add_argument("--crf", type=int, default=18)
        a = sub.add_parser("sbs", help="labelled side-by-side or stacked comparison video")
        a.add_argument("--ref", required=True)
        a.add_argument("--render", required=True)
        a.add_argument("--out", required=True)
        a.add_argument("--start", type=float, default=0.0)
        a.add_argument("--end", type=float)
        a.add_argument("--stack", choices=["h", "v"], default="h")
        a.add_argument("--scale", type=float, default=0.5)
        a.add_argument("--label", help="render label (default: RENDER <dir name>)")
        a.add_argument("--render-offset", type=float, default=0.0, help="render time = ref time + offset (s)")
        a = sub.add_parser("sheet", help="contact sheet: ref row over render row")
        a.add_argument("--ref", required=True)
        a.add_argument("--render")
        a.add_argument("--times", default="0:20:2.5")
        a.add_argument("--out", required=True)
        a.add_argument("--width", type=int, default=320)
        a.add_argument("--label")
        a.add_argument("--render-offset", type=float, default=0.0)
        args = ap.parse_args()
        if args.cmd == "extract":
            extract(args.src, parse_times(args.times), args.out, args.width)
        elif args.cmd == "assemble":
            assemble(args.seq, args.out, args.fps, args.crf)
        elif args.cmd == "sbs":
            side_by_side(args.ref, args.render, args.out, args.start, args.end, args.stack, args.scale,
                         labels=("REFERENCE", args.label), render_offset=args.render_offset)
        elif args.cmd == "sheet":
            contact_sheet(args.ref, args.render, parse_times(args.times), args.out, args.width, args.render_offset,
                          args.label)
    
    
    if __name__ == "__main__":
        main()
    
  • skills/procedural-blender-nodes/references/animation-and-simulation.md12 KB4480132fef9eupstream ↗
    # Animation and Simulation
    
    Motion in a node system is either **analytic** (a closed-form function of a `Time` input, valid at any frame in
    any order) or **simulated** (a simulation zone that steps state forward frame by frame). Choose deliberately: it
    decides cost, scrubbing, baking and how the effect can be verified.
    
    ## Contents
    
    - [The decision rule](#the-decision-rule)
    - [Time plumbing](#time-plumbing)
    - [Analytic recipes](#analytic-recipes)
    - [Loops](#loops)
    - [Simulation zones in 5.x](#simulation-zones-in-5x)
    - [Simulation recipes](#simulation-recipes)
    - [Verifying motion numerically](#verifying-motion-numerically)
    
    ## The decision rule
    
    | The motion needs... | Use | Examples |
    |---|---|---|
    | **state**: the present depends on the history (where it went, what it hit) | simulation | growth that spreads, a trail, a rope that settles after a hit |
    | **interaction**: pieces react to each other or to geometry that moves | simulation | collisions, pushing, piling up, avoidance |
    | **accumulation**: something builds up and stays | simulation | snow settling, leaves landing and staying, footprints, wear |
    | periodic or quasi-periodic motion | analytic | walk cycles, swinging signs, wind sway, bobbing |
    | a function of a driven quantity (distance walked, a path, a keyed speed) | analytic | planted feet, wheels rolling, a camera on a path |
    | independent particles with lifetimes | analytic | falling leaves that vanish, sparks, rain, bubbles |
    | a damped response to known events | analytic | flaps lagging the body, a sign ringing after a gust |
    
    If both fit, choose analytic: it scrubs, renders any frame alone, needs no bake, and can be tested at any time.
    State the choice and the reason in the deliverable ("no simulation zone: the reference shows nothing that needs
    stored state" is a valid finding; the storybook street is fully analytic).
    
    ## Time plumbing
    
    - Only the top (modifier) group reads **Scene Time** (`GeometryNodeInputSceneTime`, `Seconds`). Every part takes a
      `Time` input (FLOAT, seconds). Parts are then testable at any time, retimable and reusable.
    - Top group: `time = Scene Seconds * Time Scale + Time Offset` (`Time Offset` subtype `TIME_ABSOLUTE`).
    - Per-piece time: `t_i = Time + offset_i` with `offset_i` hashed from the piece id, so pieces do not move in sync.
    - Anything outside Geometry Nodes that must agree (a camera rig on a path) reads the same modifier inputs through a
      driver (`build.py` in storybook-street): `offset = (Walk Start + Walk Speed * (frame/fps * Time Scale +
      Time Offset) + along) / curve_length`. Drivers in background mode update only for `bpy.context.window.scene`.
    
    ## Analytic recipes
    
    **Integrated phase.** Never write `phase = speed * t` when speed changes: changing speed then jumps the phase.
    Integrate distance, then derive phase from distance:
    
    ```text
    s(t)     = s0 + ∫ v dt                (piecewise: constant segments add v_k * Δt_k;
                                            a linear ramp from v0 to v1 over T adds v0 τ + (v1 − v0) τ² / (2T))
    phase(t) = s(t) / stride              (gait cycles; feet are locked to distance walked)
    ```
    
    **Planted feet.** For each foot with phase offset `o` (0 left, 0.5 right): `u = phase − o`, cycle `k = floor(u)`,
    fraction `f = u − k`. During stance (`f < STANCE`) the foot sits at a fixed arc length `plant = (k + o + STANCE/2)
    * stride` (plus a toe offset), placed through the curve frame, so it is fixed in the world on any curve. During
    swing, travel from this plant to the next with smootherstep `e = w³(6w² − 15w + 10)` (zero velocity at both ends),
    lift `∝ w(1 − w)²`. See `SS Walker Step` in `prompts/storybook-street/street/groups/walker.py`.
    
    **Body motion** from the same phase: bob `−A cos(4π phase)` (twice per cycle), sway `cos(2π phase)` over the
    stance foot, arm swing opposite to the legs, twist with the arms.
    
    **Springs (secondary motion).** A damped spring with natural frequency `fn`, damping ratio `ζ`, driven by a sine
    at `fd`, has a closed-form steady state:
    
    ```text
    r = fd / fn
    gain = 1 / sqrt((1 − r²)² + (2ζr)²)
    lag  = atan2(2ζr, 1 − r²)
    response = A * gain * cos(drive_phase − lag)
    ```
    
    Flaps, scarves, antennae and hanging bags lag the body and overshoot near resonance. For a single event at time
    `t0` (a landing, a gust onset), add the impulse response
    `A e^(−ζω(t − t0)) sin(ω_d (t − t0))` for `t > t0`, `ω = 2π fn`, `ω_d = ω sqrt(1 − ζ²)`.
    
    **Wind.** A gust envelope: slow noise in time, shifted by position so gusts travel (`noise(t − lag − s / gust_speed)`),
    mapped to `max(1 + gust * n, 0)`. Each part responds with the lagged envelope (steady deflection) plus a ring at its
    own pendulum period scaled by the envelope. At small angles this linear response is accurate and needs no state.
    See `SS Bunting Wind` in storybook-street; the `motion` library module has reusable versions.
    
    **Pendulum / sign swing.** `angle = A (sin(2π t / T + φ_i) + 0.15 sin(4π t / T + 1.3))`, `T = 2π sqrt(L / g)`,
    `φ_i` hashed per piece.
    
    **Ballistic.** `z = z0 + v0 τ − g τ² / 2`; bounces lose height by restitution `e < 1` per contact; spin matches
    distance rolled (`angle = distance / radius`).
    
    **Independent particles with lifetimes** (falling leaves, sparks): `age = fract((t + φ_i) / life) * life`,
    position = spawn_i + v_i age + sway(age), fade or scale in and out near the ends of life so recycling never pops.
    
    **Wheels, tracks, conveyor textures**: angle or UV offset = distance / radius, from the same integrated `s(t)`.
    
    ## Loops
    
    A loop of length `L` seconds is seamless when every periodic term completes a whole number of cycles in `L`:
    
    - choose periods `L / n`; derive stride or speed from that instead of the reverse;
    - noise that must loop: sample 4D noise on a circle in two dimensions, `(cos 2πt/L, sin 2πt/L) * radius`, or
      use the W input of 4D noise with a periodic coordinate;
    - a translating camera or walker loops only if the world repeats: use a closed path, or a periodic layout whose
      period matches `speed * L`;
    - verify: frame 1 and frame `L*fps + 1` are identical (image difference 0, or attribute difference below 1e-5).
    
    ## Simulation zones in 5.x
    
    ```python
    zin, zout = g.zone("SIMULATION", [("VECTOR", "Velocity")])
    g.connect(points, zin.i("Geometry"))                  # initial state, read on the start frame only
    g.connect(initial_velocity, zin.i("Velocity"))
    dt = zin["Delta Time"]                                # 0 on the start frame, which still runs one step
    ...                                                   # update fields with dt
    g.connect(new_geo, zout.i("Geometry")); g.connect(new_vel, zout.i("Velocity"))
    out = zout["Geometry"]
    ```
    
    Behaviour, verified in Blender 5.2.2 (background, 24 fps):
    
    | Fact | Detail |
    |---|---|
    | Delta Time | seconds per frame, 1/fps (0.041667 at 24 fps). Use it for every rate so the result does not depend on fps |
    | First frame | the start frame already runs **one step with Delta Time 0**: the zone body is evaluated on the initial state and its output is shown. Anything scaled by Delta Time does not move, but per-step events do happen once (a step counter reads 1, a trail appends a point, `Random(id, step)` branches, Duplicate/Delete per step). Gate such events with `Delta Time > 0`, or connect `Delta Time < 1e-9` to `zout.i("Skip")` to show the untouched initial state on the start frame (test: `tests/test_motion.py::test_simulation_first_frame_runs_a_zero_dt_step`) |
    | Start frame | `scene.frame_start`, or `scene.use_custom_simulation_range` + `simulation_frame_start/end`. Before the start the initial state is shown |
    | Returning to the start frame | resets the simulation |
    | **Skipping frames** | jumping from frame 3 to 10 without a cache advanced the state by **one** step, not seven. Scripts must step every frame in order from the start (`for f in range(start, end+1): scene.frame_set(f)`) or bake first |
    | Cache | filled during playback (purple line in the timeline); edits upstream invalidate it |
    | Bake | `mod.bakes[i]` per zone; after `geometry_node_bake_single` every frame is exact in any order (verified) |
    | Skip | `zout.i("Skip")` true passes the state through unchanged (pause a simulation) |
    | Inputs | nodes linked into the zone input are evaluated once at the start; nodes inside are evaluated every step |
    
    Rules:
    
    - Render or hand off a simulation only baked. Say so in the deliverable, and say which controls need a re-bake.
    - Keep the state small: the attributes you need, not the whole visual geometry. Build the visual mesh after the
      zone from the state (instancing leaves on a trail after the zone, not inside it).
    - Stability: explicit integration of a spring needs `ω dt < ~0.5`. For stiff springs or constraints, substep with a
      repeat zone inside the simulation zone (`dt_sub = dt / n`).
    - Seed every random choice inside the zone with the step (Scene Frame or an accumulated step counter) plus the id,
      or every step repeats the same choice.
    - 5.2 adds an experimental physics system (Cloth and Hair Dynamics assets, an XPBD Solver node, colliders and
      effectors). Use it for cloth and hair that must collide; label it experimental; it is not wrapped by nodekit.
    
    ## Simulation recipes
    
    **Growth over a mesh** (ivy, moss, lichen). State: tip points with `direction`, `age`, `alive`; a trail point
    cloud. Each step: move tips by `speed * dt` along `direction` (blended toward the surface tangent and a noise
    field), snap to the surface with Sample Nearest Surface, append the old tip positions to the trail (Join), branch:
    duplicate tips where `Random Value(id, step) < rate * dt`, kill tips that age out or leave the surface. After the
    zone: Points to Curves or Curve Line segments for stems, leaves instanced on the trail by age. Template:
    `growth-sim`.
    
    **Spring settle** (a sign hit by a gust, a curtain). State: position, velocity per point.
    `v += (−k (x − rest) − c v + F) dt; x += v dt` (semi-implicit Euler), pinned points keep `x = rest`.
    
    **Accumulation** (snow, leaves that land). Analytic fall until contact (Raycast down from each flake); on contact,
    mark `landed` and store the position in the state; landed items stay.
    
    **Verlet rope.** State: positions and previous positions on a curve; each step `x' = 2x − x_prev + a dt²`, then a
    repeat zone of distance-constraint passes along the curve.
    
    ## Verifying motion numerically
    
    Judge motion as numbers and sequences, never one still.
    
    - **Sample attributes over time.** Store a probe attribute (`nk_probe` on the foot, root, a sign tip) and read it
      from the evaluated mesh at every frame:
    
    ```python
    vals = []
    for f in range(1, 49):
        sc.frame_set(f)
        me = ob.evaluated_get(bpy.context.evaluated_depsgraph_get()).data
        vals.append([v.co.copy() for v, p in zip(me.vertices, me.attributes["nk_probe"].data) if p.value > 0.5])
    ```
    
    - **Planted feet**: during stance the foot's world position changes by less than 1e-4 units per frame.
    - **Continuity**: first differences (velocity) have no spikes; second differences (acceleration) spike only at
      physical events (contact, takeoff). Flag any jump above 3× the median.
    - **Phase continuity across speed changes**: phase is monotonic and its derivative equals speed / stride.
    - **Springs settle**: after a step input the response decays by `e^(−ζω t)`; check the overshoot and settle time.
    - **Bounded wind**: maximum angle over a long run stays within the stated amplitude.
    - **Loops**: frame 1 equals frame `L*fps + 1`.
    - **Simulation determinism**: bake twice, compare states at several frames (identical); step without a bake from
      the start frame and compare with the bake.
    - Put these checks in a property test (`tests/test_motion.py` pattern) or a project script, and report the
      numbers.
    
  • skills/procedural-blender-nodes/references/blender-api-notes.md8.8 KB696c545b96b2upstream ↗
    # Blender 5.2 API Notes
    
    Gotchas met while building node systems from Python in Blender 5.2 (5.2.2 LTS). Items marked **verified** were
    checked in 5.2.2 with `blender -b --factory-startup`; the others come from building the library and
    storybook-street. Check `bpy.app.version_string` first: these notes assume 5.2+.
    
    ## Running Blender
    
    - Always `blender -b --factory-startup --python <script> -- <args>`. Arguments after `--` are the script's;
      pass negative values as `--values=-1,3`, `--cam=-2,0,1:...` (argparse reads `-1` as a flag).
    - `nodekit.scene.reset()` (`read_factory_settings(use_empty=True)`) frees every data-block. Build node groups
      **after** it; a tree built before raises `ReferenceError: StructRNA of type GeometryNodeTree has been removed`
      (verified). The `once` cache detects freed trees and rebuilds.
    - Drivers update only for the window's scene in background mode: set `bpy.context.window.scene = sc` before
      `frame_set`, and render with `bpy.ops.render.render(scene=sc.name)`.
    - `mod.execution_time` reads 0 in background mode (verified); time `scene.frame_set()` yourself.
    - zsh: a loop variable named `path` overwrites `$PATH`.
    
    ## Modifiers and group interfaces
    
    - Modifier inputs are RNA properties, not ID properties (5.2 release notes; verified): use
      `mod.properties.inputs.<identifier>.value` and `.attribute_name`, or `nodekit.inspect.set_modifier_inputs(mod,
      {"Name": v})`. `mod["Socket_2"]` raises `TypeError: this type doesn't support IDProperties`.
    - `mod.node_warnings` lists evaluation warnings (`type`, `message`) after the object is evaluated at a frame.
    - Group-level modifier flag: `node_group.is_modifier = True`.
    - Empty objects can carry Geometry Nodes modifiers in 5.2 (verified); `nodekit.scene.holder` still uses an empty
      mesh object, which works in every version.
    - Interface sockets: `subtype` is validated per type; float subtypes are `NONE PIXEL PERCENTAGE FACTOR MASS ANGLE
      TIME TIME_ABSOLUTE DISTANCE WAVELENGTH COLOR_TEMPERATURE FREQUENCY` (verified). Other item properties:
      `hide_in_modifier`, `force_non_field`, `structure_type` (`AUTO DYNAMIC FIELD GRID LIST SINGLE`), `default_input`
      (`VALUE INDEX ID_OR_INDEX NORMAL POSITION INSTANCE_TRANSFORM HANDLE_LEFT HANDLE_RIGHT SCENE_FRAME
      UNIFORM_IMAGE_COORDINATES SELF_OBJECT`), `attribute_domain` (verified). Panels have `default_closed`.
    - A shared group rebuilt by a second module clears its interface and silently unlinks every earlier user; shared
      builders are build-once (`@nodegroup` does this; `nodekit.graph.once` for anything else).
    
    ## Nodes and sockets
    
    - Look sockets up by **name among enabled sockets** (`find_socket`). Identifiers are unstable: 5.2 changed the
      socket identifiers of Compare and Random Value (release notes), and zone items are `Item_0`, `Generation_0`, ….
    - `ShaderNodeMix` has several sockets named A/B (one pair per data type); `Graph.mix` and `find_socket` pick the
      enabled ones. Set `data_type` through `props` before linking.
    - Many former node properties are menu **input sockets** in 5.x: Transform's mode, Set Curve Normal's mode,
      Resample Curve's `Mode` (`"Count"`, `"Length"`), Remove Named Attribute's `Pattern Mode` (`"Wildcard"`),
      compositor Glare `Type`/`Quality`, Set Alpha `Type` (`"Replace Alpha"`), Alpha Over `Type` (verified for the
      compositor ones). Set them like values: `Mode="Length"`.
    - Texture outputs are displayed as `Factor` (identifier `Fac`) in 5.x, in both Geometry and Shader Nodes
      (verified for Noise and Wave); `find_socket` accepts either. Voronoi outputs `Distance`, `Color`, `Position`.
    - Sockets whose default is an implicit field (Extrude Mesh `Offset` = normal, Set Position `Position`) ignore
      constant defaults in some nodes; link a node (Combine XYZ, Value) instead of setting `default_value`.
    - Mesh Boolean solvers: `EXACT`, `FLOAT`, `MANIFOLD` (verified). Manifold does not reliably keep attributes;
      recover ids from positions after cutting (`geometry-nodes-patterns.md`).
    - Realize Instances inputs: `Geometry`, `Selection`, `Realize All`, `Depth` (verified).
    - Instance-domain attributes land on the **POINT** domain after Realize Instances (verified).
    - Remove Named Attribute on instances also strips nested instances' attributes; remove on points first.
    - Extrude Mesh on a bent grid shades with grid artefacts in EEVEE; use a flipped back sheet instead.
    
    ## Zones
    
    - Simulation zone: input node output `Delta Time` (1/fps; verified 0.041667 at 24 fps); output node input `Skip`;
      state in `state_items`. Repeat zone: `Iterations` input, `Iteration` output, `repeat_items`. For Each Element:
      input node `Geometry`, `Selection`, output `Index`; items in `main_items` (per element), `input_items`,
      `generation_items`; the generated geometry is the output's second `Geometry` (`"Geometry#1"`) (verified).
    - For Each over an empty selection gives an INFO warning; guard with a Switch on Domain Size > 0.
    - Simulation without a cache: **jumping frames advances one step** (frame 3 → 10 added one Delta Time, verified);
      returning to the start frame resets. Step every frame in order from the start, or bake.
    - Scene simulation range: `scene.use_custom_simulation_range`, `simulation_frame_start`, `simulation_frame_end`;
      before the start the initial state is shown (verified).
    - Bakes: `mod.bakes[i]` with `bake_id`, `bake_mode`, `bake_target` (`INHERIT` → `mod.bake_target`, default
      `PACKED`), `frame_start/end`, `use_custom_simulation_frame_range`, `directory`. Operators
      `object.geometry_node_bake_single` / `geometry_node_bake_delete_single` / `geometry_node_bake_pack_single` /
      `geometry_node_bake_unpack_single` take `session_uid`, `modifier_name`, `bake_id`; run them under
      `temp_override(object=ob, active_object=ob, selected_objects=[ob])` (verified). `object.simulation_nodes_cache_bake`
      bakes all simulations of the selection.
    
    ## Evaluation and dependencies
    
    - Any Scene Time node in a modifier's tree makes it re-evaluate on **every** frame change, even when its value is
      multiplied by 0 (verified: 0.13 s per frame vs 0 s for the same static graph). Only the top group should read it.
    - `obj.evaluated_get(dg).to_mesh()` (and `inspect.evaluated_stats`) sees only the mesh component: point clouds
      (Points node, Distribute Points output not converted) and unconverted curves are not counted (verified).
    - Read evaluated attributes with `ev.data.attributes[name].data[i].value` after `scene.frame_set(f)`.
    
    ## Materials and rendering
    
    - New materials and worlds already have node trees; `Material.use_nodes` is deprecated and scheduled for removal
      in 6.0 (verified: DeprecationWarning). `nodekit.materials.material()` still sets it; harmless for now.
    - A shader Attribute node returns 0 for a missing attribute: store terms where 0 means "no effect". Types:
      `GEOMETRY OBJECT INSTANCER VIEW_LAYER` (verified).
    - EEVEE's engine id is `BLENDER_EEVEE` (renamed from `BLENDER_EEVEE_NEXT` in 5.0). `CYCLES` is available with
      factory settings (verified).
    - View transforms include `AgX`, `Standard`, `Filmic`, `Khronos PBR Neutral`, `ACES 2.0`; AgX looks are prefixed:
      `"AgX - Base Contrast"`, `"AgX - Punchy"`, … (verified; `"Base Contrast"` raises).
    - Sky Texture types: `SINGLE_SCATTERING MULTIPLE_SCATTERING PREETHAM HOSEK_WILKIE` (verified).
    - World volumes swallow the sun in EEVEE; do distance haze in the compositor (mist pass, premultiplied).
    
    ## Compositor (5.x)
    
    - The compositor tree is a node group: `scene.compositing_node_group = tree` (`CompositorNodeTree`). There is no
      `CompositorNodeComposite` (verified); the Group Output's first Color input is the result. `Viewer` still exists.
    - Alpha Over inputs: `Background`, `Foreground`, `Factor`, `Type`, `Straight Alpha` (verified).
    - Image Coordinates (`Uniform`, `Normalized`, `Pixel`) needs an image input to know the size.
    - Mix also blends alpha; restore the render's alpha with Set Alpha (`Type` = `"Replace Alpha"`).
    - Render Layers outputs `Image`, `Alpha` plus enabled passes (`Mist` after `view_layer.use_pass_mist = True`).
    - `nodekit.graph.Graph.group` creates a `ShaderNodeGroup` for any non-geometry kind, so it cannot nest compositor
      groups.
    
    ## 5.2 features worth knowing
    
    From the 5.2 release notes (not all used by the library yet): Lists (Field to List, Get List Item, Filter/Sort
    List), Geometry Bundles (Set/Get Geometry Bundle), recursive Closures, Collection Children, Mesh Bevel node,
    Capture Attribute with selection, Rename Attribute / Get Attribute Names / Transfer Attributes, String fields,
    Scene Frame as a group-input default, Object inputs defaulting to the self object, and the experimental physics
    system (Cloth Dynamics, Hair Dynamics, XPBD Solver, colliders and effectors). Mark anything built on the physics
    system as experimental.
    
    ## Adding to this list
    
    When a Blender API fact surprises you, verify it with a minimal `blender -b --factory-startup --python-expr ...`
    probe, then add it here with "(verified)" and the version.
    
  • skills/procedural-blender-nodes/references/controls-and-presets.md8.4 KBb63dddb3b1c8upstream ↗
    # Controls and Presets
    
    The modifier panel is the product. An artist who never opens the node editor should be able to get every
    intended variation from it, and an artist who opens it should find each control wired to something visible.
    
    ## Contents
    
    - [What to expose](#what-to-expose)
    - [Declaring a good input](#declaring-a-good-input)
    - [Panels](#panels)
    - [The Primary panel: macro controls](#the-primary-panel-macro-controls)
    - [Forwarding part inputs to the top group](#forwarding-part-inputs-to-the-top-group)
    - [Naming and collisions](#naming-and-collisions)
    - [Structure vs parameters](#structure-vs-parameters)
    - [Presets as JSON](#presets-as-json)
    - [Proving each control matters: sweeps](#proving-each-control-matters-sweeps)
    
    ## What to expose
    
    Expose a control when an artist would plausibly want to change it **and** it changes the result visibly. Keep the
    rest as constants in the builder.
    
    | Expose | Keep internal |
    |---|---|
    | sizes and proportions (width, storeys, sag) | epsilon offsets, z-fighting nudges |
    | densities and chances (prop chance, flowers per m²) | derived values (spacing from count and length) |
    | style choices (roof style as a Menu, timber pattern) | node-count parameters with one correct value |
    | palette colours | normalisation constants |
    | motion amounts (wind, walk speed, swing) | integration constants, stance fraction (unless a gait control) |
    | seed, input object, time scale/offset, visibility toggles | anything that breaks the graph outside a narrow range |
    
    ## Declaring a good input
    
    ```python
    g.input("Sag", "FLOAT", 0.25, min=0.0, max=2.0, subtype="DISTANCE", panel="Strings",
            desc="How far each string hangs below the straight line between its anchors")
    ```
    
    - **Description**: what it does in the image, in the artist's words, with the reference value if there is one
      ("1 = the reference's light breeze; 4–6 = a windy day"). Lint fails without one.
    - **Range**: `min`/`max` that keep the graph valid. A range the graph cannot survive is a bug: either clamp
      internally or narrow the range. Test both ends.
    - **Subtype**: `DISTANCE`, `ANGLE`, `FACTOR` (0..1 slider), `PERCENTAGE`, `TIME`, `TIME_ABSOLUTE`; vectors `XYZ`,
      `TRANSLATION`, `EULER`, `DIRECTION`. Units then show correctly.
    - **Type**: INT for counts and storeys, BOOL for toggles, MENU (fed into a Menu Switch) for named styles, COLOR for
      palette, OBJECT/COLLECTION for inputs, never a FLOAT that is rounded internally.
    - **Seed**: one `Seed` INT per top group; internal decisions use `Seed + k` with a distinct `k` per decision.
    
    ## Panels
    
    Group inputs into panels named for what an artist thinks about: `Street`, `Houses`, `Windows`, `Palette`, `Wind`,
    `Visibility`. Declaration order is display order. Collapse detail panels by default
    (`g._panels[name].default_closed = True`).
    
    ## The Primary panel: macro controls
    
    A large generator has hundreds of detailed controls (storybook street: about 180). Put a **Primary** panel first
    with 6–10 macro controls, each of which scales several detailed ones. The detailed controls stay available and
    are combined with the macro:
    
    ```python
    PRIMARY = [
        # name, type, default, min, max, description, [(part, detailed input, op)]
        ("Prop Amount", "FLOAT", 1.0, 0.0, 2.0, "Scales every prop chance",
         [("Props", "Window Box Chance", "MULTIPLY"), ("Props", "Awning Chance", "MULTIPLY")]),
        ("Extra Storeys", "INT", 0, -2, 4, "Taller or lower houses",
         [("Houses", "Storeys Min", "ADD"), ("Houses", "Storeys Max", "ADD")]),
        ("Festivity", "FLOAT", 1.0, 0.2, 3.0, "More bunting strings",
         [("Bunting", "String Spacing", "DIVIDE")]),
    ]
    # when forwarding a detailed input that is a macro target:
    src = g.math(op, detailed_input_socket, macro_socket, label=f"{name} {op.lower()} {macro}")
    ```
    
    - Neutral macro values (1 for MULTIPLY/DIVIDE, 0 for ADD) reproduce the defaults exactly, so the reference look is
      the macro defaults.
    - A macro must change the image visibly at both ends of its range (prove with a sweep).
    - Put the input object (`Path`), `Seed` and the one or two most important direct controls (`Walk Speed`) in Primary
      too.
    
    ## Forwarding part inputs to the top group
    
    Each part is a group usable alone; the top group instances every part and forwards its inputs to the modifier.
    Pattern (from `prompts/storybook-street/street/groups/assembly.py`):
    
    ```python
    for it in part_tree.interface.items_tree:
        if it.item_type != "SOCKET" or it.in_out != "INPUT":
            continue
        sock = next(s for s in node.b.inputs if s.identifier == it.identifier)
        if it.name == "Curve":   g.connect(path_geometry, sock)       # shared wiring
        elif it.name == "Time":  g.connect(time, sock)
        elif it.name in shared:  g.connect(shared[it.name], sock)     # Seed, Street Width, ...
        else:                                                          # forward with the same spec
            panel = f"{label} > {it.parent.name}" if it.parent and it.parent.name else label
            src = g.input(name, stype, it.default_value, min=it.min_value, max=it.max_value,
                          subtype=it.subtype, desc=it.description, panel=panel)
            g.connect(src, sock)
    ```
    
    - Forwarded panels are `"<Part> > <Panel>"`, so the modifier reads like the part structure.
    - Shared inputs (one `Seed`, one `Street Width`) are declared once on the top group and wired to every part that
      has an input with that exact name. Agree the shared names before building parts.
    - Forward the description, range and subtype; drop subtype `NONE`.
    - Geometry inputs between parts (`Anchors`) are wired, not exposed.
    
    ## Naming and collisions
    
    - Names must be unique on one group interface. When two parts both have `Color`, forward as `"<Part> Color"`, or
      better, make the part names unambiguous at the source (`Awning Color`, `Shutter Color`): presets and sweeps
      address controls by name.
    - Names are API: renaming a control breaks presets, sweeps, drivers and docs. Rename deliberately and update them.
    - Groups: library `NK <Name>`, project groups with a project prefix (`SS <Name>`), sub-groups `<Prefix> <Name>
      <Part>`. Materials the same.
    
    ## Structure vs parameters
    
    | Kind | What | Where it changes | Reviewed as |
    |---|---|---|---|
    | **Structure** | which parts exist, how they connect, which inputs exist | Python builders only | code + regenerated `GRAPH.md` |
    | **Parameters** | default values, ranges, presets, light, camera | builder defaults, `presets/*.json`, `light.json`, build script | value diffs |
    
    Fitting to a reference (exposure, sun, densities, sizes) touches **parameters only**, so it can never break reuse
    on other inputs. If matching a reference seems to need a structural special case ("house 7 is taller"), it is a
    parameter distribution instead (a storeys range and a chance), or it is not reproduced.
    
    ## Presets as JSON
    
    A preset is a flat `{control name: value}` over the top group's inputs. Colours are linear RGBA lists.
    
    ```json
    { "Festivity": 2.4, "Wind": 4.0, "Flower Amount": 2.2, "Seed": 3 }
    ```
    
    ```python
    inspect.set_modifier_inputs(mod, json.loads(pathlib.Path("presets/festival.json").read_text()))
    ```
    
    - `{}` is the reference preset: the defaults are the reference.
    - Presets touch mostly Primary controls; a preset that needs 30 detailed values means a macro is missing.
    - Render presets with `scripts/render.py --preset <path or name> --set "Seed=7"`. Validate every preset
      (`validate.py` after applying, or a build per preset) for warnings and camera clearance.
    - Store presets next to the build script (`presets/`), one file each, numbered for the gallery order.
    
    ## Proving each control matters: sweeps
    
    A control that does nothing visible, or only breaks things, should not be exposed.
    
    ```bash
    blender -b my_effect.blend --python scripts/sweep.py -- --control "Timber Amount" \
        --values 0,1.7 --frames 48 --at 169 --out out/sweep/timber_
    ```
    
    - Sweep every Primary control over its full range at a frame where its effect is visible; look at the first and
      last frames side by side, and play the sweep for discontinuities (sudden jumps mean a hard threshold that should
      be a smooth chance or a hashed per-piece threshold).
    - Smooth sweeps: per-piece decisions compare a hashed `nk_rand` against the control (`rand < chance`), so raising
      the chance adds pieces one at a time instead of reshuffling all of them. Keep layout seeds independent of chance
      controls, so changing a chance never moves unrelated pieces.
    - Spot-check detailed controls by group: each panel's controls at min and max on a small test render.
    - Record sweeps in the deliverable (a start/end contact sheet per control).
    
  • skills/procedural-blender-nodes/references/geometry-nodes-patterns.md11 KB3a8edc7060daupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • skills/procedural-blender-nodes/references/library-index.md13 KB0b7c9352fb2bupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • skills/procedural-blender-nodes/references/materials-and-look.md8.4 KB26d729e7b382upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • skills/procedural-blender-nodes/references/nodekit-guide.md14 KB66bc4da3a229upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • skills/procedural-blender-nodes/references/subject-routing.md11 KB2eb2164a22fdupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • skills/procedural-blender-nodes/references/validation-and-review.md7.8 KB408514809f02upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • skills/procedural-blender-nodes/agents/openai.yaml373 B8195cbb66686upstream ↗
    interface:
      display_name: "Procedural Blender Nodes"
      short_description: "Geometry/Shader/compositor node systems built from Python with a tested node-group library"
      brand_color: "#E87D0D"
      default_prompt: "Use $procedural-blender-nodes to build a procedural Geometry Nodes effect with artist controls, saved as a .blend by a reproducible build script and validated."
    
  • skills/procedural-blender-nodes/library/__init__.py741 B9cd1b6aac26aupstream ↗
    """Reusable node-group builders. Import order is dependency order: a module may only build groups from modules
    listed before it (checked by `nodes_tools.py check`)."""
    
    MODULE_ORDER = [
        "curve",       # curve frames, bending flat layouts onto a path, samples and parcels along a path
        "shapes",      # small parametric meshes: rounded/chamfered boxes, rods, rings, leaves, blossoms, foliage
        "scatter",     # distributing instances on surfaces and along curves
        "motion",      # analytic animation: springs, gait steps, wind gusts, pendulums, time remaps
        "materials",   # shader node groups: attribute tints, stucco, wood, stone, felt, terracotta, stripes, glow
        "post",        # compositor groups: haze over sky, glow
    ]
    
  • skills/procedural-blender-nodes/library/curve.py26 KBe3b347e1a7f3upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • skills/procedural-blender-nodes/library/materials.py42 KBa10098c6aa58upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • skills/procedural-blender-nodes/library/motion.py21 KB82063d86a89eupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • skills/procedural-blender-nodes/library/post.py6.1 KBcbdea45f3534upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • skills/procedural-blender-nodes/library/scatter.py23 KB79dc3e4d690bupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • skills/procedural-blender-nodes/library/shapes.py39 KB403d34bac4ceupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • skills/procedural-blender-nodes/nodekit/__init__.py111 Beb29b81b23f0upstream ↗
    """nodekit: build native Blender node graphs (Geometry, Shader, Compositor) from Python and read them back."""
    
  • skills/procedural-blender-nodes/nodekit/graph.py17 KB79f0bed096c0upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • skills/procedural-blender-nodes/nodekit/inspect.py7.5 KBf4ee700c8cabupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • skills/procedural-blender-nodes/nodekit/library.py2.2 KB853583e0281cupstream ↗
    """The node-group library registry.
    
    Every reusable group in `library/` is a builder function decorated with `@nodegroup`. The decorator records its
    metadata (for `nodes_tools.py search` / `index`, which read it statically with `ast`, without Blender) and makes
    the builder build-once-per-session (rebuilding a group that other groups already instance would unlink them).
    
        from nodekit.graph import Graph
        from nodekit.library import nodegroup
    
        @nodegroup("NK Curve Frame", kind="GEOMETRY", tags=["curve", "placement"],
                   use="streets, fences, rails, anything placed along a path")
        def curve_frame():
            '''(s, d, h) along a curve to world position and orientation.'''
            g = Graph("NK Curve Frame", description=curve_frame.__doc__.strip().splitlines()[0])
            ...
            return g.finish()
    
    Rules (checked by `nodes_tools.py check`):
    - the group name passed to the decorator equals the Graph name, and starts with "NK ";
    - the docstring's first line is a one-sentence summary; `use` names concrete subjects;
    - a builder may call builders only from modules listed before its own in library/__init__.py MODULE_ORDER.
    """
    from nodekit.graph import once
    
    REGISTRY = {}          # name -> metadata dict (filled when a library module is imported)
    
    
    def nodegroup(name, kind="GEOMETRY", tags=(), use=""):
        def deco(fn):
            built = once(fn)
            built.nodegroup = {"name": name, "kind": kind, "tags": list(tags), "use": use,
                               "summary": (fn.__doc__ or "").strip().splitlines()[0] if fn.__doc__ else "",
                               "module": fn.__module__, "builder": fn.__name__}
            REGISTRY[name] = dict(built.nodegroup, build=built)
            return built
        return deco
    
    
    def build_all(modules=None):
        """Import every library module (in MODULE_ORDER) and build every registered group. Returns {name: tree}."""
        import importlib, importlib.util
        from library import MODULE_ORDER
        for m in modules or MODULE_ORDER:
            if importlib.util.find_spec(f"library.{m}") is not None:     # modules not written yet are skipped
                importlib.import_module(f"library.{m}")
        return {name: meta["build"]() for name, meta in REGISTRY.items()}
    
  • skills/procedural-blender-nodes/nodekit/materials.py846 B2416d6ee6d1eupstream ↗
    """Shared material helpers. Owner-specific materials live in street/materials/<owner>.py."""
    import bpy
    
    
    def material(name):
        """Get-or-create a node material named ``name`` with an emptied tree (rebuilt on every build)."""
        m = bpy.data.materials.get(name) or bpy.data.materials.new(name)
        if m.node_tree is None:                  # Blender 5.x materials always use nodes; use_nodes is deprecated
            m.use_nodes = True
        m.node_tree.nodes.clear()
        return m
    
    
    def flat(name, rgb, roughness=0.8):
        m = material(name)
        nt = m.node_tree
        b = nt.nodes.new("ShaderNodeBsdfPrincipled")
        b.inputs["Base Color"].default_value = (*rgb, 1)
        b.inputs["Roughness"].default_value = roughness
        o = nt.nodes.new("ShaderNodeOutputMaterial")
        o.location = (300, 0)
        nt.links.new(b.outputs[0], o.inputs[0])
        return m
    
  • skills/procedural-blender-nodes/nodekit/scene.py9.9 KB4c8b170b48beupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • skills/procedural-blender-nodes/templates/curve_fence.py34 KB4ec29e77e477upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • skills/procedural-blender-nodes/templates/growth_sim.py64 KB090effa430f9upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • skills/procedural-blender-nodes/templates/parametric_house.py66 KB5346de6d4b93upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • skills/procedural-blender-nodes/templates/surface_scatter.py44 KB00991d142388upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • skills/procedural-blender-nodes/templates/wind_bunting.py29 KBc021433676c7upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • README.mdupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

Plain-text copy, no login: /skill-bundles/gokayfem-video-to-blender-skills-procedural-blender-nodes.md

// REPO STATS

59 stars