Compare commits
14 Commits
f06f535938
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| a97efbe237 | |||
| 9759c06d5f | |||
| 4acad712b0 | |||
| 50c2395581 | |||
| 32471f0db6 | |||
| ffbc85d4e2 | |||
| 9a12e84fec | |||
| 96c0eef6cb | |||
| 6e71f3639b | |||
| 568b3bc917 | |||
| 25106a23c4 | |||
| e2d6e49728 | |||
| 98303a6448 | |||
| fb2c2af2b2 |
@@ -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`:
|
||||
|
||||
@@ -24,6 +24,7 @@ set(EDITSCENE_SOURCES
|
||||
systems/EditorSkyboxSystem.cpp
|
||||
systems/EditorWaterPlaneSystem.cpp
|
||||
systems/TerrainSystem.cpp
|
||||
systems/RoadSystem.cpp
|
||||
systems/LightSystem.cpp
|
||||
systems/CameraSystem.cpp
|
||||
systems/LodSystem.cpp
|
||||
@@ -170,6 +171,7 @@ set(EDITSCENE_SOURCES
|
||||
camera/EditorCamera.cpp
|
||||
gizmo/Gizmo.cpp
|
||||
gizmo/Cursor3D.cpp
|
||||
gizmo/RoadGizmo.cpp
|
||||
physics/physics.cpp
|
||||
lua/LuaState.cpp
|
||||
lua/LuaEntityApi.cpp
|
||||
@@ -182,6 +184,8 @@ set(EDITSCENE_SOURCES
|
||||
lua/LuaCharacterClassApi.cpp
|
||||
lua/LuaCharacterApi.cpp
|
||||
lua/LuaSaveLoadApi.cpp
|
||||
lua/LuaTerrainApi.cpp
|
||||
systems/TerrainTests.cpp
|
||||
)
|
||||
|
||||
set(EDITSCENE_HEADERS
|
||||
@@ -196,6 +200,7 @@ set(EDITSCENE_HEADERS
|
||||
components/WaterPhysics.hpp
|
||||
components/WaterPlane.hpp
|
||||
components/Terrain.hpp
|
||||
components/RoadGraph.hpp
|
||||
components/Sun.hpp
|
||||
components/Skybox.hpp
|
||||
components/Light.hpp
|
||||
@@ -288,6 +293,7 @@ set(EDITSCENE_HEADERS
|
||||
systems/EditorSkyboxSystem.hpp
|
||||
systems/EditorWaterPlaneSystem.hpp
|
||||
systems/TerrainSystem.hpp
|
||||
systems/RoadSystem.hpp
|
||||
systems/LightSystem.hpp
|
||||
systems/CameraSystem.hpp
|
||||
systems/LodSystem.hpp
|
||||
@@ -348,6 +354,7 @@ set(EDITSCENE_HEADERS
|
||||
camera/EditorCamera.hpp
|
||||
gizmo/Gizmo.hpp
|
||||
gizmo/Cursor3D.hpp
|
||||
gizmo/RoadGizmo.hpp
|
||||
physics/physics.h
|
||||
lua/LuaState.hpp
|
||||
lua/LuaEntityApi.hpp
|
||||
@@ -360,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})
|
||||
@@ -395,6 +403,7 @@ target_link_libraries(editSceneEditor
|
||||
RecastNavigation::DebugUtils
|
||||
PackageArchive
|
||||
lua
|
||||
SDL2::SDL2
|
||||
)
|
||||
|
||||
target_include_directories(editSceneEditor PRIVATE
|
||||
@@ -404,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
|
||||
)
|
||||
@@ -748,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"
|
||||
|
||||
@@ -127,6 +127,7 @@
|
||||
#include "lua/LuaCharacterClassApi.hpp"
|
||||
#include "lua/LuaDialogueApi.hpp"
|
||||
#include "lua/LuaItemApi.hpp"
|
||||
#include "lua/LuaTerrainApi.hpp"
|
||||
|
||||
//=============================================================================
|
||||
// ImGuiRenderListener Implementation
|
||||
@@ -256,6 +257,75 @@ void EditorApp::closeApp()
|
||||
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 */
|
||||
@@ -264,15 +334,13 @@ void EditorApp::closeApp()
|
||||
/* ------------------------------------------------------------------ */
|
||||
static void clearRTShaderPassBindings()
|
||||
{
|
||||
Ogre::MaterialManager &matMgr =
|
||||
Ogre::MaterialManager::getSingleton();
|
||||
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());
|
||||
Ogre::static_pointer_cast<Ogre::Material>(it.getNext());
|
||||
if (!mat)
|
||||
continue;
|
||||
|
||||
@@ -351,7 +419,8 @@ void EditorApp::destroyEditorSystems()
|
||||
// outliving their factories.
|
||||
if (m_sceneMgr) {
|
||||
m_sceneMgr->clearScene();
|
||||
Ogre::MaterialManager::getSingleton().unloadUnreferencedResources();
|
||||
Ogre::MaterialManager::getSingleton()
|
||||
.unloadUnreferencedResources();
|
||||
clearRTShaderPassBindings();
|
||||
}
|
||||
|
||||
@@ -398,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
|
||||
@@ -416,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
|
||||
@@ -445,9 +519,11 @@ void EditorApp::setup()
|
||||
|
||||
// TerrainSystem — owns Ogre terrain singletons, needs camera for paging.
|
||||
{
|
||||
Ogre::Camera *cam = m_camera ? m_camera->getCamera() : nullptr;
|
||||
Ogre::Camera *cam = m_camera ? m_camera->getCamera() :
|
||||
nullptr;
|
||||
m_terrainSystem = std::make_unique<TerrainSystem>(
|
||||
m_world, m_sceneMgr, cam, m_physicsSystem->getPhysicsWrapper());
|
||||
m_world, m_sceneMgr, cam,
|
||||
m_physicsSystem->getPhysicsWrapper());
|
||||
}
|
||||
|
||||
// Apply debug setting if it was set before system creation
|
||||
@@ -500,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 =
|
||||
@@ -661,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();
|
||||
@@ -705,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();
|
||||
@@ -786,6 +868,11 @@ void EditorApp::setDebugBuoyancy(bool enabled)
|
||||
}
|
||||
}
|
||||
|
||||
void EditorApp::setHeadless(bool headless)
|
||||
{
|
||||
m_headless = headless;
|
||||
}
|
||||
|
||||
void EditorApp::setGamePlayState(GamePlayState state)
|
||||
{
|
||||
m_gamePlayState = state;
|
||||
@@ -802,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) {
|
||||
@@ -1243,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;
|
||||
}
|
||||
@@ -1540,10 +1628,10 @@ 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);
|
||||
}
|
||||
/* --- 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) {
|
||||
@@ -1685,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(
|
||||
@@ -1793,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)
|
||||
@@ -1843,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':
|
||||
@@ -1997,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) */
|
||||
|
||||
@@ -139,6 +139,9 @@ public:
|
||||
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();
|
||||
@@ -172,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;
|
||||
@@ -225,6 +235,10 @@ public:
|
||||
{
|
||||
return m_characterSpawnerSystem.get();
|
||||
}
|
||||
ProceduralMeshSystem *getProceduralMeshSystem() const
|
||||
{
|
||||
return m_proceduralMeshSystem.get();
|
||||
}
|
||||
StartupMenuSystem *getStartupMenuSystem() const
|
||||
{
|
||||
return m_startupMenuSystem.get();
|
||||
@@ -311,6 +325,7 @@ private:
|
||||
bool m_setupComplete = false;
|
||||
bool m_systemsDestroyed = false;
|
||||
bool m_debugBuoyancy = false;
|
||||
bool m_headless = false;
|
||||
|
||||
void destroyEditorSystems();
|
||||
float m_playTime = 0.0f;
|
||||
|
||||
@@ -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:1564–1629)
|
||||
**writes fixups only at the vertices of the road surface geometry** — the
|
||||
top-surface vertices of each wedge and segment. It does **not** iterate beyond
|
||||
the road surface or apply the `laneWidth * 2` fade zone.
|
||||
|
||||
→ **Resolved**: implement falloff now (see section 1.2a).
|
||||
|
||||
#### 1.2a Required Implementation — Perpendicular Falloff
|
||||
|
||||
In `RoadSystem::complyTerrain()`, after writing the road-underside fixups,
|
||||
add a second pass that extends laterally from the road centre line:
|
||||
|
||||
```
|
||||
For each road surface point P (world pos of a top vertex):
|
||||
1. Compute the perpendicular (horizontal) direction away from the road.
|
||||
2. The road width at P = totalLanes(P) * laneWidth.
|
||||
3. Full compliance zone: fixup already written by existing code.
|
||||
4. Fade zone: for lateral offset d in [roadWidth/2, roadWidth/2 + laneWidth*2]:
|
||||
targetY = interpolate(P.y - roadThickness, baseHeight, fadeFactor)
|
||||
where fadeFactor = linear from 1.0 to 0.0 over the fade zone
|
||||
Write targetY into fixup chunk at (P.x + d * perp.x, P.z + d * perp.z)
|
||||
and (P.x - d * perp.x, P.z - d * perp.z).
|
||||
5. Beyond roadWidth/2 + laneWidth*2: no write (fall through to base height).
|
||||
```
|
||||
|
||||
To keep the implementation testable, expose a private helper:
|
||||
|
||||
```cpp
|
||||
static float computeComplianceHeight(float roadSurfaceY, float roadThickness,
|
||||
float baseHeight, float lateralDistance,
|
||||
float halfRoadWidth, float fadeWidth);
|
||||
```
|
||||
|
||||
This helper is a pure function and can be exercised directly in the test.
|
||||
|
||||
### 1.3 Road Collider Debug Draw (New Item M5.9.6)
|
||||
|
||||
Currently `TerrainSystem::TerrainBodyDrawFilter` only tracks terrain body IDs.
|
||||
Road colliders are created by `RoadSystem::createPageCollider()` as static
|
||||
`NON_MOVING` bodies but are not registered with any draw filter.
|
||||
|
||||
**Implementation**:
|
||||
- `RoadSystem` maintains a `std::set<JPH::BodyID> m_roadBodyIds` (populated
|
||||
in `createPageCollider`, cleared in `destroyPageCollider`).
|
||||
- A `RoadBodyDrawFilter` (or a combined filter) is wired into
|
||||
`JoltPhysicsWrapper::setBodyDrawFilter()`.
|
||||
- The `TerrainEditor` gains a "Show Road Colliders" checkbox
|
||||
(`getShowRoadColliders()` / `setShowRoadColliders()` on `TerrainSystem` →
|
||||
forwarded to `RoadSystem`).
|
||||
- Default: off (same behaviour as terrain colliders).
|
||||
|
||||
## 2. Missing Automated Tests (Implementation Targets)
|
||||
|
||||
These tests must be added to `TerrainTests.cpp`. The test runner already
|
||||
registers the existing 9 M5 test functions; these 3 new ones (or 4, if
|
||||
`testRoadColliderRebuild` is separate) will be appended.
|
||||
|
||||
### 2.1 `testRoadColliderInteraction`
|
||||
|
||||
**Purpose**: verify that the generated road `MeshShape` collider produces
|
||||
correct physical contact (headless-compatible — Jolt CPU queries).
|
||||
|
||||
**Steps**:
|
||||
1. Create a terrain entity with a two-node road edge (known positions).
|
||||
2. Pump frames until page activation + road mesh + collider exist.
|
||||
3. Retrieve `RoadPageGeometry::bodyId`.
|
||||
4. **Raycast from above**: `JPH::RRayCast` from `(roadCenter.x, roadSurfaceY + 50, roadCenter.z)` straight down.
|
||||
- Verify hit: body ID matches road body, hit Y ≈ `roadSurfaceY` (± 0.05).
|
||||
5. **Raycast from below**: cast from `(roadCenter.x, roadSurfaceY - 50, roadCenter.z)` upward.
|
||||
- Verify hit: body ID matches road body, hit Y ≈ `roadSurfaceY - roadThickness` (underside of slab).
|
||||
6. **Rebuild**: bump graph version, pump frames, verify old body ID is now invalid (removed) and a new body exists.
|
||||
7. **Teardown**: deactivate terrain, verify body ID is invalid (removed from physics world).
|
||||
|
||||
### 2.2 `testTerrainCompliance`
|
||||
|
||||
**Purpose**: verify `RoadSystem::complyTerrain()` writes fixups that the terrain
|
||||
sampling path observes, including the perpendicular falloff.
|
||||
|
||||
**Steps**:
|
||||
1. Create terrain, add a two-node straight road edge at known positions
|
||||
(e.g. nodes at (100,0,100) and (500,0,100)).
|
||||
2. Pump frames until road mesh exists.
|
||||
3. Call `rs->complyTerrain(ts, roadThickness, laneWidth)`.
|
||||
4. Sample at a point directly under the road centre:
|
||||
- Verify `ts->sampleHeightAt(wx, wz) ≈ roadSurfaceY - roadThickness` (± 0.1).
|
||||
5. Sample at a point far from the road (e.g. (2000, 2000)):
|
||||
- Verify height matches base heightmap (no fixup).
|
||||
6. Verify `ts->getFixupChunkCount()` > 0.
|
||||
7. Test the falloff:
|
||||
- Compute the total half-width at the sample point: `(lanesAtoB + lanesBtoA) * laneWidth / 2`.
|
||||
- Sample at `halfWidth + laneWidth` outside (mid-fade): height should be between the full-compliance value and the base height.
|
||||
- Sample at `halfWidth + laneWidth * 2` outside (fade end): height ≈ base height (± 0.1).
|
||||
- Sample at `halfWidth + laneWidth * 3` outside (beyond fade): height == base height.
|
||||
8. `ts->saveFixups()`, deactivate/reactivate, re-sample same points → same values.
|
||||
9. `ts->clearAllFixups()` → samples revert to base height.
|
||||
|
||||
**Test helper**: `computeComplianceHeight()` unit-test assertions on known inputs (pure math, no scene needed).
|
||||
|
||||
### 2.3 `testRoadSidePrefabs`
|
||||
|
||||
**Purpose**: verify `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 1–2 five times | No growing memory (watch process RSS) |
|
||||
| 4 | Exit editor | Clean shutdown, no Jolt/OGRE assertions in console |
|
||||
|
||||
## 4. Implementation Work Items
|
||||
|
||||
These are the concrete code changes needed to close all gaps. Each item is
|
||||
ordered by dependency.
|
||||
|
||||
| # | Item | Files to modify | 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.1–M5.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.1–3.7) |
|
||||
| Open questions | ✅ All resolved (section 0) |
|
||||
|
||||
**Exit criteria** — Milestone 5 is fully verified when:
|
||||
- [ ] All 8 work items (W1–W8) are implemented.
|
||||
- [ ] `./editSceneEditor --headless --run-terrain-tests=1` passes with all
|
||||
existing + new M5 tests green (expect 22–23 tests per iteration).
|
||||
- [ ] Manual verification walkthroughs 3.1–3.7 are executed and pass.
|
||||
- [ ] `ctest -R editSceneTerrainTest` passes in CI.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -2,6 +2,7 @@
|
||||
#define EDITSCENE_TERRAIN_HPP
|
||||
#pragma once
|
||||
|
||||
#include "RoadGraph.hpp"
|
||||
#include <Ogre.h>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
@@ -27,6 +28,9 @@ struct TerrainComponent {
|
||||
// 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;
|
||||
|
||||
@@ -45,48 +49,29 @@ struct TerrainComponent {
|
||||
|
||||
// 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 configuration parameters.
|
||||
struct RoadConfig {
|
||||
std::string roadMeshTemplate = "road_segment.mesh";
|
||||
float laneWidth = 3.0f;
|
||||
int lanesPerDirection = 1;
|
||||
float roadThickness = 0.3f;
|
||||
float roadLodDistance = 200.0f;
|
||||
float roadVisibilityDistance = 1000.0f;
|
||||
std::string roadMaterialName = "RoadMaterial";
|
||||
};
|
||||
RoadConfig roadConfig;
|
||||
// Road network data model: configuration, nodes, edges, and side prefabs.
|
||||
// See RoadGraph.hpp for detailed field documentation.
|
||||
RoadGraph roadGraph;
|
||||
|
||||
// Road network data (node/edge graph).
|
||||
struct RoadNode {
|
||||
Ogre::Vector3 position;
|
||||
float verticalOffset = 0.0f;
|
||||
int id = 0;
|
||||
// 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;
|
||||
};
|
||||
struct RoadEdge {
|
||||
int nodeA = -1;
|
||||
int nodeB = -1;
|
||||
float roadLevelA = 0.0f;
|
||||
float roadLevelB = 0.0f;
|
||||
int lanesPerDirectionOverride = 0;
|
||||
int lanesAtoB = 0;
|
||||
int lanesBtoA = 0;
|
||||
struct RoadSidePrefab {
|
||||
std::string prefabPath;
|
||||
float edgeT = 0.5f;
|
||||
float sideOffset = 5.0f;
|
||||
bool leftSide = true;
|
||||
};
|
||||
std::vector<RoadSidePrefab> sidePrefabs;
|
||||
};
|
||||
std::vector<RoadNode> roadNodes;
|
||||
std::vector<RoadEdge> roadEdges;
|
||||
DetailNoise detailNoise;
|
||||
|
||||
// Auxiliary maps (foliage density, material masks, etc.).
|
||||
struct AuxMap {
|
||||
@@ -101,7 +86,10 @@ struct TerrainComponent {
|
||||
bool dirty = true;
|
||||
bool rebuildInProgress = false;
|
||||
|
||||
void markDirty() { dirty = true; }
|
||||
void markDirty()
|
||||
{
|
||||
dirty = true;
|
||||
}
|
||||
};
|
||||
|
||||
#endif // EDITSCENE_TERRAIN_HPP
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,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
|
||||
@@ -1,6 +1,7 @@
|
||||
#include <iostream>
|
||||
#include "EditorApp.hpp"
|
||||
#include "systems/SceneSerializer.hpp"
|
||||
#include "systems/TerrainTests.hpp"
|
||||
#include "OgreRoot.h"
|
||||
|
||||
struct ExitAfterFirstFrameListener : public Ogre::FrameListener {
|
||||
@@ -17,6 +18,38 @@ struct ExitAfterFirstFrameListener : public Ogre::FrameListener {
|
||||
}
|
||||
};
|
||||
|
||||
/** 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[])
|
||||
{
|
||||
try {
|
||||
@@ -26,6 +59,8 @@ int main(int argc, char *argv[])
|
||||
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];
|
||||
@@ -35,6 +70,14 @@ int main(int argc, char *argv[])
|
||||
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;
|
||||
}
|
||||
@@ -48,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) {
|
||||
@@ -69,6 +126,11 @@ int main(int argc, char *argv[])
|
||||
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;
|
||||
|
||||
@@ -18,5 +18,8 @@ FileSystem=resources/terrain
|
||||
FileSystem=resources/materials/programs
|
||||
FileSystem=resources/materials/textures
|
||||
|
||||
[LuaScripts]
|
||||
FileSystem=lua-scripts
|
||||
|
||||
[Essential]
|
||||
Zip=/usr/share/OGRE-14/media/packs/SdkTrays.zip
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#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"
|
||||
@@ -80,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);
|
||||
|
||||
@@ -93,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();
|
||||
}
|
||||
@@ -104,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;
|
||||
@@ -157,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;
|
||||
@@ -174,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;
|
||||
@@ -327,29 +387,93 @@ void EditorUISystem::registerModularComponents()
|
||||
|
||||
void EditorUISystem::update(float deltaTime)
|
||||
{
|
||||
if (!m_editorUIEnabled) {
|
||||
// Only render FPS overlay when editor UI is disabled
|
||||
renderFPSOverlay(deltaTime);
|
||||
/* Sculpt mode: left mouse on terrain applies brush. */
|
||||
/* 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 && ts->isSculpting()) {
|
||||
ImGuiIO &io = ImGui::GetIO();
|
||||
if (ImGui::IsMouseDown(ImGuiMouseButton_Left) &&
|
||||
!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);
|
||||
ts->applySculptBrush(hit);
|
||||
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) {
|
||||
renderFPSOverlay(deltaTime);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -517,7 +641,8 @@ void EditorUISystem::renderHierarchyWindow()
|
||||
if (ImGui::MenuItem("Item Registry")) {
|
||||
m_showItemRegistry = true;
|
||||
}
|
||||
if (ImGui::MenuItem("Animation Tree Registry")) {
|
||||
if (ImGui::MenuItem(
|
||||
"Animation Tree Registry")) {
|
||||
m_showAnimationTreeRegistry = true;
|
||||
}
|
||||
ImGui::Separator();
|
||||
@@ -1124,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++;
|
||||
}
|
||||
|
||||
@@ -2372,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();
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
#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"
|
||||
@@ -258,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;
|
||||
|
||||
@@ -352,6 +354,26 @@ private:
|
||||
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
|
||||
@@ -4151,6 +4151,7 @@ nlohmann::json SceneSerializer::serializeTerrain(flecs::entity entity)
|
||||
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;
|
||||
@@ -4161,6 +4162,7 @@ nlohmann::json SceneSerializer::serializeTerrain(flecs::entity entity)
|
||||
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;
|
||||
@@ -4179,19 +4181,29 @@ nlohmann::json SceneSerializer::serializeTerrain(flecs::entity entity)
|
||||
}
|
||||
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.roadConfig.roadMeshTemplate;
|
||||
roadConfigJson["laneWidth"] = tc.roadConfig.laneWidth;
|
||||
roadConfigJson["lanesPerDirection"] = tc.roadConfig.lanesPerDirection;
|
||||
roadConfigJson["roadThickness"] = tc.roadConfig.roadThickness;
|
||||
roadConfigJson["roadLodDistance"] = tc.roadConfig.roadLodDistance;
|
||||
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.roadConfig.roadVisibilityDistance;
|
||||
roadConfigJson["roadMaterialName"] = tc.roadConfig.roadMaterialName;
|
||||
tc.roadGraph.config.roadVisibilityDistance;
|
||||
roadConfigJson["roadMaterialName"] = tc.roadGraph.config.roadMaterialName;
|
||||
json["roadConfig"] = roadConfigJson;
|
||||
|
||||
nlohmann::json roadNodesJson = nlohmann::json::array();
|
||||
for (auto &n : tc.roadNodes) {
|
||||
for (auto &n : tc.roadGraph.nodes) {
|
||||
nlohmann::json nj;
|
||||
nj["id"] = n.id;
|
||||
nj["position"] = { n.position.x, n.position.y, n.position.z };
|
||||
@@ -4201,7 +4213,7 @@ nlohmann::json SceneSerializer::serializeTerrain(flecs::entity entity)
|
||||
json["roadNodes"] = roadNodesJson;
|
||||
|
||||
nlohmann::json roadEdgesJson = nlohmann::json::array();
|
||||
for (auto &e : tc.roadEdges) {
|
||||
for (auto &e : tc.roadGraph.edges) {
|
||||
nlohmann::json ej;
|
||||
ej["nodeA"] = e.nodeA;
|
||||
ej["nodeB"] = e.nodeB;
|
||||
@@ -4225,10 +4237,14 @@ nlohmann::json SceneSerializer::serializeTerrain(flecs::entity entity)
|
||||
}
|
||||
json["roadEdges"] = roadEdgesJson;
|
||||
|
||||
/* Persist binary heightmap alongside the JSON. */
|
||||
/* Persist binary heightmap, blend maps, and aux maps alongside the JSON. */
|
||||
TerrainSystem *ts = TerrainSystem::getInstance();
|
||||
if (ts)
|
||||
if (ts) {
|
||||
ts->saveSceneHeightmap(tc);
|
||||
ts->saveSceneBlendMaps(tc);
|
||||
ts->saveSceneAuxMaps(tc);
|
||||
ts->saveFixups();
|
||||
}
|
||||
|
||||
return json;
|
||||
}
|
||||
@@ -4242,6 +4258,7 @@ void SceneSerializer::deserializeTerrain(flecs::entity entity,
|
||||
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);
|
||||
@@ -4252,6 +4269,7 @@ void SceneSerializer::deserializeTerrain(flecs::entity entity,
|
||||
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);
|
||||
@@ -4270,24 +4288,35 @@ void SceneSerializer::deserializeTerrain(flecs::entity entity,
|
||||
}
|
||||
}
|
||||
|
||||
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.roadConfig.roadMeshTemplate =
|
||||
tc.roadGraph.config.roadMeshTemplate =
|
||||
rcj.value("roadMeshTemplate", "road_segment.mesh");
|
||||
tc.roadConfig.laneWidth = rcj.value("laneWidth", 3.0f);
|
||||
tc.roadConfig.lanesPerDirection =
|
||||
tc.roadGraph.config.laneWidth = rcj.value("laneWidth", 3.0f);
|
||||
tc.roadGraph.config.lanesPerDirection =
|
||||
rcj.value("lanesPerDirection", 1);
|
||||
tc.roadConfig.roadThickness = rcj.value("roadThickness", 0.3f);
|
||||
tc.roadConfig.roadLodDistance = rcj.value("roadLodDistance", 200.0f);
|
||||
tc.roadConfig.roadVisibilityDistance =
|
||||
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.roadConfig.roadMaterialName =
|
||||
tc.roadGraph.config.roadMaterialName =
|
||||
rcj.value("roadMaterialName", "RoadMaterial");
|
||||
}
|
||||
|
||||
if (json.contains("roadNodes") && json["roadNodes"].is_array()) {
|
||||
for (auto &nj : json["roadNodes"]) {
|
||||
TerrainComponent::RoadNode node;
|
||||
RoadNode node;
|
||||
node.id = nj.value("id", 0);
|
||||
node.verticalOffset = nj.value("verticalOffset", 0.0f);
|
||||
if (nj.contains("position") && nj["position"].is_array() &&
|
||||
@@ -4296,13 +4325,13 @@ void SceneSerializer::deserializeTerrain(flecs::entity entity,
|
||||
node.position.y = nj["position"][1];
|
||||
node.position.z = nj["position"][2];
|
||||
}
|
||||
tc.roadNodes.push_back(node);
|
||||
tc.roadGraph.nodes.push_back(node);
|
||||
}
|
||||
}
|
||||
|
||||
if (json.contains("roadEdges") && json["roadEdges"].is_array()) {
|
||||
for (auto &ej : json["roadEdges"]) {
|
||||
TerrainComponent::RoadEdge edge;
|
||||
RoadEdge edge;
|
||||
edge.nodeA = ej.value("nodeA", -1);
|
||||
edge.nodeB = ej.value("nodeB", -1);
|
||||
edge.roadLevelA = ej.value("roadLevelA", 0.0f);
|
||||
@@ -4315,7 +4344,7 @@ void SceneSerializer::deserializeTerrain(flecs::entity entity,
|
||||
if (ej.contains("sidePrefabs") &&
|
||||
ej["sidePrefabs"].is_array()) {
|
||||
for (auto &spj : ej["sidePrefabs"]) {
|
||||
TerrainComponent::RoadEdge::RoadSidePrefab sp;
|
||||
RoadEdge::RoadSidePrefab sp;
|
||||
sp.prefabPath = spj.value("prefabPath", "");
|
||||
sp.edgeT = spj.value("edgeT", 0.5f);
|
||||
sp.sideOffset = spj.value("sideOffset", 5.0f);
|
||||
@@ -4323,10 +4352,12 @@ void SceneSerializer::deserializeTerrain(flecs::entity entity,
|
||||
edge.sidePrefabs.push_back(sp);
|
||||
}
|
||||
}
|
||||
tc.roadEdges.push_back(edge);
|
||||
tc.roadGraph.edges.push_back(edge);
|
||||
}
|
||||
}
|
||||
|
||||
tc.roadGraph.bumpVersion();
|
||||
|
||||
entity.set<TerrainComponent>(tc);
|
||||
|
||||
TerrainSystem *ts = TerrainSystem::getInstance();
|
||||
|
||||
@@ -204,10 +204,15 @@ private:
|
||||
void deserializeWaterPhysics(flecs::entity entity,
|
||||
const nlohmann::json &json);
|
||||
|
||||
// WaterPlane serialization
|
||||
nlohmann::json serializeWaterPlane(flecs::entity entity);
|
||||
// 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,
|
||||
const nlohmann::json &json);
|
||||
|
||||
|
||||
@@ -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
@@ -11,8 +11,11 @@
|
||||
#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>
|
||||
@@ -23,7 +26,12 @@
|
||||
#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:
|
||||
@@ -34,11 +42,17 @@ public:
|
||||
TerrainSystem(const TerrainSystem &) = delete;
|
||||
TerrainSystem &operator=(const TerrainSystem &) = delete;
|
||||
|
||||
static TerrainSystem *getInstance() { return s_instance; }
|
||||
static TerrainSystem *getInstance()
|
||||
{
|
||||
return s_instance;
|
||||
}
|
||||
|
||||
void update(float deltaTime);
|
||||
void deactivate();
|
||||
bool isActive() const { return m_active; }
|
||||
bool isActive() const
|
||||
{
|
||||
return m_active;
|
||||
}
|
||||
float getHeightAt(const Ogre::Vector3 &worldPos) const;
|
||||
|
||||
bool raycastTerrain(const Ogre::Ray &ray, float &outT,
|
||||
@@ -50,6 +64,12 @@ public:
|
||||
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;
|
||||
@@ -60,30 +80,290 @@ public:
|
||||
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, SplatPaint };
|
||||
bool getSculptMode() const { return m_sculptMode; }
|
||||
void setSculptMode(bool v) { m_sculptMode = v; }
|
||||
bool isSculpting() const { return m_sculptMode; }
|
||||
SculptTool getSculptTool() const { return m_sculptTool; }
|
||||
void setSculptTool(SculptTool t) { m_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; }
|
||||
int getSculptLayerIndex() const { return m_sculptLayerIndex; }
|
||||
void setSculptLayerIndex(int i) { m_sculptLayerIndex = i; }
|
||||
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);
|
||||
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,
|
||||
@@ -119,8 +399,8 @@ private:
|
||||
TerrainSystem *owner = nullptr;
|
||||
};
|
||||
|
||||
class CustomTerrainDefiner :
|
||||
public Ogre::TerrainPagedWorldSection::TerrainDefiner {
|
||||
class CustomTerrainDefiner
|
||||
: public Ogre::TerrainPagedWorldSection::TerrainDefiner {
|
||||
public:
|
||||
CustomTerrainDefiner(TerrainSystem *sys)
|
||||
: Ogre::TerrainPagedWorldSection::TerrainDefiner()
|
||||
@@ -129,6 +409,7 @@ private:
|
||||
}
|
||||
void define(Ogre::TerrainGroup *terrainGroup, long x,
|
||||
long y) override;
|
||||
|
||||
private:
|
||||
TerrainSystem *m_sys;
|
||||
};
|
||||
@@ -144,10 +425,21 @@ private:
|
||||
{
|
||||
m_terrainIds.erase(id);
|
||||
}
|
||||
void clear() { m_terrainIds.clear(); }
|
||||
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);
|
||||
@@ -164,6 +456,7 @@ private:
|
||||
JoltPhysicsWrapper *m_physics = nullptr;
|
||||
bool m_active = false;
|
||||
bool m_showTerrainColliders = false;
|
||||
bool m_showRoadColliders = false;
|
||||
|
||||
static TerrainSystem *s_instance;
|
||||
|
||||
@@ -177,7 +470,7 @@ private:
|
||||
std::unique_ptr<DummyPageProvider> mDummyPageProvider;
|
||||
|
||||
std::unordered_map<uint64_t, TerrainCollider> mColliders;
|
||||
std::deque<std::pair<long, long>> mColliderCreateQueue;
|
||||
std::deque<std::pair<long, long> > mColliderCreateQueue;
|
||||
std::set<uint64_t> mPendingColliderPages;
|
||||
std::mutex m_colliderQueueMutex;
|
||||
TerrainBodyDrawFilter mBodyDrawFilter;
|
||||
@@ -198,20 +491,110 @@ private:
|
||||
|
||||
std::set<uint64_t> m_dirtyPages;
|
||||
std::vector<uint64_t> m_reloadQueue;
|
||||
bool hasHeightmap() const { return m_heightmapLoaded; }
|
||||
|
||||
/* 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 = 5.0f;
|
||||
float m_sculptRadius = 100.0f;
|
||||
float m_sculptStrength = 0.5f;
|
||||
int m_sculptLayerIndex = 0;
|
||||
|
||||
/* 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 */
|
||||
@@ -1905,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"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user