Claude Skill

scenario-blender-uv-baking

Use when UV unwrapping, placing seams, straightening or packing UVs, setting texel density, laying out UDIMs or mirrored UVs, or baking normal, AO, curvature, ID or displacement maps from a sculpt or high poly onto a low poly or retopo in Blender, including Multires bakes. Also w

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-uv-baking-dc592af.zip · 62 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-uv-baking
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

UV unwrapping and baking

Expert-level UVs put seams where the texturing method can afford them, set texel density before texture size, and pack with pixel-true padding. Expert bakes measure their projection distances instead of guessing, and are judged in pixels and under a raking light, never eyeballed once. Every stage below has a measurable gate that scripts/bx_uvbake.py computes. 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).

Stance (the expert delta)

  • Seams follow the texturing method. Kaspar (2021, 2D painting): as few as possible, hidden under hair, clothes and inner limbs. Blender Studio guide (2024, same author): more seams rather than fewer when you paint in 3D or bake, because stretch costs more. Game bakes add a hard rule: every hard edge is a seam, or the normal map cracks (Lampel, Gambrell, On Mars 3D, Polycount).
  • Only closed shells and long tubes need extra cuts. Morren: anything closed on itself needs one cut. Gambrell: cut every taped-paper ring once, hidden. But an island with holes (a face with eye holes, a washer) flattens fine. cut_to_disks test-unwraps it and cuts only if it stretches [added; cutting everything folded the sheep's big islands].
  • Unwrap method is a measurement, and 5.2 defaults to CONFORMAL. Always pass method=. On Suzanne, Minimum Stretch gives the most even density (spread 1.38), Angle Based the best angles, and Conformal fails by area (5.89). On flat hard-surface islands all three converge. unwrap(method="BEST") picks per island and rejects islands that fold over themselves.
  • Density first, texture size second (Huge Menace). Targets: FPS 10.24 px/cm, weapon 20.48, third person 5.12, top-down 2.56. Priority follows the camera: face, hands and front up; soles, inner mouth and back down (SpeedChar, Kaspar). Match texture sets by scaling the denser set down, never the other up (Gambrell).
  • Straighten, then equalize, then pack without tilting (Lampel): Follow Active Quads from a truly rectangular quad, pin, re-unwrap the rest, pack with CARDINAL. Padding is a pixel number, res/128 (16 px at 2K), passed as FRACTION margin = gap/(2*res).
  • Measure the projection, never guess extrusion, and do not trust one global value. Ryan King's 0.01 then 0.02 was scene-specific. SpeedChar swept six values by hand, and the value that fixed the face broke the armpit ("when we fix something somewhere, some other problems occur", E6QMhGuhyoc [00:26:23]). Rays start on the low, and max_ray_distance counts from the extruded start. The adaptive bake keeps, per texel, the smallest extrusion that hits a front face. On the sheep it cut inverted texels from 7.5% to 1.2% [verified].
  • Unique UVs wherever baked lighting differs (SpeedChar: an AO ghost means shared texels). Stacked or mirrored copies move exactly one tile out of 0-1 before the bake (Polycount), because inside 0-1 the last island baked wins.
  • A bake is judged in pixels and under light. Blue < 0.5 means rays hit the far side. Exactly flat means rays missed. All-white AO means the distance is too small. Inverted pixels that do not move when extrusion changes point to a geometry problem, not a setting [verified on the sheep]. Multires bakes cast no rays: on the same surface they gave 0.09% inverted texels.

Establish first

Input Changes Default when the brief is silent
Target seams, symmetry, UDIM vs sets, triangulation game engine, one 0-1 set per material
Texturing method seam count and visibility 3D painting/Substance: seams may show at the back
Camera density and priorities third person 512 px/m, closest view defines tiles
Texture budget resolution, sets, UDIM count 2048 per set; texture_size_for decides
Deformation seams off joints, tangent-space normals character: tangent space, unique UVs
High source Selected to Active vs Multires separate sculpt: Selected to Active
Engine convention normal green channel OpenGL (Blender, Unity, glTF); directx=True for Unreal

Workflow

  1. Pre-flight. Apply scale (On Mars 3D: it "fixes the vast majority" of problems). Clear non-manifold geometry, doubles and back faces (SpeedChar). Decide on Mirror and Solidify modifiers. If UVs exist, compare island_topology with uv_qa islands: seam flags in files are often leftover retopology marks, so run seams_from_uv_islands (Lampel) before re-unwrapping. GATE: scale 1, bx_audit clean, topology table read.
  2. Seams. Hard surface: seams_from_sharp(obj, 30, mark_sharp=True). Organic: build part labels (head, ears, limbs, torso front and back, soles, inner mouth and eyelids per the Kaspar and Morren seam maps in references/expert-notes.md), then seams_from_groups(obj, labels). Specific cuts: seam_path(obj, v_a, v_b, view_dirs). Then cut_to_disks(obj, view_dirs=[camera]). GATE: must_cut empty; a checker render shows no seams across hero areas that the brief cannot afford.
  3. Unwrap. unwrap(obj, method="BEST"), or the method the island type calls for. GATE: unsolved_faces 0 (the operator returns FINISHED even when it fails), folding_islands empty (else seams_from_groups(..., faces=folding_faces) and unwrap again), angle p95 at most 15 to 20 deg.
  4. Straighten grid-like islands (hard surface, garments, strands): straighten(obj) before any scaling. GATE: straight stripes in the checker.
  5. Texel density. normalize_texel_density(obj, target, res, weights), then texture_size_for(obj, target). GATE: island spread within 10-20%, or the intended priority ratios.
  6. Pack. pack(obj, tex_res=res), with scale=False for a strict density and allow_any=True for organic islands. UDIM: pack each region, then move_to_tile. GATE: uv_qa: 0 overlaps, gap at least res/128, border at least gap/2, coverage at least 0.70 or an explained exception.
  7. Look. uv_layout_image, checker_review, distortion_review(metric="angle"). Judge with references/critique.md.
  8. Bake prep. prepare_low(low, triangulate=game, sharp_from_uv_islands=hard_surface). Check the silhouette against the high (bx_audit.audit(low, high=...)). Explode interpenetrating or moving parts (explode, one axis). Run measure_projection: miss_pct under 2 and wrong_part_pct under 1, else use a cage, explode, or bake per pair.
  9. Bake. bake_high_to_low(low, highs, out, res=512) first, then the final resolution. It uses adaptive extrusion by default and hides every other object from render. Multires: bake_multires. Extras: bake_id, curvature_from_normal. GATE: sanity[...]["problems"] empty (inverted texels under 2%, notes from 0.2%), 16-bit PNG. If inverted texels remain, run map_problem_review to see where. If sweep_projection shows they do not move with extrusion, fix the geometry (fit, holes, thin parts), not the numbers.
  10. Review and hook up. bake_review(low, highs, paths, out): the low with maps must read like the high under the raking light. Then hookup_maps. Report measured numbers and what was not verified.

Numbers

Value Relative to Source
10.24 / 20.48 / 5.12 / 2.56 px/cm FPS world / FPS weapon / third person / top-down Huge Menace [00:12:28]
10-20% density mismatch invisible between islands or sets Gambrell [01:36:16]
coverage above 70%, 75% studio minimum one 0-1 tile Gambrell [01:31:56], Huge Menace [00:16:27]
island gap res/128 (8/16/32 px at 1K/2K/4K) texture resolution Lampel [00:38:01], On Mars 3D [00:11:18]
FRACTION margin m: gap 2m, border m Blender pack verified
bake margin res/128, 16 px at 2K texture resolution On Mars 3D, digest
cylinder sides: 6 min, 8 recommended low poly silhouette On Mars 3D [00:03:48]
samples: 16 for normals, 128 for AO, 1 only while iterating antialiasing and noise Gambrell/Marmoset 16, SpeedChar 4x4, verified
test bake 512, final 2048 iteration loop SpeedChar [00:59:09]
AO distance about 0.2 x bbox diagonal (default 10 units) asset size [added], verified effect
angle error p95 at most 15 deg organic, 3 deg flat per corner [added] from 5.2.1 measurements
Blender packer on an organic character: 55-62% 16 px gap at 2K measured on the sheep (several runs)
inverted texels: single pass 7.3-7.8%, adaptive 2.2% (sculpt) / 1.2% (consistent high) selected to active, sheep measured
2048 bake, 186k-face high: one pass normal + AO 4.4 s, adaptive 25 s Metal GPU, headless measured

Quality gates

Code: q = uv_qa(obj, res), then uv_verdict(q, "game" or "film", priorities=...). unwrap()["unsolved_faces"] and ["folding_islands"]. measure_projection(). bake_high_to_low()["sanity"] (inverted, exact-flat, non-unit, AO black and white). Cross-check overlaps with bpy.ops.uv.select_overlap() when in doubt; it agreed with the raster on the tests.

Visual: layout PNG (islands, magenta overlaps), color-grid checker from front, side, three-quarter and the production camera (squares square, letters upright, seams hidden), angle and density heat maps, bake_review sheet (high, low, low + maps), map_problem_review when sanity flags pixels.

Common mistakes

Mistake Looks like Fix
uv.unwrap() without method Conformal area distortion pass method= or use unwrap(method="BEST")
Trusting FINISHED closed island keeps its old UVs, repacked zero the UVs first, check for zero-area faces (unwrap() does it)
Re-unwrapping without Seams from Islands layout lost; retopology marks used as seams seams_from_uv_islands first
Cutting every island to a disk slits, folded islands measured cut_to_disks (default)
Pack rotation ANY after straightening tilted grids, jaggy lines CARDINAL
Blender margin 0.005 padding depends on island scale FRACTION, gap/(2*res)
Target node active but not selected bake returns CANCELED, no error select and activate; assert FINISHED
Low carries the sculpt as Multires almost flat normal map render levels 0 for the bake (bake_high_to_low does it)
max_ray shorter than extrusion 90% exactly flat map ray = extrusion + inward depth
One global extrusion on thin, close parts (ears on a head) inverted patches, neighbors baked in adaptive bake (default), explode, or per-pair bakes
Other objects render-visible during AO black AO hide them from render; the active low never occludes
Default AO distance 10 AO all dark or flat about 0.2 x asset diagonal
Stacked UVs baked in 0-1 one side's detail on both unique UVs or offset_stacked
Hard edge inside an island crack lines seam it, or prepare_low(sharp_from_uv_islands=True)
Editing UVs after the bake seams, tangent mismatch never; re-bake

Blender 5.2 notes

  • uv.unwrap defaults to CONFORMAL. On failure it keeps the island's old UVs and repacks them. It has use_weights/weight_group (default group "uv_importance"): importance weights with Minimum Stretch. A weighted top half went from 31% to 74% of UV area [verified].
  • Sync select is on by default (5.0). BMLoopUV only has uv and pin_uv. A me.uv_layers[...] reference goes stale after an Edit Mode toggle, so keep the name.
  • pack_islands(udim_source="CLOSEST_UDIM") headless packed a region sitting in 1002 back into 1001: pack first, then move. uv.move_on_axis needs a UV editor.
  • Bake: the target must be selected AND active (5.0). Operator defaults differ from scene defaults (use_clear False vs True, EXTEND vs ADJACENT_FACES): pass everything. Selected to Active uses the low's render evaluation (Multires render_levels, shape keys). Multires settings live in scene.render.bake.use_multires with object.bake_image(). A float image saved as PNG is 16-bit. Normal Map node convention OPENGL/DIRECTX (5.1). UDIM tiles need image.tile_add(fill=True).
  • Auto Smooth is gone (4.1): smooth shading plus sharp edges; Triangulate "Keep Normals" is keep_custom_normals.

References

  • references/expert-notes.md: judgment by expert with timestamps, disagreements and deciding conditions, my 5.2.1 measurements. Load when choosing seams, density or a bake strategy.
  • references/procedures.md: tested pipelines (hard-surface, character, film/UDIM, Multires, bake-fix loop) and raw bpy snippets executed by the tests. Load before writing code.
  • references/critique.md: rubric for seams, stretch, density, packing, bake setup and baked maps, with proxies and severity. Load at every gate.
  • references/sources.md: videos, credentials, URLs, best timestamps, web references.
  • scripts/bx_uvbake.py: the module (docstrings list every function).
Files (skills)
  • references
    • critique.md 17.1 KB
      # Critique rubric: judging UVs and bakes like the experts
      
      Use after every stage. Each line: what the expert looks at, the measurable proxy (`bx_uvbake`), and severity. Severity: **block** = fix before the next stage; **fix** = fix before delivery; **note** = mention, accept if the brief allows. Calibrate: a stage passes with notes; do not dramatize a note into a block.
      
      ## A. Seams
      
      | Look at                               | How the expert sees it                                                                                 | Proxy                                                                                                             | Pass                                                                                                         | Severity                             |
      | ------------------------------------- | ------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ------------------------------------ |
      | Seam visibility                       | Checker or stripes from the main camera; seams under hair, clothes, inner limbs, back (Kaspar, Morren) | `checker_review` views from the production camera; `cut_to_disks(view_dirs=...)` places cuts on faces facing away | No island border across the face, front torso or other hero area unless the brief uses 3D painting/triplanar | fix (film/2D paint), note (3D paint) |
      | Hard edges vs seams (baked assets)    | Crack lines along edges after the bake (On Mars 3D, Lampel)                                            | `uv_qa()["sharp_edges_not_on_seams"]`                                                                             | 0                                                                                                            | block for game bakes                 |
      | Closed shells, handles                | Unwrap fails or collapses                                                                              | `island_topology()["must_cut"]`; `unwrap()["unsolved_faces"]`                                                     | empty / 0                                                                                                    | block                                |
      | Long tubes left uncut                 | Squashed, bent strips (Gambrell ring rule)                                                             | island density spread > 2 or angle p95 > 20 in the test unwrap (`cut_to_disks` does this)                         | cut once, hidden                                                                                             | fix                                  |
      | Over-cutting                          | Slits, confetti islands, seams where nothing forced them                                               | island count vs part count; islands with < 10 faces; slivers in `uv_layout_image`                                 | Every seam has a reason: hidden, hard edge, tube, fold                                                       | note                                 |
      | Symmetric seam placement (characters) | Left and right match (Kaspar Select Mirror)                                                            | compare seam edge sets mirrored in X                                                                              | Matches, unless the design is asymmetric                                                                     | note                                 |
      
      ## B. Unwrap and stretch
      
      | Look at                                                             | Proxy                                                                             | Pass                                                                          | Severity             |
      | ------------------------------------------------------------------- | --------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | -------------------- |
      | Angle stretch (skew) must stay out of yellow (Kaspar)               | `angle_err_p95_deg`                                                               | <= 15 deg organic, <= 3 deg flat hard-surface [added from 5.2.1 measurements] | fix above 20         |
      | Area distribution even inside an island (checker squares same size) | `td_face_spread_p95_p05`; `distortion_review(metric="density")`                   | <= 2.0 organic, <= 1.25 hard surface                                          | fix                  |
      | Folds (no flipped faces but the island covers itself)               | `unwrap()["folding_islands"]`, `self_overlapping_islands`                         | none                                                                          | block                |
      | Flipped UVs                                                         | `flipped_faces`                                                                   | 0 (unless an intentional mirrored stack)                                      | block                |
      | Straight grids straight (hard surface, garments, strands)           | axis-aligned share of island edges; stripes in the checker stay straight (Lampel) | >= 0.95 on islands meant to be grids                                          | fix                  |
      | Orientation consistent (color grid letters upright)                 | checker render                                                                    | letters read the same way on neighboring islands                              | note (2D paint: fix) |
      
      ## C. Texel density
      
      | Look at                               | Proxy                             | Pass                                                                   | Severity |
      | ------------------------------------- | --------------------------------- | ---------------------------------------------------------------------- | -------- |
      | Density vs target                     | `texel_density()["px_per_m"]`     | within 10% of target (strict) or above (soft) (Huge Menace)            | fix      |
      | Uniformity between islands            | `td_island_spread_p95_p05`        | <= 1.25 (Gambrell: 10-20% invisible), or the intended priority ratios  | fix      |
      | Priorities follow the camera          | per-island TD vs the priority map | face/hands/front >= body >= back/soles/inner mouth (SpeedChar, Kaspar) | note     |
      | Density across texture sets / objects | median TD per set                 | within 10%, higher set scaled down (Gambrell)                          | fix      |
      | Consistent visual fidelity            | render with real textures         | texture content scale and sharpness match (Huge Menace [00:05:41])     | note     |
      
      ## D. Packing
      
      | Look at                               | Proxy                                                                                   | Pass                                                                                                      | Severity                                        |
      | ------------------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
      | Overlaps inside the baked tile        | `overlap_px_pct`, `overlapping_island_pairs`; cross-check `bpy.ops.uv.select_overlap()` | 0 (< 0.05% slit-tip slivers tolerated)                                                                    | block before baking                             |
      | Padding                               | `min_gap_px`, `per_tile[...]["border_px"]`                                              | gap >= res/128, border >= gap/2                                                                           | fix                                             |
      | Coverage                              | `per_tile[1001]["coverage"]`                                                            | >= 0.70, target 0.75 (Gambrell, Huge Menace); Blender's packer measured 0.55-0.62 on an organic character | note below 0.70 with the reason; fix below 0.50 |
      | Islands inside the tile / tiles       | `tiles`, `faces_crossing_tiles`                                                         | game: [1001] only; UDIM: no face crosses a tile (Hernandez)                                               | block                                           |
      | Straightened islands not tilted       | pack result `rotate`                                                                    | CARDINAL or AXIS_ALIGNED                                                                                  | fix                                             |
      | Mirror-stacked layout going to a bake | `mirror_stacked_hint`                                                                   | offset +1 tile or unique UVs                                                                              | block for AO                                    |
      
      ## E. Bake setup (before pressing bake)
      
      | Look at                                             | Proxy                                                               | Pass                                                             | Severity    |
      | --------------------------------------------------- | ------------------------------------------------------------------- | ---------------------------------------------------------------- | ----------- |
      | Silhouette of low matches high (On Mars 3D, Abbitt) | `bx_audit.audit(low, high=...)` fidelity; `bake_review` silhouettes | no visible profile mismatch                                      | fix         |
      | Low shading                                         | `flat_shaded_share`                                                 | smooth, hard edges only on seams                                 | block       |
      | Low evaluated at the base                           | Multires/Subsurf render levels on the low                           | base level (bake uses render levels)                             | block       |
      | Projection                                          | `measure_projection`: `miss_pct`, `wrong_part_pct`                  | miss < 2%, wrong part < 1%; else cage, explode or per-pair bakes | fix         |
      | Moving or intersecting parts                        | parts within the ray distance of another part                       | exploded along one axis (SpeedChar) or baked per pair            | fix         |
      | Target image                                        | Non-Color, float for normal/displacement, node selected AND active  | `bake_high_to_low` does it; raw code asserts FINISHED            | block       |
      | Game low triangulated                               | Triangulate modifier or triangulated mesh                           | present if the engine gets triangles (Kaspar #3, Polycount)      | fix (games) |
      
      ## F. Baked maps
      
      | Artifact (SpeedChar, On Mars 3D, Polycount)     | Signature in `bake_sanity` / image                                                    | Cause                                                                                         | Fix                                                                                         | Severity                        |
      | ----------------------------------------------- | ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------- |
      | Black or wildly colored patches                 | `inverted_pct` > 2 problem, 0.2-2 note (blue < 0.5); `map_problem_review` shows where | ray start inside or behind a part of the high; or low and high differ there                   | adaptive bake (default); if a sweep does not move it, fix geometry (fit, holes, thin parts) | block above 2% on visible areas |
      | Flat areas where the high has detail            | `flat_exact_pct` high, map std tiny                                                   | max ray too short (counted from the extruded start), high not selected, low not at base level | recompute, check levels/selection                                                           | block                           |
      | Detail from the wrong part (fingers, neighbors) | speckles near gaps; `wrong_part_pct`                                                  | ray too long, parts too close                                                                 | shorter ray, cage shrunk locally, explode, per-pair bakes                                   | fix                             |
      | Crack lines along edges                         | discontinuity on hard edges                                                           | hard edge without seam                                                                        | seam it or smooth it                                                                        | block                           |
      | Big gradients across flat faces                 | low-frequency ramps in the image                                                      | fully smooth low on a boxy asset                                                              | hard edges + seams at 90 deg corners                                                        | fix (games)                     |
      | Wavy lines on cylinders                         | sinusoidal bands                                                                      | too few sides, averaged normals                                                               | 8+ sides, support edges, cage                                                               | fix                             |
      | Straight line through a face                    | line along a quad diagonal                                                            | triangulation differs between bake and engine                                                 | triangulate before baking, ship that triangulation                                          | fix                             |
      | One side's detail/AO on both sides              | AO ghost with no occluder                                                             | stacked or mirrored UVs inside 0-1                                                            | unique UVs or offset +1 tile                                                                | block for AO                    |
      | Jagged fine detail                              | aliasing                                                                              | samples 1                                                                                     | 16 samples (Marmoset 16, SpeedChar 4x4)                                                     | fix                             |
      | AO pure white / flat                            | `white_pct` > 85                                                                      | world AO distance too small                                                                   | distance ~ asset scale                                                                      | fix                             |
      | AO black                                        | `black_pct` > 10                                                                      | render-visible object enclosing the surface                                                   | hide it from render (bake_high_to_low does)                                                 | block                           |
      | Bumps lit from the wrong side in engine         | inverted green                                                                        | OpenGL vs DirectX                                                                             | `directx=True` for Unreal                                                                   | block                           |
      | Seams visible in render                         | color jump at UV borders                                                              | too little padding, mip bleed, tangent mismatch, UVs edited after baking                      | margin 16 px at 2K, same tangent basis, never edit UVs after baking                         | fix                             |
      
      ## G. The visual pass (always, before saying done)
      
      1. `uv_layout_image(obj, path, color="island")` and `color="density"`: islands spaced, no magenta, sensible sizes.
      2. `checker_review([obj], out)`: squares square and even, letters readable, seams where the brief allows.
      3. `distortion_review(obj, out, metric="angle")`: red only where unavoidable (corners, poles).
      4. `bake_review(low, [high], maps, out)`: row 3 (low + maps) should read like row 1 (high) under the raking light: same forms, no dark islands, no cracks, no seams, silhouette unchanged (the bake cannot fix silhouette).
      5. Open the normal and AO PNGs themselves: no black islands, no noise speckles, no hard color jumps inside islands.
      
      Report what was measured, what was looked at, and what was not verified (engine import, compression).
      
    • expert-notes.md 22.7 KB
      # Expert notes: UV unwrapping and baking
      
      The judgment behind SKILL.md, by expert, with source and timestamp. [added] = not stated by an expert (my measurement or heuristic). [verified] = run in Blender 5.2.1 (test scripts in `tests/code/blender-uv-baking/`, distiller probes in `archive/tests/uv_unwrapping/` and `archive/tests/uv_snow_and_baking/`).
      
      ## Where experts disagree, and what decides
      
      | Topic                      | Position A                                                                                                                       | Position B                                                                                                                                                             | Deciding condition                                                                                                                                                                                                                                                                                                                                                             |
      | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
      | How many seams             | Kaspar 2021: "as few seams as possible, hide them" (_LI28r-Nk5g [00:34:00]; 8wmhYarIyjw [01:37:04])                              | Blender Studio guide 2024, same author: "more seams rather than less, stretching and uneven texel density is much more of an issue"                                    | 2D painting (Krita, image editor) makes every seam cost paint-over time: fewer. 3D painting, triplanar and baking: stretch costs more than seams (On Mars 3D dm3bBpZVmnE [00:26:18]). Games add mandatory seams on hard edges, and every seam duplicates vertices (Polycount).                                                                                                 |
      | Unwrap method              | Lampel: Angle Based "almost always better", the manual's Conformal advice "not quite accurate" (KCKro3eGmLM [00:16:28])          | On Mars 3D and Gambrell: Conformal for hard surface (dm3bBpZVmnE [00:05:38]; HDURGTLNu2Q [01:03:04]); Morren: Minimum Stretch to finish heads (JwkgVckqGw4 [00:45:33]) | Island shape. Flat islands bounded by sharp-edge seams converge in every method; curved islands need Angle Based (angles) or Minimum Stretch (area). Measured on Suzanne: spread 2.08 / 5.89 / 1.38, angle error 9.6 / 10.4 / 12.9 deg for Angle Based / Conformal / Minimum Stretch [verified]. Conformal fails by area, not angle. `unwrap(method="BEST")` picks per island. |
      | Symmetric UVs              | Film: symmetric halves (Mirror U + Flip UDIM) so textures can be mirrored in 2D (_LI28r-Nk5g [00:45:36]; 8wmhYarIyjw [01:57:37]) | Games: ignore symmetry, use the space; stack mirrored parts (8wmhYarIyjw [01:58:10]; Gambrell HDURGTLNu2Q [01:10:43])                                                  | 2D editability vs unique texel budget. Anything with asymmetric baked AO needs unique UVs (SpeedChar qFMwPSv5KwA [00:04:29], E6QMhGuhyoc [00:04:56]). Stacked copies are offset exactly 1 tile before baking (Polycount).                                                                                                                                                      |
      | UDIM or texture sets       | Film creature: UDIMs in one material, per-region resolution (Hernandez 55sGQLX7iho [00:06:33]; Kaspar 3 tiles [00:14:44])        | Game truck: split materials into texture sets (Gambrell [01:25:24])                                                                                                    | Target renderer: engines consume one 0-1 set per material [added]; offline renderers read UDIMs.                                                                                                                                                                                                                                                                               |
      | Texel density              | Uniform: a sharp part next to a blurry one "doesn't make any sense" (Gambrell [01:22:40])                                        | Deliberate priorities: eyes, head, hands denser than shirt (Kaspar xZsrMfCI1Wg [01:28:32]; SpeedChar [00:11:05])                                                       | Hero close-up areas vs tiled/procedural surfaces; measure per priority group, not globally.                                                                                                                                                                                                                                                                                    |
      | Margin                     | Lampel: res/128 for games, less for film (KCKro3eGmLM [00:38:01])                                                                | On Mars 3D: 0.005-0.01 in Blender units (dm3bBpZVmnE [00:06:55]); 16 px at 2K (C_RqdNbYOjE [00:11:18])                                                                 | Different units. Blender's default Scaled margin is relative to island scale. For pixel-true padding use FRACTION: gap between islands = 2 x margin, border = margin [verified]. 16 px at 2K = res/128.                                                                                                                                                                        |
      | Hard edges on the game low | All smooth ("Auto Smooth 180", SpeedChar qFMwPSv5KwA [00:48:50]); a fully smooth low "looks okay" (On Mars 3D [00:08:36])        | Hard edges with seams keep the normal map flat enough to survive compression (On Mars 3D [00:09:43])                                                                   | Compression and vertex budget. Hard edges only where there is a seam; film meshes are subdivided and smooth anyway.                                                                                                                                                                                                                                                            |
      | Extrusion vs cage          | Extrusion only, 0.01 then 0.02 (Ryan King Zxl38NpPWdo [00:09:49])                                                                | Custom cage when one distance cannot fit (On Mars 3D [00:13:44]; Polycount)                                                                                            | Tight gaps (fingers, flat faces missed): cage. One distance fits: measured extrusion.                                                                                                                                                                                                                                                                                          |
      | Bake precision             | 32-bit float for normal maps, "a lot higher detail" (Ryan King [00:07:33])                                                       | 32-bit float "good for films, not for games" (Abbitt LvFilHJfinU [00:18:25])                                                                                           | Bake in float (no banding), deliver 16-bit PNG (Polycount), 8-bit only at export. A float image saved as PNG is written 16-bit [verified].                                                                                                                                                                                                                                     |
      | Separate high vs Multires  | Selected to Active from a separate sculpt (Ryan King)                                                                            | Multires on the game mesh, bake from Multires, "much quicker" (Abbitt [00:21:43])                                                                                      | Multires needs the low to be the Multires base (same topology). Retopologized sculpts, kitbashed highs, AI meshes: Selected to Active.                                                                                                                                                                                                                                         |
      | Ray distance               | One global value, accept small artifacts (SpeedChar E6QMhGuhyoc [00:27:30])                                                      | Region texture sets baked with their own distance and composited (SpeedChar [00:28:04], rejected as hassle)                                                            | Cheap in code: bake regions with their own measured distances into one image with `use_clear` only on the first pass [verified accumulation, distiller probe_pairs.py].                                                                                                                                                                                                        |
      
      ## Julien Kaspar (Blender Studio), Snow UV lives and the 2024 UV guide
      
      - Seams first for invisibility: under clothing, behind the hairline, inside lips, on the inner side of limbs; legs on the inner side are "the best place to hide seams of all" (_LI28r-Nk5g [00:10:01], [00:40:27]).
      - Back-of-head seam stops where a shorter haircut would still hide it, to keep alternative hairstyles possible [00:13:04].
      - Separate the sides of anything that wraps around (ears front and back), like a cube [00:36:45].
      - Area stretch is the working view (distribution); angle stretch is the hard limit that must stay out of yellow [00:16:59], xZsrMfCI1Wg [00:38:33]. The overlay is relative and not UDIM aware: read uniformity per tile, not colors [01:15:03].
      - Pins are the real relaxation tool: pin a few points, move them, let the solver re-flow; the UV relax brush is "just 2D", "not relaxing, it's smoothing" [00:50:33], [01:43:38], 8wmhYarIyjw [01:59:42]. Two pins per island lock position, rotation and scale through re-unwraps [01:40:04].
      - Close eyes and mouth in UV space: unwrap with a closed-eye shape key, or pin the outer and inner eyelid loops and pull the inner ones together [00:51:44], [00:56:16].
      - Live Unwrap "freaking out" means a missing seam, flipped faces, or overlapping pinned points (xZsrMfCI1Wg [00:17:28], [01:09:36]).
      - Clothing UVs follow the sewing pattern and are straightened: "you don't have a shirt that has fabric patterns that move in a curve along the sleeve" (xZsrMfCI1Wg [00:53:19]). Retopology should already put a loop on every garment seam (8wmhYarIyjw [00:08:53]).
      - Density is intentional and per tile: eyes, head, hands more than shirt (xZsrMfCI1Wg [01:28:32]).
      - Film symmetry: Mirror modifier with Mirror U + Flip UDIM mirrors each half around its own tile center, so the center line must sit at u = tile + 0.5 (_LI28r-Nk5g [02:05:34]).
      - UVs are judged at the highest subdivision level, "that's where the uvs need to look really good" [01:08:22].
      - Games triangulate the low before baking so baked shading matches the engine (8wmhYarIyjw [00:26:11]).
      - Align UVs to form flow (hair strands, stitches) on a secondary UV map if not on the main one; turn Live Unwrap off first, it is stored in the Scene (8wmhYarIyjw [01:28:42], [02:00:58]).
      
      ## Jonathan Lampel (BCON23)
      
      - "Every sharp edge has to be a seam" on assets that get baked normals; otherwise the normal map glitches, because the texture needs overshoot at the island edge (KCKro3eGmLM [00:19:55]).
      - Seams from Islands before re-unwrapping a projection or primitive UV, or its islands are lost [00:09:56]. [verified on the sheep: the file's seam flags were retopology region marks (23 seam islands, 16 of them rings) while the UV layout had 11 islands; `seams_from_uv_islands` recovers the real seams.]
      - Straightening recipe: straighten one quad, make it active, Follow Active Quads on the grid region, pin, unwrap the rest so it relaxes around the pinned grid [00:30:09]-[00:33:32]. Straight islands read cleanly at lower resolution and pack tighter [00:29:03], [00:48:14]. [verified headless: torus edges axis-aligned 1.6% to 100%.]
      - Pack with Cardinal or Axis-Aligned rotation after straightening; Any tilts them [00:35:49]. Average Islands Scale before packing "most of the time" [00:34:43].
      - Margin about res/128 for games because mipmaps bleed; film needs less [00:37:29]-[00:38:01].
      - Live Unwrap silently resets hand-tuned islands anywhere on the object when a seam is added [00:21:56].
      - Sub-D meshes stretch at seams with the default UV Smooth "Keep Boundaries": use Keep Corners and Junctions (On Mars 3D prefers All), or unwrap with Use Subdivision Surface at the render level [00:23:24], [00:17:34].
      - Pack by material (texture set), not by object [00:38:59].
      
      ## Jim Morren (CG Boost)
      
      - Decomposition: every shape is a cube, cylinder, ring or sphere; cut the caps off and down the length of anything cylindrical (JwkgVckqGw4 [00:17:53]).
      - Through-holes must be seamed or the unwrap "squeezes through these holes" [00:38:27].
      - The active face for Follow Active Quads must already be rectangular, or the whole island skews [00:24:17].
      - Sphere poles: seam the pole fans, straighten the quads, pin, re-solve the triangles [00:30:14]-[00:31:52].
      - Head seam map: eyelid interior, mouth interior behind the lips, ear bases, jawline to ear, ear to ear at the back hairline, neck ring and a lengthwise neck seam at the back; the face stays seam-free [00:47:16]-[00:51:37].
      - Eyes: a back-disc seam alone unwraps inside out; add a meridian seam far back. Scale the iris up, the back of the eye down [00:55:26]-[00:57:05].
      - Mirror U gives the mirrored copy unique UVs if the source islands stay on one half [00:57:36].
      - "You're going to have stretching regardless of what you do" [00:25:21].
      
      ## On Mars 3D (UV 2026, bakes 2024)
      
      - Applying scale "will fix probably the vast majority of your issues"; rectangular checkers on a clean unwrap mean unapplied scale (dm3bBpZVmnE [00:02:20], [00:19:31]). [verified: unwrap and Average Islands Scale work in object space; a 2x unapplied scale halves the density.]
      - Parts with thickness need the inner wall cut too [00:11:30].
      - "You cannot minimize distortion without UV seams" [00:25:16]. The most common bad island is one missed edge [00:18:26].
      - Silhouette first: the bake only fakes surface slope; check the low against the high in flat lighting (C_RqdNbYOjE [00:01:02]). Cylinders: 4 sides fail, 6 bare minimum, 8 recommended, 16 clean [00:03:48].
      - Every hard edge needs a UV seam, or black and white crack lines appear [00:05:17], [00:08:04]. A fully smooth low pushes gradients that break under compression [00:08:36].
      - Padding 16 px at 2K [00:11:18]. Moving parts bake exploded, or neighbors' AO is baked in [00:12:36]. Custom cage when the automatic distance misses a face [00:13:44]. Match high and low by name to stop intersecting parts contaminating each other [00:17:46].
      
      ## Josh Gambrell (Blender Bros)
      
      - Unwrap the low, not the high (HDURGTLNu2Q [00:02:22]). Apply form-defining Booleans, delete bakeable ones [00:06:01]. Low-poly bevels always one segment; rounded low bevels bake badly, "especially with ambient occlusion" [00:08:12].
      - Ring rule: "imagine a piece of paper taped together, cut it somewhere and flatten it" [01:12:17].
      - Overlap identical parts never seen together (Array, half-deleted Mirror); keep visible neighbors and tires unique [01:15:03], [01:15:36], [01:20:28].
      - Split into texture sets when one 4K set gives big checkers; balance area; each set costs VRAM [01:24:51], [01:38:24]. Coverage above 70% [01:31:56].
      - Match density across sets by scaling the higher set down: "we can always scale in, but we can't scale out"; 10-20% is not noticeable [01:35:11], [01:36:16].
      - Triangulate (Keep Normals) before an external bake; `_low` / `_high` names for name matching [01:41:07], [01:44:25].
      - Correction [added]: he says DirectX for Unity [01:52:36]; Unity uses OpenGL (Y+), Unreal DirectX (Y-) (Polycount swizzle table).
      
      ## Tristan (Huge Menace), texel density
      
      - Density = texture pixels per unit of surface, independent of screen size (sR1sAWWT2x8 [00:00:47]). Consistent density is not consistent fidelity [00:05:41].
      - Game targets: first person 10.24 px/cm (1024 px/m), first-person weapon 20.48, third person 5.12, top-down 2.56 [00:12:28]. Team decisions, "not gospel" [00:13:03].
      - Density first, texture size second: step the size up until the islands fit [00:15:30]. When space is left: strict target, soft target (scale up to fill), atlas, change object scale, non-square texture; "most game studios require at least 75% of UV space to be utilized" [00:16:15]-[00:17:44].
      - Delete faces that are never seen [00:10:13].
      
      ## Juan Hernandez (CG Boost), UDIMs
      
      - UDIMs are just a UV offset; tile = 1001 + u + 10 v (55sGQLX7iho [00:02:05]). The one rule: no island crosses a tile border [00:05:54].
      - Tile count from the closest camera, not ambition; one 4K beats sixteen, but sixteen 4K beat one 16K [00:03:46], [00:07:05].
      - Tiling micro-detail (weave, pores) belongs in the shader as tiling maps, not baked [00:15:54].
      
      ## Ryan King, baseline bake
      
      - The face gets the biggest island; hidden areas way down (Zxl38NpPWdo [00:05:16]). High and low in the same place [00:06:32].
      - Extrusion fixes black or wrong patches where the sculpt pokes outside the low; use "the smallest number that you need", too much makes extruded parts overlap [00:09:49], [00:11:32]. [added, verified: on the sheep, hand-picked 0.02 m / 0.05 m and the measured 0.033 m / 0.066 m gave the same ~7.3% inverted texels, and a sweep from 0.012 to 0.046 m did not move it; the bad texels sat in eye sockets, ear rims, hoof soles and the rear cut, where this retopo and this sculpt differ. When extrusion does not change the artifact, fix the geometry, not the number.]
      
      ## Grant Abbitt, Multires bake in 5.x
      
      - Even out base face sizes before Multires: detail density follows face size (LvFilHJfinU [00:02:39]). Linear subdivision keeps a hard form [00:03:33].
      - Bake from Multires compares viewport level (low) to render level (high): set viewport to 0; Conform Base rather than Apply Base, which distorts [00:19:32]. Check the level-0 silhouette; if the sculpt moved a lot, bake from level 1 [00:20:06]. [verified: equal levels bake a near-flat map.]
      - Displacement bake is more useful as a cavity/roughness mask than as displacement on a game asset [00:23:52].
      
      ## Nikolay Naydenov (SpeedChar, Gameloft), production frame
      
      - Texture budget first: one 2048 for head, body, teeth, eyes; the weapon on half a 1K (mcEcWwcPtmA [00:00:00]). Overlaps are "a no-no" in unique UVs [00:05:17]. Checker: "mostly squares and most squares the same size" [00:09:34].
      - Non-manifold edges, stray faces, back faces break unwraps: clean before UVs (mcEcWwcPtmA [00:25:07]; qFMwPSv5KwA [00:36:13]).
      - Texel priority follows the camera: face front, chest front, arms, upper glove big; back of neck, soles, glove underside small; reversed for behind-the-back cameras (qFMwPSv5KwA [00:11:05], [00:25:22]).
      - Material name = texture set = texture file name (qFMwPSv5KwA [00:47:46]). Test bake at 512, final 2048 with 4x4 anti-aliasing [00:59:09].
      - Diagnosis table (E6QMhGuhyoc): black triangle = UV overlap [00:01:36]; AO ghost where nothing occludes = shared UVs [00:04:56]; straight line in the normal map = triangulation mismatch [00:18:37]; skew or misses = ray distance [00:21:25]. Fixing the distance for the face broke the armpit: "when we fix something somewhere, some other problems occur" [00:26:23]. UVs are "almost never final" [00:07:41].
      - Explode along one axis only (Z) so reassembly is exact (speedchar digest, 6MccOhJZXPQ [00:01:06]).
      
      ## My measurements in 5.2.1 [added, verified]
      
      - `uv.unwrap` returns FINISHED on a closed mesh without seams, prints "Unwrap failed to solve 1 of 1 island(s)" only on the console, and keeps the island's previous UVs, rotated and repacked into the tile (affine fit residual 3e-8). It looks like a valid layout. Zero the UVs before unwrapping and look for zero-area faces after; `unwrap()` does this.
      - Cutting every island to a topological disk over-cuts: on the sheep the studio's big islands with openings folded into slits. Islands with holes flatten fine when they are flat enough (a washer: spread 1.0, 0 cuts); `cut_to_disks` now test-unwraps them and cuts only when density spread > 2 or angle p95 > 20 deg.
      - Angle Based folded a 1,180-face body island over itself by 1.2% of its pixels with zero flipped faces (global overlap); Minimum Stretch folded it by 0.02%. `unwrap(method="BEST")` penalizes folds.
      - Blender's packer, 16 px gap at 2K, organic character islands: 55-62% coverage (CONCAVE, CARDINAL or ANY). Experts' 70-80% needs a dedicated packer (UVPackmaster: Gambrell 85-87%), straightened islands or hand packing.
      - Selected to Active evaluates the low with its RENDER settings: a low carrying the sculpt as Multires (render level 3) bakes an almost flat map regardless of its viewport level.
      - AO in Selected to Active: the active low never occludes (an inflated low enclosing the high changed mean AO 0.805 to 0.801); the selected highs occlude whether or not they are hidden from render; every other render-visible object occludes (a cube in front of the face darkened it).
      - One global extrusion cannot serve a character with thin, close parts. On the sheep, single-pass selected-to-active left 7.3-7.8% inverted texels at every extrusion from 1.2 to 4.6 cm. That held even against the retopo's own Multires level 3 (7.5%), a high that matches the low by construction, while `bake_multires` on the same data gave 0.09%. Per-texel adaptive extrusion (smallest extrusion that hits a front face, from five passes) gave 2.2% and 1.2%, and matched the Multires bake (median texel difference 0.011). This is SpeedChar's rejected per-region idea (E6QMhGuhyoc [00:28:04]), done per pixel.
      - An automatic cage (push out past the high, capped at half the free space to the next surface) baked worse (9.5%) than one distance; kept as `build_cage`, marked experimental.
      - The sheep retopo carries a "body pose" shape key at 1.0: the bake uses that posed shape. Applying a Shrinkwrap for a fitting pass needs the key mix applied first (`shape_key_remove(all=True, apply_mix=True)`), or the modifier refuses and clearing keys reverts to the unposed basis.
      - The distiller's nearest-point cage estimate (from every high vertex to the low) is inflated by high parts the low does not cover (the sheep's tail); measuring along the low's normals is not.
      - A layer reference `me.uv_layers[...]` taken before an Edit Mode round trip becomes stale and can resolve to another attribute (`.corner_edge`); keep the layer name.
      
    • procedures.md 17.6 KB
      # Procedures (tested on Blender 5.2.1 LTS)
      
      Two layers:
      
      1. **`scripts/bx_uvbake.py`**: the tested module (use it whenever you can import a file; it works headless and in a live session). Pipelines below. Tests: `tests/code/blender-uv-baking/test_uv_primitives.py` (primitives), `test_sheep_uv.py` and `test_sheep_bake.py` (Blender Studio sheep, CC-BY), `test_snippets.py` (this file's snippets).
      2. **Raw bpy snippets** for when only inline code is possible. Every block tagged `# snippet:` is executed verbatim by `tests/code/blender-uv-baking/test_snippets.py` (it parses this file). Each block builds its own demo objects so it runs alone; replace the demo part with your objects.
      
      Import pattern:
      
      ```
      import sys; sys.path.append("<skills>/scenario-blender-uv-baking/scripts"); import bx_uvbake as ub
      ```
      
      ## Pipeline A: game hard-surface prop (verified, test_uv_primitives.py sections A, E, F, H)
      
      ```
      ub.prepare_low(low)                                   # apply scale, smooth shading
      ub.seams_from_sharp(low, angle_deg=30, mark_sharp=True)   # every hard edge a seam, sharps = seams
      ub.cut_to_disks(low, view_dirs=[(0, -1, 0)])          # rings/closed parts get one hidden cut
      ub.unwrap(low, method="BEST")                         # Conformal/Angle Based/Min Stretch per island
      ub.straighten(low)                                    # grid-like islands, before any scaling
      ub.normalize_texel_density(low, ub.TD_TARGETS["fps"], 2048)   # 1024 px/m, strict
      ub.texture_size_for(low, 1024, 0.75)                  # does 2048 hold it? else 4096 or a second set
      ub.pack(low, tex_res=2048, scale=False)               # keep density; fits_tile False = raise res/split
      q = ub.uv_qa(low, 2048); print(ub.uv_verdict(q, "game"))
      ub.prepare_low(low, sharp_from_uv_islands=True, triangulate=True)   # hard edges = island borders, ship tris
      r = ub.bake_high_to_low(low, [high], out_dir, res=2048, maps=("NORMAL", "AO"), directx=False)
      ```
      
      ## Pipeline B: game character (verified on the sheep, test_sheep_uv.py, test_sheep_bake.py)
      
      ```
      ub.seams_from_uv_islands(low)        # only if a layout exists: recover its seams (file seam flags may be retopo marks)
      # or: ub.seams_from_groups(low, "FACE_SETS") / labels per face from your part segmentation
      ub.cut_to_disks(low, view_dirs=[(0, -1, 0)])          # measured: islands with holes kept unless they stretch
      u = ub.unwrap(low, method="BEST")
      if u["folding_faces"]:                                 # a limb folded onto the body
          ub.seams_from_groups(low, "FACE_SETS", faces=u["folding_faces"]); ub.cut_to_disks(low); ub.unwrap(low, method="BEST")
      w = ub.face_weights_from_vgroup(low, "head", 1.0, 1.5)   # or any per-face weights (face 1.5-2, soles 0.5)
      ub.normalize_texel_density(low, None, 2048, weights=w)
      ub.pack(low, tex_res=2048, allow_any=True)            # organic islands: free rotation allowed
      ub.uv_layout_image(low, out + "/uv.png"); ub.checker_review([low], out); ub.distortion_review(low, out)
      ub.prepare_low(low)
      r = ub.bake_high_to_low(low, [sculpt], out, res=2048, maps=("NORMAL", "AO"))   # adaptive extrusion by default
      ub.bake_review(low, [sculpt], r["paths"], out)        # LOOK at it
      if r["sanity"]["NORMAL"]["problems"] or r["sanity"]["NORMAL"]["notes"]:
          ub.map_problem_review(low, r["paths"]["NORMAL"], out)   # where are the bad texels (Pipeline E)
      ub.curvature_from_normal(r["paths"]["NORMAL"], out + "/curv.png", mask=ub.island_mask(low, 2048))
      ub.bake_id(low, [sculpt], out, source="MATERIAL")     # or "OBJECT", or "ATTRIBUTE" + attribute="Col"
      ub.hookup_maps(low, normal=r["paths"]["NORMAL"], ao=r["paths"]["AO"])
      ```
      
      Results on the sheep (2,596-face retopo, 186k-face sculpt, 2048): `archive/tests/uv_baking_skill/out_sheep_uv/results.json` and `out_sheep_bake/results.json` (renders next to them).
      
      ## Pipeline C: film character, UDIMs and symmetry
      
      - Tiles per region from the closest shot (Hernandez): head 1001, body 1002, hands 1003 (Kaspar). Per region: `ub.pack(obj, tex_res=4096, faces=region_faces)` (the region alone into 0-1), then `ub.move_to_tile(obj, region_faces, tile)`. Do not rely on `udim_source="CLOSEST_UDIM"` headless: it packed a region sitting in 1002 back into 1001 (verified, test_uv_primitives.py section M). Raw equivalent of the move: snippet `udim_offset`.
      - Symmetric UVs: Mirror modifier with Mirror U + Flip UDIM, center line at u = tile + 0.5 (snippet `mirror_u_flip_udim`).
      - QA: `ub.uv_qa(obj, 4096)` reports per-tile coverage, gap and `faces_crossing_tiles` (must be 0); use `profile="film"` in `uv_verdict`.
      - UDIM bake target: snippet `udim_bake`.
      
      ## Pipeline D: Multires bake (Abbitt; verified on the sheep)
      
      ```
      ub.bake_multires(obj, out, res=2048, types=("NORMALS", "DISPLACEMENT"), base_level=0)
      ```
      
      Before: even base face sizes, UVs on the base, Conform Base if the sculpt moved the silhouette (`bpy.ops.object.multires_base_apply(modifier="Multires", apply_heuristic=False)` is presumably Conform Base; mapping not verified), shade smooth.
      
      ## Pipeline E: bake loop when the first bake is not clean (SpeedChar, automated)
      
      1. Read `r["sanity"][kind]["problems"]` / `["notes"]`, `r["projection"]` (`miss_pct`, `wrong_part_pct`) and `r["adaptive"]["texel_share"]` (which extrusion each texel used).
      2. `ub.map_problem_review(low, r["paths"]["NORMAL"], out)`: renders the low with bad texels painted red (inverted) and blue (missed). Look at where they are.
      3. `ub.sweep_projection(low, highs, out)`: single-pass bakes at 0.35-1.4 x the measured extrusion. If the inverted share moves, the distance is the problem: use the best factor, a cage, or `adaptive=(...)` with more factors. If it does not move (sheep: 7.3-7.8% at every factor), low and high differ there: fit the low (SpeedChar's fitting pass), close holes in the high, give thin parts more low geometry, or bake that part separately.
      4. Neighboring parts projecting into each other (fingers, ears, stacked parts): `ub.explode([[low_a, high_a], [low_b, high_b]])`, bake, `ub.unexplode(...)`; or bake each pair into the same image (snippet `per_pair_bake`).
      5. Re-bake only the map being tuned, at 512, then the final resolution.
      6. A hand-shaped cage (snippet `cage_object`) is the manual fallback. `ub.build_cage` (automatic) is EXPERIMENTAL: on the sheep it baked worse (9.5% inverted) than one measured distance (7.3%) and the adaptive bake (2.2%).
      
      Verified numbers (test_sheep_bake.py): sculpt onto retopo, single pass 7.3% inverted, adaptive 2.2%; retopo's own Multires level 3 as a separate high, single 7.5%, adaptive 1.2%, and the adaptive map matches `bake_multires` (median texel difference 0.011, p95 0.19).
      
      ## Raw bpy snippets
      
      ### Unwrap with an explicit method and catch a silent failure
      
      ```python
      # snippet: unwrap_explicit
      import bpy, bmesh
      import numpy as np
      bpy.ops.mesh.primitive_uv_sphere_add(segments=16, ring_count=8)      # demo: closed, no seams
      obj = bpy.context.active_object
      for e in obj.data.edges:
          e.use_seam = False
      lay = obj.data.uv_layers.active
      lay.uv.foreach_set("vector", np.zeros(len(obj.data.loops) * 2))       # zero first: a failed island stays zero
      bpy.ops.object.mode_set(mode='EDIT'); bpy.ops.mesh.select_all(action='SELECT')
      res = bpy.ops.uv.unwrap(method='ANGLE_BASED', fill_holes=True, margin=0.001)   # always pass method
      bpy.ops.object.mode_set(mode='OBJECT')
      lay = obj.data.uv_layers.active                                          # re-fetch: references go stale
      uv = np.empty(len(obj.data.loops) * 2); lay.uv.foreach_get("vector", uv)
      unsolved = np.ptp(uv.reshape(-1, 2), axis=0).max() < 1e-9
      assert res == {'FINISHED'} and unsolved   # FINISHED, yet unsolved; without zeroing it keeps the OLD UVs, repacked
      ```
      
      ### Pin and re-unwrap (the headless Live Unwrap)
      
      ```python
      # snippet: pin_reunwrap
      import bpy, bmesh
      bpy.ops.mesh.primitive_grid_add(x_subdivisions=6, y_subdivisions=6)   # demo
      obj = bpy.context.active_object
      bpy.ops.object.mode_set(mode='EDIT')
      bm = bmesh.from_edit_mesh(obj.data); uvl = bm.loops.layers.uv.active
      corner = min(bm.verts, key=lambda v: v.co.x + v.co.y)
      for l in corner.link_loops:
          l[uvl].uv = (0.0, 0.0); l[uvl].pin_uv = True       # pin and place
      bmesh.update_edit_mesh(obj.data)
      bpy.ops.mesh.select_all(action='SELECT')
      bpy.ops.uv.unwrap(method='ANGLE_BASED', margin=0.0)    # pinned UVs stay exactly where placed
      bm = bmesh.from_edit_mesh(obj.data); uvl = bm.loops.layers.uv.active
      corner = min(bm.verts, key=lambda v: v.co.x + v.co.y)
      assert all(l[uvl].uv.length < 1e-6 for l in corner.link_loops)
      bpy.ops.uv.pin(clear=True)
      bpy.ops.object.mode_set(mode='OBJECT')
      ```
      
      ### Pack with a pixel gap
      
      ```python
      # snippet: pack_pixel_gap
      import bpy
      bpy.ops.mesh.primitive_cube_add()                      # demo: default cube UVs, 6 faces
      obj = bpy.context.active_object
      res, gap_px = 2048, 2048 / 128                          # Lampel: res/128 for games (16 px at 2K)
      bpy.ops.object.mode_set(mode='EDIT'); bpy.ops.mesh.select_all(action='SELECT')
      bpy.ops.uv.seams_from_islands()
      bpy.ops.uv.unwrap(method='CONFORMAL', margin=0.0)
      bpy.ops.uv.average_islands_scale()
      r = bpy.ops.uv.pack_islands(udim_source='CLOSEST_UDIM', rotate=True, rotate_method='CARDINAL',
                                  scale=True, margin_method='FRACTION', margin=gap_px / (2 * res),
                                  shape_method='CONCAVE')    # FRACTION m: gap 2m between islands, m to the border
      bpy.ops.object.mode_set(mode='OBJECT')
      uvs = [d.vector for d in obj.data.uv_layers.active.uv]
      border_px = min(min(u.x, u.y, 1 - u.x, 1 - u.y) for u in uvs) * res
      assert r == {'FINISHED'} and 6 <= border_px <= 10, border_px
      ```
      
      ### Selected-to-active bake with every setting explicit
      
      ```python
      # snippet: s2a_bake
      import bpy
      bpy.ops.mesh.primitive_uv_sphere_add(segments=64, ring_count=32); high = bpy.context.active_object
      d = high.modifiers.new("d", 'DISPLACE'); d.texture = bpy.data.textures.new("n", 'CLOUDS'); d.strength = 0.05
      bpy.ops.object.shade_smooth()
      bpy.ops.mesh.primitive_uv_sphere_add(segments=16, ring_count=8); low = bpy.context.active_object
      bpy.ops.object.shade_smooth()                            # demo ends; low has UVs, high is detailed
      sc = bpy.context.scene; sc.render.engine = 'CYCLES'; sc.cycles.samples = 16    # >1 antialiases
      img = bpy.data.images.new("low_normal", 512, 512, alpha=False, float_buffer=True)
      img.colorspace_settings.name = 'Non-Color'
      mat = low.active_material or bpy.data.materials.new("low_mat")
      if not low.data.materials: low.data.materials.append(mat)
      nt = mat.node_tree
      node = nt.nodes.new("ShaderNodeTexImage"); node.image = img
      for n in nt.nodes: n.select = False
      node.select = True; nt.nodes.active = node              # 5.x: selected AND active, else CANCELED silently
      bpy.ops.object.select_all(action='DESELECT')
      high.select_set(True); low.select_set(True); bpy.context.view_layer.objects.active = low
      r = bpy.ops.object.bake(type='NORMAL', use_selected_to_active=True,
                              cage_extrusion=0.06, max_ray_distance=0.12,   # ray counts FROM the extruded start
                              margin=4, margin_type='EXTEND', use_clear=True, target='IMAGE_TEXTURES',
                              normal_space='TANGENT', normal_r='POS_X', normal_g='POS_Y', normal_b='POS_Z')
      assert r == {'FINISHED'}, r                              # always assert: failures do not raise
      img.filepath_raw = bpy.app.tempdir + "low_normal.png"; img.file_format = 'PNG'; img.save()   # 16-bit PNG
      ```
      
      ### AO bake: distance and occluders
      
      ```python
      # snippet: ao_setup
      import bpy
      sc = bpy.context.scene
      sc.world = sc.world or bpy.data.worlds.new("World")
      low = bpy.context.active_object
      sc.world.light_settings.distance = 0.2 * low.dimensions.length   # default is 10 units: far too big for props
      keep = {low.name} | {o.name for o in bpy.context.selected_objects}
      for o in sc.objects:                                   # every render-visible non-selected object occludes
          if o.name not in keep:
              o.hide_render = True
      sc.cycles.samples = 128                                # AO is noisy; normals are fine at 16
      assert sc.world.light_settings.distance < 10
      ```
      
      ### DirectX output (Unreal) and reading a DirectX map
      
      ```python
      # snippet: directx
      import bpy
      b = bpy.context.scene.render.bake
      b.normal_g = 'NEG_Y'                                    # bake writes G = 1 - G (DirectX, Y-)
      mat = bpy.data.materials.new("read_dx")
      nm = mat.node_tree.nodes.new("ShaderNodeNormalMap")
      nm.convention = 'DIRECTX'                               # 5.1+: read a DirectX map in Blender
      assert b.normal_g == 'NEG_Y' and nm.convention == 'DIRECTX'
      b.normal_g = 'POS_Y'
      ```
      
      ### Multires bake
      
      ```python
      # snippet: multires_bake
      import bpy
      bpy.ops.mesh.primitive_cube_add(); obj = bpy.context.active_object
      bpy.ops.object.mode_set(mode='EDIT'); bpy.ops.mesh.select_all(action='SELECT')
      bpy.ops.uv.smart_project(island_margin=0.02); bpy.ops.object.mode_set(mode='OBJECT')
      mr = obj.modifiers.new("Multires", 'MULTIRES')
      for _ in range(3):
          bpy.ops.object.multires_subdivide(modifier="Multires", mode='CATMULL_CLARK')
      bpy.ops.object.shade_smooth()                            # demo ends: base cube, round level 3
      sc = bpy.context.scene; sc.render.engine = 'CYCLES'
      mr.levels = 0; mr.render_levels = mr.total_levels      # low = viewport level, high = render level
      img = bpy.data.images.new("mr_normal", 256, 256, float_buffer=True); img.colorspace_settings.name = 'Non-Color'
      mat = bpy.data.materials.new("mr"); obj.data.materials.append(mat)
      node = mat.node_tree.nodes.new("ShaderNodeTexImage"); node.image = img
      for n in mat.node_tree.nodes: n.select = False
      node.select = True; mat.node_tree.nodes.active = node
      b = sc.render.bake; b.use_multires = True; b.type = 'NORMALS'; b.margin = 4; b.use_clear = True
      assert bpy.ops.object.bake_image() == {'FINISHED'}      # no arguments: reads scene.render.bake
      b.use_multires = False
      ```
      
      ### UDIM target that bakes
      
      ```python
      # snippet: udim_bake
      import bpy
      img = bpy.data.images.new("body_udim", 256, 256, float_buffer=True, tiled=True)   # tile 1001
      with bpy.context.temp_override(edit_image=img):
          bpy.ops.image.tile_add(number=1002, count=1, fill=True, width=256, height=256,
                                 float=True, generated_type='BLANK')   # fill=True: tiles.new() alone = "Uninitialized image"
      img.filepath_raw = bpy.app.tempdir + "body_udim.<UDIM>.exr"
      assert [t.number for t in img.tiles] == [1001, 1002]
      ```
      
      ### Move a region to a UDIM tile
      
      ```python
      # snippet: udim_offset
      import bpy, bmesh
      bpy.ops.mesh.primitive_cube_add(); obj = bpy.context.active_object     # demo
      tile = 1002
      du, dv = (tile - 1001) % 10, (tile - 1001) // 10
      bpy.ops.object.mode_set(mode='EDIT')
      bm = bmesh.from_edit_mesh(obj.data); uvl = bm.loops.layers.uv.active
      for f in bm.faces:
          if f.normal.z > 0.5:                                   # demo region: the top face
              for l in f.loops:
                  l[uvl].uv.x += du; l[uvl].uv.y += dv          # uv.move_on_axis needs a UV editor
      bmesh.update_edit_mesh(obj.data); bpy.ops.object.mode_set(mode='OBJECT')
      assert max(d.vector.x for d in obj.data.uv_layers.active.uv) > 1.0
      ```
      
      ### Mirror U + Flip UDIM (film symmetry)
      
      ```python
      # snippet: mirror_u_flip_udim
      import bpy
      bpy.ops.mesh.primitive_monkey_add(); obj = bpy.context.active_object   # demo
      m = obj.modifiers.new("Mirror", 'MIRROR')
      m.use_axis[0] = True; m.use_bisect_axis[0] = True       # keep one half, mirror it
      m.use_mirror_u = True; m.use_mirror_udim = True        # mirror each half around its own tile center
      assert m.use_mirror_u and m.use_mirror_udim            # the center line must sit at u = tile + 0.5
      ```
      
      ### Offset a stacked copy one tile before baking
      
      ```python
      # snippet: offset_stacked
      import bpy, bmesh
      bpy.ops.mesh.primitive_plane_add(); obj = bpy.context.active_object    # demo: one face
      bpy.ops.object.mode_set(mode='EDIT')
      bm = bmesh.from_edit_mesh(obj.data); uvl = bm.loops.layers.uv.active
      for f in bm.faces:                                        # demo "duplicate" = every face here
          for l in f.loops:
              l[uvl].uv.x += 1.0                                # outside 0-1: the baker skips it, the shader
      bmesh.update_edit_mesh(obj.data); bpy.ops.object.mode_set(mode='OBJECT')   # wraps, so it reads the same texels
      assert min(d.vector.x for d in obj.data.uv_layers.active.uv) >= 1.0
      ```
      
      ### Custom cage object
      
      ```python
      # snippet: cage_object
      import bpy
      bpy.ops.mesh.primitive_uv_sphere_add(segments=16, ring_count=8); low = bpy.context.active_object   # demo
      cage = low.copy(); cage.data = low.data.copy(); cage.name = low.name + "_cage"
      bpy.context.scene.collection.objects.link(cage)
      for v in cage.data.vertices:
          v.co += v.normal * 0.05                               # shrink by hand where parts are close (fingers)
      cage.hide_render = True
      b = bpy.context.scene.render.bake
      b.use_cage = True; b.cage_object = cage                   # same topology and face order as the low
      assert b.cage_object == cage
      b.use_cage = False; b.cage_object = None
      ```
      
      ### Several high/low pairs into one image (Blender has no match-by-name)
      
      ```python
      # snippet: per_pair_bake
      import bpy
      def bake_pairs(pairs, **kw):
          """pairs: [(low, [highs]), ...] sharing one bake image; clear only on the first pass."""
          for i, (low, highs) in enumerate(pairs):
              bpy.ops.object.select_all(action='DESELECT')
              for h in highs: h.select_set(True)
              low.select_set(True); bpy.context.view_layer.objects.active = low
              r = bpy.ops.object.bake(use_selected_to_active=True, use_clear=(i == 0), **kw)
              assert r == {'FINISHED'}, r
      assert callable(bake_pairs)
      ```
      
      ### Game low: triangulate what you ship
      
      ```python
      # snippet: triangulate
      import bpy
      bpy.ops.mesh.primitive_cube_add(); low = bpy.context.active_object     # demo
      t = low.modifiers.new("Triangulate", 'TRIANGULATE')
      t.keep_custom_normals = True                               # Gambrell's "Keep Normals"
      t.quad_method = 'BEAUTY'                                    # whatever it is, export the same (apply or FBX use_triangles)
      assert t.keep_custom_normals
      ```
      
    • sources.md 12.3 KB
      # Sources
      
      Per-video notes live in `notes/uv-baking/` (and `notes/retopology/_digest_speedchar_game_character.md` for the SpeedChar production frame). Timestamps are [hh:mm:ss] into the video.
      
      ## Videos
      
      | Expert, credential                                                                                        | Video                                                                                                              | Blender         | Best for                                                                                                                                                         | Best timestamps                                                                                                                                                                                                               |
      | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
      | Jonathan Lampel, CG Cookie instructor, pipeline and add-on developer at Orange Turbine, author of UV Flow | [Unwrapping a Faster UV Workflow, BCON23](https://www.youtube.com/watch?v=KCKro3eGmLM) (49 min, 2023)              | 3.6/4.0         | Production judgment: method choice, every sharp edge is a seam for baked assets, straighten-pin-unwrap recipe, pack rotation, margin res/128, pack per material  | [00:16:28] methods, [00:19:23] sharp = seam, [00:21:56] Live Unwrap resets work, [00:30:09]-[00:33:32] straightening, [00:35:16] Cardinal rotation, [00:37:29] margin                                                         |
      | Jim Morren, CG Boost instructor (Master 3D Characters)                                                    | [UV Unwrapping in Blender, full tutorial](https://www.youtube.com/watch?v=JwkgVckqGw4) (60 min, 2025)              | 4.3+            | Primitive decomposition (cube, cylinder, ring, sphere), holes, pins, head and eye seams, texel priority, Mirror U                                                | [00:17:53] cylinder rule, [00:24:17] active quad must be straight, [00:30:14] sphere poles, [00:38:27] through-holes, [00:43:56] methods on Suzanne, [00:47:16]-[00:51:37] head seam map, [00:55:26] eye, [00:57:36] Mirror U |
      | On Mars 3D, game-art school instructor                                                                    | [Blender 5.0: How to UV Unwrap Anything](https://www.youtube.com/watch?v=dm3bBpZVmnE) (29 min, 2026)               | 5.0             | Only 5.x UV video: apply scale, walls with thickness, Sub-D UV smooth, method switch on hard surface, 5.0 layout tools                                           | [00:02:20] apply scale, [00:11:30] inner wall seam, [00:13:24] UV Smooth, [00:17:28] Conformal on hard surface, [00:18:26] one missed edge, [00:21:56] 5.0 tools                                                              |
      | Josh Gambrell, Blender Bros founder, hard-surface instructor                                              | [Become a BOSS at UV Unwrapping](https://www.youtube.com/watch?v=HDURGTLNu2Q) (1 h 55, 2021)                       | ~3.0            | Hard-surface game pipeline: bake vs model, one-segment low bevels, ring rule, overlapping repeats, texture sets, coverage, density matching, triangulate, naming | [00:07:06] one-segment bevel, [01:02:31] sharp seams + Conformal, [01:12:17] ring rule, [01:15:03] overlaps, [01:24:51] texture sets, [01:31:56] >70% coverage, [01:35:11] scale the higher set down, [01:41:07] triangulate  |
      | Juan Hernandez, CG Boost creature artist                                                                  | [Introduction to UDIMs](https://www.youtube.com/watch?v=55sGQLX7iho) (21 min, 2021)                                | 2.93            | UDIM numbering, no island across tiles, tile count from the closest camera, tiling detail belongs in the shader                                                  | [00:02:05] numbering, [00:05:54] the one rule, [00:07:05] closest camera, [00:15:54] shader tiling                                                                                                                            |
      | Tristan, Huge Menace                                                                                      | [Texel Density Masterclass](https://www.youtube.com/watch?v=sR1sAWWT2x8) (19 min, 2024)                            | ~4.1            | Game density targets, density first then texture size, strict vs soft target, atlas, non-square                                                                  | [00:05:41] density is not fidelity, [00:12:28] targets, [00:15:30] choose size from density, [00:16:15]-[00:17:44] five options                                                                                               |
      | Julien Kaspar, Blender Studio character artist (Rain, Snow, Sprite Fright)                                | [Snow UV Unwrapping Live #1](https://www.youtube.com/watch?v=_LI28r-Nk5g) (2 h 13, 2021)                           | 2.93            | Film character body: seam placement, pin + Live Unwrap relaxation, closed eyelids, symmetry, 3 UDIM tiles                                                        | [00:10:01] hide seams, [00:14:44] UDIM plan, [00:36:45] separate wrapping sides, [00:50:33] pins, [00:56:16] close eyes in UV, [01:40:04] two pins per island, [02:05:34] Mirror U + Flip UDIM                                |
      | Julien Kaspar                                                                                             | [Snow UV Unwrapping Live #2](https://www.youtube.com/watch?v=xZsrMfCI1Wg) (2 h 08, 2021)                           | 2.93            | Clothing UVs on sewing lines, straightened fabric grain, intentional density policy                                                                              | [00:17:28] Live Unwrap instability = missing seam, [00:53:19] fabric direction, [01:09:36] overlapping pins, [01:28:32] eyes denser than shirt                                                                                |
      | Julien Kaspar                                                                                             | [Snow UV Unwrapping Live #3](https://www.youtube.com/watch?v=8wmhYarIyjw) (2 h 11, 2021)                           | 2.93            | Secondary objects (eyes, teeth, hair), UVs along shape flow, why games triangulate before baking, film vs game symmetry                                          | [00:26:11] triangulate before baking, [01:28:42] secondary UV map, [01:31:27] unpin thickness loops, [01:58:10] games ignore symmetry                                                                                         |
      | Ryan King, Blender educator                                                                               | [Texture Bake High Poly Details to a Low Poly Mesh](https://www.youtube.com/watch?v=Zxl38NpPWdo) (14 min, 2024)    | ~4.1            | Baseline selected-to-active normal bake and the extrusion fix                                                                                                    | [00:05:16] island size = texels, [00:07:33] 32-bit float Non-Color, [00:09:49] extrusion 0.01, [00:10:26] raised to 0.02, [00:11:32] smallest that works                                                                      |
      | On Mars 3D                                                                                                | [5 Tips to Master Texture Bakes Like a Pro](https://www.youtube.com/watch?v=C_RqdNbYOjE) (20 min, 2024)            | 4.0 + Substance | Game bake judgment: silhouette, cylinder sides, hard edges need seams, gradients vs compression, padding, exploded bakes, cage, match by name                    | [00:03:48] 6 min / 8 recommended sides, [00:08:04] seams on hard edges, [00:08:36] gradients, [00:11:18] 16 px at 2K, [00:12:36] explode, [00:14:16] custom cage, [00:17:46] match by name                                    |
      | Grant Abbitt, GameDev.tv educator                                                                         | [Turn Low Poly Models into AAA Game Assets, Blender 5](https://www.youtube.com/watch?v=LvFilHJfinU) (27 min, 2026) | 5.1.2           | Multires bake in 5.x: even base faces, Linear subdivide, Conform Base, viewport vs render level, displacement as a mask                                          | [00:02:39] face size, [00:03:33] Linear, [00:18:25] 32-bit is for film, [00:19:32] Conform Base, [00:20:38] Bake from Multires, [00:23:52] displacement as cavity                                                             |
      | Nikolay Naydenov (SpeedChar), character artist at Gameloft Bulgaria                                       | [Making UVs, ep36](https://www.youtube.com/watch?v=mcEcWwcPtmA) (51 min, 2022)                                     | 3.1             | Texture budget first, no overlaps, checker judgment, non-manifold check before UVs                                                                               | [00:00:00] budget, [00:05:17] overlaps are a no-no, [00:09:34] checker rule, [00:25:07] non-manifold                                                                                                                          |
      | Nikolay Naydenov                                                                                          | [Finish UVs and baking, ep37](https://www.youtube.com/watch?v=qFMwPSv5KwA) (64 min, 2022)                          | 3.1 + Substance | Texel priority from the camera, unique UVs where AO differs, material = texture set name, test bake 512 then 2048                                                | [00:04:29] unique vs mirrored, [00:11:05] priorities, [00:47:46] material naming, [00:59:09] test bake                                                                                                                        |
      | Nikolay Naydenov                                                                                          | [Baking fixes, ep38](https://www.youtube.com/watch?v=E6QMhGuhyoc) (40 min, 2022)                                   | 3.1 + Substance | Real artifact diagnosis loop and the ray-distance sweep trade-off                                                                                                | [00:01:36] black triangle = overlap, [00:04:56] AO ghost = shared UVs, [00:18:37] triangulation line, [00:21:25]-[00:28:04] distance sweep                                                                                    |
      
      ## Web references (read by the distiller)
      
      - Blender Studio, [A UV Unwrapping Guide](https://studio.blender.org/blog/a-uv-unwrapping-guide/) (Julien Kaspar with Simon Thommes, 2024-10-28): more seams rather than fewer for 3D paint and bakes, angle past green means tweak, Live Unwrap is stored in the Scene and breaks on flipped UVs, symmetric maps via the Mirror modifier, secondary UV maps for tiled patterns, fabric-pattern clothing UVs. Its paywalled follow-ups were not read.
      - Blender 5.2 Manual, [Render Baking](https://docs.blender.org/manual/en/latest/render/cycles/baking.html): cage and extrusion, Max Ray Distance, margin types, Clear Image, Bake from Multires, memory note on many high objects.
      - Polycount wiki, [Normal map](http://wiki.polycount.com/wiki/Normal_map) and its Technical Details page (swizzle table: Blender, Maya, Unity, Toolbag Y+; 3ds Max, Unreal, CryEngine, Source Y-), and [Texture Baking](http://wiki.polycount.com/wiki/Texture_Baking): split UVs on every hard edge, offset overlaps exactly 1 UV unit, triangulate before baking and mirror the triangulation, bake 16-bit with anti-aliasing, do not edit UVs after baking.
      
      ## Digests
      
      - `notes/uv-baking/_digest_uv_unwrapping.md` (Lampel, Morren, On Mars 3D, Gambrell, Hernandez, Huge Menace; verification scripts in `archive/tests/uv_unwrapping/`).
      - `notes/uv-baking/_digest_uv_snow_and_baking.md` (Kaspar x3, Ryan King, On Mars 3D bakes, Abbitt, web references; scripts in `archive/tests/uv_snow_and_baking/`).
      - `notes/retopology/_digest_speedchar_game_character.md` (SpeedChar production frame, bake diagnosis).
      
  • scripts
    • bx_uvbake.py 105 KB
      """
      bx_uvbake: UV unwrapping, UV QA and high-to-low baking for Blender 5.2 (headless-safe).
      
      Every public function works from Object Mode, restores selection, active object, modes and
      render settings it touched, and returns a plain dict an agent can print as JSON.
      
        import sys; sys.path.append("<skills>/scenario-blender-uv-baking/scripts"); import bx_uvbake as ub
      
        # seams
        ub.seams_from_sharp(obj, angle_deg=30)            # hard-surface: every hard edge is a seam
        ub.seams_from_groups(obj, "FACE_SETS")            # organic: seams on region borders
        ub.cut_to_disks(obj, view_dirs=[(0, -1, 0)])      # ring / closed-shape rule, hidden side
        ub.island_topology(obj)                           # which islands are not disks yet
        # unwrap, density, pack
        ub.unwrap(obj, method="ANGLE_BASED")              # or "MINIMUM_STRETCH", "CONFORMAL", "BEST"
        ub.straighten(obj)                                # grid islands: Follow Active Quads + pins
        ub.normalize_texel_density(obj, 512, 2048)        # px/m target, optional priority weights
        ub.pack(obj, tex_res=2048)                        # pixel gap = res/128, no tilted islands
        ub.uv_qa(obj, tex_res=2048); ub.uv_verdict(report)
        ub.uv_layout_image(obj, "/abs/uv.png"); ub.checker_review([obj], "/abs/dir")
        # bake
        ub.prepare_low(low)                               # smooth shading, scale, optional sharps
        r = ub.bake_high_to_low(low, [high], "/abs/dir", res=2048, maps=("NORMAL", "AO"))
        r["paths"]["NORMAL"], r["sanity"]["NORMAL"]["problems"]
        ub.bake_multires(obj, "/abs/dir"); ub.bake_id(...); ub.curvature_from_normal(...)
        ub.bake_review(low, [high], {"NORMAL": path, "AO": path}, "/abs/dir")
      
      Tested on Blender 5.2.1 LTS (tests/code/blender-uv-baking/). Facts the code relies on
      (all verified there): uv.unwrap defaults to CONFORMAL and returns FINISHED even when an
      island fails to solve; pack_islands FRACTION margin m leaves 2m between islands and m to
      the tile border; bake writes only to an image node that is selected AND active and
      returns CANCELLED silently otherwise; selected-to-active evaluates the low with RENDER
      settings (Multires render_levels), measures max_ray_distance from the extruded start,
      never lets the active low occlude its own AO, but every other render-visible object does.
      """
      
      __version__ = "0.1"  # Blender Expert Skills v0.1 (2026-09-24)
      import colorsys
      import heapq
      import math
      import os
      from contextlib import contextmanager
      
      import bmesh
      import bpy
      import numpy as np
      from mathutils import Vector
      from mathutils.bvhtree import BVHTree
      
      ADAPTIVE_FACTORS = (0.05, 0.15, 0.35, 0.7, 1.0)
      # Game texel density targets in px/m (Huge Menace, sR1sAWWT2x8 [00:12:28])
      TD_TARGETS = {"fps": 1024.0, "fps_weapon": 2048.0, "third_person": 512.0, "top_down": 256.0}
      SENTINEL = -7.0
      
      
      # --------------------------------------------------------------------------- helpers
      def _object_mode():
          if bpy.context.object is not None and bpy.context.object.mode != "OBJECT":
              bpy.ops.object.mode_set(mode="OBJECT")
      
      
      def _layer(me, uv_map=None):
          if uv_map:
              return me.uv_layers[uv_map]
          if me.uv_layers.active is None:
              raise ValueError(f"mesh {me.name} has no UV map")
          return me.uv_layers.active
      
      
      def _get_uv(me, uv_map=None):
          lay = _layer(me, uv_map)
          a = np.empty(len(me.loops) * 2, dtype=np.float64)
          lay.uv.foreach_get("vector", a)
          return a.reshape(-1, 2)
      
      
      def _set_uv(me, uv, uv_map=None):
          _layer(me, uv_map).uv.foreach_set("vector", np.ascontiguousarray(uv, dtype=np.float64).ravel())
          me.update()
      
      
      def _get_pins(me, uv_map=None):
          lay = _layer(me, uv_map)
          att = me.attributes.get(".pn." + lay.name)
          if att is None:
              return np.zeros(len(me.loops), dtype=bool)
          a = np.zeros(len(me.loops), dtype=bool)
          att.data.foreach_get("value", a)
          return a
      
      
      def _set_pins(me, pins, uv_map=None):
          lay = _layer(me, uv_map)
          name = ".pn." + lay.name
          att = me.attributes.get(name) or me.attributes.new(name, "BOOLEAN", "CORNER")
          att.data.foreach_set("value", np.ascontiguousarray(pins, dtype=bool))
      
      
      @contextmanager
      def _edit_mode(objs, uv_map=None, select_all=True):
          """Enter Edit Mode on objs with UV sync selection on; restore everything after."""
          vl = bpy.context.view_layer
          _object_mode()
          prev_sel = [o for o in vl.objects if o.select_get()]
          prev_act = vl.objects.active
          hidden = [o for o in objs if o.hide_get()]
          for o in hidden:
              o.hide_set(False)
          prev_uv = {o.name: o.data.uv_layers.active_index for o in objs}
          if uv_map:
              for o in objs:
                  o.data.uv_layers.active = o.data.uv_layers[uv_map]
          ts = bpy.context.scene.tool_settings
          prev_sync = ts.use_uv_select_sync
          ts.use_uv_select_sync = True
          for o in vl.objects:
              o.select_set(False)
          for o in objs:
              o.select_set(True)
          vl.objects.active = objs[0]
          bpy.ops.object.mode_set(mode="EDIT")
          if select_all:
              bpy.ops.mesh.select_all(action="SELECT")
          try:
              yield
          finally:
              bpy.ops.object.mode_set(mode="OBJECT")
              ts.use_uv_select_sync = prev_sync
              for o in objs:
                  o.data.uv_layers.active_index = prev_uv[o.name]
              for o in hidden:
                  o.hide_set(True)
              for o in vl.objects:
                  o.select_set(o in prev_sel)
              vl.objects.active = prev_act
      
      
      class _Arrays:
          """Numpy view of a mesh: world positions, loops, faces, UVs."""
      
          def __init__(self, obj, uv_map=None, evaluated=False):
              self._eo = None
              if evaluated:
                  dg = bpy.context.evaluated_depsgraph_get()
                  self._eo = obj.evaluated_get(dg)
                  me = self._eo.to_mesh()
                  mw = self._eo.matrix_world
              else:
                  me = obj.data
                  mw = obj.matrix_world
              self.me = me
              nv, nl, nf = len(me.vertices), len(me.loops), len(me.polygons)
              co = np.empty(nv * 3)
              me.vertices.foreach_get("co", co)
              co = co.reshape(-1, 3)
              M = np.array(mw)
              self.co = co @ M[:3, :3].T + M[:3, 3]
              self.lv = np.empty(nl, dtype=np.int64)
              me.loops.foreach_get("vertex_index", self.lv)
              self.le = np.empty(nl, dtype=np.int64)
              me.loops.foreach_get("edge_index", self.le)
              self.ls = np.empty(nf, dtype=np.int64)
              me.polygons.foreach_get("loop_start", self.ls)
              self.lt = np.empty(nf, dtype=np.int64)
              me.polygons.foreach_get("loop_total", self.lt)
              self.nf, self.nl, self.ne = nf, nl, len(me.edges)
              self.lf = np.repeat(np.arange(nf), self.lt)
              pos = np.arange(nl) - self.ls[self.lf]
              self.nxt = self.ls[self.lf] + (pos + 1) % self.lt[self.lf]
              self.prv = self.ls[self.lf] + (pos - 1) % self.lt[self.lf]
              self.uv = _get_uv(me, uv_map) if me.uv_layers else None
      
          def free(self):
              if self._eo is not None:
                  self._eo.to_mesh_clear()
                  self._eo = None
      
      
      def _union_find(n, a, b):
          parent = list(range(n))
      
          def find(x):
              while parent[x] != x:
                  parent[x] = parent[parent[x]]
                  x = parent[x]
              return x
      
          for x, y in zip(a.tolist(), b.tolist()):
              rx, ry = find(x), find(y)
              if rx != ry:
                  parent[rx] = ry
          roots = np.array([find(i) for i in range(n)], dtype=np.int64)
          return np.unique(roots, return_inverse=True)[1]
      
      
      def _edge_pairs(A):
          """Loops sharing an edge: returns (edge_ids, c1, c2) for edges used by exactly 2 loops."""
          order = np.argsort(A.le, kind="stable")
          les = A.le[order]
          uniq, start, cnt = np.unique(les, return_index=True, return_counts=True)
          two = cnt == 2
          return uniq[two], order[start[two]], order[start[two] + 1]
      
      
      def _uv_islands(A, eps=1e-5):
          """UV islands (faces connected by edges whose UVs match on both sides) and the
          set of UV-continuous edge ids."""
          e, c1, c2 = _edge_pairs(A)
          uv = A.uv
          opp = A.lv[c1] == A.lv[A.nxt[c2]]
          m_opp = (np.abs(uv[c1] - uv[A.nxt[c2]]).max(1) < eps) & (np.abs(uv[A.nxt[c1]] - uv[c2]).max(1) < eps)
          m_same = (np.abs(uv[c1] - uv[c2]).max(1) < eps) & (np.abs(uv[A.nxt[c1]] - uv[A.nxt[c2]]).max(1) < eps)
          con = np.where(opp, m_opp, m_same)
          labels = _union_find(A.nf, A.lf[c1][con], A.lf[c2][con])
          return labels, e[con]
      
      
      def _seam_islands(obj):
          """Face groups separated by seams, mesh borders and non-manifold edges."""
          me = obj.data
          A = _Arrays(obj)
          seam = np.zeros(len(me.edges), dtype=bool)
          me.edges.foreach_get("use_seam", seam)
          e, c1, c2 = _edge_pairs(A)
          keep = ~seam[e]
          return _union_find(A.nf, A.lf[c1][keep], A.lf[c2][keep])
      
      
      def _face_measures(A):
          uv, nxt = A.uv, A.nxt
          cr = uv[:, 0] * uv[nxt, 1] - uv[nxt, 0] * uv[:, 1]
          a_uv = 0.5 * np.bincount(A.lf, cr, minlength=A.nf)
          P = A.co[A.lv]
          c = np.cross(P, P[nxt])
          a3 = 0.5 * np.linalg.norm(np.stack([np.bincount(A.lf, c[:, k], minlength=A.nf) for k in range(3)], 1), axis=1)
          return a_uv, a3
      
      
      def _corner_angles(X, A):
          a = X[A.prv] - X
          b = X[A.nxt] - X
          na = np.linalg.norm(a, axis=1)
          nb = np.linalg.norm(b, axis=1)
          ok = (na > 1e-12) & (nb > 1e-12)
          cos = np.einsum("ij,ij->i", a, b) / np.where(ok, na * nb, 1.0)
          return np.arccos(np.clip(cos, -1, 1)), ok
      
      
      def _wpct(values, weights, q):
          if len(values) == 0:
              return float("nan")
          o = np.argsort(values)
          v, w = values[o], weights[o]
          cw = np.cumsum(w)
          return float(v[min(np.searchsorted(cw, q * cw[-1]), len(v) - 1)])
      
      
      def _tris(A, uv=None):
          """Triangulation of every face: (T,3) loop indices and owning face. Quads are split on
          the diagonal that keeps both UV triangles the same orientation (a fan over a concave
          UV quad would fake an overlap); n-gons are fanned."""
          t0, t1, t2, tf = [], [], [], []
          for n in np.unique(A.lt):
              faces = np.nonzero(A.lt == n)[0]
              s = A.ls[faces]
              if n == 4 and uv is not None:
                  def sa(i, j, k):
                      a, b, c = uv[s + i], uv[s + j], uv[s + k]
                      return (b[:, 0] - a[:, 0]) * (c[:, 1] - a[:, 1]) - (c[:, 0] - a[:, 0]) * (b[:, 1] - a[:, 1])
                  d02 = np.minimum(sa(0, 1, 2) * np.sign(sa(0, 2, 3) + sa(0, 1, 2)), sa(0, 2, 3) * np.sign(sa(0, 2, 3) + sa(0, 1, 2)))
                  d13 = np.minimum(sa(1, 2, 3) * np.sign(sa(1, 3, 0) + sa(1, 2, 3)), sa(1, 3, 0) * np.sign(sa(1, 3, 0) + sa(1, 2, 3)))
                  alt = d13 > d02
                  base = np.where(alt, 1, 0)
                  for k in (1, 2):
                      t0.append(s + base)
                      t1.append(s + (base + k) % 4)
                      t2.append(s + (base + k + 1) % 4)
                      tf.append(faces)
                  continue
              for k in range(1, n - 1):
                  t0.append(s)
                  t1.append(s + k)
                  t2.append(s + k + 1)
                  tf.append(faces)
          return np.stack([np.concatenate(t0), np.concatenate(t1), np.concatenate(t2)], 1), np.concatenate(tf)
      
      
      def _raster(tri_uv, tri_label, res, tile=(0, 0)):
          """Rasterize UV triangles into one tile: coverage count, label map, overlapping label pairs."""
          cnt = np.zeros((res, res), dtype=np.uint16)
          lab = np.full((res, res), -1, dtype=np.int64)
          pairs = set()
          P = (tri_uv - np.array(tile, dtype=np.float64)) * res
          lo = np.floor(P.min(1) - 0.5).astype(np.int64)
          hi = np.ceil(P.max(1) - 0.5).astype(np.int64)
          inside_tile = (hi[:, 0] >= 0) & (hi[:, 1] >= 0) & (lo[:, 0] < res) & (lo[:, 1] < res)
          for t in np.nonzero(inside_tile)[0]:
              x0, y0 = max(lo[t, 0], 0), max(lo[t, 1], 0)
              x1, y1 = min(hi[t, 0], res - 1), min(hi[t, 1], res - 1)
              if x1 < x0 or y1 < y0:
                  continue
              (ax, ay), (bx, by), (cx, cy) = P[t]
              d = (bx - ax) * (cy - ay) - (cx - ax) * (by - ay)
              if abs(d) < 1e-12:
                  continue
              X, Y = np.meshgrid(np.arange(x0, x1 + 1) + 0.5, np.arange(y0, y1 + 1) + 0.5)
              w0 = ((bx - X) * (cy - Y) - (cx - X) * (by - Y)) / d
              w1 = ((cx - X) * (ay - Y) - (ax - X) * (cy - Y)) / d
              ins = (w0 > 0) & (w1 > 0) & (1 - w0 - w1 > 0)
              if not ins.any():
                  continue
              sub_l = lab[y0:y1 + 1, x0:x1 + 1]
              other = sub_l[ins]
              other = other[(other >= 0) & (other != tri_label[t])]
              if len(other):
                  for o in np.unique(other).tolist():
                      pairs.add((min(o, int(tri_label[t])), max(o, int(tri_label[t]))))
              cnt[y0:y1 + 1, x0:x1 + 1][ins] += 1
              sub_l[ins] = tri_label[t]
          return cnt, lab, pairs
      
      
      def _min_gap(lab, cap):
          """Smallest gap (px) between differently labelled regions, by simultaneous dilation.
          Returns a value <= 2*cap+1, or None when every gap is larger."""
          def touching(L):
              h = (L[:, 1:] != L[:, :-1]) & (L[:, 1:] >= 0) & (L[:, :-1] >= 0)
              v = (L[1:, :] != L[:-1, :]) & (L[1:, :] >= 0) & (L[:-1, :] >= 0)
              return h.any() or v.any()
      
          L = lab.copy()
          if touching(L):
              return 0
          for k in range(1, cap + 1):
              N = L.copy()
              for sl_dst, sl_src in (((slice(None), slice(1, None)), (slice(None), slice(None, -1))),
                                     ((slice(None), slice(None, -1)), (slice(None), slice(1, None))),
                                     ((slice(1, None), slice(None)), (slice(None, -1), slice(None))),
                                     ((slice(None, -1), slice(None)), (slice(1, None), slice(None)))):
                  dst, src = N[sl_dst], L[sl_src]
                  m = (dst < 0) & (src >= 0)
                  dst[m] = src[m]
              if k % 2 == 0:  # alternate 4- and 8-neighbourhood: closer to Euclidean growth
                  for sy, sx in ((1, 1), (1, -1), (-1, 1), (-1, -1)):
                      src = np.roll(np.roll(L, sy, 0), sx, 1)
                      m = (N < 0) & (src >= 0)
                      N[m] = src[m]
              L = N
              if touching(L):
                  return 2 * k - 1
          return None
      
      
      # --------------------------------------------------------------------------- seams
      def seams_from_sharp(obj, angle_deg=30.0, use_sharp_attr=True, mark_sharp=False, clear_first=False):
          """Hard-surface rule (Lampel, Gambrell, On Mars 3D): every hard edge is a UV seam.
          Hard = dihedral angle above angle_deg, or flagged sharp (sharp_edge attribute).
          mark_sharp=True also flags those edges sharp, so shading splits exactly on seams."""
          _object_mode()
          me = obj.data
          bm = bmesh.new()
          bm.from_mesh(me)
          thr = math.radians(angle_deg)
          added = 0
          for e in bm.edges:
              if clear_first:
                  e.seam = False
              if not e.is_manifold:
                  continue
              hard = e.calc_face_angle(0.0) > thr or (use_sharp_attr and not e.smooth)
              if hard:
                  if not e.seam:
                      added += 1
                  e.seam = True
                  if mark_sharp:
                      e.smooth = False
          bm.to_mesh(me)
          bm.free()
          me.update()
          return {"seams_added": added, "seams_total": sum(e.use_seam for e in me.edges)}
      
      
      def _face_labels(obj, groups):
          me = obj.data
          n = len(me.polygons)
          if isinstance(groups, str):
              if groups == "MATERIAL":
                  a = np.empty(n, dtype=np.int64)
                  me.polygons.foreach_get("material_index", a)
                  return a
              name = ".sculpt_face_set" if groups == "FACE_SETS" else groups
              att = me.attributes.get(name)
              if att is None or att.domain != "FACE":
                  raise ValueError(f"no face attribute {name!r} on {obj.name}")
              a = np.empty(n, dtype=np.int64 if att.data_type == "INT" else np.float64)
              att.data.foreach_get("value", a)
              return a
          a = np.asarray(groups)
          if len(a) != n:
              raise ValueError("groups needs one label per face")
          return a
      
      
      def seams_from_groups(obj, groups="FACE_SETS", clear_first=False, faces=None):
          """Seam every edge between faces of different groups. groups: 'FACE_SETS', 'MATERIAL',
          a face attribute name, or one label per face (e.g. part ids an agent computed).
          faces: restrict to edges inside this face subset (e.g. one island that folds)."""
          _object_mode()
          me = obj.data
          lab = _face_labels(obj, groups)
          sub = None if faces is None else set(int(i) for i in faces)
          bm = bmesh.new()
          bm.from_mesh(me)
          added = 0
          for e in bm.edges:
              if clear_first:
                  e.seam = False
              if sub is not None and not all(f.index in sub for f in e.link_faces):
                  continue
              if len(e.link_faces) == 2 and lab[e.link_faces[0].index] != lab[e.link_faces[1].index]:
                  added += int(not e.seam)
                  e.seam = True
          bm.to_mesh(me)
          bm.free()
          me.update()
          return {"seams_added": added, "groups": int(len(np.unique(lab)))}
      
      
      def seams_from_uv_islands(obj, clear_first=True, uv_map=None):
          """Lampel: 'Seams from Islands' before re-unwrapping, or the existing layout's islands
          are thrown away. Also the way to recover real UV seams when the file's seam flags are
          leftover retopology marks (check with island_topology)."""
          _object_mode()
          if clear_first:
              for e in obj.data.edges:
                  e.use_seam = False
          with _edit_mode([obj], uv_map):
              bpy.ops.uv.seams_from_islands(mark_seams=True, mark_sharp=False)
          return {"seams_total": sum(e.use_seam for e in obj.data.edges)}
      
      
      def _visibility(bm, obj, view_dirs):
          """Per face: how much it faces the given view directions, graded 0 (pointing straight
          away) to 1 (facing the viewer), so the most hidden path wins, not any back-facing one."""
          N = np.array(obj.matrix_world.to_3x3().inverted().transposed())
          D = np.array([Vector(d).normalized() for d in view_dirs]) if view_dirs else np.zeros((0, 3))
          vis = np.zeros(len(bm.faces))
          if len(D) == 0:
              return vis
          fn = np.array([f.normal for f in bm.faces]) @ N.T
          fn /= np.maximum(np.linalg.norm(fn, axis=1, keepdims=True), 1e-12)
          return ((fn @ D.T + 1.0) / 2.0).max(1)
      
      
      def _edge_cost(e, vis, floor=0.05):
          v = np.mean([vis[f.index] for f in e.link_faces]) if e.link_faces else 1.0
          return e.calc_length() * (floor + v * v)
      
      
      def _dijkstra(sources, targets, allowed, cost):
          """Shortest edge path over bm verts. sources/targets: sets of BMVerts; allowed(e) bool."""
          dist = {v: 0.0 for v in sources}
          prev = {}
          heap = [(0.0, v.index, v) for v in sources]
          heapq.heapify(heap)
          done = set()
          while heap:
              d, _, v = heapq.heappop(heap)
              if v in done:
                  continue
              done.add(v)
              if v in targets:
                  path = []
                  while v in prev:
                      e, v = prev[v]
                      path.append(e)
                  return path
              for e in v.link_edges:
                  if not allowed(e):
                      continue
                  w = e.other_vert(v)
                  nd = d + cost(e)
                  if nd < dist.get(w, float("inf")):
                      dist[w] = nd
                      prev[w] = (e, v)
                      heapq.heappush(heap, (nd, w.index, w))
          return None
      
      
      def seam_path(obj, v_from, v_to, view_dirs=((0, -1, 0),)):
          """Mark the least visible shortest edge path between two vertex indices as a seam
          (the headless stand-in for Ctrl+click Tag Seam)."""
          _object_mode()
          bm = bmesh.new()
          bm.from_mesh(obj.data)
          bm.verts.ensure_lookup_table()
          vis = _visibility(bm, obj, view_dirs)
          path = _dijkstra({bm.verts[v_from]}, {bm.verts[v_to]}, lambda e: True, lambda e: _edge_cost(e, vis))
          for e in path or []:
              e.seam = True
          bm.to_mesh(obj.data)
          bm.free()
          obj.data.update()
          return {"edges": len(path or []), "found": path is not None}
      
      
      def _topology(bm, island_faces):
          """Euler characteristic of the island cut open along its seams, boundary loop count."""
          fset = set(island_faces)
          verts = {v for f in island_faces for v in f.verts}
          edges = {e for f in island_faces for e in f.edges}
      
          def interior(e):
              lf = e.link_faces
              return len(lf) == 2 and not e.seam and lf[0] in fset and lf[1] in fset
      
          E = sum(1 if interior(e) else sum(1 for f in e.link_faces if f in fset) for e in edges)
          V = 0
          for v in verts:
              fs = [f for f in v.link_faces if f in fset]
              par = {f: f for f in fs}
      
              def find(x):
                  while par[x] is not x:
                      par[x] = par[par[x]]
                      x = par[x]
                  return x
      
              for e in v.link_edges:
                  if interior(e):
                      a, b = e.link_faces
                      par[find(a)] = find(b)
              V += len({find(f) for f in fs})
          chi = V - E + len(island_faces)
          bedges = [e for e in edges if not interior(e)]
          par = {}
      
          def find2(x):
              par.setdefault(x, x)
              while par[x] is not x:
                  par[x] = par[par[x]]
                  x = par[x]
              return x
      
          for e in bedges:
              a, b = e.verts
              par[find2(a)] = find2(b)
          loops = len({find2(v) for e in bedges for v in e.verts})
          return chi, loops, bedges, interior
      
      
      def _kind(chi, loops):
          genus = (2 - chi - loops) // 2
          if chi == 1:
              return "disk", genus
          if loops == 0:
              return ("closed" if genus == 0 else "closed_handle"), genus
          if genus > 0:
              return "handle", genus
          return ("ring" if loops == 2 else "holes"), genus
      
      
      def island_topology(obj):
          """Per seam island: faces, Euler characteristic chi, boundary loops and genus.
          disk (chi 1) always flattens; ring (2 loops) and holes (3+ loops, genus 0) flatten as a
          planar shape with holes, fine for a flat washer or a face with eye holes, badly for a
          long tube (measure it: cut_to_disks does); closed (no boundary) cannot flatten at all;
          handle / closed_handle (genus > 0, e.g. a torus) need loop cuts."""
          _object_mode()
          labels = _seam_islands(obj)
          bm = bmesh.new()
          bm.from_mesh(obj.data)
          bm.faces.ensure_lookup_table()
          out = []
          for k in range(labels.max() + 1 if len(labels) else 0):
              faces = [bm.faces[i] for i in np.nonzero(labels == k)[0]]
              chi, loops, _, _ = _topology(bm, faces)
              kind, genus = _kind(chi, loops)
              out.append({"island": k, "faces": len(faces), "chi": chi, "loops": loops, "genus": genus, "kind": kind})
          bm.free()
          return {"islands": len(out), "not_disk": [i for i in out if i["kind"] != "disk"],
                  "must_cut": [i for i in out if i["kind"] in ("closed", "closed_handle", "handle")], "all": out}
      
      
      def _island_quality(obj, face_idx, uv_name):
          """Unwrap one island alone (Angle Based, fill holes) and measure it; UVs restored."""
          me = obj.data
          old = _get_uv(me, uv_name).copy()
          fs = set(int(i) for i in face_idx)
          with _edit_mode([obj], uv_name, select_all=False):
              bm = bmesh.from_edit_mesh(me)
              for f in bm.faces:
                  f.select_set(f.index in fs)
              bmesh.update_edit_mesh(me)
              bpy.ops.uv.unwrap(method="ANGLE_BASED", fill_holes=True, margin=0.001)
          A = _Arrays(obj, uv_name)
          a_uv, a3 = _face_measures(A)
          fm = np.zeros(A.nf, dtype=bool)
          fm[list(fs)] = True
          ang_uv, ok1 = _corner_angles(np.c_[A.uv, np.zeros(A.nl)], A)
          ang_3d, ok2 = _corner_angles(A.co[A.lv], A)
          lm = fm[A.lf] & ok1 & ok2
          td = np.sqrt(np.abs(a_uv[fm]) / np.maximum(a3[fm], 1e-18))
          spread = _wpct(td, a3[fm], 0.95) / max(_wpct(td, a3[fm], 0.05), 1e-12)
          ang95 = float(np.percentile(np.degrees(np.abs(ang_uv - ang_3d)[lm]), 95)) if lm.any() else 0.0
          flipped = int((a_uv[fm] < 0).sum())
          A.free()
          _set_uv(me, old, uv_name)
          return spread, ang95, flipped
      
      
      def cut_to_disks(obj, view_dirs=((0, -1, 0),), strict=False, max_spread=2.0, max_angle_p95=20.0, max_cuts=200):
          """Add the least visible seams an island needs to flatten well.
          Always: closed shells get a slit (Morren: anything closed on itself needs a cut), genus > 0
          (torus, handle) gets its loops via a tree-cotree cut. Islands with holes (ring, holes)
          are test-unwrapped first and cut between two boundaries only while their density spread
          exceeds max_spread or angle error p95 exceeds max_angle_p95 (a long tube: Gambrell's ring
          rule; a flat washer or a face with eye holes stays whole). strict=True cuts every island
          down to a disk. view_dirs: directions toward the viewer (front = (0,-1,0)); seams avoid
          faces facing them."""
          _object_mode()
          me = obj.data
          if not me.uv_layers:
              me.uv_layers.new(name="UVMap")
          uv_name = me.uv_layers.active.name
          report = {"cuts": 0, "kept_with_holes": [], "unsolved": []}
          accepted = set()
          for _ in range(max_cuts):
              labels = _seam_islands(obj)
              bm = bmesh.new()
              bm.from_mesh(me)
              bm.faces.ensure_lookup_table()
              vis = _visibility(bm, obj, view_dirs)
              cost = lambda e: _edge_cost(e, vis)
              changed = False
              for k in range(labels.max() + 1):
                  fidx = np.nonzero(labels == k)[0]
                  key = (len(fidx), int(fidx[0]))
                  if key in accepted:
                      continue
                  faces = [bm.faces[i] for i in fidx]
                  chi, loops, bedges, interior = _topology(bm, faces)
                  kind, genus = _kind(chi, loops)
                  if kind == "disk":
                      continue
                  fverts = {v for f in faces for v in f.verts}
                  path = None
                  if kind in ("ring", "holes"):
                      if not strict:
                          spread, ang95, flipped = _island_quality(obj, fidx, uv_name)
                          if spread <= max_spread and ang95 <= max_angle_p95 and flipped == 0:
                              accepted.add(key)
                              report["kept_with_holes"].append({"faces": len(fidx), "loops": loops, "spread": round(spread, 2),
                                                                "angle_p95": round(ang95, 1)})
                              continue
                      par = {}
      
                      def find(x):
                          par.setdefault(x, x)
                          while par[x] is not x:
                              par[x] = par[par[x]]
                              x = par[x]
                          return x
      
                      for e in bedges:
                          par[find(e.verts[0])] = find(e.verts[1])
                      comp = {}
                      for e in bedges:
                          for v in e.verts:
                              comp.setdefault(find(v), set()).add(v)
                      groups = sorted(comp.values(), key=lambda g: (len(g), min(v.index for v in g)))
                      path = _dijkstra(groups[0], set().union(*groups[1:]), interior, cost)
                  elif kind == "closed":
                      vs = sorted(fverts, key=lambda v: v.index)
                      vvis = np.array([np.mean([vis[f.index] for f in v.link_faces]) for v in vs])
                      a = vs[int(np.argmin(vvis))]
                      low = [v for v, sv in zip(vs, vvis) if sv <= np.median(vvis)]
                      far = max(low, key=lambda v: (v.co - a.co).length)
                      path = _dijkstra({a}, {far}, interior, cost)
                  elif kind == "closed_handle":
                      path = _tree_cotree(bm, faces, fverts, interior, cost, vis)
                  else:
                      report["unsolved"].append({"faces": len(fidx), "chi": chi, "loops": loops, "genus": genus,
                                                 "hint": "handle on an open island: seam a loop around it"})
                      accepted.add(key)
                      continue
                  if path:
                      for e in path:
                          e.seam = True
                      report["cuts"] += 1
                      changed = True
                      break
              bm.to_mesh(me)
              bm.free()
              me.update()
              if not changed:
                  break
          report["topology"] = island_topology(obj)["must_cut"]
          return report
      
      
      def _tree_cotree(bm, faces, fverts, interior, cost, vis):
          """Cut graph of a closed surface of genus g: 2g loops through the least visible vertex."""
          root = min(fverts, key=lambda v: (np.mean([vis[f.index] for f in v.link_faces]), v.index))
          dist, prev = {root: 0.0}, {}
          heap = [(0.0, root.index, root)]
          done = set()
          while heap:
              d, _, v = heapq.heappop(heap)
              if v in done:
                  continue
              done.add(v)
              for e in v.link_edges:
                  if not interior(e):
                      continue
                  w = e.other_vert(v)
                  nd = d + cost(e)
                  if nd < dist.get(w, float("inf")):
                      dist[w], prev[w] = nd, (e, v)
                      heapq.heappush(heap, (nd, w.index, w))
          tree = {e for e, _ in prev.values()}
          fset = set(faces)
          dual_edges = sorted([e for f in faces for e in f.edges if interior(e) and e not in tree],
                              key=lambda e: -cost(e))  # keep visible edges in the co-tree (uncut)
          par = {f: f for f in fset}
      
          def find(x):
              while par[x] is not x:
                  par[x] = par[par[x]]
                  x = par[x]
              return x
      
          left = []
          for e in dict.fromkeys(dual_edges):
              a, b = e.link_faces
              ra, rb = find(a), find(b)
              if ra is rb:
                  left.append(e)
              else:
                  par[ra] = rb
          cut = set()
          for e in left:
              cut.add(e)
              for v in e.verts:
                  while v in prev:
                      pe, v = prev[v]
                      cut.add(pe)
          return list(cut)
      
      
      # --------------------------------------------------------------------------- unwrap
      def _self_overlap(uv, T, tf, faces_mask, res=256):
          """Share of an island's covered pixels covered twice (global fold), island-normalized."""
          sel = faces_mask[tf]
          if not sel.any():
              return 0.0
          tri = uv[T[sel]]
          lo = tri.reshape(-1, 2).min(0)
          span = max(float((tri.reshape(-1, 2).max(0) - lo).max()), 1e-9)
          cnt, _, _ = _raster((tri - lo) / span * 0.999, np.zeros(len(tri), dtype=np.int64), res)
          cov = (cnt > 0).sum()
          return float((cnt > 1).sum()) / max(cov, 1)
      
      
      def _score(a_uv, a3, ang_err, faces_mask, loops_mask):
          td = np.sqrt(np.abs(a_uv[faces_mask]) / np.maximum(a3[faces_mask], 1e-18))
          w = a3[faces_mask]
          spread = _wpct(td, w, 0.95) / max(_wpct(td, w, 0.05), 1e-12)
          ang = float(np.degrees(ang_err[loops_mask].mean())) if loops_mask.any() else 0.0
          return ang / 5.0 + math.log2(max(spread, 1.0)), spread, ang
      
      
      def unwrap(obj, method="ANGLE_BASED", uv_map=None, faces=None, fill_holes=True, correct_aspect=True,
                 use_subsurf_data=False, iterations=10, weight_group=None, weight_factor=1.0):
          """Unwrap along the marked seams with an explicit method (5.2 defaults to CONFORMAL).
          method: 'ANGLE_BASED' (organic default), 'CONFORMAL' (flat hard-surface islands),
          'MINIMUM_STRETCH' (most even density), or 'BEST' = all three, kept per island by
          score = mean angle error / 5 deg + log2(density spread p95/p05).
          Detects islands the solver silently skipped ('Unwrap failed to solve') and restores
          their previous UVs. weight_group: vertex group whose weights give more texture space
          to heavy areas (MINIMUM_STRETCH only; 5.x importance weights).
          Islands that fold over themselves in every method (a limb flattened onto the body: no
          flipped face, still an overlap) are returned in folding_islands / folding_faces: add a
          seam that separates the branch (seams_from_groups(..., faces=folding_faces)) and
          unwrap again."""
          _object_mode()
          me = obj.data
          if not me.uv_layers:
              me.uv_layers.new(name=uv_map or "UVMap")
          lname = _layer(me, uv_map).name     # keep the NAME: layer references go stale after Edit Mode
          A = _Arrays(obj, lname)
          fsel = np.ones(A.nf, dtype=bool) if faces is None else np.isin(np.arange(A.nf), np.asarray(faces))
          lsel = fsel[A.lf]
          old = A.uv.copy()
          pins = _get_pins(me, lname)
          labels = _seam_islands(obj)
          methods = ["ANGLE_BASED", "CONFORMAL", "MINIMUM_STRETCH"] if method == "BEST" else [method]
          results = {}
          for m in methods:
              uv = old.copy()
              uv[lsel & ~pins] = SENTINEL
              _set_uv(me, uv, lname)
              with _edit_mode([obj], lname, select_all=False):
                  bm = bmesh.from_edit_mesh(me)
                  for f in bm.faces:
                      f.select_set(bool(fsel[f.index]))
                  bmesh.update_edit_mesh(me)
                  kw = dict(method=m, fill_holes=fill_holes, correct_aspect=correct_aspect,
                            use_subsurf_data=use_subsurf_data, margin_method="SCALED", margin=0.001)
                  if m == "MINIMUM_STRETCH":
                      kw["iterations"] = iterations
                      if weight_group:
                          kw.update(use_weights=True, weight_group=weight_group, weight_factor=weight_factor)
                  r = bpy.ops.uv.unwrap(**kw)
              new = _get_uv(me, lname)
              # 5.2.1: an island the solver cannot flatten (closed, no seam) keeps its previous UVs
              # (here the sentinel, repacked to a zero-area point) while the operator returns FINISHED.
              A.uv = new
              a_uv_n, a3_n = _face_measures(A)
              bad_f = fsel & (a3_n > 1e-14) & (np.abs(a_uv_n) < 1e-14)
              stuck = (np.all(new == SENTINEL, axis=1) | bad_f[A.lf]) & lsel
              new[stuck] = old[stuck]
              results[m] = (new, int(len(np.unique(A.lf[stuck]))), r)
          # pick per island
          per_island = {}
          final = old.copy()
          A.uv = None
          uv3 = {}
          for m, (uv, _, _) in results.items():
              A.uv = uv
              a_uv, a3 = _face_measures(A)
              ang_uv, ok1 = _corner_angles(np.c_[uv, np.zeros(len(uv))], A)
              ang_3d, ok2 = _corner_angles(A.co[A.lv], A)
              uv3[m] = (a_uv, a3, np.where(ok1 & ok2, np.abs(ang_uv - ang_3d), 0.0))
          tris = {m: _tris(A, results[m][0]) for m in results}
          folding = []
          for k in np.unique(labels[fsel]):
              fm = (labels == k) & fsel
              lm = fm[A.lf]
              scores = {}
              for m in results:
                  sc, sp, an = _score(*uv3[m], fm, lm)
                  ov = _self_overlap(results[m][0], *tris[m], fm)
                  scores[m] = (sc + 100.0 * ov, sp, an, ov)
              best = min(scores, key=lambda m: scores[m][0])
              final[lm] = results[best][0][lm]
              per_island[int(k)] = {m: {"score": round(s[0], 3), "spread": round(s[1], 2), "angle_err_deg": round(s[2], 2),
                                        "self_overlap": round(s[3], 4)} for m, s in scores.items()} | {"chosen": best}
              if scores[best][3] > 0.005:     # > 0.5% of the island's pixels: a real fold, not a slit-tip sliver
                  folding.append({"island": int(k), "faces": np.nonzero(fm)[0].tolist(), "self_overlap": round(scores[best][3], 4)})
          _set_uv(me, final, lname)
          if method == "BEST" and len(methods) > 1:
              # islands from different runs overlap: repack into the tile (real layout comes from pack())
              with _edit_mode([obj], lname, select_all=False):
                  bm = bmesh.from_edit_mesh(me)
                  for f in bm.faces:
                      f.select_set(bool(fsel[f.index]))
                  bmesh.update_edit_mesh(me)
                  bpy.ops.uv.average_islands_scale()
                  bpy.ops.uv.pack_islands(rotate=False, margin_method="FRACTION", margin=0.002)
          A.free()
          chosen = {}
          for v in per_island.values():
              chosen[v["chosen"]] = chosen.get(v["chosen"], 0) + 1
          return {"method": method, "islands": len(per_island), "chosen": chosen,
                  "unsolved_faces": {m: results[m][1] for m in results},
                  "folding_islands": [{"island": f["island"], "n_faces": len(f["faces"]), "self_overlap": f["self_overlap"]} for f in folding],
                  "folding_faces": [i for f in folding for i in f["faces"]],
                  "per_island": per_island if len(per_island) <= 40 else "omitted (>40 islands)"}
      
      
      def straighten(obj, uv_map=None, min_quad_share=0.9, islands=None):
          """Lampel/Morren straightening, headless: per grid-like island make its central quad an
          axis-aligned rectangle, Follow Active Quads over the island's quads, pin them, re-unwrap
          the rest around the pins, clear the pins. Run BEFORE normalize/pack."""
          _object_mode()
          me = obj.data
          lname = _layer(me, uv_map).name
          A = _Arrays(obj, lname)
          labels, _ = _uv_islands(A)
          old_pins = _get_pins(me, lname)
          done = []
          todo = range(labels.max() + 1) if islands is None else islands
          for k in todo:
              fidx = np.nonzero(labels == k)[0]
              quads = fidx[A.lt[fidx] == 4]
              if len(fidx) < 2 or len(quads) / len(fidx) < min_quad_share:
                  continue
              uv = _get_uv(me, lname)
              cen = np.array([uv[A.ls[f]:A.ls[f] + 4].mean(0) for f in quads])
              mid = uv[np.concatenate([np.arange(A.ls[f], A.ls[f] + A.lt[f]) for f in fidx])].mean(0)
              act = int(quads[np.argmin(np.linalg.norm(cen - mid, axis=1))])
              with _edit_mode([obj], lname, select_all=False):
                  bm = bmesh.from_edit_mesh(me)
                  bm.faces.ensure_lookup_table()
                  ul = bm.loops.layers.uv[lname]
                  f = bm.faces[act]
                  ls = list(f.loops)
                  p = [l[ul].uv.copy() for l in ls]
                  w = ((p[1] - p[0]).length + (p[2] - p[3]).length) / 2
                  h = ((p[2] - p[1]).length + (p[3] - p[0]).length) / 2
                  ang = math.atan2((p[1] - p[0]).y, (p[1] - p[0]).x)
                  q = round(ang / (math.pi / 2)) * (math.pi / 2)
                  ex = Vector((math.cos(q), math.sin(q)))
                  ey = Vector((-ex.y, ex.x))
                  sgn = 1.0 if ((p[1] - p[0]).to_3d().cross((p[3] - p[0]).to_3d())).z >= 0 else -1.0
                  c = sum(p, Vector((0, 0))) / 4
                  corners = [c - ex * w / 2 - sgn * ey * h / 2, c + ex * w / 2 - sgn * ey * h / 2,
                             c + ex * w / 2 + sgn * ey * h / 2, c - ex * w / 2 + sgn * ey * h / 2]
                  for l, co in zip(ls, corners):
                      l[ul].uv = co
                  qset = set(quads.tolist())
                  for g in bm.faces:
                      g.select_set(g.index in qset)
                  bm.faces.active = f
                  bmesh.update_edit_mesh(me)
                  bpy.ops.uv.follow_active_quads(mode="LENGTH_AVERAGE")
                  bm = bmesh.from_edit_mesh(me)
                  bm.faces.ensure_lookup_table()
                  ul = bm.loops.layers.uv[lname]
                  fset = set(fidx.tolist())
                  for g in bm.faces:
                      inq = g.index in qset
                      for l in g.loops:
                          l[ul].pin_uv = inq or l[ul].pin_uv
                      g.select_set(g.index in fset)
                  bmesh.update_edit_mesh(me)
                  if len(quads) < len(fidx):
                      bpy.ops.uv.unwrap(method="ANGLE_BASED", margin=0.0, fill_holes=True)
              done.append(int(k))
          _set_pins(me, old_pins, lname)
          A.free()
          return {"straightened_islands": done}
      
      
      def pin_loops(obj, loops, state=True, uv_map=None):
          """Pin (or unpin) face corners by loop index. Pinned UVs survive every uv.unwrap."""
          _object_mode()
          p = _get_pins(obj.data, uv_map)
          p[np.asarray(loops, dtype=np.int64)] = state
          _set_pins(obj.data, p, uv_map)
          return {"pinned": int(p.sum())}
      
      
      # --------------------------------------------------------------------------- density
      def texel_density(obj, tex_res=2048, uv_map=None):
          """Area-weighted texel density in px/m: whole object, per UV island."""
          _object_mode()
          A = _Arrays(obj, uv_map)
          labels, _ = _uv_islands(A)
          a_uv, a3 = _face_measures(A)
          isl = []
          for k in range(labels.max() + 1):
              m = labels == k
              isl.append(tex_res * math.sqrt(np.abs(a_uv[m]).sum() / max(a3[m].sum(), 1e-18)))
          A.free()
          total = tex_res * math.sqrt(np.abs(a_uv).sum() / max(a3.sum(), 1e-18))
          return {"px_per_m": round(total, 1), "px_per_cm": round(total / 100, 2), "islands": [round(v, 1) for v in isl]}
      
      
      def texture_size_for(obj, target_px_per_m, coverage=0.75):
          """Density first, texture second (Huge Menace): smallest power of two that holds the
          target at the given UV coverage. res = TD * sqrt(surface m2 / coverage)."""
          A = _Arrays(obj)
          P = A.co[A.lv]
          c = np.cross(P, P[A.nxt])
          area = 0.5 * np.linalg.norm(np.stack([np.bincount(A.lf, c[:, k], minlength=A.nf) for k in range(3)], 1), axis=1).sum()
          A.free()
          need = target_px_per_m * math.sqrt(area / coverage)
          return {"surface_m2": round(float(area), 4), "min_res": round(need), "res": int(2 ** math.ceil(math.log2(max(need, 1))))}
      
      
      def face_weights_from_vgroup(obj, group, low=1.0, high=2.0):
          """Per-face priority weight from a vertex group: weight 0 -> low, 1 -> high."""
          vg = obj.vertex_groups[group]
          w = np.zeros(len(obj.data.vertices))
          for v in obj.data.vertices:
              for g in v.groups:
                  if g.group == vg.index:
                      w[v.index] = g.weight
          return np.array([low + (high - low) * w[list(p.vertices)].mean() for p in obj.data.polygons])
      
      
      def normalize_texel_density(obj, target_px_per_m=None, tex_res=2048, weights=None, uv_map=None):
          """Scale every UV island about its center so its density = target x weight.
          target None: the current area-weighted median (like Average Islands Scale, but with
          priorities). weights: one value per face (e.g. face_weights_from_vgroup), island
          weight = area-weighted mean; face/hands > 1, soles/inner mouth < 1 (Kaspar,
          SpeedChar). Pack afterwards with scale=False to keep the density exact."""
          _object_mode()
          me = obj.data
          A = _Arrays(obj, uv_map)
          labels, _ = _uv_islands(A)
          a_uv, a3 = _face_measures(A)
          n = labels.max() + 1
          td = np.array([tex_res * math.sqrt(np.abs(a_uv[labels == k]).sum() / max(a3[labels == k].sum(), 1e-18)) for k in range(n)])
          area = np.array([a3[labels == k].sum() for k in range(n)])
          if target_px_per_m is None:
              target_px_per_m = _wpct(td, area, 0.5)
          w = np.ones(n)
          if weights is not None:
              fw = np.asarray(weights, dtype=np.float64)
              w = np.array([np.average(fw[labels == k], weights=np.maximum(a3[labels == k], 1e-18)) for k in range(n)])
          uv = A.uv.copy()
          lab_l = labels[A.lf]
          for k in range(n):
              m = lab_l == k
              s = target_px_per_m * w[k] / max(td[k], 1e-9)
              c = (uv[m].min(0) + uv[m].max(0)) / 2
              uv[m] = c + (uv[m] - c) * s
          _set_uv(me, uv, uv_map)
          A.free()
          return {"target_px_per_m": round(float(target_px_per_m), 1),
                  "before_min_max": [round(float(td.min()), 1), round(float(td.max()), 1)],
                  "islands": int(n), "weights_min_max": [round(float(w.min()), 2), round(float(w.max()), 2)]}
      
      
      # --------------------------------------------------------------------------- pack
      def pack(objs, tex_res=2048, gap_px=None, rotate_method="CARDINAL", scale=True, shape_method="CONCAVE",
               merge_overlap=False, udim_source="CLOSEST_UDIM", sweep=True, allow_any=False, uv_map=None, faces=None):
          """Pack all islands of objs into one shared layout with a PIXEL gap.
          gap_px: island-to-island gap in texture pixels, default tex_res/128 (Lampel, games;
          16 px at 2K, On Mars 3D). Uses margin_method FRACTION, margin = gap/(2*res), which also
          leaves gap/2 to the tile border. rotate_method CARDINAL keeps straightened islands
          straight (never ANY after straightening). scale=False keeps texel density exact
          (reports overflow if the islands do not fit: raise the resolution or split sets).
          sweep tries CONCAVE/CONVEX and CARDINAL/AXIS_ALIGNED and keeps the best coverage;
          allow_any adds free rotation (organic islands nobody paints in 2D). faces: pack only
          these faces of a single object (one UDIM region, one texture set); headless the region
          lands in 0-1 whatever tile it was in, so pack first, then move_to_tile()."""
          _object_mode()
          objs = [objs] if isinstance(objs, bpy.types.Object) else list(objs)
          gap_px = tex_res / 128.0 if gap_px is None else gap_px
          margin = gap_px / (2.0 * tex_res)
          start = {o.name: _get_uv(o.data, uv_map) for o in objs}
          variants = [(shape_method, rotate_method)]
          if sweep:
              rots = [rotate_method] + ([r for r in ("CARDINAL", "AXIS_ALIGNED") if r != rotate_method]
                                        if rotate_method in ("CARDINAL", "AXIS_ALIGNED") else [])
              if allow_any and "ANY" not in rots:
                  rots.append("ANY")
              for s in ("CONCAVE", "CONVEX"):
                  for r in rots:
                      if (s, r) not in variants:
                          variants.append((s, r))
          best = None
          tried = []
          for s, r in variants:
              for o in objs:
                  _set_uv(o.data, start[o.name], uv_map)
              with _edit_mode(objs, uv_map, select_all=faces is None):
                  if faces is not None:
                      fs = set(int(i) for i in faces)
                      bm = bmesh.from_edit_mesh(objs[0].data)
                      for f in bm.faces:
                          f.select_set(f.index in fs)
                      bmesh.update_edit_mesh(objs[0].data)
                  bpy.ops.uv.pack_islands(udim_source=udim_source, rotate=r != "NONE", rotate_method=r if r != "NONE" else "CARDINAL",
                                                scale=scale, merge_overlap=merge_overlap, margin_method="FRACTION",
                                                margin=margin, shape_method=s)
              cov, over = 0.0, 0
              for o in objs:
                  A = _Arrays(o, uv_map)
                  a_uv, _ = _face_measures(A)
                  fm = np.ones(A.nf, dtype=bool) if faces is None else np.isin(np.arange(A.nf), np.asarray(faces))
                  cov += float(np.abs(a_uv[fm]).sum())
                  u = A.uv[fm[A.lf]]
                  t = np.floor(u.mean(0)) if faces is not None and len(u) else np.zeros(2)
                  over += int(((u < t - 1e-6) | (u > t + 1 + 1e-6)).any(1).sum())
                  A.free()
              tried.append({"shape": s, "rotate": r, "coverage": round(cov, 4), "loops_outside_tile": over})
              key = (over == 0, cov)
              if best is None or key > best[0]:
                  best = (key, s, r, {o.name: _get_uv(o.data, uv_map) for o in objs})
          for o in objs:
              _set_uv(o.data, best[3][o.name], uv_map)
          return {"coverage": round(best[0][1], 4), "shape": best[1], "rotate": best[2], "gap_px": gap_px,
                  "margin_fraction": round(margin, 6), "fits_tile": best[0][0], "tried": tried}
      
      
      def move_to_tile(obj, faces, tile, uv_map=None):
          """Move the UVs of these faces into UDIM tile `tile` (1001 + u + 10 v), keeping their
          position inside the tile (uv.move_on_axis needs a UV editor; this does not)."""
          _object_mode()
          A = _Arrays(obj, uv_map)
          fm = np.isin(A.lf, np.asarray(faces))
          uv = A.uv.copy()
          cur = np.floor(uv[fm].mean(0))
          tgt = np.array([(tile - 1001) % 10, (tile - 1001) // 10], dtype=np.float64)
          uv[fm] += tgt - cur
          _set_uv(obj.data, uv, uv_map)
          A.free()
          return {"tile": tile, "moved_loops": int(fm.sum())}
      
      
      def offset_stacked(obj, du=1.0, uv_map=None, which="FLIPPED"):
          """Move the mirrored copy of stacked UVs exactly one tile to the right before baking
          (Polycount; verified: the baker ignores UVs outside a non-tiled image, both halves then
          read the same texels). which='FLIPPED' moves faces with negative UV area."""
          _object_mode()
          A = _Arrays(obj, uv_map)
          a_uv, _ = _face_measures(A)
          m = (a_uv < 0)[A.lf] if which == "FLIPPED" else np.isin(A.lf, np.asarray(which))
          uv = A.uv.copy()
          uv[m, 0] += du
          _set_uv(obj.data, uv, uv_map)
          A.free()
          return {"moved_faces": int((a_uv < 0).sum()) if which == "FLIPPED" else len(which), "du": du}
      
      
      # --------------------------------------------------------------------------- QA
      def uv_qa(obj, tex_res=2048, uv_map=None, evaluated=False, analysis_res=None, gap_px=None, max_islands_listed=12):
          """Measure a UV layout the way experts judge it (checker, stretch overlay, packing):
          islands, stretch (angle and area), texel density per island and its spread, flipped
          and overlapping faces (raster, per tile), coverage, min island gap and border in
          texture pixels, tiles used and faces crossing tiles, hard edges not on seams."""
          _object_mode()
          A = _Arrays(obj, uv_map, evaluated)
          me = A.me
          labels, cont_edges = _uv_islands(A)
          a_uv, a3 = _face_measures(A)
          n_isl = int(labels.max() + 1) if A.nf else 0
          valid = (a3 > 1e-14) & (np.abs(a_uv) > 1e-16)
          td_face = tex_res * np.sqrt(np.abs(a_uv) / np.maximum(a3, 1e-18))
          ang_uv, ok1 = _corner_angles(np.c_[A.uv, np.zeros(A.nl)], A)
          ang_3d, ok2 = _corner_angles(A.co[A.lv], A)
          ok = ok1 & ok2
          aerr = np.degrees(np.abs(ang_uv - ang_3d))[ok]
          ratio = (np.abs(a_uv) / max(np.abs(a_uv).sum(), 1e-18)) / (a3 / max(a3.sum(), 1e-18))
          lr = np.abs(np.log2(np.maximum(ratio[valid], 1e-9)))
          isl = []
          for k in range(n_isl):
              m = labels == k
              ta, tu = a3[m].sum(), np.abs(a_uv[m]).sum()
              fl = int((a_uv[m] < 0).sum())
              uvk = A.uv[m[A.lf]]
              isl.append({"island": k, "faces": int(m.sum()), "td": round(tex_res * math.sqrt(tu / max(ta, 1e-18)), 1),
                          "area_m2": round(float(ta), 5), "uv_px": round(float(tu) * tex_res ** 2),
                          "flipped": fl, "bbox": [round(float(x), 4) for x in (*uvk.min(0), *uvk.max(0))]})
          td_isl = np.array([i["td"] for i in isl])
          ar_isl = np.array([i["area_m2"] for i in isl])
          # tiles
          cen = np.stack([np.bincount(A.lf, A.uv[:, k], minlength=A.nf) / A.lt for k in range(2)], 1)
          tile_uv = np.floor(cen + 1e-9)
          outside = np.any((A.uv < tile_uv[A.lf] - 1e-5) | (A.uv > tile_uv[A.lf] + 1 + 1e-5), axis=1)
          crossing = int(np.bincount(A.lf, outside, minlength=A.nf).astype(bool).sum())
          tiles = sorted({(int(u), int(v)) for u, v in tile_uv})
          # raster per tile
          ares = analysis_res or min(tex_res, 2048)
          T, tf = _tris(A, A.uv)
          tri_uv = A.uv[T]
          tri_lab = labels[tf]
          tri_tile = tile_uv[tf]
          per_tile = {}
          gap_needed = tex_res / 128.0 if gap_px is None else gap_px
          overlap_px = 0
          pairs_all = set()
          self_overlap = set()
          min_gap_tex = None
          for (tu_, tv_) in tiles:
              sel = (tri_tile[:, 0] == tu_) & (tri_tile[:, 1] == tv_)
              cnt, lab, pairs = _raster(tri_uv[sel], tri_lab[sel], ares, (tu_, tv_))
              cov = float((cnt > 0).mean())
              ov = int((cnt > 1).sum())
              overlap_px += ov
              pairs_all |= pairs
              if ov:
                  ys, xs = np.nonzero(cnt > 1)
                  for lbl in np.unique(lab[ys, xs]).tolist():
                      if not any(lbl in p for p in pairs):
                          self_overlap.add(int(lbl))
              cap = int(math.ceil(gap_needed * ares / tex_res)) + 2
              g = _min_gap(lab, cap)
              g_tex = None if g is None else round(g * tex_res / ares, 1)
              if g_tex is not None:
                  min_gap_tex = g_tex if min_gap_tex is None else min(min_gap_tex, g_tex)
              uvt = tri_uv[sel].reshape(-1, 2) - np.array([tu_, tv_])
              border = float(np.min(np.minimum(uvt, 1 - uvt))) * tex_res if len(uvt) else None
              udim = 1001 + tu_ + 10 * tv_
              per_tile[udim] = {"coverage": round(cov, 4), "overlap_px_pct": round(100 * ov / ares ** 2, 3),
                                "min_gap_px": g_tex if g_tex is not None else f">{round((2 * cap + 1) * tex_res / ares)}",
                                "border_px": None if border is None else round(border, 1)}
          # seams vs hard edges
          sharp = np.zeros(A.ne, dtype=bool)
          att = me.attributes.get("sharp_edge")
          if att is not None and att.domain == "EDGE":
              att.data.foreach_get("value", sharp)
          cont = np.zeros(A.ne, dtype=bool)
          cont[cont_edges] = True
          sharp_not_seam = int((sharp & cont).sum())
          flat = me.attributes.get("sharp_face")
          flat_share = 0.0
          if flat is not None:
              fa = np.zeros(A.nf, dtype=bool)
              flat.data.foreach_get("value", fa)
              flat_share = float(fa.mean())
          tiny = [i["island"] for i in isl if i["uv_px"] < 16]
          flipped = int((a_uv < 0).sum())
          A.free()
          order = np.argsort(td_isl)
          listed = [isl[i] for i in list(order[:max_islands_listed // 2]) + list(order[-max_islands_listed // 2:])] \
              if n_isl > max_islands_listed else isl
          return {
              "object": obj.name, "uv_map": uv_map or (obj.data.uv_layers.active.name if obj.data.uv_layers else None),
              "tex_res": tex_res, "faces": int(A.nf), "islands": n_isl,
              "td_px_per_m": round(_wpct(td_face[valid], a3[valid], 0.5), 1),
              "td_face_spread_p95_p05": round(_wpct(td_face[valid], a3[valid], 0.95) / max(_wpct(td_face[valid], a3[valid], 0.05), 1e-9), 2),
              "td_island_spread_p95_p05": round(_wpct(td_isl, ar_isl, 0.95) / max(_wpct(td_isl, ar_isl, 0.05), 1e-9), 2) if n_isl else None,
              "td_island_min_max": [round(float(td_isl.min()), 1), round(float(td_isl.max()), 1)] if n_isl else None,
              "angle_err_mean_deg": round(float(aerr.mean()), 2) if len(aerr) else None,
              "angle_err_p95_deg": round(float(np.percentile(aerr, 95)), 2) if len(aerr) else None,
              "area_stretch_p95_log2": round(float(np.percentile(lr, 95)), 2) if len(lr) else None,
              "flipped_faces": flipped, "zero_area_faces": int((~valid).sum()),
              "overlap_px_pct": round(100 * overlap_px / (ares ** 2 * max(len(tiles), 1)), 3),
              "overlapping_island_pairs": len(pairs_all), "self_overlapping_islands": sorted(self_overlap)[:20],
              "tiles": sorted(per_tile), "per_tile": per_tile, "faces_crossing_tiles": crossing,
              "min_gap_px": min_gap_tex, "gap_required_px": gap_needed,
              "tiny_islands": tiny[:20], "sharp_edges_not_on_seams": sharp_not_seam, "flat_shaded_share": round(flat_share, 3),
              "mirror_stacked_hint": bool(flipped and 0.3 < flipped / max(A.nf, 1) < 0.7 and overlap_px > 0),
              "islands_detail": listed,
          }
      
      
      def uv_verdict(r, profile="game", priorities=False):
          """Human-readable problems from uv_qa. profile 'game' (one 0-1 set) or 'film' (UDIM,
          symmetry allowed). priorities=True accepts deliberate density differences."""
          p = []
          if r["zero_area_faces"]:
              p.append(f"{r['zero_area_faces']} faces with zero UV area (unsolved island or collapsed faces)")
          if r["mirror_stacked_hint"]:
              p.append("mirror-stacked UVs (half the faces flipped and overlapping): bake would overwrite one side; "
                       "offset_stacked() before baking or give unique UVs where AO differs")
          elif r["overlap_px_pct"] > 0.05:
              p.append(f"overlaps: {r['overlap_px_pct']}% of tile pixels, {r['overlapping_island_pairs']} island pairs, "
                       f"self-overlapping islands {r['self_overlapping_islands']}")
          if r["flipped_faces"] and not r["mirror_stacked_hint"]:
              p.append(f"{r['flipped_faces']} flipped UV faces")
          if r["faces_crossing_tiles"]:
              p.append(f"{r['faces_crossing_tiles']} faces cross a tile border")
          if profile == "game":
              if r["tiles"] != [1001]:
                  p.append(f"UVs outside 0-1 (tiles {r['tiles']}) on a single-set game asset")
              cov = r["per_tile"].get(1001, {}).get("coverage", 0)
              if cov < 0.70:
                  p.append(f"coverage {cov:.0%} < 70% (Gambrell >70, Huge Menace 75% studio minimum)")
          if r["min_gap_px"] is not None and r["min_gap_px"] < r["gap_required_px"] - 1:
              p.append(f"island gap {r['min_gap_px']} px < required {r['gap_required_px']:.0f} px (res/128)")
          for t, d in r["per_tile"].items():
              if d["border_px"] is not None and d["border_px"] < r["gap_required_px"] / 2 - 1:
                  p.append(f"tile {t}: islands {d['border_px']} px from the border (< gap/2)")
          if not priorities and r["td_island_spread_p95_p05"] and r["td_island_spread_p95_p05"] > 1.25:
              p.append(f"texel density spread between islands {r['td_island_spread_p95_p05']} (> 1.25; Gambrell: 10-20% is invisible)")
          if r["angle_err_p95_deg"] and r["angle_err_p95_deg"] > 15:
              p.append(f"angle stretch p95 {r['angle_err_p95_deg']} deg (> 15: add a seam or relax with pins)")
          if r["sharp_edges_not_on_seams"]:
              p.append(f"{r['sharp_edges_not_on_seams']} hard edges inside islands (bake seams): seam them or smooth them")
          if r["flat_shaded_share"] > 0.5:
              p.append("mesh is flat shaded: shade smooth (hard edges only on seams) before baking")
          if r["tiny_islands"]:
              p.append(f"islands smaller than 4x4 px: {r['tiny_islands']}")
          return p
      
      
      # --------------------------------------------------------------------------- images and renders
      def _save_rgba(arr, path, float16=False):
          h, w = arr.shape[:2]
          img = bpy.data.images.new("bx_tmp_img", w, h, alpha=True, float_buffer=float16)
          img.pixels.foreach_set(np.ascontiguousarray(arr, dtype=np.float32).ravel())
          img.filepath_raw = path
          img.file_format = "PNG"
          img.save()
          bpy.data.images.remove(img)
          return path
      
      
      def island_mask(obj, res, uv_map=None, tile=(0, 0)):
          """Boolean (res, res) mask of pixels covered by UV faces in one tile (rows bottom-up,
          like Image.pixels)."""
          A = _Arrays(obj, uv_map)
          T, tf = _tris(A, A.uv)
          cnt, _, _ = _raster(A.uv[T], tf, res, tile)
          A.free()
          return cnt > 0
      
      
      def uv_layout_image(obj, path, res=1024, uv_map=None, color="island", tile=(0, 0), wire=True):
          """PNG of the UV layout (the agent's UV editor): islands in distinct hues or colored by
          density ('density': blue low, white median, red high) or angle stretch ('angle'),
          overlaps magenta, island borders dark, UV edges drawn when wire=True."""
          A = _Arrays(obj, uv_map)
          labels, _ = _uv_islands(A)
          T, tf = _tris(A, A.uv)
          cnt, lab, _ = _raster(A.uv[T], tf, res, tile)
          a_uv, a3 = _face_measures(A)
          img = np.full((res, res, 4), (0.12, 0.12, 0.12, 1.0), dtype=np.float32)
          covered = lab >= 0
          face = lab[covered]
          if color == "density":
              td = np.sqrt(np.abs(a_uv) / np.maximum(a3, 1e-18))
              med = np.median(td[a3 > 0]) if (a3 > 0).any() else 1.0
              x = np.clip(np.log2(np.maximum(td, 1e-12) / med), -1, 1)
              col = np.where(x[:, None] < 0, (1 + x[:, None]) * np.array([1, 1, 1]) + (-x[:, None]) * np.array([0.1, 0.3, 1.0]),
                             (1 - x[:, None]) * np.array([1, 1, 1]) + x[:, None] * np.array([1.0, 0.15, 0.1]))
          elif color == "angle":
              ang_uv, ok1 = _corner_angles(np.c_[A.uv, np.zeros(A.nl)], A)
              ang_3d, ok2 = _corner_angles(A.co[A.lv], A)
              e = np.degrees(np.bincount(A.lf, np.where(ok1 & ok2, np.abs(ang_uv - ang_3d), 0), minlength=A.nf) / A.lt)
              x = np.clip(e / 20.0, 0, 1)[:, None]
              col = (1 - x) * np.array([0.95, 0.95, 0.95]) + x * np.array([1.0, 0.1, 0.1])
          else:
              hues = (labels * 0.618034) % 1.0
              col = np.array([colorsys.hsv_to_rgb(h, 0.55, 0.9) for h in hues]) if len(hues) else np.zeros((0, 3))
          img[covered, :3] = col[face]
          isl = np.where(covered, labels[np.maximum(lab, 0)], -1)
          edge = np.zeros_like(covered)
          edge[:, 1:] |= isl[:, 1:] != isl[:, :-1]
          edge[1:, :] |= isl[1:, :] != isl[:-1, :]
          img[edge & covered, :3] *= 0.35
          if wire:
              a, b = (A.uv - np.array(tile)) * res, (A.uv[A.nxt] - np.array(tile)) * res
              n = np.maximum(np.ceil(np.linalg.norm(b - a, axis=1)).astype(np.int64), 1)
              idx = np.repeat(np.arange(len(a)), n)
              t = (np.arange(n.sum()) - np.repeat(np.cumsum(n) - n, n)) / np.repeat(n, n)
              p = a[idx] + (b[idx] - a[idx]) * t[:, None]
              x, y = p[:, 0].astype(np.int64), p[:, 1].astype(np.int64)
              ok = (x >= 0) & (y >= 0) & (x < res) & (y < res)
              img[y[ok], x[ok], :3] *= 0.6
          img[cnt > 1, :3] = (1.0, 0.0, 1.0)
          A.free()
          return _save_rgba(img, path)
      
      
      def _review_helpers():
          import sys
          here = os.path.dirname(os.path.abspath(__file__))
          root = os.path.join(os.path.dirname(os.path.dirname(here)), "scenario-blender-expert", "scripts")
          if root not in sys.path:
              sys.path.append(root)
          import bx_review
          return bx_review
      
      
      def _render_views(objs_visible, frame_objs, out_dir, prefix, views, res, sc, cam, per_view=None):
          R = _review_helpers()
          lo, hi = R._world_bbox(frame_objs)
          center = (lo + hi) / 2
          pts = R._sample_points(frame_objs)
          paths = []
          for view in views:
              d = R.VIEW_DIRS[view]
              half, depth = R._extent(pts, center, d)
              if view in R.PERSPECTIVE:
                  cam.data.type, cam.data.lens = "PERSP", 50
                  fov = 2 * math.atan(cam.data.sensor_width / (2 * ca
  • SKILL.md 16.7 KB
    ---
    name: scenario-blender-uv-baking
    description: "Use when UV unwrapping, placing seams, straightening or packing UVs, setting texel density, laying out UDIMs or mirrored UVs, or baking normal, AO, curvature, ID or displacement maps from a sculpt or high poly onto a low poly or retopo in Blender, including Multires bakes. Also when a bake has black or inverted patches, flat areas, cracks, seams, wavy lines or AO ghosts, bpy.ops.object.bake returns CANCELED, or uv.unwrap silently fails."
    license: MIT
    ---
    
    # UV unwrapping and baking
    
    Expert-level UVs put seams where the texturing method can afford them, set texel density before texture size, and pack with pixel-true padding. Expert bakes measure their projection distances instead of guessing, and are judged in pixels and under a raking light, never eyeballed once. Every stage below has a measurable gate that [`scripts/bx_uvbake.py`](scripts/bx_uvbake.py) computes. 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).
    
    ## Stance (the expert delta)
    
    - **Seams follow the texturing method.** Kaspar (2021, 2D painting): as few as possible, hidden under hair, clothes and inner limbs. Blender Studio guide (2024, same author): more seams rather than fewer when you paint in 3D or bake, because stretch costs more. Game bakes add a hard rule: every hard edge is a seam, or the normal map cracks (Lampel, Gambrell, On Mars 3D, Polycount).
    - **Only closed shells and long tubes need extra cuts.** Morren: anything closed on itself needs one cut. Gambrell: cut every taped-paper ring once, hidden. But an island with holes (a face with eye holes, a washer) flattens fine. `cut_to_disks` test-unwraps it and cuts only if it stretches [added; cutting everything folded the sheep's big islands].
    - **Unwrap method is a measurement, and 5.2 defaults to CONFORMAL.** Always pass `method=`. On Suzanne, Minimum Stretch gives the most even density (spread 1.38), Angle Based the best angles, and Conformal fails by area (5.89). On flat hard-surface islands all three converge. `unwrap(method="BEST")` picks per island and rejects islands that fold over themselves.
    - **Density first, texture size second** (Huge Menace). Targets: FPS 10.24 px/cm, weapon 20.48, third person 5.12, top-down 2.56. Priority follows the camera: face, hands and front up; soles, inner mouth and back down (SpeedChar, Kaspar). Match texture sets by scaling the denser set down, never the other up (Gambrell).
    - **Straighten, then equalize, then pack without tilting** (Lampel): Follow Active Quads from a truly rectangular quad, pin, re-unwrap the rest, pack with CARDINAL. Padding is a pixel number, res/128 (16 px at 2K), passed as FRACTION margin = gap/(2*res).
    - **Measure the projection, never guess extrusion, and do not trust one global value.** Ryan King's 0.01 then 0.02 was scene-specific. SpeedChar swept six values by hand, and the value that fixed the face broke the armpit ("when we fix something somewhere, some other problems occur", E6QMhGuhyoc [00:26:23]). Rays start on the low, and `max_ray_distance` counts from the extruded start. The adaptive bake keeps, per texel, the smallest extrusion that hits a front face. On the sheep it cut inverted texels from 7.5% to 1.2% [verified].
    - **Unique UVs wherever baked lighting differs** (SpeedChar: an AO ghost means shared texels). Stacked or mirrored copies move exactly one tile out of 0-1 before the bake (Polycount), because inside 0-1 the last island baked wins.
    - **A bake is judged in pixels and under light.** Blue < 0.5 means rays hit the far side. Exactly flat means rays missed. All-white AO means the distance is too small. Inverted pixels that do not move when extrusion changes point to a geometry problem, not a setting [verified on the sheep]. Multires bakes cast no rays: on the same surface they gave 0.09% inverted texels.
    
    ## Establish first
    
    | Input             | Changes                                      | Default when the brief is silent                         |
    | ----------------- | -------------------------------------------- | -------------------------------------------------------- |
    | Target            | seams, symmetry, UDIM vs sets, triangulation | game engine, one 0-1 set per material                    |
    | Texturing method  | seam count and visibility                    | 3D painting/Substance: seams may show at the back        |
    | Camera            | density and priorities                       | third person 512 px/m, closest view defines tiles        |
    | Texture budget    | resolution, sets, UDIM count                 | 2048 per set; `texture_size_for` decides                 |
    | Deformation       | seams off joints, tangent-space normals      | character: tangent space, unique UVs                     |
    | High source       | Selected to Active vs Multires               | separate sculpt: Selected to Active                      |
    | Engine convention | normal green channel                         | OpenGL (Blender, Unity, glTF); `directx=True` for Unreal |
    
    ## Workflow
    
    1. **Pre-flight.** Apply scale (On Mars 3D: it "fixes the vast majority" of problems). Clear non-manifold geometry, doubles and back faces (SpeedChar). Decide on Mirror and Solidify modifiers. If UVs exist, compare `island_topology` with `uv_qa` islands: seam flags in files are often leftover retopology marks, so run `seams_from_uv_islands` (Lampel) before re-unwrapping. GATE: scale 1, `bx_audit` clean, topology table read.
    2. **Seams.** Hard surface: `seams_from_sharp(obj, 30, mark_sharp=True)`. Organic: build part labels (head, ears, limbs, torso front and back, soles, inner mouth and eyelids per the Kaspar and Morren seam maps in [`references/expert-notes.md`](references/expert-notes.md)), then `seams_from_groups(obj, labels)`. Specific cuts: `seam_path(obj, v_a, v_b, view_dirs)`. Then `cut_to_disks(obj, view_dirs=[camera])`. GATE: `must_cut` empty; a checker render shows no seams across hero areas that the brief cannot afford.
    3. **Unwrap.** `unwrap(obj, method="BEST")`, or the method the island type calls for. GATE: `unsolved_faces` 0 (the operator returns FINISHED even when it fails), `folding_islands` empty (else `seams_from_groups(..., faces=folding_faces)` and unwrap again), angle p95 at most 15 to 20 deg.
    4. **Straighten** grid-like islands (hard surface, garments, strands): `straighten(obj)` before any scaling. GATE: straight stripes in the checker.
    5. **Texel density.** `normalize_texel_density(obj, target, res, weights)`, then `texture_size_for(obj, target)`. GATE: island spread within 10-20%, or the intended priority ratios.
    6. **Pack.** `pack(obj, tex_res=res)`, with `scale=False` for a strict density and `allow_any=True` for organic islands. UDIM: pack each region, then `move_to_tile`. GATE: `uv_qa`: 0 overlaps, gap at least res/128, border at least gap/2, coverage at least 0.70 or an explained exception.
    7. **Look.** `uv_layout_image`, `checker_review`, `distortion_review(metric="angle")`. Judge with [`references/critique.md`](references/critique.md).
    8. **Bake prep.** `prepare_low(low, triangulate=game, sharp_from_uv_islands=hard_surface)`. Check the silhouette against the high (`bx_audit.audit(low, high=...)`). Explode interpenetrating or moving parts (`explode`, one axis). Run `measure_projection`: `miss_pct` under 2 and `wrong_part_pct` under 1, else use a cage, explode, or bake per pair.
    9. **Bake.** `bake_high_to_low(low, highs, out, res=512)` first, then the final resolution. It uses adaptive extrusion by default and hides every other object from render. Multires: `bake_multires`. Extras: `bake_id`, `curvature_from_normal`. GATE: `sanity[...]["problems"]` empty (inverted texels under 2%, notes from 0.2%), 16-bit PNG. If inverted texels remain, run `map_problem_review` to see where. If `sweep_projection` shows they do not move with extrusion, fix the geometry (fit, holes, thin parts), not the numbers.
    10. **Review and hook up.** `bake_review(low, highs, paths, out)`: the low with maps must read like the high under the raking light. Then `hookup_maps`. Report measured numbers and what was not verified.
    
    ## Numbers
    
    | Value                                                                                  | Relative to                                      | Source                                        |
    | -------------------------------------------------------------------------------------- | ------------------------------------------------ | --------------------------------------------- |
    | 10.24 / 20.48 / 5.12 / 2.56 px/cm                                                      | FPS world / FPS weapon / third person / top-down | Huge Menace [00:12:28]                        |
    | 10-20% density mismatch invisible                                                      | between islands or sets                          | Gambrell [01:36:16]                           |
    | coverage above 70%, 75% studio minimum                                                 | one 0-1 tile                                     | Gambrell [01:31:56], Huge Menace [00:16:27]   |
    | island gap res/128 (8/16/32 px at 1K/2K/4K)                                            | texture resolution                               | Lampel [00:38:01], On Mars 3D [00:11:18]      |
    | FRACTION margin m: gap 2m, border m                                                    | Blender pack                                     | verified                                      |
    | bake margin res/128, 16 px at 2K                                                       | texture resolution                               | On Mars 3D, digest                            |
    | cylinder sides: 6 min, 8 recommended                                                   | low poly silhouette                              | On Mars 3D [00:03:48]                         |
    | samples: 16 for normals, 128 for AO, 1 only while iterating                            | antialiasing and noise                           | Gambrell/Marmoset 16, SpeedChar 4x4, verified |
    | test bake 512, final 2048                                                              | iteration loop                                   | SpeedChar [00:59:09]                          |
    | AO distance about 0.2 x bbox diagonal (default 10 units)                               | asset size                                       | [added], verified effect                      |
    | angle error p95 at most 15 deg organic, 3 deg flat                                     | per corner                                       | [added] from 5.2.1 measurements               |
    | Blender packer on an organic character: 55-62%                                         | 16 px gap at 2K                                  | measured on the sheep (several runs)          |
    | inverted texels: single pass 7.3-7.8%, adaptive 2.2% (sculpt) / 1.2% (consistent high) | selected to active, sheep                        | measured                                      |
    | 2048 bake, 186k-face high: one pass normal + AO 4.4 s, adaptive 25 s                   | Metal GPU, headless                              | measured                                      |
    
    ## Quality gates
    
    Code: `q = uv_qa(obj, res)`, then `uv_verdict(q, "game" or "film", priorities=...)`. `unwrap()["unsolved_faces"]` and `["folding_islands"]`. `measure_projection()`. `bake_high_to_low()["sanity"]` (inverted, exact-flat, non-unit, AO black and white). Cross-check overlaps with `bpy.ops.uv.select_overlap()` when in doubt; it agreed with the raster on the tests.
    
    Visual: layout PNG (islands, magenta overlaps), color-grid checker from front, side, three-quarter and the production camera (squares square, letters upright, seams hidden), angle and density heat maps, `bake_review` sheet (high, low, low + maps), `map_problem_review` when sanity flags pixels.
    
    ## Common mistakes
    
    | Mistake                                                    | Looks like                                  | Fix                                                                |
    | ---------------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------ |
    | `uv.unwrap()` without `method`                             | Conformal area distortion                   | pass `method=` or use `unwrap(method="BEST")`                      |
    | Trusting FINISHED                                          | closed island keeps its old UVs, repacked   | zero the UVs first, check for zero-area faces (`unwrap()` does it) |
    | Re-unwrapping without Seams from Islands                   | layout lost; retopology marks used as seams | `seams_from_uv_islands` first                                      |
    | Cutting every island to a disk                             | slits, folded islands                       | measured `cut_to_disks` (default)                                  |
    | Pack rotation ANY after straightening                      | tilted grids, jaggy lines                   | CARDINAL                                                           |
    | Blender margin 0.005                                       | padding depends on island scale             | FRACTION, gap/(2*res)                                              |
    | Target node active but not selected                        | bake returns CANCELED, no error             | select and activate; assert FINISHED                               |
    | Low carries the sculpt as Multires                         | almost flat normal map                      | render levels 0 for the bake (bake_high_to_low does it)            |
    | max_ray shorter than extrusion                             | 90% exactly flat map                        | ray = extrusion + inward depth                                     |
    | One global extrusion on thin, close parts (ears on a head) | inverted patches, neighbors baked in        | adaptive bake (default), explode, or per-pair bakes                |
    | Other objects render-visible during AO                     | black AO                                    | hide them from render; the active low never occludes               |
    | Default AO distance 10                                     | AO all dark or flat                         | about 0.2 x asset diagonal                                         |
    | Stacked UVs baked in 0-1                                   | one side's detail on both                   | unique UVs or `offset_stacked`                                     |
    | Hard edge inside an island                                 | crack lines                                 | seam it, or `prepare_low(sharp_from_uv_islands=True)`              |
    | Editing UVs after the bake                                 | seams, tangent mismatch                     | never; re-bake                                                     |
    
    ## Blender 5.2 notes
    
    - `uv.unwrap` defaults to CONFORMAL. On failure it keeps the island's old UVs and repacks them. It has `use_weights`/`weight_group` (default group "uv_importance"): importance weights with Minimum Stretch. A weighted top half went from 31% to 74% of UV area [verified].
    - Sync select is on by default (5.0). `BMLoopUV` only has `uv` and `pin_uv`. A `me.uv_layers[...]` reference goes stale after an Edit Mode toggle, so keep the name.
    - `pack_islands(udim_source="CLOSEST_UDIM")` headless packed a region sitting in 1002 back into 1001: pack first, then move. `uv.move_on_axis` needs a UV editor.
    - Bake: the target must be selected AND active (5.0). Operator defaults differ from scene defaults (`use_clear` False vs True, EXTEND vs ADJACENT_FACES): pass everything. Selected to Active uses the low's render evaluation (Multires render_levels, shape keys). Multires settings live in `scene.render.bake.use_multires` with `object.bake_image()`. A float image saved as PNG is 16-bit. Normal Map node `convention` OPENGL/DIRECTX (5.1). UDIM tiles need `image.tile_add(fill=True)`.
    - Auto Smooth is gone (4.1): smooth shading plus sharp edges; Triangulate "Keep Normals" is `keep_custom_normals`.
    
    ## References
    
    - `references/expert-notes.md`: judgment by expert with timestamps, disagreements and deciding conditions, my 5.2.1 measurements. Load when choosing seams, density or a bake strategy.
    - [`references/procedures.md`](references/procedures.md): tested pipelines (hard-surface, character, film/UDIM, Multires, bake-fix loop) and raw bpy snippets executed by the tests. Load before writing code.
    - `references/critique.md`: rubric for seams, stretch, density, packing, bake setup and baked maps, with proxies and severity. Load at every gate.
    - [`references/sources.md`](references/sources.md): videos, credentials, URLs, best timestamps, web references.
    - `scripts/bx_uvbake.py`: the module (docstrings list every function).
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related