Track B: Opus 5.5 with a GPT-6.1-Terra reviewer

agent
Opus 5.5 worker with GPT-6.1-Terra reviewer
model
claude-opus-5-5
reviewer
gpt-6.1-terra
harness
Claude Code
started
2026-10-30 04:43 UTC
took
64 min · 6 rounds · 132 turns
verdict
1st, tied. Judged blind as Y; tied first with track A (owner torn). Correct smug looks; eyelash issues.

user

+0:00
<teammate-message teammate_id="team-lead" summary="Benchmark track B: Opus plus Terra reviewer"> You are track B of a three-track benchmark on improving a game's anime character expressions. Read these two files and follow them exactly; together they are your whole brief: - ~/Projects/lowmarch-bench/brief/BRIEF.md - ~/Projects/lowmarch-bench/brief/track-b.md The reference materials are in ~/Projects/lowmarch-bench/brief/materials/ (look at the images with your Read tool).

Ground rules for this benchmark:

  • Work alone and don't ask questions; make your own decisions. Don't open the other tracks' worktrees or work folders.
  • Every shell command must start with source ~/Projects/lowmarch-bench/kit/env-b.sh && (the shell does not keep variables between commands).
  • Run builds, shoots and the reviewer in the foreground (not background tasks). The Bash tool stops a command at 10 minutes (pass timeout 600000), and the machine is shared with two other tracks, so things run slower: build expressions in groups of about five names per command if a full build would pass 9 minutes. The reviewer script (terra_review.sh) can take several minutes; give it timeout 600000, and if it times out, run it again once.
  • Respect the budget: at most 6 rounds and 90 minutes from your start; write your start time in $WORK/NOTES.md first. Reviewer time counts toward the 90 minutes.
  • When done, make sure $WORK/FINAL_woman.png, $WORK/FINAL_youth.png and $WORK/NOTES.md exist and your code is committed on branch bench-b in your worktree, then reply with the final summary the brief asks for (under 200 words).
</teammate-message>

agent

+0:06
  • Bashsource ~/Projects/lowmarch-bench/kit/env-b.sh && date && cat ~/Projects/lowmarch-bench/brief/BRIEF.md ~/Projects/lowmarch-bench/brief/track-b.md && ls ~/Projects/lowmarch-bench/brief/materials/ && env | grep -iE 'WORK|WT|LOWMARCH|BENCH'

    command

    source ~/Projects/lowmarch-bench/kit/env-b.sh && date && cat ~/Projects/lowmarch-bench/brief/BRIEF.md ~/Projects/lowmarch-bench/brief/track-b.md && ls ~/Projects/lowmarch-bench/brief/materials/ && env | grep -iE 'WORK|WT|LOWMARCH|BENCH'

    result, 111 lines

    Fri Oct 30 12:43:14 PM PST 2026
    # Benchmark brief: improve our characters' expressions
    
    You are one of three tracks given this same task from the same starting point. Work alone; do not look at the other
    tracks' folders (`~/Projects/lowmarch-bench/{a,b,c}` and `work/{a,b,c}` other than your own).
    
    ## The project
    
    Lowmarch is a code-built anime character pipeline for a tactics game. Characters are VRoid models reshaped in code,
    exported as GLBs and drawn in the browser by our own "painted" renderer (a three.js page). Everything is drawn with an
    orthographic camera. The owner reviews characters in that page, the "armory".
    
    ## The task
    
    Make our two test bodies' facial expressions read better, in the armory's painted renderer:
    - the slender woman (`arm_f_slender`);
    - the youth (`arm_m_youth`).
    
    The expressions are baked from VRoid's 57 expression shapes at build time.
    
    **The reference** is two commercial anime rigs, Yin and Yang. They're for study only, and nothing of theirs may be
    copied into the project.
    - In `brief/materials/`:
      - `expressions-vs-yin-yang.png`: their expressions beside ours at the starting point, 9 expressions and 5 lip-sync
        mouths at 0 and 30 to 35 degrees;
      - their individual frontal renders (`yin-expressions/`, `yang-expressions/`);
      - frames from their product page;
      - `yin-yang-rig-review.md`: notes on how their rig and shading work;
      - `vroid-shapes.txt`: the VRoid shape list.
    - Study what makes theirs read, then improve ours with our own code and recipes.
    
    **The 14 expressions** are in `$WORK/expr_list.txt`: neutral, joy, smile, angry, sad, surprised, shout, wink, smug,
    and the lip shapes A, I, U, E, O.
    - You may change the recipes, the build code and the renderer.
    - Keep the names.
    - Neutral is every character's default face in the armory, so it must still look right.
    
    **Decide one open question and justify it:** should eyebrows be drawn on top of the hair (bangs) when the hair covers
    them? Some anime styles do this and many don't, and the reference rigs keep their bangs above the brows instead.
    Decide for our characters, implement your decision, and explain it.
    
    ## Your setup
    
    - **Before every shell command,** run `source ~/Projects/lowmarch-bench/kit/env-<track>.sh` (or chain it with `&&`).
      It sets:
      - `LOWMARCH_REPO`: your git worktree. Edit code only here; it's on branch `bench-<track>`.
      - `WORK`: your builds, shots, sheets and notes.
      - `KIT`: the shared tools. Don't edit them.
    - **Where the code is, in your worktree:**
      - `review/vroid-02/index.html`: the painted renderer. The shaders are `PAINT_VS`, the material fragment shader and
        `PAINT_COMP` (the composite pass with lines). Roles: face=1, body=2, hair=3, line=4, iris=5, white=6, highlight=7,
        brow=8, mouth=9.
      - `tools/vroid_portrait.py`: the `Portrait` class builds a face. It applies the expression shapes, then
        `reshape`, `face_normals`, `shadow_cards` and more.
      - `tools/vrm.py`: the VRM loader. Shapes are applied in `VRM.primitives`.
      - `tools/export_glb.py`: the GLB export and the page's material data (`scene_info`).
      - `docs/portrait-guide.md`: our accumulated lessons.
    - **A round:**
      1. Change code or recipes.
      2. If you edited `tools/vroid_portrait.py` (or `cage.py`, `fit.py`, `proportions.py`, `makehuman.py`, `anthro.py`,
         `heads.py`, `collide.py`), run `cd $LOWMARCH_REPO && python3 -I tools/cage.py arm_f_slender arm_m_youth`
         (about 2 minutes). Those files key the body cache, and the bodies come out identical.
      3. Build: `$KIT/build_expr.sh $WORK/expr_list.txt [names...]` (about 25 seconds per expression). Page-only changes
         need no rebuild.
      4. Shoot: `$KIT/shoot_expr.sh $LOWMARCH_REPO/review/vroid-02/index.html r<N>` (about 4 minutes for all 14). Shots
         land in `$WORK/shot_r<N>/<expr>/painted_<body>.png`: a frontal tile, then a 30 degree tile.
      5. Sheets: `$KIT/sheets.sh r<N> "Round N: what changed"` writes `$WORK/sheet_r<N>_woman.png` and
         `sheet_r<N>_youth.png` (before vs this round, frontal).
      6. Look at the sheets, and at the 30 degree tiles too: an expression must hold off-front.
    - **Rules for running things:**
      - Run long commands in the foreground with generous timeouts, not as background jobs.
      - Work only in your worktree and `$WORK`.
      - Don't touch `~/Projects/lowmarch`, the other tracks, the shared kit or anything published.
      - Don't install anything. Headless only: no GUI windows.
      - You may commit to your branch.
    
    ## Budget
    
    At most 6 rounds and 90 minutes of wall-clock time, whichever comes first. Note your start time in `$WORK/NOTES.md`.
    
    ## Deliverables
    
    - `$WORK/FINAL_woman.png` and `$WORK/FINAL_youth.png`: copies of your best round's sheets.
    - `$WORK/NOTES.md`:
      - each round: what you changed, why, what the sheets showed, and how long it took;
      - your eyebrow decision and why;
      - what still reads weakly.
    - Your code committed on your branch.
    - A final message under 200 words: what you changed, and the rounds and time you used.
    # Track B
    
    Your track letter is `b`: `source ~/Projects/lowmarch-bench/kit/env-b.sh`.
    
    You have a visual reviewer, GPT-6.1-Terra, a model strong at judging images. After each round's sheets:
    - run `$KIT/terra_review.sh r<N> $WORK/sheet_r<N>_woman.png $WORK/sheet_r<N>_youth.png` (in the foreground, timeout
      20 minutes);
    - read its critique, and look at the sheets yourself;
    - act on what you agree with, and say what you rejected and why.
    
    Keep each critique and your response in NOTES.md. Do the round before the reviewer, never instead of it.
    expressions-vs-yin-yang.png
    vroid-shapes.txt
    yang-expressions
    yin-expressions
    yin-yang-product-gif-frames.png
    yin-yang-rig-review.md
    PWD=~/Projects/lowmarch-bench/work/a
    KIT=~/Projects/lowmarch-bench/kit
    BENCH=~/Projects/lowmarch-bench
    WORK=~/Projects/lowmarch-bench/work/b
    LOWMARCH_REPO=~/Projects/lowmarch-bench/b
  • Bashsource ~/Projects/lowmarch-bench/kit/env-b.sh && ls -la $WORK $LOWMARCH_REPO && cat $WORK/expr_list.txt; cd $LOWMARCH_REPO && git status | head && git log --oneline | head -5; cat ~/Projects/lowmarch-bench/brief/materials/yin-yang-rig-review.md

    command

    source ~/Projects/lowmarch-bench/kit/env-b.sh && ls -la $WORK $LOWMARCH_REPO && cat $WORK/expr_list.txt; cd $LOWMARCH_REPO && git status | head && git log --oneline | head -5; cat ~/Projects/lowmarch-bench/brief/materials/yin-yang-rig-review.md

    result, 216 lines

    ~/Projects/lowmarch-bench/b:
    total 36
    drwx------ 6 severian severian 4096 Oct 30 12:34 .
    drwx------ 8 severian severian 4096 Oct 30 12:34 ..
    drwx------ 5 severian severian 4096 Oct 30 12:34 art
    drwx------ 2 severian severian 4096 Oct 30 12:34 docs
    -rw------- 1 severian severian   49 Oct 30 12:34 .envrc
    -rw------- 1 severian severian   58 Oct 30 12:34 .git
    -rw------- 1 severian severian  124 Oct 30 12:34 .gitignore
    drwx------ 4 severian severian 4096 Oct 30 12:34 review
    drwx------ 3 severian severian 4096 Oct 30 12:34 tools
    
    ~/Projects/lowmarch-bench/work/b:
    total 20
    drwx------  4 severian severian 4096 Oct 30 12:42 .
    drwx------  6 severian severian 4096 Oct 30 12:42 ..
    -rw-------  1 severian severian  544 Oct 30 12:42 expr_list.txt
    drwx------ 16 severian severian 4096 Oct 30 12:42 shot_before
    drwx------  2 severian severian 4096 Oct 30 12:42 site
    # expression recipes: name|VRoid shapes baked at build (Fcl_* names, weight; ';' between; 'none' = neutral)
    # the 14 the sheets show; keep these names (front_sheet.py reads them). Edit the recipes freely.
    neutral|none
    joy|Fcl_ALL_Joy:1
    smile|Fcl_ALL_Fun:1
    angry|Fcl_ALL_Angry:1
    sad|Fcl_ALL_Sorrow:1
    surprised|Fcl_ALL_Surprised:1
    shout|Fcl_BRW_Angry:1;Fcl_EYE_Angry:1;Fcl_MTH_A:1
    wink|Fcl_EYE_Close_R:1;Fcl_MTH_Fun:1
    smug|Fcl_EYE_Close:0.3;Fcl_MTH_Up:0.7
    lip_A|Fcl_MTH_A:1
    lip_I|Fcl_MTH_I:1
    lip_U|Fcl_MTH_U:1
    lip_E|Fcl_MTH_E:1
    lip_O|Fcl_MTH_O:1
    On branch bench-b
    nothing to commit, working tree clean
    47dcd23 Armory: a skin picker over the new range (People and Fantasy), ?skin=<tone> for sheets
    5a2b154 Skin range: 12 tones from porcelain to ebony and 7 fantasy tones, each with its own shading ramp
    5e928b9 Armory v13.4 (trial): the chin's shadow cast in screen space from the jaw and hair
    3105daf Armory v13.3: chin shadow fitted from the skeleton's neck line; shallower, tapered, softer
    fbffd7c Armory v13.2: shadow cards for the chin and nose, a smooth neck
    # Yin & Yang rigs (DillonGoo Studios): review for lowmarch
    
    2026-10-30. EEVEE builds (`_Yin_EV_Rig_Master.blend`, `_Yang_EV_Rig_Master.blend`, saved by Blender 4.3), opened in Blender 5.2.3 headless with `--disable-autoexec`.
    
    ## Short answer
    
    **Not a base.** The licence is personal/educational only: no commercial use of the characters, no redistribution "in original or modified form" (links below). A game build would redistribute them. Technically they are also a poor fit: two fixed characters whose clothes are the body (Yin has no skin under the shirt and pants), a Rigify A-pose rig split over 3 armatures (~1,640 bones), and expressions done with bones, not shape keys.
    
    **The lessons are worth a lot, and the ideas are free to reimplement.** Ranked for "too sharp from some angles" and "hold at every angle":
    
    1. **Proxy shading normals ("Vertex Skeleton Normals").** The face is shaded from normals that point away from a simple line inside the head, blended in by a painted mask and blurred. They are not the mesh's own normals. The contact sheet shows the difference: rows 2 vs 3 and 5 vs 6, same lowmarch 4-band shader, rig normals vs mesh normals. With mesh normals the nose, mouth and cheeks break into noisy patches that change with yaw. With the proxy they make clean ovals at every yaw.
    2. **One proxy for the whole head.** Hair, brows, lashes, mouth bag, teeth and tongue all take the same head proxy normal, so the hair's shade edge carries on into the face's.
    3. **Few tones, low threshold.** Skin is 2 tones split at N·L > 0.1: only surfaces turned more than about 84° from the light go to shade. Everything else is authored: a painted shade mask and small shadow cards.
    4. **Authored shadow shapes.** A painted per-corner `Shadows` attribute forces shade where the artist wants it. Nose, lip and eyelid shadows are tiny separate meshes in the shade colour. Yang's nose card has two flat normals, one per side, so the shadow shows only on the side away from the light.
    5. **Line-only geometry.** `Eyelids` is 10 vertices and no faces. It exists only so Line Art draws the lid creases as 3D lines that turn with the head. Shading-only meshes are excluded from Line Art.
    
    ## Files and safety
    
    - Embedded texts, dumped and read: `scratchpad/rigs/texts/{Yin,Yang}/`.
      - `*_ui.py` (BODY 109/121 KB, FACE, PHYSICS) are stock Rigify-generated rig UI (Blender 4.3 template): panels, IK/FK snapping and bake operators. I diffed them line by line against Rigify's own sources. The only lines not in Rigify are layout calls, the rig_id and the 4.3-era fcurve API. The one `eval` is Rigify's standard `parse_bone_names`. There is no os/subprocess/socket/urllib/file I/O.
      - `DO_NOT_DELETE_META_DATA` is pure JSON of bone property defaults (no licence text).
      - `PUBLISH_SHOT_LOG` is the studio's publish checklist (W:\StudioGooThing paths, "Pack files").
      - `Text` is a manual cleanup script that clears ID props and drops empty vertex groups. It is not a module, so it never runs on load.
    - Drivers: about 800 per rig. Only 6 are Python expressions, all arithmetic: `1-((var-N)**2)` for the eye-look parent switch, and `(var + .1125) % 0.225 - .1125` for the hand macro wrap. The sun_vec/rim_vec/light_fx drivers on Teeth/Tongue are simple expressions, and their targets are empty in the EEVEE build (leftovers from the Goo Engine build).
    - I judged the texts safe for autoexec, but the permission classifier refused `--enable-autoexec`. All renders therefore ran with autoexec off. That skips only the 6 drivers above, which have no visible effect at rest pose.
    
    ## 1. Techniques, how each works
    
    ### Vertex Skeleton Normals (GN, render-only)
    Head stack: `Armature > Subdivision (2, custom normals) > Warp x2 > GN "Blur Threshold" > GN "Vertex Skeleton Normals" > Lattice (Flattice)`. Both GN modifiers are off in the viewport and on in render.
    
    The node group `Head Vertex Skeleton Normals` (input Blur Iterations, default 5; hands use 48) works like this:
    1. Store the mesh normal as point attribute `vs_nrm`.
    2. Node `Neck_VS` (group `Edge Skeleton Projection`) runs with object `Yin_Neck_Pos`, factor 0.95, influence attribute `NRM_Neck`. Node `Head_VS` runs the same group with object `Yin_Head_Pos`, factor 0.5, influence `NRM_Head`. Each skeleton object is a few vertices skinned to the body rig. `Yin_Head_Pos` is a single vertical edge from z 1.42 to 1.755 at y -0.069, inside the head and forward of the skull's middle.
    3. `Edge Skeleton Projection` takes the nearest point on the skeleton's edges Q (Geometry Proximity, EDGES) and the skeleton's mean point M (`Mean_Pos`). It forms `target = mix(M, Q, factor)` and computes `n = normalize(P - target)` (`Edge_Pos`). Then `vs_nrm = mix(vs_nrm, n, weight of the influence group)`.
       - Factor 1 gives capsule normals around the edge, factor 0 gives sphere normals around its centre.
       - The head uses 0.5: cylinder-like across the face, rounded over the crown and chin.
       - The neck uses 0.95: a cylinder.
    4. Blur `vs_nrm` over mesh neighbours N times, normalize, store.
    
    Every material reads `vs_nrm` (Attribute node) into the shared toon group. Because the GN runs after Armature and before the Flattice lattice, the normals follow the pose but ignore the camera-facing flattening.
    
    Who carries the influence weights:
    - NRM_Head: Head 1,436 of 1,671 verts at weight 1. Hair, Eyebrows, Eyelashes, Mouthbag, Teeth and Tongue carry NRM_Head on all their verts, so they shade from the same head proxy.
    - NRM_Neck: 263 verts.
    - Hands use `Hand Vertex Skeleton Normals` (finger skeleton, 20 edges) plus a `sphere_nrm` about the mesh centroid.
    - Clothes use per-garment skeletons (`Vertex Skeleton Shirt/Pants/Shoe*`). In the material they mix 70-90% `vs_nrm` with an object-space Normal Map fed by `sphere_nrm` (frame "Mix Normals Types").
    
    ### Toon shader (`Yin_Shader` / `Yang_Shader`, used by 16-18 materials)
    ```
    N = mix(TexCoord.Normal, vs_nrm, Normals=1)
    v = ShaderToRGB(Diffuse(N, roughness 0)) * (ShadeMap - attr "Shadows" - Backfacing)
    colour = v > 0.1 ? Lit : Shaded
    ```
    - There are two colours per material, set on the group node (Skin Lit 0.85/0.65/0.53, Shaded 0.59/0.30/0.21, linear).
    - `Shadows` is a painted corner colour attribute. 21% of Yin's and 26% of Yang's face corners are non-zero.
    - `thresh`, also painted, is blurred by GN `Blur Threshold` but isn't read in the EEVEE materials. It is presumably used in the Goo Engine build (unverified).
    - The EEVEE file still holds the Goo Engine groups `BASE_SunFace_Group` etc. They contain an undefined node "Shader Info" (half-lambert per light, multiplied by a ShadeMap, then a threshold), so the Goo Engine build shades per named sun with half-lambert. I didn't diff the GE files further.
    
    ### Lights
    - `Sun_Body`, `Sun_Face` and `Sun_Hair`: energy π, no shadows, all pointing +Y, i.e. lit from the character's front.
    - Each sun is parented to a rig bone (`Body_Sunvector`, `Head_Sunvector`, `Hair_Sunvector`), and `Hair_Sunvector` copies `Head_Sunvector`'s rotation.
    - The bones carry "Head space", neck_follow and head_follow switches, so a light can follow the head.
    - Light linking gives each sun its own receivers: Face takes head, teeth, eyes and shadow cards; Hair takes hair; Body takes clothes and hands.
    
    ### Shadow cards
    - Yin `Nose_Shadow` (9 v), `Lip_Shadow` (10 v), `Eyelid_Shadow` (30 v) use material `Skin_Shadow_Caster`: a constant Emission in the skin-shade colour. They are driven by face bones (`DEF-eyelid-shadow-*`) and excluded from Line Art.
    - Yang `Nose_Shadow` (38 v) is shrinkwrapped onto `Male_Face` (TARGET_PROJECT, 1 mm). GN `Geometry Nodes.005` sets `vs_nrm` to the mean normal of the "Left" group on the left half and of the "Right" group on the right. Material `NoseShaded` shows the shade colour where N·L < 0.1 and is transparent elsewhere, so the shadow is a flat shape that flips sides with the light.
    - Yang `Shorts_Shadows` is a constant shade band under the shorts.
    
    ### Warps and lattices
    - Head and both teeth meshes have `Warp`/`PG - Cheek Warp.L/R`: bone pair `cheek-warp-from.L` to `cheek-warp-to.L` (to is a child of from, same rest position), 3.5 cm smooth falloff. Moving the "to" bone pushes the cheek and the teeth follow, with no weights painted.
    - Tongue has three of the same.
    - Yang adds `Head_Squash_Lattice` with Hook_Top/Hook_Bottom for head squash and stretch.
    - Flattice:
      - 4x2x4 B-spline lattice whose `Flatten` key moves the two depth layers ±0.5 together.
      - Driven by bone prop `General Properties["Flattice"]` (default 0).
      - `Flattice_Empty` copies location from DEF-spine.006 and damped-tracks `Camera_Reference_Flattice_Empty`, whose location is driven to the scene camera's.
      - It sits last in the stack, after the normals.
      - Under our orthographic camera, squashing along the view axis changes only depth order, not the silhouette.
    
    ### Eyes
    `Eye_Reflection` is a 358-vert shell over the eyes (BLENDED, excluded from Line Art). It draws procedural highlights from SDF circles in UV space (`white_highlights`, `purple_highlights`) with an animated Wave-texture "wibble". Iris colour comes from one packed `Eye_Texture.png`.
    
    ### Line Art
    Three Grease Pencil objects, each with Line Art modifiers, `Screen Space Thickness` GN, Taper and Overall Thickness:
    - `LineArt.Body`: collection `Yin_Main_Body`, contour+crease+edge marks+intersection+loose.
    - `LineArt.Character`: separate modifiers for Eyelashes, Head, Head gap-fill, Teeth, Mouthbag, Eyelids and Eyebrows. Each has contour, edge marks and loose edges; crease is off on the face.
    - `LineArt.Hair`.
    
    Shading meshes are set to EXCLUDE. I couldn't evaluate Line Art headless (0 strokes), so the outlines on the sheet are my approximation from an ID/depth pass.
    
    ### Face rig (`*_Rig_FACE`, 648 bones, 141 deform)
    - Lips: lip MASTER/top/bot/corner plus tweak rings, `lip-zipper-smoother`, `jaw-yapper` and `jaw-influence`.
    - Eyes: 12 eyelid bones per eye, `MCH_Eye_Tilt_upper/lower`, eye-socket bones named after the orbital bones, `eye-look-MASTER` with 4-way parent switch, `eye-look-blink-macro`.
    - Brows: 5 brow bones and 5 brow-skin bones per side, plus `brow-shape-control`.
    - Also cheek warps, nose bones, `eyelid-shadow-*`, and `sync-control`/`sync-frame`.
    - Correctives: Eyelashes `COR - Blink.L/R` (driver `var / -0.025` on blink target Z), shirt elbows, pants knees/butt.
    
    ### Body and hair
    - `*_Rig_BODY` is Rigify (590/584 bones, 176/172 deform) with knee/elbow helper bones, finger "Fatten"/"Knuckle" deform bones and `Palm_squish`. A hand-macro control curls the fist from one slider.
    - `*_Rig_PHYSICS` holds the hair chains (Yin 68 deform, Yang 50): FK + Tweak + `-PHYSICS` bones joined by Copy Rotation/Transforms. `wiggle_enable` props suggest the Wiggle 2 add-on (unverified, not installed).
    
    ## 2. What transfers to lowmarch, ranked
    
    lowmarch shades `lit = n @ LIGHT` from interpolated vertex normals (`tools/vroid_portrait.py` render(), LIGHT about (0.08, 0.06, 1) in camera space) into 4 skin bands (0.80/0.62/0.38).
    
    1. **Proxy face normals (highest value).** After posing, for face, hair, brow, line and mouth vertices:
       - Take the head segment from the head bone (neck-top to crown), set about 0.25 of the head's depth forward of the skull centre.
       - `target = lerp(centroid, closest_point_on_segment(P), 0.5)`, `n_proxy = normalize(P - target)`.
       - `n = normalize(lerp(n_mesh, n_proxy, w))`, then 5 rounds of neighbour averaging over the mesh's edges and normalize. Pass `n` to `interpolate(prims, buf, "nrm")` in place of the stored normals.
       - The weight `w` is per template vertex. All 26 VRoid faces share the skin triangles, so the mask is authored once by index (doc lesson 29): 1 on the face and hair, falling to 0 at the ears and the seam to the skull.
       - Add a neck capsule (factor 0.95) so the jaw-over-neck edge stays clean.
       - The sheet's rows 2/3 and 5/6 are this exact A/B under our bands and headlight.
    2. **Same proxy for hair, brows, lashes and mouth.** It is free once (1) exists. The hair's band edges then line up with the face's, and the forehead hair-shadow rule in `ink()` stops fighting the face bands.
    3. **Fewer skin tones.** Try 2 skin tones (maybe 3), with the split at a low N·L (0.1 to 0.3) on proxy normals, and keep 4 bands for cloth and metal. With a near-frontal light this leaves faces mostly flat lit with a shade rim, the look these rigs get. Four bands on a near-sphere make concentric rings (row 2), which read "3D", not anime.
    4. **Authored shade.**
       - A per-template-vertex "forced shade" weight (under the chin, eye sockets, below the lower lip), subtracted before banding as in `Yin_Shader`.
       - Nose and lip shadows as small fixed shapes in UV space, drawn like `MARK_WINDOW` marks, with a two-sided nose shadow chosen by the sign of the light's x in head space (Yang's `NoseShaded`).
    5. **3D line geometry for face lines.** Lid creases and the nose line as polylines on the template, projected each frame (as `draw_curve_mouth` already does for the mouth) rather than texture marks. They keep their place at every yaw.
    6. **Per-part light.** A face light defined in head space (or camera space but clamped so the face never goes mostly dark at yaw 90), separate from the scenery SUN and the body light. The rigs do this with bones and light linking.
    7. **Shading before shape tricks.** Compute normals before any view-dependent squash (Flattice), so shape fixes don't move shade edges. Flattice itself does nothing to an orthographic silhouette.
    8. **Warp-style face controls.** A bone-pair Warp (3.5 cm smooth falloff) is the same idea as our sculpt field. There's nothing new for us beyond confirming the approach.
    
    ## 3. As a base
    
    | | Yin / Yang | lowmarch VRoid |
    |---|---|---|
    | Mesh | Quad cages + Catmull-Clark level 2 at render. Head 1,671 v / 1,750 f (Yang 1,703 / 1,752), hair 1,018 (Yang 289+557+597), shirt 2,734. About 28k faces on the head after subdivision. | Triangulated game mesh. Face about 3.2k-4.2k tris on one shared template, body about 11.6k tris. |
    | Body | Clothes are the body. Yin: shirt, pants, hands, socks, shoes, no skin under them. Yang: arm and leg skin meshes masked under the clothes. | Nude base, costumes in code. |
    | Height | Yin 7.3 heads (crown 1.675, chin 1.444). Yang about 7.8 heads by the face mesh crown (1.632, chin 1.423). | arm_f_slender 7.3 (crown 1.661, chin 1.434); arm_m_youth 6.5 (1.623 / 1.375). |
    | Eye line, down from crown | Yin 0.58, Yang 0.55 (eyeball centres). | 0.56-0.64 (docs/vroid.md); 0.61 on both armory heads. |
    | Expressions | Bones (141 face deform bones) plus a few correctives. | 57 VRoid blendshapes. |
    | Rig | Rigify, 3 armatures, A-pose, many MCH/helper bones. | VRM humanoid, T-pose, spring bones + spheres. |
    
    - **Pose mapping:** feasible but not drop-in.
      - DEF-spine to `.006` map to hips, spine, chest, upperChest, neck and head.
      - DEF-shoulder, DEF-upper_arm (+.001 twist), DEF-forearm (+.001), DEF-hand, 3-bone fingers and thumb map to the arms and hands. DEF-thigh/shin/foot/toe map to the legs.
      - The A-pose rest needs a retarget to VRM's T-pose. The face and hair armatures must be merged.
    - **Spring bones:** hair spring bones could hang on the `DEF-Hair_*` chains (3 to 13 bones each).
    - **Adapting to archetypes:** poor. Two characters only, clothing fused to the silhouette, and the face rig and its warps and shadow cards are tuned to these two faces.
    - **Face design:** Yang sets the eyes higher than any VRoid sample (an older look, which our set lacks per lesson 30). The shapes are a reference to study, not to ship.
    
    **Licence** (verified 2026-10-30 on the listings):
    - [Gumroad bundle](https://dillongoo.gumroad.com/l/yin-yang-anime-twins-rig-pack), [Yin](https://dillongoo.gumroad.com/l/yin-anime-girl-rig), [Yang](https://dillongoo.gumroad.com/l/yang-anime-boy-rig): "non-exclusive right to use the assets on a royalty-free basis for personal or educational use". "You may not sell, share or otherwise redistribute the assets in original or modified form". "you cannot use the character 'Yin' or 'Yang' for your own commercial projects". Personal projects such as demo reels are fine with credit to DillonGoo Studios.
    - [Superhive](https://superhivemarket.com/products/yin-yang-twins-anime-rig-pack) lists License: **Editorial** and the same text. Its [FAQ](https://superhivemarket.com/products/yin-yang-twins-anime-rig-pack/faq): "If the game or movie is a commercial project (intended to make money), then the answer is no". Modifying for VRChat is fine, but "you are not allowed to redistribute the model".
    - So: modifying is allowed for personal use only. Shipping in a commercial game is not allowed. Redistributing in a build is not allowed, even modified.
    - Unverified: whether a character "built on" the mesh but substantially changed escapes this. Treat it as a derivative (no). Reimplementing the techniques (normals from a skeleton, two-tone thresholds, shadow cards) in our own code copies no asset. I am not giving legal advice.
    - The FAQ text mentions "Emily" in places, so it's a shared FAQ across their rigs.
    
    ## 4. Renders
    
    - No EGL or Vulkan here, so EEVEE can't run headless. Renders are Cycles CPU via `scripts/cycles_turn.py`:
      - Each `Diffuse > Shader to RGB` is replaced by `max(0, dot(N, L))`. That is equal to EEVEE for these shadowless suns of strength π on white diffuse.
      - Grease Pencil is removed and outlines are drawn from an object-ID and depth pass (`scripts/sheet.py`).
      - Ortho camera, scale 0.56 m, 640 px, light as noted per row.
    - lowmarch heads: `scripts/lm_turn.py` calls `vroid_portrait.Portrait.render` and `smooth_view` on the newest `arm_f_slender` and `arm_m_youth` cage VRMs, read-only. lowmarch's positive yaw turns the other way from mine.
    - **Contact sheet:** `/tmp/claude-1000/-home-severian-Projects/d2fdcea1-0c48-4814-81a2-89cf837a9b4f/scratchpad/rigs/out/contact_sheet.png` (and `.jpg`). Per-yaw PNGs are `out/{yin,yang}_{ship,bands,bands_novs}_yNN_lined.png` and `out/lm_*`.
    - Rows on the sheet:
      - 1 and 4: as shipped (2 tones, sun from the front, fixed in the world).
      - 2 and 5: our 4 bands with our headlight on the rig's normals.
      - 3 and 6: the same on the mesh's normals.
      - 7 to 10: lowmarch.
    
    ## Not verified
    
    - The look in EEVEE and in Goo Engine. Line Art strokes (they didn't evaluate headless).
    - The 6 Python drivers (autoexec refused).
    - Whether GN modifier inputs differ from the group defaults (Blur Iterations 5 head, 48 hands). The 5.2 API wouldn't read them.
    - The Wiggle 2 dependency. Goo Engine-only shading (`thresh`, Shader Info half-lambert).
    
    ## Addendum, 2026-10-30
    
    The owner ran `cycles_turn.py ... ship` on both rigs with `--enable-autoexec` (the embedded Rigify UI scripts loaded). All 8 renders (Yin and Yang at yaw 0, 35, 60, 90) are pixel-identical to the autoexec-off renders: the 6 Python drivers have no effect at rest pose. The Teeth/Tongue shader-vector drivers and the CORRECTIVE_Blink drivers stay invalid either way, since their targets are empty in the EEVEE build.
  • Bashsource ~/Projects/lowmarch-bench/kit/env-b.sh && cat > $WORK/NOTES.md <<EOF

    command

    source ~/Projects/lowmarch-bench/kit/env-b.sh && cat > $WORK/NOTES.md <<EOF
    # Track B notes
    
    Start: 2026-10-30 12:43 PST (budget: 6 rounds, 90 min, ends 14:13)
    
    EOF
    ls $KIT $WORK/shot_before $WORK/shot_before/joy $WORK/site ~/Projects/lowmarch-bench/brief/materials/*/ ; cat $KIT/build_expr.sh $KIT/env-b.sh

    result, 91 lines

    ~/Projects/lowmarch-bench/brief/materials/yang-expressions/:
    angry_y00.png
    joy_y00.png
    lip_A_y00.png
    lip_E_y00.png
    lip_I_y00.png
    lip_O_y00.png
    lip_U_y00.png
    neutral_y00.png
    sad_y00.png
    shout_y00.png
    smile_y00.png
    smug_y00.png
    surprised_y00.png
    wink_b_y00.png
    
    ~/Projects/lowmarch-bench/brief/materials/yin-expressions/:
    angry_y00.png
    joy_y00.png
    lip_A_y00.png
    lip_E_y00.png
    lip_I_y00.png
    lip_O_y00.png
    lip_U_y00.png
    neutral_y00.png
    sad_y00.png
    shout_y00.png
    smile_y00.png
    smug_y00.png
    surprised_y00.png
    wink_b_y00.png
    
    ~/Projects/lowmarch-bench/kit:
    arm_shoot.py
    build_expr.sh
    env-a.sh
    env-b.sh
    env-c.sh
    expr_list.txt
    front_sheet.py
    setup_base.sh
    sheets.sh
    shoot_expr.sh
    terra_review.sh
    variant_build.py
    
    ~/Projects/lowmarch-bench/work/b/shot_before:
    angry
    joy
    lip_A
    lip_E
    lip_I
    lip_O
    lip_U
    neutral
    sad
    shout
    smile
    smug
    surprised
    wink
    
    ~/Projects/lowmarch-bench/work/b/shot_before/joy:
    painted_arm_f_slender.png
    painted_arm_m_youth.png
    
    ~/Projects/lowmarch-bench/work/b/site:
    arm_f_slender.glb.json
    arm_m_youth.glb.json
    camera.json
    checks.json
    index.html
    plains.glb.json
    #!/bin/bash
    # Build both bodies (arm_f_slender, arm_m_youth) in each expression of LIST from your worktree's code, into
    # $WORK/exb/<name>/. Run tools/cage.py first if you edited tools/vroid_portrait.py (setup.sh does it).
    #   build_expr.sh LIST [only these names...]
    set -u
    LIST=$1; shift
    while IFS='|' read -r name rest; do
      [ -z "$name" ] && continue
      case "$name" in \#*) continue;; esac
      if [ $# -gt 0 ] && [[ ! " $* " =~ " $name " ]]; then continue; fi
      d=$WORK/exb/$name; rm -rf "$d"; mkdir -p "$d"; cp "$WORK/site/camera.json" "$d/"
      timeout 900 python3 "$KIT/variant_build.py" "$d" "rest=$rest" arm_f_slender arm_m_youth > "$WORK/log_build_$name.txt" 2>&1 \
        && echo "built $name" || { echo "BUILD FAILED $name"; tail -5 "$WORK/log_build_$name.txt"; }
    done < "$LIST"
    export BENCH=$HOME/Projects/lowmarch-bench
    export LOWMARCH_REPO=$BENCH/b
    export WORK=$BENCH/work/b
    export KIT=$BENCH/kit
  • Read~/Projects/lowmarch-bench/brief/materials/expressions-vs-yin-yang.png

    input

    {
      "file_path": "~/Projects/lowmarch-bench/brief/materials/expressions-vs-yin-yang.png"
    }

    result, 1 line

    [redacted: reference rig image (Yin/Yang by DillonGoo, personal-use licence)]
  • Bashsource ~/Projects/lowmarch-bench/kit/env-b.sh && cat $KIT/variant_build.py $KIT/shoot_expr.sh $KIT/sheets.sh | head -150; ls $WORK/exb 2>/dev/nullerror

    command

    source ~/Projects/lowmarch-bench/kit/env-b.sh && cat $KIT/variant_build.py $KIT/shoot_expr.sh $KIT/sheets.sh | head -150; ls $WORK/exb 2>/dev/null

    result, error, 87 lines

    Exit code 2
    """Build armory characters from the worktree in $LOWMARCH_REPO into a site dir, with an expression baked in.
    
      python3 variant_build.py SITE_COPY forward=0.13 pull=0.5 hair=0.0 NAME [NAME...]
    
    SITE_COPY is a copy of the built armory site (with the patched sheet rows); each NAME's .glb.json is replaced.
    """
    import functools
    import os
    import sys
    
    os.environ["LOWMARCH_SITE"] = "armory"
    REPO = os.environ["LOWMARCH_REPO"]          # the track's worktree
    sys.path[:0] = [os.path.join(REPO, "review", "vroid-02"), os.path.join(REPO, "tools"), os.path.join(REPO, "art", "cast")]
    import build  # noqa: E402
    import vroid_portrait as vp  # noqa: E402
    
    site = sys.argv[1]
    kw = dict(a.split("=") for a in sys.argv[2:] if "=" in a)
    names = [a for a in sys.argv[2:] if "=" not in a]
    hair = float(kw.pop("hair", 0.0))
    rest = kw.pop("rest", None)                           # rest=Fcl_ALL_Joy:1,Fcl_MTH_A:0.5 (VRoid expression shapes)
    if rest is not None:
        for n in names:
            build.CAST[n]["shape"] = dict(build.CAST[n]["shape"], rest={} if rest == "none" else
                                          {k: float(v) for k, v in (kv.split(":") for kv in rest.split(";"))})
    skin = kw.pop("skin", None)                           # skin=<tone in cast.SKINS>
    if skin is not None:
        import cast
        for n in names:
            build.CAST[n]["paint"] = dict(build.CAST[n].get("paint") or {})
            if cast.SKINS[skin]:
                build.CAST[n]["paint"]["skin"] = cast.SKINS[skin]
            else:
                build.CAST[n]["paint"].pop("skin", None)
    if "marks" in kw:                                     # marks=nose_dy,ry,rx/lip_dy,ry,rx/neck
        nose, lip, neck = kw.pop("marks").split("/")
        vp.SHADE_MARKS = dict(nose=tuple(map(float, nose.split(","))), lip=tuple(map(float, lip.split(","))),
                              neck=float(neck))
    vp.face_normals = functools.partial(vp.face_normals, **{k: float(v) for k, v in kw.items()})
    vp.NEUTRAL["hair_normals"] = hair
    build.OUT = site
    build.GEOM = os.path.join(site, "_geom")
    os.makedirs(build.GEOM, exist_ok=True)
    import json
    cam_path = os.path.join(site, "camera.json")
    cam = json.load(open(cam_path))
    for n in names:
        res = build.character((n, None))
        for c in cam["characters"]:                       # the page reads paint, roles and cameras from camera.json
            if c["name"] == n:
                c.update(info=res["info"], cams=res["cams"])
        print(n, res["size"])
    json.dump(cam, open(cam_path, "w"))
    #!/bin/bash
    # Shoot every built expression ($WORK/exb/*) through PAGE (normally $LOWMARCH_REPO/review/vroid-02/index.html):
    # a frontal face tile and a 30 degree tile per body, into $WORK/shot_<TAG>/<name>/painted_<body>.png
    #   shoot_expr.sh PAGE TAG
    set -u
    PAGE=$1; TAG=$2
    site=$WORK/shootsite_$TAG; rm -rf "$site"; cp -r "$WORK/site" "$site"
    python3 - "$PAGE" "$site/index.html" <<'PY'
    import sys
    s = open(sys.argv[1], encoding="utf-8").read()
    i = s.index("const ARMORY_ROWS = ["); j = s.index("const DUEL_YAWS")
    s = s[:i] + '''const ARMORY_ROWS = [
      { label: "Face", tw: 480, th: 480, tiles: [{ clip: "ref", t: 0, yaw: 0, pitch: 2, focus: "J_Bip_C_Head", dy: 0.045,
          size: 0.22, label: "0°" }, { clip: "ref", t: 0, yaw: 30, pitch: 2, focus: "J_Bip_C_Head", dy: 0.045,
          size: 0.22, label: "30°" }] },
    ];
    ''' + s[j:]
    open(sys.argv[2], "w", encoding="utf-8").write(s)
    PY
    for d in "$WORK"/exb/*/; do
      name=$(basename "$d")
      cp "$d"/*.glb.json "$d/camera.json" "$site/"
      rm -rf "$WORK/shot_$TAG/$name"
      ROWS=0 timeout 900 python3 "$KIT/arm_shoot.py" "$site" "$WORK/shot_$TAG/$name" arm_f_slender arm_m_youth > /dev/null || echo "SHOOT FAILED $name"
    done
    rm -rf "$site"
    echo "shot $TAG"
    #!/bin/bash
    # Frontal sheets for both bodies: before vs a round. sheets.sh TAG "caption"   -> $WORK/sheet_<TAG>_{woman,youth}.png
    set -u
    TAG=$1; CAP=$2
    python3 "$KIT/front_sheet.py" "$WORK/sheet_${TAG}_woman.png" arm_f_slender "Woman, slender: expressions, front" "before:Before" "$TAG:$CAP" | tail -1
    python3 "$KIT/front_sheet.py" "$WORK/sheet_${TAG}_youth.png" arm_m_youth "Man, youth: expressions, front" "before:Before" "$TAG:$CAP" | tail -1
  • Read~/Projects/lowmarch-bench/work/b/shot_before/angry/painted_arm_f_slender.png

    input

    {
      "file_path": "~/Projects/lowmarch-bench/work/b/shot_before/angry/painted_arm_f_slender.png"
    }

    result

    no output

    shot_before_angry_woman.jpg
    shot_before_angry_woman.jpg
  • Bashsource ~/Projects/lowmarch-bench/kit/env-b.sh && cd $LOWMARCH_REPO && wc -l review/vroid-02/index.html tools/*.py docs/*.md && grep -n -i 'brow\|role' review/vroid-02/index.html | head -80

    command

    source ~/Projects/lowmarch-bench/kit/env-b.sh && cd $LOWMARCH_REPO && wc -l review/vroid-02/index.html tools/*.py docs/*.md && grep -n -i 'brow\|role' review/vroid-02/index.html | head -80

    result, 73 lines

      1677 review/vroid-02/index.html
       611 tools/anthro.py
       888 tools/cage.py
       300 tools/checks.py
       139 tools/clips.py
       271 tools/collide.py
        61 tools/compare.py
       140 tools/duel.py
       482 tools/export_glb.py
       152 tools/features.py
       327 tools/fit.py
       322 tools/fitting.py
       984 tools/gear.py
       219 tools/hair_check.py
       318 tools/hair.py
       458 tools/head3d.py
       661 tools/heads.py
       935 tools/makehuman.py
       291 tools/mass.py
       311 tools/mobs.py
      1272 tools/outfit.py
       302 tools/pix.py
       487 tools/plates.py
       115 tools/poses.py
       331 tools/proportions.py
       207 tools/quaternius.py
       118 tools/regress.py
       190 tools/rig.py
       541 tools/saber_study.py
       127 tools/study.py
        77 tools/terrain.py
       269 tools/vecpix.py
       257 tools/vrm.py
      1192 tools/vroid_portrait.py
       136 docs/anthro.md
        33 docs/makehuman.md
      1332 docs/portrait-guide.md
       219 docs/proportions.md
        47 docs/quaternius.md
        12 docs/study.md
       158 docs/vroid.md
     16969 total
    52:.brand .eyebrow { font: 10px/1.3 var(--f-label); letter-spacing: 0.1em; text-transform: uppercase; color: var(--screen-dim); }
    126:      <div class="brand"><span class="eyebrow">Lowmarch · Model viewer</span><h1>Lowmarch cast</h1></div>
    127:      <span class="seg" role="group" aria-label="View">
    134:      <span class="seg" id="who" role="group" aria-label="Character"></span>
    135:      <span class="seg" role="group" aria-label="Pose" id="poseSeg" hidden>
    141:      <span class="seg" role="group" aria-label="Motion" id="motionSeg" hidden>
    146:      <span class="seg" role="group" aria-label="Animation" id="clipSeg">
    152:      <span class="seg" role="group" aria-label="Renderer">
    183:        <li><b>Builds and faces vary.</b> The soldier's kit is also worn by a stocky grey veteran and a slim woman. Builds run from petite (the thief, 1.4 m) to huge (the berserker), with a new overall height control and oversized hands, as the study's units have. Faces differ in shape (jaw, chin, brow, nose, cheeks), expression, hair and eye colour, skin tone, and beards and moustaches. The lineup's check sheet has rows of faces with helmets off and on.</li>
    242:// role ids for the info pass (features 4-9 get no inner lines; hair 3 and 11 shade the forehead)
    243:const ROLE_ID = { face: 1, body: 2, hair: 3, line: 4, iris: 5, white: 6, highlight: 7, brow: 8, mouth: 9, cloth: 10, hair_b: 11 };
    244:let nextRole = 12;
    245:const roleId = r => (ROLE_ID[r] ??= nextRole++);
    256:  document.querySelector(".brand .eyebrow").textContent = PAGE.eyebrow || "Lowmarch";
    802:function paintMaterial(V, role, map, index, infoRole = role, decal = false) {
    803:  const Rl = V.roles[role] || { mode: "flat", color: "#ff00ff" }, P = (V.paint || {})[role];
    806:    uInfo: { value: new THREE.Vector4(roleId(infoRole) / 255, (index % 256) / 255, Math.floor(index / 256) / 255, 1) },
    807:    uEye: { value: V.roles.iris.colors.map(hex) }, uFlat: { value: new THREE.Color() }, uSkinRef: { value: new THREE.Vector3(0.965, 0.878, 0.80) },
    826:  if (role === "face" && map) u.uFaceTex.value = 1;
    827:  if (role === "hair" && map) u.uAlpha.value = 1;
    828:  if (role === "iris") { u.uKind.value = 7; u.uAlpha.value = 1; }
    829:  else if (role === "white" || role === "highlight") { u.uKind.value = map ? 8 : 9; if (map) u.uAlpha.value = 1; u.uFlat.value = hex(Rl.color || "#ffffff"); }
    830:  else if (!P && Rl.mode === "flat") { u.uKind.value = 9; u.uFlat.value = hex(Rl.color); if (map) u.uAlpha.value = role === "line" ? 2 : 1; }
    860:  const m0 = o.material, role = o.userData.role || "body", raw = rawTex(m0.map);
    861:  const infoRole = role === "hair" && opts.hairB ? "hair_b" : role === "shadow" ? "body" : role;
    866:  if (role === "hair" && m0.map && V.paint && V.paint.hair) {
    880:  o.userData.mats = { clay, toon, painted: paintMaterial(V, role, raw, meshIndex, infoRole, decal) };
    883:  o.userData.role = role;
    905:    const band = info.band || "gold", BR = V.roles[band] || V.roles.gold;
    1346:  // the info pass (roles, ids, depth) first: the colour pass reads it for cast shadows
    1371:// drawn into one image, which lands in #sheetOut as a PNG data URL (a headless browser reads it).
  • Bashsource ~/Projects/lowmarch-bench/kit/env-b.sh && cd $LOWMARCH_REPO && grep -n -i 'brow\|mouth\|"role"\|renderOrder\|depthTest\|depthFunc' tools/export_glb.py tools/vroid_portrait.py tools/vrm.py | head -80; grep -n 'renderOrder\|depthTest\|depthWrite\|polygonOffset' review/vroid-02/index.html | head -40

    command

    source ~/Projects/lowmarch-bench/kit/env-b.sh && cd $LOWMARCH_REPO && grep -n -i 'brow\|mouth\|"role"\|renderOrder\|depthTest\|depthFunc' tools/export_glb.py tools/vroid_portrait.py tools/vrm.py | head -80; grep -n 'renderOrder\|depthTest\|depthWrite\|polygonOffset' review/vroid-02/index.html | head -40

    result, 75 lines

    tools/export_glb.py:2:browser 3D viewer.
    tools/export_glb.py:5:the viewer can toggle and x-ray them. Face, eye, line, brow, mouth and hair keep VRoid's textures
    tools/export_glb.py:18:TEXTURED = ("face", "iris", "white", "highlight", "line", "brow", "mouth", "hair")
    tools/export_glb.py:19:ROLE_TONE = {"face": "5", "body": "4", "shadow": "3", "hair": "D", "brow": "B", "coat": "M", "cape": "W", "metal": "8",
    tools/export_glb.py:22:             "grass": "c", "dirt": "f", "stone": "l", "mouth": "2", "line": "o", "iris": "s", "white": "w",
    tools/export_glb.py:31:    if role in ("face", "body", "shadow", "iris", "white", "highlight", "line", "brow", "mouth"):
    tools/export_glb.py:136:            if role in ("hair", "line", "brow", "iris", "highlight"):
    tools/export_glb.py:184:    """Everything the browser's pixel renderer and cloth solver need besides the meshes: palette
    tools/export_glb.py:185:    bands per role, the lights, body colliders, cloth grids and the mouth curve (model space,
    tools/export_glb.py:196:             "line": dict(mode="flat", color=hexp("o")), "brow": dict(mode="flat", color=hexp("A")),
    tools/export_glb.py:197:             "mouth": dict(mode="flat", color=hexp("1")), "white": dict(mode="flat", color=hexp("w")),
    tools/export_glb.py:227:    if getattr(portrait, "mouth_curve", None) is not None:
    tools/export_glb.py:232:        _, i = cKDTree(allp).query(portrait.mouth_curve)
    tools/export_glb.py:233:        curve = ((portrait.mouth_curve + alln[i] * 0.0015) * FLIP).tolist()
    tools/export_glb.py:237:        img = Image.fromarray(np.where(m == 1, 128, 0).astype(np.uint8), "L")     # nose mark only; the mouth is a line
    tools/export_glb.py:269:                mouth_curve=curve, mouth_bone="J_Bip_C_Head", ink=dict(o=hexp("o"), skin=hexp("1"), hair=hexp("A")),
    tools/export_glb.py:418:                if role in ("hair", "line", "brow", "iris", "highlight"):
    tools/vroid_portrait.py:4:palette tones (skin/hair/cloth bands from normals; eyes, brows, lashes from alpha-tested masks)
    tools/vroid_portrait.py:39:         ("FaceEyeline", "line"), ("FaceEyelash", "line"), ("FaceMouthLine", "line"), ("FaceBrow", "brow"), ("FaceMouth", "mouth"),
    tools/vroid_portrait.py:46:# VRoid face textures share one layout: marks only count inside the nose-and-mouth window
    tools/vroid_portrait.py:161:#  3. resting expression: VRoid's own shapes (hooded eyes, stern brows) held at a low weight
    tools/vroid_portrait.py:164:               face_len=0.0, jaw=0.0, chin_w=0.0, chin_len=0.0, cheek=0.0, brow_ridge=0.0,
    tools/vroid_portrait.py:166:               eye=1.0, eye_tilt=0.0, iris=1.0, mouth=1.0, arm_out=0.0, age_lines=0.0, blush=1.0, curl=0.0,
    tools/vroid_portrait.py:170:SCULPT_ROLES = ("face", "body", "mouth", "brow", "line", "white", "iris", "highlight", "hair")
    tools/vroid_portrait.py:285:    mouth = verts("mouth").mean(0)
    tools/vroid_portrait.py:286:    band = mid[(mid[:, 1] > mouth[1] + 0.005) & (mid[:, 1] < eyeL[1] - 0.01)]
    tools/vroid_portrait.py:309:    lm = dict(eyeL=eyeL, eyeR=eyeR, eye_y=eye_y, chin=chin, mouth=mouth, nose=nose, span=span)
    tools/vroid_portrait.py:341:        move("ridge" + sd, np.array([0, -0.0015, -0.004]) * sh["brow_ridge"])
    tools/vroid_portrait.py:346:    for name in ("crown", "occiput", "forehead", "neck_front", "neck_back", "mouth"):
    tools/vroid_portrait.py:397:    instead of patches that jump as the head turns. The face, its brows, lines and mouth and the skull take it in
    tools/vroid_portrait.py:417:        if r in ("face", "brow", "line", "mouth"):
    tools/vroid_portrait.py:565:    (from the nose's wing round the mouth, under the eyes, crow's feet, a frown between the brows,
    tools/vroid_portrait.py:566:    down from the mouth's corners), darker and more of them as `amount` (0..1) rises. Each stroke is
    tools/vroid_portrait.py:586:    mouth = verts("mouth")
    tools/vroid_portrait.py:591:    mo = lm["mouth"]
    tools/vroid_portrait.py:592:    mhw = 0.55 * np.ptp(mouth[:, 0])                             # half the mouth's width (the inner mouth spans it)
    tools/vroid_portrait.py:640:        # from the nose's wing round the mouth
    tools/vroid_portrait.py:651:        # down from the mouth's corner
    tools/vroid_portrait.py:655:    # a frown between the brows
    tools/vroid_portrait.py:696:    """Face length, sculpting, eye size/tilt and mouth width (skeleton and expression are applied
    tools/vroid_portrait.py:713:        if sh["mouth"] != 1.0 and r == "mouth":
    tools/vroid_portrait.py:714:            P[:, 0] = lm["mouth"][0] + (P[:, 0] - lm["mouth"][0]) * sh["mouth"]
    tools/vroid_portrait.py:732:                 mouth="curve", world=None, ref_pose=BUST_POSE, hair_trim=None):
    tools/vroid_portrait.py:733:        """mouth: how the closed mouth is drawn. 'texture' = VRoid's painted lip line, thickened;
    tools/vroid_portrait.py:734:        'texture+lip' = that plus a lower-lip shade; 'drawn' = an FE8-style mouth between the
    tools/vroid_portrait.py:735:        projected mouth corners; 'none' = geometry only (the line disappears when closed)."""
    tools/vroid_portrait.py:736:        self.mouth = mouth
    tools/vroid_portrait.py:769:        self.roles = [p.get("role") or role_of(p["material"]) for p in self.prims]
    tools/vroid_portrait.py:783:                self.roles.append(pc["role"])
    tools/vroid_portrait.py:802:            self.roles.append(pc["role"])
    tools/vroid_portrait.py:825:            if self.roles[k] in ("line", "brow", "iris", "highlight", "white", "hair", "mouth"):
    tools/vroid_portrait.py:837:        self.mouth_pts = None
    tools/vroid_portrait.py:838:        self.mouth_curve = None
    tools/vroid_portrait.py:852:            # 2 = mouth (dark line), 1 = nose mark (one tone softer)
    tools/vroid_portrait.py:854:            if self.mouth_pts is None and raw.any():
    tools/vroid_portrait.py:867:                        self.mouth_pts = pts
    tools/vroid_portrait.py:877:                    self.mouth_curve = np.array(curve) if len(curve) >= 4 else None
    tools/vroid_portrait.py:922:               if k in self.tex and self.roles[k] in ("line", "brow", "iris", "highlight", "white", "hair", "mouth")}
    tools/vroid_portrait.py:963:                if mark is not None and self.mouth in ("texture", "texture+lip", "curve", "curve+lip"):
    tools/vroid_portrait.py:970:                    if self.mouth == "texture+lip":
    tools/vroid_portrait.py:974:                    if self.mouth in ("texture", "texture+lip"):
    tools/vroid_portrait.py:993:            elif r == "brow":
    tools/vroid_portrait.py:995:            elif r == "mouth":
    tools/vroid_portrait.py:1026:        if self.mouth == "drawn" and self.mouth_pts and all(q is not None for q in self.mouth_pts):
    tools/vroid_portrait.py:1027:            pts = project(np.array(self.mouth_pts)) / [SS, SS, 1]
    tools/vroid_portrait.py:1028:            draw_mouth(ink, rol, pts)
    tools/vroid_portrait.py:1029:        if self.mouth in ("curve", "curve+lip") and self.mouth_curve is not None:
    tools/vroid_portrait.py:1030:            draw_curve_mouth(ink, rol, dep, project(self.mouth_curve) / [SS, SS, 1], lip=self.mouth == "curve+lip")
    tools/vroid_portrait.py:1104:def draw_mouth(c, rol, pts):
    tools/vroid_portrait.py:1105:    """FE8-style closed mouth: a dark line between the corners with lighter ends, and a lower-lip
    tools/vroid_portrait.py:1123:def draw_curve_mouth(c, rol, dep, pts, lip=False, keep=(0.12, 0.88), dark=(0.28, 0.72)):
    tools/vroid_portrait.py:1124:    """Closed mouth as a 1-px line along the projected lip curve: dark in the middle, one tone
    tools/vroid_portrait.py:1154:            "line": hexrgb("o"), "brow": hexrgb("B"), "mouth": hexrgb("2"), "white": hexrgb("w"),
    tools/vrm.py:6:game except through those buffers (eye/brow/lash shapes come from alpha-tested texture masks).
    939:    mat.transparent = x; mat.opacity = x ? 0.28 : 1; mat.depthWrite = !x; mat.needsUpdate = true;