Compare commits
35 Commits
3c7f50a4ef
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| ea281bf913 | |||
| 214b05b40f | |||
| 141bfd31c1 | |||
| 7e0a74d837 | |||
| 65066e6d29 | |||
| a3ec423cca | |||
| 19c5528f8e | |||
| 2873a695f7 | |||
| 54d9d4a28e | |||
| d5b9ae235e | |||
| c643d4388f | |||
| 682ccee856 | |||
| d0ca3d181a | |||
| 72851b442f | |||
| 3dfd6c12bd | |||
| e918ef8c4b | |||
| 88fd97fedd | |||
| ec3f98f8a0 | |||
| adc4c91a2c | |||
| 9e2d9fce59 | |||
| 95b42090f9 | |||
| e609d4e39b | |||
| c561fad79e | |||
| ff81a9a657 | |||
| 73e1cef832 | |||
| 136013440e | |||
| e58c061709 | |||
| 6f152b0248 | |||
| d28fce7631 | |||
| 00b2d5d1fe | |||
| a3279f984b | |||
| 7adfaa53a2 | |||
| 5ca9b66d72 | |||
| f9689eaa32 | |||
| 129557c4ba |
@@ -131,8 +131,24 @@ cmake --build build --target demoLuaSceneScript # Demo: game-mode inline scene/p
|
||||
cmake --build build --target demoSceneSwitchingExtra # Demo: scene switching with CellGrid interior
|
||||
cmake --build build --target demoSokoban # Demo: forklift sokoban activity (vehicle + crates + quest HUD)
|
||||
cmake --build build --target demoTaxi # Demo: taxi driver (procedural town, sedans, NPC traffic, fare chain)
|
||||
cmake --build build --target characters-all # All adult/baby/child/teenager/elder character assets
|
||||
cmake --build build --target character-assets-check # Validate age catalogs/LODs/resource tree
|
||||
```
|
||||
|
||||
### Parallel asset builds
|
||||
|
||||
Blender 3.6 batch jobs are not safe to run concurrently (shared user
|
||||
config/addon state and shared build files). Every Blender invocation in
|
||||
the CMake pipeline goes through `${BLENDER_LOCKED}`, which is
|
||||
`flock -x ${BLENDER_LOCK} <blender>`, so `cmake --build build -jN` is
|
||||
safe: only one Blender job runs at a time while CMake copies, compiles
|
||||
and validates in parallel. `flock` must be installed (configure fails
|
||||
otherwise). The default lock is per build tree
|
||||
(`<build-dir>/blender-assets.lock`); if two build trees are built at the
|
||||
same time, configure them with the same lock file, for example
|
||||
`-DBLENDER_LOCK=/tmp/world2-blender-assets.lock`. New custom commands
|
||||
must use `${BLENDER_LOCKED}`, never `${BLENDER}` directly.
|
||||
|
||||
### Build Outputs
|
||||
- `build/Game` - Main game executable (legacy)
|
||||
- `build/Editor` - World editor executable (legacy)
|
||||
@@ -354,6 +370,9 @@ JPH::BodyID body = physics->createBody(shape, 1.0f, position, rotation,
|
||||
1. **Source**: Blender `.blend` files in `assets/`
|
||||
2. **Export**: Python scripts in `assets/blender/scripts/`
|
||||
- `export_buildings.py` - Building export to glTF
|
||||
- `buildings/parts/bake_furniture.py` + `export_furniture_parts.py` -
|
||||
Furniture atlas, GLB and definition-JSON export (authoring contract:
|
||||
`assets/blender/buildings/parts/FURNITURE_MODELING.md`)
|
||||
- `export_characters_ogre.py` - Character export to OGRE format
|
||||
- `export_vehicles.py` - Vehicle export (car modeling contract:
|
||||
`assets/blender/vehicles/CAR_MODELING.md`)
|
||||
@@ -372,6 +391,12 @@ JPH::BodyID body = physics->createBody(shape, 1.0f, position, rotation,
|
||||
`<name>.vehicle.json` scene entity (contract:
|
||||
`assets/blender/vehicles/CAR_MODELING.md`)
|
||||
- `import_vrm.py` - VRM character import
|
||||
- `characters/generate_age_blends.py` + `characters/agegen/` - one-shot
|
||||
adult → age-band seed converter (rig scaling, mesh/shape-key
|
||||
deformation, garment/hair conversion, animation retargeting; contract:
|
||||
`assets/blender/characters/AGES-PLAN.md`)
|
||||
- `characters/validate_age_assets.py` - structural validation of the
|
||||
generated age catalogs/LODs/resource tree
|
||||
3. **Build**: CMake processes assets during build
|
||||
4. **Runtime**: Assets loaded from `resources.cfg` paths
|
||||
|
||||
|
||||
+16
-3
@@ -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
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
project(building-parts)
|
||||
find_program(PYTHON3_EXECUTABLE NAMES python3 python REQUIRED)
|
||||
set(PARTS_FILES pier.blend)
|
||||
set(FURNITURE_FILES furniture.blend furniture-sofa.blend)
|
||||
set(PARTS_OUTPUT_DIRS)
|
||||
@@ -8,30 +9,128 @@ 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}
|
||||
DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/${PARTS_FILE} ${CMAKE_SOURCE_DIR}/assets/blender/scripts/export_building_parts.py)
|
||||
list(APPEND PARTS_OUTPUT_DIRS ${PARTS_OUTPUT_DIR})
|
||||
endforeach()
|
||||
set(FURNITURE_BAKED_BLENDS)
|
||||
set(FURNITURE_ATLAS_PARTS)
|
||||
list(LENGTH FURNITURE_FILES FURNITURE_COUNT)
|
||||
foreach(FURNITURE_FILE ${FURNITURE_FILES})
|
||||
list(FIND FURNITURE_FILES ${FURNITURE_FILE} FURNITURE_INDEX)
|
||||
get_filename_component(FILE_NAME ${FURNITURE_FILE} NAME_WE)
|
||||
set(BAKED_BLEND ${CMAKE_CURRENT_BINARY_DIR}/${FILE_NAME}_baked.blend)
|
||||
set(BAKE_SUCCESS ${CMAKE_CURRENT_BINARY_DIR}/${FILE_NAME}.bake_success)
|
||||
set(ATLAS_PART ${CMAKE_CURRENT_BINARY_DIR}/${FILE_NAME}-atlas.png)
|
||||
add_custom_command(
|
||||
OUTPUT ${BAKED_BLEND} ${ATLAS_PART} ${BAKE_SUCCESS}
|
||||
COMMAND ${CMAKE_COMMAND} -E rm -f ${BAKE_SUCCESS}
|
||||
COMMAND ${CMAKE_COMMAND} -E copy
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/${FURNITURE_FILE}
|
||||
${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}
|
||||
COMMAND ${CMAKE_COMMAND} -D FILE=${BAKE_SUCCESS}
|
||||
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
|
||||
DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/${FURNITURE_FILE}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/bake_furniture.py)
|
||||
list(APPEND FURNITURE_BAKED_BLENDS ${BAKED_BLEND})
|
||||
list(APPEND FURNITURE_ATLAS_PARTS ${ATLAS_PART})
|
||||
endforeach()
|
||||
|
||||
# One shared runtime atlas/material for all furniture sources. Each source
|
||||
# owns a deterministic vertical band, so rebuilds cannot collide or reorder.
|
||||
set(FURNITURE_ATLAS_PNG
|
||||
${CMAKE_BINARY_DIR}/resources/buildings/parts/furniture-atlas.png)
|
||||
set(FURNITURE_ATLAS_MATERIAL
|
||||
${CMAKE_BINARY_DIR}/resources/buildings/parts/furniture-atlas.material)
|
||||
add_custom_command(
|
||||
OUTPUT ${FURNITURE_ATLAS_PNG} ${FURNITURE_ATLAS_MATERIAL}
|
||||
COMMAND ${CMAKE_COMMAND} -E rm -f
|
||||
${FURNITURE_ATLAS_PNG} ${FURNITURE_ATLAS_MATERIAL}
|
||||
COMMAND ${BLENDER_LOCKED} --factory-startup -b -P
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/merge_furniture_atlases.py --
|
||||
${FURNITURE_ATLAS_PNG} ${FURNITURE_ATLAS_MATERIAL}
|
||||
${FURNITURE_ATLAS_PARTS}
|
||||
COMMAND ${CMAKE_COMMAND} -D FILE=${FURNITURE_ATLAS_PNG}
|
||||
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
|
||||
COMMAND ${CMAKE_COMMAND} -D FILE=${FURNITURE_ATLAS_MATERIAL}
|
||||
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
|
||||
DEPENDS ${FURNITURE_ATLAS_PARTS}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/merge_furniture_atlases.py)
|
||||
|
||||
set(FURNITURE_EXPORT_STAMPS)
|
||||
foreach(FURNITURE_FILE ${FURNITURE_FILES})
|
||||
get_filename_component(FILE_NAME ${FURNITURE_FILE} NAME_WE)
|
||||
set(PARTS_OUTPUT_DIR ${CMAKE_BINARY_DIR}/resources/buildings/parts/${FILE_NAME})
|
||||
set(BAKED_BLEND ${CMAKE_CURRENT_BINARY_DIR}/${FILE_NAME}_baked.blend)
|
||||
set(EXPORT_SUCCESS ${CMAKE_CURRENT_BINARY_DIR}/${FILE_NAME}.export_success)
|
||||
add_custom_command(
|
||||
OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/${FILE_NAME}_baked.blend
|
||||
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} -Y -P
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/bake_furniture.py
|
||||
DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/${FURNITURE_FILE} ${CMAKE_CURRENT_SOURCE_DIR}/bake_furniture.py)
|
||||
add_custom_command(
|
||||
OUTPUT ${PARTS_OUTPUT_DIR}
|
||||
OUTPUT ${PARTS_OUTPUT_DIR} ${EXPORT_SUCCESS}
|
||||
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 ${CMAKE_CURRENT_BINARY_DIR}/${FILE_NAME}_baked.blend
|
||||
COMMAND ${BLENDER_LOCKED} -b ${BAKED_BLEND}
|
||||
-Y -P
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/export_furniture_parts.py
|
||||
-- ${PARTS_OUTPUT_DIR}
|
||||
DEPENDS ${CMAKE_CURRENT_BINARY_DIR}/${FILE_NAME}_baked.blend ${CMAKE_CURRENT_SOURCE_DIR}/export_furniture_parts.py)
|
||||
list(APPEND PARTS_OUTPUT_DIRS ${PARTS_OUTPUT_DIR})
|
||||
-- ${PARTS_OUTPUT_DIR} ${EXPORT_SUCCESS}
|
||||
COMMAND ${CMAKE_COMMAND} -D FILE=${EXPORT_SUCCESS}
|
||||
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
|
||||
DEPENDS ${BAKED_BLEND}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/export_furniture_parts.py
|
||||
${FURNITURE_ATLAS_PNG} ${FURNITURE_ATLAS_MATERIAL})
|
||||
list(APPEND PARTS_OUTPUT_DIRS ${PARTS_OUTPUT_DIR} ${EXPORT_SUCCESS})
|
||||
list(APPEND FURNITURE_EXPORT_STAMPS ${EXPORT_SUCCESS})
|
||||
endforeach()
|
||||
|
||||
# Validate the shared-atlas contract after every source has exported. This
|
||||
# catches embedded texture copies, UVs escaping the source band and shared
|
||||
# Blender mesh datablocks changing UVs between furniture variants.
|
||||
set(FURNITURE_VALIDATION
|
||||
${CMAKE_CURRENT_BINARY_DIR}/furniture-export.validated)
|
||||
add_custom_command(
|
||||
OUTPUT ${FURNITURE_VALIDATION}
|
||||
COMMAND ${CMAKE_COMMAND} -E rm -f ${FURNITURE_VALIDATION}
|
||||
COMMAND ${PYTHON3_EXECUTABLE}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/validate_furniture_export.py
|
||||
--root ${CMAKE_BINARY_DIR}/resources/buildings/parts
|
||||
--source furniture 0 ${FURNITURE_COUNT}
|
||||
--source furniture-sofa 1 ${FURNITURE_COUNT}
|
||||
COMMAND ${CMAKE_COMMAND} -E touch ${FURNITURE_VALIDATION}
|
||||
DEPENDS ${FURNITURE_EXPORT_STAMPS} ${FURNITURE_ATLAS_PNG}
|
||||
${FURNITURE_ATLAS_MATERIAL}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/validate_furniture_export.py)
|
||||
list(APPEND PARTS_OUTPUT_DIRS ${FURNITURE_VALIDATION}
|
||||
${FURNITURE_ATLAS_PNG} ${FURNITURE_ATLAS_MATERIAL})
|
||||
add_custom_target(import_building_parts ALL DEPENDS ${PARTS_OUTPUT_DIRS})
|
||||
|
||||
# Phase 6.5.B: opt-in preview gallery (kept out of the default build
|
||||
# because rendering every asset is slow). Renders each root with its
|
||||
# pivot, physical/collision bounds, occupancy cells and POI markers,
|
||||
# then writes an HTML index with the corresponding atlas UV rectangles.
|
||||
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_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_LOCKED} -b
|
||||
${CMAKE_CURRENT_BINARY_DIR}/furniture-sofa_baked.blend -Y -P
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/render_furniture_gallery.py --
|
||||
${FURNITURE_GALLERY_DIR}
|
||||
${CMAKE_BINARY_DIR}/resources/buildings/parts/furniture-sofa
|
||||
COMMAND ${PYTHON3_EXECUTABLE}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/generate_furniture_gallery_index.py
|
||||
--root ${FURNITURE_GALLERY_DIR}
|
||||
DEPENDS ${FURNITURE_EXPORT_STAMPS} ${FURNITURE_ATLAS_PNG}
|
||||
${FURNITURE_ATLAS_MATERIAL}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/render_furniture_gallery.py
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/generate_furniture_gallery_index.py)
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
# Furniture modeling and Blender export
|
||||
|
||||
This document describes the current furniture authoring path. The remaining
|
||||
room-composition, DSL and Lua work is tracked in
|
||||
`src/features/editScene/demos/demo-taxi/PLAN.md`, Phase 6.5.
|
||||
|
||||
Blender is the fast path for reusable furniture assets. Keep geometry, UVs,
|
||||
the placement pivot, physical bounds and asset-local interaction markers with
|
||||
the asset. Do not turn room layouts or game behavior into a large collection
|
||||
of Blender custom properties and empties: reusable room arrangements belong to
|
||||
the shared layout evaluator/DSL, while unique or behavior-heavy interiors use
|
||||
prefab ECS components and Lua.
|
||||
|
||||
## Pipeline files
|
||||
|
||||
- `edit-firniture-props.py` (historical file-name typo) provides the Blender
|
||||
**Furniture Controls** and **Action Controls** panels.
|
||||
- `bake_furniture.py` bakes one source blend into a deterministic vertical
|
||||
atlas band and assigns `M_Furniture_Atlas` to its visual meshes.
|
||||
- `merge_furniture_atlases.py` stacks those bands into the shared
|
||||
`furniture-atlas.png` and writes `furniture-atlas.material`.
|
||||
- `export_furniture_parts.py` exports every root object named `furniture-*`
|
||||
as GLB and writes a schema-v1 furniture-definition JSON containing bounds,
|
||||
layer, placement rules, source hash and interaction markers.
|
||||
- `validate_furniture_export.py` checks the generated GLB/JSON artifacts:
|
||||
schema references, shared material, no embedded images, UVs inside the
|
||||
source's atlas band, and identical UV bounds across variants sharing a
|
||||
Blender mesh datablock.
|
||||
- `CMakeLists.txt` runs baking, atlas merging, exporting and validation with
|
||||
success
|
||||
markers, so a Blender Python exception cannot leave stale output looking
|
||||
current. `FURNITURE_FILES` currently lists `furniture.blend` and
|
||||
`furniture-sofa.blend`.
|
||||
|
||||
For `furniture.blend`, furniture output goes to
|
||||
`build*/resources/buildings/parts/furniture/`; `furniture-sofa.blend` uses
|
||||
`resources/buildings/parts/furniture-sofa/`. The shared atlas and material
|
||||
are written once to `resources/buildings/parts/`:
|
||||
|
||||
```text
|
||||
furniture-<source-id>.glb
|
||||
furniture-<source-id>.glb.json
|
||||
resources/buildings/parts/furniture-atlas.png
|
||||
resources/buildings/parts/furniture-atlas.material
|
||||
```
|
||||
|
||||
The GLB keeps the `M_Furniture_Atlas` material name but deliberately embeds
|
||||
no image; OGRE loads the shared material from `furniture-atlas.material`.
|
||||
JSON files are generated output: edit the `.blend` source and re-export
|
||||
instead of hand-editing build artifacts.
|
||||
|
||||
## Author a furniture root
|
||||
|
||||
1. Open the source blend and run `edit-firniture-props.py` from Blender's
|
||||
Text Editor to register the helper panels.
|
||||
2. Create a root **Empty** named `furniture-<id>`. Parent all visual meshes
|
||||
to it. Keep the root origin at the intended placement pivot and use the
|
||||
same forward orientation as the existing assets.
|
||||
3. Select the root and use **Furniture Controls -> Create Furniture**.
|
||||
Available asset-local properties are:
|
||||
- `furniture_id` — stable definition id; defaults to the root suffix.
|
||||
It survives a cosmetic Blender object rename. Legacy roots named
|
||||
`furniture--<id>` export as `<id>-legacy` with the old name kept as an
|
||||
alias.
|
||||
- `display_name` — optional UI-facing name.
|
||||
- `furniture_tags` — comma-separated selection tags such as
|
||||
`bedroom, essential` or `livingroom, filler`.
|
||||
- `layer` — `normal`, `thin-floor`, `thin-wall` or `ceiling`.
|
||||
- `selection_weight` — non-negative weighted-random factor.
|
||||
- `requires_floor` / `requires_wall` — placement constraints.
|
||||
- `clearance` — extra occupied-footprint margin in metres.
|
||||
- `footprint_x` / `footprint_z` — optional occupancy override in grid
|
||||
cells; leave both at 0 to derive occupancy from mesh bounds at the
|
||||
target grid's cell size.
|
||||
- `collision_mesh` — optional explicit mesh object to use as simplified
|
||||
collision bounds. Meshes named `collision-*` are also treated as
|
||||
collision-only and are not exported as render geometry.
|
||||
4. Keep the asset reusable. A chair, table, counter section, shelf or
|
||||
repair-bay fixture is an asset; where several such assets belong in a
|
||||
particular room is a layout concern, not root-object metadata.
|
||||
|
||||
The exporter computes `bounds` from the visual mesh hierarchy in root-local
|
||||
space. If collision meshes exist, it also emits `collisionBounds`. Existing
|
||||
`foo-col` mesh names are **not** assumed to be collision because some legacy
|
||||
assets use that suffix for visible geometry; use `collision-*` or the explicit
|
||||
`collision_mesh` property for new collision proxies.
|
||||
|
||||
Runtime layer conventions: normal items use `collisionBounds` when present,
|
||||
otherwise physical `bounds`, for their generated collider; thin/ceiling
|
||||
layers never collide or obstruct the navmesh. A thin-wall root's origin is
|
||||
its attachment point and its quarter-turn placement selects the wall plane
|
||||
(internal inset 0.1 m, external 0.2 m outside the cell). A ceiling root's
|
||||
origin is the ceiling attachment point (place geometry below it, e.g. the
|
||||
ceiling light's negative local Z in Blender).
|
||||
|
||||
## Add local interaction markers
|
||||
|
||||
Simple asset-local interactions stay in Blender:
|
||||
|
||||
1. Add an **Empty** named `action-<name>` as a **direct child** of the
|
||||
furniture root. The helper offers Use and Sit actions and initializes
|
||||
`name`, `action`, `action_text`, `height`, `radius` and `tags`.
|
||||
2. Position/orient the action empty where the interaction occurs. Set its
|
||||
action name and interaction dimensions in **Action Controls**.
|
||||
3. Add optional `position-<name>` empties below that action for named
|
||||
approach/enter/exit transforms. The helper can create enter/exit markers.
|
||||
Root-level `position-*` children are also exported.
|
||||
4. The exporter additionally preserves action custom properties prefixed
|
||||
`goap_prereq_` and `goap_effect_`.
|
||||
|
||||
Only direct root/action children are discovered by the exporter, so preserve
|
||||
that hierarchy. At runtime `CellGridSystem` turns exported action markers
|
||||
into grid-owned entities carrying both `ActuatorComponent` and
|
||||
`SmartObjectComponent`; nested/root position markers become named runtime
|
||||
transforms without an actuator. Every marker has a stable id
|
||||
`furniture.<placementId>.<markerName>` and can be found through
|
||||
`CellGridSystem::queryFurniturePois` / `findFurniturePoi` by room,
|
||||
furniture tag, action, marker type or grid. Use these markers for local facts such as "sit here" or
|
||||
"stand at this side of the counter". Store work rules, repair behavior,
|
||||
cross-entity references and room-dependent relationships belong to the
|
||||
ECS/Lua layer instead.
|
||||
|
||||
## Bake and export
|
||||
|
||||
Add a new source blend to `FURNITURE_FILES` in this directory's
|
||||
`CMakeLists.txt`, then run:
|
||||
|
||||
```bash
|
||||
cmake --build build-vscode --target import_building_parts -j4
|
||||
```
|
||||
|
||||
The build performs these steps:
|
||||
|
||||
1. Copy the source blend to the build tree.
|
||||
2. Bake its visual meshes into that source's deterministic atlas band.
|
||||
3. Remap its UVs into that band.
|
||||
4. Merge all source bands into one shared atlas and material.
|
||||
5. Export GLBs without embedded texture copies, then write schema-v1 JSON.
|
||||
6. Validate the GLB/JSON set, including UV bands and shared mesh datablock
|
||||
variants.
|
||||
7. Check per-step success marker files so failed Blender scripts fail the
|
||||
build instead of leaving stale artifacts.
|
||||
|
||||
`src/features/editScene/resources.cfg` indexes
|
||||
`resources/buildings/parts`, `resources/buildings/parts/furniture` and
|
||||
`resources/buildings/parts/furniture-sofa`, so the shared material and both
|
||||
current definition sets are loadable. At runtime `FurnitureLibrary`
|
||||
discovers `furniture-*.json`, validates schema/identity/bounds and exposes
|
||||
stable ids and aliases.
|
||||
|
||||
To add the phase 6.5 fixtures (layer fixes + the 6.5.I store/garage
|
||||
set) to the existing source (idempotent), then export and render the
|
||||
preview gallery:
|
||||
|
||||
```bash
|
||||
../../blender-bin/bin/blender -b assets/blender/buildings/parts/furniture.blend \
|
||||
-Y -P assets/blender/buildings/parts/add_furniture_fixtures.py
|
||||
cmake --build build-vscode --target import_building_parts -j2
|
||||
cmake --build build-vscode --target furniture_gallery -j2
|
||||
```
|
||||
|
||||
The gallery is written to `resources/buildings/parts/gallery/index.html`:
|
||||
each asset is rendered at its export pivot with physical bounds (green),
|
||||
collision bounds (red), occupancy cells (blue), action/position markers
|
||||
and an atlas UV rectangle. `render_furniture_gallery.py` and
|
||||
`generate_furniture_gallery_index.py` are the generators. The gallery
|
||||
target is opt-in so routine builds do not render every asset.
|
||||
|
||||
### The fixture script (`add_furniture_fixtures.py`)
|
||||
|
||||
`add_furniture_fixtures.py` extends `furniture.blend` in place and is
|
||||
safe to re-run (every root is created only when missing, duplicate
|
||||
markers are never added again):
|
||||
|
||||
- Layer fixtures: `carpet1` (`thin-floor`), `switch1` and `picture1`
|
||||
(`thin-wall`), `ceiling-light1` (`ceiling`) and `shop-shelf1`
|
||||
(`normal`, with a simplified `collision-shelf-proxy`).
|
||||
- Store/garage fixtures (6.5.I): `counter1` (shop/counter/essential,
|
||||
`work` attachment marker for the demo's working-counter action),
|
||||
`workbench1` (garage/working) and `bay1` (vehicle-sized repair bay,
|
||||
`bay` marker plus visual stripes).
|
||||
- `ensure_attachment(root)` gives every fixture root one root-local
|
||||
`position-attach` marker (the guard matches the whole
|
||||
`position-attach` prefix — Blender renames colliding objects with
|
||||
numeric suffixes, so an exact-name check would re-create a second
|
||||
marker whenever a renamed marker survives; the demo's
|
||||
`demo_scene_furniture.json` POI test depends on one marker per root).
|
||||
|
||||
The phase 6.5.I demo scene and its headless check live in
|
||||
`src/features/editScene/demos/demo-taxi/` (`demo_scene_furniture.json`,
|
||||
`./demoTaxi --headless --test-furniture`).
|
||||
|
||||
## Choosing the authoring layer
|
||||
|
||||
| Need | Preferred home |
|
||||
| --- | --- |
|
||||
| Mesh, UVs, atlas, pivot and physical bounds | Blender |
|
||||
| Optional simplified collision mesh | Blender (`collision-*` or `collision_mesh`) |
|
||||
| Simple local sit/use/action and approach markers | Blender |
|
||||
| Single-mesh or multi-cell placement | Furniture definition + shared C++ kernel |
|
||||
| Multi-entity ECS behavior or custom components | Editor prefab, reusing the Blender asset |
|
||||
| Prefab-backed furniture | `prefabs/furniture.<name>.json` plus a definition whose `prefab` points at it; Create Prefab's **Furniture prefab** mode generates the companion definition with tags, layer, measured bounds and a deterministic prefab source hash |
|
||||
| Reusable dining sets, counter/shelf rows, room-fit rules | Shared room-composition evaluator and layout DSL (Phase 6.5.F) |
|
||||
| Unique store/garage layouts, scripted actions, exterior-entry wiring | Lua plus normal door/actuator/vehicle APIs (Phase 6.5.H) |
|
||||
| Occupancy, navigation, identity and lifecycle invariants | Shared C++ furniture kernel |
|
||||
|
||||
A table with chairs around it should not require a bespoke Blender empty
|
||||
hierarchy: place the table at a room anchor and generate the chairs relative
|
||||
to it through the shared composition evaluator. Likewise, a store or garage
|
||||
uses Blender-built counters, shelves and fixtures, but its working behavior
|
||||
and exterior-entry setup should remain readable DSL/Lua/ECS data.
|
||||
|
||||
Prefab instances placed through furniture definitions are runtime children of
|
||||
the CellGrid. They are not editor-selectable scene entities; non-normal
|
||||
layers strip physics components from the instantiated subtree, while normal
|
||||
prefabs keep their authored colliders.
|
||||
|
||||
## Layout DSL metadata bridge
|
||||
|
||||
Room declarations can record furniture-selection tags without hardcoding a
|
||||
placement algorithm:
|
||||
|
||||
```lisp
|
||||
(room shop (rect 0 0 1 1)
|
||||
:type store
|
||||
:furniture (shop|market counter !display))
|
||||
```
|
||||
|
||||
Entries in the `:furniture` list are ANDed. `a|b` accepts either tag and
|
||||
`!a` rejects a tag. The same term syntax works in RoomComponent tag lists.
|
||||
`BuildingPlan::roomFurnitureTags` stores the list parallel to
|
||||
`roomNames`/`roomTypes`, and the road geometry test suite covers the round
|
||||
trip through layout evaluation.
|
||||
|
||||
Full/interiorOnly grids materialize those plan rooms as RoomComponents
|
||||
(`TownGenSystem::createPlanRooms`), so the same RoomLayoutSystem furnishes
|
||||
DSL-authored and node-authored rooms. Reusable arrangements use the
|
||||
`:compose` room option instead of bespoke Blender hierarchies:
|
||||
|
||||
```lisp
|
||||
:compose (
|
||||
(group (livingroom nowall) :at center
|
||||
(child (lounge filler) -2 -2 0)
|
||||
(child (lounge filler) 2 -2 0)
|
||||
(child (lounge filler) -2 2 0)
|
||||
(child (lounge filler) 2 2 0))
|
||||
(row (nowindow optional) :at zpos :step 2 :count 2))
|
||||
```
|
||||
|
||||
A `group` is one root plus children at cell offsets (rotated with the
|
||||
root); the whole group fits or rolls back atomically. A `row` walks a
|
||||
wall edge with `step`/`skip`/`count` and skips blocked cells. Anchor is
|
||||
`center` or `zneg`/`zpos`/`xneg`/`xpos`; `:rot 0..3` overrides the root
|
||||
rotation (default: edge anchors face into the room). Tags are selection
|
||||
terms, not definition ids — check the exported `tags` array. The same
|
||||
rules are serialized on RoomComponent and editable in its Furniture
|
||||
Compositions panel.
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Add the phase 6.5 layer fixtures to furniture.blend (idempotent).
|
||||
|
||||
Run from the project root:
|
||||
../../blender-bin/bin/blender -b assets/blender/buildings/parts/furniture.blend \
|
||||
-Y -P assets/blender/buildings/parts/add_furniture_fixtures.py
|
||||
|
||||
The regular import_building_parts target then bakes/exports them. No
|
||||
room-placement rules live here: layers, bounds and (for the shelf) a
|
||||
simplified collision proxy are the only asset-local metadata.
|
||||
"""
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
def material(name, color):
|
||||
existing = bpy.data.materials.get(name)
|
||||
if existing:
|
||||
return existing
|
||||
mat = bpy.data.materials.new(name)
|
||||
mat.use_nodes = True
|
||||
bsdf = mat.node_tree.nodes.get("Principled BSDF")
|
||||
bsdf.inputs["Base Color"].default_value = (*color, 1.0)
|
||||
bsdf.inputs["Roughness"].default_value = 1.0
|
||||
return mat
|
||||
|
||||
|
||||
def make_root(name, tags, layer, requires_floor, requires_wall):
|
||||
existing = bpy.data.objects.get(name)
|
||||
if existing:
|
||||
return existing, False
|
||||
root = bpy.data.objects.new(name, None)
|
||||
root.empty_display_type = 'PLAIN_AXES'
|
||||
root.empty_display_size = 0.15
|
||||
bpy.context.scene.collection.objects.link(root)
|
||||
root["furniture_id"] = name.replace("furniture-", "")
|
||||
root["display_name"] = root["furniture_id"]
|
||||
root["furniture_tags"] = tags
|
||||
root["layer"] = layer
|
||||
root["selection_weight"] = 1.0
|
||||
root["requires_floor"] = requires_floor
|
||||
root["requires_wall"] = requires_wall
|
||||
root["clearance"] = 0.0
|
||||
root["footprint_x"] = 0
|
||||
root["footprint_z"] = 0
|
||||
return root, True
|
||||
|
||||
|
||||
def box(root, name, location, dimensions, mat, collision=False):
|
||||
bpy.ops.mesh.primitive_cube_add(size=1.0)
|
||||
obj = bpy.context.object
|
||||
obj.name = name
|
||||
obj.dimensions = dimensions
|
||||
bpy.ops.object.transform_apply(location=False, rotation=False,
|
||||
scale=True)
|
||||
obj.parent = root
|
||||
obj.location = location
|
||||
if mat and not collision:
|
||||
obj.data.materials.append(mat)
|
||||
if collision:
|
||||
obj.display_type = 'WIRE'
|
||||
return obj
|
||||
|
||||
|
||||
def ensure_attachment(root):
|
||||
"""A root-local marker used by the runtime layer-transform checks.
|
||||
|
||||
The guard matches the whole 'position-attach' prefix: Blender
|
||||
renames colliding objects with numeric suffixes, so an exact-name
|
||||
check would re-create a second marker whenever a deduped or
|
||||
renamed marker survives in the blend.
|
||||
"""
|
||||
if any(child.name.startswith("position-attach")
|
||||
for child in root.children):
|
||||
return
|
||||
marker = bpy.data.objects.new("position-attach", None)
|
||||
marker.empty_display_size = 0.05
|
||||
bpy.context.scene.collection.objects.link(marker)
|
||||
marker.parent = root
|
||||
marker.location = (0, 0, 0)
|
||||
marker["name"] = "attach"
|
||||
marker["type"] = "attachment"
|
||||
|
||||
|
||||
fabric = material("FixtureFabric", (0.34, 0.08, 0.08))
|
||||
trim = material("FixtureTrim", (0.12, 0.07, 0.04))
|
||||
plastic = material("FixturePlastic", (0.82, 0.82, 0.78))
|
||||
accent = material("FixtureAccent", (0.08, 0.25, 0.65))
|
||||
picture = material("FixturePicture", (0.15, 0.45, 0.28))
|
||||
light = material("FixtureLight", (1.0, 0.87, 0.55))
|
||||
wood = material("FixtureWood", (0.42, 0.25, 0.12))
|
||||
|
||||
root, created = make_root("furniture-carpet1",
|
||||
"livingroom, bedroom, filler", "thin-floor",
|
||||
True, False)
|
||||
if created:
|
||||
box(root, "CarpetBody", (0, 0, 0.01), (1.6, 1.2, 0.02), fabric)
|
||||
box(root, "CarpetInset", (0, 0, 0.021), (1.35, 0.95, 0.004), trim)
|
||||
ensure_attachment(root)
|
||||
|
||||
root, created = make_root("furniture-switch1", "office, utility, optional",
|
||||
"thin-wall", False, True)
|
||||
if created:
|
||||
box(root, "SwitchPlate", (0, 0, 0), (0.12, 0.04, 0.18), plastic)
|
||||
box(root, "SwitchToggle", (0, 0.025, 0), (0.035, 0.02, 0.08), trim)
|
||||
ensure_attachment(root)
|
||||
|
||||
root, created = make_root("furniture-picture1", "livingroom, office, optional",
|
||||
"thin-wall", False, True)
|
||||
if created:
|
||||
box(root, "PictureFrame", (0, 0, 0), (0.8, 0.05, 0.55), trim)
|
||||
box(root, "PictureCanvas", (0, 0.03, 0), (0.68, 0.012, 0.43), picture)
|
||||
box(root, "PictureMark", (0.12, 0.038, 0.02),
|
||||
(0.25, 0.006, 0.18), accent)
|
||||
|
||||
root, created = make_root("furniture-ceiling-light1",
|
||||
"livingroom, office, essential", "ceiling",
|
||||
False, False)
|
||||
if created:
|
||||
box(root, "CeilingLightHousing", (0, 0, -0.06), (0.6, 0.6, 0.12),
|
||||
plastic)
|
||||
box(root, "CeilingLightDiffuser", (0, 0, -0.13),
|
||||
(0.45, 0.45, 0.03), light)
|
||||
ensure_attachment(root)
|
||||
|
||||
root, created = make_root("furniture-shop-shelf1", "shop, display, important",
|
||||
"normal", True, False)
|
||||
if created:
|
||||
box(root, "ShelfBase", (0, 0, 0.05), (1.5, 0.4, 0.1), wood)
|
||||
box(root, "ShelfBack", (0, -0.17, 0.75), (1.5, 0.06, 1.5), wood)
|
||||
for x in (-0.72, 0.72):
|
||||
box(root, "ShelfSide", (x, 0, 0.75), (0.06, 0.4, 1.5), wood)
|
||||
for z in (0.5, 1.0, 1.5):
|
||||
box(root, "ShelfBoard", (0, 0, z), (1.5, 0.4, 0.05), wood)
|
||||
collision = box(root, "collision-shelf", (0, 0, 0.75),
|
||||
(1.5, 0.4, 1.5), None, collision=True)
|
||||
root["collision_mesh"] = collision.name
|
||||
|
||||
root, created = make_root("furniture-counter1", "shop, counter, essential",
|
||||
"normal", True, False)
|
||||
if created:
|
||||
box(root, "CounterTop", (0, 0, 0.95), (1.8, 0.6, 0.05), wood)
|
||||
for z in (-0.3, 0.3):
|
||||
box(root, "CounterLeg", (z, 0, 0.47), (0.06, 0.65, 0.95), wood)
|
||||
collision = box(root, "collision-counter", (0, 0, 0.5),
|
||||
(1.8, 0.6, 1.0), None, collision=True)
|
||||
root["collision_mesh"] = collision.name
|
||||
ensure_attachment(root)
|
||||
work_mark = bpy.data.objects.new("position-work", None)
|
||||
work_mark.empty_display_size = 0.05
|
||||
bpy.context.scene.collection.objects.link(work_mark)
|
||||
work_mark.parent = root
|
||||
work_mark.location = (0, -0.4, 1.0)
|
||||
work_mark["name"] = "work"
|
||||
work_mark["type"] = "attachment"
|
||||
|
||||
root, created = make_root("furniture-workbench1",
|
||||
"garage, working, optional",
|
||||
"normal", True, False)
|
||||
if created:
|
||||
box(root, "BenchTop", (0, 0, 0.9), (4.2, 0.8, 0.06), wood)
|
||||
for x in (-1.9, 0.0, 1.9):
|
||||
box(root, "BenchLeg", (x, 0, 0.44), (0.08, 0.7, 0.9), wood)
|
||||
box(root, "BenchVise", (1.4, 0, 0.78), (0.25, 0.3, 0.25), trim)
|
||||
collision = box(root, "collision-workbench", (0, 0, 0.5),
|
||||
(4.2, 0.8, 1.0), None, collision=True)
|
||||
root["collision_mesh"] = collision.name
|
||||
|
||||
root, created = make_root("furniture-bay1",
|
||||
"garage, bay, optional",
|
||||
"thin-floor", True, False)
|
||||
if created:
|
||||
box(root, "BayPad", (0, 0, 0.01), (5.2, 2.2, 0.02), accent)
|
||||
box(root, "BayStripe", (0, 0, 0.011), (4.7, 1.6, 0.004), picture)
|
||||
ensure_attachment(root)
|
||||
bay_mark = bpy.data.objects.new("position-bay", None)
|
||||
bay_mark.empty_display_size = 0.05
|
||||
bpy.context.scene.collection.objects.link(bay_mark)
|
||||
bay_mark.parent = root
|
||||
bay_mark.location = (0, 0, 0.02)
|
||||
bay_mark["name"] = "bay"
|
||||
bay_mark["type"] = "attachment"
|
||||
|
||||
bpy.ops.wm.save_as_mainfile(filepath=bpy.data.filepath)
|
||||
print("Phase 6.5 furniture fixtures present.")
|
||||
@@ -1,9 +1,27 @@
|
||||
import bpy
|
||||
import os
|
||||
import sys
|
||||
|
||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
ATLAS_OUTPUT = argv[0] if argv else ""
|
||||
ATLAS_INDEX = int(argv[1]) if len(argv) > 1 else 0
|
||||
ATLAS_COUNT = int(argv[2]) if len(argv) > 2 else 1
|
||||
SUCCESS_OUTPUT = argv[3] if len(argv) > 3 else ""
|
||||
if ATLAS_INDEX < 0 or ATLAS_INDEX >= ATLAS_COUNT:
|
||||
raise RuntimeError("invalid furniture atlas band index")
|
||||
|
||||
|
||||
def is_collision_name(name):
|
||||
# Existing assets also use "-col" for some VISUAL meshes, so only the
|
||||
# explicit collision-* prefix is excluded automatically.
|
||||
return name.lower().split(".", 1)[0].startswith("collision-")
|
||||
|
||||
|
||||
def get_all_children_recursive(obj, mesh_list):
|
||||
"""Recursively finds all mesh objects in the hierarchy."""
|
||||
"""Recursively finds visual mesh objects in the hierarchy."""
|
||||
for child in obj.children:
|
||||
if child.type == 'MESH' and child.data.uv_layers:
|
||||
if child.type == 'MESH' and child.data.uv_layers and \
|
||||
not is_collision_name(child.name):
|
||||
mesh_list.append(child)
|
||||
get_all_children_recursive(child, mesh_list)
|
||||
|
||||
@@ -11,7 +29,8 @@ def setup_furniture_atlas():
|
||||
# 1. Gather valid objects: Children of empties starting with "furniture-"
|
||||
|
||||
furniture_meshes = []
|
||||
roots = [o for o in bpy.data.objects if o.name.startswith("furniture-")]
|
||||
roots = [o for o in bpy.context.scene.objects
|
||||
if o.parent is None and o.name.startswith("furniture-")]
|
||||
|
||||
for root in roots:
|
||||
# If the root itself is a mesh with UVs, add it
|
||||
@@ -20,18 +39,15 @@ def setup_furniture_atlas():
|
||||
# Find all nested children
|
||||
get_all_children_recursive(root, furniture_meshes)
|
||||
|
||||
# Remove duplicates if any (in case of complex parenting)
|
||||
furniture_meshes = list(set(furniture_meshes))
|
||||
# Remove duplicates while preserving scene order.
|
||||
seen = set()
|
||||
furniture_meshes = [obj for obj in furniture_meshes
|
||||
if not (obj.name in seen or seen.add(obj.name))]
|
||||
|
||||
if not furniture_meshes:
|
||||
print("No recursive mesh objects found with UVs.")
|
||||
return
|
||||
raise RuntimeError("No visual furniture mesh objects found with UVs.")
|
||||
|
||||
furniture_objs = furniture_meshes
|
||||
|
||||
if not furniture_objs:
|
||||
print("No valid furniture mesh objects found with UVs.")
|
||||
return
|
||||
|
||||
# 2. Manage the Atlas Image (FurnitureColor)
|
||||
image_name = "FurnitureColor"
|
||||
@@ -74,7 +90,10 @@ def setup_furniture_atlas():
|
||||
scene.render.bake.use_pass_indirect = False
|
||||
scene.render.bake.use_pass_color = True
|
||||
scene.render.bake.target = 'IMAGE_TEXTURES'
|
||||
|
||||
# Empty atlas space stays transparent; the merge step stacks the
|
||||
# per-source bakes into deterministic vertical bands.
|
||||
scene.render.film_transparent = True
|
||||
|
||||
print("Baking Furniture Atlas...")
|
||||
override = {
|
||||
'active_object': bpy.context.view_layer.objects.active,
|
||||
@@ -83,6 +102,13 @@ def setup_furniture_atlas():
|
||||
|
||||
with bpy.context.temp_override(**override):
|
||||
bpy.ops.object.bake(type='DIFFUSE', margin=2)
|
||||
|
||||
if ATLAS_OUTPUT:
|
||||
os.makedirs(os.path.dirname(ATLAS_OUTPUT), exist_ok=True)
|
||||
atlas_img.filepath_raw = ATLAS_OUTPUT
|
||||
atlas_img.file_format = 'PNG'
|
||||
atlas_img.save()
|
||||
print("Partial atlas saved to: " + ATLAS_OUTPUT)
|
||||
atlas_img.pack()
|
||||
|
||||
# 6. Create and Assign Final Atlas Material
|
||||
@@ -106,7 +132,31 @@ def setup_furniture_atlas():
|
||||
obj.data.materials.clear()
|
||||
obj.data.materials.append(atlas_mat)
|
||||
|
||||
print("Process Complete. Atlas material applied.")
|
||||
# Put this source file into its deterministic vertical atlas band.
|
||||
# The merge script stacks the per-source bakes in the same order.
|
||||
if ATLAS_COUNT > 1:
|
||||
remapped_mesh_data = set()
|
||||
for obj in furniture_objs:
|
||||
# Variants such as table1/table2 and pole1/pole2 share mesh
|
||||
# datablocks. UV layers belong to the mesh data, so remap each
|
||||
# datablock once; otherwise the band transform compounds.
|
||||
if obj.data.name in remapped_mesh_data:
|
||||
continue
|
||||
remapped_mesh_data.add(obj.data.name)
|
||||
for uv_layer in obj.data.uv_layers:
|
||||
for loop in uv_layer.data:
|
||||
u, v = loop.uv
|
||||
if u < -0.001 or u > 1.001 or \
|
||||
v < -0.001 or v > 1.001:
|
||||
raise RuntimeError(
|
||||
"Furniture UV escapes the source atlas on " +
|
||||
obj.name)
|
||||
loop.uv = (min(max(u, 0.0), 1.0),
|
||||
(min(max(v, 0.0), 1.0) + ATLAS_INDEX) /
|
||||
ATLAS_COUNT)
|
||||
|
||||
print("Process Complete. Atlas material applied; UV band %d/%d." %
|
||||
(ATLAS_INDEX + 1, ATLAS_COUNT))
|
||||
for mat in bpy.data.materials:
|
||||
if mat.users == 0:
|
||||
bpy.data.materials.remove(mat)
|
||||
@@ -118,8 +168,11 @@ def setup_furniture_atlas():
|
||||
new_path = current_path.replace(".blend", "_baked.blend")
|
||||
bpy.ops.wm.save_as_mainfile(filepath=new_path)
|
||||
print(f"File saved to: {new_path}")
|
||||
if SUCCESS_OUTPUT:
|
||||
with open(SUCCESS_OUTPUT, "w", newline="\n") as marker:
|
||||
marker.write("ok\n")
|
||||
else:
|
||||
print("Warning: File not saved (unsaved blend file).")
|
||||
raise RuntimeError("Furniture blend was not saved")
|
||||
|
||||
setup_furniture_atlas()
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
if(EXISTS "${FILE}")
|
||||
file(SIZE "${FILE}" FILE_SIZE)
|
||||
if(FILE_SIZE GREATER 0)
|
||||
message(STATUS "Build-time check: ${FILE} exists and is not empty.")
|
||||
else()
|
||||
message(FATAL_ERROR "Build-time check: ${FILE} exists but is empty!")
|
||||
endif()
|
||||
else()
|
||||
message(FATAL_ERROR "Build-time check: ${FILE} does not exist!")
|
||||
endif()
|
||||
@@ -2,7 +2,22 @@ import bpy
|
||||
import mathutils
|
||||
|
||||
ACTION_PROPS = ["name", "action", "action_text", "height", "radius", "tags"]
|
||||
FURNITURE_PROPS = ["name", "furniture_tags"]
|
||||
# Asset-local furniture metadata exported to furniture-*.json. Room
|
||||
# placement/composition deliberately stays out of these properties; the
|
||||
# layout DSL/Lua handles table sets, stores, garages and similar setups.
|
||||
FURNITURE_PROPS = [
|
||||
"furniture_id",
|
||||
"display_name",
|
||||
"furniture_tags",
|
||||
"layer",
|
||||
"selection_weight",
|
||||
"requires_floor",
|
||||
"requires_wall",
|
||||
"clearance",
|
||||
"footprint_x",
|
||||
"footprint_z",
|
||||
"collision_mesh",
|
||||
]
|
||||
|
||||
test_tracking = {"lib_name": None}
|
||||
|
||||
@@ -122,23 +137,26 @@ class OBJECT_OT_CreateFurnitureProps(bpy.types.Operator):
|
||||
|
||||
def execute(self, context):
|
||||
obj = context.object
|
||||
defaults = {
|
||||
"furniture_id": obj.name.replace("furniture-", "").strip("-"),
|
||||
"display_name": obj.name.replace("furniture-", "").replace("-", " ").title(),
|
||||
"furniture_tags": "",
|
||||
"layer": "normal",
|
||||
"selection_weight": 1.0,
|
||||
"requires_floor": True,
|
||||
"requires_wall": False,
|
||||
"clearance": 0.0,
|
||||
"footprint_x": 0, # 0 = derive from mesh bounds
|
||||
"footprint_z": 0,
|
||||
"collision_mesh": "", # optional collision-* mesh name
|
||||
}
|
||||
# Older assets only had the now-obsolete custom "name" property;
|
||||
# use it once as the stable id instead of guessing tags from names.
|
||||
if "furniture_id" not in obj and "name" in obj:
|
||||
defaults["furniture_id"] = str(obj["name"]).strip("-")
|
||||
for p in FURNITURE_PROPS:
|
||||
if p not in obj:
|
||||
if p == "name":
|
||||
obj[p] = obj.name.replace("furniture-","")
|
||||
elif p == "furniture_tags":
|
||||
obj[p] = ""
|
||||
otags = []
|
||||
if obj.name.find("toilet") >= 0:
|
||||
otags.append("wc")
|
||||
otags.append("bathroom")
|
||||
otags.append("toilet")
|
||||
elif obj.name.find("bed") >= 0:
|
||||
otags.append("bed")
|
||||
otags.append("bedroom")
|
||||
obj[p] = ", ".join(otags)
|
||||
else:
|
||||
obj[p] = "" # Initialize as empty text string
|
||||
obj[p] = defaults[p]
|
||||
return {'FINISHED'}
|
||||
|
||||
class OBJECT_OT_CreateChildEmpties(bpy.types.Operator):
|
||||
@@ -324,7 +342,7 @@ class OBJECT_PT_FurnitureEmptyPanel(bpy.types.Panel):
|
||||
else:
|
||||
# Show the text fields if properties exist
|
||||
box = layout.box()
|
||||
box.label(text="Furnitore Settings", icon='PROPERTIES')
|
||||
box.label(text="Furniture Settings", icon='PROPERTIES')
|
||||
for p in FURNITURE_PROPS:
|
||||
box.prop(obj, f'["{p}"]', text=p.replace("_", " ").title())
|
||||
op_use = layout.operator("object.create_child_action_node", icon='EMPTY_DATA', text="Add Use Action Node")
|
||||
@@ -346,8 +364,8 @@ def register():
|
||||
|
||||
|
||||
def unregister():
|
||||
bpy.utils.register_class(OBJECT_OT_CreateActionProps)
|
||||
bpy.utils.register_class(OBJECT_OT_CreateFurnitureProps)
|
||||
bpy.utils.unregister_class(OBJECT_OT_CreateActionProps)
|
||||
bpy.utils.unregister_class(OBJECT_OT_CreateFurnitureProps)
|
||||
bpy.utils.unregister_class(OBJECT_PT_ActionEmptyPanel)
|
||||
bpy.utils.unregister_class(OBJECT_OT_CreateChildEmpties)
|
||||
bpy.utils.unregister_class(OBJECT_OT_DestroyChildByProp)
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import bpy
|
||||
import os, sys, time
|
||||
import json
|
||||
from mathutils import Matrix, Quaternion
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
from mathutils import Matrix, Quaternion, Vector
|
||||
|
||||
argv = sys.argv
|
||||
argv = argv[argv.index("--") + 1:]
|
||||
|
||||
incpath = os.path.dirname(__file__)
|
||||
|
||||
sys.path.insert(0, incpath)
|
||||
outdir = argv[0]
|
||||
SUCCESS_OUTPUT = argv[1] if len(argv) > 1 else ""
|
||||
|
||||
basis_change = Matrix((
|
||||
(1, 0, 0, 0),
|
||||
@@ -17,180 +19,317 @@ basis_change = Matrix((
|
||||
(0, 0, 0, 1)
|
||||
))
|
||||
|
||||
def export_root_objects_to_gltf(output_dir):
|
||||
# Ensure the output directory exists
|
||||
if not os.path.exists(output_dir):
|
||||
os.makedirs(output_dir)
|
||||
LAYERS = {"normal", "thin-floor", "thin-wall", "ceiling"}
|
||||
ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
|
||||
|
||||
# Deselect all objects
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
|
||||
# Iterate through all objects in the scene
|
||||
for obj in bpy.context.scene.objects:
|
||||
# Check if the object has no parent and has children
|
||||
# The original request specifies "objects having no parent with children".
|
||||
# This condition captures those that are explicitly root objects of a hierarchy.
|
||||
if obj.parent is None:
|
||||
# Select the root object and all its children
|
||||
def round4(value):
|
||||
return round(float(value), 4)
|
||||
|
||||
print(obj.name)
|
||||
if not obj.name.startswith("furniture-"):
|
||||
continue
|
||||
desc = {}
|
||||
desc["name"] = obj.name.replace("furniture-","")
|
||||
desc["mesh"] = obj.name + ".glb"
|
||||
if "furniture_tags" in obj:
|
||||
mtags = obj["furniture_tags"]
|
||||
if "," in mtags:
|
||||
atags = [m.strip() for m in mtags.split(",")]
|
||||
desc["tags"] = atags
|
||||
else:
|
||||
desc["tags"] = [mtags]
|
||||
|
||||
def prop(root, name, default):
|
||||
return root[name] if name in root else default
|
||||
|
||||
|
||||
def stable_id(root):
|
||||
custom = str(prop(root, "furniture_id", "")).strip().lower()
|
||||
legacy_suffix = root.name.replace("furniture-", "")
|
||||
raw = custom or legacy_suffix.strip("-").lower()
|
||||
if not custom and legacy_suffix.startswith("-"):
|
||||
# Keep the accidental furniture--<name> asset addressable without
|
||||
# colliding with the canonical furniture-<name> root.
|
||||
raw += "-legacy"
|
||||
if not raw or not ID_PATTERN.match(raw):
|
||||
raise ValueError("invalid furniture id %r on %s" % (raw, root.name))
|
||||
return raw
|
||||
|
||||
|
||||
def parse_tags(value):
|
||||
# Existing blends use comma-separated custom-property strings. Also
|
||||
# accept semicolons and the legacy "tag. tag" typo, normalize case and
|
||||
# drop empty/duplicate entries while preserving order.
|
||||
result = []
|
||||
seen = set()
|
||||
for part in re.split(r"[,;]|\.\s+", str(value)):
|
||||
tag = part.strip().lower()
|
||||
if not tag:
|
||||
continue
|
||||
if not re.match(r"^[a-z0-9][a-z0-9._-]*$", tag):
|
||||
raise ValueError("invalid furniture tag %r" % tag)
|
||||
if tag not in seen:
|
||||
seen.add(tag)
|
||||
result.append(tag)
|
||||
return result
|
||||
|
||||
|
||||
def iter_descendants(root):
|
||||
stack = list(root.children)
|
||||
while stack:
|
||||
obj = stack.pop(0)
|
||||
yield obj
|
||||
stack.extend(obj.children)
|
||||
|
||||
|
||||
def is_collision_object(obj):
|
||||
# Existing assets also use "-col" for some VISUAL meshes, so only the
|
||||
# explicit collision-* prefix is excluded automatically.
|
||||
return obj.name.lower().split(".", 1)[0].startswith("collision-")
|
||||
|
||||
|
||||
def is_visual_mesh(obj):
|
||||
return obj.type == 'MESH' and not is_collision_object(obj)
|
||||
|
||||
|
||||
def bounds_in_root_space(root, objects):
|
||||
root_inv = root.matrix_world.inverted()
|
||||
minimum = None
|
||||
maximum = None
|
||||
for obj in objects:
|
||||
if len(obj.bound_box) == 0:
|
||||
continue
|
||||
matrix = root_inv @ obj.matrix_world
|
||||
for corner in obj.bound_box:
|
||||
local = basis_change @ (matrix @ Vector(corner))
|
||||
if minimum is None:
|
||||
minimum = local.copy()
|
||||
maximum = local.copy()
|
||||
else:
|
||||
desc["tags"] = []
|
||||
desc["sensors"] = []
|
||||
desc["actions"] = []
|
||||
desc["positions"] = []
|
||||
for child in obj.children:
|
||||
if child.name.startswith("action-"):
|
||||
if not "action" in child:
|
||||
continue
|
||||
if not "height" in child:
|
||||
continue
|
||||
if not "radius" in child:
|
||||
continue
|
||||
local_matrix = child.matrix_local
|
||||
ogre_local_matrix = basis_change @ local_matrix @ basis_change.inverted()
|
||||
local_pos_d = ogre_local_matrix.to_translation()
|
||||
local_rot_d = ogre_local_matrix.to_quaternion()
|
||||
local_pos = [round(x, 4) for x in local_pos_d]
|
||||
local_rot = [round(x, 4) for x in local_rot_d]
|
||||
action = {}
|
||||
action["position_x"] = local_pos[0]
|
||||
action["position_y"] = local_pos[1]
|
||||
action["position_z"] = local_pos[2]
|
||||
action["rotation_w"] = local_rot[0]
|
||||
action["rotation_x"] = local_rot[1]
|
||||
action["rotation_y"] = local_rot[2]
|
||||
action["rotation_z"] = local_rot[3]
|
||||
action["height"] = child["height"]
|
||||
action["radius"] = child["radius"]
|
||||
action["action"] = child["action"]
|
||||
if "action_text" in child:
|
||||
action["action_text"] = child["action_text"]
|
||||
else:
|
||||
action["action_text"] = child["action"].capitalize()
|
||||
if "name" in child:
|
||||
action["name"] = child["name"]
|
||||
else:
|
||||
action["name"] = desc["name"] + "_" + str(len(desc["actions"]))
|
||||
if "tags" in child:
|
||||
atags = child["tags"]
|
||||
if "," in atags:
|
||||
atags = [m.strip() for m in atags.split(",")]
|
||||
action["tags"] = atags
|
||||
else:
|
||||
action["tags"] = [atags]
|
||||
else:
|
||||
action["tags"] = []
|
||||
for k, v in child.items():
|
||||
if k.startswith("goap_prereq_"):
|
||||
action[k] = v
|
||||
elif k.startswith("goap_effect_"):
|
||||
action[k] = v
|
||||
action["furniture"] = {}
|
||||
action["furniture"]["name"] = desc["name"]
|
||||
action["furniture"]["tags"] = desc["tags"]
|
||||
action["positions"] = []
|
||||
for schild in child.children:
|
||||
if schild.name.startswith("position-"):
|
||||
local_matrix = schild.matrix_local
|
||||
ogre_local_matrix = basis_change @ local_matrix @ basis_change.inverted()
|
||||
local_pos_d = ogre_local_matrix.to_translation()
|
||||
local_rot_d = ogre_local_matrix.to_quaternion()
|
||||
local_pos = [round(x, 4) for x in local_pos_d]
|
||||
local_rot = [round(x, 4) for x in local_rot_d]
|
||||
position = {}
|
||||
if "name" in schild:
|
||||
position["name"] = schild["name"]
|
||||
if "type" in schild:
|
||||
position["type"] = schild["type"]
|
||||
position["position_x"] = local_pos[0]
|
||||
position["position_y"] = local_pos[1]
|
||||
position["position_z"] = local_pos[2]
|
||||
position["rotation_w"] = local_rot[0]
|
||||
position["rotation_x"] = local_rot[1]
|
||||
position["rotation_y"] = local_rot[2]
|
||||
position["rotation_z"] = local_rot[3]
|
||||
action["positions"].append(position)
|
||||
desc["actions"].append(action)
|
||||
if child.name.startswith("position-"):
|
||||
local_matrix = child.matrix_local
|
||||
ogre_local_matrix = basis_change @ local_matrix @ basis_change.inverted()
|
||||
local_pos_d = ogre_local_matrix.to_translation()
|
||||
local_rot_d = ogre_local_matrix.to_quaternion()
|
||||
local_pos = [round(x, 4) for x in local_pos_d]
|
||||
local_rot = [round(x, 4) for x in local_rot_d]
|
||||
position = {}
|
||||
if "name" in child:
|
||||
position["name"] = child["name"]
|
||||
if "type" in child:
|
||||
position["type"] = child["type"]
|
||||
position["position_x"] = local_pos[0]
|
||||
position["position_y"] = local_pos[1]
|
||||
position["position_z"] = local_pos[2]
|
||||
position["rotation_w"] = local_rot[0]
|
||||
position["rotation_x"] = local_rot[1]
|
||||
position["rotation_y"] = local_rot[2]
|
||||
position["rotation_z"] = local_rot[3]
|
||||
desc["positions"].append(position)
|
||||
minimum.x = min(minimum.x, local.x)
|
||||
minimum.y = min(minimum.y, local.y)
|
||||
minimum.z = min(minimum.z, local.z)
|
||||
maximum.x = max(maximum.x, local.x)
|
||||
maximum.y = max(maximum.y, local.y)
|
||||
maximum.z = max(maximum.z, local.z)
|
||||
if minimum is None:
|
||||
return None
|
||||
return ([round4(minimum.x), round4(minimum.y), round4(minimum.z)],
|
||||
[round4(maximum.x), round4(maximum.y), round4(maximum.z)])
|
||||
|
||||
obj.select_set(True)
|
||||
for child in obj.children_recursive:
|
||||
child.select_set(True)
|
||||
|
||||
obj.location = (0.0, 0.0, 0.0)
|
||||
|
||||
# Set the root object as the active object for the export operator
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
def transform_json(root, obj):
|
||||
local_matrix = root.matrix_world.inverted() @ obj.matrix_world
|
||||
ogre_local_matrix = basis_change @ local_matrix @ basis_change.inverted()
|
||||
local_pos = ogre_local_matrix.to_translation()
|
||||
local_rot = ogre_local_matrix.to_quaternion()
|
||||
return [round4(x) for x in local_pos], [round4(x) for x in local_rot]
|
||||
|
||||
# Define the output file path
|
||||
file_path = os.path.join(output_dir, f"{obj.name}.glb")
|
||||
|
||||
# Export the selected objects to a glTF file
|
||||
bpy.ops.export_scene.gltf(filepath=file_path,
|
||||
use_selection=True,
|
||||
check_existing=False,
|
||||
export_format='GLB',
|
||||
export_texture_dir='textures', export_texcoords=True,
|
||||
export_normals=True,
|
||||
export_tangents=True,
|
||||
export_materials='EXPORT',
|
||||
export_colors=True,
|
||||
use_mesh_edges=False,
|
||||
use_mesh_vertices=False,
|
||||
export_cameras=False,
|
||||
use_visible=False,
|
||||
use_renderable=False,
|
||||
export_yup=True,
|
||||
export_apply=True,
|
||||
export_animations=True,
|
||||
export_force_sampling=True,
|
||||
export_def_bones=False,
|
||||
export_current_frame=False,
|
||||
export_morph=True,
|
||||
export_morph_animation=False,
|
||||
export_morph_normal=True,
|
||||
export_morph_tangent=True,
|
||||
export_lights=False,
|
||||
export_skins=True)
|
||||
|
||||
# Deselect all objects for the next iteration
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
|
||||
print(f"Exported {obj.name} and its children to {file_path}")
|
||||
with open(file_path + ".json", "w") as json_data:
|
||||
json.dump(desc, json_data)
|
||||
def position_json(root, child):
|
||||
pos, rot = transform_json(root, child)
|
||||
position = {}
|
||||
if "name" in child:
|
||||
position["name"] = str(child["name"])
|
||||
if "type" in child:
|
||||
position["type"] = str(child["type"])
|
||||
position["position_x"] = pos[0]
|
||||
position["position_y"] = pos[1]
|
||||
position["position_z"] = pos[2]
|
||||
position["rotation_w"] = rot[0]
|
||||
position["rotation_x"] = rot[1]
|
||||
position["rotation_y"] = rot[2]
|
||||
position["rotation_z"] = rot[3]
|
||||
return position
|
||||
|
||||
|
||||
def action_json(root, child, furniture_id, furniture_tags):
|
||||
for required in ("action", "height", "radius"):
|
||||
if required not in child:
|
||||
raise ValueError("action %s on %s lacks %s" %
|
||||
(child.name, root.name, required))
|
||||
pos, rot = transform_json(root, child)
|
||||
height = float(child["height"])
|
||||
radius = float(child["radius"])
|
||||
if height <= 0.0 or radius <= 0.0:
|
||||
raise ValueError("action %s on %s has non-positive dimensions" %
|
||||
(child.name, root.name))
|
||||
|
||||
action = {}
|
||||
action["position_x"] = pos[0]
|
||||
action["position_y"] = pos[1]
|
||||
action["position_z"] = pos[2]
|
||||
action["rotation_w"] = rot[0]
|
||||
action["rotation_x"] = rot[1]
|
||||
action["rotation_y"] = rot[2]
|
||||
action["rotation_z"] = rot[3]
|
||||
action["height"] = height
|
||||
action["radius"] = radius
|
||||
action["action"] = str(child["action"])
|
||||
action["action_text"] = str(child.get("action_text",
|
||||
action["action"].capitalize()))
|
||||
action["name"] = str(child.get("name",
|
||||
furniture_id + "_" + str(len(root.children))))
|
||||
action["tags"] = parse_tags(child.get("tags", ""))
|
||||
for key, value in child.items():
|
||||
if key.startswith("goap_prereq_") or key.startswith("goap_effect_"):
|
||||
action[key] = value
|
||||
action["furniture"] = {"name": furniture_id, "tags": furniture_tags}
|
||||
action["positions"] = []
|
||||
for position in child.children:
|
||||
if position.name.startswith("position-"):
|
||||
action["positions"].append(position_json(child, position))
|
||||
return action
|
||||
|
||||
|
||||
def content_hash(desc):
|
||||
payload = json.dumps(desc, sort_keys=True, separators=(",", ":"),
|
||||
ensure_ascii=False)
|
||||
value = 14695981039346656037
|
||||
for byte in payload.encode("utf-8"):
|
||||
value ^= byte
|
||||
value = (value * 1099511628211) & 0xffffffffffffffff
|
||||
return "%016x" % value
|
||||
|
||||
|
||||
def export_root_objects_to_gltf(output_dir):
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
exported = []
|
||||
|
||||
for obj in bpy.context.scene.objects:
|
||||
if obj.parent is not None or not obj.name.startswith("furniture-"):
|
||||
continue
|
||||
|
||||
print(obj.name)
|
||||
furniture_id = stable_id(obj)
|
||||
tags = parse_tags(prop(obj, "furniture_tags", ""))
|
||||
layer = str(prop(obj, "layer", "normal")).strip().lower()
|
||||
if layer not in LAYERS:
|
||||
raise ValueError("%s has unknown layer %r" % (obj.name, layer))
|
||||
selection_weight = float(prop(obj, "selection_weight", 1.0))
|
||||
clearance = float(prop(obj, "clearance", 0.0))
|
||||
if selection_weight < 0.0 or clearance < 0.0:
|
||||
raise ValueError("%s has negative weight/clearance" % obj.name)
|
||||
footprint_x = int(prop(obj, "footprint_x", 0))
|
||||
footprint_z = int(prop(obj, "footprint_z", 0))
|
||||
if (footprint_x == 0) != (footprint_z == 0):
|
||||
raise ValueError("%s needs both footprint axes or neither" %
|
||||
obj.name)
|
||||
if footprint_x < 0 or footprint_z < 0:
|
||||
raise ValueError("%s has a negative footprint" % obj.name)
|
||||
|
||||
descendants = list(iter_descendants(obj))
|
||||
visual_meshes = [child for child in descendants if is_visual_mesh(child)]
|
||||
if not visual_meshes:
|
||||
raise ValueError("%s has no visual mesh children" % obj.name)
|
||||
collision_name = str(prop(obj, "collision_mesh", "")).strip()
|
||||
collision_meshes = [child for child in descendants
|
||||
if child.type == 'MESH' and
|
||||
(is_collision_object(child) or
|
||||
(collision_name and child.name == collision_name))]
|
||||
if collision_name and not collision_meshes:
|
||||
raise ValueError("%s references missing collision mesh %s" %
|
||||
(obj.name, collision_name))
|
||||
|
||||
desc = {
|
||||
"schemaVersion": 1,
|
||||
"id": furniture_id,
|
||||
# `name` is the legacy runtime identifier. Keep it equal to
|
||||
# the stable id so old FurnitureCell records continue to work.
|
||||
"name": furniture_id,
|
||||
"displayName": str(prop(obj, "display_name", furniture_id)),
|
||||
"mesh": obj.name + ".glb",
|
||||
"materialName": "M_Furniture_Atlas",
|
||||
"atlas": "furniture-atlas.png",
|
||||
"source": "blender",
|
||||
"layer": layer,
|
||||
"tags": tags,
|
||||
"anchor": [0.0, 0.0, 0.0],
|
||||
"requiresFloor": bool(prop(obj, "requires_floor", True)),
|
||||
"requiresWall": bool(prop(obj, "requires_wall", False)),
|
||||
"clearance": clearance,
|
||||
"weight": selection_weight,
|
||||
"sensors": [],
|
||||
"actions": [],
|
||||
"positions": [],
|
||||
}
|
||||
|
||||
legacy_suffix = obj.name.replace("furniture-", "")
|
||||
aliases = []
|
||||
if legacy_suffix != furniture_id:
|
||||
aliases.append(legacy_suffix)
|
||||
legacy_custom_name = str(obj.get("name", "")).strip()
|
||||
if legacy_custom_name and legacy_custom_name != furniture_id and \
|
||||
legacy_custom_name not in aliases:
|
||||
aliases.append(legacy_custom_name)
|
||||
if aliases:
|
||||
desc["aliases"] = aliases
|
||||
|
||||
bounds = bounds_in_root_space(obj, visual_meshes)
|
||||
if bounds:
|
||||
desc["bounds"] = {"min": bounds[0], "max": bounds[1]}
|
||||
collision_bounds = bounds_in_root_space(obj, collision_meshes)
|
||||
if collision_bounds:
|
||||
desc["collisionBounds"] = {"min": collision_bounds[0],
|
||||
"max": collision_bounds[1]}
|
||||
if footprint_x > 0:
|
||||
desc["footprintCells"] = [footprint_x, footprint_z]
|
||||
|
||||
for child in obj.children:
|
||||
if child.name.startswith("action-"):
|
||||
desc["actions"].append(
|
||||
action_json(obj, child, furniture_id, tags))
|
||||
elif child.name.startswith("position-"):
|
||||
desc["positions"].append(position_json(obj, child))
|
||||
|
||||
desc["sourceHash"] = content_hash(desc)
|
||||
|
||||
# Export only the visual hierarchy. action-/position- empties are
|
||||
# metadata and collision-* meshes are physics bounds, not render
|
||||
# content.
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
obj.select_set(True)
|
||||
for child in descendants:
|
||||
if child.name.startswith("action-") or \
|
||||
child.name.startswith("position-") or \
|
||||
is_collision_object(child):
|
||||
continue
|
||||
child.select_set(True)
|
||||
|
||||
obj.location = (0.0, 0.0, 0.0)
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
|
||||
file_path = os.path.join(output_dir, obj.name + ".glb")
|
||||
bpy.ops.export_scene.gltf(filepath=file_path,
|
||||
use_selection=True,
|
||||
check_existing=False,
|
||||
export_format='GLB',
|
||||
export_texture_dir='textures',
|
||||
export_texcoords=True,
|
||||
export_normals=True,
|
||||
export_tangents=True,
|
||||
export_materials='EXPORT',
|
||||
export_image_format='NONE',
|
||||
export_colors=True,
|
||||
use_mesh_edges=False,
|
||||
use_mesh_vertices=False,
|
||||
export_cameras=False,
|
||||
use_visible=False,
|
||||
use_renderable=False,
|
||||
export_yup=True,
|
||||
export_apply=True,
|
||||
export_animations=True,
|
||||
export_force_sampling=True,
|
||||
export_def_bones=False,
|
||||
export_current_frame=False,
|
||||
export_morph=True,
|
||||
export_morph_animation=False,
|
||||
export_morph_normal=True,
|
||||
export_morph_tangent=True,
|
||||
export_lights=False,
|
||||
export_skins=True)
|
||||
|
||||
with open(file_path + ".json", "w", newline="\n") as json_data:
|
||||
json.dump(desc, json_data, indent=2, sort_keys=True)
|
||||
json_data.write("\n")
|
||||
exported.append((furniture_id, file_path))
|
||||
print("Exported %s to %s" % (furniture_id, file_path))
|
||||
|
||||
if not exported:
|
||||
raise RuntimeError("no furniture-* root objects found in " +
|
||||
bpy.data.filepath)
|
||||
if SUCCESS_OUTPUT:
|
||||
with open(SUCCESS_OUTPUT, "w", newline="\n") as marker:
|
||||
marker.write("ok\n")
|
||||
return exported
|
||||
|
||||
|
||||
export_root_objects_to_gltf(outdir)
|
||||
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Write the furniture gallery's static HTML index (stdlib only)."""
|
||||
|
||||
import argparse
|
||||
import html
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--root", required=True)
|
||||
parser.add_argument("--atlas", default="../furniture-atlas.png")
|
||||
args = parser.parse_args()
|
||||
root = Path(args.root)
|
||||
entries = []
|
||||
for manifest in sorted(root.glob("manifest-*.json")):
|
||||
entries.extend(json.loads(manifest.read_text()))
|
||||
entries.sort(key=lambda entry: entry["id"])
|
||||
|
||||
rows = []
|
||||
for entry in entries:
|
||||
u0, u1, v0, v1 = entry["uv"]
|
||||
style = (
|
||||
"left:%.4f%%;top:%.4f%%;width:%.4f%%;height:%.4f%%" %
|
||||
(u0 * 100.0, (1.0 - v1) * 100.0,
|
||||
(u1 - u0) * 100.0, (v1 - v0) * 100.0))
|
||||
bounds = entry.get("bounds")
|
||||
bounds_text = "none"
|
||||
if bounds:
|
||||
bounds_text = "%s .. %s" % (bounds["min"], bounds["max"])
|
||||
rows.append(
|
||||
"<section class='asset'><h2>%s</h2>"
|
||||
"<p><b>Layer:</b> %s <b>Tags:</b> %s</p>"
|
||||
"<p class='bounds'><b>Bounds:</b> %s%s</p>"
|
||||
"<div class='views'>"
|
||||
"<div><h3>Asset / pivot / bounds / POIs</h3>"
|
||||
"<img class='render' src='%s'></div>"
|
||||
"<div><h3>Atlas UV region</h3>"
|
||||
"<div class='atlas'><img src='%s'>"
|
||||
"<div class='uv' style='%s'></div></div></div>"
|
||||
"</div></section>" %
|
||||
(html.escape(entry["id"]), html.escape(entry["layer"]),
|
||||
html.escape(", ".join(entry["tags"])),
|
||||
html.escape(bounds_text),
|
||||
" + collision proxy" if entry.get("collisionBounds") else "",
|
||||
html.escape(entry["render"]), html.escape(args.atlas), style))
|
||||
|
||||
page = """<!doctype html>
|
||||
<meta charset='utf-8'>
|
||||
<title>Furniture export gallery</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; background: #202124; color: #ddd;
|
||||
margin: 2em; }
|
||||
.asset { border-top: 1px solid #555; padding-top: 1em; margin-top: 2em; }
|
||||
.views { display: flex; gap: 2em; align-items: flex-start; }
|
||||
.views > div { flex: 1; min-width: 0; }
|
||||
.render { width: 100%; image-rendering: auto; background: #111; }
|
||||
.atlas { position: relative; width: 100%; }
|
||||
.atlas img { width: 100%; display: block; }
|
||||
.uv { position: absolute; border: 3px solid #f33; box-sizing: border-box;
|
||||
pointer-events: none; }
|
||||
.bounds { color: #aaa; font-size: 0.9em; }
|
||||
</style>
|
||||
<h1>Furniture export gallery</h1>
|
||||
<p>Generated from the baked Blender sources. Green = physical bounds,
|
||||
red = collision bounds, blue = occupancy cells, axes = pivot, spheres =
|
||||
action/position markers. The red rectangle on each atlas shows the
|
||||
asset's UV region.</p>
|
||||
""" + "\n".join(rows)
|
||||
(root / "index.html").write_text(page)
|
||||
print("Furniture gallery index: %d assets" % len(entries))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,79 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
|
||||
# Stack deterministic per-blend furniture atlas bakes into one shared atlas.
|
||||
# bake_furniture.py remaps each source's UVs into the matching vertical band.
|
||||
#
|
||||
# Usage:
|
||||
# blender --factory-startup -b -P merge_furniture_atlases.py -- \
|
||||
# <atlas.png> <atlas.material> <part0.png> [part1.png ...]
|
||||
|
||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
if len(argv) < 3:
|
||||
raise RuntimeError(
|
||||
"merge_furniture_atlases.py needs output png/material and inputs")
|
||||
|
||||
out_png = argv[0]
|
||||
out_material = argv[1]
|
||||
inputs = argv[2:]
|
||||
|
||||
images = []
|
||||
width = None
|
||||
part_height = None
|
||||
for path in inputs:
|
||||
if not os.path.exists(path):
|
||||
raise RuntimeError("missing furniture atlas part: " + path)
|
||||
image = bpy.data.images.load(path, check_existing=False)
|
||||
images.append((path, image))
|
||||
if width is None:
|
||||
width, part_height = image.size
|
||||
elif image.size[0] != width or image.size[1] != part_height:
|
||||
raise RuntimeError("atlas size mismatch in " + path)
|
||||
|
||||
if width is None or width <= 0 or part_height <= 0:
|
||||
raise RuntimeError("no furniture atlas parts supplied")
|
||||
|
||||
part_pixels = width * part_height * 4
|
||||
merged = []
|
||||
for path, image in images:
|
||||
pixels = list(image.pixels)
|
||||
if len(pixels) != part_pixels:
|
||||
raise RuntimeError("unexpected pixel count in " + path)
|
||||
coverage = sum(1 for i in range(3, len(pixels), 4) if pixels[i] > 0.0)
|
||||
if coverage == 0:
|
||||
raise RuntimeError("furniture atlas part is empty: " + path)
|
||||
merged.extend(pixels)
|
||||
|
||||
result = bpy.data.images.new("FurnitureColor", width=width,
|
||||
height=part_height * len(images), alpha=True)
|
||||
result.pixels = merged
|
||||
os.makedirs(os.path.dirname(out_png), exist_ok=True)
|
||||
os.makedirs(os.path.dirname(out_material), exist_ok=True)
|
||||
result.filepath_raw = out_png
|
||||
result.file_format = 'PNG'
|
||||
result.save()
|
||||
|
||||
material = """// Generated by merge_furniture_atlases.py; do not edit.
|
||||
material M_Furniture_Atlas
|
||||
{
|
||||
technique
|
||||
{
|
||||
pass
|
||||
{
|
||||
texture_unit
|
||||
{
|
||||
texture furniture-atlas.png
|
||||
filtering linear linear linear
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
with open(out_material, "w", newline="\n") as file:
|
||||
file.write(material)
|
||||
|
||||
print("Furniture atlas merged: %d source band(s), %dx%d -> %s" %
|
||||
(len(images), width, part_height * len(images), out_png))
|
||||
print("Furniture material written: " + out_material)
|
||||
@@ -0,0 +1,253 @@
|
||||
"""Render one preview image per furniture root plus a gallery manifest.
|
||||
|
||||
Invoke on a *_baked.blend from the furniture pipeline:
|
||||
blender -b furniture_baked.blend -Y -P render_furniture_gallery.py -- \
|
||||
<gallery-dir> <definition-dir>
|
||||
|
||||
The manifest is consumed by generate_furniture_gallery_index.py. Rendering
|
||||
uses Cycles CPU so it also works on headless build machines.
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
from mathutils import Vector
|
||||
|
||||
argv = sys.argv[sys.argv.index("--") + 1:]
|
||||
GALLERY_DIR = argv[0]
|
||||
DEFINITION_DIR = argv[1]
|
||||
os.makedirs(GALLERY_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def ogre_to_blender(v):
|
||||
return Vector((v[0], -v[2], v[1]))
|
||||
|
||||
|
||||
def material(name, color, emission=0.0):
|
||||
mat = bpy.data.materials.get(name)
|
||||
if mat:
|
||||
return mat
|
||||
mat = bpy.data.materials.new(name)
|
||||
mat.use_nodes = True
|
||||
bsdf = mat.node_tree.nodes.get("Principled BSDF")
|
||||
bsdf.inputs["Base Color"].default_value = (*color, 1.0)
|
||||
bsdf.inputs["Roughness"].default_value = 0.8
|
||||
if emission:
|
||||
bsdf.inputs["Emission"].default_value = (*color, 1.0)
|
||||
bsdf.inputs["Emission Strength"].default_value = emission
|
||||
return mat
|
||||
|
||||
|
||||
bounds_mat = material("GalleryBounds", (0.1, 0.8, 0.2), 1.0)
|
||||
collision_mat = material("GalleryCollision", (0.9, 0.1, 0.1), 1.0)
|
||||
cell_mat = material("GalleryCells", (0.1, 0.4, 0.9), 0.5)
|
||||
pivot_mats = [material("GalleryX", (1, 0, 0), 1.0),
|
||||
material("GalleryY", (0, 1, 0), 1.0),
|
||||
material("GalleryZ", (0, 0, 1), 1.0)]
|
||||
action_mat = material("GalleryAction", (1.0, 0.5, 0.05), 1.0)
|
||||
position_mat = material("GalleryPosition", (0.2, 0.6, 1.0), 1.0)
|
||||
floor_mat = material("GalleryFloor", (0.18, 0.18, 0.18))
|
||||
|
||||
scene = bpy.context.scene
|
||||
scene.render.engine = 'CYCLES'
|
||||
scene.cycles.device = 'CPU'
|
||||
scene.cycles.samples = 4
|
||||
scene.render.resolution_x = 480
|
||||
scene.render.resolution_y = 360
|
||||
scene.render.resolution_percentage = 100
|
||||
scene.render.image_settings.file_format = 'PNG'
|
||||
scene.render.film_transparent = False
|
||||
|
||||
# Neutral floor, camera and lights are shared by every render.
|
||||
bpy.ops.mesh.primitive_plane_add(size=200.0, location=(0, 0, -0.03))
|
||||
floor = bpy.context.object
|
||||
floor.name = "GalleryFloor"
|
||||
floor.data.materials.append(floor_mat)
|
||||
|
||||
bpy.ops.object.camera_add()
|
||||
camera = bpy.context.object
|
||||
scene.camera = camera
|
||||
camera.data.lens = 50
|
||||
|
||||
for name, location, energy, size in (
|
||||
("Key", (3, 4, 6), 600, 4),
|
||||
("Fill", (-4, 1, 3), 350, 4),
|
||||
("Rim", (0, -4, 4), 450, 3)):
|
||||
bpy.ops.object.light_add(type='AREA', location=location)
|
||||
lamp = bpy.context.object
|
||||
lamp.name = "Gallery" + name
|
||||
lamp.data.energy = energy
|
||||
lamp.data.size = size
|
||||
|
||||
|
||||
def hide_tree(obj, hide):
|
||||
obj.hide_render = hide
|
||||
for child in obj.children:
|
||||
hide_tree(child, hide)
|
||||
|
||||
|
||||
def line_object(name, points, mat, width=0.008):
|
||||
curve = bpy.data.curves.new(name, 'CURVE')
|
||||
curve.dimensions = '3D'
|
||||
curve.bevel_depth = width
|
||||
curve.bevel_resolution = 1
|
||||
spline = curve.splines.new('POLY')
|
||||
spline.points.add(len(points) - 1)
|
||||
for point, co in zip(spline.points, points):
|
||||
point.co = (*co, 1.0)
|
||||
obj = bpy.data.objects.new(name, curve)
|
||||
scene.collection.objects.link(obj)
|
||||
obj.data.materials.append(mat)
|
||||
return obj
|
||||
|
||||
|
||||
def box_edges(name, minimum, maximum, mat):
|
||||
corners = []
|
||||
for x in (minimum[0], maximum[0]):
|
||||
for y in (minimum[1], maximum[1]):
|
||||
for z in (minimum[2], maximum[2]):
|
||||
corners.append(Vector((x, y, z)))
|
||||
indices = [(0, 1), (0, 2), (0, 4), (1, 3), (1, 5), (2, 3),
|
||||
(2, 6), (3, 7), (4, 5), (4, 6), (5, 7), (6, 7)]
|
||||
for index, (a, b) in enumerate(indices):
|
||||
line_object(name + str(index), [corners[a], corners[b]], mat)
|
||||
|
||||
|
||||
def sphere(name, position, mat, radius):
|
||||
bpy.ops.mesh.primitive_uv_sphere_add(segments=12, ring_count=6,
|
||||
radius=radius, location=position)
|
||||
obj = bpy.context.object
|
||||
obj.name = name
|
||||
obj.data.materials.append(mat)
|
||||
return obj
|
||||
|
||||
|
||||
def uv_bounds(root):
|
||||
result = [1.0, 0.0, 1.0, 0.0]
|
||||
found = False
|
||||
stack = list(root.children)
|
||||
while stack:
|
||||
obj = stack.pop(0)
|
||||
stack.extend(obj.children)
|
||||
if obj.type != 'MESH' or obj.name.startswith("collision-"):
|
||||
continue
|
||||
for layer in obj.data.uv_layers:
|
||||
for loop in layer.data:
|
||||
u, v = loop.uv
|
||||
result[0] = min(result[0], u)
|
||||
result[1] = max(result[1], u)
|
||||
result[2] = min(result[2], v)
|
||||
result[3] = max(result[3], v)
|
||||
found = True
|
||||
return result if found else [0.0, 1.0, 0.0, 1.0]
|
||||
|
||||
|
||||
roots = [obj for obj in scene.objects
|
||||
if obj.parent is None and obj.name.startswith("furniture-")]
|
||||
manifest = []
|
||||
for root in sorted(roots, key=lambda obj: obj.name):
|
||||
definition_path = os.path.join(DEFINITION_DIR, root.name + ".glb.json")
|
||||
with open(definition_path) as source:
|
||||
definition = json.load(source)
|
||||
|
||||
# Definitions and GLBs are exported with the root at the origin;
|
||||
# use the same pivot here (many source roots have authoring offsets).
|
||||
root.location = (0.0, 0.0, 0.0)
|
||||
for other in roots:
|
||||
hide_tree(other, True)
|
||||
hide_tree(root, False)
|
||||
|
||||
helpers_start = set(scene.objects)
|
||||
bounds = definition.get("bounds")
|
||||
if bounds:
|
||||
minimum = ogre_to_blender(bounds["min"])
|
||||
maximum = ogre_to_blender(bounds["max"])
|
||||
# ogre_to_blender swaps Y/Z and negates one axis; reorder.
|
||||
low = Vector(tuple(min(a, b) for a, b in zip(minimum, maximum)))
|
||||
high = Vector(tuple(max(a, b) for a, b in zip(minimum, maximum)))
|
||||
box_edges("PhysicalBounds", low, high, bounds_mat)
|
||||
center = (low + high) * 0.5
|
||||
size = high - low
|
||||
else:
|
||||
center = Vector((0, 0, 0.5))
|
||||
size = Vector((1, 1, 1))
|
||||
|
||||
if "collisionBounds" in definition:
|
||||
collision = definition["collisionBounds"]
|
||||
minimum = ogre_to_blender(collision["min"])
|
||||
maximum = ogre_to_blender(collision["max"])
|
||||
low = Vector(tuple(min(a, b) for a, b in zip(minimum, maximum)))
|
||||
high = Vector(tuple(max(a, b) for a, b in zip(minimum, maximum)))
|
||||
box_edges("CollisionBounds", low, high, collision_mat)
|
||||
|
||||
# Pivot axes and occupancy-cell outlines at the root origin.
|
||||
line_object("PivotX", [(0, 0, 0), (0.35, 0, 0)], pivot_mats[0], 0.012)
|
||||
line_object("PivotY", [(0, 0, 0), (0, 0.35, 0)], pivot_mats[1], 0.012)
|
||||
line_object("PivotZ", [(0, 0, 0), (0, 0, 0.35)], pivot_mats[2], 0.012)
|
||||
if bounds:
|
||||
cell_size = 2.0
|
||||
bmin = bounds["min"]
|
||||
bmax = bounds["max"]
|
||||
min_cx = math.floor(bmin[0] / cell_size)
|
||||
min_cz = math.floor(bmin[2] / cell_size)
|
||||
max_cx = math.ceil((bmax[0] - 0.0001) / cell_size) - 1
|
||||
max_cz = math.ceil((bmax[2] - 0.0001) / cell_size) - 1
|
||||
for cx in range(min_cx, max_cx + 1):
|
||||
for cz in range(min_cz, max_cz + 1):
|
||||
points = [(cx * cell_size, -cz * cell_size, -0.01),
|
||||
((cx + 1) * cell_size, -cz * cell_size, -0.01),
|
||||
((cx + 1) * cell_size,
|
||||
-(cz + 1) * cell_size, -0.01),
|
||||
(cx * cell_size, -(cz + 1) * cell_size, -0.01),
|
||||
(cx * cell_size, -cz * cell_size, -0.01)]
|
||||
line_object("OccupancyCell", points, cell_mat, 0.006)
|
||||
|
||||
radius = max(size.length * 0.025, 0.025)
|
||||
for action in definition.get("actions", []):
|
||||
position = (action["position_x"], action["position_y"],
|
||||
action["position_z"])
|
||||
sphere("ActionMarker", ogre_to_blender(position), action_mat,
|
||||
radius)
|
||||
for marker in action.get("positions", []):
|
||||
position = (marker["position_x"], marker["position_y"],
|
||||
marker["position_z"])
|
||||
sphere("PositionMarker", ogre_to_blender(position),
|
||||
position_mat, radius * 0.7)
|
||||
for marker in definition.get("positions", []):
|
||||
position = (marker["position_x"], marker["position_y"],
|
||||
marker["position_z"])
|
||||
sphere("PositionMarker", ogre_to_blender(position),
|
||||
position_mat, radius * 0.7)
|
||||
|
||||
distance = max(size.length, 0.8) * 1.8
|
||||
camera.location = center + Vector((distance * 0.8,
|
||||
distance * 1.1,
|
||||
distance * 0.65))
|
||||
camera.rotation_euler = (center - camera.location).to_track_quat(
|
||||
'-Z', 'Y').to_euler()
|
||||
camera.data.lens = 45
|
||||
output_name = root.name + ".png"
|
||||
scene.render.filepath = os.path.join(GALLERY_DIR, output_name)
|
||||
bpy.ops.render.render(write_still=True)
|
||||
|
||||
manifest.append({
|
||||
"id": definition["id"],
|
||||
"render": output_name,
|
||||
"layer": definition.get("layer", "normal"),
|
||||
"tags": definition.get("tags", []),
|
||||
"bounds": definition.get("bounds"),
|
||||
"collisionBounds": definition.get("collisionBounds"),
|
||||
"uv": uv_bounds(root),
|
||||
})
|
||||
for helper in set(scene.objects) - helpers_start:
|
||||
bpy.data.objects.remove(helper, do_unlink=True)
|
||||
|
||||
with open(os.path.join(GALLERY_DIR,
|
||||
"manifest-" + os.path.basename(DEFINITION_DIR) +
|
||||
".json"),
|
||||
"w") as output:
|
||||
json.dump(manifest, output, indent=2)
|
||||
print("Furniture gallery: %d assets rendered" % len(manifest))
|
||||
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate generated furniture GLB/JSON artifacts after the CMake export.
|
||||
|
||||
Checks the phase 6.5 shared-atlas contract without starting OGRE:
|
||||
schema metadata exists, GLBs carry no embedded images, every material is the
|
||||
shared material, UVs stay in the owning source's vertical atlas band, and
|
||||
mesh variants sharing a Blender mesh datablock keep identical UV bounds.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
COMPONENT_FLOAT = 5126
|
||||
|
||||
|
||||
def fail(message):
|
||||
raise RuntimeError(message)
|
||||
|
||||
|
||||
def read_glb(path):
|
||||
data = path.read_bytes()
|
||||
if len(data) < 20 or data[:4] != b"glTF":
|
||||
fail(f"{path}: not a GLB")
|
||||
json_length, json_type = struct.unpack_from("<II", data, 12)
|
||||
if json_type != 0x4E4F534A:
|
||||
fail(f"{path}: first chunk is not JSON")
|
||||
document = json.loads(data[20:20 + json_length])
|
||||
offset = 20 + json_length
|
||||
bin_data = b""
|
||||
if offset + 8 <= len(data):
|
||||
bin_length, bin_type = struct.unpack_from("<II", data, offset)
|
||||
if bin_type == 0x004E4942:
|
||||
bin_data = data[offset + 8:offset + 8 + bin_length]
|
||||
return document, bin_data
|
||||
|
||||
|
||||
def accessor_floats(document, bin_data, accessor_index):
|
||||
accessor = document["accessors"][accessor_index]
|
||||
if accessor["componentType"] != COMPONENT_FLOAT:
|
||||
fail(f"accessor {accessor_index} is not float")
|
||||
view = document["bufferViews"][accessor["bufferView"]]
|
||||
offset = view.get("byteOffset", 0) + accessor.get("byteOffset", 0)
|
||||
components = {"VEC2": 2, "VEC3": 3, "VEC4": 4}[accessor["type"]]
|
||||
count = accessor["count"] * components
|
||||
return struct.unpack_from("<%df" % count, bin_data, offset)
|
||||
|
||||
|
||||
def uv_bounds(document, bin_data):
|
||||
result = []
|
||||
for mesh_index, mesh in enumerate(document.get("meshes", [])):
|
||||
mesh_name = mesh.get("name", f"mesh{mesh_index}")
|
||||
for primitive in mesh.get("primitives", []):
|
||||
accessor_index = primitive.get("attributes", {}).get("TEXCOORD_0")
|
||||
if accessor_index is None:
|
||||
continue
|
||||
values = accessor_floats(document, bin_data, accessor_index)
|
||||
us = values[::2]
|
||||
vs = values[1::2]
|
||||
result.append((mesh_name, min(us), max(us), min(vs), max(vs)))
|
||||
return result
|
||||
|
||||
|
||||
def validate_source(source_root, source_name, band_index, band_count):
|
||||
source_dir = source_root / source_name
|
||||
if not source_dir.is_dir():
|
||||
fail(f"missing output directory {source_dir}")
|
||||
json_paths = sorted(source_dir.glob("furniture-*.json"))
|
||||
if not json_paths:
|
||||
fail(f"no furniture definitions in {source_dir}")
|
||||
|
||||
shared_uv = {}
|
||||
min_v = 1.0 - float(band_index + 1) / band_count - 0.002
|
||||
max_v = 1.0 - float(band_index) / band_count + 0.002
|
||||
mesh_count = 0
|
||||
|
||||
for json_path in json_paths:
|
||||
definition = json.loads(json_path.read_text())
|
||||
if definition.get("source") != "blender":
|
||||
fail(f"{json_path}: source is not blender")
|
||||
if definition.get("materialName") != "M_Furniture_Atlas":
|
||||
fail(f"{json_path}: wrong materialName")
|
||||
if not definition.get("id") or not definition.get("mesh"):
|
||||
fail(f"{json_path}: missing id/mesh")
|
||||
if "bounds" not in definition:
|
||||
fail(f"{json_path}: missing physical bounds")
|
||||
glb_path = source_dir / definition["mesh"]
|
||||
if not glb_path.is_file():
|
||||
fail(f"{json_path}: referenced mesh is missing")
|
||||
|
||||
document, bin_data = read_glb(glb_path)
|
||||
if document.get("images"):
|
||||
fail(f"{glb_path}: embeds an image instead of using the shared atlas")
|
||||
materials = document.get("materials", [])
|
||||
if not materials or any(
|
||||
material.get("name") != "M_Furniture_Atlas"
|
||||
for material in materials):
|
||||
fail(f"{glb_path}: does not use only M_Furniture_Atlas")
|
||||
|
||||
for mesh_name, min_u, max_u, mesh_min_v, mesh_max_v in uv_bounds(
|
||||
document, bin_data):
|
||||
mesh_count += 1
|
||||
if mesh_min_v < min_v or mesh_max_v > max_v:
|
||||
fail(
|
||||
f"{glb_path}:{mesh_name}: UV V range "
|
||||
f"{mesh_min_v:.4f}..{mesh_max_v:.4f} escapes band "
|
||||
f"{min_v:.4f}..{max_v:.4f}")
|
||||
key = (source_name, mesh_name)
|
||||
bounds = (min_u, max_u, mesh_min_v, mesh_max_v)
|
||||
previous = shared_uv.get(key)
|
||||
if previous:
|
||||
if any(abs(a - b) > 0.0005 for a, b in zip(previous, bounds)):
|
||||
fail(
|
||||
f"{glb_path}:{mesh_name}: shared mesh UV bounds "
|
||||
f"changed between variants {previous} -> {bounds}")
|
||||
else:
|
||||
shared_uv[key] = bounds
|
||||
|
||||
return len(json_paths), mesh_count
|
||||
|
||||
|
||||
def main():
|
||||
args = sys.argv[1:]
|
||||
if len(args) < 6 or args[0] != "--root":
|
||||
fail("usage: validate_furniture_export.py --root <dir> "
|
||||
"--source <name> <band> <count> [...]")
|
||||
root = Path(args[1])
|
||||
atlas_png = root / "furniture-atlas.png"
|
||||
atlas_material = root / "furniture-atlas.material"
|
||||
if not atlas_png.is_file() or atlas_png.stat().st_size == 0:
|
||||
fail("shared furniture-atlas.png is missing/empty")
|
||||
if "M_Furniture_Atlas" not in atlas_material.read_text():
|
||||
fail("shared material does not define M_Furniture_Atlas")
|
||||
|
||||
definitions = 0
|
||||
meshes = 0
|
||||
i = 2
|
||||
while i < len(args):
|
||||
if args[i] != "--source" or i + 3 >= len(args):
|
||||
fail("malformed --source arguments")
|
||||
count, mesh_count = validate_source(root, args[i + 1],
|
||||
int(args[i + 2]),
|
||||
int(args[i + 3]))
|
||||
definitions += count
|
||||
meshes += mesh_count
|
||||
i += 4
|
||||
print(f"Furniture export validation: {definitions} definitions, "
|
||||
f"{meshes} mesh primitives, shared atlas OK")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,207 @@
|
||||
{
|
||||
"profileHash": "dd6901d4220ae704",
|
||||
"stems": {
|
||||
"baby-female": {
|
||||
"age": "baby",
|
||||
"animationSource": "/media/slapin/library/ogre3/ogre-projects/world2/build-vscode/assets/blender/vrm-vroid-normal-female.blend",
|
||||
"animationSourceHash": "6b258336b456f84f42536bef39e1902bca50cc2cb45aa7dbaa0550c83365f92d",
|
||||
"outputHashes": {
|
||||
"clothes-baby-female-bottom.blend": "25b6b8b5230a5e9d91e76b733ee96856ecea873a9bb118ca5905f8d21c6fe16f",
|
||||
"clothes-baby-female-feet.blend": "4477d51b38b4383849bc3871c2ce83056f01874b7f951e89f082c0a287b457d9",
|
||||
"clothes-baby-female-hair.blend": "77397a68bb1d1aef4d92a1a218390cc8ad1e453bf82fc7e3ba2f1d0d0adc6dc2",
|
||||
"clothes-baby-female-top.blend": "a47bc879db6791056b4646b4428d2017d531b4368b4c245898310e2997c717e7",
|
||||
"edited-normal-baby-female.blend": "0afd20804330a1179a7447c7d4f0bbeed90d5c669bfe82c771a1f6bc737e3dd7"
|
||||
},
|
||||
"profileHash": "dd6901d4220ae704",
|
||||
"report": "conversion-report-baby-female.md",
|
||||
"reportJson": "conversion-report-baby-female.json",
|
||||
"sex": "female",
|
||||
"sourceHashes": {
|
||||
"clothes-female-bottom.blend": "c7a5cff31917b176c53aab88d9b22b816ad684ff906d1af856e146a4e3167beb",
|
||||
"clothes-female-feet.blend": "f07ea8aa5145137297b3b556a63e93ba8effa87fb123abb99754925553e0ace1",
|
||||
"clothes-female-hair.blend": "d0aa11db762f9f6974949e4cc579f66c211d7fc913def2c77b71a0ad9199f7bc",
|
||||
"clothes-female-top.blend": "0115190c186d740ac54527dbba944e7b2f0f448f4fa6bff89f3aaba2a02d1439",
|
||||
"edited-normal-female.blend": "b62d4160656b57752cb97fc81c517007c689ad93c510ef2ebc869ac410d13b51"
|
||||
},
|
||||
"state": "auto-seed",
|
||||
"updated": 1793419320
|
||||
},
|
||||
"baby-male": {
|
||||
"age": "baby",
|
||||
"animationSource": "/media/slapin/library/ogre3/ogre-projects/world2/build-vscode/assets/blender/vrm-vroid-normal-male.blend",
|
||||
"animationSourceHash": "a495ad4c4f73bf9971c7f180facbca29d117e002b258380ac946274c5e7b9fee",
|
||||
"outputHashes": {
|
||||
"clothes-baby-male-bottom.blend": "648803be203afe6e1a01553c8f9373a1e136881eb8a0edd1bfe2f837e9bced1c",
|
||||
"clothes-baby-male-feet.blend": "8d3eb2c8c9053c953895508f2de03b6e3abbd23ceb53ebb2da2025b0be3a0a97",
|
||||
"clothes-baby-male-hair.blend": "3e74c20b81b9cb19b4d79a862a5793e7d06726ee9ea8de6d0daeb269b3d4bd94",
|
||||
"clothes-baby-male-top.blend": "124fd1ad4f22f9efc1c3061914a8a74ea1e328af76d064d9080dc171bf3e0ed3",
|
||||
"edited-normal-baby-male.blend": "68537022ec4087743d636336b00bf33be3ed2028d0006e4b0ce2847350ba8737"
|
||||
},
|
||||
"profileHash": "dd6901d4220ae704",
|
||||
"report": "conversion-report-baby-male.md",
|
||||
"reportJson": "conversion-report-baby-male.json",
|
||||
"sex": "male",
|
||||
"sourceHashes": {
|
||||
"clothes-male-bottom.blend": "950298780654bd908c15045d8afe572de755ddb1aa38e548c1dfb912b1cc059c",
|
||||
"clothes-male-feet.blend": "f720ce5cd154ae967673dbc2c6a93a25640dad56760586d205a18ec7b550e39c",
|
||||
"clothes-male-hair.blend": "2b267783d2f0dc991c7edd972b6c21b344abf3b5a46737c664912feae0ca9549",
|
||||
"clothes-male-top.blend": "4ff80187a33acc2a4f6228dd6492b84aee342b22a18c68d2f4c9be9fb15ec9cf",
|
||||
"edited-normal-male.blend": "4cba5e5da1e22eeedd6962d24edb1ef6ecf56c9b719c2f623ee3983c235560ff"
|
||||
},
|
||||
"state": "auto-seed",
|
||||
"updated": 1793419311
|
||||
},
|
||||
"child-female": {
|
||||
"age": "child",
|
||||
"animationSource": "/media/slapin/library/ogre3/ogre-projects/world2/build-vscode/assets/blender/vrm-vroid-normal-female.blend",
|
||||
"animationSourceHash": "6b258336b456f84f42536bef39e1902bca50cc2cb45aa7dbaa0550c83365f92d",
|
||||
"outputHashes": {
|
||||
"clothes-child-female-bottom.blend": "924f20a2edbb56e8e41376fcd8ad58e6c3533707fc8cf7750451ed35e811d853",
|
||||
"clothes-child-female-feet.blend": "900a21c055e9464030e0248941099acb6838dce72c9b4bef72df679427589167",
|
||||
"clothes-child-female-hair.blend": "3f1766cdcf6102fafe030b99daa2ae5a78023bc9c2f6146d8a4924faa2a3cd57",
|
||||
"clothes-child-female-top.blend": "3950729a7079297aff7aa2d4be255813eaf69348dee4688a61c470a2493a6f05",
|
||||
"edited-normal-child-female.blend": "f8f22f31a68b40088fefc1a037b4a9622aafad2b952d07f1ab85d45876bf9882"
|
||||
},
|
||||
"profileHash": "dd6901d4220ae704",
|
||||
"report": "conversion-report-child-female.md",
|
||||
"reportJson": "conversion-report-child-female.json",
|
||||
"sex": "female",
|
||||
"sourceHashes": {
|
||||
"clothes-female-bottom.blend": "c7a5cff31917b176c53aab88d9b22b816ad684ff906d1af856e146a4e3167beb",
|
||||
"clothes-female-feet.blend": "f07ea8aa5145137297b3b556a63e93ba8effa87fb123abb99754925553e0ace1",
|
||||
"clothes-female-hair.blend": "d0aa11db762f9f6974949e4cc579f66c211d7fc913def2c77b71a0ad9199f7bc",
|
||||
"clothes-female-top.blend": "0115190c186d740ac54527dbba944e7b2f0f448f4fa6bff89f3aaba2a02d1439",
|
||||
"edited-normal-female.blend": "b62d4160656b57752cb97fc81c517007c689ad93c510ef2ebc869ac410d13b51"
|
||||
},
|
||||
"state": "auto-seed",
|
||||
"updated": 1793419306
|
||||
},
|
||||
"child-male": {
|
||||
"age": "child",
|
||||
"animationSource": "/media/slapin/library/ogre3/ogre-projects/world2/build-vscode/assets/blender/vrm-vroid-normal-male.blend",
|
||||
"animationSourceHash": "a495ad4c4f73bf9971c7f180facbca29d117e002b258380ac946274c5e7b9fee",
|
||||
"outputHashes": {
|
||||
"clothes-child-male-bottom.blend": "631f0ae0796b29797716be04c0cc8940693a40f673a97d5f6fd219ea541302c3",
|
||||
"clothes-child-male-feet.blend": "2bed6d24379ec2b322884324d8faea6cd0b1ddc6f62f4bbc4f6396f7adff623b",
|
||||
"clothes-child-male-hair.blend": "6e02f2bbf3f06365d6cde9acbe178e869b417dcb1b163db0dad9e2999395e6bc",
|
||||
"clothes-child-male-top.blend": "8aef24cab77cb209af0393b5bf059e61d411d77236c27bbbd0b126bf63c9211c",
|
||||
"edited-normal-child-male.blend": "6d889491af59a9551f4237cd50fba43bdb1b0aeffbf2e6cc0d5e572da9ea6c53"
|
||||
},
|
||||
"profileHash": "dd6901d4220ae704",
|
||||
"report": "conversion-report-child-male.md",
|
||||
"reportJson": "conversion-report-child-male.json",
|
||||
"sex": "male",
|
||||
"sourceHashes": {
|
||||
"clothes-male-bottom.blend": "950298780654bd908c15045d8afe572de755ddb1aa38e548c1dfb912b1cc059c",
|
||||
"clothes-male-feet.blend": "f720ce5cd154ae967673dbc2c6a93a25640dad56760586d205a18ec7b550e39c",
|
||||
"clothes-male-hair.blend": "2b267783d2f0dc991c7edd972b6c21b344abf3b5a46737c664912feae0ca9549",
|
||||
"clothes-male-top.blend": "4ff80187a33acc2a4f6228dd6492b84aee342b22a18c68d2f4c9be9fb15ec9cf",
|
||||
"edited-normal-male.blend": "4cba5e5da1e22eeedd6962d24edb1ef6ecf56c9b719c2f623ee3983c235560ff"
|
||||
},
|
||||
"state": "auto-seed",
|
||||
"updated": 1793419297
|
||||
},
|
||||
"elder-female": {
|
||||
"age": "elder",
|
||||
"animationSource": "/media/slapin/library/ogre3/ogre-projects/world2/build-vscode/assets/blender/vrm-vroid-normal-female.blend",
|
||||
"animationSourceHash": "6b258336b456f84f42536bef39e1902bca50cc2cb45aa7dbaa0550c83365f92d",
|
||||
"outputHashes": {
|
||||
"clothes-elder-female-bottom.blend": "8b9c1be20d83b46ee33397283625e03b2e147c3e35eea5b9eb3b47696f4bea66",
|
||||
"clothes-elder-female-feet.blend": "69c1e995860e5678560cfe76e1349828e49d230d7ee3b4167767bb6708325da1",
|
||||
"clothes-elder-female-hair.blend": "fced301a4709adf9e8a05a17583b8879b0a9f154bd532ed567df9ce924f2859b",
|
||||
"clothes-elder-female-top.blend": "f783777345dfeb83811adc349a4b4f7c21bd5f3085f13c2b218637298f184bad",
|
||||
"edited-normal-elder-female.blend": "570ff96ebfa9005a3c21755603b9a4942ab8336461c7af74bdd970ca1871e9c2"
|
||||
},
|
||||
"profileHash": "dd6901d4220ae704",
|
||||
"report": "conversion-report-elder-female.md",
|
||||
"reportJson": "conversion-report-elder-female.json",
|
||||
"sex": "female",
|
||||
"sourceHashes": {
|
||||
"clothes-female-bottom.blend": "c7a5cff31917b176c53aab88d9b22b816ad684ff906d1af856e146a4e3167beb",
|
||||
"clothes-female-feet.blend": "f07ea8aa5145137297b3b556a63e93ba8effa87fb123abb99754925553e0ace1",
|
||||
"clothes-female-hair.blend": "d0aa11db762f9f6974949e4cc579f66c211d7fc913def2c77b71a0ad9199f7bc",
|
||||
"clothes-female-top.blend": "0115190c186d740ac54527dbba944e7b2f0f448f4fa6bff89f3aaba2a02d1439",
|
||||
"edited-normal-female.blend": "b62d4160656b57752cb97fc81c517007c689ad93c510ef2ebc869ac410d13b51"
|
||||
},
|
||||
"state": "auto-seed",
|
||||
"updated": 1793419277
|
||||
},
|
||||
"elder-male": {
|
||||
"age": "elder",
|
||||
"animationSource": "/media/slapin/library/ogre3/ogre-projects/world2/build-vscode/assets/blender/vrm-vroid-normal-male.blend",
|
||||
"animationSourceHash": "a495ad4c4f73bf9971c7f180facbca29d117e002b258380ac946274c5e7b9fee",
|
||||
"outputHashes": {
|
||||
"clothes-elder-male-bottom.blend": "1e5dcf7d41f8f3fe5c3716342a2f9d7163558492f8e4ecab4e37e7aaa0b6d6aa",
|
||||
"clothes-elder-male-feet.blend": "60af548ee9845a29df7021f92378032d961c28108503f39f9a97640bf80f1024",
|
||||
"clothes-elder-male-hair.blend": "f62f93f4464b41d1b27ed89a39d660e3aa1cc6ee1abfbe52c9e5620ffd838616",
|
||||
"clothes-elder-male-top.blend": "a8c422ae1dbcad1f4672578f68c7577b5dff198009968f0689e036090d485e67",
|
||||
"edited-normal-elder-male.blend": "93554b09a4653357b4b91682e27bdda988421ded9eca60902a530537c18c4d5f"
|
||||
},
|
||||
"profileHash": "dd6901d4220ae704",
|
||||
"report": "conversion-report-elder-male.md",
|
||||
"reportJson": "conversion-report-elder-male.json",
|
||||
"sex": "male",
|
||||
"sourceHashes": {
|
||||
"clothes-male-bottom.blend": "950298780654bd908c15045d8afe572de755ddb1aa38e548c1dfb912b1cc059c",
|
||||
"clothes-male-feet.blend": "f720ce5cd154ae967673dbc2c6a93a25640dad56760586d205a18ec7b550e39c",
|
||||
"clothes-male-hair.blend": "2b267783d2f0dc991c7edd972b6c21b344abf3b5a46737c664912feae0ca9549",
|
||||
"clothes-male-top.blend": "4ff80187a33acc2a4f6228dd6492b84aee342b22a18c68d2f4c9be9fb15ec9cf",
|
||||
"edited-normal-male.blend": "4cba5e5da1e22eeedd6962d24edb1ef6ecf56c9b719c2f623ee3983c235560ff"
|
||||
},
|
||||
"state": "auto-seed",
|
||||
"updated": 1793419267
|
||||
},
|
||||
"teenager-female": {
|
||||
"age": "teenager",
|
||||
"animationSource": "/media/slapin/library/ogre3/ogre-projects/world2/build-vscode/assets/blender/vrm-vroid-normal-female.blend",
|
||||
"animationSourceHash": "6b258336b456f84f42536bef39e1902bca50cc2cb45aa7dbaa0550c83365f92d",
|
||||
"outputHashes": {
|
||||
"clothes-teenager-female-bottom.blend": "77ff890f2edaa894014c88bf1e1047335182d166bc39ac48746c1dac3d374f1a",
|
||||
"clothes-teenager-female-feet.blend": "b2fed95f17586630e331642f1afa33a6d63ba1ed58758be68a5526082d520809",
|
||||
"clothes-teenager-female-hair.blend": "0c5a762e74f95bd7b10679fa986133247125e46cc33f9a08dfeeed47f3f7719b",
|
||||
"clothes-teenager-female-top.blend": "ce617dbb60927fa81a530019bfd0b822743ac57b7dd96d894c5144a2803e3335",
|
||||
"edited-normal-teenager-female.blend": "1cfb42a0086b0704a1eb9f17ee027ae01ccf4be5664f126aa7d776398b8e3f91"
|
||||
},
|
||||
"profileHash": "dd6901d4220ae704",
|
||||
"report": "conversion-report-teenager-female.md",
|
||||
"reportJson": "conversion-report-teenager-female.json",
|
||||
"sex": "female",
|
||||
"sourceHashes": {
|
||||
"clothes-female-bottom.blend": "c7a5cff31917b176c53aab88d9b22b816ad684ff906d1af856e146a4e3167beb",
|
||||
"clothes-female-feet.blend": "f07ea8aa5145137297b3b556a63e93ba8effa87fb123abb99754925553e0ace1",
|
||||
"clothes-female-hair.blend": "d0aa11db762f9f6974949e4cc579f66c211d7fc913def2c77b71a0ad9199f7bc",
|
||||
"clothes-female-top.blend": "0115190c186d740ac54527dbba944e7b2f0f448f4fa6bff89f3aaba2a02d1439",
|
||||
"edited-normal-female.blend": "b62d4160656b57752cb97fc81c517007c689ad93c510ef2ebc869ac410d13b51"
|
||||
},
|
||||
"state": "auto-seed",
|
||||
"updated": 1793419291
|
||||
},
|
||||
"teenager-male": {
|
||||
"age": "teenager",
|
||||
"animationSource": "/media/slapin/library/ogre3/ogre-projects/world2/build-vscode/assets/blender/vrm-vroid-normal-male.blend",
|
||||
"animationSourceHash": "a495ad4c4f73bf9971c7f180facbca29d117e002b258380ac946274c5e7b9fee",
|
||||
"outputHashes": {
|
||||
"clothes-teenager-male-bottom.blend": "9547a808429d71da27fdb11d58f69a2c18e6ad3f4fe78b5348dd25050133ad37",
|
||||
"clothes-teenager-male-feet.blend": "33d32f57c79f5a7b8e9c701c5e0aeffdd052a75b92ea7de234128c418e8be332",
|
||||
"clothes-teenager-male-hair.blend": "fc9c767afe4291c8a4634c2c1fc886c42d0136b905cc2288d3ca363a45957e99",
|
||||
"clothes-teenager-male-top.blend": "7b14973503c70681acbcaf66d7712a5e0ed9336875e4780d84ec749525624161",
|
||||
"edited-normal-teenager-male.blend": "d476061b1ee14df740bb052b82a2be5938246c05fcaf2df1dcded73ca1196daa"
|
||||
},
|
||||
"profileHash": "dd6901d4220ae704",
|
||||
"report": "conversion-report-teenager-male.md",
|
||||
"reportJson": "conversion-report-teenager-male.json",
|
||||
"sex": "male",
|
||||
"sourceHashes": {
|
||||
"clothes-male-bottom.blend": "950298780654bd908c15045d8afe572de755ddb1aa38e548c1dfb912b1cc059c",
|
||||
"clothes-male-feet.blend": "f720ce5cd154ae967673dbc2c6a93a25640dad56760586d205a18ec7b550e39c",
|
||||
"clothes-male-hair.blend": "2b267783d2f0dc991c7edd972b6c21b344abf3b5a46737c664912feae0ca9549",
|
||||
"clothes-male-top.blend": "4ff80187a33acc2a4f6228dd6492b84aee342b22a18c68d2f4c9be9fb15ec9cf",
|
||||
"edited-normal-male.blend": "4cba5e5da1e22eeedd6962d24edb1ef6ecf56c9b719c2f623ee3983c235560ff"
|
||||
},
|
||||
"state": "auto-seed",
|
||||
"updated": 1793419282
|
||||
}
|
||||
},
|
||||
"updated": 1793419320,
|
||||
"version": 1
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,358 @@
|
||||
# Creating Character Parts for the Game (Blender -> Engine)
|
||||
|
||||
This is the contract a character model must satisfy to work with the
|
||||
character pipeline: the catalog-driven modular body
|
||||
(`CharacterSlotSystem`), the deterministic NPC look assignment
|
||||
(`NpcPopulationSystem`), the animation trees, and the hair physics.
|
||||
Companion document for cars: `../vehicles/CAR_MODELING.md`.
|
||||
|
||||
The reference implementations are `edited-normal-male.blend` and
|
||||
`edited-normal-female.blend` (base bodies) plus the
|
||||
`clothes-<sex>-<slot>.blend` garment files in this directory.
|
||||
|
||||
## 1. Export pipeline
|
||||
|
||||
All steps are driven by `assets/blender/characters/CMakeLists.txt` and
|
||||
run in Blender batch mode; from the project root:
|
||||
|
||||
```bash
|
||||
cmake --build build-vscode --target edited-blends # base bodies + animations
|
||||
cmake --build build-vscode # full pipeline incl. clothes
|
||||
```
|
||||
|
||||
All Blender steps are wrapped in `${BLENDER_LOCKED}` (`flock -x
|
||||
${BLENDER_LOCK} <blender>`). This makes `-j` builds safe: Blender jobs
|
||||
are serialized because Blender 3.6 shares user config/addon state and the
|
||||
scripts touch common build files, while CMake copies/compiles in
|
||||
parallel. Configure fails if `flock` is missing. The lock defaults to
|
||||
`<build-dir>/blender-assets.lock`; pass the same `-DBLENDER_LOCK=...` to
|
||||
concurrent build trees. Never call `${BLENDER}` directly in a custom
|
||||
command; use `${BLENDER_LOCKED}`.
|
||||
|
||||
Canonical age bands are `baby`, `child`, `teenager`, `adult` and `elder`.
|
||||
Adult keeps the legacy stem `male`/`female`; every new band uses
|
||||
`<age>-<sex>` (for example `child-male`, `elder-female`). The new base
|
||||
bodies and their clothes are generated once from the adult assets by
|
||||
`generate_age_blends.py` + the `agegen/` package (rest-pose rig scaling,
|
||||
weighted mesh/shape-key deformation, garment/hair conversion and animation
|
||||
retargeting). The generated `.blend` files are committed seeds; the normal
|
||||
build consumes them as-is and never re-runs the generator or retargeting.
|
||||
See `AGES-PLAN.md` for the conversion/hand-off contract and
|
||||
`generate_age_blends.py --check` for seed verification.
|
||||
|
||||
Pipeline per stem (`male`, `female`, `baby-male`, `child-female`, ...):
|
||||
|
||||
1. `edited-normal-<stem>.blend` is the base body. For **adult**,
|
||||
`copy_animations.py` copies the Mixamo animation library
|
||||
(`assets/blender/mixamo/`) into it. New age seeds already contain
|
||||
retargeted actions and NLA tracks.
|
||||
2. Each `clothes-<age>-<sex>-<slot>.blend` (adult:
|
||||
`clothes-<sex>-<slot>.blend`) holds garment meshes for one slot.
|
||||
`process_clothes.py` fits them to the body (weight transfer from the
|
||||
reference body parts, seam fixes), `combine_clothes.py` bakes the
|
||||
combined meshes for garment combinations (see §3).
|
||||
3. `consolidate.py` appends everything into
|
||||
`edited-normal-<stem>-consolidated.blend` and leaves exactly one body
|
||||
armature.
|
||||
4. `export_models2.py` exports the consolidated blend through
|
||||
blender2ogre: one `.mesh` per catalog mesh object, the shared
|
||||
`.skeleton`, `.material` files, the prefixed texture atlases,
|
||||
a `.glb` preview, and one **`body_part_<mesh data name>.json`
|
||||
catalog entry per mesh object** carrying the required custom
|
||||
properties (see §2).
|
||||
|
||||
Outputs land in `build-vscode/characters/<stem>/` and are staged into
|
||||
the editScene runtime `characters/` directory (OGRE resource group
|
||||
`Characters`; every stem directory is registered as a flat resource
|
||||
location). The engine discovers catalog entries by scanning every
|
||||
resource group for `body_part_*.json` — no manual registration step.
|
||||
All resource basenames in the tree must be unique; textures are prefixed
|
||||
with `<stem>-` during export.
|
||||
|
||||
**blender2ogre names `.mesh` files after the mesh DATA name** (not the
|
||||
object name) — set both, and never reuse another blend's mesh data
|
||||
names: everything exports into the same directory and silently
|
||||
overwrites.
|
||||
|
||||
## 1.1 Age seeds (baby / child / teenager / elder)
|
||||
|
||||
`generate_age_blends.py` (with the `agegen/` package) creates first-pass
|
||||
seeds from the same-sex adult base and the adult wardrobe:
|
||||
|
||||
- the age rig keeps every adult bone name, hierarchy, roll and rest
|
||||
orientation; joints/segment lengths and radial thickness are scaled by
|
||||
`age_profiles.py`;
|
||||
- body, face, hair, clothes, helpers and shape keys are deformed by the
|
||||
weighted rest-pose matrices `D_i = F_age · S_i · F_adult⁻¹`;
|
||||
- animations are copied from the Mixamo library and their location/root
|
||||
tracks are scaled by the bone rest-offset ratios; rotations are untouched;
|
||||
- the result is written as `edited-normal-<age>-<sex>.blend` plus
|
||||
`clothes-<age>-<sex>-{top,bottom,feet,hair}.blend`, a conversion report
|
||||
and `AGE-GENERATION.json`.
|
||||
|
||||
Generated seeds are committed and marked `agegen_state = auto-seed`. The
|
||||
normal build must not run the generator or the retarget step again; the
|
||||
retarget step would double-scale root motion. Seeds with
|
||||
`agegen_state = hand-edited` (or a changed file hash) are protected from
|
||||
regeneration; use `--force` only when intentionally replacing them.
|
||||
`generate_age_blends.py --check` verifies the seed hashes and
|
||||
`validate_age_assets.py` verifies the built catalog/skeleton/resource tree.
|
||||
|
||||
The automatic seed is a **complete editable base**, not final art. Baby and
|
||||
child faces/silhouettes are expected to need a manual detail pass; the
|
||||
conversion report lists the first objects to inspect (stretch, seam,
|
||||
penetration, weight and animation issues).
|
||||
|
||||
|
||||
|
||||
`export_models2.py` writes a catalog JSON for every mesh object that
|
||||
carries all three required custom properties (Properties panel ->
|
||||
Object -> Custom Properties):
|
||||
|
||||
| Property | Type | Meaning |
|
||||
|----------|------|---------|
|
||||
| `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) |
|
||||
| `garments` | string | `;`-separated garment ids this mesh CONTAINS |
|
||||
| `clothing_tags` | string | `;`-separated extra tags |
|
||||
|
||||
`shape_keys` are collected automatically from the mesh data (the
|
||||
`Basis` key is excluded). Hair entries additionally get
|
||||
`own_skeleton: true` and `attach_to_bone: "mixamorig:Head"` (see §5).
|
||||
Garment ids are automatically duplicated into `tags`.
|
||||
|
||||
Resulting entry (`body_part_male_BodyTop_coat.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"age": "adult",
|
||||
"sex": "male",
|
||||
"slot": "top",
|
||||
"layer": 2,
|
||||
"garments": ["coat1"],
|
||||
"tags": ["coat1", "coat"],
|
||||
"shape_keys": ["neck-width-plus", "fat"],
|
||||
"mesh": "male_BodyTop_coat.mesh"
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Slots, layers and mesh resolution
|
||||
|
||||
The engine builds a character from one mesh per slot
|
||||
(`CharacterSlotSystem::buildCharacter`): the **face** mesh is the
|
||||
master (it carries the full body skeleton); every other slot's mesh
|
||||
shares that skeleton instance (`shareSkeletonInstanceWith`) — so every
|
||||
body/garment mesh must be skinned to the SAME armature
|
||||
(`mixamorig:` bone names) with compatible bind poses.
|
||||
|
||||
Resolution per slot (`resolveMesh`), given the record's
|
||||
`inlineOutfitLevel` and `inlineSlotSelections`:
|
||||
|
||||
- **outfitLevel 2 (dressed)**: the selected layer-2 mesh; when a
|
||||
layer-1 garment is also selected, a COMBINED mesh whose `garments`
|
||||
array contains both ids is preferred (e.g.
|
||||
`male_BodyBottom_Panties001_Pants0001.mesh` contains
|
||||
`panties0001;pants0001`). Combined meshes are baked by
|
||||
`combine_clothes.py` — a new garment only needs its own entry, the
|
||||
combinations are generated.
|
||||
- **outfitLevel 1 (underwear)**: the selected layer-1 mesh.
|
||||
- **outfitLevel 0 (nude)**: the layer-0 entry tagged
|
||||
**`detailed-nude`** when one exists for the slot (phase 6.C.1) —
|
||||
the plain layer-0 meshes are simplified composition bases meant to
|
||||
disappear under garments and lack anatomical detail. Author at most
|
||||
one `detailed-nude` variant per slot per sex (`top`/`bottom`/`feet`);
|
||||
slots without one keep the composition base, so the variants can be
|
||||
added one slot at a time. Tag it via `clothing_tags =
|
||||
"detailed-nude"` on a layer-0 mesh.
|
||||
- Final fallback: the shortest-named layer-0 entry (Blender `.001`
|
||||
duplicates are penalised), then any entry.
|
||||
|
||||
### Authoring a `detailed-nude` part (whole-body-part replacement)
|
||||
|
||||
A detailed-nude mesh is a COMPLETE standalone body part that replaces
|
||||
the simplified composition base at runtime when the character wears
|
||||
nothing. It is NOT built from the base part by the pipeline and no
|
||||
geometry is ever deleted from it:
|
||||
|
||||
- The hidden-geometry deletion (`combine_clothes.py`) only runs on
|
||||
copies of the base meshes a garment explicitly names in its
|
||||
`ref_part` property. No garment references your new mesh, so it
|
||||
passes through the pipeline untouched, and the plain base part must
|
||||
stay in the blend — garments still need it as their combination
|
||||
target.
|
||||
- The replacement happens at mesh-resolution time only: outfitLevel 0
|
||||
shows the detailed part, outfitLevel >= 1 goes back to the
|
||||
composition bases/combined meshes.
|
||||
|
||||
Steps:
|
||||
|
||||
1. Model the part as a NEW mesh object in the base body blend
|
||||
`edited-normal-<sex>.blend`, next to the existing base parts
|
||||
(`BodyTop`/`BodyBottom`/...). One detailed variant per slot per
|
||||
sex at most (`top`/`bottom`/`feet`); the engine takes the first
|
||||
`detailed-nude` entry it finds for the slot.
|
||||
2. Skin it to the SAME `mixamorig:` armature with the same bind pose,
|
||||
max 4 bone influences per vertex. (`consolidate.py` re-parents it
|
||||
to the armature named by its `sex` property and adds an Armature
|
||||
modifier if missing, but the weights are your job.)
|
||||
3. Match the boundary loops of the base part it replaces (neck, waist,
|
||||
wrists, ankles) vertex-for-vertex in position: the consolidate
|
||||
step's seam fixer unifies shape-key offsets and normals for
|
||||
vertices at identical positions across parts, so matching
|
||||
boundaries come out seamless; mismatched ones will show gaps
|
||||
against the face/hands/neighbouring slots.
|
||||
4. Copy the same shape keys (`fat`, ...) onto it — helper scripts in
|
||||
this directory: `transfer_shape_keys.py`,
|
||||
`propagate_shape_keys.py`, `add_missing_shape_keys.py`.
|
||||
5. Custom properties on the mesh OBJECT: `age`/`sex`/`slot` as usual,
|
||||
`layer` = 0, `clothing_tags` = `detailed-nude`, no `garments`.
|
||||
6. Give object AND mesh data a unique name (e.g.
|
||||
`male_BodyTop_detailed`); the mesh data name becomes the `.mesh`
|
||||
file name.
|
||||
7. Rebuild (`edited-blends` target or full build). No CMake or code
|
||||
changes are needed: consolidate/export discover the part by its
|
||||
custom properties and emit `body_part_<mesh data name>.json`
|
||||
automatically, and the runtime starts using it for nude characters
|
||||
of that slot.
|
||||
|
||||
The town population picks looks deterministically per NPC:
|
||||
`fnv1a(npcKey|slot)` indexes into the layer-1 hair list and (when
|
||||
`outfitLevel >= 2`, the public default) the layer-2 garment lists.
|
||||
Adding or reordering catalog entries changes some NPC looks — that is
|
||||
fine — but never make the catalog itself random: the same source
|
||||
assets must always produce the same town.
|
||||
|
||||
## 4. Skeleton, skinning and animation
|
||||
|
||||
- Skeleton: Mixamo hierarchy, bone names prefixed `mixamorig:`
|
||||
(139 bones). All body/garment parts share it; only hair has its own
|
||||
armature (§5).
|
||||
- **GLES2 hardware skinning**: the runtime injects RTSS hardware
|
||||
skinning (`weight_count 4`, per-part compact bone palette) into every
|
||||
character mesh, so a part may reference at most 4 bone influences per
|
||||
vertex and its compact palette must fit the GPU uniform budget — keep
|
||||
garment weights tight around the bones they actually need (the
|
||||
exporter's weight transfer already does this). Blend weights are
|
||||
repacked to zero-padded float4 at load, so 1-3 influence parts are
|
||||
fine. `EDITSCENE_NO_HWSKIN=1` falls back to software skinning for
|
||||
debugging.
|
||||
- **Facing**: character meshes face **+Z** (registry records carry
|
||||
`frontAxis = +Z`); the walk/run clips' root motion also translates
|
||||
along +Z. A part authored facing -Z moonwalks.
|
||||
- **Root motion**: locomotion uses root motion — the clip's root track
|
||||
must translate the character (do not bake in-place walks).
|
||||
- **Animations** live on the shared skeleton and are referenced by NAME
|
||||
from `animation_tree.json`. State names the trees expect:
|
||||
`idle`, `walking`, `running`, `driving` (-> `sitting-vehicle`),
|
||||
`swimming`, `swim-idle` (-> `treading_water`), `swimming-fast`,
|
||||
plus action states `sitting` / `sitting-ground`.
|
||||
`sitting-vehicle` is authored so the seat pin IS the character's base
|
||||
(root) position — butt/hands/feet land correctly with zero offset
|
||||
(see CAR_MODELING.md §5).
|
||||
- **Shape keys** (e.g. `fat`, `neck-width-plus`) export as OGRE poses;
|
||||
the registry record's `inlineShapeKeyWeights` drives them (pose index
|
||||
= catalog order, Basis excluded). Meshes are shared between
|
||||
characters: when a record has all-zero weights the runtime strips the
|
||||
mesh's poses for performance, so shape-keyed and plain characters
|
||||
must not force the same mesh through both paths in one session.
|
||||
|
||||
## 5. Hair
|
||||
|
||||
Hair meshes export with their OWN armature (`own_skeleton: true`) and
|
||||
attach to the master skeleton's `mixamorig:Head` bone. At runtime
|
||||
`HairPhysicsSystem` builds a Jolt ragdoll over the hair bone chain:
|
||||
the root body is pinned to the head, the rest simulates.
|
||||
|
||||
Modelling consequences:
|
||||
|
||||
- Keep the hair bone chain short and rooted at the scalp; the root
|
||||
bone's bind transform must sit on the head.
|
||||
- Hair needs no shape keys and no layer-2 interaction; it lives on
|
||||
layer 1 with a `hair` tag.
|
||||
- Distant LODs (§6) disable hair physics — hair must still look
|
||||
acceptable as a rigid head-attached mesh.
|
||||
|
||||
## 6. LOD scheme (phase 6.C.3/6.C.4 — implemented)
|
||||
|
||||
Ogre mesh LODs are NOT used for characters (skinned-mesh risk and they
|
||||
cannot merge parts); instead the pipeline generates mesh VARIANTS and
|
||||
`CharacterSlotSystem` swaps whole meshes by camera distance (2 m
|
||||
hysteresis on every boundary):
|
||||
|
||||
- **LOD0 (< 10 m)**: modular, full detail — the normal catalog parts.
|
||||
- **LOD1 (10-30 m)**: per-part `<mesh base>_lod1.mesh` variants — same
|
||||
geometry, shape keys removed, UVs remapped into one shared 2048x2048
|
||||
atlas (`charlod_<stem>-atlas.png`) with a single `charlod_<stem>`
|
||||
material per stem. Hair slots are skipped (hidden/impostor-baked at
|
||||
distance anyway). Parts without a `_lod1` variant keep their base
|
||||
mesh, so the scheme degrades gracefully.
|
||||
- **LOD2 (30-70 m)**: whole-character impostors
|
||||
`charlod_<stem>_thin_lod2.mesh` / `charlod_<stem>_fat_lod2.mesh` — the
|
||||
deterministic default outfit (per slot: highest layer, first entry by
|
||||
name) joined into ONE mesh on the shared armature, `fat` shape key
|
||||
baked at 0/1, hair rigid-bound to `mixamorig:Head`, atlas material.
|
||||
The impostor skin palette is pruned to the 23 core bones (Root,
|
||||
Hips, Spine/1/2, Neck, Head, Shoulder/Arm/ForeArm/Hand and
|
||||
UpLeg/Leg/Foot/ToeBase per side — no fingers): groups of dropped
|
||||
bones (fingers, eyes, bust, hair/coat-skirt physics chains) are
|
||||
remapped to the nearest kept ancestor and merged, keeping hardware
|
||||
skinning well inside the 64-bone budget. The shared skeleton and
|
||||
its animations are untouched, so no animation conversion is needed.
|
||||
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_<stem>_<variant>_lod3.mesh` —
|
||||
collapse-decimated LOD2, ~400 triangles (hard limit 400).
|
||||
- Interiors need only LOD0/LOD1.
|
||||
|
||||
Generation is automated in the export pipeline:
|
||||
`assets/blender/characters/generate_lods.py` runs per stem on the
|
||||
consolidated blend (CMake step `edited-normal-<stem>-lods.blend`),
|
||||
`export_models2.py` appends the result via its 6th argument and exports
|
||||
the LOD meshes with the SAME shared armature. A manifest
|
||||
(`charlod_<stem>.json`: atlas file + impostor mesh names) is written for
|
||||
reference; the runtime uses name conventions + `resourceExists`, not
|
||||
the manifest. `CharacterSlotSystem::prewarmShaders` also warms the
|
||||
`*_lod1.mesh` / `charlod_*.mesh` shaders.
|
||||
|
||||
Known simplifications (revisit in phase 10.5/11.5 if visible):
|
||||
impostor hair is rigid head-bound, the impostor outfit is the default
|
||||
per-slot pick regardless of the NPC's actual garments, and the atlas
|
||||
downscales each source texture into a grid cell (7 images -> 3x3 grid
|
||||
of ~682 px cells). Known issue: the atlas looks wrong in-game
|
||||
(likely a UV remap bug in generate_lods.py — reported 2026-10-25,
|
||||
deferred; check remap_mesh_uvs against the exported OGRE UV V
|
||||
convention first).
|
||||
|
||||
## 7. Character prefabs
|
||||
|
||||
Characters are saved as `prefabs/character.<id>.json` (phase 6.C.2 —
|
||||
the `character.` prefix distinguishes them from scene prefabs; the
|
||||
editor's prefab save writes the new prefix, old `char_<id>.json` paths
|
||||
migrate on save). A prefab carries the registry record reference,
|
||||
shape-key weights and slot selections — the meshes themselves always
|
||||
come from the catalog.
|
||||
|
||||
## 8. Verifying a new part
|
||||
|
||||
```bash
|
||||
# rebuild the character pipeline, then from the demo-taxi build dir:
|
||||
cd build-vscode/src/features/editScene/demos/demo-taxi
|
||||
xvfb-run -a ./demoTaxi --headless demo_scene_exterior.json \
|
||||
--force-pos 3444 12 3425 0 --print-detail 6000
|
||||
# prints spawned NPCs with their resolved master mesh / age / sex
|
||||
```
|
||||
|
||||
Check: the part appears on NPCs (deterministic pick — several NPCs may
|
||||
need to spawn before a new garment shows), no T-pose (animation tree
|
||||
bound), no floating/crumpling parts (skeleton mismatch — the runtime
|
||||
auto-detects and head-attaches, which hides but does not fix bad
|
||||
weights), shape keys apply on the player character sheet. The full
|
||||
contract suite:
|
||||
|
||||
```bash
|
||||
./demoTaxi --headless --test-motion
|
||||
./demoTaxi --headless --test-detail
|
||||
```
|
||||
@@ -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,55 +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
|
||||
"male"
|
||||
tmp-edited-male.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-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
|
||||
"female"
|
||||
tmp-edited-female.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}/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
|
||||
@@ -120,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
|
||||
@@ -146,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
|
||||
@@ -158,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
|
||||
@@ -180,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
|
||||
@@ -205,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}
|
||||
@@ -285,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}
|
||||
@@ -342,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}
|
||||
@@ -419,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.
|
||||
@@ -452,31 +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()
|
||||
|
||||
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})
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
"""Age-band profiles and naming helpers for the character pipeline.
|
||||
|
||||
This module is the single asset-side source of truth for the automatic
|
||||
adult -> age conversion. It is imported by the Blender scripts and by
|
||||
``generate_age_blends.py``; the C++ runtime mirrors the small amount of data
|
||||
it needs (names, age thresholds, fallback chains and body metrics).
|
||||
|
||||
Keep this file dependency-free (stdlib only) so it can be imported outside
|
||||
Blender for validation/tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
CANONICAL_AGES = ["baby", "child", "teenager", "adult", "elder"]
|
||||
|
||||
AGE_ALIASES = {
|
||||
"infant": "baby",
|
||||
"teen": "teenager",
|
||||
"adolescent": "teenager",
|
||||
"senior": "elder",
|
||||
"old": "elder",
|
||||
"old-age": "elder",
|
||||
}
|
||||
|
||||
# Canonical band boundaries used by the population reference rules.
|
||||
BABY_MAX_YEARS = 2
|
||||
CHILD_MAX_YEARS = 12
|
||||
TEEN_MAX_YEARS = 17
|
||||
ADULT_MAX_YEARS = 64
|
||||
|
||||
# Population rules' default ranges (mirrors TownPopulation::PopulationRules).
|
||||
DEFAULT_AGE_RULES = {
|
||||
"childMinYears": 1,
|
||||
"childMaxYears": 17,
|
||||
"adultMinYears": 18,
|
||||
"adultMaxYears": 64,
|
||||
"elderMinYears": 65,
|
||||
"elderMaxYears": 90,
|
||||
}
|
||||
|
||||
# Animation names every age skeleton must expose. The underlying clips may
|
||||
# differ per band (baby crawl etc.); the names are the animation-tree contract.
|
||||
REQUIRED_ANIMATIONS = [
|
||||
"default",
|
||||
"idle",
|
||||
"walking",
|
||||
"running",
|
||||
"sitting",
|
||||
"sitting-ground",
|
||||
"sitting-vehicle",
|
||||
"swimming",
|
||||
"treading_water",
|
||||
"swimming-fast",
|
||||
"jump",
|
||||
"left_turn",
|
||||
"right_turn",
|
||||
]
|
||||
|
||||
# Per-band conversion profile.
|
||||
#
|
||||
# Length/radial values are multipliers over the same-sex adult bone segments /
|
||||
# thickness. They are conversion targets, not medical proportions.
|
||||
# ``targetHeight`` is filled by sex below.
|
||||
PROFILES = {
|
||||
"baby": {
|
||||
"hipHeight": 0.28,
|
||||
"torso": 0.30,
|
||||
"head": 0.85,
|
||||
"shoulderWidth": 0.50,
|
||||
"arm": 0.30,
|
||||
"leg": 0.26,
|
||||
"foot": 0.55,
|
||||
"hipWidth": 0.55,
|
||||
"bodyRadial": 0.55,
|
||||
"limbRadial": 0.50,
|
||||
"headBulge": 1.08,
|
||||
"walkSpeedScale": 0.0, # home-only in v1
|
||||
},
|
||||
"child": {
|
||||
"hipHeight": 0.55,
|
||||
"torso": 0.62,
|
||||
"head": 0.92,
|
||||
"shoulderWidth": 0.70,
|
||||
"arm": 0.60,
|
||||
"leg": 0.55,
|
||||
"foot": 0.75,
|
||||
"hipWidth": 0.72,
|
||||
"bodyRadial": 0.78,
|
||||
"limbRadial": 0.72,
|
||||
"headBulge": 1.03,
|
||||
"walkSpeedScale": 0.75,
|
||||
},
|
||||
"teenager": {
|
||||
"hipHeight": 0.94,
|
||||
"torso": 0.92,
|
||||
"head": 0.99,
|
||||
"shoulderWidth": 0.92,
|
||||
"arm": 0.92,
|
||||
"leg": 0.94,
|
||||
"foot": 0.97,
|
||||
"hipWidth": 0.92,
|
||||
"bodyRadial": 0.94,
|
||||
"limbRadial": 0.93,
|
||||
"headBulge": 1.0,
|
||||
"walkSpeedScale": 0.95,
|
||||
},
|
||||
"adult": {
|
||||
"hipHeight": 1.0,
|
||||
"torso": 1.0,
|
||||
"head": 1.0,
|
||||
"shoulderWidth": 1.0,
|
||||
"arm": 1.0,
|
||||
"leg": 1.0,
|
||||
"foot": 1.0,
|
||||
"hipWidth": 1.0,
|
||||
"bodyRadial": 1.0,
|
||||
"limbRadial": 1.0,
|
||||
"headBulge": 1.0,
|
||||
"walkSpeedScale": 1.0,
|
||||
},
|
||||
# Elder v1 stays bind-identical to adult: aging is geometry/texture only.
|
||||
# The manual detail pass can scale the rig or posture later.
|
||||
"elder": {
|
||||
"hipHeight": 1.0,
|
||||
"torso": 1.0,
|
||||
"head": 1.0,
|
||||
"shoulderWidth": 1.0,
|
||||
"arm": 1.0,
|
||||
"leg": 1.0,
|
||||
"foot": 1.0,
|
||||
"hipWidth": 1.0,
|
||||
"bodyRadial": 1.0,
|
||||
"limbRadial": 1.0,
|
||||
"headBulge": 1.0,
|
||||
"walkSpeedScale": 1.0,
|
||||
},
|
||||
}
|
||||
|
||||
# Target mesh/skeleton heights in metres. Elder is adult height for the
|
||||
# bind-identical v1 seed; the detail pass can shorten it.
|
||||
TARGET_HEIGHT = {
|
||||
("baby", "male"): 0.72,
|
||||
("baby", "female"): 0.70,
|
||||
("child", "male"): 1.20,
|
||||
("child", "female"): 1.18,
|
||||
("teenager", "male"): 1.70,
|
||||
("teenager", "female"): 1.55,
|
||||
("adult", "male"): 1.85,
|
||||
("adult", "female"): 1.58,
|
||||
("elder", "male"): 1.85,
|
||||
("elder", "female"): 1.58,
|
||||
}
|
||||
|
||||
ADULT_HEIGHT = {"male": 1.85, "female": 1.58}
|
||||
|
||||
FALLBACK_CHAINS = {
|
||||
"baby": ["baby", "child", "adult"],
|
||||
"child": ["child", "baby", "teenager", "adult"],
|
||||
"teenager": ["teenager", "child", "adult"],
|
||||
"adult": ["adult", "teenager", "elder"],
|
||||
"elder": ["elder", "adult"],
|
||||
}
|
||||
|
||||
# Garment/body fit reporting thresholds.
|
||||
GARMENT_OFFSET = 0.003 # metres pushed out from the body surface
|
||||
QUALITY = {
|
||||
"seam_warn": 0.001,
|
||||
"seam_fail": 0.01,
|
||||
"penetration_warn": 0.01,
|
||||
"penetration_fail": 0.05,
|
||||
"height_tolerance": 0.05,
|
||||
"edge_residual_warn": 0.35, # |log(expected/actual)|-ish residual
|
||||
"edge_residual_fail": 0.70,
|
||||
}
|
||||
|
||||
|
||||
def normalize_age(age: str | None) -> str:
|
||||
if not age:
|
||||
return "adult"
|
||||
key = str(age).strip().lower()
|
||||
key = AGE_ALIASES.get(key, key)
|
||||
return key if key in CANONICAL_AGES else "adult"
|
||||
|
||||
|
||||
def age_band_for_years(years: int) -> str:
|
||||
if years <= BABY_MAX_YEARS:
|
||||
return "baby"
|
||||
if years <= CHILD_MAX_YEARS:
|
||||
return "child"
|
||||
if years <= TEEN_MAX_YEARS:
|
||||
return "teenager"
|
||||
if years <= ADULT_MAX_YEARS:
|
||||
return "adult"
|
||||
return "elder"
|
||||
|
||||
|
||||
def is_working_age(years: int) -> bool:
|
||||
return age_band_for_years(years) == "adult"
|
||||
|
||||
|
||||
def fallback_chain(age: str) -> list[str]:
|
||||
return list(FALLBACK_CHAINS[normalize_age(age)])
|
||||
|
||||
|
||||
def stem(age: str, sex: str) -> str:
|
||||
age = normalize_age(age)
|
||||
return sex if age == "adult" else f"{age}-{sex}"
|
||||
|
||||
|
||||
def armature_name(age: str, sex: str) -> str:
|
||||
return stem(age, sex)
|
||||
|
||||
|
||||
def mesh_prefix(age: str, sex: str) -> str:
|
||||
return stem(age, sex) + "_"
|
||||
|
||||
|
||||
def target_height(age: str, sex: str) -> float:
|
||||
age = normalize_age(age)
|
||||
return TARGET_HEIGHT[(age, sex)]
|
||||
|
||||
|
||||
def body_metrics(age: str, sex: str) -> dict:
|
||||
"""Capsule/head metrics scaled from the current adult defaults."""
|
||||
age = normalize_age(age)
|
||||
scale = target_height(age, sex) / ADULT_HEIGHT[sex]
|
||||
radius = 0.3 * scale
|
||||
height = 1.8 * scale
|
||||
return {
|
||||
"radius": radius,
|
||||
"height": height,
|
||||
"totalHeight": height + 2.0 * radius,
|
||||
"headRadius": 0.13 * scale,
|
||||
"headOffsetY": 0.0,
|
||||
"walkSpeedScale": PROFILES[age]["walkSpeedScale"],
|
||||
}
|
||||
|
||||
|
||||
def profile(age: str) -> dict:
|
||||
return dict(PROFILES[normalize_age(age)])
|
||||
|
||||
|
||||
def profile_hash() -> str:
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
payload = {
|
||||
"ages": CANONICAL_AGES,
|
||||
"profiles": PROFILES,
|
||||
"heights": {f"{a}|{s}": h for (a, s), h in TARGET_HEIGHT.items()},
|
||||
"animations": REQUIRED_ANIMATIONS,
|
||||
"quality": QUALITY,
|
||||
}
|
||||
return hashlib.sha256(
|
||||
json.dumps(payload, sort_keys=True).encode("utf-8")
|
||||
).hexdigest()[:16]
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Age-seed conversion helpers (Blender 3.6)."""
|
||||
|
||||
__all__ = [
|
||||
"profiles",
|
||||
"rig",
|
||||
"deform",
|
||||
"report",
|
||||
"anim",
|
||||
]
|
||||
@@ -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
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Weighted rest-pose deformation for body parts, clothes and shape keys."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import bpy
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
from . import profiles
|
||||
|
||||
|
||||
def _nearest_bone_distance(point: Vector, bone) -> float:
|
||||
a = bone.head_local
|
||||
b = bone.tail_local
|
||||
ab = b - a
|
||||
denom = ab.length_squared
|
||||
if denom <= 1e-12:
|
||||
return (point - a).length
|
||||
t = max(0.0, min(1.0, (point - a).dot(ab) / denom))
|
||||
return (point - (a + ab * t)).length
|
||||
|
||||
|
||||
def _nearest_bone_name(point: Vector, adult_arm) -> str:
|
||||
best = None
|
||||
best_d = 1e30
|
||||
for b in adult_arm.data.bones:
|
||||
if not b.use_deform:
|
||||
continue
|
||||
d = _nearest_bone_distance(point, b)
|
||||
if d < best_d:
|
||||
best_d = d
|
||||
best = b.name
|
||||
return best or "Root"
|
||||
|
||||
|
||||
def _group_name_map(obj) -> dict:
|
||||
return {i: g.name for i, g in enumerate(obj.vertex_groups)}
|
||||
|
||||
|
||||
def _vertex_matrix(v, group_names, dmatrices, adult_arm, obj_to_arm):
|
||||
"""Return (matrix, influence_count) for one mesh vertex."""
|
||||
acc = 0.0
|
||||
m = Matrix(((0.0, 0.0, 0.0, 0.0),
|
||||
(0.0, 0.0, 0.0, 0.0),
|
||||
(0.0, 0.0, 0.0, 0.0),
|
||||
(0.0, 0.0, 0.0, 0.0)))
|
||||
influences = 0
|
||||
for g in v.groups:
|
||||
w = float(g.weight)
|
||||
if w <= 0.0:
|
||||
continue
|
||||
name = group_names.get(g.group)
|
||||
dm = dmatrices.get(name)
|
||||
if dm is None:
|
||||
continue
|
||||
acc += w
|
||||
m = m + dm * w
|
||||
influences += 1
|
||||
if acc > 1e-9:
|
||||
return m * (1.0 / acc), influences
|
||||
# No usable weight: bind to the nearest deform bone.
|
||||
p_arm = obj_to_arm @ v.co
|
||||
name = _nearest_bone_name(p_arm, adult_arm)
|
||||
dm = dmatrices.get(name)
|
||||
return (dm.copy() if dm else Matrix.Identity(4)), 0
|
||||
|
||||
|
||||
def deform_mesh_object(obj, adult_arm, age_arm, dmatrices,
|
||||
profile: dict, retarget_parent=True):
|
||||
"""Deform one mesh and its shape keys in place.
|
||||
|
||||
Returns a metrics dict. ``retarget_parent`` re-parents the mesh to the
|
||||
age armature and points the armature modifier at it (used for the base
|
||||
body seed). Garment seeds are saved without a body armature link, so
|
||||
they pass ``False``.
|
||||
"""
|
||||
if obj.type != 'MESH':
|
||||
return None
|
||||
mesh = obj.data
|
||||
group_names = _group_name_map(obj)
|
||||
|
||||
obj_to_arm = adult_arm.matrix_world.inverted() @ obj.matrix_world
|
||||
arm_to_obj = obj.matrix_world.inverted() @ age_arm.matrix_world
|
||||
|
||||
before = [v.co.copy() for v in mesh.vertices]
|
||||
matrices = []
|
||||
unweighted = 0
|
||||
max_inf = 0
|
||||
for v in mesh.vertices:
|
||||
m, inf = _vertex_matrix(v, group_names, dmatrices, adult_arm,
|
||||
obj_to_arm)
|
||||
matrices.append(m)
|
||||
if inf == 0:
|
||||
unweighted += 1
|
||||
max_inf = max(max_inf, inf)
|
||||
|
||||
# Basis vertices.
|
||||
for i, v in enumerate(mesh.vertices):
|
||||
p = obj_to_arm @ v.co
|
||||
v.co = arm_to_obj @ (matrices[i] @ p)
|
||||
|
||||
# Non-Basis shape keys: absolute positions, same weights.
|
||||
key_count = 0
|
||||
if mesh.shape_keys:
|
||||
for kb in mesh.shape_keys.key_blocks:
|
||||
if kb.name == 'Basis':
|
||||
continue
|
||||
key_count += 1
|
||||
for i, kp in enumerate(kb.data):
|
||||
p = obj_to_arm @ kp.co
|
||||
kp.co = arm_to_obj @ (matrices[i] @ p)
|
||||
|
||||
mesh.update()
|
||||
try:
|
||||
mesh.calc_normals()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if retarget_parent:
|
||||
obj.parent = age_arm
|
||||
mod = next((m for m in obj.modifiers
|
||||
if m.type == 'ARMATURE'), None)
|
||||
if mod is None:
|
||||
mod = obj.modifiers.new(name="Armature", type='ARMATURE')
|
||||
mod.object = age_arm
|
||||
obj.matrix_parent_inverse = age_arm.matrix_world.inverted()
|
||||
|
||||
# Metrics.
|
||||
edge_ratios = []
|
||||
for e in mesh.edges:
|
||||
a, b = e.vertices
|
||||
lb = (before[a] - before[b]).length
|
||||
la = (mesh.vertices[a].co - mesh.vertices[b].co).length
|
||||
if lb > 1e-8:
|
||||
edge_ratios.append(la / lb)
|
||||
mesh.calc_loop_triangles()
|
||||
degenerate = 0
|
||||
for tri in mesh.loop_triangles:
|
||||
a, b, c = tri.vertices
|
||||
area = ((mesh.vertices[b].co - mesh.vertices[a].co).cross(
|
||||
mesh.vertices[c].co - mesh.vertices[a].co)).length * 0.5
|
||||
if area < 1e-10:
|
||||
degenerate += 1
|
||||
|
||||
return {
|
||||
"vertices": len(mesh.vertices),
|
||||
"polygons": len(mesh.polygons),
|
||||
"shapeKeys": key_count,
|
||||
"unweighted": unweighted,
|
||||
"maxInfluences": max_inf,
|
||||
"edgeRatioMin": min(edge_ratios) if edge_ratios else 1.0,
|
||||
"edgeRatioMax": max(edge_ratios) if edge_ratios else 1.0,
|
||||
"degenerateTris": degenerate,
|
||||
"before": before,
|
||||
}
|
||||
|
||||
|
||||
def deform_shape_keys_only(obj, adult_arm, age_arm, dmatrices):
|
||||
"""Transform shape keys without touching the basis (utility)."""
|
||||
return None
|
||||
|
||||
|
||||
def snap_seams(objects_with_metrics, max_report=10):
|
||||
"""Snap coincident boundary vertices across parts to their average.
|
||||
|
||||
``objects_with_metrics`` is a list of ``(obj, metrics)`` where metrics has
|
||||
the original vertex positions. Returns a report dict.
|
||||
"""
|
||||
buckets = {}
|
||||
for obj, metrics in objects_with_metrics:
|
||||
before = metrics.get("before")
|
||||
if not before:
|
||||
continue
|
||||
for i, p in enumerate(before):
|
||||
key = (round(p.x, 5), round(p.y, 5), round(p.z, 5))
|
||||
buckets.setdefault(key, []).append((obj, i, p))
|
||||
max_gap = 0.0
|
||||
snapped = 0
|
||||
examples = []
|
||||
for key, items in buckets.items():
|
||||
if len(items) < 2:
|
||||
continue
|
||||
positions = [obj.matrix_world @ obj.data.vertices[i].co
|
||||
for obj, i, _ in items]
|
||||
avg = Vector((0, 0, 0))
|
||||
for p in positions:
|
||||
avg += p
|
||||
avg /= len(positions)
|
||||
gap = max((p - avg).length for p in positions)
|
||||
if gap < 1e-6:
|
||||
continue
|
||||
max_gap = max(max_gap, gap)
|
||||
for obj, i, _ in items:
|
||||
delta = avg - (obj.matrix_world @ obj.data.vertices[i].co)
|
||||
if delta.length <= 1e-9:
|
||||
continue
|
||||
v = obj.data.vertices[i]
|
||||
v.co += obj.matrix_world.inverted().to_3x3() @ delta
|
||||
if obj.data.shape_keys:
|
||||
for kb in obj.data.shape_keys.key_blocks:
|
||||
if kb.name == 'Basis':
|
||||
continue
|
||||
kb.data[i].co += (obj.matrix_world.inverted().to_3x3()
|
||||
@ delta)
|
||||
snapped += 1
|
||||
if len(examples) < max_report:
|
||||
examples.append({
|
||||
"position": [round(v, 4) for v in key],
|
||||
"gap": round(gap, 6),
|
||||
"objects": sorted({obj.name for obj, _, _ in items}),
|
||||
})
|
||||
return {
|
||||
"coincidentGroups": sum(1 for v in buckets.values() if len(v) > 1),
|
||||
"snappedVertices": snapped,
|
||||
"maxGap": max_gap,
|
||||
"examples": examples,
|
||||
}
|
||||
|
||||
|
||||
def transform_hair_uniform(obj, adult_pivot: Vector, age_pivot: Vector,
|
||||
scale: float):
|
||||
"""Uniformly scale a hair mesh and move it from adult to age scalp."""
|
||||
obj_to_arm = obj.matrix_world
|
||||
inv = obj.matrix_world.inverted()
|
||||
for v in obj.data.vertices:
|
||||
p = obj_to_arm @ v.co
|
||||
v.co = inv @ (age_pivot + (p - adult_pivot) * scale)
|
||||
if obj.data.shape_keys:
|
||||
for kb in obj.data.shape_keys.key_blocks:
|
||||
if kb.name == 'Basis':
|
||||
continue
|
||||
for kp in kb.data:
|
||||
p = obj_to_arm @ kp.co
|
||||
kp.co = inv @ (age_pivot + (p - adult_pivot) * scale)
|
||||
obj.data.update()
|
||||
try:
|
||||
obj.data.calc_normals()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Re-export the asset-side age profile data for the converter package."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
_CHARACTERS_DIR = os.path.dirname(_THIS_DIR)
|
||||
if _CHARACTERS_DIR not in sys.path:
|
||||
sys.path.insert(0, _CHARACTERS_DIR)
|
||||
|
||||
import age_profiles as ap # noqa: E402,F401
|
||||
|
||||
CANONICAL_AGES = ap.CANONICAL_AGES
|
||||
PROFILES = ap.PROFILES
|
||||
REQUIRED_ANIMATIONS = ap.REQUIRED_ANIMATIONS
|
||||
normalize_age = ap.normalize_age
|
||||
age_band_for_years = ap.age_band_for_years
|
||||
fallback_chain = ap.fallback_chain
|
||||
stem = ap.stem
|
||||
armature_name = ap.armature_name
|
||||
mesh_prefix = ap.mesh_prefix
|
||||
target_height = ap.target_height
|
||||
body_metrics = ap.body_metrics
|
||||
profile = ap.profile
|
||||
profile_hash = ap.profile_hash
|
||||
@@ -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"
|
||||
@@ -0,0 +1,257 @@
|
||||
"""Age-rig construction and rest-pose transform data.
|
||||
|
||||
All matrices are in armature space. The age rig keeps the adult bone names,
|
||||
hierarchy, roll and orientation; only joint positions and segment lengths
|
||||
change. This is what makes rotation-only animation retargeting valid.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import bpy
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
from . import profiles
|
||||
|
||||
|
||||
def bone_region(name: str) -> dict:
|
||||
"""Return length/radial/offset profile keys for a Mixamo bone name."""
|
||||
n = name
|
||||
low = n.lower()
|
||||
|
||||
if n in ("Root", "mixamorig:Hips"):
|
||||
return {"length": "hipHeight", "radial": "bodyRadial",
|
||||
"offset": "hipHeight"}
|
||||
if "spine" in low:
|
||||
return {"length": "torso", "radial": "bodyRadial",
|
||||
"offset": "torso"}
|
||||
if "neck" in low:
|
||||
return {"length": "torso", "radial": "bodyRadial",
|
||||
"offset": "torso"}
|
||||
if ("head" in low or "jaw" in low or "eye" in low or
|
||||
"teeth" in low or "tongue" in low):
|
||||
return {"length": "head", "radial": "head", "offset": "head"}
|
||||
if "bust" in low:
|
||||
return {"length": "torso", "radial": "bodyRadial",
|
||||
"offset": "torso"}
|
||||
if "hair" in low or "j_sec_hair" in low:
|
||||
return {"length": "head", "radial": "head", "offset": "head"}
|
||||
if "sleeve" in low:
|
||||
return {"length": "arm", "radial": "limbRadial",
|
||||
"offset": "arm"}
|
||||
if "skirt" in low:
|
||||
return {"length": "leg", "radial": "bodyRadial",
|
||||
"offset": "hipWidth"}
|
||||
if "shoulder" in low:
|
||||
return {"length": "shoulderWidth", "radial": "limbRadial",
|
||||
"offset": "shoulderWidth"}
|
||||
if "upleg" in low or "upperleg" in low:
|
||||
return {"length": "leg", "radial": "limbRadial",
|
||||
"offset": "hipWidth"}
|
||||
if "leg" in low:
|
||||
return {"length": "leg", "radial": "limbRadial",
|
||||
"offset": "hipWidth"}
|
||||
if "foot" in low:
|
||||
return {"length": "foot", "radial": "limbRadial",
|
||||
"offset": "leg"}
|
||||
if "toe" in low:
|
||||
return {"length": "foot", "radial": "limbRadial",
|
||||
"offset": "foot"}
|
||||
if "arm" in low or "hand" in low or "thumb" in low or \
|
||||
"index" in low or "middle" in low or "ring" in low or \
|
||||
"pinky" in low:
|
||||
return {"length": "arm", "radial": "limbRadial",
|
||||
"offset": "arm"}
|
||||
return {"length": "torso", "radial": "bodyRadial", "offset": "torso"}
|
||||
|
||||
|
||||
def _depth(bone) -> int:
|
||||
d = 0
|
||||
p = bone.parent
|
||||
while p is not None:
|
||||
d += 1
|
||||
p = p.parent
|
||||
return d
|
||||
|
||||
|
||||
def _unique_name(existing, base):
|
||||
if base not in existing:
|
||||
return base
|
||||
i = 1
|
||||
while f"{base}.{i:03d}" in existing:
|
||||
i += 1
|
||||
return f"{base}.{i:03d}"
|
||||
|
||||
|
||||
def build_age_armature(adult_arm, age: str, sex: str,
|
||||
profile: dict | None = None,
|
||||
collection=None):
|
||||
"""Duplicate *adult_arm* and scale its rest pose into the age rig.
|
||||
|
||||
Returns the new armature object. The new object/data are named
|
||||
``<age>-<sex>`` (adult would keep the legacy sex name; this function is
|
||||
only used for non-adult seeds).
|
||||
"""
|
||||
age = profiles.normalize_age(age)
|
||||
profile = profile or profiles.profile(age)
|
||||
stem = profiles.stem(age, sex)
|
||||
|
||||
age_arm = adult_arm.copy()
|
||||
age_arm.data = adult_arm.data.copy()
|
||||
age_arm.name = stem
|
||||
age_arm.data.name = stem
|
||||
(collection or bpy.context.collection).objects.link(age_arm)
|
||||
|
||||
# Snapshot adult rest positions before entering edit mode.
|
||||
adult_head = {b.name: b.head_local.copy() for b in adult_arm.data.bones}
|
||||
adult_tail = {b.name: b.tail_local.copy() for b in adult_arm.data.bones}
|
||||
|
||||
bpy.context.view_layer.objects.active = age_arm
|
||||
age_arm.select_set(True)
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
ebones = age_arm.data.edit_bones
|
||||
|
||||
new_head = {}
|
||||
new_tail = {}
|
||||
order = sorted(ebones, key=_depth)
|
||||
for eb in order:
|
||||
old_h = adult_head[eb.name]
|
||||
old_t = adult_tail[eb.name]
|
||||
direction = old_t - old_h
|
||||
old_len = direction.length
|
||||
if old_len <= 1e-6:
|
||||
new_h = old_h.copy()
|
||||
new_t = old_t.copy()
|
||||
new_head[eb.name] = new_h
|
||||
new_tail[eb.name] = new_t
|
||||
continue
|
||||
direction.normalize()
|
||||
|
||||
region = bone_region(eb.name)
|
||||
lscale = float(profile[region["length"]])
|
||||
rscale = float(profile[region["radial"]])
|
||||
oscale = float(profile[region["offset"]])
|
||||
|
||||
if eb.parent is not None and eb.parent.name in new_head:
|
||||
pname = eb.parent.name
|
||||
if eb.use_connect:
|
||||
new_h = new_tail[pname].copy()
|
||||
else:
|
||||
parent_old_h = adult_head[pname]
|
||||
offset = old_h - parent_old_h
|
||||
new_h = new_head[pname] + offset * oscale
|
||||
else:
|
||||
# Root or a parent-less helper: keep the origin, scale nothing
|
||||
# laterally here (the profile handles the hips length).
|
||||
new_h = old_h.copy()
|
||||
|
||||
new_len = old_len * lscale
|
||||
new_t = new_h + direction * new_len
|
||||
|
||||
eb.head = new_h
|
||||
eb.tail = new_t
|
||||
# roll is intentionally untouched
|
||||
new_head[eb.name] = new_h.copy()
|
||||
new_tail[eb.name] = new_t.copy()
|
||||
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
age_arm.select_set(False)
|
||||
return age_arm
|
||||
|
||||
|
||||
def _bone_scale_matrix(lscale: float, rscale: float) -> Matrix:
|
||||
return Matrix.Diagonal((rscale, lscale, rscale, 1.0))
|
||||
|
||||
|
||||
def rest_transform_data(adult_arm, age_arm, age: str,
|
||||
profile: dict | None = None) -> dict:
|
||||
"""Build serializable rest-pose deformation data for every bone."""
|
||||
age = profiles.normalize_age(age)
|
||||
profile = profile or profiles.profile(age)
|
||||
out = {}
|
||||
for ab in adult_arm.data.bones:
|
||||
bb = age_arm.data.bones.get(ab.name)
|
||||
if bb is None:
|
||||
continue
|
||||
region = bone_region(ab.name)
|
||||
lscale = float(profile[region["length"]])
|
||||
rscale = float(profile[region["radial"]])
|
||||
f_adult = ab.matrix_local.copy()
|
||||
f_age = bb.matrix_local.copy()
|
||||
s = _bone_scale_matrix(lscale, rscale)
|
||||
d = f_age @ s @ f_adult.inverted()
|
||||
out[ab.name] = {
|
||||
"lengthScale": lscale,
|
||||
"radialScale": rscale,
|
||||
"fAdult": [list(row) for row in f_adult],
|
||||
"fAge": [list(row) for row in f_age],
|
||||
"dMatrix": [list(row) for row in d],
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def dmatrices_from_data(data: dict) -> dict:
|
||||
return {name: Matrix(entry["dMatrix"]) for name, entry in data.items()}
|
||||
|
||||
|
||||
def bone_length_ratios(adult_arm, age_arm) -> dict:
|
||||
out = {}
|
||||
for ab in adult_arm.data.bones:
|
||||
bb = age_arm.data.bones.get(ab.name)
|
||||
if bb is None:
|
||||
continue
|
||||
al = (ab.tail_local - ab.head_local).length
|
||||
bl = (bb.tail_local - bb.head_local).length
|
||||
out[ab.name] = (bl / al) if al > 1e-9 else 1.0
|
||||
return out
|
||||
|
||||
|
||||
def validate_rest_orientation(adult_arm, age_arm, tol=1e-4) -> list:
|
||||
"""Return a list of bones whose normalized rest direction changed."""
|
||||
bad = []
|
||||
for ab in adult_arm.data.bones:
|
||||
bb = age_arm.data.bones.get(ab.name)
|
||||
if bb is None:
|
||||
continue
|
||||
ad = (ab.tail_local - ab.head_local)
|
||||
bd = (bb.tail_local - bb.head_local)
|
||||
if ad.length < 1e-3 or bd.length < 1e-3:
|
||||
continue
|
||||
ad.normalize()
|
||||
bd.normalize()
|
||||
if (ad - bd).length > tol:
|
||||
bad.append(ab.name)
|
||||
return bad
|
||||
|
||||
|
||||
def head_pivots(adult_arm, age_arm, head_bone="mixamorig:Head"):
|
||||
ab = adult_arm.data.bones.get(head_bone)
|
||||
bb = age_arm.data.bones.get(head_bone)
|
||||
if not ab or not bb:
|
||||
return Vector((0, 0, 0)), Vector((0, 0, 0))
|
||||
return ab.head_local.copy(), bb.head_local.copy()
|
||||
|
||||
|
||||
def scale_hair_armature(hair_arm, adult_head_pivot: Vector,
|
||||
age_head_pivot: Vector, scale: float):
|
||||
"""Scale a hair armature uniformly from the adult head to the age head.
|
||||
|
||||
Hair armatures are rigidly attached to the body head at runtime; a uniform
|
||||
scale around the scalp preserves their own bone chain and animations.
|
||||
"""
|
||||
bpy.context.view_layer.objects.active = hair_arm
|
||||
hair_arm.select_set(True)
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
for eb in hair_arm.data.edit_bones:
|
||||
old_h = eb.head.copy()
|
||||
old_t = eb.tail.copy()
|
||||
eb.head = age_head_pivot + (old_h - adult_head_pivot) * scale
|
||||
eb.tail = age_head_pivot + (old_t - adult_head_pivot) * scale
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
hair_arm.select_set(False)
|
||||
|
||||
|
||||
def transform_point_uniform(p: Vector, adult_pivot: Vector,
|
||||
age_pivot: Vector, scale: float) -> Vector:
|
||||
return age_pivot + (p - adult_pivot) * scale
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Vertex-weight utilities for the age converter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
def limit_vertex_influences(obj, max_influences=4):
|
||||
"""Keep the strongest N weights per vertex and normalize them to 1."""
|
||||
if obj.type != 'MESH' or not obj.vertex_groups:
|
||||
return 0
|
||||
groups = {g.index: g for g in obj.vertex_groups}
|
||||
mesh = obj.data
|
||||
changed = 0
|
||||
for v in mesh.vertices:
|
||||
ws = [(g.group, float(g.weight)) for g in v.groups
|
||||
if g.weight > 0.0 and g.group in groups]
|
||||
if not ws:
|
||||
continue
|
||||
ws.sort(key=lambda p: p[1], reverse=True)
|
||||
keep = ws[:max_influences]
|
||||
drop = ws[max_influences:]
|
||||
total = sum(w for _, w in keep)
|
||||
if total <= 1e-9:
|
||||
continue
|
||||
for gi, _ in drop:
|
||||
groups[gi].remove([v.index])
|
||||
for gi, w in keep:
|
||||
groups[gi].add([v.index], w / total, 'REPLACE')
|
||||
changed += 1
|
||||
return changed
|
||||
|
||||
|
||||
def count_max_influences(obj):
|
||||
if obj.type != 'MESH':
|
||||
return 0
|
||||
best = 0
|
||||
for v in obj.data.vertices:
|
||||
best = max(best, sum(1 for g in v.groups if g.weight > 0.0))
|
||||
return best
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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"]
|
||||
|
||||
|
||||
@@ -7,6 +7,89 @@ _script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
if _script_dir not in sys.path:
|
||||
sys.path.insert(0, _script_dir)
|
||||
from transfer_shape_keys import fix_seams_across_objects, fix_normals_across_objects
|
||||
import age_profiles
|
||||
|
||||
|
||||
def _catalog_objects():
|
||||
required = ("age", "sex", "slot")
|
||||
return [o for o in bpy.data.objects
|
||||
if o.type == 'MESH' and all(p in o for p in required)]
|
||||
|
||||
|
||||
def _own_armature_names():
|
||||
names = set()
|
||||
for obj in _catalog_objects():
|
||||
if obj.get("has_own_armature", False):
|
||||
name = obj.get("own_armature_name", "")
|
||||
if name and bpy.data.objects.get(name):
|
||||
names.add(name)
|
||||
return names
|
||||
|
||||
|
||||
def consolidate_armatures():
|
||||
"""Leave exactly one body armature per consolidated blend.
|
||||
|
||||
The clothing stages append their own copy of the body rig; Blender
|
||||
renames those copies to .001/.002/... They are unused and previously
|
||||
leaked into the export as extra skeletons.
|
||||
"""
|
||||
cats = _catalog_objects()
|
||||
if not cats:
|
||||
print("WARNING: no catalog objects; cannot consolidate armatures")
|
||||
return
|
||||
|
||||
ages = {age_profiles.normalize_age(o.get("age", "adult"))
|
||||
for o in cats}
|
||||
sexes = {str(o.get("sex", "male")) for o in cats}
|
||||
if len(ages) != 1 or len(sexes) != 1:
|
||||
raise RuntimeError(
|
||||
f"mixed ages/sexes in one consolidated blend: "
|
||||
f"ages={sorted(ages)} sexes={sorted(sexes)}")
|
||||
age = next(iter(ages))
|
||||
sex = next(iter(sexes))
|
||||
expected = age_profiles.armature_name(age, sex)
|
||||
|
||||
body = bpy.data.objects.get(expected)
|
||||
if body is None or body.type != 'ARMATURE':
|
||||
# Choose the armature with the most catalog children / deform bones.
|
||||
best = None
|
||||
best_score = -1
|
||||
for arm in [o for o in bpy.data.objects if o.type == 'ARMATURE']:
|
||||
children = sum(1 for o in cats if o.parent == arm)
|
||||
score = children * 10000 + len(arm.data.bones)
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best = arm
|
||||
body = best
|
||||
if body is None:
|
||||
raise RuntimeError("no body armature found in consolidated blend")
|
||||
|
||||
body.name = expected
|
||||
body.data.name = expected
|
||||
own = _own_armature_names()
|
||||
|
||||
for obj in cats:
|
||||
if obj.get("has_own_armature", False):
|
||||
continue
|
||||
obj.parent = body
|
||||
arm_mod = next((m for m in obj.modifiers
|
||||
if m.type == 'ARMATURE'), None)
|
||||
if arm_mod is None:
|
||||
arm_mod = obj.modifiers.new(name="Armature", type='ARMATURE')
|
||||
arm_mod.object = body
|
||||
|
||||
removed = []
|
||||
for arm in [o for o in bpy.data.objects if o.type == 'ARMATURE']:
|
||||
if arm.name == body.name or arm.name in own:
|
||||
continue
|
||||
removed.append(arm.name)
|
||||
bpy.data.objects.remove(arm, do_unlink=True)
|
||||
if removed:
|
||||
print(f"Removed duplicate body armatures: {removed}")
|
||||
print(f"Consolidated body armature: '{body.name}' "
|
||||
f"({len(body.data.bones)} bones), "
|
||||
f"hair armatures kept: {sorted(own)}")
|
||||
|
||||
|
||||
def process_append(source_files, output_path):
|
||||
required_props = {"age", "sex", "slot"}
|
||||
@@ -92,7 +175,8 @@ def process_append(source_files, output_path):
|
||||
continue
|
||||
|
||||
# Normal body part: parent to body armature
|
||||
arm_name = obj.get("sex")
|
||||
arm_name = age_profiles.armature_name(
|
||||
obj.get("age", "adult"), obj.get("sex", "male"))
|
||||
arm_obj = bpy.data.objects.get(arm_name)
|
||||
|
||||
if arm_obj and arm_obj.type == 'ARMATURE':
|
||||
@@ -121,10 +205,14 @@ def process_append(source_files, output_path):
|
||||
else:
|
||||
bpy.data.objects.remove(obj, do_unlink=True)
|
||||
|
||||
# 4. Recursive Purge of all unlinked data (Materials, Textures, Meshes)
|
||||
# 4. Hardening: one body armature, no duplicate clothing-stage copies.
|
||||
print("\nConsolidating body armatures...")
|
||||
consolidate_armatures()
|
||||
|
||||
# 5. Recursive Purge of all unlinked data (Materials, Textures, Meshes)
|
||||
bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True)
|
||||
|
||||
# 5. Fix seams across ALL body parts in the consolidated scene.
|
||||
# 6. Fix seams across ALL body parts in the consolidated scene.
|
||||
print("\nFixing seams across all consolidated body parts...")
|
||||
fix_seams_across_objects()
|
||||
fix_normals_across_objects()
|
||||
|
||||
@@ -0,0 +1,638 @@
|
||||
{
|
||||
"age": "baby",
|
||||
"fails": [],
|
||||
"info": {
|
||||
"adultArmature": "female",
|
||||
"ageArmature": "baby-female",
|
||||
"animations": {
|
||||
"aliased": {
|
||||
"swimming-fast": "swimming"
|
||||
},
|
||||
"appended": [
|
||||
"Armature|mixamo.com|Layer0.020",
|
||||
"Armature|mixamo.com|Layer0.018",
|
||||
"Armature|mixamo.com|Layer0.016",
|
||||
"Armature|mixamo.com|Layer0.014"
|
||||
],
|
||||
"missing": [],
|
||||
"scaledLocationCurves": 7028,
|
||||
"stem": "baby-female"
|
||||
},
|
||||
"outputBase": "edited-normal-baby-female.blend",
|
||||
"slots": "bottom,face,feet,hair,top",
|
||||
"sourceBase": "edited-normal-female.blend"
|
||||
},
|
||||
"objects": {
|
||||
"Accessory": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.7372296166046396,
|
||||
"edgeRatioMin": 0.3150672796769405,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 40,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 69
|
||||
},
|
||||
"Body": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 2.153788134245814,
|
||||
"edgeRatioMin": 0.0842179761594005,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 6227,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 6578
|
||||
},
|
||||
"Body-merged": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 2.153788134245814,
|
||||
"edgeRatioMin": 0.07910523007157484,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 8423,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 9178
|
||||
},
|
||||
"BodyBottom": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.5943739947353248,
|
||||
"edgeRatioMin": 0.24871503022916608,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 1242,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 1194
|
||||
},
|
||||
"BodyFeet": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.6102128302187958,
|
||||
"edgeRatioMin": 0.23948013201029564,
|
||||
"maxInfluences": 3,
|
||||
"note": "",
|
||||
"polygons": 1280,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 1050
|
||||
},
|
||||
"BodyTop": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 2.153788134245814,
|
||||
"edgeRatioMin": 0.0842179761594005,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 3701,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 3354
|
||||
},
|
||||
"BodyTopRobe": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 2.153788134245814,
|
||||
"edgeRatioMin": 0.07910523007157484,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 4727,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 4383
|
||||
},
|
||||
"Body_shapes": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 2.153788134245814,
|
||||
"edgeRatioMin": 0.0842179761594005,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 6223,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 2,
|
||||
"unweighted": 0,
|
||||
"vertices": 5550
|
||||
},
|
||||
"Cylinder": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 102.90996675755198,
|
||||
"edgeRatioMin": 0.08829058454162445,
|
||||
"maxInfluences": 0,
|
||||
"note": "",
|
||||
"polygons": 595,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 840,
|
||||
"vertices": 840
|
||||
},
|
||||
"Face": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.8507524157231209,
|
||||
"edgeRatioMin": 0.8497737875813667,
|
||||
"maxInfluences": 1,
|
||||
"note": "",
|
||||
"polygons": 3887,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 57,
|
||||
"unweighted": 0,
|
||||
"vertices": 4109
|
||||
},
|
||||
"Hair": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.8500001569254199,
|
||||
"edgeRatioMin": 0.8499997162406219,
|
||||
"maxInfluences": 1,
|
||||
"note": "",
|
||||
"polygons": 2,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 8
|
||||
},
|
||||
"Hair001": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.8653125183616986,
|
||||
"edgeRatioMin": 0.8197160422662443,
|
||||
"maxInfluences": 1,
|
||||
"note": "",
|
||||
"polygons": 5658,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 8673
|
||||
},
|
||||
"Hair2": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.8653125183616986,
|
||||
"edgeRatioMin": 0.8197160422662443,
|
||||
"maxInfluences": 2,
|
||||
"note": "",
|
||||
"polygons": 5658,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 8673
|
||||
},
|
||||
"HairBack": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.4644834678498582,
|
||||
"edgeRatioMin": 0.6099711906323935,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 726,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 760
|
||||
},
|
||||
"OnepieceCloth": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.5492324553724546,
|
||||
"edgeRatioMin": 0.5290424031153619,
|
||||
"maxInfluences": 3,
|
||||
"note": "",
|
||||
"polygons": 2,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 6
|
||||
},
|
||||
"Shoes": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.5911305932486909,
|
||||
"edgeRatioMin": 0.2363519411318045,
|
||||
"maxInfluences": 3,
|
||||
"note": "",
|
||||
"polygons": 402,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 608
|
||||
},
|
||||
"Tops": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.7959426635498152,
|
||||
"edgeRatioMin": 0.07910523007157484,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 1026,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 1029
|
||||
},
|
||||
"bra1": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.850017759730551,
|
||||
"edgeRatioMin": 0.8499810981151088,
|
||||
"maxInfluences": 1,
|
||||
"note": "",
|
||||
"polygons": 430,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 419
|
||||
},
|
||||
"bra2": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.8500176808030321,
|
||||
"edgeRatioMin": 0.8499719662092197,
|
||||
"maxInfluences": 1,
|
||||
"note": "",
|
||||
"polygons": 506,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 524
|
||||
},
|
||||
"bra3": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.8500183886242741,
|
||||
"edgeRatioMin": 0.8499869218274825,
|
||||
"maxInfluences": 1,
|
||||
"note": "",
|
||||
"polygons": 410,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 450
|
||||
},
|
||||
"bra4": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.8500183886242741,
|
||||
"edgeRatioMin": 0.8499869218274825,
|
||||
"maxInfluences": 1,
|
||||
"note": "",
|
||||
"polygons": 410,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 450
|
||||
},
|
||||
"panties": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 389.90044750135564,
|
||||
"edgeRatioMin": 0.1344951283772418,
|
||||
"maxInfluences": 0,
|
||||
"note": "",
|
||||
"polygons": 674,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 797,
|
||||
"vertices": 797
|
||||
},
|
||||
"panties-full": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 9.037905091399436,
|
||||
"edgeRatioMin": 0.09492642747770344,
|
||||
"maxInfluences": 0,
|
||||
"note": "",
|
||||
"polygons": 337,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 425,
|
||||
"vertices": 425
|
||||
},
|
||||
"panties2": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 73.44866646636162,
|
||||
"edgeRatioMin": 0.039480502681632265,
|
||||
"maxInfluences": 0,
|
||||
"note": "",
|
||||
"polygons": 1266,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 1328,
|
||||
"vertices": 1328
|
||||
},
|
||||
"panties2-full": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 69.03029060930521,
|
||||
"edgeRatioMin": 0.12109997869211511,
|
||||
"maxInfluences": 0,
|
||||
"note": "",
|
||||
"polygons": 507,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 558,
|
||||
"vertices": 558
|
||||
},
|
||||
"panties3": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 56.096226750526704,
|
||||
"edgeRatioMin": 0.07317455801311457,
|
||||
"maxInfluences": 0,
|
||||
"note": "",
|
||||
"polygons": 758,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 820,
|
||||
"vertices": 820
|
||||
},
|
||||
"panties4": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 59.134504340321854,
|
||||
"edgeRatioMin": 0.07317455801311457,
|
||||
"maxInfluences": 0,
|
||||
"note": "",
|
||||
"polygons": 690,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 752,
|
||||
"vertices": 752
|
||||
},
|
||||
"panties5": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.8500240796533016,
|
||||
"edgeRatioMin": 0.8499814844536061,
|
||||
"maxInfluences": 1,
|
||||
"note": "",
|
||||
"polygons": 698,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 758
|
||||
},
|
||||
"panties6": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.8500154180994606,
|
||||
"edgeRatioMin": 0.8499882205989492,
|
||||
"maxInfluences": 1,
|
||||
"note": "",
|
||||
"polygons": 698,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 758
|
||||
},
|
||||
"panties7": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.8500126347595567,
|
||||
"edgeRatioMin": 0.849978243664894,
|
||||
"maxInfluences": 1,
|
||||
"note": "",
|
||||
"polygons": 698,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 758
|
||||
},
|
||||
"shoes1": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.5538312559806804,
|
||||
"edgeRatioMin": 0.26002334716847597,
|
||||
"maxInfluences": 3,
|
||||
"note": "",
|
||||
"polygons": 414,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 414
|
||||
},
|
||||
"skirt1": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.5322821714343262,
|
||||
"edgeRatioMin": 0.27078238670490873,
|
||||
"maxInfluences": 3,
|
||||
"note": "",
|
||||
"polygons": 132,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 154
|
||||
},
|
||||
"skirt2": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.5392819893864378,
|
||||
"edgeRatioMin": 0.2767377972392573,
|
||||
"maxInfluences": 3,
|
||||
"note": "",
|
||||
"polygons": 154,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 176
|
||||
},
|
||||
"skirt3": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.5319295543088763,
|
||||
"edgeRatioMin": 0.2734056437018691,
|
||||
"maxInfluences": 3,
|
||||
"note": "",
|
||||
"polygons": 132,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 154
|
||||
},
|
||||
"top1": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.8500186043293043,
|
||||
"edgeRatioMin": 0.8499912680582199,
|
||||
"maxInfluences": 1,
|
||||
"note": "",
|
||||
"polygons": 860,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 852
|
||||
},
|
||||
"top2": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.8500086443486258,
|
||||
"edgeRatioMin": 0.8499937873756263,
|
||||
"maxInfluences": 1,
|
||||
"note": "",
|
||||
"polygons": 876,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 854
|
||||
},
|
||||
"top3": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.8500086443486258,
|
||||
"edgeRatioMin": 0.8499937873756263,
|
||||
"maxInfluences": 1,
|
||||
"note": "",
|
||||
"polygons": 876,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 854
|
||||
}
|
||||
},
|
||||
"profileHash": "dd6901d4220ae704",
|
||||
"rig": {
|
||||
"adultBones": 252,
|
||||
"ageBones": 252,
|
||||
"profile": {
|
||||
"arm": 0.3,
|
||||
"bodyRadial": 0.55,
|
||||
"foot": 0.55,
|
||||
"head": 0.85,
|
||||
"headBulge": 1.08,
|
||||
"hipHeight": 0.28,
|
||||
"hipWidth": 0.55,
|
||||
"leg": 0.26,
|
||||
"limbRadial": 0.5,
|
||||
"shoulderWidth": 0.5,
|
||||
"torso": 0.3,
|
||||
"walkSpeedScale": 0.0
|
||||
},
|
||||
"restOrientationErrors": []
|
||||
},
|
||||
"seams": {
|
||||
"coincidentGroups": 3697,
|
||||
"examples": [
|
||||
{
|
||||
"gap": 1e-06,
|
||||
"objects": [
|
||||
"Face"
|
||||
],
|
||||
"position": [
|
||||
0.0649,
|
||||
0.0281,
|
||||
1.3797
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 1e-06,
|
||||
"objects": [
|
||||
"Face"
|
||||
],
|
||||
"position": [
|
||||
0.066,
|
||||
0.0231,
|
||||
1.3767
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 1e-06,
|
||||
"objects": [
|
||||
"Face"
|
||||
],
|
||||
"position": [
|
||||
0.0694,
|
||||
0.0172,
|
||||
1.3783
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 1e-06,
|
||||
"objects": [
|
||||
"Face"
|
||||
],
|
||||
"position": [
|
||||
-0.0649,
|
||||
0.0281,
|
||||
1.3797
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 1e-06,
|
||||
"objects": [
|
||||
"Face"
|
||||
],
|
||||
"position": [
|
||||
-0.066,
|
||||
0.0231,
|
||||
1.3767
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 1e-06,
|
||||
"objects": [
|
||||
"Face"
|
||||
],
|
||||
"position": [
|
||||
-0.0694,
|
||||
0.0172,
|
||||
1.3783
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 2e-06,
|
||||
"objects": [
|
||||
"Hair2"
|
||||
],
|
||||
"position": [
|
||||
0.0001,
|
||||
-0.0385,
|
||||
1.5529
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 2e-06,
|
||||
"objects": [
|
||||
"Hair2"
|
||||
],
|
||||
"position": [
|
||||
-0.0001,
|
||||
-0.0385,
|
||||
1.5529
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 3e-06,
|
||||
"objects": [
|
||||
"Hair2"
|
||||
],
|
||||
"position": [
|
||||
-0.0012,
|
||||
-0.0342,
|
||||
1.5629
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 3e-06,
|
||||
"objects": [
|
||||
"Hair2"
|
||||
],
|
||||
"position": [
|
||||
-0.001,
|
||||
-0.0343,
|
||||
1.5628
|
||||
]
|
||||
}
|
||||
],
|
||||
"maxGap": 5.9825688860243066e-06,
|
||||
"snappedVertices": 95
|
||||
},
|
||||
"sex": "female",
|
||||
"status": "warn",
|
||||
"stem": "baby-female",
|
||||
"timestamp": 1793419311,
|
||||
"warnings": [
|
||||
"Accessory",
|
||||
"Body",
|
||||
"Body-merged",
|
||||
"Body_shapes",
|
||||
"BodyBottom",
|
||||
"BodyFeet",
|
||||
"BodyTop",
|
||||
"BodyTopRobe",
|
||||
"Shoes",
|
||||
"Tops",
|
||||
"BodyTop",
|
||||
"BodyBottom",
|
||||
"Cylinder",
|
||||
"panties",
|
||||
"panties-full",
|
||||
"panties2",
|
||||
"panties2-full",
|
||||
"panties3",
|
||||
"panties4",
|
||||
"skirt1",
|
||||
"skirt2",
|
||||
"skirt3",
|
||||
"BodyFeet",
|
||||
"shoes1"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
# Conversion Report — baby / female
|
||||
|
||||
- Stem: `baby-female`
|
||||
- Status: **warn**
|
||||
- Profile hash: `dd6901d4220ae704`
|
||||
- Objects: 37
|
||||
- Warnings: 24
|
||||
- Fails: 0
|
||||
|
||||
## Info
|
||||
- adultArmature: `female`
|
||||
- ageArmature: `baby-female`
|
||||
- animations: `{'scaledLocationCurves': 7028, 'appended': ['Armature|mixamo.com|Layer0.020', 'Armature|mixamo.com|Layer0.018', 'Armature|mixamo.com|Layer0.016', 'Armature|mixamo.com|Layer0.014'], 'aliased': {'swimming-fast': 'swimming'}, 'missing': [], 'stem': 'baby-female'}`
|
||||
- outputBase: `edited-normal-baby-female.blend`
|
||||
- slots: `bottom,face,feet,hair,top`
|
||||
- sourceBase: `edited-normal-female.blend`
|
||||
|
||||
## Seams
|
||||
- coincidentGroups: `3697`
|
||||
- snappedVertices: `95`
|
||||
- maxGap: `5.9825688860243066e-06`
|
||||
- examples: `[{'position': [0.0649, 0.0281, 1.3797], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.066, 0.0231, 1.3767], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0694, 0.0172, 1.3783], 'gap': 1e-06, 'objects': ['Face']}, {'position': [-0.0649, 0.0281, 1.3797], 'gap': 1e-06, 'objects': ['Face']}, {'position': [-0.066, 0.0231, 1.3767], 'gap': 1e-06, 'objects': ['Face']}, {'position': [-0.0694, 0.0172, 1.3783], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0001, -0.0385, 1.5529], 'gap': 2e-06, 'objects': ['Hair2']}, {'position': [-0.0001, -0.0385, 1.5529], 'gap': 2e-06, 'objects': ['Hair2']}, {'position': [-0.0012, -0.0342, 1.5629], 'gap': 3e-06, 'objects': ['Hair2']}, {'position': [-0.001, -0.0343, 1.5628], 'gap': 3e-06, 'objects': ['Hair2']}]`
|
||||
|
||||
## Warnings (first 20)
|
||||
|
||||
- `Accessory`
|
||||
- `Body`
|
||||
- `Body-merged`
|
||||
- `Body_shapes`
|
||||
- `BodyBottom`
|
||||
- `BodyFeet`
|
||||
- `BodyTop`
|
||||
- `BodyTopRobe`
|
||||
- `Shoes`
|
||||
- `Tops`
|
||||
- `BodyTop`
|
||||
- `BodyBottom`
|
||||
- `Cylinder`
|
||||
- `panties`
|
||||
- `panties-full`
|
||||
- `panties2`
|
||||
- `panties2-full`
|
||||
- `panties3`
|
||||
- `panties4`
|
||||
- `skirt1`
|
||||
|
||||
## Objects
|
||||
|
||||
- `Accessory` — warn; verts=69, tris=0 degenerate, influences=4, unweighted=0, edge=0.32..0.74
|
||||
- `Body` — warn; verts=6578, tris=0 degenerate, influences=4, unweighted=0, edge=0.08..2.15
|
||||
- `Body-merged` — warn; verts=9178, tris=0 degenerate, influences=4, unweighted=0, edge=0.08..2.15
|
||||
- `BodyBottom` — warn; verts=1194, tris=0 degenerate, influences=4, unweighted=0, edge=0.25..0.59
|
||||
- `BodyFeet` — warn; verts=1050, tris=0 degenerate, influences=3, unweighted=0, edge=0.24..0.61
|
||||
- `BodyTop` — warn; verts=3354, tris=0 degenerate, influences=4, unweighted=0, edge=0.08..2.15
|
||||
- `BodyTopRobe` — warn; verts=4383, tris=0 degenerate, influences=4, unweighted=0, edge=0.08..2.15
|
||||
- `Body_shapes` — warn; verts=5550, tris=0 degenerate, influences=4, unweighted=0, edge=0.08..2.15
|
||||
- `Cylinder` — warn; verts=840, tris=0 degenerate, influences=0, unweighted=840, edge=0.09..102.91
|
||||
- `Face` — ok; verts=4109, tris=0 degenerate, influences=1, unweighted=0, edge=0.85..0.85
|
||||
- `Hair` — ok; verts=8, tris=0 degenerate, influences=1, unweighted=0, edge=0.85..0.85
|
||||
- `Hair001` — ok; verts=8673, tris=0 degenerate, influences=1, unweighted=0, edge=0.82..0.87
|
||||
- `Hair2` — ok; verts=8673, tris=0 degenerate, influences=2, unweighted=0, edge=0.82..0.87
|
||||
- `HairBack` — ok; verts=760, tris=0 degenerate, influences=4, unweighted=0, edge=0.61..1.46
|
||||
- `OnepieceCloth` — ok; verts=6, tris=0 degenerate, influences=3, unweighted=0, edge=0.53..0.55
|
||||
- `Shoes` — warn; verts=608, tris=0 degenerate, influences=3, unweighted=0, edge=0.24..0.59
|
||||
- `Tops` — warn; verts=1029, tris=0 degenerate, influences=4, unweighted=0, edge=0.08..0.80
|
||||
- `bra1` — ok; verts=419, tris=0 degenerate, influences=1, unweighted=0, edge=0.85..0.85
|
||||
- `bra2` — ok; verts=524, tris=0 degenerate, influences=1, unweighted=0, edge=0.85..0.85
|
||||
- `bra3` — ok; verts=450, tris=0 degenerate, influences=1, unweighted=0, edge=0.85..0.85
|
||||
- `bra4` — ok; verts=450, tris=0 degenerate, influences=1, unweighted=0, edge=0.85..0.85
|
||||
- `panties` — warn; verts=797, tris=0 degenerate, influences=0, unweighted=797, edge=0.13..389.90
|
||||
- `panties-full` — warn; verts=425, tris=0 degenerate, influences=0, unweighted=425, edge=0.09..9.04
|
||||
- `panties2` — warn; verts=1328, tris=0 degenerate, influences=0, unweighted=1328, edge=0.04..73.45
|
||||
- `panties2-full` — warn; verts=558, tris=0 degenerate, influences=0, unweighted=558, edge=0.12..69.03
|
||||
- `panties3` — warn; verts=820, tris=0 degenerate, influences=0, unweighted=820, edge=0.07..56.10
|
||||
- `panties4` — warn; verts=752, tris=0 degenerate, influences=0, unweighted=752, edge=0.07..59.13
|
||||
- `panties5` — ok; verts=758, tris=0 degenerate, influences=1, unweighted=0, edge=0.85..0.85
|
||||
- `panties6` — ok; verts=758, tris=0 degenerate, influences=1, unweighted=0, edge=0.85..0.85
|
||||
- `panties7` — ok; verts=758, tris=0 degenerate, influences=1, unweighted=0, edge=0.85..0.85
|
||||
- `shoes1` — warn; verts=414, tris=0 degenerate, influences=3, unweighted=0, edge=0.26..1.55
|
||||
- `skirt1` — warn; verts=154, tris=0 degenerate, influences=3, unweighted=0, edge=0.27..0.53
|
||||
- `skirt2` — warn; verts=176, tris=0 degenerate, influences=3, unweighted=0, edge=0.28..0.54
|
||||
- `skirt3` — warn; verts=154, tris=0 degenerate, influences=3, unweighted=0, edge=0.27..0.53
|
||||
- `top1` — ok; verts=852, tris=0 degenerate, influences=1, unweighted=0, edge=0.85..0.85
|
||||
- `top2` — ok; verts=854, tris=0 degenerate, influences=1, unweighted=0, edge=0.85..0.85
|
||||
- `top3` — ok; verts=854, tris=0 degenerate, influences=1, unweighted=0, edge=0.85..0.85
|
||||
@@ -0,0 +1,491 @@
|
||||
{
|
||||
"age": "baby",
|
||||
"fails": [],
|
||||
"info": {
|
||||
"adultArmature": "male",
|
||||
"ageArmature": "baby-male",
|
||||
"animations": {
|
||||
"aliased": {
|
||||
"swimming-fast": "swimming"
|
||||
},
|
||||
"appended": [
|
||||
"Armature|mixamo.com|Layer0.020",
|
||||
"Armature|mixamo.com|Layer0.018",
|
||||
"Armature|mixamo.com|Layer0.016",
|
||||
"Armature|mixamo.com|Layer0.014",
|
||||
"Armature|mixamo.com|Layer0.012"
|
||||
],
|
||||
"missing": [],
|
||||
"scaledLocationCurves": 7601,
|
||||
"stem": "baby-male"
|
||||
},
|
||||
"outputBase": "edited-normal-baby-male.blend",
|
||||
"slots": "bottom,face,feet,hair,top",
|
||||
"sourceBase": "edited-normal-male.blend"
|
||||
},
|
||||
"objects": {
|
||||
"Accessory": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.6591322275489071,
|
||||
"edgeRatioMin": 0.2294774279830071,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 111,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 145
|
||||
},
|
||||
"Body": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 2.7944548668408027,
|
||||
"edgeRatioMin": 0.09792195397903704,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 6232,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 1,
|
||||
"unweighted": 0,
|
||||
"vertices": 6584
|
||||
},
|
||||
"BodyBottom": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.696915448832056,
|
||||
"edgeRatioMin": 0.2475168552675534,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 1228,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 1,
|
||||
"unweighted": 0,
|
||||
"vertices": 1181
|
||||
},
|
||||
"BodyBottomPants": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.8123872063883257,
|
||||
"edgeRatioMin": 0.2475168552675534,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 972,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 1,
|
||||
"unweighted": 0,
|
||||
"vertices": 1133
|
||||
},
|
||||
"BodyFeet": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.6102393851343675,
|
||||
"edgeRatioMin": 0.22574066520587047,
|
||||
"maxInfluences": 3,
|
||||
"note": "",
|
||||
"polygons": 1274,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 1,
|
||||
"unweighted": 0,
|
||||
"vertices": 1392
|
||||
},
|
||||
"BodyFeetPants": {
|
||||
"degenerateTris": 48,
|
||||
"edgeRatioMax": 0.6102393851343675,
|
||||
"edgeRatioMin": 0.2095881183386803,
|
||||
"maxInfluences": 3,
|
||||
"note": "",
|
||||
"polygons": 1466,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 1602
|
||||
},
|
||||
"BodyFeetPantsShoes": {
|
||||
"degenerateTris": 48,
|
||||
"edgeRatioMax": 0.6017141327128214,
|
||||
"edgeRatioMin": 0.2095881183386803,
|
||||
"maxInfluences": 3,
|
||||
"note": "",
|
||||
"polygons": 830,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 1048
|
||||
},
|
||||
"BodyTop": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 2.7944548668408027,
|
||||
"edgeRatioMin": 0.09792195397903704,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 3726,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 1,
|
||||
"unweighted": 0,
|
||||
"vertices": 3373
|
||||
},
|
||||
"BodyTopRobe": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 2.694739629684512,
|
||||
"edgeRatioMin": 0.04884643632625867,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 5384,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 1,
|
||||
"unweighted": 0,
|
||||
"vertices": 5046
|
||||
},
|
||||
"Body_shapes": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 2.7944548668408027,
|
||||
"edgeRatioMin": 0.09792195397903704,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 6228,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 2,
|
||||
"unweighted": 0,
|
||||
"vertices": 5556
|
||||
},
|
||||
"Bottoms": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.8123872063883257,
|
||||
"edgeRatioMin": 0.2095881183386803,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 826,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 942
|
||||
},
|
||||
"Face": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.8501110244008585,
|
||||
"edgeRatioMin": 0.8499080602231018,
|
||||
"maxInfluences": 1,
|
||||
"note": "",
|
||||
"polygons": 3839,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 57,
|
||||
"unweighted": 0,
|
||||
"vertices": 4045
|
||||
},
|
||||
"Hair": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.8500001087034722,
|
||||
"edgeRatioMin": 0.8499998996166309,
|
||||
"maxInfluences": 1,
|
||||
"note": "",
|
||||
"polygons": 4,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 9
|
||||
},
|
||||
"Hair2": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.8932439134046963,
|
||||
"edgeRatioMin": 0.8193953530826958,
|
||||
"maxInfluences": 2,
|
||||
"note": "",
|
||||
"polygons": 4483,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 7022
|
||||
},
|
||||
"HairBack": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.4800221295791642,
|
||||
"edgeRatioMin": 0.6036557832095256,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 724,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 760
|
||||
},
|
||||
"Panties001": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.6962243553417347,
|
||||
"edgeRatioMin": 0.2658767663648385,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 604,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 582
|
||||
},
|
||||
"Pants0001": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.651774029445214,
|
||||
"edgeRatioMin": 0.2253732629144712,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 898,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 915
|
||||
},
|
||||
"Shoes": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.6017141327128214,
|
||||
"edgeRatioMin": 0.39686482033435044,
|
||||
"maxInfluences": 3,
|
||||
"note": "",
|
||||
"polygons": 334,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 478
|
||||
},
|
||||
"Tops": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.6309442432828267,
|
||||
"edgeRatioMin": 0.04884643632625867,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 1658,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 1896
|
||||
},
|
||||
"TopsEdited": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.6309442432828267,
|
||||
"edgeRatioMin": 0.04884643632625867,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 1658,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 1896
|
||||
},
|
||||
"coat": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.5376462038278786,
|
||||
"edgeRatioMin": 0.14071060392477913,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 1658,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 1896
|
||||
},
|
||||
"hair0001": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 2.322546280419375,
|
||||
"edgeRatioMin": 0.6481636259388702,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 4483,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 7022
|
||||
},
|
||||
"hair0002": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 2.322546280419375,
|
||||
"edgeRatioMin": 0.6481636259388702,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 4483,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 7022
|
||||
},
|
||||
"hair0003": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.0,
|
||||
"edgeRatioMin": 1.0,
|
||||
"maxInfluences": 0,
|
||||
"note": "",
|
||||
"polygons": 4483,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 7022
|
||||
},
|
||||
"shoes1": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.5819192631554228,
|
||||
"edgeRatioMin": 0.36626946181073133,
|
||||
"maxInfluences": 3,
|
||||
"note": "",
|
||||
"polygons": 334,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 478
|
||||
}
|
||||
},
|
||||
"profileHash": "dd6901d4220ae704",
|
||||
"rig": {
|
||||
"adultBones": 242,
|
||||
"ageBones": 242,
|
||||
"profile": {
|
||||
"arm": 0.3,
|
||||
"bodyRadial": 0.55,
|
||||
"foot": 0.55,
|
||||
"head": 0.85,
|
||||
"headBulge": 1.08,
|
||||
"hipHeight": 0.28,
|
||||
"hipWidth": 0.55,
|
||||
"leg": 0.26,
|
||||
"limbRadial": 0.5,
|
||||
"shoulderWidth": 0.5,
|
||||
"torso": 0.3,
|
||||
"walkSpeedScale": 0.0
|
||||
},
|
||||
"restOrientationErrors": []
|
||||
},
|
||||
"seams": {
|
||||
"coincidentGroups": 5119,
|
||||
"examples": [
|
||||
{
|
||||
"gap": 1e-06,
|
||||
"objects": [
|
||||
"BodyTop",
|
||||
"BodyTopRobe"
|
||||
],
|
||||
"position": [
|
||||
0.0,
|
||||
-0.0184,
|
||||
1.5008
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 1e-06,
|
||||
"objects": [
|
||||
"BodyTop",
|
||||
"Face"
|
||||
],
|
||||
"position": [
|
||||
0.0633,
|
||||
0.0369,
|
||||
1.6496
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 1e-06,
|
||||
"objects": [
|
||||
"BodyTop",
|
||||
"Face"
|
||||
],
|
||||
"position": [
|
||||
-0.0633,
|
||||
0.0369,
|
||||
1.6496
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 1e-06,
|
||||
"objects": [
|
||||
"Face"
|
||||
],
|
||||
"position": [
|
||||
0.0636,
|
||||
0.0319,
|
||||
1.6457
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 1e-06,
|
||||
"objects": [
|
||||
"Face"
|
||||
],
|
||||
"position": [
|
||||
0.0698,
|
||||
0.021,
|
||||
1.649
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 1e-06,
|
||||
"objects": [
|
||||
"Face"
|
||||
],
|
||||
"position": [
|
||||
0.0716,
|
||||
0.0206,
|
||||
1.6529
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 1e-06,
|
||||
"objects": [
|
||||
"Face"
|
||||
],
|
||||
"position": [
|
||||
-0.0636,
|
||||
0.0319,
|
||||
1.6457
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 1e-06,
|
||||
"objects": [
|
||||
"Face"
|
||||
],
|
||||
"position": [
|
||||
-0.0698,
|
||||
0.021,
|
||||
1.649
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 1e-06,
|
||||
"objects": [
|
||||
"Face"
|
||||
],
|
||||
"position": [
|
||||
-0.0716,
|
||||
0.0206,
|
||||
1.6529
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 2e-06,
|
||||
"objects": [
|
||||
"Hair2"
|
||||
],
|
||||
"position": [
|
||||
0.0002,
|
||||
-0.0352,
|
||||
1.8214
|
||||
]
|
||||
}
|
||||
],
|
||||
"maxGap": 6.834857277971055e-06,
|
||||
"snappedVertices": 204
|
||||
},
|
||||
"sex": "male",
|
||||
"status": "warn",
|
||||
"stem": "baby-male",
|
||||
"timestamp": 1793419306,
|
||||
"warnings": [
|
||||
"Accessory",
|
||||
"Body",
|
||||
"Body_shapes",
|
||||
"BodyBottom",
|
||||
"BodyBottomPants",
|
||||
"BodyFeet",
|
||||
"BodyFeetPants",
|
||||
"BodyFeetPantsShoes",
|
||||
"BodyTop",
|
||||
"BodyTopRobe",
|
||||
"Bottoms",
|
||||
"Tops",
|
||||
"TopsEdited",
|
||||
"coat",
|
||||
"Panties001",
|
||||
"Pants0001",
|
||||
"BodyFeet"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
# Conversion Report — baby / male
|
||||
|
||||
- Stem: `baby-male`
|
||||
- Status: **warn**
|
||||
- Profile hash: `dd6901d4220ae704`
|
||||
- Objects: 25
|
||||
- Warnings: 17
|
||||
- Fails: 0
|
||||
|
||||
## Info
|
||||
- adultArmature: `male`
|
||||
- ageArmature: `baby-male`
|
||||
- animations: `{'scaledLocationCurves': 7601, 'appended': ['Armature|mixamo.com|Layer0.020', 'Armature|mixamo.com|Layer0.018', 'Armature|mixamo.com|Layer0.016', 'Armature|mixamo.com|Layer0.014', 'Armature|mixamo.com|Layer0.012'], 'aliased': {'swimming-fast': 'swimming'}, 'missing': [], 'stem': 'baby-male'}`
|
||||
- outputBase: `edited-normal-baby-male.blend`
|
||||
- slots: `bottom,face,feet,hair,top`
|
||||
- sourceBase: `edited-normal-male.blend`
|
||||
|
||||
## Seams
|
||||
- coincidentGroups: `5119`
|
||||
- snappedVertices: `204`
|
||||
- maxGap: `6.834857277971055e-06`
|
||||
- examples: `[{'position': [0.0, -0.0184, 1.5008], 'gap': 1e-06, 'objects': ['BodyTop', 'BodyTopRobe']}, {'position': [0.0633, 0.0369, 1.6496], 'gap': 1e-06, 'objects': ['BodyTop', 'Face']}, {'position': [-0.0633, 0.0369, 1.6496], 'gap': 1e-06, 'objects': ['BodyTop', 'Face']}, {'position': [0.0636, 0.0319, 1.6457], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0698, 0.021, 1.649], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0716, 0.0206, 1.6529], 'gap': 1e-06, 'objects': ['Face']}, {'position': [-0.0636, 0.0319, 1.6457], 'gap': 1e-06, 'objects': ['Face']}, {'position': [-0.0698, 0.021, 1.649], 'gap': 1e-06, 'objects': ['Face']}, {'position': [-0.0716, 0.0206, 1.6529], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0002, -0.0352, 1.8214], 'gap': 2e-06, 'objects': ['Hair2']}]`
|
||||
|
||||
## Warnings (first 20)
|
||||
|
||||
- `Accessory`
|
||||
- `Body`
|
||||
- `Body_shapes`
|
||||
- `BodyBottom`
|
||||
- `BodyBottomPants`
|
||||
- `BodyFeet`
|
||||
- `BodyFeetPants`
|
||||
- `BodyFeetPantsShoes`
|
||||
- `BodyTop`
|
||||
- `BodyTopRobe`
|
||||
- `Bottoms`
|
||||
- `Tops`
|
||||
- `TopsEdited`
|
||||
- `coat`
|
||||
- `Panties001`
|
||||
- `Pants0001`
|
||||
- `BodyFeet`
|
||||
|
||||
## Objects
|
||||
|
||||
- `Accessory` — warn; verts=145, tris=0 degenerate, influences=4, unweighted=0, edge=0.23..0.66
|
||||
- `Body` — warn; verts=6584, tris=0 degenerate, influences=4, unweighted=0, edge=0.10..2.79
|
||||
- `BodyBottom` — warn; verts=1181, tris=0 degenerate, influences=4, unweighted=0, edge=0.25..0.70
|
||||
- `BodyBottomPants` — warn; verts=1133, tris=0 degenerate, influences=4, unweighted=0, edge=0.25..0.81
|
||||
- `BodyFeet` — warn; verts=1392, tris=0 degenerate, influences=3, unweighted=0, edge=0.23..0.61
|
||||
- `BodyFeetPants` — warn; verts=1602, tris=48 degenerate, influences=3, unweighted=0, edge=0.21..0.61
|
||||
- `BodyFeetPantsShoes` — warn; verts=1048, tris=48 degenerate, influences=3, unweighted=0, edge=0.21..0.60
|
||||
- `BodyTop` — warn; verts=3373, tris=0 degenerate, influences=4, unweighted=0, edge=0.10..2.79
|
||||
- `BodyTopRobe` — warn; verts=5046, tris=0 degenerate, influences=4, unweighted=0, edge=0.05..2.69
|
||||
- `Body_shapes` — warn; verts=5556, tris=0 degenerate, influences=4, unweighted=0, edge=0.10..2.79
|
||||
- `Bottoms` — warn; verts=942, tris=0 degenerate, influences=4, unweighted=0, edge=0.21..0.81
|
||||
- `Face` — ok; verts=4045, tris=0 degenerate, influences=1, unweighted=0, edge=0.85..0.85
|
||||
- `Hair` — ok; verts=9, tris=0 degenerate, influences=1, unweighted=0, edge=0.85..0.85
|
||||
- `Hair2` — ok; verts=7022, tris=0 degenerate, influences=2, unweighted=0, edge=0.82..0.89
|
||||
- `HairBack` — ok; verts=760, tris=0 degenerate, influences=4, unweighted=0, edge=0.60..1.48
|
||||
- `Panties001` — warn; verts=582, tris=0 degenerate, influences=4, unweighted=0, edge=0.27..0.70
|
||||
- `Pants0001` — warn; verts=915, tris=0 degenerate, influences=4, unweighted=0, edge=0.23..1.65
|
||||
- `Shoes` — ok; verts=478, tris=0 degenerate, influences=3, unweighted=0, edge=0.40..0.60
|
||||
- `Tops` — warn; verts=1896, tris=0 degenerate, influences=4, unweighted=0, edge=0.05..1.63
|
||||
- `TopsEdited` — warn; verts=1896, tris=0 degenerate, influences=4, unweighted=0, edge=0.05..1.63
|
||||
- `coat` — warn; verts=1896, tris=0 degenerate, influences=4, unweighted=0, edge=0.14..1.54
|
||||
- `hair0001` — ok; verts=7022, tris=0 degenerate, influences=4, unweighted=0, edge=0.65..2.32
|
||||
- `hair0002` — ok; verts=7022, tris=0 degenerate, influences=4, unweighted=0, edge=0.65..2.32
|
||||
- `hair0003` — ok; verts=7022, tris=0 degenerate, influences=0, unweighted=0, edge=1.00..1.00
|
||||
- `shoes1` — ok; verts=478, tris=0 degenerate, influences=3, unweighted=0, edge=0.37..0.58
|
||||
@@ -0,0 +1,627 @@
|
||||
{
|
||||
"age": "child",
|
||||
"fails": [],
|
||||
"info": {
|
||||
"adultArmature": "female",
|
||||
"ageArmature": "child-female",
|
||||
"animations": {
|
||||
"aliased": {
|
||||
"swimming-fast": "swimming"
|
||||
},
|
||||
"appended": [
|
||||
"Armature|mixamo.com|Layer0.020",
|
||||
"Armature|mixamo.com|Layer0.018",
|
||||
"Armature|mixamo.com|Layer0.016",
|
||||
"Armature|mixamo.com|Layer0.014"
|
||||
],
|
||||
"missing": [],
|
||||
"scaledLocationCurves": 7028,
|
||||
"stem": "child-female"
|
||||
},
|
||||
"outputBase": "edited-normal-child-female.blend",
|
||||
"slots": "bottom,face,feet,hair,top",
|
||||
"sourceBase": "edited-normal-female.blend"
|
||||
},
|
||||
"objects": {
|
||||
"Accessory": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.7898433968486418,
|
||||
"edgeRatioMin": 0.49563877021449726,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 40,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 69
|
||||
},
|
||||
"Body": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.5936254360808924,
|
||||
"edgeRatioMin": 0.35974842621764863,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 6227,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 6578
|
||||
},
|
||||
"Body-merged": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.5936254360808924,
|
||||
"edgeRatioMin": 0.35974842621764863,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 8423,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 9178
|
||||
},
|
||||
"BodyBottom": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.7850192237493911,
|
||||
"edgeRatioMin": 0.5419409362099876,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 1242,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 1194
|
||||
},
|
||||
"BodyFeet": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.7839441718995033,
|
||||
"edgeRatioMin": 0.5345308894982719,
|
||||
"maxInfluences": 3,
|
||||
"note": "",
|
||||
"polygons": 1280,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 1050
|
||||
},
|
||||
"BodyTop": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.5936254360808924,
|
||||
"edgeRatioMin": 0.35974842621764863,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 3701,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 3354
|
||||
},
|
||||
"BodyTopRobe": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.5936254360808924,
|
||||
"edgeRatioMin": 0.35974842621764863,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 4727,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 4383
|
||||
},
|
||||
"Body_shapes": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.5936254360808924,
|
||||
"edgeRatioMin": 0.35974842621764863,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 6223,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 2,
|
||||
"unweighted": 0,
|
||||
"vertices": 5550
|
||||
},
|
||||
"Cylinder": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 55.52669888099699,
|
||||
"edgeRatioMin": 0.3728675302592677,
|
||||
"maxInfluences": 0,
|
||||
"note": "",
|
||||
"polygons": 595,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 840,
|
||||
"vertices": 840
|
||||
},
|
||||
"Face": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.9209191562868269,
|
||||
"edgeRatioMin": 0.9194440137740598,
|
||||
"maxInfluences": 1,
|
||||
"note": "",
|
||||
"polygons": 3887,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 57,
|
||||
"unweighted": 0,
|
||||
"vertices": 4109
|
||||
},
|
||||
"Hair": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.9200007036314336,
|
||||
"edgeRatioMin": 0.9199996884063618,
|
||||
"maxInfluences": 1,
|
||||
"note": "",
|
||||
"polygons": 2,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 8
|
||||
},
|
||||
"Hair001": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.9580249853514248,
|
||||
"edgeRatioMin": 0.8921870820834138,
|
||||
"maxInfluences": 1,
|
||||
"note": "",
|
||||
"polygons": 5658,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 8673
|
||||
},
|
||||
"Hair2": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.9580249853514248,
|
||||
"edgeRatioMin": 0.8921870820834138,
|
||||
"maxInfluences": 2,
|
||||
"note": "",
|
||||
"polygons": 5658,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 8673
|
||||
},
|
||||
"HairBack": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.243301152005224,
|
||||
"edgeRatioMin": 0.7996069035600766,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 726,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 760
|
||||
},
|
||||
"OnepieceCloth": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.7795104096985646,
|
||||
"edgeRatioMin": 0.7657916805350437,
|
||||
"maxInfluences": 3,
|
||||
"note": "",
|
||||
"polygons": 2,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 6
|
||||
},
|
||||
"Shoes": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.7710505397361299,
|
||||
"edgeRatioMin": 0.5274789367897975,
|
||||
"maxInfluences": 3,
|
||||
"note": "",
|
||||
"polygons": 402,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 608
|
||||
},
|
||||
"Tops": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.8665602233865596,
|
||||
"edgeRatioMin": 0.44064144963083524,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 1026,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 1029
|
||||
},
|
||||
"bra1": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.920044969946938,
|
||||
"edgeRatioMin": 0.9199654645832204,
|
||||
"maxInfluences": 1,
|
||||
"note": "",
|
||||
"polygons": 430,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 419
|
||||
},
|
||||
"bra2": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.9200462644396492,
|
||||
"edgeRatioMin": 0.9199754607547745,
|
||||
"maxInfluences": 1,
|
||||
"note": "",
|
||||
"polygons": 506,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 524
|
||||
},
|
||||
"bra3": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.9200246096786325,
|
||||
"edgeRatioMin": 0.9199754607547745,
|
||||
"maxInfluences": 1,
|
||||
"note": "",
|
||||
"polygons": 410,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 450
|
||||
},
|
||||
"bra4": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.9200246096786325,
|
||||
"edgeRatioMin": 0.9199754607547745,
|
||||
"maxInfluences": 1,
|
||||
"note": "",
|
||||
"polygons": 410,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 450
|
||||
},
|
||||
"panties": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 211.10052027794262,
|
||||
"edgeRatioMin": 0.19951643125297738,
|
||||
"maxInfluences": 0,
|
||||
"note": "",
|
||||
"polygons": 674,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 797,
|
||||
"vertices": 797
|
||||
},
|
||||
"panties-full": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 7.322137784611612,
|
||||
"edgeRatioMin": 0.21340035981963004,
|
||||
"maxInfluences": 0,
|
||||
"note": "",
|
||||
"polygons": 337,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 425,
|
||||
"vertices": 425
|
||||
},
|
||||
"panties2": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 39.470945630173105,
|
||||
"edgeRatioMin": 0.21985285672118582,
|
||||
"maxInfluences": 0,
|
||||
"note": "",
|
||||
"polygons": 1266,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 1328,
|
||||
"vertices": 1328
|
||||
},
|
||||
"panties2-full": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 36.99110437234406,
|
||||
"edgeRatioMin": 0.10284348688443892,
|
||||
"maxInfluences": 0,
|
||||
"note": "",
|
||||
"polygons": 507,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 558,
|
||||
"vertices": 558
|
||||
},
|
||||
"panties3": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 30.204732645010235,
|
||||
"edgeRatioMin": 0.20627087124391974,
|
||||
"maxInfluences": 0,
|
||||
"note": "",
|
||||
"polygons": 758,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 820,
|
||||
"vertices": 820
|
||||
},
|
||||
"panties4": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 31.683176789997283,
|
||||
"edgeRatioMin": 0.1989474077553321,
|
||||
"maxInfluences": 0,
|
||||
"note": "",
|
||||
"polygons": 690,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 752,
|
||||
"vertices": 752
|
||||
},
|
||||
"panties5": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 5.895118610080954,
|
||||
"edgeRatioMin": 0.6048716072518844,
|
||||
"maxInfluences": 3,
|
||||
"note": "",
|
||||
"polygons": 698,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 758
|
||||
},
|
||||
"panties6": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 7.880647390006613,
|
||||
"edgeRatioMin": 0.6250818099712813,
|
||||
"maxInfluences": 3,
|
||||
"note": "",
|
||||
"polygons": 698,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 758
|
||||
},
|
||||
"panties7": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 7.880647390006613,
|
||||
"edgeRatioMin": 0.6400929469228386,
|
||||
"maxInfluences": 3,
|
||||
"note": "",
|
||||
"polygons": 698,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 758
|
||||
},
|
||||
"shoes1": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.9595848242017094,
|
||||
"edgeRatioMin": 0.5492882605967488,
|
||||
"maxInfluences": 3,
|
||||
"note": "",
|
||||
"polygons": 414,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 414
|
||||
},
|
||||
"skirt1": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.3290984484841972,
|
||||
"edgeRatioMin": 0.3753802859501542,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 132,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 154
|
||||
},
|
||||
"skirt2": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.4244777332130143,
|
||||
"edgeRatioMin": 0.3753802859501542,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 154,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 176
|
||||
},
|
||||
"skirt3": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.268563086876801,
|
||||
"edgeRatioMin": 0.37917544169158424,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 132,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 154
|
||||
},
|
||||
"top1": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 7.0781828943354546,
|
||||
"edgeRatioMin": 0.7089442731001407,
|
||||
"maxInfluences": 1,
|
||||
"note": "",
|
||||
"polygons": 860,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 852
|
||||
},
|
||||
"top2": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 7.0781828943354546,
|
||||
"edgeRatioMin": 0.7089442731001407,
|
||||
"maxInfluences": 1,
|
||||
"note": "",
|
||||
"polygons": 876,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 854
|
||||
},
|
||||
"top3": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 7.0781828943354546,
|
||||
"edgeRatioMin": 0.7089442731001407,
|
||||
"maxInfluences": 1,
|
||||
"note": "",
|
||||
"polygons": 876,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 854
|
||||
}
|
||||
},
|
||||
"profileHash": "dd6901d4220ae704",
|
||||
"rig": {
|
||||
"adultBones": 252,
|
||||
"ageBones": 252,
|
||||
"profile": {
|
||||
"arm": 0.6,
|
||||
"bodyRadial": 0.78,
|
||||
"foot": 0.75,
|
||||
"head": 0.92,
|
||||
"headBulge": 1.03,
|
||||
"hipHeight": 0.55,
|
||||
"hipWidth": 0.72,
|
||||
"leg": 0.55,
|
||||
"limbRadial": 0.72,
|
||||
"shoulderWidth": 0.7,
|
||||
"torso": 0.62,
|
||||
"walkSpeedScale": 0.75
|
||||
},
|
||||
"restOrientationErrors": []
|
||||
},
|
||||
"seams": {
|
||||
"coincidentGroups": 3697,
|
||||
"examples": [
|
||||
{
|
||||
"gap": 1e-06,
|
||||
"objects": [
|
||||
"Face"
|
||||
],
|
||||
"position": [
|
||||
0.0649,
|
||||
0.0281,
|
||||
1.3797
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 1e-06,
|
||||
"objects": [
|
||||
"Face"
|
||||
],
|
||||
"position": [
|
||||
0.066,
|
||||
0.0231,
|
||||
1.3767
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 1e-06,
|
||||
"objects": [
|
||||
"Face"
|
||||
],
|
||||
"position": [
|
||||
0.0694,
|
||||
0.0172,
|
||||
1.3783
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 1e-06,
|
||||
"objects": [
|
||||
"Face"
|
||||
],
|
||||
"position": [
|
||||
-0.0649,
|
||||
0.0281,
|
||||
1.3797
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 1e-06,
|
||||
"objects": [
|
||||
"Face"
|
||||
],
|
||||
"position": [
|
||||
-0.066,
|
||||
0.0231,
|
||||
1.3767
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 1e-06,
|
||||
"objects": [
|
||||
"Face"
|
||||
],
|
||||
"position": [
|
||||
-0.0694,
|
||||
0.0172,
|
||||
1.3783
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 2e-06,
|
||||
"objects": [
|
||||
"Hair2"
|
||||
],
|
||||
"position": [
|
||||
0.0001,
|
||||
-0.0385,
|
||||
1.5529
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 2e-06,
|
||||
"objects": [
|
||||
"Hair2"
|
||||
],
|
||||
"position": [
|
||||
-0.0001,
|
||||
-0.0385,
|
||||
1.5529
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 3e-06,
|
||||
"objects": [
|
||||
"Hair2"
|
||||
],
|
||||
"position": [
|
||||
-0.0012,
|
||||
-0.0342,
|
||||
1.5629
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 3e-06,
|
||||
"objects": [
|
||||
"Hair2"
|
||||
],
|
||||
"position": [
|
||||
-0.001,
|
||||
-0.0343,
|
||||
1.5628
|
||||
]
|
||||
}
|
||||
],
|
||||
"maxGap": 6.470887154236068e-06,
|
||||
"snappedVertices": 95
|
||||
},
|
||||
"sex": "female",
|
||||
"status": "warn",
|
||||
"stem": "child-female",
|
||||
"timestamp": 1793419297,
|
||||
"warnings": [
|
||||
"top1",
|
||||
"top2",
|
||||
"top3",
|
||||
"Cylinder",
|
||||
"panties",
|
||||
"panties-full",
|
||||
"panties2",
|
||||
"panties2-full",
|
||||
"panties3",
|
||||
"panties4",
|
||||
"panties5",
|
||||
"panties6",
|
||||
"panties7"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
# Conversion Report — child / female
|
||||
|
||||
- Stem: `child-female`
|
||||
- Status: **warn**
|
||||
- Profile hash: `dd6901d4220ae704`
|
||||
- Objects: 37
|
||||
- Warnings: 13
|
||||
- Fails: 0
|
||||
|
||||
## Info
|
||||
- adultArmature: `female`
|
||||
- ageArmature: `child-female`
|
||||
- animations: `{'scaledLocationCurves': 7028, 'appended': ['Armature|mixamo.com|Layer0.020', 'Armature|mixamo.com|Layer0.018', 'Armature|mixamo.com|Layer0.016', 'Armature|mixamo.com|Layer0.014'], 'aliased': {'swimming-fast': 'swimming'}, 'missing': [], 'stem': 'child-female'}`
|
||||
- outputBase: `edited-normal-child-female.blend`
|
||||
- slots: `bottom,face,feet,hair,top`
|
||||
- sourceBase: `edited-normal-female.blend`
|
||||
|
||||
## Seams
|
||||
- coincidentGroups: `3697`
|
||||
- snappedVertices: `95`
|
||||
- maxGap: `6.470887154236068e-06`
|
||||
- examples: `[{'position': [0.0649, 0.0281, 1.3797], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.066, 0.0231, 1.3767], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0694, 0.0172, 1.3783], 'gap': 1e-06, 'objects': ['Face']}, {'position': [-0.0649, 0.0281, 1.3797], 'gap': 1e-06, 'objects': ['Face']}, {'position': [-0.066, 0.0231, 1.3767], 'gap': 1e-06, 'objects': ['Face']}, {'position': [-0.0694, 0.0172, 1.3783], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0001, -0.0385, 1.5529], 'gap': 2e-06, 'objects': ['Hair2']}, {'position': [-0.0001, -0.0385, 1.5529], 'gap': 2e-06, 'objects': ['Hair2']}, {'position': [-0.0012, -0.0342, 1.5629], 'gap': 3e-06, 'objects': ['Hair2']}, {'position': [-0.001, -0.0343, 1.5628], 'gap': 3e-06, 'objects': ['Hair2']}]`
|
||||
|
||||
## Warnings (first 20)
|
||||
|
||||
- `top1`
|
||||
- `top2`
|
||||
- `top3`
|
||||
- `Cylinder`
|
||||
- `panties`
|
||||
- `panties-full`
|
||||
- `panties2`
|
||||
- `panties2-full`
|
||||
- `panties3`
|
||||
- `panties4`
|
||||
- `panties5`
|
||||
- `panties6`
|
||||
- `panties7`
|
||||
|
||||
## Objects
|
||||
|
||||
- `Accessory` — ok; verts=69, tris=0 degenerate, influences=4, unweighted=0, edge=0.50..0.79
|
||||
- `Body` — ok; verts=6578, tris=0 degenerate, influences=4, unweighted=0, edge=0.36..1.59
|
||||
- `Body-merged` — ok; verts=9178, tris=0 degenerate, influences=4, unweighted=0, edge=0.36..1.59
|
||||
- `BodyBottom` — ok; verts=1194, tris=0 degenerate, influences=4, unweighted=0, edge=0.54..0.79
|
||||
- `BodyFeet` — ok; verts=1050, tris=0 degenerate, influences=3, unweighted=0, edge=0.53..0.78
|
||||
- `BodyTop` — ok; verts=3354, tris=0 degenerate, influences=4, unweighted=0, edge=0.36..1.59
|
||||
- `BodyTopRobe` — ok; verts=4383, tris=0 degenerate, influences=4, unweighted=0, edge=0.36..1.59
|
||||
- `Body_shapes` — ok; verts=5550, tris=0 degenerate, influences=4, unweighted=0, edge=0.36..1.59
|
||||
- `Cylinder` — warn; verts=840, tris=0 degenerate, influences=0, unweighted=840, edge=0.37..55.53
|
||||
- `Face` — ok; verts=4109, tris=0 degenerate, influences=1, unweighted=0, edge=0.92..0.92
|
||||
- `Hair` — ok; verts=8, tris=0 degenerate, influences=1, unweighted=0, edge=0.92..0.92
|
||||
- `Hair001` — ok; verts=8673, tris=0 degenerate, influences=1, unweighted=0, edge=0.89..0.96
|
||||
- `Hair2` — ok; verts=8673, tris=0 degenerate, influences=2, unweighted=0, edge=0.89..0.96
|
||||
- `HairBack` — ok; verts=760, tris=0 degenerate, influences=4, unweighted=0, edge=0.80..1.24
|
||||
- `OnepieceCloth` — ok; verts=6, tris=0 degenerate, influences=3, unweighted=0, edge=0.77..0.78
|
||||
- `Shoes` — ok; verts=608, tris=0 degenerate, influences=3, unweighted=0, edge=0.53..0.77
|
||||
- `Tops` — ok; verts=1029, tris=0 degenerate, influences=4, unweighted=0, edge=0.44..0.87
|
||||
- `bra1` — ok; verts=419, tris=0 degenerate, influences=1, unweighted=0, edge=0.92..0.92
|
||||
- `bra2` — ok; verts=524, tris=0 degenerate, influences=1, unweighted=0, edge=0.92..0.92
|
||||
- `bra3` — ok; verts=450, tris=0 degenerate, influences=1, unweighted=0, edge=0.92..0.92
|
||||
- `bra4` — ok; verts=450, tris=0 degenerate, influences=1, unweighted=0, edge=0.92..0.92
|
||||
- `panties` — warn; verts=797, tris=0 degenerate, influences=0, unweighted=797, edge=0.20..211.10
|
||||
- `panties-full` — warn; verts=425, tris=0 degenerate, influences=0, unweighted=425, edge=0.21..7.32
|
||||
- `panties2` — warn; verts=1328, tris=0 degenerate, influences=0, unweighted=1328, edge=0.22..39.47
|
||||
- `panties2-full` — warn; verts=558, tris=0 degenerate, influences=0, unweighted=558, edge=0.10..36.99
|
||||
- `panties3` — warn; verts=820, tris=0 degenerate, influences=0, unweighted=820, edge=0.21..30.20
|
||||
- `panties4` — warn; verts=752, tris=0 degenerate, influences=0, unweighted=752, edge=0.20..31.68
|
||||
- `panties5` — warn; verts=758, tris=0 degenerate, influences=3, unweighted=0, edge=0.60..5.90
|
||||
- `panties6` — warn; verts=758, tris=0 degenerate, influences=3, unweighted=0, edge=0.63..7.88
|
||||
- `panties7` — warn; verts=758, tris=0 degenerate, influences=3, unweighted=0, edge=0.64..7.88
|
||||
- `shoes1` — ok; verts=414, tris=0 degenerate, influences=3, unweighted=0, edge=0.55..0.96
|
||||
- `skirt1` — ok; verts=154, tris=0 degenerate, influences=4, unweighted=0, edge=0.38..1.33
|
||||
- `skirt2` — ok; verts=176, tris=0 degenerate, influences=4, unweighted=0, edge=0.38..1.42
|
||||
- `skirt3` — ok; verts=154, tris=0 degenerate, influences=4, unweighted=0, edge=0.38..1.27
|
||||
- `top1` — warn; verts=852, tris=0 degenerate, influences=1, unweighted=0, edge=0.71..7.08
|
||||
- `top2` — warn; verts=854, tris=0 degenerate, influences=1, unweighted=0, edge=0.71..7.08
|
||||
- `top3` — warn; verts=854, tris=0 degenerate, influences=1, unweighted=0, edge=0.71..7.08
|
||||
@@ -0,0 +1,485 @@
|
||||
{
|
||||
"age": "child",
|
||||
"fails": [],
|
||||
"info": {
|
||||
"adultArmature": "male",
|
||||
"ageArmature": "child-male",
|
||||
"animations": {
|
||||
"aliased": {
|
||||
"swimming-fast": "swimming"
|
||||
},
|
||||
"appended": [
|
||||
"Armature|mixamo.com|Layer0.020",
|
||||
"Armature|mixamo.com|Layer0.018",
|
||||
"Armature|mixamo.com|Layer0.016",
|
||||
"Armature|mixamo.com|Layer0.014",
|
||||
"Armature|mixamo.com|Layer0.012"
|
||||
],
|
||||
"missing": [],
|
||||
"scaledLocationCurves": 7601,
|
||||
"stem": "child-male"
|
||||
},
|
||||
"outputBase": "edited-normal-child-male.blend",
|
||||
"slots": "bottom,face,feet,hair,top",
|
||||
"sourceBase": "edited-normal-male.blend"
|
||||
},
|
||||
"objects": {
|
||||
"Accessory": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.7935740735522747,
|
||||
"edgeRatioMin": 0.478987783805106,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 111,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 145
|
||||
},
|
||||
"Body": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.9536679047270835,
|
||||
"edgeRatioMin": 0.33440855736119546,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 6232,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 1,
|
||||
"unweighted": 0,
|
||||
"vertices": 6584
|
||||
},
|
||||
"BodyBottom": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.8216992678030084,
|
||||
"edgeRatioMin": 0.5410301282339429,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 1228,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 1,
|
||||
"unweighted": 0,
|
||||
"vertices": 1181
|
||||
},
|
||||
"BodyBottomPants": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.86670117423969,
|
||||
"edgeRatioMin": 0.5410301282339429,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 972,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 1,
|
||||
"unweighted": 0,
|
||||
"vertices": 1133
|
||||
},
|
||||
"BodyFeet": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.7839642922675355,
|
||||
"edgeRatioMin": 0.5255821222248701,
|
||||
"maxInfluences": 3,
|
||||
"note": "",
|
||||
"polygons": 1274,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 1,
|
||||
"unweighted": 0,
|
||||
"vertices": 1392
|
||||
},
|
||||
"BodyFeetPants": {
|
||||
"degenerateTris": 48,
|
||||
"edgeRatioMax": 0.7839642922675355,
|
||||
"edgeRatioMin": 0.513176300659891,
|
||||
"maxInfluences": 3,
|
||||
"note": "",
|
||||
"polygons": 1466,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 1602
|
||||
},
|
||||
"BodyFeetPantsShoes": {
|
||||
"degenerateTris": 48,
|
||||
"edgeRatioMax": 0.7799605112083939,
|
||||
"edgeRatioMin": 0.513176300659891,
|
||||
"maxInfluences": 3,
|
||||
"note": "",
|
||||
"polygons": 830,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 1048
|
||||
},
|
||||
"BodyTop": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.9536679047270835,
|
||||
"edgeRatioMin": 0.33440855736119546,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 3726,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 1,
|
||||
"unweighted": 0,
|
||||
"vertices": 3373
|
||||
},
|
||||
"BodyTopRobe": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.8102305567029533,
|
||||
"edgeRatioMin": 0.26171116386651677,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 5384,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 1,
|
||||
"unweighted": 0,
|
||||
"vertices": 5046
|
||||
},
|
||||
"Body_shapes": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.9536679047270835,
|
||||
"edgeRatioMin": 0.33440855736119546,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 6228,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 2,
|
||||
"unweighted": 0,
|
||||
"vertices": 5556
|
||||
},
|
||||
"Bottoms": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.86670117423969,
|
||||
"edgeRatioMin": 0.513176300659891,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 826,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 942
|
||||
},
|
||||
"Face": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.9201631088272872,
|
||||
"edgeRatioMin": 0.9197675922571078,
|
||||
"maxInfluences": 1,
|
||||
"note": "",
|
||||
"polygons": 3839,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 57,
|
||||
"unweighted": 0,
|
||||
"vertices": 4045
|
||||
},
|
||||
"Hair": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.9200000519776095,
|
||||
"edgeRatioMin": 0.9199996919846277,
|
||||
"maxInfluences": 1,
|
||||
"note": "",
|
||||
"polygons": 4,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 9
|
||||
},
|
||||
"Hair2": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.9421731351522475,
|
||||
"edgeRatioMin": 0.8873110924578558,
|
||||
"maxInfluences": 2,
|
||||
"note": "",
|
||||
"polygons": 4483,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 7022
|
||||
},
|
||||
"HairBack": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.2562402728849387,
|
||||
"edgeRatioMin": 0.7903516292481316,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 724,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 760
|
||||
},
|
||||
"Panties001": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.8213261947723751,
|
||||
"edgeRatioMin": 0.5459879769698383,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 604,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 582
|
||||
},
|
||||
"Pants0001": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.0359715012588415,
|
||||
"edgeRatioMin": 0.5209577970985301,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 898,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 915
|
||||
},
|
||||
"Shoes": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.7799605112083939,
|
||||
"edgeRatioMin": 0.6458808184096261,
|
||||
"maxInfluences": 3,
|
||||
"note": "",
|
||||
"polygons": 334,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 478
|
||||
},
|
||||
"Tops": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.3836635660461891,
|
||||
"edgeRatioMin": 0.26171116386651677,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 1658,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 1896
|
||||
},
|
||||
"TopsEdited": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.3836635660461891,
|
||||
"edgeRatioMin": 0.26171116386651677,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 1658,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 1896
|
||||
},
|
||||
"coat": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 5.7431588938626055,
|
||||
"edgeRatioMin": 0.06297046287549611,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 1658,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 1896
|
||||
},
|
||||
"hair0001": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.6992910918861586,
|
||||
"edgeRatioMin": 0.8209691490768963,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 4483,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 7022
|
||||
},
|
||||
"hair0002": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.6992910918861586,
|
||||
"edgeRatioMin": 0.8209691490768963,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 4483,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 7022
|
||||
},
|
||||
"hair0003": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.0,
|
||||
"edgeRatioMin": 1.0,
|
||||
"maxInfluences": 0,
|
||||
"note": "",
|
||||
"polygons": 4483,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 7022
|
||||
},
|
||||
"shoes1": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.76872570692281,
|
||||
"edgeRatioMin": 0.6246053643865501,
|
||||
"maxInfluences": 3,
|
||||
"note": "",
|
||||
"polygons": 334,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 478
|
||||
}
|
||||
},
|
||||
"profileHash": "dd6901d4220ae704",
|
||||
"rig": {
|
||||
"adultBones": 242,
|
||||
"ageBones": 242,
|
||||
"profile": {
|
||||
"arm": 0.6,
|
||||
"bodyRadial": 0.78,
|
||||
"foot": 0.75,
|
||||
"head": 0.92,
|
||||
"headBulge": 1.03,
|
||||
"hipHeight": 0.55,
|
||||
"hipWidth": 0.72,
|
||||
"leg": 0.55,
|
||||
"limbRadial": 0.72,
|
||||
"shoulderWidth": 0.7,
|
||||
"torso": 0.62,
|
||||
"walkSpeedScale": 0.75
|
||||
},
|
||||
"restOrientationErrors": []
|
||||
},
|
||||
"seams": {
|
||||
"coincidentGroups": 5119,
|
||||
"examples": [
|
||||
{
|
||||
"gap": 1e-06,
|
||||
"objects": [
|
||||
"BodyTop",
|
||||
"BodyTopRobe"
|
||||
],
|
||||
"position": [
|
||||
0.0,
|
||||
0.09,
|
||||
1.4972
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 1e-06,
|
||||
"objects": [
|
||||
"BodyTop",
|
||||
"BodyTopRobe"
|
||||
],
|
||||
"position": [
|
||||
0.0,
|
||||
0.0858,
|
||||
1.6039
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 2e-06,
|
||||
"objects": [
|
||||
"BodyTop",
|
||||
"BodyTopRobe"
|
||||
],
|
||||
"position": [
|
||||
0.0,
|
||||
-0.0184,
|
||||
1.5008
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 2e-06,
|
||||
"objects": [
|
||||
"BodyTop",
|
||||
"Face"
|
||||
],
|
||||
"position": [
|
||||
0.0633,
|
||||
0.0369,
|
||||
1.6496
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 2e-06,
|
||||
"objects": [
|
||||
"BodyTop",
|
||||
"Face"
|
||||
],
|
||||
"position": [
|
||||
-0.0633,
|
||||
0.0369,
|
||||
1.6496
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 1e-06,
|
||||
"objects": [
|
||||
"Face"
|
||||
],
|
||||
"position": [
|
||||
0.0633,
|
||||
0.0344,
|
||||
1.6475
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 1e-06,
|
||||
"objects": [
|
||||
"Face"
|
||||
],
|
||||
"position": [
|
||||
0.0636,
|
||||
0.0319,
|
||||
1.6457
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 1e-06,
|
||||
"objects": [
|
||||
"Face"
|
||||
],
|
||||
"position": [
|
||||
0.0645,
|
||||
0.0294,
|
||||
1.6445
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 1e-06,
|
||||
"objects": [
|
||||
"Face"
|
||||
],
|
||||
"position": [
|
||||
0.0698,
|
||||
0.021,
|
||||
1.649
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 1e-06,
|
||||
"objects": [
|
||||
"Face"
|
||||
],
|
||||
"position": [
|
||||
0.0716,
|
||||
0.0206,
|
||||
1.6529
|
||||
]
|
||||
}
|
||||
],
|
||||
"maxGap": 7.33739356344359e-06,
|
||||
"snappedVertices": 216
|
||||
},
|
||||
"sex": "male",
|
||||
"status": "warn",
|
||||
"stem": "child-male",
|
||||
"timestamp": 1793419291,
|
||||
"warnings": [
|
||||
"Body",
|
||||
"Body_shapes",
|
||||
"BodyFeetPants",
|
||||
"BodyFeetPantsShoes",
|
||||
"BodyTop",
|
||||
"BodyTopRobe",
|
||||
"Tops",
|
||||
"TopsEdited",
|
||||
"coat"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
# Conversion Report — child / male
|
||||
|
||||
- Stem: `child-male`
|
||||
- Status: **warn**
|
||||
- Profile hash: `dd6901d4220ae704`
|
||||
- Objects: 25
|
||||
- Warnings: 9
|
||||
- Fails: 0
|
||||
|
||||
## Info
|
||||
- adultArmature: `male`
|
||||
- ageArmature: `child-male`
|
||||
- animations: `{'scaledLocationCurves': 7601, 'appended': ['Armature|mixamo.com|Layer0.020', 'Armature|mixamo.com|Layer0.018', 'Armature|mixamo.com|Layer0.016', 'Armature|mixamo.com|Layer0.014', 'Armature|mixamo.com|Layer0.012'], 'aliased': {'swimming-fast': 'swimming'}, 'missing': [], 'stem': 'child-male'}`
|
||||
- outputBase: `edited-normal-child-male.blend`
|
||||
- slots: `bottom,face,feet,hair,top`
|
||||
- sourceBase: `edited-normal-male.blend`
|
||||
|
||||
## Seams
|
||||
- coincidentGroups: `5119`
|
||||
- snappedVertices: `216`
|
||||
- maxGap: `7.33739356344359e-06`
|
||||
- examples: `[{'position': [0.0, 0.09, 1.4972], 'gap': 1e-06, 'objects': ['BodyTop', 'BodyTopRobe']}, {'position': [0.0, 0.0858, 1.6039], 'gap': 1e-06, 'objects': ['BodyTop', 'BodyTopRobe']}, {'position': [0.0, -0.0184, 1.5008], 'gap': 2e-06, 'objects': ['BodyTop', 'BodyTopRobe']}, {'position': [0.0633, 0.0369, 1.6496], 'gap': 2e-06, 'objects': ['BodyTop', 'Face']}, {'position': [-0.0633, 0.0369, 1.6496], 'gap': 2e-06, 'objects': ['BodyTop', 'Face']}, {'position': [0.0633, 0.0344, 1.6475], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0636, 0.0319, 1.6457], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0645, 0.0294, 1.6445], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0698, 0.021, 1.649], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0716, 0.0206, 1.6529], 'gap': 1e-06, 'objects': ['Face']}]`
|
||||
|
||||
## Warnings (first 20)
|
||||
|
||||
- `Body`
|
||||
- `Body_shapes`
|
||||
- `BodyFeetPants`
|
||||
- `BodyFeetPantsShoes`
|
||||
- `BodyTop`
|
||||
- `BodyTopRobe`
|
||||
- `Tops`
|
||||
- `TopsEdited`
|
||||
- `coat`
|
||||
|
||||
## Objects
|
||||
|
||||
- `Accessory` — ok; verts=145, tris=0 degenerate, influences=4, unweighted=0, edge=0.48..0.79
|
||||
- `Body` — warn; verts=6584, tris=0 degenerate, influences=4, unweighted=0, edge=0.33..1.95
|
||||
- `BodyBottom` — ok; verts=1181, tris=0 degenerate, influences=4, unweighted=0, edge=0.54..0.82
|
||||
- `BodyBottomPants` — ok; verts=1133, tris=0 degenerate, influences=4, unweighted=0, edge=0.54..0.87
|
||||
- `BodyFeet` — ok; verts=1392, tris=0 degenerate, influences=3, unweighted=0, edge=0.53..0.78
|
||||
- `BodyFeetPants` — warn; verts=1602, tris=48 degenerate, influences=3, unweighted=0, edge=0.51..0.78
|
||||
- `BodyFeetPantsShoes` — warn; verts=1048, tris=48 degenerate, influences=3, unweighted=0, edge=0.51..0.78
|
||||
- `BodyTop` — warn; verts=3373, tris=0 degenerate, influences=4, unweighted=0, edge=0.33..1.95
|
||||
- `BodyTopRobe` — warn; verts=5046, tris=0 degenerate, influences=4, unweighted=0, edge=0.26..1.81
|
||||
- `Body_shapes` — warn; verts=5556, tris=0 degenerate, influences=4, unweighted=0, edge=0.33..1.95
|
||||
- `Bottoms` — ok; verts=942, tris=0 degenerate, influences=4, unweighted=0, edge=0.51..0.87
|
||||
- `Face` — ok; verts=4045, tris=0 degenerate, influences=1, unweighted=0, edge=0.92..0.92
|
||||
- `Hair` — ok; verts=9, tris=0 degenerate, influences=1, unweighted=0, edge=0.92..0.92
|
||||
- `Hair2` — ok; verts=7022, tris=0 degenerate, influences=2, unweighted=0, edge=0.89..0.94
|
||||
- `HairBack` — ok; verts=760, tris=0 degenerate, influences=4, unweighted=0, edge=0.79..1.26
|
||||
- `Panties001` — ok; verts=582, tris=0 degenerate, influences=4, unweighted=0, edge=0.55..0.82
|
||||
- `Pants0001` — ok; verts=915, tris=0 degenerate, influences=4, unweighted=0, edge=0.52..1.04
|
||||
- `Shoes` — ok; verts=478, tris=0 degenerate, influences=3, unweighted=0, edge=0.65..0.78
|
||||
- `Tops` — warn; verts=1896, tris=0 degenerate, influences=4, unweighted=0, edge=0.26..1.38
|
||||
- `TopsEdited` — warn; verts=1896, tris=0 degenerate, influences=4, unweighted=0, edge=0.26..1.38
|
||||
- `coat` — warn; verts=1896, tris=0 degenerate, influences=4, unweighted=0, edge=0.06..5.74
|
||||
- `hair0001` — ok; verts=7022, tris=0 degenerate, influences=4, unweighted=0, edge=0.82..1.70
|
||||
- `hair0002` — ok; verts=7022, tris=0 degenerate, influences=4, unweighted=0, edge=0.82..1.70
|
||||
- `hair0003` — ok; verts=7022, tris=0 degenerate, influences=0, unweighted=0, edge=1.00..1.00
|
||||
- `shoes1` — ok; verts=478, tris=0 degenerate, influences=3, unweighted=0, edge=0.62..0.77
|
||||
@@ -0,0 +1,621 @@
|
||||
{
|
||||
"age": "elder",
|
||||
"fails": [],
|
||||
"info": {
|
||||
"adultArmature": "female",
|
||||
"ageArmature": "elder-female",
|
||||
"animations": {
|
||||
"aliased": {
|
||||
"swimming-fast": "swimming"
|
||||
},
|
||||
"appended": [
|
||||
"Armature|mixamo.com|Layer0.020",
|
||||
"Armature|mixamo.com|Layer0.018",
|
||||
"Armature|mixamo.com|Layer0.016",
|
||||
"Armature|mixamo.com|Layer0.014"
|
||||
],
|
||||
"missing": [],
|
||||
"scaledLocationCurves": 7028,
|
||||
"stem": "elder-female"
|
||||
},
|
||||
"outputBase": "edited-normal-elder-female.blend",
|
||||
"slots": "bottom,face,feet,hair,top",
|
||||
"sourceBase": "edited-normal-female.blend"
|
||||
},
|
||||
"objects": {
|
||||
"Accessory": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.000008309035069,
|
||||
"edgeRatioMin": 0.9999861617983601,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 40,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 69
|
||||
},
|
||||
"Body": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.0000602153963685,
|
||||
"edgeRatioMin": 0.9999297885300813,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 6227,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 6578
|
||||
},
|
||||
"Body-merged": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.0000602153963685,
|
||||
"edgeRatioMin": 0.9999297885300813,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 8423,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 9178
|
||||
},
|
||||
"BodyBottom": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.0000091614229163,
|
||||
"edgeRatioMin": 0.9999833439579573,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 1242,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 1194
|
||||
},
|
||||
"BodyFeet": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.000004257443259,
|
||||
"edgeRatioMin": 0.9999898988835334,
|
||||
"maxInfluences": 3,
|
||||
"note": "",
|
||||
"polygons": 1280,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 1050
|
||||
},
|
||||
"BodyTop": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.0000602153963685,
|
||||
"edgeRatioMin": 0.9999297885300813,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 3701,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 3354
|
||||
},
|
||||
"BodyTopRobe": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.0000602153963685,
|
||||
"edgeRatioMin": 0.9999297885300813,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 4727,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 4383
|
||||
},
|
||||
"Body_shapes": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.0000602153963685,
|
||||
"edgeRatioMin": 0.9999297885300813,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 6223,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 2,
|
||||
"unweighted": 0,
|
||||
"vertices": 5550
|
||||
},
|
||||
"Cylinder": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.0000211541526272,
|
||||
"edgeRatioMin": 0.9999790604099301,
|
||||
"maxInfluences": 0,
|
||||
"note": "",
|
||||
"polygons": 595,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 840,
|
||||
"vertices": 840
|
||||
},
|
||||
"Face": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.0000190299901157,
|
||||
"edgeRatioMin": 0.9999818716880085,
|
||||
"maxInfluences": 1,
|
||||
"note": "",
|
||||
"polygons": 3887,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 57,
|
||||
"unweighted": 0,
|
||||
"vertices": 4109
|
||||
},
|
||||
"Hair": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.0,
|
||||
"edgeRatioMin": 1.0,
|
||||
"maxInfluences": 1,
|
||||
"note": "",
|
||||
"polygons": 2,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 8
|
||||
},
|
||||
"Hair001": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.000108986292174,
|
||||
"edgeRatioMin": 0.999757672900018,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 5658,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 8673
|
||||
},
|
||||
"Hair2": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.0000886276531582,
|
||||
"edgeRatioMin": 0.9982467837381087,
|
||||
"maxInfluences": 2,
|
||||
"note": "",
|
||||
"polygons": 5658,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 8673
|
||||
},
|
||||
"HairBack": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.000033289511109,
|
||||
"edgeRatioMin": 0.9999827306906097,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 726,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 760
|
||||
},
|
||||
"OnepieceCloth": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.00000327729584,
|
||||
"edgeRatioMin": 0.9999867384343708,
|
||||
"maxInfluences": 3,
|
||||
"note": "",
|
||||
"polygons": 2,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 6
|
||||
},
|
||||
"Shoes": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.000000954873743,
|
||||
"edgeRatioMin": 0.9999976654518632,
|
||||
"maxInfluences": 3,
|
||||
"note": "",
|
||||
"polygons": 402,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 608
|
||||
},
|
||||
"Tops": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.000009505554167,
|
||||
"edgeRatioMin": 0.9999842864583346,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 1026,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 1029
|
||||
},
|
||||
"bra1": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.0000653723155617,
|
||||
"edgeRatioMin": 0.9999349455725334,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 430,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 419
|
||||
},
|
||||
"bra2": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.0000515467022928,
|
||||
"edgeRatioMin": 0.9999485610532655,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 506,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 524
|
||||
},
|
||||
"bra3": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.000045760529744,
|
||||
"edgeRatioMin": 0.9999485610532655,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 410,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 450
|
||||
},
|
||||
"bra4": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.000045760529744,
|
||||
"edgeRatioMin": 0.9999485610532655,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 410,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 450
|
||||
},
|
||||
"panties": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.0000105452390367,
|
||||
"edgeRatioMin": 0.9999622354677439,
|
||||
"maxInfluences": 0,
|
||||
"note": "",
|
||||
"polygons": 674,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 797,
|
||||
"vertices": 797
|
||||
},
|
||||
"panties-full": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.0000048680725775,
|
||||
"edgeRatioMin": 0.9999761306308599,
|
||||
"maxInfluences": 0,
|
||||
"note": "",
|
||||
"polygons": 337,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 425,
|
||||
"vertices": 425
|
||||
},
|
||||
"panties2": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.0000202864294472,
|
||||
"edgeRatioMin": 0.9999826178387231,
|
||||
"maxInfluences": 0,
|
||||
"note": "",
|
||||
"polygons": 1266,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 1328,
|
||||
"vertices": 1328
|
||||
},
|
||||
"panties2-full": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.0000121089841207,
|
||||
"edgeRatioMin": 0.9999863856203022,
|
||||
"maxInfluences": 0,
|
||||
"note": "",
|
||||
"polygons": 507,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 558,
|
||||
"vertices": 558
|
||||
},
|
||||
"panties3": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.0000139098043757,
|
||||
"edgeRatioMin": 0.9999866822085812,
|
||||
"maxInfluences": 0,
|
||||
"note": "",
|
||||
"polygons": 758,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 820,
|
||||
"vertices": 820
|
||||
},
|
||||
"panties4": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.0000139098043757,
|
||||
"edgeRatioMin": 0.9999866822085812,
|
||||
"maxInfluences": 0,
|
||||
"note": "",
|
||||
"polygons": 690,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 752,
|
||||
"vertices": 752
|
||||
},
|
||||
"panties5": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.0000461926176676,
|
||||
"edgeRatioMin": 0.9999441034118597,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 698,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 758
|
||||
},
|
||||
"panties6": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.0000277280519727,
|
||||
"edgeRatioMin": 0.9999767160991722,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 698,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 758
|
||||
},
|
||||
"panties7": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.0000385836476469,
|
||||
"edgeRatioMin": 0.9999691785976204,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 698,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 758
|
||||
},
|
||||
"shoes1": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.000001217005389,
|
||||
"edgeRatioMin": 0.9999980533858904,
|
||||
"maxInfluences": 3,
|
||||
"note": "",
|
||||
"polygons": 414,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 414
|
||||
},
|
||||
"skirt1": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.000003778236853,
|
||||
"edgeRatioMin": 0.9999955122967341,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 132,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 154
|
||||
},
|
||||
"skirt2": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.000005985850434,
|
||||
"edgeRatioMin": 0.999995191044834,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 154,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 176
|
||||
},
|
||||
"skirt3": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.0000032530300955,
|
||||
"edgeRatioMin": 0.9999941815040422,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 132,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 154
|
||||
},
|
||||
"top1": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.0000349169435994,
|
||||
"edgeRatioMin": 0.9999356995377789,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 860,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 852
|
||||
},
|
||||
"top2": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.0000115228837376,
|
||||
"edgeRatioMin": 0.9999867173552025,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 876,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 854
|
||||
},
|
||||
"top3": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.0000115228837376,
|
||||
"edgeRatioMin": 0.9999867173552025,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 876,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 854
|
||||
}
|
||||
},
|
||||
"profileHash": "dd6901d4220ae704",
|
||||
"rig": {
|
||||
"adultBones": 252,
|
||||
"ageBones": 252,
|
||||
"profile": {
|
||||
"arm": 1.0,
|
||||
"bodyRadial": 1.0,
|
||||
"foot": 1.0,
|
||||
"head": 1.0,
|
||||
"headBulge": 1.0,
|
||||
"hipHeight": 1.0,
|
||||
"hipWidth": 1.0,
|
||||
"leg": 1.0,
|
||||
"limbRadial": 1.0,
|
||||
"shoulderWidth": 1.0,
|
||||
"torso": 1.0,
|
||||
"walkSpeedScale": 1.0
|
||||
},
|
||||
"restOrientationErrors": []
|
||||
},
|
||||
"seams": {
|
||||
"coincidentGroups": 3697,
|
||||
"examples": [
|
||||
{
|
||||
"gap": 1e-06,
|
||||
"objects": [
|
||||
"Face"
|
||||
],
|
||||
"position": [
|
||||
0.0649,
|
||||
0.0281,
|
||||
1.3797
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 1e-06,
|
||||
"objects": [
|
||||
"Face"
|
||||
],
|
||||
"position": [
|
||||
0.066,
|
||||
0.0231,
|
||||
1.3767
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 1e-06,
|
||||
"objects": [
|
||||
"Face"
|
||||
],
|
||||
"position": [
|
||||
0.0694,
|
||||
0.0172,
|
||||
1.3783
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 1e-06,
|
||||
"objects": [
|
||||
"Face"
|
||||
],
|
||||
"position": [
|
||||
-0.0649,
|
||||
0.0281,
|
||||
1.3797
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 1e-06,
|
||||
"objects": [
|
||||
"Face"
|
||||
],
|
||||
"position": [
|
||||
-0.066,
|
||||
0.0231,
|
||||
1.3767
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 1e-06,
|
||||
"objects": [
|
||||
"Face"
|
||||
],
|
||||
"position": [
|
||||
-0.0694,
|
||||
0.0172,
|
||||
1.3783
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 2e-06,
|
||||
"objects": [
|
||||
"Hair2"
|
||||
],
|
||||
"position": [
|
||||
0.0001,
|
||||
-0.0385,
|
||||
1.5529
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 2e-06,
|
||||
"objects": [
|
||||
"Hair2"
|
||||
],
|
||||
"position": [
|
||||
-0.0001,
|
||||
-0.0385,
|
||||
1.5529
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 3e-06,
|
||||
"objects": [
|
||||
"Hair2"
|
||||
],
|
||||
"position": [
|
||||
-0.0012,
|
||||
-0.0342,
|
||||
1.5629
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 3e-06,
|
||||
"objects": [
|
||||
"Hair2"
|
||||
],
|
||||
"position": [
|
||||
-0.001,
|
||||
-0.0343,
|
||||
1.5628
|
||||
]
|
||||
}
|
||||
],
|
||||
"maxGap": 7.0412131889888606e-06,
|
||||
"snappedVertices": 95
|
||||
},
|
||||
"sex": "female",
|
||||
"status": "warn",
|
||||
"stem": "elder-female",
|
||||
"timestamp": 1793419268,
|
||||
"warnings": [
|
||||
"Cylinder",
|
||||
"panties",
|
||||
"panties-full",
|
||||
"panties2",
|
||||
"panties2-full",
|
||||
"panties3",
|
||||
"panties4"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
# Conversion Report — elder / female
|
||||
|
||||
- Stem: `elder-female`
|
||||
- Status: **warn**
|
||||
- Profile hash: `dd6901d4220ae704`
|
||||
- Objects: 37
|
||||
- Warnings: 7
|
||||
- Fails: 0
|
||||
|
||||
## Info
|
||||
- adultArmature: `female`
|
||||
- ageArmature: `elder-female`
|
||||
- animations: `{'scaledLocationCurves': 7028, 'appended': ['Armature|mixamo.com|Layer0.020', 'Armature|mixamo.com|Layer0.018', 'Armature|mixamo.com|Layer0.016', 'Armature|mixamo.com|Layer0.014'], 'aliased': {'swimming-fast': 'swimming'}, 'missing': [], 'stem': 'elder-female'}`
|
||||
- outputBase: `edited-normal-elder-female.blend`
|
||||
- slots: `bottom,face,feet,hair,top`
|
||||
- sourceBase: `edited-normal-female.blend`
|
||||
|
||||
## Seams
|
||||
- coincidentGroups: `3697`
|
||||
- snappedVertices: `95`
|
||||
- maxGap: `7.0412131889888606e-06`
|
||||
- examples: `[{'position': [0.0649, 0.0281, 1.3797], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.066, 0.0231, 1.3767], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0694, 0.0172, 1.3783], 'gap': 1e-06, 'objects': ['Face']}, {'position': [-0.0649, 0.0281, 1.3797], 'gap': 1e-06, 'objects': ['Face']}, {'position': [-0.066, 0.0231, 1.3767], 'gap': 1e-06, 'objects': ['Face']}, {'position': [-0.0694, 0.0172, 1.3783], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0001, -0.0385, 1.5529], 'gap': 2e-06, 'objects': ['Hair2']}, {'position': [-0.0001, -0.0385, 1.5529], 'gap': 2e-06, 'objects': ['Hair2']}, {'position': [-0.0012, -0.0342, 1.5629], 'gap': 3e-06, 'objects': ['Hair2']}, {'position': [-0.001, -0.0343, 1.5628], 'gap': 3e-06, 'objects': ['Hair2']}]`
|
||||
|
||||
## Warnings (first 20)
|
||||
|
||||
- `Cylinder`
|
||||
- `panties`
|
||||
- `panties-full`
|
||||
- `panties2`
|
||||
- `panties2-full`
|
||||
- `panties3`
|
||||
- `panties4`
|
||||
|
||||
## Objects
|
||||
|
||||
- `Accessory` — ok; verts=69, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
|
||||
- `Body` — ok; verts=6578, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
|
||||
- `Body-merged` — ok; verts=9178, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
|
||||
- `BodyBottom` — ok; verts=1194, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
|
||||
- `BodyFeet` — ok; verts=1050, tris=0 degenerate, influences=3, unweighted=0, edge=1.00..1.00
|
||||
- `BodyTop` — ok; verts=3354, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
|
||||
- `BodyTopRobe` — ok; verts=4383, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
|
||||
- `Body_shapes` — ok; verts=5550, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
|
||||
- `Cylinder` — warn; verts=840, tris=0 degenerate, influences=0, unweighted=840, edge=1.00..1.00
|
||||
- `Face` — ok; verts=4109, tris=0 degenerate, influences=1, unweighted=0, edge=1.00..1.00
|
||||
- `Hair` — ok; verts=8, tris=0 degenerate, influences=1, unweighted=0, edge=1.00..1.00
|
||||
- `Hair001` — ok; verts=8673, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
|
||||
- `Hair2` — ok; verts=8673, tris=0 degenerate, influences=2, unweighted=0, edge=1.00..1.00
|
||||
- `HairBack` — ok; verts=760, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
|
||||
- `OnepieceCloth` — ok; verts=6, tris=0 degenerate, influences=3, unweighted=0, edge=1.00..1.00
|
||||
- `Shoes` — ok; verts=608, tris=0 degenerate, influences=3, unweighted=0, edge=1.00..1.00
|
||||
- `Tops` — ok; verts=1029, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
|
||||
- `bra1` — ok; verts=419, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
|
||||
- `bra2` — ok; verts=524, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
|
||||
- `bra3` — ok; verts=450, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
|
||||
- `bra4` — ok; verts=450, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
|
||||
- `panties` — warn; verts=797, tris=0 degenerate, influences=0, unweighted=797, edge=1.00..1.00
|
||||
- `panties-full` — warn; verts=425, tris=0 degenerate, influences=0, unweighted=425, edge=1.00..1.00
|
||||
- `panties2` — warn; verts=1328, tris=0 degenerate, influences=0, unweighted=1328, edge=1.00..1.00
|
||||
- `panties2-full` — warn; verts=558, tris=0 degenerate, influences=0, unweighted=558, edge=1.00..1.00
|
||||
- `panties3` — warn; verts=820, tris=0 degenerate, influences=0, unweighted=820, edge=1.00..1.00
|
||||
- `panties4` — warn; verts=752, tris=0 degenerate, influences=0, unweighted=752, edge=1.00..1.00
|
||||
- `panties5` — ok; verts=758, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
|
||||
- `panties6` — ok; verts=758, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
|
||||
- `panties7` — ok; verts=758, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
|
||||
- `shoes1` — ok; verts=414, tris=0 degenerate, influences=3, unweighted=0, edge=1.00..1.00
|
||||
- `skirt1` — ok; verts=154, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
|
||||
- `skirt2` — ok; verts=176, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
|
||||
- `skirt3` — ok; verts=154, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
|
||||
- `top1` — ok; verts=852, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
|
||||
- `top2` — ok; verts=854, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
|
||||
- `top3` — ok; verts=854, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
|
||||
@@ -0,0 +1,482 @@
|
||||
{
|
||||
"age": "elder",
|
||||
"fails": [],
|
||||
"info": {
|
||||
"adultArmature": "male",
|
||||
"ageArmature": "elder-male",
|
||||
"animations": {
|
||||
"aliased": {
|
||||
"swimming-fast": "swimming"
|
||||
},
|
||||
"appended": [
|
||||
"Armature|mixamo.com|Layer0.020",
|
||||
"Armature|mixamo.com|Layer0.018",
|
||||
"Armature|mixamo.com|Layer0.016",
|
||||
"Armature|mixamo.com|Layer0.014",
|
||||
"Armature|mixamo.com|Layer0.012"
|
||||
],
|
||||
"missing": [],
|
||||
"scaledLocationCurves": 7601,
|
||||
"stem": "elder-male"
|
||||
},
|
||||
"outputBase": "edited-normal-elder-male.blend",
|
||||
"slots": "bottom,face,feet,hair,top",
|
||||
"sourceBase": "edited-normal-male.blend"
|
||||
},
|
||||
"objects": {
|
||||
"Accessory": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.000019766352531,
|
||||
"edgeRatioMin": 0.9999805255229,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 111,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 145
|
||||
},
|
||||
"Body": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.0000897554304082,
|
||||
"edgeRatioMin": 0.9999611641687194,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 6232,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 1,
|
||||
"unweighted": 0,
|
||||
"vertices": 6584
|
||||
},
|
||||
"BodyBottom": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.0000079995934277,
|
||||
"edgeRatioMin": 0.999989253410586,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 1228,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 1,
|
||||
"unweighted": 0,
|
||||
"vertices": 1181
|
||||
},
|
||||
"BodyBottomPants": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.0000258231043282,
|
||||
"edgeRatioMin": 0.9999859573234091,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 972,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 1,
|
||||
"unweighted": 0,
|
||||
"vertices": 1133
|
||||
},
|
||||
"BodyFeet": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.0000046342516928,
|
||||
"edgeRatioMin": 0.9999981174339364,
|
||||
"maxInfluences": 3,
|
||||
"note": "",
|
||||
"polygons": 1274,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 1,
|
||||
"unweighted": 0,
|
||||
"vertices": 1392
|
||||
},
|
||||
"BodyFeetPants": {
|
||||
"degenerateTris": 48,
|
||||
"edgeRatioMax": 1.0000046342516928,
|
||||
"edgeRatioMin": 0.9999981174339364,
|
||||
"maxInfluences": 3,
|
||||
"note": "",
|
||||
"polygons": 1466,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 1602
|
||||
},
|
||||
"BodyFeetPantsShoes": {
|
||||
"degenerateTris": 48,
|
||||
"edgeRatioMax": 1.0000013400794516,
|
||||
"edgeRatioMin": 0.9999981174339364,
|
||||
"maxInfluences": 3,
|
||||
"note": "",
|
||||
"polygons": 830,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 1048
|
||||
},
|
||||
"BodyTop": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.0000897554304082,
|
||||
"edgeRatioMin": 0.9999611641687194,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 3726,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 1,
|
||||
"unweighted": 0,
|
||||
"vertices": 3373
|
||||
},
|
||||
"BodyTopRobe": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.0000897554304082,
|
||||
"edgeRatioMin": 0.9999611641687194,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 5384,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 1,
|
||||
"unweighted": 0,
|
||||
"vertices": 5046
|
||||
},
|
||||
"Body_shapes": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.0000897554304082,
|
||||
"edgeRatioMin": 0.9999611641687194,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 6228,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 2,
|
||||
"unweighted": 0,
|
||||
"vertices": 5556
|
||||
},
|
||||
"Bottoms": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.0000258231043282,
|
||||
"edgeRatioMin": 0.9999859573234091,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 826,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 942
|
||||
},
|
||||
"Face": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.0000018813696014,
|
||||
"edgeRatioMin": 0.9999982328372087,
|
||||
"maxInfluences": 1,
|
||||
"note": "",
|
||||
"polygons": 3839,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 57,
|
||||
"unweighted": 0,
|
||||
"vertices": 4045
|
||||
},
|
||||
"Hair": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.0,
|
||||
"edgeRatioMin": 0.9999997540155123,
|
||||
"maxInfluences": 1,
|
||||
"note": "",
|
||||
"polygons": 4,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 9
|
||||
},
|
||||
"Hair2": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.000542889561435,
|
||||
"edgeRatioMin": 0.999194851963821,
|
||||
"maxInfluences": 2,
|
||||
"note": "",
|
||||
"polygons": 4483,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 7022
|
||||
},
|
||||
"HairBack": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.0000295096813758,
|
||||
"edgeRatioMin": 0.9999786138678867,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 724,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 760
|
||||
},
|
||||
"Panties001": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.0010504381569287,
|
||||
"edgeRatioMin": 0.9991726553447599,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 604,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 582
|
||||
},
|
||||
"Pants0001": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.0000229696457892,
|
||||
"edgeRatioMin": 0.9999874704829604,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 898,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 915
|
||||
},
|
||||
"Shoes": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.0000013174267397,
|
||||
"edgeRatioMin": 0.9999985866462575,
|
||||
"maxInfluences": 3,
|
||||
"note": "",
|
||||
"polygons": 334,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 478
|
||||
},
|
||||
"Tops": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.00000962524978,
|
||||
"edgeRatioMin": 0.9999900806166673,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 1658,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 1896
|
||||
},
|
||||
"TopsEdited": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.00000962524978,
|
||||
"edgeRatioMin": 0.9999900806166673,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 1658,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 1896
|
||||
},
|
||||
"coat": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.000022626000937,
|
||||
"edgeRatioMin": 0.9999831391448877,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 1658,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 1896
|
||||
},
|
||||
"hair0001": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.0000358941381666,
|
||||
"edgeRatioMin": 0.99983153357466,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 4483,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 7022
|
||||
},
|
||||
"hair0002": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.0000358941381666,
|
||||
"edgeRatioMin": 0.99983153357466,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 4483,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 7022
|
||||
},
|
||||
"hair0003": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.0,
|
||||
"edgeRatioMin": 1.0,
|
||||
"maxInfluences": 0,
|
||||
"note": "",
|
||||
"polygons": 4483,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 7022
|
||||
},
|
||||
"shoes1": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.0000013174267397,
|
||||
"edgeRatioMin": 0.9999985251538689,
|
||||
"maxInfluences": 3,
|
||||
"note": "",
|
||||
"polygons": 334,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 478
|
||||
}
|
||||
},
|
||||
"profileHash": "dd6901d4220ae704",
|
||||
"rig": {
|
||||
"adultBones": 242,
|
||||
"ageBones": 242,
|
||||
"profile": {
|
||||
"arm": 1.0,
|
||||
"bodyRadial": 1.0,
|
||||
"foot": 1.0,
|
||||
"head": 1.0,
|
||||
"headBulge": 1.0,
|
||||
"hipHeight": 1.0,
|
||||
"hipWidth": 1.0,
|
||||
"leg": 1.0,
|
||||
"limbRadial": 1.0,
|
||||
"shoulderWidth": 1.0,
|
||||
"torso": 1.0,
|
||||
"walkSpeedScale": 1.0
|
||||
},
|
||||
"restOrientationErrors": []
|
||||
},
|
||||
"seams": {
|
||||
"coincidentGroups": 5119,
|
||||
"examples": [
|
||||
{
|
||||
"gap": 2e-06,
|
||||
"objects": [
|
||||
"BodyTop",
|
||||
"BodyTopRobe"
|
||||
],
|
||||
"position": [
|
||||
0.0,
|
||||
0.09,
|
||||
1.4972
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 1e-06,
|
||||
"objects": [
|
||||
"BodyTop",
|
||||
"BodyTopRobe"
|
||||
],
|
||||
"position": [
|
||||
0.0,
|
||||
0.0858,
|
||||
1.6039
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 2e-06,
|
||||
"objects": [
|
||||
"BodyTop",
|
||||
"BodyTopRobe"
|
||||
],
|
||||
"position": [
|
||||
0.0,
|
||||
-0.0184,
|
||||
1.5008
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 2e-06,
|
||||
"objects": [
|
||||
"BodyTop",
|
||||
"Face"
|
||||
],
|
||||
"position": [
|
||||
0.0633,
|
||||
0.0369,
|
||||
1.6496
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 2e-06,
|
||||
"objects": [
|
||||
"BodyTop",
|
||||
"Face"
|
||||
],
|
||||
"position": [
|
||||
-0.0633,
|
||||
0.0369,
|
||||
1.6496
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 1e-06,
|
||||
"objects": [
|
||||
"BodyTop",
|
||||
"BodyTopRobe",
|
||||
"Face"
|
||||
],
|
||||
"position": [
|
||||
0.0842,
|
||||
0.0277,
|
||||
1.6955
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 1e-06,
|
||||
"objects": [
|
||||
"BodyTop",
|
||||
"BodyTopRobe",
|
||||
"Face"
|
||||
],
|
||||
"position": [
|
||||
-0.0842,
|
||||
0.0277,
|
||||
1.6955
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 1e-06,
|
||||
"objects": [
|
||||
"Face"
|
||||
],
|
||||
"position": [
|
||||
0.0633,
|
||||
0.0344,
|
||||
1.6475
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 1e-06,
|
||||
"objects": [
|
||||
"Face"
|
||||
],
|
||||
"position": [
|
||||
0.0636,
|
||||
0.0319,
|
||||
1.6457
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 1e-06,
|
||||
"objects": [
|
||||
"Face"
|
||||
],
|
||||
"position": [
|
||||
0.0645,
|
||||
0.0294,
|
||||
1.6445
|
||||
]
|
||||
}
|
||||
],
|
||||
"maxGap": 8.047489926985063e-06,
|
||||
"snappedVertices": 226
|
||||
},
|
||||
"sex": "male",
|
||||
"status": "warn",
|
||||
"stem": "elder-male",
|
||||
"timestamp": 1793419262,
|
||||
"warnings": [
|
||||
"BodyFeetPants",
|
||||
"BodyFeetPantsShoes"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
# Conversion Report — elder / male
|
||||
|
||||
- Stem: `elder-male`
|
||||
- Status: **warn**
|
||||
- Profile hash: `dd6901d4220ae704`
|
||||
- Objects: 25
|
||||
- Warnings: 2
|
||||
- Fails: 0
|
||||
|
||||
## Info
|
||||
- adultArmature: `male`
|
||||
- ageArmature: `elder-male`
|
||||
- animations: `{'scaledLocationCurves': 7601, 'appended': ['Armature|mixamo.com|Layer0.020', 'Armature|mixamo.com|Layer0.018', 'Armature|mixamo.com|Layer0.016', 'Armature|mixamo.com|Layer0.014', 'Armature|mixamo.com|Layer0.012'], 'aliased': {'swimming-fast': 'swimming'}, 'missing': [], 'stem': 'elder-male'}`
|
||||
- outputBase: `edited-normal-elder-male.blend`
|
||||
- slots: `bottom,face,feet,hair,top`
|
||||
- sourceBase: `edited-normal-male.blend`
|
||||
|
||||
## Seams
|
||||
- coincidentGroups: `5119`
|
||||
- snappedVertices: `226`
|
||||
- maxGap: `8.047489926985063e-06`
|
||||
- examples: `[{'position': [0.0, 0.09, 1.4972], 'gap': 2e-06, 'objects': ['BodyTop', 'BodyTopRobe']}, {'position': [0.0, 0.0858, 1.6039], 'gap': 1e-06, 'objects': ['BodyTop', 'BodyTopRobe']}, {'position': [0.0, -0.0184, 1.5008], 'gap': 2e-06, 'objects': ['BodyTop', 'BodyTopRobe']}, {'position': [0.0633, 0.0369, 1.6496], 'gap': 2e-06, 'objects': ['BodyTop', 'Face']}, {'position': [-0.0633, 0.0369, 1.6496], 'gap': 2e-06, 'objects': ['BodyTop', 'Face']}, {'position': [0.0842, 0.0277, 1.6955], 'gap': 1e-06, 'objects': ['BodyTop', 'BodyTopRobe', 'Face']}, {'position': [-0.0842, 0.0277, 1.6955], 'gap': 1e-06, 'objects': ['BodyTop', 'BodyTopRobe', 'Face']}, {'position': [0.0633, 0.0344, 1.6475], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0636, 0.0319, 1.6457], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0645, 0.0294, 1.6445], 'gap': 1e-06, 'objects': ['Face']}]`
|
||||
|
||||
## Warnings (first 20)
|
||||
|
||||
- `BodyFeetPants`
|
||||
- `BodyFeetPantsShoes`
|
||||
|
||||
## Objects
|
||||
|
||||
- `Accessory` — ok; verts=145, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
|
||||
- `Body` — ok; verts=6584, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
|
||||
- `BodyBottom` — ok; verts=1181, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
|
||||
- `BodyBottomPants` — ok; verts=1133, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
|
||||
- `BodyFeet` — ok; verts=1392, tris=0 degenerate, influences=3, unweighted=0, edge=1.00..1.00
|
||||
- `BodyFeetPants` — warn; verts=1602, tris=48 degenerate, influences=3, unweighted=0, edge=1.00..1.00
|
||||
- `BodyFeetPantsShoes` — warn; verts=1048, tris=48 degenerate, influences=3, unweighted=0, edge=1.00..1.00
|
||||
- `BodyTop` — ok; verts=3373, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
|
||||
- `BodyTopRobe` — ok; verts=5046, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
|
||||
- `Body_shapes` — ok; verts=5556, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
|
||||
- `Bottoms` — ok; verts=942, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
|
||||
- `Face` — ok; verts=4045, tris=0 degenerate, influences=1, unweighted=0, edge=1.00..1.00
|
||||
- `Hair` — ok; verts=9, tris=0 degenerate, influences=1, unweighted=0, edge=1.00..1.00
|
||||
- `Hair2` — ok; verts=7022, tris=0 degenerate, influences=2, unweighted=0, edge=1.00..1.00
|
||||
- `HairBack` — ok; verts=760, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
|
||||
- `Panties001` — ok; verts=582, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
|
||||
- `Pants0001` — ok; verts=915, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
|
||||
- `Shoes` — ok; verts=478, tris=0 degenerate, influences=3, unweighted=0, edge=1.00..1.00
|
||||
- `Tops` — ok; verts=1896, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
|
||||
- `TopsEdited` — ok; verts=1896, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
|
||||
- `coat` — ok; verts=1896, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
|
||||
- `hair0001` — ok; verts=7022, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
|
||||
- `hair0002` — ok; verts=7022, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
|
||||
- `hair0003` — ok; verts=7022, tris=0 degenerate, influences=0, unweighted=0, edge=1.00..1.00
|
||||
- `shoes1` — ok; verts=478, tris=0 degenerate, influences=3, unweighted=0, edge=1.00..1.00
|
||||
@@ -0,0 +1,622 @@
|
||||
{
|
||||
"age": "teenager",
|
||||
"fails": [],
|
||||
"info": {
|
||||
"adultArmature": "female",
|
||||
"ageArmature": "teenager-female",
|
||||
"animations": {
|
||||
"aliased": {
|
||||
"swimming-fast": "swimming"
|
||||
},
|
||||
"appended": [
|
||||
"Armature|mixamo.com|Layer0.020",
|
||||
"Armature|mixamo.com|Layer0.018",
|
||||
"Armature|mixamo.com|Layer0.016",
|
||||
"Armature|mixamo.com|Layer0.014"
|
||||
],
|
||||
"missing": [],
|
||||
"scaledLocationCurves": 7028,
|
||||
"stem": "teenager-female"
|
||||
},
|
||||
"outputBase": "edited-normal-teenager-female.blend",
|
||||
"slots": "bottom,face,feet,hair,top",
|
||||
"sourceBase": "edited-normal-female.blend"
|
||||
},
|
||||
"objects": {
|
||||
"Accessory": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.9428835642440181,
|
||||
"edgeRatioMin": 0.8937664900167279,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 40,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 69
|
||||
},
|
||||
"Body": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.153864673534735,
|
||||
"edgeRatioMin": 0.8788689898808826,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 6227,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 6578
|
||||
},
|
||||
"Body-merged": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.153864673534735,
|
||||
"edgeRatioMin": 0.875585733388662,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 8423,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 9178
|
||||
},
|
||||
"BodyBottom": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.9413767566501527,
|
||||
"edgeRatioMin": 0.894824475491785,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 1242,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 1194
|
||||
},
|
||||
"BodyFeet": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.01533051589042,
|
||||
"edgeRatioMin": 0.8905319834891143,
|
||||
"maxInfluences": 3,
|
||||
"note": "",
|
||||
"polygons": 1280,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 1050
|
||||
},
|
||||
"BodyTop": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.153864673534735,
|
||||
"edgeRatioMin": 0.8788684699247973,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 3701,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 3354
|
||||
},
|
||||
"BodyTopRobe": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.153864673534735,
|
||||
"edgeRatioMin": 0.875585733388662,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 4727,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 4383
|
||||
},
|
||||
"Body_shapes": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.153864673534735,
|
||||
"edgeRatioMin": 0.8788689898808826,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 6223,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 2,
|
||||
"unweighted": 0,
|
||||
"vertices": 5550
|
||||
},
|
||||
"Cylinder": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 13.149501164154342,
|
||||
"edgeRatioMin": 0.2067502446063823,
|
||||
"maxInfluences": 0,
|
||||
"note": "",
|
||||
"polygons": 595,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 840,
|
||||
"vertices": 840
|
||||
},
|
||||
"Face": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.9900810993796347,
|
||||
"edgeRatioMin": 0.9896786488081534,
|
||||
"maxInfluences": 1,
|
||||
"note": "",
|
||||
"polygons": 3887,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 57,
|
||||
"unweighted": 0,
|
||||
"vertices": 4109
|
||||
},
|
||||
"Hair": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.9900007559563859,
|
||||
"edgeRatioMin": 0.9899997124609613,
|
||||
"maxInfluences": 1,
|
||||
"note": "",
|
||||
"polygons": 2,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 8
|
||||
},
|
||||
"Hair001": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.3437205849900313,
|
||||
"edgeRatioMin": 0.39954414397197524,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 5658,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 8673
|
||||
},
|
||||
"Hair2": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.9961437951038233,
|
||||
"edgeRatioMin": 0.9739232308631328,
|
||||
"maxInfluences": 2,
|
||||
"note": "",
|
||||
"polygons": 5658,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 8673
|
||||
},
|
||||
"HairBack": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.0706959837595011,
|
||||
"edgeRatioMin": 0.9495467764182186,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 726,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 760
|
||||
},
|
||||
"OnepieceCloth": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.9399357283296659,
|
||||
"edgeRatioMin": 0.9381366728208823,
|
||||
"maxInfluences": 3,
|
||||
"note": "",
|
||||
"polygons": 2,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 6
|
||||
},
|
||||
"Shoes": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.9981897700090412,
|
||||
"edgeRatioMin": 0.9254076761648661,
|
||||
"maxInfluences": 3,
|
||||
"note": "",
|
||||
"polygons": 402,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 608
|
||||
},
|
||||
"Tops": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.9511826460744913,
|
||||
"edgeRatioMin": 0.875585733388662,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 1026,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 1029
|
||||
},
|
||||
"bra1": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.9900000946583577,
|
||||
"edgeRatioMin": 0.2887434501951809,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 430,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 419
|
||||
},
|
||||
"bra2": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.9984894682233404,
|
||||
"edgeRatioMin": 0.4006806605346238,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 506,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 524
|
||||
},
|
||||
"bra3": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.9984894682233404,
|
||||
"edgeRatioMin": 0.4889485839438301,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 410,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 450
|
||||
},
|
||||
"bra4": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.9984894682233404,
|
||||
"edgeRatioMin": 0.4889485839438301,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 410,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 450
|
||||
},
|
||||
"panties": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 50.21989080149669,
|
||||
"edgeRatioMin": 0.19000501701728584,
|
||||
"maxInfluences": 0,
|
||||
"note": "",
|
||||
"polygons": 674,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 797,
|
||||
"vertices": 797
|
||||
},
|
||||
"panties-full": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.3300942912650158,
|
||||
"edgeRatioMin": 0.12847562281583486,
|
||||
"maxInfluences": 0,
|
||||
"note": "",
|
||||
"polygons": 337,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 425,
|
||||
"vertices": 425
|
||||
},
|
||||
"panties2": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 10.548253137802863,
|
||||
"edgeRatioMin": 0.3920225273569097,
|
||||
"maxInfluences": 0,
|
||||
"note": "",
|
||||
"polygons": 1266,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 1328,
|
||||
"vertices": 1328
|
||||
},
|
||||
"panties2-full": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 9.15854640358302,
|
||||
"edgeRatioMin": 0.6364910348952761,
|
||||
"maxInfluences": 0,
|
||||
"note": "",
|
||||
"polygons": 507,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 558,
|
||||
"vertices": 558
|
||||
},
|
||||
"panties3": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 7.299860127337894,
|
||||
"edgeRatioMin": 0.13403860939637366,
|
||||
"maxInfluences": 0,
|
||||
"note": "",
|
||||
"polygons": 758,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 820,
|
||||
"vertices": 820
|
||||
},
|
||||
"panties4": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 7.740842499445837,
|
||||
"edgeRatioMin": 0.1158857342919668,
|
||||
"maxInfluences": 0,
|
||||
"note": "",
|
||||
"polygons": 690,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 752,
|
||||
"vertices": 752
|
||||
},
|
||||
"panties5": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.9443143846802673,
|
||||
"edgeRatioMin": 0.8965532928515044,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 698,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 758
|
||||
},
|
||||
"panties6": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.95104115229466,
|
||||
"edgeRatioMin": 0.8819173360530367,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 698,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 758
|
||||
},
|
||||
"panties7": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.9455920513906987,
|
||||
"edgeRatioMin": 0.8823391488934409,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 698,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 758
|
||||
},
|
||||
"shoes1": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.9807207937208285,
|
||||
"edgeRatioMin": 0.9278301779320385,
|
||||
"maxInfluences": 3,
|
||||
"note": "",
|
||||
"polygons": 414,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 414
|
||||
},
|
||||
"skirt1": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.9422412937744205,
|
||||
"edgeRatioMin": 0.8944357351478923,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 132,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 154
|
||||
},
|
||||
"skirt2": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.9422412937744205,
|
||||
"edgeRatioMin": 0.8944357351478923,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 154,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 176
|
||||
},
|
||||
"skirt3": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.9444972809207006,
|
||||
"edgeRatioMin": 0.9023806027952785,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 132,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 154
|
||||
},
|
||||
"top1": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.0484285122626043,
|
||||
"edgeRatioMin": 0.5756344323442318,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 860,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 852
|
||||
},
|
||||
"top2": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.0484285122626043,
|
||||
"edgeRatioMin": 0.5756344323442318,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 876,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 854
|
||||
},
|
||||
"top3": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.0484285122626043,
|
||||
"edgeRatioMin": 0.5756344323442318,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 876,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 854
|
||||
}
|
||||
},
|
||||
"profileHash": "dd6901d4220ae704",
|
||||
"rig": {
|
||||
"adultBones": 252,
|
||||
"ageBones": 252,
|
||||
"profile": {
|
||||
"arm": 0.92,
|
||||
"bodyRadial": 0.94,
|
||||
"foot": 0.97,
|
||||
"head": 0.99,
|
||||
"headBulge": 1.0,
|
||||
"hipHeight": 0.94,
|
||||
"hipWidth": 0.92,
|
||||
"leg": 0.94,
|
||||
"limbRadial": 0.93,
|
||||
"shoulderWidth": 0.92,
|
||||
"torso": 0.92,
|
||||
"walkSpeedScale": 0.95
|
||||
},
|
||||
"restOrientationErrors": []
|
||||
},
|
||||
"seams": {
|
||||
"coincidentGroups": 3697,
|
||||
"examples": [
|
||||
{
|
||||
"gap": 1e-06,
|
||||
"objects": [
|
||||
"Face"
|
||||
],
|
||||
"position": [
|
||||
0.0649,
|
||||
0.0281,
|
||||
1.3797
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 1e-06,
|
||||
"objects": [
|
||||
"Face"
|
||||
],
|
||||
"position": [
|
||||
0.066,
|
||||
0.0231,
|
||||
1.3767
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 1e-06,
|
||||
"objects": [
|
||||
"Face"
|
||||
],
|
||||
"position": [
|
||||
0.0694,
|
||||
0.0172,
|
||||
1.3783
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 1e-06,
|
||||
"objects": [
|
||||
"Face"
|
||||
],
|
||||
"position": [
|
||||
-0.0649,
|
||||
0.0281,
|
||||
1.3797
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 1e-06,
|
||||
"objects": [
|
||||
"Face"
|
||||
],
|
||||
"position": [
|
||||
-0.066,
|
||||
0.0231,
|
||||
1.3767
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 1e-06,
|
||||
"objects": [
|
||||
"Face"
|
||||
],
|
||||
"position": [
|
||||
-0.0694,
|
||||
0.0172,
|
||||
1.3783
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 2e-06,
|
||||
"objects": [
|
||||
"Hair2"
|
||||
],
|
||||
"position": [
|
||||
0.0001,
|
||||
-0.0385,
|
||||
1.5529
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 2e-06,
|
||||
"objects": [
|
||||
"Hair2"
|
||||
],
|
||||
"position": [
|
||||
-0.0001,
|
||||
-0.0385,
|
||||
1.5529
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 3e-06,
|
||||
"objects": [
|
||||
"Hair2"
|
||||
],
|
||||
"position": [
|
||||
-0.0012,
|
||||
-0.0342,
|
||||
1.5629
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 3e-06,
|
||||
"objects": [
|
||||
"Hair2"
|
||||
],
|
||||
"position": [
|
||||
-0.001,
|
||||
-0.0343,
|
||||
1.5628
|
||||
]
|
||||
}
|
||||
],
|
||||
"maxGap": 6.944388526471647e-06,
|
||||
"snappedVertices": 95
|
||||
},
|
||||
"sex": "female",
|
||||
"status": "warn",
|
||||
"stem": "teenager-female",
|
||||
"timestamp": 1793419282,
|
||||
"warnings": [
|
||||
"bra1",
|
||||
"Cylinder",
|
||||
"panties",
|
||||
"panties-full",
|
||||
"panties2",
|
||||
"panties2-full",
|
||||
"panties3",
|
||||
"panties4"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
# Conversion Report — teenager / female
|
||||
|
||||
- Stem: `teenager-female`
|
||||
- Status: **warn**
|
||||
- Profile hash: `dd6901d4220ae704`
|
||||
- Objects: 37
|
||||
- Warnings: 8
|
||||
- Fails: 0
|
||||
|
||||
## Info
|
||||
- adultArmature: `female`
|
||||
- ageArmature: `teenager-female`
|
||||
- animations: `{'scaledLocationCurves': 7028, 'appended': ['Armature|mixamo.com|Layer0.020', 'Armature|mixamo.com|Layer0.018', 'Armature|mixamo.com|Layer0.016', 'Armature|mixamo.com|Layer0.014'], 'aliased': {'swimming-fast': 'swimming'}, 'missing': [], 'stem': 'teenager-female'}`
|
||||
- outputBase: `edited-normal-teenager-female.blend`
|
||||
- slots: `bottom,face,feet,hair,top`
|
||||
- sourceBase: `edited-normal-female.blend`
|
||||
|
||||
## Seams
|
||||
- coincidentGroups: `3697`
|
||||
- snappedVertices: `95`
|
||||
- maxGap: `6.944388526471647e-06`
|
||||
- examples: `[{'position': [0.0649, 0.0281, 1.3797], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.066, 0.0231, 1.3767], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0694, 0.0172, 1.3783], 'gap': 1e-06, 'objects': ['Face']}, {'position': [-0.0649, 0.0281, 1.3797], 'gap': 1e-06, 'objects': ['Face']}, {'position': [-0.066, 0.0231, 1.3767], 'gap': 1e-06, 'objects': ['Face']}, {'position': [-0.0694, 0.0172, 1.3783], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0001, -0.0385, 1.5529], 'gap': 2e-06, 'objects': ['Hair2']}, {'position': [-0.0001, -0.0385, 1.5529], 'gap': 2e-06, 'objects': ['Hair2']}, {'position': [-0.0012, -0.0342, 1.5629], 'gap': 3e-06, 'objects': ['Hair2']}, {'position': [-0.001, -0.0343, 1.5628], 'gap': 3e-06, 'objects': ['Hair2']}]`
|
||||
|
||||
## Warnings (first 20)
|
||||
|
||||
- `bra1`
|
||||
- `Cylinder`
|
||||
- `panties`
|
||||
- `panties-full`
|
||||
- `panties2`
|
||||
- `panties2-full`
|
||||
- `panties3`
|
||||
- `panties4`
|
||||
|
||||
## Objects
|
||||
|
||||
- `Accessory` — ok; verts=69, tris=0 degenerate, influences=4, unweighted=0, edge=0.89..0.94
|
||||
- `Body` — ok; verts=6578, tris=0 degenerate, influences=4, unweighted=0, edge=0.88..1.15
|
||||
- `Body-merged` — ok; verts=9178, tris=0 degenerate, influences=4, unweighted=0, edge=0.88..1.15
|
||||
- `BodyBottom` — ok; verts=1194, tris=0 degenerate, influences=4, unweighted=0, edge=0.89..0.94
|
||||
- `BodyFeet` — ok; verts=1050, tris=0 degenerate, influences=3, unweighted=0, edge=0.89..1.02
|
||||
- `BodyTop` — ok; verts=3354, tris=0 degenerate, influences=4, unweighted=0, edge=0.88..1.15
|
||||
- `BodyTopRobe` — ok; verts=4383, tris=0 degenerate, influences=4, unweighted=0, edge=0.88..1.15
|
||||
- `Body_shapes` — ok; verts=5550, tris=0 degenerate, influences=4, unweighted=0, edge=0.88..1.15
|
||||
- `Cylinder` — warn; verts=840, tris=0 degenerate, influences=0, unweighted=840, edge=0.21..13.15
|
||||
- `Face` — ok; verts=4109, tris=0 degenerate, influences=1, unweighted=0, edge=0.99..0.99
|
||||
- `Hair` — ok; verts=8, tris=0 degenerate, influences=1, unweighted=0, edge=0.99..0.99
|
||||
- `Hair001` — ok; verts=8673, tris=0 degenerate, influences=4, unweighted=0, edge=0.40..1.34
|
||||
- `Hair2` — ok; verts=8673, tris=0 degenerate, influences=2, unweighted=0, edge=0.97..1.00
|
||||
- `HairBack` — ok; verts=760, tris=0 degenerate, influences=4, unweighted=0, edge=0.95..1.07
|
||||
- `OnepieceCloth` — ok; verts=6, tris=0 degenerate, influences=3, unweighted=0, edge=0.94..0.94
|
||||
- `Shoes` — ok; verts=608, tris=0 degenerate, influences=3, unweighted=0, edge=0.93..1.00
|
||||
- `Tops` — ok; verts=1029, tris=0 degenerate, influences=4, unweighted=0, edge=0.88..0.95
|
||||
- `bra1` — warn; verts=419, tris=0 degenerate, influences=4, unweighted=0, edge=0.29..0.99
|
||||
- `bra2` — ok; verts=524, tris=0 degenerate, influences=4, unweighted=0, edge=0.40..1.00
|
||||
- `bra3` — ok; verts=450, tris=0 degenerate, influences=4, unweighted=0, edge=0.49..1.00
|
||||
- `bra4` — ok; verts=450, tris=0 degenerate, influences=4, unweighted=0, edge=0.49..1.00
|
||||
- `panties` — warn; verts=797, tris=0 degenerate, influences=0, unweighted=797, edge=0.19..50.22
|
||||
- `panties-full` — warn; verts=425, tris=0 degenerate, influences=0, unweighted=425, edge=0.13..1.33
|
||||
- `panties2` — warn; verts=1328, tris=0 degenerate, influences=0, unweighted=1328, edge=0.39..10.55
|
||||
- `panties2-full` — warn; verts=558, tris=0 degenerate, influences=0, unweighted=558, edge=0.64..9.16
|
||||
- `panties3` — warn; verts=820, tris=0 degenerate, influences=0, unweighted=820, edge=0.13..7.30
|
||||
- `panties4` — warn; verts=752, tris=0 degenerate, influences=0, unweighted=752, edge=0.12..7.74
|
||||
- `panties5` — ok; verts=758, tris=0 degenerate, influences=4, unweighted=0, edge=0.90..0.94
|
||||
- `panties6` — ok; verts=758, tris=0 degenerate, influences=4, unweighted=0, edge=0.88..0.95
|
||||
- `panties7` — ok; verts=758, tris=0 degenerate, influences=4, unweighted=0, edge=0.88..0.95
|
||||
- `shoes1` — ok; verts=414, tris=0 degenerate, influences=3, unweighted=0, edge=0.93..0.98
|
||||
- `skirt1` — ok; verts=154, tris=0 degenerate, influences=4, unweighted=0, edge=0.89..0.94
|
||||
- `skirt2` — ok; verts=176, tris=0 degenerate, influences=4, unweighted=0, edge=0.89..0.94
|
||||
- `skirt3` — ok; verts=154, tris=0 degenerate, influences=4, unweighted=0, edge=0.90..0.94
|
||||
- `top1` — ok; verts=852, tris=0 degenerate, influences=4, unweighted=0, edge=0.58..1.05
|
||||
- `top2` — ok; verts=854, tris=0 degenerate, influences=4, unweighted=0, edge=0.58..1.05
|
||||
- `top3` — ok; verts=854, tris=0 degenerate, influences=4, unweighted=0, edge=0.58..1.05
|
||||
@@ -0,0 +1,482 @@
|
||||
{
|
||||
"age": "teenager",
|
||||
"fails": [],
|
||||
"info": {
|
||||
"adultArmature": "male",
|
||||
"ageArmature": "teenager-male",
|
||||
"animations": {
|
||||
"aliased": {
|
||||
"swimming-fast": "swimming"
|
||||
},
|
||||
"appended": [
|
||||
"Armature|mixamo.com|Layer0.020",
|
||||
"Armature|mixamo.com|Layer0.018",
|
||||
"Armature|mixamo.com|Layer0.016",
|
||||
"Armature|mixamo.com|Layer0.014",
|
||||
"Armature|mixamo.com|Layer0.012"
|
||||
],
|
||||
"missing": [],
|
||||
"scaledLocationCurves": 7601,
|
||||
"stem": "teenager-male"
|
||||
},
|
||||
"outputBase": "edited-normal-teenager-male.blend",
|
||||
"slots": "bottom,face,feet,hair,top",
|
||||
"sourceBase": "edited-normal-male.blend"
|
||||
},
|
||||
"objects": {
|
||||
"Accessory": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.9408520391417841,
|
||||
"edgeRatioMin": 0.9129875147754315,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 111,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 145
|
||||
},
|
||||
"Body": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.218153566216659,
|
||||
"edgeRatioMin": 0.7349989979664033,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 6232,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 1,
|
||||
"unweighted": 0,
|
||||
"vertices": 6584
|
||||
},
|
||||
"BodyBottom": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.9415051179583105,
|
||||
"edgeRatioMin": 0.8926428739692449,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 1228,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 1,
|
||||
"unweighted": 0,
|
||||
"vertices": 1181
|
||||
},
|
||||
"BodyBottomPants": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.9409140829633826,
|
||||
"edgeRatioMin": 0.8827862876547917,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 972,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 1,
|
||||
"unweighted": 0,
|
||||
"vertices": 1133
|
||||
},
|
||||
"BodyFeet": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.0153534321863444,
|
||||
"edgeRatioMin": 0.8887109284611726,
|
||||
"maxInfluences": 3,
|
||||
"note": "",
|
||||
"polygons": 1274,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 1,
|
||||
"unweighted": 0,
|
||||
"vertices": 1392
|
||||
},
|
||||
"BodyFeetPants": {
|
||||
"degenerateTris": 48,
|
||||
"edgeRatioMax": 1.0153534321863444,
|
||||
"edgeRatioMin": 0.8887109284611726,
|
||||
"maxInfluences": 3,
|
||||
"note": "",
|
||||
"polygons": 1466,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 1602
|
||||
},
|
||||
"BodyFeetPantsShoes": {
|
||||
"degenerateTris": 48,
|
||||
"edgeRatioMax": 1.0099758747299858,
|
||||
"edgeRatioMin": 0.9253361353429929,
|
||||
"maxInfluences": 3,
|
||||
"note": "",
|
||||
"polygons": 830,
|
||||
"quality": "warn",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 1048
|
||||
},
|
||||
"BodyTop": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.218153566216659,
|
||||
"edgeRatioMin": 0.7349989979664033,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 3726,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 1,
|
||||
"unweighted": 0,
|
||||
"vertices": 3373
|
||||
},
|
||||
"BodyTopRobe": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.1954519257878777,
|
||||
"edgeRatioMin": 0.7349989979664033,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 5384,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 1,
|
||||
"unweighted": 0,
|
||||
"vertices": 5046
|
||||
},
|
||||
"Body_shapes": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.218153566216659,
|
||||
"edgeRatioMin": 0.7349989979664033,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 6228,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 2,
|
||||
"unweighted": 0,
|
||||
"vertices": 5556
|
||||
},
|
||||
"Bottoms": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.9635867444531326,
|
||||
"edgeRatioMin": 0.8827862876547917,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 826,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 942
|
||||
},
|
||||
"Face": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.9902318510318413,
|
||||
"edgeRatioMin": 0.9899010785060705,
|
||||
"maxInfluences": 1,
|
||||
"note": "",
|
||||
"polygons": 3839,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 57,
|
||||
"unweighted": 0,
|
||||
"vertices": 4045
|
||||
},
|
||||
"Hair": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.9900002385614128,
|
||||
"edgeRatioMin": 0.9899999851477491,
|
||||
"maxInfluences": 1,
|
||||
"note": "",
|
||||
"polygons": 4,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 9
|
||||
},
|
||||
"Hair2": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.9990771032056067,
|
||||
"edgeRatioMin": 0.949542700174737,
|
||||
"maxInfluences": 2,
|
||||
"note": "",
|
||||
"polygons": 4483,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 7022
|
||||
},
|
||||
"HairBack": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.0723475348632554,
|
||||
"edgeRatioMin": 0.9495563211922972,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 724,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 760
|
||||
},
|
||||
"Panties001": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.9414287020897463,
|
||||
"edgeRatioMin": 0.8949860611150358,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 604,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 582
|
||||
},
|
||||
"Pants0001": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.9741146771468555,
|
||||
"edgeRatioMin": 0.7167993480571109,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 898,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 915
|
||||
},
|
||||
"Shoes": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.0099758747299858,
|
||||
"edgeRatioMin": 0.9253361353429929,
|
||||
"maxInfluences": 3,
|
||||
"note": "",
|
||||
"polygons": 334,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 478
|
||||
},
|
||||
"Tops": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.058850186591336,
|
||||
"edgeRatioMin": 0.8334681396211425,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 1658,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 1896
|
||||
},
|
||||
"TopsEdited": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.058850186591336,
|
||||
"edgeRatioMin": 0.8334681396211425,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 1658,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 1896
|
||||
},
|
||||
"coat": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.9465083443563386,
|
||||
"edgeRatioMin": 0.8345250576121954,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 1658,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 1896
|
||||
},
|
||||
"hair0001": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.1823451224746337,
|
||||
"edgeRatioMin": 0.949542700174737,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 4483,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 7022
|
||||
},
|
||||
"hair0002": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.1823451224746337,
|
||||
"edgeRatioMin": 0.949542700174737,
|
||||
"maxInfluences": 4,
|
||||
"note": "",
|
||||
"polygons": 4483,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 7022
|
||||
},
|
||||
"hair0003": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 1.0,
|
||||
"edgeRatioMin": 1.0,
|
||||
"maxInfluences": 0,
|
||||
"note": "",
|
||||
"polygons": 4483,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 7022
|
||||
},
|
||||
"shoes1": {
|
||||
"degenerateTris": 0,
|
||||
"edgeRatioMax": 0.9949808923340873,
|
||||
"edgeRatioMin": 0.9268173377136528,
|
||||
"maxInfluences": 3,
|
||||
"note": "",
|
||||
"polygons": 334,
|
||||
"quality": "ok",
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"vertices": 478
|
||||
}
|
||||
},
|
||||
"profileHash": "dd6901d4220ae704",
|
||||
"rig": {
|
||||
"adultBones": 242,
|
||||
"ageBones": 242,
|
||||
"profile": {
|
||||
"arm": 0.92,
|
||||
"bodyRadial": 0.94,
|
||||
"foot": 0.97,
|
||||
"head": 0.99,
|
||||
"headBulge": 1.0,
|
||||
"hipHeight": 0.94,
|
||||
"hipWidth": 0.92,
|
||||
"leg": 0.94,
|
||||
"limbRadial": 0.93,
|
||||
"shoulderWidth": 0.92,
|
||||
"torso": 0.92,
|
||||
"walkSpeedScale": 0.95
|
||||
},
|
||||
"restOrientationErrors": []
|
||||
},
|
||||
"seams": {
|
||||
"coincidentGroups": 5119,
|
||||
"examples": [
|
||||
{
|
||||
"gap": 2e-06,
|
||||
"objects": [
|
||||
"BodyTop",
|
||||
"BodyTopRobe"
|
||||
],
|
||||
"position": [
|
||||
0.0,
|
||||
0.09,
|
||||
1.4972
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 1e-06,
|
||||
"objects": [
|
||||
"BodyTop",
|
||||
"BodyTopRobe"
|
||||
],
|
||||
"position": [
|
||||
0.0,
|
||||
0.0858,
|
||||
1.6039
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 2e-06,
|
||||
"objects": [
|
||||
"BodyTop",
|
||||
"BodyTopRobe"
|
||||
],
|
||||
"position": [
|
||||
0.0,
|
||||
-0.0184,
|
||||
1.5008
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 2e-06,
|
||||
"objects": [
|
||||
"BodyTop",
|
||||
"Face"
|
||||
],
|
||||
"position": [
|
||||
0.0633,
|
||||
0.0369,
|
||||
1.6496
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 2e-06,
|
||||
"objects": [
|
||||
"BodyTop",
|
||||
"Face"
|
||||
],
|
||||
"position": [
|
||||
-0.0633,
|
||||
0.0369,
|
||||
1.6496
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 1e-06,
|
||||
"objects": [
|
||||
"BodyTop",
|
||||
"BodyTopRobe",
|
||||
"Face"
|
||||
],
|
||||
"position": [
|
||||
0.0842,
|
||||
0.0277,
|
||||
1.6955
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 1e-06,
|
||||
"objects": [
|
||||
"BodyTop",
|
||||
"BodyTopRobe",
|
||||
"Face"
|
||||
],
|
||||
"position": [
|
||||
-0.0842,
|
||||
0.0277,
|
||||
1.6955
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 1e-06,
|
||||
"objects": [
|
||||
"Face"
|
||||
],
|
||||
"position": [
|
||||
0.0633,
|
||||
0.0344,
|
||||
1.6475
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 1e-06,
|
||||
"objects": [
|
||||
"Face"
|
||||
],
|
||||
"position": [
|
||||
0.0636,
|
||||
0.0319,
|
||||
1.6457
|
||||
]
|
||||
},
|
||||
{
|
||||
"gap": 1e-06,
|
||||
"objects": [
|
||||
"Face"
|
||||
],
|
||||
"position": [
|
||||
0.0645,
|
||||
0.0294,
|
||||
1.6445
|
||||
]
|
||||
}
|
||||
],
|
||||
"maxGap": 7.936056721462239e-06,
|
||||
"snappedVertices": 226
|
||||
},
|
||||
"sex": "male",
|
||||
"status": "warn",
|
||||
"stem": "teenager-male",
|
||||
"timestamp": 1793419277,
|
||||
"warnings": [
|
||||
"BodyFeetPants",
|
||||
"BodyFeetPantsShoes"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
# Conversion Report — teenager / male
|
||||
|
||||
- Stem: `teenager-male`
|
||||
- Status: **warn**
|
||||
- Profile hash: `dd6901d4220ae704`
|
||||
- Objects: 25
|
||||
- Warnings: 2
|
||||
- Fails: 0
|
||||
|
||||
## Info
|
||||
- adultArmature: `male`
|
||||
- ageArmature: `teenager-male`
|
||||
- animations: `{'scaledLocationCurves': 7601, 'appended': ['Armature|mixamo.com|Layer0.020', 'Armature|mixamo.com|Layer0.018', 'Armature|mixamo.com|Layer0.016', 'Armature|mixamo.com|Layer0.014', 'Armature|mixamo.com|Layer0.012'], 'aliased': {'swimming-fast': 'swimming'}, 'missing': [], 'stem': 'teenager-male'}`
|
||||
- outputBase: `edited-normal-teenager-male.blend`
|
||||
- slots: `bottom,face,feet,hair,top`
|
||||
- sourceBase: `edited-normal-male.blend`
|
||||
|
||||
## Seams
|
||||
- coincidentGroups: `5119`
|
||||
- snappedVertices: `226`
|
||||
- maxGap: `7.936056721462239e-06`
|
||||
- examples: `[{'position': [0.0, 0.09, 1.4972], 'gap': 2e-06, 'objects': ['BodyTop', 'BodyTopRobe']}, {'position': [0.0, 0.0858, 1.6039], 'gap': 1e-06, 'objects': ['BodyTop', 'BodyTopRobe']}, {'position': [0.0, -0.0184, 1.5008], 'gap': 2e-06, 'objects': ['BodyTop', 'BodyTopRobe']}, {'position': [0.0633, 0.0369, 1.6496], 'gap': 2e-06, 'objects': ['BodyTop', 'Face']}, {'position': [-0.0633, 0.0369, 1.6496], 'gap': 2e-06, 'objects': ['BodyTop', 'Face']}, {'position': [0.0842, 0.0277, 1.6955], 'gap': 1e-06, 'objects': ['BodyTop', 'BodyTopRobe', 'Face']}, {'position': [-0.0842, 0.0277, 1.6955], 'gap': 1e-06, 'objects': ['BodyTop', 'BodyTopRobe', 'Face']}, {'position': [0.0633, 0.0344, 1.6475], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0636, 0.0319, 1.6457], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0645, 0.0294, 1.6445], 'gap': 1e-06, 'objects': ['Face']}]`
|
||||
|
||||
## Warnings (first 20)
|
||||
|
||||
- `BodyFeetPants`
|
||||
- `BodyFeetPantsShoes`
|
||||
|
||||
## Objects
|
||||
|
||||
- `Accessory` — ok; verts=145, tris=0 degenerate, influences=4, unweighted=0, edge=0.91..0.94
|
||||
- `Body` — ok; verts=6584, tris=0 degenerate, influences=4, unweighted=0, edge=0.73..1.22
|
||||
- `BodyBottom` — ok; verts=1181, tris=0 degenerate, influences=4, unweighted=0, edge=0.89..0.94
|
||||
- `BodyBottomPants` — ok; verts=1133, tris=0 degenerate, influences=4, unweighted=0, edge=0.88..0.94
|
||||
- `BodyFeet` — ok; verts=1392, tris=0 degenerate, influences=3, unweighted=0, edge=0.89..1.02
|
||||
- `BodyFeetPants` — warn; verts=1602, tris=48 degenerate, influences=3, unweighted=0, edge=0.89..1.02
|
||||
- `BodyFeetPantsShoes` — warn; verts=1048, tris=48 degenerate, influences=3, unweighted=0, edge=0.93..1.01
|
||||
- `BodyTop` — ok; verts=3373, tris=0 degenerate, influences=4, unweighted=0, edge=0.73..1.22
|
||||
- `BodyTopRobe` — ok; verts=5046, tris=0 degenerate, influences=4, unweighted=0, edge=0.73..1.20
|
||||
- `Body_shapes` — ok; verts=5556, tris=0 degenerate, influences=4, unweighted=0, edge=0.73..1.22
|
||||
- `Bottoms` — ok; verts=942, tris=0 degenerate, influences=4, unweighted=0, edge=0.88..0.96
|
||||
- `Face` — ok; verts=4045, tris=0 degenerate, influences=1, unweighted=0, edge=0.99..0.99
|
||||
- `Hair` — ok; verts=9, tris=0 degenerate, influences=1, unweighted=0, edge=0.99..0.99
|
||||
- `Hair2` — ok; verts=7022, tris=0 degenerate, influences=2, unweighted=0, edge=0.95..1.00
|
||||
- `HairBack` — ok; verts=760, tris=0 degenerate, influences=4, unweighted=0, edge=0.95..1.07
|
||||
- `Panties001` — ok; verts=582, tris=0 degenerate, influences=4, unweighted=0, edge=0.89..0.94
|
||||
- `Pants0001` — ok; verts=915, tris=0 degenerate, influences=4, unweighted=0, edge=0.72..0.97
|
||||
- `Shoes` — ok; verts=478, tris=0 degenerate, influences=3, unweighted=0, edge=0.93..1.01
|
||||
- `Tops` — ok; verts=1896, tris=0 degenerate, influences=4, unweighted=0, edge=0.83..1.06
|
||||
- `TopsEdited` — ok; verts=1896, tris=0 degenerate, influences=4, unweighted=0, edge=0.83..1.06
|
||||
- `coat` — ok; verts=1896, tris=0 degenerate, influences=4, unweighted=0, edge=0.83..0.95
|
||||
- `hair0001` — ok; verts=7022, tris=0 degenerate, influences=4, unweighted=0, edge=0.95..1.18
|
||||
- `hair0002` — ok; verts=7022, tris=0 degenerate, influences=4, unweighted=0, edge=0.95..1.18
|
||||
- `hair0003` — ok; verts=7022, tris=0 degenerate, influences=0, unweighted=0, edge=1.00..1.00
|
||||
- `shoes1` — ok; verts=478, tris=0 degenerate, influences=3, unweighted=0, edge=0.93..0.99
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,660 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate first-pass age-band character seeds from the adult assets.
|
||||
|
||||
Run with Blender 3.6 batch mode, for example::
|
||||
|
||||
blender -b -P assets/blender/characters/generate_age_blends.py -- \
|
||||
--age elder --sex male --report-only
|
||||
|
||||
The script is an authoring tool: it writes committed seed ``.blend`` files and
|
||||
conversion reports. It is deliberately NOT part of the CMake build, and it
|
||||
will not overwrite a file that changed since it was generated unless
|
||||
``--force`` is given. See AGES-PLAN.md for the design.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
import bpy
|
||||
|
||||
_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
if _THIS_DIR not in sys.path:
|
||||
sys.path.insert(0, _THIS_DIR)
|
||||
|
||||
from agegen import anim, deform, profiles, report, rig, weights # noqa: E402
|
||||
|
||||
|
||||
def log(msg):
|
||||
print("[agegen] " + msg, flush=True)
|
||||
|
||||
|
||||
def sha256(path):
|
||||
h = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def manifest_path(out_dir):
|
||||
return os.path.join(out_dir, "AGE-GENERATION.json")
|
||||
|
||||
|
||||
def load_manifest(out_dir):
|
||||
path = manifest_path(out_dir)
|
||||
if not os.path.exists(path):
|
||||
return {"version": 1, "stems": {}}
|
||||
try:
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
if "stems" not in data:
|
||||
data["stems"] = {}
|
||||
return data
|
||||
except Exception:
|
||||
return {"version": 1, "stems": {}}
|
||||
|
||||
|
||||
def save_manifest(out_dir, data):
|
||||
data["profileHash"] = profiles.profile_hash()
|
||||
data["updated"] = int(time.time())
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
with open(manifest_path(out_dir), "w") as f:
|
||||
json.dump(data, f, indent=2, sort_keys=True)
|
||||
|
||||
|
||||
def find_anim_source(sex, explicit):
|
||||
if explicit:
|
||||
return explicit if os.path.exists(explicit) else None
|
||||
name = f"vrm-vroid-normal-{sex}.blend"
|
||||
roots = [
|
||||
os.getcwd(),
|
||||
os.path.dirname(_THIS_DIR),
|
||||
os.path.dirname(os.path.dirname(_THIS_DIR)),
|
||||
"/media/slapin/library/ogre3/ogre-projects/world2",
|
||||
]
|
||||
patterns = []
|
||||
for root in roots:
|
||||
patterns += [
|
||||
os.path.join(root, "build-vscode", "assets", "blender", name),
|
||||
os.path.join(root, "build", "assets", "blender", name),
|
||||
os.path.join(root, "assets", "blender", name),
|
||||
]
|
||||
patterns += glob.glob(
|
||||
os.path.join(os.getcwd(), "build*", "assets", "blender", name))
|
||||
for path in patterns:
|
||||
if path and os.path.exists(path):
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def source_paths(age, sex, source_dir):
|
||||
stem = profiles.stem(age, sex)
|
||||
base = os.path.join(source_dir,
|
||||
f"edited-normal-{sex}.blend")
|
||||
clothes = {
|
||||
slot: os.path.join(source_dir, f"clothes-{sex}-{slot}.blend")
|
||||
for slot in ("top", "bottom", "feet", "hair")
|
||||
}
|
||||
out_base = os.path.join(source_dir,
|
||||
f"edited-normal-{age}-{sex}.blend")
|
||||
out_clothes = {
|
||||
slot: os.path.join(source_dir,
|
||||
f"clothes-{age}-{sex}-{slot}.blend")
|
||||
for slot in ("top", "bottom", "feet", "hair")
|
||||
}
|
||||
return stem, base, clothes, out_base, out_clothes
|
||||
|
||||
|
||||
def all_outputs(out_base, out_clothes):
|
||||
return [out_base] + [out_clothes[s] for s in
|
||||
("top", "bottom", "feet", "hair")]
|
||||
|
||||
|
||||
def stem_protected(stem, outputs, manifest, force):
|
||||
if force:
|
||||
return False
|
||||
entry = manifest.get("stems", {}).get(stem)
|
||||
if not entry:
|
||||
# Existing files without a manifest are assumed to be hand-authored.
|
||||
return any(os.path.exists(p) for p in outputs)
|
||||
if entry.get("state") == "hand-edited":
|
||||
return True
|
||||
hashes = entry.get("outputHashes", {})
|
||||
for path in outputs:
|
||||
if not os.path.exists(path):
|
||||
continue
|
||||
want = hashes.get(os.path.basename(path))
|
||||
if want and sha256(path) != want:
|
||||
log(f" {os.path.basename(path)} changed since generation; "
|
||||
f"treating as hand-edited")
|
||||
return True
|
||||
if not want:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def append_objects(blend_path, names):
|
||||
before = set(bpy.data.objects)
|
||||
with bpy.data.libraries.load(blend_path, link=False) as (src, dst):
|
||||
dst.objects = [n for n in names if n in src.objects]
|
||||
loaded = []
|
||||
for obj in dst.objects:
|
||||
if obj is None:
|
||||
continue
|
||||
try:
|
||||
bpy.context.collection.objects.link(obj)
|
||||
except RuntimeError:
|
||||
pass
|
||||
loaded.append(obj)
|
||||
# Blender may pull in child objects of an appended armature. Keep only
|
||||
# the explicitly requested objects; the conversion math does not need
|
||||
# their children, and clothes seed files must not contain body meshes.
|
||||
keep = set(loaded)
|
||||
for obj in list(bpy.data.objects):
|
||||
if obj not in before and obj not in keep:
|
||||
try:
|
||||
bpy.data.objects.remove(obj, do_unlink=True)
|
||||
except Exception:
|
||||
pass
|
||||
return loaded
|
||||
|
||||
|
||||
def delete_object(obj):
|
||||
if obj is None:
|
||||
return
|
||||
try:
|
||||
bpy.data.objects.remove(obj, do_unlink=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def purge_orphans():
|
||||
for _ in range(3):
|
||||
try:
|
||||
bpy.ops.outliner.orphans_purge(
|
||||
do_local_ids=True, do_linked_ids=True,
|
||||
do_recursive=True)
|
||||
except Exception:
|
||||
break
|
||||
|
||||
|
||||
def cleanup_seed_objects(adult_arm):
|
||||
"""Remove Blender control junk that must not live in a committed seed."""
|
||||
for obj in list(bpy.data.objects):
|
||||
if obj == adult_arm:
|
||||
continue
|
||||
low = obj.name.lower()
|
||||
if obj.name.startswith("cs_") or "collider" in low:
|
||||
delete_object(obj)
|
||||
continue
|
||||
if obj.type == 'ARMATURE':
|
||||
# Keep only the age armature (and any hair armature handled by
|
||||
# the clothes converter). The adult armature is deleted by the
|
||||
# caller after animation retargeting.
|
||||
continue
|
||||
for obj in list(bpy.data.objects):
|
||||
if obj.type == 'EMPTY' and obj.name.startswith("cs_"):
|
||||
delete_object(obj)
|
||||
|
||||
|
||||
def set_catalog_props(obj, age, sex):
|
||||
if not obj or obj.type != 'MESH':
|
||||
return
|
||||
if all(k in obj for k in ("age", "sex", "slot")):
|
||||
obj["age"] = profiles.normalize_age(age)
|
||||
obj["sex"] = sex
|
||||
obj["agegen_state"] = "auto-seed"
|
||||
obj["agegen_profile"] = profiles.profile_hash()
|
||||
|
||||
|
||||
def convert_body(age, sex, base_path, out_path, anim_source,
|
||||
write, report_only):
|
||||
log(f"loading adult base {base_path}")
|
||||
bpy.ops.wm.open_mainfile(filepath=base_path)
|
||||
bpy.context.scene.frame_set(1)
|
||||
|
||||
adult_arm = bpy.data.objects.get(sex)
|
||||
if adult_arm is None or adult_arm.type != 'ARMATURE':
|
||||
raise RuntimeError(f"adult armature '{sex}' not found in {base_path}")
|
||||
|
||||
prof = profiles.profile(age)
|
||||
stem = profiles.stem(age, sex)
|
||||
age_arm = rig.build_age_armature(adult_arm, age, sex, prof)
|
||||
log(f"built armature {age_arm.name}")
|
||||
|
||||
rest_data = rig.rest_transform_data(adult_arm, age_arm, age, prof)
|
||||
dmat = rig.dmatrices_from_data(rest_data)
|
||||
bad = rig.validate_rest_orientation(adult_arm, age_arm)
|
||||
if bad:
|
||||
raise RuntimeError("rest orientation changed for bones: " +
|
||||
", ".join(bad[:10]))
|
||||
|
||||
rep = report.ConversionReport(age, sex)
|
||||
rep.info["sourceBase"] = os.path.basename(base_path)
|
||||
rep.info["adultArmature"] = adult_arm.name
|
||||
rep.info["ageArmature"] = age_arm.name
|
||||
rep.rig = {
|
||||
"adultBones": len(adult_arm.data.bones),
|
||||
"ageBones": len(age_arm.data.bones),
|
||||
"restOrientationErrors": bad,
|
||||
"profile": prof,
|
||||
}
|
||||
|
||||
converted = []
|
||||
for obj in list(bpy.data.objects):
|
||||
if obj.type != 'MESH':
|
||||
continue
|
||||
if obj.name.startswith("cs_"):
|
||||
continue
|
||||
if obj == adult_arm:
|
||||
continue
|
||||
if obj.data is None:
|
||||
continue
|
||||
if obj.vertex_groups:
|
||||
weights.limit_vertex_influences(obj, 4)
|
||||
metrics = deform.deform_mesh_object(
|
||||
obj, adult_arm, age_arm, dmat, prof, retarget_parent=True)
|
||||
if metrics is None:
|
||||
continue
|
||||
set_catalog_props(obj, age, sex)
|
||||
is_catalog = all(k in obj for k in ("age", "sex", "slot"))
|
||||
quality = report.evaluate_object_quality(metrics, is_catalog)
|
||||
obj["agegen_quality"] = quality
|
||||
rep.add_object(obj.name, metrics, quality=quality)
|
||||
if is_catalog:
|
||||
converted.append((obj, metrics))
|
||||
|
||||
seam = deform.snap_seams(converted)
|
||||
rep.seams = seam
|
||||
log(f"seam groups={seam['coincidentGroups']} "
|
||||
f"snapped={seam['snappedVertices']} maxGap={seam['maxGap']:.6f}")
|
||||
|
||||
# Required catalog slots per sex.
|
||||
slots = sorted({obj.get("slot") for obj, _ in converted
|
||||
if obj.get("slot")})
|
||||
rep.info["slots"] = ",".join(slots)
|
||||
for required in ("face", "top", "bottom", "feet", "hair"):
|
||||
if required not in slots:
|
||||
rep.info.setdefault("missingSlots", [])
|
||||
rep.info["missingSlots"].append(required)
|
||||
|
||||
if anim_source and os.path.exists(anim_source):
|
||||
try:
|
||||
summary = anim.append_and_retarget(
|
||||
age_arm, adult_arm, anim_source, age, sex)
|
||||
rep.info["animations"] = summary
|
||||
if summary.get("missing"):
|
||||
rep.info["missingAnimations"] = summary["missing"]
|
||||
except Exception as exc:
|
||||
rep.info["animationError"] = str(exc)
|
||||
log(f"animation retarget failed: {exc}")
|
||||
else:
|
||||
rep.info["animationError"] = "animation source not found"
|
||||
log("animation source not found; seed will have no clips")
|
||||
|
||||
# Remove the adult armature and control junk; re-parented meshes remain.
|
||||
cleanup_seed_objects(adult_arm)
|
||||
delete_object(adult_arm)
|
||||
|
||||
# Name and validate the single body armature.
|
||||
age_arm.name = stem
|
||||
age_arm.data.name = stem
|
||||
set_catalog_props(age_arm, age, sex)
|
||||
for obj in bpy.data.objects:
|
||||
set_catalog_props(obj, age, sex)
|
||||
|
||||
rep.info["outputBase"] = os.path.basename(out_path)
|
||||
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
||||
purge_orphans()
|
||||
if write:
|
||||
bpy.ops.wm.save_as_mainfile(filepath=out_path)
|
||||
log(f"saved body seed {out_path}")
|
||||
return rep, rest_data, age_arm
|
||||
|
||||
|
||||
def find_own_hair_armature(obj, adult_arm, age_arm):
|
||||
own_name = obj.get("own_armature_name", "")
|
||||
if own_name:
|
||||
arm = bpy.data.objects.get(own_name)
|
||||
if arm and arm.type == 'ARMATURE':
|
||||
return arm
|
||||
for mod in obj.modifiers:
|
||||
if mod.type != 'ARMATURE' or mod.object is None:
|
||||
continue
|
||||
if mod.object != adult_arm and mod.object != age_arm:
|
||||
return mod.object
|
||||
if obj.parent and obj.parent.type == 'ARMATURE' and \
|
||||
obj.parent not in (adult_arm, age_arm):
|
||||
return obj.parent
|
||||
return None
|
||||
|
||||
|
||||
def rename_hair_armature(arm, stem, original_name):
|
||||
safe = "".join(c if c.isalnum() or c in "-_" else "_"
|
||||
for c in original_name)
|
||||
new_name = f"{stem}-hair-{safe}"
|
||||
arm.name = new_name
|
||||
arm.data.name = new_name
|
||||
return new_name
|
||||
|
||||
|
||||
def transfer_weights_from_body(obj, body_obj):
|
||||
"""Copy body weight groups onto an unskinned garment/helper.
|
||||
|
||||
The source clothes files intentionally carry no rig; the normal build
|
||||
transfers weights later. Doing it before conversion gives the geometry a
|
||||
smooth deformation instead of nearest-bone rigid pieces, and the build
|
||||
repeats it from the converted body as usual.
|
||||
"""
|
||||
if body_obj is None or body_obj.type != 'MESH':
|
||||
return False
|
||||
if not body_obj.vertex_groups:
|
||||
return False
|
||||
obj.vertex_groups.clear()
|
||||
dt = obj.modifiers.new(name="WT", type='DATA_TRANSFER')
|
||||
dt.object = body_obj
|
||||
dt.use_vert_data = True
|
||||
dt.data_types_verts = {'VGROUP_WEIGHTS'}
|
||||
dt.layers_vgroup_select_src = 'ALL'
|
||||
dt.vert_mapping = 'POLYINTERP_NEAREST'
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
try:
|
||||
bpy.ops.object.datalayout_transfer(modifier=dt.name)
|
||||
bpy.ops.object.modifier_apply(modifier=dt.name)
|
||||
except Exception as exc:
|
||||
log(f" weight transfer failed for {obj.name}: {exc}")
|
||||
try:
|
||||
obj.modifiers.remove(dt)
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
weights.limit_vertex_influences(obj, 4)
|
||||
return True
|
||||
|
||||
|
||||
def convert_clothes(age, sex, slot, clothes_path, out_path,
|
||||
adult_base, body_seed, rest_data, write, rep,
|
||||
profile):
|
||||
log(f"converting {slot}: {clothes_path}")
|
||||
bpy.ops.wm.open_mainfile(filepath=clothes_path)
|
||||
stem = profiles.stem(age, sex)
|
||||
|
||||
adult_arms = append_objects(adult_base, [sex])
|
||||
age_arms = append_objects(body_seed, [stem])
|
||||
if not adult_arms or not age_arms:
|
||||
raise RuntimeError("failed to append adult/age armatures")
|
||||
adult_arm = adult_arms[0]
|
||||
age_arm = age_arms[0]
|
||||
dmat = rig.dmatrices_from_data(rest_data)
|
||||
prof = profile
|
||||
adult_head, age_head = rig.head_pivots(adult_arm, age_arm)
|
||||
head_scale = float(prof["head"])
|
||||
|
||||
ref_cache = {}
|
||||
|
||||
def get_ref(name):
|
||||
if not name:
|
||||
return None
|
||||
if name not in ref_cache:
|
||||
objs = append_objects(body_seed, [name])
|
||||
ref_cache[name] = objs[0] if objs else None
|
||||
return ref_cache[name]
|
||||
|
||||
hair_arm_map = {}
|
||||
meshes = [o for o in bpy.data.objects
|
||||
if o.type == 'MESH' and o.data is not None]
|
||||
for obj in meshes:
|
||||
own_arm = find_own_hair_armature(obj, adult_arm, age_arm)
|
||||
if own_arm is not None:
|
||||
if own_arm.name not in hair_arm_map:
|
||||
old = own_arm.name
|
||||
new = rename_hair_armature(own_arm, stem, old)
|
||||
hair_arm_map[own_arm.name] = new
|
||||
rig.scale_hair_armature(own_arm, adult_head, age_head,
|
||||
head_scale)
|
||||
log(f" scaled hair armature {old} -> {new}")
|
||||
deform.transform_hair_uniform(obj, adult_head, age_head,
|
||||
head_scale)
|
||||
obj["has_own_armature"] = True
|
||||
obj["own_armature_name"] = own_arm.name
|
||||
obj.parent = own_arm
|
||||
mod = next((m for m in obj.modifiers
|
||||
if m.type == 'ARMATURE'), None)
|
||||
if mod:
|
||||
mod.object = own_arm
|
||||
metrics = {
|
||||
"vertices": len(obj.data.vertices),
|
||||
"polygons": len(obj.data.polygons),
|
||||
"shapeKeys": 0,
|
||||
"unweighted": 0,
|
||||
"maxInfluences": 0,
|
||||
"edgeRatioMin": 1.0,
|
||||
"edgeRatioMax": 1.0,
|
||||
"degenerateTris": 0,
|
||||
}
|
||||
quality = "ok"
|
||||
else:
|
||||
ref = get_ref(obj.get("ref_clothing", ""))
|
||||
transfer_weights_from_body(obj, ref)
|
||||
metrics = deform.deform_mesh_object(
|
||||
obj, adult_arm, age_arm, dmat, prof,
|
||||
retarget_parent=False)
|
||||
quality = report.evaluate_object_quality(metrics, True) \
|
||||
if metrics else "warn"
|
||||
obj["agegen_quality"] = quality
|
||||
# Garment seed files carry no body armature.
|
||||
obj.parent = None
|
||||
for mod in list(obj.modifiers):
|
||||
if mod.type == 'ARMATURE':
|
||||
obj.modifiers.remove(mod)
|
||||
if "ref_layer" in obj:
|
||||
obj["ref_age"] = profiles.normalize_age(age)
|
||||
obj["ref_sex"] = sex
|
||||
obj["agegen_state"] = "auto-seed"
|
||||
rep.add_object(obj.name, metrics, quality=quality)
|
||||
|
||||
# Remove the appended body armatures and reference helpers, keep hair
|
||||
# armatures.
|
||||
for ref in ref_cache.values():
|
||||
delete_object(ref)
|
||||
for arm in age_arms + adult_arms:
|
||||
delete_object(arm)
|
||||
|
||||
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
||||
purge_orphans()
|
||||
if write:
|
||||
bpy.ops.wm.save_as_mainfile(filepath=out_path)
|
||||
log(f"saved clothes seed {out_path}")
|
||||
|
||||
|
||||
def check_seed(age, sex, source_dir):
|
||||
stem, base, clothes, out_base, out_clothes = source_paths(
|
||||
age, sex, source_dir)
|
||||
outputs = all_outputs(out_base, out_clothes)
|
||||
manifest = load_manifest(source_dir)
|
||||
entry = manifest.get("stems", {}).get(stem)
|
||||
missing = [p for p in outputs if not os.path.exists(p)]
|
||||
problems = []
|
||||
if missing:
|
||||
problems.append("missing: " + ", ".join(os.path.basename(p)
|
||||
for p in missing))
|
||||
if not entry:
|
||||
problems.append("no manifest entry")
|
||||
else:
|
||||
if entry.get("profileHash") not in (None,
|
||||
profiles.profile_hash()):
|
||||
problems.append("profile hash mismatch")
|
||||
hashes = entry.get("outputHashes", {})
|
||||
for p in outputs:
|
||||
if not os.path.exists(p):
|
||||
continue
|
||||
want = hashes.get(os.path.basename(p))
|
||||
if not want:
|
||||
problems.append("no hash for " + os.path.basename(p))
|
||||
elif sha256(p) != want:
|
||||
problems.append("changed: " + os.path.basename(p))
|
||||
if problems:
|
||||
log(f"CHECK FAIL {stem}: " + "; ".join(problems))
|
||||
return False
|
||||
log(f"CHECK OK {stem}")
|
||||
return True
|
||||
|
||||
|
||||
def process_pair(age, sex, args):
|
||||
stem, base, clothes, out_base, out_clothes = source_paths(
|
||||
age, sex, args.source_dir)
|
||||
outputs = all_outputs(out_base, out_clothes)
|
||||
manifest = load_manifest(args.source_dir)
|
||||
|
||||
if args.check:
|
||||
return check_seed(age, sex, args.source_dir)
|
||||
|
||||
if not os.path.exists(base):
|
||||
raise RuntimeError(f"adult base not found: {base}")
|
||||
if stem_protected(stem, outputs, manifest, args.force):
|
||||
log(f"SKIP {stem}: output exists and is hand-edited/changed "
|
||||
f"(use --force to overwrite)")
|
||||
return True
|
||||
|
||||
anim_source = find_anim_source(sex, args.anim_source)
|
||||
if anim_source:
|
||||
log(f"animation source: {anim_source}")
|
||||
else:
|
||||
log("WARNING: animation source not found; no clips will be added")
|
||||
|
||||
# Body conversion needs a file for the clothes pass even in report-only
|
||||
# mode; use a temp file that is removed afterwards.
|
||||
tmp_dir = tempfile.mkdtemp(prefix="agegen-")
|
||||
body_seed = out_base if (args.write and not args.report_only) else \
|
||||
os.path.join(tmp_dir, os.path.basename(out_base))
|
||||
|
||||
rep, rest_data, _age_arm = convert_body(
|
||||
age, sex, base, body_seed, anim_source,
|
||||
write=True, report_only=args.report_only)
|
||||
if args.report_only:
|
||||
log(f"temporary body seed: {body_seed}")
|
||||
|
||||
if not args.no_clothes:
|
||||
for slot in ("top", "bottom", "feet", "hair"):
|
||||
src = clothes[slot]
|
||||
if not os.path.exists(src):
|
||||
log(f"WARNING: missing clothes source {src}; skipping")
|
||||
continue
|
||||
convert_clothes(
|
||||
age, sex, slot, src, out_clothes[slot], base, body_seed,
|
||||
rest_data, write=(not args.report_only), rep=rep,
|
||||
profile=profiles.profile(age))
|
||||
|
||||
# Write report (always useful, even in report-only mode).
|
||||
report_path, report_json = rep.write(args.source_dir)
|
||||
log(f"report: {report_path}")
|
||||
|
||||
if args.report_only:
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
return True
|
||||
|
||||
entry = {
|
||||
"state": "auto-seed",
|
||||
"age": profiles.normalize_age(age),
|
||||
"sex": sex,
|
||||
"profileHash": profiles.profile_hash(),
|
||||
"sourceHashes": {
|
||||
os.path.basename(base): sha256(base),
|
||||
**{os.path.basename(clothes[s]): sha256(clothes[s])
|
||||
for s in ("top", "bottom", "feet", "hair")
|
||||
if os.path.exists(clothes[s])},
|
||||
},
|
||||
"outputHashes": {
|
||||
os.path.basename(p): sha256(p)
|
||||
for p in outputs if os.path.exists(p)
|
||||
},
|
||||
"report": os.path.basename(report_path),
|
||||
"reportJson": os.path.basename(report_json),
|
||||
"updated": int(time.time()),
|
||||
}
|
||||
if anim_source:
|
||||
entry["animationSource"] = anim_source
|
||||
entry["animationSourceHash"] = sha256(anim_source)
|
||||
manifest.setdefault("stems", {})[stem] = entry
|
||||
save_manifest(args.source_dir, manifest)
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
|
||||
if rep.status() == "fail" and not args.allow_fail:
|
||||
log(f"WARNING: {stem} has fail-level objects; see report")
|
||||
return True
|
||||
|
||||
|
||||
def parse_args():
|
||||
argv = sys.argv
|
||||
if "--" in argv:
|
||||
argv = argv[argv.index("--") + 1:]
|
||||
else:
|
||||
argv = []
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--age", choices=profiles.CANONICAL_AGES)
|
||||
p.add_argument("--sex", choices=["male", "female"])
|
||||
p.add_argument("--all", action="store_true")
|
||||
p.add_argument("--source-dir", default=_THIS_DIR)
|
||||
p.add_argument("--anim-source", default="")
|
||||
p.add_argument("--report-only", action="store_true")
|
||||
p.add_argument("--write", action="store_true")
|
||||
p.add_argument("--check", action="store_true")
|
||||
p.add_argument("--force", action="store_true")
|
||||
p.add_argument("--only-unedited", action="store_true")
|
||||
p.add_argument("--allow-fail", action="store_true")
|
||||
p.add_argument("--no-clothes", action="store_true")
|
||||
p.add_argument("--no-anim", action="store_true")
|
||||
args = p.parse_args(argv)
|
||||
if not args.check and not args.report_only and not args.write:
|
||||
args.write = True
|
||||
if args.only_unedited:
|
||||
args.write = True
|
||||
if args.all:
|
||||
if args.age or args.sex:
|
||||
p.error("--all cannot be combined with --age/--sex")
|
||||
else:
|
||||
if not args.age or not args.sex:
|
||||
p.error("specify --age and --sex, or --all")
|
||||
if args.age == "adult":
|
||||
p.error("adult is the source, not a generated age")
|
||||
if args.no_anim:
|
||||
args.anim_source = "__none__"
|
||||
return args
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
args.source_dir = os.path.abspath(args.source_dir)
|
||||
pairs = []
|
||||
if args.all:
|
||||
for age in ("elder", "teenager", "child", "baby"):
|
||||
for sex in ("male", "female"):
|
||||
pairs.append((age, sex))
|
||||
else:
|
||||
pairs.append((profiles.normalize_age(args.age), args.sex))
|
||||
ok = True
|
||||
for age, sex in pairs:
|
||||
log(f"=== {age} / {sex} ===")
|
||||
try:
|
||||
if not process_pair(age, sex, args):
|
||||
ok = False
|
||||
except Exception as exc:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
log(f"FAILED {age}/{sex}: {exc}")
|
||||
ok = False
|
||||
if not ok:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,666 @@
|
||||
# generate_lods.py - character LOD asset generation (phase 6.C.3)
|
||||
#
|
||||
# Run in Blender batch mode with the consolidated blend already loaded:
|
||||
#
|
||||
# blender -b -Y <consolidated.blend> \
|
||||
# -P generate_lods.py -- <age> <sex> <out_dir> <lods_blend_out>
|
||||
#
|
||||
# Produces, per (age, sex) stem (adult keeps the legacy sex stem):
|
||||
# * <out_dir>/charlod_<stem>-atlas.png - shared 2048x2048 LOD atlas
|
||||
# * <out_dir>/charlod_<stem>.json - manifest
|
||||
# * LOD1 object variants `<part>_lod1` - same geometry, no shape keys,
|
||||
# UVs remapped into the atlas, one shared material `charlod_<stem>`
|
||||
# (hair slot is skipped - hair is hidden/impostor-baked at distance)
|
||||
# * LOD2 impostors `charlod_<stem>_{thin,fat}_lod2` - the deterministic
|
||||
# default outfit joined into one mesh skinned to the body armature
|
||||
# * LOD3 impostors `charlod_<stem>_{thin,fat}_lod3` - decimate of LOD2,
|
||||
# <= 400 triangles (hard limit)
|
||||
# * saves everything into <lods_blend_out>
|
||||
#
|
||||
# Documented simplifications:
|
||||
# * Hair re-skinning: hair meshes (which may reference their OWN hair
|
||||
# armature) are rigid-bound 100% to the body skeleton's head bone
|
||||
# (mixamorig:Head) for the impostors. At LOD2+ distances hair physics
|
||||
# is disabled anyway (CHARACTER_MODELING.md section 5/6).
|
||||
# * Default outfit: per slot the highest available layer wins
|
||||
# (2 dressed > 1 underwear > 0 nude), first entry sorted by object
|
||||
# name. This is deterministic and mirrors what the town population
|
||||
# would resolve for a dressed NPC.
|
||||
# * Core-bone palette: LOD2/3 impostors skin only to CORE_BONE_NAMES
|
||||
# (root/spine/neck/head/limbs, 23 bones, no fingers). Groups of
|
||||
# dropped bones (fingers, eyes, bust, hair/coat-skirt physics
|
||||
# chains) are remapped to the nearest kept ancestor and merged.
|
||||
# The shared skeleton and its animations are unchanged, so no
|
||||
# animation conversion is needed.
|
||||
# * UV flipping: none needed - Blender image pixel rows and UV space
|
||||
# share the same bottom-left origin, so UV v maps linearly onto the
|
||||
# pixel row index (v = y / height). The saved PNG keeps that row
|
||||
# order, which is the same convention the existing pipeline exports.
|
||||
# * Source UVs are clamped to [0,1] before remapping so remapped UVs
|
||||
# always stay inside their atlas cell (affects only tiling UVs).
|
||||
#
|
||||
# The script is idempotent: previously generated _lod1/_lod2/_lod3 and
|
||||
# charlod_* objects, the charlod_<sex> material and the atlas image are
|
||||
# wiped at start.
|
||||
|
||||
import bpy
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
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"}
|
||||
SKIP_PREFIXES = ("cs_", "WGT-", "MCH-", "ORG-")
|
||||
SLOTS_OUTFIT = ("face", "top", "bottom", "feet")
|
||||
|
||||
# Combined LOD2/3 impostors skin to this core palette only (root, spine,
|
||||
# neck, head, limbs - no fingers, no eyes/bust, no physics chains).
|
||||
# 23 bones total, comfortably inside the 64-bone hardware-skinning
|
||||
# budget. Groups of dropped bones are remapped to the nearest kept
|
||||
# ancestor (fingers -> Hand, Eye/Bust -> Head/Spine2, coat skirt ->
|
||||
# Leg). The armature and its animations are NOT touched: the impostor
|
||||
# shares the full skeleton at runtime, only the vertex weights change,
|
||||
# so no animation conversion is needed.
|
||||
CORE_BONE_NAMES = {
|
||||
"Root",
|
||||
"mixamorig:Hips",
|
||||
"mixamorig:Spine",
|
||||
"mixamorig:Spine1",
|
||||
"mixamorig:Spine2",
|
||||
"mixamorig:Neck",
|
||||
"mixamorig:Head",
|
||||
}
|
||||
for _side in ("Left", "Right"):
|
||||
for _part in ("Shoulder", "Arm", "ForeArm", "Hand",
|
||||
"UpLeg", "Leg", "Foot", "ToeBase"):
|
||||
CORE_BONE_NAMES.add("mixamorig:" + _side + _part)
|
||||
MAX_IMPOSTOR_BONES = 64
|
||||
|
||||
|
||||
def log(msg):
|
||||
print("[generate_lods] " + msg, flush=True)
|
||||
|
||||
|
||||
def parse_args():
|
||||
argv = sys.argv
|
||||
argv = argv[argv.index("--") + 1:]
|
||||
if len(argv) < 4:
|
||||
raise RuntimeError(
|
||||
"usage: blender -b -Y <consolidated.blend> -P generate_lods.py"
|
||||
" -- <age> <sex> <out_dir> <lods_blend_out>")
|
||||
age = age_profiles.normalize_age(argv[0])
|
||||
sex = argv[1]
|
||||
stem = age_profiles.stem(age, sex)
|
||||
return stem, argv[2], argv[3]
|
||||
|
||||
|
||||
def strip_suffix(name):
|
||||
"""Strip Blender .### duplicate suffix from a datablock name."""
|
||||
return re.sub(r"\.\d{3}$", "", name)
|
||||
|
||||
|
||||
def collect_catalog_objects():
|
||||
"""Same classification rule as consolidate.py."""
|
||||
result = []
|
||||
for ob in bpy.data.objects:
|
||||
if ob.type != "MESH":
|
||||
continue
|
||||
if not all(p in ob.keys() for p in REQUIRED_PROPS):
|
||||
continue
|
||||
if ob.name.startswith(SKIP_PREFIXES):
|
||||
continue
|
||||
result.append(ob)
|
||||
return sorted(result, key=lambda o: o.name)
|
||||
|
||||
|
||||
def find_armature(sex):
|
||||
arm = bpy.data.objects.get(sex)
|
||||
if arm and arm.type == "ARMATURE":
|
||||
return arm
|
||||
for ob in bpy.data.objects:
|
||||
if ob.type == "ARMATURE" and ob.name == sex:
|
||||
return ob
|
||||
raise RuntimeError("shared armature '%s' not found" % sex)
|
||||
|
||||
|
||||
def find_head_bone(armature):
|
||||
bones = armature.data.bones
|
||||
if "mixamorig:Head" in bones:
|
||||
return "mixamorig:Head"
|
||||
for b in bones:
|
||||
if "Head" in b.name:
|
||||
log("WARNING: mixamorig:Head not found, using '%s'" % b.name)
|
||||
return b.name
|
||||
raise RuntimeError("no head bone found in armature '%s'"
|
||||
% armature.name)
|
||||
|
||||
|
||||
def base_color_image(mat):
|
||||
"""Image whose content the mesh UVs address: the Base Color texture
|
||||
of the Principled BSDF, falling back to the first TEX_IMAGE node."""
|
||||
if not mat or not mat.use_nodes or not mat.node_tree:
|
||||
return None
|
||||
for node in mat.node_tree.nodes:
|
||||
if node.type == "BSDF_PRINCIPLED":
|
||||
inp = node.inputs.get("Base Color")
|
||||
if inp and inp.is_linked:
|
||||
src = inp.links[0].from_node
|
||||
if src.type == "TEX_IMAGE" and src.image:
|
||||
return src.image
|
||||
for node in mat.node_tree.nodes:
|
||||
if node.type == "TEX_IMAGE" and node.image:
|
||||
log("WARNING: material '%s' has no direct Base Color texture,"
|
||||
" falling back to image '%s'" % (mat.name, node.image.name))
|
||||
return node.image
|
||||
return None
|
||||
|
||||
|
||||
def cleanup_previous(sex):
|
||||
"""Idempotency: wipe previously generated data."""
|
||||
doomed = []
|
||||
for ob in list(bpy.data.objects):
|
||||
if (ob.name.startswith("charlod_") or ob.name.endswith("_lod1")
|
||||
or ob.name.endswith("_lod2") or ob.name.endswith("_lod3")):
|
||||
doomed.append(ob)
|
||||
for ob in doomed:
|
||||
log("wiping previous object '%s'" % ob.name)
|
||||
bpy.data.objects.remove(ob, do_unlink=True)
|
||||
for me in list(bpy.data.meshes):
|
||||
if me.users == 0 and (me.name.startswith("charlod_")
|
||||
or me.name.endswith("_lod1")
|
||||
or me.name.endswith("_lod2")
|
||||
or me.name.endswith("_lod3")):
|
||||
bpy.data.meshes.remove(me)
|
||||
mat = bpy.data.materials.get("charlod_" + sex)
|
||||
if mat:
|
||||
bpy.data.materials.remove(mat)
|
||||
for img in list(bpy.data.images):
|
||||
if img.name.startswith("charlod_%s-atlas" % sex):
|
||||
bpy.data.images.remove(img)
|
||||
|
||||
|
||||
def build_atlas(catalog, sex, out_dir):
|
||||
"""Paste every distinct base-color image into a grid cell of a new
|
||||
ATLAS_SIZE x ATLAS_SIZE image. Returns (atlas_image, rects) where
|
||||
rects maps dedupe key -> (u0, v0, u1, v1)."""
|
||||
canonical = {} # dedupe key -> bpy.types.Image
|
||||
for ob in catalog:
|
||||
for slot in ob.material_slots:
|
||||
img = base_color_image(slot.material)
|
||||
if img is None:
|
||||
continue
|
||||
key = strip_suffix(img.name)
|
||||
if key not in canonical or not key.endswith(
|
||||
strip_suffix(canonical[key].name)):
|
||||
# prefer the unsuffixed instance
|
||||
if key not in canonical or img.name == key:
|
||||
canonical[key] = img
|
||||
keys = sorted(canonical.keys())
|
||||
log("atlas source images (%d): %s" % (len(keys), keys))
|
||||
|
||||
n = max(1, math.ceil(math.sqrt(len(keys))))
|
||||
cell = ATLAS_SIZE // n
|
||||
log("atlas grid %dx%d, cell %dpx" % (n, n, cell))
|
||||
|
||||
atlas_name = "charlod_%s-atlas.png" % sex
|
||||
atlas = bpy.data.images.new(atlas_name, width=ATLAS_SIZE,
|
||||
height=ATLAS_SIZE, alpha=True)
|
||||
buf = np.zeros(ATLAS_SIZE * ATLAS_SIZE * 4, dtype=np.float32)
|
||||
grid = buf.reshape(ATLAS_SIZE, ATLAS_SIZE, 4)
|
||||
|
||||
rects = {}
|
||||
for idx, key in enumerate(keys):
|
||||
img = canonical[key]
|
||||
w, h = img.size
|
||||
if w == 0 or h == 0:
|
||||
log("WARNING: image '%s' has no pixel data, skipped"
|
||||
% img.name)
|
||||
continue
|
||||
col = idx % n
|
||||
row = idx // n # rows counted from the bottom, like UV v
|
||||
scale = min(cell / w, cell / h)
|
||||
pw = max(1, int(round(w * scale)))
|
||||
ph = max(1, int(round(h * scale)))
|
||||
tmp = img.copy()
|
||||
tmp.scale(pw, ph)
|
||||
px = np.empty(pw * ph * 4, dtype=np.float32)
|
||||
tmp.pixels.foreach_get(px)
|
||||
bpy.data.images.remove(tmp)
|
||||
tile = px.reshape(ph, pw, 4)
|
||||
x0 = col * cell + (cell - pw) // 2
|
||||
y0 = row * cell + (cell - ph) // 2
|
||||
grid[y0:y0 + ph, x0:x0 + pw, :] = tile
|
||||
rects[key] = (x0 / ATLAS_SIZE, y0 / ATLAS_SIZE,
|
||||
(x0 + pw) / ATLAS_SIZE, (y0 + ph) / ATLAS_SIZE)
|
||||
log(" cell (%d,%d): '%s' %dx%d -> %dx%d at (%d,%d) rect %s"
|
||||
% (col, row, key, w, h, pw, ph, x0, y0,
|
||||
["%.4f" % v for v in rects[key]]))
|
||||
|
||||
atlas.pixels.foreach_set(buf)
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
atlas_path = os.path.join(out_dir, atlas_name)
|
||||
atlas.filepath_raw = atlas_path
|
||||
atlas.file_format = "PNG"
|
||||
atlas.save()
|
||||
log("atlas saved to %s" % atlas_path)
|
||||
return atlas, rects
|
||||
|
||||
|
||||
def make_atlas_material(sex, atlas):
|
||||
mat_name = "charlod_" + sex
|
||||
mat = bpy.data.materials.new(mat_name)
|
||||
mat.use_nodes = True
|
||||
nt = mat.node_tree
|
||||
bsdf = next(n for n in nt.nodes if n.type == "BSDF_PRINCIPLED")
|
||||
tex = nt.nodes.new("ShaderNodeTexImage")
|
||||
tex.image = atlas
|
||||
nt.links.new(tex.outputs["Color"], bsdf.inputs["Base Color"])
|
||||
mat.blend_method = "OPAQUE"
|
||||
log("created shared material '%s' with texture '%s'"
|
||||
% (mat_name, atlas.name))
|
||||
return mat
|
||||
|
||||
|
||||
def slot_rects(ob, rects):
|
||||
"""Per material slot of ob: atlas rect of its base-color image."""
|
||||
result = []
|
||||
for slot in ob.material_slots:
|
||||
img = base_color_image(slot.material)
|
||||
if img is None:
|
||||
result.append(None)
|
||||
continue
|
||||
result.append(rects.get(strip_suffix(img.name)))
|
||||
return result
|
||||
|
||||
|
||||
def remap_mesh_uvs(mesh, rects, owner):
|
||||
"""Affine-remap loop UVs into the atlas cells of their material slot.
|
||||
Source UVs are clamped to [0,1] first so output stays in [0,1]."""
|
||||
uv_layer = mesh.uv_layers.active
|
||||
if uv_layer is None:
|
||||
log("WARNING: '%s' has no UV layer, UVs not remapped" % owner)
|
||||
return
|
||||
nloops = len(mesh.loops)
|
||||
if nloops == 0:
|
||||
return
|
||||
uvs = np.empty(nloops * 2, dtype=np.float32)
|
||||
uv_layer.data.foreach_get("uv", uvs)
|
||||
uvs = uvs.reshape(-1, 2)
|
||||
np.clip(uvs, 0.0, 1.0, out=uvs)
|
||||
slot_of_loop = np.zeros(nloops, dtype=np.int32)
|
||||
for poly in mesh.polygons:
|
||||
slot_of_loop[poly.loop_start:
|
||||
poly.loop_start + poly.loop_total] = \
|
||||
poly.material_index
|
||||
for si, rect in enumerate(rects):
|
||||
if rect is None:
|
||||
continue
|
||||
mask = slot_of_loop == si
|
||||
if not mask.any():
|
||||
continue
|
||||
u0, v0, u1, v1 = rect
|
||||
sub = uvs[mask]
|
||||
sub[:, 0] = u0 + sub[:, 0] * (u1 - u0)
|
||||
sub[:, 1] = v0 + sub[:, 1] * (v1 - v0)
|
||||
uvs[mask] = sub
|
||||
uv_layer.data.foreach_set("uv", uvs.ravel())
|
||||
lo = uvs.min()
|
||||
hi = uvs.max()
|
||||
if lo < -1e-4 or hi > 1.0 + 1e-4:
|
||||
log("WARNING: '%s' UVs out of [0,1] after remap: %f..%f"
|
||||
% (owner, lo, hi))
|
||||
|
||||
|
||||
def apply_atlas_material(mesh, atlas_mat):
|
||||
mesh.materials.clear()
|
||||
mesh.materials.append(atlas_mat)
|
||||
for poly in mesh.polygons:
|
||||
poly.material_index = 0
|
||||
|
||||
|
||||
def copy_object(ob, new_name, collection):
|
||||
dup = ob.copy()
|
||||
dup.data = ob.data.copy()
|
||||
dup.name = new_name
|
||||
dup.data.name = new_name
|
||||
collection.objects.link(dup)
|
||||
return dup
|
||||
|
||||
|
||||
def generate_lod1(catalog, rects, atlas_mat, sex):
|
||||
"""LOD1: per catalog mesh (hair excluded) a shape-key-free copy with
|
||||
atlas UVs and the single shared material."""
|
||||
made = []
|
||||
for ob in catalog:
|
||||
if ob.get("slot") == "hair":
|
||||
continue
|
||||
new_name = ob.name + "_lod1"
|
||||
dup = copy_object(ob, new_name, bpy.context.collection)
|
||||
dup.shape_key_clear()
|
||||
for key in ob.keys():
|
||||
dup[key] = ob[key]
|
||||
dup["lod"] = 1
|
||||
remap_mesh_uvs(dup.data, slot_rects(ob, rects), new_name)
|
||||
apply_atlas_material(dup.data, atlas_mat)
|
||||
made.append(dup)
|
||||
log("LOD1 '%s': %d polys, props copied, lod=1"
|
||||
% (new_name, len(dup.data.polygons)))
|
||||
return made
|
||||
|
||||
|
||||
def pick_default_outfit(catalog):
|
||||
"""Deterministic default outfit: per slot the highest available
|
||||
layer (2 > 1 > 0), first object sorted by name. Hair: layer-1,
|
||||
skipping bald/placeholder meshes (< 100 polys or 'bald' in name)."""
|
||||
picks = {}
|
||||
for slot in SLOTS_OUTFIT + ("hair",):
|
||||
entries = [ob for ob in catalog if ob.get("slot") == slot]
|
||||
if slot == "hair":
|
||||
entries = [ob for ob in entries
|
||||
if int(ob.get("layer", 0)) >= 1
|
||||
and "bald" not in ob.name.lower()
|
||||
and len(ob.data.polygons) >= 100]
|
||||
if not entries:
|
||||
log("WARNING: no catalog entry for slot '%s'" % slot)
|
||||
continue
|
||||
best_layer = max(int(ob.get("layer", 0)) for ob in entries)
|
||||
best = sorted((ob for ob in entries
|
||||
if int(ob.get("layer", 0)) == best_layer),
|
||||
key=lambda o: o.name)[0]
|
||||
picks[slot] = best
|
||||
log("default outfit slot '%s': '%s' (layer %d)"
|
||||
% (slot, best.name, best_layer))
|
||||
return picks
|
||||
|
||||
|
||||
def bake_shape_keys(ob, fat_value, depsgraph):
|
||||
"""Evaluate ob with the 'fat' shape key set to fat_value and all
|
||||
shape keys baked away. Returns a new shape-key-free mesh (vertex
|
||||
groups, UVs and material slots preserved)."""
|
||||
dup = ob.copy()
|
||||
dup.data = ob.data.copy()
|
||||
bpy.context.collection.objects.link(dup)
|
||||
if dup.data.shape_keys:
|
||||
for kb in dup.data.shape_keys.key_blocks:
|
||||
if kb.name == "fat":
|
||||
kb.value = fat_value
|
||||
eval_ob = dup.evaluated_get(depsgraph)
|
||||
mesh = bpy.data.meshes.new_from_object(
|
||||
eval_ob, preserve_all_data_layers=True, depsgraph=depsgraph)
|
||||
bpy.data.objects.remove(dup, do_unlink=True)
|
||||
return mesh
|
||||
|
||||
|
||||
def rigid_bind_to_head(obj, head_bone):
|
||||
"""Re-skin every vertex 100% to the head bone (hair shortcut, see
|
||||
header)."""
|
||||
for vg in list(obj.vertex_groups):
|
||||
obj.vertex_groups.remove(vg)
|
||||
vg = obj.vertex_groups.new(name=head_bone)
|
||||
vg.add(list(range(len(obj.data.vertices))), 1.0, "REPLACE")
|
||||
log(" rigid-bound %d verts of '%s' to '%s'"
|
||||
% (len(obj.data.vertices), obj.name, head_bone))
|
||||
|
||||
|
||||
def core_bone_target(name, parents):
|
||||
"""Nearest kept ancestor (or the bone itself) for the impostor
|
||||
palette. Bones outside the mixamorig hierarchy (control rig)
|
||||
fall back to the skeleton root."""
|
||||
cur = name
|
||||
while cur is not None:
|
||||
if cur in CORE_BONE_NAMES:
|
||||
return cur
|
||||
cur = parents.get(cur)
|
||||
return "Root"
|
||||
|
||||
|
||||
def prune_to_core_bones(obj, armature):
|
||||
"""Collapse the impostor skin palette to CORE_BONE_NAMES. Vertex
|
||||
groups of dropped bones are remapped to their nearest kept
|
||||
ancestor and their weights merged per vertex. The armature, its
|
||||
bone hierarchy and all animations stay untouched - only the
|
||||
impostor's vertex weights change."""
|
||||
parents = {b.name: b.parent.name if b.parent else None
|
||||
for b in armature.data.bones}
|
||||
lut = {}
|
||||
for vg in obj.vertex_groups:
|
||||
target = core_bone_target(vg.name, parents)
|
||||
if target not in parents:
|
||||
raise RuntimeError("core bone '%s' missing from armature"
|
||||
% target)
|
||||
lut[vg.index] = target
|
||||
merged = []
|
||||
unweighted = False
|
||||
for v in obj.data.vertices:
|
||||
acc = {}
|
||||
for g in v.groups:
|
||||
if g.weight <= 0.0:
|
||||
continue
|
||||
name = lut[g.group]
|
||||
acc[name] = acc.get(name, 0.0) + g.weight
|
||||
if not acc:
|
||||
unweighted = True
|
||||
merged.append(acc)
|
||||
for vg in list(obj.vertex_groups):
|
||||
obj.vertex_groups.remove(vg)
|
||||
used = sorted({name for acc in merged for name in acc})
|
||||
if unweighted:
|
||||
used.append("Root")
|
||||
groups = {name: obj.vertex_groups.new(name=name) for name in used}
|
||||
for vi, acc in enumerate(merged):
|
||||
if not acc:
|
||||
groups["Root"].add([vi], 1.0, "REPLACE")
|
||||
continue
|
||||
for name, w in acc.items():
|
||||
if w > 1e-6:
|
||||
groups[name].add([vi], w, "REPLACE")
|
||||
log("impostor '%s': skin palette pruned to %d core bones%s: %s"
|
||||
% (obj.name, len(groups),
|
||||
" (unweighted verts pinned to Root)" if unweighted else "",
|
||||
", ".join(sorted(groups))))
|
||||
if len(groups) > MAX_IMPOSTOR_BONES:
|
||||
raise RuntimeError("impostor '%s' palette %d > %d bones"
|
||||
% (obj.name, len(groups), MAX_IMPOSTOR_BONES))
|
||||
|
||||
|
||||
def join_objects(objects, name, armature):
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
for ob in objects:
|
||||
ob.select_set(True)
|
||||
bpy.context.view_layer.objects.active = objects[0]
|
||||
bpy.ops.object.join()
|
||||
joined = bpy.context.view_layer.objects.active
|
||||
joined.name = name
|
||||
joined.data.name = name
|
||||
joined.parent = armature
|
||||
joined.matrix_parent_inverse = armature.matrix_world.inverted()
|
||||
arm_mod = next((m for m in joined.modifiers
|
||||
if m.type == "ARMATURE"), None)
|
||||
if arm_mod is None:
|
||||
arm_mod = joined.modifiers.new(name="Armature", type="ARMATURE")
|
||||
arm_mod.object = armature
|
||||
return joined
|
||||
|
||||
|
||||
def count_tris(mesh):
|
||||
mesh.calc_loop_triangles()
|
||||
return len(mesh.loop_triangles)
|
||||
|
||||
|
||||
def generate_lod2(catalog, rects, atlas_mat, armature, head_bone, sex):
|
||||
made = {}
|
||||
for variant, fat_value in (("thin", 0.0), ("fat", 1.0)):
|
||||
picks = pick_default_outfit(catalog)
|
||||
depsgraph = bpy.context.evaluated_depsgraph_get()
|
||||
parts = []
|
||||
for slot in SLOTS_OUTFIT + ("hair",):
|
||||
ob = picks.get(slot)
|
||||
if ob is None:
|
||||
continue
|
||||
mesh = bake_shape_keys(ob, fat_value, depsgraph)
|
||||
part = bpy.data.objects.new(
|
||||
"charlod_tmp_%s_%s" % (variant, slot), mesh)
|
||||
bpy.context.collection.objects.link(part)
|
||||
part.matrix_world = ob.matrix_world
|
||||
remap_mesh_uvs(mesh, slot_rects(ob, rects),
|
||||
"lod2/%s/%s" % (variant, ob.name))
|
||||
apply_atlas_material(mesh, atlas_mat)
|
||||
if slot == "hair":
|
||||
rigid_bind_to_head(part, head_bone)
|
||||
parts.append(part)
|
||||
name = "charlod_%s_%s_lod2" % (sex, variant)
|
||||
joined = join_objects(parts, name, armature)
|
||||
prune_to_core_bones(joined, armature)
|
||||
tris = count_tris(joined.data)
|
||||
n_vg = len(joined.vertex_groups)
|
||||
has_head = any(vg.name == head_bone
|
||||
for vg in joined.vertex_groups)
|
||||
log("LOD2 '%s': %d polys, %d tris, %d vertex groups, "
|
||||
"head group: %s" % (name, len(joined.data.polygons), tris,
|
||||
n_vg, has_head))
|
||||
made[variant] = joined
|
||||
return made
|
||||
|
||||
|
||||
def decimated_mesh(src_obj, ratio, depsgraph=None):
|
||||
tmp = src_obj.copy()
|
||||
tmp.data = src_obj.data.copy()
|
||||
bpy.context.collection.objects.link(tmp)
|
||||
mod = tmp.modifiers.new(name="Decimate", type="DECIMATE")
|
||||
mod.decimate_type = "COLLAPSE"
|
||||
mod.ratio = ratio
|
||||
# refresh so the just-added modifier is part of the evaluation
|
||||
bpy.context.view_layer.update()
|
||||
depsgraph = bpy.context.evaluated_depsgraph_get()
|
||||
eval_ob = tmp.evaluated_get(depsgraph)
|
||||
mesh = bpy.data.meshes.new_from_object(
|
||||
eval_ob, preserve_all_data_layers=True, depsgraph=depsgraph)
|
||||
bpy.data.objects.remove(tmp, do_unlink=True)
|
||||
return mesh
|
||||
|
||||
|
||||
def generate_lod3(lod2_map, armature, sex):
|
||||
made = {}
|
||||
for variant, src in lod2_map.items():
|
||||
base_tris = count_tris(src.data)
|
||||
name = "charlod_%s_%s_lod3" % (sex, variant)
|
||||
depsgraph = bpy.context.evaluated_depsgraph_get()
|
||||
if base_tris <= LOD3_MAX_TRIS:
|
||||
mesh = src.data.copy()
|
||||
log("LOD3 '%s': already %d tris (<= %d), no decimate"
|
||||
% (name, base_tris, LOD3_MAX_TRIS))
|
||||
else:
|
||||
# binary search for the highest ratio still <= LOD3_MAX_TRIS;
|
||||
# a 0-tri result means the ratio collapsed everything and is
|
||||
# too LOW, not acceptable.
|
||||
lo, hi = 0.001, 1.0
|
||||
best_mesh, best_tris, best_ratio = None, 0, lo
|
||||
for _ in range(20):
|
||||
ratio = (lo + hi) / 2.0
|
||||
mesh = decimated_mesh(src, ratio, depsgraph)
|
||||
tris = count_tris(mesh)
|
||||
log(" decimate try ratio %.4f -> %d tris"
|
||||
% (ratio, tris))
|
||||
if tris > LOD3_MAX_TRIS:
|
||||
hi = ratio
|
||||
bpy.data.meshes.remove(mesh)
|
||||
elif tris == 0:
|
||||
lo = ratio
|
||||
bpy.data.meshes.remove(mesh)
|
||||
else:
|
||||
lo = ratio
|
||||
if best_mesh is not None:
|
||||
bpy.data.meshes.remove(best_mesh)
|
||||
best_mesh, best_tris, best_ratio = mesh, tris, ratio
|
||||
if best_mesh is None:
|
||||
raise RuntimeError(
|
||||
"LOD3 '%s': decimate never reached <= %d tris"
|
||||
% (name, LOD3_MAX_TRIS))
|
||||
mesh = best_mesh
|
||||
log("LOD3 '%s': decimate ratio %.4f -> %d tris "
|
||||
"(limit %d, source %d)"
|
||||
% (name, best_ratio, best_tris, LOD3_MAX_TRIS,
|
||||
base_tris))
|
||||
obj = bpy.data.objects.new(name, mesh)
|
||||
bpy.context.collection.objects.link(obj)
|
||||
# A fresh object may already carry the source's vertex groups
|
||||
# via new_from_object; only fill in any missing ones so the
|
||||
# decimated mesh's group indices keep resolving to the same
|
||||
# (core-pruned) bones on export.
|
||||
for vg in src.vertex_groups:
|
||||
if obj.vertex_groups.get(vg.name) is None:
|
||||
obj.vertex_groups.new(name=vg.name)
|
||||
obj.parent = armature
|
||||
obj.matrix_parent_inverse = armature.matrix_world.inverted()
|
||||
arm_mod = obj.modifiers.new(name="Armature", type="ARMATURE")
|
||||
arm_mod.object = armature
|
||||
made[variant] = obj
|
||||
return made
|
||||
|
||||
|
||||
def write_manifest(sex, out_dir):
|
||||
manifest = {
|
||||
"atlas": "charlod_%s-atlas.png" % sex,
|
||||
"variants": ["thin", "fat"],
|
||||
"lod2": {v: "charlod_%s_%s_lod2.mesh" % (sex, v)
|
||||
for v in ("thin", "fat")},
|
||||
"lod3": {v: "charlod_%s_%s_lod3.mesh" % (sex, v)
|
||||
for v in ("thin", "fat")},
|
||||
}
|
||||
path = os.path.join(out_dir, "charlod_%s.json" % sex)
|
||||
with open(path, "w") as f:
|
||||
json.dump(manifest, f, indent=2)
|
||||
log("manifest written to %s" % path)
|
||||
|
||||
|
||||
def main():
|
||||
sex, out_dir, lods_blend_out = parse_args()
|
||||
stem = sex
|
||||
log("stem=%s out_dir=%s lods_blend=%s" % (stem, out_dir,
|
||||
lods_blend_out))
|
||||
|
||||
cleanup_previous(stem)
|
||||
|
||||
armature = find_armature(stem)
|
||||
head_bone = find_head_bone(armature)
|
||||
log("shared armature '%s', head bone '%s'"
|
||||
% (armature.name, head_bone))
|
||||
|
||||
catalog = collect_catalog_objects()
|
||||
log("catalog objects (%d): %s"
|
||||
% (len(catalog), [ob.name for ob in catalog]))
|
||||
|
||||
log("=== step 1: 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, stem)
|
||||
log("generated %d LOD1 objects" % len(lod1))
|
||||
|
||||
log("=== step 3: LOD2 impostors ===")
|
||||
lod2 = generate_lod2(catalog, rects, atlas_mat, armature,
|
||||
head_bone, stem)
|
||||
|
||||
log("=== step 4: LOD3 impostors ===")
|
||||
generate_lod3(lod2, armature, stem)
|
||||
|
||||
log("=== step 5: manifest + save ===")
|
||||
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")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -2,6 +2,11 @@ import bpy
|
||||
import os
|
||||
import sys
|
||||
|
||||
_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
if _THIS_DIR not in sys.path:
|
||||
sys.path.insert(0, _THIS_DIR)
|
||||
import age_profiles
|
||||
|
||||
def clean_scene():
|
||||
bpy.ops.object.select_all(action='SELECT')
|
||||
bpy.ops.object.delete()
|
||||
@@ -36,8 +41,10 @@ def process_batch():
|
||||
try:
|
||||
args = sys.argv[sys.argv.index("--") + 1:]
|
||||
clothing_path, lib_directory, out_dir = args[0], args[1], args[2]
|
||||
explicit_body = args[3] if len(args) >= 4 else ""
|
||||
except (IndexError, ValueError):
|
||||
print("Usage: blender -b -P script.py -- <source_blend> <lib_dir> <out_dir>")
|
||||
print("Usage: blender -b -P script.py -- "
|
||||
"<source_blend> <lib_dir> <out_dir> [body_blend]")
|
||||
return
|
||||
|
||||
if not os.path.exists(out_dir): os.makedirs(out_dir)
|
||||
@@ -62,7 +69,7 @@ def process_batch():
|
||||
|
||||
# Get properties
|
||||
sex = clothing.get("ref_sex")
|
||||
age = clothing.get("ref_age")
|
||||
age = age_profiles.normalize_age(clothing.get("ref_age"))
|
||||
ref_mesh_name = clothing.get("ref_clothing")
|
||||
|
||||
if not all([sex, age, ref_mesh_name]):
|
||||
@@ -70,19 +77,22 @@ def process_batch():
|
||||
continue
|
||||
|
||||
# 3. Locate Reference Library
|
||||
target_lib_name = f"normal_{age}_{sex}.blend"
|
||||
target_lib_path = os.path.join(lib_directory, target_lib_name)
|
||||
rig_name = str(sex)
|
||||
|
||||
if not os.path.exists(target_lib_path):
|
||||
if target_lib_name == "normal_adult_male.blend":
|
||||
target_lib_name = "edited-normal-male.blend"
|
||||
elif target_lib_name == "normal_adult_female.blend":
|
||||
target_lib_name = "edited-normal-female.blend"
|
||||
rig_name = age_profiles.armature_name(age, sex)
|
||||
if explicit_body and os.path.exists(explicit_body):
|
||||
target_lib_path = explicit_body
|
||||
else:
|
||||
target_lib_name = f"normal_{age}_{sex}.blend"
|
||||
target_lib_path = os.path.join(lib_directory, target_lib_name)
|
||||
if not os.path.exists(target_lib_path):
|
||||
print(f"Error: Library {target_lib_path} not found")
|
||||
continue
|
||||
if age == "adult":
|
||||
fallback = {
|
||||
"male": "edited-normal-male.blend",
|
||||
"female": "edited-normal-female.blend",
|
||||
}.get(sex, "")
|
||||
target_lib_path = os.path.join(lib_directory, fallback)
|
||||
if not os.path.exists(target_lib_path):
|
||||
print(f"Error: Library {target_lib_path} not found")
|
||||
continue
|
||||
|
||||
# 4. Append Weights Source and Rig
|
||||
with bpy.data.libraries.load(target_lib_path) as (data_from, data_to):
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Structural validator for generated character age assets.
|
||||
|
||||
Run after the character build (the CMake target ``character-assets-check``
|
||||
does this automatically)::
|
||||
|
||||
python3 assets/blender/characters/validate_age_assets.py \
|
||||
--characters-dir build-vscode/characters
|
||||
|
||||
Checks:
|
||||
* every age/sex stem has the expected body skeleton and catalog slots;
|
||||
* catalog ages are canonical and clothing layers are present;
|
||||
* no duplicate resource basenames across the flat Characters namespace;
|
||||
* LOD atlas/material files exist;
|
||||
* material texture references resolve to a staged texture (warning only).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
THIS_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
if THIS_DIR not in sys.path:
|
||||
sys.path.insert(0, THIS_DIR)
|
||||
import age_profiles
|
||||
|
||||
|
||||
def fail(msg, errors):
|
||||
errors.append(msg)
|
||||
print("FAIL: " + msg)
|
||||
|
||||
|
||||
def warn(msg, warnings):
|
||||
warnings.append(msg)
|
||||
print("WARN: " + msg)
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--characters-dir", required=True)
|
||||
p.add_argument("--ages", default="",
|
||||
help="semicolon/comma separated age bands to check "
|
||||
"(default: all canonical bands)")
|
||||
args = p.parse_args()
|
||||
root = os.path.abspath(args.characters_dir)
|
||||
errors = []
|
||||
warnings = []
|
||||
if not os.path.isdir(root):
|
||||
print("FAIL: characters dir not found: " + root)
|
||||
return 1
|
||||
|
||||
dirs = sorted(d for d in os.listdir(root)
|
||||
if os.path.isdir(os.path.join(root, d)))
|
||||
by_stem = {}
|
||||
for d in dirs:
|
||||
by_stem[d] = os.path.join(root, d)
|
||||
|
||||
ages = age_profiles.CANONICAL_AGES
|
||||
if args.ages:
|
||||
ages = [a.strip() for a in re.split(r"[;,]", args.ages)
|
||||
if a.strip()]
|
||||
expected_stems = []
|
||||
for age in ages:
|
||||
for sex in ("male", "female"):
|
||||
expected_stems.append(age_profiles.stem(age, sex))
|
||||
|
||||
for stem in expected_stems:
|
||||
d = by_stem.get(stem)
|
||||
if d is None:
|
||||
fail(f"missing output directory: {stem}", errors)
|
||||
continue
|
||||
cat_files = sorted(glob.glob(os.path.join(d, "body_part_*.json")))
|
||||
if not cat_files:
|
||||
fail(f"no body_part_*.json in {stem}", errors)
|
||||
continue
|
||||
entries = []
|
||||
for f in cat_files:
|
||||
try:
|
||||
with open(f) as fh:
|
||||
e = json.load(fh)
|
||||
except Exception as exc:
|
||||
fail(f"unreadable catalog {f}: {exc}", errors)
|
||||
continue
|
||||
entries.append(e)
|
||||
ages = {age_profiles.normalize_age(e.get("age", ""))
|
||||
for e in entries}
|
||||
if ages != {stem.split("-")[0] if "-" in stem else "adult"}:
|
||||
fail(f"{stem}: catalog ages {sorted(ages)} do not match stem",
|
||||
errors)
|
||||
slots = {}
|
||||
for e in entries:
|
||||
slots.setdefault(e.get("slot", ""), []).append(e)
|
||||
for req in ("face", "top", "bottom", "feet"):
|
||||
if req not in slots:
|
||||
fail(f"{stem}: missing required slot '{req}'", errors)
|
||||
for req in ("top", "bottom", "feet"):
|
||||
if not any(int(e.get("layer", 0)) == 2
|
||||
for e in slots.get(req, [])):
|
||||
fail(f"{stem}: no layer-2 {req} garment", errors)
|
||||
if "hair" not in slots:
|
||||
warn(f"{stem}: no hair slot (bald band)", warnings)
|
||||
|
||||
skel = os.path.join(d, stem + ".skeleton")
|
||||
if not os.path.exists(skel):
|
||||
fail(f"{stem}: missing body skeleton {stem}.skeleton", errors)
|
||||
|
||||
atlas = os.path.join(d, f"charlod_{stem}-atlas.png")
|
||||
if not os.path.exists(atlas):
|
||||
fail(f"{stem}: missing LOD atlas charlod_{stem}-atlas.png",
|
||||
errors)
|
||||
mat = os.path.join(d, f"charlod_{stem}.material")
|
||||
if not os.path.exists(mat):
|
||||
warn(f"{stem}: missing LOD material charlod_{stem}.material",
|
||||
warnings)
|
||||
|
||||
# Resource basename uniqueness across the flat Characters namespace.
|
||||
seen = {}
|
||||
exts = (".mesh", ".skeleton", ".material", ".png", ".json")
|
||||
for d in dirs:
|
||||
for f in os.listdir(os.path.join(root, d)):
|
||||
if not f.endswith(exts):
|
||||
continue
|
||||
if f.startswith("OgreXMLConverter") or f.startswith(
|
||||
"OgreMeshUpgrader"):
|
||||
continue
|
||||
seen.setdefault(f, []).append(d)
|
||||
for name, dirs_with in sorted(seen.items()):
|
||||
if len(dirs_with) > 1:
|
||||
fail("duplicate resource basename '%s' in: %s" %
|
||||
(name, ", ".join(dirs_with)), errors)
|
||||
|
||||
# Material texture references: collect staged texture names.
|
||||
textures = set()
|
||||
for d in dirs:
|
||||
for f in os.listdir(os.path.join(root, d)):
|
||||
if f.lower().endswith((".png", ".jpg", ".jpeg", ".tga",
|
||||
".dds")):
|
||||
textures.add(f)
|
||||
for d in dirs:
|
||||
for f in glob.glob(os.path.join(root, d, "*.material")):
|
||||
with open(f, errors="ignore") as fh:
|
||||
for line in fh:
|
||||
m = re.match(r"\s*texture\s+(\S+)", line)
|
||||
if not m:
|
||||
continue
|
||||
tex = m.group(1)
|
||||
if tex not in textures:
|
||||
warn(f"material {os.path.basename(f)} references "
|
||||
f"missing texture {tex}", warnings)
|
||||
|
||||
print()
|
||||
print(f"checked {len(expected_stems)} stems in {root}")
|
||||
print(f"errors: {len(errors)}, warnings: {len(warnings)}")
|
||||
if errors:
|
||||
print("RESULT: FAIL")
|
||||
return 1
|
||||
print("RESULT: PASS")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -33,6 +33,18 @@ def prepare_armature(mapping):
|
||||
orig_armature = bpy.data.objects[mapping.armature_name]
|
||||
armature_name = orig_armature.name
|
||||
orig_armature.name = orig_armature.name + "_orig"
|
||||
# Rename the original data block too, so the baked armature can use the
|
||||
# exact stem as its data name (and therefore the exported skeleton file
|
||||
# is `<stem>.skeleton`, not `<stem>.005.skeleton`).
|
||||
orig_armature.data.name = armature_name + "_orig"
|
||||
# Orphaned armature data blocks left by removed duplicate clothing-stage
|
||||
# rigs still occupy the stem name; move them out of the way.
|
||||
for ad in bpy.data.armatures:
|
||||
if ad is orig_armature.data or ad is new_armature.data:
|
||||
continue
|
||||
if ad.name == armature_name or ad.name.startswith(
|
||||
armature_name + "."):
|
||||
ad.name = ad.name + "_unused"
|
||||
new_armature.name = armature_name
|
||||
queue = []
|
||||
if new_armature.animation_data is None:
|
||||
@@ -187,7 +199,14 @@ def extra_linear(angle, offset):
|
||||
mapping_blend_path = argv[0]
|
||||
mapping_gltf_path = argv[1]
|
||||
# Backward compat: support both 4-arg (blend, gltf, armature, outfile) and 5-arg (blend, gltf, objects, armature, outfile)
|
||||
if len(argv) >= 5:
|
||||
mapping_lods_path = ""
|
||||
if len(argv) >= 6:
|
||||
# 6-arg form: (blend, gltf, objects, armature, outfile, lods_blend)
|
||||
mapping_objects = argv[2]
|
||||
mapping_armature_name = argv[3]
|
||||
mapping_outfile = argv[4]
|
||||
mapping_lods_path = argv[5]
|
||||
elif len(argv) >= 5:
|
||||
mapping_objects = argv[2]
|
||||
mapping_armature_name = argv[3]
|
||||
mapping_outfile = argv[4]
|
||||
@@ -269,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),
|
||||
@@ -383,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':
|
||||
@@ -480,6 +550,57 @@ for mapping in[CommandLineMapping()]:
|
||||
with open(json_filepath, 'w') as f:
|
||||
json.dump(save_data, f, indent=2)
|
||||
|
||||
# ---- Character LOD meshes (demo-taxi phase 6.C.3/6.C.4) ----
|
||||
# Optional 6th CLI arg: the generate_lods.py output blend. Its
|
||||
# objects ("<part>_lod1" variants and "charlod_<sex>_*" impostors)
|
||||
# are appended AFTER auto-discovery/catalog-json writing so they
|
||||
# never get body_part_*.json entries, re-parented to the scene
|
||||
# armature (the one prepare_armature produced, which exports the
|
||||
# shared skeleton) and exported with the main OGRE pass below, so
|
||||
# the impostor meshes link the SAME skeleton resource as the body
|
||||
# parts (the runtime shares skeleton instances between them).
|
||||
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
|
||||
if n.startswith("charlod_") or n.endswith("_lod1")]
|
||||
body_arm = bpy.data.objects.get(mapping.armature_name)
|
||||
for obj in data_to.objects:
|
||||
if obj is None:
|
||||
continue
|
||||
try:
|
||||
bpy.context.collection.objects.link(obj)
|
||||
except RuntimeError:
|
||||
pass # Already linked
|
||||
lod_obj_names.append(obj.name)
|
||||
# Mesh data names get the sex prefix like the body parts
|
||||
# (charlod_ names already carry it).
|
||||
if not obj.name.startswith("charlod_"):
|
||||
obj.data.name = prefix + obj.name
|
||||
else:
|
||||
obj.data.name = obj.name
|
||||
if body_arm:
|
||||
obj.parent = body_arm
|
||||
obj.matrix_parent_inverse = body_arm.matrix_world.inverted()
|
||||
for mod in obj.modifiers:
|
||||
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
|
||||
# triangulation from producing mismatched split normals at seams.
|
||||
# BodyTop and BodyBottom have different face topologies, so the exporter's
|
||||
@@ -513,16 +634,53 @@ for mapping in[CommandLineMapping()]:
|
||||
|
||||
armobj = bpy.data.objects.get(mapping.armature_name)
|
||||
armobj.data.name = armobj.name
|
||||
print("EXPORT ARMATURE DATA: obj=%s data=%s all=%s" % (
|
||||
armobj.name, armobj.data.name,
|
||||
[(a.name, a.users) for a in bpy.data.armatures]))
|
||||
|
||||
# ---- Fix: sync Image filepath to Image name ----
|
||||
# Blender silently reuses existing Images when appending
|
||||
# from libraries, so the Image's filepath may still point
|
||||
# to the old texture even after the user renamed it.
|
||||
# blender2ogre writes the filepath to .material files,
|
||||
# so we must update filepath to match the Image name.
|
||||
# ---- Prefix image resources per stem ----
|
||||
# All age directories share the "Characters" resource group, so texture
|
||||
# file names must be unique across ages/sexes. Adult gets
|
||||
# `male-...`/`female-...`; new ages get `baby-male-...` etc. The prefix
|
||||
# is idempotent (an image already prefixed with the stem is left alone).
|
||||
import re as _re
|
||||
seen_imgs = set()
|
||||
for name in obj_names:
|
||||
stem = mapping.armature_name
|
||||
|
||||
def _unique_image_name(base):
|
||||
if bpy.data.images.get(base) is None:
|
||||
return base
|
||||
i = 1
|
||||
while bpy.data.images.get(f"{base}.{i:03d}") is not None:
|
||||
i += 1
|
||||
return f"{base}.{i:03d}"
|
||||
|
||||
image_obj_names = list(obj_names)
|
||||
image_obj_names += [o.name for o in hair_own_skel_objs]
|
||||
# LOD impostors use the already-unique `charlod_<stem>-atlas.png` /
|
||||
# `charlod_<stem>` material created by generate_lods.py; prefixing them
|
||||
# again would make the material reference a different file/format.
|
||||
# Normalize their image format to the filename extension, though; some
|
||||
# Blender versions keep the image blob as TARGA even when saved as PNG.
|
||||
for name in lod_obj_names:
|
||||
obj = bpy.data.objects.get(name)
|
||||
if not obj or obj.type != 'MESH':
|
||||
continue
|
||||
for slot in obj.material_slots:
|
||||
if not slot.material or not slot.material.node_tree:
|
||||
continue
|
||||
for node in slot.material.node_tree.nodes:
|
||||
if node.type == 'TEX_IMAGE' and node.image:
|
||||
fp = node.image.filepath.lower()
|
||||
if fp.endswith(".png"):
|
||||
node.image.file_format = 'PNG'
|
||||
elif fp.endswith(".jpg") or fp.endswith(".jpeg"):
|
||||
node.image.file_format = 'JPEG'
|
||||
elif fp.endswith(".tga"):
|
||||
node.image.file_format = 'TARGA'
|
||||
output_dir = os.path.dirname(mapping.gltf_path)
|
||||
|
||||
for name in image_obj_names:
|
||||
obj = bpy.data.objects.get(name)
|
||||
if obj and obj.type == 'MESH':
|
||||
for slot in obj.material_slots:
|
||||
@@ -530,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')
|
||||
|
||||
@@ -32,7 +32,15 @@
|
||||
# chassis frame). See assets/blender/vehicles/CAR_MODELING.md.
|
||||
#
|
||||
# Usage:
|
||||
# blender <vehicle>.blend -b -Y -P gen_vehicle.py -- <out>.glb <out>.vehicle.json
|
||||
# blender <vehicle>.blend -b -Y -P gen_vehicle.py -- <out>.glb \
|
||||
# <out>.vehicle.json [downforce]
|
||||
#
|
||||
# downforce (optional, default 25): aerodynamic downforce coefficient
|
||||
# (N per (m/s)^2, straight down at the COM while moving) — the
|
||||
# speed-dependent grip lever: at speed it multiplies the tire normal
|
||||
# load (and thus the lateral impulse budget) without adding inertia,
|
||||
# which is exactly where plain tires start understeering. Higher =
|
||||
# tighter turns at speed.
|
||||
|
||||
import os
|
||||
import sys
|
||||
@@ -58,6 +66,7 @@ except:
|
||||
|
||||
glb_file = argv[0]
|
||||
json_file = argv[1]
|
||||
downforce = float(argv[2]) if len(argv) > 2 else 25.0
|
||||
outdir = os.path.dirname(glb_file)
|
||||
base = os.path.splitext(os.path.basename(bpy.data.filepath))[0]
|
||||
|
||||
@@ -266,6 +275,174 @@ for m in used_mats:
|
||||
print("gen_vehicle: unpacked texture '%s' -> %s" %
|
||||
(img.name, tex_name))
|
||||
|
||||
# --- merged paint material + separate glass material (CAR_MODELING.md) ---
|
||||
# Every mesh part is re-materialized onto TWO materials: <base> for
|
||||
# paint/bloom faces and <base>-glass for glass faces, so blender2ogre
|
||||
# emits one submesh per part (glass-less parts) or two (glass last).
|
||||
# A single merged transparent submesh forced the WHOLE part (interior
|
||||
# included) into the transparent render queue, where the depth-writing
|
||||
# window glass culled interior parts that sorted behind it ("holes" in
|
||||
# the cabin with the road showing through). The ID map texture shares
|
||||
# the color atlas UV space (all parts MUST use that one UV space with
|
||||
# no texel shared between materials; fix the blend if not) and marks
|
||||
# each fragment: red = paint (atlas color), green = bloom (lights),
|
||||
# blue = glass.
|
||||
IDMAP_SIZE = 512
|
||||
|
||||
|
||||
def idmap_color(mat):
|
||||
ln = mat.name.lower() if mat else ""
|
||||
if "glass" in ln:
|
||||
return (0, 0, 255)
|
||||
if "bloom" in ln:
|
||||
return (0, 255, 0)
|
||||
return (255, 0, 0)
|
||||
|
||||
|
||||
def bake_idmap(mesh_datas, path):
|
||||
"""Paint per-face material IDs into a texture in UV space.
|
||||
|
||||
The car UVs are degenerate: every polygon collapses onto a single
|
||||
color-atlas texel (the atlas is a palette, not a surface unwrap),
|
||||
so the ID map only needs that texel plus a small margin painted.
|
||||
A texel shared by two different materials is unresolvable and is
|
||||
warned about (fix the blend: give one of them its own texel)."""
|
||||
size = IDMAP_SIZE
|
||||
buf = bytearray(size * size * 3)
|
||||
owner = {}
|
||||
warned = set()
|
||||
|
||||
def paint(u, v, col, matname):
|
||||
u = u - math.floor(u)
|
||||
v = v - math.floor(v)
|
||||
cx, cy = int(u * (size - 1)), int(v * (size - 1))
|
||||
for dy in range(-2, 3):
|
||||
for dx in range(-2, 3):
|
||||
if dx * dx + dy * dy > 5:
|
||||
continue
|
||||
px, py = cx + dx, cy + dy
|
||||
if not (0 <= px < size and 0 <= py < size):
|
||||
continue
|
||||
idx = py * size + px
|
||||
prev = owner.get(idx)
|
||||
if prev is not None and prev != matname:
|
||||
pair = (prev, matname)
|
||||
if pair not in warned:
|
||||
warned.add(pair)
|
||||
print("gen_vehicle: WARNING - UV texel (%d,%d) "
|
||||
"shared by materials '%s' and '%s'; the "
|
||||
"ID map cannot tell them apart (fix the "
|
||||
"blend)" % (px, py, prev, matname))
|
||||
continue
|
||||
owner[idx] = matname
|
||||
buf[idx * 3] = col[0]
|
||||
buf[idx * 3 + 1] = col[1]
|
||||
buf[idx * 3 + 2] = col[2]
|
||||
|
||||
for me in mesh_datas:
|
||||
uv_layer = me.uv_layers.active
|
||||
if not uv_layer:
|
||||
continue
|
||||
uv = uv_layer.data
|
||||
for poly in me.polygons:
|
||||
mat = (me.materials[poly.material_index]
|
||||
if poly.material_index < len(me.materials) else None)
|
||||
puv = uv[poly.loop_indices[0]].uv
|
||||
paint(puv.x, puv.y, idmap_color(mat),
|
||||
mat.name if mat else "<none>")
|
||||
|
||||
img = bpy.data.images.new(
|
||||
os.path.splitext(os.path.basename(path))[0],
|
||||
width=size, height=size, alpha=False)
|
||||
px = [0.0] * (size * size * 4)
|
||||
for i in range(size * size):
|
||||
px[i * 4] = buf[i * 3] / 255.0
|
||||
px[i * 4 + 1] = buf[i * 3 + 1] / 255.0
|
||||
px[i * 4 + 2] = buf[i * 3 + 2] / 255.0
|
||||
px[i * 4 + 3] = 1.0
|
||||
img.pixels = px
|
||||
img.filepath_raw = path
|
||||
img.file_format = 'PNG'
|
||||
img.save()
|
||||
bpy.data.images.remove(img)
|
||||
print("gen_vehicle: wrote ID map %s (%d texels)" %
|
||||
(path, len(owner)))
|
||||
|
||||
|
||||
def move_glass_faces_last():
|
||||
"""Re-order each mesh so glass faces export last. With glass on
|
||||
its own material slot (see the consolidation below) blender2ogre
|
||||
groups faces into submeshes by material anyway; the sort just
|
||||
keeps the glass submesh's triangles contiguous at the end."""
|
||||
for obj in bpy.data.objects:
|
||||
if obj.type != 'MESH':
|
||||
continue
|
||||
me = obj.data
|
||||
glass_slots = [i for i, m in enumerate(me.materials)
|
||||
if m and "glass" in m.name.lower()]
|
||||
if not glass_slots:
|
||||
continue
|
||||
order = [i for i in range(len(me.materials))
|
||||
if i not in glass_slots] + glass_slots
|
||||
mats = [me.materials[i] for i in order]
|
||||
remap = dict((old, new) for new, old in enumerate(order))
|
||||
# materials.clear() zeroes every polygon's material_index, so
|
||||
# save them first
|
||||
saved = [p.material_index for p in me.polygons]
|
||||
me.materials.clear()
|
||||
for m in mats:
|
||||
me.materials.append(m)
|
||||
for poly, old_idx in zip(me.polygons, saved):
|
||||
poly.material_index = remap[old_idx]
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
obj.select_set(True)
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.mesh.select_all(action='SELECT')
|
||||
bpy.ops.mesh.sort_elements(type='MATERIAL', elements={'FACE'})
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
obj.select_set(False)
|
||||
print("gen_vehicle: sorted glass faces last on '%s'" % me.name)
|
||||
|
||||
|
||||
all_mesh_datas = []
|
||||
for obj in bpy.data.objects:
|
||||
if obj.type == 'MESH' and obj.data not in all_mesh_datas:
|
||||
all_mesh_datas.append(obj.data)
|
||||
|
||||
move_glass_faces_last()
|
||||
bake_idmap(all_mesh_datas, os.path.join(outdir, base + "-idmap.png"))
|
||||
|
||||
merged_mat = bpy.data.materials.get(base)
|
||||
if merged_mat is None:
|
||||
merged_mat = bpy.data.materials.new(base)
|
||||
merged_mat.use_nodes = True
|
||||
# The blend already HAS a "<base>-glass" material (source materials are
|
||||
# renamed to <base>-<material>); reuse it — materials.new() would
|
||||
# silently create "<base>-glass.001", and the exported submesh material
|
||||
# name must match the .material template exactly.
|
||||
glass_mat = bpy.data.materials.get(base + "-glass")
|
||||
if glass_mat is None:
|
||||
glass_mat = bpy.data.materials.new(base + "-glass")
|
||||
glass_mat.use_nodes = True
|
||||
for me in all_mesh_datas:
|
||||
# materials.clear() zeroes every polygon's material_index, so
|
||||
# classify faces against the original slots first.
|
||||
glass_face = [
|
||||
p.material_index < len(me.materials) and
|
||||
me.materials[p.material_index] is not None and
|
||||
"glass" in me.materials[p.material_index].name.lower()
|
||||
for p in me.polygons
|
||||
]
|
||||
me.materials.clear()
|
||||
me.materials.append(merged_mat)
|
||||
if any(glass_face):
|
||||
me.materials.append(glass_mat)
|
||||
for p, g in zip(me.polygons, glass_face):
|
||||
p.material_index = 1 if g else 0
|
||||
print("gen_vehicle: consolidated %d mesh data blocks onto materials "
|
||||
"'%s'/'%s-glass'" % (len(all_mesh_datas), base, base))
|
||||
|
||||
# --- JSON fragment (Ogre coordinates) ---
|
||||
smn, smx = mesh_bbox(root.data)
|
||||
collider = {
|
||||
@@ -294,7 +471,9 @@ SUSP_MAX = 0.35
|
||||
# Softer than the 1.8/0.7 starting point: gives the body some slack
|
||||
# over bumps instead of pogo-stiff wheels (which also cost grip).
|
||||
SUSP_FREQ = 1.3
|
||||
SUSP_DAMP = 0.6
|
||||
# Critical-ish damping: less and the body rocks for seconds after a
|
||||
# bump (the "almost endlessly bouncy" report).
|
||||
SUSP_DAMP = 1.0
|
||||
SUSP_REST = SUSP_MAX - 9.81 / (2.0 * math.pi * SUSP_FREQ) ** 2
|
||||
for w in sorted(wheel_info, key=lambda w: w["pos"].y):
|
||||
front = w["pos"].y < axle_mid_y
|
||||
@@ -320,6 +499,17 @@ for s in seat_info:
|
||||
"driver": s["driver"],
|
||||
})
|
||||
|
||||
# Center of mass: a box collider puts the COM at the box center — for
|
||||
# a sedan that is ~1 m above the tire contact patches (twice the real
|
||||
# value) and the car trips onto its side in hard corners (the taxi,
|
||||
# with its taller box, tips even easier). Shift the COM to just above
|
||||
# wheel-hub height instead (Jolt OffsetCenterOfMassShape: only the
|
||||
# physics COM moves — geometry, wheel attachments and the render node
|
||||
# stay put).
|
||||
collider_center_ogre_y = (smx.z + smn.z) / 2.0
|
||||
hub_ogre_y = sum(w["pos"].z for w in wheel_info) / len(wheel_info)
|
||||
com_offset_y = round(hub_ogre_y + 0.1 - collider_center_ogre_y, 4)
|
||||
|
||||
driver_offset = ogre_vec3(driver_seats[0]["pos"])
|
||||
|
||||
children = []
|
||||
@@ -382,7 +572,11 @@ if trunk:
|
||||
vehicle = {
|
||||
"cameraDistance": 6.0,
|
||||
"cameraHeight": 1.6,
|
||||
"engineBrake": 0.5,
|
||||
# Cars coast when the accelerator is released (braking is the
|
||||
# driver's job: S while rolling forward, Space = handbrake).
|
||||
# Slow yard vehicles like the forklift use ~0.5 instead so they
|
||||
# do not roll forever.
|
||||
"engineBrake": 0.0,
|
||||
"maxPitchRollAngleDeg": 60.0,
|
||||
"maxTorque": 300.0,
|
||||
"rearSteer": False,
|
||||
@@ -406,6 +600,13 @@ vehicle = {
|
||||
# soft 1.3 Hz springs a full-lock turn otherwise rolls the car past
|
||||
# 45 deg onto two wheels.
|
||||
"antiRollBarStiffness": 2000.0,
|
||||
"centerOfMassOffset": {"x": 0.0, "y": com_offset_y, "z": 0.0},
|
||||
# Aerodynamic downforce (N per (m/s)^2, straight down at the COM
|
||||
# while moving): presses the car onto its suspension at speed —
|
||||
# less bounce, and a bigger lateral impulse budget exactly where
|
||||
# plain tires start understeering (turn radius stops growing with
|
||||
# speed). Per-model value from the CMake invocation.
|
||||
"downforceCoefficient": downforce,
|
||||
}
|
||||
if steering:
|
||||
vehicle["steeringWheelMeshName"] = base + "-steering-wheel.mesh"
|
||||
@@ -478,6 +679,151 @@ bpy.ops.ogre.export(filepath=scene_file,
|
||||
EX_LOD_PERCENT=40
|
||||
)
|
||||
|
||||
# --- replace the exporter's fixed-function .material with the merged
|
||||
# VehicleIdMap shader materials (CAR_MODELING.md): "<base>" is the
|
||||
# transparent near variant, "<base>-opaque" the far variant; the
|
||||
# runtime swaps them by camera distance (SedanGlassLodListener).
|
||||
# Both reference the VehicleIdMapVP/FP programs staged in the same
|
||||
# resource dir (vehicle_idmap.program, CMake-staged from
|
||||
# assets/blender/vehicles). ---
|
||||
IDMAP_MATERIAL_TEMPLATE = """// written by gen_vehicle.py — merged ID-map vehicle material
|
||||
// (CAR_MODELING.md). Not a blender2ogre product; hand edits are
|
||||
// overwritten by every export.
|
||||
//
|
||||
// Three materials per car:
|
||||
// @BASE@ paint/bloom submeshes, fully opaque.
|
||||
// @BASE@-glass near glass submesh (<10 m): alpha-blended,
|
||||
// depth_write OFF so it lands in OGRE's
|
||||
// transparent collection and can never
|
||||
// depth-block the interior/characters behind it
|
||||
// (they render first, the glass blends over).
|
||||
// @BASE@-glass-opaque far glass submesh (>=10 m): solid.
|
||||
// The demo's SedanGlassLodListener swaps only the -glass submeshes
|
||||
// between the near and far variants; paint submeshes always keep
|
||||
// @BASE@.
|
||||
material @BASE@
|
||||
{
|
||||
receive_shadows on
|
||||
technique
|
||||
{
|
||||
pass
|
||||
{
|
||||
cull_hardware clockwise
|
||||
|
||||
vertex_program_ref VehicleIdMapVP
|
||||
{
|
||||
}
|
||||
|
||||
fragment_program_ref VehicleIdMapFP
|
||||
{
|
||||
param_named glassAlpha float 1.0
|
||||
}
|
||||
|
||||
texture_unit
|
||||
{
|
||||
texture @BASE@-atlas.png
|
||||
tex_address_mode wrap
|
||||
filtering linear linear none
|
||||
}
|
||||
|
||||
texture_unit
|
||||
{
|
||||
texture @BASE@-idmap.png
|
||||
tex_address_mode clamp
|
||||
filtering point point none
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
material @BASE@-glass
|
||||
{
|
||||
receive_shadows on
|
||||
technique
|
||||
{
|
||||
pass
|
||||
{
|
||||
cull_hardware clockwise
|
||||
depth_write off
|
||||
scene_blend alpha_blend
|
||||
|
||||
vertex_program_ref VehicleIdMapVP
|
||||
{
|
||||
}
|
||||
|
||||
fragment_program_ref VehicleIdMapFP
|
||||
{
|
||||
param_named glassAlpha float 0.25
|
||||
}
|
||||
|
||||
texture_unit
|
||||
{
|
||||
texture @BASE@-atlas.png
|
||||
tex_address_mode wrap
|
||||
filtering linear linear none
|
||||
}
|
||||
|
||||
texture_unit
|
||||
{
|
||||
texture @BASE@-idmap.png
|
||||
tex_address_mode clamp
|
||||
filtering point point none
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
material @BASE@-glass-opaque
|
||||
{
|
||||
receive_shadows on
|
||||
technique
|
||||
{
|
||||
pass
|
||||
{
|
||||
cull_hardware clockwise
|
||||
|
||||
vertex_program_ref VehicleIdMapVP
|
||||
{
|
||||
}
|
||||
|
||||
fragment_program_ref VehicleIdMapFP
|
||||
{
|
||||
param_named glassAlpha float 1.0
|
||||
}
|
||||
|
||||
texture_unit
|
||||
{
|
||||
texture @BASE@-atlas.png
|
||||
tex_address_mode wrap
|
||||
filtering linear linear none
|
||||
}
|
||||
|
||||
texture_unit
|
||||
{
|
||||
texture @BASE@-idmap.png
|
||||
tex_address_mode clamp
|
||||
filtering point point none
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
mat_path = os.path.join(outdir, base + ".material")
|
||||
with open(mat_path, "w") as f:
|
||||
f.write(IDMAP_MATERIAL_TEMPLATE.replace("@BASE@", base))
|
||||
print("gen_vehicle: wrote merged material %s" % mat_path)
|
||||
|
||||
# blender2ogre also writes a fixed-function <base>-glass.material for
|
||||
# the glass material slot; drop it (and the stale pre-ID-map split
|
||||
# files) — the template above defines every <base>* material and a
|
||||
# duplicate definition from another script would shadow it depending
|
||||
# on resource parse order.
|
||||
for suffix in ("-color", "-color-bloom", "-glass"):
|
||||
stale = os.path.join(outdir, base + suffix + ".material")
|
||||
if os.path.exists(stale):
|
||||
os.remove(stale)
|
||||
|
||||
bpy.ops.wm.read_homefile(use_empty=True)
|
||||
import time
|
||||
time.sleep(2)
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
# Creating a Car Model for the Game (Blender -> Engine)
|
||||
|
||||
This is the contract a car model must satisfy to work with the vehicle
|
||||
pipeline: drivable via `VehicleComponent`/`VehicleSystem`, enterable
|
||||
seats via `VehicleControllerSystem`, openable doors/hood/trunk, and a
|
||||
seated character that actually fits the cabin.
|
||||
|
||||
The reference implementation is `sedan-v2.blend` in this directory — a
|
||||
hand-modeled car processed by the convention-based generator
|
||||
`assets/blender/scripts/gen_vehicle.py`. This is the recommended
|
||||
route: model the car once following the naming conventions below and
|
||||
the generator produces the meshes, materials, textures AND a
|
||||
ready-to-merge scene-entity JSON fragment.
|
||||
|
||||
(The older fully-procedural route, `assets/blender/scripts/
|
||||
gen_taxi_assets.py` -> `sedan-a.blend` / `sedan-taxi.blend`, still
|
||||
works and follows the same engine-side conventions, but its
|
||||
`sitting-chair`-at-0.85-scale cabin numbers are superseded by the
|
||||
`sitting-vehicle` animation below.)
|
||||
|
||||
## 1. Export pipeline (gen_vehicle.py)
|
||||
|
||||
- Put the `.blend` in `assets/blender/vehicles/` and add it to
|
||||
`VEHICLES_GEN_SRC` in `assets/blender/vehicles/CMakeLists.txt`.
|
||||
- Build target `import_vehicles` runs:
|
||||
|
||||
```
|
||||
blender <name>.blend -b -Y -P gen_vehicle.py -- <out>.glb <out>.vehicle.json
|
||||
```
|
||||
|
||||
which does everything `export_vehicles.py` does (blender2ogre
|
||||
`.scene` + one `.mesh` per mesh data block + one `.material` per
|
||||
material, 3 LOD levels, plus a `.glb` preview) and additionally:
|
||||
- classifies parts by naming convention (see §2),
|
||||
- renames mesh data blocks to `<base>-<role>` (`sedan-v2`,
|
||||
`sedan-v2-door-fl`, `sedan-v2-wheel`, `sedan-v2-steering-wheel`,
|
||||
...),
|
||||
- renames materials to `<base>-<material>` and unpacks packed
|
||||
textures to `<base>-atlas.png` next to the output,
|
||||
- normalizes wheel/steering mesh axes (see §3),
|
||||
- emits `<name>.vehicle.json`: a complete scene entity (collider
|
||||
from the shell bounding box, wheels, seats, steering, door/hood/
|
||||
trunk children with seat actuators).
|
||||
- Objects named `*-col` are deleted before export (collision helpers).
|
||||
- After (re)export, artifacts land in `build-vscode/resources/vehicles/`.
|
||||
One target re-exports changed blends, re-stages the editScene runtime
|
||||
resources and re-merges the generated vehicle JSONs into the demo-taxi
|
||||
scenes (this edits the source scene JSONs — merge_vehicle.py preserves
|
||||
entity id/name/transform, so placement edits survive):
|
||||
|
||||
```bash
|
||||
cmake --build build-vscode --target update_vehicles
|
||||
```
|
||||
|
||||
## 2. Naming conventions (what the generator picks up)
|
||||
|
||||
- **root**: the single parentless MESH object is the body shell; its
|
||||
mesh DATA frame is the chassis frame (object locations are NOT baked
|
||||
into the `.mesh`, so author shell vertex data relative to the
|
||||
intended chassis origin).
|
||||
- **`* Tire`** (the word "tire" or "wheel" in the name): wheel objects.
|
||||
All wheels may share one mesh data block. Wheel radius/width are
|
||||
measured from the mesh bounds. No mirrored copy is needed for the
|
||||
right side: Jolt reports every wheel with the same axle direction, so
|
||||
`VehicleSystem` flips right-side (chassis -X) wheel visuals 180 deg
|
||||
about the local forward axis at runtime — model one wheel and reuse it
|
||||
for all four corners.
|
||||
- **`* Door`**: door child entities; origin on the hinge line (model
|
||||
the door CLOSED, then Object -> Set Origin -> Origin to 3D Cursor at
|
||||
the hinge). Each door gets a seat actuator for the seat behind it
|
||||
(same side + row).
|
||||
- **`* hood` / `* bonnet`**, **`* trunk`**: hood/trunk child entities,
|
||||
origin on the hinge line.
|
||||
- **`* Steering`**: the steering wheel.
|
||||
- **`seat_*`**: EMPTY objects marking character seat pins. Exactly one
|
||||
must have "driver" in its name (e.g. `seat_driver`); the front
|
||||
non-driver seat becomes `passenger`, rear seats become `rl`/`rr` by
|
||||
side. The empty's position is where the character's ROOT (base) is
|
||||
pinned — see §5.
|
||||
- Everything else is exported as an extra mesh named
|
||||
`<base>-<sanitized mesh name>` but gets no child entity.
|
||||
|
||||
## 3. Axis and origin conventions
|
||||
|
||||
The exporter swaps axes `EX_SWAP_AXIS='xz-y'`: Blender `(x, y, z)`
|
||||
becomes Ogre `(x, z, -y)`.
|
||||
|
||||
| Blender | Ogre / engine | Meaning |
|
||||
|---------|---------------|---------|
|
||||
| `-Y` | `+Z` | vehicle forward (Jolt chassis +Z) |
|
||||
| `+Z` | `+Y` | up |
|
||||
| `+X` | `+X` | vehicle LEFT side |
|
||||
|
||||
- **Chassis origin**: pick it once (sedan-v2: axle-box center, ~0.6 m
|
||||
above ground contact) and model the shell data around it. The
|
||||
collider, wheels, seats and part pivots in the generated JSON are all
|
||||
relative to it automatically.
|
||||
- **Wheels**: model them as they look (axle along Blender X). The
|
||||
generator rotates the wheel mesh DATA +90 deg about Blender Y so the
|
||||
axle lands on Blender Z (Ogre Y, which `getWheelWorldTransform`
|
||||
assumes) and counter-rotates the objects, so the exported scene keeps
|
||||
the modeled look. Don't pre-rotate.
|
||||
- **Steering wheel**: model it tilted as installed; the generator
|
||||
rotates the mesh DATA +90 deg about Blender X so the disc normal
|
||||
lands on Blender Z (Ogre Y, the spin axis `VehicleSystem` uses) and
|
||||
counter-rotates the object.
|
||||
- **blender2ogre names `.mesh` files after the mesh DATA name** — the
|
||||
generator handles the renaming, but never redefine another blend's
|
||||
mesh data names (all blends export into the same
|
||||
`resources/vehicles/` directory and silently overwrite).
|
||||
|
||||
## 4. Materials and textures (merged ID-map material)
|
||||
|
||||
- The generator consolidates EVERY mesh part (shell, doors, hood,
|
||||
trunk, wheels, steering, extras) onto **two materials**: `<base>`
|
||||
for paint/bloom faces and `<base>-glass` for glass faces — so
|
||||
blender2ogre emits **one submesh per part**, or two (paint first,
|
||||
glass last) for glass-bearing parts. The blender2ogre `.material`
|
||||
output is replaced by a hand-written one referencing the shared GLSL
|
||||
programs `VehicleIdMapVP`/`VehicleIdMapFP` (`vehicle_idmap.vert/.frag/.program`,
|
||||
staged into `resources/vehicles/` by CMake; OpenGL ES 2.0 compatible
|
||||
via `OgreUnifiedShader.h`).
|
||||
**Why not one merged submesh:** a single transparent submesh forces
|
||||
the WHOLE part (interior included) into OGRE's transparent render
|
||||
queue, where renderables sort back-to-front by node distance — the
|
||||
depth-writing window glass then culls interior parts whose nodes
|
||||
sort closer to the camera ("holes" punched in the cabin with the
|
||||
road showing through, 2026-10-21/22). Opaque paint + separate
|
||||
depth-write-off glass is the only arrangement that stays correct
|
||||
under per-object transparency sorting.
|
||||
- Per-fragment behavior comes from an **auto-generated ID map**
|
||||
(`<base>-idmap.png`, 512x512) that shares the color-atlas UV space:
|
||||
red = paint (atlas color, multiplied by the shader's `vehicleColor`
|
||||
uniform — the future per-car registry color hook), green = bloom
|
||||
(lights; optional `bloomEmissive` term, 0 by default), blue = glass
|
||||
(`glassColor` tint, alpha `glassAlpha`).
|
||||
- The car UVs are degenerate by design: every face collapses onto a
|
||||
single color-atlas texel (the atlas is a palette). The ID map simply
|
||||
paints the same texels with the ID color, so **no UV texel may be
|
||||
shared between two different materials** — the generator warns when
|
||||
that happens; fix the blend (give one of them its own texel) rather
|
||||
than working around it.
|
||||
- Source-material roles are recognized by name: `*glass*` -> glass,
|
||||
`*bloom*` -> lights, everything else -> paint.
|
||||
- **Filtering**: because the atlas is a palette, mipmaps are poison —
|
||||
minification averages neighboring palette texels and distant cars
|
||||
bleed wrong colors (found 2026-10-16: blue headlights at range). The
|
||||
generated `.material` therefore samples the atlas with
|
||||
`filtering linear linear none` (no mips) and the ID map with
|
||||
`filtering point point none`. Never "upgrade" these to mipmapped
|
||||
filtering.
|
||||
- **Sampler binding**: `vehicle_idmap.program` binds the samplers
|
||||
explicitly (`param_named uAtlas int 0` / `param_named uIdMap int 1`).
|
||||
Without this the two samplers are auto-assigned in driver
|
||||
uniform-enumeration order, which is not stable — `uIdMap` can end up
|
||||
reading the ATLAS (unit 0), and then every paint texel's blue channel
|
||||
leaks into `glassAlpha` and the whole car goes semi-transparent
|
||||
(found 2026-10-16: see-through doors on llvmpipe while another
|
||||
machine rendered fine). Keep the explicit `int` params in sync with
|
||||
the texture_unit order in the generated `.material`.
|
||||
- **Near/far glass**: the generated `<base>.material` defines `<base>`
|
||||
(opaque paint, `glassAlpha 1.0`), `<base>-glass` (near glass:
|
||||
alpha-blended, `depth_write off`, `glassAlpha 0.25`) and
|
||||
`<base>-glass-opaque` (far glass: solid). The demo's
|
||||
`SedanGlassLodListener` swaps ONLY the glass submeshes between
|
||||
`<base>-glass` and `<base>-glass-opaque` at 10 m from the camera
|
||||
(Ogre 14 ignores `Material::setLodLevels` for entities); paint
|
||||
submeshes keep `<base>` at all distances. `depth_write off` is what
|
||||
routes the near glass into the transparent collection — do not add
|
||||
depth write back to it (that is what depth-blocked the driver's
|
||||
face/hair behind the window, 2026-10-21). No per-car setup is needed
|
||||
for new families beyond adding the base name to
|
||||
`kMergedCarMaterials` in the demo's `demo_main.cpp`.
|
||||
- Glass faces are sorted to the END of each mesh during generation so
|
||||
the glass submesh's triangles stay contiguous (cosmetic; the submesh
|
||||
split is what carries the rendering correctness).
|
||||
- For color variants of the same geometry, duplicate the mesh data and
|
||||
move the paint faces to a different atlas texel — or (later) drive
|
||||
`vehicleColor` per entity.
|
||||
|
||||
## 5. Matching the seated/driving character
|
||||
|
||||
The character is pinned to the seat point with its root node
|
||||
(`VehicleControllerSystem::pinCharacterToSeat`) and plays the
|
||||
locomotion state machine's `driving` state, mapped to the
|
||||
`sitting-vehicle` animation in the demo's `animation_tree.json`.
|
||||
|
||||
**With `sitting-vehicle`, the seat pin IS the character's base
|
||||
position** — the pose is authored so the butt, hands and feet land
|
||||
correctly relative to the root, so no offsets and no scaling are
|
||||
applied (`seatCharacterScale 1.0` in the generated JSON). Place each
|
||||
`seat_*` empty exactly where the character's feet-between point should
|
||||
sit: roughly on the floor pan at the cushion front edge.
|
||||
|
||||
- sedan-v2 seat pins (chassis space): driver/passenger
|
||||
(±0.48, -0.28, 0.201), rl/rr (±0.48, -0.28, -0.603); driver is
|
||||
front-LEFT (+X).
|
||||
- The generator takes the wheel centers as the intended REST position
|
||||
and lifts the suspension attach points by
|
||||
`L_rest = maxLength - g/(2*pi*freq)^2` (0.35 - 0.147 = 0.203 at the
|
||||
defaults min/max/freq/damping 0.1/0.35/1.3/0.6), so the car rides at
|
||||
the modeled stance. The suspension is deliberately soft-ish —
|
||||
stiffer springs make the wheels pogo and cost grip.
|
||||
- Tire friction: the JSON carries `tireFrictionLongitudinal` /
|
||||
`tireFrictionLateral` (1.3 / 3.0 for sedans), multipliers over the
|
||||
Jolt default tire curves (peak 1.2) which are then combined with the
|
||||
ground friction via `sqrt(tire * ground)` — at the stock 1.0 the car
|
||||
drives like it is on wet ice (full lock at ~15 m/s turns only ~44
|
||||
deg in 2.5 s; 3.0 lateral turns ~88 deg). More longitudinal grip
|
||||
adds understeer on a RWD car, so keep it near stock.
|
||||
- Anti-roll bars: `antiRollBarStiffness` (2000 for sedans) adds a Jolt
|
||||
`VehicleAntiRollBar` per axle pair to limit body roll. The engine
|
||||
also rightens a car stuck past ~35 deg of tilt automatically
|
||||
(VehicleSystem), so two-wheel balancing acts recover on their own.
|
||||
- Center of mass: `centerOfMassOffset` shifts the physics COM in
|
||||
chassis space (Jolt `OffsetCenterOfMassShape` — only the COM and the
|
||||
inertia about it move; the collider, wheel attachments and the
|
||||
render node stay put). A box collider puts the COM at the box
|
||||
center — for a sedan ~1 m above the tire contact patches, twice the
|
||||
real value — and the car trips onto its side in hard corners (the
|
||||
taxi's taller box makes it worse). The generator places the COM
|
||||
just above wheel-hub height; tweak the chassis-space offset in the
|
||||
editor Vehicle panel (applied live) if a new model still feels
|
||||
tippy.
|
||||
- Downforce: `downforceCoefficient` (35 plain sedan, 45 taxi — the
|
||||
CMake invocation passes it per model) applies a straight down force
|
||||
of coefficient * speed^2 at the COM while moving — less bounce and,
|
||||
because the tire normal load (and with it the lateral impulse
|
||||
budget) grows with speed^2, the turn radius stops growing with
|
||||
speed: this is the high-speed cornering grip lever. Raise it when a
|
||||
model understeers at speed, lower it if the car feels glued.
|
||||
- Default chassis tuning in the JSON: mass 1200, maxTorque 300,
|
||||
engineBrake 0 (cars coast when the accelerator is released — braking
|
||||
is the driver's job: S while rolling forward brakes via the
|
||||
brake-before-reverse rule, Space is the handbrake; slow yard vehicles
|
||||
like the forklift use ~0.5 so they do not roll forever),
|
||||
front steer 35 deg, rear driven + handbrake 300,
|
||||
steeringRatio 4 — tweak per vehicle after merging.
|
||||
|
||||
## 6. Scene integration
|
||||
|
||||
The generated `<name>.vehicle.json` is a complete entity fragment.
|
||||
Merge it into a scene JSON with the helper
|
||||
`src/features/editScene/demos/demo-taxi/merge_vehicle.py`:
|
||||
|
||||
```bash
|
||||
python3 merge_vehicle.py <scene.json> \
|
||||
<builddir>/resources/vehicles/<name>.vehicle.json <entityName>
|
||||
```
|
||||
|
||||
It replaces the named scene entity (see `sedan1` in
|
||||
`demo_scene_vehicletest.json`) with the generated one, renaming the
|
||||
entity and children and preserving the scene entity's id and
|
||||
transform. **The scene copy does NOT track the blend** — after every
|
||||
blend edit re-run the `update_vehicles` target (see §1), which
|
||||
re-exports, re-stages resources and re-merges both demo-taxi scenes in
|
||||
one step. The door
|
||||
children carry `actuator`s with the generic per-seat actions
|
||||
`vehicle_seat_driver` / `_passenger` / `_rl` / `_rr` (defined in
|
||||
`lua-scripts/data2.lua`), which send the `vehicle_enter` event; the
|
||||
vehicle is resolved by proximity, so the same actions work for every
|
||||
car.
|
||||
|
||||
## 7. Minimal-effort multi-look strategy
|
||||
|
||||
- **Color variants**: duplicate shell + door mesh data, swap the paint
|
||||
material (atlas UVs make this cheap).
|
||||
- **Silhouette variants** (e.g. the taxi): keep the hardpoints fixed —
|
||||
wheel positions, `seat_*` empties, hinge lines, floor pan — and vary
|
||||
the outer panels (roof sign, livery, bumpers). The generated JSON
|
||||
then carries over nearly unchanged.
|
||||
- **Kitbashing**: keep a parts-library blend with the wheel, steering
|
||||
wheel, seats and dash; append into new car blends. Never reuse
|
||||
another blend's mesh DATA names (export clash, §3).
|
||||
|
||||
## 8. Verifying a new car
|
||||
|
||||
From the demo-taxi build directory
|
||||
(`build-vscode/src/features/editScene/demos/demo-taxi`):
|
||||
|
||||
```bash
|
||||
# Sit the player in a seat and dump a framed screenshot:
|
||||
xvfb-run -a -s "-screen 0 1920x1080x24" ./demoTaxi \
|
||||
demo_scene_vehicletest.json --force-pos 4.5 1.5 0.3 90 -15 \
|
||||
--debug-enter-vehicle <vehicleEntityName> \
|
||||
--debug-enter-seat driver --screenshot 700 /tmp/seat.png
|
||||
```
|
||||
|
||||
Check: butt on the cushion, head under the headliner, feet inside the
|
||||
body, glass transparent near / opaque past ~10 m, steering wheel in
|
||||
front of the driver. The `--debug-enter-*` run also prints a bone
|
||||
probe (head bone y should match the exported animation, ~0.75 for
|
||||
sitting-vehicle). Then run the headless contract:
|
||||
|
||||
```bash
|
||||
./demoTaxi --headless --test-vehicle-drive demo_scene_vehicletest.json
|
||||
```
|
||||
|
||||
(always with `--headless`: without it the frame delta is wall time and
|
||||
the fixed-length W-hold drives the car off the test floor, failing the
|
||||
exit-spot raycast for timing reasons, not because the car is broken).
|
||||
@@ -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}
|
||||
@@ -27,7 +27,11 @@ endforeach()
|
||||
# Convention-built drivable vehicles: same mesh/material export plus a
|
||||
# generated scene-entity JSON fragment (<name>.vehicle.json, see
|
||||
# assets/blender/scripts/gen_vehicle.py and vehicles/CAR_MODELING.md).
|
||||
set(VEHICLES_GEN_SRC sedan-v2.blend)
|
||||
set(VEHICLES_GEN_SRC sedan-v2.blend sedan-v2-taxi.blend)
|
||||
# Per-model downforce coefficient (third gen_vehicle.py argument):
|
||||
# heavier/taller handling gets more speed-grip.
|
||||
set(DOWNFORCE_sedan-v2 35)
|
||||
set(DOWNFORCE_sedan-v2-taxi 45)
|
||||
|
||||
foreach(VEHICLE_FILE ${VEHICLES_GEN_SRC})
|
||||
get_filename_component(FILE_NAME ${VEHICLE_FILE} NAME_WE)
|
||||
@@ -36,10 +40,10 @@ 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}
|
||||
-- ${VEHICLE_OUTPUT_FILE} ${VEHICLE_JSON_FILE} ${DOWNFORCE_${FILE_NAME}}
|
||||
COMMAND ${CMAKE_COMMAND} -D FILE=${CMAKE_BINARY_DIR}/resources/vehicles/${FILE_NAME}.glb -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
|
||||
COMMAND ${CMAKE_COMMAND} -D FILE=${CMAKE_BINARY_DIR}/resources/vehicles/${FILE_NAME}.scene -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
|
||||
COMMAND touch ${VEHICLE_OUTPUT_FILE}
|
||||
@@ -47,6 +51,13 @@ foreach(VEHICLE_FILE ${VEHICLES_GEN_SRC})
|
||||
list(APPEND VEHICLE_OUTPUT_FILES ${VEHICLE_OUTPUT_FILE} ${CMAKE_BINARY_DIR}/resources/vehicles/${FILE_NAME}.scene ${VEHICLE_JSON_FILE})
|
||||
endforeach()
|
||||
|
||||
# Shared merged-material vehicle shader (one ID-map material per car;
|
||||
# gen_vehicle.py writes the per-car .material referencing these
|
||||
# programs; see vehicles/CAR_MODELING.md). Staged next to the
|
||||
# generated .material files.
|
||||
file(COPY vehicle_idmap.vert vehicle_idmap.frag vehicle_idmap.program
|
||||
DESTINATION ${CMAKE_BINARY_DIR}/resources/vehicles)
|
||||
|
||||
add_custom_target(import_vehicles ALL DEPENDS ${VEHICLE_OUTPUT_FILES})
|
||||
|
||||
# One-step vehicle refresh: re-export changed .blend files (import_vehicles),
|
||||
@@ -65,5 +76,7 @@ add_custom_target(update_vehicles
|
||||
${CMAKE_BINARY_DIR}/resources/vehicles/sedan-v2.vehicle.json sedan1
|
||||
COMMAND python3 merge_vehicle.py demo_scene_exterior.json
|
||||
${CMAKE_BINARY_DIR}/resources/vehicles/sedan-v2.vehicle.json sedan_plain
|
||||
COMMAND python3 merge_vehicle.py demo_scene_exterior.json
|
||||
${CMAKE_BINARY_DIR}/resources/vehicles/sedan-v2-taxi.vehicle.json sedan_taxi
|
||||
WORKING_DIRECTORY ${DEMO_TAXI_DIR}
|
||||
DEPENDS import_vehicles)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Merged vehicle material — fragment stage.
|
||||
*
|
||||
* Per fragment the ID map (same UV space as the color atlas) selects
|
||||
* which parameter set applies, by multiplying each candidate value by
|
||||
* its ID channel weight:
|
||||
*
|
||||
* idmap.r = paint — atlas color, tinted by vehicleColor (the future
|
||||
* per-car registry color hook; white for now)
|
||||
* idmap.g = bloom — lights; shaded like paint today, plus an
|
||||
* optional emissive term (bloomEmissive, off by
|
||||
* default to keep the current look)
|
||||
* idmap.b = glass — glassColor tint, alpha = glassAlpha; glass
|
||||
* faces live on their own submesh whose material
|
||||
* the demo swaps by camera distance: "<base>-glass"
|
||||
* (near, alpha 0.25, depth_write off) /
|
||||
* "<base>-glass-opaque" (far, alpha 1.0) —
|
||||
* SedanGlassLodListener
|
||||
*
|
||||
* Lighting is a single directional light (the sun) + scene ambient,
|
||||
* Blinn specular on paint and glass. OpenGL ES 2.0 compatible via
|
||||
* OgreUnifiedShader.h.
|
||||
*/
|
||||
OGRE_NATIVE_GLSL_VERSION_DIRECTIVE
|
||||
#include <OgreUnifiedShader.h>
|
||||
|
||||
OGRE_UNIFORMS(
|
||||
uniform vec4 lightDirView;
|
||||
uniform vec4 lightDiffuse;
|
||||
uniform vec4 ambient;
|
||||
uniform vec4 vehicleColor;
|
||||
uniform vec4 glassColor;
|
||||
uniform float glassAlpha;
|
||||
uniform float bloomEmissive;
|
||||
uniform float specularStrength;
|
||||
uniform float shininess;
|
||||
)
|
||||
|
||||
SAMPLER2D(uAtlas, 0);
|
||||
SAMPLER2D(uIdMap, 1);
|
||||
|
||||
MAIN_PARAMETERS
|
||||
IN(vec2 vUV, TEXCOORD0)
|
||||
IN(vec3 vNormal, TEXCOORD1)
|
||||
IN(vec3 vPosView, TEXCOORD2)
|
||||
MAIN_DECLARATION
|
||||
{
|
||||
vec4 atlas = texture2D(uAtlas, vUV);
|
||||
vec3 idm = texture2D(uIdMap, vUV).rgb;
|
||||
float wPaint = idm.r;
|
||||
float wBloom = idm.g;
|
||||
float wGlass = idm.b;
|
||||
|
||||
vec3 base = atlas.rgb * vehicleColor.rgb * wPaint +
|
||||
atlas.rgb * wBloom +
|
||||
glassColor.rgb * wGlass;
|
||||
|
||||
vec3 n = normalize(vNormal);
|
||||
vec3 l = normalize(-lightDirView.xyz);
|
||||
vec3 v = normalize(-vPosView);
|
||||
vec3 h = normalize(l + v);
|
||||
float diff = max(dot(n, l), 0.0);
|
||||
float spec = pow(max(dot(n, h), 0.0), shininess) *
|
||||
specularStrength * (wPaint + wGlass);
|
||||
|
||||
vec3 lit = base * (ambient.rgb + diff * lightDiffuse.rgb) +
|
||||
spec * lightDiffuse.rgb;
|
||||
lit += atlas.rgb * bloomEmissive * wBloom;
|
||||
|
||||
float alpha = mix(1.0, glassAlpha, wGlass);
|
||||
gl_FragColor = vec4(lit, alpha);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Merged single-material vehicle shader (paint / bloom / glass selected
|
||||
* per fragment from an ID map texture sharing the color atlas UV space).
|
||||
* Used by the <car>.material files written by gen_vehicle.py; see
|
||||
* assets/blender/vehicles/CAR_MODELING.md. OpenGL ES 2.0 compatible
|
||||
* (OgreUnifiedShader.h).
|
||||
*/
|
||||
vertex_program VehicleIdMapVP glsl glsles glslang hlsl
|
||||
{
|
||||
source vehicle_idmap.vert
|
||||
default_params
|
||||
{
|
||||
param_named_auto worldViewProj worldviewproj_matrix
|
||||
param_named_auto worldView worldview_matrix
|
||||
}
|
||||
}
|
||||
|
||||
fragment_program VehicleIdMapFP glsl glsles glslang hlsl
|
||||
{
|
||||
source vehicle_idmap.frag
|
||||
default_params
|
||||
{
|
||||
param_named_auto lightDirView light_direction_view_space 0
|
||||
param_named_auto lightDiffuse light_diffuse_colour 0
|
||||
param_named_auto ambient ambient_light_colour
|
||||
param_named vehicleColor float4 1.0 1.0 1.0 1.0
|
||||
param_named glassColor float4 0.8 0.8 0.8 1.0
|
||||
param_named glassAlpha float 0.25
|
||||
param_named bloomEmissive float 0.0
|
||||
param_named specularStrength float 0.5
|
||||
param_named shininess float 32.0
|
||||
// explicit texture-unit bindings: without these the samplers
|
||||
// are auto-assigned in driver uniform-enumeration order and
|
||||
// uIdMap can end up reading the atlas (unit 0) — every paint
|
||||
// texel's blue channel then leaks into glassAlpha and the
|
||||
// whole car goes semi-transparent (2026-10-16)
|
||||
param_named uAtlas int 0
|
||||
param_named uIdMap int 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Merged vehicle material — vertex stage.
|
||||
*
|
||||
* One material for the whole car (see assets/blender/vehicles/
|
||||
* CAR_MODELING.md): the fragment stage selects paint / bloom (lights) /
|
||||
* glass behaviour per fragment from an ID map texture that shares the
|
||||
* color atlas UV space. OpenGL ES 2.0 compatible via
|
||||
* OgreUnifiedShader.h.
|
||||
*/
|
||||
OGRE_NATIVE_GLSL_VERSION_DIRECTIVE
|
||||
#include <OgreUnifiedShader.h>
|
||||
|
||||
OGRE_UNIFORMS(
|
||||
uniform mat4 worldViewProj;
|
||||
uniform mat4 worldView;
|
||||
)
|
||||
|
||||
OUT(vec2 vUV, TEXCOORD0)
|
||||
OUT(vec3 vNormal, TEXCOORD1)
|
||||
OUT(vec3 vPosView, TEXCOORD2)
|
||||
|
||||
MAIN_PARAMETERS
|
||||
IN(vec4 vertex, POSITION)
|
||||
IN(vec3 normal, NORMAL)
|
||||
IN(vec2 uv0, TEXCOORD0)
|
||||
MAIN_DECLARATION
|
||||
{
|
||||
gl_Position = mul(worldViewProj, vertex);
|
||||
vUV = uv0;
|
||||
/* Vehicles are never non-uniformly scaled, so the plain
|
||||
* world-view 3x3 is a fine normal transform. */
|
||||
vNormal = normalize(mul(mat3(worldView), normal));
|
||||
vPosView = mul(worldView, vertex).xyz;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Minimal Lua data file required by lateSetup().
|
||||
-- The app requires this file to exist in the LuaScripts resource group.
|
||||
-- Add project-specific Lua initialisation here.
|
||||
|
||||
function init()
|
||||
-- placeholder
|
||||
end
|
||||
|
||||
init()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user