Claude Skill

scenario-blender-texturing-shading

Use when texturing, shading or look-developing in Blender: building a PBR material from a texture set, displacement, procedural materials, edge wear, dirt, grime, color variation, decals, box or triplanar mapping on meshes without UVs, stylized character skin (subsurface, pores),

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download scenario-labs-skills-skills_dcc_blender_scenario-blender-texturing-shading-dc592af.zip · 61 KB
Part of scenario-labs/skills — 97 skills

Install

skills CLI npx skills add https://github.com/scenario-labs/skills/tree/main/skills/dcc/blender/scenario-blender-texturing-shading
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install scenario-labs-skills@llmmart
Git git clone https://github.com/scenario-labs/skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole scenario-labs/skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Texturing and shading (Blender 5.2)

Expert shading is layered masks feeding physically plausible values: a clean base (scanned set or flat colors), then contrasty black/white masks that place color, roughness, height and subsurface, all editable in nodes. Values are judged under honest light in both engines, and baking comes last, for portability. If a sibling skill named here is missing from your available skills, ask the user to install it (npx skills add scenario-labs/skills --skill <name>); unattended, proceed from tool schemas and flag the gap.

REQUIRED BACKGROUND: scenario-blender-expert (execution channel, review loop, 5.2 API changes). Baking maps: scenario-blender-uv-baking. Lighting and final renders: scenario-blender-lighting-rendering.

Toolkit: scripts/bx_materials.py (builders, headless painting, checks; exercised by tests/code/blender-texturing-shading/). sys.path.append("<this skill>/scripts"); import bx_materials as M.

Stance (the expert delta)

  • Data maps are Non-Color, "not what looks better, what's correct" (Price). Only base color and emission are sRGB.
  • Normal map OR displacement, never both (Price, confirmed by Cycles lead Brecht): they encode the same relief. Displacement when heights will be combined or truly displaced; normal map for export and 8-bit safety.
  • Masks, not painted color. Kaspar and Price build every effect as a grayscale mask into a Mix or HSV Factor; color lives in the A/B inputs. Masks need contrast (Color Ramp stops pulled in) or they barely show. The same masks drive roughness, height and SSS.
  • Realism: scanned set plus masks; stylized: procedural (Price vs Kaspar and Ryan King). Kaspar strips photos to their pattern (blur plus grain extract) and adds grunge himself, and bakes at the end for portability (export, EEVEE load time), not for Cycles speed.
  • Micro detail in roughness, not bump, for skin and hair (Kaspar); edit roughness through HSV Value, never by unplugging the map (Price). Specular stays 0.5. Bump Distance is the real height of the map's 0..1: lower Distance, not Strength (Kaspar).
  • Judge honestly: Standard view, neutral mid-gray background, Base Color black to read roughness, a hard backlight and a light from below (Kaspar); grazing light for relief (Price); unlit for hand-painted albedo (Abbitt). EEVEE for iteration, Cycles as the truth, and they must match (Kaspar).
  • SSS follows anatomy (Kaspar): high on ears, nose, lids, lips, fingertips; low over bone. Black/white mask, Map Range sets values.
  • Bevel, AO, Pointiness and Random Per Island are Cycles-only, and Bevel/AO are stochastic [added, measured]: EEVEE shows no Bevel edges; thresholded live masks give 17 to 18% fractional metallic at any sample count. Bake before thresholding, or use vertex curvature.

Establish first

Input Changes Default when silent
Target: Cycles still, EEVEE, game engine live Cycles-only nodes vs baked; AO in albedo (render) vs ORM (game) Cycles finals, EEVEE side by side
Style: realistic or stylized scanned set + grime vs procedural or hand-painted light stylized restraint (Kaspar)
UVs and deformation UV images vs box projection vs Object procedurals rigid, no UVs: box; deforming: UV only
Scale tile size, bump/displacement meters real meters, scale applied (all experts)
Texel budget, export 2K to 4K hero paint, 4K to 8K large tiling surfaces 2K hero prop, glTF ORM, OpenGL normals

Workflow

  1. Base per part. Prefixed materials, placeholders removed, diffuse_color set; values picked on the Diffuse Color pass (matcap made Kaspar's skin too pale). Apply scale. GATE: M.value_report(objs) clean.
  2. Base material. Set: M.pbr_from_textures(mat, M.find_texture_set(dir), uv_map=...): color spaces by role, AO multiplied at factor 1 (Price: restores the cavity shadow flash capture removes; ao_mode="gltf" for games), bump only without a normal map, height_as="displacement" drops the normal map and sets Cubic interpolation, Displacement and Bump, Scale 0.2 for Poliigon (fixed 20 cm range); add Simple subdivision and shade smooth. UV tile = the set's physical size. Rigid, no UVs: projection="BOX". Procedural: Object coordinates (UV on deforming meshes), Noise Detail at most 5 (cost doubles per level, Price). GATE: M.audit_material(mat, obj) has no errors.
  3. Masks. Kaspar's rule is mask frequency against vertex density: vertex colors for broad soft masks (and Price's environments: tiling UVs repeat image strokes, layouts change), images for fine or exported detail, UV bands for hems. Headless: M.mask_from_points, M.write_vertex_values, M.smooth_attribute, M.curvature_attribute, M.TexelMap + M.project. Price's Color Burn: soft mask (A) burned by a contrast-ramped height map (B) breaks the mask along bricks or into grout (procedures P18). GATE: render each mask as emission.
  4. Layers. M.add_wear(mat, edge_color=..., edge_roughness=..., edge_metallic=1) adds variation, cavity/grunge dirt and chipped edges. Blend modes: Multiply darkens (grime, mold), Screen lightens (efflorescence, highlights; Add exceeds 1), Subtract on the height chain for spalling (Price). Chain order sets age (a decal before grime reads old). Decals: Alpha into Factor, Extension Clip (M.add_decal). GATE: M.preview(objs, out, rigs=("studio", "roughness")): effects follow form and water paths, nothing uniform, repeats broken.
  5. Character specifics. M.stylized_skin(mat, obj=body) (masks col_brighter/col_darker/col_lips/col_sss/col_rough); M.stylized_eye per eye object (origin at the eyeball center) plus M.cornea_material and cornea.visible_shadow = False; hair shine: Anisotropic with a Tangent on a strand-aligned UV map (Cycles only). GATE: rigs=("backlit",): red glow on ears, nostrils, lips, fingers; front not waxy; sclera off-white.
  6. Stylized hand-painted light (Abbitt): lighter top, darker bottom, Multiply in crevices, Screen on extremities. Headless: M.painted_light(mat, obj) from a curvature attribute, bake Emit, judge with rigs=("unlit",).
  7. Painting, where masks cannot place it. GUI: brushes are assets (Paint Hard/Soft, Airbrush, Blur, Smear, Fill, Mask); a factory 5.2 session paints BLACK until the brush and unified color are set; strokes project from the screen, so face the surface (Abbitt); Erase needs an alpha channel; bleed 8 to 12 px (Kaspar, Abbitt). Use M.gui_texture_stroke(): it forces a redraw (projection painting occludes with the depth buffer of the previous draw: a stroke issued in the setup call painted 162 texels in one run instead of about 14,000, full coverage in another) and resamples dabs. Headless: M.TexelMap + M.paint_stroke paints by world distance, seams included; M.dilate, then M.save_image (images are never saved with the .blend by a script). GATE: renders from views crossing seams.
  8. Lookdev check and bake. M.preview (EEVEE and Cycles, studio, backlit, roughness), M.value_report, labeled frames and node groups (Kaspar, Price). Bake procedural channels via Emission, 30+ samples, validate by rebuilding from the maps (scenario-blender-uv-baking).

Numbers

Value Number Source
Dielectric albedo / metal reflectance sRGB about 30 to 240 / 180 to 255; metallic 0 or 1 ranges [added]; binary metallic: Ryan King, Price
Skin roughness 0.68 to 0.72, rough zones 0.72 to 1, lips and mouth about 0.4 Kaspar
Pores Voronoi Smooth F1 scales 100 and 200, noise mix 0.2 to 0.25 Kaspar, on his UVs
Skin SSS scale (5.2) 0.005 to 0.04 m measured on a 0.33 m head; Kaspar's v1 0.02 to 0.15 is waxy in 5.2
Bump Distance about 0.001, Strength 0.1 to 0.2 for fabric Kaspar
Displacement Scale 0.2 (Poliigon), Midlevel 0.5, Cubic, 16-bit source Price
Brick wall roughness about 0.7 Price
Color tweak Hue within 0.5 plus or minus 0.04 Price
Noise Detail 5 or less Price
Edge-wear Bevel radius 0.04 on a 1 m crate measured
Paint bleed / island margin 8 to 12 px / 0.01 to 0.05 Kaspar, Abbitt
Painted height 2K, 32-bit float, base 0.5, Non-Color Kaspar; 0.5 gray base also Ryan King
Bake samples 30 minimum Kaspar

Quality gates

Measurable:

  • M.audit_material(mat, obj): color spaces by role, no image straight into Normal, explicit UV maps, Bump Distance at most 0.05, no Add Shader or raw summed normals, Emission Strength set, no gray constant metallic, normal map plus displacement flagged, non-Cubic displacement image, Noise Detail above 6, unsaved images.
  • M.value_report(objs): dielectric albedo within 30 to 243 sRGB (under 2% outside), metal above 180, fractional metallic under 5% (live Bevel/AO wear exceeds it until baked), roughness std above 0.01.
  • Scale applied; color attributes named col_* and matching every Attribute node exactly (Kaspar, Price).

Visual: M.preview EEVEE vs Cycles nearly identical; roughness row shows breakup; backlit glow only where flesh is thin; grazing light shows relief without terracing; repeats broken at distance; per-layer mute renders each add something (Price); scale against a human reference.

Common mistakes

Mistake Looks like Fix
Data map left sRGB bent bump, wrong gloss Non-Color
Normal map and displacement together (Node Wrangler default) doubled or broken relief keep one
Displacement with no geometry or Catmull-Clark nothing, or rounded corners Simple subdivision, shade smooth
Pre-4.5 Bump Distance 1.0 stepped, plastic real height, about 0.001 to 0.01
Emission Color set, Strength 0 no glow Strength above 0
Kaspar's v1 SSS values in 5.2 waxy white ears 0.005 to 0.04, check backlit
Bevel wear or Pointiness in EEVEE or games wear missing bake, or curvature_attribute
Threshold on live Bevel/AO for metallic grainy gray borders bake, then threshold
Cornea shell in EEVEE, default thickness iris magnified Thickness 0 (cornea_material)
Mask straight from Noise effect barely visible ramp or Map Range for contrast
Stroke right after setup partial or no paint, varies per run redraw first (gui_texture_stroke)
Paint color never set / new color attribute black strokes / white mask set colors; write zeros first
Box projection or Generated coords on a rig texture swims UV coordinates

Blender 5.2 notes

  • Principled v2: Subsurface Weight/Scale (no Subsurface Color), Specular IOR Level, Transmission Weight, Coat, Emission Strength default 0. Sockets by name; Mix node by index (RGBA: A=6, B=7, Result=2).
  • Color spaces sRGB, Non-Color, Linear Rec.709. Displacement node Scale default 0.01; material.displacement_method (not material.cycles); EEVEE displaces since 4.2 (Displacement and Bump).
  • use_nodes deprecated; surface_render_method replaces blend_method; refraction needs use_raytrace_refraction plus scene ray tracing.
  • EEVEE ignores anisotropy, Bevel, Pointiness, Random Per Island (verified). Random Walk SSS rescaled in 5.2. Musgrave is inside Noise.
  • Brushes are assets; custom brushes need "Save Changes to Asset" (Abbitt); image_brush_type; Blur is SOFTEN; sample color Shift+X.
  • paint.vertex_color_dirt works headless in Object mode; byte images store encoded values, float Linear Rec.709 images convert to sRGB when saved as PNG.

References

  • references/procedures.md: tested code for every stage (texture sets, displacement rule, box projection, wear, curvature, decals, skin, eyes, anisotropy, GUI and headless painting, high-pass, checks, camera-ray world, clay switch, painted light, Color Burn masks, physical UV scale).
  • references/expert-notes.md: reasoning per expert with timestamps and the disagreements with their deciding conditions.
  • references/critique.md: rubric for judging your own renders and node trees.
  • references/sources.md: every source video, credential, best timestamps.
Files (skills)
  • references
    • critique.md 5.7 KB
      # Critique rubric: judging your own materials
      
      Use after every stage gate. Render first (`M.preview(objs, out, rigs=("studio", "backlit", "roughness"))`, EEVEE and Cycles), open the sheet, then walk the list. Each item says what an expert looks at and how to check it in code where possible. Score each section pass / fix; fix before moving on.
      
      ## 1. Values (before anything else)
      
      - Albedo reads as a plausible surface color on the Diffuse Color pass with Standard view, not lit color (Kaspar). Code: `M.value_report(objs)["albedo_srgb_max_channel"]`; dielectrics roughly sRGB 30 to 240, metals 180 to 255 [added, Substance PBR guide].
      - No pure white and no pure black surfaces; sclera and teeth off-white (Kaspar, kAdgrBS4eDA [01:36:52]).
      - Metallic is a mask: 0 or 1, fractional only in transitions (Ryan King, Price: delete an all-black metallic map). Code: `metallic_grey_fraction` under 0.05 (live Bevel/AO wear exceeds this until baked).
      - Scanned sets: AO multiplied into base color unless true displacement is used; albedo otherwise free of baked lighting (Price). Stylized unlit art: light painted in on purpose, and it reads with no lights (`rigs=("unlit",)`, Abbitt).
      - Skin hue leans red, not orange; darker skin lightens toward light brown around brows, cheeks and nose, lips darker and more saturated (Kaspar, 0vGNOtX9iPk [01:01:46], [01:23:46]).
      
      ## 2. Roughness (the map that sells the surface; framing [added]: Kaspar puts micro detail here, Price varies it everywhere)
      
      - In the `roughness` rig (Base Color black), the surface still tells its story: breakup, zones, wear smoother or rougher than the base. Uniform roughness reads CG. Code: `roughness_std` above 0.01.
      - Skin: forehead, nose, cheeks and lids glossier; chin and brows rough; lips glossy; palms and fingertips not sweaty; nails and fingertips consistent (Kaspar).
      - Pores readable up close, invisible noise at mid distance (Kaspar, D7kuKx7BV1A).
      - Worn metal edges smoother than the paint; dirt rougher than the base.
      
      ## 3. Masks and detail placement
      
      - Every variation follows form: cheek highlight follows the cheek, wear on convex edges, dirt in cavities and on up-facing surfaces, streaks run down (Kaspar, 0vGNOtX9iPk [01:36:46]); grime follows water paths, corners and openings, in big, medium and small shapes, checked against a photo reference (Price).
      - Masks reach 0 and 1 (contrast through a ramp); a mask that stays mid-gray does nothing (Price, Ryan King).
      - Tiling breaks up at viewing distance: no visible repeat (Price's sun-fade and grime masks).
      - Each layer earns its place: mute effect groups one at a time (`node.mute = True`) and compare renders (Price).
      - Chosen restraint: variation where it adds definition, nothing where it distracts ("they all choose very well where to put the colors and where to leave them out", 0vGNOtX9iPk [01:35:31]).
      - Crossing masks do not leave holes; creases darken, never lighten (Kaspar, uTjfRGGV6ys).
      - Painted contributions blend in at distance and do not read as strokes (Kaspar, uTjfRGGV6ys [01:52:08]).
      - No visible UV seams in painted or baked maps: render views that cross seams (`test_uvpaint.py` style).
      
      ## 4. Bump and normals
      
      - Bump subtle, not stepped, not plastic; Distance in real units (Kaspar). Code: `audit_material` warnings.
      - Normals combined by chaining Bump nodes or Add + Normalize, never raw Add (Kaspar). Code: `audit_material` error.
      - Normal maps through a Normal Map node, Non-Color, correct green convention (OpenGL in Blender; DirectX sets `convention='DIRECTX'`).
      - Never a normal map and true displacement from the same set (Price; `audit_material` error). Displacement: Simple subdivision, shade smooth, Cubic height, Scale set deliberately (5.2 default 0.01; Poliigon 0.2); grazing-light render shows relief at the silhouette without terracing.
      - Modeled wrinkles are not re-enhanced in the shader (Kaspar, uTjfRGGV6ys [01:28:51]).
      
      ## 5. Light response
      
      - Backlit: ears, nostrils, lids, lips and fingers glow red; forehead, jaw and palm center stay opaque; frontal view not waxy (Kaspar, D7kuKx7BV1A).
      - From below and hard backlight: no surprises (bump artifacts, unexpected shine).
      - Hair: streaky, stylized highlights, less shine at the silhouette (Kaspar, 8GD1C9qLi-4).
      - Eyes: pupil centered from the side through the cornea, shimmer on the lower iris, highlight small, sclera not blown out (Kaspar).
      
      ## 6. Engines and portability
      
      - EEVEE and Cycles nearly identical side by side (Kaspar). If not: Bevel, Pointiness, anisotropy or AO in the tree (Cycles-only or different in EEVEE), EEVEE refraction thickness, SSS differences.
      - Anything a game engine or EEVEE needs is baked; baked material rebuilt from the maps matches the procedural one except resolution (Kaspar, EDEMbeZfVcc [00:46:36]).
      - Color spaces right on every image; images saved (`image.is_dirty` False); glTF export carries base color, metallic-roughness, normal and (if wanted) occlusion.
      
      ## 7. Node tree hygiene
      
      - Frames and labels per channel (base color, SSS, roughness, bump), shared masks as node groups placed next to consumers, named reroutes; someone else can read it (Kaspar, EDEMbeZfVcc [00:06:04]; Price: label as you build).
      - Noise Detail at most 5 or 6 (Price: cost doubles per level); scale applied on objects using Object coordinates or physical UV scale; texture scale checked against a human reference (Price).
      - Materials named with the asset prefix; no leftover placeholder or grid materials; `diffuse_color` set for solid view.
      - Every image node has an explicit UV map or vector input (Kaspar, kN5aq1qXUCE [02:16:08]).
      
      ## Report format
      
      State per section: pass or the fix applied, with the metric (value_report numbers, audit findings) and the sheet path you looked at. Say what was not verified (for example "EEVEE not checked", "no backlit render").
      
    • expert-notes.md 27.4 KB
      # Expert notes: texturing and shading
      
      The judgment behind SKILL.md, by expert, with video id and timestamp (`[hh:mm:ss]`). Video ids resolve to `https://www.youtube.com/watch?v=<id>`; details in `sources.md`. [added] marks the skill author's own additions; "measured" marks facts established by the tests in `tests/code/blender-texturing-shading/`.
      
      ---
      
      ## Julien Kaspar (Blender Studio), Snow stylized character shading lives, 2021
      
      Eight livestreams, Blender 3.0 alpha, Principled BSDF v1 and EEVEE Legacy. One expert, so "consensus" below means convictions he repeats across sessions. Values quoted are his; version translations are marked.
      
      ### Method: layered masks in nodes
      
      - Flat base colors per material first, then black/white masks mixed in nodes, "very non-destructive, like Substance" (0vGNOtX9iPk [00:05:14], [02:04:38]). Masks are later reused for roughness, SSS and bump (0vGNOtX9iPk [02:05:50]).
      - Masks gate procedural textures rather than tint: "not just adding color, but adding textures via masks" (kN5aq1qXUCE [01:03:05]).
      - Start skin darker and build value up by painting brightness in (0vGNOtX9iPk [00:18:50]).
      - Lips mask inverted and multiplied into the brighter/darker masks so brightening never touches the lips (0vGNOtX9iPk [01:04:32], [01:20:46]).
      - Vertex colors vs texture paint: vertex colors for broad soft masks on a dense retopo (fast, blur works, no UVs) (0vGNOtX9iPk [01:19:25]; D7kuKx7BV1A [00:21:38]); texture paint when vertex resolution shows (hands) or for height and streaks (kN5aq1qXUCE [00:58:32]; uTjfRGGV6ys [00:55:37]). Deciding condition: mask frequency against vertex density.
      - Color attributes are per face corner: face masking gives hard borders (nails), vertex masking soft ones (0vGNOtX9iPk [02:06:31]).
      - Name color attributes `col_*`: attribute pickers mix them up with UV maps (kN5aq1qXUCE [01:34:39]).
      - Keep the whole character at a consistent level of progress, not the skin finished first (kN5aq1qXUCE [01:08:24]).
      - Organize as you go: frames, labels, color codes, node groups, named reroutes; a shared mask becomes a node group duplicated next to each consumer instead of long crossing links (kN5aq1qXUCE [00:42:16]; EDEMbeZfVcc [00:07:09], [00:08:17]).
      
      ### Judging values
      
      - Diffuse Color view with the Standard view transform: "whatever is a value of one, I want that to be white" (0vGNOtX9iPk [00:09:32]; kN5aq1qXUCE [00:45:55]). In 5.2 the default is AgX, Standard still exists.
      - Matcap sculpting made his characters too pale, darker skin tones especially (0vGNOtX9iPk [00:07:51]).
      - Neutral mid-gray non-gradient background; an HDRI like the forest one tints everything green (0vGNOtX9iPk [00:13:20], [01:58:52]).
      - Isolate channels by unplugging or muting links: base color to black to read bump and reflections, normal off to read roughness (kN5aq1qXUCE [01:26:09]; D7kuKx7BV1A [01:19:36]). `M.preview(rigs=("roughness",))` automates this.
      - Test under many lights, including a light from below that most HDRIs cannot give (D7kuKx7BV1A [00:28:31], [00:40:50]); a strong backlight reveals bump too (uTjfRGGV6ys [01:43:52]).
      - EEVEE for iteration (viewport samples 1 while painting), Cycles as the reference; Cycles exposed an unnormalized-normal bug EEVEE hid (uTjfRGGV6ys [01:21:33], [01:25:29]; D7kuKx7BV1A [00:39:44]).
      - EEVEE loses fine texture detail when zoomed out; judge fine texture at working distance (kAdgrBS4eDA [00:25:37]).
      
      ### Skin (Snow #6 is the key session)
      
      - SSS makes stylized skin read as skin; its amount follows anatomy: "anything where the light passing through would be obscured by bone, less light would actually come through" (D7kuKx7BV1A [00:20:31]). High: earlobe, tragus, nose wings and tip, eyelids, lips inside and out, fingers and fingertips, thumb pad. Low: brows, forehead, cheekbones, jaw, palm center, back of hand (D7kuKx7BV1A [00:12:43], [00:38:36], [00:43:05]).
      - Mask pure black/white, Map Range sets the actual values: min 0.02, max 0.06 then about 0.15 (v1 "Subsurface") (D7kuKx7BV1A [00:16:39], [00:40:17]). Translation: in v1 that input also multiplied the radius, which is Subsurface Scale in v2. Measured in 5.2 (Random Walk rescaled): 0.02 to 0.15 turned a 0.33 m head's ears white-pink and waxy under a backlight; 0.005 to 0.04 gave the red glow he describes (`test_skin.py`, sss sheets).
      - Increasing SSS makes skin waxy; use it as variation (D7kuKx7BV1A [00:09:49], [00:44:14]). A small SSS value costs about as much render time as a large one (D7kuKx7BV1A [00:51:34]).
      - Backlit test rig: rotate the HDRI to backlight, add area lights straight behind the head at power 50, plus a light from below (D7kuKx7BV1A [00:14:27], [00:16:06], [00:40:50]). Ears, lips and fingers should glow red, the frontal view should not look waxy (D7kuKx7BV1A [00:17:13]).
      - SSS color inherited the darkening masks of the base color network and darkened the upper lip; he duplicated the network into its own "SSS" frame (D7kuKx7BV1A [00:24:31]). 5.2 has no Subsurface Color: the SSS albedo is Base Color; redden via Subsurface Radius or a separate Subsurface Scattering node [added].
      - Fact-check the SSS color against subdermal references: redder than the surface (D7kuKx7BV1A [00:10:21]).
      - Pores in roughness, not bump: "I want that to be not in the bump for extra skin detail, but in the roughness" (D7kuKx7BV1A [01:00:38]). Voronoi Smooth F1 (no cell cracks), scales 100 and 200 (first tries 250) mixed by a detail-0 noise, general noise mixed at 0.2 to 0.25; pores slightly glossier, cracks rougher (D7kuKx7BV1A [01:01:10], [01:06:38], [01:09:10], [01:20:47]).
      - Base skin roughness 0.68 then 0.72 (D7kuKx7BV1A [01:18:28]); a later check found skin too wet at 0.44 and raised it (8GD1C9qLi-4 [02:13:18]). Rough mask remapped to 0.72 to 1 (D7kuKx7BV1A [01:24:15]).
      - Face roughness zoning: glossier forehead, nose, cheeks, under the eyes and lids, a bit on the ear; lips and inner mouth fully glossy; shaved chin rough; brows not glossy (D7kuKx7BV1A [01:26:22], [01:31:20]). Check palms and fingertips: they easily go sweaty; shiny fingertips next to rough nails make no sense (8GD1C9qLi-4 [02:15:32], [02:25:33]).
      - Wet mouth without touching specular: keep 0.5, tongue roughness about 0.4 with strong waxy SSS, teeth roughness 0.4, slight SSS, cream color (D7kuKx7BV1A [00:50:05], [00:50:52]).
      - Paint region masks as black/white plus Map Range, not absolute painted values that get color managed (D7kuKx7BV1A [01:21:56]).
      - Stylized restraint: no wrinkles or realistic imperfections, but "the details that are there should be a reflection of how the real materials behave" (D7kuKx7BV1A [00:29:03]).
      - Stubble and hairline: keep flat, gradient to skin color bottom to top, a little detail for directional shine, a touch of SSS to blend into flesh (8GD1C9qLi-4 [02:02:25], [02:07:46], [02:11:20]).
      
      ### Eyes (Snow #5)
      
      - Procedural rather than painted; Rain's painted iris looked worse and was less adjustable (kAdgrBS4eDA [01:04:50]).
      - One object per eye with its origin at the eyeball center, mirror modifier removed, stray keys cleared; object-space gradients break if the eye is stretched in edit mode (kAdgrBS4eDA [01:12:07], [01:17:07], [01:25:07]).
      - Spherical gradient in object space, two Color Ramps for iris and pupil, a Linear Light mix with noise to break the circles without moving them, darker limbal rim via Multiply (kAdgrBS4eDA [01:10:25], [01:19:30], [01:21:40]). Implementation note [added]: a gradient centered on the eyeball center is constant over the eyeball surface, so `stylized_eye` measures distance from the front pole instead.
      - Never pure white sclera: "just like there is no 100% black anywhere on any surface, there's no 100% white"; tint toward flesh at the edges (kAdgrBS4eDA [01:22:52], [01:36:52]).
      - Iris streaks from a Voronoi stretched along one axis (kAdgrBS4eDA [01:30:35]); `stylized_eye` builds radial streaks from polar coordinates [added].
      - Iris: gray with a hint of blue, too saturated or green reads creepy (kAdgrBS4eDA [01:57:47]); later darker, deeper blue and metallic for the shine of a real iris (D7kuKx7BV1A [00:00:35]).
      - Deepen the iris indentation so the refracted shimmer catches light only on the lower iris (D7kuKx7BV1A [00:01:52]); refraction re-spheres the concave iris, IOR matters, depth barely (kAdgrBS4eDA [01:43:13]).
      - Cornea: Transmission 1, low roughness; he used Glossy + Refraction through a Mix Shader because v1 specular would not go low enough, and Add Shader is not physically correct (kAdgrBS4eDA [01:41:28], [01:44:49]). Cornea casts no shadow (kAdgrBS4eDA [01:42:39]). Pupil mask into specular so the pupil has no highlight (kAdgrBS4eDA [01:46:40]).
      - 5.2 translation, measured: EEVEE needs Material Output Thickness 0 on the cornea shell or it magnifies the iris across the eye; `visible_shadow = False` is honored by EEVEE and Cycles.
      - Scale the pupil to its neutral size (about 0.7) before baking so a dilation shape key does not stretch the bake (kAdgrBS4eDA [01:48:26], [01:50:38]).
      - Subtle bump from the gradient through Map Range (Color Ramps clip) at strength 0.1 (kAdgrBS4eDA [01:53:47], [01:55:34]).
      
      ### Cloth, seams, painted height
      
      - Photo textures contribute only their structural pattern: blur until the weave disappears, Grain Extract against the original, keep the difference as a mask (rKVeLjxZzTc [00:02:47], [00:04:03]). `M.high_pass` is the numpy equivalent.
      - Use a color map only as pattern: saturation 0, value lowered, multiplied into the flat color (kN5aq1qXUCE [01:17:00]).
      - Bump: lower Distance (about 0.001), Strength 0.1 to 0.2 for fabric (kN5aq1qXUCE [01:23:24]); displacement maps Non-Color (kN5aq1qXUCE [01:30:28]).
      - Straightened UV strips as coordinate systems: Separate XYZ on a strip UV gives band masks for hems (Greater Than hard, Map Range soft), and a second texture branch on a rotated UV map turns the weave on the hem (kN5aq1qXUCE [01:45:30], [01:56:20]).
      - Stitches: a stitch-row texture at the bottom of the UV tile with strips laid flat on it; consistent texel density across strips or stitch size varies (8GD1C9qLi-4 [00:13:16]; rKVeLjxZzTc [01:36:02]).
      - Crossing masks multiply into holes: process lines separately and combine after; creases multiply darker, never lighten (uTjfRGGV6ys [00:48:52], [00:49:27]).
      - Painted height: 2K, 32-bit float, base 0.5, Non-Color (he forgot, chat caught it); non-destructive but clips at the texture range and never changes the silhouette (uTjfRGGV6ys [00:55:37], [01:39:56], [01:44:30]). Do not re-sculpt modeled wrinkles in the shader (uTjfRGGV6ys [01:28:51]).
      - Combining bumps: Vector Math Add then Normalize fixed a wrong glossy look in Cycles (uTjfRGGV6ys [01:24:56], [01:25:29]); chaining Bump nodes is the cleaner standard [added], and what `pbr_from_textures` does.
      - One painted 0.5-based color-variation map split by Color Ramps into a bright (Add wash) and a dark (Multiply) mask; height times fabric structure through a high-contrast ramp gives acid wash on raised areas only (rKVeLjxZzTc [02:04:12]; kAdgrBS4eDA [00:24:56]).
      - Keep clothing simple: some wear and variation, never realistic or distracting (rKVeLjxZzTc [01:58:09]).
      - UVs outside 0..1 are wrong for painting and tileable images, fine as procedural coordinates (rKVeLjxZzTc [00:57:07]).
      - Custom logo: a "logo" UV map projected from view on the front faces, all other UVs scaled to zero in a corner (EDEMbeZfVcc [00:50:36]). `M.add_decal` parks other loops outside 0..1 with Extension CLIP instead.
      
      ### Hair shading
      
      - Stylized hair: extra detail without realism, no bump or strand detail, "a bit brush-strokey" (8GD1C9qLi-4 [00:56:53]). Micro detail in roughness (D7kuKx7BV1A [01:59:21]).
      - Real anisotropy in Cycles from a Tangent node on a strand-aligned UV map, streak texture into Anisotropic Rotation in a 0 to 0.5 range; fake anisotropy node group for EEVEE; separate EEVEE and Cycles material outputs (D7kuKx7BV1A [01:55:27], [02:13:27]; 8GD1C9qLi-4 [01:09:34], [01:13:56]). Measured: EEVEE 5.2 still renders Principled anisotropy isotropic.
      - Fresnel through Map Range (not Color Ramp) added to roughness so hair is less shiny at the silhouette; clamp or the shader breaks (8GD1C9qLi-4 [00:52:07], [00:54:13]).
      - Through-the-clump streak painting: Normal falloff, Occlude and Backface Culling off so one stroke passes through the whole object (8GD1C9qLi-4 [00:47:42]). Measured in the GUI test: equal paint on front and back.
      
      ### Baking, presentation, housekeeping
      
      - Procedural is fine for Cycles (Sprite Fright mostly unbaked); bake for portability and for EEVEE load time; ship the asset baked, keep procedural networks separately (EDEMbeZfVcc [00:04:55], [00:48:13], [00:53:04]). Bake is flattening a layer stack (EDEMbeZfVcc [00:38:16]).
      - Bake recipe: Emit via a Node Wrangler preview, margin 8 px, 30+ samples (1 only for flat color, "one sample is not a good idea for normal maps"), disable the Mask modifier's render visibility, validate by rebuilding the material from the maps (EDEMbeZfVcc [00:31:58], [01:21:12], [01:36:11], [00:45:28]). Details in scenario-blender-uv-baking.
      - Stylized diffuse maps may carry a bit of AO or lighting; he does not strictly separate albedo from diffuse (EDEMbeZfVcc [01:34:26]).
      - Global clay and wire switch: a node group in every material driven by one custom property (clay: diffuse 0.6 gray, SSS 0, roughness 0.6) (EDEMbeZfVcc [00:15:26], [00:17:04]).
      - Camera-ray world: camera sees flat gray, the HDRI lights the character; "a really neat trick that I'm basically always using" (8GD1C9qLi-4 [00:35:14], [00:35:50]).
      - Autosave does not save painted images: save or pack them (8GD1C9qLi-4 [01:22:36]). 5.2 release notes say autosave now covers texture paint; a headless script still has to save explicitly.
      - Viewport display colors: pick each material's diffuse color into `diffuse_color` for animators (kAdgrBS4eDA [01:05:09]).
      - UDIMs are little used at the studio because procedural detail plus low-resolution masks suffice (kAdgrBS4eDA [00:11:43]); used on Snow's body for export (EDEMbeZfVcc [00:54:33]).
      
      ---
      
      ## Measured facts added by this skill (5.2.1)
      
      - EEVEE: Bevel node gives no edges (Edges mask mean 0.0016 vs 0.111 in Cycles); AO node works but differs (screen space); Principled anisotropy ignored; Geometry Pointiness and Random Per Island constant (std 0.001 and 0.0 vs 0.013 and 0.288 in Cycles, `tests/code/blender-texturing-shading/probes/probe_pointiness.py`, `probe_island.py`); box projection matches Cycles; Grunge/Variation noise identical.
      - Bevel/AO masks are stochastic per sample: thresholded metallic 0% fractional at 1 spp, 17 to 18% at 16 and 128 spp.
      - Noise Texture Fac clusters in about 0.3 to 0.7; stretch before using as breakup.
      - Cornea shell needs Thickness 0 in EEVEE; raytrace refraction off hides the eye.
      - Texture paint: strokes issued before a redraw are occluded by the previous draw's depth buffer (162 texels in one run, full coverage in another; about 14,000 after a redraw every run); scripted strokes place one dab per point; factory paint color black.
      - New FLOAT_COLOR attributes start white (1, 1, 1, 1).
      - `bmesh.ops.create_uvsphere(calc_uvs=True)` writes UVs only into an existing UV layer.
      
      ## Andrew Price (Blender Guru, founder of Poliigon): "How to Texture in Blender", 2024
      
      1h53 course on Blender 4.2, frames checked by the distiller. The most complete realistic workflow in the sources. Video uHCJoNEWjXo.
      
      ### Stance
      
      - Production realism = scanned PBR set plus procedural and painted masks on top; hundred-node full-procedural materials are a game-pipeline niche, "almost never done" in VFX or animation [00:08:02]. Deciding condition against Kaspar and Ryan King: realistic hero surfaces take scans; stylized, file-free or endlessly variable assets go procedural.
      - Every map but base color is data, Non-Color: "it's not about what looks better or what you can see, it's what's actually correct" [00:21:29], [00:22:03].
      - "The normal map and the displacement map should not be used together" (confirmed by Brecht): same relief, doubled bump, and a normal map on true displacement wrecks it; Node Wrangler and the Megascans importer get this wrong [00:29:57], [00:41:59]. Keep the normal map only if you never displace or combine heights [00:39:47].
      - Texturing is mostly shader work: reuse the maps you have (displacement as a detail source for masks) instead of raising paint resolution [01:12:45], [01:18:48].
      - "You have to label what you're building as you build it" [00:58:32].
      
      ### PBR base
      
      - A texture is a per-pixel slider: a flat 0.2 gray Non-Color image into Roughness equals Roughness 0.2 [00:02:50].
      - Real sizes: an 8 x 4 m wall, then 10 x 5 m so one 2.5 m texture tile maps exactly, scale applied [00:09:08], [00:19:58]. Keep a human scale reference in the scene [01:26:41].
      - Download: 8K for a large wall, JPEG is fine for color and data, displacement 16-bit TIFF; skip ORM (for real-time) [00:15:32], [00:16:07].
      - AO: Mix Color Multiply, A = base color, B = AO, factor 1 ("crank it all the way up almost always"): the flash-lit capture removed cavity shadows; optional under true displacement [00:22:36]-[00:26:23].
      - All-black metallic map: delete it, it only costs render time [00:26:54].
      - Normal Map node, not a direct link; yellow into purple is the missing-converter hint [00:27:25].
      - True displacement: Displacement node into Material Output Displacement, Material Settings "Displacement and Bump", Subdivision Surface Simple (Catmull-Clark rounds the corners), shade smooth, Cubic interpolation on the height image, Poliigon Scale 0.2 (a fixed 20 cm range) and Midlevel 0.5 [00:32:05]-[00:39:15], [01:16:38]. EEVEE cannot do displacement only; it shows Displacement and Bump [00:32:50].
      - Normal maps survive 8-bit; grayscale displacement needs 16-bit; bump derived from a grayscale map is worse than a normal map (slope from neighboring pixels) [00:37:36].
      - Judge relief with a grazing sun in Cycles (sky strength 0.15, sun about 15 degrees): "our wall is paper thin" was the failure [00:35:56], [00:37:04].
      - Node Wrangler Principled Setup needs fixing: normal plus displacement both wired, Linear, Scale 1, metallic added, no AO [00:41:59]. `M.pbr_from_textures` does not repeat these.
      
      ### Masks and layers
      
      - Color tweaks non-destructively: HSV after the AO multiply, hue shifts of about 0.04 only; frames show saturation 0.9, value 1.2 to 1.4 [00:47:10]-[00:50:22].
      - Sun-fade mask: Noise (UV coordinates; Generated stretches) into a Color Ramp with stops pulled to about 0.47 and 0.71 so the mask reaches 0 and 1 [00:50:55]-[00:55:50]. Noise Detail cost roughly doubles per level: "I wouldn't go over like five or six" [00:57:26].
      - The Mix Color node is the workhorse: A = previous color, B = effect color, Factor = mask [01:00:11], [01:23:29].
      - Vertex colors for environment masks: image painting on tiling UVs repeats every stroke, needs a second UV map, wastes the image on long walls and must be redone when the layout changes; resolution is the vertex count, so subdivide (20 cuts) with high Subdivision modifiers lowered and hidden first (crash) [01:03:20]-[01:09:40]. Attribute node names are exact and case-sensitive [01:38:39].
      - Color Burn: painted mask (A) burned by a contrast-ramped displacement map (B) breaks soft low-resolution paint along individual bricks; flip the ramp to move the effect into the grout; works at mid and low paint strength only [01:12:13]-[01:20:26]. Setting the effect Mix to Multiply turns B's value into an opacity control [01:19:22].
      - Decal: Alpha into the Mix Factor, Extension Clip, Mapping for size and position; chain order sets age (under grime = older) [01:22:23]-[01:29:26].
      - Roughness: never unplug the map to get the slider back; HSV Value below 1 shinier, above 1 duller; wet leaks = Mix toward a target roughness by the mold mask; the wall's roughness is about 0.7 [01:29:38]-[01:36:14]. Every property varies somewhere, "like in games" [01:34:35].
      - Efflorescence: the same group with a flipped ramp in Screen (Add exceeds 1) [01:38:07]-[01:40:48]. Spalling: Subtract on the height before the Displacement node (factor about 0.14) and brighten the same area's color because fresh breaks have no grime [01:44:33]-[01:50:57].
      - Masks follow reality: moisture in corners and around windows, big, medium and small shapes, compared against a photo reference throughout [01:02:53], [01:42:58].
      - Paint while viewing the final shader, not the mask [01:21:32]; mute layers one at a time (`M`) to see each contribution [00:48:18].
      
      ## Grant Abbitt (Gabbitt): texture painting 2021, 4.0, 4.3, 5.1
      
      Videos WjS_zNQNVlw, E29D9rS8op8, _-zAhCOUpog, 7kSRXnAi7uA. Long-running Blender educator (GameDev.tv courses).
      
      - Setup: apply scale before unwrapping [E29D9rS8op8 00:00:33]; Smart UV Project island margin 0.01 to 0.05, larger when the texture will be downscaled for a game [E29D9rS8op8 00:01:40; WjS_zNQNVlw 00:01:27]; Texture Slots > Base Color 2048 x 2048, alpha off, filled with the dominant color [E29D9rS8op8 00:02:45]; bleed 12 px ("a really good idea") [E29D9rS8op8 00:04:21]; paint in Material Preview [00:03:18].
      - The image is saved separately from the .blend; a star marks unsaved paint [WjS_zNQNVlw 00:02:50; 7kSRXnAi7uA 00:06:31].
      - Texture paint projects the brush in screen space: strokes stretch on faces angled away and vanish at edges; paint facing the surface [_-zAhCOUpog 00:02:13]. No layers: Erase punches alpha (needs an alpha channel); the practical eraser is sampling the base color and painting it back [00:07:05], [00:07:38].
      - 4.3 brush assets: Paint Hard (no pressure), Paint Hard Pressure (size), Paint Soft (strength), Paint Soft Pressure (both), Blur, Smear (blends more easily than Blur), Clone (source at the 3D cursor), Fill (strength 1 fills everything), Erase variants, Mask (needs a stencil image; give it a fake user) [_-zAhCOUpog 00:04:24]-[00:09:16]. Custom textured brush: duplicate the asset, white color, saturation 0, image texture, Anchored stroke, then "Save Changes to Asset" or the edits are lost [00:09:49]-[00:11:28].
      - Stylized painted light (5.1): "the extremities you make lighter and the crevices you make darker"; start with a vertical gradient fill (dark bottom, light top); crevices with a darker color at strength about 0.25; edge highlights with a near-white brush in Screen; global darkening with a black Fill in Multiply at about 0.1 per tap ("Multiply is pretty much the same as darken, but it does it slightly better"); light only the crack edge facing the light; Smear to blend harsh lines [7kSRXnAi7uA 00:07:35]-[00:19:01]. Deciding condition against Price: stylized or unlit game art paints light into the albedo; PBR keeps albedo clean.
      - Judge hand-painted textures unlit: image straight into Material Output, or Solid with Flat lighting [7kSRXnAi7uA 00:12:27]. `M.preview(rigs=("unlit",))` and `M.painted_light` implement both.
      
      ## Ryan King (Ryan King Art): procedural nodes and texture painting
      
      Videos 5B244CYX1Tw, 4d4N8d4ki2Y, 6jT4K0JpGNs. Author of procedural material packs.
      
      - Object coordinates for procedurals ("for most procedural textures you're going to use object"), after applying scale; Generated stretches [5B244CYX1Tw 00:38:37], [00:40:12].
      - Metallic 1 for metal, 0 otherwise; texture it only where the two mix [5B244CYX1Tw 00:18:15].
      - Object Info Random into a Color Ramp gives each duplicate its own color from one material [5B244CYX1Tw 00:45:06]. Pointiness is a free cavity/edge mask, Cycles only, density dependent [00:41:19] (measured: EEVEE constant).
      - A mostly mid-gray factor blends weakly: add contrast before using it as a mask [5B244CYX1Tw 01:00:03] (measured: Noise Fac clusters in 0.3 to 0.7).
      - Never plug height into Normal: Bump node [5B244CYX1Tw 00:54:20].
      - Painted bump: a 0.5-gray image through a Bump node; black carves, white raises; view in Material Preview [4d4N8d4ki2Y 00:44:43]-[00:47:24].
      - Islands as large as possible, no overlaps, inside 0..1; fill a new image with the dominant color; 4K for a hero stylized prop [6jT4K0JpGNs 00:04:36]-[00:08:39]. Stroke spacing and jitter for dots, Line stroke for straight lines [6jT4K0JpGNs 00:18:59].
      
      ## Surfaced Studio: texture painting starter (3.3)
      
      Video ywR8HsNeXQQ.
      
      - Texture Slots mode Material, not Single Image: paints every channel of every material [00:05:07].
      - Most failures are state: wrong object holds the paint icon, missing UVs or image, a brush texture or texture mask, stroke settings; Load Factory Settings when lost [00:03:08], [00:28:21], [00:42:03].
      - Face-selection paint masks stored as vertex groups give clean region borders [00:16:32]-[00:18:49].
      - "Your texture mask defines where you paint, the texture defines what you're painting with" [00:33:25]. Roughness painted black (shinier) and white (rougher) through a Voronoi texture mask gives irregular patches [00:37:42]-[00:41:30].
      
      ## NoPoly and YanSculpts
      
      - NoPoly (nQaUsgf9ZUg, self-described non-expert): UV coordinates for images, Object for procedurals [00:07:21]; the Mix factor only reads black and white, colors go in A/B [00:18:12]; a stretched 3D procedural (wood grain, Mapping scale (3, 3, 0.1), distortion 2, detail 5) only reads right on two axes [00:10:14], [00:11:20].
      - YanSculpts (kbyK_N65YEk): course trailer, no technique; only a layer order for painted skin (base, variation, imperfections, freckles; separate gray maps for bump, specular and SSS) [00:02:24].
      
      ## Disagreements and deciding conditions (whole skill)
      
      | Topic                | Positions                                                                                                                            | Deciding condition                                                                                                         |
      | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- |
      | Procedural vs images | Price: scans plus masks; Kaspar, Ryan King: procedural                                                                               | realistic hero surface vs stylized or variable asset                                                                       |
      | Normal vs height     | Price: one or the other; importers wire both                                                                                         | displacement or height combining planned: displacement; export or 8-bit: normal map                                        |
      | Where masks live     | Kaspar, Price: vertex colors; Abbitt, Ryan, Surfaced: images                                                                         | mask frequency vs vertex density; tiling UVs and changing layouts favor vertex colors; export and fine detail favor images |
      | Light in albedo      | Abbitt paints light (unlit art); Price keeps albedo clean, AO multiply only for scans; Kaspar allows a bit of AO in stylized diffuse | unlit or stylized game art vs PBR render                                                                                   |
      | AO                   | Price: multiply at 1 for scanned sets; game engines keep it separate (ORM) [added]                                                   | render with no true displacement vs real-time export                                                                       |
      | Seam bleed           | Kaspar 8 px, Abbitt 12 px                                                                                                            | more when textures get downscaled                                                                                          |
      | Bake                 | Kaspar: at the end, for portability; Price: large environment detail stays in the shader (baking it would be "ginormous")            | export target or EEVEE load time vs Cycles render                                                                          |
      
    • procedures.md 30.7 KB
      # Tested procedures (Blender 5.2.1)
      
      Every `python` block below was executed on Blender 5.2.1 LTS by `tests/code/blender-texturing-shading/test_procedures_md.py` (headless, `--factory-startup`, fresh file per block). Blocks marked `python gui` need a live window and were run by `test_gui_texture_paint.py` instead. Larger behaviors were verified by the render tests named under each procedure; their contact sheets are in `tests/code/blender-texturing-shading/out/`.
      
      Blocks assume two names: `SKILL_SCRIPTS` = `<this skill>/scripts` and `TEX_DIR` = a folder holding a downloaded texture set. Replace them with real paths.
      
      ---
      
      ## P1. PBR material from a downloaded texture set
      
      Verified on 5.2.1. Test: `test_pbr.py` (ambientCG, Poly Haven and Substance naming, color spaces, glTF export).
      
      ```python
      import sys, bpy
      sys.path.append(SKILL_SCRIPTS)
      import bx_materials as M
      
      maps = M.find_texture_set(TEX_DIR)             # {'base_color': ..., 'roughness': ..., 'normal': ..., 'height': ..., 'ao': ...}
      bpy.ops.mesh.primitive_cube_add()
      obj = bpy.context.active_object
      mat = M.pbr_from_textures("Bricks", maps, uv_map="UVMap")   # sRGB on color, Non-Color on data, sockets by name
      obj.data.materials.append(mat)
      print(M.audit_material(mat, obj))              # [] when color spaces, UV maps, bump and emission are sane
      ```
      
      What the builder does, so you can do it by hand when needed:
      
      - Role from the END of the file name (`metal_plate_diff_1k` is base color, not metallic). `_nor_dx` / `NormalDX` sets the Normal Map node `convention = 'DIRECTX'` (5.1+).
      - `orm` / `arm` / `OcclusionRoughnessMetallic`: one Non-Color image, Separate Color R = AO, G = Roughness, B = Metallic.
      - Gloss maps are inverted into Roughness (`1 - gloss`).
      - Height: bump only when there is no normal map (`height_as='auto'`); Bump Distance = `height_depth` in meters (what 0..1 of the map spans). A normal map plus bump is CHAINED (Normal Map output into the Bump Normal input), never added. `height_as='displacement'` wires true displacement and DROPS the normal map (Price: they encode the same relief; `audit_material` flags materials that use both), sets the height image to Cubic interpolation and `displacement_method = 'BOTH'`.
      - AO: multiplied into base color at factor 1 by default (Price: scanned sets are flash-lit, AO restores the cavity shadow; optional under true displacement). For games use `ao_mode='gltf'`: a `glTF Material Output` group so the exporter writes a separate `occlusionTexture` (verified in the exported JSON); the default multiply still exported `baseColorTexture` (verified). `ao_mode='none'` to skip.
      - Emission map: Emission Strength set to 1 (5.x default is 0).
      
      Color-space fix for an existing material, by role of the socket it feeds:
      
      ```python
      import bpy
      bpy.ops.mesh.primitive_cube_add()
      mat = bpy.data.materials.new("Fixme")
      nt = mat.node_tree
      p = nt.nodes["Principled BSDF"]
      tex = nt.nodes.new("ShaderNodeTexImage")
      tex.image = bpy.data.images.new("rough_2k", 64, 64)
      nt.links.new(tex.outputs["Color"], p.inputs["Roughness"])
      
      COLOR_SOCKETS = {"Base Color", "Emission Color", "Sheen Tint", "Coat Tint", "Specular Tint"}
      for n in nt.nodes:
          if n.bl_idname == "ShaderNodeTexImage" and n.image:
              targets = {l.to_socket.name for l in n.outputs["Color"].links}
              n.image.colorspace_settings.name = "sRGB" if targets & COLOR_SOCKETS else "Non-Color"
      print(tex.image.colorspace_settings.name)     # Non-Color
      ```
      
      True displacement with a scanned set (Price's wall), verified in `test_price_masks.py` (normal map dropped, Cubic, BOTH) and the block test:
      
      ```python
      import sys, bpy
      sys.path.append(SKILL_SCRIPTS)
      import bx_materials as M
      
      bpy.ops.mesh.primitive_plane_add(size=2.5)                 # one tile = the set's published physical size
      wall = bpy.context.active_object
      mat = M.pbr_from_textures("Wall", M.find_texture_set(TEX_DIR), uv_map="UVMap",
                                height_as="displacement", height_depth=0.2)   # Poliigon: 0.2 = fixed 20 cm range
      wall.data.materials.append(mat)
      sub = wall.modifiers.new("Subdiv", "SUBSURF")
      sub.subdivision_type = "SIMPLE"                          # Catmull-Clark rounds the corners (Price)
      sub.levels = sub.render_levels = 6
      bpy.ops.object.shade_smooth()                            # faceted lumps otherwise
      print(mat.displacement_method, mat.get("bx_note"), M.audit_material(mat, wall))
      ```
      
      ## P2. Principled BSDF v2 by name, and the traps
      
      Verified on 5.2.1 (socket list and defaults probed).
      
      ```python
      import bpy
      mat = bpy.data.materials.new("V2")            # 5.x: already has Principled + Output, use_nodes is deprecated
      p = mat.node_tree.nodes["Principled BSDF"]
      print([s.name for s in p.inputs][:12])
      p.inputs["Base Color"].default_value = (0.8, 0.3, 0.2, 1)
      p.inputs["Roughness"].default_value = 0.55
      p.inputs["Specular IOR Level"].default_value = 0.5      # old 'Specular'
      p.inputs["Subsurface Weight"].default_value = 1.0       # old 'Subsurface'; no Subsurface Color any more
      p.inputs["Subsurface Scale"].default_value = 0.02       # meters; radius = Scale x Subsurface Radius
      p.inputs["Coat Weight"].default_value = 0.0             # old 'Clearcoat'
      p.inputs["Emission Color"].default_value = (1, 0.5, 0.1, 1)
      p.inputs["Emission Strength"].default_value = 2.0       # default 0: color alone does nothing
      p.subsurface_method = "RANDOM_WALK_SKIN"
      mat.surface_render_method = "DITHERED"                  # EEVEE; old blend_method
      ```
      
      ## P3. Box (triplanar) projection for meshes without usable UVs
      
      Verified on 5.2.1, EEVEE and Cycles render the same. Test: `test_pbr.py` (ico sphere with no UV map). Use for rigid props, rocks, AI-generated meshes before UVs exist. Not for deforming meshes: Object coordinates swim under an armature (UV digest). A tangent-space normal map is interpreted in the UV tangent frame, which box projection does not provide, so the builder drops the normal map and uses the height as bump [added].
      
      ```python
      import sys, bpy
      sys.path.append(SKILL_SCRIPTS)
      import bx_materials as M
      
      maps = {k: v for k, v in M.find_texture_set(TEX_DIR).items() if k not in ("normal", "normal_dx")}
      bpy.ops.mesh.primitive_ico_sphere_add(subdivisions=4)
      rock = bpy.context.active_object
      mat = M.pbr_from_textures("Rock_Box", maps, projection="BOX", box_blend=0.25, scale=(1.5, 1.5, 1.5), height_depth=0.01)
      rock.data.materials.append(mat)
      print([n.projection for n in mat.node_tree.nodes if n.bl_idname == "ShaderNodeTexImage"])
      ```
      
      ## P4. Wear, dirt and color variation on any material
      
      Verified on 5.2.1. Test: `test_wear.py` (mask sheet Cycles vs EEVEE, preview, value report) and `tests/code/blender-texturing-shading/probes/probe_stochastic.py`.
      
      ```python
      import sys, bpy
      sys.path.append(SKILL_SCRIPTS)
      import bx_materials as M
      
      bpy.ops.mesh.primitive_cube_add()
      crate = bpy.context.active_object
      mat = M.get_material("Crate_Paint")
      p = M.principled(mat)
      p.inputs["Base Color"].default_value = (0.045, 0.11, 0.065, 1)     # green paint, linear
      p.inputs["Roughness"].default_value = 0.5
      crate.data.materials.append(mat)
      nodes = M.add_wear(mat, edge_color=(0.62, 0.62, 0.63), edge_roughness=0.3, edge_metallic=1.0,
                         dirt_color=(0.04, 0.035, 0.03), dirt_amount=0.85, grunge_amount=0.3,
                         radius=0.04, wear_amount=0.6, cavity_dirt=0.5, noise_scale=10.0, grunge_scale=3.0)
      print(sorted(nodes), [s.name for s in nodes["edge_wear"].outputs])
      ```
      
      Measured behavior:
      
      - `BX Edge Wear`: Edges = angle between the Bevel-node normal and the shading normal, kept where AO says the surface is open (convex); Wear = Edges plus stretched noise through a threshold (chips); Cavity = 1 - AO; Dirt = Cavity plus noise through a threshold. Radius is world units: 0.04 read well on a 1 m crate, 0.015 gave hairlines.
      - EEVEE: Edges and Wear go to 0 (the Bevel node is Cycles-only), Cavity/Dirt differ (screen-space AO). Grunge and Variation match exactly (mean 0.0442 vs 0.044).
      - Bevel and AO are sampled per render sample: a threshold on them averages to fractional values. Metallic driven through `GREATER_THAN` was 0% fractional at 1 spp and 17 to 18% at 16 and 128 spp. Bake Wear and Dirt to textures (scenario-blender-uv-baking, Emit bake) before thresholding for finals, EEVEE and games.
      - Noise Texture `Fac` clusters around 0.5 (roughly 0.3 to 0.7): stretch it with Map Range before using it as a breakup, or the breakup is invisible.
      
      ## P5. Engine-independent curvature and dirt masks (no Bevel node)
      
      Verified on 5.2.1. Tests: `test_wear.py` (EEVEE and Cycles identical, mean 0.3402 vs 0.3404), `test_uvpaint.py` (rasterized into a texture).
      
      ```python
      import sys, bpy
      sys.path.append(SKILL_SCRIPTS)
      import bx_materials as M
      
      bpy.ops.mesh.primitive_monkey_add()
      obj = bpy.context.active_object
      obj.modifiers.new("s", "SUBSURF").levels = 2
      bpy.ops.object.modifier_apply(modifier="s")
      val = M.curvature_attribute(obj, name="col_curvature", smooth=1, contrast=6.0)   # 0.5 flat, >0.5 convex
      mat = M.get_material("CurvatureView")
      vc = mat.node_tree.nodes.new("ShaderNodeVertexColor")
      vc.layer_name = "col_curvature"
      mat.node_tree.links.new(vc.outputs["Color"], M.principled(mat).inputs["Base Color"])
      obj.data.materials.append(mat)
      
      # Blender's own cavity mask: works headless in OBJECT mode on the ACTIVE color attribute
      a = obj.data.color_attributes.new("col_dirt", "BYTE_COLOR", "POINT")
      obj.data.color_attributes.active_color = a
      print(bpy.ops.paint.vertex_color_dirt(blur_strength=1.0, blur_iterations=1, clean_angle=3.14, dirt_angle=0.0, dirt_only=False, normalize=True))
      ```
      
      Both depend on vertex density (like Pointiness). On a low poly, compute them on the high and bake, or rasterize per texel with `M.vertex_values_to_texels` (P11).
      
      ## P6. Decal through a dedicated UV map (Kaspar's logo method)
      
      Verified on 5.2.1. Test: `test_uvpaint.py` (logo on a sphere, render).
      
      ```python
      import sys, bpy, numpy as np
      sys.path.append(SKILL_SCRIPTS)
      import bx_materials as M
      
      bpy.ops.mesh.primitive_uv_sphere_add(radius=0.5, segments=64, ring_count=32)
      ball = bpy.context.active_object
      logo = bpy.data.images.new("logo", 128, 128, alpha=True)
      a = np.zeros((128, 128, 4), dtype=np.float32)
      a[40:88, 40:88] = (1, 1, 1, 1)                  # white square with alpha, stands in for a logo file
      M.write_pixels(logo, a)
      mat = M.get_material("BallPaint")
      ball.data.materials.append(mat)
      M.add_decal(mat, ball, logo, center=(0, -0.5, 0), normal=(0, -1, 0), up=(0, 0, 1), size=0.45)
      print([u.name for u in ball.data.uv_layers])   # ['UVMap', 'decal']
      ```
      
      Faces facing the decal get planar UVs; every other loop is parked outside 0..1 and the image node uses Extension CLIP, so it reads alpha 0 there. For games, a separate decal mesh floating a millimeter above the surface is the usual alternative [added].
      
      ## P7. Stylized skin (Kaspar, Snow #1, #2, #6, translated to Principled v2)
      
      Verified on 5.2.1. Test: `test_skin.py` (EEVEE and Cycles, studio, backlit and roughness-only rigs; two SSS ranges side by side).
      
      ```python
      import sys, bpy, numpy as np
      sys.path.append(SKILL_SCRIPTS)
      import bx_materials as M
      
      bpy.ops.mesh.primitive_monkey_add(size=0.24)          # human-scale head, ears at +-X
      head = bpy.context.active_object
      co = np.array([v.co for v in head.data.vertices])
      ear = np.clip((np.abs(co[:, 0]) - 0.10) / 0.03, 0, 1)
      M.write_vertex_values(head, "col_sss", np.maximum(0.25, ear))    # black/white mask, values set in nodes
      M.smooth_attribute(head, "col_sss", iterations=3)                 # the headless blur brush
      M.mask_from_points(head, "col_brighter", [((0, -0.12, -0.02), 0.05, 0.8)], smooth=2)
      skin = M.stylized_skin("snow.skin", obj=head, coords="OBJECT", pore_scale=40.0)
      head.data.materials.append(skin)
      p = M.principled(skin)
      print(p.subsurface_method, sorted(s.name for s in p.inputs if s.is_linked))
      ```
      
      Recipe inside the builder (sources in expert-notes.md):
      
      - Base color: start darker, brighten through `col_brighter`, darken through `col_darker`, lips mask removed from both, lips color on top.
      - SSS: Subsurface Weight 1; `col_sss` stays black/white and a Map Range sets Subsurface Scale. Kaspar's v1 values were 0.02 to 0.15. In 5.2 (Random Walk rescaled) 0.02 to 0.15 made the ears of a 0.33 m head glow white-pink and waxy under a backlight; 0.005 to 0.04 gave the red glow he asks for (default, measured). Scale proportionally with the character.
      - Roughness: base 0.68 to 0.72; pores from two Voronoi Smooth F1 textures (scale 100 and 200 on his UVs) mixed by a detail-0 noise, general noise mixed at about 0.2, pore centers glossier, cracks rougher; `col_rough` remapped to 0.72 to 1 (chin, brows); lips glossy (0.45 here; his tongue and teeth 0.4).
      - Specular IOR Level stays 0.5: wetness is roughness.
      
      Backlit SSS check: `M.preview([head, ...], out, rigs=("backlit",))` puts a strong area light straight behind plus one from below (Kaspar's rig). Ears, nostrils, lids, lips and fingers should glow red; forehead, jaw and palm center stay opaque.
      
      ## P8. Procedural eye and cornea
      
      Verified on 5.2.1. Tests: `test_skin.py` (eyes on the head, cornea shadow per engine), `tests/code/blender-texturing-shading/probes/probe_cornea.py` (EEVEE thickness variants).
      
      ```python
      import sys, bpy
      sys.path.append(SKILL_SCRIPTS)
      import bx_materials as M
      
      r = 0.012
      bpy.ops.mesh.primitive_uv_sphere_add(segments=48, ring_count=24, radius=r)   # origin = eyeball center
      eye = bpy.context.active_object
      bpy.ops.object.shade_smooth()
      eye.data.materials.append(M.stylized_eye("eye.L", radius=r, forward="-Y", iris_color=(0.10, 0.20, 0.33)))
      bpy.ops.mesh.primitive_uv_sphere_add(segments=48, ring_count=24, radius=r * 1.04)
      cornea = bpy.context.active_object
      bpy.ops.object.shade_smooth()
      cornea.data.materials.append(M.cornea_material("cornea"))
      cornea.visible_shadow = False                  # Kaspar: the cornea casts no shadow
      bpy.context.scene.eevee.use_raytracing = True
      out = M.output_node(bpy.data.materials["cornea"])
      print(out.inputs["Thickness"].is_linked, bpy.data.materials["cornea"].thickness_mode)   # True SLAB
      ```
      
      Measured: with default thickness EEVEE refracts the shell as a solid glass ball and blows the iris across the eye; Material Output Thickness 0 (SLAB or SPHERE) matches Cycles; `use_raytrace_refraction = False` makes the eye vanish behind a probe reflection. `visible_shadow = False` is honored by both engines (iris region brightness 0.59 to 0.76 in Cycles, 0.53 to 0.67 in EEVEE).
      
      ## P9. Anisotropic hair or brushed metal (Cycles only)
      
      Verified on 5.2.1. Test: `test_skin.py` (aniso sheet): EEVEE renders the same material isotropic in 5.2, Cycles shows the stretched highlight. Kaspar kept a fake-anisotropy node group for EEVEE and a separate Cycles output (Material Output `target`).
      
      ```python
      import bpy
      bpy.ops.mesh.primitive_uv_sphere_add()
      mat = bpy.data.materials.new("HairShine")
      nt = mat.node_tree
      p = nt.nodes["Principled BSDF"]
      p.inputs["Roughness"].default_value = 0.35
      p.inputs["Anisotropic"].default_value = 1.0
      t = nt.nodes.new("ShaderNodeTangent")
      t.direction_type = "UV_MAP"
      t.uv_map = "UVMap"                             # a UV map laid along the strand direction ('uv alignment')
      nt.links.new(t.outputs["Tangent"], p.inputs["Tangent"])
      streak = nt.nodes.new("ShaderNodeTexNoise")    # stands in for his painted streak texture
      streak.inputs["Scale"].default_value = 8.0
      rng = nt.nodes.new("ShaderNodeMapRange")
      rng.inputs["To Max"].default_value = 0.5       # his rotation range 0..0.5
      nt.links.new(streak.outputs["Fac"], rng.inputs["Value"])
      nt.links.new(rng.outputs["Result"], p.inputs["Anisotropic Rotation"])
      out = nt.nodes["Material Output"]
      out.target = "CYCLES"                           # an EEVEE output with a fake setup can sit beside it
      bpy.context.active_object.data.materials.append(mat)
      ```
      
      ## P10. Texture painting with real brushes (live GUI session)
      
      GUI only, verified in `test_gui_texture_paint.py` (opens a window). Facts from that run:
      
      - Factory 5.2: brush `Paint Hard`, color AND unified color black, `use_unified_color` True, seam bleed 2 px, mode `MATERIAL`.
      - A stroke issued in the same call that set up the object, view or mode painted 162 texels in one run and full coverage (31,395) in another; resampled strokes after a redraw painted about 14,000 every run. Projection painting occludes with the depth buffer of the last viewport draw, so what that draw contained decides the result. `M.gui_redraw()` forces a draw first.
      - Scripted strokes paint one dab per point (the brush's spacing is not applied): 16 points over 740 px left separate dots. `M.gui_texture_stroke()` resamples to 0.25 x brush diameter.
      - Occlude, Backface Culling and Normal falloff off: one stroke painted 9,969 texels on the front AND 9,969 on the back (Kaspar's through-the-clump hair streaks). With defaults: front only.
      - `Blur` is image_brush_type `SOFTEN`; `Fill` floods the whole canvas (1,048,540 of 1,048,576 texels).
      - `paint.add_texture_paint_slot` (via `bx_gui.run`) creates the image node, links Base Color, image sRGB.
      - Vertex-paint strokes through bx_gui were unreliable in the first tests (only strokes near the view center registered). Cause found and fixed in bx_gui on 2026-09-24: the exec path reads stroke `location` in object space and needs the point on the surface; `bx_gui.stroke()` now raycasts each point from the view and converts to object space. Re-verified in a GUI session: a 14-point `VERTEX_PAINT` stroke across an off-origin sphere painted its full width (55 vertices, local x -0.25 to +0.25). The numpy route (P7) stays the headless option and the one to use for exact, procedural masks.
      - From the batch notes (Abbitt, Surfaced Studio, Ryan King): strokes project from the screen, so they stretch on faces angled away and vanish at edges: `set_view` square to the surface first. Erase brushes punch alpha and need an image with alpha; on opaque maps paint the base color back. Texture Slots mode `MATERIAL` paints every channel of every material; `add_texture_paint_slot(type='ROUGHNESS')` sets Non-Color itself, `type='BUMP'` wires a Bump node (batch probes). Face-selection paint mask: `mesh.use_paint_mask = True`; stencil mask image: `image_paint.use_stencil_layer`, `image_paint.stencil_image`. A custom textured brush needs white color, saturation 0, and "Save Changes to Asset". "Cannot paint" checklist: wrong object holds the paint icon, missing UVs, no image slot, an active stencil or mask image, a black brush texture, extreme stroke spacing.
      - Bleed: Kaspar 8 px, Abbitt 12 px (default 2). Island margin 0.01 to 0.05, larger when the texture will be downscaled for a game.
      
      ```python gui
      import sys, bpy
      sys.path.append(EXPERT_SCRIPTS); sys.path.append(SKILL_SCRIPTS)
      import bx_gui as G, bx_materials as M
      
      ob = bpy.context.active_object                      # UV-mapped mesh with a material
      img = M.new_image("tex_body_color", 2048, color=(0.5, 0.5, 0.5, 1), colorspace="sRGB")
      nt = ob.active_material.node_tree
      tex = nt.nodes.new("ShaderNodeTexImage"); tex.image = img
      nt.links.new(tex.outputs["Color"], nt.nodes["Principled BSDF"].inputs["Base Color"])
      G.set_view("FRONT", frame=ob)
      bpy.ops.object.mode_set(mode="TEXTURE_PAINT")
      ip = bpy.context.tool_settings.image_paint
      ip.mode, ip.canvas, ip.seam_bleed = "IMAGE", img, 8   # Kaspar: bleed 8 px
      M.gui_texture_stroke([(-0.3, -1.0, 0.4), (0.3, -1.0, 0.4)], size=40, brush="Paint Hard", color=(0.8, 0.1, 0.1))
      M.save_image(img, "/abs/path/tex_body_color.png")    # images are not saved with the .blend by default
      ```
      
      ## P11. Headless painting through UV space
      
      Verified on 5.2.1. Test: `test_uvpaint.py`: strokes crossing UV seams stay continuous on a cube corner and across a sphere's seam (painted_sheet.png); 512 px TexelMaps for a cube and a 64x32 sphere built in 0.36 s.
      
      ```python
      import sys, bpy, numpy as np, tempfile, os
      sys.path.append(SKILL_SCRIPTS)
      import bx_materials as M
      
      bpy.ops.mesh.primitive_uv_sphere_add(radius=0.5, segments=64, ring_count=32)
      ball = bpy.context.active_object
      tm = M.TexelMap(ball, 512)                               # world position/normal per texel
      img = M.new_image("ball_color", 512, color=(0.45, 0.45, 0.45, 1), colorspace="sRGB")
      arr = M.pixels(img)
      ring = [(0.5 * np.sin(a) * 0.95, 0.5 * np.cos(a) * 0.95, 0.15) for a in np.linspace(0, 2 * np.pi, 40)]
      M.paint_stroke(arr, tm, ring, radius=0.06, color=(0.9, 0.75, 0.1, 1), hardness=0.6)   # crosses the seam
      M.write_pixels(img, M.dilate(arr, tm.valid, 8))
      dust = M.project(tm, lambda P, N: np.clip((N[..., 2] - 0.4) / 0.4, 0, 1))             # up-facing = dust
      mask = M.new_image("ball_dust", 512, colorspace="Non-Color")
      d4 = np.ones((512, 512, 4), dtype=np.float32); d4[..., :3] = M.dilate(dust, tm.valid, 8)[..., None]
      M.write_pixels(mask, d4)
      out = tempfile.mkdtemp()
      M.save_image(img, os.path.join(out, "ball_color.png"))
      M.save_image(mask, os.path.join(out, "ball_dust.png"))
      print(round(tm.coverage, 3))
      ```
      
      Image value semantics (measured by writing 0.5 and reading the PNG): byte images store values as encoded (0.5 becomes 128 whether sRGB or Non-Color); float images hold linear and a `Linear Rec.709` float saved to PNG is converted to sRGB (0.5 becomes 0.735); a float Non-Color image saved to 16-bit PNG keeps 0.5. Write alpha 1 into maps without real alpha: an RGB PNG save multiplies color by alpha (0.5 with alpha 0.5 saved as 64).
      
      ## P12. Photo texture pattern extraction (Kaspar, Snow #4)
      
      Verified on 5.2.1. Test: `test_uvpaint.py`: low-frequency std 0.129 to 0.018, pattern correlation 0.99.
      
      ```python
      import sys, numpy as np
      sys.path.append(SKILL_SCRIPTS)
      import bx_materials as M
      
      n = 256
      y, x = np.mgrid[0:n, 0:n] / n
      weave = 0.5 + 0.15 * np.sign(np.sin(2 * np.pi * x * 32) * np.sin(2 * np.pi * y * 32))
      photo = np.clip(weave + 0.3 * np.sin(2 * np.pi * x) * np.cos(2 * np.pi * y), 0, 1)   # weave + baked light
      detail = M.high_pass(photo, sigma=16)           # blur + grain extract: original - blur + 0.5
      print(round(float(M.gaussian_blur(detail, 16).std()), 3))
      ```
      
      Use the result as a Non-Color mask and bump; build color, grunge and wear procedurally on top.
      
      ## P13. Checks: audit, value report, previews
      
      Verified on 5.2.1. Tests: `test_pbr.py` (a deliberately wrong material raises 8 findings; the good one none), `test_wear.py`, `test_skin.py`.
      
      ```python
      import sys, bpy, tempfile
      sys.path.append(SKILL_SCRIPTS)
      import bx_materials as M
      
      bpy.ops.mesh.primitive_uv_sphere_add()
      obj = bpy.context.active_object
      mat = M.get_material("Check")
      M.principled(mat).inputs["Metallic"].default_value = 0.5
      obj.data.materials.append(mat)
      print(M.audit_material(mat, obj))                          # flags the gray metallic
      rep = M.value_report([obj], res=96, views=("front",))      # renders channels as emission to linear EXR
      print(rep["problems"])
      sheet = M.preview(mat, tempfile.mkdtemp(), engines=("BLENDER_EEVEE",), rigs=("studio",), res=160, samples=8)
      print(sheet)
      ```
      
      `preview` rigs: `studio` (key, fill, rim on neutral 0.18 gray, no gradient), `backlit` (Kaspar's SSS test), `roughness` (studio with Base Color forced black and SSS off: reads roughness and bump alone). Engines side by side catch EEVEE-only or Cycles-only nodes.
      
      ## P14. Camera-ray world for lookdev (Kaspar, Snow #7)
      
      Verified on 5.2.1 (built and rendered in `test_procedures_md.py`).
      
      ```python
      import bpy
      w = bpy.context.scene.world
      nt = w.node_tree
      env = nt.nodes.new("ShaderNodeTexEnvironment")         # Environment Texture, not Image Texture, for an HDRI
      lit = nt.nodes["Background"]
      flat = nt.nodes.new("ShaderNodeBackground")
      flat.inputs["Color"].default_value = (0.18, 0.18, 0.18, 1)
      lp = nt.nodes.new("ShaderNodeLightPath")
      mix = nt.nodes.new("ShaderNodeMixShader")
      nt.links.new(env.outputs["Color"], lit.inputs["Color"])
      nt.links.new(lp.outputs["Is Camera Ray"], mix.inputs["Fac"])
      nt.links.new(lit.outputs["Background"], mix.inputs[1])
      nt.links.new(flat.outputs["Background"], mix.inputs[2])
      nt.links.new(mix.outputs["Shader"], nt.nodes["World Output"].inputs["Surface"])
      ```
      
      The camera sees flat gray, the object is lit by the HDRI.
      
      ## P15. Global clay switch driven by one property (Kaspar, Snow finale)
      
      Verified on 5.2.1. Keeps bump and normals (unlike `view_layer.material_override`).
      
      ```python
      import bpy
      ctrl = bpy.data.objects.new("lookdev_ctrl", None)
      bpy.context.scene.collection.objects.link(ctrl)
      ctrl["clay"] = 0.0
      mat = bpy.data.materials.new("AnyMat")
      nt = mat.node_tree
      p = nt.nodes["Principled BSDF"]
      mix = nt.nodes.new("ShaderNodeMix"); mix.data_type = "RGBA"
      mix.inputs[7].default_value = (0.6, 0.6, 0.6, 1)       # B = clay gray (RGBA mode: A=inputs[6], B=inputs[7])
      nt.links.new(mix.outputs[2], p.inputs["Base Color"])
      d = mix.inputs[0].driver_add("default_value").driver
      v = d.variables.new()
      v.targets[0].id = ctrl
      v.targets[0].data_path = '["clay"]'
      d.expression = v.name
      print(d.is_valid)
      ```
      
      Add the same driven mix on Roughness (to 0.6) and Subsurface Weight (to 0) in every material; `ctrl["clay"] = 1` turns the whole character to clay for the presentation turntable.
      
      ## P17. Hand-painted stylized light, procedurally (Grant Abbitt, 5.1 guide)
      
      Verified on 5.2.1. Test: `test_painted_light.py` (unlit and studio rigs, EEVEE and Cycles; albedo top third 0.216 vs bottom third 0.177 linear). Grant paints the light into the albedo for unlit game shaders: lighter top, darker bottom, MULTIPLY in crevices, SCREEN on extremities, judged with no lighting. The node version builds the same structure from masks; bake it (Emit) for the game, then add hand details (cracks, lettering) on top in the GUI or with P11.
      
      ```python
      import sys, bpy
      sys.path.append(SKILL_SCRIPTS)
      import bx_materials as M
      
      bpy.ops.mesh.primitive_cube_add(size=1.0)
      stone = bpy.context.active_object
      stone.modifiers.new("s", "SUBSURF").levels = 3
      bpy.ops.object.modifier_apply(modifier="s")
      M.curvature_attribute(stone, smooth=2, contrast=6.0)      # engine-independent crevice/extremity masks
      mat = M.get_material("stone_painted")
      M.principled(mat).inputs["Base Color"].default_value = (0.18, 0.19, 0.2, 1)
      stone.data.materials.append(mat)
      M.painted_light(mat, obj=stone, top=1.15, bottom=0.8, crevice=0.45, highlight=0.4)
      print([n.label for n in mat.node_tree.nodes if n.label])
      ```
      
      Without a curvature attribute the builder falls back to the Bevel/AO group (Cycles only, bake before use in EEVEE or a game). Preview with `M.preview(objs, out, rigs=("unlit", "studio"))`: `unlit` routes Base Color through emission, Grant's "plug the image straight into the output" check.
      
      GUI brush equivalents he uses (5.1): Fill brush with gradient (`brush.color_type = 'GRADIENT'`, drag bottom to top in front view), Fill in MULTIPLY with black at strength about 0.1 per tap to darken globally, a brush in SCREEN with near-white for edge highlights, MULTIPLY at strength about 0.25 for crevices and cracks, Smear to soften a harsh line.
      
      ## P18. Environment grime from a numpy color-attribute mask plus Color Burn (Price)
      
      Verified on 5.2.1. Test: `test_price_masks.py` (plain soft mask vs burned mask on a brick wall, EEVEE and Cycles; the burned mold concentrates in the grout). Price paints the mask in vertex colors (resolution = vertex count, he subdivides a wall with 20 cuts) because image painting on tiling UVs repeats every stroke. Headless, the mask comes from a rule instead of strokes.
      
      ```python
      import sys, bpy, numpy as np
      sys.path.append(SKILL_SCRIPTS)
      import bx_materials as M
      
      bpy.ops.mesh.primitive_plane_add(size=2.0)
      wall = bpy.context.active_object
      bpy.ops.object.mode_set(mode="EDIT")
      bpy.ops.mesh.subdivide(number_cuts=20)
      bpy.ops.object.mode_set(mode="OBJECT")
      co = np.array([v.co for v in wall.data.vertices])
      M.write_vertex_values(wall, "black", np.clip(1 - (co[:, 1] + 1.0) / 0.9, 0, 1))   # mold rising from the bottom
      M.smooth_attribute(wall, "black", 3)
      mat = M.get_material("wall_grime")
      wall.data.materials.append(mat)
      nt = mat.node_tree
      p = M.principled(mat)
      attr = nt.nodes.new("ShaderNodeAttribute"); attr.attribute_name = "black"   # exact, case-sensitive name
      height = nt.nodes.new("ShaderNodeTexNoise")          # stands in for the set's displacement map
      ramp = nt.nodes.new("ShaderNodeValToRGB")            # contrast: scanned height sits in mid grays
      ramp.color_ramp.elements[0].position, ramp.color_ramp.elements[1].position = 0.35, 0.65
      ramp.color_ramp.elements[0].color = (1, 1, 1, 1)     # flipped: low (grout) keeps the mask
      ramp.color_ramp.elements[1].color = (0.55, 0.55, 0.55, 1)
      nt.links.new(height.outputs["Fac"], ramp.inputs["Fac"])
      burn = nt.nodes.new("ShaderNodeMix"); burn.data_type = "RGBA"; burn.blend_type = "BURN"
      burn.inputs[0].default_value = 1.0
      nt.links.new(attr.outputs["Fac"], burn.inputs[6])
      nt.links.new(ramp.outputs["Color"], burn.inputs[7])
      mold = nt.nodes.new("ShaderNodeMix"); mold.data_type = "RGBA"
      mold.inputs[6].default_value = (0.3, 0.1, 0.07, 1)   # A: the existing base-color chain in a real material
      mold.inputs[7].default_value = (0.008, 0.008, 0.007, 1)
      nt.links.new(burn.outputs[2], mold.inputs[0])
      nt.links.new(mold.outputs[2], p.inputs["Base Color"])
      print(burn.blend_type, attr.attribute_name in wall.data.color_attributes)
      ```
      
      Price's layering rules around it: keep painted values mid-range (burn detail shows only at mid and low mask values); set the effect Mix to MULTIPLY and B's value becomes the opacity; efflorescence is the same group with a flipped ramp in SCREEN; spalling is SUBTRACT on the height before the Displacement node (factor about 0.14) plus brighter color in the same area; wetness is a Mix on roughness toward about 0.35 by the same mask; label each group as you build it.
      
      ## P19. Physical UV scale for tiling sets (Price)
      
      Verified on 5.2.1 (block test). One UV unit must equal the texture's published tile size in meters on an object with applied scale.
      
      ```python
      import bpy, bmesh
      bpy.ops.mesh.primitive_plane_add(size=1.0)
      wall = bpy.context.active_object
      wall.scale = (10.0, 5.0, 1.0)
      bpy.ops.object.transform_apply(scale=True)            # all experts: apply scale first
      bpy.ops.object.mode_set(mode="EDIT")
      bpy.ops.mesh.select_all(action="SELECT")
      bpy.ops.uv.unwrap(method="ANGLE_BASED", margin=0.0)   # always pass method (5.2 default is CONFORMAL)
      bm = bmesh.from_edit_mesh(wall.data)
      uv = bm.loops.layers.uv.active
      tile = 2.5                                            # meters per texture tile (vendor data)
      us = [l[uv].uv.x for f in bm.faces for l in f.loops]
      span_u = max(us) - min(us)                            # UV width of the 10 m side
      k = (10.0 / tile) / span_u
      for f in bm.faces:
          for l in f.loops:
              l[uv].uv *= k
      bmesh.update_edit_mesh(wall.data)
      bpy.ops.object.mode_set(mode="OBJECT")
      xs = [d.vector.x for d in wall.data.uv_layers.active.uv]
      print(round(max(xs) - min(xs), 3))                     # 4.0 tiles across the 10 m side
      ```
      
      ## P20. Baking procedural channels (pointer to scenario-blender-uv-baking)
      
      Belongs to scenario-blender-uv-baking. The texturing-side rules: bake at the end for portability (other software, EEVEE load time, game export), not for Cycles speed (Kaspar: Sprite Fright is mostly procedural and unbaked); route each channel through an Emission shader and bake EMIT; 30 samples minimum for anything with high frequencies, never 1 for normals; disable Mask modifier render visibility first; validate by rebuilding the material from the maps and rendering both side by side (`M.preview` on both, or `M.value_report`). In 5.x the target image node must be selected AND active or the bake silently returns CANCELED (UV digest).
      
    • sources.md 11.2 KB
      # Sources
      
      Per-video notes live in the project's `notes/texturing/`; digests `_digest_snow_shading.md` and `_digest_texturing_pbr_painting.md`. The batch digest states that none of its experts gives albedo ranges, calls roughness the most important map, shows box projection or bakes procedurals; those parts of this skill are [added] or measured. URLs are `https://www.youtube.com/watch?v=<id>`.
      
      ## Julien Kaspar, Blender Studio: "Snow" stylized character shading lives (2021)
      
      Credential: character artist at Blender Studio (Spring, Sprite Fright), author of the Stylized Character Workflow course and the free Rain rig. Blender 3.0 alpha on screen (Principled v1, EEVEE Legacy): values need the 5.2 translations in `expert-notes.md`. Auto captions, no frames.
      
      | #   | Id / URL                                                   | Length | Best for                                                                                | Best timestamps                                                                                                                   |
      | --- | ---------------------------------------------------------- | ------ | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
      | 1   | [0vGNOtX9iPk](https://www.youtube.com/watch?v=0vGNOtX9iPk) | 2:20   | base colors, black/white vertex-color masks, judging color honestly                     | 00:09:32 Diffuse Color + Standard; 01:03:33 mask layers; 01:19:25 vertex colors vs texture paint; 02:06:31 face vs vertex masking |
      | 2   | [kN5aq1qXUCE](https://www.youtube.com/watch?v=kN5aq1qXUCE) | 2:22   | masks gating procedurals, lip streaks on a dedicated UV, fabric, bump Distance fix      | 01:03:05 textures via masks; 01:23:24 bump Distance; 01:45:30 UV band masks; 01:56:20 weave direction on hems                     |
      | 3   | [uTjfRGGV6ys](https://www.youtube.com/watch?v=uTjfRGGV6ys) | 2:02   | seam creases from strip UVs, painted 32-bit height, normalizing summed normals          | 00:48:52 crossing masks; 00:55:37 painted height setup; 01:25:29 normalize for Cycles                                             |
      | 4   | [rKVeLjxZzTc](https://www.youtube.com/watch?v=rKVeLjxZzTc) | 2:22   | photo-texture pattern extraction, stitches, color-variation map as mask                 | 00:02:47 blur + grain extract; 01:24:35 stitch group; 02:04:12 wash masks                                                         |
      | 5   | [kAdgrBS4eDA](https://www.youtube.com/watch?v=kAdgrBS4eDA) | 2:02   | procedural eye, sclera, cornea, Mask modifier                                           | 01:10:25 spherical gradient; 01:22:52 sclera tint; 01:41:28 cornea shaders; 01:48:26 pupil scale before bake                      |
      | 6   | [D7kuKx7BV1A](https://www.youtube.com/watch?v=D7kuKx7BV1A) | 2:15   | skin SSS by anatomy, backlit test, pores in roughness, roughness zones, hair anisotropy | 00:12:43 SSS placement; 00:16:06 backlight rig; 00:24:31 SSS color network; 01:00:38 pores in roughness; 01:26:22 roughness zones |
      | 7   | [8GD1C9qLi-4](https://www.youtube.com/watch?v=8GD1C9qLi-4) | 2:28   | lookdev turntable, camera-ray world, hair streak painting through clumps                | 00:22:52 turntable logic; 00:35:14 camera-ray world; 00:47:42 through-strokes; 01:09:34 Cycles anisotropy                         |
      | F   | [EDEMbeZfVcc](https://www.youtube.com/watch?v=EDEMbeZfVcc) | 2:08   | node-tree cleanup, clay/wire switch, bake recipe, presentation lighting                 | 00:06:04 cleanup; 00:15:26 clay switch; 00:31:58 bake setup; 01:36:11 samples for normals                                         |
      
      ## Texturing course and texture-painting batch (`_digest_texturing_pbr_painting.md`)
      
      | Expert / channel                                                          | Id / URL                                                   | Length, Blender | Best for                                                                                                                            | Best timestamps                                                                                                                                                                                                                                             |
      | ------------------------------------------------------------------------- | ---------------------------------------------------------- | --------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
      | Andrew Price, Blender Guru (founder of Poliigon, 9 years of scan capture) | [uHCJoNEWjXo](https://www.youtube.com/watch?v=uHCJoNEWjXo) | 1:53, 4.2       | realistic PBR wiring, normal OR displacement, displacement settings, masks, vertex-color grime, Color Burn, decals, roughness edits | 00:21:29 Non-Color; 00:24:44 AO multiply; 00:29:57 normal vs displacement; 00:32:05 true displacement; 00:57:26 Noise Detail cost; 01:03:20 vertex colors vs image paint; 01:12:13 Color Burn; 01:24:02 decals; 01:30:11 roughness edits; 01:44:33 spalling |
      | Grant Abbitt (GameDev.tv courses)                                         | [7kSRXnAi7uA](https://www.youtube.com/watch?v=7kSRXnAi7uA) | 0:21, 5.1       | stylized hand-painted light with blend modes, unlit judging                                                                         | 00:07:35 gradient fill; 00:10:18 Screen highlights; 00:12:27 unlit preview; 00:14:05 Multiply fill                                                                                                                                                          |
      | Grant Abbitt                                                              | [_-zAhCOUpog](https://www.youtube.com/watch?v=_-zAhCOUpog) | 0:12, 4.3       | brush assets, screen projection limit, Mask stencil, custom brush asset                                                             | 00:02:13 screen projection; 00:07:38 erasing; 00:08:10 mask; 00:10:56 save asset                                                                                                                                                                            |
      | Grant Abbitt                                                              | [E29D9rS8op8](https://www.youtube.com/watch?v=E29D9rS8op8) | 0:05, 4.0       | setup: apply scale, margin 0.01, bleed 12                                                                                           | 00:00:33; 00:04:21                                                                                                                                                                                                                                          |
      | Grant Abbitt                                                              | [WjS_zNQNVlw](https://www.youtube.com/watch?v=WjS_zNQNVlw) | 0:03, 2.93      | island margin reasoning, save the image                                                                                             | 00:01:27; 00:02:50                                                                                                                                                                                                                                          |
      | Ryan King Art (procedural material packs)                                 | [5B244CYX1Tw](https://www.youtube.com/watch?v=5B244CYX1Tw) | 1:09, 2.93      | Object coordinates, metallic binary, Object Info Random, Pointiness, contrast before masking                                        | 00:18:15; 00:40:12; 00:45:06; 01:00:03                                                                                                                                                                                                                      |
      | Ryan King Art                                                             | [4d4N8d4ki2Y](https://www.youtube.com/watch?v=4d4N8d4ki2Y) | 1:04, 2.9x      | painted bump (0.5 gray), stencil photo brush                                                                                        | 00:44:43; 00:56:33                                                                                                                                                                                                                                          |
      | Ryan King Art                                                             | [6jT4K0JpGNs](https://www.youtube.com/watch?v=6jT4K0JpGNs) | 0:27, 3.3       | paint setup checklist, stroke spacing and jitter                                                                                    | 00:04:36; 00:08:08; 00:18:59                                                                                                                                                                                                                                |
      | Surfaced Studio (tutorial and course channel)                             | [ywR8HsNeXQQ](https://www.youtube.com/watch?v=ywR8HsNeXQQ) | 0:44, 3.3       | Material slot mode, face-selection paint masks, texture vs mask, "cannot paint" checklist                                           | 00:05:07; 00:17:09; 00:33:25; 00:42:03                                                                                                                                                                                                                      |
      | NoPoly (self-described non-expert)                                        | [nQaUsgf9ZUg](https://www.youtube.com/watch?v=nQaUsgf9ZUg) | 0:25, 4.4       | UV vs Object coordinates, stretched-procedural axis limit                                                                           | 00:07:21; 00:11:20                                                                                                                                                                                                                                          |
      | YanSculpts                                                                | [kbyK_N65YEk](https://www.youtube.com/watch?v=kbyK_N65YEk) | 0:07, 2.7x      | course trailer only; painted-skin layer order                                                                                       | 00:02:24                                                                                                                                                                                                                                                    |
      
      ## Non-video references
      
      - Blender 5.2 release notes (cached in `archive/tests/version-deltas/rn/5.2/`): Random Walk SSS rescaling (rendering.md, cycles.md), Principled Thin Wall.
      - `sources/blender-version-deltas.md` sections 4, 8, 9, 10, 12 (texture paint, baking, materials, EEVEE, color management).
      - Albedo ranges (dielectric about sRGB 30 to 240, metal reflectance 180 to 255): common PBR guidance (Adobe Substance PBR guide), cited as [added], not re-verified against the guide in this build and not stated by any source video.
      
  • scripts
    • bx_materials.py 85.2 KB
      """
      bx_materials: material builders, headless UV-space painting and render-based material
      checks for Blender 5.2.1 (skill: scenario-blender-texturing-shading). Every function below is
      exercised by tests/code/blender-texturing-shading/test_*.py.
      
        import sys; sys.path.append("<skills>/scenario-blender-texturing-shading/scripts"); import bx_materials as M
      
      Builders (get-or-create by name, Principled BSDF v2 sockets addressed by NAME):
        maps = M.find_texture_set("/abs/textures/bricks")            # role -> path, from file names
        mat  = M.pbr_from_textures("Bricks", maps, uv_map="UVMap")      # colour spaces set per role
        mat  = M.pbr_from_textures("Rock", maps, projection="BOX")      # box/triplanar, no UVs needed
        M.add_wear(mat, edge_color=(0.75, 0.72, 0.68), edge_roughness=0.3, dirt_color=(0.05, 0.04, 0.03))
        ng   = M.edge_wear_group(); ng = M.grunge_group()               # reusable node groups
        M.stylized_skin("snow.skin", obj=body)                          # Kaspar's Snow recipe in v2 terms
        M.stylized_eye("snow.eye", iris_color=(0.18, 0.3, 0.45))        # object-space procedural eye
        M.add_decal(mat, obj, "/abs/logo.png", center, normal, up, size)
      
      Vertex masks (Color Attributes, engine independent, no GUI):
        M.curvature_attribute(obj)                  # col_curvature: 0.5 flat, >0.5 convex, <0.5 concave
        M.mask_from_points(obj, "col_sss", [(co, radius, value), ...])
        M.smooth_attribute(obj, "col_sss", iterations=4)
      
      Headless painting through UV space (the substitute for texture-paint strokes):
        tm  = M.TexelMap(obj, 1024)                 # world position + normal of every texel
        img = M.new_image("rust_mask", 1024, color=(0, 0, 0, 1), colorspace="Non-Color")
        arr = M.pixels(img)
        M.paint_stroke(arr, tm, world_points, radius=0.05, color=(1, 1, 1, 1))  # seam-proof dabs
        arr[...] = M.project(tm, lambda P, N: N[..., 2])                         # e.g. dust from normal z
        M.write_pixels(img, M.dilate(arr, tm.valid, 8)); M.save_image(img, "/abs/rust_mask.png")
        M.high_pass(gray, sigma)                    # Kaspar's grain-extract pattern extraction
      
      Live GUI session (real brushes through scenario-blender-expert/scripts/bx_gui.py):
        M.gui_texture_stroke(world_points, size=40, brush="Paint Hard", color=(0.8, 0.1, 0.1))
                                                    # forces a redraw first, resamples dabs
      
      Checks:
        M.audit_material(mat)                       # static: colour spaces by role, UV maps, bump, emission...
        M.value_report(objs)                        # renders albedo/roughness/metallic as emission to EXR, stats
        M.preview(mat_or_objs, out_dir)             # EEVEE + Cycles contact sheet, studio and backlit rigs
      
      Conventions: colours passed to node sockets are scene-linear. Byte images store values
      as encoded (sRGB images: sRGB-encoded; Non-Color: raw); float images store linear.
      Always write alpha = 1 into images without meaningful alpha: an RGB PNG save multiplies
      colour by alpha.
      """
      
      __version__ = "0.1"  # Blender Expert Skills v0.1 (2026-09-24)
      import math
      import os
      import re
      import sys
      
      import bpy
      import numpy as np
      from mathutils import Vector
      
      _HERE = os.path.dirname(os.path.abspath(__file__))
      _EXPERT = os.path.normpath(os.path.join(_HERE, "..", "..", "scenario-blender-expert", "scripts"))
      if _EXPERT not in sys.path:
          sys.path.append(_EXPERT)
      
      # --------------------------------------------------------------------------------------
      # node helpers
      # --------------------------------------------------------------------------------------
      
      
      def get_material(mat):
          """Material by object or name (created if missing; 5.x new materials already hold
          Principled BSDF + Material Output, `use_nodes` is deprecated)."""
          if isinstance(mat, bpy.types.Material):
              return mat
          m = bpy.data.materials.get(mat)
          return m if m is not None else bpy.data.materials.new(mat)
      
      
      def output_node(mat):
          nt = mat.node_tree
          outs = [n for n in nt.nodes if n.bl_idname == "ShaderNodeOutputMaterial"]
          for n in outs:
              if n.is_active_output:
                  return n
          return outs[0] if outs else nt.nodes.new("ShaderNodeOutputMaterial")
      
      
      def principled(mat):
          """The Principled BSDF that feeds the active output (or the first one)."""
          nt = mat.node_tree
          out = output_node(mat)
          if out.inputs["Surface"].is_linked:
              n = out.inputs["Surface"].links[0].from_node
              if n.bl_idname == "ShaderNodeBsdfPrincipled":
                  return n
          for n in nt.nodes:
              if n.bl_idname == "ShaderNodeBsdfPrincipled":
                  return n
          return None
      
      
      def _new(nt, idname, loc=(0, 0), label=None, **props):
          n = nt.nodes.new(idname)
          n.location = loc
          if label:
              n.label = label
          for k, v in props.items():
              setattr(n, k, v)
          return n
      
      
      def _sock(sockets, name):
          """First ENABLED socket called `name` (Mix and Map Range carry hidden duplicates)."""
          for s in sockets:
              if s.name == name and s.enabled:
                  return s
          raise KeyError(name)
      
      
      def _link(nt, a, b):
          return nt.links.new(a, b)
      
      
      def _set(sock, value):
          if hasattr(sock, "default_value"):
              dv = sock.default_value
              if hasattr(dv, "__len__") and not hasattr(value, "__len__"):
                  value = [value] * len(dv)
              elif hasattr(dv, "__len__") and len(value) != len(dv):
                  value = tuple(value) + (1.0,) * (len(dv) - len(value))
              sock.default_value = value
      
      
      def _feed(nt, sock, src):
          """src: a socket (linked), or a constant."""
          if src is None:
              return
          if isinstance(src, bpy.types.NodeSocket):
              _link(nt, src, sock)
          else:
              _set(sock, src)
      
      
      def _mix(nt, dtype="RGBA", blend="MIX", fac=None, a=None, b=None, loc=(0, 0), label=None, clamp=False):
          n = _new(nt, "ShaderNodeMix", loc, label)
          n.data_type = dtype
          if dtype == "RGBA":
              n.blend_type = blend
              n.clamp_result = clamp
          _feed(nt, _sock(n.inputs, "Factor"), fac)
          _feed(nt, _sock(n.inputs, "A"), a)
          _feed(nt, _sock(n.inputs, "B"), b)
          return n, _sock(n.outputs, "Result")
      
      
      def _math(nt, op, a=None, b=None, loc=(0, 0), clamp=False, label=None):
          n = _new(nt, "ShaderNodeMath", loc, label, operation=op, use_clamp=clamp)
          _feed(nt, n.inputs[0], a)
          if b is not None:
              _feed(nt, n.inputs[1], b)
          return n, n.outputs[0]
      
      
      def _maprange(nt, v, fmin, fmax, tmin=0.0, tmax=1.0, interp="LINEAR", loc=(0, 0), clamp=True, label=None):
          n = _new(nt, "ShaderNodeMapRange", loc, label, interpolation_type=interp, clamp=clamp)
          _feed(nt, _sock(n.inputs, "Value"), v)
          _feed(nt, _sock(n.inputs, "From Min"), fmin)
          _feed(nt, _sock(n.inputs, "From Max"), fmax)
          _feed(nt, _sock(n.inputs, "To Min"), tmin)
          _feed(nt, _sock(n.inputs, "To Max"), tmax)
          return n, _sock(n.outputs, "Result")
      
      
      def _upstream(sock):
          """(from_socket or None, default value) of an input socket."""
          if sock.is_linked:
              return sock.links[0].from_socket, None
          dv = sock.default_value
          return None, (tuple(dv) if hasattr(dv, "__len__") else dv)
      
      
      def _source(nt, sock, loc):
          """A socket carrying what currently feeds `sock` (an RGB/Value node for constants)."""
          s, dv = _upstream(sock)
          if s is not None:
              return s
          if sock.type == "RGBA":
              n = _new(nt, "ShaderNodeRGB", loc)
              n.outputs[0].default_value = dv
              return n.outputs[0]
          n = _new(nt, "ShaderNodeValue", loc)
          n.outputs[0].default_value = dv
          return n.outputs[0]
      
      
      def _group_node(nt, ng, loc=(0, 0)):
          g = _new(nt, "ShaderNodeGroup", loc)
          g.node_tree = ng
          return g
      
      
      def _iface(ng, name, io, stype, default=None, lo=None, hi=None):
          s = ng.interface.new_socket(name=name, in_out=io, socket_type=stype)
          if default is not None:
              s.default_value = default
          if lo is not None:
              s.min_value = lo
          if hi is not None:
              s.max_value = hi
          return s
      
      
      def srgb_to_linear(c):
          c = np.asarray(c, dtype=np.float64)
          return np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4)
      
      
      def linear_to_srgb(c):
          c = np.clip(np.asarray(c, dtype=np.float64), 0, None)
          return np.where(c <= 0.0031308, c * 12.92, 1.055 * np.power(c, 1 / 2.4) - 0.055)
      
      
      # --------------------------------------------------------------------------------------
      # texture sets
      # --------------------------------------------------------------------------------------
      
      # roles whose images are colour (sRGB); every other role is data (Non-Color)
      COLOR_ROLES = {"base_color", "emission"}
      IMAGE_EXT = {".png", ".jpg", ".jpeg", ".tif", ".tiff", ".exr", ".tga", ".bmp", ".webp"}
      _SKIP = {"preview", "thumb", "thumbnail", "sphere", "render", "cube", "swatch"}
      _ROLE_TOKENS = [
          ("orm", {"orm", "arm", "occlusionroughnessmetallic", "occlusionroughnessmetalness"}),
          ("normal", {"normal", "nor", "nrm", "norm", "normalgl", "normaldx", "normalmap"}),
          ("ao", {"ao", "ambientocclusion", "occlusion", "occ"}),
          ("roughness", {"roughness", "rough", "rgh"}),
          ("gloss", {"gloss", "glossiness"}),
          ("metallic", {"metallic", "metalness", "metal", "mtl"}),
          ("height", {"height", "disp", "displacement", "bump", "heightmap"}),
          ("opacity", {"opacity", "alpha", "transparency"}),
          ("emission", {"emission", "emissive", "emit", "glow"}),
          ("specular", {"specular", "spec", "reflection", "refl"}),
          ("base_color", {"basecolor", "albedo", "diffuse", "diff", "color", "colour", "col", "base"}),
      ]
      
      
      def _tokens(stem):
          s = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", stem)       # camelCase -> camel_Case
          s = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", s)
          return [t for t in re.split(r"[^a-zA-Z0-9]+", s.lower()) if t]
      
      
      _NOISE_TOK = re.compile(r"^(\d+k|\d+|png|jpe?g|exr|tiff?|tga|webp|gl|dx|opengl|directx|\d+bits?|bit|lod\d*|udim|srgb|linear|map|tex|texture|t|m)$")
      
      
      def classify_texture(filename):
          """Role of a texture file from its name, or None. The role word is taken from the END
          of the name (skipping resolution/format tokens) so a material called 'metal_plate'
          does not turn its diffuse into a metallic map. Normal maps return 'normal' or
          'normal_dx' (DirectX green: ambientCG _NormalDX, Poly Haven _nor_dx)."""
          stem, ext = os.path.splitext(os.path.basename(filename))
          if ext.lower() not in IMAGE_EXT:
              return None
          toks = _tokens(stem)
          if any(t in _SKIP for t in toks):
              return None
          flat = "".join(toks)
          if {"orm", "arm"} & set(toks) or "occlusionroughnessmetal" in flat:
              return "orm"
          dx = bool({"dx", "directx"} & set(toks)) or "normaldx" in flat or "nordx" in flat
          role_of = {}
          for role, words in _ROLE_TOKENS:
              for w in words:
                  role_of.setdefault(w, role)
          for i in range(len(toks) - 1, -1, -1):
              t = toks[i]
              if _NOISE_TOK.match(t):
                  continue
              pair = toks[i - 1] + t if i > 0 else None
              role = role_of.get(pair) if pair else None
              role = role or role_of.get(t)
              if role is None:
                  continue
              if role == "normal" and dx:
                  return "normal_dx"
              return role
          return None
      
      
      def find_texture_set(folder, stem=None):
          """{role: path} for the images in `folder` (optionally only names containing `stem`).
          Preference when a role appears twice: exr > png > tif > jpg, then larger file."""
          pref = {".exr": 0, ".png": 1, ".tif": 2, ".tiff": 2, ".tga": 3, ".jpg": 4, ".jpeg": 4}
          found = {}
          for f in sorted(os.listdir(folder)):
              if stem and stem.lower() not in f.lower():
                  continue
              role = classify_texture(f)
              if role is None:
                  continue
              p = os.path.join(folder, f)
              key = (pref.get(os.path.splitext(f)[1].lower(), 5), -os.path.getsize(p))
              if role not in found or key < found[role][0]:
                  found[role] = (key, p)
          return {r: v[1] for r, v in found.items()}
      
      
      def load_image(path, role):
          """Load (or reuse) an image and set its colour space for the role."""
          img = bpy.data.images.load(path, check_existing=True)
          img.colorspace_settings.name = "sRGB" if role in COLOR_ROLES else "Non-Color"
          return img
      
      
      def pbr_from_textures(mat, maps, uv_map=None, projection="UV", box_blend=0.2, scale=(1.0, 1.0, 1.0),
                            height_as="auto", height_depth=0.005, displacement_midlevel=0.5,
                            ao_mode="multiply", ao_strength=1.0, clear=True):
          """Wire a texture set into a Principled BSDF v2 by socket NAME.
      
          maps: {role: path or bpy Image}; roles base_color, roughness, gloss, metallic, normal,
                normal_dx, height, ao, orm (R=AO, G=roughness, B=metallic; Poly Haven 'arm'),
                opacity, emission. Unknown roles are ignored ('specular' is reported, not wired).
          uv_map: explicit UV map name for every image node (Kaspar: never rely on the active
                render UV). projection='BOX' uses Object coordinates + box blending (no UVs).
          height_as: 'auto' (bump only when there is no normal map), 'bump', 'displacement'
                (true displacement; the normal map is then NOT wired: Price, confirmed by Brecht,
                normal map and displacement encode the same relief), 'none'.
          height_depth: metres that height 0..1 spans; becomes the Bump Distance (the 5.2 default
                0.001 is 1 mm) or the Displacement Scale (Poliigon: 0.2, a fixed 20 cm range).
          ao_mode: 'multiply' (Price: AO x base colour at factor 1 for scanned sets, restores the
                cavity shadow the flash capture removed; optional under true displacement),
                'gltf' (game export: separate occlusion via a glTF Material Output group, never
                baked into albedo), 'none'.
          Returns the material. Non-Color is set on every data map, sRGB on colour maps."""
          mat = get_material(mat)
          nt = mat.node_tree
          if clear:
              for n in list(nt.nodes):
                  nt.nodes.remove(n)
          out = _new(nt, "ShaderNodeOutputMaterial", (900, 0))
          p = _new(nt, "ShaderNodeBsdfPrincipled", (500, 0))
          _link(nt, p.outputs["BSDF"], out.inputs["Surface"])
          maps = dict(maps)
          if "normal_dx" in maps and "normal" not in maps:
              maps["normal"], dx = maps.pop("normal_dx"), True
          else:
              dx = False
              maps.pop("normal_dx", None)
          if projection == "BOX":
              tc = _new(nt, "ShaderNodeTexCoord", (-1100, 0))
              vec_src = tc.outputs["Object"]
          else:
              uvn = _new(nt, "ShaderNodeUVMap", (-1100, 0))
              if uv_map:
                  uvn.uv_map = uv_map
              vec_src = uvn.outputs["UV"]
          mapping = _new(nt, "ShaderNodeMapping", (-900, 0))
          mapping.inputs["Scale"].default_value = scale
          _link(nt, vec_src, mapping.inputs["Vector"])
      
          y = [400]
      
          def tex(role):
              src = maps[role]
              img = src if isinstance(src, bpy.types.Image) else load_image(src, role)
              img.colorspace_settings.name = "sRGB" if role in COLOR_ROLES else "Non-Color"
              n = _new(nt, "ShaderNodeTexImage", (-600, y[0]), role)
              y[0] -= 300
              n.image = img
              if projection == "BOX":
                  n.projection, n.projection_blend = "BOX", box_blend
              _link(nt, mapping.outputs["Vector"], n.inputs["Vector"])
              return n
      
          ao_sock = None
          if "base_color" in maps:
              t = tex("base_color")
              base = t.outputs["Color"]
          else:
              base = None
          if "orm" in maps:
              t = tex("orm")
              sep = _new(nt, "ShaderNodeSeparateColor", (-300, y[0] + 300))
              _link(nt, t.outputs["Color"], sep.inputs["Color"])
              _link(nt, sep.outputs["Green"], p.inputs["Roughness"])
              _link(nt, sep.outputs["Blue"], p.inputs["Metallic"])
              ao_sock = sep.outputs["Red"]
          if "roughness" in maps:
              _link(nt, tex("roughness").outputs["Color"], p.inputs["Roughness"])
          elif "gloss" in maps:
              _, inv = _math(nt, "SUBTRACT", 1.0, tex("gloss").outputs["Color"], (-300, y[0] + 300))
              _link(nt, inv, p.inputs["Roughness"])
          if "metallic" in maps:
              _link(nt, tex("metallic").outputs["Color"], p.inputs["Metallic"])
          if "ao" in maps and ao_sock is None:
              ao_sock = tex("ao").outputs["Color"]
          if base is not None:
              if ao_sock is not None and ao_mode == "multiply":
                  _, base = _mix(nt, "RGBA", "MULTIPLY", ao_strength, base, ao_sock, (-150, 350), "AO multiply")
              _link(nt, base, p.inputs["Base Color"])
          if "opacity" in maps:
              _link(nt, tex("opacity").outputs["Color"], p.inputs["Alpha"])
              mat.surface_render_method = "DITHERED"
          if "emission" in maps:
              _link(nt, tex("emission").outputs["Color"], p.inputs["Emission Color"])
              p.inputs["Emission Strength"].default_value = 1.0     # 5.x default is 0
          normal_out = None
          if "normal" in maps and projection == "BOX":
              maps.pop("normal")                         # tangent frame comes from UVs, box projection has none [added]
              mat["bx_note"] = "normal map dropped: box projection (height drives bump instead)"
          if "normal" in maps and height_as == "displacement" and "height" in maps:
              maps.pop("normal")                         # one or the other, never both (Price)
              mat["bx_note"] = "normal map dropped: true displacement in use"
          if "normal" in maps:
              t = tex("normal")
              nm = _new(nt, "ShaderNodeNormalMap", (-150, y[0] + 300))
              if uv_map:
                  nm.uv_map = uv_map
              if dx:
                  nm.convention = "DIRECTX"
              _link(nt, t.outputs["Color"], nm.inputs["Color"])
              normal_out = nm.outputs["Normal"]
          mode = height_as
          if mode == "auto":
              mode = "bump" if ("height" in maps and "normal" not in maps) else "none"
          if "height" in maps and mode in ("bump", "displacement"):
              t = tex("height")
              t.interpolation = "Cubic"                  # Price: hides 8/16-bit stepping
              if mode == "bump":
                  b = _new(nt, "ShaderNodeBump", (150, -400))
                  b.inputs["Distance"].default_value = height_depth
                  b.inputs["Strength"].default_value = 1.0
                  _link(nt, t.outputs["Color"], b.inputs["Height"])
                  if normal_out is not None:                    # chain, never add normals
                      _link(nt, normal_out, b.inputs["Normal"])
                  normal_out = b.outputs["Normal"]
              else:
                  d = _new(nt, "ShaderNodeDisplacement", (500, -500))
                  d.inputs["Midlevel"].default_value = displacement_midlevel
                  d.inputs["Scale"].default_value = height_depth
                  _link(nt, t.outputs["Color"], d.inputs["Height"])
                  _link(nt, d.outputs["Displacement"], out.inputs["Displacement"])
                  mat.displacement_method = "BOTH"
          if normal_out is not None:
              _link(nt, normal_out, p.inputs["Normal"])
          if ao_mode == "gltf" and ao_sock is not None:
              ng = bpy.data.node_groups.get("glTF Material Output")
              if ng is None:
                  ng = bpy.data.node_groups.new("glTF Material Output", "ShaderNodeTree")
                  _iface(ng, "Occlusion", "INPUT", "NodeSocketFloat", 1.0, 0.0, 1.0)
              g = _group_node(nt, ng, (900, -300))
              _link(nt, ao_sock, g.inputs["Occlusion"])
          mat["bx_texture_set"] = {k: (v.filepath if isinstance(v, bpy.types.Image) else v) for k, v in maps.items()}
          return mat
      
      
      # --------------------------------------------------------------------------------------
      # wear, dirt, variation
      # --------------------------------------------------------------------------------------
      
      
      def edge_wear_group(name="BX Edge Wear"):
          """Node group: Edges (convex), Cavity (concave), Wear (edges broken by noise into
          chips), Dirt (cavities broken by noise). Edges = angle between the Bevel-node normal
          and the shading normal (linear across the bevel radius), kept only where AO says the
          surface is open (convex). Cavity = 1 - AO.
          Cycles only for Edges/Wear: the Bevel node returns the plain normal in EEVEE (masks
          go black, verified), and EEVEE's AO node is screen-space (Cavity/Dirt differ). For
          EEVEE or a game engine bake these outputs (scenario-blender-uv-baking) or use
          curvature_attribute()."""
          ng = bpy.data.node_groups.get(name)
          if ng is not None:
              return ng
          ng = bpy.data.node_groups.new(name, "ShaderNodeTree")
          _iface(ng, "Radius", "INPUT", "NodeSocketFloat", 0.03, 0.0, 10.0)
          _iface(ng, "Edge Sharpness", "INPUT", "NodeSocketFloat", 0.35, 0.01, 1.57)
          _iface(ng, "Cavity Distance", "INPUT", "NodeSocketFloat", 0.1, 0.0, 10.0)
          _iface(ng, "Noise Scale", "INPUT", "NodeSocketFloat", 12.0, 0.0, 1000.0)
          _iface(ng, "Breakup", "INPUT", "NodeSocketFloat", 0.8, 0.0, 2.0)
          _iface(ng, "Wear Amount", "INPUT", "NodeSocketFloat", 0.5, 0.0, 1.0)
          _iface(ng, "Dirt Amount", "INPUT", "NodeSocketFloat", 0.5, 0.0, 1.0)
          for o in ("Wear", "Edges", "Cavity", "Dirt"):
              _iface(ng, o, "OUTPUT", "NodeSocketFloat")
          gi = _new(ng, "NodeGroupInput", (-1400, 0))
          go = _new(ng, "NodeGroupOutput", (900, 0))
          geo = _new(ng, "ShaderNodeNewGeometry", (-1200, 300))
          bev = _new(ng, "ShaderNodeBevel", (-1200, 100))
          bev.samples = 16
          _link(ng, gi.outputs["Radius"], bev.inputs["Radius"])
          dot = _new(ng, "ShaderNodeVectorMath", (-1000, 200), operation="DOT_PRODUCT")
          _link(ng, geo.outputs["Normal"], dot.inputs[0])
          _link(ng, bev.outputs["Normal"], dot.inputs[1])
          _, dotc = _math(ng, "MINIMUM", dot.outputs["Value"], 1.0, (-850, 200))
          _, ang = _math(ng, "ARCCOSINE", dotc, None, (-700, 200))          # 0 flat, 0.785 on a 90 deg edge
          _, edge = _maprange(ng, ang, 0.0, gi.outputs["Edge Sharpness"], loc=(-500, 200))
          ao = _new(ng, "ShaderNodeAmbientOcclusion", (-1200, -200), only_local=True, samples=16)
          _link(ng, gi.outputs["Cavity Distance"], ao.inputs["Distance"])
          _, openness = _maprange(ng, ao.outputs["AO"], 0.75, 0.95, loc=(-700, 0))
          _, edge = _math(ng, "MULTIPLY", edge, openness, (-300, 200))
          _, cav = _math(ng, "SUBTRACT", 1.0, ao.outputs["AO"], (-1000, -200))
          _, cav = _maprange(ng, cav, 0.0, 0.5, loc=(-700, -200))
          tc = _new(ng, "ShaderNodeTexCoord", (-1200, -500))
          nz = _new(ng, "ShaderNodeTexNoise", (-1000, -500))
          nz.inputs["Detail"].default_value = 5.0           # Price: Detail above 5-6 blows up shader cost
          nz.inputs["Roughness"].default_value = 0.65
          _link(ng, tc.outputs["Object"], nz.inputs["Vector"])
          _link(ng, gi.outputs["Noise Scale"], nz.inputs["Scale"])
          # Noise Fac clusters around 0.5 (roughly 0.3..0.7): stretch it to -1..1 first
          _, n0 = _maprange(ng, nz.outputs["Fac"], 0.3, 0.7, -1.0, 1.0, loc=(-800, -500))
          _, nbw = _math(ng, "MULTIPLY", gi.outputs["Breakup"], 0.5, (-800, -700))
          _, nb = _math(ng, "MULTIPLY", n0, nbw, (-600, -500))
          # thresholds: a higher amount lowers the level the mask must pass
          _, wthr = _math(ng, "SUBTRACT", 1.0, gi.outputs["Wear Amount"], (-500, -300))
          _, wthr_hi = _math(ng, "ADD", wthr, 0.06, (-300, -300))
          _, e2 = _math(ng, "ADD", edge, nb, (-100, 200))
          _, wear = _maprange(ng, e2, wthr, wthr_hi, loc=(250, 200))
          _, dthr = _math(ng, "SUBTRACT", 1.0, gi.outputs["Dirt Amount"], (-500, -700))
          _, dthr_hi = _math(ng, "ADD", dthr, 0.2, (-300, -700))
          _, d2 = _math(ng, "ADD", cav, nb, (-100, -200))
          _, dirt = _maprange(ng, d2, dthr, dthr_hi, loc=(250, -200))
          _link(ng, wear, go.inputs["Wear"])
          _link(ng, edge, go.inputs["Edges"])
          _link(ng, cav, go.inputs["Cavity"])
          _link(ng, dirt, go.inputs["Dirt"])
          return ng
      
      
      def grunge_group(name="BX Grunge"):
          """Node group: Variation (low-frequency 0..1 around 0.5, for hue/value shifts),
          Grunge (spotty coverage mask), Streaks (vertical, stretched along Z). Works in EEVEE
          and Cycles. Feed Vector with Object coords (rigid props) or UV (deforming meshes)."""
          ng = bpy.data.node_groups.get(name)
          if ng is not None:
              return ng
          ng = bpy.data.node_groups.new(name, "ShaderNodeTree")
          _iface(ng, "Vector", "INPUT", "NodeSocketVector")
          _iface(ng, "Scale", "INPUT", "NodeSocketFloat", 4.0, 0.0, 1000.0)
          _iface(ng, "Variation Scale", "INPUT", "NodeSocketFloat", 1.5, 0.0, 1000.0)
          _iface(ng, "Coverage", "INPUT", "NodeSocketFloat", 0.3, 0.0, 1.0)
          _iface(ng, "Softness", "INPUT", "NodeSocketFloat", 0.1, 0.001, 1.0)
          _iface(ng, "Streak Stretch", "INPUT", "NodeSocketFloat", 10.0, 1.0, 100.0)
          for o in ("Variation", "Grunge", "Streaks"):
              _iface(ng, o, "OUTPUT", "NodeSocketFloat")
          gi = _new(ng, "NodeGroupInput", (-1200, 0))
          go = _new(ng, "NodeGroupOutput", (800, 0))
          nv = _new(ng, "ShaderNodeTexNoise", (-800, 300))
          nv.inputs["Detail"].default_value = 2.0
          _link(ng, gi.outputs["Vector"], nv.inputs["Vector"])
          _link(ng, gi.outputs["Variation Scale"], nv.inputs["Scale"])
          _link(ng, nv.outputs["Fac"], go.inputs["Variation"])
          ng_ = _new(ng, "ShaderNodeTexNoise", (-800, 0))
          ng_.inputs["Detail"].default_value = 5.0
          ng_.inputs["Roughness"].default_value = 0.7
          _link(ng, gi.outputs["Vector"], ng_.inputs["Vector"])
          _link(ng, gi.outputs["Scale"], ng_.inputs["Scale"])
          vo = _new(ng, "ShaderNodeTexVoronoi", (-800, -250))
          _link(ng, gi.outputs["Vector"], vo.inputs["Vector"])
          _, vs = _math(ng, "MULTIPLY", gi.outputs["Scale"], 2.0, (-1000, -250))
          _link(ng, vs, vo.inputs["Scale"])
          _, g = _mix(ng, "FLOAT", fac=0.3, a=ng_.outputs["Fac"], b=vo.outputs["Distance"], loc=(-550, 0))
          # coverage: fraction of the surface that ends up above the threshold (roughly)
          _, thr = _math(ng, "SUBTRACT", 0.7, _math(ng, "MULTIPLY", gi.outputs["Coverage"], 0.4, loc=(-550, -200))[1], (-350, -200))
          _, thr_hi = _math(ng, "ADD", thr, gi.outputs["Softness"], (-150, -200))
          _, grunge = _maprange(ng, g, thr, thr_hi, loc=(100, 0))
          _link(ng, grunge, go.inputs["Grunge"])
          mp = _new(ng, "ShaderNodeMapping", (-800, -550))
          _link(ng, gi.outputs["Vector"], mp.inputs["Vector"])
          _, inv = _math(ng, "DIVIDE", 1.0, gi.outputs["Streak Stretch"], (-1000, -600))
          cz = _new(ng, "ShaderNodeCombineXYZ", (-1000, -750))
          cz.inputs[0].default_value = 1.0
          cz.inputs[1].default_value = 1.0
          _link(ng, inv, cz.inputs[2])
          _link(ng, cz.outputs[0], mp.inputs["Scale"])
          ns = _new(ng, "ShaderNodeTexNoise", (-550, -550))
          ns.inputs["Detail"].default_value = 5.0
          _link(ng, mp.outputs["Vector"], ns.inputs["Vector"])
          _, ss = _math(ng, "MULTIPLY", gi.outputs["Scale"], 1.5, (-800, -800))
          _link(ng, ss, ns.inputs["Scale"])
          _, streaks = _maprange(ng, ns.outputs["Fac"], 0.5, 0.75, loc=(100, -550))
          _link(ng, streaks, go.inputs["Streaks"])
          return ng
      
      
      def add_wear(mat, edge_color=None, edge_roughness=None, edge_metallic=None,
                   dirt_color=(0.06, 0.05, 0.04), dirt_roughness=0.9, dirt_amount=0.8,
                   grunge_amount=0.35, variation=0.06, coords="OBJECT", uv_map=None,
                   radius=0.03, wear_amount=0.5, cavity_dirt=0.5, noise_scale=12.0, grunge_scale=4.0):
          """Insert colour variation, grunge/dirt and edge wear between whatever feeds the
          Principled Base Color / Roughness (/ Metallic) and the BSDF. Returns the group nodes.
          edge_*: what worn convex edges become (None = leave that channel). Dirt goes into
          cavities (AO) and grunge spots; it darkens and roughens. variation: +- value shift.
          Edge wear and cavity dirt use Bevel/AO, i.e. Cycles; grunge/variation work everywhere.
          Bevel/AO are sampled per render sample: any threshold on them (wear chips, the 0/1
          metallic here) averages to fractional values at the border (17-18% grey metallic at
          16-128 spp, 0% at 1 spp, measured). For final frames, EEVEE or games bake Wear/Dirt
          to textures first (scenario-blender-uv-baking), then threshold the baked mask."""
          mat = get_material(mat)
          nt = mat.node_tree
          p = principled(mat)
          x0, y0 = p.location.x - 700, p.location.y
          bc = _source(nt, p.inputs["Base Color"], (x0 - 400, y0 + 300))
          rg = _source(nt, p.inputs["Roughness"], (x0 - 400, y0 - 100))
          if coords == "UV":
              c = _new(nt, "ShaderNodeUVMap", (x0 - 600, y0 - 600))
              if uv_map:
                  c.uv_map = uv_map
              vec = c.outputs["UV"]
          else:
              vec = _new(nt, "ShaderNodeTexCoord", (x0 - 600, y0 - 600)).outputs["Object"]
          gg = _group_node(nt, grunge_group(), (x0 - 350, y0 - 600))
          gg.label = "grunge"
          _link(nt, vec, gg.inputs["Vector"])
          gg.inputs["Scale"].default_value = grunge_scale
          gg.inputs["Coverage"].default_value = grunge_amount
          ew = _group_node(nt, edge_wear_group(), (x0 - 350, y0 - 950))
          ew.label = "edge wear"
          ew.inputs["Radius"].default_value = radius
          ew.inputs["Wear Amount"].default_value = wear_amount
          ew.inputs["Dirt Amount"].default_value = cavity_dirt
          ew.inputs["Noise Scale"].default_value = noise_scale
          # variation: value (and a touch of hue) from the low-frequency noise
          hsv = _new(nt, "ShaderNodeHueSaturation", (x0, y0 + 300), "variation")
          _, hue = _maprange(nt, gg.outputs["Variation"], 0.0, 1.0, 0.5 - variation * 0.15, 0.5 + variation * 0.15, loc=(x0 - 200, y0 + 450))
          _, val = _maprange(nt, gg.outputs["Variation"], 0.0, 1.0, 1.0 - variation * 2, 1.0 + variation * 2, loc=(x0 - 200, y0 + 150))
          _link(nt, hue, hsv.inputs["Hue"])
          _link(nt, val, hsv.inputs["Value"])
          _link(nt, bc, hsv.inputs["Color"])
          # dirt = max(cavity dirt, grunge) * amount
          _, dmask = _math(nt, "MAXIMUM", ew.outputs["Dirt"], gg.outputs["Grunge"], (x0 + 50, y0 - 300))
          _, dmask = _math(nt, "MULTIPLY", dmask, dirt_amount, (x0 + 200, y0 - 300), clamp=True)
          _, col = _mix(nt, "RGBA", "MIX", dmask, hsv.outputs["Color"], dirt_color, (x0 + 250, y0 + 300), "dirt")
          _, rough = _mix(nt, "FLOAT", fac=dmask, a=rg, b=dirt_roughness, loc=(x0 + 250, y0 - 100))
          if edge_color is not None:
              _, col = _mix(nt, "RGBA", "MIX", ew.outputs["Wear"], col, edge_color, (x0 + 450, y0 + 300), "edge wear")
          if edge_roughness is not None:
              _, rough = _mix(nt, "FLOAT", fac=ew.outputs["Wear"], a=rough, b=edge_roughness, loc=(x0 + 450, y0 - 100))
          if edge_metallic is not None:
              # metallic stays a 0/1 mask: harden the soft wear edge (value_report flagged 19%
              # grey metallic when the soft mask drove it directly)
              mt = _source(nt, p.inputs["Metallic"], (x0 - 400, y0 - 250))
              _, hard = _math(nt, "GREATER_THAN", ew.outputs["Wear"], 0.5, (x0 + 250, y0 - 250))
              _, met = _mix(nt, "FLOAT", fac=hard, a=mt, b=edge_metallic, loc=(x0 + 450, y0 - 250))
              _link(nt, met, p.inputs["Metallic"])
          _link(nt, col, p.inputs["Base Color"])
          _link(nt, rough, p.inputs["Roughness"])
          return {"grunge": gg, "edge_wear": ew}
      
      
      def painted_light(mat, obj=None, curvature="col_curvature", top=1.15, bottom=0.8, crevice=0.45, highlight=0.4,
                        highlight_color=(1.0, 0.95, 0.85)):
          """Grant Abbitt's hand-painted stylized pass (5.1 guide) as nodes, for baking into an
          unlit/game albedo: vertical gradient (lighter top, darker bottom), MULTIPLY darkening in
          crevices, SCREEN highlights on extremities. Masks come from a curvature colour
          attribute when `obj` has one (engine independent; see curvature_attribute), else from
          the BX Edge Wear group (Bevel/AO, Cycles). Judge it with preview(rigs=('unlit',)) and
          bake it (Emit) for the game. Returns the final colour socket."""
          mat = get_material(mat)
          nt = mat.node_tree
          p = principled(mat)
          x0, y0 = p.location.x - 900, p.location.y + 500
          base = _source(nt, p.inputs["Base Color"], (x0 - 300, y0))
          tc = _new(nt, "ShaderNodeTexCoord", (x0 - 700, y0 + 300))
          sep = _new(nt, "ShaderNodeSeparateXYZ", (x0 - 500, y0 + 300))
          _link(nt, tc.outputs["Generated"], sep.inputs[0])            # 0 at the bottom of the bbox, 1 at the top
          _, grad = _maprange(nt, sep.outputs["Z"], 0.0, 1.0, bottom, top, loc=(x0 - 300, y0 + 300), label="vertical gradient")
          _, col = _mix(nt, "RGBA", "MULTIPLY", 1.0, base, _math(nt, "MULTIPLY", grad, 1.0, (x0 - 150, y0 + 300))[1], (x0, y0 + 200), "gradient")
          if obj is not None and obj.type == "MESH" and curvature in obj.data.color_attributes:
              vc = _new(nt, "ShaderNodeVertexColor", (x0 - 500, y0 - 200), curvature)
              vc.layer_name = curvature
              k = vc.outputs["Color"]
              _, cav = _maprange(nt, k, 0.45, 0.2, loc=(x0 - 300, y0 - 150), label="crevices")
              _, edge = _maprange(nt, k, 0.55, 0.8, loc=(x0 - 300, y0 - 350), label="extremities")
          else:
              ew = _group_node(nt, edge_wear_group(), (x0 - 500, y0 - 250))
              cav, edge = ew.outputs["Cavity"], ew.outputs["Edges"]
          _, fc = _math(nt, "MULTIPLY", cav, crevice, (x0 - 100, y0 - 150))
          _, col = _mix(nt, "RGBA", "MULTIPLY", fc, col, (0.0, 0.0, 0.0), (x0 + 200, y0), "crevices (multiply)")
          _, fe = _math(nt, "MULTIPLY", edge, highlight, (x0 - 100, y0 - 350))
          _, col = _mix(nt, "RGBA", "SCREEN", fe, col, highlight_color, (x0 + 400, y0), "highlights (screen)")
          _link(nt, col, p.inputs["Base Color"])
          return col
      
      
      # --------------------------------------------------------------------------------------
      # stylized character shading (Julien Kaspar, Snow lives, translated to Principled v2)
      # --------------------------------------------------------------------------------------
      
      
      def _attr_fac(nt, obj, name, loc):
          """Fac of a colour attribute if the mesh has it, else None."""
          if obj is None or obj.type != "MESH" or name not in obj.data.color_attributes:
              return None
          n = _new(nt, "ShaderNodeVertexColor", loc, name)
          n.layer_name = name
          return n.outputs["Color"]
      
      
      def stylized_skin(mat, obj=None, base_color=(0.50, 0.26, 0.18), brighter_color=(0.68, 0.40, 0.29),
                        darker_color=(0.36, 0.16, 0.12), lips_color=(0.45, 0.14, 0.12),
                        sss_scale=(0.005, 0.04), base_roughness=0.7, rough_zone=(0.72, 1.0), lips_roughness=0.45,
                        pore_scale=100.0, pore_amount=0.06, noise_mix=0.22, coords="UV", uv_map=None,
                        masks=None):
          """Kaspar's Snow skin in 5.2 terms. Masks are black/white colour attributes, read only
          if present on `obj`: col_brighter, col_darker, col_lips, col_sss, col_rough.
            base colour: start darker, brighten/darken through masks; lips mask removed from both.
            SSS: Subsurface Weight 1, Subsurface Scale = Map Range(col_sss) over `sss_scale`
                 metres, Random Walk (Skin). His v1 'Subsurface' 0.02..0.15 was a radius
                 multiplier; in 5.2 (Random Walk rescaled) those values turned a 0.33 m head's
                 ears white-pink and waxy in a backlit Cycles test, 0.005..0.04 gave the red
                 glow he asks for (measured, test_skin.py). Scale with character size.
            roughness: base 0.68..0.72 with pores (two Voronoi Smooth F1 scales, mixed by a
                 detail-0 noise, general noise mixed at ~0.2); col_rough -> 0.72..1; lips glossy.
            Specular IOR Level stays 0.5: wetness is roughness, not specular.
          Returns the material."""
          mat = get_material(mat)
          nt = mat.node_tree
          for n in list(nt.nodes):
              nt.nodes.remove(n)
          names = {"brighter": "col_brighter", "darker": "col_darker", "lips": "col_lips",
                   "sss": "col_sss", "rough": "col_rough"}
          names.update(masks or {})
          out = _new(nt, "ShaderNodeOutputMaterial", (1400, 0))
          p = _new(nt, "ShaderNodeBsdfPrincipled", (1000, 0))
          _link(nt, p.outputs["BSDF"], out.inputs["Surface"])
          p.subsurface_method = "RANDOM_WALK_SKIN"
          p.inputs["Subsurface Weight"].default_value = 1.0
          p.inputs["Specular IOR Level"].default_value = 0.5
          # ---- base colour block
          m = {k: _attr_fac(nt, obj, v, (-1200, 600 - i * 180)) for i, (k, v) in enumerate(names.items())}
          col = base_color
          not_lips = None
          if m["lips"] is not None:
              _, not_lips = _math(nt, "SUBTRACT", 1.0, m["lips"], (-900, 700))
          for key, c, blend in (("brighter", brighter_color, "MIX"), ("darker", darker_color, "MIX")):
              if m[key] is None:
                  continue
              f = m[key]
              if not_lips is not None:
                  _, f = _math(nt, "MULTIPLY", f, not_lips, (-700, 600 if key == "brighter" else 450))
              _, col = _mix(nt, "RGBA", blend, f, col, c, (-450, 600 if key == "brighter" else 450), key)
          if m["lips"] is not None:
              _, col = _mix(nt, "RGBA", "MIX", m["lips"], col, lips_color, (-200, 500), "lips")
          _feed(nt, p.inputs["Base Color"], col)
          # ---- SSS amount block (mask stays black/white, Map Range sets the real values)
          if m["sss"] is not None:
              _, s = _maprange(nt, m["sss"], 0.0, 1.0, sss_scale[0], sss_scale[1], loc=(300, -150), label="sss scale")
              _link(nt, s, p.inputs["Subsurface Scale"])
          else:
              p.inputs["Subsurface Scale"].default_value = (sss_scale[0] + sss_scale[1]) * 0.5
          # ---- roughness block: pores in roughness, not in bump
          if coords == "UV":
              cn = _new(nt, "ShaderNodeUVMap", (-1400, -500))
              if uv_map:
                  cn.uv_map = uv_map
              vec = cn.outputs["UV"]
          else:
              vec = _new(nt, "ShaderNodeTexCoord", (-1400, -500)).outputs["Object"]
          v1 = _new(nt, "ShaderNodeTexVoronoi", (-1100, -400), "pores 1", feature="SMOOTH_F1")
          v2 = _new(nt, "ShaderNodeTexVoronoi", (-1100, -650), "pores 2", feature="SMOOTH_F1")
          v1.inputs["Scale"].default_value = pore_scale
          v2.inputs["Scale"].default_value = pore_scale * 2
          nsz = _new(nt, "ShaderNodeTexNoise", (-1100, -900), "pore size mix")
          nsz.inputs["Detail"].default_value = 0.0
          nsz.inputs["Scale"].default_value = pore_scale * 0.05
          ngen = _new(nt, "ShaderNodeTexNoise", (-1100, -1150), "general noise")
          ngen.inputs["Scale"].default_value = pore_scale * 0.5
          for n in (v1, v2, nsz, ngen):
              _link(nt, vec, n.inputs["Vector"])
          _, pores = _mix(nt, "FLOAT", fac=nsz.outputs["Fac"], a=v1.outputs["Distance"], b=v2.outputs["Distance"], loc=(-850, -500))
          _, pores = _mix(nt, "FLOAT", fac=noise_mix, a=pores, b=ngen.outputs["Fac"], loc=(-650, -500))
          # pore centres (low distance) glossier, cracks rougher
          _, rough = _maprange(nt, pores, 0.0, 1.0, base_roughness - pore_amount, base_roughness + pore_amount,
                               loc=(-450, -500), label="pore roughness")
          if m["rough"] is not None:
              _, zone = _maprange(nt, m["rough"], 0.0, 1.0, rough_zone[0], rough_zone[1], loc=(-450, -250), label="rough zones")
              _, rough = _mix(nt, "FLOAT", fac=m["rough"], a=rough, b=zone, loc=(-200, -400))
          if m["lips"] is not None:
              _, rough = _mix(nt, "FLOAT", fac=m["lips"], a=rough, b=lips_roughness, loc=(0, -400))
          _link(nt, rough, p.inputs["Roughness"])
          mat.diffuse_color = tuple(base_color) + (1.0,)          # Workbench / animator solid view
          return mat
      
      
      def stylized_eye(mat, iris_color=(0.10, 0.20, 0.33), sclera_color=(0.80, 0.74, 0.70),
                       sclera_edge=(0.72, 0.50, 0.45), iris_radius=0.45, pupil_radius=0.18,
                       radius=1.0, forward="-Y", streaks=True, iris_metallic=0.6):
          """Procedural stylized eye on an eyeball whose ORIGIN is the eyeball centre and whose
          front pole looks along `forward` (object axes). Kaspar: spherical gradient in object
          space (here centred on the front pole), Linear Light noise to break the circles,
          darker limbal rim, radial iris streaks, off-white sclera tinted to flesh at the edges,
          black pupil without specular. iris_radius/pupil_radius are fractions of `radius`.
          One object per eye (mirror modifier removed) or the gradient is shared and wrong."""
          mat = get_material(mat)
          nt = mat.node_tree
          for n in list(nt.nodes):
              nt.nodes.remove(n)
          out = _new(nt, "ShaderNodeOutputMaterial", (1500, 0))
          p = _new(nt, "ShaderNodeBsdfPrincipled", (1150, 0))
          _link(nt, p.outputs["BSDF"], out.inputs["Surface"])
          axis = {"-Y": (0, -1, 0), "Y": (0, 1, 0), "-X": (-1, 0, 0), "X": (1, 0, 0), "Z": (0, 0, 1), "-Z": (0, 0, -1)}[forward]
          tc = _new(nt, "ShaderNodeTexCoord", (-1500, 0))
          # distance from the front pole, normalised by radius: 0 at pole
          sub = _new(nt, "ShaderNodeVectorMath", (-1300, 0), operation="SUBTRACT")
          _link(nt, tc.outputs["Object"], sub.inputs[0])
          sub.inputs[1].default_value = tuple(a * radius for a in axis)
          ln = _new(nt, "ShaderNodeVectorMath", (-1100, 0), operation="LENGTH")
          _link(nt, sub.outputs["Vector"], ln.inputs[0])
          _, d = _math(nt, "DIVIDE", ln.outputs["Value"], radius, (-900, 0))
          # break the circle without moving it: Linear Light with low-contrast noise
          nz = _new(nt, "ShaderNodeTexNoise", (-1100, -250))
          nz.inputs["Scale"].default_value = 6.0 / radius
          _link(nt, tc.outputs["Object"], nz.inputs["Vector"])
          _, dn = _mix(nt, "FLOAT", fac=0.06, a=d, b=nz.outputs["Fac"], loc=(-700, 0))
          _, iris_m = _maprange(nt, dn, iris_radius, iris_radius - 0.03, interp="SMOOTHSTEP", loc=(-500, 150), label="iris mask")
          _, pupil_m = _maprange(nt, dn, pupil_radius, pupil_radius - 0.015, interp="SMOOTHSTEP", loc=(-500, -50), label="pupil mask")
          _, rim = _maprange(nt, dn, iris_radius * 0.72, iris_radius, 0.0, 1.0, interp="SMOOTHSTEP", loc=(-500, 350), label="limbal rim")
          iris_c = iris_color
          if streaks:
              # radial streaks: polar coordinates around the forward axis
              sep = _new(nt, "ShaderNodeSeparateXYZ", (-1100, -500))
              _link(nt, sub.outputs["Vector"], sep.inputs[0])
              a_idx = [i for i in range(3) if axis[i] == 0]
              _, ang = _math(nt, "ARCTAN2", sep.outputs[a_idx[0]], sep.outputs[a_idx[1]], (-900, -500))
              cmb = _new(nt, "ShaderNodeCombineXYZ", (-700, -500))
              _, angs = _math(nt, "MULTIPLY", ang, 12.0, (-800, -650))
              _link(nt, angs, cmb.inputs[0])
              _, ds = _math(nt, "MULTIPLY", d, 3.0, (-800, -800))
              _link(nt, ds, cmb.inputs[1])
              vo = _new(nt, "ShaderNodeTexNoise", (-500, -500))
              vo.inputs["Scale"].default_value = 2.0
              vo.inputs["Detail"].default_value = 4.0
              _link(nt, cmb.outputs[0], vo.inputs["Vector"])
              _, st = _maprange(nt, vo.outputs["Fac"], 0.35, 0.75, loc=(-300, -500))
              light = tuple(min(1.0, c * 2.2 + 0.05) for c in iris_color)
              _, iris_c = _mix(nt, "RGBA", "MIX", st, iris_color, light, (-100, -300), "iris streaks")
          rim_dark = tuple(c * 0.35 for c in iris_color)
          _, iris_c = _mix(nt, "RGBA", "MIX", rim, iris_c, rim_dark, (100, -100), "limbal rim")
          # sclera: never pure white, flesh toward the edges (far from the pole)
          _, edge_t = _maprange(nt, d, 0.8, 1.4, interp="SMOOTHSTEP", loc=(-300, 500))
          _, scl = _mix(nt, "RGBA", "MIX", edge_t, sclera_color, sclera_edge, (100, 500), "sclera")
          _, col = _mix(nt, "RGBA", "MIX", iris_m, scl, iris_c, (350, 300), "iris")
          _, col = _mix(nt, "RGBA", "MIX", pupil_m, col, (0.003, 0.003, 0.003), (550, 300), "pupil")
          _link(nt, col, p.inputs["Base Color"])
          _, met = _math(nt, "MULTIPLY", iris_m, iris_metallic, (350, -300))
          _, met = _mix(nt, "FLOAT", fac=pupil_m, a=met, b=0.0, loc=(550, -300))
          _link(nt, met, p.inputs["Metallic"])
          _, spec = _mix(nt, "FLOAT", fac=pupil_m, a=0.5, b=0.0, loc=(550, -500))        # no highlight in the pupil
          _link(nt, spec, p.inputs["Specular IOR Level"])
          p.inputs["Roughness"].default_value = 0.35
          mat.diffuse_color = tuple(sclera_color) + (1.0,)
          return mat
      
      
      def cornea_material(mat, ior=1.376, roughness=0.02):
          """Clear cornea shell: Transmission 1, low roughness, Material Output Thickness 0
          (SLAB). Without the zero thickness EEVEE refracts the shell as a solid glass ball and
          magnifies the iris across the eye; Cycles is right either way (verified). Keep
          material.use_raytrace_refraction on (off: the eye vanishes behind a probe reflection)
          and scene.eevee.use_raytracing on. Pair with obj.visible_shadow = False (Kaspar: the
          cornea casts no shadow; honoured by EEVEE and Cycles, verified). ior: human cornea
          [added]."""
          mat = get_material(mat)
          p = principled(mat)
          out = output_node(mat)
          th = out.inputs["Thickness"]
          if not th.is_linked:          # EEVEE treats a closed transmissive shell as a solid lens
              z = mat.node_tree.nodes.new("ShaderNodeValue")
              z.location = (out.location.x - 200, out.location.y - 300)
              z.label = "thin shell"
              z.outputs[0].default_value = 0.0
              mat.node_tree.links.new(z.outputs[0], th)
          mat.thickness_mode = "SLAB"
          p.inputs["Transmission Weight"].default_value = 1.0
          p.inputs["Roughness"].default_value = roughness
          p.inputs["IOR"].default_value = ior
          p.inputs["Base Color"].default_value = (1, 1, 1, 1)
          mat.use_raytrace_refraction = True
          mat.surface_render_method = "DITHERED"
          return mat
      
      
      # --------------------------------------------------------------------------------------
      # decals
      # --------------------------------------------------------------------------------------
      
      
      def add_decal(mat, obj, image, center, normal, up=(0, 0, 1), size=0.2, uv_name="decal", max_angle=70.0):
          """Kaspar's logo method, scripted: a dedicated UV map projected along -normal onto the
          faces that face it within `size`, every other loop parked outside 0..1; the image node
          uses Extension CLIP so parked loops read alpha 0. Mixes the decal over Base Color by
          its alpha. `image` is a path or bpy Image (sRGB). Returns the mix node."""
          mat = get_material(mat)
          img = image if isinstance(image, bpy.types.Image) else load_image(image, "base_color")
          img.alpha_mode = "CHANNEL_PACKED"
          me = obj.data
          uvl = me.uv_layers.get(uv_name) or me.uv_layers.new(name=uv_name)
          mw = obj.matrix_world
          n = Vector(normal).normalized()
          u = Vector(up).cross(n)
          if u.length < 1e-6:
              u = Vector((1, 0, 0)).cross(n)
          u.normalize()
          v = n.cross(u)
          c = Vector(center)
          cos_max = math.cos(math.radians(max_angle))
          nmat = mw.to_3x3().inverted_safe().transposed()
          uv = np.full((len(me.loops), 2), -2.0, dtype=np.float32)
          for poly in me.polygons:
              pn = (nmat @ poly.normal).normalized()
              if pn.dot(n) < cos_max:
                  continue
              for li in poly.loop_indices:
                  w = mw @ me.vertices[me.loops[li].vertex_index].co
                  d = w - c
                  uu, vv = d.dot(u) / size + 0.5, d.dot(v) / size + 0.5
                  if abs(d.dot(n)) > size:
                      continue
                  uv[li] = (uu, vv)
          uvl.uv.foreach_set("vector", uv.ravel())
          nt = mat.node_tree
          p = principled(mat)
          un = _new(nt, "ShaderNodeUVMap", (p.location.x - 900, p.location.y + 700), uv_name)
          un.uv_map = uv_name
          tx = _new(nt, "ShaderNodeTexImage", (p.location.x - 700, p.location.y + 700), "decal")
          tx.image, tx.extension = img, "CLIP"
          _link(nt, un.outputs["UV"], tx.inputs["Vector"])
          base = _source(nt, p.inputs["Base Color"], (p.location.x - 700, p.location.y + 400))
          mix, res = _mix(nt, "RGBA", "MIX", tx.outputs["Alpha"], base, tx.outputs["Color"],
                          (p.location.x - 300, p.location.y + 500), "decal over")
          _link(nt, res, p.inputs["Base Color"])
          return mix
      
      
      # --------------------------------------------------------------------------------------
      # vertex masks (colour attributes)
      # --------------------------------------------------------------------------------------
      
      
      def _mesh_arrays(obj):
          me = obj.data
          co = np.empty(len(me.vertices) * 3, dtype=np.float64)
          me.vertices.foreach_get("co", co)
          co = co.reshape(-1, 3)
          ev = np.empty(len(me.edges) * 2, dtype=np.int64)
          me.edges.foreach_get("vertices", ev)
          return co, ev.reshape(-1, 2)
      
      
      def _vertex_normals(obj):
          me = obj.data
          n = np.empty(len(me.vertices) * 3, dtype=np.float64)
          me.vertex_normals.foreach_get("vector", n)
          return n.reshape(-1, 3)
      
      
      def _neighbor_mean(values, ev, nv):
          acc = np.zeros((nv,) + values.shape[1:])
          cnt = np.zeros(nv)
          np.add.at(acc, ev[:, 0], values[ev[:, 1]])
          np.add.at(acc, ev[:, 1], values[ev[:, 0]])
          np.add.at(cnt, ev[:, 0], 1)
          np.add.at(cnt, ev[:, 1], 1)
          cnt = np.maximum(cnt, 1)
          return acc / cnt.reshape((-1,) + (1,) * (values.ndim - 1))
      
      
      def write_vertex_values(obj, name, values, domain="POINT"):
          """Write a per-vertex scalar (0..1) into a FLOAT_COLOR attribute (grey)."""
          me = obj.data
          attr = me.color_attributes.get(name)
          if attr is not None and (attr.domain != domain or attr.data_type != "FLOAT_COLOR"):
              me.color_attributes.remove(attr)
              attr = None
          if attr is None:
              attr = me.color_attributes.new(name=name, type="FLOAT_COLOR", domain=domain)
          v = np.clip(np.asarray(values, dtype=np.float32), 0, 1)
          if domain == "CORNER":
              lv = np.empty(len(me.loops), dtype=np.int64)
              me.loops.foreach_get("vertex_index", lv)
              v = v[lv]
          rgba = np.repeat(v[:, None], 4, axis=1)
          rgba[:, 3] = 1.0
          attr.data.foreach_set("color", rgba.ravel())
          return attr
      
      
      def read_vertex_values(obj, name):
          attr = obj.data.color_attributes[name]
          a = np.empty(len(attr.data) * 4, dtype=np.float32)
          attr.data.foreach_get("color", a)
          return a.reshape(-1, 4)[:, 0]
      
      
      def curvature_attribute(obj, name="col_curvature", smooth=2, contrast=4.0):
          """Vertex curvature as a colour attribute: 0.5 flat, > 0.5 convex, < 0.5 concave
          (Laplacian along the normal, scaled by mean edge length). Engine independent: the
          EEVEE/game substitute for Bevel-node edge masks and Cycles-only Pointiness. Depends on
          vertex density like Pointiness: on a low poly, bake from the high instead."""
          co, ev = _mesh_arrays(obj)
          nrm = _vertex_normals(obj)
          nv = len(co)
          lap = _neighbor_mean(co, ev, nv) - co
          el = np.linalg.norm(co[ev[:, 0]] - co[ev[:, 1]], axis=1)
          le = np.zeros(nv)
          ce = np.zeros(nv)
          np.add.at(le, ev[:, 0], el)
          np.add.at(le, ev[:, 1], el)
          np.add.at(ce, ev[:, 0], 1)
          np.add.at(ce, ev[:, 1], 1)
          le = le / np.maximum(ce, 1)
          k = -(lap * nrm).sum(1) / np.maximum(le, 1e-9)      # convex -> positive
          for _ in range(smooth):
              k = 0.5 * k + 0.5 * _neighbor_mean(k, ev, nv)
          val = 0.5 + 0.5 * np.tanh(k * contrast)
          write_vertex_values(obj, name, val)
          return val
      
      
      def mask_from_points(obj, name, points, falloff="SMOOTH", combine="MAX", base=0.0, smooth=0):
          """Colour-attribute mask from world-space landmarks: points = [(co, radius, value)].
          The substitute for painting a black/white mask by hand (Kaspar paints col_sss,
          col_brighter...). combine MAX or ADD. Returns the per-vertex values."""
          co, ev = _mesh_arrays(obj)
          mw = np.array(obj.matrix_world)
          wco = co @ mw[:3, :3].T + mw[:3, 3]
          val = np.full(len(co), base, dtype=np.float64)
          for c, r, v in points:
              d = np.linalg.norm(wco - np.asarray(c), axis=1) / max(r, 1e-9)
              t = np.clip(1 - d, 0, 1)
              if falloff == "SMOOTH":
                  t = t * t * (3 - 2 * t)
              contrib = t * v
              val = np.maximum(val, contrib) if combine == "MAX" else val + contrib
          for _ in range(smooth):
              val = 0.5 * val + 0.5 * _neighbor_mean(val, ev, len(co))
          write_vertex_values(obj, name, np.clip(val, 0, 1))
          return val
      
      
      def smooth_attribute(obj, name, iterations=4, factor=0.5):
          """Laplacian blur of a POINT colour attribute (the headless 'blur brush')."""
          co, ev = _mesh_arrays(obj)
          v = read_vertex_values(obj, name).astype(np.float64)
          for _ in range(iterations):
              v = (1 - factor) * v + factor * _neighbor_mean(v, ev, len(co))
          write_vertex_values(obj, name, v)
          return v
      
      
      # --------------------------------------------------------------------------------------
      # UV-space painting (headless substitute for texture-paint strokes)
      # --------------------------------------------------------------------------------------
      
      
      class TexelMap:
          """World position, world normal and validity of every texel of a res x res image,
          rasterised from `uv_layer` (default: active render UV). Array layout matches
          image.pixels: row 0 is the BOTTOM of the image (v = 0).
          Strokes painted by world distance through this map are seam-proof: a dab that
          crosses a UV seam lands on both islands, unlike a stroke drawn in UV space."""
      
          def __init__(self, obj, res, uv_layer=None, use_modifiers=False):
              self.res = res
              dg = bpy.context.evaluated_depsgraph_get()
              src = obj.evaluated_get(dg) if use_modifiers else obj
              me = src.to_mesh() if use_modifiers else obj.data
              try:
                  me.calc_loop_triangles()
                  nt = len(me.loop_triangles)
                  tl = np.empty(nt * 3, dtype=np.int64)
                  me.loop_triangles.foreach_get("loops", tl)
                  tl = tl.reshape(-1, 3)
                  tv = np.empty(nt * 3, dtype=np.int64)
                  me.loop_triangles.foreach_get("vertices", tv)
                  tv = tv.reshape(-1, 3)
                  if uv_layer is None:
                      uvl = next((u for u in me.uv_layers if u.active_render), me.uv_layers.active)
                  else:
                      uvl = me.uv_layers[uv_layer]
                  uv = np.empty(len(me.loops) * 2, dtype=np.float64)
                  uvl.uv.foreach_get("vector", uv)
                  uv = uv.reshape(-1, 2)
                  co = np.empty(len(me.vertices) * 3, dtype=np.float64)
                  me.vertices.foreach_get("co", co)
                  co = co.reshape(-1, 3)
                  cn = np.empty(len(me.loops) * 3, dtype=np.float64)
                  me.corner_normals.foreach_get("vector", cn)
                  cn = cn.reshape(-1, 3)
              finally:
                  if use_modifiers:
                      src.to_mesh_clear()
              mw = np.array(obj.matrix_world)
              co = co @ mw[:3, :3].T + mw[:3, 3]
              nm = np.linalg.inv(mw[:3, :3]).T
              cn = cn @ nm.T
              cn /= np.maximum(np.linalg.norm(cn, axis=1, keepdims=True), 1e-12)
              self.pos = np.zeros((res, res, 3), dtype=np.float32)
              self.nrm = np.zeros((res, res, 3), dtype=np.float32)
              self.valid = np.zeros((res, res), dtype=bool)
              self.tri = np.full((res, res), -1, dtype=np.int32)
              P = uv[tl] * res - 0.5                         # pixel-centre coordinates
              for t in range(len(tl)):
                  p0, p1, p2 = P[t]
                  x0 = int(max(math.floor(min(p0[0], p1[0], p2[0])), 0))
                  x1 = int(min(math.ceil(max(p0[0], p1[0], p2[0])), res - 1))
                  y0 = int(max(math.floor(min(p0[1], p1[1], p2[1])), 0))
                  y1 = int(min(math.ceil(max(p0[1], p1[1], p2[1])), res - 1))
                  if x1 < x0 or y1 < y0:
                      continue
                  den = (p1[1] - p2[1]) * (p0[0] - p2[0]) + (p2[0] - p1[0]) * (p0[1] - p2[1])
                  if abs(den) < 1e-12:
                      continue
                  xs, ys = np.meshgrid(np.arange(x0, x1 + 1), np.arange(y0, y1 + 1))
                  w0 = ((p1[1] - p2[1]) * (xs - p2[0]) + (p2[0] - p1[0]) * (ys - p2[1])) / den
                  w1 = ((p2[1] - p0[1]) * (xs - p2[0]) + (p0[0] - p2[0]) * (ys - p2[1])) / den
                  w2 = 1 - w0 - w1
                  ins = (w0 >= -1e-4) & (w1 >= -1e-4) & (w2 >= -1e-4)
                  if not ins.any():
                      continue
                  yy, xx = ys[ins], xs[ins]
                  W = np.stack([w0[ins], w1[ins], w2[ins]], 1)
                  self.pos[yy, xx] = W @ co[tv[t]]
                  self.nrm[yy, xx] = W @ cn[tl[t]]
                  self.valid[yy, xx] = True
                  self.tri[yy, xx] = t
              n = np.linalg.norm(self.nrm, axis=2, keepdims=True)
              self.nrm /= np.maximum(n, 1e-12)
              self.coverage = float(self.valid.mean())
      
      
      def new_image(name, res, color=(0, 0, 0, 1), colorspace="Non-Color", float_buffer=False):
          """Get-or-create a square image filled with `color` (values as stored, see module doc)."""
          img = bpy.data.images.get(name)
          if img is None or tuple(img.size) != (res, res) or img.is_float != float_buffer:
              if img is not None:
                  bpy.data.images.remove(img)
              img = bpy.data.images.new(name, res, res, alpha=True, float_buffer=float_buffer)
          img.colorspace_settings.name = colorspace
          a = np.empty((res, res, 4), dtype=np.float32)
          a[:] = color
          img.pixels.foreach_set(a.ravel())
          img.update()
          return img
      
      
      def pixels(img):
          """image.pixels as an (h, w, 4) float32 array (row 0 = bottom)."""
          w, h = img.size
          a = np.empty(w * h * 4, dtype=np.float32)
          img.pixels.foreach_get(a)
          return a.reshape(h, w, 4)
      
      
      def write_pixels(img, arr):
          img.pixels.foreach_set(np.ascontiguousarray(arr, dtype=np.float32).ravel())
          img.update()
          return img
      
      
      def save_image(img, path, file_format=None):
          """Save to disk (headless scripts have no autosave; Kaspar lost painted maps to this).
          PNG for byte maps, OPEN_EXR for float data."""
          fmt = file_format or ("OPEN_EXR" if path.lower().endswith(".exr") else "PNG")
          img.filepath_raw = path
          img.file_format = fmt
          img.save()
          return path
      
      
      def _dab_weights(dist, radius, hardness):
          t = np.clip(1 - dist / radius, 0, 1)
          if hardness >= 1:
              return (dist <= radius).astype(np.float32)
          # smoothstep falloff sharpened by hardness
          s = np.clip(t / max(1 - hardness, 1e-3), 0, 1)
          return s * s * (3 - 2 * s)
      
      
      def paint_sphere(arr, tm, center, radius, color=(1, 1, 1, 1), strength=1.0, hardness=0.3, normal_cut=None):
          """One world-space dab: texels within `radius` of `center` blend toward `color`.
          normal_cut: dot(texel normal, normal_cut) > 0 required (skip the far side of thin parts)."""
          c = np.asarray(center, dtype=np.float32)
          lo, hi = c - radius, c + radius
          box = tm.valid & np.all((tm.pos >= lo) & (tm.pos <= hi), axis=2)
          if not box.any():
              return 0
          yy, xx = np.nonzero(box)
          d = np.linalg.norm(tm.pos[yy, xx] - c, axis=1)
          w = _dab_weights(d, radius, hardness) * strength
          if normal_cut is not None:
              w = w * (tm.nrm[yy, xx] @ np.asarray(normal_cut, dtype=np.float32) > 0)
          col = np.asarray(color, dtype=np.float32)
          arr[yy, xx] = arr[yy, xx] * (1 - w[:, None]) + col * w[:, None]
          return int((w > 0).sum())
      
      
      def paint_stroke(arr, tm, points, radius, color=(1, 1, 1, 1), strength=1.0, hardness=0.3, spacing=0.3):
          """Dabs along a world-space polyline, spaced `spacing * radius` apart (a brush stroke)."""
          pts = [np.asarray(p, dtype=np.float64) for p in points]
          dabs = [pts[0]]
          for a, b in zip(pts, pts[1:]):
              seg = np.linalg.norm(b - a)
              k = max(1, int(seg / (spacing * radius)))
              dabs += [a + (b - a) * (i / k) for i in range(1, k + 1)]
          touched = 0
          for d in dabs:
              touched += paint_sphere(arr, tm, d, radius, color, strength, hardness)
          return touched
      
      
      def project(tm, fn, fill=0.0):
          """Evaluate fn(P, N) -> (h, w) values on every valid texel (P, N world position and
          normal arrays of shape (h, w, 3)). The general 'project a 3D mask into the texture'."""
          v = np.asarray(fn(tm.pos, tm.nrm), dtype=np.float32)
          return np.where(tm.valid, v, fill).astype(np.float32)
      
      
      def vertex_values_to_texels(obj, values, tm):
          """Rasterise a per-vertex scalar (e.g. curvature_attribute output) into texel space
          by barycentric interpolation, reusing the TexelMap triangle ids."""
          me = obj.data
          me.calc_loop_triangles()
          tv = np.empty(len(me.loop_triangles) * 3, dtype=np.int64)
          me.loop_triangles.foreach_get("vertices", tv)
          tv = tv.reshape(-1, 3)
          vals = np.asarray(values, dtype=np.float64)
          out = np.zeros((tm.res, tm.res), dtype=np.float32)
          ok = tm.valid
          t = tm.tri[ok]
          # recover barycentrics from positions (least squares on the triangle's vertices)
          mw = np.array(obj.matrix_world)
          co = np.empty(len(me.vertices) * 3)
          me.vertices.foreach_get("co", co)
          co = co.reshape(-1, 3) @ mw[:3, :3].T + mw[:3, 3]
          A = co[tv[t]]                         # (n, 3, 3) triangle corners
          p = tm.pos[ok].astype(np.float64)
          v0, v1 = A[:, 1] - A[:, 0], A[:, 2] - A[:, 0]
          v2 = p - A[:, 0]
          d00, d01, d11 = (v0 * v0).sum(1), (v0 * v1).sum(1), (v1 * v1).sum(1)
          d20, d21 = (v2 * v0).sum(1), (v2 * v1).sum(1)
          den = np.maximum(d00 * d11 
  • SKILL.md 15.3 KB
    ---
    name: scenario-blender-texturing-shading
    description: "Use when texturing, shading or look-developing in Blender: building a PBR material from a texture set, displacement, procedural materials, edge wear, dirt, grime, color variation, decals, box or triplanar mapping on meshes without UVs, stylized character skin (subsurface, pores), eyes and cornea, hand-painted stylized textures, texture painting or painting masks headless. Also when a material looks plastic, CG, waxy or blown out, color spaces are wrong, or EEVEE and Cycles disagree."
    license: MIT
    ---
    
    # Texturing and shading (Blender 5.2)
    
    Expert shading is layered masks feeding physically plausible values: a clean base (scanned set or flat colors), then contrasty black/white masks that place color, roughness, height and subsurface, all editable in nodes. Values are judged under honest light in both engines, and baking comes last, for portability. If a sibling skill named here is missing from your available skills, ask the user to install it (`npx skills add scenario-labs/skills --skill <name>`); unattended, proceed from tool schemas and flag the gap.
    
    **REQUIRED BACKGROUND:** scenario-blender-expert (execution channel, review loop, 5.2 API changes). Baking maps: scenario-blender-uv-baking. Lighting and final renders: scenario-blender-lighting-rendering.
    
    Toolkit: [`scripts/bx_materials.py`](scripts/bx_materials.py) (builders, headless painting, checks; exercised by `tests/code/blender-texturing-shading/`). `sys.path.append("<this skill>/scripts"); import bx_materials as M`.
    
    ## Stance (the expert delta)
    
    - **Data maps are Non-Color, "not what looks better, what's correct"** (Price). Only base color and emission are sRGB.
    - **Normal map OR displacement, never both** (Price, confirmed by Cycles lead Brecht): they encode the same relief. Displacement when heights will be combined or truly displaced; normal map for export and 8-bit safety.
    - **Masks, not painted color.** Kaspar and Price build every effect as a grayscale mask into a Mix or HSV Factor; color lives in the A/B inputs. Masks need contrast (Color Ramp stops pulled in) or they barely show. The same masks drive roughness, height and SSS.
    - **Realism: scanned set plus masks; stylized: procedural** (Price vs Kaspar and Ryan King). Kaspar strips photos to their pattern (blur plus grain extract) and adds grunge himself, and bakes at the end for portability (export, EEVEE load time), not for Cycles speed.
    - **Micro detail in roughness, not bump**, for skin and hair (Kaspar); edit roughness through HSV Value, never by unplugging the map (Price). Specular stays 0.5. Bump Distance is the real height of the map's 0..1: lower Distance, not Strength (Kaspar).
    - **Judge honestly:** Standard view, neutral mid-gray background, Base Color black to read roughness, a hard backlight and a light from below (Kaspar); grazing light for relief (Price); unlit for hand-painted albedo (Abbitt). EEVEE for iteration, Cycles as the truth, and they must match (Kaspar).
    - **SSS follows anatomy** (Kaspar): high on ears, nose, lids, lips, fingertips; low over bone. Black/white mask, Map Range sets values.
    - **Bevel, AO, Pointiness and Random Per Island are Cycles-only, and Bevel/AO are stochastic** [added, measured]: EEVEE shows no Bevel edges; thresholded live masks give 17 to 18% fractional metallic at any sample count. Bake before thresholding, or use vertex curvature.
    
    ## Establish first
    
    | Input                                    | Changes                                                              | Default when silent                      |
    | ---------------------------------------- | -------------------------------------------------------------------- | ---------------------------------------- |
    | Target: Cycles still, EEVEE, game engine | live Cycles-only nodes vs baked; AO in albedo (render) vs ORM (game) | Cycles finals, EEVEE side by side        |
    | Style: realistic or stylized             | scanned set + grime vs procedural or hand-painted light              | stylized restraint (Kaspar)              |
    | UVs and deformation                      | UV images vs box projection vs Object procedurals                    | rigid, no UVs: box; deforming: UV only   |
    | Scale                                    | tile size, bump/displacement meters                                  | real meters, scale applied (all experts) |
    | Texel budget, export                     | 2K to 4K hero paint, 4K to 8K large tiling surfaces                  | 2K hero prop, glTF ORM, OpenGL normals   |
    
    ## Workflow
    
    1. **Base per part.** Prefixed materials, placeholders removed, `diffuse_color` set; values picked on the Diffuse Color pass (matcap made Kaspar's skin too pale). Apply scale. GATE: `M.value_report(objs)` clean.
    2. **Base material.** Set: `M.pbr_from_textures(mat, M.find_texture_set(dir), uv_map=...)`: color spaces by role, AO multiplied at factor 1 (Price: restores the cavity shadow flash capture removes; `ao_mode="gltf"` for games), bump only without a normal map, `height_as="displacement"` drops the normal map and sets Cubic interpolation, Displacement and Bump, Scale 0.2 for Poliigon (fixed 20 cm range); add Simple subdivision and shade smooth. UV tile = the set's physical size. Rigid, no UVs: `projection="BOX"`. Procedural: Object coordinates (UV on deforming meshes), Noise Detail at most 5 (cost doubles per level, Price). GATE: `M.audit_material(mat, obj)` has no errors.
    3. **Masks.** Kaspar's rule is mask frequency against vertex density: vertex colors for broad soft masks (and Price's environments: tiling UVs repeat image strokes, layouts change), images for fine or exported detail, UV bands for hems. Headless: `M.mask_from_points`, `M.write_vertex_values`, `M.smooth_attribute`, `M.curvature_attribute`, `M.TexelMap` + `M.project`. Price's Color Burn: soft mask (A) burned by a contrast-ramped height map (B) breaks the mask along bricks or into grout (procedures P18). GATE: render each mask as emission.
    4. **Layers.** `M.add_wear(mat, edge_color=..., edge_roughness=..., edge_metallic=1)` adds variation, cavity/grunge dirt and chipped edges. Blend modes: Multiply darkens (grime, mold), Screen lightens (efflorescence, highlights; Add exceeds 1), Subtract on the height chain for spalling (Price). Chain order sets age (a decal before grime reads old). Decals: Alpha into Factor, Extension Clip (`M.add_decal`). GATE: `M.preview(objs, out, rigs=("studio", "roughness"))`: effects follow form and water paths, nothing uniform, repeats broken.
    5. **Character specifics.** `M.stylized_skin(mat, obj=body)` (masks `col_brighter/col_darker/col_lips/col_sss/col_rough`); `M.stylized_eye` per eye object (origin at the eyeball center) plus `M.cornea_material` and `cornea.visible_shadow = False`; hair shine: Anisotropic with a Tangent on a strand-aligned UV map (Cycles only). GATE: `rigs=("backlit",)`: red glow on ears, nostrils, lips, fingers; front not waxy; sclera off-white.
    6. **Stylized hand-painted light** (Abbitt): lighter top, darker bottom, Multiply in crevices, Screen on extremities. Headless: `M.painted_light(mat, obj)` from a curvature attribute, bake Emit, judge with `rigs=("unlit",)`.
    7. **Painting, where masks cannot place it.** GUI: brushes are assets (`Paint Hard/Soft`, `Airbrush`, `Blur`, `Smear`, `Fill`, `Mask`); a factory 5.2 session paints BLACK until the brush and unified color are set; strokes project from the screen, so face the surface (Abbitt); Erase needs an alpha channel; bleed 8 to 12 px (Kaspar, Abbitt). Use `M.gui_texture_stroke()`: it forces a redraw (projection painting occludes with the depth buffer of the previous draw: a stroke issued in the setup call painted 162 texels in one run instead of about 14,000, full coverage in another) and resamples dabs. Headless: `M.TexelMap` + `M.paint_stroke` paints by world distance, seams included; `M.dilate`, then `M.save_image` (images are never saved with the .blend by a script). GATE: renders from views crossing seams.
    8. **Lookdev check and bake.** `M.preview` (EEVEE and Cycles, studio, backlit, roughness), `M.value_report`, labeled frames and node groups (Kaspar, Price). Bake procedural channels via Emission, 30+ samples, validate by rebuilding from the maps (scenario-blender-uv-baking).
    
    ## Numbers
    
    | Value                                 | Number                                                        | Source                                                             |
    | ------------------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------ |
    | Dielectric albedo / metal reflectance | sRGB about 30 to 240 / 180 to 255; metallic 0 or 1            | ranges [added]; binary metallic: Ryan King, Price                  |
    | Skin roughness                        | 0.68 to 0.72, rough zones 0.72 to 1, lips and mouth about 0.4 | Kaspar                                                             |
    | Pores                                 | Voronoi Smooth F1 scales 100 and 200, noise mix 0.2 to 0.25   | Kaspar, on his UVs                                                 |
    | Skin SSS scale (5.2)                  | 0.005 to 0.04 m                                               | measured on a 0.33 m head; Kaspar's v1 0.02 to 0.15 is waxy in 5.2 |
    | Bump                                  | Distance about 0.001, Strength 0.1 to 0.2 for fabric          | Kaspar                                                             |
    | Displacement                          | Scale 0.2 (Poliigon), Midlevel 0.5, Cubic, 16-bit source      | Price                                                              |
    | Brick wall roughness                  | about 0.7                                                     | Price                                                              |
    | Color tweak                           | Hue within 0.5 plus or minus 0.04                             | Price                                                              |
    | Noise Detail                          | 5 or less                                                     | Price                                                              |
    | Edge-wear Bevel radius                | 0.04 on a 1 m crate                                           | measured                                                           |
    | Paint bleed / island margin           | 8 to 12 px / 0.01 to 0.05                                     | Kaspar, Abbitt                                                     |
    | Painted height                        | 2K, 32-bit float, base 0.5, Non-Color                         | Kaspar; 0.5 gray base also Ryan King                               |
    | Bake samples                          | 30 minimum                                                    | Kaspar                                                             |
    
    ## Quality gates
    
    Measurable:
    
    - `M.audit_material(mat, obj)`: color spaces by role, no image straight into Normal, explicit UV maps, Bump Distance at most 0.05, no Add Shader or raw summed normals, Emission Strength set, no gray constant metallic, normal map plus displacement flagged, non-Cubic displacement image, Noise Detail above 6, unsaved images.
    - `M.value_report(objs)`: dielectric albedo within 30 to 243 sRGB (under 2% outside), metal above 180, fractional metallic under 5% (live Bevel/AO wear exceeds it until baked), roughness std above 0.01.
    - Scale applied; color attributes named `col_*` and matching every Attribute node exactly (Kaspar, Price).
    
    Visual: `M.preview` EEVEE vs Cycles nearly identical; roughness row shows breakup; backlit glow only where flesh is thin; grazing light shows relief without terracing; repeats broken at distance; per-layer mute renders each add something (Price); scale against a human reference.
    
    ## Common mistakes
    
    | Mistake                                                      | Looks like                          | Fix                                 |
    | ------------------------------------------------------------ | ----------------------------------- | ----------------------------------- |
    | Data map left sRGB                                           | bent bump, wrong gloss              | Non-Color                           |
    | Normal map and displacement together (Node Wrangler default) | doubled or broken relief            | keep one                            |
    | Displacement with no geometry or Catmull-Clark               | nothing, or rounded corners         | Simple subdivision, shade smooth    |
    | Pre-4.5 Bump Distance 1.0                                    | stepped, plastic                    | real height, about 0.001 to 0.01    |
    | Emission Color set, Strength 0                               | no glow                             | Strength above 0                    |
    | Kaspar's v1 SSS values in 5.2                                | waxy white ears                     | 0.005 to 0.04, check backlit        |
    | Bevel wear or Pointiness in EEVEE or games                   | wear missing                        | bake, or `curvature_attribute`      |
    | Threshold on live Bevel/AO for metallic                      | grainy gray borders                 | bake, then threshold                |
    | Cornea shell in EEVEE, default thickness                     | iris magnified                      | Thickness 0 (`cornea_material`)     |
    | Mask straight from Noise                                     | effect barely visible               | ramp or Map Range for contrast      |
    | Stroke right after setup                                     | partial or no paint, varies per run | redraw first (`gui_texture_stroke`) |
    | Paint color never set / new color attribute                  | black strokes / white mask          | set colors; write zeros first       |
    | Box projection or Generated coords on a rig                  | texture swims                       | UV coordinates                      |
    
    ## Blender 5.2 notes
    
    - Principled v2: Subsurface Weight/Scale (no Subsurface Color), Specular IOR Level, Transmission Weight, Coat, Emission Strength default 0. Sockets by name; Mix node by index (RGBA: A=6, B=7, Result=2).
    - Color spaces `sRGB`, `Non-Color`, `Linear Rec.709`. Displacement node Scale default 0.01; `material.displacement_method` (not `material.cycles`); EEVEE displaces since 4.2 (Displacement and Bump).
    - `use_nodes` deprecated; `surface_render_method` replaces `blend_method`; refraction needs `use_raytrace_refraction` plus scene ray tracing.
    - EEVEE ignores anisotropy, Bevel, Pointiness, Random Per Island (verified). Random Walk SSS rescaled in 5.2. Musgrave is inside Noise.
    - Brushes are assets; custom brushes need "Save Changes to Asset" (Abbitt); `image_brush_type`; Blur is `SOFTEN`; sample color `Shift+X`.
    - `paint.vertex_color_dirt` works headless in Object mode; byte images store encoded values, float `Linear Rec.709` images convert to sRGB when saved as PNG.
    
    ## References
    
    - [`references/procedures.md`](references/procedures.md): tested code for every stage (texture sets, displacement rule, box projection, wear, curvature, decals, skin, eyes, anisotropy, GUI and headless painting, high-pass, checks, camera-ray world, clay switch, painted light, Color Burn masks, physical UV scale).
    - [`references/expert-notes.md`](references/expert-notes.md): reasoning per expert with timestamps and the disagreements with their deciding conditions.
    - [`references/critique.md`](references/critique.md): rubric for judging your own renders and node trees.
    - [`references/sources.md`](references/sources.md): every source video, credential, best timestamps.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related