Phase 6.5 checkpoint

This commit is contained in:
2026-10-26 11:03:11 +03:00
parent 9e2d9fce59
commit adc4c91a2c
61 changed files with 10261 additions and 450 deletions
+110 -11
View File
@@ -1,4 +1,5 @@
project(building-parts)
find_program(PYTHON3_EXECUTABLE NAMES python3 python REQUIRED)
set(PARTS_FILES pier.blend)
set(FURNITURE_FILES furniture.blend furniture-sofa.blend)
set(PARTS_OUTPUT_DIRS)
@@ -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 &nbsp; <b>Tags:</b> %s</p>"
"<p class='bounds'><b>Bounds:</b> %s%s</p>"
"<div class='views'>"
"<div><h3>Asset / pivot / bounds / POIs</h3>"
"<img class='render' src='%s'></div>"
"<div><h3>Atlas UV region</h3>"
"<div class='atlas'><img src='%s'>"
"<div class='uv' style='%s'></div></div></div>"
"</div></section>" %
(html.escape(entry["id"]), html.escape(entry["layer"]),
html.escape(", ".join(entry["tags"])),
html.escape(bounds_text),
" + collision proxy" if entry.get("collisionBounds") else "",
html.escape(entry["render"]), html.escape(args.atlas), style))
page = """<!doctype html>
<meta charset='utf-8'>
<title>Furniture export gallery</title>
<style>
body { font-family: sans-serif; background: #202124; color: #ddd;
margin: 2em; }
.asset { border-top: 1px solid #555; padding-top: 1em; margin-top: 2em; }
.views { display: flex; gap: 2em; align-items: flex-start; }
.views > div { flex: 1; min-width: 0; }
.render { width: 100%; image-rendering: auto; background: #111; }
.atlas { position: relative; width: 100%; }
.atlas img { width: 100%; display: block; }
.uv { position: absolute; border: 3px solid #f33; box-sizing: border-box;
pointer-events: none; }
.bounds { color: #aaa; font-size: 0.9em; }
</style>
<h1>Furniture export gallery</h1>
<p>Generated from the baked Blender sources. Green = physical bounds,
red = collision bounds, blue = occupancy cells, axes = pivot, spheres =
action/position markers. The red rectangle on each atlas shows the
asset's UV region.</p>
""" + "\n".join(rows)
(root / "index.html").write_text(page)
print("Furniture gallery index: %d assets" % len(entries))
if __name__ == "__main__":
main()
@@ -0,0 +1,79 @@
import os
import sys
import bpy
# Stack deterministic per-blend furniture atlas bakes into one shared atlas.
# bake_furniture.py remaps each source's UVs into the matching vertical band.
#
# Usage:
# blender --factory-startup -b -P merge_furniture_atlases.py -- \
# <atlas.png> <atlas.material> <part0.png> [part1.png ...]
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
if len(argv) < 3:
raise RuntimeError(
"merge_furniture_atlases.py needs output png/material and inputs")
out_png = argv[0]
out_material = argv[1]
inputs = argv[2:]
images = []
width = None
part_height = None
for path in inputs:
if not os.path.exists(path):
raise RuntimeError("missing furniture atlas part: " + path)
image = bpy.data.images.load(path, check_existing=False)
images.append((path, image))
if width is None:
width, part_height = image.size
elif image.size[0] != width or image.size[1] != part_height:
raise RuntimeError("atlas size mismatch in " + path)
if width is None or width <= 0 or part_height <= 0:
raise RuntimeError("no furniture atlas parts supplied")
part_pixels = width * part_height * 4
merged = []
for path, image in images:
pixels = list(image.pixels)
if len(pixels) != part_pixels:
raise RuntimeError("unexpected pixel count in " + path)
coverage = sum(1 for i in range(3, len(pixels), 4) if pixels[i] > 0.0)
if coverage == 0:
raise RuntimeError("furniture atlas part is empty: " + path)
merged.extend(pixels)
result = bpy.data.images.new("FurnitureColor", width=width,
height=part_height * len(images), alpha=True)
result.pixels = merged
os.makedirs(os.path.dirname(out_png), exist_ok=True)
os.makedirs(os.path.dirname(out_material), exist_ok=True)
result.filepath_raw = out_png
result.file_format = 'PNG'
result.save()
material = """// Generated by merge_furniture_atlases.py; do not edit.
material M_Furniture_Atlas
{
technique
{
pass
{
texture_unit
{
texture furniture-atlas.png
filtering linear linear linear
}
}
}
}
"""
with open(out_material, "w", newline="\n") as file:
file.write(material)
print("Furniture atlas merged: %d source band(s), %dx%d -> %s" %
(len(images), width, part_height * len(images), out_png))
print("Furniture material written: " + out_material)
@@ -0,0 +1,253 @@
"""Render one preview image per furniture root plus a gallery manifest.
Invoke on a *_baked.blend from the furniture pipeline:
blender -b furniture_baked.blend -Y -P render_furniture_gallery.py -- \
<gallery-dir> <definition-dir>
The manifest is consumed by generate_furniture_gallery_index.py. Rendering
uses Cycles CPU so it also works on headless build machines.
"""
import json
import math
import os
import sys
import bpy
from mathutils import Vector
argv = sys.argv[sys.argv.index("--") + 1:]
GALLERY_DIR = argv[0]
DEFINITION_DIR = argv[1]
os.makedirs(GALLERY_DIR, exist_ok=True)
def ogre_to_blender(v):
return Vector((v[0], -v[2], v[1]))
def material(name, color, emission=0.0):
mat = bpy.data.materials.get(name)
if mat:
return mat
mat = bpy.data.materials.new(name)
mat.use_nodes = True
bsdf = mat.node_tree.nodes.get("Principled BSDF")
bsdf.inputs["Base Color"].default_value = (*color, 1.0)
bsdf.inputs["Roughness"].default_value = 0.8
if emission:
bsdf.inputs["Emission"].default_value = (*color, 1.0)
bsdf.inputs["Emission Strength"].default_value = emission
return mat
bounds_mat = material("GalleryBounds", (0.1, 0.8, 0.2), 1.0)
collision_mat = material("GalleryCollision", (0.9, 0.1, 0.1), 1.0)
cell_mat = material("GalleryCells", (0.1, 0.4, 0.9), 0.5)
pivot_mats = [material("GalleryX", (1, 0, 0), 1.0),
material("GalleryY", (0, 1, 0), 1.0),
material("GalleryZ", (0, 0, 1), 1.0)]
action_mat = material("GalleryAction", (1.0, 0.5, 0.05), 1.0)
position_mat = material("GalleryPosition", (0.2, 0.6, 1.0), 1.0)
floor_mat = material("GalleryFloor", (0.18, 0.18, 0.18))
scene = bpy.context.scene
scene.render.engine = 'CYCLES'
scene.cycles.device = 'CPU'
scene.cycles.samples = 4
scene.render.resolution_x = 480
scene.render.resolution_y = 360
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = 'PNG'
scene.render.film_transparent = False
# Neutral floor, camera and lights are shared by every render.
bpy.ops.mesh.primitive_plane_add(size=200.0, location=(0, 0, -0.03))
floor = bpy.context.object
floor.name = "GalleryFloor"
floor.data.materials.append(floor_mat)
bpy.ops.object.camera_add()
camera = bpy.context.object
scene.camera = camera
camera.data.lens = 50
for name, location, energy, size in (
("Key", (3, 4, 6), 600, 4),
("Fill", (-4, 1, 3), 350, 4),
("Rim", (0, -4, 4), 450, 3)):
bpy.ops.object.light_add(type='AREA', location=location)
lamp = bpy.context.object
lamp.name = "Gallery" + name
lamp.data.energy = energy
lamp.data.size = size
def hide_tree(obj, hide):
obj.hide_render = hide
for child in obj.children:
hide_tree(child, hide)
def line_object(name, points, mat, width=0.008):
curve = bpy.data.curves.new(name, 'CURVE')
curve.dimensions = '3D'
curve.bevel_depth = width
curve.bevel_resolution = 1
spline = curve.splines.new('POLY')
spline.points.add(len(points) - 1)
for point, co in zip(spline.points, points):
point.co = (*co, 1.0)
obj = bpy.data.objects.new(name, curve)
scene.collection.objects.link(obj)
obj.data.materials.append(mat)
return obj
def box_edges(name, minimum, maximum, mat):
corners = []
for x in (minimum[0], maximum[0]):
for y in (minimum[1], maximum[1]):
for z in (minimum[2], maximum[2]):
corners.append(Vector((x, y, z)))
indices = [(0, 1), (0, 2), (0, 4), (1, 3), (1, 5), (2, 3),
(2, 6), (3, 7), (4, 5), (4, 6), (5, 7), (6, 7)]
for index, (a, b) in enumerate(indices):
line_object(name + str(index), [corners[a], corners[b]], mat)
def sphere(name, position, mat, radius):
bpy.ops.mesh.primitive_uv_sphere_add(segments=12, ring_count=6,
radius=radius, location=position)
obj = bpy.context.object
obj.name = name
obj.data.materials.append(mat)
return obj
def uv_bounds(root):
result = [1.0, 0.0, 1.0, 0.0]
found = False
stack = list(root.children)
while stack:
obj = stack.pop(0)
stack.extend(obj.children)
if obj.type != 'MESH' or obj.name.startswith("collision-"):
continue
for layer in obj.data.uv_layers:
for loop in layer.data:
u, v = loop.uv
result[0] = min(result[0], u)
result[1] = max(result[1], u)
result[2] = min(result[2], v)
result[3] = max(result[3], v)
found = True
return result if found else [0.0, 1.0, 0.0, 1.0]
roots = [obj for obj in scene.objects
if obj.parent is None and obj.name.startswith("furniture-")]
manifest = []
for root in sorted(roots, key=lambda obj: obj.name):
definition_path = os.path.join(DEFINITION_DIR, root.name + ".glb.json")
with open(definition_path) as source:
definition = json.load(source)
# Definitions and GLBs are exported with the root at the origin;
# use the same pivot here (many source roots have authoring offsets).
root.location = (0.0, 0.0, 0.0)
for other in roots:
hide_tree(other, True)
hide_tree(root, False)
helpers_start = set(scene.objects)
bounds = definition.get("bounds")
if bounds:
minimum = ogre_to_blender(bounds["min"])
maximum = ogre_to_blender(bounds["max"])
# ogre_to_blender swaps Y/Z and negates one axis; reorder.
low = Vector(tuple(min(a, b) for a, b in zip(minimum, maximum)))
high = Vector(tuple(max(a, b) for a, b in zip(minimum, maximum)))
box_edges("PhysicalBounds", low, high, bounds_mat)
center = (low + high) * 0.5
size = high - low
else:
center = Vector((0, 0, 0.5))
size = Vector((1, 1, 1))
if "collisionBounds" in definition:
collision = definition["collisionBounds"]
minimum = ogre_to_blender(collision["min"])
maximum = ogre_to_blender(collision["max"])
low = Vector(tuple(min(a, b) for a, b in zip(minimum, maximum)))
high = Vector(tuple(max(a, b) for a, b in zip(minimum, maximum)))
box_edges("CollisionBounds", low, high, collision_mat)
# Pivot axes and occupancy-cell outlines at the root origin.
line_object("PivotX", [(0, 0, 0), (0.35, 0, 0)], pivot_mats[0], 0.012)
line_object("PivotY", [(0, 0, 0), (0, 0.35, 0)], pivot_mats[1], 0.012)
line_object("PivotZ", [(0, 0, 0), (0, 0, 0.35)], pivot_mats[2], 0.012)
if bounds:
cell_size = 2.0
bmin = bounds["min"]
bmax = bounds["max"]
min_cx = math.floor(bmin[0] / cell_size)
min_cz = math.floor(bmin[2] / cell_size)
max_cx = math.ceil((bmax[0] - 0.0001) / cell_size) - 1
max_cz = math.ceil((bmax[2] - 0.0001) / cell_size) - 1
for cx in range(min_cx, max_cx + 1):
for cz in range(min_cz, max_cz + 1):
points = [(cx * cell_size, -cz * cell_size, -0.01),
((cx + 1) * cell_size, -cz * cell_size, -0.01),
((cx + 1) * cell_size,
-(cz + 1) * cell_size, -0.01),
(cx * cell_size, -(cz + 1) * cell_size, -0.01),
(cx * cell_size, -cz * cell_size, -0.01)]
line_object("OccupancyCell", points, cell_mat, 0.006)
radius = max(size.length * 0.025, 0.025)
for action in definition.get("actions", []):
position = (action["position_x"], action["position_y"],
action["position_z"])
sphere("ActionMarker", ogre_to_blender(position), action_mat,
radius)
for marker in action.get("positions", []):
position = (marker["position_x"], marker["position_y"],
marker["position_z"])
sphere("PositionMarker", ogre_to_blender(position),
position_mat, radius * 0.7)
for marker in definition.get("positions", []):
position = (marker["position_x"], marker["position_y"],
marker["position_z"])
sphere("PositionMarker", ogre_to_blender(position),
position_mat, radius * 0.7)
distance = max(size.length, 0.8) * 1.8
camera.location = center + Vector((distance * 0.8,
distance * 1.1,
distance * 0.65))
camera.rotation_euler = (center - camera.location).to_track_quat(
'-Z', 'Y').to_euler()
camera.data.lens = 45
output_name = root.name + ".png"
scene.render.filepath = os.path.join(GALLERY_DIR, output_name)
bpy.ops.render.render(write_still=True)
manifest.append({
"id": definition["id"],
"render": output_name,
"layer": definition.get("layer", "normal"),
"tags": definition.get("tags", []),
"bounds": definition.get("bounds"),
"collisionBounds": definition.get("collisionBounds"),
"uv": uv_bounds(root),
})
for helper in set(scene.objects) - helpers_start:
bpy.data.objects.remove(helper, do_unlink=True)
with open(os.path.join(GALLERY_DIR,
"manifest-" + os.path.basename(DEFINITION_DIR) +
".json"),
"w") as output:
json.dump(manifest, output, indent=2)
print("Furniture gallery: %d assets rendered" % len(manifest))
@@ -0,0 +1,154 @@
#!/usr/bin/env python3
"""Validate generated furniture GLB/JSON artifacts after the CMake export.
Checks the phase 6.5 shared-atlas contract without starting OGRE:
schema metadata exists, GLBs carry no embedded images, every material is the
shared material, UVs stay in the owning source's vertical atlas band, and
mesh variants sharing a Blender mesh datablock keep identical UV bounds.
"""
import json
import os
import struct
import sys
from pathlib import Path
COMPONENT_FLOAT = 5126
def fail(message):
raise RuntimeError(message)
def read_glb(path):
data = path.read_bytes()
if len(data) < 20 or data[:4] != b"glTF":
fail(f"{path}: not a GLB")
json_length, json_type = struct.unpack_from("<II", data, 12)
if json_type != 0x4E4F534A:
fail(f"{path}: first chunk is not JSON")
document = json.loads(data[20:20 + json_length])
offset = 20 + json_length
bin_data = b""
if offset + 8 <= len(data):
bin_length, bin_type = struct.unpack_from("<II", data, offset)
if bin_type == 0x004E4942:
bin_data = data[offset + 8:offset + 8 + bin_length]
return document, bin_data
def accessor_floats(document, bin_data, accessor_index):
accessor = document["accessors"][accessor_index]
if accessor["componentType"] != COMPONENT_FLOAT:
fail(f"accessor {accessor_index} is not float")
view = document["bufferViews"][accessor["bufferView"]]
offset = view.get("byteOffset", 0) + accessor.get("byteOffset", 0)
components = {"VEC2": 2, "VEC3": 3, "VEC4": 4}[accessor["type"]]
count = accessor["count"] * components
return struct.unpack_from("<%df" % count, bin_data, offset)
def uv_bounds(document, bin_data):
result = []
for mesh_index, mesh in enumerate(document.get("meshes", [])):
mesh_name = mesh.get("name", f"mesh{mesh_index}")
for primitive in mesh.get("primitives", []):
accessor_index = primitive.get("attributes", {}).get("TEXCOORD_0")
if accessor_index is None:
continue
values = accessor_floats(document, bin_data, accessor_index)
us = values[::2]
vs = values[1::2]
result.append((mesh_name, min(us), max(us), min(vs), max(vs)))
return result
def validate_source(source_root, source_name, band_index, band_count):
source_dir = source_root / source_name
if not source_dir.is_dir():
fail(f"missing output directory {source_dir}")
json_paths = sorted(source_dir.glob("furniture-*.json"))
if not json_paths:
fail(f"no furniture definitions in {source_dir}")
shared_uv = {}
min_v = 1.0 - float(band_index + 1) / band_count - 0.002
max_v = 1.0 - float(band_index) / band_count + 0.002
mesh_count = 0
for json_path in json_paths:
definition = json.loads(json_path.read_text())
if definition.get("source") != "blender":
fail(f"{json_path}: source is not blender")
if definition.get("materialName") != "M_Furniture_Atlas":
fail(f"{json_path}: wrong materialName")
if not definition.get("id") or not definition.get("mesh"):
fail(f"{json_path}: missing id/mesh")
if "bounds" not in definition:
fail(f"{json_path}: missing physical bounds")
glb_path = source_dir / definition["mesh"]
if not glb_path.is_file():
fail(f"{json_path}: referenced mesh is missing")
document, bin_data = read_glb(glb_path)
if document.get("images"):
fail(f"{glb_path}: embeds an image instead of using the shared atlas")
materials = document.get("materials", [])
if not materials or any(
material.get("name") != "M_Furniture_Atlas"
for material in materials):
fail(f"{glb_path}: does not use only M_Furniture_Atlas")
for mesh_name, min_u, max_u, mesh_min_v, mesh_max_v in uv_bounds(
document, bin_data):
mesh_count += 1
if mesh_min_v < min_v or mesh_max_v > max_v:
fail(
f"{glb_path}:{mesh_name}: UV V range "
f"{mesh_min_v:.4f}..{mesh_max_v:.4f} escapes band "
f"{min_v:.4f}..{max_v:.4f}")
key = (source_name, mesh_name)
bounds = (min_u, max_u, mesh_min_v, mesh_max_v)
previous = shared_uv.get(key)
if previous:
if any(abs(a - b) > 0.0005 for a, b in zip(previous, bounds)):
fail(
f"{glb_path}:{mesh_name}: shared mesh UV bounds "
f"changed between variants {previous} -> {bounds}")
else:
shared_uv[key] = bounds
return len(json_paths), mesh_count
def main():
args = sys.argv[1:]
if len(args) < 6 or args[0] != "--root":
fail("usage: validate_furniture_export.py --root <dir> "
"--source <name> <band> <count> [...]")
root = Path(args[1])
atlas_png = root / "furniture-atlas.png"
atlas_material = root / "furniture-atlas.material"
if not atlas_png.is_file() or atlas_png.stat().st_size == 0:
fail("shared furniture-atlas.png is missing/empty")
if "M_Furniture_Atlas" not in atlas_material.read_text():
fail("shared material does not define M_Furniture_Atlas")
definitions = 0
meshes = 0
i = 2
while i < len(args):
if args[i] != "--source" or i + 3 >= len(args):
fail("malformed --source arguments")
count, mesh_count = validate_source(root, args[i + 1],
int(args[i + 2]),
int(args[i + 3]))
definitions += count
meshes += mesh_count
i += 4
print(f"Furniture export validation: {definitions} definitions, "
f"{meshes} mesh primitives, shared atlas OK")
if __name__ == "__main__":
main()
@@ -0,0 +1,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
```
+32
View File
@@ -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()
+657
View File
@@ -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()
+48 -1
View File
@@ -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