Phase 6.5 checkpoint
This commit is contained in:
@@ -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`)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
project(building-parts)
|
||||
find_program(PYTHON3_EXECUTABLE NAMES python3 python REQUIRED)
|
||||
set(PARTS_FILES pier.blend)
|
||||
set(FURNITURE_FILES furniture.blend furniture-sofa.blend)
|
||||
set(PARTS_OUTPUT_DIRS)
|
||||
@@ -15,23 +16,121 @@ foreach(PARTS_FILE ${PARTS_FILES})
|
||||
DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/${PARTS_FILE} ${CMAKE_SOURCE_DIR}/assets/blender/scripts/export_building_parts.py)
|
||||
list(APPEND PARTS_OUTPUT_DIRS ${PARTS_OUTPUT_DIR})
|
||||
endforeach()
|
||||
set(FURNITURE_BAKED_BLENDS)
|
||||
set(FURNITURE_ATLAS_PARTS)
|
||||
list(LENGTH FURNITURE_FILES FURNITURE_COUNT)
|
||||
foreach(FURNITURE_FILE ${FURNITURE_FILES})
|
||||
list(FIND FURNITURE_FILES ${FURNITURE_FILE} FURNITURE_INDEX)
|
||||
get_filename_component(FILE_NAME ${FURNITURE_FILE} NAME_WE)
|
||||
set(BAKED_BLEND ${CMAKE_CURRENT_BINARY_DIR}/${FILE_NAME}_baked.blend)
|
||||
set(BAKE_SUCCESS ${CMAKE_CURRENT_BINARY_DIR}/${FILE_NAME}.bake_success)
|
||||
set(ATLAS_PART ${CMAKE_CURRENT_BINARY_DIR}/${FILE_NAME}-atlas.png)
|
||||
add_custom_command(
|
||||
OUTPUT ${BAKED_BLEND} ${ATLAS_PART} ${BAKE_SUCCESS}
|
||||
COMMAND ${CMAKE_COMMAND} -E rm -f ${BAKE_SUCCESS}
|
||||
COMMAND ${CMAKE_COMMAND} -E copy
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/${FURNITURE_FILE}
|
||||
${CMAKE_CURRENT_BINARY_DIR}/${FURNITURE_FILE}
|
||||
COMMAND ${BLENDER} -b ${CMAKE_CURRENT_BINARY_DIR}/${FURNITURE_FILE}
|
||||
-Y -P ${CMAKE_CURRENT_SOURCE_DIR}/bake_furniture.py
|
||||
-- ${ATLAS_PART} ${FURNITURE_INDEX} ${FURNITURE_COUNT}
|
||||
${BAKE_SUCCESS}
|
||||
COMMAND ${CMAKE_COMMAND} -D FILE=${BAKE_SUCCESS}
|
||||
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
|
||||
DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/${FURNITURE_FILE}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/bake_furniture.py)
|
||||
list(APPEND FURNITURE_BAKED_BLENDS ${BAKED_BLEND})
|
||||
list(APPEND FURNITURE_ATLAS_PARTS ${ATLAS_PART})
|
||||
endforeach()
|
||||
|
||||
# One shared runtime atlas/material for all furniture sources. Each source
|
||||
# owns a deterministic vertical band, so rebuilds cannot collide or reorder.
|
||||
set(FURNITURE_ATLAS_PNG
|
||||
${CMAKE_BINARY_DIR}/resources/buildings/parts/furniture-atlas.png)
|
||||
set(FURNITURE_ATLAS_MATERIAL
|
||||
${CMAKE_BINARY_DIR}/resources/buildings/parts/furniture-atlas.material)
|
||||
add_custom_command(
|
||||
OUTPUT ${FURNITURE_ATLAS_PNG} ${FURNITURE_ATLAS_MATERIAL}
|
||||
COMMAND ${CMAKE_COMMAND} -E rm -f
|
||||
${FURNITURE_ATLAS_PNG} ${FURNITURE_ATLAS_MATERIAL}
|
||||
COMMAND ${BLENDER} --factory-startup -b -P
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/merge_furniture_atlases.py --
|
||||
${FURNITURE_ATLAS_PNG} ${FURNITURE_ATLAS_MATERIAL}
|
||||
${FURNITURE_ATLAS_PARTS}
|
||||
COMMAND ${CMAKE_COMMAND} -D FILE=${FURNITURE_ATLAS_PNG}
|
||||
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
|
||||
COMMAND ${CMAKE_COMMAND} -D FILE=${FURNITURE_ATLAS_MATERIAL}
|
||||
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
|
||||
DEPENDS ${FURNITURE_ATLAS_PARTS}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/merge_furniture_atlases.py)
|
||||
|
||||
set(FURNITURE_EXPORT_STAMPS)
|
||||
foreach(FURNITURE_FILE ${FURNITURE_FILES})
|
||||
get_filename_component(FILE_NAME ${FURNITURE_FILE} NAME_WE)
|
||||
set(PARTS_OUTPUT_DIR ${CMAKE_BINARY_DIR}/resources/buildings/parts/${FILE_NAME})
|
||||
set(BAKED_BLEND ${CMAKE_CURRENT_BINARY_DIR}/${FILE_NAME}_baked.blend)
|
||||
set(EXPORT_SUCCESS ${CMAKE_CURRENT_BINARY_DIR}/${FILE_NAME}.export_success)
|
||||
add_custom_command(
|
||||
OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/${FILE_NAME}_baked.blend
|
||||
COMMAND ${CMAKE_COMMAND} -E copy ${CMAKE_CURRENT_SOURCE_DIR}/${FURNITURE_FILE} ${CMAKE_CURRENT_BINARY_DIR}/${FURNITURE_FILE}
|
||||
COMMAND ${BLENDER} -b ${CMAKE_CURRENT_BINARY_DIR}/${FURNITURE_FILE} -Y -P
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/bake_furniture.py
|
||||
DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/${FURNITURE_FILE} ${CMAKE_CURRENT_SOURCE_DIR}/bake_furniture.py)
|
||||
add_custom_command(
|
||||
OUTPUT ${PARTS_OUTPUT_DIR}
|
||||
OUTPUT ${PARTS_OUTPUT_DIR} ${EXPORT_SUCCESS}
|
||||
COMMAND ${CMAKE_COMMAND} -E rm -rf ${PARTS_OUTPUT_DIR}
|
||||
COMMAND ${CMAKE_COMMAND} -E rm -f ${EXPORT_SUCCESS}
|
||||
COMMAND ${CMAKE_COMMAND} -E make_directory ${PARTS_OUTPUT_DIR}
|
||||
COMMAND ${BLENDER} -b ${CMAKE_CURRENT_BINARY_DIR}/${FILE_NAME}_baked.blend
|
||||
COMMAND ${BLENDER} -b ${BAKED_BLEND}
|
||||
-Y -P
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/export_furniture_parts.py
|
||||
-- ${PARTS_OUTPUT_DIR}
|
||||
DEPENDS ${CMAKE_CURRENT_BINARY_DIR}/${FILE_NAME}_baked.blend ${CMAKE_CURRENT_SOURCE_DIR}/export_furniture_parts.py)
|
||||
list(APPEND PARTS_OUTPUT_DIRS ${PARTS_OUTPUT_DIR})
|
||||
-- ${PARTS_OUTPUT_DIR} ${EXPORT_SUCCESS}
|
||||
COMMAND ${CMAKE_COMMAND} -D FILE=${EXPORT_SUCCESS}
|
||||
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
|
||||
DEPENDS ${BAKED_BLEND}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/export_furniture_parts.py
|
||||
${FURNITURE_ATLAS_PNG} ${FURNITURE_ATLAS_MATERIAL})
|
||||
list(APPEND PARTS_OUTPUT_DIRS ${PARTS_OUTPUT_DIR} ${EXPORT_SUCCESS})
|
||||
list(APPEND FURNITURE_EXPORT_STAMPS ${EXPORT_SUCCESS})
|
||||
endforeach()
|
||||
|
||||
# Validate the shared-atlas contract after every source has exported. This
|
||||
# catches embedded texture copies, UVs escaping the source band and shared
|
||||
# Blender mesh datablocks changing UVs between furniture variants.
|
||||
set(FURNITURE_VALIDATION
|
||||
${CMAKE_CURRENT_BINARY_DIR}/furniture-export.validated)
|
||||
add_custom_command(
|
||||
OUTPUT ${FURNITURE_VALIDATION}
|
||||
COMMAND ${CMAKE_COMMAND} -E rm -f ${FURNITURE_VALIDATION}
|
||||
COMMAND ${PYTHON3_EXECUTABLE}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/validate_furniture_export.py
|
||||
--root ${CMAKE_BINARY_DIR}/resources/buildings/parts
|
||||
--source furniture 0 ${FURNITURE_COUNT}
|
||||
--source furniture-sofa 1 ${FURNITURE_COUNT}
|
||||
COMMAND ${CMAKE_COMMAND} -E touch ${FURNITURE_VALIDATION}
|
||||
DEPENDS ${FURNITURE_EXPORT_STAMPS} ${FURNITURE_ATLAS_PNG}
|
||||
${FURNITURE_ATLAS_MATERIAL}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/validate_furniture_export.py)
|
||||
list(APPEND PARTS_OUTPUT_DIRS ${FURNITURE_VALIDATION}
|
||||
${FURNITURE_ATLAS_PNG} ${FURNITURE_ATLAS_MATERIAL})
|
||||
add_custom_target(import_building_parts ALL DEPENDS ${PARTS_OUTPUT_DIRS})
|
||||
|
||||
# Phase 6.5.B: opt-in preview gallery (kept out of the default build
|
||||
# because rendering every asset is slow). Renders each root with its
|
||||
# pivot, physical/collision bounds, occupancy cells and POI markers,
|
||||
# then writes an HTML index with the corresponding atlas UV rectangles.
|
||||
set(FURNITURE_GALLERY_DIR
|
||||
${CMAKE_BINARY_DIR}/resources/buildings/parts/gallery)
|
||||
add_custom_target(furniture_gallery
|
||||
COMMAND ${CMAKE_COMMAND} -E make_directory ${FURNITURE_GALLERY_DIR}
|
||||
COMMAND ${BLENDER} -b
|
||||
${CMAKE_CURRENT_BINARY_DIR}/furniture_baked.blend -Y -P
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/render_furniture_gallery.py --
|
||||
${FURNITURE_GALLERY_DIR}
|
||||
${CMAKE_BINARY_DIR}/resources/buildings/parts/furniture
|
||||
COMMAND ${BLENDER} -b
|
||||
${CMAKE_CURRENT_BINARY_DIR}/furniture-sofa_baked.blend -Y -P
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/render_furniture_gallery.py --
|
||||
${FURNITURE_GALLERY_DIR}
|
||||
${CMAKE_BINARY_DIR}/resources/buildings/parts/furniture-sofa
|
||||
COMMAND ${PYTHON3_EXECUTABLE}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/generate_furniture_gallery_index.py
|
||||
--root ${FURNITURE_GALLERY_DIR}
|
||||
DEPENDS ${FURNITURE_EXPORT_STAMPS} ${FURNITURE_ATLAS_PNG}
|
||||
${FURNITURE_ATLAS_MATERIAL}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/render_furniture_gallery.py
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/generate_furniture_gallery_index.py)
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
# Furniture modeling and Blender export
|
||||
|
||||
This document describes the current furniture authoring path. The remaining
|
||||
room-composition, DSL and Lua work is tracked in
|
||||
`src/features/editScene/demos/demo-taxi/PLAN.md`, Phase 6.5.
|
||||
|
||||
Blender is the fast path for reusable furniture assets. Keep geometry, UVs,
|
||||
the placement pivot, physical bounds and asset-local interaction markers with
|
||||
the asset. Do not turn room layouts or game behavior into a large collection
|
||||
of Blender custom properties and empties: reusable room arrangements belong to
|
||||
the shared layout evaluator/DSL, while unique or behavior-heavy interiors use
|
||||
prefab ECS components and Lua.
|
||||
|
||||
## Pipeline files
|
||||
|
||||
- `edit-firniture-props.py` (historical file-name typo) provides the Blender
|
||||
**Furniture Controls** and **Action Controls** panels.
|
||||
- `bake_furniture.py` bakes one source blend into a deterministic vertical
|
||||
atlas band and assigns `M_Furniture_Atlas` to its visual meshes.
|
||||
- `merge_furniture_atlases.py` stacks those bands into the shared
|
||||
`furniture-atlas.png` and writes `furniture-atlas.material`.
|
||||
- `export_furniture_parts.py` exports every root object named `furniture-*`
|
||||
as GLB and writes a schema-v1 furniture-definition JSON containing bounds,
|
||||
layer, placement rules, source hash and interaction markers.
|
||||
- `validate_furniture_export.py` checks the generated GLB/JSON artifacts:
|
||||
schema references, shared material, no embedded images, UVs inside the
|
||||
source's atlas band, and identical UV bounds across variants sharing a
|
||||
Blender mesh datablock.
|
||||
- `CMakeLists.txt` runs baking, atlas merging, exporting and validation with
|
||||
success
|
||||
markers, so a Blender Python exception cannot leave stale output looking
|
||||
current. `FURNITURE_FILES` currently lists `furniture.blend` and
|
||||
`furniture-sofa.blend`.
|
||||
|
||||
For `furniture.blend`, furniture output goes to
|
||||
`build*/resources/buildings/parts/furniture/`; `furniture-sofa.blend` uses
|
||||
`resources/buildings/parts/furniture-sofa/`. The shared atlas and material
|
||||
are written once to `resources/buildings/parts/`:
|
||||
|
||||
```text
|
||||
furniture-<source-id>.glb
|
||||
furniture-<source-id>.glb.json
|
||||
resources/buildings/parts/furniture-atlas.png
|
||||
resources/buildings/parts/furniture-atlas.material
|
||||
```
|
||||
|
||||
The GLB keeps the `M_Furniture_Atlas` material name but deliberately embeds
|
||||
no image; OGRE loads the shared material from `furniture-atlas.material`.
|
||||
JSON files are generated output: edit the `.blend` source and re-export
|
||||
instead of hand-editing build artifacts.
|
||||
|
||||
## Author a furniture root
|
||||
|
||||
1. Open the source blend and run `edit-firniture-props.py` from Blender's
|
||||
Text Editor to register the helper panels.
|
||||
2. Create a root **Empty** named `furniture-<id>`. Parent all visual meshes
|
||||
to it. Keep the root origin at the intended placement pivot and use the
|
||||
same forward orientation as the existing assets.
|
||||
3. Select the root and use **Furniture Controls -> Create Furniture**.
|
||||
Available asset-local properties are:
|
||||
- `furniture_id` — stable definition id; defaults to the root suffix.
|
||||
It survives a cosmetic Blender object rename. Legacy roots named
|
||||
`furniture--<id>` export as `<id>-legacy` with the old name kept as an
|
||||
alias.
|
||||
- `display_name` — optional UI-facing name.
|
||||
- `furniture_tags` — comma-separated selection tags such as
|
||||
`bedroom, essential` or `livingroom, filler`.
|
||||
- `layer` — `normal`, `thin-floor`, `thin-wall` or `ceiling`.
|
||||
- `selection_weight` — non-negative weighted-random factor.
|
||||
- `requires_floor` / `requires_wall` — placement constraints.
|
||||
- `clearance` — extra occupied-footprint margin in metres.
|
||||
- `footprint_x` / `footprint_z` — optional occupancy override in grid
|
||||
cells; leave both at 0 to derive occupancy from mesh bounds at the
|
||||
target grid's cell size.
|
||||
- `collision_mesh` — optional explicit mesh object to use as simplified
|
||||
collision bounds. Meshes named `collision-*` are also treated as
|
||||
collision-only and are not exported as render geometry.
|
||||
4. Keep the asset reusable. A chair, table, counter section, shelf or
|
||||
repair-bay fixture is an asset; where several such assets belong in a
|
||||
particular room is a layout concern, not root-object metadata.
|
||||
|
||||
The exporter computes `bounds` from the visual mesh hierarchy in root-local
|
||||
space. If collision meshes exist, it also emits `collisionBounds`. Existing
|
||||
`foo-col` mesh names are **not** assumed to be collision because some legacy
|
||||
assets use that suffix for visible geometry; use `collision-*` or the explicit
|
||||
`collision_mesh` property for new collision proxies.
|
||||
|
||||
Runtime layer conventions: normal items use `collisionBounds` when present,
|
||||
otherwise physical `bounds`, for their generated collider; thin/ceiling
|
||||
layers never collide or obstruct the navmesh. A thin-wall root's origin is
|
||||
its attachment point and its quarter-turn placement selects the wall plane
|
||||
(internal inset 0.1 m, external 0.2 m outside the cell). A ceiling root's
|
||||
origin is the ceiling attachment point (place geometry below it, e.g. the
|
||||
ceiling light's negative local Z in Blender).
|
||||
|
||||
## Add local interaction markers
|
||||
|
||||
Simple asset-local interactions stay in Blender:
|
||||
|
||||
1. Add an **Empty** named `action-<name>` as a **direct child** of the
|
||||
furniture root. The helper offers Use and Sit actions and initializes
|
||||
`name`, `action`, `action_text`, `height`, `radius` and `tags`.
|
||||
2. Position/orient the action empty where the interaction occurs. Set its
|
||||
action name and interaction dimensions in **Action Controls**.
|
||||
3. Add optional `position-<name>` empties below that action for named
|
||||
approach/enter/exit transforms. The helper can create enter/exit markers.
|
||||
Root-level `position-*` children are also exported.
|
||||
4. The exporter additionally preserves action custom properties prefixed
|
||||
`goap_prereq_` and `goap_effect_`.
|
||||
|
||||
Only direct root/action children are discovered by the exporter, so preserve
|
||||
that hierarchy. At runtime `CellGridSystem` turns exported action markers
|
||||
into grid-owned entities carrying both `ActuatorComponent` and
|
||||
`SmartObjectComponent`; nested/root position markers become named runtime
|
||||
transforms without an actuator. Every marker has a stable id
|
||||
`furniture.<placementId>.<markerName>` and can be found through
|
||||
`CellGridSystem::queryFurniturePois` / `findFurniturePoi` by room,
|
||||
furniture tag, action, marker type or grid. Use these markers for local facts such as "sit here" or
|
||||
"stand at this side of the counter". Store work rules, repair behavior,
|
||||
cross-entity references and room-dependent relationships belong to the
|
||||
ECS/Lua layer instead.
|
||||
|
||||
## Bake and export
|
||||
|
||||
Add a new source blend to `FURNITURE_FILES` in this directory's
|
||||
`CMakeLists.txt`, then run:
|
||||
|
||||
```bash
|
||||
cmake --build build-vscode --target import_building_parts -j4
|
||||
```
|
||||
|
||||
The build performs these steps:
|
||||
|
||||
1. Copy the source blend to the build tree.
|
||||
2. Bake its visual meshes into that source's deterministic atlas band.
|
||||
3. Remap its UVs into that band.
|
||||
4. Merge all source bands into one shared atlas and material.
|
||||
5. Export GLBs without embedded texture copies, then write schema-v1 JSON.
|
||||
6. Validate the GLB/JSON set, including UV bands and shared mesh datablock
|
||||
variants.
|
||||
7. Check per-step success marker files so failed Blender scripts fail the
|
||||
build instead of leaving stale artifacts.
|
||||
|
||||
`src/features/editScene/resources.cfg` indexes
|
||||
`resources/buildings/parts`, `resources/buildings/parts/furniture` and
|
||||
`resources/buildings/parts/furniture-sofa`, so the shared material and both
|
||||
current definition sets are loadable. At runtime `FurnitureLibrary`
|
||||
discovers `furniture-*.json`, validates schema/identity/bounds and exposes
|
||||
stable ids and aliases.
|
||||
|
||||
To add the phase 6.5 layer fixtures to the existing source (idempotent),
|
||||
then export and render the preview gallery:
|
||||
|
||||
```bash
|
||||
../../blender-bin/bin/blender -b assets/blender/buildings/parts/furniture.blend \
|
||||
-Y -P assets/blender/buildings/parts/add_furniture_fixtures.py
|
||||
cmake --build build-vscode --target import_building_parts -j2
|
||||
cmake --build build-vscode --target furniture_gallery -j2
|
||||
```
|
||||
|
||||
The gallery is written to `resources/buildings/parts/gallery/index.html`:
|
||||
each asset is rendered at its export pivot with physical bounds (green),
|
||||
collision bounds (red), occupancy cells (blue), action/position markers
|
||||
and an atlas UV rectangle. `render_furniture_gallery.py` and
|
||||
`generate_furniture_gallery_index.py` are the generators. The gallery
|
||||
target is opt-in so routine builds do not render every asset.
|
||||
|
||||
## Choosing the authoring layer
|
||||
|
||||
| Need | Preferred home |
|
||||
| --- | --- |
|
||||
| Mesh, UVs, atlas, pivot and physical bounds | Blender |
|
||||
| Optional simplified collision mesh | Blender (`collision-*` or `collision_mesh`) |
|
||||
| Simple local sit/use/action and approach markers | Blender |
|
||||
| Single-mesh or multi-cell placement | Furniture definition + shared C++ kernel |
|
||||
| Multi-entity ECS behavior or custom components | Editor prefab, reusing the Blender asset |
|
||||
| Prefab-backed furniture | `prefabs/furniture.<name>.json` plus a definition whose `prefab` points at it; Create Prefab's **Furniture prefab** mode generates the companion definition with tags, layer, measured bounds and a deterministic prefab source hash |
|
||||
| Reusable dining sets, counter/shelf rows, room-fit rules | Shared room-composition evaluator and layout DSL (Phase 6.5.F) |
|
||||
| Unique store/garage layouts, scripted actions, exterior-entry wiring | Lua plus normal door/actuator/vehicle APIs (Phase 6.5.H) |
|
||||
| Occupancy, navigation, identity and lifecycle invariants | Shared C++ furniture kernel |
|
||||
|
||||
A table with chairs around it should not require a bespoke Blender empty
|
||||
hierarchy: place the table at a room anchor and generate the chairs relative
|
||||
to it through the shared composition evaluator. Likewise, a store or garage
|
||||
uses Blender-built counters, shelves and fixtures, but its working behavior
|
||||
and exterior-entry setup should remain readable DSL/Lua/ECS data.
|
||||
|
||||
Prefab instances placed through furniture definitions are runtime children of
|
||||
the CellGrid. They are not editor-selectable scene entities; non-normal
|
||||
layers strip physics components from the instantiated subtree, while normal
|
||||
prefabs keep their authored colliders.
|
||||
|
||||
## Layout DSL metadata bridge
|
||||
|
||||
Room declarations can record furniture-selection tags without hardcoding a
|
||||
placement algorithm:
|
||||
|
||||
```lisp
|
||||
(room shop (rect 0 0 1 1)
|
||||
:type store
|
||||
:furniture (shop|market counter !display))
|
||||
```
|
||||
|
||||
Entries in the `:furniture` list are ANDed. `a|b` accepts either tag and
|
||||
`!a` rejects a tag. The same term syntax works in RoomComponent tag lists.
|
||||
`BuildingPlan::roomFurnitureTags` stores the list parallel to
|
||||
`roomNames`/`roomTypes`, and the road geometry test suite covers the round
|
||||
trip through layout evaluation.
|
||||
|
||||
Full/interiorOnly grids materialize those plan rooms as RoomComponents
|
||||
(`TownGenSystem::createPlanRooms`), so the same RoomLayoutSystem furnishes
|
||||
DSL-authored and node-authored rooms. Reusable arrangements use the
|
||||
`:compose` room option instead of bespoke Blender hierarchies:
|
||||
|
||||
```lisp
|
||||
:compose (
|
||||
(group (livingroom nowall) :at center
|
||||
(child (lounge filler) -2 -2 0)
|
||||
(child (lounge filler) 2 -2 0)
|
||||
(child (lounge filler) -2 2 0)
|
||||
(child (lounge filler) 2 2 0))
|
||||
(row (nowindow optional) :at zpos :step 2 :count 2))
|
||||
```
|
||||
|
||||
A `group` is one root plus children at cell offsets (rotated with the
|
||||
root); the whole group fits or rolls back atomically. A `row` walks a
|
||||
wall edge with `step`/`skip`/`count` and skips blocked cells. Anchor is
|
||||
`center` or `zneg`/`zpos`/`xneg`/`xpos`; `:rot 0..3` overrides the root
|
||||
rotation (default: edge anchors face into the room). Tags are selection
|
||||
terms, not definition ids — check the exported `tags` array. The same
|
||||
rules are serialized on RoomComponent and editable in its Furniture
|
||||
Compositions panel.
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Add the phase 6.5 layer fixtures to furniture.blend (idempotent).
|
||||
|
||||
Run from the project root:
|
||||
../../blender-bin/bin/blender -b assets/blender/buildings/parts/furniture.blend \
|
||||
-Y -P assets/blender/buildings/parts/add_furniture_fixtures.py
|
||||
|
||||
The regular import_building_parts target then bakes/exports them. No
|
||||
room-placement rules live here: layers, bounds and (for the shelf) a
|
||||
simplified collision proxy are the only asset-local metadata.
|
||||
"""
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
def material(name, color):
|
||||
existing = bpy.data.materials.get(name)
|
||||
if existing:
|
||||
return existing
|
||||
mat = bpy.data.materials.new(name)
|
||||
mat.use_nodes = True
|
||||
bsdf = mat.node_tree.nodes.get("Principled BSDF")
|
||||
bsdf.inputs["Base Color"].default_value = (*color, 1.0)
|
||||
bsdf.inputs["Roughness"].default_value = 1.0
|
||||
return mat
|
||||
|
||||
|
||||
def make_root(name, tags, layer, requires_floor, requires_wall):
|
||||
existing = bpy.data.objects.get(name)
|
||||
if existing:
|
||||
return existing, False
|
||||
root = bpy.data.objects.new(name, None)
|
||||
root.empty_display_type = 'PLAIN_AXES'
|
||||
root.empty_display_size = 0.15
|
||||
bpy.context.scene.collection.objects.link(root)
|
||||
root["furniture_id"] = name.replace("furniture-", "")
|
||||
root["display_name"] = root["furniture_id"]
|
||||
root["furniture_tags"] = tags
|
||||
root["layer"] = layer
|
||||
root["selection_weight"] = 1.0
|
||||
root["requires_floor"] = requires_floor
|
||||
root["requires_wall"] = requires_wall
|
||||
root["clearance"] = 0.0
|
||||
root["footprint_x"] = 0
|
||||
root["footprint_z"] = 0
|
||||
return root, True
|
||||
|
||||
|
||||
def box(root, name, location, dimensions, mat, collision=False):
|
||||
bpy.ops.mesh.primitive_cube_add(size=1.0)
|
||||
obj = bpy.context.object
|
||||
obj.name = name
|
||||
obj.dimensions = dimensions
|
||||
bpy.ops.object.transform_apply(location=False, rotation=False,
|
||||
scale=True)
|
||||
obj.parent = root
|
||||
obj.location = location
|
||||
if mat and not collision:
|
||||
obj.data.materials.append(mat)
|
||||
if collision:
|
||||
obj.display_type = 'WIRE'
|
||||
return obj
|
||||
|
||||
|
||||
def ensure_attachment(root):
|
||||
"""A root-local marker used by the runtime layer-transform checks."""
|
||||
if any(child.name == "position-attach" for child in root.children):
|
||||
return
|
||||
marker = bpy.data.objects.new("position-attach", None)
|
||||
marker.empty_display_size = 0.05
|
||||
bpy.context.scene.collection.objects.link(marker)
|
||||
marker.parent = root
|
||||
marker.location = (0, 0, 0)
|
||||
marker["name"] = "attach"
|
||||
marker["type"] = "attachment"
|
||||
|
||||
|
||||
fabric = material("FixtureFabric", (0.34, 0.08, 0.08))
|
||||
trim = material("FixtureTrim", (0.12, 0.07, 0.04))
|
||||
plastic = material("FixturePlastic", (0.82, 0.82, 0.78))
|
||||
accent = material("FixtureAccent", (0.08, 0.25, 0.65))
|
||||
picture = material("FixturePicture", (0.15, 0.45, 0.28))
|
||||
light = material("FixtureLight", (1.0, 0.87, 0.55))
|
||||
wood = material("FixtureWood", (0.42, 0.25, 0.12))
|
||||
|
||||
root, created = make_root("furniture-carpet1",
|
||||
"livingroom, bedroom, filler", "thin-floor",
|
||||
True, False)
|
||||
if created:
|
||||
box(root, "CarpetBody", (0, 0, 0.01), (1.6, 1.2, 0.02), fabric)
|
||||
box(root, "CarpetInset", (0, 0, 0.021), (1.35, 0.95, 0.004), trim)
|
||||
ensure_attachment(root)
|
||||
|
||||
root, created = make_root("furniture-switch1", "office, utility, optional",
|
||||
"thin-wall", False, True)
|
||||
if created:
|
||||
box(root, "SwitchPlate", (0, 0, 0), (0.12, 0.04, 0.18), plastic)
|
||||
box(root, "SwitchToggle", (0, 0.025, 0), (0.035, 0.02, 0.08), trim)
|
||||
ensure_attachment(root)
|
||||
|
||||
root, created = make_root("furniture-picture1", "livingroom, office, optional",
|
||||
"thin-wall", False, True)
|
||||
if created:
|
||||
box(root, "PictureFrame", (0, 0, 0), (0.8, 0.05, 0.55), trim)
|
||||
box(root, "PictureCanvas", (0, 0.03, 0), (0.68, 0.012, 0.43), picture)
|
||||
box(root, "PictureMark", (0.12, 0.038, 0.02),
|
||||
(0.25, 0.006, 0.18), accent)
|
||||
|
||||
root, created = make_root("furniture-ceiling-light1",
|
||||
"livingroom, office, essential", "ceiling",
|
||||
False, False)
|
||||
if created:
|
||||
box(root, "CeilingLightHousing", (0, 0, -0.06), (0.6, 0.6, 0.12),
|
||||
plastic)
|
||||
box(root, "CeilingLightDiffuser", (0, 0, -0.13),
|
||||
(0.45, 0.45, 0.03), light)
|
||||
ensure_attachment(root)
|
||||
|
||||
root, created = make_root("furniture-shop-shelf1", "shop, display, important",
|
||||
"normal", True, False)
|
||||
if created:
|
||||
box(root, "ShelfBase", (0, 0, 0.05), (1.5, 0.4, 0.1), wood)
|
||||
box(root, "ShelfBack", (0, -0.17, 0.75), (1.5, 0.06, 1.5), wood)
|
||||
for x in (-0.72, 0.72):
|
||||
box(root, "ShelfSide", (x, 0, 0.75), (0.06, 0.4, 1.5), wood)
|
||||
for z in (0.5, 1.0, 1.5):
|
||||
box(root, "ShelfBoard", (0, 0, z), (1.5, 0.4, 0.05), wood)
|
||||
collision = box(root, "collision-shelf", (0, 0, 0.75),
|
||||
(1.5, 0.4, 1.5), None, collision=True)
|
||||
root["collision_mesh"] = collision.name
|
||||
|
||||
bpy.ops.wm.save_as_mainfile(filepath=bpy.data.filepath)
|
||||
print("Phase 6.5 furniture fixtures present.")
|
||||
@@ -1,9 +1,27 @@
|
||||
import bpy
|
||||
import os
|
||||
import sys
|
||||
|
||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
ATLAS_OUTPUT = argv[0] if argv else ""
|
||||
ATLAS_INDEX = int(argv[1]) if len(argv) > 1 else 0
|
||||
ATLAS_COUNT = int(argv[2]) if len(argv) > 2 else 1
|
||||
SUCCESS_OUTPUT = argv[3] if len(argv) > 3 else ""
|
||||
if ATLAS_INDEX < 0 or ATLAS_INDEX >= ATLAS_COUNT:
|
||||
raise RuntimeError("invalid furniture atlas band index")
|
||||
|
||||
|
||||
def is_collision_name(name):
|
||||
# Existing assets also use "-col" for some VISUAL meshes, so only the
|
||||
# explicit collision-* prefix is excluded automatically.
|
||||
return name.lower().split(".", 1)[0].startswith("collision-")
|
||||
|
||||
|
||||
def get_all_children_recursive(obj, mesh_list):
|
||||
"""Recursively finds all mesh objects in the hierarchy."""
|
||||
"""Recursively finds visual mesh objects in the hierarchy."""
|
||||
for child in obj.children:
|
||||
if child.type == 'MESH' and child.data.uv_layers:
|
||||
if child.type == 'MESH' and child.data.uv_layers and \
|
||||
not is_collision_name(child.name):
|
||||
mesh_list.append(child)
|
||||
get_all_children_recursive(child, mesh_list)
|
||||
|
||||
@@ -11,7 +29,8 @@ def setup_furniture_atlas():
|
||||
# 1. Gather valid objects: Children of empties starting with "furniture-"
|
||||
|
||||
furniture_meshes = []
|
||||
roots = [o for o in bpy.data.objects if o.name.startswith("furniture-")]
|
||||
roots = [o for o in bpy.context.scene.objects
|
||||
if o.parent is None and o.name.startswith("furniture-")]
|
||||
|
||||
for root in roots:
|
||||
# If the root itself is a mesh with UVs, add it
|
||||
@@ -20,18 +39,15 @@ def setup_furniture_atlas():
|
||||
# Find all nested children
|
||||
get_all_children_recursive(root, furniture_meshes)
|
||||
|
||||
# Remove duplicates if any (in case of complex parenting)
|
||||
furniture_meshes = list(set(furniture_meshes))
|
||||
# Remove duplicates while preserving scene order.
|
||||
seen = set()
|
||||
furniture_meshes = [obj for obj in furniture_meshes
|
||||
if not (obj.name in seen or seen.add(obj.name))]
|
||||
|
||||
if not furniture_meshes:
|
||||
print("No recursive mesh objects found with UVs.")
|
||||
return
|
||||
raise RuntimeError("No visual furniture mesh objects found with UVs.")
|
||||
|
||||
furniture_objs = furniture_meshes
|
||||
|
||||
if not furniture_objs:
|
||||
print("No valid furniture mesh objects found with UVs.")
|
||||
return
|
||||
|
||||
# 2. Manage the Atlas Image (FurnitureColor)
|
||||
image_name = "FurnitureColor"
|
||||
@@ -74,7 +90,10 @@ def setup_furniture_atlas():
|
||||
scene.render.bake.use_pass_indirect = False
|
||||
scene.render.bake.use_pass_color = True
|
||||
scene.render.bake.target = 'IMAGE_TEXTURES'
|
||||
|
||||
# Empty atlas space stays transparent; the merge step stacks the
|
||||
# per-source bakes into deterministic vertical bands.
|
||||
scene.render.film_transparent = True
|
||||
|
||||
print("Baking Furniture Atlas...")
|
||||
override = {
|
||||
'active_object': bpy.context.view_layer.objects.active,
|
||||
@@ -83,6 +102,13 @@ def setup_furniture_atlas():
|
||||
|
||||
with bpy.context.temp_override(**override):
|
||||
bpy.ops.object.bake(type='DIFFUSE', margin=2)
|
||||
|
||||
if ATLAS_OUTPUT:
|
||||
os.makedirs(os.path.dirname(ATLAS_OUTPUT), exist_ok=True)
|
||||
atlas_img.filepath_raw = ATLAS_OUTPUT
|
||||
atlas_img.file_format = 'PNG'
|
||||
atlas_img.save()
|
||||
print("Partial atlas saved to: " + ATLAS_OUTPUT)
|
||||
atlas_img.pack()
|
||||
|
||||
# 6. Create and Assign Final Atlas Material
|
||||
@@ -106,7 +132,31 @@ def setup_furniture_atlas():
|
||||
obj.data.materials.clear()
|
||||
obj.data.materials.append(atlas_mat)
|
||||
|
||||
print("Process Complete. Atlas material applied.")
|
||||
# Put this source file into its deterministic vertical atlas band.
|
||||
# The merge script stacks the per-source bakes in the same order.
|
||||
if ATLAS_COUNT > 1:
|
||||
remapped_mesh_data = set()
|
||||
for obj in furniture_objs:
|
||||
# Variants such as table1/table2 and pole1/pole2 share mesh
|
||||
# datablocks. UV layers belong to the mesh data, so remap each
|
||||
# datablock once; otherwise the band transform compounds.
|
||||
if obj.data.name in remapped_mesh_data:
|
||||
continue
|
||||
remapped_mesh_data.add(obj.data.name)
|
||||
for uv_layer in obj.data.uv_layers:
|
||||
for loop in uv_layer.data:
|
||||
u, v = loop.uv
|
||||
if u < -0.001 or u > 1.001 or \
|
||||
v < -0.001 or v > 1.001:
|
||||
raise RuntimeError(
|
||||
"Furniture UV escapes the source atlas on " +
|
||||
obj.name)
|
||||
loop.uv = (min(max(u, 0.0), 1.0),
|
||||
(min(max(v, 0.0), 1.0) + ATLAS_INDEX) /
|
||||
ATLAS_COUNT)
|
||||
|
||||
print("Process Complete. Atlas material applied; UV band %d/%d." %
|
||||
(ATLAS_INDEX + 1, ATLAS_COUNT))
|
||||
for mat in bpy.data.materials:
|
||||
if mat.users == 0:
|
||||
bpy.data.materials.remove(mat)
|
||||
@@ -118,8 +168,11 @@ def setup_furniture_atlas():
|
||||
new_path = current_path.replace(".blend", "_baked.blend")
|
||||
bpy.ops.wm.save_as_mainfile(filepath=new_path)
|
||||
print(f"File saved to: {new_path}")
|
||||
if SUCCESS_OUTPUT:
|
||||
with open(SUCCESS_OUTPUT, "w", newline="\n") as marker:
|
||||
marker.write("ok\n")
|
||||
else:
|
||||
print("Warning: File not saved (unsaved blend file).")
|
||||
raise RuntimeError("Furniture blend was not saved")
|
||||
|
||||
setup_furniture_atlas()
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
if(EXISTS "${FILE}")
|
||||
file(SIZE "${FILE}" FILE_SIZE)
|
||||
if(FILE_SIZE GREATER 0)
|
||||
message(STATUS "Build-time check: ${FILE} exists and is not empty.")
|
||||
else()
|
||||
message(FATAL_ERROR "Build-time check: ${FILE} exists but is empty!")
|
||||
endif()
|
||||
else()
|
||||
message(FATAL_ERROR "Build-time check: ${FILE} does not exist!")
|
||||
endif()
|
||||
@@ -2,7 +2,22 @@ import bpy
|
||||
import mathutils
|
||||
|
||||
ACTION_PROPS = ["name", "action", "action_text", "height", "radius", "tags"]
|
||||
FURNITURE_PROPS = ["name", "furniture_tags"]
|
||||
# Asset-local furniture metadata exported to furniture-*.json. Room
|
||||
# placement/composition deliberately stays out of these properties; the
|
||||
# layout DSL/Lua handles table sets, stores, garages and similar setups.
|
||||
FURNITURE_PROPS = [
|
||||
"furniture_id",
|
||||
"display_name",
|
||||
"furniture_tags",
|
||||
"layer",
|
||||
"selection_weight",
|
||||
"requires_floor",
|
||||
"requires_wall",
|
||||
"clearance",
|
||||
"footprint_x",
|
||||
"footprint_z",
|
||||
"collision_mesh",
|
||||
]
|
||||
|
||||
test_tracking = {"lib_name": None}
|
||||
|
||||
@@ -122,23 +137,26 @@ class OBJECT_OT_CreateFurnitureProps(bpy.types.Operator):
|
||||
|
||||
def execute(self, context):
|
||||
obj = context.object
|
||||
defaults = {
|
||||
"furniture_id": obj.name.replace("furniture-", "").strip("-"),
|
||||
"display_name": obj.name.replace("furniture-", "").replace("-", " ").title(),
|
||||
"furniture_tags": "",
|
||||
"layer": "normal",
|
||||
"selection_weight": 1.0,
|
||||
"requires_floor": True,
|
||||
"requires_wall": False,
|
||||
"clearance": 0.0,
|
||||
"footprint_x": 0, # 0 = derive from mesh bounds
|
||||
"footprint_z": 0,
|
||||
"collision_mesh": "", # optional collision-* mesh name
|
||||
}
|
||||
# Older assets only had the now-obsolete custom "name" property;
|
||||
# use it once as the stable id instead of guessing tags from names.
|
||||
if "furniture_id" not in obj and "name" in obj:
|
||||
defaults["furniture_id"] = str(obj["name"]).strip("-")
|
||||
for p in FURNITURE_PROPS:
|
||||
if p not in obj:
|
||||
if p == "name":
|
||||
obj[p] = obj.name.replace("furniture-","")
|
||||
elif p == "furniture_tags":
|
||||
obj[p] = ""
|
||||
otags = []
|
||||
if obj.name.find("toilet") >= 0:
|
||||
otags.append("wc")
|
||||
otags.append("bathroom")
|
||||
otags.append("toilet")
|
||||
elif obj.name.find("bed") >= 0:
|
||||
otags.append("bed")
|
||||
otags.append("bedroom")
|
||||
obj[p] = ", ".join(otags)
|
||||
else:
|
||||
obj[p] = "" # Initialize as empty text string
|
||||
obj[p] = defaults[p]
|
||||
return {'FINISHED'}
|
||||
|
||||
class OBJECT_OT_CreateChildEmpties(bpy.types.Operator):
|
||||
@@ -324,7 +342,7 @@ class OBJECT_PT_FurnitureEmptyPanel(bpy.types.Panel):
|
||||
else:
|
||||
# Show the text fields if properties exist
|
||||
box = layout.box()
|
||||
box.label(text="Furnitore Settings", icon='PROPERTIES')
|
||||
box.label(text="Furniture Settings", icon='PROPERTIES')
|
||||
for p in FURNITURE_PROPS:
|
||||
box.prop(obj, f'["{p}"]', text=p.replace("_", " ").title())
|
||||
op_use = layout.operator("object.create_child_action_node", icon='EMPTY_DATA', text="Add Use Action Node")
|
||||
@@ -346,8 +364,8 @@ def register():
|
||||
|
||||
|
||||
def unregister():
|
||||
bpy.utils.register_class(OBJECT_OT_CreateActionProps)
|
||||
bpy.utils.register_class(OBJECT_OT_CreateFurnitureProps)
|
||||
bpy.utils.unregister_class(OBJECT_OT_CreateActionProps)
|
||||
bpy.utils.unregister_class(OBJECT_OT_CreateFurnitureProps)
|
||||
bpy.utils.unregister_class(OBJECT_PT_ActionEmptyPanel)
|
||||
bpy.utils.unregister_class(OBJECT_OT_CreateChildEmpties)
|
||||
bpy.utils.unregister_class(OBJECT_OT_DestroyChildByProp)
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import bpy
|
||||
import os, sys, time
|
||||
import json
|
||||
from mathutils import Matrix, Quaternion
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
from mathutils import Matrix, Quaternion, Vector
|
||||
|
||||
argv = sys.argv
|
||||
argv = argv[argv.index("--") + 1:]
|
||||
|
||||
incpath = os.path.dirname(__file__)
|
||||
|
||||
sys.path.insert(0, incpath)
|
||||
outdir = argv[0]
|
||||
SUCCESS_OUTPUT = argv[1] if len(argv) > 1 else ""
|
||||
|
||||
basis_change = Matrix((
|
||||
(1, 0, 0, 0),
|
||||
@@ -17,180 +19,317 @@ basis_change = Matrix((
|
||||
(0, 0, 0, 1)
|
||||
))
|
||||
|
||||
def export_root_objects_to_gltf(output_dir):
|
||||
# Ensure the output directory exists
|
||||
if not os.path.exists(output_dir):
|
||||
os.makedirs(output_dir)
|
||||
LAYERS = {"normal", "thin-floor", "thin-wall", "ceiling"}
|
||||
ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
|
||||
|
||||
# Deselect all objects
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
|
||||
# Iterate through all objects in the scene
|
||||
for obj in bpy.context.scene.objects:
|
||||
# Check if the object has no parent and has children
|
||||
# The original request specifies "objects having no parent with children".
|
||||
# This condition captures those that are explicitly root objects of a hierarchy.
|
||||
if obj.parent is None:
|
||||
# Select the root object and all its children
|
||||
def round4(value):
|
||||
return round(float(value), 4)
|
||||
|
||||
print(obj.name)
|
||||
if not obj.name.startswith("furniture-"):
|
||||
continue
|
||||
desc = {}
|
||||
desc["name"] = obj.name.replace("furniture-","")
|
||||
desc["mesh"] = obj.name + ".glb"
|
||||
if "furniture_tags" in obj:
|
||||
mtags = obj["furniture_tags"]
|
||||
if "," in mtags:
|
||||
atags = [m.strip() for m in mtags.split(",")]
|
||||
desc["tags"] = atags
|
||||
else:
|
||||
desc["tags"] = [mtags]
|
||||
|
||||
def prop(root, name, default):
|
||||
return root[name] if name in root else default
|
||||
|
||||
|
||||
def stable_id(root):
|
||||
custom = str(prop(root, "furniture_id", "")).strip().lower()
|
||||
legacy_suffix = root.name.replace("furniture-", "")
|
||||
raw = custom or legacy_suffix.strip("-").lower()
|
||||
if not custom and legacy_suffix.startswith("-"):
|
||||
# Keep the accidental furniture--<name> asset addressable without
|
||||
# colliding with the canonical furniture-<name> root.
|
||||
raw += "-legacy"
|
||||
if not raw or not ID_PATTERN.match(raw):
|
||||
raise ValueError("invalid furniture id %r on %s" % (raw, root.name))
|
||||
return raw
|
||||
|
||||
|
||||
def parse_tags(value):
|
||||
# Existing blends use comma-separated custom-property strings. Also
|
||||
# accept semicolons and the legacy "tag. tag" typo, normalize case and
|
||||
# drop empty/duplicate entries while preserving order.
|
||||
result = []
|
||||
seen = set()
|
||||
for part in re.split(r"[,;]|\.\s+", str(value)):
|
||||
tag = part.strip().lower()
|
||||
if not tag:
|
||||
continue
|
||||
if not re.match(r"^[a-z0-9][a-z0-9._-]*$", tag):
|
||||
raise ValueError("invalid furniture tag %r" % tag)
|
||||
if tag not in seen:
|
||||
seen.add(tag)
|
||||
result.append(tag)
|
||||
return result
|
||||
|
||||
|
||||
def iter_descendants(root):
|
||||
stack = list(root.children)
|
||||
while stack:
|
||||
obj = stack.pop(0)
|
||||
yield obj
|
||||
stack.extend(obj.children)
|
||||
|
||||
|
||||
def is_collision_object(obj):
|
||||
# Existing assets also use "-col" for some VISUAL meshes, so only the
|
||||
# explicit collision-* prefix is excluded automatically.
|
||||
return obj.name.lower().split(".", 1)[0].startswith("collision-")
|
||||
|
||||
|
||||
def is_visual_mesh(obj):
|
||||
return obj.type == 'MESH' and not is_collision_object(obj)
|
||||
|
||||
|
||||
def bounds_in_root_space(root, objects):
|
||||
root_inv = root.matrix_world.inverted()
|
||||
minimum = None
|
||||
maximum = None
|
||||
for obj in objects:
|
||||
if len(obj.bound_box) == 0:
|
||||
continue
|
||||
matrix = root_inv @ obj.matrix_world
|
||||
for corner in obj.bound_box:
|
||||
local = basis_change @ (matrix @ Vector(corner))
|
||||
if minimum is None:
|
||||
minimum = local.copy()
|
||||
maximum = local.copy()
|
||||
else:
|
||||
desc["tags"] = []
|
||||
desc["sensors"] = []
|
||||
desc["actions"] = []
|
||||
desc["positions"] = []
|
||||
for child in obj.children:
|
||||
if child.name.startswith("action-"):
|
||||
if not "action" in child:
|
||||
continue
|
||||
if not "height" in child:
|
||||
continue
|
||||
if not "radius" in child:
|
||||
continue
|
||||
local_matrix = child.matrix_local
|
||||
ogre_local_matrix = basis_change @ local_matrix @ basis_change.inverted()
|
||||
local_pos_d = ogre_local_matrix.to_translation()
|
||||
local_rot_d = ogre_local_matrix.to_quaternion()
|
||||
local_pos = [round(x, 4) for x in local_pos_d]
|
||||
local_rot = [round(x, 4) for x in local_rot_d]
|
||||
action = {}
|
||||
action["position_x"] = local_pos[0]
|
||||
action["position_y"] = local_pos[1]
|
||||
action["position_z"] = local_pos[2]
|
||||
action["rotation_w"] = local_rot[0]
|
||||
action["rotation_x"] = local_rot[1]
|
||||
action["rotation_y"] = local_rot[2]
|
||||
action["rotation_z"] = local_rot[3]
|
||||
action["height"] = child["height"]
|
||||
action["radius"] = child["radius"]
|
||||
action["action"] = child["action"]
|
||||
if "action_text" in child:
|
||||
action["action_text"] = child["action_text"]
|
||||
else:
|
||||
action["action_text"] = child["action"].capitalize()
|
||||
if "name" in child:
|
||||
action["name"] = child["name"]
|
||||
else:
|
||||
action["name"] = desc["name"] + "_" + str(len(desc["actions"]))
|
||||
if "tags" in child:
|
||||
atags = child["tags"]
|
||||
if "," in atags:
|
||||
atags = [m.strip() for m in atags.split(",")]
|
||||
action["tags"] = atags
|
||||
else:
|
||||
action["tags"] = [atags]
|
||||
else:
|
||||
action["tags"] = []
|
||||
for k, v in child.items():
|
||||
if k.startswith("goap_prereq_"):
|
||||
action[k] = v
|
||||
elif k.startswith("goap_effect_"):
|
||||
action[k] = v
|
||||
action["furniture"] = {}
|
||||
action["furniture"]["name"] = desc["name"]
|
||||
action["furniture"]["tags"] = desc["tags"]
|
||||
action["positions"] = []
|
||||
for schild in child.children:
|
||||
if schild.name.startswith("position-"):
|
||||
local_matrix = schild.matrix_local
|
||||
ogre_local_matrix = basis_change @ local_matrix @ basis_change.inverted()
|
||||
local_pos_d = ogre_local_matrix.to_translation()
|
||||
local_rot_d = ogre_local_matrix.to_quaternion()
|
||||
local_pos = [round(x, 4) for x in local_pos_d]
|
||||
local_rot = [round(x, 4) for x in local_rot_d]
|
||||
position = {}
|
||||
if "name" in schild:
|
||||
position["name"] = schild["name"]
|
||||
if "type" in schild:
|
||||
position["type"] = schild["type"]
|
||||
position["position_x"] = local_pos[0]
|
||||
position["position_y"] = local_pos[1]
|
||||
position["position_z"] = local_pos[2]
|
||||
position["rotation_w"] = local_rot[0]
|
||||
position["rotation_x"] = local_rot[1]
|
||||
position["rotation_y"] = local_rot[2]
|
||||
position["rotation_z"] = local_rot[3]
|
||||
action["positions"].append(position)
|
||||
desc["actions"].append(action)
|
||||
if child.name.startswith("position-"):
|
||||
local_matrix = child.matrix_local
|
||||
ogre_local_matrix = basis_change @ local_matrix @ basis_change.inverted()
|
||||
local_pos_d = ogre_local_matrix.to_translation()
|
||||
local_rot_d = ogre_local_matrix.to_quaternion()
|
||||
local_pos = [round(x, 4) for x in local_pos_d]
|
||||
local_rot = [round(x, 4) for x in local_rot_d]
|
||||
position = {}
|
||||
if "name" in child:
|
||||
position["name"] = child["name"]
|
||||
if "type" in child:
|
||||
position["type"] = child["type"]
|
||||
position["position_x"] = local_pos[0]
|
||||
position["position_y"] = local_pos[1]
|
||||
position["position_z"] = local_pos[2]
|
||||
position["rotation_w"] = local_rot[0]
|
||||
position["rotation_x"] = local_rot[1]
|
||||
position["rotation_y"] = local_rot[2]
|
||||
position["rotation_z"] = local_rot[3]
|
||||
desc["positions"].append(position)
|
||||
minimum.x = min(minimum.x, local.x)
|
||||
minimum.y = min(minimum.y, local.y)
|
||||
minimum.z = min(minimum.z, local.z)
|
||||
maximum.x = max(maximum.x, local.x)
|
||||
maximum.y = max(maximum.y, local.y)
|
||||
maximum.z = max(maximum.z, local.z)
|
||||
if minimum is None:
|
||||
return None
|
||||
return ([round4(minimum.x), round4(minimum.y), round4(minimum.z)],
|
||||
[round4(maximum.x), round4(maximum.y), round4(maximum.z)])
|
||||
|
||||
obj.select_set(True)
|
||||
for child in obj.children_recursive:
|
||||
child.select_set(True)
|
||||
|
||||
obj.location = (0.0, 0.0, 0.0)
|
||||
|
||||
# Set the root object as the active object for the export operator
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
def transform_json(root, obj):
|
||||
local_matrix = root.matrix_world.inverted() @ obj.matrix_world
|
||||
ogre_local_matrix = basis_change @ local_matrix @ basis_change.inverted()
|
||||
local_pos = ogre_local_matrix.to_translation()
|
||||
local_rot = ogre_local_matrix.to_quaternion()
|
||||
return [round4(x) for x in local_pos], [round4(x) for x in local_rot]
|
||||
|
||||
# Define the output file path
|
||||
file_path = os.path.join(output_dir, f"{obj.name}.glb")
|
||||
|
||||
# Export the selected objects to a glTF file
|
||||
bpy.ops.export_scene.gltf(filepath=file_path,
|
||||
use_selection=True,
|
||||
check_existing=False,
|
||||
export_format='GLB',
|
||||
export_texture_dir='textures', export_texcoords=True,
|
||||
export_normals=True,
|
||||
export_tangents=True,
|
||||
export_materials='EXPORT',
|
||||
export_colors=True,
|
||||
use_mesh_edges=False,
|
||||
use_mesh_vertices=False,
|
||||
export_cameras=False,
|
||||
use_visible=False,
|
||||
use_renderable=False,
|
||||
export_yup=True,
|
||||
export_apply=True,
|
||||
export_animations=True,
|
||||
export_force_sampling=True,
|
||||
export_def_bones=False,
|
||||
export_current_frame=False,
|
||||
export_morph=True,
|
||||
export_morph_animation=False,
|
||||
export_morph_normal=True,
|
||||
export_morph_tangent=True,
|
||||
export_lights=False,
|
||||
export_skins=True)
|
||||
|
||||
# Deselect all objects for the next iteration
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
|
||||
print(f"Exported {obj.name} and its children to {file_path}")
|
||||
with open(file_path + ".json", "w") as json_data:
|
||||
json.dump(desc, json_data)
|
||||
def position_json(root, child):
|
||||
pos, rot = transform_json(root, child)
|
||||
position = {}
|
||||
if "name" in child:
|
||||
position["name"] = str(child["name"])
|
||||
if "type" in child:
|
||||
position["type"] = str(child["type"])
|
||||
position["position_x"] = pos[0]
|
||||
position["position_y"] = pos[1]
|
||||
position["position_z"] = pos[2]
|
||||
position["rotation_w"] = rot[0]
|
||||
position["rotation_x"] = rot[1]
|
||||
position["rotation_y"] = rot[2]
|
||||
position["rotation_z"] = rot[3]
|
||||
return position
|
||||
|
||||
|
||||
def action_json(root, child, furniture_id, furniture_tags):
|
||||
for required in ("action", "height", "radius"):
|
||||
if required not in child:
|
||||
raise ValueError("action %s on %s lacks %s" %
|
||||
(child.name, root.name, required))
|
||||
pos, rot = transform_json(root, child)
|
||||
height = float(child["height"])
|
||||
radius = float(child["radius"])
|
||||
if height <= 0.0 or radius <= 0.0:
|
||||
raise ValueError("action %s on %s has non-positive dimensions" %
|
||||
(child.name, root.name))
|
||||
|
||||
action = {}
|
||||
action["position_x"] = pos[0]
|
||||
action["position_y"] = pos[1]
|
||||
action["position_z"] = pos[2]
|
||||
action["rotation_w"] = rot[0]
|
||||
action["rotation_x"] = rot[1]
|
||||
action["rotation_y"] = rot[2]
|
||||
action["rotation_z"] = rot[3]
|
||||
action["height"] = height
|
||||
action["radius"] = radius
|
||||
action["action"] = str(child["action"])
|
||||
action["action_text"] = str(child.get("action_text",
|
||||
action["action"].capitalize()))
|
||||
action["name"] = str(child.get("name",
|
||||
furniture_id + "_" + str(len(root.children))))
|
||||
action["tags"] = parse_tags(child.get("tags", ""))
|
||||
for key, value in child.items():
|
||||
if key.startswith("goap_prereq_") or key.startswith("goap_effect_"):
|
||||
action[key] = value
|
||||
action["furniture"] = {"name": furniture_id, "tags": furniture_tags}
|
||||
action["positions"] = []
|
||||
for position in child.children:
|
||||
if position.name.startswith("position-"):
|
||||
action["positions"].append(position_json(child, position))
|
||||
return action
|
||||
|
||||
|
||||
def content_hash(desc):
|
||||
payload = json.dumps(desc, sort_keys=True, separators=(",", ":"),
|
||||
ensure_ascii=False)
|
||||
value = 14695981039346656037
|
||||
for byte in payload.encode("utf-8"):
|
||||
value ^= byte
|
||||
value = (value * 1099511628211) & 0xffffffffffffffff
|
||||
return "%016x" % value
|
||||
|
||||
|
||||
def export_root_objects_to_gltf(output_dir):
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
exported = []
|
||||
|
||||
for obj in bpy.context.scene.objects:
|
||||
if obj.parent is not None or not obj.name.startswith("furniture-"):
|
||||
continue
|
||||
|
||||
print(obj.name)
|
||||
furniture_id = stable_id(obj)
|
||||
tags = parse_tags(prop(obj, "furniture_tags", ""))
|
||||
layer = str(prop(obj, "layer", "normal")).strip().lower()
|
||||
if layer not in LAYERS:
|
||||
raise ValueError("%s has unknown layer %r" % (obj.name, layer))
|
||||
selection_weight = float(prop(obj, "selection_weight", 1.0))
|
||||
clearance = float(prop(obj, "clearance", 0.0))
|
||||
if selection_weight < 0.0 or clearance < 0.0:
|
||||
raise ValueError("%s has negative weight/clearance" % obj.name)
|
||||
footprint_x = int(prop(obj, "footprint_x", 0))
|
||||
footprint_z = int(prop(obj, "footprint_z", 0))
|
||||
if (footprint_x == 0) != (footprint_z == 0):
|
||||
raise ValueError("%s needs both footprint axes or neither" %
|
||||
obj.name)
|
||||
if footprint_x < 0 or footprint_z < 0:
|
||||
raise ValueError("%s has a negative footprint" % obj.name)
|
||||
|
||||
descendants = list(iter_descendants(obj))
|
||||
visual_meshes = [child for child in descendants if is_visual_mesh(child)]
|
||||
if not visual_meshes:
|
||||
raise ValueError("%s has no visual mesh children" % obj.name)
|
||||
collision_name = str(prop(obj, "collision_mesh", "")).strip()
|
||||
collision_meshes = [child for child in descendants
|
||||
if child.type == 'MESH' and
|
||||
(is_collision_object(child) or
|
||||
(collision_name and child.name == collision_name))]
|
||||
if collision_name and not collision_meshes:
|
||||
raise ValueError("%s references missing collision mesh %s" %
|
||||
(obj.name, collision_name))
|
||||
|
||||
desc = {
|
||||
"schemaVersion": 1,
|
||||
"id": furniture_id,
|
||||
# `name` is the legacy runtime identifier. Keep it equal to
|
||||
# the stable id so old FurnitureCell records continue to work.
|
||||
"name": furniture_id,
|
||||
"displayName": str(prop(obj, "display_name", furniture_id)),
|
||||
"mesh": obj.name + ".glb",
|
||||
"materialName": "M_Furniture_Atlas",
|
||||
"atlas": "furniture-atlas.png",
|
||||
"source": "blender",
|
||||
"layer": layer,
|
||||
"tags": tags,
|
||||
"anchor": [0.0, 0.0, 0.0],
|
||||
"requiresFloor": bool(prop(obj, "requires_floor", True)),
|
||||
"requiresWall": bool(prop(obj, "requires_wall", False)),
|
||||
"clearance": clearance,
|
||||
"weight": selection_weight,
|
||||
"sensors": [],
|
||||
"actions": [],
|
||||
"positions": [],
|
||||
}
|
||||
|
||||
legacy_suffix = obj.name.replace("furniture-", "")
|
||||
aliases = []
|
||||
if legacy_suffix != furniture_id:
|
||||
aliases.append(legacy_suffix)
|
||||
legacy_custom_name = str(obj.get("name", "")).strip()
|
||||
if legacy_custom_name and legacy_custom_name != furniture_id and \
|
||||
legacy_custom_name not in aliases:
|
||||
aliases.append(legacy_custom_name)
|
||||
if aliases:
|
||||
desc["aliases"] = aliases
|
||||
|
||||
bounds = bounds_in_root_space(obj, visual_meshes)
|
||||
if bounds:
|
||||
desc["bounds"] = {"min": bounds[0], "max": bounds[1]}
|
||||
collision_bounds = bounds_in_root_space(obj, collision_meshes)
|
||||
if collision_bounds:
|
||||
desc["collisionBounds"] = {"min": collision_bounds[0],
|
||||
"max": collision_bounds[1]}
|
||||
if footprint_x > 0:
|
||||
desc["footprintCells"] = [footprint_x, footprint_z]
|
||||
|
||||
for child in obj.children:
|
||||
if child.name.startswith("action-"):
|
||||
desc["actions"].append(
|
||||
action_json(obj, child, furniture_id, tags))
|
||||
elif child.name.startswith("position-"):
|
||||
desc["positions"].append(position_json(obj, child))
|
||||
|
||||
desc["sourceHash"] = content_hash(desc)
|
||||
|
||||
# Export only the visual hierarchy. action-/position- empties are
|
||||
# metadata and collision-* meshes are physics bounds, not render
|
||||
# content.
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
obj.select_set(True)
|
||||
for child in descendants:
|
||||
if child.name.startswith("action-") or \
|
||||
child.name.startswith("position-") or \
|
||||
is_collision_object(child):
|
||||
continue
|
||||
child.select_set(True)
|
||||
|
||||
obj.location = (0.0, 0.0, 0.0)
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
|
||||
file_path = os.path.join(output_dir, obj.name + ".glb")
|
||||
bpy.ops.export_scene.gltf(filepath=file_path,
|
||||
use_selection=True,
|
||||
check_existing=False,
|
||||
export_format='GLB',
|
||||
export_texture_dir='textures',
|
||||
export_texcoords=True,
|
||||
export_normals=True,
|
||||
export_tangents=True,
|
||||
export_materials='EXPORT',
|
||||
export_image_format='NONE',
|
||||
export_colors=True,
|
||||
use_mesh_edges=False,
|
||||
use_mesh_vertices=False,
|
||||
export_cameras=False,
|
||||
use_visible=False,
|
||||
use_renderable=False,
|
||||
export_yup=True,
|
||||
export_apply=True,
|
||||
export_animations=True,
|
||||
export_force_sampling=True,
|
||||
export_def_bones=False,
|
||||
export_current_frame=False,
|
||||
export_morph=True,
|
||||
export_morph_animation=False,
|
||||
export_morph_normal=True,
|
||||
export_morph_tangent=True,
|
||||
export_lights=False,
|
||||
export_skins=True)
|
||||
|
||||
with open(file_path + ".json", "w", newline="\n") as json_data:
|
||||
json.dump(desc, json_data, indent=2, sort_keys=True)
|
||||
json_data.write("\n")
|
||||
exported.append((furniture_id, file_path))
|
||||
print("Exported %s to %s" % (furniture_id, file_path))
|
||||
|
||||
if not exported:
|
||||
raise RuntimeError("no furniture-* root objects found in " +
|
||||
bpy.data.filepath)
|
||||
if SUCCESS_OUTPUT:
|
||||
with open(SUCCESS_OUTPUT, "w", newline="\n") as marker:
|
||||
marker.write("ok\n")
|
||||
return exported
|
||||
|
||||
|
||||
export_root_objects_to_gltf(outdir)
|
||||
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Write the furniture gallery's static HTML index (stdlib only)."""
|
||||
|
||||
import argparse
|
||||
import html
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--root", required=True)
|
||||
parser.add_argument("--atlas", default="../furniture-atlas.png")
|
||||
args = parser.parse_args()
|
||||
root = Path(args.root)
|
||||
entries = []
|
||||
for manifest in sorted(root.glob("manifest-*.json")):
|
||||
entries.extend(json.loads(manifest.read_text()))
|
||||
entries.sort(key=lambda entry: entry["id"])
|
||||
|
||||
rows = []
|
||||
for entry in entries:
|
||||
u0, u1, v0, v1 = entry["uv"]
|
||||
style = (
|
||||
"left:%.4f%%;top:%.4f%%;width:%.4f%%;height:%.4f%%" %
|
||||
(u0 * 100.0, (1.0 - v1) * 100.0,
|
||||
(u1 - u0) * 100.0, (v1 - v0) * 100.0))
|
||||
bounds = entry.get("bounds")
|
||||
bounds_text = "none"
|
||||
if bounds:
|
||||
bounds_text = "%s .. %s" % (bounds["min"], bounds["max"])
|
||||
rows.append(
|
||||
"<section class='asset'><h2>%s</h2>"
|
||||
"<p><b>Layer:</b> %s <b>Tags:</b> %s</p>"
|
||||
"<p class='bounds'><b>Bounds:</b> %s%s</p>"
|
||||
"<div class='views'>"
|
||||
"<div><h3>Asset / pivot / bounds / POIs</h3>"
|
||||
"<img class='render' src='%s'></div>"
|
||||
"<div><h3>Atlas UV region</h3>"
|
||||
"<div class='atlas'><img src='%s'>"
|
||||
"<div class='uv' style='%s'></div></div></div>"
|
||||
"</div></section>" %
|
||||
(html.escape(entry["id"]), html.escape(entry["layer"]),
|
||||
html.escape(", ".join(entry["tags"])),
|
||||
html.escape(bounds_text),
|
||||
" + collision proxy" if entry.get("collisionBounds") else "",
|
||||
html.escape(entry["render"]), html.escape(args.atlas), style))
|
||||
|
||||
page = """<!doctype html>
|
||||
<meta charset='utf-8'>
|
||||
<title>Furniture export gallery</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; background: #202124; color: #ddd;
|
||||
margin: 2em; }
|
||||
.asset { border-top: 1px solid #555; padding-top: 1em; margin-top: 2em; }
|
||||
.views { display: flex; gap: 2em; align-items: flex-start; }
|
||||
.views > div { flex: 1; min-width: 0; }
|
||||
.render { width: 100%; image-rendering: auto; background: #111; }
|
||||
.atlas { position: relative; width: 100%; }
|
||||
.atlas img { width: 100%; display: block; }
|
||||
.uv { position: absolute; border: 3px solid #f33; box-sizing: border-box;
|
||||
pointer-events: none; }
|
||||
.bounds { color: #aaa; font-size: 0.9em; }
|
||||
</style>
|
||||
<h1>Furniture export gallery</h1>
|
||||
<p>Generated from the baked Blender sources. Green = physical bounds,
|
||||
red = collision bounds, blue = occupancy cells, axes = pivot, spheres =
|
||||
action/position markers. The red rectangle on each atlas shows the
|
||||
asset's UV region.</p>
|
||||
""" + "\n".join(rows)
|
||||
(root / "index.html").write_text(page)
|
||||
print("Furniture gallery index: %d assets" % len(entries))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,79 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
|
||||
# Stack deterministic per-blend furniture atlas bakes into one shared atlas.
|
||||
# bake_furniture.py remaps each source's UVs into the matching vertical band.
|
||||
#
|
||||
# Usage:
|
||||
# blender --factory-startup -b -P merge_furniture_atlases.py -- \
|
||||
# <atlas.png> <atlas.material> <part0.png> [part1.png ...]
|
||||
|
||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
if len(argv) < 3:
|
||||
raise RuntimeError(
|
||||
"merge_furniture_atlases.py needs output png/material and inputs")
|
||||
|
||||
out_png = argv[0]
|
||||
out_material = argv[1]
|
||||
inputs = argv[2:]
|
||||
|
||||
images = []
|
||||
width = None
|
||||
part_height = None
|
||||
for path in inputs:
|
||||
if not os.path.exists(path):
|
||||
raise RuntimeError("missing furniture atlas part: " + path)
|
||||
image = bpy.data.images.load(path, check_existing=False)
|
||||
images.append((path, image))
|
||||
if width is None:
|
||||
width, part_height = image.size
|
||||
elif image.size[0] != width or image.size[1] != part_height:
|
||||
raise RuntimeError("atlas size mismatch in " + path)
|
||||
|
||||
if width is None or width <= 0 or part_height <= 0:
|
||||
raise RuntimeError("no furniture atlas parts supplied")
|
||||
|
||||
part_pixels = width * part_height * 4
|
||||
merged = []
|
||||
for path, image in images:
|
||||
pixels = list(image.pixels)
|
||||
if len(pixels) != part_pixels:
|
||||
raise RuntimeError("unexpected pixel count in " + path)
|
||||
coverage = sum(1 for i in range(3, len(pixels), 4) if pixels[i] > 0.0)
|
||||
if coverage == 0:
|
||||
raise RuntimeError("furniture atlas part is empty: " + path)
|
||||
merged.extend(pixels)
|
||||
|
||||
result = bpy.data.images.new("FurnitureColor", width=width,
|
||||
height=part_height * len(images), alpha=True)
|
||||
result.pixels = merged
|
||||
os.makedirs(os.path.dirname(out_png), exist_ok=True)
|
||||
os.makedirs(os.path.dirname(out_material), exist_ok=True)
|
||||
result.filepath_raw = out_png
|
||||
result.file_format = 'PNG'
|
||||
result.save()
|
||||
|
||||
material = """// Generated by merge_furniture_atlases.py; do not edit.
|
||||
material M_Furniture_Atlas
|
||||
{
|
||||
technique
|
||||
{
|
||||
pass
|
||||
{
|
||||
texture_unit
|
||||
{
|
||||
texture furniture-atlas.png
|
||||
filtering linear linear linear
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
with open(out_material, "w", newline="\n") as file:
|
||||
file.write(material)
|
||||
|
||||
print("Furniture atlas merged: %d source band(s), %dx%d -> %s" %
|
||||
(len(images), width, part_height * len(images), out_png))
|
||||
print("Furniture material written: " + out_material)
|
||||
@@ -0,0 +1,253 @@
|
||||
"""Render one preview image per furniture root plus a gallery manifest.
|
||||
|
||||
Invoke on a *_baked.blend from the furniture pipeline:
|
||||
blender -b furniture_baked.blend -Y -P render_furniture_gallery.py -- \
|
||||
<gallery-dir> <definition-dir>
|
||||
|
||||
The manifest is consumed by generate_furniture_gallery_index.py. Rendering
|
||||
uses Cycles CPU so it also works on headless build machines.
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
from mathutils import Vector
|
||||
|
||||
argv = sys.argv[sys.argv.index("--") + 1:]
|
||||
GALLERY_DIR = argv[0]
|
||||
DEFINITION_DIR = argv[1]
|
||||
os.makedirs(GALLERY_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def ogre_to_blender(v):
|
||||
return Vector((v[0], -v[2], v[1]))
|
||||
|
||||
|
||||
def material(name, color, emission=0.0):
|
||||
mat = bpy.data.materials.get(name)
|
||||
if mat:
|
||||
return mat
|
||||
mat = bpy.data.materials.new(name)
|
||||
mat.use_nodes = True
|
||||
bsdf = mat.node_tree.nodes.get("Principled BSDF")
|
||||
bsdf.inputs["Base Color"].default_value = (*color, 1.0)
|
||||
bsdf.inputs["Roughness"].default_value = 0.8
|
||||
if emission:
|
||||
bsdf.inputs["Emission"].default_value = (*color, 1.0)
|
||||
bsdf.inputs["Emission Strength"].default_value = emission
|
||||
return mat
|
||||
|
||||
|
||||
bounds_mat = material("GalleryBounds", (0.1, 0.8, 0.2), 1.0)
|
||||
collision_mat = material("GalleryCollision", (0.9, 0.1, 0.1), 1.0)
|
||||
cell_mat = material("GalleryCells", (0.1, 0.4, 0.9), 0.5)
|
||||
pivot_mats = [material("GalleryX", (1, 0, 0), 1.0),
|
||||
material("GalleryY", (0, 1, 0), 1.0),
|
||||
material("GalleryZ", (0, 0, 1), 1.0)]
|
||||
action_mat = material("GalleryAction", (1.0, 0.5, 0.05), 1.0)
|
||||
position_mat = material("GalleryPosition", (0.2, 0.6, 1.0), 1.0)
|
||||
floor_mat = material("GalleryFloor", (0.18, 0.18, 0.18))
|
||||
|
||||
scene = bpy.context.scene
|
||||
scene.render.engine = 'CYCLES'
|
||||
scene.cycles.device = 'CPU'
|
||||
scene.cycles.samples = 4
|
||||
scene.render.resolution_x = 480
|
||||
scene.render.resolution_y = 360
|
||||
scene.render.resolution_percentage = 100
|
||||
scene.render.image_settings.file_format = 'PNG'
|
||||
scene.render.film_transparent = False
|
||||
|
||||
# Neutral floor, camera and lights are shared by every render.
|
||||
bpy.ops.mesh.primitive_plane_add(size=200.0, location=(0, 0, -0.03))
|
||||
floor = bpy.context.object
|
||||
floor.name = "GalleryFloor"
|
||||
floor.data.materials.append(floor_mat)
|
||||
|
||||
bpy.ops.object.camera_add()
|
||||
camera = bpy.context.object
|
||||
scene.camera = camera
|
||||
camera.data.lens = 50
|
||||
|
||||
for name, location, energy, size in (
|
||||
("Key", (3, 4, 6), 600, 4),
|
||||
("Fill", (-4, 1, 3), 350, 4),
|
||||
("Rim", (0, -4, 4), 450, 3)):
|
||||
bpy.ops.object.light_add(type='AREA', location=location)
|
||||
lamp = bpy.context.object
|
||||
lamp.name = "Gallery" + name
|
||||
lamp.data.energy = energy
|
||||
lamp.data.size = size
|
||||
|
||||
|
||||
def hide_tree(obj, hide):
|
||||
obj.hide_render = hide
|
||||
for child in obj.children:
|
||||
hide_tree(child, hide)
|
||||
|
||||
|
||||
def line_object(name, points, mat, width=0.008):
|
||||
curve = bpy.data.curves.new(name, 'CURVE')
|
||||
curve.dimensions = '3D'
|
||||
curve.bevel_depth = width
|
||||
curve.bevel_resolution = 1
|
||||
spline = curve.splines.new('POLY')
|
||||
spline.points.add(len(points) - 1)
|
||||
for point, co in zip(spline.points, points):
|
||||
point.co = (*co, 1.0)
|
||||
obj = bpy.data.objects.new(name, curve)
|
||||
scene.collection.objects.link(obj)
|
||||
obj.data.materials.append(mat)
|
||||
return obj
|
||||
|
||||
|
||||
def box_edges(name, minimum, maximum, mat):
|
||||
corners = []
|
||||
for x in (minimum[0], maximum[0]):
|
||||
for y in (minimum[1], maximum[1]):
|
||||
for z in (minimum[2], maximum[2]):
|
||||
corners.append(Vector((x, y, z)))
|
||||
indices = [(0, 1), (0, 2), (0, 4), (1, 3), (1, 5), (2, 3),
|
||||
(2, 6), (3, 7), (4, 5), (4, 6), (5, 7), (6, 7)]
|
||||
for index, (a, b) in enumerate(indices):
|
||||
line_object(name + str(index), [corners[a], corners[b]], mat)
|
||||
|
||||
|
||||
def sphere(name, position, mat, radius):
|
||||
bpy.ops.mesh.primitive_uv_sphere_add(segments=12, ring_count=6,
|
||||
radius=radius, location=position)
|
||||
obj = bpy.context.object
|
||||
obj.name = name
|
||||
obj.data.materials.append(mat)
|
||||
return obj
|
||||
|
||||
|
||||
def uv_bounds(root):
|
||||
result = [1.0, 0.0, 1.0, 0.0]
|
||||
found = False
|
||||
stack = list(root.children)
|
||||
while stack:
|
||||
obj = stack.pop(0)
|
||||
stack.extend(obj.children)
|
||||
if obj.type != 'MESH' or obj.name.startswith("collision-"):
|
||||
continue
|
||||
for layer in obj.data.uv_layers:
|
||||
for loop in layer.data:
|
||||
u, v = loop.uv
|
||||
result[0] = min(result[0], u)
|
||||
result[1] = max(result[1], u)
|
||||
result[2] = min(result[2], v)
|
||||
result[3] = max(result[3], v)
|
||||
found = True
|
||||
return result if found else [0.0, 1.0, 0.0, 1.0]
|
||||
|
||||
|
||||
roots = [obj for obj in scene.objects
|
||||
if obj.parent is None and obj.name.startswith("furniture-")]
|
||||
manifest = []
|
||||
for root in sorted(roots, key=lambda obj: obj.name):
|
||||
definition_path = os.path.join(DEFINITION_DIR, root.name + ".glb.json")
|
||||
with open(definition_path) as source:
|
||||
definition = json.load(source)
|
||||
|
||||
# Definitions and GLBs are exported with the root at the origin;
|
||||
# use the same pivot here (many source roots have authoring offsets).
|
||||
root.location = (0.0, 0.0, 0.0)
|
||||
for other in roots:
|
||||
hide_tree(other, True)
|
||||
hide_tree(root, False)
|
||||
|
||||
helpers_start = set(scene.objects)
|
||||
bounds = definition.get("bounds")
|
||||
if bounds:
|
||||
minimum = ogre_to_blender(bounds["min"])
|
||||
maximum = ogre_to_blender(bounds["max"])
|
||||
# ogre_to_blender swaps Y/Z and negates one axis; reorder.
|
||||
low = Vector(tuple(min(a, b) for a, b in zip(minimum, maximum)))
|
||||
high = Vector(tuple(max(a, b) for a, b in zip(minimum, maximum)))
|
||||
box_edges("PhysicalBounds", low, high, bounds_mat)
|
||||
center = (low + high) * 0.5
|
||||
size = high - low
|
||||
else:
|
||||
center = Vector((0, 0, 0.5))
|
||||
size = Vector((1, 1, 1))
|
||||
|
||||
if "collisionBounds" in definition:
|
||||
collision = definition["collisionBounds"]
|
||||
minimum = ogre_to_blender(collision["min"])
|
||||
maximum = ogre_to_blender(collision["max"])
|
||||
low = Vector(tuple(min(a, b) for a, b in zip(minimum, maximum)))
|
||||
high = Vector(tuple(max(a, b) for a, b in zip(minimum, maximum)))
|
||||
box_edges("CollisionBounds", low, high, collision_mat)
|
||||
|
||||
# Pivot axes and occupancy-cell outlines at the root origin.
|
||||
line_object("PivotX", [(0, 0, 0), (0.35, 0, 0)], pivot_mats[0], 0.012)
|
||||
line_object("PivotY", [(0, 0, 0), (0, 0.35, 0)], pivot_mats[1], 0.012)
|
||||
line_object("PivotZ", [(0, 0, 0), (0, 0, 0.35)], pivot_mats[2], 0.012)
|
||||
if bounds:
|
||||
cell_size = 2.0
|
||||
bmin = bounds["min"]
|
||||
bmax = bounds["max"]
|
||||
min_cx = math.floor(bmin[0] / cell_size)
|
||||
min_cz = math.floor(bmin[2] / cell_size)
|
||||
max_cx = math.ceil((bmax[0] - 0.0001) / cell_size) - 1
|
||||
max_cz = math.ceil((bmax[2] - 0.0001) / cell_size) - 1
|
||||
for cx in range(min_cx, max_cx + 1):
|
||||
for cz in range(min_cz, max_cz + 1):
|
||||
points = [(cx * cell_size, -cz * cell_size, -0.01),
|
||||
((cx + 1) * cell_size, -cz * cell_size, -0.01),
|
||||
((cx + 1) * cell_size,
|
||||
-(cz + 1) * cell_size, -0.01),
|
||||
(cx * cell_size, -(cz + 1) * cell_size, -0.01),
|
||||
(cx * cell_size, -cz * cell_size, -0.01)]
|
||||
line_object("OccupancyCell", points, cell_mat, 0.006)
|
||||
|
||||
radius = max(size.length * 0.025, 0.025)
|
||||
for action in definition.get("actions", []):
|
||||
position = (action["position_x"], action["position_y"],
|
||||
action["position_z"])
|
||||
sphere("ActionMarker", ogre_to_blender(position), action_mat,
|
||||
radius)
|
||||
for marker in action.get("positions", []):
|
||||
position = (marker["position_x"], marker["position_y"],
|
||||
marker["position_z"])
|
||||
sphere("PositionMarker", ogre_to_blender(position),
|
||||
position_mat, radius * 0.7)
|
||||
for marker in definition.get("positions", []):
|
||||
position = (marker["position_x"], marker["position_y"],
|
||||
marker["position_z"])
|
||||
sphere("PositionMarker", ogre_to_blender(position),
|
||||
position_mat, radius * 0.7)
|
||||
|
||||
distance = max(size.length, 0.8) * 1.8
|
||||
camera.location = center + Vector((distance * 0.8,
|
||||
distance * 1.1,
|
||||
distance * 0.65))
|
||||
camera.rotation_euler = (center - camera.location).to_track_quat(
|
||||
'-Z', 'Y').to_euler()
|
||||
camera.data.lens = 45
|
||||
output_name = root.name + ".png"
|
||||
scene.render.filepath = os.path.join(GALLERY_DIR, output_name)
|
||||
bpy.ops.render.render(write_still=True)
|
||||
|
||||
manifest.append({
|
||||
"id": definition["id"],
|
||||
"render": output_name,
|
||||
"layer": definition.get("layer", "normal"),
|
||||
"tags": definition.get("tags", []),
|
||||
"bounds": definition.get("bounds"),
|
||||
"collisionBounds": definition.get("collisionBounds"),
|
||||
"uv": uv_bounds(root),
|
||||
})
|
||||
for helper in set(scene.objects) - helpers_start:
|
||||
bpy.data.objects.remove(helper, do_unlink=True)
|
||||
|
||||
with open(os.path.join(GALLERY_DIR,
|
||||
"manifest-" + os.path.basename(DEFINITION_DIR) +
|
||||
".json"),
|
||||
"w") as output:
|
||||
json.dump(manifest, output, indent=2)
|
||||
print("Furniture gallery: %d assets rendered" % len(manifest))
|
||||
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate generated furniture GLB/JSON artifacts after the CMake export.
|
||||
|
||||
Checks the phase 6.5 shared-atlas contract without starting OGRE:
|
||||
schema metadata exists, GLBs carry no embedded images, every material is the
|
||||
shared material, UVs stay in the owning source's vertical atlas band, and
|
||||
mesh variants sharing a Blender mesh datablock keep identical UV bounds.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
COMPONENT_FLOAT = 5126
|
||||
|
||||
|
||||
def fail(message):
|
||||
raise RuntimeError(message)
|
||||
|
||||
|
||||
def read_glb(path):
|
||||
data = path.read_bytes()
|
||||
if len(data) < 20 or data[:4] != b"glTF":
|
||||
fail(f"{path}: not a GLB")
|
||||
json_length, json_type = struct.unpack_from("<II", data, 12)
|
||||
if json_type != 0x4E4F534A:
|
||||
fail(f"{path}: first chunk is not JSON")
|
||||
document = json.loads(data[20:20 + json_length])
|
||||
offset = 20 + json_length
|
||||
bin_data = b""
|
||||
if offset + 8 <= len(data):
|
||||
bin_length, bin_type = struct.unpack_from("<II", data, offset)
|
||||
if bin_type == 0x004E4942:
|
||||
bin_data = data[offset + 8:offset + 8 + bin_length]
|
||||
return document, bin_data
|
||||
|
||||
|
||||
def accessor_floats(document, bin_data, accessor_index):
|
||||
accessor = document["accessors"][accessor_index]
|
||||
if accessor["componentType"] != COMPONENT_FLOAT:
|
||||
fail(f"accessor {accessor_index} is not float")
|
||||
view = document["bufferViews"][accessor["bufferView"]]
|
||||
offset = view.get("byteOffset", 0) + accessor.get("byteOffset", 0)
|
||||
components = {"VEC2": 2, "VEC3": 3, "VEC4": 4}[accessor["type"]]
|
||||
count = accessor["count"] * components
|
||||
return struct.unpack_from("<%df" % count, bin_data, offset)
|
||||
|
||||
|
||||
def uv_bounds(document, bin_data):
|
||||
result = []
|
||||
for mesh_index, mesh in enumerate(document.get("meshes", [])):
|
||||
mesh_name = mesh.get("name", f"mesh{mesh_index}")
|
||||
for primitive in mesh.get("primitives", []):
|
||||
accessor_index = primitive.get("attributes", {}).get("TEXCOORD_0")
|
||||
if accessor_index is None:
|
||||
continue
|
||||
values = accessor_floats(document, bin_data, accessor_index)
|
||||
us = values[::2]
|
||||
vs = values[1::2]
|
||||
result.append((mesh_name, min(us), max(us), min(vs), max(vs)))
|
||||
return result
|
||||
|
||||
|
||||
def validate_source(source_root, source_name, band_index, band_count):
|
||||
source_dir = source_root / source_name
|
||||
if not source_dir.is_dir():
|
||||
fail(f"missing output directory {source_dir}")
|
||||
json_paths = sorted(source_dir.glob("furniture-*.json"))
|
||||
if not json_paths:
|
||||
fail(f"no furniture definitions in {source_dir}")
|
||||
|
||||
shared_uv = {}
|
||||
min_v = 1.0 - float(band_index + 1) / band_count - 0.002
|
||||
max_v = 1.0 - float(band_index) / band_count + 0.002
|
||||
mesh_count = 0
|
||||
|
||||
for json_path in json_paths:
|
||||
definition = json.loads(json_path.read_text())
|
||||
if definition.get("source") != "blender":
|
||||
fail(f"{json_path}: source is not blender")
|
||||
if definition.get("materialName") != "M_Furniture_Atlas":
|
||||
fail(f"{json_path}: wrong materialName")
|
||||
if not definition.get("id") or not definition.get("mesh"):
|
||||
fail(f"{json_path}: missing id/mesh")
|
||||
if "bounds" not in definition:
|
||||
fail(f"{json_path}: missing physical bounds")
|
||||
glb_path = source_dir / definition["mesh"]
|
||||
if not glb_path.is_file():
|
||||
fail(f"{json_path}: referenced mesh is missing")
|
||||
|
||||
document, bin_data = read_glb(glb_path)
|
||||
if document.get("images"):
|
||||
fail(f"{glb_path}: embeds an image instead of using the shared atlas")
|
||||
materials = document.get("materials", [])
|
||||
if not materials or any(
|
||||
material.get("name") != "M_Furniture_Atlas"
|
||||
for material in materials):
|
||||
fail(f"{glb_path}: does not use only M_Furniture_Atlas")
|
||||
|
||||
for mesh_name, min_u, max_u, mesh_min_v, mesh_max_v in uv_bounds(
|
||||
document, bin_data):
|
||||
mesh_count += 1
|
||||
if mesh_min_v < min_v or mesh_max_v > max_v:
|
||||
fail(
|
||||
f"{glb_path}:{mesh_name}: UV V range "
|
||||
f"{mesh_min_v:.4f}..{mesh_max_v:.4f} escapes band "
|
||||
f"{min_v:.4f}..{max_v:.4f}")
|
||||
key = (source_name, mesh_name)
|
||||
bounds = (min_u, max_u, mesh_min_v, mesh_max_v)
|
||||
previous = shared_uv.get(key)
|
||||
if previous:
|
||||
if any(abs(a - b) > 0.0005 for a, b in zip(previous, bounds)):
|
||||
fail(
|
||||
f"{glb_path}:{mesh_name}: shared mesh UV bounds "
|
||||
f"changed between variants {previous} -> {bounds}")
|
||||
else:
|
||||
shared_uv[key] = bounds
|
||||
|
||||
return len(json_paths), mesh_count
|
||||
|
||||
|
||||
def main():
|
||||
args = sys.argv[1:]
|
||||
if len(args) < 6 or args[0] != "--root":
|
||||
fail("usage: validate_furniture_export.py --root <dir> "
|
||||
"--source <name> <band> <count> [...]")
|
||||
root = Path(args[1])
|
||||
atlas_png = root / "furniture-atlas.png"
|
||||
atlas_material = root / "furniture-atlas.material"
|
||||
if not atlas_png.is_file() or atlas_png.stat().st_size == 0:
|
||||
fail("shared furniture-atlas.png is missing/empty")
|
||||
if "M_Furniture_Atlas" not in atlas_material.read_text():
|
||||
fail("shared material does not define M_Furniture_Atlas")
|
||||
|
||||
definitions = 0
|
||||
meshes = 0
|
||||
i = 2
|
||||
while i < len(args):
|
||||
if args[i] != "--source" or i + 3 >= len(args):
|
||||
fail("malformed --source arguments")
|
||||
count, mesh_count = validate_source(root, args[i + 1],
|
||||
int(args[i + 2]),
|
||||
int(args[i + 3]))
|
||||
definitions += count
|
||||
meshes += mesh_count
|
||||
i += 4
|
||||
print(f"Furniture export validation: {definitions} definitions, "
|
||||
f"{meshes} mesh primitives, shared atlas OK")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,305 @@
|
||||
# Creating Character Parts for the Game (Blender -> Engine)
|
||||
|
||||
This is the contract a character model must satisfy to work with the
|
||||
character pipeline: the catalog-driven modular body
|
||||
(`CharacterSlotSystem`), the deterministic NPC look assignment
|
||||
(`NpcPopulationSystem`), the animation trees, and the hair physics.
|
||||
Companion document for cars: `../vehicles/CAR_MODELING.md`.
|
||||
|
||||
The reference implementations are `edited-normal-male.blend` and
|
||||
`edited-normal-female.blend` (base bodies) plus the
|
||||
`clothes-<sex>-<slot>.blend` garment files in this directory.
|
||||
|
||||
## 1. Export pipeline
|
||||
|
||||
All steps are driven by `assets/blender/characters/CMakeLists.txt` and
|
||||
run in Blender batch mode; from the project root:
|
||||
|
||||
```bash
|
||||
cmake --build build-vscode --target edited-blends # base bodies + animations
|
||||
cmake --build build-vscode # full pipeline incl. clothes
|
||||
```
|
||||
|
||||
Pipeline per sex (`male` / `female`):
|
||||
|
||||
1. `edited-normal-<sex>.blend` is the base body. `copy_animations.py`
|
||||
copies the Mixamo animation library (`assets/blender/mixamo/`) into
|
||||
it — idle, walking, running, swimming, treading_water, sitting,
|
||||
sitting-ground, sitting-vehicle, ...
|
||||
2. Each `clothes-<sex>-<slot>.blend` holds garment meshes for one slot.
|
||||
`process_clothes.py` fits them to the body (weight transfer from the
|
||||
reference body parts, seam fixes), `combine_clothes.py` bakes the
|
||||
combined meshes for garment combinations (see §3).
|
||||
3. `consolidate.py` appends everything into
|
||||
`edited-normal-<sex>-consolidated.blend`.
|
||||
4. `export_models2.py` exports the consolidated blend through
|
||||
blender2ogre: one `.mesh` per catalog mesh object, the shared
|
||||
`.skeleton`, `.material` files, the texture atlases
|
||||
(`clothes-atlas.png`, `clothes-atlas-normal.png`, `face-atlas.png`),
|
||||
a `.glb` preview, and one **`body_part_<mesh data name>.json`
|
||||
catalog entry per mesh object** carrying the required custom
|
||||
properties (see §2).
|
||||
|
||||
Outputs land in `build-vscode/characters/<sex>/` and are staged into
|
||||
the editScene runtime `characters/` directory (OGRE resource group
|
||||
`Characters`). The engine discovers catalog entries by scanning every
|
||||
resource group for `body_part_*.json` — no manual registration step.
|
||||
|
||||
**blender2ogre names `.mesh` files after the mesh DATA name** (not the
|
||||
object name) — set both, and never reuse another blend's mesh data
|
||||
names: everything exports into the same directory and silently
|
||||
overwrites.
|
||||
|
||||
## 2. Catalog entry contract (custom properties on the MESH OBJECT)
|
||||
|
||||
`export_models2.py` writes a catalog JSON for every mesh object that
|
||||
carries all three required custom properties (Properties panel ->
|
||||
Object -> Custom Properties):
|
||||
|
||||
| Property | Type | Meaning |
|
||||
|----------|------|---------|
|
||||
| `age` | string | age band, currently only `adult` exists |
|
||||
| `sex` | string | `male` / `female` |
|
||||
| `slot` | string | `top` / `bottom` / `feet` / `hair` / `face` |
|
||||
| `layer` | int | 0 = nude base, 1 = underwear (and hair), 2 = clothes (default 0) |
|
||||
| `garments` | string | `;`-separated garment ids this mesh CONTAINS |
|
||||
| `clothing_tags` | string | `;`-separated extra tags |
|
||||
|
||||
`shape_keys` are collected automatically from the mesh data (the
|
||||
`Basis` key is excluded). Hair entries additionally get
|
||||
`own_skeleton: true` and `attach_to_bone: "mixamorig:Head"` (see §5).
|
||||
Garment ids are automatically duplicated into `tags`.
|
||||
|
||||
Resulting entry (`body_part_male_BodyTop_coat.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"age": "adult",
|
||||
"sex": "male",
|
||||
"slot": "top",
|
||||
"layer": 2,
|
||||
"garments": ["coat1"],
|
||||
"tags": ["coat1", "coat"],
|
||||
"shape_keys": ["neck-width-plus", "fat"],
|
||||
"mesh": "male_BodyTop_coat.mesh"
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Slots, layers and mesh resolution
|
||||
|
||||
The engine builds a character from one mesh per slot
|
||||
(`CharacterSlotSystem::buildCharacter`): the **face** mesh is the
|
||||
master (it carries the full body skeleton); every other slot's mesh
|
||||
shares that skeleton instance (`shareSkeletonInstanceWith`) — so every
|
||||
body/garment mesh must be skinned to the SAME armature
|
||||
(`mixamorig:` bone names) with compatible bind poses.
|
||||
|
||||
Resolution per slot (`resolveMesh`), given the record's
|
||||
`inlineOutfitLevel` and `inlineSlotSelections`:
|
||||
|
||||
- **outfitLevel 2 (dressed)**: the selected layer-2 mesh; when a
|
||||
layer-1 garment is also selected, a COMBINED mesh whose `garments`
|
||||
array contains both ids is preferred (e.g.
|
||||
`male_BodyBottom_Panties001_Pants0001.mesh` contains
|
||||
`panties0001;pants0001`). Combined meshes are baked by
|
||||
`combine_clothes.py` — a new garment only needs its own entry, the
|
||||
combinations are generated.
|
||||
- **outfitLevel 1 (underwear)**: the selected layer-1 mesh.
|
||||
- **outfitLevel 0 (nude)**: the layer-0 entry tagged
|
||||
**`detailed-nude`** when one exists for the slot (phase 6.C.1) —
|
||||
the plain layer-0 meshes are simplified composition bases meant to
|
||||
disappear under garments and lack anatomical detail. Author at most
|
||||
one `detailed-nude` variant per slot per sex (`top`/`bottom`/`feet`);
|
||||
slots without one keep the composition base, so the variants can be
|
||||
added one slot at a time. Tag it via `clothing_tags =
|
||||
"detailed-nude"` on a layer-0 mesh.
|
||||
- Final fallback: the shortest-named layer-0 entry (Blender `.001`
|
||||
duplicates are penalised), then any entry.
|
||||
|
||||
### Authoring a `detailed-nude` part (whole-body-part replacement)
|
||||
|
||||
A detailed-nude mesh is a COMPLETE standalone body part that replaces
|
||||
the simplified composition base at runtime when the character wears
|
||||
nothing. It is NOT built from the base part by the pipeline and no
|
||||
geometry is ever deleted from it:
|
||||
|
||||
- The hidden-geometry deletion (`combine_clothes.py`) only runs on
|
||||
copies of the base meshes a garment explicitly names in its
|
||||
`ref_part` property. No garment references your new mesh, so it
|
||||
passes through the pipeline untouched, and the plain base part must
|
||||
stay in the blend — garments still need it as their combination
|
||||
target.
|
||||
- The replacement happens at mesh-resolution time only: outfitLevel 0
|
||||
shows the detailed part, outfitLevel >= 1 goes back to the
|
||||
composition bases/combined meshes.
|
||||
|
||||
Steps:
|
||||
|
||||
1. Model the part as a NEW mesh object in the base body blend
|
||||
`edited-normal-<sex>.blend`, next to the existing base parts
|
||||
(`BodyTop`/`BodyBottom`/...). One detailed variant per slot per
|
||||
sex at most (`top`/`bottom`/`feet`); the engine takes the first
|
||||
`detailed-nude` entry it finds for the slot.
|
||||
2. Skin it to the SAME `mixamorig:` armature with the same bind pose,
|
||||
max 4 bone influences per vertex. (`consolidate.py` re-parents it
|
||||
to the armature named by its `sex` property and adds an Armature
|
||||
modifier if missing, but the weights are your job.)
|
||||
3. Match the boundary loops of the base part it replaces (neck, waist,
|
||||
wrists, ankles) vertex-for-vertex in position: the consolidate
|
||||
step's seam fixer unifies shape-key offsets and normals for
|
||||
vertices at identical positions across parts, so matching
|
||||
boundaries come out seamless; mismatched ones will show gaps
|
||||
against the face/hands/neighbouring slots.
|
||||
4. Copy the same shape keys (`fat`, ...) onto it — helper scripts in
|
||||
this directory: `transfer_shape_keys.py`,
|
||||
`propagate_shape_keys.py`, `add_missing_shape_keys.py`.
|
||||
5. Custom properties on the mesh OBJECT: `age`/`sex`/`slot` as usual,
|
||||
`layer` = 0, `clothing_tags` = `detailed-nude`, no `garments`.
|
||||
6. Give object AND mesh data a unique name (e.g.
|
||||
`male_BodyTop_detailed`); the mesh data name becomes the `.mesh`
|
||||
file name.
|
||||
7. Rebuild (`edited-blends` target or full build). No CMake or code
|
||||
changes are needed: consolidate/export discover the part by its
|
||||
custom properties and emit `body_part_<mesh data name>.json`
|
||||
automatically, and the runtime starts using it for nude characters
|
||||
of that slot.
|
||||
|
||||
The town population picks looks deterministically per NPC:
|
||||
`fnv1a(npcKey|slot)` indexes into the layer-1 hair list and (when
|
||||
`outfitLevel >= 2`, the public default) the layer-2 garment lists.
|
||||
Adding or reordering catalog entries changes some NPC looks — that is
|
||||
fine — but never make the catalog itself random: the same source
|
||||
assets must always produce the same town.
|
||||
|
||||
## 4. Skeleton, skinning and animation
|
||||
|
||||
- Skeleton: Mixamo hierarchy, bone names prefixed `mixamorig:`
|
||||
(139 bones). All body/garment parts share it; only hair has its own
|
||||
armature (§5).
|
||||
- **GLES2 hardware skinning**: the runtime injects RTSS hardware
|
||||
skinning (`weight_count 4`, per-part compact bone palette) into every
|
||||
character mesh, so a part may reference at most 4 bone influences per
|
||||
vertex and its compact palette must fit the GPU uniform budget — keep
|
||||
garment weights tight around the bones they actually need (the
|
||||
exporter's weight transfer already does this). Blend weights are
|
||||
repacked to zero-padded float4 at load, so 1-3 influence parts are
|
||||
fine. `EDITSCENE_NO_HWSKIN=1` falls back to software skinning for
|
||||
debugging.
|
||||
- **Facing**: character meshes face **+Z** (registry records carry
|
||||
`frontAxis = +Z`); the walk/run clips' root motion also translates
|
||||
along +Z. A part authored facing -Z moonwalks.
|
||||
- **Root motion**: locomotion uses root motion — the clip's root track
|
||||
must translate the character (do not bake in-place walks).
|
||||
- **Animations** live on the shared skeleton and are referenced by NAME
|
||||
from `animation_tree.json`. State names the trees expect:
|
||||
`idle`, `walking`, `running`, `driving` (-> `sitting-vehicle`),
|
||||
`swimming`, `swim-idle` (-> `treading_water`), `swimming-fast`,
|
||||
plus action states `sitting` / `sitting-ground`.
|
||||
`sitting-vehicle` is authored so the seat pin IS the character's base
|
||||
(root) position — butt/hands/feet land correctly with zero offset
|
||||
(see CAR_MODELING.md §5).
|
||||
- **Shape keys** (e.g. `fat`, `neck-width-plus`) export as OGRE poses;
|
||||
the registry record's `inlineShapeKeyWeights` drives them (pose index
|
||||
= catalog order, Basis excluded). Meshes are shared between
|
||||
characters: when a record has all-zero weights the runtime strips the
|
||||
mesh's poses for performance, so shape-keyed and plain characters
|
||||
must not force the same mesh through both paths in one session.
|
||||
|
||||
## 5. Hair
|
||||
|
||||
Hair meshes export with their OWN armature (`own_skeleton: true`) and
|
||||
attach to the master skeleton's `mixamorig:Head` bone. At runtime
|
||||
`HairPhysicsSystem` builds a Jolt ragdoll over the hair bone chain:
|
||||
the root body is pinned to the head, the rest simulates.
|
||||
|
||||
Modelling consequences:
|
||||
|
||||
- Keep the hair bone chain short and rooted at the scalp; the root
|
||||
bone's bind transform must sit on the head.
|
||||
- Hair needs no shape keys and no layer-2 interaction; it lives on
|
||||
layer 1 with a `hair` tag.
|
||||
- Distant LODs (§6) disable hair physics — hair must still look
|
||||
acceptable as a rigid head-attached mesh.
|
||||
|
||||
## 6. LOD scheme (phase 6.C.3/6.C.4 — implemented)
|
||||
|
||||
Ogre mesh LODs are NOT used for characters (skinned-mesh risk and they
|
||||
cannot merge parts); instead the pipeline generates mesh VARIANTS and
|
||||
`CharacterSlotSystem` swaps whole meshes by camera distance (2 m
|
||||
hysteresis on every boundary):
|
||||
|
||||
- **LOD0 (< 10 m)**: modular, full detail — the normal catalog parts.
|
||||
- **LOD1 (10-30 m)**: per-part `<mesh base>_lod1.mesh` variants — same
|
||||
geometry, shape keys removed, UVs remapped into one shared 2048x2048
|
||||
atlas (`charlod_<sex>-atlas.png`) with a single `charlod_<sex>`
|
||||
material per sex. Hair slots are skipped (hidden/impostor-baked at
|
||||
distance anyway). Parts without a `_lod1` variant keep their base
|
||||
mesh, so the scheme degrades gracefully.
|
||||
- **LOD2 (30-70 m)**: whole-character impostors
|
||||
`charlod_<sex>_thin_lod2.mesh` / `charlod_<sex>_fat_lod2.mesh` — the
|
||||
deterministic default outfit (per slot: highest layer, first entry by
|
||||
name) joined into ONE mesh on the shared armature, `fat` shape key
|
||||
baked at 0/1, hair rigid-bound to `mixamorig:Head`, atlas material.
|
||||
The impostor skin palette is pruned to the 23 core bones (Root,
|
||||
Hips, Spine/1/2, Neck, Head, Shoulder/Arm/ForeArm/Hand and
|
||||
UpLeg/Leg/Foot/ToeBase per side — no fingers): groups of dropped
|
||||
bones (fingers, eyes, bust, hair/coat-skirt physics chains) are
|
||||
remapped to the nearest kept ancestor and merged, keeping hardware
|
||||
skinning well inside the 64-bone budget. The shared skeleton and
|
||||
its animations are untouched, so no animation conversion is needed.
|
||||
All part entities are hidden; the master entity keeps animating the
|
||||
shared skeleton and the impostor follows it via
|
||||
`shareSkeletonInstanceWith`. Hair physics (ragdoll) is OFF.
|
||||
- **LOD3 (70-100 m, till despawn)**: `charlod_<sex>_<variant>_lod3.mesh` —
|
||||
collapse-decimated LOD2, ~400 triangles (hard limit 400).
|
||||
- Interiors need only LOD0/LOD1.
|
||||
|
||||
Generation is automated in the export pipeline:
|
||||
`assets/blender/characters/generate_lods.py` runs per sex on the
|
||||
consolidated blend (CMake step `edited-normal-<sex>-lods.blend`),
|
||||
`export_models2.py` appends the result via its 6th argument and exports
|
||||
the LOD meshes with the SAME shared armature. A manifest
|
||||
(`charlod_<sex>.json`: atlas file + impostor mesh names) is written for
|
||||
reference; the runtime uses name conventions + `resourceExists`, not
|
||||
the manifest. `CharacterSlotSystem::prewarmShaders` also warms the
|
||||
`*_lod1.mesh` / `charlod_*.mesh` shaders.
|
||||
|
||||
Known simplifications (revisit in phase 10.5/11.5 if visible):
|
||||
impostor hair is rigid head-bound, the impostor outfit is the default
|
||||
per-slot pick regardless of the NPC's actual garments, and the atlas
|
||||
downscales each source texture into a grid cell (7 images -> 3x3 grid
|
||||
of ~682 px cells). Known issue: the atlas looks wrong in-game
|
||||
(likely a UV remap bug in generate_lods.py — reported 2026-10-25,
|
||||
deferred; check remap_mesh_uvs against the exported OGRE UV V
|
||||
convention first).
|
||||
|
||||
## 7. Character prefabs
|
||||
|
||||
Characters are saved as `prefabs/character.<id>.json` (phase 6.C.2 —
|
||||
the `character.` prefix distinguishes them from scene prefabs; the
|
||||
editor's prefab save writes the new prefix, old `char_<id>.json` paths
|
||||
migrate on save). A prefab carries the registry record reference,
|
||||
shape-key weights and slot selections — the meshes themselves always
|
||||
come from the catalog.
|
||||
|
||||
## 8. Verifying a new part
|
||||
|
||||
```bash
|
||||
# rebuild the character pipeline, then from the demo-taxi build dir:
|
||||
cd build-vscode/src/features/editScene/demos/demo-taxi
|
||||
xvfb-run -a ./demoTaxi --headless demo_scene_exterior.json \
|
||||
--force-pos 3444 12 3425 0 --print-detail 6000
|
||||
# prints spawned NPCs with their resolved master mesh / age / sex
|
||||
```
|
||||
|
||||
Check: the part appears on NPCs (deterministic pick — several NPCs may
|
||||
need to spawn before a new garment shows), no T-pose (animation tree
|
||||
bound), no floating/crumpling parts (skeleton mismatch — the runtime
|
||||
auto-detects and head-attaches, which hides but does not fix bad
|
||||
weights), shape keys apply on the player character sheet. The full
|
||||
contract suite:
|
||||
|
||||
```bash
|
||||
./demoTaxi --headless --test-motion
|
||||
./demoTaxi --headless --test-detail
|
||||
```
|
||||
@@ -53,8 +53,10 @@ add_custom_command(
|
||||
COMMAND ${BLENDER} -b -Y -P ${CMAKE_SOURCE_DIR}/assets/blender/scripts/export_models2.py --
|
||||
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-consolidated.blend
|
||||
${CMAKE_BINARY_DIR}/characters/male/normal-male.glb
|
||||
"AUTO"
|
||||
"male"
|
||||
tmp-edited-male.blend
|
||||
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-lods.blend
|
||||
COMMAND ${CMAKE_COMMAND} -D FILE=${CMAKE_BINARY_DIR}/characters/male/normal-male.glb
|
||||
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
|
||||
COMMAND ${CMAKE_COMMAND} -D FILE=${CMAKE_BINARY_DIR}/characters/male/normal-male.scene
|
||||
@@ -62,6 +64,7 @@ add_custom_command(
|
||||
DEPENDS ${CMAKE_SOURCE_DIR}/assets/blender/scripts/export_models2.py
|
||||
${VRM_IMPORTED_BLENDS}
|
||||
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-consolidated.blend
|
||||
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-lods.blend
|
||||
# ${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-consolidated-hair.stamp
|
||||
${CMAKE_CURRENT_BINARY_DIR}/blender-addons-installed
|
||||
WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
|
||||
@@ -74,8 +77,10 @@ add_custom_command(
|
||||
COMMAND ${BLENDER} -b -Y -P ${CMAKE_SOURCE_DIR}/assets/blender/scripts/export_models2.py --
|
||||
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-female-consolidated.blend
|
||||
${CMAKE_BINARY_DIR}/characters/female/normal-female.glb
|
||||
"AUTO"
|
||||
"female"
|
||||
tmp-edited-female.blend
|
||||
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-female-lods.blend
|
||||
COMMAND ${CMAKE_COMMAND} -D FILE=${CMAKE_BINARY_DIR}/characters/female/normal-female.glb
|
||||
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
|
||||
COMMAND ${CMAKE_COMMAND} -D FILE=${CMAKE_BINARY_DIR}/characters/female/normal-female.scene
|
||||
@@ -83,6 +88,7 @@ add_custom_command(
|
||||
DEPENDS ${CMAKE_SOURCE_DIR}/assets/blender/scripts/export_models2.py
|
||||
${VRM_IMPORTED_BLENDS}
|
||||
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-female-consolidated.blend
|
||||
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-female-lods.blend
|
||||
${CMAKE_CURRENT_BINARY_DIR}/blender-addons-installed
|
||||
WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
|
||||
VERBATIM
|
||||
@@ -480,3 +486,29 @@ foreach (SEX ${SEX_LIST})
|
||||
set(SLOT_INPUT ${SLOT_OUTPUT})
|
||||
endforeach()
|
||||
endforeach()
|
||||
|
||||
# Character LOD generation (demo-taxi phase 6.C.3/6.C.4): from each
|
||||
# consolidated blend generate per-part "_lod1" atlas variants and
|
||||
# whole-character charlod impostors (LOD2 thin/fat, LOD3 <= 400 tris),
|
||||
# the shared 2048 atlas and the charlod_<sex>.json manifest. The
|
||||
# export_models2 commands above append the lods blend so the impostors
|
||||
# link the same skeleton as the body parts.
|
||||
foreach (SEX ${SEX_LIST})
|
||||
add_custom_command(
|
||||
OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/edited-normal-${SEX}-lods.blend
|
||||
COMMAND ${CMAKE_COMMAND} -E make_directory
|
||||
${CMAKE_BINARY_DIR}/characters/${SEX}
|
||||
COMMAND ${BLENDER} -b -Y
|
||||
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-${SEX}-consolidated.blend
|
||||
-P ${CMAKE_CURRENT_SOURCE_DIR}/generate_lods.py --
|
||||
${SEX}
|
||||
${CMAKE_BINARY_DIR}/characters/${SEX}
|
||||
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-${SEX}-lods.blend
|
||||
COMMAND ${CMAKE_COMMAND} -D FILE=${CMAKE_CURRENT_BINARY_DIR}/edited-normal-${SEX}-lods.blend
|
||||
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
|
||||
DEPENDS ${CMAKE_CURRENT_BINARY_DIR}/edited-normal-${SEX}-consolidated.blend
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/generate_lods.py
|
||||
${CMAKE_CURRENT_BINARY_DIR}/blender-addons-installed
|
||||
COMMENT "Generating character LOD meshes for ${SEX}"
|
||||
)
|
||||
endforeach()
|
||||
|
||||
@@ -0,0 +1,657 @@
|
||||
# generate_lods.py - character LOD asset generation (phase 6.C.3)
|
||||
#
|
||||
# Run in Blender batch mode with the consolidated blend already loaded:
|
||||
#
|
||||
# blender -b -Y edited-normal-<sex>-consolidated.blend \
|
||||
# -P generate_lods.py -- <sex> <out_dir> <lods_blend_out>
|
||||
#
|
||||
# Produces, per sex:
|
||||
# * <out_dir>/charlod_<sex>-atlas.png - shared 2048x2048 LOD atlas
|
||||
# * <out_dir>/charlod_<sex>.json - manifest
|
||||
# * LOD1 object variants `<part>_lod1` - same geometry, no shape keys,
|
||||
# UVs remapped into the atlas, one shared material `charlod_<sex>`
|
||||
# (hair slot is skipped - hair is hidden/impostor-baked at distance)
|
||||
# * LOD2 impostors `charlod_<sex>_{thin,fat}_lod2` - the deterministic
|
||||
# default outfit joined into one mesh skinned to the body armature
|
||||
# * LOD3 impostors `charlod_<sex>_{thin,fat}_lod3` - decimate of LOD2,
|
||||
# <= 400 triangles (hard limit)
|
||||
# * saves everything into <lods_blend_out>
|
||||
#
|
||||
# Documented simplifications:
|
||||
# * Hair re-skinning: hair meshes (which may reference their OWN hair
|
||||
# armature) are rigid-bound 100% to the body skeleton's head bone
|
||||
# (mixamorig:Head) for the impostors. At LOD2+ distances hair physics
|
||||
# is disabled anyway (CHARACTER_MODELING.md section 5/6).
|
||||
# * Default outfit: per slot the highest available layer wins
|
||||
# (2 dressed > 1 underwear > 0 nude), first entry sorted by object
|
||||
# name. This is deterministic and mirrors what the town population
|
||||
# would resolve for a dressed NPC.
|
||||
# * Core-bone palette: LOD2/3 impostors skin only to CORE_BONE_NAMES
|
||||
# (root/spine/neck/head/limbs, 23 bones, no fingers). Groups of
|
||||
# dropped bones (fingers, eyes, bust, hair/coat-skirt physics
|
||||
# chains) are remapped to the nearest kept ancestor and merged.
|
||||
# The shared skeleton and its animations are unchanged, so no
|
||||
# animation conversion is needed.
|
||||
# * UV flipping: none needed - Blender image pixel rows and UV space
|
||||
# share the same bottom-left origin, so UV v maps linearly onto the
|
||||
# pixel row index (v = y / height). The saved PNG keeps that row
|
||||
# order, which is the same convention the existing pipeline exports.
|
||||
# * Source UVs are clamped to [0,1] before remapping so remapped UVs
|
||||
# always stay inside their atlas cell (affects only tiling UVs).
|
||||
#
|
||||
# The script is idempotent: previously generated _lod1/_lod2/_lod3 and
|
||||
# charlod_* objects, the charlod_<sex> material and the atlas image are
|
||||
# wiped at start.
|
||||
|
||||
import bpy
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
ATLAS_SIZE = 2048
|
||||
LOD3_MAX_TRIS = 400
|
||||
REQUIRED_PROPS = {"age", "sex", "slot"}
|
||||
SKIP_PREFIXES = ("cs_", "WGT-", "MCH-", "ORG-")
|
||||
SLOTS_OUTFIT = ("face", "top", "bottom", "feet")
|
||||
|
||||
# Combined LOD2/3 impostors skin to this core palette only (root, spine,
|
||||
# neck, head, limbs - no fingers, no eyes/bust, no physics chains).
|
||||
# 23 bones total, comfortably inside the 64-bone hardware-skinning
|
||||
# budget. Groups of dropped bones are remapped to the nearest kept
|
||||
# ancestor (fingers -> Hand, Eye/Bust -> Head/Spine2, coat skirt ->
|
||||
# Leg). The armature and its animations are NOT touched: the impostor
|
||||
# shares the full skeleton at runtime, only the vertex weights change,
|
||||
# so no animation conversion is needed.
|
||||
CORE_BONE_NAMES = {
|
||||
"Root",
|
||||
"mixamorig:Hips",
|
||||
"mixamorig:Spine",
|
||||
"mixamorig:Spine1",
|
||||
"mixamorig:Spine2",
|
||||
"mixamorig:Neck",
|
||||
"mixamorig:Head",
|
||||
}
|
||||
for _side in ("Left", "Right"):
|
||||
for _part in ("Shoulder", "Arm", "ForeArm", "Hand",
|
||||
"UpLeg", "Leg", "Foot", "ToeBase"):
|
||||
CORE_BONE_NAMES.add("mixamorig:" + _side + _part)
|
||||
MAX_IMPOSTOR_BONES = 64
|
||||
|
||||
|
||||
def log(msg):
|
||||
print("[generate_lods] " + msg, flush=True)
|
||||
|
||||
|
||||
def parse_args():
|
||||
argv = sys.argv
|
||||
argv = argv[argv.index("--") + 1:]
|
||||
if len(argv) < 3:
|
||||
raise RuntimeError(
|
||||
"usage: blender -b -Y <consolidated.blend> -P generate_lods.py"
|
||||
" -- <sex> <out_dir> <lods_blend_out>")
|
||||
return argv[0], argv[1], argv[2]
|
||||
|
||||
|
||||
def strip_suffix(name):
|
||||
"""Strip Blender .### duplicate suffix from a datablock name."""
|
||||
return re.sub(r"\.\d{3}$", "", name)
|
||||
|
||||
|
||||
def collect_catalog_objects():
|
||||
"""Same classification rule as consolidate.py."""
|
||||
result = []
|
||||
for ob in bpy.data.objects:
|
||||
if ob.type != "MESH":
|
||||
continue
|
||||
if not all(p in ob.keys() for p in REQUIRED_PROPS):
|
||||
continue
|
||||
if ob.name.startswith(SKIP_PREFIXES):
|
||||
continue
|
||||
result.append(ob)
|
||||
return sorted(result, key=lambda o: o.name)
|
||||
|
||||
|
||||
def find_armature(sex):
|
||||
arm = bpy.data.objects.get(sex)
|
||||
if arm and arm.type == "ARMATURE":
|
||||
return arm
|
||||
for ob in bpy.data.objects:
|
||||
if ob.type == "ARMATURE" and ob.name == sex:
|
||||
return ob
|
||||
raise RuntimeError("shared armature '%s' not found" % sex)
|
||||
|
||||
|
||||
def find_head_bone(armature):
|
||||
bones = armature.data.bones
|
||||
if "mixamorig:Head" in bones:
|
||||
return "mixamorig:Head"
|
||||
for b in bones:
|
||||
if "Head" in b.name:
|
||||
log("WARNING: mixamorig:Head not found, using '%s'" % b.name)
|
||||
return b.name
|
||||
raise RuntimeError("no head bone found in armature '%s'"
|
||||
% armature.name)
|
||||
|
||||
|
||||
def base_color_image(mat):
|
||||
"""Image whose content the mesh UVs address: the Base Color texture
|
||||
of the Principled BSDF, falling back to the first TEX_IMAGE node."""
|
||||
if not mat or not mat.use_nodes or not mat.node_tree:
|
||||
return None
|
||||
for node in mat.node_tree.nodes:
|
||||
if node.type == "BSDF_PRINCIPLED":
|
||||
inp = node.inputs.get("Base Color")
|
||||
if inp and inp.is_linked:
|
||||
src = inp.links[0].from_node
|
||||
if src.type == "TEX_IMAGE" and src.image:
|
||||
return src.image
|
||||
for node in mat.node_tree.nodes:
|
||||
if node.type == "TEX_IMAGE" and node.image:
|
||||
log("WARNING: material '%s' has no direct Base Color texture,"
|
||||
" falling back to image '%s'" % (mat.name, node.image.name))
|
||||
return node.image
|
||||
return None
|
||||
|
||||
|
||||
def cleanup_previous(sex):
|
||||
"""Idempotency: wipe previously generated data."""
|
||||
doomed = []
|
||||
for ob in list(bpy.data.objects):
|
||||
if (ob.name.startswith("charlod_") or ob.name.endswith("_lod1")
|
||||
or ob.name.endswith("_lod2") or ob.name.endswith("_lod3")):
|
||||
doomed.append(ob)
|
||||
for ob in doomed:
|
||||
log("wiping previous object '%s'" % ob.name)
|
||||
bpy.data.objects.remove(ob, do_unlink=True)
|
||||
for me in list(bpy.data.meshes):
|
||||
if me.users == 0 and (me.name.startswith("charlod_")
|
||||
or me.name.endswith("_lod1")
|
||||
or me.name.endswith("_lod2")
|
||||
or me.name.endswith("_lod3")):
|
||||
bpy.data.meshes.remove(me)
|
||||
mat = bpy.data.materials.get("charlod_" + sex)
|
||||
if mat:
|
||||
bpy.data.materials.remove(mat)
|
||||
for img in list(bpy.data.images):
|
||||
if img.name.startswith("charlod_%s-atlas" % sex):
|
||||
bpy.data.images.remove(img)
|
||||
|
||||
|
||||
def build_atlas(catalog, sex, out_dir):
|
||||
"""Paste every distinct base-color image into a grid cell of a new
|
||||
ATLAS_SIZE x ATLAS_SIZE image. Returns (atlas_image, rects) where
|
||||
rects maps dedupe key -> (u0, v0, u1, v1)."""
|
||||
canonical = {} # dedupe key -> bpy.types.Image
|
||||
for ob in catalog:
|
||||
for slot in ob.material_slots:
|
||||
img = base_color_image(slot.material)
|
||||
if img is None:
|
||||
continue
|
||||
key = strip_suffix(img.name)
|
||||
if key not in canonical or not key.endswith(
|
||||
strip_suffix(canonical[key].name)):
|
||||
# prefer the unsuffixed instance
|
||||
if key not in canonical or img.name == key:
|
||||
canonical[key] = img
|
||||
keys = sorted(canonical.keys())
|
||||
log("atlas source images (%d): %s" % (len(keys), keys))
|
||||
|
||||
n = max(1, math.ceil(math.sqrt(len(keys))))
|
||||
cell = ATLAS_SIZE // n
|
||||
log("atlas grid %dx%d, cell %dpx" % (n, n, cell))
|
||||
|
||||
atlas_name = "charlod_%s-atlas.png" % sex
|
||||
atlas = bpy.data.images.new(atlas_name, width=ATLAS_SIZE,
|
||||
height=ATLAS_SIZE, alpha=True)
|
||||
buf = np.zeros(ATLAS_SIZE * ATLAS_SIZE * 4, dtype=np.float32)
|
||||
grid = buf.reshape(ATLAS_SIZE, ATLAS_SIZE, 4)
|
||||
|
||||
rects = {}
|
||||
for idx, key in enumerate(keys):
|
||||
img = canonical[key]
|
||||
w, h = img.size
|
||||
if w == 0 or h == 0:
|
||||
log("WARNING: image '%s' has no pixel data, skipped"
|
||||
% img.name)
|
||||
continue
|
||||
col = idx % n
|
||||
row = idx // n # rows counted from the bottom, like UV v
|
||||
scale = min(cell / w, cell / h)
|
||||
pw = max(1, int(round(w * scale)))
|
||||
ph = max(1, int(round(h * scale)))
|
||||
tmp = img.copy()
|
||||
tmp.scale(pw, ph)
|
||||
px = np.empty(pw * ph * 4, dtype=np.float32)
|
||||
tmp.pixels.foreach_get(px)
|
||||
bpy.data.images.remove(tmp)
|
||||
tile = px.reshape(ph, pw, 4)
|
||||
x0 = col * cell + (cell - pw) // 2
|
||||
y0 = row * cell + (cell - ph) // 2
|
||||
grid[y0:y0 + ph, x0:x0 + pw, :] = tile
|
||||
rects[key] = (x0 / ATLAS_SIZE, y0 / ATLAS_SIZE,
|
||||
(x0 + pw) / ATLAS_SIZE, (y0 + ph) / ATLAS_SIZE)
|
||||
log(" cell (%d,%d): '%s' %dx%d -> %dx%d at (%d,%d) rect %s"
|
||||
% (col, row, key, w, h, pw, ph, x0, y0,
|
||||
["%.4f" % v for v in rects[key]]))
|
||||
|
||||
atlas.pixels.foreach_set(buf)
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
atlas_path = os.path.join(out_dir, atlas_name)
|
||||
atlas.filepath_raw = atlas_path
|
||||
atlas.file_format = "PNG"
|
||||
atlas.save()
|
||||
log("atlas saved to %s" % atlas_path)
|
||||
return atlas, rects
|
||||
|
||||
|
||||
def make_atlas_material(sex, atlas):
|
||||
mat_name = "charlod_" + sex
|
||||
mat = bpy.data.materials.new(mat_name)
|
||||
mat.use_nodes = True
|
||||
nt = mat.node_tree
|
||||
bsdf = next(n for n in nt.nodes if n.type == "BSDF_PRINCIPLED")
|
||||
tex = nt.nodes.new("ShaderNodeTexImage")
|
||||
tex.image = atlas
|
||||
nt.links.new(tex.outputs["Color"], bsdf.inputs["Base Color"])
|
||||
mat.blend_method = "OPAQUE"
|
||||
log("created shared material '%s' with texture '%s'"
|
||||
% (mat_name, atlas.name))
|
||||
return mat
|
||||
|
||||
|
||||
def slot_rects(ob, rects):
|
||||
"""Per material slot of ob: atlas rect of its base-color image."""
|
||||
result = []
|
||||
for slot in ob.material_slots:
|
||||
img = base_color_image(slot.material)
|
||||
if img is None:
|
||||
result.append(None)
|
||||
continue
|
||||
result.append(rects.get(strip_suffix(img.name)))
|
||||
return result
|
||||
|
||||
|
||||
def remap_mesh_uvs(mesh, rects, owner):
|
||||
"""Affine-remap loop UVs into the atlas cells of their material slot.
|
||||
Source UVs are clamped to [0,1] first so output stays in [0,1]."""
|
||||
uv_layer = mesh.uv_layers.active
|
||||
if uv_layer is None:
|
||||
log("WARNING: '%s' has no UV layer, UVs not remapped" % owner)
|
||||
return
|
||||
nloops = len(mesh.loops)
|
||||
if nloops == 0:
|
||||
return
|
||||
uvs = np.empty(nloops * 2, dtype=np.float32)
|
||||
uv_layer.data.foreach_get("uv", uvs)
|
||||
uvs = uvs.reshape(-1, 2)
|
||||
np.clip(uvs, 0.0, 1.0, out=uvs)
|
||||
slot_of_loop = np.zeros(nloops, dtype=np.int32)
|
||||
for poly in mesh.polygons:
|
||||
slot_of_loop[poly.loop_start:
|
||||
poly.loop_start + poly.loop_total] = \
|
||||
poly.material_index
|
||||
for si, rect in enumerate(rects):
|
||||
if rect is None:
|
||||
continue
|
||||
mask = slot_of_loop == si
|
||||
if not mask.any():
|
||||
continue
|
||||
u0, v0, u1, v1 = rect
|
||||
sub = uvs[mask]
|
||||
sub[:, 0] = u0 + sub[:, 0] * (u1 - u0)
|
||||
sub[:, 1] = v0 + sub[:, 1] * (v1 - v0)
|
||||
uvs[mask] = sub
|
||||
uv_layer.data.foreach_set("uv", uvs.ravel())
|
||||
lo = uvs.min()
|
||||
hi = uvs.max()
|
||||
if lo < -1e-4 or hi > 1.0 + 1e-4:
|
||||
log("WARNING: '%s' UVs out of [0,1] after remap: %f..%f"
|
||||
% (owner, lo, hi))
|
||||
|
||||
|
||||
def apply_atlas_material(mesh, atlas_mat):
|
||||
mesh.materials.clear()
|
||||
mesh.materials.append(atlas_mat)
|
||||
for poly in mesh.polygons:
|
||||
poly.material_index = 0
|
||||
|
||||
|
||||
def copy_object(ob, new_name, collection):
|
||||
dup = ob.copy()
|
||||
dup.data = ob.data.copy()
|
||||
dup.name = new_name
|
||||
dup.data.name = new_name
|
||||
collection.objects.link(dup)
|
||||
return dup
|
||||
|
||||
|
||||
def generate_lod1(catalog, rects, atlas_mat, sex):
|
||||
"""LOD1: per catalog mesh (hair excluded) a shape-key-free copy with
|
||||
atlas UVs and the single shared material."""
|
||||
made = []
|
||||
for ob in catalog:
|
||||
if ob.get("slot") == "hair":
|
||||
continue
|
||||
new_name = ob.name + "_lod1"
|
||||
dup = copy_object(ob, new_name, bpy.context.collection)
|
||||
dup.shape_key_clear()
|
||||
for key in ob.keys():
|
||||
dup[key] = ob[key]
|
||||
dup["lod"] = 1
|
||||
remap_mesh_uvs(dup.data, slot_rects(ob, rects), new_name)
|
||||
apply_atlas_material(dup.data, atlas_mat)
|
||||
made.append(dup)
|
||||
log("LOD1 '%s': %d polys, props copied, lod=1"
|
||||
% (new_name, len(dup.data.polygons)))
|
||||
return made
|
||||
|
||||
|
||||
def pick_default_outfit(catalog):
|
||||
"""Deterministic default outfit: per slot the highest available
|
||||
layer (2 > 1 > 0), first object sorted by name. Hair: layer-1,
|
||||
skipping bald/placeholder meshes (< 100 polys or 'bald' in name)."""
|
||||
picks = {}
|
||||
for slot in SLOTS_OUTFIT + ("hair",):
|
||||
entries = [ob for ob in catalog if ob.get("slot") == slot]
|
||||
if slot == "hair":
|
||||
entries = [ob for ob in entries
|
||||
if int(ob.get("layer", 0)) >= 1
|
||||
and "bald" not in ob.name.lower()
|
||||
and len(ob.data.polygons) >= 100]
|
||||
if not entries:
|
||||
log("WARNING: no catalog entry for slot '%s'" % slot)
|
||||
continue
|
||||
best_layer = max(int(ob.get("layer", 0)) for ob in entries)
|
||||
best = sorted((ob for ob in entries
|
||||
if int(ob.get("layer", 0)) == best_layer),
|
||||
key=lambda o: o.name)[0]
|
||||
picks[slot] = best
|
||||
log("default outfit slot '%s': '%s' (layer %d)"
|
||||
% (slot, best.name, best_layer))
|
||||
return picks
|
||||
|
||||
|
||||
def bake_shape_keys(ob, fat_value, depsgraph):
|
||||
"""Evaluate ob with the 'fat' shape key set to fat_value and all
|
||||
shape keys baked away. Returns a new shape-key-free mesh (vertex
|
||||
groups, UVs and material slots preserved)."""
|
||||
dup = ob.copy()
|
||||
dup.data = ob.data.copy()
|
||||
bpy.context.collection.objects.link(dup)
|
||||
if dup.data.shape_keys:
|
||||
for kb in dup.data.shape_keys.key_blocks:
|
||||
if kb.name == "fat":
|
||||
kb.value = fat_value
|
||||
eval_ob = dup.evaluated_get(depsgraph)
|
||||
mesh = bpy.data.meshes.new_from_object(
|
||||
eval_ob, preserve_all_data_layers=True, depsgraph=depsgraph)
|
||||
bpy.data.objects.remove(dup, do_unlink=True)
|
||||
return mesh
|
||||
|
||||
|
||||
def rigid_bind_to_head(obj, head_bone):
|
||||
"""Re-skin every vertex 100% to the head bone (hair shortcut, see
|
||||
header)."""
|
||||
for vg in list(obj.vertex_groups):
|
||||
obj.vertex_groups.remove(vg)
|
||||
vg = obj.vertex_groups.new(name=head_bone)
|
||||
vg.add(list(range(len(obj.data.vertices))), 1.0, "REPLACE")
|
||||
log(" rigid-bound %d verts of '%s' to '%s'"
|
||||
% (len(obj.data.vertices), obj.name, head_bone))
|
||||
|
||||
|
||||
def core_bone_target(name, parents):
|
||||
"""Nearest kept ancestor (or the bone itself) for the impostor
|
||||
palette. Bones outside the mixamorig hierarchy (control rig)
|
||||
fall back to the skeleton root."""
|
||||
cur = name
|
||||
while cur is not None:
|
||||
if cur in CORE_BONE_NAMES:
|
||||
return cur
|
||||
cur = parents.get(cur)
|
||||
return "Root"
|
||||
|
||||
|
||||
def prune_to_core_bones(obj, armature):
|
||||
"""Collapse the impostor skin palette to CORE_BONE_NAMES. Vertex
|
||||
groups of dropped bones are remapped to their nearest kept
|
||||
ancestor and their weights merged per vertex. The armature, its
|
||||
bone hierarchy and all animations stay untouched - only the
|
||||
impostor's vertex weights change."""
|
||||
parents = {b.name: b.parent.name if b.parent else None
|
||||
for b in armature.data.bones}
|
||||
lut = {}
|
||||
for vg in obj.vertex_groups:
|
||||
target = core_bone_target(vg.name, parents)
|
||||
if target not in parents:
|
||||
raise RuntimeError("core bone '%s' missing from armature"
|
||||
% target)
|
||||
lut[vg.index] = target
|
||||
merged = []
|
||||
unweighted = False
|
||||
for v in obj.data.vertices:
|
||||
acc = {}
|
||||
for g in v.groups:
|
||||
if g.weight <= 0.0:
|
||||
continue
|
||||
name = lut[g.group]
|
||||
acc[name] = acc.get(name, 0.0) + g.weight
|
||||
if not acc:
|
||||
unweighted = True
|
||||
merged.append(acc)
|
||||
for vg in list(obj.vertex_groups):
|
||||
obj.vertex_groups.remove(vg)
|
||||
used = sorted({name for acc in merged for name in acc})
|
||||
if unweighted:
|
||||
used.append("Root")
|
||||
groups = {name: obj.vertex_groups.new(name=name) for name in used}
|
||||
for vi, acc in enumerate(merged):
|
||||
if not acc:
|
||||
groups["Root"].add([vi], 1.0, "REPLACE")
|
||||
continue
|
||||
for name, w in acc.items():
|
||||
if w > 1e-6:
|
||||
groups[name].add([vi], w, "REPLACE")
|
||||
log("impostor '%s': skin palette pruned to %d core bones%s: %s"
|
||||
% (obj.name, len(groups),
|
||||
" (unweighted verts pinned to Root)" if unweighted else "",
|
||||
", ".join(sorted(groups))))
|
||||
if len(groups) > MAX_IMPOSTOR_BONES:
|
||||
raise RuntimeError("impostor '%s' palette %d > %d bones"
|
||||
% (obj.name, len(groups), MAX_IMPOSTOR_BONES))
|
||||
|
||||
|
||||
def join_objects(objects, name, armature):
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
for ob in objects:
|
||||
ob.select_set(True)
|
||||
bpy.context.view_layer.objects.active = objects[0]
|
||||
bpy.ops.object.join()
|
||||
joined = bpy.context.view_layer.objects.active
|
||||
joined.name = name
|
||||
joined.data.name = name
|
||||
joined.parent = armature
|
||||
joined.matrix_parent_inverse = armature.matrix_world.inverted()
|
||||
arm_mod = next((m for m in joined.modifiers
|
||||
if m.type == "ARMATURE"), None)
|
||||
if arm_mod is None:
|
||||
arm_mod = joined.modifiers.new(name="Armature", type="ARMATURE")
|
||||
arm_mod.object = armature
|
||||
return joined
|
||||
|
||||
|
||||
def count_tris(mesh):
|
||||
mesh.calc_loop_triangles()
|
||||
return len(mesh.loop_triangles)
|
||||
|
||||
|
||||
def generate_lod2(catalog, rects, atlas_mat, armature, head_bone, sex):
|
||||
made = {}
|
||||
for variant, fat_value in (("thin", 0.0), ("fat", 1.0)):
|
||||
picks = pick_default_outfit(catalog)
|
||||
depsgraph = bpy.context.evaluated_depsgraph_get()
|
||||
parts = []
|
||||
for slot in SLOTS_OUTFIT + ("hair",):
|
||||
ob = picks.get(slot)
|
||||
if ob is None:
|
||||
continue
|
||||
mesh = bake_shape_keys(ob, fat_value, depsgraph)
|
||||
part = bpy.data.objects.new(
|
||||
"charlod_tmp_%s_%s" % (variant, slot), mesh)
|
||||
bpy.context.collection.objects.link(part)
|
||||
part.matrix_world = ob.matrix_world
|
||||
remap_mesh_uvs(mesh, slot_rects(ob, rects),
|
||||
"lod2/%s/%s" % (variant, ob.name))
|
||||
apply_atlas_material(mesh, atlas_mat)
|
||||
if slot == "hair":
|
||||
rigid_bind_to_head(part, head_bone)
|
||||
parts.append(part)
|
||||
name = "charlod_%s_%s_lod2" % (sex, variant)
|
||||
joined = join_objects(parts, name, armature)
|
||||
prune_to_core_bones(joined, armature)
|
||||
tris = count_tris(joined.data)
|
||||
n_vg = len(joined.vertex_groups)
|
||||
has_head = any(vg.name == head_bone
|
||||
for vg in joined.vertex_groups)
|
||||
log("LOD2 '%s': %d polys, %d tris, %d vertex groups, "
|
||||
"head group: %s" % (name, len(joined.data.polygons), tris,
|
||||
n_vg, has_head))
|
||||
made[variant] = joined
|
||||
return made
|
||||
|
||||
|
||||
def decimated_mesh(src_obj, ratio, depsgraph=None):
|
||||
tmp = src_obj.copy()
|
||||
tmp.data = src_obj.data.copy()
|
||||
bpy.context.collection.objects.link(tmp)
|
||||
mod = tmp.modifiers.new(name="Decimate", type="DECIMATE")
|
||||
mod.decimate_type = "COLLAPSE"
|
||||
mod.ratio = ratio
|
||||
# refresh so the just-added modifier is part of the evaluation
|
||||
bpy.context.view_layer.update()
|
||||
depsgraph = bpy.context.evaluated_depsgraph_get()
|
||||
eval_ob = tmp.evaluated_get(depsgraph)
|
||||
mesh = bpy.data.meshes.new_from_object(
|
||||
eval_ob, preserve_all_data_layers=True, depsgraph=depsgraph)
|
||||
bpy.data.objects.remove(tmp, do_unlink=True)
|
||||
return mesh
|
||||
|
||||
|
||||
def generate_lod3(lod2_map, armature, sex):
|
||||
made = {}
|
||||
for variant, src in lod2_map.items():
|
||||
base_tris = count_tris(src.data)
|
||||
name = "charlod_%s_%s_lod3" % (sex, variant)
|
||||
depsgraph = bpy.context.evaluated_depsgraph_get()
|
||||
if base_tris <= LOD3_MAX_TRIS:
|
||||
mesh = src.data.copy()
|
||||
log("LOD3 '%s': already %d tris (<= %d), no decimate"
|
||||
% (name, base_tris, LOD3_MAX_TRIS))
|
||||
else:
|
||||
# binary search for the highest ratio still <= LOD3_MAX_TRIS;
|
||||
# a 0-tri result means the ratio collapsed everything and is
|
||||
# too LOW, not acceptable.
|
||||
lo, hi = 0.001, 1.0
|
||||
best_mesh, best_tris, best_ratio = None, 0, lo
|
||||
for _ in range(20):
|
||||
ratio = (lo + hi) / 2.0
|
||||
mesh = decimated_mesh(src, ratio, depsgraph)
|
||||
tris = count_tris(mesh)
|
||||
log(" decimate try ratio %.4f -> %d tris"
|
||||
% (ratio, tris))
|
||||
if tris > LOD3_MAX_TRIS:
|
||||
hi = ratio
|
||||
bpy.data.meshes.remove(mesh)
|
||||
elif tris == 0:
|
||||
lo = ratio
|
||||
bpy.data.meshes.remove(mesh)
|
||||
else:
|
||||
lo = ratio
|
||||
if best_mesh is not None:
|
||||
bpy.data.meshes.remove(best_mesh)
|
||||
best_mesh, best_tris, best_ratio = mesh, tris, ratio
|
||||
if best_mesh is None:
|
||||
raise RuntimeError(
|
||||
"LOD3 '%s': decimate never reached <= %d tris"
|
||||
% (name, LOD3_MAX_TRIS))
|
||||
mesh = best_mesh
|
||||
log("LOD3 '%s': decimate ratio %.4f -> %d tris "
|
||||
"(limit %d, source %d)"
|
||||
% (name, best_ratio, best_tris, LOD3_MAX_TRIS,
|
||||
base_tris))
|
||||
obj = bpy.data.objects.new(name, mesh)
|
||||
bpy.context.collection.objects.link(obj)
|
||||
# A fresh object may already carry the source's vertex groups
|
||||
# via new_from_object; only fill in any missing ones so the
|
||||
# decimated mesh's group indices keep resolving to the same
|
||||
# (core-pruned) bones on export.
|
||||
for vg in src.vertex_groups:
|
||||
if obj.vertex_groups.get(vg.name) is None:
|
||||
obj.vertex_groups.new(name=vg.name)
|
||||
obj.parent = armature
|
||||
obj.matrix_parent_inverse = armature.matrix_world.inverted()
|
||||
arm_mod = obj.modifiers.new(name="Armature", type="ARMATURE")
|
||||
arm_mod.object = armature
|
||||
made[variant] = obj
|
||||
return made
|
||||
|
||||
|
||||
def write_manifest(sex, out_dir):
|
||||
manifest = {
|
||||
"atlas": "charlod_%s-atlas.png" % sex,
|
||||
"variants": ["thin", "fat"],
|
||||
"lod2": {v: "charlod_%s_%s_lod2.mesh" % (sex, v)
|
||||
for v in ("thin", "fat")},
|
||||
"lod3": {v: "charlod_%s_%s_lod3.mesh" % (sex, v)
|
||||
for v in ("thin", "fat")},
|
||||
}
|
||||
path = os.path.join(out_dir, "charlod_%s.json" % sex)
|
||||
with open(path, "w") as f:
|
||||
json.dump(manifest, f, indent=2)
|
||||
log("manifest written to %s" % path)
|
||||
|
||||
|
||||
def main():
|
||||
sex, out_dir, lods_blend_out = parse_args()
|
||||
log("sex=%s out_dir=%s lods_blend=%s" % (sex, out_dir,
|
||||
lods_blend_out))
|
||||
|
||||
cleanup_previous(sex)
|
||||
|
||||
armature = find_armature(sex)
|
||||
head_bone = find_head_bone(armature)
|
||||
log("shared armature '%s', head bone '%s'"
|
||||
% (armature.name, head_bone))
|
||||
|
||||
catalog = collect_catalog_objects()
|
||||
log("catalog objects (%d): %s"
|
||||
% (len(catalog), [ob.name for ob in catalog]))
|
||||
|
||||
log("=== step 1: atlas ===")
|
||||
atlas, rects = build_atlas(catalog, sex, out_dir)
|
||||
atlas_mat = make_atlas_material(sex, atlas)
|
||||
|
||||
log("=== step 2: LOD1 variants ===")
|
||||
lod1 = generate_lod1(catalog, rects, atlas_mat, sex)
|
||||
log("generated %d LOD1 objects" % len(lod1))
|
||||
|
||||
log("=== step 3: LOD2 impostors ===")
|
||||
lod2 = generate_lod2(catalog, rects, atlas_mat, armature,
|
||||
head_bone, sex)
|
||||
|
||||
log("=== step 4: LOD3 impostors ===")
|
||||
generate_lod3(lod2, armature, sex)
|
||||
|
||||
log("=== step 5: manifest + save ===")
|
||||
write_manifest(sex, out_dir)
|
||||
bpy.ops.wm.save_as_mainfile(filepath=lods_blend_out)
|
||||
log("saved lods blend to %s" % lods_blend_out)
|
||||
log("DONE")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -187,7 +187,14 @@ def extra_linear(angle, offset):
|
||||
mapping_blend_path = argv[0]
|
||||
mapping_gltf_path = argv[1]
|
||||
# Backward compat: support both 4-arg (blend, gltf, armature, outfile) and 5-arg (blend, gltf, objects, armature, outfile)
|
||||
if len(argv) >= 5:
|
||||
mapping_lods_path = ""
|
||||
if len(argv) >= 6:
|
||||
# 6-arg form: (blend, gltf, objects, armature, outfile, lods_blend)
|
||||
mapping_objects = argv[2]
|
||||
mapping_armature_name = argv[3]
|
||||
mapping_outfile = argv[4]
|
||||
mapping_lods_path = argv[5]
|
||||
elif len(argv) >= 5:
|
||||
mapping_objects = argv[2]
|
||||
mapping_armature_name = argv[3]
|
||||
mapping_outfile = argv[4]
|
||||
@@ -480,6 +487,46 @@ for mapping in[CommandLineMapping()]:
|
||||
with open(json_filepath, 'w') as f:
|
||||
json.dump(save_data, f, indent=2)
|
||||
|
||||
# ---- Character LOD meshes (demo-taxi phase 6.C.3/6.C.4) ----
|
||||
# Optional 6th CLI arg: the generate_lods.py output blend. Its
|
||||
# objects ("<part>_lod1" variants and "charlod_<sex>_*" impostors)
|
||||
# are appended AFTER auto-discovery/catalog-json writing so they
|
||||
# never get body_part_*.json entries, re-parented to the scene
|
||||
# armature (the one prepare_armature produced, which exports the
|
||||
# shared skeleton) and exported with the main OGRE pass below, so
|
||||
# the impostor meshes link the SAME skeleton resource as the body
|
||||
# parts (the runtime shares skeleton instances between them).
|
||||
lod_obj_names = []
|
||||
if mapping_lods_path and os.path.exists(mapping_lods_path):
|
||||
print("Appending character LOD objects from: " + mapping_lods_path)
|
||||
with bpy.data.libraries.load(mapping_lods_path) as (data_from, data_to):
|
||||
data_to.objects = [
|
||||
n for n in data_from.objects
|
||||
if n.startswith("charlod_") or n.endswith("_lod1")]
|
||||
body_arm = bpy.data.objects.get(mapping.armature_name)
|
||||
for obj in data_to.objects:
|
||||
if obj is None:
|
||||
continue
|
||||
try:
|
||||
bpy.context.collection.objects.link(obj)
|
||||
except RuntimeError:
|
||||
pass # Already linked
|
||||
lod_obj_names.append(obj.name)
|
||||
# Mesh data names get the sex prefix like the body parts
|
||||
# (charlod_ names already carry it).
|
||||
if not obj.name.startswith("charlod_"):
|
||||
obj.data.name = prefix + obj.name
|
||||
else:
|
||||
obj.data.name = obj.name
|
||||
if body_arm:
|
||||
obj.parent = body_arm
|
||||
obj.matrix_parent_inverse = body_arm.matrix_world.inverted()
|
||||
for mod in obj.modifiers:
|
||||
if mod.type == 'ARMATURE':
|
||||
mod.object = body_arm
|
||||
print(f" LOD object '{obj.name}' -> mesh '{obj.data.name}'")
|
||||
# -------------------------------------------------------------
|
||||
|
||||
# Triangulate body part meshes before OGRE export to prevent the exporter's
|
||||
# triangulation from producing mismatched split normals at seams.
|
||||
# BodyTop and BodyBottom have different face topologies, so the exporter's
|
||||
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
+19
@@ -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": []
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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, "
|
||||
|
||||
Reference in New Issue
Block a user