Phase 8 checkpoint
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user