Phase 6.5 checkpoint
This commit is contained in:
@@ -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)
|
||||
@@ -15,23 +16,121 @@ foreach(PARTS_FILE ${PARTS_FILES})
|
||||
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} -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} --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} -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} -b
|
||||
${CMAKE_CURRENT_BINARY_DIR}/furniture_baked.blend -Y -P
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/render_furniture_gallery.py --
|
||||
${FURNITURE_GALLERY_DIR}
|
||||
${CMAKE_BINARY_DIR}/resources/buildings/parts/furniture
|
||||
COMMAND ${BLENDER} -b
|
||||
${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,232 @@
|
||||
# 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 layer fixtures 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.
|
||||
|
||||
## 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,132 @@
|
||||
"""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."""
|
||||
if any(child.name == "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
|
||||
|
||||
bpy.ops.wm.save_as_mainfile(filepath=bpy.data.filepath)
|
||||
print("Phase 6.5 furniture fixtures present.")
|
||||
@@ -1,9 +1,27 @@
|
||||
import bpy
|
||||
import os
|
||||
import sys
|
||||
|
||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
ATLAS_OUTPUT = argv[0] if argv else ""
|
||||
ATLAS_INDEX = int(argv[1]) if len(argv) > 1 else 0
|
||||
ATLAS_COUNT = int(argv[2]) if len(argv) > 2 else 1
|
||||
SUCCESS_OUTPUT = argv[3] if len(argv) > 3 else ""
|
||||
if ATLAS_INDEX < 0 or ATLAS_INDEX >= ATLAS_COUNT:
|
||||
raise RuntimeError("invalid furniture atlas band index")
|
||||
|
||||
|
||||
def is_collision_name(name):
|
||||
# Existing assets also use "-col" for some VISUAL meshes, so only the
|
||||
# explicit collision-* prefix is excluded automatically.
|
||||
return name.lower().split(".", 1)[0].startswith("collision-")
|
||||
|
||||
|
||||
def get_all_children_recursive(obj, mesh_list):
|
||||
"""Recursively finds all mesh objects in the hierarchy."""
|
||||
"""Recursively finds visual mesh objects in the hierarchy."""
|
||||
for child in obj.children:
|
||||
if child.type == 'MESH' and child.data.uv_layers:
|
||||
if child.type == 'MESH' and child.data.uv_layers and \
|
||||
not is_collision_name(child.name):
|
||||
mesh_list.append(child)
|
||||
get_all_children_recursive(child, mesh_list)
|
||||
|
||||
@@ -11,7 +29,8 @@ def setup_furniture_atlas():
|
||||
# 1. Gather valid objects: Children of empties starting with "furniture-"
|
||||
|
||||
furniture_meshes = []
|
||||
roots = [o for o in bpy.data.objects if o.name.startswith("furniture-")]
|
||||
roots = [o for o in bpy.context.scene.objects
|
||||
if o.parent is None and o.name.startswith("furniture-")]
|
||||
|
||||
for root in roots:
|
||||
# If the root itself is a mesh with UVs, add it
|
||||
@@ -20,18 +39,15 @@ def setup_furniture_atlas():
|
||||
# Find all nested children
|
||||
get_all_children_recursive(root, furniture_meshes)
|
||||
|
||||
# Remove duplicates if any (in case of complex parenting)
|
||||
furniture_meshes = list(set(furniture_meshes))
|
||||
# Remove duplicates while preserving scene order.
|
||||
seen = set()
|
||||
furniture_meshes = [obj for obj in furniture_meshes
|
||||
if not (obj.name in seen or seen.add(obj.name))]
|
||||
|
||||
if not furniture_meshes:
|
||||
print("No recursive mesh objects found with UVs.")
|
||||
return
|
||||
raise RuntimeError("No visual furniture mesh objects found with UVs.")
|
||||
|
||||
furniture_objs = furniture_meshes
|
||||
|
||||
if not furniture_objs:
|
||||
print("No valid furniture mesh objects found with UVs.")
|
||||
return
|
||||
|
||||
# 2. Manage the Atlas Image (FurnitureColor)
|
||||
image_name = "FurnitureColor"
|
||||
@@ -74,7 +90,10 @@ def setup_furniture_atlas():
|
||||
scene.render.bake.use_pass_indirect = False
|
||||
scene.render.bake.use_pass_color = True
|
||||
scene.render.bake.target = 'IMAGE_TEXTURES'
|
||||
|
||||
# Empty atlas space stays transparent; the merge step stacks the
|
||||
# per-source bakes into deterministic vertical bands.
|
||||
scene.render.film_transparent = True
|
||||
|
||||
print("Baking Furniture Atlas...")
|
||||
override = {
|
||||
'active_object': bpy.context.view_layer.objects.active,
|
||||
@@ -83,6 +102,13 @@ def setup_furniture_atlas():
|
||||
|
||||
with bpy.context.temp_override(**override):
|
||||
bpy.ops.object.bake(type='DIFFUSE', margin=2)
|
||||
|
||||
if ATLAS_OUTPUT:
|
||||
os.makedirs(os.path.dirname(ATLAS_OUTPUT), exist_ok=True)
|
||||
atlas_img.filepath_raw = ATLAS_OUTPUT
|
||||
atlas_img.file_format = 'PNG'
|
||||
atlas_img.save()
|
||||
print("Partial atlas saved to: " + ATLAS_OUTPUT)
|
||||
atlas_img.pack()
|
||||
|
||||
# 6. Create and Assign Final Atlas Material
|
||||
@@ -106,7 +132,31 @@ def setup_furniture_atlas():
|
||||
obj.data.materials.clear()
|
||||
obj.data.materials.append(atlas_mat)
|
||||
|
||||
print("Process Complete. Atlas material applied.")
|
||||
# Put this source file into its deterministic vertical atlas band.
|
||||
# The merge script stacks the per-source bakes in the same order.
|
||||
if ATLAS_COUNT > 1:
|
||||
remapped_mesh_data = set()
|
||||
for obj in furniture_objs:
|
||||
# Variants such as table1/table2 and pole1/pole2 share mesh
|
||||
# datablocks. UV layers belong to the mesh data, so remap each
|
||||
# datablock once; otherwise the band transform compounds.
|
||||
if obj.data.name in remapped_mesh_data:
|
||||
continue
|
||||
remapped_mesh_data.add(obj.data.name)
|
||||
for uv_layer in obj.data.uv_layers:
|
||||
for loop in uv_layer.data:
|
||||
u, v = loop.uv
|
||||
if u < -0.001 or u > 1.001 or \
|
||||
v < -0.001 or v > 1.001:
|
||||
raise RuntimeError(
|
||||
"Furniture UV escapes the source atlas on " +
|
||||
obj.name)
|
||||
loop.uv = (min(max(u, 0.0), 1.0),
|
||||
(min(max(v, 0.0), 1.0) + ATLAS_INDEX) /
|
||||
ATLAS_COUNT)
|
||||
|
||||
print("Process Complete. Atlas material applied; UV band %d/%d." %
|
||||
(ATLAS_INDEX + 1, ATLAS_COUNT))
|
||||
for mat in bpy.data.materials:
|
||||
if mat.users == 0:
|
||||
bpy.data.materials.remove(mat)
|
||||
@@ -118,8 +168,11 @@ def setup_furniture_atlas():
|
||||
new_path = current_path.replace(".blend", "_baked.blend")
|
||||
bpy.ops.wm.save_as_mainfile(filepath=new_path)
|
||||
print(f"File saved to: {new_path}")
|
||||
if SUCCESS_OUTPUT:
|
||||
with open(SUCCESS_OUTPUT, "w", newline="\n") as marker:
|
||||
marker.write("ok\n")
|
||||
else:
|
||||
print("Warning: File not saved (unsaved blend file).")
|
||||
raise RuntimeError("Furniture blend was not saved")
|
||||
|
||||
setup_furniture_atlas()
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
if(EXISTS "${FILE}")
|
||||
file(SIZE "${FILE}" FILE_SIZE)
|
||||
if(FILE_SIZE GREATER 0)
|
||||
message(STATUS "Build-time check: ${FILE} exists and is not empty.")
|
||||
else()
|
||||
message(FATAL_ERROR "Build-time check: ${FILE} exists but is empty!")
|
||||
endif()
|
||||
else()
|
||||
message(FATAL_ERROR "Build-time check: ${FILE} does not exist!")
|
||||
endif()
|
||||
@@ -2,7 +2,22 @@ import bpy
|
||||
import mathutils
|
||||
|
||||
ACTION_PROPS = ["name", "action", "action_text", "height", "radius", "tags"]
|
||||
FURNITURE_PROPS = ["name", "furniture_tags"]
|
||||
# Asset-local furniture metadata exported to furniture-*.json. Room
|
||||
# placement/composition deliberately stays out of these properties; the
|
||||
# layout DSL/Lua handles table sets, stores, garages and similar setups.
|
||||
FURNITURE_PROPS = [
|
||||
"furniture_id",
|
||||
"display_name",
|
||||
"furniture_tags",
|
||||
"layer",
|
||||
"selection_weight",
|
||||
"requires_floor",
|
||||
"requires_wall",
|
||||
"clearance",
|
||||
"footprint_x",
|
||||
"footprint_z",
|
||||
"collision_mesh",
|
||||
]
|
||||
|
||||
test_tracking = {"lib_name": None}
|
||||
|
||||
@@ -122,23 +137,26 @@ class OBJECT_OT_CreateFurnitureProps(bpy.types.Operator):
|
||||
|
||||
def execute(self, context):
|
||||
obj = context.object
|
||||
defaults = {
|
||||
"furniture_id": obj.name.replace("furniture-", "").strip("-"),
|
||||
"display_name": obj.name.replace("furniture-", "").replace("-", " ").title(),
|
||||
"furniture_tags": "",
|
||||
"layer": "normal",
|
||||
"selection_weight": 1.0,
|
||||
"requires_floor": True,
|
||||
"requires_wall": False,
|
||||
"clearance": 0.0,
|
||||
"footprint_x": 0, # 0 = derive from mesh bounds
|
||||
"footprint_z": 0,
|
||||
"collision_mesh": "", # optional collision-* mesh name
|
||||
}
|
||||
# Older assets only had the now-obsolete custom "name" property;
|
||||
# use it once as the stable id instead of guessing tags from names.
|
||||
if "furniture_id" not in obj and "name" in obj:
|
||||
defaults["furniture_id"] = str(obj["name"]).strip("-")
|
||||
for p in FURNITURE_PROPS:
|
||||
if p not in obj:
|
||||
if p == "name":
|
||||
obj[p] = obj.name.replace("furniture-","")
|
||||
elif p == "furniture_tags":
|
||||
obj[p] = ""
|
||||
otags = []
|
||||
if obj.name.find("toilet") >= 0:
|
||||
otags.append("wc")
|
||||
otags.append("bathroom")
|
||||
otags.append("toilet")
|
||||
elif obj.name.find("bed") >= 0:
|
||||
otags.append("bed")
|
||||
otags.append("bedroom")
|
||||
obj[p] = ", ".join(otags)
|
||||
else:
|
||||
obj[p] = "" # Initialize as empty text string
|
||||
obj[p] = defaults[p]
|
||||
return {'FINISHED'}
|
||||
|
||||
class OBJECT_OT_CreateChildEmpties(bpy.types.Operator):
|
||||
@@ -324,7 +342,7 @@ class OBJECT_PT_FurnitureEmptyPanel(bpy.types.Panel):
|
||||
else:
|
||||
# Show the text fields if properties exist
|
||||
box = layout.box()
|
||||
box.label(text="Furnitore Settings", icon='PROPERTIES')
|
||||
box.label(text="Furniture Settings", icon='PROPERTIES')
|
||||
for p in FURNITURE_PROPS:
|
||||
box.prop(obj, f'["{p}"]', text=p.replace("_", " ").title())
|
||||
op_use = layout.operator("object.create_child_action_node", icon='EMPTY_DATA', text="Add Use Action Node")
|
||||
@@ -346,8 +364,8 @@ def register():
|
||||
|
||||
|
||||
def unregister():
|
||||
bpy.utils.register_class(OBJECT_OT_CreateActionProps)
|
||||
bpy.utils.register_class(OBJECT_OT_CreateFurnitureProps)
|
||||
bpy.utils.unregister_class(OBJECT_OT_CreateActionProps)
|
||||
bpy.utils.unregister_class(OBJECT_OT_CreateFurnitureProps)
|
||||
bpy.utils.unregister_class(OBJECT_PT_ActionEmptyPanel)
|
||||
bpy.utils.unregister_class(OBJECT_OT_CreateChildEmpties)
|
||||
bpy.utils.unregister_class(OBJECT_OT_DestroyChildByProp)
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import bpy
|
||||
import os, sys, time
|
||||
import json
|
||||
from mathutils import Matrix, Quaternion
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
from mathutils import Matrix, Quaternion, Vector
|
||||
|
||||
argv = sys.argv
|
||||
argv = argv[argv.index("--") + 1:]
|
||||
|
||||
incpath = os.path.dirname(__file__)
|
||||
|
||||
sys.path.insert(0, incpath)
|
||||
outdir = argv[0]
|
||||
SUCCESS_OUTPUT = argv[1] if len(argv) > 1 else ""
|
||||
|
||||
basis_change = Matrix((
|
||||
(1, 0, 0, 0),
|
||||
@@ -17,180 +19,317 @@ basis_change = Matrix((
|
||||
(0, 0, 0, 1)
|
||||
))
|
||||
|
||||
def export_root_objects_to_gltf(output_dir):
|
||||
# Ensure the output directory exists
|
||||
if not os.path.exists(output_dir):
|
||||
os.makedirs(output_dir)
|
||||
LAYERS = {"normal", "thin-floor", "thin-wall", "ceiling"}
|
||||
ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
|
||||
|
||||
# Deselect all objects
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
|
||||
# Iterate through all objects in the scene
|
||||
for obj in bpy.context.scene.objects:
|
||||
# Check if the object has no parent and has children
|
||||
# The original request specifies "objects having no parent with children".
|
||||
# This condition captures those that are explicitly root objects of a hierarchy.
|
||||
if obj.parent is None:
|
||||
# Select the root object and all its children
|
||||
def round4(value):
|
||||
return round(float(value), 4)
|
||||
|
||||
print(obj.name)
|
||||
if not obj.name.startswith("furniture-"):
|
||||
continue
|
||||
desc = {}
|
||||
desc["name"] = obj.name.replace("furniture-","")
|
||||
desc["mesh"] = obj.name + ".glb"
|
||||
if "furniture_tags" in obj:
|
||||
mtags = obj["furniture_tags"]
|
||||
if "," in mtags:
|
||||
atags = [m.strip() for m in mtags.split(",")]
|
||||
desc["tags"] = atags
|
||||
else:
|
||||
desc["tags"] = [mtags]
|
||||
|
||||
def prop(root, name, default):
|
||||
return root[name] if name in root else default
|
||||
|
||||
|
||||
def stable_id(root):
|
||||
custom = str(prop(root, "furniture_id", "")).strip().lower()
|
||||
legacy_suffix = root.name.replace("furniture-", "")
|
||||
raw = custom or legacy_suffix.strip("-").lower()
|
||||
if not custom and legacy_suffix.startswith("-"):
|
||||
# Keep the accidental furniture--<name> asset addressable without
|
||||
# colliding with the canonical furniture-<name> root.
|
||||
raw += "-legacy"
|
||||
if not raw or not ID_PATTERN.match(raw):
|
||||
raise ValueError("invalid furniture id %r on %s" % (raw, root.name))
|
||||
return raw
|
||||
|
||||
|
||||
def parse_tags(value):
|
||||
# Existing blends use comma-separated custom-property strings. Also
|
||||
# accept semicolons and the legacy "tag. tag" typo, normalize case and
|
||||
# drop empty/duplicate entries while preserving order.
|
||||
result = []
|
||||
seen = set()
|
||||
for part in re.split(r"[,;]|\.\s+", str(value)):
|
||||
tag = part.strip().lower()
|
||||
if not tag:
|
||||
continue
|
||||
if not re.match(r"^[a-z0-9][a-z0-9._-]*$", tag):
|
||||
raise ValueError("invalid furniture tag %r" % tag)
|
||||
if tag not in seen:
|
||||
seen.add(tag)
|
||||
result.append(tag)
|
||||
return result
|
||||
|
||||
|
||||
def iter_descendants(root):
|
||||
stack = list(root.children)
|
||||
while stack:
|
||||
obj = stack.pop(0)
|
||||
yield obj
|
||||
stack.extend(obj.children)
|
||||
|
||||
|
||||
def is_collision_object(obj):
|
||||
# Existing assets also use "-col" for some VISUAL meshes, so only the
|
||||
# explicit collision-* prefix is excluded automatically.
|
||||
return obj.name.lower().split(".", 1)[0].startswith("collision-")
|
||||
|
||||
|
||||
def is_visual_mesh(obj):
|
||||
return obj.type == 'MESH' and not is_collision_object(obj)
|
||||
|
||||
|
||||
def bounds_in_root_space(root, objects):
|
||||
root_inv = root.matrix_world.inverted()
|
||||
minimum = None
|
||||
maximum = None
|
||||
for obj in objects:
|
||||
if len(obj.bound_box) == 0:
|
||||
continue
|
||||
matrix = root_inv @ obj.matrix_world
|
||||
for corner in obj.bound_box:
|
||||
local = basis_change @ (matrix @ Vector(corner))
|
||||
if minimum is None:
|
||||
minimum = local.copy()
|
||||
maximum = local.copy()
|
||||
else:
|
||||
desc["tags"] = []
|
||||
desc["sensors"] = []
|
||||
desc["actions"] = []
|
||||
desc["positions"] = []
|
||||
for child in obj.children:
|
||||
if child.name.startswith("action-"):
|
||||
if not "action" in child:
|
||||
continue
|
||||
if not "height" in child:
|
||||
continue
|
||||
if not "radius" in child:
|
||||
continue
|
||||
local_matrix = child.matrix_local
|
||||
ogre_local_matrix = basis_change @ local_matrix @ basis_change.inverted()
|
||||
local_pos_d = ogre_local_matrix.to_translation()
|
||||
local_rot_d = ogre_local_matrix.to_quaternion()
|
||||
local_pos = [round(x, 4) for x in local_pos_d]
|
||||
local_rot = [round(x, 4) for x in local_rot_d]
|
||||
action = {}
|
||||
action["position_x"] = local_pos[0]
|
||||
action["position_y"] = local_pos[1]
|
||||
action["position_z"] = local_pos[2]
|
||||
action["rotation_w"] = local_rot[0]
|
||||
action["rotation_x"] = local_rot[1]
|
||||
action["rotation_y"] = local_rot[2]
|
||||
action["rotation_z"] = local_rot[3]
|
||||
action["height"] = child["height"]
|
||||
action["radius"] = child["radius"]
|
||||
action["action"] = child["action"]
|
||||
if "action_text" in child:
|
||||
action["action_text"] = child["action_text"]
|
||||
else:
|
||||
action["action_text"] = child["action"].capitalize()
|
||||
if "name" in child:
|
||||
action["name"] = child["name"]
|
||||
else:
|
||||
action["name"] = desc["name"] + "_" + str(len(desc["actions"]))
|
||||
if "tags" in child:
|
||||
atags = child["tags"]
|
||||
if "," in atags:
|
||||
atags = [m.strip() for m in atags.split(",")]
|
||||
action["tags"] = atags
|
||||
else:
|
||||
action["tags"] = [atags]
|
||||
else:
|
||||
action["tags"] = []
|
||||
for k, v in child.items():
|
||||
if k.startswith("goap_prereq_"):
|
||||
action[k] = v
|
||||
elif k.startswith("goap_effect_"):
|
||||
action[k] = v
|
||||
action["furniture"] = {}
|
||||
action["furniture"]["name"] = desc["name"]
|
||||
action["furniture"]["tags"] = desc["tags"]
|
||||
action["positions"] = []
|
||||
for schild in child.children:
|
||||
if schild.name.startswith("position-"):
|
||||
local_matrix = schild.matrix_local
|
||||
ogre_local_matrix = basis_change @ local_matrix @ basis_change.inverted()
|
||||
local_pos_d = ogre_local_matrix.to_translation()
|
||||
local_rot_d = ogre_local_matrix.to_quaternion()
|
||||
local_pos = [round(x, 4) for x in local_pos_d]
|
||||
local_rot = [round(x, 4) for x in local_rot_d]
|
||||
position = {}
|
||||
if "name" in schild:
|
||||
position["name"] = schild["name"]
|
||||
if "type" in schild:
|
||||
position["type"] = schild["type"]
|
||||
position["position_x"] = local_pos[0]
|
||||
position["position_y"] = local_pos[1]
|
||||
position["position_z"] = local_pos[2]
|
||||
position["rotation_w"] = local_rot[0]
|
||||
position["rotation_x"] = local_rot[1]
|
||||
position["rotation_y"] = local_rot[2]
|
||||
position["rotation_z"] = local_rot[3]
|
||||
action["positions"].append(position)
|
||||
desc["actions"].append(action)
|
||||
if child.name.startswith("position-"):
|
||||
local_matrix = child.matrix_local
|
||||
ogre_local_matrix = basis_change @ local_matrix @ basis_change.inverted()
|
||||
local_pos_d = ogre_local_matrix.to_translation()
|
||||
local_rot_d = ogre_local_matrix.to_quaternion()
|
||||
local_pos = [round(x, 4) for x in local_pos_d]
|
||||
local_rot = [round(x, 4) for x in local_rot_d]
|
||||
position = {}
|
||||
if "name" in child:
|
||||
position["name"] = child["name"]
|
||||
if "type" in child:
|
||||
position["type"] = child["type"]
|
||||
position["position_x"] = local_pos[0]
|
||||
position["position_y"] = local_pos[1]
|
||||
position["position_z"] = local_pos[2]
|
||||
position["rotation_w"] = local_rot[0]
|
||||
position["rotation_x"] = local_rot[1]
|
||||
position["rotation_y"] = local_rot[2]
|
||||
position["rotation_z"] = local_rot[3]
|
||||
desc["positions"].append(position)
|
||||
minimum.x = min(minimum.x, local.x)
|
||||
minimum.y = min(minimum.y, local.y)
|
||||
minimum.z = min(minimum.z, local.z)
|
||||
maximum.x = max(maximum.x, local.x)
|
||||
maximum.y = max(maximum.y, local.y)
|
||||
maximum.z = max(maximum.z, local.z)
|
||||
if minimum is None:
|
||||
return None
|
||||
return ([round4(minimum.x), round4(minimum.y), round4(minimum.z)],
|
||||
[round4(maximum.x), round4(maximum.y), round4(maximum.z)])
|
||||
|
||||
obj.select_set(True)
|
||||
for child in obj.children_recursive:
|
||||
child.select_set(True)
|
||||
|
||||
obj.location = (0.0, 0.0, 0.0)
|
||||
|
||||
# Set the root object as the active object for the export operator
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
def transform_json(root, obj):
|
||||
local_matrix = root.matrix_world.inverted() @ obj.matrix_world
|
||||
ogre_local_matrix = basis_change @ local_matrix @ basis_change.inverted()
|
||||
local_pos = ogre_local_matrix.to_translation()
|
||||
local_rot = ogre_local_matrix.to_quaternion()
|
||||
return [round4(x) for x in local_pos], [round4(x) for x in local_rot]
|
||||
|
||||
# Define the output file path
|
||||
file_path = os.path.join(output_dir, f"{obj.name}.glb")
|
||||
|
||||
# Export the selected objects to a glTF file
|
||||
bpy.ops.export_scene.gltf(filepath=file_path,
|
||||
use_selection=True,
|
||||
check_existing=False,
|
||||
export_format='GLB',
|
||||
export_texture_dir='textures', export_texcoords=True,
|
||||
export_normals=True,
|
||||
export_tangents=True,
|
||||
export_materials='EXPORT',
|
||||
export_colors=True,
|
||||
use_mesh_edges=False,
|
||||
use_mesh_vertices=False,
|
||||
export_cameras=False,
|
||||
use_visible=False,
|
||||
use_renderable=False,
|
||||
export_yup=True,
|
||||
export_apply=True,
|
||||
export_animations=True,
|
||||
export_force_sampling=True,
|
||||
export_def_bones=False,
|
||||
export_current_frame=False,
|
||||
export_morph=True,
|
||||
export_morph_animation=False,
|
||||
export_morph_normal=True,
|
||||
export_morph_tangent=True,
|
||||
export_lights=False,
|
||||
export_skins=True)
|
||||
|
||||
# Deselect all objects for the next iteration
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
|
||||
print(f"Exported {obj.name} and its children to {file_path}")
|
||||
with open(file_path + ".json", "w") as json_data:
|
||||
json.dump(desc, json_data)
|
||||
def position_json(root, child):
|
||||
pos, rot = transform_json(root, child)
|
||||
position = {}
|
||||
if "name" in child:
|
||||
position["name"] = str(child["name"])
|
||||
if "type" in child:
|
||||
position["type"] = str(child["type"])
|
||||
position["position_x"] = pos[0]
|
||||
position["position_y"] = pos[1]
|
||||
position["position_z"] = pos[2]
|
||||
position["rotation_w"] = rot[0]
|
||||
position["rotation_x"] = rot[1]
|
||||
position["rotation_y"] = rot[2]
|
||||
position["rotation_z"] = rot[3]
|
||||
return position
|
||||
|
||||
|
||||
def action_json(root, child, furniture_id, furniture_tags):
|
||||
for required in ("action", "height", "radius"):
|
||||
if required not in child:
|
||||
raise ValueError("action %s on %s lacks %s" %
|
||||
(child.name, root.name, required))
|
||||
pos, rot = transform_json(root, child)
|
||||
height = float(child["height"])
|
||||
radius = float(child["radius"])
|
||||
if height <= 0.0 or radius <= 0.0:
|
||||
raise ValueError("action %s on %s has non-positive dimensions" %
|
||||
(child.name, root.name))
|
||||
|
||||
action = {}
|
||||
action["position_x"] = pos[0]
|
||||
action["position_y"] = pos[1]
|
||||
action["position_z"] = pos[2]
|
||||
action["rotation_w"] = rot[0]
|
||||
action["rotation_x"] = rot[1]
|
||||
action["rotation_y"] = rot[2]
|
||||
action["rotation_z"] = rot[3]
|
||||
action["height"] = height
|
||||
action["radius"] = radius
|
||||
action["action"] = str(child["action"])
|
||||
action["action_text"] = str(child.get("action_text",
|
||||
action["action"].capitalize()))
|
||||
action["name"] = str(child.get("name",
|
||||
furniture_id + "_" + str(len(root.children))))
|
||||
action["tags"] = parse_tags(child.get("tags", ""))
|
||||
for key, value in child.items():
|
||||
if key.startswith("goap_prereq_") or key.startswith("goap_effect_"):
|
||||
action[key] = value
|
||||
action["furniture"] = {"name": furniture_id, "tags": furniture_tags}
|
||||
action["positions"] = []
|
||||
for position in child.children:
|
||||
if position.name.startswith("position-"):
|
||||
action["positions"].append(position_json(child, position))
|
||||
return action
|
||||
|
||||
|
||||
def content_hash(desc):
|
||||
payload = json.dumps(desc, sort_keys=True, separators=(",", ":"),
|
||||
ensure_ascii=False)
|
||||
value = 14695981039346656037
|
||||
for byte in payload.encode("utf-8"):
|
||||
value ^= byte
|
||||
value = (value * 1099511628211) & 0xffffffffffffffff
|
||||
return "%016x" % value
|
||||
|
||||
|
||||
def export_root_objects_to_gltf(output_dir):
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
exported = []
|
||||
|
||||
for obj in bpy.context.scene.objects:
|
||||
if obj.parent is not None or not obj.name.startswith("furniture-"):
|
||||
continue
|
||||
|
||||
print(obj.name)
|
||||
furniture_id = stable_id(obj)
|
||||
tags = parse_tags(prop(obj, "furniture_tags", ""))
|
||||
layer = str(prop(obj, "layer", "normal")).strip().lower()
|
||||
if layer not in LAYERS:
|
||||
raise ValueError("%s has unknown layer %r" % (obj.name, layer))
|
||||
selection_weight = float(prop(obj, "selection_weight", 1.0))
|
||||
clearance = float(prop(obj, "clearance", 0.0))
|
||||
if selection_weight < 0.0 or clearance < 0.0:
|
||||
raise ValueError("%s has negative weight/clearance" % obj.name)
|
||||
footprint_x = int(prop(obj, "footprint_x", 0))
|
||||
footprint_z = int(prop(obj, "footprint_z", 0))
|
||||
if (footprint_x == 0) != (footprint_z == 0):
|
||||
raise ValueError("%s needs both footprint axes or neither" %
|
||||
obj.name)
|
||||
if footprint_x < 0 or footprint_z < 0:
|
||||
raise ValueError("%s has a negative footprint" % obj.name)
|
||||
|
||||
descendants = list(iter_descendants(obj))
|
||||
visual_meshes = [child for child in descendants if is_visual_mesh(child)]
|
||||
if not visual_meshes:
|
||||
raise ValueError("%s has no visual mesh children" % obj.name)
|
||||
collision_name = str(prop(obj, "collision_mesh", "")).strip()
|
||||
collision_meshes = [child for child in descendants
|
||||
if child.type == 'MESH' and
|
||||
(is_collision_object(child) or
|
||||
(collision_name and child.name == collision_name))]
|
||||
if collision_name and not collision_meshes:
|
||||
raise ValueError("%s references missing collision mesh %s" %
|
||||
(obj.name, collision_name))
|
||||
|
||||
desc = {
|
||||
"schemaVersion": 1,
|
||||
"id": furniture_id,
|
||||
# `name` is the legacy runtime identifier. Keep it equal to
|
||||
# the stable id so old FurnitureCell records continue to work.
|
||||
"name": furniture_id,
|
||||
"displayName": str(prop(obj, "display_name", furniture_id)),
|
||||
"mesh": obj.name + ".glb",
|
||||
"materialName": "M_Furniture_Atlas",
|
||||
"atlas": "furniture-atlas.png",
|
||||
"source": "blender",
|
||||
"layer": layer,
|
||||
"tags": tags,
|
||||
"anchor": [0.0, 0.0, 0.0],
|
||||
"requiresFloor": bool(prop(obj, "requires_floor", True)),
|
||||
"requiresWall": bool(prop(obj, "requires_wall", False)),
|
||||
"clearance": clearance,
|
||||
"weight": selection_weight,
|
||||
"sensors": [],
|
||||
"actions": [],
|
||||
"positions": [],
|
||||
}
|
||||
|
||||
legacy_suffix = obj.name.replace("furniture-", "")
|
||||
aliases = []
|
||||
if legacy_suffix != furniture_id:
|
||||
aliases.append(legacy_suffix)
|
||||
legacy_custom_name = str(obj.get("name", "")).strip()
|
||||
if legacy_custom_name and legacy_custom_name != furniture_id and \
|
||||
legacy_custom_name not in aliases:
|
||||
aliases.append(legacy_custom_name)
|
||||
if aliases:
|
||||
desc["aliases"] = aliases
|
||||
|
||||
bounds = bounds_in_root_space(obj, visual_meshes)
|
||||
if bounds:
|
||||
desc["bounds"] = {"min": bounds[0], "max": bounds[1]}
|
||||
collision_bounds = bounds_in_root_space(obj, collision_meshes)
|
||||
if collision_bounds:
|
||||
desc["collisionBounds"] = {"min": collision_bounds[0],
|
||||
"max": collision_bounds[1]}
|
||||
if footprint_x > 0:
|
||||
desc["footprintCells"] = [footprint_x, footprint_z]
|
||||
|
||||
for child in obj.children:
|
||||
if child.name.startswith("action-"):
|
||||
desc["actions"].append(
|
||||
action_json(obj, child, furniture_id, tags))
|
||||
elif child.name.startswith("position-"):
|
||||
desc["positions"].append(position_json(obj, child))
|
||||
|
||||
desc["sourceHash"] = content_hash(desc)
|
||||
|
||||
# Export only the visual hierarchy. action-/position- empties are
|
||||
# metadata and collision-* meshes are physics bounds, not render
|
||||
# content.
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
obj.select_set(True)
|
||||
for child in descendants:
|
||||
if child.name.startswith("action-") or \
|
||||
child.name.startswith("position-") or \
|
||||
is_collision_object(child):
|
||||
continue
|
||||
child.select_set(True)
|
||||
|
||||
obj.location = (0.0, 0.0, 0.0)
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
|
||||
file_path = os.path.join(output_dir, obj.name + ".glb")
|
||||
bpy.ops.export_scene.gltf(filepath=file_path,
|
||||
use_selection=True,
|
||||
check_existing=False,
|
||||
export_format='GLB',
|
||||
export_texture_dir='textures',
|
||||
export_texcoords=True,
|
||||
export_normals=True,
|
||||
export_tangents=True,
|
||||
export_materials='EXPORT',
|
||||
export_image_format='NONE',
|
||||
export_colors=True,
|
||||
use_mesh_edges=False,
|
||||
use_mesh_vertices=False,
|
||||
export_cameras=False,
|
||||
use_visible=False,
|
||||
use_renderable=False,
|
||||
export_yup=True,
|
||||
export_apply=True,
|
||||
export_animations=True,
|
||||
export_force_sampling=True,
|
||||
export_def_bones=False,
|
||||
export_current_frame=False,
|
||||
export_morph=True,
|
||||
export_morph_animation=False,
|
||||
export_morph_normal=True,
|
||||
export_morph_tangent=True,
|
||||
export_lights=False,
|
||||
export_skins=True)
|
||||
|
||||
with open(file_path + ".json", "w", newline="\n") as json_data:
|
||||
json.dump(desc, json_data, indent=2, sort_keys=True)
|
||||
json_data.write("\n")
|
||||
exported.append((furniture_id, file_path))
|
||||
print("Exported %s to %s" % (furniture_id, file_path))
|
||||
|
||||
if not exported:
|
||||
raise RuntimeError("no furniture-* root objects found in " +
|
||||
bpy.data.filepath)
|
||||
if SUCCESS_OUTPUT:
|
||||
with open(SUCCESS_OUTPUT, "w", newline="\n") as marker:
|
||||
marker.write("ok\n")
|
||||
return exported
|
||||
|
||||
|
||||
export_root_objects_to_gltf(outdir)
|
||||
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Write the furniture gallery's static HTML index (stdlib only)."""
|
||||
|
||||
import argparse
|
||||
import html
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--root", required=True)
|
||||
parser.add_argument("--atlas", default="../furniture-atlas.png")
|
||||
args = parser.parse_args()
|
||||
root = Path(args.root)
|
||||
entries = []
|
||||
for manifest in sorted(root.glob("manifest-*.json")):
|
||||
entries.extend(json.loads(manifest.read_text()))
|
||||
entries.sort(key=lambda entry: entry["id"])
|
||||
|
||||
rows = []
|
||||
for entry in entries:
|
||||
u0, u1, v0, v1 = entry["uv"]
|
||||
style = (
|
||||
"left:%.4f%%;top:%.4f%%;width:%.4f%%;height:%.4f%%" %
|
||||
(u0 * 100.0, (1.0 - v1) * 100.0,
|
||||
(u1 - u0) * 100.0, (v1 - v0) * 100.0))
|
||||
bounds = entry.get("bounds")
|
||||
bounds_text = "none"
|
||||
if bounds:
|
||||
bounds_text = "%s .. %s" % (bounds["min"], bounds["max"])
|
||||
rows.append(
|
||||
"<section class='asset'><h2>%s</h2>"
|
||||
"<p><b>Layer:</b> %s <b>Tags:</b> %s</p>"
|
||||
"<p class='bounds'><b>Bounds:</b> %s%s</p>"
|
||||
"<div class='views'>"
|
||||
"<div><h3>Asset / pivot / bounds / POIs</h3>"
|
||||
"<img class='render' src='%s'></div>"
|
||||
"<div><h3>Atlas UV region</h3>"
|
||||
"<div class='atlas'><img src='%s'>"
|
||||
"<div class='uv' style='%s'></div></div></div>"
|
||||
"</div></section>" %
|
||||
(html.escape(entry["id"]), html.escape(entry["layer"]),
|
||||
html.escape(", ".join(entry["tags"])),
|
||||
html.escape(bounds_text),
|
||||
" + collision proxy" if entry.get("collisionBounds") else "",
|
||||
html.escape(entry["render"]), html.escape(args.atlas), style))
|
||||
|
||||
page = """<!doctype html>
|
||||
<meta charset='utf-8'>
|
||||
<title>Furniture export gallery</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; background: #202124; color: #ddd;
|
||||
margin: 2em; }
|
||||
.asset { border-top: 1px solid #555; padding-top: 1em; margin-top: 2em; }
|
||||
.views { display: flex; gap: 2em; align-items: flex-start; }
|
||||
.views > div { flex: 1; min-width: 0; }
|
||||
.render { width: 100%; image-rendering: auto; background: #111; }
|
||||
.atlas { position: relative; width: 100%; }
|
||||
.atlas img { width: 100%; display: block; }
|
||||
.uv { position: absolute; border: 3px solid #f33; box-sizing: border-box;
|
||||
pointer-events: none; }
|
||||
.bounds { color: #aaa; font-size: 0.9em; }
|
||||
</style>
|
||||
<h1>Furniture export gallery</h1>
|
||||
<p>Generated from the baked Blender sources. Green = physical bounds,
|
||||
red = collision bounds, blue = occupancy cells, axes = pivot, spheres =
|
||||
action/position markers. The red rectangle on each atlas shows the
|
||||
asset's UV region.</p>
|
||||
""" + "\n".join(rows)
|
||||
(root / "index.html").write_text(page)
|
||||
print("Furniture gallery index: %d assets" % len(entries))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,79 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
|
||||
# Stack deterministic per-blend furniture atlas bakes into one shared atlas.
|
||||
# bake_furniture.py remaps each source's UVs into the matching vertical band.
|
||||
#
|
||||
# Usage:
|
||||
# blender --factory-startup -b -P merge_furniture_atlases.py -- \
|
||||
# <atlas.png> <atlas.material> <part0.png> [part1.png ...]
|
||||
|
||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
if len(argv) < 3:
|
||||
raise RuntimeError(
|
||||
"merge_furniture_atlases.py needs output png/material and inputs")
|
||||
|
||||
out_png = argv[0]
|
||||
out_material = argv[1]
|
||||
inputs = argv[2:]
|
||||
|
||||
images = []
|
||||
width = None
|
||||
part_height = None
|
||||
for path in inputs:
|
||||
if not os.path.exists(path):
|
||||
raise RuntimeError("missing furniture atlas part: " + path)
|
||||
image = bpy.data.images.load(path, check_existing=False)
|
||||
images.append((path, image))
|
||||
if width is None:
|
||||
width, part_height = image.size
|
||||
elif image.size[0] != width or image.size[1] != part_height:
|
||||
raise RuntimeError("atlas size mismatch in " + path)
|
||||
|
||||
if width is None or width <= 0 or part_height <= 0:
|
||||
raise RuntimeError("no furniture atlas parts supplied")
|
||||
|
||||
part_pixels = width * part_height * 4
|
||||
merged = []
|
||||
for path, image in images:
|
||||
pixels = list(image.pixels)
|
||||
if len(pixels) != part_pixels:
|
||||
raise RuntimeError("unexpected pixel count in " + path)
|
||||
coverage = sum(1 for i in range(3, len(pixels), 4) if pixels[i] > 0.0)
|
||||
if coverage == 0:
|
||||
raise RuntimeError("furniture atlas part is empty: " + path)
|
||||
merged.extend(pixels)
|
||||
|
||||
result = bpy.data.images.new("FurnitureColor", width=width,
|
||||
height=part_height * len(images), alpha=True)
|
||||
result.pixels = merged
|
||||
os.makedirs(os.path.dirname(out_png), exist_ok=True)
|
||||
os.makedirs(os.path.dirname(out_material), exist_ok=True)
|
||||
result.filepath_raw = out_png
|
||||
result.file_format = 'PNG'
|
||||
result.save()
|
||||
|
||||
material = """// Generated by merge_furniture_atlases.py; do not edit.
|
||||
material M_Furniture_Atlas
|
||||
{
|
||||
technique
|
||||
{
|
||||
pass
|
||||
{
|
||||
texture_unit
|
||||
{
|
||||
texture furniture-atlas.png
|
||||
filtering linear linear linear
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
with open(out_material, "w", newline="\n") as file:
|
||||
file.write(material)
|
||||
|
||||
print("Furniture atlas merged: %d source band(s), %dx%d -> %s" %
|
||||
(len(images), width, part_height * len(images), out_png))
|
||||
print("Furniture material written: " + out_material)
|
||||
@@ -0,0 +1,253 @@
|
||||
"""Render one preview image per furniture root plus a gallery manifest.
|
||||
|
||||
Invoke on a *_baked.blend from the furniture pipeline:
|
||||
blender -b furniture_baked.blend -Y -P render_furniture_gallery.py -- \
|
||||
<gallery-dir> <definition-dir>
|
||||
|
||||
The manifest is consumed by generate_furniture_gallery_index.py. Rendering
|
||||
uses Cycles CPU so it also works on headless build machines.
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
from mathutils import Vector
|
||||
|
||||
argv = sys.argv[sys.argv.index("--") + 1:]
|
||||
GALLERY_DIR = argv[0]
|
||||
DEFINITION_DIR = argv[1]
|
||||
os.makedirs(GALLERY_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def ogre_to_blender(v):
|
||||
return Vector((v[0], -v[2], v[1]))
|
||||
|
||||
|
||||
def material(name, color, emission=0.0):
|
||||
mat = bpy.data.materials.get(name)
|
||||
if mat:
|
||||
return mat
|
||||
mat = bpy.data.materials.new(name)
|
||||
mat.use_nodes = True
|
||||
bsdf = mat.node_tree.nodes.get("Principled BSDF")
|
||||
bsdf.inputs["Base Color"].default_value = (*color, 1.0)
|
||||
bsdf.inputs["Roughness"].default_value = 0.8
|
||||
if emission:
|
||||
bsdf.inputs["Emission"].default_value = (*color, 1.0)
|
||||
bsdf.inputs["Emission Strength"].default_value = emission
|
||||
return mat
|
||||
|
||||
|
||||
bounds_mat = material("GalleryBounds", (0.1, 0.8, 0.2), 1.0)
|
||||
collision_mat = material("GalleryCollision", (0.9, 0.1, 0.1), 1.0)
|
||||
cell_mat = material("GalleryCells", (0.1, 0.4, 0.9), 0.5)
|
||||
pivot_mats = [material("GalleryX", (1, 0, 0), 1.0),
|
||||
material("GalleryY", (0, 1, 0), 1.0),
|
||||
material("GalleryZ", (0, 0, 1), 1.0)]
|
||||
action_mat = material("GalleryAction", (1.0, 0.5, 0.05), 1.0)
|
||||
position_mat = material("GalleryPosition", (0.2, 0.6, 1.0), 1.0)
|
||||
floor_mat = material("GalleryFloor", (0.18, 0.18, 0.18))
|
||||
|
||||
scene = bpy.context.scene
|
||||
scene.render.engine = 'CYCLES'
|
||||
scene.cycles.device = 'CPU'
|
||||
scene.cycles.samples = 4
|
||||
scene.render.resolution_x = 480
|
||||
scene.render.resolution_y = 360
|
||||
scene.render.resolution_percentage = 100
|
||||
scene.render.image_settings.file_format = 'PNG'
|
||||
scene.render.film_transparent = False
|
||||
|
||||
# Neutral floor, camera and lights are shared by every render.
|
||||
bpy.ops.mesh.primitive_plane_add(size=200.0, location=(0, 0, -0.03))
|
||||
floor = bpy.context.object
|
||||
floor.name = "GalleryFloor"
|
||||
floor.data.materials.append(floor_mat)
|
||||
|
||||
bpy.ops.object.camera_add()
|
||||
camera = bpy.context.object
|
||||
scene.camera = camera
|
||||
camera.data.lens = 50
|
||||
|
||||
for name, location, energy, size in (
|
||||
("Key", (3, 4, 6), 600, 4),
|
||||
("Fill", (-4, 1, 3), 350, 4),
|
||||
("Rim", (0, -4, 4), 450, 3)):
|
||||
bpy.ops.object.light_add(type='AREA', location=location)
|
||||
lamp = bpy.context.object
|
||||
lamp.name = "Gallery" + name
|
||||
lamp.data.energy = energy
|
||||
lamp.data.size = size
|
||||
|
||||
|
||||
def hide_tree(obj, hide):
|
||||
obj.hide_render = hide
|
||||
for child in obj.children:
|
||||
hide_tree(child, hide)
|
||||
|
||||
|
||||
def line_object(name, points, mat, width=0.008):
|
||||
curve = bpy.data.curves.new(name, 'CURVE')
|
||||
curve.dimensions = '3D'
|
||||
curve.bevel_depth = width
|
||||
curve.bevel_resolution = 1
|
||||
spline = curve.splines.new('POLY')
|
||||
spline.points.add(len(points) - 1)
|
||||
for point, co in zip(spline.points, points):
|
||||
point.co = (*co, 1.0)
|
||||
obj = bpy.data.objects.new(name, curve)
|
||||
scene.collection.objects.link(obj)
|
||||
obj.data.materials.append(mat)
|
||||
return obj
|
||||
|
||||
|
||||
def box_edges(name, minimum, maximum, mat):
|
||||
corners = []
|
||||
for x in (minimum[0], maximum[0]):
|
||||
for y in (minimum[1], maximum[1]):
|
||||
for z in (minimum[2], maximum[2]):
|
||||
corners.append(Vector((x, y, z)))
|
||||
indices = [(0, 1), (0, 2), (0, 4), (1, 3), (1, 5), (2, 3),
|
||||
(2, 6), (3, 7), (4, 5), (4, 6), (5, 7), (6, 7)]
|
||||
for index, (a, b) in enumerate(indices):
|
||||
line_object(name + str(index), [corners[a], corners[b]], mat)
|
||||
|
||||
|
||||
def sphere(name, position, mat, radius):
|
||||
bpy.ops.mesh.primitive_uv_sphere_add(segments=12, ring_count=6,
|
||||
radius=radius, location=position)
|
||||
obj = bpy.context.object
|
||||
obj.name = name
|
||||
obj.data.materials.append(mat)
|
||||
return obj
|
||||
|
||||
|
||||
def uv_bounds(root):
|
||||
result = [1.0, 0.0, 1.0, 0.0]
|
||||
found = False
|
||||
stack = list(root.children)
|
||||
while stack:
|
||||
obj = stack.pop(0)
|
||||
stack.extend(obj.children)
|
||||
if obj.type != 'MESH' or obj.name.startswith("collision-"):
|
||||
continue
|
||||
for layer in obj.data.uv_layers:
|
||||
for loop in layer.data:
|
||||
u, v = loop.uv
|
||||
result[0] = min(result[0], u)
|
||||
result[1] = max(result[1], u)
|
||||
result[2] = min(result[2], v)
|
||||
result[3] = max(result[3], v)
|
||||
found = True
|
||||
return result if found else [0.0, 1.0, 0.0, 1.0]
|
||||
|
||||
|
||||
roots = [obj for obj in scene.objects
|
||||
if obj.parent is None and obj.name.startswith("furniture-")]
|
||||
manifest = []
|
||||
for root in sorted(roots, key=lambda obj: obj.name):
|
||||
definition_path = os.path.join(DEFINITION_DIR, root.name + ".glb.json")
|
||||
with open(definition_path) as source:
|
||||
definition = json.load(source)
|
||||
|
||||
# Definitions and GLBs are exported with the root at the origin;
|
||||
# use the same pivot here (many source roots have authoring offsets).
|
||||
root.location = (0.0, 0.0, 0.0)
|
||||
for other in roots:
|
||||
hide_tree(other, True)
|
||||
hide_tree(root, False)
|
||||
|
||||
helpers_start = set(scene.objects)
|
||||
bounds = definition.get("bounds")
|
||||
if bounds:
|
||||
minimum = ogre_to_blender(bounds["min"])
|
||||
maximum = ogre_to_blender(bounds["max"])
|
||||
# ogre_to_blender swaps Y/Z and negates one axis; reorder.
|
||||
low = Vector(tuple(min(a, b) for a, b in zip(minimum, maximum)))
|
||||
high = Vector(tuple(max(a, b) for a, b in zip(minimum, maximum)))
|
||||
box_edges("PhysicalBounds", low, high, bounds_mat)
|
||||
center = (low + high) * 0.5
|
||||
size = high - low
|
||||
else:
|
||||
center = Vector((0, 0, 0.5))
|
||||
size = Vector((1, 1, 1))
|
||||
|
||||
if "collisionBounds" in definition:
|
||||
collision = definition["collisionBounds"]
|
||||
minimum = ogre_to_blender(collision["min"])
|
||||
maximum = ogre_to_blender(collision["max"])
|
||||
low = Vector(tuple(min(a, b) for a, b in zip(minimum, maximum)))
|
||||
high = Vector(tuple(max(a, b) for a, b in zip(minimum, maximum)))
|
||||
box_edges("CollisionBounds", low, high, collision_mat)
|
||||
|
||||
# Pivot axes and occupancy-cell outlines at the root origin.
|
||||
line_object("PivotX", [(0, 0, 0), (0.35, 0, 0)], pivot_mats[0], 0.012)
|
||||
line_object("PivotY", [(0, 0, 0), (0, 0.35, 0)], pivot_mats[1], 0.012)
|
||||
line_object("PivotZ", [(0, 0, 0), (0, 0, 0.35)], pivot_mats[2], 0.012)
|
||||
if bounds:
|
||||
cell_size = 2.0
|
||||
bmin = bounds["min"]
|
||||
bmax = bounds["max"]
|
||||
min_cx = math.floor(bmin[0] / cell_size)
|
||||
min_cz = math.floor(bmin[2] / cell_size)
|
||||
max_cx = math.ceil((bmax[0] - 0.0001) / cell_size) - 1
|
||||
max_cz = math.ceil((bmax[2] - 0.0001) / cell_size) - 1
|
||||
for cx in range(min_cx, max_cx + 1):
|
||||
for cz in range(min_cz, max_cz + 1):
|
||||
points = [(cx * cell_size, -cz * cell_size, -0.01),
|
||||
((cx + 1) * cell_size, -cz * cell_size, -0.01),
|
||||
((cx + 1) * cell_size,
|
||||
-(cz + 1) * cell_size, -0.01),
|
||||
(cx * cell_size, -(cz + 1) * cell_size, -0.01),
|
||||
(cx * cell_size, -cz * cell_size, -0.01)]
|
||||
line_object("OccupancyCell", points, cell_mat, 0.006)
|
||||
|
||||
radius = max(size.length * 0.025, 0.025)
|
||||
for action in definition.get("actions", []):
|
||||
position = (action["position_x"], action["position_y"],
|
||||
action["position_z"])
|
||||
sphere("ActionMarker", ogre_to_blender(position), action_mat,
|
||||
radius)
|
||||
for marker in action.get("positions", []):
|
||||
position = (marker["position_x"], marker["position_y"],
|
||||
marker["position_z"])
|
||||
sphere("PositionMarker", ogre_to_blender(position),
|
||||
position_mat, radius * 0.7)
|
||||
for marker in definition.get("positions", []):
|
||||
position = (marker["position_x"], marker["position_y"],
|
||||
marker["position_z"])
|
||||
sphere("PositionMarker", ogre_to_blender(position),
|
||||
position_mat, radius * 0.7)
|
||||
|
||||
distance = max(size.length, 0.8) * 1.8
|
||||
camera.location = center + Vector((distance * 0.8,
|
||||
distance * 1.1,
|
||||
distance * 0.65))
|
||||
camera.rotation_euler = (center - camera.location).to_track_quat(
|
||||
'-Z', 'Y').to_euler()
|
||||
camera.data.lens = 45
|
||||
output_name = root.name + ".png"
|
||||
scene.render.filepath = os.path.join(GALLERY_DIR, output_name)
|
||||
bpy.ops.render.render(write_still=True)
|
||||
|
||||
manifest.append({
|
||||
"id": definition["id"],
|
||||
"render": output_name,
|
||||
"layer": definition.get("layer", "normal"),
|
||||
"tags": definition.get("tags", []),
|
||||
"bounds": definition.get("bounds"),
|
||||
"collisionBounds": definition.get("collisionBounds"),
|
||||
"uv": uv_bounds(root),
|
||||
})
|
||||
for helper in set(scene.objects) - helpers_start:
|
||||
bpy.data.objects.remove(helper, do_unlink=True)
|
||||
|
||||
with open(os.path.join(GALLERY_DIR,
|
||||
"manifest-" + os.path.basename(DEFINITION_DIR) +
|
||||
".json"),
|
||||
"w") as output:
|
||||
json.dump(manifest, output, indent=2)
|
||||
print("Furniture gallery: %d assets rendered" % len(manifest))
|
||||
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate generated furniture GLB/JSON artifacts after the CMake export.
|
||||
|
||||
Checks the phase 6.5 shared-atlas contract without starting OGRE:
|
||||
schema metadata exists, GLBs carry no embedded images, every material is the
|
||||
shared material, UVs stay in the owning source's vertical atlas band, and
|
||||
mesh variants sharing a Blender mesh datablock keep identical UV bounds.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
COMPONENT_FLOAT = 5126
|
||||
|
||||
|
||||
def fail(message):
|
||||
raise RuntimeError(message)
|
||||
|
||||
|
||||
def read_glb(path):
|
||||
data = path.read_bytes()
|
||||
if len(data) < 20 or data[:4] != b"glTF":
|
||||
fail(f"{path}: not a GLB")
|
||||
json_length, json_type = struct.unpack_from("<II", data, 12)
|
||||
if json_type != 0x4E4F534A:
|
||||
fail(f"{path}: first chunk is not JSON")
|
||||
document = json.loads(data[20:20 + json_length])
|
||||
offset = 20 + json_length
|
||||
bin_data = b""
|
||||
if offset + 8 <= len(data):
|
||||
bin_length, bin_type = struct.unpack_from("<II", data, offset)
|
||||
if bin_type == 0x004E4942:
|
||||
bin_data = data[offset + 8:offset + 8 + bin_length]
|
||||
return document, bin_data
|
||||
|
||||
|
||||
def accessor_floats(document, bin_data, accessor_index):
|
||||
accessor = document["accessors"][accessor_index]
|
||||
if accessor["componentType"] != COMPONENT_FLOAT:
|
||||
fail(f"accessor {accessor_index} is not float")
|
||||
view = document["bufferViews"][accessor["bufferView"]]
|
||||
offset = view.get("byteOffset", 0) + accessor.get("byteOffset", 0)
|
||||
components = {"VEC2": 2, "VEC3": 3, "VEC4": 4}[accessor["type"]]
|
||||
count = accessor["count"] * components
|
||||
return struct.unpack_from("<%df" % count, bin_data, offset)
|
||||
|
||||
|
||||
def uv_bounds(document, bin_data):
|
||||
result = []
|
||||
for mesh_index, mesh in enumerate(document.get("meshes", [])):
|
||||
mesh_name = mesh.get("name", f"mesh{mesh_index}")
|
||||
for primitive in mesh.get("primitives", []):
|
||||
accessor_index = primitive.get("attributes", {}).get("TEXCOORD_0")
|
||||
if accessor_index is None:
|
||||
continue
|
||||
values = accessor_floats(document, bin_data, accessor_index)
|
||||
us = values[::2]
|
||||
vs = values[1::2]
|
||||
result.append((mesh_name, min(us), max(us), min(vs), max(vs)))
|
||||
return result
|
||||
|
||||
|
||||
def validate_source(source_root, source_name, band_index, band_count):
|
||||
source_dir = source_root / source_name
|
||||
if not source_dir.is_dir():
|
||||
fail(f"missing output directory {source_dir}")
|
||||
json_paths = sorted(source_dir.glob("furniture-*.json"))
|
||||
if not json_paths:
|
||||
fail(f"no furniture definitions in {source_dir}")
|
||||
|
||||
shared_uv = {}
|
||||
min_v = 1.0 - float(band_index + 1) / band_count - 0.002
|
||||
max_v = 1.0 - float(band_index) / band_count + 0.002
|
||||
mesh_count = 0
|
||||
|
||||
for json_path in json_paths:
|
||||
definition = json.loads(json_path.read_text())
|
||||
if definition.get("source") != "blender":
|
||||
fail(f"{json_path}: source is not blender")
|
||||
if definition.get("materialName") != "M_Furniture_Atlas":
|
||||
fail(f"{json_path}: wrong materialName")
|
||||
if not definition.get("id") or not definition.get("mesh"):
|
||||
fail(f"{json_path}: missing id/mesh")
|
||||
if "bounds" not in definition:
|
||||
fail(f"{json_path}: missing physical bounds")
|
||||
glb_path = source_dir / definition["mesh"]
|
||||
if not glb_path.is_file():
|
||||
fail(f"{json_path}: referenced mesh is missing")
|
||||
|
||||
document, bin_data = read_glb(glb_path)
|
||||
if document.get("images"):
|
||||
fail(f"{glb_path}: embeds an image instead of using the shared atlas")
|
||||
materials = document.get("materials", [])
|
||||
if not materials or any(
|
||||
material.get("name") != "M_Furniture_Atlas"
|
||||
for material in materials):
|
||||
fail(f"{glb_path}: does not use only M_Furniture_Atlas")
|
||||
|
||||
for mesh_name, min_u, max_u, mesh_min_v, mesh_max_v in uv_bounds(
|
||||
document, bin_data):
|
||||
mesh_count += 1
|
||||
if mesh_min_v < min_v or mesh_max_v > max_v:
|
||||
fail(
|
||||
f"{glb_path}:{mesh_name}: UV V range "
|
||||
f"{mesh_min_v:.4f}..{mesh_max_v:.4f} escapes band "
|
||||
f"{min_v:.4f}..{max_v:.4f}")
|
||||
key = (source_name, mesh_name)
|
||||
bounds = (min_u, max_u, mesh_min_v, mesh_max_v)
|
||||
previous = shared_uv.get(key)
|
||||
if previous:
|
||||
if any(abs(a - b) > 0.0005 for a, b in zip(previous, bounds)):
|
||||
fail(
|
||||
f"{glb_path}:{mesh_name}: shared mesh UV bounds "
|
||||
f"changed between variants {previous} -> {bounds}")
|
||||
else:
|
||||
shared_uv[key] = bounds
|
||||
|
||||
return len(json_paths), mesh_count
|
||||
|
||||
|
||||
def main():
|
||||
args = sys.argv[1:]
|
||||
if len(args) < 6 or args[0] != "--root":
|
||||
fail("usage: validate_furniture_export.py --root <dir> "
|
||||
"--source <name> <band> <count> [...]")
|
||||
root = Path(args[1])
|
||||
atlas_png = root / "furniture-atlas.png"
|
||||
atlas_material = root / "furniture-atlas.material"
|
||||
if not atlas_png.is_file() or atlas_png.stat().st_size == 0:
|
||||
fail("shared furniture-atlas.png is missing/empty")
|
||||
if "M_Furniture_Atlas" not in atlas_material.read_text():
|
||||
fail("shared material does not define M_Furniture_Atlas")
|
||||
|
||||
definitions = 0
|
||||
meshes = 0
|
||||
i = 2
|
||||
while i < len(args):
|
||||
if args[i] != "--source" or i + 3 >= len(args):
|
||||
fail("malformed --source arguments")
|
||||
count, mesh_count = validate_source(root, args[i + 1],
|
||||
int(args[i + 2]),
|
||||
int(args[i + 3]))
|
||||
definitions += count
|
||||
meshes += mesh_count
|
||||
i += 4
|
||||
print(f"Furniture export validation: {definitions} definitions, "
|
||||
f"{meshes} mesh primitives, shared atlas OK")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,305 @@
|
||||
# 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
|
||||
```
|
||||
|
||||
Pipeline per sex (`male` / `female`):
|
||||
|
||||
1. `edited-normal-<sex>.blend` is the base body. `copy_animations.py`
|
||||
copies the Mixamo animation library (`assets/blender/mixamo/`) into
|
||||
it — idle, walking, running, swimming, treading_water, sitting,
|
||||
sitting-ground, sitting-vehicle, ...
|
||||
2. Each `clothes-<sex>-<slot>.blend` holds garment meshes for one slot.
|
||||
`process_clothes.py` fits them to the body (weight transfer from the
|
||||
reference body parts, seam fixes), `combine_clothes.py` bakes the
|
||||
combined meshes for garment combinations (see §3).
|
||||
3. `consolidate.py` appends everything into
|
||||
`edited-normal-<sex>-consolidated.blend`.
|
||||
4. `export_models2.py` exports the consolidated blend through
|
||||
blender2ogre: one `.mesh` per catalog mesh object, the shared
|
||||
`.skeleton`, `.material` files, the texture atlases
|
||||
(`clothes-atlas.png`, `clothes-atlas-normal.png`, `face-atlas.png`),
|
||||
a `.glb` preview, and one **`body_part_<mesh data name>.json`
|
||||
catalog entry per mesh object** carrying the required custom
|
||||
properties (see §2).
|
||||
|
||||
Outputs land in `build-vscode/characters/<sex>/` and are staged into
|
||||
the editScene runtime `characters/` directory (OGRE resource group
|
||||
`Characters`). The engine discovers catalog entries by scanning every
|
||||
resource group for `body_part_*.json` — no manual registration step.
|
||||
|
||||
**blender2ogre names `.mesh` files after the mesh DATA name** (not the
|
||||
object name) — set both, and never reuse another blend's mesh data
|
||||
names: everything exports into the same directory and silently
|
||||
overwrites.
|
||||
|
||||
## 2. Catalog entry contract (custom properties on the MESH OBJECT)
|
||||
|
||||
`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 | age band, currently only `adult` exists |
|
||||
| `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_<sex>-atlas.png`) with a single `charlod_<sex>`
|
||||
material per sex. Hair slots are skipped (hidden/impostor-baked at
|
||||
distance anyway). Parts without a `_lod1` variant keep their base
|
||||
mesh, so the scheme degrades gracefully.
|
||||
- **LOD2 (30-70 m)**: whole-character impostors
|
||||
`charlod_<sex>_thin_lod2.mesh` / `charlod_<sex>_fat_lod2.mesh` — the
|
||||
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_<sex>_<variant>_lod3.mesh` —
|
||||
collapse-decimated LOD2, ~400 triangles (hard limit 400).
|
||||
- Interiors need only LOD0/LOD1.
|
||||
|
||||
Generation is automated in the export pipeline:
|
||||
`assets/blender/characters/generate_lods.py` runs per sex on the
|
||||
consolidated blend (CMake step `edited-normal-<sex>-lods.blend`),
|
||||
`export_models2.py` appends the result via its 6th argument and exports
|
||||
the LOD meshes with the SAME shared armature. A manifest
|
||||
(`charlod_<sex>.json`: atlas file + impostor mesh names) is written for
|
||||
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
|
||||
```
|
||||
@@ -53,8 +53,10 @@ add_custom_command(
|
||||
COMMAND ${BLENDER} -b -Y -P ${CMAKE_SOURCE_DIR}/assets/blender/scripts/export_models2.py --
|
||||
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-consolidated.blend
|
||||
${CMAKE_BINARY_DIR}/characters/male/normal-male.glb
|
||||
"AUTO"
|
||||
"male"
|
||||
tmp-edited-male.blend
|
||||
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-lods.blend
|
||||
COMMAND ${CMAKE_COMMAND} -D FILE=${CMAKE_BINARY_DIR}/characters/male/normal-male.glb
|
||||
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
|
||||
COMMAND ${CMAKE_COMMAND} -D FILE=${CMAKE_BINARY_DIR}/characters/male/normal-male.scene
|
||||
@@ -62,6 +64,7 @@ add_custom_command(
|
||||
DEPENDS ${CMAKE_SOURCE_DIR}/assets/blender/scripts/export_models2.py
|
||||
${VRM_IMPORTED_BLENDS}
|
||||
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-consolidated.blend
|
||||
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-lods.blend
|
||||
# ${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-consolidated-hair.stamp
|
||||
${CMAKE_CURRENT_BINARY_DIR}/blender-addons-installed
|
||||
WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
|
||||
@@ -74,8 +77,10 @@ add_custom_command(
|
||||
COMMAND ${BLENDER} -b -Y -P ${CMAKE_SOURCE_DIR}/assets/blender/scripts/export_models2.py --
|
||||
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-female-consolidated.blend
|
||||
${CMAKE_BINARY_DIR}/characters/female/normal-female.glb
|
||||
"AUTO"
|
||||
"female"
|
||||
tmp-edited-female.blend
|
||||
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-female-lods.blend
|
||||
COMMAND ${CMAKE_COMMAND} -D FILE=${CMAKE_BINARY_DIR}/characters/female/normal-female.glb
|
||||
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
|
||||
COMMAND ${CMAKE_COMMAND} -D FILE=${CMAKE_BINARY_DIR}/characters/female/normal-female.scene
|
||||
@@ -83,6 +88,7 @@ add_custom_command(
|
||||
DEPENDS ${CMAKE_SOURCE_DIR}/assets/blender/scripts/export_models2.py
|
||||
${VRM_IMPORTED_BLENDS}
|
||||
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-female-consolidated.blend
|
||||
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-female-lods.blend
|
||||
${CMAKE_CURRENT_BINARY_DIR}/blender-addons-installed
|
||||
WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
|
||||
VERBATIM
|
||||
@@ -480,3 +486,29 @@ foreach (SEX ${SEX_LIST})
|
||||
set(SLOT_INPUT ${SLOT_OUTPUT})
|
||||
endforeach()
|
||||
endforeach()
|
||||
|
||||
# Character LOD generation (demo-taxi phase 6.C.3/6.C.4): from each
|
||||
# consolidated blend generate per-part "_lod1" atlas variants and
|
||||
# whole-character charlod impostors (LOD2 thin/fat, LOD3 <= 400 tris),
|
||||
# the shared 2048 atlas and the charlod_<sex>.json manifest. The
|
||||
# export_models2 commands above append the lods blend so the impostors
|
||||
# link the same skeleton as the body parts.
|
||||
foreach (SEX ${SEX_LIST})
|
||||
add_custom_command(
|
||||
OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/edited-normal-${SEX}-lods.blend
|
||||
COMMAND ${CMAKE_COMMAND} -E make_directory
|
||||
${CMAKE_BINARY_DIR}/characters/${SEX}
|
||||
COMMAND ${BLENDER} -b -Y
|
||||
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-${SEX}-consolidated.blend
|
||||
-P ${CMAKE_CURRENT_SOURCE_DIR}/generate_lods.py --
|
||||
${SEX}
|
||||
${CMAKE_BINARY_DIR}/characters/${SEX}
|
||||
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-${SEX}-lods.blend
|
||||
COMMAND ${CMAKE_COMMAND} -D FILE=${CMAKE_CURRENT_BINARY_DIR}/edited-normal-${SEX}-lods.blend
|
||||
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
|
||||
DEPENDS ${CMAKE_CURRENT_BINARY_DIR}/edited-normal-${SEX}-consolidated.blend
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/generate_lods.py
|
||||
${CMAKE_CURRENT_BINARY_DIR}/blender-addons-installed
|
||||
COMMENT "Generating character LOD meshes for ${SEX}"
|
||||
)
|
||||
endforeach()
|
||||
|
||||
@@ -0,0 +1,657 @@
|
||||
# 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 edited-normal-<sex>-consolidated.blend \
|
||||
# -P generate_lods.py -- <sex> <out_dir> <lods_blend_out>
|
||||
#
|
||||
# Produces, per sex:
|
||||
# * <out_dir>/charlod_<sex>-atlas.png - shared 2048x2048 LOD atlas
|
||||
# * <out_dir>/charlod_<sex>.json - manifest
|
||||
# * LOD1 object variants `<part>_lod1` - same geometry, no shape keys,
|
||||
# UVs remapped into the atlas, one shared material `charlod_<sex>`
|
||||
# (hair slot is skipped - hair is hidden/impostor-baked at distance)
|
||||
# * LOD2 impostors `charlod_<sex>_{thin,fat}_lod2` - the deterministic
|
||||
# default outfit joined into one mesh skinned to the body armature
|
||||
# * LOD3 impostors `charlod_<sex>_{thin,fat}_lod3` - decimate of LOD2,
|
||||
# <= 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
|
||||
|
||||
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) < 3:
|
||||
raise RuntimeError(
|
||||
"usage: blender -b -Y <consolidated.blend> -P generate_lods.py"
|
||||
" -- <sex> <out_dir> <lods_blend_out>")
|
||||
return argv[0], argv[1], argv[2]
|
||||
|
||||
|
||||
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()
|
||||
log("sex=%s out_dir=%s lods_blend=%s" % (sex, out_dir,
|
||||
lods_blend_out))
|
||||
|
||||
cleanup_previous(sex)
|
||||
|
||||
armature = find_armature(sex)
|
||||
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, sex, out_dir)
|
||||
atlas_mat = make_atlas_material(sex, atlas)
|
||||
|
||||
log("=== step 2: LOD1 variants ===")
|
||||
lod1 = generate_lod1(catalog, rects, atlas_mat, sex)
|
||||
log("generated %d LOD1 objects" % len(lod1))
|
||||
|
||||
log("=== step 3: LOD2 impostors ===")
|
||||
lod2 = generate_lod2(catalog, rects, atlas_mat, armature,
|
||||
head_bone, sex)
|
||||
|
||||
log("=== step 4: LOD3 impostors ===")
|
||||
generate_lod3(lod2, armature, sex)
|
||||
|
||||
log("=== step 5: manifest + save ===")
|
||||
write_manifest(sex, 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()
|
||||
@@ -187,7 +187,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]
|
||||
@@ -480,6 +487,46 @@ 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)
|
||||
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}'")
|
||||
# -------------------------------------------------------------
|
||||
|
||||
# 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
|
||||
|
||||
Reference in New Issue
Block a user