-- -- -- ### Author: **nielsvaes/coconutcockpit** -- -- === -- @module Shapes.POLYGON --- POLYGON class. -- @type POLYGON -- @field #string ClassName Name of the class. -- @field #table Points List of 3D points defining the shape, this will be assigned automatically if you're passing in a drawing from the Mission Editor -- @field #table Coords List of COORDINATE defining the path, this will be assigned automatically if you're passing in a drawing from the Mission Editor -- @field #table MarkIDs List any MARKIDs this class use, this will be assigned automatically if you're passing in a drawing from the Mission Editor -- @field #table Triangles List of TRIANGLEs that make up the shape of the POLYGON after being triangulated -- @extends Core.Base#BASE --- *Polygons are fashionable at the moment* -- Trip Hawkins -- -- === -- -- # POLYGON -- POLYGONs can be fetched from the drawings in the Mission Editor if the drawing is: -- * A closed shape made with line segments -- * A closed shape made with a freehand line -- * A freehand drawn polygon -- * A rect -- Use the POLYGON:FindOnMap() of POLYGON:Find() functions for this. You can also create a non existing polygon in memory using the POLYGON:New() function. Pass in a -- any number of Vec2s into this function to define the shape of the polygon you want. -- You can draw very intricate and complex polygons in the Mission Editor to avoid (or include) map objects. You can then generate random points within this complex -- shape for spawning groups or checking positions. -- When a POLYGON is made, it's automatically triangulated. The resulting triangles are stored in POLYGON.Triangles. This also immeadiately saves the surface area -- of the POLYGON. Because the POLYGON is triangulated, it's possible to generate random points within this POLYGON without having to use a trial and error method to see if -- the point is contained within the shape. -- Using POLYGON:GetRandomVec2() will result in a truly, non-biased, random Vec2 within the shape. You'll want to use this function most. There's also POLYGON:GetRandomNonWeightedVec2 -- which ignores the size of the triangles in the polygon to pick a random points. This will result in more points clumping together in parts of the polygon where the triangles are -- the smallest. -- @field #POLYGON POLYGON = { ClassName = "POLYGON", Points = {}, Coords = {}, Triangles = {}, SurfaceArea = 0, TriangleMarkIDs = {}, OutlineMarkIDs = {}, Angle = nil, -- for arrows Heading = nil -- for arrows } --- Finds a polygon on the map by its name. The polygon must be added in the mission editor. -- @param #string shape_name Name of the polygon to find -- @return #POLYGON The found polygon, or nil if not found function POLYGON:FindOnMap(shape_name) local self = BASE:Inherit(self, SHAPE_BASE:FindOnMap(shape_name)) for _, layer in pairs(env.mission.drawings.layers) do for _, object in pairs(layer["objects"]) do if object["name"] == shape_name then if (object["primitiveType"] == "Line" and object["closed"] == true) or (object["polygonMode"] == "free") then for _, point in UTILS.spairs(object["points"]) do local p = {x = object["mapX"] + point["x"], y = object["mapY"] + point["y"] } local coord = COORDINATE:NewFromVec2(p) self.Points[#self.Points + 1] = p self.Coords[#self.Coords + 1] = coord end elseif object["polygonMode"] == "rect" then local angle = object["angle"] local half_width = object["width"] / 2 local half_height = object["height"] / 2 local p1 = UTILS.RotatePointAroundPivot({ x = self.CenterVec2.x - half_height, y = self.CenterVec2.y + half_width }, self.CenterVec2, angle) local p2 = UTILS.RotatePointAroundPivot({ x = self.CenterVec2.x + half_height, y = self.CenterVec2.y + half_width }, self.CenterVec2, angle) local p3 = UTILS.RotatePointAroundPivot({ x = self.CenterVec2.x + half_height, y = self.CenterVec2.y - half_width }, self.CenterVec2, angle) local p4 = UTILS.RotatePointAroundPivot({ x = self.CenterVec2.x - half_height, y = self.CenterVec2.y - half_width }, self.CenterVec2, angle) self.Points = {p1, p2, p3, p4} for _, point in pairs(self.Points) do self.Coords[#self.Coords + 1] = COORDINATE:NewFromVec2(point) end elseif object["polygonMode"] == "arrow" then for _, point in UTILS.spairs(object["points"]) do local p = {x = object["mapX"] + point["x"], y = object["mapY"] + point["y"] } local coord = COORDINATE:NewFromVec2(p) self.Points[#self.Points + 1] = p self.Coords[#self.Coords + 1] = coord end self.Angle = object["angle"] self.Heading = UTILS.ClampAngle(self.Angle + 90) end end end end if #self.Points == 0 then return nil end self.CenterVec2 = self:GetCentroid() self.Triangles = self:Triangulate() self.SurfaceArea = self:__CalculateSurfaceArea() self.TriangleMarkIDs = {} self.OutlineMarkIDs = {} return self end --- Creates a polygon from a zone. The zone must be defined in the mission. -- @param #string zone_name Name of the zone -- @return #POLYGON The polygon created from the zone, or nil if the zone is not found function POLYGON:FromZone(zone_name) for _, zone in pairs(env.mission.triggers.zones) do if zone["name"] == zone_name then return POLYGON:New(unpack(zone["verticies"] or {})) end end end --- Finds a polygon by its name in the database. -- @param #string shape_name Name of the polygon to find -- @return #POLYGON The found polygon, or nil if not found function POLYGON:Find(shape_name) return _DATABASE:FindShape(shape_name) end --- Creates a new polygon from a list of points. Each point is a table with 'x' and 'y' fields. -- @param #table ... Points of the polygon -- @return #POLYGON The new polygon function POLYGON:New(...) local self = BASE:Inherit(self, SHAPE_BASE:New()) self.Points = {...} self.Coords = {} for _, point in UTILS.spairs(self.Points) do table.insert(self.Coords, COORDINATE:NewFromVec2(point)) end self.Triangles = self:Triangulate() self.SurfaceArea = self:__CalculateSurfaceArea() return self end --- Calculates the centroid of the polygon. The centroid is the average of the 'x' and 'y' coordinates of the points. -- @return #table The centroid of the polygon function POLYGON:GetCentroid() local function sum(t) local total = 0 for _, value in pairs(t) do total = total + value end return total end local x_values = {} local y_values = {} local length = table.length(self.Points) for _, point in pairs(self.Points) do table.insert(x_values, point.x) table.insert(y_values, point.y) end local x = sum(x_values) / length local y = sum(y_values) / length return { ["x"] = x, ["y"] = y } end --- Returns the coordinates of the polygon. Each coordinate is a COORDINATE object. -- @return #table The coordinates of the polygon function POLYGON:GetCoordinates() return self.Coords end --- Returns the start coordinate of the polygon. The start coordinate is the first point of the polygon. -- @return #COORDINATE The start coordinate of the polygon function POLYGON:GetStartCoordinate() return self.Coords[1] end --- Returns the end coordinate of the polygon. The end coordinate is the last point of the polygon. -- @return #COORDINATE The end coordinate of the polygon function POLYGON:GetEndCoordinate() return self.Coords[#self.Coords] end --- Returns the start point of the polygon. The start point is the first point of the polygon. -- @return #table The start point of the polygon function POLYGON:GetStartPoint() return self.Points[1] end --- Returns the end point of the polygon. The end point is the last point of the polygon. -- @return #table The end point of the polygon function POLYGON:GetEndPoint() return self.Points[#self.Points] end --- Returns the points of the polygon. Each point is a table with 'x' and 'y' fields. -- @return #table The points of the polygon function POLYGON:GetPoints() return self.Points end --- Calculates the surface area of the polygon. The surface area is the sum of the areas of the triangles that make up the polygon. -- @return #number The surface area of the polygon function POLYGON:GetSurfaceArea() return self.SurfaceArea end --- Calculates the bounding box of the polygon. The bounding box is the smallest rectangle that contains the polygon. -- @return #table The bounding box of the polygon function POLYGON:GetBoundingBox() local min_x, min_y, max_x, max_y = self.Points[1].x, self.Points[1].y, self.Points[1].x, self.Points[1].y for i = 2, #self.Points do local x, y = self.Points[i].x, self.Points[i].y if x < min_x then min_x = x end if y < min_y then min_y = y end if x > max_x then max_x = x end if y > max_y then max_y = y end end return { {x=min_x, y=min_x}, {x=max_x, y=min_y}, {x=max_x, y=max_y}, {x=min_x, y=max_y} } end --- Triangulates the polygon. The polygon is divided into triangles. -- @param #table points (optional) Points of the polygon or other points if you're just using the POLYGON class without an object of it -- @return #table The triangles of the polygon function POLYGON:Triangulate(points) points = points or self.Points local triangles = {} local function get_orientation(shape_points) local sum = 0 for i = 1, #shape_points do local j = i % #shape_points + 1 sum = sum + (shape_points[j].x - shape_points[i].x) * (shape_points[j].y + shape_points[i].y) end return sum >= 0 and "clockwise" or "counter-clockwise" -- sum >= 0, return "clockwise", else return "counter-clockwise" end local function ensure_clockwise(shape_points) local orientation = get_orientation(shape_points) if orientation == "counter-clockwise" then -- Reverse the order of shape_points so they're clockwise local reversed = {} for i = #shape_points, 1, -1 do table.insert(reversed, shape_points[i]) end return reversed end return shape_points end local function is_clockwise(p1, p2, p3) local cross_product = (p2.x - p1.x) * (p3.y - p1.y) - (p2.y - p1.y) * (p3.x - p1.x) return cross_product < 0 end local function divide_recursively(shape_points) if #shape_points == 3 then table.insert(triangles, TRIANGLE:New(shape_points[1], shape_points[2], shape_points[3])) elseif #shape_points > 3 then -- find an ear -> a triangle with no other points inside it for i, p1 in ipairs(shape_points) do local p2 = shape_points[(i % #shape_points) + 1] local p3 = shape_points[(i + 1) % #shape_points + 1] local triangle = TRIANGLE:New(p1, p2, p3) local is_ear = true if not is_clockwise(p1, p2, p3) then is_ear = false else for _, point in ipairs(shape_points) do if point ~= p1 and point ~= p2 and point ~= p3 and triangle:ContainsPoint(point) then is_ear = false break end end end if is_ear then -- Check if any point in the original polygon is inside the ear triangle local is_valid_triangle = true for _, point in ipairs(points) do if point ~= p1 and point ~= p2 and point ~= p3 and triangle:ContainsPoint(point) then is_valid_triangle = false break end end if is_valid_triangle then table.insert(triangles, triangle) local remaining_points = {} for j, point in ipairs(shape_points) do if point ~= p2 then table.insert(remaining_points, point) end end divide_recursively(remaining_points) break end end end end end points = ensure_clockwise(points) divide_recursively(points) return triangles end function POLYGON:CovarianceMatrix() local cx, cy = self:GetCentroid() local covXX, covYY, covXY = 0, 0, 0 for _, p in ipairs(self.points) do covXX = covXX + (p.x - cx)^2 covYY = covYY + (p.y - cy)^2 covXY = covXY + (p.x - cx) * (p.y - cy) end covXX = covXX / (#self.points - 1) covYY = covYY / (#self.points - 1) covXY = covXY / (#self.points - 1) return covXX, covYY, covXY end function POLYGON:Direction() local covXX, covYY, covXY = self:CovarianceMatrix() -- Simplified calculation for the largest eigenvector's direction local theta = 0.5 * math.atan2(2 * covXY, covXX - covYY) return math.cos(theta), math.sin(theta) end --- Returns a random Vec2 within the polygon. The Vec2 is weighted by the areas of the triangles that make up the polygon. -- @return #table The random Vec2 function POLYGON:GetRandomVec2() local weights = {} for _, triangle in pairs(self.Triangles) do weights[triangle] = triangle.SurfaceArea / self.SurfaceArea end local random_weight = math.random() local accumulated_weight = 0 for triangle, weight in pairs(weights) do accumulated_weight = accumulated_weight + weight if accumulated_weight >= random_weight then return triangle:GetRandomVec2() end end end --- Returns a random non-weighted Vec2 within the polygon. The Vec2 is chosen from one of the triangles that make up the polygon. -- @return #table The random non-weighted Vec2 function POLYGON:GetRandomNonWeightedVec2() return self.Triangles[math.random(1, #self.Triangles)]:GetRandomVec2() end --- Checks if a point is contained within the polygon. The point is a table with 'x' and 'y' fields. -- @param #table point The point to check -- @param #table points (optional) Points of the polygon or other points if you're just using the POLYGON class without an object of it -- @return #bool True if the point is contained, false otherwise function POLYGON:ContainsPoint(point, polygon_points) local x = point.x local y = point.y polygon_points = polygon_points or self.Points local counter = 0 local num_points = #polygon_points for current_index = 1, num_points do local next_index = (current_index % num_points) + 1 local current_x, current_y = polygon_points[current_index].x, polygon_points[current_index].y local next_x, next_y = polygon_points[next_index].x, polygon_points[next_index].y if ((current_y > y) ~= (next_y > y)) and (x < (next_x - current_x) * (y - current_y) / (next_y - current_y) + current_x) then counter = counter + 1 end end return counter % 2 == 1 end --- Draws the polygon on the map. The polygon can be drawn with or without inner triangles. This is just for debugging -- @param #bool include_inner_triangles Whether to include inner triangles in the drawing function POLYGON:Draw(include_inner_triangles) include_inner_triangles = include_inner_triangles or false for i=1, #self.Coords do local c1 = self.Coords[i] local c2 = self.Coords[i % #self.Coords + 1] table.add(self.OutlineMarkIDs, c1:LineToAll(c2)) end if include_inner_triangles then for _, triangle in ipairs(self.Triangles) do triangle:Draw() end end end --- Removes the drawing of the polygon from the map. function POLYGON:RemoveDraw() for _, triangle in pairs(self.Triangles) do triangle:RemoveDraw() end for _, mark_id in pairs(self.OutlineMarkIDs) do UTILS.RemoveMark(mark_id) end end --- Calculates the surface area of the polygon. The surface area is the sum of the areas of the triangles that make up the polygon. -- @return #number The surface area of the polygon function POLYGON:__CalculateSurfaceArea() local area = 0 for _, triangle in pairs(self.Triangles) do area = area + triangle.SurfaceArea end return area end