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:
1 parent
7efb5024ec
commit
d513e0b82c
109 files changed
+1916
-114
No files matched your search
+204
-79
@@ -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
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user