Compare commits

...

1 Commits

Author SHA1 Message Date
slapin ea281bf913 Character ages and Phase 8 checkpoint (interiors) 2026-10-31 15:51:42 +03:00
117 changed files with 13301 additions and 432 deletions
+22
View File
@@ -131,8 +131,24 @@ cmake --build build --target demoLuaSceneScript # Demo: game-mode inline scene/p
cmake --build build --target demoSceneSwitchingExtra # Demo: scene switching with CellGrid interior
cmake --build build --target demoSokoban # Demo: forklift sokoban activity (vehicle + crates + quest HUD)
cmake --build build --target demoTaxi # Demo: taxi driver (procedural town, sedans, NPC traffic, fare chain)
cmake --build build --target characters-all # All adult/baby/child/teenager/elder character assets
cmake --build build --target character-assets-check # Validate age catalogs/LODs/resource tree
```
### Parallel asset builds
Blender 3.6 batch jobs are not safe to run concurrently (shared user
config/addon state and shared build files). Every Blender invocation in
the CMake pipeline goes through `${BLENDER_LOCKED}`, which is
`flock -x ${BLENDER_LOCK} <blender>`, so `cmake --build build -jN` is
safe: only one Blender job runs at a time while CMake copies, compiles
and validates in parallel. `flock` must be installed (configure fails
otherwise). The default lock is per build tree
(`<build-dir>/blender-assets.lock`); if two build trees are built at the
same time, configure them with the same lock file, for example
`-DBLENDER_LOCK=/tmp/world2-blender-assets.lock`. New custom commands
must use `${BLENDER_LOCKED}`, never `${BLENDER}` directly.
### Build Outputs
- `build/Game` - Main game executable (legacy)
- `build/Editor` - World editor executable (legacy)
@@ -375,6 +391,12 @@ JPH::BodyID body = physics->createBody(shape, 1.0f, position, rotation,
`<name>.vehicle.json` scene entity (contract:
`assets/blender/vehicles/CAR_MODELING.md`)
- `import_vrm.py` - VRM character import
- `characters/generate_age_blends.py` + `characters/agegen/` - one-shot
adult → age-band seed converter (rig scaling, mesh/shape-key
deformation, garment/hair conversion, animation retargeting; contract:
`assets/blender/characters/AGES-PLAN.md`)
- `characters/validate_age_assets.py` - structural validation of the
generated age catalogs/LODs/resource tree
3. **Build**: CMake processes assets during build
4. **Runtime**: Assets loaded from `resources.cfg` paths
+16 -3
View File
@@ -4,6 +4,19 @@ project(world2)
set(CMAKE_CXX_STANDARD 17)
enable_testing()
set(BLENDER "${CMAKE_SOURCE_DIR}/../../blender-bin/bin/blender" CACHE STRING "Blender path")
# Blender 3.6 batch jobs are not safe to run concurrently: they share
# user config/addon state and the asset scripts read/write the same
# build-tree files. Every Blender invocation must go through
# ${BLENDER_LOCKED} so only one runs at a time. CMake can still run
# copies, converters, Python validation and C++ compilation in parallel.
find_program(FLOCK_EXECUTABLE flock)
if(NOT FLOCK_EXECUTABLE)
message(FATAL_ERROR
"flock (util-linux) is required to serialize Blender asset jobs")
endif()
set(BLENDER_LOCK "${CMAKE_BINARY_DIR}/blender-assets.lock" CACHE STRING
"Advisory lock for Blender jobs; share across concurrently-built trees")
set(BLENDER_LOCKED "${FLOCK_EXECUTABLE}" -x "${BLENDER_LOCK}" "${BLENDER}")
set(CREATE_DIRECTORIES
${CMAKE_BINARY_DIR}/assets/blender/shapes/male
${CMAKE_BINARY_DIR}/assets/blender/shapes/female
@@ -128,7 +141,7 @@ foreach(BUILDING_FILE ${BUILDINGS_SRC})
add_custom_command(
OUTPUT ${BUILDING_OUTPUT_FILE}
COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_BINARY_DIR}/resources/buildings
COMMAND ${BLENDER} ${BUILDING_FILE}
COMMAND ${BLENDER_LOCKED} ${BUILDING_FILE}
-b -Y -P
${CMAKE_SOURCE_DIR}/assets/blender/scripts/export_buildings.py
-- ${BUILDING_OUTPUT_FILE}
@@ -148,7 +161,7 @@ foreach(CHARACTER_SHAPE_FILE ${CHARACTER_SHAPES_SRC})
OUTPUT ${CHARACTER_SHAPE_OUTPUT_FILE}
COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_BINARY_DIR}/assets/blender
COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_BINARY_DIR}/characters/shapes/male/${FILE_NAME}
COMMAND ${BLENDER} ${CMAKE_SOURCE_DIR}/assets/blender/${CHARACTER_SHAPE_FILE}
COMMAND ${BLENDER_LOCKED} ${CMAKE_SOURCE_DIR}/assets/blender/${CHARACTER_SHAPE_FILE}
-b -Y -P
${CMAKE_SOURCE_DIR}/assets/blender/scripts/export_characters_ogre.py
-- ${CHARACTER_SHAPE_OUTPUT_FILE}
@@ -161,7 +174,7 @@ add_custom_target(import_character_shapes ALL DEPENDS ${CHARACTER_SHAPES_OUTPUT_
set(WATER_STUFF)
add_custom_command(OUTPUT ${CMAKE_BINARY_DIR}/water/sea.glb
COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_BINARY_DIR}/water
COMMAND ${BLENDER} ${CMAKE_SOURCE_DIR}/assets/blender/sea.blend
COMMAND ${BLENDER_LOCKED} ${CMAKE_SOURCE_DIR}/assets/blender/sea.blend
-b -Y -P
${CMAKE_SOURCE_DIR}/assets/blender/scripts/export_buildings.py
-- ${CMAKE_BINARY_DIR}/water/sea.glb
@@ -9,7 +9,7 @@ foreach(PARTS_FILE ${PARTS_FILES})
add_custom_command(
OUTPUT ${PARTS_OUTPUT_DIR}
COMMAND ${CMAKE_COMMAND} -E make_directory ${PARTS_OUTPUT_DIR}
COMMAND ${BLENDER} ${CMAKE_CURRENT_SOURCE_DIR}/${PARTS_FILE}
COMMAND ${BLENDER_LOCKED} ${CMAKE_CURRENT_SOURCE_DIR}/${PARTS_FILE}
-b -Y -P
${CMAKE_SOURCE_DIR}/assets/blender/scripts/export_building_parts.py
-- ${PARTS_OUTPUT_DIR}
@@ -31,7 +31,7 @@ foreach(FURNITURE_FILE ${FURNITURE_FILES})
COMMAND ${CMAKE_COMMAND} -E copy
${CMAKE_CURRENT_SOURCE_DIR}/${FURNITURE_FILE}
${CMAKE_CURRENT_BINARY_DIR}/${FURNITURE_FILE}
COMMAND ${BLENDER} -b ${CMAKE_CURRENT_BINARY_DIR}/${FURNITURE_FILE}
COMMAND ${BLENDER_LOCKED} -b ${CMAKE_CURRENT_BINARY_DIR}/${FURNITURE_FILE}
-Y -P ${CMAKE_CURRENT_SOURCE_DIR}/bake_furniture.py
-- ${ATLAS_PART} ${FURNITURE_INDEX} ${FURNITURE_COUNT}
${BAKE_SUCCESS}
@@ -53,7 +53,7 @@ add_custom_command(
OUTPUT ${FURNITURE_ATLAS_PNG} ${FURNITURE_ATLAS_MATERIAL}
COMMAND ${CMAKE_COMMAND} -E rm -f
${FURNITURE_ATLAS_PNG} ${FURNITURE_ATLAS_MATERIAL}
COMMAND ${BLENDER} --factory-startup -b -P
COMMAND ${BLENDER_LOCKED} --factory-startup -b -P
${CMAKE_CURRENT_SOURCE_DIR}/merge_furniture_atlases.py --
${FURNITURE_ATLAS_PNG} ${FURNITURE_ATLAS_MATERIAL}
${FURNITURE_ATLAS_PARTS}
@@ -75,7 +75,7 @@ foreach(FURNITURE_FILE ${FURNITURE_FILES})
COMMAND ${CMAKE_COMMAND} -E rm -rf ${PARTS_OUTPUT_DIR}
COMMAND ${CMAKE_COMMAND} -E rm -f ${EXPORT_SUCCESS}
COMMAND ${CMAKE_COMMAND} -E make_directory ${PARTS_OUTPUT_DIR}
COMMAND ${BLENDER} -b ${BAKED_BLEND}
COMMAND ${BLENDER_LOCKED} -b ${BAKED_BLEND}
-Y -P
${CMAKE_CURRENT_SOURCE_DIR}/export_furniture_parts.py
-- ${PARTS_OUTPUT_DIR} ${EXPORT_SUCCESS}
@@ -117,12 +117,12 @@ set(FURNITURE_GALLERY_DIR
${CMAKE_BINARY_DIR}/resources/buildings/parts/gallery)
add_custom_target(furniture_gallery
COMMAND ${CMAKE_COMMAND} -E make_directory ${FURNITURE_GALLERY_DIR}
COMMAND ${BLENDER} -b
COMMAND ${BLENDER_LOCKED} -b
${CMAKE_CURRENT_BINARY_DIR}/furniture_baked.blend -Y -P
${CMAKE_CURRENT_SOURCE_DIR}/render_furniture_gallery.py --
${FURNITURE_GALLERY_DIR}
${CMAKE_BINARY_DIR}/resources/buildings/parts/furniture
COMMAND ${BLENDER} -b
COMMAND ${BLENDER_LOCKED} -b
${CMAKE_CURRENT_BINARY_DIR}/furniture-sofa_baked.blend -Y -P
${CMAKE_CURRENT_SOURCE_DIR}/render_furniture_gallery.py --
${FURNITURE_GALLERY_DIR}
@@ -0,0 +1,207 @@
{
"profileHash": "dd6901d4220ae704",
"stems": {
"baby-female": {
"age": "baby",
"animationSource": "/media/slapin/library/ogre3/ogre-projects/world2/build-vscode/assets/blender/vrm-vroid-normal-female.blend",
"animationSourceHash": "6b258336b456f84f42536bef39e1902bca50cc2cb45aa7dbaa0550c83365f92d",
"outputHashes": {
"clothes-baby-female-bottom.blend": "25b6b8b5230a5e9d91e76b733ee96856ecea873a9bb118ca5905f8d21c6fe16f",
"clothes-baby-female-feet.blend": "4477d51b38b4383849bc3871c2ce83056f01874b7f951e89f082c0a287b457d9",
"clothes-baby-female-hair.blend": "77397a68bb1d1aef4d92a1a218390cc8ad1e453bf82fc7e3ba2f1d0d0adc6dc2",
"clothes-baby-female-top.blend": "a47bc879db6791056b4646b4428d2017d531b4368b4c245898310e2997c717e7",
"edited-normal-baby-female.blend": "0afd20804330a1179a7447c7d4f0bbeed90d5c669bfe82c771a1f6bc737e3dd7"
},
"profileHash": "dd6901d4220ae704",
"report": "conversion-report-baby-female.md",
"reportJson": "conversion-report-baby-female.json",
"sex": "female",
"sourceHashes": {
"clothes-female-bottom.blend": "c7a5cff31917b176c53aab88d9b22b816ad684ff906d1af856e146a4e3167beb",
"clothes-female-feet.blend": "f07ea8aa5145137297b3b556a63e93ba8effa87fb123abb99754925553e0ace1",
"clothes-female-hair.blend": "d0aa11db762f9f6974949e4cc579f66c211d7fc913def2c77b71a0ad9199f7bc",
"clothes-female-top.blend": "0115190c186d740ac54527dbba944e7b2f0f448f4fa6bff89f3aaba2a02d1439",
"edited-normal-female.blend": "b62d4160656b57752cb97fc81c517007c689ad93c510ef2ebc869ac410d13b51"
},
"state": "auto-seed",
"updated": 1793419320
},
"baby-male": {
"age": "baby",
"animationSource": "/media/slapin/library/ogre3/ogre-projects/world2/build-vscode/assets/blender/vrm-vroid-normal-male.blend",
"animationSourceHash": "a495ad4c4f73bf9971c7f180facbca29d117e002b258380ac946274c5e7b9fee",
"outputHashes": {
"clothes-baby-male-bottom.blend": "648803be203afe6e1a01553c8f9373a1e136881eb8a0edd1bfe2f837e9bced1c",
"clothes-baby-male-feet.blend": "8d3eb2c8c9053c953895508f2de03b6e3abbd23ceb53ebb2da2025b0be3a0a97",
"clothes-baby-male-hair.blend": "3e74c20b81b9cb19b4d79a862a5793e7d06726ee9ea8de6d0daeb269b3d4bd94",
"clothes-baby-male-top.blend": "124fd1ad4f22f9efc1c3061914a8a74ea1e328af76d064d9080dc171bf3e0ed3",
"edited-normal-baby-male.blend": "68537022ec4087743d636336b00bf33be3ed2028d0006e4b0ce2847350ba8737"
},
"profileHash": "dd6901d4220ae704",
"report": "conversion-report-baby-male.md",
"reportJson": "conversion-report-baby-male.json",
"sex": "male",
"sourceHashes": {
"clothes-male-bottom.blend": "950298780654bd908c15045d8afe572de755ddb1aa38e548c1dfb912b1cc059c",
"clothes-male-feet.blend": "f720ce5cd154ae967673dbc2c6a93a25640dad56760586d205a18ec7b550e39c",
"clothes-male-hair.blend": "2b267783d2f0dc991c7edd972b6c21b344abf3b5a46737c664912feae0ca9549",
"clothes-male-top.blend": "4ff80187a33acc2a4f6228dd6492b84aee342b22a18c68d2f4c9be9fb15ec9cf",
"edited-normal-male.blend": "4cba5e5da1e22eeedd6962d24edb1ef6ecf56c9b719c2f623ee3983c235560ff"
},
"state": "auto-seed",
"updated": 1793419311
},
"child-female": {
"age": "child",
"animationSource": "/media/slapin/library/ogre3/ogre-projects/world2/build-vscode/assets/blender/vrm-vroid-normal-female.blend",
"animationSourceHash": "6b258336b456f84f42536bef39e1902bca50cc2cb45aa7dbaa0550c83365f92d",
"outputHashes": {
"clothes-child-female-bottom.blend": "924f20a2edbb56e8e41376fcd8ad58e6c3533707fc8cf7750451ed35e811d853",
"clothes-child-female-feet.blend": "900a21c055e9464030e0248941099acb6838dce72c9b4bef72df679427589167",
"clothes-child-female-hair.blend": "3f1766cdcf6102fafe030b99daa2ae5a78023bc9c2f6146d8a4924faa2a3cd57",
"clothes-child-female-top.blend": "3950729a7079297aff7aa2d4be255813eaf69348dee4688a61c470a2493a6f05",
"edited-normal-child-female.blend": "f8f22f31a68b40088fefc1a037b4a9622aafad2b952d07f1ab85d45876bf9882"
},
"profileHash": "dd6901d4220ae704",
"report": "conversion-report-child-female.md",
"reportJson": "conversion-report-child-female.json",
"sex": "female",
"sourceHashes": {
"clothes-female-bottom.blend": "c7a5cff31917b176c53aab88d9b22b816ad684ff906d1af856e146a4e3167beb",
"clothes-female-feet.blend": "f07ea8aa5145137297b3b556a63e93ba8effa87fb123abb99754925553e0ace1",
"clothes-female-hair.blend": "d0aa11db762f9f6974949e4cc579f66c211d7fc913def2c77b71a0ad9199f7bc",
"clothes-female-top.blend": "0115190c186d740ac54527dbba944e7b2f0f448f4fa6bff89f3aaba2a02d1439",
"edited-normal-female.blend": "b62d4160656b57752cb97fc81c517007c689ad93c510ef2ebc869ac410d13b51"
},
"state": "auto-seed",
"updated": 1793419306
},
"child-male": {
"age": "child",
"animationSource": "/media/slapin/library/ogre3/ogre-projects/world2/build-vscode/assets/blender/vrm-vroid-normal-male.blend",
"animationSourceHash": "a495ad4c4f73bf9971c7f180facbca29d117e002b258380ac946274c5e7b9fee",
"outputHashes": {
"clothes-child-male-bottom.blend": "631f0ae0796b29797716be04c0cc8940693a40f673a97d5f6fd219ea541302c3",
"clothes-child-male-feet.blend": "2bed6d24379ec2b322884324d8faea6cd0b1ddc6f62f4bbc4f6396f7adff623b",
"clothes-child-male-hair.blend": "6e02f2bbf3f06365d6cde9acbe178e869b417dcb1b163db0dad9e2999395e6bc",
"clothes-child-male-top.blend": "8aef24cab77cb209af0393b5bf059e61d411d77236c27bbbd0b126bf63c9211c",
"edited-normal-child-male.blend": "6d889491af59a9551f4237cd50fba43bdb1b0aeffbf2e6cc0d5e572da9ea6c53"
},
"profileHash": "dd6901d4220ae704",
"report": "conversion-report-child-male.md",
"reportJson": "conversion-report-child-male.json",
"sex": "male",
"sourceHashes": {
"clothes-male-bottom.blend": "950298780654bd908c15045d8afe572de755ddb1aa38e548c1dfb912b1cc059c",
"clothes-male-feet.blend": "f720ce5cd154ae967673dbc2c6a93a25640dad56760586d205a18ec7b550e39c",
"clothes-male-hair.blend": "2b267783d2f0dc991c7edd972b6c21b344abf3b5a46737c664912feae0ca9549",
"clothes-male-top.blend": "4ff80187a33acc2a4f6228dd6492b84aee342b22a18c68d2f4c9be9fb15ec9cf",
"edited-normal-male.blend": "4cba5e5da1e22eeedd6962d24edb1ef6ecf56c9b719c2f623ee3983c235560ff"
},
"state": "auto-seed",
"updated": 1793419297
},
"elder-female": {
"age": "elder",
"animationSource": "/media/slapin/library/ogre3/ogre-projects/world2/build-vscode/assets/blender/vrm-vroid-normal-female.blend",
"animationSourceHash": "6b258336b456f84f42536bef39e1902bca50cc2cb45aa7dbaa0550c83365f92d",
"outputHashes": {
"clothes-elder-female-bottom.blend": "8b9c1be20d83b46ee33397283625e03b2e147c3e35eea5b9eb3b47696f4bea66",
"clothes-elder-female-feet.blend": "69c1e995860e5678560cfe76e1349828e49d230d7ee3b4167767bb6708325da1",
"clothes-elder-female-hair.blend": "fced301a4709adf9e8a05a17583b8879b0a9f154bd532ed567df9ce924f2859b",
"clothes-elder-female-top.blend": "f783777345dfeb83811adc349a4b4f7c21bd5f3085f13c2b218637298f184bad",
"edited-normal-elder-female.blend": "570ff96ebfa9005a3c21755603b9a4942ab8336461c7af74bdd970ca1871e9c2"
},
"profileHash": "dd6901d4220ae704",
"report": "conversion-report-elder-female.md",
"reportJson": "conversion-report-elder-female.json",
"sex": "female",
"sourceHashes": {
"clothes-female-bottom.blend": "c7a5cff31917b176c53aab88d9b22b816ad684ff906d1af856e146a4e3167beb",
"clothes-female-feet.blend": "f07ea8aa5145137297b3b556a63e93ba8effa87fb123abb99754925553e0ace1",
"clothes-female-hair.blend": "d0aa11db762f9f6974949e4cc579f66c211d7fc913def2c77b71a0ad9199f7bc",
"clothes-female-top.blend": "0115190c186d740ac54527dbba944e7b2f0f448f4fa6bff89f3aaba2a02d1439",
"edited-normal-female.blend": "b62d4160656b57752cb97fc81c517007c689ad93c510ef2ebc869ac410d13b51"
},
"state": "auto-seed",
"updated": 1793419277
},
"elder-male": {
"age": "elder",
"animationSource": "/media/slapin/library/ogre3/ogre-projects/world2/build-vscode/assets/blender/vrm-vroid-normal-male.blend",
"animationSourceHash": "a495ad4c4f73bf9971c7f180facbca29d117e002b258380ac946274c5e7b9fee",
"outputHashes": {
"clothes-elder-male-bottom.blend": "1e5dcf7d41f8f3fe5c3716342a2f9d7163558492f8e4ecab4e37e7aaa0b6d6aa",
"clothes-elder-male-feet.blend": "60af548ee9845a29df7021f92378032d961c28108503f39f9a97640bf80f1024",
"clothes-elder-male-hair.blend": "f62f93f4464b41d1b27ed89a39d660e3aa1cc6ee1abfbe52c9e5620ffd838616",
"clothes-elder-male-top.blend": "a8c422ae1dbcad1f4672578f68c7577b5dff198009968f0689e036090d485e67",
"edited-normal-elder-male.blend": "93554b09a4653357b4b91682e27bdda988421ded9eca60902a530537c18c4d5f"
},
"profileHash": "dd6901d4220ae704",
"report": "conversion-report-elder-male.md",
"reportJson": "conversion-report-elder-male.json",
"sex": "male",
"sourceHashes": {
"clothes-male-bottom.blend": "950298780654bd908c15045d8afe572de755ddb1aa38e548c1dfb912b1cc059c",
"clothes-male-feet.blend": "f720ce5cd154ae967673dbc2c6a93a25640dad56760586d205a18ec7b550e39c",
"clothes-male-hair.blend": "2b267783d2f0dc991c7edd972b6c21b344abf3b5a46737c664912feae0ca9549",
"clothes-male-top.blend": "4ff80187a33acc2a4f6228dd6492b84aee342b22a18c68d2f4c9be9fb15ec9cf",
"edited-normal-male.blend": "4cba5e5da1e22eeedd6962d24edb1ef6ecf56c9b719c2f623ee3983c235560ff"
},
"state": "auto-seed",
"updated": 1793419267
},
"teenager-female": {
"age": "teenager",
"animationSource": "/media/slapin/library/ogre3/ogre-projects/world2/build-vscode/assets/blender/vrm-vroid-normal-female.blend",
"animationSourceHash": "6b258336b456f84f42536bef39e1902bca50cc2cb45aa7dbaa0550c83365f92d",
"outputHashes": {
"clothes-teenager-female-bottom.blend": "77ff890f2edaa894014c88bf1e1047335182d166bc39ac48746c1dac3d374f1a",
"clothes-teenager-female-feet.blend": "b2fed95f17586630e331642f1afa33a6d63ba1ed58758be68a5526082d520809",
"clothes-teenager-female-hair.blend": "0c5a762e74f95bd7b10679fa986133247125e46cc33f9a08dfeeed47f3f7719b",
"clothes-teenager-female-top.blend": "ce617dbb60927fa81a530019bfd0b822743ac57b7dd96d894c5144a2803e3335",
"edited-normal-teenager-female.blend": "1cfb42a0086b0704a1eb9f17ee027ae01ccf4be5664f126aa7d776398b8e3f91"
},
"profileHash": "dd6901d4220ae704",
"report": "conversion-report-teenager-female.md",
"reportJson": "conversion-report-teenager-female.json",
"sex": "female",
"sourceHashes": {
"clothes-female-bottom.blend": "c7a5cff31917b176c53aab88d9b22b816ad684ff906d1af856e146a4e3167beb",
"clothes-female-feet.blend": "f07ea8aa5145137297b3b556a63e93ba8effa87fb123abb99754925553e0ace1",
"clothes-female-hair.blend": "d0aa11db762f9f6974949e4cc579f66c211d7fc913def2c77b71a0ad9199f7bc",
"clothes-female-top.blend": "0115190c186d740ac54527dbba944e7b2f0f448f4fa6bff89f3aaba2a02d1439",
"edited-normal-female.blend": "b62d4160656b57752cb97fc81c517007c689ad93c510ef2ebc869ac410d13b51"
},
"state": "auto-seed",
"updated": 1793419291
},
"teenager-male": {
"age": "teenager",
"animationSource": "/media/slapin/library/ogre3/ogre-projects/world2/build-vscode/assets/blender/vrm-vroid-normal-male.blend",
"animationSourceHash": "a495ad4c4f73bf9971c7f180facbca29d117e002b258380ac946274c5e7b9fee",
"outputHashes": {
"clothes-teenager-male-bottom.blend": "9547a808429d71da27fdb11d58f69a2c18e6ad3f4fe78b5348dd25050133ad37",
"clothes-teenager-male-feet.blend": "33d32f57c79f5a7b8e9c701c5e0aeffdd052a75b92ea7de234128c418e8be332",
"clothes-teenager-male-hair.blend": "fc9c767afe4291c8a4634c2c1fc886c42d0136b905cc2288d3ca363a45957e99",
"clothes-teenager-male-top.blend": "7b14973503c70681acbcaf66d7712a5e0ed9336875e4780d84ec749525624161",
"edited-normal-teenager-male.blend": "d476061b1ee14df740bb052b82a2be5938246c05fcaf2df1dcded73ca1196daa"
},
"profileHash": "dd6901d4220ae704",
"report": "conversion-report-teenager-male.md",
"reportJson": "conversion-report-teenager-male.json",
"sex": "male",
"sourceHashes": {
"clothes-male-bottom.blend": "950298780654bd908c15045d8afe572de755ddb1aa38e548c1dfb912b1cc059c",
"clothes-male-feet.blend": "f720ce5cd154ae967673dbc2c6a93a25640dad56760586d205a18ec7b550e39c",
"clothes-male-hair.blend": "2b267783d2f0dc991c7edd972b6c21b344abf3b5a46737c664912feae0ca9549",
"clothes-male-top.blend": "4ff80187a33acc2a4f6228dd6492b84aee342b22a18c68d2f4c9be9fb15ec9cf",
"edited-normal-male.blend": "4cba5e5da1e22eeedd6962d24edb1ef6ecf56c9b719c2f623ee3983c235560ff"
},
"state": "auto-seed",
"updated": 1793419282
}
},
"updated": 1793419320,
"version": 1
}
File diff suppressed because it is too large Load Diff
+73 -20
View File
@@ -20,37 +20,90 @@ cmake --build build-vscode --target edited-blends # base bodies + animations
cmake --build build-vscode # full pipeline incl. clothes
```
Pipeline per sex (`male` / `female`):
All Blender steps are wrapped in `${BLENDER_LOCKED}` (`flock -x
${BLENDER_LOCK} <blender>`). This makes `-j` builds safe: Blender jobs
are serialized because Blender 3.6 shares user config/addon state and the
scripts touch common build files, while CMake copies/compiles in
parallel. Configure fails if `flock` is missing. The lock defaults to
`<build-dir>/blender-assets.lock`; pass the same `-DBLENDER_LOCK=...` to
concurrent build trees. Never call `${BLENDER}` directly in a custom
command; use `${BLENDER_LOCKED}`.
1. `edited-normal-<sex>.blend` is the base body. `copy_animations.py`
copies the Mixamo animation library (`assets/blender/mixamo/`) into
it — idle, walking, running, swimming, treading_water, sitting,
sitting-ground, sitting-vehicle, ...
2. Each `clothes-<sex>-<slot>.blend` holds garment meshes for one slot.
Canonical age bands are `baby`, `child`, `teenager`, `adult` and `elder`.
Adult keeps the legacy stem `male`/`female`; every new band uses
`<age>-<sex>` (for example `child-male`, `elder-female`). The new base
bodies and their clothes are generated once from the adult assets by
`generate_age_blends.py` + the `agegen/` package (rest-pose rig scaling,
weighted mesh/shape-key deformation, garment/hair conversion and animation
retargeting). The generated `.blend` files are committed seeds; the normal
build consumes them as-is and never re-runs the generator or retargeting.
See `AGES-PLAN.md` for the conversion/hand-off contract and
`generate_age_blends.py --check` for seed verification.
Pipeline per stem (`male`, `female`, `baby-male`, `child-female`, ...):
1. `edited-normal-<stem>.blend` is the base body. For **adult**,
`copy_animations.py` copies the Mixamo animation library
(`assets/blender/mixamo/`) into it. New age seeds already contain
retargeted actions and NLA tracks.
2. Each `clothes-<age>-<sex>-<slot>.blend` (adult:
`clothes-<sex>-<slot>.blend`) holds garment meshes for one slot.
`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-<sex>-consolidated.blend`.
`edited-normal-<stem>-consolidated.blend` and leaves exactly one body
armature.
4. `export_models2.py` exports the consolidated blend through
blender2ogre: one `.mesh` per catalog mesh object, the shared
`.skeleton`, `.material` files, the texture atlases
(`clothes-atlas.png`, `clothes-atlas-normal.png`, `face-atlas.png`),
`.skeleton`, `.material` files, the prefixed texture atlases,
a `.glb` preview, and one **`body_part_<mesh data name>.json`
catalog entry per mesh object** carrying the required custom
properties (see §2).
Outputs land in `build-vscode/characters/<sex>/` and are staged into
Outputs land in `build-vscode/characters/<stem>/` and are staged into
the editScene runtime `characters/` directory (OGRE resource group
`Characters`). The engine discovers catalog entries by scanning every
`Characters`; every stem directory is registered as a flat resource
location). The engine discovers catalog entries by scanning every
resource group for `body_part_*.json` — no manual registration step.
All resource basenames in the tree must be unique; textures are prefixed
with `<stem>-` during export.
**blender2ogre names `.mesh` files after the mesh DATA name** (not the
object name) — set both, and never reuse another blend's mesh data
names: everything exports into the same directory and silently
overwrites.
## 2. Catalog entry contract (custom properties on the MESH OBJECT)
## 1.1 Age seeds (baby / child / teenager / elder)
`generate_age_blends.py` (with the `agegen/` package) creates first-pass
seeds from the same-sex adult base and the adult wardrobe:
- the age rig keeps every adult bone name, hierarchy, roll and rest
orientation; joints/segment lengths and radial thickness are scaled by
`age_profiles.py`;
- body, face, hair, clothes, helpers and shape keys are deformed by the
weighted rest-pose matrices `D_i = F_age · S_i · F_adult⁻¹`;
- animations are copied from the Mixamo library and their location/root
tracks are scaled by the bone rest-offset ratios; rotations are untouched;
- the result is written as `edited-normal-<age>-<sex>.blend` plus
`clothes-<age>-<sex>-{top,bottom,feet,hair}.blend`, a conversion report
and `AGE-GENERATION.json`.
Generated seeds are committed and marked `agegen_state = auto-seed`. The
normal build must not run the generator or the retarget step again; the
retarget step would double-scale root motion. Seeds with
`agegen_state = hand-edited` (or a changed file hash) are protected from
regeneration; use `--force` only when intentionally replacing them.
`generate_age_blends.py --check` verifies the seed hashes and
`validate_age_assets.py` verifies the built catalog/skeleton/resource tree.
The automatic seed is a **complete editable base**, not final art. Baby and
child faces/silhouettes are expected to need a manual detail pass; the
conversion report lists the first objects to inspect (stretch, seam,
penetration, weight and animation issues).
`export_models2.py` writes a catalog JSON for every mesh object that
carries all three required custom properties (Properties panel ->
@@ -58,7 +111,7 @@ Object -> Custom Properties):
| Property | Type | Meaning |
|----------|------|---------|
| `age` | string | age band, currently only `adult` exists |
| `age` | string | canonical band: `baby` / `child` / `teenager` / `adult` / `elder` (aliases `infant`, `teen`, `senior` normalize on load) |
| `sex` | string | `male` / `female` |
| `slot` | string | `top` / `bottom` / `feet` / `hair` / `face` |
| `layer` | int | 0 = nude base, 1 = underwear (and hair), 2 = clothes (default 0) |
@@ -231,12 +284,12 @@ hysteresis on every boundary):
- **LOD0 (< 10 m)**: modular, full detail — the normal catalog parts.
- **LOD1 (10-30 m)**: per-part `<mesh base>_lod1.mesh` variants — same
geometry, shape keys removed, UVs remapped into one shared 2048x2048
atlas (`charlod_<sex>-atlas.png`) with a single `charlod_<sex>`
material per sex. Hair slots are skipped (hidden/impostor-baked at
atlas (`charlod_<stem>-atlas.png`) with a single `charlod_<stem>`
material per stem. Hair slots are skipped (hidden/impostor-baked at
distance anyway). Parts without a `_lod1` variant keep their base
mesh, so the scheme degrades gracefully.
- **LOD2 (30-70 m)**: whole-character impostors
`charlod_<sex>_thin_lod2.mesh` / `charlod_<sex>_fat_lod2.mesh` — the
`charlod_<stem>_thin_lod2.mesh` / `charlod_<stem>_fat_lod2.mesh` — the
deterministic default outfit (per slot: highest layer, first entry by
name) joined into ONE mesh on the shared armature, `fat` shape key
baked at 0/1, hair rigid-bound to `mixamorig:Head`, atlas material.
@@ -250,16 +303,16 @@ hysteresis on every boundary):
All part entities are hidden; the master entity keeps animating the
shared skeleton and the impostor follows it via
`shareSkeletonInstanceWith`. Hair physics (ragdoll) is OFF.
- **LOD3 (70-100 m, till despawn)**: `charlod_<sex>_<variant>_lod3.mesh` —
- **LOD3 (70-100 m, till despawn)**: `charlod_<stem>_<variant>_lod3.mesh` —
collapse-decimated LOD2, ~400 triangles (hard limit 400).
- Interiors need only LOD0/LOD1.
Generation is automated in the export pipeline:
`assets/blender/characters/generate_lods.py` runs per sex on the
consolidated blend (CMake step `edited-normal-<sex>-lods.blend`),
`assets/blender/characters/generate_lods.py` runs per stem on the
consolidated blend (CMake step `edited-normal-<stem>-lods.blend`),
`export_models2.py` appends the result via its 6th argument and exports
the LOD meshes with the SAME shared armature. A manifest
(`charlod_<sex>.json`: atlas file + impostor mesh names) is written for
(`charlod_<stem>.json`: atlas file + impostor mesh names) is written for
reference; the runtime uses name conventions + `resourceExists`, not
the manifest. `CharacterSlotSystem::prewarmShaders` also warms the
`*_lod1.mesh` / `charlod_*.mesh` shaders.
+209 -152
View File
@@ -1,30 +1,76 @@
project(characters)
set(EDITED_BLENDS_LIST
male
female
)
set(CHARACTER_AGES adult baby child teenager elder)
set(CHARACTER_SEXES male female)
set(CHARACTER_BUILD_AGES ${CHARACTER_AGES} CACHE STRING
"Age bands to build (semicolon list; e.g. adult or adult;elder)")
# Adult keeps the legacy stem (male/female) and all existing resource names.
function(character_stem AGE SEX OUT)
if(AGE STREQUAL "adult")
set(${OUT} ${SEX} PARENT_SCOPE)
else()
set(${OUT} "${AGE}-${SEX}" PARENT_SCOPE)
endif()
endfunction()
function(character_build_blend AGE SEX OUT)
character_stem(${AGE} ${SEX} STEM)
set(${OUT}
"${CMAKE_CURRENT_BINARY_DIR}/edited-normal-${STEM}.blend"
PARENT_SCOPE)
endfunction()
function(character_source_blend AGE SEX OUT)
character_stem(${AGE} ${SEX} STEM)
set(${OUT}
"${CMAKE_CURRENT_SOURCE_DIR}/edited-normal-${STEM}.blend"
PARENT_SCOPE)
endfunction()
function(character_glb AGE SEX OUT)
character_stem(${AGE} ${SEX} STEM)
set(${OUT} "${CMAKE_BINARY_DIR}/characters/${STEM}/normal-${STEM}.glb"
PARENT_SCOPE)
endfunction()
set(EDITED_BLEND_TARGETS)
set(CHARACTER_GLBS)
foreach(EDITED_BLEND ${EDITED_BLENDS_LIST})
add_custom_command(
OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/edited-normal-${EDITED_BLEND}.blend
DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/edited-normal-${EDITED_BLEND}.blend
${CMAKE_BINARY_DIR}/assets/blender/vrm-vroid-normal-${EDITED_BLEND}.blend
${CMAKE_CURRENT_SOURCE_DIR}/copy_animations.py
${CMAKE_BINARY_DIR}/assets/blender/mixamo
${CMAKE_CURRENT_BINARY_DIR}/blender-addons-installed
COMMAND ${CMAKE_COMMAND}
-E copy ${CMAKE_CURRENT_SOURCE_DIR}/edited-normal-${EDITED_BLEND}.blend
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-${EDITED_BLEND}.blend
COMMAND ${BLENDER} -b -Y
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-${EDITED_BLEND}.blend
-P ${CMAKE_CURRENT_SOURCE_DIR}/copy_animations.py --
${CMAKE_BINARY_DIR}/assets/blender/vrm-vroid-normal-${EDITED_BLEND}.blend ${EDITED_BLEND}
WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
)
list(APPEND EDITED_BLEND_TARGETS ${CMAKE_BINARY_DIR}/assets/blender/characters/edited-normal-${EDITED_BLEND}.blend)
list(APPEND CHARACTER_GLBS ${CMAKE_BINARY_DIR}/characters/${EDITED_BLEND}/normal-${EDITED_BLEND}.glb)
foreach(AGE ${CHARACTER_BUILD_AGES})
foreach(SEX ${CHARACTER_SEXES})
character_stem(${AGE} ${SEX} STEM)
character_source_blend(${AGE} ${SEX} BASE_SOURCE)
character_build_blend(${AGE} ${SEX} BASE_BUILD)
set(BASE_DEPS ${BASE_SOURCE} ${CMAKE_CURRENT_BINARY_DIR}/blender-addons-installed)
set(BASE_COMMANDS
COMMAND ${CMAKE_COMMAND} -E copy ${BASE_SOURCE} ${BASE_BUILD})
if(AGE STREQUAL "adult")
# Adult keeps the historical animation-copy step.
list(APPEND BASE_DEPS
${CMAKE_BINARY_DIR}/assets/blender/vrm-vroid-normal-${SEX}.blend
${CMAKE_CURRENT_SOURCE_DIR}/copy_animations.py
${CMAKE_BINARY_DIR}/assets/blender/mixamo)
list(APPEND BASE_COMMANDS
COMMAND ${BLENDER_LOCKED} -b -Y ${BASE_BUILD}
-P ${CMAKE_CURRENT_SOURCE_DIR}/copy_animations.py --
${CMAKE_BINARY_DIR}/assets/blender/vrm-vroid-normal-${SEX}.blend ${SEX})
else()
# Generated seeds already contain retargeted actions + NLA
# tracks. Never retarget here (double scaling).
list(APPEND BASE_DEPS
${CMAKE_CURRENT_SOURCE_DIR}/generate_age_blends.py)
endif()
add_custom_command(
OUTPUT ${BASE_BUILD}
DEPENDS ${BASE_DEPS}
${BASE_COMMANDS}
WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
)
list(APPEND EDITED_BLEND_TARGETS ${BASE_BUILD})
character_glb(${AGE} ${SEX} GLB)
list(APPEND CHARACTER_GLBS ${GLB})
endforeach()
endforeach()
list(APPEND CHARACTER_GLBS ${CMAKE_BINARY_DIR}/characters/male/male-clothes-toprobe.glb)
add_custom_command(OUTPUT ${CMAKE_BINARY_DIR}/assets/blender/mixamo
@@ -41,61 +87,19 @@ set(VRM_IMPORTED_BLENDS
#add_custom_command(
# OUTPUT ${CHARACTER_GLBS}
# COMMAND ${CMAKE_COMMAND} -E make_directory ${CREATE_DIRECTORIES}
# COMMAND ${BLENDER} -b -Y -P ${CMAKE_SOURCE_DIR}/assets/blender/scripts/export_models.py
# COMMAND ${BLENDER_LOCKED} -b -Y -P ${CMAKE_SOURCE_DIR}/assets/blender/scripts/export_models.py
# COMMAND ${CMAKE_COMMAND} -D FILE=${CMAKE_BINARY_DIR}/characters/male/normal-male.glb -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
# COMMAND ${CMAKE_COMMAND} -D FILE=${CMAKE_BINARY_DIR}/characters/female/normal-female.glb -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
# COMMAND ${CMAKE_COMMAND} -E touch_nocreate ${CHARACTER_GLBS}
# DEPENDS ${CMAKE_SOURCE_DIR}/assets/blender/scripts/export_models.py ${VRM_IMPORTED_BLENDS} ${EDITED_BLEND_TARGETS}
# WORKING_DIRECTORY ${CMAKE_BINARY_DIR})
add_custom_command(
OUTPUT ${CMAKE_BINARY_DIR}/characters/male/normal-male.glb
COMMAND ${CMAKE_COMMAND} -E make_directory ${CREATE_DIRECTORIES}
COMMAND ${BLENDER} -b -Y -P ${CMAKE_SOURCE_DIR}/assets/blender/scripts/export_models2.py --
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-consolidated.blend
${CMAKE_BINARY_DIR}/characters/male/normal-male.glb
"AUTO"
"male"
tmp-edited-male.blend
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-lods.blend
COMMAND ${CMAKE_COMMAND} -D FILE=${CMAKE_BINARY_DIR}/characters/male/normal-male.glb
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
COMMAND ${CMAKE_COMMAND} -D FILE=${CMAKE_BINARY_DIR}/characters/male/normal-male.scene
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
DEPENDS ${CMAKE_SOURCE_DIR}/assets/blender/scripts/export_models2.py
${VRM_IMPORTED_BLENDS}
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-consolidated.blend
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-lods.blend
# ${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-consolidated-hair.stamp
${CMAKE_CURRENT_BINARY_DIR}/blender-addons-installed
WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
VERBATIM
)
# Age/sex export commands are generated after the clothes/LOD functions are
# defined (see the bottom of this file). The top-level export loop covers
# adult and all generated age seeds.
add_custom_command(
OUTPUT ${CMAKE_BINARY_DIR}/characters/female/normal-female.glb
COMMAND ${CMAKE_COMMAND} -E make_directory ${CREATE_DIRECTORIES}
COMMAND ${BLENDER} -b -Y -P ${CMAKE_SOURCE_DIR}/assets/blender/scripts/export_models2.py --
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-female-consolidated.blend
${CMAKE_BINARY_DIR}/characters/female/normal-female.glb
"AUTO"
"female"
tmp-edited-female.blend
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-female-lods.blend
COMMAND ${CMAKE_COMMAND} -D FILE=${CMAKE_BINARY_DIR}/characters/female/normal-female.glb
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
COMMAND ${CMAKE_COMMAND} -D FILE=${CMAKE_BINARY_DIR}/characters/female/normal-female.scene
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
DEPENDS ${CMAKE_SOURCE_DIR}/assets/blender/scripts/export_models2.py
${VRM_IMPORTED_BLENDS}
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-female-consolidated.blend
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-female-lods.blend
${CMAKE_CURRENT_BINARY_DIR}/blender-addons-installed
WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
VERBATIM
)
add_custom_command(
OUTPUT ${CMAKE_BINARY_DIR}/characters/male/male-clothes-toprobe.glb
COMMAND ${BLENDER} -b -Y ${CMAKE_CURRENT_SOURCE_DIR}/edited-normal-male.blend
COMMAND ${BLENDER_LOCKED} -b -Y ${CMAKE_CURRENT_SOURCE_DIR}/edited-normal-male.blend
-P ${CMAKE_CURRENT_SOURCE_DIR}/export_clothes.py
-- ${CMAKE_BINARY_DIR}/characters/male/male-clothes-toprobe.glb
BodyTopRobe
@@ -126,7 +130,7 @@ foreach(MIXAMO_FILE ${MIXAMO_FILES})
list(APPEND VRM_SOURCE "${OUTPUT_FILE}")
endforeach()
add_custom_command(OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/blender-addons-installed
COMMAND ${BLENDER} -b -Y -P ${CMAKE_SOURCE_DIR}/assets/blender/scripts/install_addons.py
COMMAND ${BLENDER_LOCKED} -b -Y -P ${CMAKE_SOURCE_DIR}/assets/blender/scripts/install_addons.py
COMMAND ${CMAKE_COMMAND} -E touch ${CMAKE_CURRENT_BINARY_DIR}/blender-addons-installed
DEPENDS ${CMAKE_BINARY_DIR}/assets/blender/scripts/addons/3.6/VRM_Addon_for_Blender-release.zip
${CMAKE_BINARY_DIR}/assets/blender/scripts/addons/3.6/io_ogre.zip
@@ -152,7 +156,7 @@ add_custom_target(install_addons ALL DEPENDS ${CMAKE_CURRENT_BINARY_DIR}/blender
#add_custom_command(OUTPUT ${VRM_IMPORTED_BLENDS}
# COMMAND ${CMAKE_COMMAND} -E make_directory ${CREATE_DIRECTORIES}
# COMMAND ${BLENDER} -b -Y -P ${CMAKE_CURRENT_SOURCE_DIR}/import_vrm.py
# COMMAND ${BLENDER_LOCKED} -b -Y -P ${CMAKE_CURRENT_SOURCE_DIR}/import_vrm.py
# COMMAND ${CMAKE_COMMAND} -E touch_nocreate ${VRM_IMPORTED_BLENDS}
# DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/import_vrm.py
# ${CMAKE_CURRENT_BINARY_DIR}/blender-assons-installed
@@ -164,7 +168,7 @@ function(blender_import_vrm BLEND VRM EDITABLE RIG)
get_filename_component(VRM_NAME ${VRM} NAME_WE)
add_custom_command(OUTPUT ${BLEND}
COMMAND ${CMAKE_COMMAND} -E make_directory ${CREATE_DIRECTORIES}
COMMAND ${BLENDER} -b -Y -P ${CMAKE_SOURCE_DIR}/assets/blender/scripts/import_vrm2.py -- ${VRM_NAME}.vrm ${BLEND} ${EDITABLE} ${RIG}
COMMAND ${BLENDER_LOCKED} -b -Y -P ${CMAKE_SOURCE_DIR}/assets/blender/scripts/import_vrm2.py -- ${VRM_NAME}.vrm ${BLEND} ${EDITABLE} ${RIG}
COMMAND ${CMAKE_COMMAND} -D FILE=${BLEND} -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
COMMAND ${CMAKE_COMMAND} -E touch_nocreate ${BLEND}
DEPENDS ${CMAKE_SOURCE_DIR}/assets/blender/scripts/import_vrm2.py
@@ -186,9 +190,9 @@ blender_import_vrm(${CMAKE_BINARY_DIR}/assets/blender/shapes/male/vrm-vroid-norm
#add_custom_command(OUTPUT ${VRM_IMPORTED_BLENDS}
# COMMAND ${CMAKE_COMMAND} -E make_directory ${CREATE_DIRECTORIES}
# COMMAND ${BLENDER} -b -Y -P ${CMAKE_CURRENT_SOURCE_DIR}/import_vrm2.py jane2-dress.vrm vrm-vroid-normal-female.blend modelling-vroid-normal-female.blend female
# COMMAND ${BLENDER} -b -Y -P ${CMAKE_CURRENT_SOURCE_DIR}/import_vrm2.py buch1.vrm vrm-vroid-normal-male.blend modelling-vroid-normal-male.blend male
# COMMAND ${BLENDER} -b -Y -P ${CMAKE_CURRENT_SOURCE_DIR}/import_vrm2.py buch1-chibi.vrm shapes/male/vrm-vroid-normal-male-chibi.blend modelling-shape-male-chibi.blend male
# COMMAND ${BLENDER_LOCKED} -b -Y -P ${CMAKE_CURRENT_SOURCE_DIR}/import_vrm2.py jane2-dress.vrm vrm-vroid-normal-female.blend modelling-vroid-normal-female.blend female
# COMMAND ${BLENDER_LOCKED} -b -Y -P ${CMAKE_CURRENT_SOURCE_DIR}/import_vrm2.py buch1.vrm vrm-vroid-normal-male.blend modelling-vroid-normal-male.blend male
# COMMAND ${BLENDER_LOCKED} -b -Y -P ${CMAKE_CURRENT_SOURCE_DIR}/import_vrm2.py buch1-chibi.vrm shapes/male/vrm-vroid-normal-male-chibi.blend modelling-shape-male-chibi.blend male
# COMMAND ${CMAKE_COMMAND} -E touch_nocreate ${VRM_IMPORTED_BLENDS}
# DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/import_vrm.py
# ${CMAKE_CURRENT_BINARY_DIR}/blender-assons-installed
@@ -211,31 +215,28 @@ add_custom_command(OUTPUT ${CMAKE_BINARY_DIR}/characters/shapes/male/chibi/vroid
DEPENDS ${CMAKE_CURRENT_BINARY_DIR}/shapes/male/chibi/vroid-normal-male-chibi.glb
COMMAND ${CMAKE_COMMAND} -E copy ${CMAKE_CURRENT_BINARY_DIR}/shapes/male/chibi/vroid-normal-male-chibi.glb
${CMAKE_BINARY_DIR}/characters/shapes/male/chibi/vroid-normal-male-chibi.glb)
add_custom_target(morph ALL DEPENDS MorphTargetsResearch ${CHARACTER_GLBS})
# Targets (morph/edited-blends/import_vrm and per-age characters-*) are
# defined after the age/sex export commands at the bottom of this file.
add_custom_target(edited-blends ALL DEPENDS ${EDITED_BLEND_TARGETS})
add_custom_target(import_vrm DEPENDS ${CHARACTER_GLBS})
function(weight_clothes SRC)
function(weight_clothes SRC BODY)
get_filename_component(TARGET_NAME ${SRC} NAME_WE)
add_custom_command(
OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/clothes/${TARGET_NAME}_weighted.stamp
${CMAKE_CURRENT_BINARY_DIR}/clothes/${TARGET_NAME}_weighted.blend
COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_CURRENT_BINARY_DIR}/clothes
COMMAND ${BLENDER} -b -Y -P ${CMAKE_CURRENT_SOURCE_DIR}/process_clothes.py -- ${CMAKE_CURRENT_SOURCE_DIR}/${TARGET_NAME}.blend ./ ${CMAKE_CURRENT_BINARY_DIR}/clothes
COMMAND ${BLENDER_LOCKED} -b -Y -P ${CMAKE_CURRENT_SOURCE_DIR}/process_clothes.py -- ${SRC} ./ ${CMAKE_CURRENT_BINARY_DIR}/clothes ${BODY}
COMMAND ${CMAKE_COMMAND} -E touch ${CMAKE_CURRENT_BINARY_DIR}/clothes/${TARGET_NAME}_weighted.stamp
COMMAND ${CMAKE_COMMAND} -D FILE=${CMAKE_CURRENT_BINARY_DIR}/clothes/${TARGET_NAME}_weighted.blend
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
DEPENDS ${SRC}
DEPENDS ${SRC} ${BODY}
${CMAKE_CURRENT_BINARY_DIR}/blender-addons-installed
)
endfunction()
function(transfer_shape_keys SRC CLOTH DST)
add_custom_command(
OUTPUT ${DST}
COMMAND ${BLENDER} -b -Y -P ${CMAKE_CURRENT_SOURCE_DIR}/transfer_shape_keys.py -- ${SRC} ${CLOTH} ${DST}
COMMAND ${BLENDER_LOCKED} -b -Y -P ${CMAKE_CURRENT_SOURCE_DIR}/transfer_shape_keys.py -- ${SRC} ${CLOTH} ${DST}
COMMAND ${CMAKE_COMMAND} -D FILE=${DST}
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
DEPENDS ${SRC} ${CLOTH}
@@ -291,7 +292,7 @@ function(add_clothes_combination INPUT_BLEND WEIGHTED_BLEND COMBINED_BLEND)
add_custom_command(
OUTPUT ${STAMP_FILE} ${COMBINED_BLEND}
COMMAND ${CMAKE_COMMAND} -E make_directory ${COMBINED_DIR}
COMMAND ${BLENDER} -b -Y -P ${CMAKE_CURRENT_SOURCE_DIR}/combine_clothes.py --
COMMAND ${BLENDER_LOCKED} -b -Y -P ${CMAKE_CURRENT_SOURCE_DIR}/combine_clothes.py --
${INPUT_BLEND}
${WEIGHTED_BLEND}
${COMBINED_BLEND}
@@ -348,7 +349,7 @@ function(add_blend_consolidation INPUT_BLEND COMBINED_BLEND OUTPUT_BLEND)
${COMBINE_STAMP}
${INPUT_BLEND}
COMMAND ${CMAKE_COMMAND} -E make_directory ${OUTPUT_DIR}
COMMAND ${BLENDER} -b -Y ${INPUT_BLEND}
COMMAND ${BLENDER_LOCKED} -b -Y ${INPUT_BLEND}
-P ${CMAKE_CURRENT_SOURCE_DIR}/consolidate.py --
${COMBINED_BLEND}
${OUTPUT_BLEND}
@@ -425,28 +426,26 @@ function(add_clothes_pipeline INPUT_BLEND WEIGHTED_BLEND FINAL_OUTPUT_BLEND)
)
endfunction()
set(SEX_LIST "male" "female")
foreach(SEX ${SEX_LIST})
set(HAIR_WEIGHTED_STAMP "${CMAKE_CURRENT_BINARY_DIR}/clothes/clothes-${SEX}-hair_weighted.stamp")
add_custom_command(
OUTPUT ${HAIR_WEIGHTED_STAMP}
DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/clothes-${SEX}-hair.blend
${CMAKE_CURRENT_SOURCE_DIR}/process_clothes.py
${CMAKE_CURRENT_BINARY_DIR}/blender-addons-installed
COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_CURRENT_BINARY_DIR}/clothes
COMMAND ${BLENDER} -b -Y -P ${CMAKE_CURRENT_SOURCE_DIR}/process_clothes.py --
${CMAKE_CURRENT_SOURCE_DIR}/clothes-${SEX}-hair.blend
./ ${CMAKE_CURRENT_BINARY_DIR}/clothes
COMMAND ${CMAKE_COMMAND} -E touch ${HAIR_WEIGHTED_STAMP}
COMMAND ${CMAKE_COMMAND} -D FILE=${CMAKE_CURRENT_BINARY_DIR}/clothes/clothes-${SEX}-hair_weighted.blend
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
COMMENT "Processing hair meshes (weight transfer)"
)
set(SLOT_LIST "bottom" "top" "feet" "hair")
list(GET SLOT_LIST -1 LAST_SLOT)
weight_clothes(${CMAKE_CURRENT_SOURCE_DIR}/clothes-${SEX}-top.blend)
weight_clothes(${CMAKE_CURRENT_SOURCE_DIR}/clothes-${SEX}-bottom.blend)
weight_clothes(${CMAKE_CURRENT_SOURCE_DIR}/clothes-${SEX}-feet.blend)
endforeach()
function(character_clothes_source AGE SEX SLOT OUT)
if(AGE STREQUAL "adult")
set(${OUT} ${CMAKE_CURRENT_SOURCE_DIR}/clothes-${SEX}-${SLOT}.blend
PARENT_SCOPE)
else()
set(${OUT} ${CMAKE_CURRENT_SOURCE_DIR}/clothes-${AGE}-${SEX}-${SLOT}.blend
PARENT_SCOPE)
endif()
endfunction()
function(character_clothes_name AGE SEX SLOT OUT)
if(AGE STREQUAL "adult")
set(${OUT} "clothes-${SEX}-${SLOT}" PARENT_SCOPE)
else()
set(${OUT} "clothes-${AGE}-${SEX}-${SLOT}" PARENT_SCOPE)
endif()
endfunction()
# Propagate missing shape keys (like "fat") from Body_shapes to base body parts.
# This ensures all body part meshes have consistent shape keys, preventing seams.
@@ -458,57 +457,115 @@ function(add_shape_key_propagation INPUT_BLEND OUTPUT_BLEND)
${CMAKE_CURRENT_SOURCE_DIR}/transfer_shape_keys.py
${CMAKE_CURRENT_BINARY_DIR}/blender-addons-installed
COMMAND ${CMAKE_COMMAND} -E copy ${INPUT_BLEND} ${OUTPUT_BLEND}
COMMAND ${BLENDER} -b -Y ${OUTPUT_BLEND}
COMMAND ${BLENDER_LOCKED} -b -Y ${OUTPUT_BLEND}
-P ${CMAKE_CURRENT_SOURCE_DIR}/add_missing_shape_keys.py --
${OUTPUT_BLEND} ${OUTPUT_BLEND}
WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
)
endfunction()
set(SLOT_LIST "bottom" "top" "feet" "hair")
list(GET SLOT_LIST -1 LAST_SLOT)
# male and female clothes pipeline
foreach (SEX ${SEX_LIST})
add_shape_key_propagation(
${CMAKE_CURRENT_SOURCE_DIR}/edited-normal-${SEX}.blend
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-${SEX}-shapes.blend
)
set(SLOT_INPUT "edited-normal-${SEX}-shapes.blend")
foreach (SLOT ${SLOT_LIST})
set(SLOT_OUTPUT "edited-normal-${SEX}-consolidated-${SLOT}.blend")
if (SLOT STREQUAL LAST_SLOT)
set(SLOT_OUTPUT "edited-normal-${SEX}-consolidated.blend")
endif()
add_clothes_pipeline("${CMAKE_CURRENT_BINARY_DIR}/${SLOT_INPUT}"
"${CMAKE_CURRENT_BINARY_DIR}/clothes/clothes-${SEX}-${SLOT}_weighted.blend"
"${CMAKE_CURRENT_BINARY_DIR}/${SLOT_OUTPUT}"
# Age/sex clothes, consolidation, LOD and export pipeline.
set(ALL_CHARACTER_TARGETS)
foreach(AGE ${CHARACTER_BUILD_AGES})
set(AGE_TARGETS)
foreach(SEX ${CHARACTER_SEXES})
character_stem(${AGE} ${SEX} STEM)
character_build_blend(${AGE} ${SEX} BASE_BUILD)
set(SHAPES_BLEND
"${CMAKE_CURRENT_BINARY_DIR}/edited-normal-${STEM}-shapes.blend")
set(CONSOLIDATED
"${CMAKE_CURRENT_BINARY_DIR}/edited-normal-${STEM}-consolidated.blend")
set(LODS "${CMAKE_CURRENT_BINARY_DIR}/edited-normal-${STEM}-lods.blend")
set(OUTDIR "${CMAKE_BINARY_DIR}/characters/${STEM}")
add_shape_key_propagation(${BASE_BUILD} ${SHAPES_BLEND})
set(SLOT_INPUT "edited-normal-${STEM}-shapes.blend")
foreach(SLOT ${SLOT_LIST})
character_clothes_source(${AGE} ${SEX} ${SLOT} CLOTHES_SRC)
character_clothes_name(${AGE} ${SEX} ${SLOT} CLOTHES_NAME)
weight_clothes(${CLOTHES_SRC} ${BASE_BUILD})
set(WEIGHTED
"${CMAKE_CURRENT_BINARY_DIR}/clothes/${CLOTHES_NAME}_weighted.blend")
set(SLOT_OUTPUT
"edited-normal-${STEM}-consolidated-${SLOT}.blend")
if(SLOT STREQUAL LAST_SLOT)
set(SLOT_OUTPUT "edited-normal-${STEM}-consolidated.blend")
endif()
add_clothes_pipeline("${CMAKE_CURRENT_BINARY_DIR}/${SLOT_INPUT}"
"${WEIGHTED}"
"${CMAKE_CURRENT_BINARY_DIR}/${SLOT_OUTPUT}")
set(SLOT_INPUT ${SLOT_OUTPUT})
endforeach()
add_custom_command(
OUTPUT ${LODS}
COMMAND ${CMAKE_COMMAND} -E remove_directory ${OUTDIR}
COMMAND ${CMAKE_COMMAND} -E make_directory ${OUTDIR}
COMMAND ${BLENDER_LOCKED} -b -Y ${CONSOLIDATED}
-P ${CMAKE_CURRENT_SOURCE_DIR}/generate_lods.py --
${AGE} ${SEX} ${OUTDIR} ${LODS}
COMMAND ${CMAKE_COMMAND} -D FILE=${LODS}
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
DEPENDS ${CONSOLIDATED}
${CMAKE_CURRENT_SOURCE_DIR}/generate_lods.py
${CMAKE_CURRENT_BINARY_DIR}/blender-addons-installed
COMMENT "Generating character LOD meshes for ${STEM}"
)
set(SLOT_INPUT ${SLOT_OUTPUT})
character_glb(${AGE} ${SEX} GLB)
add_custom_command(
OUTPUT ${GLB}
COMMAND ${CMAKE_COMMAND} -E make_directory ${OUTDIR}
COMMAND ${BLENDER_LOCKED} -b -Y
-P ${CMAKE_SOURCE_DIR}/assets/blender/scripts/export_models2.py --
${CONSOLIDATED}
${GLB}
"AUTO"
${STEM}
tmp-edited-${STEM}.blend
${LODS}
COMMAND ${CMAKE_COMMAND} -D FILE=${GLB}
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
COMMAND ${CMAKE_COMMAND} -D FILE=${OUTDIR}/normal-${STEM}.scene
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
DEPENDS ${CMAKE_SOURCE_DIR}/assets/blender/scripts/export_models2.py
${CONSOLIDATED} ${LODS}
${CMAKE_CURRENT_BINARY_DIR}/blender-addons-installed
WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
VERBATIM
)
add_custom_target(character-${STEM}
DEPENDS ${GLB} ${CONSOLIDATED} ${LODS})
list(APPEND AGE_TARGETS character-${STEM})
list(APPEND ALL_CHARACTER_TARGETS character-${STEM})
endforeach()
add_custom_target(characters-${AGE} DEPENDS ${AGE_TARGETS})
endforeach()
# Character LOD generation (demo-taxi phase 6.C.3/6.C.4): from each
# consolidated blend generate per-part "_lod1" atlas variants and
# whole-character charlod impostors (LOD2 thin/fat, LOD3 <= 400 tris),
# the shared 2048 atlas and the charlod_<sex>.json manifest. The
# export_models2 commands above append the lods blend so the impostors
# link the same skeleton as the body parts.
foreach (SEX ${SEX_LIST})
add_custom_command(
OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/edited-normal-${SEX}-lods.blend
COMMAND ${CMAKE_COMMAND} -E make_directory
${CMAKE_BINARY_DIR}/characters/${SEX}
COMMAND ${BLENDER} -b -Y
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-${SEX}-consolidated.blend
-P ${CMAKE_CURRENT_SOURCE_DIR}/generate_lods.py --
${SEX}
${CMAKE_BINARY_DIR}/characters/${SEX}
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-${SEX}-lods.blend
COMMAND ${CMAKE_COMMAND} -D FILE=${CMAKE_CURRENT_BINARY_DIR}/edited-normal-${SEX}-lods.blend
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
DEPENDS ${CMAKE_CURRENT_BINARY_DIR}/edited-normal-${SEX}-consolidated.blend
${CMAKE_CURRENT_SOURCE_DIR}/generate_lods.py
${CMAKE_CURRENT_BINARY_DIR}/blender-addons-installed
COMMENT "Generating character LOD meshes for ${SEX}"
)
endforeach()
add_custom_target(characters-all DEPENDS ${ALL_CHARACTER_TARGETS})
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}"
DEPENDS characters-all
COMMENT "Validating generated character age assets")
add_custom_target(generate-age-seeds
COMMAND ${BLENDER_LOCKED} -b -Y
-P ${CMAKE_CURRENT_SOURCE_DIR}/generate_age_blends.py --
--all --write --only-unedited
DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/generate_age_blends.py
COMMENT "Generating unedited age seed blends (never touches hand-edited seeds)")
add_custom_target(check-age-seeds
COMMAND ${BLENDER_LOCKED} -b -Y
-P ${CMAKE_CURRENT_SOURCE_DIR}/generate_age_blends.py --
--all --check
DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/generate_age_blends.py
COMMENT "Checking age seed presence/hashes")
add_custom_target(morph ALL DEPENDS MorphTargetsResearch ${CHARACTER_GLBS})
add_custom_target(edited-blends ALL DEPENDS ${EDITED_BLEND_TARGETS})
add_custom_target(import_vrm DEPENDS ${CHARACTER_GLBS})
+256
View File
@@ -0,0 +1,256 @@
"""Age-band profiles and naming helpers for the character pipeline.
This module is the single asset-side source of truth for the automatic
adult -> age conversion. It is imported by the Blender scripts and by
``generate_age_blends.py``; the C++ runtime mirrors the small amount of data
it needs (names, age thresholds, fallback chains and body metrics).
Keep this file dependency-free (stdlib only) so it can be imported outside
Blender for validation/tests.
"""
from __future__ import annotations
CANONICAL_AGES = ["baby", "child", "teenager", "adult", "elder"]
AGE_ALIASES = {
"infant": "baby",
"teen": "teenager",
"adolescent": "teenager",
"senior": "elder",
"old": "elder",
"old-age": "elder",
}
# Canonical band boundaries used by the population reference rules.
BABY_MAX_YEARS = 2
CHILD_MAX_YEARS = 12
TEEN_MAX_YEARS = 17
ADULT_MAX_YEARS = 64
# Population rules' default ranges (mirrors TownPopulation::PopulationRules).
DEFAULT_AGE_RULES = {
"childMinYears": 1,
"childMaxYears": 17,
"adultMinYears": 18,
"adultMaxYears": 64,
"elderMinYears": 65,
"elderMaxYears": 90,
}
# Animation names every age skeleton must expose. The underlying clips may
# differ per band (baby crawl etc.); the names are the animation-tree contract.
REQUIRED_ANIMATIONS = [
"default",
"idle",
"walking",
"running",
"sitting",
"sitting-ground",
"sitting-vehicle",
"swimming",
"treading_water",
"swimming-fast",
"jump",
"left_turn",
"right_turn",
]
# Per-band conversion profile.
#
# Length/radial values are multipliers over the same-sex adult bone segments /
# thickness. They are conversion targets, not medical proportions.
# ``targetHeight`` is filled by sex below.
PROFILES = {
"baby": {
"hipHeight": 0.28,
"torso": 0.30,
"head": 0.85,
"shoulderWidth": 0.50,
"arm": 0.30,
"leg": 0.26,
"foot": 0.55,
"hipWidth": 0.55,
"bodyRadial": 0.55,
"limbRadial": 0.50,
"headBulge": 1.08,
"walkSpeedScale": 0.0, # home-only in v1
},
"child": {
"hipHeight": 0.55,
"torso": 0.62,
"head": 0.92,
"shoulderWidth": 0.70,
"arm": 0.60,
"leg": 0.55,
"foot": 0.75,
"hipWidth": 0.72,
"bodyRadial": 0.78,
"limbRadial": 0.72,
"headBulge": 1.03,
"walkSpeedScale": 0.75,
},
"teenager": {
"hipHeight": 0.94,
"torso": 0.92,
"head": 0.99,
"shoulderWidth": 0.92,
"arm": 0.92,
"leg": 0.94,
"foot": 0.97,
"hipWidth": 0.92,
"bodyRadial": 0.94,
"limbRadial": 0.93,
"headBulge": 1.0,
"walkSpeedScale": 0.95,
},
"adult": {
"hipHeight": 1.0,
"torso": 1.0,
"head": 1.0,
"shoulderWidth": 1.0,
"arm": 1.0,
"leg": 1.0,
"foot": 1.0,
"hipWidth": 1.0,
"bodyRadial": 1.0,
"limbRadial": 1.0,
"headBulge": 1.0,
"walkSpeedScale": 1.0,
},
# Elder v1 stays bind-identical to adult: aging is geometry/texture only.
# The manual detail pass can scale the rig or posture later.
"elder": {
"hipHeight": 1.0,
"torso": 1.0,
"head": 1.0,
"shoulderWidth": 1.0,
"arm": 1.0,
"leg": 1.0,
"foot": 1.0,
"hipWidth": 1.0,
"bodyRadial": 1.0,
"limbRadial": 1.0,
"headBulge": 1.0,
"walkSpeedScale": 1.0,
},
}
# Target mesh/skeleton heights in metres. Elder is adult height for the
# bind-identical v1 seed; the detail pass can shorten it.
TARGET_HEIGHT = {
("baby", "male"): 0.72,
("baby", "female"): 0.70,
("child", "male"): 1.20,
("child", "female"): 1.18,
("teenager", "male"): 1.70,
("teenager", "female"): 1.55,
("adult", "male"): 1.85,
("adult", "female"): 1.58,
("elder", "male"): 1.85,
("elder", "female"): 1.58,
}
ADULT_HEIGHT = {"male": 1.85, "female": 1.58}
FALLBACK_CHAINS = {
"baby": ["baby", "child", "adult"],
"child": ["child", "baby", "teenager", "adult"],
"teenager": ["teenager", "child", "adult"],
"adult": ["adult", "teenager", "elder"],
"elder": ["elder", "adult"],
}
# Garment/body fit reporting thresholds.
GARMENT_OFFSET = 0.003 # metres pushed out from the body surface
QUALITY = {
"seam_warn": 0.001,
"seam_fail": 0.01,
"penetration_warn": 0.01,
"penetration_fail": 0.05,
"height_tolerance": 0.05,
"edge_residual_warn": 0.35, # |log(expected/actual)|-ish residual
"edge_residual_fail": 0.70,
}
def normalize_age(age: str | None) -> str:
if not age:
return "adult"
key = str(age).strip().lower()
key = AGE_ALIASES.get(key, key)
return key if key in CANONICAL_AGES else "adult"
def age_band_for_years(years: int) -> str:
if years <= BABY_MAX_YEARS:
return "baby"
if years <= CHILD_MAX_YEARS:
return "child"
if years <= TEEN_MAX_YEARS:
return "teenager"
if years <= ADULT_MAX_YEARS:
return "adult"
return "elder"
def is_working_age(years: int) -> bool:
return age_band_for_years(years) == "adult"
def fallback_chain(age: str) -> list[str]:
return list(FALLBACK_CHAINS[normalize_age(age)])
def stem(age: str, sex: str) -> str:
age = normalize_age(age)
return sex if age == "adult" else f"{age}-{sex}"
def armature_name(age: str, sex: str) -> str:
return stem(age, sex)
def mesh_prefix(age: str, sex: str) -> str:
return stem(age, sex) + "_"
def target_height(age: str, sex: str) -> float:
age = normalize_age(age)
return TARGET_HEIGHT[(age, sex)]
def body_metrics(age: str, sex: str) -> dict:
"""Capsule/head metrics scaled from the current adult defaults."""
age = normalize_age(age)
scale = target_height(age, sex) / ADULT_HEIGHT[sex]
radius = 0.3 * scale
height = 1.8 * scale
return {
"radius": radius,
"height": height,
"totalHeight": height + 2.0 * radius,
"headRadius": 0.13 * scale,
"headOffsetY": 0.0,
"walkSpeedScale": PROFILES[age]["walkSpeedScale"],
}
def profile(age: str) -> dict:
return dict(PROFILES[normalize_age(age)])
def profile_hash() -> str:
import hashlib
import json
payload = {
"ages": CANONICAL_AGES,
"profiles": PROFILES,
"heights": {f"{a}|{s}": h for (a, s), h in TARGET_HEIGHT.items()},
"animations": REQUIRED_ANIMATIONS,
"quality": QUALITY,
}
return hashlib.sha256(
json.dumps(payload, sort_keys=True).encode("utf-8")
).hexdigest()[:16]
@@ -0,0 +1,9 @@
"""Age-seed conversion helpers (Blender 3.6)."""
__all__ = [
"profiles",
"rig",
"deform",
"report",
"anim",
]
+140
View File
@@ -0,0 +1,140 @@
"""Animation copy/retargeting for age seeds.
The adult Mixamo clips animate rotations in bone-local space and locations for
the hips/root. Because the age rig keeps every bone's rest orientation, the
rotation curves transfer 1:1; only location curves need the bone's rest-offset
ratio. This module is authoring-time only and must not run during the normal
build (that would scale the location tracks twice).
"""
from __future__ import annotations
import re
import bpy
from . import profiles
from .rig import bone_length_ratios
_LOC_RE = re.compile(r'pose\.bones\["([^"]+)"\]\.(location|delta_location)')
def _nla_track_names(arm):
if not arm.animation_data:
return set()
return {t.name for t in arm.animation_data.nla_tracks}
def _ensure_track(arm, action):
ad = arm.animation_data
if ad is None:
arm.animation_data_create()
ad = arm.animation_data
existing = {t.name for t in ad.nla_tracks}
if action.name in existing:
return
track = ad.nla_tracks.new()
track.name = action.name
# Match copy_animations.py: strips start at frame 1 and remain active;
# export_models2.py bakes each strip explicitly.
track.strips.new(action.name, 1, action)
def retarget_actions(arm, ratios: dict, hip_ratio: float,
actions) -> dict:
"""Scale location F-curves in *actions* and return a small summary."""
scaled_curves = 0
for action in actions:
for fc in action.fcurves:
m = _LOC_RE.match(fc.data_path)
if m:
bone = m.group(1)
ratio = ratios.get(bone, 1.0)
for kp in fc.keyframe_points:
kp.co.y *= ratio
kp.handle_left.y *= ratio
kp.handle_right.y *= ratio
scaled_curves += 1
elif fc.data_path in ("location", "delta_location"):
for kp in fc.keyframe_points:
kp.co.y *= hip_ratio
kp.handle_left.y *= hip_ratio
kp.handle_right.y *= hip_ratio
scaled_curves += 1
return {"scaledLocationCurves": scaled_curves}
def _ensure_alias(missing: str, present: set) -> str | None:
candidates = {
"swimming-fast": ["swimming", "walking", "idle"],
"sitting-ground": ["sitting", "default"],
"sitting-vehicle": ["sitting", "default"],
"sitting": ["default", "idle"],
"treading_water": ["swimming", "idle"],
"default": ["idle", "walking", "T-Pose"],
}.get(missing, [])
for name in candidates:
if name in present:
return name
return None
def append_and_retarget(arm, adult_arm, source_blend: str, age: str,
sex: str, hips_bone="mixamorig:Hips"):
"""Append the sex's Mixamo actions, retarget them and build NLA tracks."""
age = profiles.normalize_age(age)
stem = profiles.stem(age, sex)
existing_names = {a.name for a in bpy.data.actions}
with bpy.data.libraries.load(source_blend, link=False) as (src, dst):
wanted = [n for n in src.actions if n not in existing_names]
dst.actions = wanted
appended = [a for a in dst.actions if a is not None]
# Fill required-but-missing action names with the closest available clip.
# These are explicit placeholders for the manual animation pass; keeping
# the names present prevents silent T-poses / missing-state errors.
aliased = {}
for name in profiles.REQUIRED_ANIMATIONS:
if bpy.data.actions.get(name) is not None:
continue
src_name = _ensure_alias(name, {a.name for a in bpy.data.actions})
if not src_name:
continue
src = bpy.data.actions[src_name]
new = src.copy()
new.name = name
new.use_fake_user = True
aliased[name] = src_name
# Ensure every action (including custom baby clips and aliases) has an
# NLA track.
for action in bpy.data.actions:
if action.name.startswith("bake_"):
continue
if action.name in ("T-Pose",):
continue
_ensure_track(arm, action)
ratios = bone_length_ratios(adult_arm, arm)
hip_ratio = ratios.get(hips_bone, 1.0)
# Retarget ALL actions, including clips already present in the source
# base blend (sitting-ground/sitting-vehicle/etc. are authored there).
summary = retarget_actions(arm, ratios, hip_ratio,
list(bpy.data.actions))
summary["appended"] = [a.name for a in appended]
summary["aliased"] = aliased
# Ensure the default action is active (export_models2 expects it).
default = bpy.data.actions.get("default")
if default is not None:
if arm.animation_data is None:
arm.animation_data_create()
arm.animation_data.action = default
present = {a.name for a in bpy.data.actions}
missing = [n for n in profiles.REQUIRED_ANIMATIONS if n not in present]
summary["missing"] = missing
summary["stem"] = stem
return summary
+240
View File
@@ -0,0 +1,240 @@
"""Weighted rest-pose deformation for body parts, clothes and shape keys."""
from __future__ import annotations
import math
import bpy
from mathutils import Matrix, Vector
from . import profiles
def _nearest_bone_distance(point: Vector, bone) -> float:
a = bone.head_local
b = bone.tail_local
ab = b - a
denom = ab.length_squared
if denom <= 1e-12:
return (point - a).length
t = max(0.0, min(1.0, (point - a).dot(ab) / denom))
return (point - (a + ab * t)).length
def _nearest_bone_name(point: Vector, adult_arm) -> str:
best = None
best_d = 1e30
for b in adult_arm.data.bones:
if not b.use_deform:
continue
d = _nearest_bone_distance(point, b)
if d < best_d:
best_d = d
best = b.name
return best or "Root"
def _group_name_map(obj) -> dict:
return {i: g.name for i, g in enumerate(obj.vertex_groups)}
def _vertex_matrix(v, group_names, dmatrices, adult_arm, obj_to_arm):
"""Return (matrix, influence_count) for one mesh vertex."""
acc = 0.0
m = Matrix(((0.0, 0.0, 0.0, 0.0),
(0.0, 0.0, 0.0, 0.0),
(0.0, 0.0, 0.0, 0.0),
(0.0, 0.0, 0.0, 0.0)))
influences = 0
for g in v.groups:
w = float(g.weight)
if w <= 0.0:
continue
name = group_names.get(g.group)
dm = dmatrices.get(name)
if dm is None:
continue
acc += w
m = m + dm * w
influences += 1
if acc > 1e-9:
return m * (1.0 / acc), influences
# No usable weight: bind to the nearest deform bone.
p_arm = obj_to_arm @ v.co
name = _nearest_bone_name(p_arm, adult_arm)
dm = dmatrices.get(name)
return (dm.copy() if dm else Matrix.Identity(4)), 0
def deform_mesh_object(obj, adult_arm, age_arm, dmatrices,
profile: dict, retarget_parent=True):
"""Deform one mesh and its shape keys in place.
Returns a metrics dict. ``retarget_parent`` re-parents the mesh to the
age armature and points the armature modifier at it (used for the base
body seed). Garment seeds are saved without a body armature link, so
they pass ``False``.
"""
if obj.type != 'MESH':
return None
mesh = obj.data
group_names = _group_name_map(obj)
obj_to_arm = adult_arm.matrix_world.inverted() @ obj.matrix_world
arm_to_obj = obj.matrix_world.inverted() @ age_arm.matrix_world
before = [v.co.copy() for v in mesh.vertices]
matrices = []
unweighted = 0
max_inf = 0
for v in mesh.vertices:
m, inf = _vertex_matrix(v, group_names, dmatrices, adult_arm,
obj_to_arm)
matrices.append(m)
if inf == 0:
unweighted += 1
max_inf = max(max_inf, inf)
# Basis vertices.
for i, v in enumerate(mesh.vertices):
p = obj_to_arm @ v.co
v.co = arm_to_obj @ (matrices[i] @ p)
# Non-Basis shape keys: absolute positions, same weights.
key_count = 0
if mesh.shape_keys:
for kb in mesh.shape_keys.key_blocks:
if kb.name == 'Basis':
continue
key_count += 1
for i, kp in enumerate(kb.data):
p = obj_to_arm @ kp.co
kp.co = arm_to_obj @ (matrices[i] @ p)
mesh.update()
try:
mesh.calc_normals()
except Exception:
pass
if retarget_parent:
obj.parent = age_arm
mod = next((m for m in obj.modifiers
if m.type == 'ARMATURE'), None)
if mod is None:
mod = obj.modifiers.new(name="Armature", type='ARMATURE')
mod.object = age_arm
obj.matrix_parent_inverse = age_arm.matrix_world.inverted()
# Metrics.
edge_ratios = []
for e in mesh.edges:
a, b = e.vertices
lb = (before[a] - before[b]).length
la = (mesh.vertices[a].co - mesh.vertices[b].co).length
if lb > 1e-8:
edge_ratios.append(la / lb)
mesh.calc_loop_triangles()
degenerate = 0
for tri in mesh.loop_triangles:
a, b, c = tri.vertices
area = ((mesh.vertices[b].co - mesh.vertices[a].co).cross(
mesh.vertices[c].co - mesh.vertices[a].co)).length * 0.5
if area < 1e-10:
degenerate += 1
return {
"vertices": len(mesh.vertices),
"polygons": len(mesh.polygons),
"shapeKeys": key_count,
"unweighted": unweighted,
"maxInfluences": max_inf,
"edgeRatioMin": min(edge_ratios) if edge_ratios else 1.0,
"edgeRatioMax": max(edge_ratios) if edge_ratios else 1.0,
"degenerateTris": degenerate,
"before": before,
}
def deform_shape_keys_only(obj, adult_arm, age_arm, dmatrices):
"""Transform shape keys without touching the basis (utility)."""
return None
def snap_seams(objects_with_metrics, max_report=10):
"""Snap coincident boundary vertices across parts to their average.
``objects_with_metrics`` is a list of ``(obj, metrics)`` where metrics has
the original vertex positions. Returns a report dict.
"""
buckets = {}
for obj, metrics in objects_with_metrics:
before = metrics.get("before")
if not before:
continue
for i, p in enumerate(before):
key = (round(p.x, 5), round(p.y, 5), round(p.z, 5))
buckets.setdefault(key, []).append((obj, i, p))
max_gap = 0.0
snapped = 0
examples = []
for key, items in buckets.items():
if len(items) < 2:
continue
positions = [obj.matrix_world @ obj.data.vertices[i].co
for obj, i, _ in items]
avg = Vector((0, 0, 0))
for p in positions:
avg += p
avg /= len(positions)
gap = max((p - avg).length for p in positions)
if gap < 1e-6:
continue
max_gap = max(max_gap, gap)
for obj, i, _ in items:
delta = avg - (obj.matrix_world @ obj.data.vertices[i].co)
if delta.length <= 1e-9:
continue
v = obj.data.vertices[i]
v.co += obj.matrix_world.inverted().to_3x3() @ delta
if obj.data.shape_keys:
for kb in obj.data.shape_keys.key_blocks:
if kb.name == 'Basis':
continue
kb.data[i].co += (obj.matrix_world.inverted().to_3x3()
@ delta)
snapped += 1
if len(examples) < max_report:
examples.append({
"position": [round(v, 4) for v in key],
"gap": round(gap, 6),
"objects": sorted({obj.name for obj, _, _ in items}),
})
return {
"coincidentGroups": sum(1 for v in buckets.values() if len(v) > 1),
"snappedVertices": snapped,
"maxGap": max_gap,
"examples": examples,
}
def transform_hair_uniform(obj, adult_pivot: Vector, age_pivot: Vector,
scale: float):
"""Uniformly scale a hair mesh and move it from adult to age scalp."""
obj_to_arm = obj.matrix_world
inv = obj.matrix_world.inverted()
for v in obj.data.vertices:
p = obj_to_arm @ v.co
v.co = inv @ (age_pivot + (p - adult_pivot) * scale)
if obj.data.shape_keys:
for kb in obj.data.shape_keys.key_blocks:
if kb.name == 'Basis':
continue
for kp in kb.data:
p = obj_to_arm @ kp.co
kp.co = inv @ (age_pivot + (p - adult_pivot) * scale)
obj.data.update()
try:
obj.data.calc_normals()
except Exception:
pass
@@ -0,0 +1,25 @@
"""Re-export the asset-side age profile data for the converter package."""
import os
import sys
_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
_CHARACTERS_DIR = os.path.dirname(_THIS_DIR)
if _CHARACTERS_DIR not in sys.path:
sys.path.insert(0, _CHARACTERS_DIR)
import age_profiles as ap # noqa: E402,F401
CANONICAL_AGES = ap.CANONICAL_AGES
PROFILES = ap.PROFILES
REQUIRED_ANIMATIONS = ap.REQUIRED_ANIMATIONS
normalize_age = ap.normalize_age
age_band_for_years = ap.age_band_for_years
fallback_chain = ap.fallback_chain
stem = ap.stem
armature_name = ap.armature_name
mesh_prefix = ap.mesh_prefix
target_height = ap.target_height
body_metrics = ap.body_metrics
profile = ap.profile
profile_hash = ap.profile_hash
+134
View File
@@ -0,0 +1,134 @@
"""Conversion quality reporting for generated age seeds."""
from __future__ import annotations
import json
import os
import time
from . import profiles
class ConversionReport:
def __init__(self, age: str, sex: str):
self.age = profiles.normalize_age(age)
self.sex = sex
self.stem = profiles.stem(self.age, sex)
self.started = time.time()
self.objects = {}
self.warnings = []
self.fails = []
self.info = {}
self.seams = {}
self.rig = {}
def add_object(self, name: str, metrics: dict, quality: str = "ok",
note: str = ""):
entry = dict(metrics or {})
entry.pop("before", None) # large, not serializable/useful
entry["quality"] = quality
entry["note"] = note
self.objects[name] = entry
if quality == "fail":
self.fails.append(name)
elif quality in ("warn", "placeholder"):
self.warnings.append(name)
def status(self):
if self.fails:
return "fail"
if self.warnings:
return "warn"
return "ok"
def to_dict(self):
return {
"age": self.age,
"sex": self.sex,
"stem": self.stem,
"profileHash": profiles.profile_hash(),
"timestamp": int(self.started),
"status": self.status(),
"info": self.info,
"rig": self.rig,
"seams": self.seams,
"objects": self.objects,
"warnings": self.warnings,
"fails": self.fails,
}
def write(self, out_dir: str):
os.makedirs(out_dir, exist_ok=True)
base = os.path.join(out_dir,
f"conversion-report-{self.age}-{self.sex}")
data = self.to_dict()
with open(base + ".json", "w") as f:
json.dump(data, f, indent=2, sort_keys=True)
lines = [
f"# Conversion Report — {self.age} / {self.sex}",
"",
f"- Stem: `{self.stem}`",
f"- Status: **{data['status']}**",
f"- Profile hash: `{data['profileHash']}`",
f"- Objects: {len(self.objects)}",
f"- Warnings: {len(self.warnings)}",
f"- Fails: {len(self.fails)}",
"",
]
if self.info:
lines.append("## Info")
for k in sorted(self.info):
lines.append(f"- {k}: `{self.info[k]}`")
lines.append("")
if self.seams:
lines.append("## Seams")
for k, v in self.seams.items():
lines.append(f"- {k}: `{v}`")
lines.append("")
if self.fails:
lines += ["## Failures", ""]
lines += [f"- `{name}`" for name in self.fails]
lines.append("")
if self.warnings:
lines += ["## Warnings (first 20)", ""]
lines += [f"- `{name}`" for name in self.warnings[:20]]
lines.append("")
lines += ["## Objects", ""]
for name in sorted(self.objects):
e = self.objects[name]
lines.append(
f"- `{name}` — {e.get('quality', 'ok')}; "
f"verts={e.get('vertices', '?')}, "
f"tris={e.get('degenerateTris', '?')} degenerate, "
f"influences={e.get('maxInfluences', '?')}, "
f"unweighted={e.get('unweighted', '?')}, "
f"edge={e.get('edgeRatioMin', 1):.2f}.."
f"{e.get('edgeRatioMax', 1):.2f}"
+ (f"; {e.get('note')}" if e.get("note") else "")
)
path = base + ".md"
with open(path, "w") as f:
f.write("\n".join(lines) + "\n")
return path, base + ".json"
def evaluate_object_quality(metrics: dict, is_catalog: bool) -> str:
"""Cheap per-object quality classification for the seed report."""
if not metrics:
return "warn"
if metrics.get("unweighted", 0) > 0 and is_catalog:
return "warn"
if metrics.get("maxInfluences", 0) > 4:
return "fail"
if metrics.get("degenerateTris", 0) > 0:
return "warn"
emin = metrics.get("edgeRatioMin", 1.0)
emax = metrics.get("edgeRatioMax", 1.0)
# Raw ratios are expected to compress for baby/child; flag only extreme
# local distortion. The per-bone expected ratio is evaluated in the
# report writer's caller when available.
if emax > 2.5 or (emin > 0.0 and emin < 0.35):
return "warn"
return "ok"
+257
View File
@@ -0,0 +1,257 @@
"""Age-rig construction and rest-pose transform data.
All matrices are in armature space. The age rig keeps the adult bone names,
hierarchy, roll and orientation; only joint positions and segment lengths
change. This is what makes rotation-only animation retargeting valid.
"""
from __future__ import annotations
import math
import bpy
from mathutils import Matrix, Vector
from . import profiles
def bone_region(name: str) -> dict:
"""Return length/radial/offset profile keys for a Mixamo bone name."""
n = name
low = n.lower()
if n in ("Root", "mixamorig:Hips"):
return {"length": "hipHeight", "radial": "bodyRadial",
"offset": "hipHeight"}
if "spine" in low:
return {"length": "torso", "radial": "bodyRadial",
"offset": "torso"}
if "neck" in low:
return {"length": "torso", "radial": "bodyRadial",
"offset": "torso"}
if ("head" in low or "jaw" in low or "eye" in low or
"teeth" in low or "tongue" in low):
return {"length": "head", "radial": "head", "offset": "head"}
if "bust" in low:
return {"length": "torso", "radial": "bodyRadial",
"offset": "torso"}
if "hair" in low or "j_sec_hair" in low:
return {"length": "head", "radial": "head", "offset": "head"}
if "sleeve" in low:
return {"length": "arm", "radial": "limbRadial",
"offset": "arm"}
if "skirt" in low:
return {"length": "leg", "radial": "bodyRadial",
"offset": "hipWidth"}
if "shoulder" in low:
return {"length": "shoulderWidth", "radial": "limbRadial",
"offset": "shoulderWidth"}
if "upleg" in low or "upperleg" in low:
return {"length": "leg", "radial": "limbRadial",
"offset": "hipWidth"}
if "leg" in low:
return {"length": "leg", "radial": "limbRadial",
"offset": "hipWidth"}
if "foot" in low:
return {"length": "foot", "radial": "limbRadial",
"offset": "leg"}
if "toe" in low:
return {"length": "foot", "radial": "limbRadial",
"offset": "foot"}
if "arm" in low or "hand" in low or "thumb" in low or \
"index" in low or "middle" in low or "ring" in low or \
"pinky" in low:
return {"length": "arm", "radial": "limbRadial",
"offset": "arm"}
return {"length": "torso", "radial": "bodyRadial", "offset": "torso"}
def _depth(bone) -> int:
d = 0
p = bone.parent
while p is not None:
d += 1
p = p.parent
return d
def _unique_name(existing, base):
if base not in existing:
return base
i = 1
while f"{base}.{i:03d}" in existing:
i += 1
return f"{base}.{i:03d}"
def build_age_armature(adult_arm, age: str, sex: str,
profile: dict | None = None,
collection=None):
"""Duplicate *adult_arm* and scale its rest pose into the age rig.
Returns the new armature object. The new object/data are named
``<age>-<sex>`` (adult would keep the legacy sex name; this function is
only used for non-adult seeds).
"""
age = profiles.normalize_age(age)
profile = profile or profiles.profile(age)
stem = profiles.stem(age, sex)
age_arm = adult_arm.copy()
age_arm.data = adult_arm.data.copy()
age_arm.name = stem
age_arm.data.name = stem
(collection or bpy.context.collection).objects.link(age_arm)
# Snapshot adult rest positions before entering edit mode.
adult_head = {b.name: b.head_local.copy() for b in adult_arm.data.bones}
adult_tail = {b.name: b.tail_local.copy() for b in adult_arm.data.bones}
bpy.context.view_layer.objects.active = age_arm
age_arm.select_set(True)
bpy.ops.object.mode_set(mode='EDIT')
ebones = age_arm.data.edit_bones
new_head = {}
new_tail = {}
order = sorted(ebones, key=_depth)
for eb in order:
old_h = adult_head[eb.name]
old_t = adult_tail[eb.name]
direction = old_t - old_h
old_len = direction.length
if old_len <= 1e-6:
new_h = old_h.copy()
new_t = old_t.copy()
new_head[eb.name] = new_h
new_tail[eb.name] = new_t
continue
direction.normalize()
region = bone_region(eb.name)
lscale = float(profile[region["length"]])
rscale = float(profile[region["radial"]])
oscale = float(profile[region["offset"]])
if eb.parent is not None and eb.parent.name in new_head:
pname = eb.parent.name
if eb.use_connect:
new_h = new_tail[pname].copy()
else:
parent_old_h = adult_head[pname]
offset = old_h - parent_old_h
new_h = new_head[pname] + offset * oscale
else:
# Root or a parent-less helper: keep the origin, scale nothing
# laterally here (the profile handles the hips length).
new_h = old_h.copy()
new_len = old_len * lscale
new_t = new_h + direction * new_len
eb.head = new_h
eb.tail = new_t
# roll is intentionally untouched
new_head[eb.name] = new_h.copy()
new_tail[eb.name] = new_t.copy()
bpy.ops.object.mode_set(mode='OBJECT')
age_arm.select_set(False)
return age_arm
def _bone_scale_matrix(lscale: float, rscale: float) -> Matrix:
return Matrix.Diagonal((rscale, lscale, rscale, 1.0))
def rest_transform_data(adult_arm, age_arm, age: str,
profile: dict | None = None) -> dict:
"""Build serializable rest-pose deformation data for every bone."""
age = profiles.normalize_age(age)
profile = profile or profiles.profile(age)
out = {}
for ab in adult_arm.data.bones:
bb = age_arm.data.bones.get(ab.name)
if bb is None:
continue
region = bone_region(ab.name)
lscale = float(profile[region["length"]])
rscale = float(profile[region["radial"]])
f_adult = ab.matrix_local.copy()
f_age = bb.matrix_local.copy()
s = _bone_scale_matrix(lscale, rscale)
d = f_age @ s @ f_adult.inverted()
out[ab.name] = {
"lengthScale": lscale,
"radialScale": rscale,
"fAdult": [list(row) for row in f_adult],
"fAge": [list(row) for row in f_age],
"dMatrix": [list(row) for row in d],
}
return out
def dmatrices_from_data(data: dict) -> dict:
return {name: Matrix(entry["dMatrix"]) for name, entry in data.items()}
def bone_length_ratios(adult_arm, age_arm) -> dict:
out = {}
for ab in adult_arm.data.bones:
bb = age_arm.data.bones.get(ab.name)
if bb is None:
continue
al = (ab.tail_local - ab.head_local).length
bl = (bb.tail_local - bb.head_local).length
out[ab.name] = (bl / al) if al > 1e-9 else 1.0
return out
def validate_rest_orientation(adult_arm, age_arm, tol=1e-4) -> list:
"""Return a list of bones whose normalized rest direction changed."""
bad = []
for ab in adult_arm.data.bones:
bb = age_arm.data.bones.get(ab.name)
if bb is None:
continue
ad = (ab.tail_local - ab.head_local)
bd = (bb.tail_local - bb.head_local)
if ad.length < 1e-3 or bd.length < 1e-3:
continue
ad.normalize()
bd.normalize()
if (ad - bd).length > tol:
bad.append(ab.name)
return bad
def head_pivots(adult_arm, age_arm, head_bone="mixamorig:Head"):
ab = adult_arm.data.bones.get(head_bone)
bb = age_arm.data.bones.get(head_bone)
if not ab or not bb:
return Vector((0, 0, 0)), Vector((0, 0, 0))
return ab.head_local.copy(), bb.head_local.copy()
def scale_hair_armature(hair_arm, adult_head_pivot: Vector,
age_head_pivot: Vector, scale: float):
"""Scale a hair armature uniformly from the adult head to the age head.
Hair armatures are rigidly attached to the body head at runtime; a uniform
scale around the scalp preserves their own bone chain and animations.
"""
bpy.context.view_layer.objects.active = hair_arm
hair_arm.select_set(True)
bpy.ops.object.mode_set(mode='EDIT')
for eb in hair_arm.data.edit_bones:
old_h = eb.head.copy()
old_t = eb.tail.copy()
eb.head = age_head_pivot + (old_h - adult_head_pivot) * scale
eb.tail = age_head_pivot + (old_t - adult_head_pivot) * scale
bpy.ops.object.mode_set(mode='OBJECT')
hair_arm.select_set(False)
def transform_point_uniform(p: Vector, adult_pivot: Vector,
age_pivot: Vector, scale: float) -> Vector:
return age_pivot + (p - adult_pivot) * scale
@@ -0,0 +1,40 @@
"""Vertex-weight utilities for the age converter."""
from __future__ import annotations
import bpy
def limit_vertex_influences(obj, max_influences=4):
"""Keep the strongest N weights per vertex and normalize them to 1."""
if obj.type != 'MESH' or not obj.vertex_groups:
return 0
groups = {g.index: g for g in obj.vertex_groups}
mesh = obj.data
changed = 0
for v in mesh.vertices:
ws = [(g.group, float(g.weight)) for g in v.groups
if g.weight > 0.0 and g.group in groups]
if not ws:
continue
ws.sort(key=lambda p: p[1], reverse=True)
keep = ws[:max_influences]
drop = ws[max_influences:]
total = sum(w for _, w in keep)
if total <= 1e-9:
continue
for gi, _ in drop:
groups[gi].remove([v.index])
for gi, w in keep:
groups[gi].add([v.index], w / total, 'REPLACE')
changed += 1
return changed
def count_max_influences(obj):
if obj.type != 'MESH':
return 0
best = 0
for v in obj.data.vertices:
best = max(best, sum(1 for g in v.groups if g.weight > 0.0))
return best
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+32 -1
View File
@@ -6,6 +6,11 @@ import json
import mathutils
from mathutils.bvhtree import BVHTree
_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
def load_blend_files(clothes_blend_path, body_blend_path):
"""Load objects from blend files and return all loaded objects
@@ -364,6 +369,28 @@ def run_batch_combine():
print(f"Found {len(clothing_layer1)} layer 1 clothing objects")
print(f"Found {len(clothing_layer2)} layer 2 clothing objects")
# Guard against mixing ages/sexes in one pipeline run. The CMake inputs
# are per age/sex; this catches a misnamed file before it bakes nonsense.
ages = {age_profiles.normalize_age(o.get("age", "adult"))
for o in body_objects}
sexes = {str(o.get("sex", "")) for o in body_objects}
if len(ages) != 1 or len(sexes) != 1:
raise RuntimeError(
f"combine_clothes: mixed body ages/sexes: "
f"ages={sorted(ages)} sexes={sorted(sexes)}")
target_age = next(iter(ages))
target_sex = next(iter(sexes))
for o in clothing_layer1 + clothing_layer2:
obj_age = age_profiles.normalize_age(o.get("ref_age", "adult"))
obj_sex = str(o.get("ref_sex", ""))
if obj_age != target_age or obj_sex != target_sex:
raise RuntimeError(
f"combine_clothes: {o.name} is {obj_age}/{obj_sex}, "
f"body is {target_age}/{target_sex}")
# Keep the authoritative band on the source garment.
o["ref_age"] = target_age
o["ref_sex"] = target_sex
body_objects_dict = {obj.name: obj for obj in body_objects}
whitelist = set()
combined_objects = []
@@ -474,7 +501,11 @@ def run_batch_combine():
else:
obj["clothing_tags"] = ""
print(f"Finalized metadata for {obj.name}: layer={obj['layer']}, garments={obj['garments']}, tags={obj['clothing_tags']}")
obj["age"] = target_age
obj["sex"] = target_sex
print(f"Finalized metadata for {obj.name}: age={target_age}, "
f"sex={target_sex}, layer={obj['layer']}, "
f"garments={obj['garments']}, tags={obj['clothing_tags']}")
del obj["_combined_meta"]
+91 -3
View File
@@ -7,6 +7,89 @@ _script_dir = os.path.dirname(os.path.abspath(__file__))
if _script_dir not in sys.path:
sys.path.insert(0, _script_dir)
from transfer_shape_keys import fix_seams_across_objects, fix_normals_across_objects
import age_profiles
def _catalog_objects():
required = ("age", "sex", "slot")
return [o for o in bpy.data.objects
if o.type == 'MESH' and all(p in o for p in required)]
def _own_armature_names():
names = set()
for obj in _catalog_objects():
if obj.get("has_own_armature", False):
name = obj.get("own_armature_name", "")
if name and bpy.data.objects.get(name):
names.add(name)
return names
def consolidate_armatures():
"""Leave exactly one body armature per consolidated blend.
The clothing stages append their own copy of the body rig; Blender
renames those copies to .001/.002/... They are unused and previously
leaked into the export as extra skeletons.
"""
cats = _catalog_objects()
if not cats:
print("WARNING: no catalog objects; cannot consolidate armatures")
return
ages = {age_profiles.normalize_age(o.get("age", "adult"))
for o in cats}
sexes = {str(o.get("sex", "male")) for o in cats}
if len(ages) != 1 or len(sexes) != 1:
raise RuntimeError(
f"mixed ages/sexes in one consolidated blend: "
f"ages={sorted(ages)} sexes={sorted(sexes)}")
age = next(iter(ages))
sex = next(iter(sexes))
expected = age_profiles.armature_name(age, sex)
body = bpy.data.objects.get(expected)
if body is None or body.type != 'ARMATURE':
# Choose the armature with the most catalog children / deform bones.
best = None
best_score = -1
for arm in [o for o in bpy.data.objects if o.type == 'ARMATURE']:
children = sum(1 for o in cats if o.parent == arm)
score = children * 10000 + len(arm.data.bones)
if score > best_score:
best_score = score
best = arm
body = best
if body is None:
raise RuntimeError("no body armature found in consolidated blend")
body.name = expected
body.data.name = expected
own = _own_armature_names()
for obj in cats:
if obj.get("has_own_armature", False):
continue
obj.parent = body
arm_mod = next((m for m in obj.modifiers
if m.type == 'ARMATURE'), None)
if arm_mod is None:
arm_mod = obj.modifiers.new(name="Armature", type='ARMATURE')
arm_mod.object = body
removed = []
for arm in [o for o in bpy.data.objects if o.type == 'ARMATURE']:
if arm.name == body.name or arm.name in own:
continue
removed.append(arm.name)
bpy.data.objects.remove(arm, do_unlink=True)
if removed:
print(f"Removed duplicate body armatures: {removed}")
print(f"Consolidated body armature: '{body.name}' "
f"({len(body.data.bones)} bones), "
f"hair armatures kept: {sorted(own)}")
def process_append(source_files, output_path):
required_props = {"age", "sex", "slot"}
@@ -92,7 +175,8 @@ def process_append(source_files, output_path):
continue
# Normal body part: parent to body armature
arm_name = obj.get("sex")
arm_name = age_profiles.armature_name(
obj.get("age", "adult"), obj.get("sex", "male"))
arm_obj = bpy.data.objects.get(arm_name)
if arm_obj and arm_obj.type == 'ARMATURE':
@@ -121,10 +205,14 @@ def process_append(source_files, output_path):
else:
bpy.data.objects.remove(obj, do_unlink=True)
# 4. Recursive Purge of all unlinked data (Materials, Textures, Meshes)
# 4. Hardening: one body armature, no duplicate clothing-stage copies.
print("\nConsolidating body armatures...")
consolidate_armatures()
# 5. Recursive Purge of all unlinked data (Materials, Textures, Meshes)
bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True)
# 5. Fix seams across ALL body parts in the consolidated scene.
# 6. Fix seams across ALL body parts in the consolidated scene.
print("\nFixing seams across all consolidated body parts...")
fix_seams_across_objects()
fix_normals_across_objects()
@@ -0,0 +1,638 @@
{
"age": "baby",
"fails": [],
"info": {
"adultArmature": "female",
"ageArmature": "baby-female",
"animations": {
"aliased": {
"swimming-fast": "swimming"
},
"appended": [
"Armature|mixamo.com|Layer0.020",
"Armature|mixamo.com|Layer0.018",
"Armature|mixamo.com|Layer0.016",
"Armature|mixamo.com|Layer0.014"
],
"missing": [],
"scaledLocationCurves": 7028,
"stem": "baby-female"
},
"outputBase": "edited-normal-baby-female.blend",
"slots": "bottom,face,feet,hair,top",
"sourceBase": "edited-normal-female.blend"
},
"objects": {
"Accessory": {
"degenerateTris": 0,
"edgeRatioMax": 0.7372296166046396,
"edgeRatioMin": 0.3150672796769405,
"maxInfluences": 4,
"note": "",
"polygons": 40,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 69
},
"Body": {
"degenerateTris": 0,
"edgeRatioMax": 2.153788134245814,
"edgeRatioMin": 0.0842179761594005,
"maxInfluences": 4,
"note": "",
"polygons": 6227,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 6578
},
"Body-merged": {
"degenerateTris": 0,
"edgeRatioMax": 2.153788134245814,
"edgeRatioMin": 0.07910523007157484,
"maxInfluences": 4,
"note": "",
"polygons": 8423,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 9178
},
"BodyBottom": {
"degenerateTris": 0,
"edgeRatioMax": 0.5943739947353248,
"edgeRatioMin": 0.24871503022916608,
"maxInfluences": 4,
"note": "",
"polygons": 1242,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1194
},
"BodyFeet": {
"degenerateTris": 0,
"edgeRatioMax": 0.6102128302187958,
"edgeRatioMin": 0.23948013201029564,
"maxInfluences": 3,
"note": "",
"polygons": 1280,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1050
},
"BodyTop": {
"degenerateTris": 0,
"edgeRatioMax": 2.153788134245814,
"edgeRatioMin": 0.0842179761594005,
"maxInfluences": 4,
"note": "",
"polygons": 3701,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 3354
},
"BodyTopRobe": {
"degenerateTris": 0,
"edgeRatioMax": 2.153788134245814,
"edgeRatioMin": 0.07910523007157484,
"maxInfluences": 4,
"note": "",
"polygons": 4727,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 4383
},
"Body_shapes": {
"degenerateTris": 0,
"edgeRatioMax": 2.153788134245814,
"edgeRatioMin": 0.0842179761594005,
"maxInfluences": 4,
"note": "",
"polygons": 6223,
"quality": "warn",
"shapeKeys": 2,
"unweighted": 0,
"vertices": 5550
},
"Cylinder": {
"degenerateTris": 0,
"edgeRatioMax": 102.90996675755198,
"edgeRatioMin": 0.08829058454162445,
"maxInfluences": 0,
"note": "",
"polygons": 595,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 840,
"vertices": 840
},
"Face": {
"degenerateTris": 0,
"edgeRatioMax": 0.8507524157231209,
"edgeRatioMin": 0.8497737875813667,
"maxInfluences": 1,
"note": "",
"polygons": 3887,
"quality": "ok",
"shapeKeys": 57,
"unweighted": 0,
"vertices": 4109
},
"Hair": {
"degenerateTris": 0,
"edgeRatioMax": 0.8500001569254199,
"edgeRatioMin": 0.8499997162406219,
"maxInfluences": 1,
"note": "",
"polygons": 2,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 8
},
"Hair001": {
"degenerateTris": 0,
"edgeRatioMax": 0.8653125183616986,
"edgeRatioMin": 0.8197160422662443,
"maxInfluences": 1,
"note": "",
"polygons": 5658,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 8673
},
"Hair2": {
"degenerateTris": 0,
"edgeRatioMax": 0.8653125183616986,
"edgeRatioMin": 0.8197160422662443,
"maxInfluences": 2,
"note": "",
"polygons": 5658,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 8673
},
"HairBack": {
"degenerateTris": 0,
"edgeRatioMax": 1.4644834678498582,
"edgeRatioMin": 0.6099711906323935,
"maxInfluences": 4,
"note": "",
"polygons": 726,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 760
},
"OnepieceCloth": {
"degenerateTris": 0,
"edgeRatioMax": 0.5492324553724546,
"edgeRatioMin": 0.5290424031153619,
"maxInfluences": 3,
"note": "",
"polygons": 2,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 6
},
"Shoes": {
"degenerateTris": 0,
"edgeRatioMax": 0.5911305932486909,
"edgeRatioMin": 0.2363519411318045,
"maxInfluences": 3,
"note": "",
"polygons": 402,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 608
},
"Tops": {
"degenerateTris": 0,
"edgeRatioMax": 0.7959426635498152,
"edgeRatioMin": 0.07910523007157484,
"maxInfluences": 4,
"note": "",
"polygons": 1026,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1029
},
"bra1": {
"degenerateTris": 0,
"edgeRatioMax": 0.850017759730551,
"edgeRatioMin": 0.8499810981151088,
"maxInfluences": 1,
"note": "",
"polygons": 430,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 419
},
"bra2": {
"degenerateTris": 0,
"edgeRatioMax": 0.8500176808030321,
"edgeRatioMin": 0.8499719662092197,
"maxInfluences": 1,
"note": "",
"polygons": 506,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 524
},
"bra3": {
"degenerateTris": 0,
"edgeRatioMax": 0.8500183886242741,
"edgeRatioMin": 0.8499869218274825,
"maxInfluences": 1,
"note": "",
"polygons": 410,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 450
},
"bra4": {
"degenerateTris": 0,
"edgeRatioMax": 0.8500183886242741,
"edgeRatioMin": 0.8499869218274825,
"maxInfluences": 1,
"note": "",
"polygons": 410,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 450
},
"panties": {
"degenerateTris": 0,
"edgeRatioMax": 389.90044750135564,
"edgeRatioMin": 0.1344951283772418,
"maxInfluences": 0,
"note": "",
"polygons": 674,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 797,
"vertices": 797
},
"panties-full": {
"degenerateTris": 0,
"edgeRatioMax": 9.037905091399436,
"edgeRatioMin": 0.09492642747770344,
"maxInfluences": 0,
"note": "",
"polygons": 337,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 425,
"vertices": 425
},
"panties2": {
"degenerateTris": 0,
"edgeRatioMax": 73.44866646636162,
"edgeRatioMin": 0.039480502681632265,
"maxInfluences": 0,
"note": "",
"polygons": 1266,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 1328,
"vertices": 1328
},
"panties2-full": {
"degenerateTris": 0,
"edgeRatioMax": 69.03029060930521,
"edgeRatioMin": 0.12109997869211511,
"maxInfluences": 0,
"note": "",
"polygons": 507,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 558,
"vertices": 558
},
"panties3": {
"degenerateTris": 0,
"edgeRatioMax": 56.096226750526704,
"edgeRatioMin": 0.07317455801311457,
"maxInfluences": 0,
"note": "",
"polygons": 758,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 820,
"vertices": 820
},
"panties4": {
"degenerateTris": 0,
"edgeRatioMax": 59.134504340321854,
"edgeRatioMin": 0.07317455801311457,
"maxInfluences": 0,
"note": "",
"polygons": 690,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 752,
"vertices": 752
},
"panties5": {
"degenerateTris": 0,
"edgeRatioMax": 0.8500240796533016,
"edgeRatioMin": 0.8499814844536061,
"maxInfluences": 1,
"note": "",
"polygons": 698,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 758
},
"panties6": {
"degenerateTris": 0,
"edgeRatioMax": 0.8500154180994606,
"edgeRatioMin": 0.8499882205989492,
"maxInfluences": 1,
"note": "",
"polygons": 698,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 758
},
"panties7": {
"degenerateTris": 0,
"edgeRatioMax": 0.8500126347595567,
"edgeRatioMin": 0.849978243664894,
"maxInfluences": 1,
"note": "",
"polygons": 698,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 758
},
"shoes1": {
"degenerateTris": 0,
"edgeRatioMax": 1.5538312559806804,
"edgeRatioMin": 0.26002334716847597,
"maxInfluences": 3,
"note": "",
"polygons": 414,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 414
},
"skirt1": {
"degenerateTris": 0,
"edgeRatioMax": 0.5322821714343262,
"edgeRatioMin": 0.27078238670490873,
"maxInfluences": 3,
"note": "",
"polygons": 132,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 154
},
"skirt2": {
"degenerateTris": 0,
"edgeRatioMax": 0.5392819893864378,
"edgeRatioMin": 0.2767377972392573,
"maxInfluences": 3,
"note": "",
"polygons": 154,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 176
},
"skirt3": {
"degenerateTris": 0,
"edgeRatioMax": 0.5319295543088763,
"edgeRatioMin": 0.2734056437018691,
"maxInfluences": 3,
"note": "",
"polygons": 132,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 154
},
"top1": {
"degenerateTris": 0,
"edgeRatioMax": 0.8500186043293043,
"edgeRatioMin": 0.8499912680582199,
"maxInfluences": 1,
"note": "",
"polygons": 860,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 852
},
"top2": {
"degenerateTris": 0,
"edgeRatioMax": 0.8500086443486258,
"edgeRatioMin": 0.8499937873756263,
"maxInfluences": 1,
"note": "",
"polygons": 876,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 854
},
"top3": {
"degenerateTris": 0,
"edgeRatioMax": 0.8500086443486258,
"edgeRatioMin": 0.8499937873756263,
"maxInfluences": 1,
"note": "",
"polygons": 876,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 854
}
},
"profileHash": "dd6901d4220ae704",
"rig": {
"adultBones": 252,
"ageBones": 252,
"profile": {
"arm": 0.3,
"bodyRadial": 0.55,
"foot": 0.55,
"head": 0.85,
"headBulge": 1.08,
"hipHeight": 0.28,
"hipWidth": 0.55,
"leg": 0.26,
"limbRadial": 0.5,
"shoulderWidth": 0.5,
"torso": 0.3,
"walkSpeedScale": 0.0
},
"restOrientationErrors": []
},
"seams": {
"coincidentGroups": 3697,
"examples": [
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
0.0649,
0.0281,
1.3797
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
0.066,
0.0231,
1.3767
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
0.0694,
0.0172,
1.3783
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
-0.0649,
0.0281,
1.3797
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
-0.066,
0.0231,
1.3767
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
-0.0694,
0.0172,
1.3783
]
},
{
"gap": 2e-06,
"objects": [
"Hair2"
],
"position": [
0.0001,
-0.0385,
1.5529
]
},
{
"gap": 2e-06,
"objects": [
"Hair2"
],
"position": [
-0.0001,
-0.0385,
1.5529
]
},
{
"gap": 3e-06,
"objects": [
"Hair2"
],
"position": [
-0.0012,
-0.0342,
1.5629
]
},
{
"gap": 3e-06,
"objects": [
"Hair2"
],
"position": [
-0.001,
-0.0343,
1.5628
]
}
],
"maxGap": 5.9825688860243066e-06,
"snappedVertices": 95
},
"sex": "female",
"status": "warn",
"stem": "baby-female",
"timestamp": 1793419311,
"warnings": [
"Accessory",
"Body",
"Body-merged",
"Body_shapes",
"BodyBottom",
"BodyFeet",
"BodyTop",
"BodyTopRobe",
"Shoes",
"Tops",
"BodyTop",
"BodyBottom",
"Cylinder",
"panties",
"panties-full",
"panties2",
"panties2-full",
"panties3",
"panties4",
"skirt1",
"skirt2",
"skirt3",
"BodyFeet",
"shoes1"
]
}
@@ -0,0 +1,85 @@
# Conversion Report — baby / female
- Stem: `baby-female`
- Status: **warn**
- Profile hash: `dd6901d4220ae704`
- Objects: 37
- Warnings: 24
- Fails: 0
## Info
- adultArmature: `female`
- ageArmature: `baby-female`
- animations: `{'scaledLocationCurves': 7028, 'appended': ['Armature|mixamo.com|Layer0.020', 'Armature|mixamo.com|Layer0.018', 'Armature|mixamo.com|Layer0.016', 'Armature|mixamo.com|Layer0.014'], 'aliased': {'swimming-fast': 'swimming'}, 'missing': [], 'stem': 'baby-female'}`
- outputBase: `edited-normal-baby-female.blend`
- slots: `bottom,face,feet,hair,top`
- sourceBase: `edited-normal-female.blend`
## Seams
- coincidentGroups: `3697`
- snappedVertices: `95`
- maxGap: `5.9825688860243066e-06`
- examples: `[{'position': [0.0649, 0.0281, 1.3797], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.066, 0.0231, 1.3767], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0694, 0.0172, 1.3783], 'gap': 1e-06, 'objects': ['Face']}, {'position': [-0.0649, 0.0281, 1.3797], 'gap': 1e-06, 'objects': ['Face']}, {'position': [-0.066, 0.0231, 1.3767], 'gap': 1e-06, 'objects': ['Face']}, {'position': [-0.0694, 0.0172, 1.3783], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0001, -0.0385, 1.5529], 'gap': 2e-06, 'objects': ['Hair2']}, {'position': [-0.0001, -0.0385, 1.5529], 'gap': 2e-06, 'objects': ['Hair2']}, {'position': [-0.0012, -0.0342, 1.5629], 'gap': 3e-06, 'objects': ['Hair2']}, {'position': [-0.001, -0.0343, 1.5628], 'gap': 3e-06, 'objects': ['Hair2']}]`
## Warnings (first 20)
- `Accessory`
- `Body`
- `Body-merged`
- `Body_shapes`
- `BodyBottom`
- `BodyFeet`
- `BodyTop`
- `BodyTopRobe`
- `Shoes`
- `Tops`
- `BodyTop`
- `BodyBottom`
- `Cylinder`
- `panties`
- `panties-full`
- `panties2`
- `panties2-full`
- `panties3`
- `panties4`
- `skirt1`
## Objects
- `Accessory` — warn; verts=69, tris=0 degenerate, influences=4, unweighted=0, edge=0.32..0.74
- `Body` — warn; verts=6578, tris=0 degenerate, influences=4, unweighted=0, edge=0.08..2.15
- `Body-merged` — warn; verts=9178, tris=0 degenerate, influences=4, unweighted=0, edge=0.08..2.15
- `BodyBottom` — warn; verts=1194, tris=0 degenerate, influences=4, unweighted=0, edge=0.25..0.59
- `BodyFeet` — warn; verts=1050, tris=0 degenerate, influences=3, unweighted=0, edge=0.24..0.61
- `BodyTop` — warn; verts=3354, tris=0 degenerate, influences=4, unweighted=0, edge=0.08..2.15
- `BodyTopRobe` — warn; verts=4383, tris=0 degenerate, influences=4, unweighted=0, edge=0.08..2.15
- `Body_shapes` — warn; verts=5550, tris=0 degenerate, influences=4, unweighted=0, edge=0.08..2.15
- `Cylinder` — warn; verts=840, tris=0 degenerate, influences=0, unweighted=840, edge=0.09..102.91
- `Face` — ok; verts=4109, tris=0 degenerate, influences=1, unweighted=0, edge=0.85..0.85
- `Hair` — ok; verts=8, tris=0 degenerate, influences=1, unweighted=0, edge=0.85..0.85
- `Hair001` — ok; verts=8673, tris=0 degenerate, influences=1, unweighted=0, edge=0.82..0.87
- `Hair2` — ok; verts=8673, tris=0 degenerate, influences=2, unweighted=0, edge=0.82..0.87
- `HairBack` — ok; verts=760, tris=0 degenerate, influences=4, unweighted=0, edge=0.61..1.46
- `OnepieceCloth` — ok; verts=6, tris=0 degenerate, influences=3, unweighted=0, edge=0.53..0.55
- `Shoes` — warn; verts=608, tris=0 degenerate, influences=3, unweighted=0, edge=0.24..0.59
- `Tops` — warn; verts=1029, tris=0 degenerate, influences=4, unweighted=0, edge=0.08..0.80
- `bra1` — ok; verts=419, tris=0 degenerate, influences=1, unweighted=0, edge=0.85..0.85
- `bra2` — ok; verts=524, tris=0 degenerate, influences=1, unweighted=0, edge=0.85..0.85
- `bra3` — ok; verts=450, tris=0 degenerate, influences=1, unweighted=0, edge=0.85..0.85
- `bra4` — ok; verts=450, tris=0 degenerate, influences=1, unweighted=0, edge=0.85..0.85
- `panties` — warn; verts=797, tris=0 degenerate, influences=0, unweighted=797, edge=0.13..389.90
- `panties-full` — warn; verts=425, tris=0 degenerate, influences=0, unweighted=425, edge=0.09..9.04
- `panties2` — warn; verts=1328, tris=0 degenerate, influences=0, unweighted=1328, edge=0.04..73.45
- `panties2-full` — warn; verts=558, tris=0 degenerate, influences=0, unweighted=558, edge=0.12..69.03
- `panties3` — warn; verts=820, tris=0 degenerate, influences=0, unweighted=820, edge=0.07..56.10
- `panties4` — warn; verts=752, tris=0 degenerate, influences=0, unweighted=752, edge=0.07..59.13
- `panties5` — ok; verts=758, tris=0 degenerate, influences=1, unweighted=0, edge=0.85..0.85
- `panties6` — ok; verts=758, tris=0 degenerate, influences=1, unweighted=0, edge=0.85..0.85
- `panties7` — ok; verts=758, tris=0 degenerate, influences=1, unweighted=0, edge=0.85..0.85
- `shoes1` — warn; verts=414, tris=0 degenerate, influences=3, unweighted=0, edge=0.26..1.55
- `skirt1` — warn; verts=154, tris=0 degenerate, influences=3, unweighted=0, edge=0.27..0.53
- `skirt2` — warn; verts=176, tris=0 degenerate, influences=3, unweighted=0, edge=0.28..0.54
- `skirt3` — warn; verts=154, tris=0 degenerate, influences=3, unweighted=0, edge=0.27..0.53
- `top1` — ok; verts=852, tris=0 degenerate, influences=1, unweighted=0, edge=0.85..0.85
- `top2` — ok; verts=854, tris=0 degenerate, influences=1, unweighted=0, edge=0.85..0.85
- `top3` — ok; verts=854, tris=0 degenerate, influences=1, unweighted=0, edge=0.85..0.85
@@ -0,0 +1,491 @@
{
"age": "baby",
"fails": [],
"info": {
"adultArmature": "male",
"ageArmature": "baby-male",
"animations": {
"aliased": {
"swimming-fast": "swimming"
},
"appended": [
"Armature|mixamo.com|Layer0.020",
"Armature|mixamo.com|Layer0.018",
"Armature|mixamo.com|Layer0.016",
"Armature|mixamo.com|Layer0.014",
"Armature|mixamo.com|Layer0.012"
],
"missing": [],
"scaledLocationCurves": 7601,
"stem": "baby-male"
},
"outputBase": "edited-normal-baby-male.blend",
"slots": "bottom,face,feet,hair,top",
"sourceBase": "edited-normal-male.blend"
},
"objects": {
"Accessory": {
"degenerateTris": 0,
"edgeRatioMax": 0.6591322275489071,
"edgeRatioMin": 0.2294774279830071,
"maxInfluences": 4,
"note": "",
"polygons": 111,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 145
},
"Body": {
"degenerateTris": 0,
"edgeRatioMax": 2.7944548668408027,
"edgeRatioMin": 0.09792195397903704,
"maxInfluences": 4,
"note": "",
"polygons": 6232,
"quality": "warn",
"shapeKeys": 1,
"unweighted": 0,
"vertices": 6584
},
"BodyBottom": {
"degenerateTris": 0,
"edgeRatioMax": 0.696915448832056,
"edgeRatioMin": 0.2475168552675534,
"maxInfluences": 4,
"note": "",
"polygons": 1228,
"quality": "warn",
"shapeKeys": 1,
"unweighted": 0,
"vertices": 1181
},
"BodyBottomPants": {
"degenerateTris": 0,
"edgeRatioMax": 0.8123872063883257,
"edgeRatioMin": 0.2475168552675534,
"maxInfluences": 4,
"note": "",
"polygons": 972,
"quality": "warn",
"shapeKeys": 1,
"unweighted": 0,
"vertices": 1133
},
"BodyFeet": {
"degenerateTris": 0,
"edgeRatioMax": 0.6102393851343675,
"edgeRatioMin": 0.22574066520587047,
"maxInfluences": 3,
"note": "",
"polygons": 1274,
"quality": "warn",
"shapeKeys": 1,
"unweighted": 0,
"vertices": 1392
},
"BodyFeetPants": {
"degenerateTris": 48,
"edgeRatioMax": 0.6102393851343675,
"edgeRatioMin": 0.2095881183386803,
"maxInfluences": 3,
"note": "",
"polygons": 1466,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1602
},
"BodyFeetPantsShoes": {
"degenerateTris": 48,
"edgeRatioMax": 0.6017141327128214,
"edgeRatioMin": 0.2095881183386803,
"maxInfluences": 3,
"note": "",
"polygons": 830,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1048
},
"BodyTop": {
"degenerateTris": 0,
"edgeRatioMax": 2.7944548668408027,
"edgeRatioMin": 0.09792195397903704,
"maxInfluences": 4,
"note": "",
"polygons": 3726,
"quality": "warn",
"shapeKeys": 1,
"unweighted": 0,
"vertices": 3373
},
"BodyTopRobe": {
"degenerateTris": 0,
"edgeRatioMax": 2.694739629684512,
"edgeRatioMin": 0.04884643632625867,
"maxInfluences": 4,
"note": "",
"polygons": 5384,
"quality": "warn",
"shapeKeys": 1,
"unweighted": 0,
"vertices": 5046
},
"Body_shapes": {
"degenerateTris": 0,
"edgeRatioMax": 2.7944548668408027,
"edgeRatioMin": 0.09792195397903704,
"maxInfluences": 4,
"note": "",
"polygons": 6228,
"quality": "warn",
"shapeKeys": 2,
"unweighted": 0,
"vertices": 5556
},
"Bottoms": {
"degenerateTris": 0,
"edgeRatioMax": 0.8123872063883257,
"edgeRatioMin": 0.2095881183386803,
"maxInfluences": 4,
"note": "",
"polygons": 826,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 942
},
"Face": {
"degenerateTris": 0,
"edgeRatioMax": 0.8501110244008585,
"edgeRatioMin": 0.8499080602231018,
"maxInfluences": 1,
"note": "",
"polygons": 3839,
"quality": "ok",
"shapeKeys": 57,
"unweighted": 0,
"vertices": 4045
},
"Hair": {
"degenerateTris": 0,
"edgeRatioMax": 0.8500001087034722,
"edgeRatioMin": 0.8499998996166309,
"maxInfluences": 1,
"note": "",
"polygons": 4,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 9
},
"Hair2": {
"degenerateTris": 0,
"edgeRatioMax": 0.8932439134046963,
"edgeRatioMin": 0.8193953530826958,
"maxInfluences": 2,
"note": "",
"polygons": 4483,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 7022
},
"HairBack": {
"degenerateTris": 0,
"edgeRatioMax": 1.4800221295791642,
"edgeRatioMin": 0.6036557832095256,
"maxInfluences": 4,
"note": "",
"polygons": 724,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 760
},
"Panties001": {
"degenerateTris": 0,
"edgeRatioMax": 0.6962243553417347,
"edgeRatioMin": 0.2658767663648385,
"maxInfluences": 4,
"note": "",
"polygons": 604,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 582
},
"Pants0001": {
"degenerateTris": 0,
"edgeRatioMax": 1.651774029445214,
"edgeRatioMin": 0.2253732629144712,
"maxInfluences": 4,
"note": "",
"polygons": 898,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 915
},
"Shoes": {
"degenerateTris": 0,
"edgeRatioMax": 0.6017141327128214,
"edgeRatioMin": 0.39686482033435044,
"maxInfluences": 3,
"note": "",
"polygons": 334,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 478
},
"Tops": {
"degenerateTris": 0,
"edgeRatioMax": 1.6309442432828267,
"edgeRatioMin": 0.04884643632625867,
"maxInfluences": 4,
"note": "",
"polygons": 1658,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1896
},
"TopsEdited": {
"degenerateTris": 0,
"edgeRatioMax": 1.6309442432828267,
"edgeRatioMin": 0.04884643632625867,
"maxInfluences": 4,
"note": "",
"polygons": 1658,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1896
},
"coat": {
"degenerateTris": 0,
"edgeRatioMax": 1.5376462038278786,
"edgeRatioMin": 0.14071060392477913,
"maxInfluences": 4,
"note": "",
"polygons": 1658,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1896
},
"hair0001": {
"degenerateTris": 0,
"edgeRatioMax": 2.322546280419375,
"edgeRatioMin": 0.6481636259388702,
"maxInfluences": 4,
"note": "",
"polygons": 4483,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 7022
},
"hair0002": {
"degenerateTris": 0,
"edgeRatioMax": 2.322546280419375,
"edgeRatioMin": 0.6481636259388702,
"maxInfluences": 4,
"note": "",
"polygons": 4483,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 7022
},
"hair0003": {
"degenerateTris": 0,
"edgeRatioMax": 1.0,
"edgeRatioMin": 1.0,
"maxInfluences": 0,
"note": "",
"polygons": 4483,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 7022
},
"shoes1": {
"degenerateTris": 0,
"edgeRatioMax": 0.5819192631554228,
"edgeRatioMin": 0.36626946181073133,
"maxInfluences": 3,
"note": "",
"polygons": 334,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 478
}
},
"profileHash": "dd6901d4220ae704",
"rig": {
"adultBones": 242,
"ageBones": 242,
"profile": {
"arm": 0.3,
"bodyRadial": 0.55,
"foot": 0.55,
"head": 0.85,
"headBulge": 1.08,
"hipHeight": 0.28,
"hipWidth": 0.55,
"leg": 0.26,
"limbRadial": 0.5,
"shoulderWidth": 0.5,
"torso": 0.3,
"walkSpeedScale": 0.0
},
"restOrientationErrors": []
},
"seams": {
"coincidentGroups": 5119,
"examples": [
{
"gap": 1e-06,
"objects": [
"BodyTop",
"BodyTopRobe"
],
"position": [
0.0,
-0.0184,
1.5008
]
},
{
"gap": 1e-06,
"objects": [
"BodyTop",
"Face"
],
"position": [
0.0633,
0.0369,
1.6496
]
},
{
"gap": 1e-06,
"objects": [
"BodyTop",
"Face"
],
"position": [
-0.0633,
0.0369,
1.6496
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
0.0636,
0.0319,
1.6457
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
0.0698,
0.021,
1.649
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
0.0716,
0.0206,
1.6529
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
-0.0636,
0.0319,
1.6457
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
-0.0698,
0.021,
1.649
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
-0.0716,
0.0206,
1.6529
]
},
{
"gap": 2e-06,
"objects": [
"Hair2"
],
"position": [
0.0002,
-0.0352,
1.8214
]
}
],
"maxGap": 6.834857277971055e-06,
"snappedVertices": 204
},
"sex": "male",
"status": "warn",
"stem": "baby-male",
"timestamp": 1793419306,
"warnings": [
"Accessory",
"Body",
"Body_shapes",
"BodyBottom",
"BodyBottomPants",
"BodyFeet",
"BodyFeetPants",
"BodyFeetPantsShoes",
"BodyTop",
"BodyTopRobe",
"Bottoms",
"Tops",
"TopsEdited",
"coat",
"Panties001",
"Pants0001",
"BodyFeet"
]
}
@@ -0,0 +1,70 @@
# Conversion Report — baby / male
- Stem: `baby-male`
- Status: **warn**
- Profile hash: `dd6901d4220ae704`
- Objects: 25
- Warnings: 17
- Fails: 0
## Info
- adultArmature: `male`
- ageArmature: `baby-male`
- animations: `{'scaledLocationCurves': 7601, 'appended': ['Armature|mixamo.com|Layer0.020', 'Armature|mixamo.com|Layer0.018', 'Armature|mixamo.com|Layer0.016', 'Armature|mixamo.com|Layer0.014', 'Armature|mixamo.com|Layer0.012'], 'aliased': {'swimming-fast': 'swimming'}, 'missing': [], 'stem': 'baby-male'}`
- outputBase: `edited-normal-baby-male.blend`
- slots: `bottom,face,feet,hair,top`
- sourceBase: `edited-normal-male.blend`
## Seams
- coincidentGroups: `5119`
- snappedVertices: `204`
- maxGap: `6.834857277971055e-06`
- examples: `[{'position': [0.0, -0.0184, 1.5008], 'gap': 1e-06, 'objects': ['BodyTop', 'BodyTopRobe']}, {'position': [0.0633, 0.0369, 1.6496], 'gap': 1e-06, 'objects': ['BodyTop', 'Face']}, {'position': [-0.0633, 0.0369, 1.6496], 'gap': 1e-06, 'objects': ['BodyTop', 'Face']}, {'position': [0.0636, 0.0319, 1.6457], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0698, 0.021, 1.649], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0716, 0.0206, 1.6529], 'gap': 1e-06, 'objects': ['Face']}, {'position': [-0.0636, 0.0319, 1.6457], 'gap': 1e-06, 'objects': ['Face']}, {'position': [-0.0698, 0.021, 1.649], 'gap': 1e-06, 'objects': ['Face']}, {'position': [-0.0716, 0.0206, 1.6529], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0002, -0.0352, 1.8214], 'gap': 2e-06, 'objects': ['Hair2']}]`
## Warnings (first 20)
- `Accessory`
- `Body`
- `Body_shapes`
- `BodyBottom`
- `BodyBottomPants`
- `BodyFeet`
- `BodyFeetPants`
- `BodyFeetPantsShoes`
- `BodyTop`
- `BodyTopRobe`
- `Bottoms`
- `Tops`
- `TopsEdited`
- `coat`
- `Panties001`
- `Pants0001`
- `BodyFeet`
## Objects
- `Accessory` — warn; verts=145, tris=0 degenerate, influences=4, unweighted=0, edge=0.23..0.66
- `Body` — warn; verts=6584, tris=0 degenerate, influences=4, unweighted=0, edge=0.10..2.79
- `BodyBottom` — warn; verts=1181, tris=0 degenerate, influences=4, unweighted=0, edge=0.25..0.70
- `BodyBottomPants` — warn; verts=1133, tris=0 degenerate, influences=4, unweighted=0, edge=0.25..0.81
- `BodyFeet` — warn; verts=1392, tris=0 degenerate, influences=3, unweighted=0, edge=0.23..0.61
- `BodyFeetPants` — warn; verts=1602, tris=48 degenerate, influences=3, unweighted=0, edge=0.21..0.61
- `BodyFeetPantsShoes` — warn; verts=1048, tris=48 degenerate, influences=3, unweighted=0, edge=0.21..0.60
- `BodyTop` — warn; verts=3373, tris=0 degenerate, influences=4, unweighted=0, edge=0.10..2.79
- `BodyTopRobe` — warn; verts=5046, tris=0 degenerate, influences=4, unweighted=0, edge=0.05..2.69
- `Body_shapes` — warn; verts=5556, tris=0 degenerate, influences=4, unweighted=0, edge=0.10..2.79
- `Bottoms` — warn; verts=942, tris=0 degenerate, influences=4, unweighted=0, edge=0.21..0.81
- `Face` — ok; verts=4045, tris=0 degenerate, influences=1, unweighted=0, edge=0.85..0.85
- `Hair` — ok; verts=9, tris=0 degenerate, influences=1, unweighted=0, edge=0.85..0.85
- `Hair2` — ok; verts=7022, tris=0 degenerate, influences=2, unweighted=0, edge=0.82..0.89
- `HairBack` — ok; verts=760, tris=0 degenerate, influences=4, unweighted=0, edge=0.60..1.48
- `Panties001` — warn; verts=582, tris=0 degenerate, influences=4, unweighted=0, edge=0.27..0.70
- `Pants0001` — warn; verts=915, tris=0 degenerate, influences=4, unweighted=0, edge=0.23..1.65
- `Shoes` — ok; verts=478, tris=0 degenerate, influences=3, unweighted=0, edge=0.40..0.60
- `Tops` — warn; verts=1896, tris=0 degenerate, influences=4, unweighted=0, edge=0.05..1.63
- `TopsEdited` — warn; verts=1896, tris=0 degenerate, influences=4, unweighted=0, edge=0.05..1.63
- `coat` — warn; verts=1896, tris=0 degenerate, influences=4, unweighted=0, edge=0.14..1.54
- `hair0001` — ok; verts=7022, tris=0 degenerate, influences=4, unweighted=0, edge=0.65..2.32
- `hair0002` — ok; verts=7022, tris=0 degenerate, influences=4, unweighted=0, edge=0.65..2.32
- `hair0003` — ok; verts=7022, tris=0 degenerate, influences=0, unweighted=0, edge=1.00..1.00
- `shoes1` — ok; verts=478, tris=0 degenerate, influences=3, unweighted=0, edge=0.37..0.58
@@ -0,0 +1,627 @@
{
"age": "child",
"fails": [],
"info": {
"adultArmature": "female",
"ageArmature": "child-female",
"animations": {
"aliased": {
"swimming-fast": "swimming"
},
"appended": [
"Armature|mixamo.com|Layer0.020",
"Armature|mixamo.com|Layer0.018",
"Armature|mixamo.com|Layer0.016",
"Armature|mixamo.com|Layer0.014"
],
"missing": [],
"scaledLocationCurves": 7028,
"stem": "child-female"
},
"outputBase": "edited-normal-child-female.blend",
"slots": "bottom,face,feet,hair,top",
"sourceBase": "edited-normal-female.blend"
},
"objects": {
"Accessory": {
"degenerateTris": 0,
"edgeRatioMax": 0.7898433968486418,
"edgeRatioMin": 0.49563877021449726,
"maxInfluences": 4,
"note": "",
"polygons": 40,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 69
},
"Body": {
"degenerateTris": 0,
"edgeRatioMax": 1.5936254360808924,
"edgeRatioMin": 0.35974842621764863,
"maxInfluences": 4,
"note": "",
"polygons": 6227,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 6578
},
"Body-merged": {
"degenerateTris": 0,
"edgeRatioMax": 1.5936254360808924,
"edgeRatioMin": 0.35974842621764863,
"maxInfluences": 4,
"note": "",
"polygons": 8423,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 9178
},
"BodyBottom": {
"degenerateTris": 0,
"edgeRatioMax": 0.7850192237493911,
"edgeRatioMin": 0.5419409362099876,
"maxInfluences": 4,
"note": "",
"polygons": 1242,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1194
},
"BodyFeet": {
"degenerateTris": 0,
"edgeRatioMax": 0.7839441718995033,
"edgeRatioMin": 0.5345308894982719,
"maxInfluences": 3,
"note": "",
"polygons": 1280,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1050
},
"BodyTop": {
"degenerateTris": 0,
"edgeRatioMax": 1.5936254360808924,
"edgeRatioMin": 0.35974842621764863,
"maxInfluences": 4,
"note": "",
"polygons": 3701,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 3354
},
"BodyTopRobe": {
"degenerateTris": 0,
"edgeRatioMax": 1.5936254360808924,
"edgeRatioMin": 0.35974842621764863,
"maxInfluences": 4,
"note": "",
"polygons": 4727,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 4383
},
"Body_shapes": {
"degenerateTris": 0,
"edgeRatioMax": 1.5936254360808924,
"edgeRatioMin": 0.35974842621764863,
"maxInfluences": 4,
"note": "",
"polygons": 6223,
"quality": "ok",
"shapeKeys": 2,
"unweighted": 0,
"vertices": 5550
},
"Cylinder": {
"degenerateTris": 0,
"edgeRatioMax": 55.52669888099699,
"edgeRatioMin": 0.3728675302592677,
"maxInfluences": 0,
"note": "",
"polygons": 595,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 840,
"vertices": 840
},
"Face": {
"degenerateTris": 0,
"edgeRatioMax": 0.9209191562868269,
"edgeRatioMin": 0.9194440137740598,
"maxInfluences": 1,
"note": "",
"polygons": 3887,
"quality": "ok",
"shapeKeys": 57,
"unweighted": 0,
"vertices": 4109
},
"Hair": {
"degenerateTris": 0,
"edgeRatioMax": 0.9200007036314336,
"edgeRatioMin": 0.9199996884063618,
"maxInfluences": 1,
"note": "",
"polygons": 2,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 8
},
"Hair001": {
"degenerateTris": 0,
"edgeRatioMax": 0.9580249853514248,
"edgeRatioMin": 0.8921870820834138,
"maxInfluences": 1,
"note": "",
"polygons": 5658,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 8673
},
"Hair2": {
"degenerateTris": 0,
"edgeRatioMax": 0.9580249853514248,
"edgeRatioMin": 0.8921870820834138,
"maxInfluences": 2,
"note": "",
"polygons": 5658,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 8673
},
"HairBack": {
"degenerateTris": 0,
"edgeRatioMax": 1.243301152005224,
"edgeRatioMin": 0.7996069035600766,
"maxInfluences": 4,
"note": "",
"polygons": 726,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 760
},
"OnepieceCloth": {
"degenerateTris": 0,
"edgeRatioMax": 0.7795104096985646,
"edgeRatioMin": 0.7657916805350437,
"maxInfluences": 3,
"note": "",
"polygons": 2,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 6
},
"Shoes": {
"degenerateTris": 0,
"edgeRatioMax": 0.7710505397361299,
"edgeRatioMin": 0.5274789367897975,
"maxInfluences": 3,
"note": "",
"polygons": 402,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 608
},
"Tops": {
"degenerateTris": 0,
"edgeRatioMax": 0.8665602233865596,
"edgeRatioMin": 0.44064144963083524,
"maxInfluences": 4,
"note": "",
"polygons": 1026,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1029
},
"bra1": {
"degenerateTris": 0,
"edgeRatioMax": 0.920044969946938,
"edgeRatioMin": 0.9199654645832204,
"maxInfluences": 1,
"note": "",
"polygons": 430,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 419
},
"bra2": {
"degenerateTris": 0,
"edgeRatioMax": 0.9200462644396492,
"edgeRatioMin": 0.9199754607547745,
"maxInfluences": 1,
"note": "",
"polygons": 506,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 524
},
"bra3": {
"degenerateTris": 0,
"edgeRatioMax": 0.9200246096786325,
"edgeRatioMin": 0.9199754607547745,
"maxInfluences": 1,
"note": "",
"polygons": 410,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 450
},
"bra4": {
"degenerateTris": 0,
"edgeRatioMax": 0.9200246096786325,
"edgeRatioMin": 0.9199754607547745,
"maxInfluences": 1,
"note": "",
"polygons": 410,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 450
},
"panties": {
"degenerateTris": 0,
"edgeRatioMax": 211.10052027794262,
"edgeRatioMin": 0.19951643125297738,
"maxInfluences": 0,
"note": "",
"polygons": 674,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 797,
"vertices": 797
},
"panties-full": {
"degenerateTris": 0,
"edgeRatioMax": 7.322137784611612,
"edgeRatioMin": 0.21340035981963004,
"maxInfluences": 0,
"note": "",
"polygons": 337,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 425,
"vertices": 425
},
"panties2": {
"degenerateTris": 0,
"edgeRatioMax": 39.470945630173105,
"edgeRatioMin": 0.21985285672118582,
"maxInfluences": 0,
"note": "",
"polygons": 1266,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 1328,
"vertices": 1328
},
"panties2-full": {
"degenerateTris": 0,
"edgeRatioMax": 36.99110437234406,
"edgeRatioMin": 0.10284348688443892,
"maxInfluences": 0,
"note": "",
"polygons": 507,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 558,
"vertices": 558
},
"panties3": {
"degenerateTris": 0,
"edgeRatioMax": 30.204732645010235,
"edgeRatioMin": 0.20627087124391974,
"maxInfluences": 0,
"note": "",
"polygons": 758,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 820,
"vertices": 820
},
"panties4": {
"degenerateTris": 0,
"edgeRatioMax": 31.683176789997283,
"edgeRatioMin": 0.1989474077553321,
"maxInfluences": 0,
"note": "",
"polygons": 690,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 752,
"vertices": 752
},
"panties5": {
"degenerateTris": 0,
"edgeRatioMax": 5.895118610080954,
"edgeRatioMin": 0.6048716072518844,
"maxInfluences": 3,
"note": "",
"polygons": 698,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 758
},
"panties6": {
"degenerateTris": 0,
"edgeRatioMax": 7.880647390006613,
"edgeRatioMin": 0.6250818099712813,
"maxInfluences": 3,
"note": "",
"polygons": 698,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 758
},
"panties7": {
"degenerateTris": 0,
"edgeRatioMax": 7.880647390006613,
"edgeRatioMin": 0.6400929469228386,
"maxInfluences": 3,
"note": "",
"polygons": 698,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 758
},
"shoes1": {
"degenerateTris": 0,
"edgeRatioMax": 0.9595848242017094,
"edgeRatioMin": 0.5492882605967488,
"maxInfluences": 3,
"note": "",
"polygons": 414,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 414
},
"skirt1": {
"degenerateTris": 0,
"edgeRatioMax": 1.3290984484841972,
"edgeRatioMin": 0.3753802859501542,
"maxInfluences": 4,
"note": "",
"polygons": 132,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 154
},
"skirt2": {
"degenerateTris": 0,
"edgeRatioMax": 1.4244777332130143,
"edgeRatioMin": 0.3753802859501542,
"maxInfluences": 4,
"note": "",
"polygons": 154,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 176
},
"skirt3": {
"degenerateTris": 0,
"edgeRatioMax": 1.268563086876801,
"edgeRatioMin": 0.37917544169158424,
"maxInfluences": 4,
"note": "",
"polygons": 132,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 154
},
"top1": {
"degenerateTris": 0,
"edgeRatioMax": 7.0781828943354546,
"edgeRatioMin": 0.7089442731001407,
"maxInfluences": 1,
"note": "",
"polygons": 860,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 852
},
"top2": {
"degenerateTris": 0,
"edgeRatioMax": 7.0781828943354546,
"edgeRatioMin": 0.7089442731001407,
"maxInfluences": 1,
"note": "",
"polygons": 876,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 854
},
"top3": {
"degenerateTris": 0,
"edgeRatioMax": 7.0781828943354546,
"edgeRatioMin": 0.7089442731001407,
"maxInfluences": 1,
"note": "",
"polygons": 876,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 854
}
},
"profileHash": "dd6901d4220ae704",
"rig": {
"adultBones": 252,
"ageBones": 252,
"profile": {
"arm": 0.6,
"bodyRadial": 0.78,
"foot": 0.75,
"head": 0.92,
"headBulge": 1.03,
"hipHeight": 0.55,
"hipWidth": 0.72,
"leg": 0.55,
"limbRadial": 0.72,
"shoulderWidth": 0.7,
"torso": 0.62,
"walkSpeedScale": 0.75
},
"restOrientationErrors": []
},
"seams": {
"coincidentGroups": 3697,
"examples": [
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
0.0649,
0.0281,
1.3797
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
0.066,
0.0231,
1.3767
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
0.0694,
0.0172,
1.3783
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
-0.0649,
0.0281,
1.3797
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
-0.066,
0.0231,
1.3767
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
-0.0694,
0.0172,
1.3783
]
},
{
"gap": 2e-06,
"objects": [
"Hair2"
],
"position": [
0.0001,
-0.0385,
1.5529
]
},
{
"gap": 2e-06,
"objects": [
"Hair2"
],
"position": [
-0.0001,
-0.0385,
1.5529
]
},
{
"gap": 3e-06,
"objects": [
"Hair2"
],
"position": [
-0.0012,
-0.0342,
1.5629
]
},
{
"gap": 3e-06,
"objects": [
"Hair2"
],
"position": [
-0.001,
-0.0343,
1.5628
]
}
],
"maxGap": 6.470887154236068e-06,
"snappedVertices": 95
},
"sex": "female",
"status": "warn",
"stem": "child-female",
"timestamp": 1793419297,
"warnings": [
"top1",
"top2",
"top3",
"Cylinder",
"panties",
"panties-full",
"panties2",
"panties2-full",
"panties3",
"panties4",
"panties5",
"panties6",
"panties7"
]
}
@@ -0,0 +1,78 @@
# Conversion Report — child / female
- Stem: `child-female`
- Status: **warn**
- Profile hash: `dd6901d4220ae704`
- Objects: 37
- Warnings: 13
- Fails: 0
## Info
- adultArmature: `female`
- ageArmature: `child-female`
- animations: `{'scaledLocationCurves': 7028, 'appended': ['Armature|mixamo.com|Layer0.020', 'Armature|mixamo.com|Layer0.018', 'Armature|mixamo.com|Layer0.016', 'Armature|mixamo.com|Layer0.014'], 'aliased': {'swimming-fast': 'swimming'}, 'missing': [], 'stem': 'child-female'}`
- outputBase: `edited-normal-child-female.blend`
- slots: `bottom,face,feet,hair,top`
- sourceBase: `edited-normal-female.blend`
## Seams
- coincidentGroups: `3697`
- snappedVertices: `95`
- maxGap: `6.470887154236068e-06`
- examples: `[{'position': [0.0649, 0.0281, 1.3797], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.066, 0.0231, 1.3767], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0694, 0.0172, 1.3783], 'gap': 1e-06, 'objects': ['Face']}, {'position': [-0.0649, 0.0281, 1.3797], 'gap': 1e-06, 'objects': ['Face']}, {'position': [-0.066, 0.0231, 1.3767], 'gap': 1e-06, 'objects': ['Face']}, {'position': [-0.0694, 0.0172, 1.3783], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0001, -0.0385, 1.5529], 'gap': 2e-06, 'objects': ['Hair2']}, {'position': [-0.0001, -0.0385, 1.5529], 'gap': 2e-06, 'objects': ['Hair2']}, {'position': [-0.0012, -0.0342, 1.5629], 'gap': 3e-06, 'objects': ['Hair2']}, {'position': [-0.001, -0.0343, 1.5628], 'gap': 3e-06, 'objects': ['Hair2']}]`
## Warnings (first 20)
- `top1`
- `top2`
- `top3`
- `Cylinder`
- `panties`
- `panties-full`
- `panties2`
- `panties2-full`
- `panties3`
- `panties4`
- `panties5`
- `panties6`
- `panties7`
## Objects
- `Accessory` — ok; verts=69, tris=0 degenerate, influences=4, unweighted=0, edge=0.50..0.79
- `Body` — ok; verts=6578, tris=0 degenerate, influences=4, unweighted=0, edge=0.36..1.59
- `Body-merged` — ok; verts=9178, tris=0 degenerate, influences=4, unweighted=0, edge=0.36..1.59
- `BodyBottom` — ok; verts=1194, tris=0 degenerate, influences=4, unweighted=0, edge=0.54..0.79
- `BodyFeet` — ok; verts=1050, tris=0 degenerate, influences=3, unweighted=0, edge=0.53..0.78
- `BodyTop` — ok; verts=3354, tris=0 degenerate, influences=4, unweighted=0, edge=0.36..1.59
- `BodyTopRobe` — ok; verts=4383, tris=0 degenerate, influences=4, unweighted=0, edge=0.36..1.59
- `Body_shapes` — ok; verts=5550, tris=0 degenerate, influences=4, unweighted=0, edge=0.36..1.59
- `Cylinder` — warn; verts=840, tris=0 degenerate, influences=0, unweighted=840, edge=0.37..55.53
- `Face` — ok; verts=4109, tris=0 degenerate, influences=1, unweighted=0, edge=0.92..0.92
- `Hair` — ok; verts=8, tris=0 degenerate, influences=1, unweighted=0, edge=0.92..0.92
- `Hair001` — ok; verts=8673, tris=0 degenerate, influences=1, unweighted=0, edge=0.89..0.96
- `Hair2` — ok; verts=8673, tris=0 degenerate, influences=2, unweighted=0, edge=0.89..0.96
- `HairBack` — ok; verts=760, tris=0 degenerate, influences=4, unweighted=0, edge=0.80..1.24
- `OnepieceCloth` — ok; verts=6, tris=0 degenerate, influences=3, unweighted=0, edge=0.77..0.78
- `Shoes` — ok; verts=608, tris=0 degenerate, influences=3, unweighted=0, edge=0.53..0.77
- `Tops` — ok; verts=1029, tris=0 degenerate, influences=4, unweighted=0, edge=0.44..0.87
- `bra1` — ok; verts=419, tris=0 degenerate, influences=1, unweighted=0, edge=0.92..0.92
- `bra2` — ok; verts=524, tris=0 degenerate, influences=1, unweighted=0, edge=0.92..0.92
- `bra3` — ok; verts=450, tris=0 degenerate, influences=1, unweighted=0, edge=0.92..0.92
- `bra4` — ok; verts=450, tris=0 degenerate, influences=1, unweighted=0, edge=0.92..0.92
- `panties` — warn; verts=797, tris=0 degenerate, influences=0, unweighted=797, edge=0.20..211.10
- `panties-full` — warn; verts=425, tris=0 degenerate, influences=0, unweighted=425, edge=0.21..7.32
- `panties2` — warn; verts=1328, tris=0 degenerate, influences=0, unweighted=1328, edge=0.22..39.47
- `panties2-full` — warn; verts=558, tris=0 degenerate, influences=0, unweighted=558, edge=0.10..36.99
- `panties3` — warn; verts=820, tris=0 degenerate, influences=0, unweighted=820, edge=0.21..30.20
- `panties4` — warn; verts=752, tris=0 degenerate, influences=0, unweighted=752, edge=0.20..31.68
- `panties5` — warn; verts=758, tris=0 degenerate, influences=3, unweighted=0, edge=0.60..5.90
- `panties6` — warn; verts=758, tris=0 degenerate, influences=3, unweighted=0, edge=0.63..7.88
- `panties7` — warn; verts=758, tris=0 degenerate, influences=3, unweighted=0, edge=0.64..7.88
- `shoes1` — ok; verts=414, tris=0 degenerate, influences=3, unweighted=0, edge=0.55..0.96
- `skirt1` — ok; verts=154, tris=0 degenerate, influences=4, unweighted=0, edge=0.38..1.33
- `skirt2` — ok; verts=176, tris=0 degenerate, influences=4, unweighted=0, edge=0.38..1.42
- `skirt3` — ok; verts=154, tris=0 degenerate, influences=4, unweighted=0, edge=0.38..1.27
- `top1` — warn; verts=852, tris=0 degenerate, influences=1, unweighted=0, edge=0.71..7.08
- `top2` — warn; verts=854, tris=0 degenerate, influences=1, unweighted=0, edge=0.71..7.08
- `top3` — warn; verts=854, tris=0 degenerate, influences=1, unweighted=0, edge=0.71..7.08
@@ -0,0 +1,485 @@
{
"age": "child",
"fails": [],
"info": {
"adultArmature": "male",
"ageArmature": "child-male",
"animations": {
"aliased": {
"swimming-fast": "swimming"
},
"appended": [
"Armature|mixamo.com|Layer0.020",
"Armature|mixamo.com|Layer0.018",
"Armature|mixamo.com|Layer0.016",
"Armature|mixamo.com|Layer0.014",
"Armature|mixamo.com|Layer0.012"
],
"missing": [],
"scaledLocationCurves": 7601,
"stem": "child-male"
},
"outputBase": "edited-normal-child-male.blend",
"slots": "bottom,face,feet,hair,top",
"sourceBase": "edited-normal-male.blend"
},
"objects": {
"Accessory": {
"degenerateTris": 0,
"edgeRatioMax": 0.7935740735522747,
"edgeRatioMin": 0.478987783805106,
"maxInfluences": 4,
"note": "",
"polygons": 111,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 145
},
"Body": {
"degenerateTris": 0,
"edgeRatioMax": 1.9536679047270835,
"edgeRatioMin": 0.33440855736119546,
"maxInfluences": 4,
"note": "",
"polygons": 6232,
"quality": "warn",
"shapeKeys": 1,
"unweighted": 0,
"vertices": 6584
},
"BodyBottom": {
"degenerateTris": 0,
"edgeRatioMax": 0.8216992678030084,
"edgeRatioMin": 0.5410301282339429,
"maxInfluences": 4,
"note": "",
"polygons": 1228,
"quality": "ok",
"shapeKeys": 1,
"unweighted": 0,
"vertices": 1181
},
"BodyBottomPants": {
"degenerateTris": 0,
"edgeRatioMax": 0.86670117423969,
"edgeRatioMin": 0.5410301282339429,
"maxInfluences": 4,
"note": "",
"polygons": 972,
"quality": "ok",
"shapeKeys": 1,
"unweighted": 0,
"vertices": 1133
},
"BodyFeet": {
"degenerateTris": 0,
"edgeRatioMax": 0.7839642922675355,
"edgeRatioMin": 0.5255821222248701,
"maxInfluences": 3,
"note": "",
"polygons": 1274,
"quality": "ok",
"shapeKeys": 1,
"unweighted": 0,
"vertices": 1392
},
"BodyFeetPants": {
"degenerateTris": 48,
"edgeRatioMax": 0.7839642922675355,
"edgeRatioMin": 0.513176300659891,
"maxInfluences": 3,
"note": "",
"polygons": 1466,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1602
},
"BodyFeetPantsShoes": {
"degenerateTris": 48,
"edgeRatioMax": 0.7799605112083939,
"edgeRatioMin": 0.513176300659891,
"maxInfluences": 3,
"note": "",
"polygons": 830,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1048
},
"BodyTop": {
"degenerateTris": 0,
"edgeRatioMax": 1.9536679047270835,
"edgeRatioMin": 0.33440855736119546,
"maxInfluences": 4,
"note": "",
"polygons": 3726,
"quality": "warn",
"shapeKeys": 1,
"unweighted": 0,
"vertices": 3373
},
"BodyTopRobe": {
"degenerateTris": 0,
"edgeRatioMax": 1.8102305567029533,
"edgeRatioMin": 0.26171116386651677,
"maxInfluences": 4,
"note": "",
"polygons": 5384,
"quality": "warn",
"shapeKeys": 1,
"unweighted": 0,
"vertices": 5046
},
"Body_shapes": {
"degenerateTris": 0,
"edgeRatioMax": 1.9536679047270835,
"edgeRatioMin": 0.33440855736119546,
"maxInfluences": 4,
"note": "",
"polygons": 6228,
"quality": "warn",
"shapeKeys": 2,
"unweighted": 0,
"vertices": 5556
},
"Bottoms": {
"degenerateTris": 0,
"edgeRatioMax": 0.86670117423969,
"edgeRatioMin": 0.513176300659891,
"maxInfluences": 4,
"note": "",
"polygons": 826,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 942
},
"Face": {
"degenerateTris": 0,
"edgeRatioMax": 0.9201631088272872,
"edgeRatioMin": 0.9197675922571078,
"maxInfluences": 1,
"note": "",
"polygons": 3839,
"quality": "ok",
"shapeKeys": 57,
"unweighted": 0,
"vertices": 4045
},
"Hair": {
"degenerateTris": 0,
"edgeRatioMax": 0.9200000519776095,
"edgeRatioMin": 0.9199996919846277,
"maxInfluences": 1,
"note": "",
"polygons": 4,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 9
},
"Hair2": {
"degenerateTris": 0,
"edgeRatioMax": 0.9421731351522475,
"edgeRatioMin": 0.8873110924578558,
"maxInfluences": 2,
"note": "",
"polygons": 4483,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 7022
},
"HairBack": {
"degenerateTris": 0,
"edgeRatioMax": 1.2562402728849387,
"edgeRatioMin": 0.7903516292481316,
"maxInfluences": 4,
"note": "",
"polygons": 724,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 760
},
"Panties001": {
"degenerateTris": 0,
"edgeRatioMax": 0.8213261947723751,
"edgeRatioMin": 0.5459879769698383,
"maxInfluences": 4,
"note": "",
"polygons": 604,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 582
},
"Pants0001": {
"degenerateTris": 0,
"edgeRatioMax": 1.0359715012588415,
"edgeRatioMin": 0.5209577970985301,
"maxInfluences": 4,
"note": "",
"polygons": 898,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 915
},
"Shoes": {
"degenerateTris": 0,
"edgeRatioMax": 0.7799605112083939,
"edgeRatioMin": 0.6458808184096261,
"maxInfluences": 3,
"note": "",
"polygons": 334,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 478
},
"Tops": {
"degenerateTris": 0,
"edgeRatioMax": 1.3836635660461891,
"edgeRatioMin": 0.26171116386651677,
"maxInfluences": 4,
"note": "",
"polygons": 1658,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1896
},
"TopsEdited": {
"degenerateTris": 0,
"edgeRatioMax": 1.3836635660461891,
"edgeRatioMin": 0.26171116386651677,
"maxInfluences": 4,
"note": "",
"polygons": 1658,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1896
},
"coat": {
"degenerateTris": 0,
"edgeRatioMax": 5.7431588938626055,
"edgeRatioMin": 0.06297046287549611,
"maxInfluences": 4,
"note": "",
"polygons": 1658,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1896
},
"hair0001": {
"degenerateTris": 0,
"edgeRatioMax": 1.6992910918861586,
"edgeRatioMin": 0.8209691490768963,
"maxInfluences": 4,
"note": "",
"polygons": 4483,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 7022
},
"hair0002": {
"degenerateTris": 0,
"edgeRatioMax": 1.6992910918861586,
"edgeRatioMin": 0.8209691490768963,
"maxInfluences": 4,
"note": "",
"polygons": 4483,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 7022
},
"hair0003": {
"degenerateTris": 0,
"edgeRatioMax": 1.0,
"edgeRatioMin": 1.0,
"maxInfluences": 0,
"note": "",
"polygons": 4483,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 7022
},
"shoes1": {
"degenerateTris": 0,
"edgeRatioMax": 0.76872570692281,
"edgeRatioMin": 0.6246053643865501,
"maxInfluences": 3,
"note": "",
"polygons": 334,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 478
}
},
"profileHash": "dd6901d4220ae704",
"rig": {
"adultBones": 242,
"ageBones": 242,
"profile": {
"arm": 0.6,
"bodyRadial": 0.78,
"foot": 0.75,
"head": 0.92,
"headBulge": 1.03,
"hipHeight": 0.55,
"hipWidth": 0.72,
"leg": 0.55,
"limbRadial": 0.72,
"shoulderWidth": 0.7,
"torso": 0.62,
"walkSpeedScale": 0.75
},
"restOrientationErrors": []
},
"seams": {
"coincidentGroups": 5119,
"examples": [
{
"gap": 1e-06,
"objects": [
"BodyTop",
"BodyTopRobe"
],
"position": [
0.0,
0.09,
1.4972
]
},
{
"gap": 1e-06,
"objects": [
"BodyTop",
"BodyTopRobe"
],
"position": [
0.0,
0.0858,
1.6039
]
},
{
"gap": 2e-06,
"objects": [
"BodyTop",
"BodyTopRobe"
],
"position": [
0.0,
-0.0184,
1.5008
]
},
{
"gap": 2e-06,
"objects": [
"BodyTop",
"Face"
],
"position": [
0.0633,
0.0369,
1.6496
]
},
{
"gap": 2e-06,
"objects": [
"BodyTop",
"Face"
],
"position": [
-0.0633,
0.0369,
1.6496
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
0.0633,
0.0344,
1.6475
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
0.0636,
0.0319,
1.6457
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
0.0645,
0.0294,
1.6445
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
0.0698,
0.021,
1.649
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
0.0716,
0.0206,
1.6529
]
}
],
"maxGap": 7.33739356344359e-06,
"snappedVertices": 216
},
"sex": "male",
"status": "warn",
"stem": "child-male",
"timestamp": 1793419291,
"warnings": [
"Body",
"Body_shapes",
"BodyFeetPants",
"BodyFeetPantsShoes",
"BodyTop",
"BodyTopRobe",
"Tops",
"TopsEdited",
"coat"
]
}
@@ -0,0 +1,62 @@
# Conversion Report — child / male
- Stem: `child-male`
- Status: **warn**
- Profile hash: `dd6901d4220ae704`
- Objects: 25
- Warnings: 9
- Fails: 0
## Info
- adultArmature: `male`
- ageArmature: `child-male`
- animations: `{'scaledLocationCurves': 7601, 'appended': ['Armature|mixamo.com|Layer0.020', 'Armature|mixamo.com|Layer0.018', 'Armature|mixamo.com|Layer0.016', 'Armature|mixamo.com|Layer0.014', 'Armature|mixamo.com|Layer0.012'], 'aliased': {'swimming-fast': 'swimming'}, 'missing': [], 'stem': 'child-male'}`
- outputBase: `edited-normal-child-male.blend`
- slots: `bottom,face,feet,hair,top`
- sourceBase: `edited-normal-male.blend`
## Seams
- coincidentGroups: `5119`
- snappedVertices: `216`
- maxGap: `7.33739356344359e-06`
- examples: `[{'position': [0.0, 0.09, 1.4972], 'gap': 1e-06, 'objects': ['BodyTop', 'BodyTopRobe']}, {'position': [0.0, 0.0858, 1.6039], 'gap': 1e-06, 'objects': ['BodyTop', 'BodyTopRobe']}, {'position': [0.0, -0.0184, 1.5008], 'gap': 2e-06, 'objects': ['BodyTop', 'BodyTopRobe']}, {'position': [0.0633, 0.0369, 1.6496], 'gap': 2e-06, 'objects': ['BodyTop', 'Face']}, {'position': [-0.0633, 0.0369, 1.6496], 'gap': 2e-06, 'objects': ['BodyTop', 'Face']}, {'position': [0.0633, 0.0344, 1.6475], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0636, 0.0319, 1.6457], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0645, 0.0294, 1.6445], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0698, 0.021, 1.649], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0716, 0.0206, 1.6529], 'gap': 1e-06, 'objects': ['Face']}]`
## Warnings (first 20)
- `Body`
- `Body_shapes`
- `BodyFeetPants`
- `BodyFeetPantsShoes`
- `BodyTop`
- `BodyTopRobe`
- `Tops`
- `TopsEdited`
- `coat`
## Objects
- `Accessory` — ok; verts=145, tris=0 degenerate, influences=4, unweighted=0, edge=0.48..0.79
- `Body` — warn; verts=6584, tris=0 degenerate, influences=4, unweighted=0, edge=0.33..1.95
- `BodyBottom` — ok; verts=1181, tris=0 degenerate, influences=4, unweighted=0, edge=0.54..0.82
- `BodyBottomPants` — ok; verts=1133, tris=0 degenerate, influences=4, unweighted=0, edge=0.54..0.87
- `BodyFeet` — ok; verts=1392, tris=0 degenerate, influences=3, unweighted=0, edge=0.53..0.78
- `BodyFeetPants` — warn; verts=1602, tris=48 degenerate, influences=3, unweighted=0, edge=0.51..0.78
- `BodyFeetPantsShoes` — warn; verts=1048, tris=48 degenerate, influences=3, unweighted=0, edge=0.51..0.78
- `BodyTop` — warn; verts=3373, tris=0 degenerate, influences=4, unweighted=0, edge=0.33..1.95
- `BodyTopRobe` — warn; verts=5046, tris=0 degenerate, influences=4, unweighted=0, edge=0.26..1.81
- `Body_shapes` — warn; verts=5556, tris=0 degenerate, influences=4, unweighted=0, edge=0.33..1.95
- `Bottoms` — ok; verts=942, tris=0 degenerate, influences=4, unweighted=0, edge=0.51..0.87
- `Face` — ok; verts=4045, tris=0 degenerate, influences=1, unweighted=0, edge=0.92..0.92
- `Hair` — ok; verts=9, tris=0 degenerate, influences=1, unweighted=0, edge=0.92..0.92
- `Hair2` — ok; verts=7022, tris=0 degenerate, influences=2, unweighted=0, edge=0.89..0.94
- `HairBack` — ok; verts=760, tris=0 degenerate, influences=4, unweighted=0, edge=0.79..1.26
- `Panties001` — ok; verts=582, tris=0 degenerate, influences=4, unweighted=0, edge=0.55..0.82
- `Pants0001` — ok; verts=915, tris=0 degenerate, influences=4, unweighted=0, edge=0.52..1.04
- `Shoes` — ok; verts=478, tris=0 degenerate, influences=3, unweighted=0, edge=0.65..0.78
- `Tops` — warn; verts=1896, tris=0 degenerate, influences=4, unweighted=0, edge=0.26..1.38
- `TopsEdited` — warn; verts=1896, tris=0 degenerate, influences=4, unweighted=0, edge=0.26..1.38
- `coat` — warn; verts=1896, tris=0 degenerate, influences=4, unweighted=0, edge=0.06..5.74
- `hair0001` — ok; verts=7022, tris=0 degenerate, influences=4, unweighted=0, edge=0.82..1.70
- `hair0002` — ok; verts=7022, tris=0 degenerate, influences=4, unweighted=0, edge=0.82..1.70
- `hair0003` — ok; verts=7022, tris=0 degenerate, influences=0, unweighted=0, edge=1.00..1.00
- `shoes1` — ok; verts=478, tris=0 degenerate, influences=3, unweighted=0, edge=0.62..0.77
@@ -0,0 +1,621 @@
{
"age": "elder",
"fails": [],
"info": {
"adultArmature": "female",
"ageArmature": "elder-female",
"animations": {
"aliased": {
"swimming-fast": "swimming"
},
"appended": [
"Armature|mixamo.com|Layer0.020",
"Armature|mixamo.com|Layer0.018",
"Armature|mixamo.com|Layer0.016",
"Armature|mixamo.com|Layer0.014"
],
"missing": [],
"scaledLocationCurves": 7028,
"stem": "elder-female"
},
"outputBase": "edited-normal-elder-female.blend",
"slots": "bottom,face,feet,hair,top",
"sourceBase": "edited-normal-female.blend"
},
"objects": {
"Accessory": {
"degenerateTris": 0,
"edgeRatioMax": 1.000008309035069,
"edgeRatioMin": 0.9999861617983601,
"maxInfluences": 4,
"note": "",
"polygons": 40,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 69
},
"Body": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000602153963685,
"edgeRatioMin": 0.9999297885300813,
"maxInfluences": 4,
"note": "",
"polygons": 6227,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 6578
},
"Body-merged": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000602153963685,
"edgeRatioMin": 0.9999297885300813,
"maxInfluences": 4,
"note": "",
"polygons": 8423,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 9178
},
"BodyBottom": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000091614229163,
"edgeRatioMin": 0.9999833439579573,
"maxInfluences": 4,
"note": "",
"polygons": 1242,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1194
},
"BodyFeet": {
"degenerateTris": 0,
"edgeRatioMax": 1.000004257443259,
"edgeRatioMin": 0.9999898988835334,
"maxInfluences": 3,
"note": "",
"polygons": 1280,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1050
},
"BodyTop": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000602153963685,
"edgeRatioMin": 0.9999297885300813,
"maxInfluences": 4,
"note": "",
"polygons": 3701,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 3354
},
"BodyTopRobe": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000602153963685,
"edgeRatioMin": 0.9999297885300813,
"maxInfluences": 4,
"note": "",
"polygons": 4727,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 4383
},
"Body_shapes": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000602153963685,
"edgeRatioMin": 0.9999297885300813,
"maxInfluences": 4,
"note": "",
"polygons": 6223,
"quality": "ok",
"shapeKeys": 2,
"unweighted": 0,
"vertices": 5550
},
"Cylinder": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000211541526272,
"edgeRatioMin": 0.9999790604099301,
"maxInfluences": 0,
"note": "",
"polygons": 595,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 840,
"vertices": 840
},
"Face": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000190299901157,
"edgeRatioMin": 0.9999818716880085,
"maxInfluences": 1,
"note": "",
"polygons": 3887,
"quality": "ok",
"shapeKeys": 57,
"unweighted": 0,
"vertices": 4109
},
"Hair": {
"degenerateTris": 0,
"edgeRatioMax": 1.0,
"edgeRatioMin": 1.0,
"maxInfluences": 1,
"note": "",
"polygons": 2,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 8
},
"Hair001": {
"degenerateTris": 0,
"edgeRatioMax": 1.000108986292174,
"edgeRatioMin": 0.999757672900018,
"maxInfluences": 4,
"note": "",
"polygons": 5658,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 8673
},
"Hair2": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000886276531582,
"edgeRatioMin": 0.9982467837381087,
"maxInfluences": 2,
"note": "",
"polygons": 5658,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 8673
},
"HairBack": {
"degenerateTris": 0,
"edgeRatioMax": 1.000033289511109,
"edgeRatioMin": 0.9999827306906097,
"maxInfluences": 4,
"note": "",
"polygons": 726,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 760
},
"OnepieceCloth": {
"degenerateTris": 0,
"edgeRatioMax": 1.00000327729584,
"edgeRatioMin": 0.9999867384343708,
"maxInfluences": 3,
"note": "",
"polygons": 2,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 6
},
"Shoes": {
"degenerateTris": 0,
"edgeRatioMax": 1.000000954873743,
"edgeRatioMin": 0.9999976654518632,
"maxInfluences": 3,
"note": "",
"polygons": 402,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 608
},
"Tops": {
"degenerateTris": 0,
"edgeRatioMax": 1.000009505554167,
"edgeRatioMin": 0.9999842864583346,
"maxInfluences": 4,
"note": "",
"polygons": 1026,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1029
},
"bra1": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000653723155617,
"edgeRatioMin": 0.9999349455725334,
"maxInfluences": 4,
"note": "",
"polygons": 430,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 419
},
"bra2": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000515467022928,
"edgeRatioMin": 0.9999485610532655,
"maxInfluences": 4,
"note": "",
"polygons": 506,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 524
},
"bra3": {
"degenerateTris": 0,
"edgeRatioMax": 1.000045760529744,
"edgeRatioMin": 0.9999485610532655,
"maxInfluences": 4,
"note": "",
"polygons": 410,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 450
},
"bra4": {
"degenerateTris": 0,
"edgeRatioMax": 1.000045760529744,
"edgeRatioMin": 0.9999485610532655,
"maxInfluences": 4,
"note": "",
"polygons": 410,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 450
},
"panties": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000105452390367,
"edgeRatioMin": 0.9999622354677439,
"maxInfluences": 0,
"note": "",
"polygons": 674,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 797,
"vertices": 797
},
"panties-full": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000048680725775,
"edgeRatioMin": 0.9999761306308599,
"maxInfluences": 0,
"note": "",
"polygons": 337,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 425,
"vertices": 425
},
"panties2": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000202864294472,
"edgeRatioMin": 0.9999826178387231,
"maxInfluences": 0,
"note": "",
"polygons": 1266,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 1328,
"vertices": 1328
},
"panties2-full": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000121089841207,
"edgeRatioMin": 0.9999863856203022,
"maxInfluences": 0,
"note": "",
"polygons": 507,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 558,
"vertices": 558
},
"panties3": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000139098043757,
"edgeRatioMin": 0.9999866822085812,
"maxInfluences": 0,
"note": "",
"polygons": 758,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 820,
"vertices": 820
},
"panties4": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000139098043757,
"edgeRatioMin": 0.9999866822085812,
"maxInfluences": 0,
"note": "",
"polygons": 690,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 752,
"vertices": 752
},
"panties5": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000461926176676,
"edgeRatioMin": 0.9999441034118597,
"maxInfluences": 4,
"note": "",
"polygons": 698,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 758
},
"panties6": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000277280519727,
"edgeRatioMin": 0.9999767160991722,
"maxInfluences": 4,
"note": "",
"polygons": 698,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 758
},
"panties7": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000385836476469,
"edgeRatioMin": 0.9999691785976204,
"maxInfluences": 4,
"note": "",
"polygons": 698,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 758
},
"shoes1": {
"degenerateTris": 0,
"edgeRatioMax": 1.000001217005389,
"edgeRatioMin": 0.9999980533858904,
"maxInfluences": 3,
"note": "",
"polygons": 414,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 414
},
"skirt1": {
"degenerateTris": 0,
"edgeRatioMax": 1.000003778236853,
"edgeRatioMin": 0.9999955122967341,
"maxInfluences": 4,
"note": "",
"polygons": 132,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 154
},
"skirt2": {
"degenerateTris": 0,
"edgeRatioMax": 1.000005985850434,
"edgeRatioMin": 0.999995191044834,
"maxInfluences": 4,
"note": "",
"polygons": 154,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 176
},
"skirt3": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000032530300955,
"edgeRatioMin": 0.9999941815040422,
"maxInfluences": 4,
"note": "",
"polygons": 132,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 154
},
"top1": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000349169435994,
"edgeRatioMin": 0.9999356995377789,
"maxInfluences": 4,
"note": "",
"polygons": 860,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 852
},
"top2": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000115228837376,
"edgeRatioMin": 0.9999867173552025,
"maxInfluences": 4,
"note": "",
"polygons": 876,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 854
},
"top3": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000115228837376,
"edgeRatioMin": 0.9999867173552025,
"maxInfluences": 4,
"note": "",
"polygons": 876,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 854
}
},
"profileHash": "dd6901d4220ae704",
"rig": {
"adultBones": 252,
"ageBones": 252,
"profile": {
"arm": 1.0,
"bodyRadial": 1.0,
"foot": 1.0,
"head": 1.0,
"headBulge": 1.0,
"hipHeight": 1.0,
"hipWidth": 1.0,
"leg": 1.0,
"limbRadial": 1.0,
"shoulderWidth": 1.0,
"torso": 1.0,
"walkSpeedScale": 1.0
},
"restOrientationErrors": []
},
"seams": {
"coincidentGroups": 3697,
"examples": [
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
0.0649,
0.0281,
1.3797
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
0.066,
0.0231,
1.3767
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
0.0694,
0.0172,
1.3783
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
-0.0649,
0.0281,
1.3797
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
-0.066,
0.0231,
1.3767
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
-0.0694,
0.0172,
1.3783
]
},
{
"gap": 2e-06,
"objects": [
"Hair2"
],
"position": [
0.0001,
-0.0385,
1.5529
]
},
{
"gap": 2e-06,
"objects": [
"Hair2"
],
"position": [
-0.0001,
-0.0385,
1.5529
]
},
{
"gap": 3e-06,
"objects": [
"Hair2"
],
"position": [
-0.0012,
-0.0342,
1.5629
]
},
{
"gap": 3e-06,
"objects": [
"Hair2"
],
"position": [
-0.001,
-0.0343,
1.5628
]
}
],
"maxGap": 7.0412131889888606e-06,
"snappedVertices": 95
},
"sex": "female",
"status": "warn",
"stem": "elder-female",
"timestamp": 1793419268,
"warnings": [
"Cylinder",
"panties",
"panties-full",
"panties2",
"panties2-full",
"panties3",
"panties4"
]
}
@@ -0,0 +1,72 @@
# Conversion Report — elder / female
- Stem: `elder-female`
- Status: **warn**
- Profile hash: `dd6901d4220ae704`
- Objects: 37
- Warnings: 7
- Fails: 0
## Info
- adultArmature: `female`
- ageArmature: `elder-female`
- animations: `{'scaledLocationCurves': 7028, 'appended': ['Armature|mixamo.com|Layer0.020', 'Armature|mixamo.com|Layer0.018', 'Armature|mixamo.com|Layer0.016', 'Armature|mixamo.com|Layer0.014'], 'aliased': {'swimming-fast': 'swimming'}, 'missing': [], 'stem': 'elder-female'}`
- outputBase: `edited-normal-elder-female.blend`
- slots: `bottom,face,feet,hair,top`
- sourceBase: `edited-normal-female.blend`
## Seams
- coincidentGroups: `3697`
- snappedVertices: `95`
- maxGap: `7.0412131889888606e-06`
- examples: `[{'position': [0.0649, 0.0281, 1.3797], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.066, 0.0231, 1.3767], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0694, 0.0172, 1.3783], 'gap': 1e-06, 'objects': ['Face']}, {'position': [-0.0649, 0.0281, 1.3797], 'gap': 1e-06, 'objects': ['Face']}, {'position': [-0.066, 0.0231, 1.3767], 'gap': 1e-06, 'objects': ['Face']}, {'position': [-0.0694, 0.0172, 1.3783], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0001, -0.0385, 1.5529], 'gap': 2e-06, 'objects': ['Hair2']}, {'position': [-0.0001, -0.0385, 1.5529], 'gap': 2e-06, 'objects': ['Hair2']}, {'position': [-0.0012, -0.0342, 1.5629], 'gap': 3e-06, 'objects': ['Hair2']}, {'position': [-0.001, -0.0343, 1.5628], 'gap': 3e-06, 'objects': ['Hair2']}]`
## Warnings (first 20)
- `Cylinder`
- `panties`
- `panties-full`
- `panties2`
- `panties2-full`
- `panties3`
- `panties4`
## Objects
- `Accessory` — ok; verts=69, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `Body` — ok; verts=6578, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `Body-merged` — ok; verts=9178, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `BodyBottom` — ok; verts=1194, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `BodyFeet` — ok; verts=1050, tris=0 degenerate, influences=3, unweighted=0, edge=1.00..1.00
- `BodyTop` — ok; verts=3354, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `BodyTopRobe` — ok; verts=4383, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `Body_shapes` — ok; verts=5550, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `Cylinder` — warn; verts=840, tris=0 degenerate, influences=0, unweighted=840, edge=1.00..1.00
- `Face` — ok; verts=4109, tris=0 degenerate, influences=1, unweighted=0, edge=1.00..1.00
- `Hair` — ok; verts=8, tris=0 degenerate, influences=1, unweighted=0, edge=1.00..1.00
- `Hair001` — ok; verts=8673, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `Hair2` — ok; verts=8673, tris=0 degenerate, influences=2, unweighted=0, edge=1.00..1.00
- `HairBack` — ok; verts=760, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `OnepieceCloth` — ok; verts=6, tris=0 degenerate, influences=3, unweighted=0, edge=1.00..1.00
- `Shoes` — ok; verts=608, tris=0 degenerate, influences=3, unweighted=0, edge=1.00..1.00
- `Tops` — ok; verts=1029, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `bra1` — ok; verts=419, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `bra2` — ok; verts=524, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `bra3` — ok; verts=450, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `bra4` — ok; verts=450, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `panties` — warn; verts=797, tris=0 degenerate, influences=0, unweighted=797, edge=1.00..1.00
- `panties-full` — warn; verts=425, tris=0 degenerate, influences=0, unweighted=425, edge=1.00..1.00
- `panties2` — warn; verts=1328, tris=0 degenerate, influences=0, unweighted=1328, edge=1.00..1.00
- `panties2-full` — warn; verts=558, tris=0 degenerate, influences=0, unweighted=558, edge=1.00..1.00
- `panties3` — warn; verts=820, tris=0 degenerate, influences=0, unweighted=820, edge=1.00..1.00
- `panties4` — warn; verts=752, tris=0 degenerate, influences=0, unweighted=752, edge=1.00..1.00
- `panties5` — ok; verts=758, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `panties6` — ok; verts=758, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `panties7` — ok; verts=758, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `shoes1` — ok; verts=414, tris=0 degenerate, influences=3, unweighted=0, edge=1.00..1.00
- `skirt1` — ok; verts=154, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `skirt2` — ok; verts=176, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `skirt3` — ok; verts=154, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `top1` — ok; verts=852, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `top2` — ok; verts=854, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `top3` — ok; verts=854, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
@@ -0,0 +1,482 @@
{
"age": "elder",
"fails": [],
"info": {
"adultArmature": "male",
"ageArmature": "elder-male",
"animations": {
"aliased": {
"swimming-fast": "swimming"
},
"appended": [
"Armature|mixamo.com|Layer0.020",
"Armature|mixamo.com|Layer0.018",
"Armature|mixamo.com|Layer0.016",
"Armature|mixamo.com|Layer0.014",
"Armature|mixamo.com|Layer0.012"
],
"missing": [],
"scaledLocationCurves": 7601,
"stem": "elder-male"
},
"outputBase": "edited-normal-elder-male.blend",
"slots": "bottom,face,feet,hair,top",
"sourceBase": "edited-normal-male.blend"
},
"objects": {
"Accessory": {
"degenerateTris": 0,
"edgeRatioMax": 1.000019766352531,
"edgeRatioMin": 0.9999805255229,
"maxInfluences": 4,
"note": "",
"polygons": 111,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 145
},
"Body": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000897554304082,
"edgeRatioMin": 0.9999611641687194,
"maxInfluences": 4,
"note": "",
"polygons": 6232,
"quality": "ok",
"shapeKeys": 1,
"unweighted": 0,
"vertices": 6584
},
"BodyBottom": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000079995934277,
"edgeRatioMin": 0.999989253410586,
"maxInfluences": 4,
"note": "",
"polygons": 1228,
"quality": "ok",
"shapeKeys": 1,
"unweighted": 0,
"vertices": 1181
},
"BodyBottomPants": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000258231043282,
"edgeRatioMin": 0.9999859573234091,
"maxInfluences": 4,
"note": "",
"polygons": 972,
"quality": "ok",
"shapeKeys": 1,
"unweighted": 0,
"vertices": 1133
},
"BodyFeet": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000046342516928,
"edgeRatioMin": 0.9999981174339364,
"maxInfluences": 3,
"note": "",
"polygons": 1274,
"quality": "ok",
"shapeKeys": 1,
"unweighted": 0,
"vertices": 1392
},
"BodyFeetPants": {
"degenerateTris": 48,
"edgeRatioMax": 1.0000046342516928,
"edgeRatioMin": 0.9999981174339364,
"maxInfluences": 3,
"note": "",
"polygons": 1466,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1602
},
"BodyFeetPantsShoes": {
"degenerateTris": 48,
"edgeRatioMax": 1.0000013400794516,
"edgeRatioMin": 0.9999981174339364,
"maxInfluences": 3,
"note": "",
"polygons": 830,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1048
},
"BodyTop": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000897554304082,
"edgeRatioMin": 0.9999611641687194,
"maxInfluences": 4,
"note": "",
"polygons": 3726,
"quality": "ok",
"shapeKeys": 1,
"unweighted": 0,
"vertices": 3373
},
"BodyTopRobe": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000897554304082,
"edgeRatioMin": 0.9999611641687194,
"maxInfluences": 4,
"note": "",
"polygons": 5384,
"quality": "ok",
"shapeKeys": 1,
"unweighted": 0,
"vertices": 5046
},
"Body_shapes": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000897554304082,
"edgeRatioMin": 0.9999611641687194,
"maxInfluences": 4,
"note": "",
"polygons": 6228,
"quality": "ok",
"shapeKeys": 2,
"unweighted": 0,
"vertices": 5556
},
"Bottoms": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000258231043282,
"edgeRatioMin": 0.9999859573234091,
"maxInfluences": 4,
"note": "",
"polygons": 826,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 942
},
"Face": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000018813696014,
"edgeRatioMin": 0.9999982328372087,
"maxInfluences": 1,
"note": "",
"polygons": 3839,
"quality": "ok",
"shapeKeys": 57,
"unweighted": 0,
"vertices": 4045
},
"Hair": {
"degenerateTris": 0,
"edgeRatioMax": 1.0,
"edgeRatioMin": 0.9999997540155123,
"maxInfluences": 1,
"note": "",
"polygons": 4,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 9
},
"Hair2": {
"degenerateTris": 0,
"edgeRatioMax": 1.000542889561435,
"edgeRatioMin": 0.999194851963821,
"maxInfluences": 2,
"note": "",
"polygons": 4483,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 7022
},
"HairBack": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000295096813758,
"edgeRatioMin": 0.9999786138678867,
"maxInfluences": 4,
"note": "",
"polygons": 724,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 760
},
"Panties001": {
"degenerateTris": 0,
"edgeRatioMax": 1.0010504381569287,
"edgeRatioMin": 0.9991726553447599,
"maxInfluences": 4,
"note": "",
"polygons": 604,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 582
},
"Pants0001": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000229696457892,
"edgeRatioMin": 0.9999874704829604,
"maxInfluences": 4,
"note": "",
"polygons": 898,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 915
},
"Shoes": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000013174267397,
"edgeRatioMin": 0.9999985866462575,
"maxInfluences": 3,
"note": "",
"polygons": 334,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 478
},
"Tops": {
"degenerateTris": 0,
"edgeRatioMax": 1.00000962524978,
"edgeRatioMin": 0.9999900806166673,
"maxInfluences": 4,
"note": "",
"polygons": 1658,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1896
},
"TopsEdited": {
"degenerateTris": 0,
"edgeRatioMax": 1.00000962524978,
"edgeRatioMin": 0.9999900806166673,
"maxInfluences": 4,
"note": "",
"polygons": 1658,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1896
},
"coat": {
"degenerateTris": 0,
"edgeRatioMax": 1.000022626000937,
"edgeRatioMin": 0.9999831391448877,
"maxInfluences": 4,
"note": "",
"polygons": 1658,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1896
},
"hair0001": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000358941381666,
"edgeRatioMin": 0.99983153357466,
"maxInfluences": 4,
"note": "",
"polygons": 4483,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 7022
},
"hair0002": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000358941381666,
"edgeRatioMin": 0.99983153357466,
"maxInfluences": 4,
"note": "",
"polygons": 4483,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 7022
},
"hair0003": {
"degenerateTris": 0,
"edgeRatioMax": 1.0,
"edgeRatioMin": 1.0,
"maxInfluences": 0,
"note": "",
"polygons": 4483,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 7022
},
"shoes1": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000013174267397,
"edgeRatioMin": 0.9999985251538689,
"maxInfluences": 3,
"note": "",
"polygons": 334,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 478
}
},
"profileHash": "dd6901d4220ae704",
"rig": {
"adultBones": 242,
"ageBones": 242,
"profile": {
"arm": 1.0,
"bodyRadial": 1.0,
"foot": 1.0,
"head": 1.0,
"headBulge": 1.0,
"hipHeight": 1.0,
"hipWidth": 1.0,
"leg": 1.0,
"limbRadial": 1.0,
"shoulderWidth": 1.0,
"torso": 1.0,
"walkSpeedScale": 1.0
},
"restOrientationErrors": []
},
"seams": {
"coincidentGroups": 5119,
"examples": [
{
"gap": 2e-06,
"objects": [
"BodyTop",
"BodyTopRobe"
],
"position": [
0.0,
0.09,
1.4972
]
},
{
"gap": 1e-06,
"objects": [
"BodyTop",
"BodyTopRobe"
],
"position": [
0.0,
0.0858,
1.6039
]
},
{
"gap": 2e-06,
"objects": [
"BodyTop",
"BodyTopRobe"
],
"position": [
0.0,
-0.0184,
1.5008
]
},
{
"gap": 2e-06,
"objects": [
"BodyTop",
"Face"
],
"position": [
0.0633,
0.0369,
1.6496
]
},
{
"gap": 2e-06,
"objects": [
"BodyTop",
"Face"
],
"position": [
-0.0633,
0.0369,
1.6496
]
},
{
"gap": 1e-06,
"objects": [
"BodyTop",
"BodyTopRobe",
"Face"
],
"position": [
0.0842,
0.0277,
1.6955
]
},
{
"gap": 1e-06,
"objects": [
"BodyTop",
"BodyTopRobe",
"Face"
],
"position": [
-0.0842,
0.0277,
1.6955
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
0.0633,
0.0344,
1.6475
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
0.0636,
0.0319,
1.6457
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
0.0645,
0.0294,
1.6445
]
}
],
"maxGap": 8.047489926985063e-06,
"snappedVertices": 226
},
"sex": "male",
"status": "warn",
"stem": "elder-male",
"timestamp": 1793419262,
"warnings": [
"BodyFeetPants",
"BodyFeetPantsShoes"
]
}
@@ -0,0 +1,55 @@
# Conversion Report — elder / male
- Stem: `elder-male`
- Status: **warn**
- Profile hash: `dd6901d4220ae704`
- Objects: 25
- Warnings: 2
- Fails: 0
## Info
- adultArmature: `male`
- ageArmature: `elder-male`
- animations: `{'scaledLocationCurves': 7601, 'appended': ['Armature|mixamo.com|Layer0.020', 'Armature|mixamo.com|Layer0.018', 'Armature|mixamo.com|Layer0.016', 'Armature|mixamo.com|Layer0.014', 'Armature|mixamo.com|Layer0.012'], 'aliased': {'swimming-fast': 'swimming'}, 'missing': [], 'stem': 'elder-male'}`
- outputBase: `edited-normal-elder-male.blend`
- slots: `bottom,face,feet,hair,top`
- sourceBase: `edited-normal-male.blend`
## Seams
- coincidentGroups: `5119`
- snappedVertices: `226`
- maxGap: `8.047489926985063e-06`
- examples: `[{'position': [0.0, 0.09, 1.4972], 'gap': 2e-06, 'objects': ['BodyTop', 'BodyTopRobe']}, {'position': [0.0, 0.0858, 1.6039], 'gap': 1e-06, 'objects': ['BodyTop', 'BodyTopRobe']}, {'position': [0.0, -0.0184, 1.5008], 'gap': 2e-06, 'objects': ['BodyTop', 'BodyTopRobe']}, {'position': [0.0633, 0.0369, 1.6496], 'gap': 2e-06, 'objects': ['BodyTop', 'Face']}, {'position': [-0.0633, 0.0369, 1.6496], 'gap': 2e-06, 'objects': ['BodyTop', 'Face']}, {'position': [0.0842, 0.0277, 1.6955], 'gap': 1e-06, 'objects': ['BodyTop', 'BodyTopRobe', 'Face']}, {'position': [-0.0842, 0.0277, 1.6955], 'gap': 1e-06, 'objects': ['BodyTop', 'BodyTopRobe', 'Face']}, {'position': [0.0633, 0.0344, 1.6475], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0636, 0.0319, 1.6457], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0645, 0.0294, 1.6445], 'gap': 1e-06, 'objects': ['Face']}]`
## Warnings (first 20)
- `BodyFeetPants`
- `BodyFeetPantsShoes`
## Objects
- `Accessory` — ok; verts=145, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `Body` — ok; verts=6584, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `BodyBottom` — ok; verts=1181, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `BodyBottomPants` — ok; verts=1133, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `BodyFeet` — ok; verts=1392, tris=0 degenerate, influences=3, unweighted=0, edge=1.00..1.00
- `BodyFeetPants` — warn; verts=1602, tris=48 degenerate, influences=3, unweighted=0, edge=1.00..1.00
- `BodyFeetPantsShoes` — warn; verts=1048, tris=48 degenerate, influences=3, unweighted=0, edge=1.00..1.00
- `BodyTop` — ok; verts=3373, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `BodyTopRobe` — ok; verts=5046, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `Body_shapes` — ok; verts=5556, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `Bottoms` — ok; verts=942, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `Face` — ok; verts=4045, tris=0 degenerate, influences=1, unweighted=0, edge=1.00..1.00
- `Hair` — ok; verts=9, tris=0 degenerate, influences=1, unweighted=0, edge=1.00..1.00
- `Hair2` — ok; verts=7022, tris=0 degenerate, influences=2, unweighted=0, edge=1.00..1.00
- `HairBack` — ok; verts=760, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `Panties001` — ok; verts=582, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `Pants0001` — ok; verts=915, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `Shoes` — ok; verts=478, tris=0 degenerate, influences=3, unweighted=0, edge=1.00..1.00
- `Tops` — ok; verts=1896, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `TopsEdited` — ok; verts=1896, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `coat` — ok; verts=1896, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `hair0001` — ok; verts=7022, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `hair0002` — ok; verts=7022, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `hair0003` — ok; verts=7022, tris=0 degenerate, influences=0, unweighted=0, edge=1.00..1.00
- `shoes1` — ok; verts=478, tris=0 degenerate, influences=3, unweighted=0, edge=1.00..1.00
@@ -0,0 +1,622 @@
{
"age": "teenager",
"fails": [],
"info": {
"adultArmature": "female",
"ageArmature": "teenager-female",
"animations": {
"aliased": {
"swimming-fast": "swimming"
},
"appended": [
"Armature|mixamo.com|Layer0.020",
"Armature|mixamo.com|Layer0.018",
"Armature|mixamo.com|Layer0.016",
"Armature|mixamo.com|Layer0.014"
],
"missing": [],
"scaledLocationCurves": 7028,
"stem": "teenager-female"
},
"outputBase": "edited-normal-teenager-female.blend",
"slots": "bottom,face,feet,hair,top",
"sourceBase": "edited-normal-female.blend"
},
"objects": {
"Accessory": {
"degenerateTris": 0,
"edgeRatioMax": 0.9428835642440181,
"edgeRatioMin": 0.8937664900167279,
"maxInfluences": 4,
"note": "",
"polygons": 40,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 69
},
"Body": {
"degenerateTris": 0,
"edgeRatioMax": 1.153864673534735,
"edgeRatioMin": 0.8788689898808826,
"maxInfluences": 4,
"note": "",
"polygons": 6227,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 6578
},
"Body-merged": {
"degenerateTris": 0,
"edgeRatioMax": 1.153864673534735,
"edgeRatioMin": 0.875585733388662,
"maxInfluences": 4,
"note": "",
"polygons": 8423,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 9178
},
"BodyBottom": {
"degenerateTris": 0,
"edgeRatioMax": 0.9413767566501527,
"edgeRatioMin": 0.894824475491785,
"maxInfluences": 4,
"note": "",
"polygons": 1242,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1194
},
"BodyFeet": {
"degenerateTris": 0,
"edgeRatioMax": 1.01533051589042,
"edgeRatioMin": 0.8905319834891143,
"maxInfluences": 3,
"note": "",
"polygons": 1280,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1050
},
"BodyTop": {
"degenerateTris": 0,
"edgeRatioMax": 1.153864673534735,
"edgeRatioMin": 0.8788684699247973,
"maxInfluences": 4,
"note": "",
"polygons": 3701,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 3354
},
"BodyTopRobe": {
"degenerateTris": 0,
"edgeRatioMax": 1.153864673534735,
"edgeRatioMin": 0.875585733388662,
"maxInfluences": 4,
"note": "",
"polygons": 4727,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 4383
},
"Body_shapes": {
"degenerateTris": 0,
"edgeRatioMax": 1.153864673534735,
"edgeRatioMin": 0.8788689898808826,
"maxInfluences": 4,
"note": "",
"polygons": 6223,
"quality": "ok",
"shapeKeys": 2,
"unweighted": 0,
"vertices": 5550
},
"Cylinder": {
"degenerateTris": 0,
"edgeRatioMax": 13.149501164154342,
"edgeRatioMin": 0.2067502446063823,
"maxInfluences": 0,
"note": "",
"polygons": 595,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 840,
"vertices": 840
},
"Face": {
"degenerateTris": 0,
"edgeRatioMax": 0.9900810993796347,
"edgeRatioMin": 0.9896786488081534,
"maxInfluences": 1,
"note": "",
"polygons": 3887,
"quality": "ok",
"shapeKeys": 57,
"unweighted": 0,
"vertices": 4109
},
"Hair": {
"degenerateTris": 0,
"edgeRatioMax": 0.9900007559563859,
"edgeRatioMin": 0.9899997124609613,
"maxInfluences": 1,
"note": "",
"polygons": 2,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 8
},
"Hair001": {
"degenerateTris": 0,
"edgeRatioMax": 1.3437205849900313,
"edgeRatioMin": 0.39954414397197524,
"maxInfluences": 4,
"note": "",
"polygons": 5658,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 8673
},
"Hair2": {
"degenerateTris": 0,
"edgeRatioMax": 0.9961437951038233,
"edgeRatioMin": 0.9739232308631328,
"maxInfluences": 2,
"note": "",
"polygons": 5658,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 8673
},
"HairBack": {
"degenerateTris": 0,
"edgeRatioMax": 1.0706959837595011,
"edgeRatioMin": 0.9495467764182186,
"maxInfluences": 4,
"note": "",
"polygons": 726,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 760
},
"OnepieceCloth": {
"degenerateTris": 0,
"edgeRatioMax": 0.9399357283296659,
"edgeRatioMin": 0.9381366728208823,
"maxInfluences": 3,
"note": "",
"polygons": 2,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 6
},
"Shoes": {
"degenerateTris": 0,
"edgeRatioMax": 0.9981897700090412,
"edgeRatioMin": 0.9254076761648661,
"maxInfluences": 3,
"note": "",
"polygons": 402,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 608
},
"Tops": {
"degenerateTris": 0,
"edgeRatioMax": 0.9511826460744913,
"edgeRatioMin": 0.875585733388662,
"maxInfluences": 4,
"note": "",
"polygons": 1026,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1029
},
"bra1": {
"degenerateTris": 0,
"edgeRatioMax": 0.9900000946583577,
"edgeRatioMin": 0.2887434501951809,
"maxInfluences": 4,
"note": "",
"polygons": 430,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 419
},
"bra2": {
"degenerateTris": 0,
"edgeRatioMax": 0.9984894682233404,
"edgeRatioMin": 0.4006806605346238,
"maxInfluences": 4,
"note": "",
"polygons": 506,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 524
},
"bra3": {
"degenerateTris": 0,
"edgeRatioMax": 0.9984894682233404,
"edgeRatioMin": 0.4889485839438301,
"maxInfluences": 4,
"note": "",
"polygons": 410,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 450
},
"bra4": {
"degenerateTris": 0,
"edgeRatioMax": 0.9984894682233404,
"edgeRatioMin": 0.4889485839438301,
"maxInfluences": 4,
"note": "",
"polygons": 410,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 450
},
"panties": {
"degenerateTris": 0,
"edgeRatioMax": 50.21989080149669,
"edgeRatioMin": 0.19000501701728584,
"maxInfluences": 0,
"note": "",
"polygons": 674,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 797,
"vertices": 797
},
"panties-full": {
"degenerateTris": 0,
"edgeRatioMax": 1.3300942912650158,
"edgeRatioMin": 0.12847562281583486,
"maxInfluences": 0,
"note": "",
"polygons": 337,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 425,
"vertices": 425
},
"panties2": {
"degenerateTris": 0,
"edgeRatioMax": 10.548253137802863,
"edgeRatioMin": 0.3920225273569097,
"maxInfluences": 0,
"note": "",
"polygons": 1266,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 1328,
"vertices": 1328
},
"panties2-full": {
"degenerateTris": 0,
"edgeRatioMax": 9.15854640358302,
"edgeRatioMin": 0.6364910348952761,
"maxInfluences": 0,
"note": "",
"polygons": 507,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 558,
"vertices": 558
},
"panties3": {
"degenerateTris": 0,
"edgeRatioMax": 7.299860127337894,
"edgeRatioMin": 0.13403860939637366,
"maxInfluences": 0,
"note": "",
"polygons": 758,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 820,
"vertices": 820
},
"panties4": {
"degenerateTris": 0,
"edgeRatioMax": 7.740842499445837,
"edgeRatioMin": 0.1158857342919668,
"maxInfluences": 0,
"note": "",
"polygons": 690,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 752,
"vertices": 752
},
"panties5": {
"degenerateTris": 0,
"edgeRatioMax": 0.9443143846802673,
"edgeRatioMin": 0.8965532928515044,
"maxInfluences": 4,
"note": "",
"polygons": 698,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 758
},
"panties6": {
"degenerateTris": 0,
"edgeRatioMax": 0.95104115229466,
"edgeRatioMin": 0.8819173360530367,
"maxInfluences": 4,
"note": "",
"polygons": 698,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 758
},
"panties7": {
"degenerateTris": 0,
"edgeRatioMax": 0.9455920513906987,
"edgeRatioMin": 0.8823391488934409,
"maxInfluences": 4,
"note": "",
"polygons": 698,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 758
},
"shoes1": {
"degenerateTris": 0,
"edgeRatioMax": 0.9807207937208285,
"edgeRatioMin": 0.9278301779320385,
"maxInfluences": 3,
"note": "",
"polygons": 414,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 414
},
"skirt1": {
"degenerateTris": 0,
"edgeRatioMax": 0.9422412937744205,
"edgeRatioMin": 0.8944357351478923,
"maxInfluences": 4,
"note": "",
"polygons": 132,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 154
},
"skirt2": {
"degenerateTris": 0,
"edgeRatioMax": 0.9422412937744205,
"edgeRatioMin": 0.8944357351478923,
"maxInfluences": 4,
"note": "",
"polygons": 154,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 176
},
"skirt3": {
"degenerateTris": 0,
"edgeRatioMax": 0.9444972809207006,
"edgeRatioMin": 0.9023806027952785,
"maxInfluences": 4,
"note": "",
"polygons": 132,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 154
},
"top1": {
"degenerateTris": 0,
"edgeRatioMax": 1.0484285122626043,
"edgeRatioMin": 0.5756344323442318,
"maxInfluences": 4,
"note": "",
"polygons": 860,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 852
},
"top2": {
"degenerateTris": 0,
"edgeRatioMax": 1.0484285122626043,
"edgeRatioMin": 0.5756344323442318,
"maxInfluences": 4,
"note": "",
"polygons": 876,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 854
},
"top3": {
"degenerateTris": 0,
"edgeRatioMax": 1.0484285122626043,
"edgeRatioMin": 0.5756344323442318,
"maxInfluences": 4,
"note": "",
"polygons": 876,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 854
}
},
"profileHash": "dd6901d4220ae704",
"rig": {
"adultBones": 252,
"ageBones": 252,
"profile": {
"arm": 0.92,
"bodyRadial": 0.94,
"foot": 0.97,
"head": 0.99,
"headBulge": 1.0,
"hipHeight": 0.94,
"hipWidth": 0.92,
"leg": 0.94,
"limbRadial": 0.93,
"shoulderWidth": 0.92,
"torso": 0.92,
"walkSpeedScale": 0.95
},
"restOrientationErrors": []
},
"seams": {
"coincidentGroups": 3697,
"examples": [
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
0.0649,
0.0281,
1.3797
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
0.066,
0.0231,
1.3767
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
0.0694,
0.0172,
1.3783
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
-0.0649,
0.0281,
1.3797
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
-0.066,
0.0231,
1.3767
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
-0.0694,
0.0172,
1.3783
]
},
{
"gap": 2e-06,
"objects": [
"Hair2"
],
"position": [
0.0001,
-0.0385,
1.5529
]
},
{
"gap": 2e-06,
"objects": [
"Hair2"
],
"position": [
-0.0001,
-0.0385,
1.5529
]
},
{
"gap": 3e-06,
"objects": [
"Hair2"
],
"position": [
-0.0012,
-0.0342,
1.5629
]
},
{
"gap": 3e-06,
"objects": [
"Hair2"
],
"position": [
-0.001,
-0.0343,
1.5628
]
}
],
"maxGap": 6.944388526471647e-06,
"snappedVertices": 95
},
"sex": "female",
"status": "warn",
"stem": "teenager-female",
"timestamp": 1793419282,
"warnings": [
"bra1",
"Cylinder",
"panties",
"panties-full",
"panties2",
"panties2-full",
"panties3",
"panties4"
]
}
@@ -0,0 +1,73 @@
# Conversion Report — teenager / female
- Stem: `teenager-female`
- Status: **warn**
- Profile hash: `dd6901d4220ae704`
- Objects: 37
- Warnings: 8
- Fails: 0
## Info
- adultArmature: `female`
- ageArmature: `teenager-female`
- animations: `{'scaledLocationCurves': 7028, 'appended': ['Armature|mixamo.com|Layer0.020', 'Armature|mixamo.com|Layer0.018', 'Armature|mixamo.com|Layer0.016', 'Armature|mixamo.com|Layer0.014'], 'aliased': {'swimming-fast': 'swimming'}, 'missing': [], 'stem': 'teenager-female'}`
- outputBase: `edited-normal-teenager-female.blend`
- slots: `bottom,face,feet,hair,top`
- sourceBase: `edited-normal-female.blend`
## Seams
- coincidentGroups: `3697`
- snappedVertices: `95`
- maxGap: `6.944388526471647e-06`
- examples: `[{'position': [0.0649, 0.0281, 1.3797], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.066, 0.0231, 1.3767], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0694, 0.0172, 1.3783], 'gap': 1e-06, 'objects': ['Face']}, {'position': [-0.0649, 0.0281, 1.3797], 'gap': 1e-06, 'objects': ['Face']}, {'position': [-0.066, 0.0231, 1.3767], 'gap': 1e-06, 'objects': ['Face']}, {'position': [-0.0694, 0.0172, 1.3783], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0001, -0.0385, 1.5529], 'gap': 2e-06, 'objects': ['Hair2']}, {'position': [-0.0001, -0.0385, 1.5529], 'gap': 2e-06, 'objects': ['Hair2']}, {'position': [-0.0012, -0.0342, 1.5629], 'gap': 3e-06, 'objects': ['Hair2']}, {'position': [-0.001, -0.0343, 1.5628], 'gap': 3e-06, 'objects': ['Hair2']}]`
## Warnings (first 20)
- `bra1`
- `Cylinder`
- `panties`
- `panties-full`
- `panties2`
- `panties2-full`
- `panties3`
- `panties4`
## Objects
- `Accessory` — ok; verts=69, tris=0 degenerate, influences=4, unweighted=0, edge=0.89..0.94
- `Body` — ok; verts=6578, tris=0 degenerate, influences=4, unweighted=0, edge=0.88..1.15
- `Body-merged` — ok; verts=9178, tris=0 degenerate, influences=4, unweighted=0, edge=0.88..1.15
- `BodyBottom` — ok; verts=1194, tris=0 degenerate, influences=4, unweighted=0, edge=0.89..0.94
- `BodyFeet` — ok; verts=1050, tris=0 degenerate, influences=3, unweighted=0, edge=0.89..1.02
- `BodyTop` — ok; verts=3354, tris=0 degenerate, influences=4, unweighted=0, edge=0.88..1.15
- `BodyTopRobe` — ok; verts=4383, tris=0 degenerate, influences=4, unweighted=0, edge=0.88..1.15
- `Body_shapes` — ok; verts=5550, tris=0 degenerate, influences=4, unweighted=0, edge=0.88..1.15
- `Cylinder` — warn; verts=840, tris=0 degenerate, influences=0, unweighted=840, edge=0.21..13.15
- `Face` — ok; verts=4109, tris=0 degenerate, influences=1, unweighted=0, edge=0.99..0.99
- `Hair` — ok; verts=8, tris=0 degenerate, influences=1, unweighted=0, edge=0.99..0.99
- `Hair001` — ok; verts=8673, tris=0 degenerate, influences=4, unweighted=0, edge=0.40..1.34
- `Hair2` — ok; verts=8673, tris=0 degenerate, influences=2, unweighted=0, edge=0.97..1.00
- `HairBack` — ok; verts=760, tris=0 degenerate, influences=4, unweighted=0, edge=0.95..1.07
- `OnepieceCloth` — ok; verts=6, tris=0 degenerate, influences=3, unweighted=0, edge=0.94..0.94
- `Shoes` — ok; verts=608, tris=0 degenerate, influences=3, unweighted=0, edge=0.93..1.00
- `Tops` — ok; verts=1029, tris=0 degenerate, influences=4, unweighted=0, edge=0.88..0.95
- `bra1` — warn; verts=419, tris=0 degenerate, influences=4, unweighted=0, edge=0.29..0.99
- `bra2` — ok; verts=524, tris=0 degenerate, influences=4, unweighted=0, edge=0.40..1.00
- `bra3` — ok; verts=450, tris=0 degenerate, influences=4, unweighted=0, edge=0.49..1.00
- `bra4` — ok; verts=450, tris=0 degenerate, influences=4, unweighted=0, edge=0.49..1.00
- `panties` — warn; verts=797, tris=0 degenerate, influences=0, unweighted=797, edge=0.19..50.22
- `panties-full` — warn; verts=425, tris=0 degenerate, influences=0, unweighted=425, edge=0.13..1.33
- `panties2` — warn; verts=1328, tris=0 degenerate, influences=0, unweighted=1328, edge=0.39..10.55
- `panties2-full` — warn; verts=558, tris=0 degenerate, influences=0, unweighted=558, edge=0.64..9.16
- `panties3` — warn; verts=820, tris=0 degenerate, influences=0, unweighted=820, edge=0.13..7.30
- `panties4` — warn; verts=752, tris=0 degenerate, influences=0, unweighted=752, edge=0.12..7.74
- `panties5` — ok; verts=758, tris=0 degenerate, influences=4, unweighted=0, edge=0.90..0.94
- `panties6` — ok; verts=758, tris=0 degenerate, influences=4, unweighted=0, edge=0.88..0.95
- `panties7` — ok; verts=758, tris=0 degenerate, influences=4, unweighted=0, edge=0.88..0.95
- `shoes1` — ok; verts=414, tris=0 degenerate, influences=3, unweighted=0, edge=0.93..0.98
- `skirt1` — ok; verts=154, tris=0 degenerate, influences=4, unweighted=0, edge=0.89..0.94
- `skirt2` — ok; verts=176, tris=0 degenerate, influences=4, unweighted=0, edge=0.89..0.94
- `skirt3` — ok; verts=154, tris=0 degenerate, influences=4, unweighted=0, edge=0.90..0.94
- `top1` — ok; verts=852, tris=0 degenerate, influences=4, unweighted=0, edge=0.58..1.05
- `top2` — ok; verts=854, tris=0 degenerate, influences=4, unweighted=0, edge=0.58..1.05
- `top3` — ok; verts=854, tris=0 degenerate, influences=4, unweighted=0, edge=0.58..1.05
@@ -0,0 +1,482 @@
{
"age": "teenager",
"fails": [],
"info": {
"adultArmature": "male",
"ageArmature": "teenager-male",
"animations": {
"aliased": {
"swimming-fast": "swimming"
},
"appended": [
"Armature|mixamo.com|Layer0.020",
"Armature|mixamo.com|Layer0.018",
"Armature|mixamo.com|Layer0.016",
"Armature|mixamo.com|Layer0.014",
"Armature|mixamo.com|Layer0.012"
],
"missing": [],
"scaledLocationCurves": 7601,
"stem": "teenager-male"
},
"outputBase": "edited-normal-teenager-male.blend",
"slots": "bottom,face,feet,hair,top",
"sourceBase": "edited-normal-male.blend"
},
"objects": {
"Accessory": {
"degenerateTris": 0,
"edgeRatioMax": 0.9408520391417841,
"edgeRatioMin": 0.9129875147754315,
"maxInfluences": 4,
"note": "",
"polygons": 111,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 145
},
"Body": {
"degenerateTris": 0,
"edgeRatioMax": 1.218153566216659,
"edgeRatioMin": 0.7349989979664033,
"maxInfluences": 4,
"note": "",
"polygons": 6232,
"quality": "ok",
"shapeKeys": 1,
"unweighted": 0,
"vertices": 6584
},
"BodyBottom": {
"degenerateTris": 0,
"edgeRatioMax": 0.9415051179583105,
"edgeRatioMin": 0.8926428739692449,
"maxInfluences": 4,
"note": "",
"polygons": 1228,
"quality": "ok",
"shapeKeys": 1,
"unweighted": 0,
"vertices": 1181
},
"BodyBottomPants": {
"degenerateTris": 0,
"edgeRatioMax": 0.9409140829633826,
"edgeRatioMin": 0.8827862876547917,
"maxInfluences": 4,
"note": "",
"polygons": 972,
"quality": "ok",
"shapeKeys": 1,
"unweighted": 0,
"vertices": 1133
},
"BodyFeet": {
"degenerateTris": 0,
"edgeRatioMax": 1.0153534321863444,
"edgeRatioMin": 0.8887109284611726,
"maxInfluences": 3,
"note": "",
"polygons": 1274,
"quality": "ok",
"shapeKeys": 1,
"unweighted": 0,
"vertices": 1392
},
"BodyFeetPants": {
"degenerateTris": 48,
"edgeRatioMax": 1.0153534321863444,
"edgeRatioMin": 0.8887109284611726,
"maxInfluences": 3,
"note": "",
"polygons": 1466,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1602
},
"BodyFeetPantsShoes": {
"degenerateTris": 48,
"edgeRatioMax": 1.0099758747299858,
"edgeRatioMin": 0.9253361353429929,
"maxInfluences": 3,
"note": "",
"polygons": 830,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1048
},
"BodyTop": {
"degenerateTris": 0,
"edgeRatioMax": 1.218153566216659,
"edgeRatioMin": 0.7349989979664033,
"maxInfluences": 4,
"note": "",
"polygons": 3726,
"quality": "ok",
"shapeKeys": 1,
"unweighted": 0,
"vertices": 3373
},
"BodyTopRobe": {
"degenerateTris": 0,
"edgeRatioMax": 1.1954519257878777,
"edgeRatioMin": 0.7349989979664033,
"maxInfluences": 4,
"note": "",
"polygons": 5384,
"quality": "ok",
"shapeKeys": 1,
"unweighted": 0,
"vertices": 5046
},
"Body_shapes": {
"degenerateTris": 0,
"edgeRatioMax": 1.218153566216659,
"edgeRatioMin": 0.7349989979664033,
"maxInfluences": 4,
"note": "",
"polygons": 6228,
"quality": "ok",
"shapeKeys": 2,
"unweighted": 0,
"vertices": 5556
},
"Bottoms": {
"degenerateTris": 0,
"edgeRatioMax": 0.9635867444531326,
"edgeRatioMin": 0.8827862876547917,
"maxInfluences": 4,
"note": "",
"polygons": 826,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 942
},
"Face": {
"degenerateTris": 0,
"edgeRatioMax": 0.9902318510318413,
"edgeRatioMin": 0.9899010785060705,
"maxInfluences": 1,
"note": "",
"polygons": 3839,
"quality": "ok",
"shapeKeys": 57,
"unweighted": 0,
"vertices": 4045
},
"Hair": {
"degenerateTris": 0,
"edgeRatioMax": 0.9900002385614128,
"edgeRatioMin": 0.9899999851477491,
"maxInfluences": 1,
"note": "",
"polygons": 4,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 9
},
"Hair2": {
"degenerateTris": 0,
"edgeRatioMax": 0.9990771032056067,
"edgeRatioMin": 0.949542700174737,
"maxInfluences": 2,
"note": "",
"polygons": 4483,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 7022
},
"HairBack": {
"degenerateTris": 0,
"edgeRatioMax": 1.0723475348632554,
"edgeRatioMin": 0.9495563211922972,
"maxInfluences": 4,
"note": "",
"polygons": 724,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 760
},
"Panties001": {
"degenerateTris": 0,
"edgeRatioMax": 0.9414287020897463,
"edgeRatioMin": 0.8949860611150358,
"maxInfluences": 4,
"note": "",
"polygons": 604,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 582
},
"Pants0001": {
"degenerateTris": 0,
"edgeRatioMax": 0.9741146771468555,
"edgeRatioMin": 0.7167993480571109,
"maxInfluences": 4,
"note": "",
"polygons": 898,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 915
},
"Shoes": {
"degenerateTris": 0,
"edgeRatioMax": 1.0099758747299858,
"edgeRatioMin": 0.9253361353429929,
"maxInfluences": 3,
"note": "",
"polygons": 334,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 478
},
"Tops": {
"degenerateTris": 0,
"edgeRatioMax": 1.058850186591336,
"edgeRatioMin": 0.8334681396211425,
"maxInfluences": 4,
"note": "",
"polygons": 1658,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1896
},
"TopsEdited": {
"degenerateTris": 0,
"edgeRatioMax": 1.058850186591336,
"edgeRatioMin": 0.8334681396211425,
"maxInfluences": 4,
"note": "",
"polygons": 1658,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1896
},
"coat": {
"degenerateTris": 0,
"edgeRatioMax": 0.9465083443563386,
"edgeRatioMin": 0.8345250576121954,
"maxInfluences": 4,
"note": "",
"polygons": 1658,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1896
},
"hair0001": {
"degenerateTris": 0,
"edgeRatioMax": 1.1823451224746337,
"edgeRatioMin": 0.949542700174737,
"maxInfluences": 4,
"note": "",
"polygons": 4483,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 7022
},
"hair0002": {
"degenerateTris": 0,
"edgeRatioMax": 1.1823451224746337,
"edgeRatioMin": 0.949542700174737,
"maxInfluences": 4,
"note": "",
"polygons": 4483,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 7022
},
"hair0003": {
"degenerateTris": 0,
"edgeRatioMax": 1.0,
"edgeRatioMin": 1.0,
"maxInfluences": 0,
"note": "",
"polygons": 4483,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 7022
},
"shoes1": {
"degenerateTris": 0,
"edgeRatioMax": 0.9949808923340873,
"edgeRatioMin": 0.9268173377136528,
"maxInfluences": 3,
"note": "",
"polygons": 334,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 478
}
},
"profileHash": "dd6901d4220ae704",
"rig": {
"adultBones": 242,
"ageBones": 242,
"profile": {
"arm": 0.92,
"bodyRadial": 0.94,
"foot": 0.97,
"head": 0.99,
"headBulge": 1.0,
"hipHeight": 0.94,
"hipWidth": 0.92,
"leg": 0.94,
"limbRadial": 0.93,
"shoulderWidth": 0.92,
"torso": 0.92,
"walkSpeedScale": 0.95
},
"restOrientationErrors": []
},
"seams": {
"coincidentGroups": 5119,
"examples": [
{
"gap": 2e-06,
"objects": [
"BodyTop",
"BodyTopRobe"
],
"position": [
0.0,
0.09,
1.4972
]
},
{
"gap": 1e-06,
"objects": [
"BodyTop",
"BodyTopRobe"
],
"position": [
0.0,
0.0858,
1.6039
]
},
{
"gap": 2e-06,
"objects": [
"BodyTop",
"BodyTopRobe"
],
"position": [
0.0,
-0.0184,
1.5008
]
},
{
"gap": 2e-06,
"objects": [
"BodyTop",
"Face"
],
"position": [
0.0633,
0.0369,
1.6496
]
},
{
"gap": 2e-06,
"objects": [
"BodyTop",
"Face"
],
"position": [
-0.0633,
0.0369,
1.6496
]
},
{
"gap": 1e-06,
"objects": [
"BodyTop",
"BodyTopRobe",
"Face"
],
"position": [
0.0842,
0.0277,
1.6955
]
},
{
"gap": 1e-06,
"objects": [
"BodyTop",
"BodyTopRobe",
"Face"
],
"position": [
-0.0842,
0.0277,
1.6955
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
0.0633,
0.0344,
1.6475
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
0.0636,
0.0319,
1.6457
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
0.0645,
0.0294,
1.6445
]
}
],
"maxGap": 7.936056721462239e-06,
"snappedVertices": 226
},
"sex": "male",
"status": "warn",
"stem": "teenager-male",
"timestamp": 1793419277,
"warnings": [
"BodyFeetPants",
"BodyFeetPantsShoes"
]
}
@@ -0,0 +1,55 @@
# Conversion Report — teenager / male
- Stem: `teenager-male`
- Status: **warn**
- Profile hash: `dd6901d4220ae704`
- Objects: 25
- Warnings: 2
- Fails: 0
## Info
- adultArmature: `male`
- ageArmature: `teenager-male`
- animations: `{'scaledLocationCurves': 7601, 'appended': ['Armature|mixamo.com|Layer0.020', 'Armature|mixamo.com|Layer0.018', 'Armature|mixamo.com|Layer0.016', 'Armature|mixamo.com|Layer0.014', 'Armature|mixamo.com|Layer0.012'], 'aliased': {'swimming-fast': 'swimming'}, 'missing': [], 'stem': 'teenager-male'}`
- outputBase: `edited-normal-teenager-male.blend`
- slots: `bottom,face,feet,hair,top`
- sourceBase: `edited-normal-male.blend`
## Seams
- coincidentGroups: `5119`
- snappedVertices: `226`
- maxGap: `7.936056721462239e-06`
- examples: `[{'position': [0.0, 0.09, 1.4972], 'gap': 2e-06, 'objects': ['BodyTop', 'BodyTopRobe']}, {'position': [0.0, 0.0858, 1.6039], 'gap': 1e-06, 'objects': ['BodyTop', 'BodyTopRobe']}, {'position': [0.0, -0.0184, 1.5008], 'gap': 2e-06, 'objects': ['BodyTop', 'BodyTopRobe']}, {'position': [0.0633, 0.0369, 1.6496], 'gap': 2e-06, 'objects': ['BodyTop', 'Face']}, {'position': [-0.0633, 0.0369, 1.6496], 'gap': 2e-06, 'objects': ['BodyTop', 'Face']}, {'position': [0.0842, 0.0277, 1.6955], 'gap': 1e-06, 'objects': ['BodyTop', 'BodyTopRobe', 'Face']}, {'position': [-0.0842, 0.0277, 1.6955], 'gap': 1e-06, 'objects': ['BodyTop', 'BodyTopRobe', 'Face']}, {'position': [0.0633, 0.0344, 1.6475], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0636, 0.0319, 1.6457], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0645, 0.0294, 1.6445], 'gap': 1e-06, 'objects': ['Face']}]`
## Warnings (first 20)
- `BodyFeetPants`
- `BodyFeetPantsShoes`
## Objects
- `Accessory` — ok; verts=145, tris=0 degenerate, influences=4, unweighted=0, edge=0.91..0.94
- `Body` — ok; verts=6584, tris=0 degenerate, influences=4, unweighted=0, edge=0.73..1.22
- `BodyBottom` — ok; verts=1181, tris=0 degenerate, influences=4, unweighted=0, edge=0.89..0.94
- `BodyBottomPants` — ok; verts=1133, tris=0 degenerate, influences=4, unweighted=0, edge=0.88..0.94
- `BodyFeet` — ok; verts=1392, tris=0 degenerate, influences=3, unweighted=0, edge=0.89..1.02
- `BodyFeetPants` — warn; verts=1602, tris=48 degenerate, influences=3, unweighted=0, edge=0.89..1.02
- `BodyFeetPantsShoes` — warn; verts=1048, tris=48 degenerate, influences=3, unweighted=0, edge=0.93..1.01
- `BodyTop` — ok; verts=3373, tris=0 degenerate, influences=4, unweighted=0, edge=0.73..1.22
- `BodyTopRobe` — ok; verts=5046, tris=0 degenerate, influences=4, unweighted=0, edge=0.73..1.20
- `Body_shapes` — ok; verts=5556, tris=0 degenerate, influences=4, unweighted=0, edge=0.73..1.22
- `Bottoms` — ok; verts=942, tris=0 degenerate, influences=4, unweighted=0, edge=0.88..0.96
- `Face` — ok; verts=4045, tris=0 degenerate, influences=1, unweighted=0, edge=0.99..0.99
- `Hair` — ok; verts=9, tris=0 degenerate, influences=1, unweighted=0, edge=0.99..0.99
- `Hair2` — ok; verts=7022, tris=0 degenerate, influences=2, unweighted=0, edge=0.95..1.00
- `HairBack` — ok; verts=760, tris=0 degenerate, influences=4, unweighted=0, edge=0.95..1.07
- `Panties001` — ok; verts=582, tris=0 degenerate, influences=4, unweighted=0, edge=0.89..0.94
- `Pants0001` — ok; verts=915, tris=0 degenerate, influences=4, unweighted=0, edge=0.72..0.97
- `Shoes` — ok; verts=478, tris=0 degenerate, influences=3, unweighted=0, edge=0.93..1.01
- `Tops` — ok; verts=1896, tris=0 degenerate, influences=4, unweighted=0, edge=0.83..1.06
- `TopsEdited` — ok; verts=1896, tris=0 degenerate, influences=4, unweighted=0, edge=0.83..1.06
- `coat` — ok; verts=1896, tris=0 degenerate, influences=4, unweighted=0, edge=0.83..0.95
- `hair0001` — ok; verts=7022, tris=0 degenerate, influences=4, unweighted=0, edge=0.95..1.18
- `hair0002` — ok; verts=7022, tris=0 degenerate, influences=4, unweighted=0, edge=0.95..1.18
- `hair0003` — ok; verts=7022, tris=0 degenerate, influences=0, unweighted=0, edge=1.00..1.00
- `shoes1` — ok; verts=478, tris=0 degenerate, influences=3, unweighted=0, edge=0.93..0.99
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,660 @@
#!/usr/bin/env python3
"""Generate first-pass age-band character seeds from the adult assets.
Run with Blender 3.6 batch mode, for example::
blender -b -P assets/blender/characters/generate_age_blends.py -- \
--age elder --sex male --report-only
The script is an authoring tool: it writes committed seed ``.blend`` files and
conversion reports. It is deliberately NOT part of the CMake build, and it
will not overwrite a file that changed since it was generated unless
``--force`` is given. See AGES-PLAN.md for the design.
"""
from __future__ import annotations
import argparse
import glob
import hashlib
import json
import os
import shutil
import sys
import tempfile
import time
import bpy
_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
if _THIS_DIR not in sys.path:
sys.path.insert(0, _THIS_DIR)
from agegen import anim, deform, profiles, report, rig, weights # noqa: E402
def log(msg):
print("[agegen] " + msg, flush=True)
def sha256(path):
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
def manifest_path(out_dir):
return os.path.join(out_dir, "AGE-GENERATION.json")
def load_manifest(out_dir):
path = manifest_path(out_dir)
if not os.path.exists(path):
return {"version": 1, "stems": {}}
try:
with open(path) as f:
data = json.load(f)
if "stems" not in data:
data["stems"] = {}
return data
except Exception:
return {"version": 1, "stems": {}}
def save_manifest(out_dir, data):
data["profileHash"] = profiles.profile_hash()
data["updated"] = int(time.time())
os.makedirs(out_dir, exist_ok=True)
with open(manifest_path(out_dir), "w") as f:
json.dump(data, f, indent=2, sort_keys=True)
def find_anim_source(sex, explicit):
if explicit:
return explicit if os.path.exists(explicit) else None
name = f"vrm-vroid-normal-{sex}.blend"
roots = [
os.getcwd(),
os.path.dirname(_THIS_DIR),
os.path.dirname(os.path.dirname(_THIS_DIR)),
"/media/slapin/library/ogre3/ogre-projects/world2",
]
patterns = []
for root in roots:
patterns += [
os.path.join(root, "build-vscode", "assets", "blender", name),
os.path.join(root, "build", "assets", "blender", name),
os.path.join(root, "assets", "blender", name),
]
patterns += glob.glob(
os.path.join(os.getcwd(), "build*", "assets", "blender", name))
for path in patterns:
if path and os.path.exists(path):
return path
return None
def source_paths(age, sex, source_dir):
stem = profiles.stem(age, sex)
base = os.path.join(source_dir,
f"edited-normal-{sex}.blend")
clothes = {
slot: os.path.join(source_dir, f"clothes-{sex}-{slot}.blend")
for slot in ("top", "bottom", "feet", "hair")
}
out_base = os.path.join(source_dir,
f"edited-normal-{age}-{sex}.blend")
out_clothes = {
slot: os.path.join(source_dir,
f"clothes-{age}-{sex}-{slot}.blend")
for slot in ("top", "bottom", "feet", "hair")
}
return stem, base, clothes, out_base, out_clothes
def all_outputs(out_base, out_clothes):
return [out_base] + [out_clothes[s] for s in
("top", "bottom", "feet", "hair")]
def stem_protected(stem, outputs, manifest, force):
if force:
return False
entry = manifest.get("stems", {}).get(stem)
if not entry:
# Existing files without a manifest are assumed to be hand-authored.
return any(os.path.exists(p) for p in outputs)
if entry.get("state") == "hand-edited":
return True
hashes = entry.get("outputHashes", {})
for path in outputs:
if not os.path.exists(path):
continue
want = hashes.get(os.path.basename(path))
if want and sha256(path) != want:
log(f" {os.path.basename(path)} changed since generation; "
f"treating as hand-edited")
return True
if not want:
return True
return False
def append_objects(blend_path, names):
before = set(bpy.data.objects)
with bpy.data.libraries.load(blend_path, link=False) as (src, dst):
dst.objects = [n for n in names if n in src.objects]
loaded = []
for obj in dst.objects:
if obj is None:
continue
try:
bpy.context.collection.objects.link(obj)
except RuntimeError:
pass
loaded.append(obj)
# Blender may pull in child objects of an appended armature. Keep only
# the explicitly requested objects; the conversion math does not need
# their children, and clothes seed files must not contain body meshes.
keep = set(loaded)
for obj in list(bpy.data.objects):
if obj not in before and obj not in keep:
try:
bpy.data.objects.remove(obj, do_unlink=True)
except Exception:
pass
return loaded
def delete_object(obj):
if obj is None:
return
try:
bpy.data.objects.remove(obj, do_unlink=True)
except Exception:
pass
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 cleanup_seed_objects(adult_arm):
"""Remove Blender control junk that must not live in a committed seed."""
for obj in list(bpy.data.objects):
if obj == adult_arm:
continue
low = obj.name.lower()
if obj.name.startswith("cs_") or "collider" in low:
delete_object(obj)
continue
if obj.type == 'ARMATURE':
# Keep only the age armature (and any hair armature handled by
# the clothes converter). The adult armature is deleted by the
# caller after animation retargeting.
continue
for obj in list(bpy.data.objects):
if obj.type == 'EMPTY' and obj.name.startswith("cs_"):
delete_object(obj)
def set_catalog_props(obj, age, sex):
if not obj or obj.type != 'MESH':
return
if all(k in obj for k in ("age", "sex", "slot")):
obj["age"] = profiles.normalize_age(age)
obj["sex"] = sex
obj["agegen_state"] = "auto-seed"
obj["agegen_profile"] = profiles.profile_hash()
def convert_body(age, sex, base_path, out_path, anim_source,
write, report_only):
log(f"loading adult base {base_path}")
bpy.ops.wm.open_mainfile(filepath=base_path)
bpy.context.scene.frame_set(1)
adult_arm = bpy.data.objects.get(sex)
if adult_arm is None or adult_arm.type != 'ARMATURE':
raise RuntimeError(f"adult armature '{sex}' not found in {base_path}")
prof = profiles.profile(age)
stem = profiles.stem(age, sex)
age_arm = rig.build_age_armature(adult_arm, age, sex, prof)
log(f"built armature {age_arm.name}")
rest_data = rig.rest_transform_data(adult_arm, age_arm, age, prof)
dmat = rig.dmatrices_from_data(rest_data)
bad = rig.validate_rest_orientation(adult_arm, age_arm)
if bad:
raise RuntimeError("rest orientation changed for bones: " +
", ".join(bad[:10]))
rep = report.ConversionReport(age, sex)
rep.info["sourceBase"] = os.path.basename(base_path)
rep.info["adultArmature"] = adult_arm.name
rep.info["ageArmature"] = age_arm.name
rep.rig = {
"adultBones": len(adult_arm.data.bones),
"ageBones": len(age_arm.data.bones),
"restOrientationErrors": bad,
"profile": prof,
}
converted = []
for obj in list(bpy.data.objects):
if obj.type != 'MESH':
continue
if obj.name.startswith("cs_"):
continue
if obj == adult_arm:
continue
if obj.data is None:
continue
if obj.vertex_groups:
weights.limit_vertex_influences(obj, 4)
metrics = deform.deform_mesh_object(
obj, adult_arm, age_arm, dmat, prof, retarget_parent=True)
if metrics is None:
continue
set_catalog_props(obj, age, sex)
is_catalog = all(k in obj for k in ("age", "sex", "slot"))
quality = report.evaluate_object_quality(metrics, is_catalog)
obj["agegen_quality"] = quality
rep.add_object(obj.name, metrics, quality=quality)
if is_catalog:
converted.append((obj, metrics))
seam = deform.snap_seams(converted)
rep.seams = seam
log(f"seam groups={seam['coincidentGroups']} "
f"snapped={seam['snappedVertices']} maxGap={seam['maxGap']:.6f}")
# Required catalog slots per sex.
slots = sorted({obj.get("slot") for obj, _ in converted
if obj.get("slot")})
rep.info["slots"] = ",".join(slots)
for required in ("face", "top", "bottom", "feet", "hair"):
if required not in slots:
rep.info.setdefault("missingSlots", [])
rep.info["missingSlots"].append(required)
if anim_source and os.path.exists(anim_source):
try:
summary = anim.append_and_retarget(
age_arm, adult_arm, anim_source, age, sex)
rep.info["animations"] = summary
if summary.get("missing"):
rep.info["missingAnimations"] = summary["missing"]
except Exception as exc:
rep.info["animationError"] = str(exc)
log(f"animation retarget failed: {exc}")
else:
rep.info["animationError"] = "animation source not found"
log("animation source not found; seed will have no clips")
# Remove the adult armature and control junk; re-parented meshes remain.
cleanup_seed_objects(adult_arm)
delete_object(adult_arm)
# Name and validate the single body armature.
age_arm.name = stem
age_arm.data.name = stem
set_catalog_props(age_arm, age, sex)
for obj in bpy.data.objects:
set_catalog_props(obj, age, sex)
rep.info["outputBase"] = os.path.basename(out_path)
os.makedirs(os.path.dirname(out_path), exist_ok=True)
purge_orphans()
if write:
bpy.ops.wm.save_as_mainfile(filepath=out_path)
log(f"saved body seed {out_path}")
return rep, rest_data, age_arm
def find_own_hair_armature(obj, adult_arm, age_arm):
own_name = obj.get("own_armature_name", "")
if own_name:
arm = bpy.data.objects.get(own_name)
if arm and arm.type == 'ARMATURE':
return arm
for mod in obj.modifiers:
if mod.type != 'ARMATURE' or mod.object is None:
continue
if mod.object != adult_arm and mod.object != age_arm:
return mod.object
if obj.parent and obj.parent.type == 'ARMATURE' and \
obj.parent not in (adult_arm, age_arm):
return obj.parent
return None
def rename_hair_armature(arm, stem, original_name):
safe = "".join(c if c.isalnum() or c in "-_" else "_"
for c in original_name)
new_name = f"{stem}-hair-{safe}"
arm.name = new_name
arm.data.name = new_name
return new_name
def transfer_weights_from_body(obj, body_obj):
"""Copy body weight groups onto an unskinned garment/helper.
The source clothes files intentionally carry no rig; the normal build
transfers weights later. Doing it before conversion gives the geometry a
smooth deformation instead of nearest-bone rigid pieces, and the build
repeats it from the converted body as usual.
"""
if body_obj is None or body_obj.type != 'MESH':
return False
if not body_obj.vertex_groups:
return False
obj.vertex_groups.clear()
dt = obj.modifiers.new(name="WT", type='DATA_TRANSFER')
dt.object = body_obj
dt.use_vert_data = True
dt.data_types_verts = {'VGROUP_WEIGHTS'}
dt.layers_vgroup_select_src = 'ALL'
dt.vert_mapping = 'POLYINTERP_NEAREST'
bpy.context.view_layer.objects.active = obj
try:
bpy.ops.object.datalayout_transfer(modifier=dt.name)
bpy.ops.object.modifier_apply(modifier=dt.name)
except Exception as exc:
log(f" weight transfer failed for {obj.name}: {exc}")
try:
obj.modifiers.remove(dt)
except Exception:
pass
return False
weights.limit_vertex_influences(obj, 4)
return True
def convert_clothes(age, sex, slot, clothes_path, out_path,
adult_base, body_seed, rest_data, write, rep,
profile):
log(f"converting {slot}: {clothes_path}")
bpy.ops.wm.open_mainfile(filepath=clothes_path)
stem = profiles.stem(age, sex)
adult_arms = append_objects(adult_base, [sex])
age_arms = append_objects(body_seed, [stem])
if not adult_arms or not age_arms:
raise RuntimeError("failed to append adult/age armatures")
adult_arm = adult_arms[0]
age_arm = age_arms[0]
dmat = rig.dmatrices_from_data(rest_data)
prof = profile
adult_head, age_head = rig.head_pivots(adult_arm, age_arm)
head_scale = float(prof["head"])
ref_cache = {}
def get_ref(name):
if not name:
return None
if name not in ref_cache:
objs = append_objects(body_seed, [name])
ref_cache[name] = objs[0] if objs else None
return ref_cache[name]
hair_arm_map = {}
meshes = [o for o in bpy.data.objects
if o.type == 'MESH' and o.data is not None]
for obj in meshes:
own_arm = find_own_hair_armature(obj, adult_arm, age_arm)
if own_arm is not None:
if own_arm.name not in hair_arm_map:
old = own_arm.name
new = rename_hair_armature(own_arm, stem, old)
hair_arm_map[own_arm.name] = new
rig.scale_hair_armature(own_arm, adult_head, age_head,
head_scale)
log(f" scaled hair armature {old} -> {new}")
deform.transform_hair_uniform(obj, adult_head, age_head,
head_scale)
obj["has_own_armature"] = True
obj["own_armature_name"] = own_arm.name
obj.parent = own_arm
mod = next((m for m in obj.modifiers
if m.type == 'ARMATURE'), None)
if mod:
mod.object = own_arm
metrics = {
"vertices": len(obj.data.vertices),
"polygons": len(obj.data.polygons),
"shapeKeys": 0,
"unweighted": 0,
"maxInfluences": 0,
"edgeRatioMin": 1.0,
"edgeRatioMax": 1.0,
"degenerateTris": 0,
}
quality = "ok"
else:
ref = get_ref(obj.get("ref_clothing", ""))
transfer_weights_from_body(obj, ref)
metrics = deform.deform_mesh_object(
obj, adult_arm, age_arm, dmat, prof,
retarget_parent=False)
quality = report.evaluate_object_quality(metrics, True) \
if metrics else "warn"
obj["agegen_quality"] = quality
# Garment seed files carry no body armature.
obj.parent = None
for mod in list(obj.modifiers):
if mod.type == 'ARMATURE':
obj.modifiers.remove(mod)
if "ref_layer" in obj:
obj["ref_age"] = profiles.normalize_age(age)
obj["ref_sex"] = sex
obj["agegen_state"] = "auto-seed"
rep.add_object(obj.name, metrics, quality=quality)
# Remove the appended body armatures and reference helpers, keep hair
# armatures.
for ref in ref_cache.values():
delete_object(ref)
for arm in age_arms + adult_arms:
delete_object(arm)
os.makedirs(os.path.dirname(out_path), exist_ok=True)
purge_orphans()
if write:
bpy.ops.wm.save_as_mainfile(filepath=out_path)
log(f"saved clothes seed {out_path}")
def check_seed(age, sex, source_dir):
stem, base, clothes, out_base, out_clothes = source_paths(
age, sex, source_dir)
outputs = all_outputs(out_base, out_clothes)
manifest = load_manifest(source_dir)
entry = manifest.get("stems", {}).get(stem)
missing = [p for p in outputs if not os.path.exists(p)]
problems = []
if missing:
problems.append("missing: " + ", ".join(os.path.basename(p)
for p in missing))
if not entry:
problems.append("no manifest entry")
else:
if entry.get("profileHash") not in (None,
profiles.profile_hash()):
problems.append("profile hash mismatch")
hashes = entry.get("outputHashes", {})
for p in outputs:
if not os.path.exists(p):
continue
want = hashes.get(os.path.basename(p))
if not want:
problems.append("no hash for " + os.path.basename(p))
elif sha256(p) != want:
problems.append("changed: " + os.path.basename(p))
if problems:
log(f"CHECK FAIL {stem}: " + "; ".join(problems))
return False
log(f"CHECK OK {stem}")
return True
def process_pair(age, sex, args):
stem, base, clothes, out_base, out_clothes = source_paths(
age, sex, args.source_dir)
outputs = all_outputs(out_base, out_clothes)
manifest = load_manifest(args.source_dir)
if args.check:
return check_seed(age, sex, args.source_dir)
if not os.path.exists(base):
raise RuntimeError(f"adult base not found: {base}")
if stem_protected(stem, outputs, manifest, args.force):
log(f"SKIP {stem}: output exists and is hand-edited/changed "
f"(use --force to overwrite)")
return True
anim_source = find_anim_source(sex, args.anim_source)
if anim_source:
log(f"animation source: {anim_source}")
else:
log("WARNING: animation source not found; no clips will be added")
# Body conversion needs a file for the clothes pass even in report-only
# mode; use a temp file that is removed afterwards.
tmp_dir = tempfile.mkdtemp(prefix="agegen-")
body_seed = out_base if (args.write and not args.report_only) else \
os.path.join(tmp_dir, os.path.basename(out_base))
rep, rest_data, _age_arm = convert_body(
age, sex, base, body_seed, anim_source,
write=True, report_only=args.report_only)
if args.report_only:
log(f"temporary body seed: {body_seed}")
if not args.no_clothes:
for slot in ("top", "bottom", "feet", "hair"):
src = clothes[slot]
if not os.path.exists(src):
log(f"WARNING: missing clothes source {src}; skipping")
continue
convert_clothes(
age, sex, slot, src, out_clothes[slot], base, body_seed,
rest_data, write=(not args.report_only), rep=rep,
profile=profiles.profile(age))
# Write report (always useful, even in report-only mode).
report_path, report_json = rep.write(args.source_dir)
log(f"report: {report_path}")
if args.report_only:
shutil.rmtree(tmp_dir, ignore_errors=True)
return True
entry = {
"state": "auto-seed",
"age": profiles.normalize_age(age),
"sex": sex,
"profileHash": profiles.profile_hash(),
"sourceHashes": {
os.path.basename(base): sha256(base),
**{os.path.basename(clothes[s]): sha256(clothes[s])
for s in ("top", "bottom", "feet", "hair")
if os.path.exists(clothes[s])},
},
"outputHashes": {
os.path.basename(p): sha256(p)
for p in outputs if os.path.exists(p)
},
"report": os.path.basename(report_path),
"reportJson": os.path.basename(report_json),
"updated": int(time.time()),
}
if anim_source:
entry["animationSource"] = anim_source
entry["animationSourceHash"] = sha256(anim_source)
manifest.setdefault("stems", {})[stem] = entry
save_manifest(args.source_dir, manifest)
shutil.rmtree(tmp_dir, ignore_errors=True)
if rep.status() == "fail" and not args.allow_fail:
log(f"WARNING: {stem} has fail-level objects; see report")
return True
def parse_args():
argv = sys.argv
if "--" in argv:
argv = argv[argv.index("--") + 1:]
else:
argv = []
p = argparse.ArgumentParser()
p.add_argument("--age", choices=profiles.CANONICAL_AGES)
p.add_argument("--sex", choices=["male", "female"])
p.add_argument("--all", action="store_true")
p.add_argument("--source-dir", default=_THIS_DIR)
p.add_argument("--anim-source", default="")
p.add_argument("--report-only", action="store_true")
p.add_argument("--write", action="store_true")
p.add_argument("--check", action="store_true")
p.add_argument("--force", action="store_true")
p.add_argument("--only-unedited", action="store_true")
p.add_argument("--allow-fail", action="store_true")
p.add_argument("--no-clothes", action="store_true")
p.add_argument("--no-anim", action="store_true")
args = p.parse_args(argv)
if not args.check and not args.report_only and not args.write:
args.write = True
if args.only_unedited:
args.write = True
if args.all:
if args.age or args.sex:
p.error("--all cannot be combined with --age/--sex")
else:
if not args.age or not args.sex:
p.error("specify --age and --sex, or --all")
if args.age == "adult":
p.error("adult is the source, not a generated age")
if args.no_anim:
args.anim_source = "__none__"
return args
def main():
args = parse_args()
args.source_dir = os.path.abspath(args.source_dir)
pairs = []
if args.all:
for age in ("elder", "teenager", "child", "baby"):
for sex in ("male", "female"):
pairs.append((age, sex))
else:
pairs.append((profiles.normalize_age(args.age), args.sex))
ok = True
for age, sex in pairs:
log(f"=== {age} / {sex} ===")
try:
if not process_pair(age, sex, args):
ok = False
except Exception as exc:
import traceback
traceback.print_exc()
log(f"FAILED {age}/{sex}: {exc}")
ok = False
if not ok:
sys.exit(1)
if __name__ == "__main__":
main()
+30 -21
View File
@@ -2,18 +2,18 @@
#
# Run in Blender batch mode with the consolidated blend already loaded:
#
# blender -b -Y edited-normal-<sex>-consolidated.blend \
# -P generate_lods.py -- <sex> <out_dir> <lods_blend_out>
# blender -b -Y <consolidated.blend> \
# -P generate_lods.py -- <age> <sex> <out_dir> <lods_blend_out>
#
# Produces, per sex:
# * <out_dir>/charlod_<sex>-atlas.png - shared 2048x2048 LOD atlas
# * <out_dir>/charlod_<sex>.json - manifest
# Produces, per (age, sex) stem (adult keeps the legacy sex stem):
# * <out_dir>/charlod_<stem>-atlas.png - shared 2048x2048 LOD atlas
# * <out_dir>/charlod_<stem>.json - manifest
# * LOD1 object variants `<part>_lod1` - same geometry, no shape keys,
# UVs remapped into the atlas, one shared material `charlod_<sex>`
# UVs remapped into the atlas, one shared material `charlod_<stem>`
# (hair slot is skipped - hair is hidden/impostor-baked at distance)
# * LOD2 impostors `charlod_<sex>_{thin,fat}_lod2` - the deterministic
# * LOD2 impostors `charlod_<stem>_{thin,fat}_lod2` - the deterministic
# default outfit joined into one mesh skinned to the body armature
# * LOD3 impostors `charlod_<sex>_{thin,fat}_lod3` - decimate of LOD2,
# * LOD3 impostors `charlod_<stem>_{thin,fat}_lod3` - decimate of LOD2,
# <= 400 triangles (hard limit)
# * saves everything into <lods_blend_out>
#
@@ -52,6 +52,11 @@ import sys
import numpy as np
_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
ATLAS_SIZE = 2048
LOD3_MAX_TRIS = 400
REQUIRED_PROPS = {"age", "sex", "slot"}
@@ -89,11 +94,14 @@ def log(msg):
def parse_args():
argv = sys.argv
argv = argv[argv.index("--") + 1:]
if len(argv) < 3:
if len(argv) < 4:
raise RuntimeError(
"usage: blender -b -Y <consolidated.blend> -P generate_lods.py"
" -- <sex> <out_dir> <lods_blend_out>")
return argv[0], argv[1], argv[2]
" -- <age> <sex> <out_dir> <lods_blend_out>")
age = age_profiles.normalize_age(argv[0])
sex = argv[1]
stem = age_profiles.stem(age, sex)
return stem, argv[2], argv[3]
def strip_suffix(name):
@@ -617,12 +625,13 @@ def write_manifest(sex, out_dir):
def main():
sex, out_dir, lods_blend_out = parse_args()
log("sex=%s out_dir=%s lods_blend=%s" % (sex, out_dir,
lods_blend_out))
stem = sex
log("stem=%s out_dir=%s lods_blend=%s" % (stem, out_dir,
lods_blend_out))
cleanup_previous(sex)
cleanup_previous(stem)
armature = find_armature(sex)
armature = find_armature(stem)
head_bone = find_head_bone(armature)
log("shared armature '%s', head bone '%s'"
% (armature.name, head_bone))
@@ -632,22 +641,22 @@ def main():
% (len(catalog), [ob.name for ob in catalog]))
log("=== step 1: atlas ===")
atlas, rects = build_atlas(catalog, sex, out_dir)
atlas_mat = make_atlas_material(sex, atlas)
atlas, rects = build_atlas(catalog, stem, out_dir)
atlas_mat = make_atlas_material(stem, atlas)
log("=== step 2: LOD1 variants ===")
lod1 = generate_lod1(catalog, rects, atlas_mat, sex)
lod1 = generate_lod1(catalog, rects, atlas_mat, stem)
log("generated %d LOD1 objects" % len(lod1))
log("=== step 3: LOD2 impostors ===")
lod2 = generate_lod2(catalog, rects, atlas_mat, armature,
head_bone, sex)
head_bone, stem)
log("=== step 4: LOD3 impostors ===")
generate_lod3(lod2, armature, sex)
generate_lod3(lod2, armature, stem)
log("=== step 5: manifest + save ===")
write_manifest(sex, out_dir)
write_manifest(stem, out_dir)
bpy.ops.wm.save_as_mainfile(filepath=lods_blend_out)
log("saved lods blend to %s" % lods_blend_out)
log("DONE")
+23 -13
View File
@@ -2,6 +2,11 @@ import bpy
import os
import sys
_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
def clean_scene():
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete()
@@ -36,8 +41,10 @@ def process_batch():
try:
args = sys.argv[sys.argv.index("--") + 1:]
clothing_path, lib_directory, out_dir = args[0], args[1], args[2]
explicit_body = args[3] if len(args) >= 4 else ""
except (IndexError, ValueError):
print("Usage: blender -b -P script.py -- <source_blend> <lib_dir> <out_dir>")
print("Usage: blender -b -P script.py -- "
"<source_blend> <lib_dir> <out_dir> [body_blend]")
return
if not os.path.exists(out_dir): os.makedirs(out_dir)
@@ -62,7 +69,7 @@ def process_batch():
# Get properties
sex = clothing.get("ref_sex")
age = clothing.get("ref_age")
age = age_profiles.normalize_age(clothing.get("ref_age"))
ref_mesh_name = clothing.get("ref_clothing")
if not all([sex, age, ref_mesh_name]):
@@ -70,19 +77,22 @@ def process_batch():
continue
# 3. Locate Reference Library
target_lib_name = f"normal_{age}_{sex}.blend"
target_lib_path = os.path.join(lib_directory, target_lib_name)
rig_name = str(sex)
if not os.path.exists(target_lib_path):
if target_lib_name == "normal_adult_male.blend":
target_lib_name = "edited-normal-male.blend"
elif target_lib_name == "normal_adult_female.blend":
target_lib_name = "edited-normal-female.blend"
rig_name = age_profiles.armature_name(age, sex)
if explicit_body and os.path.exists(explicit_body):
target_lib_path = explicit_body
else:
target_lib_name = f"normal_{age}_{sex}.blend"
target_lib_path = os.path.join(lib_directory, target_lib_name)
if not os.path.exists(target_lib_path):
print(f"Error: Library {target_lib_path} not found")
continue
if age == "adult":
fallback = {
"male": "edited-normal-male.blend",
"female": "edited-normal-female.blend",
}.get(sex, "")
target_lib_path = os.path.join(lib_directory, fallback)
if not os.path.exists(target_lib_path):
print(f"Error: Library {target_lib_path} not found")
continue
# 4. Append Weights Source and Rig
with bpy.data.libraries.load(target_lib_path) as (data_from, data_to):
@@ -0,0 +1,167 @@
#!/usr/bin/env python3
"""Structural validator for generated character age assets.
Run after the character build (the CMake target ``character-assets-check``
does this automatically)::
python3 assets/blender/characters/validate_age_assets.py \
--characters-dir build-vscode/characters
Checks:
* every age/sex stem has the expected body skeleton and catalog slots;
* catalog ages are canonical and clothing layers are present;
* no duplicate resource basenames across the flat Characters namespace;
* LOD atlas/material files exist;
* material texture references resolve to a staged texture (warning only).
"""
from __future__ import annotations
import argparse
import glob
import json
import os
import re
import sys
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
def fail(msg, errors):
errors.append(msg)
print("FAIL: " + msg)
def warn(msg, warnings):
warnings.append(msg)
print("WARN: " + msg)
def main():
p = argparse.ArgumentParser()
p.add_argument("--characters-dir", required=True)
p.add_argument("--ages", default="",
help="semicolon/comma separated age bands to check "
"(default: all canonical bands)")
args = p.parse_args()
root = os.path.abspath(args.characters_dir)
errors = []
warnings = []
if not os.path.isdir(root):
print("FAIL: characters dir not found: " + root)
return 1
dirs = sorted(d for d in os.listdir(root)
if os.path.isdir(os.path.join(root, d)))
by_stem = {}
for d in dirs:
by_stem[d] = os.path.join(root, d)
ages = age_profiles.CANONICAL_AGES
if args.ages:
ages = [a.strip() for a in re.split(r"[;,]", args.ages)
if a.strip()]
expected_stems = []
for age in ages:
for sex in ("male", "female"):
expected_stems.append(age_profiles.stem(age, sex))
for stem in expected_stems:
d = by_stem.get(stem)
if d is None:
fail(f"missing output directory: {stem}", errors)
continue
cat_files = sorted(glob.glob(os.path.join(d, "body_part_*.json")))
if not cat_files:
fail(f"no body_part_*.json in {stem}", errors)
continue
entries = []
for f in cat_files:
try:
with open(f) as fh:
e = json.load(fh)
except Exception as exc:
fail(f"unreadable catalog {f}: {exc}", errors)
continue
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"}:
fail(f"{stem}: catalog ages {sorted(ages)} do not match stem",
errors)
slots = {}
for e in entries:
slots.setdefault(e.get("slot", ""), []).append(e)
for req in ("face", "top", "bottom", "feet"):
if req not in slots:
fail(f"{stem}: missing required slot '{req}'", errors)
for req in ("top", "bottom", "feet"):
if not any(int(e.get("layer", 0)) == 2
for e in slots.get(req, [])):
fail(f"{stem}: no layer-2 {req} garment", errors)
if "hair" not in slots:
warn(f"{stem}: no hair slot (bald band)", warnings)
skel = os.path.join(d, stem + ".skeleton")
if not os.path.exists(skel):
fail(f"{stem}: missing body skeleton {stem}.skeleton", errors)
atlas = os.path.join(d, f"charlod_{stem}-atlas.png")
if not os.path.exists(atlas):
fail(f"{stem}: missing LOD atlas charlod_{stem}-atlas.png",
errors)
mat = os.path.join(d, f"charlod_{stem}.material")
if not os.path.exists(mat):
warn(f"{stem}: missing LOD material charlod_{stem}.material",
warnings)
# Resource basename uniqueness across the flat Characters namespace.
seen = {}
exts = (".mesh", ".skeleton", ".material", ".png", ".json")
for d in dirs:
for f in os.listdir(os.path.join(root, d)):
if not f.endswith(exts):
continue
if f.startswith("OgreXMLConverter") or f.startswith(
"OgreMeshUpgrader"):
continue
seen.setdefault(f, []).append(d)
for name, dirs_with in sorted(seen.items()):
if len(dirs_with) > 1:
fail("duplicate resource basename '%s' in: %s" %
(name, ", ".join(dirs_with)), errors)
# Material texture references: collect staged texture names.
textures = set()
for d in dirs:
for f in os.listdir(os.path.join(root, d)):
if f.lower().endswith((".png", ".jpg", ".jpeg", ".tga",
".dds")):
textures.add(f)
for d in dirs:
for f in glob.glob(os.path.join(root, d, "*.material")):
with open(f, errors="ignore") as fh:
for line in fh:
m = re.match(r"\s*texture\s+(\S+)", line)
if not m:
continue
tex = m.group(1)
if tex not in textures:
warn(f"material {os.path.basename(f)} references "
f"missing texture {tex}", warnings)
print()
print(f"checked {len(expected_stems)} stems in {root}")
print(f"errors: {len(errors)}, warnings: {len(warnings)}")
if errors:
print("RESULT: FAIL")
return 1
print("RESULT: PASS")
return 0
if __name__ == "__main__":
sys.exit(main())
+163 -30
View File
@@ -33,6 +33,18 @@ def prepare_armature(mapping):
orig_armature = bpy.data.objects[mapping.armature_name]
armature_name = orig_armature.name
orig_armature.name = orig_armature.name + "_orig"
# Rename the original data block too, so the baked armature can use the
# exact stem as its data name (and therefore the exported skeleton file
# is `<stem>.skeleton`, not `<stem>.005.skeleton`).
orig_armature.data.name = armature_name + "_orig"
# Orphaned armature data blocks left by removed duplicate clothing-stage
# rigs still occupy the stem name; move them out of the way.
for ad in bpy.data.armatures:
if ad is orig_armature.data or ad is new_armature.data:
continue
if ad.name == armature_name or ad.name.startswith(
armature_name + "."):
ad.name = ad.name + "_unused"
new_armature.name = armature_name
queue = []
if new_armature.animation_data is None:
@@ -276,6 +288,19 @@ for mapping in[CommandLineMapping()]:
else:
print(f"No hair with own skeleton detected")
# ---------------------------------------------
# Helper/reference meshes (Body, Tops, Bottoms, Shoes, HairBack,
# Accessory, Body_shapes) are build-time weight-transfer aids and
# must not be exported: they are not catalog parts, and exporting
# them bloats the resource tree and creates texture-name collisions
# across age directories.
allowed = set(mapping.objs)
allowed.update(o.name for o in hair_own_skel_objs)
for ob in list(bpy.data.objects):
if ob.type != 'MESH':
continue
if ob.name not in allowed:
print(f"Removing non-catalog helper mesh '{ob.name}'")
bpy.data.objects.remove(ob, do_unlink=True)
else:
bpy.ops.wm.append(
filepath=os.path.join(mapping.blend_path, mapping.inner_path),
@@ -390,8 +415,46 @@ for mapping in[CommandLineMapping()]:
hair_arm.hide_render = False
obj_names = mapping.objs
prefix = mapping.armature_name + "_"
material_obj_names = list(obj_names)
material_obj_names += [o.name for o in hair_own_skel_objs]
for name in obj_names:
# Deduplicate image datablocks that came from multiple clothing stages
# but point at the same source file. Without this, the exporter writes
# `foo.png`, `foo.001.png`, ... for one texture.
def dedupe_images():
canonical = {}
for img in list(bpy.data.images):
if not img.filepath:
continue
try:
key = os.path.normpath(bpy.path.abspath(img.filepath))
except Exception:
continue
if not key:
continue
if key not in canonical:
canonical[key] = img
continue
keep = canonical[key]
for mat in bpy.data.materials:
if not mat.node_tree:
continue
for node in mat.node_tree.nodes:
if node.type == 'TEX_IMAGE' and node.image is img:
node.image = keep
try:
bpy.data.images.remove(img)
except Exception:
pass
dedupe_images()
try:
bpy.ops.outliner.orphans_purge(do_local_ids=True,
do_linked_ids=True,
do_recursive=True)
except Exception:
pass
for name in material_obj_names:
obj = bpy.data.objects.get(name)
if obj and obj.type == 'MESH':
@@ -499,6 +562,7 @@ for mapping in[CommandLineMapping()]:
lod_obj_names = []
if mapping_lods_path and os.path.exists(mapping_lods_path):
print("Appending character LOD objects from: " + mapping_lods_path)
before_lod_objects = set(bpy.data.objects)
with bpy.data.libraries.load(mapping_lods_path) as (data_from, data_to):
data_to.objects = [
n for n in data_from.objects
@@ -525,6 +589,16 @@ for mapping in[CommandLineMapping()]:
if mod.type == 'ARMATURE':
mod.object = body_arm
print(f" LOD object '{obj.name}' -> mesh '{obj.data.name}'")
# The lods blend also contains its own body armature. Drop appended
# armature objects so their data blocks cannot steal the stem
# skeleton name during export.
for obj in list(bpy.data.objects):
if obj in before_lod_objects or obj.type != 'ARMATURE':
continue
bpy.data.objects.remove(obj, do_unlink=True)
for ad in list(bpy.data.armatures):
if ad.users == 0:
bpy.data.armatures.remove(ad)
# -------------------------------------------------------------
# Triangulate body part meshes before OGRE export to prevent the exporter's
@@ -560,16 +634,53 @@ for mapping in[CommandLineMapping()]:
armobj = bpy.data.objects.get(mapping.armature_name)
armobj.data.name = armobj.name
print("EXPORT ARMATURE DATA: obj=%s data=%s all=%s" % (
armobj.name, armobj.data.name,
[(a.name, a.users) for a in bpy.data.armatures]))
# ---- Fix: sync Image filepath to Image name ----
# Blender silently reuses existing Images when appending
# from libraries, so the Image's filepath may still point
# to the old texture even after the user renamed it.
# blender2ogre writes the filepath to .material files,
# so we must update filepath to match the Image name.
# ---- Prefix image resources per stem ----
# All age directories share the "Characters" resource group, so texture
# file names must be unique across ages/sexes. Adult gets
# `male-...`/`female-...`; new ages get `baby-male-...` etc. The prefix
# is idempotent (an image already prefixed with the stem is left alone).
import re as _re
seen_imgs = set()
for name in obj_names:
stem = mapping.armature_name
def _unique_image_name(base):
if bpy.data.images.get(base) is None:
return base
i = 1
while bpy.data.images.get(f"{base}.{i:03d}") is not None:
i += 1
return f"{base}.{i:03d}"
image_obj_names = list(obj_names)
image_obj_names += [o.name for o in hair_own_skel_objs]
# LOD impostors use the already-unique `charlod_<stem>-atlas.png` /
# `charlod_<stem>` material created by generate_lods.py; prefixing them
# again would make the material reference a different file/format.
# Normalize their image format to the filename extension, though; some
# Blender versions keep the image blob as TARGA even when saved as PNG.
for name in lod_obj_names:
obj = bpy.data.objects.get(name)
if not obj or obj.type != 'MESH':
continue
for slot in obj.material_slots:
if not slot.material or not slot.material.node_tree:
continue
for node in slot.material.node_tree.nodes:
if node.type == 'TEX_IMAGE' and node.image:
fp = node.image.filepath.lower()
if fp.endswith(".png"):
node.image.file_format = 'PNG'
elif fp.endswith(".jpg") or fp.endswith(".jpeg"):
node.image.file_format = 'JPEG'
elif fp.endswith(".tga"):
node.image.file_format = 'TARGA'
output_dir = os.path.dirname(mapping.gltf_path)
for name in image_obj_names:
obj = bpy.data.objects.get(name)
if obj and obj.type == 'MESH':
for slot in obj.material_slots:
@@ -577,28 +688,50 @@ for mapping in[CommandLineMapping()]:
for node in slot.material.node_tree.nodes:
if node.type == 'TEX_IMAGE' and node.image:
img = node.image
if img.name not in seen_imgs:
seen_imgs.add(img.name)
# Strip Blender auto-suffix (.NNN)
clean = _re.sub(
r'\.\d{3}$', '', img.name)
# Extract just the filename from
# the current filepath
old_name = os.path.basename(
img.filepath)
if old_name != clean and clean:
# Rebuild filepath with new
# filename
dirpart = os.path.dirname(
img.filepath)
new_path = os.path.join(
dirpart, clean)
print(f" Updating Image "
f"'{img.name}': "
f"filepath "
f"'{old_name}' -> "
f"'{clean}'")
img.filepath = new_path
if img.name in seen_imgs:
continue
seen_imgs.add(img.name)
clean = _re.sub(r'\.\d{3}$', '', img.name)
old_name = os.path.basename(img.filepath)
if not old_name:
# Packed/generated image: use the datablock
# name as the file base.
old_name = clean
if not os.path.splitext(old_name)[1]:
old_name += ".png"
# Strip Blender auto-suffix before the extension.
base, ext = os.path.splitext(old_name)
base = _re.sub(r'\.\d{3}$', '', base)
if not ext:
ext = ".png"
if (base.startswith(stem + "-") or
base.startswith(stem + "_")):
new_base = base
else:
new_base = f"{stem}-{base}"
image_name = _unique_image_name(new_base)
new_file = image_name + ext
new_path = os.path.join(output_dir, new_file)
if img.name != image_name:
print(f" Prefixing Image "
f"'{img.name}' -> '{image_name}' "
f"({new_file})")
img.name = image_name
if new_file.lower().endswith(".png"):
img.file_format = 'PNG'
elif new_file.lower().endswith(".jpg") or \
new_file.lower().endswith(".jpeg"):
img.file_format = 'JPEG'
elif new_file.lower().endswith(".tga"):
img.file_format = 'TARGA'
img.filepath = new_path
img.filepath_raw = new_path
try:
img.save()
except Exception as exc:
print(f" WARNING: could not save image "
f"'{img.name}': {exc}")
seen_imgs.add(img.name)
# ---------------------------------------------------
bpy.ops.ogre.export(filepath=mapping.gltf_path.replace(".glb", ".scene"), EX_SELECTED_ONLY=False, EX_SHARED_ARMATURE=True, EX_LOD_GENERATION='0', EX_LOD_DISTANCE=20, EX_LOD_LEVELS=4, EX_GENERATE_TANGENTS='4')
+2 -2
View File
@@ -13,7 +13,7 @@ foreach(VEHICLE_FILE ${VEHICLES_SRC})
add_custom_command(
OUTPUT ${VEHICLE_OUTPUT_FILE} ${CMAKE_BINARY_DIR}/resources/vehicles/${FILE_NAME}.scene
COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_BINARY_DIR}/resources/vehicles
COMMAND ${BLENDER} ${CMAKE_CURRENT_SOURCE_DIR}/${VEHICLE_FILE}
COMMAND ${BLENDER_LOCKED} ${CMAKE_CURRENT_SOURCE_DIR}/${VEHICLE_FILE}
-b -Y -P
${CMAKE_SOURCE_DIR}/assets/blender/scripts/export_vehicles.py
-- ${VEHICLE_OUTPUT_FILE}
@@ -40,7 +40,7 @@ foreach(VEHICLE_FILE ${VEHICLES_GEN_SRC})
add_custom_command(
OUTPUT ${VEHICLE_OUTPUT_FILE} ${CMAKE_BINARY_DIR}/resources/vehicles/${FILE_NAME}.scene ${VEHICLE_JSON_FILE}
COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_BINARY_DIR}/resources/vehicles
COMMAND ${BLENDER} ${CMAKE_CURRENT_SOURCE_DIR}/${VEHICLE_FILE}
COMMAND ${BLENDER_LOCKED} ${CMAKE_CURRENT_SOURCE_DIR}/${VEHICLE_FILE}
-b -Y -P
${CMAKE_SOURCE_DIR}/assets/blender/scripts/gen_vehicle.py
-- ${VEHICLE_OUTPUT_FILE} ${VEHICLE_JSON_FILE} ${DOWNFORCE_${FILE_NAME}}
+124 -4
View File
@@ -305,8 +305,10 @@ cd demos/demo-taxi
# ...or headless phase 7.2 CellGrid stairs acceptance: on the flat
# vehicletest floor, builds a two-storey building with a straight
# stair run, walks a real Jolt character capsule up it through
# CharacterSystem, then checks Recast finds a ground->top path only
# in the stair building:
# CharacterSystem, checks Recast finds a ground->top path only in the
# stair building, climbs an exterior (motel-style) stair, and previews
# the showcase library layouts (roadside-motel exterior stair,
# lobby-hall wide grand flight):
./demoTaxi --headless --test-stairs demo_scene_vehicletest.json
# ...or headless check of the NPC motion core (phase 4.7.5): sweeps the
# game clock over a full day at a slowed walk speed, asserting NPCs enter
@@ -1309,6 +1311,40 @@ caught. The meshes are generated by `assets/blender/characters/generate_lods.py
`export_models2.py`'s 6th argument) — see
assets/blender/characters/CHARACTER_MODELING.md §6.
### Character age bands (AGES-PLAN implementation)
Canonical appearance bands are `baby` (0–2), `child` (3–12), `teenager`
(13–17), `adult` (18–64) and `elder` (65+); `TownPopulation` derives the band
from the already-drawn `ageYears`, so no extra RNG draw is introduced. Adult
keeps the legacy `male`/`female` resource stem; new bands use
`<age>-<sex>` (for example `child-male`, `elder-female`). Each band has its
own skeleton, mesh prefix, output directory under `characters/<stem>/` and LOD
names (`charlod_<stem>_...`); `EditorApp::locateResources()` registers every
subdirectory of `characters/` as a flat `Characters` location.
`CharacterSlotSystem::resolveAgeBand(age, sex, outfitLevel)` resolves a record
to a complete catalog band atomically (no mixing skeletons). The fallback
chains live in `roadlib/AgeBands.hpp`; `buildCharacter`, hair physics and LOD
impostor naming all use the resolved band. If a band is incomplete, the
character renders a complete fallback instead of a nude/broken NPC. The
slot-system startup log prints `catalog band <age>/<sex> resolved=<band>`
(plus `FALLBACK` when incomplete). `demoTaxi --headless --test-ages` builds
one record per age/sex and asserts the master mesh prefix, skeleton and
`idle`/`walking` clips.
Assets are generated by `assets/blender/characters/generate_age_blends.py`
(`agegen/` package) from the adult sources: rest-pose rig scaling, weighted
mesh/shape-key deformation, garment/hair conversion and animation location
retargeting. Generated seeds are committed and marked `agegen_state =
auto-seed`; the normal build never regenerates or retargets them (running the
retarget step twice would double-scale root motion). Hand edits are protected
by `agegen_state = hand-edited` plus a manifest file hash; use
`generate_age_blends.py --check` / `character-assets-check` before rebuilding.
Baby v1 schedules are home-only, so they do not spawn as exterior pedestrians;
their assets are still validated by `--test-ages`. See
`assets/blender/characters/AGES-PLAN.md` for the full conversion/hand-off
contract.
### NpcTaxiSystem (demo-taxi phase 4.7.7)
`NpcTaxiSystem` (EditorApp-owned singleton `getInstance()`, game mode only,
@@ -3188,8 +3224,10 @@ that must get an automatic stairwell (cells + mesh), plus an
exterior-stair fixture (upper door + `ExteriorStair`: mesh outside the
facade, reaches the upper floor, Recast path ground->upper through the
gallery door), and a 3 x 2-cell grand-stair block (one continuous
3.7 m wide flight, the whole block opened above);
`road_geometry_overlap_test` DSL probes
3.7 m wide flight, the whole block opened above); the demo library's
`roadside-motel`/`lobby-hall` showcase layouts are previewed by name in
`--test-stairs` (real `TownGenSystem`); `road_geometry_overlap_test`
DSL probes
(rooms-first + filled-shell flag placement and `door :gallery`);
`component_lua_test` test 33 (`stairRectName` round trip). Traversal
acceptance is the demo probe `demoTaxi --headless --test-stairs
@@ -3203,6 +3241,88 @@ path only in the stair building. A 0.25 m riser variant was tried and
the capsule never mounted the first tread - keep generated risers at or
below 0.18 m.
#### Generated interior scene builder (phase 8.1)
`roadlib/InteriorScene.{hpp,cpp}` (OGRE/flecs-free, in
`RoadGeometryLib`) turns the SAME `LayoutLib::BuildingPlan` that built
the exterior shell into the in-memory interior scene document — the
phase 8.2 loader consumes it, nothing is written to disk.
- `InteriorScene::build(Input)`: `Input` carries the plan, `gridUid`
(`town-<address>`), address, the source (exterior) scene path, the
entrance list and the `Lighting` spec. `Output` is
`{document, planHash}`; `planHash()` is FNV-1a over the metrics,
every cell and the address (cache validity key), and
`doorEdgeKey()` mirrors `CellGridSystem::doorEdgeKey` (equality is
unit-tested in `cellgrid_door_test` test 16).
- Document: `version: "1.0"`, a top-level `interior` metadata block
`{gridUid, address, planHash, sourceScene}`, then entities:
`interior:<gridUid>` (the plan cells in `interiorOnly` mode with the
standard rect names), one `arrival:<edgeKey>` marker 0.6 m inside
each entrance doorway (yaw facing in), and one
`interior-light` directional light. The grid entity carries no
material/texture block; the 8.2 service injects the town's shared
ones. Door sides use the engine's numbering throughout
(`0 = -Z DoorZNeg`, `1 = +Z DoorZPos`, `2 = -X`, `3 = +X`).
- Pre-wired returns: every entrance doorway gets a `doorConfigs` entry
(`hasOverride`, unlocked) with `sceneSwitchPath` = the source scene
and the pose fields `sceneSwitchHasPose`/`sceneSwitchPos`/
`sceneSwitchYaw` (new serialized `CellGridDoorConfig` fields, also
Lua-bound; DoorSystem passes them into the scene switch). The
exterior door's target names `arrival:<edgeKey>` (8.2
`fillBuildingCells`).
- Coverage: the `road_geometry_overlap_test` interior probe (document
shape/counts, determinism + hash sensitivity, engine-matching keys,
pose fields, arrival placement, LRU policy) and
`cellgrid_door_test` tests 4 (pose round trip) + 16 (key mirror).
**Generated interior switching (phase 8.2).**
`SceneSerializer::loadFromJson(document)` loads a scene from memory
(`loadFromFile` just reads the file and calls it).
`SceneSwitchOptions::sceneDocument` carries such a document; a switch
path of the form `generated:<gridUid>` is resolved by
`GeneratedInteriorService` (EditorApp-owned,
`getGeneratedInteriorService()`) at `switchScene()` time:
- the plan comes from `TownGenSystem::registerBuildingPlan` (recorded
by `createBuilding` for every generated building, runtime only);
- entrances are the plan's external door flags converted to world-space
approach poses (render-origin aware, building yaw included);
- the town's shared ProceduralMaterial/ProceduralTexture are injected
into the grid entity so the interior renders with the town atlas;
- documents are cached in an LRU (~1 MB default) keyed by
gridUid|sourceScene|planHash|building world pose; a lot-param or
building transform change rebuilds instead of serving stale exit
poses. Nothing is written to disk;
- while a document scene is active,
`EditorApp::isInsideGeneratedInterior()` is true and the BASE scene
stays the exterior one; `saveGame` then writes a
`saveGame.interior` marker ({scene, sourceScene, posX/Y/Z, yaw}) next
to the normal save data. `loadGame` loads the exterior first, then
`processPendingInteriorReentry` waits (probing every 30 frames, up to
1800) for the streamed town to rebuild the building + plan, resolves
the document, and queues a normal switch; `applyLoadedSaveState`
(the split-out loadGame tail: registries, spawns, runtime entities,
controller targeting) runs at the end of `performSceneSwitch` so the
snapshot lands on the interior. The marker pose places the player
on the arrival marker; if the interior never resolves, the snapshot
is restored in the exterior with a logged warning. Two supporting
fixes: `CharacterRegistry::spawnCharacter` uses the inline spawn when
the prefab exists but no UI system is present (headless) instead of
returning null (identity applied in the common tail, so headless
players stay in the registry and survive saves), and
`performSceneSwitch` destroys a marker-less controller/character left
over from an earlier carry when the target scene brings its own
controller;
- `fillBuildingCells` wires every external doorway to the generated
interior (`sceneSwitchPath = generated:<gridUid>`, target =
`arrival:<edgeKey>`, unlocked) instead of the old "E Locked" rule;
block a doorway with its own door config if needed. The interior exit
doors carry the return pose in
`CellGridDoorConfig`/`DoorBuildParams`/`DoorComponent`
(`sceneSwitchHasPose`/`sceneSwitchPos`/`sceneSwitchYaw`, serialized +
Lua-bound), which `DoorSystem` passes into `SceneSwitchOptions`.
#### Door identity & per-door configuration (F0)
A doorway's identity is its **canonical edge key** (`"X:x:y:z"` /
+3
View File
@@ -50,6 +50,7 @@ set(EDITSCENE_SOURCES
systems/ProceduralMeshSystem.cpp
systems/CellGridSystem.cpp
systems/DoorSystem.cpp
systems/GeneratedInteriorService.cpp
systems/DoorBuilder.cpp
systems/StandaloneDoorSystem.cpp
components/StandaloneDoorModule.cpp
@@ -1024,6 +1025,8 @@ add_library(RoadGeometryLib STATIC
roadlib/TownRostersWeeks.hpp
roadlib/PoiDatabase.cpp
roadlib/PoiDatabase.hpp
roadlib/InteriorScene.cpp
roadlib/InteriorScene.hpp
)
target_include_directories(RoadGeometryLib PUBLIC
+323 -66
View File
@@ -5,6 +5,7 @@
#include <fstream>
#include <functional>
#include <unordered_set>
#include <algorithm>
#include "EditorApp.hpp"
#include "GameMode.hpp"
#include <OgreMaterialManager.h>
@@ -134,6 +135,7 @@
#include "components/PathFollowing.hpp"
#include "systems/ActuatorSystem.hpp"
#include "systems/DoorSystem.hpp"
#include "systems/GeneratedInteriorService.hpp"
#include "systems/StandaloneDoorSystem.hpp"
#include "systems/EventHandlerSystem.hpp"
#include "systems/EventBus.hpp"
@@ -865,6 +867,12 @@ void EditorApp::setup()
m_doorSystem = std::make_unique<DoorSystem>(m_world);
m_doorSystem->setEditorApp(this);
/* Phase 8.2: in-memory generated interior documents + the
* "generated:<gridUid>" scene-switch scheme. */
m_generatedInteriorService =
std::make_unique<GeneratedInteriorService>(m_world,
m_sceneMgr);
// Standalone doors (F2, same DoorBuilder as CellGrid doors)
m_standaloneDoorSystem =
std::make_unique<StandaloneDoorSystem>(m_world, m_sceneMgr);
@@ -1460,7 +1468,20 @@ bool EditorApp::switchScene(const Ogre::String &scenePath,
"EditorApp::switchScene: empty scene path");
return false;
}
if (!std::filesystem::exists(scenePath)) {
SceneSwitchOptions effective = opts;
if (GeneratedInteriorService::isScheme(scenePath) &&
effective.sceneDocument.is_null()) {
/* Phase 8.2: resolve the generated-interior scheme to an
* in-memory scene document (the path stays the display
* name). The current base scene is the interior's return
* target. */
if (!m_generatedInteriorService ||
!m_generatedInteriorService->resolveGeneratedInterior(
scenePath, m_currentBaseScene,
effective.sceneDocument))
return false;
} else if (opts.sceneDocument.is_null() &&
!std::filesystem::exists(scenePath)) {
Ogre::LogManager::getSingleton().logMessage(
"EditorApp::switchScene: file not found: " + scenePath);
return false;
@@ -1471,7 +1492,7 @@ bool EditorApp::switchScene(const Ogre::String &scenePath,
* mutate the world mid-frame. A new request replaces a queued
* one. */
m_pendingSceneSwitchPath = scenePath;
m_pendingSceneSwitchOptions = opts;
m_pendingSceneSwitchOptions = effective;
m_sceneSwitchPending = true;
/* Raise the loading cover NOW (game mode): the switch itself runs
@@ -1531,6 +1552,98 @@ void EditorApp::processPendingSceneSwitch()
(switchTraceClock() - loadStartMs) * 1000.0);
}
/* Phase 8.2: deferred load-game re-entry. loadGame loads the exterior
* base scene first, then waits here until the streamed town has rebuilt
* the saved building and registered its plan; the interior document is
* resolved and the scene switch runs with the saved snapshot restored
* on the interior (applyLoadedSaveState, at the end of
* performSceneSwitch). */
void EditorApp::processPendingInteriorReentry()
{
if (!m_pendingInteriorReentry)
return;
if (m_gameMode != GameMode::Game) {
/* Left the game session: the pending load is moot. */
m_pendingInteriorReentry = false;
m_pendingSavedGame = nlohmann::json();
return;
}
auto restoreInExterior = [&](const std::string &why) {
Ogre::LogManager::getSingleton().logMessage(
"Game load: " + why + "; restoring in the exterior "
"scene");
m_pendingInteriorReentry = false;
nlohmann::json saved = std::move(m_pendingSavedGame);
m_pendingSavedGame = nlohmann::json();
applyLoadedSaveState(saved, m_pendingSaveSlot);
};
if (!m_pendingSavedGame.contains("saveGame")) {
m_pendingInteriorReentry = false;
m_pendingSavedGame = nlohmann::json();
return;
}
const nlohmann::json &saveGame = m_pendingSavedGame["saveGame"];
if (!saveGame.contains("interior") ||
!saveGame["interior"].is_object() ||
!m_generatedInteriorService) {
restoreInExterior("no usable interior marker");
return;
}
const nlohmann::json marker = saveGame["interior"];
const std::string scheme = marker.value("scene", std::string());
const std::string sourceScene =
marker.value("sourceScene", m_currentBaseScene);
if (scheme.empty()) {
restoreInExterior("interior marker has no scene");
return;
}
/* The streamed town needs a few frames to rebuild the building
* and register its plan; probe every 30 frames (~0.5 s) so the
* failure logs stay readable. */
++m_pendingInteriorReentryFrames;
if (m_pendingInteriorReentryFrames > 1800) {
restoreInExterior("generated interior '" + scheme +
"' unavailable");
return;
}
if (m_pendingInteriorReentryFrames > 1 &&
m_pendingInteriorReentryFrames % 30 != 0)
return;
nlohmann::json document;
if (!m_generatedInteriorService->resolveGeneratedInterior(
scheme, sourceScene, document))
return;
m_pendingInteriorReentry = false;
m_applySaveStateAfterSwitch = std::move(m_pendingSavedGame);
m_pendingSavedGame = nlohmann::json();
SceneSwitchOptions opts;
opts.sceneDocument = document;
if (marker.contains("posX") && marker.contains("posZ")) {
opts.hasPosition = true;
opts.posX = marker.value("posX", 0.0);
opts.posY = marker.value("posY", 0.0);
opts.posZ = marker.value("posZ", 0.0);
if (marker.contains("yaw")) {
opts.hasRotation = true;
opts.rotation = Ogre::Quaternion(
Ogre::Degree(marker.value("yaw", 0.0f)),
Ogre::Vector3::UNIT_Y);
}
}
if (!switchScene(scheme, opts)) {
/* Resolution raced a scene change: restore the snapshot in
* the exterior rather than losing it. */
nlohmann::json saved = std::move(m_applySaveStateAfterSwitch);
m_applySaveStateAfterSwitch = nlohmann::json();
applyLoadedSaveState(saved, m_pendingSaveSlot);
}
}
void EditorApp::renderSceneSwitchCover()
{
if (m_gameMode != GameMode::Game || m_sceneSwitchCoverFrames <= 0)
@@ -1637,15 +1750,27 @@ bool EditorApp::performSceneSwitch(const Ogre::String &scenePath,
m_renderOriginSystem->rebase(JPH::DVec3(0, 0, 0));
if (m_camera)
m_camera->resetPose();
if (m_uiSystem) {
if (!opts.sceneDocument.is_null()) {
/* Phase 8.2: in-memory generated interior. */
SceneSerializer serializer(m_world, m_sceneMgr);
serializer.loadFromJson(opts.sceneDocument);
m_activeGeneratedInterior = scenePath;
m_activeGeneratedSourceScene =
m_currentBaseScene;
} else if (m_uiSystem) {
/* loadScene wires the serializer, navigation
* bookmarks and scene script events. */
m_uiSystem->loadScene(scenePath);
m_activeGeneratedInterior.clear();
m_activeGeneratedSourceScene.clear();
m_currentBaseScene = scenePath;
} else {
SceneSerializer serializer(m_world, m_sceneMgr);
serializer.loadFromFile(scenePath, nullptr);
m_activeGeneratedInterior.clear();
m_activeGeneratedSourceScene.clear();
m_currentBaseScene = scenePath;
}
m_currentBaseScene = scenePath;
return true;
}
@@ -1656,8 +1781,13 @@ bool EditorApp::performSceneSwitch(const Ogre::String &scenePath,
bool newHasCamera = false;
bool newHasController = false;
try {
std::ifstream in(scenePath);
nlohmann::json scene = nlohmann::json::parse(in);
nlohmann::json scene;
if (!opts.sceneDocument.is_null()) {
scene = opts.sceneDocument;
} else {
std::ifstream in(scenePath);
scene = nlohmann::json::parse(in);
}
if (scene.contains("entities") && scene["entities"].is_array())
for (const auto &ent : scene["entities"])
scanSceneEntityJson(ent, newHasCamera,
@@ -1681,6 +1811,23 @@ bool EditorApp::performSceneSwitch(const Ogre::String &scenePath,
flecs::entity carriedCharacter = flecs::entity::null();
const bool preservePlayer = !newHasController && controller.is_alive();
/* A controller carried into the current scene by an EARLIER switch
* was stripped of its markers (so that switch could keep it), and
* destroySceneEntities() below therefore cannot see it. When the
* target scene brings its own controller, drop the leftover so
* the fresh one wins the "player" lookup instead of resolving a
* dead target. */
if (newHasController && controller.is_alive() &&
!controller.has<EditorMarkerComponent>() &&
!controller.has<RuntimeMarkerComponent>()) {
flecs::entity leftoverCharacter = getPlayerCharacterEntity();
if (leftoverCharacter.is_alive() &&
!leftoverCharacter.has<EditorMarkerComponent>() &&
!leftoverCharacter.has<RuntimeMarkerComponent>())
leftoverCharacter.destruct();
controller.destruct();
controller = flecs::entity::null();
}
if (preservePlayer) {
carriedCharacter = getPlayerCharacterEntity();
@@ -1726,17 +1873,40 @@ bool EditorApp::performSceneSwitch(const Ogre::String &scenePath,
destroySceneEntities();
SceneSerializer serializer(m_world, m_sceneMgr);
if (!serializer.loadFromFile(scenePath, m_uiSystem.get())) {
bool loaded = false;
if (!opts.sceneDocument.is_null())
loaded = serializer.loadFromJson(opts.sceneDocument,
m_uiSystem.get());
else
loaded = serializer.loadFromFile(scenePath,
m_uiSystem.get());
if (!loaded) {
Ogre::LogManager::getSingleton().logMessage(
"EditorApp: failed to load scene: " +
serializer.getLastError());
if (!m_applySaveStateAfterSwitch.is_null()) {
Ogre::LogManager::getSingleton().logMessage(
"EditorApp: discarding the pending load-game "
"state after the failed interior load");
m_applySaveStateAfterSwitch = nlohmann::json();
}
return false;
}
PrefabSystem prefabSys(m_world, m_sceneMgr);
prefabSys.resolveInstances();
SceneScriptSystem::loadPendingScripts(m_world);
SceneScriptSystem::sendSceneLoaded(scenePath);
m_currentBaseScene = scenePath;
/* Phase 8.2: an in-memory document is a generated interior — the
* BASE scene stays the exterior one (saves stay anchored there);
* file switches leave any generated interior and become the base. */
if (!opts.sceneDocument.is_null()) {
m_activeGeneratedInterior = scenePath;
m_activeGeneratedSourceScene = m_currentBaseScene;
} else {
m_activeGeneratedInterior.clear();
m_activeGeneratedSourceScene.clear();
m_currentBaseScene = scenePath;
}
/* m_playTime keeps accumulating across the switch (same session). */
setGamePlayState(GamePlayState::Playing);
@@ -1853,6 +2023,16 @@ bool EditorApp::performSceneSwitch(const Ogre::String &scenePath,
* to build. */
m_sceneSwitchCoverFrames = SCENE_SWITCH_COVER_FRAMES;
/* Phase 8.2: a deferred load-game re-entry restores the saved
* snapshot on the freshly loaded interior (registry spawns,
* runtime entities, controller targeting). */
if (!m_applySaveStateAfterSwitch.is_null()) {
nlohmann::json saved =
std::move(m_applySaveStateAfterSwitch);
m_applySaveStateAfterSwitch = nlohmann::json();
applyLoadedSaveState(saved, m_pendingSaveSlot);
}
EventBus::getInstance().send("scene_ready");
return true;
}
@@ -1900,6 +2080,32 @@ void EditorApp::saveGame(const std::string &slotPath,
}
saveData["saveGame"]["playerCharacterId"] = playerCharId;
/* Phase 8.2: saving while inside a generated interior only records
* a marker — the exterior base scene above and all normal state
* stay authoritative; loading rebuilds the interior from the plan. */
if (!m_activeGeneratedInterior.empty() && playerChar.is_alive() &&
playerChar.has<TransformComponent>()) {
const auto &pt = playerChar.get<TransformComponent>();
const Ogre::Vector3 pos =
pt.node ? pt.node->_getDerivedPosition() : pt.position;
const Ogre::Quaternion rot =
pt.node ? pt.node->_getDerivedOrientation() : pt.rotation;
JPH::DVec3 wp(pos.x, pos.y, pos.z);
if (m_renderOriginSystem)
wp = m_renderOriginSystem->renderToWorld(pos);
const Ogre::Vector3 fwd = rot * Ogre::Vector3::UNIT_Z;
const float yaw =
Ogre::Math::ATan2(fwd.x, fwd.z).valueDegrees();
saveData["saveGame"]["interior"] = {
{ "scene", m_activeGeneratedInterior },
{ "sourceScene", m_activeGeneratedSourceScene },
{ "posX", wp.GetX() },
{ "posY", wp.GetY() },
{ "posZ", wp.GetZ() },
{ "yaw", yaw }
};
}
saveData["characterRegistry"] = registry.serialize();
saveData["containerState"] =
ContainerStateRegistry::getInstance().serialize();
@@ -1962,46 +2168,10 @@ void EditorApp::saveGame(const std::string &slotPath,
}
}
void EditorApp::loadGame(const std::string &slotPath)
void EditorApp::applyLoadedSaveState(const nlohmann::json &saveData,
const std::string &slotPath)
{
EventBus::getInstance().send("load_game_requested");
nlohmann::json saveData;
if (!SaveLoadSystem::readSaveFile(slotPath, saveData)) {
Ogre::LogManager::getSingleton().logMessage(
"Failed to read save file: " + slotPath);
return;
}
std::string baseScene = saveData["saveGame"].value("baseScene", "");
if (baseScene.empty()) {
Ogre::LogManager::getSingleton().logMessage(
"Save file missing base scene reference");
return;
}
clearScene();
/* loadGame replaces the base scene: deactivate the terrain
* BEFORE loading it so TerrainSystem::update() reactivates
* with FRESH entity ids (m_active otherwise stays true with a
* stale m_terrainEntityId — the graph read-throughs after a
* reload then see a dead entity, as the week-revision probe
* exposed). */
if (m_terrainSystem)
m_terrainSystem->deactivate();
SceneSerializer serializer(m_world, m_sceneMgr);
if (!serializer.loadFromFile(baseScene, m_uiSystem.get())) {
Ogre::LogManager::getSingleton().logMessage(
"Failed to load base scene: " +
serializer.getLastError());
return;
}
PrefabSystem prefabSys(m_world, m_sceneMgr);
prefabSys.resolveInstances();
SceneScriptSystem::loadPendingScripts(m_world);
SceneScriptSystem::sendSceneLoaded(baseScene);
/* Restore registries */
auto &registry = CharacterRegistry::getSingleton();
@@ -2232,7 +2402,6 @@ void EditorApp::loadGame(const std::string &slotPath)
saveData["luaData"]);
}
m_currentBaseScene = baseScene;
m_playTime = saveData["saveGame"].value("playTime", 0.0f);
setGamePlayState(GamePlayState::Playing);
@@ -2241,6 +2410,77 @@ void EditorApp::loadGame(const std::string &slotPath)
EventBus::getInstance().send("game_loaded");
}
void EditorApp::loadGame(const std::string &slotPath)
{
EventBus::getInstance().send("load_game_requested");
nlohmann::json saveData;
if (!SaveLoadSystem::readSaveFile(slotPath, saveData)) {
Ogre::LogManager::getSingleton().logMessage(
"Failed to read save file: " + slotPath);
return;
}
std::string baseScene = saveData["saveGame"].value("baseScene", "");
if (baseScene.empty()) {
Ogre::LogManager::getSingleton().logMessage(
"Save file missing base scene reference");
return;
}
clearScene();
/* The loaded scene replaces the one the player was in: any
* generated interior is gone until the re-entry below. */
m_activeGeneratedInterior.clear();
m_activeGeneratedSourceScene.clear();
/* loadGame replaces the base scene: deactivate the terrain
* BEFORE loading it so TerrainSystem::update() reactivates
* with FRESH entity ids (m_active otherwise stays true with a
* stale m_terrainEntityId — the graph read-throughs after a
* reload then see a dead entity, as the week-revision probe
* exposed). */
if (m_terrainSystem)
m_terrainSystem->deactivate();
SceneSerializer serializer(m_world, m_sceneMgr);
if (!serializer.loadFromFile(baseScene, m_uiSystem.get())) {
Ogre::LogManager::getSingleton().logMessage(
"Failed to load base scene: " +
serializer.getLastError());
return;
}
PrefabSystem prefabSys(m_world, m_sceneMgr);
prefabSys.resolveInstances();
SceneScriptSystem::loadPendingScripts(m_world);
SceneScriptSystem::sendSceneLoaded(baseScene);
/* Phase 8.2: the BASE scene is authoritative for the world, but
* a save taken inside a generated interior carries its world
* snapshot in the marker. That snapshot must be restored on the
* interior, not on the exterior — and the streamed town may need
* a few frames to rebuild the building and register its plan, so
* the state restore is deferred until the interior resolves. */
m_currentBaseScene = baseScene;
m_playTime = saveData["saveGame"].value("playTime", 0.0f);
setGamePlayState(GamePlayState::Playing);
if (m_gameMode == GameMode::Game &&
saveData["saveGame"].contains("interior") &&
saveData["saveGame"]["interior"].is_object()) {
m_pendingSavedGame = saveData;
m_pendingSaveSlot = slotPath;
m_pendingInteriorReentry = true;
m_pendingInteriorReentryFrames = 0;
Ogre::LogManager::getSingleton().logMessage(
"Game load: re-entering the generated interior " +
saveData["saveGame"]["interior"].value(
"scene", std::string()));
return;
}
applyLoadedSaveState(saveData, slotPath);
}
void EditorApp::setupECS()
{
// Register components
@@ -2569,6 +2809,7 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt)
/* A queued scene switch runs before anything else so systems
* below already see the new scene. */
processPendingInteriorReentry();
processPendingSceneSwitch();
stepProfile("sceneSwitch");
@@ -3491,34 +3732,50 @@ void EditorApp::locateResources()
std::filesystem::current_path().string());
}
/* Try multiple relative paths for characters to handle different
* working directories (build root vs binary subdirectory) */
struct CharPathPair {
const char *male;
const char *female;
/* Characters live in one directory per age/sex stem: adult keeps
* `male`/`female`; new bands use e.g. `baby-male`, `child-female`.
* Register every subdirectory as its own flat resource location so
* mesh names remain `male_Face.mesh` rather than being prefixed by
* the directory path. */
const char *charRoots[] = {
"./characters",
"../../../characters",
"../../characters",
"../characters",
};
CharPathPair charPaths[] = {
{ "./characters/male", "./characters/female" },
{ "../../../characters/male", "../../../characters/female" },
{ "../../characters/male", "../../characters/female" },
{ "../characters/male", "../characters/female" },
};
bool added = false;
for (const auto &pair : charPaths) {
if (std::filesystem::exists(pair.male)) {
for (const char *root : charRoots) {
std::error_code ec;
if (!std::filesystem::is_directory(root, ec))
continue;
std::vector<std::filesystem::path> dirs;
for (const auto &entry :
std::filesystem::directory_iterator(root, ec)) {
if (entry.is_directory(ec))
dirs.push_back(entry.path());
}
std::sort(dirs.begin(), dirs.end());
if (dirs.empty()) {
/* Legacy flat layout. */
Ogre::ResourceGroupManager::getSingleton()
.addResourceLocation(pair.male, "FileSystem",
"Characters", false, true);
Ogre::ResourceGroupManager::getSingleton()
.addResourceLocation(pair.female, "FileSystem",
.addResourceLocation(root, "FileSystem",
"Characters", false, true);
Ogre::LogManager::getSingleton().logMessage(
"Characters resource location added: " +
Ogre::String(pair.male));
Ogre::String(root));
added = true;
break;
}
for (const auto &dir : dirs) {
Ogre::ResourceGroupManager::getSingleton()
.addResourceLocation(dir.string(), "FileSystem",
"Characters", false, true);
Ogre::LogManager::getSingleton().logMessage(
"Characters resource location added: " +
dir.string());
}
added = true;
break;
}
if (!added) {
Ogre::LogManager::getSingleton().logMessage(
+47
View File
@@ -10,6 +10,7 @@
#include <flecs.h>
#include <map>
#include <memory>
#include <nlohmann/json.hpp>
#include <string>
#include <utility>
#include "lua/LuaState.hpp"
@@ -17,6 +18,7 @@
// Forward declarations
class EditorUISystem;
class GeneratedInteriorService;
class VehicleSystem;
class AiDriverSystem;
class ZoneSystem;
@@ -167,6 +169,11 @@ struct SceneSwitchOptions {
* When set, the player character is teleported to that entity's
* transform (takes precedence over position/rotation). */
Ogre::String targetEntityName;
/* Phase 8.2: an in-memory scene document to load instead of a
* file (generated interiors). Null = load from scenePath. The
* path stays the display/bookkeeping name. */
nlohmann::json sceneDocument;
};
/**
@@ -486,6 +493,25 @@ public:
{
return m_doorSystem.get();
}
GeneratedInteriorService *getGeneratedInteriorService() const
{
return m_generatedInteriorService.get();
}
/* Phase 8.2: non-empty while the player is inside a generated
* interior ("generated:<gridUid>")); the base scene stays the
* exterior one for saves. */
bool isInsideGeneratedInterior() const
{
return !m_activeGeneratedInterior.empty();
}
const Ogre::String &getActiveGeneratedInterior() const
{
return m_activeGeneratedInterior;
}
const Ogre::String &getActiveGeneratedSourceScene() const
{
return m_activeGeneratedSourceScene;
}
Ogre::ImGuiOverlay *getImGuiOverlay() const
{
return m_imguiOverlay;
@@ -543,6 +569,7 @@ private:
std::unique_ptr<GoapPlannerSystem> m_goapPlannerSystem;
std::unique_ptr<ActuatorSystem> m_actuatorSystem;
std::unique_ptr<DoorSystem> m_doorSystem;
std::unique_ptr<GeneratedInteriorService> m_generatedInteriorService;
std::unique_ptr<StandaloneDoorSystem> m_standaloneDoorSystem;
std::unique_ptr<EventHandlerSystem> m_eventHandlerSystem;
std::unique_ptr<ItemSystem> m_itemSystem;
@@ -596,6 +623,21 @@ private:
Ogre::String m_pendingSceneSwitchPath;
SceneSwitchOptions m_pendingSceneSwitchOptions;
/* Phase 8.2: deferred load-game re-entry into a generated
* interior. The save snapshot is applied only after the interior
* switch, so runtime entities and spawns land in the interior. */
bool m_pendingInteriorReentry = false;
int m_pendingInteriorReentryFrames = 0;
Ogre::String m_pendingSaveSlot;
nlohmann::json m_pendingSavedGame;
nlohmann::json m_applySaveStateAfterSwitch;
/* Phase 8.2: the generated interior currently loaded (empty while
* a normal file scene is active). The BASE scene keeps pointing
* at the exterior scene for saves. */
Ogre::String m_activeGeneratedInterior;
Ogre::String m_activeGeneratedSourceScene;
/* Grounding watchdog: after a game-mode scene switch the terrain
* physics colliders may not exist yet; keep the player character
* at or above the terrain surface until it reports a floor. */
@@ -652,6 +694,11 @@ private:
double m_profBatchesAccum = 0.0;
void processPendingSceneSwitch();
/* Phase 8.2: deferred load-game re-entry into a generated
* interior (waits for the streamed town to register the plan). */
void processPendingInteriorReentry();
void applyLoadedSaveState(const nlohmann::json &saveData,
const std::string &slotPath);
bool performSceneSwitch(const Ogre::String &scenePath,
const SceneSwitchOptions &opts);
void destroySceneEntities();
@@ -161,6 +161,16 @@ struct CellGridDoorConfig {
std::string actionName;
std::string sceneSwitchPath; // F1 (empty = normal swinging door)
std::string sceneSwitchTarget;
/* Phase 8.1: pre-wired return pose in the destination scene (used
* when sceneSwitchTarget is empty). Filled by the generated
* interior builder for the exit doors (each entrance doorway is
* paired with its exterior approach pose). Serialized only when
* hasSceneSwitchPose is set. */
bool hasSceneSwitchPose = false;
float sceneSwitchPosX = 0.0f;
float sceneSwitchPosY = 0.0f;
float sceneSwitchPosZ = 0.0f;
float sceneSwitchYawDeg = 0.0f;
bool disabled = false; // spawn no door entity for this doorway
// Persistence / locking (F6)
@@ -64,6 +64,13 @@ struct DoorComponent {
// the scene switch fires once the door is fully open (F1)
std::string sceneSwitchPath; // Target scene path (empty = disabled)
std::string sceneSwitchTarget; // Teleport target entity name
/* Phase 8.2: pre-wired return pose in the target scene (generated
* interior exit doors); used when sceneSwitchTarget is empty. */
bool sceneSwitchHasPose = false;
float sceneSwitchPosX = 0.0f;
float sceneSwitchPosY = 0.0f;
float sceneSwitchPosZ = 0.0f;
float sceneSwitchYawDeg = 0.0f;
// F1: a scene-switch door first swings open; DoorSystem fires the
// switch (and the "door_scene_switch" event) only when the leaf
@@ -1,3 +1,4 @@
- parallel builds do not work
- Water:
- sometimes when entering shallow water, characters float above water playing swimming animation.
- sometimes in shallow water characters start switch between swimming/walking animations, looks like hysteresis prevention needs to be tweaked and more tests added.
+349 -33
View File
@@ -6054,6 +6054,12 @@ Verified:
walk speed (PASS at Y > 3.9). `road_geometry_overlap_test` asserts
the `:gallery` DSL flag placement. CTest 9/9 and the demo
batteries stay green.
- The demo town library ships the `roadside-motel` showcase layout
(two storeys, `(door front -8 :gallery)` on the upper floor), which
generates its exterior stair through the normal town pipeline; the
`--test-stairs` probe previews it by name through the real
`TownGenSystem` and checks the flight geometry, and a full town
generation (64 buildings) runs without layout warnings.
#### Phase 7.5 — Grand / lobby stairs — DONE
@@ -6091,7 +6097,10 @@ spans one continuous 3.7 m wide flight (z extent at the tread-top
level) and the level above is opened over the whole block;
`road_geometry_overlap_test` and the demo batteries stay green. The
DSL help window documents the wide-stairwell room and ships a "Grand
lobby stair (wide stairwell room)" example.
lobby stair (wide stairwell room)" example. The demo town library
ships the `lobby-hall` showcase layout (3 x 2-cell stairwell room);
`--test-stairs` previews it by name and checks the wide-flight
geometry.
**Explicitly out of scope for Phase 7:** interior scene generation
and door wiring (Phase 8), spiral stairs, elevators, and town-wide
@@ -6099,38 +6108,345 @@ rules for where stairwells go (layouts decide; default layouts get
stairwells when Phase 8 makes upper floors enterable).
### Phase 8 — Enterable buildings (on-the-fly interiors)
- Interior depends on previous steps, most notably furniture and stairs.
- That is only place where we can start properly verifying verify some of previous things, so be ready to fix older problems here.
- Additionnally interior depends on beinc capable of creating furniture pieces with actionable items.
- using furniture prefabs, where with the help of editor we create scene, add one or more entities with meshes, add physics colliders, other objects. The instructions should be provided for the one who will do it. With constraints and a way to create multiple cell furniture.
- using Blender. The instructions should be provided for the one who will do it. With constraints and a way to create multiple cell furniture, and how to properly fill custom properties.
- while documenting, separate cases should looked upon:
- normal furniture, like beds, tables
- ceiling furniture (lights, simple fixtures), having no collider
- floor furniture, like carpets/rags, having no collider
- wall-only furniture, like pictures, wall rags, ornaments, having no collider too.
- with automatic furniture placement there are simple constraints:
- there should always be a path from a door to each normal furniture object, so NPCs should never be trapped in the rooms or be unable to get to a piece of furniture.
- placement algorithm should be fast and simple, but produce realistic enough placement, current algorithm is rudimentary.
- it would be best, but not absolutely required, to have straight path theough the room where there are doors across the rooms.
- the iterior should have its state and remember it
- Interior scene generator (new, engine-level): given a building's
exterior parameters (footprint, floors, template, seed), generate
the matching interior scene deterministically — same building, same
interior, every visit. Generated as a scene at switch time (or
cached on first visit), not pre-authored per building. Multi-storey
buildings get Phase 7 stairwells so every floor is traversable.
- Enable the F1 scene-switch exterior doors prepared in Phase 4
(the per-door `lockable`/`lockedByDefault` config is replaced by
`sceneSwitchPath`/target wiring);
entering switches to the generated interior, the interior's exit
door switches back to the player's exterior position.
- Interior content level for v1: walls/floor/ceiling/stairs/windows
matching the exterior shell, no furniture gameplay.
- Headless: for N generated buildings, enter -> interior loads and
matches exterior footprint -> exit returns to the curb; two visits
produce identical interiors (determinism check); a multi-storey
building's upper floor is reachable by stairs.
**Goal.** Every generated building with a front door can be entered:
E on the door opens it and switches to an interior scene
**materialized from the same building plan** — the same footprint,
floors, rooms, stairs and windows as the exterior shell, deterministic
per building (same building -> same interior, every visit). The
interior's exit doors are pre-paired with the exterior entrances: each
returns the player (or NPC) to that doorway's approach point outside.
Engine-level, not taxi-specific; the taxi demo is the proving ground.
**Status:** 8.1 DONE, 8.2 DONE (2026-10-30/31) — in-memory interior
documents load through the real serializer, the `generated:<gridUid>`
scheme resolves through the service, exterior doors are wired, the
pre-wired return pose rides DoorSystem, the LRU cache exists, and a
save taken inside an interior re-enters it on load (deferred until the
streamed town rebuilds the building + plan, then the save snapshot is
restored on the interior). 8.3-8.6 planned. Phase 7 (stairs,
automatic stairwells, wide flights) provides the multi-floor traversal
this phase depends on.
**Requirements carried over (the original notes):**
- Interior work is the natural integration test for the earlier
systems — expect to fix pre-existing problems exposed here rather
than working around them.
- Interior furniture depends on actionable items: furniture prefabs
(editor-built scenes with meshes, colliders and multiple cells) and
Blender-authored assets, with authoring instructions. Document the
four furniture cases separately: normal (beds/tables), ceiling
(lights, no collider), floor (carpets, no collider), wall-only
(pictures, no collider).
- Automatic furniture placement constraints: a walkable path from
every door to every normal furniture object (nobody trapped, no
unreachable furniture); fast and simple but realistic; straight
paths through rooms where doors face each other are highly
desirable (not mandatory). The current placement is rudimentary.
- The interior should have **state and remember it**.
**What the code already gives us (verified 2026-10-30):**
- **Buildings are pure data.** `TownGen::TownBuilding` carries the
evaluated `BuildingPlan` (grid metrics, per-cell flags,
`roomIds/roomNames/roomTypes` incl. `:type`, roofs, parking, DSL
stat/POI/stat-effect data), plus `address`, `floors`, `center/yaw/
floorY`, `doorCellX`, `doorstep`, door roles, `tags`/`layoutName`
and per-lot RNG streams (each lot draws from its own seed, phase
4.6). The plan is the build truth for the shell AND the rooms, so
the interior is a pure function of the plan (+ seed/address).
- **The shell and the interior share the CellGrid feature set.** The
town builds an `exteriorOnly` grid from the plan; the paired
`interiorOnly` mode (floors, ceilings, internal walls/frames,
boundary-window glass, stairs, furniture) is the proven
demo-scene-switching-extra pattern. The same cells in the other
mode are the interior.
- **Doors are ready.** Per-doorway `doorConfigs` (F0) already carry
`sceneSwitchPath`/`sceneSwitchTarget`; DoorSystem swings the leaf
and queues `EditorApp::switchScene()` at `openAngle` (F1); the
loading cover hides the load; scene-switch doors always reopen
closed; the navmesh treats doors as passable (F7).
- **Scene switching is ready.** Game mode carries the live player
controller/character into scenes without their own controller;
arrival is by world pose or `targetEntityName`, ground-clamped with
a watchdog.
- **Stairs are ready** (Phase 7): interiorOnly builds the stair
meshes; automatic stairwells give every multi-storey generated plan
a climbable stair, including wide lobby flights; furniture avoids
stair cells.
- **Rooms/furniture are ready** (6.5): `createPlanRooms` materializes
RoomComponents from the plan for full/interiorOnly grids;
RoomLayoutSystem furnishes them deterministically; furniture
placement is validated by the shared kernel.
- **State persistence is ready:** `GlobalStateStore` (F9) + item/
container/door stores + Lua save callbacks. "Interiors remember
their state" rides these.
- **Loading is nearly in-memory already:** `SceneSerializer::
loadFromFile` reads the file and then deserializes the JSON; only the
file read and the game-mode camera/controller pre-scan are
path-bound, so an in-memory `loadFromJson(document)` entry (phase
8.2) is a small, contained refactor.
**Design decisions (settled; the "research" is folded in):**
1. **Interiors live in MEMORY, not on disk.** The interior is built as
an in-memory scene document on first visit and cached in memory
(keyed by `gridUid` + plan hash + source scene path). No generated
scene files are written. Determinism = the document is a pure
function of the plan and the seed: if the seed stays the same, the
interior looks the same on every visit. Persistent/changeable state
(NPCs, items, doors, furniture interactions) is cached in memory
while the game runs and saved only in the save file when requested —
nothing is persisted that did not change. NPC locations are defined
by NPC *states* (the clock-pure population/schedule layer), so
restoring them never needs an interior-scene snapshot.
Engineering implication: `SceneSerializer::loadFromFile` is split
into `loadFromJson(document)`; `performSceneSwitch` and its game-mode
pre-scan (camera/controller detection) accept an in-memory document
instead of a file path, and the pending-scene-switch slot can carry
one.
**Save/load while inside:** the save file keeps being a NORMAL full
save (base scene = the EXTERIOR scene; character registry, stats,
schedules/week store, global state, items — everything as today)
plus an `interior` marker section: {gridUid, sourceScenePath,
player pose}. Loading restores the normal state, regenerates the
town deterministically, rebuilds the interior document from the
plan (in-memory cache) and resumes the player inside at that pose.
Nothing about the interior itself is serialized — only the marker
that says which interior and where the player stood.
2. **Door wiring via a reserved path scheme.** Exterior doors of
enterable buildings get `sceneSwitchPath = "generated:<gridUid>"`
(replacing the "always locked" default); EditorApp resolves the
scheme at switch time (build or reuse the cached in-memory document).
Authored buildings can use the same scheme.
3. **Pre-wired door pairs, not a single return slot.** Every entrance
doorway (front, back, side) is paired with its own interior exit
door at generation time: the exit door's config carries the source
scene path plus the pre-filled exterior approach pose of THAT
doorway (the doorstep/back/side doorstep already recorded on
`TownBuilding`). Each return door knows where to put the character
upfront — multiple entrances and exits work naturally, for the
player and for NPCs, with no dynamic "last exit" bookkeeping.
4. **Furniture is always on; the layout decides.** Interiors are
furnished by the existing deterministic RoomLayoutSystem planner;
the layout DSL's room `:type`/`:furniture`/`:compose` data controls
what appears. There is no global "no furniture v1" switch.
5. **Interior content = the interior only.** The generated scene
contains the shell-matched interior (walls/floor/ceiling/stairs/
windows/doors), its furniture and POIs, the arrival marker just
inside each entrance, lighting, and the NPCs whose state says they
are inside that building — nothing else for now (no terrain, no
exterior world copy).
6. **Lighting: simple now, state-ready later.** v1 uses a plain
ambient + directional light (no sky, no sun sync), described by a
small `InteriorLighting` spec in the builder. The spec is the
extension point for future light states (lights off, day lights
off, night lighting, per-room switches) — the state itself is
stored later (global store / interior state), not now.
7. **Interior cache = LRU with a memory budget.** Keep visited
interior documents in an LRU capped by total JSON size (default
~1 MB, configurable). Eviction simply drops a document; the next
visit rebuilds it from the plan (the build is pure, so eviction
only costs a rebuild, never correctness).
8. **Every layout is enterable by default.** No building-level
opt-out: an exterior door is wired to the generated interior
unless its own door config blocks it — `lockable`/`lockedByDefault`
(F6, unlockable by key/script) or `disabled` (no door entity) are
the supported ways to prevent entry.
**Decomposition (each step is usable and testable on its own):**
#### 8.1 — Pure in-memory interior scene builder — DONE
As built (2026-10-30):
- `roadlib/InteriorScene.{hpp,cpp}` (OGRE/flecs-free, linked into
RoadGeometryLib): `Input {plan, gridUid, address, sourceScenePath,
entrances, Lighting}`, `Output {document, planHash}`,
`build()`, `planHash()` (FNV-1a over metrics + every cell + the
address) and `doorEdgeKey()` — a component-free mirror of
`CellGridSystem::doorEdgeKey`, unit-tested for equality over a range
of cells/sides.
- Document contract: `version: "1.0"`, a top-level `interior` block
`{gridUid, address, planHash, sourceScene}`, and entities:
1. `interior:<gridUid>` — the plan's cells in `interiorOnly` mode
with the full CellGrid defaults, the standard rect names
(floor/ceiling/wall/door/winframe/roof) and `gridUid`;
2. one `arrival:<edgeKey>` marker per entrance, 0.6 m inside the
doorway cell, yaw facing into the building;
3. one `interior-light` directional light (`InteriorLighting` spec —
the future hook for light states).
- Pre-wired returns: every entrance doorway's `doorConfigs` entry is
`hasOverride`, unlocked, `sceneSwitchPath = sourceScenePath` and
carries the pose fields `sceneSwitchHasPose`/`sceneSwitchPos`/
`sceneSwitchYaw` (new serialized `CellGridDoorConfig` fields, also
Lua-bound). The exterior door's `sceneSwitchTarget` will name the
matching `arrival:<edgeKey>` marker (8.2); DoorSystem consumes the
pose in 8.2.
- Materials: the document intentionally carries no
proceduralMaterial/proceduralTexture; 8.2 injects the town's shared
material/texture names (the grid otherwise falls back to the default
material).
- Tests: the `road_geometry_overlap_test` probe evaluates a two-floor
layout, builds the document twice (bit-identical + equal hash),
checks the entity shape/counts, the plan-derived cell count, the
engine-matching door keys, the pose fields, the arrival marker
inside the door, and hash sensitivity to a plan edit;
`cellgrid_door_test` test 16 asserts the key mirror equals the
engine's, and test 4 now round-trips the new pose fields.
#### 8.2 — In-memory generated-scene switching (engine) — LANDED
As built (2026-10-30/31):
- `SceneSerializer::loadFromJson(document)` — the file read is split
from the load body; `loadFromFile` calls it.
- `SceneSwitchOptions::sceneDocument`: `switchScene` resolves the
`generated:<gridUid>` scheme through `GeneratedInteriorService`
(replacing the file-exists check), keeps the scheme string as the
display/bookkeeping name, and `performSceneSwitch` pre-scans and
loads the in-memory document (game and editor modes). The BASE
scene stays the exterior one on document switches; file switches
leave the generated interior and become the base. The active
interior is tracked (`isInsideGeneratedInterior`,
`getActiveGeneratedInterior`).
- `GeneratedInteriorService` (EditorApp-owned, created in setup):
- plan lookup via `TownGenSystem::registerBuildingPlan` (recorded by
`createBuilding` for every generated building, keyed by gridUid);
- entrance discovery from the plan's external door flags with
world-space approach poses (render-origin converted, building
yaw included);
- `InteriorScene::build` + the town's shared
ProceduralMaterial/ProceduralTexture injected into the grid entity
(serialized from the town container's components);
- an LRU document cache (~1 MB default) keyed by
gridUid|sourceScene|planHash|building world pose; a plan edit or a
moved/rotated building rebuilds instead of serving stale exits.
- Door wiring: `fillBuildingCells` replaces the "E Locked" rule —
every external doorway becomes a scene-switch door to
`generated:<gridUid>` with the matching interior arrival marker as
its target; entry can still be blocked with the doorway's own door
config. The interior exit doors' pre-wired pose rides
`CellGridDoorConfig` -> `DoorBuildParams` -> `DoorComponent` and
`DoorSystem` passes it into `SceneSwitchOptions` (position + yaw).
- Save: `EditorApp::saveGame` adds a `saveGame.interior` marker
({scene, sourceScene, posX/Y/Z, yaw}) only while inside a generated
interior; all normal save data (registry, stats, schedules, global
state, items) is unchanged.
- Load-time re-entry: `loadGame` loads the exterior base scene first
(the streamed town must rebuild the saved building and register its
plan), then `processPendingInteriorReentry` probes the scheme every
30 frames (immediate on the first frame). On success the interior
document is loaded through a normal queued scene switch and
`applyLoadedSaveState` (the former loadGame tail, split out) runs at
the end of `performSceneSwitch`, so registry spawns, runtime
entities, stats, schedules and controller targeting land on the
interior. The marker pose teleports the player; the registry
position saved by `saveGame` agrees with it. If the interior cannot
be resolved within 1800 frames (scene/town changed), the save state
is restored in the exterior scene with a logged warning. `loadGame`
clears the active-interior bookkeeping as it tears the scene down.
- Supporting fixes the re-entry exposed:
- `CharacterRegistry::spawnCharacter` now falls back to an inline
spawn when the prefab exists but there is no UI system (headless)
instead of returning null; the identity is applied in the common
tail, so the player stays a persistent registry character and
survives load. Previously the headless spawner instance had no
identity, `setupPlayerCharacter` minted a runtime record, and the
player was absent from saves.
- `performSceneSwitch` drops a marker-less controller/character left
over from an earlier carry when the target scene brings its own
controller; otherwise the stale "player" won the lookup and the
arrival teleport missed.
- Tests (all passing):
- the road probe covers the document + LRU policy;
- the `interiorSceneLoad` terrain step (document -> real serializer
-> grid/arrival/pre-wired door assertions);
- the `--test-interior` demo probe: synthetic building -> scheme ->
interior -> arrival -> save -> `loadGame` -> deferred re-entry ->
player on the arrival marker again -> pre-wired exit pose;
- full gate: build, CTest 9/9, `cellgrid_door_test` 16/16,
`component_lua_test` 67/67, and the `demoTaxi` batteries
`--test-interior`, `--test-stairs`, `--test-switch`,
`--test-population-switch`, `--test-furniture`;
`demoSceneSwitchingExtra`, `demoSceneSwitching` and
`demoInteriorExteriorDynamics` `--test-switch`.
#### 8.3 — Arrival and exit contract (pre-wired door pairs)
- Each entrance doorway is paired with its interior exit door at build
time (8.1); the pair carries the source scene path and the exterior
approach pose of that doorway, so a return needs no dynamic
bookkeeping and multiple entrances/exits work for the player and for
NPCs.
- Arrival: the carried player is placed at the entrance's interior
arrival marker (ground-clamped/watchdog); the interior scene has no
controller and carries the live player over.
- Exit: E on any interior exit door switches to the source scene at
that doorway's pre-filled approach pose, behind the loading cover.
- Interior startup is standard: resources, `scene_loaded`, global
state; no terrain.
#### 8.4 — Multi-storey traversal and inside NPCs
- Verify Phase 7 stair meshes build in interiorOnly and the automatic
stairwell lands inside (7.3a); furniture avoids stair cells.
- Inside NPCs: when the interior loads, spawn the NPCs whose effective
symbolic state places them inside this building (population/schedule
layer is the source of truth; positions come from their states, not
from interior-scene persistence). Anchors: the layout's room POIs
when declared, else room centers. No NPC AI inside for v1 — they are
placed and idle; richer behavior is Phase 8.5.
#### 8.5 — Acceptance battery `--test-interiors`
- Generate a town with a fixed seed; pick N = 3 enterable buildings
including one multi-storey and one with exterior stairs.
- Enter each through the door action; assert the interior grid bbox
matches the plan footprint, stairs exist for floors > 1, the arrival
marker is inside the entrance, and every interior exit door is wired
to the source scene + its doorway's approach pose.
- Exit; assert the player lands at that doorway's approach pose
(within epsilon) and the scene is the source one. Repeat through a
second (back) door when the layout has one.
- Visit each twice: the rebuilt document equals the cached one
(bit-identical in memory; no files); a changed lot param
(regenerate-by-lot-params) makes the hash mismatch and the cache
invalidate.
- Save/load inside: save while standing in an interior, then load the
save; assert the player resumes in the SAME generated interior at
the saved pose, the exterior base scene regenerated identically, and
the normal save data (NPC registry/stats/schedules, global state)
came back.
- Inside-NPC check: the spawned count equals the number of NPCs whose
state places them in that building (0 allowed), and their anchors are
the declared room POIs or the room centers.
- Regression: the authored F1 scene-switch batteries
(`demoSceneSwitchingExtra --test-switch`, `demoTaxi --test-switch`)
stay green.
#### 8.6 — Documentation and authoring notes
- `editScene/AGENTS.md`: "Generated interiors" section (service, path
scheme, LRU cache + budget, pre-wired door pairs, enterable-by-
default and how to block a door).
- `PLAN.md`: status + the as-built decisions; authoring notes for
interior furniture (the four layer cases; how to inspect a generated
interior document — a debug dump helper instead of files).
**No open design questions remain** — the cache (LRU, ~1 MB budget) and
enterability (every layout, blocked per door) decisions are settled
above; anything discovered during implementation gets recorded in the
8.x as-built notes.
**Explicitly later (not Phase 8):** NPC AI wandering inside buildings,
interior pathfinding for schedules (schedules keep using entry POIs),
furniture gameplay interactions inside generated interiors (the
Actuator/POI machinery exists; wiring it in is a follow-up), interior
light states (lights off / day-night profiles — the `InteriorLighting`
spec reserves the hook), and extending save data beyond the global
store + the interior marker (doors/items/stats already persist there).
### Phase 8.5 — Population, residents & schedule POIs
@@ -24,6 +24,8 @@
#include "systems/CellGridSystem.hpp"
#include "systems/NavMeshSystem.hpp"
#include "components/NavMesh.hpp"
#include "systems/GeneratedInteriorService.hpp"
#include "layoutlib/LayoutEval.hpp"
#include "systems/ActuatorSystem.hpp"
#include "components/CellGrid.hpp"
#include "components/Actuator.hpp"
@@ -42,6 +44,7 @@
#include "systems/NpcTaxiSystem.hpp"
#include "systems/NpcTrafficSystem.hpp"
#include "roadlib/TownLocations.hpp"
#include "roadlib/AgeBands.hpp"
#include "components/Sun.hpp"
#include "systems/GameClockSystem.hpp"
#include "components/AiDriver.hpp"
@@ -1109,6 +1112,168 @@ struct WeekRolloverTestListener : public Ogre::FrameListener {
* exact pre-spawn symbolic values (the despawn's live-position
* persist is overwritten by the pure motion evaluation).
*/
/* --test-ages: spawn one character record per canonical age and
* sex, then verify that the resolved catalog band built the expected mesh
* prefix, skeleton and animation set. Asset-level check, independent of
* the town population. */
struct AgeTestListener : public Ogre::FrameListener {
EditorApp *app;
int frame = 0;
int phase = 0;
int phaseFrames = 0;
int deadline = 900;
bool failed = false;
Ogre::String failReason;
std::vector<uint64_t> ids;
std::vector<flecs::entity> entities;
std::vector<std::string> stems;
AgeTestListener(EditorApp *a) : app(a) {}
bool fail(const Ogre::String &reason)
{
failed = true;
failReason = reason;
std::cerr << "[test] FAIL: " << reason << std::endl;
app->getRoot()->queueEndRendering();
return true;
}
bool frameRenderingQueued(const Ogre::FrameEvent &evt) override
{
(void)evt;
++frame;
if (failed)
return true;
if (frame > deadline)
return fail("age test watchdog timeout");
CharacterRegistry &reg = CharacterRegistry::getSingleton();
CharacterSlotSystem *slots =
CharacterSlotSystem::getSingletonPtr();
if (!slots)
return fail("CharacterSlotSystem missing");
if (phase == 0) {
const char *ages[] = { "baby", "child", "teenager",
"adult", "elder" };
const char *sexes[] = { "male", "female" };
Ogre::Vector3 base(0, 0, 0);
int n = 0;
for (const char *age : ages) {
for (const char *sex : sexes) {
const uint64_t id =
1ull << 50 | (uint64_t)n;
reg.ensureRuntimeCharacter(
id, "AgeTest", "Character");
auto *rec = reg.findCharacter(id);
if (!rec)
return fail("could not create "
"age test record");
rec->persistent = false;
rec->age = age;
rec->ageYears =
std::string(age) == "baby" ? 1 :
std::string(age) == "child" ? 8 :
std::string(age) == "teenager" ? 15 :
std::string(age) == "elder" ? 70 : 30;
rec->inlineSex = sex;
rec->inlineOutfitLevel = 2;
rec->frontAxis = Ogre::Vector3::UNIT_Z;
rec->position = base +
Ogre::Vector3((n % 5) * 1.2f,
0,
(n / 5) * 1.2f);
rec->inlineSlotSelections.clear();
reg.markCharacterDirty(id);
flecs::entity e = reg.spawnCharacter(id);
if (!e.is_alive())
return fail("spawn failed for " +
std::string(age) +
"/" + sex);
ids.push_back(id);
entities.push_back(e);
stems.push_back(AgeBands::stem(
age, sex));
++n;
}
}
phase = 1;
phaseFrames = 0;
return true;
}
if (phase == 1) {
if (++phaseFrames < 90)
return true;
for (size_t i = 0; i < entities.size(); ++i) {
flecs::entity e = entities[i];
if (!e.is_alive())
return fail("age test entity died: " +
stems[i]);
Ogre::Entity *master =
slots->getMasterEntity(e);
if (!master)
return fail("no master entity for " +
stems[i]);
const Ogre::String mesh =
master->getMesh()->getName();
if (mesh.find(stems[i] + "_") !=
Ogre::String::npos)
continue;
return fail("master mesh '" + mesh +
"' does not use stem '" +
stems[i] + "'");
}
/* Skeleton/name/animation checks. */
for (size_t i = 0; i < entities.size(); ++i) {
Ogre::Entity *master =
slots->getMasterEntity(entities[i]);
if (!master || !master->getSkeleton())
return fail("no skeleton for " +
stems[i]);
const Ogre::String skelName =
master->getSkeleton()->getName();
if (skelName != stems[i] &&
skelName != stems[i] + ".skeleton")
return fail("skeleton '" + skelName +
"' != '" + stems[i] + "'");
bool idle = false, walking = false;
Ogre::SkeletonInstance *sk =
master->getSkeleton();
for (unsigned short a = 0;
a < sk->getNumAnimations(); ++a) {
Ogre::Animation *an =
sk->getAnimation(a);
if (!an)
continue;
if (an->getName() == "idle")
idle = true;
if (an->getName() == "walking")
walking = true;
}
if (!idle || !walking)
return fail("missing idle/walking on " +
stems[i]);
const Ogre::String lod2 = "charlod_" + stems[i] +
"_thin_lod2.mesh";
if (!Ogre::ResourceGroupManager::getSingleton()
.resourceExists("Characters", lod2))
return fail("missing LOD2 impostor " + lod2);
}
std::cout << "[test] PASS: age bands built ("
<< entities.size() << " characters)"
<< std::endl;
phase = 2;
for (uint64_t id : ids)
reg.deleteCharacter(id);
app->getRoot()->queueEndRendering();
return true;
}
return true;
}
};
struct DetailTestListener : public Ogre::FrameListener {
EditorApp *app;
int frame = 0;
@@ -1351,8 +1516,11 @@ struct DetailTestListener : public Ogre::FrameListener {
* first free ring spot — legitimate,
* bounded by the ring's 5 m max. */
if (d > 0.5f) {
/* The ring is 5 m horizontally; the
* ground raycast can add a small
* vertical snap. */
if (detail->spawnedRelocated(id) &&
d <= 5.0f)
d <= 5.2f)
continue;
std::cout << "[dbg] NPC " << id
<< " d=" << d
@@ -4398,7 +4566,7 @@ struct VehicleParkTestListener : public Ogre::FrameListener {
bool frameRenderingQueued(const Ogre::FrameEvent &) override
{
frame++;
if (failed || phase == 3) {
if (failed || phase == 4) {
app->getRoot()->queueEndRendering();
return true;
}
@@ -4587,6 +4755,54 @@ static void setAiRoute(AiDriverComponent &ai, const Ogre::Vector3 *points,
*
* Run: ./demoTaxi --headless --test-stairs demo_scene_vehicletest.json
*/
/* Small helpers for the stairs probe's layout checks. */
static Ogre::MeshPtr demoAttachedMesh(Ogre::SceneNode *node,
const std::string &suffix)
{
if (!node)
return Ogre::MeshPtr();
for (unsigned i = 0; i < node->numAttachedObjects(); ++i) {
Ogre::Entity *ent =
dynamic_cast<Ogre::Entity *>(
node->getAttachedObject(i));
if (!ent)
continue;
const std::string &name = ent->getMesh()->getName();
if (name.size() >= suffix.size() &&
name.compare(name.size() - suffix.size(), suffix.size(),
suffix) == 0)
return ent->getMesh();
}
return Ogre::MeshPtr();
}
template <typename Fn>
static void demoScanMesh(const Ogre::MeshPtr &mesh, Fn fn)
{
if (mesh.isNull())
return;
for (unsigned si = 0; si < mesh->getNumSubMeshes(); ++si) {
Ogre::SubMesh *sub = mesh->getSubMesh(si);
Ogre::VertexData *vd = sub->useSharedVertices ?
mesh->sharedVertexData :
sub->vertexData;
if (!vd || !vd->vertexBufferBinding->isBufferBound(0))
continue;
Ogre::HardwareVertexBufferSharedPtr vb =
vd->vertexBufferBinding->getBuffer(0);
const size_t verts = vd->vertexCount;
if (verts == 0)
continue;
std::vector<float> data(vb->getVertexSize() * verts);
vb->readData(0, data.size(), data.data());
const size_t stride = vb->getVertexSize() / sizeof(float);
for (size_t v = 0; v < verts; ++v)
fn(Ogre::Vector3(data[v * stride],
data[v * stride + 1],
data[v * stride + 2]));
}
}
struct StairsTestListener : public Ogre::FrameListener {
EditorApp *app;
int frame = 0;
@@ -4730,7 +4946,7 @@ struct StairsTestListener : public Ogre::FrameListener {
bool frameRenderingQueued(const Ogre::FrameEvent &) override
{
frame++;
if (failed || phase == 7) {
if (failed || phase == 8) {
app->getRoot()->queueEndRendering();
return true;
}
@@ -4965,10 +5181,534 @@ struct StairsTestListener : public Ogre::FrameListener {
return true;
}
std::cout << "[test] stairs: navmesh checks done"
<< std::endl;
phase = 7;
return true;
}
case 7: {
/* Showcase town layouts: the demo library's exterior-
* stair motel and wide-lobby layouts generate their
* stair geometry through the real generator. */
EditorPhysicsSystem *phys =
app->getEditorPhysicsSystem();
CellGridSystem *cellSys = app->getCellGridSystem();
if (!phys || !cellSys) {
fail("no physics/CellGridSystem");
return true;
}
flecs::world *wf = app->getWorld();
TownGenSystem tgs(*wf, app->getSceneManager(),
phys->getPhysicsWrapper());
LayoutLib::LayoutLibrary lib =
TownGenSystem::loadLayoutLibrary();
std::string err;
auto lastGrid = [&]() {
flecs::entity best;
wf->each([&](flecs::entity e,
CellGridComponent &) {
if (!best.is_alive() ||
e.id() > best.id())
best = e;
});
return best;
};
/* Motel: exterior climb on the front facade (the
* flight extends past the 8 m deep shell). */
if (!tgs.previewLayout("roadside-motel", &lib, 0,
7, Ogre::Vector3(0, 0, 90),
"full", &err)) {
fail("roadside-motel layout failed: " + err);
return true;
}
cellSys->update();
flecs::entity b = lastGrid();
Ogre::MeshPtr st =
b.is_alive() &&
b.has<TransformComponent>() ?
demoAttachedMesh(
b.get<TransformComponent>().node,
"_stairs") :
Ogre::MeshPtr();
float extMaxY = -1.0e9f;
int extOutside = 0;
demoScanMesh(st, [&](const Ogre::Vector3 &v) {
extMaxY = std::max(extMaxY, v.y);
if (v.z > 8.1f)
++extOutside;
});
if (st.isNull() || extMaxY < 4.1f || extOutside == 0) {
fail("roadside-motel has no exterior stair");
return true;
}
tgs.clearLayoutPreview();
/* Lobby hall: one wide grand flight. */
if (!tgs.previewLayout("lobby-hall", &lib, 0, 7,
Ogre::Vector3(0, 0, 120),
"full", &err)) {
fail("lobby-hall layout failed: " + err);
return true;
}
cellSys->update();
b = lastGrid();
st = b.is_alive() && b.has<TransformComponent>() ?
demoAttachedMesh(
b.get<TransformComponent>().node,
"_stairs") :
Ogre::MeshPtr();
const float firstTop = 0.2f + 4.0f / 23.0f;
float zMin = 1.0e9f, zMax = -1.0e9f;
demoScanMesh(st, [&](const Ogre::Vector3 &v) {
if (std::fabs(v.y - firstTop) < 0.01f) {
zMin = std::min(zMin, v.z);
zMax = std::max(zMax, v.z);
}
});
if (st.isNull() || zMax - zMin < 3.4f) {
fail("lobby-hall has no wide lobby stair");
return true;
}
tgs.clearLayoutPreview();
std::cout << "[test] PASS: stairs are walkable and "
"navmesh-connected; stairless floors are "
"not" << std::endl;
phase = 7;
"not; showcase layouts build their stairs"
<< std::endl;
phase = 8;
app->getRoot()->queueEndRendering();
return true;
}
}
return true;
}
};
/*
* --test-interior (phase 8.2): in-memory generated-scene switching.
*
* Builds one synthetic two-storey exterior building from a layout plan
* (registered in the town plan registry), resolves the
* "generated:<gridUid>" scheme twice (determinism + LRU hit), switches
* into the in-memory interior, checks the interior grid matches the
* plan and the player arrives just inside the front door, then exits
* through the interior door's PRE-WIRED pose and verifies the player
* lands at that exterior approach point.
*
* Run: ./demoTaxi --headless --test-interior demo_scene_vehicletest.json
*/
struct InteriorTestListener : public Ogre::FrameListener {
EditorApp *app;
Ogre::String sourceScene;
int frame = 0;
int phase = 0;
bool failed = false;
Ogre::String failReason;
std::string uid = "town-Interior Test";
Ogre::Vector3 origin = Ogre::Vector3(0, 0, 140);
flecs::entity building;
LayoutLib::BuildingPlan plan;
std::string doorKey;
std::string arrivalName;
std::string scheme;
Ogre::Vector3 exitPos = Ogre::Vector3::ZERO;
float exitYaw = 0.0f;
InteriorTestListener(EditorApp *a, const Ogre::String &scene)
: app(a)
, sourceScene(scene)
{
}
void fail(const std::string &why)
{
if (failed)
return;
failed = true;
failReason = why;
std::cerr << "[test] interior FAIL: " << why << std::endl;
app->getRoot()->queueEndRendering();
}
bool frameRenderingQueued(const Ogre::FrameEvent &) override
{
frame++;
if (failed || phase == 4) {
app->getRoot()->queueEndRendering();
return true;
}
if (frame > 4000) {
fail("timeout in phase " + std::to_string(phase));
return true;
}
switch (phase) {
case 0: {
if (!editScene::isGamePlaying())
return true;
/* Plan + registry + synthetic exterior building. */
LayoutLib::LayoutBindings lb;
lb.lotW = 40.0f;
lb.lotD = 40.0f;
lb.availW = 35.0f;
lb.availD = 35.0f;
lb.minFp = 12.0f;
lb.maxFp = 16.0f;
lb.minFloors = 2;
lb.maxFloors = 2;
lb.density = 1.0f;
lb.densityPass = true;
std::mt19937 rng(7);
LayoutLib::LayoutEval eval(lb, &rng, nullptr);
std::string err;
if (!eval.evaluate(
"(shell 12 8 :floors 2 :empty)"
"(floor 0 (room hall (rect 0 0 1 1))"
" (door front center))"
"(floor 1 (room up (rect 0 0 1 1))"
" (windows all))",
plan, &err)) {
fail("layout eval failed: " + err);
return true;
}
TownGenSystem::registerBuildingPlan(uid, plan);
flecs::world *w = app->getWorld();
Ogre::SceneNode *node =
app->getSceneManager()
->getRootSceneNode()
->createChildSceneNode(
"InteriorTestBuilding");
node->setPosition(origin);
building = w->entity();
TransformComponent t;
t.node = node;
t.position = origin;
t.rotation = Ogre::Quaternion::IDENTITY;
t.scale = Ogre::Vector3::UNIT_SCALE;
building.set<TransformComponent>(t);
CellGridComponent grid;
grid.gridUid = uid;
grid.width = plan.cellsX;
grid.height = plan.floors;
grid.depth = plan.cellsZ;
grid.cellSize = plan.cellSize;
grid.cellHeight = plan.cellHeight;
grid.generationMode = "exteriorOnly";
grid.floorRectName = "floor";
grid.ceilingRectName = "ceiling";
grid.extWallRectName = "wall";
grid.intWallRectName = "wall";
grid.extDoorFrameRectName = "door";
grid.intDoorFrameRectName = "door";
grid.extWindowFrameRectName = "winframe";
grid.intWindowFrameRectName = "winframe";
grid.roofTopRectName = "roof";
grid.roofSideRectName = "roof";
grid.doorRectName = "door";
for (int y = 0; y < plan.floors; ++y)
for (int z = 0; z < plan.cellsZ; ++z)
for (int x = 0; x < plan.cellsX; ++x) {
const uint64_t flags =
plan.at(x, y, z);
if (!flags)
continue;
grid.getOrCreateCell(x, y, z)
.flags = flags;
}
/* Door wiring exactly like fillBuildingCells. */
for (const CellGridSystem::DoorwayInfo &dw :
CellGridSystem::collectDoorways(grid)) {
if (dw.internal)
continue;
CellGridDoorConfig cfg;
cfg.label = "Entrance";
cfg.hasOverride = true;
cfg.sceneSwitchPath =
GeneratedInteriorService::schemeFor(
uid);
cfg.sceneSwitchTarget =
"arrival:" + dw.edgeKey;
grid.doorConfigs[dw.edgeKey] = cfg;
if (doorKey.empty())
doorKey = dw.edgeKey;
}
if (doorKey.empty()) {
fail("plan declares no external door");
return true;
}
arrivalName = "arrival:" + doorKey;
building.set<CellGridComponent>(grid);
/* Scheme resolution: determinism + LRU hit. */
GeneratedInteriorService *svc =
app->getGeneratedInteriorService();
if (!svc) {
fail("no GeneratedInteriorService");
return true;
}
nlohmann::json d1, d2;
scheme = GeneratedInteriorService::schemeFor(uid);
if (!svc->resolveGeneratedInterior(scheme,
sourceScene, d1) ||
!svc->resolveGeneratedInterior(scheme,
sourceScene, d2)) {
fail("scheme resolve failed");
return true;
}
if (!(d1 == d2) || svc->cache().size() < 1) {
fail("interior document not deterministic or "
"not cached");
return true;
}
SceneSwitchOptions opts;
opts.targetEntityName = arrivalName;
if (!app->switchScene(scheme, opts)) {
fail("switchScene(scheme) refused");
return true;
}
std::cout << "[test] interior: switching into "
<< scheme << std::endl;
phase = 1;
return true;
}
case 1: {
if (!app->isInsideGeneratedInterior())
return true;
/* The interior grid must be loaded and match the
* plan. */
flecs::entity interior;
int interiorCells = 0;
app->getWorld()->each(
[&](flecs::entity e,
CellGridComponent &g) {
if (g.gridUid == uid &&
g.generationMode ==
"interiorOnly") {
interior = e;
interiorCells =
(int)g.cells.size();
}
});
if (!interior.is_alive()) {
return true; /* still loading */
}
int expected = 0;
for (int y = 0; y < plan.floors; ++y)
for (int z = 0; z < plan.cellsZ; ++z)
for (int x = 0; x < plan.cellsX;
++x)
if (plan.at(x, y, z))
++expected;
if (interiorCells != expected) {
fail("interior cell count " +
std::to_string(interiorCells) +
" != plan " + std::to_string(expected));
return true;
}
flecs::entity player =
app->getPlayerCharacterEntity();
if (!player.is_alive() ||
!player.has<TransformComponent>())
return true;
const auto &pt =
player.get<TransformComponent>();
if (!pt.node)
return true;
/* The player must stand on the arrival marker (the
* interior has its own local origin). */
flecs::entity marker;
app->getWorld()->each(
[&](flecs::entity e, EntityNameComponent &n) {
if (n.name == arrivalName)
marker = e;
});
if (!marker.is_alive() || !marker.has<TransformComponent>()) {
fail("arrival marker not found after load");
return true;
}
const auto &mt = marker.get<TransformComponent>();
const Ogre::Vector3 mp =
mt.node ? mt.node->_getDerivedPosition() : mt.position;
const Ogre::Vector3 pp =
pt.node->_getDerivedPosition();
if ((pp - mp).length() > 1.0f) {
fail("player did not arrive on the marker "
"(got " +
Ogre::StringConverter::toString(pp) +
", marker " +
Ogre::StringConverter::toString(mp) +
")");
return true;
}
/* Read the pre-wired exit pose from the loaded
* interior. */
const CellGridComponent &ig =
interior.get<CellGridComponent>();
auto it = ig.doorConfigs.find(doorKey);
if (it == ig.doorConfigs.end() ||
!it->second.hasSceneSwitchPose ||
it->second.sceneSwitchPath != sourceScene) {
fail("interior exit door is not pre-wired");
return true;
}
exitPos = Ogre::Vector3(it->second.sceneSwitchPosX,
it->second.sceneSwitchPosY,
it->second.sceneSwitchPosZ);
exitYaw = it->second.sceneSwitchYawDeg;
if (std::fabs(exitPos.z - origin.z) < 6.0f) {
fail("exit pose is not outside the front "
"facade");
return true;
}
/* Save/load round trip: the save keeps the exterior
* base scene plus an interior marker; loading must
* regenerate the interior and resume the player
* inside. */
const std::string savePath =
"/tmp/opencode/demo_taxi_interior_save.json";
app->saveGame(savePath, "interior-probe");
{
std::ifstream in(savePath);
nlohmann::json saved;
in >> saved;
const nlohmann::json &sg = saved["saveGame"];
if (!sg.contains("interior") ||
sg["interior"].value("scene",
std::string()) !=
scheme ||
sg["interior"].value("sourceScene",
std::string()) !=
sourceScene ||
!sg["interior"].contains("posX") ||
sg.value("baseScene", std::string()) !=
sourceScene) {
fail("save file interior marker wrong");
return true;
}
}
std::cout << "[test] interior: saved, reloading"
<< std::endl;
app->loadGame(savePath);
phase = 2;
return true;
}
case 2: {
if (!app->isInsideGeneratedInterior())
return true;
if (app->getActiveGeneratedInterior() != scheme) {
fail("re-entered a different interior: " +
Ogre::String(app->getActiveGeneratedInterior()));
return true;
}
flecs::entity interior;
app->getWorld()->each(
[&](flecs::entity e,
CellGridComponent &g) {
if (g.gridUid == uid &&
g.generationMode ==
"interiorOnly")
interior = e;
});
flecs::entity player =
app->getPlayerCharacterEntity();
if (!player.is_alive() ||
!player.has<TransformComponent>())
return true;
const auto &pt =
player.get<TransformComponent>();
if (!pt.node)
return true;
flecs::entity marker;
app->getWorld()->each(
[&](flecs::entity e, EntityNameComponent &n) {
if (n.name == arrivalName)
marker = e;
});
if (!marker.is_alive() ||
!marker.has<TransformComponent>()) {
fail("arrival marker missing after reload");
return true;
}
const auto &mt = marker.get<TransformComponent>();
const Ogre::Vector3 mp =
mt.node ? mt.node->_getDerivedPosition() :
mt.position;
const Ogre::Vector3 pp =
pt.node->_getDerivedPosition();
if ((pp - mp).length() > 1.0f) {
fail("reloaded player is not inside (got " +
Ogre::StringConverter::toString(pp) +
", marker " +
Ogre::StringConverter::toString(mp) +
")");
return true;
}
const CellGridComponent &ig =
interior.get<CellGridComponent>();
auto it = ig.doorConfigs.find(doorKey);
if (it == ig.doorConfigs.end() ||
!it->second.hasSceneSwitchPose) {
fail("reloaded exit door is not pre-wired");
return true;
}
exitPos = Ogre::Vector3(it->second.sceneSwitchPosX,
it->second.sceneSwitchPosY,
it->second.sceneSwitchPosZ);
exitYaw = it->second.sceneSwitchYawDeg;
std::cout << "[test] interior: re-entered from save, "
"exiting to "
<< exitPos << " yaw " << exitYaw
<< std::endl;
SceneSwitchOptions back;
back.hasPosition = true;
back.posX = exitPos.x;
back.posY = exitPos.y;
back.posZ = exitPos.z;
back.hasRotation = true;
back.rotation = Ogre::Quaternion(
Ogre::Degree(exitYaw), Ogre::Vector3::UNIT_Y);
if (!app->switchScene(it->second.sceneSwitchPath,
back)) {
fail("exit switch refused");
return true;
}
phase = 3;
return true;
}
case 3: {
if (app->isInsideGeneratedInterior())
return true;
flecs::entity player =
app->getPlayerCharacterEntity();
if (!player.is_alive() ||
!player.has<TransformComponent>())
return true;
const auto &pt =
player.get<TransformComponent>();
if (!pt.node)
return true;
const Ogre::Vector3 pp = pt.node->getPosition();
if ((pp - exitPos).length() > 1.5f) {
fail("player did not return to the pre-wired "
"approach pose (got " +
Ogre::StringConverter::toString(pp) +
", want " +
Ogre::StringConverter::toString(
exitPos) +
")");
return true;
}
std::cout << "[test] PASS: generated interior entered, "
"matched the plan, survived a save/load "
"re-entry and returned through the "
"pre-wired exit pose" << std::endl;
phase = 4;
app->getRoot()->queueEndRendering();
return true;
}
@@ -11126,6 +11866,15 @@ struct EmergencyTestListener : public Ogre::FrameListener {
* the expected Y from TerrainSystem::getHeightAt and
* polls while the streaming window loads and the
* grounding watchdog re-clamps); exits 0 on PASS.
* --test-interior
* headless-friendly phase 8.2 check on the flat
* vehicletest floor: registers a synthetic two-storey
* building plan, resolves its generated-interior
* document (determinism + LRU), switches in, verifies
* the interior grid/arrival marker, saves, loads (the
* deferred re-entry rebuilds and resumes the player on
* the arrival marker), then exits through the interior
* door's pre-wired exterior pose; exits 0 on PASS.
* --test-vehicle-drive
* headless-friendly sedan enter/drive/exit check
* (Phase 1): loads the exterior scene directly (or the
@@ -11206,6 +11955,7 @@ int main(int argc, char *argv[])
bool testWeekRevision = false;
bool testEmergency = false;
bool testDetail = false;
bool testAges = false;
bool testTaxi = false;
bool testDeterminism = false;
bool testTaxiLowDetail = false;
@@ -11216,6 +11966,7 @@ int main(int argc, char *argv[])
bool testParking = false;
bool testFurniture = false;
bool testStairs = false;
bool testInterior = false;
bool testAnimTravel = false;
bool testPoi = false;
bool benchmark = false;
@@ -11279,6 +12030,8 @@ int main(int argc, char *argv[])
testEmergency = true;
} else if (arg == "--test-detail") {
testDetail = true;
} else if (arg == "--test-ages") {
testAges = true;
} else if (arg == "--test-taxi") {
testTaxi = true;
} else if (arg == "--test-determinism") {
@@ -11299,6 +12052,8 @@ int main(int argc, char *argv[])
testFurniture = true;
} else if (arg == "--test-stairs") {
testStairs = true;
} else if (arg == "--test-interior") {
testInterior = true;
} else if (arg == "--test-anim-travel") {
testAnimTravel = true;
} else if (arg == "--test-poi") {
@@ -11422,6 +12177,8 @@ int main(int argc, char *argv[])
/* The detail-tier test too. */
if (testDetail && !sceneArgGiven)
sceneFile = "demo_scene_exterior.json";
if (testAges && !sceneArgGiven)
sceneFile = "demo_scene_exterior.json";
/* The taxi test too (town population + parked sedans). */
if (testTaxi && !sceneArgGiven)
sceneFile = "demo_scene_exterior.json";
@@ -11451,6 +12208,9 @@ int main(int argc, char *argv[])
/* The stairs test needs a flat floor to seat the building. */
if (testStairs && !sceneArgGiven)
sceneFile = "demo_scene_vehicletest.json";
/* The interior-switch probe uses the same flat floor. */
if (testInterior && !sceneArgGiven)
sceneFile = "demo_scene_vehicletest.json";
/* The benchmark measures the generated town. */
if (benchmark && !sceneArgGiven)
sceneFile = "demo_scene_exterior.json";
@@ -11585,6 +12345,10 @@ int main(int argc, char *argv[])
if (testDetail)
app.getRoot()->addFrameListener(&detailListener);
AgeTestListener ageListener(&app);
if (testAges)
app.getRoot()->addFrameListener(&ageListener);
TaxiTestListener taxiListener(&app);
if (testTaxi)
app.getRoot()->addFrameListener(&taxiListener);
@@ -11626,6 +12390,11 @@ int main(int argc, char *argv[])
if (testStairs)
app.getRoot()->addFrameListener(&stairsListener);
/* Phase 8.2: in-memory generated interior switching. */
InteriorTestListener interiorListener(&app, sceneFile);
if (testInterior)
app.getRoot()->addFrameListener(&interiorListener);
/* Phase 6.7 travel probe: runs on the default interior
* scene (no scene override needed). */
AnimTravelTestListener animTravelListener(&app);
@@ -11916,6 +12685,24 @@ int main(int argc, char *argv[])
}
}
if (testAges) {
if (ageListener.failed) {
std::cerr << "[test] FAIL: "
<< ageListener.failReason << std::endl;
app.clearScene();
app.closeApp();
return 1;
}
if (ageListener.phase != 2) {
std::cerr << "[test] FAIL: incomplete (age test "
"phase " << ageListener.phase << ")"
<< std::endl;
app.clearScene();
app.closeApp();
return 1;
}
}
if (testDetail) {
if (detailListener.failed) {
std::cerr << "[test] FAIL: "
@@ -12125,7 +12912,7 @@ int main(int argc, char *argv[])
app.closeApp();
return 1;
}
if (stairsListener.phase != 7) {
if (stairsListener.phase != 8) {
std::cerr << "[test] FAIL: incomplete (stairs "
"phase "
<< stairsListener.phase << ")"
@@ -12136,6 +12923,26 @@ int main(int argc, char *argv[])
}
}
if (testInterior) {
if (interiorListener.failed) {
std::cerr << "[test] FAIL: "
<< interiorListener.failReason
<< std::endl;
app.clearScene();
app.closeApp();
return 1;
}
if (interiorListener.phase != 4) {
std::cerr << "[test] FAIL: incomplete "
"(interior phase "
<< interiorListener.phase << ")"
<< std::endl;
app.clearScene();
app.closeApp();
return 1;
}
}
if (testAnimTravel) {
if (animTravelListener.failed) {
std::cerr << "[test] FAIL: "
@@ -74,6 +74,58 @@
(roof flat 0 0 w d)
(parking (rect-front-center 13 5.2))))
; ---------------------------------------------------------------------
; Phase 7 showcase layouts: exterior stairs (7.4) and lobby stairs
; (7.5). Both are generated town layouts, not one-off previews.
; ---------------------------------------------------------------------
; Roadside motel: one-storey reception, upper floor reached by an
; EXTERIOR stair on the front facade (door :gallery -> walkway landing
; + flight down the facade, phase 7.4). The town generator additionally
; carves the automatic interior stairwell (phase 7.3a), so the units
; stay reachable from inside too. The door sits near the -X end so the
; flight has the remaining facade to descend along.
(layout roadside-motel :tags (commercial hotel house village town) :score 1
(let ((w 20) (d 8))
(shell w d :floors 2 :empty)
(floor 0
(room reception (rect 0 0 1 1) :type shop)
(door front center)
(windows all))
(floor 1
(room unit1 (rect 0 0 0.5 1) :type living)
(room unit2 (rect 0.5 0 1 1) :type bedroom)
(connect unit1 unit2)
(door front -8 :gallery)
(windows all))
(roof gable 0 0 w d)
(parking (rect-front-center 5.2 5.2))))
; Lobby hall: an open two-storey hall whose 3 x 2-cell stairwell room
; builds ONE wide grand flight (phase 7.5) from the lobby up to the
; upper hall. The stair room boards through the lobby door on its -X
; side and steps off into the east room at the top (+X).
(layout lobby-hall :tags (house village town commercial center) :score 1
(let ((w 12) (d 12))
(shell w d :floors 2 :empty)
(floor 0
(room lobby (rect 0 0 0.333 1) :type living)
(room stair (rect 0.333 0.166 0.833 0.5) :type stairwell)
(room east (rect 0.833 0.166 1 0.5))
(room back (rect 0.333 0 1 0.166) :windows none)
(room south (rect 0.333 0.5 1 1) :type living)
(connect lobby stair)
(connect stair east)
(connect lobby back)
(connect lobby south)
(door front -4)
(windows all))
(floor 1
(room hall (rect 0 0 1 1) :type living)
(windows all))
(roof gable 0 0 w d)
(parking (rect-front-center 5.2 5.2))))
; ---------------------------------------------------------------------
; Reference layouts (phase 6.8.13): one per location kind, exercising
; tags -> POI database -> entry POI -> schedule assignment -> stat
@@ -1571,6 +1571,18 @@ static void registerAllComponents()
lua_setfield(L, -2, "sceneSwitchPath");
lua_pushstring(L, dc.sceneSwitchTarget.c_str());
lua_setfield(L, -2, "sceneSwitchTarget");
lua_pushboolean(L, dc.hasSceneSwitchPose ? 1 : 0);
lua_setfield(L, -2, "sceneSwitchHasPose");
lua_newtable(L);
lua_pushnumber(L, dc.sceneSwitchPosX);
lua_rawseti(L, -2, 1);
lua_pushnumber(L, dc.sceneSwitchPosY);
lua_rawseti(L, -2, 2);
lua_pushnumber(L, dc.sceneSwitchPosZ);
lua_rawseti(L, -2, 3);
lua_setfield(L, -2, "sceneSwitchPos");
lua_pushnumber(L, dc.sceneSwitchYawDeg);
lua_setfield(L, -2, "sceneSwitchYaw");
lua_pushboolean(L, dc.disabled ? 1 : 0);
lua_setfield(L, -2, "disabled");
lua_pushboolean(L, dc.persistent ? 1 : 0);
@@ -1700,6 +1712,42 @@ static void registerAllComponents()
dc.sceneSwitchTarget =
lua_tostring(L, -1);
lua_pop(L, 1);
if (lua_getfield(L, -1,
"sceneSwitchHasPose"),
lua_isboolean(L, -1))
dc.hasSceneSwitchPose =
lua_toboolean(L, -1) != 0;
lua_pop(L, 1);
if (lua_getfield(L, -1,
"sceneSwitchPos"),
lua_istable(L, -1)) {
lua_rawgeti(L, -1, 1);
if (lua_isnumber(L, -1))
dc.sceneSwitchPosX =
(float)lua_tonumber(
L, -1);
lua_pop(L, 1);
lua_rawgeti(L, -1, 2);
if (lua_isnumber(L, -1))
dc.sceneSwitchPosY =
(float)lua_tonumber(
L, -1);
lua_pop(L, 1);
lua_rawgeti(L, -1, 3);
if (lua_isnumber(L, -1))
dc.sceneSwitchPosZ =
(float)lua_tonumber(
L, -1);
lua_pop(L, 1);
}
lua_pop(L, 1);
if (lua_getfield(L, -1,
"sceneSwitchYaw"),
lua_isnumber(L, -1))
dc.sceneSwitchYawDeg =
(float)lua_tonumber(
L, -1);
lua_pop(L, 1);
if (lua_getfield(L, -1, "disabled"),
lua_isboolean(L, -1))
dc.disabled =
+179
View File
@@ -0,0 +1,179 @@
#ifndef EDITSCENE_AGEBANDS_HPP
#define EDITSCENE_AGEBANDS_HPP
#pragma once
#include <algorithm>
#include <cctype>
#include <string>
#include <vector>
/**
* Pure age-band vocabulary shared by the population, schedules, character
* catalog and editor. Mirrors assets/blender/characters/age_profiles.py.
*
* Canonical strings: baby, child, teenager, adult, elder.
* Legacy aliases: infant -> baby, teen/adolescent -> teenager,
* senior/old -> elder.
*/
namespace AgeBands {
enum class Band { Baby, Child, Teenager, Adult, Elder };
inline const char *name(Band band)
{
switch (band) {
case Band::Baby:
return "baby";
case Band::Child:
return "child";
case Band::Teenager:
return "teenager";
case Band::Adult:
return "adult";
case Band::Elder:
return "elder";
}
return "adult";
}
inline std::string lower(const std::string &s)
{
std::string out = s;
std::transform(out.begin(), out.end(), out.begin(),
[](unsigned char c) { return (char)std::tolower(c); });
return out;
}
inline std::string normalize(const std::string &age)
{
std::string a = lower(age);
if (a.empty())
return "adult";
if (a == "infant")
return "baby";
if (a == "teen" || a == "adolescent")
return "teenager";
if (a == "senior" || a == "old" || a == "old-age")
return "elder";
if (a == "baby" || a == "child" || a == "teenager" ||
a == "adult" || a == "elder")
return a;
return "adult";
}
inline bool isKnown(const std::string &age)
{
const std::string a = lower(age);
return a == "baby" || a == "child" || a == "teenager" ||
a == "adult" || a == "elder" || a == "infant" || a == "teen" ||
a == "adolescent" || a == "senior" || a == "old" ||
a == "old-age";
}
/** Reference band boundaries used by the generated population. */
inline Band fromYears(int years)
{
if (years <= 2)
return Band::Baby;
if (years <= 12)
return Band::Child;
if (years <= 17)
return Band::Teenager;
if (years <= 64)
return Band::Adult;
return Band::Elder;
}
inline std::vector<std::string> fallbackChain(const std::string &age)
{
const std::string a = normalize(age);
if (a == "baby")
return { "baby", "child", "adult" };
if (a == "child")
return { "child", "baby", "teenager", "adult" };
if (a == "teenager")
return { "teenager", "child", "adult" };
if (a == "elder")
return { "elder", "adult" };
return { "adult", "teenager", "elder" };
}
inline bool isWorkingAge(int years)
{
return fromYears(years) == Band::Adult;
}
/** Adult keeps the legacy stem (male/female); new bands use age-sex. */
inline std::string stem(const std::string &age, const std::string &sex)
{
const std::string a = normalize(age);
return a == "adult" ? sex : a + "-" + sex;
}
inline std::string armatureName(const std::string &age,
const std::string &sex)
{
return stem(age, sex);
}
inline float adultHeight(const std::string &sex)
{
return sex == "female" ? 1.58f : 1.85f;
}
inline float targetHeight(const std::string &age, const std::string &sex)
{
const std::string a = normalize(age);
const bool female = sex == "female";
if (a == "baby")
return female ? 0.70f : 0.72f;
if (a == "child")
return female ? 1.18f : 1.20f;
if (a == "teenager")
return female ? 1.55f : 1.70f;
/* Elder v1 is bind-identical to adult. */
if (a == "elder")
return adultHeight(sex);
return adultHeight(sex);
}
inline float walkSpeedScale(const std::string &age)
{
const std::string a = normalize(age);
if (a == "baby")
return 0.0f;
if (a == "child")
return 0.75f;
if (a == "teenager")
return 0.95f;
return 1.0f;
}
/** Scale the existing adult capsule defaults by the age height ratio. */
struct BodyMetrics {
float radius = 0.3f;
float height = 1.8f;
float totalHeight = 2.4f;
float headRadius = 0.13f;
};
inline BodyMetrics bodyMetrics(const std::string &age,
const std::string &sex)
{
const float scale = targetHeight(age, sex) / adultHeight(sex);
BodyMetrics m;
m.radius = 0.3f * scale;
m.height = 1.8f * scale;
m.totalHeight = m.height + 2.0f * m.radius;
m.headRadius = 0.13f * scale;
return m;
}
inline std::vector<std::string> canonicalOrder()
{
return { "baby", "child", "teenager", "adult", "elder" };
}
} // namespace AgeBands
#endif // EDITSCENE_AGEBANDS_HPP
@@ -0,0 +1,389 @@
#include "InteriorScene.hpp"
#include <cmath>
namespace InteriorScene {
namespace {
/* Floor surface of a plan level (mirrors CellGridSystem::floorSurfaceY). */
constexpr float FLOOR_SURFACE_Y = 0.2f;
void hashMix(uint64_t &h, uint64_t v)
{
h ^= v;
h *= 1099511628211ULL;
}
void hashString(uint64_t &h, const std::string &s)
{
for (unsigned char c : s)
hashMix(h, c);
hashMix(h, 0xff); /* terminator so "ab"+"c" != "a"+"bc" */
}
/* Quaternion for a yaw rotation about +Y (character facing: the meshes
* face +Z, so a front (+Z) door faces the player into -Z = 180 deg). */
nlohmann::json yawRotation(float yawDeg)
{
const float half = yawDeg * 0.5f * 3.14159265358979323846f / 180.0f;
nlohmann::json q;
q["w"] = std::cos(half);
q["x"] = 0.0f;
q["y"] = std::sin(half);
q["z"] = 0.0f;
return q;
}
nlohmann::json identityRotation()
{
nlohmann::json q;
q["w"] = 1.0f;
q["x"] = 0.0f;
q["y"] = 0.0f;
q["z"] = 0.0f;
return q;
}
nlohmann::json transformJson(float x, float y, float z,
const nlohmann::json &rotation)
{
nlohmann::json t;
t["position"] = { { "x", x }, { "y", y }, { "z", z } };
t["rotation"] = rotation;
t["scale"] = { { "x", 1.0f }, { "y", 1.0f }, { "z", 1.0f } };
return t;
}
/* Marker yaw that faces INTO the building from its doorway. Side
* mapping matches CellGridSystem: 0 = -Z, 1 = +Z, 2 = -X, 3 = +X. */
float arrivalYawDeg(int side)
{
switch (side) {
case 0: /* -Z (back): inside is +Z */
return 0.0f;
case 1: /* +Z (front): inside is -Z */
return 180.0f;
case 2: /* -X (left): inside is +X */
return 90.0f;
default: /* +X (right): inside is -X */
return 270.0f;
}
}
/* Arrival position just inside the doorway cell. */
void arrivalPosition(const LayoutLib::BuildingPlan &plan, const Entrance &e,
float &x, float &y, float &z)
{
const float cs = plan.cellSize;
x = (float)e.cellX * cs;
z = (float)e.cellZ * cs;
const float inward = 0.6f;
switch (e.side) {
case 0: /* -Z doorway: inside is +Z */
z += inward;
break;
case 1: /* +Z doorway: inside is -Z */
z -= inward;
break;
case 2:
x += inward;
break;
default:
x -= inward;
break;
}
y = (float)e.floor * plan.cellHeight + FLOOR_SURFACE_Y + 0.02f;
}
nlohmann::json lightEntity(int id, const Lighting &lighting)
{
nlohmann::json e;
e["id"] = id;
e["name"] = { { "name", "interior-light" } };
e["transform"] = transformJson(0.0f, 10.0f, 0.0f,
identityRotation());
nlohmann::json l;
l["castShadows"] = false;
l["constantAttenuation"] = 1.0f;
l["linearAttenuation"] = 0.0f;
l["quadraticAttenuation"] = 0.0f;
l["diffuseColor"] = { { "r", lighting.diffuse },
{ "g", lighting.diffuse },
{ "b", lighting.diffuse },
{ "a", 1.0f } };
l["specularColor"] = { { "r", 0.5f },
{ "g", 0.5f },
{ "b", 0.5f },
{ "a", 1.0f } };
l["direction"] = { { "x", lighting.dirX },
{ "y", lighting.dirY },
{ "z", lighting.dirZ } };
l["intensity"] = 1.0f;
l["lightType"] = "directional";
l["range"] = 100.0f;
l["spotlightFalloff"] = 1.0f;
l["spotlightInnerAngle"] = 30.0f;
l["spotlightOuterAngle"] = 45.0f;
e["light"] = l;
return e;
}
} // namespace
float inwardYawDeg(int side)
{
return arrivalYawDeg(side);
}
DocumentCache::DocumentCache(size_t limitBytes)
: m_limit(limitBytes)
{
}
void DocumentCache::touch(const std::string &key)
{
auto pos = m_pos.find(key);
if (pos == m_pos.end())
return;
m_lru.splice(m_lru.begin(), m_lru, pos->second);
pos->second = m_lru.begin();
}
void DocumentCache::dropOldest()
{
if (m_lru.empty())
return;
const std::string key = m_lru.back();
m_lru.pop_back();
auto it = m_entries.find(key);
if (it != m_entries.end()) {
m_bytes -= it->second.bytes;
m_entries.erase(it);
}
m_pos.erase(key);
}
const nlohmann::json *DocumentCache::find(const std::string &key)
{
auto it = m_entries.find(key);
if (it == m_entries.end())
return nullptr;
touch(key);
return &it->second.document;
}
void DocumentCache::put(const std::string &key,
const nlohmann::json &document)
{
const size_t bytes = document.dump().size();
auto old = m_entries.find(key);
if (old != m_entries.end()) {
m_bytes -= old->second.bytes;
m_entries.erase(old);
auto pos = m_pos.find(key);
if (pos != m_pos.end()) {
m_lru.erase(pos->second);
m_pos.erase(pos);
}
}
if (bytes > m_limit)
return; /* cannot fit even alone */
while (m_bytes + bytes > m_limit)
dropOldest();
Entry entry;
entry.document = document;
entry.bytes = bytes;
m_entries.emplace(key, std::move(entry));
m_lru.push_front(key);
m_pos[key] = m_lru.begin();
m_bytes += bytes;
}
void DocumentCache::clear()
{
m_lru.clear();
m_entries.clear();
m_pos.clear();
m_bytes = 0;
}
void DocumentCache::setLimitBytes(size_t limitBytes)
{
m_limit = limitBytes;
while (m_bytes > m_limit)
dropOldest();
}
uint64_t planHash(const LayoutLib::BuildingPlan &plan,
const std::string &address)
{
uint64_t h = 1469598103934665603ULL; /* FNV-1a offset basis */
hashMix(h, (uint64_t)plan.cellsX);
hashMix(h, (uint64_t)plan.cellsZ);
hashMix(h, (uint64_t)plan.floors);
hashMix(h, (uint64_t)std::lround(plan.cellSize * 1000.0f));
hashMix(h, (uint64_t)std::lround(plan.cellHeight * 1000.0f));
for (int y = 0; y < plan.floors; ++y)
for (int z = 0; z < plan.cellsZ; ++z)
for (int x = 0; x < plan.cellsX; ++x) {
const uint64_t flags = plan.at(x, y, z);
if (!flags)
continue;
hashMix(h, (uint64_t)(x + 1024));
hashMix(h, (uint64_t)(y + 1024));
hashMix(h, (uint64_t)(z + 1024));
hashMix(h, flags);
}
hashString(h, address);
return h;
}
std::string doorEdgeKey(int cellX, int cellY, int cellZ, int side)
{
/* Canonical key of the shared cell edge, identical to
* CellGridSystem::doorEdgeKey (X+ uses the neighbour's
* coordinate; -Z uses the neighbour's coordinate). */
if (side == 3)
cellX += 1;
else if (side == 1)
cellZ += 1;
const char axis = (side == 2 || side == 3) ? 'X' : 'Z';
return std::string(1, axis) + ":" + std::to_string(cellX) + ":" +
std::to_string(cellY) + ":" + std::to_string(cellZ);
}
Output build(const Input &in)
{
Output out;
const LayoutLib::BuildingPlan *plan = in.plan;
if (!plan)
return out;
out.planHash = planHash(*plan, in.address);
nlohmann::json doc;
doc["version"] = "1.0";
doc["interior"] = {
{ "gridUid", in.gridUid },
{ "address", in.address },
{ "planHash", out.planHash },
{ "sourceScene", in.sourceScenePath },
};
nlohmann::json entities = nlohmann::json::array();
int nextId = 1;
/* ---- Grid entity: the same cells in interiorOnly mode. ---- */
nlohmann::json gridEntity;
gridEntity["id"] = nextId++;
gridEntity["name"] = { { "name", "interior:" + in.gridUid } };
gridEntity["transform"] = transformJson(0.0f, 0.0f, 0.0f,
identityRotation());
nlohmann::json cg;
cg["width"] = plan->cellsX;
cg["height"] = plan->floors;
cg["depth"] = plan->cellsZ;
cg["cellSize"] = plan->cellSize;
cg["cellHeight"] = plan->cellHeight;
cg["generationScript"] = "";
cg["generationMode"] = "interiorOnly";
cg["floorRectName"] = "floor";
cg["ceilingRectName"] = "ceiling";
cg["extWallRectName"] = "wall";
cg["intWallRectName"] = "wall";
cg["extDoorFrameRectName"] = "door";
cg["intDoorFrameRectName"] = "door";
cg["extWindowFrameRectName"] = "winframe";
cg["intWindowFrameRectName"] = "winframe";
cg["roofTopRectName"] = "roof";
cg["roofSideRectName"] = "roof";
cg["stairRectName"] = "";
cg["friction"] = 0.5f;
cg["doorsEnabled"] = true;
cg["doorRectName"] = "door";
cg["doorMeshName"] = "";
cg["doorUseMeshMaterial"] = false;
cg["doorOpenAngle"] = 100.0f;
cg["doorOpenSpeed"] = 180.0f;
cg["doorSwingReversed"] = false;
cg["doorActionName"] = "";
cg["doorSceneSwitchPath"] = "";
cg["doorSceneSwitchTarget"] = "";
cg["glassColor"] = { 0.4f, 0.6f, 0.8f, 0.35f };
cg["glassMaterialName"] = "";
cg["glassReflectivity"] = 0.8f;
cg["gridUid"] = in.gridUid;
/* Exit doors: every entrance doorway, pre-wired back to the source
* scene at that doorway's exterior approach pose. */
nlohmann::json doorConfigs = nlohmann::json::object();
for (const Entrance &e : in.entrances) {
const std::string key =
doorEdgeKey(e.cellX, e.floor, e.cellZ, e.side);
nlohmann::json dc;
dc["label"] = "Exit";
dc["hasOverride"] = true;
dc["openAngle"] = 100.0f;
dc["openSpeed"] = 180.0f;
dc["swingReversed"] = false;
dc["actionName"] = "";
dc["sceneSwitchPath"] = in.sourceScenePath;
dc["sceneSwitchTarget"] = "";
dc["sceneSwitchHasPose"] = true;
dc["sceneSwitchPos"] = { e.approachX, e.approachY,
e.approachZ };
dc["sceneSwitchYaw"] = e.approachYawDeg;
dc["disabled"] = false;
dc["persistent"] = false;
dc["lockable"] = false;
dc["lockedByDefault"] = false;
dc["keyItemId"] = "";
doorConfigs[key] = dc;
}
cg["doorConfigs"] = doorConfigs;
nlohmann::json cells = nlohmann::json::array();
for (int y = 0; y < plan->floors; ++y)
for (int z = 0; z < plan->cellsZ; ++z)
for (int x = 0; x < plan->cellsX; ++x) {
const uint64_t flags = plan->at(x, y, z);
if (!flags)
continue;
cells.push_back({ { "x", x },
{ "y", y },
{ "z", z },
{ "flags", flags } });
}
cg["cells"] = cells;
cg["furnitureCells"] = nlohmann::json::array();
gridEntity["cellGrid"] = cg;
entities.push_back(gridEntity);
/* ---- Arrival markers: one just inside every entrance. ---- */
for (const Entrance &e : in.entrances) {
float ax, ay, az;
arrivalPosition(*plan, e, ax, ay, az);
nlohmann::json marker;
marker["id"] = nextId++;
marker["name"] = {
{ "name", "arrival:" +
doorEdgeKey(e.cellX, e.floor,
e.cellZ, e.side) }
};
marker["transform"] = transformJson(
ax, ay, az, yawRotation(arrivalYawDeg(e.side)));
entities.push_back(marker);
}
/* ---- Lighting (v1: one directional light). ---- */
entities.push_back(lightEntity(nextId++, in.lighting));
doc["entities"] = entities;
out.document = std::move(doc);
return out;
}
} // namespace InteriorScene
@@ -0,0 +1,133 @@
#pragma once
#include <cstdint>
#include <list>
#include <string>
#include <unordered_map>
#include <vector>
#include <nlohmann/json.hpp>
#include "../layoutlib/BuildingPlan.hpp"
/*
* InteriorScene — pure builder of the in-memory interior scene document
* (demo-taxi phase 8.1).
*
* The interior of an enterable generated building is a pure function of
* the SAME LayoutLib::BuildingPlan that built the exterior shell: same
* grid metrics, same cells/flags (interiorOnly mode), same doorways.
* build() returns a complete scene JSON document (version/entities) that
* phase 8.2 loads from memory — nothing is written to disk.
*
* Every exterior entrance doorway becomes an interior exit door whose
* per-door config is PRE-WIRED: it points at the source (exterior)
* scene and carries that doorway's exterior approach pose, so returning
* needs no dynamic bookkeeping (player and NPCs alike). Each entrance
* also gets an "arrival:<edgeKey>" marker just inside the door.
*/
namespace InteriorScene {
/** One exterior entrance doorway of the building. side mirrors
* CellGridSystem's door sides exactly: 0 = -Z (DoorZNeg), 1 = +Z
* (DoorZPos), 2 = -X (DoorXNeg), 3 = +X (DoorXPos). The approach pose
* is in EXTERIOR world space and is filled by the caller from the town
* building placement (doorstep / back / side doorstep). */
struct Entrance {
int floor = 0;
int cellX = 0;
int cellZ = 0;
int side = 0;
float approachX = 0.0f;
float approachY = 0.0f;
float approachZ = 0.0f;
float approachYawDeg = 0.0f;
};
/** v1 interior lighting: one directional light. The struct is the
* extension point for later light states (lights off, day/night). */
struct Lighting {
float dirX = 0.3f;
float dirY = -1.0f;
float dirZ = 0.2f;
float diffuse = 1.0f;
};
struct Input {
/** Build truth; null is a hard no-op (empty document). */
const LayoutLib::BuildingPlan *plan = nullptr;
/** Stable identity, e.g. "town-<address>" (door ids + cache). */
std::string gridUid;
std::string address;
/** Exterior scene the interior exit doors return to. */
std::string sourceScenePath;
std::vector<Entrance> entrances;
Lighting lighting;
};
struct Output {
/** Complete scene document (entities + version + metadata). */
nlohmann::json document;
/** Cache-validity key (phase 8.2); 0 for an empty input. */
uint64_t planHash = 0;
};
/** FNV-1a over the plan metrics, every cell (coords + flags) and the
* address. Deterministic; used as the in-memory cache key. */
uint64_t planHash(const LayoutLib::BuildingPlan &plan,
const std::string &address);
/** Canonical doorway edge key, identical to
* CellGridSystem::doorEdgeKey() (component-free mirror so this builder
* stays OGRE/flecs-free). */
std::string doorEdgeKey(int cellX, int cellY, int cellZ, int side);
/** Build the in-memory interior scene document. */
Output build(const Input &in);
/** Inward facing yaw (degrees) for a doorway side — the direction the
* character faces when standing inside the doorway (and the exterior
* return pose when standing outside it). Side mapping as Entrance. */
float inwardYawDeg(int side);
/** LRU of in-memory interior scene documents, capped by total JSON
* size (phase 8.2; default 1 MB). Pure and headless-testable; the
* generated-interior service owns one. */
class DocumentCache {
public:
explicit DocumentCache(size_t limitBytes = size_t(1) << 20);
/** Most-recently-used document for key, or nullptr on a miss. */
const nlohmann::json *find(const std::string &key);
/** Insert/replace a document. A document larger than the whole
* budget is not cached; least-recently-used entries are evicted
* until the new one fits. */
void put(const std::string &key,
const nlohmann::json &document);
void clear();
size_t bytes() const { return m_bytes; }
size_t size() const { return m_entries.size(); }
size_t limitBytes() const { return m_limit; }
void setLimitBytes(size_t limitBytes);
private:
struct Entry {
nlohmann::json document;
size_t bytes = 0;
};
std::list<std::string> m_lru; /* most recent at the front */
std::unordered_map<std::string, Entry> m_entries;
std::unordered_map<std::string,
std::list<std::string>::iterator>
m_pos;
size_t m_bytes = 0;
size_t m_limit;
void touch(const std::string &key);
void dropOldest();
};
} // namespace InteriorScene
@@ -1,5 +1,7 @@
#include "TownPopulation.hpp"
#include "AgeBands.hpp"
#include <random>
namespace TownPopulation {
@@ -137,18 +139,20 @@ Population generatePopulation(const TownGen::TownResult &town,
m.household = (int)pop.households.size();
m.sex = unit(rng) < 0.5f ? "male" : "female";
if (child) {
m.age = "child";
m.ageYears = randInt(rng, rules.childMinYears,
rules.childMaxYears);
} else {
bool elder = unit(rng) < rules.elderProbability;
m.age = elder ? "elder" : "adult";
m.ageYears = elder ?
randInt(rng, rules.elderMinYears,
rules.elderMaxYears) :
randInt(rng, rules.adultMinYears,
rules.adultMaxYears);
}
/* Derive the appearance band from the drawn age; no
* extra RNG draw, so existing population streams stay
* deterministic. */
m.age = AgeBands::name(AgeBands::fromYears(m.ageYears));
m.outfitLevel = randInt(rng, rules.minOutfitLevel,
rules.maxOutfitLevel);
const std::vector<std::string> &pool =
@@ -84,7 +84,7 @@ struct PopMember {
std::string lastName;
/** "male" | "female" (CharacterRecord::inlineSex). */
std::string sex;
/** "child" | "adult" | "elder" (CharacterRecord::age). */
/** Canonical appearance band from ageYears (see AgeBands). */
std::string age;
int ageYears = 0;
int outfitLevel = 0;
@@ -1,5 +1,7 @@
#include "TownSchedule.hpp"
#include "AgeBands.hpp"
#include <algorithm>
#include <climits>
#include <cmath>
@@ -51,7 +53,9 @@ std::vector<NpcSchedule> generateSchedules(
randRange(rng, -rules.wakeSpread,
rules.wakeSpread),
4.0f, 10.0f);
const bool adult = pm.age == "adult";
const bool adult = AgeBands::isWorkingAge(pm.ageYears);
const bool baby = AgeBands::fromYears(pm.ageYears) ==
AgeBands::Band::Baby;
float workStart = 0.0f;
if (adult)
workStart = clampf(rules.workStartHour +
@@ -96,8 +100,11 @@ std::vector<NpcSchedule> generateSchedules(
push(sched, workEnd + errandLen,
bind.interest, "errand");
}
} else {
float outHour = pm.age == "child" ?
} else if (!baby) {
const AgeBands::Band band =
AgeBands::fromYears(pm.ageYears);
float outHour = (band == AgeBands::Band::Child ||
band == AgeBands::Band::Teenager) ?
rules.childOutHour : rules.elderOutHour;
push(sched, outHour, bind.home, "home");
if (errand)
@@ -420,7 +427,9 @@ std::vector<WeekSchedule> generateWeekSchedules(
randRange(rng, -rules.daily.wakeSpread,
rules.daily.wakeSpread),
4.0f, 10.0f));
const bool adult = pm->age == "adult";
const bool adult = AgeBands::isWorkingAge(pm->ageYears);
const bool baby = AgeBands::fromYears(pm->ageYears) ==
AgeBands::Band::Baby;
int workStart = 0;
if (adult)
workStart = (int)std::round(
@@ -664,16 +673,19 @@ std::vector<WeekSchedule> generateWeekSchedules(
push((float)weekendWake + 4.0f, bind.home,
"home", "", day);
}
} else {
} else if (!baby) {
const AgeBands::Band band =
AgeBands::fromYears(pm->ageYears);
float outHour =
pm->age == "child" ?
(band == AgeBands::Band::Child ||
band == AgeBands::Band::Teenager) ?
rules.daily.childOutHour :
rules.daily.elderOutHour;
push(outHour, bind.home, "home", "", day);
}
/* The daily leisure visit (the old "errand"). */
if (errand[day] && t > 0.0f && t < 23.4f) {
if (!baby && errand[day] && t > 0.0f && t < 23.4f) {
std::vector<std::string> ltags =
sortedTagKeys(ix.leisureTags);
std::string leisureTag;
@@ -6184,6 +6184,14 @@ void CellGridSystem::buildDoorEntities(flecs::entity entity,
params.sceneSwitchTarget = hasOverride ?
doorCfg->sceneSwitchTarget :
grid.doorSceneSwitchTarget;
/* Phase 8.2: pre-wired return pose (generated interiors). */
if (hasOverride && doorCfg->hasSceneSwitchPose) {
params.sceneSwitchHasPose = true;
params.sceneSwitchPosX = doorCfg->sceneSwitchPosX;
params.sceneSwitchPosY = doorCfg->sceneSwitchPosY;
params.sceneSwitchPosZ = doorCfg->sceneSwitchPosZ;
params.sceneSwitchYawDeg = doorCfg->sceneSwitchYawDeg;
}
params.persistent = doorCfg && doorCfg->persistent;
params.lockable = doorCfg && doorCfg->lockable;
params.lockedByDefault = doorCfg && doorCfg->lockedByDefault;
@@ -9,6 +9,7 @@
#include "../components/EntityName.hpp"
#include "../components/EditorMarker.hpp"
#include "../components/RuntimeMarker.hpp"
#include "../roadlib/AgeBands.hpp"
#include <OgreLogManager.h>
#include <OgreSceneManager.h>
#include <fstream>
@@ -22,6 +23,11 @@
CharacterRegistry *CharacterRegistry::ms_singleton = nullptr;
static std::string normalizeAge(const std::string &age)
{
return AgeBands::normalize(age);
}
CharacterRegistry &CharacterRegistry::getSingleton()
{
OgreAssert(ms_singleton, "CharacterRegistry not created");
@@ -126,7 +132,7 @@ bool CharacterRegistry::readPrefabSlots(const std::string &filepath,
if (!j.contains("characterSlots"))
return false;
auto &cs = j["characterSlots"];
age = cs.value("age", "adult");
age = normalizeAge(cs.value("age", "adult"));
sex = cs.value("sex", "male");
outfitLevel = cs.value("outfitLevel", 2);
return true;
@@ -326,7 +332,7 @@ readPrefabAppearance(const std::string &path, std::string &outSex,
if (j.contains("characterSlots")) {
auto &s = j["characterSlots"];
outSex = s.value("sex", "male");
outAge = s.value("age", "adult");
outAge = normalizeAge(s.value("age", "adult"));
if (s.contains("frontAxis") && s["frontAxis"].is_array() &&
s["frontAxis"].size() >= 3) {
outFrontAxis = Ogre::Vector3(
@@ -479,13 +485,13 @@ uint64_t CharacterRegistry::createChild(uint64_t parentA, uint64_t parentB)
prefabSelections,
child->inlineShapeKeyWeights, prefabAge,
prefabFrontAxis);
child->age = prefabAge;
child->age = normalizeAge(prefabAge);
child->inlineSex = prefabSex;
child->inlineOutfitLevel = sameSexParent->inlineOutfitLevel;
child->inlineSlotSelections = prefabSelections;
child->frontAxis = sameSexParent->frontAxis;
} else {
child->age = sameSexParent->age;
child->age = normalizeAge(sameSexParent->age);
child->inlineSex = sameSexParent->inlineSex;
child->inlineOutfitLevel = sameSexParent->inlineOutfitLevel;
child->inlineSlotSelections =
@@ -495,6 +501,13 @@ uint64_t CharacterRegistry::createChild(uint64_t parentA, uint64_t parentB)
child->frontAxis = sameSexParent->frontAxis;
}
/* A newborn is always a baby, regardless of the parent's appearance
* band. Parent slot selections point at another age's skeleton and
* must not leak into the baby catalog. */
child->age = "baby";
child->ageYears = 0;
child->inlineSlotSelections.clear();
/* Randomize configured birth shape keys */
if (!m_birthRandomizableShapeKeys.empty()) {
for (const auto &key : m_birthRandomizableShapeKeys) {
@@ -808,17 +821,13 @@ flecs::entity CharacterRegistry::spawnCharacter(uint64_t id)
c->inlineShapeKeyWeights, prefabAge,
c->frontAxis);
if (!prefabAge.empty())
c->age = prefabAge;
c->age = normalizeAge(prefabAge);
autoSave();
}
Ogre::Vector3 pos = c->position;
flecs::entity inst;
if (std::filesystem::exists(c->prefabPath)) {
/* Prefab instantiation needs the UI system; without it
* (headless) the caller falls back to an inline spawn. */
if (!m_uiSystem)
return flecs::entity::null();
if (std::filesystem::exists(c->prefabPath) && m_uiSystem) {
PrefabSystem prefabSys(*m_world, m_sceneMgr);
inst = prefabSys.createInstance(
c->prefabPath, flecs::entity::null(), pos,
@@ -830,6 +839,9 @@ flecs::entity CharacterRegistry::spawnCharacter(uint64_t id)
t.applyToNode();
}
} else {
/* No UI system (headless) or no prefab: inline spawn.
* The identity is applied below, so save/load keeps the
* registry record linkage either way. */
inst = spawnInlineCharacter(*c, pos);
if (inst.is_alive() && inst.has<TransformComponent>()) {
auto &t = inst.get_mut<TransformComponent>();
@@ -1553,7 +1565,7 @@ void CharacterRegistry::deserialize(const nlohmann::json &j)
c.availablePoints = rec.value("availablePoints", 0);
c.levelUpPending = rec.value("levelUpPending", false);
c.ageYears = rec.value("ageYears", 0);
c.age = rec.value("age", "adult");
c.age = normalizeAge(rec.value("age", "adult"));
c.inlineSex = rec.value("inlineSex", "male");
c.inlineOutfitLevel = rec.value("inlineOutfitLevel", 2);
if (rec.contains("frontAxis") &&
@@ -5,6 +5,7 @@
#include "../components/AnimationTree.hpp"
#include "../components/CharacterIdentity.hpp"
#include "../components/CharacterSlots.hpp"
#include "../roadlib/AgeBands.hpp"
#include <OgreAnimation.h>
#include <OgreAnimationState.h>
#include <OgreAnimationTrack.h>
@@ -20,6 +21,7 @@
#include <OgreSceneNode.h>
#include <algorithm>
#include <map>
#include <unordered_map>
CharacterSlotSystem *CharacterSlotSystem::ms_singleton = nullptr;
bool CharacterSlotSystem::s_catalogLoaded = false;
@@ -138,6 +140,23 @@ void CharacterSlotSystem::loadCatalog()
}
s_catalogLoaded = true;
/* Diagnostic: verify each canonical band resolves to itself for the
* dressed outfit level. A fallback here means the age assets are
* incomplete or misnamed; the per-band log line makes that obvious
* without spawning an NPC of every age. */
for (const auto &ageName : AgeBands::canonicalOrder()) {
const Ogre::String age(ageName);
for (const auto &sex : { Ogre::String("male"),
Ogre::String("female") }) {
const Ogre::String resolved =
resolveAgeBand(age, sex, 2);
Ogre::LogManager::getSingleton().logMessage(
"[CharacterSlotSystem] catalog band " + age + "/" +
sex + " resolved=" + resolved +
(resolved == age ? "" : " (FALLBACK)"));
}
}
}
bool CharacterSlotSystem::isCatalogLoaded()
@@ -145,13 +164,102 @@ bool CharacterSlotSystem::isCatalogLoaded()
return s_catalogLoaded;
}
static bool catalogHasSlot(const Ogre::String &age, const Ogre::String &sex,
const Ogre::String &slot)
{
const auto &cat = CharacterSlotSystem::getCatalog();
if (!cat.contains(age) || !cat[age].contains(sex) ||
!cat[age][sex].contains(slot))
return false;
const auto &entries = cat[age][sex][slot];
return entries.is_array() && !entries.empty();
}
static bool catalogHasLayer(const Ogre::String &age, const Ogre::String &sex,
const Ogre::String &slot, int layer)
{
if (!catalogHasSlot(age, sex, slot))
return false;
const auto &cat = CharacterSlotSystem::getCatalog();
for (const auto &entry : cat[age][sex][slot]) {
if (entry.value("layer", 0) == layer)
return true;
}
return false;
}
static bool catalogBandComplete(const Ogre::String &age,
const Ogre::String &sex, int outfitLevel)
{
const auto &cat = CharacterSlotSystem::getCatalog();
if (!cat.contains(age) || !cat[age].contains(sex))
return false;
/* Face is the master slot and is always required. */
if (!catalogHasSlot(age, sex, "face"))
return false;
const char *bodySlots[] = { "top", "bottom", "feet" };
for (const char *slot : bodySlots) {
if (!catalogHasSlot(age, sex, slot))
return false;
if (outfitLevel >= 2 && !catalogHasLayer(age, sex, slot, 2))
return false;
if (outfitLevel >= 1 && !catalogHasLayer(age, sex, slot, 1) &&
!catalogHasLayer(age, sex, slot, 2))
return false;
}
/* Hair may be a bald/scalp layer-0 or a layer-1 hairstyle. A band
* without hair is still renderable; it just has no hair part. */
return true;
}
Ogre::String CharacterSlotSystem::resolveAgeBand(const Ogre::String &age,
const Ogre::String &sex,
int outfitLevel)
{
const std::string requested = AgeBands::normalize(age);
if (!s_catalogLoaded)
return Ogre::String(requested);
static std::map<std::string, std::string> cache;
const std::string key = requested + "|" + sex + "|" +
std::to_string(outfitLevel);
auto it = cache.find(key);
if (it != cache.end())
return it->second;
for (const auto &candidate : AgeBands::fallbackChain(requested)) {
const Ogre::String a(candidate);
if (catalogBandComplete(a, sex, outfitLevel)) {
cache[key] = candidate;
return a;
}
}
/* Last resort: adult if present, else the requested band. */
if (s_bodyParts.contains("adult") &&
s_bodyParts["adult"].contains(sex)) {
cache[key] = "adult";
return "adult";
}
cache[key] = requested;
return Ogre::String(requested);
}
std::vector<Ogre::String> CharacterSlotSystem::getAges()
{
std::vector<Ogre::String> ages;
if (!s_catalogLoaded)
return ages;
for (auto &el : s_bodyParts.items())
ages.push_back(el.key());
std::set<Ogre::String> seen;
for (const auto &a : AgeBands::canonicalOrder()) {
const Ogre::String age(a);
if (s_bodyParts.contains(age)) {
ages.push_back(age);
seen.insert(age);
}
}
for (auto &el : s_bodyParts.items()) {
if (seen.find(el.key()) == seen.end())
ages.push_back(el.key());
}
return ages;
}
@@ -309,8 +417,20 @@ Ogre::String CharacterSlotSystem::resolveMesh(const Ogre::String &age,
const SlotSelection &sel,
int outfitLevel)
{
if (!sel.explicitMesh.empty())
return sel.explicitMesh;
if (!sel.explicitMesh.empty()) {
if (!s_catalogLoaded || !s_bodyParts.contains(age) ||
!s_bodyParts[age].contains(sex) ||
!s_bodyParts[age][sex].contains(slot))
return sel.explicitMesh;
for (const auto &entry : s_bodyParts[age][sex][slot]) {
if (entry.value("mesh", "") == sel.explicitMesh)
return sel.explicitMesh;
}
Ogre::LogManager::getSingleton().logMessage(
"[CharacterSlotSystem] ignoring foreign explicit mesh '" +
sel.explicitMesh + "' for " + age + "/" + sex + "/" +
slot);
}
if (!s_catalogLoaded || !s_bodyParts.contains(age) ||
!s_bodyParts[age].contains(sex) ||
@@ -567,11 +687,15 @@ impostorMeshName(const CharacterRegistry::CharacterRecord *rec, int tier)
{
if (!rec || rec->inlineSex.empty())
return "";
const Ogre::String resolved = CharacterSlotSystem::resolveAgeBand(
rec->age, rec->inlineSex, rec->inlineOutfitLevel);
const std::string stem = AgeBands::stem(resolved,
rec->inlineSex);
Ogre::String variant = "thin";
auto it = rec->inlineShapeKeyWeights.find("fat");
if (it != rec->inlineShapeKeyWeights.end() && it->second >= 0.5f)
variant = "fat";
return "charlod_" + rec->inlineSex + "_" + variant + "_lod" +
return "charlod_" + Ogre::String(stem) + "_" + variant + "_lod" +
Ogre::StringConverter::toString(tier) + ".mesh";
}
@@ -1146,13 +1270,16 @@ void CharacterSlotSystem::buildCharacter(flecs::entity e)
const auto &slotSelections = rec->inlineSlotSelections;
const int outfitLevel = rec->inlineOutfitLevel;
/* The catalog only carries "adult" parts for now; age bands
* without their own parts (child/elder from the town population)
* fall back to the adult parts so every NPC gets a visual. */
Ogre::String catAge = age;
if (s_catalogLoaded && !s_bodyParts.contains(catAge) &&
s_bodyParts.contains("adult"))
catAge = "adult";
/* Resolve the requested band to a complete catalog band for this
* outfit level. Missing bands fall back atomically; never mix
* skeletons between age bands. */
const Ogre::String catAge =
resolveAgeBand(age, sex, outfitLevel);
if (AgeBands::normalize(age) != catAge)
Ogre::LogManager::getSingleton().logMessage(
"[CharacterSlotSystem] age fallback for '" + age +
"' -> '" + catAge + "' (" + sex + ", outfit " +
Ogre::StringConverter::toString(outfitLevel) + ")");
/* Make sure every catalog slot exists in the selection map. */
auto slots = getSlots(catAge, sex);

Some files were not shown because too many files have changed in this diff Show More