Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
203 changes: 203 additions & 0 deletions lib/delaunay/src/2d/Delaunay2dTesting/delaunay2d.spec.luau
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
--!strict

return function(t: tiniest)
local Delaunay2d = require(script.Parent.Parent.Delaunay2d)

local describe = t.describe
local expect = t.expect
local test = t.test

local function sum(values: { number }): number
if #values == 0 then
return 0
end

local s = values[1]
local err = 0
for i = 2, #values do
local k = values[i]
local m = s + k
if math.abs(s) >= math.abs(k) then
err += s - m + k
else
err += k - m + s
end
s = m
end
return s + err
end

local function orient(p: Vector2, r: Vector2, q: Vector2): number
local l = (r.Y - p.Y) * (q.X - p.X)
local rr = (r.X - p.X) * (q.Y - p.Y)
if math.abs(l - rr) >= 3.3306690738754716e-16 * math.abs(l + rr) then
return l - rr
end
return 0
end

local function convex(r: Vector2, q: Vector2, p: Vector2): boolean
local o1 = orient(p, r, q)
if o1 ~= 0 then
return o1 >= 0
end
local o2 = orient(r, q, p)
if o2 ~= 0 then
return o2 >= 0
end
return orient(q, p, r) >= 0
end

local function validate(points: { Vector2 }, triangulation: Delaunay2d.Delaunay2dObject?)
local d = triangulation or Delaunay2d.new(points)
local triangles = d:GetTriangles()
local halfedges = d:GetHalfedges()
local hull = d:GetHull()

for i = 1, #halfedges do
local twin = halfedges[i]
expect(twin == -1 or halfedges[twin + 1] == (i - 1)).is(true)
end

if #hull >= 3 then
local hullAreas = {}
for i = 1, #hull do
local j = (i == 1) and #hull or (i - 1)
local p0 = points[hull[j] + 1]
local p1 = points[hull[i] + 1]
hullAreas[#hullAreas + 1] = (p1.X - p0.X) * (p1.Y + p0.Y)

local pA = points[hull[j] + 1]
local pB = points[hull[(j % #hull) + 1] + 1]
local pC = points[hull[((j + 2) % #hull) + 1] + 1]
expect(convex(pA, pB, pC)).is(true)
end

local triAreas = {}
for i = 1, #triangles, 3 do
local a = points[triangles[i] + 1]
local b = points[triangles[i + 1] + 1]
local c = points[triangles[i + 2] + 1]
triAreas[#triAreas + 1] = math.abs((b.Y - a.Y) * (c.X - b.X) - (b.X - a.X) * (c.Y - b.Y))
end

local hullArea = sum(hullAreas)
local trianglesArea = sum(triAreas)
if hullArea ~= 0 then
local err = math.abs((hullArea - trianglesArea) / hullArea)
expect(err <= 2 ^ -45).is(true)
end
end
end

describe("Delaunay2d", function()
test("returns empty triangulation for 0 points", function()
local d = Delaunay2d.new {}
expect(#d:GetTriangles()).is(0)
expect(#d:GetHull()).is(0)
end)

test("returns empty triangulation for 1 point", function()
local d = Delaunay2d.new { Vector2.new(5, 5) }
expect(#d:GetTriangles()).is(0)
expect(#d:GetHull()).is(1)
expect(d:GetHull()[1]).is(0)
end)

test("returns empty triangulation for 2 points", function()
local d = Delaunay2d.new { Vector2.new(1, 1), Vector2.new(2, 2) }
expect(#d:GetTriangles()).is(0)
expect(#d:GetHull()).is(2)
end)

test("returns empty triangulation for all-collinear input", function()
local d = Delaunay2d.new {
Vector2.new(0, 0),
Vector2.new(1, 0),
Vector2.new(3, 0),
Vector2.new(2, 0),
}
expect(#d:GetTriangles()).is(0)
expect(#d:GetHull()).is(4)
end)

test("produces valid triangulation on a basic point set", function()
local points = {
Vector2.new(0, 0),
Vector2.new(1, 0),
Vector2.new(2, 0.5),
Vector2.new(1.25, 1.25),
Vector2.new(0.5, 1.0),
Vector2.new(-0.3, 0.4),
}
validate(points)
end)

test("supports update after coordinate mutation", function()
local points = {
Vector2.new(0, 0),
Vector2.new(1, 0),
Vector2.new(0, 1),
Vector2.new(1, 1),
Vector2.new(0.4, 0.6),
}
local d = Delaunay2d.new(points)
validate(points, d)

local coords = d:GetCoords()
coords[1] = 2.5
coords[2] = 0.2
d:Update()

local updatedPoints = {
Vector2.new(2.5, 0.2),
points[2],
points[3],
points[4],
points[5],
}
validate(updatedPoints, d)
end)

test("triangulate static wrapper returns arrays", function()
local result = Delaunay2d.Triangulate {
Vector2.new(0, 0),
Vector2.new(2, 0),
Vector2.new(1, 1),
Vector2.new(0.25, 0.8),
}
expect(result.Triangles).is_a("table")
expect(result.Halfedges).is_a("table")
expect(result.Hull).is_a("table")
end)

test("triangulate from 3d projects and triangulates", function()
local result = Delaunay2d.TriangulateFrom3d({
Vector3.new(0, 0, 0),
Vector3.new(10, 0, 0),
Vector3.new(8, 0, 7),
Vector3.new(2, 0, 8),
Vector3.new(5, 0, 4),
}, Vector3.yAxis)
expect(#result.Triangles > 0).is(true)
expect(#result.Halfedges == #result.Triangles).is(true)
end)

test("edge helper returns valid point index pairs", function()
local points = {
Vector2.new(0, 0),
Vector2.new(3, 0),
Vector2.new(3, 2),
Vector2.new(0, 2),
Vector2.new(1.5, 1),
}
local d = Delaunay2d.new(points)
local edges = d:GetEdges()
expect(#edges > 0).is(true)
for _, edge in edges do
expect(edge[1] >= 0 and edge[1] < #points).is(true)
expect(edge[2] >= 0 and edge[2] < #points).is(true)
end
end)
end)
end
181 changes: 181 additions & 0 deletions lib/delaunay/src/2d/Delaunay2dTesting/delaunay2d.story.luau
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
--!strict
local RunService = game:GetService("RunService")

local Delaunay2d = require(script.Parent.Parent.Delaunay2d)
local DrawTriangle3d = require(script.Parent.Parent.Parent.DrawTriangle3d)

local DRAW_TRIANGLES = true
local DRAW_EDGES = true

local function asVec2(part: BasePart): Vector2
return Vector2.new(part.Position.X, part.Position.Z)
end

local function asVec3(v: Vector2): Vector3
return Vector3.new(v.X, 0, v.Y)
end

return function()
local pointCount = 8
local boundsMin = Vector3.new(-40, 0, -40)
local boundsMax = Vector3.new(40, 0, 40)

local pointsFolder = Instance.new("Folder")
pointsFolder.Name = "Delaunay2d_Vertices"
pointsFolder.Parent = workspace

local parts: { Part } = {}
local attachments: { Attachment } = {}
local lastPositions: { Vector3 } = table.create(pointCount) :: any

for i = 1, pointCount do
local part = Instance.new("Part")
part.Size = Vector3.one
part.Shape = Enum.PartType.Ball
part.Color = Color3.fromRGB(60, 190, 255)
part.Material = Enum.Material.Neon
part.Position = Vector3.new(math.random(boundsMin.X, boundsMax.X), 0, math.random(boundsMin.Z, boundsMax.Z))
part.Anchored = true
part.CanCollide = false
part.CastShadow = false
part.Parent = pointsFolder
lastPositions[i] = part.Position

local att = Instance.new("Attachment")
att.Position = Vector3.zero
att.Parent = part

parts[i] = part
attachments[i] = att
end

local triangulation = Delaunay2d.new()

local edgeFolder = Instance.new("Folder")
edgeFolder.Name = "Delaunay2d_Edges"
edgeFolder.Parent = workspace

local beamAnchor = Instance.new("Part")
beamAnchor.Name = "BeamAnchor"
beamAnchor.Size = Vector3.zero
beamAnchor.Transparency = 1
beamAnchor.CanCollide = false
beamAnchor.CanTouch = false
beamAnchor.CanQuery = false
beamAnchor.CastShadow = false
beamAnchor.Anchored = true
beamAnchor.Position = Vector3.zero
beamAnchor.Parent = edgeFolder

local beamCache: { Beam } = {}
local function getBeam(index: number): Beam
local existing = beamCache[index]
if existing then
return existing
end

local beam = Instance.new("Beam")
beam.Width0 = 0.08
beam.Width1 = 0.08
beam.Color = ColorSequence.new(Color3.fromRGB(255, 220, 120))
beam.Transparency = NumberSequence.new(0.15)
beam.LightEmission = 0.2
beam.FaceCamera = true
beam.Enabled = false
beam.Parent = beamAnchor
beamCache[index] = beam
return beam
end

local triangleFolder = Instance.new("Folder")
triangleFolder.Name = "Delaunay2d_Triangles"
triangleFolder.Parent = workspace

local triangleCache: { Model } = {}
local function createTriangleModel(points3d: { Vector3 }): Model
return DrawTriangle3d.create(
points3d,
{
Name = "Tri2d",
Parent = triangleFolder,
Anchored = true,
Thickness = 0.01,
Transparency = 0,
Color = Color3.fromRGB(math.random(255), math.random(255), math.random(255)),
Material = Enum.Material.Plastic,
} :: any
)
end

local function rebuild()
local points2d = table.create(#parts) :: { Vector2 }
for i, part in parts do
points2d[i] = asVec2(part)
end

triangulation:SetVertices(points2d)

if DRAW_TRIANGLES then
local triangles = triangulation:GetTriangles()
local triCount = #triangles // 3
local triIndex = 1
for i = 1, #triangles, 3 do
local a = points2d[triangles[i] + 1]
local b = points2d[triangles[i + 1] + 1]
local c = points2d[triangles[i + 2] + 1]
local points3d = { asVec3(a), asVec3(b), asVec3(c) }

local model = triangleCache[triIndex]
if model then
if model.Parent ~= triangleFolder then
model.Parent = triangleFolder
end
DrawTriangle3d.render(points3d, model)
else
triangleCache[triIndex] = createTriangleModel(points3d)
end
triIndex += 1
end

for i = triCount + 1, #triangleCache do
local model = triangleCache[i]
if model.Parent ~= nil then
model.Parent = nil
end
end
end

if DRAW_EDGES then
local edges = triangulation:GetEdges()
for i, edge in edges do
local beam = getBeam(i)
beam.Attachment0 = attachments[edge[1] + 1]
beam.Attachment1 = attachments[edge[2] + 1]
beam.Enabled = true
end

for i = #edges + 1, #beamCache do
beamCache[i].Enabled = false
end
end
end

rebuild()

local connection = RunService.Heartbeat:Connect(function()
for i, part in parts do
if part.Position ~= lastPositions[i] then
lastPositions[i] = part.Position
rebuild()
return
end
end
end)

return function()
connection:Disconnect()
pointsFolder:Destroy()
edgeFolder:Destroy()
triangleFolder:Destroy()
end
end
Loading
Loading