Compare commits

..

35 Commits

Author SHA1 Message Date
slapin ea281bf913 Character ages and Phase 8 checkpoint (interiors) 2026-10-31 15:51:42 +03:00
slapin 214b05b40f Phase 7 checkpoint 2026-10-30 19:58:52 +03:00
slapin 141bfd31c1 Phase 7 checkpoint 2026-10-30 18:24:30 +03:00
slapin 7e0a74d837 Phase 7 start checkpoint 2026-10-30 16:47:10 +03:00
slapin 65066e6d29 Phase 6.8 checkpoint 2026-10-30 15:33:41 +03:00
slapin a3ec423cca Phase 6.8 checkpoint 2026-10-30 00:27:47 +03:00
slapin 19c5528f8e phase 6.8 checkpoint 2026-10-29 02:39:05 +03:00
slapin 2873a695f7 phase 6.8 checkpoint 2026-10-29 02:16:52 +03:00
slapin 54d9d4a28e phase 6.8 checkpoint 2026-10-29 01:31:02 +03:00
slapin d5b9ae235e Phase 6.8: character stats foundation 2026-10-28 15:56:20 +03:00
slapin c643d4388f Phase 6.7 complete checkpoint - checked 2026-10-28 13:38:24 +03:00
slapin 682ccee856 Phase 6.7 complete checkpoint 2026-10-28 12:43:16 +03:00
slapin d0ca3d181a Phase 6.7 complete checkpoint 2026-10-28 10:49:22 +03:00
slapin 72851b442f Phase 6.6 complete checkpoint 2026-10-28 07:35:18 +03:00
slapin 3dfd6c12bd Plan update; Loading screen for scene switching door is finally fixed 2026-10-26 16:25:28 +03:00
slapin e918ef8c4b Fixes for doors and arrival positions 2026-10-26 15:22:35 +03:00
slapin 88fd97fedd Phase 6.5 checkpoint again 2026-10-26 12:58:02 +03:00
slapin ec3f98f8a0 Keep legacy parts still compiling 2026-10-26 12:56:39 +03:00
slapin adc4c91a2c Phase 6.5 checkpoint 2026-10-26 11:03:11 +03:00
slapin 9e2d9fce59 Scheduled driving 2026-10-24 16:38:53 +03:00
slapin 95b42090f9 Data stability improved, town works much better 2026-10-23 16:08:29 +03:00
slapin e609d4e39b Improved data handling 2026-10-23 07:13:39 +03:00
slapin c561fad79e Fixed vehicle rendering 2026-10-22 10:14:44 +03:00
slapin ff81a9a657 Fixed some glass material woes making characters look bals and faceless 2026-10-22 07:58:19 +03:00
slapin 73e1cef832 Hardweware skinning works! 2026-10-21 12:21:58 +03:00
slapin 136013440e In phase 5 2026-10-16 15:24:34 +03:00
slapin e58c061709 NPC motion 2026-10-14 05:35:03 +03:00
slapin 6f152b0248 Layout updates. 2026-10-11 22:45:26 +03:00
slapin d28fce7631 Phase 4.7: in progress 2026-10-11 22:23:43 +03:00
slapin 00b2d5d1fe Phase 4.6 complete: buildings made of rooms, per-building regen 2026-10-10 10:30:44 +03:00
slapin a3279f984b Phase 4.5, town and building layouts 2026-10-08 05:16:04 +03:00
slapin 7adfaa53a2 Phases till 4 implemented. Basic generation of the town is added 2026-10-05 16:55:04 +03:00
slapin 5ca9b66d72 Road geometry fixed 2026-10-01 04:33:51 +03:00
slapin f9689eaa32 taxi demo: Taxi integrated, phase 1 complete 2026-09-17 04:58:46 +03:00
slapin 129557c4ba Taxi sedan added 2026-09-17 04:09:22 +03:00
366 changed files with 107232 additions and 3777 deletions
+25
View File
@@ -131,8 +131,24 @@ cmake --build build --target demoLuaSceneScript # Demo: game-mode inline scene/p
cmake --build build --target demoSceneSwitchingExtra # Demo: scene switching with CellGrid interior
cmake --build build --target demoSokoban # Demo: forklift sokoban activity (vehicle + crates + quest HUD)
cmake --build build --target demoTaxi # Demo: taxi driver (procedural town, sedans, NPC traffic, fare chain)
cmake --build build --target characters-all # All adult/baby/child/teenager/elder character assets
cmake --build build --target character-assets-check # Validate age catalogs/LODs/resource tree
```
### Parallel asset builds
Blender 3.6 batch jobs are not safe to run concurrently (shared user
config/addon state and shared build files). Every Blender invocation in
the CMake pipeline goes through `${BLENDER_LOCKED}`, which is
`flock -x ${BLENDER_LOCK} <blender>`, so `cmake --build build -jN` is
safe: only one Blender job runs at a time while CMake copies, compiles
and validates in parallel. `flock` must be installed (configure fails
otherwise). The default lock is per build tree
(`<build-dir>/blender-assets.lock`); if two build trees are built at the
same time, configure them with the same lock file, for example
`-DBLENDER_LOCK=/tmp/world2-blender-assets.lock`. New custom commands
must use `${BLENDER_LOCKED}`, never `${BLENDER}` directly.
### Build Outputs
- `build/Game` - Main game executable (legacy)
- `build/Editor` - World editor executable (legacy)
@@ -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
View File
@@ -4,6 +4,19 @@ project(world2)
set(CMAKE_CXX_STANDARD 17)
enable_testing()
set(BLENDER "${CMAKE_SOURCE_DIR}/../../blender-bin/bin/blender" CACHE STRING "Blender path")
# Blender 3.6 batch jobs are not safe to run concurrently: they share
# user config/addon state and the asset scripts read/write the same
# build-tree files. Every Blender invocation must go through
# ${BLENDER_LOCKED} so only one runs at a time. CMake can still run
# copies, converters, Python validation and C++ compilation in parallel.
find_program(FLOCK_EXECUTABLE flock)
if(NOT FLOCK_EXECUTABLE)
message(FATAL_ERROR
"flock (util-linux) is required to serialize Blender asset jobs")
endif()
set(BLENDER_LOCK "${CMAKE_BINARY_DIR}/blender-assets.lock" CACHE STRING
"Advisory lock for Blender jobs; share across concurrently-built trees")
set(BLENDER_LOCKED "${FLOCK_EXECUTABLE}" -x "${BLENDER_LOCK}" "${BLENDER}")
set(CREATE_DIRECTORIES
${CMAKE_BINARY_DIR}/assets/blender/shapes/male
${CMAKE_BINARY_DIR}/assets/blender/shapes/female
@@ -128,7 +141,7 @@ foreach(BUILDING_FILE ${BUILDINGS_SRC})
add_custom_command(
OUTPUT ${BUILDING_OUTPUT_FILE}
COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_BINARY_DIR}/resources/buildings
COMMAND ${BLENDER} ${BUILDING_FILE}
COMMAND ${BLENDER_LOCKED} ${BUILDING_FILE}
-b -Y -P
${CMAKE_SOURCE_DIR}/assets/blender/scripts/export_buildings.py
-- ${BUILDING_OUTPUT_FILE}
@@ -148,7 +161,7 @@ foreach(CHARACTER_SHAPE_FILE ${CHARACTER_SHAPES_SRC})
OUTPUT ${CHARACTER_SHAPE_OUTPUT_FILE}
COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_BINARY_DIR}/assets/blender
COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_BINARY_DIR}/characters/shapes/male/${FILE_NAME}
COMMAND ${BLENDER} ${CMAKE_SOURCE_DIR}/assets/blender/${CHARACTER_SHAPE_FILE}
COMMAND ${BLENDER_LOCKED} ${CMAKE_SOURCE_DIR}/assets/blender/${CHARACTER_SHAPE_FILE}
-b -Y -P
${CMAKE_SOURCE_DIR}/assets/blender/scripts/export_characters_ogre.py
-- ${CHARACTER_SHAPE_OUTPUT_FILE}
@@ -161,7 +174,7 @@ add_custom_target(import_character_shapes ALL DEPENDS ${CHARACTER_SHAPES_OUTPUT_
set(WATER_STUFF)
add_custom_command(OUTPUT ${CMAKE_BINARY_DIR}/water/sea.glb
COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_BINARY_DIR}/water
COMMAND ${BLENDER} ${CMAKE_SOURCE_DIR}/assets/blender/sea.blend
COMMAND ${BLENDER_LOCKED} ${CMAKE_SOURCE_DIR}/assets/blender/sea.blend
-b -Y -P
${CMAKE_SOURCE_DIR}/assets/blender/scripts/export_buildings.py
-- ${CMAKE_BINARY_DIR}/water/sea.glb
+111 -12
View File
@@ -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 &nbsp; <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
```
+210 -121
View File
@@ -1,30 +1,76 @@
project(characters)
set(EDITED_BLENDS_LIST
male
female
)
set(CHARACTER_AGES adult baby child teenager elder)
set(CHARACTER_SEXES male female)
set(CHARACTER_BUILD_AGES ${CHARACTER_AGES} CACHE STRING
"Age bands to build (semicolon list; e.g. adult or adult;elder)")
# Adult keeps the legacy stem (male/female) and all existing resource names.
function(character_stem AGE SEX OUT)
if(AGE STREQUAL "adult")
set(${OUT} ${SEX} PARENT_SCOPE)
else()
set(${OUT} "${AGE}-${SEX}" PARENT_SCOPE)
endif()
endfunction()
function(character_build_blend AGE SEX OUT)
character_stem(${AGE} ${SEX} STEM)
set(${OUT}
"${CMAKE_CURRENT_BINARY_DIR}/edited-normal-${STEM}.blend"
PARENT_SCOPE)
endfunction()
function(character_source_blend AGE SEX OUT)
character_stem(${AGE} ${SEX} STEM)
set(${OUT}
"${CMAKE_CURRENT_SOURCE_DIR}/edited-normal-${STEM}.blend"
PARENT_SCOPE)
endfunction()
function(character_glb AGE SEX OUT)
character_stem(${AGE} ${SEX} STEM)
set(${OUT} "${CMAKE_BINARY_DIR}/characters/${STEM}/normal-${STEM}.glb"
PARENT_SCOPE)
endfunction()
set(EDITED_BLEND_TARGETS)
set(CHARACTER_GLBS)
foreach(EDITED_BLEND ${EDITED_BLENDS_LIST})
add_custom_command(
OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/edited-normal-${EDITED_BLEND}.blend
DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/edited-normal-${EDITED_BLEND}.blend
${CMAKE_BINARY_DIR}/assets/blender/vrm-vroid-normal-${EDITED_BLEND}.blend
${CMAKE_CURRENT_SOURCE_DIR}/copy_animations.py
${CMAKE_BINARY_DIR}/assets/blender/mixamo
${CMAKE_CURRENT_BINARY_DIR}/blender-addons-installed
COMMAND ${CMAKE_COMMAND}
-E copy ${CMAKE_CURRENT_SOURCE_DIR}/edited-normal-${EDITED_BLEND}.blend
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-${EDITED_BLEND}.blend
COMMAND ${BLENDER} -b -Y
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-${EDITED_BLEND}.blend
-P ${CMAKE_CURRENT_SOURCE_DIR}/copy_animations.py --
${CMAKE_BINARY_DIR}/assets/blender/vrm-vroid-normal-${EDITED_BLEND}.blend ${EDITED_BLEND}
WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
)
list(APPEND EDITED_BLEND_TARGETS ${CMAKE_BINARY_DIR}/assets/blender/characters/edited-normal-${EDITED_BLEND}.blend)
list(APPEND CHARACTER_GLBS ${CMAKE_BINARY_DIR}/characters/${EDITED_BLEND}/normal-${EDITED_BLEND}.glb)
foreach(AGE ${CHARACTER_BUILD_AGES})
foreach(SEX ${CHARACTER_SEXES})
character_stem(${AGE} ${SEX} STEM)
character_source_blend(${AGE} ${SEX} BASE_SOURCE)
character_build_blend(${AGE} ${SEX} BASE_BUILD)
set(BASE_DEPS ${BASE_SOURCE} ${CMAKE_CURRENT_BINARY_DIR}/blender-addons-installed)
set(BASE_COMMANDS
COMMAND ${CMAKE_COMMAND} -E copy ${BASE_SOURCE} ${BASE_BUILD})
if(AGE STREQUAL "adult")
# Adult keeps the historical animation-copy step.
list(APPEND BASE_DEPS
${CMAKE_BINARY_DIR}/assets/blender/vrm-vroid-normal-${SEX}.blend
${CMAKE_CURRENT_SOURCE_DIR}/copy_animations.py
${CMAKE_BINARY_DIR}/assets/blender/mixamo)
list(APPEND BASE_COMMANDS
COMMAND ${BLENDER_LOCKED} -b -Y ${BASE_BUILD}
-P ${CMAKE_CURRENT_SOURCE_DIR}/copy_animations.py --
${CMAKE_BINARY_DIR}/assets/blender/vrm-vroid-normal-${SEX}.blend ${SEX})
else()
# Generated seeds already contain retargeted actions + NLA
# tracks. Never retarget here (double scaling).
list(APPEND BASE_DEPS
${CMAKE_CURRENT_SOURCE_DIR}/generate_age_blends.py)
endif()
add_custom_command(
OUTPUT ${BASE_BUILD}
DEPENDS ${BASE_DEPS}
${BASE_COMMANDS}
WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
)
list(APPEND EDITED_BLEND_TARGETS ${BASE_BUILD})
character_glb(${AGE} ${SEX} GLB)
list(APPEND CHARACTER_GLBS ${GLB})
endforeach()
endforeach()
list(APPEND CHARACTER_GLBS ${CMAKE_BINARY_DIR}/characters/male/male-clothes-toprobe.glb)
add_custom_command(OUTPUT ${CMAKE_BINARY_DIR}/assets/blender/mixamo
@@ -41,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})
+256
View File
@@ -0,0 +1,256 @@
"""Age-band profiles and naming helpers for the character pipeline.
This module is the single asset-side source of truth for the automatic
adult -> age conversion. It is imported by the Blender scripts and by
``generate_age_blends.py``; the C++ runtime mirrors the small amount of data
it needs (names, age thresholds, fallback chains and body metrics).
Keep this file dependency-free (stdlib only) so it can be imported outside
Blender for validation/tests.
"""
from __future__ import annotations
CANONICAL_AGES = ["baby", "child", "teenager", "adult", "elder"]
AGE_ALIASES = {
"infant": "baby",
"teen": "teenager",
"adolescent": "teenager",
"senior": "elder",
"old": "elder",
"old-age": "elder",
}
# Canonical band boundaries used by the population reference rules.
BABY_MAX_YEARS = 2
CHILD_MAX_YEARS = 12
TEEN_MAX_YEARS = 17
ADULT_MAX_YEARS = 64
# Population rules' default ranges (mirrors TownPopulation::PopulationRules).
DEFAULT_AGE_RULES = {
"childMinYears": 1,
"childMaxYears": 17,
"adultMinYears": 18,
"adultMaxYears": 64,
"elderMinYears": 65,
"elderMaxYears": 90,
}
# Animation names every age skeleton must expose. The underlying clips may
# differ per band (baby crawl etc.); the names are the animation-tree contract.
REQUIRED_ANIMATIONS = [
"default",
"idle",
"walking",
"running",
"sitting",
"sitting-ground",
"sitting-vehicle",
"swimming",
"treading_water",
"swimming-fast",
"jump",
"left_turn",
"right_turn",
]
# Per-band conversion profile.
#
# Length/radial values are multipliers over the same-sex adult bone segments /
# thickness. They are conversion targets, not medical proportions.
# ``targetHeight`` is filled by sex below.
PROFILES = {
"baby": {
"hipHeight": 0.28,
"torso": 0.30,
"head": 0.85,
"shoulderWidth": 0.50,
"arm": 0.30,
"leg": 0.26,
"foot": 0.55,
"hipWidth": 0.55,
"bodyRadial": 0.55,
"limbRadial": 0.50,
"headBulge": 1.08,
"walkSpeedScale": 0.0, # home-only in v1
},
"child": {
"hipHeight": 0.55,
"torso": 0.62,
"head": 0.92,
"shoulderWidth": 0.70,
"arm": 0.60,
"leg": 0.55,
"foot": 0.75,
"hipWidth": 0.72,
"bodyRadial": 0.78,
"limbRadial": 0.72,
"headBulge": 1.03,
"walkSpeedScale": 0.75,
},
"teenager": {
"hipHeight": 0.94,
"torso": 0.92,
"head": 0.99,
"shoulderWidth": 0.92,
"arm": 0.92,
"leg": 0.94,
"foot": 0.97,
"hipWidth": 0.92,
"bodyRadial": 0.94,
"limbRadial": 0.93,
"headBulge": 1.0,
"walkSpeedScale": 0.95,
},
"adult": {
"hipHeight": 1.0,
"torso": 1.0,
"head": 1.0,
"shoulderWidth": 1.0,
"arm": 1.0,
"leg": 1.0,
"foot": 1.0,
"hipWidth": 1.0,
"bodyRadial": 1.0,
"limbRadial": 1.0,
"headBulge": 1.0,
"walkSpeedScale": 1.0,
},
# Elder v1 stays bind-identical to adult: aging is geometry/texture only.
# The manual detail pass can scale the rig or posture later.
"elder": {
"hipHeight": 1.0,
"torso": 1.0,
"head": 1.0,
"shoulderWidth": 1.0,
"arm": 1.0,
"leg": 1.0,
"foot": 1.0,
"hipWidth": 1.0,
"bodyRadial": 1.0,
"limbRadial": 1.0,
"headBulge": 1.0,
"walkSpeedScale": 1.0,
},
}
# Target mesh/skeleton heights in metres. Elder is adult height for the
# bind-identical v1 seed; the detail pass can shorten it.
TARGET_HEIGHT = {
("baby", "male"): 0.72,
("baby", "female"): 0.70,
("child", "male"): 1.20,
("child", "female"): 1.18,
("teenager", "male"): 1.70,
("teenager", "female"): 1.55,
("adult", "male"): 1.85,
("adult", "female"): 1.58,
("elder", "male"): 1.85,
("elder", "female"): 1.58,
}
ADULT_HEIGHT = {"male": 1.85, "female": 1.58}
FALLBACK_CHAINS = {
"baby": ["baby", "child", "adult"],
"child": ["child", "baby", "teenager", "adult"],
"teenager": ["teenager", "child", "adult"],
"adult": ["adult", "teenager", "elder"],
"elder": ["elder", "adult"],
}
# Garment/body fit reporting thresholds.
GARMENT_OFFSET = 0.003 # metres pushed out from the body surface
QUALITY = {
"seam_warn": 0.001,
"seam_fail": 0.01,
"penetration_warn": 0.01,
"penetration_fail": 0.05,
"height_tolerance": 0.05,
"edge_residual_warn": 0.35, # |log(expected/actual)|-ish residual
"edge_residual_fail": 0.70,
}
def normalize_age(age: str | None) -> str:
if not age:
return "adult"
key = str(age).strip().lower()
key = AGE_ALIASES.get(key, key)
return key if key in CANONICAL_AGES else "adult"
def age_band_for_years(years: int) -> str:
if years <= BABY_MAX_YEARS:
return "baby"
if years <= CHILD_MAX_YEARS:
return "child"
if years <= TEEN_MAX_YEARS:
return "teenager"
if years <= ADULT_MAX_YEARS:
return "adult"
return "elder"
def is_working_age(years: int) -> bool:
return age_band_for_years(years) == "adult"
def fallback_chain(age: str) -> list[str]:
return list(FALLBACK_CHAINS[normalize_age(age)])
def stem(age: str, sex: str) -> str:
age = normalize_age(age)
return sex if age == "adult" else f"{age}-{sex}"
def armature_name(age: str, sex: str) -> str:
return stem(age, sex)
def mesh_prefix(age: str, sex: str) -> str:
return stem(age, sex) + "_"
def target_height(age: str, sex: str) -> float:
age = normalize_age(age)
return TARGET_HEIGHT[(age, sex)]
def body_metrics(age: str, sex: str) -> dict:
"""Capsule/head metrics scaled from the current adult defaults."""
age = normalize_age(age)
scale = target_height(age, sex) / ADULT_HEIGHT[sex]
radius = 0.3 * scale
height = 1.8 * scale
return {
"radius": radius,
"height": height,
"totalHeight": height + 2.0 * radius,
"headRadius": 0.13 * scale,
"headOffsetY": 0.0,
"walkSpeedScale": PROFILES[age]["walkSpeedScale"],
}
def profile(age: str) -> dict:
return dict(PROFILES[normalize_age(age)])
def profile_hash() -> str:
import hashlib
import json
payload = {
"ages": CANONICAL_AGES,
"profiles": PROFILES,
"heights": {f"{a}|{s}": h for (a, s), h in TARGET_HEIGHT.items()},
"animations": REQUIRED_ANIMATIONS,
"quality": QUALITY,
}
return hashlib.sha256(
json.dumps(payload, sort_keys=True).encode("utf-8")
).hexdigest()[:16]
@@ -0,0 +1,9 @@
"""Age-seed conversion helpers (Blender 3.6)."""
__all__ = [
"profiles",
"rig",
"deform",
"report",
"anim",
]
+140
View File
@@ -0,0 +1,140 @@
"""Animation copy/retargeting for age seeds.
The adult Mixamo clips animate rotations in bone-local space and locations for
the hips/root. Because the age rig keeps every bone's rest orientation, the
rotation curves transfer 1:1; only location curves need the bone's rest-offset
ratio. This module is authoring-time only and must not run during the normal
build (that would scale the location tracks twice).
"""
from __future__ import annotations
import re
import bpy
from . import profiles
from .rig import bone_length_ratios
_LOC_RE = re.compile(r'pose\.bones\["([^"]+)"\]\.(location|delta_location)')
def _nla_track_names(arm):
if not arm.animation_data:
return set()
return {t.name for t in arm.animation_data.nla_tracks}
def _ensure_track(arm, action):
ad = arm.animation_data
if ad is None:
arm.animation_data_create()
ad = arm.animation_data
existing = {t.name for t in ad.nla_tracks}
if action.name in existing:
return
track = ad.nla_tracks.new()
track.name = action.name
# Match copy_animations.py: strips start at frame 1 and remain active;
# export_models2.py bakes each strip explicitly.
track.strips.new(action.name, 1, action)
def retarget_actions(arm, ratios: dict, hip_ratio: float,
actions) -> dict:
"""Scale location F-curves in *actions* and return a small summary."""
scaled_curves = 0
for action in actions:
for fc in action.fcurves:
m = _LOC_RE.match(fc.data_path)
if m:
bone = m.group(1)
ratio = ratios.get(bone, 1.0)
for kp in fc.keyframe_points:
kp.co.y *= ratio
kp.handle_left.y *= ratio
kp.handle_right.y *= ratio
scaled_curves += 1
elif fc.data_path in ("location", "delta_location"):
for kp in fc.keyframe_points:
kp.co.y *= hip_ratio
kp.handle_left.y *= hip_ratio
kp.handle_right.y *= hip_ratio
scaled_curves += 1
return {"scaledLocationCurves": scaled_curves}
def _ensure_alias(missing: str, present: set) -> str | None:
candidates = {
"swimming-fast": ["swimming", "walking", "idle"],
"sitting-ground": ["sitting", "default"],
"sitting-vehicle": ["sitting", "default"],
"sitting": ["default", "idle"],
"treading_water": ["swimming", "idle"],
"default": ["idle", "walking", "T-Pose"],
}.get(missing, [])
for name in candidates:
if name in present:
return name
return None
def append_and_retarget(arm, adult_arm, source_blend: str, age: str,
sex: str, hips_bone="mixamorig:Hips"):
"""Append the sex's Mixamo actions, retarget them and build NLA tracks."""
age = profiles.normalize_age(age)
stem = profiles.stem(age, sex)
existing_names = {a.name for a in bpy.data.actions}
with bpy.data.libraries.load(source_blend, link=False) as (src, dst):
wanted = [n for n in src.actions if n not in existing_names]
dst.actions = wanted
appended = [a for a in dst.actions if a is not None]
# Fill required-but-missing action names with the closest available clip.
# These are explicit placeholders for the manual animation pass; keeping
# the names present prevents silent T-poses / missing-state errors.
aliased = {}
for name in profiles.REQUIRED_ANIMATIONS:
if bpy.data.actions.get(name) is not None:
continue
src_name = _ensure_alias(name, {a.name for a in bpy.data.actions})
if not src_name:
continue
src = bpy.data.actions[src_name]
new = src.copy()
new.name = name
new.use_fake_user = True
aliased[name] = src_name
# Ensure every action (including custom baby clips and aliases) has an
# NLA track.
for action in bpy.data.actions:
if action.name.startswith("bake_"):
continue
if action.name in ("T-Pose",):
continue
_ensure_track(arm, action)
ratios = bone_length_ratios(adult_arm, arm)
hip_ratio = ratios.get(hips_bone, 1.0)
# Retarget ALL actions, including clips already present in the source
# base blend (sitting-ground/sitting-vehicle/etc. are authored there).
summary = retarget_actions(arm, ratios, hip_ratio,
list(bpy.data.actions))
summary["appended"] = [a.name for a in appended]
summary["aliased"] = aliased
# Ensure the default action is active (export_models2 expects it).
default = bpy.data.actions.get("default")
if default is not None:
if arm.animation_data is None:
arm.animation_data_create()
arm.animation_data.action = default
present = {a.name for a in bpy.data.actions}
missing = [n for n in profiles.REQUIRED_ANIMATIONS if n not in present]
summary["missing"] = missing
summary["stem"] = stem
return summary
+240
View File
@@ -0,0 +1,240 @@
"""Weighted rest-pose deformation for body parts, clothes and shape keys."""
from __future__ import annotations
import math
import bpy
from mathutils import Matrix, Vector
from . import profiles
def _nearest_bone_distance(point: Vector, bone) -> float:
a = bone.head_local
b = bone.tail_local
ab = b - a
denom = ab.length_squared
if denom <= 1e-12:
return (point - a).length
t = max(0.0, min(1.0, (point - a).dot(ab) / denom))
return (point - (a + ab * t)).length
def _nearest_bone_name(point: Vector, adult_arm) -> str:
best = None
best_d = 1e30
for b in adult_arm.data.bones:
if not b.use_deform:
continue
d = _nearest_bone_distance(point, b)
if d < best_d:
best_d = d
best = b.name
return best or "Root"
def _group_name_map(obj) -> dict:
return {i: g.name for i, g in enumerate(obj.vertex_groups)}
def _vertex_matrix(v, group_names, dmatrices, adult_arm, obj_to_arm):
"""Return (matrix, influence_count) for one mesh vertex."""
acc = 0.0
m = Matrix(((0.0, 0.0, 0.0, 0.0),
(0.0, 0.0, 0.0, 0.0),
(0.0, 0.0, 0.0, 0.0),
(0.0, 0.0, 0.0, 0.0)))
influences = 0
for g in v.groups:
w = float(g.weight)
if w <= 0.0:
continue
name = group_names.get(g.group)
dm = dmatrices.get(name)
if dm is None:
continue
acc += w
m = m + dm * w
influences += 1
if acc > 1e-9:
return m * (1.0 / acc), influences
# No usable weight: bind to the nearest deform bone.
p_arm = obj_to_arm @ v.co
name = _nearest_bone_name(p_arm, adult_arm)
dm = dmatrices.get(name)
return (dm.copy() if dm else Matrix.Identity(4)), 0
def deform_mesh_object(obj, adult_arm, age_arm, dmatrices,
profile: dict, retarget_parent=True):
"""Deform one mesh and its shape keys in place.
Returns a metrics dict. ``retarget_parent`` re-parents the mesh to the
age armature and points the armature modifier at it (used for the base
body seed). Garment seeds are saved without a body armature link, so
they pass ``False``.
"""
if obj.type != 'MESH':
return None
mesh = obj.data
group_names = _group_name_map(obj)
obj_to_arm = adult_arm.matrix_world.inverted() @ obj.matrix_world
arm_to_obj = obj.matrix_world.inverted() @ age_arm.matrix_world
before = [v.co.copy() for v in mesh.vertices]
matrices = []
unweighted = 0
max_inf = 0
for v in mesh.vertices:
m, inf = _vertex_matrix(v, group_names, dmatrices, adult_arm,
obj_to_arm)
matrices.append(m)
if inf == 0:
unweighted += 1
max_inf = max(max_inf, inf)
# Basis vertices.
for i, v in enumerate(mesh.vertices):
p = obj_to_arm @ v.co
v.co = arm_to_obj @ (matrices[i] @ p)
# Non-Basis shape keys: absolute positions, same weights.
key_count = 0
if mesh.shape_keys:
for kb in mesh.shape_keys.key_blocks:
if kb.name == 'Basis':
continue
key_count += 1
for i, kp in enumerate(kb.data):
p = obj_to_arm @ kp.co
kp.co = arm_to_obj @ (matrices[i] @ p)
mesh.update()
try:
mesh.calc_normals()
except Exception:
pass
if retarget_parent:
obj.parent = age_arm
mod = next((m for m in obj.modifiers
if m.type == 'ARMATURE'), None)
if mod is None:
mod = obj.modifiers.new(name="Armature", type='ARMATURE')
mod.object = age_arm
obj.matrix_parent_inverse = age_arm.matrix_world.inverted()
# Metrics.
edge_ratios = []
for e in mesh.edges:
a, b = e.vertices
lb = (before[a] - before[b]).length
la = (mesh.vertices[a].co - mesh.vertices[b].co).length
if lb > 1e-8:
edge_ratios.append(la / lb)
mesh.calc_loop_triangles()
degenerate = 0
for tri in mesh.loop_triangles:
a, b, c = tri.vertices
area = ((mesh.vertices[b].co - mesh.vertices[a].co).cross(
mesh.vertices[c].co - mesh.vertices[a].co)).length * 0.5
if area < 1e-10:
degenerate += 1
return {
"vertices": len(mesh.vertices),
"polygons": len(mesh.polygons),
"shapeKeys": key_count,
"unweighted": unweighted,
"maxInfluences": max_inf,
"edgeRatioMin": min(edge_ratios) if edge_ratios else 1.0,
"edgeRatioMax": max(edge_ratios) if edge_ratios else 1.0,
"degenerateTris": degenerate,
"before": before,
}
def deform_shape_keys_only(obj, adult_arm, age_arm, dmatrices):
"""Transform shape keys without touching the basis (utility)."""
return None
def snap_seams(objects_with_metrics, max_report=10):
"""Snap coincident boundary vertices across parts to their average.
``objects_with_metrics`` is a list of ``(obj, metrics)`` where metrics has
the original vertex positions. Returns a report dict.
"""
buckets = {}
for obj, metrics in objects_with_metrics:
before = metrics.get("before")
if not before:
continue
for i, p in enumerate(before):
key = (round(p.x, 5), round(p.y, 5), round(p.z, 5))
buckets.setdefault(key, []).append((obj, i, p))
max_gap = 0.0
snapped = 0
examples = []
for key, items in buckets.items():
if len(items) < 2:
continue
positions = [obj.matrix_world @ obj.data.vertices[i].co
for obj, i, _ in items]
avg = Vector((0, 0, 0))
for p in positions:
avg += p
avg /= len(positions)
gap = max((p - avg).length for p in positions)
if gap < 1e-6:
continue
max_gap = max(max_gap, gap)
for obj, i, _ in items:
delta = avg - (obj.matrix_world @ obj.data.vertices[i].co)
if delta.length <= 1e-9:
continue
v = obj.data.vertices[i]
v.co += obj.matrix_world.inverted().to_3x3() @ delta
if obj.data.shape_keys:
for kb in obj.data.shape_keys.key_blocks:
if kb.name == 'Basis':
continue
kb.data[i].co += (obj.matrix_world.inverted().to_3x3()
@ delta)
snapped += 1
if len(examples) < max_report:
examples.append({
"position": [round(v, 4) for v in key],
"gap": round(gap, 6),
"objects": sorted({obj.name for obj, _, _ in items}),
})
return {
"coincidentGroups": sum(1 for v in buckets.values() if len(v) > 1),
"snappedVertices": snapped,
"maxGap": max_gap,
"examples": examples,
}
def transform_hair_uniform(obj, adult_pivot: Vector, age_pivot: Vector,
scale: float):
"""Uniformly scale a hair mesh and move it from adult to age scalp."""
obj_to_arm = obj.matrix_world
inv = obj.matrix_world.inverted()
for v in obj.data.vertices:
p = obj_to_arm @ v.co
v.co = inv @ (age_pivot + (p - adult_pivot) * scale)
if obj.data.shape_keys:
for kb in obj.data.shape_keys.key_blocks:
if kb.name == 'Basis':
continue
for kp in kb.data:
p = obj_to_arm @ kp.co
kp.co = inv @ (age_pivot + (p - adult_pivot) * scale)
obj.data.update()
try:
obj.data.calc_normals()
except Exception:
pass
@@ -0,0 +1,25 @@
"""Re-export the asset-side age profile data for the converter package."""
import os
import sys
_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
_CHARACTERS_DIR = os.path.dirname(_THIS_DIR)
if _CHARACTERS_DIR not in sys.path:
sys.path.insert(0, _CHARACTERS_DIR)
import age_profiles as ap # noqa: E402,F401
CANONICAL_AGES = ap.CANONICAL_AGES
PROFILES = ap.PROFILES
REQUIRED_ANIMATIONS = ap.REQUIRED_ANIMATIONS
normalize_age = ap.normalize_age
age_band_for_years = ap.age_band_for_years
fallback_chain = ap.fallback_chain
stem = ap.stem
armature_name = ap.armature_name
mesh_prefix = ap.mesh_prefix
target_height = ap.target_height
body_metrics = ap.body_metrics
profile = ap.profile
profile_hash = ap.profile_hash
+134
View File
@@ -0,0 +1,134 @@
"""Conversion quality reporting for generated age seeds."""
from __future__ import annotations
import json
import os
import time
from . import profiles
class ConversionReport:
def __init__(self, age: str, sex: str):
self.age = profiles.normalize_age(age)
self.sex = sex
self.stem = profiles.stem(self.age, sex)
self.started = time.time()
self.objects = {}
self.warnings = []
self.fails = []
self.info = {}
self.seams = {}
self.rig = {}
def add_object(self, name: str, metrics: dict, quality: str = "ok",
note: str = ""):
entry = dict(metrics or {})
entry.pop("before", None) # large, not serializable/useful
entry["quality"] = quality
entry["note"] = note
self.objects[name] = entry
if quality == "fail":
self.fails.append(name)
elif quality in ("warn", "placeholder"):
self.warnings.append(name)
def status(self):
if self.fails:
return "fail"
if self.warnings:
return "warn"
return "ok"
def to_dict(self):
return {
"age": self.age,
"sex": self.sex,
"stem": self.stem,
"profileHash": profiles.profile_hash(),
"timestamp": int(self.started),
"status": self.status(),
"info": self.info,
"rig": self.rig,
"seams": self.seams,
"objects": self.objects,
"warnings": self.warnings,
"fails": self.fails,
}
def write(self, out_dir: str):
os.makedirs(out_dir, exist_ok=True)
base = os.path.join(out_dir,
f"conversion-report-{self.age}-{self.sex}")
data = self.to_dict()
with open(base + ".json", "w") as f:
json.dump(data, f, indent=2, sort_keys=True)
lines = [
f"# Conversion Report — {self.age} / {self.sex}",
"",
f"- Stem: `{self.stem}`",
f"- Status: **{data['status']}**",
f"- Profile hash: `{data['profileHash']}`",
f"- Objects: {len(self.objects)}",
f"- Warnings: {len(self.warnings)}",
f"- Fails: {len(self.fails)}",
"",
]
if self.info:
lines.append("## Info")
for k in sorted(self.info):
lines.append(f"- {k}: `{self.info[k]}`")
lines.append("")
if self.seams:
lines.append("## Seams")
for k, v in self.seams.items():
lines.append(f"- {k}: `{v}`")
lines.append("")
if self.fails:
lines += ["## Failures", ""]
lines += [f"- `{name}`" for name in self.fails]
lines.append("")
if self.warnings:
lines += ["## Warnings (first 20)", ""]
lines += [f"- `{name}`" for name in self.warnings[:20]]
lines.append("")
lines += ["## Objects", ""]
for name in sorted(self.objects):
e = self.objects[name]
lines.append(
f"- `{name}` — {e.get('quality', 'ok')}; "
f"verts={e.get('vertices', '?')}, "
f"tris={e.get('degenerateTris', '?')} degenerate, "
f"influences={e.get('maxInfluences', '?')}, "
f"unweighted={e.get('unweighted', '?')}, "
f"edge={e.get('edgeRatioMin', 1):.2f}.."
f"{e.get('edgeRatioMax', 1):.2f}"
+ (f"; {e.get('note')}" if e.get("note") else "")
)
path = base + ".md"
with open(path, "w") as f:
f.write("\n".join(lines) + "\n")
return path, base + ".json"
def evaluate_object_quality(metrics: dict, is_catalog: bool) -> str:
"""Cheap per-object quality classification for the seed report."""
if not metrics:
return "warn"
if metrics.get("unweighted", 0) > 0 and is_catalog:
return "warn"
if metrics.get("maxInfluences", 0) > 4:
return "fail"
if metrics.get("degenerateTris", 0) > 0:
return "warn"
emin = metrics.get("edgeRatioMin", 1.0)
emax = metrics.get("edgeRatioMax", 1.0)
# Raw ratios are expected to compress for baby/child; flag only extreme
# local distortion. The per-bone expected ratio is evaluated in the
# report writer's caller when available.
if emax > 2.5 or (emin > 0.0 and emin < 0.35):
return "warn"
return "ok"
+257
View File
@@ -0,0 +1,257 @@
"""Age-rig construction and rest-pose transform data.
All matrices are in armature space. The age rig keeps the adult bone names,
hierarchy, roll and orientation; only joint positions and segment lengths
change. This is what makes rotation-only animation retargeting valid.
"""
from __future__ import annotations
import math
import bpy
from mathutils import Matrix, Vector
from . import profiles
def bone_region(name: str) -> dict:
"""Return length/radial/offset profile keys for a Mixamo bone name."""
n = name
low = n.lower()
if n in ("Root", "mixamorig:Hips"):
return {"length": "hipHeight", "radial": "bodyRadial",
"offset": "hipHeight"}
if "spine" in low:
return {"length": "torso", "radial": "bodyRadial",
"offset": "torso"}
if "neck" in low:
return {"length": "torso", "radial": "bodyRadial",
"offset": "torso"}
if ("head" in low or "jaw" in low or "eye" in low or
"teeth" in low or "tongue" in low):
return {"length": "head", "radial": "head", "offset": "head"}
if "bust" in low:
return {"length": "torso", "radial": "bodyRadial",
"offset": "torso"}
if "hair" in low or "j_sec_hair" in low:
return {"length": "head", "radial": "head", "offset": "head"}
if "sleeve" in low:
return {"length": "arm", "radial": "limbRadial",
"offset": "arm"}
if "skirt" in low:
return {"length": "leg", "radial": "bodyRadial",
"offset": "hipWidth"}
if "shoulder" in low:
return {"length": "shoulderWidth", "radial": "limbRadial",
"offset": "shoulderWidth"}
if "upleg" in low or "upperleg" in low:
return {"length": "leg", "radial": "limbRadial",
"offset": "hipWidth"}
if "leg" in low:
return {"length": "leg", "radial": "limbRadial",
"offset": "hipWidth"}
if "foot" in low:
return {"length": "foot", "radial": "limbRadial",
"offset": "leg"}
if "toe" in low:
return {"length": "foot", "radial": "limbRadial",
"offset": "foot"}
if "arm" in low or "hand" in low or "thumb" in low or \
"index" in low or "middle" in low or "ring" in low or \
"pinky" in low:
return {"length": "arm", "radial": "limbRadial",
"offset": "arm"}
return {"length": "torso", "radial": "bodyRadial", "offset": "torso"}
def _depth(bone) -> int:
d = 0
p = bone.parent
while p is not None:
d += 1
p = p.parent
return d
def _unique_name(existing, base):
if base not in existing:
return base
i = 1
while f"{base}.{i:03d}" in existing:
i += 1
return f"{base}.{i:03d}"
def build_age_armature(adult_arm, age: str, sex: str,
profile: dict | None = None,
collection=None):
"""Duplicate *adult_arm* and scale its rest pose into the age rig.
Returns the new armature object. The new object/data are named
``<age>-<sex>`` (adult would keep the legacy sex name; this function is
only used for non-adult seeds).
"""
age = profiles.normalize_age(age)
profile = profile or profiles.profile(age)
stem = profiles.stem(age, sex)
age_arm = adult_arm.copy()
age_arm.data = adult_arm.data.copy()
age_arm.name = stem
age_arm.data.name = stem
(collection or bpy.context.collection).objects.link(age_arm)
# Snapshot adult rest positions before entering edit mode.
adult_head = {b.name: b.head_local.copy() for b in adult_arm.data.bones}
adult_tail = {b.name: b.tail_local.copy() for b in adult_arm.data.bones}
bpy.context.view_layer.objects.active = age_arm
age_arm.select_set(True)
bpy.ops.object.mode_set(mode='EDIT')
ebones = age_arm.data.edit_bones
new_head = {}
new_tail = {}
order = sorted(ebones, key=_depth)
for eb in order:
old_h = adult_head[eb.name]
old_t = adult_tail[eb.name]
direction = old_t - old_h
old_len = direction.length
if old_len <= 1e-6:
new_h = old_h.copy()
new_t = old_t.copy()
new_head[eb.name] = new_h
new_tail[eb.name] = new_t
continue
direction.normalize()
region = bone_region(eb.name)
lscale = float(profile[region["length"]])
rscale = float(profile[region["radial"]])
oscale = float(profile[region["offset"]])
if eb.parent is not None and eb.parent.name in new_head:
pname = eb.parent.name
if eb.use_connect:
new_h = new_tail[pname].copy()
else:
parent_old_h = adult_head[pname]
offset = old_h - parent_old_h
new_h = new_head[pname] + offset * oscale
else:
# Root or a parent-less helper: keep the origin, scale nothing
# laterally here (the profile handles the hips length).
new_h = old_h.copy()
new_len = old_len * lscale
new_t = new_h + direction * new_len
eb.head = new_h
eb.tail = new_t
# roll is intentionally untouched
new_head[eb.name] = new_h.copy()
new_tail[eb.name] = new_t.copy()
bpy.ops.object.mode_set(mode='OBJECT')
age_arm.select_set(False)
return age_arm
def _bone_scale_matrix(lscale: float, rscale: float) -> Matrix:
return Matrix.Diagonal((rscale, lscale, rscale, 1.0))
def rest_transform_data(adult_arm, age_arm, age: str,
profile: dict | None = None) -> dict:
"""Build serializable rest-pose deformation data for every bone."""
age = profiles.normalize_age(age)
profile = profile or profiles.profile(age)
out = {}
for ab in adult_arm.data.bones:
bb = age_arm.data.bones.get(ab.name)
if bb is None:
continue
region = bone_region(ab.name)
lscale = float(profile[region["length"]])
rscale = float(profile[region["radial"]])
f_adult = ab.matrix_local.copy()
f_age = bb.matrix_local.copy()
s = _bone_scale_matrix(lscale, rscale)
d = f_age @ s @ f_adult.inverted()
out[ab.name] = {
"lengthScale": lscale,
"radialScale": rscale,
"fAdult": [list(row) for row in f_adult],
"fAge": [list(row) for row in f_age],
"dMatrix": [list(row) for row in d],
}
return out
def dmatrices_from_data(data: dict) -> dict:
return {name: Matrix(entry["dMatrix"]) for name, entry in data.items()}
def bone_length_ratios(adult_arm, age_arm) -> dict:
out = {}
for ab in adult_arm.data.bones:
bb = age_arm.data.bones.get(ab.name)
if bb is None:
continue
al = (ab.tail_local - ab.head_local).length
bl = (bb.tail_local - bb.head_local).length
out[ab.name] = (bl / al) if al > 1e-9 else 1.0
return out
def validate_rest_orientation(adult_arm, age_arm, tol=1e-4) -> list:
"""Return a list of bones whose normalized rest direction changed."""
bad = []
for ab in adult_arm.data.bones:
bb = age_arm.data.bones.get(ab.name)
if bb is None:
continue
ad = (ab.tail_local - ab.head_local)
bd = (bb.tail_local - bb.head_local)
if ad.length < 1e-3 or bd.length < 1e-3:
continue
ad.normalize()
bd.normalize()
if (ad - bd).length > tol:
bad.append(ab.name)
return bad
def head_pivots(adult_arm, age_arm, head_bone="mixamorig:Head"):
ab = adult_arm.data.bones.get(head_bone)
bb = age_arm.data.bones.get(head_bone)
if not ab or not bb:
return Vector((0, 0, 0)), Vector((0, 0, 0))
return ab.head_local.copy(), bb.head_local.copy()
def scale_hair_armature(hair_arm, adult_head_pivot: Vector,
age_head_pivot: Vector, scale: float):
"""Scale a hair armature uniformly from the adult head to the age head.
Hair armatures are rigidly attached to the body head at runtime; a uniform
scale around the scalp preserves their own bone chain and animations.
"""
bpy.context.view_layer.objects.active = hair_arm
hair_arm.select_set(True)
bpy.ops.object.mode_set(mode='EDIT')
for eb in hair_arm.data.edit_bones:
old_h = eb.head.copy()
old_t = eb.tail.copy()
eb.head = age_head_pivot + (old_h - adult_head_pivot) * scale
eb.tail = age_head_pivot + (old_t - adult_head_pivot) * scale
bpy.ops.object.mode_set(mode='OBJECT')
hair_arm.select_set(False)
def transform_point_uniform(p: Vector, adult_pivot: Vector,
age_pivot: Vector, scale: float) -> Vector:
return age_pivot + (p - adult_pivot) * scale
@@ -0,0 +1,40 @@
"""Vertex-weight utilities for the age converter."""
from __future__ import annotations
import bpy
def limit_vertex_influences(obj, max_influences=4):
"""Keep the strongest N weights per vertex and normalize them to 1."""
if obj.type != 'MESH' or not obj.vertex_groups:
return 0
groups = {g.index: g for g in obj.vertex_groups}
mesh = obj.data
changed = 0
for v in mesh.vertices:
ws = [(g.group, float(g.weight)) for g in v.groups
if g.weight > 0.0 and g.group in groups]
if not ws:
continue
ws.sort(key=lambda p: p[1], reverse=True)
keep = ws[:max_influences]
drop = ws[max_influences:]
total = sum(w for _, w in keep)
if total <= 1e-9:
continue
for gi, _ in drop:
groups[gi].remove([v.index])
for gi, w in keep:
groups[gi].add([v.index], w / total, 'REPLACE')
changed += 1
return changed
def count_max_influences(obj):
if obj.type != 'MESH':
return 0
best = 0
for v in obj.data.vertices:
best = max(best, sum(1 for g in v.groups if g.weight > 0.0))
return best
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+32 -1
View File
@@ -6,6 +6,11 @@ import json
import mathutils
from mathutils.bvhtree import BVHTree
_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
if _THIS_DIR not in sys.path:
sys.path.insert(0, _THIS_DIR)
import age_profiles
def load_blend_files(clothes_blend_path, body_blend_path):
"""Load objects from blend files and return all loaded objects
@@ -364,6 +369,28 @@ def run_batch_combine():
print(f"Found {len(clothing_layer1)} layer 1 clothing objects")
print(f"Found {len(clothing_layer2)} layer 2 clothing objects")
# Guard against mixing ages/sexes in one pipeline run. The CMake inputs
# are per age/sex; this catches a misnamed file before it bakes nonsense.
ages = {age_profiles.normalize_age(o.get("age", "adult"))
for o in body_objects}
sexes = {str(o.get("sex", "")) for o in body_objects}
if len(ages) != 1 or len(sexes) != 1:
raise RuntimeError(
f"combine_clothes: mixed body ages/sexes: "
f"ages={sorted(ages)} sexes={sorted(sexes)}")
target_age = next(iter(ages))
target_sex = next(iter(sexes))
for o in clothing_layer1 + clothing_layer2:
obj_age = age_profiles.normalize_age(o.get("ref_age", "adult"))
obj_sex = str(o.get("ref_sex", ""))
if obj_age != target_age or obj_sex != target_sex:
raise RuntimeError(
f"combine_clothes: {o.name} is {obj_age}/{obj_sex}, "
f"body is {target_age}/{target_sex}")
# Keep the authoritative band on the source garment.
o["ref_age"] = target_age
o["ref_sex"] = target_sex
body_objects_dict = {obj.name: obj for obj in body_objects}
whitelist = set()
combined_objects = []
@@ -474,7 +501,11 @@ def run_batch_combine():
else:
obj["clothing_tags"] = ""
print(f"Finalized metadata for {obj.name}: layer={obj['layer']}, garments={obj['garments']}, tags={obj['clothing_tags']}")
obj["age"] = target_age
obj["sex"] = target_sex
print(f"Finalized metadata for {obj.name}: age={target_age}, "
f"sex={target_sex}, layer={obj['layer']}, "
f"garments={obj['garments']}, tags={obj['clothing_tags']}")
del obj["_combined_meta"]
+91 -3
View File
@@ -7,6 +7,89 @@ _script_dir = os.path.dirname(os.path.abspath(__file__))
if _script_dir not in sys.path:
sys.path.insert(0, _script_dir)
from transfer_shape_keys import fix_seams_across_objects, fix_normals_across_objects
import age_profiles
def _catalog_objects():
required = ("age", "sex", "slot")
return [o for o in bpy.data.objects
if o.type == 'MESH' and all(p in o for p in required)]
def _own_armature_names():
names = set()
for obj in _catalog_objects():
if obj.get("has_own_armature", False):
name = obj.get("own_armature_name", "")
if name and bpy.data.objects.get(name):
names.add(name)
return names
def consolidate_armatures():
"""Leave exactly one body armature per consolidated blend.
The clothing stages append their own copy of the body rig; Blender
renames those copies to .001/.002/... They are unused and previously
leaked into the export as extra skeletons.
"""
cats = _catalog_objects()
if not cats:
print("WARNING: no catalog objects; cannot consolidate armatures")
return
ages = {age_profiles.normalize_age(o.get("age", "adult"))
for o in cats}
sexes = {str(o.get("sex", "male")) for o in cats}
if len(ages) != 1 or len(sexes) != 1:
raise RuntimeError(
f"mixed ages/sexes in one consolidated blend: "
f"ages={sorted(ages)} sexes={sorted(sexes)}")
age = next(iter(ages))
sex = next(iter(sexes))
expected = age_profiles.armature_name(age, sex)
body = bpy.data.objects.get(expected)
if body is None or body.type != 'ARMATURE':
# Choose the armature with the most catalog children / deform bones.
best = None
best_score = -1
for arm in [o for o in bpy.data.objects if o.type == 'ARMATURE']:
children = sum(1 for o in cats if o.parent == arm)
score = children * 10000 + len(arm.data.bones)
if score > best_score:
best_score = score
best = arm
body = best
if body is None:
raise RuntimeError("no body armature found in consolidated blend")
body.name = expected
body.data.name = expected
own = _own_armature_names()
for obj in cats:
if obj.get("has_own_armature", False):
continue
obj.parent = body
arm_mod = next((m for m in obj.modifiers
if m.type == 'ARMATURE'), None)
if arm_mod is None:
arm_mod = obj.modifiers.new(name="Armature", type='ARMATURE')
arm_mod.object = body
removed = []
for arm in [o for o in bpy.data.objects if o.type == 'ARMATURE']:
if arm.name == body.name or arm.name in own:
continue
removed.append(arm.name)
bpy.data.objects.remove(arm, do_unlink=True)
if removed:
print(f"Removed duplicate body armatures: {removed}")
print(f"Consolidated body armature: '{body.name}' "
f"({len(body.data.bones)} bones), "
f"hair armatures kept: {sorted(own)}")
def process_append(source_files, output_path):
required_props = {"age", "sex", "slot"}
@@ -92,7 +175,8 @@ def process_append(source_files, output_path):
continue
# Normal body part: parent to body armature
arm_name = obj.get("sex")
arm_name = age_profiles.armature_name(
obj.get("age", "adult"), obj.get("sex", "male"))
arm_obj = bpy.data.objects.get(arm_name)
if arm_obj and arm_obj.type == 'ARMATURE':
@@ -121,10 +205,14 @@ def process_append(source_files, output_path):
else:
bpy.data.objects.remove(obj, do_unlink=True)
# 4. Recursive Purge of all unlinked data (Materials, Textures, Meshes)
# 4. Hardening: one body armature, no duplicate clothing-stage copies.
print("\nConsolidating body armatures...")
consolidate_armatures()
# 5. Recursive Purge of all unlinked data (Materials, Textures, Meshes)
bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True)
# 5. Fix seams across ALL body parts in the consolidated scene.
# 6. Fix seams across ALL body parts in the consolidated scene.
print("\nFixing seams across all consolidated body parts...")
fix_seams_across_objects()
fix_normals_across_objects()
@@ -0,0 +1,638 @@
{
"age": "baby",
"fails": [],
"info": {
"adultArmature": "female",
"ageArmature": "baby-female",
"animations": {
"aliased": {
"swimming-fast": "swimming"
},
"appended": [
"Armature|mixamo.com|Layer0.020",
"Armature|mixamo.com|Layer0.018",
"Armature|mixamo.com|Layer0.016",
"Armature|mixamo.com|Layer0.014"
],
"missing": [],
"scaledLocationCurves": 7028,
"stem": "baby-female"
},
"outputBase": "edited-normal-baby-female.blend",
"slots": "bottom,face,feet,hair,top",
"sourceBase": "edited-normal-female.blend"
},
"objects": {
"Accessory": {
"degenerateTris": 0,
"edgeRatioMax": 0.7372296166046396,
"edgeRatioMin": 0.3150672796769405,
"maxInfluences": 4,
"note": "",
"polygons": 40,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 69
},
"Body": {
"degenerateTris": 0,
"edgeRatioMax": 2.153788134245814,
"edgeRatioMin": 0.0842179761594005,
"maxInfluences": 4,
"note": "",
"polygons": 6227,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 6578
},
"Body-merged": {
"degenerateTris": 0,
"edgeRatioMax": 2.153788134245814,
"edgeRatioMin": 0.07910523007157484,
"maxInfluences": 4,
"note": "",
"polygons": 8423,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 9178
},
"BodyBottom": {
"degenerateTris": 0,
"edgeRatioMax": 0.5943739947353248,
"edgeRatioMin": 0.24871503022916608,
"maxInfluences": 4,
"note": "",
"polygons": 1242,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1194
},
"BodyFeet": {
"degenerateTris": 0,
"edgeRatioMax": 0.6102128302187958,
"edgeRatioMin": 0.23948013201029564,
"maxInfluences": 3,
"note": "",
"polygons": 1280,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1050
},
"BodyTop": {
"degenerateTris": 0,
"edgeRatioMax": 2.153788134245814,
"edgeRatioMin": 0.0842179761594005,
"maxInfluences": 4,
"note": "",
"polygons": 3701,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 3354
},
"BodyTopRobe": {
"degenerateTris": 0,
"edgeRatioMax": 2.153788134245814,
"edgeRatioMin": 0.07910523007157484,
"maxInfluences": 4,
"note": "",
"polygons": 4727,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 4383
},
"Body_shapes": {
"degenerateTris": 0,
"edgeRatioMax": 2.153788134245814,
"edgeRatioMin": 0.0842179761594005,
"maxInfluences": 4,
"note": "",
"polygons": 6223,
"quality": "warn",
"shapeKeys": 2,
"unweighted": 0,
"vertices": 5550
},
"Cylinder": {
"degenerateTris": 0,
"edgeRatioMax": 102.90996675755198,
"edgeRatioMin": 0.08829058454162445,
"maxInfluences": 0,
"note": "",
"polygons": 595,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 840,
"vertices": 840
},
"Face": {
"degenerateTris": 0,
"edgeRatioMax": 0.8507524157231209,
"edgeRatioMin": 0.8497737875813667,
"maxInfluences": 1,
"note": "",
"polygons": 3887,
"quality": "ok",
"shapeKeys": 57,
"unweighted": 0,
"vertices": 4109
},
"Hair": {
"degenerateTris": 0,
"edgeRatioMax": 0.8500001569254199,
"edgeRatioMin": 0.8499997162406219,
"maxInfluences": 1,
"note": "",
"polygons": 2,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 8
},
"Hair001": {
"degenerateTris": 0,
"edgeRatioMax": 0.8653125183616986,
"edgeRatioMin": 0.8197160422662443,
"maxInfluences": 1,
"note": "",
"polygons": 5658,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 8673
},
"Hair2": {
"degenerateTris": 0,
"edgeRatioMax": 0.8653125183616986,
"edgeRatioMin": 0.8197160422662443,
"maxInfluences": 2,
"note": "",
"polygons": 5658,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 8673
},
"HairBack": {
"degenerateTris": 0,
"edgeRatioMax": 1.4644834678498582,
"edgeRatioMin": 0.6099711906323935,
"maxInfluences": 4,
"note": "",
"polygons": 726,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 760
},
"OnepieceCloth": {
"degenerateTris": 0,
"edgeRatioMax": 0.5492324553724546,
"edgeRatioMin": 0.5290424031153619,
"maxInfluences": 3,
"note": "",
"polygons": 2,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 6
},
"Shoes": {
"degenerateTris": 0,
"edgeRatioMax": 0.5911305932486909,
"edgeRatioMin": 0.2363519411318045,
"maxInfluences": 3,
"note": "",
"polygons": 402,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 608
},
"Tops": {
"degenerateTris": 0,
"edgeRatioMax": 0.7959426635498152,
"edgeRatioMin": 0.07910523007157484,
"maxInfluences": 4,
"note": "",
"polygons": 1026,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1029
},
"bra1": {
"degenerateTris": 0,
"edgeRatioMax": 0.850017759730551,
"edgeRatioMin": 0.8499810981151088,
"maxInfluences": 1,
"note": "",
"polygons": 430,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 419
},
"bra2": {
"degenerateTris": 0,
"edgeRatioMax": 0.8500176808030321,
"edgeRatioMin": 0.8499719662092197,
"maxInfluences": 1,
"note": "",
"polygons": 506,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 524
},
"bra3": {
"degenerateTris": 0,
"edgeRatioMax": 0.8500183886242741,
"edgeRatioMin": 0.8499869218274825,
"maxInfluences": 1,
"note": "",
"polygons": 410,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 450
},
"bra4": {
"degenerateTris": 0,
"edgeRatioMax": 0.8500183886242741,
"edgeRatioMin": 0.8499869218274825,
"maxInfluences": 1,
"note": "",
"polygons": 410,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 450
},
"panties": {
"degenerateTris": 0,
"edgeRatioMax": 389.90044750135564,
"edgeRatioMin": 0.1344951283772418,
"maxInfluences": 0,
"note": "",
"polygons": 674,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 797,
"vertices": 797
},
"panties-full": {
"degenerateTris": 0,
"edgeRatioMax": 9.037905091399436,
"edgeRatioMin": 0.09492642747770344,
"maxInfluences": 0,
"note": "",
"polygons": 337,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 425,
"vertices": 425
},
"panties2": {
"degenerateTris": 0,
"edgeRatioMax": 73.44866646636162,
"edgeRatioMin": 0.039480502681632265,
"maxInfluences": 0,
"note": "",
"polygons": 1266,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 1328,
"vertices": 1328
},
"panties2-full": {
"degenerateTris": 0,
"edgeRatioMax": 69.03029060930521,
"edgeRatioMin": 0.12109997869211511,
"maxInfluences": 0,
"note": "",
"polygons": 507,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 558,
"vertices": 558
},
"panties3": {
"degenerateTris": 0,
"edgeRatioMax": 56.096226750526704,
"edgeRatioMin": 0.07317455801311457,
"maxInfluences": 0,
"note": "",
"polygons": 758,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 820,
"vertices": 820
},
"panties4": {
"degenerateTris": 0,
"edgeRatioMax": 59.134504340321854,
"edgeRatioMin": 0.07317455801311457,
"maxInfluences": 0,
"note": "",
"polygons": 690,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 752,
"vertices": 752
},
"panties5": {
"degenerateTris": 0,
"edgeRatioMax": 0.8500240796533016,
"edgeRatioMin": 0.8499814844536061,
"maxInfluences": 1,
"note": "",
"polygons": 698,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 758
},
"panties6": {
"degenerateTris": 0,
"edgeRatioMax": 0.8500154180994606,
"edgeRatioMin": 0.8499882205989492,
"maxInfluences": 1,
"note": "",
"polygons": 698,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 758
},
"panties7": {
"degenerateTris": 0,
"edgeRatioMax": 0.8500126347595567,
"edgeRatioMin": 0.849978243664894,
"maxInfluences": 1,
"note": "",
"polygons": 698,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 758
},
"shoes1": {
"degenerateTris": 0,
"edgeRatioMax": 1.5538312559806804,
"edgeRatioMin": 0.26002334716847597,
"maxInfluences": 3,
"note": "",
"polygons": 414,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 414
},
"skirt1": {
"degenerateTris": 0,
"edgeRatioMax": 0.5322821714343262,
"edgeRatioMin": 0.27078238670490873,
"maxInfluences": 3,
"note": "",
"polygons": 132,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 154
},
"skirt2": {
"degenerateTris": 0,
"edgeRatioMax": 0.5392819893864378,
"edgeRatioMin": 0.2767377972392573,
"maxInfluences": 3,
"note": "",
"polygons": 154,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 176
},
"skirt3": {
"degenerateTris": 0,
"edgeRatioMax": 0.5319295543088763,
"edgeRatioMin": 0.2734056437018691,
"maxInfluences": 3,
"note": "",
"polygons": 132,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 154
},
"top1": {
"degenerateTris": 0,
"edgeRatioMax": 0.8500186043293043,
"edgeRatioMin": 0.8499912680582199,
"maxInfluences": 1,
"note": "",
"polygons": 860,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 852
},
"top2": {
"degenerateTris": 0,
"edgeRatioMax": 0.8500086443486258,
"edgeRatioMin": 0.8499937873756263,
"maxInfluences": 1,
"note": "",
"polygons": 876,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 854
},
"top3": {
"degenerateTris": 0,
"edgeRatioMax": 0.8500086443486258,
"edgeRatioMin": 0.8499937873756263,
"maxInfluences": 1,
"note": "",
"polygons": 876,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 854
}
},
"profileHash": "dd6901d4220ae704",
"rig": {
"adultBones": 252,
"ageBones": 252,
"profile": {
"arm": 0.3,
"bodyRadial": 0.55,
"foot": 0.55,
"head": 0.85,
"headBulge": 1.08,
"hipHeight": 0.28,
"hipWidth": 0.55,
"leg": 0.26,
"limbRadial": 0.5,
"shoulderWidth": 0.5,
"torso": 0.3,
"walkSpeedScale": 0.0
},
"restOrientationErrors": []
},
"seams": {
"coincidentGroups": 3697,
"examples": [
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
0.0649,
0.0281,
1.3797
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
0.066,
0.0231,
1.3767
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
0.0694,
0.0172,
1.3783
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
-0.0649,
0.0281,
1.3797
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
-0.066,
0.0231,
1.3767
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
-0.0694,
0.0172,
1.3783
]
},
{
"gap": 2e-06,
"objects": [
"Hair2"
],
"position": [
0.0001,
-0.0385,
1.5529
]
},
{
"gap": 2e-06,
"objects": [
"Hair2"
],
"position": [
-0.0001,
-0.0385,
1.5529
]
},
{
"gap": 3e-06,
"objects": [
"Hair2"
],
"position": [
-0.0012,
-0.0342,
1.5629
]
},
{
"gap": 3e-06,
"objects": [
"Hair2"
],
"position": [
-0.001,
-0.0343,
1.5628
]
}
],
"maxGap": 5.9825688860243066e-06,
"snappedVertices": 95
},
"sex": "female",
"status": "warn",
"stem": "baby-female",
"timestamp": 1793419311,
"warnings": [
"Accessory",
"Body",
"Body-merged",
"Body_shapes",
"BodyBottom",
"BodyFeet",
"BodyTop",
"BodyTopRobe",
"Shoes",
"Tops",
"BodyTop",
"BodyBottom",
"Cylinder",
"panties",
"panties-full",
"panties2",
"panties2-full",
"panties3",
"panties4",
"skirt1",
"skirt2",
"skirt3",
"BodyFeet",
"shoes1"
]
}
@@ -0,0 +1,85 @@
# Conversion Report — baby / female
- Stem: `baby-female`
- Status: **warn**
- Profile hash: `dd6901d4220ae704`
- Objects: 37
- Warnings: 24
- Fails: 0
## Info
- adultArmature: `female`
- ageArmature: `baby-female`
- animations: `{'scaledLocationCurves': 7028, 'appended': ['Armature|mixamo.com|Layer0.020', 'Armature|mixamo.com|Layer0.018', 'Armature|mixamo.com|Layer0.016', 'Armature|mixamo.com|Layer0.014'], 'aliased': {'swimming-fast': 'swimming'}, 'missing': [], 'stem': 'baby-female'}`
- outputBase: `edited-normal-baby-female.blend`
- slots: `bottom,face,feet,hair,top`
- sourceBase: `edited-normal-female.blend`
## Seams
- coincidentGroups: `3697`
- snappedVertices: `95`
- maxGap: `5.9825688860243066e-06`
- examples: `[{'position': [0.0649, 0.0281, 1.3797], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.066, 0.0231, 1.3767], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0694, 0.0172, 1.3783], 'gap': 1e-06, 'objects': ['Face']}, {'position': [-0.0649, 0.0281, 1.3797], 'gap': 1e-06, 'objects': ['Face']}, {'position': [-0.066, 0.0231, 1.3767], 'gap': 1e-06, 'objects': ['Face']}, {'position': [-0.0694, 0.0172, 1.3783], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0001, -0.0385, 1.5529], 'gap': 2e-06, 'objects': ['Hair2']}, {'position': [-0.0001, -0.0385, 1.5529], 'gap': 2e-06, 'objects': ['Hair2']}, {'position': [-0.0012, -0.0342, 1.5629], 'gap': 3e-06, 'objects': ['Hair2']}, {'position': [-0.001, -0.0343, 1.5628], 'gap': 3e-06, 'objects': ['Hair2']}]`
## Warnings (first 20)
- `Accessory`
- `Body`
- `Body-merged`
- `Body_shapes`
- `BodyBottom`
- `BodyFeet`
- `BodyTop`
- `BodyTopRobe`
- `Shoes`
- `Tops`
- `BodyTop`
- `BodyBottom`
- `Cylinder`
- `panties`
- `panties-full`
- `panties2`
- `panties2-full`
- `panties3`
- `panties4`
- `skirt1`
## Objects
- `Accessory` — warn; verts=69, tris=0 degenerate, influences=4, unweighted=0, edge=0.32..0.74
- `Body` — warn; verts=6578, tris=0 degenerate, influences=4, unweighted=0, edge=0.08..2.15
- `Body-merged` — warn; verts=9178, tris=0 degenerate, influences=4, unweighted=0, edge=0.08..2.15
- `BodyBottom` — warn; verts=1194, tris=0 degenerate, influences=4, unweighted=0, edge=0.25..0.59
- `BodyFeet` — warn; verts=1050, tris=0 degenerate, influences=3, unweighted=0, edge=0.24..0.61
- `BodyTop` — warn; verts=3354, tris=0 degenerate, influences=4, unweighted=0, edge=0.08..2.15
- `BodyTopRobe` — warn; verts=4383, tris=0 degenerate, influences=4, unweighted=0, edge=0.08..2.15
- `Body_shapes` — warn; verts=5550, tris=0 degenerate, influences=4, unweighted=0, edge=0.08..2.15
- `Cylinder` — warn; verts=840, tris=0 degenerate, influences=0, unweighted=840, edge=0.09..102.91
- `Face` — ok; verts=4109, tris=0 degenerate, influences=1, unweighted=0, edge=0.85..0.85
- `Hair` — ok; verts=8, tris=0 degenerate, influences=1, unweighted=0, edge=0.85..0.85
- `Hair001` — ok; verts=8673, tris=0 degenerate, influences=1, unweighted=0, edge=0.82..0.87
- `Hair2` — ok; verts=8673, tris=0 degenerate, influences=2, unweighted=0, edge=0.82..0.87
- `HairBack` — ok; verts=760, tris=0 degenerate, influences=4, unweighted=0, edge=0.61..1.46
- `OnepieceCloth` — ok; verts=6, tris=0 degenerate, influences=3, unweighted=0, edge=0.53..0.55
- `Shoes` — warn; verts=608, tris=0 degenerate, influences=3, unweighted=0, edge=0.24..0.59
- `Tops` — warn; verts=1029, tris=0 degenerate, influences=4, unweighted=0, edge=0.08..0.80
- `bra1` — ok; verts=419, tris=0 degenerate, influences=1, unweighted=0, edge=0.85..0.85
- `bra2` — ok; verts=524, tris=0 degenerate, influences=1, unweighted=0, edge=0.85..0.85
- `bra3` — ok; verts=450, tris=0 degenerate, influences=1, unweighted=0, edge=0.85..0.85
- `bra4` — ok; verts=450, tris=0 degenerate, influences=1, unweighted=0, edge=0.85..0.85
- `panties` — warn; verts=797, tris=0 degenerate, influences=0, unweighted=797, edge=0.13..389.90
- `panties-full` — warn; verts=425, tris=0 degenerate, influences=0, unweighted=425, edge=0.09..9.04
- `panties2` — warn; verts=1328, tris=0 degenerate, influences=0, unweighted=1328, edge=0.04..73.45
- `panties2-full` — warn; verts=558, tris=0 degenerate, influences=0, unweighted=558, edge=0.12..69.03
- `panties3` — warn; verts=820, tris=0 degenerate, influences=0, unweighted=820, edge=0.07..56.10
- `panties4` — warn; verts=752, tris=0 degenerate, influences=0, unweighted=752, edge=0.07..59.13
- `panties5` — ok; verts=758, tris=0 degenerate, influences=1, unweighted=0, edge=0.85..0.85
- `panties6` — ok; verts=758, tris=0 degenerate, influences=1, unweighted=0, edge=0.85..0.85
- `panties7` — ok; verts=758, tris=0 degenerate, influences=1, unweighted=0, edge=0.85..0.85
- `shoes1` — warn; verts=414, tris=0 degenerate, influences=3, unweighted=0, edge=0.26..1.55
- `skirt1` — warn; verts=154, tris=0 degenerate, influences=3, unweighted=0, edge=0.27..0.53
- `skirt2` — warn; verts=176, tris=0 degenerate, influences=3, unweighted=0, edge=0.28..0.54
- `skirt3` — warn; verts=154, tris=0 degenerate, influences=3, unweighted=0, edge=0.27..0.53
- `top1` — ok; verts=852, tris=0 degenerate, influences=1, unweighted=0, edge=0.85..0.85
- `top2` — ok; verts=854, tris=0 degenerate, influences=1, unweighted=0, edge=0.85..0.85
- `top3` — ok; verts=854, tris=0 degenerate, influences=1, unweighted=0, edge=0.85..0.85
@@ -0,0 +1,491 @@
{
"age": "baby",
"fails": [],
"info": {
"adultArmature": "male",
"ageArmature": "baby-male",
"animations": {
"aliased": {
"swimming-fast": "swimming"
},
"appended": [
"Armature|mixamo.com|Layer0.020",
"Armature|mixamo.com|Layer0.018",
"Armature|mixamo.com|Layer0.016",
"Armature|mixamo.com|Layer0.014",
"Armature|mixamo.com|Layer0.012"
],
"missing": [],
"scaledLocationCurves": 7601,
"stem": "baby-male"
},
"outputBase": "edited-normal-baby-male.blend",
"slots": "bottom,face,feet,hair,top",
"sourceBase": "edited-normal-male.blend"
},
"objects": {
"Accessory": {
"degenerateTris": 0,
"edgeRatioMax": 0.6591322275489071,
"edgeRatioMin": 0.2294774279830071,
"maxInfluences": 4,
"note": "",
"polygons": 111,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 145
},
"Body": {
"degenerateTris": 0,
"edgeRatioMax": 2.7944548668408027,
"edgeRatioMin": 0.09792195397903704,
"maxInfluences": 4,
"note": "",
"polygons": 6232,
"quality": "warn",
"shapeKeys": 1,
"unweighted": 0,
"vertices": 6584
},
"BodyBottom": {
"degenerateTris": 0,
"edgeRatioMax": 0.696915448832056,
"edgeRatioMin": 0.2475168552675534,
"maxInfluences": 4,
"note": "",
"polygons": 1228,
"quality": "warn",
"shapeKeys": 1,
"unweighted": 0,
"vertices": 1181
},
"BodyBottomPants": {
"degenerateTris": 0,
"edgeRatioMax": 0.8123872063883257,
"edgeRatioMin": 0.2475168552675534,
"maxInfluences": 4,
"note": "",
"polygons": 972,
"quality": "warn",
"shapeKeys": 1,
"unweighted": 0,
"vertices": 1133
},
"BodyFeet": {
"degenerateTris": 0,
"edgeRatioMax": 0.6102393851343675,
"edgeRatioMin": 0.22574066520587047,
"maxInfluences": 3,
"note": "",
"polygons": 1274,
"quality": "warn",
"shapeKeys": 1,
"unweighted": 0,
"vertices": 1392
},
"BodyFeetPants": {
"degenerateTris": 48,
"edgeRatioMax": 0.6102393851343675,
"edgeRatioMin": 0.2095881183386803,
"maxInfluences": 3,
"note": "",
"polygons": 1466,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1602
},
"BodyFeetPantsShoes": {
"degenerateTris": 48,
"edgeRatioMax": 0.6017141327128214,
"edgeRatioMin": 0.2095881183386803,
"maxInfluences": 3,
"note": "",
"polygons": 830,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1048
},
"BodyTop": {
"degenerateTris": 0,
"edgeRatioMax": 2.7944548668408027,
"edgeRatioMin": 0.09792195397903704,
"maxInfluences": 4,
"note": "",
"polygons": 3726,
"quality": "warn",
"shapeKeys": 1,
"unweighted": 0,
"vertices": 3373
},
"BodyTopRobe": {
"degenerateTris": 0,
"edgeRatioMax": 2.694739629684512,
"edgeRatioMin": 0.04884643632625867,
"maxInfluences": 4,
"note": "",
"polygons": 5384,
"quality": "warn",
"shapeKeys": 1,
"unweighted": 0,
"vertices": 5046
},
"Body_shapes": {
"degenerateTris": 0,
"edgeRatioMax": 2.7944548668408027,
"edgeRatioMin": 0.09792195397903704,
"maxInfluences": 4,
"note": "",
"polygons": 6228,
"quality": "warn",
"shapeKeys": 2,
"unweighted": 0,
"vertices": 5556
},
"Bottoms": {
"degenerateTris": 0,
"edgeRatioMax": 0.8123872063883257,
"edgeRatioMin": 0.2095881183386803,
"maxInfluences": 4,
"note": "",
"polygons": 826,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 942
},
"Face": {
"degenerateTris": 0,
"edgeRatioMax": 0.8501110244008585,
"edgeRatioMin": 0.8499080602231018,
"maxInfluences": 1,
"note": "",
"polygons": 3839,
"quality": "ok",
"shapeKeys": 57,
"unweighted": 0,
"vertices": 4045
},
"Hair": {
"degenerateTris": 0,
"edgeRatioMax": 0.8500001087034722,
"edgeRatioMin": 0.8499998996166309,
"maxInfluences": 1,
"note": "",
"polygons": 4,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 9
},
"Hair2": {
"degenerateTris": 0,
"edgeRatioMax": 0.8932439134046963,
"edgeRatioMin": 0.8193953530826958,
"maxInfluences": 2,
"note": "",
"polygons": 4483,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 7022
},
"HairBack": {
"degenerateTris": 0,
"edgeRatioMax": 1.4800221295791642,
"edgeRatioMin": 0.6036557832095256,
"maxInfluences": 4,
"note": "",
"polygons": 724,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 760
},
"Panties001": {
"degenerateTris": 0,
"edgeRatioMax": 0.6962243553417347,
"edgeRatioMin": 0.2658767663648385,
"maxInfluences": 4,
"note": "",
"polygons": 604,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 582
},
"Pants0001": {
"degenerateTris": 0,
"edgeRatioMax": 1.651774029445214,
"edgeRatioMin": 0.2253732629144712,
"maxInfluences": 4,
"note": "",
"polygons": 898,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 915
},
"Shoes": {
"degenerateTris": 0,
"edgeRatioMax": 0.6017141327128214,
"edgeRatioMin": 0.39686482033435044,
"maxInfluences": 3,
"note": "",
"polygons": 334,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 478
},
"Tops": {
"degenerateTris": 0,
"edgeRatioMax": 1.6309442432828267,
"edgeRatioMin": 0.04884643632625867,
"maxInfluences": 4,
"note": "",
"polygons": 1658,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1896
},
"TopsEdited": {
"degenerateTris": 0,
"edgeRatioMax": 1.6309442432828267,
"edgeRatioMin": 0.04884643632625867,
"maxInfluences": 4,
"note": "",
"polygons": 1658,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1896
},
"coat": {
"degenerateTris": 0,
"edgeRatioMax": 1.5376462038278786,
"edgeRatioMin": 0.14071060392477913,
"maxInfluences": 4,
"note": "",
"polygons": 1658,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1896
},
"hair0001": {
"degenerateTris": 0,
"edgeRatioMax": 2.322546280419375,
"edgeRatioMin": 0.6481636259388702,
"maxInfluences": 4,
"note": "",
"polygons": 4483,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 7022
},
"hair0002": {
"degenerateTris": 0,
"edgeRatioMax": 2.322546280419375,
"edgeRatioMin": 0.6481636259388702,
"maxInfluences": 4,
"note": "",
"polygons": 4483,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 7022
},
"hair0003": {
"degenerateTris": 0,
"edgeRatioMax": 1.0,
"edgeRatioMin": 1.0,
"maxInfluences": 0,
"note": "",
"polygons": 4483,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 7022
},
"shoes1": {
"degenerateTris": 0,
"edgeRatioMax": 0.5819192631554228,
"edgeRatioMin": 0.36626946181073133,
"maxInfluences": 3,
"note": "",
"polygons": 334,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 478
}
},
"profileHash": "dd6901d4220ae704",
"rig": {
"adultBones": 242,
"ageBones": 242,
"profile": {
"arm": 0.3,
"bodyRadial": 0.55,
"foot": 0.55,
"head": 0.85,
"headBulge": 1.08,
"hipHeight": 0.28,
"hipWidth": 0.55,
"leg": 0.26,
"limbRadial": 0.5,
"shoulderWidth": 0.5,
"torso": 0.3,
"walkSpeedScale": 0.0
},
"restOrientationErrors": []
},
"seams": {
"coincidentGroups": 5119,
"examples": [
{
"gap": 1e-06,
"objects": [
"BodyTop",
"BodyTopRobe"
],
"position": [
0.0,
-0.0184,
1.5008
]
},
{
"gap": 1e-06,
"objects": [
"BodyTop",
"Face"
],
"position": [
0.0633,
0.0369,
1.6496
]
},
{
"gap": 1e-06,
"objects": [
"BodyTop",
"Face"
],
"position": [
-0.0633,
0.0369,
1.6496
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
0.0636,
0.0319,
1.6457
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
0.0698,
0.021,
1.649
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
0.0716,
0.0206,
1.6529
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
-0.0636,
0.0319,
1.6457
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
-0.0698,
0.021,
1.649
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
-0.0716,
0.0206,
1.6529
]
},
{
"gap": 2e-06,
"objects": [
"Hair2"
],
"position": [
0.0002,
-0.0352,
1.8214
]
}
],
"maxGap": 6.834857277971055e-06,
"snappedVertices": 204
},
"sex": "male",
"status": "warn",
"stem": "baby-male",
"timestamp": 1793419306,
"warnings": [
"Accessory",
"Body",
"Body_shapes",
"BodyBottom",
"BodyBottomPants",
"BodyFeet",
"BodyFeetPants",
"BodyFeetPantsShoes",
"BodyTop",
"BodyTopRobe",
"Bottoms",
"Tops",
"TopsEdited",
"coat",
"Panties001",
"Pants0001",
"BodyFeet"
]
}
@@ -0,0 +1,70 @@
# Conversion Report — baby / male
- Stem: `baby-male`
- Status: **warn**
- Profile hash: `dd6901d4220ae704`
- Objects: 25
- Warnings: 17
- Fails: 0
## Info
- adultArmature: `male`
- ageArmature: `baby-male`
- animations: `{'scaledLocationCurves': 7601, 'appended': ['Armature|mixamo.com|Layer0.020', 'Armature|mixamo.com|Layer0.018', 'Armature|mixamo.com|Layer0.016', 'Armature|mixamo.com|Layer0.014', 'Armature|mixamo.com|Layer0.012'], 'aliased': {'swimming-fast': 'swimming'}, 'missing': [], 'stem': 'baby-male'}`
- outputBase: `edited-normal-baby-male.blend`
- slots: `bottom,face,feet,hair,top`
- sourceBase: `edited-normal-male.blend`
## Seams
- coincidentGroups: `5119`
- snappedVertices: `204`
- maxGap: `6.834857277971055e-06`
- examples: `[{'position': [0.0, -0.0184, 1.5008], 'gap': 1e-06, 'objects': ['BodyTop', 'BodyTopRobe']}, {'position': [0.0633, 0.0369, 1.6496], 'gap': 1e-06, 'objects': ['BodyTop', 'Face']}, {'position': [-0.0633, 0.0369, 1.6496], 'gap': 1e-06, 'objects': ['BodyTop', 'Face']}, {'position': [0.0636, 0.0319, 1.6457], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0698, 0.021, 1.649], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0716, 0.0206, 1.6529], 'gap': 1e-06, 'objects': ['Face']}, {'position': [-0.0636, 0.0319, 1.6457], 'gap': 1e-06, 'objects': ['Face']}, {'position': [-0.0698, 0.021, 1.649], 'gap': 1e-06, 'objects': ['Face']}, {'position': [-0.0716, 0.0206, 1.6529], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0002, -0.0352, 1.8214], 'gap': 2e-06, 'objects': ['Hair2']}]`
## Warnings (first 20)
- `Accessory`
- `Body`
- `Body_shapes`
- `BodyBottom`
- `BodyBottomPants`
- `BodyFeet`
- `BodyFeetPants`
- `BodyFeetPantsShoes`
- `BodyTop`
- `BodyTopRobe`
- `Bottoms`
- `Tops`
- `TopsEdited`
- `coat`
- `Panties001`
- `Pants0001`
- `BodyFeet`
## Objects
- `Accessory` — warn; verts=145, tris=0 degenerate, influences=4, unweighted=0, edge=0.23..0.66
- `Body` — warn; verts=6584, tris=0 degenerate, influences=4, unweighted=0, edge=0.10..2.79
- `BodyBottom` — warn; verts=1181, tris=0 degenerate, influences=4, unweighted=0, edge=0.25..0.70
- `BodyBottomPants` — warn; verts=1133, tris=0 degenerate, influences=4, unweighted=0, edge=0.25..0.81
- `BodyFeet` — warn; verts=1392, tris=0 degenerate, influences=3, unweighted=0, edge=0.23..0.61
- `BodyFeetPants` — warn; verts=1602, tris=48 degenerate, influences=3, unweighted=0, edge=0.21..0.61
- `BodyFeetPantsShoes` — warn; verts=1048, tris=48 degenerate, influences=3, unweighted=0, edge=0.21..0.60
- `BodyTop` — warn; verts=3373, tris=0 degenerate, influences=4, unweighted=0, edge=0.10..2.79
- `BodyTopRobe` — warn; verts=5046, tris=0 degenerate, influences=4, unweighted=0, edge=0.05..2.69
- `Body_shapes` — warn; verts=5556, tris=0 degenerate, influences=4, unweighted=0, edge=0.10..2.79
- `Bottoms` — warn; verts=942, tris=0 degenerate, influences=4, unweighted=0, edge=0.21..0.81
- `Face` — ok; verts=4045, tris=0 degenerate, influences=1, unweighted=0, edge=0.85..0.85
- `Hair` — ok; verts=9, tris=0 degenerate, influences=1, unweighted=0, edge=0.85..0.85
- `Hair2` — ok; verts=7022, tris=0 degenerate, influences=2, unweighted=0, edge=0.82..0.89
- `HairBack` — ok; verts=760, tris=0 degenerate, influences=4, unweighted=0, edge=0.60..1.48
- `Panties001` — warn; verts=582, tris=0 degenerate, influences=4, unweighted=0, edge=0.27..0.70
- `Pants0001` — warn; verts=915, tris=0 degenerate, influences=4, unweighted=0, edge=0.23..1.65
- `Shoes` — ok; verts=478, tris=0 degenerate, influences=3, unweighted=0, edge=0.40..0.60
- `Tops` — warn; verts=1896, tris=0 degenerate, influences=4, unweighted=0, edge=0.05..1.63
- `TopsEdited` — warn; verts=1896, tris=0 degenerate, influences=4, unweighted=0, edge=0.05..1.63
- `coat` — warn; verts=1896, tris=0 degenerate, influences=4, unweighted=0, edge=0.14..1.54
- `hair0001` — ok; verts=7022, tris=0 degenerate, influences=4, unweighted=0, edge=0.65..2.32
- `hair0002` — ok; verts=7022, tris=0 degenerate, influences=4, unweighted=0, edge=0.65..2.32
- `hair0003` — ok; verts=7022, tris=0 degenerate, influences=0, unweighted=0, edge=1.00..1.00
- `shoes1` — ok; verts=478, tris=0 degenerate, influences=3, unweighted=0, edge=0.37..0.58
@@ -0,0 +1,627 @@
{
"age": "child",
"fails": [],
"info": {
"adultArmature": "female",
"ageArmature": "child-female",
"animations": {
"aliased": {
"swimming-fast": "swimming"
},
"appended": [
"Armature|mixamo.com|Layer0.020",
"Armature|mixamo.com|Layer0.018",
"Armature|mixamo.com|Layer0.016",
"Armature|mixamo.com|Layer0.014"
],
"missing": [],
"scaledLocationCurves": 7028,
"stem": "child-female"
},
"outputBase": "edited-normal-child-female.blend",
"slots": "bottom,face,feet,hair,top",
"sourceBase": "edited-normal-female.blend"
},
"objects": {
"Accessory": {
"degenerateTris": 0,
"edgeRatioMax": 0.7898433968486418,
"edgeRatioMin": 0.49563877021449726,
"maxInfluences": 4,
"note": "",
"polygons": 40,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 69
},
"Body": {
"degenerateTris": 0,
"edgeRatioMax": 1.5936254360808924,
"edgeRatioMin": 0.35974842621764863,
"maxInfluences": 4,
"note": "",
"polygons": 6227,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 6578
},
"Body-merged": {
"degenerateTris": 0,
"edgeRatioMax": 1.5936254360808924,
"edgeRatioMin": 0.35974842621764863,
"maxInfluences": 4,
"note": "",
"polygons": 8423,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 9178
},
"BodyBottom": {
"degenerateTris": 0,
"edgeRatioMax": 0.7850192237493911,
"edgeRatioMin": 0.5419409362099876,
"maxInfluences": 4,
"note": "",
"polygons": 1242,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1194
},
"BodyFeet": {
"degenerateTris": 0,
"edgeRatioMax": 0.7839441718995033,
"edgeRatioMin": 0.5345308894982719,
"maxInfluences": 3,
"note": "",
"polygons": 1280,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1050
},
"BodyTop": {
"degenerateTris": 0,
"edgeRatioMax": 1.5936254360808924,
"edgeRatioMin": 0.35974842621764863,
"maxInfluences": 4,
"note": "",
"polygons": 3701,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 3354
},
"BodyTopRobe": {
"degenerateTris": 0,
"edgeRatioMax": 1.5936254360808924,
"edgeRatioMin": 0.35974842621764863,
"maxInfluences": 4,
"note": "",
"polygons": 4727,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 4383
},
"Body_shapes": {
"degenerateTris": 0,
"edgeRatioMax": 1.5936254360808924,
"edgeRatioMin": 0.35974842621764863,
"maxInfluences": 4,
"note": "",
"polygons": 6223,
"quality": "ok",
"shapeKeys": 2,
"unweighted": 0,
"vertices": 5550
},
"Cylinder": {
"degenerateTris": 0,
"edgeRatioMax": 55.52669888099699,
"edgeRatioMin": 0.3728675302592677,
"maxInfluences": 0,
"note": "",
"polygons": 595,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 840,
"vertices": 840
},
"Face": {
"degenerateTris": 0,
"edgeRatioMax": 0.9209191562868269,
"edgeRatioMin": 0.9194440137740598,
"maxInfluences": 1,
"note": "",
"polygons": 3887,
"quality": "ok",
"shapeKeys": 57,
"unweighted": 0,
"vertices": 4109
},
"Hair": {
"degenerateTris": 0,
"edgeRatioMax": 0.9200007036314336,
"edgeRatioMin": 0.9199996884063618,
"maxInfluences": 1,
"note": "",
"polygons": 2,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 8
},
"Hair001": {
"degenerateTris": 0,
"edgeRatioMax": 0.9580249853514248,
"edgeRatioMin": 0.8921870820834138,
"maxInfluences": 1,
"note": "",
"polygons": 5658,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 8673
},
"Hair2": {
"degenerateTris": 0,
"edgeRatioMax": 0.9580249853514248,
"edgeRatioMin": 0.8921870820834138,
"maxInfluences": 2,
"note": "",
"polygons": 5658,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 8673
},
"HairBack": {
"degenerateTris": 0,
"edgeRatioMax": 1.243301152005224,
"edgeRatioMin": 0.7996069035600766,
"maxInfluences": 4,
"note": "",
"polygons": 726,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 760
},
"OnepieceCloth": {
"degenerateTris": 0,
"edgeRatioMax": 0.7795104096985646,
"edgeRatioMin": 0.7657916805350437,
"maxInfluences": 3,
"note": "",
"polygons": 2,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 6
},
"Shoes": {
"degenerateTris": 0,
"edgeRatioMax": 0.7710505397361299,
"edgeRatioMin": 0.5274789367897975,
"maxInfluences": 3,
"note": "",
"polygons": 402,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 608
},
"Tops": {
"degenerateTris": 0,
"edgeRatioMax": 0.8665602233865596,
"edgeRatioMin": 0.44064144963083524,
"maxInfluences": 4,
"note": "",
"polygons": 1026,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1029
},
"bra1": {
"degenerateTris": 0,
"edgeRatioMax": 0.920044969946938,
"edgeRatioMin": 0.9199654645832204,
"maxInfluences": 1,
"note": "",
"polygons": 430,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 419
},
"bra2": {
"degenerateTris": 0,
"edgeRatioMax": 0.9200462644396492,
"edgeRatioMin": 0.9199754607547745,
"maxInfluences": 1,
"note": "",
"polygons": 506,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 524
},
"bra3": {
"degenerateTris": 0,
"edgeRatioMax": 0.9200246096786325,
"edgeRatioMin": 0.9199754607547745,
"maxInfluences": 1,
"note": "",
"polygons": 410,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 450
},
"bra4": {
"degenerateTris": 0,
"edgeRatioMax": 0.9200246096786325,
"edgeRatioMin": 0.9199754607547745,
"maxInfluences": 1,
"note": "",
"polygons": 410,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 450
},
"panties": {
"degenerateTris": 0,
"edgeRatioMax": 211.10052027794262,
"edgeRatioMin": 0.19951643125297738,
"maxInfluences": 0,
"note": "",
"polygons": 674,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 797,
"vertices": 797
},
"panties-full": {
"degenerateTris": 0,
"edgeRatioMax": 7.322137784611612,
"edgeRatioMin": 0.21340035981963004,
"maxInfluences": 0,
"note": "",
"polygons": 337,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 425,
"vertices": 425
},
"panties2": {
"degenerateTris": 0,
"edgeRatioMax": 39.470945630173105,
"edgeRatioMin": 0.21985285672118582,
"maxInfluences": 0,
"note": "",
"polygons": 1266,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 1328,
"vertices": 1328
},
"panties2-full": {
"degenerateTris": 0,
"edgeRatioMax": 36.99110437234406,
"edgeRatioMin": 0.10284348688443892,
"maxInfluences": 0,
"note": "",
"polygons": 507,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 558,
"vertices": 558
},
"panties3": {
"degenerateTris": 0,
"edgeRatioMax": 30.204732645010235,
"edgeRatioMin": 0.20627087124391974,
"maxInfluences": 0,
"note": "",
"polygons": 758,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 820,
"vertices": 820
},
"panties4": {
"degenerateTris": 0,
"edgeRatioMax": 31.683176789997283,
"edgeRatioMin": 0.1989474077553321,
"maxInfluences": 0,
"note": "",
"polygons": 690,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 752,
"vertices": 752
},
"panties5": {
"degenerateTris": 0,
"edgeRatioMax": 5.895118610080954,
"edgeRatioMin": 0.6048716072518844,
"maxInfluences": 3,
"note": "",
"polygons": 698,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 758
},
"panties6": {
"degenerateTris": 0,
"edgeRatioMax": 7.880647390006613,
"edgeRatioMin": 0.6250818099712813,
"maxInfluences": 3,
"note": "",
"polygons": 698,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 758
},
"panties7": {
"degenerateTris": 0,
"edgeRatioMax": 7.880647390006613,
"edgeRatioMin": 0.6400929469228386,
"maxInfluences": 3,
"note": "",
"polygons": 698,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 758
},
"shoes1": {
"degenerateTris": 0,
"edgeRatioMax": 0.9595848242017094,
"edgeRatioMin": 0.5492882605967488,
"maxInfluences": 3,
"note": "",
"polygons": 414,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 414
},
"skirt1": {
"degenerateTris": 0,
"edgeRatioMax": 1.3290984484841972,
"edgeRatioMin": 0.3753802859501542,
"maxInfluences": 4,
"note": "",
"polygons": 132,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 154
},
"skirt2": {
"degenerateTris": 0,
"edgeRatioMax": 1.4244777332130143,
"edgeRatioMin": 0.3753802859501542,
"maxInfluences": 4,
"note": "",
"polygons": 154,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 176
},
"skirt3": {
"degenerateTris": 0,
"edgeRatioMax": 1.268563086876801,
"edgeRatioMin": 0.37917544169158424,
"maxInfluences": 4,
"note": "",
"polygons": 132,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 154
},
"top1": {
"degenerateTris": 0,
"edgeRatioMax": 7.0781828943354546,
"edgeRatioMin": 0.7089442731001407,
"maxInfluences": 1,
"note": "",
"polygons": 860,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 852
},
"top2": {
"degenerateTris": 0,
"edgeRatioMax": 7.0781828943354546,
"edgeRatioMin": 0.7089442731001407,
"maxInfluences": 1,
"note": "",
"polygons": 876,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 854
},
"top3": {
"degenerateTris": 0,
"edgeRatioMax": 7.0781828943354546,
"edgeRatioMin": 0.7089442731001407,
"maxInfluences": 1,
"note": "",
"polygons": 876,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 854
}
},
"profileHash": "dd6901d4220ae704",
"rig": {
"adultBones": 252,
"ageBones": 252,
"profile": {
"arm": 0.6,
"bodyRadial": 0.78,
"foot": 0.75,
"head": 0.92,
"headBulge": 1.03,
"hipHeight": 0.55,
"hipWidth": 0.72,
"leg": 0.55,
"limbRadial": 0.72,
"shoulderWidth": 0.7,
"torso": 0.62,
"walkSpeedScale": 0.75
},
"restOrientationErrors": []
},
"seams": {
"coincidentGroups": 3697,
"examples": [
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
0.0649,
0.0281,
1.3797
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
0.066,
0.0231,
1.3767
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
0.0694,
0.0172,
1.3783
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
-0.0649,
0.0281,
1.3797
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
-0.066,
0.0231,
1.3767
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
-0.0694,
0.0172,
1.3783
]
},
{
"gap": 2e-06,
"objects": [
"Hair2"
],
"position": [
0.0001,
-0.0385,
1.5529
]
},
{
"gap": 2e-06,
"objects": [
"Hair2"
],
"position": [
-0.0001,
-0.0385,
1.5529
]
},
{
"gap": 3e-06,
"objects": [
"Hair2"
],
"position": [
-0.0012,
-0.0342,
1.5629
]
},
{
"gap": 3e-06,
"objects": [
"Hair2"
],
"position": [
-0.001,
-0.0343,
1.5628
]
}
],
"maxGap": 6.470887154236068e-06,
"snappedVertices": 95
},
"sex": "female",
"status": "warn",
"stem": "child-female",
"timestamp": 1793419297,
"warnings": [
"top1",
"top2",
"top3",
"Cylinder",
"panties",
"panties-full",
"panties2",
"panties2-full",
"panties3",
"panties4",
"panties5",
"panties6",
"panties7"
]
}
@@ -0,0 +1,78 @@
# Conversion Report — child / female
- Stem: `child-female`
- Status: **warn**
- Profile hash: `dd6901d4220ae704`
- Objects: 37
- Warnings: 13
- Fails: 0
## Info
- adultArmature: `female`
- ageArmature: `child-female`
- animations: `{'scaledLocationCurves': 7028, 'appended': ['Armature|mixamo.com|Layer0.020', 'Armature|mixamo.com|Layer0.018', 'Armature|mixamo.com|Layer0.016', 'Armature|mixamo.com|Layer0.014'], 'aliased': {'swimming-fast': 'swimming'}, 'missing': [], 'stem': 'child-female'}`
- outputBase: `edited-normal-child-female.blend`
- slots: `bottom,face,feet,hair,top`
- sourceBase: `edited-normal-female.blend`
## Seams
- coincidentGroups: `3697`
- snappedVertices: `95`
- maxGap: `6.470887154236068e-06`
- examples: `[{'position': [0.0649, 0.0281, 1.3797], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.066, 0.0231, 1.3767], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0694, 0.0172, 1.3783], 'gap': 1e-06, 'objects': ['Face']}, {'position': [-0.0649, 0.0281, 1.3797], 'gap': 1e-06, 'objects': ['Face']}, {'position': [-0.066, 0.0231, 1.3767], 'gap': 1e-06, 'objects': ['Face']}, {'position': [-0.0694, 0.0172, 1.3783], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0001, -0.0385, 1.5529], 'gap': 2e-06, 'objects': ['Hair2']}, {'position': [-0.0001, -0.0385, 1.5529], 'gap': 2e-06, 'objects': ['Hair2']}, {'position': [-0.0012, -0.0342, 1.5629], 'gap': 3e-06, 'objects': ['Hair2']}, {'position': [-0.001, -0.0343, 1.5628], 'gap': 3e-06, 'objects': ['Hair2']}]`
## Warnings (first 20)
- `top1`
- `top2`
- `top3`
- `Cylinder`
- `panties`
- `panties-full`
- `panties2`
- `panties2-full`
- `panties3`
- `panties4`
- `panties5`
- `panties6`
- `panties7`
## Objects
- `Accessory` — ok; verts=69, tris=0 degenerate, influences=4, unweighted=0, edge=0.50..0.79
- `Body` — ok; verts=6578, tris=0 degenerate, influences=4, unweighted=0, edge=0.36..1.59
- `Body-merged` — ok; verts=9178, tris=0 degenerate, influences=4, unweighted=0, edge=0.36..1.59
- `BodyBottom` — ok; verts=1194, tris=0 degenerate, influences=4, unweighted=0, edge=0.54..0.79
- `BodyFeet` — ok; verts=1050, tris=0 degenerate, influences=3, unweighted=0, edge=0.53..0.78
- `BodyTop` — ok; verts=3354, tris=0 degenerate, influences=4, unweighted=0, edge=0.36..1.59
- `BodyTopRobe` — ok; verts=4383, tris=0 degenerate, influences=4, unweighted=0, edge=0.36..1.59
- `Body_shapes` — ok; verts=5550, tris=0 degenerate, influences=4, unweighted=0, edge=0.36..1.59
- `Cylinder` — warn; verts=840, tris=0 degenerate, influences=0, unweighted=840, edge=0.37..55.53
- `Face` — ok; verts=4109, tris=0 degenerate, influences=1, unweighted=0, edge=0.92..0.92
- `Hair` — ok; verts=8, tris=0 degenerate, influences=1, unweighted=0, edge=0.92..0.92
- `Hair001` — ok; verts=8673, tris=0 degenerate, influences=1, unweighted=0, edge=0.89..0.96
- `Hair2` — ok; verts=8673, tris=0 degenerate, influences=2, unweighted=0, edge=0.89..0.96
- `HairBack` — ok; verts=760, tris=0 degenerate, influences=4, unweighted=0, edge=0.80..1.24
- `OnepieceCloth` — ok; verts=6, tris=0 degenerate, influences=3, unweighted=0, edge=0.77..0.78
- `Shoes` — ok; verts=608, tris=0 degenerate, influences=3, unweighted=0, edge=0.53..0.77
- `Tops` — ok; verts=1029, tris=0 degenerate, influences=4, unweighted=0, edge=0.44..0.87
- `bra1` — ok; verts=419, tris=0 degenerate, influences=1, unweighted=0, edge=0.92..0.92
- `bra2` — ok; verts=524, tris=0 degenerate, influences=1, unweighted=0, edge=0.92..0.92
- `bra3` — ok; verts=450, tris=0 degenerate, influences=1, unweighted=0, edge=0.92..0.92
- `bra4` — ok; verts=450, tris=0 degenerate, influences=1, unweighted=0, edge=0.92..0.92
- `panties` — warn; verts=797, tris=0 degenerate, influences=0, unweighted=797, edge=0.20..211.10
- `panties-full` — warn; verts=425, tris=0 degenerate, influences=0, unweighted=425, edge=0.21..7.32
- `panties2` — warn; verts=1328, tris=0 degenerate, influences=0, unweighted=1328, edge=0.22..39.47
- `panties2-full` — warn; verts=558, tris=0 degenerate, influences=0, unweighted=558, edge=0.10..36.99
- `panties3` — warn; verts=820, tris=0 degenerate, influences=0, unweighted=820, edge=0.21..30.20
- `panties4` — warn; verts=752, tris=0 degenerate, influences=0, unweighted=752, edge=0.20..31.68
- `panties5` — warn; verts=758, tris=0 degenerate, influences=3, unweighted=0, edge=0.60..5.90
- `panties6` — warn; verts=758, tris=0 degenerate, influences=3, unweighted=0, edge=0.63..7.88
- `panties7` — warn; verts=758, tris=0 degenerate, influences=3, unweighted=0, edge=0.64..7.88
- `shoes1` — ok; verts=414, tris=0 degenerate, influences=3, unweighted=0, edge=0.55..0.96
- `skirt1` — ok; verts=154, tris=0 degenerate, influences=4, unweighted=0, edge=0.38..1.33
- `skirt2` — ok; verts=176, tris=0 degenerate, influences=4, unweighted=0, edge=0.38..1.42
- `skirt3` — ok; verts=154, tris=0 degenerate, influences=4, unweighted=0, edge=0.38..1.27
- `top1` — warn; verts=852, tris=0 degenerate, influences=1, unweighted=0, edge=0.71..7.08
- `top2` — warn; verts=854, tris=0 degenerate, influences=1, unweighted=0, edge=0.71..7.08
- `top3` — warn; verts=854, tris=0 degenerate, influences=1, unweighted=0, edge=0.71..7.08
@@ -0,0 +1,485 @@
{
"age": "child",
"fails": [],
"info": {
"adultArmature": "male",
"ageArmature": "child-male",
"animations": {
"aliased": {
"swimming-fast": "swimming"
},
"appended": [
"Armature|mixamo.com|Layer0.020",
"Armature|mixamo.com|Layer0.018",
"Armature|mixamo.com|Layer0.016",
"Armature|mixamo.com|Layer0.014",
"Armature|mixamo.com|Layer0.012"
],
"missing": [],
"scaledLocationCurves": 7601,
"stem": "child-male"
},
"outputBase": "edited-normal-child-male.blend",
"slots": "bottom,face,feet,hair,top",
"sourceBase": "edited-normal-male.blend"
},
"objects": {
"Accessory": {
"degenerateTris": 0,
"edgeRatioMax": 0.7935740735522747,
"edgeRatioMin": 0.478987783805106,
"maxInfluences": 4,
"note": "",
"polygons": 111,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 145
},
"Body": {
"degenerateTris": 0,
"edgeRatioMax": 1.9536679047270835,
"edgeRatioMin": 0.33440855736119546,
"maxInfluences": 4,
"note": "",
"polygons": 6232,
"quality": "warn",
"shapeKeys": 1,
"unweighted": 0,
"vertices": 6584
},
"BodyBottom": {
"degenerateTris": 0,
"edgeRatioMax": 0.8216992678030084,
"edgeRatioMin": 0.5410301282339429,
"maxInfluences": 4,
"note": "",
"polygons": 1228,
"quality": "ok",
"shapeKeys": 1,
"unweighted": 0,
"vertices": 1181
},
"BodyBottomPants": {
"degenerateTris": 0,
"edgeRatioMax": 0.86670117423969,
"edgeRatioMin": 0.5410301282339429,
"maxInfluences": 4,
"note": "",
"polygons": 972,
"quality": "ok",
"shapeKeys": 1,
"unweighted": 0,
"vertices": 1133
},
"BodyFeet": {
"degenerateTris": 0,
"edgeRatioMax": 0.7839642922675355,
"edgeRatioMin": 0.5255821222248701,
"maxInfluences": 3,
"note": "",
"polygons": 1274,
"quality": "ok",
"shapeKeys": 1,
"unweighted": 0,
"vertices": 1392
},
"BodyFeetPants": {
"degenerateTris": 48,
"edgeRatioMax": 0.7839642922675355,
"edgeRatioMin": 0.513176300659891,
"maxInfluences": 3,
"note": "",
"polygons": 1466,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1602
},
"BodyFeetPantsShoes": {
"degenerateTris": 48,
"edgeRatioMax": 0.7799605112083939,
"edgeRatioMin": 0.513176300659891,
"maxInfluences": 3,
"note": "",
"polygons": 830,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1048
},
"BodyTop": {
"degenerateTris": 0,
"edgeRatioMax": 1.9536679047270835,
"edgeRatioMin": 0.33440855736119546,
"maxInfluences": 4,
"note": "",
"polygons": 3726,
"quality": "warn",
"shapeKeys": 1,
"unweighted": 0,
"vertices": 3373
},
"BodyTopRobe": {
"degenerateTris": 0,
"edgeRatioMax": 1.8102305567029533,
"edgeRatioMin": 0.26171116386651677,
"maxInfluences": 4,
"note": "",
"polygons": 5384,
"quality": "warn",
"shapeKeys": 1,
"unweighted": 0,
"vertices": 5046
},
"Body_shapes": {
"degenerateTris": 0,
"edgeRatioMax": 1.9536679047270835,
"edgeRatioMin": 0.33440855736119546,
"maxInfluences": 4,
"note": "",
"polygons": 6228,
"quality": "warn",
"shapeKeys": 2,
"unweighted": 0,
"vertices": 5556
},
"Bottoms": {
"degenerateTris": 0,
"edgeRatioMax": 0.86670117423969,
"edgeRatioMin": 0.513176300659891,
"maxInfluences": 4,
"note": "",
"polygons": 826,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 942
},
"Face": {
"degenerateTris": 0,
"edgeRatioMax": 0.9201631088272872,
"edgeRatioMin": 0.9197675922571078,
"maxInfluences": 1,
"note": "",
"polygons": 3839,
"quality": "ok",
"shapeKeys": 57,
"unweighted": 0,
"vertices": 4045
},
"Hair": {
"degenerateTris": 0,
"edgeRatioMax": 0.9200000519776095,
"edgeRatioMin": 0.9199996919846277,
"maxInfluences": 1,
"note": "",
"polygons": 4,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 9
},
"Hair2": {
"degenerateTris": 0,
"edgeRatioMax": 0.9421731351522475,
"edgeRatioMin": 0.8873110924578558,
"maxInfluences": 2,
"note": "",
"polygons": 4483,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 7022
},
"HairBack": {
"degenerateTris": 0,
"edgeRatioMax": 1.2562402728849387,
"edgeRatioMin": 0.7903516292481316,
"maxInfluences": 4,
"note": "",
"polygons": 724,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 760
},
"Panties001": {
"degenerateTris": 0,
"edgeRatioMax": 0.8213261947723751,
"edgeRatioMin": 0.5459879769698383,
"maxInfluences": 4,
"note": "",
"polygons": 604,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 582
},
"Pants0001": {
"degenerateTris": 0,
"edgeRatioMax": 1.0359715012588415,
"edgeRatioMin": 0.5209577970985301,
"maxInfluences": 4,
"note": "",
"polygons": 898,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 915
},
"Shoes": {
"degenerateTris": 0,
"edgeRatioMax": 0.7799605112083939,
"edgeRatioMin": 0.6458808184096261,
"maxInfluences": 3,
"note": "",
"polygons": 334,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 478
},
"Tops": {
"degenerateTris": 0,
"edgeRatioMax": 1.3836635660461891,
"edgeRatioMin": 0.26171116386651677,
"maxInfluences": 4,
"note": "",
"polygons": 1658,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1896
},
"TopsEdited": {
"degenerateTris": 0,
"edgeRatioMax": 1.3836635660461891,
"edgeRatioMin": 0.26171116386651677,
"maxInfluences": 4,
"note": "",
"polygons": 1658,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1896
},
"coat": {
"degenerateTris": 0,
"edgeRatioMax": 5.7431588938626055,
"edgeRatioMin": 0.06297046287549611,
"maxInfluences": 4,
"note": "",
"polygons": 1658,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1896
},
"hair0001": {
"degenerateTris": 0,
"edgeRatioMax": 1.6992910918861586,
"edgeRatioMin": 0.8209691490768963,
"maxInfluences": 4,
"note": "",
"polygons": 4483,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 7022
},
"hair0002": {
"degenerateTris": 0,
"edgeRatioMax": 1.6992910918861586,
"edgeRatioMin": 0.8209691490768963,
"maxInfluences": 4,
"note": "",
"polygons": 4483,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 7022
},
"hair0003": {
"degenerateTris": 0,
"edgeRatioMax": 1.0,
"edgeRatioMin": 1.0,
"maxInfluences": 0,
"note": "",
"polygons": 4483,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 7022
},
"shoes1": {
"degenerateTris": 0,
"edgeRatioMax": 0.76872570692281,
"edgeRatioMin": 0.6246053643865501,
"maxInfluences": 3,
"note": "",
"polygons": 334,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 478
}
},
"profileHash": "dd6901d4220ae704",
"rig": {
"adultBones": 242,
"ageBones": 242,
"profile": {
"arm": 0.6,
"bodyRadial": 0.78,
"foot": 0.75,
"head": 0.92,
"headBulge": 1.03,
"hipHeight": 0.55,
"hipWidth": 0.72,
"leg": 0.55,
"limbRadial": 0.72,
"shoulderWidth": 0.7,
"torso": 0.62,
"walkSpeedScale": 0.75
},
"restOrientationErrors": []
},
"seams": {
"coincidentGroups": 5119,
"examples": [
{
"gap": 1e-06,
"objects": [
"BodyTop",
"BodyTopRobe"
],
"position": [
0.0,
0.09,
1.4972
]
},
{
"gap": 1e-06,
"objects": [
"BodyTop",
"BodyTopRobe"
],
"position": [
0.0,
0.0858,
1.6039
]
},
{
"gap": 2e-06,
"objects": [
"BodyTop",
"BodyTopRobe"
],
"position": [
0.0,
-0.0184,
1.5008
]
},
{
"gap": 2e-06,
"objects": [
"BodyTop",
"Face"
],
"position": [
0.0633,
0.0369,
1.6496
]
},
{
"gap": 2e-06,
"objects": [
"BodyTop",
"Face"
],
"position": [
-0.0633,
0.0369,
1.6496
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
0.0633,
0.0344,
1.6475
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
0.0636,
0.0319,
1.6457
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
0.0645,
0.0294,
1.6445
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
0.0698,
0.021,
1.649
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
0.0716,
0.0206,
1.6529
]
}
],
"maxGap": 7.33739356344359e-06,
"snappedVertices": 216
},
"sex": "male",
"status": "warn",
"stem": "child-male",
"timestamp": 1793419291,
"warnings": [
"Body",
"Body_shapes",
"BodyFeetPants",
"BodyFeetPantsShoes",
"BodyTop",
"BodyTopRobe",
"Tops",
"TopsEdited",
"coat"
]
}
@@ -0,0 +1,62 @@
# Conversion Report — child / male
- Stem: `child-male`
- Status: **warn**
- Profile hash: `dd6901d4220ae704`
- Objects: 25
- Warnings: 9
- Fails: 0
## Info
- adultArmature: `male`
- ageArmature: `child-male`
- animations: `{'scaledLocationCurves': 7601, 'appended': ['Armature|mixamo.com|Layer0.020', 'Armature|mixamo.com|Layer0.018', 'Armature|mixamo.com|Layer0.016', 'Armature|mixamo.com|Layer0.014', 'Armature|mixamo.com|Layer0.012'], 'aliased': {'swimming-fast': 'swimming'}, 'missing': [], 'stem': 'child-male'}`
- outputBase: `edited-normal-child-male.blend`
- slots: `bottom,face,feet,hair,top`
- sourceBase: `edited-normal-male.blend`
## Seams
- coincidentGroups: `5119`
- snappedVertices: `216`
- maxGap: `7.33739356344359e-06`
- examples: `[{'position': [0.0, 0.09, 1.4972], 'gap': 1e-06, 'objects': ['BodyTop', 'BodyTopRobe']}, {'position': [0.0, 0.0858, 1.6039], 'gap': 1e-06, 'objects': ['BodyTop', 'BodyTopRobe']}, {'position': [0.0, -0.0184, 1.5008], 'gap': 2e-06, 'objects': ['BodyTop', 'BodyTopRobe']}, {'position': [0.0633, 0.0369, 1.6496], 'gap': 2e-06, 'objects': ['BodyTop', 'Face']}, {'position': [-0.0633, 0.0369, 1.6496], 'gap': 2e-06, 'objects': ['BodyTop', 'Face']}, {'position': [0.0633, 0.0344, 1.6475], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0636, 0.0319, 1.6457], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0645, 0.0294, 1.6445], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0698, 0.021, 1.649], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0716, 0.0206, 1.6529], 'gap': 1e-06, 'objects': ['Face']}]`
## Warnings (first 20)
- `Body`
- `Body_shapes`
- `BodyFeetPants`
- `BodyFeetPantsShoes`
- `BodyTop`
- `BodyTopRobe`
- `Tops`
- `TopsEdited`
- `coat`
## Objects
- `Accessory` — ok; verts=145, tris=0 degenerate, influences=4, unweighted=0, edge=0.48..0.79
- `Body` — warn; verts=6584, tris=0 degenerate, influences=4, unweighted=0, edge=0.33..1.95
- `BodyBottom` — ok; verts=1181, tris=0 degenerate, influences=4, unweighted=0, edge=0.54..0.82
- `BodyBottomPants` — ok; verts=1133, tris=0 degenerate, influences=4, unweighted=0, edge=0.54..0.87
- `BodyFeet` — ok; verts=1392, tris=0 degenerate, influences=3, unweighted=0, edge=0.53..0.78
- `BodyFeetPants` — warn; verts=1602, tris=48 degenerate, influences=3, unweighted=0, edge=0.51..0.78
- `BodyFeetPantsShoes` — warn; verts=1048, tris=48 degenerate, influences=3, unweighted=0, edge=0.51..0.78
- `BodyTop` — warn; verts=3373, tris=0 degenerate, influences=4, unweighted=0, edge=0.33..1.95
- `BodyTopRobe` — warn; verts=5046, tris=0 degenerate, influences=4, unweighted=0, edge=0.26..1.81
- `Body_shapes` — warn; verts=5556, tris=0 degenerate, influences=4, unweighted=0, edge=0.33..1.95
- `Bottoms` — ok; verts=942, tris=0 degenerate, influences=4, unweighted=0, edge=0.51..0.87
- `Face` — ok; verts=4045, tris=0 degenerate, influences=1, unweighted=0, edge=0.92..0.92
- `Hair` — ok; verts=9, tris=0 degenerate, influences=1, unweighted=0, edge=0.92..0.92
- `Hair2` — ok; verts=7022, tris=0 degenerate, influences=2, unweighted=0, edge=0.89..0.94
- `HairBack` — ok; verts=760, tris=0 degenerate, influences=4, unweighted=0, edge=0.79..1.26
- `Panties001` — ok; verts=582, tris=0 degenerate, influences=4, unweighted=0, edge=0.55..0.82
- `Pants0001` — ok; verts=915, tris=0 degenerate, influences=4, unweighted=0, edge=0.52..1.04
- `Shoes` — ok; verts=478, tris=0 degenerate, influences=3, unweighted=0, edge=0.65..0.78
- `Tops` — warn; verts=1896, tris=0 degenerate, influences=4, unweighted=0, edge=0.26..1.38
- `TopsEdited` — warn; verts=1896, tris=0 degenerate, influences=4, unweighted=0, edge=0.26..1.38
- `coat` — warn; verts=1896, tris=0 degenerate, influences=4, unweighted=0, edge=0.06..5.74
- `hair0001` — ok; verts=7022, tris=0 degenerate, influences=4, unweighted=0, edge=0.82..1.70
- `hair0002` — ok; verts=7022, tris=0 degenerate, influences=4, unweighted=0, edge=0.82..1.70
- `hair0003` — ok; verts=7022, tris=0 degenerate, influences=0, unweighted=0, edge=1.00..1.00
- `shoes1` — ok; verts=478, tris=0 degenerate, influences=3, unweighted=0, edge=0.62..0.77
@@ -0,0 +1,621 @@
{
"age": "elder",
"fails": [],
"info": {
"adultArmature": "female",
"ageArmature": "elder-female",
"animations": {
"aliased": {
"swimming-fast": "swimming"
},
"appended": [
"Armature|mixamo.com|Layer0.020",
"Armature|mixamo.com|Layer0.018",
"Armature|mixamo.com|Layer0.016",
"Armature|mixamo.com|Layer0.014"
],
"missing": [],
"scaledLocationCurves": 7028,
"stem": "elder-female"
},
"outputBase": "edited-normal-elder-female.blend",
"slots": "bottom,face,feet,hair,top",
"sourceBase": "edited-normal-female.blend"
},
"objects": {
"Accessory": {
"degenerateTris": 0,
"edgeRatioMax": 1.000008309035069,
"edgeRatioMin": 0.9999861617983601,
"maxInfluences": 4,
"note": "",
"polygons": 40,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 69
},
"Body": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000602153963685,
"edgeRatioMin": 0.9999297885300813,
"maxInfluences": 4,
"note": "",
"polygons": 6227,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 6578
},
"Body-merged": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000602153963685,
"edgeRatioMin": 0.9999297885300813,
"maxInfluences": 4,
"note": "",
"polygons": 8423,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 9178
},
"BodyBottom": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000091614229163,
"edgeRatioMin": 0.9999833439579573,
"maxInfluences": 4,
"note": "",
"polygons": 1242,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1194
},
"BodyFeet": {
"degenerateTris": 0,
"edgeRatioMax": 1.000004257443259,
"edgeRatioMin": 0.9999898988835334,
"maxInfluences": 3,
"note": "",
"polygons": 1280,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1050
},
"BodyTop": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000602153963685,
"edgeRatioMin": 0.9999297885300813,
"maxInfluences": 4,
"note": "",
"polygons": 3701,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 3354
},
"BodyTopRobe": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000602153963685,
"edgeRatioMin": 0.9999297885300813,
"maxInfluences": 4,
"note": "",
"polygons": 4727,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 4383
},
"Body_shapes": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000602153963685,
"edgeRatioMin": 0.9999297885300813,
"maxInfluences": 4,
"note": "",
"polygons": 6223,
"quality": "ok",
"shapeKeys": 2,
"unweighted": 0,
"vertices": 5550
},
"Cylinder": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000211541526272,
"edgeRatioMin": 0.9999790604099301,
"maxInfluences": 0,
"note": "",
"polygons": 595,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 840,
"vertices": 840
},
"Face": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000190299901157,
"edgeRatioMin": 0.9999818716880085,
"maxInfluences": 1,
"note": "",
"polygons": 3887,
"quality": "ok",
"shapeKeys": 57,
"unweighted": 0,
"vertices": 4109
},
"Hair": {
"degenerateTris": 0,
"edgeRatioMax": 1.0,
"edgeRatioMin": 1.0,
"maxInfluences": 1,
"note": "",
"polygons": 2,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 8
},
"Hair001": {
"degenerateTris": 0,
"edgeRatioMax": 1.000108986292174,
"edgeRatioMin": 0.999757672900018,
"maxInfluences": 4,
"note": "",
"polygons": 5658,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 8673
},
"Hair2": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000886276531582,
"edgeRatioMin": 0.9982467837381087,
"maxInfluences": 2,
"note": "",
"polygons": 5658,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 8673
},
"HairBack": {
"degenerateTris": 0,
"edgeRatioMax": 1.000033289511109,
"edgeRatioMin": 0.9999827306906097,
"maxInfluences": 4,
"note": "",
"polygons": 726,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 760
},
"OnepieceCloth": {
"degenerateTris": 0,
"edgeRatioMax": 1.00000327729584,
"edgeRatioMin": 0.9999867384343708,
"maxInfluences": 3,
"note": "",
"polygons": 2,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 6
},
"Shoes": {
"degenerateTris": 0,
"edgeRatioMax": 1.000000954873743,
"edgeRatioMin": 0.9999976654518632,
"maxInfluences": 3,
"note": "",
"polygons": 402,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 608
},
"Tops": {
"degenerateTris": 0,
"edgeRatioMax": 1.000009505554167,
"edgeRatioMin": 0.9999842864583346,
"maxInfluences": 4,
"note": "",
"polygons": 1026,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1029
},
"bra1": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000653723155617,
"edgeRatioMin": 0.9999349455725334,
"maxInfluences": 4,
"note": "",
"polygons": 430,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 419
},
"bra2": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000515467022928,
"edgeRatioMin": 0.9999485610532655,
"maxInfluences": 4,
"note": "",
"polygons": 506,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 524
},
"bra3": {
"degenerateTris": 0,
"edgeRatioMax": 1.000045760529744,
"edgeRatioMin": 0.9999485610532655,
"maxInfluences": 4,
"note": "",
"polygons": 410,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 450
},
"bra4": {
"degenerateTris": 0,
"edgeRatioMax": 1.000045760529744,
"edgeRatioMin": 0.9999485610532655,
"maxInfluences": 4,
"note": "",
"polygons": 410,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 450
},
"panties": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000105452390367,
"edgeRatioMin": 0.9999622354677439,
"maxInfluences": 0,
"note": "",
"polygons": 674,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 797,
"vertices": 797
},
"panties-full": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000048680725775,
"edgeRatioMin": 0.9999761306308599,
"maxInfluences": 0,
"note": "",
"polygons": 337,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 425,
"vertices": 425
},
"panties2": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000202864294472,
"edgeRatioMin": 0.9999826178387231,
"maxInfluences": 0,
"note": "",
"polygons": 1266,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 1328,
"vertices": 1328
},
"panties2-full": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000121089841207,
"edgeRatioMin": 0.9999863856203022,
"maxInfluences": 0,
"note": "",
"polygons": 507,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 558,
"vertices": 558
},
"panties3": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000139098043757,
"edgeRatioMin": 0.9999866822085812,
"maxInfluences": 0,
"note": "",
"polygons": 758,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 820,
"vertices": 820
},
"panties4": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000139098043757,
"edgeRatioMin": 0.9999866822085812,
"maxInfluences": 0,
"note": "",
"polygons": 690,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 752,
"vertices": 752
},
"panties5": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000461926176676,
"edgeRatioMin": 0.9999441034118597,
"maxInfluences": 4,
"note": "",
"polygons": 698,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 758
},
"panties6": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000277280519727,
"edgeRatioMin": 0.9999767160991722,
"maxInfluences": 4,
"note": "",
"polygons": 698,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 758
},
"panties7": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000385836476469,
"edgeRatioMin": 0.9999691785976204,
"maxInfluences": 4,
"note": "",
"polygons": 698,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 758
},
"shoes1": {
"degenerateTris": 0,
"edgeRatioMax": 1.000001217005389,
"edgeRatioMin": 0.9999980533858904,
"maxInfluences": 3,
"note": "",
"polygons": 414,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 414
},
"skirt1": {
"degenerateTris": 0,
"edgeRatioMax": 1.000003778236853,
"edgeRatioMin": 0.9999955122967341,
"maxInfluences": 4,
"note": "",
"polygons": 132,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 154
},
"skirt2": {
"degenerateTris": 0,
"edgeRatioMax": 1.000005985850434,
"edgeRatioMin": 0.999995191044834,
"maxInfluences": 4,
"note": "",
"polygons": 154,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 176
},
"skirt3": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000032530300955,
"edgeRatioMin": 0.9999941815040422,
"maxInfluences": 4,
"note": "",
"polygons": 132,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 154
},
"top1": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000349169435994,
"edgeRatioMin": 0.9999356995377789,
"maxInfluences": 4,
"note": "",
"polygons": 860,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 852
},
"top2": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000115228837376,
"edgeRatioMin": 0.9999867173552025,
"maxInfluences": 4,
"note": "",
"polygons": 876,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 854
},
"top3": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000115228837376,
"edgeRatioMin": 0.9999867173552025,
"maxInfluences": 4,
"note": "",
"polygons": 876,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 854
}
},
"profileHash": "dd6901d4220ae704",
"rig": {
"adultBones": 252,
"ageBones": 252,
"profile": {
"arm": 1.0,
"bodyRadial": 1.0,
"foot": 1.0,
"head": 1.0,
"headBulge": 1.0,
"hipHeight": 1.0,
"hipWidth": 1.0,
"leg": 1.0,
"limbRadial": 1.0,
"shoulderWidth": 1.0,
"torso": 1.0,
"walkSpeedScale": 1.0
},
"restOrientationErrors": []
},
"seams": {
"coincidentGroups": 3697,
"examples": [
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
0.0649,
0.0281,
1.3797
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
0.066,
0.0231,
1.3767
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
0.0694,
0.0172,
1.3783
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
-0.0649,
0.0281,
1.3797
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
-0.066,
0.0231,
1.3767
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
-0.0694,
0.0172,
1.3783
]
},
{
"gap": 2e-06,
"objects": [
"Hair2"
],
"position": [
0.0001,
-0.0385,
1.5529
]
},
{
"gap": 2e-06,
"objects": [
"Hair2"
],
"position": [
-0.0001,
-0.0385,
1.5529
]
},
{
"gap": 3e-06,
"objects": [
"Hair2"
],
"position": [
-0.0012,
-0.0342,
1.5629
]
},
{
"gap": 3e-06,
"objects": [
"Hair2"
],
"position": [
-0.001,
-0.0343,
1.5628
]
}
],
"maxGap": 7.0412131889888606e-06,
"snappedVertices": 95
},
"sex": "female",
"status": "warn",
"stem": "elder-female",
"timestamp": 1793419268,
"warnings": [
"Cylinder",
"panties",
"panties-full",
"panties2",
"panties2-full",
"panties3",
"panties4"
]
}
@@ -0,0 +1,72 @@
# Conversion Report — elder / female
- Stem: `elder-female`
- Status: **warn**
- Profile hash: `dd6901d4220ae704`
- Objects: 37
- Warnings: 7
- Fails: 0
## Info
- adultArmature: `female`
- ageArmature: `elder-female`
- animations: `{'scaledLocationCurves': 7028, 'appended': ['Armature|mixamo.com|Layer0.020', 'Armature|mixamo.com|Layer0.018', 'Armature|mixamo.com|Layer0.016', 'Armature|mixamo.com|Layer0.014'], 'aliased': {'swimming-fast': 'swimming'}, 'missing': [], 'stem': 'elder-female'}`
- outputBase: `edited-normal-elder-female.blend`
- slots: `bottom,face,feet,hair,top`
- sourceBase: `edited-normal-female.blend`
## Seams
- coincidentGroups: `3697`
- snappedVertices: `95`
- maxGap: `7.0412131889888606e-06`
- examples: `[{'position': [0.0649, 0.0281, 1.3797], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.066, 0.0231, 1.3767], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0694, 0.0172, 1.3783], 'gap': 1e-06, 'objects': ['Face']}, {'position': [-0.0649, 0.0281, 1.3797], 'gap': 1e-06, 'objects': ['Face']}, {'position': [-0.066, 0.0231, 1.3767], 'gap': 1e-06, 'objects': ['Face']}, {'position': [-0.0694, 0.0172, 1.3783], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0001, -0.0385, 1.5529], 'gap': 2e-06, 'objects': ['Hair2']}, {'position': [-0.0001, -0.0385, 1.5529], 'gap': 2e-06, 'objects': ['Hair2']}, {'position': [-0.0012, -0.0342, 1.5629], 'gap': 3e-06, 'objects': ['Hair2']}, {'position': [-0.001, -0.0343, 1.5628], 'gap': 3e-06, 'objects': ['Hair2']}]`
## Warnings (first 20)
- `Cylinder`
- `panties`
- `panties-full`
- `panties2`
- `panties2-full`
- `panties3`
- `panties4`
## Objects
- `Accessory` — ok; verts=69, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `Body` — ok; verts=6578, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `Body-merged` — ok; verts=9178, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `BodyBottom` — ok; verts=1194, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `BodyFeet` — ok; verts=1050, tris=0 degenerate, influences=3, unweighted=0, edge=1.00..1.00
- `BodyTop` — ok; verts=3354, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `BodyTopRobe` — ok; verts=4383, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `Body_shapes` — ok; verts=5550, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `Cylinder` — warn; verts=840, tris=0 degenerate, influences=0, unweighted=840, edge=1.00..1.00
- `Face` — ok; verts=4109, tris=0 degenerate, influences=1, unweighted=0, edge=1.00..1.00
- `Hair` — ok; verts=8, tris=0 degenerate, influences=1, unweighted=0, edge=1.00..1.00
- `Hair001` — ok; verts=8673, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `Hair2` — ok; verts=8673, tris=0 degenerate, influences=2, unweighted=0, edge=1.00..1.00
- `HairBack` — ok; verts=760, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `OnepieceCloth` — ok; verts=6, tris=0 degenerate, influences=3, unweighted=0, edge=1.00..1.00
- `Shoes` — ok; verts=608, tris=0 degenerate, influences=3, unweighted=0, edge=1.00..1.00
- `Tops` — ok; verts=1029, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `bra1` — ok; verts=419, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `bra2` — ok; verts=524, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `bra3` — ok; verts=450, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `bra4` — ok; verts=450, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `panties` — warn; verts=797, tris=0 degenerate, influences=0, unweighted=797, edge=1.00..1.00
- `panties-full` — warn; verts=425, tris=0 degenerate, influences=0, unweighted=425, edge=1.00..1.00
- `panties2` — warn; verts=1328, tris=0 degenerate, influences=0, unweighted=1328, edge=1.00..1.00
- `panties2-full` — warn; verts=558, tris=0 degenerate, influences=0, unweighted=558, edge=1.00..1.00
- `panties3` — warn; verts=820, tris=0 degenerate, influences=0, unweighted=820, edge=1.00..1.00
- `panties4` — warn; verts=752, tris=0 degenerate, influences=0, unweighted=752, edge=1.00..1.00
- `panties5` — ok; verts=758, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `panties6` — ok; verts=758, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `panties7` — ok; verts=758, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `shoes1` — ok; verts=414, tris=0 degenerate, influences=3, unweighted=0, edge=1.00..1.00
- `skirt1` — ok; verts=154, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `skirt2` — ok; verts=176, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `skirt3` — ok; verts=154, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `top1` — ok; verts=852, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `top2` — ok; verts=854, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `top3` — ok; verts=854, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
@@ -0,0 +1,482 @@
{
"age": "elder",
"fails": [],
"info": {
"adultArmature": "male",
"ageArmature": "elder-male",
"animations": {
"aliased": {
"swimming-fast": "swimming"
},
"appended": [
"Armature|mixamo.com|Layer0.020",
"Armature|mixamo.com|Layer0.018",
"Armature|mixamo.com|Layer0.016",
"Armature|mixamo.com|Layer0.014",
"Armature|mixamo.com|Layer0.012"
],
"missing": [],
"scaledLocationCurves": 7601,
"stem": "elder-male"
},
"outputBase": "edited-normal-elder-male.blend",
"slots": "bottom,face,feet,hair,top",
"sourceBase": "edited-normal-male.blend"
},
"objects": {
"Accessory": {
"degenerateTris": 0,
"edgeRatioMax": 1.000019766352531,
"edgeRatioMin": 0.9999805255229,
"maxInfluences": 4,
"note": "",
"polygons": 111,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 145
},
"Body": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000897554304082,
"edgeRatioMin": 0.9999611641687194,
"maxInfluences": 4,
"note": "",
"polygons": 6232,
"quality": "ok",
"shapeKeys": 1,
"unweighted": 0,
"vertices": 6584
},
"BodyBottom": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000079995934277,
"edgeRatioMin": 0.999989253410586,
"maxInfluences": 4,
"note": "",
"polygons": 1228,
"quality": "ok",
"shapeKeys": 1,
"unweighted": 0,
"vertices": 1181
},
"BodyBottomPants": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000258231043282,
"edgeRatioMin": 0.9999859573234091,
"maxInfluences": 4,
"note": "",
"polygons": 972,
"quality": "ok",
"shapeKeys": 1,
"unweighted": 0,
"vertices": 1133
},
"BodyFeet": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000046342516928,
"edgeRatioMin": 0.9999981174339364,
"maxInfluences": 3,
"note": "",
"polygons": 1274,
"quality": "ok",
"shapeKeys": 1,
"unweighted": 0,
"vertices": 1392
},
"BodyFeetPants": {
"degenerateTris": 48,
"edgeRatioMax": 1.0000046342516928,
"edgeRatioMin": 0.9999981174339364,
"maxInfluences": 3,
"note": "",
"polygons": 1466,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1602
},
"BodyFeetPantsShoes": {
"degenerateTris": 48,
"edgeRatioMax": 1.0000013400794516,
"edgeRatioMin": 0.9999981174339364,
"maxInfluences": 3,
"note": "",
"polygons": 830,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1048
},
"BodyTop": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000897554304082,
"edgeRatioMin": 0.9999611641687194,
"maxInfluences": 4,
"note": "",
"polygons": 3726,
"quality": "ok",
"shapeKeys": 1,
"unweighted": 0,
"vertices": 3373
},
"BodyTopRobe": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000897554304082,
"edgeRatioMin": 0.9999611641687194,
"maxInfluences": 4,
"note": "",
"polygons": 5384,
"quality": "ok",
"shapeKeys": 1,
"unweighted": 0,
"vertices": 5046
},
"Body_shapes": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000897554304082,
"edgeRatioMin": 0.9999611641687194,
"maxInfluences": 4,
"note": "",
"polygons": 6228,
"quality": "ok",
"shapeKeys": 2,
"unweighted": 0,
"vertices": 5556
},
"Bottoms": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000258231043282,
"edgeRatioMin": 0.9999859573234091,
"maxInfluences": 4,
"note": "",
"polygons": 826,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 942
},
"Face": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000018813696014,
"edgeRatioMin": 0.9999982328372087,
"maxInfluences": 1,
"note": "",
"polygons": 3839,
"quality": "ok",
"shapeKeys": 57,
"unweighted": 0,
"vertices": 4045
},
"Hair": {
"degenerateTris": 0,
"edgeRatioMax": 1.0,
"edgeRatioMin": 0.9999997540155123,
"maxInfluences": 1,
"note": "",
"polygons": 4,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 9
},
"Hair2": {
"degenerateTris": 0,
"edgeRatioMax": 1.000542889561435,
"edgeRatioMin": 0.999194851963821,
"maxInfluences": 2,
"note": "",
"polygons": 4483,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 7022
},
"HairBack": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000295096813758,
"edgeRatioMin": 0.9999786138678867,
"maxInfluences": 4,
"note": "",
"polygons": 724,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 760
},
"Panties001": {
"degenerateTris": 0,
"edgeRatioMax": 1.0010504381569287,
"edgeRatioMin": 0.9991726553447599,
"maxInfluences": 4,
"note": "",
"polygons": 604,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 582
},
"Pants0001": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000229696457892,
"edgeRatioMin": 0.9999874704829604,
"maxInfluences": 4,
"note": "",
"polygons": 898,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 915
},
"Shoes": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000013174267397,
"edgeRatioMin": 0.9999985866462575,
"maxInfluences": 3,
"note": "",
"polygons": 334,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 478
},
"Tops": {
"degenerateTris": 0,
"edgeRatioMax": 1.00000962524978,
"edgeRatioMin": 0.9999900806166673,
"maxInfluences": 4,
"note": "",
"polygons": 1658,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1896
},
"TopsEdited": {
"degenerateTris": 0,
"edgeRatioMax": 1.00000962524978,
"edgeRatioMin": 0.9999900806166673,
"maxInfluences": 4,
"note": "",
"polygons": 1658,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1896
},
"coat": {
"degenerateTris": 0,
"edgeRatioMax": 1.000022626000937,
"edgeRatioMin": 0.9999831391448877,
"maxInfluences": 4,
"note": "",
"polygons": 1658,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1896
},
"hair0001": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000358941381666,
"edgeRatioMin": 0.99983153357466,
"maxInfluences": 4,
"note": "",
"polygons": 4483,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 7022
},
"hair0002": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000358941381666,
"edgeRatioMin": 0.99983153357466,
"maxInfluences": 4,
"note": "",
"polygons": 4483,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 7022
},
"hair0003": {
"degenerateTris": 0,
"edgeRatioMax": 1.0,
"edgeRatioMin": 1.0,
"maxInfluences": 0,
"note": "",
"polygons": 4483,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 7022
},
"shoes1": {
"degenerateTris": 0,
"edgeRatioMax": 1.0000013174267397,
"edgeRatioMin": 0.9999985251538689,
"maxInfluences": 3,
"note": "",
"polygons": 334,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 478
}
},
"profileHash": "dd6901d4220ae704",
"rig": {
"adultBones": 242,
"ageBones": 242,
"profile": {
"arm": 1.0,
"bodyRadial": 1.0,
"foot": 1.0,
"head": 1.0,
"headBulge": 1.0,
"hipHeight": 1.0,
"hipWidth": 1.0,
"leg": 1.0,
"limbRadial": 1.0,
"shoulderWidth": 1.0,
"torso": 1.0,
"walkSpeedScale": 1.0
},
"restOrientationErrors": []
},
"seams": {
"coincidentGroups": 5119,
"examples": [
{
"gap": 2e-06,
"objects": [
"BodyTop",
"BodyTopRobe"
],
"position": [
0.0,
0.09,
1.4972
]
},
{
"gap": 1e-06,
"objects": [
"BodyTop",
"BodyTopRobe"
],
"position": [
0.0,
0.0858,
1.6039
]
},
{
"gap": 2e-06,
"objects": [
"BodyTop",
"BodyTopRobe"
],
"position": [
0.0,
-0.0184,
1.5008
]
},
{
"gap": 2e-06,
"objects": [
"BodyTop",
"Face"
],
"position": [
0.0633,
0.0369,
1.6496
]
},
{
"gap": 2e-06,
"objects": [
"BodyTop",
"Face"
],
"position": [
-0.0633,
0.0369,
1.6496
]
},
{
"gap": 1e-06,
"objects": [
"BodyTop",
"BodyTopRobe",
"Face"
],
"position": [
0.0842,
0.0277,
1.6955
]
},
{
"gap": 1e-06,
"objects": [
"BodyTop",
"BodyTopRobe",
"Face"
],
"position": [
-0.0842,
0.0277,
1.6955
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
0.0633,
0.0344,
1.6475
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
0.0636,
0.0319,
1.6457
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
0.0645,
0.0294,
1.6445
]
}
],
"maxGap": 8.047489926985063e-06,
"snappedVertices": 226
},
"sex": "male",
"status": "warn",
"stem": "elder-male",
"timestamp": 1793419262,
"warnings": [
"BodyFeetPants",
"BodyFeetPantsShoes"
]
}
@@ -0,0 +1,55 @@
# Conversion Report — elder / male
- Stem: `elder-male`
- Status: **warn**
- Profile hash: `dd6901d4220ae704`
- Objects: 25
- Warnings: 2
- Fails: 0
## Info
- adultArmature: `male`
- ageArmature: `elder-male`
- animations: `{'scaledLocationCurves': 7601, 'appended': ['Armature|mixamo.com|Layer0.020', 'Armature|mixamo.com|Layer0.018', 'Armature|mixamo.com|Layer0.016', 'Armature|mixamo.com|Layer0.014', 'Armature|mixamo.com|Layer0.012'], 'aliased': {'swimming-fast': 'swimming'}, 'missing': [], 'stem': 'elder-male'}`
- outputBase: `edited-normal-elder-male.blend`
- slots: `bottom,face,feet,hair,top`
- sourceBase: `edited-normal-male.blend`
## Seams
- coincidentGroups: `5119`
- snappedVertices: `226`
- maxGap: `8.047489926985063e-06`
- examples: `[{'position': [0.0, 0.09, 1.4972], 'gap': 2e-06, 'objects': ['BodyTop', 'BodyTopRobe']}, {'position': [0.0, 0.0858, 1.6039], 'gap': 1e-06, 'objects': ['BodyTop', 'BodyTopRobe']}, {'position': [0.0, -0.0184, 1.5008], 'gap': 2e-06, 'objects': ['BodyTop', 'BodyTopRobe']}, {'position': [0.0633, 0.0369, 1.6496], 'gap': 2e-06, 'objects': ['BodyTop', 'Face']}, {'position': [-0.0633, 0.0369, 1.6496], 'gap': 2e-06, 'objects': ['BodyTop', 'Face']}, {'position': [0.0842, 0.0277, 1.6955], 'gap': 1e-06, 'objects': ['BodyTop', 'BodyTopRobe', 'Face']}, {'position': [-0.0842, 0.0277, 1.6955], 'gap': 1e-06, 'objects': ['BodyTop', 'BodyTopRobe', 'Face']}, {'position': [0.0633, 0.0344, 1.6475], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0636, 0.0319, 1.6457], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0645, 0.0294, 1.6445], 'gap': 1e-06, 'objects': ['Face']}]`
## Warnings (first 20)
- `BodyFeetPants`
- `BodyFeetPantsShoes`
## Objects
- `Accessory` — ok; verts=145, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `Body` — ok; verts=6584, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `BodyBottom` — ok; verts=1181, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `BodyBottomPants` — ok; verts=1133, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `BodyFeet` — ok; verts=1392, tris=0 degenerate, influences=3, unweighted=0, edge=1.00..1.00
- `BodyFeetPants` — warn; verts=1602, tris=48 degenerate, influences=3, unweighted=0, edge=1.00..1.00
- `BodyFeetPantsShoes` — warn; verts=1048, tris=48 degenerate, influences=3, unweighted=0, edge=1.00..1.00
- `BodyTop` — ok; verts=3373, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `BodyTopRobe` — ok; verts=5046, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `Body_shapes` — ok; verts=5556, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `Bottoms` — ok; verts=942, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `Face` — ok; verts=4045, tris=0 degenerate, influences=1, unweighted=0, edge=1.00..1.00
- `Hair` — ok; verts=9, tris=0 degenerate, influences=1, unweighted=0, edge=1.00..1.00
- `Hair2` — ok; verts=7022, tris=0 degenerate, influences=2, unweighted=0, edge=1.00..1.00
- `HairBack` — ok; verts=760, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `Panties001` — ok; verts=582, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `Pants0001` — ok; verts=915, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `Shoes` — ok; verts=478, tris=0 degenerate, influences=3, unweighted=0, edge=1.00..1.00
- `Tops` — ok; verts=1896, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `TopsEdited` — ok; verts=1896, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `coat` — ok; verts=1896, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `hair0001` — ok; verts=7022, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `hair0002` — ok; verts=7022, tris=0 degenerate, influences=4, unweighted=0, edge=1.00..1.00
- `hair0003` — ok; verts=7022, tris=0 degenerate, influences=0, unweighted=0, edge=1.00..1.00
- `shoes1` — ok; verts=478, tris=0 degenerate, influences=3, unweighted=0, edge=1.00..1.00
@@ -0,0 +1,622 @@
{
"age": "teenager",
"fails": [],
"info": {
"adultArmature": "female",
"ageArmature": "teenager-female",
"animations": {
"aliased": {
"swimming-fast": "swimming"
},
"appended": [
"Armature|mixamo.com|Layer0.020",
"Armature|mixamo.com|Layer0.018",
"Armature|mixamo.com|Layer0.016",
"Armature|mixamo.com|Layer0.014"
],
"missing": [],
"scaledLocationCurves": 7028,
"stem": "teenager-female"
},
"outputBase": "edited-normal-teenager-female.blend",
"slots": "bottom,face,feet,hair,top",
"sourceBase": "edited-normal-female.blend"
},
"objects": {
"Accessory": {
"degenerateTris": 0,
"edgeRatioMax": 0.9428835642440181,
"edgeRatioMin": 0.8937664900167279,
"maxInfluences": 4,
"note": "",
"polygons": 40,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 69
},
"Body": {
"degenerateTris": 0,
"edgeRatioMax": 1.153864673534735,
"edgeRatioMin": 0.8788689898808826,
"maxInfluences": 4,
"note": "",
"polygons": 6227,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 6578
},
"Body-merged": {
"degenerateTris": 0,
"edgeRatioMax": 1.153864673534735,
"edgeRatioMin": 0.875585733388662,
"maxInfluences": 4,
"note": "",
"polygons": 8423,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 9178
},
"BodyBottom": {
"degenerateTris": 0,
"edgeRatioMax": 0.9413767566501527,
"edgeRatioMin": 0.894824475491785,
"maxInfluences": 4,
"note": "",
"polygons": 1242,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1194
},
"BodyFeet": {
"degenerateTris": 0,
"edgeRatioMax": 1.01533051589042,
"edgeRatioMin": 0.8905319834891143,
"maxInfluences": 3,
"note": "",
"polygons": 1280,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1050
},
"BodyTop": {
"degenerateTris": 0,
"edgeRatioMax": 1.153864673534735,
"edgeRatioMin": 0.8788684699247973,
"maxInfluences": 4,
"note": "",
"polygons": 3701,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 3354
},
"BodyTopRobe": {
"degenerateTris": 0,
"edgeRatioMax": 1.153864673534735,
"edgeRatioMin": 0.875585733388662,
"maxInfluences": 4,
"note": "",
"polygons": 4727,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 4383
},
"Body_shapes": {
"degenerateTris": 0,
"edgeRatioMax": 1.153864673534735,
"edgeRatioMin": 0.8788689898808826,
"maxInfluences": 4,
"note": "",
"polygons": 6223,
"quality": "ok",
"shapeKeys": 2,
"unweighted": 0,
"vertices": 5550
},
"Cylinder": {
"degenerateTris": 0,
"edgeRatioMax": 13.149501164154342,
"edgeRatioMin": 0.2067502446063823,
"maxInfluences": 0,
"note": "",
"polygons": 595,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 840,
"vertices": 840
},
"Face": {
"degenerateTris": 0,
"edgeRatioMax": 0.9900810993796347,
"edgeRatioMin": 0.9896786488081534,
"maxInfluences": 1,
"note": "",
"polygons": 3887,
"quality": "ok",
"shapeKeys": 57,
"unweighted": 0,
"vertices": 4109
},
"Hair": {
"degenerateTris": 0,
"edgeRatioMax": 0.9900007559563859,
"edgeRatioMin": 0.9899997124609613,
"maxInfluences": 1,
"note": "",
"polygons": 2,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 8
},
"Hair001": {
"degenerateTris": 0,
"edgeRatioMax": 1.3437205849900313,
"edgeRatioMin": 0.39954414397197524,
"maxInfluences": 4,
"note": "",
"polygons": 5658,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 8673
},
"Hair2": {
"degenerateTris": 0,
"edgeRatioMax": 0.9961437951038233,
"edgeRatioMin": 0.9739232308631328,
"maxInfluences": 2,
"note": "",
"polygons": 5658,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 8673
},
"HairBack": {
"degenerateTris": 0,
"edgeRatioMax": 1.0706959837595011,
"edgeRatioMin": 0.9495467764182186,
"maxInfluences": 4,
"note": "",
"polygons": 726,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 760
},
"OnepieceCloth": {
"degenerateTris": 0,
"edgeRatioMax": 0.9399357283296659,
"edgeRatioMin": 0.9381366728208823,
"maxInfluences": 3,
"note": "",
"polygons": 2,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 6
},
"Shoes": {
"degenerateTris": 0,
"edgeRatioMax": 0.9981897700090412,
"edgeRatioMin": 0.9254076761648661,
"maxInfluences": 3,
"note": "",
"polygons": 402,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 608
},
"Tops": {
"degenerateTris": 0,
"edgeRatioMax": 0.9511826460744913,
"edgeRatioMin": 0.875585733388662,
"maxInfluences": 4,
"note": "",
"polygons": 1026,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1029
},
"bra1": {
"degenerateTris": 0,
"edgeRatioMax": 0.9900000946583577,
"edgeRatioMin": 0.2887434501951809,
"maxInfluences": 4,
"note": "",
"polygons": 430,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 419
},
"bra2": {
"degenerateTris": 0,
"edgeRatioMax": 0.9984894682233404,
"edgeRatioMin": 0.4006806605346238,
"maxInfluences": 4,
"note": "",
"polygons": 506,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 524
},
"bra3": {
"degenerateTris": 0,
"edgeRatioMax": 0.9984894682233404,
"edgeRatioMin": 0.4889485839438301,
"maxInfluences": 4,
"note": "",
"polygons": 410,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 450
},
"bra4": {
"degenerateTris": 0,
"edgeRatioMax": 0.9984894682233404,
"edgeRatioMin": 0.4889485839438301,
"maxInfluences": 4,
"note": "",
"polygons": 410,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 450
},
"panties": {
"degenerateTris": 0,
"edgeRatioMax": 50.21989080149669,
"edgeRatioMin": 0.19000501701728584,
"maxInfluences": 0,
"note": "",
"polygons": 674,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 797,
"vertices": 797
},
"panties-full": {
"degenerateTris": 0,
"edgeRatioMax": 1.3300942912650158,
"edgeRatioMin": 0.12847562281583486,
"maxInfluences": 0,
"note": "",
"polygons": 337,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 425,
"vertices": 425
},
"panties2": {
"degenerateTris": 0,
"edgeRatioMax": 10.548253137802863,
"edgeRatioMin": 0.3920225273569097,
"maxInfluences": 0,
"note": "",
"polygons": 1266,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 1328,
"vertices": 1328
},
"panties2-full": {
"degenerateTris": 0,
"edgeRatioMax": 9.15854640358302,
"edgeRatioMin": 0.6364910348952761,
"maxInfluences": 0,
"note": "",
"polygons": 507,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 558,
"vertices": 558
},
"panties3": {
"degenerateTris": 0,
"edgeRatioMax": 7.299860127337894,
"edgeRatioMin": 0.13403860939637366,
"maxInfluences": 0,
"note": "",
"polygons": 758,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 820,
"vertices": 820
},
"panties4": {
"degenerateTris": 0,
"edgeRatioMax": 7.740842499445837,
"edgeRatioMin": 0.1158857342919668,
"maxInfluences": 0,
"note": "",
"polygons": 690,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 752,
"vertices": 752
},
"panties5": {
"degenerateTris": 0,
"edgeRatioMax": 0.9443143846802673,
"edgeRatioMin": 0.8965532928515044,
"maxInfluences": 4,
"note": "",
"polygons": 698,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 758
},
"panties6": {
"degenerateTris": 0,
"edgeRatioMax": 0.95104115229466,
"edgeRatioMin": 0.8819173360530367,
"maxInfluences": 4,
"note": "",
"polygons": 698,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 758
},
"panties7": {
"degenerateTris": 0,
"edgeRatioMax": 0.9455920513906987,
"edgeRatioMin": 0.8823391488934409,
"maxInfluences": 4,
"note": "",
"polygons": 698,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 758
},
"shoes1": {
"degenerateTris": 0,
"edgeRatioMax": 0.9807207937208285,
"edgeRatioMin": 0.9278301779320385,
"maxInfluences": 3,
"note": "",
"polygons": 414,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 414
},
"skirt1": {
"degenerateTris": 0,
"edgeRatioMax": 0.9422412937744205,
"edgeRatioMin": 0.8944357351478923,
"maxInfluences": 4,
"note": "",
"polygons": 132,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 154
},
"skirt2": {
"degenerateTris": 0,
"edgeRatioMax": 0.9422412937744205,
"edgeRatioMin": 0.8944357351478923,
"maxInfluences": 4,
"note": "",
"polygons": 154,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 176
},
"skirt3": {
"degenerateTris": 0,
"edgeRatioMax": 0.9444972809207006,
"edgeRatioMin": 0.9023806027952785,
"maxInfluences": 4,
"note": "",
"polygons": 132,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 154
},
"top1": {
"degenerateTris": 0,
"edgeRatioMax": 1.0484285122626043,
"edgeRatioMin": 0.5756344323442318,
"maxInfluences": 4,
"note": "",
"polygons": 860,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 852
},
"top2": {
"degenerateTris": 0,
"edgeRatioMax": 1.0484285122626043,
"edgeRatioMin": 0.5756344323442318,
"maxInfluences": 4,
"note": "",
"polygons": 876,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 854
},
"top3": {
"degenerateTris": 0,
"edgeRatioMax": 1.0484285122626043,
"edgeRatioMin": 0.5756344323442318,
"maxInfluences": 4,
"note": "",
"polygons": 876,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 854
}
},
"profileHash": "dd6901d4220ae704",
"rig": {
"adultBones": 252,
"ageBones": 252,
"profile": {
"arm": 0.92,
"bodyRadial": 0.94,
"foot": 0.97,
"head": 0.99,
"headBulge": 1.0,
"hipHeight": 0.94,
"hipWidth": 0.92,
"leg": 0.94,
"limbRadial": 0.93,
"shoulderWidth": 0.92,
"torso": 0.92,
"walkSpeedScale": 0.95
},
"restOrientationErrors": []
},
"seams": {
"coincidentGroups": 3697,
"examples": [
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
0.0649,
0.0281,
1.3797
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
0.066,
0.0231,
1.3767
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
0.0694,
0.0172,
1.3783
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
-0.0649,
0.0281,
1.3797
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
-0.066,
0.0231,
1.3767
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
-0.0694,
0.0172,
1.3783
]
},
{
"gap": 2e-06,
"objects": [
"Hair2"
],
"position": [
0.0001,
-0.0385,
1.5529
]
},
{
"gap": 2e-06,
"objects": [
"Hair2"
],
"position": [
-0.0001,
-0.0385,
1.5529
]
},
{
"gap": 3e-06,
"objects": [
"Hair2"
],
"position": [
-0.0012,
-0.0342,
1.5629
]
},
{
"gap": 3e-06,
"objects": [
"Hair2"
],
"position": [
-0.001,
-0.0343,
1.5628
]
}
],
"maxGap": 6.944388526471647e-06,
"snappedVertices": 95
},
"sex": "female",
"status": "warn",
"stem": "teenager-female",
"timestamp": 1793419282,
"warnings": [
"bra1",
"Cylinder",
"panties",
"panties-full",
"panties2",
"panties2-full",
"panties3",
"panties4"
]
}
@@ -0,0 +1,73 @@
# Conversion Report — teenager / female
- Stem: `teenager-female`
- Status: **warn**
- Profile hash: `dd6901d4220ae704`
- Objects: 37
- Warnings: 8
- Fails: 0
## Info
- adultArmature: `female`
- ageArmature: `teenager-female`
- animations: `{'scaledLocationCurves': 7028, 'appended': ['Armature|mixamo.com|Layer0.020', 'Armature|mixamo.com|Layer0.018', 'Armature|mixamo.com|Layer0.016', 'Armature|mixamo.com|Layer0.014'], 'aliased': {'swimming-fast': 'swimming'}, 'missing': [], 'stem': 'teenager-female'}`
- outputBase: `edited-normal-teenager-female.blend`
- slots: `bottom,face,feet,hair,top`
- sourceBase: `edited-normal-female.blend`
## Seams
- coincidentGroups: `3697`
- snappedVertices: `95`
- maxGap: `6.944388526471647e-06`
- examples: `[{'position': [0.0649, 0.0281, 1.3797], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.066, 0.0231, 1.3767], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0694, 0.0172, 1.3783], 'gap': 1e-06, 'objects': ['Face']}, {'position': [-0.0649, 0.0281, 1.3797], 'gap': 1e-06, 'objects': ['Face']}, {'position': [-0.066, 0.0231, 1.3767], 'gap': 1e-06, 'objects': ['Face']}, {'position': [-0.0694, 0.0172, 1.3783], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0001, -0.0385, 1.5529], 'gap': 2e-06, 'objects': ['Hair2']}, {'position': [-0.0001, -0.0385, 1.5529], 'gap': 2e-06, 'objects': ['Hair2']}, {'position': [-0.0012, -0.0342, 1.5629], 'gap': 3e-06, 'objects': ['Hair2']}, {'position': [-0.001, -0.0343, 1.5628], 'gap': 3e-06, 'objects': ['Hair2']}]`
## Warnings (first 20)
- `bra1`
- `Cylinder`
- `panties`
- `panties-full`
- `panties2`
- `panties2-full`
- `panties3`
- `panties4`
## Objects
- `Accessory` — ok; verts=69, tris=0 degenerate, influences=4, unweighted=0, edge=0.89..0.94
- `Body` — ok; verts=6578, tris=0 degenerate, influences=4, unweighted=0, edge=0.88..1.15
- `Body-merged` — ok; verts=9178, tris=0 degenerate, influences=4, unweighted=0, edge=0.88..1.15
- `BodyBottom` — ok; verts=1194, tris=0 degenerate, influences=4, unweighted=0, edge=0.89..0.94
- `BodyFeet` — ok; verts=1050, tris=0 degenerate, influences=3, unweighted=0, edge=0.89..1.02
- `BodyTop` — ok; verts=3354, tris=0 degenerate, influences=4, unweighted=0, edge=0.88..1.15
- `BodyTopRobe` — ok; verts=4383, tris=0 degenerate, influences=4, unweighted=0, edge=0.88..1.15
- `Body_shapes` — ok; verts=5550, tris=0 degenerate, influences=4, unweighted=0, edge=0.88..1.15
- `Cylinder` — warn; verts=840, tris=0 degenerate, influences=0, unweighted=840, edge=0.21..13.15
- `Face` — ok; verts=4109, tris=0 degenerate, influences=1, unweighted=0, edge=0.99..0.99
- `Hair` — ok; verts=8, tris=0 degenerate, influences=1, unweighted=0, edge=0.99..0.99
- `Hair001` — ok; verts=8673, tris=0 degenerate, influences=4, unweighted=0, edge=0.40..1.34
- `Hair2` — ok; verts=8673, tris=0 degenerate, influences=2, unweighted=0, edge=0.97..1.00
- `HairBack` — ok; verts=760, tris=0 degenerate, influences=4, unweighted=0, edge=0.95..1.07
- `OnepieceCloth` — ok; verts=6, tris=0 degenerate, influences=3, unweighted=0, edge=0.94..0.94
- `Shoes` — ok; verts=608, tris=0 degenerate, influences=3, unweighted=0, edge=0.93..1.00
- `Tops` — ok; verts=1029, tris=0 degenerate, influences=4, unweighted=0, edge=0.88..0.95
- `bra1` — warn; verts=419, tris=0 degenerate, influences=4, unweighted=0, edge=0.29..0.99
- `bra2` — ok; verts=524, tris=0 degenerate, influences=4, unweighted=0, edge=0.40..1.00
- `bra3` — ok; verts=450, tris=0 degenerate, influences=4, unweighted=0, edge=0.49..1.00
- `bra4` — ok; verts=450, tris=0 degenerate, influences=4, unweighted=0, edge=0.49..1.00
- `panties` — warn; verts=797, tris=0 degenerate, influences=0, unweighted=797, edge=0.19..50.22
- `panties-full` — warn; verts=425, tris=0 degenerate, influences=0, unweighted=425, edge=0.13..1.33
- `panties2` — warn; verts=1328, tris=0 degenerate, influences=0, unweighted=1328, edge=0.39..10.55
- `panties2-full` — warn; verts=558, tris=0 degenerate, influences=0, unweighted=558, edge=0.64..9.16
- `panties3` — warn; verts=820, tris=0 degenerate, influences=0, unweighted=820, edge=0.13..7.30
- `panties4` — warn; verts=752, tris=0 degenerate, influences=0, unweighted=752, edge=0.12..7.74
- `panties5` — ok; verts=758, tris=0 degenerate, influences=4, unweighted=0, edge=0.90..0.94
- `panties6` — ok; verts=758, tris=0 degenerate, influences=4, unweighted=0, edge=0.88..0.95
- `panties7` — ok; verts=758, tris=0 degenerate, influences=4, unweighted=0, edge=0.88..0.95
- `shoes1` — ok; verts=414, tris=0 degenerate, influences=3, unweighted=0, edge=0.93..0.98
- `skirt1` — ok; verts=154, tris=0 degenerate, influences=4, unweighted=0, edge=0.89..0.94
- `skirt2` — ok; verts=176, tris=0 degenerate, influences=4, unweighted=0, edge=0.89..0.94
- `skirt3` — ok; verts=154, tris=0 degenerate, influences=4, unweighted=0, edge=0.90..0.94
- `top1` — ok; verts=852, tris=0 degenerate, influences=4, unweighted=0, edge=0.58..1.05
- `top2` — ok; verts=854, tris=0 degenerate, influences=4, unweighted=0, edge=0.58..1.05
- `top3` — ok; verts=854, tris=0 degenerate, influences=4, unweighted=0, edge=0.58..1.05
@@ -0,0 +1,482 @@
{
"age": "teenager",
"fails": [],
"info": {
"adultArmature": "male",
"ageArmature": "teenager-male",
"animations": {
"aliased": {
"swimming-fast": "swimming"
},
"appended": [
"Armature|mixamo.com|Layer0.020",
"Armature|mixamo.com|Layer0.018",
"Armature|mixamo.com|Layer0.016",
"Armature|mixamo.com|Layer0.014",
"Armature|mixamo.com|Layer0.012"
],
"missing": [],
"scaledLocationCurves": 7601,
"stem": "teenager-male"
},
"outputBase": "edited-normal-teenager-male.blend",
"slots": "bottom,face,feet,hair,top",
"sourceBase": "edited-normal-male.blend"
},
"objects": {
"Accessory": {
"degenerateTris": 0,
"edgeRatioMax": 0.9408520391417841,
"edgeRatioMin": 0.9129875147754315,
"maxInfluences": 4,
"note": "",
"polygons": 111,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 145
},
"Body": {
"degenerateTris": 0,
"edgeRatioMax": 1.218153566216659,
"edgeRatioMin": 0.7349989979664033,
"maxInfluences": 4,
"note": "",
"polygons": 6232,
"quality": "ok",
"shapeKeys": 1,
"unweighted": 0,
"vertices": 6584
},
"BodyBottom": {
"degenerateTris": 0,
"edgeRatioMax": 0.9415051179583105,
"edgeRatioMin": 0.8926428739692449,
"maxInfluences": 4,
"note": "",
"polygons": 1228,
"quality": "ok",
"shapeKeys": 1,
"unweighted": 0,
"vertices": 1181
},
"BodyBottomPants": {
"degenerateTris": 0,
"edgeRatioMax": 0.9409140829633826,
"edgeRatioMin": 0.8827862876547917,
"maxInfluences": 4,
"note": "",
"polygons": 972,
"quality": "ok",
"shapeKeys": 1,
"unweighted": 0,
"vertices": 1133
},
"BodyFeet": {
"degenerateTris": 0,
"edgeRatioMax": 1.0153534321863444,
"edgeRatioMin": 0.8887109284611726,
"maxInfluences": 3,
"note": "",
"polygons": 1274,
"quality": "ok",
"shapeKeys": 1,
"unweighted": 0,
"vertices": 1392
},
"BodyFeetPants": {
"degenerateTris": 48,
"edgeRatioMax": 1.0153534321863444,
"edgeRatioMin": 0.8887109284611726,
"maxInfluences": 3,
"note": "",
"polygons": 1466,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1602
},
"BodyFeetPantsShoes": {
"degenerateTris": 48,
"edgeRatioMax": 1.0099758747299858,
"edgeRatioMin": 0.9253361353429929,
"maxInfluences": 3,
"note": "",
"polygons": 830,
"quality": "warn",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1048
},
"BodyTop": {
"degenerateTris": 0,
"edgeRatioMax": 1.218153566216659,
"edgeRatioMin": 0.7349989979664033,
"maxInfluences": 4,
"note": "",
"polygons": 3726,
"quality": "ok",
"shapeKeys": 1,
"unweighted": 0,
"vertices": 3373
},
"BodyTopRobe": {
"degenerateTris": 0,
"edgeRatioMax": 1.1954519257878777,
"edgeRatioMin": 0.7349989979664033,
"maxInfluences": 4,
"note": "",
"polygons": 5384,
"quality": "ok",
"shapeKeys": 1,
"unweighted": 0,
"vertices": 5046
},
"Body_shapes": {
"degenerateTris": 0,
"edgeRatioMax": 1.218153566216659,
"edgeRatioMin": 0.7349989979664033,
"maxInfluences": 4,
"note": "",
"polygons": 6228,
"quality": "ok",
"shapeKeys": 2,
"unweighted": 0,
"vertices": 5556
},
"Bottoms": {
"degenerateTris": 0,
"edgeRatioMax": 0.9635867444531326,
"edgeRatioMin": 0.8827862876547917,
"maxInfluences": 4,
"note": "",
"polygons": 826,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 942
},
"Face": {
"degenerateTris": 0,
"edgeRatioMax": 0.9902318510318413,
"edgeRatioMin": 0.9899010785060705,
"maxInfluences": 1,
"note": "",
"polygons": 3839,
"quality": "ok",
"shapeKeys": 57,
"unweighted": 0,
"vertices": 4045
},
"Hair": {
"degenerateTris": 0,
"edgeRatioMax": 0.9900002385614128,
"edgeRatioMin": 0.9899999851477491,
"maxInfluences": 1,
"note": "",
"polygons": 4,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 9
},
"Hair2": {
"degenerateTris": 0,
"edgeRatioMax": 0.9990771032056067,
"edgeRatioMin": 0.949542700174737,
"maxInfluences": 2,
"note": "",
"polygons": 4483,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 7022
},
"HairBack": {
"degenerateTris": 0,
"edgeRatioMax": 1.0723475348632554,
"edgeRatioMin": 0.9495563211922972,
"maxInfluences": 4,
"note": "",
"polygons": 724,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 760
},
"Panties001": {
"degenerateTris": 0,
"edgeRatioMax": 0.9414287020897463,
"edgeRatioMin": 0.8949860611150358,
"maxInfluences": 4,
"note": "",
"polygons": 604,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 582
},
"Pants0001": {
"degenerateTris": 0,
"edgeRatioMax": 0.9741146771468555,
"edgeRatioMin": 0.7167993480571109,
"maxInfluences": 4,
"note": "",
"polygons": 898,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 915
},
"Shoes": {
"degenerateTris": 0,
"edgeRatioMax": 1.0099758747299858,
"edgeRatioMin": 0.9253361353429929,
"maxInfluences": 3,
"note": "",
"polygons": 334,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 478
},
"Tops": {
"degenerateTris": 0,
"edgeRatioMax": 1.058850186591336,
"edgeRatioMin": 0.8334681396211425,
"maxInfluences": 4,
"note": "",
"polygons": 1658,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1896
},
"TopsEdited": {
"degenerateTris": 0,
"edgeRatioMax": 1.058850186591336,
"edgeRatioMin": 0.8334681396211425,
"maxInfluences": 4,
"note": "",
"polygons": 1658,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1896
},
"coat": {
"degenerateTris": 0,
"edgeRatioMax": 0.9465083443563386,
"edgeRatioMin": 0.8345250576121954,
"maxInfluences": 4,
"note": "",
"polygons": 1658,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 1896
},
"hair0001": {
"degenerateTris": 0,
"edgeRatioMax": 1.1823451224746337,
"edgeRatioMin": 0.949542700174737,
"maxInfluences": 4,
"note": "",
"polygons": 4483,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 7022
},
"hair0002": {
"degenerateTris": 0,
"edgeRatioMax": 1.1823451224746337,
"edgeRatioMin": 0.949542700174737,
"maxInfluences": 4,
"note": "",
"polygons": 4483,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 7022
},
"hair0003": {
"degenerateTris": 0,
"edgeRatioMax": 1.0,
"edgeRatioMin": 1.0,
"maxInfluences": 0,
"note": "",
"polygons": 4483,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 7022
},
"shoes1": {
"degenerateTris": 0,
"edgeRatioMax": 0.9949808923340873,
"edgeRatioMin": 0.9268173377136528,
"maxInfluences": 3,
"note": "",
"polygons": 334,
"quality": "ok",
"shapeKeys": 0,
"unweighted": 0,
"vertices": 478
}
},
"profileHash": "dd6901d4220ae704",
"rig": {
"adultBones": 242,
"ageBones": 242,
"profile": {
"arm": 0.92,
"bodyRadial": 0.94,
"foot": 0.97,
"head": 0.99,
"headBulge": 1.0,
"hipHeight": 0.94,
"hipWidth": 0.92,
"leg": 0.94,
"limbRadial": 0.93,
"shoulderWidth": 0.92,
"torso": 0.92,
"walkSpeedScale": 0.95
},
"restOrientationErrors": []
},
"seams": {
"coincidentGroups": 5119,
"examples": [
{
"gap": 2e-06,
"objects": [
"BodyTop",
"BodyTopRobe"
],
"position": [
0.0,
0.09,
1.4972
]
},
{
"gap": 1e-06,
"objects": [
"BodyTop",
"BodyTopRobe"
],
"position": [
0.0,
0.0858,
1.6039
]
},
{
"gap": 2e-06,
"objects": [
"BodyTop",
"BodyTopRobe"
],
"position": [
0.0,
-0.0184,
1.5008
]
},
{
"gap": 2e-06,
"objects": [
"BodyTop",
"Face"
],
"position": [
0.0633,
0.0369,
1.6496
]
},
{
"gap": 2e-06,
"objects": [
"BodyTop",
"Face"
],
"position": [
-0.0633,
0.0369,
1.6496
]
},
{
"gap": 1e-06,
"objects": [
"BodyTop",
"BodyTopRobe",
"Face"
],
"position": [
0.0842,
0.0277,
1.6955
]
},
{
"gap": 1e-06,
"objects": [
"BodyTop",
"BodyTopRobe",
"Face"
],
"position": [
-0.0842,
0.0277,
1.6955
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
0.0633,
0.0344,
1.6475
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
0.0636,
0.0319,
1.6457
]
},
{
"gap": 1e-06,
"objects": [
"Face"
],
"position": [
0.0645,
0.0294,
1.6445
]
}
],
"maxGap": 7.936056721462239e-06,
"snappedVertices": 226
},
"sex": "male",
"status": "warn",
"stem": "teenager-male",
"timestamp": 1793419277,
"warnings": [
"BodyFeetPants",
"BodyFeetPantsShoes"
]
}
@@ -0,0 +1,55 @@
# Conversion Report — teenager / male
- Stem: `teenager-male`
- Status: **warn**
- Profile hash: `dd6901d4220ae704`
- Objects: 25
- Warnings: 2
- Fails: 0
## Info
- adultArmature: `male`
- ageArmature: `teenager-male`
- animations: `{'scaledLocationCurves': 7601, 'appended': ['Armature|mixamo.com|Layer0.020', 'Armature|mixamo.com|Layer0.018', 'Armature|mixamo.com|Layer0.016', 'Armature|mixamo.com|Layer0.014', 'Armature|mixamo.com|Layer0.012'], 'aliased': {'swimming-fast': 'swimming'}, 'missing': [], 'stem': 'teenager-male'}`
- outputBase: `edited-normal-teenager-male.blend`
- slots: `bottom,face,feet,hair,top`
- sourceBase: `edited-normal-male.blend`
## Seams
- coincidentGroups: `5119`
- snappedVertices: `226`
- maxGap: `7.936056721462239e-06`
- examples: `[{'position': [0.0, 0.09, 1.4972], 'gap': 2e-06, 'objects': ['BodyTop', 'BodyTopRobe']}, {'position': [0.0, 0.0858, 1.6039], 'gap': 1e-06, 'objects': ['BodyTop', 'BodyTopRobe']}, {'position': [0.0, -0.0184, 1.5008], 'gap': 2e-06, 'objects': ['BodyTop', 'BodyTopRobe']}, {'position': [0.0633, 0.0369, 1.6496], 'gap': 2e-06, 'objects': ['BodyTop', 'Face']}, {'position': [-0.0633, 0.0369, 1.6496], 'gap': 2e-06, 'objects': ['BodyTop', 'Face']}, {'position': [0.0842, 0.0277, 1.6955], 'gap': 1e-06, 'objects': ['BodyTop', 'BodyTopRobe', 'Face']}, {'position': [-0.0842, 0.0277, 1.6955], 'gap': 1e-06, 'objects': ['BodyTop', 'BodyTopRobe', 'Face']}, {'position': [0.0633, 0.0344, 1.6475], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0636, 0.0319, 1.6457], 'gap': 1e-06, 'objects': ['Face']}, {'position': [0.0645, 0.0294, 1.6445], 'gap': 1e-06, 'objects': ['Face']}]`
## Warnings (first 20)
- `BodyFeetPants`
- `BodyFeetPantsShoes`
## Objects
- `Accessory` — ok; verts=145, tris=0 degenerate, influences=4, unweighted=0, edge=0.91..0.94
- `Body` — ok; verts=6584, tris=0 degenerate, influences=4, unweighted=0, edge=0.73..1.22
- `BodyBottom` — ok; verts=1181, tris=0 degenerate, influences=4, unweighted=0, edge=0.89..0.94
- `BodyBottomPants` — ok; verts=1133, tris=0 degenerate, influences=4, unweighted=0, edge=0.88..0.94
- `BodyFeet` — ok; verts=1392, tris=0 degenerate, influences=3, unweighted=0, edge=0.89..1.02
- `BodyFeetPants` — warn; verts=1602, tris=48 degenerate, influences=3, unweighted=0, edge=0.89..1.02
- `BodyFeetPantsShoes` — warn; verts=1048, tris=48 degenerate, influences=3, unweighted=0, edge=0.93..1.01
- `BodyTop` — ok; verts=3373, tris=0 degenerate, influences=4, unweighted=0, edge=0.73..1.22
- `BodyTopRobe` — ok; verts=5046, tris=0 degenerate, influences=4, unweighted=0, edge=0.73..1.20
- `Body_shapes` — ok; verts=5556, tris=0 degenerate, influences=4, unweighted=0, edge=0.73..1.22
- `Bottoms` — ok; verts=942, tris=0 degenerate, influences=4, unweighted=0, edge=0.88..0.96
- `Face` — ok; verts=4045, tris=0 degenerate, influences=1, unweighted=0, edge=0.99..0.99
- `Hair` — ok; verts=9, tris=0 degenerate, influences=1, unweighted=0, edge=0.99..0.99
- `Hair2` — ok; verts=7022, tris=0 degenerate, influences=2, unweighted=0, edge=0.95..1.00
- `HairBack` — ok; verts=760, tris=0 degenerate, influences=4, unweighted=0, edge=0.95..1.07
- `Panties001` — ok; verts=582, tris=0 degenerate, influences=4, unweighted=0, edge=0.89..0.94
- `Pants0001` — ok; verts=915, tris=0 degenerate, influences=4, unweighted=0, edge=0.72..0.97
- `Shoes` — ok; verts=478, tris=0 degenerate, influences=3, unweighted=0, edge=0.93..1.01
- `Tops` — ok; verts=1896, tris=0 degenerate, influences=4, unweighted=0, edge=0.83..1.06
- `TopsEdited` — ok; verts=1896, tris=0 degenerate, influences=4, unweighted=0, edge=0.83..1.06
- `coat` — ok; verts=1896, tris=0 degenerate, influences=4, unweighted=0, edge=0.83..0.95
- `hair0001` — ok; verts=7022, tris=0 degenerate, influences=4, unweighted=0, edge=0.95..1.18
- `hair0002` — ok; verts=7022, tris=0 degenerate, influences=4, unweighted=0, edge=0.95..1.18
- `hair0003` — ok; verts=7022, tris=0 degenerate, influences=0, unweighted=0, edge=1.00..1.00
- `shoes1` — ok; verts=478, tris=0 degenerate, influences=3, unweighted=0, edge=0.93..0.99
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,660 @@
#!/usr/bin/env python3
"""Generate first-pass age-band character seeds from the adult assets.
Run with Blender 3.6 batch mode, for example::
blender -b -P assets/blender/characters/generate_age_blends.py -- \
--age elder --sex male --report-only
The script is an authoring tool: it writes committed seed ``.blend`` files and
conversion reports. It is deliberately NOT part of the CMake build, and it
will not overwrite a file that changed since it was generated unless
``--force`` is given. See AGES-PLAN.md for the design.
"""
from __future__ import annotations
import argparse
import glob
import hashlib
import json
import os
import shutil
import sys
import tempfile
import time
import bpy
_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
if _THIS_DIR not in sys.path:
sys.path.insert(0, _THIS_DIR)
from agegen import anim, deform, profiles, report, rig, weights # noqa: E402
def log(msg):
print("[agegen] " + msg, flush=True)
def sha256(path):
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
def manifest_path(out_dir):
return os.path.join(out_dir, "AGE-GENERATION.json")
def load_manifest(out_dir):
path = manifest_path(out_dir)
if not os.path.exists(path):
return {"version": 1, "stems": {}}
try:
with open(path) as f:
data = json.load(f)
if "stems" not in data:
data["stems"] = {}
return data
except Exception:
return {"version": 1, "stems": {}}
def save_manifest(out_dir, data):
data["profileHash"] = profiles.profile_hash()
data["updated"] = int(time.time())
os.makedirs(out_dir, exist_ok=True)
with open(manifest_path(out_dir), "w") as f:
json.dump(data, f, indent=2, sort_keys=True)
def find_anim_source(sex, explicit):
if explicit:
return explicit if os.path.exists(explicit) else None
name = f"vrm-vroid-normal-{sex}.blend"
roots = [
os.getcwd(),
os.path.dirname(_THIS_DIR),
os.path.dirname(os.path.dirname(_THIS_DIR)),
"/media/slapin/library/ogre3/ogre-projects/world2",
]
patterns = []
for root in roots:
patterns += [
os.path.join(root, "build-vscode", "assets", "blender", name),
os.path.join(root, "build", "assets", "blender", name),
os.path.join(root, "assets", "blender", name),
]
patterns += glob.glob(
os.path.join(os.getcwd(), "build*", "assets", "blender", name))
for path in patterns:
if path and os.path.exists(path):
return path
return None
def source_paths(age, sex, source_dir):
stem = profiles.stem(age, sex)
base = os.path.join(source_dir,
f"edited-normal-{sex}.blend")
clothes = {
slot: os.path.join(source_dir, f"clothes-{sex}-{slot}.blend")
for slot in ("top", "bottom", "feet", "hair")
}
out_base = os.path.join(source_dir,
f"edited-normal-{age}-{sex}.blend")
out_clothes = {
slot: os.path.join(source_dir,
f"clothes-{age}-{sex}-{slot}.blend")
for slot in ("top", "bottom", "feet", "hair")
}
return stem, base, clothes, out_base, out_clothes
def all_outputs(out_base, out_clothes):
return [out_base] + [out_clothes[s] for s in
("top", "bottom", "feet", "hair")]
def stem_protected(stem, outputs, manifest, force):
if force:
return False
entry = manifest.get("stems", {}).get(stem)
if not entry:
# Existing files without a manifest are assumed to be hand-authored.
return any(os.path.exists(p) for p in outputs)
if entry.get("state") == "hand-edited":
return True
hashes = entry.get("outputHashes", {})
for path in outputs:
if not os.path.exists(path):
continue
want = hashes.get(os.path.basename(path))
if want and sha256(path) != want:
log(f" {os.path.basename(path)} changed since generation; "
f"treating as hand-edited")
return True
if not want:
return True
return False
def append_objects(blend_path, names):
before = set(bpy.data.objects)
with bpy.data.libraries.load(blend_path, link=False) as (src, dst):
dst.objects = [n for n in names if n in src.objects]
loaded = []
for obj in dst.objects:
if obj is None:
continue
try:
bpy.context.collection.objects.link(obj)
except RuntimeError:
pass
loaded.append(obj)
# Blender may pull in child objects of an appended armature. Keep only
# the explicitly requested objects; the conversion math does not need
# their children, and clothes seed files must not contain body meshes.
keep = set(loaded)
for obj in list(bpy.data.objects):
if obj not in before and obj not in keep:
try:
bpy.data.objects.remove(obj, do_unlink=True)
except Exception:
pass
return loaded
def delete_object(obj):
if obj is None:
return
try:
bpy.data.objects.remove(obj, do_unlink=True)
except Exception:
pass
def purge_orphans():
for _ in range(3):
try:
bpy.ops.outliner.orphans_purge(
do_local_ids=True, do_linked_ids=True,
do_recursive=True)
except Exception:
break
def cleanup_seed_objects(adult_arm):
"""Remove Blender control junk that must not live in a committed seed."""
for obj in list(bpy.data.objects):
if obj == adult_arm:
continue
low = obj.name.lower()
if obj.name.startswith("cs_") or "collider" in low:
delete_object(obj)
continue
if obj.type == 'ARMATURE':
# Keep only the age armature (and any hair armature handled by
# the clothes converter). The adult armature is deleted by the
# caller after animation retargeting.
continue
for obj in list(bpy.data.objects):
if obj.type == 'EMPTY' and obj.name.startswith("cs_"):
delete_object(obj)
def set_catalog_props(obj, age, sex):
if not obj or obj.type != 'MESH':
return
if all(k in obj for k in ("age", "sex", "slot")):
obj["age"] = profiles.normalize_age(age)
obj["sex"] = sex
obj["agegen_state"] = "auto-seed"
obj["agegen_profile"] = profiles.profile_hash()
def convert_body(age, sex, base_path, out_path, anim_source,
write, report_only):
log(f"loading adult base {base_path}")
bpy.ops.wm.open_mainfile(filepath=base_path)
bpy.context.scene.frame_set(1)
adult_arm = bpy.data.objects.get(sex)
if adult_arm is None or adult_arm.type != 'ARMATURE':
raise RuntimeError(f"adult armature '{sex}' not found in {base_path}")
prof = profiles.profile(age)
stem = profiles.stem(age, sex)
age_arm = rig.build_age_armature(adult_arm, age, sex, prof)
log(f"built armature {age_arm.name}")
rest_data = rig.rest_transform_data(adult_arm, age_arm, age, prof)
dmat = rig.dmatrices_from_data(rest_data)
bad = rig.validate_rest_orientation(adult_arm, age_arm)
if bad:
raise RuntimeError("rest orientation changed for bones: " +
", ".join(bad[:10]))
rep = report.ConversionReport(age, sex)
rep.info["sourceBase"] = os.path.basename(base_path)
rep.info["adultArmature"] = adult_arm.name
rep.info["ageArmature"] = age_arm.name
rep.rig = {
"adultBones": len(adult_arm.data.bones),
"ageBones": len(age_arm.data.bones),
"restOrientationErrors": bad,
"profile": prof,
}
converted = []
for obj in list(bpy.data.objects):
if obj.type != 'MESH':
continue
if obj.name.startswith("cs_"):
continue
if obj == adult_arm:
continue
if obj.data is None:
continue
if obj.vertex_groups:
weights.limit_vertex_influences(obj, 4)
metrics = deform.deform_mesh_object(
obj, adult_arm, age_arm, dmat, prof, retarget_parent=True)
if metrics is None:
continue
set_catalog_props(obj, age, sex)
is_catalog = all(k in obj for k in ("age", "sex", "slot"))
quality = report.evaluate_object_quality(metrics, is_catalog)
obj["agegen_quality"] = quality
rep.add_object(obj.name, metrics, quality=quality)
if is_catalog:
converted.append((obj, metrics))
seam = deform.snap_seams(converted)
rep.seams = seam
log(f"seam groups={seam['coincidentGroups']} "
f"snapped={seam['snappedVertices']} maxGap={seam['maxGap']:.6f}")
# Required catalog slots per sex.
slots = sorted({obj.get("slot") for obj, _ in converted
if obj.get("slot")})
rep.info["slots"] = ",".join(slots)
for required in ("face", "top", "bottom", "feet", "hair"):
if required not in slots:
rep.info.setdefault("missingSlots", [])
rep.info["missingSlots"].append(required)
if anim_source and os.path.exists(anim_source):
try:
summary = anim.append_and_retarget(
age_arm, adult_arm, anim_source, age, sex)
rep.info["animations"] = summary
if summary.get("missing"):
rep.info["missingAnimations"] = summary["missing"]
except Exception as exc:
rep.info["animationError"] = str(exc)
log(f"animation retarget failed: {exc}")
else:
rep.info["animationError"] = "animation source not found"
log("animation source not found; seed will have no clips")
# Remove the adult armature and control junk; re-parented meshes remain.
cleanup_seed_objects(adult_arm)
delete_object(adult_arm)
# Name and validate the single body armature.
age_arm.name = stem
age_arm.data.name = stem
set_catalog_props(age_arm, age, sex)
for obj in bpy.data.objects:
set_catalog_props(obj, age, sex)
rep.info["outputBase"] = os.path.basename(out_path)
os.makedirs(os.path.dirname(out_path), exist_ok=True)
purge_orphans()
if write:
bpy.ops.wm.save_as_mainfile(filepath=out_path)
log(f"saved body seed {out_path}")
return rep, rest_data, age_arm
def find_own_hair_armature(obj, adult_arm, age_arm):
own_name = obj.get("own_armature_name", "")
if own_name:
arm = bpy.data.objects.get(own_name)
if arm and arm.type == 'ARMATURE':
return arm
for mod in obj.modifiers:
if mod.type != 'ARMATURE' or mod.object is None:
continue
if mod.object != adult_arm and mod.object != age_arm:
return mod.object
if obj.parent and obj.parent.type == 'ARMATURE' and \
obj.parent not in (adult_arm, age_arm):
return obj.parent
return None
def rename_hair_armature(arm, stem, original_name):
safe = "".join(c if c.isalnum() or c in "-_" else "_"
for c in original_name)
new_name = f"{stem}-hair-{safe}"
arm.name = new_name
arm.data.name = new_name
return new_name
def transfer_weights_from_body(obj, body_obj):
"""Copy body weight groups onto an unskinned garment/helper.
The source clothes files intentionally carry no rig; the normal build
transfers weights later. Doing it before conversion gives the geometry a
smooth deformation instead of nearest-bone rigid pieces, and the build
repeats it from the converted body as usual.
"""
if body_obj is None or body_obj.type != 'MESH':
return False
if not body_obj.vertex_groups:
return False
obj.vertex_groups.clear()
dt = obj.modifiers.new(name="WT", type='DATA_TRANSFER')
dt.object = body_obj
dt.use_vert_data = True
dt.data_types_verts = {'VGROUP_WEIGHTS'}
dt.layers_vgroup_select_src = 'ALL'
dt.vert_mapping = 'POLYINTERP_NEAREST'
bpy.context.view_layer.objects.active = obj
try:
bpy.ops.object.datalayout_transfer(modifier=dt.name)
bpy.ops.object.modifier_apply(modifier=dt.name)
except Exception as exc:
log(f" weight transfer failed for {obj.name}: {exc}")
try:
obj.modifiers.remove(dt)
except Exception:
pass
return False
weights.limit_vertex_influences(obj, 4)
return True
def convert_clothes(age, sex, slot, clothes_path, out_path,
adult_base, body_seed, rest_data, write, rep,
profile):
log(f"converting {slot}: {clothes_path}")
bpy.ops.wm.open_mainfile(filepath=clothes_path)
stem = profiles.stem(age, sex)
adult_arms = append_objects(adult_base, [sex])
age_arms = append_objects(body_seed, [stem])
if not adult_arms or not age_arms:
raise RuntimeError("failed to append adult/age armatures")
adult_arm = adult_arms[0]
age_arm = age_arms[0]
dmat = rig.dmatrices_from_data(rest_data)
prof = profile
adult_head, age_head = rig.head_pivots(adult_arm, age_arm)
head_scale = float(prof["head"])
ref_cache = {}
def get_ref(name):
if not name:
return None
if name not in ref_cache:
objs = append_objects(body_seed, [name])
ref_cache[name] = objs[0] if objs else None
return ref_cache[name]
hair_arm_map = {}
meshes = [o for o in bpy.data.objects
if o.type == 'MESH' and o.data is not None]
for obj in meshes:
own_arm = find_own_hair_armature(obj, adult_arm, age_arm)
if own_arm is not None:
if own_arm.name not in hair_arm_map:
old = own_arm.name
new = rename_hair_armature(own_arm, stem, old)
hair_arm_map[own_arm.name] = new
rig.scale_hair_armature(own_arm, adult_head, age_head,
head_scale)
log(f" scaled hair armature {old} -> {new}")
deform.transform_hair_uniform(obj, adult_head, age_head,
head_scale)
obj["has_own_armature"] = True
obj["own_armature_name"] = own_arm.name
obj.parent = own_arm
mod = next((m for m in obj.modifiers
if m.type == 'ARMATURE'), None)
if mod:
mod.object = own_arm
metrics = {
"vertices": len(obj.data.vertices),
"polygons": len(obj.data.polygons),
"shapeKeys": 0,
"unweighted": 0,
"maxInfluences": 0,
"edgeRatioMin": 1.0,
"edgeRatioMax": 1.0,
"degenerateTris": 0,
}
quality = "ok"
else:
ref = get_ref(obj.get("ref_clothing", ""))
transfer_weights_from_body(obj, ref)
metrics = deform.deform_mesh_object(
obj, adult_arm, age_arm, dmat, prof,
retarget_parent=False)
quality = report.evaluate_object_quality(metrics, True) \
if metrics else "warn"
obj["agegen_quality"] = quality
# Garment seed files carry no body armature.
obj.parent = None
for mod in list(obj.modifiers):
if mod.type == 'ARMATURE':
obj.modifiers.remove(mod)
if "ref_layer" in obj:
obj["ref_age"] = profiles.normalize_age(age)
obj["ref_sex"] = sex
obj["agegen_state"] = "auto-seed"
rep.add_object(obj.name, metrics, quality=quality)
# Remove the appended body armatures and reference helpers, keep hair
# armatures.
for ref in ref_cache.values():
delete_object(ref)
for arm in age_arms + adult_arms:
delete_object(arm)
os.makedirs(os.path.dirname(out_path), exist_ok=True)
purge_orphans()
if write:
bpy.ops.wm.save_as_mainfile(filepath=out_path)
log(f"saved clothes seed {out_path}")
def check_seed(age, sex, source_dir):
stem, base, clothes, out_base, out_clothes = source_paths(
age, sex, source_dir)
outputs = all_outputs(out_base, out_clothes)
manifest = load_manifest(source_dir)
entry = manifest.get("stems", {}).get(stem)
missing = [p for p in outputs if not os.path.exists(p)]
problems = []
if missing:
problems.append("missing: " + ", ".join(os.path.basename(p)
for p in missing))
if not entry:
problems.append("no manifest entry")
else:
if entry.get("profileHash") not in (None,
profiles.profile_hash()):
problems.append("profile hash mismatch")
hashes = entry.get("outputHashes", {})
for p in outputs:
if not os.path.exists(p):
continue
want = hashes.get(os.path.basename(p))
if not want:
problems.append("no hash for " + os.path.basename(p))
elif sha256(p) != want:
problems.append("changed: " + os.path.basename(p))
if problems:
log(f"CHECK FAIL {stem}: " + "; ".join(problems))
return False
log(f"CHECK OK {stem}")
return True
def process_pair(age, sex, args):
stem, base, clothes, out_base, out_clothes = source_paths(
age, sex, args.source_dir)
outputs = all_outputs(out_base, out_clothes)
manifest = load_manifest(args.source_dir)
if args.check:
return check_seed(age, sex, args.source_dir)
if not os.path.exists(base):
raise RuntimeError(f"adult base not found: {base}")
if stem_protected(stem, outputs, manifest, args.force):
log(f"SKIP {stem}: output exists and is hand-edited/changed "
f"(use --force to overwrite)")
return True
anim_source = find_anim_source(sex, args.anim_source)
if anim_source:
log(f"animation source: {anim_source}")
else:
log("WARNING: animation source not found; no clips will be added")
# Body conversion needs a file for the clothes pass even in report-only
# mode; use a temp file that is removed afterwards.
tmp_dir = tempfile.mkdtemp(prefix="agegen-")
body_seed = out_base if (args.write and not args.report_only) else \
os.path.join(tmp_dir, os.path.basename(out_base))
rep, rest_data, _age_arm = convert_body(
age, sex, base, body_seed, anim_source,
write=True, report_only=args.report_only)
if args.report_only:
log(f"temporary body seed: {body_seed}")
if not args.no_clothes:
for slot in ("top", "bottom", "feet", "hair"):
src = clothes[slot]
if not os.path.exists(src):
log(f"WARNING: missing clothes source {src}; skipping")
continue
convert_clothes(
age, sex, slot, src, out_clothes[slot], base, body_seed,
rest_data, write=(not args.report_only), rep=rep,
profile=profiles.profile(age))
# Write report (always useful, even in report-only mode).
report_path, report_json = rep.write(args.source_dir)
log(f"report: {report_path}")
if args.report_only:
shutil.rmtree(tmp_dir, ignore_errors=True)
return True
entry = {
"state": "auto-seed",
"age": profiles.normalize_age(age),
"sex": sex,
"profileHash": profiles.profile_hash(),
"sourceHashes": {
os.path.basename(base): sha256(base),
**{os.path.basename(clothes[s]): sha256(clothes[s])
for s in ("top", "bottom", "feet", "hair")
if os.path.exists(clothes[s])},
},
"outputHashes": {
os.path.basename(p): sha256(p)
for p in outputs if os.path.exists(p)
},
"report": os.path.basename(report_path),
"reportJson": os.path.basename(report_json),
"updated": int(time.time()),
}
if anim_source:
entry["animationSource"] = anim_source
entry["animationSourceHash"] = sha256(anim_source)
manifest.setdefault("stems", {})[stem] = entry
save_manifest(args.source_dir, manifest)
shutil.rmtree(tmp_dir, ignore_errors=True)
if rep.status() == "fail" and not args.allow_fail:
log(f"WARNING: {stem} has fail-level objects; see report")
return True
def parse_args():
argv = sys.argv
if "--" in argv:
argv = argv[argv.index("--") + 1:]
else:
argv = []
p = argparse.ArgumentParser()
p.add_argument("--age", choices=profiles.CANONICAL_AGES)
p.add_argument("--sex", choices=["male", "female"])
p.add_argument("--all", action="store_true")
p.add_argument("--source-dir", default=_THIS_DIR)
p.add_argument("--anim-source", default="")
p.add_argument("--report-only", action="store_true")
p.add_argument("--write", action="store_true")
p.add_argument("--check", action="store_true")
p.add_argument("--force", action="store_true")
p.add_argument("--only-unedited", action="store_true")
p.add_argument("--allow-fail", action="store_true")
p.add_argument("--no-clothes", action="store_true")
p.add_argument("--no-anim", action="store_true")
args = p.parse_args(argv)
if not args.check and not args.report_only and not args.write:
args.write = True
if args.only_unedited:
args.write = True
if args.all:
if args.age or args.sex:
p.error("--all cannot be combined with --age/--sex")
else:
if not args.age or not args.sex:
p.error("specify --age and --sex, or --all")
if args.age == "adult":
p.error("adult is the source, not a generated age")
if args.no_anim:
args.anim_source = "__none__"
return args
def main():
args = parse_args()
args.source_dir = os.path.abspath(args.source_dir)
pairs = []
if args.all:
for age in ("elder", "teenager", "child", "baby"):
for sex in ("male", "female"):
pairs.append((age, sex))
else:
pairs.append((profiles.normalize_age(args.age), args.sex))
ok = True
for age, sex in pairs:
log(f"=== {age} / {sex} ===")
try:
if not process_pair(age, sex, args):
ok = False
except Exception as exc:
import traceback
traceback.print_exc()
log(f"FAILED {age}/{sex}: {exc}")
ok = False
if not ok:
sys.exit(1)
if __name__ == "__main__":
main()
+666
View File
@@ -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()
+23 -13
View File
@@ -2,6 +2,11 @@ import bpy
import os
import sys
_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
if _THIS_DIR not in sys.path:
sys.path.insert(0, _THIS_DIR)
import age_profiles
def clean_scene():
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete()
@@ -36,8 +41,10 @@ def process_batch():
try:
args = sys.argv[sys.argv.index("--") + 1:]
clothing_path, lib_directory, out_dir = args[0], args[1], args[2]
explicit_body = args[3] if len(args) >= 4 else ""
except (IndexError, ValueError):
print("Usage: blender -b -P script.py -- <source_blend> <lib_dir> <out_dir>")
print("Usage: blender -b -P script.py -- "
"<source_blend> <lib_dir> <out_dir> [body_blend]")
return
if not os.path.exists(out_dir): os.makedirs(out_dir)
@@ -62,7 +69,7 @@ def process_batch():
# Get properties
sex = clothing.get("ref_sex")
age = clothing.get("ref_age")
age = age_profiles.normalize_age(clothing.get("ref_age"))
ref_mesh_name = clothing.get("ref_clothing")
if not all([sex, age, ref_mesh_name]):
@@ -70,19 +77,22 @@ def process_batch():
continue
# 3. Locate Reference Library
target_lib_name = f"normal_{age}_{sex}.blend"
target_lib_path = os.path.join(lib_directory, target_lib_name)
rig_name = str(sex)
if not os.path.exists(target_lib_path):
if target_lib_name == "normal_adult_male.blend":
target_lib_name = "edited-normal-male.blend"
elif target_lib_name == "normal_adult_female.blend":
target_lib_name = "edited-normal-female.blend"
rig_name = age_profiles.armature_name(age, sex)
if explicit_body and os.path.exists(explicit_body):
target_lib_path = explicit_body
else:
target_lib_name = f"normal_{age}_{sex}.blend"
target_lib_path = os.path.join(lib_directory, target_lib_name)
if not os.path.exists(target_lib_path):
print(f"Error: Library {target_lib_path} not found")
continue
if age == "adult":
fallback = {
"male": "edited-normal-male.blend",
"female": "edited-normal-female.blend",
}.get(sex, "")
target_lib_path = os.path.join(lib_directory, fallback)
if not os.path.exists(target_lib_path):
print(f"Error: Library {target_lib_path} not found")
continue
# 4. Append Weights Source and Rig
with bpy.data.libraries.load(target_lib_path) as (data_from, data_to):
@@ -0,0 +1,167 @@
#!/usr/bin/env python3
"""Structural validator for generated character age assets.
Run after the character build (the CMake target ``character-assets-check``
does this automatically)::
python3 assets/blender/characters/validate_age_assets.py \
--characters-dir build-vscode/characters
Checks:
* every age/sex stem has the expected body skeleton and catalog slots;
* catalog ages are canonical and clothing layers are present;
* no duplicate resource basenames across the flat Characters namespace;
* LOD atlas/material files exist;
* material texture references resolve to a staged texture (warning only).
"""
from __future__ import annotations
import argparse
import glob
import json
import os
import re
import sys
THIS_DIR = os.path.dirname(os.path.abspath(__file__))
if THIS_DIR not in sys.path:
sys.path.insert(0, THIS_DIR)
import age_profiles
def fail(msg, errors):
errors.append(msg)
print("FAIL: " + msg)
def warn(msg, warnings):
warnings.append(msg)
print("WARN: " + msg)
def main():
p = argparse.ArgumentParser()
p.add_argument("--characters-dir", required=True)
p.add_argument("--ages", default="",
help="semicolon/comma separated age bands to check "
"(default: all canonical bands)")
args = p.parse_args()
root = os.path.abspath(args.characters_dir)
errors = []
warnings = []
if not os.path.isdir(root):
print("FAIL: characters dir not found: " + root)
return 1
dirs = sorted(d for d in os.listdir(root)
if os.path.isdir(os.path.join(root, d)))
by_stem = {}
for d in dirs:
by_stem[d] = os.path.join(root, d)
ages = age_profiles.CANONICAL_AGES
if args.ages:
ages = [a.strip() for a in re.split(r"[;,]", args.ages)
if a.strip()]
expected_stems = []
for age in ages:
for sex in ("male", "female"):
expected_stems.append(age_profiles.stem(age, sex))
for stem in expected_stems:
d = by_stem.get(stem)
if d is None:
fail(f"missing output directory: {stem}", errors)
continue
cat_files = sorted(glob.glob(os.path.join(d, "body_part_*.json")))
if not cat_files:
fail(f"no body_part_*.json in {stem}", errors)
continue
entries = []
for f in cat_files:
try:
with open(f) as fh:
e = json.load(fh)
except Exception as exc:
fail(f"unreadable catalog {f}: {exc}", errors)
continue
entries.append(e)
ages = {age_profiles.normalize_age(e.get("age", ""))
for e in entries}
if ages != {stem.split("-")[0] if "-" in stem else "adult"}:
fail(f"{stem}: catalog ages {sorted(ages)} do not match stem",
errors)
slots = {}
for e in entries:
slots.setdefault(e.get("slot", ""), []).append(e)
for req in ("face", "top", "bottom", "feet"):
if req not in slots:
fail(f"{stem}: missing required slot '{req}'", errors)
for req in ("top", "bottom", "feet"):
if not any(int(e.get("layer", 0)) == 2
for e in slots.get(req, [])):
fail(f"{stem}: no layer-2 {req} garment", errors)
if "hair" not in slots:
warn(f"{stem}: no hair slot (bald band)", warnings)
skel = os.path.join(d, stem + ".skeleton")
if not os.path.exists(skel):
fail(f"{stem}: missing body skeleton {stem}.skeleton", errors)
atlas = os.path.join(d, f"charlod_{stem}-atlas.png")
if not os.path.exists(atlas):
fail(f"{stem}: missing LOD atlas charlod_{stem}-atlas.png",
errors)
mat = os.path.join(d, f"charlod_{stem}.material")
if not os.path.exists(mat):
warn(f"{stem}: missing LOD material charlod_{stem}.material",
warnings)
# Resource basename uniqueness across the flat Characters namespace.
seen = {}
exts = (".mesh", ".skeleton", ".material", ".png", ".json")
for d in dirs:
for f in os.listdir(os.path.join(root, d)):
if not f.endswith(exts):
continue
if f.startswith("OgreXMLConverter") or f.startswith(
"OgreMeshUpgrader"):
continue
seen.setdefault(f, []).append(d)
for name, dirs_with in sorted(seen.items()):
if len(dirs_with) > 1:
fail("duplicate resource basename '%s' in: %s" %
(name, ", ".join(dirs_with)), errors)
# Material texture references: collect staged texture names.
textures = set()
for d in dirs:
for f in os.listdir(os.path.join(root, d)):
if f.lower().endswith((".png", ".jpg", ".jpeg", ".tga",
".dds")):
textures.add(f)
for d in dirs:
for f in glob.glob(os.path.join(root, d, "*.material")):
with open(f, errors="ignore") as fh:
for line in fh:
m = re.match(r"\s*texture\s+(\S+)", line)
if not m:
continue
tex = m.group(1)
if tex not in textures:
warn(f"material {os.path.basename(f)} references "
f"missing texture {tex}", warnings)
print()
print(f"checked {len(expected_stems)} stems in {root}")
print(f"errors: {len(errors)}, warnings: {len(warnings)}")
if errors:
print("RESULT: FAIL")
return 1
print("RESULT: PASS")
return 0
if __name__ == "__main__":
sys.exit(main())
+211 -31
View File
@@ -33,6 +33,18 @@ def prepare_armature(mapping):
orig_armature = bpy.data.objects[mapping.armature_name]
armature_name = orig_armature.name
orig_armature.name = orig_armature.name + "_orig"
# Rename the original data block too, so the baked armature can use the
# exact stem as its data name (and therefore the exported skeleton file
# is `<stem>.skeleton`, not `<stem>.005.skeleton`).
orig_armature.data.name = armature_name + "_orig"
# Orphaned armature data blocks left by removed duplicate clothing-stage
# rigs still occupy the stem name; move them out of the way.
for ad in bpy.data.armatures:
if ad is orig_armature.data or ad is new_armature.data:
continue
if ad.name == armature_name or ad.name.startswith(
armature_name + "."):
ad.name = ad.name + "_unused"
new_armature.name = armature_name
queue = []
if new_armature.animation_data is None:
@@ -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')
+349 -3
View File
@@ -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)
+301
View File
@@ -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).
+17 -4
View File
@@ -13,7 +13,7 @@ foreach(VEHICLE_FILE ${VEHICLES_SRC})
add_custom_command(
OUTPUT ${VEHICLE_OUTPUT_FILE} ${CMAKE_BINARY_DIR}/resources/vehicles/${FILE_NAME}.scene
COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_BINARY_DIR}/resources/vehicles
COMMAND ${BLENDER} ${CMAKE_CURRENT_SOURCE_DIR}/${VEHICLE_FILE}
COMMAND ${BLENDER_LOCKED} ${CMAKE_CURRENT_SOURCE_DIR}/${VEHICLE_FILE}
-b -Y -P
${CMAKE_SOURCE_DIR}/assets/blender/scripts/export_vehicles.py
-- ${VEHICLE_OUTPUT_FILE}
@@ -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;
}
+9
View File
@@ -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