diff --git a/.github/workflows/cmake-multi-platform.yml b/.github/workflows/cmake-multi-platform.yml
index 2c6b9858..a33fe073 100644
--- a/.github/workflows/cmake-multi-platform.yml
+++ b/.github/workflows/cmake-multi-platform.yml
@@ -107,7 +107,7 @@ jobs:
-DOMATH_BUILD_TESTS=ON \
-DOMATH_BUILD_BENCHMARK=OFF \
-DOMATH_ENABLE_COVERAGE=${{ matrix.coverage == true && 'ON' || 'OFF' }} \
- -DVCPKG_MANIFEST_FEATURES="imgui;avx2;tests"
+ -DVCPKG_MANIFEST_FEATURES="imgui;avx2;tests;lua"
- name: Build
shell: bash
@@ -193,7 +193,7 @@ jobs:
-DOMATH_BUILD_TESTS=ON \
-DOMATH_BUILD_BENCHMARK=OFF \
-DOMATH_ENABLE_COVERAGE=OFF \
- -DVCPKG_MANIFEST_FEATURES="imgui;avx2;tests"
+ -DVCPKG_MANIFEST_FEATURES="imgui;avx2;tests;lua"
- name: Build
shell: bash
@@ -234,7 +234,7 @@ jobs:
-DOMATH_ENABLE_COVERAGE=ON \
-DOMATH_THREAT_WARNING_AS_ERROR=OFF \
-DCMAKE_BUILD_TYPE=Debug \
- -DVCPKG_MANIFEST_FEATURES="imgui;avx2;tests"
+ -DVCPKG_MANIFEST_FEATURES="imgui;avx2;tests;lua"
cmake --build cmake-build/build/${{ matrix.preset }} --config Debug --target unit_tests omath
- name: Run Tests (Generates .profraw)
@@ -373,7 +373,7 @@ jobs:
-DOMATH_BUILD_TESTS=ON \
-DOMATH_BUILD_BENCHMARK=OFF \
-DOMATH_ENABLE_COVERAGE=${{ matrix.coverage == true && 'ON' || 'OFF' }} \
- -DVCPKG_MANIFEST_FEATURES="imgui;avx2;tests"
+ -DVCPKG_MANIFEST_FEATURES="imgui;avx2;tests;lua"
- name: Build
shell: bash
@@ -450,7 +450,7 @@ jobs:
-DVCPKG_INSTALL_OPTIONS="--allow-unsupported" \
-DOMATH_BUILD_TESTS=ON \
-DOMATH_BUILD_BENCHMARK=OFF \
- -DVCPKG_MANIFEST_FEATURES="imgui;tests"
+ -DVCPKG_MANIFEST_FEATURES="imgui;tests;lua"
- name: Build
shell: bash
@@ -509,7 +509,7 @@ jobs:
cmake --preset ${{ matrix.preset }} \
-DOMATH_BUILD_TESTS=ON \
-DOMATH_BUILD_BENCHMARK=OFF \
- -DVCPKG_MANIFEST_FEATURES="imgui;avx2;tests" \
+ -DVCPKG_MANIFEST_FEATURES="imgui;avx2;tests;lua" \
-DVCPKG_INSTALL_OPTIONS="--allow-unsupported"
cmake --build cmake-build/build/${{ matrix.preset }} --target unit_tests omath
./out/Release/unit_tests
@@ -581,7 +581,7 @@ jobs:
-DVCPKG_INSTALL_OPTIONS="--allow-unsupported" \
-DOMATH_BUILD_TESTS=ON \
-DOMATH_BUILD_BENCHMARK=OFF \
- -DVCPKG_MANIFEST_FEATURES="imgui;tests"
+ -DVCPKG_MANIFEST_FEATURES="imgui;tests;lua"
- name: Build
shell: bash
@@ -650,7 +650,7 @@ jobs:
-DVCPKG_INSTALL_OPTIONS="--allow-unsupported" \
-DOMATH_BUILD_TESTS=ON \
-DOMATH_BUILD_BENCHMARK=OFF \
- -DVCPKG_MANIFEST_FEATURES="imgui;tests"
+ -DVCPKG_MANIFEST_FEATURES="imgui;tests;lua"
- name: Build
shell: bash
@@ -735,7 +735,7 @@ jobs:
-DVCPKG_INSTALL_OPTIONS="--allow-unsupported" \
-DOMATH_BUILD_TESTS=ON \
-DOMATH_BUILD_BENCHMARK=OFF \
- -DVCPKG_MANIFEST_FEATURES="imgui;tests"
+ -DVCPKG_MANIFEST_FEATURES="imgui;tests;lua"
- name: Build
run: |
@@ -800,7 +800,7 @@ jobs:
-DOMATH_BUILD_TESTS=ON \
-DOMATH_BUILD_BENCHMARK=ON \
-DOMATH_ENABLE_VALGRIND=ON \
- -DVCPKG_MANIFEST_FEATURES="imgui;avx2;tests;benchmark"
+ -DVCPKG_MANIFEST_FEATURES="imgui;avx2;lua;tests;benchmark"
- name: Build All Targets
shell: bash
diff --git a/.luarc.json b/.luarc.json
new file mode 100644
index 00000000..4ffc1da9
--- /dev/null
+++ b/.luarc.json
@@ -0,0 +1,5 @@
+{
+ "diagnostics.globals": [
+ "omath"
+ ]
+}
\ No newline at end of file
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 29a20e7d..ff324be4 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -31,6 +31,9 @@ option(OMATH_SUPRESS_SAFETY_CHECKS
option(OMATH_ENABLE_COVERAGE "Enable coverage" OFF)
option(OMATH_ENABLE_FORCE_INLINE
"Will for compiler to make some functions to be force inlined no matter what" ON)
+
+option(OMATH_ENABLE_LUA
+ "omath bindings for lua" OFF)
if(VCPKG_MANIFEST_FEATURES)
foreach(omath_feature IN LISTS VCPKG_MANIFEST_FEATURES)
if(omath_feature STREQUAL "imgui")
@@ -43,6 +46,8 @@ if(VCPKG_MANIFEST_FEATURES)
set(OMATH_BUILD_BENCHMARK ON)
elseif(omath_feature STREQUAL "examples")
set(OMATH_BUILD_EXAMPLES ON)
+ elseif(omath_feature STREQUAL "lua")
+ set(OMATH_ENABLE_LUA ON)
endif()
endforeach()
@@ -72,6 +77,7 @@ if(${PROJECT_IS_TOP_LEVEL})
message(STATUS "[${PROJECT_NAME}]: Building using vcpkg ${OMATH_BUILD_VIA_VCPKG}")
message(STATUS "[${PROJECT_NAME}]: Coverage feature status ${OMATH_ENABLE_COVERAGE}")
message(STATUS "[${PROJECT_NAME}]: Valgrind feature status ${OMATH_ENABLE_VALGRIND}")
+ message(STATUS "[${PROJECT_NAME}]: Lua feature status ${OMATH_ENABLE_LUA}")
endif()
file(GLOB_RECURSE OMATH_SOURCES CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/source/*.cpp")
@@ -83,6 +89,17 @@ else()
add_library(${PROJECT_NAME} STATIC ${OMATH_SOURCES} ${OMATH_HEADERS})
endif()
+if (OMATH_ENABLE_LUA)
+ target_compile_definitions(${PROJECT_NAME} PUBLIC OMATH_ENABLE_LUA)
+
+ find_package(Lua REQUIRED)
+ target_include_directories(${PROJECT_NAME} PRIVATE ${LUA_INCLUDE_DIR})
+ target_link_libraries(${PROJECT_NAME} PRIVATE ${LUA_LIBRARIES})
+
+ find_path(SOL2_INCLUDE_DIRS "sol/abort.hpp")
+ target_include_directories(${PROJECT_NAME} PRIVATE ${SOL2_INCLUDE_DIRS})
+endif ()
+
add_library(${PROJECT_NAME}::${PROJECT_NAME} ALIAS ${PROJECT_NAME})
target_compile_definitions(${PROJECT_NAME} PUBLIC OMATH_VERSION="${PROJECT_VERSION}")
@@ -174,6 +191,12 @@ elseif(OMATH_THREAT_WARNING_AS_ERROR)
target_compile_options(${PROJECT_NAME} PRIVATE -Wall -Wextra -Wpedantic -Werror)
endif()
+if (CMAKE_CXX_COMPILER_ID STREQUAL "MSVC")
+ target_compile_options(${PROJECT_NAME} PRIVATE /bigobj)
+endif()
+if (CMAKE_CXX_COMPILER_ID STREQUAL "GNU" AND CMAKE_HOST_SYSTEM_NAME EQUAL "Windows")
+ target_compile_options(${PROJECT_NAME} PRIVATE -mbig-obj)
+endif()
# Windows SDK redefine min/max via preprocessor and break std::min and std::max
if(CMAKE_CXX_COMPILER_ID STREQUAL "MSVC")
target_compile_definitions(${PROJECT_NAME} INTERFACE NOMINMAX)
diff --git a/CMakePresets.json b/CMakePresets.json
index 5efed251..8d27ee1d 100644
--- a/CMakePresets.json
+++ b/CMakePresets.json
@@ -145,7 +145,7 @@
"hidden": true,
"inherits": ["linux-base", "vcpkg-base"],
"cacheVariables": {
- "VCPKG_MANIFEST_FEATURES": "tests;imgui;avx2"
+ "VCPKG_MANIFEST_FEATURES": "tests;imgui;avx2;lua"
}
},
{
@@ -235,7 +235,7 @@
"hidden": true,
"inherits": ["darwin-base", "vcpkg-base"],
"cacheVariables": {
- "VCPKG_MANIFEST_FEATURES": "tests;imgui;avx2;examples"
+ "VCPKG_MANIFEST_FEATURES": "tests;imgui;avx2;examples;lua"
}
},
{
diff --git a/README.md b/README.md
index 260e81b5..ec2a968d 100644
--- a/README.md
+++ b/README.md
@@ -9,6 +9,7 @@
[](https://www.codefactor.io/repository/github/orange-cpp/omath)

[](https://repology.org/project/orange-math/versions)
+

[](https://discord.gg/eDgdaWbqwZ)
[](https://t.me/orangennotes)
@@ -83,6 +84,7 @@ if (auto screen = camera.world_to_screen(world_position)) {
- **Engine support**: Supports coordinate systems of **Source, Unity, Unreal, Frostbite, IWEngine, CryEngine and canonical OpenGL**.
- **Cross platform**: Supports Windows, MacOS and Linux.
- **Algorithms**: Has ability to scan for byte pattern with wildcards in ELF/Mach-O/PE files/modules, binary slices, works even with Wine apps.
+- **Scripting**: Supports to make scripts in Lua out of box
- **Battle tested**: It's already used by some big players on the market like wraith.su and bluedream.ltd
diff --git a/include/omath/lua/lua.hpp b/include/omath/lua/lua.hpp
new file mode 100644
index 00000000..45c0942f
--- /dev/null
+++ b/include/omath/lua/lua.hpp
@@ -0,0 +1,25 @@
+//
+// Created by orange on 07.03.2026.
+//
+#pragma once
+#ifdef OMATH_ENABLE_LUA
+#include
+namespace omath::lua
+{
+ class LuaInterpreter final
+ {
+ public:
+ static void register_lib(lua_State* lua_state);
+
+ private:
+ static void register_vec2(sol::table& omath_table);
+ static void register_vec3(sol::table& omath_table);
+ static void register_vec4(sol::table& omath_table);
+ static void register_color(sol::table& omath_table);
+ static void register_triangle(sol::table& omath_table);
+ static void register_shared_types(sol::table& omath_table);
+ static void register_engines(sol::table& omath_table);
+ static void register_pattern_scan(sol::table& omath_table);
+ };
+}
+#endif
\ No newline at end of file
diff --git a/source/lua/lua.cpp b/source/lua/lua.cpp
new file mode 100644
index 00000000..0d226cea
--- /dev/null
+++ b/source/lua/lua.cpp
@@ -0,0 +1,27 @@
+//
+// Created by orange on 07.03.2026.
+//
+#ifdef OMATH_ENABLE_LUA
+#include "lua.hpp"
+#include
+#include "omath/lua/lua.hpp"
+
+namespace omath::lua
+{
+ void LuaInterpreter::register_lib(lua_State* lua_state)
+ {
+ sol::state_view lua(lua_state);
+
+ auto omath_table = lua["omath"].get_or_create();
+
+ register_vec2(omath_table);
+ register_vec3(omath_table);
+ register_vec4(omath_table);
+ register_color(omath_table);
+ register_triangle(omath_table);
+ register_shared_types(omath_table);
+ register_engines(omath_table);
+ register_pattern_scan(omath_table);
+ }
+} // namespace omath::lua
+#endif
diff --git a/source/lua/lua_color.cpp b/source/lua/lua_color.cpp
new file mode 100644
index 00000000..8e84fdaf
--- /dev/null
+++ b/source/lua/lua_color.cpp
@@ -0,0 +1,46 @@
+//
+// Created by orange on 07.03.2026.
+//
+#ifdef OMATH_ENABLE_LUA
+#include "omath/lua/lua.hpp"
+#include
+#include
+
+namespace omath::lua
+{
+ void LuaInterpreter::register_color(sol::table& omath_table)
+ {
+ omath_table.new_usertype(
+ "Color",
+ sol::factories([](float r, float g, float b, float a) { return omath::Color(r, g, b, a); },
+ []() { return omath::Color(); }),
+
+ "from_rgba", [](uint8_t r, uint8_t g, uint8_t b, uint8_t a)
+ { return omath::Color::from_rgba(r, g, b, a); }, "from_hsv",
+ sol::overload([](float h, float s, float v) { return omath::Color::from_hsv(h, s, v); },
+ [](const omath::Hsv& hsv) { return omath::Color::from_hsv(hsv); }),
+ "red", []() { return omath::Color::red(); }, "green", []() { return omath::Color::green(); }, "blue",
+ []() { return omath::Color::blue(); },
+
+ "r", sol::property([](const omath::Color& c) { return c.value().x; }), "g",
+ sol::property([](const omath::Color& c) { return c.value().y; }), "b",
+ sol::property([](const omath::Color& c) { return c.value().z; }), "a",
+ sol::property([](const omath::Color& c) { return c.value().w; }),
+
+ "to_hsv", &omath::Color::to_hsv, "set_hue", &omath::Color::set_hue, "set_saturation",
+ &omath::Color::set_saturation, "set_value", &omath::Color::set_value, "blend", &omath::Color::blend,
+
+ sol::meta_function::to_string, &omath::Color::to_string);
+
+ omath_table.new_usertype(
+ "Hsv", sol::constructors(), "hue",
+ sol::property([](const omath::Hsv& h) { return h.hue; }, [](omath::Hsv& h, float val) { h.hue = val; }),
+ "saturation",
+ sol::property([](const omath::Hsv& h) { return h.saturation; },
+ [](omath::Hsv& h, float val) { h.saturation = val; }),
+ "value",
+ sol::property([](const omath::Hsv& h) { return h.value; },
+ [](omath::Hsv& h, float val) { h.value = val; }));
+ }
+} // namespace omath::lua::detail
+#endif
diff --git a/source/lua/lua_engines.cpp b/source/lua/lua_engines.cpp
new file mode 100644
index 00000000..0467446b
--- /dev/null
+++ b/source/lua/lua_engines.cpp
@@ -0,0 +1,227 @@
+//
+// Created by orange on 07.03.2026.
+//
+#ifdef OMATH_ENABLE_LUA
+#include "omath/lua/lua.hpp"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace
+{
+ // ---- Canonical shared C++ type aliases ----------------------------------
+ // Each unique template instantiation must be registered exactly once.
+
+ using PitchAngle90 = omath::Angle;
+ using PitchAngle89 = omath::Angle;
+ using SharedYawRoll = omath::Angle;
+ using SharedFoV = omath::Angle;
+ using ViewAngles90 = omath::ViewAngles;
+ using ViewAngles89 = omath::ViewAngles;
+
+ std::string projection_error_to_string(omath::projection::Error e)
+ {
+ switch (e)
+ {
+ case omath::projection::Error::WORLD_POSITION_IS_OUT_OF_SCREEN_BOUNDS:
+ return "world position is out of screen bounds";
+ case omath::projection::Error::INV_VIEW_PROJ_MAT_DET_EQ_ZERO:
+ return "inverse view-projection matrix determinant is zero";
+ }
+ return "unknown error";
+ }
+
+ template
+ void register_angle(sol::table& table, const char* name)
+ {
+ table.new_usertype(
+ name, sol::no_constructor, "from_degrees", &AngleType::from_degrees, "from_radians",
+ &AngleType::from_radians, "as_degrees", &AngleType::as_degrees, "as_radians", &AngleType::as_radians,
+ "sin", &AngleType::sin, "cos", &AngleType::cos, "tan", &AngleType::tan, "cot", &AngleType::cot,
+ sol::meta_function::addition, [](const AngleType& a, const AngleType& b)
+ { return AngleType::from_degrees(a.as_degrees() + b.as_degrees()); }, sol::meta_function::subtraction,
+ [](const AngleType& a, const AngleType& b)
+ { return AngleType::from_degrees(a.as_degrees() - b.as_degrees()); }, sol::meta_function::unary_minus,
+ [](const AngleType& a) { return AngleType::from_degrees(-a.as_degrees()); },
+ sol::meta_function::equal_to, [](const AngleType& a, const AngleType& b) { return a == b; },
+ sol::meta_function::to_string, [](const AngleType& a) { return std::format("{}deg", a.as_degrees()); });
+ }
+
+ // Set aliases in an engine subtable pointing to the already-registered shared types
+ template
+ void set_engine_aliases(sol::table& engine_table, sol::table& types)
+ {
+ if constexpr (std::is_same_v)
+ engine_table["PitchAngle"] = types["PitchAngle90"];
+ else
+ engine_table["PitchAngle"] = types["PitchAngle89"];
+
+ engine_table["YawAngle"] = types["YawRoll"];
+ engine_table["RollAngle"] = types["YawRoll"];
+ engine_table["FieldOfView"] = types["FieldOfView"];
+ engine_table["ViewPort"] = types["ViewPort"];
+
+ if constexpr (std::is_same_v)
+ engine_table["ViewAngles"] = types["ViewAngles90"];
+ else
+ engine_table["ViewAngles"] = types["ViewAngles89"];
+ }
+
+ // Register an engine: alias shared types, register unique Camera
+ template
+ void register_engine(sol::table& omath_table, const char* subtable_name)
+ {
+ using PitchAngle = typename EngineTraits::PitchAngle;
+ using ViewAngles = typename EngineTraits::ViewAngles;
+ using Camera = typename EngineTraits::Camera;
+
+ auto engine_table = omath_table[subtable_name].get_or_create();
+ auto types = omath_table["_types"].get();
+
+ set_engine_aliases(engine_table, types);
+
+ engine_table.new_usertype(
+ "Camera",
+ sol::constructors&, const ViewAngles&,
+ const omath::projection::ViewPort&, const omath::projection::FieldOfView&,
+ float, float)>(),
+ "look_at", &Camera::look_at, "get_forward", &Camera::get_forward, "get_right", &Camera::get_right,
+ "get_up", &Camera::get_up, "get_origin", &Camera::get_origin, "get_view_angles",
+ &Camera::get_view_angles, "get_near_plane", &Camera::get_near_plane, "get_far_plane",
+ &Camera::get_far_plane, "get_field_of_view", &Camera::get_field_of_view, "set_origin",
+ &Camera::set_origin, "set_view_angles", &Camera::set_view_angles, "set_view_port",
+ &Camera::set_view_port, "set_field_of_view", &Camera::set_field_of_view, "set_near_plane",
+ &Camera::set_near_plane, "set_far_plane", &Camera::set_far_plane,
+
+ "world_to_screen",
+ [](const Camera& cam, const omath::Vector3& pos)
+ -> std::tuple>, sol::optional>
+ {
+ auto result = cam.world_to_screen(pos);
+ if (result)
+ return {*result, sol::nullopt};
+ return {sol::nullopt, projection_error_to_string(result.error())};
+ },
+
+ "screen_to_world",
+ [](const Camera& cam, const omath::Vector3& pos)
+ -> std::tuple>, sol::optional>
+ {
+ auto result = cam.screen_to_world(pos);
+ if (result)
+ return {*result, sol::nullopt};
+ return {sol::nullopt, projection_error_to_string(result.error())};
+ });
+ }
+
+ // ---- Engine trait structs -----------------------------------------------
+
+ struct OpenGLEngineTraits
+ {
+ using PitchAngle = omath::opengl_engine::PitchAngle;
+ using ViewAngles = omath::opengl_engine::ViewAngles;
+ using Camera = omath::opengl_engine::Camera;
+ };
+ struct FrostbiteEngineTraits
+ {
+ using PitchAngle = omath::frostbite_engine::PitchAngle;
+ using ViewAngles = omath::frostbite_engine::ViewAngles;
+ using Camera = omath::frostbite_engine::Camera;
+ };
+ struct IWEngineTraits
+ {
+ using PitchAngle = omath::iw_engine::PitchAngle;
+ using ViewAngles = omath::iw_engine::ViewAngles;
+ using Camera = omath::iw_engine::Camera;
+ };
+ struct SourceEngineTraits
+ {
+ using PitchAngle = omath::source_engine::PitchAngle;
+ using ViewAngles = omath::source_engine::ViewAngles;
+ using Camera = omath::source_engine::Camera;
+ };
+ struct UnityEngineTraits
+ {
+ using PitchAngle = omath::unity_engine::PitchAngle;
+ using ViewAngles = omath::unity_engine::ViewAngles;
+ using Camera = omath::unity_engine::Camera;
+ };
+ struct UnrealEngineTraits
+ {
+ using PitchAngle = omath::unreal_engine::PitchAngle;
+ using ViewAngles = omath::unreal_engine::ViewAngles;
+ using Camera = omath::unreal_engine::Camera;
+ };
+ struct CryEngineTraits
+ {
+ using PitchAngle = omath::cry_engine::PitchAngle;
+ using ViewAngles = omath::cry_engine::ViewAngles;
+ using Camera = omath::cry_engine::Camera;
+ };
+} // namespace
+
+namespace omath::lua
+{
+ void LuaInterpreter::register_shared_types(sol::table& omath_table)
+ {
+ auto t = omath_table["_types"].get_or_create();
+
+ register_angle(t, "PitchAngle90");
+ register_angle(t, "PitchAngle89");
+ register_angle(t, "YawRoll");
+ register_angle(t, "FieldOfView");
+
+ t.new_usertype(
+ "ViewPort", sol::factories([](float w, float h) { return omath::projection::ViewPort{w, h}; }), "width",
+ sol::property([](const omath::projection::ViewPort& vp) { return vp.m_width; },
+ [](omath::projection::ViewPort& vp, float val) { vp.m_width = val; }),
+ "height",
+ sol::property([](const omath::projection::ViewPort& vp) { return vp.m_height; },
+ [](omath::projection::ViewPort& vp, float val) { vp.m_height = val; }),
+ "aspect_ratio", &omath::projection::ViewPort::aspect_ratio);
+
+ t.new_usertype(
+ "ViewAngles90",
+ sol::factories([](PitchAngle90 p, SharedYawRoll y, SharedYawRoll r) { return ViewAngles90{p, y, r}; }),
+ "pitch",
+ sol::property([](const ViewAngles90& va) { return va.pitch; },
+ [](ViewAngles90& va, const PitchAngle90& val) { va.pitch = val; }),
+ "yaw",
+ sol::property([](const ViewAngles90& va) { return va.yaw; },
+ [](ViewAngles90& va, const SharedYawRoll& val) { va.yaw = val; }),
+ "roll",
+ sol::property([](const ViewAngles90& va) { return va.roll; },
+ [](ViewAngles90& va, const SharedYawRoll& val) { va.roll = val; }));
+
+ t.new_usertype(
+ "ViewAngles89",
+ sol::factories([](PitchAngle89 p, SharedYawRoll y, SharedYawRoll r) { return ViewAngles89{p, y, r}; }),
+ "pitch",
+ sol::property([](const ViewAngles89& va) { return va.pitch; },
+ [](ViewAngles89& va, const PitchAngle89& val) { va.pitch = val; }),
+ "yaw",
+ sol::property([](const ViewAngles89& va) { return va.yaw; },
+ [](ViewAngles89& va, const SharedYawRoll& val) { va.yaw = val; }),
+ "roll",
+ sol::property([](const ViewAngles89& va) { return va.roll; },
+ [](ViewAngles89& va, const SharedYawRoll& val) { va.roll = val; }));
+ }
+
+ void LuaInterpreter::register_engines(sol::table& omath_table)
+ {
+ register_engine(omath_table, "opengl");
+ register_engine(omath_table, "frostbite");
+ register_engine(omath_table, "iw");
+ register_engine(omath_table, "source");
+ register_engine(omath_table, "unity");
+ register_engine(omath_table, "unreal");
+ register_engine(omath_table, "cry");
+ }
+} // namespace omath::lua::detail
+#endif
diff --git a/source/lua/lua_pattern_scan.cpp b/source/lua/lua_pattern_scan.cpp
new file mode 100644
index 00000000..56345a4f
--- /dev/null
+++ b/source/lua/lua_pattern_scan.cpp
@@ -0,0 +1,104 @@
+//
+// Created by orange on 10.03.2026.
+//
+#ifdef OMATH_ENABLE_LUA
+#include "omath/lua/lua.hpp"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#endif
+
+namespace omath::lua
+{
+ void LuaInterpreter::register_pattern_scan(sol::table& omath_table)
+ {
+ omath_table.new_usertype(
+ "SectionScanResult", sol::no_constructor,
+ "virtual_base_addr",
+ sol::property([](const SectionScanResult& r) { return r.virtual_base_addr; }),
+ "raw_base_addr",
+ sol::property([](const SectionScanResult& r) { return r.raw_base_addr; }),
+ "target_offset",
+ sol::property([](const SectionScanResult& r) { return r.target_offset; }),
+ sol::meta_function::to_string,
+ [](const SectionScanResult& r)
+ {
+ return std::format("SectionScanResult(vbase=0x{:X}, raw_base=0x{:X}, offset={})",
+ r.virtual_base_addr, r.raw_base_addr, r.target_offset);
+ });
+
+ // Generic scanner: accepts a Lua string as a byte buffer
+ auto ps_table = omath_table["PatternScanner"].get_or_create();
+ ps_table["scan"] = [](const std::string& data, const std::string& pattern) -> sol::optional
+ {
+ const auto* begin = reinterpret_cast(data.data());
+ const auto* end = begin + data.size();
+ const auto* result = PatternScanner::scan_for_pattern(begin, end, pattern);
+ if (result == end)
+ return sol::nullopt;
+ return std::distance(begin, result);
+ };
+
+ auto pe_table = omath_table["PePatternScanner"].get_or_create();
+ pe_table["scan_in_module"] = [](std::uintptr_t base_addr, const std::string& pattern,
+ sol::optional section) -> sol::optional
+ {
+ auto result = PePatternScanner::scan_for_pattern_in_loaded_module(reinterpret_cast(base_addr),
+ pattern, section.value_or(".text"));
+ if (!result)
+ return sol::nullopt;
+ return *result;
+ };
+ pe_table["scan_in_file"] = [](const std::string& path, const std::string& pattern,
+ sol::optional section) -> sol::optional
+ {
+ auto result = PePatternScanner::scan_for_pattern_in_file(std::filesystem::path(path), pattern,
+ section.value_or(".text"));
+ if (!result)
+ return sol::nullopt;
+ return *result;
+ };
+ auto elf_table = omath_table["ElfPatternScanner"].get_or_create();
+ elf_table["scan_in_module"] = [](std::uintptr_t base_addr, const std::string& pattern,
+ sol::optional section) -> sol::optional
+ {
+ auto result = ElfPatternScanner::scan_for_pattern_in_loaded_module(reinterpret_cast(base_addr),
+ pattern, section.value_or(".text"));
+ if (!result)
+ return sol::nullopt;
+ return *result;
+ };
+ elf_table["scan_in_file"] = [](const std::string& path, const std::string& pattern,
+ sol::optional section) -> sol::optional
+ {
+ auto result = ElfPatternScanner::scan_for_pattern_in_file(std::filesystem::path(path), pattern,
+ section.value_or(".text"));
+ if (!result)
+ return sol::nullopt;
+ return *result;
+ };
+ auto macho_table = omath_table["MachOPatternScanner"].get_or_create();
+ macho_table["scan_in_module"] = [](std::uintptr_t base_addr, const std::string& pattern,
+ sol::optional section) -> sol::optional
+ {
+ auto result = MachOPatternScanner::scan_for_pattern_in_loaded_module(
+ reinterpret_cast(base_addr), pattern, section.value_or("__text"));
+ if (!result)
+ return sol::nullopt;
+ return *result;
+ };
+ macho_table["scan_in_file"] = [](const std::string& path, const std::string& pattern,
+ sol::optional section) -> sol::optional
+ {
+ auto result = MachOPatternScanner::scan_for_pattern_in_file(std::filesystem::path(path), pattern,
+ section.value_or("__text"));
+ if (!result)
+ return sol::nullopt;
+ return *result;
+ };
+ }
+} // namespace omath::lua
\ No newline at end of file
diff --git a/source/lua/lua_triangle.cpp b/source/lua/lua_triangle.cpp
new file mode 100644
index 00000000..28ecdcfc
--- /dev/null
+++ b/source/lua/lua_triangle.cpp
@@ -0,0 +1,48 @@
+//
+// Created by orange on 10.03.2026.
+//
+#ifdef OMATH_ENABLE_LUA
+#include "omath/lua/lua.hpp"
+#include
+#include
+
+namespace omath::lua
+{
+ void LuaInterpreter::register_triangle(sol::table& omath_table)
+ {
+ using Vec3f = omath::Vector3;
+ using Tri3f = omath::Triangle;
+
+ omath_table.new_usertype(
+ "Triangle", sol::constructors(),
+
+ "vertex1",
+ sol::property([](const Tri3f& t) { return t.m_vertex1; },
+ [](Tri3f& t, const Vec3f& v) { t.m_vertex1 = v; }),
+ "vertex2",
+ sol::property([](const Tri3f& t) { return t.m_vertex2; },
+ [](Tri3f& t, const Vec3f& v) { t.m_vertex2 = v; }),
+ "vertex3",
+ sol::property([](const Tri3f& t) { return t.m_vertex3; },
+ [](Tri3f& t, const Vec3f& v) { t.m_vertex3 = v; }),
+
+ "calculate_normal", &Tri3f::calculate_normal,
+ "side_a_length", &Tri3f::side_a_length,
+ "side_b_length", &Tri3f::side_b_length,
+ "side_a_vector", &Tri3f::side_a_vector,
+ "side_b_vector", &Tri3f::side_b_vector,
+ "hypot", &Tri3f::hypot,
+ "is_rectangular", &Tri3f::is_rectangular,
+ "mid_point", &Tri3f::mid_point,
+
+ sol::meta_function::to_string,
+ [](const Tri3f& t)
+ {
+ return std::format("Triangle(({}, {}, {}), ({}, {}, {}), ({}, {}, {}))",
+ t.m_vertex1.x, t.m_vertex1.y, t.m_vertex1.z,
+ t.m_vertex2.x, t.m_vertex2.y, t.m_vertex2.z,
+ t.m_vertex3.x, t.m_vertex3.y, t.m_vertex3.z);
+ });
+ }
+} // namespace omath::lua
+#endif
diff --git a/source/lua/lua_vec2.cpp b/source/lua/lua_vec2.cpp
new file mode 100644
index 00000000..e4196213
--- /dev/null
+++ b/source/lua/lua_vec2.cpp
@@ -0,0 +1,54 @@
+//
+// Created by orange on 07.03.2026.
+//
+#ifdef OMATH_ENABLE_LUA
+#include "omath/lua/lua.hpp"
+#include
+#include
+
+namespace omath::lua
+{
+ void LuaInterpreter::register_vec2(sol::table& omath_table)
+ {
+ using Vec2f = omath::Vector2;
+
+ omath_table.new_usertype(
+ "Vec2", sol::constructors(),
+
+ "x", sol::property([](const Vec2f& v) { return v.x; }, [](Vec2f& v, const float val) { v.x = val; }),
+ "y", sol::property([](const Vec2f& v) { return v.y; }, [](Vec2f& v, const float val) { v.y = val; }),
+
+ sol::meta_function::addition, sol::resolve(&Vec2f::operator+),
+ sol::meta_function::subtraction, sol::resolve(&Vec2f::operator-),
+ sol::meta_function::unary_minus, sol::resolve(&Vec2f::operator-),
+ sol::meta_function::equal_to, &Vec2f::operator==,
+ sol::meta_function::less_than, sol::resolve(&Vec2f::operator<),
+ sol::meta_function::less_than_or_equal_to, sol::resolve(&Vec2f::operator<=),
+ sol::meta_function::to_string,
+ [](const Vec2f& v) { return std::format("Vec2({}, {})", v.x, v.y); },
+
+ sol::meta_function::multiplication,
+ sol::overload(sol::resolve(&Vec2f::operator*),
+ [](const float s, const Vec2f& v) { return v * s; }),
+
+ sol::meta_function::division,
+ sol::resolve(&Vec2f::operator/),
+
+ "length", &Vec2f::length,
+ "length_sqr", &Vec2f::length_sqr,
+ "normalized", &Vec2f::normalized,
+ "dot", &Vec2f::dot,
+ "distance_to", &Vec2f::distance_to,
+ "distance_to_sqr", &Vec2f::distance_to_sqr,
+ "sum", &Vec2f::sum,
+
+ "abs",
+ [](const Vec2f& v)
+ {
+ Vec2f copy = v;
+ copy.abs();
+ return copy;
+ });
+ }
+} // namespace omath::lua::detail
+#endif
diff --git a/source/lua/lua_vec3.cpp b/source/lua/lua_vec3.cpp
new file mode 100644
index 00000000..1edb4e84
--- /dev/null
+++ b/source/lua/lua_vec3.cpp
@@ -0,0 +1,81 @@
+//
+// Created by orange on 07.03.2026.
+//
+#ifdef OMATH_ENABLE_LUA
+#include "omath/lua/lua.hpp"
+#include
+#include
+
+namespace omath::lua
+{
+ void LuaInterpreter::register_vec3(sol::table& omath_table)
+ {
+ using Vec3f = omath::Vector3;
+
+ omath_table.new_usertype(
+ "Vec3", sol::constructors(),
+
+ "x", sol::property([](const Vec3f& v) { return v.x; }, [](Vec3f& v, float val) { v.x = val; }),
+ "y", sol::property([](const Vec3f& v) { return v.y; }, [](Vec3f& v, float val) { v.y = val; }),
+ "z", sol::property([](const Vec3f& v) { return v.z; }, [](Vec3f& v, float val) { v.z = val; }),
+
+ sol::meta_function::addition, sol::resolve(&Vec3f::operator+),
+ sol::meta_function::subtraction, sol::resolve(&Vec3f::operator-),
+ sol::meta_function::unary_minus, sol::resolve(&Vec3f::operator-),
+ sol::meta_function::equal_to, &Vec3f::operator==, sol::meta_function::less_than,
+ sol::resolve(&Vec3f::operator<), sol::meta_function::less_than_or_equal_to,
+ sol::resolve(&Vec3f::operator<=), sol::meta_function::to_string,
+ [](const Vec3f& v) { return std::format("Vec3({}, {}, {})", v.x, v.y, v.z); },
+
+ sol::meta_function::multiplication,
+ sol::overload(sol::resolve(&Vec3f::operator*),
+ sol::resolve(&Vec3f::operator*),
+ [](const float s, const Vec3f& v) { return v * s; }),
+
+ sol::meta_function::division,
+ sol::overload(sol::resolve(&Vec3f::operator/),
+ sol::resolve(&Vec3f::operator/)),
+
+ "length", &Vec3f::length, "length_2d", &Vec3f::length_2d, "length_sqr", &Vec3f::length_sqr,
+ "normalized", &Vec3f::normalized, "dot", &Vec3f::dot, "cross", &Vec3f::cross, "distance_to",
+ &Vec3f::distance_to, "distance_to_sqr", &Vec3f::distance_to_sqr, "sum",
+ sol::resolve(&Vec3f::sum), "sum_2d", &Vec3f::sum_2d, "point_to_same_direction",
+ &Vec3f::point_to_same_direction, "as_array", &Vec3f::as_array,
+
+ "abs",
+ [](const Vec3f& v)
+ {
+ Vec3f copy = v;
+ copy.abs();
+ return copy;
+ },
+
+ "angle_between",
+ [](const Vec3f& self,
+ const Vec3f& other) -> std::tuple, sol::optional>
+ {
+ auto result = self.angle_between(other);
+ if (result)
+ return std::make_tuple(sol::optional(result->as_degrees()),
+ sol::optional(sol::nullopt));
+ return std::make_tuple(sol::optional(sol::nullopt),
+ sol::optional("impossible angle (zero-length vector)"));
+ },
+
+ "is_perpendicular",
+ [](const Vec3f& self, const Vec3f& other, sol::optional eps)
+ { return self.is_perpendicular(other, eps.value_or(0.0001f)); },
+
+ "as_table",
+ [](const Vec3f& v, sol::this_state s) -> sol::table
+ {
+ sol::state_view lua(s);
+ sol::table t = lua.create_table();
+ t["x"] = v.x;
+ t["y"] = v.y;
+ t["z"] = v.z;
+ return t;
+ });
+ }
+} // namespace omath::lua::detail
+#endif
diff --git a/source/lua/lua_vec4.cpp b/source/lua/lua_vec4.cpp
new file mode 100644
index 00000000..dfa8b978
--- /dev/null
+++ b/source/lua/lua_vec4.cpp
@@ -0,0 +1,62 @@
+//
+// Created by orange on 07.03.2026.
+//
+#ifdef OMATH_ENABLE_LUA
+#include "omath/lua/lua.hpp"
+#include
+#include
+
+namespace omath::lua
+{
+ void LuaInterpreter::register_vec4(sol::table& omath_table)
+ {
+ using Vec4f = omath::Vector4;
+
+ omath_table.new_usertype(
+ "Vec4", sol::constructors(),
+
+ "x", sol::property([](const Vec4f& v) { return v.x; }, [](Vec4f& v, float val) { v.x = val; }),
+ "y", sol::property([](const Vec4f& v) { return v.y; }, [](Vec4f& v, float val) { v.y = val; }),
+ "z", sol::property([](const Vec4f& v) { return v.z; }, [](Vec4f& v, float val) { v.z = val; }),
+ "w", sol::property([](const Vec4f& v) { return v.w; }, [](Vec4f& v, float val) { v.w = val; }),
+
+ sol::meta_function::addition, sol::resolve(&Vec4f::operator+),
+ sol::meta_function::subtraction, sol::resolve(&Vec4f::operator-),
+ sol::meta_function::unary_minus, sol::resolve(&Vec4f::operator-),
+ sol::meta_function::equal_to, &Vec4f::operator==,
+ sol::meta_function::less_than, sol::resolve(&Vec4f::operator<),
+ sol::meta_function::less_than_or_equal_to, sol::resolve(&Vec4f::operator<=),
+ sol::meta_function::to_string,
+ [](const Vec4f& v) { return std::format("Vec4({}, {}, {}, {})", v.x, v.y, v.z, v.w); },
+
+ sol::meta_function::multiplication,
+ sol::overload(sol::resolve(&Vec4f::operator*),
+ sol::resolve(&Vec4f::operator*),
+ [](const float s, const Vec4f& v) { return v * s; }),
+
+ sol::meta_function::division,
+ sol::overload(sol::resolve(&Vec4f::operator/),
+ sol::resolve(&Vec4f::operator/)),
+
+ "length", &Vec4f::length,
+ "length_sqr", &Vec4f::length_sqr,
+ "dot", &Vec4f::dot,
+ "sum", &Vec4f::sum,
+
+ "abs",
+ [](const Vec4f& v)
+ {
+ Vec4f copy = v;
+ copy.abs();
+ return copy;
+ },
+
+ "clamp",
+ [](Vec4f& v, float mn, float mx)
+ {
+ v.clamp(mn, mx);
+ return v;
+ });
+ }
+} // namespace omath::lua::detail
+#endif
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index de4f83bd..1e1b2718 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -4,8 +4,8 @@ project(unit_tests)
include(GoogleTest)
-file(GLOB_RECURSE UNIT_TESTS_SOURCES CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/*.cpp")
-add_executable(${PROJECT_NAME} ${UNIT_TESTS_SOURCES})
+file(GLOB_RECURSE UNIT_TESTS_SOURCES CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/general/*.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/engines/*.cpp")
+add_executable(${PROJECT_NAME} ${UNIT_TESTS_SOURCES} main.cpp)
set_target_properties(
${PROJECT_NAME}
@@ -22,6 +22,16 @@ else() # GTest is being linked as vcpkg package
target_link_libraries(${PROJECT_NAME} PRIVATE GTest::gtest GTest::gtest_main omath::omath)
endif()
+if (OMATH_ENABLE_LUA)
+ file(GLOB_RECURSE UNIT_TESTS_SOURCES_LUA CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/lua/*.cpp")
+ target_compile_definitions(${PROJECT_NAME} PRIVATE LUA_SCRIPTS_DIR="${CMAKE_CURRENT_SOURCE_DIR}/lua")
+ target_sources(${PROJECT_NAME} PRIVATE ${UNIT_TESTS_SOURCES_LUA})
+ if (EMSCRIPTEN)
+ target_link_options(${PROJECT_NAME} PRIVATE
+ "SHELL:--embed-file ${CMAKE_CURRENT_SOURCE_DIR}/lua@${CMAKE_CURRENT_SOURCE_DIR}/lua")
+ endif()
+endif()
+
if(OMATH_ENABLE_COVERAGE)
include(${CMAKE_SOURCE_DIR}/cmake/Coverage.cmake)
omath_setup_coverage(${PROJECT_NAME})
@@ -36,3 +46,4 @@ endif()
if(NOT (ANDROID OR IOS OR EMSCRIPTEN))
gtest_discover_tests(${PROJECT_NAME})
endif()
+
diff --git a/tests/lua/color_tests.lua b/tests/lua/color_tests.lua
new file mode 100644
index 00000000..234e5f9c
--- /dev/null
+++ b/tests/lua/color_tests.lua
@@ -0,0 +1,96 @@
+local function approx(a, b, eps) return math.abs(a - b) < (eps or 1e-4) end
+
+function Color_Constructor_float()
+ local c = omath.Color.new(1, 0.5, 0.25, 1)
+ assert(approx(c.r, 1) and approx(c.g, 0.5) and approx(c.b, 0.25) and approx(c.a, 1))
+end
+
+function Color_Constructor_default()
+ local c = omath.Color.new()
+ assert(c ~= nil)
+end
+
+function Color_Constructor_clamping()
+ local c = omath.Color.new(2, -1, 0.5, 1)
+ assert(approx(c.r, 1) and approx(c.g, 0) and approx(c.b, 0.5))
+end
+
+function Color_from_rgba()
+ local c = omath.Color.from_rgba(255, 128, 0, 255)
+ assert(approx(c.r, 1) and approx(c.g, 128/255) and approx(c.b, 0) and approx(c.a, 1))
+end
+
+function Color_from_hsv_components()
+ local c = omath.Color.from_hsv(0, 1, 1)
+ assert(approx(c.r, 1) and approx(c.g, 0) and approx(c.b, 0))
+end
+
+function Color_from_hsv_struct()
+ local hsv = omath.Hsv.new()
+ hsv.hue = 0
+ hsv.saturation = 1
+ hsv.value = 1
+ local c = omath.Color.from_hsv(hsv)
+ assert(approx(c.r, 1) and approx(c.g, 0) and approx(c.b, 0))
+end
+
+function Color_red()
+ local c = omath.Color.red()
+ assert(approx(c.r, 1) and approx(c.g, 0) and approx(c.b, 0) and approx(c.a, 1))
+end
+
+function Color_green()
+ local c = omath.Color.green()
+ assert(approx(c.r, 0) and approx(c.g, 1) and approx(c.b, 0) and approx(c.a, 1))
+end
+
+function Color_blue()
+ local c = omath.Color.blue()
+ assert(approx(c.r, 0) and approx(c.g, 0) and approx(c.b, 1) and approx(c.a, 1))
+end
+
+function Color_to_hsv()
+ local hsv = omath.Color.red():to_hsv()
+ assert(approx(hsv.hue, 0) and approx(hsv.saturation, 1) and approx(hsv.value, 1))
+end
+
+function Color_set_hue()
+ local c = omath.Color.red()
+ c:set_hue(1/3)
+ assert(approx(c.g, 1, 1e-3))
+end
+
+function Color_set_saturation()
+ local c = omath.Color.red()
+ c:set_saturation(0)
+ assert(approx(c.r, c.g) and approx(c.g, c.b))
+end
+
+function Color_set_value()
+ local c = omath.Color.red()
+ c:set_value(0)
+ assert(approx(c.r, 0) and approx(c.g, 0) and approx(c.b, 0))
+end
+
+function Color_blend()
+ local c = omath.Color.red():blend(omath.Color.blue(), 0.5)
+ assert(approx(c.r, 0.5) and approx(c.b, 0.5))
+end
+
+function Color_blend_clamped_ratio()
+ local c = omath.Color.red():blend(omath.Color.blue(), 2.0)
+ assert(approx(c.r, 0) and approx(c.b, 1))
+end
+
+function Color_to_string()
+ local s = tostring(omath.Color.red())
+ assert(s == "[r:255, g:0, b:0, a:255]")
+end
+
+function Hsv_fields()
+ local hsv = omath.Hsv.new()
+ hsv.hue = 0.5
+ hsv.saturation = 0.8
+ hsv.value = 0.9
+ assert(approx(hsv.hue, 0.5) and approx(hsv.saturation, 0.8) and approx(hsv.value, 0.9))
+end
diff --git a/tests/lua/source_engine_tests.lua b/tests/lua/source_engine_tests.lua
new file mode 100644
index 00000000..26a10af8
--- /dev/null
+++ b/tests/lua/source_engine_tests.lua
@@ -0,0 +1,197 @@
+local function approx(a, b, eps) return math.abs(a - b) < (eps or 1e-4) end
+
+local function make_camera()
+ local pos = omath.Vec3.new(0, 0, 0)
+ local pitch = omath.source.PitchAngle.from_degrees(0)
+ local yaw = omath.source.YawAngle.from_degrees(0)
+ local roll = omath.source.RollAngle.from_degrees(0)
+ local angles = omath.source.ViewAngles.new(pitch, yaw, roll)
+ local vp = omath.opengl.ViewPort.new(1920, 1080)
+ local fov = omath.source.FieldOfView.from_degrees(90)
+ return omath.source.Camera.new(pos, angles, vp, fov, 0.1, 1000)
+end
+
+-- PitchAngle
+function Source_PitchAngle_from_degrees()
+ assert(omath.source.PitchAngle.from_degrees(45):as_degrees() == 45)
+end
+
+function Source_PitchAngle_clamping_max()
+ assert(omath.source.PitchAngle.from_degrees(100):as_degrees() == 89)
+end
+
+function Source_PitchAngle_clamping_min()
+ assert(omath.source.PitchAngle.from_degrees(-100):as_degrees() == -89)
+end
+
+function Source_PitchAngle_from_radians()
+ assert(approx(omath.source.PitchAngle.from_radians(math.pi / 4):as_degrees(), 45))
+end
+
+function Source_PitchAngle_as_radians()
+ assert(approx(omath.source.PitchAngle.from_degrees(0):as_radians(), 0))
+end
+
+function Source_PitchAngle_sin()
+ assert(approx(omath.source.PitchAngle.from_degrees(30):sin(), 0.5))
+end
+
+function Source_PitchAngle_cos()
+ assert(approx(omath.source.PitchAngle.from_degrees(60):cos(), 0.5))
+end
+
+function Source_PitchAngle_tan()
+ assert(approx(omath.source.PitchAngle.from_degrees(45):tan(), 1.0))
+end
+
+function Source_PitchAngle_addition()
+ local c = omath.source.PitchAngle.from_degrees(20) + omath.source.PitchAngle.from_degrees(15)
+ assert(c:as_degrees() == 35)
+end
+
+function Source_PitchAngle_addition_clamped()
+ local c = omath.source.PitchAngle.from_degrees(80) + omath.source.PitchAngle.from_degrees(20)
+ assert(c:as_degrees() == 89)
+end
+
+function Source_PitchAngle_subtraction()
+ local c = omath.source.PitchAngle.from_degrees(50) - omath.source.PitchAngle.from_degrees(20)
+ assert(c:as_degrees() == 30)
+end
+
+function Source_PitchAngle_unary_minus()
+ assert((-omath.source.PitchAngle.from_degrees(45)):as_degrees() == -45)
+end
+
+function Source_PitchAngle_equal_to()
+ local a = omath.source.PitchAngle.from_degrees(45)
+ assert(a == omath.source.PitchAngle.from_degrees(45))
+ assert(not (a == omath.source.PitchAngle.from_degrees(30)))
+end
+
+function Source_PitchAngle_to_string()
+ assert(tostring(omath.source.PitchAngle.from_degrees(45)) == "45deg")
+end
+
+-- YawAngle
+function Source_YawAngle_from_degrees()
+ assert(omath.source.YawAngle.from_degrees(90):as_degrees() == 90)
+end
+
+function Source_YawAngle_normalization()
+ assert(approx(omath.source.YawAngle.from_degrees(200):as_degrees(), -160))
+end
+
+-- RollAngle
+function Source_RollAngle_from_degrees()
+ assert(omath.source.RollAngle.from_degrees(45):as_degrees() == 45)
+end
+
+-- FieldOfView
+function Source_FieldOfView_from_degrees()
+ assert(omath.source.FieldOfView.from_degrees(90):as_degrees() == 90)
+end
+
+function Source_FieldOfView_clamping()
+ assert(omath.source.FieldOfView.from_degrees(200):as_degrees() == 180)
+end
+
+-- ViewAngles
+function Source_ViewAngles_new()
+ local angles = omath.source.ViewAngles.new(
+ omath.source.PitchAngle.from_degrees(30),
+ omath.source.YawAngle.from_degrees(90),
+ omath.source.RollAngle.from_degrees(0))
+ assert(angles.pitch:as_degrees() == 30)
+ assert(angles.yaw:as_degrees() == 90)
+ assert(angles.roll:as_degrees() == 0)
+end
+
+function Source_ViewAngles_field_mutation()
+ local angles = omath.source.ViewAngles.new(
+ omath.source.PitchAngle.from_degrees(0),
+ omath.source.YawAngle.from_degrees(0),
+ omath.source.RollAngle.from_degrees(0))
+ angles.pitch = omath.source.PitchAngle.from_degrees(45)
+ assert(angles.pitch:as_degrees() == 45)
+end
+
+-- Camera
+function Source_Camera_constructor()
+ assert(make_camera() ~= nil)
+end
+
+function Source_Camera_get_set_origin()
+ local cam = make_camera()
+ cam:set_origin(omath.Vec3.new(1, 2, 3))
+ local o = cam:get_origin()
+ assert(approx(o.x, 1) and approx(o.y, 2) and approx(o.z, 3))
+end
+
+function Source_Camera_get_set_near_plane()
+ local cam = make_camera()
+ cam:set_near_plane(0.5)
+ assert(approx(cam:get_near_plane(), 0.5))
+end
+
+function Source_Camera_get_set_far_plane()
+ local cam = make_camera()
+ cam:set_far_plane(500)
+ assert(approx(cam:get_far_plane(), 500))
+end
+
+function Source_Camera_get_set_fov()
+ local cam = make_camera()
+ cam:set_field_of_view(omath.source.FieldOfView.from_degrees(60))
+ assert(approx(cam:get_field_of_view():as_degrees(), 60))
+end
+
+function Source_Camera_get_set_view_angles()
+ local cam = make_camera()
+ cam:set_view_angles(omath.source.ViewAngles.new(
+ omath.source.PitchAngle.from_degrees(30),
+ omath.source.YawAngle.from_degrees(90),
+ omath.source.RollAngle.from_degrees(0)))
+ assert(approx(cam:get_view_angles().pitch:as_degrees(), 30))
+ assert(approx(cam:get_view_angles().yaw:as_degrees(), 90))
+end
+
+function Source_Camera_look_at()
+ local cam = make_camera()
+ cam:look_at(omath.Vec3.new(10, 0, 0))
+ assert(cam:get_view_angles() ~= nil)
+end
+
+function Source_Camera_get_forward()
+ local fwd = make_camera():get_forward()
+ assert(approx(fwd:length(), 1.0))
+end
+
+function Source_Camera_get_right()
+ assert(approx(make_camera():get_right():length(), 1.0))
+end
+
+function Source_Camera_get_up()
+ assert(approx(make_camera():get_up():length(), 1.0))
+end
+
+function Source_Camera_world_to_screen_success()
+ local cam = make_camera()
+ cam:look_at(omath.Vec3.new(1, 0, 0))
+ local screen, err = cam:world_to_screen(omath.Vec3.new(5, 0, 0))
+ assert(screen ~= nil, "expected screen pos, got: " .. tostring(err))
+end
+
+function Source_Camera_world_to_screen_error()
+ local cam = make_camera()
+ cam:look_at(omath.Vec3.new(1, 0, 0))
+ local screen, err = cam:world_to_screen(omath.Vec3.new(-100, 0, 0))
+ assert(screen == nil and err ~= nil)
+end
+
+function Source_Camera_screen_to_world()
+ local cam = make_camera()
+ cam:look_at(omath.Vec3.new(1, 0, 0))
+ local world, err = cam:screen_to_world(omath.Vec3.new(960, 540, 1))
+ assert(world ~= nil, "expected world pos, got: " .. tostring(err))
+end
diff --git a/tests/lua/triangle_tests.lua b/tests/lua/triangle_tests.lua
new file mode 100644
index 00000000..566b23bc
--- /dev/null
+++ b/tests/lua/triangle_tests.lua
@@ -0,0 +1,82 @@
+local function approx(a, b, eps) return math.abs(a - b) < (eps or 1e-5) end
+
+function Triangle_Constructor_default()
+ local t = omath.Triangle.new()
+ assert(t.vertex1.x == 0 and t.vertex1.y == 0 and t.vertex1.z == 0)
+ assert(t.vertex2.x == 0 and t.vertex2.y == 0 and t.vertex2.z == 0)
+ assert(t.vertex3.x == 0 and t.vertex3.y == 0 and t.vertex3.z == 0)
+end
+
+function Triangle_Constructor_vertices()
+ local v1 = omath.Vec3.new(1, 0, 0)
+ local v2 = omath.Vec3.new(0, 1, 0)
+ local v3 = omath.Vec3.new(0, 0, 1)
+ local t = omath.Triangle.new(v1, v2, v3)
+ assert(t.vertex1.x == 1 and t.vertex1.y == 0 and t.vertex1.z == 0)
+ assert(t.vertex2.x == 0 and t.vertex2.y == 1 and t.vertex2.z == 0)
+ assert(t.vertex3.x == 0 and t.vertex3.y == 0 and t.vertex3.z == 1)
+end
+
+function Triangle_Vertex_mutation()
+ local t = omath.Triangle.new()
+ t.vertex1 = omath.Vec3.new(5, 6, 7)
+ assert(t.vertex1.x == 5 and t.vertex1.y == 6 and t.vertex1.z == 7)
+end
+
+-- Right triangle: v1=(0,3,0), v2=(0,0,0), v3=(4,0,0) — sides 3, 4, hypot 5
+function Triangle_SideALength()
+ local t = omath.Triangle.new(omath.Vec3.new(0, 3, 0), omath.Vec3.new(0, 0, 0), omath.Vec3.new(4, 0, 0))
+ assert(approx(t:side_a_length(), 3.0))
+end
+
+function Triangle_SideBLength()
+ local t = omath.Triangle.new(omath.Vec3.new(0, 3, 0), omath.Vec3.new(0, 0, 0), omath.Vec3.new(4, 0, 0))
+ assert(approx(t:side_b_length(), 4.0))
+end
+
+function Triangle_Hypot()
+ local t = omath.Triangle.new(omath.Vec3.new(0, 3, 0), omath.Vec3.new(0, 0, 0), omath.Vec3.new(4, 0, 0))
+ assert(approx(t:hypot(), 5.0))
+end
+
+function Triangle_SideAVector()
+ local t = omath.Triangle.new(omath.Vec3.new(0, 3, 0), omath.Vec3.new(0, 0, 0), omath.Vec3.new(4, 0, 0))
+ local a = t:side_a_vector()
+ assert(approx(a.x, 0) and approx(a.y, 3) and approx(a.z, 0))
+end
+
+function Triangle_SideBVector()
+ local t = omath.Triangle.new(omath.Vec3.new(0, 3, 0), omath.Vec3.new(0, 0, 0), omath.Vec3.new(4, 0, 0))
+ local b = t:side_b_vector()
+ assert(approx(b.x, 4) and approx(b.y, 0) and approx(b.z, 0))
+end
+
+function Triangle_IsRectangular_true()
+ local t = omath.Triangle.new(omath.Vec3.new(0, 3, 0), omath.Vec3.new(0, 0, 0), omath.Vec3.new(4, 0, 0))
+ assert(t:is_rectangular() == true)
+end
+
+function Triangle_IsRectangular_false()
+ -- equilateral-ish triangle, not rectangular
+ local t = omath.Triangle.new(omath.Vec3.new(0, 1, 0), omath.Vec3.new(-1, 0, 0), omath.Vec3.new(1, 0, 0))
+ assert(t:is_rectangular() == false)
+end
+
+function Triangle_MidPoint()
+ local t = omath.Triangle.new(omath.Vec3.new(3, 0, 0), omath.Vec3.new(0, 3, 0), omath.Vec3.new(0, 0, 3))
+ local m = t:mid_point()
+ assert(approx(m.x, 1.0) and approx(m.y, 1.0) and approx(m.z, 1.0))
+end
+
+function Triangle_CalculateNormal()
+ -- flat triangle in XY plane — normal should be (0, 0, 1)
+ local t = omath.Triangle.new(omath.Vec3.new(0, 1, 0), omath.Vec3.new(0, 0, 0), omath.Vec3.new(1, 0, 0))
+ local n = t:calculate_normal()
+ assert(approx(n.x, 0) and approx(n.y, 0) and approx(n.z, 1))
+end
+
+function Triangle_ToString()
+ local t = omath.Triangle.new(omath.Vec3.new(1, 0, 0), omath.Vec3.new(0, 1, 0), omath.Vec3.new(0, 0, 1))
+ local s = tostring(t)
+ assert(s == "Triangle((1, 0, 0), (0, 1, 0), (0, 0, 1))")
+end
diff --git a/tests/lua/unit_test_lua_color.cpp b/tests/lua/unit_test_lua_color.cpp
new file mode 100644
index 00000000..1397b5e7
--- /dev/null
+++ b/tests/lua/unit_test_lua_color.cpp
@@ -0,0 +1,51 @@
+//
+// Created by orange on 08.03.2026.
+//
+#include
+#include
+#include
+
+class LuaColor : public ::testing::Test
+{
+protected:
+ lua_State* L = nullptr;
+
+ void SetUp() override
+ {
+ L = luaL_newstate();
+ luaL_openlibs(L);
+ omath::lua::LuaInterpreter::register_lib(L);
+ if (luaL_dofile(L, LUA_SCRIPTS_DIR "/color_tests.lua") != LUA_OK)
+ FAIL() << lua_tostring(L, -1);
+ }
+
+ void TearDown() override { lua_close(L); }
+
+ void check(const char* func_name)
+ {
+ lua_getglobal(L, func_name);
+ if (lua_pcall(L, 0, 0, 0) != LUA_OK)
+ {
+ FAIL() << lua_tostring(L, -1);
+ lua_pop(L, 1);
+ }
+ }
+};
+
+TEST_F(LuaColor, Constructor_float) { check("Color_Constructor_float"); }
+TEST_F(LuaColor, Constructor_default) { check("Color_Constructor_default"); }
+TEST_F(LuaColor, Constructor_clamping) { check("Color_Constructor_clamping"); }
+TEST_F(LuaColor, from_rgba) { check("Color_from_rgba"); }
+TEST_F(LuaColor, from_hsv_components) { check("Color_from_hsv_components"); }
+TEST_F(LuaColor, from_hsv_struct) { check("Color_from_hsv_struct"); }
+TEST_F(LuaColor, red) { check("Color_red"); }
+TEST_F(LuaColor, green) { check("Color_green"); }
+TEST_F(LuaColor, blue) { check("Color_blue"); }
+TEST_F(LuaColor, to_hsv) { check("Color_to_hsv"); }
+TEST_F(LuaColor, set_hue) { check("Color_set_hue"); }
+TEST_F(LuaColor, set_saturation) { check("Color_set_saturation"); }
+TEST_F(LuaColor, set_value) { check("Color_set_value"); }
+TEST_F(LuaColor, blend) { check("Color_blend"); }
+TEST_F(LuaColor, blend_clamped_ratio) { check("Color_blend_clamped_ratio"); }
+TEST_F(LuaColor, to_string) { check("Color_to_string"); }
+TEST_F(LuaColor, Hsv_fields) { check("Hsv_fields"); }
diff --git a/tests/lua/unit_test_lua_source_engine.cpp b/tests/lua/unit_test_lua_source_engine.cpp
new file mode 100644
index 00000000..102644c3
--- /dev/null
+++ b/tests/lua/unit_test_lua_source_engine.cpp
@@ -0,0 +1,79 @@
+//
+// Created by orange on 07.03.2026.
+//
+#include
+#include
+#include
+
+class LuaSourceEngine : public ::testing::Test
+{
+protected:
+ lua_State* L = nullptr;
+
+ void SetUp() override
+ {
+ L = luaL_newstate();
+ luaL_openlibs(L);
+ omath::lua::LuaInterpreter::register_lib(L);
+ if (luaL_dofile(L, LUA_SCRIPTS_DIR "/source_engine_tests.lua") != LUA_OK)
+ FAIL() << lua_tostring(L, -1);
+ }
+
+ void TearDown() override { lua_close(L); }
+
+ void check(const char* func_name)
+ {
+ lua_getglobal(L, func_name);
+ if (lua_pcall(L, 0, 0, 0) != LUA_OK)
+ {
+ FAIL() << lua_tostring(L, -1);
+ lua_pop(L, 1);
+ }
+ }
+};
+
+// PitchAngle
+TEST_F(LuaSourceEngine, PitchAngle_from_degrees) { check("Source_PitchAngle_from_degrees"); }
+TEST_F(LuaSourceEngine, PitchAngle_clamping_max) { check("Source_PitchAngle_clamping_max"); }
+TEST_F(LuaSourceEngine, PitchAngle_clamping_min) { check("Source_PitchAngle_clamping_min"); }
+TEST_F(LuaSourceEngine, PitchAngle_from_radians) { check("Source_PitchAngle_from_radians"); }
+TEST_F(LuaSourceEngine, PitchAngle_as_radians) { check("Source_PitchAngle_as_radians"); }
+TEST_F(LuaSourceEngine, PitchAngle_sin) { check("Source_PitchAngle_sin"); }
+TEST_F(LuaSourceEngine, PitchAngle_cos) { check("Source_PitchAngle_cos"); }
+TEST_F(LuaSourceEngine, PitchAngle_tan) { check("Source_PitchAngle_tan"); }
+TEST_F(LuaSourceEngine, PitchAngle_addition) { check("Source_PitchAngle_addition"); }
+TEST_F(LuaSourceEngine, PitchAngle_addition_clamped) { check("Source_PitchAngle_addition_clamped"); }
+TEST_F(LuaSourceEngine, PitchAngle_subtraction) { check("Source_PitchAngle_subtraction"); }
+TEST_F(LuaSourceEngine, PitchAngle_unary_minus) { check("Source_PitchAngle_unary_minus"); }
+TEST_F(LuaSourceEngine, PitchAngle_equal_to) { check("Source_PitchAngle_equal_to"); }
+TEST_F(LuaSourceEngine, PitchAngle_to_string) { check("Source_PitchAngle_to_string"); }
+
+// YawAngle
+TEST_F(LuaSourceEngine, YawAngle_from_degrees) { check("Source_YawAngle_from_degrees"); }
+TEST_F(LuaSourceEngine, YawAngle_normalization) { check("Source_YawAngle_normalization"); }
+
+// RollAngle
+TEST_F(LuaSourceEngine, RollAngle_from_degrees) { check("Source_RollAngle_from_degrees"); }
+
+// FieldOfView
+TEST_F(LuaSourceEngine, FieldOfView_from_degrees) { check("Source_FieldOfView_from_degrees"); }
+TEST_F(LuaSourceEngine, FieldOfView_clamping) { check("Source_FieldOfView_clamping"); }
+
+// ViewAngles
+TEST_F(LuaSourceEngine, ViewAngles_new) { check("Source_ViewAngles_new"); }
+TEST_F(LuaSourceEngine, ViewAngles_field_mutation) { check("Source_ViewAngles_field_mutation"); }
+
+// Camera
+TEST_F(LuaSourceEngine, Camera_constructor) { check("Source_Camera_constructor"); }
+TEST_F(LuaSourceEngine, Camera_get_set_origin) { check("Source_Camera_get_set_origin"); }
+TEST_F(LuaSourceEngine, Camera_get_set_near_plane) { check("Source_Camera_get_set_near_plane"); }
+TEST_F(LuaSourceEngine, Camera_get_set_far_plane) { check("Source_Camera_get_set_far_plane"); }
+TEST_F(LuaSourceEngine, Camera_get_set_fov) { check("Source_Camera_get_set_fov"); }
+TEST_F(LuaSourceEngine, Camera_get_set_view_angles) { check("Source_Camera_get_set_view_angles"); }
+TEST_F(LuaSourceEngine, Camera_look_at) { check("Source_Camera_look_at"); }
+TEST_F(LuaSourceEngine, Camera_get_forward) { check("Source_Camera_get_forward"); }
+TEST_F(LuaSourceEngine, Camera_get_right) { check("Source_Camera_get_right"); }
+TEST_F(LuaSourceEngine, Camera_get_up) { check("Source_Camera_get_up"); }
+TEST_F(LuaSourceEngine, Camera_world_to_screen_success) { check("Source_Camera_world_to_screen_success"); }
+TEST_F(LuaSourceEngine, Camera_world_to_screen_error) { check("Source_Camera_world_to_screen_error"); }
+TEST_F(LuaSourceEngine, Camera_screen_to_world) { check("Source_Camera_screen_to_world"); }
diff --git a/tests/lua/unit_test_lua_triangle.cpp b/tests/lua/unit_test_lua_triangle.cpp
new file mode 100644
index 00000000..096017b0
--- /dev/null
+++ b/tests/lua/unit_test_lua_triangle.cpp
@@ -0,0 +1,47 @@
+//
+// Created by orange on 10.03.2026.
+//
+#include
+#include
+#include
+
+class LuaTriangle : public ::testing::Test
+{
+protected:
+ lua_State* L = nullptr;
+
+ void SetUp() override
+ {
+ L = luaL_newstate();
+ luaL_openlibs(L);
+ omath::lua::LuaInterpreter::register_lib(L);
+ if (luaL_dofile(L, LUA_SCRIPTS_DIR "/triangle_tests.lua") != LUA_OK)
+ FAIL() << lua_tostring(L, -1);
+ }
+
+ void TearDown() override { lua_close(L); }
+
+ void check(const char* func_name)
+ {
+ lua_getglobal(L, func_name);
+ if (lua_pcall(L, 0, 0, 0) != LUA_OK)
+ {
+ FAIL() << lua_tostring(L, -1);
+ lua_pop(L, 1);
+ }
+ }
+};
+
+TEST_F(LuaTriangle, Constructor_default) { check("Triangle_Constructor_default"); }
+TEST_F(LuaTriangle, Constructor_vertices) { check("Triangle_Constructor_vertices"); }
+TEST_F(LuaTriangle, Vertex_mutation) { check("Triangle_Vertex_mutation"); }
+TEST_F(LuaTriangle, SideALength) { check("Triangle_SideALength"); }
+TEST_F(LuaTriangle, SideBLength) { check("Triangle_SideBLength"); }
+TEST_F(LuaTriangle, Hypot) { check("Triangle_Hypot"); }
+TEST_F(LuaTriangle, SideAVector) { check("Triangle_SideAVector"); }
+TEST_F(LuaTriangle, SideBVector) { check("Triangle_SideBVector"); }
+TEST_F(LuaTriangle, IsRectangular_true) { check("Triangle_IsRectangular_true"); }
+TEST_F(LuaTriangle, IsRectangular_false) { check("Triangle_IsRectangular_false"); }
+TEST_F(LuaTriangle, MidPoint) { check("Triangle_MidPoint"); }
+TEST_F(LuaTriangle, CalculateNormal) { check("Triangle_CalculateNormal"); }
+TEST_F(LuaTriangle, ToString) { check("Triangle_ToString"); }
diff --git a/tests/lua/unit_test_lua_vector2.cpp b/tests/lua/unit_test_lua_vector2.cpp
new file mode 100644
index 00000000..929cb20d
--- /dev/null
+++ b/tests/lua/unit_test_lua_vector2.cpp
@@ -0,0 +1,56 @@
+//
+// Created by orange on 07.03.2026.
+//
+#include
+#include
+#include
+
+class LuaVec2 : public ::testing::Test
+{
+protected:
+ lua_State* L = nullptr;
+
+ void SetUp() override
+ {
+ L = luaL_newstate();
+ luaL_openlibs(L);
+ omath::lua::LuaInterpreter::register_lib(L);
+ if (luaL_dofile(L, LUA_SCRIPTS_DIR "/vec2_tests.lua") != LUA_OK)
+ FAIL() << lua_tostring(L, -1);
+ }
+
+ void TearDown() override { lua_close(L); }
+
+ void check(const char* func_name)
+ {
+ lua_getglobal(L, func_name);
+ if (lua_pcall(L, 0, 0, 0) != LUA_OK)
+ {
+ FAIL() << lua_tostring(L, -1);
+ lua_pop(L, 1);
+ }
+ }
+};
+
+TEST_F(LuaVec2, Constructor_default) { check("Vec2_Constructor_default"); }
+TEST_F(LuaVec2, Constructor_xy) { check("Vec2_Constructor_xy"); }
+TEST_F(LuaVec2, Field_mutation) { check("Vec2_Field_mutation"); }
+TEST_F(LuaVec2, Addition) { check("Vec2_Addition"); }
+TEST_F(LuaVec2, Subtraction) { check("Vec2_Subtraction"); }
+TEST_F(LuaVec2, UnaryMinus) { check("Vec2_UnaryMinus"); }
+TEST_F(LuaVec2, Multiplication_scalar) { check("Vec2_Multiplication_scalar"); }
+TEST_F(LuaVec2, Multiplication_scalar_reversed) { check("Vec2_Multiplication_scalar_reversed"); }
+TEST_F(LuaVec2, Division_scalar) { check("Vec2_Division_scalar"); }
+TEST_F(LuaVec2, EqualTo_true) { check("Vec2_EqualTo_true"); }
+TEST_F(LuaVec2, EqualTo_false) { check("Vec2_EqualTo_false"); }
+TEST_F(LuaVec2, LessThan) { check("Vec2_LessThan"); }
+TEST_F(LuaVec2, LessThanOrEqual) { check("Vec2_LessThanOrEqual"); }
+TEST_F(LuaVec2, ToString) { check("Vec2_ToString"); }
+TEST_F(LuaVec2, Length) { check("Vec2_Length"); }
+TEST_F(LuaVec2, LengthSqr) { check("Vec2_LengthSqr"); }
+TEST_F(LuaVec2, Normalized) { check("Vec2_Normalized"); }
+TEST_F(LuaVec2, Dot) { check("Vec2_Dot"); }
+TEST_F(LuaVec2, DistanceTo) { check("Vec2_DistanceTo"); }
+TEST_F(LuaVec2, DistanceToSqr) { check("Vec2_DistanceToSqr"); }
+TEST_F(LuaVec2, Sum) { check("Vec2_Sum"); }
+TEST_F(LuaVec2, Abs) { check("Vec2_Abs"); }
diff --git a/tests/lua/unit_test_lua_vector3.cpp b/tests/lua/unit_test_lua_vector3.cpp
new file mode 100644
index 00000000..a2148658
--- /dev/null
+++ b/tests/lua/unit_test_lua_vector3.cpp
@@ -0,0 +1,69 @@
+//
+// Created by orange on 07.03.2026.
+//
+#include
+#include
+#include
+
+class LuaVec3 : public ::testing::Test
+{
+protected:
+ lua_State* L = nullptr;
+
+ void SetUp() override
+ {
+ L = luaL_newstate();
+ luaL_openlibs(L);
+ omath::lua::LuaInterpreter::register_lib(L);
+ if (luaL_dofile(L, LUA_SCRIPTS_DIR "/vec3_tests.lua") != LUA_OK)
+ FAIL() << lua_tostring(L, -1);
+ }
+
+ void TearDown() override { lua_close(L); }
+
+ void check(const char* func_name)
+ {
+ lua_getglobal(L, func_name);
+ if (lua_pcall(L, 0, 0, 0) != LUA_OK)
+ {
+ FAIL() << lua_tostring(L, -1);
+ lua_pop(L, 1);
+ }
+ }
+};
+
+TEST_F(LuaVec3, Constructor_default) { check("Vec3_Constructor_default"); }
+TEST_F(LuaVec3, Constructor_xyz) { check("Vec3_Constructor_xyz"); }
+TEST_F(LuaVec3, Field_mutation) { check("Vec3_Field_mutation"); }
+TEST_F(LuaVec3, Addition) { check("Vec3_Addition"); }
+TEST_F(LuaVec3, Subtraction) { check("Vec3_Subtraction"); }
+TEST_F(LuaVec3, UnaryMinus) { check("Vec3_UnaryMinus"); }
+TEST_F(LuaVec3, Multiplication_scalar) { check("Vec3_Multiplication_scalar"); }
+TEST_F(LuaVec3, Multiplication_scalar_reversed) { check("Vec3_Multiplication_scalar_reversed"); }
+TEST_F(LuaVec3, Multiplication_vec) { check("Vec3_Multiplication_vec"); }
+TEST_F(LuaVec3, Division_scalar) { check("Vec3_Division_scalar"); }
+TEST_F(LuaVec3, Division_vec) { check("Vec3_Division_vec"); }
+TEST_F(LuaVec3, EqualTo_true) { check("Vec3_EqualTo_true"); }
+TEST_F(LuaVec3, EqualTo_false) { check("Vec3_EqualTo_false"); }
+TEST_F(LuaVec3, LessThan) { check("Vec3_LessThan"); }
+TEST_F(LuaVec3, LessThanOrEqual) { check("Vec3_LessThanOrEqual"); }
+TEST_F(LuaVec3, ToString) { check("Vec3_ToString"); }
+TEST_F(LuaVec3, Length) { check("Vec3_Length"); }
+TEST_F(LuaVec3, Length2d) { check("Vec3_Length2d"); }
+TEST_F(LuaVec3, LengthSqr) { check("Vec3_LengthSqr"); }
+TEST_F(LuaVec3, Normalized) { check("Vec3_Normalized"); }
+TEST_F(LuaVec3, Dot_perpendicular) { check("Vec3_Dot_perpendicular"); }
+TEST_F(LuaVec3, Dot_parallel) { check("Vec3_Dot_parallel"); }
+TEST_F(LuaVec3, Cross) { check("Vec3_Cross"); }
+TEST_F(LuaVec3, DistanceTo) { check("Vec3_DistanceTo"); }
+TEST_F(LuaVec3, DistanceToSqr) { check("Vec3_DistanceToSqr"); }
+TEST_F(LuaVec3, Sum) { check("Vec3_Sum"); }
+TEST_F(LuaVec3, Sum2d) { check("Vec3_Sum2d"); }
+TEST_F(LuaVec3, Abs) { check("Vec3_Abs"); }
+TEST_F(LuaVec3, PointToSameDirection_true) { check("Vec3_PointToSameDirection_true"); }
+TEST_F(LuaVec3, PointToSameDirection_false) { check("Vec3_PointToSameDirection_false"); }
+TEST_F(LuaVec3, IsPerpendicular_true) { check("Vec3_IsPerpendicular_true"); }
+TEST_F(LuaVec3, IsPerpendicular_false) { check("Vec3_IsPerpendicular_false"); }
+TEST_F(LuaVec3, AngleBetween_90deg) { check("Vec3_AngleBetween_90deg"); }
+TEST_F(LuaVec3, AngleBetween_zero_vector_error) { check("Vec3_AngleBetween_zero_vector_error"); }
+TEST_F(LuaVec3, AsTable) { check("Vec3_AsTable"); }
diff --git a/tests/lua/unit_test_lua_vector4.cpp b/tests/lua/unit_test_lua_vector4.cpp
new file mode 100644
index 00000000..5d073595
--- /dev/null
+++ b/tests/lua/unit_test_lua_vector4.cpp
@@ -0,0 +1,57 @@
+//
+// Created by orange on 07.03.2026.
+//
+#include
+#include
+#include
+
+class LuaVec4 : public ::testing::Test
+{
+protected:
+ lua_State* L = nullptr;
+
+ void SetUp() override
+ {
+ L = luaL_newstate();
+ luaL_openlibs(L);
+ omath::lua::LuaInterpreter::register_lib(L);
+ if (luaL_dofile(L, LUA_SCRIPTS_DIR "/vec4_tests.lua") != LUA_OK)
+ FAIL() << lua_tostring(L, -1);
+ }
+
+ void TearDown() override { lua_close(L); }
+
+ void check(const char* func_name)
+ {
+ lua_getglobal(L, func_name);
+ if (lua_pcall(L, 0, 0, 0) != LUA_OK)
+ {
+ FAIL() << lua_tostring(L, -1);
+ lua_pop(L, 1);
+ }
+ }
+};
+
+TEST_F(LuaVec4, Constructor_default) { check("Vec4_Constructor_default"); }
+TEST_F(LuaVec4, Constructor_xyzw) { check("Vec4_Constructor_xyzw"); }
+TEST_F(LuaVec4, Field_mutation) { check("Vec4_Field_mutation"); }
+TEST_F(LuaVec4, Addition) { check("Vec4_Addition"); }
+TEST_F(LuaVec4, Subtraction) { check("Vec4_Subtraction"); }
+TEST_F(LuaVec4, UnaryMinus) { check("Vec4_UnaryMinus"); }
+TEST_F(LuaVec4, Multiplication_scalar) { check("Vec4_Multiplication_scalar"); }
+TEST_F(LuaVec4, Multiplication_scalar_reversed) { check("Vec4_Multiplication_scalar_reversed"); }
+TEST_F(LuaVec4, Multiplication_vec) { check("Vec4_Multiplication_vec"); }
+TEST_F(LuaVec4, Division_scalar) { check("Vec4_Division_scalar"); }
+TEST_F(LuaVec4, Division_vec) { check("Vec4_Division_vec"); }
+TEST_F(LuaVec4, EqualTo_true) { check("Vec4_EqualTo_true"); }
+TEST_F(LuaVec4, EqualTo_false) { check("Vec4_EqualTo_false"); }
+TEST_F(LuaVec4, LessThan) { check("Vec4_LessThan"); }
+TEST_F(LuaVec4, LessThanOrEqual) { check("Vec4_LessThanOrEqual"); }
+TEST_F(LuaVec4, ToString) { check("Vec4_ToString"); }
+TEST_F(LuaVec4, Length) { check("Vec4_Length"); }
+TEST_F(LuaVec4, LengthSqr) { check("Vec4_LengthSqr"); }
+TEST_F(LuaVec4, Dot) { check("Vec4_Dot"); }
+TEST_F(LuaVec4, Dot_perpendicular) { check("Vec4_Dot_perpendicular"); }
+TEST_F(LuaVec4, Sum) { check("Vec4_Sum"); }
+TEST_F(LuaVec4, Abs) { check("Vec4_Abs"); }
+TEST_F(LuaVec4, Clamp) { check("Vec4_Clamp"); }
diff --git a/tests/lua/vec2_tests.lua b/tests/lua/vec2_tests.lua
new file mode 100644
index 00000000..e51417e0
--- /dev/null
+++ b/tests/lua/vec2_tests.lua
@@ -0,0 +1,102 @@
+local function approx(a, b) return math.abs(a - b) < 1e-5 end
+
+function Vec2_Constructor_default()
+ local v = omath.Vec2.new()
+ assert(v.x == 0 and v.y == 0)
+end
+
+function Vec2_Constructor_xy()
+ local v = omath.Vec2.new(3, 4)
+ assert(v.x == 3 and v.y == 4)
+end
+
+function Vec2_Field_mutation()
+ local v = omath.Vec2.new(1, 2)
+ v.x = 9; v.y = 8
+ assert(v.x == 9 and v.y == 8)
+end
+
+function Vec2_Addition()
+ local c = omath.Vec2.new(1, 2) + omath.Vec2.new(3, 4)
+ assert(c.x == 4 and c.y == 6)
+end
+
+function Vec2_Subtraction()
+ local c = omath.Vec2.new(5, 7) - omath.Vec2.new(2, 3)
+ assert(c.x == 3 and c.y == 4)
+end
+
+function Vec2_UnaryMinus()
+ local b = -omath.Vec2.new(1, 2)
+ assert(b.x == -1 and b.y == -2)
+end
+
+function Vec2_Multiplication_scalar()
+ local b = omath.Vec2.new(2, 3) * 2.0
+ assert(b.x == 4 and b.y == 6)
+end
+
+function Vec2_Multiplication_scalar_reversed()
+ local b = 2.0 * omath.Vec2.new(2, 3)
+ assert(b.x == 4 and b.y == 6)
+end
+
+function Vec2_Division_scalar()
+ local b = omath.Vec2.new(4, 6) / 2.0
+ assert(b.x == 2 and b.y == 3)
+end
+
+function Vec2_EqualTo_true()
+ assert(omath.Vec2.new(1, 2) == omath.Vec2.new(1, 2))
+end
+
+function Vec2_EqualTo_false()
+ assert(not (omath.Vec2.new(1, 2) == omath.Vec2.new(9, 9)))
+end
+
+function Vec2_LessThan()
+ assert(omath.Vec2.new(1, 0) < omath.Vec2.new(3, 4))
+end
+
+function Vec2_LessThanOrEqual()
+ -- (3,4) and (4,3) both have length 5
+ assert(omath.Vec2.new(3, 4) <= omath.Vec2.new(4, 3))
+end
+
+function Vec2_ToString()
+ assert(tostring(omath.Vec2.new(1, 2)) == "Vec2(1, 2)")
+end
+
+function Vec2_Length()
+ assert(approx(omath.Vec2.new(3, 4):length(), 5.0))
+end
+
+function Vec2_LengthSqr()
+ assert(omath.Vec2.new(3, 4):length_sqr() == 25.0)
+end
+
+function Vec2_Normalized()
+ local n = omath.Vec2.new(3, 4):normalized()
+ assert(approx(n.x, 0.6) and approx(n.y, 0.8))
+end
+
+function Vec2_Dot()
+ assert(omath.Vec2.new(1, 2):dot(omath.Vec2.new(3, 4)) == 11.0)
+end
+
+function Vec2_DistanceTo()
+ assert(approx(omath.Vec2.new(0, 0):distance_to(omath.Vec2.new(3, 4)), 5.0))
+end
+
+function Vec2_DistanceToSqr()
+ assert(omath.Vec2.new(0, 0):distance_to_sqr(omath.Vec2.new(3, 4)) == 25.0)
+end
+
+function Vec2_Sum()
+ assert(omath.Vec2.new(3, 4):sum() == 7.0)
+end
+
+function Vec2_Abs()
+ local a = omath.Vec2.new(-3, -4):abs()
+ assert(a.x == 3 and a.y == 4)
+end
diff --git a/tests/lua/vec3_tests.lua b/tests/lua/vec3_tests.lua
new file mode 100644
index 00000000..8ac7d93c
--- /dev/null
+++ b/tests/lua/vec3_tests.lua
@@ -0,0 +1,163 @@
+local function approx(a, b, eps) return math.abs(a - b) < (eps or 1e-5) end
+
+function Vec3_Constructor_default()
+ local v = omath.Vec3.new()
+ assert(v.x == 0 and v.y == 0 and v.z == 0)
+end
+
+function Vec3_Constructor_xyz()
+ local v = omath.Vec3.new(1, 2, 3)
+ assert(v.x == 1 and v.y == 2 and v.z == 3)
+end
+
+function Vec3_Field_mutation()
+ local v = omath.Vec3.new(1, 2, 3)
+ v.x = 9; v.y = 8; v.z = 7
+ assert(v.x == 9 and v.y == 8 and v.z == 7)
+end
+
+function Vec3_Addition()
+ local c = omath.Vec3.new(1, 2, 3) + omath.Vec3.new(4, 5, 6)
+ assert(c.x == 5 and c.y == 7 and c.z == 9)
+end
+
+function Vec3_Subtraction()
+ local c = omath.Vec3.new(4, 5, 6) - omath.Vec3.new(1, 2, 3)
+ assert(c.x == 3 and c.y == 3 and c.z == 3)
+end
+
+function Vec3_UnaryMinus()
+ local b = -omath.Vec3.new(1, 2, 3)
+ assert(b.x == -1 and b.y == -2 and b.z == -3)
+end
+
+function Vec3_Multiplication_scalar()
+ local b = omath.Vec3.new(1, 2, 3) * 2.0
+ assert(b.x == 2 and b.y == 4 and b.z == 6)
+end
+
+function Vec3_Multiplication_scalar_reversed()
+ local b = 2.0 * omath.Vec3.new(1, 2, 3)
+ assert(b.x == 2 and b.y == 4 and b.z == 6)
+end
+
+function Vec3_Multiplication_vec()
+ local c = omath.Vec3.new(2, 3, 4) * omath.Vec3.new(2, 2, 2)
+ assert(c.x == 4 and c.y == 6 and c.z == 8)
+end
+
+function Vec3_Division_scalar()
+ local b = omath.Vec3.new(2, 4, 6) / 2.0
+ assert(b.x == 1 and b.y == 2 and b.z == 3)
+end
+
+function Vec3_Division_vec()
+ local c = omath.Vec3.new(4, 6, 8) / omath.Vec3.new(2, 2, 2)
+ assert(c.x == 2 and c.y == 3 and c.z == 4)
+end
+
+function Vec3_EqualTo_true()
+ assert(omath.Vec3.new(1, 2, 3) == omath.Vec3.new(1, 2, 3))
+end
+
+function Vec3_EqualTo_false()
+ assert(not (omath.Vec3.new(1, 2, 3) == omath.Vec3.new(9, 9, 9)))
+end
+
+function Vec3_LessThan()
+ assert(omath.Vec3.new(1, 0, 0) < omath.Vec3.new(3, 4, 0))
+end
+
+function Vec3_LessThanOrEqual()
+ -- (0,3,4) and (0,4,3) both have length 5
+ assert(omath.Vec3.new(0, 3, 4) <= omath.Vec3.new(0, 4, 3))
+end
+
+function Vec3_ToString()
+ assert(tostring(omath.Vec3.new(1, 2, 3)) == "Vec3(1, 2, 3)")
+end
+
+function Vec3_Length()
+ assert(approx(omath.Vec3.new(1, 2, 2):length(), 3.0))
+end
+
+function Vec3_Length2d()
+ assert(approx(omath.Vec3.new(3, 4, 99):length_2d(), 5.0))
+end
+
+function Vec3_LengthSqr()
+ assert(omath.Vec3.new(1, 2, 2):length_sqr() == 9.0)
+end
+
+function Vec3_Normalized()
+ local n = omath.Vec3.new(3, 0, 0):normalized()
+ assert(approx(n.x, 1.0) and approx(n.y, 0.0) and approx(n.z, 0.0))
+end
+
+function Vec3_Dot_perpendicular()
+ assert(omath.Vec3.new(1, 0, 0):dot(omath.Vec3.new(0, 1, 0)) == 0.0)
+end
+
+function Vec3_Dot_parallel()
+ local a = omath.Vec3.new(1, 2, 3)
+ assert(a:dot(a) == 14.0)
+end
+
+function Vec3_Cross()
+ local c = omath.Vec3.new(1, 0, 0):cross(omath.Vec3.new(0, 1, 0))
+ assert(approx(c.x, 0) and approx(c.y, 0) and approx(c.z, 1))
+end
+
+function Vec3_DistanceTo()
+ assert(approx(omath.Vec3.new(0, 0, 0):distance_to(omath.Vec3.new(1, 2, 2)), 3.0))
+end
+
+function Vec3_DistanceToSqr()
+ assert(omath.Vec3.new(0, 0, 0):distance_to_sqr(omath.Vec3.new(1, 2, 2)) == 9.0)
+end
+
+function Vec3_Sum()
+ assert(omath.Vec3.new(1, 2, 3):sum() == 6.0)
+end
+
+function Vec3_Sum2d()
+ assert(omath.Vec3.new(1, 2, 3):sum_2d() == 3.0)
+end
+
+function Vec3_Abs()
+ local a = omath.Vec3.new(-1, -2, -3):abs()
+ assert(a.x == 1 and a.y == 2 and a.z == 3)
+end
+
+function Vec3_PointToSameDirection_true()
+ assert(omath.Vec3.new(1, 1, 0):point_to_same_direction(omath.Vec3.new(2, 2, 0)) == true)
+end
+
+function Vec3_PointToSameDirection_false()
+ assert(omath.Vec3.new(1, 0, 0):point_to_same_direction(omath.Vec3.new(-1, 0, 0)) == false)
+end
+
+function Vec3_IsPerpendicular_true()
+ assert(omath.Vec3.new(1, 0, 0):is_perpendicular(omath.Vec3.new(0, 1, 0)) == true)
+end
+
+function Vec3_IsPerpendicular_false()
+ local a = omath.Vec3.new(1, 0, 0)
+ assert(a:is_perpendicular(a) == false)
+end
+
+function Vec3_AngleBetween_90deg()
+ local angle, err = omath.Vec3.new(1, 0, 0):angle_between(omath.Vec3.new(0, 1, 0))
+ assert(angle ~= nil, err)
+ assert(math.abs(angle - 90.0) < 1e-3)
+end
+
+function Vec3_AngleBetween_zero_vector_error()
+ local angle, err = omath.Vec3.new(0, 0, 0):angle_between(omath.Vec3.new(1, 0, 0))
+ assert(angle == nil and err ~= nil)
+end
+
+function Vec3_AsTable()
+ local t = omath.Vec3.new(1, 2, 3):as_table()
+ assert(t.x == 1 and t.y == 2 and t.z == 3)
+end
diff --git a/tests/lua/vec4_tests.lua b/tests/lua/vec4_tests.lua
new file mode 100644
index 00000000..ba584c3f
--- /dev/null
+++ b/tests/lua/vec4_tests.lua
@@ -0,0 +1,110 @@
+local function approx(a, b) return math.abs(a - b) < 1e-5 end
+
+function Vec4_Constructor_default()
+ local v = omath.Vec4.new()
+ assert(v.x == 0 and v.y == 0 and v.z == 0 and v.w == 0)
+end
+
+function Vec4_Constructor_xyzw()
+ local v = omath.Vec4.new(1, 2, 3, 4)
+ assert(v.x == 1 and v.y == 2 and v.z == 3 and v.w == 4)
+end
+
+function Vec4_Field_mutation()
+ local v = omath.Vec4.new(1, 2, 3, 4)
+ v.w = 99
+ assert(v.w == 99)
+end
+
+function Vec4_Addition()
+ local c = omath.Vec4.new(1, 2, 3, 4) + omath.Vec4.new(4, 3, 2, 1)
+ assert(c.x == 5 and c.y == 5 and c.z == 5 and c.w == 5)
+end
+
+function Vec4_Subtraction()
+ local c = omath.Vec4.new(5, 5, 5, 5) - omath.Vec4.new(1, 2, 3, 4)
+ assert(c.x == 4 and c.y == 3 and c.z == 2 and c.w == 1)
+end
+
+function Vec4_UnaryMinus()
+ local b = -omath.Vec4.new(1, 2, 3, 4)
+ assert(b.x == -1 and b.y == -2 and b.z == -3 and b.w == -4)
+end
+
+function Vec4_Multiplication_scalar()
+ local b = omath.Vec4.new(1, 2, 3, 4) * 2.0
+ assert(b.x == 2 and b.y == 4 and b.z == 6 and b.w == 8)
+end
+
+function Vec4_Multiplication_scalar_reversed()
+ local b = 2.0 * omath.Vec4.new(1, 2, 3, 4)
+ assert(b.x == 2 and b.y == 4 and b.z == 6 and b.w == 8)
+end
+
+function Vec4_Multiplication_vec()
+ local c = omath.Vec4.new(2, 3, 4, 5) * omath.Vec4.new(2, 2, 2, 2)
+ assert(c.x == 4 and c.y == 6 and c.z == 8 and c.w == 10)
+end
+
+function Vec4_Division_scalar()
+ local b = omath.Vec4.new(2, 4, 6, 8) / 2.0
+ assert(b.x == 1 and b.y == 2 and b.z == 3 and b.w == 4)
+end
+
+function Vec4_Division_vec()
+ local c = omath.Vec4.new(4, 6, 8, 10) / omath.Vec4.new(2, 2, 2, 2)
+ assert(c.x == 2 and c.y == 3 and c.z == 4 and c.w == 5)
+end
+
+function Vec4_EqualTo_true()
+ assert(omath.Vec4.new(1, 2, 3, 4) == omath.Vec4.new(1, 2, 3, 4))
+end
+
+function Vec4_EqualTo_false()
+ assert(not (omath.Vec4.new(1, 2, 3, 4) == omath.Vec4.new(9, 9, 9, 9)))
+end
+
+function Vec4_LessThan()
+ assert(omath.Vec4.new(1, 0, 0, 0) < omath.Vec4.new(0, 0, 3, 4))
+end
+
+function Vec4_LessThanOrEqual()
+ -- (0,0,3,4) and (0,0,4,3) both have length 5
+ assert(omath.Vec4.new(0, 0, 3, 4) <= omath.Vec4.new(0, 0, 4, 3))
+end
+
+function Vec4_ToString()
+ assert(tostring(omath.Vec4.new(1, 2, 3, 4)) == "Vec4(1, 2, 3, 4)")
+end
+
+function Vec4_Length()
+ assert(approx(omath.Vec4.new(0, 0, 3, 4):length(), 5.0))
+end
+
+function Vec4_LengthSqr()
+ assert(omath.Vec4.new(0, 0, 3, 4):length_sqr() == 25.0)
+end
+
+function Vec4_Dot()
+ local a = omath.Vec4.new(1, 2, 3, 4)
+ assert(a:dot(a) == 30.0)
+end
+
+function Vec4_Dot_perpendicular()
+ assert(omath.Vec4.new(1, 0, 0, 0):dot(omath.Vec4.new(0, 1, 0, 0)) == 0.0)
+end
+
+function Vec4_Sum()
+ assert(omath.Vec4.new(1, 2, 3, 4):sum() == 10.0)
+end
+
+function Vec4_Abs()
+ local a = omath.Vec4.new(-1, -2, -3, -4):abs()
+ assert(a.x == 1 and a.y == 2 and a.z == 3 and a.w == 4)
+end
+
+function Vec4_Clamp()
+ local v = omath.Vec4.new(5, -3, 10, 99)
+ v:clamp(0, 7)
+ assert(v.x == 5 and v.y == 0 and v.z == 7)
+end
diff --git a/tests/lua/vector2_test.lua b/tests/lua/vector2_test.lua
new file mode 100644
index 00000000..d20dd2cf
--- /dev/null
+++ b/tests/lua/vector2_test.lua
@@ -0,0 +1,29 @@
+local a = omath.Vec2.new(1, 2)
+local b = omath.Vec2.new(10, 20)
+
+-- Operators
+local c = a + b
+local d = a - b
+local e = a * 2.0
+local f = -a
+print("a + b = " .. tostring(c))
+print("a - b = " .. tostring(d))
+print("a * 2 = " .. tostring(e))
+print("-a = " .. tostring(f))
+print("a == Vec2(1,2): " .. tostring(a == omath.Vec2.new(1, 2)))
+print("a < b: " .. tostring(a < b))
+
+-- Field access + mutation
+print("c.x = " .. c.x .. ", c.y = " .. c.y)
+c.x = 99
+print("c.x after mutation = " .. c.x)
+
+-- Methods
+print("a:length() = " .. a:length())
+print("a:length_sqr() = " .. a:length_sqr())
+print("a:normalized() = " .. tostring(a:normalized()))
+print("a:dot(b) = " .. a:dot(b))
+print("a:distance_to(b) = " .. a:distance_to(b))
+print("a:distance_to_sqr(b) = " .. a:distance_to_sqr(b))
+print("a:sum() = " .. a:sum())
+print("a:abs() = " .. tostring(a:abs()))
diff --git a/tests/lua/vector3_test.lua b/tests/lua/vector3_test.lua
new file mode 100644
index 00000000..88835cc3
--- /dev/null
+++ b/tests/lua/vector3_test.lua
@@ -0,0 +1,55 @@
+local a = omath.Vec3.new(1, 0, 0)
+local b = omath.Vec3.new(0, 1, 0)
+
+-- Operators
+local c = a + b
+local d = a - b
+local e = a * 2.0
+local f = -a
+print("a + b = " .. tostring(c))
+print("a - b = " .. tostring(d))
+print("a * 2 = " .. tostring(e))
+print("-a = " .. tostring(f))
+print("a == Vec3(1,2,3): " .. tostring(a == omath.Vec3.new(1, 2, 3)))
+print("a < b: " .. tostring(a < b))
+
+-- Field access + mutation
+print("c.x = " .. c.x .. ", c.y = " .. c.y .. ", c.z = " .. c.z)
+c.x = 99
+print("c.x after mutation = " .. c.x)
+
+-- Methods
+print("a:length() = " .. a:length())
+print("a:length_2d() = " .. a:length_2d())
+print("a:length_sqr() = " .. a:length_sqr())
+print("a:normalized() = " .. tostring(a:normalized()))
+print("a:dot(b) = " .. a:dot(b))
+print("a:cross(b) = " .. tostring(a:cross(b)))
+print("a:distance_to(b) = " .. a:distance_to(b))
+print("a:distance_to_sqr(b) = " .. a:distance_to_sqr(b))
+print("a:abs() = " .. tostring(a:abs()))
+print("a:sum() = " .. a:sum())
+print("a:sum_2d() = " .. a:sum_2d())
+print("a:point_to_same_direction(b) = " .. tostring(a:point_to_same_direction(b)))
+print("a:is_perpendicular(b) = " .. tostring(a:is_perpendicular(b)))
+
+-- angle_between
+local angle, err = a:angle_between(b)
+if angle then
+ print("angle_between = " .. angle .. " degrees")
+else
+ print("angle_between error: " .. err)
+end
+
+-- Zero vector edge case
+local zero = omath.Vec3.new(0, 0, 0)
+local ang2, err2 = zero:angle_between(a)
+if ang2 then
+ print("zero angle = " .. ang2)
+else
+ print("zero angle error: " .. err2)
+end
+
+-- as_table
+local t = a:as_table()
+print("as_table: x=" .. t.x .. " y=" .. t.y .. " z=" .. t.z)
diff --git a/tests/lua/vector4_test.lua b/tests/lua/vector4_test.lua
new file mode 100644
index 00000000..4983a6f2
--- /dev/null
+++ b/tests/lua/vector4_test.lua
@@ -0,0 +1,31 @@
+local a = omath.Vec4.new(1, 2, 3, 4)
+local b = omath.Vec4.new(10, 20, 30, 40)
+
+-- Operators
+local c = a + b
+local d = a - b
+local e = a * 2.0
+local f = -a
+print("a + b = " .. tostring(c))
+print("a - b = " .. tostring(d))
+print("a * 2 = " .. tostring(e))
+print("-a = " .. tostring(f))
+print("a == Vec4(1,2,3,4): " .. tostring(a == omath.Vec4.new(1, 2, 3, 4)))
+print("a < b: " .. tostring(a < b))
+
+-- Field access + mutation
+print("c.x=" .. c.x .. " c.y=" .. c.y .. " c.z=" .. c.z .. " c.w=" .. c.w)
+c.w = 99
+print("c.w after mutation = " .. c.w)
+
+-- Methods
+print("a:length() = " .. a:length())
+print("a:length_sqr() = " .. a:length_sqr())
+print("a:dot(b) = " .. a:dot(b))
+print("a:sum() = " .. a:sum())
+print("a:abs() = " .. tostring(a:abs()))
+
+-- clamp
+local clamped = omath.Vec4.new(5, -3, 10, 1)
+clamped:clamp(0, 7)
+print("clamp([5,-3,10,1], 0, 7).x=" .. clamped.x .. " .y=" .. clamped.y .. " .z=" .. clamped.z)
diff --git a/vcpkg.json b/vcpkg.json
index 7a99ddfb..52c18565 100644
--- a/vcpkg.json
+++ b/vcpkg.json
@@ -17,7 +17,7 @@
],
"features": {
"avx2": {
- "description": "Omath will use AVX2 to boost performance",
+ "description": "omath will use AVX2 to boost performance",
"supports": "!arm"
},
"benchmark": {
@@ -35,7 +35,7 @@
]
},
"imgui": {
- "description": "Omath will define method to convert omath types to imgui types",
+ "description": "omath will define method to convert omath types to imgui types",
"dependencies": [
"imgui"
]
@@ -45,6 +45,13 @@
"dependencies": [
"gtest"
]
+ },
+ "lua": {
+ "description": "lua support for omath",
+ "dependencies": [
+ "lua",
+ "sol2"
+ ]
}
}
}