Swap the full 12-character roster onto the CC0 Quaternius bases
This commit is contained in:
1 parent
1ce173e9d1
commit
a6ccd86f85
39 files changed
+583
-173
No files matched your search
+355
-15
@@ -85,37 +85,47 @@ except ImportError: # pragma: no cover - audit/sheet/list run outside Blender
|
||||
# Characters map onto NpcAvatar.layer_for() / ACCESSORIES so the runtime can
|
||||
# pick one per avatar later. `tint` lists the slots the runtime should tint;
|
||||
# every other material keeps its authored colour.
|
||||
# `base` selects the CC0 Quaternius source the character is baked from
|
||||
# (ADR-0017/ADR-0018, owner chose the full roster swap); `drop` removes upstream
|
||||
# materials that do not belong to the role (the Worker's hivis vest and hard hat).
|
||||
# Rebuild one procedurally with `build --source procedural --head mh`.
|
||||
CHARACTERS = {
|
||||
"npc_casual": {"layer": "none", "hair": "short", "gear": ["backpack", "camera"],
|
||||
"base": "hoodie",
|
||||
"roles": ["tourist", "confused_visitor", "intoxicated", "guest"]},
|
||||
"npc_jacket": {"layer": "jacket", "hair": "short", "gear": ["lanyard", "clipboard"],
|
||||
"base": "casual",
|
||||
"roles": ["authorized_staff", "protester", "guest"]},
|
||||
"npc_coat": {"layer": "coat", "hair": "long",
|
||||
"npc_coat": {"layer": "coat", "hair": "long", "base": "hoodie",
|
||||
"gear": ["sunglasses", "shoulder_bag", "phone"],
|
||||
"roles": ["influencer", "trespasser", "violent_threat"]},
|
||||
"npc_vest": {"layer": "vest", "hair": "buzz", "gear": ["cap", "clipboard"],
|
||||
"base": "worker",
|
||||
"roles": ["delivery"]},
|
||||
"npc_dress": {"layer": "dress", "hair": "bun", "build": "slim",
|
||||
"npc_dress": {"layer": "dress", "hair": "bun", "build": "slim", "base": "casual",
|
||||
"gear": ["shoulder_bag"],
|
||||
"roles": ["performer"]},
|
||||
"npc_hoodie": {"layer": "hood", "hair": "short", "gear": ["cup"],
|
||||
"npc_hoodie": {"layer": "hood", "hair": "short", "gear": ["cup"], "base": "hoodie",
|
||||
"roles": ["trespasser", "mental_crisis", "violent_threat"]},
|
||||
"npc_firefighter": {"layer": "responder", "hair": "buzz", "build": "broad",
|
||||
"base": "worker", "drop": ["Worker_Vest", "Worker_Yellow"],
|
||||
"gear": ["firefighter_helmet", "scba_tank", "flashlight"],
|
||||
"roles": ["responder"]},
|
||||
"npc_guard": {"layer": "guard", "hair": "buzz", "build": "broad",
|
||||
"base": "worker", "drop": ["Worker_Vest", "Worker_Yellow"],
|
||||
"gear": ["cap", "wand"],
|
||||
"roles": ["player_guard", "venue_security"]},
|
||||
# Crowd-variety prototypes (character development): vary build, head
|
||||
# silhouette, hair/beard and outer layer without labelling behaviour.
|
||||
"npc_crowd_a": {"layer": "none", "hair": "bun", "build": "slim",
|
||||
# Crowd-variety prototypes (character development): the three Quaternius
|
||||
# sources are spread across the crowd pools so a crowd never shows one mesh.
|
||||
"npc_crowd_a": {"layer": "none", "hair": "bun", "build": "slim", "base": "casual",
|
||||
"gear": ["sunglasses"], "roles": ["tourist", "guest"]},
|
||||
"npc_crowd_b": {"layer": "none", "hair": "bald", "build": "broad",
|
||||
"npc_crowd_b": {"layer": "none", "hair": "bald", "build": "broad", "base": "casual",
|
||||
"beard": True, "gear": ["shoulder_bag"],
|
||||
"roles": ["confused_visitor", "guest"]},
|
||||
"npc_crowd_c": {"layer": "coat", "hair": "short",
|
||||
"npc_crowd_c": {"layer": "coat", "hair": "short", "base": "hoodie",
|
||||
"gear": ["umbrella"], "roles": ["intoxicated", "guest"]},
|
||||
"npc_crowd_d": {"layer": "hood", "hair": "short", "build": "slim",
|
||||
"base": "worker", "drop": ["Worker_Vest", "Worker_Yellow"],
|
||||
"gear": ["cup"], "roles": ["tourist", "guest"]},
|
||||
}
|
||||
|
||||
@@ -133,6 +143,11 @@ ATTACH_BLENDER = {"head": (0.0, 0.0, 1.62), "hand": (0.27, -0.02, 0.78),
|
||||
ATTACH_GODOT = {"head": (0.0, 1.62, 0.0), "hand": (0.27, 0.78, 0.02),
|
||||
"back": (0.0, 1.10, 0.0), "chest": (0.0, 1.30, 0.0)}
|
||||
HEAD_BAND = (1.55, 1.81) # face/headgear height band in Godot metres
|
||||
# Head gear is authored at the procedural library's head size; the shipped
|
||||
# Quaternius roster has a 0.20 m head whose centre sits higher (see
|
||||
# `build_gear`). 0.64 = 0.201 / 0.315.
|
||||
HEAD_GEAR_SCALE = 0.64
|
||||
HEAD_GEAR_LIFT = 0.07
|
||||
FIT_HEAD_MATERIALS = ("skin",)
|
||||
FIT_CLOTHING_MATERIAL = "cloth"
|
||||
FIT_CLOTHING_BAND = (0.30, 0.52) # hip/skirt band; captures coat/dress flare
|
||||
@@ -1439,6 +1454,312 @@ def build_character(name, head="face"):
|
||||
return [arm, mesh]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Quaternius roster (ADR-0017 / ADR-0018)
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# The CC0 sources live under `blender_src/quaternius/` (gitignored for Godot via
|
||||
# the parent `.gdignore`) and are build inputs, not runtime assets. Every source
|
||||
# is 4 skinned meshes on the same 62-joint armature; the build joins them
|
||||
# **without welding** (a weld corrupts the skin weights), renames the upstream
|
||||
# materials onto the five tint slots, unwraps the degenerate source UVs and bakes
|
||||
# the arm-down rest. See docs/npc-model-sourcing.md for the measurements.
|
||||
|
||||
QUATERNIUS_SRC = SRC_DIR / "quaternius"
|
||||
|
||||
# Material -> contract slot per source. `"keep"` leaves the authored colour
|
||||
# (face, hair detail and hivis fabrics); anything not listed is kept too, so a
|
||||
# new upstream material shows up as an extra fixed-colour slot instead of
|
||||
# silently disappearing. The maps were derived from the per-material height bands
|
||||
# measured in docs/npc-model-sourcing.md.
|
||||
QUATERNIUS_SOURCES = {
|
||||
"casual": {
|
||||
"file": "Casual.glb",
|
||||
"slots": {"Skin": "skin", "White": "cloth", "Orange": "pants",
|
||||
"Grey": "accessory", "Hair_Blond": "hair",
|
||||
"Brown": "keep", "Hair_Brown": "keep"},
|
||||
"notes": "white tee + orange trousers + grey shoes; blond hair",
|
||||
},
|
||||
"hoodie": {
|
||||
"file": "Casual_Hoodie.glb",
|
||||
"slots": {"Skin": "skin", "Purple": "cloth", "LightBlue": "pants",
|
||||
"Hair": "hair", "White": "accessory",
|
||||
"Eye": "keep", "Eyebrows": "keep"},
|
||||
"notes": "hoodie + shorts + white shoes (the hoodie covers the shoe uppers)",
|
||||
},
|
||||
"worker": {
|
||||
"file": "Worker.glb",
|
||||
"slots": {"Skin": "skin", "LightBrown": "cloth", "Brown": "pants",
|
||||
"Brown2": "pants", "Grey": "accessory", "Black": "accessory",
|
||||
# The Worker's head is bare apart from a hard hat, so the
|
||||
# brows carry the hair tint and the slot keeps a surface.
|
||||
"Eyebrows": "hair",
|
||||
"Eye": "keep", "Moustache": "keep",
|
||||
"Worker_Vest": "keep", "Worker_Yellow": "keep"},
|
||||
"notes": "work shirt + trousers + hivis vest + hard hat",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _quaternius_slot_map(obj, slot_map, drop):
|
||||
"""Rename the upstream materials onto the contract slots.
|
||||
|
||||
Several upstream materials can share a slot (trousers + patches), so the
|
||||
polygons are repointed at one Blender material instead of relying on a
|
||||
rename that Blender would de-duplicate to `pants.001`.
|
||||
"""
|
||||
materials = [m for m in obj.data.materials if m is not None]
|
||||
dropped = {i for i, m in enumerate(materials) if m.name in drop}
|
||||
kept = []
|
||||
target = {}
|
||||
remap = {}
|
||||
for index, m in enumerate(materials):
|
||||
slot = slot_map.get(m.name, "keep")
|
||||
if slot == "keep":
|
||||
if m.name not in target:
|
||||
target[m.name] = len(kept)
|
||||
kept.append(m)
|
||||
remap[index] = target[m.name]
|
||||
continue
|
||||
if slot in target:
|
||||
remap[index] = target[slot]
|
||||
continue
|
||||
m.name = slot
|
||||
target[slot] = len(kept)
|
||||
kept.append(m)
|
||||
remap[index] = target[slot]
|
||||
# Faces that only exist for a dropped material go away with it.
|
||||
if dropped:
|
||||
doomed = [p.index for p in obj.data.polygons
|
||||
if p.material_index in dropped]
|
||||
if doomed:
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(obj.data)
|
||||
bm.faces.ensure_lookup_table()
|
||||
bmesh.ops.delete(bm, geom=[bm.faces[i] for i in doomed],
|
||||
context="FACES")
|
||||
bm.to_mesh(obj.data)
|
||||
bm.free()
|
||||
# Loose vertices left behind by the helmet/vest would export as an
|
||||
# unreferenced blob, so drop them before the UV pass.
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(obj.data)
|
||||
loose = [v for v in bm.verts if not v.link_faces]
|
||||
if loose:
|
||||
bmesh.ops.delete(bm, geom=loose, context="VERTS")
|
||||
bm.to_mesh(obj.data)
|
||||
bm.free()
|
||||
# `materials.clear()` resets every polygon to slot 0, so record the source
|
||||
# indices first or the whole mesh collapses onto the first slot.
|
||||
source_index = [poly.material_index for poly in obj.data.polygons]
|
||||
obj.data.materials.clear()
|
||||
for m in kept:
|
||||
obj.data.materials.append(m)
|
||||
for poly, index in zip(obj.data.polygons, source_index):
|
||||
poly.material_index = remap.get(index, 0)
|
||||
|
||||
|
||||
def _bake_quaternius_rest(arm, mesh):
|
||||
"""Bake the T-pose into an arm-down rest pose.
|
||||
|
||||
This runs on the re-imported T-pose export, not on the raw upstream file.
|
||||
The order matters: pose, read the posed matrices, apply the Armature
|
||||
modifier, **reset the pose to identity**, write the rest matrices, re-add the
|
||||
modifier. Skipping the reset double-applies the arm-down (mesh width ±0.53 m
|
||||
instead of ±0.23 m); `bpy.ops.object.armature_apply()` in Blender 5.2
|
||||
deforms the mesh without baking the rest and is not usable here.
|
||||
"""
|
||||
bpy.context.view_layer.objects.active = arm
|
||||
bpy.ops.object.mode_set(mode="POSE")
|
||||
for side in ("L", "R"):
|
||||
pose_bone = arm.pose.bones.get("UpperArm." + side)
|
||||
if pose_bone is None:
|
||||
continue
|
||||
pose_bone.rotation_mode = "XYZ"
|
||||
# Local X swings the arm in the frontal plane. This is only symmetric on
|
||||
# *our* exported rest: the raw upstream file mirrors the bone rolls, so
|
||||
# the build always round-trips through `_quaternius_tpose_glb()` first.
|
||||
pose_bone.rotation_euler = (-math.pi / 2, 0, 0)
|
||||
bpy.ops.object.mode_set(mode="OBJECT")
|
||||
bpy.context.view_layer.update()
|
||||
for side in ("L", "R"):
|
||||
wrist = arm.pose.bones.get("Wrist." + side)
|
||||
if wrist is not None:
|
||||
head = arm.matrix_world @ wrist.head
|
||||
print(f" wrist.{side} at x={head.x:+.3f} y={head.y:+.3f} z={head.z:+.3f}")
|
||||
|
||||
fingers = ("Thumb", "Index", "Middle", "Ring", "Pinky")
|
||||
posed = {}
|
||||
for side in ("L", "R"):
|
||||
for bone in arm.data.bones:
|
||||
name = bone.name
|
||||
if not name.endswith(side):
|
||||
continue
|
||||
if name.startswith(("UpperArm", "LowerArm", "Wrist")) or name.startswith(fingers):
|
||||
posed[name] = arm.pose.bones[name].matrix.copy()
|
||||
|
||||
arm_modifier = next(m for m in mesh.modifiers if m.type == "ARMATURE")
|
||||
bpy.context.view_layer.objects.active = mesh
|
||||
bpy.ops.object.modifier_apply(modifier=arm_modifier.name)
|
||||
|
||||
bpy.context.view_layer.objects.active = arm
|
||||
bpy.ops.object.mode_set(mode="POSE")
|
||||
for pose_bone in arm.pose.bones:
|
||||
pose_bone.matrix_basis.identity()
|
||||
bpy.ops.object.mode_set(mode="OBJECT")
|
||||
bpy.context.view_layer.update()
|
||||
|
||||
bpy.context.view_layer.objects.active = arm
|
||||
bpy.ops.object.mode_set(mode="EDIT")
|
||||
for name, matrix in posed.items():
|
||||
arm.data.edit_bones[name].matrix = matrix
|
||||
bpy.ops.object.mode_set(mode="OBJECT")
|
||||
bpy.context.view_layer.update()
|
||||
|
||||
bpy.context.view_layer.objects.active = mesh
|
||||
modifier = mesh.modifiers.new("Armature", "ARMATURE")
|
||||
modifier.object = arm
|
||||
bpy.context.view_layer.update()
|
||||
evaluated = mesh.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
xs = [v.co.x for v in evaluated.data.vertices]
|
||||
print(f" arm-down rest baked, deformed x {min(xs):.3f}..{max(xs):.3f}")
|
||||
|
||||
|
||||
def _export_preserving_skin(objs, category, name):
|
||||
"""Export a skinned GLB without touching the rest pose.
|
||||
|
||||
`export_apply=True` (what `export()` uses for the procedural library) bakes
|
||||
the evaluated deformation a second time and writes wrong inverse bind
|
||||
matrices, so Godot then renders the A-pose.
|
||||
"""
|
||||
path = asset_path(category, name)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
for o in bpy.context.selected_objects:
|
||||
o.select_set(False)
|
||||
for o in objs:
|
||||
o.select_set(True)
|
||||
bpy.context.view_layer.objects.active = objs[0]
|
||||
bpy.ops.export_scene.gltf(
|
||||
filepath=str(path), export_format="GLB", use_selection=True,
|
||||
export_apply=False, export_animations=False, export_skins=True,
|
||||
export_yup=True, export_materials="EXPORT", export_image_format="AUTO")
|
||||
SRC_DIR.mkdir(parents=True, exist_ok=True)
|
||||
bpy.ops.wm.save_as_mainfile(filepath=str(SRC_DIR / f"{name}.blend"),
|
||||
compress=True)
|
||||
return path
|
||||
|
||||
|
||||
def _quaternius_work_dir():
|
||||
path = ROOT / "tmp" / "npc-build"
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def _quaternius_tpose_glb(base, drop):
|
||||
"""Export (and cache) the joined, slot-mapped, unwrapped T-pose base.
|
||||
|
||||
The arm-down bake only behaves symmetrically on our own export, because the
|
||||
upstream file mirrors the bone rolls; this round trip normalises them. The
|
||||
result depends only on the source and the dropped materials, so it is cached
|
||||
for the run.
|
||||
"""
|
||||
key = base + ("-" + "-".join(sorted(drop)) if drop else "")
|
||||
path = _quaternius_work_dir() / f"tpose_{key}.glb"
|
||||
if path.is_file():
|
||||
return path
|
||||
source = QUATERNIUS_SOURCES[base]
|
||||
source_path = QUATERNIUS_SRC / source["file"]
|
||||
if not source_path.is_file():
|
||||
raise FileNotFoundError(
|
||||
f"{source_path} is missing; see {QUATERNIUS_SRC / 'README.md'}")
|
||||
reset_scene()
|
||||
bpy.ops.import_scene.gltf(filepath=str(source_path))
|
||||
# The importer brings an Icosphere helper along; it is not part of the body
|
||||
# and has no UVs or weights.
|
||||
for o in list(bpy.context.scene.objects):
|
||||
if o.name.startswith("Icosphere"):
|
||||
bpy.data.objects.remove(o, do_unlink=True)
|
||||
meshes = [o for o in bpy.context.scene.objects if o.type == "MESH"]
|
||||
if not meshes:
|
||||
raise RuntimeError(f"{source_path} imported no skinned meshes")
|
||||
arm = next(o for o in bpy.context.scene.objects if o.type == "ARMATURE")
|
||||
|
||||
# Join without welding: remove_doubles merges vertices across weight groups
|
||||
# and the resulting skinning is wrong (see MEMORY.md).
|
||||
for o in bpy.context.scene.objects:
|
||||
o.select_set(False)
|
||||
for o in meshes:
|
||||
o.select_set(True)
|
||||
bpy.context.view_layer.objects.active = meshes[0]
|
||||
bpy.ops.object.join()
|
||||
obj = bpy.context.object
|
||||
obj.name = "npc_base"
|
||||
obj.data.name = "npc_base"
|
||||
|
||||
_quaternius_slot_map(obj, source["slots"], drop)
|
||||
|
||||
# Upstream UVs are degenerate (every loop at 0,0), so an AO bake would write
|
||||
# an all-black map. Repack a real layout at a tight island margin (the
|
||||
# default 0.01 wastes most of the square on a dense body), then scale it to
|
||||
# fill the square so the 256-square maps keep a usable texel density.
|
||||
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(66.0), island_margin=0.0005)
|
||||
bpy.ops.uv.average_islands_scale()
|
||||
bpy.ops.uv.pack_islands(margin=0.0015, rotate=True)
|
||||
bpy.ops.object.mode_set(mode="OBJECT")
|
||||
uv_layer = obj.data.uv_layers.active
|
||||
loops = uv_layer.data
|
||||
us = [d.uv[0] for d in loops]
|
||||
vs = [d.uv[1] for d in loops]
|
||||
span = max(max(us) - min(us), max(vs) - min(vs))
|
||||
if span > 1e-6:
|
||||
scale = 0.98 / span
|
||||
low_u, low_v = min(us), min(vs)
|
||||
for d in loops:
|
||||
d.uv = ((d.uv[0] - low_u) * scale + 0.01,
|
||||
(d.uv[1] - low_v) * scale + 0.01)
|
||||
|
||||
# Only the five contract slots are tinted at runtime; make sure a source that
|
||||
# does not paint with one of them still declares it.
|
||||
present = {m.name for m in obj.data.materials if m}
|
||||
for slot in SLOTS:
|
||||
if slot not in present:
|
||||
obj.data.materials.append(bpy.data.materials.get(slot)
|
||||
or material(slot))
|
||||
|
||||
for o in bpy.context.scene.objects:
|
||||
o.select_set(False)
|
||||
arm.select_set(True)
|
||||
obj.select_set(True)
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
bpy.ops.export_scene.gltf(
|
||||
filepath=str(path), export_format="GLB", use_selection=True,
|
||||
export_apply=False, export_animations=False, export_skins=True,
|
||||
export_yup=True, export_materials="EXPORT", export_image_format="AUTO")
|
||||
print(f" T-pose base {path.relative_to(ROOT)}")
|
||||
return path
|
||||
|
||||
|
||||
def build_quaternius_character(name):
|
||||
"""Bake one roster character from its CC0 Quaternius source."""
|
||||
spec = CHARACTERS[name]
|
||||
tpose = _quaternius_tpose_glb(spec["base"], spec.get("drop", []))
|
||||
reset_scene()
|
||||
bpy.ops.import_scene.gltf(filepath=str(tpose))
|
||||
arm = next(o for o in bpy.context.scene.objects if o.type == "ARMATURE")
|
||||
mesh = next(o for o in bpy.context.scene.objects if o.type == "MESH")
|
||||
mesh.name = "npc_base"
|
||||
mesh.data.name = "npc_base"
|
||||
_bake_quaternius_rest(arm, mesh)
|
||||
return [arm, mesh]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gear (static, attach-point space; see GEAR_ATTACH)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1602,7 +1923,19 @@ def build_gear(name):
|
||||
else:
|
||||
raise KeyError(name)
|
||||
obj = join(p, name)
|
||||
if GEAR[name] == "hand": # origin = Hand proxy (wrist); grip 5 cm lower
|
||||
if GEAR[name] == "head":
|
||||
# Head gear is authored around the head *centre*. The shipped roster is
|
||||
# the Quaternius base (ADR-0018): its head is 0.20 m wide against the
|
||||
# procedural library's 0.315 m, and its centre sits 0.07 m higher, so the
|
||||
# head gear is scaled and lifted to match. The procedural fallback then
|
||||
# wears slightly small hats — cosmetic, and recorded in
|
||||
# docs/blender-assets.md.
|
||||
centre = Vector((0.0, 0.0, HEAD_GEAR_LIFT))
|
||||
obj.data.transform(
|
||||
Matrix.Translation(centre)
|
||||
@ Matrix.Diagonal((HEAD_GEAR_SCALE, HEAD_GEAR_SCALE,
|
||||
HEAD_GEAR_SCALE, 1.0)))
|
||||
elif 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]
|
||||
@@ -1930,15 +2263,18 @@ def build_prop(name):
|
||||
# Build + render entry points (inside Blender)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _build_one(category, name, head="face"):
|
||||
def _build_one(category, name, head="face", source="quaternius"):
|
||||
reset_scene()
|
||||
quaternius = (category == "npc" and source == "quaternius"
|
||||
and "base" in CHARACTERS[name])
|
||||
if category == "npc":
|
||||
objs = build_character(name, head=head)
|
||||
objs = (build_quaternius_character(name) if quaternius
|
||||
else build_character(name, head=head))
|
||||
elif category == "npc_gear":
|
||||
objs = build_gear(name)
|
||||
else:
|
||||
objs = build_prop(name)
|
||||
path = export(objs, category, name)
|
||||
path = (_export_preserving_skin if quaternius else export)(objs, category, name)
|
||||
print(f"wrote {path.relative_to(ROOT)}")
|
||||
return path
|
||||
|
||||
@@ -2840,7 +3176,8 @@ def write_manifest(results):
|
||||
if r["category"] == "npc":
|
||||
spec = CHARACTERS[r["name"]]
|
||||
entry.update({"layer": spec["layer"], "roles": spec["roles"],
|
||||
"tint_slots": SLOTS, "mesh_node": "npc_base"})
|
||||
"tint_slots": SLOTS, "mesh_node": "npc_base",
|
||||
"base": spec.get("base", "procedural")})
|
||||
elif r["category"] == "npc_gear":
|
||||
entry["attach"] = GEAR[r["name"]]
|
||||
else:
|
||||
@@ -2892,7 +3229,10 @@ def main(argv=None):
|
||||
s.add_argument("--only", default="")
|
||||
if cmd == "build":
|
||||
s.add_argument("--head", default="face", choices=("face", "proto", "mh"),
|
||||
help="character head source (default: face)")
|
||||
help="procedural character head source (default: face)")
|
||||
s.add_argument("--source", default="quaternius",
|
||||
choices=("quaternius", "procedural"),
|
||||
help="character mesh source (default: the Quaternius roster)")
|
||||
a = sub.add_parser("audit")
|
||||
a.add_argument("--json", action="store_true")
|
||||
fit = sub.add_parser("fit")
|
||||
@@ -2984,7 +3324,7 @@ def main(argv=None):
|
||||
return 2
|
||||
for cat, name in _selected(args.only):
|
||||
if args.cmd == "build":
|
||||
_build_one(cat, name, head=args.head)
|
||||
_build_one(cat, name, head=args.head, source=args.source)
|
||||
else:
|
||||
_render_one(cat, name)
|
||||
if args.cmd == "build":
|
||||
|
||||
Reference in new issue
Block a user