Plan the v0.6.0 graphics pass and add Blender-authored NPC assets
This commit is contained in:
1 parent
a27e8cb74c
commit
b24d0aeb61
33 files changed
+1005
-2
No files matched your search
@@ -0,0 +1,541 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build the low-poly, skinned NPC base mesh used by Step Line.
|
||||
|
||||
The mesh, armature, UVs and neutral material maps are authored procedurally in
|
||||
Blender and exported to `assets/models/generated/npc_base.glb`. Godot imports the
|
||||
GLB as a PackedScene, tints the material slots per avatar, and drives the armature
|
||||
bones (see `src/game/npc_avatar.gd`). The procedural capsule rig stays as the
|
||||
offline fallback when the GLB is missing.
|
||||
|
||||
The tool runs inside Blender (the flatpak sandbox cannot see `/tmp`, so every
|
||||
path must be absolute):
|
||||
|
||||
flatpak run org.blender.Blender --background \
|
||||
--python "$PWD/tools/npc_pipeline.py" -- build
|
||||
python3 tools/npc_pipeline.py prepare # layer AI fabric albedos (Pillow)
|
||||
python3 tools/npc_pipeline.py palette # derive outfit colours -> palette.json
|
||||
python3 tools/npc_pipeline.py audit
|
||||
|
||||
Contract (frozen in docs/plan-graphics-v0.6.md + ADR-0013):
|
||||
|
||||
* scene root "Rig" with a skinned mesh and an armature
|
||||
* bones: Head, HipL/R, KneeL/R, ShoulderL/R, ElbowL/R, HandL/R
|
||||
(plus Hips/Spine/Neck as parents)
|
||||
* material slots: skin, cloth, pants, accessory, hair
|
||||
* neutral albedo + normal + packed ORM maps, albedo tinted at runtime
|
||||
* <= 6000 triangles / 4000 vertices, UVs in [0,1], feet at y ~= 0
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import struct
|
||||
import sys
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
GLB_PATH = ROOT / "assets" / "models" / "generated" / "npc_base.glb"
|
||||
BLEND_PATH = ROOT / "assets" / "models" / "npc" / "npc_base.blend"
|
||||
TEX_DIR = ROOT / "assets" / "textures" / "npc"
|
||||
|
||||
SLOTS = ["skin", "cloth", "pants", "accessory", "hair"]
|
||||
BONES = [
|
||||
"Head", "HipL", "HipR", "KneeL", "KneeR",
|
||||
"ShoulderL", "ShoulderR", "ElbowL", "ElbowR", "HandL", "HandR",
|
||||
]
|
||||
MAX_TRIANGLES = 6000
|
||||
MAX_VERTICES = 4000
|
||||
TEX_SIZE = 128
|
||||
AI_DIR = TEX_DIR / "ai"
|
||||
# Base material slots that get an AI fabric albedo (`prepare` layers them over
|
||||
# the procedural weave). Outer layers (jacket / vest / coat / dress) have no
|
||||
# material slot yet; Phase 3 adds them and can reuse this override mechanism.
|
||||
AI_FABRICS = {"cloth": "shirt", "pants": "trousers"}
|
||||
AI_FABRICS_ALL = ["shirt", "trousers", "jacket", "vest", "coat", "dress"]
|
||||
|
||||
try: # Blender's Python has bpy; the audit command works without it.
|
||||
import bpy # type: ignore
|
||||
except ImportError: # pragma: no cover - exercised outside Blender
|
||||
bpy = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Texture helpers (stdlib only: Blender ships no Pillow)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SLOT_STYLE = {
|
||||
# base sRGB tone, weave frequency, roughness, metallic, AO strength
|
||||
"skin": ((222, 184, 150), 0.05, 150, 0, 0.10),
|
||||
"cloth": ((232, 232, 228), 0.30, 220, 0, 0.18),
|
||||
"pants": ((70, 78, 92), 0.22, 235, 0, 0.20),
|
||||
"accessory": ((46, 48, 56), 0.12, 120, 180, 0.22),
|
||||
"hair": ((58, 44, 34), 0.18, 170, 0, 0.16),
|
||||
}
|
||||
|
||||
|
||||
def _png(width: int, height: int, rgba: bytes) -> bytes:
|
||||
rows = bytearray()
|
||||
stride = width * 4
|
||||
for y in range(height):
|
||||
rows.append(0)
|
||||
rows.extend(rgba[y * stride:(y + 1) * stride])
|
||||
|
||||
def chunk(tag: bytes, data: bytes) -> bytes:
|
||||
return (struct.pack(">I", len(data)) + tag + data +
|
||||
struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF))
|
||||
|
||||
header = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0)
|
||||
return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", header) +
|
||||
chunk(b"IDAT", zlib.compress(bytes(rows), 9)) + chunk(b"IEND", b""))
|
||||
|
||||
|
||||
def _fabric_pattern(slot: str, x: int, y: int) -> float:
|
||||
"""A repeatable 0..1 height/weave signal per slot."""
|
||||
u = x / TEX_SIZE
|
||||
v = y / TEX_SIZE
|
||||
if slot == "cloth":
|
||||
return 0.5 + 0.5 * math.sin(u * math.tau * 22.0) * math.sin(v * math.tau * 18.0)
|
||||
if slot == "pants":
|
||||
return 0.5 + 0.5 * math.sin((u + v) * math.tau * 26.0)
|
||||
if slot == "skin":
|
||||
return 0.5 + 0.5 * math.sin(u * math.tau * 7.0 + v * math.tau * 11.0)
|
||||
if slot == "hair":
|
||||
return 0.5 + 0.5 * math.sin((u * 1.7 - v) * math.tau * 30.0)
|
||||
return 0.5 + 0.5 * math.sin(u * math.tau * 9.0) * math.sin(v * math.tau * 9.0)
|
||||
|
||||
|
||||
def write_slot_textures() -> None:
|
||||
"""Write neutral sRGB albedo, tangent normal and packed ORM for each slot."""
|
||||
TEX_DIR.mkdir(parents=True, exist_ok=True)
|
||||
for slot in SLOTS:
|
||||
base, freq, roughness, metallic, ao_strength = SLOT_STYLE[slot]
|
||||
albedo = bytearray(TEX_SIZE * TEX_SIZE * 4)
|
||||
normal = bytearray(TEX_SIZE * TEX_SIZE * 4)
|
||||
orm = bytearray(TEX_SIZE * TEX_SIZE * 4)
|
||||
fabric = AI_FABRICS.get(slot)
|
||||
use_ai_albedo = fabric is not None and (AI_DIR / f"{fabric}.png").is_file()
|
||||
for y in range(TEX_SIZE):
|
||||
for x in range(TEX_SIZE):
|
||||
pattern = _fabric_pattern(slot, x, y)
|
||||
# Baked AO approximation: darker in creases (pattern valleys) and
|
||||
# toward the bottom of the tile.
|
||||
ao = 1.0 - ao_strength * (1.0 - pattern) - 0.08 * (y / TEX_SIZE)
|
||||
r = min(255, int(base[0] * ao))
|
||||
g = min(255, int(base[1] * ao))
|
||||
b = min(255, int(base[2] * ao))
|
||||
offset = (y * TEX_SIZE + x) * 4
|
||||
albedo[offset:offset + 4] = bytes((r, g, b, 255))
|
||||
dx = _fabric_pattern(slot, (x + 1) % TEX_SIZE, y) - pattern
|
||||
dy = _fabric_pattern(slot, x, (y + 1) % TEX_SIZE) - pattern
|
||||
nx = 128 + int(dx * 90.0)
|
||||
ny = 128 + int(dy * 90.0)
|
||||
normal[offset:offset + 4] = bytes((max(0, min(255, nx)),
|
||||
max(0, min(255, ny)), 255, 255))
|
||||
orm[offset:offset + 4] = bytes((int(ao * 255), roughness, metallic, 255))
|
||||
# The prepared AI fabric replaces the procedural albedo; the baked AO now
|
||||
# lives in the ORM R channel, so the albedo can stay unshaded.
|
||||
if not use_ai_albedo:
|
||||
(TEX_DIR / f"{slot}_albedo.png").write_bytes(_png(TEX_SIZE, TEX_SIZE, albedo))
|
||||
(TEX_DIR / f"{slot}_normal.png").write_bytes(_png(TEX_SIZE, TEX_SIZE, normal))
|
||||
(TEX_DIR / f"{slot}_orm.png").write_bytes(_png(TEX_SIZE, TEX_SIZE, orm))
|
||||
kept = [s for s, f in AI_FABRICS.items() if (AI_DIR / f"{f}.png").is_file()]
|
||||
print(f"wrote {len(SLOTS) * 3} textures to {TEX_DIR}" +
|
||||
(f" (kept AI albedo for {', '.join(kept)})" if kept else ""))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Blender build
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _material(slot: str):
|
||||
mat = bpy.data.materials.new(slot)
|
||||
mat.use_nodes = True
|
||||
nodes = mat.node_tree.nodes
|
||||
links = mat.node_tree.links
|
||||
bsdf = nodes.get("Principled BSDF")
|
||||
base, _, roughness, metallic, _ = SLOT_STYLE[slot]
|
||||
bsdf.inputs["Base Color"].default_value = (
|
||||
base[0] / 255.0, base[1] / 255.0, base[2] / 255.0, 1.0)
|
||||
bsdf.inputs["Roughness"].default_value = roughness / 255.0
|
||||
bsdf.inputs["Metallic"].default_value = metallic / 255.0
|
||||
|
||||
albedo = nodes.new("ShaderNodeTexImage")
|
||||
albedo.image = bpy.data.images.load(str(TEX_DIR / f"{slot}_albedo.png"))
|
||||
albedo.image.colorspace_settings.name = "sRGB"
|
||||
links.new(albedo.outputs["Color"], bsdf.inputs["Base Color"])
|
||||
|
||||
normal_tex = nodes.new("ShaderNodeTexImage")
|
||||
normal_tex.image = bpy.data.images.load(str(TEX_DIR / f"{slot}_normal.png"))
|
||||
normal_tex.image.colorspace_settings.name = "Non-Color"
|
||||
normal_map = nodes.new("ShaderNodeNormalMap")
|
||||
links.new(normal_tex.outputs["Color"], normal_map.inputs["Color"])
|
||||
links.new(normal_map.outputs["Normal"], bsdf.inputs["Normal"])
|
||||
return mat
|
||||
|
||||
|
||||
def _assign(obj, material, bone: str) -> None:
|
||||
obj.data.materials.append(material)
|
||||
group = obj.vertex_groups.new(name=bone)
|
||||
group.add(list(range(len(obj.data.vertices))), 1.0, "REPLACE")
|
||||
|
||||
|
||||
def _cube(name, size, loc, material, bone) -> None:
|
||||
bpy.ops.mesh.primitive_cube_add(size=1.0, location=loc)
|
||||
obj = bpy.context.object
|
||||
obj.name = name
|
||||
obj.scale = (size[0], size[1], size[2])
|
||||
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
|
||||
_assign(obj, material, bone)
|
||||
|
||||
|
||||
def _cylinder(name, radius, depth, loc, material, bone, vertices=8) -> None:
|
||||
bpy.ops.mesh.primitive_cylinder_add(vertices=vertices, radius=radius,
|
||||
depth=depth, location=loc)
|
||||
obj = bpy.context.object
|
||||
obj.name = name
|
||||
_assign(obj, material, bone)
|
||||
|
||||
|
||||
def _sphere(name, radius, loc, material, bone, segments=12, rings=8,
|
||||
scale=(1.0, 1.0, 1.0), rotation=(0.0, 0.0, 0.0)) -> None:
|
||||
bpy.ops.mesh.primitive_uv_sphere_add(segments=segments, ring_count=rings,
|
||||
radius=radius, location=loc)
|
||||
obj = bpy.context.object
|
||||
obj.name = name
|
||||
obj.scale = scale
|
||||
obj.rotation_euler = rotation
|
||||
bpy.ops.object.transform_apply(location=False, rotation=True, scale=True)
|
||||
_assign(obj, material, bone)
|
||||
|
||||
|
||||
def _bone(edit_bones, name, head, tail, parent=None) -> None:
|
||||
bone = edit_bones.new(name)
|
||||
bone.head = head
|
||||
bone.tail = tail
|
||||
if parent is not None:
|
||||
bone.parent = parent
|
||||
|
||||
|
||||
def build_character() -> None:
|
||||
bpy.ops.object.select_all(action="SELECT")
|
||||
bpy.ops.object.delete(use_global=False)
|
||||
for collection in (bpy.data.meshes, bpy.data.armatures, bpy.data.materials):
|
||||
for datablock in list(collection):
|
||||
if datablock.users == 0:
|
||||
collection.remove(datablock)
|
||||
|
||||
write_slot_textures()
|
||||
mats = {slot: _material(slot) for slot in SLOTS}
|
||||
parts = []
|
||||
|
||||
# Body (Blender is Z-up; the glTF exporter converts to Godot Y-up. The face
|
||||
# points toward Blender -Y, which becomes Godot +Z like the fallback rig).
|
||||
_cube("Pelvis", (0.36, 0.23, 0.22), (0, 0, 0.71), mats["pants"], "Hips")
|
||||
_cube("Torso", (0.42, 0.26, 0.56), (0, 0, 1.10), mats["cloth"], "Spine")
|
||||
_cube("Shoulders", (0.50, 0.26, 0.12), (0, 0, 1.38), mats["cloth"], "Spine")
|
||||
_cylinder("Neck", 0.07, 0.15, (0, 0, 1.47), mats["skin"], "Neck", vertices=8)
|
||||
_sphere("Head", 0.17, (0, 0, 1.63), mats["skin"], "Head",
|
||||
scale=(1.0, 0.95, 1.08))
|
||||
_sphere("Hair", 0.178, (0, 0.01, 1.69), mats["hair"], "Head",
|
||||
scale=(1.0, 1.0, 0.82))
|
||||
for side in (-1.0, 1.0):
|
||||
_sphere("Ear", 0.04, (0.165 * side, 0.0, 1.62), mats["skin"], "Head",
|
||||
segments=8, rings=6)
|
||||
_sphere("Eye", 0.027, (0.058 * side, -0.155, 1.655), mats["accessory"],
|
||||
"Head", segments=8, rings=6)
|
||||
_cube("Brow", (0.065, 0.02, 0.014), (0.058 * side, -0.158, 1.705),
|
||||
mats["hair"], "Head")
|
||||
_cube("Mouth", (0.085, 0.02, 0.014), (0, -0.155, 1.575),
|
||||
mats["accessory"], "Head")
|
||||
|
||||
for side in (-1.0, 1.0):
|
||||
x = 0.27 * side
|
||||
_cylinder("UpperArm", 0.075, 0.30, (x, 0, 1.21), mats["cloth"],
|
||||
"ShoulderL" if side < 0 else "ShoulderR")
|
||||
_cylinder("Forearm", 0.065, 0.28, (x, 0, 0.92), mats["cloth"],
|
||||
"ElbowL" if side < 0 else "ElbowR")
|
||||
_sphere("Hand", 0.06, (x, 0, 0.745), mats["skin"],
|
||||
"HandL" if side < 0 else "HandR", segments=10, rings=6)
|
||||
|
||||
hx = 0.12 * side
|
||||
_cylinder("Thigh", 0.10, 0.36, (hx, 0, 0.46), mats["pants"],
|
||||
"HipL" if side < 0 else "HipR")
|
||||
_cylinder("Shin", 0.085, 0.30, (hx, 0, 0.15), mats["pants"],
|
||||
"KneeL" if side < 0 else "KneeR")
|
||||
_cube("Shoe", (0.16, 0.29, 0.10), (hx, 0.04, 0.05),
|
||||
mats["accessory"], "KneeL" if side < 0 else "KneeR")
|
||||
|
||||
parts = [obj for obj in bpy.context.scene.objects if obj.type == "MESH"]
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
for obj in parts:
|
||||
obj.select_set(True)
|
||||
bpy.context.view_layer.objects.active = parts[0]
|
||||
bpy.ops.object.join()
|
||||
mesh = bpy.context.object
|
||||
mesh.name = "npc_base"
|
||||
|
||||
bpy.ops.object.mode_set(mode="EDIT")
|
||||
bpy.ops.mesh.select_all(action="SELECT")
|
||||
bpy.ops.uv.smart_project(angle_limit=math.radians(66.0), island_margin=0.02)
|
||||
bpy.ops.object.mode_set(mode="OBJECT")
|
||||
# Neutral flat shading keeps the low-poly read; smoothing is not needed.
|
||||
for polygon in mesh.data.polygons:
|
||||
polygon.use_smooth = False
|
||||
|
||||
arm_data = bpy.data.armatures.new("Armature")
|
||||
arm = bpy.data.objects.new("Armature", arm_data)
|
||||
bpy.context.scene.collection.objects.link(arm)
|
||||
bpy.context.view_layer.objects.active = arm
|
||||
bpy.ops.object.mode_set(mode="EDIT")
|
||||
bones = arm_data.edit_bones
|
||||
_bone(bones, "Hips", (0, 0, 0.62), (0, 0, 0.82))
|
||||
_bone(bones, "Spine", (0, 0, 0.82), (0, 0, 1.40), bones["Hips"])
|
||||
_bone(bones, "Neck", (0, 0, 1.40), (0, 0, 1.52), bones["Spine"])
|
||||
_bone(bones, "Head", (0, 0, 1.52), (0, 0, 1.82), bones["Neck"])
|
||||
for side, suffix in ((-1.0, "L"), (1.0, "R")):
|
||||
x = 0.27 * side
|
||||
_bone(bones, f"Shoulder{suffix}", (x, 0, 1.38), (x, 0, 1.06),
|
||||
bones["Spine"])
|
||||
_bone(bones, f"Elbow{suffix}", (x, 0, 1.06), (x, 0, 0.78),
|
||||
bones[f"Shoulder{suffix}"])
|
||||
_bone(bones, f"Hand{suffix}", (x, 0, 0.78), (x, 0, 0.68),
|
||||
bones[f"Elbow{suffix}"])
|
||||
hx = 0.12 * side
|
||||
_bone(bones, f"Hip{suffix}", (hx, 0, 0.62), (hx, 0, 0.30),
|
||||
bones["Hips"])
|
||||
_bone(bones, f"Knee{suffix}", (hx, 0, 0.30), (hx, 0, 0.0),
|
||||
bones[f"Hip{suffix}"])
|
||||
bpy.ops.object.mode_set(mode="OBJECT")
|
||||
|
||||
mesh.parent = arm
|
||||
modifier = mesh.modifiers.new("Armature", "ARMATURE")
|
||||
modifier.object = arm
|
||||
|
||||
for obj in bpy.context.scene.objects:
|
||||
obj.select_set(True)
|
||||
bpy.context.view_layer.objects.active = mesh
|
||||
GLB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
bpy.ops.export_scene.gltf(
|
||||
filepath=str(GLB_PATH), export_format="GLB", use_selection=False,
|
||||
export_apply=False, export_animations=False, export_skins=True,
|
||||
export_yup=True, export_materials="EXPORT", export_image_format="AUTO",
|
||||
)
|
||||
BLEND_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
bpy.ops.wm.save_as_mainfile(filepath=str(BLEND_PATH))
|
||||
print(f"wrote {GLB_PATH} and {BLEND_PATH}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Audit (stdlib only)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _read_accessor(gltf, binary, index):
|
||||
accessor = gltf["accessors"][index]
|
||||
width = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4}[accessor["type"]]
|
||||
fmt = {5126: "f", 5125: "I", 5123: "H", 5121: "B"}[int(accessor["componentType"])]
|
||||
view = gltf["bufferViews"][accessor["bufferView"]]
|
||||
offset = int(view.get("byteOffset", 0)) + int(accessor.get("byteOffset", 0))
|
||||
stride = int(view.get("byteStride", width * struct.calcsize(fmt)))
|
||||
values = []
|
||||
for i in range(int(accessor["count"])):
|
||||
row = struct.unpack_from("<" + fmt * width, binary, offset + i * stride)
|
||||
values.append(row[0] if width == 1 else row)
|
||||
return values
|
||||
|
||||
|
||||
def audit_glb(path: Path = GLB_PATH) -> dict:
|
||||
issues = []
|
||||
if not path.is_file():
|
||||
return {"ok": False, "issues": ["missing npc_base.glb"], "triangles": 0,
|
||||
"vertices": 0, "bones": []}
|
||||
data = path.read_bytes()
|
||||
try:
|
||||
magic, version, length = struct.unpack_from("<III", data, 0)
|
||||
json_len, json_type = struct.unpack_from("<II", data, 12)
|
||||
gltf = json.loads(data[20:20 + json_len].decode("utf-8"))
|
||||
_, bin_len, _ = struct.unpack_from("<III", data, 12 + 8 + json_len)
|
||||
binary = data[12 + 8 + json_len + 8:12 + 8 + json_len + 8 + bin_len]
|
||||
except (struct.error, ValueError) as exc:
|
||||
return {"ok": False, "issues": [f"unreadable GLB: {exc}"],
|
||||
"triangles": 0, "vertices": 0, "bones": []}
|
||||
if magic != 0x46546C67 or version != 2 or length != len(data):
|
||||
issues.append("bad GLB container")
|
||||
if json_type != 0x4E4F534A:
|
||||
issues.append("first chunk is not JSON")
|
||||
|
||||
nodes = gltf.get("nodes", [])
|
||||
bones = []
|
||||
for skin in gltf.get("skins", []):
|
||||
for joint in skin.get("joints", []):
|
||||
if 0 <= joint < len(nodes):
|
||||
bones.append(nodes[joint].get("name", ""))
|
||||
for bone in BONES:
|
||||
if bone not in bones:
|
||||
issues.append(f"missing bone {bone}")
|
||||
if not gltf.get("skins"):
|
||||
issues.append("no skin (mesh is not skinned)")
|
||||
|
||||
materials = [m.get("name", "") for m in gltf.get("materials", [])]
|
||||
for slot in SLOTS:
|
||||
if slot not in materials:
|
||||
issues.append(f"missing material slot {slot}")
|
||||
if len(gltf.get("images", [])) < len(SLOTS) * 2:
|
||||
issues.append("expected albedo + normal textures per slot")
|
||||
for slot in SLOTS:
|
||||
if not (TEX_DIR / f"{slot}_orm.png").is_file():
|
||||
issues.append(f"missing packed ORM map for {slot}")
|
||||
|
||||
triangles = 0
|
||||
vertices = 0
|
||||
min_y = 1e9
|
||||
for mesh in gltf.get("meshes", []):
|
||||
for primitive in mesh.get("primitives", []):
|
||||
attrs = primitive.get("attributes", {})
|
||||
for required in ("POSITION", "NORMAL", "TEXCOORD_0", "JOINTS_0", "WEIGHTS_0"):
|
||||
if required not in attrs:
|
||||
issues.append(f"primitive missing {required}")
|
||||
if "POSITION" in attrs:
|
||||
positions = _read_accessor(gltf, binary, attrs["POSITION"])
|
||||
vertices += len(positions)
|
||||
if positions:
|
||||
min_y = min(min_y, min(p[1] for p in positions))
|
||||
if "TEXCOORD_0" in attrs:
|
||||
for u, v in _read_accessor(gltf, binary, attrs["TEXCOORD_0"]):
|
||||
if not (0.0 <= u <= 1.0 and 0.0 <= v <= 1.0):
|
||||
issues.append("UV outside [0, 1]")
|
||||
break
|
||||
if primitive.get("indices") is not None:
|
||||
triangles += gltf["accessors"][primitive["indices"]]["count"] // 3
|
||||
if min_y < 1e9 and abs(min_y) > 0.03:
|
||||
issues.append(f"feet not on the ground (min y {min_y:.3f})")
|
||||
if triangles > MAX_TRIANGLES:
|
||||
issues.append(f"triangle budget exceeded ({triangles} > {MAX_TRIANGLES})")
|
||||
if vertices > MAX_VERTICES:
|
||||
issues.append(f"vertex budget exceeded ({vertices} > {MAX_VERTICES})")
|
||||
if not BLEND_PATH.is_file():
|
||||
issues.append("editable .blend source missing")
|
||||
return {"ok": not issues, "issues": issues, "triangles": triangles,
|
||||
"vertices": vertices, "bones": bones}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AI fabric preparation (Pillow; runs outside Blender)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def prepare_albedos() -> list:
|
||||
"""Layer the committed AI fabric albedos over the procedural slot albedos.
|
||||
|
||||
For each slot in AI_FABRICS, resize `assets/textures/npc/ai/<fabric>.png`
|
||||
to TEX_SIZE and write it as `<slot>_albedo.png`. A later `build` then keeps
|
||||
that albedo (write_slot_textures skips it) and still regenerates the
|
||||
procedural normal + packed ORM maps, so the AI colour sits on top of the
|
||||
baked normal/AO detail.
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
TEX_DIR.mkdir(parents=True, exist_ok=True)
|
||||
written = []
|
||||
for slot, fabric in AI_FABRICS.items():
|
||||
src = AI_DIR / f"{fabric}.png"
|
||||
if not src.is_file():
|
||||
print(f"skip {slot}: {src.relative_to(ROOT)} missing (procedural albedo stays)")
|
||||
continue
|
||||
with Image.open(src) as image:
|
||||
image.load()
|
||||
image.convert("RGB").resize(
|
||||
(TEX_SIZE, TEX_SIZE), Image.Resampling.LANCZOS
|
||||
).save(TEX_DIR / f"{slot}_albedo.png")
|
||||
written.append(f"{slot}<={fabric}")
|
||||
print("prepared albedos: " + (", ".join(written) if written else "none"))
|
||||
return written
|
||||
|
||||
|
||||
def write_palette() -> dict:
|
||||
"""Derive one representative colour per AI fabric and write palette.json.
|
||||
|
||||
Reuses model_pipeline.dominant_colors so the colours come from the same
|
||||
centre-crop + saturation ranking as the prop concepts. Phase 3 can consume
|
||||
these as deterministic, committed outfit base colours.
|
||||
"""
|
||||
import model_pipeline
|
||||
from PIL import Image
|
||||
|
||||
palette = {}
|
||||
for fabric in AI_FABRICS_ALL:
|
||||
src = AI_DIR / f"{fabric}.png"
|
||||
if not src.is_file():
|
||||
continue
|
||||
with Image.open(src) as image:
|
||||
image.load()
|
||||
colors = model_pipeline.dominant_colors(image, count=1)
|
||||
if not colors:
|
||||
continue
|
||||
r, g, b = colors[0]
|
||||
palette[fabric] = {
|
||||
"rgb": [round(c, 4) for c in (r, g, b)],
|
||||
"hex": f"#{int(round(r * 255)):02x}{int(round(g * 255)):02x}"
|
||||
f"{int(round(b * 255)):02x}",
|
||||
}
|
||||
(TEX_DIR / "palette.json").write_text(
|
||||
json.dumps(palette, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
print(f"wrote {TEX_DIR / 'palette.json'}")
|
||||
return palette
|
||||
|
||||
|
||||
def list_contract() -> int:
|
||||
print("npc_base contract")
|
||||
print(f" glb: {GLB_PATH.relative_to(ROOT)}")
|
||||
print(f" blend: {BLEND_PATH.relative_to(ROOT)}")
|
||||
print(f" slots: {', '.join(SLOTS)}")
|
||||
print(f" bones: {', '.join(BONES)} (+ Hips/Spine/Neck)")
|
||||
print(f" budget: <= {MAX_TRIANGLES} tris / {MAX_VERTICES} verts")
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
raw = list(argv if argv is not None else sys.argv[1:])
|
||||
if "--" in raw:
|
||||
raw = raw[raw.index("--") + 1:]
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
sub.add_parser("list")
|
||||
sub.add_parser("build")
|
||||
sub.add_parser("prepare")
|
||||
sub.add_parser("palette")
|
||||
audit = sub.add_parser("audit")
|
||||
audit.add_argument("--json", action="store_true")
|
||||
args = parser.parse_args(raw)
|
||||
|
||||
if args.command == "list":
|
||||
return list_contract()
|
||||
if args.command == "build":
|
||||
if bpy is None:
|
||||
print("build must run inside Blender:\n flatpak run org.blender.Blender "
|
||||
"--background --python %s -- build" % Path(__file__).resolve(),
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
build_character()
|
||||
result = audit_glb()
|
||||
print(json.dumps(result, indent=2))
|
||||
return 0 if result["ok"] else 1
|
||||
if args.command == "prepare":
|
||||
prepare_albedos()
|
||||
return 0
|
||||
if args.command == "palette":
|
||||
write_palette()
|
||||
return 0
|
||||
result = audit_glb()
|
||||
if args.json:
|
||||
print(json.dumps(result, indent=2))
|
||||
else:
|
||||
print(f"{'OK ' if result['ok'] else 'BAD'} npc_base.glb "
|
||||
f"tris={result['triangles']} verts={result['vertices']} "
|
||||
f"bones={len(result['bones'])}")
|
||||
for issue in result["issues"]:
|
||||
print(" -", issue)
|
||||
return 0 if result["ok"] else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in new issue
Block a user