Compare commits

...

92 Commits

Author SHA1 Message Date
slapin 214b05b40f Phase 7 checkpoint 2026-10-30 19:58:52 +03:00
slapin 141bfd31c1 Phase 7 checkpoint 2026-10-30 18:24:30 +03:00
slapin 7e0a74d837 Phase 7 start checkpoint 2026-10-30 16:47:10 +03:00
slapin 65066e6d29 Phase 6.8 checkpoint 2026-10-30 15:33:41 +03:00
slapin a3ec423cca Phase 6.8 checkpoint 2026-10-30 00:27:47 +03:00
slapin 19c5528f8e phase 6.8 checkpoint 2026-10-29 02:39:05 +03:00
slapin 2873a695f7 phase 6.8 checkpoint 2026-10-29 02:16:52 +03:00
slapin 54d9d4a28e phase 6.8 checkpoint 2026-10-29 01:31:02 +03:00
slapin d5b9ae235e Phase 6.8: character stats foundation 2026-10-28 15:56:20 +03:00
slapin c643d4388f Phase 6.7 complete checkpoint - checked 2026-10-28 13:38:24 +03:00
slapin 682ccee856 Phase 6.7 complete checkpoint 2026-10-28 12:43:16 +03:00
slapin d0ca3d181a Phase 6.7 complete checkpoint 2026-10-28 10:49:22 +03:00
slapin 72851b442f Phase 6.6 complete checkpoint 2026-10-28 07:35:18 +03:00
slapin 3dfd6c12bd Plan update; Loading screen for scene switching door is finally fixed 2026-10-26 16:25:28 +03:00
slapin e918ef8c4b Fixes for doors and arrival positions 2026-10-26 15:22:35 +03:00
slapin 88fd97fedd Phase 6.5 checkpoint again 2026-10-26 12:58:02 +03:00
slapin ec3f98f8a0 Keep legacy parts still compiling 2026-10-26 12:56:39 +03:00
slapin adc4c91a2c Phase 6.5 checkpoint 2026-10-26 11:03:11 +03:00
slapin 9e2d9fce59 Scheduled driving 2026-10-24 16:38:53 +03:00
slapin 95b42090f9 Data stability improved, town works much better 2026-10-23 16:08:29 +03:00
slapin e609d4e39b Improved data handling 2026-10-23 07:13:39 +03:00
slapin c561fad79e Fixed vehicle rendering 2026-10-22 10:14:44 +03:00
slapin ff81a9a657 Fixed some glass material woes making characters look bals and faceless 2026-10-22 07:58:19 +03:00
slapin 73e1cef832 Hardweware skinning works! 2026-10-21 12:21:58 +03:00
slapin 136013440e In phase 5 2026-10-16 15:24:34 +03:00
slapin e58c061709 NPC motion 2026-10-14 05:35:03 +03:00
slapin 6f152b0248 Layout updates. 2026-10-11 22:45:26 +03:00
slapin d28fce7631 Phase 4.7: in progress 2026-10-11 22:23:43 +03:00
slapin 00b2d5d1fe Phase 4.6 complete: buildings made of rooms, per-building regen 2026-10-10 10:30:44 +03:00
slapin a3279f984b Phase 4.5, town and building layouts 2026-10-08 05:16:04 +03:00
slapin 7adfaa53a2 Phases till 4 implemented. Basic generation of the town is added 2026-10-05 16:55:04 +03:00
slapin 5ca9b66d72 Road geometry fixed 2026-10-01 04:33:51 +03:00
slapin f9689eaa32 taxi demo: Taxi integrated, phase 1 complete 2026-09-17 04:58:46 +03:00
slapin 129557c4ba Taxi sedan added 2026-09-17 04:09:22 +03:00
slapin 3c7f50a4ef Taxi demo update with sedan 2026-09-17 02:55:53 +03:00
slapin c748a7dd1e Custom model sedan was integrated. 2026-09-17 02:51:45 +03:00
slapin e2e4a398a9 Sedan vehicle added 2026-09-16 19:41:54 +03:00
slapin 55cd5f3c52 taxi: Phase 0 2026-09-15 15:05:41 +03:00
slapin 6d10fec2a0 Added taxi model 2026-09-15 14:12:22 +03:00
slapin d300a16333 Sokoban demo is complete 2026-09-15 12:52:53 +03:00
slapin dd1835b4a3 Sokoban demo: phase 1 complete 2026-09-12 05:31:07 +03:00
slapin 7a388d2a6f Fixed light bleeding throw scene switching doors 2026-09-11 21:53:36 +03:00
slapin 7aa7682e97 Fixed black tunnels 2026-09-11 18:39:02 +03:00
slapin 1159bb5a47 Visual barriers for doors are now black 2026-09-11 16:38:12 +03:00
slapin 867a15d6aa Camera collision fixes 2026-09-11 14:38:57 +03:00
slapin bb65c589f4 Sky and water are fixed for demo 2026-09-10 22:16:33 +03:00
slapin 1572066df3 Interior/exterior implemented in the demo 2026-09-08 13:50:11 +03:00
slapin dd7d8f6b39 Doors implementation 2026-09-07 11:10:36 +03:00
slapin b18c213ee8 Added more demos 2026-09-06 03:19:34 +03:00
slapin 58deefa0a0 Demos for scee script and player controller 2026-09-05 13:32:15 +03:00
slapin 4670aa7af9 Scene Script added. Hide verbose error message. 2026-09-04 23:45:08 +03:00
slapin b062d8331d Terrain improvement 2026-09-04 21:52:47 +03:00
slapin 4562db481a Terrain improvements 2026-08-21 00:46:49 +03:00
slapin 5ea7a6bde8 Fixed normals for road geometry 2026-08-15 08:29:22 +03:00
slapin c38ac8eff3 Component workflow 2026-08-15 06:52:14 +03:00
slapin 2d60b20cca Added road geometry overlap test 2026-08-10 07:28:08 +03:00
slapin e82190ec6d Procedural road geometry fixes 2026-08-10 07:07:57 +03:00
slapin 33f76a1e54 Added demo for road geometry 2026-08-04 17:37:49 +03:00
slapin c48ff9f4e2 Road geometry snapshot 2026-08-02 04:12:30 +03:00
slapin a97efbe237 Road geometry is actually generated 2026-07-31 13:25:56 +03:00
slapin 9759c06d5f Implemented Milestone 5 as AI says 2026-07-30 21:23:12 +03:00
slapin 4acad712b0 snapshot 20260729 2026-07-29 13:20:06 +03:00
slapin 50c2395581 Updating mid-implementation of M5.2 2026-07-19 22:44:52 +03:00
slapin 32471f0db6 Batched composite-map updates 2026-07-12 14:25:38 +03:00
slapin ffbc85d4e2 AUX Painting works 2026-07-12 13:50:07 +03:00
slapin 9a12e84fec Detail noise implemented 2026-07-12 01:30:01 +03:00
slapin 96c0eef6cb Headless tests are implemented 2026-07-11 21:06:38 +03:00
slapin 6e71f3639b Updated the plan for road generation. 2026-07-11 18:53:15 +03:00
slapin 568b3bc917 Paint mode is functional 2026-07-08 23:27:15 +03:00
slapin 25106a23c4 Splat paint mode ans sculpt mode are separate now 2026-07-04 17:08:16 +03:00
slapin e2d6e49728 Editing works 2026-07-03 06:41:46 +03:00
slapin 98303a6448 decal works 2026-07-03 05:48:54 +03:00
slapin fb2c2af2b2 Terrain update 2026-07-02 03:29:44 +03:00
slapin f06f535938 Terrain robustness update 2026-07-01 10:57:45 +03:00
slapin 4cd2c72fb0 Now terrains serialization works 2026-06-27 04:53:33 +03:00
slapin 2c095de9f2 Start of terrain sculpting implementation 2026-06-27 03:18:11 +03:00
slapin e047f7bea1 Fixed terrain cleanup 2026-06-27 02:44:55 +03:00
slapin 31864e3da5 Terrain colliders work now! 2026-06-27 02:22:03 +03:00
slapin 3b00e0cc47 Updated AnimationTree component to use Animation Tree Registry 2026-06-27 00:14:09 +03:00
slapin 4b46b1a8e0 Update terrain plan 2026-06-27 00:13:04 +03:00
slapin 52de5cef44 Implemented terrain system core 2026-06-26 23:46:19 +03:00
slapin 5a7d97045f Planning Terrain 2026-06-26 21:10:29 +03:00
slapin 9b6881d873 Planning Terrain 2026-06-26 16:49:20 +03:00
slapin 414744cceb Use Animation Tree Registry 2026-06-25 18:58:59 +03:00
slapin eea4fbdb2f Update Lua examples path 2026-06-25 09:33:22 +03:00
slapin 19faeee8fa Forgot to update AGENTS.md 2026-06-25 09:11:24 +03:00
slapin d832127d5a Fixed stuck animations. 2026-06-24 13:24:56 +03:00
slapin d4974f5d90 Added src/features/editScene/AGENTS.md 2026-06-22 00:50:21 +03:00
slapin 68ca1f750d Added AGENTS.md 2026-06-22 00:39:14 +03:00
slapin 0ed5d9a970 Improvements for character registry 2026-06-19 14:53:26 +03:00
slapin 0a5edacf8a Character hair physics implementation 2026-06-16 01:22:20 +03:00
slapin 765dffbed0 Clothes and hairs 2026-05-30 20:04:07 +03:00
514 changed files with 179456 additions and 3918 deletions
+442
View File
@@ -0,0 +1,442 @@
# World2 Game Project - Agent Guide
## Project Overview
World2 is a 3D life simulation game built using the OGRE3D rendering engine. The project features an Entity-Component-System (ECS) architecture powered by Flecs, with Jolt Physics for simulation, Lua scripting for story content, and ImGui for user interfaces.
## Technology Stack
### Core Dependencies
- **Rendering**: OGRE3D 14.x+ (Object-Oriented Graphics Rendering Engine)
- **ECS Framework**: Flecs (Fast Lightweight Entity Component System)
- **Physics**: Jolt Physics with double precision support (JPH_DOUBLE_PRECISION)
- **Scripting**: Lua 5.4 with LPEG library
- **Audio**: miniaudio (single-header audio library)
- **UI**: ImGui (integrated via Ogre::ImGuiOverlay)
- **Model Loading**: Assimp with pugixml
- **Navigation**: Recast/Detour for crowd simulation
- **Procedural Generation**: OgreProcedural
- **Profiling**: Tracy frame profiler
### Build Requirements
- CMake 3.13+
- C++17 compatible compiler
- Blender (for asset processing, path configurable via `BLENDER` CMake variable)
- OGRE SDK with components: Bites, Paging, Terrain, MeshLodGenerator
## Project Structure
```
/
├── CMakeLists.txt # Main build configuration
├── resources.cfg # OGRE resource locations
├── .clang-format # Linux kernel code style config
│
├── Game.cpp # Main game executable entry point (legacy)
├── Editor.cpp # World editor executable (legacy)
├── Procedural.cpp # Procedural generation test / character controller demo (legacy)
├── Bootstrap.cpp # Legacy character controller demo (legacy)
├── terrain.cpp # Terrain system test executable (legacy)
│
├── src/ # Source code
│ ├── gamedata/ # Core ECS modules and game logic (legacy)
│ │ ├── GameData.cpp/h # ECS world setup and management (legacy)
│ │ ├── Components.h # ECS component definitions (legacy)
│ │ ├── *Module.cpp/h # Individual ECS modules (legacy)
│ │ └── items/ # Game item implementations (harbour, temple, town, etc.) (legacy)
│ ├── lua/ # Lua integration
│ │ ├── lua-5.4.8/ # Lua source code
│ │ └── lpeg-1.1.0/ # LPEG parsing library
│ ├── physics/ # Jolt Physics wrapper (physics.h/cpp) (legacy - for buildiung legacy code only)
│ ├── sound/ # Audio system (miniaudio)
│ ├── crowd/ # Recast/Detour navigation
│ │ ├── include/ # Crowd headers (OgreRecast, OgreDetourCrowd, etc.)
│ │ └── src/ # Crowd implementations
│ ├── editor/ # Editor-specific code (legacy)
│ │ ├── EditorGizmoModule.h/cpp
│ │ ├── EditorInputModule.h/cpp
│ │ └── main.cpp
│ ├── features/ # Feature modules
│ │ ├── characters/ # Character rendering test
│ │ ├── editScene/ # Scene editor with ECS, it has game mode and all features, current work is here
│ │ └── sceneEditor/ # some other editor code
│ ├── world/ # World generation and GOAP AI (legacy)
│ ├── terrain/ # Terrain system (legacy)
│ ├── tests/ # Test utilities
│ ├── text_editor/ # ImGui text editor component
│ ├── miniaudio/ # Audio library
│ ├── sceneloader/ # Scene loading utilitie (legacy)
│ ├── aitoolkit/ # AI utilities (GOAP, FSM, Behavior Trees) (legacy)
│ └── vehicles/ # Vehicle definitions (legacy)
│
├── assets/ # Source assets (Blender files)
│ ├── blender/ # Blender source files
│ │ ├── buildings/ # Building models (.blend)
│ │ ├── characters/ # Character models and clothes (characters asset pipeline)
│ │ ├── vehicles/ # Vehicle models
│ │ ├── scripts/ # Blender Python scripts for export
│ │ └── world/ # World terrain models
│ ├── textures/ # Source textures
│ └── fonts/ # Game fonts
│
├── lua-scripts/ # Lua game scripts
│ ├── narrator/ # Ink narrative parser
│ ├── stories/ # Story scripts (.ink format, compiled to .lua)
│ └── json/ # JSON library for Lua
│
├── water/ # Water rendering system (legacy)
│ ├── water.h/cpp # Water class
│ ├── *.frag, *.vert # GLSL shaders
│ └── water.compositor # OGRE compositor script
│
├── skybox/ # Sky rendering resources
├── resources/ # Runtime resources (build output)
├── audio/ # Audio resources
├── morph/ # Character morphing data
└── build/ # build directory
└── build-vscode/ # VSCode build directory
```
## Critical Constraints & Rules
- The source code is the only source of truth. Please check code as documentation might be out of sync.
- Do not modify legacy code. Current code is at src/features/editScene and its dependencies.
- Please ask questions if anything is unclear.
- Please update tests, documentation and examples every time API or modules and systems change.
## Build Instructions
### Prerequisites
1. Install OGRE3D SDK with Bites, Paging, Terrain, MeshLodGenerator components
2. Install dependencies: Jolt Physics, Flecs, Assimp, pugixml, OgreProcedural, Tracy, SDL2, ZLIB
3. Set `CMAKE_PREFIX_PATH` to OGRE installation
4. Configure Blender path in CMake (default: `${CMAKE_SOURCE_DIR}/../../blender-bin/bin/blender`)
### Build Commands
```bash
# Configure (adjust paths as needed)
cmake -B build -S . -DCMAKE_PREFIX_PATH=/path/to/ogre-sdk
# Build all targets
cmake --build build
# Build specific targets
cmake --build build --target Game # Main game executable (legacy)
cmake --build build --target Editor # World editor (legacy)
cmake --build build --target Procedural # Procedural test (legacy)
cmake --build build --target TerrainTest # Terrain test (legacy)
cmake --build build --target TerrainTest # Terrain test (legacy)
cmake --build build --target editSceneEditor # Current executable for scene editor and game
cmake --build build --target demoLuaSceneScript # Demo: game-mode inline scene/prefab Lua scripts
cmake --build build --target demoSceneSwitchingExtra # Demo: scene switching with CellGrid interior
cmake --build build --target demoSokoban # Demo: forklift sokoban activity (vehicle + crates + quest HUD)
cmake --build build --target demoTaxi # Demo: taxi driver (procedural town, sedans, NPC traffic, fare chain)
```
### Build Outputs
- `build/Game` - Main game executable (legacy)
- `build/Editor` - World editor executable (legacy)
- `build/Procedural` - Procedural generation test (legacy)
- `build/TerrainTest` - Terrain system test (legacy)
- `build//src/features/editScene/editSceneEditor` - current executable
- `build//src/features/editScene/demos/demo-lua-scene-script/demoLuaSceneScript` - SceneScriptComponent demo (game mode, inline Lua); self-contained runtime staged in the same directory
- `build//src/features/editScene/demos/demo-scene-switching-extra/demoSceneSwitchingExtra` - Scene-switching demo with a CellGrid interior (own-entity ProceduralMaterial/ProceduralTexture); self-contained runtime staged in the same directory
- `build//src/features/editScene/demos/demo-interior-exterior-dynamics/demoInteriorExteriorDynamics` - Same scene-switching demo, but the exterior scene has a streaming terrain (baseNoise archipelago - a large island at the content site amid an ocean with more islands) + sky + water instead of the flat floor; self-contained runtime staged in the same directory
- `build//src/features/editScene/demos/demo-sokoban/demoSokoban` - Forklift sokoban activity demo (drivable Jolt VehicleConstraint forklift, pushable crates, target pads, completion HUD); based on demo-interior-exterior-dynamics, see demos/demo-sokoban/PLAN.md; self-contained runtime staged in the same directory
- `build//src/features/editScene/demos/demo-taxi/demoTaxi` - Taxi driver demo (procedurally generated town, drivable sedans, passenger/foot-traffic/vehicle-traffic NPCs, chained timed fares with doubling payout); based on demo-interior-exterior-dynamics with vehicle ideas from demo-sokoban, see demos/demo-taxi/PLAN.md; self-contained runtime staged in the same directory
- `build/resources/` - Staged game resources
- `build/characters/` - Processed character models
- `build/water/` - Water assets (legacy)
## Code Style Guidelines
This project follows the **Linux Kernel Coding Style** for C++:
### Formatting Rules
- **Indentation**: 8-character tabs (UseTab: Always)
- **Line Length**: 80 characters maximum
- **Braces**:
- Functions: Opening brace on new line
- Control structures: Opening brace on same line
- **Pointer Alignment**: Right (`int *ptr` not `int* ptr`)
- **Namespaces**: No indentation inside namespaces
### Example
```cpp
void myFunction(int arg)
{
if (arg > 0) {
doSomething();
return;
}
for (int i = 0; i < arg; i++) {
process(i);
}
}
```
### Running clang-format
```bash
clang-format -i src/gamedata/*.cpp src/gamedata/*.h
```
## Architecture
### ECS (Entity Component System)
The game uses Flecs for ECS architecture. Core systems:
#### Components (src/gamedata/Components.h) (legacy)
- `GameData` - Global game state
- `EngineData` - Engine configuration (SceneManager, delta time, debug draw)
- `Input` - Input state (keyboard, mouse, controls)
- `Camera` - Camera node and configuration
- `RenderWindow` - Window and DPI information
- `CollisionShape` - Physics shape references
- `EditorSceneSwitch` - Editor scene switching
- `GameState` - Game running state
#### Modules (src/gamedata/*Module.h) (legacy)
Modules are ECS systems organized by functionality:
| Module | Purpose |
|--------|---------|
| AppModule | Application context management |
| GUIModule | ImGui interface management |
| EditorGUIModule | Editor UI |
| PhysicsModule | Jolt physics integration |
| CharacterModule | Character spawning and management |
| CharacterManagerModule | Player/NPC character management |
| CharacterAnimationModule | Animation state machines |
| CharacterAIModule | AI behavior (GOAP-based) |
| TerrainModule | Terrain generation and rendering |
| WaterModule | Water surface and physics |
| SunModule | Day/night cycle and lighting |
| WorldMapModule | World map overlay |
| QuestModule | Quest system |
| EventModule | Game event handling |
| EventTriggerModule | Event triggers |
| SlotsModule | Inventory slot management |
| BoatModule | Boat physics and control |
| VehicleManagerModule | Vehicle management |
| PlayerActionModule | Player action handling |
| LuaModule | Lua scripting integration |
| StaticGeometryModule | Static geometry management |
### EditScene Modules (`src/features/editScene`)
The editor/game mode executable adds its own ECS components.
The editor/game mode executable adds its own ECS modules:
| Module | Purpose |
|--------|---------|
| CharacterSlotSystem | Catalog-driven multi-slot character mesh builder |
| CharacterSpawnerSystem | Distance-based spawn/despawn of registry characters |
| TerrainPrefabSpawnerSystem | Distance-based terrain prefab spawn/despawn, terrain snap and compliance flattening |
| PlayerControllerSystem | Player input, camera, locomotion and animation state |
| ActuatorSystem | Player interaction prompts and smart-object/item actions |
| DoorSystem | Swing animation and collider toggle for CellGrid door entities |
| EditorUISystem | ImGui property panels and scene management |
| ProceduralTextureSystem | Runtime procedural texture generation |
| ProceduralMaterialSystem | Runtime procedural material creation |
| AnimationTreeRegistry | Global named animation tree definitions |
| AnimationTreeSystem | Runtime evaluation of registry-referenced animation trees |
| SceneScriptSystem | Execute-once scene/prefab Lua scripts (SceneScriptComponent) and scene_loaded/prefab_loaded events |
| VehicleSystem | Jolt VehicleConstraint vehicles (VehicleComponent): chassis constraint lifetime, driver input, wheel visual nodes |
#### PlayerControllerSystem
`PlayerControllerSystem` reads the shared `GameInputState`, drives the TPS/FPS
camera and sets the locomotion animation state (`idle`/`walking`/`running` and
the swim equivalents) on the controlled entity. In game mode it adds the
`PlayerControlledComponent` tag to the target so AI systems know to leave it
alone.
When a controller's `targetCharacterName` points at an entity with
`CharacterSpawnerComponent`, the system locks the spawner and forces an
immediate spawn; the actual controlled entity is the spawned character
instance, not the spawner.
The TPS camera boom is collision-clamped via a physics raycast from the
pivot to the desired camera position (instant pull-in, eased recovery), and
the character geometry is hidden when the clamped camera gets too close to
the body — see `src/features/editScene/AGENTS.md` for details.
#### Player Character Resolution
Systems that need the live player character (inventory UI, character sheet,
save/load, actuators) must not resolve the controller's
`targetCharacterName` against `EntityNameComponent` directly – that returns the
spawner entity when the controller targets a spawner. Instead use:
```cpp
flecs::entity player = editorApp->getPlayerCharacterEntity();
```
`EditorApp::getPlayerCharacterEntity()` returns the controller's current
target, spawning the character through `CharacterSpawnerSystem` if necessary.
`CharacterSpawnerSystem::getSpawnerForCharacter()` can be used in save/load to
keep the controller pointed at the spawner rather than the transient instance.
#### Game Mode Input
Game mode movement keys (`W`, `A`, `S`, `D`, left/right `Shift`) are handled
inside `keyPressed`/`keyReleased` by `keysym.sym`. Repeated `KEYDOWN` events
(`evt.repeat != 0`) are ignored for movement keys, because releasing `Shift`
while `W`/`A`/`S`/`D` is held can generate a repeated keydown with a different
symbol after the physical release, which would otherwise leave the key stuck.
One-shot actions (`E`, `F`, `I`, `Space`) are also matched by `keysym.sym`,
and their `*Pressed` edges ignore repeats too — otherwise holding `E` (e.g.
the hold-E-to-exit a vehicle) re-fires the door actuators and re-enters.
#### CharacterSpawnerComponent
- `registryId` - Character registry ID to spawn.
- `spawnDistanceSq` - Squared distance to camera at which the character spawns (default 100²).
- `despawnDistanceSq` - Squared distance to camera at which the character despawns (default 200²).
The spawner uses the entity's `TransformComponent` for spawn position/rotation. When the spawner itself moves/rotates/scales in the editor, the spawned character is updated to match; when the spawner is stationary, the character is left alone so it can move on its own. Spawned characters are created without `EditorMarkerComponent` and removed from the editor UI cache so they remain visible but are not selectable/editable in the editor. Registry changes that bump the character version trigger an immediate respawn. Scene serialization stores plain `spawnDistance`/`despawnDistance` values for readability while the component keeps squared values for comparisons.
#### AnimationTreeComponent
- `treeName` - Name of the animation tree in `AnimationTreeRegistry`.
- `enabled` - Whether the tree is evaluated.
- `useRootMotion` - Whether root motion from the animation drives the character's linear velocity.
The actual tree definition (node hierarchy, state machines, animations) lives in the `AnimationTreeRegistry`, persisted to `animation_tree.json`. Trees are authored in the registry editor (**Tools -> Animation Tree Registry**), where each entry can also select a skeleton source from the `Characters` resource group (one representative mesh per skeleton) to populate animation-name dropdowns. Old scenes and prefabs that stored the tree inline or referenced an `AnimationTreeTemplate` are migrated automatically on load.
#### Road Region Streaming (M5)
When the terrain has `streamingEnabled`, `RoadSystem` persists the road graph per terrain page in `heightmaps/<terrainId>/roads/<px>_<py>.json` (managed by `RoadRegionStore`) instead of inline in the scene JSON — `SceneSerializer::serializeTerrain` then skips `roadNodes`/`roadEdges` and keeps only `roadConfig`. Region files store absolute world-space doubles; a cross-page edge is written to the region file of each endpoint page with the foreign endpoint repeated inline. On the first sync a non-empty inline graph is migrated to region files automatically. Merges match nodes by world XZ (0.01 eps) so reloaded pages never duplicate nodes; `EditorUISystem::saveScene` calls `RoadSystem::flushRegionStore()` before writing the scene. Page mesh rebuilds are gated by a rebase-invariant FNV-1a content signature (world positions quantized to cm), so a render-origin rebase shifts node render positions without re-dirtying pages; navmesh dirtying is debounced (~15 quiet frames) while streaming.
#### Terrain Streaming Architecture
The terrain can span up to 40,000,000 x 40,000,000 units (`TerrainComponent::worldSizeUnits`, default; 20000 x 20000 pages at the default 2000-unit page size, clamped to 32768 per axis for TerrainGroup's signed 16-bit slot packing). Streaming is opt-in per terrain via `TerrainComponent::streamingEnabled`.
**Coordinate spaces.** Three spaces coexist (see `TerrainSystem.hpp` "Coordinate spaces"):
- **WORLD** space — absolute double-precision coordinates; authoritative. `TransformComponent::worldX/Y/Z`, region files, bookmarks and the Navigation panel all use it.
- **RENDER** space — floats relative to the floating render origin: `render = world - renderOrigin`. Scene nodes, mouse rays and the editor camera live here. `RenderOriginSystem` (singleton, `RenderOriginSystem::getInstance()`) owns the origin and rebases when the camera moves further than `REBASE_THRESHOLD` (8192 render units) from it: the origin snaps to integer world coordinates near the camera and every render-space position shifts by the exact negated delta. Convert with `RenderOriginSystem::worldToRender(x,y,z)` / `renderToWorld(Ogre::Vector3)`.
- **PHYSICS** space — Jolt `DVec3`/`RVec3` in absolute world coordinates via `physics/physics.h`; terrain colliders live here, so `TerrainSystem::raycastTerrain` converts its render-space ray to world space for the collider query.
`TerrainSystem` additionally exposes `renderToWorldX/Y/Z`, `worldToRender`, and `visualToPhysicalX/Z` / `physicalToVisualX/Z` for the physical heightmap space (physical X shifted by half a page, physical Z mirrored within each page).
**Page streaming window.** With `streamingEnabled`, pages are not defined up front; `TerrainSystem::updateStreamingWindow()` loads a square window of `pageLoadRadius` pages around the camera page each frame (2 loads/frame cap) and unloads pages beyond `pageHoldRadius` (4 unloads/frame cap), all synchronously to avoid WorkQueue shutdown hangs. Base heights come from procedural `TerrainComponent::baseNoise` (FastNoiseLite, double precision) instead of the legacy heightmap buffer; `setHeightAt` writes through the LRU-capped fixup chunk layer. Pages are indexed in PHYSICAL page coords `[0, N-1]`; the Ogre TerrainGroup slot for physical page `(x, y)` is `(x, -y)` because `ALIGN_X_Z` negates Z. The world is bounded — page indices and teleports clamp to `[0, N-1]`.
**Region storage.** Per-page data lives under `heightmaps/<terrainId>/`: painted blend maps, aux maps, fixup chunks, plus the streamed content stores `spawners/<px>_<py>.json` (prefab spawner defs, see below) and `roads/<px>_<py>.json` (road graph, see "Road Region Streaming (M5)" above).
**Editor navigation tools (M3).** `EditorCamera` supports mouse-wheel zoom (`handleMouseWheel`), Shift boost (x10) and a configurable fly speed (`setFlySpeed`, 1..100000 units/s). **Tools -> Navigation** opens `NavigationPanel` (teleport by world X/Y/Z or by page index, clamped to world bounds; named world bookmarks stored in the scene JSON top-level `bookmarks` array, tolerated when missing on load). **Tools -> World Map** opens `WorldMapPanel` (ImGui canvas over `WorldMapData`: coarse heights, loaded pages, roads, spawners, bookmarks and camera overlays; drag pan, wheel zoom, double-click teleport). `TerrainEditor` also has a compact "Navigation" section sharing the same static teleport backend.
**Streamed vs always-loaded spawners (M4).** Scene-embedded `TerrainPrefabSpawnerComponent` entities behave as always-loaded spawners for small/non-streaming scenes. When the terrain streams, spawner definitions persist per page in `heightmaps/<terrainId>/spawners/<px>_<py>.json` (via `SpawnerRegionStore`); `TerrainPrefabSpawnerSystem::syncStreamedSpawners()` polls `TerrainGroup::getTerrainSlots()` and creates a region spawner entity (tagged `StreamedSpawnerTag{pageX,pageY,defId}`, excluded from scene JSON) when a page loads, destroying it and writing edits back (including page-boundary crossings) when it unloads. The editor "prefab spawn mode" click-to-place writes to the region store when streaming.
### Physics System
Uses Jolt Physics with custom wrapper (legacy - `src/physics/physics.h`, current - `src/features/editScene/physics/physics.h`):
```cpp
// Example: Creating a physics body
JoltPhysicsWrapper *physics = new JoltPhysicsWrapper(scnMgr, cameraNode);
JPH::ShapeRefC shape = physics->createBoxShape(Ogre::Vector3(1, 1, 1));
JPH::BodyID body = physics->createBody(shape, 1.0f, position, rotation,
JPH::EMotionType::Dynamic, Layers::MOVING);
```
### Rendering Pipeline
1. **Main Render**: Standard OGRE render with RTSS (Real Time Shader System)
2. **Water**: Reflection/refraction with custom shaders
3. **Sky**: Dynamic skybox with day/night cycle
4. **Shadows**: Configurable shadow techniques
### Asset Pipeline
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`)
- `gen_sokoban_assets.py` - One-shot generator for the demo-sokoban
`forklift`/`crate`/`pad` .blend files (blender2ogre names .mesh
files after mesh *data* names — set both object and data names)
- `gen_taxi_assets.py` - Generator for the demo-taxi `sedan-a` /
`sedan-taxi` .blend files (body + hinge-local door/hood/trunk
parts, shared wheel meshes; body colors baked as material
variants)
- `gen_vehicle.py` - Convention-based vehicle pipeline for
hand-modeled cars (`sedan-v2.blend` is the reference): classifies
parts by name (`* Tire`, `* Door`, `* hood`/`* trunk`,
`* Steering`, `seat_*` empties), normalizes wheel/steering axes,
unpacks texture atlases, and emits a ready-to-merge
`<name>.vehicle.json` scene entity (contract:
`assets/blender/vehicles/CAR_MODELING.md`)
- `import_vrm.py` - VRM character import
3. **Build**: CMake processes assets during build
4. **Runtime**: Assets loaded from `resources.cfg` paths
## Key Conventions
### Module Registration
Modules register themselves with Flecs using the `FLECS_CPP_NO_AUTO_REGISTRATION` macro:
```cpp
// In module header
struct MyModule {
MyModule(flecs::world &ecs);
};
// In module implementation
MyModule::MyModule(flecs::world &ecs) {
// Register systems, components, etc.
}
```
### Scene Setup
Scenes are configured through ECS setup functions:
```cpp
// Setup exterior game scene
ECS::setupExteriorScene(scnMgr, cameraNode, camera, renderWindow);
// Setup interior scene
ECS::setupInteriorScene(scnMgr, cameraNode, camera, renderWindow);
// Setup editor
ECS::setupEditor(scnMgr, cameraNode, camera, renderWindow);
```
### Lua Scripting
Story content is written in Ink format and parsed to Lua:
- Source: `lua-scripts/stories/*.ink`
- Compiled: `lua-scripts/stories/*.lua`
- Runtime: Narrator parser loads and executes stories
## Development Workflow
### Adding Assets
1. Create/modify `.blend` file in `assets/blender/`
2. Add export logic to relevant Python script if needed
3. Add CMake custom command in `CMakeLists.txt` if new asset type
4. Rebuild to process assets
### Debugging
- Use `ECS::EngineData.enableDbgDraw` for physics debug visualization
- Tracy profiler integration for performance analysis
- FPS and draw call stats printed to console
## Security Considerations
- Lua scripts run in sandboxed environment
- File system access restricted to resource directories
- No network functionality currently implemented
## Additional Resources
- **OGRE3D Docs**: https://ogrecave.github.io/ogre/api/latest/
- **Flecs Manual**: https://www.flecs.dev/flecs/
- **Jolt Physics**: https://jrouwe.github.io/JoltPhysics/
- **Ink Narrative**: https://www.inklestudios.com/ink/
+1
View File
@@ -2,6 +2,7 @@ cmake_minimum_required(VERSION 3.13.0)
project(world2)
set(CMAKE_CXX_STANDARD 17)
enable_testing()
set(BLENDER "${CMAKE_SOURCE_DIR}/../../blender-bin/bin/blender" CACHE STRING "Blender path")
set(CREATE_DIRECTORIES
${CMAKE_BINARY_DIR}/assets/blender/shapes/male
+110 -11
View File
@@ -1,4 +1,5 @@
project(building-parts)
find_program(PYTHON3_EXECUTABLE NAMES python3 python REQUIRED)
set(PARTS_FILES pier.blend)
set(FURNITURE_FILES furniture.blend furniture-sofa.blend)
set(PARTS_OUTPUT_DIRS)
@@ -15,23 +16,121 @@ foreach(PARTS_FILE ${PARTS_FILES})
DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/${PARTS_FILE} ${CMAKE_SOURCE_DIR}/assets/blender/scripts/export_building_parts.py)
list(APPEND PARTS_OUTPUT_DIRS ${PARTS_OUTPUT_DIR})
endforeach()
set(FURNITURE_BAKED_BLENDS)
set(FURNITURE_ATLAS_PARTS)
list(LENGTH FURNITURE_FILES FURNITURE_COUNT)
foreach(FURNITURE_FILE ${FURNITURE_FILES})
list(FIND FURNITURE_FILES ${FURNITURE_FILE} FURNITURE_INDEX)
get_filename_component(FILE_NAME ${FURNITURE_FILE} NAME_WE)
set(BAKED_BLEND ${CMAKE_CURRENT_BINARY_DIR}/${FILE_NAME}_baked.blend)
set(BAKE_SUCCESS ${CMAKE_CURRENT_BINARY_DIR}/${FILE_NAME}.bake_success)
set(ATLAS_PART ${CMAKE_CURRENT_BINARY_DIR}/${FILE_NAME}-atlas.png)
add_custom_command(
OUTPUT ${BAKED_BLEND} ${ATLAS_PART} ${BAKE_SUCCESS}
COMMAND ${CMAKE_COMMAND} -E rm -f ${BAKE_SUCCESS}
COMMAND ${CMAKE_COMMAND} -E copy
${CMAKE_CURRENT_SOURCE_DIR}/${FURNITURE_FILE}
${CMAKE_CURRENT_BINARY_DIR}/${FURNITURE_FILE}
COMMAND ${BLENDER} -b ${CMAKE_CURRENT_BINARY_DIR}/${FURNITURE_FILE}
-Y -P ${CMAKE_CURRENT_SOURCE_DIR}/bake_furniture.py
-- ${ATLAS_PART} ${FURNITURE_INDEX} ${FURNITURE_COUNT}
${BAKE_SUCCESS}
COMMAND ${CMAKE_COMMAND} -D FILE=${BAKE_SUCCESS}
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/${FURNITURE_FILE}
${CMAKE_CURRENT_SOURCE_DIR}/bake_furniture.py)
list(APPEND FURNITURE_BAKED_BLENDS ${BAKED_BLEND})
list(APPEND FURNITURE_ATLAS_PARTS ${ATLAS_PART})
endforeach()
# One shared runtime atlas/material for all furniture sources. Each source
# owns a deterministic vertical band, so rebuilds cannot collide or reorder.
set(FURNITURE_ATLAS_PNG
${CMAKE_BINARY_DIR}/resources/buildings/parts/furniture-atlas.png)
set(FURNITURE_ATLAS_MATERIAL
${CMAKE_BINARY_DIR}/resources/buildings/parts/furniture-atlas.material)
add_custom_command(
OUTPUT ${FURNITURE_ATLAS_PNG} ${FURNITURE_ATLAS_MATERIAL}
COMMAND ${CMAKE_COMMAND} -E rm -f
${FURNITURE_ATLAS_PNG} ${FURNITURE_ATLAS_MATERIAL}
COMMAND ${BLENDER} --factory-startup -b -P
${CMAKE_CURRENT_SOURCE_DIR}/merge_furniture_atlases.py --
${FURNITURE_ATLAS_PNG} ${FURNITURE_ATLAS_MATERIAL}
${FURNITURE_ATLAS_PARTS}
COMMAND ${CMAKE_COMMAND} -D FILE=${FURNITURE_ATLAS_PNG}
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
COMMAND ${CMAKE_COMMAND} -D FILE=${FURNITURE_ATLAS_MATERIAL}
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
DEPENDS ${FURNITURE_ATLAS_PARTS}
${CMAKE_CURRENT_SOURCE_DIR}/merge_furniture_atlases.py)
set(FURNITURE_EXPORT_STAMPS)
foreach(FURNITURE_FILE ${FURNITURE_FILES})
get_filename_component(FILE_NAME ${FURNITURE_FILE} NAME_WE)
set(PARTS_OUTPUT_DIR ${CMAKE_BINARY_DIR}/resources/buildings/parts/${FILE_NAME})
set(BAKED_BLEND ${CMAKE_CURRENT_BINARY_DIR}/${FILE_NAME}_baked.blend)
set(EXPORT_SUCCESS ${CMAKE_CURRENT_BINARY_DIR}/${FILE_NAME}.export_success)
add_custom_command(
OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/${FILE_NAME}_baked.blend
COMMAND ${CMAKE_COMMAND} -E copy ${CMAKE_CURRENT_SOURCE_DIR}/${FURNITURE_FILE} ${CMAKE_CURRENT_BINARY_DIR}/${FURNITURE_FILE}
COMMAND ${BLENDER} -b ${CMAKE_CURRENT_BINARY_DIR}/${FURNITURE_FILE} -Y -P
${CMAKE_CURRENT_SOURCE_DIR}/bake_furniture.py
DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/${FURNITURE_FILE} ${CMAKE_CURRENT_SOURCE_DIR}/bake_furniture.py)
add_custom_command(
OUTPUT ${PARTS_OUTPUT_DIR}
OUTPUT ${PARTS_OUTPUT_DIR} ${EXPORT_SUCCESS}
COMMAND ${CMAKE_COMMAND} -E rm -rf ${PARTS_OUTPUT_DIR}
COMMAND ${CMAKE_COMMAND} -E rm -f ${EXPORT_SUCCESS}
COMMAND ${CMAKE_COMMAND} -E make_directory ${PARTS_OUTPUT_DIR}
COMMAND ${BLENDER} -b ${CMAKE_CURRENT_BINARY_DIR}/${FILE_NAME}_baked.blend
COMMAND ${BLENDER} -b ${BAKED_BLEND}
-Y -P
${CMAKE_CURRENT_SOURCE_DIR}/export_furniture_parts.py
-- ${PARTS_OUTPUT_DIR}
DEPENDS ${CMAKE_CURRENT_BINARY_DIR}/${FILE_NAME}_baked.blend ${CMAKE_CURRENT_SOURCE_DIR}/export_furniture_parts.py)
list(APPEND PARTS_OUTPUT_DIRS ${PARTS_OUTPUT_DIR})
-- ${PARTS_OUTPUT_DIR} ${EXPORT_SUCCESS}
COMMAND ${CMAKE_COMMAND} -D FILE=${EXPORT_SUCCESS}
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
DEPENDS ${BAKED_BLEND}
${CMAKE_CURRENT_SOURCE_DIR}/export_furniture_parts.py
${FURNITURE_ATLAS_PNG} ${FURNITURE_ATLAS_MATERIAL})
list(APPEND PARTS_OUTPUT_DIRS ${PARTS_OUTPUT_DIR} ${EXPORT_SUCCESS})
list(APPEND FURNITURE_EXPORT_STAMPS ${EXPORT_SUCCESS})
endforeach()
# Validate the shared-atlas contract after every source has exported. This
# catches embedded texture copies, UVs escaping the source band and shared
# Blender mesh datablocks changing UVs between furniture variants.
set(FURNITURE_VALIDATION
${CMAKE_CURRENT_BINARY_DIR}/furniture-export.validated)
add_custom_command(
OUTPUT ${FURNITURE_VALIDATION}
COMMAND ${CMAKE_COMMAND} -E rm -f ${FURNITURE_VALIDATION}
COMMAND ${PYTHON3_EXECUTABLE}
${CMAKE_CURRENT_SOURCE_DIR}/validate_furniture_export.py
--root ${CMAKE_BINARY_DIR}/resources/buildings/parts
--source furniture 0 ${FURNITURE_COUNT}
--source furniture-sofa 1 ${FURNITURE_COUNT}
COMMAND ${CMAKE_COMMAND} -E touch ${FURNITURE_VALIDATION}
DEPENDS ${FURNITURE_EXPORT_STAMPS} ${FURNITURE_ATLAS_PNG}
${FURNITURE_ATLAS_MATERIAL}
${CMAKE_CURRENT_SOURCE_DIR}/validate_furniture_export.py)
list(APPEND PARTS_OUTPUT_DIRS ${FURNITURE_VALIDATION}
${FURNITURE_ATLAS_PNG} ${FURNITURE_ATLAS_MATERIAL})
add_custom_target(import_building_parts ALL DEPENDS ${PARTS_OUTPUT_DIRS})
# Phase 6.5.B: opt-in preview gallery (kept out of the default build
# because rendering every asset is slow). Renders each root with its
# pivot, physical/collision bounds, occupancy cells and POI markers,
# then writes an HTML index with the corresponding atlas UV rectangles.
set(FURNITURE_GALLERY_DIR
${CMAKE_BINARY_DIR}/resources/buildings/parts/gallery)
add_custom_target(furniture_gallery
COMMAND ${CMAKE_COMMAND} -E make_directory ${FURNITURE_GALLERY_DIR}
COMMAND ${BLENDER} -b
${CMAKE_CURRENT_BINARY_DIR}/furniture_baked.blend -Y -P
${CMAKE_CURRENT_SOURCE_DIR}/render_furniture_gallery.py --
${FURNITURE_GALLERY_DIR}
${CMAKE_BINARY_DIR}/resources/buildings/parts/furniture
COMMAND ${BLENDER} -b
${CMAKE_CURRENT_BINARY_DIR}/furniture-sofa_baked.blend -Y -P
${CMAKE_CURRENT_SOURCE_DIR}/render_furniture_gallery.py --
${FURNITURE_GALLERY_DIR}
${CMAKE_BINARY_DIR}/resources/buildings/parts/furniture-sofa
COMMAND ${PYTHON3_EXECUTABLE}
${CMAKE_CURRENT_SOURCE_DIR}/generate_furniture_gallery_index.py
--root ${FURNITURE_GALLERY_DIR}
DEPENDS ${FURNITURE_EXPORT_STAMPS} ${FURNITURE_ATLAS_PNG}
${FURNITURE_ATLAS_MATERIAL}
${CMAKE_CURRENT_SOURCE_DIR}/render_furniture_gallery.py
${CMAKE_CURRENT_SOURCE_DIR}/generate_furniture_gallery_index.py)
@@ -0,0 +1,257 @@
# Furniture modeling and Blender export
This document describes the current furniture authoring path. The remaining
room-composition, DSL and Lua work is tracked in
`src/features/editScene/demos/demo-taxi/PLAN.md`, Phase 6.5.
Blender is the fast path for reusable furniture assets. Keep geometry, UVs,
the placement pivot, physical bounds and asset-local interaction markers with
the asset. Do not turn room layouts or game behavior into a large collection
of Blender custom properties and empties: reusable room arrangements belong to
the shared layout evaluator/DSL, while unique or behavior-heavy interiors use
prefab ECS components and Lua.
## Pipeline files
- `edit-firniture-props.py` (historical file-name typo) provides the Blender
**Furniture Controls** and **Action Controls** panels.
- `bake_furniture.py` bakes one source blend into a deterministic vertical
atlas band and assigns `M_Furniture_Atlas` to its visual meshes.
- `merge_furniture_atlases.py` stacks those bands into the shared
`furniture-atlas.png` and writes `furniture-atlas.material`.
- `export_furniture_parts.py` exports every root object named `furniture-*`
as GLB and writes a schema-v1 furniture-definition JSON containing bounds,
layer, placement rules, source hash and interaction markers.
- `validate_furniture_export.py` checks the generated GLB/JSON artifacts:
schema references, shared material, no embedded images, UVs inside the
source's atlas band, and identical UV bounds across variants sharing a
Blender mesh datablock.
- `CMakeLists.txt` runs baking, atlas merging, exporting and validation with
success
markers, so a Blender Python exception cannot leave stale output looking
current. `FURNITURE_FILES` currently lists `furniture.blend` and
`furniture-sofa.blend`.
For `furniture.blend`, furniture output goes to
`build*/resources/buildings/parts/furniture/`; `furniture-sofa.blend` uses
`resources/buildings/parts/furniture-sofa/`. The shared atlas and material
are written once to `resources/buildings/parts/`:
```text
furniture-<source-id>.glb
furniture-<source-id>.glb.json
resources/buildings/parts/furniture-atlas.png
resources/buildings/parts/furniture-atlas.material
```
The GLB keeps the `M_Furniture_Atlas` material name but deliberately embeds
no image; OGRE loads the shared material from `furniture-atlas.material`.
JSON files are generated output: edit the `.blend` source and re-export
instead of hand-editing build artifacts.
## Author a furniture root
1. Open the source blend and run `edit-firniture-props.py` from Blender's
Text Editor to register the helper panels.
2. Create a root **Empty** named `furniture-<id>`. Parent all visual meshes
to it. Keep the root origin at the intended placement pivot and use the
same forward orientation as the existing assets.
3. Select the root and use **Furniture Controls -> Create Furniture**.
Available asset-local properties are:
- `furniture_id` — stable definition id; defaults to the root suffix.
It survives a cosmetic Blender object rename. Legacy roots named
`furniture--<id>` export as `<id>-legacy` with the old name kept as an
alias.
- `display_name` — optional UI-facing name.
- `furniture_tags` — comma-separated selection tags such as
`bedroom, essential` or `livingroom, filler`.
- `layer` — `normal`, `thin-floor`, `thin-wall` or `ceiling`.
- `selection_weight` — non-negative weighted-random factor.
- `requires_floor` / `requires_wall` — placement constraints.
- `clearance` — extra occupied-footprint margin in metres.
- `footprint_x` / `footprint_z` — optional occupancy override in grid
cells; leave both at 0 to derive occupancy from mesh bounds at the
target grid's cell size.
- `collision_mesh` — optional explicit mesh object to use as simplified
collision bounds. Meshes named `collision-*` are also treated as
collision-only and are not exported as render geometry.
4. Keep the asset reusable. A chair, table, counter section, shelf or
repair-bay fixture is an asset; where several such assets belong in a
particular room is a layout concern, not root-object metadata.
The exporter computes `bounds` from the visual mesh hierarchy in root-local
space. If collision meshes exist, it also emits `collisionBounds`. Existing
`foo-col` mesh names are **not** assumed to be collision because some legacy
assets use that suffix for visible geometry; use `collision-*` or the explicit
`collision_mesh` property for new collision proxies.
Runtime layer conventions: normal items use `collisionBounds` when present,
otherwise physical `bounds`, for their generated collider; thin/ceiling
layers never collide or obstruct the navmesh. A thin-wall root's origin is
its attachment point and its quarter-turn placement selects the wall plane
(internal inset 0.1 m, external 0.2 m outside the cell). A ceiling root's
origin is the ceiling attachment point (place geometry below it, e.g. the
ceiling light's negative local Z in Blender).
## Add local interaction markers
Simple asset-local interactions stay in Blender:
1. Add an **Empty** named `action-<name>` as a **direct child** of the
furniture root. The helper offers Use and Sit actions and initializes
`name`, `action`, `action_text`, `height`, `radius` and `tags`.
2. Position/orient the action empty where the interaction occurs. Set its
action name and interaction dimensions in **Action Controls**.
3. Add optional `position-<name>` empties below that action for named
approach/enter/exit transforms. The helper can create enter/exit markers.
Root-level `position-*` children are also exported.
4. The exporter additionally preserves action custom properties prefixed
`goap_prereq_` and `goap_effect_`.
Only direct root/action children are discovered by the exporter, so preserve
that hierarchy. At runtime `CellGridSystem` turns exported action markers
into grid-owned entities carrying both `ActuatorComponent` and
`SmartObjectComponent`; nested/root position markers become named runtime
transforms without an actuator. Every marker has a stable id
`furniture.<placementId>.<markerName>` and can be found through
`CellGridSystem::queryFurniturePois` / `findFurniturePoi` by room,
furniture tag, action, marker type or grid. Use these markers for local facts such as "sit here" or
"stand at this side of the counter". Store work rules, repair behavior,
cross-entity references and room-dependent relationships belong to the
ECS/Lua layer instead.
## Bake and export
Add a new source blend to `FURNITURE_FILES` in this directory's
`CMakeLists.txt`, then run:
```bash
cmake --build build-vscode --target import_building_parts -j4
```
The build performs these steps:
1. Copy the source blend to the build tree.
2. Bake its visual meshes into that source's deterministic atlas band.
3. Remap its UVs into that band.
4. Merge all source bands into one shared atlas and material.
5. Export GLBs without embedded texture copies, then write schema-v1 JSON.
6. Validate the GLB/JSON set, including UV bands and shared mesh datablock
variants.
7. Check per-step success marker files so failed Blender scripts fail the
build instead of leaving stale artifacts.
`src/features/editScene/resources.cfg` indexes
`resources/buildings/parts`, `resources/buildings/parts/furniture` and
`resources/buildings/parts/furniture-sofa`, so the shared material and both
current definition sets are loadable. At runtime `FurnitureLibrary`
discovers `furniture-*.json`, validates schema/identity/bounds and exposes
stable ids and aliases.
To add the phase 6.5 fixtures (layer fixes + the 6.5.I store/garage
set) to the existing source (idempotent), then export and render the
preview gallery:
```bash
../../blender-bin/bin/blender -b assets/blender/buildings/parts/furniture.blend \
-Y -P assets/blender/buildings/parts/add_furniture_fixtures.py
cmake --build build-vscode --target import_building_parts -j2
cmake --build build-vscode --target furniture_gallery -j2
```
The gallery is written to `resources/buildings/parts/gallery/index.html`:
each asset is rendered at its export pivot with physical bounds (green),
collision bounds (red), occupancy cells (blue), action/position markers
and an atlas UV rectangle. `render_furniture_gallery.py` and
`generate_furniture_gallery_index.py` are the generators. The gallery
target is opt-in so routine builds do not render every asset.
### The fixture script (`add_furniture_fixtures.py`)
`add_furniture_fixtures.py` extends `furniture.blend` in place and is
safe to re-run (every root is created only when missing, duplicate
markers are never added again):
- Layer fixtures: `carpet1` (`thin-floor`), `switch1` and `picture1`
(`thin-wall`), `ceiling-light1` (`ceiling`) and `shop-shelf1`
(`normal`, with a simplified `collision-shelf-proxy`).
- Store/garage fixtures (6.5.I): `counter1` (shop/counter/essential,
`work` attachment marker for the demo's working-counter action),
`workbench1` (garage/working) and `bay1` (vehicle-sized repair bay,
`bay` marker plus visual stripes).
- `ensure_attachment(root)` gives every fixture root one root-local
`position-attach` marker (the guard matches the whole
`position-attach` prefix — Blender renames colliding objects with
numeric suffixes, so an exact-name check would re-create a second
marker whenever a renamed marker survives; the demo's
`demo_scene_furniture.json` POI test depends on one marker per root).
The phase 6.5.I demo scene and its headless check live in
`src/features/editScene/demos/demo-taxi/` (`demo_scene_furniture.json`,
`./demoTaxi --headless --test-furniture`).
## Choosing the authoring layer
| Need | Preferred home |
| --- | --- |
| Mesh, UVs, atlas, pivot and physical bounds | Blender |
| Optional simplified collision mesh | Blender (`collision-*` or `collision_mesh`) |
| Simple local sit/use/action and approach markers | Blender |
| Single-mesh or multi-cell placement | Furniture definition + shared C++ kernel |
| Multi-entity ECS behavior or custom components | Editor prefab, reusing the Blender asset |
| Prefab-backed furniture | `prefabs/furniture.<name>.json` plus a definition whose `prefab` points at it; Create Prefab's **Furniture prefab** mode generates the companion definition with tags, layer, measured bounds and a deterministic prefab source hash |
| Reusable dining sets, counter/shelf rows, room-fit rules | Shared room-composition evaluator and layout DSL (Phase 6.5.F) |
| Unique store/garage layouts, scripted actions, exterior-entry wiring | Lua plus normal door/actuator/vehicle APIs (Phase 6.5.H) |
| Occupancy, navigation, identity and lifecycle invariants | Shared C++ furniture kernel |
A table with chairs around it should not require a bespoke Blender empty
hierarchy: place the table at a room anchor and generate the chairs relative
to it through the shared composition evaluator. Likewise, a store or garage
uses Blender-built counters, shelves and fixtures, but its working behavior
and exterior-entry setup should remain readable DSL/Lua/ECS data.
Prefab instances placed through furniture definitions are runtime children of
the CellGrid. They are not editor-selectable scene entities; non-normal
layers strip physics components from the instantiated subtree, while normal
prefabs keep their authored colliders.
## Layout DSL metadata bridge
Room declarations can record furniture-selection tags without hardcoding a
placement algorithm:
```lisp
(room shop (rect 0 0 1 1)
:type store
:furniture (shop|market counter !display))
```
Entries in the `:furniture` list are ANDed. `a|b` accepts either tag and
`!a` rejects a tag. The same term syntax works in RoomComponent tag lists.
`BuildingPlan::roomFurnitureTags` stores the list parallel to
`roomNames`/`roomTypes`, and the road geometry test suite covers the round
trip through layout evaluation.
Full/interiorOnly grids materialize those plan rooms as RoomComponents
(`TownGenSystem::createPlanRooms`), so the same RoomLayoutSystem furnishes
DSL-authored and node-authored rooms. Reusable arrangements use the
`:compose` room option instead of bespoke Blender hierarchies:
```lisp
:compose (
(group (livingroom nowall) :at center
(child (lounge filler) -2 -2 0)
(child (lounge filler) 2 -2 0)
(child (lounge filler) -2 2 0)
(child (lounge filler) 2 2 0))
(row (nowindow optional) :at zpos :step 2 :count 2))
```
A `group` is one root plus children at cell offsets (rotated with the
root); the whole group fits or rolls back atomically. A `row` walks a
wall edge with `step`/`skip`/`count` and skips blocked cells. Anchor is
`center` or `zneg`/`zpos`/`xneg`/`xpos`; `:rot 0..3` overrides the root
rotation (default: edge anchors face into the room). Tags are selection
terms, not definition ids — check the exported `tags` array. The same
rules are serialized on RoomComponent and editable in its Furniture
Compositions panel.
@@ -0,0 +1,184 @@
"""Add the phase 6.5 layer fixtures to furniture.blend (idempotent).
Run from the project root:
../../blender-bin/bin/blender -b assets/blender/buildings/parts/furniture.blend \
-Y -P assets/blender/buildings/parts/add_furniture_fixtures.py
The regular import_building_parts target then bakes/exports them. No
room-placement rules live here: layers, bounds and (for the shelf) a
simplified collision proxy are the only asset-local metadata.
"""
import bpy
def material(name, color):
existing = bpy.data.materials.get(name)
if existing:
return existing
mat = bpy.data.materials.new(name)
mat.use_nodes = True
bsdf = mat.node_tree.nodes.get("Principled BSDF")
bsdf.inputs["Base Color"].default_value = (*color, 1.0)
bsdf.inputs["Roughness"].default_value = 1.0
return mat
def make_root(name, tags, layer, requires_floor, requires_wall):
existing = bpy.data.objects.get(name)
if existing:
return existing, False
root = bpy.data.objects.new(name, None)
root.empty_display_type = 'PLAIN_AXES'
root.empty_display_size = 0.15
bpy.context.scene.collection.objects.link(root)
root["furniture_id"] = name.replace("furniture-", "")
root["display_name"] = root["furniture_id"]
root["furniture_tags"] = tags
root["layer"] = layer
root["selection_weight"] = 1.0
root["requires_floor"] = requires_floor
root["requires_wall"] = requires_wall
root["clearance"] = 0.0
root["footprint_x"] = 0
root["footprint_z"] = 0
return root, True
def box(root, name, location, dimensions, mat, collision=False):
bpy.ops.mesh.primitive_cube_add(size=1.0)
obj = bpy.context.object
obj.name = name
obj.dimensions = dimensions
bpy.ops.object.transform_apply(location=False, rotation=False,
scale=True)
obj.parent = root
obj.location = location
if mat and not collision:
obj.data.materials.append(mat)
if collision:
obj.display_type = 'WIRE'
return obj
def ensure_attachment(root):
"""A root-local marker used by the runtime layer-transform checks.
The guard matches the whole 'position-attach' prefix: Blender
renames colliding objects with numeric suffixes, so an exact-name
check would re-create a second marker whenever a deduped or
renamed marker survives in the blend.
"""
if any(child.name.startswith("position-attach")
for child in root.children):
return
marker = bpy.data.objects.new("position-attach", None)
marker.empty_display_size = 0.05
bpy.context.scene.collection.objects.link(marker)
marker.parent = root
marker.location = (0, 0, 0)
marker["name"] = "attach"
marker["type"] = "attachment"
fabric = material("FixtureFabric", (0.34, 0.08, 0.08))
trim = material("FixtureTrim", (0.12, 0.07, 0.04))
plastic = material("FixturePlastic", (0.82, 0.82, 0.78))
accent = material("FixtureAccent", (0.08, 0.25, 0.65))
picture = material("FixturePicture", (0.15, 0.45, 0.28))
light = material("FixtureLight", (1.0, 0.87, 0.55))
wood = material("FixtureWood", (0.42, 0.25, 0.12))
root, created = make_root("furniture-carpet1",
"livingroom, bedroom, filler", "thin-floor",
True, False)
if created:
box(root, "CarpetBody", (0, 0, 0.01), (1.6, 1.2, 0.02), fabric)
box(root, "CarpetInset", (0, 0, 0.021), (1.35, 0.95, 0.004), trim)
ensure_attachment(root)
root, created = make_root("furniture-switch1", "office, utility, optional",
"thin-wall", False, True)
if created:
box(root, "SwitchPlate", (0, 0, 0), (0.12, 0.04, 0.18), plastic)
box(root, "SwitchToggle", (0, 0.025, 0), (0.035, 0.02, 0.08), trim)
ensure_attachment(root)
root, created = make_root("furniture-picture1", "livingroom, office, optional",
"thin-wall", False, True)
if created:
box(root, "PictureFrame", (0, 0, 0), (0.8, 0.05, 0.55), trim)
box(root, "PictureCanvas", (0, 0.03, 0), (0.68, 0.012, 0.43), picture)
box(root, "PictureMark", (0.12, 0.038, 0.02),
(0.25, 0.006, 0.18), accent)
root, created = make_root("furniture-ceiling-light1",
"livingroom, office, essential", "ceiling",
False, False)
if created:
box(root, "CeilingLightHousing", (0, 0, -0.06), (0.6, 0.6, 0.12),
plastic)
box(root, "CeilingLightDiffuser", (0, 0, -0.13),
(0.45, 0.45, 0.03), light)
ensure_attachment(root)
root, created = make_root("furniture-shop-shelf1", "shop, display, important",
"normal", True, False)
if created:
box(root, "ShelfBase", (0, 0, 0.05), (1.5, 0.4, 0.1), wood)
box(root, "ShelfBack", (0, -0.17, 0.75), (1.5, 0.06, 1.5), wood)
for x in (-0.72, 0.72):
box(root, "ShelfSide", (x, 0, 0.75), (0.06, 0.4, 1.5), wood)
for z in (0.5, 1.0, 1.5):
box(root, "ShelfBoard", (0, 0, z), (1.5, 0.4, 0.05), wood)
collision = box(root, "collision-shelf", (0, 0, 0.75),
(1.5, 0.4, 1.5), None, collision=True)
root["collision_mesh"] = collision.name
root, created = make_root("furniture-counter1", "shop, counter, essential",
"normal", True, False)
if created:
box(root, "CounterTop", (0, 0, 0.95), (1.8, 0.6, 0.05), wood)
for z in (-0.3, 0.3):
box(root, "CounterLeg", (z, 0, 0.47), (0.06, 0.65, 0.95), wood)
collision = box(root, "collision-counter", (0, 0, 0.5),
(1.8, 0.6, 1.0), None, collision=True)
root["collision_mesh"] = collision.name
ensure_attachment(root)
work_mark = bpy.data.objects.new("position-work", None)
work_mark.empty_display_size = 0.05
bpy.context.scene.collection.objects.link(work_mark)
work_mark.parent = root
work_mark.location = (0, -0.4, 1.0)
work_mark["name"] = "work"
work_mark["type"] = "attachment"
root, created = make_root("furniture-workbench1",
"garage, working, optional",
"normal", True, False)
if created:
box(root, "BenchTop", (0, 0, 0.9), (4.2, 0.8, 0.06), wood)
for x in (-1.9, 0.0, 1.9):
box(root, "BenchLeg", (x, 0, 0.44), (0.08, 0.7, 0.9), wood)
box(root, "BenchVise", (1.4, 0, 0.78), (0.25, 0.3, 0.25), trim)
collision = box(root, "collision-workbench", (0, 0, 0.5),
(4.2, 0.8, 1.0), None, collision=True)
root["collision_mesh"] = collision.name
root, created = make_root("furniture-bay1",
"garage, bay, optional",
"thin-floor", True, False)
if created:
box(root, "BayPad", (0, 0, 0.01), (5.2, 2.2, 0.02), accent)
box(root, "BayStripe", (0, 0, 0.011), (4.7, 1.6, 0.004), picture)
ensure_attachment(root)
bay_mark = bpy.data.objects.new("position-bay", None)
bay_mark.empty_display_size = 0.05
bpy.context.scene.collection.objects.link(bay_mark)
bay_mark.parent = root
bay_mark.location = (0, 0, 0.02)
bay_mark["name"] = "bay"
bay_mark["type"] = "attachment"
bpy.ops.wm.save_as_mainfile(filepath=bpy.data.filepath)
print("Phase 6.5 furniture fixtures present.")
@@ -1,9 +1,27 @@
import bpy
import os
import sys
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
ATLAS_OUTPUT = argv[0] if argv else ""
ATLAS_INDEX = int(argv[1]) if len(argv) > 1 else 0
ATLAS_COUNT = int(argv[2]) if len(argv) > 2 else 1
SUCCESS_OUTPUT = argv[3] if len(argv) > 3 else ""
if ATLAS_INDEX < 0 or ATLAS_INDEX >= ATLAS_COUNT:
raise RuntimeError("invalid furniture atlas band index")
def is_collision_name(name):
# Existing assets also use "-col" for some VISUAL meshes, so only the
# explicit collision-* prefix is excluded automatically.
return name.lower().split(".", 1)[0].startswith("collision-")
def get_all_children_recursive(obj, mesh_list):
"""Recursively finds all mesh objects in the hierarchy."""
"""Recursively finds visual mesh objects in the hierarchy."""
for child in obj.children:
if child.type == 'MESH' and child.data.uv_layers:
if child.type == 'MESH' and child.data.uv_layers and \
not is_collision_name(child.name):
mesh_list.append(child)
get_all_children_recursive(child, mesh_list)
@@ -11,7 +29,8 @@ def setup_furniture_atlas():
# 1. Gather valid objects: Children of empties starting with "furniture-"
furniture_meshes = []
roots = [o for o in bpy.data.objects if o.name.startswith("furniture-")]
roots = [o for o in bpy.context.scene.objects
if o.parent is None and o.name.startswith("furniture-")]
for root in roots:
# If the root itself is a mesh with UVs, add it
@@ -20,18 +39,15 @@ def setup_furniture_atlas():
# Find all nested children
get_all_children_recursive(root, furniture_meshes)
# Remove duplicates if any (in case of complex parenting)
furniture_meshes = list(set(furniture_meshes))
# Remove duplicates while preserving scene order.
seen = set()
furniture_meshes = [obj for obj in furniture_meshes
if not (obj.name in seen or seen.add(obj.name))]
if not furniture_meshes:
print("No recursive mesh objects found with UVs.")
return
raise RuntimeError("No visual furniture mesh objects found with UVs.")
furniture_objs = furniture_meshes
if not furniture_objs:
print("No valid furniture mesh objects found with UVs.")
return
# 2. Manage the Atlas Image (FurnitureColor)
image_name = "FurnitureColor"
@@ -74,7 +90,10 @@ def setup_furniture_atlas():
scene.render.bake.use_pass_indirect = False
scene.render.bake.use_pass_color = True
scene.render.bake.target = 'IMAGE_TEXTURES'
# Empty atlas space stays transparent; the merge step stacks the
# per-source bakes into deterministic vertical bands.
scene.render.film_transparent = True
print("Baking Furniture Atlas...")
override = {
'active_object': bpy.context.view_layer.objects.active,
@@ -83,6 +102,13 @@ def setup_furniture_atlas():
with bpy.context.temp_override(**override):
bpy.ops.object.bake(type='DIFFUSE', margin=2)
if ATLAS_OUTPUT:
os.makedirs(os.path.dirname(ATLAS_OUTPUT), exist_ok=True)
atlas_img.filepath_raw = ATLAS_OUTPUT
atlas_img.file_format = 'PNG'
atlas_img.save()
print("Partial atlas saved to: " + ATLAS_OUTPUT)
atlas_img.pack()
# 6. Create and Assign Final Atlas Material
@@ -106,7 +132,31 @@ def setup_furniture_atlas():
obj.data.materials.clear()
obj.data.materials.append(atlas_mat)
print("Process Complete. Atlas material applied.")
# Put this source file into its deterministic vertical atlas band.
# The merge script stacks the per-source bakes in the same order.
if ATLAS_COUNT > 1:
remapped_mesh_data = set()
for obj in furniture_objs:
# Variants such as table1/table2 and pole1/pole2 share mesh
# datablocks. UV layers belong to the mesh data, so remap each
# datablock once; otherwise the band transform compounds.
if obj.data.name in remapped_mesh_data:
continue
remapped_mesh_data.add(obj.data.name)
for uv_layer in obj.data.uv_layers:
for loop in uv_layer.data:
u, v = loop.uv
if u < -0.001 or u > 1.001 or \
v < -0.001 or v > 1.001:
raise RuntimeError(
"Furniture UV escapes the source atlas on " +
obj.name)
loop.uv = (min(max(u, 0.0), 1.0),
(min(max(v, 0.0), 1.0) + ATLAS_INDEX) /
ATLAS_COUNT)
print("Process Complete. Atlas material applied; UV band %d/%d." %
(ATLAS_INDEX + 1, ATLAS_COUNT))
for mat in bpy.data.materials:
if mat.users == 0:
bpy.data.materials.remove(mat)
@@ -118,8 +168,11 @@ def setup_furniture_atlas():
new_path = current_path.replace(".blend", "_baked.blend")
bpy.ops.wm.save_as_mainfile(filepath=new_path)
print(f"File saved to: {new_path}")
if SUCCESS_OUTPUT:
with open(SUCCESS_OUTPUT, "w", newline="\n") as marker:
marker.write("ok\n")
else:
print("Warning: File not saved (unsaved blend file).")
raise RuntimeError("Furniture blend was not saved")
setup_furniture_atlas()
@@ -0,0 +1,10 @@
if(EXISTS "${FILE}")
file(SIZE "${FILE}" FILE_SIZE)
if(FILE_SIZE GREATER 0)
message(STATUS "Build-time check: ${FILE} exists and is not empty.")
else()
message(FATAL_ERROR "Build-time check: ${FILE} exists but is empty!")
endif()
else()
message(FATAL_ERROR "Build-time check: ${FILE} does not exist!")
endif()
@@ -2,7 +2,22 @@ import bpy
import mathutils
ACTION_PROPS = ["name", "action", "action_text", "height", "radius", "tags"]
FURNITURE_PROPS = ["name", "furniture_tags"]
# Asset-local furniture metadata exported to furniture-*.json. Room
# placement/composition deliberately stays out of these properties; the
# layout DSL/Lua handles table sets, stores, garages and similar setups.
FURNITURE_PROPS = [
"furniture_id",
"display_name",
"furniture_tags",
"layer",
"selection_weight",
"requires_floor",
"requires_wall",
"clearance",
"footprint_x",
"footprint_z",
"collision_mesh",
]
test_tracking = {"lib_name": None}
@@ -122,23 +137,26 @@ class OBJECT_OT_CreateFurnitureProps(bpy.types.Operator):
def execute(self, context):
obj = context.object
defaults = {
"furniture_id": obj.name.replace("furniture-", "").strip("-"),
"display_name": obj.name.replace("furniture-", "").replace("-", " ").title(),
"furniture_tags": "",
"layer": "normal",
"selection_weight": 1.0,
"requires_floor": True,
"requires_wall": False,
"clearance": 0.0,
"footprint_x": 0, # 0 = derive from mesh bounds
"footprint_z": 0,
"collision_mesh": "", # optional collision-* mesh name
}
# Older assets only had the now-obsolete custom "name" property;
# use it once as the stable id instead of guessing tags from names.
if "furniture_id" not in obj and "name" in obj:
defaults["furniture_id"] = str(obj["name"]).strip("-")
for p in FURNITURE_PROPS:
if p not in obj:
if p == "name":
obj[p] = obj.name.replace("furniture-","")
elif p == "furniture_tags":
obj[p] = ""
otags = []
if obj.name.find("toilet") >= 0:
otags.append("wc")
otags.append("bathroom")
otags.append("toilet")
elif obj.name.find("bed") >= 0:
otags.append("bed")
otags.append("bedroom")
obj[p] = ", ".join(otags)
else:
obj[p] = "" # Initialize as empty text string
obj[p] = defaults[p]
return {'FINISHED'}
class OBJECT_OT_CreateChildEmpties(bpy.types.Operator):
@@ -324,7 +342,7 @@ class OBJECT_PT_FurnitureEmptyPanel(bpy.types.Panel):
else:
# Show the text fields if properties exist
box = layout.box()
box.label(text="Furnitore Settings", icon='PROPERTIES')
box.label(text="Furniture Settings", icon='PROPERTIES')
for p in FURNITURE_PROPS:
box.prop(obj, f'["{p}"]', text=p.replace("_", " ").title())
op_use = layout.operator("object.create_child_action_node", icon='EMPTY_DATA', text="Add Use Action Node")
@@ -346,8 +364,8 @@ def register():
def unregister():
bpy.utils.register_class(OBJECT_OT_CreateActionProps)
bpy.utils.register_class(OBJECT_OT_CreateFurnitureProps)
bpy.utils.unregister_class(OBJECT_OT_CreateActionProps)
bpy.utils.unregister_class(OBJECT_OT_CreateFurnitureProps)
bpy.utils.unregister_class(OBJECT_PT_ActionEmptyPanel)
bpy.utils.unregister_class(OBJECT_OT_CreateChildEmpties)
bpy.utils.unregister_class(OBJECT_OT_DestroyChildByProp)
@@ -1,14 +1,16 @@
import bpy
import os, sys, time
import json
from mathutils import Matrix, Quaternion
import os
import re
import sys
import bpy
from mathutils import Matrix, Quaternion, Vector
argv = sys.argv
argv = argv[argv.index("--") + 1:]
incpath = os.path.dirname(__file__)
sys.path.insert(0, incpath)
outdir = argv[0]
SUCCESS_OUTPUT = argv[1] if len(argv) > 1 else ""
basis_change = Matrix((
(1, 0, 0, 0),
@@ -17,180 +19,317 @@ basis_change = Matrix((
(0, 0, 0, 1)
))
def export_root_objects_to_gltf(output_dir):
# Ensure the output directory exists
if not os.path.exists(output_dir):
os.makedirs(output_dir)
LAYERS = {"normal", "thin-floor", "thin-wall", "ceiling"}
ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
# Deselect all objects
bpy.ops.object.select_all(action='DESELECT')
# Iterate through all objects in the scene
for obj in bpy.context.scene.objects:
# Check if the object has no parent and has children
# The original request specifies "objects having no parent with children".
# This condition captures those that are explicitly root objects of a hierarchy.
if obj.parent is None:
# Select the root object and all its children
def round4(value):
return round(float(value), 4)
print(obj.name)
if not obj.name.startswith("furniture-"):
continue
desc = {}
desc["name"] = obj.name.replace("furniture-","")
desc["mesh"] = obj.name + ".glb"
if "furniture_tags" in obj:
mtags = obj["furniture_tags"]
if "," in mtags:
atags = [m.strip() for m in mtags.split(",")]
desc["tags"] = atags
else:
desc["tags"] = [mtags]
def prop(root, name, default):
return root[name] if name in root else default
def stable_id(root):
custom = str(prop(root, "furniture_id", "")).strip().lower()
legacy_suffix = root.name.replace("furniture-", "")
raw = custom or legacy_suffix.strip("-").lower()
if not custom and legacy_suffix.startswith("-"):
# Keep the accidental furniture--<name> asset addressable without
# colliding with the canonical furniture-<name> root.
raw += "-legacy"
if not raw or not ID_PATTERN.match(raw):
raise ValueError("invalid furniture id %r on %s" % (raw, root.name))
return raw
def parse_tags(value):
# Existing blends use comma-separated custom-property strings. Also
# accept semicolons and the legacy "tag. tag" typo, normalize case and
# drop empty/duplicate entries while preserving order.
result = []
seen = set()
for part in re.split(r"[,;]|\.\s+", str(value)):
tag = part.strip().lower()
if not tag:
continue
if not re.match(r"^[a-z0-9][a-z0-9._-]*$", tag):
raise ValueError("invalid furniture tag %r" % tag)
if tag not in seen:
seen.add(tag)
result.append(tag)
return result
def iter_descendants(root):
stack = list(root.children)
while stack:
obj = stack.pop(0)
yield obj
stack.extend(obj.children)
def is_collision_object(obj):
# Existing assets also use "-col" for some VISUAL meshes, so only the
# explicit collision-* prefix is excluded automatically.
return obj.name.lower().split(".", 1)[0].startswith("collision-")
def is_visual_mesh(obj):
return obj.type == 'MESH' and not is_collision_object(obj)
def bounds_in_root_space(root, objects):
root_inv = root.matrix_world.inverted()
minimum = None
maximum = None
for obj in objects:
if len(obj.bound_box) == 0:
continue
matrix = root_inv @ obj.matrix_world
for corner in obj.bound_box:
local = basis_change @ (matrix @ Vector(corner))
if minimum is None:
minimum = local.copy()
maximum = local.copy()
else:
desc["tags"] = []
desc["sensors"] = []
desc["actions"] = []
desc["positions"] = []
for child in obj.children:
if child.name.startswith("action-"):
if not "action" in child:
continue
if not "height" in child:
continue
if not "radius" in child:
continue
local_matrix = child.matrix_local
ogre_local_matrix = basis_change @ local_matrix @ basis_change.inverted()
local_pos_d = ogre_local_matrix.to_translation()
local_rot_d = ogre_local_matrix.to_quaternion()
local_pos = [round(x, 4) for x in local_pos_d]
local_rot = [round(x, 4) for x in local_rot_d]
action = {}
action["position_x"] = local_pos[0]
action["position_y"] = local_pos[1]
action["position_z"] = local_pos[2]
action["rotation_w"] = local_rot[0]
action["rotation_x"] = local_rot[1]
action["rotation_y"] = local_rot[2]
action["rotation_z"] = local_rot[3]
action["height"] = child["height"]
action["radius"] = child["radius"]
action["action"] = child["action"]
if "action_text" in child:
action["action_text"] = child["action_text"]
else:
action["action_text"] = child["action"].capitalize()
if "name" in child:
action["name"] = child["name"]
else:
action["name"] = desc["name"] + "_" + str(len(desc["actions"]))
if "tags" in child:
atags = child["tags"]
if "," in atags:
atags = [m.strip() for m in atags.split(",")]
action["tags"] = atags
else:
action["tags"] = [atags]
else:
action["tags"] = []
for k, v in child.items():
if k.startswith("goap_prereq_"):
action[k] = v
elif k.startswith("goap_effect_"):
action[k] = v
action["furniture"] = {}
action["furniture"]["name"] = desc["name"]
action["furniture"]["tags"] = desc["tags"]
action["positions"] = []
for schild in child.children:
if schild.name.startswith("position-"):
local_matrix = schild.matrix_local
ogre_local_matrix = basis_change @ local_matrix @ basis_change.inverted()
local_pos_d = ogre_local_matrix.to_translation()
local_rot_d = ogre_local_matrix.to_quaternion()
local_pos = [round(x, 4) for x in local_pos_d]
local_rot = [round(x, 4) for x in local_rot_d]
position = {}
if "name" in schild:
position["name"] = schild["name"]
if "type" in schild:
position["type"] = schild["type"]
position["position_x"] = local_pos[0]
position["position_y"] = local_pos[1]
position["position_z"] = local_pos[2]
position["rotation_w"] = local_rot[0]
position["rotation_x"] = local_rot[1]
position["rotation_y"] = local_rot[2]
position["rotation_z"] = local_rot[3]
action["positions"].append(position)
desc["actions"].append(action)
if child.name.startswith("position-"):
local_matrix = child.matrix_local
ogre_local_matrix = basis_change @ local_matrix @ basis_change.inverted()
local_pos_d = ogre_local_matrix.to_translation()
local_rot_d = ogre_local_matrix.to_quaternion()
local_pos = [round(x, 4) for x in local_pos_d]
local_rot = [round(x, 4) for x in local_rot_d]
position = {}
if "name" in child:
position["name"] = child["name"]
if "type" in child:
position["type"] = child["type"]
position["position_x"] = local_pos[0]
position["position_y"] = local_pos[1]
position["position_z"] = local_pos[2]
position["rotation_w"] = local_rot[0]
position["rotation_x"] = local_rot[1]
position["rotation_y"] = local_rot[2]
position["rotation_z"] = local_rot[3]
desc["positions"].append(position)
minimum.x = min(minimum.x, local.x)
minimum.y = min(minimum.y, local.y)
minimum.z = min(minimum.z, local.z)
maximum.x = max(maximum.x, local.x)
maximum.y = max(maximum.y, local.y)
maximum.z = max(maximum.z, local.z)
if minimum is None:
return None
return ([round4(minimum.x), round4(minimum.y), round4(minimum.z)],
[round4(maximum.x), round4(maximum.y), round4(maximum.z)])
obj.select_set(True)
for child in obj.children_recursive:
child.select_set(True)
obj.location = (0.0, 0.0, 0.0)
# Set the root object as the active object for the export operator
bpy.context.view_layer.objects.active = obj
def transform_json(root, obj):
local_matrix = root.matrix_world.inverted() @ obj.matrix_world
ogre_local_matrix = basis_change @ local_matrix @ basis_change.inverted()
local_pos = ogre_local_matrix.to_translation()
local_rot = ogre_local_matrix.to_quaternion()
return [round4(x) for x in local_pos], [round4(x) for x in local_rot]
# Define the output file path
file_path = os.path.join(output_dir, f"{obj.name}.glb")
# Export the selected objects to a glTF file
bpy.ops.export_scene.gltf(filepath=file_path,
use_selection=True,
check_existing=False,
export_format='GLB',
export_texture_dir='textures', export_texcoords=True,
export_normals=True,
export_tangents=True,
export_materials='EXPORT',
export_colors=True,
use_mesh_edges=False,
use_mesh_vertices=False,
export_cameras=False,
use_visible=False,
use_renderable=False,
export_yup=True,
export_apply=True,
export_animations=True,
export_force_sampling=True,
export_def_bones=False,
export_current_frame=False,
export_morph=True,
export_morph_animation=False,
export_morph_normal=True,
export_morph_tangent=True,
export_lights=False,
export_skins=True)
# Deselect all objects for the next iteration
bpy.ops.object.select_all(action='DESELECT')
print(f"Exported {obj.name} and its children to {file_path}")
with open(file_path + ".json", "w") as json_data:
json.dump(desc, json_data)
def position_json(root, child):
pos, rot = transform_json(root, child)
position = {}
if "name" in child:
position["name"] = str(child["name"])
if "type" in child:
position["type"] = str(child["type"])
position["position_x"] = pos[0]
position["position_y"] = pos[1]
position["position_z"] = pos[2]
position["rotation_w"] = rot[0]
position["rotation_x"] = rot[1]
position["rotation_y"] = rot[2]
position["rotation_z"] = rot[3]
return position
def action_json(root, child, furniture_id, furniture_tags):
for required in ("action", "height", "radius"):
if required not in child:
raise ValueError("action %s on %s lacks %s" %
(child.name, root.name, required))
pos, rot = transform_json(root, child)
height = float(child["height"])
radius = float(child["radius"])
if height <= 0.0 or radius <= 0.0:
raise ValueError("action %s on %s has non-positive dimensions" %
(child.name, root.name))
action = {}
action["position_x"] = pos[0]
action["position_y"] = pos[1]
action["position_z"] = pos[2]
action["rotation_w"] = rot[0]
action["rotation_x"] = rot[1]
action["rotation_y"] = rot[2]
action["rotation_z"] = rot[3]
action["height"] = height
action["radius"] = radius
action["action"] = str(child["action"])
action["action_text"] = str(child.get("action_text",
action["action"].capitalize()))
action["name"] = str(child.get("name",
furniture_id + "_" + str(len(root.children))))
action["tags"] = parse_tags(child.get("tags", ""))
for key, value in child.items():
if key.startswith("goap_prereq_") or key.startswith("goap_effect_"):
action[key] = value
action["furniture"] = {"name": furniture_id, "tags": furniture_tags}
action["positions"] = []
for position in child.children:
if position.name.startswith("position-"):
action["positions"].append(position_json(child, position))
return action
def content_hash(desc):
payload = json.dumps(desc, sort_keys=True, separators=(",", ":"),
ensure_ascii=False)
value = 14695981039346656037
for byte in payload.encode("utf-8"):
value ^= byte
value = (value * 1099511628211) & 0xffffffffffffffff
return "%016x" % value
def export_root_objects_to_gltf(output_dir):
os.makedirs(output_dir, exist_ok=True)
exported = []
for obj in bpy.context.scene.objects:
if obj.parent is not None or not obj.name.startswith("furniture-"):
continue
print(obj.name)
furniture_id = stable_id(obj)
tags = parse_tags(prop(obj, "furniture_tags", ""))
layer = str(prop(obj, "layer", "normal")).strip().lower()
if layer not in LAYERS:
raise ValueError("%s has unknown layer %r" % (obj.name, layer))
selection_weight = float(prop(obj, "selection_weight", 1.0))
clearance = float(prop(obj, "clearance", 0.0))
if selection_weight < 0.0 or clearance < 0.0:
raise ValueError("%s has negative weight/clearance" % obj.name)
footprint_x = int(prop(obj, "footprint_x", 0))
footprint_z = int(prop(obj, "footprint_z", 0))
if (footprint_x == 0) != (footprint_z == 0):
raise ValueError("%s needs both footprint axes or neither" %
obj.name)
if footprint_x < 0 or footprint_z < 0:
raise ValueError("%s has a negative footprint" % obj.name)
descendants = list(iter_descendants(obj))
visual_meshes = [child for child in descendants if is_visual_mesh(child)]
if not visual_meshes:
raise ValueError("%s has no visual mesh children" % obj.name)
collision_name = str(prop(obj, "collision_mesh", "")).strip()
collision_meshes = [child for child in descendants
if child.type == 'MESH' and
(is_collision_object(child) or
(collision_name and child.name == collision_name))]
if collision_name and not collision_meshes:
raise ValueError("%s references missing collision mesh %s" %
(obj.name, collision_name))
desc = {
"schemaVersion": 1,
"id": furniture_id,
# `name` is the legacy runtime identifier. Keep it equal to
# the stable id so old FurnitureCell records continue to work.
"name": furniture_id,
"displayName": str(prop(obj, "display_name", furniture_id)),
"mesh": obj.name + ".glb",
"materialName": "M_Furniture_Atlas",
"atlas": "furniture-atlas.png",
"source": "blender",
"layer": layer,
"tags": tags,
"anchor": [0.0, 0.0, 0.0],
"requiresFloor": bool(prop(obj, "requires_floor", True)),
"requiresWall": bool(prop(obj, "requires_wall", False)),
"clearance": clearance,
"weight": selection_weight,
"sensors": [],
"actions": [],
"positions": [],
}
legacy_suffix = obj.name.replace("furniture-", "")
aliases = []
if legacy_suffix != furniture_id:
aliases.append(legacy_suffix)
legacy_custom_name = str(obj.get("name", "")).strip()
if legacy_custom_name and legacy_custom_name != furniture_id and \
legacy_custom_name not in aliases:
aliases.append(legacy_custom_name)
if aliases:
desc["aliases"] = aliases
bounds = bounds_in_root_space(obj, visual_meshes)
if bounds:
desc["bounds"] = {"min": bounds[0], "max": bounds[1]}
collision_bounds = bounds_in_root_space(obj, collision_meshes)
if collision_bounds:
desc["collisionBounds"] = {"min": collision_bounds[0],
"max": collision_bounds[1]}
if footprint_x > 0:
desc["footprintCells"] = [footprint_x, footprint_z]
for child in obj.children:
if child.name.startswith("action-"):
desc["actions"].append(
action_json(obj, child, furniture_id, tags))
elif child.name.startswith("position-"):
desc["positions"].append(position_json(obj, child))
desc["sourceHash"] = content_hash(desc)
# Export only the visual hierarchy. action-/position- empties are
# metadata and collision-* meshes are physics bounds, not render
# content.
bpy.ops.object.select_all(action='DESELECT')
obj.select_set(True)
for child in descendants:
if child.name.startswith("action-") or \
child.name.startswith("position-") or \
is_collision_object(child):
continue
child.select_set(True)
obj.location = (0.0, 0.0, 0.0)
bpy.context.view_layer.objects.active = obj
file_path = os.path.join(output_dir, obj.name + ".glb")
bpy.ops.export_scene.gltf(filepath=file_path,
use_selection=True,
check_existing=False,
export_format='GLB',
export_texture_dir='textures',
export_texcoords=True,
export_normals=True,
export_tangents=True,
export_materials='EXPORT',
export_image_format='NONE',
export_colors=True,
use_mesh_edges=False,
use_mesh_vertices=False,
export_cameras=False,
use_visible=False,
use_renderable=False,
export_yup=True,
export_apply=True,
export_animations=True,
export_force_sampling=True,
export_def_bones=False,
export_current_frame=False,
export_morph=True,
export_morph_animation=False,
export_morph_normal=True,
export_morph_tangent=True,
export_lights=False,
export_skins=True)
with open(file_path + ".json", "w", newline="\n") as json_data:
json.dump(desc, json_data, indent=2, sort_keys=True)
json_data.write("\n")
exported.append((furniture_id, file_path))
print("Exported %s to %s" % (furniture_id, file_path))
if not exported:
raise RuntimeError("no furniture-* root objects found in " +
bpy.data.filepath)
if SUCCESS_OUTPUT:
with open(SUCCESS_OUTPUT, "w", newline="\n") as marker:
marker.write("ok\n")
return exported
export_root_objects_to_gltf(outdir)
Binary file not shown.
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""Write the furniture gallery's static HTML index (stdlib only)."""
import argparse
import html
import json
from pathlib import Path
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--root", required=True)
parser.add_argument("--atlas", default="../furniture-atlas.png")
args = parser.parse_args()
root = Path(args.root)
entries = []
for manifest in sorted(root.glob("manifest-*.json")):
entries.extend(json.loads(manifest.read_text()))
entries.sort(key=lambda entry: entry["id"])
rows = []
for entry in entries:
u0, u1, v0, v1 = entry["uv"]
style = (
"left:%.4f%%;top:%.4f%%;width:%.4f%%;height:%.4f%%" %
(u0 * 100.0, (1.0 - v1) * 100.0,
(u1 - u0) * 100.0, (v1 - v0) * 100.0))
bounds = entry.get("bounds")
bounds_text = "none"
if bounds:
bounds_text = "%s .. %s" % (bounds["min"], bounds["max"])
rows.append(
"<section class='asset'><h2>%s</h2>"
"<p><b>Layer:</b> %s &nbsp; <b>Tags:</b> %s</p>"
"<p class='bounds'><b>Bounds:</b> %s%s</p>"
"<div class='views'>"
"<div><h3>Asset / pivot / bounds / POIs</h3>"
"<img class='render' src='%s'></div>"
"<div><h3>Atlas UV region</h3>"
"<div class='atlas'><img src='%s'>"
"<div class='uv' style='%s'></div></div></div>"
"</div></section>" %
(html.escape(entry["id"]), html.escape(entry["layer"]),
html.escape(", ".join(entry["tags"])),
html.escape(bounds_text),
" + collision proxy" if entry.get("collisionBounds") else "",
html.escape(entry["render"]), html.escape(args.atlas), style))
page = """<!doctype html>
<meta charset='utf-8'>
<title>Furniture export gallery</title>
<style>
body { font-family: sans-serif; background: #202124; color: #ddd;
margin: 2em; }
.asset { border-top: 1px solid #555; padding-top: 1em; margin-top: 2em; }
.views { display: flex; gap: 2em; align-items: flex-start; }
.views > div { flex: 1; min-width: 0; }
.render { width: 100%; image-rendering: auto; background: #111; }
.atlas { position: relative; width: 100%; }
.atlas img { width: 100%; display: block; }
.uv { position: absolute; border: 3px solid #f33; box-sizing: border-box;
pointer-events: none; }
.bounds { color: #aaa; font-size: 0.9em; }
</style>
<h1>Furniture export gallery</h1>
<p>Generated from the baked Blender sources. Green = physical bounds,
red = collision bounds, blue = occupancy cells, axes = pivot, spheres =
action/position markers. The red rectangle on each atlas shows the
asset's UV region.</p>
""" + "\n".join(rows)
(root / "index.html").write_text(page)
print("Furniture gallery index: %d assets" % len(entries))
if __name__ == "__main__":
main()
@@ -0,0 +1,79 @@
import os
import sys
import bpy
# Stack deterministic per-blend furniture atlas bakes into one shared atlas.
# bake_furniture.py remaps each source's UVs into the matching vertical band.
#
# Usage:
# blender --factory-startup -b -P merge_furniture_atlases.py -- \
# <atlas.png> <atlas.material> <part0.png> [part1.png ...]
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
if len(argv) < 3:
raise RuntimeError(
"merge_furniture_atlases.py needs output png/material and inputs")
out_png = argv[0]
out_material = argv[1]
inputs = argv[2:]
images = []
width = None
part_height = None
for path in inputs:
if not os.path.exists(path):
raise RuntimeError("missing furniture atlas part: " + path)
image = bpy.data.images.load(path, check_existing=False)
images.append((path, image))
if width is None:
width, part_height = image.size
elif image.size[0] != width or image.size[1] != part_height:
raise RuntimeError("atlas size mismatch in " + path)
if width is None or width <= 0 or part_height <= 0:
raise RuntimeError("no furniture atlas parts supplied")
part_pixels = width * part_height * 4
merged = []
for path, image in images:
pixels = list(image.pixels)
if len(pixels) != part_pixels:
raise RuntimeError("unexpected pixel count in " + path)
coverage = sum(1 for i in range(3, len(pixels), 4) if pixels[i] > 0.0)
if coverage == 0:
raise RuntimeError("furniture atlas part is empty: " + path)
merged.extend(pixels)
result = bpy.data.images.new("FurnitureColor", width=width,
height=part_height * len(images), alpha=True)
result.pixels = merged
os.makedirs(os.path.dirname(out_png), exist_ok=True)
os.makedirs(os.path.dirname(out_material), exist_ok=True)
result.filepath_raw = out_png
result.file_format = 'PNG'
result.save()
material = """// Generated by merge_furniture_atlases.py; do not edit.
material M_Furniture_Atlas
{
technique
{
pass
{
texture_unit
{
texture furniture-atlas.png
filtering linear linear linear
}
}
}
}
"""
with open(out_material, "w", newline="\n") as file:
file.write(material)
print("Furniture atlas merged: %d source band(s), %dx%d -> %s" %
(len(images), width, part_height * len(images), out_png))
print("Furniture material written: " + out_material)
@@ -0,0 +1,253 @@
"""Render one preview image per furniture root plus a gallery manifest.
Invoke on a *_baked.blend from the furniture pipeline:
blender -b furniture_baked.blend -Y -P render_furniture_gallery.py -- \
<gallery-dir> <definition-dir>
The manifest is consumed by generate_furniture_gallery_index.py. Rendering
uses Cycles CPU so it also works on headless build machines.
"""
import json
import math
import os
import sys
import bpy
from mathutils import Vector
argv = sys.argv[sys.argv.index("--") + 1:]
GALLERY_DIR = argv[0]
DEFINITION_DIR = argv[1]
os.makedirs(GALLERY_DIR, exist_ok=True)
def ogre_to_blender(v):
return Vector((v[0], -v[2], v[1]))
def material(name, color, emission=0.0):
mat = bpy.data.materials.get(name)
if mat:
return mat
mat = bpy.data.materials.new(name)
mat.use_nodes = True
bsdf = mat.node_tree.nodes.get("Principled BSDF")
bsdf.inputs["Base Color"].default_value = (*color, 1.0)
bsdf.inputs["Roughness"].default_value = 0.8
if emission:
bsdf.inputs["Emission"].default_value = (*color, 1.0)
bsdf.inputs["Emission Strength"].default_value = emission
return mat
bounds_mat = material("GalleryBounds", (0.1, 0.8, 0.2), 1.0)
collision_mat = material("GalleryCollision", (0.9, 0.1, 0.1), 1.0)
cell_mat = material("GalleryCells", (0.1, 0.4, 0.9), 0.5)
pivot_mats = [material("GalleryX", (1, 0, 0), 1.0),
material("GalleryY", (0, 1, 0), 1.0),
material("GalleryZ", (0, 0, 1), 1.0)]
action_mat = material("GalleryAction", (1.0, 0.5, 0.05), 1.0)
position_mat = material("GalleryPosition", (0.2, 0.6, 1.0), 1.0)
floor_mat = material("GalleryFloor", (0.18, 0.18, 0.18))
scene = bpy.context.scene
scene.render.engine = 'CYCLES'
scene.cycles.device = 'CPU'
scene.cycles.samples = 4
scene.render.resolution_x = 480
scene.render.resolution_y = 360
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = 'PNG'
scene.render.film_transparent = False
# Neutral floor, camera and lights are shared by every render.
bpy.ops.mesh.primitive_plane_add(size=200.0, location=(0, 0, -0.03))
floor = bpy.context.object
floor.name = "GalleryFloor"
floor.data.materials.append(floor_mat)
bpy.ops.object.camera_add()
camera = bpy.context.object
scene.camera = camera
camera.data.lens = 50
for name, location, energy, size in (
("Key", (3, 4, 6), 600, 4),
("Fill", (-4, 1, 3), 350, 4),
("Rim", (0, -4, 4), 450, 3)):
bpy.ops.object.light_add(type='AREA', location=location)
lamp = bpy.context.object
lamp.name = "Gallery" + name
lamp.data.energy = energy
lamp.data.size = size
def hide_tree(obj, hide):
obj.hide_render = hide
for child in obj.children:
hide_tree(child, hide)
def line_object(name, points, mat, width=0.008):
curve = bpy.data.curves.new(name, 'CURVE')
curve.dimensions = '3D'
curve.bevel_depth = width
curve.bevel_resolution = 1
spline = curve.splines.new('POLY')
spline.points.add(len(points) - 1)
for point, co in zip(spline.points, points):
point.co = (*co, 1.0)
obj = bpy.data.objects.new(name, curve)
scene.collection.objects.link(obj)
obj.data.materials.append(mat)
return obj
def box_edges(name, minimum, maximum, mat):
corners = []
for x in (minimum[0], maximum[0]):
for y in (minimum[1], maximum[1]):
for z in (minimum[2], maximum[2]):
corners.append(Vector((x, y, z)))
indices = [(0, 1), (0, 2), (0, 4), (1, 3), (1, 5), (2, 3),
(2, 6), (3, 7), (4, 5), (4, 6), (5, 7), (6, 7)]
for index, (a, b) in enumerate(indices):
line_object(name + str(index), [corners[a], corners[b]], mat)
def sphere(name, position, mat, radius):
bpy.ops.mesh.primitive_uv_sphere_add(segments=12, ring_count=6,
radius=radius, location=position)
obj = bpy.context.object
obj.name = name
obj.data.materials.append(mat)
return obj
def uv_bounds(root):
result = [1.0, 0.0, 1.0, 0.0]
found = False
stack = list(root.children)
while stack:
obj = stack.pop(0)
stack.extend(obj.children)
if obj.type != 'MESH' or obj.name.startswith("collision-"):
continue
for layer in obj.data.uv_layers:
for loop in layer.data:
u, v = loop.uv
result[0] = min(result[0], u)
result[1] = max(result[1], u)
result[2] = min(result[2], v)
result[3] = max(result[3], v)
found = True
return result if found else [0.0, 1.0, 0.0, 1.0]
roots = [obj for obj in scene.objects
if obj.parent is None and obj.name.startswith("furniture-")]
manifest = []
for root in sorted(roots, key=lambda obj: obj.name):
definition_path = os.path.join(DEFINITION_DIR, root.name + ".glb.json")
with open(definition_path) as source:
definition = json.load(source)
# Definitions and GLBs are exported with the root at the origin;
# use the same pivot here (many source roots have authoring offsets).
root.location = (0.0, 0.0, 0.0)
for other in roots:
hide_tree(other, True)
hide_tree(root, False)
helpers_start = set(scene.objects)
bounds = definition.get("bounds")
if bounds:
minimum = ogre_to_blender(bounds["min"])
maximum = ogre_to_blender(bounds["max"])
# ogre_to_blender swaps Y/Z and negates one axis; reorder.
low = Vector(tuple(min(a, b) for a, b in zip(minimum, maximum)))
high = Vector(tuple(max(a, b) for a, b in zip(minimum, maximum)))
box_edges("PhysicalBounds", low, high, bounds_mat)
center = (low + high) * 0.5
size = high - low
else:
center = Vector((0, 0, 0.5))
size = Vector((1, 1, 1))
if "collisionBounds" in definition:
collision = definition["collisionBounds"]
minimum = ogre_to_blender(collision["min"])
maximum = ogre_to_blender(collision["max"])
low = Vector(tuple(min(a, b) for a, b in zip(minimum, maximum)))
high = Vector(tuple(max(a, b) for a, b in zip(minimum, maximum)))
box_edges("CollisionBounds", low, high, collision_mat)
# Pivot axes and occupancy-cell outlines at the root origin.
line_object("PivotX", [(0, 0, 0), (0.35, 0, 0)], pivot_mats[0], 0.012)
line_object("PivotY", [(0, 0, 0), (0, 0.35, 0)], pivot_mats[1], 0.012)
line_object("PivotZ", [(0, 0, 0), (0, 0, 0.35)], pivot_mats[2], 0.012)
if bounds:
cell_size = 2.0
bmin = bounds["min"]
bmax = bounds["max"]
min_cx = math.floor(bmin[0] / cell_size)
min_cz = math.floor(bmin[2] / cell_size)
max_cx = math.ceil((bmax[0] - 0.0001) / cell_size) - 1
max_cz = math.ceil((bmax[2] - 0.0001) / cell_size) - 1
for cx in range(min_cx, max_cx + 1):
for cz in range(min_cz, max_cz + 1):
points = [(cx * cell_size, -cz * cell_size, -0.01),
((cx + 1) * cell_size, -cz * cell_size, -0.01),
((cx + 1) * cell_size,
-(cz + 1) * cell_size, -0.01),
(cx * cell_size, -(cz + 1) * cell_size, -0.01),
(cx * cell_size, -cz * cell_size, -0.01)]
line_object("OccupancyCell", points, cell_mat, 0.006)
radius = max(size.length * 0.025, 0.025)
for action in definition.get("actions", []):
position = (action["position_x"], action["position_y"],
action["position_z"])
sphere("ActionMarker", ogre_to_blender(position), action_mat,
radius)
for marker in action.get("positions", []):
position = (marker["position_x"], marker["position_y"],
marker["position_z"])
sphere("PositionMarker", ogre_to_blender(position),
position_mat, radius * 0.7)
for marker in definition.get("positions", []):
position = (marker["position_x"], marker["position_y"],
marker["position_z"])
sphere("PositionMarker", ogre_to_blender(position),
position_mat, radius * 0.7)
distance = max(size.length, 0.8) * 1.8
camera.location = center + Vector((distance * 0.8,
distance * 1.1,
distance * 0.65))
camera.rotation_euler = (center - camera.location).to_track_quat(
'-Z', 'Y').to_euler()
camera.data.lens = 45
output_name = root.name + ".png"
scene.render.filepath = os.path.join(GALLERY_DIR, output_name)
bpy.ops.render.render(write_still=True)
manifest.append({
"id": definition["id"],
"render": output_name,
"layer": definition.get("layer", "normal"),
"tags": definition.get("tags", []),
"bounds": definition.get("bounds"),
"collisionBounds": definition.get("collisionBounds"),
"uv": uv_bounds(root),
})
for helper in set(scene.objects) - helpers_start:
bpy.data.objects.remove(helper, do_unlink=True)
with open(os.path.join(GALLERY_DIR,
"manifest-" + os.path.basename(DEFINITION_DIR) +
".json"),
"w") as output:
json.dump(manifest, output, indent=2)
print("Furniture gallery: %d assets rendered" % len(manifest))
@@ -0,0 +1,154 @@
#!/usr/bin/env python3
"""Validate generated furniture GLB/JSON artifacts after the CMake export.
Checks the phase 6.5 shared-atlas contract without starting OGRE:
schema metadata exists, GLBs carry no embedded images, every material is the
shared material, UVs stay in the owning source's vertical atlas band, and
mesh variants sharing a Blender mesh datablock keep identical UV bounds.
"""
import json
import os
import struct
import sys
from pathlib import Path
COMPONENT_FLOAT = 5126
def fail(message):
raise RuntimeError(message)
def read_glb(path):
data = path.read_bytes()
if len(data) < 20 or data[:4] != b"glTF":
fail(f"{path}: not a GLB")
json_length, json_type = struct.unpack_from("<II", data, 12)
if json_type != 0x4E4F534A:
fail(f"{path}: first chunk is not JSON")
document = json.loads(data[20:20 + json_length])
offset = 20 + json_length
bin_data = b""
if offset + 8 <= len(data):
bin_length, bin_type = struct.unpack_from("<II", data, offset)
if bin_type == 0x004E4942:
bin_data = data[offset + 8:offset + 8 + bin_length]
return document, bin_data
def accessor_floats(document, bin_data, accessor_index):
accessor = document["accessors"][accessor_index]
if accessor["componentType"] != COMPONENT_FLOAT:
fail(f"accessor {accessor_index} is not float")
view = document["bufferViews"][accessor["bufferView"]]
offset = view.get("byteOffset", 0) + accessor.get("byteOffset", 0)
components = {"VEC2": 2, "VEC3": 3, "VEC4": 4}[accessor["type"]]
count = accessor["count"] * components
return struct.unpack_from("<%df" % count, bin_data, offset)
def uv_bounds(document, bin_data):
result = []
for mesh_index, mesh in enumerate(document.get("meshes", [])):
mesh_name = mesh.get("name", f"mesh{mesh_index}")
for primitive in mesh.get("primitives", []):
accessor_index = primitive.get("attributes", {}).get("TEXCOORD_0")
if accessor_index is None:
continue
values = accessor_floats(document, bin_data, accessor_index)
us = values[::2]
vs = values[1::2]
result.append((mesh_name, min(us), max(us), min(vs), max(vs)))
return result
def validate_source(source_root, source_name, band_index, band_count):
source_dir = source_root / source_name
if not source_dir.is_dir():
fail(f"missing output directory {source_dir}")
json_paths = sorted(source_dir.glob("furniture-*.json"))
if not json_paths:
fail(f"no furniture definitions in {source_dir}")
shared_uv = {}
min_v = 1.0 - float(band_index + 1) / band_count - 0.002
max_v = 1.0 - float(band_index) / band_count + 0.002
mesh_count = 0
for json_path in json_paths:
definition = json.loads(json_path.read_text())
if definition.get("source") != "blender":
fail(f"{json_path}: source is not blender")
if definition.get("materialName") != "M_Furniture_Atlas":
fail(f"{json_path}: wrong materialName")
if not definition.get("id") or not definition.get("mesh"):
fail(f"{json_path}: missing id/mesh")
if "bounds" not in definition:
fail(f"{json_path}: missing physical bounds")
glb_path = source_dir / definition["mesh"]
if not glb_path.is_file():
fail(f"{json_path}: referenced mesh is missing")
document, bin_data = read_glb(glb_path)
if document.get("images"):
fail(f"{glb_path}: embeds an image instead of using the shared atlas")
materials = document.get("materials", [])
if not materials or any(
material.get("name") != "M_Furniture_Atlas"
for material in materials):
fail(f"{glb_path}: does not use only M_Furniture_Atlas")
for mesh_name, min_u, max_u, mesh_min_v, mesh_max_v in uv_bounds(
document, bin_data):
mesh_count += 1
if mesh_min_v < min_v or mesh_max_v > max_v:
fail(
f"{glb_path}:{mesh_name}: UV V range "
f"{mesh_min_v:.4f}..{mesh_max_v:.4f} escapes band "
f"{min_v:.4f}..{max_v:.4f}")
key = (source_name, mesh_name)
bounds = (min_u, max_u, mesh_min_v, mesh_max_v)
previous = shared_uv.get(key)
if previous:
if any(abs(a - b) > 0.0005 for a, b in zip(previous, bounds)):
fail(
f"{glb_path}:{mesh_name}: shared mesh UV bounds "
f"changed between variants {previous} -> {bounds}")
else:
shared_uv[key] = bounds
return len(json_paths), mesh_count
def main():
args = sys.argv[1:]
if len(args) < 6 or args[0] != "--root":
fail("usage: validate_furniture_export.py --root <dir> "
"--source <name> <band> <count> [...]")
root = Path(args[1])
atlas_png = root / "furniture-atlas.png"
atlas_material = root / "furniture-atlas.material"
if not atlas_png.is_file() or atlas_png.stat().st_size == 0:
fail("shared furniture-atlas.png is missing/empty")
if "M_Furniture_Atlas" not in atlas_material.read_text():
fail("shared material does not define M_Furniture_Atlas")
definitions = 0
meshes = 0
i = 2
while i < len(args):
if args[i] != "--source" or i + 3 >= len(args):
fail("malformed --source arguments")
count, mesh_count = validate_source(root, args[i + 1],
int(args[i + 2]),
int(args[i + 3]))
definitions += count
meshes += mesh_count
i += 4
print(f"Furniture export validation: {definitions} definitions, "
f"{meshes} mesh primitives, shared atlas OK")
if __name__ == "__main__":
main()
@@ -0,0 +1,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
```
+75 -52
View File
@@ -53,8 +53,10 @@ add_custom_command(
COMMAND ${BLENDER} -b -Y -P ${CMAKE_SOURCE_DIR}/assets/blender/scripts/export_models2.py --
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-consolidated.blend
${CMAKE_BINARY_DIR}/characters/male/normal-male.glb
"AUTO"
"male"
tmp-edited-male.blend
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-lods.blend
COMMAND ${CMAKE_COMMAND} -D FILE=${CMAKE_BINARY_DIR}/characters/male/normal-male.glb
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
COMMAND ${CMAKE_COMMAND} -D FILE=${CMAKE_BINARY_DIR}/characters/male/normal-male.scene
@@ -62,6 +64,8 @@ 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}
VERBATIM
@@ -73,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
@@ -82,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
@@ -418,12 +425,28 @@ function(add_clothes_pipeline INPUT_BLEND WEIGHTED_BLEND FINAL_OUTPUT_BLEND)
)
endfunction()
weight_clothes(${CMAKE_CURRENT_SOURCE_DIR}/clothes-male-top.blend)
weight_clothes(${CMAKE_CURRENT_SOURCE_DIR}/clothes-male-bottom.blend)
weight_clothes(${CMAKE_CURRENT_SOURCE_DIR}/clothes-male-feet.blend)
weight_clothes(${CMAKE_CURRENT_SOURCE_DIR}/clothes-female-top.blend)
weight_clothes(${CMAKE_CURRENT_SOURCE_DIR}/clothes-female-bottom.blend)
weight_clothes(${CMAKE_CURRENT_SOURCE_DIR}/clothes-female-feet.blend)
set(SEX_LIST "male" "female")
foreach(SEX ${SEX_LIST})
set(HAIR_WEIGHTED_STAMP "${CMAKE_CURRENT_BINARY_DIR}/clothes/clothes-${SEX}-hair_weighted.stamp")
add_custom_command(
OUTPUT ${HAIR_WEIGHTED_STAMP}
DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/clothes-${SEX}-hair.blend
${CMAKE_CURRENT_SOURCE_DIR}/process_clothes.py
${CMAKE_CURRENT_BINARY_DIR}/blender-addons-installed
COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_CURRENT_BINARY_DIR}/clothes
COMMAND ${BLENDER} -b -Y -P ${CMAKE_CURRENT_SOURCE_DIR}/process_clothes.py --
${CMAKE_CURRENT_SOURCE_DIR}/clothes-${SEX}-hair.blend
./ ${CMAKE_CURRENT_BINARY_DIR}/clothes
COMMAND ${CMAKE_COMMAND} -E touch ${HAIR_WEIGHTED_STAMP}
COMMAND ${CMAKE_COMMAND} -D FILE=${CMAKE_CURRENT_BINARY_DIR}/clothes/clothes-${SEX}-hair_weighted.blend
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
COMMENT "Processing hair meshes (weight transfer)"
)
weight_clothes(${CMAKE_CURRENT_SOURCE_DIR}/clothes-${SEX}-top.blend)
weight_clothes(${CMAKE_CURRENT_SOURCE_DIR}/clothes-${SEX}-bottom.blend)
weight_clothes(${CMAKE_CURRENT_SOURCE_DIR}/clothes-${SEX}-feet.blend)
endforeach()
# Propagate missing shape keys (like "fat") from Body_shapes to base body parts.
# This ensures all body part meshes have consistent shape keys, preventing seams.
@@ -442,50 +465,50 @@ function(add_shape_key_propagation INPUT_BLEND OUTPUT_BLEND)
)
endfunction()
add_shape_key_propagation(
${CMAKE_CURRENT_SOURCE_DIR}/edited-normal-male.blend
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-shapes.blend
)
add_shape_key_propagation(
${CMAKE_CURRENT_SOURCE_DIR}/edited-normal-female.blend
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-female-shapes.blend
)
# male
add_clothes_pipeline(
"${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-shapes.blend" # INPUT_BLEND
"${CMAKE_CURRENT_BINARY_DIR}/clothes/clothes-male-bottom_weighted.blend" # WEIGHTED_BLEND
"${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-consolidated-bottom.blend" # BOTTOM_OUTPUT_BLEND
)
add_clothes_pipeline(
"${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-consolidated-bottom.blend" # INPUT_BLEND
"${CMAKE_CURRENT_BINARY_DIR}/clothes/clothes-male-top_weighted.blend" # WEIGHTED_BLEND
"${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-consolidated-top.blend" # FINAL_OUTPUT_BLEND
)
add_clothes_pipeline(
"${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-consolidated-top.blend" # INPUT_BLEND
"${CMAKE_CURRENT_BINARY_DIR}/clothes/clothes-male-feet_weighted.blend" # WEIGHTED_BLEND
"${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-consolidated.blend" # FINAL_OUTPUT_BLEND
)
# female
add_clothes_pipeline(
"${CMAKE_CURRENT_BINARY_DIR}/edited-normal-female-shapes.blend" # INPUT_BLEND
"${CMAKE_CURRENT_BINARY_DIR}/clothes/clothes-female-bottom_weighted.blend" # WEIGHTED_BLEND
"${CMAKE_CURRENT_BINARY_DIR}/edited-normal-female-consolidated-bottom.blend" # FINAL_OUTPUT_BLEND
)
add_clothes_pipeline(
"${CMAKE_CURRENT_BINARY_DIR}/edited-normal-female-consolidated-bottom.blend" # INPUT_BLEND
"${CMAKE_CURRENT_BINARY_DIR}/clothes/clothes-female-top_weighted.blend" # WEIGHTED_BLEND
"${CMAKE_CURRENT_BINARY_DIR}/edited-normal-female-consolidated-top.blend" # FINAL_OUTPUT_BLEND
)
add_clothes_pipeline(
"${CMAKE_CURRENT_BINARY_DIR}/edited-normal-female-consolidated-top.blend" # INPUT_BLEND
"${CMAKE_CURRENT_BINARY_DIR}/clothes/clothes-female-feet_weighted.blend" # WEIGHTED_BLEND
"${CMAKE_CURRENT_BINARY_DIR}/edited-normal-female-consolidated.blend" # FINAL_OUTPUT_BLEND
)
set(SLOT_LIST "bottom" "top" "feet" "hair")
list(GET SLOT_LIST -1 LAST_SLOT)
# male and female clothes pipeline
foreach (SEX ${SEX_LIST})
add_shape_key_propagation(
${CMAKE_CURRENT_SOURCE_DIR}/edited-normal-${SEX}.blend
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-${SEX}-shapes.blend
)
set(SLOT_INPUT "edited-normal-${SEX}-shapes.blend")
foreach (SLOT ${SLOT_LIST})
set(SLOT_OUTPUT "edited-normal-${SEX}-consolidated-${SLOT}.blend")
if (SLOT STREQUAL LAST_SLOT)
set(SLOT_OUTPUT "edited-normal-${SEX}-consolidated.blend")
endif()
add_clothes_pipeline("${CMAKE_CURRENT_BINARY_DIR}/${SLOT_INPUT}"
"${CMAKE_CURRENT_BINARY_DIR}/clothes/clothes-${SEX}-${SLOT}_weighted.blend"
"${CMAKE_CURRENT_BINARY_DIR}/${SLOT_OUTPUT}"
)
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()
Binary file not shown.
Binary file not shown.
Binary file not shown.
+54 -38
View File
@@ -151,16 +151,19 @@ def process_clothing_pair(clothing_obj, target_obj, whitelist, is_clothing_copy=
print(f"Processing: {clothing_name_for_final} -> {target_name} (using copy)")
# NEW CODE: Store custom properties from clothing before they might be lost
# Store custom properties from clothing before they might be lost
clothing_props = {}
for prop_name in ["ref_shapes", "ref_ray_length"]:
for prop_name in ["ref_shapes", "ref_ray_length",
"has_own_armature", "own_armature_name"]:
if prop_name in clothing_obj:
clothing_props[prop_name] = clothing_obj[prop_name]
print(f" Stored property '{prop_name}' = {clothing_obj[prop_name]} from clothing")
# Also store metadata tags for export pipeline
clothing_meta = {}
for prop_name in ["ref_layer", "ref_part", "garment_id", "tags", "age", "sex", "slot"]:
for prop_name in ["ref_layer", "ref_part", "garment_id", "tags",
"age", "sex", "slot",
"has_own_armature", "own_armature_name"]:
if prop_name in clothing_obj:
clothing_meta[prop_name] = clothing_obj[prop_name]
print(f" Stored meta '{prop_name}' = {clothing_obj[prop_name]} from clothing")
@@ -181,18 +184,31 @@ def process_clothing_pair(clothing_obj, target_obj, whitelist, is_clothing_copy=
whitelist.add(master_arm)
# Handle clothing armature (if it's not a copy for layer 2)
has_own_arm = clothing_obj.get("has_own_armature", False)
if not is_clothing_copy and clothing_obj.parent and clothing_obj.parent.type == 'ARMATURE':
old_arm = clothing_obj.parent
clothing_obj.matrix_world = clothing_obj.matrix_world.copy()
clothing_obj.parent = None
if old_arm not in whitelist:
bpy.data.objects.remove(old_arm, do_unlink=True)
# Keep hair armatures (objects with own skeleton)
if not has_own_arm:
if old_arm not in whitelist:
bpy.data.objects.remove(old_arm, do_unlink=True)
else:
whitelist.add(old_arm) # preserve hair skeleton
elif not is_clothing_copy and has_own_arm:
# Hair has own skeleton but parent is None (or not an armature);
# find the armature by name and preserve it.
own_arm_name = clothing_obj.get("own_armature_name", "")
old_arm = bpy.data.objects.get(own_arm_name)
if old_arm and old_arm.type == 'ARMATURE' and old_arm not in whitelist:
whitelist.add(old_arm)
print(f" Preserved hair armature '{old_arm.name}' by name for {clothing_name_for_final}")
# For layer 2 clothing copies, we don't need to handle armature separately
# as they'll inherit from the target
# Reparent to master armature if exists
if master_arm:
if master_arm and not clothing_obj.get("has_own_armature", False):
clothing_obj.parent = master_arm
for mod in clothing_obj.modifiers:
if mod.type == 'ARMATURE':
@@ -203,17 +219,42 @@ def process_clothing_pair(clothing_obj, target_obj, whitelist, is_clothing_copy=
clothing_obj.select_set(True)
new_target.select_set(True)
bpy.context.view_layer.objects.active = new_target
# Remember clothing's material BEFORE join so we can verify
# after Blender potentially remaps faces to a stale copy.
clothing_mat = clothing_obj.active_material
bpy.ops.object.join()
# NEW CODE: Copy stored custom properties to combined object
# After joining, new_target is the active object and contains the combined mesh
# ---- Fix: ensure clothing faces keep the clothing's material ----
if clothing_mat:
clothing_slot = None
stale_slot = None
for i, slot in enumerate(new_target.material_slots):
if slot.material == clothing_mat:
clothing_slot = i
elif (slot.material and
slot.material != clothing_mat and
slot.material.name == clothing_mat.name):
# Same name, different data block -> stale
stale_slot = i
if clothing_slot is not None and stale_slot is not None:
mesh = new_target.data
fixed = 0
for poly in mesh.polygons:
if poly.material_index == stale_slot:
poly.material_index = clothing_slot
fixed += 1
if fixed:
print(f" Fixed {fixed} faces: stale mat slot "
f"{stale_slot} -> clothing slot {clothing_slot}")
# ----------------------------------------------------------------
# Copy stored custom properties to combined object
for prop_name, prop_value in clothing_props.items():
new_target[prop_name] = prop_value
print(f" Copied property '{prop_name}' = {prop_value} to combined object")
# NEW CODE: Aggregate clothing metadata onto combined object
# Collect from all clothing objects processed for this target
# Use JSON string since Blender ID properties don't support Python lists/sets
# Aggregate clothing metadata onto combined object
if "_combined_meta" in new_target:
meta = json.loads(new_target["_combined_meta"])
else:
@@ -264,17 +305,14 @@ def run_batch_combine():
all_objs = bpy.data.objects
# Separate body objects (no ref_layer property)
# Filter out rig control shapes (cs_*), helper objects, and objects with no vertices
body_objects = []
skipped_objects = []
for o in all_objs:
if o.type != 'MESH' or "ref_layer" in o:
continue
# Skip rig control shapes and helper objects
if o.name.startswith("cs_") or o.name.startswith("WGT-") or o.name.startswith("MCH-") or o.name.startswith("ORG-"):
skipped_objects.append(o.name)
continue
# Skip objects with no vertices (empty meshes)
if len(o.data.vertices) == 0:
skipped_objects.append(o.name)
continue
@@ -283,7 +321,6 @@ def run_batch_combine():
if skipped_objects:
print(f"Skipped {len(skipped_objects)} non-body mesh objects: {', '.join(skipped_objects[:10])}{'...' if len(skipped_objects) > 10 else ''}")
# Filter body objects: those missing age/sex/slot are treated as helpers/references
required_body_props = {"age", "sex", "slot"}
valid_body_objects = []
skipped_body_objects = []
@@ -302,7 +339,6 @@ def run_batch_combine():
body_objects = valid_body_objects
# Separate clothing by layer, filtering out objects missing required clothing properties
required_clothing_props = {"ref_layer", "ref_part", "garment_id"}
clothing_layer1 = []
clothing_layer2 = []
@@ -310,7 +346,6 @@ def run_batch_combine():
for o in all_objs:
if o.type != 'MESH' or "ref_layer" not in o:
continue
# Check if this is a valid clothing object (has required clothing properties)
missing = [p for p in required_clothing_props if p not in o]
if missing:
print(f"WARNING: Mesh object '{o.name}' has ref_layer but is missing required clothing properties {missing}.")
@@ -329,16 +364,11 @@ def run_batch_combine():
print(f"Found {len(clothing_layer1)} layer 1 clothing objects")
print(f"Found {len(clothing_layer2)} layer 2 clothing objects")
# Create dictionary of body objects for quick lookup
body_objects_dict = {obj.name: obj for obj in body_objects}
# Track objects to keep
whitelist = set()
# List to store combined objects from layer 1
combined_objects = []
# PROCESS LAYER 1: Combine with original body parts
# PROCESS LAYER 1
print("\n=== PROCESSING LAYER 1 CLOTHING ===")
for clothing_obj in clothing_layer1:
if "ref_part" not in clothing_obj:
@@ -352,7 +382,6 @@ def run_batch_combine():
print(f"Warning: Target body '{target_name}' not found for clothing '{clothing_obj.name}', skipping")
continue
# Process the pair
result = process_clothing_pair(clothing_obj, original_body, whitelist)
if result:
combined_objects.append(result)
@@ -366,27 +395,20 @@ def run_batch_combine():
for clothing_obj in clothing_layer2:
print(f"\nProcessing layer 2 clothing: {clothing_obj.name}")
# Store the original clothing name for later use
original_clothing_name = clothing_obj.name
# For each combined object from layer 1
for combined_obj in combined_objects:
# Create a copy of the layer 2 clothing
clothing_copy = clothing_obj.copy()
clothing_copy.data = clothing_obj.data.copy()
bpy.context.collection.objects.link(clothing_copy)
# Copy custom properties
for key in clothing_obj.keys():
clothing_copy[key] = clothing_obj[key]
# Set the ref_part to point to the combined object
clothing_copy["ref_part"] = combined_obj.name
print(f" Combining with layer 1 result: {combined_obj.name}")
# Process the pair
result = process_clothing_pair(clothing_copy, combined_obj, whitelist,
is_clothing_copy=True,
original_clothing_name=original_clothing_name)
@@ -396,7 +418,7 @@ def run_batch_combine():
print(f"Layer 2 first pass complete. Created {len(layer2_results_over_l1)} combined objects")
# PROCESS LAYER 2 (Second Pass): Combine directly with body parts (like layer 1)
# PROCESS LAYER 2 (Second Pass): Combine directly with body parts
print("\n=== PROCESSING LAYER 2 CLOTHING (Second Pass - Direct to Body) ===")
layer2_results_direct = []
@@ -414,7 +436,6 @@ def run_batch_combine():
print(f"\nProcessing layer 2 clothing directly with body: {clothing_obj.name} -> {target_name}")
# Process directly with body part (no copy needed for the clothing itself)
result = process_clothing_pair(clothing_obj, original_body, whitelist, is_clothing_copy=False)
if result:
layer2_results_direct.append(result)
@@ -422,7 +443,6 @@ def run_batch_combine():
print(f"Layer 2 second pass complete. Created {len(layer2_results_direct)} combined objects")
# Add all results to combined objects list
all_results = combined_objects + layer2_results_over_l1 + layer2_results_direct
print(f"\n=== SUMMARY ===")
@@ -431,7 +451,6 @@ def run_batch_combine():
print(f"Layer 2 direct to body results: {len(layer2_results_direct)}")
print(f"Total combined objects: {len(all_results)}")
# Finalize aggregated metadata on all combined objects
for obj in all_results:
whitelist.add(obj)
if "_combined_meta" in obj:
@@ -457,16 +476,13 @@ def run_batch_combine():
print(f"Finalized metadata for {obj.name}: layer={obj['layer']}, garments={obj['garments']}, tags={obj['clothing_tags']}")
# Remove temporary meta property
del obj["_combined_meta"]
cleanup_unused_objects(whitelist)
# Save the result
bpy.ops.wm.save_as_mainfile(filepath=output_path)
print(f"\nSaved to: {output_path}")
if __name__ == "__main__":
run_batch_combine()
+70 -20
View File
@@ -10,70 +10,121 @@ from transfer_shape_keys import fix_seams_across_objects, fix_normals_across_obj
def process_append(source_files, output_path):
required_props = {"age", "sex", "slot"}
for file_path in source_files:
if not os.path.exists(file_path):
print(f"Warning: Source file not found: {file_path}")
continue
with bpy.data.libraries.load(file_path) as (data_from, data_to):
# ---- Pre-import materials and armatures from source blend ----
with bpy.data.libraries.load(file_path, link=False) as (data_from, data_to):
data_to.materials = data_from.materials
data_to.objects = data_from.objects
imported_materials = {}
for mat in data_to.materials:
if mat is None:
continue
existing = bpy.data.materials.get(mat.name)
if existing and existing != mat:
print(f" Material '{mat.name}' already exists with "
f"different data; incoming material will "
f"replace it on appended objects.")
imported_materials[mat.name] = mat
# Also track imported armatures
imported_armatures = {}
for obj in data_to.objects:
if obj is None:
continue
if obj.type == 'ARMATURE':
imported_armatures[obj.name] = obj
# ----------------------------------------------------
for obj in data_to.objects:
if obj is None: continue
# Check criteria
# Keep armatures from the source blend
if obj.type == 'ARMATURE':
bpy.context.collection.objects.link(obj)
print(f"Imported armature '{obj.name}' from source blend")
continue
# Check criteria for body part meshes
has_props = all(p in obj.keys() for p in required_props)
if obj.type == 'MESH' and has_props:
# 1. Link to the scene root temporarily
bpy.context.collection.objects.link(obj)
# ---- Remap material slots to imported versions ----
for slot in obj.material_slots:
if slot.material and slot.material.name in imported_materials:
imported = imported_materials[slot.material.name]
if imported != slot.material:
print(f" Remapping slot "
f"'{slot.material.name}' -> "
f"'{imported.name}' on '{obj.name}'")
slot.material = imported
# ---------------------------------------------------
# 2. Synchronize Names
obj.data.name = obj.name
# 3. Find Target Armature
# 3. Armature handling
has_own_arm = obj.get("has_own_armature", False)
if has_own_arm:
own_arm_name = obj.get("own_armature_name", "")
own_arm = bpy.data.objects.get(own_arm_name)
if not own_arm and own_arm_name in imported_armatures:
own_arm = imported_armatures[own_arm_name]
if own_arm:
# Keep hair's own armature; don't reparent
# to body armature.
# Fix the Armature modifier to point to the
# hair armature (combine_clothes may have
# changed it to the body rig).
arm_mod = next((m for m in obj.modifiers
if m.type == 'ARMATURE'), None)
if arm_mod:
arm_mod.object = own_arm
obj.parent = own_arm
print(f"Processed {obj.name}: keeping own "
f"armature '{own_arm_name}'")
continue
# Normal body part: parent to body armature
arm_name = obj.get("sex")
arm_obj = bpy.data.objects.get(arm_name)
if arm_obj and arm_obj.type == 'ARMATURE':
# A. Handle Collections: Move mesh to armature's collections
# Remove from all current collections first
# A. Handle Collections
for col in obj.users_collection:
col.objects.unlink(obj)
# Link to every collection the armature belongs to
for col in arm_obj.users_collection:
col.objects.link(obj)
# B. Parent to Armature
obj.parent = arm_obj
# C. Handle Armature Modifier
arm_mod = next((m for m in obj.modifiers if m.type == 'ARMATURE'), None)
if not arm_mod:
arm_mod = obj.modifiers.new(name="Armature", type='ARMATURE')
arm_mod.object = arm_obj
print(f"Processed {obj.name}: Parented and Modset to {arm_name}")
else:
print(f"Warning: Armature '{arm_name}' not found for {obj.name}")
elif obj.type == 'MESH':
# Object is a mesh but missing required properties - treat as helper/reference
missing = [p for p in required_props if p not in obj.keys()]
print(f"WARNING: Mesh object '{obj.name}' is missing required properties {missing}.")
print(f" Treating as helper/reference object (not a body part).")
print(f" Available properties: {[k for k in obj.keys() if not k.startswith('_')]}")
# Don't remove - keep helper/reference objects in the scene
else:
# Clean up data not meeting criteria
bpy.data.objects.remove(obj, do_unlink=True)
# 4. Recursive Purge of all unlinked data (Materials, Textures, Meshes)
bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True)
# 5. Fix seams across ALL body parts in the consolidated scene.
# This synchronizes shape key offsets and normals for matching vertices
# across base body parts and combined clothing variants.
print("\nFixing seams across all consolidated body parts...")
fix_seams_across_objects()
fix_normals_across_objects()
@@ -90,4 +141,3 @@ if __name__ == "__main__":
process_append(sources, output)
except ValueError:
print("Error: Use '--' to separate Blender args from script args.")
Binary file not shown.
Binary file not shown.
+657
View File
@@ -0,0 +1,657 @@
# generate_lods.py - character LOD asset generation (phase 6.C.3)
#
# Run in Blender batch mode with the consolidated blend already loaded:
#
# blender -b -Y edited-normal-<sex>-consolidated.blend \
# -P generate_lods.py -- <sex> <out_dir> <lods_blend_out>
#
# Produces, per sex:
# * <out_dir>/charlod_<sex>-atlas.png - shared 2048x2048 LOD atlas
# * <out_dir>/charlod_<sex>.json - manifest
# * LOD1 object variants `<part>_lod1` - same geometry, no shape keys,
# UVs remapped into the atlas, one shared material `charlod_<sex>`
# (hair slot is skipped - hair is hidden/impostor-baked at distance)
# * LOD2 impostors `charlod_<sex>_{thin,fat}_lod2` - the deterministic
# default outfit joined into one mesh skinned to the body armature
# * LOD3 impostors `charlod_<sex>_{thin,fat}_lod3` - decimate of LOD2,
# <= 400 triangles (hard limit)
# * saves everything into <lods_blend_out>
#
# Documented simplifications:
# * Hair re-skinning: hair meshes (which may reference their OWN hair
# armature) are rigid-bound 100% to the body skeleton's head bone
# (mixamorig:Head) for the impostors. At LOD2+ distances hair physics
# is disabled anyway (CHARACTER_MODELING.md section 5/6).
# * Default outfit: per slot the highest available layer wins
# (2 dressed > 1 underwear > 0 nude), first entry sorted by object
# name. This is deterministic and mirrors what the town population
# would resolve for a dressed NPC.
# * Core-bone palette: LOD2/3 impostors skin only to CORE_BONE_NAMES
# (root/spine/neck/head/limbs, 23 bones, no fingers). Groups of
# dropped bones (fingers, eyes, bust, hair/coat-skirt physics
# chains) are remapped to the nearest kept ancestor and merged.
# The shared skeleton and its animations are unchanged, so no
# animation conversion is needed.
# * UV flipping: none needed - Blender image pixel rows and UV space
# share the same bottom-left origin, so UV v maps linearly onto the
# pixel row index (v = y / height). The saved PNG keeps that row
# order, which is the same convention the existing pipeline exports.
# * Source UVs are clamped to [0,1] before remapping so remapped UVs
# always stay inside their atlas cell (affects only tiling UVs).
#
# The script is idempotent: previously generated _lod1/_lod2/_lod3 and
# charlod_* objects, the charlod_<sex> material and the atlas image are
# wiped at start.
import bpy
import json
import math
import os
import re
import sys
import numpy as np
ATLAS_SIZE = 2048
LOD3_MAX_TRIS = 400
REQUIRED_PROPS = {"age", "sex", "slot"}
SKIP_PREFIXES = ("cs_", "WGT-", "MCH-", "ORG-")
SLOTS_OUTFIT = ("face", "top", "bottom", "feet")
# Combined LOD2/3 impostors skin to this core palette only (root, spine,
# neck, head, limbs - no fingers, no eyes/bust, no physics chains).
# 23 bones total, comfortably inside the 64-bone hardware-skinning
# budget. Groups of dropped bones are remapped to the nearest kept
# ancestor (fingers -> Hand, Eye/Bust -> Head/Spine2, coat skirt ->
# Leg). The armature and its animations are NOT touched: the impostor
# shares the full skeleton at runtime, only the vertex weights change,
# so no animation conversion is needed.
CORE_BONE_NAMES = {
"Root",
"mixamorig:Hips",
"mixamorig:Spine",
"mixamorig:Spine1",
"mixamorig:Spine2",
"mixamorig:Neck",
"mixamorig:Head",
}
for _side in ("Left", "Right"):
for _part in ("Shoulder", "Arm", "ForeArm", "Hand",
"UpLeg", "Leg", "Foot", "ToeBase"):
CORE_BONE_NAMES.add("mixamorig:" + _side + _part)
MAX_IMPOSTOR_BONES = 64
def log(msg):
print("[generate_lods] " + msg, flush=True)
def parse_args():
argv = sys.argv
argv = argv[argv.index("--") + 1:]
if len(argv) < 3:
raise RuntimeError(
"usage: blender -b -Y <consolidated.blend> -P generate_lods.py"
" -- <sex> <out_dir> <lods_blend_out>")
return argv[0], argv[1], argv[2]
def strip_suffix(name):
"""Strip Blender .### duplicate suffix from a datablock name."""
return re.sub(r"\.\d{3}$", "", name)
def collect_catalog_objects():
"""Same classification rule as consolidate.py."""
result = []
for ob in bpy.data.objects:
if ob.type != "MESH":
continue
if not all(p in ob.keys() for p in REQUIRED_PROPS):
continue
if ob.name.startswith(SKIP_PREFIXES):
continue
result.append(ob)
return sorted(result, key=lambda o: o.name)
def find_armature(sex):
arm = bpy.data.objects.get(sex)
if arm and arm.type == "ARMATURE":
return arm
for ob in bpy.data.objects:
if ob.type == "ARMATURE" and ob.name == sex:
return ob
raise RuntimeError("shared armature '%s' not found" % sex)
def find_head_bone(armature):
bones = armature.data.bones
if "mixamorig:Head" in bones:
return "mixamorig:Head"
for b in bones:
if "Head" in b.name:
log("WARNING: mixamorig:Head not found, using '%s'" % b.name)
return b.name
raise RuntimeError("no head bone found in armature '%s'"
% armature.name)
def base_color_image(mat):
"""Image whose content the mesh UVs address: the Base Color texture
of the Principled BSDF, falling back to the first TEX_IMAGE node."""
if not mat or not mat.use_nodes or not mat.node_tree:
return None
for node in mat.node_tree.nodes:
if node.type == "BSDF_PRINCIPLED":
inp = node.inputs.get("Base Color")
if inp and inp.is_linked:
src = inp.links[0].from_node
if src.type == "TEX_IMAGE" and src.image:
return src.image
for node in mat.node_tree.nodes:
if node.type == "TEX_IMAGE" and node.image:
log("WARNING: material '%s' has no direct Base Color texture,"
" falling back to image '%s'" % (mat.name, node.image.name))
return node.image
return None
def cleanup_previous(sex):
"""Idempotency: wipe previously generated data."""
doomed = []
for ob in list(bpy.data.objects):
if (ob.name.startswith("charlod_") or ob.name.endswith("_lod1")
or ob.name.endswith("_lod2") or ob.name.endswith("_lod3")):
doomed.append(ob)
for ob in doomed:
log("wiping previous object '%s'" % ob.name)
bpy.data.objects.remove(ob, do_unlink=True)
for me in list(bpy.data.meshes):
if me.users == 0 and (me.name.startswith("charlod_")
or me.name.endswith("_lod1")
or me.name.endswith("_lod2")
or me.name.endswith("_lod3")):
bpy.data.meshes.remove(me)
mat = bpy.data.materials.get("charlod_" + sex)
if mat:
bpy.data.materials.remove(mat)
for img in list(bpy.data.images):
if img.name.startswith("charlod_%s-atlas" % sex):
bpy.data.images.remove(img)
def build_atlas(catalog, sex, out_dir):
"""Paste every distinct base-color image into a grid cell of a new
ATLAS_SIZE x ATLAS_SIZE image. Returns (atlas_image, rects) where
rects maps dedupe key -> (u0, v0, u1, v1)."""
canonical = {} # dedupe key -> bpy.types.Image
for ob in catalog:
for slot in ob.material_slots:
img = base_color_image(slot.material)
if img is None:
continue
key = strip_suffix(img.name)
if key not in canonical or not key.endswith(
strip_suffix(canonical[key].name)):
# prefer the unsuffixed instance
if key not in canonical or img.name == key:
canonical[key] = img
keys = sorted(canonical.keys())
log("atlas source images (%d): %s" % (len(keys), keys))
n = max(1, math.ceil(math.sqrt(len(keys))))
cell = ATLAS_SIZE // n
log("atlas grid %dx%d, cell %dpx" % (n, n, cell))
atlas_name = "charlod_%s-atlas.png" % sex
atlas = bpy.data.images.new(atlas_name, width=ATLAS_SIZE,
height=ATLAS_SIZE, alpha=True)
buf = np.zeros(ATLAS_SIZE * ATLAS_SIZE * 4, dtype=np.float32)
grid = buf.reshape(ATLAS_SIZE, ATLAS_SIZE, 4)
rects = {}
for idx, key in enumerate(keys):
img = canonical[key]
w, h = img.size
if w == 0 or h == 0:
log("WARNING: image '%s' has no pixel data, skipped"
% img.name)
continue
col = idx % n
row = idx // n # rows counted from the bottom, like UV v
scale = min(cell / w, cell / h)
pw = max(1, int(round(w * scale)))
ph = max(1, int(round(h * scale)))
tmp = img.copy()
tmp.scale(pw, ph)
px = np.empty(pw * ph * 4, dtype=np.float32)
tmp.pixels.foreach_get(px)
bpy.data.images.remove(tmp)
tile = px.reshape(ph, pw, 4)
x0 = col * cell + (cell - pw) // 2
y0 = row * cell + (cell - ph) // 2
grid[y0:y0 + ph, x0:x0 + pw, :] = tile
rects[key] = (x0 / ATLAS_SIZE, y0 / ATLAS_SIZE,
(x0 + pw) / ATLAS_SIZE, (y0 + ph) / ATLAS_SIZE)
log(" cell (%d,%d): '%s' %dx%d -> %dx%d at (%d,%d) rect %s"
% (col, row, key, w, h, pw, ph, x0, y0,
["%.4f" % v for v in rects[key]]))
atlas.pixels.foreach_set(buf)
os.makedirs(out_dir, exist_ok=True)
atlas_path = os.path.join(out_dir, atlas_name)
atlas.filepath_raw = atlas_path
atlas.file_format = "PNG"
atlas.save()
log("atlas saved to %s" % atlas_path)
return atlas, rects
def make_atlas_material(sex, atlas):
mat_name = "charlod_" + sex
mat = bpy.data.materials.new(mat_name)
mat.use_nodes = True
nt = mat.node_tree
bsdf = next(n for n in nt.nodes if n.type == "BSDF_PRINCIPLED")
tex = nt.nodes.new("ShaderNodeTexImage")
tex.image = atlas
nt.links.new(tex.outputs["Color"], bsdf.inputs["Base Color"])
mat.blend_method = "OPAQUE"
log("created shared material '%s' with texture '%s'"
% (mat_name, atlas.name))
return mat
def slot_rects(ob, rects):
"""Per material slot of ob: atlas rect of its base-color image."""
result = []
for slot in ob.material_slots:
img = base_color_image(slot.material)
if img is None:
result.append(None)
continue
result.append(rects.get(strip_suffix(img.name)))
return result
def remap_mesh_uvs(mesh, rects, owner):
"""Affine-remap loop UVs into the atlas cells of their material slot.
Source UVs are clamped to [0,1] first so output stays in [0,1]."""
uv_layer = mesh.uv_layers.active
if uv_layer is None:
log("WARNING: '%s' has no UV layer, UVs not remapped" % owner)
return
nloops = len(mesh.loops)
if nloops == 0:
return
uvs = np.empty(nloops * 2, dtype=np.float32)
uv_layer.data.foreach_get("uv", uvs)
uvs = uvs.reshape(-1, 2)
np.clip(uvs, 0.0, 1.0, out=uvs)
slot_of_loop = np.zeros(nloops, dtype=np.int32)
for poly in mesh.polygons:
slot_of_loop[poly.loop_start:
poly.loop_start + poly.loop_total] = \
poly.material_index
for si, rect in enumerate(rects):
if rect is None:
continue
mask = slot_of_loop == si
if not mask.any():
continue
u0, v0, u1, v1 = rect
sub = uvs[mask]
sub[:, 0] = u0 + sub[:, 0] * (u1 - u0)
sub[:, 1] = v0 + sub[:, 1] * (v1 - v0)
uvs[mask] = sub
uv_layer.data.foreach_set("uv", uvs.ravel())
lo = uvs.min()
hi = uvs.max()
if lo < -1e-4 or hi > 1.0 + 1e-4:
log("WARNING: '%s' UVs out of [0,1] after remap: %f..%f"
% (owner, lo, hi))
def apply_atlas_material(mesh, atlas_mat):
mesh.materials.clear()
mesh.materials.append(atlas_mat)
for poly in mesh.polygons:
poly.material_index = 0
def copy_object(ob, new_name, collection):
dup = ob.copy()
dup.data = ob.data.copy()
dup.name = new_name
dup.data.name = new_name
collection.objects.link(dup)
return dup
def generate_lod1(catalog, rects, atlas_mat, sex):
"""LOD1: per catalog mesh (hair excluded) a shape-key-free copy with
atlas UVs and the single shared material."""
made = []
for ob in catalog:
if ob.get("slot") == "hair":
continue
new_name = ob.name + "_lod1"
dup = copy_object(ob, new_name, bpy.context.collection)
dup.shape_key_clear()
for key in ob.keys():
dup[key] = ob[key]
dup["lod"] = 1
remap_mesh_uvs(dup.data, slot_rects(ob, rects), new_name)
apply_atlas_material(dup.data, atlas_mat)
made.append(dup)
log("LOD1 '%s': %d polys, props copied, lod=1"
% (new_name, len(dup.data.polygons)))
return made
def pick_default_outfit(catalog):
"""Deterministic default outfit: per slot the highest available
layer (2 > 1 > 0), first object sorted by name. Hair: layer-1,
skipping bald/placeholder meshes (< 100 polys or 'bald' in name)."""
picks = {}
for slot in SLOTS_OUTFIT + ("hair",):
entries = [ob for ob in catalog if ob.get("slot") == slot]
if slot == "hair":
entries = [ob for ob in entries
if int(ob.get("layer", 0)) >= 1
and "bald" not in ob.name.lower()
and len(ob.data.polygons) >= 100]
if not entries:
log("WARNING: no catalog entry for slot '%s'" % slot)
continue
best_layer = max(int(ob.get("layer", 0)) for ob in entries)
best = sorted((ob for ob in entries
if int(ob.get("layer", 0)) == best_layer),
key=lambda o: o.name)[0]
picks[slot] = best
log("default outfit slot '%s': '%s' (layer %d)"
% (slot, best.name, best_layer))
return picks
def bake_shape_keys(ob, fat_value, depsgraph):
"""Evaluate ob with the 'fat' shape key set to fat_value and all
shape keys baked away. Returns a new shape-key-free mesh (vertex
groups, UVs and material slots preserved)."""
dup = ob.copy()
dup.data = ob.data.copy()
bpy.context.collection.objects.link(dup)
if dup.data.shape_keys:
for kb in dup.data.shape_keys.key_blocks:
if kb.name == "fat":
kb.value = fat_value
eval_ob = dup.evaluated_get(depsgraph)
mesh = bpy.data.meshes.new_from_object(
eval_ob, preserve_all_data_layers=True, depsgraph=depsgraph)
bpy.data.objects.remove(dup, do_unlink=True)
return mesh
def rigid_bind_to_head(obj, head_bone):
"""Re-skin every vertex 100% to the head bone (hair shortcut, see
header)."""
for vg in list(obj.vertex_groups):
obj.vertex_groups.remove(vg)
vg = obj.vertex_groups.new(name=head_bone)
vg.add(list(range(len(obj.data.vertices))), 1.0, "REPLACE")
log(" rigid-bound %d verts of '%s' to '%s'"
% (len(obj.data.vertices), obj.name, head_bone))
def core_bone_target(name, parents):
"""Nearest kept ancestor (or the bone itself) for the impostor
palette. Bones outside the mixamorig hierarchy (control rig)
fall back to the skeleton root."""
cur = name
while cur is not None:
if cur in CORE_BONE_NAMES:
return cur
cur = parents.get(cur)
return "Root"
def prune_to_core_bones(obj, armature):
"""Collapse the impostor skin palette to CORE_BONE_NAMES. Vertex
groups of dropped bones are remapped to their nearest kept
ancestor and their weights merged per vertex. The armature, its
bone hierarchy and all animations stay untouched - only the
impostor's vertex weights change."""
parents = {b.name: b.parent.name if b.parent else None
for b in armature.data.bones}
lut = {}
for vg in obj.vertex_groups:
target = core_bone_target(vg.name, parents)
if target not in parents:
raise RuntimeError("core bone '%s' missing from armature"
% target)
lut[vg.index] = target
merged = []
unweighted = False
for v in obj.data.vertices:
acc = {}
for g in v.groups:
if g.weight <= 0.0:
continue
name = lut[g.group]
acc[name] = acc.get(name, 0.0) + g.weight
if not acc:
unweighted = True
merged.append(acc)
for vg in list(obj.vertex_groups):
obj.vertex_groups.remove(vg)
used = sorted({name for acc in merged for name in acc})
if unweighted:
used.append("Root")
groups = {name: obj.vertex_groups.new(name=name) for name in used}
for vi, acc in enumerate(merged):
if not acc:
groups["Root"].add([vi], 1.0, "REPLACE")
continue
for name, w in acc.items():
if w > 1e-6:
groups[name].add([vi], w, "REPLACE")
log("impostor '%s': skin palette pruned to %d core bones%s: %s"
% (obj.name, len(groups),
" (unweighted verts pinned to Root)" if unweighted else "",
", ".join(sorted(groups))))
if len(groups) > MAX_IMPOSTOR_BONES:
raise RuntimeError("impostor '%s' palette %d > %d bones"
% (obj.name, len(groups), MAX_IMPOSTOR_BONES))
def join_objects(objects, name, armature):
bpy.ops.object.select_all(action="DESELECT")
for ob in objects:
ob.select_set(True)
bpy.context.view_layer.objects.active = objects[0]
bpy.ops.object.join()
joined = bpy.context.view_layer.objects.active
joined.name = name
joined.data.name = name
joined.parent = armature
joined.matrix_parent_inverse = armature.matrix_world.inverted()
arm_mod = next((m for m in joined.modifiers
if m.type == "ARMATURE"), None)
if arm_mod is None:
arm_mod = joined.modifiers.new(name="Armature", type="ARMATURE")
arm_mod.object = armature
return joined
def count_tris(mesh):
mesh.calc_loop_triangles()
return len(mesh.loop_triangles)
def generate_lod2(catalog, rects, atlas_mat, armature, head_bone, sex):
made = {}
for variant, fat_value in (("thin", 0.0), ("fat", 1.0)):
picks = pick_default_outfit(catalog)
depsgraph = bpy.context.evaluated_depsgraph_get()
parts = []
for slot in SLOTS_OUTFIT + ("hair",):
ob = picks.get(slot)
if ob is None:
continue
mesh = bake_shape_keys(ob, fat_value, depsgraph)
part = bpy.data.objects.new(
"charlod_tmp_%s_%s" % (variant, slot), mesh)
bpy.context.collection.objects.link(part)
part.matrix_world = ob.matrix_world
remap_mesh_uvs(mesh, slot_rects(ob, rects),
"lod2/%s/%s" % (variant, ob.name))
apply_atlas_material(mesh, atlas_mat)
if slot == "hair":
rigid_bind_to_head(part, head_bone)
parts.append(part)
name = "charlod_%s_%s_lod2" % (sex, variant)
joined = join_objects(parts, name, armature)
prune_to_core_bones(joined, armature)
tris = count_tris(joined.data)
n_vg = len(joined.vertex_groups)
has_head = any(vg.name == head_bone
for vg in joined.vertex_groups)
log("LOD2 '%s': %d polys, %d tris, %d vertex groups, "
"head group: %s" % (name, len(joined.data.polygons), tris,
n_vg, has_head))
made[variant] = joined
return made
def decimated_mesh(src_obj, ratio, depsgraph=None):
tmp = src_obj.copy()
tmp.data = src_obj.data.copy()
bpy.context.collection.objects.link(tmp)
mod = tmp.modifiers.new(name="Decimate", type="DECIMATE")
mod.decimate_type = "COLLAPSE"
mod.ratio = ratio
# refresh so the just-added modifier is part of the evaluation
bpy.context.view_layer.update()
depsgraph = bpy.context.evaluated_depsgraph_get()
eval_ob = tmp.evaluated_get(depsgraph)
mesh = bpy.data.meshes.new_from_object(
eval_ob, preserve_all_data_layers=True, depsgraph=depsgraph)
bpy.data.objects.remove(tmp, do_unlink=True)
return mesh
def generate_lod3(lod2_map, armature, sex):
made = {}
for variant, src in lod2_map.items():
base_tris = count_tris(src.data)
name = "charlod_%s_%s_lod3" % (sex, variant)
depsgraph = bpy.context.evaluated_depsgraph_get()
if base_tris <= LOD3_MAX_TRIS:
mesh = src.data.copy()
log("LOD3 '%s': already %d tris (<= %d), no decimate"
% (name, base_tris, LOD3_MAX_TRIS))
else:
# binary search for the highest ratio still <= LOD3_MAX_TRIS;
# a 0-tri result means the ratio collapsed everything and is
# too LOW, not acceptable.
lo, hi = 0.001, 1.0
best_mesh, best_tris, best_ratio = None, 0, lo
for _ in range(20):
ratio = (lo + hi) / 2.0
mesh = decimated_mesh(src, ratio, depsgraph)
tris = count_tris(mesh)
log(" decimate try ratio %.4f -> %d tris"
% (ratio, tris))
if tris > LOD3_MAX_TRIS:
hi = ratio
bpy.data.meshes.remove(mesh)
elif tris == 0:
lo = ratio
bpy.data.meshes.remove(mesh)
else:
lo = ratio
if best_mesh is not None:
bpy.data.meshes.remove(best_mesh)
best_mesh, best_tris, best_ratio = mesh, tris, ratio
if best_mesh is None:
raise RuntimeError(
"LOD3 '%s': decimate never reached <= %d tris"
% (name, LOD3_MAX_TRIS))
mesh = best_mesh
log("LOD3 '%s': decimate ratio %.4f -> %d tris "
"(limit %d, source %d)"
% (name, best_ratio, best_tris, LOD3_MAX_TRIS,
base_tris))
obj = bpy.data.objects.new(name, mesh)
bpy.context.collection.objects.link(obj)
# A fresh object may already carry the source's vertex groups
# via new_from_object; only fill in any missing ones so the
# decimated mesh's group indices keep resolving to the same
# (core-pruned) bones on export.
for vg in src.vertex_groups:
if obj.vertex_groups.get(vg.name) is None:
obj.vertex_groups.new(name=vg.name)
obj.parent = armature
obj.matrix_parent_inverse = armature.matrix_world.inverted()
arm_mod = obj.modifiers.new(name="Armature", type="ARMATURE")
arm_mod.object = armature
made[variant] = obj
return made
def write_manifest(sex, out_dir):
manifest = {
"atlas": "charlod_%s-atlas.png" % sex,
"variants": ["thin", "fat"],
"lod2": {v: "charlod_%s_%s_lod2.mesh" % (sex, v)
for v in ("thin", "fat")},
"lod3": {v: "charlod_%s_%s_lod3.mesh" % (sex, v)
for v in ("thin", "fat")},
}
path = os.path.join(out_dir, "charlod_%s.json" % sex)
with open(path, "w") as f:
json.dump(manifest, f, indent=2)
log("manifest written to %s" % path)
def main():
sex, out_dir, lods_blend_out = parse_args()
log("sex=%s out_dir=%s lods_blend=%s" % (sex, out_dir,
lods_blend_out))
cleanup_previous(sex)
armature = find_armature(sex)
head_bone = find_head_bone(armature)
log("shared armature '%s', head bone '%s'"
% (armature.name, head_bone))
catalog = collect_catalog_objects()
log("catalog objects (%d): %s"
% (len(catalog), [ob.name for ob in catalog]))
log("=== step 1: atlas ===")
atlas, rects = build_atlas(catalog, sex, out_dir)
atlas_mat = make_atlas_material(sex, atlas)
log("=== step 2: LOD1 variants ===")
lod1 = generate_lod1(catalog, rects, atlas_mat, sex)
log("generated %d LOD1 objects" % len(lod1))
log("=== step 3: LOD2 impostors ===")
lod2 = generate_lod2(catalog, rects, atlas_mat, armature,
head_bone, sex)
log("=== step 4: LOD3 impostors ===")
generate_lod3(lod2, armature, sex)
log("=== step 5: manifest + save ===")
write_manifest(sex, out_dir)
bpy.ops.wm.save_as_mainfile(filepath=lods_blend_out)
log("saved lods blend to %s" % lods_blend_out)
log("DONE")
if __name__ == "__main__":
main()
+81 -21
View File
@@ -72,7 +72,7 @@ def process_batch():
# 3. Locate Reference Library
target_lib_name = f"normal_{age}_{sex}.blend"
target_lib_path = os.path.join(lib_directory, target_lib_name)
rig_name = str(sex)
rig_name = str(sex)
if not os.path.exists(target_lib_path):
if target_lib_name == "normal_adult_male.blend":
@@ -94,11 +94,48 @@ def process_batch():
source_mesh = bpy.data.objects.get(ref_mesh_name)
rig = bpy.data.objects.get(rig_name)
# ---- Detect hair with its own skeleton ----
has_own_arm = False
own_arm_name = None
own_arm = None
print(f" Checking {name} modifiers for own armature:")
for mod in clothing.modifiers:
print(f" type={mod.type} object={mod.object}")
if mod.type == 'ARMATURE':
arm = mod.object
if arm is None:
print(f" mod.object is None, trying by name...")
continue
print(f" arm.name='{arm.name}' rig_name='{rig_name}'")
if arm.name != rig_name:
has_own_arm = True
own_arm_name = arm.name
own_arm = arm
break
if has_own_arm and own_arm:
clothing["has_own_armature"] = True
clothing["own_armature_name"] = own_arm_name
# Ensure hair armature is linked to scene so parent/modifier
# references survive the save.
try:
bpy.context.collection.objects.link(own_arm)
except RuntimeError:
pass # Already linked
print(f" DETECTED own armature '{own_arm_name}' on {name}")
else:
print(f" No own armature on {name} (rig='{rig_name}')")
# -------------------------------------------
# 5. Prep Objects (Apply Scale & Clear Animation)
bpy.context.view_layer.objects.active = clothing
bpy.ops.object.transform_apply(location=False, rotation=True, scale=True)
for item in [clothing, rig]:
# Don't clear animation on the hair armature if it has
# its own skeleton (we want to preserve hair animations)
items_to_clear = [clothing]
if not has_own_arm:
items_to_clear.append(rig)
for item in items_to_clear:
if item.animation_data: item.animation_data_clear()
if item.type == 'ARMATURE':
bpy.context.view_layer.objects.active = item
@@ -106,30 +143,54 @@ def process_batch():
bpy.ops.pose.transforms_clear()
bpy.ops.object.mode_set(mode='OBJECT')
# 6. Weight Transfer
clothing.vertex_groups.clear()
dt = clothing.modifiers.new(name="WT", type='DATA_TRANSFER')
dt.object = source_mesh
dt.use_vert_data = True
dt.data_types_verts = {'VGROUP_WEIGHTS'}
dt.layers_vgroup_select_src = 'ALL'
dt.vert_mapping = 'POLYINTERP_NEAREST'
bpy.context.view_layer.objects.active = clothing
# "Generate Data Layers" step
bpy.ops.object.datalayout_transfer(modifier=dt.name)
bpy.ops.object.modifier_apply(modifier=dt.name)
remove_empty_vertex_groups(clothing, threshold=0.001)
# Also clear the body rig's animation (always)
if has_own_arm:
for item in [rig]:
if item.animation_data: item.animation_data_clear()
if item.type == 'ARMATURE':
bpy.context.view_layer.objects.active = item
bpy.ops.object.mode_set(mode='POSE')
bpy.ops.pose.transforms_clear()
bpy.ops.object.mode_set(mode='OBJECT')
# Set garment_id from object name if not already set
if "garment_id" not in clothing:
clothing["garment_id"] = clothing.name
# 6. Weight Transfer
if has_own_arm:
# Hair with its own skeleton already has correct vertex
# groups for its hair bones; body weight transfer would
# destroy them.
print(f"Skipped weight transfer for {name}: has own skeleton")
else:
clothing.vertex_groups.clear()
dt = clothing.modifiers.new(name="WT", type='DATA_TRANSFER')
dt.object = source_mesh
dt.use_vert_data = True
dt.data_types_verts = {'VGROUP_WEIGHTS'}
dt.layers_vgroup_select_src = 'ALL'
dt.vert_mapping = 'POLYINTERP_NEAREST'
bpy.context.view_layer.objects.active = clothing
# "Generate Data Layers" step
bpy.ops.object.datalayout_transfer(modifier=dt.name)
bpy.ops.object.modifier_apply(modifier=dt.name)
remove_empty_vertex_groups(clothing, threshold=0.001)
# 7. Final Parenting
clothing.parent = rig
arm_mod = clothing.modifiers.new(name="Armature", type='ARMATURE')
arm_mod.object = rig
if has_own_arm:
# Hair with its own skeleton: keep the existing Armature
# modifier (which points to the hair skeleton). Just
# parent to the hair armature; do NOT add a body
# Armature modifier.
clothing.parent = own_arm
print(f"Parented {name} to hair armature '{own_arm_name}'")
else:
clothing.parent = rig
arm_mod = clothing.modifiers.new(name="Armature", type='ARMATURE')
arm_mod.object = rig
# 8. Cleanup Reference Mesh (keep the Rig!)
bpy.data.objects.remove(source_mesh, do_unlink=True)
@@ -147,4 +208,3 @@ def process_batch():
if __name__ == "__main__":
process_batch()
+294 -5
View File
@@ -187,7 +187,14 @@ def extra_linear(angle, offset):
mapping_blend_path = argv[0]
mapping_gltf_path = argv[1]
# Backward compat: support both 4-arg (blend, gltf, armature, outfile) and 5-arg (blend, gltf, objects, armature, outfile)
if len(argv) >= 5:
mapping_lods_path = ""
if len(argv) >= 6:
# 6-arg form: (blend, gltf, objects, armature, outfile, lods_blend)
mapping_objects = argv[2]
mapping_armature_name = argv[3]
mapping_outfile = argv[4]
mapping_lods_path = argv[5]
elif len(argv) >= 5:
mapping_objects = argv[2]
mapping_armature_name = argv[3]
mapping_outfile = argv[4]
@@ -248,6 +255,27 @@ for mapping in[CommandLineMapping()]:
discovered.append(ob.name)
mapping.objs += discovered
print(f"Auto-discovered {len(discovered)} body part objects: {discovered}")
# ---- Separate hair with its own skeleton ----
hair_own_skel = []
hair_own_skel_objs = [] # save object references
for name in list(mapping.objs):
obj = bpy.data.objects.get(name)
if obj:
has_it = obj.get("has_own_armature", False)
own_arm = obj.get("own_armature_name", "")
print(f" Object '{name}': has_own_armature="
f"{has_it}, own_armature_name='{own_arm}'")
if has_it:
hair_own_skel.append(name)
hair_own_skel_objs.append(obj)
mapping.objs.remove(name)
if hair_own_skel:
print(f"Hair with own skeleton (exported separately): "
f"{hair_own_skel}")
else:
print(f"No hair with own skeleton detected")
# ---------------------------------------------
else:
bpy.ops.wm.append(
filepath=os.path.join(mapping.blend_path, mapping.inner_path),
@@ -266,6 +294,8 @@ for mapping in[CommandLineMapping()]:
bpy.data.objects.remove(ob)
# Remove auto-discovered objects that weren't meant for export
elif mapping.auto_discover and ob.type == 'MESH' and ob.name not in mapping.objs:
if ob.get("has_own_armature", False):
continue # exported separately, don't rename
if not ob.name.endswith("-noimp"):
ob.name = ob.name + "-noimp"
@@ -294,11 +324,28 @@ for mapping in[CommandLineMapping()]:
bpy.data.actions.remove(act)
for obj in bpy.data.objects:
if obj.type == 'MESH':
# Skip hair-with-own-skeleton objects — they're
# exported separately and must not be renamed
if obj.get("has_own_armature", False):
continue
if not obj.name in mapping.objs and obj.parent is None:
if not obj.name.endswith("-noimp"):
obj.name = obj.name + "-noimp"
bpy.ops.wm.save_as_mainfile(filepath=(basepath + "/assets/blender/scripts/" + mapping.outfile))
# Hide hair-with-own-skeleton objects from the body glTF export.
# The glTF exporter crashes when sampling animations for hair armatures
# that are not part of the main rig, so we exclude them here and export
# hair separately via OGRE later.
for obj in hair_own_skel_objs:
obj.hide_viewport = True
obj.hide_render = True
arm_name = obj.get("own_armature_name", "")
hair_arm = bpy.data.objects.get(arm_name)
if hair_arm:
hair_arm.hide_viewport = True
hair_arm.hide_render = True
os.makedirs(os.path.dirname(mapping.gltf_path), exist_ok=True)
bpy.ops.export_scene.gltf(filepath=mapping.gltf_path,
use_selection=False,
@@ -317,7 +364,7 @@ for mapping in[CommandLineMapping()]:
use_mesh_edges=False,
use_mesh_vertices=False,
export_cameras=False,
use_visible=False,
use_visible=True,
use_renderable=False,
export_yup=True,
export_animations=True,
@@ -331,6 +378,16 @@ for mapping in[CommandLineMapping()]:
export_lights=False,
export_skins=True)
print("exported to: " + mapping.gltf_path)
# Unhide hair objects for OGRE export
for obj in hair_own_skel_objs:
obj.hide_viewport = False
obj.hide_render = False
arm_name = obj.get("own_armature_name", "")
hair_arm = bpy.data.objects.get(arm_name)
if hair_arm:
hair_arm.hide_viewport = False
hair_arm.hide_render = False
obj_names = mapping.objs
prefix = mapping.armature_name + "_"
@@ -355,10 +412,27 @@ for mapping in[CommandLineMapping()]:
for slot in obj.material_slots:
if slot.material:
mat = slot.material
# 3. Check if material already has the prefix
# 3. Normalize material name: strip Blender auto-suffixes
# (.001,.002 from name collisions, .### from dupes)
# before applying the armature prefix. This prevents
# duplicate .material files with unpredictable names
# (e.g. male_male-clothes-ed.001 vs male_male-clothes-ed)
# that cause submeshes to reference missing materials
# at runtime.
import re
clean = re.sub(r'\.\d{3}$', '', mat.name)
if clean != mat.name:
old = mat.name
mat.name = clean
# If Blender added .001 again because the clean
# name is already taken, keep the suffixed name.
print(f"Normalized material '{old}' -> '{mat.name}'"
f" on object '{name}'")
# 4. Check if material already has the prefix
if not mat.name.startswith(prefix):
mat.name = prefix + mat.name
print(f"Renamed material '{mat.name}' on object '{name}'")
print(f"Renamed material '{mat.name}'"
f" on object '{name}'")
# 3. Export custom properties to json
save_data = {}
for key in obj.keys():
@@ -413,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
@@ -446,15 +560,81 @@ for mapping in[CommandLineMapping()]:
armobj = bpy.data.objects.get(mapping.armature_name)
armobj.data.name = armobj.name
# ---- Fix: sync Image filepath to Image name ----
# Blender silently reuses existing Images when appending
# from libraries, so the Image's filepath may still point
# to the old texture even after the user renamed it.
# blender2ogre writes the filepath to .material files,
# so we must update filepath to match the Image name.
import re as _re
seen_imgs = set()
for name in obj_names:
obj = bpy.data.objects.get(name)
if obj and obj.type == 'MESH':
for slot in obj.material_slots:
if slot.material and slot.material.node_tree:
for node in slot.material.node_tree.nodes:
if node.type == 'TEX_IMAGE' and node.image:
img = node.image
if img.name not in seen_imgs:
seen_imgs.add(img.name)
# Strip Blender auto-suffix (.NNN)
clean = _re.sub(
r'\.\d{3}$', '', img.name)
# Extract just the filename from
# the current filepath
old_name = os.path.basename(
img.filepath)
if old_name != clean and clean:
# Rebuild filepath with new
# filename
dirpart = os.path.dirname(
img.filepath)
new_path = os.path.join(
dirpart, clean)
print(f" Updating Image "
f"'{img.name}': "
f"filepath "
f"'{old_name}' -> "
f"'{clean}'")
img.filepath = new_path
# ---------------------------------------------------
bpy.ops.ogre.export(filepath=mapping.gltf_path.replace(".glb", ".scene"), EX_SELECTED_ONLY=False, EX_SHARED_ARMATURE=True, EX_LOD_GENERATION='0', EX_LOD_DISTANCE=20, EX_LOD_LEVELS=4, EX_GENERATE_TANGENTS='4')
ogre_export_dir = os.path.dirname(mapping.gltf_path.replace(".glb", ".scene"))
# Fix alpha_rejection values in ALL generated .material files.
# blender2ogre 0.9.0 writes float values (e.g. "127.5") for
# alpha_rejection thresholds, but OGRE 14 expects an integer
# in the range 0-255. Float values are truncated to integer
# during material compilation, causing 127.5 -> 127 when 128
# was intended. This makes submeshes with alpha < 128 in the
# texture atlas completely invisible.
import glob as _glob
_mat_files = _glob.glob(os.path.join(ogre_export_dir, "*.material"))
for _mf in _mat_files:
with open(_mf, 'r') as _f:
_content = _f.read()
if 'alpha_rejection' in _content:
import re as _re
_new = _re.sub(
r'alpha_rejection (\S+) (\d+\.?\d*)',
lambda m: 'alpha_rejection ' + m.group(1) +
' ' + str(int(float(m.group(2)))),
_content)
if _new != _content:
with open(_mf, 'w') as _f:
_f.write(_new)
print(f" Fixed alpha_rejection in {_mf}")
# Post-process exported OGRE meshes to fix normal/tangent seams between
# body parts. Even with identical custom normals in Blender, the OGRE
# exporter's calc_tangents() produces slightly different tangents for
# matching vertices because BodyTop and BodyBottom have different face
# topologies. This causes a visible lighting seam with normal mapping,
# especially when shape keys (like "fat") are applied.
ogre_export_dir = os.path.dirname(mapping.gltf_path.replace(".glb", ".scene"))
fix_script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fix_ogre_mesh_seams.py")
if os.path.exists(fix_script):
print(f"\nPost-processing OGRE meshes in {ogre_export_dir}...")
@@ -473,6 +653,115 @@ for mapping in[CommandLineMapping()]:
else:
print(f"WARNING: fix_ogre_mesh_seams.py not found at {fix_script}")
# ---- Export hair with own skeleton ----
print(f"\n=== HAIR DEBUG: auto_discover={mapping.auto_discover} "
f"hair_own_skel={hair_own_skel}")
if mapping.auto_discover and hair_own_skel:
# Collect hair meshes grouped by their armature name
hair_by_arm = {}
for obj in hair_own_skel_objs:
arm_name = obj.get("own_armature_name", "")
print(f" HAIR DEBUG: obj '{obj.name}' "
f"own_armature_name='{arm_name}' "
f"has_own_armature={obj.get('has_own_armature', False)}")
if arm_name not in hair_by_arm:
hair_by_arm[arm_name] = []
hair_by_arm[arm_name].append(obj)
print(f" HAIR DEBUG: hair_by_arm has {len(hair_by_arm)} groups")
body_arm_name = mapping.armature_name # save "male"/"female"
body_prefix = body_arm_name + "_"
# Debug: list all armatures in the scene
all_arms = [o.name for o in bpy.data.objects
if o.type == 'ARMATURE']
print(f" HAIR DEBUG: armatures in scene: {all_arms}")
for arm_name, hair_objs in hair_by_arm.items():
hair_arm = bpy.data.objects.get(arm_name)
print(f" HAIR DEBUG: arm_name='{arm_name}' "
f"hair_arm_found={hair_arm is not None}")
if not hair_arm or hair_arm.type != 'ARMATURE':
print(f" WARNING: Armature '{arm_name}' not found "
f"for hair, skipping")
continue
hair_obj_names = [o.name for o in hair_objs]
print(f" Exporting hair with skeleton '{arm_name}': "
f"{hair_obj_names}")
# Sync armature data name
hair_arm.data.name = hair_arm.name
print(f" Armature data name: '{hair_arm.data.name}'")
# Write body_part JSON for each hair mesh
json_dir = os.path.dirname(mapping.gltf_path)
for obj in hair_objs:
new_mesh_name = body_prefix + obj.name
obj.data.name = new_mesh_name
save_data = {
"age": obj.get("age", ""),
"sex": obj.get("sex", ""),
"slot": obj.get("slot", ""),
"mesh": obj.data.name + ".mesh",
"layer": int(obj.get("layer", 0)),
"garments": [],
"tags": [],
"shape_keys": [],
"own_skeleton": True,
"attach_to_bone": "mixamorig:Head"
}
garments = obj.get("garments", "")
if garments:
save_data["garments"] = [
g.strip()
for g in str(garments).split(";")
if g.strip()]
clothing_tags = obj.get("clothing_tags", "")
if clothing_tags:
save_data["tags"] = [
t.strip()
for t in str(clothing_tags).split(";")
if t.strip()]
save_file = (json_dir + "/body_part_" +
obj.data.name + ".json")
with open(save_file, 'w') as f:
json.dump(save_data, f, indent=2)
print(f" Wrote {save_file}")
# OGRE export for hair with its own skeleton
# OGRE export for hair with its own skeleton
hair_scene = mapping.gltf_path.replace(
".glb", "_hair_" + arm_name + ".scene")
bpy.ops.ogre.export(
filepath=hair_scene,
EX_SELECTED_ONLY=False,
EX_SHARED_ARMATURE=True,
EX_LOD_GENERATION='0',
EX_LOD_DISTANCE=20,
EX_LOD_LEVELS=4,
EX_GENERATE_TANGENTS='4')
print(f" Exported hair scene: {hair_scene}")
# Fix alpha_rejection in hair .material files
_mat_files = _glob.glob(
os.path.join(ogre_export_dir, "*.material"))
for _mf in _mat_files:
with open(_mf, 'r') as _f:
_content = _f.read()
if 'alpha_rejection' in _content:
_new = _re.sub(
r'alpha_rejection (\S+) (\d+\.?\d*)',
lambda m: 'alpha_rejection ' +
m.group(1) + ' ' +
str(int(float(m.group(2)))),
_content)
if _new != _content:
with open(_mf, 'w') as _f:
_f.write(_new)
# -----------------------------------------
bpy.ops.wm.read_homefile(use_empty=True)
time.sleep(2)
bpy.ops.wm.quit_blender()
@@ -0,0 +1,286 @@
#!/usr/bin/env python
"""Generate the demo-sokoban .blend assets (forklift, crate, pad, wall).
Run once with the project Blender:
blender -b -Y -P assets/blender/scripts/gen_sokoban_assets.py
Writes forklift.blend, crate.blend, pad.blend and wall.blend into
assets/blender/vehicles/, from where the import_vehicles CMake target
exports them through export_vehicles.py like the other vehicles.
Axis convention: blender2ogre EX_SWAP_AXIS='xz-y' maps Blender
(x, y, z) -> Ogre (x, z, -y), so Ogre +Z (vehicle forward, Jolt
chassis +Z) is Blender -Y and Ogre +Y (up) is Blender +Z. All meshes
are modelled around the origin: the forklift body sits in the chassis
collider space (collider half extents 0.7 x 0.3 x 1.2, wheels at
(+/-0.65, -0.2, +/-0.85) r=0.3), the crate fills the 1 m box collider,
the pad is a flat 1.2 x 1.2 marker slab.
NOTE: join_into() makes the first part the join target, whose object
location stays baked into the exported mesh as an origin offset (the
exporter does not apply it). For the forklift that is body-front at
blender (0, -0.45, 0.55), so a blender point (X, Y, Z) renders at
Ogre (X, Z - 0.55, -(Y + 0.45)). Keep that offset in mind when
matching mesh geometry against VehicleComponent offsets.
"""
import os
import bpy
from math import radians
OUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"..", "vehicles")
def reset_scene():
bpy.ops.wm.read_homefile(use_empty=True)
def make_material(name, color, roughness=0.8, metallic=0.0):
mat = bpy.data.materials.new(name)
mat.use_nodes = True
bsdf = mat.node_tree.nodes["Principled BSDF"]
bsdf.inputs["Base Color"].default_value = (*color, 1.0)
bsdf.inputs["Roughness"].default_value = roughness
bsdf.inputs["Metallic"].default_value = metallic
return mat
def add_box(name, dims, loc, mat=None):
bpy.ops.mesh.primitive_cube_add(size=1.0, location=loc)
obj = bpy.context.active_object
obj.name = name
obj.dimensions = dims
bpy.ops.object.transform_apply(location=False, rotation=False,
scale=True)
if mat:
obj.data.materials.append(mat)
return obj
def add_cylinder(name, radius, depth, loc, mat=None, rotation=None,
vertices=24):
bpy.ops.mesh.primitive_cylinder_add(vertices=vertices, radius=radius,
depth=depth, location=loc)
obj = bpy.context.active_object
obj.name = name
if rotation:
obj.rotation_euler = rotation
bpy.ops.object.transform_apply(location=False, rotation=True,
scale=True)
if mat:
obj.data.materials.append(mat)
return obj
def join_into(name, objects):
bpy.ops.object.select_all(action='DESELECT')
for obj in objects:
obj.select_set(True)
bpy.context.view_layer.objects.active = objects[0]
bpy.ops.object.join()
obj = bpy.context.active_object
obj.name = name
obj.data.name = name # the exporter names .mesh files after mesh data
return obj
def save_blend(filename):
path = os.path.join(OUT_DIR, filename)
bpy.ops.wm.save_as_mainfile(filepath=path)
print("wrote " + path)
def gen_forklift():
"""forklift.mesh (body+mast), forklift-forks.mesh (carriage+tines,
lifted by VehicleSystem) and forklift-wheel.mesh.
Chassis origin is the Jolt body origin; the visible body sits on
top of the collider box (which spans y -0.3..0.3 in Ogre space).
Front (mast, forks) is Blender -Y == Ogre +Z.
"""
reset_scene()
yellow = make_material("forklift", (0.85, 0.6, 0.05))
steel = make_material("forklift-steel", (0.35, 0.35, 0.38),
roughness=0.5, metallic=0.6)
dark = make_material("wheel", (0.08, 0.08, 0.08), roughness=0.95)
seat_mat = make_material("forklift-seat", (0.1, 0.1, 0.12))
parts = []
# Main chassis / counterweight body, split for a driver footwell:
# the front block (hood/dash) stops at blender y 0.0 so the seated
# driver's legs dangle into the recess in front of the seat.
parts.append(add_box("body-front", (1.3, 0.9, 0.5), (0, -0.45, 0.55),
yellow))
# Nose extension: continues the hood forward over the front wheels
# (blender y -1.35..-0.9 -> Ogre z 0.45..0.90); the wheels tuck
# under it with only their fronts/sides poking out. A separate
# part so body-front keeps its location, which join_into() bakes
# in as the mesh origin offset (see the file docstring).
parts.append(add_box("body-nose", (1.3, 0.45, 0.5),
(0, -1.125, 0.55), yellow))
# Under-seat block (the seat itself sits on top, see below).
parts.append(add_box("body-seat", (0.7, 0.5, 0.42), (0, 0.45, 0.59),
yellow))
# Footwell floor.
parts.append(add_box("footwell", (0.7, 0.5, 0.06), (0, 0.05, 0.36),
steel))
# Rear counterweight bulge (back is blender +Y). Slid forward to
# touch/embed 1 cm into the under-seat block (back face y 0.70) so
# there is no gap; its rear is covered by the chassis collider
# (half z 1.3 with offset z -0.3 -> Ogre z -1.6..1.0).
parts.append(add_box("counterweight", (1.2, 0.45, 0.62),
(0, 0.915, 0.6), yellow))
# Steering column: runs from the hood top up to just under the
# wheel hub. NOTE the mesh coordinates: join_into() bakes the
# first part's (body-front) location (0, -0.45, 0.55) in as an
# origin offset, so a blender point (X, Y, Z) lands at Ogre
# (X, Z - 0.55, -(Y + 0.45)). VehicleComponent::steeringWheelOffset
# (0, 0.75, -0.3) is therefore blender (0, -0.15, 1.30); the column
# leans 25 deg toward the driver (+Y), matching the wheel tilt.
parts.append(add_cylinder("steering-column", 0.03, 0.65,
(0, -0.30, 0.98), steel,
rotation=(radians(-25.0), 0, 0)))
# Driver seat (shifted 0.1 back from the driver pin so the legs
# dangle into the footwell opening and the gap behind the seat
# closes).
parts.append(add_box("seat-base", (0.55, 0.5, 0.12),
(0, 0.45, 0.86), seat_mat))
parts.append(add_box("seat-back", (0.55, 0.12, 0.5),
(0, 0.68, 1.1), seat_mat))
# Overhead guard: 4 posts + roof (raised so the seated driver's
# head stays below the roof).
for sx in (-0.58, 0.58):
for sy in (-0.15, 0.95):
parts.append(add_box("post", (0.08, 0.08, 1.20),
(sx, sy, 1.395), steel))
parts.append(add_box("roof", (1.28, 1.3, 0.06), (0, 0.4, 2.02),
steel))
# Mast rails at the very front, ahead of the front wheels (wheel
# front edge is Ogre z 1.15; the mast front face lands at Ogre z
# 1.14) so the fork carriage rides in front of them.
for sx in (-0.45, 0.45):
parts.append(add_box("mast", (0.14, 0.12, 1.5),
(sx, -1.53, 0.95), steel))
# Low cross beam at axle height linking the nose to the mast;
# passes between the front wheels (beam x half 0.5 clears the
# wheel inner edges at x +/-0.55).
parts.append(add_box("mast-cross", (1.0, 0.75, 0.12),
(0, -1.275, 0.35), steel))
join_into("forklift", parts)
# Fork carriage + tines as a separate mesh: VehicleSystem attaches
# it as a child node at VehicleComponent::forkOffset (chassis
# space, (0,0,1.17) for this model — that shifts the assembly in
# front of the front wheels) and raises it by the fork height, so
# the geometry is modelled at the fork-down position relative to
# the carriage. join_into() bakes the carriage location
# (0, -1.12, 0.15) in as the origin offset, so a blender point
# (X, Y, Z) of this mesh renders at Ogre (X, Z - 0.15, -(Y + 1.12))
# plus forkOffset.
fork_parts = []
# Carriage plate sliding on the mast.
fork_parts.append(add_box("carriage", (0.85, 0.06, 0.55),
(0, -1.12, 0.15), steel))
# Fork tines reaching forward at floor level: the chassis rides
# ~0.73 above the floor, so the tines are dropped to Ogre y -0.70
# (blender z -0.55) — just above the floor and the resting crate's
# bottom edge. They have no collider; the chassis front face stops
# at the crate.
for sx in (-0.35, 0.35):
fork_parts.append(add_box("fork", (0.16, 0.95, 0.05),
(sx, -1.55, -0.55), steel))
fork_parts.append(add_box("fork-heel", (0.16, 0.06, 0.45),
(sx, -1.12, -0.325), steel))
join_into("forklift-forks", fork_parts)
# Wheel: the wrapper's getWheelWorldTransform assumes a Y-axis
# cylinder (Jolt GetWheelWorldTransform(inWheelRight=Y,
# inWheelUp=X)), so keep the default Blender cylinder axis (Z),
# which exports to Ogre Y.
wheel = add_cylinder("forklift-wheel", 0.3, 0.2, (0, 0, 0), dark)
wheel.data.name = "forklift-wheel"
# Steering wheel: modelled at the origin with the spin axis on
# Blender Z (exports to Ogre Y); VehicleSystem places it at
# VehicleComponent::steeringWheelOffset with a tilt and spins it
# around that local axis with the steering. Rim + hub + 3 spokes.
sw_parts = []
bpy.ops.mesh.primitive_torus_add(major_segments=24,
minor_segments=8,
major_radius=0.19,
minor_radius=0.028,
location=(0, 0, 0))
rim = bpy.context.active_object
rim.name = "sw-rim"
rim.data.materials.append(dark)
sw_parts.append(rim)
sw_parts.append(add_cylinder("sw-hub", 0.04, 0.06, (0, 0, 0), dark))
for ang in (0, 60, 120):
spoke = add_box("sw-spoke", (0.34, 0.03, 0.02), (0, 0, 0), dark)
spoke.rotation_euler = (0, 0, radians(ang))
bpy.ops.object.transform_apply(location=False, rotation=True,
scale=True)
sw_parts.append(spoke)
join_into("forklift-steering-wheel", sw_parts)
save_blend("forklift.blend")
def gen_crate():
"""crate.mesh: 1 m box centred at the origin (matches a 0.5 half
extents box collider), with proud edge slats for a wooden-crate
look."""
reset_scene()
wood = make_material("crate", (0.45, 0.3, 0.12))
wood_dark = make_material("crate-frame", (0.32, 0.2, 0.08))
parts = []
parts.append(add_box("core", (0.96, 0.96, 0.96), (0, 0, 0), wood))
# 12 edge beams.
e = 0.5
b = 0.07
for sx in (-e, e):
for sy in (-e, e):
parts.append(add_box("edge", (b, b, 1.0), (sx, sy, 0),
wood_dark))
for sx in (-e, e):
for sz in (-e, e):
parts.append(add_box("edge", (b, 1.0, b), (sx, 0, sz),
wood_dark))
for sy in (-e, e):
for sz in (-e, e):
parts.append(add_box("edge", (1.0, b, b), (0, sy, sz),
wood_dark))
join_into("crate", parts)
save_blend("crate.blend")
def gen_pad():
"""pad.mesh: flat 1.2 x 1.2 marker slab, 4 cm thick, centred at the
origin. The zone system swaps its material for covered/uncovered
feedback, so keep a single named material."""
reset_scene()
pad_mat = make_material("pad", (0.9, 0.75, 0.1))
parts = []
parts.append(add_box("slab", (1.2, 1.2, 0.04), (0, 0, 0), pad_mat))
join_into("pad", parts)
save_blend("pad.blend")
def gen_wall():
"""wall.mesh: unit 1 x 1 x 1 cube centred at the origin, scaled
per-instance in the scene for yard boundary walls (the buildings
Cube.mesh is not 1-unit, so the demo gets its own)."""
reset_scene()
concrete = make_material("sokoban-wall", (0.55, 0.55, 0.52))
parts = []
parts.append(add_box("wall", (1.0, 1.0, 1.0), (0, 0, 0), concrete))
join_into("wall", parts)
save_blend("wall.blend")
gen_forklift()
gen_crate()
gen_pad()
gen_wall()
+451
View File
@@ -0,0 +1,451 @@
#!/usr/bin/env python
"""Generate the demo-taxi .blend assets (sedan-a, sedan-taxi).
Run once with the project Blender:
blender -b -Y -P assets/blender/scripts/gen_taxi_assets.py
Writes sedan-a.blend and sedan-taxi.blend into assets/blender/vehicles/,
from where the import_vehicles CMake target exports them through
export_vehicles.py like the other vehicles. After editing this script,
re-run the command above and then:
cmake --build build-vscode --target import_vehicles -j4
Axis convention: blender2ogre EX_SWAP_AXIS='xz-y' maps Blender
(x, y, z) -> Ogre (x, z, -y), so Ogre +Z (vehicle forward, Jolt
chassis +Z) is Blender -Y and Ogre +Y (up) is Blender +Z. The cars are
modelled with the chassis origin at the axle-box centre, 0.58 m above
the ground contact plane (Blender z -0.58), matching a physics box
collider of half extents (0.9, 0.35, 2.4) centred at the origin.
Dimensions (Blender coords; front = -Y):
overall length y -2.45..+2.45 (Ogre z +/-2.45, incl. bumpers)
overall width x +/-0.95 (fender flares; doors/sills at +/-0.88)
body bottom z -0.30, except the side skirts (sills and the
front/rear side panels) which reach z -0.49 (9 cm
ground clearance) so the seated character's feet --
the sit pose dangles them below the floor pan --
stay hidden behind the skirt from side views
beltline z +0.32 (0.90 m above ground)
roof z +1.02 (1.60 m above ground -- a chair-sitting
character with feet on the floor pan (butt ~0.45 m
above the feet, head ~+0.85 above the butt) clears
the headliner)
wheels (x +/-0.80, y -/+1.50, z -0.25), r 0.33, w 0.22
Origin baking: unlike gen_sokoban_assets.py (which lets join_into()
bake the first part's location in as an origin offset), here every
primitive gets its location applied to the mesh data right away, so
joined meshes start out in chassis space with a zero origin -- the
body shells (sedan-a-blue/-red/-grey, sedan-taxi) therefore have NO
baked origin offset: mesh vertex (x, y, z) renders at Ogre
(x, z, -y) relative to the chassis origin.
Doors/hood/trunk are modelled at the CLOSED position and then
re-origined with set_origin() onto their hinge line (the object moves
to the hinge, the mesh data becomes hinge-local), so a later phase can
rotate them around the hinge:
<body>-door-fl/-fr origin at the front-bottom hinge edge,
panel spans backward (+Y) and up (+Z)
<body>-door-rl/-rr same, hinge at the door's front-bottom edge
<body>-hood origin at the cowl (rear edge of the hood)
<body>-trunk origin at the cabin-side edge of the trunk lid
The shell meshes carry the door/hood/trunk openings (sills, cowl,
bulkhead, fenders), so shell + parts fit together flush (parts embed
~2 cm into the shell to avoid coincident-face z-fighting).
Part list per body variant (exported mesh data names, referenced by
the scene JSON):
sedan-a.blend: sedan-a-blue, sedan-a-red, sedan-a-grey
+ <name>-door-fl/-fr/-rl/-rr, <name>-hood,
<name>-trunk for each
+ sedan-wheel, sedan-steering-wheel (shared)
sedan-taxi.blend: sedan-taxi
+ sedan-taxi-door-fl/-fr/-rl/-rr,
sedan-taxi-hood, sedan-taxi-trunk
(reuses sedan-wheel/-steering-wheel from
sedan-a.blend -- do NOT duplicate them here)
The sedan-wheel cylinder axis is Blender Z (exports to Ogre Y), like
the forklift's: the wrapper's getWheelWorldTransform assumes a Y-axis
cylinder. The steering wheel (rim + hub + 3 spokes at the origin)
also spins around Blender Z. Doors on Blender +X are the Ogre-left
(-fl/-rl) side (facing Ogre +Z, +X is left).
Materials are plain named Principled-BSDF materials; blender2ogre
writes one .material file per material (EX_SEPARATE_MATERIALS).
sedan-taxi.blend re-defines the shared ones (sedan-glass, sedan-trim,
...) with identical values, which simply re-writes the same files.
"""
import os
import bpy
from math import radians
from mathutils import Vector, Matrix
OUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"..", "vehicles")
# Shared hardpoints (see the file docstring).
Z_BOT = -0.30 # body bottom edge (ground contact at -0.58)
BELT = 0.32 # beltline: solid body below, glasshouse above
ROOF = 1.02 # roof exterior (1.60 m above ground -- a chair-sitting
# character (feet on the floor pan, head ~0.85 above
# the cushion) fits under the headliner)
WHEEL_X = 0.80
WHEEL_Y = 1.50 # front axle at Blender y -1.50 (Ogre z +1.50)
WHEEL_Z = -0.25
WHEEL_R = 0.33
WHEEL_W = 0.22
SIDE_X = 0.88 # door/sill outer face
FENDER_X = 0.95 # fender flare outer face
# Door openings (y extents) and hinge positions.
DOOR_F = (-0.55, 0.44) # front door, hinge at y -0.55 (front-bottom)
DOOR_R = (0.46, 1.12) # rear door, hinge at y 0.46 (front-bottom)
DOOR_Z = (-0.10, BELT) # panel z extent (bottom embeds into the sill)
# Silhouette parameters per body type.
TYPE_A = dict(hood_top=0.20, nose_top=0.14, tail_top=0.30, trunk_top=0.30,
glass=(0.78, -0.70, 1.30, 0.60, -0.42, 1.02),
stripe=False, sign=False)
TYPE_B = dict(hood_top=0.22, nose_top=0.16, tail_top=0.34, trunk_top=0.34,
glass=(0.82, -0.78, 1.32, 0.70, -0.55, 1.10),
stripe=True, sign=True)
def reset_scene():
bpy.ops.wm.read_homefile(use_empty=True)
def make_material(name, color, roughness=0.8, metallic=0.0):
mat = bpy.data.materials.new(name)
mat.use_nodes = True
bsdf = mat.node_tree.nodes["Principled BSDF"]
bsdf.inputs["Base Color"].default_value = (*color, 1.0)
bsdf.inputs["Roughness"].default_value = roughness
bsdf.inputs["Metallic"].default_value = metallic
return mat
def add_box(name, dims, loc, mat=None):
"""Add a box at a world-space location; the location is applied to
the mesh data immediately so the object location stays zero (no
hidden origin offset for join_into()/the exporter)."""
bpy.ops.mesh.primitive_cube_add(size=1.0, location=loc)
obj = bpy.context.active_object
obj.name = name
obj.dimensions = dims
bpy.ops.object.transform_apply(location=True, rotation=False,
scale=True)
if mat:
obj.data.materials.append(mat)
return obj
def add_cylinder(name, radius, depth, loc, mat=None, rotation=None,
vertices=24):
bpy.ops.mesh.primitive_cylinder_add(vertices=vertices, radius=radius,
depth=depth, location=loc)
obj = bpy.context.active_object
obj.name = name
if rotation:
obj.rotation_euler = rotation
bpy.ops.object.transform_apply(location=True, rotation=True,
scale=True)
if mat:
obj.data.materials.append(mat)
return obj
def add_frustum(name, bx, by0, by1, tx, ty0, ty1, z0, z1, mat=None):
"""Tapered box (truncated pyramid) between z0 (bottom rect half
width bx, y by0..by1) and z1 (top rect tx, ty0..ty1) -- used for
the glasshouse. Mesh data is in world space, object at origin."""
verts = [(-bx, by0, z0), (bx, by0, z0), (bx, by1, z0), (-bx, by1, z0),
(-tx, ty0, z1), (tx, ty0, z1), (tx, ty1, z1), (-tx, ty1, z1)]
# Winding gives outward normals (bottom down, top up, sides out).
faces = [(0, 3, 2, 1), (4, 5, 6, 7),
(0, 1, 5, 4), (1, 2, 6, 5), (2, 3, 7, 6), (3, 0, 4, 7)]
mesh = bpy.data.meshes.new(name)
mesh.from_pydata(verts, [], faces)
mesh.update()
obj = bpy.data.objects.new(name, mesh)
bpy.context.collection.objects.link(obj)
if mat:
mesh.materials.append(mat)
return obj
def join_into(name, objects):
bpy.ops.object.select_all(action='DESELECT')
for obj in objects:
obj.select_set(True)
bpy.context.view_layer.objects.active = objects[0]
bpy.ops.object.join()
obj = bpy.context.active_object
obj.name = name
obj.data.name = name # the exporter names .mesh files after mesh data
return obj
def set_origin(obj, point):
"""Move the mesh data origin to a world-space hinge point: verts
become hinge-local, the object moves to the hinge (closed pose
unchanged). Exported .mesh vertices are hinge-local because the
exporter does not apply object locations."""
obj.data.transform(Matrix.Translation(Vector((-point[0], -point[1],
-point[2]))))
obj.location = point
def save_blend(filename):
path = os.path.join(OUT_DIR, filename)
bpy.ops.wm.save_as_mainfile(filepath=path)
print("wrote " + path)
def make_shared_materials():
"""Materials shared by both .blends (identical definitions)."""
return {
'glass': make_material("sedan-glass", (0.04, 0.06, 0.08),
roughness=0.15, metallic=0.3),
'trim': make_material("sedan-trim", (0.10, 0.10, 0.11)),
'interior': make_material("sedan-interior", (0.16, 0.14, 0.12)),
'light': make_material("sedan-light", (0.95, 0.92, 0.75),
roughness=0.3),
'taillight': make_material("sedan-taillight", (0.55, 0.05, 0.05),
roughness=0.3),
}
def build_shell(prefix, paint, mats, cfg):
"""Body shell with the door/hood/trunk openings: floor pan, sills,
cowl, engine bay, nose, fenders, bulkhead, trunk well, tail,
bumpers, lights, glasshouse + roof cap (plus taxi sign/stripes for
type B). Exported with a zero origin (chassis space)."""
glass, trim, interior = mats['glass'], mats['trim'], mats['interior']
ht, nt = cfg['hood_top'], cfg['nose_top']
tt, kt = cfg['tail_top'], cfg['trunk_top']
parts = []
# Floor pan and rockers (sills carry the door bottoms). The sill
# reaches down to z -0.49 (side skirt, see the file docstring).
parts.append(add_box("floor", (1.30, 2.10, 0.18), (0, 0.30, -0.21),
trim))
for sx in (-0.765, 0.765):
parts.append(add_box("sill", (0.23, 1.80, 0.41),
(sx, 0.35, -0.285), paint))
# Cowl/dash panel between hood and glasshouse (front door hinges
# sit on its lower corners).
parts.append(add_box("cowl", (1.76, 0.22, 0.62), (0, -0.64, 0.01),
paint))
# Engine bay block under the hood (dark, seen through panel gaps).
parts.append(add_box("bay", (1.30, 1.42, 0.40), (0, -1.44, -0.10),
trim))
# Nose: grille block + bumper + headlights.
parts.append(add_box("nose", (1.70, 0.19, nt + 0.30),
(0, -2.225, nt / 2 - 0.15), paint))
parts.append(add_box("bumper-f", (1.80, 0.13, 0.24),
(0, -2.385, -0.17), trim))
parts.append(add_box("grille", (0.90, 0.06, 0.14),
(0, -2.30, nt - 0.16), trim))
for sx in (-0.58, 0.58):
parts.append(add_box("headlight", (0.35, 0.06, 0.12),
(sx, -2.30, nt - 0.10), mats['light']))
# Front side panels ahead of and behind the front wheel arch
# (arch opening y -1.88..-1.10, fender over the top); both reach
# down to z -0.49 (side skirt, see the file docstring).
for sx in (-0.765, 0.765):
parts.append(add_box("fside-f", (0.23, 0.27, nt + 0.49),
(sx, -2.015, nt / 2 - 0.245), paint))
parts.append(add_box("fside-r", (0.23, 0.37, 0.81),
(sx, -0.915, -0.085), paint))
for sx in (-0.80, 0.80):
parts.append(add_box("fender-f", (0.30, 0.78, ht - 0.05),
(sx, -1.49, ht / 2 + 0.035), paint))
# Interior: dashboard, front seats, rear bench (seen through the
# glasshouse).
parts.append(add_box("dash", (1.30, 0.20, 0.18), (0, -0.45, 0.20),
interior))
for sx in (-0.35, 0.35):
parts.append(add_box("seat-f", (0.50, 0.45, 0.30),
(sx, 0.0, 0.03), interior))
parts.append(add_box("seat-f-back", (0.50, 0.14, 0.55),
(sx, 0.26, 0.20), interior))
parts.append(add_box("bench", (1.30, 0.45, 0.28), (0, 0.85, 0.02),
interior))
parts.append(add_box("bench-back", (1.30, 0.16, 0.50),
(0, 1.10, 0.20), interior))
# Rear bulkhead (trunk hinges on its top), trunk well, tail,
# rear fenders, bumper, taillights.
parts.append(add_box("bulkhead", (1.76, 0.22, 0.62), (0, 1.25, 0.01),
paint))
parts.append(add_box("well", (1.30, 0.80, 0.40), (0, 1.75, -0.10),
trim))
for sx in (-0.80, 0.80):
parts.append(add_box("fender-r", (0.30, 0.78, tt - 0.05),
(sx, 1.49, tt / 2 + 0.035), paint))
for sx in (-0.765, 0.765):
parts.append(add_box("rside", (0.23, 0.27, tt + 0.51),
(sx, 2.015, tt / 2 - 0.235), paint))
parts.append(add_box("tail", (1.70, 0.19, tt + 0.30),
(0, 2.205, tt / 2 - 0.15), paint))
parts.append(add_box("bumper-r", (1.80, 0.15, 0.24),
(0, 2.375, -0.17), trim))
for sx in (-0.58, 0.58):
parts.append(add_box("taillight", (0.35, 0.06, 0.12),
(sx, 2.30, tt - 0.18), mats['taillight']))
# Glasshouse: tapered glass box from the beltline to just under
# the roof, body-colour roof cap on top.
bx, by0, by1, tx, ty0, ty1 = cfg['glass']
parts.append(add_frustum("glasshouse", bx, by0, by1, tx, ty0, ty1,
BELT, ROOF - 0.05, glass))
parts.append(add_box("roof", (2 * tx + 0.08, ty1 - ty0 + 0.08, 0.05),
(0, (ty0 + ty1) / 2, ROOF - 0.025), paint))
if cfg['stripe']:
stripe = mats['stripe']
# Colour-blocked dark stripe band (z 0.02..0.14) along the
# shell sides; the door segments live in the door meshes and
# the band breaks over the wheel arches.
for sx in (-0.895, 0.895):
parts.append(add_box("stripe-f", (0.03, 0.25, 0.12),
(sx, -2.015, 0.08), stripe))
parts.append(add_box("stripe-b", (0.03, 0.20, 0.12),
(sx, 1.25, 0.08), stripe))
parts.append(add_box("stripe-r", (0.03, 0.25, 0.12),
(sx, 2.015, 0.08), stripe))
if cfg['sign']:
parts.append(add_box("sign-base", (0.60, 0.34, 0.04),
(0, 0.30, ROOF + 0.02), trim))
parts.append(add_box("sign", (0.55, 0.28, 0.16),
(0, 0.30, ROOF + 0.11), mats['sign']))
return join_into(prefix, parts)
def build_door(name, paint, mats, cfg, front, left):
"""One door: panel + handle (+ taxi stripe), painted to match the
body, origin on the front-bottom hinge edge."""
(y0, y1) = DOOR_F if front else DOOR_R
(z0, z1) = DOOR_Z
sx = SIDE_X - 0.025 if left else -(SIDE_X - 0.025)
length = y1 - y0
cy = (y0 + y1) / 2
out = SIDE_X + 0.015 if left else -(SIDE_X + 0.015)
parts = [add_box("panel", (0.05, length, z1 - z0),
(sx, cy, (z0 + z1) / 2), paint)]
parts.append(add_box("handle", (0.03, 0.15, 0.03),
(out, y1 - 0.18, BELT - 0.08), mats['trim']))
if cfg['stripe']:
parts.append(add_box("stripe", (0.03, length - 0.04, 0.12),
(out, cy, 0.08), mats['stripe']))
obj = join_into(name, parts)
set_origin(obj, (sx, y0, z0))
return obj
def build_hood(name, paint, cfg):
"""Hood slab, origin at the cowl (rear edge of the hood)."""
ht = cfg['hood_top']
parts = [add_box("hood", (1.60, 1.44, 0.06), (0, -1.44, ht - 0.03),
paint)]
obj = join_into(name, parts)
set_origin(obj, (0, -0.75, ht - 0.03))
return obj
def build_trunk(name, paint, cfg):
"""Trunk lid, origin at its cabin-side edge (near the back
glass)."""
kt = cfg['trunk_top']
parts = [add_box("trunk", (1.60, 0.84, 0.06), (0, 1.74, kt - 0.03),
paint)]
obj = join_into(name, parts)
set_origin(obj, (0, 1.35, kt - 0.03))
return obj
def build_body(prefix, paint, mats, cfg):
"""One full car: shell + 4 doors + hood + trunk (7 meshes)."""
build_shell(prefix, paint, mats, cfg)
for front, tag_f in ((True, 'f'), (False, 'r')):
for left, tag_s in ((True, 'l'), (False, 'r')):
build_door("%s-door-%s%s" % (prefix, tag_f, tag_s),
paint, mats, cfg, front, left)
build_hood(prefix + "-hood", paint, cfg)
build_trunk(prefix + "-trunk", paint, cfg)
def build_wheel():
"""sedan-wheel: dark tire + lighter hub as one mesh (two
submeshes). Cylinder axis stays on Blender Z so it exports to
Ogre Y, like the forklift wheel."""
tire = make_material("sedan-tire", (0.07, 0.07, 0.07), roughness=0.95)
hub = make_material("sedan-hub", (0.55, 0.55, 0.58), roughness=0.4,
metallic=0.6)
parts = [add_cylinder("tire", WHEEL_R, WHEEL_W, (0, 0, 0), tire,
vertices=16),
add_cylinder("hub", 0.17, WHEEL_W + 0.02, (0, 0, 0), hub,
vertices=12)]
join_into("sedan-wheel", parts)
def build_steering_wheel():
"""sedan-steering-wheel: rim + hub + 3 spokes at the origin, spin
axis Blender Z (exports to Ogre Y), like the forklift's."""
dark = make_material("sedan-steering", (0.08, 0.08, 0.09))
parts = []
bpy.ops.mesh.primitive_torus_add(major_segments=24, minor_segments=8,
major_radius=0.17,
minor_radius=0.025,
location=(0, 0, 0))
rim = bpy.context.active_object
rim.name = "sw-rim"
rim.data.materials.append(dark)
parts.append(rim)
parts.append(add_cylinder("sw-hub", 0.035, 0.06, (0, 0, 0), dark))
for ang in (0, 60, 120):
spoke = add_box("sw-spoke", (0.30, 0.025, 0.02), (0, 0, 0), dark)
spoke.rotation_euler = (0, 0, radians(ang))
bpy.ops.object.transform_apply(location=False, rotation=True,
scale=True)
parts.append(spoke)
join_into("sedan-steering-wheel", parts)
def gen_sedan_a():
"""sedan-a.blend: type-A body (three-box sedan after Car2.blend)
in three colours, plus the shared wheel and steering wheel."""
reset_scene()
mats = make_shared_materials()
blue = make_material("sedan-a-blue", (0.08, 0.18, 0.65),
roughness=0.35, metallic=0.2)
red = make_material("sedan-a-red", (0.62, 0.07, 0.07),
roughness=0.35, metallic=0.2)
grey = make_material("sedan-a-grey", (0.45, 0.46, 0.48),
roughness=0.35, metallic=0.2)
build_body("sedan-a-blue", blue, mats, TYPE_A)
build_body("sedan-a-red", red, mats, TYPE_A)
build_body("sedan-a-grey", grey, mats, TYPE_A)
build_wheel()
build_steering_wheel()
save_blend("sedan-a.blend")
def gen_sedan_taxi():
"""sedan-taxi.blend: type-B body (boxier, upright glasshouse after
Car5_Taxi.blend) in taxi yellow with a roof sign and a dark
checker-stripe band. Reuses sedan-wheel/-steering-wheel from
sedan-a.blend."""
reset_scene()
mats = make_shared_materials()
mats['stripe'] = make_material("sedan-taxi-dark", (0.08, 0.08, 0.08))
mats['sign'] = make_material("taxi-sign", (0.95, 0.85, 0.40),
roughness=0.4)
yellow = make_material("sedan-taxi-yellow", (0.85, 0.60, 0.05),
roughness=0.35, metallic=0.2)
build_body("sedan-taxi", yellow, mats, TYPE_B)
save_blend("sedan-taxi.blend")
gen_sedan_a()
gen_sedan_taxi()
+830
View File
@@ -0,0 +1,830 @@
#!/usr/bin/env python
#
# Vehicle pipeline generator for convention-built vehicle blends.
#
# Does everything export_vehicles.py does (blender2ogre .scene/.mesh/
# .material export + a .glb preview) and additionally derives a
# ready-to-merge scene-entity JSON fragment (<name>.vehicle.json) from
# the blend by naming convention:
#
# root the single parentless MESH object; its mesh DATA frame
# is the chassis frame (object location is NOT baked into
# the .mesh, so author the mesh data relative to the
# intended chassis origin).
# * Tire wheel objects; mesh data is rotated so the axle lands
# on Blender Z (Ogre Y, the convention
# getWheelWorldTransform assumes); the objects are
# counter-rotated so the scene look is unchanged.
# * Door door child entities, hinge-local mesh data (origin on
# the hinge line); each gets a seat actuator for the
# nearest seat.
# * hood hood child entity.
# * trunk trunk child entity.
# * Steering steering wheel; mesh data is rotated so the spin axis
# lands on Blender Z (Ogre Y, the convention VehicleSystem
# assumes for steeringWheelMeshName).
# seat_* EMPTY objects marking character seat pins (character
# root with the sitting-vehicle animation; no offsets
# applied). The one with "driver" in the name becomes
# the driver seat.
#
# The emitted JSON has Ogre coordinates ((x, z, -y) of the Blender
# chassis frame). See assets/blender/vehicles/CAR_MODELING.md.
#
# Usage:
# blender <vehicle>.blend -b -Y -P gen_vehicle.py -- <out>.glb \
# <out>.vehicle.json [downforce]
#
# downforce (optional, default 25): aerodynamic downforce coefficient
# (N per (m/s)^2, straight down at the COM while moving) — the
# speed-dependent grip lever: at speed it multiplies the tire normal
# load (and thus the lateral impulse budget) without adding inertia,
# which is exactly where plain tires start understeering. Higher =
# tighter turns at speed.
import os
import sys
import json
import math
import bpy
from math import radians
from mathutils import Matrix, Vector
argv = sys.argv
argv = argv[argv.index("--") + 1:]
incpath = os.path.dirname(__file__)
sys.path.insert(0, incpath)
sys.path.insert(1, incpath + "/blender2ogre")
import io_ogre
try:
io_ogre.register()
except:
pass
glb_file = argv[0]
json_file = argv[1]
downforce = float(argv[2]) if len(argv) > 2 else 25.0
outdir = os.path.dirname(glb_file)
base = os.path.splitext(os.path.basename(bpy.data.filepath))[0]
print("gen_vehicle: %s -> %s, %s" % (bpy.data.filepath, glb_file,
json_file))
for obj in list(bpy.data.objects):
if obj.name.endswith("-col"):
bpy.data.objects.remove(obj)
# GLB preview of the untouched scene (as modeled).
bpy.ops.export_scene.gltf(filepath=glb_file,
use_selection=False,
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=False,
export_animations=False,
export_morph=True,
export_morph_animation=False,
export_morph_normal=True,
export_morph_tangent=True,
export_lights=False,
export_skins=True)
def chassis_pos(obj, root_inv):
"""Object origin in the chassis frame (root mesh-data frame)."""
return root_inv @ obj.matrix_world.translation
def mesh_bbox(me):
vs = [v.co for v in me.vertices]
mn = Vector((min(v.x for v in vs), min(v.y for v in vs),
min(v.z for v in vs)))
mx = Vector((max(v.x for v in vs), max(v.y for v in vs),
max(v.z for v in vs)))
return mn, mx
def ogre(v):
"""Blender chassis frame -> Ogre (x, z, -y)."""
return (round(v.x, 4), round(v.z, 4), round(-v.y, 4))
def ogre_vec3(v):
x, y, z = ogre(v)
return {"x": x, "y": y, "z": z}
# --- find the root (the single parentless MESH object) ---
roots = [o for o in bpy.data.objects
if o.type == 'MESH' and o.parent is None]
if len(roots) != 1:
raise RuntimeError("gen_vehicle: expected exactly one parentless "
"MESH object (the body shell), found %d: %s" %
(len(roots), [o.name for o in roots]))
root = roots[0]
root_inv = root.matrix_world.inverted()
print("gen_vehicle: root '%s' (mesh data '%s')" %
(root.name, root.data.name))
# --- classify parts by naming convention ---
wheels = []
doors = []
seats = []
hood = None
trunk = None
steering = None
extras = []
for obj in bpy.data.objects:
if obj == root:
continue
lname = obj.name.lower()
if obj.type == 'EMPTY':
if lname.startswith("seat"):
seats.append(obj)
continue
if obj.type != 'MESH':
continue
if "tire" in lname or "wheel" in lname:
wheels.append(obj)
elif "door" in lname:
doors.append(obj)
elif "hood" in lname or "bonnet" in lname:
hood = obj
elif "trunk" in lname:
trunk = obj
elif "steering" in lname:
steering = obj
else:
extras.append(obj)
if len(wheels) < 2:
raise RuntimeError("gen_vehicle: no wheel objects found "
"(name them '* Tire')")
if not seats:
raise RuntimeError("gen_vehicle: no seat empties found "
"(name them 'seat_*')")
print("gen_vehicle: %d wheels, %d doors, %d seats, hood=%s, trunk=%s, "
"steering=%s, extras=%s" %
(len(wheels), len(doors), len(seats),
hood.name if hood else "-", trunk.name if trunk else "-",
steering.name if steering else "-",
[o.name for o in extras]))
# --- wheel geometry (from the shared tire mesh data, pre-rotation) ---
tyme = wheels[0].data
wmn, wmx = mesh_bbox(tyme)
wheel_radius = max(wmx.y - wmn.y, wmx.z - wmn.z) / 2.0
wheel_width = wmx.x - wmn.x
wheel_info = []
for obj in wheels:
p = chassis_pos(obj, root_inv)
wheel_info.append({"obj": obj, "pos": p})
axle_mid_y = (min(w["pos"].y for w in wheel_info) +
max(w["pos"].y for w in wheel_info)) / 2.0
# --- seats ---
seat_info = []
for obj in seats:
p = chassis_pos(obj, root_inv)
seat_info.append({"obj": obj, "pos": p,
"front": p.y < axle_mid_y,
"left": p.x > 0.0,
"driver": "driver" in obj.name.lower()})
driver_seats = [s for s in seat_info if s["driver"]]
if len(driver_seats) != 1:
raise RuntimeError("gen_vehicle: expected exactly one seat empty "
"with 'driver' in the name, found %d" %
len(driver_seats))
for s in seat_info:
if s["driver"]:
s["name"] = "driver"
elif s["front"]:
s["name"] = "passenger"
else:
s["name"] = "rl" if s["left"] else "rr"
# --- rename mesh data blocks to the <base>-<role> scheme ---
def part_side_row(p):
return ("f" if p.y < axle_mid_y else "r") + \
("l" if p.x > 0.0 else "r")
def sanitize(name):
return name.lower().replace(" ", "-").replace("_", "-")
root.data.name = base
for w in wheel_info:
w["obj"].data.name = base + "-wheel"
for d in doors:
p = chassis_pos(d, root_inv)
d.data.name = base + "-door-" + part_side_row(p)
if hood:
hood.data.name = base + "-hood"
if trunk:
trunk.data.name = base + "-trunk"
if steering:
steering.data.name = base + "-steering-wheel"
for e in extras:
e.data.name = base + "-" + sanitize(e.data.name)
# --- rename materials (<base>-<material>) and unpack textures ---
used_mats = set()
for obj in bpy.data.objects:
if obj.type == 'MESH':
for m in obj.data.materials:
if m:
used_mats.add(m)
for m in used_mats:
if not m.name.lower().startswith(base.lower()):
m.name = base + "-" + sanitize(m.name)
tex_index = 0
saved_images = set()
for m in used_mats:
if not m.node_tree:
continue
for n in m.node_tree.nodes:
if n.type != 'TEX_IMAGE' or not n.image:
continue
img = n.image
if img in saved_images or not img.packed_file:
continue
saved_images.add(img)
tex_name = base + ("-atlas.png" if tex_index == 0 else
"-tex%d.png" % tex_index)
tex_index += 1
img.filepath_raw = os.path.join(outdir, tex_name)
img.file_format = 'PNG'
img.save()
print("gen_vehicle: unpacked texture '%s' -> %s" %
(img.name, tex_name))
# --- merged paint material + separate glass material (CAR_MODELING.md) ---
# Every mesh part is re-materialized onto TWO materials: <base> for
# paint/bloom faces and <base>-glass for glass faces, so blender2ogre
# emits one submesh per part (glass-less parts) or two (glass last).
# A single merged transparent submesh forced the WHOLE part (interior
# included) into the transparent render queue, where the depth-writing
# window glass culled interior parts that sorted behind it ("holes" in
# the cabin with the road showing through). The ID map texture shares
# the color atlas UV space (all parts MUST use that one UV space with
# no texel shared between materials; fix the blend if not) and marks
# each fragment: red = paint (atlas color), green = bloom (lights),
# blue = glass.
IDMAP_SIZE = 512
def idmap_color(mat):
ln = mat.name.lower() if mat else ""
if "glass" in ln:
return (0, 0, 255)
if "bloom" in ln:
return (0, 255, 0)
return (255, 0, 0)
def bake_idmap(mesh_datas, path):
"""Paint per-face material IDs into a texture in UV space.
The car UVs are degenerate: every polygon collapses onto a single
color-atlas texel (the atlas is a palette, not a surface unwrap),
so the ID map only needs that texel plus a small margin painted.
A texel shared by two different materials is unresolvable and is
warned about (fix the blend: give one of them its own texel)."""
size = IDMAP_SIZE
buf = bytearray(size * size * 3)
owner = {}
warned = set()
def paint(u, v, col, matname):
u = u - math.floor(u)
v = v - math.floor(v)
cx, cy = int(u * (size - 1)), int(v * (size - 1))
for dy in range(-2, 3):
for dx in range(-2, 3):
if dx * dx + dy * dy > 5:
continue
px, py = cx + dx, cy + dy
if not (0 <= px < size and 0 <= py < size):
continue
idx = py * size + px
prev = owner.get(idx)
if prev is not None and prev != matname:
pair = (prev, matname)
if pair not in warned:
warned.add(pair)
print("gen_vehicle: WARNING - UV texel (%d,%d) "
"shared by materials '%s' and '%s'; the "
"ID map cannot tell them apart (fix the "
"blend)" % (px, py, prev, matname))
continue
owner[idx] = matname
buf[idx * 3] = col[0]
buf[idx * 3 + 1] = col[1]
buf[idx * 3 + 2] = col[2]
for me in mesh_datas:
uv_layer = me.uv_layers.active
if not uv_layer:
continue
uv = uv_layer.data
for poly in me.polygons:
mat = (me.materials[poly.material_index]
if poly.material_index < len(me.materials) else None)
puv = uv[poly.loop_indices[0]].uv
paint(puv.x, puv.y, idmap_color(mat),
mat.name if mat else "<none>")
img = bpy.data.images.new(
os.path.splitext(os.path.basename(path))[0],
width=size, height=size, alpha=False)
px = [0.0] * (size * size * 4)
for i in range(size * size):
px[i * 4] = buf[i * 3] / 255.0
px[i * 4 + 1] = buf[i * 3 + 1] / 255.0
px[i * 4 + 2] = buf[i * 3 + 2] / 255.0
px[i * 4 + 3] = 1.0
img.pixels = px
img.filepath_raw = path
img.file_format = 'PNG'
img.save()
bpy.data.images.remove(img)
print("gen_vehicle: wrote ID map %s (%d texels)" %
(path, len(owner)))
def move_glass_faces_last():
"""Re-order each mesh so glass faces export last. With glass on
its own material slot (see the consolidation below) blender2ogre
groups faces into submeshes by material anyway; the sort just
keeps the glass submesh's triangles contiguous at the end."""
for obj in bpy.data.objects:
if obj.type != 'MESH':
continue
me = obj.data
glass_slots = [i for i, m in enumerate(me.materials)
if m and "glass" in m.name.lower()]
if not glass_slots:
continue
order = [i for i in range(len(me.materials))
if i not in glass_slots] + glass_slots
mats = [me.materials[i] for i in order]
remap = dict((old, new) for new, old in enumerate(order))
# materials.clear() zeroes every polygon's material_index, so
# save them first
saved = [p.material_index for p in me.polygons]
me.materials.clear()
for m in mats:
me.materials.append(m)
for poly, old_idx in zip(me.polygons, saved):
poly.material_index = remap[old_idx]
bpy.ops.object.select_all(action='DESELECT')
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.mesh.sort_elements(type='MATERIAL', elements={'FACE'})
bpy.ops.object.mode_set(mode='OBJECT')
obj.select_set(False)
print("gen_vehicle: sorted glass faces last on '%s'" % me.name)
all_mesh_datas = []
for obj in bpy.data.objects:
if obj.type == 'MESH' and obj.data not in all_mesh_datas:
all_mesh_datas.append(obj.data)
move_glass_faces_last()
bake_idmap(all_mesh_datas, os.path.join(outdir, base + "-idmap.png"))
merged_mat = bpy.data.materials.get(base)
if merged_mat is None:
merged_mat = bpy.data.materials.new(base)
merged_mat.use_nodes = True
# The blend already HAS a "<base>-glass" material (source materials are
# renamed to <base>-<material>); reuse it — materials.new() would
# silently create "<base>-glass.001", and the exported submesh material
# name must match the .material template exactly.
glass_mat = bpy.data.materials.get(base + "-glass")
if glass_mat is None:
glass_mat = bpy.data.materials.new(base + "-glass")
glass_mat.use_nodes = True
for me in all_mesh_datas:
# materials.clear() zeroes every polygon's material_index, so
# classify faces against the original slots first.
glass_face = [
p.material_index < len(me.materials) and
me.materials[p.material_index] is not None and
"glass" in me.materials[p.material_index].name.lower()
for p in me.polygons
]
me.materials.clear()
me.materials.append(merged_mat)
if any(glass_face):
me.materials.append(glass_mat)
for p, g in zip(me.polygons, glass_face):
p.material_index = 1 if g else 0
print("gen_vehicle: consolidated %d mesh data blocks onto materials "
"'%s'/'%s-glass'" % (len(all_mesh_datas), base, base))
# --- JSON fragment (Ogre coordinates) ---
smn, smx = mesh_bbox(root.data)
collider = {
"halfHeight": 1.0,
"meshName": "",
"offset": ogre_vec3(Vector(((smx.x + smn.x) / 2.0,
(smx.y + smn.y) / 2.0,
(smx.z + smn.z) / 2.0))),
"parameters": {
"x": round((smx.x - smn.x) / 2.0, 4),
"y": round((smx.z - smn.z) / 2.0, 4),
"z": round((smx.y - smn.y) / 2.0, 4),
},
"radius": 0.5,
"rotationOffset": {"w": 1.0, "x": 0.0, "y": 0.0, "z": 0.0},
"shapeType": "box",
}
json_wheels = []
# Jolt's suspension spring settles at L_rest = maxLength - g/(2*pi*f)^2
# below the attach point. The modeled wheel centers are the intended
# REST position, so place the attach points L_rest above them — the car
# then rides exactly at the modeled stance.
SUSP_MIN = 0.1
SUSP_MAX = 0.35
# Softer than the 1.8/0.7 starting point: gives the body some slack
# over bumps instead of pogo-stiff wheels (which also cost grip).
SUSP_FREQ = 1.3
# Critical-ish damping: less and the body rocks for seconds after a
# bump (the "almost endlessly bouncy" report).
SUSP_DAMP = 1.0
SUSP_REST = SUSP_MAX - 9.81 / (2.0 * math.pi * SUSP_FREQ) ** 2
for w in sorted(wheel_info, key=lambda w: w["pos"].y):
front = w["pos"].y < axle_mid_y
attach = Vector((w["pos"].x, w["pos"].y, w["pos"].z + SUSP_REST))
json_wheels.append({
"driven": not front,
"maxHandBrakeTorque": 0.0 if front else 300.0,
"maxSteerAngleDeg": 35.0 if front else 0.0,
"position": ogre_vec3(attach),
"radius": round(wheel_radius, 4),
"suspensionDamping": SUSP_DAMP,
"suspensionFrequency": SUSP_FREQ,
"suspensionMaxLength": SUSP_MAX,
"suspensionMinLength": SUSP_MIN,
"width": round(wheel_width, 4),
})
json_seats = []
for s in seat_info:
json_seats.append({
"name": s["name"],
"offset": ogre_vec3(s["pos"]),
"driver": s["driver"],
})
# Center of mass: a box collider puts the COM at the box center — for
# a sedan that is ~1 m above the tire contact patches (twice the real
# value) and the car trips onto its side in hard corners (the taxi,
# with its taller box, tips even easier). Shift the COM to just above
# wheel-hub height instead (Jolt OffsetCenterOfMassShape: only the
# physics COM moves — geometry, wheel attachments and the render node
# stay put).
collider_center_ogre_y = (smx.z + smn.z) / 2.0
hub_ogre_y = sum(w["pos"].z for w in wheel_info) / len(wheel_info)
com_offset_y = round(hub_ogre_y + 0.1 - collider_center_ogre_y, 4)
driver_offset = ogre_vec3(driver_seats[0]["pos"])
children = []
def make_child(name, mesh, pos, actuator_action=None):
child = {
"children": [],
"name": {"name": name},
"renderable": {"meshName": mesh + ".mesh", "visible": True},
"transform": {
"position": ogre_vec3(pos),
"rotation": {"w": 1.0, "x": 0.0, "y": 0.0, "z": 0.0},
"scale": {"x": 1.0, "y": 1.0, "z": 1.0},
},
}
if actuator_action:
child["actuator"] = {
"actionNames": [actuator_action],
"height": 2.0,
"radius": 1.2,
}
return child
def nearest_seat_name(p):
best = None
best_dist = 1e9
for s in seat_info:
d = (s["pos"] - p).length
if d < best_dist:
best_dist = d
best = s["name"]
return best
def seat_for_door(p):
"""The seat behind a door: same side and row (front/rear)."""
front = p.y < axle_mid_y
left = p.x > 0.0
for s in seat_info:
if s["front"] == front and s["left"] == left:
return s["name"]
return nearest_seat_name(p)
for d in doors:
p = chassis_pos(d, root_inv)
seat_name = seat_for_door(p)
children.append(make_child(
base + "_door_" + part_side_row(p), d.data.name, p,
"vehicle_seat_" + seat_name))
if hood:
children.append(make_child(base + "_hood", hood.data.name,
chassis_pos(hood, root_inv)))
if trunk:
children.append(make_child(base + "_trunk", trunk.data.name,
chassis_pos(trunk, root_inv)))
vehicle = {
"cameraDistance": 6.0,
"cameraHeight": 1.6,
# Cars coast when the accelerator is released (braking is the
# driver's job: S while rolling forward, Space = handbrake).
# Slow yard vehicles like the forklift use ~0.5 instead so they
# do not roll forever.
"engineBrake": 0.0,
"maxPitchRollAngleDeg": 60.0,
"maxTorque": 300.0,
"rearSteer": False,
"seatOffset": driver_offset,
"steeringRatio": 4.0,
"steeringWheelMeshName": "",
"steeringWheelOffset": {"x": 0.0, "y": 0.0, "z": 0.0},
"steeringWheelTiltDeg": 25.0,
"wheelMeshName": base + "-wheel.mesh",
"wheels": json_wheels,
"seats": json_seats,
"seatCharacterScale": 1.0,
# Arcade-friendly grip: multipliers over the Jolt default tire
# curves (peak 1.2); 1.0 feels like wet ice at speed (full lock at
# ~15 m/s turns only ~44 deg in 2.5 s; 3.0 lateral turns ~88 deg).
# Note longitudinal grip adds understeer on a RWD car (1.5 costs
# ~16 deg on the same turn), so keep it near stock.
"tireFrictionLongitudinal": 1.3,
"tireFrictionLateral": 3.0,
# Anti-roll bars per axle pair (Jolt VehicleAntiRollBar); with the
# soft 1.3 Hz springs a full-lock turn otherwise rolls the car past
# 45 deg onto two wheels.
"antiRollBarStiffness": 2000.0,
"centerOfMassOffset": {"x": 0.0, "y": com_offset_y, "z": 0.0},
# Aerodynamic downforce (N per (m/s)^2, straight down at the COM
# while moving): presses the car onto its suspension at speed —
# less bounce, and a bigger lateral impulse budget exactly where
# plain tires start understeering (turn radius stops growing with
# speed). Per-model value from the CMake invocation.
"downforceCoefficient": downforce,
}
if steering:
vehicle["steeringWheelMeshName"] = base + "-steering-wheel.mesh"
vehicle["steeringWheelOffset"] = ogre_vec3(
chassis_pos(steering, root_inv))
entity = {
"children": children,
"collider": collider,
"name": {"name": base},
"renderable": {"meshName": base + ".mesh", "visible": True},
"rigidBody": {
"bodyType": "dynamic",
"enabled": True,
"friction": 0.8,
"isSensor": False,
"mass": 1200.0,
"restitution": 0.0,
},
"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},
},
"vehicle": vehicle,
}
with open(json_file, "w") as f:
json.dump({"entities": [entity]}, f, indent=2)
print("gen_vehicle: wrote %s" % json_file)
# --- axis normalization (mesh data rotated, objects counter-rotated,
# so the .mesh files follow the engine conventions while the
# exported scene keeps the modeled look) ---
def rotate_data_shared(data, rot):
data.transform(rot)
inv = rot.inverted()
for obj in bpy.data.objects:
if obj.type == 'MESH' and obj.data == data:
obj.matrix_world = obj.matrix_world @ inv
for data in set(w["obj"].data for w in wheel_info):
# Axle from Blender X onto Blender Z (-> Ogre Y).
rotate_data_shared(data, Matrix.Rotation(radians(90.0), 4, 'Y'))
if steering:
# Disc normal from Blender Y onto Blender Z (-> Ogre Y spin axis).
rotate_data_shared(steering.data,
Matrix.Rotation(radians(90.0), 4, 'X'))
# --- OGRE export (same parameters as export_vehicles.py) ---
scene_file = glb_file.replace(".glb", "").replace(".gltf", "") + ".scene"
bpy.ops.ogre.export(filepath=scene_file,
EX_SWAP_AXIS='xz-y',
EX_V2_MESH_TOOL_VERSION='v2',
EX_EXPORT_XML_DELETE=True,
EX_SCENE=True,
EX_SELECTED_ONLY=False,
EX_EXPORT_HIDDEN=False,
EX_FORCE_CAMERA=False,
EX_FORCE_LIGHTS=False,
EX_NODE_ANIMATION=True,
EX_MATERIALS=True,
EX_SEPARATE_MATERIALS=True,
EX_COPY_SHADER_PROGRAMS=True,
EX_MESH=True,
EX_LOD_LEVELS=3,
EX_LOD_DISTANCE=100,
EX_LOD_PERCENT=40
)
# --- replace the exporter's fixed-function .material with the merged
# VehicleIdMap shader materials (CAR_MODELING.md): "<base>" is the
# transparent near variant, "<base>-opaque" the far variant; the
# runtime swaps them by camera distance (SedanGlassLodListener).
# Both reference the VehicleIdMapVP/FP programs staged in the same
# resource dir (vehicle_idmap.program, CMake-staged from
# assets/blender/vehicles). ---
IDMAP_MATERIAL_TEMPLATE = """// written by gen_vehicle.py — merged ID-map vehicle material
// (CAR_MODELING.md). Not a blender2ogre product; hand edits are
// overwritten by every export.
//
// Three materials per car:
// @BASE@ paint/bloom submeshes, fully opaque.
// @BASE@-glass near glass submesh (<10 m): alpha-blended,
// depth_write OFF so it lands in OGRE's
// transparent collection and can never
// depth-block the interior/characters behind it
// (they render first, the glass blends over).
// @BASE@-glass-opaque far glass submesh (>=10 m): solid.
// The demo's SedanGlassLodListener swaps only the -glass submeshes
// between the near and far variants; paint submeshes always keep
// @BASE@.
material @BASE@
{
receive_shadows on
technique
{
pass
{
cull_hardware clockwise
vertex_program_ref VehicleIdMapVP
{
}
fragment_program_ref VehicleIdMapFP
{
param_named glassAlpha float 1.0
}
texture_unit
{
texture @BASE@-atlas.png
tex_address_mode wrap
filtering linear linear none
}
texture_unit
{
texture @BASE@-idmap.png
tex_address_mode clamp
filtering point point none
}
}
}
}
material @BASE@-glass
{
receive_shadows on
technique
{
pass
{
cull_hardware clockwise
depth_write off
scene_blend alpha_blend
vertex_program_ref VehicleIdMapVP
{
}
fragment_program_ref VehicleIdMapFP
{
param_named glassAlpha float 0.25
}
texture_unit
{
texture @BASE@-atlas.png
tex_address_mode wrap
filtering linear linear none
}
texture_unit
{
texture @BASE@-idmap.png
tex_address_mode clamp
filtering point point none
}
}
}
}
material @BASE@-glass-opaque
{
receive_shadows on
technique
{
pass
{
cull_hardware clockwise
vertex_program_ref VehicleIdMapVP
{
}
fragment_program_ref VehicleIdMapFP
{
param_named glassAlpha float 1.0
}
texture_unit
{
texture @BASE@-atlas.png
tex_address_mode wrap
filtering linear linear none
}
texture_unit
{
texture @BASE@-idmap.png
tex_address_mode clamp
filtering point point none
}
}
}
}
"""
mat_path = os.path.join(outdir, base + ".material")
with open(mat_path, "w") as f:
f.write(IDMAP_MATERIAL_TEMPLATE.replace("@BASE@", base))
print("gen_vehicle: wrote merged material %s" % mat_path)
# blender2ogre also writes a fixed-function <base>-glass.material for
# the glass material slot; drop it (and the stale pre-ID-map split
# files) — the template above defines every <base>* material and a
# duplicate definition from another script would shadow it depending
# on resource parse order.
for suffix in ("-color", "-color-bloom", "-glass"):
stale = os.path.join(outdir, base + suffix + ".material")
if os.path.exists(stale):
os.remove(stale)
bpy.ops.wm.read_homefile(use_empty=True)
import time
time.sleep(2)
bpy.ops.wm.quit_blender()
+301
View File
@@ -0,0 +1,301 @@
# Creating a Car Model for the Game (Blender -> Engine)
This is the contract a car model must satisfy to work with the vehicle
pipeline: drivable via `VehicleComponent`/`VehicleSystem`, enterable
seats via `VehicleControllerSystem`, openable doors/hood/trunk, and a
seated character that actually fits the cabin.
The reference implementation is `sedan-v2.blend` in this directory — a
hand-modeled car processed by the convention-based generator
`assets/blender/scripts/gen_vehicle.py`. This is the recommended
route: model the car once following the naming conventions below and
the generator produces the meshes, materials, textures AND a
ready-to-merge scene-entity JSON fragment.
(The older fully-procedural route, `assets/blender/scripts/
gen_taxi_assets.py` -> `sedan-a.blend` / `sedan-taxi.blend`, still
works and follows the same engine-side conventions, but its
`sitting-chair`-at-0.85-scale cabin numbers are superseded by the
`sitting-vehicle` animation below.)
## 1. Export pipeline (gen_vehicle.py)
- Put the `.blend` in `assets/blender/vehicles/` and add it to
`VEHICLES_GEN_SRC` in `assets/blender/vehicles/CMakeLists.txt`.
- Build target `import_vehicles` runs:
```
blender <name>.blend -b -Y -P gen_vehicle.py -- <out>.glb <out>.vehicle.json
```
which does everything `export_vehicles.py` does (blender2ogre
`.scene` + one `.mesh` per mesh data block + one `.material` per
material, 3 LOD levels, plus a `.glb` preview) and additionally:
- classifies parts by naming convention (see §2),
- renames mesh data blocks to `<base>-<role>` (`sedan-v2`,
`sedan-v2-door-fl`, `sedan-v2-wheel`, `sedan-v2-steering-wheel`,
...),
- renames materials to `<base>-<material>` and unpacks packed
textures to `<base>-atlas.png` next to the output,
- normalizes wheel/steering mesh axes (see §3),
- emits `<name>.vehicle.json`: a complete scene entity (collider
from the shell bounding box, wheels, seats, steering, door/hood/
trunk children with seat actuators).
- Objects named `*-col` are deleted before export (collision helpers).
- After (re)export, artifacts land in `build-vscode/resources/vehicles/`.
One target re-exports changed blends, re-stages the editScene runtime
resources and re-merges the generated vehicle JSONs into the demo-taxi
scenes (this edits the source scene JSONs — merge_vehicle.py preserves
entity id/name/transform, so placement edits survive):
```bash
cmake --build build-vscode --target update_vehicles
```
## 2. Naming conventions (what the generator picks up)
- **root**: the single parentless MESH object is the body shell; its
mesh DATA frame is the chassis frame (object locations are NOT baked
into the `.mesh`, so author shell vertex data relative to the
intended chassis origin).
- **`* Tire`** (the word "tire" or "wheel" in the name): wheel objects.
All wheels may share one mesh data block. Wheel radius/width are
measured from the mesh bounds. No mirrored copy is needed for the
right side: Jolt reports every wheel with the same axle direction, so
`VehicleSystem` flips right-side (chassis -X) wheel visuals 180 deg
about the local forward axis at runtime — model one wheel and reuse it
for all four corners.
- **`* Door`**: door child entities; origin on the hinge line (model
the door CLOSED, then Object -> Set Origin -> Origin to 3D Cursor at
the hinge). Each door gets a seat actuator for the seat behind it
(same side + row).
- **`* hood` / `* bonnet`**, **`* trunk`**: hood/trunk child entities,
origin on the hinge line.
- **`* Steering`**: the steering wheel.
- **`seat_*`**: EMPTY objects marking character seat pins. Exactly one
must have "driver" in its name (e.g. `seat_driver`); the front
non-driver seat becomes `passenger`, rear seats become `rl`/`rr` by
side. The empty's position is where the character's ROOT (base) is
pinned — see §5.
- Everything else is exported as an extra mesh named
`<base>-<sanitized mesh name>` but gets no child entity.
## 3. Axis and origin conventions
The exporter swaps axes `EX_SWAP_AXIS='xz-y'`: Blender `(x, y, z)`
becomes Ogre `(x, z, -y)`.
| Blender | Ogre / engine | Meaning |
|---------|---------------|---------|
| `-Y` | `+Z` | vehicle forward (Jolt chassis +Z) |
| `+Z` | `+Y` | up |
| `+X` | `+X` | vehicle LEFT side |
- **Chassis origin**: pick it once (sedan-v2: axle-box center, ~0.6 m
above ground contact) and model the shell data around it. The
collider, wheels, seats and part pivots in the generated JSON are all
relative to it automatically.
- **Wheels**: model them as they look (axle along Blender X). The
generator rotates the wheel mesh DATA +90 deg about Blender Y so the
axle lands on Blender Z (Ogre Y, which `getWheelWorldTransform`
assumes) and counter-rotates the objects, so the exported scene keeps
the modeled look. Don't pre-rotate.
- **Steering wheel**: model it tilted as installed; the generator
rotates the mesh DATA +90 deg about Blender X so the disc normal
lands on Blender Z (Ogre Y, the spin axis `VehicleSystem` uses) and
counter-rotates the object.
- **blender2ogre names `.mesh` files after the mesh DATA name** — the
generator handles the renaming, but never redefine another blend's
mesh data names (all blends export into the same
`resources/vehicles/` directory and silently overwrite).
## 4. Materials and textures (merged ID-map material)
- The generator consolidates EVERY mesh part (shell, doors, hood,
trunk, wheels, steering, extras) onto **two materials**: `<base>`
for paint/bloom faces and `<base>-glass` for glass faces — so
blender2ogre emits **one submesh per part**, or two (paint first,
glass last) for glass-bearing parts. The blender2ogre `.material`
output is replaced by a hand-written one referencing the shared GLSL
programs `VehicleIdMapVP`/`VehicleIdMapFP` (`vehicle_idmap.vert/.frag/.program`,
staged into `resources/vehicles/` by CMake; OpenGL ES 2.0 compatible
via `OgreUnifiedShader.h`).
**Why not one merged submesh:** a single transparent submesh forces
the WHOLE part (interior included) into OGRE's transparent render
queue, where renderables sort back-to-front by node distance — the
depth-writing window glass then culls interior parts whose nodes
sort closer to the camera ("holes" punched in the cabin with the
road showing through, 2026-10-21/22). Opaque paint + separate
depth-write-off glass is the only arrangement that stays correct
under per-object transparency sorting.
- Per-fragment behavior comes from an **auto-generated ID map**
(`<base>-idmap.png`, 512x512) that shares the color-atlas UV space:
red = paint (atlas color, multiplied by the shader's `vehicleColor`
uniform — the future per-car registry color hook), green = bloom
(lights; optional `bloomEmissive` term, 0 by default), blue = glass
(`glassColor` tint, alpha `glassAlpha`).
- The car UVs are degenerate by design: every face collapses onto a
single color-atlas texel (the atlas is a palette). The ID map simply
paints the same texels with the ID color, so **no UV texel may be
shared between two different materials** — the generator warns when
that happens; fix the blend (give one of them its own texel) rather
than working around it.
- Source-material roles are recognized by name: `*glass*` -> glass,
`*bloom*` -> lights, everything else -> paint.
- **Filtering**: because the atlas is a palette, mipmaps are poison —
minification averages neighboring palette texels and distant cars
bleed wrong colors (found 2026-10-16: blue headlights at range). The
generated `.material` therefore samples the atlas with
`filtering linear linear none` (no mips) and the ID map with
`filtering point point none`. Never "upgrade" these to mipmapped
filtering.
- **Sampler binding**: `vehicle_idmap.program` binds the samplers
explicitly (`param_named uAtlas int 0` / `param_named uIdMap int 1`).
Without this the two samplers are auto-assigned in driver
uniform-enumeration order, which is not stable — `uIdMap` can end up
reading the ATLAS (unit 0), and then every paint texel's blue channel
leaks into `glassAlpha` and the whole car goes semi-transparent
(found 2026-10-16: see-through doors on llvmpipe while another
machine rendered fine). Keep the explicit `int` params in sync with
the texture_unit order in the generated `.material`.
- **Near/far glass**: the generated `<base>.material` defines `<base>`
(opaque paint, `glassAlpha 1.0`), `<base>-glass` (near glass:
alpha-blended, `depth_write off`, `glassAlpha 0.25`) and
`<base>-glass-opaque` (far glass: solid). The demo's
`SedanGlassLodListener` swaps ONLY the glass submeshes between
`<base>-glass` and `<base>-glass-opaque` at 10 m from the camera
(Ogre 14 ignores `Material::setLodLevels` for entities); paint
submeshes keep `<base>` at all distances. `depth_write off` is what
routes the near glass into the transparent collection — do not add
depth write back to it (that is what depth-blocked the driver's
face/hair behind the window, 2026-10-21). No per-car setup is needed
for new families beyond adding the base name to
`kMergedCarMaterials` in the demo's `demo_main.cpp`.
- Glass faces are sorted to the END of each mesh during generation so
the glass submesh's triangles stay contiguous (cosmetic; the submesh
split is what carries the rendering correctness).
- For color variants of the same geometry, duplicate the mesh data and
move the paint faces to a different atlas texel — or (later) drive
`vehicleColor` per entity.
## 5. Matching the seated/driving character
The character is pinned to the seat point with its root node
(`VehicleControllerSystem::pinCharacterToSeat`) and plays the
locomotion state machine's `driving` state, mapped to the
`sitting-vehicle` animation in the demo's `animation_tree.json`.
**With `sitting-vehicle`, the seat pin IS the character's base
position** — the pose is authored so the butt, hands and feet land
correctly relative to the root, so no offsets and no scaling are
applied (`seatCharacterScale 1.0` in the generated JSON). Place each
`seat_*` empty exactly where the character's feet-between point should
sit: roughly on the floor pan at the cushion front edge.
- sedan-v2 seat pins (chassis space): driver/passenger
(±0.48, -0.28, 0.201), rl/rr (±0.48, -0.28, -0.603); driver is
front-LEFT (+X).
- The generator takes the wheel centers as the intended REST position
and lifts the suspension attach points by
`L_rest = maxLength - g/(2*pi*freq)^2` (0.35 - 0.147 = 0.203 at the
defaults min/max/freq/damping 0.1/0.35/1.3/0.6), so the car rides at
the modeled stance. The suspension is deliberately soft-ish —
stiffer springs make the wheels pogo and cost grip.
- Tire friction: the JSON carries `tireFrictionLongitudinal` /
`tireFrictionLateral` (1.3 / 3.0 for sedans), multipliers over the
Jolt default tire curves (peak 1.2) which are then combined with the
ground friction via `sqrt(tire * ground)` — at the stock 1.0 the car
drives like it is on wet ice (full lock at ~15 m/s turns only ~44
deg in 2.5 s; 3.0 lateral turns ~88 deg). More longitudinal grip
adds understeer on a RWD car, so keep it near stock.
- Anti-roll bars: `antiRollBarStiffness` (2000 for sedans) adds a Jolt
`VehicleAntiRollBar` per axle pair to limit body roll. The engine
also rightens a car stuck past ~35 deg of tilt automatically
(VehicleSystem), so two-wheel balancing acts recover on their own.
- Center of mass: `centerOfMassOffset` shifts the physics COM in
chassis space (Jolt `OffsetCenterOfMassShape` — only the COM and the
inertia about it move; the collider, wheel attachments and the
render node stay put). A box collider puts the COM at the box
center — for a sedan ~1 m above the tire contact patches, twice the
real value — and the car trips onto its side in hard corners (the
taxi's taller box makes it worse). The generator places the COM
just above wheel-hub height; tweak the chassis-space offset in the
editor Vehicle panel (applied live) if a new model still feels
tippy.
- Downforce: `downforceCoefficient` (35 plain sedan, 45 taxi — the
CMake invocation passes it per model) applies a straight down force
of coefficient * speed^2 at the COM while moving — less bounce and,
because the tire normal load (and with it the lateral impulse
budget) grows with speed^2, the turn radius stops growing with
speed: this is the high-speed cornering grip lever. Raise it when a
model understeers at speed, lower it if the car feels glued.
- Default chassis tuning in the JSON: mass 1200, maxTorque 300,
engineBrake 0 (cars coast when the accelerator is released — braking
is the driver's job: S while rolling forward brakes via the
brake-before-reverse rule, Space is the handbrake; slow yard vehicles
like the forklift use ~0.5 so they do not roll forever),
front steer 35 deg, rear driven + handbrake 300,
steeringRatio 4 — tweak per vehicle after merging.
## 6. Scene integration
The generated `<name>.vehicle.json` is a complete entity fragment.
Merge it into a scene JSON with the helper
`src/features/editScene/demos/demo-taxi/merge_vehicle.py`:
```bash
python3 merge_vehicle.py <scene.json> \
<builddir>/resources/vehicles/<name>.vehicle.json <entityName>
```
It replaces the named scene entity (see `sedan1` in
`demo_scene_vehicletest.json`) with the generated one, renaming the
entity and children and preserving the scene entity's id and
transform. **The scene copy does NOT track the blend** — after every
blend edit re-run the `update_vehicles` target (see §1), which
re-exports, re-stages resources and re-merges both demo-taxi scenes in
one step. The door
children carry `actuator`s with the generic per-seat actions
`vehicle_seat_driver` / `_passenger` / `_rl` / `_rr` (defined in
`lua-scripts/data2.lua`), which send the `vehicle_enter` event; the
vehicle is resolved by proximity, so the same actions work for every
car.
## 7. Minimal-effort multi-look strategy
- **Color variants**: duplicate shell + door mesh data, swap the paint
material (atlas UVs make this cheap).
- **Silhouette variants** (e.g. the taxi): keep the hardpoints fixed —
wheel positions, `seat_*` empties, hinge lines, floor pan — and vary
the outer panels (roof sign, livery, bumpers). The generated JSON
then carries over nearly unchanged.
- **Kitbashing**: keep a parts-library blend with the wheel, steering
wheel, seats and dash; append into new car blends. Never reuse
another blend's mesh DATA names (export clash, §3).
## 8. Verifying a new car
From the demo-taxi build directory
(`build-vscode/src/features/editScene/demos/demo-taxi`):
```bash
# Sit the player in a seat and dump a framed screenshot:
xvfb-run -a -s "-screen 0 1920x1080x24" ./demoTaxi \
demo_scene_vehicletest.json --force-pos 4.5 1.5 0.3 90 -15 \
--debug-enter-vehicle <vehicleEntityName> \
--debug-enter-seat driver --screenshot 700 /tmp/seat.png
```
Check: butt on the cushion, head under the headliner, feet inside the
body, glass transparent near / opaque past ~10 m, steering wheel in
front of the driver. The `--debug-enter-*` run also prints a bone
probe (head bone y should match the exported animation, ~0.75 for
sitting-vehicle). Then run the headless contract:
```bash
./demoTaxi --headless --test-vehicle-drive demo_scene_vehicletest.json
```
(always with `--headless`: without it the frame delta is wall time and
the fixed-length W-hold drives the car off the test floor, failing the
exit-spot raycast for timing reasons, not because the car is broken).
+58 -1
View File
@@ -2,7 +2,9 @@ project(vehicles)
set(VEHICLES_SRC boat-big.blend boat.blend
boat-gobbot.blend boat-sails.blend
raft.blend tiny-boat.blend)
raft.blend tiny-boat.blend
forklift.blend crate.blend pad.blend
wall.blend sedan-a.blend sedan-taxi.blend)
set(VEHICLE_OUTPUT_FILES)
foreach(VEHICLE_FILE ${VEHICLES_SRC})
@@ -22,4 +24,59 @@ foreach(VEHICLE_FILE ${VEHICLES_SRC})
list(APPEND VEHICLE_OUTPUT_FILES ${VEHICLE_OUTPUT_FILE} ${CMAKE_BINARY_DIR}/resources/vehicles/${FILE_NAME}.scene)
endforeach()
# Convention-built drivable vehicles: same mesh/material export plus a
# generated scene-entity JSON fragment (<name>.vehicle.json, see
# assets/blender/scripts/gen_vehicle.py and vehicles/CAR_MODELING.md).
set(VEHICLES_GEN_SRC sedan-v2.blend sedan-v2-taxi.blend)
# Per-model downforce coefficient (third gen_vehicle.py argument):
# heavier/taller handling gets more speed-grip.
set(DOWNFORCE_sedan-v2 35)
set(DOWNFORCE_sedan-v2-taxi 45)
foreach(VEHICLE_FILE ${VEHICLES_GEN_SRC})
get_filename_component(FILE_NAME ${VEHICLE_FILE} NAME_WE)
set(VEHICLE_OUTPUT_FILE ${CMAKE_BINARY_DIR}/resources/vehicles/${FILE_NAME}.glb)
set(VEHICLE_JSON_FILE ${CMAKE_BINARY_DIR}/resources/vehicles/${FILE_NAME}.vehicle.json)
add_custom_command(
OUTPUT ${VEHICLE_OUTPUT_FILE} ${CMAKE_BINARY_DIR}/resources/vehicles/${FILE_NAME}.scene ${VEHICLE_JSON_FILE}
COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_BINARY_DIR}/resources/vehicles
COMMAND ${BLENDER} ${CMAKE_CURRENT_SOURCE_DIR}/${VEHICLE_FILE}
-b -Y -P
${CMAKE_SOURCE_DIR}/assets/blender/scripts/gen_vehicle.py
-- ${VEHICLE_OUTPUT_FILE} ${VEHICLE_JSON_FILE} ${DOWNFORCE_${FILE_NAME}}
COMMAND ${CMAKE_COMMAND} -D FILE=${CMAKE_BINARY_DIR}/resources/vehicles/${FILE_NAME}.glb -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
COMMAND ${CMAKE_COMMAND} -D FILE=${CMAKE_BINARY_DIR}/resources/vehicles/${FILE_NAME}.scene -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
COMMAND touch ${VEHICLE_OUTPUT_FILE}
DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/${VEHICLE_FILE} ${CMAKE_SOURCE_DIR}/assets/blender/scripts/gen_vehicle.py)
list(APPEND VEHICLE_OUTPUT_FILES ${VEHICLE_OUTPUT_FILE} ${CMAKE_BINARY_DIR}/resources/vehicles/${FILE_NAME}.scene ${VEHICLE_JSON_FILE})
endforeach()
# Shared merged-material vehicle shader (one ID-map material per car;
# gen_vehicle.py writes the per-car .material referencing these
# programs; see vehicles/CAR_MODELING.md). Staged next to the
# generated .material files.
file(COPY vehicle_idmap.vert vehicle_idmap.frag vehicle_idmap.program
DESTINATION ${CMAKE_BINARY_DIR}/resources/vehicles)
add_custom_target(import_vehicles ALL DEPENDS ${VEHICLE_OUTPUT_FILES})
# One-step vehicle refresh: re-export changed .blend files (import_vehicles),
# re-stage the resources for the editScene runtime and re-merge the generated
# vehicle entity JSONs into the demo-taxi scenes. NOTE: the merge step EDITS
# the source scene JSONs under src/features/editScene/demos/demo-taxi (that is
# the point - merge_vehicle.py preserves entity id/name/transform, so hand
# edits to placement survive). Run after editing a vehicle .blend:
# cmake --build <build-dir> --target update_vehicles
set(DEMO_TAXI_DIR ${CMAKE_SOURCE_DIR}/src/features/editScene/demos/demo-taxi)
add_custom_target(update_vehicles
COMMAND ${CMAKE_COMMAND} -E copy_directory
${CMAKE_BINARY_DIR}/resources
${CMAKE_BINARY_DIR}/src/features/editScene/resources
COMMAND python3 merge_vehicle.py demo_scene_vehicletest.json
${CMAKE_BINARY_DIR}/resources/vehicles/sedan-v2.vehicle.json sedan1
COMMAND python3 merge_vehicle.py demo_scene_exterior.json
${CMAKE_BINARY_DIR}/resources/vehicles/sedan-v2.vehicle.json sedan_plain
COMMAND python3 merge_vehicle.py demo_scene_exterior.json
${CMAKE_BINARY_DIR}/resources/vehicles/sedan-v2-taxi.vehicle.json sedan_taxi
WORKING_DIRECTORY ${DEMO_TAXI_DIR}
DEPENDS import_vehicles)
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,72 @@
/*
* Merged vehicle material — fragment stage.
*
* Per fragment the ID map (same UV space as the color atlas) selects
* which parameter set applies, by multiplying each candidate value by
* its ID channel weight:
*
* idmap.r = paint — atlas color, tinted by vehicleColor (the future
* per-car registry color hook; white for now)
* idmap.g = bloom — lights; shaded like paint today, plus an
* optional emissive term (bloomEmissive, off by
* default to keep the current look)
* idmap.b = glass — glassColor tint, alpha = glassAlpha; glass
* faces live on their own submesh whose material
* the demo swaps by camera distance: "<base>-glass"
* (near, alpha 0.25, depth_write off) /
* "<base>-glass-opaque" (far, alpha 1.0) —
* SedanGlassLodListener
*
* Lighting is a single directional light (the sun) + scene ambient,
* Blinn specular on paint and glass. OpenGL ES 2.0 compatible via
* OgreUnifiedShader.h.
*/
OGRE_NATIVE_GLSL_VERSION_DIRECTIVE
#include <OgreUnifiedShader.h>
OGRE_UNIFORMS(
uniform vec4 lightDirView;
uniform vec4 lightDiffuse;
uniform vec4 ambient;
uniform vec4 vehicleColor;
uniform vec4 glassColor;
uniform float glassAlpha;
uniform float bloomEmissive;
uniform float specularStrength;
uniform float shininess;
)
SAMPLER2D(uAtlas, 0);
SAMPLER2D(uIdMap, 1);
MAIN_PARAMETERS
IN(vec2 vUV, TEXCOORD0)
IN(vec3 vNormal, TEXCOORD1)
IN(vec3 vPosView, TEXCOORD2)
MAIN_DECLARATION
{
vec4 atlas = texture2D(uAtlas, vUV);
vec3 idm = texture2D(uIdMap, vUV).rgb;
float wPaint = idm.r;
float wBloom = idm.g;
float wGlass = idm.b;
vec3 base = atlas.rgb * vehicleColor.rgb * wPaint +
atlas.rgb * wBloom +
glassColor.rgb * wGlass;
vec3 n = normalize(vNormal);
vec3 l = normalize(-lightDirView.xyz);
vec3 v = normalize(-vPosView);
vec3 h = normalize(l + v);
float diff = max(dot(n, l), 0.0);
float spec = pow(max(dot(n, h), 0.0), shininess) *
specularStrength * (wPaint + wGlass);
vec3 lit = base * (ambient.rgb + diff * lightDiffuse.rgb) +
spec * lightDiffuse.rgb;
lit += atlas.rgb * bloomEmissive * wBloom;
float alpha = mix(1.0, glassAlpha, wGlass);
gl_FragColor = vec4(lit, alpha);
}
@@ -0,0 +1,40 @@
/*
* Merged single-material vehicle shader (paint / bloom / glass selected
* per fragment from an ID map texture sharing the color atlas UV space).
* Used by the <car>.material files written by gen_vehicle.py; see
* assets/blender/vehicles/CAR_MODELING.md. OpenGL ES 2.0 compatible
* (OgreUnifiedShader.h).
*/
vertex_program VehicleIdMapVP glsl glsles glslang hlsl
{
source vehicle_idmap.vert
default_params
{
param_named_auto worldViewProj worldviewproj_matrix
param_named_auto worldView worldview_matrix
}
}
fragment_program VehicleIdMapFP glsl glsles glslang hlsl
{
source vehicle_idmap.frag
default_params
{
param_named_auto lightDirView light_direction_view_space 0
param_named_auto lightDiffuse light_diffuse_colour 0
param_named_auto ambient ambient_light_colour
param_named vehicleColor float4 1.0 1.0 1.0 1.0
param_named glassColor float4 0.8 0.8 0.8 1.0
param_named glassAlpha float 0.25
param_named bloomEmissive float 0.0
param_named specularStrength float 0.5
param_named shininess float 32.0
// explicit texture-unit bindings: without these the samplers
// are auto-assigned in driver uniform-enumeration order and
// uIdMap can end up reading the atlas (unit 0) — every paint
// texel's blue channel then leaks into glassAlpha and the
// whole car goes semi-transparent (2026-10-16)
param_named uAtlas int 0
param_named uIdMap int 1
}
}
@@ -0,0 +1,34 @@
/*
* Merged vehicle material — vertex stage.
*
* One material for the whole car (see assets/blender/vehicles/
* CAR_MODELING.md): the fragment stage selects paint / bloom (lights) /
* glass behaviour per fragment from an ID map texture that shares the
* color atlas UV space. OpenGL ES 2.0 compatible via
* OgreUnifiedShader.h.
*/
OGRE_NATIVE_GLSL_VERSION_DIRECTIVE
#include <OgreUnifiedShader.h>
OGRE_UNIFORMS(
uniform mat4 worldViewProj;
uniform mat4 worldView;
)
OUT(vec2 vUV, TEXCOORD0)
OUT(vec3 vNormal, TEXCOORD1)
OUT(vec3 vPosView, TEXCOORD2)
MAIN_PARAMETERS
IN(vec4 vertex, POSITION)
IN(vec3 normal, NORMAL)
IN(vec2 uv0, TEXCOORD0)
MAIN_DECLARATION
{
gl_Position = mul(worldViewProj, vertex);
vUV = uv0;
/* Vehicles are never non-uniformly scaled, so the plain
* world-view 3x3 is a fine normal transform. */
vNormal = normalize(mul(mat3(worldView), normal));
vPosView = mul(worldView, vertex).xyz;
}
+9
View File
@@ -0,0 +1,9 @@
-- Minimal Lua data file required by lateSetup().
-- The app requires this file to exist in the LuaScripts resource group.
-- Add project-specific Lua initialisation here.
function init()
-- placeholder
end
init()
File diff suppressed because it is too large Load Diff
+519 -4
View File
@@ -1,7 +1,7 @@
project(editScene)
set(CMAKE_CXX_STANDARD 17)
find_package(OGRE REQUIRED COMPONENTS Bites Overlay MeshLodGenerator CONFIG)
find_package(OGRE REQUIRED COMPONENTS Bites Overlay MeshLodGenerator Paging Terrain CONFIG)
find_package(flecs REQUIRED CONFIG)
find_package(nlohmann_json REQUIRED)
find_package(SDL2 REQUIRED)
@@ -16,13 +16,31 @@ set(EDITSCENE_SOURCES
main.cpp
EditorApp.cpp
GameMode.cpp
ProjectConfig.cpp
systems/EditorUISystem.cpp
systems/SceneSerializer.cpp
systems/PhysicsSystem.cpp
systems/VehicleSystem.cpp
systems/VehicleControllerSystem.cpp
systems/BuoyancySystem.cpp
systems/EditorSunSystem.cpp
systems/EditorSkyboxSystem.cpp
systems/EditorWaterPlaneSystem.cpp
systems/TerrainSystem.cpp
systems/RenderOriginSystem.cpp
systems/SpawnerRegionStore.cpp
systems/RoadRegionStore.cpp
systems/RoadSystem.cpp
systems/TownGenSystem.cpp
systems/NpcPopulationSystem.cpp
systems/NpcMotionSystem.cpp
systems/NpcDetailSystem.cpp
systems/NpcTaxiSystem.cpp
systems/NpcAvoidanceSystem.cpp
systems/NpcTrafficSystem.cpp
systems/AiDriverSystem.cpp
systems/GameClockSystem.cpp
systems/NpcScheduleDebugOverlay.cpp
systems/LightSystem.cpp
systems/CameraSystem.cpp
systems/LodSystem.cpp
@@ -31,23 +49,37 @@ set(EDITSCENE_SOURCES
systems/ProceduralMaterialSystem.cpp
systems/ProceduralMeshSystem.cpp
systems/CellGridSystem.cpp
systems/DoorSystem.cpp
systems/DoorBuilder.cpp
systems/StandaloneDoorSystem.cpp
components/StandaloneDoorModule.cpp
ui/StandaloneDoorEditor.cpp
systems/NormalDebugSystem.cpp
systems/RoomLayoutSystem.cpp
systems/FurnitureLibrary.cpp
systems/FurniturePlacement.cpp
systems/StartupMenuSystem.cpp
systems/PauseMenuSystem.cpp
systems/ItemRegistry.cpp
systems/AnimationTreeRegistry.cpp
systems/ContainerStateRegistry.cpp
systems/ItemStateRegistry.cpp
systems/GlobalStateStore.cpp
systems/CharacterStatStore.cpp
systems/ScheduleScoring.cpp
systems/SaveLoadSystem.cpp
systems/SaveLoadDialog.cpp
systems/PlayerControllerSystem.cpp
systems/CharacterSlotSystem.cpp
systems/CharacterSpawnerSystem.cpp
systems/TerrainPrefabSpawnerSystem.cpp
systems/OgreEntityHack.cpp
systems/CharacterRegistry.cpp
systems/MarkovNameGenerator.cpp
systems/PregnancySystem.cpp
systems/AnimationTreeSystem.cpp
systems/AnimTravel.cpp
systems/HairPhysicsSystem.cpp
systems/BehaviorTreeSystem.cpp
systems/NavMeshSystem.cpp
recast/TileCacheNavMesh.cpp
@@ -69,10 +101,25 @@ set(EDITSCENE_SOURCES
systems/ActuatorSystem.cpp
ui/ActuatorEditor.cpp
components/ActuatorModule.cpp
ui/PushableEditor.cpp
components/PushableModule.cpp
ui/TargetZoneEditor.cpp
components/TargetZoneModule.cpp
ui/NpcLocationEditor.cpp
ui/PoiEditor.cpp
ui/PoiDatabasePanel.cpp
components/NpcLocationModule.cpp
components/PoiModule.cpp
systems/ZoneSystem.cpp
systems/PoiSystem.cpp
systems/GameHudSystem.cpp
systems/EventBus.cpp
systems/EventHandlerSystem.cpp
ui/EventHandlerEditor.cpp
components/EventHandlerModule.cpp
systems/SceneScriptSystem.cpp
ui/SceneScriptEditor.cpp
components/SceneScriptModule.cpp
systems/PrefabSystem.cpp
ui/PrefabInstanceEditor.cpp
@@ -86,6 +133,7 @@ set(EDITSCENE_SOURCES
ui/RenderableEditor.cpp
ui/PhysicsColliderEditor.cpp
ui/RigidBodyEditor.cpp
ui/VehicleEditor.cpp
ui/LightEditor.cpp
ui/CameraEditor.cpp
ui/LodEditor.cpp
@@ -97,9 +145,12 @@ set(EDITSCENE_SOURCES
ui/PrimitiveEditor.cpp
ui/TriangleBufferEditor.cpp
ui/CharacterSlotsEditor.cpp
ui/CharacterSpawnerEditor.cpp
ui/TerrainPrefabSpawnerEditor.cpp
ui/CharacterIdentityEditor.cpp
ui/AnimationTreeEditor.cpp
ui/AnimationTreeTemplateEditor.cpp
ui/AnimationTreeNodeEditor.cpp
ui/AnimationTreeRegistryEditor.cpp
ui/CharacterEditor.cpp
ui/CellGridEditor.cpp
ui/LotEditor.cpp
@@ -139,8 +190,9 @@ set(EDITSCENE_SOURCES
components/PrimitiveModule.cpp
components/TriangleBufferModule.cpp
components/CharacterSlotsModule.cpp
components/CharacterSpawnerModule.cpp
components/TerrainPrefabSpawnerModule.cpp
components/AnimationTreeModule.cpp
components/AnimationTreeTemplateModule.cpp
components/AnimationTree.cpp
components/CharacterModule.cpp
components/CellGridModule.cpp
@@ -158,15 +210,25 @@ set(EDITSCENE_SOURCES
components/BuoyancyInfoModule.cpp
components/WaterPhysicsModule.cpp
components/WaterPlaneModule.cpp
components/TerrainModule.cpp
components/SunModule.cpp
components/SkyboxModule.cpp
components/TransformModule.cpp
components/RenderableModule.cpp
components/RigidBodyModule.cpp
components/VehicleModule.cpp
components/PhysicsColliderModule.cpp
components/PrefabInstanceModule.cpp
components/CharacterIdentityModule.cpp
camera/EditorCamera.cpp
gizmo/Gizmo.cpp
gizmo/Cursor3D.cpp
gizmo/RoadGizmo.cpp
physics/physics.cpp
lua/LuaState.cpp
lua/LuaEntityApi.cpp
lua/LuaComponentApi.cpp
lua/LuaAnimationTreeApi.cpp
lua/LuaEventApi.cpp
lua/LuaActionApi.cpp
lua/LuaBehaviorTreeApi.cpp
@@ -174,6 +236,19 @@ set(EDITSCENE_SOURCES
lua/LuaCharacterClassApi.cpp
lua/LuaCharacterApi.cpp
lua/LuaSaveLoadApi.cpp
lua/LuaGlobalStateApi.cpp
lua/LuaCharacterStatsApi.cpp
lua/LuaEmergencyApi.cpp
lua/LuaScheduleScoringApi.cpp
lua/LuaRostersApi.cpp
lua/LuaDoorApi.cpp
lua/LuaHudApi.cpp
lua/LuaFurnitureApi.cpp
lua/LuaTerrainApi.cpp
lua/LuaSceneSwitchApi.cpp
systems/TerrainTests.cpp
systems/StatEffectsApplier.cpp
systems/ActivityEffectHook.cpp
)
set(EDITSCENE_HEADERS
@@ -187,6 +262,8 @@ set(EDITSCENE_HEADERS
components/BuoyancyInfo.hpp
components/WaterPhysics.hpp
components/WaterPlane.hpp
components/Terrain.hpp
components/RoadGraph.hpp
components/Sun.hpp
components/Skybox.hpp
components/Light.hpp
@@ -218,11 +295,16 @@ set(EDITSCENE_HEADERS
systems/StartupMenuSystem.hpp
systems/PauseMenuSystem.hpp
systems/ItemRegistry.hpp
systems/AnimationTreeRegistry.hpp
systems/ContainerStateRegistry.hpp
systems/ItemStateRegistry.hpp
systems/GlobalStateStore.hpp
systems/PlayerControllerSystem.hpp
systems/EditorUISystem.hpp
systems/CellGridSystem.hpp
systems/DoorSystem.hpp
systems/DoorBuilder.hpp
systems/StandaloneDoorSystem.hpp
systems/NormalDebugSystem.hpp
systems/RoomLayoutSystem.hpp
systems/FurnitureLibrary.hpp
@@ -232,6 +314,7 @@ set(EDITSCENE_HEADERS
systems/CharacterRegistry.hpp
systems/PregnancySystem.hpp
systems/AnimationTreeSystem.hpp
systems/HairPhysicsSystem.hpp
systems/BehaviorTreeSystem.hpp
systems/NavMeshSystem.hpp
recast/TileCacheNavMesh.hpp
@@ -252,13 +335,21 @@ set(EDITSCENE_HEADERS
systems/PathFollowingSystem.hpp
systems/GoapPlannerSystem.hpp
components/Actuator.hpp
components/Door.hpp
components/StandaloneDoor.hpp
ui/ActuatorEditor.hpp
ui/StandaloneDoorEditor.hpp
systems/EventBus.hpp
components/EventHandler.hpp
systems/EventHandlerSystem.hpp
ui/EventHandlerEditor.hpp
components/SceneScript.hpp
systems/SceneScriptSystem.hpp
ui/SceneScriptEditor.hpp
components/PrefabInstance.hpp
components/TerrainPrefabSpawner.hpp
ui/PrefabInstanceEditor.hpp
ui/TerrainPrefabSpawnerEditor.hpp
systems/ItemSystem.hpp
components/Item.hpp
@@ -276,6 +367,9 @@ set(EDITSCENE_HEADERS
systems/EditorSunSystem.hpp
systems/EditorSkyboxSystem.hpp
systems/EditorWaterPlaneSystem.hpp
systems/TerrainSystem.hpp
systems/RoadSystem.hpp
systems/TerrainPrefabSpawnerSystem.hpp
systems/LightSystem.hpp
systems/CameraSystem.hpp
systems/LodSystem.hpp
@@ -297,9 +391,11 @@ set(EDITSCENE_HEADERS
ui/PrimitiveEditor.hpp
ui/TriangleBufferEditor.hpp
ui/CharacterSlotsEditor.hpp
ui/CharacterSpawnerEditor.hpp
ui/CharacterIdentityEditor.hpp
ui/AnimationTreeEditor.hpp
ui/AnimationTreeTemplateEditor.hpp
ui/AnimationTreeNodeEditor.hpp
ui/AnimationTreeRegistryEditor.hpp
ui/CharacterEditor.hpp
ui/CellGridEditor.hpp
ui/LotEditor.hpp
@@ -334,10 +430,12 @@ set(EDITSCENE_HEADERS
camera/EditorCamera.hpp
gizmo/Gizmo.hpp
gizmo/Cursor3D.hpp
gizmo/RoadGizmo.hpp
physics/physics.h
lua/LuaState.hpp
lua/LuaEntityApi.hpp
lua/LuaComponentApi.hpp
lua/LuaAnimationTreeApi.hpp
lua/LuaEventApi.hpp
lua/LuaActionApi.hpp
lua/LuaBehaviorTreeApi.hpp
@@ -345,11 +443,25 @@ set(EDITSCENE_HEADERS
lua/LuaCharacterClassApi.hpp
lua/LuaCharacterApi.hpp
lua/LuaSaveLoadApi.hpp
lua/LuaGlobalStateApi.hpp
lua/LuaDoorApi.hpp
lua/LuaHudApi.hpp
lua/LuaTerrainApi.hpp
lua/LuaSceneSwitchApi.hpp
)
add_executable(editSceneEditor ${EDITSCENE_SOURCES} ${EDITSCENE_HEADERS})
add_dependencies(editSceneEditor morph)
# Verbose diagnostics for player input, locomotion state changes and animation
# blending. Disabled by default; enable with -DEDITSCENE_VERBOSE_DEBUG=ON.
option(EDITSCENE_VERBOSE_DEBUG
"Enable verbose player controller / animation diagnostics" OFF)
if(EDITSCENE_VERBOSE_DEBUG)
target_compile_definitions(editSceneEditor
PRIVATE EDITSCENE_VERBOSE_DEBUG)
endif()
# Define JPH_DEBUG_RENDERER for physics debug drawing
target_compile_definitions(editSceneEditor PRIVATE JPH_DEBUG_RENDERER)
@@ -358,6 +470,8 @@ target_link_libraries(editSceneEditor
OgreBites
OgreOverlay
OgreMeshLodGenerator
OgrePaging
OgreTerrain
flecs::flecs_static
nlohmann_json::nlohmann_json
Jolt::Jolt
@@ -368,7 +482,9 @@ target_link_libraries(editSceneEditor
RecastNavigation::DetourCrowd
RecastNavigation::DebugUtils
PackageArchive
RoadGeometryLib
lua
SDL2::SDL2
)
target_include_directories(editSceneEditor PRIVATE
@@ -378,6 +494,7 @@ target_include_directories(editSceneEditor PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/recastnavigation/DetourTileCache/Include
${CMAKE_CURRENT_SOURCE_DIR}/recastnavigation/DetourCrowd/Include
${CMAKE_CURRENT_SOURCE_DIR}/recastnavigation/DebugUtils/Include
${CMAKE_SOURCE_DIR}/src/FastNoiseLite
${CMAKE_SOURCE_DIR}/src/lua/lua-5.4.8/src
${CMAKE_SOURCE_DIR}/src/lua/lpeg-1.1.0
)
@@ -593,6 +710,376 @@ target_include_directories(save_load_lua_test PRIVATE
${CMAKE_SOURCE_DIR}/src/lua/lua-5.4.8/src
)
# ---------------------------------------------------------------------------
# Test: Scene switch API + "switchScene" behavior tree node (headless)
# ---------------------------------------------------------------------------
# Links the full editScene sources (minus main.cpp) like the demos, but runs
# without OGRE initialization: EditorApp::switchScene() only validates the
# path and queues a deferred request, and the behavior tree node evaluation
# needs no SceneManager. Covers the Lua API (ecs.switch_scene,
# ecs.behavior_tree.create_scene_switch_node) and the BT node through both
# the actuator path (evaluatePlayerAction) and the AI path
# (BehaviorTreeComponent + update()).
set(SCENE_SWITCH_TEST_SOURCES ${EDITSCENE_SOURCES})
list(REMOVE_ITEM SCENE_SWITCH_TEST_SOURCES main.cpp)
add_executable(scene_switch_test
tests/scene_switch_test.cpp
${SCENE_SWITCH_TEST_SOURCES}
)
add_dependencies(scene_switch_test morph)
target_compile_definitions(scene_switch_test PRIVATE JPH_DEBUG_RENDERER)
target_link_libraries(scene_switch_test
OgreMain
OgreBites
OgreOverlay
OgreMeshLodGenerator
OgrePaging
OgreTerrain
flecs::flecs_static
nlohmann_json::nlohmann_json
Jolt::Jolt
OgreProcedural::OgreProcedural
RecastNavigation::Recast
RecastNavigation::Detour
RecastNavigation::DetourTileCache
RecastNavigation::DetourCrowd
RecastNavigation::DebugUtils
PackageArchive
RoadGeometryLib
lua
SDL2::SDL2
)
target_include_directories(scene_switch_test PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
${CMAKE_CURRENT_SOURCE_DIR}/recastnavigation/Recast/Include
${CMAKE_CURRENT_SOURCE_DIR}/recastnavigation/Detour/Include
${CMAKE_CURRENT_SOURCE_DIR}/recastnavigation/DetourTileCache/Include
${CMAKE_CURRENT_SOURCE_DIR}/recastnavigation/DetourCrowd/Include
${CMAKE_CURRENT_SOURCE_DIR}/recastnavigation/DebugUtils/Include
${CMAKE_SOURCE_DIR}/src/FastNoiseLite
${CMAKE_SOURCE_DIR}/src/lua/lua-5.4.8/src
${CMAKE_SOURCE_DIR}/src/lua/lpeg-1.1.0
)
# ---------------------------------------------------------------------------
# Test: CellGrid door identity + per-door config (F0, headless)
# ---------------------------------------------------------------------------
# Links the full editScene sources (minus main.cpp) like scene_switch_test,
# but runs without OGRE initialization: doorway identity is pure grid math
# and the SceneSerializer round trip needs no SceneManager for the CellGrid
# component.
set(CELLGRID_DOOR_TEST_SOURCES ${EDITSCENE_SOURCES})
list(REMOVE_ITEM CELLGRID_DOOR_TEST_SOURCES main.cpp)
add_executable(cellgrid_door_test
tests/cellgrid_door_test.cpp
${CELLGRID_DOOR_TEST_SOURCES}
)
add_dependencies(cellgrid_door_test morph)
target_compile_definitions(cellgrid_door_test PRIVATE JPH_DEBUG_RENDERER)
target_link_libraries(cellgrid_door_test
OgreMain
OgreBites
OgreOverlay
OgreMeshLodGenerator
OgrePaging
OgreTerrain
flecs::flecs_static
nlohmann_json::nlohmann_json
Jolt::Jolt
OgreProcedural::OgreProcedural
RecastNavigation::Recast
RecastNavigation::Detour
RecastNavigation::DetourTileCache
RecastNavigation::DetourCrowd
RecastNavigation::DebugUtils
PackageArchive
RoadGeometryLib
lua
SDL2::SDL2
)
target_include_directories(cellgrid_door_test PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
${CMAKE_CURRENT_SOURCE_DIR}/recastnavigation/Recast/Include
${CMAKE_CURRENT_SOURCE_DIR}/recastnavigation/Detour/Include
${CMAKE_CURRENT_SOURCE_DIR}/recastnavigation/DetourTileCache/Include
${CMAKE_CURRENT_SOURCE_DIR}/recastnavigation/DetourCrowd/Include
${CMAKE_CURRENT_SOURCE_DIR}/recastnavigation/DebugUtils/Include
${CMAKE_SOURCE_DIR}/src/FastNoiseLite
${CMAKE_SOURCE_DIR}/src/lua/lua-5.4.8/src
${CMAKE_SOURCE_DIR}/src/lua/lpeg-1.1.0
)
add_test(NAME cellgridDoorTest
COMMAND cellgrid_door_test
WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR})
# ---------------------------------------------------------------------------
# Test: Global persistent variable storage (F9, headless)
# ---------------------------------------------------------------------------
# Links only the store and its Lua API: no OGRE, no flecs.
add_executable(global_state_test
tests/global_state_test.cpp
systems/GlobalStateStore.cpp
lua/LuaGlobalStateApi.cpp
)
target_link_libraries(global_state_test
lua
nlohmann_json::nlohmann_json
)
target_include_directories(global_state_test PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
${CMAKE_SOURCE_DIR}/src/lua/lua-5.4.8/src
)
add_test(NAME globalStateTest
COMMAND global_state_test
WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR})
# ---------------------------------------------------------------------------
# Test: Phase 6.8.9 planner scoring hook (headless)
# ---------------------------------------------------------------------------
add_executable(schedule_scoring_test
tests/schedule_scoring_test.cpp
systems/ScheduleScoring.cpp
roadlib/TownScheduleScore.cpp
lua/LuaScheduleScoringApi.cpp
)
target_link_libraries(schedule_scoring_test
lua
nlohmann_json::nlohmann_json
)
target_include_directories(schedule_scoring_test PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
${CMAKE_SOURCE_DIR}/src/lua/lua-5.4.8/src
)
add_test(NAME scheduleScoringTest
COMMAND schedule_scoring_test
WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR})
# ---------------------------------------------------------------------------
# Test: Phase 6.8.7 roster Lua binding (headless)
# ---------------------------------------------------------------------------
# The pure derivation (roadlib/TownRosters) + the ecs.rosters binding;
# no OGRE, no flecs — the provider pattern is exercised with a fake area.
add_executable(rosters_lua_test
tests/rosters_lua_test.cpp
roadlib/TownRosters.cpp
lua/LuaRostersApi.cpp
)
target_link_libraries(rosters_lua_test
lua
nlohmann_json::nlohmann_json
)
target_include_directories(rosters_lua_test PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
${CMAKE_SOURCE_DIR}/src/lua/lua-5.4.8/src
)
add_test(NAME rostersLuaTest
COMMAND rosters_lua_test
WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR})
# ---------------------------------------------------------------------------
# Test: Phase 6.8.9 character stat container (headless)
# ---------------------------------------------------------------------------
# Standalone store with opaque character ids; only the store and its Lua
# binding are linked (no OGRE, no flecs).
add_executable(character_stats_test
tests/character_stats_test.cpp
systems/CharacterStatStore.cpp
systems/ActivityEffectHook.cpp
lua/LuaCharacterStatsApi.cpp
)
target_link_libraries(character_stats_test
lua
nlohmann_json::nlohmann_json
)
target_include_directories(character_stats_test PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
${CMAKE_SOURCE_DIR}/src/lua/lua-5.4.8/src
)
target_compile_definitions(character_stats_test PRIVATE
CHARACTER_STATS_RULES_LUA="${CMAKE_CURRENT_SOURCE_DIR}/demos/demo-taxi/lua-scripts/character_stats_rules.lua"
)
add_test(NAME characterStatsTest
COMMAND character_stats_test
WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR})
# ---------------------------------------------------------------------------
# Test: Phase 6.7 animation travel helpers (headless)
# ---------------------------------------------------------------------------
# Pure BFS pathing + connect-link validation over the AnimationTreeLink
# model; only AnimTravel.cpp and the tree component are linked.
add_executable(animation_travel_test
tests/animation_travel_test.cpp
systems/AnimTravel.cpp
components/AnimationTree.cpp
)
target_link_libraries(animation_travel_test
OgreMain
nlohmann_json::nlohmann_json
)
target_include_directories(animation_travel_test PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
)
add_test(NAME animationTravelTest
COMMAND animation_travel_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)
# ---------------------------------------------------------------------------
# Extracted from RoadSystem.cpp; depends only on Ogre, OgreProcedural, and
# RoadGraph.hpp. No ECS, physics, or terrain dependency.
# ---------------------------------------------------------------------------
# Layout Library — building layout DSL (phase 4.5 item 2)
# ---------------------------------------------------------------------------
# Pure std library (no Ogre/ECS): S-expression layout language parser and
# evaluator producing a BuildingPlan (cells/flags, doors, rooms, roofs,
# parking) consumed by roadlib/TownGen and TownGenSystem.
add_library(LayoutLib STATIC
layoutlib/BuildingPlan.hpp
layoutlib/LayoutLang.cpp
layoutlib/LayoutLang.hpp
layoutlib/LayoutEval.cpp
layoutlib/LayoutEval.hpp
)
target_include_directories(LayoutLib PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}
)
add_library(RoadGeometryLib STATIC
roadlib/RoadGeometryLib.cpp
roadlib/RoadGeometryLib.hpp
roadlib/RoadNetworkGen.cpp
roadlib/RoadNetworkGen.hpp
roadlib/RoadPath.cpp
roadlib/RoadPath.hpp
roadlib/TownGen.cpp
roadlib/TownGen.hpp
roadlib/TownParking.cpp
roadlib/TownParking.hpp
roadlib/TownPopulation.cpp
roadlib/TownPopulation.hpp
roadlib/TownLocations.cpp
roadlib/TownLocations.hpp
roadlib/TownSchedule.cpp
roadlib/TownSchedule.hpp
roadlib/TownScheduleScore.cpp
roadlib/TownScheduleScore.hpp
roadlib/TownRosters.cpp
roadlib/TownRosters.hpp
roadlib/TownRostersWeeks.cpp
roadlib/TownRostersWeeks.hpp
roadlib/PoiDatabase.cpp
roadlib/PoiDatabase.hpp
)
target_include_directories(RoadGeometryLib PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}
)
target_link_libraries(RoadGeometryLib PUBLIC
OgreMain
OgreProcedural::OgreProcedural
nlohmann_json::nlohmann_json
LayoutLib
)
# ---------------------------------------------------------------------------
# Road Geometry Demo — standalone OGRE + ImGui app for debugging
# ---------------------------------------------------------------------------
# Takes 3 world-space points ABC and renders the wedge formed at node B
# (midpoint AB → B → midpoint BC). ImGui sliders adjust points in real-time.
add_executable(RoadGeometryDemo
road_demo/main.cpp
)
target_link_libraries(RoadGeometryDemo
RoadGeometryLib
OgreBites
OgreOverlay
OgreMain
OgreProcedural::OgreProcedural
)
target_include_directories(RoadGeometryDemo PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
)
# ---------------------------------------------------------------------------
# Road geometry overlap test — standalone self-intersection regression test
# ---------------------------------------------------------------------------
# Headless (no scene/ECS/render window): builds the demo's A-B-C wedge graph
# in several height configurations and fails if any generated slab has
# coplanar-overlapping or piercing triangle pairs. Optional args
# "Ax Ay Az Bx By Bz Cx Cy Cz" analyse a single custom configuration.
add_executable(road_geometry_overlap_test
tests/road_geometry_overlap_test.cpp
systems/CharacterStatStore.cpp
systems/StatEffectsApplier.cpp
systems/ActivityEffectHook.cpp
)
target_link_libraries(road_geometry_overlap_test
RoadGeometryLib
nlohmann_json::nlohmann_json
)
add_test(NAME roadGeometryOverlapTest
COMMAND road_geometry_overlap_test)
# ---------------------------------------------------------------------------
# Package Archive Library
# ---------------------------------------------------------------------------
@@ -722,6 +1209,16 @@ target_include_directories(PackageArchiveTest PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/package
)
# ---------------------------------------------------------------------------
# Terrain integration test (headless)
# ---------------------------------------------------------------------------
# Runs the frame-driven terrain test suite in a 1x1 hidden SDL window so it
# can be executed on build machines without a physical display.
add_test(NAME editSceneTerrainTest
COMMAND editSceneEditor --headless --run-terrain-tests=1
WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR})
set_tests_properties(editSceneTerrainTest PROPERTIES RUN_SERIAL TRUE)
# Copy local resources (materials, etc.)
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/resources")
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/resources"
@@ -750,5 +1247,23 @@ add_custom_command(TARGET editSceneEditor POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_directory
"${CMAKE_CURRENT_SOURCE_DIR}/resources"
"${CMAKE_CURRENT_BINARY_DIR}/resources"
# Test fixtures (road side-prefab test, etc.)
COMMAND ${CMAKE_COMMAND} -E copy_directory
"${CMAKE_CURRENT_SOURCE_DIR}/tests/prefabs"
"${CMAKE_CURRENT_BINARY_DIR}/tests/prefabs"
# Project runtime prefabs (PrefabSystem resolves "prefabs/..."
# relative to the executable directory)
COMMAND ${CMAKE_COMMAND} -E copy_directory
"${CMAKE_SOURCE_DIR}/prefabs"
"${CMAKE_CURRENT_BINARY_DIR}/prefabs"
COMMENT "Copying resources to editSceneEditor build directory"
)
# Demos (separate executables reusing the editScene sources)
add_subdirectory(demos/demo-lua-scene-script)
add_subdirectory(demos/demo-character-controller)
add_subdirectory(demos/demo-scene-switching)
add_subdirectory(demos/demo-scene-switching-extra)
add_subdirectory(demos/demo-interior-exterior-dynamics)
add_subdirectory(demos/demo-sokoban)
add_subdirectory(demos/demo-taxi)
@@ -0,0 +1,256 @@
# Component Architecture Improvement — Implementation Specification
This document is the working specification for removing the remaining
per-component hardcoding in `src/features/editScene`. It records what has
already been implemented and, step by step, what remains.
## Goal
Adding a new component to the editor should require **one** place to describe
the component (name, group, editor, add/remove, render, serialize, Lua) instead
of editing six or seven files with scattered special-case code.
## Status
| Step | Description | Status |
|------|-------------|--------|
| A | Type-erased editor renderer (`ComponentRenderer`) | Done |
| B | Unified registration (extract hardcoded components into `*Module.cpp`) | Done |
| C | Serialization hooks (drive `SceneSerializer` from the registry) | To do |
| D | Lifecycle hooks (hierarchy badges, delete/duplicate cleanup) | To do |
| E | Unified Lua component table (single source of truth) | To do |
| F | Flecs meta reflection for auto-generated editors/serde (optional) | To do |
---
## What is already done (A + B)
### A — Type-erased renderer
`ui/ComponentRegistry.hpp` now stores a `ComponentRenderer` closure per
component in `ComponentInfo`:
```cpp
struct ComponentInfo {
const char *name;
const char *group;
int order = 0; // lower renders first; Transform = -1
std::unique_ptr<IComponentEditor> editor;
ComponentRenderer renderer; // type-erased render
ComponentAdder adder;
ComponentRemover remover;
ComponentChecker checker;
ComponentModified onModified; // optional post-render side effect
};
```
`registerComponent<T>()` builds `renderer` from the (templated) type, handling
empty/tag components via `std::is_empty_v<T>` so `get_mut<T>()` is never called
on a zero-sized type.
`EditorUISystem::renderComponentList()` is now a generic loop: it collects the
registered components present on the entity, sorts them by `(order, name)`, and
invokes each `renderer`; if the renderer returns `true` it also runs the
optional `onModified` hook.
### B — Unified registration
The six previously hardcoded components were extracted into module files that
use the same `REGISTER_COMPONENT_GROUP` mechanism as every other component:
- `components/TransformModule.cpp`
- `components/RenderableModule.cpp`
- `components/RigidBodyModule.cpp`
- `components/PhysicsColliderModule.cpp`
- `components/PrefabInstanceModule.cpp`
- `components/CharacterIdentityModule.cpp`
`EditorUISystem::registerComponentEditors()` was removed; the constructor now
calls `registerModularComponents()` directly, which invokes
`ComponentRegistration::registerAll()`.
Cross-component post-render side effects were moved into `onModified` hooks
co-located with each component's registration:
- Transform → marks `StaticGeometryMemberComponent` dirty.
- Sun / Skybox / WaterPlane / Room / ClearArea → `markDirty()`.
- NavMesh → syncs `debugDraw` with `NavMeshSystem`.
---
## Suggestion C — Serialization hooks
**Problem.** `systems/SceneSerializer.cpp` (~4366 lines) hardcodes a
`serializeXxx`/`deserializeXxx` method pair per component plus two giant
`if (entity.has<T>())` / `if (json.contains("key"))` dispatch chains in
`serializeEntity()` and `deserializeEntityComponents()`. Adding a component
today still requires edits to the serializer.
**Design.** Extend `ComponentInfo` (or a parallel serde table) with two closures
and drive the serializer from the registry:
```cpp
using ComponentSerialize = std::function<nlohmann::json(flecs::entity)>;
using ComponentDeserialize = std::function<void(flecs::entity, const nlohmann::json&)>;
struct ComponentInfo {
...
ComponentSerialize serialize;
ComponentDeserialize deserialize;
ComponentModified postLoad; // second-pass reference resolution
};
```
The serializer then:
1. In `serializeEntity`, iterate registered components, call `serialize(e)` for
those that have it, and write the result under a per-component JSON key.
2. In `deserializeEntityComponents`, for each key in the entity JSON that
matches a registered component, call `deserialize(e, value)`.
3. Keep the two-pass reference resolution (material references,
procedural-texture references, character spawner) as `postLoad` hooks that
run after all entities are loaded.
**Steps.**
1. Add the serde type aliases + fields to `ComponentRegistry.hpp` (keep the
`nlohmann::json` dependency out of the header if possible; prefer a
`void*`/`std::function` boundary or move the serde table into
`SceneSerializer` so the registry stays UI-only).
2. Migrate simple components first (Transform, Renderable, RigidBody,
Collider, Light, Camera, Lod, etc.) — move their `serialize/deserialize`
bodies from `SceneSerializer.cpp` into their `*Module.cpp` registration.
3. Leave complex components (Town/District/Lot, ProceduralMaterial,
CharacterSpawner, NavMesh) as explicit overrides initially; the registry is
a fallback. Then migrate them one at a time, adding `postLoad` where the
current two-pass logic lives.
4. Remove the now-dead `serializeXxx`/`deserializeXxx` declarations from
`SceneSerializer.hpp` and their dispatch blocks from `.cpp`.
**Acceptance criteria.**
- `serializeEntity` / `deserializeEntityComponents` contain no
`if (entity.has<T>())` component dispatch (or only a tiny, documented set of
explicit overrides for two-pass components).
- Round-trip save/load tests still pass (see `tests/` and
`docs/SaveLoadSystem.md`).
- Adding a new serializable component touches only its `*Module.cpp`.
---
## Suggestion D — Lifecycle hooks
**Problem.** Three UI hot-spots still enumerate components by hand:
1. `EditorUISystem::renderEntityNode()` — ~26 hardcoded hierarchy badge lines
(`indicators += " [T]"`).
2. `EditorUISystem::deleteEntity()` — hardcoded cleanup for RigidBody
(physics body), Transform (scene node), Renderable (Ogre entity).
3. `EditorUISystem::duplicateEntity()` — hardcoded copy logic for name,
transform, renderable.
**Design.** Add nullable hooks to `ComponentInfo` (following the `onModified`
pattern already established):
```cpp
struct ComponentInfo {
...
std::function<std::string(flecs::entity)> badge; // hierarchy indicator
std::function<void(flecs::entity)> onDelete; // cleanup on delete
std::function<void(flecs::entity, flecs::entity)> onDuplicate; // copy
};
```
**Steps.**
1. Add the fields + optional parameters to `registerComponent<T>()` (or a
post-registration setter to avoid growing the signature further).
2. Move the badge strings into each `*Module.cpp` (Transform `[T]`, Renderable
`[R]`, Light `[L]`, …). `renderEntityNode` then appends
`info.badge(entity)` for each component the entity has.
3. Move `deleteEntity` cleanup into `onDelete` hooks (RigidBody removes its
physics body, Transform destroys its scene node, Renderable destroys its
Ogre entity). `deleteEntity` becomes a generic "invoke onDelete for every
registered component present, then destruct".
4. Move `duplicateEntity` copy logic into `onDuplicate` hooks, or — for most
components — a generic `entity.set<T>(other.get<T>())` copy that falls back
to a hook for components with non-trivial ownership.
**Acceptance criteria.**
- `renderEntityNode`, `deleteEntity`, `duplicateEntity` contain no
`if (entity.has<T>())` component dispatch.
- Deleting/duplicating an entity with a renderable + rigid body + transform
behaves identically to before.
---
## Suggestion E — Unified Lua component table
**Problem.** `lua/LuaComponentApi.cpp` maintains its own `s_components` map and
its own `REGISTER_COMPONENT` macro with hand-written push/read field lambdas —
a third independent list of components.
**Design.** Make one shared descriptor the single source of truth. The editor
registry, the serializer (C) and the Lua binder (E) all read name/group/fields
from it.
**Steps.**
1. Introduce a `ComponentTraits<T>` (or `ComponentDescriptor`) struct in a
neutral header (not UI-, not Lua-specific) holding at least `name`, `group`,
and optional `luaPush` / `luaRead` closures (or field-table reflection).
2. Have the `REGISTER_COMPONENT_GROUP` macro (or a new unified macro) populate
both the `ComponentRegistry` entry and the Lua `s_components` entry from the
same traits.
3. Migrate the existing hand-written `REGISTER_COMPONENT(...)` entries in
`LuaComponentApi.cpp` to the shared traits one component at a time, keeping
`lua-examples/` and `tests/*_lua_test.cpp` green.
**Acceptance criteria.**
- There is exactly one place that lists a component's name/group/serde/Lua
behaviour.
- `component_lua_test` and the other `*_lua_test` executables still pass.
---
## Suggestion F — Flecs meta reflection (optional, long term)
**Problem.** Even after C/D/E, "dumb data" components still need a hand-written
editor + serializer + Lua binding.
**Design.** Register plain-data components with Flecs meta (`flecs::meta`) and
generate the ImGui editor, JSON serde and Lua accessor from the reflected
fields. Keep custom editors only for behaviour-heavy components (Camera
preview, NavMesh, CharacterSlots, etc.).
**Steps.**
1. Add `flecs::meta` to the project and register a pilot component with it.
2. Write a generic `MetaComponentEditor<T>` that walks `flecs::meta` cursor
fields and renders an ImGui widget per field.
3. Write a generic meta-based serializer (or use the built-in `flecs::json`
exporter) and a meta-based Lua binding.
4. Migrate the simplest components; validate against existing round-trip tests.
**Acceptance criteria.**
- A new plain-data component can be added without writing an editor, a
serializer, or a Lua binding.
- OGRE math types (`Ogre::Vector3`, `Quaternion`, `ColourValue`) have registered
meta type handlers so they render and serialize correctly.
---
## Conventions and caveats
- Keep `order` on `ComponentInfo` for deterministic render order; only Transform
needs a non-zero value today. Do not rely on `unordered_map` iteration order.
- Empty/tag components must render via a stack-local dummy (handled by
`std::is_empty_v` in the renderer). Do not call `get_mut<T>()` on them.
- Two-pass scene load (material/character/procedural-texture references) must
stay explicit; fold it into a `postLoad` hook, never into a single pass.
- The source code is the source of truth. Update `AGENTS.md` ("Adding a New
Component"), `docs/`, and `lua-examples/` whenever the registration API
changes.
File diff suppressed because it is too large Load Diff
+378 -3
View File
@@ -8,11 +8,21 @@
#include <OgreImGuiOverlay.h>
#include <OgreRenderTargetListener.h>
#include <flecs.h>
#include <map>
#include <memory>
#include <string>
#include <utility>
#include "lua/LuaState.hpp"
#include "ProjectConfig.hpp"
// Forward declarations
class EditorUISystem;
class VehicleSystem;
class AiDriverSystem;
class ZoneSystem;
class PoiSystem;
class GameHudSystem;
class NpcScheduleDebugOverlay;
class EditorCamera;
class EditorPhysicsSystem;
class EditorLightSystem;
@@ -23,7 +33,17 @@ class ProceduralTextureSystem;
class ProceduralMaterialSystem;
class ProceduralMeshSystem;
class CharacterSlotSystem;
class CharacterSpawnerSystem;
class NpcPopulationSystem;
class NpcMotionSystem;
class NpcDetailSystem;
class NpcTaxiSystem;
class NpcAvoidanceSystem;
class NpcTrafficSystem;
class GameClockSystem;
class TerrainPrefabSpawnerSystem;
class AnimationTreeSystem;
class HairPhysicsSystem;
class BehaviorTreeSystem;
class NavMeshSystem;
class CharacterSystem;
@@ -33,16 +53,21 @@ class StartupMenuSystem;
class PauseMenuSystem;
class DialogueSystem;
class PlayerControllerSystem;
class VehicleControllerSystem;
class BuoyancySystem;
class EditorSunSystem;
class EditorSkyboxSystem;
class EditorWaterPlaneSystem;
class NormalDebugSystem;
class TerrainSystem;
class RenderOriginSystem;
class SmartObjectSystem;
class GoapRunnerSystem;
class PathFollowingSystem;
class GoapPlannerSystem;
class ActuatorSystem;
class DoorSystem;
class StandaloneDoorSystem;
class EventHandlerSystem;
class ItemSystem;
class CharacterClassSystem;
@@ -61,9 +86,13 @@ struct GameInputState {
bool e = false;
bool f = false;
bool i = false;
bool r = false;
bool space = false;
bool ePressed = false;
bool fPressed = false;
bool iPressed = false;
bool rPressed = false;
bool spacePressed = false;
float mouseDeltaX = 0.0f;
float mouseDeltaY = 0.0f;
bool mouseMoved = false;
@@ -76,6 +105,17 @@ struct GameInputState {
ePressed = false;
fPressed = false;
iPressed = false;
rPressed = false;
spacePressed = false;
}
void clearMovementInput()
{
w = false;
a = false;
s = false;
d = false;
shift = false;
}
};
@@ -110,6 +150,25 @@ private:
int m_lastBatchCount = 0;
};
/**
* Options for EditorApp::switchScene(). Positions are absolute
* world-space doubles (see systems/RenderOriginSystem).
*/
struct SceneSwitchOptions {
/* Explicit destination for the player character (world space). */
bool hasPosition = false;
double posX = 0.0;
double posY = 0.0;
double posZ = 0.0;
bool hasRotation = false;
Ogre::Quaternion rotation = Ogre::Quaternion::IDENTITY;
/* Name of an entity with a TransformComponent in the NEW scene.
* When set, the player character is teleported to that entity's
* transform (takes precedence over position/rotation). */
Ogre::String targetEntityName;
};
/**
* Main application class for the scene editor / game
*/
@@ -119,18 +178,31 @@ public:
enum class GameMode { Editor, Game };
enum class GamePlayState { Menu, Playing, Paused };
EditorApp();
EditorApp(const Ogre::String &appName = "EditSceneEditor");
virtual ~EditorApp();
// OgreBites::ApplicationContext overrides
void setup() override;
bool frameStarted(const Ogre::FrameEvent &evt) override;
bool frameRenderingQueued(const Ogre::FrameEvent &evt) override;
bool frameEnded(const Ogre::FrameEvent &evt) override;
void locateResources() override;
OgreBites::NativeWindowPair
createWindow(const Ogre::String &name, uint32_t w, uint32_t h,
Ogre::NameValuePairList miscParams) override;
void shutdownEditor();
void closeApp();
/* Pin the per-frame delta (seconds) for deterministic headless
* tests; 0 (default) uses the wall-clock delta. */
void setFixedDeltaTime(float dt) { m_fixedDeltaTime = dt; }
// OgreBites::InputListener overrides
bool mouseMoved(const OgreBites::MouseMotionEvent &evt) override;
bool mousePressed(const OgreBites::MouseButtonEvent &evt) override;
bool mouseReleased(const OgreBites::MouseButtonEvent &evt) override;
bool mouseWheelRolled(const OgreBites::MouseWheelEvent &evt) override;
bool keyPressed(const OgreBites::KeyboardEvent &evt) override;
bool keyReleased(const OgreBites::KeyboardEvent &evt) override;
@@ -156,19 +228,81 @@ public:
{
return m_debugBuoyancy;
}
// Headless mode (no visible window, no editor UI)
void setHeadless(bool headless);
bool isHeadless() const
{
return m_headless;
}
GamePlayState getGamePlayState() const
{
return m_gamePlayState;
}
void setGamePlayState(GamePlayState state);
/** Apply the game-mode mouse grab for the current state: grabbed
* only while Playing with no mouse-owning UI open (F7 NPC schedule
* overlay, actuator menus). Change-tracked; safe to call every
* frame. */
void syncGameMouseGrab();
void startNewGame(const Ogre::String &scenePath);
void clearScene();
/**
* Queue a switch to another scene, executed at the start of the
* next frame. In editor mode the current scene is completely
* destroyed and the new one loaded as if the editor was restarted.
* In game mode the player controller and camera are carried over
* when the new scene has none of its own; otherwise the new
* scene's controller/camera take over. opts can teleport the
* player character to a world-space position/rotation or to a
* named entity with a TransformComponent in the new scene.
* Returns false only for obvious errors (empty/unreadable path);
* load errors are reported via the log.
*/
bool switchScene(const Ogre::String &scenePath,
const SceneSwitchOptions &opts = SceneSwitchOptions{});
/* Introspection/cancel for a queued scene switch (tests, debug). */
bool hasPendingSceneSwitch() const
{
return m_sceneSwitchPending;
}
const Ogre::String &getPendingSceneSwitchPath() const
{
return m_pendingSceneSwitchPath;
}
const SceneSwitchOptions &getPendingSceneSwitchOptions() const
{
return m_pendingSceneSwitchOptions;
}
void clearPendingSceneSwitch()
{
m_sceneSwitchPending = false;
}
// Save / Load
void saveGame(const std::string &slotPath,
const std::string &slotName);
void saveGame(const std::string &slotPath, const std::string &slotName);
void loadGame(const std::string &slotPath);
// Project directory (F8)
void setProjectConfig(const ProjectConfig &config);
const ProjectConfig &getProjectConfig() const
{
return m_projectConfig;
}
const std::string &getProjectRoot() const
{
return m_projectConfig.rootDir;
}
/**
* Open a project directory at runtime (editor File -> Open
* Project...): chdir()s into it, loads its project.json, switches
* the save directory to the project's appName and clears the
* current scene. Returns false when the directory does not exist.
*/
bool openProject(const std::string &dir);
// Input access
GameInputState &getGameInputState()
{
@@ -177,10 +311,29 @@ public:
// Getters
flecs::entity getSelectedEntity() const;
/**
* Return the live player character entity that is currently controlled by
* the "player" controller. If the controller targets a character spawner,
* this returns the spawned character instance, forcing a spawn if necessary.
* Returns null if no player controller or target exists.
*/
flecs::entity getPlayerCharacterEntity();
Ogre::SceneManager *getSceneManager() const
{
return m_sceneMgr;
}
/* Per-frame time split (see the m_last*Ms members): the render
* pass (scene graph update, cull, submit), the CPU systems in
* frameRenderingQueued, and the swap/vsync wait. */
float getLastRenderPassMs() const { return m_lastRenderPassMs; }
float getLastSystemsMs() const { return m_lastSystemsMs; }
float getLastSwapWaitMs() const { return m_lastSwapWaitMs; }
/* Slowest profiled step of the last frame (see m_lastSpikeStep*). */
float getLastSpikeStepMs() const { return m_lastSpikeStepMs; }
const std::string &getLastSpikeStep() const { return m_lastSpikeStep; }
flecs::world *getWorld()
{
return &m_world;
@@ -189,6 +342,10 @@ public:
{
return m_camera.get();
}
RenderOriginSystem *getRenderOriginSystem() const
{
return m_renderOriginSystem.get();
}
AnimationTreeSystem *getAnimationTreeSystem() const
{
return m_animationTreeSystem.get();
@@ -197,6 +354,95 @@ public:
{
return m_characterSlotSystem.get();
}
CharacterSpawnerSystem *getCharacterSpawnerSystem() const
{
return m_characterSpawnerSystem.get();
}
NpcPopulationSystem *getNpcPopulationSystem() const
{
return m_npcPopulationSystem.get();
}
NpcMotionSystem *getNpcMotionSystem() const
{
return m_npcMotionSystem.get();
}
NpcDetailSystem *getNpcDetailSystem() const
{
return m_npcDetailSystem.get();
}
NpcTaxiSystem *getNpcTaxiSystem() const
{
return m_npcTaxiSystem.get();
}
NpcTrafficSystem *getNpcTrafficSystem() const
{
return m_npcTrafficSystem.get();
}
NpcAvoidanceSystem *getNpcAvoidanceSystem() const
{
return m_npcAvoidanceSystem.get();
}
GameClockSystem *getGameClockSystem() const
{
return m_gameClockSystem.get();
}
PlayerControllerSystem *getPlayerControllerSystem() const
{
return m_playerControllerSystem.get();
}
CharacterSystem *getCharacterSystem() const
{
return m_characterSystem.get();
}
VehicleControllerSystem *getVehicleControllerSystem() const
{
return m_vehicleControllerSystem.get();
}
VehicleSystem *getVehicleSystem() const
{
return m_vehicleSystem.get();
}
AiDriverSystem *getAiDriverSystem() const
{
return m_aiDriverSystem.get();
}
ZoneSystem *getZoneSystem() const
{
return m_zoneSystem.get();
}
PoiSystem *getPoiSystem() const
{
return m_poiSystem.get();
}
GameHudSystem *getGameHudSystem() const
{
return m_gameHudSystem.get();
}
NpcScheduleDebugOverlay *getNpcScheduleDebugOverlay() const
{
return m_npcScheduleDebugOverlay.get();
}
/* Shared Lua state (tests/tools; scripts access it via the ecs API). */
editScene::LuaState *getLuaState()
{
return &m_lua;
}
EditorPhysicsSystem *getEditorPhysicsSystem() const
{
return m_physicsSystem.get();
}
TerrainPrefabSpawnerSystem *getTerrainPrefabSpawnerSystem() const
{
return m_terrainPrefabSpawnerSystem.get();
}
ProceduralMeshSystem *getProceduralMeshSystem() const
{
return m_proceduralMeshSystem.get();
}
EditorLodSystem *getLodSystem() const
{
return m_lodSystem.get();
}
StartupMenuSystem *getStartupMenuSystem() const
{
return m_startupMenuSystem.get();
@@ -207,6 +453,11 @@ public:
{
return m_actuatorSystem.get();
}
/* Draws the fullscreen "Loading" cover shown for a few frames
* after a game-mode scene switch (hides T-posing characters and
* half-built content). Called from ImGuiRenderListener. */
void renderSceneSwitchCover();
EventHandlerSystem *getEventHandlerSystem() const
{
return m_eventHandlerSystem.get();
@@ -219,6 +470,22 @@ public:
{
return m_pregnancySystem.get();
}
NavMeshSystem *getNavMeshSystem() const
{
return m_navMeshSystem.get();
}
RoomLayoutSystem *getRoomLayoutSystem() const
{
return m_roomLayoutSystem.get();
}
CellGridSystem *getCellGridSystem() const
{
return m_cellGridSystem.get();
}
DoorSystem *getDoorSystem() const
{
return m_doorSystem.get();
}
Ogre::ImGuiOverlay *getImGuiOverlay() const
{
return m_imguiOverlay;
@@ -243,6 +510,8 @@ private:
std::unique_ptr<EditorSunSystem> m_sunSystem;
std::unique_ptr<EditorSkyboxSystem> m_skyboxSystem;
std::unique_ptr<EditorWaterPlaneSystem> m_waterPlaneSystem;
std::unique_ptr<TerrainSystem> m_terrainSystem;
std::unique_ptr<RenderOriginSystem> m_renderOriginSystem;
std::unique_ptr<EditorLightSystem> m_lightSystem;
std::unique_ptr<EditorCameraSystem> m_cameraSystem;
std::unique_ptr<EditorLodSystem> m_lodSystem;
@@ -251,7 +520,17 @@ private:
std::unique_ptr<ProceduralMaterialSystem> m_proceduralMaterialSystem;
std::unique_ptr<ProceduralMeshSystem> m_proceduralMeshSystem;
std::unique_ptr<CharacterSlotSystem> m_characterSlotSystem;
std::unique_ptr<CharacterSpawnerSystem> m_characterSpawnerSystem;
std::unique_ptr<NpcPopulationSystem> m_npcPopulationSystem;
std::unique_ptr<NpcMotionSystem> m_npcMotionSystem;
std::unique_ptr<NpcDetailSystem> m_npcDetailSystem;
std::unique_ptr<NpcTaxiSystem> m_npcTaxiSystem;
std::unique_ptr<NpcTrafficSystem> m_npcTrafficSystem;
std::unique_ptr<NpcAvoidanceSystem> m_npcAvoidanceSystem;
std::unique_ptr<GameClockSystem> m_gameClockSystem;
std::unique_ptr<TerrainPrefabSpawnerSystem> m_terrainPrefabSpawnerSystem;
std::unique_ptr<AnimationTreeSystem> m_animationTreeSystem;
std::unique_ptr<HairPhysicsSystem> m_hairPhysicsSystem;
std::unique_ptr<BehaviorTreeSystem> m_behaviorTreeSystem;
std::unique_ptr<NavMeshSystem> m_navMeshSystem;
std::unique_ptr<CharacterSystem> m_characterSystem;
@@ -263,6 +542,8 @@ private:
std::unique_ptr<PathFollowingSystem> m_pathFollowingSystem;
std::unique_ptr<GoapPlannerSystem> m_goapPlannerSystem;
std::unique_ptr<ActuatorSystem> m_actuatorSystem;
std::unique_ptr<DoorSystem> m_doorSystem;
std::unique_ptr<StandaloneDoorSystem> m_standaloneDoorSystem;
std::unique_ptr<EventHandlerSystem> m_eventHandlerSystem;
std::unique_ptr<ItemSystem> m_itemSystem;
std::unique_ptr<CharacterClassSystem> m_characterClassSystem;
@@ -271,17 +552,111 @@ private:
// Game systems
std::unique_ptr<StartupMenuSystem> m_startupMenuSystem;
std::unique_ptr<PlayerControllerSystem> m_playerControllerSystem;
/* F10 vehicles: Jolt VehicleConstraint per VehicleComponent. */
std::unique_ptr<VehicleSystem> m_vehicleSystem;
/* AI-driven vehicles (demo-taxi phase 5.1): writes the same
* VehicleComponent input fields the player controller writes. */
std::unique_ptr<AiDriverSystem> m_aiDriverSystem;
std::unique_ptr<ZoneSystem> m_zoneSystem;
std::unique_ptr<PoiSystem> m_poiSystem;
std::unique_ptr<GameHudSystem> m_gameHudSystem;
/* F7 NPC schedule debug overlay (demo-taxi phase 4.7.4). */
std::unique_ptr<NpcScheduleDebugOverlay> m_npcScheduleDebugOverlay;
/* F10 enter/exit + drive input for vehicles (game mode). */
std::unique_ptr<VehicleControllerSystem> m_vehicleControllerSystem;
// State
uint16_t m_currentModifiers;
GameMode m_gameMode = GameMode::Editor;
GamePlayState m_gamePlayState = GamePlayState::Menu;
/* Last grab state applied by syncGameMouseGrab (-1 = none yet). */
int m_gameGrabApplied = -1;
GameInputState m_gameInput;
bool m_setupComplete = false;
bool m_systemsDestroyed = false;
bool m_debugBuoyancy = false;
bool m_headless = false;
/* Project directory (F8): empty rootDir = plain session in the
* binary directory. */
ProjectConfig m_projectConfig;
void destroyEditorSystems();
float m_playTime = 0.0f;
std::string m_currentBaseScene;
/* Scene switch state. switchScene() only queues a request; the
* actual teardown + load runs at the top of the next
* frameRenderingQueued() so callers (Lua scripts, ImGui menus)
* never mutate the world mid-frame. In game mode the pending
* switch additionally waits one full loop iteration after the
* loading cover is raised (m_sceneSwitchWaitPresent), because
* OGRE presents a frame only after frameRenderingQueued. */
bool m_sceneSwitchPending = false;
Ogre::String m_pendingSceneSwitchPath;
SceneSwitchOptions m_pendingSceneSwitchOptions;
/* Grounding watchdog: after a game-mode scene switch the terrain
* physics colliders may not exist yet; keep the player character
* at or above the terrain surface until it reports a floor. */
flecs::entity_t m_groundingEntity = 0;
int m_groundingFramesLeft = 0;
/* Fullscreen loading cover shown after a game-mode scene switch
* (frames remaining). Hides freshly spawned characters before
* their animation state is applied (T-pose) and any content that
* takes a few frames to build. */
int m_sceneSwitchCoverFrames = 0;
static const int SCENE_SWITCH_COVER_FRAMES = 45;
/* When true, a queued game-mode switch waits one more loop
* iteration before executing: OGRE presents (swaps) a frame only
* AFTER frameRenderingQueued, so the covered frame submitted in
* this iteration's render pass reaches the screen at the end of
* this iteration and the load must not run in the same iteration
* it was raised in (the last presented frame would stay the old
* scenery through the entire hitch). */
bool m_sceneSwitchWaitPresent = false;
/* When > 0, frameRenderingQueued uses this fixed delta instead of
* the wall-clock delta (headless test determinism). */
float m_fixedDeltaTime = 0.0f;
/* EDITSCENE_PROFILE=1 accumulation: step name -> (total ms,
* sample count) over the last 600 frames (see
* frameRenderingQueued). */
std::map<std::string, std::pair<double, long> > m_profileAccum;
long m_profileFrames = 0;
/* Per-frame time split, recorded unconditionally (a few timer
* reads per frame): frameStarted -> frameRenderingQueued entry is
* the render pass (scene graph update, cull, draw submission);
* the frameRenderingQueued body is the CPU systems; its end ->
* frameEnded is the swap/vsync wait (where a GPU-bound frame
* hides). The EDITSCENE_PROFILE dump prints 600-frame averages
* including these spans plus OGRE render stats; benchmarks read
* the per-frame getters. */
Ogre::Timer m_profTimer;
uint64_t m_profFrameStartUs = 0;
uint64_t m_profQueuedEndUs = 0;
float m_lastRenderPassMs = 0.0f;
float m_lastSystemsMs = 0.0f;
float m_lastSwapWaitMs = 0.0f;
/* Per-frame spike attribution: the slowest profiled step of the
* last frameRenderingQueued (benchmark CSVs correlate FPS drops
* with the responsible system). */
float m_lastSpikeStepMs = 0.0f;
std::string m_lastSpikeStep;
double m_profRenderAccum = 0.0;
double m_profSwapAccum = 0.0;
double m_profTrisAccum = 0.0;
double m_profBatchesAccum = 0.0;
void processPendingSceneSwitch();
bool performSceneSwitch(const Ogre::String &scenePath,
const SceneSwitchOptions &opts);
void destroySceneEntities();
void setupPlayerCharacter();
// Lua scripting
editScene::LuaState m_lua;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,454 @@
# Procedural Road Geometry — Improvement Plan
Procedural road geometry is specified in `ProceduralRoadGeometry.md`; the road
milestones and their status live in `TerrainRequirements.md` (Milestone 5,
M5.1–M5.12). This plan covers three improvements:
1. Edge splitting: create a node in the middle of an edge, remove the edge and
create two edges through the new node (A–B → A–C–B).
2. Sidewalks: an elevated extra lane where vehicles do not go, intended for
pedestrians, enabled by checkbox, with selectable template geometry (same
mechanism as the normal lane template) and a procedural-box fallback.
3. Road prefabs: per-edge left/right side prefabs plus one road-midpoint
prefab, with distance-based unload, edit-time preview, easy prefab
selection and leak-free resource handling.
4. It should be somehow possible to select two nodes on the graph and create
new edge.
5. It should be possible to select 3 neighboring nodes on the graph and smooth
their position (position relaxation, no graph mutation — see §5).
Each section first states what already exists (with code references — the
source is the only source of truth) and then defines the remaining work.
---
## 1. Edge splitting (A–B → A–C–B)
**Status (2026-08-18): ✅ DONE** — per-row "Split" `SmallButton` in the
edge list (toolbar button removed), slot remap in `splitEdge`, editor-layer
Y snap of the new node, `testRoadDataModel` extended. Verified by the
terrain test suite (`--headless --run-terrain-tests=1`).
### Current state
The core operation already exists and is exposed in the editor:
- `RoadGraph::splitEdge(edgeIndex, t)` (`components/RoadGraph.hpp:523`) removes
the edge, inserts node C at `lerp(A, B, t)` and pushes two new edges A–C and
C–B. C is snapped to integer half-edge lengths per M5.7
(`snapToIntegerLength`), and `verticalOffset`/`roadLevel` are interpolated so
the road surface stays continuous.
- UI: "Split Selected Edge" button in the Terrain panel's road-section
toolbar (`ui/TerrainEditor.hpp:733`) — hardcoded `t = 0.5`, i.e. exactly
the requested middle split. The interaction is counter-intuitive: the
toolbar is overloaded, and splitting takes two steps — select the edge in
the edge list, then find the toolbar button.
- Tests: `testRoadDataModel` (`systems/TerrainTests.cpp:1334`) and
`testRoadEdgeLengthConstraint` (`:2986–3038`).
### Remaining work
- **1.1 Move splitting into the edge list.** The edge list
(`ui/TerrainEditor.hpp:864-891`) renders one `Selectable` row per edge
(with a "Remove" `SmallButton` on the selected row), and the node list
already shows the per-row action pattern with its "Connect"
`SmallButton`s (`:835-860`). Give *every* edge row its own "Split"
`SmallButton` (placed `SameLine` after the row label, like "Connect") that
splits that edge at `t = 0.5` — one click, no prior selection, and the
overloaded toolbar shrinks accordingly: delete the toolbar's "Split
Selected Edge" button (`:733-738`). After the split, select the new node
so the gizmo and node inspector follow it.
- **1.2 Prefab carry-over on split (bug).** `splitEdge` copies the old edge
struct into both new edges, so `sidePrefabs` (and later the §3 slots) are
*duplicated*: both halves spawn the same prefabs. Remap instead: prefab
`edgeT < actualT` stays on edge A–C with `edgeT' = edgeT / actualT`;
otherwise it moves to C–B with `edgeT' = (edgeT - actualT) / (1 - actualT)`
(`actualT` is the post-snap position already computed in `splitEdge`).
Left/right side semantics are preserved because both halves keep the A→B
orientation. With the §3 slot model: the left/right slots remap their
`edgeT` this way on both halves; the mid slot transfers to the half that
contains the original midpoint, the other half gets an empty slot.
- **1.3 New-node Y snap.** `RoadGraph` is terrain-agnostic, so `splitEdge`
only interpolates Y (see the comment at `RoadGraph.hpp:561`). After a UI
split, snap C's Y to `terrainHeight(x, z) + verticalOffset` in the editor
layer, the same way `addRoadNodeAt` does
(`systems/EditorUISystem.cpp:2303`).
- **1.4 Tests.** Extend `testRoadDataModel`: split an edge carrying prefab
slots and assert remapped `edgeT`/side on both halves and that no prefab is
duplicated; assert road-surface continuity at C (levels from both halves
match).
---
## 2. Sidewalks
**Status (2026-08-18): ✅ DONE** — data model, widened-chain geometry
(see the corrected §2.2 mapping below), template + fallback, terrain
compliance, editor UI and tests; specified in
`ProceduralRoadGeometry.md` §15. Verified by `testRoadWedgeGeometry`
(new sidewalk case), `testRoadSerialization` and
`road_geometry_overlap_test` (sidewalks enabled in all regression
configs).
New feature — nothing existed before (no mentions in code or docs).
### Requirement mapping
"Extra lane where vehicles will not go": sidewalks are pure geometry appended
to the road slab; lane counts (`resolveLaneCounts`) are unchanged, so the
drivable width — and everything derived from it — is unaffected. Pedestrian
routing over sidewalks is crowd/navmesh integration and is out of scope here.
### 2.1 Data model (`components/RoadGraph.hpp`, `RoadConfig`)
Global per-graph settings, next to `roadMeshTemplate`:
```cpp
bool sidewalkEnabled = false; // the checkbox
float sidewalkWidth = 1.5f; // lateral width in world units
float sidewalkHeight = 0.15f; // top elevation above road surface
float sidewalkThickness = 0.3f; // fallback box thickness
std::string sidewalkMeshTemplate; // empty = procedural box
```
Serialization goes into the existing `roadConfig` block
(`systems/SceneSerializer.cpp:4233` write, `:4341` read) with the defaults
above, so old scenes load with sidewalks disabled. Per-edge opt-out flags are
a possible later extension and are deliberately not part of this plan.
### 2.2 Geometry (`roadlib/RoadGeometryLib.cpp`)
One sidewalk strip per wedge along its outer curb (each wedge is one lateral
half of the road, so per-wedge strips automatically produce both sides and
continuous corner sidewalks), built with the same three-phase pipeline:
- **Template** — new `RoadSystem::getSidewalkTemplate(cfg)` mirroring
`getRoadTemplate` (`systems/RoadSystem.cpp`), same template-space
conventions (`ProceduralRoadGeometry.md` §2), cached like
`m_templateBuffer`. Empty/missing `sidewalkMeshTemplate` yields a
procedural box of `sidewalkWidth × sidewalkThickness` profile.
- **Phase 1** — identical concatenation (`N = ceil(L1 + L2)` copies).
- **Phase 2 mapping** — the strip starts at the curb and extends outward.
The originally planned per-vertex extension
`worldXZ = center(d) + offset(d) + normalize(offset(d)) * (vx * sidewalkWidth + ROAD_SIDEWALK_WALL_GAP)`
**folds at inner corners** (proven numerically during implementation):
cross-sections pivot around the pinned miter corner K while the curb
chain transitions between half-edges, so consecutive strip rows cross
near K — the overlap test reported coplanar/piercing triangles and
inverted normals at K. The shipped mapping instead interpolates
between two *widened* curb-offset chains:
```
offIn(d) = curbOffsetWidened(d, ROAD_SIDEWALK_WALL_GAP)
offOut(d) = curbOffsetWidened(d, ROAD_SIDEWALK_WALL_GAP + sidewalkWidth)
worldXZ = center(d) + offIn(d) + (offOut(d) - offIn(d)) * vx
worldY = roadSurfaceY(d) + sidewalkHeight + vy
```
`curbOffsetWidened` scales offA/offB by `(len + widen) / len` before
the miter math, so the inner and outer sidewalk curbs get their own
pinned miter corners and each keeps the §5.4 no-fold property
independently (gap/fold-freedom is inherited by construction, not by
ray-sharing). Public `computeCurbOffset` is a wrapper with
`widen = 0` — road geometry unchanged. UVs:
`u = halfEdgeU(...)` (phase-continuous, same as road), `v` across
`[0, sidewalkWidth]`.
- **Z-fighting** — the sidewalk inner wall is coplanar with the road curb
wall over a small Y band. `ROAD_SIDEWALK_WALL_GAP` (~2 mm) shifts the whole
sidewalk strip slightly outward so the walls never share a plane; the top
surfaces overlap horizontally, so no hole is visible.
- **Straight segments** (dead ends, §7 of the spec) get *two* sidewalk bands
(inbound and outbound curb), each elevated and extruded via the existing
`extrudeToSlab`.
- The strips are appended to the same page `TriangleBuffer` as the road, so
LOD/visibility distance, the M5.9 collider soup and `markNavMeshDirty()`
need no changes. Sidewalks initially share the road material; a separate
material (separate buffer/entity per page) is out of scope.
### 2.3 Terrain compliance
`RoadSystem::complyTerrain` writes fixups under every buffer vertex, so
sidewalk vertices are flattened automatically. Only the falloff origin moves:
pass `sideWidth + sidewalkWidth` (when enabled) into `writeComplianceFalloff`
(`systems/RoadSystem.hpp:291`) so the shoulder fade starts at the sidewalk's
outer edge.
### 2.4 Editor UI
In the Terrain panel "Road Config" tree (`ui/TerrainEditor.hpp:683`): an
"Enabled" checkbox, width/height/thickness sliders, and a template combo
reusing the existing mesh-scan helpers (`scanMeshFiles`/`getMeshList`,
`:1460–1518`) with a "(Procedural Box)" default — the same UX as the road
mesh combo at `:648`. All edits call `rg.bumpVersion()`.
### 2.5 Tests
- `testRoadWedgeGeometry` (`TerrainTests.cpp:2135`): with sidewalks enabled —
lateral extent reaches `halfWidth + sidewalkWidth`, sidewalk top Y ≈
`roadSurfaceY + sidewalkHeight`, vertex count grows by the sidewalk
template count × `ceil(L)`; dead-end segment case has both bands.
- `tests/road_geometry_overlap_test.cpp`: enable sidewalks in the existing
A–B–C configurations (flat, corner raised/lowered) — the
coplanar-overlap/piercing checks must stay green (miter no-fold
regression).
- `testRoadSerialization` (`TerrainTests.cpp:1346`): `roadConfig` round-trip
with the new fields; loading a pre-sidewalk scene yields disabled defaults.
---
## 3. Road prefabs
**Status (2026-08-18): ✅ DONE** — three fixed slots per edge with legacy
JSON migration, per-edge spawn records keyed by `edgePrefabKey`,
plain-meter spawn/despawn hysteresis, edit-mode force preview, shared
`PrefabSystem::destroyInstance`, yaw/Y/anchor transforms, picker UI.
Verified by the reworked `testRoadSidePrefabs`.
### Current state (M5.11) and its problems
`RoadEdge::sidePrefabs` is an unbounded vector of
`{prefabPath, edgeT, sideOffset, leftSide}` (`RoadGraph.hpp:185-211`),
serialized per edge (`SceneSerializer.cpp:4254-4277` / `:4371-4396`), spawned
by `RoadSystem::spawnSidePrefabs` (`systems/RoadSystem.cpp:985-1050`) once per
page finalize. Known defects:
- **Duplicates across pages**: it iterates *all* graph edges for every
finalizing page, so each loaded road page spawns its own copy of every
edge's prefabs.
- **Name collisions**: instances are named `road_prefab_<A>_<B>_<edgeT>`; two
prefabs on one edge with equal `edgeT` collide.
- **No yaw alignment**: road direction is never applied to the instance
rotation.
- **Wrong Y**: snapped to raw terrain height; road levels/elevation ignored.
- **Leaks**: teardown is a bare `entity.destruct()` (`RoadSystem.cpp:417`,
`:456`) — the Ogre SceneNode/Entity and Jolt bodies survive (contrast
`TerrainPrefabSpawnerSystem::destroyInstanceRecursive`,
`TerrainPrefabSpawnerSystem.cpp:182-225`).
- **Silent failure**: `PrefabSystem::createInstance` returns a live empty
entity when the file is missing (`PrefabSystem.cpp:129-141`), so the
"failed to spawn" branch is dead and the empty proxy is tracked.
- **No distance unload** and **no prefab picker** (raw `InputText`,
`ui/TerrainEditor.hpp:1045-1087`).
### 3.1 Data model — fixed slots instead of a vector
Replace `sidePrefabs` with three fixed slots per edge
(`components/RoadGraph.hpp`):
```cpp
struct RoadEdgePrefabSlot {
std::string prefabPath; // empty = slot disabled
float edgeT = 0.5f; // normalized position along the edge
float lateralOffset = 0.0f; // meters from the anchor, away from road
float yOffset = 0.0f;
};
// RoadEdge members:
RoadEdgePrefabSlot prefabLeft; // anchor: left curb (looking A -> B)
RoadEdgePrefabSlot prefabRight; // anchor: right curb
RoadEdgePrefabSlot prefabMid; // anchor: centerline, edge midpoint
```
Anchors implement "zero position at the road edge, tunable along and
perpendicular": the side-slot anchor sits on the curb at `edgeT` — lateral
distance from the centerline equals the resolved half-width *of that side*
(asymmetric roads have different left/right widths) — and `lateralOffset = 0`
places the prefab exactly at the road edge. `prefabMid` anchors at the
centerline (`edgeT`, default 0.5) and is the "one per edge, aligned to the
road midpoint" prefab.
**Serialization/migration** (`SceneSerializer.cpp:4254`, `:4371`): write the
three slots; when loading an old scene, migrate the first `leftSide == true`
entry to `prefabLeft` and the first `false` entry to `prefabRight` (remap
`sideOffset` to `lateralOffset = sideOffset - sideHalfWidth`), log and drop
any extra entries.
### 3.2 Spawning, distance unload, lifetime (`systems/RoadSystem.cpp`)
- **Per-edge records, not per-page.** Track spawn state in
`RoadSystem`, keyed by the ordered node-id pair `(min(A,B), max(A,B))` —
stable across edge-vector reordering. Remove
`RoadPageGeometry::spawnedPrefabs`. This fixes the cross-page duplication.
- **Distance gating.** New `RoadConfig` fields `prefabSpawnDistance = 150`
and `prefabDespawnDistance = 250` (serialized as plain meters, same
convention as `TerrainPrefabSpawnerComponent`). `RoadSystem::update()`
re-evaluates when the camera has moved beyond a threshold (the
`SpawnerRecord`/`CAM_REEVAL_DIST_SQ` pattern in
`TerrainPrefabSpawnerSystem.hpp:157-178`): spawn when the edge is within
spawn distance *and* its endpoint pages are loaded; despawn beyond despawn
distance (hysteresis), on page unload, on edge removal and on teardown.
- **Transform.** Position: `center(edgeT)` plus the lateral anchor described
in §3.1. Y: interpolated road surface height
`lerp(A.y + roadLevelA, B.y + roadLevelB, edgeT) + yOffset` (terrain
compliance already flattens the curb area to road level). Rotation: yaw
from the edge direction, composed with the prefab's own stored root
rotation. Unique names: `road_prefab_<A>_<B>_<slot>`.
- **Failure handling.** Verify the prefab instantiated (file readable /
instance flagged) before tracking it; otherwise despawn and log once.
- **Graph changes.** On version bump, diff slot data per edge key and respawn
only edges whose slots changed; a split (§1.2) remaps slots onto the new
halves so prefabs survive the edit.
- **No leaks.** Hoist `TerrainPrefabSpawnerSystem::destroyInstanceRecursive`
into a shared `PrefabSystem::destroyInstance(entity)` and call it from both
systems; it recursively destroys child entities, Jolt bodies, Ogre entities
and scene nodes before `destruct()`.
### 3.3 Editor
- Rework the edge inspector prefab section (`ui/TerrainEditor.hpp:1045-1087`)
into three slot groups (Left / Right / Mid), each with: a prefab picker
combo fed by the existing `scanPrefabFiles` helper (`:508-523`, same pattern
as the terrain prefab-spawn picker), and `edgeT` / `lateralOffset` /
`yOffset` sliders.
- **Edit-time preview.** While road edit mode is active, force-spawn the
selected edge's prefabs regardless of distance so placement is WYSIWYG;
leaving edit mode re-applies distance rules. (Road edit mode currently has
no ESC handling — adding ESC-to-exit alongside this, mirroring prefab spawn
mode at `EditorApp.cpp:1917-1923`, is a cheap consistency win.)
- Config UI: spawn/despawn distance sliders in the "Road Config" tree.
### 3.4 Tests
Extend `testRoadSidePrefabs` (`TerrainTests.cpp:3462-3622`, fixtures in
`tests/prefabs/`): side anchors sit at the curb (±half-width), mid slot at
the centerline midpoint; yaw follows the edge direction; Y follows road
level; spawn/despawn hysteresis under camera movement; force-spawn in edit
mode; **no duplicates with two road pages loaded**; scene-graph node and
Ogre entity counts return to baseline after despawn/teardown cycles (leak
check); split remaps slots without duplication.
---
## 4. Connecting two nodes with a new edge
**Status (2026-08-18): ✅ DONE** — "Connect" radio tool with viewport
pinning and chaining (the second node becomes the new source),
validation with feedback (self/duplicate, cross-page via
`edgeStaysWithinOnePage`, wedge-angle rollback), `testRoadDataModel`
extended. The node-list "Connect" buttons remain for off-screen nodes.
### Current state
- The graph op exists: `RoadGraph::joinNodes(nodeA, nodeB)`
(`components/RoadGraph.hpp:593`) creates the edge if absent; `addEdge`
(`:480`) rejects self-edges, duplicates and missing nodes, and `joinNodes`
logs a warning for sub-`ROAD_MIN_EDGE_LENGTH` edges.
- A UI path exists but is list-only: select node A's row, then click the
"Connect" `SmallButton` on node B's row (`ui/TerrainEditor.hpp:835-860`).
There is no 3D-view gesture, and the selection model is single-node
(`RoadSystem::m_selectedNodeId`).
- `RoadGraph::edgeStaysWithinOnePage` (`RoadGraph.hpp:631`) encodes the hard
page constraint — an edge is valid only when both endpoints lie in the
same terrain page — but it is never called today, so cross-page edges can
be created and silently break the page-bucketed geometry (M5.4).
- The M5.5 wedge-angle limits (30°–270°) are only checked by
`RoadGraph::validate()`, never enforced when an edge is created.
### Remaining work
- **4.1 Connect tool for the 3D view.** Add a third radio "Connect" next to
"Move Node" / "Add Node" (`ui/TerrainEditor.hpp:716-725`; `RoadEditTool`
enum at `systems/TerrainSystem.hpp:331-335`). The first click in the
viewport pins the source node (reuse `pickRoadNode`,
`systems/EditorUISystem.cpp:2217`) and gives it the selection highlight;
the second click on another node calls `joinNodes` and clears the pin;
clicking empty terrain moves or clears the pin. This is the "select two
nodes, create edge" gesture; the node-list "Connect" buttons stay for
off-screen nodes.
- **4.2 Validation with feedback.** On a connect attempt: reject
self/duplicate via `joinNodes` (exists); reject cross-page pairs by wiring
up `edgeStaysWithinOnePage` (page world size comes from the
`TerrainComponent`); after the edge is added, re-enumerate the wedges at
both endpoint nodes and roll back with `removeEdge` if a new wedge
violates the 30°–270° limits. Report the rejection reason in the road
section, following the existing "Validate Road Graph" modal pattern
(`ui/TerrainEditor.hpp:741-767`).
- **4.3 Tests.** Extend `testRoadDataModel`
(`systems/TerrainTests.cpp:1246`): duplicate/self connects rejected,
cross-page pair rejected, angle-violating connect rolled back, successful
connect bumps the graph version.
---
## 5. Smoothing three neighboring nodes
**Status (2026-08-18): ✅ DONE** — `RoadGraph::smoothNode` (position
relaxation, no graph mutation) with min-length / page-crossing /
wedge-angle rollback, inspector UI ("Smooth A–B–C" + strength slider)
and neighbor highlight, editor-layer Y re-snap, `testRoadDataModel`
extended.
### Current state
No smoothing exists. Corners are intentionally sharp — the centerline is a
polyline (`ProceduralRoadGeometry.md` §5.3) — and selection is single-node
only.
Key observation: a node with exactly two neighbors unambiguously identifies
a 3-node chain A–B–C (itself plus its two neighbors), so the triple needs no
new multi-selection infrastructure — clicking the middle node B selects it.
### Semantics (decided)
Position relaxation, no graph mutation: B moves toward the straight line
A–C; A and C are anchors and never move (they connect to the rest of the
network). A strength factor (0..1, default 0.5) controls how far B moves
per application, so repeated applications converge to straight:
```
B'.xz = lerp(B.xz, midpoint(A.xz, C.xz), strength)
B'.verticalOffset = lerp(B.verticalOffset,
(A.verticalOffset + C.verticalOffset) / 2, strength)
```
Y is then re-snapped to `terrainHeight + verticalOffset` by the editor layer
(the same helper used by the gizmo drag and by §1.3), keeping the road
surface continuous.
### Remaining work
- **5.1 Graph op.** New `RoadGraph::smoothNode(int nodeId, float strength)`
next to `splitEdge` (`components/RoadGraph.hpp:523`): pure graph function,
returns false unless the node has exactly two neighbors; applies the lerp
above (terrain-agnostic — no Y snap) and bumps the version.
- **5.2 Validation.** A move is rejected and reverted when it would shorten
an incident edge below `ROAD_MIN_EDGE_LENGTH`, push B across a terrain
page boundary (`edgeStaysWithinOnePage` for both incident edges), or
create a wedge outside 30°–270° (re-enumerate at A, B and C).
- **5.3 UI.** In the selected-node inspector (`ui/TerrainEditor.hpp:894-958`),
when the selected node has exactly two neighbors: show the derived triple
("Smooth A–B–C"), a strength slider and a "Smooth" button; extend
`buildSelectionHighlight` (`systems/RoadSystem.cpp`) to also mark the two
neighbor nodes in a second color so the affected triple is visible in the
viewport. Disabled with a hint when the degree is not 2 (junctions and
endpoints have no unique triple).
- **5.4 Tests.** `testRoadDataModel` additions: degree ≠ 2 rejected; B moved
by the exact lerp; `verticalOffset` averaged; min-length and page-crossing
moves reverted. Geometry needs no new cases — existing wedge tests
already cover straight and angled chains.
---
## 6. Implementation order
1. **§3 Road prefabs** — the slot data model and lifecycle fixes are a
prerequisite for §1.2 (split remapping operates on the slot model).
2. **§1 Edge splitting** — small, bounded; finishes the split/prefab
interaction.
3. **§4 Node connecting** and **§5 Corner smoothing** — small, independent
graph-editing additions; either order.
4. **§2 Sidewalks** — independent of the others; the largest piece
(geometry pipeline changes).
## 7. Documentation and definition of done
Per the project rules, every landed item updates docs and tests in the same
change:
- `TerrainRequirements.md`: add sub-milestones **M5.13 Road prefab slots and
distance unload**, **M5.14 Edge split polish**, **M5.15 Sidewalks**,
**M5.16 Node connect tool**, **M5.17 Corner smoothing**, each with a
status block and a definition-of-done checklist naming the verifying tests
(`--headless --run-terrain-tests=1` suite and
`road_geometry_overlap_test`).
- `ProceduralRoadGeometry.md`: new section specifying the sidewalk strip
(template conventions, per-vertex mapping, miter behavior, UV, segment
bands) once §2 lands. **Landed as §15.**
- This document: mark items done with dates as they land.
+47
View File
@@ -0,0 +1,47 @@
#include "ProjectConfig.hpp"
#include <nlohmann/json.hpp>
#include <filesystem>
#include <fstream>
std::string sanitizeAppName(const std::string &name)
{
std::string out = name;
for (auto &c : out) {
bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') || c == '.' || c == '_' ||
c == '-' || c == ' ';
if (!ok)
c = '_';
}
if (out.empty())
out = "project";
return out;
}
ProjectConfig loadProjectConfig(const std::string &dir)
{
ProjectConfig cfg;
cfg.rootDir = dir;
cfg.appName = std::filesystem::path(dir).filename().string();
std::filesystem::path jsonPath =
std::filesystem::path(dir) / "project.json";
std::ifstream file(jsonPath);
if (!file.is_open())
return cfg;
try {
nlohmann::json j;
file >> j;
cfg.appName = j.value("appName", cfg.appName);
cfg.startScene = j.value("startScene", "");
cfg.gameMode = j.value("gameMode", false);
cfg.loaded = true;
} catch (const std::exception &e) {
fprintf(stderr, "WARNING: could not parse %s: %s\n",
jsonPath.string().c_str(), e.what());
}
return cfg;
}
+61
View File
@@ -0,0 +1,61 @@
#ifndef EDITSCENE_PROJECT_CONFIG_HPP
#define EDITSCENE_PROJECT_CONFIG_HPP
#pragma once
#include <string>
/**
* Project directory configuration (F8).
*
* A "project" is a directory that acts as the editor/game working
* directory: scenes, prefabs, resources.cfg, runtime config JSONs and
* heightmaps/ are all resolved relative to it (the process chdir()s into
* the project root at startup when --project is given, or the release
* binary simply runs from it).
*
* The project root may contain a project.json:
*
* {
* "appName": "My Game",
* "startScene": "scenes/level1.json",
* "gameMode": true
* }
*
* appName - per-project identity: drives the window title and the
* per-project save directory (<user-data>/<appName>/saves/).
* Falls back to the directory name when missing.
* startScene - scene loaded directly by game mode (skips the startup
* menu) when gameMode is true.
* gameMode - the project is a playable game; the editor binary enters
* game mode by default for such projects (--editor forces
* editor mode).
*/
struct ProjectConfig {
/* Absolute path of the project root; empty when no project is open
* (plain editor session in the binary directory). */
std::string rootDir;
/* Per-project application name (window title, save directory). */
std::string appName;
/* Game-mode start scene (relative to the project root). */
std::string startScene;
/* Project defaults to game mode. */
bool gameMode = false;
/* True when a project.json was found and parsed. */
bool loaded = false;
};
/**
* Load <dir>/project.json. Tolerates a missing or malformed file:
* appName falls back to the directory name, loaded is set false and a
* warning is logged on parse errors. rootDir is always set to dir.
*/
ProjectConfig loadProjectConfig(const std::string &dir);
/** Make an appName safe for use as a filesystem path component. */
std::string sanitizeAppName(const std::string &name);
#endif // EDITSCENE_PROJECT_CONFIG_HPP
+190
View File
@@ -0,0 +1,190 @@
# Terrain System — Functional Description
This document describes how the terrain in `src/features/editScene` works: what it can
do, how the data is organised, and how the editor and runtime systems interact with it.
For the implementation history see `TerrainImprovment2.md`; for contributor rules see
`AGENTS.md`.
## Overview
The terrain is a single `TerrainComponent`-driven Ogre `TerrainGroup` owned by
`TerrainSystem`. Two modes exist:
- **Legacy mode** (`streamingEnabled = false`): a small fixed grid, all pages defined
and loaded up front, base heights from a single in-memory heightmap buffer. Used by
old scenes and most headless tests.
- **Streaming mode** (`streamingEnabled = true`): a bounded world of up to
40,000,000 × 40,000,000 units (20,000 × 20,000 pages at the default 2000-unit page
size). Pages stream in around the camera; base heights are procedural; edits, paint
layers, spawners and roads persist in per-page sidecar files.
## Coordinate spaces
Four coordinate spaces coexist; converting at system boundaries is every caller's job:
- **WORLD** — absolute double-precision coordinates, authoritative. Region files,
bookmarks, the Navigation panel, `TransformComponent::worldX/Y/Z` and physics use it.
- **RENDER** — float, relative to the floating render origin:
`render = world - renderOrigin`. Scene nodes, mouse rays and cameras live here.
`RenderOriginSystem` (singleton) owns the origin and rebases when the camera moves
more than 8192 render units away from it, shifting every render-space position by the
exact negated delta. Convert with `RenderOriginSystem::worldToRender()` /
`renderToWorld()`.
- **PHYSICS** — Jolt `DVec3`/`RVec3` in absolute world coordinates
(`physics/physics.h`). Terrain colliders live here.
- **PHYSICAL HEIGHTMAP** — per-page vertex indices: physical X is shifted by half a
page, physical Z is mirrored within each page. Convert with
`TerrainSystem::visualToPhysicalX/Z` / `physicalToVisualX/Z`.
Page indices are PHYSICAL page coordinates `[0, N-1]`. The Ogre TerrainGroup slot for
physical page `(x, y)` is `(x, -y)` because `ALIGN_X_Z` negates Z — use
`TerrainSystem::isPageLoaded(x, y)` rather than raw slot math.
Never store a world position in a float; floats cannot represent 40,000,000 with
sub-metre precision.
## Configuration (`TerrainComponent`)
| Field | Default | Meaning |
|---|---|---|
| `streamingEnabled` | false | Opt in to the streamed world |
| `worldSizeUnits` | 40,000,000 | World extent per axis (units) |
| `pageLoadRadius` | 2 | Pages loaded around the camera page |
| `pageHoldRadius` | 3 | Pages kept before unloading (hysteresis) |
| `farClipDistance` | 6000 | Camera far clip while terrain is active |
| `fogEnabled` / `fogStart` / `fogEnd` | true / 2500 / 5500 | Linear fog hiding pop-in |
| `baseNoise` | seeded FastNoiseLite | Procedural base height source |
All fields serialize with the scene (`SceneSerializer::serializeTerrain`).
## Streaming window
Each frame `TerrainSystem::updateStreamingWindow()` computes the camera's page, then:
- defines and loads pages entering the load window (at most 2 per frame), and
- unloads pages beyond the hold radius (at most 4 per frame).
Loading is synchronous and amortized — Ogre's background WorkQueue is deliberately not
used (it caused shutdown hangs). Base heights for a page are evaluated on demand from
`baseNoise` (double precision, deterministic for a given seed); a legacy
`heightmaps/<terrainId>/heightmap.bin`, when present, is sampled as a "base patch"
inside its bounds for backward compatibility.
Sculpting never touches the base layer: `setHeightAt`/brushes write into the sparse
per-page **fixup chunk** store (`heightmaps/<terrainId>/terrain_fixup/x{cX}_z{cZ}.bin`),
which is lazily disk-loaded, LRU-capped, and saved on eviction when dirty. Blend maps
and aux maps are likewise per-page files under `heightmaps/<terrainId>/`.
When a page is (re)built, `fillPageHeightData()` evaluates the base layer at its legacy
integer sample positions but overlays the fixup layer at the exact double-precision
vertex coordinates, so road/pad planes are not shifted by position truncation.
Height queries (`getHeightAt`, `raycastTerrain`) hit loaded pages when possible and fall
back to the analytic base+fixup evaluation for far/unloaded terrain, so tools (teleport
snapping, world map) work anywhere in the world.
Each loaded page gets a static Jolt collider created at its page-index-computed world
position (double precision, no float round-trip); colliders are queued/removed with the
page lifecycle.
## Region storage layout
All per-terrain streamed data lives under `heightmaps/<terrainId>/`:
```
heightmaps/<terrainId>/
├── terrain_fixup/x{cX}_z{cZ}.bin # sparse sculpt edits (chunk = page)
├── blend/... aux/... # per-page paint layers
├── spawners/<px>_<py>.json # prefab spawner definitions (M4)
└── roads/<px>_<py>.json # road graph partition (M5)
```
`<px>_<py>` are physical page indices. Positions inside the JSON files are absolute
world-space doubles, so the files are immune to render-origin rebases.
## Prefab spawner streaming
Two kinds of prefab spawners exist:
- **Always-loaded**: scene-embedded `TerrainPrefabSpawnerComponent` entities — the
classic behaviour, unchanged, right for small scenes.
- **Streamed**: when the terrain streams, spawner definitions live in the region store.
`TerrainPrefabSpawnerSystem::syncStreamedSpawners()` watches the loaded page set,
creates a region spawner entity (tagged `StreamedSpawnerTag`, excluded from scene
JSON) when a page loads, and destroys it — writing position/distance edits back,
including moves across page boundaries — when it unloads. The distance-based
spawn/despawn of the actual prefab instances is unchanged.
The editor's click-to-place prefab spawn mode writes straight to the region store when
streaming.
## Road streaming
With streaming enabled, the road graph (`RoadGraph`: nodes + edges + `roadConfig`) is
partitioned per page:
- A node belongs to the page containing its position; a cross-page edge is written to
**both** endpoint pages' files (foreign endpoint repeated inline), so each file is
self-contained.
- On page load, `RoadSystem::syncRegions()` merges the file into the active graph,
matching nodes by world XZ (0.01 eps) and creating an edge only when both endpoints
exist — an edge into an unloaded neighbour appears when that neighbour loads.
- On page unload, the page's nodes and their edges are extracted back to the file.
- Legacy scenes with an inline `roadGraph` migrate to region files automatically on
first sync; the scene JSON then keeps only `roadConfig`.
- Saving the scene flushes the region store first
(`EditorUISystem::saveScene` → `RoadSystem::flushRegionStore()`).
Per-page road meshes, colliders and roadside prefabs were already page-scoped; they are
rebuilt only when a rebase-invariant content signature changes, and navmesh dirtying is
debounced (~15 quiet frames) so mass page transitions do not trigger rebuild storms.
## Editor navigation tools
- **Fly camera**: mouse-wheel zoom, Shift boost (×10), configurable speed
(`EditorCamera::setFlySpeed`, 1..100,000 units/s).
- **Tools → Navigation**: teleport to world X/Y/Z or to a page index (clamped to world
bounds, camera snapped above the terrain); named **bookmarks** stored in the scene
JSON `bookmarks` array.
- **Tools → World Map**: data-driven ImGui canvas (no RTT) showing coarse cached height
shading, loaded/unloaded pages, road polylines, prefab spawners, bookmarks and the
camera marker; drag to pan, wheel to zoom, double-click to teleport.
- `TerrainEditor` hosts a compact "Navigation" section with the same teleport backend.
## Serialization behaviour
- Scene JSON: terrain component settings, `roadConfig`, bookmarks, and all
non-streamed entities. When streaming, `roadNodes`/`roadEdges` and
`StreamedSpawnerTag` entities are **not** written — they live in the region stores.
- Region stores are written immediately on edit (spawners), on page unload, and on
scene save (roads).
- Loading an old scene with inline roads/spawners migrates them into region files on
first sync/save.
## Lua API coordinates
`lua/LuaTerrainApi.hpp` documents the coordinate space per function:
`terrain.sculpt` / `terrain.paintAux` take visual world coordinates,
`terrain.paint` converts world → render internally (rebase-safe), and
`terrain.sampleAux` takes physical heightmap coordinates.
## Testing
Headless integration suite (no display needed):
```bash
cd build-vscode/src/features/editScene
./editSceneEditor --headless --run-terrain-tests=1 # full suite
TERRAIN_TEST_FILTER=roadRegion ./editSceneEditor --headless --run-terrain-tests=1
```
Covers: streaming window follow/unload, procedural determinism, fixup persistence,
rebase stability and round-trip, bookmarks, world-map transforms, spawner region
round-trip and window lifecycle, road region store/streaming/migration. Success line:
`TERRAIN TESTS: ALL 1 ITERATIONS PASSED`.
## Known limitations
- The world is **bounded**, not looped: page indices and teleports clamp to
`[0, N-1]`. Wrap-around was explicitly scoped out (see `TerrainImprovment2.md`).
- Synchronous amortized loading can briefly lag behind very fast camera movement.
- Roads into an unloaded neighbour page render only once that page loads (by design).
@@ -0,0 +1,204 @@
# Terrain Improvement Plan 2 — Streamed 40M×40M World (with status)
Status legend: **[DONE]** implemented and verified, **[PARTIAL]** implemented with
deviations, **[OPEN]** not implemented.
Overall status: **all milestones complete** (verified 2026-09: build clean,
`TERRAIN TESTS: ALL 1 ITERATIONS PASSED`, `component_lua_test` 63/63).
## Goal
Convert `src/features/editScene` terrain from a fixed 3×3-page, always-loaded grid into a
camera-streamed, bounded 40,000,000 × 40,000,000-unit world with:
- Terrain page streaming — pages load/unload around the camera.
- Procedural base terrain + sparse persisted edits.
- Rendering origin rebasing — world coordinates stay `double`; rendering floats never see
huge values. Jolt runs with `JPH_DOUBLE_PRECISION`, so physics stays in world space.
- Editor navigation tools — teleport, world map with info layers, bookmarks, speed-scaled
fly camera.
- Terrain-attached prefab streaming — spawner definitions stored per region.
- Road streaming — road graph stored per region, loaded around the camera.
## Confirmed decisions
- Bounded world, **no wrap-around**. (The original request mentioned a looped world; this
was consciously scoped out. See "Open items" below.)
- Base terrain = procedural + sparse edits (no giant heightmap file).
- Precision = origin rebasing for rendering only; physics uses doubles already.
- All four editor tools wanted; world map must show info layers (prefabs, roads, pages).
## Architecture
### Coordinate model
- **World space**: `double`, authoritative. World bounds `[0, 40,000,000]` both axes →
pages `[0, 19999]` at the default 2000-unit page size (clamped to 32768 for
TerrainGroup's signed 16-bit slot packing).
- **Render space**: float, camera-centric. `RenderOriginSystem` owns a `JPH::DVec3`
origin; `render = world - origin`. Rebase when the camera moves further than 8192
render units from the origin: the origin snaps to integer world coordinates near the
camera and every render-space position shifts by the exact negated delta.
- **Physics space**: Jolt `DVec3`/`RVec3` in absolute world coordinates.
- **Physical heightmap space**: physical X shifted by half a page, physical Z mirrored
within each page; convert with `TerrainSystem::visualToPhysicalX/Z` /
`physicalToVisualX/Z`. Note: the Ogre TerrainGroup slot for physical page `(x, y)` is
`(x, -y)` because `ALIGN_X_Z` negates Z.
### Region/window model for content streaming
- Terrain pages stream in a window around the camera's page (`pageLoadRadius` = 2,
`pageHoldRadius` = 3 by default, configurable in `TerrainComponent`), synchronously,
amortized (2 loads + 4 unloads per frame) to avoid hitches and the WorkQueue shutdown
hangs seen with Ogre background loading.
- Sidecar content (prefab spawners, road graph) is stored **per page** under
`heightmaps/<terrainId>/spawners/<px>_<py>.json` and
`heightmaps/<terrainId>/roads/<px>_<py>.json` (the plan originally considered coarser
8×8-page regions; per-page files proved simpler and match the terrain page lifecycle).
---
## Milestones
### M1 — Terrain page streaming core — [DONE]
- `TerrainComponent` gained `streamingEnabled` (default false; opt-in per terrain),
`worldSizeUnits` (default 40,000,000), `pageLoadRadius`/`pageHoldRadius` (2/3),
`farClipDistance` (6000), linear fog (`fogEnabled`, 2500→5500) and `baseNoise`
(FastNoiseLite config). All serialized in `SceneSerializer`.
- Base heights are procedural-on-demand from `baseNoise` (double precision);
`setHeightAt`/sculpting write through the sparse per-page fixup chunk layer
(LRU-capped, save-on-evict). A legacy `heightmap.bin` keeps working as an optional
base patch for old scenes.
- `TerrainSystem::updateStreamingWindow()` loads/unloads pages around the camera page
each frame; old `m_pageMin/Max` full-grid loops were converted to iterate loaded
slots; blend/aux maps are per-page files under `heightmaps/<terrainId>/`.
- `getHeightAt`/`raycastTerrain` fall back to the analytic height path for unloaded
(far) pages.
- Camera far clip + fog come from `TerrainComponent` so pop-in is hidden at the hold
radius.
- Tests: page window follows camera, unload frees slots, procedural determinism across
reload, fixup persistence across unload/reload; legacy assumptions adapted.
### M2 — Rendering origin rebasing + double world positions — [DONE]
- New `systems/RenderOriginSystem.{hpp,cpp}` (singleton): `worldToRender(x,y,z)`,
`renderToWorld(Ogre::Vector3) -> JPH::DVec3`, 8192-unit rebase threshold; runs before
all other systems each frame.
- `TransformComponent` carries authoritative double `worldX/Y/Z` alongside the float
node-local position; serialization writes the doubles (float kept for legacy load).
- Physics wrapper gained `JPH::RVec3` overloads so world-space doubles flow without a
float round-trip; terrain page colliders are created at page-index-computed world
positions.
- `TerrainSystem` exposes `worldToRender`, `renderToWorldX/Y/Z`, `isPageLoaded(x,y)`
(physical page coords), `streamingCameraPage`, `getTerrainEntityId`, `getStreamingActive`,
`getStreamingPageCount`, `snapCameraAboveTerrain`.
- Tests: rebase stability (geometry shifts by exactly the origin delta),
world→render→world round-trip, far-corner teleport renders correct terrain.
### M3 — Editor navigation tools — [DONE]
- `EditorCamera`: mouse-wheel zoom (`handleMouseWheel`), Shift boost (×10), configurable
fly speed (`setFlySpeed`, 1..100,000 units/s).
- **Tools → Navigation** (`ui/NavigationPanel.hpp`, header-only): teleport by world
X/Y/Z or by page index (clamped to world bounds); named bookmarks
(`ui/WorldBookmark.hpp`) persisted in the scene JSON top-level `bookmarks` array
(tolerated when missing on load).
- **Tools → World Map** (`ui/WorldMapPanel.hpp` + `systems/WorldMapData.hpp`): ImGui
2D canvas (data-driven, not RTT) with coarse cached height shading, loaded/unloaded
page overlay, road polylines, prefab-spawner points, bookmarks, camera marker;
drag pan, wheel zoom, double-click teleport.
- `TerrainEditor` has a compact "Navigation" section sharing the same teleport backend.
- Tests: bookmark round-trip, world↔map transforms at extremes, navigation teleport.
### M4 — Terrain-attached prefab streaming — [DONE]
- New `systems/SpawnerRegionStore.{hpp,cpp}`: `SpawnerRegionDef` (prefabPath, world-space
double position, spawn/despawn distances, per-page id) in
`heightmaps/<terrainId>/spawners/<px>_<py>.json`, saved immediately on edit.
- `TerrainPrefabSpawnerSystem::syncStreamedSpawners()` polls
`TerrainGroup::getTerrainSlots()`: creates region spawner entities on page load
(tagged `StreamedSpawnerTag{pageX,pageY,defId}`), destroys them and writes edits back
(including page-boundary crossings) on unload. Existing distance-based spawn/despawn
logic is unchanged.
- Editor click-to-place (`createSpawnPoint`) writes to the region store when streaming;
scene-embedded spawner entities remain as "always-loaded" spawners for
small/non-streaming scenes.
- `SceneSerializer` excludes `StreamedSpawnerTag` entities from scene JSON and caches
parsed prefab JSON (`loadPrefabJsonCached`/`invalidatePrefabJsonCache`, invalidated on
prefab save/delete).
- Tests: region store round-trip, streamed spawner window lifecycle, prefab JSON cache.
### M5 — Road streaming — [DONE]
- New `systems/RoadRegionStore.{hpp,cpp}`: per-page files
`heightmaps/<terrainId>/roads/<px>_<py>.json` with nodes as absolute world-space
doubles and edges referencing per-file node ids (full lane/level/prefab-slot data).
- Membership rule: a node belongs to the page containing its position; an edge is stored
in the region file of **each** endpoint page (foreign endpoint repeated inline), so a
page file is self-contained. Merge matches nodes by world XZ (0.01 eps); an edge is
created only when both endpoints exist in the active graph; `hasEdge` prevents
duplicates.
- `RoadSystem::syncRegions()` runs each frame: merges region files of newly loaded pages,
extracts departing pages back to disk (two-phase: all departing pages are written
before any node is removed, because `removeNode` cascades incident edges).
- One-time migration: a non-empty inline `roadGraph` (legacy scene JSON) is partitioned
into region files on first sync; `SceneSerializer::serializeTerrain` then skips
`roadNodes`/`roadEdges` when `streamingEnabled` (keeps `roadConfig`).
- `EditorUISystem::saveScene` calls `RoadSystem::flushRegionStore()` before writing.
- Page mesh rebuilds are gated by a rebase-invariant FNV-1a content signature (world
positions quantized to cm) instead of re-dirtying all pages on every graph change;
navmesh dirtying is debounced (~15 quiet frames) while streaming.
- `TerrainSystem::onRenderOriginChanged` forwards to `RoadSystem::onRenderOriginChanged`
(shifts render-space node positions, bumps version; region files untouched).
- Notable fix found by tests: physical page assignment on the mirrored Z axis needs a
small epsilon (`floor((visualToPhysicalZ(wz) - 1e-6) / ws)`) so boundary nodes land in
the same page `connectNodes`' round-half rule picks.
- Tests: `roadRegionStore`, `roadRegionStreaming` (incl. cross-page `connectNodes` split,
rebase invariance, unload/reload remerge), `roadRegionMigration`.
### M6 — Docs, examples, cleanup — [DONE]
- Root `AGENTS.md`: "Road Region Streaming (M5)" + "Terrain Streaming Architecture"
sections (coordinate spaces, streaming window, region storage layout, navigation
tools, streamed vs always-loaded spawners).
- `src/features/editScene/AGENTS.md`: streaming paragraphs for
`TerrainPrefabSpawnerSystem` and `RoadSystem`, a "Terrain Streaming & Navigation"
section, and new caveats (physical page vs Ogre slot `(x,-y)`; never store world
positions in floats; streamed content lives in region stores; Lua coordinate
expectations).
- `TerrainRequirements.md`: stale "all pages loaded" / fixed-grid statements corrected.
- `lua/LuaTerrainApi`: per-function coordinate spaces documented in the header;
`terrain.paint` fixed to convert world→render (it painted at the wrong spot after a
rebase); `terrain.sculpt`/`paintAux` take visual world coords, `sampleAux` takes
physical coords.
- CMake: `prefabs/` is now copied next to the runtime resources (mirrors the existing
`tests/prefabs` copy), fixing the missing runtime prefab directory.
- Stale comments in `RoadSystem.hpp` / `TerrainSystem.hpp` / `RenderOriginSystem.hpp`
brought in line with the final behaviour.
---
## Verification
After every milestone and at completion:
- `cmake --build build-vscode --target editSceneEditor -j4` — clean.
- `cd build-vscode/src/features/editScene && ./editSceneEditor --headless --run-terrain-tests=1`
— `TERRAIN TESTS: ALL 1 ITERATIONS PASSED` (single steps via `TERRAIN_TEST_FILTER=<substr>`).
- `./component_lua_test` — 63/63 passed.
## Open items / known limitations
- **Looped (wrap-around) world**: requested but explicitly scoped out; the world is
bounded and page indices/teleports clamp to `[0, N-1]`. Implementing a toroidal world
(wrapping streaming window, render origin, region stores, minimap) is a separate
feature.
- Page loads/unloads are synchronous and amortized (2+4 per frame); very fast camera
movement can outrun the window briefly. Ogre's background WorkQueue was deliberately
avoided (shutdown hangs).
- `connectNodes` boundary splitting uses render-space round-half math and can misplace
splits after a non-page-aligned rebase (nodes re-merge by position, so this is
cosmetic at worst).
- Roads crossing into an unloaded neighbour page appear only when the second page loads
(by design; both-files rule).
@@ -0,0 +1,395 @@
# Milestone 5 Verification Plan — Procedural Roads
This document enumerates all verification steps needed to confirm the
correctness of Milestone 5 (Procedural Roads) as specified in
`TerrainRequirements.md`. It does **not** trust the plan's self-reported
completion status; each item is independently assessed against the current
working tree (2026-07-30).
## 0. Resolved Decisions
Decisions made by the plan author (2026-07-30) during the verification audit:
| Decision | Resolution |
|----------|------------|
| M5.10 perpendicular falloff | **Implement now.** Add the `laneWidth * 2` linear fade to `complyTerrain()` before M5 closure. |
| Road collider debug visibility | **Add toggle.** Include road colliders in the physics debug draw with a checkbox (or filter them alongside terrain bodies). |
| Roadside prefab test fixture | **Real prefab, separate directory.** Create a minimal `.prefab` JSON in a `tests/` directory (not mixed with user prefabs). The test verifies successful spawn + entity destruction on teardown. |
## 1. Automated Test Coverage Audit
The table below maps every M5 sub-item to the headless tests in
`TerrainTests.cpp`. Status is `COVERED` when at least one test exists that
exercises the core logic, `PARTIAL` when some behaviours are verified but
others are not, and `UNCOVERED` when no automated test exists at all.
| Item | Spec section | Test function | Coverage | Notes |
|-------|-------------|---------------|----------|-------|
| M5.1 | Road data model | `testRoadDataModel` | COVERED | addNode, addEdge, removeNode, removeEdge, splitEdge, joinNodes, resolveLaneCounts, validate |
| M5.2 | Visual road editor | — | UNCOVERED (manual) | Interactive 3D tool; cannot be headlessly tested. See manual section below. |
| M5.3 | Road mesh template | `testRoadTemplate` | COVERED | Fallback box bounds, UV range, missing-template fallback |
| M5.4 | Road geometry gen | `testRoadPageAssignment` | COVERED | Page bucketing, rebucketing after graph change, unloaded-page exclusion, teardown |
| M5.5 | Wedge enumeration | `testRoadWedgeEnumeration` | COVERED | Endpoints, asymmetric lanes, 90°, 270°, T-junction sort, degenerate 300° rejection |
| M5.6 | Wedge geometry | `testRoadWedgeGeometry` | COVERED | Segment extents, asymmetric lanes, 90° fan, 270° fallback, degenerate rejection |
| M5.7 | Edge length constraint| `testRoadEdgeLengthConstraint` | COVERED | snapToIntegerLength, splitEdge snapping, joinNodes warning, validate rejects short edges |
| M5.8 | Mesh assembly per page| `testRoadPageMeshes` | COVERED | Components, buffer fill, mesh creation, finalize, mesh resource, teardown |
| M5.9 | Road physics colliders| `testRoadPageMeshes` (partial) | PARTIAL | bodyId validity and PhysicsColliderComponent presence asserted at page finalize time. **Missing**: physical interaction (raycast against road collider, collider removal on rebuild). |
| M5.9.6| Road collider debug visibility | — | UNCOVERED | New item: toggle for road colliders in physics debug draw. Not tested in headless suite. |
| M5.9.5| Fixup chunk support | `testFixupChunks` | COVERED | writeFixup, sampleHeightAt reads override, save/load round-trip, clearAll |
| M5.10 | Terrain compliance | `testTerrainCompliance` | COVERED | Under-road / mid-fade / fade-end / beyond-fade heights vs base heightmap, save/load + clear round-trips. With sidewalks enabled (M5.15) the falloff origin moves to the sidewalk outer edge and fixups under sidewalk vertices target the strip underside. |
| M5.11 | Roadside prefab spawning | `testRoadSidePrefabs` | COVERED (reworked by M5.13) | Per-edge slot spawn/respawn/despawn, distance gating, edit-mode preview, teardown |
| M5.12 | Serialization + wiring| `testRoadSerialization` | COVERED | Config/nodes/edges/prefab-slot JSON round-trip (legacy `sidePrefabs` migrates); wiring: roadSystem lifecycle covered by testRoadPageAssignment + testRoadPageMeshes |
| M5.13 | Road prefab slots + distance unload | `testRoadSidePrefabs`, `testRoadSerialization` | COVERED | Slot anchors/yaw/Y, per-edge records (`RoadSystem::getEdgePrefabs`), hysteresis, edit-mode preview, no cross-page duplicates, leak-free despawn |
| M5.14 | Edge split polish | `testRoadDataModel` | COVERED | Per-row Split button, slot `edgeT` remap without duplication, surface continuity at the new node |
| M5.15 | Sidewalks | `testRoadWedgeGeometry`, `testRoadSerialization`, `road_geometry_overlap_test` | COVERED | Strip extent/top Y/vertex growth, both segment bands, config round-trip; overlap test runs all A–B–C configs with sidewalks enabled; per-edge gating probe (`runSidewalkGatingProbe`) and driveway-pass lowering probe (`runSidewalkPassProbe`) |
| M5.16 | Node connect tool | `testRoadDataModel` | COVERED | Duplicate/self rejected, cross-page auto-split at the page boundary (incl. corner dedupe), axis-crossing same-page accept, wedge-angle rollback, version bump |
| M5.17 | Corner smoothing | `testRoadDataModel` | COVERED | Degree ≠ 2 rejected, exact lerp, `verticalOffset` averaged, min-length/page-crossing moves reverted |
| M5.18 | Entry aprons | `testRoadApron` | COVERED | Segment bands (extent/inner top flush/outer bottom, both bands), disabled config emits nothing, sidewalks exclude aprons, straight-through wedge extents + template vertex count |
| M5.19 | Notch corner fold fix | `road_geometry_overlap_test` | COVERED | Wide probes (2-way 90°, 3-way T, 4-way, demo-taxi node-16 notch reproduction, all 3+3 lanes): no coplanar/crossing/inverted faces; inter-node boundary overlap at clamped pins deferred to graph-level corner work by design |
| M5.20 | Corner fillets (spline smoothing) | `road_geometry_overlap_test`, `roadWedgeGeometry` case 3c | COVERED | `cornerFilletLength` Bezier fillets on turns/junctions with orientation-independent fold fallback; node-relative seam rows; junction `(1-x)` blend; fillet probes (60° wide, 90° asymmetric, 3-way) and demo-taxi region node 17 clean under `--region-node` |
### 1.1 M5.9 Collider Coverage Detail
The current `testRoadPageMeshes` test checks:
- bodyId is valid (not invalid) after page finalize
- buffer entity has PhysicsColliderComponent
These are **static existence checks** — no physics simulation step, no collision
query, no body-vs-body contact is verified. The following concerns are
untested:
- **Raycast accuracy**: a vertical ray from above the road should hit the road
body at the road top surface Y. A ray from below should hit the underside
(the road slab is a closed solid, not a one-sided surface).
- **Collider removal on page rebuild**: when a road page is marked dirty and
rebuilt, the old collider body must be removed **before** the new mesh is
created (otherwise the old body references a freed mesh).
- **Collider removal on page unload**: the body must be removed from the Jolt
world when the terrain page unloads.
- **Multi-page colliders**: a road that spans two terrain pages should have two
independent static collider bodies, one per page, and the seam between them
must not trap small bodies.
### 1.2 M5.10 Spec-vs-implementation Gap
The spec (TerrainRequirements.md, M5.10) describes a **perpendicular falloff**:
> 4. Apply a smooth falloff perpendicular to the road:
> - Full compliance within the road width (all lanes).
> - Linear fade over `laneWidth * 2` outside the outermost lane.
> - Beyond the fade zone, leave the fixup entry empty (use base heightmap + detail noise).
The current `RoadSystem::complyTerrain()` (RoadSystem.cpp:1564–1629)
**writes fixups only at the vertices of the road surface geometry** — the
top-surface vertices of each wedge and segment. It does **not** iterate beyond
the road surface or apply the `laneWidth * 2` fade zone.
→ **Resolved**: implement falloff now (see section 1.2a).
#### 1.2a Required Implementation — Perpendicular Falloff
In `RoadSystem::complyTerrain()`, after writing the road-underside fixups,
add a second pass that extends laterally from the road centre line:
```
For each road surface point P (world pos of a top vertex):
1. Compute the perpendicular (horizontal) direction away from the road.
2. The road width at P = totalLanes(P) * laneWidth.
3. Full compliance zone: fixup already written by existing code.
4. Fade zone: for lateral offset d in [roadWidth/2, roadWidth/2 + laneWidth*2]:
targetY = interpolate(P.y - roadThickness, baseHeight, fadeFactor)
where fadeFactor = linear from 1.0 to 0.0 over the fade zone
Write targetY into fixup chunk at (P.x + d * perp.x, P.z + d * perp.z)
and (P.x - d * perp.x, P.z - d * perp.z).
5. Beyond roadWidth/2 + laneWidth*2: no write (fall through to base height).
```
To keep the implementation testable, expose a private helper:
```cpp
static float computeComplianceHeight(float roadSurfaceY, float roadThickness,
float baseHeight, float lateralDistance,
float halfRoadWidth, float fadeWidth);
```
This helper is a pure function and can be exercised directly in the test.
### 1.3 Road Collider Debug Draw (New Item M5.9.6)
Currently `TerrainSystem::TerrainBodyDrawFilter` only tracks terrain body IDs.
Road colliders are created by `RoadSystem::createPageCollider()` as static
`NON_MOVING` bodies but are not registered with any draw filter.
**Implementation**:
- `RoadSystem` maintains a `std::set<JPH::BodyID> m_roadBodyIds` (populated
in `createPageCollider`, cleared in `destroyPageCollider`).
- A `RoadBodyDrawFilter` (or a combined filter) is wired into
`JoltPhysicsWrapper::setBodyDrawFilter()`.
- The `TerrainEditor` gains a "Show Road Colliders" checkbox
(`getShowRoadColliders()` / `setShowRoadColliders()` on `TerrainSystem` →
forwarded to `RoadSystem`).
- Default: off (same behaviour as terrain colliders).
## 2. Missing Automated Tests (Implementation Targets)
These tests must be added to `TerrainTests.cpp`. The test runner already
registers the existing 9 M5 test functions; these 3 new ones (or 4, if
`testRoadColliderRebuild` is separate) will be appended.
### 2.1 `testRoadColliderInteraction`
**Purpose**: verify that the generated road `MeshShape` collider produces
correct physical contact (headless-compatible — Jolt CPU queries).
**Steps**:
1. Create a terrain entity with a two-node road edge (known positions).
2. Pump frames until page activation + road mesh + collider exist.
3. Retrieve `RoadPageGeometry::bodyId`.
4. **Raycast from above**: `JPH::RRayCast` from `(roadCenter.x, roadSurfaceY + 50, roadCenter.z)` straight down.
- Verify hit: body ID matches road body, hit Y ≈ `roadSurfaceY` (± 0.05).
5. **Raycast from below**: cast from `(roadCenter.x, roadSurfaceY - 50, roadCenter.z)` upward.
- Verify hit: body ID matches road body, hit Y ≈ `roadSurfaceY - roadThickness` (underside of slab).
6. **Rebuild**: bump graph version, pump frames, verify old body ID is now invalid (removed) and a new body exists.
7. **Teardown**: deactivate terrain, verify body ID is invalid (removed from physics world).
### 2.2 `testTerrainCompliance`
**Purpose**: verify `RoadSystem::complyTerrain()` writes fixups that the terrain
sampling path observes, including the perpendicular falloff.
**Steps**:
1. Create terrain, add a two-node straight road edge at known positions
(e.g. nodes at (100,0,100) and (500,0,100)).
2. Pump frames until road mesh exists.
3. Call `rs->complyTerrain(ts, roadThickness, laneWidth)`.
4. Sample at a point directly under the road centre:
- Verify `ts->sampleHeightAt(wx, wz) ≈ roadSurfaceY - roadThickness` (± 0.1).
5. Sample at a point far from the road (e.g. (2000, 2000)):
- Verify height matches base heightmap (no fixup).
6. Verify `ts->getFixupChunkCount()` > 0.
7. Test the falloff:
- Compute the total half-width at the sample point: `(lanesAtoB + lanesBtoA) * laneWidth / 2`.
- Sample at `halfWidth + laneWidth` outside (mid-fade): height should be between the full-compliance value and the base height.
- Sample at `halfWidth + laneWidth * 2` outside (fade end): height ≈ base height (± 0.1).
- Sample at `halfWidth + laneWidth * 3` outside (beyond fade): height == base height.
8. `ts->saveFixups()`, deactivate/reactivate, re-sample same points → same values.
9. `ts->clearAllFixups()` → samples revert to base height.
**Test helper**: `computeComplianceHeight()` unit-test assertions on known inputs (pure math, no scene needed).
### 2.3 `testRoadSidePrefabs`
**Purpose**: verify the M5.13 slot-based roadside prefab lifecycle in
`RoadSystem` — spawn at the correct anchor, respawn on slot edits,
distance gating, edit-mode preview and leak-free teardown. (Originally
written against the M5.11 `spawnSidePrefabs()`/`sidePrefabs` vector;
fully reworked with M5.13.)
**Test prefab fixture**: a minimal `.prefab` JSON file placed in
`src/features/editScene/tests/prefabs/tiny_cube.prefab`. This is a self-contained
test resource; it must not be mixed with user-created prefabs.
**Steps**:
1. Ensure the test prefab is loadable (the file is registered in a resource
group accessible during tests).
2. Create terrain, add a road edge, fill its `prefabLeft` slot with
`tiny_cube.prefab` (`edgeT=0.5, lateralOffset=0`) and set huge
spawn/despawn distances so the test camera is always in range.
3. Pump frames until road mesh + prefabs exist.
4. Verify the per-edge record (`RoadSystem::getEdgePrefabs()` keyed by
`RoadSystem::edgePrefabKey(n1, n2)`) has a live `left` instance with a
`TransformComponent` at the left-curb anchor (edge midpoint offset by
the side half-width), road-level Y and yaw aligned to the edge
direction.
5. Add an unrelated node+edge → pump frames: the prefab must stay alive
(spawn state is per edge, not per page — no cross-page duplicates).
6. Edit the slot (`edgeT 0.5 → 0.25`) → pump frames: old instance
destroyed, new instance at the remapped anchor.
7. Shrink spawn/despawn distances to 1 m → the instance despawns and the
root scene-node child count returns to baseline (no leak).
8. Enable road edit mode and select the edge → the prefab force-spawns
(edit-time preview); leaving edit mode despawns it again.
9. Restore large distances (respawn), then destroy the terrain entity →
the prefab entity is not alive (teardown).
## 3. Manual Verification Procedures
Tests not possible in headless/automated mode. Run `./editSceneEditor` in
interactive mode (with display).
### 3.1 Road Editor (M5.2) — Interactive Walkthrough
| Step | Action | Expected Result |
|------|--------|-----------------|
| 1 | Create terrain entity (Add Entity → Terrain) | Terrain renders, Road Edit Mode checkbox appears in Terrain panel |
| 2 | Enable "Road Edit Mode" | Sculpt/Paint mode deactivates, brush decal hides, toolbar appears |
| 3 | Switch to "Add Node" tool, click on terrain surface | A cube marker appears at the clicked position, node is added |
| 4 | Click a second location | Second cube marker appears |
| 5 | Switch to "Move" tool, click first node → click "Connect" on second node in node list | Edge line appears between the two nodes, road-width indicator renders |
| 6 | Select the edge in the edge list | Edge line turns magenta, A→B direction arrow appears |
| 7 | In edge inspector, set `lanesAtoB = 2, lanesBtoA = 1` | Road-width indicator shows 2 lanes on A→B side, 1 lane on B→A side |
| 8 | Drag a node with gizmo | Node follows mouse, Y auto-snaps to terrain surface |
| 9 | Split edge (click "Split") | New node appears at midpoint, two edges replace the one |
| 10 | "Validate Road Graph" button | Modal shows "Graph is valid" (or errors if intentional bad state) |
| 11 | Remove selected node | Node cube disappears, connected edges disappear |
| 12 | Disable "Road Edit Mode" | All visual aids vanish, brush decal reappears if paint/sculpt mode enabled |
| 13 | Save scene, reload | Road nodes, edges, and config are restored |
### 3.2 Road Mesh Rendering — Visual Check
| Step | Action | Expected Result |
|------|--------|-----------------|
| 1 | After step 7 of 3.1, look at the terrain surface | Road mesh renders on top of terrain with correct material |
| 2 | Move camera far away (> roadVisibilityDistance) | Road meshes disappear (distance culling) |
| 3 | Move camera to distance between lodDistance and visibilityDistance | Road meshes visibly reduce detail (LOD 50%) |
| 4 | Move camera very close | Road surface shows full detail, top face visible, no z-fighting with terrain |
| 5 | Check road-material appearance | Material matches `RoadMaterial` (or config's roadMaterialName) |
### 3.3 Physics Colliders — Interactive
| Step | Action | Expected Result |
|------|--------|-----------------|
| 1 | Enable physics debug draw in editor | Body wireframes appear |
| 2 | Enable "Show Terrain Colliders" | Terrain collider wireframes appear |
| 3 | Enable "Show Road Colliders" (new checkbox) | Road collider wireframes appear, matching the road mesh exactly |
| 4 | Spawn a dynamic rigid body (e.g. a sphere via editor) above the road | Body falls and rests on the road surface, does not fall through |
| 5 | Push the body along the road | Body slides along the road surface, does not sink into seams between pages |
| 6 | Walk a character (if player controller exists) onto the road | Character stands on road surface, walks along it without falling through |
### 3.4 Terrain Compliance — Visual + Sampling
| Step | Action | Expected Result |
|------|--------|-----------------|
| 1 | After 3.1, click "Comply Terrain to Roads" | Message logged: "RoadSystem: terrain compliance applied for N pages" |
| 2 | Look closely at the road from the side | Terrain is flush with the underside of the road (no gap, terrain does not poke through) |
| 3 | Look at the road edge | **Terrain slopes smoothly down from the road edge over ~laneWidth*2 distance** (falloff visible). No abrupt cliff. |
| 4 | Disable road rendering (toggle visibility) | The terrain under the road shows a depression matching the road footprint, with smooth shoulders at edges |
| 5 | Save scene, reload | Fixup data persists; terrain still conforms to roads after reload |
| 6 | "Clear All Fixups" button | Terrain returns to its original surface (no road depression) |
### 3.5 Roadside Prefabs — Visual
| Step | Action | Expected Result |
|------|--------|-----------------|
| 1 | In edge inspector, add a side prefab with a known valid prefab path, `edgeT=0.5`, `sideOffset=3`, `leftSide=true` | A prefab instance appears beside the road at the midpoint, offset 3 units to the left |
| 2 | Move camera far away and back | Prefab visible when close, absent when far (handled by PrefabSystem's own distance logic, not RoadSystem) |
| 3 | Delete the edge or remove the terrain | Prefab instance is destroyed |
| 4 | Save scene, reload | Side prefabs are re-spawned from edge data |
### 3.6 Integration — NavMesh
| Step | Action | Expected Result |
|------|--------|-----------------|
| 1 | After 3.1, build navigation mesh (if NavMeshSystem UI exists) | Navigation mesh covers the road surface (walkable area) |
| 2 | Visualize navmesh (if supported) | Road area shows as walkable, connected to terrain walkable areas |
### 3.7 Memory / Leak Check
| Step | Action | Expected Result |
|------|--------|-----------------|
| 1 | Create terrain, add roads, enable compliance, add prefabs | Terrain + roads render |
| 2 | Delete terrain entity | No crash, all road visual aids/meshes/colliders/prefabs destroyed |
| 3 | Repeat steps 1–2 five times | No growing memory (watch process RSS) |
| 4 | Exit editor | Clean shutdown, no Jolt/OGRE assertions in console |
## 4. Implementation Work Items
These are the concrete code changes needed to close all gaps. Each item is
ordered by dependency.
| # | Item | Files to modify | Status |
|---|------|-----------------|--------|
| W0 | Sweep-based wedge geometry (M5.6 gaps + overlaps) — **superseded 2026-08-02**: the radial curb sweep left node-center holes, diagonal > 180° bands, bowed through-roads and double-height segments; replaced by the mitered polyline sweep (`computeWedgeOutline`/`triangulateOutline` + `emitSlab`) per user direction | `RoadSystem.cpp`, `RoadSystem.hpp`, `TerrainTests.cpp` | ✅ DONE (2026-08-02, reworked) |
| W1 | M5.10 perpendicular falloff in `complyTerrain()` | `RoadSystem.cpp` | ✅ DONE (2026-08-16 rework: per-sample fade writes, see below) |
| W2 | Helper `computeComplianceHeight()` + unit test | `RoadSystem.cpp`, `TerrainTests.cpp` | ✅ DONE |
| W3 | `testTerrainCompliance` (automated) | `TerrainTests.cpp`, `TerrainTests.hpp` | ✅ DONE |
| W4 | `testRoadColliderInteraction` (automated) | `TerrainTests.cpp`, `TerrainTests.hpp` | ✅ DONE |
| W5 | Road collider debug draw toggle (M5.9.6) | `RoadSystem.hpp/.cpp`, `TerrainSystem.hpp/.cpp`, `TerrainEditor.hpp` | ✅ DONE (2026-08-16) |
| W6 | Test prefab fixture `tiny_cube.prefab` | `src/features/editScene/tests/prefabs/tiny_cube.prefab` (new) | ✅ DONE |
| W7 | `testRoadSidePrefabs` (automated) | `TerrainTests.cpp`, `TerrainTests.hpp` | ✅ DONE |
| W8 | Register new tests in `TerrainTestRunner::run()` | `TerrainTests.cpp` | ✅ DONE |
## 5. Summary
| Area | Status |
|------|--------|
| M5.1–M5.8 automated coverage | ✅ Adequate (8/8 sub-items have tests) |
| M5.6 wedge geometry | ✅ Mitered polyline sweep (2026-08-02) — replaces the broken radial curb sweep (W0 rework): wedge = one mesh bent along the 2-segment centerline polyline, exact width at corners, no node holes/overlaps |
| M5.9 automated coverage | ✅ W4 adds raycast+rebuild verification |
| M5.9.6 road collider debug toggle | ✅ Implemented (W5, 2026-08-16) |
| M5.10 perpendicular falloff | ✅ Implemented → W1+W2 |
| M5.10 automated coverage | ✅ W3 covers falloff + save/load |
| M5.11 automated coverage | ✅ W6+W7 cover prefab spawn + teardown |
| M5.12 automated coverage | ✅ Adequate |
| Manual verification steps | 📋 Defined (sections 3.1–3.7) |
| Open questions | ✅ All resolved (section 0) |
**Exit criteria** — Milestone 5 is fully verified when:
- [x] W0 (wedge geometry) implemented and tested — mitered polyline sweep
(2026-08-02 rework; radial curb sweep attempt reverted).
- [x] W1–W4, W6–W8 implemented.
- [x] `./editSceneEditor --headless --run-terrain-tests=1` passes with all
22 tests green per iteration (re-verified 2026-08-16 after the
re-audit fixes below).
- [ ] Manual verification walkthroughs 3.1–3.7 are executed and pass.
- [x] `ctest -R editSceneTerrainTest` passes in CI (verified 2026-08-16,
102 s).
- [x] W5 (road collider debug draw toggle) implemented (2026-08-16).
## 6. Re-audit fixes (2026-08-16)
A code-level re-audit (prompted by "do not trust completion status") found
several items marked ✅ that were not actually working; all fixed and now
covered by the 22-test headless suite:
- **W1 was not implemented**: `complyTerrain()` wrote only full-compliance
fixups under the road; the `laneWidth * 2` perpendicular fade did not
exist. Implemented as `RoadSystem::writeComplianceFalloff()` (wedge
outer curb + both segment sides), with `TerrainSystem::sampleBaseHeightAt()`
added so fade targets blend toward the natural height rather than reading
back already-written fixups.
- **W5 was not implemented**: the `TerrainBodyDrawFilter` road-ID set
(`RoadSystem::m_roadBodyIds`) was never populated, so the "Show Road
Colliders" checkbox drew nothing. `createPageCollider()` /
`destroyPageCollider()` now maintain the set.
- **Fixup chunk grid unusable at spec density**: M5.9.5/4.2's literal
addressing (chunk span `worldSize/256`, 256x256 samples → one sample per
`worldSize/65536` units) cannot be filled by the compliance writer, and
page vertices (sampling at ~`worldSize/64` spacing) never see the fixups.
Chunks now span the full per-page `worldSize` with 256x256 samples (one
sample per `worldSize/256` units — "same as base heightmap", section 4.2);
chunk indices coincide with page indices. Fixup files written before this
change used the old grid and must be deleted
(`heightmaps/<terrainId>/terrain_fixup/` or "Clear All Fixups").
- **Sentinel blending toward 0.0**: partially written chunk cells blended
toward zero, digging trenches at fixup borders; unwritten corners now fall
back to the natural (base + noise) height at the corner.
- **Falloff writes were sparse point splats** (invisible between chunk
cells). The fade pass now evaluates the exact fade target at every fixup
sample inside the band (`writeFixupSample()`), so bilinear reads reproduce
the linear ramp. The fade pass runs BEFORE the full-compliance pass so
slab-underside values win on shared samples.
- **Segment underside depth mismatch**: the segment pass wrote
`centerY - roadThickness` while the wedge pass wrote `topY -
roadThickness` (= `centerY - roadThickness/2`); unified on the half
thickness.
- **Roadside prefabs leaked on graph rebuild** (M5.11): `buildPageMeshes()`
rebuilt mesh + collider but never destroyed `spawnedPrefabs`; old
instances survived and new ones spawned on top. Old prefabs are now
destroyed at the start of every page mesh rebuild. *(Superseded by
M5.13: spawn state moved out of the pages into per-edge records and
teardown goes through the shared `PrefabSystem::destroyInstance()`.)*
- **Roadside prefabs were serialized with the scene** (M5.12): spawned
instances kept their `EditorMarkerComponent` and, as flecs children of the
terrain entity, were written into the scene file and duplicated on reload.
`spawnSidePrefabs()` now strips the marker (runtime-only entities).
*(M5.13: instances are created marker-free by the new spawn path.)*
- **Tests strengthened**: `terrainCompliance` checks under-road / mid-fade /
fade-end / beyond-fade heights against the natural (procedural) base
height plus save/load and clear round-trips; `roadColliderInteraction`
raycasts the slab top (40.15) and underside (39.85) on a Y=40 road clear
of the terrain and verifies collider replacement across a graph rebuild;
`roadSidePrefabs` verifies slot anchor position/yaw/Y, respawn on slot
edit, distance gating, edit-mode preview and teardown destruction; the
`tiny_cube.prefab` fixture is staged next to the test binary by CMake.
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
#pragma once
#include <atomic>
#include <chrono>
#include <string>
/**
* Generate a unique persistent identifier.
* The value is generated once and should be stored in a component field;
* it will survive scene save/load because it is serialized with the component.
*/
inline std::string generatePersistentId()
{
static std::atomic<uint64_t> s_counter{ 0 };
uint64_t counter = ++s_counter;
uint64_t timestamp = static_cast<uint64_t>(
std::chrono::high_resolution_clock::now()
.time_since_epoch()
.count());
return "id_" + std::to_string(timestamp) + "_" +
std::to_string(counter);
}
+51 -1
View File
@@ -1,5 +1,7 @@
#include "EditorCamera.hpp"
#include <OgreViewport.h>
#include <cmath>
#include <algorithm>
EditorCamera::EditorCamera(Ogre::SceneManager *sceneMgr,
Ogre::RenderWindow *window)
@@ -102,7 +104,8 @@ void EditorCamera::updateFPSMovement(float deltaTime)
// Apply movement
if (movement.squaredLength() > 0.0001f) {
movement.normalise();
m_target += movement * FPS_SPEED * deltaTime;
float speed = m_flySpeed * (m_keyShift ? SHIFT_BOOST : 1.0f);
m_target += movement * speed * deltaTime;
m_targetNode->setPosition(m_target);
m_cameraMan->setTarget(m_targetNode);
}
@@ -163,6 +166,7 @@ void EditorCamera::handleMouseRelease(const OgreBites::MouseButtonEvent &evt)
false; // Disable FPS mode when right mouse is released
// Reset key states
m_keyW = m_keyS = m_keyA = m_keyD = m_keyQ = m_keyE = false;
m_keyShift = false;
} else if (evt.button == OgreBites::BUTTON_MIDDLE) {
m_panning = false;
}
@@ -198,9 +202,28 @@ void EditorCamera::handleKeyboard(const OgreBites::KeyboardEvent &evt)
case 'E':
m_keyE = pressed;
break;
case OgreBites::SDLK_LSHIFT:
m_keyShift = pressed;
break;
}
}
void EditorCamera::handleMouseWheel(float y)
{
if (y == 0.0f)
return;
m_distance *= std::pow(0.85f, y);
if (m_distance < 0.05f)
m_distance = 0.05f;
if (m_distance > 500000.0f)
m_distance = 500000.0f;
}
void EditorCamera::setFlySpeed(float s)
{
m_flySpeed = std::max(1.0f, std::min(100000.0f, s));
}
void EditorCamera::focusOn(const Ogre::Vector3 &point)
{
m_target = point;
@@ -215,6 +238,15 @@ void EditorCamera::setPosition(const Ogre::Vector3 &pos)
m_cameraMan->setTarget(m_targetNode);
}
void EditorCamera::shiftPosition(const Ogre::Vector3 &offset)
{
m_position += offset;
m_target += offset;
m_targetNode->setPosition(m_target);
m_cameraNode->setPosition(m_cameraNode->getPosition() + offset);
m_cameraMan->setTarget(m_targetNode);
}
Ogre::Ray EditorCamera::getMouseRay(float screenX, float screenY) const
{
// Convert pixel coordinates to normalized viewport coordinates (0-1)
@@ -235,3 +267,21 @@ void EditorCamera::updateCameraPosition()
m_cameraMan->setYawPitchDist(Ogre::Degree(m_yaw), Ogre::Degree(m_pitch),
m_distance);
}
void EditorCamera::resetPose()
{
m_position = Ogre::Vector3(0, 5, 15);
m_target = Ogre::Vector3(0, 0, 0);
m_distance = 15.0f;
m_yaw = 0.0f;
m_pitch = -20.0f;
m_fpsMode = false;
m_rotating = false;
m_panning = false;
m_keyW = m_keyS = m_keyA = m_keyD = m_keyQ = m_keyE = false;
m_keyShift = false;
m_cameraMan->setStyle(OgreBites::CS_ORBIT);
m_cameraMan->setTarget(m_targetNode);
updateCameraPosition();
}
+48 -1
View File
@@ -49,6 +49,43 @@ public:
*/
void setPosition(const Ogre::Vector3 &pos);
/**
* Shift camera and target by a render-space offset (used by
* RenderOriginSystem on rebase)
*/
void shiftPosition(const Ogre::Vector3 &offset);
/**
* Orbit target (render space)
*/
Ogre::Vector3 getTarget() const
{
return m_target;
}
/**
* Orbit distance
*/
float getDistance() const
{
return m_distance;
}
/**
* Mouse wheel: zoom the orbit distance (multiplicative).
*/
void handleMouseWheel(float y);
/**
* Fly (FPS-mode) speed in units/second; Shift boosts x10.
* Range 1 .. 100000 (100 km/s) for streamed-world navigation.
*/
float getFlySpeed() const
{
return m_flySpeed;
}
void setFlySpeed(float s);
/**
* Get camera position
*/
@@ -70,6 +107,14 @@ public:
return m_fpsMode;
}
/**
* Reset the camera to the default startup pose (position (0,5,15),
* target origin, yaw 0, pitch -20, distance 15) and clear any input
* state. Used by scene switching so an editor-mode scene switch
* looks like a fresh editor start.
*/
void resetPose();
private:
void updateCameraPosition();
void updateFPSMovement(float deltaTime);
@@ -103,11 +148,13 @@ private:
bool m_keyD;
bool m_keyQ;
bool m_keyE;
bool m_keyShift = false;
// Movement speeds
static constexpr float ROTATION_SPEED = 0.3f;
static constexpr float PAN_SPEED = 0.01f;
static constexpr float FPS_SPEED = 10.0f;
float m_flySpeed = 10.0f; // units/second, see setFlySpeed
static constexpr float SHIFT_BOOST = 10.0f;
};
#endif // EDITSCENE_EDITORCAMERA_HPP
@@ -0,0 +1,65 @@
#ifndef EDITSCENE_AIDRIVER_HPP
#define EDITSCENE_AIDRIVER_HPP
#pragma once
#include <vector>
#include <OgreVector3.h>
/**
* One waypoint of an AiDriverComponent route (demo-taxi phase 5.1,
* junction fields phase 5.3).
*/
struct AiRoutePoint {
/* Route position (world/render space matching the scene). */
Ogre::Vector3 position;
/* Road node id of the junction (degree >= 3 road node) this
* point passes through, -1 when the point is not a junction.
* Junction points make the AI slow, yield to cross traffic and
* reserve the junction before crossing. */
int junctionNodeId = -1;
/* Junction occupancy center — the road node's position (the
* route point itself is lane-offset). Only meaningful when
* junctionNodeId >= 0. */
Ogre::Vector3 junctionCenter = Ogre::Vector3::ZERO;
};
/**
* AI driver component (demo-taxi phase 5.1).
*
* Turns an entity with a VehicleComponent into an AI-driven vehicle:
* AiDriverSystem follows `route` (a world-space polyline, e.g. built
* from the road graph by RoadPath::findPath plus a right-lane offset)
* with pure-pursuit steering and a throttle/brake speed controller,
* writing the same VehicleComponent::inputForward/inputRight/inputBrake
* fields the player controller writes — VehicleSystem stays the single
* consumer of those fields. A vehicle carrying the player
* (PlayerControlledComponent) is never touched.
*
* The route polyline is XZ-driven: waypoint Y is ignored for steering
* and arrival (the physics keeps the car on the ground), it only
* carries the road surface height for callers that want it.
*/
struct AiDriverComponent {
/* Route polyline (world/render space matching the scene). */
std::vector<AiRoutePoint> route;
/* Index of the waypoint the car currently drives toward. */
size_t routeIndex = 0;
/* true: wrap to route[0] after the last waypoint (circuit);
* false: brake to a stop at the last waypoint and set arrived. */
bool loop = false;
/* Cruise speed in m/s (the per-edge speed limit folds into this
* when routes carry limits — phase 5.1 keeps a single value). */
float cruiseSpeed = 11.0f;
/* Runtime: the car reached the end of a non-looping route. */
bool arrived = false;
/* Runtime: number of completed route wraps (loop laps) — used by
* the headless traffic test. */
int lapsCompleted = 0;
/* Runtime: junction waypoints were classified against the road
* graph (AiDriverSystem::classifyJunctions). Route builders that
* flag junctions themselves set this to skip the auto pass. */
bool junctionsClassified = false;
};
#endif /* EDITSCENE_AIDRIVER_HPP */
@@ -1,9 +1,99 @@
#include "AnimationTree.hpp"
AnimationTreeComponent::AnimationTreeComponent()
: root()
: treeName()
, enabled(true)
, useRootMotion(false)
, dirty(true)
, treeVersion(0)
{
}
nlohmann::json animationTreeNodeToJson(const AnimationTreeNode &node)
{
nlohmann::json json;
json["type"] = node.type;
if (!node.name.empty())
json["name"] = node.name;
if (!node.animationName.empty())
json["animationName"] = node.animationName;
if (node.speed != 1.0f)
json["speed"] = node.speed;
if (node.fadeSpeed != 7.5f)
json["fadeSpeed"] = node.fadeSpeed;
if (!node.endTransitions.empty()) {
nlohmann::json trans = nlohmann::json::object();
for (const auto &pair : node.endTransitions)
trans[pair.first] = pair.second;
json["endTransitions"] = trans;
}
if (!node.connectLinks.empty()) {
nlohmann::json links = nlohmann::json::array();
for (const auto &link : node.connectLinks) {
nlohmann::json lj = nlohmann::json::object();
lj["from"] = link.from;
lj["to"] = link.to;
/* Omit the default so old files stay stable. */
if (link.waitMode != "atEnd")
lj["waitMode"] = link.waitMode;
links.push_back(lj);
}
json["connectLinks"] = links;
}
if (!node.startState.empty())
json["startState"] = node.startState;
if (!node.endState.empty())
json["endState"] = node.endState;
if (!node.children.empty()) {
json["children"] = nlohmann::json::array();
for (const auto &child : node.children)
json["children"].push_back(animationTreeNodeToJson(child));
}
return json;
}
void animationTreeNodeFromJson(AnimationTreeNode &node,
const nlohmann::json &json)
{
node.type = json.value("type", "animation");
node.name = json.value("name", "");
node.animationName = json.value("animationName", "");
node.speed = json.value("speed", 1.0f);
node.fadeSpeed = json.value("fadeSpeed", 7.5f);
node.endTransitions.clear();
if (json.contains("endTransitions") &&
json["endTransitions"].is_object()) {
for (auto it = json["endTransitions"].begin();
it != json["endTransitions"].end(); ++it)
node.endTransitions[it.key()] = it.value();
}
/* Phase 6.7: connect links + start/end markers. Files without
* these keys keep their defaults (no links, legacy init). */
node.connectLinks.clear();
node.startState.clear();
node.endState.clear();
if (json.contains("connectLinks") &&
json["connectLinks"].is_array()) {
for (const auto &linkJson : json["connectLinks"]) {
if (!linkJson.is_object())
continue;
AnimationTreeLink link;
link.from = linkJson.value("from", "");
link.to = linkJson.value("to", "");
link.waitMode = linkJson.value("waitMode", "atEnd");
if (link.from.empty() || link.to.empty())
continue;
node.connectLinks.push_back(link);
}
}
node.startState = json.value("startState", "");
node.endState = json.value("endState", "");
node.children.clear();
if (json.contains("children") && json["children"].is_array()) {
for (const auto &childJson : json["children"]) {
AnimationTreeNode child;
animationTreeNodeFromJson(child, childJson);
node.children.push_back(child);
}
}
}
@@ -2,9 +2,37 @@
#define EDITSCENE_ANIMATIONTREE_HPP
#pragma once
#include <Ogre.h>
#include <nlohmann/json.hpp>
#include <vector>
#include <unordered_map>
/**
* A directed connect link between two states of one state machine
* ("B may follow A", Phase 6.7 travel).
*
* waitMode picks when a hop along this link may fire:
* "atEnd" - (default, the Phase 6.7 contract) the hop fires when
* the current clip completes: a one-shot at its end, a
* looping clip at the loop boundary. Use for
* intermediate chain states that must play out.
* "immediate" - the hop fires on the next runtime update through the
* usual cross-fade, without waiting for the clip.
* Use for locomotion links (idle/walking/running/swim,
* driving) where responsiveness beats the chain feel -
* a looping idle clip would otherwise hold the hop for
* its whole length (the stock idle clip is ~10 s).
*/
struct AnimationTreeLink {
Ogre::String from;
Ogre::String to;
Ogre::String waitMode = "atEnd";
bool isImmediate() const
{
return waitMode == "immediate";
}
};
/**
* A node in the animation tree.
*
@@ -24,6 +52,15 @@ struct AnimationTreeNode {
std::vector<AnimationTreeNode> children;
/* For stateMachine nodes: auto-transition when animation ends */
std::unordered_map<Ogre::String, Ogre::String> endTransitions;
/* For stateMachine nodes: directed travel links (Phase 6.7) */
std::vector<AnimationTreeLink> connectLinks;
/* For stateMachine nodes: state entered on activation (empty =
* first "state" child, legacy behavior) */
Ogre::String startState;
/* For stateMachine nodes used as a nested machine (a state child
* of an upper machine): state the machine must reach before the
* upper node may deactivate (empty = immediate, legacy behavior) */
Ogre::String endState;
AnimationTreeNode() = default;
@@ -138,6 +175,11 @@ struct AnimationTreeNode {
}
};
/* Serialization helpers shared between registry and scene serializer. */
nlohmann::json animationTreeNodeToJson(const AnimationTreeNode &node);
void animationTreeNodeFromJson(AnimationTreeNode &node,
const nlohmann::json &json);
/**
* Animation tree component for hierarchical state-machine-based animation.
*
@@ -145,16 +187,15 @@ struct AnimationTreeNode {
* which Ogre AnimationStates are active and their blend weights.
*/
struct AnimationTreeComponent {
AnimationTreeNode root;
/* Name of the tree in AnimationTreeRegistry. */
Ogre::String treeName;
bool enabled = true;
bool useRootMotion = false;
bool dirty = true;
/* If set, the tree root is copied from the named template */
Ogre::String templateName;
/* Runtime: last copied template version (not serialized) */
uint64_t templateVersion = 0;
/* Runtime: last resolved registry version (not serialized) */
uint64_t treeVersion = 0;
/* Runtime: current state of each state machine (not serialized) */
std::unordered_map<Ogre::String, Ogre::String> currentStates;
@@ -1,14 +1,14 @@
#include "AnimationTree.hpp"
#include "../ui/ComponentRegistration.hpp"
#include "../ui/AnimationTreeEditor.hpp"
#include "../systems/AnimationTreeRegistry.hpp"
#include "Transform.hpp"
static AnimationTreeComponent createDefaultTree()
static Ogre::String createDefaultTree()
{
AnimationTreeComponent at;
at.root.type = "output";
at.root.speed = 1.0f;
AnimationTreeNode root;
root.type = "output";
root.speed = 1.0f;
AnimationTreeNode sm;
sm.type = "stateMachine";
@@ -25,9 +25,17 @@ static AnimationTreeComponent createDefaultTree()
state.children.push_back(anim);
sm.children.push_back(state);
at.root.children.push_back(sm);
root.children.push_back(sm);
return at;
AnimationTreeRegistry &reg = AnimationTreeRegistry::getSingleton();
Ogre::String name = reg.generateUniqueName("default");
AnimationTreeRegistry::AnimationTreeDefinition def;
def.name = name;
def.root = root;
reg.registerTree(def);
return name;
}
REGISTER_COMPONENT_GROUP("Animation Tree", "Rendering",
@@ -45,7 +53,8 @@ REGISTER_COMPONENT_GROUP("Animation Tree", "Rendering",
->createChildSceneNode();
e.set<TransformComponent>(transform);
}
AnimationTreeComponent at = createDefaultTree();
AnimationTreeComponent at;
at.treeName = createDefaultTree();
at.dirty = true;
e.set<AnimationTreeComponent>(at);
},
@@ -1,20 +0,0 @@
#ifndef EDITSCENE_ANIMATIONTREETEMPLATE_HPP
#define EDITSCENE_ANIMATIONTREETEMPLATE_HPP
#pragma once
#include <Ogre.h>
/**
* Template marker for reusable animation trees.
*
* Entities with this component serve as shared animation tree templates.
* They should also have an AnimationTreeComponent for editing the tree.
* Other entities reference the template by name via
* AnimationTreeComponent::templateName.
*/
struct AnimationTreeTemplate {
Ogre::String name;
uint64_t version = 1;
};
#endif // EDITSCENE_ANIMATIONTREETEMPLATE_HPP
@@ -1,23 +0,0 @@
#include "AnimationTreeTemplate.hpp"
#include "../ui/ComponentRegistration.hpp"
#include "../ui/AnimationTreeTemplateEditor.hpp"
REGISTER_COMPONENT_GROUP("Animation Tree Template", "Animation",
AnimationTreeTemplate, AnimationTreeTemplateEditor)
{
registry.registerComponent<AnimationTreeTemplate>(
AnimationTreeTemplate_name, AnimationTreeTemplate_group,
std::make_unique<AnimationTreeTemplateEditor>(),
// Adder
[](flecs::entity e) {
if (!e.has<AnimationTreeTemplate>()) {
e.set<AnimationTreeTemplate>({});
}
},
// Remover
[](flecs::entity e) {
if (e.has<AnimationTreeTemplate>()) {
e.remove<AnimationTreeTemplate>();
}
});
}
@@ -51,6 +51,19 @@
* (for quest rewards, etc.).
* params="itemId,itemName,itemType,count,weight,value"
*
* --- Scene switch node ---
* "switchScene" - Leaf: queues a scene switch via
* EditorApp::switchScene (name=scene file path).
* The switch is deferred to the start of the next
* frame, so it is safe from AI and actuator trees.
* params (optional):
* "@EntityName" - teleport the player to a named
* entity with a Transform component
* in the new scene
* "x,y,z" - teleport to a world-space position
* "x,y,z,yaw" - position + yaw in degrees
* Returns success when the switch was queued.
*
* --- Lua node ---
* "luaTask" - Leaf: calls a registered Lua function.
* name = registered node handler name.
@@ -105,6 +118,7 @@ struct BehaviorTreeNode {
type == "hasItemByName" || type == "countItem" ||
type == "pickupItem" || type == "dropItem" ||
type == "useItem" || type == "addItemToInventory" ||
type == "switchScene" ||
type == "luaTask";
}
};
@@ -1,5 +1,28 @@
#include "CellGrid.hpp"
#include <algorithm>
#include <random>
#include <cstdio>
const std::string& CellGridComponent::ensureGridUid()
{
if (!gridUid.empty())
return gridUid;
// Random UUID-like hex string (8-4-4-4-12), no external dependency.
std::random_device rd;
std::mt19937_64 gen(((uint64_t)rd() << 32) ^ (uint64_t)rd());
uint64_t a = gen(), b = gen();
char buf[40];
snprintf(buf, sizeof(buf), "%08x-%04x-%04x-%04x-%04x%08x",
(unsigned)(a & 0xffffffffu),
(unsigned)((a >> 32) & 0xffffu),
(unsigned)(((a >> 48) & 0x0fffu) | 0x4000u), // version 4
(unsigned)((b & 0x3fffu) | 0x8000u), // variant 1
(unsigned)((b >> 16) & 0xffffu),
(unsigned)((b >> 32) & 0xffffffffu));
gridUid = buf;
return gridUid;
}
Cell* CellGridComponent::findCell(int x, int y, int z)
{
@@ -45,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);
@@ -130,6 +163,8 @@ const char* CellGridComponent::getFlagName(uint64_t flag)
case CellFlags::IntWindowXPos: return "Int Window X+";
case CellFlags::IntWindowZPos: return "Int Window Z+";
case CellFlags::IntWindowZNeg: return "Int Window Z-";
case CellFlags::StairWell: return "Stairwell";
case CellFlags::ExteriorStair: return "Exterior Stair";
default: return "Unknown";
}
}
@@ -174,5 +209,7 @@ std::vector<std::pair<uint64_t, const char*>> CellGridComponent::getAllFlags()
{ CellFlags::IntWindowXPos, "Int Window X+" },
{ CellFlags::IntWindowZPos, "Int Window Z+" },
{ CellFlags::IntWindowZNeg, "Int Window Z-" },
{ CellFlags::StairWell, "Stairwell" },
{ CellFlags::ExteriorStair, "Exterior Stair" },
};
}
+161 -1
View File
@@ -3,11 +3,14 @@
#include <cstdint>
#include <vector>
#include <string>
#include <map>
#include <unordered_map>
#include <chrono>
#include <flecs.h>
#include <Ogre.h>
#include "../layoutlib/BuildingPlan.hpp"
/**
* @brief Cell flags for building geometry
*
@@ -51,7 +54,24 @@ namespace CellFlags {
constexpr uint64_t IntWindowXPos = 1ULL << 23;
constexpr uint64_t IntWindowZPos = 1ULL << 24;
constexpr uint64_t IntWindowZNeg = 1ULL << 25;
// Phase 7.1: a stair cell. A cell carrying this flag hosts a
// straight-flight or dogleg staircase rising from its own floor
// (level y) to the floor above (level y+1): the generator cuts
// the cell's own ceiling and the floor of the cell directly
// above, encloses the opening with a slab-edge ring and fills it
// with stepped boxes sized within the character's climb limits.
// A stairwell column is consecutive flagged cells at the same
// (x, z) on consecutive levels.
constexpr uint64_t StairWell = 1ULL << 26;
// Phase 7.4: an exterior stair. On an upper-floor boundary cell
// carrying an exterior door on one side, the generator builds an
// outside walkway landing in front of the door and a straight
// flight descending along that facade to the ground level
// (motel-style external stairs). Does not cut any floors.
constexpr uint64_t ExteriorStair = 1ULL << 27;
// Masks
constexpr uint64_t AllExternalWalls = WallXNeg | WallXPos | WallZPos | WallZNeg;
constexpr uint64_t AllInternalWalls = IntWallXNeg | IntWallXPos | IntWallZPos | IntWallZNeg;
@@ -99,12 +119,57 @@ 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;
}
};
/**
* @brief Per-doorway configuration override (F0).
*
* Stored in CellGridComponent::doorConfigs keyed by the canonical doorway
* edge key ("X:x:y:z" / "Z:x:y:z" - the same key buildDoorEntities() uses
* to deduplicate doorways, see CellGridSystem::doorEdgeKey()). Entries
* override the grid-wide door* defaults for one specific doorway and
* survive scene loads and grid rebuilds. Entries whose doorway no longer
* exists are "orphaned": they are kept (never auto-deleted) until pruned
* or reassigned in the Cell Grid editor.
*/
struct CellGridDoorConfig {
// Identity / UX
std::string label; // user-visible name ("Kitchen door")
// Behaviour overrides (grid defaults apply when hasOverride is false)
bool hasOverride = false;
float openAngle = 100.0f; // copied from grid on first override
float openSpeed = 180.0f;
bool swingReversed = false; // F3
std::string actionName;
std::string sceneSwitchPath; // F1 (empty = normal swinging door)
std::string sceneSwitchTarget;
bool disabled = false; // spawn no door entity for this doorway
// Persistence / locking (F6)
bool persistent = false; // track state in the global storage
bool lockable = false;
bool lockedByDefault = false;
std::string keyItemId; // item that unlocks this door
};
/**
* @brief Cell grid for procedural building generation
*
@@ -114,6 +179,39 @@ struct FurnitureCell {
* Used by: House/Lot generation, dungeon generation
*/
struct CellGridComponent {
// Stable identity of this grid (F0): a UUID generated when the
// component is created or first serialized. Door global IDs are
// "<gridUid>:<edgeKey>", so they survive grid entity renames.
std::string gridUid;
// Per-doorway configuration overrides keyed by canonical edge key
// (see CellGridDoorConfig). Serialized inside this component.
std::map<std::string, CellGridDoorConfig> doorConfigs;
// Generation mode (F4/F5, serialized as a string):
// "full" - default, the whole building
// "interiorOnly" - skip the exterior shell (external walls, external
// window panels + frames, roofs, external corners);
// floors, ceilings, internal walls/frames, furniture,
// exit door wall panels + frames and all door entities
// are still generated (paired exterior scene handles
// the shell)
// "exteriorOnly" - F5: only the exterior shell
std::string generationMode = "full";
bool interiorOnlyMode() const { return generationMode == "interiorOnly"; }
bool exteriorOnlyMode() const { return generationMode == "exteriorOnly"; }
// Window glass (F5, exteriorOnly/interiorOnly): window openings get an
// opaque glossy pane (exteriorOnly: every external window; interiorOnly:
// boundary windows) that hides the missing half of the building and is
// part of the static collider set, so the player cannot climb through
// windows. The built-in material is used when glassMaterialName is
// empty; the glassColor alpha is ignored by it (opaque).
Ogre::ColourValue glassColor = Ogre::ColourValue(0.4f, 0.6f, 0.8f, 0.35f);
std::string glassMaterialName; // empty = built-in CellGridGlass
float glassReflectivity = 0.8f; // built-in material gloss [0..1]
// Grid dimensions (in cells)
int width = 10; // X dimension
int height = 1; // Y dimension (floors)
@@ -144,6 +242,26 @@ struct CellGridComponent {
// Roof texture rectangles
std::string roofTopRectName;
std::string roofSideRectName;
// Phase 7.1: named rect for stair treads (empty = floorRectName)
std::string stairRectName;
// Door settings - one door entity is spawned per unique doorway
// (deduplicated across the two cells sharing a door edge) on the same
// condition as door frames (any of the 8 door cell flags).
bool doorsEnabled = true; // Spawn openable door entities
std::string doorRectName; // Named rect for procedural door leaf UV
std::string doorMeshName; // Custom door leaf mesh (empty = procedural)
bool doorUseMeshMaterial = false; // Custom mesh keeps its own material
float doorOpenAngle = 100.0f; // Swing angle when open (degrees)
float doorOpenSpeed = 180.0f; // Swing speed (degrees per second)
bool doorSwingReversed = false; // F3: swing to the other side
std::string doorActionName; // Optional action run on activation
// Scene switching doors (e.g. interior <-> exterior): when
// doorSceneSwitchPath is set, activating a door queues an
// EditorApp::switchScene() instead of swinging the leaf
std::string doorSceneSwitchPath; // Target scene path (empty = disabled)
std::string doorSceneSwitchTarget; // Teleport target entity name (optional)
// Physics properties for generated colliders
float friction = 0.5f;
@@ -176,6 +294,10 @@ struct CellGridComponent {
// Mark for rebuild
void markDirty() { dirty = true; version++; }
// Return gridUid, generating a random UUID first when empty.
// Call before building door IDs or serializing.
const std::string& ensureGridUid();
// Convert local cell position to world position
Ogre::Vector3 cellToWorld(int x, int y, int z) const;
@@ -213,6 +335,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;
@@ -497,6 +625,38 @@ struct TownComponent {
void markMaterialDirty() { materialDirty = true; }
};
/**
* Tag for streamed town containers (taxi demo phase 4.5 item 5).
* TownGenSystem::update() creates/destroys "TownGen <area> p<x>_<y>"
* container entities as terrain pages stream in/out in game mode;
* their content is a pure function of the road graph (already
* persisted), so SceneSerializer must NOT write them into the scene
* JSON. pageX/pageY identify the owning physical terrain page.
*/
struct StreamedTownTag {
long pageX = 0;
long pageY = 0;
};
/**
* Tag for generated town containers (taxi demo phase 4.5). Towns
* are a pure function of the road graph + town areas (already
* persisted): they regenerate via the Town Generator panel in the
* editor and stream in per page in game mode, so SceneSerializer
* must NOT write them into the scene JSON. (Streamed per-page
* containers additionally carry StreamedTownTag.)
*/
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
*/
@@ -120,6 +120,10 @@ REGISTER_COMPONENT_GROUP("Room", "Room Layout", RoomComponent, RoomEditor)
if (e.has<RoomComponent>()) {
e.remove<RoomComponent>();
}
},
// On-modified: mark dirty so RoomLayoutSystem rebuilds the room
[](flecs::entity e) {
e.get_mut<RoomComponent>().markDirty();
}
);
}
@@ -158,6 +162,10 @@ REGISTER_COMPONENT_GROUP("Clear Area", "Room Layout", ClearAreaComponent, ClearA
if (e.has<ClearAreaComponent>()) {
e.remove<ClearAreaComponent>();
}
},
// On-modified: mark dirty so RoomLayoutSystem rebuilds the clear area
[](flecs::entity e) {
e.get_mut<ClearAreaComponent>().markDirty();
}
);
}
@@ -3,13 +3,16 @@
#pragma once
#include <Ogre.h>
#include <Jolt/Jolt.h>
#include <Jolt/Physics/Body/BodyID.h>
/**
* Character physics component
*
* Attaches a Jolt JPH::Character (kinematic capsule) to the entity.
* The entity may also have CharacterSlotsComponent; the character
* physics lives on the same entity as the visual character.
* Attaches a Jolt JPH::Character (dynamic capsule, translation-only)
* to the entity. The entity may also have CharacterSlotsComponent;
* the character physics lives on the same entity as the visual
* character.
*
* Child entities can add extra collision shapes via PhysicsColliderComponent.
*/
@@ -48,6 +51,45 @@ struct CharacterComponent {
float floorCheckDistance = 2.0f;
bool useGravity = true;
/* Ground contact state from JPH::Character::IsSupported(), refreshed
* by CharacterSystem every frame. Runtime only — used to tell
* "floating/swimming" apart from "standing on the bottom in shallow
* water" (InWater alone cannot). */
bool isSupported = false;
/* Per-character collision group. Body = subgroup 0, head = subgroup 1,
* hair joints = 2+. Runtime only — regenerated when character is rebuilt. */
uint32_t collisionGroupId = 0;
/* Separate head collider body for hair physics. Runtime only. */
JPH::BodyID headColliderBody;
/* Head sphere radius for hair physics collision. */
float headRadius = 0.13f;
/* Vertical offset from the character base to the head sphere centre.
* If zero, defaults to getTotalHeight() - headRadius. */
float headOffsetY = 0.0f;
/* Bone that drives the head collider. If empty, falls back to the
* static offset above. */
Ogre::String headBoneName = "mixamorig:Head";
/* Optional chest/upper-body collider to stop long hair from clipping
* through the torso. Runtime only. */
JPH::BodyID chestColliderBody;
/* Chest box half-extents. If any axis is zero, no chest collider is
* created. */
Ogre::Vector3 chestHalfExtents = Ogre::Vector3(0.2f, 0.12f, 0.12f);
/* Vertical offset along the world Y axis applied to the chest bone
* position when placing the chest collider. */
float chestOffsetY = 0.0f;
/* Bone that drives the chest collider. */
Ogre::String chestBoneName = "mixamorig:Spine2";
float getHalfHeight() const
{
return height * 0.5f;
@@ -0,0 +1,23 @@
#include "CharacterIdentity.hpp"
#include "../ui/ComponentRegistration.hpp"
#include "../ui/CharacterIdentityEditor.hpp"
// Register CharacterIdentity component
REGISTER_COMPONENT_GROUP("Character Identity", "Character",
CharacterIdentityComponent, CharacterIdentityEditor)
{
registry.registerComponent<CharacterIdentityComponent>(
"Character Identity", "Character",
std::make_unique<CharacterIdentityEditor>(),
// Adder
[](flecs::entity e) {
if (!e.has<CharacterIdentityComponent>())
e.set<CharacterIdentityComponent>(
CharacterIdentityComponent{});
},
// Remover
[](flecs::entity e) {
if (e.has<CharacterIdentityComponent>())
e.remove<CharacterIdentityComponent>();
});
}
@@ -5,54 +5,39 @@
#include <unordered_map>
#include <vector>
#include "AnimationTree.hpp"
/**
* Selection criteria for a single character slot.
* Layer 0 (nude base) is always implicit.
* Layer 0 (nude base) can be selected via combo box (e.g. hair styles).
* Layer 1 and 2 are selected via combo boxes.
*/
struct SlotSelection {
Ogre::String layer0Mesh; // "none" or exact mesh name for layer 0
Ogre::String layer1Mesh; // "none" or exact mesh name for layer 1
Ogre::String layer2Mesh; // "none" or exact mesh name for layer 2
Ogre::String explicitMesh; // backward-compat override
};
/**
* Multi-slot mesh component for character parts sharing a skeleton.
* The "face" slot (or first available slot) serves as the master skeleton.
* Deprecated marker component for characters.
*
* Age is now stored in CharacterRegistry::CharacterRecord::age
* and should be retrieved from there when needed.
* All persistent character appearance data (sex, slot selections, shape keys,
* front axis) now lives in CharacterRegistry::CharacterRecord. This component
* is kept only as a temporary reminder for scenes that still contain it; it
* is not serialized, cannot be added from the editor, and does not affect
* runtime behavior.
*/
struct CharacterSlotsComponent {
Ogre::String sex = "male";
/* Backward-compat: old mesh-name map. Deserialized into slotSelections on load. */
std::unordered_map<Ogre::String, Ogre::String> slots;
/* Per-slot layer selections (runtime) */
std::unordered_map<Ogre::String, SlotSelection> slotSelections;
bool dirty = true;
/* Runtime: master entity with shared skeleton (set by CharacterSlotSystem) */
Ogre::Entity *masterEntity = nullptr;
/**
* Front-facing axis for this character model.
* Most models face -Z (NEGATIVE_UNIT_Z), but some face +Z.
*/
Ogre::Vector3 frontAxis = Ogre::Vector3::NEGATIVE_UNIT_Z;
CharacterSlotsComponent() = default;
};
/**
* Global shape key weights for a character.
* Same name applies to all slots uniformly.
* Deprecated marker component for character shape keys.
*
* Shape key weights are now stored in
* CharacterRegistry::CharacterRecord::inlineShapeKeyWeights.
*/
struct CharacterShapeKeysComponent {
std::unordered_map<Ogre::String, float> weights;
bool dirty = true;
};
#endif // EDITSCENE_CHARACTERSLOTS_HPP
@@ -13,19 +13,9 @@ REGISTER_COMPONENT_GROUP("Character Slots", "Rendering",
"Character Slots",
"Rendering",
std::make_unique<CharacterSlotsEditor>(sceneMgr),
/* Adder */
[sceneMgr](flecs::entity e) {
if (!e.has<TransformComponent>()) {
TransformComponent transform;
transform.node =
sceneMgr->getRootSceneNode()
->createChildSceneNode();
e.set<TransformComponent>(transform);
}
CharacterSlotsComponent cs;
cs.dirty = true;
e.set<CharacterSlotsComponent>(cs);
},
/* Adder: disabled; this component is deprecated and cannot be
* created from the editor. */
nullptr,
/* Remover */
[sceneMgr](flecs::entity e) {
(void)sceneMgr;
@@ -0,0 +1,29 @@
#ifndef EDITSCENE_CHARACTERSPAWNER_HPP
#define EDITSCENE_CHARACTERSPAWNER_HPP
#pragma once
#include <cstdint>
/**
* @brief Distance-based character spawner.
*
* Attaches to an entity with a TransformComponent. When the active camera is
* within spawnDistanceSq of the spawner, the registry character identified by
* registryId is spawned as an independent entity at the spawner's transform.
* When the camera moves beyond despawnDistanceSq, the spawned entity is
* destroyed.
*
* Distances are stored squared for fast comparison.
*/
struct CharacterSpawnerComponent {
/** Registry character ID to spawn. */
uint64_t registryId = 0;
/** Squared distance at which the character should be spawned. */
float spawnDistanceSq = 100.0f * 100.0f;
/** Squared distance at which the character should be despawned. */
float despawnDistanceSq = 200.0f * 200.0f;
};
#endif // EDITSCENE_CHARACTERSPAWNER_HPP
@@ -0,0 +1,26 @@
#include "CharacterSpawner.hpp"
#include "../ui/ComponentRegistration.hpp"
#include "../ui/CharacterSpawnerEditor.hpp"
REGISTER_COMPONENT_GROUP("Character Spawner", "Character",
CharacterSpawnerComponent, CharacterSpawnerEditor)
{
registry.registerComponent<CharacterSpawnerComponent>(
"Character Spawner",
std::make_unique<CharacterSpawnerEditor>(sceneMgr),
/* Adder */
[sceneMgr](flecs::entity e) {
(void)sceneMgr;
if (!e.has<CharacterSpawnerComponent>()) {
e.set<CharacterSpawnerComponent>(
CharacterSpawnerComponent{});
}
},
/* Remover */
[sceneMgr](flecs::entity e) {
(void)sceneMgr;
if (e.has<CharacterSpawnerComponent>()) {
e.remove<CharacterSpawnerComponent>();
}
});
}
@@ -0,0 +1,90 @@
#ifndef EDITSCENE_DOOR_HPP
#define EDITSCENE_DOOR_HPP
#pragma once
#include <Ogre.h>
/**
* Door component (runtime only - not serialized).
*
* Attached to door entities spawned by CellGridSystem for each unique
* doorway (door cell flags, deduplicated across the two cells sharing a
* door edge). The entity's TransformComponent node is the hinge node:
* DoorSystem rotates it around local Y to swing the door.
*
* Interaction works through the ActuatorComponent on the same entity:
* ActuatorSystem sets toggleRequested on activation; DoorSystem consumes
* it, swings the door and disables/enables the door's RigidBodyComponent
* (collider disabled while the door is not fully closed).
*/
struct DoorComponent {
// Current state
bool isOpen = false;
// Set by ActuatorSystem (or scripts) to request an open/close toggle
bool toggleRequested = false;
// Swing configuration (copied from CellGridComponent at build time)
float openAngle = 100.0f; // Target angle when open (degrees)
float openSpeed = 180.0f; // Swing speed (degrees per second)
bool swingReversed = false; // F3: swing to the other side (negates
// the applied angle; currentAngle and
// openAngle stay positive)
// Runtime: current swing angle (0 = closed)
float currentAngle = 0.0f;
// Local orientation of the hinge node in the closed pose
Ogre::Quaternion closedOrientation = Ogre::Quaternion::IDENTITY;
// Door-local offset from the hinge to the leaf center; used by
// ActuatorSystem to place the interaction prompt/circle (the hinge
// node sits on the side edge of the doorway)
Ogre::Vector3 centerOffset = Ogre::Vector3::ZERO;
// Door identity (F0): canonical doorway edge key within the owning
// grid ("X:x:y:z" / "Z:x:y:z") and the global persistent ID
// "<gridUid>:<edgeKey>" (empty = ephemeral door, never persisted).
std::string edgeKey;
std::string doorId;
// Persistence / locking (F6, copied from the per-door
// CellGridDoorConfig at build time). The actual locked and
// open/closed state lives in the GlobalStateStore under
// "door.<doorId>.locked" / "door.<doorId>.isOpen"; these flags only
// say the door participates. See DoorSystem::isDoorLocked() /
// setDoorLocked().
bool persistent = false; // open/closed state is persisted
bool lockable = false; // can be locked/unlocked
std::string keyItemId; // inventory item that unlocks/locks it
// Scene switching (copied from CellGridComponent or the per-door
// CellGridDoorConfig override at build time):
// when sceneSwitchPath is set, activation swings the leaf open and
// the scene switch fires once the door is fully open (F1)
std::string sceneSwitchPath; // Target scene path (empty = disabled)
std::string sceneSwitchTarget; // Teleport target entity name
// F1: a scene-switch door first swings open; DoorSystem fires the
// switch (and the "door_scene_switch" event) only when the leaf
// reaches openAngle. Runtime only, set by ActuatorSystem.
bool sceneSwitchPending = false;
// F1: black open-front tunnel covering the doorway of a scene-switch
// door, created by the door builder on the VOID side of the doorway
// (door-local +Z outward from the owning cell; -Z for exteriorOnly
// grids); its depth covers the fully-open leaf sweep, so the leaf
// stays visible for both swing directions. Child of the grid node,
// so it does NOT swing with the hinge. Hidden while the door is
// fully closed. Runtime only.
Ogre::Entity *occluder = nullptr;
// F1: flat black gap-shield quad just behind the closed leaf of a
// scene-switch door (same void side as the tunnel), visible only
// while the door is fully closed - it blacks out the leaf/frame
// clearance slits (0.02 m sides, 0.05 m top) the tunnel, hidden
// while closed, does not cover. Runtime only.
Ogre::Entity *gapShield = nullptr;
};
#endif // EDITSCENE_DOOR_HPP
@@ -27,6 +27,10 @@ struct NavMeshComponent {
float regionMergeSize = 20.0f;
int tileSize = 48; // cells per tile
// F7: traversal cost multiplier for doorway polys (> 1 makes
// paths prefer doorless routes but still allows doorways)
float doorAreaCost = 5.0f;
// Runtime flags
bool enabled = true;
bool debugDraw = false;
@@ -2,6 +2,7 @@
#include "../ui/ComponentRegistration.hpp"
#include "../ui/NavMeshEditor.hpp"
#include "../ui/NavMeshGeometrySourceEditor.hpp"
#include "../systems/NavMeshSystem.hpp"
REGISTER_COMPONENT_GROUP("NavMesh", "Navigation", NavMeshComponent,
NavMeshEditor)
@@ -18,6 +19,13 @@ REGISTER_COMPONENT_GROUP("NavMesh", "Navigation", NavMeshComponent,
[](flecs::entity e) {
if (e.has<NavMeshComponent>())
e.remove<NavMeshComponent>();
},
// On-modified: sync the debug-draw toggle with the NavMesh system
[](flecs::entity e) {
auto &nav = e.get_mut<NavMeshComponent>();
if (nav.debugDraw && NavMeshSystem::getInstance())
NavMeshSystem::getInstance()->setDebugDraw(e,
nav.debugDraw);
});
}
@@ -0,0 +1,33 @@
#ifndef EDITSCENE_NPC_LOCATION_HPP
#define EDITSCENE_NPC_LOCATION_HPP
#pragma once
#include <Ogre.h>
/**
* NPC location marker component (demo-taxi phase 4.7.3).
*
* Authors a symbolic location by hand: an entity with this component
* marks a named place NPCs can bind to and travel between (their
* TransformComponent position is the location position). Generated
* town locations come from roadlib/TownLocations; authored markers
* are for places the generator cannot know (a bus stop, a taxi rank,
* a landmark) and are merged into the location set by the population
* / motion systems.
*
* `kind` mirrors TownLocations::LocationKind as an int so the
* component stays free of the roadlib dependency:
* 0 = home, 1 = work, 2 = leisure, 3 = interest, 4 = curb,
* 5 = parking.
*/
struct NpcLocationComponent {
/* TownLocations::LocationKind as int (see above). */
int kind = 3; /* Interest */
/* Unique location name (the binding key stored on NPCs). */
Ogre::String name;
/* Arrival radius in meters (how close is "at" the location). */
float radius = 2.0f;
bool enabled = true;
};
#endif // EDITSCENE_NPC_LOCATION_HPP
@@ -0,0 +1,20 @@
#include "NpcLocation.hpp"
#include "../ui/ComponentRegistration.hpp"
#include "../ui/NpcLocationEditor.hpp"
REGISTER_COMPONENT_GROUP("NpcLocation", "Game", NpcLocationComponent,
NpcLocationEditor)
{
registry.registerComponent<NpcLocationComponent>(
"NpcLocation", "Game", std::make_unique<NpcLocationEditor>(),
// Adder
[](flecs::entity e) {
if (!e.has<NpcLocationComponent>())
e.set<NpcLocationComponent>({});
},
// Remover
[](flecs::entity e) {
if (e.has<NpcLocationComponent>())
e.remove<NpcLocationComponent>();
});
}
@@ -48,6 +48,11 @@ struct PathFollowingComponent {
// Run speed (m/s) for root motion scaling
float runSpeed = 5.0f;
/* Force the "run" locomotion state/speed regardless of the
* distance-to-target rule (demo-taxi 4.9.4: pedestrians clear the
* carriageway at a run). Runtime only, not serialized. */
bool forceRun = false;
// Whether to use root motion
bool useRootMotion = true;
@@ -0,0 +1,22 @@
#include "PhysicsCollider.hpp"
#include "../ui/ComponentRegistration.hpp"
#include "../ui/PhysicsColliderEditor.hpp"
// Register PhysicsCollider component
REGISTER_COMPONENT_GROUP("Physics Collider", "Physics",
PhysicsColliderComponent, PhysicsColliderEditor)
{
registry.registerComponent<PhysicsColliderComponent>(
"Physics Collider", "Physics",
std::make_unique<PhysicsColliderEditor>(sceneMgr),
// Adder
[](flecs::entity e) {
if (!e.has<PhysicsColliderComponent>())
e.set<PhysicsColliderComponent>({});
},
// Remover
[](flecs::entity e) {
if (e.has<PhysicsColliderComponent>())
e.remove<PhysicsColliderComponent>();
});
}
@@ -24,8 +24,8 @@ struct PlayerControllerComponent {
/* Swim animation states (used when character is in water) */
Ogre::String swimIdleState = "swim-idle";
Ogre::String swimState = "swim";
Ogre::String swimFastState = "swim-fast";
Ogre::String swimState = "swimming";
Ogre::String swimFastState = "swimming-fast";
/* Actuator interaction settings */
float actuatorDistance = 25.0f;
@@ -37,6 +37,21 @@ struct PlayerControllerComponent {
/* Runtime: set by ActuatorSystem while executing an action */
bool inputLocked = false;
/* Runtime: set by VehicleControllerSystem while the player is
* driving a vehicle; locomotion and actuator prompts are skipped. */
bool driving = false;
/* Locomotion state-machine state selected by VehicleControllerSystem
* while driving (animation_tree.json "locomotion" machine, currently
* mapped to the "sitting" animation); idleState is restored on
* exit. */
Ogre::String drivingState = "driving";
};
/**
* Tag component marking a character entity as currently under player control.
* AI systems (e.g. SmartObjectSystem) should skip entities with this tag.
*/
struct PlayerControlledComponent {
};
#endif // EDITSCENE_PLAYERCONTROLLER_HPP
+74
View File
@@ -0,0 +1,74 @@
#ifndef EDITSCENE_POI_HPP
#define EDITSCENE_POI_HPP
#pragma once
#include <Ogre.h>
#include <string>
#include <unordered_map>
#include <vector>
#include "../roadlib/PoiDatabase.hpp"
/**
* POI component (demo-taxi phase 6.8.11): authors a full POI on a
* scene entity — the planner data AND the mechanical activation spec
* of the unified POI model. The entity's transform is the POI
* position (exterior POIs; interior POIs come from the DSL path,
* 6.8.6).
*
* Planner aspect (merged into the area's PoiDatabase by
* NpcPopulationSystem): stable poiId, kind, tags, entranceRole,
* opening hours and statEffects offered per in-game hour. Queries
* work with no runtime activation at all (symbolic tier).
*
* Mechanical aspect (PoiSystem): activation.kind selects what
* happens when an actor reaches the POI — a behavior tree (default,
* executed through ActuatorSystem) or a `poi_activated` Lua event
* carrying luaScript for scene scripts to handle. Manual = the
* player E prompt; OnApproach = fires on entering radius;
* OnArrive = fires after settling at the POI. Fired flags and
* cooldowns are runtime-only.
*/
struct PoiComponent {
/** Stable unique POI id (required; e.g. "poi:bus-stop-1"). */
Ogre::String poiId;
/** Planner kind: "home"/"work"/"leisure"/"interest"/"curb"/
* "parking"/"entrance"/"interaction" (free-form string). */
Ogre::String kind = "interest";
/** Planner tags (job tags, entrance roles, ...). */
std::vector<Ogre::String> tags;
/** Entrance role: "" | "front" | "back" | "side". */
Ogre::String entranceRole;
/** Opening hours in [0,24); openHour < 0 = always open. */
float openHour = -1.0f;
float closeHour = -1.0f;
/** Stat effects offered per in-game hour (stat -> delta). */
std::unordered_map<std::string, double> statEffects;
/** Planner domain and owning-building identity (6.8.12):
* domainValue 0 = Exterior, 1 = Interior. Interior POIs approach
* through the building entry; address is the authoritative
* fallback key when building is -1. */
int domainValue = 0;
Ogre::String address;
int building = -1;
/** Activation: kind as int mirroring PoiDatabase::ActivationKind
* (0 none, 1 manual, 2 onApproach, 3 onArrive), behaviorTree
* (ActionDatabase action/behavior-tree name) or luaScript
* (script path carried by the `poi_activated` event),
* radius/cooldownSeconds/once. */
int activationKind = 0;
Ogre::String behaviorTree;
Ogre::String luaScript;
float radius = 1.5f;
float cooldownSeconds = 0.0f;
bool once = false;
PoiDatabase::Domain domain() const
{
return domainValue == 1 ? PoiDatabase::Domain::Interior :
PoiDatabase::Domain::Exterior;
}
};
#endif // EDITSCENE_POI_HPP
@@ -0,0 +1,17 @@
#include "Poi.hpp"
#include "../ui/ComponentRegistration.hpp"
#include "../ui/PoiEditor.hpp"
REGISTER_COMPONENT_GROUP("Poi", "Game", PoiComponent, PoiEditor)
{
registry.registerComponent<PoiComponent>(
"Poi", "Game", std::make_unique<PoiEditor>(),
[](flecs::entity e) {
if (!e.has<PoiComponent>())
e.set<PoiComponent>({});
},
[](flecs::entity e) {
if (e.has<PoiComponent>())
e.remove<PoiComponent>();
});
}
@@ -0,0 +1,21 @@
#include "PrefabInstance.hpp"
#include "../ui/ComponentRegistration.hpp"
#include "../ui/PrefabInstanceEditor.hpp"
// Register PrefabInstance component
REGISTER_COMPONENT_GROUP("Prefab Instance", "Scene", PrefabInstanceComponent,
PrefabInstanceEditor)
{
registry.registerComponent<PrefabInstanceComponent>(
"Prefab Instance", "Scene", std::make_unique<PrefabInstanceEditor>(),
// Adder
[](flecs::entity e) {
if (!e.has<PrefabInstanceComponent>())
e.set<PrefabInstanceComponent>({});
},
// Remover
[](flecs::entity e) {
if (e.has<PrefabInstanceComponent>())
e.remove<PrefabInstanceComponent>();
});
}
@@ -1,5 +1,6 @@
#pragma once
#include "../Utils.hpp"
#include <string>
#include <Ogre.h>
#include <flecs.h>
@@ -18,6 +19,11 @@ struct ProceduralMaterialComponent {
// Material name for Ogre resource
std::string materialName;
ProceduralMaterialComponent()
{
materialId = generatePersistentId();
}
// Persistent reference to texture by ID
std::string diffuseTextureId;
@@ -1,5 +1,6 @@
#pragma once
#include "../Utils.hpp"
#include <string>
#include <array>
#include <vector>
@@ -78,6 +79,9 @@ struct ProceduralTextureComponent {
ProceduralTextureComponent()
{
// Unique persistent ID for referencing this texture across saves.
textureId = generatePersistentId();
// Initialize with default colors (checkerboard pattern)
for (int i = 0; i < RECT_COUNT; ++i) {
int row = i / GRID_SIZE;
@@ -0,0 +1,20 @@
#ifndef EDITSCENE_PUSHABLE_HPP
#define EDITSCENE_PUSHABLE_HPP
#pragma once
/**
* Pushable component (F11, see demos/demo-sokoban/PLAN.md).
*
* Generic marker for "a dynamic prop that activities care about": zone
* detection (TargetZoneComponent) keys off it, so any crate can be used
* by any sokoban instance (or a future activity). The entity is expected
* to also carry a dynamic RigidBodyComponent and a box collider.
*
* Note: flecs treats empty structs as tags (set/get_mut assert on them),
* so this marker carries an enabled flag to stay a real component.
*/
struct PushableComponent {
bool enabled = true;
};
#endif // EDITSCENE_PUSHABLE_HPP
@@ -0,0 +1,20 @@
#include "Pushable.hpp"
#include "../ui/ComponentRegistration.hpp"
#include "../ui/PushableEditor.hpp"
REGISTER_COMPONENT_GROUP("Pushable", "Game", PushableComponent,
PushableEditor)
{
registry.registerComponent<PushableComponent>(
"Pushable", "Game", std::make_unique<PushableEditor>(),
// Adder
[](flecs::entity e) {
if (!e.has<PushableComponent>())
e.set<PushableComponent>({});
},
// Remover
[](flecs::entity e) {
if (e.has<PushableComponent>())
e.remove<PushableComponent>();
});
}
@@ -0,0 +1,28 @@
#include "Renderable.hpp"
#include "../ui/ComponentRegistration.hpp"
#include "../ui/RenderableEditor.hpp"
// Register Renderable component
REGISTER_COMPONENT_GROUP("Renderable", "Rendering", RenderableComponent,
RenderableEditor)
{
registry.registerComponent<RenderableComponent>(
"Renderable", "Rendering",
std::make_unique<RenderableEditor>(sceneMgr),
// Adder
[](flecs::entity e) {
if (!e.has<RenderableComponent>())
e.set<RenderableComponent>({});
},
// Remover
[sceneMgr](flecs::entity e) {
if (e.has<RenderableComponent>()) {
auto &renderable = e.get_mut<RenderableComponent>();
if (renderable.entity) {
sceneMgr->destroyEntity(renderable.entity);
renderable.entity = nullptr;
}
e.remove<RenderableComponent>();
}
});
}
@@ -0,0 +1,21 @@
#include "RigidBody.hpp"
#include "../ui/ComponentRegistration.hpp"
#include "../ui/RigidBodyEditor.hpp"
// Register RigidBody component
REGISTER_COMPONENT_GROUP("Rigid Body", "Physics", RigidBodyComponent,
RigidBodyEditor)
{
registry.registerComponent<RigidBodyComponent>(
"Rigid Body", "Physics", std::make_unique<RigidBodyEditor>(),
// Adder
[](flecs::entity e) {
if (!e.has<RigidBodyComponent>())
e.set<RigidBodyComponent>({});
},
// Remover
[](flecs::entity e) {
if (e.has<RigidBodyComponent>())
e.remove<RigidBodyComponent>();
});
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,28 @@
#ifndef EDITSCENE_SCENE_SCRIPT_HPP
#define EDITSCENE_SCENE_SCRIPT_HPP
#pragma once
#include <string>
/**
* Scene/prefab Lua script component.
*
* References a Lua script that is executed exactly once in the shared
* lua_State owned by EditorApp when the scene or prefab containing this
* entity is loaded. Scripts are never unloaded; their usual purpose is
* to subscribe event handlers (ecs.subscribe_event) specific to the
* scene or prefab.
*
* If scriptPath is non-empty it wins and the file is resolved in the
* "LuaScripts" OGRE resource group (same resolution as
* LuaState::luaLibraryLoader). Otherwise inlineScript is executed.
* Execution is deduplicated: by path for file scripts, by content hash
* for inline scripts. On a Lua error the script is NOT marked executed,
* so a later load can retry.
*/
struct SceneScriptComponent {
std::string scriptPath;
std::string inlineScript;
};
#endif // EDITSCENE_SCENE_SCRIPT_HPP
@@ -0,0 +1,52 @@
#include "SceneScript.hpp"
#include "../ui/ComponentRegistration.hpp"
#include "../ui/SceneScriptEditor.hpp"
/**
* Template prefilled into inlineScript when the component is added in
* the editor. Shows the most common event handlers; everything is
* commented out so a fresh component executes as a no-op.
*/
static const char kSceneScriptTemplate[] =
"-- Scene script: executed once when this scene or prefab is loaded.\n"
"-- Lives in the shared Lua environment and is never unloaded.\n"
"-- Its purpose is to subscribe event handlers specific to this\n"
"-- scene/prefab. Uncomment the handlers you need:\n"
"\n"
"-- ecs.subscribe_event(\"scene_loaded\", function(event, params)\n"
"-- -- params.scenePath identifies the loaded scene\n"
"-- end)\n"
"\n"
"-- ecs.subscribe_event(\"prefab_loaded\", function(event, params)\n"
"-- -- params.prefabPath and params.entityId identify the instance\n"
"-- end)\n"
"\n"
"-- ecs.subscribe_event(\"game_start\", function(event, params) end)\n"
"-- ecs.subscribe_event(\"game_saved\", function(event, params) end)\n"
"-- ecs.subscribe_event(\"game_loaded\", function(event, params) end)\n"
"\n"
"-- Custom events:\n"
"-- local id = ecs.subscribe_event(\"my_event\", function(event, params) end)\n"
"-- ecs.unsubscribe_event(id)\n"
"-- ecs.send_event(\"my_event\", { key = \"value\" })\n";
REGISTER_COMPONENT_GROUP("Scene Script", "Game", SceneScriptComponent,
SceneScriptEditor)
{
registry.registerComponent<SceneScriptComponent>(
"Scene Script", "Game",
std::make_unique<SceneScriptEditor>(),
// Adder
[](flecs::entity e) {
if (!e.has<SceneScriptComponent>()) {
SceneScriptComponent script;
script.inlineScript = kSceneScriptTemplate;
e.set<SceneScriptComponent>(script);
}
},
// Remover
[](flecs::entity e) {
if (e.has<SceneScriptComponent>())
e.remove<SceneScriptComponent>();
});
}
+5 -4
View File
@@ -5,9 +5,9 @@
#include <Ogre.h>
/**
* Skybox component - procedural sky rendered as a large cube
* with a fragment shader that creates dynamic day/night/sunset
* sky gradients.
* Skybox component - procedural sky rendered as a fullscreen
* triangle with a fragment shader that creates dynamic
* day/night/sunset sky gradients from the per-pixel view ray.
*
* Designed to work alongside SunComponent on the same entity.
* If no SunComponent is present, uses default noon lighting.
@@ -16,7 +16,8 @@ struct SkyboxComponent {
// Enable/disable skybox
bool enabled = true;
// Size of the skybox cube (default 500)
// Legacy size of the old skybox cube; kept for scene
// compatibility but unused - the sky is a fullscreen triangle now.
float size = 500.0f;
// Day sky colors
@@ -20,5 +20,9 @@ REGISTER_COMPONENT_GROUP("Skybox", "Environment", SkyboxComponent, SkyboxEditor)
if (e.has<SkyboxComponent>()) {
e.remove<SkyboxComponent>();
}
},
// On-modified: mark dirty so EditorSkyboxSystem rebuilds the skybox
[](flecs::entity e) {
e.get_mut<SkyboxComponent>().markDirty();
});
}
@@ -0,0 +1,59 @@
#ifndef EDITSCENE_STANDALONE_DOOR_HPP
#define EDITSCENE_STANDALONE_DOOR_HPP
#pragma once
#include <Ogre.h>
#include <string>
/**
* Standalone door component (F2, serialized).
*
* A door without a CellGrid: the entity's TransformComponent marks the
* doorway (center of the opening at floor level; local +X runs along the
* wall, +Z faces out of the room) and StandaloneDoorSystem builds the
* runtime door subtree under it with DoorBuilder - hinge node, leaf,
* collider child, actuator and (for scene-switch doors) the F1 black
* occluder, identical to CellGrid doors.
*
* All behaviour flags mirror the CellGrid per-door configuration
* (CellGridDoorConfig); persistence and locking work through the same
* GlobalStateStore keys "door.<doorId>.locked" / ".isOpen".
*
* Identity: `doorId` is the global persistent ID, user-editable and
* expected to be unique scene-wide (the editor warns on duplicates).
* When empty while the door needs one (persistent/lockable/scene-switch),
* a random UUID is generated on first build and stays in the serialized
* component from then on. Empty + no persistence flags = ephemeral door.
*/
struct StandaloneDoorComponent {
// Leaf geometry
std::string meshName; // empty = procedural box leaf
bool useMeshMaterial = false; // keep the custom mesh's material
std::string rectName; // UV rect name in the entity's own
// ProceduralTexture (optional)
float leafWidth = 1.0f; // procedural leaf dimensions
float leafHeight = 2.0f; // (explicit, not cell-derived)
float leafThickness = 0.08f;
// Behaviour
float openAngle = 100.0f; // degrees
float openSpeed = 180.0f; // degrees per second
bool swingReversed = false; // F3
std::string actionName; // optional actuator action
std::string sceneSwitchPath; // F1 (empty = normal swinging door)
std::string sceneSwitchTarget;
// Persistence / locking (F6)
bool persistent = false;
bool lockable = false;
bool lockedByDefault = false;
std::string keyItemId;
std::string doorId; // global ID; see the class comment
// Runtime: rebuild the door subtree on the next
// StandaloneDoorSystem::update (set by the editor on changes;
// not serialized)
bool dirty = true;
};
#endif // EDITSCENE_STANDALONE_DOOR_HPP
@@ -0,0 +1,21 @@
#include "StandaloneDoor.hpp"
#include "../ui/ComponentRegistration.hpp"
#include "../ui/StandaloneDoorEditor.hpp"
REGISTER_COMPONENT_GROUP("Standalone Door", "Game", StandaloneDoorComponent,
StandaloneDoorEditor)
{
registry.registerComponent<StandaloneDoorComponent>(
"Standalone Door", "Game",
std::make_unique<StandaloneDoorEditor>(),
// Adder
[](flecs::entity e) {
if (!e.has<StandaloneDoorComponent>())
e.set<StandaloneDoorComponent>({});
},
// Remover
[](flecs::entity e) {
if (e.has<StandaloneDoorComponent>())
e.remove<StandaloneDoorComponent>();
});
}
@@ -20,5 +20,9 @@ REGISTER_COMPONENT_GROUP("Sun", "Environment", SunComponent, SunEditor)
if (e.has<SunComponent>()) {
e.remove<SunComponent>();
}
},
// On-modified: mark dirty so EditorSunSystem rebuilds light/material
[](flecs::entity e) {
e.get_mut<SunComponent>().markDirty();
});
}
@@ -0,0 +1,34 @@
#ifndef EDITSCENE_TARGET_ZONE_HPP
#define EDITSCENE_TARGET_ZONE_HPP
#pragma once
#include <Ogre.h>
/**
* Target zone component (F11, see demos/demo-sokoban/PLAN.md).
*
* Generic "pad/zone" marker: an axis-aligned box (centred on the
* entity transform) that activities use as a goal area. ZoneSystem
* (Phase 6) tests PushableComponent entities for XZ-overlap + nearly-
* at-rest against every zone and emits zone_entered/zone_left events;
* it also swaps the zone entity's material between
* uncovered/coveredMaterialName as feedback.
*
* Generic on purpose: any activity can use zones, not just sokoban.
* Zone ids must be unique per yard (e.g. "yard1.pad1") so multiple
* instances never interfere.
*/
struct TargetZoneComponent {
/* Unique zone id, e.g. "yard1.pad1". */
Ogre::String zoneId;
/* Zone box half extents in entity space (y is only informational
* for the XZ-overlap check). */
Ogre::Vector3 halfExtents = Ogre::Vector3(0.6f, 0.1f, 0.6f);
/* Material swapped onto the zone entity's renderable by ZoneSystem
* when the zone is uncovered/covered; empty = no visual swap. */
Ogre::String uncoveredMaterialName;
Ogre::String coveredMaterialName;
bool enabled = true;
};
#endif // EDITSCENE_TARGET_ZONE_HPP

Some files were not shown because too many files have changed in this diff Show More