# blender-forest

Adopt this skill: https://proskills.md/skills/other/mromidev-claude-blender-skills-skills-blender-forest

Page: https://proskills.md/skills/other/mromidev-claude-blender-skills-skills-blender-forest
This document is the public skill bundle for that page. It contains SKILL.md and every helper file the catalog carries. No login is required.

## File: skills/blender-forest/SKILL.md
size: 7658
sha: a24b954d514fdc38c5ebbaefb699569d981fb028
upstream: https://github.com/mromidev/claude-blender-skills/blob/main/skills/blender-forest/SKILL.md

```text
---
name: blender-forest
description: Make an ultra-realistic 3D environment in Blender with Claude (a forest, a nature scene, a car driving through it) by following a long YouTube tutorial instead of writing a prompt - Claude watches the tutorial, writes the shot plan, checks the downloaded assets for anything unsafe, builds the world in stages with its own scatter system, rigs the car itself, measures the camera moves from the reference frames, benchmarks the render settings and renders overnight. Use when someone wants a realistic environment / forest / landscape / car-in-nature shot in Blender with Claude, wants to "recreate a tutorial scene", or asks for any single step (scatter, gravel path, car rig, render benchmark, clay preview).
---

# Ultra-realistic environment from a tutorial

The workflow behind a 13-second shot of a Porsche driving through a sunlit forest (2560x1440, 390 frames). The big idea:
**don't ask Claude for a scene. Give it a long tutorial and let it follow the tutorial step by step.** Claude writes the code and
places the cameras. The tutorial's free assets and open-source tools make the models. Every script in `scripts/` was actually run
for that shot. Names inside them (`forest.blend`, his material and collection names) come from that scene - rename them for yours.

## Rules for Claude

- Do not build 3D models or animation from scratch. Use the free assets that come with the tutorial, or open-source ones.
- You are good at code and cameras: scripts, scatter systems, camera moves, render settings. Do those.
- Write your own code. Do not install add-ons or run scripts you did not read. (The paid Geo-Scatter add-on and the Rig-A-Car add-on
  were both replaced by our own code.)
- After every stage, render a small test still and put it next to the reference frame. Fix what is off before moving on.
- Hand render-and-compare loops to a cheaper sub-agent (Sonnet) with a tight brief and read only its short report. Saves tokens.
- Tell me how long a render will take before you start it. Keep the PC usable: below-normal priority, 8 threads, texture limit 2048
  for tests.

## 0. Setup

- Blender 5.x (made on 5.2), an NVIDIA GPU (OptiX), Python 3, `ffmpeg`.
- Project folder `~/blender-forest` (the scripts use it as `ROOT`): `scene/forest.blend`, `assets/`, `research/`, `render/`.
- Background runs: `blender -b scene/forest.blend --python scripts/<script>.py`. For a live session, use the bridge from the
  `blender-film` skill (`tools/bridge.py` + `tools/send.py`).

## 1. Pick the tutorial

A long (1 h+) Blender environment tutorial that gives its assets away for free. Then say:
"Go watch his video. We are going to recreate his scene exactly as he did."

## 2. Claude watches it

- Download the transcript and read all of it.
- Capture the first 30 s at 30 fps, build contact sheets, detect the cuts (`notes/camera_moves.md` is what that analysis looks like).
- Write `PLAN.md`: every shot, its camera move and its length, plus the build stages (`notes/PLAN.md`).
- YouTube may block command-line downloaders with a bot check. Then grab frames and the transcript through the browser.

## 3. Assets and safety

- Download every asset, unpack each archive into its own folder. Run a virus scan on the downloads.
- List each archive for `.exe .bat .ps1 .py .dll` before unpacking.
- Open every .blend headless with `--disable-autoexec`. `scripts/audit_blend.py` counts embedded scripts, drivers and linked
  libraries: expect 0. `scripts/inspect_assets.py` lists what is inside, `scripts/check_missing.py` finds missing textures.
- Write the results to `SECURITY.md` (`notes/SECURITY.md` is the real one).

## 4. Build in stages

One script per stage, resumable, save the .blend after each. Rebuild order: `build_world` -> `scatter` -> `build_path`.

- `build_world.py`: terrain, ground material, HDRI world with a light-path split (the HDRI lights the scene at one strength and is
  seen by the camera at another), sun, colour management. His numbers where he gives them.
- `layout.py`: shared layout (clearing, where the path runs) that every later stage reads.
- `scatter.py`: our own Geometry Nodes scatter instead of a paid add-on. One layer table with his densities and scales.
  560k to 730k instances, built in about 15 s, because everything is an instance.
- `build_path.py`: gravel ribbon + pad from the layout spline, his gravel material with a cleaner wobbly middle band, a transparent
  edge fade into the ground, leaf litter riding on the path.
- `camset.py` places a look-dev camera from `CAM="x,y,height,yaw,pitch,lens"`; `test_still.py` renders a 960x540 still.
  32 samples is fine for layout, but the denoiser smears gravel: judge textures at 128 samples.

## 5. Pivot when something looks dull

The tutorial's cabins looked dull in our build, so we dropped them and switched to his car clip (19-32 s, 390 frames, 4 shots).
Same world, new target. Don't polish something that doesn't work.

## 6. The car

`build_car.py`: his Porsche model + **our own rig** instead of the add-on. The car follows the path, the wheels spin with the
distance travelled, the front wheels steer into the curve, the body bounces slightly. `car.json` holds the path and speed.
Part names the rig expects: `mycar_body`, `mycar_wheel.Ft.R`, `mycar_wheel.Ft.L`, `mycar_wheel.Bk.R`, `mycar_wheel.Bk.L`,
`mycar_wheelbrake.Ft.R`, `mycar_wheelbrake.Ft.L`, `mycar_wheelbrake.Bk.R`, `mycar_wheelbrake.Bk.L`.

## 7. The camera shots

- Capture every frame of the target clip. Have a sub-agent measure each shot: camera height, lens, fisheye, speed, cut times
  (`notes/clip-analysis.md` is the full measurement).
- `build_shots.py` builds every shot from `shots.json`: start and end camera, lens, f-stop, focus, easing, shake, and one sun
  angle per shot. The 4 shots: a slow truck through leaves, a locked wide with the car crossing, a camera rigid on the car's rear
  corner with a fisheye, a top-down follow.

## 8. Look pass

`look.py` is driven entirely by `look_params.json`: haze, sun dapples, depth of field, motion blur, colour, denser ground cover.
Change numbers in the JSON, not the script.

## 9. Render

- `bench.py` renders the same frames with different Cycles settings and times them. Result: **128 samples + OpenImageDenoise looks the
  same as 256 samples and is about 45% faster (51 s -> 28 s a frame).**
- Persistent data on, so the forest is built once and stays in GPU memory between frames.
- `render_final.py` / `render_final.cmd`: 2560x1440 PNG sequence, resumes after a power cut (empty placeholder frames are removed
  first). 390 frames took about 3 hours overnight.
- `watch_render.ps1` shows frames done, speed and time left. `stay_awake_encode.ps1` keeps the PC awake (no settings changed) and makes
  the MP4 at the end. `screen_guard.ps1` keeps the monitor off while nobody is using the PC.
- Preview first: `render_clay.py` renders a grey clay (Workbench) version of the whole clip in about 4 minutes. `preview.py` makes a
  fast low-quality animation preview to check timing and cuts.

## 10. Sound

The render is silent. Real recorded CC0 sound effects (BigSoundBank) sounded better than generated ones: ambience, the car passing,
onboard sound, whooshes. `gen_sfx.py` can generate effects with your own ElevenLabs key (`ELEVENLABS_API_KEY`) if you want that.

## Start prompt

"Go watch this video: [tutorial link]. We're going to recreate his scene exactly on my GPU. Capture the transcript and the first
30 seconds at 30 fps, write PLAN.md, then tell me which assets to download. Follow the rules in the blender-forest skill."

License: MIT. Made by Mr omi with Claude.
```

## File: skills/blender-forest/scripts/audit_blend.py
size: 918
sha: 1eea931b42b1abc74be2edf317eea27bd8c55ead
upstream: https://github.com/mromidev/claude-blender-skills/blob/main/skills/blender-forest/scripts/audit_blend.py

```text
import bpy
print("##FILE", bpy.data.filepath)
print("##TEXTS", [(t.name, len(t.as_string())) for t in bpy.data.texts])
drv=[]
for coll in (bpy.data.objects,bpy.data.materials,bpy.data.node_groups,bpy.data.scenes,bpy.data.worlds,bpy.data.meshes,bpy.data.shape_keys,bpy.data.cameras,bpy.data.lights,bpy.data.armatures):
    for d in coll:
        ad=getattr(d,'animation_data',None)
        if ad:
            for f in ad.drivers:
                if f.driver.type=='SCRIPTED': drv.append((d.name,f.data_path,f.driver.expression[:80]))
        nt=getattr(d,'node_tree',None)
        if nt:
            for n in nt.nodes:
                if n.bl_idname=='ShaderNodeScript': drv.append((d.name,'OSL script node',n.name))
print("##SCRIPTED_DRIVERS", len(drv), drv[:10])
print("##ADDONS_IN_FILE n/a; libraries:", [l.filepath for l in bpy.data.libraries])
print("##OBJ", len(bpy.data.objects), "MESHES", len(bpy.data.meshes))
```

## File: skills/blender-forest/scripts/bench.py
size: 2608
sha: 340000ddd17907b7797c0be1cd4074cf2d57a796
upstream: https://github.com/mromidev/claude-blender-skills/blob/main/skills/blender-forest/scripts/bench.py

```text
"""Render-settings benchmark: same frames, different Cycles settings, timed per frame.

  BENCH='{"samples":128,"denoiser":"OPENIMAGEDENOISE"}' blender -b scene/forest.blend \
      --python scripts/look.py --python scripts/bench.py -- <out_dir> <frame> [frame ...]
Prints BENCH <frame> <seconds>. Always 2560x1440, full textures, persistent data.
"""
import bpy, sys, os, json, time, ctypes

argv = sys.argv[sys.argv.index("--") + 1:]
out_dir, frames = argv[0], [int(f) for f in argv[1:]]
cfg = json.loads(os.environ.get("BENCH", "{}"))
try:
    k = ctypes.windll.kernel32
    k.SetPriorityClass(k.GetCurrentProcess(), 0x00004000)
except Exception:
    pass

sc = bpy.context.scene
prefs = bpy.context.preferences.addons["cycles"].preferences
prefs.compute_device_type = "OPTIX"
prefs.get_devices()
for d in prefs.devices:
    d.use = d.type == "OPTIX"
cy = sc.cycles
cy.device = "GPU"
sc.render.resolution_x, sc.render.resolution_y, sc.render.resolution_percentage = 2560, 1440, 100
sc.render.use_simplify = False
sc.render.use_persistent_data = True
sc.render.image_settings.file_format = "PNG"

cy.samples = cfg.get("samples", 256)
cy.use_adaptive_sampling = True
cy.adaptive_threshold = cfg.get("threshold", 0.01)
cy.use_denoising = True
cy.denoiser = cfg.get("denoiser", "OPTIX")
if cy.denoiser == "OPENIMAGEDENOISE":
    try:
        cy.denoising_use_gpu = True
        cy.denoising_prefilter = "ACCURATE"
        cy.denoising_quality = "HIGH"
    except AttributeError:
        pass
for key in ("max_bounces", "diffuse_bounces", "glossy_bounces", "transmission_bounces",
            "transparent_max_bounces", "volume_bounces"):
    if key in cfg:
        setattr(cy, key, cfg[key])
if "seed" in cfg:
    cy.seed = cfg["seed"]
if "clamp_indirect" in cfg:
    cy.sample_clamp_indirect = cfg["clamp_indirect"]
if "caustics" in cfg:
    cy.caustics_reflective = cy.caustics_refractive = cfg["caustics"]
if "filter_glossy" in cfg:
    cy.blur_glossy = cfg["filter_glossy"]
print("BENCH cfg", json.dumps(cfg), "| bounces", cy.max_bounces, cy.diffuse_bounces, cy.glossy_bounces,
      cy.transmission_bounces, cy.transparent_max_bounces, "| clamp", cy.sample_clamp_indirect)

os.makedirs(out_dir, exist_ok=True)
markers = sorted((m for m in sc.timeline_markers if m.camera), key=lambda m: m.frame)
for f in frames:
    sc.frame_set(f)
    cams = [m.camera for m in markers if m.frame <= f]
    if cams:
        sc.camera = cams[-1]
    sc.render.filepath = os.path.join(out_dir, f"r{f:04d}.png")
    t = time.time()
    bpy.ops.render.render(write_still=True)
    print(f"BENCH {f} {time.time() - t:.1f}")
```

## File: skills/blender-forest/scripts/build_car.py
size: 8883
sha: 4661364d1c34dade0e601f8ec79b71153ef450a0
upstream: https://github.com/mromidev/claude-blender-skills/blob/main/skills/blender-forest/scripts/build_car.py

```text
"""His Porsche 992 GT3 RS + our own car rig (no Rig-A-Car add-on).

His file already splits the car onto 9 empties (mycar_body, mycar_wheel.Ft/Bk.L/R,
mycar_wheelbrake.*). We append those empties with everything under them, drop his armature,
camera, light and road, and drive the car ourselves, baked per frame from scripts/car.json:
  * follows the gravel road spline (layout.path_spline) at the keyed speeds
  * height / pitch / roll from the ground under the four tyres (raycast road + terrain)
  * small gravel bob on the body
  * wheels spin from distance travelled (s / radius), front wheels + brakes steer from curvature

  blender -b scene/forest.blend --python scripts/build_car.py        (saves the .blend)
"""
import bpy, json, math, os, sys
import numpy as np
from mathutils import Vector, Matrix, Euler, noise

HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import layout as L

ROOT = os.path.dirname(HERE)
CAR_BLEND = os.path.join(ROOT, "quarantine", "Porsche Gtr_Rigged", "Porsche Gtr_Rigged.blend")
C = json.load(open(os.path.join(HERE, "car.json"), encoding="utf-8"))
sc = bpy.context.scene
RIG = ["mycar_body"] + [f"mycar_{k}.{a}.{s}" for k in ("wheel", "wheelbrake") for a in ("Ft", "Bk") for s in ("L", "R")]

def clear_previous():
    col = bpy.data.collections.get("Car")
    if col:
        for o in list(col.all_objects):
            bpy.data.objects.remove(o, do_unlink=True)
        bpy.data.collections.remove(col)

def descendants(o):
    out = [o]
    for c in o.children:
        out += descendants(c)
    return out

def import_car():
    with bpy.data.libraries.load(CAR_BLEND, link=False) as (src, dst):
        dst.objects = list(src.objects)
    col = bpy.data.collections.new("Car")
    sc.collection.children.link(col)
    for o in dst.objects:
        if o is not None:
            col.objects.link(o)
    bpy.context.view_layer.update()
    keep = set()
    for n in RIG:
        keep.update(descendants(bpy.data.objects[n]))
    # Freeze each rig empty where his rig had it, then cut it loose from his armature.
    world = {n: bpy.data.objects[n].matrix_world.copy() for n in RIG}
    for n in RIG:
        o = bpy.data.objects[n]
        o.constraints.clear()
        o.animation_data_clear()
        o.parent = None
        o.matrix_world = world[n]
    dropped = [o for o in col.objects if o not in keep]
    print(f"car: keep {len(keep)} objects, drop {len(dropped)}:",
          sorted({o.type for o in dropped}), [o.name for o in dropped][:12])
    for o in dropped:
        bpy.data.objects.remove(o, do_unlink=True)
    return col

def car_frame():
    P = {n: bpy.data.objects[n].matrix_world.translation.copy() for n in RIG}
    front = (P["mycar_wheel.Ft.L"] + P["mycar_wheel.Ft.R"]) / 2
    back = (P["mycar_wheel.Bk.L"] + P["mycar_wheel.Bk.R"]) / 2
    fwd = (front - back); fwd.z = 0; fwd.normalize()
    wb = (front - back).length
    track = (P["mycar_wheel.Ft.L"] - P["mycar_wheel.Ft.R"]).length
    # Tyre radius from the wheel's own meshes.
    w = bpy.data.objects["mycar_wheel.Ft.L"]
    zs = [(m.matrix_world @ Vector(c)).z for m in descendants(w) if m.type == "MESH" for c in m.bound_box]
    radius = (max(zs) - min(zs)) / 2
    ground = min(zs)
    mid = (front + back) / 2
    print(f"car: wheelbase {wb:.2f} m, track {track:.2f} m, tyre radius {radius:.3f} m")
    return mid, fwd, wb, track, radius, ground

def build_root(mid, fwd, ground):
    root = bpy.data.objects.new("CarRoot", None)
    root.empty_display_size = 2
    bpy.data.collections["Car"].objects.link(root)
    yaw = math.atan2(fwd.y, fwd.x) - math.pi / 2          # root +Y = car forward
    root.matrix_world = Matrix.Translation((mid.x, mid.y, ground)) @ Matrix.Rotation(yaw, 4, "Z")
    bpy.context.view_layer.update()
    inv = root.matrix_world.inverted()
    rest = {}
    for n in RIG:
        o = bpy.data.objects[n]
        mw = o.matrix_world.copy()
        o.parent = root
        o.matrix_parent_inverse = Matrix.Identity(4)
        o.matrix_basis = inv @ mw
        rest[n] = o.matrix_basis.copy()
    return root, rest

def road():
    pts = L.path_spline(0.05)
    seg = np.diff(pts, axis=0)
    s = np.concatenate([[0.0], np.cumsum(np.linalg.norm(seg, axis=1))])
    if C["direction"] < 0:
        pts = pts[::-1]
        s = s[-1] - s[::-1]

    def at(d):
        d = float(np.clip(d, 0.0, s[-1] - 0.01))
        i = int(np.searchsorted(s, d) - 1)
        i = max(0, min(i, len(pts) - 2))
        t = (d - s[i]) / max(s[i + 1] - s[i], 1e-9)
        p = pts[i] + (pts[i + 1] - pts[i]) * t
        tg = pts[i + 1] - pts[i]
        tg = tg / np.linalg.norm(tg)
        return p, tg
    return at, s[-1]

def ground_z(x, y):
    best = None
    for name in ("Path", "Terrain"):
        ob = bpy.data.objects.get(name)
        hit, loc, *_ = ob.ray_cast(Vector((x, y, 60.0)), Vector((0, 0, -1)))
        if hit:
            z = (ob.matrix_world @ loc).z
            best = z if best is None else max(best, z)
    return best if best is not None else 0.0

def distance_curve():
    f0, f1 = C["frames"]
    keys = C["speed"]
    fr = np.arange(f0, f1 + 1)
    v = np.interp(fr, [k[0] for k in keys], [k[1] for k in keys])
    d = C["s0"] + np.concatenate([[0.0], np.cumsum(v[:-1] / sc.render.fps)])
    return fr, d

def animate(root, rest, wb, track, radius):
    at, length = road()
    fr, dist = distance_curve()
    lat = C.get("lateral", 0.0)
    bob = C.get("bob_mm", 6.0) / 1000.0
    prev_heading = None
    for f, d in zip(fr, dist):
        p, tg = at(d)
        side = np.array([tg[1], -tg[0]])                     # right of travel
        centre = p + side * lat
        heading = math.atan2(tg[1], tg[0])
        # Four tyre contacts on the ground.
        pf, tf = at(d + wb / 2)
        pr, tr = at(d - wb / 2)
        sf, sr = np.array([tf[1], -tf[0]]), np.array([tr[1], -tr[0]])
        zfl = ground_z(*(pf + sf * (lat - track / 2)))
        zfr = ground_z(*(pf + sf * (lat + track / 2)))
        zrl = ground_z(*(pr + sr * (lat - track / 2)))
        zrr = ground_z(*(pr + sr * (lat + track / 2)))
        pitch = math.atan2(((zfl + zfr) - (zrl + zrr)) / 2, wb)
        roll = math.atan2(((zfr + zrr) - (zfl + zrl)) / 2, track)
        t = f / sc.render.fps
        z = (zfl + zfr + zrl + zrr) / 4 + bob * noise.noise(Vector((t * 7.0, 0.3, 0.0)))
        pitch += 0.004 * noise.noise(Vector((t * 5.0, 1.7, 0.0)))
        roll += 0.003 * noise.noise(Vector((t * 4.0, 3.1, 0.0)))
        root.location = (centre[0], centre[1], z)
        root.rotation_mode = "YXZ"                       # R = Rz(yaw) Rx(pitch) Ry(roll)
        yaw = heading - math.pi / 2
        if prev_heading is not None:                     # unwrap yaw too
            yaw = prev_heading + ((yaw - prev_heading + math.pi) % (2 * math.pi) - math.pi)
        prev_heading = yaw
        root.rotation_euler = (pitch, -roll, yaw)
        root.keyframe_insert("location", frame=int(f))
        root.keyframe_insert("rotation_euler", frame=int(f))
        # Steering from road curvature (bicycle model), clamped.
        p2, t2 = at(d + 1.0)
        dh = math.atan2(t2[1], t2[0]) - heading
        dh = (dh + math.pi) % (2 * math.pi) - math.pi
        steer = max(-0.5, min(0.5, math.atan(wb * dh / 1.0)))
        spin = (d - C["s0"]) / radius
        for n in RIG[1:]:
            o = bpy.data.objects[n]
            r = rest[n]
            loc = r.to_translation()
            rot = r.to_3x3().to_4x4()
            m = Matrix.Translation(loc)
            if ".Ft." in n:
                m = m @ Matrix.Rotation(steer, 4, "Z")
            if "wheelbrake" not in n:
                m = m @ Matrix.Rotation(-spin, 4, "X")
            prev = o.rotation_euler.copy()
            o.matrix_basis = m @ rot
            # Keep Euler angles continuous frame to frame (no +180 -> -180 wrap), otherwise
            # motion blur interpolates a backwards near-full turn and smears the tyre.
            o.rotation_euler = o.rotation_euler.to_matrix().to_euler(o.rotation_mode, prev)
            o.keyframe_insert("location", frame=int(f))
            o.keyframe_insert("rotation_euler", frame=int(f))
    print(f"car: drove {dist[-1] - dist[0]:.1f} m over {len(fr)} frames (road {length:.0f} m)")

def main():
    clear_previous()
    import_car()
    mid, fwd, wb, track, radius, ground = car_frame()
    root, rest = build_root(mid, fwd, ground)
    # His model is oversized; scale to the real 992 GT3 RS wheelbase (2.46 m).
    k = C.get("real_wheelbase", 2.46) / wb
    root.scale = (k, k, k)
    wb, track, radius = wb * k, track * k, radius * k
    print(f"car: scaled x{k:.3f} -> wheelbase {wb:.2f}, track {track:.2f}, tyre r {radius:.3f}")
    animate(root, rest, wb, track, radius)
    sc.frame_set(C["frames"][0])
    bpy.ops.wm.save_mainfile(relative_remap=True)
    print("SAVED", bpy.data.filepath)

main()
```

## File: skills/blender-forest/scripts/build_path.py
size: 9350
sha: 480d08b7ab2a132e14eb3b102885727aa34be9ea
upstream: https://github.com/mromidev/claude-blender-skills/blob/main/skills/blender-forest/scripts/build_path.py

```text
"""Stage D: gravel path + gravel pad in front of the cabins.

His method was boolean(curve, duplicated terrain). We build the same surface directly:
a ribbon that follows the smoothed path and hugs the terrain (raycast), plus a pad
whose outline is snapped to a smooth rounded box. Both get a 'middle' colour attribute
(1 = centre, 0 = edges) that mixes his two gravel textures, exactly like his setup.

Run:  blender -b scene/forest.blend --python scripts/build_path.py
"""
import bpy, bmesh, math, os, sys, ctypes
import numpy as np
from mathutils import Vector, noise

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import layout as L

ROOT = os.path.join(os.path.expanduser("~"), "blender-forest")
LIB_BLEND = os.path.join(ROOT, "assets", "Plant models", "assets", "AE_15th Assets.blend")

try:
    k = ctypes.windll.kernel32
    k.SetPriorityClass(k.GetCurrentProcess(), 0x00004000)
except Exception:
    pass

STEP = 0.12          # metres between cross sections / grid cells
COLS = 16            # vertices across the path
EDGE_FADE = 0.8      # metres over which gravel thins out into the soil
LIFT_MID, LIFT_EDGE = L.LIFT_MID, L.LIFT_EDGE   # height above terrain (no z-fighting)

def n1(x, seed):
    return noise.noise(Vector((x, seed * 13.1, seed * 7.7)))

def ground_z(terrain, x, y):
    hit, loc, *_ = terrain.ray_cast(Vector((x, y, 80.0)), Vector((0, 0, -1)))
    return loc.z if hit else 0.0

def build_ribbon(bm, terrain, mid_layer, uv_layer):
    pts = L.path_spline(STEP)
    seg = np.diff(pts, axis=0)
    tang = np.vstack([seg, seg[-1:]])
    tang /= np.linalg.norm(tang, axis=1)[:, None]
    side = np.stack([-tang[:, 1], tang[:, 0]], axis=1)
    s = np.concatenate([[0.0], np.cumsum(np.linalg.norm(seg, axis=1))])
    rows = []
    for i, (p, sd, d) in enumerate(zip(pts, side, s)):
        wl = L.PATH_HALF_WIDTH * (1.0 + 0.10 * n1(d * 0.35, 1) + 0.04 * n1(d * 2.1, 2))
        wr = L.PATH_HALF_WIDTH * (1.0 + 0.10 * n1(d * 0.35, 3) + 0.04 * n1(d * 2.1, 4))
        row = []
        for j in range(COLS):
            u = -1.0 + 2.0 * j / (COLS - 1)          # -1 left edge .. +1 right edge
            w = wl if u < 0 else wr
            x, y = p + sd * (u * w)
            crown = 1.0 - u * u
            z = ground_z(terrain, x, y) + LIFT_EDGE + (LIFT_MID - LIFT_EDGE) * crown
            v = bm.verts.new((x, y, z))
            # 'middle' mask: wobbly centre band of cleaner gravel.
            mval = np.clip(crown * 1.4 - 0.25 + 0.35 * n1(d * 0.5, 5), 0.0, 1.0)
            edge = float(np.clip((1.0 - abs(u)) * w / EDGE_FADE, 0.0, 1.0))
            row.append((v, mval, edge))
        rows.append(row)
    for r0, r1 in zip(rows[:-1], rows[1:]):
        for j in range(COLS - 1):
            f = bm.faces.new((r0[j][0], r0[j + 1][0], r1[j + 1][0], r1[j][0]))
            for loop in f.loops:
                val, edge = next((m, e) for vv, m, e in (r0[j], r0[j + 1], r1[j + 1], r1[j])
                                 if vv is loop.vert)
                loop[mid_layer] = (val, edge, 0.0, 1.0)
                loop[uv_layer].uv = (loop.vert.co.x, loop.vert.co.y)
    print(f"ribbon: {len(rows)} sections, length {s[-1]:.1f} m")

def build_pad(bm, terrain, mid_layer, uv_layer):
    cx, cy = L.PAD_CENTER
    hx, hy = L.PAD_HALF
    xs = np.arange(cx - hx - 0.5, cx + hx + 0.5 + STEP, STEP)
    ys = np.arange(cy - hy - 0.5, cy + hy + 0.5 + STEP, STEP)
    gx, gy = np.meshgrid(xs, ys)
    xy = np.stack([gx.ravel(), gy.ravel()], axis=1)

    def sdf(q):
        wob = np.array([0.12 * n1(a * 0.6 + b * 0.4, 9) for a, b in q])
        return L.dist_to_pad(q) + wob

    d = sdf(xy).reshape(gy.shape)
    inside = d < 0
    keep = inside[:-1, :-1] | inside[1:, :-1] | inside[:-1, 1:] | inside[1:, 1:]
    used = np.zeros_like(inside)
    used[:-1, :-1] |= keep; used[1:, :-1] |= keep; used[:-1, 1:] |= keep; used[1:, 1:] |= keep
    verts = {}
    e = 0.02
    for (iy, ix) in zip(*np.nonzero(used)):
        p = np.array([gx[iy, ix], gy[iy, ix]])
        dv = d[iy, ix]
        if dv > 0:  # pull outside corners onto the smooth outline
            g = np.array([sdf(np.array([p + [e, 0]]))[0] - sdf(np.array([p - [e, 0]]))[0],
                          sdf(np.array([p + [0, e]]))[0] - sdf(np.array([p - [0, e]]))[0]]) / (2 * e)
            p = p - g / max(np.linalg.norm(g), 1e-6) * dv
        depth = max(-dv, 0.0)
        lift = LIFT_EDGE + (LIFT_MID - LIFT_EDGE) * min(depth / 0.6, 1.0)
        z = ground_z(terrain, p[0], p[1]) + lift
        mval = float(np.clip(0.55 + 0.3 * n1(p[0] * 0.4 + p[1] * 0.3, 11), 0, 1)) * min(depth / 0.8, 1.0)
        edge = min(depth / EDGE_FADE, 1.0)
        verts[(iy, ix)] = (bm.verts.new((p[0], p[1], z + 0.003)), mval, edge)  # just above the ribbon
    for (iy, ix) in zip(*np.nonzero(keep)):
        quad = [verts[(iy, ix)], verts[(iy, ix + 1)], verts[(iy + 1, ix + 1)], verts[(iy + 1, ix)]]
        f = bm.faces.new([q[0] for q in quad])
        for loop, (_, m, e) in zip(f.loops, quad):
            loop[mid_layer] = (m, e, 0.0, 1.0)
            loop[uv_layer].uv = (loop.vert.co.x, loop.vert.co.y)
    print(f"pad: {int(keep.sum())} quads")

def gravel_material():
    with bpy.data.libraries.load(LIB_BLEND, link=False) as (src, dst):
        dst.materials = ["AE_15th_Gravel"]
    mat = dst.materials[0]
    nt = mat.node_tree
    nodes = {n.name: n for n in nt.nodes}
    tc = nt.nodes.new("ShaderNodeTexCoord")
    mp = nt.nodes.new("ShaderNodeMapping")
    mp.inputs["Scale"].default_value = (0.5, 0.5, 0.5)   # UVs are metres -> one tile per 2 m
    nt.links.new(tc.outputs["UV"], mp.inputs["Vector"])
    for n in nt.nodes:
        if n.type == "TEX_IMAGE":
            nt.links.new(mp.outputs["Vector"], n.inputs["Vector"])
    # His two-texture mix: clean gravel ('gravel soils') in the middle, gravel+soil at edges.
    attr = nt.nodes.new("ShaderNodeVertexColor")
    attr.layer_name = "middle"
    mix = nt.nodes.new("ShaderNodeMix")
    mix.data_type = "RGBA"
    bsdf = next(n for n in nt.nodes if n.type == "BSDF_PRINCIPLED")
    edge_tex, mid_tex = nodes["Image Texture.002"], nodes["Image Texture"]

    def s(sockets, name, kind):
        return next(x for x in sockets if x.name == name and x.type == kind and x.enabled)

    # 'middle' colour: R = centre band (texture mix), G = distance-from-edge (fade).
    sep = nt.nodes.new("ShaderNodeSeparateColor")
    nt.links.new(attr.outputs["Color"], sep.inputs["Color"])
    nt.links.new(sep.outputs[0], s(mix.inputs, "Factor", "VALUE"))
    nt.links.new(edge_tex.outputs["Color"], s(mix.inputs, "A", "RGBA"))
    nt.links.new(mid_tex.outputs["Color"], s(mix.inputs, "B", "RGBA"))
    # Lift the dark 4k scans a little (his path reads light beige in the sun).
    hsv = nt.nodes.new("ShaderNodeHueSaturation")
    hsv.inputs["Value"].default_value = 1.35
    nt.links.new(s(mix.outputs, "Result", "RGBA"), hsv.inputs["Color"])
    nt.links.new(hsv.outputs["Color"], bsdf.inputs["Base Color"])
    nodes["Image Texture.001"].image.colorspace_settings.name = "Non-Color"  # height map
    # Edge fade: gravel breaks up into the soil instead of a hard slab edge.
    noi = nt.nodes.new("ShaderNodeTexNoise")
    noi.inputs["Scale"].default_value = 3.0
    noi.inputs["Detail"].default_value = 6.0
    nt.links.new(mp.outputs["Vector"], noi.inputs["Vector"])
    add = nt.nodes.new("ShaderNodeMath"); add.operation = "ADD"
    nt.links.new(sep.outputs[1], add.inputs[0])
    sub = nt.nodes.new("ShaderNodeMath"); sub.operation = "SUBTRACT"
    nt.links.new(noi.outputs["Fac"], sub.inputs[0]); sub.inputs[1].default_value = 0.5
    sc = nt.nodes.new("ShaderNodeMath"); sc.operation = "MULTIPLY"
    nt.links.new(sub.outputs[0], sc.inputs[0]); sc.inputs[1].default_value = 0.8
    nt.links.new(sc.outputs[0], add.inputs[1])
    ramp = nt.nodes.new("ShaderNodeMapRange")
    ramp.inputs["From Min"].default_value = 0.25
    ramp.inputs["From Max"].default_value = 0.65
    nt.links.new(add.outputs[0], ramp.inputs["Value"])
    tr = nt.nodes.new("ShaderNodeBsdfTransparent")
    ms = nt.nodes.new("ShaderNodeMixShader")
    out = next(n for n in nt.nodes if n.type == "OUTPUT_MATERIAL")
    nt.links.new(ramp.outputs["Result"], ms.inputs["Fac"])
    nt.links.new(tr.outputs[0], ms.inputs[1])
    nt.links.new(bsdf.outputs[0], ms.inputs[2])
    nt.links.new(ms.outputs[0], out.inputs["Surface"])
    # Stronger pebble relief.
    for n in nt.nodes:
        if n.type == "BUMP":
            n.inputs["Strength"].default_value = 0.35
    mat.displacement_method = "BOTH"
    return mat

def main():
    for name in ("Path",):
        o = bpy.data.objects.get(name)
        if o:
            bpy.data.objects.remove(o, do_unlink=True)
    terrain = bpy.data.objects["Terrain"]
    bm = bmesh.new()
    mid = bm.loops.layers.color.new("middle")
    uv = bm.loops.layers.uv.new("UVMap")
    build_ribbon(bm, terrain, mid, uv)
    if L.USE_CABINS:
        build_pad(bm, terrain, mid, uv)
    me = bpy.data.meshes.new("Path")
    bm.to_mesh(me)
    bm.free()
    for p in me.polygons:
        p.use_smooth = True
    ob = bpy.data.objects.new("Path", me)
    col = bpy.data.collections.get("Terrain")
    col.objects.link(ob)
    me.materials.append(gravel_material())
    bpy.ops.wm.save_mainfile(relative_remap=True)
    print("SAVED", bpy.data.filepath, "path verts", len(me.vertices))

main()
```

## File: skills/blender-forest/scripts/build_shots.py
size: 8374
sha: 534f6a12321903461840b79841be8e61835ac9cb
upstream: https://github.com/mromidev/claude-blender-skills/blob/main/skills/blender-forest/scripts/build_shots.py

```text
"""Stage F: the 5 shots of his first 16 s, driven by scripts/shots.json.

Per shot: an animated camera (start -> end pose, linear or eased, optional handheld noise),
lens + depth of field, its own sun angle/strength (switched on the cut, so every shot can match
his lighting), and optional hand-placed hero props (fallen log, rocks...) from his library.
Timeline markers switch cameras on the cuts. Frame 1 = 0.0 s, 30 fps.

  blender -b scene/forest.blend --python scripts/build_shots.py          (saves the .blend)

Pose format: {"pos": [x, y, h], "yaw": deg, "pitch": deg, "roll": deg}
  h = height above the ground at (x, y); yaw 0 = looking +Y; pitch 90 = level, <90 = looking down.
"""
import bpy, json, math, os, sys
from mathutils import Vector, Matrix

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
LIB_BLEND = os.path.join(ROOT, "assets", "Plant models", "assets", "AE_15th Assets.blend")
S = json.load(open(os.path.join(HERE, "shots.json"), encoding="utf-8"))
sc = bpy.context.scene
terrain = bpy.data.objects["Terrain"]

def ground(x, y):
    """Highest surface under (x, y): deck, path or terrain (so cameras on the deck sit right)."""
    best = None
    for name in ("Deck", "Path", "Terrain"):           # walkable surfaces only (never tree tops)
        ob = bpy.data.objects.get(name)
        if not ob:
            continue
        hit, loc, *_ = ob.ray_cast(Vector((x, y, 60.0)), Vector((0, 0, -1)))
        if hit:
            z = (ob.matrix_world @ loc).z
            best = z if best is None else max(best, z)
    return best if best is not None else 0.0

def fcurves(obj):
    """All F-curves of an object's action (Blender 5 layered actions, or legacy)."""
    ad = obj.animation_data
    if not ad or not ad.action:
        return []
    act = ad.action
    try:
        out = []
        for layer in act.layers:
            for strip in layer.strips:
                bag = strip.channelbag(ad.action_slot)
                if bag:
                    out.extend(bag.fcurves)
        return out
    except AttributeError:
        return list(act.fcurves)

def clear_previous():
    for o in list(bpy.data.objects):
        if o.name.startswith("CAM_") or o.name.startswith("HERO_"):
            bpy.data.objects.remove(o, do_unlink=True)
    sc.timeline_markers.clear()
    sun = bpy.data.objects.get("Sun")
    if sun and sun.animation_data:
        sun.animation_data_clear()
        sun.data.animation_data_clear()

def pose(obj, p, frame):
    x, y, h = p["pos"]
    # Parented cameras (e.g. bolted to the car) use plain local coordinates.
    obj.location = (x, y, h) if obj.parent else (x, y, ground(x, y) + h)
    obj.rotation_euler = (math.radians(p["pitch"]), math.radians(p.get("roll", 0.0)), math.radians(p["yaw"]))
    obj.keyframe_insert("location", frame=frame)
    obj.keyframe_insert("rotation_euler", frame=frame)

def make_camera(shot):
    d = bpy.data.cameras.new("CAM_" + shot["name"])
    d.lens = shot.get("lens", 24)
    d.sensor_width = 36
    d.clip_start, d.clip_end = 0.05, 400
    cam = bpy.data.objects.new("CAM_" + shot["name"], d)
    sc.collection.objects.link(cam)
    cam["shot"] = shot["name"]
    if shot.get("parent"):
        cam.parent = bpy.data.objects[shot["parent"]]
        cam.matrix_parent_inverse = Matrix.Identity(4)
    pano = shot.get("pano")
    if pano:                                    # his car shot: panoramic fisheye (FOV 200, lens 23)
        d.type = "PANO"
        try:
            d.panorama_type = pano.get("type", "FISHEYE_EQUISOLID")
            d.fisheye_fov = math.radians(pano.get("fov", 200))
            d.fisheye_lens = pano.get("lens", 23)
        except AttributeError:
            d.cycles.panorama_type = pano.get("type", "FISHEYE_EQUISOLID")
            d.cycles.fisheye_fov = math.radians(pano.get("fov", 200))
            d.cycles.fisheye_lens = pano.get("lens", 23)
    pose(cam, shot["cam_start"], shot["start"])
    pose(cam, shot["cam_end"], shot["end"])
    ease = shot.get("ease", "linear")
    for fc in fcurves(cam):
        for kp in fc.keyframe_points:
            kp.interpolation = "LINEAR" if ease == "linear" else "BEZIER"
            if ease != "linear":
                kp.handle_left_type = kp.handle_right_type = "AUTO_CLAMPED"
        shake = shot.get("shake", 0.0)
        if shake > 0:
            m = fc.modifiers.new("NOISE")
            is_rot = fc.data_path == "rotation_euler"
            m.strength = shake * (0.02 if is_rot else 1.0)      # metres / radians
            m.scale = 18.0
            m.phase = 13.0 * fc.array_index + (5 if is_rot else 0)
    # Depth of field: focus on the frame centre at the shot's middle pose (or a fixed distance).
    d.dof.use_dof = shot.get("fstop", 0) > 0
    if d.dof.use_dof:
        d.dof.aperture_fstop = shot["fstop"]
        f = shot.get("focus", "auto")
        if f == "auto":
            sc.frame_set((shot["start"] + shot["end"]) // 2)
            dg = bpy.context.evaluated_depsgraph_get()
            fwd = cam.matrix_world.to_quaternion() @ Vector((0, 0, -1))
            hit, loc, *_ = sc.ray_cast(dg, cam.matrix_world.translation, fwd)
            d.dof.focus_distance = (loc - cam.matrix_world.translation).length if hit else 10.0
        else:
            d.dof.focus_distance = float(f)
    m = sc.timeline_markers.new(shot["name"], frame=shot["start"])
    m.camera = cam
    return cam

def key_sun(shot):
    sun = bpy.data.objects["Sun"]
    s = shot.get("sun")
    if not s:
        return
    az, el = math.radians(s["azimuth"]), math.radians(s["elevation"])
    to_sun = Vector((math.cos(el) * math.cos(az), math.cos(el) * math.sin(az), math.sin(el)))
    sun.rotation_euler = (-to_sun).to_track_quat("-Z", "Y").to_euler()
    sun.keyframe_insert("rotation_euler", frame=shot["start"])
    if "energy" in s:
        sun.data.energy = s["energy"]
        sun.data.keyframe_insert("energy", frame=shot["start"])

_PROP_COLS = {}

def hero(p, i):
    name = p["name"]
    if name not in _PROP_COLS:
        col = bpy.data.collections.get("PROP_" + name)
        if not col:
            with bpy.data.libraries.load(LIB_BLEND, link=True) as (src, dst):
                dst.objects = [name]
            src_ob = dst.objects[0]
            col = bpy.data.collections.new("PROP_" + name)
            props = bpy.data.collections.get("PROPS")
            if not props:                       # hidden holder for linked library props
                props = bpy.data.collections.new("PROPS")
                sc.collection.children.link(props)
                bpy.context.view_layer.layer_collection.children["PROPS"].exclude = True
            props.children.link(col)
            col.objects.link(src_ob)
            mw = src_ob.matrix_basis
            bb = [mw @ Vector(c) for c in src_ob.bound_box]
            col.instance_offset = (sum(v.x for v in bb) / 8, sum(v.y for v in bb) / 8, min(v.z for v in bb))
        _PROP_COLS[name] = col
    e = bpy.data.objects.new(f"HERO_{i}_{name}", None)
    e.instance_type = "COLLECTION"
    e.instance_collection = _PROP_COLS[name]
    col = bpy.data.collections.get("Hero") or bpy.data.collections.new("Hero")
    if col.name not in sc.collection.children:
        sc.collection.children.link(col)
    col.objects.link(e)
    x, y = p["pos"][:2]
    e.location = (x, y, ground(x, y) + p.get("z", 0.0))
    e.rotation_euler = (0, 0, math.radians(p.get("rot", 0.0)))
    e.scale = (p.get("scale", 1.0),) * 3

def main():
    clear_previous()
    sc.render.fps = S.get("fps", 30)
    for i, shot in enumerate(S["shots"]):
        make_camera(shot)
        key_sun(shot)
        for j, p in enumerate(shot.get("props", [])):
            hero(p, f"{i}{j}")
    sun = bpy.data.objects["Sun"]
    for ob in (sun, sun.data):
        if ob.animation_data and ob.animation_data.action:
            for fc in fcurves(ob):
                for kp in fc.keyframe_points:
                    kp.interpolation = "CONSTANT"          # sun jumps on the cut
    sc.frame_start = S["shots"][0]["start"]
    sc.frame_end = S.get("frame_end", S["shots"][-1]["end"])
    sc.frame_set(sc.frame_start)
    sc.camera = bpy.data.objects["CAM_" + S["shots"][0]["name"]]
    bpy.ops.wm.save_mainfile(relative_remap=True)
    print("SHOTS built:", [s["name"] for s in S["shots"]], "frames", sc.frame_start, sc.frame_end)

main()
```

## File: skills/blender-forest/scripts/build_world.py
size: 9141
sha: e1749c4cc9ba8b9afd4461f2e27df95e895d7679
upstream: https://github.com/mromidev/claude-blender-skills/blob/main/skills/blender-forest/scripts/build_world.py

```text
"""Stage B: terrain + ground material + HDRI world (light-path split) + sun + colour management.

Run (headless, low priority set inside):
  blender -b --factory-startup --python scripts/build_world.py
Writes scene/forest.blend. Re-runnable: always rebuilds from scratch.
"""
import bpy, bmesh, math, os, sys, ctypes
from mathutils import Vector

ROOT = os.path.join(os.path.expanduser("~"), "blender-forest")
ASSETS = os.path.join(ROOT, "assets")
TERRAIN_BLEND = os.path.join(ASSETS, "Remeshed Terrain", "Remeshed Terrain.blend")
LIB_BLEND = os.path.join(ASSETS, "Plant models", "assets", "AE_15th Assets.blend")
HDRI = os.path.join(ASSETS, "Ever_cabins_cloud_layers_8k", "Ever_cabins_cloud_layers_8k.hdr")
OUT = os.path.join(ROOT, "scene", "forest.blend")

def low_priority():
    # Keep the PC responsive: BELOW_NORMAL_PRIORITY_CLASS for this Blender process.
    try:
        k = ctypes.windll.kernel32
        k.SetPriorityClass(k.GetCurrentProcess(), 0x00004000)
    except Exception as e:
        print("priority not set:", e)

def clear_scene():
    for o in list(bpy.data.objects):
        bpy.data.objects.remove(o, do_unlink=True)
    for c in list(bpy.data.collections):
        bpy.data.collections.remove(c)

def get_collection(name):
    col = bpy.data.collections.get(name) or bpy.data.collections.new(name)
    if col.name not in bpy.context.scene.collection.children:
        bpy.context.scene.collection.children.link(col)
    return col

def append(blend, kind, name):
    with bpy.data.libraries.load(blend, link=False) as (src, dst):
        if name not in getattr(src, kind):
            raise RuntimeError(f"{name} not in {blend}")
        setattr(dst, kind, [name])
    return getattr(dst, kind)[0]

def build_terrain():
    obj = append(TERRAIN_BLEND, "objects", "Terrain")
    get_collection("Terrain").objects.link(obj)
    bpy.context.view_layer.objects.active = obj
    obj.select_set(True)
    # Apply the 90x scale (his file keeps it unapplied).
    bpy.ops.object.transform_apply(location=False, rotation=True, scale=True)

    # More geometry for masks/displacement (he subdivides before painting).
    sub = obj.modifiers.new("Subdiv", "SUBSURF")
    sub.subdivision_type = "SIMPLE"
    sub.levels = sub.render_levels = 2
    bpy.ops.object.modifier_apply(modifier=sub.name)

    # Gentle clouds displacement: size 6, depth 5, almost flat (his settings).
    tex = bpy.data.textures.new("TerrainClouds", "CLOUDS")
    tex.noise_scale = 6.0
    tex.noise_depth = 5
    disp = obj.modifiers.new("Displace", "DISPLACE")
    disp.texture = tex
    disp.texture_coords = "GLOBAL"
    disp.strength = 1.6
    disp.mid_level = 0.5
    bpy.ops.object.modifier_apply(modifier=disp.name)
    bpy.ops.object.origin_set(type="ORIGIN_GEOMETRY", center="BOUNDS")
    obj.location = (0, 0, 0)
    bpy.ops.object.transform_apply(location=True, rotation=False, scale=False)

    # Level the cabin site (his cabins sit on flat ground), blending out over 6 m.
    import numpy as np
    sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
    import layout as L
    me = obj.data
    co = np.empty(len(me.vertices) * 3)
    me.vertices.foreach_get("co", co)
    co = co.reshape(-1, 3)
    d = L.dist_to_cabin(co[:, :2])
    site_z = float(np.median(co[d < 0, 2]))
    w = (1.0 - L.smoothstep(0.0, 6.0, d)) * (1.0 if L.USE_CABINS else 0.0)
    co[:, 2] = co[:, 2] * (1.0 - w) + site_z * w
    me.vertices.foreach_set("co", co.ravel())
    me.update()
    print(f"cabin site levelled at z={site_z:.2f}")

    # Planar top-down UVs, 0..1 across the terrain (mapping scale 50 tiles it).
    me = obj.data
    bm = bmesh.new()
    bm.from_mesh(me)
    uv = bm.loops.layers.uv.new("UVMap")
    xs = [v.co.x for v in bm.verts]
    ys = [v.co.y for v in bm.verts]
    minx, miny = min(xs), min(ys)
    size = max(max(xs) - minx, max(ys) - miny)
    for f in bm.faces:
        for l in f.loops:
            l[uv].uv = ((l.vert.co.x - minx) / size, (l.vert.co.y - miny) / size)
    bm.to_mesh(me)
    bm.free()
    for p in me.polygons:
        p.use_smooth = True
    print(f"terrain: {len(me.vertices)} verts, size {size:.1f} m")
    return obj

def ground_material(obj):
    mat = append(LIB_BLEND, "materials", "AE_15th_Leafs_Soil_Ground")
    obj.data.materials.clear()
    obj.data.materials.append(mat)
    nt = mat.node_tree
    for n in nt.nodes:
        if n.type == "HUE_SAT":
            n.inputs["Saturation"].default_value = 1.2
            n.inputs["Value"].default_value = 1.2
        if n.type == "DISPLACEMENT":
            n.inputs["Scale"].default_value = 0.2
    # Library material has no mapping: add UV -> Mapping (scale 50) into every image,
    # so the 4k soil texture tiles every ~3.6 m instead of stretching over 179 m.
    tc = nt.nodes.new("ShaderNodeTexCoord"); tc.location = (-1300, 0)
    mp = nt.nodes.new("ShaderNodeMapping"); mp.location = (-1100, 0)
    mp.inputs["Scale"].default_value = (50, 50, 50)
    nt.links.new(tc.outputs["UV"], mp.inputs["Vector"])
    for n in nt.nodes:
        if n.type == "TEX_IMAGE":
            nt.links.new(mp.outputs["Vector"], n.inputs["Vector"])
    mat.displacement_method = "BOTH"
    print("ground material nodes:", sorted({n.type for n in nt.nodes}))

def build_world():
    world = bpy.data.worlds.new("ForestWorld")
    bpy.context.scene.world = world
    world.use_nodes = True
    nt = world.node_tree
    nt.nodes.clear()
    N = nt.nodes.new
    L = nt.links.new
    img = bpy.data.images.load(HDRI, check_existing=True)

    def chain(y, strength, label):
        tc = N("ShaderNodeTexCoord"); tc.location = (-1400, y)
        mp = N("ShaderNodeMapping"); mp.location = (-1200, y)
        mp.inputs["Location"].default_value = (0, 0, 0.25)  # pushes the HDRI ground below the horizon
        mp.inputs["Rotation"].default_value = (0, 0, math.radians(0))
        env = N("ShaderNodeTexEnvironment"); env.location = (-1000, y); env.image = img
        gm = N("ShaderNodeGamma"); gm.location = (-700, y); gm.inputs["Gamma"].default_value = 1.0
        hs = N("ShaderNodeHueSaturation"); hs.location = (-500, y)
        bg = N("ShaderNodeBackground"); bg.location = (-250, y); bg.label = label
        bg.inputs["Strength"].default_value = strength
        L(tc.outputs["Generated"], mp.inputs["Vector"])
        L(mp.outputs["Vector"], env.inputs["Vector"])
        L(env.outputs["Color"], gm.inputs["Color"])
        L(gm.outputs["Color"], hs.inputs["Color"])
        L(hs.outputs["Color"], bg.inputs["Color"])
        return bg

    light = chain(300, 2.25, "Lighting")   # what objects see
    sky = chain(-300, 1.0, "Sky")          # what the camera sees directly
    lp = N("ShaderNodeLightPath"); lp.location = (-250, 650)
    mix = N("ShaderNodeMixShader"); mix.location = (0, 0)
    out = N("ShaderNodeOutputWorld"); out.location = (200, 0)
    L(lp.outputs["Is Camera Ray"], mix.inputs["Fac"])
    L(light.outputs["Background"], mix.inputs[1])
    L(sky.outputs["Background"], mix.inputs[2])
    L(mix.outputs["Shader"], out.inputs["Surface"])

def build_sun():
    data = bpy.data.lights.new("Sun", "SUN")
    data.energy = 10.0
    data.color = (1.0, 0.95, 0.88)  # slightly warm
    data.angle = math.radians(1.5)
    sun = bpy.data.objects.new("Sun", data)
    get_collection("Lighting").objects.link(sun)
    sun.location = (0, 0, 30)
    sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
    import layout as L
    az, el = math.radians(L.SUN_AZIMUTH), math.radians(L.SUN_ELEVATION)
    to_sun = Vector((math.cos(el) * math.cos(az), math.cos(el) * math.sin(az), math.sin(el)))
    sun.rotation_euler = (-to_sun).to_track_quat("-Z", "Y").to_euler()

def colour_management():
    sc = bpy.context.scene
    sc.display_settings.display_device = "sRGB"
    vs = sc.view_settings
    vs.view_transform = "Filmic"
    # Look enum is dynamic (enum_items only shows NONE), so set by name.
    vs.look = "High Contrast"
    vs.exposure = 1.5
    vs.gamma = 0.9
    print("colour:", vs.view_transform, "/", vs.look)

def render_settings():
    sc = bpy.context.scene
    sc.render.engine = "CYCLES"
    prefs = bpy.context.preferences.addons["cycles"].preferences
    prefs.compute_device_type = "OPTIX"
    prefs.get_devices()
    for d in prefs.devices:
        d.use = d.type == "OPTIX"
    sc.cycles.device = "GPU"
    sc.render.fps = 30
    sc.render.resolution_x, sc.render.resolution_y = 1920, 1080
    sc.cycles.samples = 256
    sc.cycles.use_adaptive_sampling = True
    sc.cycles.adaptive_threshold = 0.01
    sc.cycles.use_denoising = True
    sc.cycles.denoiser = "OPTIX"
    sc.render.use_motion_blur = True
    # Keep the CPU free for your other apps.
    sc.render.threads_mode = "FIXED"
    sc.render.threads = 8
    sc.render.use_persistent_data = False

def main():
    low_priority()
    clear_scene()
    terrain = build_terrain()
    ground_material(terrain)
    build_world()
    build_sun()
    colour_management()
    render_settings()
    os.makedirs(os.path.dirname(OUT), exist_ok=True)
    bpy.ops.wm.save_as_mainfile(filepath=OUT, relative_remap=True)
    print("SAVED", OUT)

main()
```

## File: skills/blender-forest/scripts/camset.py
size: 677
sha: 796345820c6073ab980a171e6047fd8e091f7259
upstream: https://github.com/mromidev/claude-blender-skills/blob/main/skills/blender-forest/scripts/camset.py

```text
"""Place a look-dev camera from env CAM="x,y,height,yaw_deg,pitch_deg,lens" (height above ground)."""
import bpy, os, math
from mathutils import Vector
x, y, h, yaw, pitch, lens = [float(v) for v in os.environ["CAM"].split(",")]
sc = bpy.context.scene
dg = bpy.context.evaluated_depsgraph_get()
t = bpy.data.objects["Terrain"]
hit, loc, *_ = t.ray_cast(Vector((x, y, 60)), Vector((0, 0, -1)))
d = bpy.data.cameras.new("TestCam"); d.lens = lens; d.clip_start = 0.05; d.clip_end = 400
c = bpy.data.objects.new("TestCam", d); sc.collection.objects.link(c)
c.location = (x, y, (loc.z if hit else 0) + h)
c.rotation_euler = (math.radians(pitch), 0, math.radians(yaw))
sc.camera = c
```

## File: skills/blender-forest/scripts/car.json
size: 573
sha: 5cf0849a960b5dfa102e5afaee82b0bffc0adbed
upstream: https://github.com/mromidev/claude-blender-skills/blob/main/skills/blender-forest/scripts/car.json

```text
{
 "comment": "Car drive for the 19-32 s clip. Timeline frame 1 = his f570. s0 = start distance (m) along the road spline from its north end; direction +1 drives south (PATH order), -1 drives north. speed keys: [frame, metres per second], linear between keys.",
 "s0": 40.0,
 "direction": 1,
 "lateral": 0.15,
 "speed": [
  [
   1,
   6.0
  ],
  [
   76,
   6.0
  ],
  [
   77,
   4.3
  ],
  [
   186,
   4.3
  ],
  [
   187,
   7.5
  ],
  [
   291,
   7.5
  ],
  [
   292,
   6.5
  ],
  [
   400,
   6.5
  ]
 ],
 "frames": [
  1,
  400
 ],
 "bob_mm": 6.0,
 "paint": null
}
```

## File: skills/blender-forest/scripts/check_missing.py
size: 216
sha: 301475629899b2a84c58e054c6a7b6716855bb5a
upstream: https://github.com/mromidev/claude-blender-skills/blob/main/skills/blender-forest/scripts/check_missing.py

```text
import bpy, os
miss=[i.filepath for i in bpy.data.images if i.source=='FILE' and not os.path.exists(bpy.path.abspath(i.filepath))]
print("##IMAGES", len(bpy.data.images), "MISSING", len(miss), sorted(set(miss))[:8])
```

## File: skills/blender-forest/scripts/gen_sfx.py
size: 1718
sha: 351e29529c726b3d25245d59634997611e78160d
upstream: https://github.com/mromidev/claude-blender-skills/blob/main/skills/blender-forest/scripts/gen_sfx.py

```text
"""Generate the clip's sound effects with your ElevenLabs account (sound-generation API).
Standard library only. Writes audio/<name>.mp3."""
import json, os, sys, urllib.request

KEY = os.environ.get("ELEVENLABS_API_KEY")
OUT = os.path.join(os.path.expanduser("~"), "blender-forest", "audio")
SFX = {
    "forest_amb":  ("Calm summer forest ambience, birds chirping in the distance, gentle wind moving through leaves, no people", 14.0),
    "car_far":     ("Porsche 911 GT3 RS flat-six engine approaching from a distance on a gravel forest road, tyres crunching gravel, driving past slowly", 5.0),
    "car_onboard": ("Close-up recording at the rear wheel of a sports car driving fast on a gravel road, loud flat-six engine, gravel crunching and small stones hitting the underbody, wind rush", 4.0),
    "car_away":    ("Sports car with a flat-six engine driving away on a gravel forest road, engine fading into the distance, birds in the background", 4.0),
    "whoosh":      ("Short subtle cinematic whoosh transition, airy, soft", 1.0),
}
if not KEY:
    sys.exit("ELEVENLABS_API_KEY not set")
os.makedirs(OUT, exist_ok=True)
for name, (text, dur) in SFX.items():
    path = os.path.join(OUT, name + ".mp3")
    if os.path.exists(path):
        print("skip", name); continue
    req = urllib.request.Request(
        "https://api.elevenlabs.io/v1/sound-generation",
        data=json.dumps({"text": text, "duration_seconds": dur, "prompt_influence": 0.45}).encode(),
        headers={"xi-api-key": KEY, "Content-Type": "application/json", "Accept": "audio/mpeg"})
    with urllib.request.urlopen(req, timeout=120) as r:
        open(path, "wb").write(r.read())
    print("ok", name, os.path.getsize(path), "bytes")
```

## File: skills/blender-forest/scripts/inspect_assets.py
size: 724
sha: 905d3c2dd14d71f3691997b1718e0468017bd774
upstream: https://github.com/mromidev/claude-blender-skills/blob/main/skills/blender-forest/scripts/inspect_assets.py

```text
import bpy, collections
print("##OBJ", len(bpy.data.objects), "COLL", len(bpy.data.collections), "MAT", len(bpy.data.materials), "IMG", len(bpy.data.images))
packed=sum(1 for i in bpy.data.images if i.packed_file); print("##IMG packed", packed, "of", len(bpy.data.images))
missing=[i.filepath for i in bpy.data.images if not i.packed_file and i.source=='FILE'][:5]; print("##IMG external sample", missing)
c=collections.Counter()
for o in bpy.data.objects:
    n=o.name.rsplit('.',1)[0]
    import re; n=re.sub(r'[_ ]?\d+$','',n); c[(n,o.type)]+=1
for k,v in sorted(c.items()): print("##NAME",k,v)
print("##ASSETS", len([o for o in bpy.data.objects if o.asset_data]), len([o for o in bpy.data.collections if o.asset_data]))
```

## File: skills/blender-forest/scripts/layout.py
size: 4172
sha: 0b98fa519b1899d2e68c4cbd9c37832b94e16bc9
upstream: https://github.com/mromidev/claude-blender-skills/blob/main/skills/blender-forest/scripts/layout.py

```text
"""Shared scene layout: where the cabins sit and where the gravel path runs.

Every stage (scatter masks, path boolean, cabins, cameras) reads these numbers,
so moving the cabin or bending the path is a one-place change.
Units: metres, terrain is a ~179 m disc centred on the origin.
"""
import numpy as np

# Cabin clearing (two A-frames + deck), rounded box.
CABIN_CENTER = (0.0, 6.0)
CABIN_HALF = (13.0, 9.0)      # half size x, y
CABIN_CORNER = 4.0            # corner rounding

# Gravel path: leaves the cabin deck southwards and winds to the terrain edge.
# No cabins any more: the road now runs right through the forest (north end extended).
PATH = [(6.0, 70.0), (2.0, 52.0), (-4.0, 36.0), (-6.0, 20.0), (-2.0, 8.0), (0.0, -3.0),
        (1.5, -14.0), (-2.5, -27.0), (-9.0, -39.0), (-8.0, -53.0), (-1.0, -66.0),
        (5.0, -80.0), (8.0, -90.0)]
PATH_HALF_WIDTH = 1.75         # 3.5 m gravel road (car is ~1.9 m wide)
USE_CABINS = False             # user dropped the cabins 2026-10-19

# Sun: his frames are backlit (sun behind trees/cabins). Azimuth from +X, degrees.
SUN_AZIMUTH = 110.0
SUN_ELEVATION = 40.0

# Path surface sits this far above the terrain (centre / edge) to avoid z-fighting.
LIFT_MID, LIFT_EDGE = 0.03, 0.005

# Cabins (from his frames). Local frame: gable/front faces -Y, floor top at z=0.
CABIN_A = dict(center=(-5.0, 7.2), rot=-15.0, W=6.6, D=10.0, eave=2.9, ridge=8.0)
CABIN_B = dict(center=(5.2, 8.0), rot=31.0, W=5.2, D=7.5, eave=2.6, ridge=6.6)
# Deck = union of rectangles (cx, cy, sx, sy, rot_deg), top a little above the highest ground.
DECK_RECTS = [(-5.1, 6.95, 8.4, 12.5, -15.0), (1.2, 4.0, 5.2, 4.8, 0.0), (5.2, 8.0, 6.8, 9.1, 31.0)]
DECK_THICK = 0.32

# Gravel pad between the path end and the cabin deck.
PAD_CENTER = (0.0, 0.5)
PAD_HALF = (7.0, 2.5)
PAD_CORNER = 2.0

def path_spline(step=0.1):
    """Catmull-Rom through PATH, resampled every `step` m. Returns (N,2)."""
    p = np.asarray(PATH, dtype=np.float64)
    p = np.vstack([2 * p[0] - p[1], p, 2 * p[-1] - p[-2]])
    out = []
    for i in range(1, len(p) - 2):
        p0, p1, p2, p3 = p[i - 1], p[i], p[i + 1], p[i + 2]
        n = max(2, int(np.linalg.norm(p2 - p1) / step))
        for t in np.linspace(0.0, 1.0, n, endpoint=False):
            t2, t3 = t * t, t * t * t
            out.append(0.5 * ((2 * p1) + (-p0 + p2) * t + (2 * p0 - 5 * p1 + 4 * p2 - p3) * t2
                              + (-p0 + 3 * p1 - 3 * p2 + p3) * t3))
    out.append(p[-2])
    return np.asarray(out)

def dist_to_pad(xy):
    c = np.asarray(PAD_CENTER)
    h = np.asarray(PAD_HALF) - PAD_CORNER
    q = np.abs(xy - c) - h
    return (np.linalg.norm(np.maximum(q, 0.0), axis=1)
            + np.minimum(np.max(q, axis=1), 0.0) - PAD_CORNER)

def dist_to_deck(xy):
    """Signed distance to the union of deck rectangles (negative on the deck)."""
    best = np.full(len(xy), np.inf)
    for cx, cy, sx, sy, rot in DECK_RECTS:
        r = np.radians(rot)
        d = xy - np.array([cx, cy])
        lx = d[:, 0] * np.cos(r) + d[:, 1] * np.sin(r)
        ly = -d[:, 0] * np.sin(r) + d[:, 1] * np.cos(r)
        q = np.abs(np.stack([lx, ly], axis=1)) - np.array([sx / 2, sy / 2])
        sd = np.linalg.norm(np.maximum(q, 0.0), axis=1) + np.minimum(np.max(q, axis=1), 0.0)
        best = np.minimum(best, sd)
    return best

def dist_to_path(xy):
    """xy: (N,2) array -> distance to the smoothed path centre line."""
    p = path_spline(0.5)
    best = np.full(len(xy), np.inf)
    for a, b in zip(p[:-1], p[1:]):
        ab = b - a
        t = np.clip(((xy - a) @ ab) / (ab @ ab), 0.0, 1.0)
        proj = a + t[:, None] * ab
        best = np.minimum(best, np.linalg.norm(xy - proj, axis=1))
    return best

def dist_to_cabin(xy):
    """Signed distance to the rounded cabin clearing (negative inside)."""
    c = np.asarray(CABIN_CENTER)
    h = np.asarray(CABIN_HALF) - CABIN_CORNER
    q = np.abs(xy - c) - h
    outside = np.linalg.norm(np.maximum(q, 0.0), axis=1)
    inside = np.minimum(np.max(q, axis=1), 0.0)
    return outside + inside - CABIN_CORNER

def smoothstep(e0, e1, x):
    t = np.clip((x - e0) / (e1 - e0), 0.0, 1.0)
    return t * t * (3.0 - 2.0 * t)
```

## File: skills/blender-forest/scripts/look.py
size: 7698
sha: acb3f764528f7caad0cb827acd9e3212803ab093
upstream: https://github.com/mromidev/claude-blender-skills/blob/main/skills/blender-forest/scripts/look.py

```text
"""Stage G: realism / look pass, driven entirely by scripts/look_params.json.

Applies on top of the built scene (run AFTER camset.py when testing, so DOF can focus):
  sun (direction, strength, softness), world light/sky split, colour management,
  haze from the mist pass, glare, colour grade, vignette, slight lens dispersion, camera DOF.

  LOOK_SAVE=1 blender -b scene/forest.blend --python scripts/look.py     # bake into the .blend
  CAM=... blender -b scene/forest.blend --python scripts/camset.py --python scripts/look.py       --python scripts/test_still.py -- out.png 64 960                    # test still, no save
"""
import bpy, json, math, os
from mathutils import Vector

HERE = os.path.dirname(os.path.abspath(__file__))
P = json.load(open(os.path.join(HERE, "look_params.json"), encoding="utf-8"))
sc = bpy.context.scene

def sun():
    s = P["sun"]
    ob = bpy.data.objects.get("Sun")
    ob.data.angle = math.radians(s["angle_deg"])
    ob.data.color = s["color"]
    if ob.animation_data and ob.animation_data.action:
        return                                   # per-shot sun from build_shots.py wins
    az, el = math.radians(s["azimuth"]), math.radians(s["elevation"])
    to_sun = Vector((math.cos(el) * math.cos(az), math.cos(el) * math.sin(az), math.sin(el)))
    ob.rotation_euler = (-to_sun).to_track_quat("-Z", "Y").to_euler()
    ob.data.energy = s["energy"]
    ob.data.angle = math.radians(s["angle_deg"])     # smaller = crisper leaf dapples
    ob.data.color = s["color"]

def world():
    w = P["world"]
    for n in sc.world.node_tree.nodes:
        if n.type == "BACKGROUND":
            n.inputs["Strength"].default_value = w["light_strength"] if n.label == "Lighting" else w["sky_strength"]
        if n.type == "MAPPING":
            n.inputs["Rotation"].default_value[2] = math.radians(w["hdri_rot_z_deg"])
    m = sc.world.mist_settings
    m.start, m.depth, m.falloff = P["haze"]["start"], P["haze"]["depth"], "QUADRATIC"

def colour():
    c = P["colour"]
    vs = sc.view_settings
    vs.view_transform = "Filmic"
    vs.look = c["look"]
    vs.exposure, vs.gamma = c["exposure"], c["gamma"]
    sc.render.motion_blur_shutter = P.get("shutter", 0.5)     # his car shot is only lightly blurred

def pick(sockets, name, kind=None):
    return next(s for s in sockets if s.name == name and (kind is None or s.type == kind))

def set_menu(sock, *options):
    for o in options:
        try:
            sock.default_value = o
            return
        except Exception:
            pass

def compositor():
    bpy.context.view_layer.use_pass_mist = True
    ng = bpy.data.node_groups.get("LookComp") or bpy.data.node_groups.new("LookComp", "CompositorNodeTree")
    ng.nodes.clear()
    if not ng.interface.items_tree:
        ng.interface.new_socket("Image", in_out="OUTPUT", socket_type="NodeSocketColor")
    N, Lk = ng.nodes.new, ng.links.new
    rl = N("CompositorNodeRLayers")
    out = N("NodeGroupOutput")

    # Haze: mix toward haze colour by mist * strength.
    h = P["haze"]
    # Sky pixels have mist == 1: keep them out of the haze so the blue sky stays blue.
    notsky = N("ShaderNodeMath"); notsky.operation = "LESS_THAN"
    Lk(rl.outputs["Mist"], notsky.inputs[0]); notsky.inputs[1].default_value = 0.995
    m1 = N("ShaderNodeMath"); m1.operation = "MULTIPLY"
    Lk(rl.outputs["Mist"], m1.inputs[0]); Lk(notsky.outputs[0], m1.inputs[1])
    fac = N("ShaderNodeMath"); fac.operation = "MULTIPLY"
    Lk(m1.outputs[0], fac.inputs[0]); fac.inputs[1].default_value = h["strength"]
    haze = N("ShaderNodeMix"); haze.data_type = "RGBA"
    Lk(fac.outputs[0], pick(haze.inputs, "Factor", "VALUE"))
    Lk(rl.outputs["Image"], pick(haze.inputs, "A", "RGBA"))
    pick(haze.inputs, "B", "RGBA").default_value = (*h["color"], 1.0)
    img = pick(haze.outputs, "Result", "RGBA")

    # Glare (soft bloom on sun-lit leaves / windows).
    g = P["glare"]
    gl = N("CompositorNodeGlare")
    set_menu(gl.inputs["Type"], "Bloom", "BLOOM", "Fog Glow", "FOG_GLOW")
    gl.inputs["Threshold"].default_value = g["threshold"]
    gl.inputs["Strength"].default_value = g["strength"]
    if "Size" in gl.inputs:
        gl.inputs["Size"].default_value = g["size"]
    Lk(img, gl.inputs["Image"]); img = gl.outputs["Image"]

    # Grade: lift / gamma / gain, then saturation.
    gr = P["grade"]
    cb = N("CompositorNodeColorBalance")
    for nm in ("Lift", "Gamma", "Gain"):
        pick(cb.inputs, nm, "RGBA").default_value = (*gr[nm.lower()], 1.0)
    Lk(img, cb.inputs["Image"]); img = cb.outputs["Image"]
    hs = N("CompositorNodeHueSat")
    hs.inputs["Saturation"].default_value = gr["saturation"]
    Lk(img, hs.inputs["Image"]); img = hs.outputs["Image"]

    # Lens: tiny chromatic dispersion like a real lens.
    ld = N("CompositorNodeLensdist")
    ld.inputs["Dispersion"].default_value = P["lens"]["dispersion"]
    Lk(img, ld.inputs["Image"]); img = ld.outputs["Image"]

    # Vignette: soft ellipse mask darkening the corners.
    em = N("CompositorNodeEllipseMask")
    em.inputs["Size"].default_value = (0.95, 0.95)
    bl = N("CompositorNodeBlur")
    bl.inputs["Size"].default_value = (300, 300)
    Lk(em.outputs["Mask"], bl.inputs["Image"])
    vf = N("ShaderNodeMath"); vf.operation = "MULTIPLY_ADD"
    Lk(bl.outputs["Image"], vf.inputs[0])
    vf.inputs[1].default_value = P["vignette"]
    vf.inputs[2].default_value = 1.0 - P["vignette"]
    vg = N("ShaderNodeMix"); vg.data_type = "RGBA"; vg.blend_type = "MULTIPLY"
    pick(vg.inputs, "Factor", "VALUE").default_value = 1.0
    Lk(img, pick(vg.inputs, "A", "RGBA"))
    Lk(vf.outputs[0], pick(vg.inputs, "B", "RGBA"))
    Lk(pick(vg.outputs, "Result", "RGBA"), out.inputs[0])
    sc.compositing_node_group = ng
    sc.render.use_compositing = True

def dof():
    d = P["dof"]
    cam = sc.camera
    if not cam or "shot" in cam:
        return                                   # shot cameras carry their own DOF
    cam.data.dof.use_dof = d["enabled"]
    cam.data.dof.aperture_fstop = d["fstop"]
    # Focus on whatever is in the centre of frame.
    dg = bpy.context.evaluated_depsgraph_get()
    fwd = cam.matrix_world.to_quaternion() @ Vector((0, 0, -1))
    hit, loc, *_ = sc.ray_cast(dg, cam.matrix_world.translation, fwd)
    cam.data.dof.focus_distance = (loc - cam.matrix_world.translation).length if hit else 10.0

def materials():
    """look_params "materials": {"<material name>": {"value": 0.9, "saturation": 1.0}} -> HSV before Base Color."""
    for name, o in P.get("materials", {}).items():
        m = bpy.data.materials.get(name)
        if not m or not m.node_tree:
            print("material not found:", name)
            continue
        nt = m.node_tree
        bsdf = next((n for n in nt.nodes if n.type == "BSDF_PRINCIPLED"), None)
        if not bsdf:
            continue
        hsv = nt.nodes.get("LookHSV")
        if not hsv:
            src = bsdf.inputs["Base Color"].links[0].from_socket if bsdf.inputs["Base Color"].links else None
            hsv = nt.nodes.new("ShaderNodeHueSaturation")
            hsv.name = "LookHSV"
            if src:
                nt.links.new(src, hsv.inputs["Color"])
            else:
                hsv.inputs["Color"].default_value = bsdf.inputs["Base Color"].default_value
            nt.links.new(hsv.outputs["Color"], bsdf.inputs["Base Color"])
        hsv.inputs["Value"].default_value = o.get("value", 1.0)
        hsv.inputs["Saturation"].default_value = o.get("saturation", 1.0)

sun(); world(); colour(); compositor(); dof(); materials()
print("LOOK applied")
if os.environ.get("LOOK_SAVE") == "1":
    bpy.ops.wm.save_mainfile(relative_remap=True)
    print("LOOK SAVED")
```

## File: skills/blender-forest/scripts/look_params.json
size: 1199
sha: 1f710317117225e4fef7ea3682b773b3df1f1241
upstream: https://github.com/mromidev/claude-blender-skills/blob/main/skills/blender-forest/scripts/look_params.json

```text
{
 "sun": {
  "azimuth": 268.0,
  "elevation": 42.0,
  "energy": 24.0,
  "angle_deg": 0.6,
  "color": [
   1.0,
   0.94,
   0.84
  ]
 },
 "world": {
  "light_strength": 1.6,
  "sky_strength": 1.0,
  "hdri_rot_z_deg": 0.0
 },
 "colour": {
  "exposure": 1.45,
  "gamma": 0.95,
  "look": "High Contrast"
 },
 "haze": {
  "start": 12.0,
  "depth": 110.0,
  "strength": 0.25,
  "color": [
   0.78,
   0.84,
   0.9
  ]
 },
 "glare": {
  "threshold": 1.0,
  "strength": 0.15,
  "size": 0.5
 },
 "grade": {
  "lift": [
   1.0,
   1.0,
   1.0
  ],
  "gamma": [
   1.0,
   1.0,
   1.0
  ],
  "gain": [
   1.03,
   1.02,
   0.97
  ],
  "saturation": 0.98
 },
 "vignette": 0.22,
 "lens": {
  "dispersion": 0.004
 },
 "dof": {
  "enabled": true,
  "fstop": 4.0
 },
 "scatter": {
  "Ferns": {
   "density_mul": 2.0,
   "scale_mul": 1.0
  },
  "GroundPlants": {
   "density_mul": 1.8,
   "scale_mul": 1.0
  },
  "Leaves": {
   "density_mul": 0.7,
   "scale_mul": 1.0
  }
 },
 "materials": {
  "AE_15th_Gravel": {
   "value": 0.8,
   "saturation": 1.0
  },
  "Wooden_Planks_01": {
   "value": 0.85,
   "saturation": 1.0
  },
  "Black_Painted Wood": {
   "value": 2.2,
   "saturation": 1.0
  }
 },
 "shutter": 0.35
}
```

## File: skills/blender-forest/scripts/preview.py
size: 1543
sha: 042823922d69b7ad44d15843074e4784aa280c5e
upstream: https://github.com/mromidev/claude-blender-skills/blob/main/skills/blender-forest/scripts/preview.py

```text
"""Fast animation preview: whole clip, low quality, to check motion/timing/cuts.

  blender -b scene/forest.blend --python scripts/look.py --python scripts/preview.py -- <out_dir>
Writes <out_dir>/p####.png (skips frames already rendered, so it resumes after a power cut).
"""
import bpy, sys, os, ctypes

out_dir = sys.argv[sys.argv.index("--") + 1]
try:
    k = ctypes.windll.kernel32
    k.SetPriorityClass(k.GetCurrentProcess(), 0x00004000)  # below normal
except Exception:
    pass

sc = bpy.context.scene
prefs = bpy.context.preferences.addons["cycles"].preferences
prefs.compute_device_type = "OPTIX"
prefs.get_devices()
for d in prefs.devices:
    d.use = d.type == "OPTIX"
cy = sc.cycles
cy.device = "GPU"
cy.samples = 4
cy.use_adaptive_sampling = False
cy.use_denoising = True
cy.denoiser = "OPTIX"
cy.max_bounces, cy.diffuse_bounces, cy.glossy_bounces = 3, 1, 1
cy.transmission_bounces, cy.transparent_max_bounces = 2, 4
sc.render.resolution_x, sc.render.resolution_y, sc.render.resolution_percentage = 960, 540, 100
sc.render.use_simplify = True
cy.texture_limit_render = "256"
sc.render.use_motion_blur = False
sc.render.use_persistent_data = True
sc.render.image_settings.file_format = "PNG"
sc.render.use_overwrite = False          # resume: keep frames already on disk
sc.render.use_placeholder = True
os.makedirs(out_dir, exist_ok=True)
sc.render.filepath = os.path.join(out_dir, "p####")
bpy.ops.render.render(animation=True)    # timeline markers switch the shot cameras
print("PREVIEW DONE", sc.frame_start, sc.frame_end)
```

## File: skills/blender-forest/scripts/render_clay.py
size: 1728
sha: 7a2954016478cc4c6126ff8b3d75eaae8e96fd30
upstream: https://github.com/mromidev/claude-blender-skills/blob/main/skills/blender-forest/scripts/render_clay.py

```text
"""Grey clay (Workbench, Solid shading) version of the 19-32 s car clip, for the tutorial's fake Blender viewport.
1280x720 PNG sequence in render/clay, resumable. Pass --test to time a single frame (frame 200) instead.

  blender -b --disable-autoexec scene/forest.blend --python scripts/render_clay.py [-- --test]
"""
import bpy, os, sys, time

ROOT = os.path.join(os.path.expanduser("~"), "blender-forest")
OUT = os.path.join(ROOT, "render", "clay")
test = "--test" in sys.argv

sc = bpy.context.scene
sc.render.engine = "BLENDER_WORKBENCH"
sh = sc.display.shading
sh.light = "STUDIO"
sh.color_type = "SINGLE"
sh.single_color = (0.8, 0.8, 0.8)
sh.show_cavity = False
sh.show_shadows = False
sh.shadow_intensity = 0.45
sh.show_object_outline = False
sc.display.render_aa = "OFF"
sc.render.film_transparent = False
w = sc.world
if w:  # Workbench uses the world viewport colour as the background
    w.color = (0.24, 0.24, 0.24)
sc.render.resolution_x, sc.render.resolution_y, sc.render.resolution_percentage = 960, 540, 100
sc.render.use_motion_blur = False
sc.render.image_settings.file_format = "PNG"
sc.render.image_settings.color_depth = "8"
sc.render.use_overwrite = False
sc.render.use_placeholder = True
os.makedirs(OUT, exist_ok=True)
print("CLAY settings ok", flush=True)
if test:
    sc.frame_set(200)
    sc.render.filepath = os.path.join(OUT, "test_f0200.png")
    t = time.time()
    bpy.ops.render.render(write_still=True)
    print(f"CLAY TEST frame 200: {time.time() - t:.1f} s  (frames {sc.frame_start}-{sc.frame_end})")
else:
    sc.render.filepath = os.path.join(OUT, "c####")
    print("CLAY start", sc.frame_start, sc.frame_end, "->", OUT)
    bpy.ops.render.render(animation=True)
    print("CLAY DONE")
```

## File: skills/blender-forest/scripts/render_final.cmd
size: 363
sha: f3b964cc73b74f302b8befcd9c7c3508afe4b312
upstream: https://github.com/mromidev/claude-blender-skills/blob/main/skills/blender-forest/scripts/render_final.cmd

```text
@echo off
rem Final render: resumes after a power cut. Removes empty placeholder frames first.
cd /d %USERPROFILE%\blender-forest
for %%F in (render\final_2k\*.png) do if %%~zF==0 del "%%F"
"C:\Program Files\Blender Foundation\Blender 5.2\blender.exe" -b scene\forest.blend --python scripts\look.py --python scripts\render_final.py > render\final_render.log 2>&1
```

## File: skills/blender-forest/scripts/render_final.py
size: 1681
sha: 3e84026a8aa13a1e47b41f058a176ff4f61a9a91
upstream: https://github.com/mromidev/claude-blender-skills/blob/main/skills/blender-forest/scripts/render_final.py

```text
"""Final render of the 19-32 s clip: 2560x1440, 128 samples + OpenImageDenoise (benchmarked:
no visible difference vs 256 samples, ~45% faster), motion blur on, PNG sequence.
Resumes after a power cut (frames already on disk are skipped).

  blender -b scene/forest.blend --python scripts/look.py --python scripts/render_final.py
"""
import bpy, os, ctypes

ROOT = os.path.join(os.path.expanduser("~"), "blender-forest")
OUT = os.path.join(ROOT, "render", "final_2k")
try:
    k = ctypes.windll.kernel32
    k.SetPriorityClass(k.GetCurrentProcess(), 0x00004000)  # below normal: PC stays usable
except Exception:
    pass

sc = bpy.context.scene
prefs = bpy.context.preferences.addons["cycles"].preferences
prefs.compute_device_type = "OPTIX"
prefs.get_devices()
for d in prefs.devices:
    d.use = d.type == "OPTIX"
cy = sc.cycles
cy.device = "GPU"
cy.samples = 128
cy.use_adaptive_sampling = True
cy.adaptive_threshold = 0.01
cy.use_denoising = True
cy.denoiser = "OPENIMAGEDENOISE"
try:
    cy.denoising_use_gpu = True
    cy.denoising_prefilter = "ACCURATE"
    cy.denoising_quality = "HIGH"
except AttributeError:
    pass
sc.render.resolution_x, sc.render.resolution_y, sc.render.resolution_percentage = 2560, 1440, 100
sc.render.use_simplify = False
sc.render.use_motion_blur = True
sc.render.use_persistent_data = True
sc.render.image_settings.file_format = "PNG"
sc.render.image_settings.color_depth = "16"
sc.render.use_overwrite = False
sc.render.use_placeholder = True
os.makedirs(OUT, exist_ok=True)
sc.render.filepath = os.path.join(OUT, "f####")
print("FINAL start", sc.frame_start, sc.frame_end, "->", OUT)
bpy.ops.render.render(animation=True)
print("FINAL DONE")
```

## File: skills/blender-forest/scripts/render_frames.py
size: 1923
sha: 72969816153be5e16b0dd8f2da25660a7c6e5694
upstream: https://github.com/mromidev/claude-blender-skills/blob/main/skills/blender-forest/scripts/render_frames.py

```text
"""Render chosen frames of the shot timeline (look-dev / checks). Does not save the .blend.

  blender -b scene/forest.blend --python scripts/look.py --python scripts/render_frames.py -- <out_dir> <samples> <width> <frame> [frame ...]
Writes <out_dir>/r####.png (#### = timeline frame).
"""
import bpy, sys, os, ctypes

argv = sys.argv[sys.argv.index("--") + 1:]
out_dir, samples, width = argv[0], int(argv[1]), int(argv[2])
frames = [int(f) for f in argv[3:]]

try:
    k = ctypes.windll.kernel32
    k.SetPriorityClass(k.GetCurrentProcess(), 0x00004000)  # below normal: PC stays usable
except Exception:
    pass

sc = bpy.context.scene
prefs = bpy.context.preferences.addons["cycles"].preferences
prefs.compute_device_type = "OPTIX"
prefs.get_devices()
for d in prefs.devices:
    d.use = d.type == "OPTIX"
sc.cycles.device = "GPU"
sc.cycles.samples = samples
sc.render.resolution_x, sc.render.resolution_y = width, round(width * 9 / 16)
sc.render.resolution_percentage = 100
if os.environ.get("FULL") == "1":          # final quality: full-res textures
    sc.render.use_simplify = False
else:
    sc.render.use_simplify = True
    sc.cycles.texture_limit_render = "2048"
sc.cycles.use_denoising = True
sc.cycles.denoiser = "OPTIX"
sc.render.image_settings.file_format = "PNG"
# Keep the static forest (730k instances, BVH, textures) on the GPU between frames instead of
# rebuilding it on the CPU for every frame - that rebuild was most of the render time.
sc.render.use_persistent_data = True
os.makedirs(out_dir, exist_ok=True)

markers = sorted((m for m in sc.timeline_markers if m.camera), key=lambda m: m.frame)
for f in frames:
    sc.frame_set(f)
    cams = [m.camera for m in markers if m.frame <= f]
    if cams:
        sc.camera = cams[-1]
    sc.render.filepath = os.path.join(out_dir, f"r{f:04d}.png")
    bpy.ops.render.render(write_still=True)
    print("FRAME", f, sc.camera.name, sc.render.filepath)
```

## File: skills/blender-forest/scripts/scatter.py
size: 16445
sha: 0574ec81e79aaf3bbedb37ac62b0f82711da344a
upstream: https://github.com/mromidev/claude-blender-skills/blob/main/skills/blender-forest/scripts/scatter.py

```text
"""Stage C: our own scatter system (replaces his paid Geo-Scatter).

How it works:
  1. Link (not copy) his plant objects from the library .blend into hidden LIB_* collections.
  2. Compute one density-factor attribute per layer on the terrain vertices in Python
     (path/cabin clearings, focus area, Musgrave-like patterns, his "reduce density").
  3. Each layer = an empty mesh object with a Geometry Nodes modifier we build here:
     Object Info(Terrain) -> Distribute Points (density x attribute) -> Instance on Points
     with his rotation/scale rules. Everything stays instanced (12 GB VRAM friendly).

Run:  blender -b scene/forest.blend --python scripts/scatter.py
Re-runnable: removes the previous scatter first, then saves scene/forest.blend.
"""
import bpy, math, os, sys, ctypes
import numpy as np
from mathutils import noise, Vector

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import layout as L

ROOT = os.path.join(os.path.expanduser("~"), "blender-forest")
LIB_BLEND = os.path.join(ROOT, "assets", "Plant models", "assets", "AE_15th Assets.blend")
P = "AE_15TH_"

try:
    k = ctypes.windll.kernel32
    k.SetPriorityClass(k.GetCurrentProcess(), 0x00004000)  # below normal
except Exception:
    pass

def names(prefix, nums):
    return [f"{P}{prefix}_{n:02d}" for n in nums]

# Layer table: his numbers from the tutorial where he gives them.
#  density   = instances per m2 before masks (Geo-Scatter "instances")
#  vi        = vertical influence (1 = straight up, 0 = follow ground normal)
#  scale     = (min, max) uniform scale; zmul squashes height
LAYERS = [
    dict(name="BirchTrees", objs=names("BIRCH", range(1, 12)), density=0.06, poisson=1.5,
         mask="trees", pattern=(1 / 25.0, 1.0, 1.0), pattern_infl=0.3, vi=0.85, tilt=5, scale=(1.6, 1.6), seed=11),
    dict(name="YoungTrees", objs=names("TREE_YOUNG", (1, 2)), density=0.05, poisson=1.5,
         mask="trees", vi=0.85, tilt=5, scale=(0.7, 1.3), seed=12),
    dict(name="BigTrees", objs=names("TREE", (1, 2, 3)), density=0.012, poisson=5.0,
         mask="trees", vi=0.9, tilt=3, scale=(0.9, 1.2), seed=13),
    dict(name="LargeShrubs", objs=names("FOREST_BUSH", range(1, 8)), density=0.5,
         mask="shrubs", pattern=(1 / 12.0, 0.7, 1.6), vi=0.4, tilt=8, scale=(1.07, 1.6), seed=14),
    dict(name="SmallShrubs", objs=names("FOREST_BUSH", range(3, 8)), density=0.4,
         mask="cabin_ring", vi=0.5, tilt=0, scale=(0.35, 0.7), seed=15),
    dict(name="Ferns", objs=names("FERN", range(1, 8)), density=5.0,
         mask="ground", pattern=(1 / 4.0, 1.23, 1.87), pattern_infl=0.6, vi=0.5, tilt=10, scale=(0.6, 1.5), seed=16),
    dict(name="Branches", objs=names("BRANCH", [1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 13, 14, 15]),
         density=1.85, mask="ground", vi=0.0, tilt=0, scale=(0.5, 1.5), seed=17),
    dict(name="Debris", objs=names("DEBRIS", range(3, 16)), density=3.0,
         mask="ground", vi=0.0, tilt=0, scale=(0.34, 1.0), zoff=0.01, seed=18),
    dict(name="Leaves", objs=names("DRY_LEAVES", (1, 2, 3)), density=200.0 * 0.18,  # 70% reduce + thinned (litter read as tiles)
         mask="leaves", pattern=(1 / 10.0, 0.9, 1.0), pattern_infl=0.9,
         vi=0.0, tilt=0, scale=(0.4, 0.8), seed=19),
    dict(name="GroundPlants", objs=names("GROUND_PLANT", range(1, 7)), density=24.0 * 0.5,  # 50% reduce
         mask="ground", vi=0.5, tilt=0, scale=(0.6, 1.4), zmul=0.9, seed=20),
    # Seen in his frames (rocks, fallen logs, stumps) but placed by hand in the video.
    dict(name="Rocks", objs=names("ROCK", range(1, 9)), density=0.04,
         mask="ground", vi=0.3, tilt=20, scale=(0.6, 1.6), zoff=-0.05, seed=21),
    dict(name="FallenTrees", objs=names("FALLEN_TREE", range(1, 8)), density=0.008, poisson=4.0,
         mask="ground", vi=0.0, tilt=0, scale=(0.9, 1.4), zoff=-0.03, seed=22),
    dict(name="Stumps", objs=names("TREE_STUMP", (1, 2)), density=0.002,
         mask="trees", vi=0.6, tilt=0, scale=(0.8, 1.2), zoff=-0.03, seed=23),
]

# ---------------------------------------------------------------- cleanup
def remove_previous():
    for o in list(bpy.data.objects):
        if o.name.startswith("Scatter_"):
            me = o.data
            bpy.data.objects.remove(o, do_unlink=True)
            if me and me.users == 0:
                bpy.data.meshes.remove(me)
    for ng in list(bpy.data.node_groups):
        if ng.name.startswith("ScatterGN_"):
            bpy.data.node_groups.remove(ng)
    for c in list(bpy.data.collections):
        if c.name.startswith("LIB_") or c.name in ("LIBRARY", "Scatter"):
            for o in list(c.objects):
                c.objects.unlink(o)
            bpy.data.collections.remove(c)

# ---------------------------------------------------------------- library
def link_library():
    wanted = sorted({n for layer in LAYERS for n in layer["objs"]})
    with bpy.data.libraries.load(LIB_BLEND, link=True) as (src, dst):
        missing = [n for n in wanted if n not in src.objects]
        if missing:
            raise RuntimeError(f"missing in library: {missing}")
        dst.objects = wanted
    linked = {o.name: o for o in dst.objects}
    lib_root = bpy.data.collections.new("LIBRARY")
    bpy.context.scene.collection.children.link(lib_root)
    for layer in LAYERS:
        col = bpy.data.collections.new("LIB_" + layer["name"])
        lib_root.children.link(col)
        for n in layer["objs"]:
            col.objects.link(linked[n])
        layer["collection"] = col
    # Excluded from the view layer: never rendered directly, only through instancing.
    bpy.context.view_layer.layer_collection.children["LIBRARY"].exclude = True

# ---------------------------------------------------------------- masks
def fbm(xy, freq, seed):
    off = Vector((seed * 31.7, seed * 17.3, seed * 5.1))
    return np.array([noise.fractal(Vector((x * freq, y * freq, 0.0)) + off, 1.0, 2.0, 4,
                                   noise_basis="PERLIN_ORIGINAL") for x, y in xy])

def pattern(xy, freq, bright, contrast, seed, infl=1.0):
    n = fbm(xy, freq, seed)
    v = np.clip(((0.5 + 0.5 * n) - 0.5) * contrast + 0.5, 0.0, 1.0) * bright
    v = np.clip(v, 0.0, 1.0)
    return 1.0 - infl + infl * v

def compute_masks(terrain):
    me = terrain.data
    co = np.empty(len(me.vertices) * 3)
    me.vertices.foreach_get("co", co)
    xy = co.reshape(-1, 3)[:, :2]
    d_path = L.dist_to_path(xy)
    d_cab = L.dist_to_cabin(xy)
    r = np.linalg.norm(xy, axis=1)
    hw = L.PATH_HALF_WIDTH
    edge_ok = 1.0 - L.smoothstep(84.0, 88.0, r)
    near = np.minimum(d_path, d_cab)
    focus = 1.0 - L.smoothstep(40.0, 55.0, near)
    if L.USE_CABINS:
        d_deck = L.dist_to_deck(xy)
        d_pad = L.dist_to_pad(xy)
    else:                                   # no cabins: nothing to clear there, forest fills in
        d_deck = d_pad = np.full(len(xy), 1e9)
        d_cab = np.full(len(xy), 1e9)
        near = d_path
    clear = np.maximum.reduce([1.0 - L.smoothstep(hw - 0.2, hw + 1.0, d_path),
                               1.0 - L.smoothstep(-0.2, 0.6, d_deck),
                               1.0 - L.smoothstep(-0.3, 0.8, d_pad)])
    focus = np.maximum(1.0 - L.smoothstep(40.0, 55.0, near), 1.0 - L.smoothstep(20.0, 30.0, d_cab))
    masks = {
        "trees": L.smoothstep(3.0, 5.0, d_path) * L.smoothstep(4.0, 7.0, d_cab) * edge_ok,
        "shrubs": L.smoothstep(2.0, 3.5, d_path) * L.smoothstep(2.0, 5.0, d_cab) * edge_ok,
        "ground": (1.0 - clear) * focus * edge_ok,
        "leaves": L.smoothstep(hw - 0.9, hw + 0.3, d_path) * (1.0 - clear * 0.85) * focus * edge_ok,
        "cabin_ring": L.smoothstep(-0.5, 0.5, d_cab) * (1.0 - L.smoothstep(4.0, 8.0, d_cab))
                      * L.smoothstep(2.0, 3.0, d_path),
    }
    # Path surface height above terrain, so litter at the gravel edges sits on top of it.
    t = np.clip(d_path / L.PATH_HALF_WIDTH, 0.0, 1.0)
    lift = np.where(d_path < L.PATH_HALF_WIDTH * 1.15,
                    L.LIFT_EDGE + (L.LIFT_MID - L.LIFT_EDGE) * (1.0 - t * t), 0.0)
    lift = np.maximum(lift, np.where(L.dist_to_pad(xy) < 0, L.LIFT_MID, 0.0))
    la = me.attributes.get("lift") or me.attributes.new("lift", "FLOAT", "POINT")
    la.data.foreach_set("value", lift.astype(np.float32))
    # Store a "center" weight like his vertex group (1 = cabin + path) for later stages.
    vg = terrain.vertex_groups.get("center") or terrain.vertex_groups.new(name="center")
    for i, w in enumerate(clear):
        if w > 0.001:
            vg.add([i], float(w), "REPLACE")
    for layer in LAYERS:
        f = masks[layer["mask"]].copy()
        if "pattern" in layer:
            freq, bright, contrast = layer["pattern"]
            f *= pattern(xy, freq, bright, contrast, layer["seed"], layer.get("pattern_infl", 1.0))
        attr_name = "d_" + layer["name"]
        a = me.attributes.get(attr_name) or me.attributes.new(attr_name, "FLOAT", "POINT")
        a.data.foreach_set("value", f.astype(np.float32))
        layer["attr"] = attr_name
        print(f"mask {layer['name']:13s} mean {f.mean():.3f}")

# ---------------------------------------------------------------- geometry nodes
def sock(sockets, name, kind=None):
    for s in sockets:
        if s.name == name and s.enabled and (kind is None or s.type == kind):
            return s
    raise KeyError(f"{name} ({kind})")

def build_group(layer, terrain):
    ng = bpy.data.node_groups.new("ScatterGN_" + layer["name"], "GeometryNodeTree")
    ng.interface.new_socket("Geometry", in_out="INPUT", socket_type="NodeSocketGeometry")
    ng.interface.new_socket("Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
    N, Ln = ng.nodes.new, ng.links.new
    gin = N("NodeGroupInput"); gout = N("NodeGroupOutput")

    info = N("GeometryNodeObjectInfo")
    info.transform_space = "RELATIVE"
    info.inputs["Object"].default_value = terrain

    attr = N("GeometryNodeInputNamedAttribute")
    attr.data_type = "FLOAT"
    attr.inputs["Name"].default_value = layer["attr"]

    dist = N("GeometryNodeDistributePointsOnFaces")
    if "poisson" in layer:
        dist.distribute_method = "POISSON"
        sock(dist.inputs, "Distance Min").default_value = layer["poisson"]
        sock(dist.inputs, "Density Max").default_value = layer["density"]
        Ln(sock(attr.outputs, "Attribute"), sock(dist.inputs, "Density Factor"))
    else:
        dist.distribute_method = "RANDOM"
        mul = N("ShaderNodeMath"); mul.operation = "MULTIPLY"
        mul.inputs[1].default_value = layer["density"]
        Ln(sock(attr.outputs, "Attribute"), mul.inputs[0])
        Ln(mul.outputs[0], sock(dist.inputs, "Density"))
    sock(dist.inputs, "Seed").default_value = layer["seed"]
    Ln(info.outputs["Geometry"], dist.inputs["Mesh"])

    # Rotation: lean between ground normal and world up, then random tilt + yaw.
    mix = N("ShaderNodeMix"); mix.data_type = "VECTOR"
    sock(mix.inputs, "Factor", "VALUE").default_value = layer["vi"]
    Ln(sock(dist.outputs, "Normal"), sock(mix.inputs, "A", "VECTOR"))
    sock(mix.inputs, "B", "VECTOR").default_value = (0, 0, 1)
    nrm = N("ShaderNodeVectorMath"); nrm.operation = "NORMALIZE"
    Ln(sock(mix.outputs, "Result", "VECTOR"), nrm.inputs[0])
    align = N("FunctionNodeAlignRotationToVector"); align.axis = "Z"
    Ln(nrm.outputs["Vector"], sock(align.inputs, "Vector"))

    t = math.radians(layer["tilt"])
    rnd_rot = N("FunctionNodeRandomValue"); rnd_rot.data_type = "FLOAT_VECTOR"
    sock(rnd_rot.inputs, "Min", "VECTOR").default_value = (-t, -t, 0.0)
    sock(rnd_rot.inputs, "Max", "VECTOR").default_value = (t, t, 2 * math.pi)
    sock(rnd_rot.inputs, "Seed").default_value = layer["seed"] + 100
    e2r = N("FunctionNodeEulerToRotation")
    Ln(sock(rnd_rot.outputs, "Value", "VECTOR"), e2r.inputs[0])
    rot = N("FunctionNodeRotateRotation")
    try:
        rot.rotation_space = "LOCAL"
    except AttributeError:
        pass
    Ln(sock(align.outputs, "Rotation"), sock(rot.inputs, "Rotation"))
    Ln(e2r.outputs[0], sock(rot.inputs, "Rotate By"))

    # Scale: random uniform in (min, max), optional z squash.
    rnd_s = N("FunctionNodeRandomValue"); rnd_s.data_type = "FLOAT"
    sock(rnd_s.inputs, "Min", "VALUE").default_value = layer["scale"][0]
    sock(rnd_s.inputs, "Max", "VALUE").default_value = layer["scale"][1]
    sock(rnd_s.inputs, "Seed").default_value = layer["seed"] + 200
    zm = N("ShaderNodeMath"); zm.operation = "MULTIPLY"
    zm.inputs[1].default_value = layer.get("zmul", 1.0)
    s_out = sock(rnd_s.outputs, "Value", "VALUE")
    Ln(s_out, zm.inputs[0])
    cxyz = N("ShaderNodeCombineXYZ")
    Ln(s_out, cxyz.inputs[0]); Ln(s_out, cxyz.inputs[1]); Ln(zm.outputs[0], cxyz.inputs[2])

    # Random pick of which plant model.
    rnd_i = N("FunctionNodeRandomValue"); rnd_i.data_type = "INT"
    sock(rnd_i.inputs, "Min", "INT").default_value = 0
    sock(rnd_i.inputs, "Max", "INT").default_value = 9999
    sock(rnd_i.inputs, "Seed").default_value = layer["seed"] + 300

    col = N("GeometryNodeCollectionInfo")
    col.transform_space = "ORIGINAL"
    col.inputs["Collection"].default_value = layer["collection"]
    sock(col.inputs, "Separate Children").default_value = True
    sock(col.inputs, "Reset Children").default_value = True

    # Offset = (0, 0, lift + zoff): his z offset, plus riding on top of the gravel.
    lift = N("GeometryNodeInputNamedAttribute"); lift.data_type = "FLOAT"
    lift.inputs["Name"].default_value = "lift"
    add = N("ShaderNodeMath"); add.operation = "ADD"
    add.inputs[1].default_value = layer.get("zoff", 0.0)
    Ln(sock(lift.outputs, "Attribute"), add.inputs[0])
    off = N("ShaderNodeCombineXYZ")
    Ln(add.outputs[0], off.inputs[2])
    sp = N("GeometryNodeSetPosition")
    Ln(dist.outputs["Points"], sp.inputs["Geometry"])
    Ln(off.outputs[0], sp.inputs["Offset"])
    pts = sp.outputs["Geometry"]

    iop = N("GeometryNodeInstanceOnPoints")
    Ln(pts, iop.inputs["Points"])
    Ln(col.outputs[0], iop.inputs["Instance"])
    sock(iop.inputs, "Pick Instance").default_value = True
    Ln(sock(rnd_i.outputs, "Value", "INT"), sock(iop.inputs, "Instance Index"))
    Ln(rot.outputs[0], sock(iop.inputs, "Rotation"))
    Ln(cxyz.outputs[0], sock(iop.inputs, "Scale"))
    Ln(iop.outputs["Instances"], gout.inputs[0])
    return ng

def build_layers(terrain):
    scol = bpy.data.collections.new("Scatter")
    bpy.context.scene.collection.children.link(scol)
    for layer in LAYERS:
        me = bpy.data.meshes.new("Scatter_" + layer["name"])
        ob = bpy.data.objects.new("Scatter_" + layer["name"], me)
        scol.objects.link(ob)
        mod = ob.modifiers.new("Scatter", "NODES")
        mod.node_group = build_group(layer, terrain)
        mod.show_viewport = False   # keep the UI light when the file is opened by hand

def report():
    dg = bpy.context.evaluated_depsgraph_get()
    counts = {}
    for inst in dg.object_instances:
        if inst.is_instance and inst.parent and inst.parent.name.startswith("Scatter_"):
            k = inst.parent.name[8:]
            counts[k] = counts.get(k, 0) + 1
    for k, v in counts.items():
        print(f"instances {k:13s} {v:8d}")
    print("instances total", sum(counts.values()))

def apply_overrides():
    """look_params.json -> "scatter": {"Ferns": {"density_mul": 1.5, "scale_mul": 1.1}, ...}"""
    import json
    p = os.path.join(os.path.dirname(os.path.abspath(__file__)), "look_params.json")
    if not os.path.exists(p):
        return
    ov = json.load(open(p, encoding="utf-8")).get("scatter", {})
    for layer in LAYERS:
        o = ov.get(layer["name"])
        if o:
            layer["density"] *= o.get("density_mul", 1.0)
            sm = o.get("scale_mul", 1.0)
            layer["scale"] = (layer["scale"][0] * sm, layer["scale"][1] * sm)
            print("override", layer["name"], o)

def main():
    apply_overrides()
    remove_previous()
    terrain = bpy.data.objects["Terrain"]
    link_library()
    compute_masks(terrain)
    build_layers(terrain)
    if "--count" in sys.argv:
        for o in bpy.data.objects:
            if o.name.startswith("Scatter_"):
                o.modifiers["Scatter"].show_viewport = True
        report()
        for o in bpy.data.objects:
            if o.name.startswith("Scatter_"):
                o.modifiers["Scatter"].show_viewport = False
    bpy.ops.wm.save_mainfile(relative_remap=True)
    print("SAVED", bpy.data.filepath)

main()
```

## File: skills/blender-forest/scripts/screen_guard.ps1
size: 1349
sha: 75cebb56f25904241f3f5fa14f59790c1026c06f
upstream: https://github.com/mromidev/claude-blender-skills/blob/main/skills/blender-forest/scripts/screen_guard.ps1

```text
# Keeps the monitor OFF whenever nobody has touched mouse/keyboard for 15 s.
# Notifications can't keep it on: it is switched off again within ~3 s.
# Runs up to 10 hours. To stop early: create the file render\stop_guard
Add-Type -TypeDefinition @"
using System; using System.Runtime.InteropServices;
public class G {
  [StructLayout(LayoutKind.Sequential)] public struct LII { public uint cbSize; public uint dwTime; }
  [DllImport("user32.dll")] public static extern bool GetLastInputInfo(ref LII p);
  [DllImport("user32.dll")] public static extern bool PostMessage(IntPtr h, uint m, IntPtr w, IntPtr l);
  public static uint IdleMs() { LII l = new LII(); l.cbSize = (uint)Marshal.SizeOf(l); GetLastInputInfo(ref l); return (uint)Environment.TickCount - l.dwTime; }
  public static void Off() { PostMessage((IntPtr)0xffff, 0x0112, (IntPtr)0xF170, (IntPtr)2); }
}
"@
$root = Join-Path $env:USERPROFILE "blender-forest\render"
$log = Join-Path $root "screen_guard.log"
$stop = Join-Path $root "stop_guard"
Remove-Item $stop -ErrorAction SilentlyContinue
"guard v2 started $(Get-Date)" | Out-File $log
$end = (Get-Date).AddHours(10)
while ((Get-Date) -lt $end -and -not (Test-Path $stop)) {
    if ([G]::IdleMs() -gt 15000) { [G]::Off() }   # PostMessage never blocks
    Start-Sleep -Seconds 3
}
"guard stopped $(Get-Date)" | Out-File $log -Append
```

## File: skills/blender-forest/scripts/shots.json
size: 2739
sha: c1afdf8703dbf3c1dcbf319b53977b76f4a401f1
upstream: https://github.com/mromidev/claude-blender-skills/blob/main/skills/blender-forest/scripts/shots.json

```text
{
 "fps": 30,
 "frame_end": 390,
 "comment": "His 19.0-32.0 s (f570-f959). Timeline frame 1 = his f570. Car drives south on the road (see car.json).",
 "shots": [
  {
   "name": "S1",
   "start": 1,
   "end": 50,
   "lens": 40,
   "fstop": 5.6,
   "focus": 10.0,
   "ease": "linear",
   "shake": 0.0,
   "cam_start": {
    "pos": [
     9.0,
     16.0,
     0.6
    ],
    "yaw": 135,
    "pitch": 95,
    "roll": 0.0
   },
   "cam_end": {
    "pos": [
     8.85,
     16.2,
     0.6
    ],
    "yaw": 135,
    "pitch": 95,
    "roll": 0.0
   },
   "sun": {
    "azimuth": 200.0,
    "elevation": 45.0,
    "energy": 30.0
   },
   "props": [
    {
     "name": "AE_15TH_FOREST_BUSH_01",
     "pos": [
      8.0,
      15.6
     ],
     "rot": 20.0,
     "scale": 1.4,
     "z": 0.0
    },
    {
     "name": "AE_15TH_TREE_YOUNG_02",
     "pos": [
      8.5,
      14.6
     ],
     "rot": 30.0,
     "scale": 0.9,
     "z": -4.0
    },
    {
     "name": "AE_15TH_TREE_YOUNG_01",
     "pos": [
      5.5,
      13.5
     ],
     "rot": 0.0,
     "scale": 0.8
    }
   ]
  },
  {
   "name": "S2",
   "start": 51,
   "end": 186,
   "lens": 26,
   "fstop": 8.0,
   "focus": 15.0,
   "ease": "linear",
   "shake": 0.0,
   "cam_start": {
    "pos": [
     8.0,
     14.0,
     1.2
    ],
    "yaw": 130,
    "pitch": 90.0,
    "roll": 0.0
   },
   "cam_end": {
    "pos": [
     8.0,
     14.0,
     1.2
    ],
    "yaw": 130,
    "pitch": 90.0,
    "roll": 0.0
   },
   "sun": {
    "azimuth": 215.0,
    "elevation": 50.0,
    "energy": 28.0
   },
   "props": [
    {
     "name": "AE_15TH_BIRCH_04",
     "pos": [
      6.5,
      11.4
     ],
     "rot": 10.0,
     "scale": 1.3
    }
   ]
  },
  {
   "name": "S3",
   "start": 187,
   "end": 291,
   "parent": "CarRoot",
   "pano": {
    "type": "FISHEYE_EQUISOLID",
    "fov": 200,
    "lens": 28
   },
   "fstop": 0,
   "ease": "linear",
   "shake": 0.0,
   "cam_start": {
    "pos": [
     -2.1,
     -2.8,
     0.65
    ],
    "yaw": -18,
    "pitch": 91,
    "roll": 0.0
   },
   "cam_end": {
    "pos": [
     -2.1,
     -2.8,
     0.65
    ],
    "yaw": -18,
    "pitch": 91,
    "roll": 0.0
   },
   "sun": {
    "azimuth": 270.0,
    "elevation": 45.0,
    "energy": 24.0
   }
  },
  {
   "name": "S4",
   "start": 292,
   "end": 390,
   "lens": 20,
   "fstop": 4.0,
   "focus": 8.5,
   "ease": "linear",
   "shake": 0.0,
   "cam_start": {
    "pos": [
     1.0,
     -30.5,
     9.0
    ],
    "yaw": 180.0,
    "pitch": 0.0,
    "roll": 0.0
   },
   "cam_end": {
    "pos": [
     -5.6,
     -45.8,
     8.0
    ],
    "yaw": 180.0,
    "pitch": 0.0,
    "roll": 0.0
   },
   "sun": {
    "azimuth": 250.0,
    "elevation": 60.0,
    "energy": 24.0
   },
   "props": []
  }
 ]
}
```

## File: skills/blender-forest/scripts/stay_awake_encode.ps1
size: 1278
sha: 665550dbb69da4bf725274bcc1ef6e956549db96
upstream: https://github.com/mromidev/claude-blender-skills/blob/main/skills/blender-forest/scripts/stay_awake_encode.ps1

```text
# Keeps the PC awake (no settings changed) while the final render runs, then makes the MP4.
Add-Type -TypeDefinition @"
using System; using System.Runtime.InteropServices;
public class Awake { [DllImport("kernel32.dll")] public static extern uint SetThreadExecutionState(uint f); }
"@
$ES_CONTINUOUS = [uint32]2147483648; $ES_SYSTEM_REQUIRED = [uint32]1
$root = Join-Path $env:USERPROFILE "blender-forest"
$log = Join-Path $root "render\stay_awake.log"
"started $(Get-Date)" | Out-File $log
[Awake]::SetThreadExecutionState($ES_CONTINUOUS -bor $ES_SYSTEM_REQUIRED) | Out-Null
while (Get-CimInstance Win32_Process -Filter "Name='blender.exe'" | Where-Object { $_.CommandLine -like '*render_final.py*' }) {
    [Awake]::SetThreadExecutionState($ES_CONTINUOUS -bor $ES_SYSTEM_REQUIRED) | Out-Null
    Start-Sleep 60
}
$n = (Get-ChildItem "$root\render\final_2k" -Filter "f*.png" | Where-Object Length -gt 0).Count
"render ended $(Get-Date), frames $n" | Out-File $log -Append
if ($n -ge 390) {
    & ffmpeg -loglevel error -y -framerate 30 -i "$root\render\final_2k\f%04d.png" -c:v libx264 -preset slow -crf 14 -pix_fmt yuv420p "$root\render\forest_car_19-32s_2K.mp4"
    "mp4 done $(Get-Date)" | Out-File $log -Append
}
[Awake]::SetThreadExecutionState($ES_CONTINUOUS) | Out-Null
```

## File: skills/blender-forest/scripts/test_still.py
size: 1911
sha: e86790a307f9365983d38300552ebc8c9c0804d2
upstream: https://github.com/mromidev/claude-blender-skills/blob/main/skills/blender-forest/scripts/test_still.py

```text
"""Cheap look-dev still from scene/forest.blend (does not modify the .blend).

  blender -b scene/forest.blend --python scripts/test_still.py -- <out.png> [samples] [width]
"""
import bpy, sys, os, math, ctypes
from mathutils import Vector

argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
out = argv[0] if argv else os.path.join(os.path.expanduser("~"), "blender-forest", "scene", "tests", "still.png")
samples = int(argv[1]) if len(argv) > 1 else 32
width = int(argv[2]) if len(argv) > 2 else 960

try:
    k = ctypes.windll.kernel32
    k.SetPriorityClass(k.GetCurrentProcess(), 0x00004000)  # below normal
except Exception:
    pass

sc = bpy.context.scene
prefs = bpy.context.preferences.addons["cycles"].preferences
prefs.compute_device_type = "OPTIX"
prefs.get_devices()
for d in prefs.devices:
    d.use = d.type == "OPTIX"
sc.cycles.device = "GPU"

cam = sc.camera
if cam is None:
    # Default look-dev camera: eye level (~0.6 m) like shot 2, looking across the ground.
    data = bpy.data.cameras.new("LookDev")
    data.lens = 28
    cam = bpy.data.objects.new("LookDev", data)
    sc.collection.objects.link(cam)
    terrain = bpy.data.objects["Terrain"]
    dg = bpy.context.evaluated_depsgraph_get()
    x, y = 6.0, -4.0
    hit, loc, *_ = sc.ray_cast(dg, Vector((x, y, 50)), Vector((0, 0, -1)))
    z = loc.z if hit else 0.0
    cam.location = (x, y, z + 0.6)
    cam.rotation_euler = (math.radians(86), 0, math.radians(20))
    sc.camera = cam

sc.render.resolution_x = width
sc.render.resolution_y = round(width * 9 / 16)
sc.render.resolution_percentage = 100
sc.cycles.samples = samples
sc.render.use_simplify = True
sc.cycles.texture_limit_render = "2048"
sc.render.use_motion_blur = False
sc.render.image_settings.file_format = "PNG"
sc.render.filepath = out
os.makedirs(os.path.dirname(out), exist_ok=True)
bpy.ops.render.render(write_still=True)
print("STILL", out)
```

## File: skills/blender-forest/scripts/watch_render.ps1
size: 2415
sha: b799b4f0ab417d5164bf1a5c5ed3537e253f2be7
upstream: https://github.com/mromidev/claude-blender-skills/blob/main/skills/blender-forest/scripts/watch_render.ps1

```text
# Live progress for the final render: frames done, speed, time left.
$dir = Join-Path $env:USERPROFILE "blender-forest\render\final_2k"
$total = 390
$host.UI.RawUI.WindowTitle = "Forest render - progress"
$start = Get-Date
$startDone = $null
while ($true) {
    $files = @(Get-ChildItem $dir -Filter "f*.png" -ErrorAction SilentlyContinue | Where-Object { $_.Length -gt 0 } | Sort-Object LastWriteTime)
    $done = $files.Count
    if ($null -eq $startDone) { $startDone = $done }
    Clear-Host
    Write-Host ""
    Write-Host "  FOREST CLIP - FINAL 2K RENDER (2560x1440, 128 samples + OIDN)" -ForegroundColor Cyan
    Write-Host "  ---------------------------------------------------------------"
    $pct = [math]::Round(100 * $done / $total, 1)
    $bar = ("#" * [int]($pct / 2.5)).PadRight(40, ".")
    Write-Host ("  [{0}] {1}%" -f $bar, $pct)
    Write-Host ("  Frames done : {0} / {1}" -f $done, $total)
    if ($files.Count -ge 3) {
        $recent = $files | Select-Object -Last ([math]::Min(20, $files.Count))
        $span = ($recent[-1].LastWriteTime - $recent[0].LastWriteTime).TotalSeconds
        $per = $span / [math]::Max(1, $recent.Count - 1)
        $left = ($total - $done) * $per
        $eta = (Get-Date).AddSeconds($left)
        Write-Host ("  Speed       : {0:N1} s per frame" -f $per)
        Write-Host ("  Time left   : {0}h {1:D2}m" -f [int][math]::Floor($left / 3600), [int][math]::Floor(($left % 3600) / 60))
        Write-Host ("  Finishes at : {0:HH:mm}" -f $eta)
        Write-Host ("  Last frame  : {0}  ({1:HH:mm:ss})" -f $files[-1].Name, $files[-1].LastWriteTime)
    } else {
        Write-Host "  Speed       : measuring... (first frame loads the forest, ~1 min)"
    }
    $gpu = (nvidia-smi --query-gpu=utilization.gpu,memory.used,temperature.gpu --format=csv,noheader 2>$null)
    if ($gpu) { Write-Host ("  GPU         : {0}  (util, VRAM, temp)" -f $gpu) }
    $running = Get-Process blender -ErrorAction SilentlyContinue
    Write-Host ("  Blender     : {0}" -f $(if ($running) { "rendering" } else { "not running" }))
    if ($done -ge $total) { Write-Host "`n  DONE - all frames rendered." -ForegroundColor Green }
    elseif (-not $running) { Write-Host "`n  Stopped (power cut?). Restart the render; it resumes from the last frame." -ForegroundColor Yellow }
    Write-Host "`n  (this window only watches; closing it does not stop the render)"
    Start-Sleep 10
}
```

## File: skills/blender-forest/notes/camera_moves.md
size: 6352
sha: 78d8a5214d31b9c8ff223fef1aaf3e4f5a16826c
upstream: https://github.com/mromidev/claude-blender-skills/blob/main/skills/blender-forest/notes/camera_moves.md

```text
# Camera motion analysis (visual estimates from contact sheets + full frames; numbers are approximate)

Conventions: truck = sideways move, dolly = along view axis, pitch = degrees below horizon (+ = looking down, - = looking up). Frame thirds: L/C/R, top/mid/low.

## Shot 1 (f0000-f0105) - top-down ferns/logs
- Move: near-vertical look-down; camera glides FORWARD over the ground (content slides DOWN frame and slightly right). Ground-plane dolly/truck ~0.9-1.2 m total. Last frames start to tilt up: gravel path appears at top-right (horizon creeping in).
- Speed: smooth, roughly constant, perhaps a gentle ease-in; no shake.
- Height: ~2.0-2.5 m start and end (a ~0.6 m fern spans ~25% of frame width). Pitch ~88 deg start -> ~75 deg end (slow tilt-up of ~10-15 deg).
- Lens ~35 mm FF (little distortion, frame covers ~2.3 m wide). DOF: shallow-ish; mid/low ferns and logs sharp, far top edge softer.
- Composition start: fallen log lying upper-left to centre (log centre ~ x 0.3, y 0.3), big fern lower-left (0.35, 0.65), fern cluster lower-right, a second fern at upper-centre. End: log pushed to bottom edge (y ~0.9), mossy rock + fern in centre-right, a second log upper-left, gravel strip top-right corner.
- Light: strong dappled sun, high, from upper-left; dark shade pockets, bright fern patches; high contrast.

## Shot 2 (f0106-f0216) - low ground-level truck
- Move: pure TRUCK LEFT (foreground rocks/ferns slide RIGHT ~45% of frame width; the far dark trunk slides right only ~14%) = parallax, no pan. ~1.0-1.5 m travel. Tiny pedestal-up / tilt drift (path line sinks ~10 px in 640 tile, i.e. horizon goes slightly lower).
- Speed: near constant, perhaps tiny ease at ends. Perfectly smooth, no handheld.
- Height ~0.35-0.5 m (ferns are at eye level), pitch ~0-3 deg (level; horizon/path line at ~48% from top, vanishing back to 25 m of forest).
- Lens ~28-35 mm. Foreground ferns/rocks in near-focus but slightly soft; midground gravel path and tall trunk sharp-ish; distant canopy softer. Mild DOF.
- Composition start: dark tree trunk at x~0.17 (left third), gravel path band across middle (y~0.45-0.5), fern clump centre-low, white fallen log right-mid, sky slivers top. End: trunk x~0.3, rock cluster centre-right low (0.55-0.8, y 0.8), path band wider/left, fern lower-right.
- Light: back/side-lit (sun in front-left, high): trunks dark, foliage glowing, ferns translucent rim light, soft patches on ground (weak-medium).

## Shot 3 (f0217-f0337) - path walk toward the bend
- Move: DOLLY FORWARD ~4-5 m along the path plus slight yaw/truck to the left (trees slide right, path bend drifts right and widens toward the lower right). Slight tilt up of ~5 deg and small height drop.
- Speed: constant walking pace (~0.04 m/frame-ish -> ~1.2 m/s), eased out slightly at the end. Smooth, no shake.
- Height ~1.7 m -> ~1.5 m; pitch ~14 deg down at start -> ~6-8 deg at end.
- Lens ~24-28 mm (wide, strong parallax in ferns). DOF: moderate; path and mid-ground trees sharp, far canopy slightly soft.
- Composition start: path S-curve occupying right half, lower-right diagonal (path enters bottom-centre-right, bends away at top x~0.55), thin white birch left-third (x 0.25), forest bank left-low. End: horizon at ~y 0.33, path enters bottom-centre/left, bends right to x~0.75-0.8, dark trunk at x~0.85, birches left, fern bank low-left.
- Light: sun high (50-60 deg), from camera LEFT-behind/side; heavy dappled shadows on the gravel (strong, ~60-70% contrast), warm bright forest bank.

## Shot 4 (f0338-f0442) - cabin reveal at distance
- Move: slow DOLLY FORWARD ~1 m (cabin A grows ~7%) + TRUCK LEFT ~1.5 m (foreground trees/branches slide RIGHT by ~15-20% of width). Static pitch.
- Speed: slow, constant to mild ease-out. Smooth.
- Height ~1.2 m; pitch ~0-2 deg (level). Distance to cabin A ~14-17 m.
- Lens ~28-35 mm. DOF: cabins and deck crisp; foreground ferns/branches slightly soft; background trees soft.
- Composition start: cabin A gable left (x 0.1-0.43, apex at y 0.05, chimney pipe on top), cabin B right (x 0.5-0.73, apex y 0.2), gravel path in lower-left/centre (y 0.8-1), foreground birch trunk right (x 0.75), deck strip at y ~0.64. End: same but path centred-low (x 0.5), cabin A x 0.08-0.4, cabin B 0.5-0.72, foreground birch at x 0.9, deck y ~0.65-0.7 slightly bigger.
- Light: sun HIGH and from camera-left, slightly behind; tree shadows fall across cabin A gable (strong, patchy), B's left roof slope lit, right face in shade; blue sky with clouds top-centre.

## Shot 5 (f0443-f0528) - close cabin push/arc
- Move: TRUCK RIGHT ~1.8 m (deck corner slides left from x 0.77 to 0.47 of frame; the cabin A side wall is progressively revealed = orbit-like) with a small dolly in of ~0.5 m. Little or no pan.
- Speed: eased - fastest at start (~100 px/36 f), about 20-25% slower late. NOT stopped.
- STILL MOVING at f0480 and at f0528 (frame diffs non-zero throughout; deck corner at x 0.62 at f480 -> 0.47 at f528). Does not settle - shot ends mid-motion with slight ease.
- Height ~0.6-0.8 m (deck 0.3 m thick fills lower 12% of frame); pitch ~ -3 to -5 deg (looking slightly UP so roof apex nearly clipped at the top).
- Lens ~24 mm (wide, converging verticals, cabin A gable tops cropped). DOF: cabin glass, wall, deck edge sharp; far trees soft, tiny fern/foreground branch blurred at bottom-left.
- Composition start: cabin A fills left third (x 0-0.47, glazing centre-left y 0.3-0.65), cabin B right-centre (x 0.58-0.93, apex top y 0), woodstack cabinet right (x 0.88), deck front edge sweeping from lower-left to centre (y 0.8-0.95), gravel right-low, sky top-centre. End: cabin A corner window + side wall sliding to x 0-0.62 diagonal, cabin B now x 0.55-0.93 more frontal and smaller, deck corner centre (x 0.47, y 0.85), more gravel in lower right, sky strip top-centre/right.
- Light: sun high, from camera LEFT-behind; cabin B's gable half-lit, soft dappled shadows across cabin A wall; gravel and deck brightly lit; moderate sun-patch contrast; blue sky with clouds.

## Quick notes for Blender
- No handheld shake anywhere; use smooth linear/ease-out curves. Cuts hide changes in lens: shots 1-3 ~28-35 mm, shot 4 ~28-35 mm, shot 5 ~24 mm.
- Sun: one high sun lamp (elevation ~55 deg) from camera-left/behind in shots 3-5; shot 2 is sunlit from front-left (rim light); strong leaf-cookie dappling.
```

## File: skills/blender-forest/notes/clip-analysis.md
size: 17348
sha: ac9abdd76b99b1db19abd3a948157478a7e67dcd
upstream: https://github.com/mromidev/claude-blender-skills/blob/main/skills/blender-forest/notes/clip-analysis.md

```text
# Clip 19.0 s – 32.0 s (f0570 – f0960 at 30 fps): shot-by-shot breakdown

Verified cuts: f0620, f0756, f0861 (all HARD cuts, no dissolve, flash, whip, speed ramp, text or grain change). No fourth cut up to f0959. Frame refs are 1920x1080; x/y given as fractions of frame (0,0 = top-left). "Est." = my visual estimate, not measured in 3D.

## Per-second timeline

| Sec | Frames | Shot | What happens |
|---|---|---|---|
| 19 | 570-599 | S1 | Low creeping move through blurred leaves. Near sapling trunk drifts x 0.76 to 0.69. Heavy dark out-of-focus leaves across top 45%. Sky patches top-left. |
| 20 | 600-629 | S1 to S2 | S1 continues to f619 (sapling at x 0.65, big dark blurred leaves sweep in lower right). **CUT f620** to static wide: curved birch trunk left (x 0.22-0.35), white-backed leaves bottom right, no car yet. |
| 21 | 630-659 | S2 | Locked-off. Car appears at the right edge around f646-648 (x 1.0, y 0.62), moving left. By f659 car x 0.88-1.0. |
| 22 | 660-689 | S2 | Car crosses right to centre-right: centre x 0.92 (f660) to 0.70 (f690), ~14 px/frame, partly hidden by foreground branches/ferns. |
| 23 | 690-719 | S2 | Car centre x 0.70 to 0.57, slows on screen (~10 to 8 px/frame), gets smaller (length 440 px to ~330 px). |
| 24 | 720-749 | S2 | Car x 0.55 to 0.42, ~8 px/frame, length ~300 px, receding along the road. |
| 25 | 750-779 | S2 to S3 | Car at x 0.40, length ~270 px, y 0.60. **CUT f756 (25.2 s)** to ultra-low close-up of the left rear quarter, rigidly attached to the car. Background streaks (motion blur). |
| 26 | 780-809 | S3 | Car frozen in frame; world streams past; sky patch top-left grows. |
| 27 | 810-839 | S3 | Same; background blur eases slightly. Gravel road at lower left with dappled sun. |
| 28 | 840-869 | S3 to S4 | S3 ends f860. **CUT f861 (28.7 s)** to top-down view through blurred foliage, ground orange leaf litter. |
| 29 | 870-899 | S4 | Bush drifts down-frame and out; gravel road (right edge) comes in from top; car roof first peeks at bottom right around f887-890. |
| 30 | 900-929 | S4 | Car rises into lower right (roof then windscreen then bonnet). Pale-green leaf cluster enters top-left. |
| 31 | 930-959 | S4 | Car fully in lower right, nose up-frame, enlarging; ground flows down at ~6 px/frame. Clip ends mid-move at f959. |

---

## SHOT 1 — f0570 to f0619 (clip starts mid-shot; 50 frames = 1.67 s, 19.0-20.63 s)

**Camera**
- Position: very low, est. 0.5-0.8 m above ground, just above the fern tops (ferns visible bottom 20%). Looking horizontally north-ish into a clearing; far-clearing floor "horizon" sits at y ≈ 0.69-0.71 and does not move, so NO tilt/pan, no roll.
- Move: slow truck/slider to the RIGHT (scene drifts left). Near sapling trunk (about 1-1.5 m away) x: 1450 px (f570), 1370 (f590), 1290 (f610), 1250 (f619) = constant ~4 px/frame (~120 px/s). Second, farther dark trunk: 1490, 1465, 1445, 1430 = ~1 px/frame. That parallax ratio ≈ 4:1 implies camera speed only ~5-10 cm/s. Essentially linear, maybe a hair of ease-out at the end. No shake. Total travel in the shot ≈ 0.15 m. Possibly a tiny push-in; no measurable zoom.
- Foreground leaves move much faster than the sapling (10-20 px/frame) because they are almost at the lens (and probably wind-animated).

**Lens**
- ~35-50 mm, no visible distortion. SHALLOW depth of field (wide aperture ~f/2-2.8): focus on the sapling and twigs 1.5-3 m out. Big leaves in the top 45% (and sweeping lower right at f610-619) are huge soft dark-green blobs 150-350 px across. Far background (trees 10 m+) is moderately soft. Ferns/litter at the bottom are soft-ish. Motion blur on leaves: moderate, 180° shutter look.

**Car:** not visible.

**Foreground occluders:** continuous. Dark, backlit out-of-focus leaves fill top-left to top-right; individual leaf blobs ~200 px. Between f604-619 one big dark blurred leaf slides in from the right edge (x 0.77-0.95, y 0.6-0.75). They are dark (backlit, translucent edges) and low-contrast.

**Environment:** deciduous understory (young beech/hornbeam-like saplings with thin black twigs, small heart-shaped leaves); forest floor = brown leaf litter + clumps of fern; a fallen log fragment bottom-left (x 0.25-0.33, y 0.82). A pale clearing/old track floor behind the sapling (y 0.69). Sky: small pale-blue/white patches through the canopy top-left and top centre-right. No road visible.

**Light:** sun high, behind the subject (backlit/rim-lit leaves, translucent yellow-green edges), clearing in the middle glows. Dapple is weak because the whole frame is under canopy. Exposure: slightly hazy, lifted blacks, medium contrast. Grade: soft yellow-green highlights, cool desaturated green shadows, low-moderate saturation, slight bloom on bright gaps.

**Composition:** start: sapling at right third line (x 0.76), second trunk at 0.78, ground 15-20% bottom, leaf mass top. Middle: sapling x 0.70. End: sapling x 0.65, camera about to cut to the wide. Bright clearing slot centre.

---

## SHOT 2 — f0620 to f0755 (136 frames = 4.53 s, 20.67-25.17 s)

**Camera**
- Locked-off tripod shot. Birch trunk (left) stays at x 420-690 px at bottom and 410-470 at top in every frame; foreground leaf branches (bottom right) identical in f620 and f755. Only tiny drift/wind sway of the top-right pale leaf (≤10 px). No shake, no zoom.
- Height: est. 1.0-1.3 m (looking slightly down at the forest floor; the track appears as a pale strip at y 0.63-0.68, car's roof is at y ≈ 0.56-0.58). Slight downhill/upslope: the ground rises toward the back; the car's road sits on a gentle bench.
- Position: a few metres from the track, camera facing roughly across it; the track runs from the right edge (x 1.0) to centre-left, curving away. Car travels right to LEFT.

**Lens:** ~24-28 mm (est. 65-74° horizontal), rectilinear, no fisheye. DEEP focus (f/8-11 look): foreground leaves, birch, ferns and background trees all crisp, only very distant canopy softens by haze. Motion blur: the car shows slight horizontal smear (f690-f755, 1/60 s-ish); everything else static.

**The car (red 911 GT3 RS-like, big fixed wing)**
- Heading: screen-left, receding slightly (length shrinks).
- Entry f646-648 at right edge (x 1.0, y 0.62) partly behind the foreground branches. Positions (centre x / apparent length): f660 0.92 / cropped; f690 0.70 / 440 px; f700 0.64 / 390; f720 0.55 / ~330; f740 0.47 / ~300; f755 0.40 / 270. y centre stays 0.59-0.61. Screen speed 14 px/frame (f660-690) easing to ~8 px/frame (f700-755): decelerating or turning away. Using ~28 mm and 4.6 m length: ≈15-25 km/h (est. 5 m/s). Smooth, no visible steering, body steady, slight wheel blur, partly hidden by ferns in front at f650-700 (low understory crosses the lower half of the car; wing and roof stay visible).
- Paint reads dark red / orange-red, specular glints on the roof, glasshouse reflects bright sky.

**Foreground:** large sharp white-backed (lit, silvery undersides) leaves + twigs bottom right (x 0.5-1.0, y 0.62-0.95), bright translucent lime leaf top right (x 0.84-0.95, y 0.03-0.25), curved birch trunk left. These are NOT moving past the lens; they are framing devices.

**Environment:** open mixed deciduous woodland, many thin trunks (birch, beech), canopy fully closed above except pale sky slot top centre (x 0.5-0.6) and cloud top right; dense saplings; lots of ferns and low shrubs; forest floor brown leaf litter and lit green carpet; the track is a narrow (est. 3 m) pale gravel/dirt strip, indistinct, winding to the left behind a bright clearing at x 0.45-0.65, y 0.55-0.65. Dark brown fallen branches bottom centre (x 0.45-0.55, y 0.95-1.0) and bottom-left tree base.

**Light:** sun high and behind the scene (from the far side, back-lit/3/4 back) shining through the canopy; leaves glow; bright dapples across the floor and clearing; shaded trunk faces. Dapple strength: medium-high contrast patches (~2-3 stops). Exposure: bright but retains detail, the middle clearing near clipping. Grade: fresh natural yellow-green, a touch cooler in shadows, moderate saturation, mild vignette; the red car is the only warm accent.

**Composition:** thirds: birch on the left third (x 0.22-0.35), foreground leaf mass bottom right, car on the right-centre running along y ≈ 0.6 (lower-third line). Start (f620): no car, frame empty centre with the clearing at x 0.5. Middle (f690): car at x 0.70 (right-third line), 23% frame width. End (f755): car at x 0.40, small (14% width), near the clearing, leaving big empty space at right. Horizon/canopy line ~0.55.

---

## SHOT 3 — f0756 to f0860 (105 frames = 3.5 s, 25.2-28.67 s)

**Camera**
- RIGIDLY ATTACHED to the car (parented to the body). Mirror (x 0.27, y 0.37), B-pillar, window, rear arch, wheel (centre ≈ x 0.57, y 0.78) sit within a few pixels at f756, f790, f825 and f860. Background streams by instead. Not a follow with lag; zero sway relative to the car.
- Position: just behind and beside the rear-left wheel, ~0.3 m above gravel, ~0.8-1.0 m from the body side, looking forward-diagonally at the door/mirror area (tilted up ~10-15°). Road visible at left curving ahead. Camera fixed in the car's frame, so the car "heading" is toward screen-left, speed est. 30-50 km/h from the wheel smear (spoke blur arc ~40° per 1/60 s ≈ 13 m/s upper bound) and the background streaking.
- Slow subtle change of attitude: sky/cloud patch at top-left grows from f756 to f860 and the tree line drops by ~50-100 px (car pitches up slightly / road crests); background blur decreases a bit (car easing off or distance of trees increases). No roll beyond roughly 3-5°.

**Lens:** very wide (est. 14-16 mm, ~100° H) with strong barrel/fisheye feel: trunks at left lean and bow outward, the road edge and the sill curve, the wheel is egg-shaped; magnified stretching toward corners. Depth of field: the car side from mirror to rear arch is sharp; background trees (bokeh not strong) are smeared by motion rather than defocus; gravel foreground (bottom-left) is blurred by motion/DoF. Motion blur: strong (180-270° shutter): wheel spokes smeared into a dark disc, background tree trunks horizontal streaks of 40-100 px, gravel at bottom-left radial streaks.

**The car in frame**
- Left (driver) side. Mirror at x 0.27, y 0.37; door handle (0.36, 0.48); window and roofline arc to top centre; fixed wing's top edge visible at top centre (x 0.52-0.63, y 0.08-0.11); rear arch big at x 0.45-0.8; rear wheel centre (0.57, 0.77), diameter ≈ 640+ px, so only the upper 70% is in frame; caliper red at lower right (x 0.69, y 0.92); side blade/rear vent behind the door; taillight slit far right (x 0.95, y 0.5); side sill carbon strip diagonal bottom-left. Front wheel visible beyond the door, small at x 0.22, y 0.62.
- Wheel: rotating fast (blurred spokes, black centre-lock hub visible), forwards direction.
- Steering: none visible. Suspension: no motion relative to camera (since the camera is mounted).
- Paint: saturated orange-red (more orange than in S2); reflections: window shows blue sky, white cumulus, tree canopy (reflecting trees sweep through the glass), rear fender top has soft white cloud highlights with sun-dapple patterns drifting; door panel mirrors dark trunks.

**Environment:** gravel track (grey-brown, fine stones, est. 3 m wide) at bottom-left with dapple of sun in the distance and dark shade in the foreground; fern clumps and orange leaf litter on the left bank (x 0-0.1, y 0.55-0.85); beyond, a green wall of thin trees (aspen/birch) with a few trunks, sky top-left/top-right pale with clouds (f825-860 more sky). Terrain flat to gently rising.

**Light:** sun high, from the car's right/behind (so the visible left side is in open shade/skylight with dappled highlights), strong specular reflections of sky. Dapple: strong on the road ahead (bright patches vs deep shadow), moderate on the car. Exposure: contrasty; shadows near black (wheel arch, road in the foreground), highlights almost clipped on the fender. Grade: higher contrast and saturation than S2, rich red/orange, vivid greens, cool blue sky reflection, slight vignette (corners darker).

**Composition:** start (f756): car dominates the right 75%; wheel and arch centre-right; road at left lower third leads the eye out; background trees/sky across the top 35%. Middle (f790-825): same with sky patch growing, the road straighter. End (f860): identical car placement, more sky top-left, trees behind brighter and steadier. Subject centre of mass at x 0.6-0.65, y 0.55.

---

## SHOT 4 — f0861 to f0959 (99 frames = 3.3 s, 28.7-31.97 s) (clip ends here, f0960 not included)

**Camera**
- Top-down (nadir, 85-90° down), est. 6-8 m above the ground, crane/jib/drone-like. Moves forward along the road direction (up-frame) while slightly DESCENDING / pushing in: scale of the ground litter grows ~10-15% from f861 to f959 (car width 380 px at f930 to 430 px at f959). Ground (stump/log) flows down at ~6 px/frame (~180 px/s ≈ 1 m/s at the ground, est.), the foreground bush at ~13 px/frame (about half the camera-ground distance, so a ~3 m tall bush top). Steady, linear to slow ease, no shake, no roll (log directions unchanged). The car appears ~190 px higher on screen between f930 and f959.
- The shot starts with the lens pushing through the tops of a bush: big blurred leaves cover the right and upper right in f861-870, then clear by f875.

**Lens:** ~24-35 mm, rectilinear, the road and the car edge seem to lean slightly because of perspective. DoF: ground sharp (f/5.6-8 look), the very close bush top (nearer to the lens) in f861-885 is soft with motion blur, the pale-green leaf mass top-left (f915-959) is crisp but slightly desaturated/greyed. Motion blur: moderate; only the nearest leaves are smeared.

**The car (from above):**
- First seen as a dark, grey-green-tinted roof sliver at bottom (x 0.6-0.75, y ≥ 0.96) behind foliage at f861-870; red roof at f887-895 in the lower-right corner (x 0.78-0.9, y 0.88-1.0); f900-929 rising: windscreen and wing glimpses; f930: occupying x 0.75-0.94, y 0.63-1.0; f959: x 0.76-0.98, y 0.46-1.0, bonnet with two black stripes, round headlights/ lamps visible, windscreen reflecting trees, roof black, nose pointing UP and slightly right (~10-15° off vertical, following the road).
- Heading: up-frame (north), driving up the gravel road at the right. Relative to ground: ~12 px/frame i.e. ≈ 2 m/s (7 km/h, uncertain, a slow roll in this shot, i.e. much slower than the S3 feeling). Wheels/steering: not visible from above. No suspension motion.
- Paint: saturated red-orange with black stripe, sunlit; reflections of treetops in the windscreen.

**Occluders:** f861-885 big close bush at centre/right with blurred leaves (150-400 px) moving down-frame; later a second pale leaf mass (aspen-like round leaves, light grey-green, ~40-60 px each) at top-left that grows to fill the left 45% by f959. No leaf covers the car.

**Environment:** forest floor from above: reddish-orange leaf litter, patches of fern/green herbs, small dead sticks, a grey weathered log/stump (x 0.55, y 0.3-0.6 at f959), a smaller white stick; gravel road on the right 20-25% of the frame (grey fine gravel, ~3-4 m wide, with long tree-shadow bars), shadowed edge on the left of the car. Dark tree trunks at top. No sky.

**Light:** sun high, from the right/behind (shadows of trees fall as horizontal bars on the road toward the lower-left; the car throws a shadow on the left side). Dapple: strong (bright orange litter pools vs dark green shade). Exposure: darker and richer than the wide shots; mid-low key. Grade: warm earthy oranges and browns, desaturated cool greens, high local contrast.

**Composition:** start (f861): bush mass filling the centre, orange ground showing at left, gravel and the car's roof sliver at the bottom right corner. Middle (f895-900): gravel road enters from top right edge, car corner at bottom right. End (f959): car on the lower right third intersection (x 0.85, y 0.75); the road along the right edge; the leaf mass top-left; open ground at centre. Subject at bottom right with empty space on the left (diagonal composition).

---

## Clip end (32.0 s)
The clip ends at f959 mid-move: the top-down camera is still travelling forward/descending and the ground still flowing down at ~6 px/frame; the car is still rising in frame. No fade, dip, flash, text or ramp: next frame (f960) is beyond this clip. All transitions in this clip are straight hard cuts (S1>S2 at f620 and S2>S3 at f756, S3>S4 at f861); the cuts are on action: car heading left in S2 then S3 (same screen direction), then the viewpoint changes completely at f861.

## Recreation notes
- S1: keyframe a slow right-truck (~5-10 cm/s, linear) at 0.6 m height, 40-50 mm f/2.5, place a few dark leaf cards 5-20 cm from the lens for blur.
- S2: tripod 1.2 m, 26 mm, f/11, birch trunk at left third, silvery leaf clump foreground bottom right; car drive path left, 20 km/h decelerating, lane ending at clearing. Car partly hidden behind ferns.
- S3: parent camera to the car's rear-left quarter 0.3 m high, 15 mm with strong barrel (or fisheye ~110°), shutter 270°, spinning wheel with motion blur, dapple on the gravel.
- S4: top-down camera 7 m, drifts forward 1 m/s with a slow 10% descent, car with 2 m/s forward speed starting below frame, a bush just under the lens at the start.
```

## File: skills/blender-forest/notes/PLAN.md
size: 3450
sha: 286e025b4c1c25a6b9815733ec3738eef2b0811a
upstream: https://github.com/mromidev/claude-blender-skills/blob/main/skills/blender-forest/notes/PLAN.md

```text
# Plan: recreate first 16 s of "Blender Hyper-Realistic Forest" (roe.num77) on RTX 5070

Reference: research/frames/f0000-f0900.jpg (30 fps, 1080p), research/a0.jpg a1.jpg b.jpg, research/frames/transcript.txt
Project file from him: NONE. Only assets (376-384). Final forest .blend and the cabin are not given. Cabin is "tell me in the comments" in the video.

## Shots (cuts at 3.53 / 7.23 / 11.27 / 14.77 / 17.63 s) = 480 frames for 0-16 s @30
1. 0.0-3.5   low cam over floor, tilted down: fallen log, ferns, orange leaf litter. Slide + tilt up.
2. 3.5-7.2   eye level ~0.5 m, ferns in front, rocks, gravel path far, dark trunk left. Slow glide sideways.
3. 7.2-11.3  low beside path, gravel curves right, birches, dappled sun. Slow forward glide.
4. 11.3-14.8 two black A-frame cabins, wood deck, gravel path, blue sky + clouds. Slow push-in.
5. 14.8-17.6 close on cabin glass (warm lights), firewood rack, deck edge. (we stop at 16.0)

## Stages (one script each in scripts/, resumable, save .blend after each)
A. Unpack + audit. DONE except textures (379).
B. build_world.py  Terrain (his Remeshed Terrain) + leaf-soil material (map 50, disp 0.2, hue/sat 1.2, rough 1). HDRI strength 2.25, ground hidden (z 0.25), Filmic/High Contrast (fall back to AgX look if 5.2 lacks Filmic), exposure 1.5, gamma 0.9. Light-path trick (camera ray -> sky strength 1, lighting 2.25). Sun power 10, warm, rot 24/-53/148.
C. scatter.py  OUR OWN scatter (Geometry Nodes built by script, no Geo-Scatter). Layers with his numbers: birch x11 (dens .06, vert 0.85, scale 1.6), young trees x2 (.05), large shrubs (.5), small shrubs (hand-placed ring around cabin), ferns (5, scale 1.3-1.87), branches (1.85), debris (3), leaves (200, 70% cull), ground plants (50% cull), rocks, fallen logs. Masks: vertex group "center" (cabin+path cleared), bezier-area, color attribute for leaves. Seeded so renders are repeatable.
D. build_path.py  Bezier -> mesh -> boolean with duplicate terrain -> gravel material (2 gravel textures mixed by 'middle' vertex mask, bump 0.1, disp 0.05).
E. build_cabins.py  OUR OWN cabins from frame references: two black charred-wood A-frames, slate roof, big glass front with warm interior (stove, furniture, lamps from plant library), wood deck (planks, bevel .003, array), rusty firewood rack, chimney pipe.
F. camera.py  5 cameras/shots keyframed to match timings and moves, subtle handheld noise, shallow DOF, motion blur on.
G. Look-dev stills (low res), compare side by side with reference frames, tune until it matches. Loop here.
H. Render + export.

## Render budget (RTX 5070 12 GB, Cycles OptiX, denoise)
- Look-dev stills: 960x540, 32 samples, ~10-30 s each.
- Test frame: 1080p, ~128 samples, estimate 30-90 s/frame (dense foliage). 480 frames = ~4-12 h. Will measure on 1 frame per shot first and report real number before any full render.
- Per-shot frame ranges, PNG sequence (power-cut safe, resume), then ffmpeg mp4. 1080p for scenes, 2K only for final stitched video.
- Will ask before starting any long render.

## Safety / rules
- Blender always headless with --disable-autoexec on his files. Nothing of his run except as data.
- Defender clean, no scripts inside (see SECURITY.md). Rig-a-Car and Porsches untouched until needed.
- Own code only: scatter, cabins, camera, rig (later) are written by us.

## Open items
- textures.rar (379) not downloaded yet.
- Decide 1080p vs lower for the full 16 s after the timing test.
```

## File: skills/blender-forest/notes/SECURITY.md
size: 2120
sha: c00e76a0053bc8e3577fa019fb7f108b8021967d
upstream: https://github.com/mromidev/claude-blender-skills/blob/main/skills/blender-forest/notes/SECURITY.md

```text
# Security check of roe.num77 downloads (2026-10-19)

Source: the tutorial's free asset posts, unpacked to blender-forest\assets (one folder per archive, nothing run).

| Check | Result |
|---|---|
| Windows Defender custom scan (archives + unpacked folder), signatures updated 2026-10-19 | clean, no detections |
| Archive contents (7z listing): any .exe .dll .bat .cmd .ps1 .vbs .js .jar .msi .scr .lnk .py .sh | none in the 5 archives used |
| Unpacked files are only: .blend, .hdr, .jpg, .png, .tif, .txt | yes |
| Terrain.blend and Plant library .blend opened headless with --disable-autoexec: embedded text/script blocks | 0 |
| Scripted drivers / OSL script nodes in them | 0 |
| Linked external .blend libraries | 0 |

Car files (rigacar.rar, Porsche Carrera.rar, Porsche Gtr_Rigged.rar): checked later, see the addendum below. Only needed after the 17 s mark. Do NOT install the add-on (your own-code-only rule).

Rules kept: Blender always run with --disable-autoexec on his files; no add-ons from him installed.

Textures (post 379, textures.rar 1.4 GB): Defender clean; contents only 374 jpg, 27 png, 2 tif, 2 hdr. Unpacked to `Plant models\textures\`. Plant library now resolves 415 of 416 images; the 1 missing is a junk path `C:\Map #1781324284` (leftover from the creator's export).

## Addendum: car files (post 382-384), unpacked to blender-forest\quarantine (not installed)
- Defender scan of the download folder already covered them: clean.
- Porsche Carrera.blend and Porsche Gtr_Rigged.blend, opened headless --disable-autoexec: 0 text/script blocks, 0 scripted drivers, 0 OSL nodes, 0 linked libraries.
- rigacar-Jeanyan3D-Fix-v3.zip = Rig-A-Car add-on (open source by digicreatures, "Jeanyan3D fix" is a third-party patch). Source read: 4 .py files, only imports bpy, bpy_extras, math, mathutils, re, itertools, importlib. No network, subprocess, os, exec/eval, file writes. Only URLs are doc/issue links in metadata.
- Caveat: the zip also ships precompiled .pyc files (cannot be read). Do not install as-is; if we ever use it, delete the __pycache__ folder first. Not needed for 0-16 s anyway.
```

## File: README.md
upstream: https://github.com/mromidev/claude-blender-skills/blob/main/README.md

````text
# Claude Blender Skills

Free Claude skills that make **realistic Blender animations**. Each one is the complete workflow behind one of my YouTube videos,
with the real scripts that made the shot. Install a skill, tell Claude your idea, and it plans, builds, lights, animates and
renders the scene in your Blender. You don't need to watch the videos first.

![Clay vs final render of the night FPV drone shot](docs/img/thumbnail.jpg)

| Skill | What it makes | Video |
|---|---|---|
| [`blender-film`](skills/blender-film/SKILL.md) | 10 s night FPV drone shot through a city: storyboard, real physics path, night lighting, sound designed from the 3D motion, overnight render, white clay build-up reveal | [How I Built This Insane Realistic Animation Using Claude in Blender](https://www.youtube.com/watch?v=ZN-Ugd-4HDM) |
| [`blender-forest`](skills/blender-forest/SKILL.md) | Ultra-realistic forest with a car driving through it, built by following a long tutorial: own scatter system, own car rig, render benchmark | [How to Make an Ultra-Realistic 3D Environment Using Claude](https://www.youtube.com/watch?v=Udahf4a0UwQ) |
| [`blender-car-film`](skills/blender-car-film/SKILL.md) | 20 s cinematic story film on a snowy night: real car model, set built in Python, snow particles, 6 hand-held cameras, render from 5-8 h down to 1 h | [How I Built This 3D Short Film With Claude in Blender](https://www.youtube.com/watch?v=BagZ55ac4xs) |

<p>
<img src="docs/img/night_final.jpg" width="32%" alt="Night city drone shot">
<img src="docs/img/forest_car.jpg" width="32%" alt="Car in a sunlit forest">
<img src="docs/img/car_film.jpg" width="32%" alt="Snowy night car film">
</p>

## Contents

- [What you need](#what-you-need)
- [Install](#install)
- [Quick start: your first shot](#quick-start-your-first-shot)
- [How Claude controls Blender](#how-claude-controls-blender)
- [The skills in detail](#the-skills-in-detail)
- [Rules that make it look real](#rules-that-make-it-look-real)
- [Render without losing a night](#render-without-losing-a-night)
- [Safety](#safety)
- [FAQ](#faq)
- [Credits](#credits)

## What you need

- **Claude**. Claude Code works best, because Claude can run the scripts and look at the renders itself.
  The workflows also work in a normal Claude chat: Claude writes the scripts, you run them.
- **Blender 4.4 or newer** (everything was made on Blender 5.2), free from blender.org.
- **A GPU** for Cycles. All three films were rendered on one NVIDIA RTX 5070 (12 GB). Smaller GPUs work, renders just take longer.
- **Python 3** and **ffmpeg** on your PATH. `numpy` and `scipy` for the physics and sound steps of `blender-film`.
- **Your own assets**: a city or environment pack you own, or free CC0 assets from Poly Haven. No paid add-ons are needed.

## Install

### Claude Code (recommended)

Windows (PowerShell):

```powershell
git clone https://github.com/mromidev/claude-blender-skills.git
New-Item -ItemType Directory -Force "$HOME\.claude\skills" | Out-Null
Copy-Item -Recurse -Force claude-blender-skills\skills\* "$HOME\.claude\skills\"
```

macOS / Linux:

```bash
git clone https://github.com/mromidev/claude-blender-skills.git
mkdir -p ~/.claude/skills
cp -r claude-blender-skills/skills/* ~/.claude/skills/
```

Restart Claude Code. The skills load by themselves when you ask for "a cinematic Blender shot", "a realistic forest in Blender",
"a short film in Blender", or any single step ("night lighting for my city", "a clay build-up reveal").

### Claude app (claude.ai / desktop)

If your Claude app lets you add custom skills, zip one skill folder (for example `skills/blender-film`) and upload it there.

### Any chat, no install

Open a skill's `SKILL.md`, copy all of it into a new Claude chat, then describe your idea. Add the scripts you need from that skill's
`scripts/` folder when Claude asks for them.

## Quick start: your first shot

1. Install the skills (above).
2. Make a project folder, for example `~/blender-film`, and open Claude Code in it.
3. Open Blender. In the **Scripting** tab, open `tools/bridge.py` from the `blender-film` skill and press **Run Script**.
   Blender now listens on `127.0.0.1:9876` (only your own PC can reach it).
4. Paste this into Claude Code:

   ```
   Use the blender-film skill. My idea: [a drone flying through a canyon at sunset / a car chase at night / ...].
   My assets: [what you have, or "free Poly Haven assets"]. Start with step 1: write the storyboard and wait for my approval.
   ```

5. Approve the storyboard, then let Claude go step by step. After each step it renders a small test still so you can check it.
   It asks before any long render.

## How Claude controls Blender

```
Claude Code  --python tools/send.py script.py-->  bridge.py inside your open Blender (127.0.0.1:9876)
     ^                                                         |
     +---------------- output, errors, test renders <----------+
```

- `tools/bridge.py` runs inside Blender and executes the Python it receives on Blender's main thread.
- `tools/send.py` sends a script file (`python tools/send.py scripts/06_night.py`) or one line
  (`python tools/send.py -c "print(bpy.data.filepath)"`) and prints what came back.
- You watch every change happen in your own Blender window. Long renders run in background Blender (`blender -b`) from a saved file.

## The skills in detail

### `blender-film`: night FPV drone shot

![Drone on the night avenue](docs/img/shot2.jpg)

1. **Storyboard first.** A 10-second shot list with timings, camera per shot, where the hero is. Turned into one image sheet.
2. **City.** 5 buildings from an asset pack copied, stretched and recoloured into 173 instances. Cars, lamps, trees, hydrants.
3. **Motion from physics.** A point-mass simulation (24 fps, 1/480 s steps, gravity, quadratic air drag, ropes that can't stretch)
   searches the anchor timings until the path threads the street. Path curve + empty on Follow Path, offset keyed every frame.
4. **Hero and the pivot.** The first hero was a rigged Spider-Man with Mixamo motion capture. It looked stiff, because mocap is
   recorded on the ground. Swapped for an FPV drone on the same path: thrust axis = acceleration + gravity, nose along the velocity,
   throttle cuts where the path is ballistic, power flip, barrel roll, back flip, braking flare into a hover.
5. **Realism pass.** AgX exposure, thin haze, asphalt wear, oil drip line, repair patches, dirt at the foot of every wall.
6. **Night.** Practical lights only. Windows found from the facade roughness map (glass is smooth): `roughness < 0.3` = glass,
   then a random subset lit warm, cool or off. LED street lamps at 3200 K, car lights, red signals, blue fog, wet asphalt.
7. **Camera.** Camera Shakify (real recorded shake) on every camera, a street-level lens 0.45 m above the wet road, bloom and slight
   barrel distortion, no colour grade.
8. **Sound from the motion.** The animation is read every frame (distance, radial speed, side, throttle). Throttle drives motor pitch,
   radial speed drives Doppler, side drives the pan, cars pass at their closest approach, street-canyon reverb. -14 LUFS.
9. **Render.** Tree LODs and hidden duplicates removed, six final-quality test stills first, only the frames of the edit rendered,
   resumable, with a progress panel. About 3.5 hours overnight. Then every frame is checked.
10. **Clay build-up.** The whole city in white clay drops from the sky piece by piece (507 pieces, 7 s), then one wipe to night.

![Clay build-up](docs/img/clay_drop2.jpg)

Full step list and all 20 scripts: [`skills/blender-film`](skills/blender-film/SKILL.md).

### `blender-forest`: realistic environment from a tutorial

![Forest car shot](docs/img/forest_car.jpg)

1. Give Claude a long tutorial (1 h+, free assets) instead of a prompt: "We are going to recreate his scene exactly."
2. Claude reads the transcript, captures the reference frames, writes `PLAN.md` with every shot and stage.
3. Every downloaded asset is checked: 
````
