Compare commits

...

27 Commits

Author SHA1 Message Date
slapin a97efbe237 Road geometry is actually generated 2026-07-31 13:25:56 +03:00
slapin 9759c06d5f Implemented Milestone 5 as AI says 2026-07-30 21:23:12 +03:00
slapin 4acad712b0 snapshot 20260729 2026-07-29 13:20:06 +03:00
slapin 50c2395581 Updating mid-implementation of M5.2 2026-07-19 22:44:52 +03:00
slapin 32471f0db6 Batched composite-map updates 2026-07-12 14:25:38 +03:00
slapin ffbc85d4e2 AUX Painting works 2026-07-12 13:50:07 +03:00
slapin 9a12e84fec Detail noise implemented 2026-07-12 01:30:01 +03:00
slapin 96c0eef6cb Headless tests are implemented 2026-07-11 21:06:38 +03:00
slapin 6e71f3639b Updated the plan for road generation. 2026-07-11 18:53:15 +03:00
slapin 568b3bc917 Paint mode is functional 2026-07-08 23:27:15 +03:00
slapin 25106a23c4 Splat paint mode ans sculpt mode are separate now 2026-07-04 17:08:16 +03:00
slapin e2d6e49728 Editing works 2026-07-03 06:41:46 +03:00
slapin 98303a6448 decal works 2026-07-03 05:48:54 +03:00
slapin fb2c2af2b2 Terrain update 2026-07-02 03:29:44 +03:00
slapin f06f535938 Terrain robustness update 2026-07-01 10:57:45 +03:00
slapin 4cd2c72fb0 Now terrains serialization works 2026-06-27 04:53:33 +03:00
slapin 2c095de9f2 Start of terrain sculpting implementation 2026-06-27 03:18:11 +03:00
slapin e047f7bea1 Fixed terrain cleanup 2026-06-27 02:44:55 +03:00
slapin 31864e3da5 Terrain colliders work now! 2026-06-27 02:22:03 +03:00
slapin 3b00e0cc47 Updated AnimationTree component to use Animation Tree Registry 2026-06-27 00:14:09 +03:00
slapin 4b46b1a8e0 Update terrain plan 2026-06-27 00:13:04 +03:00
slapin 52de5cef44 Implemented terrain system core 2026-06-26 23:46:19 +03:00
slapin 5a7d97045f Planning Terrain 2026-06-26 21:10:29 +03:00
slapin 9b6881d873 Planning Terrain 2026-06-26 16:49:20 +03:00
slapin 414744cceb Use Animation Tree Registry 2026-06-25 18:58:59 +03:00
slapin eea4fbdb2f Update Lua examples path 2026-06-25 09:33:22 +03:00
slapin 19faeee8fa Forgot to update AGENTS.md 2026-06-25 09:11:24 +03:00
62 changed files with 19240 additions and 961 deletions
+16 -6
View File
@@ -229,6 +229,8 @@ The editor/game mode executable adds its own ECS modules:
| 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 |
#### PlayerControllerSystem
@@ -261,12 +263,12 @@ keep the controller pointed at the spawner rather than the transient instance.
#### Game Mode Input
Game mode input is kept in sync with the physical keyboard each frame by
calling `SDL_PumpEvents()` followed by `SDL_GetKeyboardState()`. This prevents
missed `keyReleased` events from leaving movement keys stuck (the original
cause of the "infinite slow walk" after releasing Shift+W). Event-driven
`keyPressed`/`keyReleased` handlers are still used for one-shot actions such
as `ePressed`, `fPressed` and `iPressed`.
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`, `Escape`) are still matched by `keysym.sym`.
#### CharacterSpawnerComponent
@@ -276,6 +278,14 @@ as `ePressed`, `fPressed` and `iPressed`.
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.
### Physics System
Uses Jolt Physics with custom wrapper (legacy - `src/physics/physics.h`, current - `src/features/editScene/physics/physics.h`):
+13
View File
@@ -37,6 +37,9 @@ cd build-vscode/src/features/editScene
# Auto-load a scene in editor mode
./editSceneEditor /path/to/scene.json
# Run terrain integration tests headless (1x1 hidden SDL window, no UI)
./editSceneEditor --headless --run-terrain-tests=1
```
The main test target is `component_lua_test`:
@@ -147,6 +150,15 @@ to avoid stuck keys caused by missed `keyReleased` events. One-shot action flags
### AnimationTreeSystem & Root Motion
- Animation trees are stored in the global `AnimationTreeRegistry`, persisted to
`animation_tree.json`. Each `AnimationTreeComponent` references a registry
tree by name only.
- Trees are authored in **Tools -> Animation Tree Registry**. Each registry
entry can select a skeleton source from the `Characters` resource group (one
representative mesh per skeleton); this is used only by the editor to
populate animation-name dropdowns.
- `AnimationTreeSystem` resolves the tree from the registry each frame and
evaluates it.
- Selects a root bone (`Root`, `mixamorig:Hips`, then `Spineroot`), freezes it,
and excludes it from animation via a blend mask.
- Computes `CharacterComponent::linearVelocity` from root-bone displacement when
@@ -202,6 +214,7 @@ restored to the spawner name so the character respawns correctly.
- Please ask questions if anything is unclear.
- Please update tests, documentation and examples every time API or modules and systems change.
Make sure documentation, tests and examples are always in sync.
- Lua examples are stored in lua-examples directory.
## Conventions & Caveats
+35 -4
View File
@@ -1,7 +1,7 @@
project(editScene)
set(CMAKE_CXX_STANDARD 17)
find_package(OGRE REQUIRED COMPONENTS Bites Overlay MeshLodGenerator CONFIG)
find_package(OGRE REQUIRED COMPONENTS Bites Overlay MeshLodGenerator Paging Terrain CONFIG)
find_package(flecs REQUIRED CONFIG)
find_package(nlohmann_json REQUIRED)
find_package(SDL2 REQUIRED)
@@ -23,6 +23,8 @@ set(EDITSCENE_SOURCES
systems/EditorSunSystem.cpp
systems/EditorSkyboxSystem.cpp
systems/EditorWaterPlaneSystem.cpp
systems/TerrainSystem.cpp
systems/RoadSystem.cpp
systems/LightSystem.cpp
systems/CameraSystem.cpp
systems/LodSystem.cpp
@@ -37,6 +39,7 @@ set(EDITSCENE_SOURCES
systems/StartupMenuSystem.cpp
systems/PauseMenuSystem.cpp
systems/ItemRegistry.cpp
systems/AnimationTreeRegistry.cpp
systems/ContainerStateRegistry.cpp
systems/ItemStateRegistry.cpp
systems/SaveLoadSystem.cpp
@@ -102,7 +105,8 @@ set(EDITSCENE_SOURCES
ui/CharacterSpawnerEditor.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
@@ -144,7 +148,6 @@ set(EDITSCENE_SOURCES
components/CharacterSlotsModule.cpp
components/CharacterSpawnerModule.cpp
components/AnimationTreeModule.cpp
components/AnimationTreeTemplateModule.cpp
components/AnimationTree.cpp
components/CharacterModule.cpp
components/CellGridModule.cpp
@@ -162,15 +165,18 @@ set(EDITSCENE_SOURCES
components/BuoyancyInfoModule.cpp
components/WaterPhysicsModule.cpp
components/WaterPlaneModule.cpp
components/TerrainModule.cpp
components/SunModule.cpp
components/SkyboxModule.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
@@ -178,6 +184,8 @@ set(EDITSCENE_SOURCES
lua/LuaCharacterClassApi.cpp
lua/LuaCharacterApi.cpp
lua/LuaSaveLoadApi.cpp
lua/LuaTerrainApi.cpp
systems/TerrainTests.cpp
)
set(EDITSCENE_HEADERS
@@ -191,6 +199,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
@@ -222,6 +232,7 @@ set(EDITSCENE_HEADERS
systems/StartupMenuSystem.hpp
systems/PauseMenuSystem.hpp
systems/ItemRegistry.hpp
systems/AnimationTreeRegistry.hpp
systems/ContainerStateRegistry.hpp
systems/ItemStateRegistry.hpp
systems/PlayerControllerSystem.hpp
@@ -281,6 +292,8 @@ set(EDITSCENE_HEADERS
systems/EditorSunSystem.hpp
systems/EditorSkyboxSystem.hpp
systems/EditorWaterPlaneSystem.hpp
systems/TerrainSystem.hpp
systems/RoadSystem.hpp
systems/LightSystem.hpp
systems/CameraSystem.hpp
systems/LodSystem.hpp
@@ -305,7 +318,8 @@ set(EDITSCENE_HEADERS
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
@@ -340,10 +354,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
@@ -351,6 +367,7 @@ set(EDITSCENE_HEADERS
lua/LuaCharacterClassApi.hpp
lua/LuaCharacterApi.hpp
lua/LuaSaveLoadApi.hpp
lua/LuaTerrainApi.hpp
)
add_executable(editSceneEditor ${EDITSCENE_SOURCES} ${EDITSCENE_HEADERS})
@@ -373,6 +390,8 @@ target_link_libraries(editSceneEditor
OgreBites
OgreOverlay
OgreMeshLodGenerator
OgrePaging
OgreTerrain
flecs::flecs_static
nlohmann_json::nlohmann_json
Jolt::Jolt
@@ -384,6 +403,7 @@ target_link_libraries(editSceneEditor
RecastNavigation::DebugUtils
PackageArchive
lua
SDL2::SDL2
)
target_include_directories(editSceneEditor PRIVATE
@@ -393,6 +413,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
)
@@ -737,6 +758,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"
+292 -48
View File
@@ -2,12 +2,14 @@
#include <filesystem>
#include "EditorApp.hpp"
#include "GameMode.hpp"
#include <OgreMaterialManager.h>
#include "systems/EditorUISystem.hpp"
#include "systems/PhysicsSystem.hpp"
#include "systems/BuoyancySystem.hpp"
#include "systems/EditorSunSystem.hpp"
#include "systems/EditorSkyboxSystem.hpp"
#include "systems/EditorWaterPlaneSystem.hpp"
#include "systems/TerrainSystem.hpp"
#include "systems/LightSystem.hpp"
#include "systems/CameraSystem.hpp"
#include "systems/LodSystem.hpp"
@@ -35,6 +37,7 @@
#include "systems/PauseMenuSystem.hpp"
#include "systems/DialogueSystem.hpp"
#include "systems/ItemRegistry.hpp"
#include "systems/AnimationTreeRegistry.hpp"
#include "systems/ContainerStateRegistry.hpp"
#include "systems/ItemStateRegistry.hpp"
#include "systems/CharacterClassSystem.hpp"
@@ -59,6 +62,7 @@
#include "components/InWater.hpp"
#include "components/WaterPhysics.hpp"
#include "components/WaterPlane.hpp"
#include "components/Terrain.hpp"
#include "components/Sun.hpp"
#include "components/Skybox.hpp"
#include "components/Light.hpp"
@@ -75,7 +79,6 @@
#include "components/CharacterIdentity.hpp"
#include "systems/CharacterRegistry.hpp"
#include "components/AnimationTree.hpp"
#include "components/AnimationTreeTemplate.hpp"
#include "components/Character.hpp"
#include "components/StartupMenu.hpp"
#include "components/PlayerController.hpp"
@@ -109,12 +112,14 @@
#endif
#include <OgreRTShaderSystem.h>
#include <OgreShaderRenderState.h>
#include <OgreArchiveManager.h>
#include "package/OgrePackageArchive.h"
#include <imgui.h>
#include <SDL2/SDL.h>
#include "lua/LuaEntityApi.hpp"
#include "lua/LuaComponentApi.hpp"
#include "lua/LuaAnimationTreeApi.hpp"
#include "lua/LuaEventApi.hpp"
#include "lua/LuaActionApi.hpp"
#include "lua/LuaBehaviorTreeApi.hpp"
@@ -122,6 +127,7 @@
#include "lua/LuaCharacterClassApi.hpp"
#include "lua/LuaDialogueApi.hpp"
#include "lua/LuaItemApi.hpp"
#include "lua/LuaTerrainApi.hpp"
//=============================================================================
// ImGuiRenderListener Implementation
@@ -233,16 +239,135 @@ EditorApp::EditorApp()
EditorApp::~EditorApp()
{
destroyEditorSystems();
}
void EditorApp::shutdownEditor()
{
destroyEditorSystems();
}
void EditorApp::closeApp()
{
shutdownEditor();
/* Terrain materials and other RTSS-generated materials are torn down
* in shutdownEditor(), so the base-class RTSS teardown can run safely
* while MaterialManager still exists. */
OgreBites::ApplicationContext::closeApp();
}
OgreBites::NativeWindowPair
EditorApp::createWindow(const Ogre::String &name, uint32_t w, uint32_t h,
Ogre::NameValuePairList miscParams)
{
if (!m_headless) {
return OgreBites::ApplicationContext::createWindow(name, w, h,
miscParams);
}
Ogre::LogManager::getSingleton().logMessage(
"EditorApp: creating headless 1x1 render window");
if (SDL_InitSubSystem(SDL_INIT_VIDEO | SDL_INIT_GAMECONTROLLER) < 0) {
OGRE_EXCEPT(
Ogre::Exception::ERR_INTERNAL_ERROR,
Ogre::String(
"Could not initialize SDL video subsystem: ") +
SDL_GetError(),
"EditorApp::createWindow");
}
/* Request a minimal, broadly-supported OpenGL pixel format. This is
* especially important for offscreen Xvfb where the default SDL/X11
* visual query can fail with "Couldn't find matching GLX visual". */
SDL_GL_SetAttribute(SDL_GL_RED_SIZE, 8);
SDL_GL_SetAttribute(SDL_GL_GREEN_SIZE, 8);
SDL_GL_SetAttribute(SDL_GL_BLUE_SIZE, 8);
SDL_GL_SetAttribute(SDL_GL_ALPHA_SIZE, 0);
SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 0);
SDL_GL_SetAttribute(SDL_GL_STENCIL_SIZE, 0);
SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 0);
SDL_GL_SetAttribute(SDL_GL_FRAMEBUFFER_SRGB_CAPABLE, 0);
SDL_Window *sdlWin = SDL_CreateWindow(
name.c_str(), SDL_WINDOWPOS_UNDEFINED, SDL_WINDOWPOS_UNDEFINED,
1, 1, SDL_WINDOW_HIDDEN | SDL_WINDOW_OPENGL);
if (!sdlWin) {
Ogre::LogManager::getSingleton().logMessage(
"EditorApp: SDL_WINDOW_OPENGL failed, trying plain hidden window: " +
Ogre::String(SDL_GetError()));
sdlWin = SDL_CreateWindow(name.c_str(), SDL_WINDOWPOS_UNDEFINED,
SDL_WINDOWPOS_UNDEFINED, 1, 1,
SDL_WINDOW_HIDDEN);
}
if (!sdlWin) {
OGRE_EXCEPT(Ogre::Exception::ERR_INTERNAL_ERROR,
Ogre::String("Could not create SDL window: ") +
SDL_GetError(),
"EditorApp::createWindow");
}
miscParams["sdlwin"] = Ogre::StringConverter::toString((size_t)sdlWin);
miscParams["gamma"] = "No";
miscParams["vsync"] = "No";
miscParams["FSAA"] = "0";
Ogre::RenderWindow *rw =
mRoot->createRenderWindow(name, 1, 1, false, &miscParams);
if (!rw) {
OGRE_EXCEPT(Ogre::Exception::ERR_INTERNAL_ERROR,
"Could not create Ogre render window",
"EditorApp::createWindow");
}
OgreBites::NativeWindowPair win = { rw, sdlWin };
mWindows.push_back(win);
return win;
}
/* ------------------------------------------------------------------ */
/* Helper: release RTShader TargetRenderState objects held by every */
/* material pass. This destroys the SubRenderState instances owned */
/* by the target render states before the RTSS factories are torn */
/* down, avoiding the "Sub render states still exists" assertion. */
/* ------------------------------------------------------------------ */
static void clearRTShaderPassBindings()
{
Ogre::MaterialManager &matMgr = Ogre::MaterialManager::getSingleton();
Ogre::MaterialManager::ResourceMapIterator it =
matMgr.getResourceIterator();
while (it.hasMoreElements()) {
Ogre::MaterialPtr mat =
Ogre::static_pointer_cast<Ogre::Material>(it.getNext());
if (!mat)
continue;
for (Ogre::Technique *tech : mat->getTechniques()) {
if (!tech)
continue;
for (Ogre::Pass *pass : tech->getPasses()) {
if (!pass)
continue;
pass->getUserObjectBindings().eraseUserAny(
Ogre::RTShader::TargetRenderState::
UserKey);
}
}
}
}
void EditorApp::destroyEditorSystems()
{
if (m_systemsDestroyed)
return;
// Shutdown UI system first (cleans up gizmo while SceneManager is still valid)
if (m_uiSystem) {
m_uiSystem->shutdown();
}
// Delete all editor entities before OGRE cleanup
// This ensures all components with Ogre resources are cleaned up while SceneManager exists
// Collect entities first, then delete after iteration (can't modify during iteration)
std::vector<flecs::entity> entitiesToDelete;
// DialogueSystem is a singleton, no manual teardown needed
m_startupMenuSystem.reset();
@@ -270,13 +395,45 @@ EditorApp::~EditorApp()
m_lodSystem.reset();
m_cameraSystem.reset();
m_lightSystem.reset();
/* Terrain must be torn down before water, sky, sun and physics. */
m_terrainSystem.reset();
m_waterPlaneSystem.reset();
m_skyboxSystem.reset();
m_sunSystem.reset();
m_buoyancySystem.reset();
m_physicsSystem.reset();
/* Flush the RTShader generator cache before clearing materials. This
* waits for any pending shader-generation tasks and destroys generated
* SubRenderState instances while their factories are still alive. */
Ogre::RTShader::ShaderGenerator *shadergen =
Ogre::RTShader::ShaderGenerator::getSingletonPtr();
if (shadergen) {
shadergen->removeAllShaderBasedTechniques();
shadergen->flushShaderCache();
}
// Clear the scene and drop any materials that are no longer referenced so
// the RTShader generator can shut down without SubRenderState instances
// outliving their factories.
if (m_sceneMgr) {
m_sceneMgr->clearScene();
Ogre::MaterialManager::getSingleton()
.unloadUnreferencedResources();
clearRTShaderPassBindings();
}
if (m_imguiListener) {
Ogre::RenderWindow *rw = getRenderWindow();
if (rw)
rw->removeListener(m_imguiListener.get());
}
m_imguiListener.reset();
m_uiSystem.reset();
m_camera.reset();
// Now OGRE can shut down safely
// Singletons are managed by OGRE, don't delete them
m_systemsDestroyed = true;
}
void EditorApp::setup()
@@ -310,11 +467,13 @@ void EditorApp::setup()
OgreAssert(m_overlaySystem, "OverlaySystem not available");
m_sceneMgr->addRenderQueueListener(m_overlaySystem);
// Setup ImGui overlay
m_imguiOverlay = initialiseImGui();
if (m_imguiOverlay) {
m_imguiOverlay->setZOrder(300);
ImGui::StyleColorsDark();
// Setup ImGui overlay (skip in headless mode)
if (!m_headless) {
m_imguiOverlay = initialiseImGui();
if (m_imguiOverlay) {
m_imguiOverlay->setZOrder(300);
ImGui::StyleColorsDark();
}
}
// Setup camera
@@ -328,19 +487,22 @@ void EditorApp::setup()
createAxes();
createDefaultEntities();
// Setup UI system
m_uiSystem = std::make_unique<EditorUISystem>(
m_world, m_sceneMgr, getRenderWindow());
if (m_uiSystem)
m_uiSystem->setEditorUIEnabled(m_gameMode ==
GameMode::Editor);
m_uiSystem->setEditorCamera(m_camera.get());
// Setup UI system (skip in headless mode)
if (!m_headless) {
m_uiSystem = std::make_unique<EditorUISystem>(
m_world, m_sceneMgr, getRenderWindow());
if (m_uiSystem)
m_uiSystem->setEditorUIEnabled(
m_gameMode == GameMode::Editor);
m_uiSystem->setEditorCamera(m_camera.get());
}
// Setup physics system
m_physicsSystem = std::make_unique<EditorPhysicsSystem>(
m_world, m_sceneMgr);
m_physicsSystem->initialize();
m_uiSystem->setPhysicsSystem(m_physicsSystem.get());
if (m_uiSystem)
m_uiSystem->setPhysicsSystem(m_physicsSystem.get());
// Setup buoyancy system (requires physics system)
// Get the physics wrapper from the physics system
@@ -355,6 +517,15 @@ void EditorApp::setup()
m_waterPlaneSystem = std::make_unique<EditorWaterPlaneSystem>(
m_world, m_sceneMgr);
// TerrainSystem — owns Ogre terrain singletons, needs camera for paging.
{
Ogre::Camera *cam = m_camera ? m_camera->getCamera() :
nullptr;
m_terrainSystem = std::make_unique<TerrainSystem>(
m_world, m_sceneMgr, cam,
m_physicsSystem->getPhysicsWrapper());
}
// Apply debug setting if it was set before system creation
if (m_debugBuoyancy) {
m_buoyancySystem->setDebugEnabled(true);
@@ -405,8 +576,9 @@ void EditorApp::setup()
m_world, m_sceneMgr);
m_characterSlotSystem->initialize();
if (m_uiSystem)
m_uiSystem->getCharacterRegistry().setCharacterSlotSystem(
m_characterSlotSystem.get());
m_uiSystem->getCharacterRegistry()
.setCharacterSlotSystem(
m_characterSlotSystem.get());
// Setup CharacterSpawner system
m_characterSpawnerSystem =
@@ -519,6 +691,7 @@ void EditorApp::setup()
PauseMenuSystem::getInstance().init(this);
static ItemRegistry s_itemRegistry;
ItemRegistry::getSingleton().initialize();
AnimationTreeRegistry::getSingleton().initialize();
ItemStateRegistry::getInstance().loadFromFile(
"item_state.json");
ContainerStateRegistry::getInstance().loadFromFile(
@@ -565,25 +738,29 @@ void EditorApp::setup()
this);
}
// Now show the overlay — font atlas will be built with our font
if (m_imguiOverlay)
m_imguiOverlay->show();
// In headless mode we skip the overlay, editor UI, render listener
// and input listeners because there is no interactive window.
if (!m_headless) {
// Now show the overlay — font atlas will be built with our font
if (m_imguiOverlay)
m_imguiOverlay->show();
// Add default entities to UI cache
for (auto &e : m_defaultEntities) {
m_uiSystem->addEntity(e);
// Add default entities to UI cache
for (auto &e : m_defaultEntities) {
m_uiSystem->addEntity(e);
}
// Create and register ImGui render listener
m_imguiListener = std::make_unique<ImGuiRenderListener>(
m_imguiOverlay, m_uiSystem.get(),
getRenderWindow(), this);
getRenderWindow()->addListener(m_imguiListener.get());
// Register input listeners
addInputListener(this);
addInputListener(getImGuiInputListener());
}
// Create and register ImGui render listener
m_imguiListener = std::make_unique<ImGuiRenderListener>(
m_imguiOverlay, m_uiSystem.get(), getRenderWindow(),
this);
getRenderWindow()->addListener(m_imguiListener.get());
// Register input listeners
addInputListener(this);
addInputListener(getImGuiInputListener());
// Initialize Lua scripting
{
lua_State *L = m_lua.getState();
@@ -600,6 +777,7 @@ void EditorApp::setup()
// Component and Event APIs add to it.
editScene::registerLuaEntityApi(L);
editScene::registerLuaComponentApi(L);
editScene::registerLuaAnimationTreeApi(L);
editScene::registerLuaEventApi(L);
editScene::registerLuaActionApi(L);
editScene::registerLuaBehaviorTreeApi(L);
@@ -608,6 +786,7 @@ void EditorApp::setup()
editScene::registerLuaCharacterApi(L);
editScene::registerLuaDialogueApi(L);
editScene::registerLuaItemApi(L);
editScene::registerLuaTerrainApi(L);
// Run late setup: load data.lua and initial scripts.
m_lua.lateSetup();
@@ -689,6 +868,11 @@ void EditorApp::setDebugBuoyancy(bool enabled)
}
}
void EditorApp::setHeadless(bool headless)
{
m_headless = headless;
}
void EditorApp::setGamePlayState(GamePlayState state)
{
m_gamePlayState = state;
@@ -705,7 +889,7 @@ void EditorApp::setGamePlayState(GamePlayState state)
m_gameInput.clearMovementInput();
// Grab/ungrab mouse based on gameplay state
if (m_gameMode == GameMode::Game) {
if (m_gameMode == GameMode::Game && !m_headless) {
if (state == GamePlayState::Playing) {
setWindowGrab(true);
} else if (state == GamePlayState::Menu) {
@@ -1146,7 +1330,8 @@ void EditorApp::loadGame(const std::string &slotPath)
if (m_characterSpawnerSystem) {
flecs::entity spawner =
m_characterSpawnerSystem
->getSpawnerForCharacter(playerChar);
->getSpawnerForCharacter(
playerChar);
if (spawner.is_alive())
targetEntity = spawner;
}
@@ -1192,6 +1377,7 @@ void EditorApp::setupECS()
m_world.component<BuoyancyInfo>();
m_world.component<WaterPhysics>();
m_world.component<WaterPlane>();
m_world.component<TerrainComponent>();
// Register light and camera components
m_world.component<LightComponent>();
@@ -1224,9 +1410,6 @@ void EditorApp::setupECS()
// Register AnimationTree component
m_world.component<AnimationTreeComponent>();
// Register AnimationTreeTemplate component
m_world.component<AnimationTreeTemplate>();
// Register Character component
m_world.component<CharacterComponent>();
@@ -1398,6 +1581,11 @@ void EditorApp::createAxes()
}
}
bool EditorApp::frameEnded(const Ogre::FrameEvent & /*evt*/)
{
return true;
}
bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt)
{
bool paused = (m_gamePlayState == GamePlayState::Paused);
@@ -1440,6 +1628,11 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt)
m_proceduralMeshSystem->update();
}
/* --- Terrain update (before static world so navmesh can use terrain) --- */
if (m_terrainSystem) {
m_terrainSystem->update(evt.timeSinceLastFrame);
}
/* --- Static world generation (meshes + physics) --- */
if (m_roomLayoutSystem) {
m_roomLayoutSystem->update();
@@ -1580,7 +1773,8 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt)
// Resolve the live player character (spawned instance if
// the controller targets a spawner) so the sheet shows
// the same entity ActuatorSystem uses.
flecs::entity playerCharacter = getPlayerCharacterEntity();
flecs::entity playerCharacter =
getPlayerCharacterEntity();
if (playerCharacter.is_valid() &&
playerCharacter.is_alive()) {
m_characterClassSystem->toggleCharacterSheet(
@@ -1688,6 +1882,16 @@ bool EditorApp::keyPressed(const OgreBites::KeyboardEvent &evt)
{
m_currentModifiers = evt.keysym.mod;
/* Exit terrain sculpt/paint modes with ESC before other handlers
* consume the key. */
if (m_terrainSystem &&
(m_terrainSystem->isSculpting() || m_terrainSystem->isPainting()) &&
evt.keysym.sym == OgreBites::SDLK_ESCAPE) {
m_terrainSystem->setSculptMode(false);
m_terrainSystem->setPaintMode(false);
return true;
}
if (m_gameMode == GameMode::Game) {
if (evt.keysym.sym == OgreBites::SDLK_ESCAPE) {
if (m_gamePlayState == GamePlayState::Playing)
@@ -1738,7 +1942,8 @@ bool EditorApp::keyPressed(const OgreBites::KeyboardEvent &evt)
case SDLK_RSHIFT:
if (!isRepeat && !m_gameInput.shift) {
m_gameInput.shift = true;
EDITSCENE_LOG_INPUT("[INPUT_DBG] keyPressed SHIFT");
EDITSCENE_LOG_INPUT(
"[INPUT_DBG] keyPressed SHIFT");
}
break;
case 'e':
@@ -1892,8 +2097,47 @@ void EditorApp::locateResources()
"Characters", true);
Ogre::ResourceGroupManager::getSingleton().createResourceGroup(
"LuaScripts", false);
Ogre::ResourceGroupManager::getSingleton().addResourceLocation(
"./lua-scripts.package", "Package", "LuaScripts", true, true);
/* Try package and raw filesystem from several relative paths so the
* editor works when launched from the build root or from the binary
* subdirectory. */
struct LuaPathEntry {
const char *packagePath;
const char *fsPath;
};
LuaPathEntry luaPaths[] = {
{ "./lua-scripts.package", "./lua-scripts" },
{ "./src/features/editScene/lua-scripts.package",
"./src/features/editScene/lua-scripts" },
{ "../../src/features/editScene/lua-scripts.package",
"../../src/features/editScene/lua-scripts" },
{ "../../../src/features/editScene/lua-scripts.package",
"../../../src/features/editScene/lua-scripts" },
};
bool luaAdded = false;
for (const auto &entry : luaPaths) {
if (std::filesystem::exists(entry.packagePath)) {
Ogre::ResourceGroupManager::getSingleton()
.addResourceLocation(entry.packagePath,
"Package", "LuaScripts",
true, true);
luaAdded = true;
break;
}
if (std::filesystem::exists(entry.fsPath)) {
Ogre::ResourceGroupManager::getSingleton()
.addResourceLocation(entry.fsPath, "FileSystem",
"LuaScripts", true, true);
luaAdded = true;
break;
}
}
if (!luaAdded) {
Ogre::LogManager::getSingleton().logMessage(
"WARNING: Could not find lua-scripts package or directory from " +
std::filesystem::current_path().string());
}
/* Try multiple relative paths for characters to handle different
* working directories (build root vs binary subdirectory) */
+24
View File
@@ -40,6 +40,7 @@ class EditorSunSystem;
class EditorSkyboxSystem;
class EditorWaterPlaneSystem;
class NormalDebugSystem;
class TerrainSystem;
class SmartObjectSystem;
class GoapRunnerSystem;
class PathFollowingSystem;
@@ -136,7 +137,14 @@ public:
// OgreBites::ApplicationContext overrides
void setup() 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();
// OgreBites::InputListener overrides
bool mouseMoved(const OgreBites::MouseMotionEvent &evt) override;
@@ -167,6 +175,13 @@ 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;
@@ -220,6 +235,10 @@ public:
{
return m_characterSpawnerSystem.get();
}
ProceduralMeshSystem *getProceduralMeshSystem() const
{
return m_proceduralMeshSystem.get();
}
StartupMenuSystem *getStartupMenuSystem() const
{
return m_startupMenuSystem.get();
@@ -266,6 +285,7 @@ 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<EditorLightSystem> m_lightSystem;
std::unique_ptr<EditorCameraSystem> m_cameraSystem;
std::unique_ptr<EditorLodSystem> m_lodSystem;
@@ -303,7 +323,11 @@ private:
GamePlayState m_gamePlayState = GamePlayState::Menu;
GameInputState m_gameInput;
bool m_setupComplete = false;
bool m_systemsDestroyed = false;
bool m_debugBuoyancy = false;
bool m_headless = false;
void destroyEditorSystems();
float m_playTime = 0.0f;
std::string m_currentBaseScene;
@@ -0,0 +1,312 @@
# 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 | — | UNCOVERED | `RoadSystem::complyTerrain()` has no automated test. Perpendicular falloff (laneWidth*2 fade) needs implementation. |
| M5.11 | Roadside prefab spawning | — | UNCOVERED | `RoadSystem::spawnSidePrefabs()` has no automated test |
| M5.12 | Serialization + wiring| `testRoadSerialization` | COVERED | Config/nodes/edges/sidePrefabs JSON round-trip; wiring: roadSystem lifecycle covered by testRoadPageAssignment + testRoadPageMeshes |
### 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:15641629)
**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 `RoadSystem::spawnSidePrefabs()` creates and destroys
prefab instances correctly.
**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 with one `RoadSidePrefab` pointing at
`tiny_cube.prefab` with `edgeT=0.5, sideOffset=3, leftSide=true`.
3. Pump frames until road mesh + prefabs exist.
4. Verify `RoadPageGeometry::spawnedPrefabs` is non-empty (1 entity).
5. Verify the spawned entity is alive, has a `TransformComponent`, and its
position is approximately the expected world position (edge midpoint + 3
units left).
6. Bump graph version (add a dummy node+edge) → pump frames.
- Verify old prefab entity is no longer alive.
- Verify `spawnedPrefabs` now contains the new prefab instance.
7. Deactivate terrain → verify spawned prefab entity is not alive.
## 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 12 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 | Depends on |
|---|------|-----------------|------------|
| W1 | M5.10 perpendicular falloff in `complyTerrain()` | `RoadSystem.cpp` | — |
| W2 | Helper `computeComplianceHeight()` + unit test | `RoadSystem.cpp`, `TerrainTests.cpp` | W1 |
| W3 | `testTerrainCompliance` (automated) | `TerrainTests.cpp`, `TerrainTests.hpp` | W1 |
| W4 | `testRoadColliderInteraction` (automated) | `TerrainTests.cpp`, `TerrainTests.hpp` | — |
| W5 | Road collider debug draw toggle (M5.9.6) | `RoadSystem.hpp/.cpp`, `TerrainSystem.hpp/.cpp`, `TerrainEditor.hpp` | — |
| W6 | Test prefab fixture `tiny_cube.prefab` | `src/features/editScene/tests/prefabs/tiny_cube.prefab` (new) | — |
| W7 | `testRoadSidePrefabs` (automated) | `TerrainTests.cpp`, `TerrainTests.hpp` | W6 |
| W8 | Register new tests in `TerrainTestRunner::run()` | `TerrainTests.cpp` | W3, W4, W7 |
## 5. Summary
| Area | Status |
|------|--------|
| M5.1M5.8 automated coverage | ✅ Adequate (8/8 sub-items have tests) |
| M5.9 automated coverage | ⚠️ Partial → W4 adds raycast+rebuild verification |
| M5.9.6 road collider debug toggle | ❌ Not implemented → W5 |
| M5.10 perpendicular falloff | ❌ Missing from implementation → W1+W2 |
| M5.10 automated coverage | ❌ None → W3 |
| M5.11 automated coverage | ❌ None → W6+W7 |
| M5.12 automated coverage | ✅ Adequate |
| Manual verification steps | 📋 Defined (sections 3.13.7) |
| Open questions | ✅ All resolved (section 0) |
**Exit criteria** — Milestone 5 is fully verified when:
- [ ] All 8 work items (W1W8) are implemented.
- [ ] `./editSceneEditor --headless --run-terrain-tests=1` passes with all
existing + new M5 tests green (expect 2223 tests per iteration).
- [ ] Manual verification walkthroughs 3.13.7 are executed and pass.
- [ ] `ctest -R editSceneTerrainTest` passes in CI.
File diff suppressed because it is too large Load Diff
@@ -1,9 +1,61 @@
#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.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();
}
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,6 +2,7 @@
#define EDITSCENE_ANIMATIONTREE_HPP
#pragma once
#include <Ogre.h>
#include <nlohmann/json.hpp>
#include <vector>
#include <unordered_map>
@@ -138,6 +139,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 +151,15 @@ struct AnimationTreeNode {
* which Ogre AnimationStates are active and their blend weights.
*/
struct AnimationTreeComponent {
AnimationTreeNode root;
/* Name of the tree in AnimationTreeRegistry. */
Ogre::String treeName;
bool enabled = true;
bool useRootMotion = false;
bool dirty = true;
/* If set, the tree root is copied from the named template */
Ogre::String templateName;
/* Runtime: last copied template version (not serialized) */
uint64_t templateVersion = 0;
/* Runtime: last resolved registry version (not serialized) */
uint64_t treeVersion = 0;
/* Runtime: current state of each state machine (not serialized) */
std::unordered_map<Ogre::String, Ogre::String> currentStates;
@@ -1,14 +1,14 @@
#include "AnimationTree.hpp"
#include "../ui/ComponentRegistration.hpp"
#include "../ui/AnimationTreeEditor.hpp"
#include "../systems/AnimationTreeRegistry.hpp"
#include "Transform.hpp"
static AnimationTreeComponent createDefaultTree()
static Ogre::String createDefaultTree()
{
AnimationTreeComponent at;
at.root.type = "output";
at.root.speed = 1.0f;
AnimationTreeNode root;
root.type = "output";
root.speed = 1.0f;
AnimationTreeNode sm;
sm.type = "stateMachine";
@@ -25,9 +25,17 @@ static AnimationTreeComponent createDefaultTree()
state.children.push_back(anim);
sm.children.push_back(state);
at.root.children.push_back(sm);
root.children.push_back(sm);
return at;
AnimationTreeRegistry &reg = AnimationTreeRegistry::getSingleton();
Ogre::String name = reg.generateUniqueName("default");
AnimationTreeRegistry::AnimationTreeDefinition def;
def.name = name;
def.root = root;
reg.registerTree(def);
return name;
}
REGISTER_COMPONENT_GROUP("Animation Tree", "Rendering",
@@ -45,7 +53,8 @@ REGISTER_COMPONENT_GROUP("Animation Tree", "Rendering",
->createChildSceneNode();
e.set<TransformComponent>(transform);
}
AnimationTreeComponent at = createDefaultTree();
AnimationTreeComponent at;
at.treeName = createDefaultTree();
at.dirty = true;
e.set<AnimationTreeComponent>(at);
},
@@ -1,20 +0,0 @@
#ifndef EDITSCENE_ANIMATIONTREETEMPLATE_HPP
#define EDITSCENE_ANIMATIONTREETEMPLATE_HPP
#pragma once
#include <Ogre.h>
/**
* Template marker for reusable animation trees.
*
* Entities with this component serve as shared animation tree templates.
* They should also have an AnimationTreeComponent for editing the tree.
* Other entities reference the template by name via
* AnimationTreeComponent::templateName.
*/
struct AnimationTreeTemplate {
Ogre::String name;
uint64_t version = 1;
};
#endif // EDITSCENE_ANIMATIONTREETEMPLATE_HPP
@@ -1,23 +0,0 @@
#include "AnimationTreeTemplate.hpp"
#include "../ui/ComponentRegistration.hpp"
#include "../ui/AnimationTreeTemplateEditor.hpp"
REGISTER_COMPONENT_GROUP("Animation Tree Template", "Animation",
AnimationTreeTemplate, AnimationTreeTemplateEditor)
{
registry.registerComponent<AnimationTreeTemplate>(
AnimationTreeTemplate_name, AnimationTreeTemplate_group,
std::make_unique<AnimationTreeTemplateEditor>(),
// Adder
[](flecs::entity e) {
if (!e.has<AnimationTreeTemplate>()) {
e.set<AnimationTreeTemplate>({});
}
},
// Remover
[](flecs::entity e) {
if (e.has<AnimationTreeTemplate>()) {
e.remove<AnimationTreeTemplate>();
}
});
}
@@ -0,0 +1,989 @@
#ifndef EDITSCENE_ROADGRAPH_HPP
#define EDITSCENE_ROADGRAPH_HPP
#pragma once
#include <Ogre.h>
#include <algorithm>
#include <cmath>
#include <string>
#include <vector>
/**
* Minimum edge length in world units (M5.7). The road mesh template is
* exactly 1 unit along X; edges shorter than this will produce visibly
* compressed UVs. Operations that would create shorter half-edges snap
* to integer multiples of this value; if snapping is impossible the
* geometry system's UV scaling handles the fractional remainder.
*/
static const float ROAD_MIN_EDGE_LENGTH = 1.0f;
/**
* Road configuration parameters global settings that apply to the whole
* road network owned by a single TerrainComponent. These values are
* serialized with the scene and edited through the terrain editor.
*/
struct RoadConfig {
/**
* Ogre mesh template used to build each unit-length road segment.
*
* The mesh must occupy exactly 1 unit along the positive X axis
* (X in [0, 1]) and 1 unit in Z (Z in [0, 1] or [-1, 0]). The top
* surface is at Y = +roadThickness/2 and the bottom at
* Y = -roadThickness/2. The template is repeated and scaled along
* each road edge/wedge during geometry generation.
*/
std::string roadMeshTemplate = "road_segment.mesh";
/**
* World-space width of one traffic lane.
*
* This is a global constant for the whole terrain; it cannot be
* overridden per edge. Total road width at any point is derived
* from this value and the resolved lane counts of the incident
* edges.
*/
float laneWidth = 3.0f;
/**
* Default number of lanes in each direction.
*
* An edge can override this with RoadEdge::lanesAtoB and
* RoadEdge::lanesBtoA. A value of 0 in an edge field means "use
* this global default".
*/
int lanesPerDirection = 1;
/**
* Vertical thickness of the road surface.
*
* Used both to size the generated/fallback road mesh and to compute
* terrain compliance: the terrain is flattened to
* roadSurfaceY - roadThickness so the road sits flush on top without
* clipping or floating.
*/
float roadThickness = 0.3f;
/**
* Distance from the camera at which a lower LOD level is selected.
*/
float roadLodDistance = 200.0f;
/**
* Maximum distance from the camera at which road meshes are rendered.
*/
float roadVisibilityDistance = 1000.0f;
/**
* Ogre material applied to generated road surfaces.
*/
std::string roadMaterialName = "RoadMaterial";
};
/**
* A single point in the road network graph.
*
* RoadNode stores the world position of a graph vertex plus a small vertical
* offset above the terrain surface. The Y component of @c position is always
* kept consistent with terrain_height(x, z) + verticalOffset by the editor
* tools; the serializable source of truth is the pair
* (position.x, position.z, verticalOffset).
*/
struct RoadNode {
/**
* World-space position of the node.
*
* The X and Z coordinates are chosen by the user and stored exactly.
* The Y coordinate is derived from the terrain surface height plus
* @c verticalOffset. Runtime systems that sample the node should
* recompute Y from the current terrain when precise placement is
* required.
*/
Ogre::Vector3 position;
/**
* Vertical offset above the terrain surface at (position.x, position.z).
*
* Positive values raise the road above the terrain; negative values
* sink it. Editable in the node inspector.
*/
float verticalOffset = 0.0f;
/**
* Stable identifier for the node.
*
* IDs are assigned monotonically and are never reused after a node is
* deleted. RoadEdge endpoints reference nodes by this ID, not by
* index into a vector, so reordering or deleting nodes does not break
* existing edges.
*/
int id = 0;
};
/**
* A connection between two RoadNode objects.
*
* The edge stores its endpoint node IDs, the road surface height at each
* end relative to the corresponding node, and per-direction lane counts.
* Lane counts are stored as written; a value of 0 means "fall back to the
* global RoadConfig::lanesPerDirection".
*/
struct RoadEdge {
/** Stable ID of the first endpoint node. */
int nodeA = -1;
/** Stable ID of the second endpoint node. */
int nodeB = -1;
/**
* Road surface height at the @c nodeA end, relative to nodeA.position.y.
*
* The absolute road surface Y at nodeA is nodeA.position.y + roadLevelA.
*/
float roadLevelA = 0.0f;
/**
* Road surface height at the @c nodeB end, relative to nodeB.position.y.
*
* The absolute road surface Y at nodeB is nodeB.position.y + roadLevelB.
*/
float roadLevelB = 0.0f;
/**
* Deprecated per-edge lane-count override.
*
* Kept for backward compatibility with older serialized scenes. New
* code should prefer RoadEdge::lanesAtoB and RoadEdge::lanesBtoA.
* When non-zero this value behaves like lanesAtoB == lanesBtoA ==
* lanesPerDirectionOverride.
*/
int lanesPerDirectionOverride = 0;
/**
* Number of lanes traveling from nodeA to nodeB.
*
* 0 means "use RoadConfig::lanesPerDirection". Positive values
* override the global default for this direction only. Asymmetric
* roads are allowed (e.g. lanesAtoB = 2, lanesBtoA = 1).
*/
int lanesAtoB = 0;
/**
* Number of lanes traveling from nodeB to nodeA.
*
* 0 means "use RoadConfig::lanesPerDirection". See lanesAtoB for
* asymmetric-lane semantics.
*/
int lanesBtoA = 0;
/**
* A prefab instance placed beside the road on a specific edge.
*
* Roadside prefabs are regenerated from edge data when the terrain
* page that contains them is loaded; they are not serialized as
* independent scene entities.
*/
struct RoadSidePrefab {
/** Prefab JSON file path, relative to the prefabs directory. */
std::string prefabPath;
/**
* Normalized position along the edge from nodeA to nodeB.
*
* 0.0 places the prefab at nodeA; 1.0 places it at nodeB.
*/
float edgeT = 0.5f;
/**
* Lateral distance from the road center line.
*
* The actual side (left or right) is determined by @c leftSide.
*/
float sideOffset = 5.0f;
/**
* true = left side of the road when looking from nodeA toward nodeB.
* false = right side of the road when looking from nodeA toward nodeB.
*/
bool leftSide = true;
};
/** Prefab spawn points attached to this edge. */
std::vector<RoadSidePrefab> sidePrefabs;
};
/**
* RoadGraph container and lightweight utility layer for a terrain's road
* network.
*
* The graph is an indexed edge list: nodes are stored in @c nodes and edges
* in @c edges. Edges reference nodes by stable ID, so the graph supports
* deletion and reordering without invalidating references. The helper
* methods below are intentionally simple; they are meant to be used by the
* road editor UI and by RoadSystem during geometry generation.
*/
struct RoadGraph {
/** Global configuration for every road in this graph. */
RoadConfig config;
/** All road nodes in the graph. Ordering is not significant. */
std::vector<RoadNode> nodes;
/** All road edges in the graph. Ordering is not significant. */
std::vector<RoadEdge> edges;
/**
* Monotonic version counter incremented by every mutating operation.
*
* Systems that cache derived data (visual aids, geometry, colliders) can
* compare this value to detect changes without deep-copying the graph.
*/
uint64_t version = 0;
/** Increment @c version. Called by all editing helpers. */
void bumpVersion()
{
++version;
}
/**
* Find a node by its stable ID.
*
* @return pointer to the node, or nullptr if no node with @c id exists.
*/
const RoadNode *findNodeById(int id) const
{
for (const auto &n : nodes)
if (n.id == id)
return &n;
return nullptr;
}
/**
* Find the vector index of a node by its stable ID.
*
* @return index into @c nodes, or -1 if not found.
*/
int findNodeIndexById(int id) const
{
for (size_t i = 0; i < nodes.size(); ++i)
if (nodes[i].id == id)
return (int)i;
return -1;
}
/**
* Return the indices of all edges connected to @c nodeId.
*
* The returned indices are stable only until the edge vector is
* modified.
*/
std::vector<size_t> findEdgeIndicesForNode(int nodeId) const
{
std::vector<size_t> result;
for (size_t i = 0; i < edges.size(); ++i) {
if (edges[i].nodeA == nodeId ||
edges[i].nodeB == nodeId)
result.push_back(i);
}
return result;
}
/**
* Return the IDs of all neighbor nodes connected to @c nodeId.
*
* Neighbors are returned in insertion order, not angle-sorted order.
* Callers that need angular ordering (e.g. wedge generation) must sort
* the returned IDs themselves.
*/
std::vector<int> getNeighborIds(int nodeId) const
{
std::vector<int> result;
for (const auto &e : edges) {
if (e.nodeA == nodeId)
result.push_back(e.nodeB);
else if (e.nodeB == nodeId)
result.push_back(e.nodeA);
}
return result;
}
/**
* Determine whether @c nodeId is an endpoint (degree == 1).
*/
bool isEndpoint(int nodeId) const
{
int degree = 0;
for (const auto &e : edges) {
if (e.nodeA == nodeId || e.nodeB == nodeId) {
++degree;
if (degree > 1)
return false;
}
}
return degree == 1;
}
/**
* Compute the next stable node ID.
*
* Returns one greater than the highest existing ID, or 1 if the graph
* contains no nodes. The returned value is guaranteed not to collide
* with any existing node.
*/
int getNextNodeId() const
{
int maxId = 0;
for (const auto &n : nodes)
if (n.id > maxId)
maxId = n.id;
return maxId + 1;
}
/**
* Resolve the effective lane counts for a directed traversal of @c edge.
*
* @param edge The edge to evaluate.
* @param fromNodeId The node we are traveling away from.
* @param outLanesFrom Receives the number of lanes on the side of the
* road that is outbound from @c fromNodeId.
* @param outLanesTo Receives the number of lanes on the side of the
* road that is inbound toward @c fromNodeId.
*
* The legacy lanesPerDirectionOverride field is honored only when both
* per-direction fields are zero.
*/
void resolveLaneCounts(const RoadEdge &edge, int fromNodeId,
int &outLanesFrom, int &outLanesTo) const
{
int la = edge.lanesAtoB;
int lb = edge.lanesBtoA;
if (la == 0 && lb == 0 && edge.lanesPerDirectionOverride > 0) {
la = edge.lanesPerDirectionOverride;
lb = edge.lanesPerDirectionOverride;
}
if (la == 0)
la = config.lanesPerDirection;
if (lb == 0)
lb = config.lanesPerDirection;
if (fromNodeId == edge.nodeA) {
outLanesFrom = la;
outLanesTo = lb;
} else {
outLanesFrom = lb;
outLanesTo = la;
}
}
/**
* Validate the graph and report the first problem found.
*
* @param error If non-null and the graph is invalid, receives a human
* readable description of the problem.
* @return true if the graph is structurally consistent.
*
* Checks performed:
* - Edge endpoints reference existing node IDs.
* - Node IDs are unique.
* - No edge connects a node to itself.
*/
/**
* Non-const node lookup for editing operations.
*
* @return pointer to the node, or nullptr if not found.
*/
RoadNode *findNodeById(int id)
{
for (auto &n : nodes)
if (n.id == id)
return &n;
return nullptr;
}
/**
* Find the index of an undirected edge connecting @c nodeA and @c nodeB.
*
* @return index into @c edges, or -1 if no such edge exists.
*/
int findEdgeIndex(int nodeA, int nodeB) const
{
for (size_t i = 0; i < edges.size(); ++i) {
if ((edges[i].nodeA == nodeA && edges[i].nodeB == nodeB) ||
(edges[i].nodeA == nodeB && edges[i].nodeB == nodeA))
return (int)i;
}
return -1;
}
/**
* Return true if an edge (in either direction) connects the two nodes.
*/
bool hasEdge(int nodeA, int nodeB) const
{
return findEdgeIndex(nodeA, nodeB) >= 0;
}
/**
* Add a new node to the graph.
*
* @param position World-space position. The caller is responsible
* for snapping X/Z to the desired location and Y to
* terrain_height + verticalOffset.
* @param verticalOffset Offset above the terrain surface.
* @return the stable ID assigned to the new node.
*/
int addNode(const Ogre::Vector3 &position, float verticalOffset = 0.0f)
{
RoadNode node;
node.id = getNextNodeId();
node.position = position;
node.verticalOffset = verticalOffset;
nodes.push_back(node);
bumpVersion();
return node.id;
}
/**
* Remove a node and all edges connected to it.
*
* @return true if the node existed and was removed.
*/
bool removeNode(int nodeId)
{
bool removedAny = false;
for (auto it = edges.begin(); it != edges.end();) {
if (it->nodeA == nodeId || it->nodeB == nodeId) {
it = edges.erase(it);
removedAny = true;
} else {
++it;
}
}
for (auto it = nodes.begin(); it != nodes.end(); ++it) {
if (it->id == nodeId) {
nodes.erase(it);
bumpVersion();
return true;
}
}
return removedAny;
}
/**
* Add an undirected edge between two existing nodes.
*
* @return index of the new edge, or -1 if the edge would be invalid
* (missing node, self-edge, or duplicate).
*/
int addEdge(int nodeA, int nodeB)
{
if (nodeA == nodeB)
return -1;
if (findNodeById(nodeA) == nullptr || findNodeById(nodeB) == nullptr)
return -1;
if (hasEdge(nodeA, nodeB))
return -1;
RoadEdge edge;
edge.nodeA = nodeA;
edge.nodeB = nodeB;
edges.push_back(edge);
bumpVersion();
return (int)edges.size() - 1;
}
/**
* Remove the edge at @c edgeIndex.
*
* @return true if the index was valid and the edge was removed.
*/
bool removeEdge(size_t edgeIndex)
{
if (edgeIndex >= edges.size())
return false;
edges.erase(edges.begin() + edgeIndex);
bumpVersion();
return true;
}
/**
* Split an edge at a normalized position and insert a new node.
*
* The new node is placed at lerp(nodeA.position, nodeB.position, t) with
* verticalOffset chosen so that its Y matches the interpolated road
* surface height along the edge. The original edge is replaced by two
* edges connecting the new node to the original endpoints.
*
* @param edgeIndex Index of the edge to split.
* @param t Normalized position along the edge (0..1).
* @return ID of the newly created node, or -1 on failure.
*/
int splitEdge(size_t edgeIndex, float t)
{
if (edgeIndex >= edges.size())
return -1;
const RoadEdge &oldEdge = edges[edgeIndex];
const RoadNode *na = findNodeById(oldEdge.nodeA);
const RoadNode *nb = findNodeById(oldEdge.nodeB);
if (!na || !nb)
return -1;
float clampedT = std::max(0.0f, std::min(1.0f, t));
Ogre::Vector3 rawPos = na->position +
(nb->position - na->position) * clampedT;
/* M5.7: snap the split point to an integer number of
* ROAD_MIN_EDGE_LENGTH units from nodeA along the edge so both
* resulting half-edges have integer lengths. If the total edge
* length is less than 2*ROAD_MIN_EDGE_LENGTH we snap to the
* midpoint and let the geometry system's UV scaling compensate. */
Ogre::Vector3 dir = nb->position - na->position;
dir.y = 0.0f;
float edgeLen = dir.length();
Ogre::Vector3 newPos;
if (edgeLen >= 2.0f * ROAD_MIN_EDGE_LENGTH)
newPos = snapToIntegerLength(na->position, rawPos);
else
newPos = na->position + dir * 0.5f;
float actualT = (edgeLen > 0.001f) ?
(newPos - na->position).length() / edgeLen :
clampedT;
float newYOffset = na->verticalOffset +
(nb->verticalOffset - na->verticalOffset) *
actualT +
oldEdge.roadLevelA +
(oldEdge.roadLevelB - oldEdge.roadLevelA) *
actualT;
/* roadLevel values are relative to the node Y, so the new node's
* verticalOffset must keep the road surface continuous. Subtract
* the terrain height at the new XZ if it is known; otherwise keep
* the interpolated offset and let the editor re-snap it. */
int newId = addNode(newPos, newYOffset);
RoadEdge edgeA = oldEdge;
edgeA.nodeB = newId;
edgeA.roadLevelB = 0.0f;
RoadEdge edgeB = oldEdge;
edgeB.nodeA = newId;
edgeB.roadLevelA = 0.0f;
edges.erase(edges.begin() + edgeIndex);
edges.push_back(edgeA);
edges.push_back(edgeB);
bumpVersion();
return newId;
}
/**
* Create an edge between two existing nodes if one does not already
* exist (M5.7).
*
* If the horizontal distance between the two nodes is less than
* ROAD_MIN_EDGE_LENGTH a warning is logged via Ogre::LogManager
* but the edge is still created the geometry system's UV scaling
* handles fractional and sub-unit lengths.
*
* @return index of the edge, or -1 on failure.
*/
int joinNodes(int nodeA, int nodeB)
{
int existing = findEdgeIndex(nodeA, nodeB);
if (existing >= 0)
return existing;
const RoadNode *na = findNodeById(nodeA);
const RoadNode *nb = findNodeById(nodeB);
if (na && nb) {
float dx = nb->position.x - na->position.x;
float dz = nb->position.z - na->position.z;
float dist = std::sqrt(dx * dx + dz * dz);
if (dist < ROAD_MIN_EDGE_LENGTH)
Ogre::LogManager::getSingleton().logMessage(
"RoadGraph: edge between nodes " +
std::to_string(nodeA) + " and " +
std::to_string(nodeB) +
" is " + std::to_string(dist) +
" units (< " +
std::to_string(ROAD_MIN_EDGE_LENGTH) +
"); UV scaling will compensate");
}
return addEdge(nodeA, nodeB);
}
/**
* Check whether the straight-line edge between two world positions crosses
* a terrain page boundary without a node at the crossing.
*
* Terrain pages are axis-aligned squares of side @c worldSize. An edge
* is valid only if both endpoints lie inside the same page.
*
* @param a Start position in world space.
* @param b End position in world space.
* @param worldSize Length of one terrain page in world units.
* @return true if the edge stays within a single page.
*/
static bool edgeStaysWithinOnePage(const Ogre::Vector3 &a,
const Ogre::Vector3 &b,
float worldSize)
{
if (worldSize <= 0.0f)
return true;
long pageAx = (long)std::floor(a.x / worldSize);
long pageAz = (long)std::floor(a.z / worldSize);
long pageBx = (long)std::floor(b.x / worldSize);
long pageBz = (long)std::floor(b.z / worldSize);
return pageAx == pageBx && pageAz == pageBz;
}
/**
* Validate the graph and report the first problem found.
*
* @param error If non-null and the graph is invalid, receives a human
* readable description of the problem.
* @return true if the graph is structurally consistent.
*
* Checks performed:
* - Edge endpoints reference existing node IDs.
* - Node IDs are unique.
* - No edge connects a node to itself.
* - No edge is shorter than ROAD_MIN_EDGE_LENGTH (M5.7).
* - No wedge is sharper than 30 degrees or wider than 270 degrees
* (M5.5 angle constraint; editor operations reject such wedges,
* this catches hand-edited or legacy scenes).
*/
bool validate(std::string *error = nullptr) const;
/**
* Snap a world-space position to an integer number of
* ROAD_MIN_EDGE_LENGTH units from @c anchor along the horizontal
* line connecting them (M5.7).
*
* If the distance is less than ROAD_MIN_EDGE_LENGTH the position is
* returned unchanged. Only the X and Z components are adjusted;
* Y is left as-is (the caller should snap Y to terrain separately).
*/
static Ogre::Vector3
snapToIntegerLength(const Ogre::Vector3 &anchor,
const Ogre::Vector3 &pos);
};
/**
* One half of an edge as seen from one of its endpoint nodes (M5.5).
*
* A half-edge starts at @c nodeId and ends at the midpoint of the edge
* connecting @c nodeId and @c neighborId. Geometry generation sweeps the
* road mesh template along half-edges; wedge generation pairs two half-edges
* that share the same seed node.
*/
struct RoadHalfEdge {
/** Index into RoadGraph::edges for the edge this half belongs to. */
int edgeIndex = -1;
/** Stable ID of the seed node this half-edge starts at. */
int nodeId = -1;
/** Stable ID of the node at the other end of the edge. */
int neighborId = -1;
/**
* Normalized horizontal (XZ) direction from the seed node toward the
* neighbor. Y is always 0; vertical placement is handled separately
* through the roadLevel fields.
*/
Ogre::Vector3 direction = Ogre::Vector3::UNIT_X;
/**
* Angle of @c direction around the world Y axis, atan2(z, x).
* Used as the sort key for wedge enumeration.
*/
float angleRad = 0.0f;
/**
* Horizontal distance from the seed node to the edge midpoint the
* length of this half-edge in world units.
*/
float halfLength = 0.0f;
/** Number of lanes traveling away from the seed node. */
int lanesOut = 0;
/** Number of lanes traveling toward the seed node. */
int lanesIn = 0;
/**
* Road surface height offset at the seed-node end of the edge
* (RoadEdge::roadLevelA/B mapped to this half-edge's orientation).
*/
float roadLevelAtNode = 0.0f;
/** Road surface height offset at the neighbor end of the edge. */
float roadLevelAtNeighbor = 0.0f;
};
/**
* A V-shaped region at a road node bounded by two consecutive (in
* angle-sorted order) half-edges (M5.5).
*
* Wedges with @c degenerate set (swept angle near 360 degrees, i.e.
* both half-edges pointing in the same direction) must be skipped.
*/
struct RoadWedge {
/** Stable ID of the seed node both half-edges start at. */
int nodeId = -1;
/** First bounding half-edge (lower sort angle). */
RoadHalfEdge first;
/** Second bounding half-edge (next in angle-sorted order). */
RoadHalfEdge second;
/**
* Counter-clockwise swept angle from @c first to @c second around the
* seed node, in degrees, in the range (0, 360].
*/
float sweptAngleDeg = 0.0f;
/** true when @c sweptAngleDeg is near 360 (wedge angle ~ 0). */
bool degenerate = false;
};
/**
* Geometry primitive for a node with exactly one connection (M5.5): a
* straight road segment along the single half-edge.
*/
struct RoadStraightSegment {
/** Stable ID of the endpoint node. */
int nodeId = -1;
/** The node's single half-edge. */
RoadHalfEdge halfEdge;
};
/** Minimum wedge swept angle; sharper wedges are rejected (M5.5). */
static const float ROAD_WEDGE_MIN_ANGLE_DEG = 30.0f;
/** Maximum wedge swept angle; only near-360 deg wedges are degenerate. */
static const float ROAD_WEDGE_MAX_ANGLE_DEG = 359.9f;
/**
* Enumerate all wedges and straight segments of a road graph (M5.5).
*
* For every node the connected half-edges are sorted by angle around the
* world Y axis; each consecutive pair (with wrap-around) yields one wedge.
* Nodes with exactly one connection yield one straight segment instead.
* Zero-length edges (endpoints sharing the same XZ) are skipped.
*
* This is a pure function of the graph data no scene or terrain state is
* required so it can be exercised by headless tests.
*/
inline void enumerateWedges(const RoadGraph &graph,
std::vector<RoadWedge> &outWedges,
std::vector<RoadStraightSegment> &outSegments)
{
outWedges.clear();
outSegments.clear();
for (const auto &node : graph.nodes) {
std::vector<RoadHalfEdge> halfEdges;
for (size_t ei = 0; ei < graph.edges.size(); ++ei) {
const RoadEdge &e = graph.edges[ei];
int neighborId = -1;
if (e.nodeA == node.id)
neighborId = e.nodeB;
else if (e.nodeB == node.id)
neighborId = e.nodeA;
else
continue;
const RoadNode *neighbor = graph.findNodeById(neighborId);
if (!neighbor)
continue;
Ogre::Vector3 dir = neighbor->position - node.position;
dir.y = 0.0f;
float fullLength = dir.normalise();
if (fullLength < 1e-4f)
continue;
RoadHalfEdge he;
he.edgeIndex = (int)ei;
he.nodeId = node.id;
he.neighborId = neighborId;
he.direction = dir;
he.angleRad = std::atan2(dir.z, dir.x);
he.halfLength = fullLength * 0.5f;
graph.resolveLaneCounts(e, node.id, he.lanesOut,
he.lanesIn);
if (e.nodeA == node.id) {
he.roadLevelAtNode = e.roadLevelA;
he.roadLevelAtNeighbor = e.roadLevelB;
} else {
he.roadLevelAtNode = e.roadLevelB;
he.roadLevelAtNeighbor = e.roadLevelA;
}
halfEdges.push_back(he);
}
if (halfEdges.empty())
continue;
std::sort(halfEdges.begin(), halfEdges.end(),
[](const RoadHalfEdge &a, const RoadHalfEdge &b) {
return a.angleRad < b.angleRad;
});
if (halfEdges.size() == 1) {
RoadStraightSegment seg;
seg.nodeId = node.id;
seg.halfEdge = halfEdges[0];
outSegments.push_back(seg);
continue;
}
for (size_t i = 0; i < halfEdges.size(); ++i) {
const RoadHalfEdge &h1 = halfEdges[i];
const RoadHalfEdge &h2 =
halfEdges[(i + 1) % halfEdges.size()];
float swept = h2.angleRad - h1.angleRad;
if (swept <= 0.0f)
swept += (float)(2.0 * M_PI);
RoadWedge w;
w.nodeId = node.id;
w.first = h1;
w.second = h2;
w.sweptAngleDeg =
swept * (180.0f / (float)M_PI);
w.degenerate =
w.sweptAngleDeg > ROAD_WEDGE_MAX_ANGLE_DEG;
outWedges.push_back(w);
}
}
}
inline Ogre::Vector3
RoadGraph::snapToIntegerLength(const Ogre::Vector3 &anchor,
const Ogre::Vector3 &pos)
{
float dx = pos.x - anchor.x;
float dz = pos.z - anchor.z;
float dist = std::sqrt(dx * dx + dz * dz);
if (dist < ROAD_MIN_EDGE_LENGTH)
return pos;
/* Round to the nearest integer multiple of ROAD_MIN_EDGE_LENGTH. */
float snappedDist =
std::round(dist / ROAD_MIN_EDGE_LENGTH) * ROAD_MIN_EDGE_LENGTH;
if (snappedDist < ROAD_MIN_EDGE_LENGTH)
snappedDist = ROAD_MIN_EDGE_LENGTH;
float scale = snappedDist / dist;
Ogre::Vector3 result = pos;
result.x = anchor.x + dx * scale;
result.z = anchor.z + dz * scale;
return result;
}
inline bool RoadGraph::validate(std::string *error) const
{
for (const auto &e : edges) {
if (findNodeById(e.nodeA) == nullptr) {
if (error)
*error =
"Edge references missing node A: " +
std::to_string(e.nodeA);
return false;
}
if (findNodeById(e.nodeB) == nullptr) {
if (error)
*error =
"Edge references missing node B: " +
std::to_string(e.nodeB);
return false;
}
if (e.nodeA == e.nodeB) {
if (error)
*error =
"Edge connects node to itself: " +
std::to_string(e.nodeA);
return false;
}
/* M5.7: check minimum edge length. */
const RoadNode *na = findNodeById(e.nodeA);
const RoadNode *nb = findNodeById(e.nodeB);
if (na && nb) {
float dx = nb->position.x - na->position.x;
float dz = nb->position.z - na->position.z;
float dist = std::sqrt(dx * dx + dz * dz);
if (dist < ROAD_MIN_EDGE_LENGTH) {
if (error)
*error =
"Edge between nodes " +
std::to_string(e.nodeA) +
" and " +
std::to_string(e.nodeB) +
" is too short (" +
std::to_string(dist) +
" < " +
std::to_string(
ROAD_MIN_EDGE_LENGTH) +
")";
return false;
}
}
}
for (size_t i = 0; i < nodes.size(); ++i) {
for (size_t j = i + 1; j < nodes.size(); ++j) {
if (nodes[i].id == nodes[j].id) {
if (error)
*error = "Duplicate node ID: " +
std::to_string(
nodes[i].id);
return false;
}
}
}
/* Wedge angle sanity (M5.5): wedges that are too sharp or too wide
* cannot be generated cleanly. Editor operations reject them, so a
* failure here means the scene was hand-edited or comes from an older
* build. */
std::vector<RoadWedge> wedges;
std::vector<RoadStraightSegment> segments;
enumerateWedges(*this, wedges, segments);
for (const auto &w : wedges) {
if (w.sweptAngleDeg < ROAD_WEDGE_MIN_ANGLE_DEG) {
if (error)
*error = "Sharp wedge at node " +
std::to_string(w.nodeId) + " (" +
std::to_string(w.sweptAngleDeg) +
" deg < 30)";
return false;
}
if (w.degenerate) {
if (error)
*error = "Near-zero-angle wedge at node " +
std::to_string(w.nodeId) + " (" +
std::to_string(w.sweptAngleDeg) +
" deg ~ 360)";
return false;
}
}
return true;
}
#endif // EDITSCENE_ROADGRAPH_HPP
@@ -0,0 +1,95 @@
#ifndef EDITSCENE_TERRAIN_HPP
#define EDITSCENE_TERRAIN_HPP
#pragma once
#include "RoadGraph.hpp"
#include <Ogre.h>
#include <string>
#include <vector>
#include <cstdint>
/**
* Terrain component singleton ECS component holding serializable
* parameters for the Ogre::Terrain-based terrain feature.
*
* Only one entity per scene should carry this component. All runtime
* Ogre/Jolt objects live in TerrainSystem, not here.
*/
struct TerrainComponent {
bool enabled = true;
// Page/tile vertex resolution. Must be 2^n+1 (e.g. 33, 65, 129, 257).
int terrainSize = 65;
// Stable unique ID — generated once at component creation, serialized,
// never changed. Used as the directory key for binary terrain data.
uint64_t terrainId = 0;
// Base binary heightmap resolution. Default 256x256.
int heightmapSize = 256;
// Blend-map texture resolution. Default 256x256; independent of heightmapSize.
int blendMapSize = 256;
// World-space size of one Ogre terrain page in units.
float worldSize = 2000.0f;
// Maximum geometric error in pixels before a LOD tile splits.
float maxPixelError = 1.0f;
// Distance at which the cheap composite map is used.
float compositeMapDistance = 300.0f;
// Minimum and maximum batch LOD sizes. Must be 2^n+1 and <= terrainSize.
int minBatchSize = 17;
int maxBatchSize = 65;
// Base heightmap binary file path.
std::string heightmapFile = "heightmap.bin";
// Layer texture settings (diffuse+normal pairs).
struct Layer {
std::string name;
std::string diffuseTexture;
std::string normalTexture;
float worldSize = 100.0f;
};
std::vector<Layer> layers;
// Road network data model: configuration, nodes, edges, and side prefabs.
// See RoadGraph.hpp for detailed field documentation.
RoadGraph roadGraph;
// Procedural detail noise layered on top of the base heightmap.
// Not baked into heightmap.bin; evaluated on demand.
struct DetailNoise {
bool enabled = false;
int seed = 0;
int octaves = 4;
float frequency = 0.001f;
float amplitude = 10.0f;
float lacunarity = 2.0f;
float persistence = 0.5f;
};
DetailNoise detailNoise;
// Auxiliary maps (foliage density, material masks, etc.).
struct AuxMap {
std::string name;
std::string fileName;
int resolution = 256;
float defaultValue = 0.0f;
};
std::vector<AuxMap> auxMaps;
// Runtime-only: managed by TerrainSystem. Not serialized.
bool dirty = true;
bool rebuildInProgress = false;
void markDirty()
{
dirty = true;
}
};
#endif // EDITSCENE_TERRAIN_HPP
@@ -0,0 +1,31 @@
#include "Terrain.hpp"
#include "Transform.hpp"
#include "EditorMarker.hpp"
#include "EntityName.hpp"
#include "../ui/ComponentRegistration.hpp"
#include "../ui/TerrainEditor.hpp"
#include <chrono>
REGISTER_COMPONENT_GROUP("Terrain", "Environment", TerrainComponent,
TerrainEditor)
{
registry.registerComponent<TerrainComponent>(
"Terrain", "Environment",
std::make_unique<TerrainEditor>(),
/* Adder */
[](flecs::entity e) {
if (!e.has<TerrainComponent>()) {
TerrainComponent tc;
tc.terrainId = (uint64_t)std::chrono::
system_clock::now()
.time_since_epoch()
.count();
e.set<TerrainComponent>(tc);
}
},
/* Remover */
[](flecs::entity e) {
if (e.has<TerrainComponent>())
e.remove<TerrainComponent>();
});
}
@@ -38,6 +38,11 @@ struct TriangleBufferComponent {
// Whether the buffer needs rebuilding
bool dirty = true;
// Buffer content is filled directly by the owning system (e.g. roads);
// ProceduralMeshSystem keeps the existing contents instead of rebuilding
// from Primitive children when clearing the dirty flag.
bool proceduralContent = false;
// Whether the buffer has been converted to a mesh
bool meshCreated = false;
+262
View File
@@ -0,0 +1,262 @@
#include "RoadGizmo.hpp"
#include <cmath>
// Project ray onto axis line and return the parameter t along the axis.
static bool projectRayOntoAxis(const Ogre::Ray &ray,
const Ogre::Vector3 &axisOrigin,
const Ogre::Vector3 &axisDir, float &outT)
{
Ogre::Vector3 rayOrigin = ray.getOrigin();
Ogre::Vector3 rayDir = ray.getDirection();
Ogre::Vector3 w0 = rayOrigin - axisOrigin;
float a = rayDir.dotProduct(rayDir);
float b = rayDir.dotProduct(axisDir);
float c = axisDir.dotProduct(axisDir);
float d = rayDir.dotProduct(w0);
float e = axisDir.dotProduct(w0);
float denom = a * c - b * b;
if (std::abs(denom) < 0.0001f)
return false;
outT = (a * e - b * d) / denom;
return true;
}
RoadGizmo::RoadGizmo(Ogre::SceneManager *sceneMgr)
: m_sceneMgr(sceneMgr)
{
m_gizmoNode = m_sceneMgr->getRootSceneNode()->createChildSceneNode(
"RoadGizmoNode");
m_axisX = m_sceneMgr->createManualObject("RoadGizmoAxisX");
m_axisZ = m_sceneMgr->createManualObject("RoadGizmoAxisZ");
m_gizmoNode->attachObject(m_axisX);
m_gizmoNode->attachObject(m_axisZ);
m_axisX->setRenderQueueGroup(Ogre::RENDER_QUEUE_OVERLAY);
m_axisZ->setRenderQueueGroup(Ogre::RENDER_QUEUE_OVERLAY);
m_gizmoNode->setVisible(false);
createGeometry();
}
RoadGizmo::~RoadGizmo()
{
if (m_sceneMgr)
shutdown();
}
void RoadGizmo::shutdown()
{
if (!m_sceneMgr)
return;
if (m_gizmoNode && m_axisX) {
try {
m_gizmoNode->detachObject(m_axisX);
} catch (...) {
}
}
if (m_gizmoNode && m_axisZ) {
try {
m_gizmoNode->detachObject(m_axisZ);
} catch (...) {
}
}
if (m_axisX) {
try {
m_sceneMgr->destroyManualObject(m_axisX);
} catch (...) {
}
m_axisX = nullptr;
}
if (m_axisZ) {
try {
m_sceneMgr->destroyManualObject(m_axisZ);
} catch (...) {
}
m_axisZ = nullptr;
}
if (m_gizmoNode) {
try {
m_sceneMgr->destroySceneNode(m_gizmoNode);
} catch (...) {
}
m_gizmoNode = nullptr;
}
m_sceneMgr = nullptr;
}
void RoadGizmo::createGeometry()
{
float len = m_axisLength * m_size;
float arrowSize = 0.3f * m_size;
bool xSelected = (m_selectedAxis == Axis::X || m_hoveredAxis == Axis::X);
m_axisX->clear();
m_axisX->begin("Ogre/AxisGizmo", Ogre::RenderOperation::OT_LINE_LIST);
if (xSelected)
m_axisX->colour(1.0f, 1.0f, 0.0f);
else
m_axisX->colour(1.0f, 0.0f, 0.0f);
m_axisX->position(0, 0, 0);
m_axisX->position(len, 0, 0);
m_axisX->position(len, 0, 0);
m_axisX->position(len - arrowSize, arrowSize, 0);
m_axisX->position(len, 0, 0);
m_axisX->position(len - arrowSize, -arrowSize, 0);
m_axisX->end();
bool zSelected = (m_selectedAxis == Axis::Z || m_hoveredAxis == Axis::Z);
m_axisZ->clear();
m_axisZ->begin("Ogre/AxisGizmo", Ogre::RenderOperation::OT_LINE_LIST);
if (zSelected)
m_axisZ->colour(1.0f, 1.0f, 0.0f);
else
m_axisZ->colour(0.0f, 0.0f, 1.0f);
m_axisZ->position(0, 0, 0);
m_axisZ->position(0, 0, len);
m_axisZ->position(0, 0, len);
m_axisZ->position(arrowSize, 0, len - arrowSize);
m_axisZ->position(0, 0, len);
m_axisZ->position(-arrowSize, 0, len - arrowSize);
m_axisZ->end();
}
void RoadGizmo::setPosition(const Ogre::Vector3 &position)
{
if (m_gizmoNode)
m_gizmoNode->setPosition(position);
}
Ogre::Vector3 RoadGizmo::getPosition() const
{
if (m_gizmoNode)
return m_gizmoNode->getPosition();
return Ogre::Vector3::ZERO;
}
void RoadGizmo::setVisible(bool visible)
{
m_visible = visible;
if (m_gizmoNode)
m_gizmoNode->setVisible(visible);
}
bool RoadGizmo::isVisible() const
{
return m_visible;
}
bool RoadGizmo::onMousePressed(const Ogre::Ray &mouseRay)
{
if (!m_axisX || !m_axisX->isVisible())
return false;
m_selectedAxis = hitTest(mouseRay);
if (m_selectedAxis != Axis::None) {
m_isDragging = true;
m_dragStartPosition = getPosition();
switch (m_selectedAxis) {
case Axis::X:
m_dragAxisDir = Ogre::Vector3::UNIT_X;
break;
case Axis::Z:
m_dragAxisDir = Ogre::Vector3::UNIT_Z;
break;
default:
m_dragAxisDir = Ogre::Vector3::UNIT_X;
break;
}
projectRayOntoAxis(mouseRay, m_dragStartPosition, m_dragAxisDir,
m_dragStartT);
createGeometry();
return true;
}
return false;
}
bool RoadGizmo::onMouseMoved(const Ogre::Ray &mouseRay)
{
if (m_isDragging && m_selectedAxis != Axis::None) {
float currentT;
if (!projectRayOntoAxis(mouseRay, m_dragStartPosition, m_dragAxisDir,
currentT))
return true;
float deltaT = currentT - m_dragStartT;
Ogre::Vector3 newPos = m_dragStartPosition + m_dragAxisDir * deltaT;
setPosition(newPos);
return true;
} else if (m_axisX && m_axisX->isVisible()) {
Axis prevHover = m_hoveredAxis;
m_hoveredAxis = hitTest(mouseRay);
if (prevHover != m_hoveredAxis)
createGeometry();
}
return false;
}
bool RoadGizmo::onMouseReleased()
{
if (m_isDragging) {
m_isDragging = false;
m_selectedAxis = Axis::None;
m_dragStartPosition = Ogre::Vector3::ZERO;
createGeometry();
return true;
}
return false;
}
RoadGizmo::Axis RoadGizmo::hitTest(const Ogre::Ray &mouseRay)
{
if (!m_gizmoNode || !m_axisX || !m_axisX->isVisible())
return Axis::None;
Ogre::Vector3 gizmoPos = m_gizmoNode->getPosition();
float len = m_axisLength * m_size;
float threshold = 0.5f * m_size;
float bestDist = 1000000.0f;
Axis bestAxis = Axis::None;
for (int i = 0; i < 2; ++i) {
Ogre::Vector3 axisDir = (i == 0) ?
Ogre::Vector3::UNIT_X :
Ogre::Vector3::UNIT_Z;
for (int j = 0; j <= 8; ++j) {
float t = len * j / 8.0f;
Ogre::Vector3 pointOnAxis = gizmoPos + axisDir * t;
Ogre::Vector3 L = pointOnAxis - mouseRay.getOrigin();
float tca = L.dotProduct(mouseRay.getDirection());
if (tca < 0.01f)
continue;
float d2 = L.dotProduct(L) - tca * tca;
if (d2 <= threshold * threshold) {
if (tca < bestDist) {
bestDist = tca;
bestAxis = (i == 0) ? Axis::X : Axis::Z;
}
}
}
}
return bestAxis;
}
@@ -0,0 +1,92 @@
#ifndef EDITSCENE_ROADGIZMO_HPP
#define EDITSCENE_ROADGIZMO_HPP
#pragma once
#include <Ogre.h>
#include <OgreManualObject.h>
/**
* Simple 2-axis (X/Z) translation gizmo used by the road editor.
*
* Unlike the general entity transform gizmo, RoadGizmo is designed to sit at a
* road node position and drag the node along the horizontal plane. It is
* self-contained and does not require a Flecs entity.
*/
class RoadGizmo {
public:
enum class Axis {
None,
X,
Z
};
RoadGizmo(Ogre::SceneManager *sceneMgr);
~RoadGizmo();
/**
* Shutdown and cleanup - must be called before SceneManager destruction.
*/
void shutdown();
/**
* Move the gizmo to a world-space position.
*/
void setPosition(const Ogre::Vector3 &position);
/**
* Get the current world-space position (valid while dragging).
*/
Ogre::Vector3 getPosition() const;
/**
* Show or hide the gizmo geometry.
*/
void setVisible(bool visible);
bool isVisible() const;
/**
* Mouse interaction. Returns true when the gizmo consumed the event.
*/
bool onMousePressed(const Ogre::Ray &mouseRay);
bool onMouseMoved(const Ogre::Ray &mouseRay);
bool onMouseReleased();
/**
* Current drag state.
*/
bool isDragging() const { return m_isDragging; }
Axis getSelectedAxis() const { return m_selectedAxis; }
/**
* Set gizmo size/scale.
*/
void setSize(float size)
{
m_size = size;
createGeometry();
}
float getSize() const { return m_size; }
private:
void createGeometry();
Axis hitTest(const Ogre::Ray &mouseRay);
Ogre::SceneManager *m_sceneMgr;
Ogre::SceneNode *m_gizmoNode = nullptr;
Ogre::ManualObject *m_axisX = nullptr;
Ogre::ManualObject *m_axisZ = nullptr;
float m_size = 1.0f;
float m_axisLength = 2.0f;
bool m_visible = false;
Axis m_selectedAxis = Axis::None;
Axis m_hoveredAxis = Axis::None;
bool m_isDragging = false;
Ogre::Vector3 m_dragStartPosition = Ogre::Vector3::ZERO;
Ogre::Vector3 m_dragAxisDir = Ogre::Vector3::UNIT_X;
float m_dragStartT = 0.0f;
};
#endif // EDITSCENE_ROADGIZMO_HPP
@@ -0,0 +1,79 @@
-- =============================================================================
-- Animation Tree Registry Lua API Examples
-- =============================================================================
-- Animation trees are stored in the Animation Tree Registry
-- (animation_tree.json). They are referenced from entities by name and edited
-- in the registry editor (Tools -> Animation Tree Registry).
--
-- The Lua API lets you create, query and remove registry entries.
-- =============================================================================
-- Register a simple humanoid locomotion tree.
-- skeletonSource is optional; when set, the registry editor loads that mesh
-- from the Characters resource group to populate animation-name dropdowns.
ecs.animation_trees.register("humanoid", {
skeletonSource = "male_Face.mesh",
root = {
type = "output",
speed = 1.0,
children = {
{
type = "stateMachine",
name = "main",
fadeSpeed = 7.5,
children = {
{
type = "state",
name = "idle",
children = {
{ type = "animation", animationName = "idle" }
}
},
{
type = "state",
name = "walk",
children = {
{ type = "animation", animationName = "walk" }
}
},
{
type = "state",
name = "run",
children = {
{ type = "animation", animationName = "run" }
}
}
},
endTransitions = {
-- auto-transition when the source state's animation ends
idle = "walk"
}
}
}
}
})
-- List all registered trees
local trees = ecs.animation_trees.list()
for _, name in ipairs(trees) do
print("Registered animation tree: " .. name)
end
-- Query a tree definition
local def = ecs.animation_trees.find("humanoid")
if def then
print("Tree: " .. def.name)
print("Skeleton source: " .. def.skeletonSource)
print("Root type: " .. def.root.type)
end
-- Attach the tree to an entity
local entity = ecs.create_entity()
ecs.set_component(entity, "AnimationTree", {
treeName = "humanoid",
enabled = true,
useRootMotion = false
})
-- Persist the registry to disk
ecs.animation_trees.save()
@@ -551,15 +551,44 @@ local chair = create_furniture("Wooden Chair", "chair.mesh", "seating")
-- Working with Animation Components
-- =============================================================================
-- Animation trees are authored in the Animation Tree Registry
-- (Tools -> Animation Tree Registry). Entities reference a registry tree by
-- name. The registry can also be manipulated from Lua:
ecs.animation_trees.register("humanoid", {
skeletonSource = "male_Face.mesh",
root = {
type = "output",
children = {
{
type = "stateMachine",
name = "main",
fadeSpeed = 7.5,
children = {
{
type = "state",
name = "idle",
children = {
{ type = "animation", animationName = "idle" }
}
},
{
type = "state",
name = "walk",
children = {
{ type = "animation", animationName = "walk" }
}
}
}
}
}
}
})
-- Add animation tree to a character:
ecs.set_component(npc, "AnimationTree", {
treeName = "humanoid",
enabled = true
})
ecs.set_component(npc, "AnimationTreeTemplate", {
templateName = "humanoid_base",
blendTime = 0.2
enabled = true,
useRootMotion = false
})
-- =============================================================================
@@ -0,0 +1,217 @@
#include "LuaAnimationTreeApi.hpp"
#include "../systems/AnimationTreeRegistry.hpp"
#include "../components/AnimationTree.hpp"
#include <lua.hpp>
namespace editScene
{
static void pushAnimationTreeNode(lua_State *L,
const AnimationTreeNode &node);
static bool readAnimationTreeNode(lua_State *L, int idx,
AnimationTreeNode &node);
static void pushAnimationTreeNode(lua_State *L,
const AnimationTreeNode &node)
{
lua_newtable(L);
lua_pushstring(L, node.type.c_str());
lua_setfield(L, -2, "type");
if (!node.name.empty()) {
lua_pushstring(L, node.name.c_str());
lua_setfield(L, -2, "name");
}
if (!node.animationName.empty()) {
lua_pushstring(L, node.animationName.c_str());
lua_setfield(L, -2, "animationName");
}
if (node.speed != 1.0f) {
lua_pushnumber(L, node.speed);
lua_setfield(L, -2, "speed");
}
if (node.fadeSpeed != 7.5f) {
lua_pushnumber(L, node.fadeSpeed);
lua_setfield(L, -2, "fadeSpeed");
}
if (!node.endTransitions.empty()) {
lua_newtable(L);
for (const auto &pair : node.endTransitions) {
lua_pushstring(L, pair.second.c_str());
lua_setfield(L, -2, pair.first.c_str());
}
lua_setfield(L, -2, "endTransitions");
}
if (!node.children.empty()) {
lua_newtable(L);
for (size_t i = 0; i < node.children.size(); ++i) {
pushAnimationTreeNode(L, node.children[i]);
lua_rawseti(L, -2, (int)i + 1);
}
lua_setfield(L, -2, "children");
}
}
static bool readAnimationTreeNode(lua_State *L, int idx,
AnimationTreeNode &node)
{
if (!lua_istable(L, idx))
return false;
node.type = "animation";
if (lua_getfield(L, idx, "type"), lua_isstring(L, -1))
node.type = lua_tostring(L, -1);
lua_pop(L, 1);
node.name = "";
if (lua_getfield(L, idx, "name"), lua_isstring(L, -1))
node.name = lua_tostring(L, -1);
lua_pop(L, 1);
node.animationName = "";
if (lua_getfield(L, idx, "animationName"), lua_isstring(L, -1))
node.animationName = lua_tostring(L, -1);
lua_pop(L, 1);
node.speed = 1.0f;
if (lua_getfield(L, idx, "speed"), lua_isnumber(L, -1))
node.speed = (float)lua_tonumber(L, -1);
lua_pop(L, 1);
node.fadeSpeed = 7.5f;
if (lua_getfield(L, idx, "fadeSpeed"), lua_isnumber(L, -1))
node.fadeSpeed = (float)lua_tonumber(L, -1);
lua_pop(L, 1);
node.endTransitions.clear();
if (lua_getfield(L, idx, "endTransitions"), lua_istable(L, -1)) {
lua_pushnil(L);
while (lua_next(L, -2) != 0) {
if (lua_isstring(L, -2) && lua_isstring(L, -1)) {
node.endTransitions[lua_tostring(L, -2)] =
lua_tostring(L, -1);
}
lua_pop(L, 1);
}
}
lua_pop(L, 1);
node.children.clear();
if (lua_getfield(L, idx, "children"), lua_istable(L, -1)) {
int childIdx = 1;
while (true) {
lua_rawgeti(L, -1, childIdx++);
if (lua_isnil(L, -1)) {
lua_pop(L, 1);
break;
}
AnimationTreeNode child;
if (readAnimationTreeNode(L, lua_gettop(L), child))
node.children.push_back(child);
lua_pop(L, 1);
}
}
lua_pop(L, 1);
return true;
}
static int luaAnimationTreeRegister(lua_State *L)
{
if (lua_gettop(L) < 2 || !lua_isstring(L, 1) || !lua_istable(L, 2))
return 0;
Ogre::String name = lua_tostring(L, 1);
AnimationTreeRegistry::AnimationTreeDefinition def;
def.name = name;
if (lua_getfield(L, 2, "skeletonSource"), lua_isstring(L, -1))
def.skeletonSource = lua_tostring(L, -1);
lua_pop(L, 1);
if (lua_getfield(L, 2, "root"), lua_istable(L, -1))
readAnimationTreeNode(L, lua_gettop(L), def.root);
lua_pop(L, 1);
AnimationTreeRegistry::getSingleton().registerTree(def);
return 0;
}
static int luaAnimationTreeFind(lua_State *L)
{
if (lua_gettop(L) < 1 || !lua_isstring(L, 1))
return 0;
Ogre::String name = lua_tostring(L, 1);
const AnimationTreeRegistry::AnimationTreeDefinition *def =
AnimationTreeRegistry::getSingleton().findTree(name);
if (!def)
return 0;
lua_newtable(L);
lua_pushstring(L, def->name.c_str());
lua_setfield(L, -2, "name");
lua_pushstring(L, def->skeletonSource.c_str());
lua_setfield(L, -2, "skeletonSource");
pushAnimationTreeNode(L, def->root);
lua_setfield(L, -2, "root");
return 1;
}
static int luaAnimationTreeList(lua_State *L)
{
lua_newtable(L);
std::vector<Ogre::String> names =
AnimationTreeRegistry::getSingleton().getTreeNames();
std::sort(names.begin(), names.end());
for (size_t i = 0; i < names.size(); ++i) {
lua_pushstring(L, names[i].c_str());
lua_rawseti(L, -2, (int)i + 1);
}
return 1;
}
static int luaAnimationTreeRemove(lua_State *L)
{
if (lua_gettop(L) < 1 || !lua_isstring(L, 1))
return 0;
AnimationTreeRegistry::getSingleton().removeTree(lua_tostring(L, 1));
return 0;
}
static int luaAnimationTreeSave(lua_State *L)
{
(void)L;
bool ok = AnimationTreeRegistry::getSingleton().saveToFile(
AnimationTreeRegistry::defaultPath());
lua_pushboolean(L, ok ? 1 : 0);
return 1;
}
static int luaAnimationTreeLoad(lua_State *L)
{
(void)L;
bool ok = AnimationTreeRegistry::getSingleton().loadFromFile(
AnimationTreeRegistry::defaultPath());
lua_pushboolean(L, ok ? 1 : 0);
return 1;
}
void registerLuaAnimationTreeApi(lua_State *L)
{
lua_newtable(L);
lua_pushcfunction(L, luaAnimationTreeRegister);
lua_setfield(L, -2, "register");
lua_pushcfunction(L, luaAnimationTreeFind);
lua_setfield(L, -2, "find");
lua_pushcfunction(L, luaAnimationTreeList);
lua_setfield(L, -2, "list");
lua_pushcfunction(L, luaAnimationTreeRemove);
lua_setfield(L, -2, "remove");
lua_pushcfunction(L, luaAnimationTreeSave);
lua_setfield(L, -2, "save");
lua_pushcfunction(L, luaAnimationTreeLoad);
lua_setfield(L, -2, "load");
lua_setfield(L, -2, "animation_trees");
}
} // namespace editScene
@@ -0,0 +1,14 @@
#ifndef EDITSCENE_LUA_ANIMATIONTREE_API_HPP
#define EDITSCENE_LUA_ANIMATIONTREE_API_HPP
#pragma once
#include <lua.hpp>
namespace editScene
{
void registerLuaAnimationTreeApi(lua_State *L);
} // namespace editScene
#endif // EDITSCENE_LUA_ANIMATIONTREE_API_HPP
+7 -17
View File
@@ -37,7 +37,6 @@
#include "components/CharacterIdentity.hpp"
#include "systems/CharacterRegistry.hpp"
#include "components/AnimationTree.hpp"
#include "components/AnimationTreeTemplate.hpp"
#include "components/Character.hpp"
#include "components/StartupMenu.hpp"
#include "components/PlayerController.hpp"
@@ -595,31 +594,22 @@ static void registerAllComponents()
// --- AnimationTree ---
REGISTER_COMPONENT(
AnimationTreeComponent, "AnimationTree",
lua_pushstring(L, c.treeName.c_str());
lua_setfield(L, -2, "treeName");
lua_pushboolean(L, c.enabled ? 1 : 0);
lua_setfield(L, -2, "enabled");
lua_pushboolean(L, c.useRootMotion ? 1 : 0);
lua_setfield(L, -2, "useRootMotion");
lua_pushstring(L, c.templateName.c_str());
lua_setfield(L, -2, "templateName");
, if (lua_getfield(L, idx, "enabled"), lua_isboolean(L, -1))
c.enabled = lua_toboolean(L, -1) != 0;
, if (lua_getfield(L, idx, "treeName"), lua_isstring(L, -1))
c.treeName = lua_tostring(L, -1);
lua_pop(L, 1);
if (lua_getfield(L, idx, "enabled"), lua_isboolean(L, -1))
c.enabled = lua_toboolean(L, -1) != 0;
lua_pop(L, 1);
if (lua_getfield(L, idx, "useRootMotion"), lua_isboolean(L, -1))
c.useRootMotion = lua_toboolean(L, -1) != 0;
lua_pop(L, 1);
if (lua_getfield(L, idx, "templateName"), lua_isstring(L, -1))
c.templateName = lua_tostring(L, -1);
lua_pop(L, 1););
// --- AnimationTreeTemplate ---
REGISTER_COMPONENT(AnimationTreeTemplate, "AnimationTreeTemplate",
lua_pushstring(L, c.name.c_str());
lua_setfield(L, -2, "name");
, if (lua_getfield(L, idx, "name"),
lua_isstring(L, -1))
c.name = lua_tostring(L, -1);
lua_pop(L, 1););
// --- BehaviorTree ---
REGISTER_COMPONENT(
BehaviorTreeComponent, "BehaviorTree",
@@ -24,7 +24,7 @@
* Supported component names (case-sensitive):
* "EntityName", "Transform", "Renderable", "Light", "Camera",
* "RigidBody", "PhysicsCollider", "Character", "CharacterSlots",
* "AnimationTree", "AnimationTreeTemplate", "BehaviorTree",
* "AnimationTree", "BehaviorTree",
* "GoapBlackboard", "GoapAction", "GoapGoal", "ActionDatabase",
* "ActionDebug", "SmartObject", "Actuator", "EventHandler",
* "GoapPlanner", "GoapRunner", "PathFollowing", "NavMesh",
@@ -0,0 +1,408 @@
#include "LuaTerrainApi.hpp"
#include "../systems/TerrainSystem.hpp"
#include "../components/Terrain.hpp"
#include "../components/Transform.hpp"
#include <OgreLogManager.h>
#include <OgreStringConverter.h>
#include <flecs.h>
#include <cstring>
namespace editScene
{
static flecs::world getWorld(lua_State *L)
{
lua_getfield(L, LUA_REGISTRYINDEX, "EditSceneFlecsWorld");
OgreAssert(lua_islightuserdata(L, -1), "Flecs world not registered");
flecs::world *world =
static_cast<flecs::world *>(lua_touserdata(L, -1));
lua_pop(L, 1);
return *world;
}
static bool findTerrainComponent(lua_State *L, TerrainComponent &tc)
{
flecs::world world = getWorld(L);
bool found = false;
world.query<TerrainComponent, TransformComponent>().each(
[&](flecs::entity, TerrainComponent &t, TransformComponent &) {
if (!found) {
tc = t;
found = true;
}
});
return found;
}
static TerrainSystem::BrushFalloffShape parseBrushShape(const char *name)
{
if (!name)
return TerrainSystem::BrushFalloffShape::Linear;
if (strcmp(name, "smoothstep") == 0)
return TerrainSystem::BrushFalloffShape::Smoothstep;
if (strcmp(name, "gaussian") == 0)
return TerrainSystem::BrushFalloffShape::Gaussian;
if (strcmp(name, "spherical") == 0)
return TerrainSystem::BrushFalloffShape::Spherical;
if (strcmp(name, "cone") == 0)
return TerrainSystem::BrushFalloffShape::Cone;
return TerrainSystem::BrushFalloffShape::Linear;
}
static TerrainSystem::SculptTool parseSculptTool(const char *name)
{
if (!name)
return TerrainSystem::SculptTool::Raise;
if (strcmp(name, "lower") == 0)
return TerrainSystem::SculptTool::Lower;
if (strcmp(name, "smooth") == 0)
return TerrainSystem::SculptTool::Smooth;
if (strcmp(name, "flatten") == 0)
return TerrainSystem::SculptTool::Flatten;
return TerrainSystem::SculptTool::Raise;
}
static int luaTerrainSculpt(lua_State *L)
{
luaL_checktype(L, 1, LUA_TNUMBER);
luaL_checktype(L, 2, LUA_TNUMBER);
float x = (float)lua_tonumber(L, 1);
float z = (float)lua_tonumber(L, 2);
TerrainSystem *ts = TerrainSystem::getInstance();
if (!ts || !ts->isActive()) {
lua_pushboolean(L, 0);
return 1;
}
if (lua_isnumber(L, 3))
ts->setSculptRadius((float)lua_tonumber(L, 3));
if (lua_isnumber(L, 4))
ts->setSculptStrength((float)lua_tonumber(L, 4));
if (lua_isstring(L, 5))
ts->setSculptTool(parseSculptTool(lua_tostring(L, 5)));
Ogre::Vector3 pos(x, 0.0f, z);
pos.x = ts->visualToPhysicalX(x);
pos.z = ts->visualToPhysicalZ(z);
ts->applySculptBrush(pos);
lua_pushboolean(L, 1);
return 1;
}
static int luaTerrainPaint(lua_State *L)
{
luaL_checktype(L, 1, LUA_TNUMBER);
luaL_checktype(L, 2, LUA_TNUMBER);
float x = (float)lua_tonumber(L, 1);
float z = (float)lua_tonumber(L, 2);
TerrainSystem *ts = TerrainSystem::getInstance();
if (!ts || !ts->isActive()) {
lua_pushboolean(L, 0);
return 1;
}
if (lua_isnumber(L, 3))
ts->setPaintRadius((float)lua_tonumber(L, 3));
if (lua_isnumber(L, 4))
ts->setPaintStrength((float)lua_tonumber(L, 4));
if (lua_isinteger(L, 5))
ts->setPaintLayerIndex((int)lua_tointeger(L, 5));
ts->applySplatBrush(Ogre::Vector3(x, 0.0f, z));
lua_pushboolean(L, 1);
return 1;
}
static int luaTerrainPaintAux(lua_State *L)
{
luaL_checktype(L, 1, LUA_TNUMBER);
luaL_checktype(L, 2, LUA_TNUMBER);
float x = (float)lua_tonumber(L, 1);
float z = (float)lua_tonumber(L, 2);
TerrainSystem *ts = TerrainSystem::getInstance();
if (!ts || !ts->isActive()) {
lua_pushboolean(L, 0);
return 1;
}
if (lua_isstring(L, 3))
ts->setAuxPaintMapName(lua_tostring(L, 3));
if (lua_isnumber(L, 4))
ts->setPaintRadius((float)lua_tonumber(L, 4));
if (lua_isnumber(L, 5))
ts->setPaintStrength((float)lua_tonumber(L, 5));
Ogre::Vector3 pos(x, 0.0f, z);
pos.x = ts->visualToPhysicalX(x);
pos.z = ts->visualToPhysicalZ(z);
ts->applyAuxBrush(ts->getAuxPaintMapName(), pos, ts->getPaintRadius(),
ts->getPaintStrength());
lua_pushboolean(L, 1);
return 1;
}
static int luaTerrainSampleAux(lua_State *L)
{
luaL_checktype(L, 1, LUA_TSTRING);
luaL_checktype(L, 2, LUA_TNUMBER);
luaL_checktype(L, 3, LUA_TNUMBER);
const char *name = lua_tostring(L, 1);
long x = (long)lua_tonumber(L, 2);
long z = (long)lua_tonumber(L, 3);
TerrainSystem *ts = TerrainSystem::getInstance();
if (!ts || !ts->isActive()) {
lua_pushnumber(L, 0.0);
return 1;
}
lua_pushnumber(L, ts->sampleAuxMap(name, x, z));
return 1;
}
static int luaTerrainSetBrushFalloffShape(lua_State *L)
{
luaL_checktype(L, 1, LUA_TSTRING);
TerrainSystem *ts = TerrainSystem::getInstance();
if (ts)
ts->setBrushFalloffShape(parseBrushShape(lua_tostring(L, 1)));
return 0;
}
static int luaTerrainSetSculptMode(lua_State *L)
{
luaL_checktype(L, 1, LUA_TBOOLEAN);
TerrainSystem *ts = TerrainSystem::getInstance();
if (ts)
ts->setSculptMode(lua_toboolean(L, 1) != 0);
return 0;
}
static int luaTerrainSetPaintMode(lua_State *L)
{
luaL_checktype(L, 1, LUA_TBOOLEAN);
TerrainSystem *ts = TerrainSystem::getInstance();
if (ts)
ts->setPaintMode(lua_toboolean(L, 1) != 0);
return 0;
}
static int luaTerrainSetAuxPaintMode(lua_State *L)
{
luaL_checktype(L, 1, LUA_TBOOLEAN);
TerrainSystem *ts = TerrainSystem::getInstance();
if (ts)
ts->setAuxPaintMode(lua_toboolean(L, 1) != 0);
return 0;
}
static int luaTerrainSetSculptRadius(lua_State *L)
{
luaL_checktype(L, 1, LUA_TNUMBER);
TerrainSystem *ts = TerrainSystem::getInstance();
if (ts)
ts->setSculptRadius((float)lua_tonumber(L, 1));
return 0;
}
static int luaTerrainSetSculptStrength(lua_State *L)
{
luaL_checktype(L, 1, LUA_TNUMBER);
TerrainSystem *ts = TerrainSystem::getInstance();
if (ts)
ts->setSculptStrength((float)lua_tonumber(L, 1));
return 0;
}
static int luaTerrainSetSculptTool(lua_State *L)
{
luaL_checktype(L, 1, LUA_TSTRING);
TerrainSystem *ts = TerrainSystem::getInstance();
if (ts)
ts->setSculptTool(parseSculptTool(lua_tostring(L, 1)));
return 0;
}
static int luaTerrainSetPaintRadius(lua_State *L)
{
luaL_checktype(L, 1, LUA_TNUMBER);
TerrainSystem *ts = TerrainSystem::getInstance();
if (ts)
ts->setPaintRadius((float)lua_tonumber(L, 1));
return 0;
}
static int luaTerrainSetPaintStrength(lua_State *L)
{
luaL_checktype(L, 1, LUA_TNUMBER);
TerrainSystem *ts = TerrainSystem::getInstance();
if (ts)
ts->setPaintStrength((float)lua_tonumber(L, 1));
return 0;
}
static int luaTerrainSetPaintLayer(lua_State *L)
{
luaL_checktype(L, 1, LUA_TNUMBER);
TerrainSystem *ts = TerrainSystem::getInstance();
if (ts)
ts->setPaintLayerIndex((int)lua_tointeger(L, 1));
return 0;
}
static int luaTerrainSetAuxPaintMap(lua_State *L)
{
luaL_checktype(L, 1, LUA_TSTRING);
TerrainSystem *ts = TerrainSystem::getInstance();
if (ts)
ts->setAuxPaintMapName(lua_tostring(L, 1));
return 0;
}
static int luaTerrainSaveHeightmap(lua_State *L)
{
TerrainSystem *ts = TerrainSystem::getInstance();
if (!ts) {
lua_pushboolean(L, 0);
return 1;
}
TerrainComponent tc;
if (!findTerrainComponent(L, tc)) {
lua_pushboolean(L, 0);
return 1;
}
bool ok = ts->saveSceneHeightmap(tc);
lua_pushboolean(L, ok ? 1 : 0);
return 1;
}
static int luaTerrainLoadHeightmap(lua_State *L)
{
TerrainSystem *ts = TerrainSystem::getInstance();
if (!ts) {
lua_pushboolean(L, 0);
return 1;
}
TerrainComponent tc;
if (!findTerrainComponent(L, tc)) {
lua_pushboolean(L, 0);
return 1;
}
bool ok = ts->loadSceneHeightmap(tc);
lua_pushboolean(L, ok ? 1 : 0);
return 1;
}
static int luaTerrainSaveBlendMaps(lua_State *L)
{
TerrainSystem *ts = TerrainSystem::getInstance();
if (!ts) {
lua_pushboolean(L, 0);
return 1;
}
TerrainComponent tc;
if (!findTerrainComponent(L, tc)) {
lua_pushboolean(L, 0);
return 1;
}
bool ok = ts->saveSceneBlendMaps(tc);
lua_pushboolean(L, ok ? 1 : 0);
return 1;
}
static int luaTerrainLoadBlendMaps(lua_State *L)
{
TerrainSystem *ts = TerrainSystem::getInstance();
if (!ts) {
lua_pushboolean(L, 0);
return 1;
}
TerrainComponent tc;
if (!findTerrainComponent(L, tc)) {
lua_pushboolean(L, 0);
return 1;
}
bool ok = ts->loadSceneBlendMaps(tc);
lua_pushboolean(L, ok ? 1 : 0);
return 1;
}
static int luaTerrainSaveAuxMaps(lua_State *L)
{
TerrainSystem *ts = TerrainSystem::getInstance();
if (!ts) {
lua_pushboolean(L, 0);
return 1;
}
TerrainComponent tc;
if (!findTerrainComponent(L, tc)) {
lua_pushboolean(L, 0);
return 1;
}
bool ok = ts->saveSceneAuxMaps(tc);
lua_pushboolean(L, ok ? 1 : 0);
return 1;
}
static int luaTerrainLoadAuxMaps(lua_State *L)
{
TerrainSystem *ts = TerrainSystem::getInstance();
if (!ts) {
lua_pushboolean(L, 0);
return 1;
}
TerrainComponent tc;
if (!findTerrainComponent(L, tc)) {
lua_pushboolean(L, 0);
return 1;
}
bool ok = ts->loadSceneAuxMaps(tc);
lua_pushboolean(L, ok ? 1 : 0);
return 1;
}
void registerLuaTerrainApi(lua_State *L)
{
static const struct luaL_Reg terrainApi[] = {
{ "sculpt", luaTerrainSculpt },
{ "paint", luaTerrainPaint },
{ "paintAux", luaTerrainPaintAux },
{ "sampleAux", luaTerrainSampleAux },
{ "setBrushFalloffShape", luaTerrainSetBrushFalloffShape },
{ "setSculptMode", luaTerrainSetSculptMode },
{ "setPaintMode", luaTerrainSetPaintMode },
{ "setAuxPaintMode", luaTerrainSetAuxPaintMode },
{ "setSculptRadius", luaTerrainSetSculptRadius },
{ "setSculptStrength", luaTerrainSetSculptStrength },
{ "setSculptTool", luaTerrainSetSculptTool },
{ "setPaintRadius", luaTerrainSetPaintRadius },
{ "setPaintStrength", luaTerrainSetPaintStrength },
{ "setPaintLayer", luaTerrainSetPaintLayer },
{ "setAuxPaintMap", luaTerrainSetAuxPaintMap },
{ "saveHeightmap", luaTerrainSaveHeightmap },
{ "loadHeightmap", luaTerrainLoadHeightmap },
{ "saveBlendMaps", luaTerrainSaveBlendMaps },
{ "loadBlendMaps", luaTerrainLoadBlendMaps },
{ "saveAuxMaps", luaTerrainSaveAuxMaps },
{ "loadAuxMaps", luaTerrainLoadAuxMaps },
{ nullptr, nullptr }
};
/* Expose under the existing global "ecs" table (created by LuaEntityApi). */
lua_getglobal(L, "ecs");
if (lua_istable(L, -1)) {
luaL_newlib(L, terrainApi);
lua_setfield(L, -2, "terrain");
lua_pop(L, 1);
} else {
lua_pop(L, 1);
luaL_newlib(L, terrainApi);
lua_setglobal(L, "terrain");
}
}
} // namespace editScene
@@ -0,0 +1,33 @@
#ifndef EDITSCENE_LUA_TERRAIN_API_HPP
#define EDITSCENE_LUA_TERRAIN_API_HPP
#pragma once
#include <lua.hpp>
namespace editScene
{
/**
* @brief Registers the terrain editing Lua API.
*
* Exposes functions under the global `terrain` table:
* terrain.sculpt(x, z, radius, strength, tool)
* terrain.paint(x, z, radius, strength, layer)
* terrain.paintAux(x, z, mapName, radius, strength)
* terrain.sampleAux(mapName, x, z)
* terrain.setBrushFalloffShape(shape)
* terrain.setSculptMode(on), terrain.setPaintMode(on),
* terrain.setAuxPaintMode(on)
* terrain.setSculptRadius(r), terrain.setSculptStrength(s),
* terrain.setSculptTool(tool)
* terrain.setPaintRadius(r), terrain.setPaintStrength(s),
* terrain.setPaintLayer(layer)
* terrain.setAuxPaintMap(name)
* terrain.saveHeightmap(), terrain.saveBlendMaps(), terrain.saveAuxMaps()
* terrain.loadHeightmap(), terrain.loadBlendMaps(), terrain.loadAuxMaps()
*/
void registerLuaTerrainApi(lua_State *L);
} // namespace editScene
#endif // EDITSCENE_LUA_TERRAIN_API_HPP
+83
View File
@@ -1,6 +1,54 @@
#include <iostream>
#include "EditorApp.hpp"
#include "systems/SceneSerializer.hpp"
#include "systems/TerrainTests.hpp"
#include "OgreRoot.h"
struct ExitAfterFirstFrameListener : public Ogre::FrameListener {
Ogre::Root *root;
bool triggered = false;
ExitAfterFirstFrameListener(Ogre::Root *r) : root(r) {}
bool frameRenderingQueued(const Ogre::FrameEvent &) override
{
if (!triggered) {
triggered = true;
root->queueEndRendering();
}
return true;
}
};
/** Frame-driven terrain test runner.
* Terrain init needs the render loop active (GPU context).
* Runs the suite on the first frame, then exits. */
struct TerrainTestFrameListener : public Ogre::FrameListener {
Ogre::Root *root;
EditorApp &app;
int iterations;
bool triggered = false;
int result = 0;
TerrainTestFrameListener(Ogre::Root *r, EditorApp &a, int n)
: root(r), app(a), iterations(n) {}
bool frameRenderingQueued(const Ogre::FrameEvent &) override
{
if (triggered)
return true;
triggered = true;
std::cout << "Running terrain tests ("
<< iterations
<< " iterations)..." << std::endl;
result = TerrainTestRunner::run(app, iterations);
if (result != 0)
std::cerr << "TERRAIN TESTS FAILED" << std::endl;
else
std::cout << "TERRAIN TESTS: ALL "
<< iterations
<< " ITERATIONS PASSED" << std::endl;
root->queueEndRendering();
return true;
}
};
int main(int argc, char *argv[])
{
@@ -10,6 +58,9 @@ int main(int argc, char *argv[])
// Parse command line arguments
bool gameMode = false;
bool debugBuoyancy = false;
bool exitAfterFirstFrame = false;
bool headless = false;
int terrainTestIterations = 0;
Ogre::String sceneFile;
for (int i = 1; i < argc; i++) {
Ogre::String arg = argv[i];
@@ -17,6 +68,16 @@ int main(int argc, char *argv[])
gameMode = true;
} else if (arg == "--debug-buoyancy") {
debugBuoyancy = true;
} else if (arg == "--exit-after-first-frame") {
exitAfterFirstFrame = true;
} else if (arg.find("--run-terrain-tests=") == 0) {
Ogre::String val = arg.substr(20);
terrainTestIterations = Ogre::StringConverter::parseInt(
val, 1);
if (terrainTestIterations < 1)
terrainTestIterations = 1;
} else if (arg == "--headless") {
headless = true;
} else if (arg.length() > 0 && arg[0] != '-') {
sceneFile = arg;
}
@@ -30,8 +91,22 @@ int main(int argc, char *argv[])
app.setDebugBuoyancy(true);
}
app.setHeadless(headless);
if (headless && terrainTestIterations > 0) {
TerrainTestRunner::setHeadless(true);
}
app.initApp();
// Use frame-driven terrain test if requested.
// Terrain init requires an active render loop (GPU).
TerrainTestFrameListener terrainTestListener(
app.getRoot(), app,
terrainTestIterations);
if (terrainTestIterations > 0)
app.getRoot()->addFrameListener(
&terrainTestListener);
// Auto-load scene if provided as argument (editor mode only)
if (!sceneFile.empty() &&
app.getGameMode() == EditorApp::GameMode::Editor) {
@@ -46,8 +121,16 @@ int main(int argc, char *argv[])
}
}
ExitAfterFirstFrameListener exitListener(app.getRoot());
if (exitAfterFirstFrame)
app.getRoot()->addFrameListener(&exitListener);
app.getRoot()->startRendering();
app.closeApp();
/* Propagate terrain test result as process exit code so CI can
* detect failures. */
if (terrainTestIterations > 0)
return terrainTestListener.result;
} catch (const std::exception &e) {
std::cerr << "Error: " << e.what() << std::endl;
return 1;
+22 -1
View File
@@ -564,6 +564,7 @@ class Physics {
std::set<JPH::Character *> characters;
std::set<JPH::BodyID> characterBodies;
bool debugDraw;
JPH::BodyDrawFilter *mBodyDrawFilter = nullptr;
JPH::Vec3 gravity = JPH::Vec3(0.0f, -9.8f, 0.0f);
std::unordered_map<uint32_t, JPH::Ref<JPH::GroupFilterTable> > groupFilters;
@@ -601,6 +602,11 @@ public:
return nullptr;
}
void setBodyDrawFilter(JPH::BodyDrawFilter *filter)
{
mBodyDrawFilter = filter;
}
Physics(Ogre::SceneManager *scnMgr, Ogre::SceneNode *cameraNode,
ActivationListener *activationListener = nullptr,
JPH::ContactListener *contactListener = nullptr)
@@ -848,7 +854,8 @@ public:
mDebugRenderer->updateCameraPos();
physics_system.DrawBodies(
JPH::BodyManager::DrawSettings(),
mDebugRenderer);
mDebugRenderer,
mBodyDrawFilter);
}
mDebugRenderer->finish();
mDebugRenderer->NextFrame();
@@ -1720,6 +1727,14 @@ JoltPhysicsWrapper::JoltPhysicsWrapper(Ogre::SceneManager *scnMgr,
JoltPhysicsWrapper::~JoltPhysicsWrapper()
{
// Tear down the global physics state while the application is still
// running. Otherwise the Physics object is destroyed during static
// teardown and may crash because Jolt types are still referenced.
Ogre::LogManager::getSingleton().logMessage(
"JoltPhysicsWrapper: tearing down global physics state...");
phys.reset();
Ogre::LogManager::getSingleton().logMessage(
"JoltPhysicsWrapper: global physics state torn down");
}
void JoltPhysicsWrapper::update(float dt)
@@ -1885,6 +1900,12 @@ void JoltPhysicsWrapper::setDebugDraw(bool enable)
{
phys->setDebugDraw(enable);
}
void JoltPhysicsWrapper::setBodyDrawFilter(JPH::BodyDrawFilter *filter)
{
phys->setBodyDrawFilter(filter);
}
Ogre::Vector3 JoltPhysicsWrapper::getGravity() const
{
return JoltPhysics::convert(phys->getGravity());
+2
View File
@@ -3,6 +3,7 @@
#include <Ogre.h>
#include <OgreSingleton.h>
#include <Jolt/Jolt.h>
#include <Jolt/Physics/Body/BodyFilter.h>
#include <Jolt/Physics/Collision/Shape/Shape.h>
#include <Jolt/Physics/Collision/Shape/StaticCompoundShape.h>
#include <Jolt/Physics/Collision/ObjectLayer.h>
@@ -185,6 +186,7 @@ public:
void removeBody(const JPH::BodyID &id);
void destroyBody(const JPH::BodyID &id);
void setDebugDraw(bool enable);
void setBodyDrawFilter(JPH::BodyDrawFilter *filter);
Ogre::Vector3 getGravity() const;
void setGravity(const Ogre::Vector3 &gravity);
void broadphaseQuery(float dt, const Ogre::Vector3 &position,
+4
View File
@@ -12,10 +12,14 @@ FileSystem=resources/buildings/parts/furniture
FileSystem=resources/vehicles
FileSystem=resources/fonts
FileSystem=resources/debug
FileSystem=resources/terrain
[Popular]
FileSystem=resources/materials/programs
FileSystem=resources/materials/textures
[LuaScripts]
FileSystem=lua-scripts
[Essential]
Zip=/usr/share/OGRE-14/media/packs/SdkTrays.zip
@@ -0,0 +1,269 @@
#include "AnimationTreeRegistry.hpp"
#include <OgreLogManager.h>
#include <OgreMeshManager.h>
#include <OgreResourceGroupManager.h>
#include <OgreSkeleton.h>
#include <OgreAnimation.h>
#include <fstream>
#include <filesystem>
AnimationTreeRegistry *AnimationTreeRegistry::ms_singleton = nullptr;
AnimationTreeRegistry &AnimationTreeRegistry::getSingleton()
{
static AnimationTreeRegistry instance;
return instance;
}
AnimationTreeRegistry *AnimationTreeRegistry::getSingletonPtr()
{
return &getSingleton();
}
AnimationTreeRegistry::AnimationTreeRegistry()
: m_autoSavePath(defaultPath())
{
ms_singleton = this;
}
void AnimationTreeRegistry::initialize()
{
if (!std::filesystem::exists(m_autoSavePath))
return;
if (!loadFromFile(m_autoSavePath)) {
Ogre::LogManager::getSingleton().logMessage(
"AnimationTreeRegistry: auto-load failed: " +
m_lastError);
}
}
bool AnimationTreeRegistry::registerTree(const AnimationTreeDefinition &def)
{
bool wasNew = m_trees.find(def.name) == m_trees.end();
m_trees[def.name] = def;
if (wasNew)
Ogre::LogManager::getSingleton().logMessage(
"AnimationTreeRegistry: registered tree '" + def.name +
"'");
autoSave();
return wasNew;
}
bool AnimationTreeRegistry::removeTree(const Ogre::String &name)
{
if (m_trees.erase(name) == 0)
return false;
autoSave();
return true;
}
AnimationTreeRegistry::AnimationTreeDefinition *
AnimationTreeRegistry::findTree(const Ogre::String &name)
{
auto it = m_trees.find(name);
if (it != m_trees.end())
return &it->second;
return nullptr;
}
const AnimationTreeRegistry::AnimationTreeDefinition *
AnimationTreeRegistry::findTree(const Ogre::String &name) const
{
auto it = m_trees.find(name);
if (it != m_trees.end())
return &it->second;
return nullptr;
}
std::vector<Ogre::String> AnimationTreeRegistry::getTreeNames() const
{
std::vector<Ogre::String> names;
names.reserve(m_trees.size());
for (const auto &pair : m_trees)
names.push_back(pair.first);
return names;
}
Ogre::String AnimationTreeRegistry::generateUniqueName(const Ogre::String &base)
const
{
if (m_trees.find(base) == m_trees.end())
return base;
for (int i = 1; i < 10000; ++i) {
Ogre::String candidate = base + "_" + Ogre::StringConverter::toString(i);
if (m_trees.find(candidate) == m_trees.end())
return candidate;
}
return base + "_" + Ogre::StringConverter::toString(
(unsigned long long)this);
}
void AnimationTreeRegistry::markModified(const Ogre::String &name)
{
auto it = m_trees.find(name);
if (it != m_trees.end()) {
++it->second.version;
autoSave();
}
}
std::vector<AnimationTreeRegistry::SkeletonSource>
AnimationTreeRegistry::getAvailableSkeletonSources() const
{
std::vector<SkeletonSource> result;
Ogre::ResourceGroupManager &rgm =
Ogre::ResourceGroupManager::getSingleton();
try {
Ogre::StringVectorPtr names =
rgm.findResourceNames("Characters", "*.mesh");
if (!names)
return result;
std::unordered_map<Ogre::String, Ogre::String> seen;
for (const auto &meshName : *names) {
Ogre::String skelName =
getSkeletonNameForMesh(meshName);
if (skelName.empty())
continue;
if (seen.find(skelName) != seen.end())
continue;
seen[skelName] = meshName;
result.push_back({ skelName, meshName });
}
} catch (...) {
}
std::sort(result.begin(), result.end(),
[](const SkeletonSource &a, const SkeletonSource &b) {
return a.skeletonName < b.skeletonName;
});
return result;
}
Ogre::String AnimationTreeRegistry::getSkeletonNameForMesh(
const Ogre::String &meshName) const
{
try {
Ogre::MeshPtr mesh = Ogre::MeshManager::getSingleton().load(
meshName, "Characters");
if (!mesh || !mesh->hasSkeleton())
return Ogre::String();
return mesh->getSkeletonName();
} catch (const std::exception &e) {
Ogre::LogManager::getSingleton().logMessage(
"AnimationTreeRegistry: failed to read skeleton for '" +
meshName + "': " + e.what());
}
return Ogre::String();
}
std::vector<Ogre::String>
AnimationTreeRegistry::getAnimationsForSource(const Ogre::String &source) const
{
std::vector<Ogre::String> result;
if (source.empty())
return result;
try {
Ogre::MeshPtr mesh = Ogre::MeshManager::getSingleton().load(
source, "Characters");
if (!mesh || !mesh->getSkeleton())
return result;
Ogre::SkeletonPtr skel = mesh->getSkeleton();
unsigned short numAnims = skel->getNumAnimations();
for (unsigned short i = 0; i < numAnims; ++i) {
Ogre::Animation *anim = skel->getAnimation(i);
if (anim)
result.push_back(anim->getName());
}
} catch (const std::exception &e) {
Ogre::LogManager::getSingleton().logMessage(
"AnimationTreeRegistry: failed to enumerate animations for '" +
source + "': " + e.what());
}
std::sort(result.begin(), result.end());
return result;
}
nlohmann::json AnimationTreeRegistry::serialize() const
{
nlohmann::json j;
j["version"] = "1.0";
j["trees"] = nlohmann::json::array();
for (const auto &pair : m_trees) {
const AnimationTreeDefinition &def = pair.second;
nlohmann::json treeJson;
treeJson["name"] = def.name;
treeJson["skeletonSource"] = def.skeletonSource;
treeJson["version"] = def.version;
treeJson["root"] = animationTreeNodeToJson(def.root);
j["trees"].push_back(treeJson);
}
return j;
}
void AnimationTreeRegistry::deserialize(const nlohmann::json &j)
{
m_trees.clear();
if (!j.contains("trees") || !j["trees"].is_array())
return;
for (const auto &treeJson : j["trees"]) {
if (!treeJson.contains("name"))
continue;
AnimationTreeDefinition def;
def.name = treeJson.value("name", "");
def.skeletonSource = treeJson.value("skeletonSource", "");
def.version = treeJson.value("version", 1);
if (treeJson.contains("root"))
animationTreeNodeFromJson(def.root, treeJson["root"]);
if (!def.name.empty())
m_trees[def.name] = def;
}
}
bool AnimationTreeRegistry::saveToFile(const std::string &filepath)
{
try {
std::ofstream file(filepath);
if (!file.is_open()) {
m_lastError = "Failed to open file for writing: " + filepath;
return false;
}
file << serialize().dump(4);
file.close();
return true;
} catch (const std::exception &e) {
m_lastError = std::string("Save error: ") + e.what();
return false;
}
}
bool AnimationTreeRegistry::loadFromFile(const std::string &filepath)
{
try {
if (!std::filesystem::exists(filepath)) {
m_lastError = "File not found: " + filepath;
return false;
}
std::ifstream file(filepath);
if (!file.is_open()) {
m_lastError = "Failed to open file for reading: " + filepath;
return false;
}
nlohmann::json j;
file >> j;
file.close();
deserialize(j);
return true;
} catch (const std::exception &e) {
m_lastError = std::string("Load error: ") + e.what();
return false;
}
}
void AnimationTreeRegistry::autoSave()
{
if (!m_autoSavePath.empty())
saveToFile(m_autoSavePath);
}
@@ -0,0 +1,116 @@
#ifndef EDITSCENE_ANIMATIONTREEREGISTRY_HPP
#define EDITSCENE_ANIMATIONTREEREGISTRY_HPP
#pragma once
#include "../components/AnimationTree.hpp"
#include <Ogre.h>
#include <nlohmann/json.hpp>
#include <string>
#include <unordered_map>
#include <vector>
/**
* Global animation tree registry.
*
* Holds named animation tree definitions. Each tree is referenced by name
* from AnimationTreeComponent. Trees are edited in the Animation Tree
* Registry window (Tools menu) and persisted to animation_tree.json.
*/
class AnimationTreeRegistry {
public:
static AnimationTreeRegistry &getSingleton();
static AnimationTreeRegistry *getSingletonPtr();
private:
static AnimationTreeRegistry *ms_singleton;
public:
struct AnimationTreeDefinition {
Ogre::String name;
Ogre::String skeletonSource;
AnimationTreeNode root;
uint64_t version = 1;
};
AnimationTreeRegistry();
~AnimationTreeRegistry() = default;
AnimationTreeRegistry(const AnimationTreeRegistry &) = delete;
AnimationTreeRegistry &operator=(const AnimationTreeRegistry &) = delete;
/* ------------------------------------------------------------------ */
/* Life-cycle */
/* ------------------------------------------------------------------ */
void initialize();
/* ------------------------------------------------------------------ */
/* Definitions */
/* ------------------------------------------------------------------ */
bool registerTree(const AnimationTreeDefinition &def);
bool removeTree(const Ogre::String &name);
AnimationTreeDefinition *findTree(const Ogre::String &name);
const AnimationTreeDefinition *findTree(const Ogre::String &name) const;
const std::unordered_map<Ogre::String, AnimationTreeDefinition> &
getTrees() const
{
return m_trees;
}
std::vector<Ogre::String> getTreeNames() const;
bool hasTree(const Ogre::String &name) const
{
return m_trees.find(name) != m_trees.end();
}
/* Generate a unique name from a base prefix. */
Ogre::String generateUniqueName(const Ogre::String &base) const;
/* Bump version and auto-save. */
void markModified(const Ogre::String &name);
/* ------------------------------------------------------------------ */
/* Skeleton source helpers */
/* ------------------------------------------------------------------ */
struct SkeletonSource {
Ogre::String skeletonName;
Ogre::String meshName;
};
std::vector<SkeletonSource> getAvailableSkeletonSources() const;
std::vector<Ogre::String> getAnimationsForSource(
const Ogre::String &source) const;
/* Return the skeleton resource name for a given mesh, or empty. */
Ogre::String getSkeletonNameForMesh(const Ogre::String &meshName) const;
/* ------------------------------------------------------------------ */
/* Persistence */
/* ------------------------------------------------------------------ */
nlohmann::json serialize() const;
void deserialize(const nlohmann::json &j);
bool saveToFile(const std::string &filepath);
bool loadFromFile(const std::string &filepath);
const std::string &getLastError() const
{
return m_lastError;
}
void autoSave();
/* Default file path used by initialize()/autoSave(). */
static const char *defaultPath()
{
return "animation_tree.json";
}
private:
std::unordered_map<Ogre::String, AnimationTreeDefinition> m_trees;
std::string m_autoSavePath;
std::string m_lastError;
};
#endif // EDITSCENE_ANIMATIONTREEREGISTRY_HPP
@@ -1,4 +1,5 @@
#include "AnimationTreeSystem.hpp"
#include "AnimationTreeRegistry.hpp"
#include "CharacterSlotSystem.hpp"
#include "../components/Transform.hpp"
#include "../components/Renderable.hpp"
@@ -99,7 +100,8 @@ Ogre::Entity *AnimationTreeSystem::findAnimatedEntity(flecs::entity e)
}
bool AnimationTreeSystem::setupEntity(flecs::entity e,
AnimationTreeComponent &at)
AnimationTreeComponent &at,
EntityAnimTreeState &state)
{
Ogre::Entity *ent = findAnimatedEntity(e);
std::cout << "AnimationTreeSystem::setupEntity: entity=" << e.id()
@@ -111,7 +113,6 @@ bool AnimationTreeSystem::setupEntity(flecs::entity e,
return false;
}
EntityAnimTreeState &state = m_states[e.id()];
/* Preserve root-motion tracking state across rebuilds so
* setupEntity() does not produce a one-frame zero-delta
* stutter when the mesh is recreated by CharacterSlotSystem.
@@ -239,7 +240,8 @@ bool AnimationTreeSystem::setupEntity(flecs::entity e,
}
/* Initialize default state machine states */
initializeTreeStates(at.root, at);
if (state.resolvedRoot)
initializeTreeStates(*state.resolvedRoot, at);
std::cout
<< " setupEntity done: animations=" << state.animations.size()
<< " rootBone="
@@ -326,35 +328,29 @@ void AnimationTreeSystem::initializeTreeStates(const AnimationTreeNode &node,
initializeTreeStates(child, at);
}
void AnimationTreeSystem::resolveTemplate(AnimationTreeComponent &at)
const AnimationTreeNode *
AnimationTreeSystem::getResolvedRoot(const AnimationTreeComponent &at) const
{
if (at.templateName.empty())
if (at.treeName.empty())
return nullptr;
const AnimationTreeRegistry::AnimationTreeDefinition *def =
AnimationTreeRegistry::getSingleton().findTree(at.treeName);
if (!def)
return nullptr;
return &def->root;
}
void AnimationTreeSystem::resolveTree(AnimationTreeComponent &at,
EntityAnimTreeState &state)
{
state.resolvedRoot = getResolvedRoot(at);
if (!state.resolvedRoot)
return;
AnimationTreeTemplate *templ = nullptr;
AnimationTreeComponent *templAt = nullptr;
m_world.query<AnimationTreeTemplate, AnimationTreeComponent>().each(
[&](flecs::entity, AnimationTreeTemplate &t,
AnimationTreeComponent &ta) {
if (t.name == at.templateName) {
templ = &t;
templAt = &ta;
}
});
if (!templ) {
m_world.query<AnimationTreeTemplate>().each(
[&](flecs::entity, AnimationTreeTemplate &t) {
if (t.name == at.templateName)
templ = &t;
});
}
if (templ && at.templateVersion != templ->version) {
if (templAt)
at.root = templAt->root;
at.templateVersion = templ->version;
const AnimationTreeRegistry::AnimationTreeDefinition *def =
AnimationTreeRegistry::getSingleton().findTree(at.treeName);
if (def && at.treeVersion != def->version) {
at.treeVersion = def->version;
at.dirty = true;
}
}
@@ -374,31 +370,38 @@ void AnimationTreeSystem::update(float deltaTime)
if (e.has<CharacterComponent>())
e.get_mut<CharacterComponent>().linearVelocity =
Ogre::Vector3::ZERO;
resolveTemplate(at);
auto stateIt = m_states.find(e.id());
if (stateIt == m_states.end()) {
EntityAnimTreeState newState;
stateIt = m_states.emplace(e.id(), newState).first;
}
EntityAnimTreeState &state = stateIt->second;
resolveTree(at, state);
if (!state.resolvedRoot) {
/* No registry entry for this tree name. */
return;
}
if (at.dirty) {
if (setupEntity(e, at))
if (setupEntity(e, at, state))
at.dirty = false;
}
auto it = m_states.find(e.id());
if (it == m_states.end())
if (!state.ogreEntity)
return;
/* Validate cached entity pointer -
* CharacterSlotSystem rebuilds can destroy and
* recreate the Ogre::Entity */
Ogre::Entity *currentEnt = findAnimatedEntity(e);
if (currentEnt != it->second.ogreEntity) {
if (!setupEntity(e, at)) {
return;
}
it = m_states.find(e.id());
if (it == m_states.end())
if (currentEnt != state.ogreEntity) {
if (!setupEntity(e, at, state))
return;
}
EntityAnimTreeState &state = it->second;
if (!state.ogreEntity)
return;
@@ -431,7 +434,7 @@ void AnimationTreeSystem::update(float deltaTime)
/* Evaluate tree */
EvalContext ctx;
ctx.deltaTime = deltaTime;
evaluateNode(at.root, 1.0f, 1.0f, at, state, ctx);
evaluateNode(*state.resolvedRoot, 1.0f, 1.0f, at, state, ctx);
/* Apply animation weights and advance */
Ogre::Vector3 totalRootMotion = Ogre::Vector3::ZERO;
@@ -758,8 +761,11 @@ void AnimationTreeSystem::checkEndTransitions(flecs::entity e,
const Ogre::String &smName = pair.first;
const Ogre::String &stateName = pair.second;
const AnimationTreeNode *root = getResolvedRoot(at);
if (!root)
continue;
const AnimationTreeNode *smNode =
findStateMachineNode(at.root, smName);
findStateMachineNode(*root, smName);
if (!smNode)
continue;
@@ -837,8 +843,11 @@ void AnimationTreeSystem::setStateInternal(flecs::entity e,
if (reset) {
/* Reset the new state's animation */
const AnimationTreeNode *smNode =
findStateMachineNode(at.root, stateMachineName);
const AnimationTreeNode *root = getResolvedRoot(at);
const AnimationTreeNode *smNode = root ?
findStateMachineNode(*root,
stateMachineName) :
nullptr;
if (smNode) {
const AnimationTreeNode *stNode =
findStateNode(*smNode, stateName);
@@ -9,7 +9,7 @@
#include <set>
#include "../components/AnimationTree.hpp"
#include "../components/AnimationTreeTemplate.hpp"
#include "AnimationTreeRegistry.hpp"
/**
@@ -89,6 +89,9 @@ private:
std::unordered_map<Ogre::String, FadeInfo> fadeStates;
/* Track previous currentStates to detect external changes */
std::unordered_map<Ogre::String, Ogre::String> prevStates;
/* Cached root from the registry. Not owned. */
const AnimationTreeNode *resolvedRoot = nullptr;
};
struct AnimEvalData {
@@ -102,7 +105,8 @@ private:
std::vector<std::pair<Ogre::String, Ogre::String> > endChecks;
};
bool setupEntity(flecs::entity e, AnimationTreeComponent &at);
bool setupEntity(flecs::entity e, AnimationTreeComponent &at,
EntityAnimTreeState &state);
void teardownEntity(flecs::entity e);
void disableAllAnimations(EntityAnimTreeState &state);
void initializeTreeStates(const AnimationTreeNode &node,
@@ -119,8 +123,11 @@ private:
const Ogre::String &stateMachineName,
const Ogre::String &stateName, bool reset);
/* Resolve template reference and copy tree if template changed */
void resolveTemplate(AnimationTreeComponent &at);
/* Resolve tree reference from registry and copy tree if version changed */
void resolveTree(AnimationTreeComponent &at, EntityAnimTreeState &state);
const AnimationTreeNode *getResolvedRoot(
const AnimationTreeComponent &at) const;
const AnimationTreeNode *
findStateMachineNode(const AnimationTreeNode &root,
@@ -428,8 +428,12 @@ BehaviorTreeSystem::evaluateNode(const BehaviorTreeNode &node, flecs::entity e,
* lookup on the same entity. */
if (e.has<AnimationTreeComponent>()) {
auto &at = e.get<AnimationTreeComponent>();
const AnimationTreeRegistry::AnimationTreeDefinition *def =
AnimationTreeRegistry::getSingleton().findTree(
at.treeName);
const AnimationTreeNode *smNode = nullptr;
for (const auto &child : at.root.children) {
if (def) {
for (const auto &child : def->root.children) {
if (child.type == "stateMachine" &&
child.name == node.name) {
smNode = &child;
@@ -442,6 +446,7 @@ BehaviorTreeSystem::evaluateNode(const BehaviorTreeNode &node, flecs::entity e,
break;
}
}
}
if (smNode) {
for (const auto &st : smNode->children) {
if (st.type == "state" &&
@@ -328,7 +328,7 @@ void DialogueSystem::renderDialogueBox()
// Speaker name
if (!m_speaker.empty()) {
if (m_speakerFont)
ImGui::PushFont(m_speakerFont);
ImGui::PushFont(m_speakerFont, m_speakerFont->LegacySize);
ImGui::TextColored(ImVec4(0.8f, 0.8f, 1.0f, 1.0f), "%s",
m_speaker.c_str());
if (m_speakerFont)
@@ -338,7 +338,7 @@ void DialogueSystem::renderDialogueBox()
// Narration text
if (m_dialogueFont)
ImGui::PushFont(m_dialogueFont);
ImGui::PushFont(m_dialogueFont, m_dialogueFont->LegacySize);
ImGui::TextWrapped("%s", m_text.c_str());
+439 -12
View File
@@ -4,6 +4,8 @@
#include "PrefabSystem.hpp"
#include "ItemRegistry.hpp"
#include "../camera/EditorCamera.hpp"
#include "../systems/TerrainSystem.hpp"
#include "../systems/RoadSystem.hpp"
#include "../components/EntityName.hpp"
#include "../components/Transform.hpp"
#include "../components/Renderable.hpp"
@@ -13,6 +15,7 @@
#include "../components/BuoyancyInfo.hpp"
#include "../components/WaterPhysics.hpp"
#include "../components/WaterPlane.hpp"
#include "../components/Terrain.hpp"
#include "../components/Sun.hpp"
#include "../components/Skybox.hpp"
#include "../components/Light.hpp"
@@ -30,7 +33,6 @@
#include "../components/CharacterSpawner.hpp"
#include "../components/Character.hpp"
#include "../components/AnimationTree.hpp"
#include "../components/AnimationTreeTemplate.hpp"
#include "../components/StartupMenu.hpp"
#include "../components/PlayerController.hpp"
#include "../components/CellGrid.hpp"
@@ -79,6 +81,7 @@ EditorUISystem::EditorUISystem(flecs::world &world,
{
registerComponentEditors();
m_gizmo = std::make_unique<Gizmo>(m_sceneMgr);
m_roadGizmo = std::make_unique<RoadGizmo>(m_sceneMgr);
m_cursor3D = std::make_unique<Cursor3D>(m_sceneMgr);
m_serializer = std::make_unique<SceneSerializer>(m_world, m_sceneMgr);
@@ -92,10 +95,13 @@ EditorUISystem::~EditorUISystem() = default;
void EditorUISystem::shutdown()
{
// Shutdown gizmo and cursor before SceneManager is destroyed
// Shutdown gizmos and cursor before SceneManager is destroyed
if (m_gizmo) {
m_gizmo->shutdown();
}
if (m_roadGizmo) {
m_roadGizmo->shutdown();
}
if (m_cursor3D) {
m_cursor3D->shutdown();
}
@@ -103,6 +109,28 @@ void EditorUISystem::shutdown()
bool EditorUISystem::onMousePressed(const Ogre::Ray &mouseRay)
{
TerrainSystem *ts = TerrainSystem::getInstance();
const bool roadEdit = ts && ts->getRoadEditMode();
if (roadEdit) {
if (m_roadGizmo && m_roadGizmo->onMousePressed(mouseRay)) {
m_roadGizmoDragged = true;
return true;
}
if (ImGui::GetIO().WantCaptureMouse)
return false;
/* Record the press; the click-vs-drag decision is made on
* release (5 px threshold). The event is not consumed so a
* drag still reaches the camera. */
m_roadClickPending = true;
const ImVec2 &mp = ImGui::GetIO().MousePos;
m_roadMouseDownPos = Ogre::Vector2(mp.x, mp.y);
m_roadMouseDownRay = mouseRay;
return false;
}
// Try gizmo first
if (m_gizmo && m_gizmo->onMousePressed(mouseRay)) {
return true;
@@ -156,6 +184,15 @@ bool EditorUISystem::onMousePressed(const Ogre::Ray &mouseRay)
bool EditorUISystem::onMouseMoved(const Ogre::Ray &mouseRay,
const Ogre::Vector2 &mouseDelta)
{
TerrainSystem *ts = TerrainSystem::getInstance();
const bool roadEdit = ts && ts->getRoadEditMode();
if (roadEdit) {
if (m_roadGizmo && m_roadGizmo->onMouseMoved(mouseRay))
return true;
return false;
}
// Gizmo gets first shot
if (m_gizmo && m_gizmo->onMouseMoved(mouseRay, mouseDelta)) {
return true;
@@ -173,6 +210,30 @@ bool EditorUISystem::onMouseMoved(const Ogre::Ray &mouseRay,
bool EditorUISystem::onMouseReleased()
{
TerrainSystem *ts = TerrainSystem::getInstance();
const bool roadEdit = ts && ts->getRoadEditMode();
if (roadEdit) {
if (m_roadGizmo && m_roadGizmo->onMouseReleased()) {
m_roadGizmoDragged = false;
applyRoadGizmoDrag(true);
return true;
}
if (m_roadClickPending) {
m_roadClickPending = false;
const ImVec2 &mp = ImGui::GetIO().MousePos;
float dx = mp.x - m_roadMouseDownPos.x;
float dy = mp.y - m_roadMouseDownPos.y;
/* Less than 5 pixels: a click. More: a camera drag. */
if (dx * dx + dy * dy < 25.0f)
handleRoadEditClick(m_roadMouseDownRay);
/* Never consume the release: the camera saw the press. */
return false;
}
return false;
}
// Gizmo gets first shot
if (m_gizmo && m_gizmo->onMouseReleased()) {
return true;
@@ -326,8 +387,92 @@ void EditorUISystem::registerModularComponents()
void EditorUISystem::update(float deltaTime)
{
/* Sculpt/paint/aux-paint mode: left mouse on terrain applies brush.
* Also updates brush decal at mouse position.
* Runs in both editor and game mode. */
{
TerrainSystem *ts = TerrainSystem::getInstance();
if (ts) {
if (ts->isSculpting() || ts->isPainting() ||
ts->isAuxPainting()) {
ImGuiIO &io = ImGui::GetIO();
if (!io.WantCaptureMouse && m_editorCamera) {
Ogre::Ray ray =
m_editorCamera->getMouseRay(
io.MousePos.x,
io.MousePos.y);
float t;
Ogre::Vector3 normal;
if (ts->raycastTerrain(ray, t,
normal)) {
Ogre::Vector3 hit =
ray.getPoint(t);
/* Decal at hit (physical);
* brush converts to physical
* coords that visual-map
* back to hit location. */
float decalRadius =
ts->isPainting() ||
ts->isAuxPainting() ?
ts->getPaintRadius() :
ts->getSculptRadius();
ts->updateBrushDecal(
hit, normal,
decalRadius);
if (ImGui::IsMouseDown(
ImGuiMouseButton_Left)) {
if (ts->isPainting()) {
Ogre::LogManager::getSingleton()
.logMessage(
"EditorUISystem: dispatching splat brush at " +
Ogre::StringConverter::
toString(
hit) +
" layer=" +
Ogre::StringConverter::toString(
ts->getPaintLayerIndex()));
ts->applySplatBrush(
hit);
} else if (
ts->isAuxPainting()) {
Ogre::Vector3
brushPos =
hit;
brushPos.x = ts->visualToPhysicalX(
hit.x);
brushPos.z = ts->visualToPhysicalZ(
hit.z);
ts->applyAuxBrush(
ts->getAuxPaintMapName(),
brushPos,
ts->getPaintRadius(),
ts->getPaintStrength());
} else {
Ogre::Vector3
brushPos =
hit;
brushPos.x = ts->visualToPhysicalX(
hit.x);
brushPos.z = ts->visualToPhysicalZ(
hit.z);
ts->applySculptBrush(
brushPos);
}
}
} else {
ts->hideBrushDecal();
}
}
} else {
ts->hideBrushDecal();
}
}
}
// Road edit mode state transitions and per-frame gizmo sync.
updateRoadEditMode();
if (!m_editorUIEnabled) {
// Only render FPS overlay when editor UI is disabled
renderFPSOverlay(deltaTime);
return;
}
@@ -382,6 +527,12 @@ void EditorUISystem::update(float deltaTime)
ItemRegistry::getSingleton().drawEditor(&m_showItemRegistry);
}
// Render Animation Tree Registry window
if (m_showAnimationTreeRegistry) {
m_animationTreeRegistryEditor.render(
&m_showAnimationTreeRegistry);
}
// Render Inventory Dialog Config window
if (m_showInventoryConfig) {
renderInventoryConfigWindow();
@@ -490,6 +641,10 @@ void EditorUISystem::renderHierarchyWindow()
if (ImGui::MenuItem("Item Registry")) {
m_showItemRegistry = true;
}
if (ImGui::MenuItem(
"Animation Tree Registry")) {
m_showAnimationTreeRegistry = true;
}
ImGui::Separator();
if (ImGui::MenuItem(
"Inventory Dialog Config")) {
@@ -997,6 +1152,13 @@ void EditorUISystem::renderComponentList(flecs::entity entity)
componentCount++;
}
// Render Terrain if present
if (entity.has<TerrainComponent>()) {
auto &tc = entity.get_mut<TerrainComponent>();
m_componentRegistry.render<TerrainComponent>(entity, tc);
componentCount++;
}
// Render LOD Settings if present
if (entity.has<LodSettingsComponent>()) {
auto &lodSettings = entity.get_mut<LodSettingsComponent>();
@@ -1087,7 +1249,7 @@ void EditorUISystem::renderComponentList(flecs::entity entity)
if (entity.has<CharacterSpawnerComponent>()) {
auto &spawner = entity.get_mut<CharacterSpawnerComponent>();
m_componentRegistry.render<CharacterSpawnerComponent>(entity,
spawner);
spawner);
componentCount++;
}
@@ -1098,14 +1260,6 @@ void EditorUISystem::renderComponentList(flecs::entity entity)
componentCount++;
}
// Render AnimationTreeTemplate if present
if (entity.has<AnimationTreeTemplate>()) {
auto &templ = entity.get_mut<AnimationTreeTemplate>();
m_componentRegistry.render<AnimationTreeTemplate>(entity,
templ);
componentCount++;
}
// Render StartupMenu if present
if (entity.has<StartupMenuComponent>()) {
auto &sm = entity.get_mut<StartupMenuComponent>();
@@ -2343,3 +2497,276 @@ void EditorUISystem::renderDialogueSettingsWindow()
ImGui::End();
}
/* --- Road editing helpers (M5.2) --- */
void EditorUISystem::updateRoadEditMode()
{
TerrainSystem *ts = TerrainSystem::getInstance();
const bool roadEdit = ts && ts->getRoadEditMode();
if (roadEdit == m_lastRoadEditMode) {
if (roadEdit) {
/* While the gizmo is dragged, move the node every frame
* so the graph follows live; otherwise keep the gizmo
* glued to the current selection. */
if (m_roadGizmo && m_roadGizmo->isDragging())
applyRoadGizmoDrag(false);
else
syncRoadGizmoToSelection();
}
return;
}
m_lastRoadEditMode = roadEdit;
m_roadClickPending = false;
if (roadEdit) {
// Disable the regular entity gizmo while editing roads.
if (m_gizmo)
m_gizmo->detach();
if (m_roadGizmo)
m_roadGizmo->setVisible(true);
syncRoadGizmoToSelection();
} else {
if (m_roadGizmo)
m_roadGizmo->setVisible(false);
// Restore regular gizmo for the selected entity.
if (m_gizmo) {
if (m_selectedEntity.is_alive() &&
m_selectedEntity.has<TransformComponent>())
m_gizmo->attachTo(m_selectedEntity);
else
m_gizmo->detach();
}
}
}
void EditorUISystem::syncRoadGizmoToSelection()
{
if (!m_roadGizmo)
return;
TerrainSystem *ts = TerrainSystem::getInstance();
if (!ts || !ts->getRoadEditMode())
return;
RoadSystem *rs = ts->getRoadSystem();
if (!rs) {
m_roadGizmo->setVisible(false);
return;
}
int nodeId = rs->getSelectedNodeId();
if (nodeId < 0) {
m_roadGizmo->setVisible(false);
return;
}
flecs::entity terrain = rs->getTerrainEntity();
if (!terrain.is_alive() || !terrain.has<TerrainComponent>()) {
m_roadGizmo->setVisible(false);
return;
}
const TerrainComponent &tc = terrain.get<TerrainComponent>();
const RoadNode *node = tc.roadGraph.findNodeById(nodeId);
if (!node) {
m_roadGizmo->setVisible(false);
return;
}
m_roadGizmo->setPosition(node->position);
m_roadGizmo->setVisible(true);
}
void EditorUISystem::applyRoadGizmoDrag(bool snapGizmoBack)
{
TerrainSystem *ts = TerrainSystem::getInstance();
if (!ts || !ts->getRoadEditMode() || !m_roadGizmo)
return;
RoadSystem *rs = ts->getRoadSystem();
if (!rs)
return;
int nodeId = rs->getSelectedNodeId();
if (nodeId < 0)
return;
flecs::entity terrain = rs->getTerrainEntity();
if (!terrain.is_alive() || !terrain.has<TerrainComponent>())
return;
auto &tc = terrain.get_mut<TerrainComponent>();
RoadNode *node = tc.roadGraph.findNodeById(nodeId);
if (!node)
return;
Ogre::Vector3 newPos = m_roadGizmo->getPosition();
float terrainY = ts->getHeightAt(newPos);
node->position.x = newPos.x;
node->position.z = newPos.z;
node->position.y = terrainY + node->verticalOffset;
if (snapGizmoBack) {
// Snap the gizmo back to the snapped node surface position.
m_roadGizmo->setPosition(node->position);
}
tc.roadGraph.bumpVersion();
}
void EditorUISystem::handleRoadEditClick(const Ogre::Ray &mouseRay)
{
TerrainSystem *ts = TerrainSystem::getInstance();
if (!ts || !ts->getRoadEditMode())
return;
RoadSystem *rs = ts->getRoadSystem();
if (!rs)
return;
float t;
Ogre::Vector3 normal;
if (!ts->raycastTerrain(mouseRay, t, normal))
return;
Ogre::Vector3 hit = mouseRay.getPoint(t);
int nodeId = pickRoadNode(hit);
if (nodeId >= 0) {
rs->setSelectedNodeId(nodeId);
rs->setSelectedEdgeIndex(-1);
syncRoadGizmoToSelection();
return;
}
int edgeIdx = pickRoadEdge(hit);
if (edgeIdx >= 0) {
rs->setSelectedEdgeIndex(edgeIdx);
rs->setSelectedNodeId(-1);
m_roadGizmo->setVisible(false);
return;
}
// Click on empty terrain: create a node when the Add Node tool is
// active; in Move mode just clear the selection.
if (ts->getRoadEditTool() == TerrainSystem::RoadEditTool::AddNode) {
addRoadNodeAt(hit);
} else {
rs->setSelectedNodeId(-1);
rs->setSelectedEdgeIndex(-1);
if (m_roadGizmo)
m_roadGizmo->setVisible(false);
}
}
int EditorUISystem::pickRoadNode(const Ogre::Vector3 &hit) const
{
TerrainSystem *ts = TerrainSystem::getInstance();
if (!ts)
return -1;
RoadSystem *rs = ts->getRoadSystem();
if (!rs)
return -1;
flecs::entity terrain = rs->getTerrainEntity();
if (!terrain.is_alive() || !terrain.has<TerrainComponent>())
return -1;
const TerrainComponent &tc = terrain.get<TerrainComponent>();
float threshold = std::max(tc.roadGraph.config.laneWidth, 2.0f);
float bestDist = threshold * threshold;
int bestId = -1;
for (const auto &n : tc.roadGraph.nodes) {
Ogre::Vector3 d = n.position - hit;
float distSq = d.squaredLength();
if (distSq < bestDist) {
bestDist = distSq;
bestId = n.id;
}
}
return bestId;
}
int EditorUISystem::pickRoadEdge(const Ogre::Vector3 &hit) const
{
TerrainSystem *ts = TerrainSystem::getInstance();
if (!ts)
return -1;
RoadSystem *rs = ts->getRoadSystem();
if (!rs)
return -1;
flecs::entity terrain = rs->getTerrainEntity();
if (!terrain.is_alive() || !terrain.has<TerrainComponent>())
return -1;
const TerrainComponent &tc = terrain.get<TerrainComponent>();
float threshold = std::max(tc.roadGraph.config.laneWidth, 2.0f);
float bestDist = threshold * threshold;
int bestIdx = -1;
for (size_t i = 0; i < tc.roadGraph.edges.size(); ++i) {
const RoadEdge &e = tc.roadGraph.edges[i];
const RoadNode *na = tc.roadGraph.findNodeById(e.nodeA);
const RoadNode *nb = tc.roadGraph.findNodeById(e.nodeB);
if (!na || !nb)
continue;
Ogre::Vector3 ab = nb->position - na->position;
Ogre::Vector3 ah = hit - na->position;
float abLenSq = ab.squaredLength();
if (abLenSq < 0.0001f)
continue;
float t = std::max(0.0f, std::min(1.0f,
ah.dotProduct(ab) / abLenSq));
Ogre::Vector3 closest = na->position + ab * t;
float distSq = (closest - hit).squaredLength();
if (distSq < bestDist) {
bestDist = distSq;
bestIdx = (int)i;
}
}
return bestIdx;
}
Ogre::Vector3 EditorUISystem::snapRoadNodePosition(const Ogre::Vector3 &pos) const
{
TerrainSystem *ts = TerrainSystem::getInstance();
if (!ts)
return pos;
float y = ts->getHeightAt(pos);
return Ogre::Vector3(pos.x, y, pos.z);
}
void EditorUISystem::addRoadNodeAt(const Ogre::Vector3 &pos)
{
TerrainSystem *ts = TerrainSystem::getInstance();
if (!ts || !ts->getRoadEditMode())
return;
RoadSystem *rs = ts->getRoadSystem();
if (!rs)
return;
flecs::entity terrain = rs->getTerrainEntity();
if (!terrain.is_alive() || !terrain.has<TerrainComponent>())
return;
auto &tc = terrain.get_mut<TerrainComponent>();
Ogre::Vector3 snapped = snapRoadNodePosition(pos);
int newId = tc.roadGraph.addNode(snapped, 0.0f);
rs->setSelectedNodeId(newId);
rs->setSelectedEdgeIndex(-1);
syncRoadGizmoToSelection();
}
@@ -10,8 +10,10 @@
#include "../ui/ComponentRegistry.hpp"
#include "../ui/ActionDatabaseSingletonEditor.hpp"
#include "../ui/CharacterClassDatabaseEditor.hpp"
#include "../ui/AnimationTreeRegistryEditor.hpp"
#include "../components/EntityName.hpp"
#include "../gizmo/Gizmo.hpp"
#include "../gizmo/RoadGizmo.hpp"
#include "../gizmo/Cursor3D.hpp"
#include "SceneSerializer.hpp"
#include "CharacterRegistry.hpp"
@@ -257,6 +259,7 @@ private:
ComponentRegistry m_componentRegistry;
std::vector<flecs::entity> m_allEntities;
std::unique_ptr<Gizmo> m_gizmo;
std::unique_ptr<RoadGizmo> m_roadGizmo;
std::unique_ptr<Cursor3D> m_cursor3D;
std::unique_ptr<SceneSerializer> m_serializer;
@@ -347,6 +350,30 @@ private:
// Item registry
bool m_showItemRegistry = false;
// Animation tree registry
bool m_showAnimationTreeRegistry = false;
AnimationTreeRegistryEditor m_animationTreeRegistryEditor;
// Road editing (M5.2)
void updateRoadEditMode();
void syncRoadGizmoToSelection();
void applyRoadGizmoDrag(bool snapGizmoBack);
void handleRoadEditClick(const Ogre::Ray &mouseRay);
int pickRoadNode(const Ogre::Vector3 &hit) const;
int pickRoadEdge(const Ogre::Vector3 &hit) const;
Ogre::Vector3 snapRoadNodePosition(const Ogre::Vector3 &pos) const;
void addRoadNodeAt(const Ogre::Vector3 &pos);
bool m_lastRoadEditMode = false;
bool m_roadGizmoDragged = false;
/* Pending left-click in road edit mode. The press is recorded and
* resolved on release: movement below 5 pixels is a click, anything
* more is treated as a camera drag (M5.2). */
bool m_roadClickPending = false;
Ogre::Vector2 m_roadMouseDownPos = Ogre::Vector2::ZERO;
Ogre::Ray m_roadMouseDownRay;
// Queries
flecs::query<EntityNameComponent> m_nameQuery;
@@ -126,7 +126,7 @@ void PauseMenuSystem::renderMenu()
ImGuiWindowFlags_NoFocusOnAppearing);
if (m_menuFont)
ImGui::PushFont(m_menuFont);
ImGui::PushFont(m_menuFont, m_menuFont->LegacySize);
struct ButtonData {
const char *label;
@@ -85,6 +85,13 @@ bool ProceduralMeshSystem::arePrimitivesDirty(flecs::entity parent)
void ProceduralMeshSystem::buildTriangleBuffer(flecs::entity entity, TriangleBufferComponent& tb)
{
// Directly-filled buffers (e.g. road geometry) have no Primitive
// children; keep their contents and just clear the dirty flag.
if (tb.proceduralContent) {
tb.dirty = false;
return;
}
// Create new triangle buffer
tb.buffer = std::make_shared<Procedural::TriangleBuffer>();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,317 @@
#ifndef EDITSCENE_ROADSYSTEM_HPP
#define EDITSCENE_ROADSYSTEM_HPP
#pragma once
#include <Ogre.h>
#include <OgreManualObject.h>
#include <ProceduralTriangleBuffer.h>
#include <flecs.h>
#include <Jolt/Jolt.h>
#include <Jolt/Physics/Body/BodyID.h>
#include <cstdint>
#include <memory>
#include <string>
#include <unordered_map>
#include <vector>
#include "../components/RoadGraph.hpp"
namespace Ogre {
class TerrainGroup;
}
struct RoadConfig;
class JoltPhysicsWrapper;
/**
* Per-page road geometry state (M5.4).
*
* One entry exists for every loaded terrain page. The page generates the
* wedges and straight segments seeded by road nodes inside its bounds (see
* the page-assignment decision in TerrainRequirements.md, M5.4). Runtime
* objects (mesh entity, collider, roadside prefabs) are created in later
* milestones and destroyed together when the page unloads.
*/
struct RoadPageGeometry {
long pageX = 0;
long pageY = 0;
/** Entity holding this page's road TriangleBufferComponent (M5.8). */
flecs::entity bufferEntity = flecs::entity::null();
/** Entity holding this page's road collider body (M5.9). */
flecs::entity colliderEntity = flecs::entity::null();
/**
* Jolt body of this page's static road collider (M5.9), created from
* the page's render mesh once it exists. Invalid when no collider
* has been created.
*/
JPH::BodyID bodyId;
/** Collision soup mirroring the generated road triangles (M5.9). */
std::vector<Ogre::Vector3> collisionVertices;
std::vector<uint32_t> collisionIndices;
/** Roadside prefab instances spawned for this page (M5.11). */
std::vector<flecs::entity> spawnedPrefabs;
/** Wedges and straight segments seeded by nodes inside this page. */
std::vector<RoadWedge> wedges;
std::vector<RoadStraightSegment> segments;
/** Geometry needs (re)generation. */
bool dirty = true;
/**
* Rendering distance, navmesh contribution and RenderableComponent
* pointer have been applied to the created Ogre mesh (M5.8). Reset
* whenever the page mesh is (re)built.
*/
bool meshFinalized = false;
};
/**
* RoadSystem runtime owner of road visual aids, per-page road geometry
* state, and (future) road mesh generation.
*
* The class builds and updates ManualObject overlays for the road graph:
* node markers, edge lines, road-width indicators, and selection
* highlights. It also tracks terrain page load/unload and buckets the
* enumerated wedges/straight segments by the page containing their seed
* node (M5.4). It is created by TerrainSystem when a terrain is activated
* and destroyed when the terrain deactivates.
*/
class RoadSystem {
public:
RoadSystem(flecs::world &world, Ogre::SceneManager *sceneMgr);
~RoadSystem();
/**
* Associate the system with the active terrain entity.
*
* The entity must have a TerrainComponent. Passing a null entity clears
* the association and destroys any visual aids.
*/
void setTerrainEntity(flecs::entity entity);
/**
* Clear the associated terrain entity and remove visual aids.
*/
void clear();
/**
* Rebuild visual aids if the road graph has changed.
*
* Should be called from TerrainSystem::update() each frame while the
* terrain is active.
*/
void update(float deltaTime);
/** Show or hide all road visual aids. */
void setVisualAidsVisible(bool visible);
bool getVisualAidsVisible() const;
/** Selection state used by the road editor UI. */
int getSelectedNodeId() const { return m_selectedNodeId; }
void setSelectedNodeId(int id);
int getSelectedEdgeIndex() const { return m_selectedEdgeIndex; }
void setSelectedEdgeIndex(int idx);
/** Wedge-debug mode for geometry inspection. */
bool getDebugWedgeEnabled() const { return m_debugWedgeEnabled; }
void setDebugWedgeEnabled(bool v);
int getDebugWedgeIndex() const { return m_debugWedgeIndex; }
void setDebugWedgeIndex(int idx);
/** The terrain entity this system is bound to. */
flecs::entity getTerrainEntity() const;
/**
* Road mesh template (M5.3).
*
* Returns the template road segment as a Procedural::TriangleBuffer in
* template space: X in [0, 1] along the edge, Y in
* [-roadThickness/2, +roadThickness/2], Z in [0, 1] across the road
* (+Z = right of the A->B travel direction), UVs spanning (0,0)-(1,1)
* over the X/Z extents.
*
* The template is loaded from cfg.roadMeshTemplate (General resource
* group). A missing or empty mesh falls back to a generated unit box
* (the supported prototyping path no asset required). The buffer is
* cached and rebuilt only when cfg.roadMeshTemplate or
* cfg.roadThickness changes.
*/
const Procedural::TriangleBuffer &getRoadTemplate(const RoadConfig &cfg);
/**
* Geometry generation (M5.6).
*
* Builds the world-space road slab for one wedge or one straight
* segment and appends it to @ out. The slab has top and bottom
* surfaces at +/- roadThickness/2 around the interpolated road level
* and side skirts along exposed edges (curbs and endpoint caps).
*
* Static so headless tests can call them without a scene. Returns
* false when the primitive is degenerate and nothing was emitted
* (e.g. a wedge wider than 270 degrees).
*/
static bool buildWedgeGeometry(const RoadWedge &wedge,
const RoadGraph &graph,
Procedural::TriangleBuffer &out);
static bool buildSegmentGeometry(const RoadStraightSegment &segment,
const RoadGraph &graph,
Procedural::TriangleBuffer &out);
/**
* Bind the terrain group used for page tracking (M5.4).
*
* Called by TerrainSystem right after construction. Passing nullptr
* detaches and drops all per-page state.
*/
void setTerrainGroup(Ogre::TerrainGroup *group);
/**
* Provide the physics wrapper used for road colliders (M5.9).
*
* Called by TerrainSystem right after construction. Passing nullptr
* drops all colliders and disables collider creation.
*/
void setPhysics(JoltPhysicsWrapper *physics);
/**
* Provide the terrain system for height queries (M5.11).
*
* Called by TerrainSystem right after construction. Passing nullptr
* disables roadside prefab spawning.
*/
void setTerrainSystem(class TerrainSystem *terrainSystem);
/**
* Terrain compliance (M5.10): writes fixup values under every
* road surface vertex so the terrain matches the road underside.
*
* @param terrainSystem the active TerrainSystem that owns the
* fixup layer (used to call writeFixup + markPageDirty).
* @param roadThickness vertical thickness of the road slab.
* @param laneWidth falloff is measured in lane widths.
*/
void complyTerrain(class TerrainSystem *terrainSystem,
float roadThickness, float laneWidth);
/** Road physics body IDs for debug-draw filtering. */
const std::set<JPH::BodyID> &getRoadBodyIds() const
{
return m_roadBodyIds;
}
/** Compute target terrain height for road compliance falloff. */
static float computeComplianceHeight(
float roadSurfaceY, float roadThickness,
float baseHeight, float lateralDistance,
float halfRoadWidth, float fadeWidth);
/**
* Per-page road geometry state, keyed by
* TerrainGroup::packIndex(pageX, pageY) (M5.4).
*
* Exposed for headless tests and for the M5.8 mesh assembly.
*/
const std::unordered_map<uint64_t, RoadPageGeometry> &
getPageGeometry() const
{
return m_pageGeometry;
}
private:
void createManualObjects();
void destroyManualObjects();
void rebuildVisualAids();
void buildNodeMarkers();
void buildEdgeLines();
void buildWidthIndicators();
void buildSelectionHighlight();
/* Page tracking (M5.4). */
void syncPages();
void reassignWedges();
void applyPageBucket(uint64_t key, RoadPageGeometry &pg);
void destroyPageGeometry(RoadPageGeometry &pg);
void clearPageGeometry();
bool pageKeyForNode(int nodeId, uint64_t &outKey) const;
/* Mesh assembly (M5.8). */
void buildPageMeshes(RoadPageGeometry &pg);
void destroyPageMeshes(RoadPageGeometry &pg);
flecs::entity ensureRoadMaterial(const RoadConfig &cfg);
flecs::entity ensureRoadLodSettings(const RoadConfig &cfg);
void markNavMeshDirty();
/* Physics colliders (M5.9). */
void createPageCollider(RoadPageGeometry &pg, const std::string &meshName);
void destroyPageCollider(RoadPageGeometry &pg);
/* Roadside prefab spawning (M5.11). */
void spawnSidePrefabs(RoadPageGeometry &pg);
bool loadTemplateFromMesh(const std::string &meshName);
void buildFallbackTemplate(float roadThickness);
Ogre::Vector3 getNodePosition(int nodeId) const;
bool getEdgePositions(int edgeIndex, Ogre::Vector3 &outA,
Ogre::Vector3 &outB) const;
flecs::world &m_world;
Ogre::SceneManager *m_sceneMgr;
flecs::entity_t m_terrainEntityId = 0;
Ogre::TerrainGroup *m_terrainGroup = nullptr;
bool m_visualAidsVisible = true;
int m_selectedNodeId = -1;
int m_selectedEdgeIndex = -1;
uint64_t m_lastGraphVersion = 0;
/* Page geometry state (M5.4). m_wedgeBuckets holds the enumerated
* wedges/segments bucketed by seed-node page for ALL pages (including
* unloaded ones); m_pageGeometry only tracks loaded pages. */
std::unordered_map<uint64_t, RoadPageGeometry> m_pageGeometry;
std::unordered_map<uint64_t, RoadPageGeometry> m_wedgeBuckets;
uint64_t m_geometryGraphVersion = 0;
/** Shared road material entity (M5.8), created lazily. */
flecs::entity m_materialEntity = flecs::entity::null();
/** Shared road LOD settings entity (M5.8), created lazily. */
flecs::entity m_lodSettingsEntity = flecs::entity::null();
/** Physics wrapper for road colliders (M5.9), may be null. */
std::set<JPH::BodyID> m_roadBodyIds;
JoltPhysicsWrapper *m_physics = nullptr;
/** Terrain system for height queries (M5.11), may be null. */
class TerrainSystem *m_terrainSystem = nullptr;
Procedural::TriangleBuffer m_templateBuffer;
std::string m_templateName;
float m_templateThickness = -1.0f;
/* Wedge-debug visualization (ManualObject). */
bool m_debugWedgeEnabled = false;
int m_debugWedgeIndex = -1;
int m_lastDebugNodeId = -1;
int m_lastDebugWedgeIndex = -2;
uint64_t m_lastDebugGraphVersion = 0;
Ogre::ManualObject *m_debugWedgeObject = nullptr;
void rebuildDebugWedge();
Ogre::SceneNode *m_visualNode = nullptr;
Ogre::ManualObject *m_nodeMarkers = nullptr;
Ogre::ManualObject *m_edgeLines = nullptr;
Ogre::ManualObject *m_widthIndicators = nullptr;
Ogre::ManualObject *m_selectionHighlight = nullptr;
};
#endif // EDITSCENE_ROADSYSTEM_HPP
+330 -158
View File
@@ -24,7 +24,8 @@
#include "../components/CharacterIdentity.hpp"
#include "CharacterRegistry.hpp"
#include "../components/AnimationTree.hpp"
#include "../components/AnimationTreeTemplate.hpp"
#include "AnimationTreeRegistry.hpp"
#include "AnimationTreeRegistry.hpp"
#include "../components/StartupMenu.hpp"
#include "../components/PlayerController.hpp"
#include "../components/CellGrid.hpp"
@@ -32,6 +33,8 @@
#include "../components/BuoyancyInfo.hpp"
#include "../components/WaterPhysics.hpp"
#include "../components/WaterPlane.hpp"
#include "../components/Terrain.hpp"
#include "TerrainSystem.hpp"
#include "../components/Sun.hpp"
#include "../components/Skybox.hpp"
#include "../components/EventHandler.hpp"
@@ -263,11 +266,6 @@ nlohmann::json SceneSerializer::serializeEntity(flecs::entity entity)
json["animationTree"] = serializeAnimationTree(entity);
}
if (entity.has<AnimationTreeTemplate>()) {
json["animationTreeTemplate"] =
serializeAnimationTreeTemplate(entity);
}
if (entity.has<StartupMenuComponent>()) {
json["startupMenu"] = serializeStartupMenu(entity);
}
@@ -324,6 +322,10 @@ nlohmann::json SceneSerializer::serializeEntity(flecs::entity entity)
json["waterPlane"] = serializeWaterPlane(entity);
}
if (entity.has<TerrainComponent>()) {
json["terrain"] = serializeTerrain(entity);
}
// ActionDatabase is now a singleton, serialized at scene level
if (entity.has<ActionDebug>()) {
json["actionDebug"] = serializeActionDebug(entity);
@@ -477,15 +479,13 @@ void SceneSerializer::deserializeEntity(const nlohmann::json &json,
deserializeCharacter(entity, json["character"]);
}
/* Deserialize identity before slots so legacy slot data migrates into
* the correct registry record. */
if (json.contains("characterIdentity")) {
deserializeCharacterIdentity(entity, json["characterIdentity"]);
}
if (json.contains("characterSlots")) {
deserializeCharacterSlots(entity, json["characterSlots"]);
}
/* CharacterSlots -> CharacterRegistry migration is disabled; all scenes
* have been migrated. */
(void)0;
if (json.contains("characterSpawner")) {
deserializeCharacterSpawner(entity, json["characterSpawner"]);
@@ -495,10 +495,9 @@ void SceneSerializer::deserializeEntity(const nlohmann::json &json,
deserializeAnimationTree(entity, json["animationTree"]);
}
if (json.contains("animationTreeTemplate")) {
deserializeAnimationTreeTemplate(entity,
json["animationTreeTemplate"]);
}
/* AnimationTreeTemplate migration is disabled; all scenes have been
* migrated to AnimationTreeRegistry. */
(void)0;
if (json.contains("startupMenu")) {
deserializeStartupMenu(entity, json["startupMenu"]);
@@ -559,6 +558,7 @@ void SceneSerializer::deserializeEntity(const nlohmann::json &json,
if (json.contains("waterPlane")) {
deserializeWaterPlane(entity, json["waterPlane"]);
}
if (json.contains("terrain")) deserializeTerrain(entity, json["terrain"]);
// ActionDatabase is now a singleton, deserialized at scene level
if (json.contains("actionDebug")) {
@@ -716,15 +716,13 @@ void SceneSerializer::deserializeEntityComponents(
deserializeCharacter(entity, json["character"]);
}
/* Deserialize identity before slots so legacy slot data migrates into
* the correct registry record. */
if (json.contains("characterIdentity")) {
deserializeCharacterIdentity(entity, json["characterIdentity"]);
}
if (json.contains("characterSlots")) {
deserializeCharacterSlots(entity, json["characterSlots"]);
}
/* CharacterSlots -> CharacterRegistry migration is disabled; all scenes
* have been migrated. */
(void)0;
if (json.contains("characterSpawner")) {
deserializeCharacterSpawner(entity, json["characterSpawner"]);
@@ -734,10 +732,9 @@ void SceneSerializer::deserializeEntityComponents(
deserializeAnimationTree(entity, json["animationTree"]);
}
if (json.contains("animationTreeTemplate")) {
deserializeAnimationTreeTemplate(entity,
json["animationTreeTemplate"]);
}
/* AnimationTreeTemplate migration is disabled; all scenes have been
* migrated to AnimationTreeRegistry. */
(void)0;
if (json.contains("startupMenu")) {
deserializeStartupMenu(entity, json["startupMenu"]);
@@ -868,6 +865,7 @@ void SceneSerializer::deserializeEntityComponents(
if (json.contains("waterPlane")) {
deserializeWaterPlane(entity, json["waterPlane"]);
}
if (json.contains("terrain")) deserializeTerrain(entity, json["terrain"]);
// ActionDatabase is now a singleton, deserialized at scene level
if (json.contains("actionDebug")) {
@@ -2294,11 +2292,6 @@ void SceneSerializer::deserializeCharacterSpawner(flecs::entity entity,
entity.set<CharacterSpawnerComponent>(spawner);
}
/* Forward declarations for AnimationTreeNode serialization */
static nlohmann::json serializeAnimationTreeNode(const AnimationTreeNode &node);
static void deserializeAnimationTreeNode(AnimationTreeNode &node,
const nlohmann::json &json);
nlohmann::json SceneSerializer::serializeCharacterSlots(flecs::entity entity)
{
/* CharacterSlotsComponent is deprecated and no longer saved. */
@@ -2309,135 +2302,43 @@ nlohmann::json SceneSerializer::serializeCharacterSlots(flecs::entity entity)
void SceneSerializer::deserializeCharacterSlots(flecs::entity entity,
const nlohmann::json &json)
{
/* Migrate legacy CharacterSlots data into the character registry.
* The empty component is added as a placeholder reminder. */
uint64_t registryId = 0;
if (entity.has<CharacterIdentityComponent>())
registryId = entity.get<CharacterIdentityComponent>().registryId;
if (registryId == 0) {
registryId = CharacterRegistry::getSingleton().createCharacter(
"Migrated", "Character", "", true);
entity.set<CharacterIdentityComponent>(
CharacterIdentityComponent{ registryId });
}
CharacterRegistry::CharacterRecord *rec =
CharacterRegistry::getSingleton().findCharacter(registryId);
if (rec) {
rec->inlineSex = json.value("sex", rec->inlineSex);
if (json.contains("slots") && json["slots"].is_object()) {
for (auto &[slot, mesh] : json["slots"].items()) {
SlotSelection sel;
sel.explicitMesh = mesh.get<std::string>();
rec->inlineSlotSelections[slot] = sel;
}
}
if (json.contains("slotSelections") &&
json["slotSelections"].is_object()) {
for (auto &[slot, selJson] :
json["slotSelections"].items()) {
SlotSelection sel;
if (selJson.contains("layer0Mesh"))
sel.layer0Mesh =
selJson.value("layer0Mesh", "");
if (selJson.contains("layer1Mesh"))
sel.layer1Mesh =
selJson.value("layer1Mesh", "");
if (selJson.contains("layer2Mesh"))
sel.layer2Mesh =
selJson.value("layer2Mesh", "");
if (selJson.contains("layer") &&
sel.layer1Mesh.empty() &&
sel.layer2Mesh.empty()) {
int oldLayer = selJson.value("layer", 2);
if (oldLayer == 1)
sel.layer1Mesh = "auto";
else if (oldLayer >= 2)
sel.layer2Mesh = "auto";
}
sel.explicitMesh =
selJson.value("explicitMesh", "");
rec->inlineSlotSelections[slot] = sel;
}
}
if (json.contains("frontAxis") &&
json["frontAxis"].is_array() &&
json["frontAxis"].size() >= 3) {
Ogre::Vector3 fa(
json["frontAxis"][0].get<float>(),
json["frontAxis"][1].get<float>(),
json["frontAxis"][2].get<float>());
if (fa.squaredLength() > 0.0001f)
fa.normalise();
rec->frontAxis = fa;
}
CharacterRegistry::getSingleton().autoSave();
}
entity.add<CharacterSlotsComponent>();
CharacterRegistry::getSingleton().markCharacterDirty(registryId);
/* CharacterSlots -> CharacterRegistry migration is disabled. */
(void)entity;
(void)json;
}
static nlohmann::json serializeAnimationTreeNode(const AnimationTreeNode &node)
/* Migration disabled: legacy inline animation trees are no longer
* automatically imported into the registry. */
#if 0
static Ogre::String migrateInlineAnimationTree(
const nlohmann::json &json, const Ogre::String &preferredName)
{
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()) {
json["endTransitions"] = nlohmann::json::object();
for (const auto &pair : node.endTransitions)
json["endTransitions"][pair.first] = pair.second;
}
if (!node.children.empty()) {
json["children"] = nlohmann::json::array();
for (const auto &child : node.children)
json["children"].push_back(
serializeAnimationTreeNode(child));
}
return json;
}
AnimationTreeNode root;
if (json.contains("root"))
animationTreeNodeFromJson(root, json["root"]);
static void deserializeAnimationTreeNode(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 &[key, val] : json["endTransitions"].items())
node.endTransitions[key] = val.get<std::string>();
}
node.children.clear();
if (json.contains("children") && json["children"].is_array()) {
for (const auto &childJson : json["children"]) {
AnimationTreeNode child;
deserializeAnimationTreeNode(child, childJson);
node.children.push_back(child);
}
}
AnimationTreeRegistry &reg = AnimationTreeRegistry::getSingleton();
Ogre::String name = preferredName;
if (name.empty())
name = reg.generateUniqueName("migrated");
else if (reg.hasTree(name))
name = reg.generateUniqueName(name);
AnimationTreeRegistry::AnimationTreeDefinition def;
def.name = name;
def.root = root;
reg.registerTree(def);
return name;
}
#endif
nlohmann::json SceneSerializer::serializeAnimationTree(flecs::entity entity)
{
auto &at = entity.get<AnimationTreeComponent>();
nlohmann::json json;
json["treeName"] = at.treeName;
json["enabled"] = at.enabled;
json["useRootMotion"] = at.useRootMotion;
if (!at.templateName.empty())
json["templateName"] = at.templateName;
json["root"] = serializeAnimationTreeNode(at.root);
return json;
}
@@ -2447,30 +2348,74 @@ void SceneSerializer::deserializeAnimationTree(flecs::entity entity,
AnimationTreeComponent at;
at.enabled = json.value("enabled", true);
at.useRootMotion = json.value("useRootMotion", false);
at.templateName = json.value("templateName", "");
if (json.contains("root"))
deserializeAnimationTreeNode(at.root, json["root"]);
at.dirty = true;
if (json.contains("treeName")) {
/* Current format. */
at.treeName = json.value("treeName", "");
}
/* Legacy inline tree / templateName migration is disabled. */
#if 0
else if (json.contains("root")) {
/* Legacy inline tree: register in registry. */
Ogre::String preferred = json.value("templateName", "");
at.treeName = migrateInlineAnimationTree(json, preferred);
} else if (json.contains("templateName")) {
/* Legacy template reference: ensure registry entry exists. */
at.treeName = json.value("templateName", "");
AnimationTreeRegistry &reg =
AnimationTreeRegistry::getSingleton();
if (!at.treeName.empty() && !reg.hasTree(at.treeName)) {
AnimationTreeRegistry::AnimationTreeDefinition def;
def.name = at.treeName;
reg.registerTree(def);
}
}
#endif
entity.set<AnimationTreeComponent>(at);
}
nlohmann::json
SceneSerializer::serializeAnimationTreeTemplate(flecs::entity entity)
{
auto &templ = entity.get<AnimationTreeTemplate>();
nlohmann::json json;
json["name"] = templ.name;
json["version"] = templ.version;
return json;
/* AnimationTreeTemplate is no longer saved. */
(void)entity;
return nlohmann::json::object();
}
void SceneSerializer::deserializeAnimationTreeTemplate(
flecs::entity entity, const nlohmann::json &json)
{
AnimationTreeTemplate templ;
templ.name = json.value("name", "");
templ.version = json.value("version", 1);
entity.set<AnimationTreeTemplate>(templ);
/* AnimationTreeTemplate has been migrated into AnimationTreeRegistry.
* If the entity already has an AnimationTreeComponent, update its
* treeName to the template name and copy the tree data into the
* registry. Otherwise just ensure a registry placeholder exists. */
Ogre::String templName = json.value("name", "");
if (templName.empty())
return;
AnimationTreeRegistry &reg = AnimationTreeRegistry::getSingleton();
if (entity.has<AnimationTreeComponent>()) {
auto &at = entity.get_mut<AnimationTreeComponent>();
const AnimationTreeRegistry::AnimationTreeDefinition *def =
reg.findTree(at.treeName);
if (def) {
AnimationTreeRegistry::AnimationTreeDefinition renamed =
*def;
renamed.name = templName;
reg.removeTree(at.treeName);
reg.registerTree(renamed);
}
at.treeName = templName;
} else {
if (!reg.hasTree(templName)) {
AnimationTreeRegistry::AnimationTreeDefinition def;
def.name = templName;
reg.registerTree(def);
}
}
}
// ============================================================================
@@ -4192,3 +4137,230 @@ void SceneSerializer::deserializeInventory(flecs::entity entity,
}
}
}
/* ------------------------------------------------------------------ */
/* TerrainComponent serialization */
/* ------------------------------------------------------------------ */
nlohmann::json SceneSerializer::serializeTerrain(flecs::entity entity)
{
const TerrainComponent &tc = entity.get<TerrainComponent>();
nlohmann::json json;
json["enabled"] = tc.enabled;
json["terrainSize"] = tc.terrainSize;
json["terrainId"] = tc.terrainId;
json["heightmapSize"] = tc.heightmapSize;
json["blendMapSize"] = tc.blendMapSize;
json["worldSize"] = tc.worldSize;
json["maxPixelError"] = tc.maxPixelError;
json["compositeMapDistance"] = tc.compositeMapDistance;
json["minBatchSize"] = tc.minBatchSize;
json["maxBatchSize"] = tc.maxBatchSize;
json["heightmapFile"] = tc.heightmapFile;
nlohmann::json layersJson = nlohmann::json::array();
for (auto &l : tc.layers) {
nlohmann::json lj;
lj["name"] = l.name;
lj["diffuseTexture"] = l.diffuseTexture;
lj["normalTexture"] = l.normalTexture;
lj["worldSize"] = l.worldSize;
layersJson.push_back(lj);
}
json["layers"] = layersJson;
nlohmann::json auxMapsJson = nlohmann::json::array();
for (auto &am : tc.auxMaps) {
nlohmann::json aj;
aj["name"] = am.name;
aj["fileName"] = am.fileName;
aj["resolution"] = am.resolution;
aj["defaultValue"] = am.defaultValue;
auxMapsJson.push_back(aj);
}
json["auxMaps"] = auxMapsJson;
nlohmann::json detailNoiseJson;
detailNoiseJson["enabled"] = tc.detailNoise.enabled;
detailNoiseJson["seed"] = tc.detailNoise.seed;
detailNoiseJson["octaves"] = tc.detailNoise.octaves;
detailNoiseJson["frequency"] = tc.detailNoise.frequency;
detailNoiseJson["amplitude"] = tc.detailNoise.amplitude;
detailNoiseJson["lacunarity"] = tc.detailNoise.lacunarity;
detailNoiseJson["persistence"] = tc.detailNoise.persistence;
json["detailNoise"] = detailNoiseJson;
nlohmann::json roadConfigJson;
roadConfigJson["roadMeshTemplate"] = tc.roadGraph.config.roadMeshTemplate;
roadConfigJson["laneWidth"] = tc.roadGraph.config.laneWidth;
roadConfigJson["lanesPerDirection"] = tc.roadGraph.config.lanesPerDirection;
roadConfigJson["roadThickness"] = tc.roadGraph.config.roadThickness;
roadConfigJson["roadLodDistance"] = tc.roadGraph.config.roadLodDistance;
roadConfigJson["roadVisibilityDistance"] =
tc.roadGraph.config.roadVisibilityDistance;
roadConfigJson["roadMaterialName"] = tc.roadGraph.config.roadMaterialName;
json["roadConfig"] = roadConfigJson;
nlohmann::json roadNodesJson = nlohmann::json::array();
for (auto &n : tc.roadGraph.nodes) {
nlohmann::json nj;
nj["id"] = n.id;
nj["position"] = { n.position.x, n.position.y, n.position.z };
nj["verticalOffset"] = n.verticalOffset;
roadNodesJson.push_back(nj);
}
json["roadNodes"] = roadNodesJson;
nlohmann::json roadEdgesJson = nlohmann::json::array();
for (auto &e : tc.roadGraph.edges) {
nlohmann::json ej;
ej["nodeA"] = e.nodeA;
ej["nodeB"] = e.nodeB;
ej["roadLevelA"] = e.roadLevelA;
ej["roadLevelB"] = e.roadLevelB;
ej["lanesPerDirectionOverride"] = e.lanesPerDirectionOverride;
ej["lanesAtoB"] = e.lanesAtoB;
ej["lanesBtoA"] = e.lanesBtoA;
nlohmann::json sidePrefabsJson = nlohmann::json::array();
for (auto &sp : e.sidePrefabs) {
nlohmann::json spj;
spj["prefabPath"] = sp.prefabPath;
spj["edgeT"] = sp.edgeT;
spj["sideOffset"] = sp.sideOffset;
spj["leftSide"] = sp.leftSide;
sidePrefabsJson.push_back(spj);
}
ej["sidePrefabs"] = sidePrefabsJson;
roadEdgesJson.push_back(ej);
}
json["roadEdges"] = roadEdgesJson;
/* Persist binary heightmap, blend maps, and aux maps alongside the JSON. */
TerrainSystem *ts = TerrainSystem::getInstance();
if (ts) {
ts->saveSceneHeightmap(tc);
ts->saveSceneBlendMaps(tc);
ts->saveSceneAuxMaps(tc);
ts->saveFixups();
}
return json;
}
void SceneSerializer::deserializeTerrain(flecs::entity entity,
const nlohmann::json &json)
{
TerrainComponent tc;
tc.enabled = json.value("enabled", true);
tc.terrainSize = json.value("terrainSize", 65);
tc.terrainId = json.value("terrainId", (uint64_t)0);
tc.heightmapSize = json.value("heightmapSize", 256);
tc.blendMapSize = json.value("blendMapSize", 256);
tc.worldSize = json.value("worldSize", 2000.0f);
tc.maxPixelError = json.value("maxPixelError", 1.0f);
tc.compositeMapDistance = json.value("compositeMapDistance", 300.0f);
tc.minBatchSize = json.value("minBatchSize", 17);
tc.maxBatchSize = json.value("maxBatchSize", 65);
tc.heightmapFile = json.value("heightmapFile", "heightmap.bin");
if (json.contains("layers") && json["layers"].is_array()) {
for (auto &lj : json["layers"]) {
TerrainComponent::Layer layer;
layer.name = lj.value("name", "");
layer.diffuseTexture = lj.value("diffuseTexture", "");
layer.normalTexture = lj.value("normalTexture", "");
layer.worldSize = lj.value("worldSize", 100.0f);
tc.layers.push_back(layer);
}
}
if (json.contains("auxMaps") && json["auxMaps"].is_array()) {
for (auto &aj : json["auxMaps"]) {
TerrainComponent::AuxMap am;
am.name = aj.value("name", "");
am.fileName = aj.value("fileName", "");
am.resolution = aj.value("resolution", 256);
am.defaultValue = aj.value("defaultValue", 0.0f);
tc.auxMaps.push_back(am);
}
}
if (json.contains("detailNoise")) {
auto &dnj = json["detailNoise"];
tc.detailNoise.enabled = dnj.value("enabled", false);
tc.detailNoise.seed = dnj.value("seed", 0);
tc.detailNoise.octaves = dnj.value("octaves", 4);
tc.detailNoise.frequency = dnj.value("frequency", 0.001f);
tc.detailNoise.amplitude = dnj.value("amplitude", 10.0f);
tc.detailNoise.lacunarity = dnj.value("lacunarity", 2.0f);
tc.detailNoise.persistence = dnj.value("persistence", 0.5f);
}
if (json.contains("roadConfig")) {
auto &rcj = json["roadConfig"];
tc.roadGraph.config.roadMeshTemplate =
rcj.value("roadMeshTemplate", "road_segment.mesh");
tc.roadGraph.config.laneWidth = rcj.value("laneWidth", 3.0f);
tc.roadGraph.config.lanesPerDirection =
rcj.value("lanesPerDirection", 1);
tc.roadGraph.config.roadThickness = rcj.value("roadThickness", 0.3f);
tc.roadGraph.config.roadLodDistance = rcj.value("roadLodDistance", 200.0f);
tc.roadGraph.config.roadVisibilityDistance =
rcj.value("roadVisibilityDistance", 1000.0f);
tc.roadGraph.config.roadMaterialName =
rcj.value("roadMaterialName", "RoadMaterial");
}
if (json.contains("roadNodes") && json["roadNodes"].is_array()) {
for (auto &nj : json["roadNodes"]) {
RoadNode node;
node.id = nj.value("id", 0);
node.verticalOffset = nj.value("verticalOffset", 0.0f);
if (nj.contains("position") && nj["position"].is_array() &&
nj["position"].size() >= 3) {
node.position.x = nj["position"][0];
node.position.y = nj["position"][1];
node.position.z = nj["position"][2];
}
tc.roadGraph.nodes.push_back(node);
}
}
if (json.contains("roadEdges") && json["roadEdges"].is_array()) {
for (auto &ej : json["roadEdges"]) {
RoadEdge edge;
edge.nodeA = ej.value("nodeA", -1);
edge.nodeB = ej.value("nodeB", -1);
edge.roadLevelA = ej.value("roadLevelA", 0.0f);
edge.roadLevelB = ej.value("roadLevelB", 0.0f);
edge.lanesPerDirectionOverride =
ej.value("lanesPerDirectionOverride", 0);
edge.lanesAtoB = ej.value("lanesAtoB", 0);
edge.lanesBtoA = ej.value("lanesBtoA", 0);
if (ej.contains("sidePrefabs") &&
ej["sidePrefabs"].is_array()) {
for (auto &spj : ej["sidePrefabs"]) {
RoadEdge::RoadSidePrefab sp;
sp.prefabPath = spj.value("prefabPath", "");
sp.edgeT = spj.value("edgeT", 0.5f);
sp.sideOffset = spj.value("sideOffset", 5.0f);
sp.leftSide = spj.value("leftSide", true);
edge.sidePrefabs.push_back(sp);
}
}
tc.roadGraph.edges.push_back(edge);
}
}
tc.roadGraph.bumpVersion();
entity.set<TerrainComponent>(tc);
TerrainSystem *ts = TerrainSystem::getInstance();
if (ts)
ts->loadSceneHeightmap(tc);
}
@@ -204,6 +204,13 @@ private:
void deserializeWaterPhysics(flecs::entity entity,
const nlohmann::json &json);
// Terrain serialization (public so tests and tools can round-trip a
// single TerrainComponent without saving the whole scene).
public:
nlohmann::json serializeTerrain(flecs::entity entity);
void deserializeTerrain(flecs::entity entity, const nlohmann::json &json);
private:
// WaterPlane serialization
nlohmann::json serializeWaterPlane(flecs::entity entity);
void deserializeWaterPlane(flecs::entity entity,
@@ -135,7 +135,7 @@ void StartupMenuSystem::renderMenu(StartupMenuComponent &sm)
ImGuiWindowFlags_NoFocusOnAppearing);
if (m_menuFont)
ImGui::PushFont(m_menuFont);
ImGui::PushFont(m_menuFont, m_menuFont->LegacySize);
struct ButtonData {
const char *label;
@@ -0,0 +1,189 @@
#ifndef EDITSCENE_TERRAIN_COMMANDS_HPP
#define EDITSCENE_TERRAIN_COMMANDS_HPP
#pragma once
#include <Ogre.h>
#include <vector>
#include <string>
#include <functional>
/**
* @file TerrainCommands.hpp
* @brief Command-pattern terrain editing API.
*
* Terrain editing operations (raise, lower, smooth, flatten, splat paint)
* are encapsulated as command objects that can be queued, executed
* synchronously, and scripted from Lua.
*
* Usage (C++):
* @code
* TerrainSystem *ts = TerrainSystem::getInstance();
* TerrainCommandQueue &q = ts->getCommandQueue();
* q.push(TerrainCommand::raise(worldPos, radius, strength));
* q.push(TerrainCommand::splat(worldPos, radius, strength, layerIdx, weight));
* q.executeAll(); // blocks until all done, results in q.results()
* // On next update(), rebuildDirtyPages() picks up the changes.
* @endcode
*
* Usage (Lua):
* @code
* local q = terrain.create_command_queue()
* terrain.queue_raise(q, x, y, z, radius, strength)
* terrain.queue_splat(q, x, y, z, radius, strength, layer, weight)
* local results = terrain.execute_queue(q)
* @endcode
*/
/* ------------------------------------------------------------------ */
/* TerrainCommand — one brush operation */
/* ------------------------------------------------------------------ */
enum class TerrainCommandType {
Raise,
Lower,
Smooth,
Flatten,
SplatPaint
};
struct TerrainCommand {
TerrainCommandType type = TerrainCommandType::Raise;
/* Brush center in world-space coordinates. */
Ogre::Vector3 worldPos = Ogre::Vector3::ZERO;
/* Brush radius in world units. */
float radius = 5.0f;
/* Brush strength 0..1 (opacity of the effect). */
float strength = 0.5f;
/* Layer index for SplatPaint (0 = first blend layer). */
int layerIndex = 0;
/* Blend weight for SplatPaint (0..1, 0 = erase, 1 = full paint). */
float splatWeight = 1.0f;
/* --- Results (populated by executeAll) --- */
bool executed = false;
bool success = false;
int pagesAffected = 0;
std::string errorMsg;
/* Factory helpers */
static TerrainCommand raise(const Ogre::Vector3 &pos, float radius,
float strength)
{
TerrainCommand c;
c.type = TerrainCommandType::Raise;
c.worldPos = pos;
c.radius = radius;
c.strength = strength;
return c;
}
static TerrainCommand lower(const Ogre::Vector3 &pos, float radius,
float strength)
{
TerrainCommand c;
c.type = TerrainCommandType::Lower;
c.worldPos = pos;
c.radius = radius;
c.strength = strength;
return c;
}
static TerrainCommand smooth(const Ogre::Vector3 &pos, float radius,
float strength)
{
TerrainCommand c;
c.type = TerrainCommandType::Smooth;
c.worldPos = pos;
c.radius = radius;
c.strength = strength;
return c;
}
static TerrainCommand flatten(const Ogre::Vector3 &pos, float radius,
float strength)
{
TerrainCommand c;
c.type = TerrainCommandType::Flatten;
c.worldPos = pos;
c.radius = radius;
c.strength = strength;
return c;
}
static TerrainCommand splat(const Ogre::Vector3 &pos, float radius,
float strength, int layerIndex,
float splatWeight)
{
TerrainCommand c;
c.type = TerrainCommandType::SplatPaint;
c.worldPos = pos;
c.radius = radius;
c.strength = strength;
c.layerIndex = layerIndex;
c.splatWeight = splatWeight;
return c;
}
};
/* ------------------------------------------------------------------ */
/* TerrainCommandQueue — FIFO queue with synchronous execution */
/* ------------------------------------------------------------------ */
class TerrainSystem;
class TerrainCommandQueue {
public:
TerrainCommandQueue() = default;
/** Push a command to the back of the queue. */
void push(const TerrainCommand &cmd) { m_queue.push_back(cmd); }
void push(TerrainCommand &&cmd)
{
m_queue.push_back(std::move(cmd));
}
/**
* Execute all queued commands synchronously.
*
* Each command is applied to the heightmap data immediately. Affected
* terrain pages are marked dirty and will be rebuilt on the next
* TerrainSystem::update() call. Results (success, pagesAffected,
* errorMsg) are stored in each command and also appended to m_results.
*
* This call blocks until every command in the queue has been processed.
* The queue is drained after execution.
*/
void executeAll();
/** Discard all queued commands without executing them. */
void clear()
{
m_queue.clear();
m_results.clear();
}
/** Number of commands currently queued. */
size_t size() const { return m_queue.size(); }
/** Results from the last executeAll() call. */
const std::vector<TerrainCommand> &results() const
{
return m_results;
}
/** Set the owning TerrainSystem (called by TerrainSystem ctor). */
void setTerrainSystem(TerrainSystem *ts) { m_terrainSystem = ts; }
private:
void executeCommandInternal(const TerrainCommand &cmd);
std::vector<TerrainCommand> m_queue;
std::vector<TerrainCommand> m_results;
TerrainSystem *m_terrainSystem = nullptr;
};
#endif /* EDITSCENE_TERRAIN_COMMANDS_HPP */
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,600 @@
#ifndef EDITSCENE_TERRAINSYSTEM_HPP
#define EDITSCENE_TERRAINSYSTEM_HPP
#pragma once
#include <flecs.h>
#include <Ogre.h>
#include <OgreTerrain.h>
#include <OgreTerrainGroup.h>
#include <OgreTerrainPaging.h>
#include <OgreTerrainPagedWorldSection.h>
#include <OgrePageManager.h>
#include <OgrePage.h>
#include <OgrePagedWorld.h>
#include <OgreManualObject.h>
#include <OgreMaterial.h>
#include <memory>
#include <unordered_map>
#include <unordered_set>
#include <set>
#include <deque>
#include <mutex>
#include <cstdint>
#include <Jolt/Jolt.h>
#include <Jolt/Physics/Body/BodyFilter.h>
#include <Jolt/Physics/Body/Body.h>
#include <Jolt/Physics/Collision/Shape/Shape.h>
#include "TerrainCommands.hpp"
#include "../components/Terrain.hpp"
#include <FastNoiseLite.h>
class JoltPhysicsWrapper;
class RoadSystem;
class TerrainSystem {
public:
TerrainSystem(flecs::world &world, Ogre::SceneManager *sceneMgr,
Ogre::Camera *camera, JoltPhysicsWrapper *physics);
~TerrainSystem();
TerrainSystem(const TerrainSystem &) = delete;
TerrainSystem &operator=(const TerrainSystem &) = delete;
static TerrainSystem *getInstance()
{
return s_instance;
}
void update(float deltaTime);
void deactivate();
bool isActive() const
{
return m_active;
}
float getHeightAt(const Ogre::Vector3 &worldPos) const;
bool raycastTerrain(const Ogre::Ray &ray, float &outT,
Ogre::Vector3 &outNormal) const;
void setShowTerrainColliders(bool show);
bool getShowTerrainColliders() const
{
return m_showTerrainColliders;
}
void setShowRoadColliders(bool show);
bool getShowRoadColliders() const
{
return m_showRoadColliders;
}
/* --- Heightmap data (M3) --- */
float sampleHeightAt(long worldX, long worldZ) const;
float sampleHeightAtLocked(long worldX, long worldZ) const;
void setHeightAt(long worldX, long worldZ, float value);
bool loadHeightmap(const std::string &path);
bool saveHeightmap(const std::string &path);
bool loadSceneHeightmap(const struct TerrainComponent &tc);
bool saveSceneHeightmap(const struct TerrainComponent &tc);
std::string getHeightmapPath(const struct TerrainComponent &tc) const;
/* Fixup chunks (M5.9.5): sparse absolute-height overrides layered
* on top of base heightmap + detail noise during sampling.
*
* writeFixup() splats the 4 samples of the chunk cell containing the
* point so bilinear sampling returns @p height at the write position;
* the chunk is created lazily and marked dirty for saveFixups().
* Callers are responsible for marking affected terrain pages dirty
* AFTER all writes (main thread).
*
* clearAllFixups() deletes every fixup file and the in-memory chunks
* and marks all loaded pages dirty. */
void writeFixup(float worldX, float worldZ, float height);
bool saveFixups();
void clearAllFixups();
size_t getFixupChunkCount() const;
std::string getFixupDir(const struct TerrainComponent &tc) const;
/* Resolution change (M4.6) */
bool changeHeightmapResolution(struct TerrainComponent &tc,
int newResolution);
bool changeBlendMapResolution(struct TerrainComponent &tc,
int newResolution);
/* Blend map save/load (M3) */
bool saveBlendMaps(const std::string &dirPath) const;
bool loadBlendMaps(const std::string &dirPath);
bool saveSceneBlendMaps(const struct TerrainComponent &tc) const;
bool loadSceneBlendMaps(const struct TerrainComponent &tc);
void markPageDirty(long pageX, long pageY);
void rebuildDirtyPages();
void processDeferredReloads();
/* --- Detail noise (M4.2) --- */
float
computeDetailNoise(long worldX, long worldZ,
const struct TerrainComponent::DetailNoise &n) const;
/* --- Sculpting (M3) --- */
enum class SculptTool { Raise, Lower, Smooth, Flatten };
bool getSculptMode() const
{
return m_sculptMode;
}
void setSculptMode(bool v);
bool isSculpting() const
{
return m_sculptMode;
}
SculptTool getSculptTool() const
{
return m_sculptTool;
}
void setSculptTool(SculptTool t);
float getSculptRadius() const
{
return m_sculptRadius;
}
void setSculptRadius(float r)
{
m_sculptRadius = r;
}
float getSculptStrength() const
{
return m_sculptStrength;
}
void setSculptStrength(float s)
{
m_sculptStrength = s;
}
void snapCameraAboveTerrain();
void applySculptBrush(const Ogre::Vector3 &worldPos);
/* --- Brush falloff shape (M4.3) --- */
enum class BrushFalloffShape {
Linear, // 1 - t, zero at edge
Smoothstep, // 1 - (3t^2 - 2t^3)
Gaussian, // exp(-4 t^2)
Spherical, // sqrt(1 - t^2)
Cone // 1 inside radius, 0 at edge
};
BrushFalloffShape getBrushFalloffShape() const
{
return m_brushFalloffShape;
}
void setBrushFalloffShape(BrushFalloffShape s)
{
m_brushFalloffShape = s;
}
static float evaluateBrushFalloff(float distance, float radius,
BrushFalloffShape shape);
/* --- Aux-map editing (M4.4) --- */
void applyAuxBrush(const std::string &auxMapName,
const Ogre::Vector3 &worldPos, float radius,
float delta);
float sampleAuxMap(const std::string &auxMapName, long worldX,
long worldZ) const;
bool saveSceneAuxMaps(const struct TerrainComponent &tc);
bool loadSceneAuxMaps(const struct TerrainComponent &tc);
std::string
getAuxMapPath(const struct TerrainComponent &tc,
const struct TerrainComponent::AuxMap &aux) const;
/* --- Splat painting (M3) --- */
bool getPaintMode() const
{
return m_paintMode;
}
void setPaintMode(bool v)
{
if (v == m_paintMode)
return;
m_paintMode = v;
if (v) {
/* Paint mode is mutually exclusive with sculpt and aux. */
if (m_sculptMode)
setSculptMode(false);
if (m_auxPaintMode)
setAuxPaintMode(false);
} else {
hideBrushDecal();
}
}
bool isPainting() const
{
return m_paintMode;
}
int getPaintLayerIndex() const
{
return m_paintLayerIndex;
}
void setPaintLayerIndex(int i)
{
m_paintLayerIndex = i;
}
float getPaintRadius() const
{
return m_paintRadius;
}
void setPaintRadius(float r)
{
m_paintRadius = r;
}
float getPaintStrength() const
{
return m_paintStrength;
}
void setPaintStrength(float s)
{
m_paintStrength = s;
}
void applySplatBrush(const Ogre::Vector3 &worldPos);
/* --- Composite-map batching (M4.5) --- */
size_t getCompositeDirtyPageCount() const
{
return m_compositeDirtyPages.size();
}
/* --- Aux-map painting (M4.4) --- */
bool getAuxPaintMode() const
{
return m_auxPaintMode;
}
void setAuxPaintMode(bool v)
{
if (v == m_auxPaintMode)
return;
m_auxPaintMode = v;
if (v) {
if (m_sculptMode)
setSculptMode(false);
if (m_paintMode)
setPaintMode(false);
} else {
hideBrushDecal();
}
}
bool isAuxPainting() const
{
return m_auxPaintMode;
}
const std::string &getAuxPaintMapName() const
{
return m_auxPaintMapName;
}
void setAuxPaintMapName(const std::string &name)
{
m_auxPaintMapName = name;
}
/* --- Aux-map viewport visualization (M4.4 visual aid) --- */
bool getShowAuxMapVisualization() const
{
return m_showAuxMapVisualization;
}
void setShowAuxMapVisualization(bool v);
const std::string &getAuxMapVisualizationName() const
{
return m_auxMapVisualizationName;
}
void setAuxMapVisualizationName(const std::string &name);
/* --- Road editing (M5.2) --- */
bool getRoadEditMode() const
{
return m_roadEditMode;
}
void setRoadEditMode(bool v);
bool isRoadEditing() const
{
return m_roadEditMode;
}
/**
* Road editor tool modes (M5.2). Move is the default: clicks select
* and drag existing nodes/edges. In AddNode mode a click on empty
* terrain creates a new node.
*/
enum class RoadEditTool {
Move,
AddNode
};
RoadEditTool getRoadEditTool() const
{
return m_roadEditTool;
}
void setRoadEditTool(RoadEditTool tool)
{
m_roadEditTool = tool;
}
RoadSystem *getRoadSystem() const
{
return m_roadSystem.get();
}
/* --- Brush decal (M3) --- */
void updateBrushDecal(const Ogre::Vector3 &worldPos,
const Ogre::Vector3 &normal, float radius);
void hideBrushDecal();
/* Convert physical heightmap X to visual display X.
* Terrain pages render column i at X = i*step - halfSize
* but the height data lives at X = pageMinX + i*step.
* Same for Z but Z-inverted (see physicalToVisualZ). */
float physicalToVisualX(float physicalX) const;
/* Inverse: physical X corresponding to a given visual world X. */
float visualToPhysicalX(float visualX) const;
/* Half the terrain world size (for coordinate math). */
float getTerrainWorldHalfSize() const;
Ogre::TerrainGroup *getTerrainGroup() const
{
return mTerrainGroup;
}
/* Convert physical heightmap Z to visual display Z.
* Terrain pages render row j at Z = pageMinZ + halfSize - j*step
* but the height data lives at Z = pageMinZ + j*step. */
float physicalToVisualZ(float physicalZ) const;
/* Inverse: physical Z that maps to the given visual world Z.
* Uses visual page indexing, unlike physicalToVisualZ. */
float visualToPhysicalZ(float visualZ) const;
TerrainCommandQueue &getCommandQueue()
{
return m_commandQueue;
}
friend class TerrainCommandQueue;
private:
std::unique_ptr<RoadSystem> m_roadSystem;
void activate(struct TerrainComponent &tc,
struct TransformComponent &xform,
flecs::entity_t terrainEntityId);
void ensureHeightmapLoaded(struct TerrainComponent &tc);
void updatePageGeometry(long pageX, long pageY);
void fillPageHeightData(Ogre::TerrainGroup *group, long x, long y,
float *heightMap);
long worldToPage(float worldCoord, float worldSize) const;
void ensurePageColliders();
struct TerrainCollider {
long pageX, pageY;
JPH::BodyID bodyId;
bool pendingRemove = false;
};
class DummyPageProvider : public Ogre::PageProvider {
public:
bool prepareProceduralPage(Ogre::Page *,
Ogre::PagedWorldSection *) override
{
return true;
}
bool loadProceduralPage(Ogre::Page *,
Ogre::PagedWorldSection *) override
{
return true;
}
bool unloadProceduralPage(Ogre::Page *page,
Ogre::PagedWorldSection *) override;
bool unprepareProceduralPage(Ogre::Page *,
Ogre::PagedWorldSection *) override
{
return true;
}
TerrainSystem *owner = nullptr;
};
class CustomTerrainDefiner
: public Ogre::TerrainPagedWorldSection::TerrainDefiner {
public:
CustomTerrainDefiner(TerrainSystem *sys)
: Ogre::TerrainPagedWorldSection::TerrainDefiner()
, m_sys(sys)
{
}
void define(Ogre::TerrainGroup *terrainGroup, long x,
long y) override;
private:
TerrainSystem *m_sys;
};
class TerrainBodyDrawFilter : public JPH::BodyDrawFilter {
public:
bool ShouldDraw(const JPH::Body &inBody) const override;
void addTerrainBody(const JPH::BodyID &id)
{
m_terrainIds.insert(id);
}
void removeTerrainBody(const JPH::BodyID &id)
{
m_terrainIds.erase(id);
}
void clear()
{
m_terrainIds.clear();
m_roadIds.clear();
}
void syncRoadIds(const std::set<JPH::BodyID> &ids)
{
m_roadIds = ids;
}
bool showTerrain = false;
bool showRoad = false;
private:
std::set<JPH::BodyID> m_terrainIds;
std::set<JPH::BodyID> m_roadIds;
};
JPH::ShapeRefC buildPageCollider(Ogre::Terrain *terrain);
void queueColliderCreate(long x, long y);
void queueColliderRemove(long x, long y);
void processColliderCreates();
void processColliderRemoves();
void removeAllColliders();
void destroyCollider(uint64_t key, TerrainCollider &collider);
flecs::world &m_world;
Ogre::SceneManager *m_sceneMgr;
Ogre::Camera *m_camera;
JoltPhysicsWrapper *m_physics = nullptr;
bool m_active = false;
bool m_showTerrainColliders = false;
bool m_showRoadColliders = false;
static TerrainSystem *s_instance;
Ogre::TerrainGlobalOptions *mTerrainGlobals = nullptr;
Ogre::TerrainGroup *mTerrainGroup = nullptr;
Ogre::PageManager *mPageManager = nullptr;
Ogre::TerrainPaging *mTerrainPaging = nullptr;
Ogre::PagedWorld *mPagedWorld = nullptr;
Ogre::TerrainPagedWorldSection *mTerrainPagedWorldSection = nullptr;
std::unique_ptr<CustomTerrainDefiner> mTerrainDefiner;
std::unique_ptr<DummyPageProvider> mDummyPageProvider;
std::unordered_map<uint64_t, TerrainCollider> mColliders;
std::deque<std::pair<long, long> > mColliderCreateQueue;
std::set<uint64_t> mPendingColliderPages;
std::mutex m_colliderQueueMutex;
TerrainBodyDrawFilter mBodyDrawFilter;
/* Paging range used for synchronous page load and collider polling. */
long m_pageMinX = -1, m_pageMinY = -1;
long m_pageMaxX = 1, m_pageMaxY = 1;
/* Heightmap data (M3) — protected by m_heightmapMutex because
* collider building and page updates may happen concurrently. */
mutable std::mutex m_heightmapMutex;
std::vector<float> m_heightData;
bool m_heightmapLoaded = false;
int m_heightmapRes = 0;
float m_heightmapWorldMinX = 0;
float m_heightmapWorldMinZ = 0;
float m_heightmapWorldSize = 2000.0f;
std::set<uint64_t> m_dirtyPages;
std::vector<uint64_t> m_reloadQueue;
/* Composite-map dirty pages (M4.5). Updated by applySplatBrush and
* drained once per frame in update(). */
std::set<uint64_t> m_compositeDirtyPages;
bool hasHeightmap() const
{
return m_heightmapLoaded;
}
/* Detail noise state (M4.2). A local copy is kept so height sampling
* helpers do not touch ECS state while the heightmap mutex is held. */
struct TerrainComponent::DetailNoise m_detailNoise;
/* Brush falloff shape (M4.3). Runtime editor preference, not serialized. */
BrushFalloffShape m_brushFalloffShape = BrushFalloffShape::Linear;
/* Aux-map state (M4.4). Cached in memory while terrain is active. */
mutable std::unordered_map<std::string, std::vector<float> >
m_auxMapData;
std::unordered_set<std::string> m_dirtyAuxMaps;
/* Fixup chunks (M5.9.5): sparse absolute-height overrides stored as
* 256x256 float chunks under heightmaps/<terrainId>/terrain_fixup/.
* Guarded by m_heightmapMutex; loaded lazily from disk on first
* access, saved alongside the heightmap. */
struct FixupChunk {
std::vector<float> samples;
bool dirty = false;
};
static constexpr int FIXUP_CHUNK_RES = 256;
static constexpr float FIXUP_SENTINEL = -FLT_MAX;
mutable std::map<std::pair<int, int>, FixupChunk> m_fixupChunks;
std::string m_fixupDir;
float sampleFixupLocked(float worldX, float worldZ) const;
const FixupChunk *findFixupChunkLocked(int chunkX, int chunkZ) const;
bool loadFixupChunk(int chunkX, int chunkZ,
std::vector<float> &out) const;
std::string fixupChunkPath(int chunkX, int chunkZ) const;
const std::vector<float> *
ensureAuxMapLoaded(const struct TerrainComponent::AuxMap &aux,
const std::string &path) const;
/* Sculpting state */
bool m_sculptMode = false;
SculptTool m_sculptTool = SculptTool::Raise;
float m_sculptRadius = 100.0f;
float m_sculptStrength = 0.5f;
/* Splat paint state */
bool m_paintMode = false;
int m_paintLayerIndex = 1;
float m_paintRadius = 100.0f;
float m_paintStrength = 0.5f;
/* Aux-map paint state (M4.4) */
bool m_auxPaintMode = false;
std::string m_auxPaintMapName;
/* Aux-map viewport visualization (M4.4 visual aid) */
bool m_showAuxMapVisualization = false;
bool m_auxVisDirty = false;
std::string m_auxMapVisualizationName;
Ogre::ManualObject *m_auxVisObj = nullptr;
Ogre::SceneNode *m_auxVisNode = nullptr;
Ogre::MaterialPtr m_auxVisMaterial;
void buildAuxMapVisualization();
void destroyAuxMapVisualization();
static Ogre::ColourValue auxValueToColour(float v);
/* Road editing state (M5.2) */
bool m_roadEditMode = false;
RoadEditTool m_roadEditTool = RoadEditTool::Move;
/* --- Sculpt preview (M3) --- */
struct SculptPreview {
Ogre::ManualObject *manualObj = nullptr;
Ogre::SceneNode *sceneNode = nullptr;
long pageX, pageY;
};
std::unordered_map<uint64_t, SculptPreview> m_sculptPreviews;
Ogre::MaterialPtr m_sculptPreviewMaterial;
bool m_sculptPreviewsActive = false;
void beginSculptPreviews();
void endSculptPreviews();
void buildSculptPreview(long pageX, long pageY);
void updateSculptDirtyPages();
void ensureSculptPreviewMaterial();
/* Brush decal (M3) */
Ogre::ManualObject *m_brushDecal = nullptr;
Ogre::SceneNode *m_brushDecalNode = nullptr;
/* Entity tracking */
flecs::entity_t m_terrainEntityId = 0;
flecs::query<struct TerrainComponent, struct TransformComponent>
m_terrainQuery;
TerrainCommandQueue m_commandQueue;
};
#endif
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,91 @@
#ifndef EDITSCENE_TERRAIN_TESTS_HPP
#define EDITSCENE_TERRAIN_TESTS_HPP
#pragma once
#include <string>
#include <vector>
#include <functional>
#include <Ogre.h>
class TerrainSystem;
class EditorApp;
/**
* @file TerrainTests.hpp
* @brief Automated terrain test runner.
*
* Runs a series of terrain editing operations in a loop, verifying:
* - Heightmap data changes after sculpt operations
* - Terrain page geometry updates (AABB changes)
* - Clean create/edit/delete cycles
* - Splat painting writes to blend maps
* - Memory stability (no leaks, no crashes)
*
* Usage:
* @code
* // In main.cpp:
* app.initApp();
* if (runTerrainTests) {
* int result = TerrainTestRunner::run(app, iterations);
* app.closeApp();
* return result;
* }
* @endcode
*/
struct TerrainTestResult {
std::string name;
bool passed = false;
std::string message;
int iteration = 0;
};
class TerrainTestRunner {
public:
/**
* Run the full terrain test suite.
*
* @param app Initialized EditorApp (must have run initApp()).
* @param iterations Number of create/edit/delete cycles (default 10).
* @return 0 on success, non-zero on failure.
*/
static int run(EditorApp &app, int iterations = 10);
/**
* Tell the test runner whether it is executing in a headless
* (no display / offscreen) context. GPU readback tests will be
* skipped when this is true.
*/
static void setHeadless(bool headless);
private:
/* Individual test steps */
static bool testCreateTerrain(EditorApp &app, TerrainSystem *ts);
static bool testSculptOperations(EditorApp &app, TerrainSystem *ts);
static bool testSplatOperations(EditorApp &app, TerrainSystem *ts);
static bool testDetailNoise(EditorApp &app, TerrainSystem *ts);
static bool testBrushShapes(EditorApp &app, TerrainSystem *ts);
static bool testAuxMapOperations(EditorApp &app, TerrainSystem *ts);
static bool testCompositeMapBatching(EditorApp &app, TerrainSystem *ts);
static bool testChangeResolution(EditorApp &app, TerrainSystem *ts);
static bool testVerifyGeometry(EditorApp &app, TerrainSystem *ts);
static bool testDeleteTerrain(EditorApp &app, TerrainSystem *ts);
static bool testRoadDataModel(EditorApp &app, TerrainSystem *ts);
static bool testRoadSerialization(EditorApp &app, TerrainSystem *ts);
static bool testRoadTemplate(EditorApp &app, TerrainSystem *ts);
static bool testRoadWedgeEnumeration(EditorApp &app, TerrainSystem *ts);
static bool testRoadPageAssignment(EditorApp &app, TerrainSystem *ts);
static bool testRoadWedgeGeometry(EditorApp &app, TerrainSystem *ts);
static bool testRoadPageMeshes(EditorApp &app, TerrainSystem *ts);
static bool testFixupChunks(EditorApp &app, TerrainSystem *ts);
static bool testRoadEdgeLengthConstraint(EditorApp &app, TerrainSystem *ts);
static bool testTerrainCompliance(EditorApp &app, TerrainSystem *ts);
static bool testRoadColliderInteraction(EditorApp &app, TerrainSystem *ts);
static bool testRoadSidePrefabs(EditorApp &app, TerrainSystem *ts);
static bool testMemoryStability(EditorApp &app);
static void logResult(const TerrainTestResult &r);
static int m_failures;
};
#endif /* EDITSCENE_TERRAIN_TESTS_HPP */
@@ -133,6 +133,7 @@ private:
namespace editScene
{
void registerLuaComponentApi(lua_State *L);
void registerLuaAnimationTreeApi(lua_State *L);
void registerLuaEntityApi(lua_State *L);
void registerLuaDialogueApi(lua_State *L);
}
@@ -1420,12 +1421,14 @@ static int testAnimationTreeComponent(lua_State *L)
"local id = ecs.create_entity();"
"ecs.set_component(id, 'AnimationTree', {"
" treeName = 'humanoid',"
" enabled = true"
" enabled = true,"
" useRootMotion = false"
"});"
"local a = ecs.get_component(id, 'AnimationTree');"
"assert(a ~= nil, 'AnimationTree should exist');"
"assert(a.treeName == 'humanoid', 'wrong treeName');"
"assert(a.enabled == true, 'wrong enabled')");
"assert(a.enabled == true, 'wrong enabled');"
"assert(a.useRootMotion == false, 'wrong useRootMotion')");
if (!ok)
FAIL("AnimationTree component assertion failed");
@@ -1434,26 +1437,47 @@ static int testAnimationTreeComponent(lua_State *L)
}
// ---------------------------------------------------------------------------
// Test 48: AnimationTreeTemplate component
// Test 48: Animation Tree Registry Lua API
// ---------------------------------------------------------------------------
static int testAnimationTreeTemplateComponent(lua_State *L)
static int testAnimationTreeRegistryApi(lua_State *L)
{
TEST("AnimationTreeTemplate component");
TEST("Animation Tree Registry API");
bool ok = runLua(
L,
"local id = ecs.create_entity();"
"ecs.set_component(id, 'AnimationTreeTemplate', {"
" templateName = 'humanoid_base',"
" blendTime = 0.2"
"});"
"local a = ecs.get_component(id, 'AnimationTreeTemplate');"
"assert(a ~= nil, 'AnimationTreeTemplate should exist');"
"assert(a.templateName == 'humanoid_base', 'wrong templateName');"
"assert(a.blendTime == 0.2, 'wrong blendTime')");
bool ok = runLua(L,
"ecs.animation_trees.register('test_humanoid', {"
" skeletonSource = 'male_Face.mesh',"
" root = {"
" type = 'output',"
" children = {"
" { type = 'stateMachine', name = 'main', fadeSpeed = 7.5,"
" children = {"
" { type = 'state', name = 'idle',"
" children = {"
" { type = 'animation', animationName = 'idle' }"
" }"
" }"
" }"
" }"
" }"
" }"
"}); "
"local trees = ecs.animation_trees.list(); "
"assert(#trees >= 1, 'list should contain at least one tree'); "
"local found = false; "
"for _, name in ipairs(trees) do "
" if name == 'test_humanoid' then found = true end; "
"end; "
"assert(found, 'test_humanoid not found in list'); "
"local def = ecs.animation_trees.find('test_humanoid'); "
"assert(def ~= nil, 'find should return definition'); "
"assert(def.name == 'test_humanoid', 'wrong def name'); "
"assert(def.skeletonSource == 'male_Face.mesh', 'wrong skeletonSource'); "
"assert(def.root.type == 'output', 'wrong root type'); "
"ecs.animation_trees.remove('test_humanoid'); "
"assert(ecs.animation_trees.find('test_humanoid') == nil, 'remove failed')");
if (!ok)
FAIL("AnimationTreeTemplate component assertion failed");
FAIL("Animation Tree Registry API assertion failed");
PASS();
return 0;
@@ -1830,6 +1854,7 @@ int main()
// Register the entity and component APIs
editScene::registerLuaEntityApi(L);
editScene::registerLuaComponentApi(L);
editScene::registerLuaAnimationTreeApi(L);
editScene::registerLuaDialogueApi(L);
// Run tests
@@ -1881,7 +1906,7 @@ int main()
failures += testTriangleBufferComponent(L);
failures += testCharacterSpawnerComponent(L);
failures += testAnimationTreeComponent(L);
failures += testAnimationTreeTemplateComponent(L);
failures += testAnimationTreeRegistryApi(L);
failures += testBehaviorTreeComponent(L);
failures += testGoapActionComponent(L);
failures += testGoapGoalComponent(L);
@@ -705,6 +705,106 @@ void registerLuaItemApi(lua_State *L)
lua_setglobal(L, "ecs");
}
// ---------------------------------------------------------------------------
// Stub: LuaAnimationTreeApi
// ---------------------------------------------------------------------------
struct StubAnimTreeDef {
std::string name;
std::string skeletonSource;
std::string rootJson;
};
static std::unordered_map<std::string, StubAnimTreeDef> s_stubAnimTrees;
static void pushStubAnimTreeNode(lua_State *L, const std::string &json)
{
/* Simplified stub: return a minimal root table. */
lua_newtable(L);
lua_pushstring(L, "output");
lua_setfield(L, -2, "type");
}
void registerLuaAnimationTreeApi(lua_State *L)
{
lua_getglobal(L, "ecs");
if (lua_isnil(L, -1)) {
lua_pop(L, 1);
lua_newtable(L);
}
lua_newtable(L);
lua_pushcfunction(L, [](lua_State *L) -> int {
if (lua_gettop(L) < 2 || !lua_isstring(L, 1) ||
!lua_istable(L, 2))
return 0;
std::string name = lua_tostring(L, 1);
StubAnimTreeDef def;
def.name = name;
if (lua_getfield(L, 2, "skeletonSource"), lua_isstring(L, -1))
def.skeletonSource = lua_tostring(L, -1);
lua_pop(L, 1);
s_stubAnimTrees[name] = def;
return 0;
});
lua_setfield(L, -2, "register");
lua_pushcfunction(L, [](lua_State *L) -> int {
if (lua_gettop(L) < 1 || !lua_isstring(L, 1))
return 0;
auto it = s_stubAnimTrees.find(lua_tostring(L, 1));
if (it == s_stubAnimTrees.end())
return 0;
lua_newtable(L);
lua_pushstring(L, it->second.name.c_str());
lua_setfield(L, -2, "name");
lua_pushstring(L, it->second.skeletonSource.c_str());
lua_setfield(L, -2, "skeletonSource");
pushStubAnimTreeNode(L, it->second.rootJson);
lua_setfield(L, -2, "root");
return 1;
});
lua_setfield(L, -2, "find");
lua_pushcfunction(L, [](lua_State *L) -> int {
lua_newtable(L);
int idx = 1;
for (const auto &pair : s_stubAnimTrees) {
lua_pushstring(L, pair.first.c_str());
lua_rawseti(L, -2, idx++);
}
return 1;
});
lua_setfield(L, -2, "list");
lua_pushcfunction(L, [](lua_State *L) -> int {
if (lua_gettop(L) < 1 || !lua_isstring(L, 1))
return 0;
s_stubAnimTrees.erase(lua_tostring(L, 1));
return 0;
});
lua_setfield(L, -2, "remove");
lua_pushcfunction(L, [](lua_State *L) -> int {
(void)L;
lua_pushboolean(L, 1);
return 1;
});
lua_setfield(L, -2, "save");
lua_pushcfunction(L, [](lua_State *L) -> int {
(void)L;
lua_pushboolean(L, 1);
return 1;
});
lua_setfield(L, -2, "load");
lua_setfield(L, -2, "animation_trees");
lua_setglobal(L, "ecs");
}
} // namespace editScene
// ---------------------------------------------------------------------------
@@ -1805,4 +1905,23 @@ void registerLuaEventApi(lua_State *L)
lua_setglobal(L, "ecs");
}
// ---------------------------------------------------------------------------
// Stub: LuaTerrainApi
// ---------------------------------------------------------------------------
void registerLuaTerrainApi(lua_State *L)
{
/* Minimal stub: expose an empty ecs.terrain table so scripts that touch
* terrain globals do not crash. */
lua_getglobal(L, "ecs");
if (!lua_istable(L, -1)) {
lua_newtable(L);
lua_setglobal(L, "ecs");
lua_getglobal(L, "ecs");
}
lua_newtable(L);
lua_setfield(L, -2, "terrain");
lua_pop(L, 1);
}
} // namespace editScene
@@ -0,0 +1,13 @@
{
"name": "TinyCube",
"transform": {
"position": { "x": 0, "y": 0, "z": 0 },
"rotation": { "w": 1, "x": 0, "y": 0, "z": 0 },
"scale": { "x": 1, "y": 1, "z": 1 }
},
"primitive": {
"type": "cube",
"size": { "x": 1, "y": 1, "z": 1 },
"material": "RoadMaterial"
}
}
+40 -485
View File
@@ -1,6 +1,6 @@
#include "AnimationTreeEditor.hpp"
#include "../systems/AnimationTreeSystem.hpp"
#include "../components/AnimationTreeTemplate.hpp"
#include "../systems/AnimationTreeRegistry.hpp"
#include <imgui.h>
AnimationTreeEditor::AnimationTreeEditor(Ogre::SceneManager *sceneMgr)
@@ -8,433 +8,11 @@ AnimationTreeEditor::AnimationTreeEditor(Ogre::SceneManager *sceneMgr)
{
}
std::vector<Ogre::String> AnimationTreeEditor::getAvailableAnimations(
flecs::entity entity)
static void renderStatePreview(AnimationTreeComponent &at,
const AnimationTreeNode &root)
{
std::vector<Ogre::String> result;
Ogre::Entity *ent =
AnimationTreeSystem::findAnimatedEntity(entity);
if (ent && ent->hasSkeleton()) {
Ogre::AnimationStateSet *states =
ent->getAllAnimationStates();
if (states) {
for (const auto &pair : states->getAnimationStates())
result.push_back(pair.first);
}
}
return result;
}
static ImU32 nodeTypeColorU32(const Ogre::String &type)
{
if (type == "output")
return IM_COL32(0xFF, 0x88, 0x88, 0xFF);
if (type == "stateMachine")
return IM_COL32(0x88, 0xBB, 0xFF, 0xFF);
if (type == "state")
return IM_COL32(0x88, 0xFF, 0x88, 0xFF);
if (type == "speed")
return IM_COL32(0xFF, 0xEE, 0x88, 0xFF);
return IM_COL32(0xFF, 0xFF, 0xFF, 0xFF);
}
static void makeLabel(const AnimationTreeNode &node, char *buf,
size_t bufSize)
{
if (node.type == "animation") {
snprintf(buf, bufSize, "[%s] %s", node.type.c_str(),
node.animationName.empty() ?
"(none)" :
node.animationName.c_str());
} else if (node.type == "speed") {
snprintf(buf, bufSize, "[%s] %.2fx %s", node.type.c_str(),
node.speed,
node.name.empty() ? "" : node.name.c_str());
} else if (node.type == "stateMachine") {
snprintf(buf, bufSize, "[%s] %s (fade %.1f)",
node.type.c_str(), node.name.c_str(),
node.fadeSpeed);
} else {
snprintf(buf, bufSize, "[%s] %s", node.type.c_str(),
node.name.empty() ? "" : node.name.c_str());
}
}
static int countChildrenOfType(const AnimationTreeNode &node,
const Ogre::String &type)
{
int n = 0;
for (const auto &c : node.children)
if (c.type == type)
n++;
return n;
}
static size_t findChildIndex(const AnimationTreeNode &parent,
const AnimationTreeNode *child)
{
for (size_t i = 0; i < parent.children.size(); i++)
if (&parent.children[i] == child)
return i;
return (size_t)-1;
}
void AnimationTreeEditor::queueAddChild(AnimationTreeNode *parent,
const Ogre::String &type)
{
TreeEditOp op;
op.type = TreeEditOp::AddChild;
op.targetNode = parent;
op.childType = type;
m_editOps.push_back(op);
}
void AnimationTreeEditor::queueRemoveNode(AnimationTreeNode *parent,
size_t childIndex)
{
TreeEditOp op;
op.type = TreeEditOp::RemoveNode;
op.targetNode = parent;
op.childIndex = childIndex;
m_editOps.push_back(op);
}
void AnimationTreeEditor::queueMoveNode(AnimationTreeNode *parent,
size_t childIndex,
int delta)
{
TreeEditOp op;
op.type = TreeEditOp::MoveNode;
op.targetNode = parent;
op.childIndex = childIndex;
op.moveDelta = delta;
m_editOps.push_back(op);
}
void AnimationTreeEditor::applyEditOps(AnimationTreeComponent &at)
{
bool changed = false;
for (auto &op : m_editOps) {
if (!op.targetNode)
continue;
auto &vec = op.targetNode->children;
switch (op.type) {
case TreeEditOp::AddChild: {
AnimationTreeNode child;
child.type = op.childType;
if (op.childType == "stateMachine") {
child.name = "sm" +
std::to_string(countChildrenOfType(
*op.targetNode,
"stateMachine"));
child.fadeSpeed = 7.5f;
} else if (op.childType == "state") {
child.name = "state" +
std::to_string(countChildrenOfType(
*op.targetNode, "state"));
} else if (op.childType == "animation") {
child.animationName = "";
} else if (op.childType == "speed") {
child.speed = 1.0f;
}
vec.push_back(child);
changed = true;
break;
}
case TreeEditOp::RemoveNode: {
if (op.childIndex < vec.size()) {
vec.erase(vec.begin() + op.childIndex);
changed = true;
}
break;
}
case TreeEditOp::MoveNode: {
if (op.childIndex < vec.size()) {
int newIdx = (int)op.childIndex + op.moveDelta;
if (newIdx >= 0 &&
newIdx < (int)vec.size()) {
std::swap(vec[op.childIndex],
vec[newIdx]);
changed = true;
}
}
break;
}
}
}
m_editOps.clear();
if (changed)
at.dirty = true;
}
static bool canHaveChildren(const Ogre::String &type)
{
return type == "output" || type == "stateMachine" ||
type == "state" || type == "speed";
}
void AnimationTreeEditor::renderTree(AnimationTreeNode &node,
AnimationTreeNode *parent,
AnimationTreeNode *&selectedNode,
AnimationTreeNode *&selectedParent,
int &id)
{
int myId = id++;
bool isLeaf = node.children.empty();
ImGuiTreeNodeFlags flags =
ImGuiTreeNodeFlags_OpenOnArrow |
ImGuiTreeNodeFlags_OpenOnDoubleClick;
if (isLeaf)
flags |= ImGuiTreeNodeFlags_Leaf |
ImGuiTreeNodeFlags_NoTreePushOnOpen;
if (selectedNode == &node)
flags |= ImGuiTreeNodeFlags_Selected;
char label[256];
makeLabel(node, label, sizeof(label));
/* Color the whole tree node label */
ImGui::PushStyleColor(ImGuiCol_Text,
ImGui::ColorConvertU32ToFloat4(
nodeTypeColorU32(node.type)));
bool open = ImGui::TreeNodeEx(
("##animnode" + std::to_string(myId)).c_str(), flags,
"%s", label);
ImGui::PopStyleColor();
if (ImGui::IsItemClicked()) {
selectedNode = &node;
selectedParent = parent;
}
/* Right-click context menu — must be attached to TreeNodeEx
* (the last item), before we add buttons on the same line */
if (ImGui::BeginPopupContextItem()) {
if (canHaveChildren(node.type)) {
if (node.type == "output" ||
node.type == "stateMachine" ||
node.type == "state") {
if (ImGui::MenuItem("Add State Machine"))
queueAddChild(&node, "stateMachine");
}
if (node.type == "stateMachine" ||
node.type == "output" ||
node.type == "state") {
if (ImGui::MenuItem("Add State"))
queueAddChild(&node, "state");
}
if (node.type == "output" ||
node.type == "state" ||
node.type == "speed") {
if (ImGui::MenuItem("Add Animation"))
queueAddChild(&node, "animation");
}
if (node.type == "output" ||
node.type == "state") {
if (ImGui::MenuItem("Add Speed"))
queueAddChild(&node, "speed");
}
}
if (parent) {
size_t idx = findChildIndex(*parent, &node);
if (ImGui::MenuItem("Remove")) {
queueRemoveNode(parent, idx);
if (selectedNode == &node) {
selectedNode = nullptr;
selectedParent = nullptr;
}
}
if (ImGui::MenuItem("Move Up"))
queueMoveNode(parent, idx, -1);
if (ImGui::MenuItem("Move Down"))
queueMoveNode(parent, idx, +1);
}
ImGui::EndPopup();
}
/* Inline buttons on the same line as the tree node label */
ImGui::PushID(myId);
if (canHaveChildren(node.type)) {
ImGui::SameLine();
if (ImGui::SmallButton("+")) {
ImGui::OpenPopup("AddChild");
}
if (ImGui::BeginPopup("AddChild")) {
if (node.type == "output" ||
node.type == "stateMachine" ||
node.type == "state") {
if (ImGui::MenuItem("State Machine"))
queueAddChild(&node, "stateMachine");
}
if (node.type == "stateMachine" ||
node.type == "output" ||
node.type == "state") {
if (ImGui::MenuItem("State"))
queueAddChild(&node, "state");
}
if (node.type == "output" ||
node.type == "state" ||
node.type == "speed") {
if (ImGui::MenuItem("Animation"))
queueAddChild(&node, "animation");
}
if (node.type == "output" ||
node.type == "state") {
if (ImGui::MenuItem("Speed"))
queueAddChild(&node, "speed");
}
ImGui::EndPopup();
}
}
if (parent) {
size_t myIndex = findChildIndex(*parent, &node);
ImGui::SameLine();
if (ImGui::SmallButton("-"))
queueRemoveNode(parent, myIndex);
if (myIndex > 0) {
ImGui::SameLine();
if (ImGui::SmallButton("^"))
queueMoveNode(parent, myIndex, -1);
}
if (myIndex + 1 < parent->children.size()) {
ImGui::SameLine();
if (ImGui::SmallButton("v"))
queueMoveNode(parent, myIndex, +1);
}
}
ImGui::PopID();
/* Render children and TreePop (only when node is open) */
if (!isLeaf && open) {
for (auto &child : node.children)
renderTree(child, &node, selectedNode,
selectedParent, id);
ImGui::TreePop();
}
}
void AnimationTreeEditor::renderProperties(
AnimationTreeNode *node, AnimationTreeComponent &at,
flecs::entity entity)
{
if (!node)
return;
ImGui::Separator();
ImGui::Text("Node Properties");
bool modified = false;
/* Type selector */
const char *types[] = {"output", "stateMachine", "state",
"speed", "animation"};
int typeIdx = 0;
for (int i = 0; i < IM_ARRAYSIZE(types); i++) {
if (node->type == types[i]) {
typeIdx = i;
break;
}
}
if (ImGui::Combo("Type", &typeIdx, types,
IM_ARRAYSIZE(types))) {
node->type = types[typeIdx];
modified = true;
}
/* Name */
if (node->type == "stateMachine" || node->type == "state") {
char buf[256];
strncpy(buf, node->name.c_str(), sizeof(buf) - 1);
buf[sizeof(buf) - 1] = '\0';
if (ImGui::InputText("Name", buf, sizeof(buf))) {
node->name = buf;
modified = true;
}
}
/* Animation name */
if (node->type == "animation") {
std::vector<Ogre::String> anims =
getAvailableAnimations(entity);
Ogre::String current = node->animationName;
Ogre::String preview = current.empty() ? "(none)" :
current;
if (ImGui::BeginCombo("Animation", preview.c_str())) {
bool noneSel = current.empty();
if (ImGui::Selectable("(none)", noneSel)) {
node->animationName = "";
modified = true;
}
for (const auto &name : anims) {
bool sel = (current == name);
if (ImGui::Selectable(name.c_str(), sel)) {
node->animationName = name;
modified = true;
}
}
ImGui::EndCombo();
}
}
/* Speed */
if (node->type == "output" || node->type == "speed") {
if (ImGui::SliderFloat("Speed", &node->speed, -2.0f,
10.0f, "%.2f"))
modified = true;
}
/* Fade speed */
if (node->type == "stateMachine") {
if (ImGui::SliderFloat("Fade Speed", &node->fadeSpeed,
0.1f, 30.0f, "%.1f"))
modified = true;
}
/* End transitions (for state machines) */
if (node->type == "stateMachine" && !node->children.empty()) {
ImGui::Separator();
ImGui::Text("End Transitions");
ImGui::TextDisabled(
"Auto-switch state when animation ends");
for (const auto &child : node->children) {
if (child.type != "state")
continue;
char buf[256];
auto it = node->endTransitions.find(child.name);
if (it != node->endTransitions.end())
strncpy(buf, it->second.c_str(),
sizeof(buf) - 1);
else
buf[0] = '\0';
buf[sizeof(buf) - 1] = '\0';
Ogre::String lbl = "On '" + child.name +
"' end →";
if (ImGui::InputText(lbl.c_str(), buf,
sizeof(buf))) {
if (buf[0] == '\0')
node->endTransitions.erase(
child.name);
else
node->endTransitions[child.name] =
buf;
modified = true;
}
}
}
if (modified)
at.dirty = true;
}
void AnimationTreeEditor::renderStatePreview(
AnimationTreeComponent &at)
{
std::vector<AnimationTreeNode *> stateMachines;
at.root.collectStateMachines(stateMachines);
std::vector<const AnimationTreeNode *> stateMachines;
root.collectStateMachines(stateMachines);
if (stateMachines.empty())
return;
@@ -473,8 +51,8 @@ void AnimationTreeEditor::renderStatePreview(
}
}
bool AnimationTreeEditor::renderComponent(
flecs::entity entity, AnimationTreeComponent &at)
bool AnimationTreeEditor::renderComponent(flecs::entity entity,
AnimationTreeComponent &at)
{
ImGui::PushID("AnimTree");
bool modified = false;
@@ -483,33 +61,35 @@ bool AnimationTreeEditor::renderComponent(
ImGuiTreeNodeFlags_DefaultOpen)) {
ImGui::Indent();
/* Template reference */
char templBuf[256];
snprintf(templBuf, sizeof(templBuf), "%s",
at.templateName.c_str());
if (ImGui::InputText("Template Name", templBuf,
sizeof(templBuf))) {
at.templateName = templBuf;
modified = true;
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip(
"Leave empty for local tree. Set to template name to copy from a template entity.");
AnimationTreeRegistry &reg =
AnimationTreeRegistry::getSingleton();
std::vector<Ogre::String> names = reg.getTreeNames();
Ogre::String current = at.treeName;
Ogre::String preview = current.empty() ? "(none)" : current;
if (ImGui::BeginCombo("Tree", preview.c_str())) {
bool noneSel = current.empty();
if (ImGui::Selectable("(none)", noneSel)) {
at.treeName = "";
modified = true;
at.dirty = true;
}
for (const auto &name : names) {
bool sel = (current == name);
if (ImGui::Selectable(name.c_str(), sel)) {
at.treeName = name;
modified = true;
at.dirty = true;
}
}
ImGui::EndCombo();
}
bool isTemplateEntity = entity.has<AnimationTreeTemplate>();
if (isTemplateEntity) {
auto &templ = entity.get_mut<AnimationTreeTemplate>();
char nameBuf[256];
snprintf(nameBuf, sizeof(nameBuf), "%s",
templ.name.c_str());
if (ImGui::InputText("Template Publish Name", nameBuf,
sizeof(nameBuf))) {
templ.name = nameBuf;
modified = true;
}
ImGui::TextDisabled("Template version: %llu",
(unsigned long long)templ.version);
if (!at.treeName.empty() &&
ImGui::Button("Edit in Registry")) {
/* TODO: signal EditorUISystem to open the registry
* editor on this tree. For now the user can open
* Tools -> Animation Tree Registry manually. */
}
ImGui::Separator();
@@ -517,45 +97,20 @@ bool AnimationTreeEditor::renderComponent(
/* Global toggles */
if (ImGui::Checkbox("Enabled", &at.enabled))
modified = true;
if (ImGui::Checkbox("Use Root Motion",
&at.useRootMotion)) {
if (ImGui::Checkbox("Use Root Motion", &at.useRootMotion)) {
modified = true;
at.dirty = true;
}
ImGui::Separator();
/* Tree view */
static AnimationTreeNode *selectedNode = nullptr;
static AnimationTreeNode *selectedParent = nullptr;
int id = 0;
renderTree(at.root, nullptr, selectedNode,
selectedParent, id);
/* Apply all deferred edits. Vector reallocations may
* invalidate pointers, so clear selection afterwards. */
bool hadEdits = !m_editOps.empty();
applyEditOps(at);
if (hadEdits) {
selectedNode = nullptr;
selectedParent = nullptr;
}
/* Node properties */
renderProperties(selectedNode, at, entity);
/* State preview */
renderStatePreview(at);
/* State preview from registry tree */
const AnimationTreeRegistry::AnimationTreeDefinition *def =
reg.findTree(at.treeName);
if (def)
renderStatePreview(at, def->root);
ImGui::Unindent();
}
/* Auto-publish template changes */
if (modified && entity.has<AnimationTreeTemplate>()) {
auto &templ = entity.get_mut<AnimationTreeTemplate>();
templ.version++;
}
ImGui::PopID();
return modified;
}
@@ -4,13 +4,14 @@
#include "ComponentEditor.hpp"
#include "../components/AnimationTree.hpp"
#include <OgreSceneManager.h>
#include <vector>
/**
* Editor for AnimationTreeComponent.
*
* Provides a recursive tree view for editing the animation hierarchy
* and state-machine current-state selectors for preview.
* In the new registry-based design this editor is only a picker: it
* selects the tree name from AnimationTreeRegistry and exposes the
* per-instance toggles. Tree authoring happens in
* AnimationTreeRegistryEditor (Tools -> Animation Tree Registry).
*/
class AnimationTreeEditor : public ComponentEditor<AnimationTreeComponent> {
public:
@@ -18,42 +19,12 @@ public:
const char *getName() const override { return "Animation Tree"; }
/* Deferred edit operations — safe to call during UI rendering
* since they are applied only after the tree is fully drawn. */
struct TreeEditOp {
enum OpType { AddChild, RemoveNode, MoveNode } type;
AnimationTreeNode *targetNode = nullptr;
size_t childIndex = 0;
Ogre::String childType;
int moveDelta = 0;
};
void queueAddChild(AnimationTreeNode *parent,
const Ogre::String &type);
void queueRemoveNode(AnimationTreeNode *parent, size_t childIndex);
void queueMoveNode(AnimationTreeNode *parent, size_t childIndex,
int delta);
protected:
bool renderComponent(flecs::entity entity,
AnimationTreeComponent &at) override;
private:
void renderTree(AnimationTreeNode &node,
AnimationTreeNode *parent,
AnimationTreeNode *&selectedNode,
AnimationTreeNode *&selectedParent,
int &id);
void renderProperties(AnimationTreeNode *node,
AnimationTreeComponent &at,
flecs::entity entity);
void renderStatePreview(AnimationTreeComponent &at);
void applyEditOps(AnimationTreeComponent &at);
std::vector<Ogre::String> getAvailableAnimations(
flecs::entity entity);
Ogre::SceneManager *m_sceneMgr;
std::vector<TreeEditOp> m_editOps;
};
#endif // EDITSCENE_ANIMATIONTREEEDITOR_HPP
@@ -0,0 +1,440 @@
#include "AnimationTreeNodeEditor.hpp"
#include <imgui.h>
#include <cstring>
AnimationTreeNodeEditor::AnimationTreeNodeEditor()
{
}
void AnimationTreeNodeEditor::reset()
{
m_editOps.clear();
m_selectedNode = nullptr;
m_selectedParent = nullptr;
m_treeId = 0;
m_modified = false;
}
static ImU32 nodeTypeColorU32(const Ogre::String &type)
{
if (type == "output")
return IM_COL32(0xFF, 0x88, 0x88, 0xFF);
if (type == "stateMachine")
return IM_COL32(0x88, 0xBB, 0xFF, 0xFF);
if (type == "state")
return IM_COL32(0x88, 0xFF, 0x88, 0xFF);
if (type == "speed")
return IM_COL32(0xFF, 0xEE, 0x88, 0xFF);
return IM_COL32(0xFF, 0xFF, 0xFF, 0xFF);
}
static void makeLabel(const AnimationTreeNode &node, char *buf,
size_t bufSize)
{
if (node.type == "animation") {
snprintf(buf, bufSize, "[%s] %s", node.type.c_str(),
node.animationName.empty() ?
"(none)" :
node.animationName.c_str());
} else if (node.type == "speed") {
snprintf(buf, bufSize, "[%s] %.2fx %s", node.type.c_str(),
node.speed,
node.name.empty() ? "" : node.name.c_str());
} else if (node.type == "stateMachine") {
snprintf(buf, bufSize, "[%s] %s (fade %.1f)",
node.type.c_str(), node.name.c_str(),
node.fadeSpeed);
} else {
snprintf(buf, bufSize, "[%s] %s", node.type.c_str(),
node.name.empty() ? "" : node.name.c_str());
}
}
static int countChildrenOfType(const AnimationTreeNode &node,
const Ogre::String &type)
{
int n = 0;
for (const auto &c : node.children)
if (c.type == type)
n++;
return n;
}
static size_t findChildIndex(const AnimationTreeNode &parent,
const AnimationTreeNode *child)
{
for (size_t i = 0; i < parent.children.size(); i++)
if (&parent.children[i] == child)
return i;
return (size_t)-1;
}
static bool canHaveChildren(const Ogre::String &type)
{
return type == "output" || type == "stateMachine" ||
type == "state" || type == "speed";
}
void AnimationTreeNodeEditor::queueAddChild(AnimationTreeNode *parent,
const Ogre::String &type)
{
TreeEditOp op;
op.type = TreeEditOp::AddChild;
op.targetNode = parent;
op.childType = type;
m_editOps.push_back(op);
}
void AnimationTreeNodeEditor::queueRemoveNode(AnimationTreeNode *parent,
size_t childIndex)
{
TreeEditOp op;
op.type = TreeEditOp::RemoveNode;
op.targetNode = parent;
op.childIndex = childIndex;
m_editOps.push_back(op);
}
void AnimationTreeNodeEditor::queueMoveNode(AnimationTreeNode *parent,
size_t childIndex,
int delta)
{
TreeEditOp op;
op.type = TreeEditOp::MoveNode;
op.targetNode = parent;
op.childIndex = childIndex;
op.moveDelta = delta;
m_editOps.push_back(op);
}
void AnimationTreeNodeEditor::applyEditOps(AnimationTreeNode &root)
{
bool changed = false;
for (auto &op : m_editOps) {
if (!op.targetNode)
continue;
auto &vec = op.targetNode->children;
switch (op.type) {
case TreeEditOp::AddChild: {
AnimationTreeNode child;
child.type = op.childType;
if (op.childType == "stateMachine") {
child.name = "sm" +
std::to_string(countChildrenOfType(
*op.targetNode,
"stateMachine"));
child.fadeSpeed = 7.5f;
} else if (op.childType == "state") {
child.name = "state" +
std::to_string(countChildrenOfType(
*op.targetNode, "state"));
} else if (op.childType == "animation") {
child.animationName = "";
} else if (op.childType == "speed") {
child.speed = 1.0f;
}
vec.push_back(child);
changed = true;
break;
}
case TreeEditOp::RemoveNode: {
if (op.childIndex < vec.size()) {
vec.erase(vec.begin() + op.childIndex);
changed = true;
}
break;
}
case TreeEditOp::MoveNode: {
if (op.childIndex < vec.size()) {
int newIdx = (int)op.childIndex + op.moveDelta;
if (newIdx >= 0 &&
newIdx < (int)vec.size()) {
std::swap(vec[op.childIndex],
vec[newIdx]);
changed = true;
}
}
break;
}
}
}
m_editOps.clear();
if (changed)
m_modified = true;
if (changed) {
/* Selection may have been invalidated by vector reallocation. */
m_selectedNode = nullptr;
m_selectedParent = nullptr;
}
}
void AnimationTreeNodeEditor::renderTree(AnimationTreeNode &node,
AnimationTreeNode *parent,
AnimationTreeNode *&selectedNode,
AnimationTreeNode *&selectedParent,
int &id)
{
int myId = id++;
bool isLeaf = node.children.empty();
ImGuiTreeNodeFlags flags =
ImGuiTreeNodeFlags_OpenOnArrow |
ImGuiTreeNodeFlags_OpenOnDoubleClick;
if (isLeaf)
flags |= ImGuiTreeNodeFlags_Leaf |
ImGuiTreeNodeFlags_NoTreePushOnOpen;
if (selectedNode == &node)
flags |= ImGuiTreeNodeFlags_Selected;
char label[256];
makeLabel(node, label, sizeof(label));
/* Color the whole tree node label */
ImGui::PushStyleColor(ImGuiCol_Text,
ImGui::ColorConvertU32ToFloat4(
nodeTypeColorU32(node.type)));
bool open = ImGui::TreeNodeEx(
("##animnode" + std::to_string(myId)).c_str(), flags,
"%s", label);
ImGui::PopStyleColor();
if (ImGui::IsItemClicked()) {
selectedNode = &node;
selectedParent = parent;
}
/* Right-click context menu */
if (ImGui::BeginPopupContextItem()) {
if (canHaveChildren(node.type)) {
if (node.type == "output" ||
node.type == "stateMachine" ||
node.type == "state") {
if (ImGui::MenuItem("Add State Machine"))
queueAddChild(&node, "stateMachine");
}
if (node.type == "stateMachine" ||
node.type == "output" ||
node.type == "state") {
if (ImGui::MenuItem("Add State"))
queueAddChild(&node, "state");
}
if (node.type == "output" ||
node.type == "state" ||
node.type == "speed") {
if (ImGui::MenuItem("Add Animation"))
queueAddChild(&node, "animation");
}
if (node.type == "output" ||
node.type == "state") {
if (ImGui::MenuItem("Add Speed"))
queueAddChild(&node, "speed");
}
}
if (parent) {
size_t idx = findChildIndex(*parent, &node);
if (ImGui::MenuItem("Remove")) {
queueRemoveNode(parent, idx);
if (selectedNode == &node) {
selectedNode = nullptr;
selectedParent = nullptr;
}
}
if (ImGui::MenuItem("Move Up"))
queueMoveNode(parent, idx, -1);
if (ImGui::MenuItem("Move Down"))
queueMoveNode(parent, idx, +1);
}
ImGui::EndPopup();
}
/* Inline buttons on the same line as the tree node label */
ImGui::PushID(myId);
if (canHaveChildren(node.type)) {
ImGui::SameLine();
if (ImGui::SmallButton("+")) {
ImGui::OpenPopup("AddChild");
}
if (ImGui::BeginPopup("AddChild")) {
if (node.type == "output" ||
node.type == "stateMachine" ||
node.type == "state") {
if (ImGui::MenuItem("State Machine"))
queueAddChild(&node, "stateMachine");
}
if (node.type == "stateMachine" ||
node.type == "output" ||
node.type == "state") {
if (ImGui::MenuItem("State"))
queueAddChild(&node, "state");
}
if (node.type == "output" ||
node.type == "state" ||
node.type == "speed") {
if (ImGui::MenuItem("Animation"))
queueAddChild(&node, "animation");
}
if (node.type == "output" ||
node.type == "state") {
if (ImGui::MenuItem("Speed"))
queueAddChild(&node, "speed");
}
ImGui::EndPopup();
}
}
if (parent) {
size_t myIndex = findChildIndex(*parent, &node);
ImGui::SameLine();
if (ImGui::SmallButton("-"))
queueRemoveNode(parent, myIndex);
if (myIndex > 0) {
ImGui::SameLine();
if (ImGui::SmallButton("^"))
queueMoveNode(parent, myIndex, -1);
}
if (myIndex + 1 < parent->children.size()) {
ImGui::SameLine();
if (ImGui::SmallButton("v"))
queueMoveNode(parent, myIndex, +1);
}
}
ImGui::PopID();
/* Render children and TreePop (only when node is open) */
if (!isLeaf && open) {
for (auto &child : node.children)
renderTree(child, &node, selectedNode,
selectedParent, id);
ImGui::TreePop();
}
}
void AnimationTreeNodeEditor::renderProperties(
AnimationTreeNode *node,
const std::vector<Ogre::String> &animNames)
{
if (!node)
return;
ImGui::Separator();
ImGui::Text("Node Properties");
/* Type selector */
const char *types[] = { "output", "stateMachine", "state",
"speed", "animation" };
int typeIdx = 0;
for (int i = 0; i < IM_ARRAYSIZE(types); i++) {
if (node->type == types[i]) {
typeIdx = i;
break;
}
}
if (ImGui::Combo("Type", &typeIdx, types,
IM_ARRAYSIZE(types))) {
node->type = types[typeIdx];
m_modified = true;
}
/* Name */
if (node->type == "stateMachine" || node->type == "state") {
char buf[256];
strncpy(buf, node->name.c_str(), sizeof(buf) - 1);
buf[sizeof(buf) - 1] = '\0';
if (ImGui::InputText("Name", buf, sizeof(buf))) {
node->name = buf;
m_modified = true;
}
}
/* Animation name */
if (node->type == "animation") {
Ogre::String current = node->animationName;
Ogre::String preview = current.empty() ? "(none)" : current;
if (ImGui::BeginCombo("Animation", preview.c_str())) {
bool noneSel = current.empty();
if (ImGui::Selectable("(none)", noneSel)) {
node->animationName = "";
m_modified = true;
}
for (const auto &name : animNames) {
bool sel = (current == name);
if (ImGui::Selectable(name.c_str(), sel)) {
node->animationName = name;
m_modified = true;
}
}
ImGui::EndCombo();
}
}
/* Speed */
if (node->type == "output" || node->type == "speed") {
if (ImGui::SliderFloat("Speed", &node->speed, -2.0f, 10.0f,
"%.2f"))
m_modified = true;
}
/* Fade speed */
if (node->type == "stateMachine") {
if (ImGui::SliderFloat("Fade Speed", &node->fadeSpeed,
0.1f, 30.0f, "%.1f"))
m_modified = true;
}
/* End transitions (for state machines) */
if (node->type == "stateMachine" && !node->children.empty()) {
ImGui::Separator();
ImGui::Text("End Transitions");
ImGui::TextDisabled(
"Auto-switch state when animation ends");
for (const auto &child : node->children) {
if (child.type != "state")
continue;
char buf[256];
auto it = node->endTransitions.find(child.name);
if (it != node->endTransitions.end())
strncpy(buf, it->second.c_str(),
sizeof(buf) - 1);
else
buf[0] = '\0';
buf[sizeof(buf) - 1] = '\0';
Ogre::String lbl = "On '" + child.name +
"' end →";
if (ImGui::InputText(lbl.c_str(), buf,
sizeof(buf))) {
if (buf[0] == '\0')
node->endTransitions.erase(
child.name);
else
node->endTransitions[child.name] =
buf;
m_modified = true;
}
}
}
}
bool AnimationTreeNodeEditor::render(AnimationTreeNode &root,
const std::vector<Ogre::String> &animNames)
{
ImGui::PushID("AnimTreeNodeEditor");
m_treeId = 0;
renderTree(root, nullptr, m_selectedNode, m_selectedParent,
m_treeId);
/* Apply deferred edits before rendering properties so the
* property panel reflects the current selection. */
applyEditOps(root);
/* Node properties */
renderProperties(m_selectedNode, animNames);
ImGui::PopID();
bool wasModified = m_modified;
m_modified = false;
return wasModified;
}
@@ -0,0 +1,65 @@
#ifndef EDITSCENE_ANIMATIONTREENODEEDITOR_HPP
#define EDITSCENE_ANIMATIONTREENODEEDITOR_HPP
#pragma once
#include "../components/AnimationTree.hpp"
#include <Ogre.h>
#include <vector>
/**
* Reusable editor for an AnimationTreeNode hierarchy.
*
* This editor does not know about registries or components; it simply
* edits the provided root node in place. It is used by the Animation Tree
* Registry editor and can be used anywhere a tree needs to be authored.
*/
class AnimationTreeNodeEditor {
public:
AnimationTreeNodeEditor();
/**
* Render the tree editor.
*
* @param root Root node to edit.
* @param animNames Optional list of animation names for dropdowns.
* @return true if the tree was modified.
*/
bool render(AnimationTreeNode &root,
const std::vector<Ogre::String> &animNames);
/** Clear selection and pending edit operations. */
void reset();
private:
struct TreeEditOp {
enum OpType { AddChild, RemoveNode, MoveNode } type;
AnimationTreeNode *targetNode = nullptr;
size_t childIndex = 0;
Ogre::String childType;
int moveDelta = 0;
};
void queueAddChild(AnimationTreeNode *parent,
const Ogre::String &type);
void queueRemoveNode(AnimationTreeNode *parent, size_t childIndex);
void queueMoveNode(AnimationTreeNode *parent, size_t childIndex,
int delta);
void applyEditOps(AnimationTreeNode &root);
void renderTree(AnimationTreeNode &node,
AnimationTreeNode *parent,
AnimationTreeNode *&selectedNode,
AnimationTreeNode *&selectedParent,
int &id);
void renderProperties(AnimationTreeNode *node,
const std::vector<Ogre::String> &animNames);
void renderStatePreview(AnimationTreeNode &root);
std::vector<TreeEditOp> m_editOps;
AnimationTreeNode *m_selectedNode = nullptr;
AnimationTreeNode *m_selectedParent = nullptr;
int m_treeId = 0;
bool m_modified = false;
};
#endif // EDITSCENE_ANIMATIONTREENODEEDITOR_HPP
@@ -0,0 +1,182 @@
#include "AnimationTreeRegistryEditor.hpp"
#include <imgui.h>
#include <OgreLogManager.h>
AnimationTreeRegistryEditor::AnimationTreeRegistryEditor()
{
m_newNameBuf[0] = '\0';
}
void AnimationTreeRegistryEditor::selectTree(const Ogre::String &name)
{
m_pendingSelect = true;
m_pendingSelectName = name;
m_nodeEditor.reset();
}
void AnimationTreeRegistryEditor::renderMenuBar()
{
if (!ImGui::BeginMenuBar())
return;
if (ImGui::BeginMenu("File")) {
if (ImGui::MenuItem("Save", nullptr))
AnimationTreeRegistry::getSingleton().saveToFile(
AnimationTreeRegistry::defaultPath());
if (ImGui::MenuItem("Load", nullptr))
AnimationTreeRegistry::getSingleton().loadFromFile(
AnimationTreeRegistry::defaultPath());
ImGui::EndMenu();
}
ImGui::EndMenuBar();
}
void AnimationTreeRegistryEditor::renderListPane()
{
ImGui::BeginChild("TreeList", ImVec2(250, 0), true);
if (ImGui::Button("Add Tree")) {
AnimationTreeRegistry &reg =
AnimationTreeRegistry::getSingleton();
Ogre::String name = reg.generateUniqueName("new_tree");
AnimationTreeRegistry::AnimationTreeDefinition def;
def.name = name;
def.root.type = "output";
def.root.speed = 1.0f;
reg.registerTree(def);
m_selectedTreeName = name;
}
AnimationTreeRegistry &reg = AnimationTreeRegistry::getSingleton();
std::vector<Ogre::String> names = reg.getTreeNames();
std::sort(names.begin(), names.end());
for (size_t i = 0; i < names.size(); ++i) {
const auto &name = names[i];
bool selected = (m_selectedTreeName == name);
ImGui::PushID(static_cast<int>(i));
if (ImGui::Selectable(name.c_str(), selected)) {
if (m_selectedTreeName != name)
m_nodeEditor.reset();
m_selectedTreeName = name;
}
ImGui::PopID();
}
ImGui::EndChild();
}
void AnimationTreeRegistryEditor::renderEditPane()
{
ImGui::SameLine();
ImGui::BeginChild("TreeEdit", ImVec2(0, 0), true);
AnimationTreeRegistry &reg = AnimationTreeRegistry::getSingleton();
AnimationTreeRegistry::AnimationTreeDefinition *def =
reg.findTree(m_selectedTreeName);
if (!def) {
ImGui::Text("Select a tree from the list to edit.");
ImGui::EndChild();
return;
}
/* Name */
char nameBuf[256];
snprintf(nameBuf, sizeof(nameBuf), "%s", def->name.c_str());
if (ImGui::InputText("Name", nameBuf, sizeof(nameBuf))) {
Ogre::String newName = nameBuf;
if (!newName.empty() && newName != def->name &&
!reg.hasTree(newName)) {
AnimationTreeRegistry::AnimationTreeDefinition renamed =
*def;
renamed.name = newName;
reg.removeTree(def->name);
reg.registerTree(renamed);
m_selectedTreeName = newName;
def = reg.findTree(newName);
}
}
/* Skeleton source */
std::vector<AnimationTreeRegistry::SkeletonSource> sources =
reg.getAvailableSkeletonSources();
Ogre::String currentSource = def->skeletonSource;
Ogre::String currentSkelName =
currentSource.empty() ?
Ogre::String() :
reg.getSkeletonNameForMesh(currentSource);
Ogre::String preview =
currentSkelName.empty() ? "(none)" : currentSkelName;
if (ImGui::BeginCombo("Skeleton Source", preview.c_str())) {
bool noneSel = currentSource.empty();
if (ImGui::Selectable("(none)", noneSel)) {
def->skeletonSource = "";
reg.markModified(def->name);
}
for (size_t i = 0; i < sources.size(); ++i) {
const auto &src = sources[i];
bool sel = (currentSource == src.meshName);
ImGui::PushID(static_cast<int>(i));
if (ImGui::Selectable(src.skeletonName.c_str(), sel)) {
def->skeletonSource = src.meshName;
reg.markModified(def->name);
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip(
"Representative mesh: %s",
src.meshName.c_str());
}
ImGui::PopID();
}
ImGui::EndCombo();
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip(
"Skeleton used to populate animation-name dropdowns. "
"One representative mesh per skeleton is shown.");
}
ImGui::Separator();
/* Tree node editor */
std::vector<Ogre::String> animNames =
reg.getAnimationsForSource(def->skeletonSource);
AnimationTreeNode *rootPtr = def ? &def->root : nullptr;
if (rootPtr) {
bool edited = m_nodeEditor.render(*rootPtr, animNames);
if (edited)
reg.markModified(def->name);
}
ImGui::Separator();
if (ImGui::Button("Delete Tree")) {
reg.removeTree(def->name);
m_selectedTreeName.clear();
}
ImGui::EndChild();
}
void AnimationTreeRegistryEditor::render(bool *open)
{
if (m_pendingSelect) {
m_selectedTreeName = m_pendingSelectName;
m_pendingSelect = false;
}
ImGui::SetNextWindowSize(ImVec2(900, 600), ImGuiCond_FirstUseEver);
if (!ImGui::Begin("Animation Tree Registry", open,
ImGuiWindowFlags_MenuBar)) {
ImGui::End();
return;
}
renderMenuBar();
renderListPane();
renderEditPane();
ImGui::End();
}
@@ -0,0 +1,39 @@
#ifndef EDITSCENE_ANIMATIONTREEREGISTRYEDITOR_HPP
#define EDITSCENE_ANIMATIONTREEREGISTRYEDITOR_HPP
#pragma once
#include "../systems/AnimationTreeRegistry.hpp"
#include "AnimationTreeNodeEditor.hpp"
#include <Ogre.h>
#include <string>
/**
* Editor window for the global Animation Tree Registry.
*
* Accessible from Tools -> Animation Tree Registry. Allows creating,
* renaming, deleting and editing named animation trees. Each tree can
* optionally select a skeleton source from the Characters resource group
* to populate animation-name dropdowns.
*/
class AnimationTreeRegistryEditor {
public:
AnimationTreeRegistryEditor();
void render(bool *open);
/* Ask the editor to select a specific tree on next render. */
void selectTree(const Ogre::String &name);
private:
void renderMenuBar();
void renderListPane();
void renderEditPane();
AnimationTreeNodeEditor m_nodeEditor;
Ogre::String m_selectedTreeName;
char m_newNameBuf[256];
bool m_pendingSelect = false;
Ogre::String m_pendingSelectName;
};
#endif // EDITSCENE_ANIMATIONTREEREGISTRYEDITOR_HPP
@@ -1,37 +0,0 @@
#include "AnimationTreeTemplateEditor.hpp"
#include <imgui.h>
bool AnimationTreeTemplateEditor::renderComponent(
flecs::entity entity, AnimationTreeTemplate &templ)
{
ImGui::PushID("AnimTreeTempl");
(void)entity;
bool modified = false;
if (ImGui::CollapsingHeader("Animation Tree Template",
ImGuiTreeNodeFlags_DefaultOpen)) {
ImGui::Indent();
char nameBuf[256];
snprintf(nameBuf, sizeof(nameBuf), "%s", templ.name.c_str());
if (ImGui::InputText("Template Name", nameBuf,
sizeof(nameBuf))) {
templ.name = nameBuf;
modified = true;
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip(
"Other entities reference this template by name in their Animation Tree component.");
}
ImGui::TextDisabled("Version: %llu",
(unsigned long long)templ.version);
ImGui::TextDisabled(
"Edit the Animation Tree component on this entity to modify the template.");
ImGui::Unindent();
}
ImGui::PopID();
return modified;
}
@@ -1,24 +0,0 @@
#ifndef EDITSCENE_ANIMATIONTREETEMPLATEEDITOR_HPP
#define EDITSCENE_ANIMATIONTREETEMPLATEEDITOR_HPP
#pragma once
#include "ComponentEditor.hpp"
#include "../components/AnimationTreeTemplate.hpp"
/**
* Editor for AnimationTreeTemplate component.
* The actual tree editing is done via the co-located AnimationTreeComponent.
* This editor only exposes the template name and version.
*/
class AnimationTreeTemplateEditor
: public ComponentEditor<AnimationTreeTemplate> {
public:
bool renderComponent(flecs::entity entity,
AnimationTreeTemplate &templ) override;
const char *getName() const override
{
return "Animation Tree Template";
}
};
#endif // EDITSCENE_ANIMATIONTREETEMPLATEEDITOR_HPP
File diff suppressed because it is too large Load Diff