Compare commits
92 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 214b05b40f | |||
| 141bfd31c1 | |||
| 7e0a74d837 | |||
| 65066e6d29 | |||
| a3ec423cca | |||
| 19c5528f8e | |||
| 2873a695f7 | |||
| 54d9d4a28e | |||
| d5b9ae235e | |||
| c643d4388f | |||
| 682ccee856 | |||
| d0ca3d181a | |||
| 72851b442f | |||
| 3dfd6c12bd | |||
| e918ef8c4b | |||
| 88fd97fedd | |||
| ec3f98f8a0 | |||
| adc4c91a2c | |||
| 9e2d9fce59 | |||
| 95b42090f9 | |||
| e609d4e39b | |||
| c561fad79e | |||
| ff81a9a657 | |||
| 73e1cef832 | |||
| 136013440e | |||
| e58c061709 | |||
| 6f152b0248 | |||
| d28fce7631 | |||
| 00b2d5d1fe | |||
| a3279f984b | |||
| 7adfaa53a2 | |||
| 5ca9b66d72 | |||
| f9689eaa32 | |||
| 129557c4ba | |||
| 3c7f50a4ef | |||
| c748a7dd1e | |||
| e2e4a398a9 | |||
| 55cd5f3c52 | |||
| 6d10fec2a0 | |||
| d300a16333 | |||
| dd1835b4a3 | |||
| 7a388d2a6f | |||
| 7aa7682e97 | |||
| 1159bb5a47 | |||
| 867a15d6aa | |||
| bb65c589f4 | |||
| 1572066df3 | |||
| dd7d8f6b39 | |||
| b18c213ee8 | |||
| 58deefa0a0 | |||
| 4670aa7af9 | |||
| b062d8331d | |||
| 4562db481a | |||
| 5ea7a6bde8 | |||
| c38ac8eff3 | |||
| 2d60b20cca | |||
| e82190ec6d | |||
| 33f76a1e54 | |||
| c48ff9f4e2 | |||
| a97efbe237 | |||
| 9759c06d5f | |||
| 4acad712b0 | |||
| 50c2395581 | |||
| 32471f0db6 | |||
| ffbc85d4e2 | |||
| 9a12e84fec | |||
| 96c0eef6cb | |||
| 6e71f3639b | |||
| 568b3bc917 | |||
| 25106a23c4 | |||
| e2d6e49728 | |||
| 98303a6448 | |||
| fb2c2af2b2 | |||
| f06f535938 | |||
| 4cd2c72fb0 | |||
| 2c095de9f2 | |||
| e047f7bea1 | |||
| 31864e3da5 | |||
| 3b00e0cc47 | |||
| 4b46b1a8e0 | |||
| 52de5cef44 | |||
| 5a7d97045f | |||
| 9b6881d873 | |||
| 414744cceb | |||
| eea4fbdb2f | |||
| 19faeee8fa | |||
| d832127d5a | |||
| d4974f5d90 | |||
| 68ca1f750d | |||
| 0ed5d9a970 | |||
| 0a5edacf8a | |||
| 765dffbed0 |
@@ -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/
|
||||
@@ -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
|
||||
|
||||
@@ -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 <b>Tags:</b> %s</p>"
|
||||
"<p class='bounds'><b>Bounds:</b> %s%s</p>"
|
||||
"<div class='views'>"
|
||||
"<div><h3>Asset / pivot / bounds / POIs</h3>"
|
||||
"<img class='render' src='%s'></div>"
|
||||
"<div><h3>Atlas UV region</h3>"
|
||||
"<div class='atlas'><img src='%s'>"
|
||||
"<div class='uv' style='%s'></div></div></div>"
|
||||
"</div></section>" %
|
||||
(html.escape(entry["id"]), html.escape(entry["layer"]),
|
||||
html.escape(", ".join(entry["tags"])),
|
||||
html.escape(bounds_text),
|
||||
" + collision proxy" if entry.get("collisionBounds") else "",
|
||||
html.escape(entry["render"]), html.escape(args.atlas), style))
|
||||
|
||||
page = """<!doctype html>
|
||||
<meta charset='utf-8'>
|
||||
<title>Furniture export gallery</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; background: #202124; color: #ddd;
|
||||
margin: 2em; }
|
||||
.asset { border-top: 1px solid #555; padding-top: 1em; margin-top: 2em; }
|
||||
.views { display: flex; gap: 2em; align-items: flex-start; }
|
||||
.views > div { flex: 1; min-width: 0; }
|
||||
.render { width: 100%; image-rendering: auto; background: #111; }
|
||||
.atlas { position: relative; width: 100%; }
|
||||
.atlas img { width: 100%; display: block; }
|
||||
.uv { position: absolute; border: 3px solid #f33; box-sizing: border-box;
|
||||
pointer-events: none; }
|
||||
.bounds { color: #aaa; font-size: 0.9em; }
|
||||
</style>
|
||||
<h1>Furniture export gallery</h1>
|
||||
<p>Generated from the baked Blender sources. Green = physical bounds,
|
||||
red = collision bounds, blue = occupancy cells, axes = pivot, spheres =
|
||||
action/position markers. The red rectangle on each atlas shows the
|
||||
asset's UV region.</p>
|
||||
""" + "\n".join(rows)
|
||||
(root / "index.html").write_text(page)
|
||||
print("Furniture gallery index: %d assets" % len(entries))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,79 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
|
||||
# Stack deterministic per-blend furniture atlas bakes into one shared atlas.
|
||||
# bake_furniture.py remaps each source's UVs into the matching vertical band.
|
||||
#
|
||||
# Usage:
|
||||
# blender --factory-startup -b -P merge_furniture_atlases.py -- \
|
||||
# <atlas.png> <atlas.material> <part0.png> [part1.png ...]
|
||||
|
||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
if len(argv) < 3:
|
||||
raise RuntimeError(
|
||||
"merge_furniture_atlases.py needs output png/material and inputs")
|
||||
|
||||
out_png = argv[0]
|
||||
out_material = argv[1]
|
||||
inputs = argv[2:]
|
||||
|
||||
images = []
|
||||
width = None
|
||||
part_height = None
|
||||
for path in inputs:
|
||||
if not os.path.exists(path):
|
||||
raise RuntimeError("missing furniture atlas part: " + path)
|
||||
image = bpy.data.images.load(path, check_existing=False)
|
||||
images.append((path, image))
|
||||
if width is None:
|
||||
width, part_height = image.size
|
||||
elif image.size[0] != width or image.size[1] != part_height:
|
||||
raise RuntimeError("atlas size mismatch in " + path)
|
||||
|
||||
if width is None or width <= 0 or part_height <= 0:
|
||||
raise RuntimeError("no furniture atlas parts supplied")
|
||||
|
||||
part_pixels = width * part_height * 4
|
||||
merged = []
|
||||
for path, image in images:
|
||||
pixels = list(image.pixels)
|
||||
if len(pixels) != part_pixels:
|
||||
raise RuntimeError("unexpected pixel count in " + path)
|
||||
coverage = sum(1 for i in range(3, len(pixels), 4) if pixels[i] > 0.0)
|
||||
if coverage == 0:
|
||||
raise RuntimeError("furniture atlas part is empty: " + path)
|
||||
merged.extend(pixels)
|
||||
|
||||
result = bpy.data.images.new("FurnitureColor", width=width,
|
||||
height=part_height * len(images), alpha=True)
|
||||
result.pixels = merged
|
||||
os.makedirs(os.path.dirname(out_png), exist_ok=True)
|
||||
os.makedirs(os.path.dirname(out_material), exist_ok=True)
|
||||
result.filepath_raw = out_png
|
||||
result.file_format = 'PNG'
|
||||
result.save()
|
||||
|
||||
material = """// Generated by merge_furniture_atlases.py; do not edit.
|
||||
material M_Furniture_Atlas
|
||||
{
|
||||
technique
|
||||
{
|
||||
pass
|
||||
{
|
||||
texture_unit
|
||||
{
|
||||
texture furniture-atlas.png
|
||||
filtering linear linear linear
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
with open(out_material, "w", newline="\n") as file:
|
||||
file.write(material)
|
||||
|
||||
print("Furniture atlas merged: %d source band(s), %dx%d -> %s" %
|
||||
(len(images), width, part_height * len(images), out_png))
|
||||
print("Furniture material written: " + out_material)
|
||||
@@ -0,0 +1,253 @@
|
||||
"""Render one preview image per furniture root plus a gallery manifest.
|
||||
|
||||
Invoke on a *_baked.blend from the furniture pipeline:
|
||||
blender -b furniture_baked.blend -Y -P render_furniture_gallery.py -- \
|
||||
<gallery-dir> <definition-dir>
|
||||
|
||||
The manifest is consumed by generate_furniture_gallery_index.py. Rendering
|
||||
uses Cycles CPU so it also works on headless build machines.
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
from mathutils import Vector
|
||||
|
||||
argv = sys.argv[sys.argv.index("--") + 1:]
|
||||
GALLERY_DIR = argv[0]
|
||||
DEFINITION_DIR = argv[1]
|
||||
os.makedirs(GALLERY_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def ogre_to_blender(v):
|
||||
return Vector((v[0], -v[2], v[1]))
|
||||
|
||||
|
||||
def material(name, color, emission=0.0):
|
||||
mat = bpy.data.materials.get(name)
|
||||
if mat:
|
||||
return mat
|
||||
mat = bpy.data.materials.new(name)
|
||||
mat.use_nodes = True
|
||||
bsdf = mat.node_tree.nodes.get("Principled BSDF")
|
||||
bsdf.inputs["Base Color"].default_value = (*color, 1.0)
|
||||
bsdf.inputs["Roughness"].default_value = 0.8
|
||||
if emission:
|
||||
bsdf.inputs["Emission"].default_value = (*color, 1.0)
|
||||
bsdf.inputs["Emission Strength"].default_value = emission
|
||||
return mat
|
||||
|
||||
|
||||
bounds_mat = material("GalleryBounds", (0.1, 0.8, 0.2), 1.0)
|
||||
collision_mat = material("GalleryCollision", (0.9, 0.1, 0.1), 1.0)
|
||||
cell_mat = material("GalleryCells", (0.1, 0.4, 0.9), 0.5)
|
||||
pivot_mats = [material("GalleryX", (1, 0, 0), 1.0),
|
||||
material("GalleryY", (0, 1, 0), 1.0),
|
||||
material("GalleryZ", (0, 0, 1), 1.0)]
|
||||
action_mat = material("GalleryAction", (1.0, 0.5, 0.05), 1.0)
|
||||
position_mat = material("GalleryPosition", (0.2, 0.6, 1.0), 1.0)
|
||||
floor_mat = material("GalleryFloor", (0.18, 0.18, 0.18))
|
||||
|
||||
scene = bpy.context.scene
|
||||
scene.render.engine = 'CYCLES'
|
||||
scene.cycles.device = 'CPU'
|
||||
scene.cycles.samples = 4
|
||||
scene.render.resolution_x = 480
|
||||
scene.render.resolution_y = 360
|
||||
scene.render.resolution_percentage = 100
|
||||
scene.render.image_settings.file_format = 'PNG'
|
||||
scene.render.film_transparent = False
|
||||
|
||||
# Neutral floor, camera and lights are shared by every render.
|
||||
bpy.ops.mesh.primitive_plane_add(size=200.0, location=(0, 0, -0.03))
|
||||
floor = bpy.context.object
|
||||
floor.name = "GalleryFloor"
|
||||
floor.data.materials.append(floor_mat)
|
||||
|
||||
bpy.ops.object.camera_add()
|
||||
camera = bpy.context.object
|
||||
scene.camera = camera
|
||||
camera.data.lens = 50
|
||||
|
||||
for name, location, energy, size in (
|
||||
("Key", (3, 4, 6), 600, 4),
|
||||
("Fill", (-4, 1, 3), 350, 4),
|
||||
("Rim", (0, -4, 4), 450, 3)):
|
||||
bpy.ops.object.light_add(type='AREA', location=location)
|
||||
lamp = bpy.context.object
|
||||
lamp.name = "Gallery" + name
|
||||
lamp.data.energy = energy
|
||||
lamp.data.size = size
|
||||
|
||||
|
||||
def hide_tree(obj, hide):
|
||||
obj.hide_render = hide
|
||||
for child in obj.children:
|
||||
hide_tree(child, hide)
|
||||
|
||||
|
||||
def line_object(name, points, mat, width=0.008):
|
||||
curve = bpy.data.curves.new(name, 'CURVE')
|
||||
curve.dimensions = '3D'
|
||||
curve.bevel_depth = width
|
||||
curve.bevel_resolution = 1
|
||||
spline = curve.splines.new('POLY')
|
||||
spline.points.add(len(points) - 1)
|
||||
for point, co in zip(spline.points, points):
|
||||
point.co = (*co, 1.0)
|
||||
obj = bpy.data.objects.new(name, curve)
|
||||
scene.collection.objects.link(obj)
|
||||
obj.data.materials.append(mat)
|
||||
return obj
|
||||
|
||||
|
||||
def box_edges(name, minimum, maximum, mat):
|
||||
corners = []
|
||||
for x in (minimum[0], maximum[0]):
|
||||
for y in (minimum[1], maximum[1]):
|
||||
for z in (minimum[2], maximum[2]):
|
||||
corners.append(Vector((x, y, z)))
|
||||
indices = [(0, 1), (0, 2), (0, 4), (1, 3), (1, 5), (2, 3),
|
||||
(2, 6), (3, 7), (4, 5), (4, 6), (5, 7), (6, 7)]
|
||||
for index, (a, b) in enumerate(indices):
|
||||
line_object(name + str(index), [corners[a], corners[b]], mat)
|
||||
|
||||
|
||||
def sphere(name, position, mat, radius):
|
||||
bpy.ops.mesh.primitive_uv_sphere_add(segments=12, ring_count=6,
|
||||
radius=radius, location=position)
|
||||
obj = bpy.context.object
|
||||
obj.name = name
|
||||
obj.data.materials.append(mat)
|
||||
return obj
|
||||
|
||||
|
||||
def uv_bounds(root):
|
||||
result = [1.0, 0.0, 1.0, 0.0]
|
||||
found = False
|
||||
stack = list(root.children)
|
||||
while stack:
|
||||
obj = stack.pop(0)
|
||||
stack.extend(obj.children)
|
||||
if obj.type != 'MESH' or obj.name.startswith("collision-"):
|
||||
continue
|
||||
for layer in obj.data.uv_layers:
|
||||
for loop in layer.data:
|
||||
u, v = loop.uv
|
||||
result[0] = min(result[0], u)
|
||||
result[1] = max(result[1], u)
|
||||
result[2] = min(result[2], v)
|
||||
result[3] = max(result[3], v)
|
||||
found = True
|
||||
return result if found else [0.0, 1.0, 0.0, 1.0]
|
||||
|
||||
|
||||
roots = [obj for obj in scene.objects
|
||||
if obj.parent is None and obj.name.startswith("furniture-")]
|
||||
manifest = []
|
||||
for root in sorted(roots, key=lambda obj: obj.name):
|
||||
definition_path = os.path.join(DEFINITION_DIR, root.name + ".glb.json")
|
||||
with open(definition_path) as source:
|
||||
definition = json.load(source)
|
||||
|
||||
# Definitions and GLBs are exported with the root at the origin;
|
||||
# use the same pivot here (many source roots have authoring offsets).
|
||||
root.location = (0.0, 0.0, 0.0)
|
||||
for other in roots:
|
||||
hide_tree(other, True)
|
||||
hide_tree(root, False)
|
||||
|
||||
helpers_start = set(scene.objects)
|
||||
bounds = definition.get("bounds")
|
||||
if bounds:
|
||||
minimum = ogre_to_blender(bounds["min"])
|
||||
maximum = ogre_to_blender(bounds["max"])
|
||||
# ogre_to_blender swaps Y/Z and negates one axis; reorder.
|
||||
low = Vector(tuple(min(a, b) for a, b in zip(minimum, maximum)))
|
||||
high = Vector(tuple(max(a, b) for a, b in zip(minimum, maximum)))
|
||||
box_edges("PhysicalBounds", low, high, bounds_mat)
|
||||
center = (low + high) * 0.5
|
||||
size = high - low
|
||||
else:
|
||||
center = Vector((0, 0, 0.5))
|
||||
size = Vector((1, 1, 1))
|
||||
|
||||
if "collisionBounds" in definition:
|
||||
collision = definition["collisionBounds"]
|
||||
minimum = ogre_to_blender(collision["min"])
|
||||
maximum = ogre_to_blender(collision["max"])
|
||||
low = Vector(tuple(min(a, b) for a, b in zip(minimum, maximum)))
|
||||
high = Vector(tuple(max(a, b) for a, b in zip(minimum, maximum)))
|
||||
box_edges("CollisionBounds", low, high, collision_mat)
|
||||
|
||||
# Pivot axes and occupancy-cell outlines at the root origin.
|
||||
line_object("PivotX", [(0, 0, 0), (0.35, 0, 0)], pivot_mats[0], 0.012)
|
||||
line_object("PivotY", [(0, 0, 0), (0, 0.35, 0)], pivot_mats[1], 0.012)
|
||||
line_object("PivotZ", [(0, 0, 0), (0, 0, 0.35)], pivot_mats[2], 0.012)
|
||||
if bounds:
|
||||
cell_size = 2.0
|
||||
bmin = bounds["min"]
|
||||
bmax = bounds["max"]
|
||||
min_cx = math.floor(bmin[0] / cell_size)
|
||||
min_cz = math.floor(bmin[2] / cell_size)
|
||||
max_cx = math.ceil((bmax[0] - 0.0001) / cell_size) - 1
|
||||
max_cz = math.ceil((bmax[2] - 0.0001) / cell_size) - 1
|
||||
for cx in range(min_cx, max_cx + 1):
|
||||
for cz in range(min_cz, max_cz + 1):
|
||||
points = [(cx * cell_size, -cz * cell_size, -0.01),
|
||||
((cx + 1) * cell_size, -cz * cell_size, -0.01),
|
||||
((cx + 1) * cell_size,
|
||||
-(cz + 1) * cell_size, -0.01),
|
||||
(cx * cell_size, -(cz + 1) * cell_size, -0.01),
|
||||
(cx * cell_size, -cz * cell_size, -0.01)]
|
||||
line_object("OccupancyCell", points, cell_mat, 0.006)
|
||||
|
||||
radius = max(size.length * 0.025, 0.025)
|
||||
for action in definition.get("actions", []):
|
||||
position = (action["position_x"], action["position_y"],
|
||||
action["position_z"])
|
||||
sphere("ActionMarker", ogre_to_blender(position), action_mat,
|
||||
radius)
|
||||
for marker in action.get("positions", []):
|
||||
position = (marker["position_x"], marker["position_y"],
|
||||
marker["position_z"])
|
||||
sphere("PositionMarker", ogre_to_blender(position),
|
||||
position_mat, radius * 0.7)
|
||||
for marker in definition.get("positions", []):
|
||||
position = (marker["position_x"], marker["position_y"],
|
||||
marker["position_z"])
|
||||
sphere("PositionMarker", ogre_to_blender(position),
|
||||
position_mat, radius * 0.7)
|
||||
|
||||
distance = max(size.length, 0.8) * 1.8
|
||||
camera.location = center + Vector((distance * 0.8,
|
||||
distance * 1.1,
|
||||
distance * 0.65))
|
||||
camera.rotation_euler = (center - camera.location).to_track_quat(
|
||||
'-Z', 'Y').to_euler()
|
||||
camera.data.lens = 45
|
||||
output_name = root.name + ".png"
|
||||
scene.render.filepath = os.path.join(GALLERY_DIR, output_name)
|
||||
bpy.ops.render.render(write_still=True)
|
||||
|
||||
manifest.append({
|
||||
"id": definition["id"],
|
||||
"render": output_name,
|
||||
"layer": definition.get("layer", "normal"),
|
||||
"tags": definition.get("tags", []),
|
||||
"bounds": definition.get("bounds"),
|
||||
"collisionBounds": definition.get("collisionBounds"),
|
||||
"uv": uv_bounds(root),
|
||||
})
|
||||
for helper in set(scene.objects) - helpers_start:
|
||||
bpy.data.objects.remove(helper, do_unlink=True)
|
||||
|
||||
with open(os.path.join(GALLERY_DIR,
|
||||
"manifest-" + os.path.basename(DEFINITION_DIR) +
|
||||
".json"),
|
||||
"w") as output:
|
||||
json.dump(manifest, output, indent=2)
|
||||
print("Furniture gallery: %d assets rendered" % len(manifest))
|
||||
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate generated furniture GLB/JSON artifacts after the CMake export.
|
||||
|
||||
Checks the phase 6.5 shared-atlas contract without starting OGRE:
|
||||
schema metadata exists, GLBs carry no embedded images, every material is the
|
||||
shared material, UVs stay in the owning source's vertical atlas band, and
|
||||
mesh variants sharing a Blender mesh datablock keep identical UV bounds.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
COMPONENT_FLOAT = 5126
|
||||
|
||||
|
||||
def fail(message):
|
||||
raise RuntimeError(message)
|
||||
|
||||
|
||||
def read_glb(path):
|
||||
data = path.read_bytes()
|
||||
if len(data) < 20 or data[:4] != b"glTF":
|
||||
fail(f"{path}: not a GLB")
|
||||
json_length, json_type = struct.unpack_from("<II", data, 12)
|
||||
if json_type != 0x4E4F534A:
|
||||
fail(f"{path}: first chunk is not JSON")
|
||||
document = json.loads(data[20:20 + json_length])
|
||||
offset = 20 + json_length
|
||||
bin_data = b""
|
||||
if offset + 8 <= len(data):
|
||||
bin_length, bin_type = struct.unpack_from("<II", data, offset)
|
||||
if bin_type == 0x004E4942:
|
||||
bin_data = data[offset + 8:offset + 8 + bin_length]
|
||||
return document, bin_data
|
||||
|
||||
|
||||
def accessor_floats(document, bin_data, accessor_index):
|
||||
accessor = document["accessors"][accessor_index]
|
||||
if accessor["componentType"] != COMPONENT_FLOAT:
|
||||
fail(f"accessor {accessor_index} is not float")
|
||||
view = document["bufferViews"][accessor["bufferView"]]
|
||||
offset = view.get("byteOffset", 0) + accessor.get("byteOffset", 0)
|
||||
components = {"VEC2": 2, "VEC3": 3, "VEC4": 4}[accessor["type"]]
|
||||
count = accessor["count"] * components
|
||||
return struct.unpack_from("<%df" % count, bin_data, offset)
|
||||
|
||||
|
||||
def uv_bounds(document, bin_data):
|
||||
result = []
|
||||
for mesh_index, mesh in enumerate(document.get("meshes", [])):
|
||||
mesh_name = mesh.get("name", f"mesh{mesh_index}")
|
||||
for primitive in mesh.get("primitives", []):
|
||||
accessor_index = primitive.get("attributes", {}).get("TEXCOORD_0")
|
||||
if accessor_index is None:
|
||||
continue
|
||||
values = accessor_floats(document, bin_data, accessor_index)
|
||||
us = values[::2]
|
||||
vs = values[1::2]
|
||||
result.append((mesh_name, min(us), max(us), min(vs), max(vs)))
|
||||
return result
|
||||
|
||||
|
||||
def validate_source(source_root, source_name, band_index, band_count):
|
||||
source_dir = source_root / source_name
|
||||
if not source_dir.is_dir():
|
||||
fail(f"missing output directory {source_dir}")
|
||||
json_paths = sorted(source_dir.glob("furniture-*.json"))
|
||||
if not json_paths:
|
||||
fail(f"no furniture definitions in {source_dir}")
|
||||
|
||||
shared_uv = {}
|
||||
min_v = 1.0 - float(band_index + 1) / band_count - 0.002
|
||||
max_v = 1.0 - float(band_index) / band_count + 0.002
|
||||
mesh_count = 0
|
||||
|
||||
for json_path in json_paths:
|
||||
definition = json.loads(json_path.read_text())
|
||||
if definition.get("source") != "blender":
|
||||
fail(f"{json_path}: source is not blender")
|
||||
if definition.get("materialName") != "M_Furniture_Atlas":
|
||||
fail(f"{json_path}: wrong materialName")
|
||||
if not definition.get("id") or not definition.get("mesh"):
|
||||
fail(f"{json_path}: missing id/mesh")
|
||||
if "bounds" not in definition:
|
||||
fail(f"{json_path}: missing physical bounds")
|
||||
glb_path = source_dir / definition["mesh"]
|
||||
if not glb_path.is_file():
|
||||
fail(f"{json_path}: referenced mesh is missing")
|
||||
|
||||
document, bin_data = read_glb(glb_path)
|
||||
if document.get("images"):
|
||||
fail(f"{glb_path}: embeds an image instead of using the shared atlas")
|
||||
materials = document.get("materials", [])
|
||||
if not materials or any(
|
||||
material.get("name") != "M_Furniture_Atlas"
|
||||
for material in materials):
|
||||
fail(f"{glb_path}: does not use only M_Furniture_Atlas")
|
||||
|
||||
for mesh_name, min_u, max_u, mesh_min_v, mesh_max_v in uv_bounds(
|
||||
document, bin_data):
|
||||
mesh_count += 1
|
||||
if mesh_min_v < min_v or mesh_max_v > max_v:
|
||||
fail(
|
||||
f"{glb_path}:{mesh_name}: UV V range "
|
||||
f"{mesh_min_v:.4f}..{mesh_max_v:.4f} escapes band "
|
||||
f"{min_v:.4f}..{max_v:.4f}")
|
||||
key = (source_name, mesh_name)
|
||||
bounds = (min_u, max_u, mesh_min_v, mesh_max_v)
|
||||
previous = shared_uv.get(key)
|
||||
if previous:
|
||||
if any(abs(a - b) > 0.0005 for a, b in zip(previous, bounds)):
|
||||
fail(
|
||||
f"{glb_path}:{mesh_name}: shared mesh UV bounds "
|
||||
f"changed between variants {previous} -> {bounds}")
|
||||
else:
|
||||
shared_uv[key] = bounds
|
||||
|
||||
return len(json_paths), mesh_count
|
||||
|
||||
|
||||
def main():
|
||||
args = sys.argv[1:]
|
||||
if len(args) < 6 or args[0] != "--root":
|
||||
fail("usage: validate_furniture_export.py --root <dir> "
|
||||
"--source <name> <band> <count> [...]")
|
||||
root = Path(args[1])
|
||||
atlas_png = root / "furniture-atlas.png"
|
||||
atlas_material = root / "furniture-atlas.material"
|
||||
if not atlas_png.is_file() or atlas_png.stat().st_size == 0:
|
||||
fail("shared furniture-atlas.png is missing/empty")
|
||||
if "M_Furniture_Atlas" not in atlas_material.read_text():
|
||||
fail("shared material does not define M_Furniture_Atlas")
|
||||
|
||||
definitions = 0
|
||||
meshes = 0
|
||||
i = 2
|
||||
while i < len(args):
|
||||
if args[i] != "--source" or i + 3 >= len(args):
|
||||
fail("malformed --source arguments")
|
||||
count, mesh_count = validate_source(root, args[i + 1],
|
||||
int(args[i + 2]),
|
||||
int(args[i + 3]))
|
||||
definitions += count
|
||||
meshes += mesh_count
|
||||
i += 4
|
||||
print(f"Furniture export validation: {definitions} definitions, "
|
||||
f"{meshes} mesh primitives, shared atlas OK")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,305 @@
|
||||
# Creating Character Parts for the Game (Blender -> Engine)
|
||||
|
||||
This is the contract a character model must satisfy to work with the
|
||||
character pipeline: the catalog-driven modular body
|
||||
(`CharacterSlotSystem`), the deterministic NPC look assignment
|
||||
(`NpcPopulationSystem`), the animation trees, and the hair physics.
|
||||
Companion document for cars: `../vehicles/CAR_MODELING.md`.
|
||||
|
||||
The reference implementations are `edited-normal-male.blend` and
|
||||
`edited-normal-female.blend` (base bodies) plus the
|
||||
`clothes-<sex>-<slot>.blend` garment files in this directory.
|
||||
|
||||
## 1. Export pipeline
|
||||
|
||||
All steps are driven by `assets/blender/characters/CMakeLists.txt` and
|
||||
run in Blender batch mode; from the project root:
|
||||
|
||||
```bash
|
||||
cmake --build build-vscode --target edited-blends # base bodies + animations
|
||||
cmake --build build-vscode # full pipeline incl. clothes
|
||||
```
|
||||
|
||||
Pipeline per sex (`male` / `female`):
|
||||
|
||||
1. `edited-normal-<sex>.blend` is the base body. `copy_animations.py`
|
||||
copies the Mixamo animation library (`assets/blender/mixamo/`) into
|
||||
it — idle, walking, running, swimming, treading_water, sitting,
|
||||
sitting-ground, sitting-vehicle, ...
|
||||
2. Each `clothes-<sex>-<slot>.blend` holds garment meshes for one slot.
|
||||
`process_clothes.py` fits them to the body (weight transfer from the
|
||||
reference body parts, seam fixes), `combine_clothes.py` bakes the
|
||||
combined meshes for garment combinations (see §3).
|
||||
3. `consolidate.py` appends everything into
|
||||
`edited-normal-<sex>-consolidated.blend`.
|
||||
4. `export_models2.py` exports the consolidated blend through
|
||||
blender2ogre: one `.mesh` per catalog mesh object, the shared
|
||||
`.skeleton`, `.material` files, the texture atlases
|
||||
(`clothes-atlas.png`, `clothes-atlas-normal.png`, `face-atlas.png`),
|
||||
a `.glb` preview, and one **`body_part_<mesh data name>.json`
|
||||
catalog entry per mesh object** carrying the required custom
|
||||
properties (see §2).
|
||||
|
||||
Outputs land in `build-vscode/characters/<sex>/` and are staged into
|
||||
the editScene runtime `characters/` directory (OGRE resource group
|
||||
`Characters`). The engine discovers catalog entries by scanning every
|
||||
resource group for `body_part_*.json` — no manual registration step.
|
||||
|
||||
**blender2ogre names `.mesh` files after the mesh DATA name** (not the
|
||||
object name) — set both, and never reuse another blend's mesh data
|
||||
names: everything exports into the same directory and silently
|
||||
overwrites.
|
||||
|
||||
## 2. Catalog entry contract (custom properties on the MESH OBJECT)
|
||||
|
||||
`export_models2.py` writes a catalog JSON for every mesh object that
|
||||
carries all three required custom properties (Properties panel ->
|
||||
Object -> Custom Properties):
|
||||
|
||||
| Property | Type | Meaning |
|
||||
|----------|------|---------|
|
||||
| `age` | string | age band, currently only `adult` exists |
|
||||
| `sex` | string | `male` / `female` |
|
||||
| `slot` | string | `top` / `bottom` / `feet` / `hair` / `face` |
|
||||
| `layer` | int | 0 = nude base, 1 = underwear (and hair), 2 = clothes (default 0) |
|
||||
| `garments` | string | `;`-separated garment ids this mesh CONTAINS |
|
||||
| `clothing_tags` | string | `;`-separated extra tags |
|
||||
|
||||
`shape_keys` are collected automatically from the mesh data (the
|
||||
`Basis` key is excluded). Hair entries additionally get
|
||||
`own_skeleton: true` and `attach_to_bone: "mixamorig:Head"` (see §5).
|
||||
Garment ids are automatically duplicated into `tags`.
|
||||
|
||||
Resulting entry (`body_part_male_BodyTop_coat.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"age": "adult",
|
||||
"sex": "male",
|
||||
"slot": "top",
|
||||
"layer": 2,
|
||||
"garments": ["coat1"],
|
||||
"tags": ["coat1", "coat"],
|
||||
"shape_keys": ["neck-width-plus", "fat"],
|
||||
"mesh": "male_BodyTop_coat.mesh"
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Slots, layers and mesh resolution
|
||||
|
||||
The engine builds a character from one mesh per slot
|
||||
(`CharacterSlotSystem::buildCharacter`): the **face** mesh is the
|
||||
master (it carries the full body skeleton); every other slot's mesh
|
||||
shares that skeleton instance (`shareSkeletonInstanceWith`) — so every
|
||||
body/garment mesh must be skinned to the SAME armature
|
||||
(`mixamorig:` bone names) with compatible bind poses.
|
||||
|
||||
Resolution per slot (`resolveMesh`), given the record's
|
||||
`inlineOutfitLevel` and `inlineSlotSelections`:
|
||||
|
||||
- **outfitLevel 2 (dressed)**: the selected layer-2 mesh; when a
|
||||
layer-1 garment is also selected, a COMBINED mesh whose `garments`
|
||||
array contains both ids is preferred (e.g.
|
||||
`male_BodyBottom_Panties001_Pants0001.mesh` contains
|
||||
`panties0001;pants0001`). Combined meshes are baked by
|
||||
`combine_clothes.py` — a new garment only needs its own entry, the
|
||||
combinations are generated.
|
||||
- **outfitLevel 1 (underwear)**: the selected layer-1 mesh.
|
||||
- **outfitLevel 0 (nude)**: the layer-0 entry tagged
|
||||
**`detailed-nude`** when one exists for the slot (phase 6.C.1) —
|
||||
the plain layer-0 meshes are simplified composition bases meant to
|
||||
disappear under garments and lack anatomical detail. Author at most
|
||||
one `detailed-nude` variant per slot per sex (`top`/`bottom`/`feet`);
|
||||
slots without one keep the composition base, so the variants can be
|
||||
added one slot at a time. Tag it via `clothing_tags =
|
||||
"detailed-nude"` on a layer-0 mesh.
|
||||
- Final fallback: the shortest-named layer-0 entry (Blender `.001`
|
||||
duplicates are penalised), then any entry.
|
||||
|
||||
### Authoring a `detailed-nude` part (whole-body-part replacement)
|
||||
|
||||
A detailed-nude mesh is a COMPLETE standalone body part that replaces
|
||||
the simplified composition base at runtime when the character wears
|
||||
nothing. It is NOT built from the base part by the pipeline and no
|
||||
geometry is ever deleted from it:
|
||||
|
||||
- The hidden-geometry deletion (`combine_clothes.py`) only runs on
|
||||
copies of the base meshes a garment explicitly names in its
|
||||
`ref_part` property. No garment references your new mesh, so it
|
||||
passes through the pipeline untouched, and the plain base part must
|
||||
stay in the blend — garments still need it as their combination
|
||||
target.
|
||||
- The replacement happens at mesh-resolution time only: outfitLevel 0
|
||||
shows the detailed part, outfitLevel >= 1 goes back to the
|
||||
composition bases/combined meshes.
|
||||
|
||||
Steps:
|
||||
|
||||
1. Model the part as a NEW mesh object in the base body blend
|
||||
`edited-normal-<sex>.blend`, next to the existing base parts
|
||||
(`BodyTop`/`BodyBottom`/...). One detailed variant per slot per
|
||||
sex at most (`top`/`bottom`/`feet`); the engine takes the first
|
||||
`detailed-nude` entry it finds for the slot.
|
||||
2. Skin it to the SAME `mixamorig:` armature with the same bind pose,
|
||||
max 4 bone influences per vertex. (`consolidate.py` re-parents it
|
||||
to the armature named by its `sex` property and adds an Armature
|
||||
modifier if missing, but the weights are your job.)
|
||||
3. Match the boundary loops of the base part it replaces (neck, waist,
|
||||
wrists, ankles) vertex-for-vertex in position: the consolidate
|
||||
step's seam fixer unifies shape-key offsets and normals for
|
||||
vertices at identical positions across parts, so matching
|
||||
boundaries come out seamless; mismatched ones will show gaps
|
||||
against the face/hands/neighbouring slots.
|
||||
4. Copy the same shape keys (`fat`, ...) onto it — helper scripts in
|
||||
this directory: `transfer_shape_keys.py`,
|
||||
`propagate_shape_keys.py`, `add_missing_shape_keys.py`.
|
||||
5. Custom properties on the mesh OBJECT: `age`/`sex`/`slot` as usual,
|
||||
`layer` = 0, `clothing_tags` = `detailed-nude`, no `garments`.
|
||||
6. Give object AND mesh data a unique name (e.g.
|
||||
`male_BodyTop_detailed`); the mesh data name becomes the `.mesh`
|
||||
file name.
|
||||
7. Rebuild (`edited-blends` target or full build). No CMake or code
|
||||
changes are needed: consolidate/export discover the part by its
|
||||
custom properties and emit `body_part_<mesh data name>.json`
|
||||
automatically, and the runtime starts using it for nude characters
|
||||
of that slot.
|
||||
|
||||
The town population picks looks deterministically per NPC:
|
||||
`fnv1a(npcKey|slot)` indexes into the layer-1 hair list and (when
|
||||
`outfitLevel >= 2`, the public default) the layer-2 garment lists.
|
||||
Adding or reordering catalog entries changes some NPC looks — that is
|
||||
fine — but never make the catalog itself random: the same source
|
||||
assets must always produce the same town.
|
||||
|
||||
## 4. Skeleton, skinning and animation
|
||||
|
||||
- Skeleton: Mixamo hierarchy, bone names prefixed `mixamorig:`
|
||||
(139 bones). All body/garment parts share it; only hair has its own
|
||||
armature (§5).
|
||||
- **GLES2 hardware skinning**: the runtime injects RTSS hardware
|
||||
skinning (`weight_count 4`, per-part compact bone palette) into every
|
||||
character mesh, so a part may reference at most 4 bone influences per
|
||||
vertex and its compact palette must fit the GPU uniform budget — keep
|
||||
garment weights tight around the bones they actually need (the
|
||||
exporter's weight transfer already does this). Blend weights are
|
||||
repacked to zero-padded float4 at load, so 1-3 influence parts are
|
||||
fine. `EDITSCENE_NO_HWSKIN=1` falls back to software skinning for
|
||||
debugging.
|
||||
- **Facing**: character meshes face **+Z** (registry records carry
|
||||
`frontAxis = +Z`); the walk/run clips' root motion also translates
|
||||
along +Z. A part authored facing -Z moonwalks.
|
||||
- **Root motion**: locomotion uses root motion — the clip's root track
|
||||
must translate the character (do not bake in-place walks).
|
||||
- **Animations** live on the shared skeleton and are referenced by NAME
|
||||
from `animation_tree.json`. State names the trees expect:
|
||||
`idle`, `walking`, `running`, `driving` (-> `sitting-vehicle`),
|
||||
`swimming`, `swim-idle` (-> `treading_water`), `swimming-fast`,
|
||||
plus action states `sitting` / `sitting-ground`.
|
||||
`sitting-vehicle` is authored so the seat pin IS the character's base
|
||||
(root) position — butt/hands/feet land correctly with zero offset
|
||||
(see CAR_MODELING.md §5).
|
||||
- **Shape keys** (e.g. `fat`, `neck-width-plus`) export as OGRE poses;
|
||||
the registry record's `inlineShapeKeyWeights` drives them (pose index
|
||||
= catalog order, Basis excluded). Meshes are shared between
|
||||
characters: when a record has all-zero weights the runtime strips the
|
||||
mesh's poses for performance, so shape-keyed and plain characters
|
||||
must not force the same mesh through both paths in one session.
|
||||
|
||||
## 5. Hair
|
||||
|
||||
Hair meshes export with their OWN armature (`own_skeleton: true`) and
|
||||
attach to the master skeleton's `mixamorig:Head` bone. At runtime
|
||||
`HairPhysicsSystem` builds a Jolt ragdoll over the hair bone chain:
|
||||
the root body is pinned to the head, the rest simulates.
|
||||
|
||||
Modelling consequences:
|
||||
|
||||
- Keep the hair bone chain short and rooted at the scalp; the root
|
||||
bone's bind transform must sit on the head.
|
||||
- Hair needs no shape keys and no layer-2 interaction; it lives on
|
||||
layer 1 with a `hair` tag.
|
||||
- Distant LODs (§6) disable hair physics — hair must still look
|
||||
acceptable as a rigid head-attached mesh.
|
||||
|
||||
## 6. LOD scheme (phase 6.C.3/6.C.4 — implemented)
|
||||
|
||||
Ogre mesh LODs are NOT used for characters (skinned-mesh risk and they
|
||||
cannot merge parts); instead the pipeline generates mesh VARIANTS and
|
||||
`CharacterSlotSystem` swaps whole meshes by camera distance (2 m
|
||||
hysteresis on every boundary):
|
||||
|
||||
- **LOD0 (< 10 m)**: modular, full detail — the normal catalog parts.
|
||||
- **LOD1 (10-30 m)**: per-part `<mesh base>_lod1.mesh` variants — same
|
||||
geometry, shape keys removed, UVs remapped into one shared 2048x2048
|
||||
atlas (`charlod_<sex>-atlas.png`) with a single `charlod_<sex>`
|
||||
material per sex. Hair slots are skipped (hidden/impostor-baked at
|
||||
distance anyway). Parts without a `_lod1` variant keep their base
|
||||
mesh, so the scheme degrades gracefully.
|
||||
- **LOD2 (30-70 m)**: whole-character impostors
|
||||
`charlod_<sex>_thin_lod2.mesh` / `charlod_<sex>_fat_lod2.mesh` — the
|
||||
deterministic default outfit (per slot: highest layer, first entry by
|
||||
name) joined into ONE mesh on the shared armature, `fat` shape key
|
||||
baked at 0/1, hair rigid-bound to `mixamorig:Head`, atlas material.
|
||||
The impostor skin palette is pruned to the 23 core bones (Root,
|
||||
Hips, Spine/1/2, Neck, Head, Shoulder/Arm/ForeArm/Hand and
|
||||
UpLeg/Leg/Foot/ToeBase per side — no fingers): groups of dropped
|
||||
bones (fingers, eyes, bust, hair/coat-skirt physics chains) are
|
||||
remapped to the nearest kept ancestor and merged, keeping hardware
|
||||
skinning well inside the 64-bone budget. The shared skeleton and
|
||||
its animations are untouched, so no animation conversion is needed.
|
||||
All part entities are hidden; the master entity keeps animating the
|
||||
shared skeleton and the impostor follows it via
|
||||
`shareSkeletonInstanceWith`. Hair physics (ragdoll) is OFF.
|
||||
- **LOD3 (70-100 m, till despawn)**: `charlod_<sex>_<variant>_lod3.mesh` —
|
||||
collapse-decimated LOD2, ~400 triangles (hard limit 400).
|
||||
- Interiors need only LOD0/LOD1.
|
||||
|
||||
Generation is automated in the export pipeline:
|
||||
`assets/blender/characters/generate_lods.py` runs per sex on the
|
||||
consolidated blend (CMake step `edited-normal-<sex>-lods.blend`),
|
||||
`export_models2.py` appends the result via its 6th argument and exports
|
||||
the LOD meshes with the SAME shared armature. A manifest
|
||||
(`charlod_<sex>.json`: atlas file + impostor mesh names) is written for
|
||||
reference; the runtime uses name conventions + `resourceExists`, not
|
||||
the manifest. `CharacterSlotSystem::prewarmShaders` also warms the
|
||||
`*_lod1.mesh` / `charlod_*.mesh` shaders.
|
||||
|
||||
Known simplifications (revisit in phase 10.5/11.5 if visible):
|
||||
impostor hair is rigid head-bound, the impostor outfit is the default
|
||||
per-slot pick regardless of the NPC's actual garments, and the atlas
|
||||
downscales each source texture into a grid cell (7 images -> 3x3 grid
|
||||
of ~682 px cells). Known issue: the atlas looks wrong in-game
|
||||
(likely a UV remap bug in generate_lods.py — reported 2026-10-25,
|
||||
deferred; check remap_mesh_uvs against the exported OGRE UV V
|
||||
convention first).
|
||||
|
||||
## 7. Character prefabs
|
||||
|
||||
Characters are saved as `prefabs/character.<id>.json` (phase 6.C.2 —
|
||||
the `character.` prefix distinguishes them from scene prefabs; the
|
||||
editor's prefab save writes the new prefix, old `char_<id>.json` paths
|
||||
migrate on save). A prefab carries the registry record reference,
|
||||
shape-key weights and slot selections — the meshes themselves always
|
||||
come from the catalog.
|
||||
|
||||
## 8. Verifying a new part
|
||||
|
||||
```bash
|
||||
# rebuild the character pipeline, then from the demo-taxi build dir:
|
||||
cd build-vscode/src/features/editScene/demos/demo-taxi
|
||||
xvfb-run -a ./demoTaxi --headless demo_scene_exterior.json \
|
||||
--force-pos 3444 12 3425 0 --print-detail 6000
|
||||
# prints spawned NPCs with their resolved master mesh / age / sex
|
||||
```
|
||||
|
||||
Check: the part appears on NPCs (deterministic pick — several NPCs may
|
||||
need to spawn before a new garment shows), no T-pose (animation tree
|
||||
bound), no floating/crumpling parts (skeleton mismatch — the runtime
|
||||
auto-detects and head-attaches, which hides but does not fix bad
|
||||
weights), shape keys apply on the player character sheet. The full
|
||||
contract suite:
|
||||
|
||||
```bash
|
||||
./demoTaxi --headless --test-motion
|
||||
./demoTaxi --headless --test-detail
|
||||
```
|
||||
@@ -53,8 +53,10 @@ add_custom_command(
|
||||
COMMAND ${BLENDER} -b -Y -P ${CMAKE_SOURCE_DIR}/assets/blender/scripts/export_models2.py --
|
||||
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-consolidated.blend
|
||||
${CMAKE_BINARY_DIR}/characters/male/normal-male.glb
|
||||
"AUTO"
|
||||
"male"
|
||||
tmp-edited-male.blend
|
||||
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-lods.blend
|
||||
COMMAND ${CMAKE_COMMAND} -D FILE=${CMAKE_BINARY_DIR}/characters/male/normal-male.glb
|
||||
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
|
||||
COMMAND ${CMAKE_COMMAND} -D FILE=${CMAKE_BINARY_DIR}/characters/male/normal-male.scene
|
||||
@@ -62,6 +64,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.
@@ -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()
|
||||
|
||||
|
||||
@@ -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.
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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).
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
+1902
-257
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 ® = 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" },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
Reference in New Issue
Block a user