Phase 8 checkpoint

This commit is contained in:
2026-11-01 20:52:39 +03:00
parent b8af541c39
commit 8fbe9d1255
16 changed files with 1449 additions and 54 deletions
+101 -43
View File
@@ -51,9 +51,13 @@ Pipeline per stem (`male`, `female`, `baby-male`, `child-female`, ...):
`process_clothes.py` fits them to the body (weight transfer from the
reference body parts, seam fixes), `combine_clothes.py` bakes the
combined meshes for garment combinations (see §3).
3. `consolidate.py` appends everything into
`edited-normal-<stem>-consolidated.blend` and leaves exactly one body
armature.
3. `consolidate.py` appends each clothing stage and leaves exactly one
body armature. The clothes-only result is
`edited-normal-<stem>-consolidated-clothes.blend`. If
`nude-<stem>.blend` exists, a final consolidation appends its
detailed-nude parts; otherwise the clothes-only blend is copied.
Either way the final output is
`edited-normal-<stem>-consolidated.blend`.
4. `export_models2.py` exports the consolidated blend through
blender2ogre: one `.mesh` per catalog mesh object, the shared
`.skeleton`, `.material` files, the prefixed texture atlases,
@@ -158,45 +162,71 @@ Resolution per slot (`resolveMesh`), given the record's
`combine_clothes.py` — a new garment only needs its own entry, the
combinations are generated.
- **outfitLevel 1 (underwear)**: the selected layer-1 mesh.
- **outfitLevel 0 (nude)**: the layer-0 entry tagged
**`detailed-nude`** when one exists for the slot (phase 6.C.1) —
the plain layer-0 meshes are simplified composition bases meant to
disappear under garments and lack anatomical detail. Author at most
one `detailed-nude` variant per slot per sex (`top`/`bottom`/`feet`);
slots without one keep the composition base, so the variants can be
added one slot at a time. Tag it via `clothing_tags =
"detailed-nude"` on a layer-0 mesh.
- **No layer-1/2 garment resolved (nude slot)**: the layer-0 entry
tagged **`detailed-nude`** when one exists for the slot (phase
6.C.1) — the plain layer-0 meshes are simplified composition bases
meant to disappear under garments and lack anatomical detail. This
applies to `outfitLevel 0` and to `outfitLevel 1/2` records whose
garment selections are empty or point at a missing catalog entry.
Slots without a detailed variant keep the composition base, so the
variants can be authored one slot at a time. Tag it via
`clothing_tags = "detailed-nude"` on a layer-0 mesh.
- Final fallback: the shortest-named layer-0 entry (Blender `.001`
duplicates are penalised), then any entry.
### Authoring a `detailed-nude` part (whole-body-part replacement)
### Detailed-nude source files (`nude-<stem>.blend`)
Detailed-nude parts live in an optional, separate source blend per
age/sex stem: `nude-<stem>.blend` (`nude-male.blend`,
`nude-baby-female.blend`, ...). The file can contain up to three
complete body parts, one per slot: `top`, `bottom`, `feet`. It is
optional and may be populated one slot at a time; slots without a part
fall back to the composition bases. The normal build appends the file
**after** all clothing stages, so `combine_clothes.py` never sees these
parts and cannot delete or combine them.
A detailed-nude mesh is a COMPLETE standalone body part that replaces
the simplified composition base at runtime when the character wears
nothing. It is NOT built from the base part by the pipeline and no
geometry is ever deleted from it:
the simplified composition base at runtime when the slot has no
clothes. It is NOT built from the base part by the pipeline and no
geometry is ever deleted from it. The plain base part must stay in
`edited-normal-<stem>.blend` — garments still need it as their
combination target.
- The hidden-geometry deletion (`combine_clothes.py`) only runs on
copies of the base meshes a garment explicitly names in its
`ref_part` property. No garment references your new mesh, so it
passes through the pipeline untouched, and the plain base part must
stay in the blend — garments still need it as their combination
target.
- The replacement happens at mesh-resolution time only: outfitLevel 0
shows the detailed part, outfitLevel >= 1 goes back to the
composition bases/combined meshes.
#### Seeding the file from the existing base parts
Steps:
The quickest start is the seeding helper, which copies the existing
layer-0 `BodyTop` / `BodyBottom` / `BodyFeet` parts into a new
`nude-<stem>.blend`, renames them `*_detailed`, strips clothing
metadata and tags them `detailed-nude`:
1. Model the part as a NEW mesh object in the base body blend
`edited-normal-<sex>.blend`, next to the existing base parts
(`BodyTop`/`BodyBottom`/...). One detailed variant per slot per
sex at most (`top`/`bottom`/`feet`); the engine takes the first
`detailed-nude` entry it finds for the slot.
2. Skin it to the SAME `mixamorig:` armature with the same bind pose,
max 4 bone influences per vertex. (`consolidate.py` re-parents it
to the armature named by its `sex` property and adds an Armature
modifier if missing, but the weights are your job.)
```bash
# Recommended: goes through the Blender lock.
cmake --build build-vscode --target init-nude-male # adult male
cmake --build build-vscode --target init-nude-child-female
```
Or run the helper directly (stop parallel asset builds first):
```bash
blender -b -Y -P assets/blender/characters/make_nude_blend.py -- \
--age adult --sex male
```
The helper refuses to overwrite an existing file unless `--force` is
passed. It includes the body armature for editing and skinning; the
pipeline keeps one body armature during consolidation. If you
regenerate an age seed with `--force`, its base boundaries can change;
re-seed or adjust the matching `nude-<stem>.blend` afterwards.
#### Editing requirements
1. Keep or replace the `BodyTop_detailed` / `BodyBottom_detailed` /
`BodyFeet_detailed` meshes. At most one `detailed-nude` entry per
slot per stem; the engine takes the first entry it finds.
2. Skin each part to the SAME `mixamorig:` armature with the same bind
pose, max 4 bone influences per vertex. (`consolidate.py`
re-parents it to the armature named by its `sex` property and adds
an Armature modifier if missing, but the weights are your job.)
3. Match the boundary loops of the base part it replaces (neck, waist,
wrists, ankles) vertex-for-vertex in position: the consolidate
step's seam fixer unifies shape-key offsets and normals for
@@ -205,17 +235,38 @@ Steps:
against the face/hands/neighbouring slots.
4. Copy the same shape keys (`fat`, ...) onto it — helper scripts in
this directory: `transfer_shape_keys.py`,
`propagate_shape_keys.py`, `add_missing_shape_keys.py`.
`propagate_shape_keys.py`, `add_missing_shape_keys.py`. The seeding
helper already does this from `Body_shapes`, and the final
consolidation runs `add_missing_shape_keys.py` as a safety net, so a
manually authored part also receives the missing keys automatically.
5. Custom properties on the mesh OBJECT: `age`/`sex`/`slot` as usual,
`layer` = 0, `clothing_tags` = `detailed-nude`, no `garments`.
6. Give object AND mesh data a unique name (e.g.
`male_BodyTop_detailed`); the mesh data name becomes the `.mesh`
file name.
7. Rebuild (`edited-blends` target or full build). No CMake or code
changes are needed: consolidate/export discover the part by its
custom properties and emit `body_part_<mesh data name>.json`
automatically, and the runtime starts using it for nude characters
of that slot.
The seeding helper sets these automatically.
6. Give object AND mesh data unique names (e.g. `BodyTop_detailed`);
the mesh data name becomes the exported `.mesh` name prefixed with
the stem (`male_BodyTop_detailed.mesh`).
7. Rebuild the character (`character-<stem>`, `characters-all`, or the
full build). No CMake edits are needed: `CMakeLists.txt` detects
`nude-<stem>.blend`, appends it after the clothing consolidation and
exports the parts as layer-0 catalog entries. If the file was added
while CMake was already configured, the `CONFIGURE_DEPENDS` glob
re-runs CMake on the next build (or run `cmake -B build-vscode -S .`
manually).
8. Verify with `cmake --build build-vscode --target
character-assets-check`: when a `nude-<stem>.blend` exists, the
validator requires at least one `detailed-nude` catalog entry, checks
that every entry is layer 0 with no `garments` and matching
age/sex, and warns about missing slots.
Do not add detailed-nude meshes to `edited-normal-<stem>.blend` for the
new workflow. The separate file is what keeps them out of the clothing
combination/removal path and lets them be replaced independently. Older
assets with detailed-nude meshes already in the base blend still resolve
at runtime; they simply do not get the separate-file workflow.
The current LOD2/3 whole-character impostors still use the default
dressed outfit. Detailed-nude parts are visible at LOD0/LOD1; a nude
impostor variant is future work.
The town population picks looks deterministically per NPC:
`fnv1a(npcKey|slot)` indexes into the layer-1 hair list and (when
@@ -356,3 +407,10 @@ contract suite:
./demoTaxi --headless --test-motion
./demoTaxi --headless --test-detail
```
Detailed-nude verification: seed a file with `init-nude-<stem>`, rebuild
that stem, then set a character of that age/sex to outfit level Nude (or
clear its layer-1/2 garment selections). The editor's "Resolved:" line
and the slot mesh should show the `*_detailed` catalog entry; clothed
characters must keep their selected garments. `--test-ages` also checks
the runtime preference for every stem that has a `detailed-nude` entry.
+94 -2
View File
@@ -28,6 +28,20 @@ function(character_source_blend AGE SEX OUT)
PARENT_SCOPE)
endfunction()
function(character_nude_source AGE SEX OUT)
character_stem(${AGE} ${SEX} STEM)
set(${OUT}
"${CMAKE_CURRENT_SOURCE_DIR}/nude-${STEM}.blend"
PARENT_SCOPE)
endfunction()
# Optional detailed-nude sources: one nude-<stem>.blend per age/sex.
# CONFIGURE_DEPENDS makes CMake re-run when a file is added/removed, so the
# per-stem pipeline below can switch between the plain clothes-consolidated
# blend and the extra nude-append stage without a manual reconfigure.
file(GLOB CHARACTER_NUDE_BLENDS CONFIGURE_DEPENDS
"${CMAKE_CURRENT_SOURCE_DIR}/nude-*.blend")
function(character_glb AGE SEX OUT)
character_stem(${AGE} ${SEX} STEM)
set(${OUT} "${CMAKE_BINARY_DIR}/characters/${STEM}/normal-${STEM}.glb"
@@ -471,6 +485,22 @@ foreach(AGE ${CHARACTER_BUILD_AGES})
foreach(SEX ${CHARACTER_SEXES})
character_stem(${AGE} ${SEX} STEM)
character_build_blend(${AGE} ${SEX} BASE_BUILD)
character_nude_source(${AGE} ${SEX} NUDE_SRC)
if(EXISTS "${NUDE_SRC}")
set(NUDE_STATE 1)
else()
set(NUDE_STATE 0)
endif()
# The state file is rewritten only when its content changes. It is
# a dependency of the final blend, so adding/removing a nude source
# forces the final stage to re-run even when its output path stays
# the same.
configure_file(
"${CMAKE_CURRENT_SOURCE_DIR}/cmake/nude_state.in"
"${CMAKE_CURRENT_BINARY_DIR}/nude-${STEM}.state"
@ONLY)
set(CLOTHES_CONSOLIDATED
"${CMAKE_CURRENT_BINARY_DIR}/edited-normal-${STEM}-consolidated-clothes.blend")
set(SHAPES_BLEND
"${CMAKE_CURRENT_BINARY_DIR}/edited-normal-${STEM}-shapes.blend")
set(CONSOLIDATED
@@ -490,7 +520,8 @@ foreach(AGE ${CHARACTER_BUILD_AGES})
set(SLOT_OUTPUT
"edited-normal-${STEM}-consolidated-${SLOT}.blend")
if(SLOT STREQUAL LAST_SLOT)
set(SLOT_OUTPUT "edited-normal-${STEM}-consolidated.blend")
set(SLOT_OUTPUT
"edited-normal-${STEM}-consolidated-clothes.blend")
endif()
add_clothes_pipeline("${CMAKE_CURRENT_BINARY_DIR}/${SLOT_INPUT}"
"${WEIGHTED}"
@@ -498,6 +529,46 @@ foreach(AGE ${CHARACTER_BUILD_AGES})
set(SLOT_INPUT ${SLOT_OUTPUT})
endforeach()
# Final stage: the clothes pipeline always ends at
# *-consolidated-clothes.blend. When an optional
# nude-<stem>.blend exists, append its layer-0 detailed-nude parts
# after all clothing stages (so combine_clothes.py never sees or
# removes them); otherwise stage a plain copy as *-consolidated.blend.
set(NUDE_STATE_FILE
"${CMAKE_CURRENT_BINARY_DIR}/nude-${STEM}.state")
set(FINAL_DEPS "${CLOTHES_CONSOLIDATED}" "${NUDE_STATE_FILE}")
if(EXISTS "${NUDE_SRC}")
list(APPEND FINAL_DEPS "${NUDE_SRC}")
add_custom_command(
OUTPUT ${CONSOLIDATED}
COMMAND ${BLENDER_LOCKED} -b -Y "${CLOTHES_CONSOLIDATED}"
-P ${CMAKE_CURRENT_SOURCE_DIR}/consolidate.py --
"${NUDE_SRC}"
"${CONSOLIDATED}"
COMMAND ${BLENDER_LOCKED} -b -Y "${CONSOLIDATED}"
-P ${CMAKE_CURRENT_SOURCE_DIR}/add_missing_shape_keys.py --
"${CONSOLIDATED}" "${CONSOLIDATED}"
COMMAND ${CMAKE_COMMAND} -D FILE=${CONSOLIDATED}
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
DEPENDS ${FINAL_DEPS}
${CMAKE_CURRENT_SOURCE_DIR}/consolidate.py
${CMAKE_CURRENT_SOURCE_DIR}/add_missing_shape_keys.py
${CMAKE_CURRENT_SOURCE_DIR}/transfer_shape_keys.py
${CMAKE_CURRENT_BINARY_DIR}/blender-addons-installed
COMMENT "Appending detailed nude parts and propagating shape keys from ${NUDE_SRC}"
)
else()
add_custom_command(
OUTPUT ${CONSOLIDATED}
COMMAND ${CMAKE_COMMAND} -E copy
"${CLOTHES_CONSOLIDATED}" "${CONSOLIDATED}"
COMMAND ${CMAKE_COMMAND} -D FILE=${CONSOLIDATED}
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
DEPENDS ${FINAL_DEPS}
COMMENT "Staging final character blend for ${STEM}"
)
endif()
add_custom_command(
OUTPUT ${LODS}
COMMAND ${CMAKE_COMMAND} -E remove_directory ${OUTDIR}
@@ -546,10 +617,31 @@ endforeach()
add_custom_target(characters-all DEPENDS ${ALL_CHARACTER_TARGETS})
# CMake lists use semicolons; the shell would split --ages on them. The
# validator accepts commas, so pass a comma-separated list instead.
string(REPLACE ";" "," CHARACTER_ASSETS_AGES "${CHARACTER_BUILD_AGES}")
# Authoring helper: seed nude-<stem>.blend from the existing layer-0
# BodyTop/BodyBottom/BodyFeet parts. The helper refuses to overwrite an
# existing file unless --force is passed manually; these targets are
# opt-in and never part of the normal build.
foreach(AGE ${CHARACTER_AGES})
foreach(SEX ${CHARACTER_SEXES})
character_stem(${AGE} ${SEX} STEM)
add_custom_target(init-nude-${STEM}
COMMAND ${BLENDER_LOCKED} -b -Y
-P ${CMAKE_CURRENT_SOURCE_DIR}/make_nude_blend.py --
--age ${AGE} --sex ${SEX}
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
COMMENT "Seeding nude-${STEM}.blend from the existing base parts")
endforeach()
endforeach()
add_custom_target(character-assets-check
COMMAND python3 ${CMAKE_CURRENT_SOURCE_DIR}/validate_age_assets.py
--characters-dir ${CMAKE_BINARY_DIR}/characters
--ages "${CHARACTER_BUILD_AGES}"
--source-dir ${CMAKE_CURRENT_SOURCE_DIR}
--ages "${CHARACTER_ASSETS_AGES}"
DEPENDS characters-all
COMMENT "Validating generated character age assets")
@@ -0,0 +1,2 @@
nude=@NUDE_STATE@
source=@NUDE_SRC@
Binary file not shown.
@@ -0,0 +1,290 @@
#!/usr/bin/env python3
"""Seed an optional ``nude-<stem>.blend`` from the existing layer-0 parts.
``nude-<stem>.blend`` holds the optional anatomically detailed body parts
for one age/sex band. This helper makes the first version for a stem by
copying the current composition bases (``BodyTop`` / ``BodyBottom`` /
``BodyFeet``) into a standalone blend, tagging them ``detailed-nude`` and
giving them unique object/data names. Edit the resulting file in Blender;
the normal character pipeline appends it after all clothing stages and the
runtime prefers those parts whenever a slot has no layer-1/2 garment.
Run through CMake (recommended; participates in the Blender lock)::
cmake --build build-vscode --target init-nude-male
or directly::
blender -b -Y -P assets/blender/characters/make_nude_blend.py -- \
--age adult --sex male [--force]
The helper never overwrites an existing file without ``--force``.
"""
from __future__ import annotations
import argparse
import os
import sys
import bpy
_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
if _THIS_DIR not in sys.path:
sys.path.insert(0, _THIS_DIR)
import age_profiles # noqa: E402
import add_missing_shape_keys as shape_keys # noqa: E402
SLOT_ORDER = ("top", "bottom", "feet")
BASE_NAMES = {
"top": "BodyTop",
"bottom": "BodyBottom",
"feet": "BodyFeet",
}
CLOTHING_PROPS = (
"garments",
"_combined_meta",
"ref_layer",
"ref_part",
"garment_id",
"ref_age",
"ref_sex",
"ref_clothing",
"tags",
"own_skeleton",
"has_own_armature",
"own_armature_name",
)
def log(msg):
print("[nude-seed] " + msg, flush=True)
def parse_args():
argv = sys.argv
if "--" in argv:
argv = argv[argv.index("--") + 1:]
else:
argv = []
p = argparse.ArgumentParser()
p.add_argument("--age", required=True,
choices=age_profiles.CANONICAL_AGES)
p.add_argument("--sex", required=True, choices=("male", "female"))
p.add_argument("--source", default="")
p.add_argument("--output", default="")
p.add_argument("--slots", default=",".join(SLOT_ORDER))
p.add_argument("--force", action="store_true")
return p.parse_args(argv)
def object_name_score(obj):
"""Prefer the canonical base name, then the shortest name.
Mirrors CharacterSlotSystem's layer-0 fallback: Blender duplicate
suffixes are penalised so ``BodyTop`` beats ``BodyTop.001``.
"""
name = obj.name
score = len(name)
if name.startswith(BASE_NAMES.get(obj.get("slot", ""), "\0")):
score -= 1000
if "." in name:
score += 1000
return score
def find_body_armature(stem):
arm = bpy.data.objects.get(stem)
if arm is not None and arm.type == 'ARMATURE':
return arm
candidates = [o for o in bpy.data.objects if o.type == 'ARMATURE']
if not candidates:
return None
return max(candidates, key=lambda o: len(o.data.bones))
def find_base_part(slot, age, sex):
candidates = []
for obj in bpy.data.objects:
if obj.type != 'MESH' or obj.data is None:
continue
if not all(k in obj for k in ("age", "sex", "slot")):
continue
if age_profiles.normalize_age(obj.get("age", "adult")) != age:
continue
if str(obj.get("sex", "")) != sex:
continue
if str(obj.get("slot", "")) != slot:
continue
if int(obj.get("layer", 0)) != 0:
continue
candidates.append(obj)
if not candidates:
return None
return min(candidates, key=object_name_score)
def find_shape_source():
"""Find the body reference mesh that carries the full shape-key set."""
for name in ("Body_shapes", "Body"):
obj = bpy.data.objects.get(name)
if obj is not None and obj.type == 'MESH' and \
obj.data.shape_keys is not None and \
len(obj.data.shape_keys.key_blocks) > 1:
return obj
return None
def build_shape_data(source_obj):
"""Same payload add_missing_shape_keys.py builds for its propagation."""
return {
"names": [kb.name for kb in source_obj.data.shape_keys.key_blocks],
"vertex_positions": {
sk.name: [(v.co.x, v.co.y, v.co.z) for v in sk.data]
for sk in source_obj.data.shape_keys.key_blocks
},
"polygons": [[v for v in poly.vertices]
for poly in source_obj.data.polygons],
"is_relative": source_obj.data.shape_keys.use_relative,
"source_obj_name": source_obj.name,
}
def duplicate_part(src, slot, age, sex, armature):
dup = src.copy()
dup.data = src.data.copy()
bpy.context.collection.objects.link(dup)
dup.name = src.name + "_detailed"
dup.data.name = dup.name
# Keep the source transform (the base parts are usually parented to the
# armature with an identity object matrix).
dup.parent = armature
dup.matrix_parent_inverse = src.matrix_parent_inverse.copy()
dup.matrix_world = src.matrix_world.copy()
for prop in CLOTHING_PROPS:
if prop in dup:
del dup[prop]
dup["age"] = age
dup["sex"] = sex
dup["slot"] = slot
dup["layer"] = 0
dup["clothing_tags"] = "detailed-nude"
mod = next((m for m in dup.modifiers if m.type == 'ARMATURE'), None)
if mod is None:
mod = dup.modifiers.new(name="Armature", type='ARMATURE')
mod.object = armature
if not dup.vertex_groups:
log(f"WARNING: '{dup.name}' has no vertex groups; skin it before "
f"building")
if dup.data.shape_keys is None:
log(f"WARNING: '{dup.name}' has no shape keys; copy them from "
f"the base part or use transfer_shape_keys.py")
return dup
def purge_orphans():
for _ in range(3):
try:
bpy.ops.outliner.orphans_purge(
do_local_ids=True, do_linked_ids=True,
do_recursive=True)
except Exception:
break
def main():
args = parse_args()
age = age_profiles.normalize_age(args.age)
sex = args.sex
stem = age_profiles.stem(age, sex)
source = args.source or os.path.join(
_THIS_DIR, f"edited-normal-{stem}.blend")
output = args.output or os.path.join(_THIS_DIR, f"nude-{stem}.blend")
if not os.path.exists(source):
log(f"ERROR: source body blend not found: {source}")
return 1
if os.path.exists(output) and not args.force:
log(f"SKIP: {output} already exists (use --force to reseed)")
return 0
slots = [s.strip() for s in args.slots.split(",") if s.strip()]
unknown = [s for s in slots if s not in SLOT_ORDER]
if unknown:
log(f"ERROR: unknown slots: {', '.join(unknown)}")
return 1
log(f"opening {source}")
bpy.ops.wm.open_mainfile(filepath=source)
armature = find_body_armature(stem)
if armature is None:
log(f"ERROR: no body armature found in {source}")
return 1
log(f"armature: {armature.name} ({len(armature.data.bones)} bones)")
shape_source = find_shape_source()
shape_data = build_shape_data(shape_source) if shape_source else None
if shape_source:
log(f"shape source: {shape_source.name} "
f"({len(shape_data['names'])} keys)")
else:
log("WARNING: no Body_shapes/Body shape-key source; the detailed "
"parts will only get keys already present on the base part")
duplicates = []
for slot in slots:
src = find_base_part(slot, age, sex)
if src is None:
log(f"WARNING: no layer-0 {slot} part found; skipping")
continue
dup = duplicate_part(src, slot, age, sex, armature)
if shape_data:
try:
shape_keys.add_missing_shape_keys(shape_data, dup)
except Exception as exc:
log(f"WARNING: shape-key propagation failed for "
f"{dup.name}: {exc}")
duplicates.append(dup)
log(f"{slot}: {src.name} -> {dup.name} "
f"({len(dup.data.vertices)} verts)")
if not duplicates:
log("ERROR: no layer-0 parts found for the requested slots")
return 1
# Keep exactly the new parts and the body armature. The user can model
# the detailed variants in this file without the rest of the body.
keep = set(duplicates) | {armature}
for obj in list(bpy.data.objects):
if obj not in keep:
bpy.data.objects.remove(obj, do_unlink=True)
# A source-blend armature may carry the full animation library; the
# detailed-nude file only needs the rest skeleton for skinning/editing.
armature.animation_data_clear()
for action in list(bpy.data.actions):
try:
bpy.data.actions.remove(action, do_unlink=True)
except TypeError:
bpy.data.actions.remove(action)
except Exception:
pass
purge_orphans()
os.makedirs(os.path.dirname(output), exist_ok=True)
bpy.ops.wm.save_as_mainfile(filepath=output)
log(f"saved {output}")
log("next: edit the *_detailed parts, then rebuild "
f"character-{stem} (or the full build)")
log("requirements: same armature/bind pose, max 4 bone influences, "
"shape keys, matching boundary loops")
return 0
if __name__ == "__main__":
sys.exit(main())
Binary file not shown.
@@ -43,6 +43,9 @@ def warn(msg, warnings):
def main():
p = argparse.ArgumentParser()
p.add_argument("--characters-dir", required=True)
p.add_argument("--source-dir", default=THIS_DIR,
help="character source directory containing optional "
"nude-<stem>.blend files (default: %(default)s)")
p.add_argument("--ages", default="",
help="semicolon/comma separated age bands to check "
"(default: all canonical bands)")
@@ -69,6 +72,20 @@ def main():
for sex in ("male", "female"):
expected_stems.append(age_profiles.stem(age, sex))
# Catch misspelled detailed-nude source names (e.g. nude-adult-male.blend)
# instead of silently ignoring them.
all_stems = {age_profiles.stem(age, sex)
for age in age_profiles.CANONICAL_AGES
for sex in ("male", "female")}
source_root = os.path.abspath(args.source_dir)
for path in sorted(glob.glob(os.path.join(source_root, "nude-*.blend"))):
name = os.path.basename(path)
stem = name[len("nude-"):-len(".blend")]
if stem not in all_stems:
warn(f"unexpected detailed-nude source name: {name} "
f"(expected nude-<stem>.blend, e.g. nude-male.blend)",
warnings)
for stem in expected_stems:
d = by_stem.get(stem)
if d is None:
@@ -89,7 +106,9 @@ def main():
entries.append(e)
ages = {age_profiles.normalize_age(e.get("age", ""))
for e in entries}
if ages != {stem.split("-")[0] if "-" in stem else "adult"}:
stem_age = stem.split("-")[0] if "-" in stem else "adult"
stem_sex = stem.rsplit("-", 1)[-1]
if ages != {stem_age}:
fail(f"{stem}: catalog ages {sorted(ages)} do not match stem",
errors)
slots = {}
@@ -105,6 +124,67 @@ def main():
if "hair" not in slots:
warn(f"{stem}: no hair slot (bald band)", warnings)
# Optional detailed-nude source file: when present, the built
# catalog must expose its layer-0 parts. Missing slots are only a
# warning because a nude-<stem>.blend may be populated gradually.
nude_path = os.path.join(os.path.abspath(args.source_dir),
f"nude-{stem}.blend")
if os.path.exists(nude_path):
any_detailed = False
for req in ("top", "bottom", "feet"):
detailed = []
for e in slots.get(req, []):
tags = e.get("tags", [])
if isinstance(tags, str):
tags = [t.strip() for t in tags.split(";")
if t.strip()]
if "detailed-nude" in tags:
detailed.append(e)
if not detailed:
warn(f"{stem}: nude-{stem}.blend has no "
f"detailed-nude {req} catalog entry (slot falls "
f"back to the composition base)", warnings)
continue
any_detailed = True
if len(detailed) > 1:
fail(f"{stem}: multiple detailed-nude {req} entries: "
+ ", ".join(e.get("mesh", "?")
for e in detailed), errors)
base_keys = set()
for e in slots.get(req, []):
tags = e.get("tags", [])
if isinstance(tags, str):
tags = [t.strip() for t in tags.split(";")
if t.strip()]
if int(e.get("layer", 0)) == 0 and \
"detailed-nude" not in tags:
base_keys.update(e.get("shape_keys", []))
for e in detailed:
mesh = e.get("mesh", "?")
if int(e.get("layer", 0)) != 0:
fail(f"{stem}: detailed-nude {req} is not layer 0: "
f"{mesh}", errors)
if e.get("garments"):
fail(f"{stem}: detailed-nude {req} carries "
f"garments: {mesh}", errors)
if age_profiles.normalize_age(
e.get("age", "")) != stem_age:
fail(f"{stem}: detailed-nude {req} age mismatch: "
f"{mesh}", errors)
if str(e.get("sex", "")) != stem_sex:
fail(f"{stem}: detailed-nude {req} sex mismatch: "
f"{mesh}", errors)
missing_keys = base_keys - set(
e.get("shape_keys", []))
if missing_keys:
warn(f"{stem}: detailed-nude {req} is missing "
f"shape keys {sorted(missing_keys)}: {mesh}",
warnings)
if not any_detailed:
fail(f"{stem}: nude-{stem}.blend exists but the built "
f"catalog has no detailed-nude entries "
f"(re-run CMake and the character target)", errors)
skel = os.path.join(d, stem + ".skeleton")
if not os.path.exists(skel):
fail(f"{stem}: missing body skeleton {stem}.skeleton", errors)