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
+3
View File
@@ -354,6 +354,9 @@ JPH::BodyID body = physics->createBody(shape, 1.0f, position, rotation,
1. **Source**: Blender `.blend` files in `assets/`
2. **Export**: Python scripts in `assets/blender/scripts/`
- `export_buildings.py` - Building export to glTF
- `buildings/parts/bake_furniture.py` + `export_furniture_parts.py` -
Furniture atlas, GLB and definition-JSON export (authoring contract:
`assets/blender/buildings/parts/FURNITURE_MODELING.md`)
- `export_characters_ogre.py` - Character export to OGRE format
- `export_vehicles.py` - Vehicle export (car modeling contract:
`assets/blender/vehicles/CAR_MODELING.md`)
+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
+9
View File
@@ -0,0 +1,9 @@
-- Minimal Lua data file required by lateSetup().
-- The app requires this file to exist in the LuaScripts resource group.
-- Add project-specific Lua initialisation here.
function init()
-- placeholder
end
init()
+169 -2
View File
@@ -826,9 +826,9 @@ position reproducibility at a revisited hour).
`NpcDetailSystem` (EditorApp-owned singleton `getInstance()`, game mode
only, `systems/NpcDetailSystem.hpp/.cpp`) is the high-detail tier over the
symbolic motion layer: population NPCs within `spawnDistanceSq` (80 m —
the taxi demo tunes it to 65 m in demo_main.cpp for batch budget) of
the taxi demo tunes it to 85 m in demo_main.cpp for batch budget) of
the camera become real walking entities and drop back to symbolic beyond
`despawnDistanceSq` (120 m — demo: 95 m) — hysteresis plus a `minDwellSeconds` (3 s)
`despawnDistanceSq` (100 m — demo: 100 m) — hysteresis plus a `minDwellSeconds` (3 s)
dwell and a `spawnBudgetPerFrame` (2) cap, all public config fields. Spawn
eligibility (phase 4.9.1) gates the distance rule — `isSpawnEligible(id)`:
an NPC AT an INDOOR location (`TownLocations::isIndoorKind` — Home, Work,
@@ -956,6 +956,37 @@ Details in demo-taxi/PLAN.md phase 5.5 follow-up; per-step find-walk
attribution is available via the temporary `OGRE_FIND_DEBUG` patch in
the vendored OGRE repo (see PLAN.md for usage).
Character distance LODs (demo-taxi phase 6.C.3/6.C.4, 2026-10-25):
Ogre mesh LODs are not used for characters; `CharacterSlotSystem`
swaps whole meshes by camera distance instead (boundaries 10/30/70 m,
2 m hysteresis, NPCs despawn at 100 m so tier 3 runs till despawn).
Tier 0 = base modular parts; tier 1 = per-part `<mesh>_lod1.mesh`
variants (shared 2048 `charlod_<sex>` atlas material, no shape keys —
OGRE 1.x has no `Entity::setMesh`, so a 0<->1 switch REBUILDS the
character via `buildCharacter` with `m_buildLod1`); tier 2/3 = one
whole-character impostor `charlod_<sex>_<thin|fat>_lod<2|3>.mesh`
(thin/fat from the record's `fat` shape weight >= 0.5; skin palette
pruned to 23 core bones — dropped groups such as fingers/eyes/bust/
physics chains remap to the nearest kept ancestor, so hardware
skinning fits the 64-bone budget without touching the shared skeleton
or animations) shown while all
part entities are hidden — the master entity stays attached and keeps
animating the shared skeleton (visibility does not stop animation) and
the impostor follows it via `shareSkeletonInstanceWith` (a skeleton
mismatch falls back to tier 1 with a log warning). Hair ragdolls are
destroyed and not recreated at tier >= 2 (`HairPhysicsSystem` gates on
`CharacterSlotSystem::getLodTier`). All LOD meshes are optional:
missing `_lod1`/`charlod` meshes keep the character at the best
available tier, so the editor and other demos are unaffected. Mesh
availability must be checked with
`ResourceGroupManager::resourceExists("Characters", name)` (on-disk
index): `MeshManager::resourceExists` only sees already-loaded
resources, which silently disabled every LOD tier before this was
caught. The meshes are generated by `assets/blender/characters/generate_lods.py`
(CMake: `edited-normal-<sex>-lods.blend`, appended by
`export_models2.py`'s 6th argument) — see
assets/blender/characters/CHARACTER_MODELING.md §6.
### NpcTaxiSystem (demo-taxi phase 4.7.7)
`NpcTaxiSystem` (EditorApp-owned singleton `getInstance()`, game mode only,
@@ -1994,6 +2025,142 @@ applied swing angle via `DoorSystem::swingOrientation()`, also used when
snapping a restored-open door; stored angles stay positive),
`doorActionName`, `doorSceneSwitchPath`, `doorSceneSwitchTarget`.
#### Furniture definitions, placement and interactions (phase 6.5)
Furniture definitions (`furniture-*.json`, `FurnitureLibrary`) use schema
v1: stable `id` (legacy `name` is preserved as the selection identifier),
optional `displayName`/`aliases`, exactly one of `mesh` or `prefab`, `tags`,
`layer`, ownership `source`, deterministic `sourceHash`, physical `bounds`,
optional `collisionBounds`, `anchor`, optional `footprintCells`, `clearance`,
`requiresFloor`/`requiresWall`, `weight`, plus preserved `actions`,
`positions` and `sensors` arrays. The parser is strict about the new fields
and duplicate ids/aliases while retaining legacy mesh defaults. See
`tests/furniture_test.cpp` for the headless schema/occupancy coverage.
Layers: `"normal"` (default, blocking — a generated bounds box via the
static placement set or the `isolated` tag's own body; `collisionBounds`
takes priority over physical `bounds`, legacy bounds-less definitions
keep raw mesh collision), `"thin-floor"` (carpets etc.), `"thin-wall"`
(switches, picture frames) and `"ceiling"` (lights, panels).
Thin/ceiling layers render (batched into StaticGeometry or as plain
entities) but get NO generated collider, isolated physics body or
navmesh obstruction; ceiling items are placed at `pos.y += cellHeight`
(mesh origin = ceiling attachment point) instead of the room
`furnitureYOffset`. Thin-wall items anchor to the actual wall plane
(selected by the placement's quarter-turn): internal walls inset 0.1 m,
external walls 0.2 m outside the cell boundary. A prefab placed through
a non-normal definition has any authored rigid-body/collider components
stripped from its runtime subtree; normal prefabs keep them.
`TERRAIN_TEST_FILTER=furnitureLayers` covers four-layer collider counts
and exact wall/ceiling transforms.
The Blender pipeline is the asset base: `bake_furniture.py` writes a
deterministic per-source atlas band, `merge_furniture_atlases.py` produces
one shared `furniture-atlas.png`/`M_Furniture_Atlas` material, and
`export_furniture_parts.py` emits GLBs without duplicated embedded images
plus schema-valid JSON with mesh bounds and action/position marker metadata;
`validate_furniture_export.py` then checks UV bands and shared mesh
variants in the build. `add_furniture_fixtures.py` adds the layer
fixtures (carpet, switch, picture, ceiling light, shop shelf) to the
source blend; `furniture_gallery` renders per-asset pivot/bounds/
collider/occupancy/POI previews and an HTML index with atlas UV regions
into `resources/buildings/parts/gallery/index.html`. Authoring
conventions live in
`assets/blender/buildings/parts/FURNITURE_MODELING.md`.
Placement now converts physical bounds (or an explicit `footprintCells`
override) to occupied cells at the target grid's cell size and honors
quarter-turn rotation. All validation runs through the shared
`FurniturePlacement` kernel (`systems/FurniturePlacement.hpp/.cpp`, pure —
no flecs/OGRE scene deps, headless-testable): `canPlace` checks room
containment, grid cells, floor/wall requirements, door/window tags and
overlap with committed placements plus earlier requests of the same batch;
`expandGroup` turns a root request + relative `FurnitureGroupChild`ren
(local cell offsets rotated with the root, rotation offsets added) into
absolute requests sharing one `groupId`; `commit` validates the whole
batch and appends all-or-nothing (atomic roll-back, deterministic
gridUid-based placementIds); `removeGroup` tears a group down.
`RoomLayoutSystem` validates through the same kernel. Tests:
`furniture_test` group-transaction + placement-rule cases.
Navigation (6.5.D): normal-layer furniture contributes a generated
definition-bounds box to navmesh input
(`NavMeshSystem::createFurnitureNavEntity` — collision bounds when present,
else physical bounds, expanded by `clearance`, transformed like the visual
placement incl. the room `furnitureYOffset`); thin/ceiling layers contribute
nothing. Raw furniture visuals are skipped instead: runtime furniture
subtrees/POIs carry `FurnitureRuntimeTag` (excluded like doors in
`collectEntitiesFromFlecsEntity`), which also prevents double-rasterizing
prefab furniture that has its own collider meshes. Note: the mesh-name
filter `isNavMeshGeometry()` still drops names containing
"ceiling"/"frame"/"furniture", so generated obstacle meshes must avoid
those substrings (the bounds box is `NavObstacleBoundsBox`).
Coverage: `TERRAIN_TEST_FILTER=navMeshFurniture`.
`FurnitureLibrary::selectByTags` honors definition `weight` (zero-weight
entries are never selected; all-zero selects nothing) and room tag
expressions: source terms are ANDed, `a|b` accepts either tag and `!a`
rejects one. The layout DSL can record per-room `:furniture` terms in
`BuildingPlan::roomFurnitureTags`. Materialization (6.5.F):
`LayoutLib::planRoomBounds()` (pure, in BuildingPlan.hpp) derives
per-floor room bounds from `plan.roomIds` (Floor-flagged cells only, so
cut corners stay out), and `TownGenSystem::createPlanRooms()` emits
RoomComponent children for full/interiorOnly grids — exteriorOnly shells
stay unfurnished. The materialized rooms carry the DSL `:type`/
`:furniture` terms (fallback: type, then room name), a deterministic
per-room `furnitureSeed` (fnv1a of `address|name|floor`) and a stable
`townroom-<address>-<name>-<floor>` persistentId, and run with all
create* geometry flags OFF (the plan already carries the geometry — they
only drive furniture generation; `RoomLayoutSystem::processRoom` skips
its cell-creation loop entirely when both createFloor and createCeiling
are false, avoiding phantom cells in cut corners). Coverage:
`TERRAIN_TEST_FILTER=townPlanRooms` (two-room DSL building furnished
end-to-end).
Composition rules (6.5.F): the room option `:compose ((group ...)
(row ...))` stores `LayoutLib::FurnitureCompositionRule`s in
`BuildingPlan::roomCompositions`; `createPlanRooms` copies them into
`RoomComponent::furnitureCompositions` (serialized, editable in the
room property panel). `(group (tags) [:at center|zneg|zpos|xneg|xpos]
[:rot 0..3] (child (tags) dx dz rot) ...)` selects root/children with
weighted tag terms and commits atomically through
`FurniturePlacement::expandGroup` + `commit`; child cell offsets rotate
with the root. `(row (tags) [:at edge] [:step n] [:skip n] [:count n])`
walks a wall edge from its lower coordinate end, skipping blocked cells
(best-effort, count 0 = fill). `RoomLayoutSystem::evaluateCompositions`
runs these before the priority-class tag walk, using the room's isolated
seed; an over-constrained group logs a rollback warning.
Coverage: DSL grammar in `road_geometry_overlap_test`, rule round-trip
in `cellgrid_door_test`, and `TERRAIN_TEST_FILTER=townCompositions`
(centered table + four chairs, a row, deterministic regeneration).
Prefab-backed definitions instantiate through PrefabSystem as runtime,
non-editor children of the grid and are cleaned up on grid rebuild. The
Create Prefab dialog has a **Furniture prefab** checkbox that manages the
`furniture.` filename prefix without duplicating it, and the prefab browser
groups that prefix ahead of other prefabs. In that mode the dialog also
accepts selection tags and a layer, measures the prefab's local bounds
and writes a schema-v1 companion
`resources/buildings/parts/furniture/furniture-<id>.json` via
`FurnitureDefinition::writePrefabDefinition` (deterministic sourceHash
from the prefab JSON, parse-validated, byte-identical on unchanged
rewrite), then reloads FurnitureLibrary. Exported Blender action markers
become runtime entities carrying both `ActuatorComponent` and
`SmartObjectComponent`; nested/root `position-*` markers become named
runtime transforms (no actuator). A prefab that already contains either
component keeps its authored interactions without duplicate generated
POIs, and those interactions are registered in the same query shape.
`CellGridSystem::queryFurniturePois` / `findFurniturePoi` expose
`FurniturePoiInfo` records with stable ids
(`furniture.<placementId>.<marker>`, duplicate names get a deterministic
`#N` suffix), grid-local transforms and room/furniture metadata; query
filters are grid, room id, furniture tag, action and marker type.
`tests/prefabs/furniture_interactive_chair.prefab` +
`resources/buildings/parts/furniture/furniture-interactive-chair.json`
are the editor-authored prefab example. Coverage:
`TERRAIN_TEST_FILTER=furniturePoiQuery` (mesh + prefab markers, stable
ids/transforms across rebuilds).
#### Generation mode (F4/F5)
`CellGridComponent::generationMode` (string: `"full"` default,
+28
View File
@@ -57,6 +57,7 @@ set(EDITSCENE_SOURCES
systems/NormalDebugSystem.cpp
systems/RoomLayoutSystem.cpp
systems/FurnitureLibrary.cpp
systems/FurniturePlacement.cpp
systems/StartupMenuSystem.cpp
systems/PauseMenuSystem.cpp
systems/ItemRegistry.cpp
@@ -231,6 +232,7 @@ set(EDITSCENE_SOURCES
lua/LuaGlobalStateApi.cpp
lua/LuaDoorApi.cpp
lua/LuaHudApi.cpp
lua/LuaFurnitureApi.cpp
lua/LuaTerrainApi.cpp
lua/LuaSceneSwitchApi.cpp
systems/TerrainTests.cpp
@@ -832,6 +834,32 @@ add_test(NAME globalStateTest
COMMAND global_state_test
WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR})
# ---------------------------------------------------------------------------
# Test: Furniture definition schema (phase 6.5, headless)
# ---------------------------------------------------------------------------
# Only the parser is linked; the OGRE resource discovery path remains covered
# by editor/demo runs. No window or RenderSystem initialization is needed.
add_executable(furniture_test
tests/furniture_test.cpp
systems/FurnitureLibrary.cpp
systems/FurniturePlacement.cpp
components/CellGrid.cpp
)
target_link_libraries(furniture_test
OgreMain
flecs::flecs_static
nlohmann_json::nlohmann_json
)
target_include_directories(furniture_test PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
)
add_test(NAME furnitureTest
COMMAND furniture_test
WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR})
# ---------------------------------------------------------------------------
# Road Geometry Library — standalone wedge/segment generation (M5)
# ---------------------------------------------------------------------------
+3
View File
@@ -66,6 +66,7 @@
#include "lua/LuaSaveLoadApi.hpp"
#include "lua/LuaDoorApi.hpp"
#include "lua/LuaHudApi.hpp"
#include "lua/LuaFurnitureApi.hpp"
#include "lua/LuaGlobalStateApi.hpp"
#include "lua/LuaSceneSwitchApi.hpp"
#include "systems/PlayerControllerSystem.hpp"
@@ -985,6 +986,8 @@ void EditorApp::setup()
editScene::registerLuaGlobalStateApi(L);
editScene::registerLuaDoorApi(L);
editScene::registerLuaHudApi(L);
editScene::registerLuaFurnitureApi(L, &m_world);
editScene::setLuaCellGridSystem(m_cellGridSystem.get());
editScene::setLuaHudSystem(m_gameHudSystem.get());
editScene::setSceneSwitchEditorApp(this);
editScene::setLuaPlayerCharacterResolver(
+4
View File
@@ -469,6 +469,10 @@ public:
{
return m_navMeshSystem.get();
}
RoomLayoutSystem *getRoomLayoutSystem() const
{
return m_roomLayoutSystem.get();
}
CellGridSystem *getCellGridSystem() const
{
return m_cellGridSystem.get();
@@ -68,6 +68,16 @@ void CellGridComponent::removeCell(int x, int y, int z)
cells.erase(it, cells.end());
}
std::string FurnitureCell::effectivePlacementId(
const std::string &gridUid) const
{
if (!placementId.empty())
return placementId;
return gridUid + ":furniture:" + std::to_string(x) + ":" +
std::to_string(y) + ":" + std::to_string(z) + ":" +
furnitureType;
}
FurnitureCell* CellGridComponent::findFurnitureCell(int x, int y, int z)
{
int64_t key = ((int64_t)x + 2048 * (int64_t)y + 2048 * 2048 * (int64_t)z);
@@ -9,6 +9,8 @@
#include <flecs.h>
#include <Ogre.h>
#include "../layoutlib/BuildingPlan.hpp"
/**
* @brief Cell flags for building geometry
*
@@ -100,6 +102,19 @@ struct FurnitureCell {
std::vector<std::string> tags;
std::string furnitureType;
int rotation = 0; // 0-3, representing 0, 90, 180, 270 degrees
/** Stable placement identity for POIs/references (serialized when set).
* Legacy scenes leave it empty; the runtime falls back to a
* gridUid + anchor + type identity. */
std::string placementId;
/** Ownership used by room regeneration: authored (default),
* generated or script. */
std::string owner = "authored";
/** Future composition group; empty = independent placement. */
std::string groupId;
/** Stable fallback identity without mutating legacy content. */
std::string effectivePlacementId(const std::string &gridUid) const;
int64_t getKey() const {
return (int64_t)x + 2048 * (int64_t)y + 2048 * 2048 * (int64_t)z;
@@ -300,6 +315,12 @@ struct RoomComponent {
bool fillRoomWithFurniture = false; // Automatically place furniture based on tags
unsigned int furnitureSeed = 42; // Seed for deterministic furniture placement
float furnitureYOffset = 0.05f; // Y offset for all furniture in this room
/** Declarative furniture composition rules (phase 6.5.F), the node
* form of the DSL room `:compose` option — evaluated by
* RoomLayoutSystem through the FurniturePlacement kernel BEFORE the
* tag-priority walk. Empty = the tag walk furnishes the room. */
LayoutLib::RoomCompositionRules furnitureCompositions;
// Dirty flag - triggers regeneration of cell grid
bool dirty = true;
@@ -609,6 +630,13 @@ struct GeneratedTownTag {
bool generated = true;
};
/** Runtime furniture subtrees/POIs are represented in navmesh input by a
* generated bounds box; skip their raw visual meshes to avoid duplicate or
* vertical-span-merging geometry. */
struct FurnitureRuntimeTag {
bool enabled = true;
};
/**
* @brief Furniture template - defines a furniture item
*/
+588 -51
View File
@@ -3143,12 +3143,12 @@ road-editing UX and town-area ownership:
params leave edges unset, split survival
(`runGeneratorProbe`, `road_geometry_overlap_test` PASSED).
### Phase 6 — Foot traffic NPCs
### Phase 6 — Foot traffic NPCs — COMPLETE (2026-10-25)
Makes the town population behave like real townsfolk: people follow
their daily schedules on foot or in their own cars, and look right at
every distance. Part of the work is asset pipeline
(assets/blender/characters), part is code.
Makes the town population follow daily schedules on foot or in its own
cars and adds the runtime character LOD switching used at distance.
The completed scope is the schedule/traffic behavior and asset-pipeline
runtime code; final character content authoring is Phase 10.5 scope.
**Already in place (no work needed — do not redo):**
@@ -3212,9 +3212,9 @@ cruising; moved here from phase 8):**
detail tier walks it from the car to the location doorstep.
NpcTaxiSystem refuses goals for driving AND seated drivers
(`isDriving`/`isSeated`).
6. Remove the endless cruising loop; `cruisingRatio` goes away
(ambient cruising may return later as a rare garnish, never the
default).
6. Remove the endless cruising loop; `cruisingRatio` goes away.
Schedule-adherent driving is the only ambient traffic mode in this
phase.
7. F7 overlay: show the driver's leg state (parked at X / waiting /
walking to car / driving to Y / arrived) — [CAR] marker + car
detail section.
@@ -3246,8 +3246,7 @@ broke `--test-motion`'s bit-exact reproducibility.
escape (the driver's own car is the goal, not an obstacle) until
boarding.
**6.C — Character asset system (asset pipeline +
assets/blender/characters; partly user modeling work):**
**6.C — Character asset runtime and LOD pipeline — DONE:**
1. Detailed nude parts for the no-clothes case — DONE (code side).
The catalog's layer-0 ("nude base") meshes for the
@@ -3257,56 +3256,564 @@ assets/blender/characters; partly user modeling work):**
`CharacterSlotSystem::resolveMesh` now prefers a layer-0 entry
tagged `detailed-nude` at outfitLevel 0; slots without a detailed
variant keep the current composition base as the fallback, so the
parts can be authored one slot at a time. Remaining: author the
detailed variants in the blends (modelling work — see
assets/blender/characters/CHARACTER_MODELING.md §3).
parts can be authored one slot at a time.
2. Character prefabs get a `character.` file-name prefix — DONE
(code side: `CharacterRegistry::generatePrefabPath` writes
`prefabs/character.<id>.json`, legacy `char_<id>.json` paths
migrate on save — deliberately NOT on load: scene-embedded spawners
and demo staging reference the old names), with prefab save/load
support in the editor for easy renaming.
Remaining: rename the existing prefab files (modeller/user side).
Modeller-facing pipeline notes:
assets/blender/characters/CHARACTER_MODELING.md.
3. Manual LOD scheme (Ogre mesh LODs):
- LOD0 (< 10 m): modular, highly detailed body; materials optimized
but not at the cost of quality.
- LOD1 (10-20 m): modular, one material per body part (same style
as the cars — one shared material), 2048x2048 texture atlas, face
replaced by a face mask from the same atlas, shape keys removed.
- LOD2 (20-60 m): pre-generated combinations of body parts and
shapes (thin/fat); no shape keys; hair physics disabled (no
accumulated momentum from physics manipulation); generic/closest
stand-ins allowed.
- LOD3 (60-80 m): decimated combined meshes, triangle budget ~400
(hard limit 400).
- Interiors only need LOD0/LOD1 (fewer NPCs) — benchmark to
confirm.
4. Automate LOD + atlas generation from the source meshes in the
export pipeline (like the vehicle ID-map bake); findings that
require serious pipeline changes are recorded for later phases.
5. Batch/FPS budgets from 5.5 must hold with a full walking
population (verify with --benchmark / --benchmark-drive).
support in the editor for easy renaming. Modeller-facing pipeline
notes: assets/blender/characters/CHARACTER_MODELING.md.
3. Manual LOD scheme (mesh variants, NOT Ogre mesh LODs) — DONE:
- LOD0 (< 10 m): modular, full-detail parts (unchanged).
- LOD1 (10-30 m): per-part `<mesh>_lod1.mesh` variants — same
geometry, one shared 2048x2048 `charlod_<sex>` atlas material per
sex, shape keys removed.
- LOD2 (30-70 m): whole-character impostors
`charlod_<sex>_thin/fat_lod2.mesh` (default outfit joined, fat
baked at 0/1, hair rigid-bound to mixamorig:Head; face and hair
included); skin palette pruned to 23 core bones — dropped groups
(fingers, eyes, bust, hair/coat-skirt physics chains) remap to
the nearest kept ancestor, fitting the 64-bone hardware-skinning
budget with NO skeleton or animation conversion; hair physics
disabled; impostor shares the master's skeleton.
- LOD3 (70-100 m, despawn at 100 m):
`charlod_<sex>_thin/fat_lod3.mesh` decimated to ~400 triangles
(hard limit 400, verified 399/400).
- Interiors only need LOD0/LOD1.
4. LOD + atlas generation automated in the export pipeline — DONE:
`assets/blender/characters/generate_lods.py` (per-sex, on the
consolidated blend; atlas composite + UV remap + joins + decimate)
wired into `assets/blender/characters/CMakeLists.txt`
(`edited-normal-<sex>-lods.blend`) and `export_models2.py` (6th
argument appends the lods blend into the main OGRE export so
impostors link the same skeleton). Runtime swaps meshes by camera
distance with 2 m hysteresis in `CharacterSlotSystem`
(`resolveLodTier`/`applyLodTier`; tier 0<->1 rebuilds the character
since OGRE 1.x has no `Entity::setMesh`). Modelling-facing docs:
assets/blender/characters/CHARACTER_MODELING.md §6.
5. Full-population benchmark and LOD regression investigation — DONE
(2026-10-25 run with the previous 65 m demo spawn radius, full
population 145 NPCs + 18-car fleet, 17739 m tour):
`[benchmark-drive] PASS`; headless llvmpipe-relative wall
avg 24.1 ms (p50 21.7, p95 38.7, p99 48.8, max 748.9 — one
shader-compile freeze), batches avg 120 / max 296, tris avg 327k.
render 16.7 ms avg of which queueMain 13.4 ms; the old
find-by-material bottleneck is gone (find objs avg 0.69 ms; the
per-material find_objs table is now empty). The run also recorded
batches above the 5.5 "up to 80" target and `water` as the dominant
systems spike (>8 ms on 843/6000 frames; physics x8,
proceduralMesh x4). Follow-up (same day): fixed `lodMeshAvailable`
to use the ResourceGroupManager on-disk check
(MeshManager::resourceExists only sees loaded resources, so no LOD
tier had ever engaged); runtime now logs hardware skinning
`(22/139 bones)` for charlod_male and `(22/149)` for charlod_female
— the core-bone impostor palette fits the 64-bone budget, and the
full battery re-passed with tiers active.
**Verification:** `--test-traffic-schedule` (6.A), existing
`--test-motion` / `--test-detail` / `--test-taxi` /
`--test-determinism` keep passing; `--benchmark-drive` with full
population + traffic holds the 5.5 budgets.
`--test-taxi-lowdetail` / `--test-determinism` keep passing (all 7
battery entries PASS, 2026-10-25); `--benchmark-drive` with full
population + traffic completed PASS (numbers in 6.C.5 above).
### Phase 6.5 CellGrid furniture placement - before interiors
**6.D — Distance tuning — DONE (2026-10-25):**
- all furniture assets should use the same atlas material, which should be generated by Blender's export script (the one which generates jsons too)
- as with node version, building layout DSL should be able to define room type and tags/keywords for the furniture
- should support 4 layers of furniture:
- thin floor objects not blocking path/no collider (carpets, floor lights, etc.)
- thin wall objects not blocking path (light switches, wall jacks/outlets, picture frames, etc.)
- normal wall and multi-cell objects (multi-cell is new), having colliders
- ceiling objects (lights, panels, etc.)
- each furniture is defined either by json (as it is now) or prefab (new).
- some room layouts need special furniture placement, that should be supported via Lua events. This is about unique buildings and stores/shops for special room layouts, probably another S-expression based DSL would be very useful here in addition to Lua.
- NPC AI POIs/actuators are either a part of prefab/json declaration (prefab is supported more)
- furniture prefabs should have names starting with "furniture." and work together with furniture json and support the same metadata as furniture json. Probably just be able to create furniture json for the prefab easily. Prefix should be supported in editor's prefab save/load for easy naming.
- LOD0 0-10 m, LOD1 10-30 m, LOD2 30-70 m, LOD3 70-100 m.
- The taxi demo spawns NPCs at 85 m and keeps despawning at 100 m,
preserving a 15 m hysteresis band. The generic NpcDetailSystem
defaults remain 80/100 m for other users of the system.
### Phase 6.5 — CellGrid furniture placement — before interiors
**Goal.** Build furniture **Blender-first**, while keeping one shared,
deterministic placement pipeline for hand-authored CellGrids and evaluated
building-layout plans. A furniture definition can reference a mesh exported
from Blender or an entity prefab built around one; both forms use the same
metadata, four layers, occupancy rules, identity rules and interaction query
API. Phase 8 still owns town-wide interior scene generation; this phase
proves the system on hand-authored grids, pure `BuildingPlan` output and a
small set of representative complex interiors.
**Authoring boundaries (automation without losing flexibility):**
- **Blender is the asset base.** Keep geometry, UVs, the shared atlas,
placement pivot, physical bounds, optional simplified collision meshes,
layer/tags/selection weight and simple asset-local action/position markers
with the furniture source. Export these automatically; do not hand-edit
generated `furniture-*.json` artifacts.
- **Prefab/editor data extends Blender assets when ECS structure is
needed.** Use it for multi-entity components, authored SmartObject and
Actuator subtrees, custom scripts and behavior that cannot sensibly fit
into Blender custom properties. A prefab can reference the Blender mesh
without forking its visual asset.
- **The shared room-composition evaluator owns reusable layouts.** Center
arrangements, wall rows, relative groups, repetitions, random candidate
choice, clearance and fit checks are room/layout concerns, not a pile of
Blender empties. The same evaluator serves node-based `RoomComponent`
layouts and DSL `BuildingPlan` layouts.
- **Lua owns unique or behavior-heavy interiors.** Store counters/shelves
can start from declarative composition, while a store's work behavior or
a garage's repair action and direct exterior entry use normal ECS, door,
actuator, vehicle and Lua APIs. Do not encode cross-room/game behavior
in Blender properties.
- **The C++ placement kernel stays generic.** It validates footprints,
occupancy, layers, transforms, navigation and lifecycle, but contains no
dining-room/store/garage special cases.
Examples that establish the boundary:
- Table + chairs: Blender exports the table and chairs as assets with local
sit markers; the room evaluator places the table at a center anchor and
chairs relative to it as one atomic, non-overlapping group.
- Store: Blender exports shelf/counter fixtures and local work/use markers;
the DSL lays out shelves/counter rows with aisle clearance; Lua supplies
any unique work action.
- Garage: Blender exports the lift/tool fixtures; the layout guarantees a
vehicle-sized bay and clear path; Lua plus the existing door/actuator
systems wire the repair action and direct exterior entry.
**Existing baseline (keep and extend; do not rewrite):**
- `FurnitureLibrary` discovers `furniture-*.json`, reads name/mesh/tags and
keeps the raw JSON. `RoomLayoutSystem` already performs seeded tag-based
essential/important/optional/filler selection and writes `FurnitureCell`
entries. Cell Grid serialization and the room/grid furniture editors
already exist.
- `assets/blender/buildings/parts/edit-firniture-props.py` provides Blender
root/action/position helper panels, `bake_furniture.py` creates the atlas
material and `export_furniture_parts.py` exports GLB + JSON definitions.
The current authoring workflow and its present limits are documented in
`assets/blender/buildings/parts/FURNITURE_MODELING.md`; Phase 6.5 expands
that document as the pipeline lands.
- The current generated library has 29 GLB-backed definitions (chairs,
tables, beds and bathroom fixtures), including exported action/position
metadata; the demo smoke run discovers all 29. They all use the default
`normal` layer and have no footprint metadata. Reuse this content: the
missing examples are thin-floor/thin-wall/ceiling items and a prefab
variant, not another set of chairs and beds. Existing exports embed a
same-named `M_Furniture_Atlas`/`FurnitureColor` in each GLB, so one
shared runtime atlas/material still needs to be proved and deduplicated.
- Engine layers landed 2026-10-25: `FurnitureDefinition::Layer` reads
`normal` (default), `thin-floor`, `thin-wall` and `ceiling`; normal mesh
furniture gets colliders, thin/ceiling items do not, and ceiling items use
`pos.y += cellHeight`. This is only the mesh runtime baseline: multi-cell
footprints, wall anchoring and normal-furniture navigation blocking are
not complete. In particular, NavMeshSystem currently excludes ALL
furniture, so a collider does not yet imply a navmesh obstacle.
- The layout DSL already has `room ... :type`, room ids/names/types and
`if`/`cond`/`dotimes`/`for-range`; reuse these rather than creating a
second S-expression language. `BuildingPlan::placements` and
`(place ...)` are only recorded, not materialized. TownGen currently
fills grid cells and roofs but does not create RoomComponents or
furniture from the plan.
- `FurnitureTemplateComponent`, prefab serialization and prefab-held
SmartObject/Actuator components exist, but are not connected to
FurnitureLibrary.
**Cross-cutting invariant:** every schema, placement, runtime, interaction
and editor feature is implemented once and consumed by BOTH node-based and
DSL-based layouts. Translating DSL data to shared placement requests is
allowed; maintaining a second furniture implementation for one layout path
is not.
The work is split so each slice has a usable result and executable
acceptance criteria. A new headless `furniture_test` owns schema and pure
placement/composition tests; extend `road_geometry_overlap_test` for layout
evaluation, `component_lua_test` for Lua/API coverage and the terrain/demo
tests for runtime physics, navigation and lifecycle checks. Blender comes
early so its exported assets become the test fixtures instead of being a
late retrofit.
**Implementation status (2026-10-25):** 6.5.A is complete (schema,
validation, aliases/source hashes and `furniture_test`). 6.5.B core is
complete: typed Blender properties, automatic bounds, deterministic source
hashes, per-source atlas bands merged into one runtime atlas/material,
failure markers and migration of all 30 current definitions. The
Blender fixture generator added thin-floor carpet, thin-wall switch and
picture, ceiling light and a shop shelf with a simplified collision
proxy (35 generated definitions total); `furniture_gallery` renders
per-asset pivot/bounds/collider/occupancy/POI previews plus atlas UV
rectangles into `resources/buildings/parts/gallery/index.html`.
6.5.C is complete: bounds-to-cell
occupancy, placementId/owner/groupId serialization, generated-vs-authored
room regeneration, RoomLayoutSystem overlap/room/floor/wall checks, and
the shared `FurniturePlacement` kernel (systems/FurniturePlacement.*) with
atomic group transactions — `expandGroup` turns a root request plus
relative children into absolute placements, `commit` validates the whole
batch against the grid and itself before writing anything (roll-back on
first failure), `removeGroup` tears a group down; RoomLayoutSystem now
validates through the same kernel, and `furniture_test` covers atomic
commit/rollback, rotated child offsets, per-rule rejection and group
removal. 6.5.D has selective navigation: normal-layer
placements contribute a generated definition-bounds box to navmesh input
(collision bounds > bounds > footprint fallback, plus clearance) while
thin/ceiling layers stay non-blocking and `FurnitureRuntimeTag` keeps
prefab visuals from being double-rasterized; `testNavMeshFurniture`
covers baseline path, deflection, removal and thin-floor carpet staying
non-blocking. 6.5.D is complete: normal mesh furniture collides through
`collisionBounds` when present, otherwise physical `bounds` (legacy
bounds-less definitions keep raw mesh collision), while thin/ceiling
layers get no collider or nav obstruction; thin-wall items anchor to the
actual internal/external wall plane and ceiling items to cellHeight.
`TERRAIN_TEST_FILTER=furnitureLayers` verifies four-layer collider
counts, shelf proxy vs physical bounds, exact wall/ceiling/floor
transforms and rebuild stability. 6.5.E is complete: the
runtime prefab path and editor `furniture.` prefix/category workflow
are joined by companion-definition generation — Create Prefab's
Furniture prefab mode now takes selection tags/layer, measures the
prefab's local bounds and writes a schema-v1
`resources/buildings/parts/furniture/furniture-<id>.json` through
`FurnitureDefinition::writePrefabDefinition` (deterministic prefab
sourceHash, parse-validated output, byte-identical rewrite). The
headless `prefab companion definition generation` test covers metadata,
determinism and hash changes. 6.5.F has `:furniture` room
metadata, tag terms (`a|b` OR, `!a` NOT, list entries ANDed) shared by DSL
and node rooms, editor DSL help, and weighted definition selection.
Materialization landed: `LayoutLib::planRoomBounds` (pure, probed in
road_geometry_overlap_test) derives per-floor room bounds from
`plan.roomIds` (floor cells only, so cut corners stay out), and
`TownGenSystem::createPlanRooms` emits RoomComponent children for
full/interiorOnly grids (exteriorOnly shells stay unfurnished) carrying
the DSL type/`:furniture` terms (fallback: type, then name), a
deterministic per-room furnitureSeed (fnv1a of address|name|floor) and a
stable `townroom-<address>-<name>-<floor>` persistentId; the shared
RoomLayoutSystem then furnishes them exactly like node-authored rooms
(the `townPlanRooms` terrain test drives a two-room DSL building
end-to-end). Composition primitives are complete: the room `:compose`
option records `FurnitureCompositionRule` groups/rows in
`BuildingPlan::roomCompositions`, materialization copies them into the
serialized `RoomComponent::furnitureCompositions`, and the shared
evaluator runs them before the priority-class tag walk. Groups select
root/children through weighted tag terms and commit atomically through
`FurniturePlacement`; rows walk a wall edge with step/skip/count and
skip blocked cells. The room property panel edits the same rules,
cellgrid_door_test covers their serialization, and the
`townCompositions` terrain test proves a centered table with four
chairs, a two-item row and deterministic regeneration. 6.5.G is
complete: action markers and nested/root position markers materialize as
runtime POIs; prefab-authored Actuator/SmartObject subtrees are retained
and registered without generated duplicates. `FurniturePoiInfo` ids are
stable (`furniture.<placementId>.<marker>`), and
`CellGridSystem::queryFurniturePois`/`findFurniturePoi` support grid,
room, furniture-tag, action and type filters.
`TERRAIN_TEST_FILTER=furniturePoiQuery` verifies mesh and prefab POIs,
grid-local transforms, no duplicates and identity across rebuilds. An
editor-authored prefab example lives at
`tests/prefabs/furniture_interactive_chair.prefab` with companion
definition `furniture-interactive-chair.json`. Follow-up runtime fixes
(2026-10-26): CellGrid binds the definition's configured material
explicitly (Blender-generated GLBs no longer depend on Assimp's generated
material), and floor/wall furniture starts at the actual floor surface
(local Y 0.2 plus `furnitureYOffset`) instead of the cell origin.
Atlas follow-up: variants sharing a Blender mesh datablock now remap UVs
once per datablock (fixes round-table/pole colors), and
`validate_furniture_export.py` runs in the build to verify shared-material
GLBs, band-local UVs and identical UV bounds across variants.
**6.5.A — Canonical definition schema and loader hardening:**
1. Make `FurnitureDefinition` the canonical placement record: stable id,
tags, layer, exactly one of `mesh` or `prefab`, local bounds and anchor
in meters, optional occupancy/clearance override, wall/floor requirements
and selection weight. Blender exports bounds automatically; only game
clearance exceptions need a manual override. Keep legacy mesh-only JSON
loading with 1x1/`normal` defaults. Layer, not a second contradictory
`blocksPath` flag, decides collision/navigation behavior.
2. Track definition ownership/source (`blender`, editor/manual or prefab)
and the generated source hash, so re-export updates Blender-owned visual
definitions without overwriting editor-owned ECS behavior. Share
parsing/validation with the FurnitureTemplate prefab metadata instead of
maintaining two field sets.
3. Reject unknown layers, invalid bounds/footprints, duplicate ids and
definitions with both/neither mesh and prefab; log the resource name and
do not silently substitute `normal`. Preserve interaction declarations
for 6.5.G rather than dropping them.
4. Publish the versioned JSON schema and one Blender-generated mesh example
plus one editor-authored prefab example. `furniture_test`: legacy
defaults, every new field, malformed input, deterministic duplicate
handling, ownership preservation and metadata round trip.
**6.5.B — Blender-first authoring, shared atlas and export validation:**
1. Upgrade `edit-firniture-props.py` from a prototype into the supported
authoring panel: typed root id/tags/layer/selection weight, pivot and
orientation helpers, automatic mesh-bound reporting, optional occupancy
or clearance override, optional simplified collision mesh, and helpers
for the existing asset-local `action-*`/`position-*` markers. Remove
name-based tag guessing and hardcoded preview paths. Keep these
properties asset-local; room layout fields are intentionally absent.
2. Make `bake_furniture.py` + `export_furniture_parts.py` one deterministic
pipeline across ALL furniture source blends. Export one canonical atlas
texture/material referenced by every part instead of embedding duplicate
same-named copies per GLB. UV regions and output ordering are stable;
re-exporting an unchanged asset produces identical artifacts.
3. Emit schema-valid `furniture-*.json` plus runtime-loadable meshes with
stable ids. The exporter computes physical bounds, writes
layer/anchor/weight/collision data and preserves action/position/GOAP
metadata. Validate missing meshes/materials, malformed tags, invalid
layer/footprint data, marker hierarchy errors and non-atlas submeshes;
stage every resource group (including the sofa content, which editScene's
resources.cfg does not currently index).
4. Migrate the existing 29 definitions without a content rewrite: clean
malformed tags and accidental double-prefix file names while preserving
definition ids through aliases. Add only the missing fixtures — thin-floor
carpet, thin-wall switch/picture, ceiling light and one visual fixture
intended for the editor prefab variant — and annotate existing large
items such as doublebed1 rather than modeling
new chairs/beds.
5. Provide an export preview/gallery that renders each asset with its pivot,
physical bounds/occupancy cells, collider, action markers and atlas area.
Expand `FURNITURE_MODELING.md` with the workflow and worked chair/bed /
thin-wall/ceiling examples. Acceptance: clean export smoke run, legacy
migration, shared-atlas runtime reuse, JSON-to-mesh reference checks and
gallery screenshots matching the source assets.
**6.5.C — Shared placement kernel, groups and persistence:**
1. Treat a FurnitureCell as one anchored placement, not one rendered item
per covered cell. Convert the definition's physical bounds to occupied
grid cells at the TARGET grid's cell size (with the optional gameplay
override), rotate bounds at 90° steps, and check every covered cell
before committing. All occupied cells must be on the same floor/in the
same room and pass floor, wall, door, window, aisle-clearance and overlap
rules; failure is atomic.
2. Add placement-query/reservation helpers shared by the grid editor,
RoomLayoutSystem, DSL and Lua. Reservations can be batched as one
group/transaction so relative arrangements (for example table + chairs)
either fit together or roll back together; a group's root transform owns
the children and each child's local offset/rotation stays part of the
placement request.
3. Distinguish generated placements from authored/script-owned ones so
regenerating one room cannot erase manual furniture or another room's
multi-cell/group footprint. Serialize a persistent placement/group id
plus anchor/rotation/ownership once; legacy 1x1 entries migrate
deterministically.
4. `furniture_test`: all rotations and grid cell sizes, bounds-to-cell
conversion, edge/corner fits, out-of-room/occupied rejection, group
rollback, door/window/aisle clearance, id stability and scene/prefab
round trips.
**6.5.D — Layer-correct runtime, physics and navigation:**
1. Materialize every validated placement or group through one transform
path: floor items use the room furniture offset, wall items anchor to the
selected wall plane with their local offset, and ceiling items use
cellHeight. The documented Blender pivot/forward-axis contract is the
only asset correction rule; RoomLayoutSystem carries no per-asset fixes.
2. Normal mesh furniture gets one intended collider — the optional
Blender-authored simplified collision shape when present, otherwise a
generated collider from its physical bounds — plus a navmesh obstruction
derived from its occupied footprint/clearance. Thin-floor, thin-wall and
ceiling items get neither a collider nor a navmesh obstruction. Replace
the current blanket furniture exclusion selectively; do not rasterize
full furniture vertical spans into the floor (the reason the old
exclusion existed). Keep door and aisle clearance paths walkable.
3. Retain StaticGeometry batching for simple non-interactive mesh items and
explicit ownership for isolated/runtime items. Rebuilds and scene
switches must destroy every entity, body and StaticGeometry entry without
duplicates. Runtime tests: four-layer collider counts, ceiling/wall
transforms, a normal 2x1 item blocks a query while a carpet does not,
multi-cell/group navigation, pivot/marker consistency and
rebuild/scene-switch leak checks.
**6.5.E — Prefab path for ECS-rich furniture:**
1. Allow a definition's `prefab` to instantiate through PrefabSystem as a
grid-owned child with the same anchor, rotation, layer, footprint and
group-local transform rules as a Blender mesh definition. Runtime
instances are not separately serialized or editor-selectable. Normal
prefabs use their authored colliders without adding a duplicate generated
one; thin/ceiling prefabs must validate as collider-free visuals.
2. Use the editor prefab when an asset needs an ECS subtree: multiple
renderables/bodies, SmartObject/Actuator components, scene scripts or
other behavior that should not be squeezed into Blender custom
properties. A prefab can reference a Blender-exported mesh, so a
Blender re-export updates visuals while the editor-owned behavior stays
intact. Definition ownership from 6.5.A prevents either generator from
overwriting the other's JSON.
3. Store canonical placement metadata in the prefab root's
FurnitureTemplateComponent and provide **Create Furniture Definition**
to generate/reload its companion `furniture-*.json`. Saving and loading
either form preserves the same schema fields; changing the definition
updates selection behavior without duplicating the entity subtree.
4. Add a `furniture.` prefab-name category/prefix to Create Prefab, the
prefab browser and save/load helpers (avoid `furniture.furniture.` on
repeated saves). Tests: prefix handling, companion generation,
ownership/metadata parity, prefab placement in all four layers and
groups, grid rebuild/scene-switch teardown and no EditorMarker leakage.
**6.5.F — Reusable room compositions in DSL and the node editor:**
1. Extend the existing `room` options with furniture tags/keywords and
optional declarative placement/composition rules. Store room type, tags
and rules in BuildingPlan and derive room bounds from its roomIds.
Support the existing conditionals and loops in furniture rules so large
or shop layouts need no second DSL. Expose the same rule data for
node-based RoomComponents instead of forking a node-only algorithm.
2. Add composition primitives to the shared evaluator: room anchors
(center/wall/corner/door-free edge), explicit local offsets/yaw,
one-root groups with children relative to the root, row/repetition
placement, deterministic tag-based candidate selection, fit/non-overlap
constraints and clearance zones for doors, aisles and vehicles. This is
where a centered table with surrounding chairs and reusable counter/shelf
rows are expressed.
3. Selection supports required/forbidden tags and the existing
essential/important/optional/filler priority classes, calls the 6.5.C
group reservation kernel, and receives building/floor/room identity as
an isolated RNG seed. Editing one room must not change another room's
draws; an over-constrained group rolls back atomically with a useful
warning.
4. Extend the existing plan-to-CellGrid materialization helper to emit
RoomComponents/furniture requests for full/interiorOnly grids; Phase 8
reuses that helper for on-the-fly scenes, while exteriorOnly TownGen
shells stay unfurnished. Resolve the existing `(place ...)` record
through the same prefab/furniture path when its target is furniture
instead of adding a parallel placement system.
5. `road_geometry_overlap_test`: grammar/errors, loops and conditionals,
expected multi-cell placements, group atomicity, determinism and
per-room RNG isolation. Runtime tests compare the same room through the
node editor and DSL field-for-field in canonical placement output,
including a centered table with four chairs and a counter/shelf row that
preserves its aisles.
**6.5.G — Furniture AI POIs and player actuators:**
1. Map Blender-exported `action-*`/`position-*` metadata into the same
runtime POI representation used by SmartObject/Actuator components.
A simple exported chair/bed/counter should need no hand-written JSON or
C++ to expose its local interaction markers. Prefab furniture keeps its
authored ECS subtree alive through placement and rebuilding and reports
the same POI shape.
2. Give each placement and named POI a stable identity derived from gridUid
+ persistent room/placement id + POI name. Expose a read-only query by
room, furniture tag and action for GOAP/behavior-tree/schedule users.
Group placement transforms every child marker from the group root, never
from stale entity ids or per-room hardcoded offsets.
3. Keep behavior out of the Blender schema: markers reference action names
and local transforms; ActionDatabase/behavior-tree/Lua code owns what the
action does. Unsupported exporter fields produce explicit validation
errors, not silently missing interactions.
4. Tests: equivalent Blender JSON and prefab chairs expose equivalent POIs;
an NPC can query/path to a SmartObject and a player receives and executes
an actuator action; table chairs rotate with the group; multi-cell
anchors place POIs correctly; rebuild/save/load preserves identity
without duplicate components.
**6.5.H — Lua hooks for unique interiors and behavior:**
1. Add ordered room-generation events around the default evaluator (before
placement, with optional default suppression, and after placement).
Context includes grid/room stable ids, room type/tags, seed and a mutable
placement/composition list. A small `ecs.furniture` API lists
definitions and validates add/update/remove operations through 6.5.C; no
raw entity ids are persisted as placement identity.
2. Deliver script changes as one room transaction: validate all edits,
report script/placement errors with the room id, and leave a defined
fallback result. Event order and seeds are deterministic across reloads;
exteriorOnly grids do not emit furniture-generation work.
3. Lua can also bind stable placement/POI ids to actions, doors and vehicle
events. Common reusable arrangements still belong to 6.5.F — the table
+ chairs case must not be a Lua script. Lua is for cases such as a
unique shop or garage, or for behavior attached to a declarative layout.
4. Worked acceptance cases:
- Store: DSL lays out shelf rows and a counter with aisle clearance;
Lua registers a `work_counter` action on the exported counter POI.
- Garage: the layout guarantees a vehicle-sized repair bay and a clear
entrance path; a normal scene-switch/exterior door provides direct
entry, and Lua registers `repair_vehicle` on the bay POI. A headless
check only needs to prove a vehicle can occupy the bay and the action
reaches it; damage/economy simulation is not furniture scope.
5. Add `lua-examples/furniture_layout_example.lua` and a compact garage
example showing stable-id action binding. `component_lua_test`: event
order, suppression, valid/invalid edits, transaction rollback/fallback,
determinism and serialization round trip.
**6.5.I — Integrated examples, regression battery and documentation:**
1. Add a Blender-generated furniture gallery proving the base path before
complex rooms: all four layers, automatic bounds/footprints, shared
atlas material, collision markers and one working sit/use POI. The
gallery doubles as the asset-export smoke scene.
2. Furnish one demo-taxi interior with a rotated multi-cell item and a
DSL-authored centered table + chairs group, all from Blender-exported
assets. Then prove the flexibility boundaries with a store
(counter/shelf rows, clear aisles, working-counter action) and a garage
(vehicle-sized repair bay, direct exterior entry and a scripted repair
event). Add `--test-furniture` to drive these contracts headlessly; do
not delay the slice for town-wide interiors.
3. Pass `furniture_test`, `road_geometry_overlap_test`,
`component_lua_test`, `cellgrid_door_test`, relevant terrain/navmesh
tests, `--test-furniture` and the existing `--test-switch` battery.
Verify node-based and DSL rooms produce identical placements, normal
paths remain navigable around furniture, thin layers never block,
repeated regeneration is deterministic and batch counts use the shared
atlas as intended.
4. Keep `assets/blender/buildings/parts/FURNITURE_MODELING.md` current as
the Blender authoring reference: root/pivot/axis conventions, layer and
bounds metadata, marker hierarchy, collision/atlas rules, export command,
validation errors and preview workflow. Update the FurnitureLibrary
schema, CellGrid/Layout DSL reference, Lua examples and AGENTS.md with
the same Blender -> placement kernel -> prefab/DSL/Lua decision matrix.
**Explicitly later:** Phase 7 owns stairs, Phase 8 owns generated interior
scenes and town rollout, and Phase 8.5 owns residents, bed-capacity rules
and schedule/job behavior. Vehicle damage/economy simulation is likewise
gameplay scope; Phase 6.5 supplies and proves the repair-bay furniture, POI,
path and action wiring it will use.
### Phase 6.6 — Parking spots & vehicle drop-off (problem statement)
Observed in the demo (2026-10-24), all four stem from the same gap:
there is no first-class parking concept, only `Parking`/`Curb` POI
points that a single hash places a car on:
1. **Cars block building exits.** Parking/curb spots are placed
without regard to door positions or the walking paths between a
building's entrance and the road, so parked cars end up blocking
the way out (visually blocking exit from the doorway).
2. **Drivers abandon the car mid-road.** When a drive leg finishes,
the driver alights wherever the AI managed to stop — frequently in
the traffic lane — instead of reaching a parking spot or at least
pulling to the road side/shoulder. Vehicles must never be dropped
in a lane.
3. **Stacked cars.** Spot assignment has no occupancy tracking: a
visiting car's `parkingFor()` destination pick can land on another
car's home spot (and curb POIs can coincide), so two vehicles
spawn/park exactly on top of each other.
4. **Cars can't reach the road.** Spots beside the road sit on
un-flattened, sometimes sloped ground; a spawned vehicle cannot
climb from its spot onto the road surface. Parking needs terrain
compliance (flattened pad) and a drivable ramp/connection to the
road.
Design considerations to solve for (not yet formulated into an
implementation — the DSL side especially needs thought):
- **Parking lots in the layout DSL.** A lot should be definable per
building lot (most important later for commercial buildings, which
NEED proper lots; residential can start minimal). Placement rule of
thumb: between the road and the building, but leaving enough clear
space for foot travel between the parked cars and the building
entrance — the building<->road walking path must stay passable and
door clearance zones must stay car-free.
- **Discrete slots, not points.** A parking lot/rect should enumerate
individual slots (position + orientation, capacity > 1) so multiple
cars are handled gracefully: occupancy is tracked per slot, no two
cars ever receive the same slot (home spots, destination picks and
spawn placement all draw from the same occupancy registry).
- **Drop-off discipline.** The symbolic layer already chains cars
through `destParkPos`; the high-detail AI must actually ARRIVE there
(or at the nearest free fallback slot, or at least the road
shoulder) before the driver alights — never mid-lane. Blocked or
unreachable slots need a defined fallback.
- **Access ramps & compliance.** Parking pads get terrain flattening
and a shallow, drivable connection ramp to the road edge so NPC
vehicles can reliably drive in/out; spawn-time validation that the
car can physically reach the road from its slot.
- **Curb fallback stays** for layouts without parking rects, but curb
placement must be at the road side, oriented along the edge, out of
the lane, and door-clear.
### Phase 7 — CellGrid stair generation - before interiors
@@ -3474,6 +3981,23 @@ per-NPC picks) — the work is content, tooling and retargeting:
outfit-level tiers (home vs public), more hairstyles. Keep the
deterministic per-NPC pick (`fnv1a(npcKey|slot)`) so looks stay
stable across sessions.
- **Detailed base parts (moved from 6.C.1).** Author the layer-0
`detailed-nude` variants for top/bottom/feet in the character blends,
following `assets/blender/characters/CHARACTER_MODELING.md` §3. The
runtime preference and composition-base fallback already landed in
Phase 6; this item is content only. Verify outfitLevel 0 resolves the
detailed variants and clothed characters are unchanged.
- **Character prefab file migration (moved from 6.C.2).** Rename the
remaining existing `char_<id>.json` assets to `character.<id>.json`,
update demo/staging references deliberately, and exercise the existing
save-time migration. Keep old scene-embedded references loadable and
verify a registry/prefab round trip before removing compatibility.
- **LOD atlas correctness (moved from 6.C.3, reported 2026-10-25).**
Reproduce and fix the wrong in-game LOD atlas appearance, starting with
the UV remap in `generate_lods.py`. Add a per-tier screenshot/catalog
check that compares atlas regions against the source parts at LOD1 and
the joined thin/fat impostors at LOD2/3; do not hide the issue by
disabling the affected tiers.
- **Verification.** Catalog probe listing resolved meshes per age/sex/
slot (no unexpected fallbacks); headless screenshot probe of a varied
spawned crowd; `--test-detail` still passes with the full catalog.
@@ -3557,9 +4081,22 @@ must land before the Phase 12 performance/polish pass.
pedestrians + passengers live, interior scene kept as the
"home"/menu-adjacent space.
- Balance pass: speeds, timers, fare slack, traffic density.
- Performance pass: streaming radii, navmesh tile budget, active NPC
- Performance pass: streaming radii, navmesh tile budget and active NPC
caps — measure FPS with both settlements placed; verify the Phase
11.5 batch budget holds in the assembled scene.
- **NPC spawn/detail budgets (moved from 6.D):** cap high-detail NPCs at
10 overall and LOD0 NPCs at 5, with both values configurable and
tunable at run time through the F7 overlay. Spawn nearest eligible
NPCs first within the distance/priority budget and, when over budget,
despawn the most distant spawned NPCs first while preserving dwell and
spawn/despawn hysteresis. Extend `--test-detail` with nearest-first,
cap, LOD0-cap, farthest-first eviction and anti-flapping cases.
- **On-hardware performance validation (moved from 6.C.5):** repeat the
full-population `--benchmark` / `--benchmark-drive` runs windowed on
real hardware with the current 85/100 m NPC radii, including the
two-settlement scene. Record absolute
FPS/frame time alongside the existing llvmpipe-relative baseline and
close the measured batch and water-spike budgets in Phases 11.5/12.
- Docs: update `AGENTS.md` (both levels), `GameFeatures202609.md`
(F13+ entries), demo README section; full headless test battery
wired into the demo like demo-sokoban's.
@@ -6865,15 +6865,14 @@ int main(int argc, char *argv[])
<< sceneFile << std::endl;
app.startNewGame(sceneFile);
/* Phase 5.5 follow-up gameplay tuning: pull the NPC and
* traffic spawn radii in to keep the in-town batch count
* near the phase target. NPCs beyond ~65 m and cars beyond
* ~110 m are barely visible but each costs ~10/11 batches.
* Test listeners override these at frame time when they
* need deterministic full spawns, so this stays safe. */
/* Gameplay tuning: NPCs spawn at 85 m and despawn at 100 m;
* traffic uses 110/165 m. The independent radii balance
* visible population against rendering cost. Test listeners
* override these at frame time when they need deterministic
* full spawns, so this stays safe. */
if (NpcDetailSystem *detail = NpcDetailSystem::getInstance()) {
detail->spawnDistanceSq = 65.0f * 65.0f;
detail->despawnDistanceSq = 95.0f * 95.0f;
detail->spawnDistanceSq = 85.0f * 85.0f;
detail->despawnDistanceSq = 100.0f * 100.0f;
}
if (NpcTrafficSystem *traffic =
NpcTrafficSystem::getInstance()) {
@@ -104,7 +104,7 @@
"entities": [
{
"children": [],
"id": 4294967793,
"id": 489,
"name": {
"name": "arrival_a"
},
@@ -129,7 +129,7 @@
},
{
"children": [],
"id": 4294967794,
"id": 490,
"light": {
"castShadows": false,
"constantAttenuation": 1.0,
@@ -205,7 +205,7 @@
},
"shapeType": "box"
},
"id": 4294967792,
"id": 491,
"name": {
"name": "demo_floor"
},
@@ -247,7 +247,7 @@
"spawnDistance": 100.0
},
"children": [],
"id": 4294967790,
"id": 492,
"name": {
"name": "s1"
},
@@ -272,7 +272,7 @@
},
{
"children": [],
"id": 4294967789,
"id": 493,
"name": {
"name": "player"
},
@@ -287,6 +287,7 @@
"actuatorLabelFontSize": 12.0,
"cameraMode": 0,
"distantCircleRadius": 8.0,
"drivingState": "driving",
"fpsBoneName": "Head",
"idleState": "idle",
"locomotionStateMachine": "locomotion",
@@ -681,7 +682,60 @@
"extWindowFrameRectName": "",
"floorRectName": "floor",
"friction": 0.5,
"furnitureCells": [],
"furnitureCells": [
{
"furnitureType": "table-armchairs1",
"owner": "generated",
"placementId": "d6f6a1e2-4b5c-4d6e-8f70-a1b2c3d4e5f6:room_1788648326350139622_498:table-armchairs1:1:0:2",
"rotation": 1,
"tags": [
"livingroom",
"essential"
],
"x": 1,
"y": 0,
"z": 2
},
{
"furnitureType": "dinner-table1-legacy",
"owner": "generated",
"placementId": "d6f6a1e2-4b5c-4d6e-8f70-a1b2c3d4e5f6:room_1788648326350139622_498:dinner-table1-legacy:1:0:-1",
"rotation": 1,
"tags": [
"livingroom",
"important"
],
"x": 1,
"y": 0,
"z": -1
},
{
"furnitureType": "pole1",
"owner": "generated",
"placementId": "d6f6a1e2-4b5c-4d6e-8f70-a1b2c3d4e5f6:room_1788648326350139622_498:pole1:1:0:4",
"rotation": 1,
"tags": [
"livingroom",
"optional"
],
"x": 1,
"y": 0,
"z": 4
},
{
"furnitureType": "sofa2",
"owner": "generated",
"placementId": "d6f6a1e2-4b5c-4d6e-8f70-a1b2c3d4e5f6:room_1788648326350139622_498:sofa2:1:0:6",
"rotation": 1,
"tags": [
"livingroom",
"filler"
],
"x": 1,
"y": 0,
"z": 6
}
],
"generationMode": "interiorOnly",
"generationScript": "",
"glassColor": [
@@ -716,7 +770,7 @@
"minY": 0,
"minZ": -8
},
"id": 4294967786,
"id": 495,
"name": {
"name": "r1"
},
@@ -741,7 +795,7 @@
},
{
"children": [],
"id": 8589935095,
"id": 496,
"name": {
"name": "r2"
},
@@ -759,7 +813,7 @@
false,
false
],
"fillRoomWithFurniture": false,
"fillRoomWithFurniture": true,
"furnitureSeed": 42,
"furnitureYOffset": 0.05000000074505806,
"maxX": 2,
@@ -769,8 +823,10 @@
"minY": 0,
"minZ": -2,
"persistentId": "room_1788648326350139622_498",
"roomType": "",
"tags": []
"roomType": "livingroom",
"tags": [
"livingroom"
]
},
"transform": {
"position": {
@@ -793,7 +849,7 @@
},
{
"children": [],
"id": 4294967816,
"id": 497,
"name": {
"name": "r3"
},
@@ -844,7 +900,7 @@
}
}
],
"id": 4294967785,
"id": 494,
"name": {
"name": "interrior"
},
@@ -92,6 +92,56 @@ struct PlanPlacement {
float yawDeg = 0.0f;
};
/* ------------------------------------------------------------------ */
/* Furniture composition rules (phase 6.5.F). Pure data, shared by */
/* the DSL (:compose room option), the serialized RoomComponent and */
/* the RoomLayoutSystem evaluator, which runs every rule through the */
/* FurniturePlacement kernel. */
/* ------------------------------------------------------------------ */
/** One group child: selection tags plus a cell offset/rotation relative
* to the group's root (offsets rotate with the root's quarter-turns,
* exactly like FurniturePlacement::expandGroup). */
struct FurnitureChildRule {
std::vector<std::string> tags;
int offsetX = 0;
int offsetZ = 0;
int rotationOffset = 0;
};
struct FurnitureCompositionRule {
enum class Kind {
Group, /* one root + relative children, one transaction */
Row, /* repetition along a wall edge, best-effort per item */
};
Kind kind = Kind::Group;
/** Selection tag terms for the root (Group) or the repeated item
* (Row); the same expression syntax as room :furniture lists
* (entries ANDed, a|b OR, !a NOT). */
std::vector<std::string> tags;
/** Group children (Group only). */
std::vector<FurnitureChildRule> children;
/** Anchor: "center" or a wall edge "zneg"/"zpos"/"xneg"/"xpos".
* Center roots the group at the room center; edges root at the
* edge midpoint facing into the room (Row: the edge walked). */
std::string anchor = "center";
/** Group root rotation 0..3, or -1 to derive it from the anchor
* (edges face into the room; center keeps 0). */
int rootRotation = -1;
/** Row stepping (Row only): walk the anchor edge from the lower
* coordinate end, place every `step` cells, skipping `skip`
* additional cells after each placed item. */
int step = 1;
int skip = 0;
/** Row limit: 0 = walk the whole edge. */
int count = 0;
};
/** All composition rules of one room; parallel to
* roomNames/roomTypes/roomFurnitureTags. */
using RoomCompositionRules = std::vector<FurnitureCompositionRule>;
struct BuildingPlan {
int cellsX = 0; /* along the frontage */
int cellsZ = 0; /* into the lot */
@@ -118,6 +168,13 @@ struct BuildingPlan {
std::vector<int> roomIds;
std::vector<std::string> roomNames;
std::vector<std::string> roomTypes;
/** Per-room furniture-selection tags (phase 6.5.F), parallel to
* roomNames/roomTypes. Empty vector = use the room type/defaults. */
std::vector<std::vector<std::string>> roomFurnitureTags;
/** Per-room declarative composition rules from the :compose room
* option (phase 6.5.F), parallel to roomNames. Empty = the
* priority-class tag walk furnishes the room. */
std::vector<RoomCompositionRules> roomCompositions;
std::vector<PlanRoof> roofs;
std::vector<PlanParking> parkings;
std::vector<PlanPlacement> placements;
@@ -138,4 +195,83 @@ struct BuildingPlan {
}
};
/** Inclusive cell bounds of one room on one floor (phase 6.5.F). */
struct PlanRoomBounds {
int roomId = -1;
int floor = 0;
int minX = 0;
int minZ = 0;
int maxX = 0; /* inclusive */
int maxZ = 0; /* inclusive */
};
/** Derive per-(floor, room) inclusive cell bounds from plan.roomIds.
* Pure helper shared by TownGenSystem's RoomComponent materialization
* and headless tests; returns an empty vector when the plan declares
* no rooms. Rooms claiming disjoint rects on one floor (multi-rect
* rooms) keep ONE bounding box — the DSL room verb claims a single
* rect per floor today. */
inline std::vector<PlanRoomBounds> planRoomBounds(const BuildingPlan &plan)
{
std::vector<PlanRoomBounds> out;
if (!plan.valid() || plan.roomIds.empty())
return out;
const size_t roomCount = plan.roomNames.size();
/* bounds[floor * roomCount + roomId], found flag via maxX < 0. */
struct Acc {
int minX = 0;
int minZ = 0;
int maxX = -1;
int maxZ = -1;
};
std::vector<Acc> acc((size_t)plan.floors * roomCount);
for (int y = 0; y < plan.floors; ++y) {
for (int z = 0; z < plan.cellsZ; ++z) {
for (int x = 0; x < plan.cellsX; ++x) {
/* Skip cells without a floor: filled-shell
* mode keeps roomIds on cut cells, and
* furniture bounds must cover only real
* floor area. */
if (!(plan.at(x, y, z) & PlanFlags::Floor))
continue;
int id = plan.roomIds
[((size_t)y * plan.cellsZ + z) *
plan.cellsX + x];
if (id < 0 || (size_t)id >= roomCount)
continue;
Acc &a = acc[(size_t)y * roomCount + id];
if (a.maxX < 0) {
a.minX = a.maxX = x;
a.minZ = a.maxZ = z;
} else {
if (x < a.minX)
a.minX = x;
if (x > a.maxX)
a.maxX = x;
if (z < a.minZ)
a.minZ = z;
if (z > a.maxZ)
a.maxZ = z;
}
}
}
}
for (int y = 0; y < plan.floors; ++y) {
for (size_t id = 0; id < roomCount; ++id) {
const Acc &a = acc[(size_t)y * roomCount + id];
if (a.maxX < 0)
continue;
PlanRoomBounds b;
b.roomId = (int)id;
b.floor = y;
b.minX = a.minX;
b.minZ = a.minZ;
b.maxX = a.maxX;
b.maxZ = a.maxZ;
out.push_back(b);
}
}
return out;
}
} /* namespace LayoutLib */
+243 -2
View File
@@ -298,6 +298,28 @@ const std::vector<LayoutLibrary::LayoutExample> &LayoutLibrary::examples()
" (door front -2)\n"
" (windows all :spacing 4))\n"
"(roof gable :height 2)\n" },
{ "Room furniture tag expressions (phase 6.5.F)",
"(shell 12 8 :empty)\n"
"(floor 0 (room shop (rect 0 0 0.6 1) :type store\n"
" :furniture (shop|market counter !display))\n"
" (room stock (rect 0.6 0 1 1) :type storage\n"
" :windows none)\n"
" (connect shop stock)\n"
" (door front -2))\n"
"(roof flat :height 1)\n" },
{ "Centered table + chairs and a shelf row (phase 6.5.F)",
"(shell 16 16 :empty)\n"
"(floor 0 (room dining (rect 0 0 1 1)\n"
" :compose (\n"
" (group (livingroom nowall) :at center\n"
" (child (lounge filler) -2 -2 0)\n"
" (child (lounge filler) 2 -2 0)\n"
" (child (lounge filler) -2 2 0)\n"
" (child (lounge filler) 2 2 0))\n"
" (row (nowindow optional) :at zpos :step 2"
" :count 2)))\n"
" (door front -4))\n"
"(roof flat :height 1)\n" },
};
return ex;
}
@@ -1219,6 +1241,186 @@ bool LayoutEval::verbWindows(const std::vector<Value> &pos,
return true;
}
/* Parse a tag list form (list of symbols, or a bare symbol) into a
* term vector; the same shape the :furniture option takes. */
static bool parseTagList(const SExpr &form, std::vector<std::string> &out,
std::string *error)
{
if (form.isSymbol()) {
out.push_back(form.text);
return true;
}
if (!form.isList()) {
if (error)
*error = "expected a tag list (line " +
std::to_string(form.line) + ")";
return false;
}
for (const auto &tag : form.list) {
if (!tag.isSymbol()) {
if (error)
*error = "tags must be symbols (line " +
std::to_string(tag.line) + ")";
return false;
}
out.push_back(tag.text);
}
return true;
}
/* Parse one :compose rule:
* (group taglist [:at center|zneg|zpos|xneg|xpos] [:rot n] child*)
* (row taglist [:at edge] [:step n] [:skip n] [:count n])
* child := (child taglist dx dz rot) */
static bool parseCompositionRule(const SExpr &form,
FurnitureCompositionRule &out,
std::string *error)
{
if (!form.isList() || form.list.empty() || !form.list[0].isSymbol()) {
if (error)
*error = ":compose rules must be (group ...)/(row ...) "
"forms (line " +
std::to_string(form.line) + ")";
return false;
}
const bool isGroup = form.list[0].isSymbol("group");
const bool isRow = form.list[0].isSymbol("row");
if (!isGroup && !isRow) {
if (error)
*error = "unknown :compose rule '" +
form.list[0].text + "' (only group, row; line " +
std::to_string(form.line) + ")";
return false;
}
out.kind = isGroup ? FurnitureCompositionRule::Kind::Group :
FurnitureCompositionRule::Kind::Row;
size_t i = 1;
if (i >= form.list.size()) {
if (error)
*error = ":compose rule needs a tag list (line " +
std::to_string(form.line) + ")";
return false;
}
if (!parseTagList(form.list[i], out.tags, error))
return false;
++i;
auto argInt = [&](const SExpr &e, int &out) -> bool {
if (!e.isNumber()) {
if (error)
*error = "expected a number (line " +
std::to_string(e.line) + ")";
return false;
}
out = (int)std::lround(e.number);
return true;
};
for (; i < form.list.size(); ++i) {
const SExpr &e = form.list[i];
if (e.isSymbol(":at") || e.isSymbol(":rot") ||
e.isSymbol(":step") || e.isSymbol(":skip") ||
e.isSymbol(":count")) {
if (i + 1 >= form.list.size()) {
if (error)
*error = e.text + " needs a value "
"(line " +
std::to_string(e.line) + ")";
return false;
}
const SExpr &val = form.list[++i];
if (e.isSymbol(":at")) {
if (!val.isSymbol()) {
if (error)
*error = ":at needs an anchor "
"name (line " +
std::to_string(val.line) + ")";
return false;
}
static const char *anchors[] = {
"center", "zneg", "zpos", "xneg",
"xpos"
};
bool known = false;
for (const char *a : anchors)
if (val.text == a)
known = true;
if (!known) {
if (error)
*error = "unknown anchor '" +
val.text +
"' (center/zneg/zpos/"
"xneg/xpos, line " +
std::to_string(val.line) +
")";
return false;
}
out.anchor = val.text;
} else if (e.isSymbol(":rot")) {
if (!argInt(val, out.rootRotation))
return false;
if (out.rootRotation < 0 ||
out.rootRotation > 3) {
if (error)
*error = ":rot must be 0..3 "
"(line " +
std::to_string(val.line) +
")";
return false;
}
} else if (e.isSymbol(":step")) {
if (!argInt(val, out.step))
return false;
} else if (e.isSymbol(":skip")) {
if (!argInt(val, out.skip))
return false;
} else { /* :count */
if (!argInt(val, out.count))
return false;
}
continue;
}
if (e.isList() && !e.list.empty() &&
e.list[0].isSymbol("child")) {
if (!isGroup) {
if (error)
*error = "row rules take no children "
"(line " +
std::to_string(e.line) + ")";
return false;
}
FurnitureChildRule child;
if (e.list.size() != 5) {
if (error)
*error = "child needs (child taglist "
"dx dz rot) (line " +
std::to_string(e.line) + ")";
return false;
}
if (!parseTagList(e.list[1], child.tags, error))
return false;
if (!argInt(e.list[2], child.offsetX) ||
!argInt(e.list[3], child.offsetZ) ||
!argInt(e.list[4], child.rotationOffset))
return false;
out.children.push_back(std::move(child));
continue;
}
if (error)
*error = "unexpected :compose rule element (line " +
std::to_string(e.line) + ")";
return false;
}
if (out.tags.empty()) {
if (error)
*error = ":compose rule needs at least one tag (line " +
std::to_string(form.line) + ")";
return false;
}
return true;
}
bool LayoutEval::verbRoomRaw(const SExpr &form)
{
/* (room name (rect x0 z0 x1 z1)) — rect in fractions of the
@@ -1246,6 +1448,8 @@ bool LayoutEval::verbRoomRaw(const SExpr &form)
"pairs");
bool noWindows = false;
std::string roomType;
std::vector<std::string> furnitureTags;
RoomCompositionRules compositions;
for (size_t i = 3; i < form.list.size(); i += 2) {
if (!form.list[i].isSymbol())
return fail(form.list[i], "room option must be a "
@@ -1268,10 +1472,44 @@ bool LayoutEval::verbRoomRaw(const SExpr &form)
return fail(form.list[i + 1],
":type needs a room type name");
roomType = form.list[i + 1].text;
} else if (key == ":furniture") {
const SExpr &tags = form.list[i + 1];
if (!tags.isList()) {
if (!tags.isSymbol())
return fail(tags,
":furniture needs a tag list");
furnitureTags.push_back(tags.text);
} else {
for (const auto &tag : tags.list) {
if (!tag.isSymbol())
return fail(tag,
":furniture tags must be symbols");
furnitureTags.push_back(tag.text);
}
}
} else if (key == ":compose") {
/* Phase 6.5.F: declarative furniture rules.
* (group taglist [:at anchor] [:rot n] child*)
* (row taglist [:at edge] [:step n] [:skip n]
* [:count n]); child := (child taglist dx dz
* rot). */
const SExpr &rules = form.list[i + 1];
if (!rules.isList())
return fail(rules,
":compose needs a rule list");
for (const auto &ruleForm : rules.list) {
FurnitureCompositionRule rule;
std::string ruleError;
if (!parseCompositionRule(ruleForm, rule,
&ruleError))
return fail(ruleForm, ruleError);
compositions.push_back(std::move(rule));
}
} else {
return fail(form.list[i], "unknown room option '" +
key +
"' (only :windows, :type)");
"' (only :windows, :type, "
":furniture, :compose)");
}
}
const SExpr &rect = form.list[2];
@@ -1287,13 +1525,16 @@ bool LayoutEval::verbRoomRaw(const SExpr &form)
/* Register the room name (+ optional :type) into the plan's
* room tables; re-declaring a name (e.g. on another floor)
* reuses its id and its first type wins. */
auto registerRoom = [this, &form, &roomType]() -> int {
auto registerRoom = [this, &form, &roomType, &furnitureTags,
&compositions]() -> int {
auto nit = m_roomNameIds.find(form.list[1].text);
if (nit != m_roomNameIds.end())
return nit->second;
int id = (int)m_plan->roomNames.size();
m_plan->roomNames.push_back(form.list[1].text);
m_plan->roomTypes.push_back(roomType);
m_plan->roomFurnitureTags.push_back(furnitureTags);
m_plan->roomCompositions.push_back(std::move(compositions));
m_roomNameIds.emplace(form.list[1].text, id);
return id;
};
@@ -0,0 +1,665 @@
#include "LuaFurnitureApi.hpp"
#include "LuaEntityApi.hpp"
#include <OgreLogManager.h>
#include <string>
#include <vector>
namespace editScene
{
namespace
{
lua_State *g_lua = nullptr;
flecs::world *g_world = nullptr;
CellGridSystem *g_cellGridSystem = nullptr;
std::vector<int> g_beforeHandlers;
std::vector<int> g_afterHandlers;
void logError(const std::string &message)
{
Ogre::LogManager::getSingleton().logMessage(
"LuaFurniture: " + message);
}
std::string roomIdOf(const RoomComponent &room)
{
if (!room.persistentId.empty())
return room.persistentId;
return "room:" + std::to_string(room.minX) + ":" +
std::to_string(room.minZ);
}
bool findGrid(const std::string &gridUid, flecs::entity &gridEntity,
CellGridComponent **grid)
{
if (!g_world || gridUid.empty())
return false;
bool found = false;
g_world->each([&](flecs::entity e, CellGridComponent &candidate) {
if (found || candidate.gridUid != gridUid)
return;
found = true;
gridEntity = e;
*grid = &candidate;
});
return found;
}
bool findRoom(flecs::entity gridEntity, const std::string &roomId,
RoomComponent **room)
{
if (roomId.empty())
return true;
bool found = false;
gridEntity.children([&](flecs::entity child) {
if (found || !child.has<RoomComponent>())
return;
RoomComponent &candidate = child.get_mut<RoomComponent>();
if (roomIdOf(candidate) == roomId) {
*room = &candidate;
found = true;
}
});
return found;
}
void pushStringArray(lua_State *L, const std::vector<std::string> &values)
{
lua_newtable(L);
for (size_t i = 0; i < values.size(); ++i) {
lua_pushstring(L, values[i].c_str());
lua_rawseti(L, -2, (lua_Integer)i + 1);
}
}
bool readStringArray(lua_State *L, int index,
std::vector<std::string> &out, std::string *error)
{
out.clear();
if (!lua_istable(L, index)) {
if (error)
*error = "tags must be a table";
return false;
}
const size_t count = lua_rawlen(L, index);
for (size_t i = 1; i <= count; ++i) {
lua_rawgeti(L, index, (lua_Integer)i);
if (!lua_isstring(L, -1)) {
lua_pop(L, 1);
if (error)
*error = "tags must contain strings";
return false;
}
out.emplace_back(lua_tostring(L, -1));
lua_pop(L, 1);
}
return true;
}
void pushRequest(lua_State *L, const FurniturePlacementRequest &request)
{
lua_newtable(L);
lua_pushstring(L, request.definition->id.c_str());
lua_setfield(L, -2, "definition");
lua_pushinteger(L, request.x);
lua_setfield(L, -2, "x");
lua_pushinteger(L, request.y);
lua_setfield(L, -2, "y");
lua_pushinteger(L, request.z);
lua_setfield(L, -2, "z");
lua_pushinteger(L, request.rotation);
lua_setfield(L, -2, "rotation");
lua_pushstring(L, request.owner.c_str());
lua_setfield(L, -2, "owner");
lua_pushstring(L, request.groupId.c_str());
lua_setfield(L, -2, "groupId");
lua_pushstring(L, request.placementId.c_str());
lua_setfield(L, -2, "placementId");
pushStringArray(L, request.tags);
lua_setfield(L, -2, "tags");
}
bool readIntField(lua_State *L, int index, const char *field, int &out,
std::string *error)
{
lua_getfield(L, index, field);
if (!lua_isnumber(L, -1)) {
lua_pop(L, 1);
if (error)
*error = std::string("placement '") + field +
"' must be an integer";
return false;
}
out = (int)lua_tointeger(L, -1);
lua_pop(L, 1);
return true;
}
bool readOptionalIntField(lua_State *L, int index, const char *field,
int &out, std::string *error)
{
lua_getfield(L, index, field);
if (lua_isnil(L, -1)) {
lua_pop(L, 1);
return true;
}
lua_pop(L, 1);
return readIntField(L, index, field, out, error);
}
bool readStringField(lua_State *L, int index, const char *field,
std::string &out, std::string *error)
{
lua_getfield(L, index, field);
if (!lua_isstring(L, -1)) {
lua_pop(L, 1);
if (error)
*error = std::string("placement '") + field +
"' must be a string";
return false;
}
out = lua_tostring(L, -1);
lua_pop(L, 1);
return true;
}
bool readOptionalStringField(lua_State *L, int index, const char *field,
std::string &out, std::string *error)
{
lua_getfield(L, index, field);
if (lua_isnil(L, -1)) {
lua_pop(L, 1);
return true;
}
lua_pop(L, 1);
return readStringField(L, index, field, out, error);
}
bool requestFromTable(lua_State *L, int index,
FurniturePlacementRequest &request, std::string *error)
{
if (!lua_istable(L, index)) {
if (error)
*error = "placement must be a table";
return false;
}
/* Lua-authored placements are script-owned unless the table
* explicitly says otherwise. */
request.owner = "script";
std::string definitionName;
if (!readStringField(L, index, "definition", definitionName, error))
return false;
request.definition =
FurnitureLibrary::getInstance().findByName(definitionName);
if (!request.definition) {
if (error)
*error = "unknown furniture definition '" +
definitionName + "'";
return false;
}
if (!readIntField(L, index, "x", request.x, error) ||
!readIntField(L, index, "y", request.y, error) ||
!readIntField(L, index, "z", request.z, error) ||
!readOptionalIntField(L, index, "rotation", request.rotation,
error) ||
!readOptionalStringField(L, index, "owner", request.owner,
error) ||
!readOptionalStringField(L, index, "groupId", request.groupId,
error) ||
!readOptionalStringField(L, index, "placementId",
request.placementId, error))
return false;
lua_getfield(L, index, "tags");
if (!lua_isnil(L, -1)) {
if (!readStringArray(L, -1, request.tags, error)) {
lua_pop(L, 1);
return false;
}
}
lua_pop(L, 1);
if (request.rotation < 0 || request.rotation > 3) {
if (error)
*error = "placement rotation must be 0..3";
return false;
}
return true;
}
void pushEvent(lua_State *L, const FurnitureRoomEvent &event)
{
lua_newtable(L);
lua_pushstring(L, event.grid->gridUid.c_str());
lua_setfield(L, -2, "gridUid");
const std::string roomId = roomIdOf(*event.room);
lua_pushstring(L, roomId.c_str());
lua_setfield(L, -2, "roomId");
lua_pushstring(L, event.room->roomType.c_str());
lua_setfield(L, -2, "roomType");
pushStringArray(L, event.room->tags);
lua_setfield(L, -2, "tags");
lua_pushinteger(L, event.room->furnitureSeed);
lua_setfield(L, -2, "seed");
lua_pushboolean(L, event.suppressDefault);
lua_setfield(L, -2, "suppress_default");
lua_newtable(L);
for (size_t i = 0; i < event.placements.size(); ++i) {
pushRequest(L, event.placements[i]);
lua_rawseti(L, -2, (lua_Integer)i + 1);
}
lua_setfield(L, -2, "placements");
}
bool readEvent(lua_State *L, int index, FurnitureRoomEvent &event,
std::string *error)
{
lua_getfield(L, index, "suppress_default");
if (!lua_isboolean(L, -1)) {
lua_pop(L, 1);
if (error)
*error = "suppress_default must be boolean";
return false;
}
event.suppressDefault = lua_toboolean(L, -1);
lua_pop(L, 1);
lua_getfield(L, index, "placements");
if (!lua_istable(L, -1)) {
lua_pop(L, 1);
if (error)
*error = "placements must be a table";
return false;
}
std::vector<FurniturePlacementRequest> placements;
const size_t count = lua_rawlen(L, -1);
for (size_t i = 1; i <= count; ++i) {
lua_rawgeti(L, -1, (lua_Integer)i);
FurniturePlacementRequest request;
if (!requestFromTable(L, -1, request, error)) {
lua_pop(L, 2);
return false;
}
placements.push_back(request);
lua_pop(L, 1);
}
lua_pop(L, 1);
event.placements = std::move(placements);
return true;
}
int definitions(lua_State *L)
{
FurnitureLibrary::getInstance().loadAll();
lua_newtable(L);
const auto &defs = FurnitureLibrary::getInstance().getDefinitions();
for (size_t i = 0; i < defs.size(); ++i) {
const auto &def = defs[i];
lua_newtable(L);
lua_pushstring(L, def.id.c_str());
lua_setfield(L, -2, "id");
pushStringArray(L, def.tags);
lua_setfield(L, -2, "tags");
lua_pushstring(L, FurnitureDefinition::layerToString(def.layer));
lua_setfield(L, -2, "layer");
lua_pushnumber(L, def.weight);
lua_setfield(L, -2, "weight");
lua_rawseti(L, -2, (lua_Integer)i + 1);
}
return 1;
}
int canPlace(lua_State *L)
{
std::string gridUid = luaL_checkstring(L, 1);
FurniturePlacementRequest request;
std::string error;
if (!requestFromTable(L, 2, request, &error)) {
lua_pushboolean(L, false);
lua_pushstring(L, error.c_str());
return 2;
}
flecs::entity gridEntity;
CellGridComponent *grid = nullptr;
if (!findGrid(gridUid, gridEntity, &grid)) {
lua_pushboolean(L, false);
lua_pushstring(L, "unknown grid");
return 2;
}
std::string roomId = luaL_optstring(L, 3, "");
RoomComponent *room = nullptr;
if (!findRoom(gridEntity, roomId, &room)) {
lua_pushboolean(L, false);
lua_pushstring(L, "unknown room");
return 2;
}
static const std::vector<FurniturePlacementRequest> noPending;
const bool ok = FurniturePlacement::canPlace(*grid, room, request,
noPending, &error);
lua_pushboolean(L, ok);
lua_pushstring(L, error.c_str());
return 2;
}
int commit(lua_State *L)
{
std::string gridUid = luaL_checkstring(L, 1);
if (!lua_istable(L, 2))
return luaL_error(L, "commit expects a placement array");
flecs::entity gridEntity;
CellGridComponent *grid = nullptr;
if (!findGrid(gridUid, gridEntity, &grid)) {
lua_pushboolean(L, false);
lua_pushstring(L, "unknown grid");
return 2;
}
std::string roomId = luaL_optstring(L, 3, "");
RoomComponent *room = nullptr;
if (!findRoom(gridEntity, roomId, &room)) {
lua_pushboolean(L, false);
lua_pushstring(L, "unknown room");
return 2;
}
std::vector<FurniturePlacementRequest> requests;
const size_t count = lua_rawlen(L, 2);
std::string error;
for (size_t i = 1; i <= count; ++i) {
lua_rawgeti(L, 2, (lua_Integer)i);
FurniturePlacementRequest request;
if (!requestFromTable(L, -1, request, &error)) {
lua_pop(L, 1);
lua_pushboolean(L, false);
lua_pushstring(L, error.c_str());
return 2;
}
requests.push_back(request);
lua_pop(L, 1);
}
const bool ok = FurniturePlacement::commit(*grid, room, requests,
&error);
lua_pushboolean(L, ok);
lua_pushstring(L, error.c_str());
return 2;
}
int removePlacement(lua_State *L)
{
std::string gridUid = luaL_checkstring(L, 1);
std::string placementId = luaL_checkstring(L, 2);
flecs::entity gridEntity;
CellGridComponent *grid = nullptr;
if (!findGrid(gridUid, gridEntity, &grid)) {
lua_pushinteger(L, 0);
return 1;
}
lua_pushinteger(L, (lua_Integer)FurniturePlacement::removePlacement(
*grid, placementId));
return 1;
}
void pushPoi(lua_State *L, const CellGridSystem::FurniturePoiInfo &poi)
{
lua_newtable(L);
lua_pushstring(L, poi.id.c_str());
lua_setfield(L, -2, "id");
lua_pushinteger(L, luaEntityToId(poi.entity));
lua_setfield(L, -2, "entity_id");
lua_pushstring(L, poi.placementId.c_str());
lua_setfield(L, -2, "placementId");
lua_pushstring(L, poi.name.c_str());
lua_setfield(L, -2, "name");
lua_pushstring(L, poi.type.c_str());
lua_setfield(L, -2, "type");
lua_pushstring(L, poi.actionName.c_str());
lua_setfield(L, -2, "actionName");
lua_pushstring(L, poi.roomId.c_str());
lua_setfield(L, -2, "roomId");
lua_pushstring(L, poi.furnitureType.c_str());
lua_setfield(L, -2, "furnitureType");
lua_newtable(L);
lua_pushnumber(L, poi.position.x);
lua_setfield(L, -2, "x");
lua_pushnumber(L, poi.position.y);
lua_setfield(L, -2, "y");
lua_pushnumber(L, poi.position.z);
lua_setfield(L, -2, "z");
lua_setfield(L, -2, "position");
lua_newtable(L);
lua_pushnumber(L, poi.rotation.w);
lua_setfield(L, -2, "w");
lua_pushnumber(L, poi.rotation.x);
lua_setfield(L, -2, "x");
lua_pushnumber(L, poi.rotation.y);
lua_setfield(L, -2, "y");
lua_pushnumber(L, poi.rotation.z);
lua_setfield(L, -2, "z");
lua_setfield(L, -2, "rotation");
}
int findPoi(lua_State *L)
{
const std::string id = luaL_checkstring(L, 1);
if (!g_cellGridSystem) {
lua_pushnil(L);
lua_pushstring(L, "no CellGridSystem");
return 2;
}
CellGridSystem::FurniturePoiInfo poi;
if (!g_cellGridSystem->findFurniturePoi(id, poi)) {
lua_pushnil(L);
lua_pushstring(L, "unknown furniture POI");
return 2;
}
pushPoi(L, poi);
return 1;
}
int queryPois(lua_State *L)
{
CellGridSystem::FurniturePoiQuery query;
if (lua_istable(L, 1)) {
lua_getfield(L, 1, "gridUid");
if (lua_isstring(L, -1)) {
flecs::entity gridEntity;
CellGridComponent *grid = nullptr;
if (findGrid(lua_tostring(L, -1), gridEntity, &grid))
query.gridEntity = gridEntity;
}
lua_pop(L, 1);
lua_getfield(L, 1, "roomId");
if (lua_isstring(L, -1))
query.roomId = lua_tostring(L, -1);
lua_pop(L, 1);
lua_getfield(L, 1, "furnitureTag");
if (lua_isstring(L, -1))
query.furnitureTag = lua_tostring(L, -1);
lua_pop(L, 1);
lua_getfield(L, 1, "actionName");
if (lua_isstring(L, -1))
query.actionName = lua_tostring(L, -1);
lua_pop(L, 1);
lua_getfield(L, 1, "type");
if (lua_isstring(L, -1))
query.type = lua_tostring(L, -1);
lua_pop(L, 1);
}
if (!g_cellGridSystem) {
lua_newtable(L);
return 1;
}
const auto pois = g_cellGridSystem->queryFurniturePois(query);
lua_newtable(L);
for (size_t i = 0; i < pois.size(); ++i) {
pushPoi(L, pois[i]);
lua_rawseti(L, -2, (lua_Integer)i + 1);
}
return 1;
}
int onBefore(lua_State *L)
{
luaL_checktype(L, 1, LUA_TFUNCTION);
lua_pushvalue(L, 1);
int ref = luaL_ref(L, LUA_REGISTRYINDEX);
g_beforeHandlers.push_back(ref);
lua_pushinteger(L, ref);
return 1;
}
int onAfter(lua_State *L)
{
luaL_checktype(L, 1, LUA_TFUNCTION);
lua_pushvalue(L, 1);
int ref = luaL_ref(L, LUA_REGISTRYINDEX);
g_afterHandlers.push_back(ref);
lua_pushinteger(L, ref);
return 1;
}
int offBefore(lua_State *L)
{
int ref = (int)luaL_checkinteger(L, 1);
for (auto it = g_beforeHandlers.begin(); it != g_beforeHandlers.end();
++it) {
if (*it == ref) {
g_beforeHandlers.erase(it);
luaL_unref(L, LUA_REGISTRYINDEX, ref);
break;
}
}
return 0;
}
int offAfter(lua_State *L)
{
int ref = (int)luaL_checkinteger(L, 1);
for (auto it = g_afterHandlers.begin(); it != g_afterHandlers.end();
++it) {
if (*it == ref) {
g_afterHandlers.erase(it);
luaL_unref(L, LUA_REGISTRYINDEX, ref);
break;
}
}
return 0;
}
} // namespace
void registerLuaFurnitureApi(lua_State *L, flecs::world *world)
{
if (g_lua != L) {
g_beforeHandlers.clear();
g_afterHandlers.clear();
}
g_lua = L;
if (world)
g_world = world;
lua_getglobal(L, "ecs");
if (!lua_istable(L, -1)) {
lua_pop(L, 1);
lua_newtable(L);
lua_pushvalue(L, -1);
lua_setglobal(L, "ecs");
}
lua_newtable(L);
lua_pushcfunction(L, definitions);
lua_setfield(L, -2, "definitions");
lua_pushcfunction(L, canPlace);
lua_setfield(L, -2, "can_place");
lua_pushcfunction(L, commit);
lua_setfield(L, -2, "commit");
lua_pushcfunction(L, removePlacement);
lua_setfield(L, -2, "remove");
lua_pushcfunction(L, findPoi);
lua_setfield(L, -2, "find_poi");
lua_pushcfunction(L, queryPois);
lua_setfield(L, -2, "query_pois");
lua_pushcfunction(L, onBefore);
lua_setfield(L, -2, "on_room_before");
lua_pushcfunction(L, onAfter);
lua_setfield(L, -2, "on_room_after");
lua_pushcfunction(L, offBefore);
lua_setfield(L, -2, "off_room_before");
lua_pushcfunction(L, offAfter);
lua_setfield(L, -2, "off_room_after");
lua_setfield(L, -2, "furniture");
lua_pop(L, 1);
}
void setLuaCellGridSystem(CellGridSystem *system)
{
g_cellGridSystem = system;
}
bool hasFurnitureRoomBeforeHandlers()
{
return !g_beforeHandlers.empty();
}
bool hasFurnitureRoomAfterHandlers()
{
return !g_afterHandlers.empty();
}
bool notifyFurnitureRoomBefore(FurnitureRoomEvent &event)
{
if (!g_lua || g_beforeHandlers.empty())
return true;
for (int ref : g_beforeHandlers) {
lua_rawgeti(g_lua, LUA_REGISTRYINDEX, ref);
if (!lua_isfunction(g_lua, -1)) {
logError("room " + roomIdOf(*event.room) +
" before handler is not a function: " +
lua_typename(g_lua, lua_type(g_lua, -1)));
lua_pop(g_lua, 1);
return false;
}
pushEvent(g_lua, event);
/* pcall consumes the handler and its argument, so keep a
* second reference to the mutable context for read-back.
* Move that reference below the handler: the function must
* be at -2 and its argument at -1 when pcall runs. */
lua_pushvalue(g_lua, -1);
lua_insert(g_lua, 1);
if (lua_pcall(g_lua, 1, 0, 0) != LUA_OK) {
luaL_traceback(g_lua, g_lua,
lua_tostring(g_lua, -1), 1);
std::string error = lua_tostring(g_lua, -1);
lua_pop(g_lua, 2);
logError("room " + roomIdOf(*event.room) +
" before handler failed: " + error);
return false;
}
std::string error;
if (!readEvent(g_lua, -1, event, &error)) {
lua_pop(g_lua, 1);
logError("room " + roomIdOf(*event.room) +
" before handler edit rejected: " + error);
return false;
}
lua_pop(g_lua, 1);
}
return true;
}
void notifyFurnitureRoomAfter(const FurnitureRoomEvent &event)
{
if (!g_lua || g_afterHandlers.empty())
return;
for (int ref : g_afterHandlers) {
lua_rawgeti(g_lua, LUA_REGISTRYINDEX, ref);
pushEvent(g_lua, event);
if (lua_pcall(g_lua, 1, 0, 0) != LUA_OK) {
std::string error = lua_tostring(g_lua, -1);
lua_pop(g_lua, 1);
logError("room " + roomIdOf(*event.room) +
" after handler failed: " + error);
}
}
}
} // namespace editScene
@@ -0,0 +1,46 @@
#ifndef EDITSCENE_LUA_FURNITURE_API_HPP
#define EDITSCENE_LUA_FURNITURE_API_HPP
#pragma once
#include <flecs.h>
#include <lua.hpp>
#include <string>
#include <vector>
#include "../components/CellGrid.hpp"
#include "../systems/FurniturePlacement.hpp"
#include "../systems/CellGridSystem.hpp"
namespace editScene
{
/** Mutable context of the ordered furniture_room_before event. */
struct FurnitureRoomEvent {
flecs::world *world = nullptr;
CellGridComponent *grid = nullptr;
RoomComponent *room = nullptr;
std::vector<FurniturePlacementRequest> placements;
bool suppressDefault = false;
};
/** Register ecs.furniture.* and bind the shared world. Call once after
* the other Lua APIs are installed. */
void registerLuaFurnitureApi(lua_State *L, flecs::world *world = nullptr);
/** Bind the live CellGridSystem used by find_poi/query_pois. */
void setLuaCellGridSystem(CellGridSystem *system);
/** True when at least one before/after room handler is registered. */
bool hasFurnitureRoomBeforeHandlers();
bool hasFurnitureRoomAfterHandlers();
/** Ordered room-generation events. Before handlers run in registration
* order and may edit `placements` or set `suppress_default`; their edits
* are committed atomically by RoomLayoutSystem. After handlers receive
* the final generated placement list. */
bool notifyFurnitureRoomBefore(FurnitureRoomEvent &event);
void notifyFurnitureRoomAfter(const FurnitureRoomEvent &event);
} // namespace editScene
#endif // EDITSCENE_LUA_FURNITURE_API_HPP
+2
View File
@@ -7,8 +7,10 @@ FileSystem=resources/materials/scripts
FileSystem=resources/meshes
FileSystem=resources/textures
FileSystem=resources/buildings
FileSystem=resources/buildings/parts
FileSystem=resources/buildings/parts/pier
FileSystem=resources/buildings/parts/furniture
FileSystem=resources/buildings/parts/furniture-sofa
FileSystem=resources/vehicles
FileSystem=resources/fonts
FileSystem=resources/debug
@@ -0,0 +1,19 @@
{
"schemaVersion": 1,
"id": "interactive-chair",
"displayName": "Interactive chair (prefab example)",
"prefab": "tests/prefabs/furniture_interactive_chair.prefab",
"tags": [ "example" ],
"layer": "normal",
"source": "prefab",
"bounds": {
"min": [ -0.5, 0.0, -0.5 ],
"max": [ 0.5, 1.0, 0.5 ]
},
"anchor": [ 0.0, 0.0, 0.0 ],
"requiresFloor": true,
"requiresWall": false,
"weight": 0.0,
"actions": [],
"positions": []
}
+575 -55
View File
@@ -3,6 +3,7 @@
#include "DoorSystem.hpp"
#include "FurnitureLibrary.hpp"
#include "PhysicsSystem.hpp"
#include "PrefabSystem.hpp"
#include "../components/CellGrid.hpp"
#include "../components/TriangleBuffer.hpp"
#include "../components/Transform.hpp"
@@ -13,6 +14,11 @@
#include "../components/Door.hpp"
#include "../components/Actuator.hpp"
#include "../components/Renderable.hpp"
#include "../components/EditorMarker.hpp"
#include "../components/RuntimeMarker.hpp"
#include "../components/PrefabInstance.hpp"
#include "../components/EntityName.hpp"
#include "../components/SmartObject.hpp"
#include <OgreStaticGeometry.h>
#include "../components/ProceduralMaterial.hpp"
#include "../components/ProceduralTexture.hpp"
@@ -36,6 +42,7 @@
#include <ProceduralLathe.h>
#include <ProceduralShape.h>
#include <set>
#include <functional>
CellGridSystem::CellGridSystem(flecs::world &world,
Ogre::SceneManager *sceneMgr)
@@ -717,7 +724,7 @@ void CellGridSystem::buildFloorsAndCeilings(
const float hScale = grid.cellSize / 2.0f;
// Floor Y slightly below internal wall offset to avoid gap
// Internal wall offset is 0.1f, floor sits slightly below
const float floorY = 0.2f;
const float floorY = CellGridSystem::floorSurfaceY();
const float ceilingY = grid.cellHeight - 0.3f;
for (const auto &cell : grid.cells) {
@@ -869,6 +876,55 @@ std::vector<CellGridSystem::PlacementInfo> CellGridSystem::getFurniturePlacement
return result;
}
std::vector<CellGridSystem::FurniturePoiInfo>
CellGridSystem::queryFurniturePois(const FurniturePoiQuery &query) const
{
std::vector<FurniturePoiInfo> result;
for (const auto &pair : m_entityMeshes) {
if (query.gridEntity.is_valid() && query.gridEntity != 0 &&
pair.first != query.gridEntity.id())
continue;
for (const auto &poi : pair.second.furniturePois) {
if (!query.roomId.empty() &&
poi.roomId != query.roomId)
continue;
if (!query.actionName.empty() &&
poi.actionName != query.actionName)
continue;
if (!query.type.empty() && poi.type != query.type)
continue;
if (!query.furnitureTag.empty() &&
std::find(poi.furnitureTags.begin(),
poi.furnitureTags.end(),
query.furnitureTag) ==
poi.furnitureTags.end())
continue;
result.push_back(poi);
}
}
return result;
}
std::vector<CellGridSystem::FurniturePoiInfo>
CellGridSystem::queryFurniturePois() const
{
return queryFurniturePois(FurniturePoiQuery());
}
bool CellGridSystem::findFurniturePoi(const std::string &poiId,
FurniturePoiInfo &out) const
{
for (const auto &pair : m_entityMeshes) {
for (const auto &poi : pair.second.furniturePois) {
if (poi.id == poiId) {
out = poi;
return true;
}
}
}
return false;
}
void CellGridSystem::buildCorners(const CellGridComponent &grid,
Procedural::TriangleBuffer &extWallTb)
{
@@ -2586,10 +2642,314 @@ Ogre::MeshPtr CellGridSystem::convertToMesh(const std::string &name,
}
}
static Ogre::String furnitureMaterialName(const FurnitureDefinition &def,
const Ogre::MeshPtr &mesh)
{
/* Blender furniture GLBs intentionally carry no texture copies; the
* schema points every part at the shared OGRE material. */
const std::string configured = def.jsonData.value("materialName", "");
if (!configured.empty())
return configured;
if (mesh && mesh->getNumSubMeshes() > 0)
return mesh->getSubMesh(0)->getMaterialName();
return "";
}
static bool prefabHasFurnitureInteraction(flecs::entity entity)
{
if (!entity.is_alive())
return false;
if (entity.has<SmartObjectComponent>() ||
entity.has<ActuatorComponent>())
return true;
bool found = false;
entity.children([&](flecs::entity child) {
if (!found && prefabHasFurnitureInteraction(child))
found = true;
});
return found;
}
static void stripPrefabFurnitureCollision(flecs::entity entity,
bool *stripped)
{
bool changed = false;
if (entity.has<RigidBodyComponent>()) {
entity.remove<RigidBodyComponent>();
changed = true;
}
if (entity.has<PhysicsColliderComponent>()) {
entity.remove<PhysicsColliderComponent>();
changed = true;
}
if (changed)
*stripped = true;
entity.children([&](flecs::entity child) {
stripPrefabFurnitureCollision(child, stripped);
});
}
void CellGridSystem::buildFurnitureInteractions(
flecs::entity entity, const CellGridComponent &grid,
const FurnitureDefinition &definition, const FurnitureCell &fcell,
const Ogre::Vector3 &basePosition,
const Ogre::Quaternion &baseRotation,
flecs::entity prefabEntity)
{
auto &meshData = m_entityMeshes[entity.id()];
auto &gridTransform = entity.get<TransformComponent>();
if (!gridTransform.node)
return;
const std::string placementId = fcell.effectivePlacementId(
grid.gridUid.empty() ?
"entity-" + std::to_string(entity.id()) :
grid.gridUid);
std::string roomId;
entity.children([&](flecs::entity child) {
if (!roomId.empty() || !child.has<RoomComponent>())
return;
const auto &room = child.get<RoomComponent>();
if (room.contains(fcell.x, fcell.y, fcell.z)) {
roomId = room.persistentId;
if (roomId.empty())
roomId = "room:" + std::to_string(room.minX) +
":" + std::to_string(room.minZ);
}
});
/* POI ids are stable across grid rebuilds; a numeric suffix only
* disambiguates duplicate marker names inside one placement. */
auto makePoiId = [&](const std::string &name) {
const std::string base =
"furniture." + placementId + "." + name;
std::string id = base;
int suffix = 2;
auto used = [&](const std::string &candidate) {
for (const auto &poi : meshData.furniturePois)
if (poi.id == candidate)
return true;
return false;
};
while (used(id))
id = base + "#" + std::to_string(suffix++);
return id;
};
auto appendPoi = [&](const std::string &id, const std::string &name,
const std::string &type,
const std::string &actionName,
flecs::entity poiEntity,
const Ogre::Vector3 &position,
const Ogre::Quaternion &rotation) {
FurniturePoiInfo info;
info.id = id;
info.placementId = placementId;
info.name = name;
info.type = type;
info.actionName = actionName;
info.roomId = roomId;
info.furnitureType = definition.id;
info.furnitureTags = definition.tags;
info.entity = poiEntity;
info.gridEntity = entity;
info.position = position;
info.rotation = rotation;
meshData.furniturePois.push_back(info);
};
if (prefabEntity.is_alive() &&
prefabHasFurnitureInteraction(prefabEntity)) {
/* Prefab-authored interactions stay in the prefab subtree
* (no generated duplicates), but report the same stable POI
* shape as Blender markers. */
int unnamed = 0;
std::function<void(flecs::entity)> registerPrefabPoi =
[&](flecs::entity poiEntity) {
bool interactive = false;
std::string actionName;
if (poiEntity.has<ActuatorComponent>()) {
interactive = true;
const auto &actions = poiEntity
.get<ActuatorComponent>()
.actionNames;
if (!actions.empty())
actionName = actions.front();
}
if (poiEntity.has<SmartObjectComponent>()) {
interactive = true;
if (actionName.empty()) {
const auto &actions = poiEntity
.get<SmartObjectComponent>()
.actionNames;
if (!actions.empty())
actionName = actions.front();
}
}
if (interactive) {
std::string name;
if (poiEntity.has<EntityNameComponent>())
name = poiEntity
.get<EntityNameComponent>()
.name;
if (name.empty())
name = "poi-" +
std::to_string(unnamed++);
Ogre::Vector3 position = basePosition;
Ogre::Quaternion rotation = baseRotation;
if (poiEntity.has<TransformComponent>()) {
Ogre::SceneNode *node = poiEntity
.get<TransformComponent>()
.node;
if (node) {
position = gridTransform.node
->convertWorldToLocalPosition(
node->_getDerivedPosition());
rotation = gridTransform.node
->_getDerivedOrientation()
.Inverse() *
node->_getDerivedOrientation();
}
}
appendPoi(makePoiId(name), name,
actionName.empty() ?
"interaction" :
actionName,
actionName, poiEntity, position,
rotation);
}
poiEntity.children([&](flecs::entity child) {
registerPrefabPoi(child);
});
};
registerPrefabPoi(prefabEntity);
return;
}
if (definition.actions.empty() && definition.positions.empty())
return;
auto createMarker = [&](const std::string &name,
const std::string &type,
const std::string &actionName,
const Ogre::Vector3 &localPosition,
const Ogre::Quaternion &localRotation,
bool interactive, float radius, float height) {
const std::string id = makePoiId(name);
flecs::entity poiEntity = m_world.entity();
poiEntity.child_of(entity);
poiEntity.add<RuntimeMarkerComponent>();
poiEntity.add<FurnitureRuntimeTag>();
poiEntity.set<EntityNameComponent>(EntityNameComponent(id));
Ogre::SceneNode *node =
gridTransform.node->createChildSceneNode();
Ogre::Vector3 position =
basePosition + baseRotation * localPosition;
Ogre::Quaternion rotation = baseRotation * localRotation;
node->setPosition(position);
node->setOrientation(rotation);
poiEntity.set<TransformComponent>(
{ node, position, rotation,
Ogre::Vector3::UNIT_SCALE });
if (interactive) {
ActuatorComponent actuator(radius, height);
actuator.actionNames.push_back(actionName);
poiEntity.set<ActuatorComponent>(actuator);
SmartObjectComponent smartObject(radius, height);
smartObject.actionNames.push_back(actionName);
poiEntity.set<SmartObjectComponent>(smartObject);
}
meshData.furnitureInteractionEntities.push_back(poiEntity);
appendPoi(id, name, type, actionName, poiEntity, position,
rotation);
};
for (const FurnitureActionPoi &poi : definition.actions) {
createMarker(poi.name, poi.name, poi.actionName, poi.position,
poi.rotation, true, poi.radius, poi.height);
for (const FurniturePoiPosition &position : poi.positions) {
createMarker(position.name,
position.type.empty() ?
"position" :
position.type,
poi.actionName, position.position,
position.rotation, false, 0.0f, 0.0f);
}
}
for (const FurniturePoiPosition &position : definition.positions) {
createMarker(position.name,
position.type.empty() ? "position" :
position.type,
"", position.position, position.rotation, false,
0.0f, 0.0f);
}
}
/* Thin-wall assets use the same wall-plane convention as buildWalls:
* internal planes are inset 0.1 m, external planes sit 0.2 m outside
* the cell boundary. The FurnitureCell's quarter-turn rotation selects
* the side (the same mapping as the room placement walk). */
static Ogre::Vector3 thinWallPlaneOffset(const CellGridComponent &grid,
const FurnitureCell &fcell)
{
const Cell *cell = grid.findCell(fcell.x, fcell.y, fcell.z);
const float halfCell = grid.cellSize * 0.5f;
const float intOffset = 0.1f;
const float extOffset = 0.2f;
const int rotation = ((fcell.rotation % 4) + 4) % 4;
uint64_t intFlag = 0;
uint64_t extFlag = 0;
int axis = 0; /* 0=x, 1=z */
float intPlane = 0.0f;
float extPlane = 0.0f;
switch (rotation) {
case 0: /* Z+ */
intFlag = CellFlags::IntWallZPos;
extFlag = CellFlags::WallZPos;
axis = 1;
intPlane = halfCell - intOffset;
extPlane = halfCell + extOffset;
break;
case 2: /* Z- */
intFlag = CellFlags::IntWallZNeg;
extFlag = CellFlags::WallZNeg;
axis = 1;
intPlane = -halfCell + intOffset;
extPlane = -halfCell - extOffset;
break;
case 3: /* X- */
intFlag = CellFlags::IntWallXNeg;
extFlag = CellFlags::WallXNeg;
axis = 0;
intPlane = -halfCell + intOffset;
extPlane = -halfCell - extOffset;
break;
default: /* X+ */
intFlag = CellFlags::IntWallXPos;
extFlag = CellFlags::WallXPos;
axis = 0;
intPlane = halfCell - intOffset;
extPlane = halfCell + extOffset;
break;
}
bool internal = cell && (cell->flags & intFlag);
bool external = cell && (cell->flags & extFlag);
/* Invalid placements should not reach here, but the internal
* convention is the least-surprising fallback for hand-authored
* cells. */
float plane = internal ? intPlane : (external ? extPlane : intPlane);
return axis == 0 ? Ogre::Vector3(plane, 0.0f, 0.0f) :
Ogre::Vector3(0.0f, 0.0f, plane);
}
void CellGridSystem::buildFurniture(flecs::entity entity,
const CellGridComponent &grid)
CellGridComponent &grid)
{
destroyFurniture(entity);
grid.ensureGridUid();
if (!entity.has<TransformComponent>())
return;
@@ -2626,6 +2986,110 @@ void CellGridSystem::buildFurniture(flecs::entity entity,
if (!def)
continue;
const FurnitureDefinition::Layer layer = def->layer;
const bool blocking =
layer == FurnitureDefinition::Layer::Normal;
bool isolated = false;
if (blocking) {
for (const auto &tag : def->tags) {
if (tag == "isolated") {
isolated = true;
break;
}
}
}
Ogre::Vector3 pos = grid.cellToWorld(fcell.x, fcell.y,
fcell.z);
// Determine Y offset from the room that contains this furniture cell
float furnitureYOffset = 0.05f;
bool foundRoom = false;
entity.children([&](flecs::entity child) {
if (foundRoom) return;
if (child.has<RoomComponent>()) {
const auto &room = child.get<RoomComponent>();
if (room.contains(fcell.x, fcell.y, fcell.z)) {
furnitureYOffset = room.furnitureYOffset;
foundRoom = true;
}
}
});
Ogre::Quaternion localRot(
Ogre::Degree(90.0f * fcell.rotation),
Ogre::Vector3::UNIT_Y);
// Ceiling-layer items hang from the cell's ceiling plane
// (mesh origin = attachment point); floor items sit at
// the room's furniture Y offset; thin-wall items anchor
// to the selected wall plane with their local offset.
if (layer == FurnitureDefinition::Layer::Ceiling)
pos.y += grid.cellHeight;
else
pos.y += CellGridSystem::floorSurfaceY() + furnitureYOffset;
if (layer == FurnitureDefinition::Layer::ThinWall)
pos += thinWallPlaneOffset(grid, fcell);
/* `anchor` is the asset-local point aligned with the
* FurnitureCell's grid anchor; bounds/occupancy use the same
* pivot space. */
pos -= localRot * def->anchor;
/* Normal furniture collides through the authored simplified
* bounds when present, then physical bounds; only legacy
* definitions with no bounds fall back to raw mesh collision. */
const bool hasCollisionBounds =
def->hasCollisionBounds || def->hasBounds;
const Ogre::Vector3 boundsMin =
def->hasCollisionBounds ? def->collisionBoundsMin :
def->boundsMin;
const Ogre::Vector3 boundsMax =
def->hasCollisionBounds ? def->collisionBoundsMax :
def->boundsMax;
const Ogre::Vector3 boundsCenter =
(boundsMin + boundsMax) * 0.5f;
const Ogre::Vector3 boundsSize = boundsMax - boundsMin;
if (!def->prefabPath.empty()) {
PrefabSystem prefabSys(m_world, m_sceneMgr);
const std::string instanceName =
"furniture." + def->id + "." +
std::to_string(fcell.x) + "." +
std::to_string(fcell.y) + "." +
std::to_string(fcell.z);
flecs::entity item = prefabSys.createInstance(
def->prefabPath, entity, pos, instanceName);
if (!item.is_alive() ||
!item.get<PrefabInstanceComponent>().instantiated) {
Ogre::LogManager::getSingleton().logMessage(
"CellGrid: Could not instantiate furniture "
"prefab '" +
def->prefabPath + "'");
continue;
}
item.remove<EditorMarkerComponent>();
item.add<RuntimeMarkerComponent>();
item.add<FurnitureRuntimeTag>();
if (layer != FurnitureDefinition::Layer::Normal) {
bool stripped = false;
stripPrefabFurnitureCollision(item, &stripped);
if (stripped) {
Ogre::LogManager::getSingleton()
.logMessage(
"CellGrid: stripped colliders "
"from non-normal furniture "
"prefab '" +
def->prefabPath + "'");
}
}
auto &itemTransform = item.get_mut<TransformComponent>();
itemTransform.position = pos;
itemTransform.rotation = localRot;
itemTransform.applyToNode();
meshData.prefabFurnitureEntities.push_back(item);
buildFurnitureInteractions(entity, grid, *def, fcell,
pos, localRot, item);
continue;
}
Ogre::MeshPtr mesh =
Ogre::MeshManager::getSingleton().getByName(
def->meshName);
@@ -2648,34 +3112,6 @@ void CellGridSystem::buildFurniture(flecs::entity entity,
generateLodForMesh(mesh);
bool isolated = false;
for (const auto &tag : def->tags) {
if (tag == "isolated") {
isolated = true;
break;
}
}
Ogre::Vector3 pos = grid.cellToWorld(fcell.x, fcell.y,
fcell.z);
// Determine Y offset from the room that contains this furniture cell
float furnitureYOffset = 0.05f;
bool foundRoom = false;
entity.children([&](flecs::entity child) {
if (foundRoom) return;
if (child.has<RoomComponent>()) {
const auto &room = child.get<RoomComponent>();
if (room.contains(fcell.x, fcell.y, fcell.z)) {
furnitureYOffset = room.furnitureYOffset;
foundRoom = true;
}
}
});
pos.y += furnitureYOffset;
Ogre::Quaternion localRot(
Ogre::Degree(90.0f * fcell.rotation),
Ogre::Vector3::UNIT_Y);
if (!isolated && staticGeom) {
Ogre::Vector3 worldPos = parentPos +
(parentRot * (pos * parentScale));
@@ -2683,15 +3119,34 @@ void CellGridSystem::buildFurniture(flecs::entity entity,
Ogre::Entity *tempEnt =
m_sceneMgr->createEntity(def->meshName);
if (!mesh->getSubMesh(0)->getMaterialName().empty()) {
tempEnt->setMaterialName(
mesh->getSubMesh(0)->getMaterialName());
}
const Ogre::String furnitureMaterial =
furnitureMaterialName(*def, mesh);
if (!furnitureMaterial.empty())
tempEnt->setMaterialName(furnitureMaterial);
staticGeom->addEntity(tempEnt, worldPos, worldRot,
parentScale);
m_sceneMgr->destroyEntity(tempEnt);
meshData.furniturePlacements.push_back(
{def->meshName, worldPos, worldRot});
/* Only blocking (Normal-layer) furniture joins the
* static collider set; thin floor/wall and ceiling
* items render but never collide or block paths. */
if (blocking) {
if (hasCollisionBounds) {
MeshData::ColliderPlacement placement;
placement.position = parentPos +
parentRot *
((pos + localRot *
boundsCenter) *
parentScale);
placement.rotation = worldRot;
placement.boundsBox = true;
placement.size = boundsSize * parentScale;
meshData.furniturePlacements.push_back(
placement);
} else {
meshData.furniturePlacements.push_back(
{def->meshName, worldPos, worldRot});
}
}
batchedCount++;
} else {
Ogre::Entity *ent =
@@ -2706,25 +3161,48 @@ void CellGridSystem::buildFurniture(flecs::entity entity,
fnode->attachObject(ent);
ent->setRenderingDistance(100.0f);
if (!mesh->getSubMesh(0)->getMaterialName().empty()) {
ent->setMaterialName(
mesh->getSubMesh(0)->getMaterialName());
}
const Ogre::String furnitureMaterial =
furnitureMaterialName(*def, mesh);
if (!furnitureMaterial.empty())
ent->setMaterialName(furnitureMaterial);
meshData.furnitureEntities.push_back(ent);
// Create isolated furniture physics body
flecs::entity furnEntity = m_world.entity();
furnEntity.child_of(entity);
furnEntity.set<TransformComponent>(
{fnode, pos, localRot, Ogre::Vector3::UNIT_SCALE});
furnEntity.set<RigidBodyComponent>(RigidBodyComponent());
PhysicsColliderComponent collider;
collider.shapeType = PhysicsColliderComponent::ShapeType::Mesh;
collider.meshName = def->meshName;
furnEntity.set<PhysicsColliderComponent>(collider);
meshData.isolatedFurnitureEntities.push_back(furnEntity);
// Isolated physics body — blocking furniture only;
// thin/ceiling items never get a collider.
if (blocking) {
flecs::entity furnEntity = m_world.entity();
furnEntity.child_of(entity);
furnEntity.set<TransformComponent>(
{fnode, pos, localRot,
Ogre::Vector3::UNIT_SCALE});
furnEntity.set<RigidBodyComponent>(
RigidBodyComponent());
PhysicsColliderComponent collider;
if (hasCollisionBounds) {
collider.shapeType = PhysicsColliderComponent::
ShapeType::Box;
collider.parameters = Ogre::Vector3(
std::max(boundsSize.x * 0.5f,
0.005f),
std::max(boundsSize.y * 0.5f,
0.005f),
std::max(boundsSize.z * 0.5f,
0.005f));
collider.offset = boundsCenter;
} else {
collider.shapeType = PhysicsColliderComponent::
ShapeType::Mesh;
collider.meshName = def->meshName;
}
furnEntity.set<PhysicsColliderComponent>(collider);
meshData.isolatedFurnitureEntities.push_back(
furnEntity);
}
}
buildFurnitureInteractions(entity, grid, *def, fcell, pos,
localRot, flecs::entity::null());
}
if (batchedCount > 0) {
@@ -2755,7 +3233,28 @@ void CellGridSystem::destroyFurniture(flecs::entity entity)
}
}
it->second.furnitureEntities.clear();
// Generated action POI entities are grid-owned runtime children.
if (!it->second.furnitureInteractionEntities.empty()) {
PrefabSystem prefabSys(m_world, m_sceneMgr);
for (auto item : it->second.furnitureInteractionEntities) {
if (item.is_valid() && item.is_alive())
prefabSys.destroyInstance(item);
}
it->second.furnitureInteractionEntities.clear();
}
it->second.furniturePois.clear();
// Prefab-backed furniture owns full ECS subtrees.
if (!it->second.prefabFurnitureEntities.empty()) {
PrefabSystem prefabSys(m_world, m_sceneMgr);
for (auto item : it->second.prefabFurnitureEntities) {
if (item.is_valid() && item.is_alive())
prefabSys.destroyInstance(item);
}
it->second.prefabFurnitureEntities.clear();
}
// Destroy isolated furniture physics entities
for (auto furnEntity : it->second.isolatedFurnitureEntities) {
if (furnEntity.is_valid() && furnEntity.is_alive()) {
@@ -2997,6 +3496,23 @@ void CellGridSystem::addPhysicsCollider(flecs::entity physicsParent,
colliderEntity.set<PhysicsColliderComponent>(collider);
}
void CellGridSystem::addPhysicsBoxCollider(
flecs::entity physicsParent, const Ogre::Vector3 &position,
const Ogre::Quaternion &rotation, const Ogre::Vector3 &size)
{
flecs::entity colliderEntity = m_world.entity();
colliderEntity.child_of(physicsParent);
colliderEntity.set<TransformComponent>({nullptr, position, rotation,
Ogre::Vector3::UNIT_SCALE});
PhysicsColliderComponent collider;
collider.shapeType = PhysicsColliderComponent::ShapeType::Box;
collider.parameters = Ogre::Vector3(
std::max(size.x * 0.5f, 0.005f),
std::max(size.y * 0.5f, 0.005f),
std::max(size.z * 0.5f, 0.005f));
colliderEntity.set<PhysicsColliderComponent>(collider);
}
void CellGridSystem::buildPhysicsColliders(flecs::entity entity)
{
@@ -3084,8 +3600,12 @@ void CellGridSystem::buildPhysicsColliders(flecs::entity entity)
(placement.position - parentPos);
Ogre::Quaternion localRot =
parentRot.Inverse() * placement.rotation;
addPhysicsCollider(physicsParent, placement.meshName,
localPos, localRot);
if (placement.boundsBox)
addPhysicsBoxCollider(physicsParent, localPos,
localRot, placement.size);
else
addPhysicsCollider(physicsParent, placement.meshName,
localPos, localRot);
}
}
@@ -17,11 +17,16 @@ namespace Ogre
class StaticGeometry;
}
struct FurnitureDefinition;
class CellGridSystem {
public:
CellGridSystem(flecs::world &world, Ogre::SceneManager *sceneMgr);
~CellGridSystem();
/** Local Y of the generated floor's visible/physical surface. */
static constexpr float floorSurfaceY() { return 0.2f; }
void initialize();
void update();
@@ -49,6 +54,41 @@ public:
std::vector<PlacementInfo>
getFurniturePlacements(flecs::entity entity) const;
/** Stable identity and live runtime handle of one furniture POI
* (phase 6.5.G). `id` is derived from gridUid + persistent
* placement id + marker name, never from a transient flecs id.
* Positions/rotations are grid-local. */
struct FurniturePoiInfo {
std::string id;
std::string placementId;
std::string name;
std::string type;
std::string actionName;
std::string roomId;
std::string furnitureType;
std::vector<std::string> furnitureTags;
flecs::entity entity;
flecs::entity gridEntity;
Ogre::Vector3 position = Ogre::Vector3::ZERO;
Ogre::Quaternion rotation = Ogre::Quaternion::IDENTITY;
};
/** Read-only POI query by grid, room, furniture tag, action and
* marker type. Empty strings and a null gridEntity are wildcards. */
struct FurniturePoiQuery {
flecs::entity gridEntity = flecs::entity::null();
std::string roomId;
std::string furnitureTag;
std::string actionName;
std::string type;
};
std::vector<FurniturePoiInfo>
queryFurniturePois(const FurniturePoiQuery &query) const;
std::vector<FurniturePoiInfo> queryFurniturePois() const;
bool findFurniturePoi(const std::string &poiId,
FurniturePoiInfo &out) const;
// Plaza mesh storage per district entity
struct PlazaData {
std::string meshName;
@@ -171,7 +211,14 @@ private:
// Build furniture meshes
void buildFurniture(flecs::entity entity,
const struct CellGridComponent &grid);
struct CellGridComponent &grid);
void buildFurnitureInteractions(
flecs::entity entity, const struct CellGridComponent &grid,
const FurnitureDefinition &definition,
const struct FurnitureCell &fcell,
const Ogre::Vector3 &basePosition,
const Ogre::Quaternion &baseRotation,
flecs::entity prefabEntity);
void destroyFurniture(flecs::entity entity);
// Build window and door frames (3D frame meshes)
@@ -253,6 +300,10 @@ private:
const std::string &meshName,
const Ogre::Vector3 &position,
const Ogre::Quaternion &rotation);
void addPhysicsBoxCollider(flecs::entity physicsParent,
const Ogre::Vector3 &position,
const Ogre::Quaternion &rotation,
const Ogre::Vector3 &size);
void buildPhysicsColliders(flecs::entity entity);
void destroyPhysicsColliders(flecs::entity entity);
@@ -262,6 +313,10 @@ private:
std::string meshName;
Ogre::Vector3 position;
Ogre::Quaternion rotation;
/* Normal furniture uses collisionBounds > physical
* bounds > legacy raw mesh collision (phase 6.5.D). */
bool boundsBox = false;
Ogre::Vector3 size = Ogre::Vector3::ZERO;
};
std::string floorMesh;
@@ -299,6 +354,10 @@ private:
std::vector<ColliderPlacement> framePlacements;
std::vector<ColliderPlacement> furniturePlacements;
std::vector<flecs::entity> isolatedFurnitureEntities;
std::vector<flecs::entity> prefabFurnitureEntities;
std::vector<flecs::entity> furnitureInteractionEntities;
/** Generated + prefab-authored POIs, rebuilt with the grid. */
std::vector<FurniturePoiInfo> furniturePois;
flecs::entity physicsParentEntity = flecs::entity::null();
// Door leaf entities (runtime, one per unique doorway), each
@@ -8,6 +8,7 @@
#include <OgreAnimation.h>
#include <OgreAnimationState.h>
#include <OgreAnimationTrack.h>
#include <OgreCamera.h>
#include <OgreDataStream.h>
#include <OgreEntity.h>
#include <OgreKeyFrame.h>
@@ -449,6 +450,11 @@ Ogre::String CharacterSlotSystem::resolveMesh(const Ogre::String &age,
return "";
}
static const CharacterRegistry::CharacterRecord *
getCharacterRecord(flecs::entity e);
static void ensureHardwareSkinning(const Ogre::MeshPtr &mesh);
static void finalizeHardwareSkinning(Ogre::Entity *ent);
void CharacterSlotSystem::update()
{
if (!m_initialized)
@@ -462,6 +468,245 @@ void CharacterSlotSystem::update()
if (e.is_alive() && e.has<CharacterIdentityComponent>())
buildCharacter(e);
}
/* Distance LOD tier pass (demo-taxi phase 6.C.3). Work on a
* copied key list: applyLodTier can rebuild a character, which
* erases/recreates its m_entities entry (rehash hazard). */
Ogre::Camera *cam = nullptr;
if (m_sceneMgr->hasCamera("PlayerCamera"))
cam = m_sceneMgr->getCamera("PlayerCamera");
if (!cam) {
const Ogre::SceneManager::CameraList &cams =
m_sceneMgr->getCameras();
if (!cams.empty())
cam = cams.begin()->second;
}
if (!cam)
return;
const Ogre::Vector3 camPos = cam->getDerivedPosition();
std::vector<flecs::entity_t> ids;
ids.reserve(m_entities.size());
for (const auto &pair : m_entities)
ids.push_back(pair.first);
for (flecs::entity_t id : ids) {
flecs::entity e = m_world.entity(id);
if (!e.is_alive() || !e.has<TransformComponent>())
continue;
auto &transform = e.get<TransformComponent>();
if (!transform.node)
continue;
auto it = m_entities.find(id);
if (it == m_entities.end())
continue;
float dist =
camPos.distance(transform.node->_getDerivedPosition());
int target = resolveLodTier(dist, it->second.lodTier);
if (target != it->second.lodTier)
applyLodTier(e, target);
}
}
/* Distance LOD tiers (6.C.3): boundaries in metres. NPCs despawn at
* 100 m, so tier 3 covers 70 m till despawn. */
static const float kLodEnter[3] = {10.0f, 30.0f, 70.0f};
static const float kLodHysteresis = 2.0f;
int CharacterSlotSystem::resolveLodTier(float distance, int current) const
{
int t = 0;
for (int i = 0; i < 3; ++i) {
if (distance >= kLodEnter[i])
t = i + 1;
}
if (current < 0 || t == current)
return t;
if (t > current) {
/* moving away: must clear the boundary by the margin */
if (distance < kLodEnter[t - 1] + kLodHysteresis)
return current;
} else {
/* moving closer: must get back below the margin */
if (distance >= kLodEnter[current - 1] - kLodHysteresis)
return current;
}
return t;
}
static Ogre::String lod1MeshName(const Ogre::String &mesh)
{
Ogre::String base = mesh;
size_t dot = base.rfind(".mesh");
if (dot != Ogre::String::npos)
base = base.substr(0, dot);
return base + "_lod1.mesh";
}
/* Pipeline-generated LOD meshes (charlod impostors, _lod1 parts) are
* optional; lookups are cached so the fallback costs one query per
* unique name. */
static std::unordered_map<Ogre::String, bool> s_lodMeshExists;
static bool lodMeshAvailable(const Ogre::String &mesh)
{
auto it = s_lodMeshExists.find(mesh);
if (it != s_lodMeshExists.end())
return it->second;
/* MeshManager::resourceExists only checks the already-loaded
* resource map; the group-level check also finds on-disk meshes
* that were never loaded (the normal case for LOD variants). */
bool exists = Ogre::ResourceGroupManager::getSingleton()
.resourceExists("Characters", mesh);
s_lodMeshExists[mesh] = exists;
return exists;
}
static Ogre::String
impostorMeshName(const CharacterRegistry::CharacterRecord *rec, int tier)
{
if (!rec || rec->inlineSex.empty())
return "";
Ogre::String variant = "thin";
auto it = rec->inlineShapeKeyWeights.find("fat");
if (it != rec->inlineShapeKeyWeights.end() && it->second >= 0.5f)
variant = "fat";
return "charlod_" + rec->inlineSex + "_" + variant + "_lod" +
Ogre::StringConverter::toString(tier) + ".mesh";
}
void CharacterSlotSystem::applyLodTier(flecs::entity e, int tier)
{
auto it = m_entities.find(e.id());
if (it == m_entities.end())
return;
/* Tier 2/3 need an impostor mesh for the record's sex/shape;
* without one fall back to the per-part tier. */
if (tier >= 2) {
const CharacterRegistry::CharacterRecord *rec =
getCharacterRecord(e);
Ogre::String name = impostorMeshName(rec, tier);
if (name.empty() || !lodMeshAvailable(name))
tier = 1;
}
if (tier >= 2) {
if (it->second.impostorTier != tier) {
destroyImpostor(e.id());
const CharacterRegistry::CharacterRecord *rec =
getCharacterRecord(e);
Ogre::String meshName = impostorMeshName(rec, tier);
Ogre::Entity *master = it->second.masterEntity;
try {
Ogre::MeshPtr mp =
Ogre::MeshManager::getSingleton().load(
meshName, "Characters");
/* The impostor must link the SAME skeleton
* as the parts — shareSkeletonInstanceWith
* with a different skeleton binds the wrong
* bones (crumpled mesh, like the bad-weights
* auto-detection in buildCharacter). */
if (master && mp->getSkeleton() &&
mp->getSkeleton() ==
master->getMesh()->getSkeleton()) {
ensureHardwareSkinning(mp);
Ogre::Entity *ent =
m_sceneMgr->createEntity(mp);
ent->shareSkeletonInstanceWith(master);
e.get<TransformComponent>()
.node->attachObject(ent);
finalizeHardwareSkinning(ent);
ent->setUpdateBoundingBoxFromSkeleton(
true);
it->second.impostor = ent;
it->second.impostorTier = tier;
} else {
Ogre::LogManager::getSingleton()
.logMessage(
"[CharacterSlotSystem] "
"impostor mesh '" +
meshName +
"' missing or skeleton "
"mismatch, staying at "
"tier 1");
}
} catch (const Ogre::Exception &ex) {
Ogre::LogManager::getSingleton().logMessage(
"[CharacterSlotSystem] failed to create "
"impostor '" +
meshName + "': " + ex.getDescription());
}
}
if (it->second.impostor) {
it->second.lodTier = it->second.impostorTier;
applyVisibility(e.id());
return;
}
tier = 1; /* impostor creation failed */
}
destroyImpostor(e.id());
/* OGRE 1.x has no Entity::setMesh — swapping between base and
* "_lod1" part meshes requires a rebuild. Rare: only on a tier
* boundary crossing (with hysteresis). */
bool wantLod1 = tier >= 1;
if (it->second.partsLod1 != wantLod1) {
m_buildLod1 = wantLod1;
buildCharacter(e);
m_buildLod1 = false;
auto it2 = m_entities.find(e.id());
if (it2 != m_entities.end()) {
it2->second.partsLod1 = wantLod1;
it2->second.lodTier = wantLod1 ? 1 : 0;
}
return;
}
it->second.lodTier = tier;
applyVisibility(e.id());
}
void CharacterSlotSystem::destroyImpostor(flecs::entity_t id)
{
auto it = m_entities.find(id);
if (it == m_entities.end())
return;
if (it->second.impostor) {
try {
it->second.impostor->detachFromParent();
m_sceneMgr->destroyEntity(it->second.impostor);
} catch (...) {
}
it->second.impostor = nullptr;
}
it->second.impostorTier = -1;
}
void CharacterSlotSystem::applyVisibility(flecs::entity_t id)
{
auto it = m_entities.find(id);
if (it == m_entities.end())
return;
bool impostorVisible = !it->second.cameraHidden &&
it->second.impostor != nullptr &&
it->second.lodTier >= 2;
bool partsVisible = !it->second.cameraHidden && !impostorVisible;
for (auto &pair : it->second.parts) {
if (pair.second)
pair.second->setVisible(partsVisible);
}
if (it->second.impostor)
it->second.impostor->setVisible(impostorVisible);
}
int CharacterSlotSystem::getLodTier(flecs::entity e) const
{
auto it = m_entities.find(e.id());
if (it == m_entities.end())
return -1;
return it->second.lodTier;
}
static const nlohmann::json *findCatalogEntry(const Ogre::String &age,
@@ -820,8 +1065,22 @@ void CharacterSlotSystem::prewarmShaders()
if (!gen)
return;
/* Include the pipeline-generated LOD meshes (6.C.3): "_lod1" part
* variants and "charlod" impostors share the per-sex atlas
* material, but each mesh still sizes its compact bone palette on
* first use. */
std::set<Ogre::String> warmNames = s_meshNames;
Ogre::ResourceGroupManager &rgm =
Ogre::ResourceGroupManager::getSingleton();
for (const char *pattern : {"*_lod1.mesh", "charlod_*.mesh"}) {
Ogre::StringVectorPtr names =
rgm.findResourceNames("Characters", pattern);
if (names)
warmNames.insert(names->begin(), names->end());
}
size_t warmed = 0;
for (const auto &meshName : s_meshNames) {
for (const auto &meshName : warmNames) {
Ogre::MeshPtr mesh;
try {
mesh = Ogre::MeshManager::getSingleton().load(
@@ -857,7 +1116,7 @@ void CharacterSlotSystem::prewarmShaders()
Ogre::LogManager::getSingleton().logMessage(
"[CharacterSlotSystem] prewarmed character shaders (" +
Ogre::StringConverter::toString(warmed) + " passes, " +
Ogre::StringConverter::toString(s_meshNames.size()) +
Ogre::StringConverter::toString(warmNames.size()) +
" catalog meshes)");
}
@@ -942,6 +1201,14 @@ void CharacterSlotSystem::buildCharacter(flecs::entity e)
if (masterMesh.empty())
return;
/* Tier-1 rebuild: swap in the pipeline-generated "_lod1" atlas
* variant when one exists (6.C.3). */
if (m_buildLod1) {
Ogre::String l1 = lod1MeshName(masterMesh);
if (lodMeshAvailable(l1))
masterMesh = l1;
}
/* Pre-create pose animation on mesh so entity knows about it --
* but only when the character actually uses shape keys. */
const bool needsShapeKeys = recordNeedsShapeKeys(rec);
@@ -997,6 +1264,12 @@ void CharacterSlotSystem::buildCharacter(flecs::entity e)
if (mesh.empty())
continue;
if (m_buildLod1) {
Ogre::String l1 = lod1MeshName(mesh);
if (lodMeshAvailable(l1))
mesh = l1;
}
try {
if (needsShapeKeys)
ensureMeshPoseAnimation(mesh);
@@ -1224,6 +1497,15 @@ void CharacterSlotSystem::destroyCharacterParts(flecs::entity e)
if (it == m_entities.end())
return;
if (it->second.impostor) {
try {
it->second.impostor->detachFromParent();
m_sceneMgr->destroyEntity(it->second.impostor);
} catch (...) {
}
it->second.impostor = nullptr;
}
for (auto &pair : it->second.parts) {
Ogre::Entity *ent = pair.second;
if (ent) {
@@ -1274,8 +1556,6 @@ void CharacterSlotSystem::setCharacterVisible(flecs::entity e, bool visible)
auto it = m_entities.find(e.id());
if (it == m_entities.end())
return;
for (auto &pair : it->second.parts) {
if (pair.second)
pair.second->setVisible(visible);
}
it->second.cameraHidden = !visible;
applyVisibility(e.id());
}
@@ -102,6 +102,14 @@ public:
* the character geometry). */
void setCharacterVisible(flecs::entity e, bool visible);
/* Distance LOD tier of a built character (demo-taxi phase 6.C.3):
* 0 = full modular meshes (< 10 m), 1 = per-part "_lod1" atlas
* meshes (10-20 m), 2/3 = whole-character "charlod" impostor
* sharing the master skeleton (20-60 m / 60+ m, hair physics
* off). -1 when the character is not built or not yet
* evaluated. HairPhysicsSystem skips ragdolls at tier >= 2. */
int getLodTier(flecs::entity e) const;
private:
static CharacterSlotSystem *ms_singleton;
static bool s_catalogLoaded;
@@ -113,6 +121,12 @@ private:
void applyShapeKeys(flecs::entity e, Ogre::Entity *ent,
const nlohmann::json *entry);
/* Distance LOD tiers (6.C.3). */
int resolveLodTier(float distance, int current) const;
void applyLodTier(flecs::entity e, int tier);
void destroyImpostor(flecs::entity_t id);
void applyVisibility(flecs::entity_t id);
flecs::world &m_world;
/* Kept so the destructor can unregister them: the observers must
* not fire into a destroyed system during world teardown. */
@@ -124,9 +138,20 @@ private:
struct PartEntities {
Ogre::Entity *masterEntity = nullptr;
std::unordered_map<Ogre::String, Ogre::Entity *> parts;
/* Whole-character impostor shown at LOD tiers 2/3 while the
* part entities are hidden (the master keeps animating the
* shared skeleton — visibility does not stop animation). */
Ogre::Entity *impostor = nullptr;
int impostorTier = -1;
int lodTier = -1;
bool partsLod1 = false;
bool cameraHidden = false;
};
std::unordered_map<flecs::entity_t, PartEntities> m_entities;
std::unordered_set<flecs::entity_t> m_dirtyEntities;
/* Set around buildCharacter() to resolve the "_lod1" mesh variants
* instead of the base part meshes (tier 1 rebuild). */
bool m_buildLod1 = false;
};
#endif // EDITSCENE_CHARACTERSLOTSYSTEM_HPP
@@ -7,6 +7,7 @@
#include "../EditorApp.hpp"
#include "DialogueSystem.hpp"
#include "PrefabSystem.hpp"
#include "FurnitureLibrary.hpp"
#include "ItemRegistry.hpp"
#include "../camera/EditorCamera.hpp"
#include "../systems/TerrainSystem.hpp"
@@ -969,7 +970,14 @@ void EditorUISystem::renderEntityContextMenu(flecs::entity entity)
if (ImGui::MenuItem("Create Prefab")) {
m_prefabSourceEntity = entity;
m_showCreatePrefabDialog = true;
m_prefabNameBuffer[0] = '\0';
m_prefabAsFurniture = entity.has<FurnitureTemplateComponent>();
if (m_prefabAsFurniture) {
strncpy(m_prefabNameBuffer, "furniture.",
sizeof(m_prefabNameBuffer) - 1);
m_prefabNameBuffer[sizeof(m_prefabNameBuffer) - 1] = '\0';
} else {
m_prefabNameBuffer[0] = '\0';
}
}
ImGui::Separator();
if (ImGui::MenuItem("Delete")) {
@@ -1688,22 +1696,181 @@ void EditorUISystem::renderFPSOverlay(float deltaTime)
ImGui::End();
}
static void mergePrefabWorldBounds(flecs::entity entity,
Ogre::AxisAlignedBox &box)
{
if (entity.has<RenderableComponent>()) {
Ogre::Entity *renderable =
entity.get<RenderableComponent>().entity;
if (renderable)
box.merge(renderable->getWorldBoundingBox(true));
}
entity.children([&](flecs::entity child) {
mergePrefabWorldBounds(child, box);
});
}
/* Measure a prefab in its root/pivot space for the companion
* definition's physical bounds (phase 6.5.E). */
static bool computePrefabLocalBounds(flecs::entity entity,
Ogre::Vector3 &outMin,
Ogre::Vector3 &outMax)
{
if (!entity.is_alive() || !entity.has<TransformComponent>())
return false;
Ogre::SceneNode *root = entity.get<TransformComponent>().node;
if (!root)
return false;
Ogre::AxisAlignedBox worldBox;
mergePrefabWorldBounds(entity, worldBox);
if (worldBox.isNull())
return false;
Ogre::AxisAlignedBox localBox;
for (int i = 0; i < 8; ++i) {
localBox.merge(root->convertWorldToLocalPosition(
worldBox.getCorner(
(Ogre::AxisAlignedBox::CornerEnum)i)));
}
outMin = localBox.getMinimum();
outMax = localBox.getMaximum();
return true;
}
void EditorUISystem::showCreatePrefabDialog(flecs::entity entity)
{
static const std::string furniturePrefix = "furniture.";
auto trim = [](std::string value) {
const size_t first = value.find_first_not_of(" \t\r\n");
if (first == std::string::npos)
return std::string();
const size_t last = value.find_last_not_of(" \t\r\n");
return value.substr(first, last - first + 1);
};
ImGui::OpenPopup("Create Prefab");
if (ImGui::BeginPopupModal("Create Prefab", &m_showCreatePrefabDialog,
ImGuiWindowFlags_AlwaysAutoResize)) {
if (ImGui::Checkbox("Furniture prefab", &m_prefabAsFurniture)) {
std::string name = trim(m_prefabNameBuffer);
if (m_prefabAsFurniture) {
if (name.rfind(furniturePrefix, 0) != 0)
name = furniturePrefix + name;
} else if (name.rfind(furniturePrefix, 0) == 0) {
name.erase(0, furniturePrefix.size());
}
strncpy(m_prefabNameBuffer, name.c_str(),
sizeof(m_prefabNameBuffer) - 1);
m_prefabNameBuffer[sizeof(m_prefabNameBuffer) - 1] = '\0';
}
if (ImGui::IsItemHovered())
ImGui::SetTooltip(
"Use the furniture.<name>.json prefix for CellGrid furniture prefabs");
ImGui::InputText("Prefab Name", m_prefabNameBuffer,
sizeof(m_prefabNameBuffer));
if (m_prefabAsFurniture) {
ImGui::InputText("Selection Tags", m_prefabFurnitureTags,
sizeof(m_prefabFurnitureTags));
const char *layers[] = { "normal", "thin-floor",
"thin-wall", "ceiling" };
ImGui::Combo("Furniture Layer", &m_prefabFurnitureLayer,
layers, 4);
ImGui::TextDisabled(
"Also generates resources/buildings/parts/furniture/furniture-<id>.json");
}
if (ImGui::Button("Save", ImVec2(120, 0))) {
if (entity.is_alive() &&
m_prefabNameBuffer[0] != '\0') {
std::string prefabName = trim(m_prefabNameBuffer);
if (m_prefabAsFurniture) {
if (prefabName.rfind(furniturePrefix, 0) != 0)
prefabName = furniturePrefix + prefabName;
} else if (prefabName.rfind(furniturePrefix, 0) == 0) {
prefabName.erase(0, furniturePrefix.size());
}
if (entity.is_alive() && !prefabName.empty() &&
prefabName != furniturePrefix) {
std::string prefabPath =
PrefabSystem::getPrefabsDirectory() +
"/" + m_prefabNameBuffer + ".json";
"/" + prefabName + ".json";
PrefabSystem prefabSys(m_world, m_sceneMgr);
if (prefabSys.savePrefab(entity, prefabPath)) {
if (m_prefabAsFurniture) {
/* Generate the schema-v1 companion
* definition alongside the prefab. */
std::string id = prefabName.substr(
furniturePrefix.size());
if (id.rfind("furniture-", 0) == 0)
id.erase(0, 10);
FurnitureDefinition::
PrefabDefinitionOptions options;
options.id = id;
options.prefabPath = prefabPath;
options.layer =
(FurnitureDefinition::Layer)
m_prefabFurnitureLayer;
options.hasBounds =
computePrefabLocalBounds(
entity, options.boundsMin,
options.boundsMax);
std::string tagText =
m_prefabFurnitureTags;
size_t start = 0;
while (start <= tagText.size()) {
size_t comma = tagText.find(',',
start);
std::string tag = trim(
tagText.substr(
start,
comma ==
std::string::
npos ?
std::string::
npos :
comma -
start));
if (!tag.empty())
options.tags.push_back(
tag);
if (comma == std::string::npos)
break;
start = comma + 1;
}
const nlohmann::json *prefabJson =
SceneSerializer::
loadPrefabJsonCached(
prefabPath);
const std::string definitionPath =
"resources/buildings/parts/"
"furniture/furniture-" +
id + ".json";
std::string error;
if (!prefabJson) {
Ogre::LogManager::getSingleton()
.logMessage(
"Furniture companion: "
"could not read " +
prefabPath);
} else if (FurnitureDefinition::
writePrefabDefinition(
definitionPath,
*prefabJson,
options,
&error)) {
Ogre::LogManager::getSingleton()
.logMessage(
"Furniture companion "
"definition written to " +
definitionPath);
FurnitureLibrary::getInstance()
.reload();
} else {
Ogre::LogManager::getSingleton()
.logMessage(
"Furniture companion "
"generation failed: " +
error);
}
}
// Convert source entity to prefab instance
entity.set<PrefabInstanceComponent>(
PrefabInstanceComponent{
@@ -1716,11 +1883,13 @@ void EditorUISystem::showCreatePrefabDialog(flecs::entity entity)
}
m_showCreatePrefabDialog = false;
m_prefabSourceEntity = flecs::entity::null();
m_prefabAsFurniture = false;
}
ImGui::SameLine();
if (ImGui::Button("Cancel", ImVec2(120, 0))) {
m_showCreatePrefabDialog = false;
m_prefabSourceEntity = flecs::entity::null();
m_prefabAsFurniture = false;
}
ImGui::EndPopup();
}
@@ -1756,6 +1925,14 @@ void EditorUISystem::renderPrefabBrowser()
}
}
}
std::sort(m_prefabFiles.begin(), m_prefabFiles.end(),
[](const std::string &a, const std::string &b) {
const bool af = a.rfind("furniture.", 0) == 0;
const bool bf = b.rfind("furniture.", 0) == 0;
if (af != bf)
return af;
return a < b;
});
m_refreshPrefabList = false;
}
@@ -1775,8 +1952,21 @@ void EditorUISystem::renderPrefabBrowser()
ImGui::Separator();
// Prefab list
// Prefab list; furniture.* is the CellGrid furniture category.
bool furnitureHeaderShown = false;
bool otherHeaderShown = false;
for (const auto &file : m_prefabFiles) {
const bool furniture =
file.rfind("furniture.", 0) == 0;
if (furniture && !furnitureHeaderShown) {
ImGui::TextDisabled("Furniture");
furnitureHeaderShown = true;
} else if (!furniture && furnitureHeaderShown &&
!otherHeaderShown) {
ImGui::Separator();
ImGui::TextDisabled("Other Prefabs");
otherHeaderShown = true;
}
std::string path = PrefabSystem::getPrefabsDirectory() +
"/" + file;
ImGui::Text("%s", file.c_str());
@@ -336,8 +336,11 @@ private:
// Prefab dialog state
bool m_showCreatePrefabDialog = false;
bool m_prefabAsFurniture = false;
flecs::entity m_prefabSourceEntity = flecs::entity::null();
char m_prefabNameBuffer[256] = { 0 };
char m_prefabFurnitureTags[128] = "custom";
int m_prefabFurnitureLayer = 0;
bool m_showPrefabBrowser = false;
std::vector<std::string> m_prefabFiles;
bool m_refreshPrefabList = true;
File diff suppressed because it is too large Load Diff
@@ -6,11 +6,152 @@
#include <vector>
#include <random>
struct FurnitureDefinition {
/** Named local transform exported from a Blender `position-*` marker. */
struct FurniturePoiPosition {
std::string name;
std::string meshName;
std::string type;
Ogre::Vector3 position = Ogre::Vector3::ZERO;
Ogre::Quaternion rotation = Ogre::Quaternion::IDENTITY;
};
/** Asset-local action/POI exported from a Blender `action-*` marker. */
struct FurnitureActionPoi {
std::string name;
std::string actionName;
std::string actionText;
std::vector<std::string> tags;
Ogre::Vector3 position = Ogre::Vector3::ZERO;
Ogre::Quaternion rotation = Ogre::Quaternion::IDENTITY;
float height = 1.0f;
float radius = 0.5f;
std::vector<FurniturePoiPosition> positions;
};
/**
* Canonical runtime metadata for one furniture definition.
*
* `name` remains the legacy identifier used by existing FurnitureCell
* records. New schema v1 files may also provide `id`; when present it is
* authoritative and copied to `name` so older call sites keep working.
*/
/** One selection term: all source-list entries are ANDed; `a|b` accepts
* either tag and `!a` rejects it. */
struct FurnitureTagTerm {
bool negated = false;
std::vector<std::string> alternatives;
};
struct FurnitureDefinition {
/** Placement layer (phase 6.5), from the json "layer" field.
* Normal furniture blocks movement and gets a collider; the thin
* layers render but never collide or block paths; Ceiling items
* are placed at the cell's ceiling plane instead of the floor. */
enum class Layer {
Normal,
ThinFloor,
ThinWall,
Ceiling,
};
/** Ownership of the generated/manual definition content. */
enum class Source {
Manual,
Blender,
Prefab,
};
std::string id;
std::string name;
std::string displayName;
std::string meshName;
std::string prefabPath;
std::vector<std::string> tags;
std::vector<std::string> aliases;
std::vector<FurnitureActionPoi> actions;
std::vector<FurniturePoiPosition> positions;
nlohmann::json jsonData;
Layer layer = Layer::Normal;
Source source = Source::Manual;
std::string sourceHash;
/** Local physical bounds in metres, in the mesh/prefab pivot space. */
bool hasBounds = false;
Ogre::Vector3 boundsMin = Ogre::Vector3::ZERO;
Ogre::Vector3 boundsMax = Ogre::Vector3::ZERO;
/** Optional simplified collision bounds, likewise in pivot space. */
bool hasCollisionBounds = false;
Ogre::Vector3 collisionBoundsMin = Ogre::Vector3::ZERO;
Ogre::Vector3 collisionBoundsMax = Ogre::Vector3::ZERO;
/** Placement anchor in pivot space. The current convention is the
* asset root/origin, so this defaults to zero. */
Ogre::Vector3 anchor = Ogre::Vector3::ZERO;
/** Optional gameplay occupancy override in grid cells. When absent,
* the placement kernel derives cells from the physical bounds and the
* target grid's cell size. */
bool hasFootprintCells = false;
int footprintCellsX = 1;
int footprintCellsZ = 1;
/** Extra clearance required around the physical footprint (metres). */
float clearance = 0.0f;
bool requiresWall = false;
bool requiresFloor = true;
float weight = 1.0f;
static Layer layerFromString(const std::string &name);
static const char *layerToString(Layer layer);
static bool tryLayerFromString(const std::string &name, Layer &layer);
static Source sourceFromString(const std::string &name);
static const char *sourceToString(Source source);
/** Parse and validate one schema-v1/legacy definition. This function
* is deliberately independent of OGRE resource loading so it can run
* in headless unit tests. */
static bool parseJson(const nlohmann::json &json,
const std::string &resourceName,
FurnitureDefinition &out, std::string *error);
/** Canonical serialization of the metadata fields understood by the
* runtime. Interaction arrays not interpreted yet are copied through
* from the source JSON. */
nlohmann::json toJson() const;
/** Inputs for editor-generated prefab companion definitions
* (phase 6.5.E). The writer owns source=Prefab and computes the
* deterministic sourceHash from the prefab JSON, so re-saving the
* same prefab reproduces the same definition byte-for-byte. */
struct PrefabDefinitionOptions {
std::string id;
std::string prefabPath;
std::vector<std::string> tags;
Layer layer = Layer::Normal;
bool hasBounds = false;
Ogre::Vector3 boundsMin = Ogre::Vector3::ZERO;
Ogre::Vector3 boundsMax = Ogre::Vector3::ZERO;
};
static bool writePrefabDefinition(
const std::string &definitionPath,
const nlohmann::json &prefabJson,
const PrefabDefinitionOptions &options,
std::string *error = nullptr);
/** Validate id/alias uniqueness in a deterministic definition set. */
static bool validateDefinitionSet(
const std::vector<FurnitureDefinition> &definitions,
std::string *error);
/** Absolute grid cells occupied by this definition when its anchor is
* placed at (anchorX, anchorZ). Rotation is the FurnitureCell 0..3
* quarter-turn convention and bounds are converted at the target
* grid's cell size. */
std::vector<std::pair<int, int>> occupiedCells(
int anchorX, int anchorZ, int rotation, float cellSize) const;
bool overlaps(const FurnitureDefinition &other, int anchorX,
int anchorZ, int rotation, float cellSize,
int otherX, int otherZ, int otherRotation) const;
};
class FurnitureLibrary {
@@ -25,14 +166,33 @@ public:
const FurnitureDefinition *findByName(const std::string &name) const;
static bool parseTagExpression(
const std::vector<std::string> &sourceTerms,
std::vector<FurnitureTagTerm> &terms, std::string *error);
static bool matchesTagTerms(
const FurnitureDefinition &definition,
const std::vector<FurnitureTagTerm> &terms);
std::vector<const FurnitureDefinition *> findByTags(
const std::vector<std::string> &requiredTags,
const std::vector<std::string> &forbiddenTags = {}) const;
std::vector<const FurnitureDefinition *> findByTagTerms(
const std::vector<std::string> &sourceTerms,
const std::vector<std::string> &forbiddenTags = {}) const;
const FurnitureDefinition *selectByTags(
const std::vector<std::string> &requiredTags,
std::mt19937 &rng,
const std::vector<std::string> &forbiddenTags = {}) const;
const FurnitureDefinition *selectByTagTerms(
const std::vector<std::string> &sourceTerms,
std::mt19937 &rng,
const std::vector<std::string> &forbiddenTags = {}) const;
/** Weighted selection helper, exposed for pure tests. */
static const FurnitureDefinition *selectWeighted(
const std::vector<const FurnitureDefinition *> &candidates,
std::mt19937 &rng);
private:
FurnitureLibrary() = default;
@@ -0,0 +1,288 @@
#include "FurniturePlacement.hpp"
#include <algorithm>
#include <set>
namespace {
/* Wall flags per side, mirroring RoomLayoutSystem's tables: interior or
* exterior wall in any direction counts as "has wall". */
const uint64_t WALL_FLAG_MASK = CellFlags::IntWallZNeg |
CellFlags::WallZNeg | CellFlags::IntWallZPos |
CellFlags::WallZPos | CellFlags::IntWallXNeg |
CellFlags::WallXNeg | CellFlags::IntWallXPos |
CellFlags::WallXPos;
bool hasAnyDoor(const CellGridComponent &grid, int x, int y, int z)
{
const Cell *cell = grid.findCell(x, y, z);
return cell && (cell->flags & CellFlags::AllDoors) != 0;
}
bool hasAnyWindow(const CellGridComponent &grid, int x, int y, int z)
{
const Cell *cell = grid.findCell(x, y, z);
return cell && (cell->flags & CellFlags::AllWindows) != 0;
}
bool hasTag(const std::vector<std::string> &tags, const char *tag)
{
return std::find(tags.begin(), tags.end(), tag) != tags.end();
}
/* Rotate a cell offset by quarter turns, same convention as
* FurnitureDefinition::occupiedCells (1 = 90 degrees CW around +Y). */
void rotateOffset(int rotation, int &x, int &z)
{
rotation = ((rotation % 4) + 4) % 4;
int inX = x;
int inZ = z;
switch (rotation) {
case 1:
x = inZ;
z = -inX;
break;
case 2:
x = -inX;
z = -inZ;
break;
case 3:
x = -inZ;
z = inX;
break;
default:
break;
}
}
} // namespace
bool FurniturePlacement::canPlace(const CellGridComponent &grid,
const RoomComponent *room,
const FurniturePlacementRequest &request,
const std::vector<FurniturePlacementRequest> &pending,
std::string *error,
const DefinitionResolver &resolver)
{
const FurnitureDefinition *def = request.definition;
if (!def) {
if (error)
*error = "missing furniture definition";
return false;
}
const DefinitionResolver resolve =
resolver ? resolver :
DefinitionResolver([](const std::string &name) {
return FurnitureLibrary::getInstance()
.findByName(name);
});
const auto occupied =
def->occupiedCells(request.x, request.z, request.rotation,
grid.cellSize);
for (const auto &cellPos : occupied) {
const int cx = cellPos.first;
const int cz = cellPos.second;
if (room && !room->contains(cx, request.y, cz)) {
if (error)
*error = "outside room";
return false;
}
const Cell *cell = grid.findCell(cx, request.y, cz);
if (!cell) {
if (error)
*error = "no grid cell";
return false;
}
if (hasAnyDoor(grid, cx, request.y, cz) &&
!hasTag(def->tags, "door")) {
if (error)
*error = "blocks a door";
return false;
}
if (hasAnyWindow(grid, cx, request.y, cz) &&
hasTag(def->tags, "nowindow")) {
if (error)
*error = "blocks a window";
return false;
}
if (def->requiresFloor &&
!(cell->flags & CellFlags::Floor)) {
if (error)
*error = "requires a floor";
return false;
}
}
if (def->requiresWall ||
def->layer == FurnitureDefinition::Layer::ThinWall) {
bool hasWall = false;
for (const auto &cellPos : occupied) {
const Cell *cell = grid.findCell(cellPos.first,
request.y,
cellPos.second);
if (cell && (cell->flags & WALL_FLAG_MASK)) {
hasWall = true;
break;
}
}
if (!hasWall) {
if (error)
*error = "requires a wall";
return false;
}
}
/* Overlap with committed placements. A non-empty placementId is
* an update handle: the existing cell with the same id does not
* block the update (it is replaced by commit()). */
for (const auto &otherCell : grid.furnitureCells) {
if (otherCell.y != request.y)
continue;
if (!request.placementId.empty() &&
otherCell.placementId == request.placementId)
continue;
const FurnitureDefinition *other = resolve(otherCell.furnitureType);
if (!other)
continue;
if (def->overlaps(*other, request.x, request.z,
request.rotation, grid.cellSize,
otherCell.x, otherCell.z,
otherCell.rotation)) {
if (error)
*error = "overlaps existing furniture";
return false;
}
}
/* Overlap with earlier requests of the same transaction. */
for (const auto &otherReq : pending) {
if (otherReq.y != request.y || !otherReq.definition)
continue;
if (!request.placementId.empty() &&
otherReq.placementId == request.placementId)
continue;
if (def->overlaps(*otherReq.definition, request.x, request.z,
request.rotation, grid.cellSize,
otherReq.x, otherReq.z,
otherReq.rotation)) {
if (error)
*error = "overlaps a placement in the same group";
return false;
}
}
return true;
}
std::vector<FurniturePlacementRequest> FurniturePlacement::expandGroup(
const FurniturePlacementRequest &root,
const std::vector<FurnitureGroupChild> &children)
{
std::vector<FurniturePlacementRequest> out;
out.push_back(root);
for (const auto &child : children) {
FurniturePlacementRequest req;
req.definition = child.definition;
int offX = child.offsetX;
int offZ = child.offsetZ;
rotateOffset(root.rotation, offX, offZ);
req.x = root.x + offX;
req.y = root.y;
req.z = root.z + offZ;
req.rotation = ((root.rotation + child.rotationOffset) % 4 + 4) % 4;
req.owner = root.owner;
req.groupId = root.groupId;
req.tags = child.tags;
out.push_back(req);
}
return out;
}
bool FurniturePlacement::commit(CellGridComponent &grid,
const RoomComponent *room,
std::vector<FurniturePlacementRequest> &requests,
std::string *error,
const DefinitionResolver &resolver)
{
/* Validate each request against the grid and the earlier members
* of the same batch; any failure rolls the whole group back. */
std::vector<FurniturePlacementRequest> validated;
validated.reserve(requests.size());
for (auto &request : requests) {
std::string localError;
if (!canPlace(grid, room, request, validated,
error ? error : &localError, resolver))
return false;
validated.push_back(request);
}
const std::string &gridUid = grid.ensureGridUid();
for (auto &request : validated) {
/* Stable-id updates replace the previous cell (including a
* move to a different anchor) instead of duplicating it. */
if (!request.placementId.empty()) {
grid.furnitureCells.erase(
std::remove_if(grid.furnitureCells.begin(),
grid.furnitureCells.end(),
[&](const FurnitureCell &cell) {
return cell.placementId ==
request.placementId;
}),
grid.furnitureCells.end());
}
FurnitureCell &fcell = grid.getOrCreateFurnitureCell(
request.x, request.y, request.z);
fcell.furnitureType = request.definition->name;
fcell.rotation = request.rotation;
fcell.tags = request.tags;
fcell.owner = request.owner;
fcell.groupId = request.groupId;
fcell.placementId = request.placementId.empty() ?
gridUid + ":" + request.groupId + ":" +
request.definition->id + ":" +
std::to_string(request.x) + ":" +
std::to_string(request.y) + ":" +
std::to_string(request.z) :
request.placementId;
}
if (!validated.empty())
grid.markDirty();
return true;
}
size_t FurniturePlacement::removePlacement(
CellGridComponent &grid, const std::string &placementId)
{
const size_t before = grid.furnitureCells.size();
grid.furnitureCells.erase(
std::remove_if(grid.furnitureCells.begin(),
grid.furnitureCells.end(),
[&](const FurnitureCell &cell) {
return !placementId.empty() &&
cell.placementId == placementId;
}),
grid.furnitureCells.end());
const size_t removed = before - grid.furnitureCells.size();
if (removed > 0)
grid.markDirty();
return removed;
}
size_t FurniturePlacement::removeGroup(CellGridComponent &grid,
const std::string &groupId)
{
const size_t before = grid.furnitureCells.size();
grid.furnitureCells.erase(
std::remove_if(grid.furnitureCells.begin(),
grid.furnitureCells.end(),
[&](const FurnitureCell &cell) {
return !groupId.empty() &&
cell.groupId == groupId;
}),
grid.furnitureCells.end());
const size_t removed = before - grid.furnitureCells.size();
if (removed > 0)
grid.markDirty();
return removed;
}
@@ -0,0 +1,89 @@
#pragma once
#include <functional>
#include <string>
#include <vector>
#include "../components/CellGrid.hpp"
#include "FurnitureLibrary.hpp"
/**
* Shared furniture placement query/reservation helpers (phase 6.5.C).
*
* One placement request describes a single anchored placement. A batch of
* requests forms a group transaction: every request is validated against
* the grid, the optional room and the other pending requests, and either
* all are committed to CellGridComponent::furnitureCells or none are
* (atomic roll-back on the first failure). Children of a group are
* expressed with local cell offsets/rotations relative to the group's
* root placement (expandGroup), so relative arrangements such as a table
* with chairs move and rotate as one unit.
*
* The helpers are deliberately free of flecs/OGRE scene dependencies so
* the grid editor, RoomLayoutSystem, the DSL materialization and Lua can
* share them, and so furniture_test can cover them headlessly.
*/
struct FurniturePlacementRequest {
const FurnitureDefinition *definition = nullptr;
int x = 0;
int y = 0;
int z = 0; // anchor cell
int rotation = 0; // 0..3 quarter turns
std::string owner = "authored";
std::string groupId; // empty = independent placement
std::string placementId; // empty = generated by commit()
std::vector<std::string> tags;
};
/** One group child: local offset (in cells, rotated with the root) and a
* rotation offset added to the root rotation. */
struct FurnitureGroupChild {
const FurnitureDefinition *definition = nullptr;
int offsetX = 0;
int offsetZ = 0;
int rotationOffset = 0;
std::vector<std::string> tags;
};
namespace FurniturePlacement {
/** Resolves a committed FurnitureCell's furnitureType back to its
* definition. Defaults to FurnitureLibrary::findByName; tests can inject
* a local table. */
using DefinitionResolver =
std::function<const FurnitureDefinition *(const std::string &name)>;
/** Validate one request against the grid, the optional room containment
* (room may be nullptr), existing furniture and the earlier requests of
* the same transaction (pending). Pure query - nothing is mutated. */
bool canPlace(const CellGridComponent &grid, const RoomComponent *room,
const FurniturePlacementRequest &request,
const std::vector<FurniturePlacementRequest> &pending,
std::string *error = nullptr,
const DefinitionResolver &resolver = nullptr);
/** Expand a root placement plus relative children into absolute,
* flat requests sharing one groupId. Child offsets rotate with the
* root's quarter-turn rotation; child rotations add the root rotation. */
std::vector<FurniturePlacementRequest> expandGroup(
const FurniturePlacementRequest &root,
const std::vector<FurnitureGroupChild> &children);
/** Atomically commit a batch: validate every request against the grid
* and all earlier batch members, then append all of them to
* grid.furnitureCells. On the first validation failure the grid is
* left untouched and false is returned. placementId defaults to a
* deterministic gridUid-based id when left empty. */
bool commit(CellGridComponent &grid, const RoomComponent *room,
std::vector<FurniturePlacementRequest> &requests,
std::string *error = nullptr,
const DefinitionResolver &resolver = nullptr);
/** Remove every placement carrying groupId. Returns the count. */
size_t removeGroup(CellGridComponent &grid, const std::string &groupId);
/** Remove every placement carrying placementId. Returns the count. */
size_t removePlacement(CellGridComponent &grid,
const std::string &placementId);
} // namespace FurniturePlacement
@@ -105,6 +105,19 @@ void HairPhysicsSystem::prePhysicsUpdate()
if (!masterEnt)
return;
/* LOD tiers 2/3 show a baked impostor (hair included)
* instead of the part meshes — hair physics is off
* there (demo-taxi phase 6.C.3). */
if (m_slotSystem->getLodTier(e) >= 2) {
auto sit = m_states.find(e.id());
if (sit != m_states.end()) {
for (auto &pair : sit->second)
destroyRagdoll(pair.second);
m_states.erase(sit);
}
return;
}
const CharacterRegistry::CharacterRecord *rec =
CharacterRegistry::getSingleton().findCharacter(
ci.registryId);
@@ -11,7 +11,9 @@
#include "../recast/TileCacheNavMesh.hpp"
#include "CellGridSystem.hpp"
#include "DoorSystem.hpp"
#include "FurnitureLibrary.hpp"
#include <OgreLogManager.h>
#include <ProceduralBoxGenerator.h>
#include <algorithm>
#include <cctype>
@@ -83,7 +85,7 @@ static void collectEntitiesFromNode(Ogre::SceneNode *node,
static void collectEntitiesFromFlecsEntity(flecs::entity e,
std::vector<Ogre::Entity *> &out)
{
if (e.has<DoorComponent>())
if (e.has<DoorComponent>() || e.has<FurnitureRuntimeTag>())
return;
if (e.has<TransformComponent>()) {
auto &trans = e.get<TransformComponent>();
@@ -410,15 +412,25 @@ void NavMeshSystem::collectStaticEntities(std::vector<Ogre::Entity *> &out,
collectEntitiesFromFlecsEntity(e, out);
});
// 5. Frame and furniture placements from CellGridSystem
// 5. Normal-layer furniture contributes a generated bounds box. Raw
// furniture meshes/frames stay excluded: their vertical spans merge with
// floors in Recast, while the bounds box blocks only the occupied area.
if (m_cellGridSystem) {
FurnitureLibrary::getInstance().loadAll();
m_world.query<CellGridComponent>().each(
[&](flecs::entity e, CellGridComponent &grid) {
(void)grid;
// Frames and furniture are excluded from navmesh
// geometry because their vertical spans merge with
// floor spans in Recast and break walkability.
(void)m_cellGridSystem;
for (const auto &fcell : grid.furnitureCells) {
const FurnitureDefinition *def =
FurnitureLibrary::getInstance()
.findByName(fcell.furnitureType);
if (!def)
continue;
Ogre::Entity *navEntity =
createFurnitureNavEntity(e, grid, *def,
fcell);
if (navEntity)
addTemp(navEntity);
}
});
}
@@ -447,6 +459,103 @@ void NavMeshSystem::collectStaticEntities(std::vector<Ogre::Entity *> &out,
}
}
Ogre::Entity *NavMeshSystem::createFurnitureNavEntity(
flecs::entity gridEntity, const CellGridComponent &grid,
const FurnitureDefinition &definition, const FurnitureCell &fcell)
{
if (definition.layer != FurnitureDefinition::Layer::Normal)
return nullptr;
if (!gridEntity.has<TransformComponent>())
return nullptr;
const auto &gridTransform = gridEntity.get<TransformComponent>();
if (!gridTransform.node)
return nullptr;
float furnitureYOffset = 0.05f;
bool foundRoom = false;
gridEntity.children([&](flecs::entity child) {
if (foundRoom || !child.has<RoomComponent>())
return;
const auto &room = child.get<RoomComponent>();
if (room.contains(fcell.x, fcell.y, fcell.z)) {
furnitureYOffset = room.furnitureYOffset;
foundRoom = true;
}
});
Ogre::Vector3 base =
grid.cellToWorld(fcell.x, fcell.y, fcell.z);
base.y += CellGridSystem::floorSurfaceY() + furnitureYOffset;
Ogre::Quaternion localRotation(
Ogre::Degree(90.0f * fcell.rotation), Ogre::Vector3::UNIT_Y);
base -= localRotation * definition.anchor;
bool haveBounds = definition.hasCollisionBounds || definition.hasBounds;
Ogre::Vector3 boundsMin;
Ogre::Vector3 boundsMax;
if (definition.hasCollisionBounds) {
boundsMin = definition.collisionBoundsMin;
boundsMax = definition.collisionBoundsMax;
} else if (definition.hasBounds) {
boundsMin = definition.boundsMin;
boundsMax = definition.boundsMax;
}
if (definition.hasFootprintCells) {
boundsMin.x = 0.0f;
boundsMax.x = definition.footprintCellsX * grid.cellSize;
boundsMin.z = 0.0f;
boundsMax.z = definition.footprintCellsZ * grid.cellSize;
if (!haveBounds) {
boundsMin.y = 0.0f;
boundsMax.y = 1.0f;
haveBounds = true;
}
}
if (!haveBounds) {
boundsMin = Ogre::Vector3::ZERO;
boundsMax = Ogre::Vector3(grid.cellSize, 1.0f, grid.cellSize);
}
boundsMin.x -= definition.clearance;
boundsMax.x += definition.clearance;
boundsMin.z -= definition.clearance;
boundsMax.z += definition.clearance;
Ogre::Vector3 center = (boundsMin + boundsMax) * 0.5f;
Ogre::Vector3 size = boundsMax - boundsMin;
size.x = std::max(size.x, 0.02f);
size.y = std::max(size.y, 0.02f);
size.z = std::max(size.z, 0.02f);
const Ogre::Vector3 parentPosition =
gridTransform.node->_getDerivedPosition();
const Ogre::Quaternion parentRotation =
gridTransform.node->_getDerivedOrientation();
const Ogre::Vector3 parentScale =
gridTransform.node->_getDerivedScale();
Ogre::Vector3 localCenter = base + localRotation * center;
Ogre::Vector3 worldPosition =
parentPosition + parentRotation * (localCenter * parentScale);
Ogre::Quaternion worldRotation = parentRotation * localRotation;
Ogre::Vector3 worldScale(size.x * parentScale.x,
size.y * parentScale.y,
size.z * parentScale.z);
/* The mesh name must not contain "furniture" (or ceiling/frame) -
* isNavMeshGeometry() would filter the obstacle box out again. */
static const Ogre::String meshName = "NavObstacleBoundsBox";
static bool meshCreated = false;
if (!meshCreated) {
Procedural::BoxGenerator boxGen;
boxGen.setSizeX(1.0f).setSizeY(1.0f).setSizeZ(1.0f);
boxGen.realizeMesh(
meshName,
Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME);
meshCreated = true;
}
return createTempEntity(meshName, worldPosition, worldRotation,
worldScale);
}
Ogre::Entity *NavMeshSystem::createTempEntity(const Ogre::String &meshName,
const Ogre::Vector3 &pos,
const Ogre::Quaternion &rot,
@@ -11,6 +11,8 @@
// Forward declarations
class TileCacheNavMesh;
class CellGridSystem;
struct FurnitureDefinition;
struct FurnitureCell;
/**
* System that builds and maintains a DetourTileCache navmesh.
@@ -84,6 +86,10 @@ private:
const Ogre::Vector3 &pos,
const Ogre::Quaternion &rot,
const Ogre::Vector3 &scale);
Ogre::Entity *createFurnitureNavEntity(
flecs::entity gridEntity, const struct CellGridComponent &grid,
const FurnitureDefinition &definition,
const FurnitureCell &fcell);
Ogre::AxisAlignedBox getEntityBounds(flecs::entity e);
// F7: door volumes (closed-pose doorway bounds, render space)
@@ -90,7 +90,7 @@ public:
* position. */
float spawnDistanceSq = 80.0f * 80.0f;
/** Despawn distance (squared); larger than spawn -> hysteresis. */
float despawnDistanceSq = 120.0f * 120.0f;
float despawnDistanceSq = 100.0f * 100.0f;
/** Minimum real seconds between tier changes of one NPC. */
float minDwellSeconds = 3.0f;
/** Max spawns per update (spawn cost is a frame hitch). */
@@ -1,5 +1,7 @@
#include "RoomLayoutSystem.hpp"
#include "FurnitureLibrary.hpp"
#include "FurniturePlacement.hpp"
#include "../lua/LuaFurnitureApi.hpp"
#include "../components/CellGrid.hpp"
#include <OgreLogManager.h>
#include <climits>
@@ -323,15 +325,21 @@ void RoomLayoutSystem::processRoom(flecs::entity roomEntity, RoomComponent& room
int y = room.minY;
// Create floor and ceiling for the room area
for (int z = room.minZ; z < room.maxZ; z++) {
for (int x = room.minX; x < room.maxX; x++) {
Cell& cell = grid.getOrCreateCell(x, y, z);
if (room.createFloor)
cell.setFlag(CellFlags::Floor);
if (room.createCeiling)
cell.setFlag(CellFlags::Ceiling);
// Create floor and ceiling for the room area. When neither flag
// is requested the loop is skipped entirely: getOrCreateCell
// would create phantom zero-flag cells inside cut-out corners of
// L-shaped rooms (plan-derived rooms, phase 6.5.F, carry their
// geometry in the grid already and run with both flags off).
if (room.createFloor || room.createCeiling) {
for (int z = room.minZ; z < room.maxZ; z++) {
for (int x = room.minX; x < room.maxX; x++) {
Cell& cell = grid.getOrCreateCell(x, y, z);
if (room.createFloor)
cell.setFlag(CellFlags::Floor);
if (room.createCeiling)
cell.setFlag(CellFlags::Ceiling);
}
}
}
@@ -854,18 +862,215 @@ void RoomLayoutSystem::clearWallFlags(CellGridComponent& grid, int x, int y, int
cell->flags &= ~flagsToClear;
}
/* ------------------------------------------------------------------ */
/* Phase 6.5.F: declarative composition rules */
/* ------------------------------------------------------------------ */
namespace {
/* Resolve a composition anchor to an anchor cell + inward-facing
* rotation for the root (the same rotation convention as the tag
* walk's ROTATION_BY_SIDE: the item's front faces away from the
* wall). */
bool resolveCompositionAnchor(
const RoomComponent &room, const std::string &anchor, int &outX,
int &outZ, int &outRotation)
{
const int midX = (room.minX + room.maxX - 1) / 2;
const int midZ = (room.minZ + room.maxZ - 1) / 2;
if (anchor == "center") {
outX = midX;
outZ = midZ;
outRotation = 0;
return true;
}
if (anchor == "zneg") {
outX = midX;
outZ = room.minZ;
outRotation = 2;
return true;
}
if (anchor == "zpos") {
outX = midX;
outZ = room.maxZ - 1;
outRotation = 0;
return true;
}
if (anchor == "xneg") {
outX = room.minX;
outZ = midZ;
outRotation = 3;
return true;
}
if (anchor == "xpos") {
outX = room.maxX - 1;
outZ = midZ;
outRotation = 1;
return true;
}
return false;
}
} // namespace
void RoomLayoutSystem::evaluateCompositions(RoomComponent &room,
CellGridComponent &grid,
std::mt19937 &rng)
{
FurnitureLibrary &library = FurnitureLibrary::getInstance();
const std::string roomId = room.persistentId.empty() ?
"room:" + std::to_string(room.minX) + ":" +
std::to_string(room.minZ) :
room.persistentId;
int groupCounter = 0;
for (const LayoutLib::FurnitureCompositionRule &rule :
room.furnitureCompositions) {
int anchorX;
int anchorZ;
int anchorRotation;
if (!resolveCompositionAnchor(room, rule.anchor, anchorX,
anchorZ, anchorRotation)) {
Ogre::LogManager::getSingleton().logMessage(
"RoomLayoutSystem: room " + roomId +
": unknown composition anchor '" +
rule.anchor + "'");
continue;
}
if (rule.kind ==
LayoutLib::FurnitureCompositionRule::Kind::Group) {
/* One weighted draw for the root, then one per
* child; the whole group commits atomically. */
const FurnitureDefinition *rootDef =
library.selectByTagTerms(rule.tags, rng);
if (!rootDef) {
Ogre::LogManager::getSingleton().logMessage(
"RoomLayoutSystem: room " + roomId +
": no furniture matches the group "
"root tags");
continue;
}
FurniturePlacementRequest root;
root.definition = rootDef;
root.x = anchorX;
root.y = room.minY;
root.z = anchorZ;
root.rotation = rule.rootRotation >= 0 ?
rule.rootRotation :
anchorRotation;
root.owner = "generated";
root.groupId =
roomId + ":group-" + std::to_string(groupCounter++);
root.tags = rule.tags;
std::vector<FurnitureGroupChild> children;
bool missing = false;
for (const LayoutLib::FurnitureChildRule &childRule :
rule.children) {
const FurnitureDefinition *childDef =
library.selectByTagTerms(
childRule.tags, rng);
if (!childDef) {
Ogre::LogManager::getSingleton()
.logMessage(
"RoomLayoutSystem: "
"room " +
roomId +
": no furniture matches "
"a group child's tags");
missing = true;
break;
}
FurnitureGroupChild child;
child.definition = childDef;
child.offsetX = childRule.offsetX;
child.offsetZ = childRule.offsetZ;
child.rotationOffset =
childRule.rotationOffset;
child.tags = childRule.tags;
children.push_back(child);
}
if (missing)
continue;
std::vector<FurniturePlacementRequest> batch =
FurniturePlacement::expandGroup(root,
children);
std::string error;
if (!FurniturePlacement::commit(grid, &room, batch,
&error)) {
Ogre::LogManager::getSingleton().logMessage(
"RoomLayoutSystem: room " + roomId +
": group rolled back: " + error);
}
continue;
}
/* Row: walk the anchor edge from the lower coordinate
* end and place an item every `step` cells, skipping
* `skip` extra cells after each success; failures just
* skip the cell (rows are best-effort, not atomic). */
const bool walkX = rule.anchor == "zneg" ||
rule.anchor == "zpos";
const int lo = walkX ? room.minX : room.minZ;
const int hi = (walkX ? room.maxX : room.maxZ) - 1;
const int step = rule.step > 0 ? rule.step : 1;
int placed = 0;
for (int c = lo; c <= hi; c += step) {
if (rule.count > 0 && placed >= rule.count)
break;
FurniturePlacementRequest request;
request.definition =
library.selectByTagTerms(rule.tags, rng);
if (!request.definition)
break;
request.x = walkX ? c : anchorX;
request.y = room.minY;
request.z = walkX ? anchorZ : c;
request.rotation = rule.rootRotation >= 0 ?
rule.rootRotation :
anchorRotation;
request.owner = "generated";
request.tags = rule.tags;
static const std::vector<FurniturePlacementRequest>
noPending;
if (!FurniturePlacement::canPlace(grid, &room, request,
noPending))
continue;
std::vector<FurniturePlacementRequest> single = {
request
};
std::string error;
if (FurniturePlacement::commit(grid, &room, single,
&error)) {
++placed;
c += rule.skip;
}
}
}
}
void RoomLayoutSystem::generateRoomFurniture(RoomComponent& room,
CellGridComponent& grid)
{
if (room.tags.empty())
/* Exterior shells never emit furniture-generation work. */
if (grid.exteriorOnlyMode())
return;
const bool hasBefore = editScene::hasFurnitureRoomBeforeHandlers();
const bool hasAfter = editScene::hasFurnitureRoomAfterHandlers();
if (room.tags.empty() && room.furnitureCompositions.empty() &&
!hasBefore && !hasAfter)
return;
std::mt19937 rng(room.furnitureSeed);
// Clear existing furniture in room bounds
// Clear this room's generated placements. Authored and script-owned
// furniture stays put even when its bounds cross the room edge.
for (auto it = grid.furnitureCells.begin();
it != grid.furnitureCells.end();) {
if (it->x >= room.minX && it->x < room.maxX &&
if (it->owner == "generated" &&
it->x >= room.minX && it->x < room.maxX &&
it->y >= room.minY && it->y < room.maxY &&
it->z >= room.minZ && it->z < room.maxZ) {
it = grid.furnitureCells.erase(it);
@@ -874,6 +1079,63 @@ void RoomLayoutSystem::generateRoomFurniture(RoomComponent& room,
}
}
/* Ordered Lua room events (phase 6.5.H). Before handlers may add
* script-owned placements or suppress the default evaluator. Their
* edits form one transaction; on rejection the default evaluator is
* the defined fallback, so a bad script cannot strand a room empty. */
editScene::FurnitureRoomEvent before;
before.world = &m_world;
before.grid = &grid;
before.room = &room;
const bool beforeOk = editScene::notifyFurnitureRoomBefore(before);
bool suppressDefault = beforeOk ? before.suppressDefault : false;
if (beforeOk && !before.placements.empty()) {
std::string error;
if (!FurniturePlacement::commit(grid, &room,
before.placements, &error)) {
Ogre::LogManager::getSingleton().logMessage(
"RoomLayoutSystem: room " + room.persistentId +
": Lua placement transaction rejected: " + error +
"; using default furniture generation");
suppressDefault = false;
}
}
auto sendAfterEvent = [&]() {
if (!hasAfter)
return;
editScene::FurnitureRoomEvent after;
after.world = &m_world;
after.grid = &grid;
after.room = &room;
after.suppressDefault = suppressDefault;
for (const auto &fcell : grid.furnitureCells) {
if (fcell.x < room.minX || fcell.x >= room.maxX ||
fcell.y < room.minY || fcell.y >= room.maxY ||
fcell.z < room.minZ || fcell.z >= room.maxZ)
continue;
if (fcell.owner != "generated" && fcell.owner != "script")
continue;
const FurnitureDefinition *def =
FurnitureLibrary::getInstance().findByName(
fcell.furnitureType);
if (!def)
continue;
FurniturePlacementRequest request;
request.definition = def;
request.x = fcell.x;
request.y = fcell.y;
request.z = fcell.z;
request.rotation = fcell.rotation;
request.owner = fcell.owner;
request.groupId = fcell.groupId;
request.placementId = fcell.placementId;
request.tags = fcell.tags;
after.placements.push_back(request);
}
editScene::notifyFurnitureRoomAfter(after);
};
int midX = (room.minX + room.maxX - 1) / 2;
int midZ = (room.minZ + room.maxZ - 1) / 2;
@@ -898,28 +1160,21 @@ void RoomLayoutSystem::generateRoomFurniture(RoomComponent& room,
return false;
return (cell->flags & CellFlags::AllDoors) != 0;
};
auto cellHasAnyWindow = [&](int x, int y, int z) -> bool {
const Cell *cell = grid.findCell(x, y, z);
if (!cell)
return false;
return (cell->flags & CellFlags::AllWindows) != 0;
};
/* Placement validation is shared with the grid editor, DSL and Lua
* through the FurniturePlacement kernel (phase 6.5.C). */
auto canPlaceFurniture = [&](const FurnitureDefinition *def,
int x, int y, int z) -> bool {
if (!def)
return false;
if (cellHasAnyDoor(x, y, z)) {
if (std::find(def->tags.begin(), def->tags.end(),
"door") == def->tags.end())
return false;
}
if (cellHasAnyWindow(x, y, z)) {
if (std::find(def->tags.begin(), def->tags.end(),
"nowindow") != def->tags.end())
return false;
}
return true;
int x, int y, int z,
int rotation) -> bool {
FurniturePlacementRequest request;
request.definition = def;
request.x = x;
request.y = y;
request.z = z;
request.rotation = rotation;
static const std::vector<FurniturePlacementRequest> noPending;
return FurniturePlacement::canPlace(grid, &room, request,
noPending);
};
auto tryPlace = [&](int cx, int cy, int cz, int side,
@@ -940,7 +1195,8 @@ void RoomLayoutSystem::generateRoomFurniture(RoomComponent& room,
tags, rng, notags);
if (!def)
return nullptr;
if (!canPlaceFurniture(def, cx, cy, cz))
if (!canPlaceFurniture(def, cx, cy, cz,
ROTATION_BY_SIDE[side]))
return nullptr;
return def;
};
@@ -963,9 +1219,30 @@ void RoomLayoutSystem::generateRoomFurniture(RoomComponent& room,
fcell.furnitureType = def->name;
fcell.rotation = ROTATION_BY_SIDE[side];
fcell.tags = tags;
fcell.owner = "generated";
const std::string roomId = room.persistentId.empty() ?
"room:" + std::to_string(room.minX) + ":" +
std::to_string(room.minZ) :
room.persistentId;
fcell.placementId = grid.ensureGridUid() + ":" + roomId +
":" + def->id + ":" + std::to_string(cx) + ":" +
std::to_string(cy) + ":" + std::to_string(cz);
return true;
};
if (suppressDefault) {
sendAfterEvent();
grid.markDirty();
return;
}
/* Phase 6.5.F: declarative composition rules run first — they own
* their space (groups commit atomically through the placement
* kernel), and the tag-priority walk below fills around them via
* the overlap checks. */
if (!room.furnitureCompositions.empty())
evaluateCompositions(room, grid, rng);
std::vector<std::string> baseTags = room.tags;
std::vector<std::string> etags = baseTags;
std::vector<std::string> itags = baseTags;
@@ -1138,5 +1415,6 @@ void RoomLayoutSystem::generateRoomFurniture(RoomComponent& room,
}
}
sendAfterEvent();
grid.markDirty();
}
@@ -2,6 +2,7 @@
#include <flecs.h>
#include <Ogre.h>
#include <random>
/**
@@ -66,6 +67,13 @@ private:
// Generate furniture for a room based on its tags
void generateRoomFurniture(class RoomComponent& room, class CellGridComponent& grid);
// Phase 6.5.F: evaluate a room's declarative composition rules
// (groups/rows) through the shared FurniturePlacement kernel,
// before the tag-priority walk.
void evaluateCompositions(class RoomComponent& room,
class CellGridComponent& grid,
std::mt19937& rng);
// Helper: Get the parent CellGridComponent for an entity
class CellGridComponent* findParentGrid(flecs::entity entity);
@@ -2901,6 +2901,12 @@ nlohmann::json SceneSerializer::serializeCellGrid(flecs::entity entity)
furnJson["furnitureType"] = furn.furnitureType;
furnJson["rotation"] = furn.rotation;
furnJson["tags"] = furn.tags;
if (!furn.placementId.empty())
furnJson["placementId"] = furn.placementId;
if (furn.owner != "authored")
furnJson["owner"] = furn.owner;
if (!furn.groupId.empty())
furnJson["groupId"] = furn.groupId;
furnitureJson.push_back(furnJson);
}
json["furnitureCells"] = furnitureJson;
@@ -3001,6 +3007,42 @@ nlohmann::json SceneSerializer::serializeRoom(flecs::entity entity)
json["furnitureSeed"] = room.furnitureSeed;
json["furnitureYOffset"] = room.furnitureYOffset;
/* Phase 6.5.F: declarative composition rules (only serialized
* when present — the common case is an empty list). */
if (!room.furnitureCompositions.empty()) {
nlohmann::json rules = nlohmann::json::array();
for (const auto &rule : room.furnitureCompositions) {
nlohmann::json rj;
rj["kind"] = rule.kind ==
LayoutLib::FurnitureCompositionRule::
Kind::Group ?
"group" :
"row";
rj["tags"] = rule.tags;
rj["anchor"] = rule.anchor;
rj["rootRotation"] = rule.rootRotation;
rj["step"] = rule.step;
rj["skip"] = rule.skip;
rj["count"] = rule.count;
if (!rule.children.empty()) {
nlohmann::json children =
nlohmann::json::array();
for (const auto &child : rule.children) {
nlohmann::json cj;
cj["tags"] = child.tags;
cj["offsetX"] = child.offsetX;
cj["offsetZ"] = child.offsetZ;
cj["rotationOffset"] =
child.rotationOffset;
children.push_back(std::move(cj));
}
rj["children"] = std::move(children);
}
rules.push_back(std::move(rj));
}
json["furnitureCompositions"] = std::move(rules);
}
// Serialize exits (bool array for Z-, Z+, X-, X+)
nlohmann::json exits = nlohmann::json::array();
for (int i = 0; i < 4; i++) {
@@ -3154,6 +3196,9 @@ void SceneSerializer::deserializeCellGrid(flecs::entity entity,
furn.furnitureType =
furnJson.value("furnitureType", "");
furn.rotation = furnJson.value("rotation", 0);
furn.placementId = furnJson.value("placementId", "");
furn.owner = furnJson.value("owner", "authored");
furn.groupId = furnJson.value("groupId", "");
if (furnJson.contains("tags") &&
furnJson["tags"].is_array()) {
for (const auto &tag : furnJson["tags"]) {
@@ -3384,6 +3429,51 @@ void SceneSerializer::deserializeRoom(flecs::entity entity,
room.furnitureSeed = json.value("furnitureSeed", 42u);
room.furnitureYOffset = json.value("furnitureYOffset", 0.05f);
/* Phase 6.5.F: declarative composition rules (absent = the tag
* walk furnishes the room). */
if (json.contains("furnitureCompositions") &&
json["furnitureCompositions"].is_array()) {
for (const auto &rj : json["furnitureCompositions"]) {
LayoutLib::FurnitureCompositionRule rule;
rule.kind =
rj.value("kind", "") == std::string("row") ?
LayoutLib::FurnitureCompositionRule::
Kind::Row :
LayoutLib::FurnitureCompositionRule::
Kind::Group;
for (const auto &tag : rj.value(
"tags", nlohmann::json::array()))
if (tag.is_string())
rule.tags.push_back(tag);
rule.anchor = rj.value("anchor", "center");
rule.rootRotation = rj.value("rootRotation", -1);
rule.step = rj.value("step", 1);
rule.skip = rj.value("skip", 0);
rule.count = rj.value("count", 0);
if (rj.contains("children") &&
rj["children"].is_array()) {
for (const auto &cj : rj["children"]) {
LayoutLib::FurnitureChildRule child;
for (const auto &tag : cj.value(
"tags",
nlohmann::json::array()))
if (tag.is_string())
child.tags.push_back(
tag);
child.offsetX =
cj.value("offsetX", 0);
child.offsetZ =
cj.value("offsetZ", 0);
child.rotationOffset =
cj.value("rotationOffset", 0);
rule.children.push_back(
std::move(child));
}
}
room.furnitureCompositions.push_back(std::move(rule));
}
}
// Load exits (bool array for Z-, Z+, X-, X+)
if (json.contains("exits") && json["exits"].is_array()) {
int i = 0;
@@ -17,6 +17,9 @@
#include "../components/Renderable.hpp"
#include "../components/NavMesh.hpp"
#include "../components/Door.hpp"
#include "../components/CellGrid.hpp"
#include "../components/Actuator.hpp"
#include "../components/SmartObject.hpp"
#include "../components/RigidBody.hpp"
#include "../components/Lod.hpp"
#include "../components/PhysicsCollider.hpp"
@@ -26,6 +29,12 @@
#include "../systems/WorldMapData.hpp"
#include "../systems/NavMeshSystem.hpp"
#include "../systems/DoorSystem.hpp"
#include "../systems/CellGridSystem.hpp"
#include "../systems/TownGenSystem.hpp"
#include "../systems/RoomLayoutSystem.hpp"
#include "../systems/FurnitureLibrary.hpp"
#include "../systems/PhysicsSystem.hpp"
#include "../layoutlib/LayoutEval.hpp"
#include "../systems/GlobalStateStore.hpp"
#include "../recast/TileCacheNavMesh.hpp"
#include "../ui/NavigationPanel.hpp"
@@ -45,6 +54,7 @@
#include <OgreTexture.h>
#include <iostream>
#include <fstream>
#include <algorithm>
#include <iterator>
#include <cmath>
#include <cfloat>
@@ -6601,6 +6611,805 @@ bool TerrainTestRunner::testNavMeshDoors(EditorApp &app, TerrainSystem *ts)
return true;
}
/* ------------------------------------------------------------------ */
/* testNavMeshFurniture (phase 6.5.D: normal furniture bounds) */
/* ------------------------------------------------------------------ */
bool TerrainTestRunner::testNavMeshFurniture(EditorApp &app,
TerrainSystem *ts)
{
(void)ts;
NavMeshSystem *navSys = app.getNavMeshSystem();
if (!navSys) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - no NavMeshSystem in app");
return false;
}
flecs::world *w = app.getWorld();
Ogre::SceneNode *floorNode;
Ogre::Entity *floorEnt = navTestBox(
app, "NavTestFurnFloor", Ogre::Vector3(12, 0.2f, 12),
Ogre::Vector3(0, -0.1f, 0), &floorNode);
flecs::entity floorE = navTestStaticEntity(w, floorEnt, floorNode);
/* A CellGrid entity carries the furniture placement; its own visual
* mesh is irrelevant here, because NavMeshSystem must use the
* definition's generated bounds box rather than rasterizing raw
* furniture geometry. */
Ogre::SceneNode *gridNode =
app.getSceneManager()->getRootSceneNode()->createChildSceneNode(
"NavTestFurnitureGridNode");
flecs::entity gridE = w->entity();
gridE.set<TransformComponent>(
{ gridNode, Ogre::Vector3::ZERO, Ogre::Quaternion::IDENTITY,
Ogre::Vector3::UNIT_SCALE });
CellGridComponent grid;
grid.cellSize = 2.0f;
grid.cellHeight = 3.0f;
gridE.set<CellGridComponent>(grid);
flecs::entity navE = w->entity();
{
NavMeshComponent nm;
nm.cellSize = 0.3f;
nm.cellHeight = 0.2f;
nm.agentHeight = 2.0f;
nm.agentRadius = 0.4f;
nm.agentMaxClimb = 0.5f;
nm.agentMaxSlope = 30.0f;
nm.tileSize = 48;
navE.set<NavMeshComponent>(nm);
}
const Ogre::Vector3 start(-4.5f, 0.5f, 0.0f);
const Ogre::Vector3 target(4.5f, 0.5f, 0.0f);
std::vector<Ogre::Vector3> path;
auto pathLength = [](const std::vector<Ogre::Vector3> &path) {
float length = 0.0f;
for (size_t i = 1; i < path.size(); ++i)
length += (path[i] - path[i - 1]).length();
return length;
};
auto maxAbsZ = [](const std::vector<Ogre::Vector3> &path) {
float result = 0.0f;
for (const auto &point : path)
result = std::max(result, std::fabs(point.z));
return result;
};
bool ok = true;
navSys->rebuild(navE);
if (!navSys->findPath(navE, start, target, path)) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - no baseline path in furniture test");
ok = false;
}
const float clearLength = pathLength(path);
const float clearMaxZ = maxAbsZ(path);
FurnitureCell bed;
bed.furnitureType = "bed1";
bed.owner = "authored";
grid.furnitureCells.push_back(bed);
grid.markDirty();
gridE.set<CellGridComponent>(grid);
navSys->rebuild(navE);
if (ok && (!navSys->findPath(navE, start, target, path) ||
path.empty())) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - furniture bounds made the room "
"completely unreachable");
ok = false;
}
if (ok) {
const float blockedLength = pathLength(path);
const float blockedMaxZ = maxAbsZ(path);
if (blockedLength < clearLength + 0.3f &&
blockedMaxZ < clearMaxZ + 0.3f) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - normal furniture did not "
"deflect the path (length " +
Ogre::StringConverter::toString(clearLength) +
" -> " +
Ogre::StringConverter::toString(blockedLength) +
", maxZ " +
Ogre::StringConverter::toString(clearMaxZ) +
" -> " +
Ogre::StringConverter::toString(blockedMaxZ) +
")");
ok = false;
}
}
grid.furnitureCells.clear();
grid.markDirty();
gridE.set<CellGridComponent>(grid);
navSys->rebuild(navE);
if (ok && (!navSys->findPath(navE, start, target, path) ||
pathLength(path) > clearLength + 0.1f)) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - furniture nav obstacle survived "
"removal");
ok = false;
}
/* Thin-floor furniture renders but contributes no navmesh
* obstruction: the same bed-anchor cell is now a carpet and the
* clear path must be unchanged. */
FurnitureCell carpet;
carpet.furnitureType = "carpet1";
grid.furnitureCells.push_back(carpet);
grid.markDirty();
gridE.set<CellGridComponent>(grid);
navSys->rebuild(navE);
if (ok && (!navSys->findPath(navE, start, target, path) ||
pathLength(path) > clearLength + 0.1f)) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - thin-floor carpet blocked the "
"navmesh");
ok = false;
}
if (gridE.is_alive()) {
if (gridE.has<TransformComponent>()) {
Ogre::SceneNode *node =
gridE.get<TransformComponent>().node;
if (node)
app.getSceneManager()->destroySceneNode(node);
}
gridE.destruct();
}
if (navE.is_alive())
navE.destruct();
navTestDestroyEntity(app, floorE);
if (!ok)
return false;
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: navmesh furniture test passed");
return true;
}
/* ------------------------------------------------------------------ */
/* testTownPlanRooms (phase 6.5.F: DSL room -> RoomComponent -> */
/* furniture materialization) */
/* ------------------------------------------------------------------ */
bool TerrainTestRunner::testTownPlanRooms(EditorApp &app, TerrainSystem *ts)
{
(void)ts;
RoomLayoutSystem *roomSys = app.getRoomLayoutSystem();
EditorPhysicsSystem *physSys = app.getEditorPhysicsSystem();
if (!roomSys || !physSys) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - no RoomLayoutSystem/physics in app");
return false;
}
flecs::world *w = app.getWorld();
TownGenSystem tgs(*w, app.getSceneManager(),
physSys->getPhysicsWrapper());
/* A rooms-first DSL layout with two furnished rooms and a
* connecting door, built in "full" mode so the interior is
* generated. 16x12 m at 2 m cells = 8x6 cells; the bedroom
* covers the west half, the living room the east half. */
const char *source =
"(shell 16 12 :empty)"
"(floor 0"
" (room bed (rect 0 0 0.5 1) :type bedroom"
" :furniture (bedroom))"
" (room liv (rect 0.5 0 1 1) :type livingroom"
" :furniture (livingroom)))"
"(connect bed liv)";
bool ok = true;
std::string error;
if (!tgs.previewLayout(source, nullptr, 0 /*village*/, 42,
Ogre::Vector3(0, 0, 0), "full", &error)) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - plan-room preview failed: " +
error);
return false;
}
/* Find the preview building (child of the "Layout Preview"
* container carrying the CellGridComponent). */
flecs::entity building;
w->each([&](flecs::entity e, CellGridComponent &) {
if (!building.is_alive())
building = e;
});
if (!building.is_alive()) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - preview created no building");
tgs.clearLayoutPreview();
return false;
}
/* The plan's two rooms must have materialized as RoomComponent
* children with the DSL metadata carried over. */
int roomCount = 0;
bool sawBedroom = false;
bool sawLiving = false;
building.children([&](flecs::entity child) {
if (!child.has<RoomComponent>())
return;
const auto &room = child.get<RoomComponent>();
++roomCount;
if (room.roomType == "bedroom") {
sawBedroom = true;
if (room.tags != std::vector<std::string>{ "bedroom" } ||
!room.fillRoomWithFurniture || room.minX != 0 ||
room.maxX != 4 || room.minZ != 0 ||
room.maxZ != 6 || room.createFloor ||
room.createInteriorWalls) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - bedroom metadata "
"wrong (tags " +
std::to_string(room.tags.size()) +
", bounds " + std::to_string(room.minX) +
".." + std::to_string(room.maxX) + ")");
ok = false;
}
/* Deterministic seed from address + room name. */
if (room.furnitureSeed == 42) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - room kept the "
"default seed (no per-room isolation)");
ok = false;
}
if (room.persistentId.empty()) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - plan room has no "
"persistent id");
ok = false;
}
}
if (room.roomType == "livingroom")
sawLiving = true;
});
if (roomCount != 2 || !sawBedroom || !sawLiving) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - expected 2 materialized rooms, "
"got " +
std::to_string(roomCount));
ok = false;
}
/* Run the shared room furniture generation (the system is only
* pumped per-frame in the app; the test drives it directly). */
roomSys->update();
const auto &grid = building.get<CellGridComponent>();
size_t generatedCount = 0;
size_t withPlacementId = 0;
for (const auto &fc : grid.furnitureCells) {
if (fc.owner != "generated")
continue;
++generatedCount;
if (!fc.placementId.empty() &&
fc.placementId.find("townroom-") != std::string::npos)
++withPlacementId;
}
if (generatedCount == 0) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - plan rooms produced no "
"furniture");
ok = false;
} else if (withPlacementId != generatedCount) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - generated placements lack "
"stable ids (" +
std::to_string(withPlacementId) + "/" +
std::to_string(generatedCount) + ")");
ok = false;
}
/* Every generated placement must reference a known definition
* and sit inside its room's floor cells. */
for (const auto &fc : grid.furnitureCells) {
if (fc.owner != "generated")
continue;
const FurnitureDefinition *def =
FurnitureLibrary::getInstance().findByName(
fc.furnitureType);
if (!def) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - unknown furniture '" +
fc.furnitureType + "'");
ok = false;
continue;
}
for (const auto &cellPos :
def->occupiedCells(fc.x, fc.z, fc.rotation,
grid.cellSize)) {
const Cell *cell =
grid.findCell(cellPos.first, fc.y,
cellPos.second);
if (!cell || !(cell->flags & CellFlags::Floor)) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - furniture '" +
fc.furnitureType +
"' occupies a floorless cell");
ok = false;
break;
}
}
}
tgs.clearLayoutPreview();
if (!ok)
return false;
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: town plan rooms test passed (" +
std::to_string(generatedCount) + " placements)");
return true;
}
/* ------------------------------------------------------------------ */
/* testTownCompositions (phase 6.5.F: DSL groups/rows) */
/* ------------------------------------------------------------------ */
bool TerrainTestRunner::testTownCompositions(EditorApp &app,
TerrainSystem *ts)
{
(void)ts;
RoomLayoutSystem *roomSys = app.getRoomLayoutSystem();
EditorPhysicsSystem *physSys = app.getEditorPhysicsSystem();
if (!roomSys || !physSys) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - no RoomLayoutSystem/physics in app");
return false;
}
flecs::world *w = app.getWorld();
TownGenSystem tgs(*w, app.getSceneManager(),
physSys->getPhysicsWrapper());
/* 16x16 m = 8x8 cells. The table roots at cell (3,3); four
* chairs sit at diagonal offsets. Two dressers form a row on
* the zpos edge. Root and row tags have unique matches; the
* child tag may select chairs1 or sofa2, both of which fit and
* stay deterministic across regeneration. */
const char *source =
"(shell 16 16 :empty)"
"(floor 0 (room dining (rect 0 0 1 1)"
" :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))))";
std::string error;
if (!tgs.previewLayout(source, nullptr, 0, 7,
Ogre::Vector3(0, 0, 0), "full", &error)) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - composition preview failed: " +
error);
return false;
}
flecs::entity building;
w->each([&](flecs::entity e, CellGridComponent &) {
if (!building.is_alive())
building = e;
});
bool ok = true;
if (!building.is_alive()) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - composition preview created no "
"building");
tgs.clearLayoutPreview();
return false;
}
int ruleCount = 0;
building.children([&](flecs::entity child) {
if (child.has<RoomComponent>())
ruleCount = (int)child.get<RoomComponent>()
.furnitureCompositions.size();
});
if (ruleCount != 2) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - :compose rules did not reach "
"the RoomComponent");
ok = false;
}
roomSys->update();
auto snapshot = [&]() {
std::vector<std::string> out;
const auto &grid = building.get<CellGridComponent>();
for (const auto &fc : grid.furnitureCells) {
if (fc.owner != "generated")
continue;
out.push_back(fc.furnitureType + "@" +
std::to_string(fc.x) + ":" +
std::to_string(fc.z) + ":r" +
std::to_string(fc.rotation) + ":g" +
fc.groupId + ":p" + fc.placementId);
}
std::sort(out.begin(), out.end());
return out;
};
auto first = snapshot();
int groupMembers = 0;
int rowMembers = 0;
bool rootOk = false;
for (const auto &fc : building.get<CellGridComponent>().furnitureCells) {
if (fc.owner != "generated")
continue;
if (!fc.groupId.empty()) {
++groupMembers;
if (fc.furnitureType == "dinner-table1" &&
fc.x == 3 && fc.z == 3 && fc.rotation == 0)
rootOk = true;
} else if (fc.furnitureType == "dresser2" && fc.z == 7) {
++rowMembers;
}
}
if (first.size() != 7 || groupMembers != 5 || rowMembers != 2 ||
!rootOk) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - composition placements: total " +
std::to_string(first.size()) + " group " +
std::to_string(groupMembers) + " row " +
std::to_string(rowMembers) + " root " +
std::to_string((int)rootOk));
ok = false;
}
/* Force a full room reprocess: generated placements are cleared
* and rebuilt from the same isolated seed, bit-identically. */
building.children([&](flecs::entity child) {
if (child.has<RoomComponent>())
child.get_mut<RoomComponent>().markDirty();
});
roomSys->update();
if (snapshot() != first) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - composition regeneration was "
"not deterministic");
ok = false;
}
tgs.clearLayoutPreview();
if (!ok)
return false;
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: town compositions test passed");
return true;
}
/* ------------------------------------------------------------------ */
/* testFurniturePoiQuery (phase 6.5.G: stable POI identity/queries) */
/* ------------------------------------------------------------------ */
bool TerrainTestRunner::testFurniturePoiQuery(EditorApp &app,
TerrainSystem *ts)
{
(void)ts;
CellGridSystem *cellSys = app.getCellGridSystem();
if (!cellSys) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - no CellGridSystem in app");
return false;
}
flecs::world *w = app.getWorld();
flecs::entity gridE = w->entity();
Ogre::SceneNode *gridNode =
app.getSceneManager()->getRootSceneNode()->createChildSceneNode(
"FurniturePoiTestGrid");
gridE.set<TransformComponent>(
{ gridNode, Ogre::Vector3::ZERO, Ogre::Quaternion::IDENTITY,
Ogre::Vector3::UNIT_SCALE });
CellGridComponent grid;
grid.gridUid = "poi-test-grid";
grid.cellSize = 2.0f;
grid.cellHeight = 3.0f;
for (int z = 0; z < 4; ++z)
for (int x = 0; x < 4; ++x) {
Cell &cell = grid.getOrCreateCell(x, 0, z);
cell.flags = CellFlags::Floor | CellFlags::Ceiling;
}
FurnitureCell table;
table.x = 2;
table.z = 2;
table.rotation = 1;
table.furnitureType = "table-chair1";
table.placementId = "table-main";
grid.furnitureCells.push_back(table);
FurnitureCell prefabChair;
prefabChair.x = 0;
prefabChair.z = 0;
prefabChair.furnitureType = "interactive-chair";
prefabChair.placementId = "prefab-chair";
grid.furnitureCells.push_back(prefabChair);
gridE.set<CellGridComponent>(grid);
flecs::entity roomE = w->entity();
roomE.child_of(gridE);
RoomComponent room;
room.maxX = 4;
room.maxZ = 4;
room.persistentId = "dining";
room.createFloor = false;
room.createCeiling = false;
room.createInteriorWalls = false;
roomE.set<RoomComponent>(room);
cellSys->update();
bool ok = true;
auto fail = [&](const std::string &message) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - " + message);
ok = false;
};
auto near = [](float a, float b) {
return std::fabs(a - b) < 0.001f;
};
CellGridSystem::FurniturePoiQuery byAction;
byAction.actionName = "sit";
auto sitPois = cellSys->queryFurniturePois(byAction);
/* table-chair1 exports the sit action plus nested enter/exit
* approach markers; the prefab example contributes one authored
* sit actuator without duplicating it. */
if (sitPois.size() != 4)
fail("expected mesh + prefab interaction POIs, got " +
std::to_string(sitPois.size()));
CellGridSystem::FurniturePoiInfo prefabPoi;
if (!cellSys->findFurniturePoi("furniture.prefab-chair.sit",
prefabPoi)) {
fail("prefab-authored sit POI was not registered");
} else {
if (!prefabPoi.entity.is_alive() ||
!prefabPoi.entity.has<ActuatorComponent>() ||
!prefabPoi.entity.has<SmartObjectComponent>())
fail("prefab POI lost its authored components");
if (!near(prefabPoi.position.x, 0.2f) ||
!near(prefabPoi.position.y, 0.35f) ||
!near(prefabPoi.position.z, 0.3f))
fail("prefab POI grid-local transform mismatch (" +
Ogre::StringConverter::toString(
prefabPoi.position) +
")");
}
CellGridSystem::FurniturePoiInfo action;
if (!cellSys->findFurniturePoi("furniture.table-main.sit", action)) {
fail("stable sit POI id was not found");
} else if (!action.entity.is_alive() ||
!action.entity.has<ActuatorComponent>() ||
!action.entity.has<SmartObjectComponent>()) {
fail("action POI lacks actuator/smart-object components");
} else if (action.roomId != "dining" ||
action.furnitureType != "table-chair1") {
fail("action POI metadata mismatch");
}
CellGridSystem::FurniturePoiInfo enter;
if (!cellSys->findFurniturePoi("furniture.table-main.enter",
enter)) {
fail("nested enter position marker was not materialized");
} else {
if (enter.entity.has<ActuatorComponent>())
fail("position marker must not be an actuator");
/* base (4,0.25,4), rotation 90 degrees; enter local
* (0,-0.0891,-0.0528) -> (3.9472,0.1609,4). */
if (!near(enter.position.x, 3.9472f) ||
!near(enter.position.y, 0.1609f) ||
!near(enter.position.z, 4.0f)) {
fail("enter position transform mismatch (" +
Ogre::StringConverter::toString(enter.position) +
")");
}
}
CellGridSystem::FurniturePoiQuery byRoom;
byRoom.roomId = "dining";
if (cellSys->queryFurniturePois(byRoom).size() != 4)
fail("room query did not return all markers");
byRoom.roomId = "other-room";
if (!cellSys->queryFurniturePois(byRoom).empty())
fail("wrong room returned POIs");
CellGridSystem::FurniturePoiQuery byTag;
byTag.furnitureTag = "office";
if (cellSys->queryFurniturePois(byTag).size() != 3)
fail("furniture tag query did not return all markers");
byTag.furnitureTag = "bedroom";
if (!cellSys->queryFurniturePois(byTag).empty())
fail("wrong furniture tag returned POIs");
byTag.furnitureTag = "example";
if (cellSys->queryFurniturePois(byTag).size() != 1)
fail("prefab tag query did not return exactly one POI");
CellGridSystem::FurniturePoiQuery byGrid;
byGrid.gridEntity = gridE;
if (cellSys->queryFurniturePois(byGrid).size() != 4)
fail("grid query did not return all markers");
/* Rebuild: no duplicates, and ids/transforms stay stable. */
gridE.get_mut<CellGridComponent>().markDirty();
cellSys->update();
if (cellSys->queryFurniturePois(byGrid).size() != 4)
fail("grid rebuild duplicated or dropped POIs");
CellGridSystem::FurniturePoiInfo rebuilt;
if (!cellSys->findFurniturePoi("furniture.table-main.enter",
rebuilt)) {
fail("enter POI lost its stable id on rebuild");
} else if (rebuilt.entity == enter.entity) {
/* The runtime entity may be recreated; identity must come
* from the id, not the transient handle. */
if (rebuilt.id != enter.id)
fail("rebuild changed the POI id");
}
if (ok && rebuilt.entity.is_alive() &&
(rebuilt.position - enter.position).length() > 0.001f)
fail("rebuild moved the enter POI");
if (!cellSys->findFurniturePoi("furniture.prefab-chair.sit",
prefabPoi))
fail("prefab POI lost its stable id on rebuild");
if (roomE.is_alive())
roomE.destruct();
if (gridE.is_alive())
gridE.destruct();
cellSys->update();
if (!ok)
return false;
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: furniture POI query test passed");
return true;
}
/* ------------------------------------------------------------------ */
/* testFurnitureLayers (phase 6.5.B/D: layers + colliders) */
/* ------------------------------------------------------------------ */
bool TerrainTestRunner::testFurnitureLayers(EditorApp &app,
TerrainSystem *ts)
{
(void)ts;
CellGridSystem *cellSys = app.getCellGridSystem();
if (!cellSys) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - no CellGridSystem in app");
return false;
}
flecs::world *w = app.getWorld();
flecs::entity gridE = w->entity();
Ogre::SceneNode *gridNode =
app.getSceneManager()->getRootSceneNode()->createChildSceneNode(
"FurnitureLayerTestGrid");
gridE.set<TransformComponent>(
{ gridNode, Ogre::Vector3::ZERO, Ogre::Quaternion::IDENTITY,
Ogre::Vector3::UNIT_SCALE });
CellGridComponent grid;
grid.gridUid = "layer-test-grid";
grid.cellSize = 2.0f;
grid.cellHeight = 3.0f;
for (int z = 0; z < 4; ++z)
for (int x = 0; x < 4; ++x) {
Cell &cell = grid.getOrCreateCell(x, 0, z);
cell.flags = CellFlags::Floor | CellFlags::Ceiling;
if (x == 0)
cell.flags |= CellFlags::IntWallXNeg;
}
auto addFurniture = [&](int x, int z, int rotation,
const std::string &type,
const std::string &placementId) {
FurnitureCell cell;
cell.x = x;
cell.z = z;
cell.rotation = rotation;
cell.furnitureType = type;
cell.placementId = placementId;
grid.furnitureCells.push_back(cell);
};
addFurniture(1, 1, 0, "table-chair1", "table");
addFurniture(2, 2, 0, "shop-shelf1", "shelf");
addFurniture(0, 0, 0, "carpet1", "carpet");
addFurniture(0, 1, 3, "switch1", "switch");
addFurniture(3, 3, 0, "ceiling-light1", "ceiling");
gridE.set<CellGridComponent>(grid);
cellSys->update();
bool ok = true;
auto fail = [&](const std::string &message) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - " + message);
ok = false;
};
auto near = [](float a, float b) {
return std::fabs(a - b) < 0.002f;
};
auto checkPosition = [&](const std::string &id,
const Ogre::Vector3 &expected) {
CellGridSystem::FurniturePoiInfo poi;
if (!cellSys->findFurniturePoi(id, poi)) {
fail("missing attachment POI " + id);
return;
}
if ((poi.position - expected).length() > 0.002f)
fail(id + " position mismatch: got " +
Ogre::StringConverter::toString(poi.position) +
" expected " +
Ogre::StringConverter::toString(expected));
};
checkPosition("furniture.carpet.attach",
Ogre::Vector3(0.0f, 0.25f, 0.0f));
checkPosition("furniture.switch.attach",
Ogre::Vector3(-0.9f, 0.25f, 2.0f));
checkPosition("furniture.ceiling.attach",
Ogre::Vector3(6.0f, 3.0f, 6.0f));
/* Only the two normal-layer items get colliders. Both are
* generated bounds boxes (shelf uses its authored collision
* proxy); no raw furniture mesh or thin-layer collider remains. */
int boxCount = 0;
bool tableBox = false;
bool shelfBox = false;
w->query<TransformComponent, PhysicsColliderComponent>().each(
[&](flecs::entity, TransformComponent &transform,
PhysicsColliderComponent &collider) {
if (collider.meshName.find("furniture-") !=
std::string::npos)
fail("raw furniture mesh collider found: " +
collider.meshName);
if (collider.shapeType !=
PhysicsColliderComponent::ShapeType::Box)
return;
++boxCount;
const auto &p = transform.position;
const auto &h = collider.parameters;
if (near(p.x, 2.0f) && near(p.y, 0.69025f) &&
near(p.z, 2.1698f) && near(h.x, 0.8584f) &&
near(h.y, 0.44195f) && near(h.z, 0.6947f))
tableBox = true;
if (near(p.x, 4.0f) && near(p.y, 1.0f) &&
near(p.z, 4.0f) && near(h.x, 0.75f) &&
near(h.y, 0.75f) && near(h.z, 0.2f))
shelfBox = true;
});
if (boxCount != 2 || !tableBox || !shelfBox)
fail("normal furniture collider set mismatch: boxes " +
std::to_string(boxCount) + " table " +
std::to_string((int)tableBox) + " shelf " +
std::to_string((int)shelfBox));
/* A rebuild must not duplicate colliders or POIs. */
gridE.get_mut<CellGridComponent>().markDirty();
cellSys->update();
if (cellSys->queryFurniturePois().size() != 6)
fail("layer rebuild duplicated or dropped POIs");
if (gridE.is_alive())
gridE.destruct();
cellSys->update();
if (!ok)
return false;
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: furniture layers test passed");
return true;
}
/* ------------------------------------------------------------------ */
/* Logging */
/* ------------------------------------------------------------------ */
@@ -6711,6 +7520,11 @@ int TerrainTestRunner::run(EditorApp &app, int iterations)
{ "roadRegionStreaming", testRoadRegionStreaming },
{ "roadRegionMigration", testRoadRegionMigration },
{ "navMeshDoors", testNavMeshDoors },
{ "navMeshFurniture", testNavMeshFurniture },
{ "townPlanRooms", testTownPlanRooms },
{ "townCompositions", testTownCompositions },
{ "furniturePoiQuery", testFurniturePoiQuery },
{ "furnitureLayers", testFurnitureLayers },
};
@@ -125,6 +125,11 @@ static bool testTerrainPrefabSpawners(EditorApp &app, TerrainSystem *ts);
static bool testRoadRegionStreaming(EditorApp &app, TerrainSystem *ts);
static bool testRoadRegionMigration(EditorApp &app, TerrainSystem *ts);
static bool testNavMeshDoors(EditorApp &app, TerrainSystem *ts);
static bool testNavMeshFurniture(EditorApp &app, TerrainSystem *ts);
static bool testTownPlanRooms(EditorApp &app, TerrainSystem *ts);
static bool testTownCompositions(EditorApp &app, TerrainSystem *ts);
static bool testFurniturePoiQuery(EditorApp &app, TerrainSystem *ts);
static bool testFurnitureLayers(EditorApp &app, TerrainSystem *ts);
static bool testMemoryStability(EditorApp &app);
static void logResult(const TerrainTestResult &r);
@@ -1141,6 +1141,85 @@ void TownGenSystem::fillBuildingCells(const TownGen::TownBuilding &b,
grid.markDirty();
}
void TownGenSystem::createPlanRooms(flecs::entity building,
const TownGen::TownBuilding &b,
const CellGridComponent &grid)
{
const LayoutLib::BuildingPlan &plan = b.plan;
const std::vector<LayoutLib::PlanRoomBounds> bounds =
LayoutLib::planRoomBounds(plan);
for (const LayoutLib::PlanRoomBounds &rb : bounds) {
flecs::entity room = m_world.entity();
room.child_of(building);
const std::string &roomName = plan.roomNames[rb.roomId];
room.set<EntityNameComponent>(EntityNameComponent(
"room " + roomName +
(rb.floor > 0 ? " f" + std::to_string(rb.floor) :
"")));
room.add<EditorMarkerComponent>();
RoomComponent rc;
rc.minX = rb.minX;
rc.minY = rb.floor;
rc.minZ = rb.minZ;
rc.maxX = rb.maxX + 1; /* RoomComponent max is exclusive */
rc.maxY = rb.floor + 1;
rc.maxZ = rb.maxZ + 1;
rc.roomType = plan.roomTypes[rb.roomId];
/* Geometry (floors/walls/doors/windows) is already in the
* plan's cell flags — the room entity only drives
* furniture generation, so none of the geometry verbs
* run. */
rc.createFloor = false;
rc.createCeiling = false;
rc.createInteriorWalls = false;
rc.createWindows = false;
/* Furniture selection: explicit :furniture terms win;
* otherwise fall back to the room type, then the room
* name, so (room bed (rect ...) :type bedroom) needs no
* tag list. */
const std::vector<std::string> &ftags =
plan.roomFurnitureTags[rb.roomId];
if (!ftags.empty())
rc.tags = ftags;
else if (!rc.roomType.empty())
rc.tags = { rc.roomType };
else
rc.tags = { roomName };
rc.fillRoomWithFurniture = !rc.tags.empty();
/* Declarative composition rules from the room's
* :compose option (phase 6.5.F) — the RoomLayoutSystem
* evaluator treats node-authored and DSL-authored rules
* identically. */
if (rb.roomId < (int)plan.roomCompositions.size())
rc.furnitureCompositions =
plan.roomCompositions[rb.roomId];
/* Deterministic per-room seed: rederivable from the
* building address and room identity alone, so a town
* rebuild refurnishes this room identically without
* touching other rooms' draws. */
std::string seedKey = b.address + "|" + roomName + "|" +
std::to_string(rb.floor);
uint32_t h = 2166136261u;
for (char c : seedKey) {
h ^= (unsigned char)c;
h *= 16777619u;
}
rc.furnitureSeed = h;
/* Stable persistent id for placement ids (phase 6.5.C):
* the grid uid is "town-<address>", so room ids survive
* regeneration/stream rebuilds too. */
rc.persistentId = "townroom-" + b.address + "-" + roomName +
"-" + std::to_string(rb.floor);
room.set<RoomComponent>(rc);
}
}
void TownGenSystem::createBuilding(flecs::entity container,
flecs::entity material, flecs::entity texture,
const TownGen::TownBuilding &b,
@@ -1192,6 +1271,13 @@ void TownGenSystem::createBuilding(flecs::entity container,
building.set<CellGridComponent>(grid);
/* Phase 6.5.F: exteriorOnly shells stay unfurnished (their
* interior is not generated at all); full/interiorOnly grids get
* the plan's rooms as RoomComponents so the shared
* RoomLayoutSystem furnishes them. */
if (genMode != "exteriorOnly")
createPlanRooms(building, b, building.get<CellGridComponent>());
/* Roofs from the layout plan; when the plan defines none (the
* district defaults don't), fall back to the edge-side roofType
* override or a flat slab over the whole footprint.
@@ -213,6 +213,14 @@ private:
static void fillBuildingCells(const TownGen::TownBuilding &b,
CellGridComponent &grid,
const std::string &genMode);
/** Phase 6.5.F: materialize the plan's room metadata (roomIds +
* roomNames/roomTypes/roomFurnitureTags) as RoomComponent children
* for full/interiorOnly grids, so DSL-authored rooms are furnished
* by the same RoomLayoutSystem path as node-authored rooms.
* ExteriorOnly shells stay unfurnished. */
void createPlanRooms(flecs::entity building,
const TownGen::TownBuilding &b,
const CellGridComponent &grid);
/** Full synchronous teardown of a generated entity and its
* children (regen/clear used to bare-destruct, leaking physics
@@ -22,6 +22,10 @@
#include <cstdio>
#include <string>
#include <algorithm>
#include <OgreResourceGroupManager.h>
#include <OgreRoot.h>
#include "../systems/CellGridSystem.hpp"
#include "../systems/SceneSerializer.hpp"
@@ -35,6 +39,9 @@
#include "../lua/LuaEventApi.hpp"
#include "../lua/LuaGlobalStateApi.hpp"
#include "../lua/LuaDoorApi.hpp"
#include "../lua/LuaFurnitureApi.hpp"
#include "../systems/RoomLayoutSystem.hpp"
#include "../systems/FurnitureLibrary.hpp"
// ---------------------------------------------------------------------------
// Test helpers
@@ -207,6 +214,20 @@ static int testSerializationRoundTrip()
orphan.lockable = true;
grid.doorConfigs["Z:9:9:9"] = orphan;
// Furniture placement metadata (phase 6.5.C) rides the same grid
// serialization path.
FurnitureCell furn;
furn.x = 2;
furn.y = 0;
furn.z = 3;
furn.furnitureType = "doublebed1";
furn.rotation = 1;
furn.tags = { "bedroom", "essential" };
furn.placementId = "grid:room:bed";
furn.owner = "generated";
furn.groupId = "bedroom-set";
grid.furnitureCells.push_back(furn);
e.set<CellGridComponent>(grid);
nlohmann::json json = serializer.serializeEntity(e);
@@ -250,6 +271,90 @@ static int testSerializationRoundTrip()
FAIL("orphaned door config lost in round trip");
if (oit->second.label != "Old cellar door" || !oit->second.lockable)
FAIL("orphaned door config fields mismatch");
if (g2.furnitureCells.size() != 1)
FAIL("furniture cell missing after round trip");
const FurnitureCell &f2 = g2.furnitureCells[0];
if (f2.furnitureType != "doublebed1" || f2.rotation != 1 ||
f2.placementId != "grid:room:bed" || f2.owner != "generated" ||
f2.groupId != "bedroom-set" || f2.tags.size() != 2)
FAIL("furniture placement metadata mismatch after round trip");
PASS();
return 0;
}
static int testRoomCompositionSerialization()
{
TEST("room furniture composition serialization round trip");
flecs::world world;
SceneSerializer serializer(world, nullptr);
RoomComponent room;
room.minX = 1;
room.minZ = 2;
room.maxX = 8;
room.maxZ = 9;
room.roomType = "dining";
room.tags = { "livingroom" };
LayoutLib::FurnitureCompositionRule group;
group.kind = LayoutLib::FurnitureCompositionRule::Kind::Group;
group.tags = { "dinner-table1" };
group.anchor = "center";
group.rootRotation = 1;
group.step = 2;
group.skip = 3;
group.count = 4;
LayoutLib::FurnitureChildRule child;
child.tags = { "chairs1", "lounge|livingroom" };
child.offsetX = -2;
child.offsetZ = 3;
child.rotationOffset = 2;
group.children.push_back(child);
LayoutLib::FurnitureCompositionRule row;
row.kind = LayoutLib::FurnitureCompositionRule::Kind::Row;
row.tags = { "dresser2" };
row.anchor = "zpos";
row.rootRotation = 0;
row.step = 2;
row.skip = 1;
row.count = 3;
room.furnitureCompositions = { group, row };
flecs::entity e = world.entity();
e.set<RoomComponent>(room);
nlohmann::json json = serializer.serializeEntity(e);
if (!json.contains("room") ||
!json["room"].contains("furnitureCompositions") ||
json["room"]["furnitureCompositions"].size() != 2)
FAIL("composition rules not serialized");
flecs::entity e2 = world.entity();
serializer.deserializeEntityComponents(e2, json, flecs::entity::null(),
nullptr, false, false, false);
if (!e2.has<RoomComponent>())
FAIL("RoomComponent missing after deserialize");
const RoomComponent &r2 = e2.get<RoomComponent>();
if (r2.furnitureCompositions.size() != 2)
FAIL("composition count mismatch after round trip");
const auto &g2 = r2.furnitureCompositions[0];
if (g2.kind != LayoutLib::FurnitureCompositionRule::Kind::Group ||
g2.anchor != "center" || g2.rootRotation != 1 ||
g2.step != 2 || g2.skip != 3 || g2.count != 4 ||
g2.tags != group.tags || g2.children.size() != 1 ||
g2.children[0].tags != child.tags ||
g2.children[0].offsetX != -2 ||
g2.children[0].offsetZ != 3 ||
g2.children[0].rotationOffset != 2)
FAIL("group fields mismatch after round trip");
const auto &row2 = r2.furnitureCompositions[1];
if (row2.kind != LayoutLib::FurnitureCompositionRule::Kind::Row ||
row2.anchor != "zpos" || row2.rootRotation != 0 ||
row2.step != 2 || row2.skip != 1 || row2.count != 3 ||
row2.tags != row.tags || !row2.children.empty())
FAIL("row fields mismatch after round trip");
PASS();
return 0;
@@ -779,6 +884,204 @@ static int testGenerationModeSerialization()
return 0;
}
static int testFurnitureLuaHooks()
{
TEST("Lua furniture room events, fallback and transactions");
Ogre::Root ogreRoot;
try {
Ogre::ResourceGroupManager::getSingleton().addResourceLocation(
"resources/buildings/parts/furniture", "FileSystem",
"General");
} catch (...) {
/* Already registered by an earlier test in this process. */
}
FurnitureLibrary::getInstance().loadAll();
if (!FurnitureLibrary::getInstance().findByName("table-chair1"))
FAIL("table-chair1 definition was not loaded");
lua_State *L = luaL_newstate();
if (!L)
FAIL("luaL_newstate failed");
luaL_openlibs(L);
flecs::world world;
editScene::registerLuaFurnitureApi(L, &world);
flecs::entity gridE = world.entity();
CellGridComponent grid;
grid.gridUid = "lua-grid";
grid.cellSize = 2.0f;
grid.cellHeight = 3.0f;
for (int z = 0; z < 4; ++z)
for (int x = 0; x < 4; ++x) {
Cell &cell = grid.getOrCreateCell(x, 0, z);
cell.flags = CellFlags::Floor | CellFlags::Ceiling;
if (x == 0)
cell.flags |= CellFlags::WallXNeg;
if (x == 3)
cell.flags |= CellFlags::WallXPos;
if (z == 0)
cell.flags |= CellFlags::WallZNeg;
if (z == 3)
cell.flags |= CellFlags::WallZPos;
}
gridE.set<CellGridComponent>(grid);
flecs::entity roomE = world.entity();
roomE.child_of(gridE);
RoomComponent room;
room.maxX = 4;
room.maxZ = 4;
room.persistentId = "shop";
room.furnitureSeed = 123;
room.createFloor = false;
room.createCeiling = false;
room.createInteriorWalls = false;
roomE.set<RoomComponent>(room);
RoomLayoutSystem rooms(world, nullptr);
rooms.initialize();
const char *script =
"order = {}\n"
"mode = 'valid'\n"
"ecs.furniture.on_room_before(function(ctx)\n"
" table.insert(order, 'before')\n"
" assert(ctx.gridUid == 'lua-grid' and ctx.roomId == 'shop')\n"
" assert(ctx.seed == 123)\n"
" assert(#ecs.furniture.definitions() > 0)\n"
" if mode == 'valid' then\n"
" table.insert(ctx.placements, { definition = 'table-chair1',"
" x = 1, y = 0, z = 1, rotation = 0,"
" placementId = 'script-table' })\n"
" ctx.suppress_default = true\n"
" elseif mode == 'invalid' then\n"
" table.insert(ctx.placements, { definition = 'table-chair1',"
" x = 99, y = 0, z = 99, rotation = 0,"
" placementId = 'bad-placement' })\n"
" ctx.suppress_default = true\n"
" else\n"
" ctx.suppress_default = false\n"
" end\n"
"end)\n"
"ecs.furniture.on_room_after(function(ctx)\n"
" table.insert(order, 'after')\n"
"end)\n";
if (luaL_dostring(L, script) != LUA_OK) {
std::string error = lua_tostring(L, -1);
lua_close(L);
FAIL("hook registration failed: " + error);
}
auto setMode = [&](const char *mode) {
std::string code = "mode = '" + std::string(mode) + "'";
if (luaL_dostring(L, code.c_str()) != LUA_OK) {
std::string error = lua_tostring(L, -1);
lua_pop(L, 1);
FAIL("failed to set test mode: " + error);
}
};
auto orderIs = [&](const char *expected) {
std::string code = "assert(table.concat(order, ',') == '" +
std::string(expected) + "')";
if (luaL_dostring(L, code.c_str()) != LUA_OK) {
std::string error = lua_tostring(L, -1);
lua_pop(L, 1);
FAIL("event order mismatch: " + error);
}
};
/* Suppression: only the script placement exists; a second run is
* idempotent because stable placement ids update in place. */
rooms.update();
if (gridE.get<CellGridComponent>().furnitureCells.size() != 1)
FAIL("suppressed default produced unexpected placements");
const FurnitureCell &scriptCell =
gridE.get<CellGridComponent>().furnitureCells[0];
if (scriptCell.placementId != "script-table" ||
scriptCell.owner != "script")
FAIL("script placement metadata mismatch");
orderIs("before,after");
roomE.get_mut<RoomComponent>().markDirty();
rooms.update();
if (gridE.get<CellGridComponent>().furnitureCells.size() != 1)
FAIL("stable-id script placement duplicated on regeneration");
orderIs("before,after,before,after");
/* Invalid script edits roll back and fall back to the default
* evaluator instead of leaving the room empty. */
roomE.get_mut<RoomComponent>().tags = { "office" };
roomE.get_mut<RoomComponent>().markDirty();
setMode("invalid");
rooms.update();
size_t generated = 0;
for (const auto &cell : gridE.get<CellGridComponent>().furnitureCells) {
if (cell.owner == "generated")
++generated;
if (cell.placementId == "bad-placement")
FAIL("invalid placement escaped the transaction");
}
if (generated == 0)
FAIL("invalid edit did not use the default fallback");
orderIs("before,after,before,after,before,after");
/* Default regeneration is deterministic, including after events. */
setMode("default");
roomE.get_mut<RoomComponent>().markDirty();
rooms.update();
auto snapshot = [&]() {
std::vector<std::string> result;
for (const auto &cell : gridE.get<CellGridComponent>().furnitureCells)
result.push_back(cell.furnitureType + "@" +
std::to_string(cell.x) + ":" +
std::to_string(cell.z) + ":r" +
std::to_string(cell.rotation) + ":" +
cell.placementId);
std::sort(result.begin(), result.end());
return result;
};
const auto first = snapshot();
roomE.get_mut<RoomComponent>().markDirty();
rooms.update();
if (snapshot() != first)
FAIL("default furniture regeneration was not deterministic");
orderIs("before,after,before,after,before,after,before,after,"
"before,after");
/* Direct API: validate, atomically commit, then remove by id,
* against a separate empty grid. */
CellGridComponent apiGrid = grid;
apiGrid.gridUid = "api-grid";
apiGrid.furnitureCells.clear();
flecs::entity apiGridE = world.entity();
apiGridE.set<CellGridComponent>(apiGrid);
const char *apiScript =
"local ok, err\n"
"local probe = nil\n"
"for x = 0, 3 do\n"
" for z = 0, 3 do\n"
" if not probe then\n"
" local candidate = { definition = 'table-chair1',"
" x = x, y = 0, z = z, placementId = 'probe' }\n"
" ok, err = ecs.furniture.can_place('api-grid',"
" candidate)\n"
" if ok then probe = candidate end\n"
" end\n"
" end\n"
"end\n"
"assert(probe, err)\n"
"ok, err = ecs.furniture.commit('api-grid', { probe })\n"
"assert(ok, err)\n"
"assert(ecs.furniture.remove('api-grid', 'probe') == 1)\n"
"assert(ecs.furniture.remove('api-grid', 'probe') == 0)\n";
if (luaL_dostring(L, apiScript) != LUA_OK) {
std::string error = lua_tostring(L, -1);
lua_close(L);
FAIL("direct furniture API failed: " + error);
}
lua_close(L);
PASS();
return 0;
}
int main()
{
printf("CellGrid door identity / per-door config tests (F0, F6)\n");
@@ -793,6 +1096,7 @@ int main()
failures += testCollectDoorways();
failures += testGridUid();
failures += testSerializationRoundTrip();
failures += testRoomCompositionSerialization();
failures += testOldSceneDefaults();
failures += testDoorDefaultsAndLocked();
failures += testDoorUnlockEvents();
@@ -802,6 +1106,7 @@ int main()
failures += testSwingReversed();
failures += testStandaloneDoorSerialization();
failures += testGenerationModeSerialization();
failures += testFurnitureLuaHooks();
printf("====================================================\n");
printf("Results: %d/%d passed\n", passCount, testCount);
@@ -0,0 +1,898 @@
/**
* @file furniture_test.cpp
* @brief Headless schema/parser tests for FurnitureLibrary definitions.
*
* Covers the phase 6.5 canonical schema without initializing OGRE:
* legacy defaults, Blender metadata, prefab references, validation errors,
* stable-id aliases, duplicate detection and canonical serialization.
*/
#include <cmath>
#include <cstdio>
#include <filesystem>
#include <fstream>
#include <string>
#include <vector>
#include <nlohmann/json.hpp>
#include "../systems/FurnitureLibrary.hpp"
#include "../systems/FurniturePlacement.hpp"
namespace {
int testCount = 0;
int passCount = 0;
#define TEST(name) \
do { \
++testCount; \
std::printf(" TEST %d: %s ... ", testCount, name); \
} while (0)
#define PASS() \
do { \
++passCount; \
std::printf("PASS\n"); \
} while (0)
#define FAIL(message) \
do { \
std::printf("FAIL: %s\n", std::string(message).c_str()); \
return 1; \
} while (0)
bool near(float a, float b)
{
return std::fabs(a - b) < 0.0001f;
}
bool parse(const nlohmann::json &json, FurnitureDefinition &def,
std::string &error)
{
return FurnitureDefinition::parseJson(json, "test.json", def, &error);
}
int testLegacyDefaults()
{
TEST("legacy mesh definition defaults");
nlohmann::json json = {
{ "name", "bed1" },
{ "mesh", "furniture-bed1.glb" },
{ "tags", { "bedroom", "essential" } },
{ "actions", nlohmann::json::array(
{ { { "action", "sit" }, { "radius", 0.5f } } }) },
};
FurnitureDefinition def;
std::string error;
if (!parse(json, def, error))
FAIL(error);
if (def.id != "bed1" || def.name != "bed1" ||
def.meshName != "furniture-bed1.glb" || !def.prefabPath.empty())
FAIL("legacy identity/mesh fields changed");
if (def.layer != FurnitureDefinition::Layer::Normal ||
def.source != FurnitureDefinition::Source::Manual ||
def.requiresWall || !def.requiresFloor || !near(def.weight, 1.0f))
FAIL("legacy defaults are wrong");
if (def.hasBounds || def.hasCollisionBounds ||
def.hasFootprintCells || !near(def.clearance, 0.0f))
FAIL("optional bounds/footprint should stay absent");
if (def.sourceHash.size() != 16)
FAIL("missing deterministic source hash");
if (!def.jsonData.contains("actions") || def.jsonData["actions"].size() != 1)
FAIL("interaction declarations were not preserved");
PASS();
return 0;
}
nlohmann::json fullDefinitionJson()
{
return {
{ "schemaVersion", 1 },
{ "id", "wall-switch" },
{ "name", "legacy-switch" },
{ "displayName", "Wall Switch" },
{ "mesh", "furniture-wall-switch.glb" },
{ "source", "blender" },
{ "sourceHash", "0123456789abcdef" },
{ "layer", "thin-wall" },
{ "tags", { "wall", " switch ", "wall", "" } },
{ "aliases", { "switch-old" } },
{ "bounds", { { "min", { -0.1f, 0.8f, -0.02f } },
{ "max", { 0.1f, 1.2f, 0.02f } } } },
{ "collisionBounds",
{ { "min", { -0.08f, 0.8f, -0.01f } },
{ "max", { 0.08f, 1.2f, 0.01f } } } },
{ "anchor", { 0.0f, 0.8f, 0.0f } },
{ "footprintCells", { 1, 1 } },
{ "clearance", 0.05f },
{ "requiresWall", true },
{ "requiresFloor", false },
{ "weight", 2.5f },
{ "actions", nlohmann::json::array(
{ { { "name", "use" },
{ "action", "use" },
{ "height", 1.2f },
{ "radius", 0.4f },
{ "position_x", 0.1f },
{ "position_y", 0.8f },
{ "positions", nlohmann::json::array(
{ { { "name", "enter" },
{ "position_z", -0.2f } } }) } } }) },
};
}
int testFullMetadata()
{
TEST("schema v1 metadata");
FurnitureDefinition def;
std::string error;
if (!parse(fullDefinitionJson(), def, error))
FAIL(error);
if (def.id != "wall-switch" || def.name != "wall-switch" ||
def.displayName != "Wall Switch")
FAIL("id/display name mismatch");
if (def.layer != FurnitureDefinition::Layer::ThinWall ||
def.source != FurnitureDefinition::Source::Blender ||
def.sourceHash != "0123456789abcdef")
FAIL("layer/source fields mismatch");
if (def.tags.size() != 2 || def.tags[0] != "wall" ||
def.tags[1] != "switch" || def.aliases.size() != 1)
FAIL("tags/aliases were not normalized");
if (!def.hasBounds || !near(def.boundsMin.x, -0.1f) ||
!near(def.boundsMax.y, 1.2f) || !def.hasCollisionBounds ||
!near(def.collisionBoundsMax.z, 0.01f))
FAIL("bounds did not parse");
if (!near(def.anchor.y, 0.8f) || !def.hasFootprintCells ||
def.footprintCellsX != 1 || def.footprintCellsZ != 1 ||
!near(def.clearance, 0.05f))
FAIL("anchor/footprint/clearance did not parse");
if (!def.requiresWall || def.requiresFloor || !near(def.weight, 2.5f))
FAIL("placement rule fields did not parse");
if (def.actions.size() != 1 || def.actions[0].actionName != "use" ||
!near(def.actions[0].height, 1.2f) ||
!near(def.actions[0].position.x, 0.1f) ||
def.actions[0].positions.size() != 1 ||
def.actions[0].positions[0].name != "enter")
FAIL("action POI metadata did not parse");
PASS();
return 0;
}
int testPrefabShapeValidation()
{
TEST("mesh/prefab shape validation");
FurnitureDefinition def;
std::string error;
nlohmann::json prefab = {
{ "id", "counter" },
{ "prefab", "prefabs/furniture.counter.json" },
};
if (!parse(prefab, def, error) || def.prefabPath.empty())
FAIL("prefab-only definition rejected");
if (!def.meshName.empty())
FAIL("prefab definition unexpectedly has a mesh");
nlohmann::json both = prefab;
both["mesh"] = "counter.glb";
if (parse(both, def, error))
FAIL("definition with mesh and prefab was accepted");
nlohmann::json neither = { { "id", "bad" } };
if (parse(neither, def, error))
FAIL("definition with neither mesh nor prefab was accepted");
PASS();
return 0;
}
int testInvalidDefinitions()
{
TEST("schema validation errors");
FurnitureDefinition def;
std::string error;
struct Case {
const char *name;
nlohmann::json json;
};
nlohmann::json base = {
{ "id", "bad" },
{ "mesh", "bad.glb" },
};
std::vector<Case> cases;
nlohmann::json badLayer = base;
badLayer["layer"] = "floating";
cases.push_back({ "unknown layer", badLayer });
nlohmann::json badBounds = base;
badBounds["bounds"] = { { "min", { 2.0f, 0.0f, 0.0f } },
{ "max", { 1.0f, 0.0f, 0.0f } } };
cases.push_back({ "inverted bounds", badBounds });
nlohmann::json badFootprint = base;
badFootprint["footprintCells"] = { 0, 1 };
cases.push_back({ "empty footprint", badFootprint });
nlohmann::json badWeight = base;
badWeight["weight"] = -0.1f;
cases.push_back({ "negative weight", badWeight });
nlohmann::json badVersion = base;
badVersion["schemaVersion"] = 2;
cases.push_back({ "unsupported version", badVersion });
nlohmann::json badActions = base;
badActions["actions"] = { 1 };
cases.push_back({ "non-object action", badActions });
for (const Case &testCase : cases) {
error.clear();
if (parse(testCase.json, def, error))
FAIL(testCase.name);
if (error.empty())
FAIL("validation failure has no diagnostic");
}
PASS();
return 0;
}
int testAliasesAndDuplicates()
{
TEST("stable ids, aliases and duplicate detection");
FurnitureDefinition first;
FurnitureDefinition second;
std::string error;
nlohmann::json a = {
{ "id", "bed" },
{ "mesh", "bed.glb" },
{ "aliases", { "bed1" } },
};
nlohmann::json b = {
{ "id", "chair" },
{ "mesh", "chair.glb" },
};
if (!parse(a, first, error) || !parse(b, second, error))
FAIL(error);
std::vector<FurnitureDefinition> defs = { first, second };
if (!FurnitureDefinition::validateDefinitionSet(defs, &error))
FAIL(error);
FurnitureDefinition duplicate = second;
duplicate.id = "bed";
defs.push_back(duplicate);
if (FurnitureDefinition::validateDefinitionSet(defs, &error))
FAIL("duplicate id accepted");
defs.pop_back();
second.id = "bed1";
defs[1] = second;
if (FurnitureDefinition::validateDefinitionSet(defs, &error))
FAIL("alias/id conflict accepted");
PASS();
return 0;
}
int testCanonicalRoundTrip()
{
TEST("canonical metadata round trip");
FurnitureDefinition first;
FurnitureDefinition second;
std::string error;
if (!parse(fullDefinitionJson(), first, error))
FAIL(error);
nlohmann::json canonical = first.toJson();
if (!parse(canonical, second, error))
FAIL(error);
if (first.id != second.id || first.name != second.name ||
first.displayName != second.displayName ||
first.meshName != second.meshName ||
first.prefabPath != second.prefabPath ||
first.layer != second.layer || first.source != second.source ||
first.sourceHash != second.sourceHash ||
first.tags != second.tags || first.aliases != second.aliases ||
first.hasBounds != second.hasBounds ||
first.hasCollisionBounds != second.hasCollisionBounds ||
first.hasFootprintCells != second.hasFootprintCells ||
!near(first.anchor.y, second.anchor.y) ||
!near(first.clearance, second.clearance) ||
first.requiresWall != second.requiresWall ||
first.requiresFloor != second.requiresFloor ||
!near(first.weight, second.weight) ||
first.actions.size() != second.actions.size() ||
(!first.actions.empty() &&
(first.actions[0].name != second.actions[0].name ||
first.actions[0].positions.size() !=
second.actions[0].positions.size())))
FAIL("round trip changed metadata");
if (!canonical.contains("actions") || canonical["actions"].size() != 1)
FAIL("round trip dropped interaction declarations");
PASS();
return 0;
}
int testTagExpressions()
{
TEST("tag expression AND/OR/NOT terms");
FurnitureDefinition bed;
bed.tags = { "bedroom", "essential" };
FurnitureDefinition bedWindow;
bedWindow.tags = { "bedroom", "nowindow" };
FurnitureDefinition sofa;
sofa.tags = { "livingroom", "filler" };
std::vector<FurnitureTagTerm> terms;
std::string error;
if (!FurnitureLibrary::parseTagExpression(
{ "bedroom|kitchen", "!nowindow" }, terms, &error))
FAIL(error);
if (!FurnitureLibrary::matchesTagTerms(bed, terms) ||
FurnitureLibrary::matchesTagTerms(bedWindow, terms) ||
FurnitureLibrary::matchesTagTerms(sofa, terms))
FAIL("OR+NOT expression matched the wrong definitions");
terms.clear();
if (!FurnitureLibrary::parseTagExpression({ "livingroom" }, terms,
&error))
FAIL(error);
if (!FurnitureLibrary::matchesTagTerms(sofa, terms) ||
FurnitureLibrary::matchesTagTerms(bed, terms))
FAIL("plain term no longer behaves as required tag");
terms.clear();
if (!FurnitureLibrary::parseTagExpression({ "!essential" }, terms,
&error))
FAIL(error);
if (!FurnitureLibrary::matchesTagTerms(sofa, terms) ||
FurnitureLibrary::matchesTagTerms(bed, terms))
FAIL("NOT term mismatch");
for (const std::string &bad : { "!", "bedroom|", "!bedroom|chair" }) {
error.clear();
if (FurnitureLibrary::parseTagExpression({ bad }, terms,
&error))
FAIL("invalid tag term accepted: " + bad);
if (error.empty())
FAIL("invalid tag term produced no diagnostic");
}
PASS();
return 0;
}
int testWeightedSelection()
{
TEST("weighted deterministic furniture selection");
FurnitureDefinition never;
never.id = never.name = "never";
never.meshName = "never.glb";
never.weight = 0.0f;
FurnitureDefinition common;
common.id = common.name = "common";
common.meshName = "common.glb";
common.weight = 3.0f;
std::vector<const FurnitureDefinition *> candidates = { &never,
&common };
for (unsigned seed : { 1u, 7u, 42u, 1000u }) {
std::mt19937 rng(seed);
if (FurnitureLibrary::selectWeighted(candidates, rng) != &common)
FAIL("zero-weight candidate selected");
}
std::vector<const FurnitureDefinition *> zeroOnly = { &never };
std::mt19937 rng(1);
if (FurnitureLibrary::selectWeighted(zeroOnly, rng) != nullptr)
FAIL("all-zero weights should select nothing");
PASS();
return 0;
}
int testOccupiedCells()
{
TEST("bounds-to-cell occupancy and overlap");
FurnitureDefinition legacy;
legacy.id = legacy.name = "legacy";
legacy.meshName = "legacy.glb";
auto legacyCells = legacy.occupiedCells(2, 3, 0, 2.0f);
if (legacyCells.size() != 1 || legacyCells[0] != std::make_pair(2, 3))
FAIL("legacy definitions must occupy only their anchor cell");
FurnitureDefinition def;
def.id = def.name = "bench";
def.meshName = "bench.glb";
def.hasBounds = true;
def.boundsMin = Ogre::Vector3(0.0f, 0.0f, 0.0f);
def.boundsMax = Ogre::Vector3(1.5f, 0.5f, 0.5f);
auto cells = def.occupiedCells(2, 3, 0, 1.0f);
if (cells.size() != 2 || cells[0] != std::make_pair(2, 3) ||
cells[1] != std::make_pair(3, 3))
FAIL("horizontal bounds occupancy mismatch");
cells = def.occupiedCells(2, 3, 1, 1.0f);
if (cells.size() != 2 || cells[0] != std::make_pair(2, 1) ||
cells[1] != std::make_pair(2, 2))
FAIL("90-degree bounds occupancy mismatch");
def.hasFootprintCells = true;
def.footprintCellsX = 2;
def.footprintCellsZ = 1;
cells = def.occupiedCells(1, 1, 3, 2.0f);
if (cells.size() != 2 || cells[0] != std::make_pair(0, 1) ||
cells[1] != std::make_pair(0, 2))
FAIL("270-degree footprint occupancy mismatch");
FurnitureDefinition other = legacy;
if (!def.overlaps(other, 1, 1, 3, 2.0f, 0, 1, 0))
FAIL("overlapping occupied cells were missed");
if (def.overlaps(other, 1, 1, 3, 2.0f, 4, 4, 0))
FAIL("distant cells reported as overlapping");
PASS();
return 0;
}
int testGeneratedDefinitions(const std::string &directory)
{
TEST("generated Blender definition directory");
if (!std::filesystem::is_directory(directory))
FAIL("not a directory: " + directory);
std::vector<FurnitureDefinition> definitions;
for (const auto &entry :
std::filesystem::recursive_directory_iterator(directory)) {
const std::string filename = entry.path().filename().string();
if (!entry.is_regular_file() ||
filename.find("furniture-") != 0 ||
filename.find("-manifest.json") != std::string::npos ||
entry.path().extension() != ".json")
continue;
std::ifstream input(entry.path());
nlohmann::json json;
try {
input >> json;
} catch (const std::exception &e) {
FAIL(entry.path().string() + ": " + e.what());
}
FurnitureDefinition def;
std::string error;
if (!FurnitureDefinition::parseJson(json, entry.path().string(),
def, &error))
FAIL(error);
if (def.source != FurnitureDefinition::Source::Blender ||
def.meshName.empty() || !def.hasBounds ||
def.sourceHash.empty() || !json.contains("atlas"))
FAIL("incomplete generated metadata in " +
entry.path().string());
definitions.push_back(std::move(def));
}
if (definitions.empty())
FAIL("no generated definitions found in " + directory);
std::string error;
if (!FurnitureDefinition::validateDefinitionSet(definitions, &error))
FAIL(error);
std::printf("%zu generated definitions ... ", definitions.size());
PASS();
return 0;
}
/* Build a minimal room grid: cells [0,sizeX) x [0,sizeZ) at y=0, each
* with a floor flag and exterior walls on the boundary. */
static CellGridComponent makeTestGrid(int sizeX, int sizeZ, float cellSize)
{
CellGridComponent grid;
grid.cellSize = cellSize;
grid.cellHeight = 3.0f;
for (int z = 0; z < sizeZ; ++z) {
for (int x = 0; x < sizeX; ++x) {
Cell &cell = grid.getOrCreateCell(x, 0, z);
cell.flags |= CellFlags::Floor | CellFlags::Ceiling;
if (z == 0)
cell.flags |= CellFlags::WallZNeg;
if (z == sizeZ - 1)
cell.flags |= CellFlags::WallZPos;
if (x == 0)
cell.flags |= CellFlags::WallXNeg;
if (x == sizeX - 1)
cell.flags |= CellFlags::WallXPos;
}
}
return grid;
}
static FurnitureDefinition makeTestDef(const std::string &name, float sx,
float sz)
{
FurnitureDefinition def;
def.id = def.name = name;
def.meshName = name + ".glb";
def.hasBounds = true;
def.boundsMin = Ogre::Vector3(0.0f, 0.0f, 0.0f);
def.boundsMax = Ogre::Vector3(sx, 0.5f, sz);
return def;
}
int testGroupTransactions()
{
TEST("group transactions: atomic commit and rollback");
/* 4x4 cells of 1 m, one room covering all of them. */
CellGridComponent grid = makeTestGrid(4, 4, 1.0f);
RoomComponent room;
room.minX = 0;
room.minZ = 0;
room.maxX = 4;
room.maxZ = 4;
room.minY = 0;
room.maxY = 1;
FurnitureDefinition table = makeTestDef("t-table", 1.0f, 1.0f);
FurnitureDefinition chair = makeTestDef("t-chair", 0.9f, 0.9f);
/* The test definitions are local; resolve committed cells through
* this table instead of the global library. */
const FurniturePlacement::DefinitionResolver resolver =
[&](const std::string &name) -> const FurnitureDefinition * {
if (name == table.name)
return &table;
if (name == chair.name)
return &chair;
return nullptr;
};
/* A table at (1,1) plus chairs on all four sides, expressed as a
* group with local offsets, must commit atomically. */
FurniturePlacementRequest root;
root.definition = &table;
root.x = 1;
root.y = 0;
root.z = 1;
root.rotation = 0;
root.owner = "generated";
root.groupId = "dining-1";
/* Children keep rotationOffset 0 here: with the pivot-at-corner
* convention a rotated body can swing out of its anchor cell, which
* is validated correctly but only distracts this commit test. The
* rotation math itself is asserted at expansion time below. */
std::vector<FurnitureGroupChild> children = {
{ &chair, 0, -1, 0, {} },
{ &chair, 0, 1, 0, {} },
{ &chair, -1, 0, 0, {} },
{ &chair, 1, 0, 0, {} },
};
std::vector<FurniturePlacementRequest> batch =
FurniturePlacement::expandGroup(root, children);
if (batch.size() != 5)
FAIL("group expansion produced the wrong request count");
for (const auto &req : batch) {
if (req.groupId != "dining-1" || req.owner != "generated")
FAIL("group expansion lost group/owner metadata");
}
/* Rotation 0: offsets are unchanged. */
if (batch[1].x != 1 || batch[1].z != 0 || batch[1].rotation != 0)
FAIL("first chair placement is wrong");
if (batch[3].x != 0 || batch[3].z != 1 || batch[3].rotation != 0)
FAIL("third chair placement is wrong");
/* Rotation offsets add to the root rotation at expansion time. */
{
std::vector<FurnitureGroupChild> turned = {
{ &chair, 0, -1, 2, {} },
};
FurniturePlacementRequest turnedRoot = root;
turnedRoot.rotation = 1;
auto turnedBatch =
FurniturePlacement::expandGroup(turnedRoot, turned);
if (turnedBatch[1].rotation != 3)
FAIL("child rotation offset was not added");
}
std::string error;
if (!FurniturePlacement::commit(grid, &room, batch, &error,
resolver))
FAIL("valid group commit failed: " + error);
if (grid.furnitureCells.size() != 5)
FAIL("commit did not write all group members");
for (const auto &cell : grid.furnitureCells) {
if (cell.groupId != "dining-1" || cell.placementId.empty())
FAIL("committed cell lost group/placement id");
}
/* The same group again must fail (overlap with committed cells)
* and must not append partial results. */
batch = FurniturePlacement::expandGroup(root, children);
if (FurniturePlacement::commit(grid, &room, batch, &error,
resolver))
FAIL("overlapping group commit was accepted");
if (grid.furnitureCells.size() != 5)
FAIL("failed group commit mutated the grid");
/* A group with ONE failing member rolls back entirely, including
* members validated before it. */
FurniturePlacementRequest root2 = root;
root2.x = 3;
root2.z = 3;
root2.groupId = "dining-2";
std::vector<FurnitureGroupChild> badChildren = {
{ &chair, -1, 0, 0, {} }, /* (2,3) free */
{ &chair, 1, 0, 0, {} }, /* (4,3) outside the room */
};
std::vector<FurniturePlacementRequest> batch2 =
FurniturePlacement::expandGroup(root2, badChildren);
if (FurniturePlacement::commit(grid, &room, batch2, &error,
resolver))
FAIL("group with an out-of-room member was accepted");
if (grid.furnitureCells.size() != 5)
FAIL("rolled-back group left placements behind");
error.clear();
/* Rotated groups rotate child offsets around the root: with
* rotation 1 (90 deg), local +X maps to world -Z. Note that a
* rotated piece's bounds swing around its min-corner pivot
* (occupiedCells), so the anchors are chosen to keep the occupied
* cells inside the room. */
FurniturePlacementRequest root3 = root;
root3.x = 3;
root3.z = 2;
root3.rotation = 1;
root3.groupId = "dining-3";
std::vector<FurnitureGroupChild> oneChild = {
{ &chair, 1, 0, 0, {} },
};
std::vector<FurniturePlacementRequest> batch3 =
FurniturePlacement::expandGroup(root3, oneChild);
if (batch3.size() != 2 || batch3[1].x != 3 || batch3[1].z != 1 ||
batch3[1].rotation != 1)
FAIL("rotated group child offset/rotation is wrong");
if (!FurniturePlacement::commit(grid, &room, batch3, &error,
resolver))
FAIL("rotated group commit failed: " + error);
if (grid.furnitureCells.size() != 7)
FAIL("rotated group commit count mismatch");
/* removeGroup removes exactly the group's placements. */
if (FurniturePlacement::removeGroup(grid, "dining-1") != 5)
FAIL("removeGroup removed the wrong number of cells");
if (grid.furnitureCells.size() != 2)
FAIL("removeGroup touched other placements");
if (FurniturePlacement::removeGroup(grid, "no-such-group") != 0)
FAIL("removeGroup matched an unknown group");
if (FurniturePlacement::removeGroup(grid, "") != 0)
FAIL("removeGroup must never match the empty group");
PASS();
return 0;
}
int testPlacementValidation()
{
TEST("placement kernel validation rules");
CellGridComponent grid = makeTestGrid(4, 4, 1.0f);
RoomComponent room;
room.minX = 0;
room.minZ = 0;
room.maxX = 4;
room.maxZ = 4;
room.minY = 0;
room.maxY = 1;
static const std::vector<FurniturePlacementRequest> noPending;
FurnitureDefinition wallItem = makeTestDef("t-shelf", 0.9f, 0.4f);
wallItem.requiresWall = true;
FurniturePlacementRequest req;
req.definition = &wallItem;
req.y = 0;
std::string error;
/* Wall-requiring item in the room centre: rejected. */
req.x = 2;
req.z = 2;
if (FurniturePlacement::canPlace(grid, &room, req, noPending,
&error))
FAIL("wall item accepted without a wall: " + error);
/* Same item on the boundary: accepted. */
req.x = 0;
req.z = 2;
if (!FurniturePlacement::canPlace(grid, &room, req, noPending,
&error))
FAIL("wall item on a wall was rejected: " + error);
/* Without a room constraint the grid bounds still apply through
* findCell. */
req.x = 9;
req.z = 9;
if (FurniturePlacement::canPlace(grid, nullptr, req, noPending,
&error))
FAIL("placement outside the grid was accepted");
/* Door cells reject items without the door tag. */
Cell &doorCell = grid.getOrCreateCell(1, 0, 0);
doorCell.flags |= CellFlags::IntDoorZNeg;
FurnitureDefinition box = makeTestDef("t-box", 0.9f, 0.9f);
req.definition = &box;
req.x = 1;
req.z = 0;
if (FurniturePlacement::canPlace(grid, &room, req, noPending,
&error))
FAIL("non-door item accepted on a door cell");
FurnitureDefinition doorStop = makeTestDef("t-doorstop", 0.9f, 0.9f);
doorStop.tags.push_back("door");
req.definition = &doorStop;
if (!FurniturePlacement::canPlace(grid, &room, req, noPending,
&error))
FAIL("door-tagged item rejected on a door cell: " + error);
PASS();
return 0;
}
int testPlacementUpdate()
{
TEST("stable-id placement update and removal");
CellGridComponent grid = makeTestGrid(4, 4, 1.0f);
FurnitureDefinition table = makeTestDef("t-update", 0.9f, 0.9f);
const FurniturePlacement::DefinitionResolver resolver =
[&](const std::string &name) -> const FurnitureDefinition * {
return name == table.name ? &table : nullptr;
};
FurniturePlacementRequest request;
request.definition = &table;
request.x = 1;
request.z = 1;
request.placementId = "stable-chair";
std::vector<FurniturePlacementRequest> batch = { request };
std::string error;
if (!FurniturePlacement::commit(grid, nullptr, batch, &error,
resolver))
FAIL(error);
request.x = 2;
request.z = 2;
batch = { request };
if (!FurniturePlacement::commit(grid, nullptr, batch, &error,
resolver))
FAIL("stable-id update was rejected: " + error);
if (grid.furnitureCells.size() != 1 ||
grid.furnitureCells[0].x != 2 || grid.furnitureCells[0].z != 2)
FAIL("stable-id update duplicated or failed to move");
/* A different placement at the occupied cell still fails. */
FurniturePlacementRequest other = request;
other.placementId = "other-chair";
batch = { other };
if (FurniturePlacement::commit(grid, nullptr, batch, &error,
resolver))
FAIL("overlapping placement was accepted as an update");
if (grid.furnitureCells.size() != 1)
FAIL("failed update mutated the grid");
if (FurniturePlacement::removePlacement(grid, "stable-chair") != 1 ||
!grid.furnitureCells.empty())
FAIL("stable-id removal failed");
if (FurniturePlacement::removePlacement(grid, "stable-chair") != 0)
FAIL("removal was not idempotent");
PASS();
return 0;
}
int testPrefabCompanion()
{
TEST("prefab companion definition generation");
const std::filesystem::path path =
std::filesystem::temp_directory_path() /
"furniture-companion-test.json";
const std::filesystem::path changedPath =
std::filesystem::temp_directory_path() /
"furniture-companion-test-changed.json";
nlohmann::json prefab = {
{ "name", { { "name", "ChairCustom" } } },
{ "transform", { { "position",
{ { "x", 0.0 }, { "y", 0.0 }, { "z", 0.0 } } } } },
{ "children", nlohmann::json::array() },
};
FurnitureDefinition::PrefabDefinitionOptions options;
options.id = "chair-custom";
options.prefabPath = "prefabs/furniture.chair-custom.json";
options.tags = { "office", "essential" };
options.layer = FurnitureDefinition::Layer::ThinWall;
options.hasBounds = true;
options.boundsMin = Ogre::Vector3(-0.5f, 0.0f, -0.25f);
options.boundsMax = Ogre::Vector3(0.5f, 1.0f, 0.25f);
std::string error;
if (!FurnitureDefinition::writePrefabDefinition(path.string(),
prefab, options, &error))
FAIL(error);
const std::string firstBytes = [&]() {
std::ifstream input(path, std::ios::binary);
return std::string(std::istreambuf_iterator<char>(input),
std::istreambuf_iterator<char>());
}();
if (firstBytes.empty())
FAIL("companion definition is empty");
/* Rewriting the same prefab must be byte-identical. */
if (!FurnitureDefinition::writePrefabDefinition(path.string(),
prefab, options, &error))
FAIL(error);
const std::string secondBytes = [&]() {
std::ifstream input(path, std::ios::binary);
return std::string(std::istreambuf_iterator<char>(input),
std::istreambuf_iterator<char>());
}();
if (firstBytes != secondBytes)
FAIL("unchanged prefab produced a different companion");
std::ifstream input(path);
nlohmann::json generated;
input >> generated;
FurnitureDefinition def;
if (!parse(generated, def, error))
FAIL(error);
if (def.id != "chair-custom" ||
def.prefabPath != options.prefabPath ||
def.source != FurnitureDefinition::Source::Prefab ||
def.layer != FurnitureDefinition::Layer::ThinWall ||
def.tags != options.tags || def.sourceHash.empty() ||
!def.hasBounds || !near(def.boundsMin.x, -0.5f) ||
!near(def.boundsMax.y, 1.0f))
FAIL("companion metadata mismatch");
/* The source hash tracks the prefab, not the companion file. */
prefab["children"].push_back({ { "name",
{ { "name", "sit" } } } });
if (!FurnitureDefinition::writePrefabDefinition(changedPath.string(),
prefab, options, &error))
FAIL(error);
std::ifstream changedInput(changedPath);
nlohmann::json changedJson;
changedInput >> changedJson;
FurnitureDefinition changed;
if (!parse(changedJson, changed, error))
FAIL(error);
if (changed.sourceHash == def.sourceHash)
FAIL("source hash did not change with the prefab");
FurnitureDefinition::PrefabDefinitionOptions invalid = options;
invalid.id.clear();
if (FurnitureDefinition::writePrefabDefinition(path.string(), prefab,
invalid, &error))
FAIL("empty id was accepted");
std::filesystem::remove(path);
std::filesystem::remove(changedPath);
PASS();
return 0;
}
} // namespace
int main(int argc, char **argv)
{
std::printf("Furniture definition tests\n");
int failures = 0;
failures += testLegacyDefaults();
failures += testFullMetadata();
failures += testPrefabShapeValidation();
failures += testInvalidDefinitions();
failures += testAliasesAndDuplicates();
failures += testCanonicalRoundTrip();
failures += testTagExpressions();
failures += testWeightedSelection();
failures += testOccupiedCells();
failures += testGroupTransactions();
failures += testPlacementValidation();
failures += testPrefabCompanion();
failures += testPlacementUpdate();
if (argc > 1)
failures += testGeneratedDefinitions(argv[1]);
std::printf("%d/%d tests passed\n", passCount, testCount);
return failures ? 1 : 0;
}
@@ -0,0 +1,33 @@
{
"name": { "name": "InteractiveChair" },
"transform": {
"position": { "x": 0.0, "y": 0.0, "z": 0.0 },
"rotation": { "w": 1.0, "x": 0.0, "y": 0.0, "z": 0.0 },
"scale": { "x": 1.0, "y": 1.0, "z": 1.0 }
},
"renderable": {
"meshName": "furniture-table-chair1.glb",
"visible": true
},
"children": [
{
"name": { "name": "sit" },
"transform": {
"position": { "x": 0.2, "y": 0.1, "z": 0.3 },
"rotation": { "w": 1.0, "x": 0.0, "y": 0.0, "z": 0.0 },
"scale": { "x": 1.0, "y": 1.0, "z": 1.0 }
},
"actuator": {
"actionNames": [ "sit" ],
"radius": 0.5,
"height": 1.0
},
"smartObject": {
"actionNames": [ "sit" ],
"radius": 0.5,
"height": 1.0
},
"children": []
}
]
}
@@ -4236,6 +4236,204 @@ static bool runTownGenProbe()
}
}
/* Phase 6.5.F: furniture-selection metadata is part of the
* room plan and stays parallel to room names/types. */
{
BuildingPlan pFurniture;
LayoutEval evFurniture(lb, &rngDsl, nullptr);
if (!evFurniture.evaluate(
"(shell 8 8 :empty)"
"(floor 0 (room shop (rect 0 0 1 1)"
" :type store"
" :furniture (shop|market counter !display)))",
pFurniture, &derr) ||
pFurniture.roomNames.size() != 1 ||
pFurniture.roomTypes.size() != 1 ||
pFurniture.roomFurnitureTags.size() != 1 ||
pFurniture.roomTypes[0] != "store" ||
pFurniture.roomFurnitureTags[0] !=
std::vector<std::string>{ "shop|market",
"counter",
"!display" }) {
printf(" *** dsl room furniture metadata: err '%s' "
"rooms %zu/%zu/%zu ***\n",
derr.c_str(), pFurniture.roomNames.size(),
pFurniture.roomTypes.size(),
pFurniture.roomFurnitureTags.size());
ok = false;
}
}
/* Phase 6.5.F: :compose records reusable group/row rules
* alongside the room's other furniture metadata. */
{
BuildingPlan pCompose;
LayoutEval evCompose(lb, &rngDsl, nullptr);
if (!evCompose.evaluate(
"(shell 16 16 :empty)"
"(floor 0 (room dining (rect 0 0 1 1)"
" :compose ("
" (group (dinner-table1) :at center :rot 1"
" (child (chairs1) -2 -2 0)"
" (child (chairs2|chairs1) 2 2 2))"
" (row (dresser2) :at zpos :step 2 :skip 1"
" :count 3))))",
pCompose, &derr)) {
printf(" *** dsl compose eval: %s ***\n",
derr.c_str());
ok = false;
} else if (pCompose.roomCompositions.size() != 1 ||
pCompose.roomCompositions[0].size() != 2) {
printf(" *** dsl compose rule count %zu/%zu "
"***\n",
pCompose.roomCompositions.size(),
pCompose.roomCompositions.empty() ?
0 :
pCompose.roomCompositions[0]
.size());
ok = false;
} else {
const auto &rules =
pCompose.roomCompositions[0];
const auto &group = rules[0];
const auto &row = rules[1];
if (group.kind !=
FurnitureCompositionRule::Kind::
Group ||
group.anchor != "center" ||
group.rootRotation != 1 ||
group.children.size() != 2 ||
group.children[0].offsetX != -2 ||
group.children[0].offsetZ != -2 ||
group.children[1].rotationOffset != 2 ||
group.children[1].tags !=
std::vector<std::string>{
"chairs2|chairs1" }) {
printf(" *** dsl compose group fields "
"***\n");
ok = false;
}
if (row.kind !=
FurnitureCompositionRule::Kind::Row ||
row.anchor != "zpos" || row.step != 2 ||
row.skip != 1 || row.count != 3 ||
!row.children.empty()) {
printf(" *** dsl compose row fields "
"***\n");
ok = false;
}
}
/* Malformed rules must be hard evaluation errors,
* never silently dropped furniture. */
const char *badRules[] = {
"(shell 8 8 :empty)(floor 0 (room r "
"(rect 0 0 1 1) :compose ((bogus (bed1)))))",
"(shell 8 8 :empty)(floor 0 (room r "
"(rect 0 0 1 1) :compose ((group (bed1) "
":at nowhere))))",
"(shell 8 8 :empty)(floor 0 (room r "
"(rect 0 0 1 1) :compose ((group (bed1) "
"(child (chair1) 0 0)))))",
"(shell 8 8 :empty)(floor 0 (room r "
"(rect 0 0 1 1) :compose ((row (bed1) "
"(child (chair1) 0 0 0)))))",
};
for (const char *bad : badRules) {
BuildingPlan pBad;
LayoutEval evBad(lb, &rngDsl, nullptr);
if (evBad.evaluate(bad, pBad, &derr)) {
printf(" *** dsl compose accepted bad "
"rule: %s ***\n",
bad);
ok = false;
}
}
}
/* Phase 6.5.F: planRoomBounds derives per-floor room cell
* bounds from roomIds — the input of the plan-to-
* RoomComponent materialization in TownGenSystem. */
{
BuildingPlan pBounds;
LayoutEval evBounds(lb, &rngDsl, nullptr);
if (!evBounds.evaluate(
"(shell 12 8 :empty)"
"(floor 0 (room hall (rect 0 0 0.5 1))"
" (room shop (rect 0.5 0 1 0.5)))",
pBounds, &derr)) {
printf(" *** dsl room bounds eval: %s ***\n",
derr.c_str());
ok = false;
} else {
std::vector<PlanRoomBounds> rb =
planRoomBounds(pBounds);
/* 12x8 m shell = 6x4 cells at 2 m: hall
* covers x[0,2] z[0,3], shop covers
* x[3,5] z[0,1]. */
bool hallOk = false;
bool shopOk = false;
for (const PlanRoomBounds &b : rb) {
if (b.floor != 0)
continue;
if (pBounds.roomNames[b.roomId] ==
"hall")
hallOk = b.minX == 0 &&
b.minZ == 0 &&
b.maxX == 2 &&
b.maxZ == 3;
if (pBounds.roomNames[b.roomId] ==
"shop")
shopOk = b.minX == 3 &&
b.minZ == 0 &&
b.maxX == 5 &&
b.maxZ == 1;
}
if (!hallOk || !shopOk || rb.size() != 2) {
printf(" *** dsl room bounds: %zu "
"bounds, hall %d shop %d ***\n",
rb.size(), (int)hallOk,
(int)shopOk);
ok = false;
}
}
/* Filled-shell mode: roomIds is metadata only, and
* cut cells (no Floor) must not widen the
* bounds. 24m shell = 12x12 cells; the cut
* removes the x[8,11] strip on every floor. */
BuildingPlan pCut;
LayoutEval evCut(lb, &rngDsl, nullptr);
if (!evCut.evaluate(
"(shell 24 24)"
"(cut 16 0 24 24)"
"(floor 0 (room main (rect 0 0 1 1)))",
pCut, &derr)) {
printf(" *** dsl room bounds cut eval: %s "
"***\n",
derr.c_str());
ok = false;
} else {
std::vector<PlanRoomBounds> rb =
planRoomBounds(pCut);
bool mainOk = rb.size() == 1 &&
rb[0].minX == 0 &&
rb[0].minZ == 0 &&
rb[0].maxX == 7 &&
rb[0].maxZ == 11;
if (!mainOk) {
printf(" *** dsl room bounds cut: "
"%zu bounds, got %d..%d x "
"%d..%d ***\n",
rb.size(),
rb.empty() ? -1 : rb[0].minX,
rb.empty() ? -1 : rb[0].maxX,
rb.empty() ? -1 : rb[0].minZ,
rb.empty() ? -1 : rb[0].maxZ);
ok = false;
}
}
}
/* Validation: overlapping rooms, out-of-shell fractions,
* zero-cell rooms and unconnected rooms are hard errors. */
{
@@ -256,6 +256,13 @@ void CellGridEditor::renderFurnitureEditor(CellGridComponent& grid)
auto& fcell = grid.getOrCreateFurnitureCell(fx, fy, fz);
fcell.furnitureType = defs[selectedFurnitureIndex].name;
fcell.rotation = rotation;
if (fcell.owner.empty() || fcell.owner == "generated")
fcell.owner = "authored";
if (fcell.placementId.empty()) {
fcell.placementId = grid.ensureGridUid() + ":furniture:" +
std::to_string(fx) + ":" + std::to_string(fy) + ":" +
std::to_string(fz) + ":" + fcell.furnitureType;
}
fcell.tags.clear();
// Parse tags
std::string tagsStr(tagsBuffer);
+148
View File
@@ -139,6 +139,154 @@ bool RoomEditor::renderComponent(flecs::entity entity, RoomComponent& room)
}
ImGui::Unindent();
}
/* Declarative furniture rules (phase 6.5.F). This is the
* node-editor form of the DSL room :compose option: groups
* commit atomically, rows are best-effort. */
if (ImGui::CollapsingHeader("Furniture Compositions")) {
ImGui::Indent();
ImGui::TextDisabled("Groups fit atomically; rows skip blocked cells");
auto editTags = [](std::vector<std::string>& tags,
const char* label) -> bool {
std::string value;
for (size_t i = 0; i < tags.size(); ++i) {
if (i > 0) value += ", ";
value += tags[i];
}
char buffer[256] = { 0 };
strncpy(buffer, value.c_str(), sizeof(buffer) - 1);
if (!ImGui::InputText(label, buffer, sizeof(buffer)))
return false;
tags.clear();
std::string input(buffer);
size_t start = 0;
while (start <= input.size()) {
size_t comma = input.find(',', start);
std::string tag = input.substr(start,
comma == std::string::npos ? std::string::npos :
comma - start);
tag.erase(0, tag.find_first_not_of(" \t"));
if (!tag.empty())
tag.erase(tag.find_last_not_of(" \t") + 1);
if (!tag.empty() && tag.find_first_not_of(" \t") !=
std::string::npos)
tags.push_back(tag);
if (comma == std::string::npos)
break;
start = comma + 1;
}
return true;
};
const char* anchors[] = { "center", "zneg", "zpos", "xneg",
"xpos" };
int removeRule = -1;
for (size_t ri = 0; ri < room.furnitureCompositions.size();
++ri) {
auto& rule = room.furnitureCompositions[ri];
const bool isGroup = rule.kind ==
LayoutLib::FurnitureCompositionRule::Kind::Group;
ImGui::PushID((int)ri);
bool open = ImGui::CollapsingHeader(
isGroup ? "Group" : "Row");
ImGui::SameLine();
if (ImGui::SmallButton("Remove"))
removeRule = (int)ri;
if (open) {
ImGui::Indent();
if (editTags(rule.tags, "Selection tags"))
modified = true;
int anchor = 0;
for (int ai = 0; ai < 5; ++ai)
if (rule.anchor == anchors[ai])
anchor = ai;
if (ImGui::Combo("Anchor", &anchor, anchors, 5)) {
rule.anchor = anchors[anchor];
modified = true;
}
if (ImGui::InputInt("Root rotation (-1 = auto)",
&rule.rootRotation)) {
rule.rootRotation = std::max(-1,
std::min(3, rule.rootRotation));
modified = true;
}
if (isGroup) {
int removeChild = -1;
for (size_t ci = 0; ci < rule.children.size();
++ci) {
auto& child = rule.children[ci];
ImGui::PushID((int)ci);
ImGui::Separator();
ImGui::Text("Child %zu", ci + 1);
ImGui::SameLine();
if (ImGui::SmallButton("Remove child"))
removeChild = (int)ci;
if (editTags(child.tags, "Tags"))
modified = true;
int offset[2] = { child.offsetX,
child.offsetZ };
if (ImGui::InputInt2("Cell offset X/Z",
offset)) {
child.offsetX = offset[0];
child.offsetZ = offset[1];
modified = true;
}
if (ImGui::InputInt("Rotation offset",
&child.rotationOffset))
modified = true;
ImGui::PopID();
}
if (removeChild >= 0) {
rule.children.erase(rule.children.begin() +
removeChild);
modified = true;
}
if (ImGui::Button("Add child")) {
rule.children.push_back(
LayoutLib::FurnitureChildRule());
modified = true;
}
} else {
if (ImGui::InputInt("Step cells", &rule.step)) {
rule.step = std::max(1, rule.step);
modified = true;
}
if (ImGui::InputInt("Skip after placement",
&rule.skip)) {
rule.skip = std::max(0, rule.skip);
modified = true;
}
if (ImGui::InputInt("Count (0 = full edge)",
&rule.count)) {
rule.count = std::max(0, rule.count);
modified = true;
}
}
ImGui::Unindent();
}
ImGui::PopID();
}
if (removeRule >= 0) {
room.furnitureCompositions.erase(
room.furnitureCompositions.begin() + removeRule);
modified = true;
}
if (ImGui::Button("Add group")) {
LayoutLib::FurnitureCompositionRule rule;
rule.kind = LayoutLib::FurnitureCompositionRule::Kind::Group;
room.furnitureCompositions.push_back(rule);
modified = true;
}
ImGui::SameLine();
if (ImGui::Button("Add row")) {
LayoutLib::FurnitureCompositionRule rule;
rule.kind = LayoutLib::FurnitureCompositionRule::Kind::Row;
rule.anchor = "zneg";
room.furnitureCompositions.push_back(rule);
modified = true;
}
ImGui::Unindent();
}
ImGui::Unindent();
}
+40 -5
View File
@@ -756,11 +756,13 @@ private:
" (windows all|front|back|left|right [:spacing m])"
" 'all' covers the whole footprint boundary of a "
"rooms-first shell (incl. courtyard walls)\n"
" (room name (rect x0 z0 x1 z1) [:windows "
"none]) fractions of the shell; :windows none "
"suppresses exterior windows on the room's "
"boundary cells (bathroom/storage); declare "
"rooms before the windows verb\n"
" (room name (rect x0 z0 x1 z1) [:type type] "
"[:windows none|all] [:furniture (tag ...)] "
"[:compose (rule ...)]) "
"fractions of the shell; :windows none suppresses "
"exterior windows on the room's boundary cells "
"(bathroom/storage); declare rooms before the "
"windows verb\n"
" (connect roomA roomB) door in the "
"shared wall (alias: idoor)\n"
" (roof flat|normal|normal2|gable|cone|cylinder "
@@ -772,6 +774,39 @@ private:
" (place \"<prefab>\" (at x z) [:yaw deg]) "
"recorded only");
ImGui::Spacing();
ImGui::TextUnformatted("Furniture tag expressions:");
ImGui::TextWrapped(
" Entries in a room's :furniture list are ANDed. "
"Use a|b for either tag and !a to reject a tag, "
"e.g. :furniture (shop|market counter !display). "
"RoomComponent tag lists use the same expression "
"terms. Definition weights still choose between "
"the matching candidates.");
ImGui::Spacing();
ImGui::TextUnformatted("Furniture composition rules (:compose):");
ImGui::TextWrapped(
" (group (root-tags) [:at center|zneg|zpos|xneg|xpos] "
"[:rot 0..3] (child (child-tags) dx dz rot) ...) "
"places one root plus children at cell offsets from "
"the root, atomically — the whole group fits or "
"nothing is placed. :at center is the room center; "
"edge anchors use the edge midpoint and face into "
"the room. Offsets rotate with the root.\n"
" (row (item-tags) [:at zneg|zpos|xneg|xpos] "
"[:step n] [:skip n] [:count n]) walks a wall "
"edge from its lower coordinate end; step is the "
"cell increment (default 1), skip adds empty cells "
"after a placed item, count 0 fills the edge. "
"Rows are best-effort: blocked cells are skipped.\n"
" Rules run before the priority-class tag walk, "
"use isolated per-room seeds, and are serialized "
"on RoomComponent. Example: :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))");
ImGui::Spacing();
ImGui::TextUnformatted("Forms & builtins:");
ImGui::TextWrapped(
" let, if (1 or 2 branches), cond, dotimes, "