Stage the Blender character/gear/prop library

Commit the finished generator output from the parallel Blender pass:
8 skinned NPC variants, 18 gear pieces, and 20 props under
assets/models/blender/, editable sources (gdignored), manifest, review sheets,
and the integration notes in docs/blender-assets.md.

- audit: all 46 assets clean (characters 3,788-4,640 tris, under 6000/4000)
- tests: Python manifest/audit contract and Godot import/bone/slot/ground checks
- docs: README library section, architecture/plan references, TODO Phase 3.5
  status, MEMORY integration rules
- .gitignore: per-asset preview PNGs, extracted GLB textures, blend1 backups

Nothing in the game loads the library yet; integration follows." && git log --oneline -1
This commit is contained in:
zwitschi committed 2026-11-01 14:33:30 +01:00
1 parent 7efb5024ec
commit d513e0b82c
109 files changed
+1916 -114

No files matched your search

+204 -79
View File
@@ -9,12 +9,12 @@ integration: nothing in the game loads them yet (see
# build every asset (or --only name,name) -> assets/models/blender/**.glb
flatpak run org.blender.Blender --background \
--python "$PWD/tools/blender_assets.py" -- build
# Cycles preview renders -> docs/blender_previews/<asset>.png
# Cycles preview renders -> docs/blender_previews/<asset>.png (local only)
flatpak run org.blender.Blender --background \
--python "$PWD/tools/blender_assets.py" -- render
# outside Blender (stdlib + Pillow)
python3 tools/blender_assets.py audit # GLB contract checks
python3 tools/blender_assets.py sheet # contact sheets from previews
python3 tools/blender_assets.py sheet # sheet_<category>.jpg (committed)
python3 tools/blender_assets.py list
The flatpak sandbox cwd is /app/blender and it cannot read /tmp, so every path
@@ -68,31 +68,38 @@ except ImportError: # pragma: no cover - audit/sheet/list run outside Blender
# pick one per avatar later. `tint` lists the slots the runtime should tint;
# every other material keeps its authored colour.
CHARACTERS = {
"npc_casual": {"layer": "none", "hair": "short",
"npc_casual": {"layer": "none", "hair": "short", "gear": ["backpack", "camera"],
"roles": ["tourist", "confused_visitor", "intoxicated", "guest"]},
"npc_jacket": {"layer": "jacket", "hair": "short",
"npc_jacket": {"layer": "jacket", "hair": "short", "gear": ["lanyard", "clipboard"],
"roles": ["authorized_staff", "protester", "guest"]},
"npc_coat": {"layer": "coat", "hair": "long",
"gear": ["sunglasses", "shoulder_bag", "phone"],
"roles": ["influencer", "trespasser", "violent_threat"]},
"npc_vest": {"layer": "vest", "hair": "buzz",
"npc_vest": {"layer": "vest", "hair": "buzz", "gear": ["cap", "clipboard"],
"roles": ["delivery"]},
"npc_dress": {"layer": "dress", "hair": "bun", "build": "slim",
"gear": ["shoulder_bag"],
"roles": ["performer"]},
"npc_hoodie": {"layer": "hood", "hair": "short",
"npc_hoodie": {"layer": "hood", "hair": "short", "gear": ["cup"],
"roles": ["trespasser", "mental_crisis", "violent_threat"]},
"npc_firefighter": {"layer": "responder", "hair": "buzz", "build": "broad",
"gear": ["firefighter_helmet", "scba_tank", "flashlight"],
"roles": ["responder"]},
"npc_guard": {"layer": "guard", "hair": "buzz", "build": "broad",
"gear": ["cap", "wand"],
"roles": ["player_guard", "venue_security"]},
}
# Gear origin = the attach point; offsets are in the attach node's local space.
GEAR_ATTACH = {
"head": "Rig/Head marker (y = 1.62 m, head centre)",
"hand": "Hand proxy (grip centre, item extends along -Y = down the forearm)",
"hand": "Hand proxy (Godot (+-0.27, 0.78, 0.02)); grip sits 5 cm below the origin",
"back": "Rig root, origin at the spine centre (0, 1.10, 0)",
"chest": "Rig root, origin at the sternum (0, 1.30, 0)",
}
# Same attach points in Blender coordinates (Godot (x, y, z) -> Blender (x, -z, y)).
ATTACH_BLENDER = {"head": (0.0, 0.0, 1.62), "hand": (0.27, -0.02, 0.78),
"back": (0.0, 0.0, 1.10), "chest": (0.0, 0.0, 1.30)}
GEAR = {
"cap": "head", "beanie": "head", "hood": "head", "sunglasses": "head",
"firefighter_helmet": "head",
@@ -292,6 +299,7 @@ def _finish(obj, mat, smooth, sharp_angle=35.0, bevel=0.0, bevel_segments=1):
mod.limit_method = "ANGLE"
mod.angle_limit = math.radians(40)
_apply_all(obj)
_clean(obj)
if smooth:
obj.data.shade_smooth()
obj.data.set_sharp_from_angle(angle=math.radians(sharp_angle))
@@ -300,6 +308,16 @@ def _finish(obj, mat, smooth, sharp_angle=35.0, bevel=0.0, bevel_segments=1):
return obj
def _clean(obj):
"""Drop zero-area faces (thin bevels, lathe poles) before export."""
bm = bmesh.new()
bm.from_mesh(obj.data)
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-5)
bmesh.ops.dissolve_degenerate(bm, dist=1e-5, edges=bm.edges)
bm.to_mesh(obj.data)
bm.free()
def _apply_all(obj):
bpy.context.view_layer.objects.active = obj
for o in bpy.context.selected_objects:
@@ -347,14 +365,20 @@ def torus(name, major, minor, loc, mat, rot=(0, 0, 0), seg=20, minor_seg=6,
return _finish(obj, mat, smooth=True, sharp_angle=70)
def lathe(name, profile, mat, seg=16, loc=(0, 0, 0), smooth=True, sharp=30.0):
"""Revolve [(radius, z), ...] around Z (bottom to top), capped."""
def lathe(name, profile, mat, seg=16, loc=(0, 0, 0), smooth=True, sharp=30.0,
face_front=False, caps=True):
"""Revolve [(radius, z), ...] around Z (bottom to top), capped.
`face_front` rotates the ring half a step so a flat face (not a corner)
points at the front (-Y); used for octagonal street furniture.
"""
phase = (math.pi / seg + math.pi / 2) if face_front else 0.0
bm = bmesh.new()
rings = []
for r, z in profile:
ring = []
for i in range(seg):
a = math.tau * i / seg
a = math.tau * i / seg + phase
ring.append(bm.verts.new((loc[0] + r * math.cos(a),
loc[1] + r * math.sin(a), loc[2] + z)))
rings.append(ring)
@@ -362,9 +386,9 @@ def lathe(name, profile, mat, seg=16, loc=(0, 0, 0), smooth=True, sharp=30.0):
for i in range(seg):
j = (i + 1) % seg
bm.faces.new((a[i], a[j], b[j], b[i]))
if profile[0][0] > 1e-4:
if caps and profile[0][0] > 1e-4:
bm.faces.new(list(reversed(rings[0])))
if profile[-1][0] > 1e-4:
if caps and profile[-1][0] > 1e-4:
bm.faces.new(rings[-1])
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-5)
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
@@ -416,14 +440,14 @@ def join(objs, name):
return obj
def uv_unwrap(obj):
def uv_unwrap(obj, angle=60.0):
for o in bpy.context.selected_objects:
o.select_set(False)
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
bpy.ops.object.mode_set(mode="EDIT")
bpy.ops.mesh.select_all(action="SELECT")
bpy.ops.uv.smart_project(angle_limit=math.radians(60.0), island_margin=0.01)
bpy.ops.uv.smart_project(angle_limit=math.radians(angle), island_margin=0.01)
bpy.ops.object.mode_set(mode="OBJECT")
@@ -481,12 +505,12 @@ def _skin_body(spec):
"guard": 0.03, "vest": 0.02}.get(layer, 0.0)
sleeve = 1.0 + {"coat": 0.12, "jacket": 0.1, "hood": 0.1, "responder": 0.22,
"guard": 0.05}.get(layer, 0.0)
leg = 1.0 + {"responder": 0.2, "dress": -0.1}.get(layer, 0.0)
leg = 1.0 + {"responder": 0.1, "dress": -0.1}.get(layer, 0.0)
nodes = [ # (pos, radius_x, radius_y)
((0, 0, 0.80), 0.165 * w, 0.115 * d), # 0 pelvis
((0, 0, 0.96), 0.150 * w * bulk, 0.105 * d * bulk), # 1 waist
((0, 0.0, 1.18), 0.170 * w * bulk, 0.115 * d * bulk), # 2 chest
((0, 0.0, 1.33), 0.175 * w * bulk, 0.110 * d * bulk), # 3 upper chest
((0, 0.0, 1.355), 0.172 * w * bulk, 0.108 * d * bulk), # 3 upper chest
((0, 0, 1.44), 0.058, 0.058), # 4 neck base
((0, -0.005, 1.53), 0.052, 0.052), # 5 neck top
]
@@ -494,7 +518,7 @@ def _skin_body(spec):
for s in (-1.0, 1.0):
base = len(nodes)
nodes += [
((0.20 * s * w, 0, 1.37), 0.075 * t * sleeve, 0.07 * t * sleeve), # shoulder
((0.20 * s * w, 0, 1.39), 0.075 * t * sleeve, 0.07 * t * sleeve), # shoulder
((0.27 * s * w, 0, 1.24), 0.058 * t * sleeve, 0.058 * t * sleeve), # upper arm
((0.275 * s * w, 0.005, 1.06), 0.046 * t * sleeve, 0.048 * t * sleeve), # elbow
((0.275 * s * w, -0.01, 0.80), 0.034 * t, 0.030 * t), # wrist
@@ -521,7 +545,7 @@ def _skin_body(spec):
sub = obj.modifiers.new("Sub", "SUBSURF")
sub.levels = 2
dec = obj.modifiers.new("Decimate", "DECIMATE")
dec.ratio = 0.5
dec.ratio = 0.36
dec.use_symmetry = True
dec.symmetry_axis = "X"
_apply_all(obj)
@@ -531,6 +555,9 @@ def _skin_body(spec):
for z in (0.86, 1.19, 1.43):
geom = bm.verts[:] + bm.edges[:] + bm.faces[:]
bmesh.ops.bisect_plane(bm, geom=geom, plane_co=(0, 0, z), plane_no=(0, 0, 1))
# Cuts next to existing vertices leave slivers; weld them.
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=6e-4)
bmesh.ops.dissolve_degenerate(bm, dist=1e-4, edges=bm.edges)
bm.to_mesh(obj.data)
bm.free()
# Front of the chest/backside slightly shaped: push chest forward a touch.
@@ -552,10 +579,10 @@ def _assign_body_slots(obj, spec, mats):
obj.data.materials.append(m)
idx = {n: i for i, n in enumerate(names)}
short_sleeve = layer in ("none", "vest", "dress")
top_slot = {"vest": "undershirt", "responder": "turnout",
"guard": "cloth"}.get(layer, "cloth")
leg_slot = {"responder": "turnout", "dress": "skin"}.get(layer, "pants")
waist = 0.86 if layer != "dress" else 0.0
# Main surfaces always use the tintable contract slots (dress legs = tights).
top_slot = "cloth"
leg_slot = "pants"
waist = 0.86
for poly in obj.data.polygons:
c = poly.center
arm = abs(c.x) > 0.205 and c.z > 0.78
@@ -648,6 +675,32 @@ def _hair(style, mat_hair, broad=False):
return parts
def _hood(name, mat, head_center):
"""Hood shell around the head with an oval face opening and a solid rim."""
cx, cy, cz = head_center
bm = bmesh.new()
bmesh.ops.create_uvsphere(bm, u_segments=20, v_segments=14, radius=0.19)
bmesh.ops.transform(bm, matrix=_xf((cx, cy + 0.025, cz + 0.02), (0, 0, 0),
(0.97, 1.05, 1.06)), verts=bm.verts)
doomed = [v for v in bm.verts
if v.co.z < cz - 0.17
or (v.co.y < cy - 0.02 and ((v.co.x - cx) / 0.125) ** 2
+ ((v.co.z - cz + 0.01) / 0.165) ** 2 < 1.0)]
bmesh.ops.delete(bm, geom=doomed, context="VERTS")
# Snap the stair-stepped opening onto the ellipse for a clean edge.
for v in bm.verts:
if v.co.y < cy and v.is_boundary and v.co.z > cz - 0.16:
dx, dz = (v.co.x - cx) / 0.125, (v.co.z - cz + 0.01) / 0.165
k = math.hypot(dx, dz)
if k > 1e-6:
v.co.x = cx + dx / k * 0.125
v.co.z = cz - 0.01 + dz / k * 0.165
# Thicken into a shell so the open edge reads as fabric, not a paper cut.
bmesh.ops.solidify(bm, geom=bm.faces[:], thickness=0.018)
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
return _finish(_link(name, bm), mat, smooth=True, sharp_angle=60)
def _face(mats, spec):
skin, acc, hair = mats["skin"], mats["accessory"], mats["hair"]
parts = [sphere("Head", 0.155, (0, 0, 1.635), skin, seg=18, rings=12,
@@ -666,7 +719,7 @@ def _face(mats, spec):
rot=(0, 0.12 * s, 0.0)))
parts.append(box("Mouth", (0.06, 0.012, 0.01), (0, -0.142, 1.585), acc))
if spec.get("beard"):
parts.append(sphere("Beard", 0.105, (0, -0.06, 1.55), hair, seg=16, rings=10,
parts.append(sphere("Beard", 0.105, (0, -0.06, 1.55), hair, seg=12, rings=8,
scale=(1.0, 0.9, 0.75), cut_below=-0.2))
return parts
@@ -678,12 +731,12 @@ def _hands_and_shoes(mats, spec):
w = BUILDS[spec.get("build", "average")][0]
for s in (-1.0, 1.0):
x = 0.275 * s * w
parts.append(sphere("Palm", 0.045, (x, -0.01, 0.735), skin, seg=12, rings=8,
parts.append(sphere("Palm", 0.045, (x, -0.01, 0.735), skin, seg=10, rings=6,
scale=(0.55, 0.95, 1.25)))
parts.append(sphere("Thumb", 0.018, (x - 0.01 * s, -0.045, 0.75), skin, seg=8,
rings=6, scale=(1.0, 1.0, 1.8), rot=(0.4, 0, 0)))
parts.append(box("Shoe", (0.10, 0.25, 0.075), (0.12 * s, -0.045, 0.04), shoe,
bevel=0.028, segs=2))
bevel=0.028, segs=1))
parts.append(box("Sole", (0.106, 0.262, 0.02), (0.12 * s, -0.045, 0.01),
material("rubber", textured_solid=True)))
return parts
@@ -696,15 +749,24 @@ def _clothing(spec, mats):
parts = []
if layer in ("coat", "dress", "responder"):
bottom = {"coat": 0.36, "dress": 0.40, "responder": 0.52}[layer]
flare = {"coat": 0.215, "dress": 0.26, "responder": 0.21}[layer]
mat = cloth if layer != "responder" else mats["turnout"]
parts.append(lathe("Skirt", [(flare, bottom), (flare * 0.92, bottom + 0.1),
(0.185, 0.80), (0.16, 0.93)], mat, seg=20))
flare = {"coat": 0.225, "dress": 0.26, "responder": 0.255}[layer]
mat = cloth
top = 0.17 if layer != "responder" else 0.20
parts.append(lathe("Skirt", [(flare, bottom), (flare * 0.95, bottom + 0.1),
(top + 0.03, 0.80), (top, 0.93)], mat, seg=16,
caps=False))
# Double-sided shell: an inner copy with flipped normals, so the open
# hem does not vanish under Godot's back-face culling.
bm = bmesh.new()
bm.from_mesh(parts[-1].data)
bmesh.ops.solidify(bm, geom=bm.faces[:], thickness=0.012)
bm.to_mesh(parts[-1].data)
bm.free()
for o in parts[-1:]:
for v in o.data.vertices: # flatten front/back
v.co.y *= 0.78
v.co.y *= 0.8
if layer in ("coat", "jacket", "guard", "responder"):
mat = cloth if layer != "responder" else mats["turnout"]
mat = cloth
parts.append(torus("Collar", 0.075, 0.025, (0, 0.005, 1.455), mat,
rot=(math.radians(8), 0, 0), seg=16))
parts.append(box("Placket", (0.018, 0.01, 0.52), (0, -0.128, 1.13), acc))
@@ -724,29 +786,18 @@ def _clothing(spec, mats):
parts.append(box("Strap", (0.03, 0.012, 0.18), (0.07 * s, -0.115, 1.25), refl,
rot=(0, 0.12 * s, 0)))
if layer == "hood":
parts.append(sphere("Hood", 0.20, (0, 0.03, 1.66), cloth, seg=20, rings=12,
scale=(0.95, 1.05, 1.05), cut_below=-0.55))
# open the face: drop front-facing verts of the hood
hood = parts[-1]
bm = bmesh.new()
bm.from_mesh(hood.data)
front = [v for v in bm.verts if v.co.y < -0.08 and v.co.z < 1.79 and v.co.z > 1.50]
bmesh.ops.delete(bm, geom=front, context="VERTS")
bm.to_mesh(hood.data)
bm.free()
parts.append(torus("HoodRim", 0.13, 0.022, (0, -0.075, 1.645), cloth,
rot=(math.radians(78), 0, 0), scale=(0.95, 1.0, 1.2)))
parts.append(box("Pocket", (0.22, 0.02, 0.12), (0, -0.115, 0.98), cloth, bevel=0.01))
parts.append(_hood("Hood", cloth, (0, 0, 1.635)))
parts.append(box("Pocket", (0.22, 0.02, 0.12), (0, -0.115, 0.98), cloth))
for s in (-1.0, 1.0):
parts.append(cyl("Drawstring", 0.006, 0.006, 0.12, (0.04 * s, -0.125, 1.38),
acc, seg=5))
if layer == "responder":
refl = mats["reflective"]
hv = mats["hivis"]
for z in (0.62, 1.00):
band = torus("Band", 0.20, 0.016, (0, 0, z), hv, seg=24, minor_seg=4)
for z, r in ((0.60, 0.252), (1.16, 0.205)):
band = torus("Band", r, 0.014, (0, 0, z), hv, seg=20, minor_seg=4)
for v in band.data.vertices:
v.co.y *= 0.78
v.co.y *= 0.8 if z < 1.0 else 0.66
parts.append(band)
for s in (-1.0, 1.0):
parts.append(torus("ArmBand", 0.07, 0.012, (0.285 * s, 0, 1.0), refl,
@@ -764,7 +815,7 @@ def _clothing(spec, mats):
parts.append(box("Patch", (0.012, 0.07, 0.07), (0.33 * s, 0, 1.28),
mats["patch"], bevel=0.005))
parts.append(box("Pouch", (0.06, 0.05, 0.08), (0.14 * s, -0.08, 0.84),
mats["leather"], bevel=0.01))
mats["leather"]))
parts.append(box("Radio", (0.05, 0.03, 0.09), (-0.12, -0.12, 1.30),
mats["plastic_black"], bevel=0.008))
return parts
@@ -775,14 +826,13 @@ def build_character(name):
if name == "npc_hoodie":
spec["beard"] = True
mats = {slot: material(slot) for slot in SLOTS}
for extra in ("undershirt", "turnout", "hivis", "reflective", "leather",
for extra in ("hivis", "reflective", "leather",
"steel", "patch", "plastic_black", "rubber", "helmet"):
mats[extra] = material(extra, textured_solid=True)
layout = _bone_layout()
body = _skin_body(spec)
body_mats = [mats["skin"], mats["cloth"], mats["pants"], mats["undershirt"],
mats["turnout"]]
body_mats = [mats["skin"], mats["cloth"], mats["pants"]]
_assign_body_slots(body, spec, body_mats)
_auto_weights(body, layout)
@@ -819,15 +869,14 @@ def build_character(name):
_auto_weights(cl, layout, rigid=cloth_bone)
rest.append(cl)
for o in rest:
print(f" part {o.name}: {len(o.data.polygons)} faces {len(o.data.vertices)} verts")
mesh = join(rest, "npc_base")
# Every contract slot must exist even if a variant does not paint with it.
present = {m.name for m in mesh.data.materials if m}
for slot in SLOTS:
if slot not in present:
mesh.data.materials.append(mats[slot])
uv_unwrap(mesh)
# Fewer, larger islands keep the seam-split vertex count inside the budget.
uv_unwrap(mesh, angle=80.0)
arm_data = bpy.data.armatures.new("Armature")
arm = bpy.data.objects.new("Armature", arm_data)
@@ -870,20 +919,14 @@ def build_gear(name):
scale=(1.0, 1.04, 1.02), cut_below=-0.05))
p.append(torus("Cuff", 0.163, 0.03, (0, 0.01, 0.04), M("plastic_grey"), seg=20))
elif name == "hood":
p.append(sphere("Hood", 0.20, (0, 0.03, 0.03), M("plastic_grey"), seg=20,
rings=12, cut_below=-0.55))
hood = p[-1]
bm = bmesh.new()
bm.from_mesh(hood.data)
bmesh.ops.delete(bm, geom=[v for v in bm.verts if v.co.y < -0.08 and -0.12 < v.co.z < 0.16],
context="VERTS")
bm.to_mesh(hood.data)
bm.free()
p.append(_hood("Hood", M("plastic_grey"), (0, 0, 0.015)))
elif name == "sunglasses":
for s in (-1.0, 1.0):
p.append(cyl("Lens", 0.03, 0.03, 0.008, (0.05 * s, -0.152, 0.035), M("lens"),
seg=14, rot=(math.radians(90), 0, 0), scale=(1.25, 1.0, 1.0)))
p.append(box("Arm", (0.006, 0.15, 0.008), (0.135 * s, -0.08, 0.04),
p.append(box("Hinge", (0.06, 0.008, 0.01), (0.11 * s, -0.150, 0.045),
M("plastic_black")))
p.append(box("Arm", (0.006, 0.15, 0.008), (0.138 * s, -0.078, 0.042),
M("plastic_black")))
p.append(box("Bridge", (0.04, 0.006, 0.008), (0, -0.155, 0.045), M("plastic_black")))
elif name == "firefighter_helmet":
@@ -1003,6 +1046,8 @@ def build_gear(name):
else:
raise KeyError(name)
obj = join(p, name)
if GEAR[name] == "hand": # origin = Hand proxy (wrist); grip 5 cm lower
obj.data.transform(Matrix.Translation((0, 0, -0.05)))
uv_unwrap(obj)
return [obj]
@@ -1052,16 +1097,18 @@ def build_prop(name):
(x, 0.24, 0.62)], 0.016, M("black_metal"), seg=8))
elif name == "trash_bin": # octagonal, r ~0.28, h 0.9
p.append(lathe("Base", [(0.27, 0.0), (0.27, 0.08), (0.25, 0.10)], M("bin_green"),
seg=8, smooth=False))
seg=8, smooth=False, face_front=True))
p.append(lathe("Body", [(0.25, 0.10), (0.26, 0.78)], M("bin_green"), seg=8,
smooth=False))
smooth=False, face_front=True))
p.append(lathe("Lid", [(0.0, 0.78), (0.285, 0.78), (0.285, 0.84), (0.24, 0.90),
(0.0, 0.92)], M("bin_green"), seg=8, smooth=False))
p.append(box("Slot", (0.30, 0.03, 0.10), (0, -0.255, 0.70), M("plastic_black")))
for a in range(8): # recessed panels
ang = math.tau * (a + 0.5) / 8
p.append(box("Panel", (0.17, 0.012, 0.42),
(math.sin(ang) * 0.248, -math.cos(ang) * 0.248, 0.38),
(0.0, 0.92)], M("bin_green"), seg=8, smooth=False,
face_front=True))
p.append(box("Slot", (0.26, 0.03, 0.09), (0, -0.258, 0.70), M("plastic_black")))
apothem = 0.255 * math.cos(math.pi / 8)
for a in range(8): # inset panels, one per face
ang = math.tau * a / 8
p.append(box("Panel", (0.15, 0.01, 0.40),
(math.sin(ang) * apothem, -math.cos(ang) * apothem, 0.38),
M("dark_metal"), rot=(0, 0, ang)))
elif name == "planter_box": # 0.96 square, 0.54 high + shrub
p.append(box("Planter", (0.96, 0.96, 0.50), (0, 0, 0.25), M("stone_pot"),
@@ -1127,8 +1174,8 @@ def build_prop(name):
p.append(trunk)
top = (0.29, 0.0, 6.6)
p.append(sphere("Crown", 0.24, top, M("palm_bark"), seg=10, rings=6))
for i in range(11):
a = math.tau * i / 11
for i in range(14):
a = math.tau * i / 14
droop = 0.6 + 0.35 * (i % 3) / 2
tip = (top[0] + math.cos(a) * 2.0, top[1] + math.sin(a) * 2.0,
top[2] - droop * 1.1)
@@ -1136,7 +1183,7 @@ def build_prop(name):
bm = bmesh.new()
pts = [Vector(top), Vector(mid), Vector(tip)]
side = Vector((-math.sin(a), math.cos(a), 0))
widths = [0.05, 0.38, 0.0]
widths = [0.06, 0.45, 0.0]
vs = []
for pt, wd in zip(pts, widths):
vs.append((bm.verts.new(pt - side * wd + Vector((0, 0, -0.06))),
@@ -1172,7 +1219,7 @@ def build_prop(name):
p.append(tube("Arm", [(0, -0.01, 0.05), (0, -0.14, 0.10), (0, -0.22, 0.08)], 0.015,
M("black_metal"), seg=6))
p.append(lathe("Cap", [(0.0, 0.16), (0.05, 0.15), (0.11, 0.09), (0.11, 0.07)],
M("black_metal"), seg=8, loc=(0, -0.24, -0.12)))
M("black_metal"), seg=8, loc=(0, -0.24, 0.0)))
p.append(lathe("Glass", [(0.08, -0.12), (0.10, 0.07)], M("bulb"), seg=8,
loc=(0, -0.24, 0.0), smooth=False))
p.append(lathe("Bottom", [(0.0, -0.17), (0.09, -0.15), (0.09, -0.12)],
@@ -1365,6 +1412,45 @@ def _setup_render(objs, out_path, character=False):
scene.render.filepath = str(out_path)
# Preview-only tints (sRGB) standing in for NpcAvatar.outfit_for(); the GLBs
# keep neutral slot textures so the runtime tint stays authoritative.
PREVIEW_TINTS = {
"npc_casual": {"cloth": (34, 40, 62), "pants": (150, 120, 84)},
"npc_jacket": {"cloth": (52, 70, 58), "pants": (40, 46, 60)},
"npc_coat": {"cloth": (150, 112, 74), "pants": (30, 32, 38)},
"npc_vest": {"cloth": (70, 74, 82), "pants": (44, 50, 64)},
"npc_dress": {"cloth": (150, 30, 44), "pants": (30, 28, 30)},
"npc_hoodie": {"cloth": (78, 80, 86), "pants": (34, 38, 48)},
"npc_firefighter": {"cloth": (40, 42, 46), "pants": (40, 42, 46)},
"npc_guard": {"cloth": (30, 34, 46), "pants": (30, 34, 46)},
}
DEFAULT_TINTS = {"skin": (214, 170, 136), "hair": (58, 40, 28), "accessory": (30, 30, 34)}
def _tint_preview(name):
tints = dict(DEFAULT_TINTS)
tints.update(PREVIEW_TINTS.get(name, {}))
for mat in bpy.data.materials:
slot = mat.name.split(".")[0]
if slot not in tints or not mat.node_tree:
continue
nodes = mat.node_tree.nodes
bsdf = next((n for n in nodes if n.type == "BSDF_PRINCIPLED"), None)
if bsdf is None or not bsdf.inputs["Base Color"].links:
continue
src = bsdf.inputs["Base Color"].links[0].from_socket
mix = nodes.new("ShaderNodeMix")
mix.data_type = "RGBA"
mix.blend_type = "MULTIPLY"
mix.inputs["Factor"].default_value = 1.0
mat.node_tree.links.new(src, mix.inputs[6])
rgb = tints[slot]
# neutral textures are light; lift the tint so the product lands near it
mix.inputs[7].default_value = tuple(
min(1.0, _srgb_to_linear(c / 255.0) * 1.5) for c in rgb) + (1.0,)
mat.node_tree.links.new(mix.outputs[2], bsdf.inputs["Base Color"])
def _render_one(category, name):
path = asset_path(category, name)
if not path.is_file():
@@ -1373,12 +1459,27 @@ def _render_one(category, name):
reset_scene()
bpy.ops.import_scene.gltf(filepath=str(path))
objs = list(bpy.context.scene.objects)
if category == "npc":
_tint_preview(name)
# Dress the preview with the recommended gear at the documented attach
# points; this also checks the attach-point convention visually.
for gear in CHARACTERS[name].get("gear", []):
gpath = asset_path("npc_gear", gear)
if not gpath.is_file():
continue
before = set(bpy.context.scene.objects)
bpy.ops.import_scene.gltf(filepath=str(gpath))
for o in set(bpy.context.scene.objects) - before:
if o.parent is None:
o.location = Vector(o.location) + Vector(ATTACH_BLENDER[GEAR[gear]])
objs = list(bpy.context.scene.objects)
if category == "npc_gear": # show gear at a readable scale on the floor
mins_z = min((o.matrix_world @ Vector(c)).z for o in objs if o.type == "MESH"
for c in o.bound_box)
for o in objs:
if o.parent is None:
o.location.z -= mins_z
bpy.context.view_layer.update()
PREVIEW_DIR.mkdir(parents=True, exist_ok=True)
out = PREVIEW_DIR / f"{name}.png"
_setup_render(objs, out, character=category == "npc")
@@ -1412,6 +1513,22 @@ def _accessor(gltf, binary, index):
for i in range(int(acc["count"]))]
def _degenerate(gltf, binary, pos_index, idx_index):
pos = _accessor(gltf, binary, pos_index)
idx = [i[0] for i in _accessor(gltf, binary, idx_index)]
count = 0
for k in range(0, len(idx) - 2, 3):
a, b, c = pos[idx[k]], pos[idx[k + 1]], pos[idx[k + 2]]
u = (b[0] - a[0], b[1] - a[1], b[2] - a[2])
v = (c[0] - a[0], c[1] - a[1], c[2] - a[2])
cx = u[1] * v[2] - u[2] * v[1]
cy = u[2] * v[0] - u[0] * v[2]
cz = u[0] * v[1] - u[1] * v[0]
if cx * cx + cy * cy + cz * cz < 1e-14:
count += 1
return count
def audit_asset(category, name):
path = asset_path(category, name)
res = {"name": name, "category": category, "ok": False, "issues": [],
@@ -1428,7 +1545,7 @@ def audit_asset(category, name):
res["issues"].append("bad GLB container")
mins = [1e9] * 3
maxs = [-1e9] * 3
tris = verts = 0
tris = verts = degenerate = 0
for mesh in gltf.get("meshes", []):
for prim in mesh.get("primitives", []):
attrs = prim.get("attributes", {})
@@ -1448,7 +1565,13 @@ def audit_asset(category, name):
break
if prim.get("indices") is not None:
tris += gltf["accessors"][prim["indices"]]["count"] // 3
if "POSITION" in attrs:
degenerate += _degenerate(gltf, binary, attrs["POSITION"],
prim["indices"])
res["triangles"], res["vertices"] = tris, verts
if degenerate:
# Godot warns "non-finite normal in LOD generation" for these.
res["issues"].append(f"{degenerate} zero-area triangles")
res["materials"] = [m.get("name", "") for m in gltf.get("materials", [])]
if verts:
res["bounds"] = {"min": [round(v, 3) for v in mins],
@@ -1535,8 +1658,10 @@ def contact_sheets():
x, y = (i % cols) * cell, (i // cols) * (cell + 24)
sheet.paste(im, (x, y))
draw.text((x + 8, y + cell + 5), n, fill=(230, 230, 230))
out = PREVIEW_DIR / f"sheet_{cat}.png"
sheet.save(out)
# JPEG keeps the committed review sheets small; per-asset PNG renders
# stay local (gitignored) and are rebuilt with `render`.
out = PREVIEW_DIR / f"sheet_{cat}.jpg"
sheet.save(out, quality=86, optimize=True)
written.append(out)
print(f"wrote {out.relative_to(ROOT)}")
return written
+62
View File
@@ -0,0 +1,62 @@
import json
import unittest
import blender_assets as ba
import npc_pipeline
class CatalogueTests(unittest.TestCase):
def test_contract_matches_npc_pipeline(self):
self.assertEqual(ba.SLOTS, npc_pipeline.SLOTS)
self.assertEqual(sorted(ba.BONES), sorted(npc_pipeline.BONES))
self.assertLessEqual(ba.CHAR_MAX_TRIS, npc_pipeline.MAX_TRIANGLES)
self.assertLessEqual(ba.CHAR_MAX_VERTS, npc_pipeline.MAX_VERTICES)
def test_gear_uses_known_attach_points(self):
for name, attach in ba.GEAR.items():
self.assertIn(attach, ba.GEAR_ATTACH, name)
self.assertIn(attach, ba.ATTACH_BLENDER, name)
def test_character_gear_exists(self):
layers = set()
for name, spec in ba.CHARACTERS.items():
layers.add(spec["layer"])
for gear in spec.get("gear", []):
self.assertIn(gear, ba.GEAR, f"{name} -> {gear}")
# Every NpcAvatar.layer_for() value has a character.
for layer in ("none", "jacket", "coat", "vest", "dress"):
self.assertIn(layer, layers)
def test_emissive_props_use_emissive_palette_entries(self):
for name, spec in ba.PROPS.items():
for mat in spec.get("emissive", []):
self.assertGreater(ba.PALETTE[mat][3], 0.0, f"{name}:{mat}")
@unittest.skipUnless(ba.MANIFEST.is_file(), "Blender assets not built")
class BuiltAssetTests(unittest.TestCase):
def test_every_asset_audits_clean(self):
bad = {}
for category, name in ba.catalogue():
result = ba.audit_asset(category, name)
if not result["ok"]:
bad[name] = result["issues"]
self.assertEqual(bad, {})
def test_manifest_covers_catalogue(self):
manifest = json.loads(ba.MANIFEST.read_text(encoding="utf-8"))
self.assertEqual(set(manifest["assets"]), {n for _, n in ba.catalogue()})
def test_manifest_paths_stay_in_the_library(self):
manifest = json.loads(ba.MANIFEST.read_text(encoding="utf-8"))
for name, entry in manifest["assets"].items():
self.assertTrue(entry["path"].startswith("assets/models/blender/"), name)
if entry["category"] == "npc_gear":
self.assertIn(entry["attach"], ba.GEAR_ATTACH, name)
def test_sources_are_hidden_from_godot(self):
self.assertTrue((ba.SRC_DIR / ".gdignore").is_file())
if __name__ == "__main__":
unittest.main()