diff --git a/AGENTS.md b/AGENTS.md index 6af386a..dcaa16f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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} `, 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 +(`/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, `.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 diff --git a/CMakeLists.txt b/CMakeLists.txt index ebde4b9..b9005c9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/assets/blender/buildings/parts/CMakeLists.txt b/assets/blender/buildings/parts/CMakeLists.txt index 956733d..064f040 100644 --- a/assets/blender/buildings/parts/CMakeLists.txt +++ b/assets/blender/buildings/parts/CMakeLists.txt @@ -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} diff --git a/assets/blender/characters/AGE-GENERATION.json b/assets/blender/characters/AGE-GENERATION.json new file mode 100644 index 0000000..513cd94 --- /dev/null +++ b/assets/blender/characters/AGE-GENERATION.json @@ -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 +} \ No newline at end of file diff --git a/assets/blender/characters/AGES-PLAN.md b/assets/blender/characters/AGES-PLAN.md new file mode 100644 index 0000000..466ea35 --- /dev/null +++ b/assets/blender/characters/AGES-PLAN.md @@ -0,0 +1,1790 @@ +# Age Bands Plan — Baby, Child, Teenager, Elder (Adult Preserved) + +Status: **implemented (2026-10-31) — automation converter, seeds, pipeline +and runtime age bands are in place; the manual art detail pass remains.** +This document remains the design and hand-off reference. + +Companion document: [`CHARACTER_MODELING.md`](CHARACTER_MODELING.md) (the +current character-asset contract). This plan extends that contract with four +new age bands while keeping the existing `adult` pipeline behavior-compatible. + +Directory: `assets/blender/characters/` + +### Executive summary + +- **Automation-first:** there is no dedicated modeller. A Blender-Python + converter builds first-pass `baby`, `child`, `teenager` and `elder` assets + from the existing adult blends: rigs, body parts, face, hair, clothes, + shape keys, weights, materials and retargeted animations. +- The generated `.blend` files are **committed seeds**, not build-time + throwaway output. They are converted with complete parts and rigs, then + handed off for a manual detail/sculpt pass. The build never overwrites a + hand-edited seed. +- Add `baby`, `child`, `teenager` and `elder` as **five canonical age bands**; + `adult` keeps its existing files, names and runtime output. +- Give each new age/sex pair its own stem (`baby-male`, `child-female`, …), + armature/skeleton, output directory and LOD names. Adult keeps the legacy + `male`/`female` stem. +- Build new bodies on **age-scaled Mixamo skeletons**; retarget the existing + clips by scaling location tracks only. Elder starts bind-identical to adult. +- Harden the existing pipeline first: one armature per consolidated blend, + exact skeleton names, prefixed textures and a duplicate-resource audit. +- Integrate through an outfit-aware **age fallback chain**, so a partially + converted band renders a complete fallback instead of a nude/broken NPC. +- Land in risk order: elder → teenager → child → baby. Each band ships as a + coherent automatic seed plus a conversion-quality report that tells the + manual detail pass where to start. + +### What “automatic” means here + +The converter is expected to produce a **structurally complete, game-ready +base**, not final art: + +| Automatically produced | Left for the manual detail pass | +|------------------------|---------------------------------| +| Age-scaled skeleton with the same bone names/hierarchy | Fine proportion adjustments, rest-pose tweaks | +| Deformed body/face/hair/clothes meshes with preserved UVs, material slots and vertex groups | Anatomy, child/baby silhouettes, facial features, hairstyle shapes | +| Shape keys, weights, seams, normals, catalog props | Shape-key refinement, new expressions | +| Retargeted animation clips and NLA tracks | Custom walk/run/idle/crawl cycles, foot-plant polish | +| Textures/materials, LODs, prefab/catalog integration | Repainting, new garments, wrinkle/detail textures | +| Conversion report with per-object quality flags | Pixel-level art direction | + +A generated band must be visually coherent in-game and must not T-pose, float, +crumple, clip catastrophically or render nude by accident. It does **not** have +to be portfolio-quality; that is the hand-off. + +--- + +### Implementation status (2026-10-31) + +Implemented in the repository: + +- `age_profiles.py`, the `agegen/` package (`rig`, `deform`, `weights`, + `report`, `anim`), `generate_age_blends.py` and seed guards + (`AGE-GENERATION.json`, `--check`, `--only-unedited`, `--force`); +- eight generated seed sets (`baby|child|teenager|elder` × `male|female`), + complete with body/face/hair/clothes, shape keys, weights and retargeted + actions/NLA (`elder` is bind-identical to adult for v1); +- pipeline hardening: single body armature, exact skeleton names, helper + meshes removed from exports, stem-prefixed textures, LOD stem names and + `character-assets-check` validation; +- CMake age loops (`CHARACTER_BUILD_AGES`, `character-`, + `characters-`, `characters-all`) and the `characters/` layout; +- runtime age support: `roadlib/AgeBands.hpp`, outfit-aware + `CharacterSlotSystem::resolveAgeBand`, stem-based LOD impostors, capsule + metrics for population NPCs, age-aware hair physics, baby/child/teen/elder + population/schedule bands, resource-directory discovery and age + normalization in the registry/prefabs; +- headless acceptance: `demoTaxi --headless --test-ages` builds one + character per age/sex and verifies the master mesh, skeleton and + `idle`/`walking` clips; the startup catalog log confirms every band + resolves to itself (no fallback). + +Generated seed heights (actual catalog mesh bounds, male): +`baby` ≈ 0.75 m, `child` ≈ 1.21 m, `teenager` ≈ 1.74 m, +`adult`/`elder` ≈ 1.85 m. Baby/child feet sit a few centimetres below the +origin in the current seed; the manual detail pass should correct that. + +Not implemented yet (hand-off/documentation follow-ups): + +- a scripted baby crawl loop (`generate_baby_crawl.py`); babies are home-only + in v1, so the retargeted clips are not visible as exterior pedestrians; +- script-generated baby-specific clothes (the current baby wardrobe is the + converted adult wardrobe, not a diaper/onesie set); +- the optional Blender “Age Generation” panel (`age_generation_ui.py`) and + texture-variant generator (`generate_age_textures.py`); +- the manual art detail pass itself (faces, baby/child silhouettes, clothing + fit, textures, custom cycles); +- some pre-existing adult material texture references (`female-clothes2`, + `Untitled`) are still missing from the staged tree; the validator reports + them as warnings, not age-pipeline errors. + + + +### Goals + +1. Add first-class character assets for: + - `baby` (0–2 years) + - `child` (3–12 years) + - `teenager` (13–17 years) + - `elder` (65+ years) +2. Generate the first-pass `.blend` files **automatically from the adult + assets** — complete with rig, body parts, face, hair, clothes, shape keys, + weights and retargeted animations — so no dedicated modeller is required + for the initial integration. Commit the generated seeds and hand them off + for a manual detail pass. +3. Keep the existing `adult` characters (`male`, `female`) working exactly as + they do now: same source blends, same `.mesh` names, same catalog mesh + names, same LOD names, same prefabs and scenes. +4. Make the Blender → CMake → OGRE catalog pipeline fully age-aware: + base bodies, clothes, hair, shape keys, animations, LODs, textures, + skeletons and catalog JSON. +5. Integrate the bands into the runtime: + - `CharacterSlotSystem` chooses the correct age assets. + - `NpcPopulationSystem` can generate baby/child/teenager/elder NPCs. + - character capsules, hair physics and LODs follow the age. + - the editor exposes the bands without crashes when an asset set is + incomplete. +6. Keep the rollout safe: an age band whose assets are incomplete must fall + back to a complete band instead of rendering a nude or broken character. + +### Non-goals (for this plan) + +- **Final art quality.** The automatic converter produces a complete, + coherent, editable base; anatomy, baby/child faces, garment silhouettes and + custom animation polish are the hand-off. No dedicated modeller is assumed. +- Full gameplay AI per age (school, daycare, retirement homes, baby carrying). + Only the minimum schedule/spawn rules needed to see and use the assets are + in scope. +- New garment/asset design beyond converting the existing adult wardrobe and + adding a small number of simple, script-generated age-specific pieces + (diaper/onesie, teen/elder variants). The manual pass can add more. +- Facial animation/lip-sync content. Facial shape keys are preserved where the + source rig has them, but no new expression animation work is planned. +- Redesigning the furniture/building pipelines. Age-specific furniture + (cribs, high chairs) is explicitly out of scope. +- Changing the adult body proportions, textures or clothing designs. + +--- + +## 2. Current pipeline audit (what exists, what blocks us) + +### 2.1 Asset chain today + +Per sex (`male`, `female`): + +```text +edited-normal-.blend base body, face, hair, shape-key source + + copy_animations.py appends Mixamo actions + NLA tracks + + add_missing_shape_keys.py propagates Body_shapes keys to all parts + + process_clothes.py weight-transfers garments from body parts + + combine_clothes.py bakes layer-1 + layer-2 combinations + + transfer_shape_keys.py transfers body shape keys to garments + + consolidate.py appends everything into one blend + + generate_lods.py per-part _lod1 + charlod impostors + + export_models2.py blender2ogre export + catalog JSON +``` + +Outputs: `build/characters//` with one `.mesh` per catalog object, the +shared `.skeleton`, `.material` files, textures, LODs and +`body_part_.json` catalog entries. + +### 2.2 What is already age-aware + +| Area | Current support | +|------|-----------------| +| Catalog property | `age` is already written and read (`body_part_*.json`). Adult only in the current data. | +| Runtime catalog | `CharacterSlotSystem` groups entries by `age / sex / slot`; `getAges()` exists. | +| Registry | `CharacterRecord::age` and `ageYears` are serialized; prefabs store `characterSlots.age`. | +| Editor | The character editor lists ages from the catalog and can change the age. | +| Population | `TownPopulation` already emits `child`, `adult` and `elder`; `ageYears` carries the real age. | +| Garment metadata | Clothing objects already have `ref_age`; `garment_system_ui.py` exposes an Age dropdown. | +| Fallback | `CharacterSlotSystem::buildCharacter` falls back to `adult` when the requested age is absent. | + +### 2.3 Concrete blockers + +| # | Blocker | Evidence / consequence | +|---|---------|------------------------| +| B1 | **One skeleton per sex, and every mesh name is prefixed with the armature name** | `export_models2.py` uses `prefix = mapping.armature_name + "_"` and CMake passes `male`/`female`. All age bands would export `male_Face.mesh`, `male.skeleton`, etc.; even in separate directories those names collide in the shared `Characters` resource group. | +| B2 | **The CMake pipeline is sex-only** | `EDITED_BLENDS_LIST` is `male;female`; clothes, consolidate, LOD and export are all keyed off sex only. | +| B3 | **LOD names are sex-only** | `generate_lods.py` writes `charlod__*`, `charlod_-atlas.png`; `CharacterSlotSystem::impostorMeshName()` builds `charlod_...`. Elder and child would share/steal LOD resources. | +| B4 | **Only two character resource locations are registered** | `EditorApp::locateResources()` hard-codes `./characters/male` and `./characters/female`. New age directories would never be discovered. | +| B5 | **Texture names can collide across directories** | Materials reference bare image names (`male_face-atlas.material` → `texture face-atlas.png`), and the male/female outputs already share names such as `_18.png` / `_19.png`; male uses `face-atlas.png` while female uses numbered atlases. Adding more age directories multiplies the collision. | +| B6 | **Duplicate body armatures accumulate in consolidated blends** | A current `tmp-edited-male.blend` contains armatures `male`, `male.001`, `male.002`, `male.003`, `male.004`; `characters/male/` contains `male.005.skeleton` / `male.006.skeleton` instead of one `male.skeleton`. This inflates exports and makes armature lookup ambiguous. Age bands would multiply the problem 5×. | +| B7 | **`edited-infant-male.blend` is a placeholder** | It contains a single `Cube` (1281 verts) with a mirror and subsurf modifier — it is not a usable baby body. There is no reusable infant asset. | +| B8 | **Clothes age plumbing is partial** | `process_clothes.py` guesses `normal__.blend` and only special-cases adult. `combine_clothes.py` never checks `ref_age`; cross-age mixing is possible only if a file mixes ages, but nothing prevents that. `garment_system_ui.py` lists `teen` instead of `teenager` and does not list `elder` at all. | +| B9 | **Animations are adult-only and sex-keyed** | `copy_animations.py` copies actions into `male`/`female`. Scaled age rigs need the same clips with location tracks retargeted to their bone lengths. | +| B10 | **Exact age string checks exist in rules** | `TownSchedule` tests `age == "adult"` / `age == "child"`. A new `teenager` string would silently become non-working and take the elder outing branch. | +| B11 | **Capsule defaults are adult** | `CharacterComponent` defaults to `radius 0.3`, `height 1.8`; `NpcDetailSystem` sets `{}` for inline population NPCs. Babies/children would have adult-sized collision and hair-physics head/chest volumes. | +| B12 | **Runtime preloading loads every catalog mesh** | `CharacterSlotSystem::loadCatalog()` loads every mesh on discovery and `prewarmShaders()` loads every `_lod1` / `charlod_*` mesh. Ten age/sex sets would multiply startup cost. | + +--- + +## 3. Canonical design decisions + +### 3.1 Age vocabulary and thresholds + +Canonical catalog/registry strings: + +| Band | `ageYears` | Canonical `age` | Accepted aliases (normalized on load) | +|------|-----------|-----------------|----------------------------------------| +| Baby | 0–2 | `baby` | `infant` | +| Child | 3–12 | `child` | — | +| Teenager | 13–17 | `teenager` | `teen`, `adolescent` | +| Adult | 18–64 | `adult` | — | +| Elder | 65+ | `elder` | `senior`, `old` | + +Rules: + +- The canonical string in new `.blend` files, catalogs and prefabs is the + exact word in the table. `teenager` is chosen deliberately because the user + request uses it; `teen` remains an accepted alias for old data. +- Normalize on load (`CharacterRegistry::deserialize`, prefab reads, catalog + load) and write canonical values on save. +- `ageYears` remains authoritative for progression; `age` is the appearance + band. Add a pure helper `ageBandForYears()` and a UI button/checkbox to + derive `age` from `ageYears`. +- Population rules keep their existing `childMinYears` / `childMaxYears` + range (1–17). The band is derived from the drawn `ageYears`, so no extra RNG + draw is introduced and the deterministic population stream is preserved. + +### 3.2 Naming rules + +Use one stem per (age, sex) pair: + +```text +stem(age, sex) = sex if age == "adult" (legacy) + = age + "-" + sex otherwise (e.g. baby-male) +``` + +| Artifact | Adult (preserved) | New bands | +|----------|-------------------|-----------| +| Source base blend | `edited-normal-male.blend` | `edited-normal-baby-male.blend` | +| Clothes blend | `clothes-male-top.blend` | `clothes-baby-male-top.blend` | +| Armature object / skeleton resource | `male` | `baby-male` | +| Mesh prefix | `male_` | `baby-male_` | +| Build output dir | `build/characters/male/` | `build/characters/baby-male/` | +| LOD prefix | `charlod_male_...` | `charlod_baby-male_...` | +| LOD atlas | `charlod_male-atlas.png` | `charlod_baby-male-atlas.png` | +| LOD material | `charlod_male` | `charlod_baby-male` | +| Catalog JSON | `body_part_male_Face.json` | `body_part_baby-male_Face.json` | + +The **adult exception is mandatory**: adult keeps the old source names, +armature names, mesh prefixes, catalog mesh names, LOD names and CMake target +names. This avoids migrating prefabs/scenes that may contain explicit mesh +names and keeps the baseline renderer output stable. + +### 3.3 Skeleton strategy + +Each age/sex pair gets its **own skeleton resource**. This is required because +`CharacterSlotSystem::buildCharacter` shares one skeleton instance between all +slots of a character (`shareSkeletonInstanceWith`); parts with different bind +poses cannot be mixed. + +| Band | Skeleton | Rig creation | Animation source | +|------|----------|--------------|------------------| +| Adult | existing `male` / `female` | untouched | existing Mixamo clips | +| Elder | new `elder-male` / `elder-female` | copy the adult rig; **bind-identical** (no bone movement); aged geometry only | copy adult clips, no retarget | +| Teenager | new `teenager-male` / `-female` | scaled adult rig (~0.92 height, slim) | copy + retarget location tracks | +| Child | new `child-male` / `-female` | scaled adult rig (~0.65 height, larger head) | copy + retarget location tracks | +| Baby | new `baby-male` / `-female` | scaled adult rig (~0.38 height, large head/short limbs) | custom crawl/idle/sit clips + retargeted sit/swim clips | + +Why not reuse the adult skeleton for all bands? Because bone rest positions are +the bind pose. Shrinking a mesh while keeping adult joints bends the elbow at +the adult elbow position and produces stretched/detached limbs. A separate +skeleton per band is the only robust option. + +**Critical retarget rule:** when building a scaled rig, move bone heads/tails +to shorten segments but **do not change their normalized direction or roll**. +If rest orientations stay identical, Mixamo rotation F-curves transfer 1:1. +Location F-curves (Hips/root motion) must be scaled by the bone's rest-offset +ratio; otherwise children/babies slide or bounce with adult stride lengths. + +Animation trees stay shared and skeleton-independent: they reference clip +names only, so every age skeleton must expose the same names. There is no +per-age `animation_tree.json` duplication and no tree-authoring work per band. + +For an initial pipeline test, a uniform scale of the adult mesh + rig + all +animation location tracks is acceptable as a temporary placeholder. It is not +acceptable as final art for baby/child (proportions are wrong); it is a +reasonable first pass for teenager and a possible interim for elder. + +### 3.4 Age resolution and fallback + +Resolution happens **atomically** (never mix skeletons within one character): + +```text +resolveAgeBand(requestedAge, sex, outfitLevel): + normalize requestedAge + for each age in fallbackChain(requestedAge): + if catalog has age/sex and required slots are complete for outfitLevel: + return age + return "adult" (or fail) +``` + +Fallback chains (nearest first): + +| Requested | Chain | +|-----------|-------| +| baby | `baby → child → adult` | +| child | `child → baby → teenager → adult` | +| teenager | `teenager → child → adult` | +| adult | `adult → teenager → elder` | +| elder | `elder → adult` | + +Completeness tiers: + +- **Renderable**: `face`, `top`, `bottom`, `feet` each have at least one + layer-0 entry for the age/sex. +- **Dressable**: in addition, at least one layer-2 entry for `top`, `bottom` + and `feet`, and one layer-1 hair entry (or an explicit bald scalp). +- Outfit level 2 requires Dressable; outfit level 1 requires the layer-1 + entries; outfit level 0 requires Renderable. +- The resolved band is used for the master mesh, all slots, LOD impostor + naming, hair physics and capsule metrics. Hair that does not exist for the + resolved band is simply skipped (bald), never borrowed from another band's + skeleton. +- Log the resolved fallback once per (record, requested age) at most. The + editor shows a warning next to the age combo: “assets missing, using X”. + +This makes it safe to land one age at a time. If a band is incomplete (for +example the first child seed is generated but its clothes conversion is still +flagged), child NPCs render as a complete fallback band (same sex) instead of +nude; once the child catalog is Dressable they switch over. + +### 3.5 Resource layout and uniqueness + +New output layout (flat, one level below `characters/`): + +```text +characters/ + male/ # adult male (unchanged) + female/ # adult female (unchanged) + baby-male/ + baby-female/ + child-male/ + child-female/ + teenager-male/ + teenager-female/ + elder-male/ + elder-female/ +``` + +`EditorApp::locateResources()` will enumerate every subdirectory of the +`characters` root and register each one as a separate non-recursive +`FileSystem` location in the `Characters` group. This preserves the current +flat resource-name model (`MeshManager::load("male_Face.mesh", "Characters")`). + +Because all locations share one namespace, **every resource basename in the +tree must be unique**. New rules: + +- Mesh/skeleton names already become unique through the stem prefix. +- Materials are already prefixed by the exporter. +- **Textures must be prefixed too.** Export images as + `male-face-atlas.png`, `female-_18.png`, `baby-male-face-atlas.png`, etc., + and update material references automatically. This fixes the existing + cross-directory texture name collisions (for example the shared `_18.png` / + `_19.png` names and the male `face-atlas.png` vs. the female numbered + atlases). +- LOD atlas/material names follow the stem. +- Add a build-time audit that fails if two files with the same basename exist + under `characters/**` for `.mesh`, `.skeleton`, `.material`, `.png`, or + `body_part_*.json`. + +### 3.6 Consolidation hardening (prerequisite) + +Before adding ages, fix the duplicate-armature issue: + +1. `consolidate.py` must end with exactly **one body armature** per output + blend (plus each hair's own armature). +2. When appending a clothing/combined blend, reuse the existing body armature; + do not link a second copy. If a duplicate was linked, re-parent its meshes + and delete it. +3. `export_models2.py` must rename the original armature **data block** to + `_orig` before creating the baked armature, so the exported skeleton + is exactly `.skeleton` (not `.005.skeleton`). +4. Clean the per-stem output directory before LOD/export (it contains only + generated files): remove `*.mesh`, `*.skeleton`, `*.material`, `*.glb`, + `*.scene`, `body_part_*.json`, then let `generate_lods.py` write the atlas + and LOD blend before the export runs. +5. Validate that no `..skeleton` or duplicate-armature exports remain. + +This is a pure pipeline fix that benefits adult too. Adult catalog JSON should +remain identical; only generated skeleton/texture file names become canonical. + +### 3.7 Automation-first converter (no dedicated modeller) + +A new Blender-Python tool, `generate_age_blends.py` (with an `agegen/` +package for reusable math), converts the existing adult assets into seed +blends for each target age/sex pair. It is a **one-shot authoring tool**, not +part of the normal build: generated seeds are reviewed, committed, and then +edited by hand. The pipeline only builds the committed seeds. + +The converter has five jobs: + +1. **Rig conversion** — derive `-` from the same-sex adult armature + by moving joints and scaling bone segments, keeping bone names, hierarchy, + roll and rest orientation identical. +2. **Mesh conversion** — deform every body/face/hair/garment mesh with the + same skin-space transform that maps the adult rest pose to the age rest + pose. UVs, material slots, shape keys and vertex groups are preserved. +3. **Wardrobe conversion** — convert the raw layer-1/layer-2 garments and + hairstyles, not the already-combined catalog meshes; let the existing + `combine_clothes.py` regenerate age-specific combinations. +4. **Animation retargeting** — append the Mixamo action set to the age rig and + scale location/root-motion tracks by the corresponding rest-offset ratios; + rotations are untouched. Elder is a pure copy. +5. **Reporting** — write a per-object quality report (stretch, seam gap, + penetration, weight/shape-key sanity), mark auto-seed objects and list the + first things the manual pass should inspect. + +Guardrails against losing hand work: + +- Generated seeds live at the final paths + (`edited-normal--.blend`, `clothes---.blend`) and + are committed to the repository. +- Each generated blend carries `agegen_state = "auto-seed"` plus a profile + hash and report path. The manual pass sets `agegen_state = "hand-edited"` + (small Blender panel or one-line operator). +- `generate_age_blends.py --check` verifies seeds exist and are not older than + their source assets; the normal CMake build never invokes the generator. +- Regeneration only overwrites `auto-seed` files. Overwriting a + `hand-edited` file requires `--force` and is explicitly named in the + output. `--only-unedited` updates the rest. +- Even when `agegen_state` says `auto-seed`, `--write` verifies the file hash + against `AGE-GENERATION.json`; a file that changed since generation is + treated as hand-edited and skipped unless `--force` is given. This catches a + forgotten “Mark as hand-edited”. +- A seed manifest (`AGE-GENERATION.json`) records source blend hashes, + profile values, converter version, output file hashes, state and per-file + report path. +- All conversion is deterministic: no unseeded randomness, stable object + order, fixed profile tables. Re-running on unchanged inputs produces the + same geometry (ignoring Blender’s binary file metadata). + +This is the core of the no-modeller plan: the converter produces **all the +parts**, and the hand-off is a refinement task rather than a from-scratch +modeling task. The implementation path below is written to be executed +end-to-end by the coding agent; the only human-only phase is the art detail +pass (plus asset-license decisions). + +--- + +## 4. Age profiles (conversion targets) + +These are starting targets, ±5%, to be calibrated against child growth +references and the existing adult proportions. The converter uses them as +input; the validator measures the generated skeleton and mesh bounds against +them. + +| Band | Target height male / female | Overall height ratio | Hip height ratio | Head size ratio | Shoulder width ratio | Arm length ratio | Leg length ratio | Animations | +|------|----------------------------|----------------------|------------------|-----------------|----------------------|------------------|------------------|------------| +| Baby | 0.72 / 0.70 m | 0.39 / 0.44 | 0.28 | 0.85 (absolute) | 0.50 | 0.30 | 0.26 | custom crawl/idle/sit; retargeted sit/swim | +| Child | 1.20 / 1.18 m | 0.65 / 0.75 | 0.55 | 0.92 | 0.70 | 0.60 | 0.55 | retargeted adult set | +| Teenager | 1.70 / 1.55 m | 0.92 / 0.98 | 0.94 | 0.99 | 0.92 | 0.92 | 0.94 | retargeted adult set | +| Adult | 1.85 / 1.58 m | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | existing | +| Elder | 1.85 / 1.58 m (v1 seed) | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | copy of adult set | + +Notes: + +- “Ratio” means relative to the same-sex adult segment length, not a global + scale. Baby/child have relatively shorter limbs and larger heads than a + uniformly scaled adult; the rig profile must express that. +- Elder is bind-identical to adult for v1: no bone movement and adult + height; aging comes from geometry/texture work. A later detail pass may + scale the rig or add a stooped posture. +- Female baby/child can share the male body silhouette initially and diverge + through face/hair/clothes; keep the `sex` property correct from day one. +- `fat` and `neck-width-plus` shape keys should exist on every body part of + every band (the LOD thin/fat impostors and the character sheet depend on + them). Face bands should either carry the full `Fcl_*` set or a documented + reduced set; the catalog `shape_keys` array is the contract. + +--- + +## 5. Deliverables + +### 5.1 Source blends + +New base bodies (8 files), initially generated as **auto-seeds** by +`generate_age_blends.py` and then committed/edited: + +```text +edited-normal-baby-male.blend +edited-normal-baby-female.blend +edited-normal-child-male.blend +edited-normal-child-female.blend +edited-normal-teenager-male.blend +edited-normal-teenager-female.blend +edited-normal-elder-male.blend +edited-normal-elder-female.blend +``` + +Each base body contains, with the canonical object names: + +- one armature object named `-` (adult exception: `male`/`female`); +- `Face` (master slot, skinned to the full body armature); +- `BodyTop`, `BodyBottom`, `BodyFeet` (layer 0); +- `Body_shapes` (shape-key source; no catalog props); +- helper/reference meshes used by `ref_clothing`: + `Body`, `Tops`, `Bottoms`, `Shoes`, `HairBack` (same generic names as adult, + scoped to their own file); +- a layer-0 hair/scalp mesh named `Hair`; +- all existing adult layer-0 pre-combined variants converted as well + (`BodyTopRobe`, `BodyBottomPants`, `BodyFeetPants`, + `BodyFeetPantsShoes`, ...), so the new catalog matches the adult slot + variety instead of shipping only the naked base; +- the retargeted action set and NLA tracks (the build does not retarget + again); +- `agegen_state = "auto-seed"` on every converted catalog object (changed to + `"hand-edited"` by the manual pass). + +Every catalog mesh carries `age`, `sex`, `slot`, and optional `layer`, +`garments`, `clothing_tags`. + +Example catalog entry that the pipeline must produce for a new band: + +```json +{ + "age": "child", + "sex": "female", + "slot": "top", + "layer": 2, + "garments": ["shirt1"], + "tags": ["shirt1", "shirt"], + "shape_keys": ["neck-width-plus", "fat"], + "mesh": "child-female_BodyTop_shirt1.mesh" +} +``` + +The `mesh` name is generated by the exporter (`_.mesh`); +never hand-author the prefix in the source blend’s object name. + +New clothes files (4 slots × 8 age/sex pairs), also generated as auto-seeds +from the existing adult wardrobe: + +```text +clothes---top.blend +clothes---bottom.blend +clothes---feet.blend +clothes---hair.blend +``` + +Each garment object carries the existing clothing props plus `ref_age` and +`ref_sex` matching the age/sex, `ref_part` pointing at the canonical body +object name, `ref_clothing` pointing at the helper mesh used for weight +transfer, `ref_layer` 1 or 2, and a unique `garment_id`. + +The converter carries over the **entire existing adult wardrobe** per slot +(every layer-1 and layer-2 garment), so the first landing already has more +than the minimum below. Additional baby/child-specific pieces are simple +script-generated meshes intended for the manual pass to refine: + +Minimum content per age/sex for the first landing: + +| Slot | Layer 1 | Layer 2 | +|------|---------|---------| +| top | underwear/undershirt | 2 tops | +| bottom | underwear/diaper | 2 bottoms | +| feet | socks | 2 shoes | +| hair | 3 hairstyles | — (hair lives on layer 1) | + +Baby special case: `bottom` layer 1 should be a diaper, layer 2 a onesie or +shorts; `feet` can be barefoot/soft shoes; hair can be a small scalp cap + +two hairstyles. + +### 5.2 Generated outputs + +Per stem, `build/characters//` contains the same file types as today: +`.mesh`, one `.skeleton` (plus hair skeletons if a hair has its own +rig), `.material`, textures prefixed by `-`, `body_part_.json`, +LOD1 meshes, `charlod__{thin,fat}_lod{2,3}.mesh`, the atlas and the LOD +manifest. + +Authoring-side outputs per stem: + +```text +conversion-report--.md # human-readable hand-off/work queue +conversion-report--.json # per-object metrics/flags +``` + +`assets/blender/characters/AGE-GENERATION.json` is the repository-level +manifest: it aggregates every stem's source hashes, profile hash, state and +output file list. + +--- + +## 6. Pipeline implementation + +### 6.1 Blender scripts + +#### `age_profiles.py` (new, pure Python) + +Single source on the asset side for: + +- canonical age names and aliases; +- numeric age thresholds; +- per-age skeleton profile values: region length scales (`hipHeight`, + `torso`, `head`, `shoulderWidth`, `arm`, `leg`, `foot`, `hipWidth`), + radial scales, head bulge, target height; +- body/capsule metrics (`radius`, `height`, `headRadius`, `headOffsetY`) and + walking speed scale; +- the bone region map used by the rig/deform passes; +- garment fit offset and quality thresholds; +- expected animation names; +- naming helpers: + ```python + def stem(age, sex): return sex if age == "adult" else f"{age}-{sex}" + def armature_name(age, sex): return stem(age, sex) + def mesh_prefix(age, sex): return stem(age, sex) + "_" + ``` +- fallback chain used by validation. + +Imported by the other Blender scripts and by `validate_age_assets.py`. A +stable `profile_hash()` feeds `AGE-GENERATION.json` and the seed staleness +check. + +#### `generate_age_blends.py` (new, one-shot authoring driver) + +The main converter. It loads the adult sources, builds the age rig, converts +all meshes/wardrobes, retargets animations, writes the seed `.blend` files and +emits the quality report + `AGE-GENERATION.json`. + +Suggested CLI: + +```bash +blender -b -P assets/blender/characters/generate_age_blends.py -- \ + --age child --sex female \ + --source-dir assets/blender/characters \ + --out-dir assets/blender/characters \ + --report-only # or --write / --check / --force / --only-unedited + +# all bands/sexes +blender -b -P assets/blender/characters/generate_age_blends.py -- \ + --all --write --only-unedited +``` + +Modes: + +| Mode | Behavior | +|------|----------| +| `--report-only` | Convert in memory, print/write the quality report, do not save blends. | +| `--write` | Write seeds only for files whose state is `auto-seed` **and** whose on-disk hash still matches the manifest; skip hand-edited or changed files. | +| `--only-unedited` | `--write`, but skip every hand-edited/changed file and report it. | +| `--check` | Verify seeds exist, are parseable and not older than their source assets; never modify files. | +| `--force` | Explicitly overwrite hand-edited files; prints each path it destroys. | + +Outputs per age/sex: + +```text +edited-normal--.blend +clothes---{top,bottom,feet,hair}.blend +conversion-report--.md/json +# plus an updated entry in the repository-level AGE-GENERATION.json +``` + +The driver is deterministic and refuses to run if the adult source blends are +missing. It does not touch CMake build outputs or `build/`. It inserts its own +directory into `sys.path` and imports the `agegen` package; the package uses +only Blender Python + the standard library (no extra dependencies). + +#### `agegen/` package (new, reusable conversion math) + +Keep the math out of the driver so it is testable and reviewable: + +| Module | Responsibility | +|--------|----------------| +| `agegen/profiles.py` | Load/re-export `age_profiles.py` data; region map for every bone; body metrics. | +| `agegen/rig.py` | Duplicate the adult armature; compute new head/tail positions; keep roll/orientation; write rest-pose measurement data. | +| `agegen/deform.py` | Rest-pose delta matrices (`D_i`), weighted vertex/shape-key deformation, seam snap, radial scaling. | +| `agegen/weights.py` | Preserve/normalize weights; transfer weights for unskinned helpers/garments; influence checks. | +| `agegen/fit.py` | Garment push-out against the converted body (BVH), stretch/penetration metrics. | +| `agegen/report.py` | Per-object quality flags, JSON/Markdown report, `agegen_state` properties. | +| `agegen/anim.py` | Action copy, location-track scaling, foot-contact pass, NLA track setup. | + +#### `age_generation_ui.py` (new, small Blender panel) + +Optional but recommended hand-off helper. Adds an “Age Generation” panel: + +- shows `agegen_state`, profile hash and report path for the current blend; +- **Mark as hand-edited** (sets `agegen_state = "hand-edited"`); +- **Validate current blend** (runs the same checks as the CLI); +- **Open conversion report** (text). + +This makes it hard to accidentally overwrite a manual sculpt in a later +regeneration. + +#### `generate_age_textures.py` (new, optional) + +Produces simple deterministic texture variants from the adult atlases, reusing +the exact UV layout: + +- baby/child: gentle skin tone shift, reduced contrast; +- elder: desaturated/gray hair variants, subtle wrinkle/age-spot noise on the + face diffuse; +- all ages: prefixed image names per stem. + +These are placeholders for the manual repaint; the converter must also work +with “reuse adult textures unchanged” so the first seed is valid even if the +texture pass is skipped. + +#### `generate_baby_crawl.py` (new, placeholder animation) + +No Mixamo crawl clip exists in `assets/blender/mixamo/`. This script authors a +simple, deterministic 24-frame crawl loop directly on the baby rig: + +- hands/knees base pose with alternating arm/leg swings; +- forward root motion matched to the baby capsule/speed profile; +- named `walking` (crawl) and `running` (faster crawl), plus a resting `idle` + and a floor `sitting` pose; +- explicitly marked `agegen_quality = "placeholder"` so the manual animation + pass can replace it. + +If this clip is not ready, babies remain home-only (no exterior pedestrian +spawn) and the normal retargeted clips are used at home; the plan supports +both. + +#### `retarget_animations.py` (new, CLI wrapper around `agegen/anim.py`) + +Authoring-time only: runs on the generated age seed after +`copy_animations.py`. The normal CMake build must never invoke it (double +location scaling). + +1. Iterate all appended actions. +2. For every F-curve on `pose.bones[""].location`, multiply values by + the bone's rest-offset ratio computed from the adult reference rig and the + age rig. Pass the same-sex adult base as `--adult ` (or derive the + ratio from `age_profiles.py`); do not guess a global scale. + - Hips: use the hip-height ratio (this also scales root motion). + - Other bones with location keys: use their own rest-offset ratio. +3. Leave rotation/scale F-curves untouched (rest orientations are identical + by construction). +4. Keep custom actions that already exist in the age blend (same name wins), + so baby crawl clips are not overwritten by adult walk clips. +5. Ensure a `default` action exists (required by `export_models2.py`). +6. Create/replace NLA tracks exactly like `copy_animations.py`. +7. Validate the required animation-name set and fail the build if a required + clip is missing. + +Required clip names (every band must expose the names its animation trees +reference; the stock imported set is the baseline): + +```text +default, idle, walking, running, sitting, sitting-ground, sitting-vehicle, +swimming, treading_water, swimming-fast, jump, left_turn, right_turn +``` + +`driving` is an animation-tree state mapped to `sitting-vehicle`; keep the +underlying clip name present. Baby custom clips replace `idle`/`walking`/ +`running` semantics but must keep those action names so the shared animation +trees work unchanged. + +Optional polish: foot-plant correction (adjust Hips Z per clip so the lowest +foot vertex touches z=0 at contact frames). Do this only after visual review; +do not make it a hidden global offset. + +#### `copy_animations.py` (modify, backward-compatible) + +- Keep the current behavior as the default (adult). +- The seed generator calls it once to append the sex's Mixamo action set, then + `retarget_animations.py` scales the location tracks and builds NLA tracks. +- The normal age build must **not** call the retarget step again. It may call + `copy_animations.py`; it appends nothing when the actions already exist. +- Skip appending an action whose name already exists in the target blend + (already true) so custom age clips win. + +#### `process_clothes.py` (modify) + +- Replace `rig_name = str(sex)` with `armature_name(ref_age, ref_sex)`. +- Replace the `normal__.blend` guess with an explicit body-blend + argument from CMake (`--body `), keeping the old lookup as a + compatibility fallback. +- Validate `ref_age` against the body blend's `age` and fail loudly if the + garment's age does not match. +- Validate that `ref_part` exists in the body blend and that the resulting + rig is the expected `` armature. + +#### `combine_clothes.py` (modify) + +- Assert that all loaded body objects and all clothing objects have the same + `age` and `sex` (the CMake files are already per age/sex; this prevents a + silent mistake). +- Carry `age`/`sex` onto every combined object explicitly (in addition to the + existing copy-from-target behavior). +- Keep combined object names derived from the target body object; the exporter + adds the stem prefix, so no cross-age name collision. +- Do not combine a layer-2 garment with a layer-1 garment from another age. + +#### `consolidate.py` (modify) + +- Parent catalog meshes to `armatureName(age, sex)` instead of `sex`. + Accept the legacy `sex` name only for `age == "adult"`. +- After appending all sources, choose the single body armature (the one the + catalog meshes are parented/modded to), re-parent any stragglers, delete + duplicate body armatures, and keep hair own-armatures. +- Fail loudly if a catalog mesh has no armature, if parts reference different + armatures, or if more than one body armature remains. +- Keep the existing seam/normal fix passes; they operate per blend and remain + valid. + +#### `generate_lods.py` (modify) + +- CLI becomes `-- ` or + `-- ...`; use `stem` for every generated name. +- Replace `find_armature(sex)` with an exact `find_armature(stem)`. +- Names: + - `charlod__thin_lod2.mesh`, `charlod__fat_lod2.mesh` + - `charlod__thin_lod3.mesh`, `charlod__fat_lod3.mesh` + - `charlod_-atlas.png`, material `charlod_` + - manifest `charlod_.json` +- Keep the adult output exactly `charlod_male...` (stem `male`), preserving + runtime compatibility. +- Fix/verify the known atlas UV remap issue before generating ten atlases; + add a visual check to the acceptance criteria. + +#### `export_models2.py` (modify) + +- Accept an explicit `--mesh-prefix ` (default: armature name) so the + prefix is always `_`. +- Rename the original armature **data** block to `_orig` before creating + the baked armature, so the exported skeleton is exactly `.skeleton`. +- Prefix exported image datablocks/files with `-` and rewrite the + material texture references. The prefixing must be idempotent: strip an + existing exact `-` prefix before adding it, so a re-export cannot + produce `male-male-_18.png`. This fixes the current cross-directory texture + collision. +- Ensure the catalog's `mesh` field uses the unique prefixed name; the + `age`/`sex` fields come from the object properties as today. +- Keep hair own-skeleton export behavior; hair armature names must be unique + per age/sex (`-hair-*`). +- After export, verify: + - one body skeleton `.skeleton`; + - every catalog mesh references it; + - all required animations exist; + - no duplicate basenames in the output tree. + +#### `garment_system_ui.py` (modify) + +- Replace the hard-coded `AGE_ITEMS` (`adult/child/teen/baby`) with the five + canonical bands (`baby/child/teenager/adult/elder`). +- Update both validation sets (`valid_ages`) to the same list; they currently + reject `elder` and accept the legacy `teen` spelling. +- Keep the enum labels user-friendly; the stored value is the canonical + string. + +#### `validate_age_assets.py` (new) + +CMake-invoked validator, run on the consolidated blend and on the exported +directory. Suggested checks: + +| Scope | Check | +|-------|-------| +| Seed/manifest | `agegen_state` is `auto-seed`/`hand-edited`; `AGE-GENERATION.json` exists; profile hash matches; no stale seed unless explicitly waived. | +| Source/consolidated | One body armature per blend; all catalog parts share it; no duplicate armatures. | +| Rig | Armature name == stem; bone names match `mixamorig:` for deform bones; deform bone count matches the same-sex adult reference (male 139, female 149; or required names). | +| Rig retarget | For scaled bands, normalized rest bone axes match the adult reference (dot > 0.9999); no unintentional rotation. | +| Skin | ≤4 influences/vertex; no unweighted vertices; compact palette ≤64 bones. | +| Props | All catalog meshes have `age`/`sex`/`slot`; `age` is canonical; clothing `ref_age` matches. | +| Geometry | Feet at z≈0, root at origin, facing matches adult, height within ±5% of the age target; conversion report seam/penetration metrics within limits. | +| Shape keys | Required keys present and same names across parts; `Basis` excluded from catalog; key offsets non-zero where expected. | +| Catalog | Every age/sex has `face/top/bottom/feet`; Dressable bands have layer-2 and hair layer-1; no unknown age strings. | +| Animation | Required actions and NLA tracks present; location-track scale values match the profile (catches a double-retargeted seed). | +| Output | Unique basenames; exactly `.skeleton`; one LOD atlas per stem; LOD2 palette ≤64; LOD3 ≤400 tris; required animations present. | + +Add the validator as a build step after export and as a standalone CMake target +`character-assets-check` for CI. + +### 6.2 CMake (`assets/blender/characters/CMakeLists.txt`) + +Replace the two-sex loop with age/sex loops while preserving adult output names +and target names: + +```cmake +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; adult-only for quick iteration)") + +# stem(age, sex): adult -> sex, otherwise age-sex +function(character_stem AGE SEX OUT) + if(AGE STREQUAL "adult") + set(${OUT} ${SEX} PARENT_SCOPE) + else() + set(${OUT} "${AGE}-${SEX}" PARENT_SCOPE) + endif() +endfunction() + +foreach(AGE ${CHARACTER_BUILD_AGES}) + foreach(SEX ${CHARACTER_SEXES}) + character_stem(${AGE} ${SEX} STEM) + # base blend: adult legacy path, new ages age-sex path + # new age seeds already carry retargeted actions + NLA tracks; + # the build validates them and does NOT retarget again + # add_shape_key_propagation -> ...-shapes.blend + # clothes pipeline per slot -> ...-consolidated.blend + # generate_lods -> ...-lods.blend + # export_models2 -> characters// + endforeach() +endforeach() +``` + +Details: + +- Keep `edited-blends` as the umbrella target; add + `character-` convenience targets and per-age aliases + (`characters-baby`, etc.). +- Keep adult output paths (`edited-normal-male.blend`, + `characters/male/...`) exactly unchanged. +- New age build files: `edited-normal--.blend`, + `...-shapes.blend`, `...-consolidated.blend`, `...-lods.blend`, + `normal-.glb`. +- `process_clothes` calls receive the age's body blend explicitly. +- **Do not run `retarget_animations.py` in the normal build.** The converter + has already retargeted the actions and built NLA tracks in the seed; running + it again would scale location tracks twice. The age build may run + `copy_animations.py` for backward compatibility (it appends nothing when the + actions already exist), or skip it for non-adult seeds. Add a build-time + check that the seed contains the required NLA tracks. +- Add the output-directory clean step before LOD generation. +- Add `character-assets-check` after all exports. +- Add a CMake configure/build error when a requested age seed is missing, + naming `generate_age_blends.py` as the fix. +- Add optional `generate-age-seeds` and `check-age-seeds` targets that invoke + the Blender driver. They are **not** part of `ALL`; the normal build never + regenerates or mutates seeds. +- Consider a cache option `CHARACTER_BUILD_AGES` (default `all`; during + Phase 0 configure it as `adult` only) so local iteration can build only + `adult` or a single band; CI builds all. +- The VRM import source remains sex-based (one animation library per sex); + the seed generator uses it once; the build consumes the seeded actions. + +### 6.3 Build and staging + +- `build/characters//` is copied to the editScene runtime + `characters/` directory by the existing POST_BUILD `copy_directory`; + nothing new is needed for staging. +- `EditorApp::locateResources()` must enumerate all child directories: + ```cpp + for (const auto &entry : directory_iterator(charactersRoot)) + if (entry.is_directory()) + rgm.addResourceLocation(entry.path().string(), "FileSystem", + "Characters", false, true); + ``` + Keep the existing relative-root fallbacks and log each added location. +- Add a startup warning when a directory has no `body_part_*.json` (catches a + partially staged build). + +--- + +## 7. Runtime integration + +### 7.1 New pure age helper + +Add `src/features/editScene/roadlib/AgeBands.hpp/.cpp` (OGRE-free, compiled +into `RoadGeometryLib`, usable by both roadlib and systems): + +```cpp +namespace AgeBands { +enum class Band { Baby, Child, Teenager, Adult, Elder }; +const char *name(Band); +Band fromYears(int ageYears); // canonical 0-2/3-12/13-17/18-64/65+ +const char *normalize(const std::string &age); // aliases -> canonical +std::vector fallbackChain(const std::string &age); +bool isWorkingAge(int ageYears); // band == Adult (elders keep today's non-working policy) +} +``` + +When generating a population, derive the band from the same `ageYears` value +that `TownPopulation` already draws; if per-area `PopulationRules` override the +age range, keep the canonical band boundaries (2 / 12 / 17 / 64) unless the +rules explicitly contradict them, and document the precedence. Use it in +`TownPopulation`, `TownSchedule`, `CharacterSlotSystem`, +`NpcPopulationSystem`, `NpcDetailSystem`, `CharacterRegistry` and the editor. +Add unit coverage for thresholds/aliases/fallback chains. + +### 7.2 CharacterSlotSystem + +- Add a cached `resolveAgeBand(age, sex, outfitLevel)` implementing §3.4. + Invalidate when `loadCatalog()` reloads. +- `buildCharacter()` uses the resolved band for: + - `getSlots`, `resolveMesh`, `findCatalogEntry`; + - master mesh selection; + - `ensureHardwareSkinning`; + - LOD1 variant lookup (mesh names already carry the stem). +- Replace the current “if missing, use adult” block with the fallback helper + and a one-time log. +- Validate `SlotSelection::explicitMesh` against the **resolved** band's + catalog before returning it (today `resolveMesh()` returns it + unconditionally). An adult mesh selected on a child record would otherwise + bind a different skeleton; ignore it with a one-time warning and fall through + to the normal layer resolution. +- LOD impostor naming: + ```cpp + static Ogre::String impostorMeshName(rec, resolvedAge, tier) { + stem = (resolvedAge == "adult") ? rec->inlineSex + : resolvedAge + "-" + rec->inlineSex; + return "charlod_" + stem + "_" + variant + "_lod" + tier + ".mesh"; + } + ``` +- `getAges()` returns canonical order + (`baby, child, teenager, adult, elder`) rather than JSON map order. +- `prewarmShaders()`/`loadCatalog()`: stop preloading every age's meshes at + startup. Preload the bands actually used by the registry/scene, lazily + prewarm a band on its first character build, or add + `EDITSCENE_PREWARM_AGES` / `EDITSCENE_PREWARM=0`. Measure startup time and + memory; adult-only scenes must not pay for ten bands. +- Expose `resolveAgeBand` (or a `getResolvedAge`) for the editor and for + `--test-ages`. + +### 7.3 Hair physics and capsule metrics + +- `HairPhysicsSystem` uses the resolved age (`CharacterSlotSystem` helper) so + it finds the hair part that was actually built. +- Add age body metrics (`radius`, `height`, `headRadius`, `headOffsetY`) to + the age profile. Scale the existing adult defaults by the height ratio so + adult characters remain byte-identical in behavior: + ```text + scale = targetHeight / adultHeight(sex) + radius = 0.3 * scale + height = 1.8 * scale + total = height + 2*radius + ``` + Example for the male targets in §4 (female uses the same formula with the + female adult height): + + | Band | scale | radius | height | total | + |------|-------|--------|--------|-------| + | Baby | 0.39 | 0.117 | 0.700 | 0.934 | + | Child | 0.65 | 0.195 | 1.168 | 1.558 | + | Teenager | 0.92 | 0.276 | 1.654 | 2.206 | + | Adult | 1.00 | 0.300 | 1.800 | 2.400 | + | Elder | 1.00 | 0.300 | 1.800 | 2.400 | + + The runtime table is kept in sync with the same `age_profiles.py` values; + the validator fails the build if an exported skeleton/mesh height differs + from the table by more than 5%, so C++ and assets cannot drift silently. +- `NpcDetailSystem::spawnHighDetail` fills `CharacterComponent` with the + age metrics instead of `{}`. +- `CharacterSlotSystem::buildCharacter` (or a small sync helper) updates an + existing `CharacterComponent` when the record's resolved band changes; set + `dirty = true` to rebuild the Jolt capsule without re-dirtying the visual. +- `CharacterSpawnerSystem`/prefabs: no schema change; prefab-authored capsules + win. Add an editor warning if a prefab's capsule and the record's age + profile disagree by more than 20%. +- Player-controlled small bands: the TPS camera boom height/distance and + vehicle seat pins are adult-tuned. Scale them by the character's height + ratio (or clamp the boom) when the resolved band is baby/child/teenager so + the camera does not sit above the character's head; vehicles can keep using + `seatCharacterScale` for the seated pose. + +### 7.4 Population and schedules + +- `TownPopulation::generatePopulation` derives the band from the drawn + `ageYears` via `AgeBands::fromYears` (no new RNG draw, same stream): + - `1–2` → baby + - `3–12` → child + - `13–17` → teenager + - adult/elder as today +- `PopulationRules` defaults stay `childMinYears=1`, `childMaxYears=17`, + `adultMinYears=18`, `elderMinYears=65`; do not change the draw order. +- `TownSchedule` replaces exact string comparisons: + - working = `AgeBands::isWorkingAge(ageYears)` (band `adult` only; elders keep + today's non-working elder-out policy, teenagers are non-working for v1). + - child outing/elder outing/teen outing branches keyed by `ageYears`: + teenagers use `childOutHour` by default (no school system yet), babies get + **no outing** (home all day) so they never spawn as pedestrians. +- `NpcPopulationSystem::assignLookFromCatalog` uses the resolved band (with + the record’s `outfitLevel`, i.e. the same `resolveAgeBand` inputs that + `buildCharacter` will use) and, for hair, the resolved band's layer-1 list. + Using a different resolution here would store selections for a band that the + renderer does not use. +- `NpcDetailSystem::isSpawnEligible` already refuses indoor locations; a baby + home-only schedule therefore keeps babies out of the exterior scene until + interiors exist. This is the intended v1 behavior (see open questions). +- Walking speed: keep the symbolic/live layers in sync. Add a per-band speed + scale (baby 0 (not spawned), child 0.75, teenager 0.95, adult/elder 1.0) to + the same helper used by `NpcMotionSystem` and `NpcDetailSystem`; if this is + deferred, keep all bands at 1.4 m/s to avoid tier disagreement. +- `NpcTrafficSystem` already uses `ageYears >= 18` for drivers; no change. + Teenagers remain passengers/pedestrians until a work roll is added. + +### 7.5 Registry, editor and prefabs + +- Normalize `age` on load/deserialize/prefab read; keep unknown strings + untouched only if the catalog has them (future bands), otherwise adult. +- `CharacterRegistry::createChild`: `ageYears = 0` must set `age = "baby"`, + not `"adult"`. The function later copies the same-sex parent's `age` and slot + selections while inheriting appearance; force the newborn band **after** + that inheritance, and clear/remap parent slot selections that do not exist + in the baby catalog. Do not let a newborn inherit the parent's adult band. +- Editor: + - Age combo lists canonical bands in order. + - When `ageYears` changes, offer “Sync age category” (or auto-sync for + births); do not silently overwrite an explicitly chosen appearance band. + - On age change, remap slot selections by garment id/tag to the new age's + catalog. If a selected mesh does not exist in the new band, clear that + selection instead of leaving a foreign mesh name. + - Show the resolved fallback warning (requested vs used). + - Show the asset seed state (`auto-seed` / `hand-edited`) and the conversion + report path in the character/asset tooling, so it is obvious which bands + are still first-pass. +- Prefabs: no schema change. `characterSlots.age` stores the requested band; + runtime resolves. Add a migration test for `teen` → `teenager`. +- Save compatibility: old saves/registries with `child`/`elder` records need + no migration; they resolve to the new bands when available and to adult + while the assets are missing. Old `teen` records normalize to `teenager`. + Unknown future age strings resolve through the fallback chain and log once. +- `CharacterSlotSystem::getMeshesForLayer`/`getSlots` remain per exact band for + the editor; only rendering resolves fallbacks. + +### 7.6 Resource discovery and preloading + +- Update `EditorApp::locateResources()` as in §6.3. +- Update `CharacterSlotSystem::prewarmShaders` gating as in §7.2. +- Startup log: number of catalog entries per band/sex/slot, and any duplicate + resource names detected before OGRE resolves them. + +--- + +## 8. Automated conversion recipe and hand-off + +### 8.1 Driver sequence + +`generate_age_blends.py` runs the following per age/sex: + +1. Load the same-sex adult base blend. +2. Build the age rig (`agegen/rig.py`). +3. Compute the rest-pose skin matrices (`agegen/deform.py`). +4. Convert body parts, face, helpers and layer-0 variants — every mesh object + in the source blend, including non-catalog helper/reference meshes; helpers + are converted for weighting/fitting but are not exported. +5. Convert raw garments and hair from the clothes blends. +6. Transform all shape keys with the same skin matrices. +7. Copy/retarget animations and build NLA tracks. +8. Apply material/texture variants and catalog properties. +9. Save the seed blends + report + manifest. +10. Run the same structural validation as the build. + +Nothing in this sequence is random. The same input blends and profile must +produce the same geometry. + +### 8.2 Rig conversion + +For every bone `i`: + +- `F_adult(i)` is the adult bone rest frame (armature space, head position + + orientation; bone Y points head→tail). +- `F_age(i)` is the converted frame: same rotation/roll, new head position. +- `l_i = new_length / adult_length` (longitudinal scale). +- `r_i` is the profile's radial scale for that bone's region (limb thickness, + torso width/depth, head width). + +The rig is built in hierarchy order: + +1. Root/hips: scale the hip height by the profile's `hipHeight`. +2. Spine chain: scale each segment length by `torso`. +3. Neck/head: scale by `head`; the head bone may use an absolute length that + is larger relative to the body than the adult's. +4. Clavicles/shoulders: scale the lateral offset by `shoulderWidth`; arm + segments by `arm`. +5. Legs: thigh/calf by `leg`, foot by `foot`; pelvis width by `hipWidth`. +6. Fingers, eyes, jaw, bust, twist and other helpers inherit the region of + their nearest classified ancestor. +7. For connected bones, `head = parent tail`; never change roll or bone + direction. Elder skips all bone movement (bind-identical). + +Starting region profile (multiply the same-sex adult lengths; radial scales +apply to the bone-local X/Z thickness): + +| Region | Baby | Child | Teenager | Elder | +|--------|------|-------|----------|-------| +| Hip height | 0.28 | 0.55 | 0.94 | 1.00 | +| Torso (hip→neck) | 0.30 | 0.62 | 0.92 | 1.00 | +| Head length / width | 0.85 | 0.92 | 0.99 | 1.00 | +| Shoulder width | 0.50 | 0.70 | 0.92 | 1.00 | +| Arm (shoulder→wrist) | 0.30 | 0.60 | 0.92 | 1.00 | +| Leg (hip→ankle) | 0.26 | 0.55 | 0.94 | 1.00 | +| Foot length | 0.55 | 0.75 | 0.97 | 1.00 | +| Body radial (torso/hips) | 0.55 | 0.78 | 0.94 | 1.00 | +| Limb radial | 0.50 | 0.72 | 0.93 | 1.00 | + +These are conversion seeds, not medical proportions; tune them in +`age_profiles.py` using the validator and the visual gallery. + +Write the actual new head/tail positions and `l_i`/`r_i` values into +`AGE-GENERATION.json`; the report uses them to explain deformation. + +### 8.3 Rest-pose skin transform + +For each bone define the armature-space deformation matrix: + +```text +S_i = diag(r_i, l_i, r_i) # bone-local: X/Z radial, Y longitudinal +D_i = F_age(i) @ S_i @ inverse(F_adult(i)) +``` + +For each vertex with normalized skin weights `w_i`: + +```text +v_age = Σ_i w_i * (D_i @ v_adult) +``` + +Minimal implementation shape: + +```python +for v in mesh.vertices: + m = Matrix.Identity(4) + for g in v.groups: # <=4 influences + m += (g.weight / total) * D[g.group] + v.co = m @ v.co + +for key in mesh.shape_keys.key_blocks: + for i, p in enumerate(key.data): + p.co = vertex_matrix(i) @ p.co +``` + +This is the key to converting the whole wardrobe automatically: + +- It preserves vertex groups and bone names, so `process_clothes.py` and + `export_models2.py` continue to work unchanged. +- It scales both limb length and thickness; the result is a coherent + proportional base, not a uniformly scaled adult. +- Coincident boundary vertices with identical weights map to identical + positions, so neck/waist/wrist/ankle seams stay closed. Vertices with + different boundary weights are snapped to their group average and reported. +- Shape keys use the same transform: for each key block, transform its + absolute positions with the same `D_i` and weights. The Basis and every + delta move consistently, preserving the `fat`/`neck-width-plus` behavior. +- Vertices with no weights fall back to the nearest deform bone (distance to + the bone segment), then to the object's single bone if it has one. The + report lists every fallback. +- Meshes are processed in armature space; object transforms are handled + explicitly so parts with non-identity transforms do not shear. + +After deformation, recompute normals, run the existing seam/normal fixers and +validate: +- max edge stretch/compression per object; +- degenerate triangles (area below epsilon); +- unmatched boundary vertices across parts; +- unweighted vertices and >4 influences. + +### 8.4 Head, face and radial shaping + +The head is the most important age signal: + +- The `Head` bone length/radial scale controls the absolute head size. For + baby/child the profile deliberately makes the head larger relative to the + shortened body. +- Apply an optional **head bulge** around the head pivot (neck→head ramp) for + baby/child, so facial features are not merely a shrunk adult face. Keep the + neck seam continuous with a smooth weight ramp. +- Eyes, teeth, tongue, eyelashes, brows and other face submeshes are converted + by the same head/neck transforms. They remain separate material slots. +- Face shape keys are transformed with the same weights; the full `Fcl_*` + set is carried when present. If a reduced set is chosen, document it in the + catalog and validator. +- The adult face texture/UV layout is preserved, so the face renders + correctly even before the manual repaint. Expect the automatic baby/child + face to look like a scaled adult; that is the known hand-off item. + +### 8.5 Weights, garments and fitting + +1. Body/face/hair keep their adult weights because bone names are identical. + Normalize to ≤4 influences and check for unweighted verts. +2. Garments: + - If a garment already has weights, use `agegen/deform.py` directly. + - If it has no weights, run the existing Data Transfer from the adult body + **before** conversion, then deform. + - After conversion, the normal pipeline’s `process_clothes.py` transfers + weights from the converted age body, cleaning up seams. +3. Automatic fit pass (`agegen/fit.py`): + - Build a BVH of the converted body. + - For each garment vertex inside the body, push it out along the body + normal by `garmentOffset` (2–5 mm, profile-configurable). + - For loose/long garments (coats, robes, dresses), measure stretch and + penetration; flag instead of aggressively reshaping. + - Report the worst offenders; do not silently delete geometry. +4. Preserve `ref_part`, `ref_clothing`, `ref_layer`, `garment_id`, `tags`; + set `ref_age`/`ref_sex` to the target band. The existing + `combine_clothes.py` regenerates age-specific combinations. +5. Baby: also generate a simple diaper/onesie/soft-shoe set with basic + box/loft geometry, marked `agegen_quality = "placeholder"`. The manual + pass replaces them with real garments. +6. Elder: carry the adult wardrobe and recolor/loosen it via material and + simple scaling; mark long/formal garments for review. +7. Never convert the already-combined `*_combined` catalog meshes. Only the + raw layer-1/layer-2 source garments are converted; combinations must be + rebuilt for each age. +8. Save the garment seed files in the same shape as the adult source clothes + files: converted mesh objects + properties, with **no body armature + linked** (clear the temporary armature modifier/parent before saving; the + build's `process_clothes.py` reloads the age rig from the body blend and + re-parents/re-weights them). Hair with its own skeleton keeps its hair + armature in the file, exactly like the adult source. + +### 8.6 Hair + +- Hair with its own armature: build a scaled hair rig by the head region + scale, transform the hair mesh with the hair rig's rest-pose deltas, keep + the root bone on `mixamorig:Head`, and preserve `has_own_armature` / + `own_armature_name`. +- Hair parented to the body rig: use the body's head/neck deltas. +- Convert all existing adult hairstyles; mark styles whose silhouette or + physics chain is clearly adult (long coats, extreme spikes) for review. +- Hair stays layer 1, `hair` tag. The layer-0 bald/scalp mesh is converted as + well. +- Hair must remain acceptable as a rigid head-attached mesh at LOD2. + +### 8.7 Materials and textures + +- UVs, material slots and material assignments are preserved by the + deformation; no texture repack is required for the first seed. +- Reuse the adult atlases; optionally run `generate_age_textures.py` for + tint/age variants. +- Prefix image names per stem (idempotent) as in §3.5. +- The catalog’s `shape_keys` array, `layer`, `garments`, `tags` and `mesh` + fields are generated by the existing exporter from the converted objects. +- Keep material count identical to adult per part; do not split meshes just + to apply a tint. + +### 8.8 Animation retargeting + +- Copy the sex's Mixamo action set into the age blend (`copy_animations.py`). +- For every bone location F-curve, multiply by the bone's longitudinal ratio + `l_i`; rotations and scales stay untouched. +- Root motion (Hips translation) uses the hip-height ratio so walking/running + move at the scaled stride. The report records the applied ratios. +- Optional foot-contact pass: + - sample each locomotion clip; + - find the lowest foot/toe position at contact frames; + - offset the Hips Z track so the foot touches the ground; + - never apply a constant offset to rotations. +- Baby: + - use `generate_baby_crawl.py` for `idle`/`walking`/`running`/`sitting` + placeholders if available; + - otherwise keep the retargeted clips and keep babies home-only so the + adult walk cycle is not visible outdoors. +- Elder: copy clips unchanged; optionally add a slouched idle later. +- Ensure `default` exists and all required animation names are present; fail + the seed generation if not. +- NLA tracks are created exactly like `copy_animations.py`, so + `export_models2.py` bakes the same set for every age. + +### 8.9 Conversion quality report + +The report is the hand-off’s work queue. Per object record: + +| Metric | Pass / warn / fail | +|--------|--------------------| +| Edge stretch vs profile-expected local scale | residual ratio outside 0.7–1.4 warn, outside 0.5–2.0 fail (raw compression is expected for baby/child; compare against the per-bone scale) | +| Degenerate triangles | fail if area < epsilon | +| Boundary seam gap (coincident vertices across parts) | warn > 1 mm, fail > 1 cm | +| Garment/body penetration | warn > 1% verts inside, fail > 5% | +| Unweighted vertices / >4 influences | fail | +| Height / hip height / head size | fail if > 5% from profile | +| Material slots / UV layers | fail if changed | +| Required shape keys | fail if missing | +| Required animations | fail if missing | +| LOD2/LOD3 availability after build | warn if optional, fail if required by the band | + +Reported states: + +- `agegen_state`: `auto-seed`, `hand-edited`; +- `agegen_quality`: `ok`, `warn`, `placeholder`, `fail`; +- `agegen_notes`: short human-readable list of the top issues; +- `agegen_report`: path to the per-stem report. + +`--write` refuses to save a file whose objects are all `fail` unless +`--allow-fail`; warnings are allowed (the whole point is to get an editable +base). The Markdown report lists the top 10 objects to fix first, grouped by +severity and by body region. + +### 8.10 Manual detail pass (hand-off checklist) + +The user opens each generated seed and works in this order: + +1. **Rig/proportions** — verify shoulders, hips, elbows, knees, neck and head + size; edit bone rest positions only if the validator is rerun. Avoid + changing bone rest orientations after retargeting: the animation F-curves + are rotation-local and assume the original orientation. If orientations + must change, re-run the animation retarget step for that band. +2. **Seams** — neck, waist, wrists, ankles; the converter keeps them closed, + but manual sculpting can reopen them. +3. **Face** — baby/child facial proportions, eyes, mouth, ears; elder + wrinkles and skin; check eye/teeth submeshes. +4. **Body silhouette** — anatomy, baby/child roundness, elder posture. +5. **Clothes fit** — fix flagged garments first; add new baby/child/teen/ + elder pieces; re-run `process_clothes` via the normal build. +6. **Hair** — silhouettes, scalp fit, hair physics root placement. +7. **Textures** — repaint age-specific faces/clothes; keep UV/material slot + layout unless deliberately re-authoring. +8. **Animations** — custom idle/walk/run/crawl; check sitting and vehicle + seats; replace placeholder clips. +9. **Mark hand-edited** (`agegen_state`) and run `validate_age_assets.py`. +10. **Build** the normal character targets; do not run the generator again + unless intentionally seeding a new file. + +Rules for the manual pass: + +- Keep canonical object names (`Face`, `BodyTop`, `BodyBottom`, `BodyFeet`, + `Hair`, ...), custom properties and bone names/hierarchy. If bones are + added/removed, update `age_profiles.py`, the region map and the validator in + the same change. +- Keep the armature object/data named `` and the mesh data names + unprefixed in the source blend; the exporter adds the prefix. +- Do not rename/delete the shape-key vocabulary; extend it only with a + matching catalog/validator update. +- If a garment is replaced entirely, keep its `garment_id`/`ref_*` metadata + so combinations and look assignment keep working. +- If the rig's joint positions change significantly, the converted body, + clothes and shape keys must be re-fitted (or the seed regenerated and the + hand edits re-applied) — a pure rig edit will otherwise leave garments and + seams behind. Small proportion tweaks are fine; large changes are a + re-conversion event. +- After a hand edit, the conversion report is historical (metrics describe the + seed). `validate_age_assets.py` still checks structure, naming, weights, + height, animations and catalog completeness; use the report only to know + what was originally flagged. +- A hand-edited file must not be regenerated by a normal build. Only the + explicit `--force`/`--only-unedited` paths touch seeds. + +### 8.11 Expected automatic quality per band + +| Band | Expected seed quality | Main manual work | +|------|-----------------------|------------------| +| Elder | High — adult geometry/height, aged textures | Face wrinkles, hair, clothing style, posture | +| Teenager | Medium-high — scaled adult with slim silhouette | Face age, hands/feet proportions, clothes fit | +| Child | Medium — correct rig, child-ish scale, large head | Face/head sculpt, body silhouette, garment redesign | +| Baby | Medium-low — coherent tiny rig with large head, simple clothes | Face, anatomy, hands/feet, clothes, crawl/animations | + +The automation target is that every band is **usable in-game immediately** +after the seed build (no broken skeleton, no missing parts, no nude NPCs), not +that it passes close-up art review. + +--- + +## 9. Testing and acceptance + +### 9.0 Seed generation checks + +Before the normal build: + +- `generate_age_blends.py --check` passes for all 10 stems: seeds exist, + parse, `agegen_state` is valid, and source/profile hashes are recorded in + `AGE-GENERATION.json`. +- A clean `--report-only` run on all stems produces: + - zero `fail` objects (or an explicit `--allow-fail` waiver per object); + - seam gap ≤ 1 mm on neck/waist/wrist/ankle boundaries; + - no unweighted vertices and no >4-influence vertices; + - all catalog mesh counts, material slots, UV layers and shape-key names + equal to the adult source per part; + - every garment and hairstyle from the adult source present in the target + age/sex clothes files; + - required animation names present and NLA tracks built; + - height/hip/head metrics within ±5% of the profile. +- Two consecutive `--report-only` runs on unchanged inputs produce identical + geometry hashes (determinism). +- The hand-edit guard is tested: a synthetic `hand-edited` file is not + overwritten by `--write` or `--only-unedited`, and is overwritten by + `--force` with a printed warning. A file that changed on disk while still + marked `auto-seed` is also skipped (hash guard). +- A seed with a missing armature, part, garment or animation fails the check + rather than producing a partially usable band. +- Unit tests for `agegen` math on a synthetic 2–3 bone armature: verify the + `D_i` matrices, weighted rest-pose deformation, shape-key transformation, + seam snap and rotation preservation against hand-computed positions. These + tests can run headless in Blender and catch math regressions before any + character asset is regenerated. + +### 9.1 Asset build checks + +- `character-assets-check` passes for all 10 stems: + - one body armature, exact skeleton name, no `.NNN` exports; + - all catalog parts share the armature, ≤4 influences, no unweighted verts; + - height/facing/root checks; + - canonical ages, Dressable tiers, `ref_age` match; + - unique resource basenames across `characters/**`; + - LODs and animations present. +- Adult catalog JSON diff vs the pre-change build: only expected image/ + material name changes; mesh names, ages, slots, garments and shape-key + arrays unchanged. + +### 9.2 Runtime checks + +Add `demoTaxi --headless --test-ages` (or an editScene test target) that: + +1. Creates one record per canonical age × sex with outfit level 2. +2. Spawns each through the normal spawner path. +3. Asserts: + - master entity exists and its mesh prefix is the resolved stem; + - all slot entities exist and share the master skeleton; + - requested age resolves to itself (no fallback) when all assets are + present; + - capsule height/radius match the age profile; + - hair physics ragdoll is created for bands with hair; + - LOD1 switch and LOD2 impostor switch succeed (resources exist and share + the age skeleton); + - animation states `idle`/`walking`/`running` are found on the skeleton. +4. Re-runs with `outfitLevel 0/1` and with one age's catalog temporarily + hidden to verify fallback and no nudity. + +Update existing probes: + +- `road_geometry_overlap_test` population assertions currently require every + child’s `age == "child"`; replace with `AgeBands::fromYears(ageYears)` checks. +- `demoTaxi --test-detail`/`--test-motion`/`--test-taxi`/`--test-parking`/ + `--test-determinism`/`--test-week-rollover` must still pass. The only + expected population change is appearance band, not positions/ids. +- `--test-ages` must also run on the adult-only build (ages fall back) to + prove the safe rollout path. + +### 9.3 Visual checks + +- Screenshot gallery: each age/sex in a fixed pose set (idle, walking, sitting, + sitting-vehicle, swimming) at LOD0/LOD1/LOD2. +- Check joints: elbows/knees/neck, no collapsing, no floating hair, no adult + face texture on a child. +- Check clothes: no gaps at waist/ankle/neck, no body poking through, no + cross-age texture bleed. +- Check baby crawling/sitting and vehicle seat scale. +- Check adult face textures after the image-prefix fix (male and female must + each use their own prefixed atlases; the shared `_18.png` / `_19.png` + names must no longer resolve to one sex's texture for the other). + +### 9.4 Performance checks + +- Measure startup time, memory and first-spawn hitch with: + - adult only, + - all ages prewarmed, + - lazy per-band prewarm. +- Verify a ten-band catalog does not make adult-only scenes slower. Target: + no regression for adult-only scenes; acceptable first-spawn cost for a new + band (shader compile behind the loading cover). +- `demoTaxi --benchmark` before/after with the same town seed. + +--- + +## 10. Implementation plan (automation-first) + +Order is by risk: get the converter and multi-age plumbing correct on elder +(bind-identical, no retargeting), then teenager/child/baby. Every band ships +as a complete automatic seed plus a report; manual detail can start as soon as +the first seed is valid. + +Rough engineering effort (no dedicated modeller; user detail pass is +iterative and not estimated here): + +| Phase | Engineering | Output | +|-------|-------------|--------| +| 0 — prerequisites | 2–3 days | hardened pipeline, adult baseline | +| 1 — converter core | 1–1.5 weeks | rig/deform/report math + elder seed | +| 2 — all seed blends | 1–1.5 weeks | 8 base bodies, 32 clothes files, hair, animations, reports | +| 3 — runtime integration | ~1 week | age catalog, LODs, capsules, population, fallback | +| 4 — validation/hand-off | 3–5 days | `--test-ages`, validator, visual report, hand-off | +| 5 — user detail pass | iterative | final bodies/faces/clothes/animations | +| 6 — hardening/docs | 3–5 days | performance, docs, regression battery | + +Plan for ~5–7 engineering weeks before the hand-off; the manual detail pass +then runs per band and can overlap with later engineering. Eight new +base-body `.blend` files at roughly 10–20 MB each add ~80–160 MB of binary +assets before clothes/hair; decide whether Git LFS is required before +committing them. + +### Phase 0 — Pipeline prerequisites (no new art) + +Deliverables: + +- `age_profiles.py`, `validate_age_assets.py` skeleton; +- CMake converted to age/sex loops with `CHARACTER_BUILD_AGES=adult` only; +- consolidation hardening (single armature, exact skeleton name, clean output + dirs, duplicate detection); +- texture prefixing and resource-uniqueness audit; +- adult rebuild is behavior-compatible (catalog mesh entries unchanged; + expected texture/material name changes documented and visually checked). + +Exit criteria: adult-only build passes all existing character tests and the +new validator; `characters/male` has exactly `male.skeleton` + hair skeletons; +no duplicate basenames. + +### Phase 1 — Converter core and elder seed + +Deliverables: + +- `agegen/` package: profile loading, rig conversion, rest-pose deformation, + shape-key transform, weight checks, report, animation retarget; +- `generate_age_blends.py` with `--report-only` / `--write` / `--check` / + `--force` / `--only-unedited` and the hand-edit guard; +- first generated seed: `edited-normal-elder-{male,female}.blend` (bind + identical, no retargeting) plus converted elder clothes/hair; +- geometry determinism and seam/penetration metrics proven on the elder seed. + +Exit criteria: the elder seed converts all adult parts, has no `fail` report +items, and renders/animate with the elder skeleton. The converter is +deterministic and never overwrites a hand-edited file. + +### Phase 2 — Generate all seed blends + +Deliverables: + +- `edited-normal-baby|child|teenager|elder-{male,female}.blend`; +- `clothes---{top,bottom,feet,hair}.blend` with every adult garment + converted, plus simple script-generated baby pieces; +- shape keys, weights, materials, hair rigs, layer-0 variants and helpers; +- retargeted animation sets; baby crawl/idle/sit placeholder; +- `AGE-GENERATION.json`, per-stem conversion reports and quality flags. + +Exit criteria: all 10 stems exist and pass `--check`; each band is a complete +catalog set (Dressable) with all parts present; warnings are triaged in the +report, no `fail` blocks the integration build. + +### Phase 3 — Runtime integration + +Deliverables: + +- CMake age/sex loops, per-stem output dirs, LODs, staging; +- resource-directory discovery, exact resource names, texture prefixes; +- `AgeBands` helper, resolved-age fallback, LOD impostor names, capsule + metrics, hair physics age handling; +- population schedule changes for baby/child/teenager/elder; +- editor/prefab normalization and hand-edit status visibility. + +Exit criteria: `editSceneEditor` and the demo build with all stems staged; +adult-only scenes unchanged; `--test-ages` passes on a machine with all seeds. + +### Phase 4 — Validation and hand-off + +Deliverables: + +- full `character-assets-check` output for all stems; +- `demoTaxi --headless --test-ages`, visual gallery, benchmark; +- per-band Markdown report: what is good, what is flagged, the first 10 + objects to fix; +- hand-off instructions and the Blender “Age Generation” panel. + +Exit criteria: a user can open any seed, see its report/status, make changes, +mark it hand-edited, rebuild, and see the band in-game. + +### Phase 5 — User detail pass (outside the automation) + +The user works through the reports per band, refines geometry/faces/clothes/ +hair/textures/animations, sets `agegen_state = "hand-edited"` and rebuilds. +Engineering remains available for pipeline fixes discovered during this pass. + +### Phase 6 — Hardening and documentation + +Deliverables: + +- performance tuning (lazy prewarm, memory); +- visual regression gallery; +- `CHARACTER_MODELING.md` update: age bands, naming, retargeting, LOD prefixes, + resource uniqueness, validation, conversion/hand-off workflow; +- `assets/blender/characters/README`/AGENTS links, `editScene/AGENTS.md` age + notes; +- build docs for per-age targets and the seed generator. + +Exit criteria: full test battery passes; docs match the implementation. + +--- + +## 11. Risks and mitigations + +| Risk | Impact | Mitigation | +|------|--------|------------| +| Automatic baby/child silhouettes read as “scaled adults” | High | Accept as seed quality; large-head/head-bulge profile, radial scaling, `agegen_quality` flags, hand-off report; user detail pass owns anatomy. | +| Rest-pose deformation produces seam gaps or joint collapse | High | Weight-consistent transform, seam snap, max-gap metric, existing seam/normal fixers, per-object report; block `fail` seeds. | +| Shape keys drift or lose effect | High | Transform Basis and every key with the same matrices; compare key bounds/offsets to adult; validator checks names and non-zero deltas. | +| Garments clip or distort after conversion | High | BVH body push-out, fit report, flag long/loose items; manual pass prioritizes flagged garments. | +| Converter overwrites manual edits | High | `agegen_state` guard, `--only-unedited`, manifest hashes, `--force` only; seeds are committed and build never regenerates. | +| Converter and profile/runtime drift | Medium | One profile table, report measurements, validator enforces ±5% height and required metrics; converter version in manifest. | +| Retargeted clips look wrong (stride/foot slide) | High | Keep rest orientations identical; scale location tracks by rest ratio; foot-contact pass; visual review; custom cycles in the manual pass. | +| No Mixamo crawl clip | Medium | Script-generated crawl placeholder; otherwise babies stay home-only until the manual animation pass. | +| Resource name collisions (textures/skeletons) | High | Stem prefixes, image prefixing, duplicate-basename audit, clean output dirs. | +| Duplicate armatures in consolidated blends | High | Phase 0 consolidation hardening and validator. | +| Adult regression | High | Adult naming exception; catalog diff; full existing demo test battery; screenshot compare. | +| Build time grows ~5× | Medium | Per-age CMake targets; `CHARACTER_BUILD_AGES`; CI builds all, local builds one. | +| Startup memory/shader cost | Medium | Lazy per-band load, prewarm gating, benchmark. | +| Population determinism/tests break | Medium | Derive band from existing `ageYears` draw; no new RNG draws; update assertions to thresholds; rerun determinism probes. | +| Incomplete band shows nude NPCs | High | Dressable-tier resolution; fallback to a complete band; population smoke test. | +| Baby behavior/AI scope creep | Medium | v1 home-only schedule; asset validation independent of behavior. | +| LOD atlas UV issue (known) | Medium | Fix/verify before generating ten atlases; visual acceptance. | +| Asset licensing (VRM/VRoid bases) | Medium | Confirm license for derived bodies/clothes; document provenance; prefer in-house sculpts where required. | + +--- + +## 12. Open questions / decisions for the user + +Assumptions this plan makes explicit: + +- The automatic seeds are committed and then hand-edited in place; the normal + build never regenerates them. +- Adult textures/UVs may be reused unchanged for the first seed; the manual + pass repaints. +- Automatic baby/child faces/bodies are accepted as editable seeds, not final + art. +- The converter is allowed to add simple placeholder baby clothes and a crawl + placeholder animation as long as they are flagged. + +Questions: + +1. **Baby visibility**: should babies stay indoors (home-only schedules, this + plan’s v1) or appear outdoors with a caregiver and crawl animations? +2. **Teen work**: should 16–17-year-olds be eligible for jobs, or stay + non-working with child-style outings until a school system exists? +3. **Elder rig**: confirm bind-identical adult rig for v1 (simplest, no extra + clips) versus a separate slightly stooped rig (more art/animation work). +4. **Texture prefix migration**: this plan recommends prefixing textures for + adult too (`male-face-atlas.png`, `female-_18.png`), fixing the current + cross-directory texture name collisions. Confirm that changing generated + adult texture/material file names is acceptable (no source prefab + references them). +5. **Build policy**: should the default build include all five bands, or keep + `CHARACTER_BUILD_AGES=adult` by default with an explicit `characters-all` + target? +6. **Baby/child asset provenance**: can the new bodies be derived from the + existing VRoid/VRM bases, or must they be modeled from scratch for license + reasons? +7. **Target heights**: confirm the values in §4 or provide reference + measurements from the art direction. +8. **Hand-off state**: confirm the `agegen_state = auto-seed/hand-edited` + convention (the alternative is a separate `generated/` directory copied + manually into the source tree). +9. **Crawl placeholder**: is a script-generated crawl loop acceptable as the + first baby locomotion, or should babies remain home-only until you author + the animation? + +--- + +## 13. Command reference (after implementation) + +```bash +# Seed generation (authoring only — not part of the normal build) +blender -b -P assets/blender/characters/generate_age_blends.py -- \ + --age elder --sex male --report-only +blender -b -P assets/blender/characters/generate_age_blends.py -- \ + --age elder --sex male --write +blender -b -P assets/blender/characters/generate_age_blends.py -- \ + --all --write --only-unedited +blender -b -P assets/blender/characters/generate_age_blends.py -- \ + --all --check + +# Optional CMake helpers (not part of ALL) +cmake --build build-vscode --target generate-age-seeds +cmake --build build-vscode --target check-age-seeds + +# Adult-only regression build +cmake --build build-vscode --target edited-blends +cmake --build build-vscode --target editSceneEditor -j4 + +# One age/sex +cmake --build build-vscode --target character-elder-male +cmake --build build-vscode --target character-teenager-female + +# All ages +cmake --build build-vscode --target characters-all +cmake --build build-vscode --target character-assets-check + +# Runtime verification (demo-taxi build dir) +cd build-vscode/src/features/editScene/demos/demo-taxi +./demoTaxi --headless --test-ages +./demoTaxi --headless --test-detail +./demoTaxi --headless --test-motion +./demoTaxi --headless --test-determinism + +# Adult baseline render check (example) +./demoTaxi --headless demo_scene_exterior.json \ + --force-pos 3444 12 3425 0 --print-detail 6000 +``` + +### Recommended first implementation slice + +1. Phase 0 hardening + a minimal `agegen` (rig + rest-pose deform + report) on + `elder-male`. +2. Convert one garment and the elder hair, run the normal build and + `--test-ages` with adult/elder only. +3. Add `teenager-male`, then `child-male`, then `baby-male`; only then mirror + female and the remaining bands. +4. Once the converter is stable, generate all stems in one pass and hand off + the reports. + +This keeps the first deliverable small: a working converter and one complete +new age band end-to-end, before multiplying the asset count. diff --git a/assets/blender/characters/CHARACTER_MODELING.md b/assets/blender/characters/CHARACTER_MODELING.md index 2307759..028f63b 100644 --- a/assets/blender/characters/CHARACTER_MODELING.md +++ b/assets/blender/characters/CHARACTER_MODELING.md @@ -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} `). 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 +`/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-.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--.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 +`-` (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-.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---.blend` (adult: + `clothes--.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--consolidated.blend`. + `edited-normal--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_.json` catalog entry per mesh object** carrying the required custom properties (see §2). -Outputs land in `build-vscode/characters//` and are staged into +Outputs land in `build-vscode/characters//` 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 `-` 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--.blend` plus + `clothes---{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 `_lod1.mesh` variants — same geometry, shape keys removed, UVs remapped into one shared 2048x2048 - atlas (`charlod_-atlas.png`) with a single `charlod_` - material per sex. Hair slots are skipped (hidden/impostor-baked at + atlas (`charlod_-atlas.png`) with a single `charlod_` + 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__thin_lod2.mesh` / `charlod__fat_lod2.mesh` — the + `charlod__thin_lod2.mesh` / `charlod__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___lod3.mesh` — +- **LOD3 (70-100 m, till despawn)**: `charlod___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--lods.blend`), +`assets/blender/characters/generate_lods.py` runs per stem on the +consolidated blend (CMake step `edited-normal--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_.json`: atlas file + impostor mesh names) is written for +(`charlod_.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. diff --git a/assets/blender/characters/CMakeLists.txt b/assets/blender/characters/CMakeLists.txt index 37d3e6f..ce165b1 100644 --- a/assets/blender/characters/CMakeLists.txt +++ b/assets/blender/characters/CMakeLists.txt @@ -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_.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}) diff --git a/assets/blender/characters/age_profiles.py b/assets/blender/characters/age_profiles.py new file mode 100644 index 0000000..20d5a1c --- /dev/null +++ b/assets/blender/characters/age_profiles.py @@ -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] diff --git a/assets/blender/characters/agegen/__init__.py b/assets/blender/characters/agegen/__init__.py new file mode 100644 index 0000000..ab38877 --- /dev/null +++ b/assets/blender/characters/agegen/__init__.py @@ -0,0 +1,9 @@ +"""Age-seed conversion helpers (Blender 3.6).""" + +__all__ = [ + "profiles", + "rig", + "deform", + "report", + "anim", +] diff --git a/assets/blender/characters/agegen/anim.py b/assets/blender/characters/agegen/anim.py new file mode 100644 index 0000000..8a5fbda --- /dev/null +++ b/assets/blender/characters/agegen/anim.py @@ -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 diff --git a/assets/blender/characters/agegen/deform.py b/assets/blender/characters/agegen/deform.py new file mode 100644 index 0000000..ae3bac1 --- /dev/null +++ b/assets/blender/characters/agegen/deform.py @@ -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 diff --git a/assets/blender/characters/agegen/profiles.py b/assets/blender/characters/agegen/profiles.py new file mode 100644 index 0000000..bf173d9 --- /dev/null +++ b/assets/blender/characters/agegen/profiles.py @@ -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 diff --git a/assets/blender/characters/agegen/report.py b/assets/blender/characters/agegen/report.py new file mode 100644 index 0000000..c3ec621 --- /dev/null +++ b/assets/blender/characters/agegen/report.py @@ -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" diff --git a/assets/blender/characters/agegen/rig.py b/assets/blender/characters/agegen/rig.py new file mode 100644 index 0000000..728f5fa --- /dev/null +++ b/assets/blender/characters/agegen/rig.py @@ -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 + ``-`` (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 diff --git a/assets/blender/characters/agegen/weights.py b/assets/blender/characters/agegen/weights.py new file mode 100644 index 0000000..c8019c3 --- /dev/null +++ b/assets/blender/characters/agegen/weights.py @@ -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 diff --git a/assets/blender/characters/clothes-baby-female-bottom.blend b/assets/blender/characters/clothes-baby-female-bottom.blend new file mode 100644 index 0000000..5f0c4b3 --- /dev/null +++ b/assets/blender/characters/clothes-baby-female-bottom.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:25b6b8b5230a5e9d91e76b733ee96856ecea873a9bb118ca5905f8d21c6fe16f +size 13298884 diff --git a/assets/blender/characters/clothes-baby-female-feet.blend b/assets/blender/characters/clothes-baby-female-feet.blend new file mode 100644 index 0000000..7c36995 --- /dev/null +++ b/assets/blender/characters/clothes-baby-female-feet.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4477d51b38b4383849bc3871c2ce83056f01874b7f951e89f082c0a287b457d9 +size 9274988 diff --git a/assets/blender/characters/clothes-baby-female-hair.blend b/assets/blender/characters/clothes-baby-female-hair.blend new file mode 100644 index 0000000..9255c15 --- /dev/null +++ b/assets/blender/characters/clothes-baby-female-hair.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:77397a68bb1d1aef4d92a1a218390cc8ad1e453bf82fc7e3ba2f1d0d0adc6dc2 +size 4081284 diff --git a/assets/blender/characters/clothes-baby-female-top.blend b/assets/blender/characters/clothes-baby-female-top.blend new file mode 100644 index 0000000..6050205 --- /dev/null +++ b/assets/blender/characters/clothes-baby-female-top.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a47bc879db6791056b4646b4428d2017d531b4368b4c245898310e2997c717e7 +size 10706592 diff --git a/assets/blender/characters/clothes-baby-male-bottom.blend b/assets/blender/characters/clothes-baby-male-bottom.blend new file mode 100644 index 0000000..37dca32 --- /dev/null +++ b/assets/blender/characters/clothes-baby-male-bottom.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:648803be203afe6e1a01553c8f9373a1e136881eb8a0edd1bfe2f837e9bced1c +size 1504912 diff --git a/assets/blender/characters/clothes-baby-male-feet.blend b/assets/blender/characters/clothes-baby-male-feet.blend new file mode 100644 index 0000000..8279066 --- /dev/null +++ b/assets/blender/characters/clothes-baby-male-feet.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8d3eb2c8c9053c953895508f2de03b6e3abbd23ceb53ebb2da2025b0be3a0a97 +size 5164972 diff --git a/assets/blender/characters/clothes-baby-male-hair.blend b/assets/blender/characters/clothes-baby-male-hair.blend new file mode 100644 index 0000000..cd89794 --- /dev/null +++ b/assets/blender/characters/clothes-baby-male-hair.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3e74c20b81b9cb19b4d79a862a5793e7d06726ee9ea8de6d0daeb269b3d4bd94 +size 1290792 diff --git a/assets/blender/characters/clothes-baby-male-top.blend b/assets/blender/characters/clothes-baby-male-top.blend new file mode 100644 index 0000000..5d13773 --- /dev/null +++ b/assets/blender/characters/clothes-baby-male-top.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:124fd1ad4f22f9efc1c3061914a8a74ea1e328af76d064d9080dc171bf3e0ed3 +size 1295768 diff --git a/assets/blender/characters/clothes-child-female-bottom.blend b/assets/blender/characters/clothes-child-female-bottom.blend new file mode 100644 index 0000000..0fced06 --- /dev/null +++ b/assets/blender/characters/clothes-child-female-bottom.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:924f20a2edbb56e8e41376fcd8ad58e6c3533707fc8cf7750451ed35e811d853 +size 13313204 diff --git a/assets/blender/characters/clothes-child-female-feet.blend b/assets/blender/characters/clothes-child-female-feet.blend new file mode 100644 index 0000000..6453df5 --- /dev/null +++ b/assets/blender/characters/clothes-child-female-feet.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:900a21c055e9464030e0248941099acb6838dce72c9b4bef72df679427589167 +size 9275524 diff --git a/assets/blender/characters/clothes-child-female-hair.blend b/assets/blender/characters/clothes-child-female-hair.blend new file mode 100644 index 0000000..86e9aab --- /dev/null +++ b/assets/blender/characters/clothes-child-female-hair.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3f1766cdcf6102fafe030b99daa2ae5a78023bc9c2f6146d8a4924faa2a3cd57 +size 4081284 diff --git a/assets/blender/characters/clothes-child-female-top.blend b/assets/blender/characters/clothes-child-female-top.blend new file mode 100644 index 0000000..531d967 --- /dev/null +++ b/assets/blender/characters/clothes-child-female-top.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3950729a7079297aff7aa2d4be255813eaf69348dee4688a61c470a2493a6f05 +size 10706592 diff --git a/assets/blender/characters/clothes-child-male-bottom.blend b/assets/blender/characters/clothes-child-male-bottom.blend new file mode 100644 index 0000000..9b754d2 --- /dev/null +++ b/assets/blender/characters/clothes-child-male-bottom.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:631f0ae0796b29797716be04c0cc8940693a40f673a97d5f6fd219ea541302c3 +size 1504912 diff --git a/assets/blender/characters/clothes-child-male-feet.blend b/assets/blender/characters/clothes-child-male-feet.blend new file mode 100644 index 0000000..e692574 --- /dev/null +++ b/assets/blender/characters/clothes-child-male-feet.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2bed6d24379ec2b322884324d8faea6cd0b1ddc6f62f4bbc4f6396f7adff623b +size 5164972 diff --git a/assets/blender/characters/clothes-child-male-hair.blend b/assets/blender/characters/clothes-child-male-hair.blend new file mode 100644 index 0000000..cec3a43 --- /dev/null +++ b/assets/blender/characters/clothes-child-male-hair.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6e02f2bbf3f06365d6cde9acbe178e869b417dcb1b163db0dad9e2999395e6bc +size 1313453 diff --git a/assets/blender/characters/clothes-child-male-top.blend b/assets/blender/characters/clothes-child-male-top.blend new file mode 100644 index 0000000..c47cd46 --- /dev/null +++ b/assets/blender/characters/clothes-child-male-top.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8aef24cab77cb209af0393b5bf059e61d411d77236c27bbbd0b126bf63c9211c +size 1303008 diff --git a/assets/blender/characters/clothes-elder-female-bottom.blend b/assets/blender/characters/clothes-elder-female-bottom.blend new file mode 100644 index 0000000..784a769 --- /dev/null +++ b/assets/blender/characters/clothes-elder-female-bottom.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8b9c1be20d83b46ee33397283625e03b2e147c3e35eea5b9eb3b47696f4bea66 +size 13346124 diff --git a/assets/blender/characters/clothes-elder-female-feet.blend b/assets/blender/characters/clothes-elder-female-feet.blend new file mode 100644 index 0000000..f2c8679 --- /dev/null +++ b/assets/blender/characters/clothes-elder-female-feet.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:69c1e995860e5678560cfe76e1349828e49d230d7ee3b4167767bb6708325da1 +size 9275684 diff --git a/assets/blender/characters/clothes-elder-female-hair.blend b/assets/blender/characters/clothes-elder-female-hair.blend new file mode 100644 index 0000000..c0e8c43 --- /dev/null +++ b/assets/blender/characters/clothes-elder-female-hair.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fced301a4709adf9e8a05a17583b8879b0a9f154bd532ed567df9ce924f2859b +size 4166532 diff --git a/assets/blender/characters/clothes-elder-female-top.blend b/assets/blender/characters/clothes-elder-female-top.blend new file mode 100644 index 0000000..b9287b0 --- /dev/null +++ b/assets/blender/characters/clothes-elder-female-top.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f783777345dfeb83811adc349a4b4f7c21bd5f3085f13c2b218637298f184bad +size 10789552 diff --git a/assets/blender/characters/clothes-elder-male-bottom.blend b/assets/blender/characters/clothes-elder-male-bottom.blend new file mode 100644 index 0000000..1f7065e --- /dev/null +++ b/assets/blender/characters/clothes-elder-male-bottom.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1e5dcf7d41f8f3fe5c3716342a2f9d7163558492f8e4ecab4e37e7aaa0b6d6aa +size 1504912 diff --git a/assets/blender/characters/clothes-elder-male-feet.blend b/assets/blender/characters/clothes-elder-male-feet.blend new file mode 100644 index 0000000..282dfaa --- /dev/null +++ b/assets/blender/characters/clothes-elder-male-feet.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:60af548ee9845a29df7021f92378032d961c28108503f39f9a97640bf80f1024 +size 5164972 diff --git a/assets/blender/characters/clothes-elder-male-hair.blend b/assets/blender/characters/clothes-elder-male-hair.blend new file mode 100644 index 0000000..31bcde0 --- /dev/null +++ b/assets/blender/characters/clothes-elder-male-hair.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f62f93f4464b41d1b27ed89a39d660e3aa1cc6ee1abfbe52c9e5620ffd838616 +size 1259491 diff --git a/assets/blender/characters/clothes-elder-male-top.blend b/assets/blender/characters/clothes-elder-male-top.blend new file mode 100644 index 0000000..ca6b43a --- /dev/null +++ b/assets/blender/characters/clothes-elder-male-top.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a8c422ae1dbcad1f4672578f68c7577b5dff198009968f0689e036090d485e67 +size 1298912 diff --git a/assets/blender/characters/clothes-teenager-female-bottom.blend b/assets/blender/characters/clothes-teenager-female-bottom.blend new file mode 100644 index 0000000..147f386 --- /dev/null +++ b/assets/blender/characters/clothes-teenager-female-bottom.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:77ff890f2edaa894014c88bf1e1047335182d166bc39ac48746c1dac3d374f1a +size 13351948 diff --git a/assets/blender/characters/clothes-teenager-female-feet.blend b/assets/blender/characters/clothes-teenager-female-feet.blend new file mode 100644 index 0000000..7449a79 --- /dev/null +++ b/assets/blender/characters/clothes-teenager-female-feet.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b2fed95f17586630e331642f1afa33a6d63ba1ed58758be68a5526082d520809 +size 9275672 diff --git a/assets/blender/characters/clothes-teenager-female-hair.blend b/assets/blender/characters/clothes-teenager-female-hair.blend new file mode 100644 index 0000000..b8a4ca2 --- /dev/null +++ b/assets/blender/characters/clothes-teenager-female-hair.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0c5a762e74f95bd7b10679fa986133247125e46cc33f9a08dfeeed47f3f7719b +size 4139760 diff --git a/assets/blender/characters/clothes-teenager-female-top.blend b/assets/blender/characters/clothes-teenager-female-top.blend new file mode 100644 index 0000000..d43f37b --- /dev/null +++ b/assets/blender/characters/clothes-teenager-female-top.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ce617dbb60927fa81a530019bfd0b822743ac57b7dd96d894c5144a2803e3335 +size 10784156 diff --git a/assets/blender/characters/clothes-teenager-male-bottom.blend b/assets/blender/characters/clothes-teenager-male-bottom.blend new file mode 100644 index 0000000..9e3b498 --- /dev/null +++ b/assets/blender/characters/clothes-teenager-male-bottom.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9547a808429d71da27fdb11d58f69a2c18e6ad3f4fe78b5348dd25050133ad37 +size 1504920 diff --git a/assets/blender/characters/clothes-teenager-male-feet.blend b/assets/blender/characters/clothes-teenager-male-feet.blend new file mode 100644 index 0000000..f881d71 --- /dev/null +++ b/assets/blender/characters/clothes-teenager-male-feet.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:33d32f57c79f5a7b8e9c701c5e0aeffdd052a75b92ea7de234128c418e8be332 +size 5164976 diff --git a/assets/blender/characters/clothes-teenager-male-hair.blend b/assets/blender/characters/clothes-teenager-male-hair.blend new file mode 100644 index 0000000..1a9ddd6 --- /dev/null +++ b/assets/blender/characters/clothes-teenager-male-hair.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fc9c767afe4291c8a4634c2c1fc886c42d0136b905cc2288d3ca363a45957e99 +size 1279030 diff --git a/assets/blender/characters/clothes-teenager-male-top.blend b/assets/blender/characters/clothes-teenager-male-top.blend new file mode 100644 index 0000000..f0ff604 --- /dev/null +++ b/assets/blender/characters/clothes-teenager-male-top.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7b14973503c70681acbcaf66d7712a5e0ed9336875e4780d84ec749525624161 +size 1296788 diff --git a/assets/blender/characters/combine_clothes.py b/assets/blender/characters/combine_clothes.py index b8dcabc..95774b6 100644 --- a/assets/blender/characters/combine_clothes.py +++ b/assets/blender/characters/combine_clothes.py @@ -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"] diff --git a/assets/blender/characters/consolidate.py b/assets/blender/characters/consolidate.py index e230e3c..569f7d2 100644 --- a/assets/blender/characters/consolidate.py +++ b/assets/blender/characters/consolidate.py @@ -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() diff --git a/assets/blender/characters/conversion-report-baby-female.json b/assets/blender/characters/conversion-report-baby-female.json new file mode 100644 index 0000000..385e95f --- /dev/null +++ b/assets/blender/characters/conversion-report-baby-female.json @@ -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" + ] +} \ No newline at end of file diff --git a/assets/blender/characters/conversion-report-baby-female.md b/assets/blender/characters/conversion-report-baby-female.md new file mode 100644 index 0000000..1ba8db6 --- /dev/null +++ b/assets/blender/characters/conversion-report-baby-female.md @@ -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 diff --git a/assets/blender/characters/conversion-report-baby-male.json b/assets/blender/characters/conversion-report-baby-male.json new file mode 100644 index 0000000..036c255 --- /dev/null +++ b/assets/blender/characters/conversion-report-baby-male.json @@ -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" + ] +} \ No newline at end of file diff --git a/assets/blender/characters/conversion-report-baby-male.md b/assets/blender/characters/conversion-report-baby-male.md new file mode 100644 index 0000000..4e5f744 --- /dev/null +++ b/assets/blender/characters/conversion-report-baby-male.md @@ -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 diff --git a/assets/blender/characters/conversion-report-child-female.json b/assets/blender/characters/conversion-report-child-female.json new file mode 100644 index 0000000..9f54602 --- /dev/null +++ b/assets/blender/characters/conversion-report-child-female.json @@ -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" + ] +} \ No newline at end of file diff --git a/assets/blender/characters/conversion-report-child-female.md b/assets/blender/characters/conversion-report-child-female.md new file mode 100644 index 0000000..3473c07 --- /dev/null +++ b/assets/blender/characters/conversion-report-child-female.md @@ -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 diff --git a/assets/blender/characters/conversion-report-child-male.json b/assets/blender/characters/conversion-report-child-male.json new file mode 100644 index 0000000..42cd55f --- /dev/null +++ b/assets/blender/characters/conversion-report-child-male.json @@ -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" + ] +} \ No newline at end of file diff --git a/assets/blender/characters/conversion-report-child-male.md b/assets/blender/characters/conversion-report-child-male.md new file mode 100644 index 0000000..dd945a7 --- /dev/null +++ b/assets/blender/characters/conversion-report-child-male.md @@ -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 diff --git a/assets/blender/characters/conversion-report-elder-female.json b/assets/blender/characters/conversion-report-elder-female.json new file mode 100644 index 0000000..ba73919 --- /dev/null +++ b/assets/blender/characters/conversion-report-elder-female.json @@ -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" + ] +} \ No newline at end of file diff --git a/assets/blender/characters/conversion-report-elder-female.md b/assets/blender/characters/conversion-report-elder-female.md new file mode 100644 index 0000000..b34283a --- /dev/null +++ b/assets/blender/characters/conversion-report-elder-female.md @@ -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 diff --git a/assets/blender/characters/conversion-report-elder-male.json b/assets/blender/characters/conversion-report-elder-male.json new file mode 100644 index 0000000..de4f3db --- /dev/null +++ b/assets/blender/characters/conversion-report-elder-male.json @@ -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" + ] +} \ No newline at end of file diff --git a/assets/blender/characters/conversion-report-elder-male.md b/assets/blender/characters/conversion-report-elder-male.md new file mode 100644 index 0000000..74a167b --- /dev/null +++ b/assets/blender/characters/conversion-report-elder-male.md @@ -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 diff --git a/assets/blender/characters/conversion-report-teenager-female.json b/assets/blender/characters/conversion-report-teenager-female.json new file mode 100644 index 0000000..1c52c86 --- /dev/null +++ b/assets/blender/characters/conversion-report-teenager-female.json @@ -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" + ] +} \ No newline at end of file diff --git a/assets/blender/characters/conversion-report-teenager-female.md b/assets/blender/characters/conversion-report-teenager-female.md new file mode 100644 index 0000000..b74e995 --- /dev/null +++ b/assets/blender/characters/conversion-report-teenager-female.md @@ -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 diff --git a/assets/blender/characters/conversion-report-teenager-male.json b/assets/blender/characters/conversion-report-teenager-male.json new file mode 100644 index 0000000..b5b1552 --- /dev/null +++ b/assets/blender/characters/conversion-report-teenager-male.json @@ -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" + ] +} \ No newline at end of file diff --git a/assets/blender/characters/conversion-report-teenager-male.md b/assets/blender/characters/conversion-report-teenager-male.md new file mode 100644 index 0000000..3cd419c --- /dev/null +++ b/assets/blender/characters/conversion-report-teenager-male.md @@ -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 diff --git a/assets/blender/characters/edited-infant-male.blend b/assets/blender/characters/edited-infant-male.blend new file mode 100644 index 0000000..cc4eed3 --- /dev/null +++ b/assets/blender/characters/edited-infant-male.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0f87cc168bf0fa3ff37e46750266fc0fb68252e32a84052978a2dd9dfefdff37 +size 1086084 diff --git a/assets/blender/characters/edited-normal-baby-female.blend b/assets/blender/characters/edited-normal-baby-female.blend new file mode 100644 index 0000000..bf0a507 --- /dev/null +++ b/assets/blender/characters/edited-normal-baby-female.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0afd20804330a1179a7447c7d4f0bbeed90d5c669bfe82c771a1f6bc737e3dd7 +size 21254971 diff --git a/assets/blender/characters/edited-normal-baby-male.blend b/assets/blender/characters/edited-normal-baby-male.blend new file mode 100644 index 0000000..56270ed --- /dev/null +++ b/assets/blender/characters/edited-normal-baby-male.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:68537022ec4087743d636336b00bf33be3ed2028d0006e4b0ce2847350ba8737 +size 17580374 diff --git a/assets/blender/characters/edited-normal-child-female.blend b/assets/blender/characters/edited-normal-child-female.blend new file mode 100644 index 0000000..16b9276 --- /dev/null +++ b/assets/blender/characters/edited-normal-child-female.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f8f22f31a68b40088fefc1a037b4a9622aafad2b952d07f1ab85d45876bf9882 +size 21284707 diff --git a/assets/blender/characters/edited-normal-child-male.blend b/assets/blender/characters/edited-normal-child-male.blend new file mode 100644 index 0000000..2fd7dc3 --- /dev/null +++ b/assets/blender/characters/edited-normal-child-male.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6d889491af59a9551f4237cd50fba43bdb1b0aeffbf2e6cc0d5e572da9ea6c53 +size 17600714 diff --git a/assets/blender/characters/edited-normal-elder-female.blend b/assets/blender/characters/edited-normal-elder-female.blend new file mode 100644 index 0000000..b687cc7 --- /dev/null +++ b/assets/blender/characters/edited-normal-elder-female.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:570ff96ebfa9005a3c21755603b9a4942ab8336461c7af74bdd970ca1871e9c2 +size 21063105 diff --git a/assets/blender/characters/edited-normal-elder-male.blend b/assets/blender/characters/edited-normal-elder-male.blend new file mode 100644 index 0000000..708c3ad --- /dev/null +++ b/assets/blender/characters/edited-normal-elder-male.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:93554b09a4653357b4b91682e27bdda988421ded9eca60902a530537c18c4d5f +size 17393593 diff --git a/assets/blender/characters/edited-normal-teenager-female.blend b/assets/blender/characters/edited-normal-teenager-female.blend new file mode 100644 index 0000000..cee2458 --- /dev/null +++ b/assets/blender/characters/edited-normal-teenager-female.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1cfb42a0086b0704a1eb9f17ee027ae01ccf4be5664f126aa7d776398b8e3f91 +size 21214296 diff --git a/assets/blender/characters/edited-normal-teenager-male.blend b/assets/blender/characters/edited-normal-teenager-male.blend new file mode 100644 index 0000000..e40c0e9 --- /dev/null +++ b/assets/blender/characters/edited-normal-teenager-male.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d476061b1ee14df740bb052b82a2be5938246c05fcaf2df1dcded73ca1196daa +size 17572525 diff --git a/assets/blender/characters/generate_age_blends.py b/assets/blender/characters/generate_age_blends.py new file mode 100644 index 0000000..2c736b3 --- /dev/null +++ b/assets/blender/characters/generate_age_blends.py @@ -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() diff --git a/assets/blender/characters/generate_lods.py b/assets/blender/characters/generate_lods.py index f1667ee..05d33a2 100644 --- a/assets/blender/characters/generate_lods.py +++ b/assets/blender/characters/generate_lods.py @@ -2,18 +2,18 @@ # # Run in Blender batch mode with the consolidated blend already loaded: # -# blender -b -Y edited-normal--consolidated.blend \ -# -P generate_lods.py -- +# blender -b -Y \ +# -P generate_lods.py -- # -# Produces, per sex: -# * /charlod_-atlas.png - shared 2048x2048 LOD atlas -# * /charlod_.json - manifest +# Produces, per (age, sex) stem (adult keeps the legacy sex stem): +# * /charlod_-atlas.png - shared 2048x2048 LOD atlas +# * /charlod_.json - manifest # * LOD1 object variants `_lod1` - same geometry, no shape keys, -# UVs remapped into the atlas, one shared material `charlod_` +# UVs remapped into the atlas, one shared material `charlod_` # (hair slot is skipped - hair is hidden/impostor-baked at distance) -# * LOD2 impostors `charlod__{thin,fat}_lod2` - the deterministic +# * LOD2 impostors `charlod__{thin,fat}_lod2` - the deterministic # default outfit joined into one mesh skinned to the body armature -# * LOD3 impostors `charlod__{thin,fat}_lod3` - decimate of LOD2, +# * LOD3 impostors `charlod__{thin,fat}_lod3` - decimate of LOD2, # <= 400 triangles (hard limit) # * saves everything into # @@ -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 -P generate_lods.py" - " -- ") - return argv[0], argv[1], argv[2] + " -- ") + 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") diff --git a/assets/blender/characters/process_clothes.py b/assets/blender/characters/process_clothes.py index 1850882..bc3cecc 100644 --- a/assets/blender/characters/process_clothes.py +++ b/assets/blender/characters/process_clothes.py @@ -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 -- ") + print("Usage: blender -b -P script.py -- " + " [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): diff --git a/assets/blender/characters/validate_age_assets.py b/assets/blender/characters/validate_age_assets.py new file mode 100644 index 0000000..b1d59cd --- /dev/null +++ b/assets/blender/characters/validate_age_assets.py @@ -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()) diff --git a/assets/blender/scripts/export_models2.py b/assets/blender/scripts/export_models2.py index ba88ece..22c0e32 100644 --- a/assets/blender/scripts/export_models2.py +++ b/assets/blender/scripts/export_models2.py @@ -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 `.skeleton`, not `.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_-atlas.png` / + # `charlod_` 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') diff --git a/assets/blender/vehicles/CMakeLists.txt b/assets/blender/vehicles/CMakeLists.txt index 7f7092b..ac83190 100644 --- a/assets/blender/vehicles/CMakeLists.txt +++ b/assets/blender/vehicles/CMakeLists.txt @@ -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}} diff --git a/src/features/editScene/AGENTS.md b/src/features/editScene/AGENTS.md index 1e05849..f3de43f 100644 --- a/src/features/editScene/AGENTS.md +++ b/src/features/editScene/AGENTS.md @@ -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 +`-` (for example `child-male`, `elder-female`). Each band has its +own skeleton, mesh prefix, output directory under `characters//` and LOD +names (`charlod__...`); `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 / resolved=` +(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, 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:` (the plan cells in `interiorOnly` mode with the + standard rect names), one `arrival:` 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:` (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:` 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:`, target = + `arrival:`, 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"` / diff --git a/src/features/editScene/CMakeLists.txt b/src/features/editScene/CMakeLists.txt index 94a5db2..924b705 100644 --- a/src/features/editScene/CMakeLists.txt +++ b/src/features/editScene/CMakeLists.txt @@ -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 diff --git a/src/features/editScene/EditorApp.cpp b/src/features/editScene/EditorApp.cpp index bfcfee9..f4846f4 100644 --- a/src/features/editScene/EditorApp.cpp +++ b/src/features/editScene/EditorApp.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include "EditorApp.hpp" #include "GameMode.hpp" #include @@ -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(m_world); m_doorSystem->setEditorApp(this); + /* Phase 8.2: in-memory generated interior documents + the + * "generated:" scene-switch scheme. */ + m_generatedInteriorService = + std::make_unique(m_world, + m_sceneMgr); + // Standalone doors (F2, same DoorBuilder as CellGrid doors) m_standaloneDoorSystem = std::make_unique(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() && + !controller.has()) { + flecs::entity leftoverCharacter = getPlayerCharacterEntity(); + if (leftoverCharacter.is_alive() && + !leftoverCharacter.has() && + !leftoverCharacter.has()) + 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()) { + const auto &pt = playerChar.get(); + 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 ®istry = 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 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( diff --git a/src/features/editScene/EditorApp.hpp b/src/features/editScene/EditorApp.hpp index 59ca966..e2e7bb3 100644 --- a/src/features/editScene/EditorApp.hpp +++ b/src/features/editScene/EditorApp.hpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #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:")); 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 m_goapPlannerSystem; std::unique_ptr m_actuatorSystem; std::unique_ptr m_doorSystem; + std::unique_ptr m_generatedInteriorService; std::unique_ptr m_standaloneDoorSystem; std::unique_ptr m_eventHandlerSystem; std::unique_ptr 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(); diff --git a/src/features/editScene/components/CellGrid.hpp b/src/features/editScene/components/CellGrid.hpp index 55934a0..0de8839 100644 --- a/src/features/editScene/components/CellGrid.hpp +++ b/src/features/editScene/components/CellGrid.hpp @@ -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) diff --git a/src/features/editScene/components/Door.hpp b/src/features/editScene/components/Door.hpp index 0f521d1..187e176 100644 --- a/src/features/editScene/components/Door.hpp +++ b/src/features/editScene/components/Door.hpp @@ -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 diff --git a/src/features/editScene/demos/demo-taxi/BUGS.md b/src/features/editScene/demos/demo-taxi/BUGS.md index c829b7a..c450416 100644 --- a/src/features/editScene/demos/demo-taxi/BUGS.md +++ b/src/features/editScene/demos/demo-taxi/BUGS.md @@ -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. \ No newline at end of file diff --git a/src/features/editScene/demos/demo-taxi/PLAN.md b/src/features/editScene/demos/demo-taxi/PLAN.md index 4938dac..e18ed9f 100644 --- a/src/features/editScene/demos/demo-taxi/PLAN.md +++ b/src/features/editScene/demos/demo-taxi/PLAN.md @@ -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:` +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:"` + (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:` — 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:` 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:` 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:` 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:` 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 diff --git a/src/features/editScene/demos/demo-taxi/demo_main.cpp b/src/features/editScene/demos/demo-taxi/demo_main.cpp index 6275614..10ed855 100644 --- a/src/features/editScene/demos/demo-taxi/demo_main.cpp +++ b/src/features/editScene/demos/demo-taxi/demo_main.cpp @@ -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 ids; + std::vector entities; + std::vector 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 ® = 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( + 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 +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 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() ? + demoAttachedMesh( + b.get().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() ? + demoAttachedMesh( + b.get().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:" 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(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(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()) + return true; + const auto &pt = + player.get(); + 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()) { + fail("arrival marker not found after load"); + return true; + } + const auto &mt = marker.get(); + 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(); + 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()) + return true; + const auto &pt = + player.get(); + 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()) { + fail("arrival marker missing after reload"); + return true; + } + const auto &mt = marker.get(); + 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(); + 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()) + return true; + const auto &pt = + player.get(); + 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: " diff --git a/src/features/editScene/demos/demo-taxi/town_layouts.txt b/src/features/editScene/demos/demo-taxi/town_layouts.txt index a2a197c..351393c 100644 --- a/src/features/editScene/demos/demo-taxi/town_layouts.txt +++ b/src/features/editScene/demos/demo-taxi/town_layouts.txt @@ -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 diff --git a/src/features/editScene/lua/LuaComponentApi.cpp b/src/features/editScene/lua/LuaComponentApi.cpp index c562e3d..735c53b 100644 --- a/src/features/editScene/lua/LuaComponentApi.cpp +++ b/src/features/editScene/lua/LuaComponentApi.cpp @@ -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 = diff --git a/src/features/editScene/roadlib/AgeBands.hpp b/src/features/editScene/roadlib/AgeBands.hpp new file mode 100644 index 0000000..0aa1edf --- /dev/null +++ b/src/features/editScene/roadlib/AgeBands.hpp @@ -0,0 +1,179 @@ +#ifndef EDITSCENE_AGEBANDS_HPP +#define EDITSCENE_AGEBANDS_HPP +#pragma once + +#include +#include +#include +#include + +/** + * 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 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 canonicalOrder() +{ + return { "baby", "child", "teenager", "adult", "elder" }; +} + +} // namespace AgeBands + +#endif // EDITSCENE_AGEBANDS_HPP diff --git a/src/features/editScene/roadlib/InteriorScene.cpp b/src/features/editScene/roadlib/InteriorScene.cpp new file mode 100644 index 0000000..830dcb5 --- /dev/null +++ b/src/features/editScene/roadlib/InteriorScene.cpp @@ -0,0 +1,389 @@ +#include "InteriorScene.hpp" + +#include + +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 diff --git a/src/features/editScene/roadlib/InteriorScene.hpp b/src/features/editScene/roadlib/InteriorScene.hpp new file mode 100644 index 0000000..70c5b5c --- /dev/null +++ b/src/features/editScene/roadlib/InteriorScene.hpp @@ -0,0 +1,133 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include + +#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:" 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-
" (door ids + cache). */ + std::string gridUid; + std::string address; + /** Exterior scene the interior exit doors return to. */ + std::string sourceScenePath; + std::vector 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 m_lru; /* most recent at the front */ + std::unordered_map m_entries; + std::unordered_map::iterator> + m_pos; + size_t m_bytes = 0; + size_t m_limit; + + void touch(const std::string &key); + void dropOldest(); +}; + +} // namespace InteriorScene diff --git a/src/features/editScene/roadlib/TownPopulation.cpp b/src/features/editScene/roadlib/TownPopulation.cpp index 82afc41..6f8df08 100644 --- a/src/features/editScene/roadlib/TownPopulation.cpp +++ b/src/features/editScene/roadlib/TownPopulation.cpp @@ -1,5 +1,7 @@ #include "TownPopulation.hpp" +#include "AgeBands.hpp" + #include 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 &pool = diff --git a/src/features/editScene/roadlib/TownPopulation.hpp b/src/features/editScene/roadlib/TownPopulation.hpp index 6d7e94d..a655c5b 100644 --- a/src/features/editScene/roadlib/TownPopulation.hpp +++ b/src/features/editScene/roadlib/TownPopulation.hpp @@ -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; diff --git a/src/features/editScene/roadlib/TownSchedule.cpp b/src/features/editScene/roadlib/TownSchedule.cpp index 695d686..790a7f1 100644 --- a/src/features/editScene/roadlib/TownSchedule.cpp +++ b/src/features/editScene/roadlib/TownSchedule.cpp @@ -1,5 +1,7 @@ #include "TownSchedule.hpp" +#include "AgeBands.hpp" + #include #include #include @@ -51,7 +53,9 @@ std::vector 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 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 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 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 ltags = sortedTagKeys(ix.leisureTags); std::string leisureTag; diff --git a/src/features/editScene/systems/CellGridSystem.cpp b/src/features/editScene/systems/CellGridSystem.cpp index d144c09..5d5ccdf 100644 --- a/src/features/editScene/systems/CellGridSystem.cpp +++ b/src/features/editScene/systems/CellGridSystem.cpp @@ -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; diff --git a/src/features/editScene/systems/CharacterRegistry.cpp b/src/features/editScene/systems/CharacterRegistry.cpp index 66fec78..a19be03 100644 --- a/src/features/editScene/systems/CharacterRegistry.cpp +++ b/src/features/editScene/systems/CharacterRegistry.cpp @@ -9,6 +9,7 @@ #include "../components/EntityName.hpp" #include "../components/EditorMarker.hpp" #include "../components/RuntimeMarker.hpp" +#include "../roadlib/AgeBands.hpp" #include #include #include @@ -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()) { auto &t = inst.get_mut(); @@ -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") && diff --git a/src/features/editScene/systems/CharacterSlotSystem.cpp b/src/features/editScene/systems/CharacterSlotSystem.cpp index c2ff4a0..a81c59c 100644 --- a/src/features/editScene/systems/CharacterSlotSystem.cpp +++ b/src/features/editScene/systems/CharacterSlotSystem.cpp @@ -5,6 +5,7 @@ #include "../components/AnimationTree.hpp" #include "../components/CharacterIdentity.hpp" #include "../components/CharacterSlots.hpp" +#include "../roadlib/AgeBands.hpp" #include #include #include @@ -20,6 +21,7 @@ #include #include #include +#include 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 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 CharacterSlotSystem::getAges() { std::vector ages; if (!s_catalogLoaded) return ages; - for (auto &el : s_bodyParts.items()) - ages.push_back(el.key()); + std::set 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); diff --git a/src/features/editScene/systems/CharacterSlotSystem.hpp b/src/features/editScene/systems/CharacterSlotSystem.hpp index 9272324..0a5c6be 100644 --- a/src/features/editScene/systems/CharacterSlotSystem.hpp +++ b/src/features/editScene/systems/CharacterSlotSystem.hpp @@ -47,6 +47,14 @@ public: static void loadCatalog(); static bool isCatalogLoaded(); + /* Resolve an age band to a complete catalog band for the requested + * outfit level. Falls back through AgeBands::fallbackChain(); the + * resolved band is used for every slot, LOD impostor and hair lookup + * so skeletons are never mixed. */ + static Ogre::String resolveAgeBand(const Ogre::String &age, + const Ogre::String &sex, + int outfitLevel); + /* Generate + compile the RTSS hardware-skinning programs for every * catalog mesh up front (call at game start, behind the loading * cover) so the first NPC spawn does not hitch on GL shader diff --git a/src/features/editScene/systems/DoorBuilder.cpp b/src/features/editScene/systems/DoorBuilder.cpp index 0159ab5..2511e9d 100644 --- a/src/features/editScene/systems/DoorBuilder.cpp +++ b/src/features/editScene/systems/DoorBuilder.cpp @@ -288,6 +288,11 @@ flecs::entity DoorBuilder::build(flecs::world &world, door.centerOffset = colliderCenter; door.sceneSwitchPath = params.sceneSwitchPath; door.sceneSwitchTarget = params.sceneSwitchTarget; + door.sceneSwitchHasPose = params.sceneSwitchHasPose; + door.sceneSwitchPosX = params.sceneSwitchPosX; + door.sceneSwitchPosY = params.sceneSwitchPosY; + door.sceneSwitchPosZ = params.sceneSwitchPosZ; + door.sceneSwitchYawDeg = params.sceneSwitchYawDeg; door.persistent = params.persistent; door.lockable = params.lockable; door.keyItemId = params.keyItemId; diff --git a/src/features/editScene/systems/DoorBuilder.hpp b/src/features/editScene/systems/DoorBuilder.hpp index 0da32fa..3f53b3e 100644 --- a/src/features/editScene/systems/DoorBuilder.hpp +++ b/src/features/editScene/systems/DoorBuilder.hpp @@ -62,6 +62,12 @@ struct DoorBuildParams { std::string doorId; // "" = ephemeral (never persisted) std::string edgeKey; // CellGrid only (findDoorEntity) float friction = 0.5f; + /* Phase 8.2: pre-wired return pose for generated interiors. */ + bool sceneSwitchHasPose = false; + float sceneSwitchPosX = 0.0f; + float sceneSwitchPosY = 0.0f; + float sceneSwitchPosZ = 0.0f; + float sceneSwitchYawDeg = 0.0f; /* Leaf. */ std::string leafMeshName; // required diff --git a/src/features/editScene/systems/DoorSystem.cpp b/src/features/editScene/systems/DoorSystem.cpp index 7c3ddad..5d7821a 100644 --- a/src/features/editScene/systems/DoorSystem.cpp +++ b/src/features/editScene/systems/DoorSystem.cpp @@ -169,6 +169,21 @@ void DoorSystem::update(float deltaTime) SceneSwitchOptions opts; opts.targetEntityName = door.sceneSwitchTarget; + /* Phase 8.2: the pre-wired return pose + * (interior exit doors). */ + if (door.sceneSwitchHasPose) { + opts.hasPosition = true; + opts.posX = door.sceneSwitchPosX; + opts.posY = door.sceneSwitchPosY; + opts.posZ = door.sceneSwitchPosZ; + opts.hasRotation = true; + opts.rotation = + Ogre::Quaternion( + Ogre::Degree( + door.sceneSwitchYawDeg), + Ogre::Vector3:: + UNIT_Y); + } m_editorApp->switchScene( door.sceneSwitchPath, opts); } diff --git a/src/features/editScene/systems/GeneratedInteriorService.cpp b/src/features/editScene/systems/GeneratedInteriorService.cpp new file mode 100644 index 0000000..1856734 --- /dev/null +++ b/src/features/editScene/systems/GeneratedInteriorService.cpp @@ -0,0 +1,260 @@ +#include "GeneratedInteriorService.hpp" + +#include +#include + +#include +#include + +#include "../components/CellGrid.hpp" +#include "../components/ProceduralMaterial.hpp" +#include "../components/ProceduralTexture.hpp" +#include "../components/Transform.hpp" +#include "RenderOriginSystem.hpp" +#include "SceneSerializer.hpp" +#include "TownGenSystem.hpp" + +namespace { + +/* Door flag per CellFlags side, matching CellGridSystem's numbering: + * 0=-Z (DoorZNeg), 1=+Z (DoorZPos), 2=-X, 3=+X. */ +const uint64_t kDoorSide[4] = { CellFlags::DoorZNeg, CellFlags::DoorZPos, + CellFlags::DoorXNeg, CellFlags::DoorXPos }; + +/* Outward normal (plan space) per side. */ +void sideNormal(int side, float &nx, float &nz) +{ + switch (side) { + case 0: + nx = 0.0f; + nz = -1.0f; + break; + case 1: + nx = 0.0f; + nz = 1.0f; + break; + case 2: + nx = -1.0f; + nz = 0.0f; + break; + default: + nx = 1.0f; + nz = 0.0f; + break; + } +} + +} // namespace + +GeneratedInteriorService::GeneratedInteriorService( + flecs::world &world, Ogre::SceneManager *sceneMgr) + : m_world(world) + , m_sceneMgr(sceneMgr) +{ +} + +flecs::entity +GeneratedInteriorService::findBuilding(const std::string &gridUid) const +{ + flecs::entity found; + m_world.each([&](flecs::entity e, CellGridComponent &grid) { + if (!found.is_alive() && grid.gridUid == gridUid) + found = e; + }); + return found; +} + +bool GeneratedInteriorService::collectEntrances( + flecs::entity building, const LayoutLib::BuildingPlan &plan, + std::vector &out) const +{ + Ogre::Vector3 nodePos = Ogre::Vector3::ZERO; + Ogre::Quaternion nodeRot = Ogre::Quaternion::IDENTITY; + Ogre::Vector3 nodeScale = Ogre::Vector3::UNIT_SCALE; + if (building.has()) { + const auto &t = building.get(); + if (t.node) { + nodePos = t.node->_getDerivedPosition(); + nodeRot = t.node->_getDerivedOrientation(); + nodeScale = t.node->_getDerivedScale(); + } else { + nodePos = t.position; + nodeRot = t.rotation; + nodeScale = t.scale; + } + } + /* Render -> world (render-origin rebases must not leak into the + * stored return poses). */ + JPH::DVec3 worldPos(nodePos.x, nodePos.y, nodePos.z); + if (RenderOriginSystem *origin = RenderOriginSystem::getInstance()) + worldPos = origin->renderToWorld(nodePos); + + const Ogre::Vector3 forward = nodeRot * Ogre::Vector3::UNIT_Z; + const float buildingYawDeg = + Ogre::Math::ATan2(forward.x, forward.z).valueDegrees(); + + const float cs = plan.cellSize; + const float ch = plan.cellHeight; + out.clear(); + 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; + for (int side = 0; side < 4; ++side) { + if (!(flags & kDoorSide[side])) + continue; + InteriorScene::Entrance e; + e.floor = y; + e.cellX = x; + e.cellZ = z; + e.side = side; + float nx, nz; + sideNormal(side, nx, nz); + const Ogre::Vector3 local( + (float)x * cs + + nx * (cs * 0.5f + 1.2f), + (float)y * ch + 0.2f, + (float)z * cs + + nz * (cs * 0.5f + 1.2f)); + const Ogre::Vector3 scaled( + local.x * nodeScale.x, + local.y * nodeScale.y, + local.z * nodeScale.z); + const Ogre::Vector3 world = + nodePos + nodeRot * scaled; + /* The node position was render-space; shift + * the local offset onto the world anchor. */ + const Ogre::Vector3 anchor( + (float)worldPos.GetX(), + (float)worldPos.GetY(), + (float)worldPos.GetZ()); + e.approachX = + anchor.x + (world.x - nodePos.x); + e.approachY = + anchor.y + (world.y - nodePos.y); + e.approachZ = + anchor.z + (world.z - nodePos.z); + e.approachYawDeg = + buildingYawDeg + + InteriorScene::inwardYawDeg(side); + out.push_back(e); + } + } + return !out.empty(); +} + +void GeneratedInteriorService::injectTownMaterial( + flecs::entity building, nlohmann::json &document) const +{ + flecs::entity townE; + flecs::entity parent = building.parent(); + while (parent.is_valid() && parent.is_alive()) { + if (parent.has()) { + townE = parent; + break; + } + parent = parent.parent(); + } + if (!townE.is_alive()) + return; + const TownComponent &tc = townE.get(); + const flecs::entity matE = tc.proceduralMaterialEntity; + if (!matE.is_valid() || !matE.is_alive() || + !matE.has()) + return; + SceneSerializer serializer(m_world, m_sceneMgr); + nlohmann::json material = serializer.serializeProceduralMaterial(matE); + nlohmann::json texture; + const flecs::entity texE = + matE.get().diffuseTextureEntity; + if (texE.is_valid() && texE.is_alive() && + texE.has()) + texture = serializer.serializeProceduralTexture(texE); + if (!document.contains("entities") || + !document["entities"].is_array() || + document["entities"].empty()) + return; + document["entities"][0]["proceduralMaterial"] = material; + if (!texture.is_null()) + document["entities"][0]["proceduralTexture"] = texture; +} + +bool GeneratedInteriorService::resolveGeneratedInterior( + const std::string &path, const std::string &sourceScene, + nlohmann::json &documentOut) +{ + if (!isScheme(path)) { + Ogre::LogManager::getSingleton().logMessage( + "GeneratedInteriorService: not a generated scheme: " + + path); + return false; + } + const std::string gridUid = path.substr(10); + const LayoutLib::BuildingPlan *plan = + TownGenSystem::findBuildingPlan(gridUid); + if (!plan) { + Ogre::LogManager::getSingleton().logMessage( + "GeneratedInteriorService: no plan for building '" + + gridUid + "'"); + return false; + } + const flecs::entity building = findBuilding(gridUid); + if (!building.is_alive()) { + Ogre::LogManager::getSingleton().logMessage( + "GeneratedInteriorService: building entity '" + + gridUid + "' not in the scene"); + return false; + } + + const uint64_t hash = InteriorScene::planHash(*plan, gridUid); + /* Cache key folds in the source scene (the exit doors are + * pre-wired to it) and the building's world pose (the pre-wired + * exit poses are derived from it): a moved/rotated building + * rebuilds instead of serving stale exits. The world anchor is + * rebase-invariant. */ + Ogre::Vector3 bpos = Ogre::Vector3::ZERO; + Ogre::Quaternion brot = Ogre::Quaternion::IDENTITY; + if (building.has()) { + const auto &t = building.get(); + bpos = t.node ? t.node->_getDerivedPosition() : t.position; + brot = t.node ? t.node->_getDerivedOrientation() : t.rotation; + } + JPH::DVec3 bworld(bpos.x, bpos.y, bpos.z); + if (RenderOriginSystem *origin = RenderOriginSystem::getInstance()) + bworld = origin->renderToWorld(bpos); + const Ogre::Vector3 bfwd = brot * Ogre::Vector3::UNIT_Z; + const float byaw = + Ogre::Math::ATan2(bfwd.x, bfwd.z).valueDegrees(); + char poseKey[96]; + std::snprintf(poseKey, sizeof(poseKey), "|%.2f,%.2f,%.2f|%.2f", + bworld.GetX(), bworld.GetY(), bworld.GetZ(), + (double)byaw); + const std::string key = gridUid + "|" + sourceScene + "|" + + std::to_string(hash) + poseKey; + if (const nlohmann::json *cached = m_cache.find(key)) { + documentOut = *cached; + return true; + } + + InteriorScene::Input in; + in.plan = plan; + in.gridUid = gridUid; + in.address = gridUid.rfind("town-", 0) == 0 ? + gridUid.substr(5) : + gridUid; + in.sourceScenePath = sourceScene; + collectEntrances(building, *plan, in.entrances); + InteriorScene::Output out = InteriorScene::build(in); + if (out.document.is_null()) { + Ogre::LogManager::getSingleton().logMessage( + "GeneratedInteriorService: build failed for '" + + gridUid + "'"); + return false; + } + injectTownMaterial(building, out.document); + m_cache.put(key, out.document); + documentOut = std::move(out.document); + return true; +} diff --git a/src/features/editScene/systems/GeneratedInteriorService.hpp b/src/features/editScene/systems/GeneratedInteriorService.hpp new file mode 100644 index 0000000..ef0cba9 --- /dev/null +++ b/src/features/editScene/systems/GeneratedInteriorService.hpp @@ -0,0 +1,72 @@ +#pragma once + +#include +#include + +#include +#include + +#include "../roadlib/InteriorScene.hpp" + +namespace Ogre +{ +class SceneManager; +} + +/** + * GeneratedInteriorService — builds and caches the in-memory interior + * scene documents of enterable generated buildings and resolves the + * "generated:" scene-switch scheme (demo-taxi phase 8.2). + * + * The building's plan comes from TownGenSystem's runtime registry (the + * same BuildingPlan that built the exterior shell), its live transform + * supplies the world-space entrance approach poses, and the town's + * shared ProceduralMaterial/ProceduralTexture are injected into the + * grid entity so the interior renders with the town's atlas. The + * document cache is an LRU capped by total JSON size (nothing is + * written to disk). + */ +class GeneratedInteriorService { +public: + GeneratedInteriorService(flecs::world &world, + Ogre::SceneManager *sceneMgr); + + /** Resolve "generated:" to the interior scene document. + * sourceScene is the exterior scene the interior exit doors + * return to. Returns false (logged) when the scheme is malformed + * or the building/plan is unknown. */ + bool resolveGeneratedInterior(const std::string &path, + const std::string &sourceScene, + nlohmann::json &documentOut); + + InteriorScene::DocumentCache &cache() + { + return m_cache; + } + const InteriorScene::DocumentCache &cache() const + { + return m_cache; + } + + static std::string schemeFor(const std::string &gridUid) + { + return "generated:" + gridUid; + } + static bool isScheme(const std::string &path) + { + return path.rfind("generated:", 0) == 0; + } + +private: + flecs::world &m_world; + Ogre::SceneManager *m_sceneMgr; + InteriorScene::DocumentCache m_cache; + + flecs::entity findBuilding(const std::string &gridUid) const; + bool collectEntrances( + flecs::entity building, + const LayoutLib::BuildingPlan &plan, + std::vector &out) const; + void injectTownMaterial(flecs::entity building, + nlohmann::json &document) const; +}; diff --git a/src/features/editScene/systems/HairPhysicsSystem.cpp b/src/features/editScene/systems/HairPhysicsSystem.cpp index aaf40a9..f854c44 100644 --- a/src/features/editScene/systems/HairPhysicsSystem.cpp +++ b/src/features/editScene/systems/HairPhysicsSystem.cpp @@ -124,8 +124,12 @@ void HairPhysicsSystem::prePhysicsUpdate() if (!rec) return; + const Ogre::String resolvedAge = + CharacterSlotSystem::resolveAgeBand( + rec->age, rec->inlineSex, + rec->inlineOutfitLevel); auto slots = CharacterSlotSystem::getSlots( - rec->age, rec->inlineSex); + resolvedAge, rec->inlineSex); for (const auto &slot : slots) { Ogre::Entity *partEnt = m_slotSystem->getSlotEntity(e, slot); diff --git a/src/features/editScene/systems/NpcDetailSystem.cpp b/src/features/editScene/systems/NpcDetailSystem.cpp index f76e09d..1683355 100644 --- a/src/features/editScene/systems/NpcDetailSystem.cpp +++ b/src/features/editScene/systems/NpcDetailSystem.cpp @@ -28,6 +28,7 @@ #include "../components/CharacterSpawner.hpp" #include "../components/PathFollowing.hpp" #include "../components/RuntimeMarker.hpp" +#include "../roadlib/AgeBands.hpp" #include "../components/Transform.hpp" #include "../physics/physics.h" #include "../roadlib/TownLocations.hpp" @@ -273,8 +274,15 @@ bool NpcDetailSystem::spawnHighDetail(NpcDetail &det, uint64_t registryId) /* Inline-spawned population NPCs have no prefab, so add what a * prefab would carry: physics capsule, animation tree, path * following. */ - if (!ch.has()) - ch.set({}); + if (!ch.has()) { + CharacterComponent cc; + const AgeBands::BodyMetrics bm = + AgeBands::bodyMetrics(rec->age, rec->inlineSex); + cc.radius = bm.radius; + cc.height = bm.height; + cc.headRadius = bm.headRadius; + ch.set(cc); + } if (!ch.has()) { AnimationTreeComponent at; at.treeName = animationTreeName; diff --git a/src/features/editScene/systems/NpcPopulationSystem.cpp b/src/features/editScene/systems/NpcPopulationSystem.cpp index c2449a0..e724159 100644 --- a/src/features/editScene/systems/NpcPopulationSystem.cpp +++ b/src/features/editScene/systems/NpcPopulationSystem.cpp @@ -22,6 +22,7 @@ #include "../components/RoadGraph.hpp" #include "../components/RuntimeMarker.hpp" #include "../components/Transform.hpp" +#include "../roadlib/AgeBands.hpp" #include "../roadlib/RoadNetworkGen.hpp" /* Deterministic per-NPC look from the character-part catalog: a @@ -33,9 +34,8 @@ static void assignLookFromCatalog(CharacterRegistry::CharacterRecord *rec) if (!CharacterSlotSystem::isCatalogLoaded()) return; const nlohmann::json &cat = CharacterSlotSystem::getCatalog(); - Ogre::String catAge = rec->age; - if (!cat.contains(catAge) && cat.contains("adult")) - catAge = "adult"; + const Ogre::String catAge = CharacterSlotSystem::resolveAgeBand( + rec->age, rec->inlineSex, rec->inlineOutfitLevel); if (!cat.contains(catAge) || !cat[catAge].contains(rec->inlineSex)) return; auto keyIt = rec->stringColumns.find("npcKey"); diff --git a/src/features/editScene/systems/SceneSerializer.cpp b/src/features/editScene/systems/SceneSerializer.cpp index fe7b5df..071b86c 100644 --- a/src/features/editScene/systems/SceneSerializer.cpp +++ b/src/features/editScene/systems/SceneSerializer.cpp @@ -183,6 +183,17 @@ bool SceneSerializer::loadFromFile(const std::string &filepath, file >> scene; file.close(); + return loadFromJson(scene, uiSystem); + } catch (const std::exception &e) { + m_lastError = std::string("Load error: ") + e.what(); + return false; + } +} + +bool SceneSerializer::loadFromJson(const nlohmann::json &scene, + EditorUISystem *uiSystem) +{ + try { // Clear existing entities (optional - could be made configurable) // For now, we'll just add to the existing scene @@ -2892,6 +2903,16 @@ nlohmann::json SceneSerializer::serializeCellGrid(flecs::entity entity) cj["actionName"] = cfg.actionName; cj["sceneSwitchPath"] = cfg.sceneSwitchPath; cj["sceneSwitchTarget"] = cfg.sceneSwitchTarget; + /* Phase 8.1: pre-wired destination pose (generated interior + * exit doors). Written only when set so manual scenes stay + * compact. */ + if (cfg.hasSceneSwitchPose) { + cj["sceneSwitchHasPose"] = true; + cj["sceneSwitchPos"] = { cfg.sceneSwitchPosX, + cfg.sceneSwitchPosY, + cfg.sceneSwitchPosZ }; + cj["sceneSwitchYaw"] = cfg.sceneSwitchYawDeg; + } cj["disabled"] = cfg.disabled; cj["persistent"] = cfg.persistent; cj["lockable"] = cfg.lockable; @@ -3186,6 +3207,20 @@ void SceneSerializer::deserializeCellGrid(flecs::entity entity, cfg.actionName = cj.value("actionName", ""); cfg.sceneSwitchPath = cj.value("sceneSwitchPath", ""); cfg.sceneSwitchTarget = cj.value("sceneSwitchTarget", ""); + cfg.hasSceneSwitchPose = + cj.value("sceneSwitchHasPose", false); + if (cfg.hasSceneSwitchPose && + cj.contains("sceneSwitchPos") && + cj["sceneSwitchPos"].is_array() && + cj["sceneSwitchPos"].size() >= 3) { + cfg.sceneSwitchPosX = + cj["sceneSwitchPos"][0]; + cfg.sceneSwitchPosY = + cj["sceneSwitchPos"][1]; + cfg.sceneSwitchPosZ = + cj["sceneSwitchPos"][2]; + } + cfg.sceneSwitchYawDeg = cj.value("sceneSwitchYaw", 0.0f); cfg.disabled = cj.value("disabled", false); cfg.persistent = cj.value("persistent", false); cfg.lockable = cj.value("lockable", false); diff --git a/src/features/editScene/systems/SceneSerializer.hpp b/src/features/editScene/systems/SceneSerializer.hpp index 709aa47..bc56ad4 100644 --- a/src/features/editScene/systems/SceneSerializer.hpp +++ b/src/features/editScene/systems/SceneSerializer.hpp @@ -30,6 +30,14 @@ public: bool loadFromFile(const std::string &filepath, EditorUISystem *uiSystem = nullptr); + /** + * Load a scene from an in-memory JSON document (phase 8.2): the + * generated-interior documents are never written to disk. Same + * semantics as loadFromFile without the file read. + */ + bool loadFromJson(const nlohmann::json &scene, + EditorUISystem *uiSystem = nullptr); + /** * Editor world bookmarks (world-space camera positions), saved * in the scene JSON as a top-level "bookmarks" array. @@ -129,8 +137,6 @@ private: nlohmann::json serializeLod(flecs::entity entity); nlohmann::json serializeStaticGeometry(flecs::entity entity); nlohmann::json serializeStaticGeometryMember(flecs::entity entity); - nlohmann::json serializeProceduralTexture(flecs::entity entity); - nlohmann::json serializeProceduralMaterial(flecs::entity entity); nlohmann::json serializePrimitive(flecs::entity entity); nlohmann::json serializeTriangleBuffer(flecs::entity entity); nlohmann::json serializeCharacter(flecs::entity entity); @@ -353,6 +359,12 @@ private: */ nlohmann::json serializeEntity(flecs::entity entity); + /** ProceduralTexture/ProceduralMaterial component JSON (phase 8.2: + * the generated-interior service copies the town's shared + * material/texture into an in-memory scene document). */ + nlohmann::json serializeProceduralTexture(flecs::entity entity); + nlohmann::json serializeProceduralMaterial(flecs::entity entity); + private: flecs::world &m_world; diff --git a/src/features/editScene/systems/TerrainTests.cpp b/src/features/editScene/systems/TerrainTests.cpp index 4185a42..19e5637 100644 --- a/src/features/editScene/systems/TerrainTests.cpp +++ b/src/features/editScene/systems/TerrainTests.cpp @@ -43,6 +43,7 @@ #include "../physics/physics.h" #include "../roadlib/RoadGeometryLib.hpp" #include "../roadlib/RoadPath.hpp" +#include "../roadlib/InteriorScene.hpp" #include #include @@ -7903,6 +7904,139 @@ bool TerrainTestRunner::testCellGridStairs(EditorApp &app, TerrainSystem *ts) return true; } +/* ------------------------------------------------------------------ */ +/* testInteriorSceneLoad (phase 8.2: in-memory interior documents) */ +/* ------------------------------------------------------------------ */ + +bool TerrainTestRunner::testInteriorSceneLoad(EditorApp &app, + TerrainSystem *ts) +{ + (void)ts; + CellGridSystem *cellSys = app.getCellGridSystem(); + if (!cellSys) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - no CellGridSystem in app"); + return false; + } + flecs::world *w = app.getWorld(); + Ogre::SceneManager *sm = app.getSceneManager(); + + bool ok = true; + auto fail = [&](const std::string &message) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - " + message); + ok = false; + }; + + /* A two-floor plan with a front door. */ + 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); + LayoutLib::BuildingPlan plan; + 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)) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - interior plan eval: " + err); + return false; + } + + InteriorScene::Input in; + in.plan = &plan; + in.gridUid = "town-Interior Load Test"; + in.address = "Interior Load Test"; + in.sourceScenePath = "exterior.json"; + InteriorScene::Entrance entrance; + entrance.floor = 0; + entrance.cellX = 3; + entrance.cellZ = 3; + entrance.side = 1; /* +Z front door (DoorZPos) */ + entrance.approachX = 10.0f; + entrance.approachY = 0.2f; + entrance.approachZ = 20.0f; + in.entrances.push_back(entrance); + const InteriorScene::Output out = InteriorScene::build(in); + const std::string key = InteriorScene::doorEdgeKey(3, 0, 3, 1); + + /* Load the document through the real serializer (phase 8.2's + * loadFromJson path) and materialize the grid. */ + SceneSerializer serializer(*w, sm); + if (!serializer.loadFromJson(out.document)) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - interior document load: " + + serializer.getLastError()); + return false; + } + cellSys->update(); + + flecs::entity grid; + std::vector loaded; + w->each([&](flecs::entity e, EntityNameComponent &n) { + if (n.name == "interior:" + in.gridUid) + grid = e; + if (n.name == "interior:" + in.gridUid || + n.name == "arrival:" + key || + n.name == "interior-light") + loaded.push_back(e); + }); + bool arrivalFound = false; + for (const flecs::entity &e : loaded) + if (e.has() && + e.get().name == "arrival:" + key) + arrivalFound = true; + + if (!grid.is_alive()) { + fail("interior grid entity not loaded"); + } else { + const CellGridComponent &g = grid.get(); + 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 (g.generationMode != "interiorOnly") + fail("interior grid not in interiorOnly mode"); + if ((int)g.cells.size() != expected) + fail("interior cell count differs from the plan"); + auto it = g.doorConfigs.find(key); + if (it == g.doorConfigs.end() || + !it->second.hasSceneSwitchPose || + it->second.sceneSwitchPath != "exterior.json" || + it->second.sceneSwitchPosZ != 20.0f) + fail("interior exit door lost its pre-wired pose"); + } + if (!arrivalFound) + fail("arrival marker not loaded"); + + /* Teardown: collect-then-destruct (never inside each()), then let + * the CellGridSystem sweep own the Ogre resources. */ + for (flecs::entity e : loaded) + if (e.is_alive()) + e.destruct(); + cellSys->update(); + + if (!ok) + return false; + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: interior scene load test passed"); + return true; +} + /* ------------------------------------------------------------------ */ /* Logging */ /* ------------------------------------------------------------------ */ @@ -8020,6 +8154,7 @@ int TerrainTestRunner::run(EditorApp &app, int iterations) { "furniturePoiQuery", testFurniturePoiQuery }, { "furnitureLayers", testFurnitureLayers }, { "cellGridStairs", testCellGridStairs }, + { "interiorSceneLoad", testInteriorSceneLoad }, }; diff --git a/src/features/editScene/systems/TerrainTests.hpp b/src/features/editScene/systems/TerrainTests.hpp index b281111..df7d014 100644 --- a/src/features/editScene/systems/TerrainTests.hpp +++ b/src/features/editScene/systems/TerrainTests.hpp @@ -132,6 +132,7 @@ static bool testTerrainPrefabSpawners(EditorApp &app, TerrainSystem *ts); static bool testFurniturePoiQuery(EditorApp &app, TerrainSystem *ts); static bool testFurnitureLayers(EditorApp &app, TerrainSystem *ts); static bool testCellGridStairs(EditorApp &app, TerrainSystem *ts); + static bool testInteriorSceneLoad(EditorApp &app, TerrainSystem *ts); static bool testMemoryStability(EditorApp &app); static void logResult(const TerrainTestResult &r); diff --git a/src/features/editScene/systems/TownGenSystem.cpp b/src/features/editScene/systems/TownGenSystem.cpp index 99c554a..7c68d16 100644 --- a/src/features/editScene/systems/TownGenSystem.cpp +++ b/src/features/editScene/systems/TownGenSystem.cpp @@ -4,6 +4,7 @@ #include "TerrainSystem.hpp" #include "RoadSystem.hpp" #include "CellGridSystem.hpp" +#include "GeneratedInteriorService.hpp" #include "../GameMode.hpp" #include "../components/Terrain.hpp" #include "../components/Transform.hpp" @@ -1108,6 +1109,25 @@ TownGenSystem::createSettlementResources(flecs::entity container, return texture; } +/* Phase 8.2: runtime plan registry for generated interiors. */ +static std::unordered_map + g_buildingPlans; + +void TownGenSystem::registerBuildingPlan( + const std::string &gridUid, const LayoutLib::BuildingPlan &plan) +{ + if (gridUid.empty()) + return; + g_buildingPlans[gridUid] = plan; +} + +const LayoutLib::BuildingPlan * +TownGenSystem::findBuildingPlan(const std::string &gridUid) +{ + auto it = g_buildingPlans.find(gridUid); + return it == g_buildingPlans.end() ? nullptr : &it->second; +} + void TownGenSystem::fillBuildingCells(const TownGen::TownBuilding &b, CellGridComponent &grid, const std::string &genMode) @@ -1125,13 +1145,11 @@ void TownGenSystem::fillBuildingCells(const TownGen::TownBuilding &b, grid.cellHeight = plan.cellHeight; grid.generationMode = genMode; - /* Exterior shell only. The entrance gets a real door leaf so the - * doorway reads as a door, but every external door is locked by - * default (prompt "E Locked", never opens) until Phase 8 wires - * the interior scene switch — Phase 8 replaces the lock with a - * per-door sceneSwitchPath/target. The grid uid is derived from - * the address so streamed rebuilds keep stable door IDs in the - * global state store instead of churning random UUIDs. */ + /* Exterior shell only. The entrance gets a real door leaf and a + * per-door sceneSwitchPath/target to the generated interior + * (phase 8.2); the grid uid is derived from the address so + * streamed rebuilds keep stable door IDs in the global state + * store instead of churning random UUIDs. */ grid.doorsEnabled = true; grid.gridUid = "town-" + b.address; @@ -1162,17 +1180,21 @@ void TownGenSystem::fillBuildingCells(const TownGen::TownBuilding &b, if (plan.floors > 1) autoPlaceStairwell(grid, b.address); - /* Lock every external doorway: the leaf is there (and blocks - * the doorway physically and on the navmesh) but cannot be - * opened before the interior exists. */ + /* Phase 8.2: every external doorway is the entrance to the + * generated interior (the town-wide "E Locked" rule is replaced; + * a doorway can still be blocked with its own door config). The + * target names the matching interior arrival marker; the interior + * scene's exit doors carry the return pose for this doorway. */ for (const CellGridSystem::DoorwayInfo &dw : CellGridSystem::collectDoorways(grid)) { if (dw.internal) continue; CellGridDoorConfig cfg; - cfg.label = "Front door"; - cfg.lockable = true; - cfg.lockedByDefault = true; + cfg.label = "Entrance"; + cfg.hasOverride = true; + cfg.sceneSwitchPath = + GeneratedInteriorService::schemeFor(grid.gridUid); + cfg.sceneSwitchTarget = "arrival:" + dw.edgeKey; grid.doorConfigs[dw.edgeKey] = cfg; } @@ -1541,6 +1563,8 @@ void TownGenSystem::createBuilding(flecs::entity container, CellGridComponent grid; fillBuildingCells(b, grid, genMode); + /* Phase 8.2: remember the plan for the generated interior. */ + registerBuildingPlan(grid.gridUid, b.plan); /* cellToWorld() puts cell (x, y, z) at x*cellSize, so the shell * spans [0, width*cellSize] from the entity node — shift the diff --git a/src/features/editScene/systems/TownGenSystem.hpp b/src/features/editScene/systems/TownGenSystem.hpp index 583c462..a7f7108 100644 --- a/src/features/editScene/systems/TownGenSystem.hpp +++ b/src/features/editScene/systems/TownGenSystem.hpp @@ -129,6 +129,18 @@ private: public: + /** Phase 8.2: per-building plan registry (runtime only, never + * serialized). createBuilding records each generated building's + * plan under its gridUid ("town-
") so the + * generated-interior service can rebuild the matching interior + * document on demand. Re-registering a gridUid replaces the + * entry (streamed rebuilds, lot-param edits). */ + static void registerBuildingPlan( + const std::string &gridUid, + const LayoutLib::BuildingPlan &plan); + static const LayoutLib::BuildingPlan * + findBuildingPlan(const std::string &gridUid); + /** Warnings of the last generate()/generateAreas()/regenerate* * run (layout errors that fell back to the district default, * unreachable rooms, ...), for the editor UI; the same lines also diff --git a/src/features/editScene/tests/cellgrid_door_test.cpp b/src/features/editScene/tests/cellgrid_door_test.cpp index 74bec27..42b7819 100644 --- a/src/features/editScene/tests/cellgrid_door_test.cpp +++ b/src/features/editScene/tests/cellgrid_door_test.cpp @@ -42,6 +42,7 @@ #include "../lua/LuaFurnitureApi.hpp" #include "../systems/RoomLayoutSystem.hpp" #include "../systems/FurnitureLibrary.hpp" +#include "../roadlib/InteriorScene.hpp" // --------------------------------------------------------------------------- // Test helpers @@ -212,6 +213,11 @@ static int testSerializationRoundTrip() cfg.actionName = "knock"; cfg.sceneSwitchPath = "interior.json"; cfg.sceneSwitchTarget = "arrival"; + cfg.hasSceneSwitchPose = true; + cfg.sceneSwitchPosX = 11.5f; + cfg.sceneSwitchPosY = 4.25f; + cfg.sceneSwitchPosZ = -7.0f; + cfg.sceneSwitchYawDeg = 270.0f; cfg.persistent = true; cfg.lockable = true; cfg.lockedByDefault = true; @@ -276,6 +282,10 @@ static int testSerializationRoundTrip() !c2.lockable || !c2.lockedByDefault || c2.keyItemId != "key_brass" || c2.disabled) FAIL("door config fields mismatch after round trip"); + if (!c2.hasSceneSwitchPose || c2.sceneSwitchPosX != 11.5f || + c2.sceneSwitchPosY != 4.25f || c2.sceneSwitchPosZ != -7.0f || + c2.sceneSwitchYawDeg != 270.0f) + FAIL("pre-wired return pose lost in round trip"); auto oit = g2.doorConfigs.find("Z:9:9:9"); if (oit == g2.doorConfigs.end()) @@ -1103,6 +1113,35 @@ static int testFurnitureLuaHooks() return 0; } +/* Phase 8.1: the interior scene builder mirrors the engine's canonical + * doorway edge keys (the pre-wired exit doors are keyed by them). */ +static int testInteriorSceneDoorKeys() +{ + TEST("phase 8.1 interior door edge keys match the engine"); + + for (int y = 0; y < 2; ++y) + for (int z = -2; z <= 2; ++z) + for (int x = -2; x <= 2; ++x) + for (int side = 0; side < 4; ++side) { + const std::string mine = + InteriorScene::doorEdgeKey( + x, y, z, side); + const std::string engine = + CellGridSystem::doorEdgeKey( + x, y, z, side); + if (mine != engine) + FAIL("doorEdgeKey mismatch at (" + + std::to_string(x) + "," + + std::to_string(y) + "," + + std::to_string(z) + ") side " + + std::to_string(side) + ": " + + mine + " vs " + engine); + } + + PASS(); + return 0; +} + int main() { printf("CellGrid door identity / per-door config tests (F0, F6)\n"); @@ -1128,6 +1167,7 @@ int main() failures += testStandaloneDoorSerialization(); failures += testGenerationModeSerialization(); failures += testFurnitureLuaHooks(); + failures += testInteriorSceneDoorKeys(); printf("====================================================\n"); printf("Results: %d/%d passed\n", passCount, testCount); diff --git a/src/features/editScene/tests/road_geometry_overlap_test.cpp b/src/features/editScene/tests/road_geometry_overlap_test.cpp index d596c39..ebe6bc7 100644 --- a/src/features/editScene/tests/road_geometry_overlap_test.cpp +++ b/src/features/editScene/tests/road_geometry_overlap_test.cpp @@ -40,10 +40,12 @@ #include "roadlib/TownGen.hpp" #include "roadlib/TownParking.hpp" #include "roadlib/TownPopulation.hpp" +#include "roadlib/AgeBands.hpp" #include "roadlib/TownLocations.hpp" #include "roadlib/PoiDatabase.hpp" #include "roadlib/TownSchedule.hpp" #include "roadlib/TownRostersWeeks.hpp" +#include "roadlib/InteriorScene.hpp" #include "systems/CharacterStatStore.hpp" #include "systems/StatEffectsApplier.hpp" @@ -4546,6 +4548,203 @@ static bool runTownGenProbe() } } + /* Phase 8.1: interior scene document builder. */ + { + BuildingPlan pInt; + LayoutEval evInt(lb, &rngDsl, nullptr); + if (!evInt.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))" + " (door front -4))", + pInt, &derr)) { + printf(" *** interior scene: eval failed " + "'%s' ***\n", derr.c_str()); + ok = false; + } else { + InteriorScene::Input in; + in.plan = &pInt; + in.gridUid = "town-Test 1"; + in.address = "Test 1"; + in.sourceScenePath = "demo_scene_exterior.json"; + InteriorScene::Entrance e0; + e0.floor = 0; + e0.cellX = 3; + e0.cellZ = 3; + e0.side = 1; /* +Z front door */ + e0.approachX = 100.0f; + e0.approachY = 4.2f; + e0.approachZ = 200.0f; + e0.approachYawDeg = 90.0f; + in.entrances.push_back(e0); + InteriorScene::Entrance e1; + e1.floor = 1; + e1.cellX = 1; + e1.cellZ = 3; + e1.side = 1; /* +Z front door */ + e1.approachX = 110.0f; + e1.approachY = 4.2f; + e1.approachZ = 210.0f; + in.entrances.push_back(e1); + + const InteriorScene::Output a = + InteriorScene::build(in); + const InteriorScene::Output b = + InteriorScene::build(in); + const nlohmann::json &doc = a.document; + + size_t expectedCells = 0; + for (int y = 0; y < pInt.floors; ++y) + for (int z = 0; z < pInt.cellsZ; ++z) + for (int x = 0; + x < pInt.cellsX; ++x) + if (pInt.at(x, y, z)) + ++expectedCells; + + const std::string k0 = + InteriorScene::doorEdgeKey(3, 0, 3, 1); + const std::string k1 = + InteriorScene::doorEdgeKey(1, 1, 3, 1); + + bool docOk = a.document == b.document && + a.planHash == b.planHash && + a.planHash != 0 && + doc.value("version", "") == + "1.0"; + size_t gridCount = 0, markerCount = 0, + lightCount = 0; + float arrivalZ = 1.0e9f; + const nlohmann::json *cg = nullptr; + for (const auto &ent : doc["entities"]) { + const std::string n = + ent["name"]["name"]; + if (ent.contains("cellGrid")) { + ++gridCount; + cg = &ent["cellGrid"]; + } + if (ent.contains("light")) + ++lightCount; + if (n.rfind("arrival:", 0) == 0) { + ++markerCount; + if (n == "arrival:" + k0) + arrivalZ = + ent["transform"] + ["position"] + ["z"] + .get(); + } + } + docOk = docOk && gridCount == 1 && + markerCount == 2 && lightCount == 1 && + cg != nullptr; + if (docOk) { + const nlohmann::json &g = *cg; + docOk = g.value("generationMode", "") == + "interiorOnly" && + g.value("width", -1) == + pInt.cellsX && + g.value("height", -1) == + pInt.floors && + g.value("depth", -1) == + pInt.cellsZ && + g.value("gridUid", "") == + "town-Test 1" && + g["cells"].size() == + expectedCells && + g["doorConfigs"].size() == 2 && + g["doorConfigs"].contains(k0) && + g["doorConfigs"].contains(k1); + } + if (docOk) { + const nlohmann::json &dc = + (*cg)["doorConfigs"][k0]; + docOk = + dc.value("sceneSwitchPath", + "") == + "demo_scene_exterior.json" && + dc.value("sceneSwitchHasPose", + false) && + dc["sceneSwitchPos"][0] + .get() == + 100.0f && + dc["sceneSwitchPos"][1] + .get() == + 4.2f && + dc["sceneSwitchPos"][2] + .get() == + 200.0f && + dc["sceneSwitchYaw"] + .get() == + 90.0f; + } + /* The arrival sits INSIDE the front door cell + * (front is +Z, so the marker's z is below + * the cell center). */ + if (docOk) + docOk = arrivalZ < + 3 * pInt.cellSize; + /* The plan hash is sensitive to plan edits. */ + if (docOk) { + BuildingPlan pInt2 = pInt; + pInt2.at(0, 0, 0) |= + PlanFlags::Ceiling; + docOk = + InteriorScene::planHash(pInt2, + "Test 1") != + a.planHash; + } + if (!docOk) { + printf(" *** interior scene: " + "document wrong (grid %zu markers " + "%zu lights %zu cells %zu hash " + "%llx) ***\n", + gridCount, markerCount, + lightCount, + cg ? (*cg)["cells"].size() : 0, + (unsigned long long)a.planHash); + ok = false; + } + /* LRU cache: budget accounting + eviction. */ + if (docOk) { + const size_t sz = + a.document.dump().size(); + InteriorScene::DocumentCache cache( + sz * 2 + 10); + cache.put("a", a.document); + cache.put("b", b.document); + cache.put("c", a.document); + bool lruOk = + cache.find("a") == nullptr && + cache.find("b") != nullptr && + cache.find("c") != nullptr && + cache.size() == 2 && + cache.bytes() <= + cache.limitBytes(); + /* Touch b, then insert d: c (LRU) + * must drop. */ + cache.find("b"); + cache.put("d", b.document); + lruOk = lruOk && + cache.find("b") != nullptr && + cache.find("c") == nullptr; + /* A document larger than the whole + * budget is not cached. */ + InteriorScene::DocumentCache tiny(16); + tiny.put("x", a.document); + lruOk = lruOk && tiny.size() == 0; + if (!lruOk) { + printf(" *** interior scene: " + "LRU cache policy wrong " + "(bytes %zu/%zu) ***\n", + cache.bytes(), + cache.limitBytes()); + ok = false; + } + } + } + } + /* Phase 6.5.F: furniture-selection metadata is part of the * room plan and stays parallel to room names/types. */ { @@ -6286,18 +6485,24 @@ static bool runTownPopulationProbe() hh.lastName.c_str()); ok = false; } - bool isChild = m.age == "child"; - bool isAdult = m.age == "adult"; - bool isElder = m.age == "elder"; - if (!isChild && !isAdult && !isElder) { - printf(" *** member %d bad age band " - "'%s' ***\n", gi, m.age.c_str()); + const AgeBands::Band band = + AgeBands::fromYears(m.ageYears); + const bool isDependent = band == AgeBands::Band::Baby || + band == AgeBands::Band::Child || + band == AgeBands::Band::Teenager; + const bool isAdult = band == AgeBands::Band::Adult; + const bool isElder = band == AgeBands::Band::Elder; + if (std::string(AgeBands::name(band)) != + AgeBands::normalize(m.age)) { + printf(" *** member %d age band '%s' != " + "'%s' ***\n", gi, m.age.c_str(), + AgeBands::name(band)); ok = false; } - if (isChild && + if (isDependent && (m.ageYears < rules.childMinYears || m.ageYears > rules.childMaxYears)) { - printf(" *** child ageYears %d ***\n", + printf(" *** dependent ageYears %d ***\n", m.ageYears); ok = false; } @@ -6315,8 +6520,8 @@ static bool runTownPopulationProbe() m.ageYears); ok = false; } - /* Adults come first in the member list. */ - if (!isChild) { + /* Adults (including elders) come first in the member list. */ + if (!isDependent) { ++adults; if (mi >= 2) { printf(" *** adult at member " @@ -6379,18 +6584,46 @@ static bool runTownPopulationProbe() "household %zu ***\n", hi); ok = false; } - if (mf.age == "child" || - mt.age == "child") { + const bool mfDep = + AgeBands::fromYears(mf.ageYears) == + AgeBands::Band::Baby || + AgeBands::fromYears(mf.ageYears) == + AgeBands::Band::Child || + AgeBands::fromYears(mf.ageYears) == + AgeBands::Band::Teenager; + const bool mtDep = + AgeBands::fromYears(mt.ageYears) == + AgeBands::Band::Baby || + AgeBands::fromYears(mt.ageYears) == + AgeBands::Band::Child || + AgeBands::fromYears(mt.ageYears) == + AgeBands::Band::Teenager; + if (mfDep || mtDep) { printf(" *** spouse involving a " - "child ***\n"); + "dependent ***\n"); ok = false; } } - if (r.tag == "child" && - (mf.age != "child" || mt.age == "child")) { - printf(" *** 'child' tag %s -> %s ***\n", - mf.age.c_str(), mt.age.c_str()); - ok = false; + if (r.tag == "child") { + const bool mfDep = + AgeBands::fromYears(mf.ageYears) == + AgeBands::Band::Baby || + AgeBands::fromYears(mf.ageYears) == + AgeBands::Band::Child || + AgeBands::fromYears(mf.ageYears) == + AgeBands::Band::Teenager; + const bool mtDep = + AgeBands::fromYears(mt.ageYears) == + AgeBands::Band::Baby || + AgeBands::fromYears(mt.ageYears) == + AgeBands::Band::Child || + AgeBands::fromYears(mt.ageYears) == + AgeBands::Band::Teenager; + if (!mfDep || mtDep) { + printf(" *** 'child' tag %s -> %s ***\n", + mf.age.c_str(), mt.age.c_str()); + ok = false; + } } } } @@ -8120,6 +8353,7 @@ static bool runTownRostersProbe() pm.stableKey = "roster-" + std::to_string((char)('a' + i)); pm.household = (size_t)i; pm.age = "adult"; + pm.ageYears = 30; pop.members.push_back(pm); } @@ -8262,6 +8496,7 @@ static bool runWeekVarietyProbe() pm.stableKey = key; pm.household = 0; pm.age = "adult"; + pm.ageYears = 30; pop.members.push_back(pm); }; member(70001, "alpha"); @@ -8781,6 +9016,7 @@ static bool runTownWeekScheduleProbe() pm.stableKey = "npc-" + std::to_string((char)('a' + i)); pm.household = i < 2 ? 1 : 0; pm.age = "adult"; + pm.ageYears = 30; pop.members.push_back(pm); } std::reverse(pop.members.begin(), pop.members.end()); @@ -9272,6 +9508,8 @@ static bool runTownWeekScheduleProbe() std::to_string((char)('a' + i)); pm.household = 2; pm.age = "adult"; + pm.ageYears = 30; + pm.ageYears = 30; pop.members.push_back(pm); } pop.households.resize(3);