scripts/_bpyx.py
"""_bpyx - tiny shared helpers for blender-agent-skills scripts (Blender 5.2+).
Source of truth: shared/_bpyx.py. Copies in skills/*/scripts/ are synced by
tools/sync_shared.py - never edit a copy.
Every skill script follows one contract:
run(**kwargs) -> dict # call from MCP / Python (import the module)
blender -b f.blend -P x.py -- --arg v # CLI: prints one line "@@JSON@@{...}"
"""
import argparse
import json
import sys
import bpy
MARK = "@@JSON@@"
def argv():
"""Arguments after '--' (Blender ignores them)."""
return sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
def _default(o):
if hasattr(o, "__len__") and not isinstance(o, str):
try:
return [round(v, 5) if isinstance(v, float) else v for v in o]
except TypeError:
pass
if isinstance(o, (set, frozenset)):
return sorted(o)
return str(o)
def emit(data):
"""Print compact JSON on one marked line (parsed by run_in_blender.py)."""
print(MARK + json.dumps(data, separators=(",", ":"), default=_default), flush=True)
return data
def parser(doc):
"""ArgumentParser whose help is the script docstring's first line."""
return argparse.ArgumentParser(description=(doc or "").strip().splitlines()[0])
def cli(run, p):
"""CLI entry: parse args after '--', run, emit JSON. Exit 1 on failure."""
args = p.parse_args(argv())
try:
result = run(**vars(args))
save = getattr(args, "save", None)
if save:
bpy.ops.wm.save_as_mainfile(filepath=bpy.path.abspath(save))
result["saved"] = save
emit(result)
except Exception as e: # report, don't dump a traceback into agent context
emit({"ok": False, "error": f"{type(e).__name__}: {e}"})
if bpy.app.background:
sys.exit(1)
def add_common(p, save=True):
"""--objects / --save flags shared by most scripts."""
p.add_argument("--objects", nargs="*", default=None,
help="object names (default: selected, else all meshes)")
if save:
p.add_argument("--save", default=None, help="save .blend to this path after run")
return p
def objects(names=None, types=("MESH",)):
"""Resolve names -> objects. No names: selected of `types`, else all visible of `types`."""
if names:
missing = [n for n in names if n not in bpy.data.objects]
if missing:
raise KeyError(f"objects not found: {missing}")
return [bpy.data.objects[n] for n in names]
pool = [o for o in bpy.context.scene.objects if not types or o.type in types]
sel = [o for o in pool if o.select_get()]
return sel or [o for o in pool if o.visible_get()]
def object_mode():
"""Leave edit/sculpt/pose modes; most data API work needs OBJECT mode."""
ob = bpy.context.view_layer.objects.active
if ob and ob.mode != "OBJECT":
bpy.ops.object.mode_set(mode="OBJECT")
def select_only(objs, active=None):
"""Select exactly `objs` and make `active` (default first) active."""
for o in bpy.context.view_layer.objects:
o.select_set(False)
for o in objs:
o.select_set(True)
bpy.context.view_layer.objects.active = active or (objs[0] if objs else None)
def node(tree, type_):
"""First node of `type_` (e.g. 'BSDF_PRINCIPLED'). Names are localized - never look up by name."""
return next((n for n in tree.nodes if n.type == type_), None)
def enum_ids(owner, prop):
"""Valid identifiers of an enum property (works on types or instances)."""
return [i.identifier for i in owner.bl_rna.properties[prop].enum_items]
def set_enum(owner, prop, value):
"""Assign an enum; on failure raise listing valid values (dynamic enums under-report)."""
try:
setattr(owner, prop, value)
except TypeError as e:
raise ValueError(f"{prop}={value!r} invalid: {e}") from None
def mesh_tris(ob, evaluated=True):
"""Triangle count (evaluated = with modifiers)."""
if evaluated:
eo = ob.evaluated_get(bpy.context.evaluated_depsgraph_get())
me = eo.to_mesh()
try:
me.calc_loop_triangles()
return len(me.loop_triangles)
finally:
eo.to_mesh_clear()
me = ob.data
me.calc_loop_triangles()
return len(me.loop_triangles)
def r(v, n=4):
"""Round a float or a vector for compact JSON."""
if isinstance(v, float):
return round(v, n)
return [round(x, n) for x in v]
scripts/auto_retopo.py
"""Make a low-poly retopo copy of a dense mesh (sculpt/scan/AI-generated) at a triangle budget.
Methods: quadriflow (clean quads; needs manifold input - auto voxel-remesh first),
decimate (fast, keeps UVs/shape; triangles), planar (hard-surface: dissolve flat areas).
The result is shrinkwrapped back onto the source and named <source>_low.
Usage:
blender -b f.blend -P auto_retopo.py -- --objects Sculpt [--method quadriflow|decimate|planar]
[--tris 10000] [--voxel 0] [--symmetry] [--save out.blend]
MCP: import auto_retopo; print(auto_retopo.run(objects=["Sculpt"], tris=8000))
--tris is the triangle budget (quadriflow targets tris/2 quads).
"""
import math
import os
import sys
import bpy
sys.path.insert(0, os.path.dirname(__file__))
import _bpyx as bx # noqa: E402
def _ctx(ob):
return bpy.context.temp_override(object=ob, active_object=ob, selected_objects=[ob],
selected_editable_objects=[ob])
def _apply_all(ob):
for name in [m.name for m in ob.modifiers]: # names: applied modifiers are freed
with _ctx(ob):
bpy.ops.object.modifier_apply(modifier=name)
def run(objects=None, method="quadriflow", tris=10000, voxel=0.0, symmetry=False, **_):
bx.object_mode()
out = {}
for src in bx.objects(objects):
low = src.copy()
low.data = src.data.copy()
low.name = low.data.name = f"{src.name}_low"
for c in src.users_collection:
c.objects.link(low)
_apply_all(low)
before = len(low.data.polygons)
tris_before = bx.mesh_tris(low, evaluated=False)
faces = max(tris // 2, 4)
if method == "quadriflow":
# Quadriflow needs a closed manifold: voxel remesh first (size from target density)
area = sum(p.area for p in low.data.polygons) * max(low.scale) ** 2
low.data.remesh_voxel_size = voxel or max(math.sqrt(area / max(faces * 4, 1)), 1e-4)
with _ctx(low):
bpy.ops.object.voxel_remesh()
res = bpy.ops.object.quadriflow_remesh(mode="FACES", target_faces=faces,
use_mesh_symmetry=symmetry,
use_preserve_sharp=True, seed=0)
if res != {"FINISHED"}:
raise RuntimeError("quadriflow failed (non-manifold after voxel remesh?)")
elif method == "decimate":
d = low.modifiers.new("Decimate", "DECIMATE")
d.decimate_type = "COLLAPSE"
d.ratio = min(1.0, tris / max(tris_before, 1))
d.use_symmetry = symmetry
d.use_collapse_triangulate = False
_apply_all(low)
elif method == "planar":
d = low.modifiers.new("Decimate", "DECIMATE")
d.decimate_type = "DISSOLVE"
d.angle_limit = math.radians(5)
d.delimit = {"SEAM", "SHARP", "MATERIAL", "UV"}
_apply_all(low)
if method != "planar":
sw = low.modifiers.new("Shrinkwrap", "SHRINKWRAP")
sw.target, sw.wrap_method, sw.wrap_mode = src, "PROJECT", "ON_SURFACE"
sw.use_negative_direction = sw.use_positive_direction = True
_apply_all(low)
src.hide_set(True)
src.hide_render = True
out[src.name] = {"low": low.name, "faces_before": before, "faces_after": len(low.data.polygons),
"tris_after": bx.mesh_tris(low, evaluated=False),
"uvs_kept": bool(low.data.uv_layers) and method != "quadriflow"}
return {"ok": True, "method": method, "objects": out,
"next": "check edge flow (look wireframe), unwrap (uv_pack.py), bake (blender-texture-baking)"}
if __name__ == "__main__":
p = bx.add_common(bx.parser(__doc__))
p.add_argument("--method", default="quadriflow", choices=["quadriflow", "decimate", "planar"])
p.add_argument("--tris", type=int, default=10000)
p.add_argument("--voxel", type=float, default=0.0)
p.add_argument("--symmetry", action="store_true")
bx.cli(run, p)
scripts/texel_density.py
"""Measure (and optionally set) texel density in px/m for objects' active UV map.
Reports per-object area-weighted mean, min/max over islands, and spread (max/min).
--set scales every island to the target density (about its UV centre) then repacks
without rescaling; reports overflow if it no longer fits in 0-1.
Usage:
blender -b f.blend -P texel_density.py -- [--objects A B] [--res 2048] [--set 512] [--margin-px 8] [--save out.blend]
MCP: import texel_density; print(texel_density.run(objects=["SM_Crate"], res=2048))
"""
import math
import os
import sys
import bmesh
import bpy
sys.path.insert(0, os.path.dirname(__file__))
import _bpyx as bx # noqa: E402
def _islands(bm, uv):
"""UV islands as lists of faces (connected through shared UV coords)."""
seen, out = set(), []
for f in bm.faces:
if f.index in seen:
continue
stack, isl = [f], []
seen.add(f.index)
while stack:
cur = stack.pop()
isl.append(cur)
for l in cur.loops:
rad = l.link_loop_radial_next
nf = rad.face
if nf.index in seen or rad == l:
continue
# same UV on both sides of the edge -> connected
a1, a2 = l[uv].uv, l.link_loop_next[uv].uv
b1, b2 = rad.link_loop_next[uv].uv, rad[uv].uv
if (a1 - b1).length < 1e-5 and (a2 - b2).length < 1e-5:
seen.add(nf.index)
stack.append(nf)
out.append(isl)
return out
def _uv_area(f, uv):
pts = [l[uv].uv for l in f.loops]
return abs(sum(pts[i].x * pts[i - 1].y - pts[i - 1].x * pts[i].y for i in range(len(pts)))) / 2
def _measure(ob, res):
bm = bmesh.new()
bm.from_mesh(ob.data)
bm.transform(ob.matrix_world)
uv = bm.loops.layers.uv.active
if uv is None:
bm.free()
return None, None, None
bm.faces.ensure_lookup_table()
isl_d = []
tot_w = tot_uv = 0.0
for isl in _islands(bm, uv):
wa = sum(f.calc_area() for f in isl)
ua = sum(_uv_area(f, uv) for f in isl)
tot_w, tot_uv = tot_w + wa, tot_uv + ua
if wa > 1e-10:
isl_d.append((math.sqrt(ua / wa) * res, [f.index for f in isl]))
mean = math.sqrt(tot_uv / tot_w) * res if tot_w > 1e-10 else 0.0
return bm, mean, isl_d
def run(objects=None, res=2048, set=None, margin_px=8, **_): # noqa: A002 (CLI name)
bx.object_mode()
out = {}
for ob in bx.objects(objects):
bm, mean, isl = _measure(ob, res)
if bm is None:
out[ob.name] = {"error": "no UV map"}
continue
bm.free()
dens = [d for d, _ in isl] or [0]
rec = {"px_per_m": round(mean, 1), "island_min": round(min(dens), 1),
"island_max": round(max(dens), 1), "islands": len(isl),
"spread": round(max(dens) / max(min(dens), 1e-6), 2)}
if set:
bm = bmesh.new()
bm.from_mesh(ob.data)
uv = bm.loops.layers.uv.active
bm.faces.ensure_lookup_table()
for d, faces in isl:
k = set / max(d, 1e-6)
loops = [l for i in faces for l in bm.faces[i].loops]
c = sum((l[uv].uv for l in loops), loops[0][uv].uv * 0) / len(loops)
for l in loops:
l[uv].uv = c + (l[uv].uv - c) * k
bm.to_mesh(ob.data)
bm.free()
bx.select_only([ob])
bpy.ops.object.mode_set(mode="EDIT")
bpy.ops.mesh.select_all(action="SELECT")
bpy.ops.uv.select_all(action="SELECT")
bpy.ops.uv.pack_islands(scale=False, rotate=True, shape_method="AABB", margin_method="FRACTION",
margin=margin_px / res)
bpy.ops.object.mode_set(mode="OBJECT")
bm, mean2, isl2 = _measure(ob, res)
uvl = ob.data.uv_layers.active.data
co = [d.uv for d in uvl]
over = any(u.x > 1.0001 or u.y > 1.0001 or u.x < -1e-4 or u.y < -1e-4 for u in co)
bm.free()
rec["after"] = {"px_per_m": round(mean2, 1), "fits_0_1": not over}
if over:
rec["after"]["hint"] = "lower density, raise --res, or split into more texture sets/UDIMs"
out[ob.name] = rec
return {"ok": True, "res": res, "objects": out}
if __name__ == "__main__":
p = bx.add_common(bx.parser(__doc__))
p.add_argument("--res", type=int, default=2048)
p.add_argument("--set", type=float, default=None, help="target px/m")
p.add_argument("--margin-px", type=int, default=8)
bx.cli(run, p)
scripts/uv_pack.py
"""Unwrap + pack UVs with production defaults; optional seams-from-sharp and lightmap UV2.
Methods: smart (auto seams by angle), seams (use marked seams; --seams-from-sharp marks
hard edges first), min_stretch (SLIM, marked seams), lightmap (non-overlapping UV2 only).
Margins are given in pixels at --res so padding survives mip-mapping.
Usage:
blender -b f.blend -P uv_pack.py -- [--objects A B] [--method smart|seams|min_stretch]
[--angle 66] [--seams-from-sharp 60] [--res 2048] [--margin-px 8] [--lightmap]
[--shape AABB|CONVEX|CONCAVE] [--save out.blend]
MCP: import uv_pack; print(uv_pack.run(objects=["SM_Crate"], method="seams", seams_from_sharp=60))
"""
import math
import os
import sys
import bmesh
import bpy
sys.path.insert(0, os.path.dirname(__file__))
import _bpyx as bx # noqa: E402
def _seams_from_sharp(ob, angle):
bm = bmesh.new()
bm.from_mesh(ob.data)
n = 0
for e in bm.edges:
if len(e.link_faces) == 2 and e.calc_face_angle(0) > math.radians(angle):
e.seam = True
n += 1
elif len(e.link_faces) == 1:
e.seam = True
bm.to_mesh(ob.data)
bm.free()
return n
def _collapsed_ratio(ob):
"""Fraction of faces whose UV area is ~0 (failed unwrap)."""
uv = ob.data.uv_layers.active.data
bad = 0
for p in ob.data.polygons:
pts = [uv[i].uv for i in p.loop_indices]
a = sum(pts[i].x * pts[i - 1].y - pts[i - 1].x * pts[i].y for i in range(len(pts)))
bad += abs(a) < 1e-10
return bad / max(len(ob.data.polygons), 1)
def run(objects=None, method="smart", angle=66.0, seams_from_sharp=None, res=2048, margin_px=8,
lightmap=False, shape="AABB", **_):
bx.object_mode()
obs = bx.objects(objects)
margin = margin_px / res
rep = {}
for ob in obs:
if seams_from_sharp:
rep.setdefault(ob.name, {})["seams_marked"] = _seams_from_sharp(ob, seams_from_sharp)
if not ob.data.uv_layers:
ob.data.uv_layers.new(name="UVMap")
bx.select_only(obs)
bpy.ops.object.mode_set(mode="EDIT")
bpy.ops.mesh.select_all(action="SELECT")
if method == "smart":
bpy.ops.uv.smart_project(angle_limit=math.radians(angle), island_margin=margin,
area_weight=0.0, correct_aspect=True, scale_to_bounds=False)
else:
bpy.ops.uv.unwrap(method="MINIMUM_STRETCH" if method == "min_stretch" else "ANGLE_BASED",
fill_holes=True, correct_aspect=True, margin_method="FRACTION", margin=margin)
bpy.ops.object.mode_set(mode="OBJECT")
bad = [ob.name for ob in obs if _collapsed_ratio(ob) > 0.2] # closed island without seams
bpy.ops.object.mode_set(mode="EDIT")
if bad:
bpy.ops.uv.smart_project(angle_limit=math.radians(angle), island_margin=margin,
area_weight=0.0, correct_aspect=True, scale_to_bounds=False)
for n in bad:
rep.setdefault(n, {})["fallback"] = "smart (unwrap collapsed: add seams for control)"
bpy.ops.uv.select_all(action="SELECT")
bpy.ops.uv.average_islands_scale()
bpy.ops.uv.pack_islands(rotate=True, rotate_method="ANY", scale=True, shape_method=shape,
margin_method="FRACTION", margin=margin)
bpy.ops.object.mode_set(mode="OBJECT")
if lightmap:
for ob in obs:
uvs = ob.data.uv_layers
lm = uvs.get("UVMap_Lightmap") or uvs.new(name="UVMap_Lightmap")
uvs.active = lm
bpy.ops.object.mode_set(mode="EDIT")
bpy.ops.mesh.select_all(action="SELECT")
bpy.ops.uv.lightmap_pack(PREF_CONTEXT="ALL_FACES", PREF_PACK_IN_ONE=False,
PREF_MARGIN_DIV=max(0.05, margin_px / 4))
bpy.ops.object.mode_set(mode="OBJECT")
for ob in obs:
ob.data.uv_layers.active = ob.data.uv_layers[0] # keep UV0 active for texturing
for ob in obs:
rep.setdefault(ob.name, {})["uv_maps"] = [u.name for u in ob.data.uv_layers]
return {"ok": True, "method": method, "margin_fraction": round(margin, 5), "objects": rep}
if __name__ == "__main__":
p = bx.add_common(bx.parser(__doc__))
p.add_argument("--method", default="smart", choices=["smart", "seams", "min_stretch"])
p.add_argument("--angle", type=float, default=66.0)
p.add_argument("--seams-from-sharp", type=float, default=None)
p.add_argument("--res", type=int, default=2048)
p.add_argument("--margin-px", type=int, default=8)
p.add_argument("--lightmap", action="store_true")
p.add_argument("--shape", default="AABB", choices=["AABB", "CONVEX", "CONCAVE"],
help="AABB fast; CONCAVE tightest but 2-25s headless even on small meshes")
bx.cli(run, p)
SKILL.md
---
name: blender-retopology-uv
description: Retopology and UVs in Blender 5.2 - auto retopo (Quadriflow, Decimate, planar), deformation edge flow, seams, unwrap (Angle Based, Minimum Stretch, Smart), pixel-margin packing, texel density measure/set, lightmap UV2, UDIMs, mirrored UVs. Use to turn sculpts/scans/AI meshes into game meshes or prep UVs for texturing/baking.
license: MIT
metadata:
author: blender-agent-skills
version: "1.2.0"
---
# Retopology & UVs
The execution model is in `blender-core`.
## Retopology
**Automatic** (`scripts/auto_retopo.py -- --objects Sculpt --tris 8000 --method ...`) makes `<name>_low` and leaves the source hidden:
| Method | Use for | Notes |
|---|---|---|
| `quadriflow` | organic props, static characters, scans | clean quads. Voxel-remeshes first so the input is manifold; UVs are lost (re-unwrap) |
| `decimate` | background props, LODs, AI-generated meshes | keeps UVs/shape, gives triangles. Fast |
| `planar` | hard-surface CAD/boolean results | dissolves flat areas; keeps seams/sharp/material/UV borders |
**Manual** (hero characters, anything that deforms). Set up a new mesh object with a Shrinkwrap modifier (`PROJECT`, target = sculpt), snapping to `FACE_PROJECT` + `use_snap_project`, and the source in a different viewport color/x-ray. Auto retopo isn't good enough for deforming hero meshes, because the edge flow below matters.
### Edge flow rules for deforming meshes
- Concentric loops around the eyes and mouth (≥3 rings). The nasolabial loop flows from the nose wing to the mouth corner.
- **3 loops** at every bending joint (elbow, knee, finger knuckles), plus a loop on each side to keep volume. The shoulder needs extra loops for raising the arm.
- Keep poles (3/5-edge verts) out of bending areas. Put them on flat, stiff regions (chest, back of skull).
- Distribute quads evenly, with density where deformation and silhouette need it. Use triangles only in hidden, non-deforming spots.
- Character tri budgets in 2026: mobile 5–15k, Roblox body ≤ 10.7k total, console/PC third-person 30–80k, hero/cinematic 100k+. Check the target in `blender-game-optimization`.
## UVs
1. **Seams**: hide them (under arms, inner legs, back of the head, under parts), follow hard edges, cut cylinders on the back side. Rule for game meshes: every hard edge (>~60°, `sharp_edge`) **must** be a UV seam, or the normal map shows artifacts. `scripts/uv_pack.py --seams-from-sharp 60` does this.
2. **Unwrap**: `--method seams` (Angle Based with marked seams), `--method min_stretch` (SLIM, 4.3+, least distortion on organics), `--method smart` (hard-surface/auto, `--angle 66`).
3. **Straighten** rectangular islands (Follow Active Quads, live only) so trims and pixel grids line up.
4. **Average island scale + pack** with margin in pixels: 2k → 8 px, 4k → 16 px, mobile 1k → 4 px. `--shape CONCAVE` gives the tightest pack (slow headless). The default `AABB` is instant.
5. **Texel density** (`scripts/texel_density.py --res 2048`): keep `spread` ≤ 1.3 within an asset. Set it with `--set <px/m>`; if `fits_0_1` is false, change res or density.
6. QA: `blender-asset-qa` reports zero-area UV faces and overlap %.
### Texel density targets (px/m; studio baselines, not laws)
| Context | px/m |
|---|---|
| First-person hero / weapons | 1024–2048 |
| First-person environment | 512–1024 |
| Third-person games | 512 (console/PC), 256 (mobile) |
| Top-down / strategy | 128–256 |
| Roblox | ~256–512 per texture: 5×5 studs → 256², 10×10 → 512², 20×20 → 1024² |
Everything in the same scene should share one density. The exception is hero assets, kept within 2x.
## Special cases
- **Mirrored/stacked UVs** save space but break unique bakes. Move the duplicate islands exactly +1 U (outside 0–1) during baking, then move them back. Their normal maps must be mirror-safe (tangent space handles it in engines with MikkTSpace).
- **Lightmap UV2** (`--lightmap`, gives `UVMap_Lightmap`): no overlaps, every island inside 0–1, generous margin. Unreal/Unity can generate their own; ship one only if the engine pipeline wants it. Keep UV0 active for texturing.
- **UDIMs** (film/hero): islands in tiles 1001+ (U offset 1 per tile). Texture node image `source="TILED"`. Game engines mostly don't support UDIMs.
- **Trim sheets**: unwrap the strips, then scale/move the islands onto the trim rows (V positions from the trim layout). Islands may exceed 0–1 in U for tiling.
- **Roblox**: exactly one UV set, all inside 0–1. Overlaps are allowed.
## Gotchas
- 5.0+: UV selection is shared across UV maps (`.uv_select_*` attributes). Old `BMLoopUV.select` code fails.
- `uv.*` operators run in Edit Mode and act on selected faces. Select everything first (scripts handle this).
- Headless `uv.pack_islands` with `CONCAVE`/`CONVEX` + rotation can take 2–25 s even on tiny meshes. Use `AABB` while iterating.
- Unwrap warning "failed to solve island": the island has no seams (closed shape). Add seams.
- Apply scale before measuring texel density, or the density reads wrong (the scripts use world space and already account for scale).