diff --git a/.luacheckrc b/.luacheckrc index e0c910c421a0c..20f67cc609c94 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -15,6 +15,7 @@ read_globals = { "dump", "dump2", "fgettext", "fgettext_ne", "vector", + "vector2", "VoxelArea", "VoxelManip", "profiler", @@ -24,7 +25,7 @@ read_globals = { string = {fields = {"split", "trim"}}, table = {fields = {"copy", "copy_with_metatables", "getn", "indexof", "keyof", "insert_all", "shuffle"}}, - math = {fields = {"hypot", "round", "isfinite"}}, + math = {fields = {"hypot", "round", "isfinite", "sign"}}, } globals = { @@ -71,6 +72,10 @@ files["builtin/common/vector.lua"] = { globals = { "vector", "math" }, } +files["builtin/common/vector2.lua"] = { + globals = { "vector2", "math" }, +} + files["builtin/game/voxelarea.lua"] = { globals = { "VoxelArea" }, } diff --git a/builtin/common/tests/vector2_spec.lua b/builtin/common/tests/vector2_spec.lua new file mode 100644 index 0000000000000..d973bc0784f6c --- /dev/null +++ b/builtin/common/tests/vector2_spec.lua @@ -0,0 +1,401 @@ +_G.vector = {} +_G.vector2 = {} +dofile("builtin/common/math.lua") +dofile("builtin/common/vector.lua") +dofile("builtin/common/vector2.lua") + +-- Custom assertion for comparing floating-point numbers with tolerance +local function number_close(state, arguments) + if #arguments < 2 then + return false + end + + local expected = arguments[1] + local actual = arguments[2] + local tolerance = arguments[3] or 0.000001 + + if type(expected) == "number" and type(actual) == "number" then + return math.abs(expected - actual) < tolerance + end + + + return false +end + +-- Custom assertion for comparing vectors with tolerance +-- Uses component-wise comparison to be self-contained +local function vector2_close(state, arguments) + if #arguments < 2 then + return false + end + + local expected = arguments[1] + local actual = arguments[2] + local tolerance = arguments[3] or 0.000001 + + if type(expected) == "table" and type(actual) == "table" then + return math.abs(expected.x - actual.x) < tolerance and + math.abs(expected.y - actual.y) < tolerance + end + + + return false +end + +assert:register("assertion", "number_close", number_close) +assert:register("assertion", "vector2_close", vector2_close) + +describe("vector2", function() + describe("new()", function() + it("constructs", function() + assert.same({x = 1, y = 2}, vector2.new(1, 2)) + + assert.is_true(vector2.check(vector2.new(1, 2))) + end) + + it("throws on invalid input", function() + assert.has.errors(function() + vector2.new() + end) + + assert.has.errors(function() + vector2.new({ x = 3, y = 2 }) + end) + + assert.has.errors(function() + vector2.new({ x = 3 }) + end) + + assert.has.errors(function() + vector2.new({ d = 3 }) + end) + end) + end) + + it("zero()", function() + assert.same({x = 0, y = 0}, vector2.zero()) + assert.is_true(vector2.check(vector2.zero())) + end) + + it("copy()", function() + local v = vector2.new(1, 2) + assert.same(v, vector2.copy(v)) + assert.is_true(vector2.check(vector2.copy(v))) + end) + + it("indexes", function() + local some_vector = vector2.new(24, 42) + assert.equal(24, some_vector[1]) + assert.equal(24, some_vector.x) + assert.equal(42, some_vector[2]) + assert.equal(42, some_vector.y) + + some_vector[1] = 100 + assert.equal(100, some_vector.x) + some_vector.x = 101 + assert.equal(101, some_vector[1]) + + some_vector[2] = 100 + assert.equal(100, some_vector.y) + some_vector.y = 102 + assert.equal(102, some_vector[2]) + end) + + it("direction()", function() + local a = vector2.new(1, 0) + local b = vector2.new(1, 42) + local dir1 = vector2.direction(a, b) + assert.number_close(0, dir1.x) + assert.number_close(1, dir1.y) + local dir2 = a:direction(b) + assert.number_close(0, dir2.x) + assert.number_close(1, dir2.y) + end) + + it("distance()", function() + local a = vector2.new(1, 0) + local b = vector2.new(4, 4) + assert.number_close(5, vector2.distance(a, b)) + assert.number_close(5, a:distance(b)) + assert.number_close(0, vector2.distance(a, a)) + assert.number_close(0, b:distance(b)) + end) + + it("length()", function() + local a = vector2.new(3, 4) + assert.number_close(0, vector2.length(vector2.zero())) + assert.number_close(5, vector2.length(a)) + assert.number_close(5, a:length()) + end) + + it("normalize()", function() + local a = vector2.new(0, -5) + local norm1 = vector2.normalize(a) + assert.number_close(0, norm1.x) + assert.number_close(-1, norm1.y) + local norm2 = a:normalize() + assert.number_close(0, norm2.x) + assert.number_close(-1, norm2.y) + local norm3 = vector2.normalize(vector2.zero()) + assert.number_close(0, norm3.x) + assert.number_close(0, norm3.y) + end) + + it("floor()", function() + local a = vector2.new(0.1, 0.9) + assert.same(vector2.new(0, 0), vector2.floor(a)) + assert.same(vector2.new(0, 0), a:floor()) + end) + + it("round()", function() + local a = vector2.new(0.1, 0.9) + assert.same(vector2.new(0, 1), vector2.round(a)) + assert.same(vector2.new(0, 1), a:round()) + end) + + it("ceil()", function() + local a = vector2.new(0.1, 0.9) + assert.same(vector2.new(1, 1), vector2.ceil(a)) + assert.same(vector2.new(1, 1), a:ceil()) + end) + + it("sign()", function() + local a = vector2.new(-120.3, 231.5) + assert.same(vector2.new(-1, 1), vector2.sign(a)) + assert.same(vector2.new(-1, 1), a:sign()) + assert.same(vector2.new(0, 1), vector2.sign(a, 200)) + assert.same(vector2.new(0, 1), a:sign(200)) + end) + + it("abs()", function() + local a = vector2.new(-123.456, 13) + assert.same(vector2.new(123.456, 13), vector2.abs(a)) + assert.same(vector2.new(123.456, 13), a:abs()) + end) + + it("apply()", function() + local f = function(x) + return x * 2 + end + local f2 = function(x, opt1, opt2) + return x + opt1 + opt2 + end + local a = vector2.new(0.1, 0.9) + assert.same(vector2.new(1, 1), vector2.apply(a, math.ceil)) + assert.same(vector2.new(1, 1), a:apply(math.ceil)) + assert.same(vector2.new(0.1, 0.9), vector2.apply(a, math.abs)) + assert.same(vector2.new(0.1, 0.9), a:apply(math.abs)) + assert.same(vector2.new(0.2, 1.8), vector2.apply(a, f)) + assert.same(vector2.new(0.2, 1.8), a:apply(f)) + local b = vector2.new(1, 2) + assert.same(vector2.new(3, 4), vector2.apply(b, f2, 1, 1)) + assert.same(vector2.new(3, 4), b:apply(f2, 1, 1)) + end) + + it("combine()", function() + local a = vector2.new(1, 4) + local b = vector2.new(2, 3) + assert.same(vector2.add(a, b), vector2.combine(a, b, function(x, y) return x + y end)) + assert.same(vector2.new(2, 4), vector2.combine(a, b, math.max)) + assert.same(vector2.new(1, 3), vector2.combine(a, b, math.min)) + end) + + it("equals()", function() + assert.is_true(vector2.equals({x = 0, y = 0}, {x = 0, y = 0})) + assert.is_true(vector2.equals({x = -1, y = 0}, vector2.new(-1, 0))) + assert.is_false(vector2.equals({x = 1, y = 2}, {x = 1, y = 3})) + local a = vector2.new(1, 2) + assert.is_true(a:equals(a)) + assert.is_true(vector2.new(1, 2) == vector2.new(1, 2)) + assert.is_false(vector2.new(1, 2) == vector2.new(1, 3)) + end) + + it("metatable is same", function() + local a = vector2.zero() + local b = vector2.new(1, 2) + + assert.equal(true, vector2.check(a)) + assert.equal(true, vector2.check(b)) + + assert.equal(vector2.metatable, getmetatable(a)) + assert.equal(vector2.metatable, getmetatable(b)) + assert.equal(vector2.metatable, a.metatable) + end) + + it("sort()", function() + local a = vector2.new(1, 2) + local b = vector2.new(0.5, 232) + local sorted = {vector2.new(0.5, 2), vector2.new(1, 232)} + assert.same(sorted, {vector2.sort(a, b)}) + assert.same(sorted, {a:sort(b)}) + end) + + it("angle()", function() + assert.number_close(math.pi, vector2.angle(vector2.new(-1, -2), vector2.new(1, 2))) + assert.number_close(math.pi/2, vector2.new(0, 1):angle(vector2.new(1, 0))) + end) + + it("dot()", function() + assert.equal(-5, vector2.dot(vector2.new(-1, -2), vector2.new(1, 2))) + assert.equal(0, vector2.zero():dot(vector2.new(1, 2))) + end) + + it("offset()", function() + assert.same({x = 41, y = 52}, vector2.offset(vector2.new(1, 2), 40, 50)) + assert.same(vector2.new(41, 52), vector2.offset(vector2.new(1, 2), 40, 50)) + assert.same(vector2.new(41, 52), vector2.new(1, 2):offset(40, 50)) + end) + + it("check()", function() + assert.is_false(vector2.check(nil)) + assert.is_false(vector2.check(1)) + assert.is_false(vector2.check({x = 1, y = 2})) + local real = vector2.new(1, 2) + assert.is_true(vector2.check(real)) + assert.is_true(real:check()) + end) + + it("abusing works", function() + local v = vector2.new(1, 2) + v.a = 1 + assert.equal(1, v.a) + + local a_is_there = false + for key, value in pairs(v) do + if key == "a" then + a_is_there = true + assert.equal(value, 1) + break + end + end + assert.is_true(a_is_there) + end) + + it("add()", function() + local a = vector2.new(1, 2) + local b = vector2.new(1, 4) + local c = vector2.new(2, 6) + assert.same(c, vector2.add(a, {x = 1, y = 4})) + assert.same(c, vector2.add(a, b)) + assert.same(c, a:add(b)) + assert.same(c, a + b) + assert.same(c, b + a) + end) + + it("subtract()", function() + local a = vector2.new(1, 2) + local b = vector2.new(2, 4) + local c = vector2.new(-1, -2) + assert.same(c, vector2.subtract(a, {x = 2, y = 4})) + assert.same(c, vector2.subtract(a, b)) + assert.same(c, a:subtract(b)) + assert.same(c, a - b) + assert.same(c, -b + a) + end) + + it("multiply()", function() + local a = vector2.new(1, 2) + local s = 2 + local d = vector2.new(2, 4) + assert.same(d, vector2.multiply(a, s)) + assert.same(d, a:multiply(s)) + assert.same(d, a * s) + assert.same(d, s * a) + assert.same(-a, -1 * a) + end) + + it("divide()", function() + local a = vector2.new(1, 2) + local s = 2 + local d = vector2.new(0.5, 1) + assert.same(d, vector2.divide(a, s)) + assert.same(d, a:divide(s)) + assert.same(d, a / s) + assert.same(d, 1/s * a) + assert.same(-a, a / -1) + end) + + it("to_string()", function() + local v = vector2.new(1, 2) + local str1 = vector2.to_string(v) + local str2 = v:to_string() + local str3 = tostring(v) + + -- All should produce the same string + assert.same(str1, str2) + assert.same(str1, str3) + + -- Verify the string format + assert.same("(1, 2)", str1) + + -- Test edge cases for %g format + assert.same("(0, 0)", vector2.to_string(vector2.new(0, 0))) + assert.same("(-1, -2)", vector2.to_string(vector2.new(-1, -2))) + assert.same("(0.0001, 1e+10)", vector2.to_string(vector2.new(0.0001, 1e10))) + assert.same("(3.14159, 1.41421)", vector2.to_string(vector2.new(math.pi, math.sqrt(2)))) + end) + + it("from_string()", function() + local v = vector2.new(1, 2) + assert.is_true(vector2.check(vector2.from_string("(1, 2)"))) + assert.same({v, 7}, {vector2.from_string("(1, 2)")}) + assert.same({v, 7}, {vector2.from_string("(1,2 )")}) + assert.same({v, 7}, {vector2.from_string("(1,2,)")}) + assert.same({v, 6}, {vector2.from_string("(1 2)")}) + assert.same({v, 9}, {vector2.from_string("( 1, 2 )")}) + assert.same({v, 9}, {vector2.from_string(" ( 1, 2) ")}) + assert.same({vector2.zero(), 6}, {vector2.from_string("(0,0) ( 1, 2) ")}) + assert.same({v, 14}, {vector2.from_string("(0,0) ( 1, 2) ", 6)}) + assert.same({v, 14}, {vector2.from_string("(0,0) ( 1, 2) ", 7)}) + assert.is_nil(vector2.from_string("nothing")) + end) + + describe("from_angle()", function() + it("creates unit vector from angle", function() + assert.vector2_close(vector2.new(1, 0), vector2.from_angle(0)) + assert.vector2_close(vector2.new(0, 1), vector2.from_angle(math.pi / 2)) + assert.vector2_close(vector2.new(-1, 0), vector2.from_angle(math.pi)) + end) + + it("throws on invalid input", function() + assert.has.errors(function() + vector2.from_angle() + end) + end) + end) + + describe("to_angle()", function() + it("returns angle of vector", function() + assert.number_close(0, vector2.to_angle(vector2.new(1, 0))) + assert.number_close(math.pi / 2, vector2.to_angle(vector2.new(0, 1))) + assert.number_close(math.pi / 4, vector2.to_angle(vector2.new(1, 1))) + end) + + it("is inverse of from_angle", function() + local angle = math.pi / 3 + local v = vector2.from_angle(angle) + assert.number_close(angle, vector2.to_angle(v)) + end) + end) + + describe("rotate()", function() + it("rotates vector by angle in radians", function() + assert.vector2_close(vector2.new(0, 1), vector2.rotate(vector2.new(1, 0), math.pi / 2)) + assert.vector2_close(vector2.new(-1, 0), vector2.rotate(vector2.new(1, 0), math.pi)) + assert.vector2_close(vector2.new(0, 1), vector2.new(1, 0):rotate(math.pi / 2)) + end) + + it("preserves length", function() + local v = vector2.new(3, 4) + local rotated = vector2.rotate(v, math.pi / 3) + assert.number_close(vector2.length(v), vector2.length(rotated)) + end) + end) + + it("in_area()", function() + assert.is_true(vector2.in_area(vector2.zero(), vector2.new(-10, -10), vector2.new(10, 10))) + assert.is_true(vector2.in_area(vector2.new(-2, 5), vector2.new(-10, -10), vector2.new(10, 10))) + assert.is_true(vector2.in_area(vector2.new(-10, -10), vector2.new(-10, -10), vector2.new(10, 10))) + assert.is_false(vector2.in_area(vector2.new(-11, -10), vector2.new(-10, -10), vector2.new(10, 10))) + end) +end) diff --git a/builtin/common/vector2.lua b/builtin/common/vector2.lua new file mode 100644 index 0000000000000..41e192838c657 --- /dev/null +++ b/builtin/common/vector2.lua @@ -0,0 +1,275 @@ +--[[ +2D Vector helpers +Note: The vector2.*-functions must be able to accept old vectors that had no metatables +]] + +-- localize functions +local setmetatable = setmetatable +local math = math + +vector2 = {} + +local metatable = {} +vector2.metatable = metatable + +local xy = {"x", "y"} + +-- only called when rawget(v, key) returns nil +function metatable.__index(v, key) + return rawget(v, xy[key]) or vector2[key] +end + +-- only called when rawget(v, key) returns nil +function metatable.__newindex(v, key, value) + rawset(v, xy[key] or key, value) +end + +-- constructors + +local function fast_new(x, y) + return setmetatable({x = x, y = y}, metatable) +end + +function vector2.new(x, y) + assert(x and y, "Invalid arguments for vector2.new()") + return fast_new(x, y) +end + +function vector2.zero() + return fast_new(0, 0) +end + +function vector2.copy(v) + assert(v.x and v.y, "Invalid vector passed to vector2.copy()") + return fast_new(v.x, v.y) +end + +function vector2.from_angle(angle) + assert(angle, "Invalid argument for vector2.from_angle()") + return fast_new(math.cos(angle), math.sin(angle)) +end + +function vector2.from_string(s, init) + local x, y, np = string.match(s, "^%s*%(%s*([^%s,]+)%s*[,%s]%s*([^%s,]+)%s*,?%s*%)()", init) + x = tonumber(x) + y = tonumber(y) + if not (x and y) then + return + end + return fast_new(x, y), np +end + +function vector2.to_string(v) + return string.format("(%g, %g)", v.x, v.y) +end +metatable.__tostring = vector2.to_string + +function vector2.equals(a, b) + return a.x == b.x and a.y == b.y +end +metatable.__eq = vector2.equals + +-- unary operations + +function vector2.length(v) + return math.sqrt(v.x * v.x + v.y * v.y) +end + +function vector2.to_angle(v) + return math.atan2(v.y, v.x) +end + +function vector2.normalize(v) + local len = vector2.length(v) + if len == 0 then + return fast_new(0, 0) + else + return vector2.divide(v, len) + end +end + +function vector2.floor(v) + return vector2.apply(v, math.floor) +end + +function vector2.round(v) + return vector2.apply(v, math.round) +end + +function vector2.ceil(v) + return vector2.apply(v, math.ceil) +end + +function vector2.sign(v, tolerance) + return vector2.apply(v, math.sign, tolerance) +end + +function vector2.abs(v) + return vector2.apply(v, math.abs) +end + +function vector2.apply(v, func, ...) + return fast_new( + func(v.x, ...), + func(v.y, ...) + ) +end + +function vector2.combine(a, b, func) + return fast_new( + func(a.x, b.x), + func(a.y, b.y) + ) +end + +function vector2.distance(a, b) + local x = a.x - b.x + local y = a.y - b.y + return math.sqrt(x * x + y * y) +end + +function vector2.direction(pos1, pos2) + return vector2.subtract(pos2, pos1):normalize() +end + +function vector2.angle(a, b) + local dotp = vector2.dot(a, b) + local crossplen = math.abs(a.x * b.y - a.y * b.x) + return math.atan2(crossplen, dotp) +end + +function vector2.dot(a, b) + return a.x * b.x + a.y * b.y +end + +function vector2.rotate(v, angle) + local cosangle = math.cos(angle) + local sinangle = math.sin(angle) + return fast_new( + v.x * cosangle - v.y * sinangle, + v.x * sinangle + v.y * cosangle + ) +end + +function metatable.__unm(v) + return fast_new(-v.x, -v.y) +end + +-- add, sub, mul, div operations + +function vector2.add(a, b) + if type(b) == "table" then + return fast_new( + a.x + b.x, + a.y + b.y + ) + else + return fast_new( + a.x + b, + a.y + b + ) + end +end +function metatable.__add(a, b) + return fast_new( + a.x + b.x, + a.y + b.y + ) +end + +function vector2.subtract(a, b) + if type(b) == "table" then + return fast_new( + a.x - b.x, + a.y - b.y + ) + else + return fast_new( + a.x - b, + a.y - b + ) + end +end +function metatable.__sub(a, b) + return fast_new( + a.x - b.x, + a.y - b.y + ) +end + +function vector2.multiply(a, b) + return fast_new( + a.x * b, + a.y * b + ) +end +function metatable.__mul(a, b) + if type(a) == "table" then + return fast_new( + a.x * b, + a.y * b + ) + else + return fast_new( + a * b.x, + a * b.y + ) + end +end + +function vector2.divide(a, b) + return fast_new( + a.x / b, + a.y / b + ) +end +-- vector÷vector makes no sense +metatable.__div = vector2.divide + +-- misc stuff + +function vector2.offset(v, x, y) + return fast_new( + v.x + x, + v.y + y + ) +end + +function vector2.sort(a, b) + return fast_new(math.min(a.x, b.x), math.min(a.y, b.y)), + fast_new(math.max(a.x, b.x), math.max(a.y, b.y)) +end + +function vector2.check(v) + return getmetatable(v) == metatable +end + +function vector2.in_area(pos, min, max) + return (pos.x >= min.x) and (pos.x <= max.x) and + (pos.y >= min.y) and (pos.y <= max.y) +end + +function vector2.random_direction() + -- Generate a random direction of unit length + local angle = math.random() * 2 * math.pi + return fast_new(math.cos(angle), math.sin(angle)) +end + +if rawget(_G, "core") and core.set_read_vector2 and core.set_push_vector2 then + local function read_vector2(v) + return v.x, v.y + end + core.set_read_vector2(read_vector2) + core.set_read_vector2 = nil + + if rawget(_G, "jit") then + -- This is necessary to prevent trace aborts. + local function push_vector2(x, y) + return (fast_new(x, y)) + end + core.set_push_vector2(push_vector2) + else + core.set_push_vector2(fast_new) + end + core.set_push_vector2 = nil +end diff --git a/builtin/init.lua b/builtin/init.lua index c0ba5f40013a0..d2ee8f06233b6 100644 --- a/builtin/init.lua +++ b/builtin/init.lua @@ -44,6 +44,7 @@ local asyncpath = scriptdir .. "async" .. DIR_DELIM dofile(commonpath .. "math.lua") dofile(commonpath .. "vector.lua") +dofile(commonpath .. "vector2.lua") dofile(commonpath .. "strict.lua") dofile(commonpath .. "serialize.lua") dofile(commonpath .. "misc_helpers.lua") diff --git a/doc/lua_api.md b/doc/lua_api.md index d4a59ce124be8..9475537ce9c06 100644 --- a/doc/lua_api.md +++ b/doc/lua_api.md @@ -3961,112 +3961,102 @@ or [Wikipedia](https://en.wikipedia.org/wiki/Cartesian_coordinate_system#Orienta for a more detailed and pictorial explanation of these terms. -Spatial Vectors -=============== +Vectors +======= -Luanti stores 3-dimensional spatial vectors in Lua as tables of 3 coordinates, -and has a class to represent them (`vector.*`), which this chapter is about. -For details on what a spatial vectors is, please refer to Wikipedia: -https://en.wikipedia.org/wiki/Euclidean_vector. +Luanti provides two vector classes for working with coordinates and mathematical operations: -Spatial vectors are used for various things, including, but not limited to: +* **Spatial Vectors** (`vector.*`) - 3-dimensional vectors for 3D positions, directions, and spatial operations +* **2D Vectors** (`vector2.*`) - 2-dimensional vectors for 2D positions, screen coordinates, and 2D operations -* any 3D spatial vector (x/y/z-directions) -* Euler angles (pitch/yaw/roll in radians) (Spatial vectors have no real semantic - meaning here. Therefore, most vector operations make no sense in this use case.) +Both vector types share many common properties and operations, which are described in the following sections. -Note that they are *not* used for: +Common to all vector types +--------------------------- -* n-dimensional vectors where n is not 3 (ie. n=2) -* arrays of the form `{num, num, num}` +### Special properties -The API documentation may refer to spatial vectors, as produced by `vector.new`, -by any of the following notations: +Vectors can be indexed with numbers and allow method and operator syntax. -* `(x, y, z)` (Used rarely, and only if it's clear that it's a vector.) -* `vector.new(x, y, z)` -* `{x=num, y=num, z=num}` (Even here you are still supposed to use `vector.new`.) +All these forms of addressing a vector `v` are valid: -Compatibility notes -------------------- +* For 3D vectors: `v[1]`, `v[3]`, `v.x`, `v[1] = 42`, `v.y = 13` +* For 2D vectors: `v[1]`, `v[2]`, `v.x`, `v[1] = 42`, `v.y = 13` -Vectors used to be defined as tables of the form `{x = num, y = num, z = num}`. -Since version 5.5.0, vectors additionally have a metatable to enable easier use. -Note: Those old-style vectors can still be found in old mod code. Hence, mod and -engine APIs still need to be able to cope with them in many places. +Note: Prefer letter over number indexing for performance and compatibility reasons. -Manually constructed tables are deprecated and highly discouraged. This interface -should be used to ensure seamless compatibility between mods and the Luanti API. -This is especially important to callback function parameters and functions overwritten -by mods. -Also, though not likely, the internal implementation of a vector might change in -the future. -In your own code, or if you define your own API, you can, of course, still use -other representations of vectors. +Where `v` is a vector and `foo` stands for any function name, `v:foo(...)` does +the same as `vector.foo(v, ...)` (or `vector2.foo(v, ...)` for 2D vectors). -Vectors provided by API functions will provide an instance of this class if not -stated otherwise. Mods should adapt this for convenience reasons. +`tostring` is defined for vectors, see `vector.to_string` and `vector2.to_string`. -Special properties of the class -------------------------------- +The metatable that is used for vectors can be accessed via `vector.metatable` or `vector2.metatable`. +Do not modify it! -Vectors can be indexed with numbers and allow method and operator syntax. +All `vector.*` and `vector2.*` functions allow vectors (e.g., `{x = X, y = Y, z = Z}`) without metatables. +Returned vectors always have a metatable set. -All these forms of addressing a vector `v` are valid: -`v[1]`, `v[3]`, `v.x`, `v[1] = 42`, `v.y = 13` -Note: Prefer letter over number indexing for performance and compatibility reasons. +Note: Vectors are *not* used for simple numeric arrays of the form `{num, num, num}` or `{num, num}`. +Use proper vector tables with named fields (`x`, `y`, `z`) instead. -Where `v` is a vector and `foo` stands for any function name, `v:foo(...)` does -the same as `vector.foo(v, ...)`, apart from deprecated functionality. +### Operators -`tostring` is defined for vectors, see `vector.to_string`. +Operators can be used if all of the involved vectors have metatables: -The metatable that is used for vectors can be accessed via `vector.metatable`. -Do not modify it! +* `v1 == v2`: + * Returns whether `v1` and `v2` are identical. +* `-v`: + * Returns the additive inverse of v. +* `v1 + v2`: + * Returns the sum of both vectors. + * Note: `+` cannot be used together with scalars. +* `v1 - v2`: + * Returns the difference of `v1` subtracted by `v2`. + * Note: `-` cannot be used together with scalars. +* `v * s` or `s * v`: + * Returns `v` scaled by `s`. +* `v / s`: + * Returns `v` scaled by `1 / s`. -All `vector.*` functions allow vectors `{x = X, y = Y, z = Z}` without metatables. -Returned vectors always have a metatable set. +### Common functions -Common functions and methods ----------------------------- +The following functions are available for both `vector` and `vector2` types with the same signature and behavior. +Replace `vector` with `vector2` for 2D vectors (e.g., `vector2.add(v, x)`). -For the following functions (and subchapters), -`v`, `v1`, `v2` are vectors, +For the following functions, +`v`, `v1`, `v2` are vectors (either 3D or 2D depending on context), `p1`, `p2` are position vectors, -`s` is a scalar (a number), -vectors are written like this: `(x, y, z)`: +`s` is a scalar (a number). -* `vector.new([a[, b, c]])`: - * Returns a new vector `(a, b, c)`. - * Deprecated: `vector.new()` does the same as `vector.zero()` and - `vector.new(v)` does the same as `vector.copy(v)` -* `vector.zero()`: - * Returns a new vector `(0, 0, 0)`. -* `vector.random_direction()`: - * Returns a new vector of length 1, pointing into a direction chosen uniformly at random. * `vector.copy(v)`: * Returns a copy of the vector `v`. +* `vector.zero()`: + * Returns a new zero vector. + * For 3D: `(0, 0, 0)`. For 2D: `(0, 0)`. +* `vector.random_direction()`: + * Returns a new vector of length 1, pointing in a direction chosen uniformly at random. * `vector.from_string(s[, init])`: * Returns `v, np`, where `v` is a vector read from the given string `s` and `np` is the next position in the string after the vector. * Returns `nil` on failure. - * `s`: Has to begin with a substring of the form `"(x, y, z)"`. Additional - spaces, leaving away commas and adding an additional comma to the end - is allowed. + * `s`: Has to begin with a substring of the form `"(x, y, z)"` (for 3D) or `"(x, y)"` (for 2D). + Additional spaces, omitting commas and adding an additional comma to the end is allowed. * `init`: If given starts looking for the vector at this string index. * `vector.to_string(v)`: - * Returns a string of the form `"(x, y, z)"`. - * `tostring(v)` does the same. + * Returns a human-readable string of the form `"(x, y, z)"` (for 3D) or `"(x, y)"` (for 2D). + * `tostring(v)` does the same. + * Note: This function loses precision. For exact precision, use `core.serialize()` instead. + * Note: Precision may increase in future versions. * `vector.direction(p1, p2)`: * Returns a vector of length 1 with direction `p1` to `p2`. - * If `p1` and `p2` are identical, returns `(0, 0, 0)`. + * If `p1` and `p2` are identical, returns a zero vector. * `vector.distance(p1, p2)`: * Returns zero or a positive number, the distance between `p1` and `p2`. * `vector.length(v)`: * Returns zero or a positive number, the length of vector `v`. * `vector.normalize(v)`: * Returns a vector of length 1 with direction of vector `v`. - * If `v` has zero length, returns `(0, 0, 0)`. + * If `v` has zero length, returns a zero vector. * `vector.floor(v)`: * Returns a vector, each dimension rounded down. * `vector.ceil(v)`: @@ -4076,41 +4066,28 @@ vectors are written like this: `(x, y, z)`: * At a multiple of 0.5, rounds away from zero. * `vector.sign(v, tolerance)`: * Returns a vector where `math.sign` was called for each component. - * See [Helper functions](#helper-functions) for details. + * See [Helper functions](#helper-functions) for details on `math.sign`. * `vector.abs(v)`: * Returns a vector with absolute values for each component. * `vector.apply(v, func, ...)`: - * Returns a vector where the function `func` has been applied to each - component. + * Returns a vector where the function `func` has been applied to each component. * `...` are optional arguments passed to `func`. * `vector.combine(v, w, func)`: * Returns a vector where the function `func` has combined both components of `v` and `w` - for each component + for each component. * `vector.equals(v1, v2)`: * Returns a boolean, `true` if the vectors are identical. -* `vector.sort(v1, v2)`: - * Returns in order minp, maxp vectors of the cuboid defined by `v1`, `v2`. -* `vector.angle(v1, v2)`: - * Returns the angle between `v1` and `v2` in radians. * `vector.dot(v1, v2)`: * Returns the dot product of `v1` and `v2`. -* `vector.cross(v1, v2)`: - * Returns the cross product of `v1` and `v2`. -* `vector.offset(v, x, y, z)`: - * Returns the sum of the vectors `v` and `(x, y, z)`. * `vector.check(v)`: - * Returns a boolean value indicating whether `v` is a real vector, eg. created - by a `vector.*` function. - * Returns `false` for anything else, including tables like `{x=3,y=1,z=4}`. + * Returns a boolean value indicating whether `v` is a real vector, e.g. created + by a `vector.*` or `vector2.*` function. + * Returns `false` for anything else, including tables like `{x=3, y=1, z=4}` or `{x=3, y=1}`. * `vector.in_area(pos, min, max)`: * Returns a boolean value indicating if `pos` is inside area formed by `min` and `max`. * `min` and `max` are inclusive. * If `min` is bigger than `max` on some axis, function always returns false. - * You can use `vector.sort` if you have two vectors and don't know which are the minimum and the maximum. -* `vector.random_in_area(min, max)`: - * Returns a random integer position in area formed by `min` and `max` - * `min` and `max` are inclusive. - * You can use `vector.sort` if you have two vectors and don't know which are the minimum and the maximum. + * You can use `vector.sort` (or `vector2.sort` for 2D) if you have two vectors and don't know which are the minimum and the maximum. For the following functions `x` can be either a vector or a number: @@ -4124,30 +4101,94 @@ For the following functions `x` can be either a vector or a number: * If `x` is a number: Subtracts `x` from each component of `v`. * `vector.multiply(v, s)`: * Returns a scaled vector. - * Deprecated: If `s` is a vector: Returns the Schur product. + * For `vector` only, deprecated behavior: If `s` is a vector, returns the Schur product. * `vector.divide(v, s)`: * Returns a scaled vector. - * Deprecated: If `s` is a vector: Returns the Schur quotient. + * For `vector` only, deprecated behavior: If `s` is a vector, returns the Schur quotient. -Operators + +Spatial Vectors +=============== + +Luanti stores 3-dimensional spatial vectors in Lua as tables of 3 coordinates, +and has a class to represent them (`vector.*`), which this chapter is about. +For details on what a spatial vectors is, please refer to Wikipedia: +https://en.wikipedia.org/wiki/Euclidean_vector. + +Spatial vectors are used for various things, including, but not limited to: + +* any 3D spatial vector (x/y/z-directions) +* Euler angles (pitch/yaw/roll in radians) (Spatial vectors have no real semantic + meaning here. Therefore, most vector operations make no sense in this use case.) + +The API documentation may refer to spatial vectors, as produced by `vector.new`, +by any of the following notations: + +* `(x, y, z)` (Used rarely, and only if it's clear that it's a vector.) +* `vector.new(x, y, z)` +* `{x=num, y=num, z=num}` (Even here you are still supposed to use `vector.new`.) + +Compatibility notes +------------------- + +Vectors used to be defined as tables of the form `{x = num, y = num, z = num}`. +Since version 5.5.0, vectors additionally have a metatable to enable easier use. +Note: Those old-style vectors can still be found in old mod code. Hence, mod and +engine APIs still need to be able to cope with them in many places. + +Manually constructed tables are deprecated and highly discouraged. This interface +should be used to ensure seamless compatibility between mods and the Luanti API. +This is especially important to callback function parameters and functions overwritten +by mods. +Also, though not likely, the internal implementation of a vector might change in +the future. +In your own code, or if you define your own API, you can, of course, still use +other representations of vectors. + +Vectors provided by API functions will provide an instance of this class if not +stated otherwise. Mods should adapt this for convenience reasons. + +Special properties of the class +------------------------------- + +For special properties common to all vector types (indexing, method syntax, operators, etc.), +see [Common to all vector types](#common-to-all-vector-types). + +Functions --------- -Operators can be used if all of the involved vectors have metatables: +For common functions available to both `vector` and `vector2`, +see [Common functions](#common-functions). -* `v1 == v2`: - * Returns whether `v1` and `v2` are identical. -* `-v`: - * Returns the additive inverse of v. -* `v1 + v2`: - * Returns the sum of both vectors. - * Note: `+` cannot be used together with scalars. -* `v1 - v2`: - * Returns the difference of `v1` subtracted by `v2`. - * Note: `-` cannot be used together with scalars. -* `v * s` or `s * v`: - * Returns `v` scaled by `s`. -* `v / s`: - * Returns `v` scaled by `1 / s`. +The following functions are specific to `vector` (3D vectors). + +For the following functions, +`v`, `v1`, `v2` are vectors, +`p1`, `p2` are position vectors, +`s` is a scalar (a number), +vectors are written like this: `(x, y, z)`: + +* `vector.new([a[, b, c]])`: + * Returns a new vector `(a, b, c)`. + * Deprecated: `vector.new()` does the same as `vector.zero()` and + `vector.new(v)` does the same as `vector.copy(v)` +* `vector.sort(v1, v2)`: + * Returns in order minp, maxp vectors of the cuboid defined by `v1`, `v2`. +* `vector.angle(v1, v2)`: + * Returns the angle between `v1` and `v2` in radians. +* `vector.cross(v1, v2)`: + * Returns the cross product of `v1` and `v2`. +* `vector.offset(v, x, y, z)`: + * Returns the sum of the vectors `v` and `(x, y, z)`. +* `vector.random_in_area(min, max)`: + * Returns a random integer position in area formed by `min` and `max` + * `min` and `max` are inclusive. + * You can use `vector.sort` if you have two vectors and don't know which are the minimum and the maximum. + +Operators +--------- + +For vector operators (`+`, `-`, `*`, `/`, `==`, unary `-`), see [Common to all vector types](#common-to-all-vector-types). Rotation-related functions -------------------------- @@ -4186,6 +4227,77 @@ For example: +2D Vectors +========== + +Luanti stores 2-dimensional vectors in Lua as tables of 2 coordinates, +and has a class to represent them (`vector2.*`). + +The API provides `vector2.new` to create vectors: + +* `vector2.new(x, y)` +* `{x=num, y=num}` (Even here you are still supposed to use `vector2.new`.) + +Compatibility notes +------------------- + +Vectors should be created using `vector2.new(x, y)` to ensure they have the +proper metatable. This enables: + +* Method call syntax (e.g., `v:length()` instead of `vector2.length(v)`) +* Operator overloading (e.g., `v1 + v2` instead of `vector2.add(v1, v2)`) +* Type checking with `vector2.check()` + +Special properties of the class +------------------------------- + +For special properties common to all vector types (indexing, method syntax, operators, etc.), +see [Common to all vector types](#common-to-all-vector-types). + +Functions +--------- + +For common functions available to both `vector` and `vector2`, +see [Common functions](#common-functions). + +The following functions are specific to `vector2` (2D vectors). + +For the following functions, +`v`, `v1`, `v2` are vectors, +`p1`, `p2` are position vectors, +`s` is a scalar (a number), +vectors are written like this: `(x, y)`: + +* `vector2.new(x, y)`: + * Returns a new vector `(x, y)`. +* `vector2.from_angle(angle)`: + * Returns a new unit vector from an angle. + * `angle` is the angle in radians from the positive x-axis (counterclockwise). + * Example: `vector2.from_angle(math.pi / 2)` returns a vector pointing up `(0, 1)`. +* `vector2.to_angle(v)`: + * Returns the angle of the vector in radians. + * `angle` is the angle from the positive x-axis (counterclockwise), in the range `(-pi, pi]`. + * The edge case of `(0, 0)` returns `0`. + * Example: `vector2.to_angle(vector2.new(0, 1))` returns `math.pi / 2`. +* `vector2.sort(v1, v2)`: + * Returns in order minp, maxp vectors of the rectangle defined by `v1`, `v2`. +* `vector2.angle(v1, v2)`: + * Returns the angle between `v1` and `v2` in radians. + * This is always a positive value (unsigned angle). +* `vector2.rotate(v, angle)`: + * Returns a new vector rotated counterclockwise by `angle` radians around the origin. + * The length of the vector is preserved. + * Example: `vector2.rotate(vector2.new(1, 0), math.pi / 2)` returns `(0, 1)`. +* `vector2.offset(v, x, y)`: + * Returns the sum of the vectors `v` and `(x, y)`. + +Operators +--------- + +For vector operators (`+`, `-`, `*`, `/`, `==`, unary `-`), see [Common to all vector types](#common-to-all-vector-types). + + + Helper functions ================ diff --git a/games/devtest/.luacheckrc b/games/devtest/.luacheckrc index 2ef36d2095108..b7d3a1d821a50 100644 --- a/games/devtest/.luacheckrc +++ b/games/devtest/.luacheckrc @@ -22,6 +22,7 @@ read_globals = { "dump", "dump2", "fgettext", "fgettext_ne", "vector", + "vector2", "VoxelArea", "VoxelManip", "profiler", diff --git a/games/devtest/mods/unittests/init.lua b/games/devtest/mods/unittests/init.lua index 22057f26a0f8c..0af384c414541 100644 --- a/games/devtest/mods/unittests/init.lua +++ b/games/devtest/mods/unittests/init.lua @@ -201,6 +201,7 @@ dofile(modpath .. "/inventory.lua") dofile(modpath .. "/load_time.lua") dofile(modpath .. "/on_shutdown.lua") dofile(modpath .. "/color.lua") +dofile(modpath .. "/vector2.lua") -------------- diff --git a/games/devtest/mods/unittests/vector2.lua b/games/devtest/mods/unittests/vector2.lua new file mode 100644 index 0000000000000..8c2141c8fcbd3 --- /dev/null +++ b/games/devtest/mods/unittests/vector2.lua @@ -0,0 +1,42 @@ +-- +-- Vector2 engine API push/read test +-- +-- This test verifies that the engine correctly pushes and reads 2D vectors +-- to and from Lua. It uses the spritediv property on player object properties, +-- which is a 2D vector in the engine (v2s16). +-- The test ensures that metatables are correctly set by vector2.check(). +-- +local function test_vector2_push_read(player) + -- Get original properties to restore later + local old_props = player:get_properties() + + -- Set a vector2 value via engine API (spritediv is a v2s16 in the engine) + local test_vector = vector2.new(5, 8) + player:set_properties({spritediv = test_vector}) + + -- Read back the value from engine + local props = player:get_properties() + local retrieved_vector = props.spritediv + + -- Verify the engine correctly pushed a vector2 with proper metatable + assert(vector2.check(retrieved_vector), "Retrieved spritediv is not a valid vector2") + + -- Verify the values are correct + assert(retrieved_vector.x == 5, "spritediv.x should be 5") + assert(retrieved_vector.y == 8, "spritediv.y should be 8") + + -- Test with a table (should be converted by engine) + player:set_properties({spritediv = {x = 3, y = 7}}) + props = player:get_properties() + retrieved_vector = props.spritediv + + -- Verify the engine converted the table to a proper vector2 + assert(vector2.check(retrieved_vector), "Retrieved spritediv from table is not a valid vector2") + assert(retrieved_vector.x == 3, "spritediv.x should be 3") + assert(retrieved_vector.y == 7, "spritediv.y should be 7") + + -- Restore original properties + player:set_properties({spritediv = old_props.spritediv}) +end + +unittests.register("test_vector2_push_read", test_vector2_push_read, {player=true}) diff --git a/src/script/common/c_converter.cpp b/src/script/common/c_converter.cpp index 1a2a28d66ae26..5e19c3113e708 100644 --- a/src/script/common/c_converter.cpp +++ b/src/script/common/c_converter.cpp @@ -69,6 +69,18 @@ static void read_v3_aux(lua_State *L, int index) lua_call(L, 1, 3); } +/** + * A helper which calls CUSTOM_RIDX_READ_VECTOR2 with the argument at the given index + */ +static void read_v2_aux(lua_State *L, int index) +{ + CHECK_POS_TAB(index); + lua_pushvalue(L, index); + lua_rawgeti(L, LUA_REGISTRYINDEX, CUSTOM_RIDX_READ_VECTOR2); + lua_insert(L, -2); + lua_call(L, 1, 2); +} + // Retrieve an integer vector where all components are optional template static bool getv3intfield(lua_State *L, int index, @@ -96,11 +108,10 @@ void push_v3f(lua_State *L, v3f p) void push_v2f(lua_State *L, v2f p) { - lua_createtable(L, 0, 2); + lua_rawgeti(L, LUA_REGISTRYINDEX, CUSTOM_RIDX_PUSH_VECTOR2); lua_pushnumber(L, p.X); - lua_setfield(L, -2, "x"); lua_pushnumber(L, p.Y); - lua_setfield(L, -2, "y"); + lua_call(L, 2, 1); } v2s16 read_v2s16(lua_State *L, int index) @@ -115,20 +126,18 @@ void push_v2s16(lua_State *L, v2s16 p) void push_v2s32(lua_State *L, v2s32 p) { - lua_createtable(L, 0, 2); + lua_rawgeti(L, LUA_REGISTRYINDEX, CUSTOM_RIDX_PUSH_VECTOR2); lua_pushinteger(L, p.X); - lua_setfield(L, -2, "x"); lua_pushinteger(L, p.Y); - lua_setfield(L, -2, "y"); + lua_call(L, 2, 1); } void push_v2u32(lua_State *L, v2u32 p) { - lua_createtable(L, 0, 2); + lua_rawgeti(L, LUA_REGISTRYINDEX, CUSTOM_RIDX_PUSH_VECTOR2); lua_pushinteger(L, p.X); - lua_setfield(L, -2, "x"); lua_pushinteger(L, p.Y); - lua_setfield(L, -2, "y"); + lua_call(L, 2, 1); } v2s32 read_v2s32(lua_State *L, int index) @@ -138,34 +147,26 @@ v2s32 read_v2s32(lua_State *L, int index) v2f read_v2f(lua_State *L, int index) { - v2f p; - CHECK_POS_TAB(index); - lua_getfield(L, index, "x"); - CHECK_POS_COORD2(-1, "x"); - p.X = lua_tonumber(L, -1); - lua_pop(L, 1); - lua_getfield(L, index, "y"); + read_v2_aux(L, index); + CHECK_POS_COORD2(-2, "x"); CHECK_POS_COORD2(-1, "y"); - p.Y = lua_tonumber(L, -1); - lua_pop(L, 1); - return p; + float x = lua_tonumber(L, -2); + float y = lua_tonumber(L, -1); + lua_pop(L, 2); + return v2f(x, y); } v2f check_v2f(lua_State *L, int index) { - v2f p; - CHECK_POS_TAB(index); - lua_getfield(L, index, "x"); - CHECK_POS_COORD(-1, "x"); - p.X = lua_tonumber(L, -1); - CHECK_FLOAT(p.X, "x"); - lua_pop(L, 1); - lua_getfield(L, index, "y"); + read_v2_aux(L, index); + CHECK_POS_COORD(-2, "x"); CHECK_POS_COORD(-1, "y"); - p.Y = lua_tonumber(L, -1); - CHECK_FLOAT(p.Y, "y"); - lua_pop(L, 1); - return p; + float x = lua_tonumber(L, -2); + float y = lua_tonumber(L, -1); + lua_pop(L, 2); + CHECK_FLOAT(x, "x"); + CHECK_FLOAT(y, "y"); + return v2f(x, y); } v3f read_v3f(lua_State *L, int index) diff --git a/src/script/common/c_internal.h b/src/script/common/c_internal.h index 5414efae86236..17869d98467c4 100644 --- a/src/script/common/c_internal.h +++ b/src/script/common/c_internal.h @@ -49,6 +49,8 @@ enum { // trace them and optimize tables/string better than from the C API. CUSTOM_RIDX_READ_VECTOR, CUSTOM_RIDX_PUSH_VECTOR, + CUSTOM_RIDX_READ_VECTOR2, + CUSTOM_RIDX_PUSH_VECTOR2, CUSTOM_RIDX_READ_NODE, CUSTOM_RIDX_PUSH_NODE, CUSTOM_RIDX_PUSH_MOVERESULT1, diff --git a/src/script/cpp_api/s_base.cpp b/src/script/cpp_api/s_base.cpp index 95c34bdf063c6..1a3d8ae8ef09e 100644 --- a/src/script/cpp_api/s_base.cpp +++ b/src/script/cpp_api/s_base.cpp @@ -127,6 +127,16 @@ ScriptApiBase::ScriptApiBase(ScriptingType type): return 0; }); lua_setfield(m_luastack, -2, "set_push_vector"); + lua_pushcfunction(m_luastack, [](lua_State *L) -> int { + lua_rawseti(L, LUA_REGISTRYINDEX, CUSTOM_RIDX_READ_VECTOR2); + return 0; + }); + lua_setfield(m_luastack, -2, "set_read_vector2"); + lua_pushcfunction(m_luastack, [](lua_State *L) -> int { + lua_rawseti(L, LUA_REGISTRYINDEX, CUSTOM_RIDX_PUSH_VECTOR2); + return 0; + }); + lua_setfield(m_luastack, -2, "set_push_vector2"); lua_pushcfunction(m_luastack, [](lua_State *L) -> int { lua_rawseti(L, LUA_REGISTRYINDEX, CUSTOM_RIDX_READ_NODE); return 0; @@ -209,6 +219,8 @@ void ScriptApiBase::checkSetByBuiltin() CHECK(CUSTOM_RIDX_READ_VECTOR, "read_vector"); CHECK(CUSTOM_RIDX_PUSH_VECTOR, "push_vector"); + CHECK(CUSTOM_RIDX_READ_VECTOR2, "read_vector2"); + CHECK(CUSTOM_RIDX_PUSH_VECTOR2, "push_vector2"); if (getType() == ScriptingType::Server || (getType() == ScriptingType::Async && m_gamedef) || diff --git a/src/unittest/test_scriptapi.cpp b/src/unittest/test_scriptapi.cpp index 676c5f7fa8913..7ca9b2dcbbbc2 100644 --- a/src/unittest/test_scriptapi.cpp +++ b/src/unittest/test_scriptapi.cpp @@ -98,6 +98,12 @@ void TestScriptApi::testVectorMetatable(MyScriptApi *script) return lua_toboolean(L, -1); }; + const auto &call_vector2_check = [&] () -> bool { + lua_setglobal(L, "tmp"); + run(L, "return vector2.check(tmp)", 1); + return lua_toboolean(L, -1); + }; + push_v3s16(L, {1, 2, 3}); UASSERT(call_vector_check()); @@ -110,6 +116,13 @@ void TestScriptApi::testVectorMetatable(MyScriptApi *script) push_v2f(L, {0, 0}); UASSERT(!call_vector_check()); + + // but they must have the vector2 metatable + push_v2s32(L, {0, 0}); + UASSERT(call_vector2_check()); + + push_v2f(L, {0, 0}); + UASSERT(call_vector2_check()); } void TestScriptApi::testVectorRead(MyScriptApi *script)