Add orx-triangulation
This commit is contained in:
committed by
Edwin Jakobs
parent
199e9635fa
commit
acdb038c98
69
orx-triangulation/README.md
Normal file
69
orx-triangulation/README.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# orx-triangulation
|
||||||
|
|
||||||
|
An extension for triangulating a set of points using the **Delaunay** triangulation method. From that triangulation we can also derive a **Voronoi** diagram.
|
||||||
|
|
||||||
|
The functionality comes from a Javascript port of the following libraries:
|
||||||
|
|
||||||
|
* [delaunator](https://github.com/ricardomatias/delaunator) (external)
|
||||||
|
* [d3-delaunay](https://github.com/d3/d3-delaunay) (the port is included in this package)
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Delaunay
|
||||||
|
|
||||||
|
The entry point is the `Delaunay` class.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
val points: List<Vector2>
|
||||||
|
val delaunay = Delaunay.from(points)
|
||||||
|
|
||||||
|
// or
|
||||||
|
val flatPoints: DoubleArray // (x0, y0, x1, x1, x2, y2)
|
||||||
|
val delaunay = Delaunay(flatPoints)
|
||||||
|
```
|
||||||
|
|
||||||
|
This is how you retrieve the triangulation results:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
val triangles: List<Triangle> = delaunay.triangles()
|
||||||
|
val halfedges: List<ShapeContour> = delaunay.halfedges()
|
||||||
|
val hull: ShapeContour = delaunay.hull()
|
||||||
|
|
||||||
|
// Updates the triangulation after the points have been modified in-place.
|
||||||
|
delaunay.update()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Voronoi
|
||||||
|
|
||||||
|
The bounds specifices where the Voronoi diagram will be clipped.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
val bounds: Rectangle
|
||||||
|
|
||||||
|
val delaunay = Delaunay.from(points)
|
||||||
|
val voronoi = delaunay.voronoi(bounds)
|
||||||
|
// or
|
||||||
|
val voronoi = Voronoi(Delaunay.from(points), bounds)
|
||||||
|
```
|
||||||
|
|
||||||
|
See [To Infinity and Back Again](https://observablehq.com/@mbostock/to-infinity-and-back-again) for an interactive explanation of Voronoi cell clipping.
|
||||||
|
|
||||||
|
This is how you retrieve th results:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
val cells: List<ShapeContour> = voronoi.cellsPolygons()
|
||||||
|
val cell: ShapeContour = voronoi.cellPolygon(int) // index
|
||||||
|
val circumcenters: List<Vector2> = voronoi.circumcenters()
|
||||||
|
|
||||||
|
// Returns true if the cell with the specified index i contains the specified vector
|
||||||
|
val contaisVector = voronoi.contains(int, Vector2)
|
||||||
|
|
||||||
|
// Updates the Voronoi diagram and underlying triangulation
|
||||||
|
// after the points have been modified in-place
|
||||||
|
voronoi.update()
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### Author
|
||||||
|
|
||||||
|
Ricardo Matias / [@ricardomatias](https://github.com/ricardomatias)
|
||||||
21
orx-triangulation/build.gradle
Normal file
21
orx-triangulation/build.gradle
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
sourceSets {
|
||||||
|
demo {
|
||||||
|
java {
|
||||||
|
srcDirs = ["src/demo/kotlin"]
|
||||||
|
compileClasspath += main.getCompileClasspath()
|
||||||
|
runtimeClasspath += main.getRuntimeClasspath()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation project(":orx-noise")
|
||||||
|
|
||||||
|
implementation("com.github.ricardomatias:delaunator:1.0.0")
|
||||||
|
|
||||||
|
demoImplementation("org.openrndr:openrndr-core:$openrndrVersion")
|
||||||
|
demoImplementation("org.openrndr:openrndr-extensions:$openrndrVersion")
|
||||||
|
demoRuntimeOnly("org.openrndr:openrndr-gl3:$openrndrVersion")
|
||||||
|
demoRuntimeOnly("org.openrndr:openrndr-gl3-natives-$openrndrOS:$openrndrVersion")
|
||||||
|
demoImplementation(sourceSets.getByName("main").output)
|
||||||
|
}
|
||||||
45
orx-triangulation/src/demo/kotlin/DemoDelaunay01.kt
Normal file
45
orx-triangulation/src/demo/kotlin/DemoDelaunay01.kt
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import org.openrndr.application
|
||||||
|
import org.openrndr.color.ColorRGBa
|
||||||
|
import org.openrndr.extensions.SingleScreenshot
|
||||||
|
import org.openrndr.extra.noise.poissonDiskSampling
|
||||||
|
import org.openrndr.extra.triangulation.Delaunay
|
||||||
|
import org.openrndr.math.Vector2
|
||||||
|
import org.openrndr.shape.Circle
|
||||||
|
import org.openrndr.shape.Rectangle
|
||||||
|
|
||||||
|
fun main() {
|
||||||
|
application {
|
||||||
|
configure {
|
||||||
|
width = 800
|
||||||
|
height = 800
|
||||||
|
title = "Delaunator"
|
||||||
|
}
|
||||||
|
program {
|
||||||
|
if (System.getProperty("takeScreenshot") == "true") {
|
||||||
|
extend(SingleScreenshot()) {
|
||||||
|
this.outputFile = System.getProperty("screenshotPath")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val circle = Circle(Vector2(400.0), 250.0)
|
||||||
|
|
||||||
|
val points = poissonDiskSampling(width * 1.0, height * 1.0, 30.0)
|
||||||
|
.filter { circle.contains(it) }
|
||||||
|
|
||||||
|
val delaunay = Delaunay.from(points + circle.contour.equidistantPositions(40))
|
||||||
|
val triangles = delaunay.triangles().map { it.contour }
|
||||||
|
|
||||||
|
extend {
|
||||||
|
drawer.clear(ColorRGBa.BLACK)
|
||||||
|
|
||||||
|
|
||||||
|
for ((i, triangle) in triangles.withIndex()) {
|
||||||
|
drawer.fill = ColorRGBa.PINK.shade(1.0 - i / (triangles.size * 1.2))
|
||||||
|
drawer.stroke = ColorRGBa.PINK.shade( i / (triangles.size * 1.0) + 0.1)
|
||||||
|
|
||||||
|
drawer.contour(triangle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
42
orx-triangulation/src/demo/kotlin/DemoDelaunay02.kt
Normal file
42
orx-triangulation/src/demo/kotlin/DemoDelaunay02.kt
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import org.openrndr.application
|
||||||
|
import org.openrndr.color.ColorRGBa
|
||||||
|
import org.openrndr.extensions.SingleScreenshot
|
||||||
|
import org.openrndr.extra.noise.poissonDiskSampling
|
||||||
|
import org.openrndr.extra.triangulation.Delaunay
|
||||||
|
import org.openrndr.math.Vector2
|
||||||
|
import org.openrndr.shape.Rectangle
|
||||||
|
|
||||||
|
fun main() {
|
||||||
|
application {
|
||||||
|
configure {
|
||||||
|
width = 800
|
||||||
|
height = 800
|
||||||
|
}
|
||||||
|
program {
|
||||||
|
if (System.getProperty("takeScreenshot") == "true") {
|
||||||
|
extend(SingleScreenshot()) {
|
||||||
|
this.outputFile = System.getProperty("screenshotPath")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val frame = Rectangle.fromCenter(Vector2(400.0), 600.0, 600.0)
|
||||||
|
|
||||||
|
val points = poissonDiskSampling(frame.width, frame.height, 50.0).map { it + frame.corner }
|
||||||
|
|
||||||
|
val delaunay = Delaunay.from(points)
|
||||||
|
val halfedges = delaunay.halfedges()
|
||||||
|
val hull = delaunay.hull()
|
||||||
|
|
||||||
|
extend {
|
||||||
|
drawer.clear(ColorRGBa.BLACK)
|
||||||
|
|
||||||
|
drawer.fill = null
|
||||||
|
drawer.stroke = ColorRGBa.PINK
|
||||||
|
drawer.contours(halfedges)
|
||||||
|
|
||||||
|
drawer.stroke = ColorRGBa.GREEN
|
||||||
|
drawer.contour(hull)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
43
orx-triangulation/src/demo/kotlin/DemoVoronoi01.kt
Normal file
43
orx-triangulation/src/demo/kotlin/DemoVoronoi01.kt
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import org.openrndr.application
|
||||||
|
import org.openrndr.color.ColorRGBa
|
||||||
|
import org.openrndr.extensions.SingleScreenshot
|
||||||
|
import org.openrndr.extra.noise.poissonDiskSampling
|
||||||
|
import org.openrndr.extra.triangulation.Delaunay
|
||||||
|
import org.openrndr.math.Vector2
|
||||||
|
import org.openrndr.shape.Circle
|
||||||
|
import org.openrndr.shape.Rectangle
|
||||||
|
|
||||||
|
fun main() {
|
||||||
|
application {
|
||||||
|
configure {
|
||||||
|
width = 800
|
||||||
|
height = 800
|
||||||
|
}
|
||||||
|
program {
|
||||||
|
if (System.getProperty("takeScreenshot") == "true") {
|
||||||
|
extend(SingleScreenshot()) {
|
||||||
|
this.outputFile = System.getProperty("screenshotPath")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val circle = Circle(Vector2(400.0), 250.0)
|
||||||
|
val frame = Rectangle.fromCenter(Vector2(400.0), 600.0, 600.0)
|
||||||
|
|
||||||
|
val points = poissonDiskSampling(width * 1.0, height * 1.0, 30.0)
|
||||||
|
.filter { circle.contains(it) }
|
||||||
|
|
||||||
|
val delaunay = Delaunay.from(points + circle.contour.equidistantPositions(40))
|
||||||
|
val voronoi = delaunay.voronoi(frame)
|
||||||
|
|
||||||
|
val cells = voronoi.cellsPolygons()
|
||||||
|
|
||||||
|
extend {
|
||||||
|
drawer.clear(ColorRGBa.BLACK)
|
||||||
|
|
||||||
|
drawer.fill = null
|
||||||
|
drawer.stroke = ColorRGBa.PINK
|
||||||
|
drawer.contours(cells)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
205
orx-triangulation/src/main/kotlin/Delaunay.kt
Normal file
205
orx-triangulation/src/main/kotlin/Delaunay.kt
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
package org.openrndr.extra.triangulation
|
||||||
|
|
||||||
|
import org.openrndr.math.Vector2
|
||||||
|
import org.openrndr.shape.Rectangle
|
||||||
|
import org.openrndr.shape.Triangle
|
||||||
|
import org.openrndr.shape.contour
|
||||||
|
import org.openrndr.shape.contours
|
||||||
|
import com.github.ricardomatias.Delaunator
|
||||||
|
import kotlin.math.pow
|
||||||
|
|
||||||
|
/*
|
||||||
|
ISC License
|
||||||
|
|
||||||
|
Copyright 2021 Ricardo Matias.
|
||||||
|
|
||||||
|
Permission to use, copy, modify, and/or distribute this software for any purpose
|
||||||
|
with or without fee is hereby granted, provided that the above copyright notice
|
||||||
|
and this permission notice appear in all copies.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
|
||||||
|
FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
|
||||||
|
OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
|
||||||
|
TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
|
||||||
|
THIS SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use [from] static method to use the delaunay triangulation
|
||||||
|
*
|
||||||
|
* @description Port of d3-delaunay (JavaScript) library - https://github.com/d3/d3-delaunay
|
||||||
|
* @property points flat positions' array - [x0, y0, x1, y1..]
|
||||||
|
*
|
||||||
|
* @since 9258fa3 - commit
|
||||||
|
* @author Ricardo Matias
|
||||||
|
*/
|
||||||
|
@Suppress("unused")
|
||||||
|
class Delaunay(val points: DoubleArray) {
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Entry point for the delaunay triangulation
|
||||||
|
*
|
||||||
|
* @property points a list of 2D points
|
||||||
|
*/
|
||||||
|
fun from(points: List<Vector2>): Delaunay {
|
||||||
|
val n = points.size
|
||||||
|
val coords = DoubleArray(n * 2)
|
||||||
|
|
||||||
|
for (i in points.indices) {
|
||||||
|
val p = points[i]
|
||||||
|
coords[2 * i] = p.x
|
||||||
|
coords[2 * i + 1] = p.y
|
||||||
|
}
|
||||||
|
|
||||||
|
return Delaunay(coords)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var delaunator = Delaunator(points)
|
||||||
|
|
||||||
|
val inedges = IntArray(points.size / 2) { -1 }
|
||||||
|
private val hullIndex = IntArray(points.size / 2) { -1 }
|
||||||
|
|
||||||
|
private var collinear = IntArray(points.size / 2) { it }
|
||||||
|
|
||||||
|
var halfedges = delaunator.halfedges
|
||||||
|
var hull = delaunator.hull
|
||||||
|
var triangles = delaunator.triangles
|
||||||
|
|
||||||
|
init {
|
||||||
|
init()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun update() {
|
||||||
|
delaunator.update()
|
||||||
|
init()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun init() {
|
||||||
|
halfedges = delaunator.halfedges
|
||||||
|
hull = delaunator.hull
|
||||||
|
triangles = delaunator.triangles
|
||||||
|
|
||||||
|
// Compute an index from each point to an (arbitrary) incoming halfedge
|
||||||
|
// Used to give the first neighbor of each point for this reason,
|
||||||
|
// on the hull we give priority to exterior halfedges
|
||||||
|
for (e in halfedges.indices) {
|
||||||
|
val p = triangles[nextHalfedge(e)]
|
||||||
|
|
||||||
|
if (halfedges[e] == -1 || inedges[p] == -1) inedges[p] = e
|
||||||
|
}
|
||||||
|
|
||||||
|
for (i in hull.indices) {
|
||||||
|
hullIndex[hull[i]] = i
|
||||||
|
}
|
||||||
|
|
||||||
|
// degenerate case: 1 or 2 (distinct) points
|
||||||
|
if (hull.size in 1..2) {
|
||||||
|
triangles = IntArray(3) { -1 }
|
||||||
|
halfedges = IntArray(3) { -1 }
|
||||||
|
triangles[0] = hull[0]
|
||||||
|
triangles[1] = hull[1]
|
||||||
|
triangles[2] = hull[1]
|
||||||
|
inedges[hull[0]] = 1
|
||||||
|
if (hull.size == 2) inedges[hull[1]] = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun triangles(): List<Triangle> {
|
||||||
|
val list = mutableListOf<Triangle>()
|
||||||
|
|
||||||
|
for (i in triangles.indices step 3 ) {
|
||||||
|
val t0 = triangles[i] * 2
|
||||||
|
val t1 = triangles[i + 1] * 2
|
||||||
|
val t2 = triangles[i + 2] * 2
|
||||||
|
|
||||||
|
val p1 = Vector2(points[t0], points[t0 + 1])
|
||||||
|
val p2 = Vector2(points[t1], points[t1 + 1])
|
||||||
|
val p3 = Vector2(points[t2], points[t2 + 1])
|
||||||
|
|
||||||
|
// originally they are defined *counterclockwise*
|
||||||
|
list.add(Triangle(p3, p2, p1))
|
||||||
|
}
|
||||||
|
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inner edges of the delaunay triangulation (without hull)
|
||||||
|
fun halfedges() = contours {
|
||||||
|
for (i in halfedges.indices) {
|
||||||
|
val j = halfedges[i]
|
||||||
|
|
||||||
|
if (j < i) continue
|
||||||
|
val ti = triangles[i] * 2
|
||||||
|
val tj = triangles[j] * 2
|
||||||
|
|
||||||
|
moveTo(points[ti], points[ti + 1])
|
||||||
|
lineTo(points[tj], points[tj + 1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hull() = contour {
|
||||||
|
for (h in hull) {
|
||||||
|
moveOrLineTo(points[2 * h], points[2 * h + 1])
|
||||||
|
}
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun find(x: Double, y: Double, i: Int = 0): Int {
|
||||||
|
val x0 = +x
|
||||||
|
val y0 = +y
|
||||||
|
var i0 = i
|
||||||
|
|
||||||
|
if ((x0 != x) || (y0 != y)) return -1
|
||||||
|
|
||||||
|
val i1 = i0
|
||||||
|
var c = step(i0, x, y)
|
||||||
|
|
||||||
|
while (c >= 0 && c != i && c != i1) {
|
||||||
|
i0 = c
|
||||||
|
c = step(i0, x, y)
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
fun nextHalfedge(e: Int) = if (e % 3 == 2) e - 2 else e + 1
|
||||||
|
fun prevHalfedge(e: Int) = if (e % 3 == 0) e + 2 else e - 1
|
||||||
|
|
||||||
|
fun step(i: Int, x: Double, y: Double): Int {
|
||||||
|
if (inedges[i] == -1 || points.isEmpty()) return (i + 1) % (points.size shr 1)
|
||||||
|
|
||||||
|
var c = i
|
||||||
|
var dc = (x - points[i * 2]).pow(2) + (y - points[i * 2 + 1]).pow(2)
|
||||||
|
val e0 = inedges[i]
|
||||||
|
var e = e0
|
||||||
|
do {
|
||||||
|
val t = triangles[e]
|
||||||
|
val dt = (x - points[t * 2]).pow(2) + (y - points[t * 2 + 1]).pow(2)
|
||||||
|
|
||||||
|
if (dt < dc) {
|
||||||
|
dc = dt
|
||||||
|
c = t
|
||||||
|
}
|
||||||
|
|
||||||
|
e = nextHalfedge(e)
|
||||||
|
|
||||||
|
if (triangles[e] != i) break // bad triangulation
|
||||||
|
|
||||||
|
e = halfedges[e]
|
||||||
|
|
||||||
|
if (e == -1) {
|
||||||
|
e = hull[(hullIndex[i] + 1) % hull.size]
|
||||||
|
if (e != t) {
|
||||||
|
if ((x - points[e * 2]).pow(2) + (y - points[e * 2 + 1]).pow(2) < dc) return e
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} while (e != e0)
|
||||||
|
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
fun voronoi(bounds: Rectangle): Voronoi = Voronoi(this, bounds)
|
||||||
|
}
|
||||||
600
orx-triangulation/src/main/kotlin/Voronoi.kt
Normal file
600
orx-triangulation/src/main/kotlin/Voronoi.kt
Normal file
@@ -0,0 +1,600 @@
|
|||||||
|
package org.openrndr.extra.triangulation
|
||||||
|
|
||||||
|
import org.openrndr.math.Vector2
|
||||||
|
import org.openrndr.shape.Rectangle
|
||||||
|
import org.openrndr.shape.ShapeContour
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
/*
|
||||||
|
ISC License
|
||||||
|
|
||||||
|
Copyright 2021 Ricardo Matias.
|
||||||
|
|
||||||
|
Permission to use, copy, modify, and/or distribute this software for any purpose
|
||||||
|
with or without fee is hereby granted, provided that the above copyright notice
|
||||||
|
and this permission notice appear in all copies.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
|
||||||
|
FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
|
||||||
|
OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
|
||||||
|
TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
|
||||||
|
THIS SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is a fast library for computing the Voronoi diagram of a set of two-dimensional points.
|
||||||
|
* The Voronoi diagram is constructed by connecting the circumcenters of adjacent triangles
|
||||||
|
* in the Delaunay triangulation.
|
||||||
|
*
|
||||||
|
* @description Port of d3-delaunay (JavaScript) library - https://github.com/d3/d3-delaunay
|
||||||
|
* @property points flat positions' array - [x0, y0, x1, y1..]
|
||||||
|
*
|
||||||
|
* @since 9258fa3 - commit
|
||||||
|
* @author Ricardo Matias
|
||||||
|
*/
|
||||||
|
class Voronoi(val delaunay: Delaunay, val bounds: Rectangle) {
|
||||||
|
private val _circumcenters = DoubleArray(delaunay.points.size * 2)
|
||||||
|
val circumcenters = _circumcenters.copyOf(delaunay.triangles.size / 3 * 2)
|
||||||
|
|
||||||
|
val vectors = DoubleArray(delaunay.points.size * 2)
|
||||||
|
|
||||||
|
init {
|
||||||
|
init()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun update() {
|
||||||
|
delaunay.update()
|
||||||
|
init()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun init() {
|
||||||
|
val points = delaunay.points
|
||||||
|
val triangles = delaunay.triangles
|
||||||
|
val hull = delaunay.hull
|
||||||
|
|
||||||
|
// Compute circumcenters
|
||||||
|
var i = 0
|
||||||
|
var j = 0
|
||||||
|
|
||||||
|
var x: Double
|
||||||
|
var y: Double
|
||||||
|
|
||||||
|
while (i < triangles.size) {
|
||||||
|
val t1 = triangles[i] * 2
|
||||||
|
val t2 = triangles[i + 1] * 2
|
||||||
|
val t3 = triangles[i + 2] * 2
|
||||||
|
val x1 = points[t1]
|
||||||
|
val y1 = points[t1 + 1]
|
||||||
|
val x2 = points[t2]
|
||||||
|
val y2 = points[t2 + 1]
|
||||||
|
val x3 = points[t3]
|
||||||
|
val y3 = points[t3 + 1]
|
||||||
|
|
||||||
|
val dx = x2 - x1
|
||||||
|
val dy = y2 - y1
|
||||||
|
val ex = x3 - x1
|
||||||
|
val ey = y3 - y1
|
||||||
|
val bl = dx * dx + dy * dy
|
||||||
|
val cl = ex * ex + ey * ey
|
||||||
|
val ab = (dx * ey - dy * ex) * 2
|
||||||
|
|
||||||
|
when {
|
||||||
|
ab == 0.0 -> {
|
||||||
|
// degenerate case (collinear diagram)
|
||||||
|
x = (x1 + x3) / 2 - 1e8 * ey
|
||||||
|
y = (y1 + y3) / 2 + 1e8 * ex
|
||||||
|
}
|
||||||
|
abs(ab) < 1e-8 -> {
|
||||||
|
// almost equal points (degenerate triangle)
|
||||||
|
x = (x1 + x3) / 2
|
||||||
|
y = (y1 + y3) / 2
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
val d = 1 / ab
|
||||||
|
x = x1 + (ey * bl - dy * cl) * d
|
||||||
|
y = y1 + (dx * cl - ex * bl) * d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
circumcenters[j] = x
|
||||||
|
circumcenters[j + 1] = y
|
||||||
|
|
||||||
|
i += 3
|
||||||
|
j += 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute exterior cell rays.
|
||||||
|
var h = hull[hull.size - 1]
|
||||||
|
var p0: Int
|
||||||
|
var p1 = h * 4
|
||||||
|
var x0: Double
|
||||||
|
var x1 = points[2 * h]
|
||||||
|
var y0: Double
|
||||||
|
var y1 = points[2 * h + 1]
|
||||||
|
var y01: Double
|
||||||
|
var x10: Double
|
||||||
|
|
||||||
|
vectors.fill(0.0)
|
||||||
|
|
||||||
|
for (idx in hull.indices) {
|
||||||
|
h = hull[idx]
|
||||||
|
p0 = p1
|
||||||
|
x0 = x1
|
||||||
|
y0 = y1
|
||||||
|
p1 = h * 4
|
||||||
|
x1 = points[2 * h]
|
||||||
|
y1 = points[2 * h + 1]
|
||||||
|
|
||||||
|
y01 = y0 - y1
|
||||||
|
x10 = x1 - x0
|
||||||
|
|
||||||
|
vectors[p0 + 2] = y01
|
||||||
|
vectors[p1] = y01
|
||||||
|
vectors[p0 + 3] = x10
|
||||||
|
vectors[p1 + 1] = x10
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cellsPolygons(): List<ShapeContour> {
|
||||||
|
val points = delaunay.points
|
||||||
|
val cells = mutableListOf<ShapeContour>()
|
||||||
|
|
||||||
|
for (i in 0 until (points.size / 2)) {
|
||||||
|
cellPolygon(i)?.let {
|
||||||
|
cells.add(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cells
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cellPolygon(i: Int): ShapeContour? {
|
||||||
|
val points = clip(i)
|
||||||
|
|
||||||
|
if (points == null || points.isEmpty()) return null
|
||||||
|
|
||||||
|
val polygon = mutableListOf(Vector2(points[0], points[1]))
|
||||||
|
var n = points.size
|
||||||
|
|
||||||
|
while (points[0] == points[n-2] && points[1] == points[n-1] && n > 1) n -= 2
|
||||||
|
|
||||||
|
for (idx in 2 until n step 2) {
|
||||||
|
if (points[idx] != points[idx - 2] || points[idx + 1] != points[idx - 1]) {
|
||||||
|
polygon.add(Vector2(points[idx], points[idx + 1]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ShapeContour.fromPoints(polygon, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun circumcenters() = circumcenters.toList().windowed(2, 2).map {
|
||||||
|
Vector2(it[0], it[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
fun contains(i: Int, v: Vector2): Boolean {
|
||||||
|
return contains(i, v.x, v.y)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cell(i: Int): MutableList<Double>? {
|
||||||
|
val inedges = delaunay.inedges
|
||||||
|
val halfedges = delaunay.halfedges
|
||||||
|
val triangles = delaunay.triangles
|
||||||
|
|
||||||
|
val e0 = inedges[i]
|
||||||
|
|
||||||
|
if (e0 == -1) return null // coincident point
|
||||||
|
|
||||||
|
val points = mutableListOf<Double>()
|
||||||
|
|
||||||
|
var e = e0
|
||||||
|
|
||||||
|
do {
|
||||||
|
val t = Math.floorDiv(e, 3) // triangle of edge
|
||||||
|
|
||||||
|
points.add(circumcenters[t * 2])
|
||||||
|
points.add(circumcenters[t * 2 + 1])
|
||||||
|
|
||||||
|
e = if (e % 3 == 2) e - 2 else e + 1 // next half edge
|
||||||
|
|
||||||
|
if (triangles[e] != i) break
|
||||||
|
|
||||||
|
e = halfedges[e]
|
||||||
|
} while (e != e0 && e != -1)
|
||||||
|
|
||||||
|
return points
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clip(i: Int): List<Double>? {
|
||||||
|
// degenerate case (1 valid point: return the box)
|
||||||
|
if (i == 0 && delaunay.hull.size == 1) {
|
||||||
|
return listOf(bounds.xmax, bounds.ymin, bounds.xmax, bounds.ymax, bounds.xmin, bounds.ymax, bounds.xmin, bounds.ymin)
|
||||||
|
}
|
||||||
|
|
||||||
|
val points = cell(i) ?: return null
|
||||||
|
|
||||||
|
val clipVectors = vectors
|
||||||
|
val v = i * 4
|
||||||
|
|
||||||
|
val a = !clipVectors[v].isFalsy()
|
||||||
|
val b = !clipVectors[v + 1].isFalsy()
|
||||||
|
|
||||||
|
return if (a || b) {
|
||||||
|
this.clipInfinite(i, points, clipVectors[v], clipVectors[v +1], clipVectors[v + 2], clipVectors[v + 3])
|
||||||
|
} else {
|
||||||
|
this.clipFinite(i, points)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clipInfinite(
|
||||||
|
i: Int,
|
||||||
|
points: MutableList<Double>,
|
||||||
|
vx0: Double,
|
||||||
|
vy0: Double,
|
||||||
|
vxn: Double,
|
||||||
|
vyn: Double
|
||||||
|
): List<Double>? {
|
||||||
|
var P: MutableList<Double>? = points.mutableCopyOf().also { list ->
|
||||||
|
// SHAKY
|
||||||
|
this.project(list[0], list[1], vx0, vy0)?.also {
|
||||||
|
list.addAll(0, listOf(it.x, it.y))
|
||||||
|
}
|
||||||
|
|
||||||
|
this.project(list[list.size - 2], list[list.size - 1], vxn, vyn)?.also {
|
||||||
|
list.addAll(0, listOf(it.x, it.y))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
P = clipFinite(i, P!!)
|
||||||
|
|
||||||
|
if (P != null) {
|
||||||
|
var n = P.size
|
||||||
|
var c0: Int?
|
||||||
|
var c1 = edgeCode(P[n - 2], P[n - 1])
|
||||||
|
var j = 0
|
||||||
|
|
||||||
|
while (j < n) {
|
||||||
|
c0 = c1
|
||||||
|
c1 = edgeCode(P[j], P[j + 1])
|
||||||
|
|
||||||
|
if ((c0 and c1) != 0) {
|
||||||
|
j = edge(i, c0, c1, P, j)
|
||||||
|
n = P.size
|
||||||
|
}
|
||||||
|
|
||||||
|
j += 2
|
||||||
|
}
|
||||||
|
} else if (contains(i, (bounds.xmin + bounds.xmax) / 2, (bounds.ymin + bounds.ymax) / 2)) {
|
||||||
|
P = mutableListOf(bounds.xmin, bounds.ymin, bounds.xmax, bounds.ymin, bounds.xmax, bounds.ymax, bounds.xmin, bounds.ymax)
|
||||||
|
}
|
||||||
|
|
||||||
|
return P
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clipFinite(i: Int, points: MutableList<Double>): MutableList<Double>? {
|
||||||
|
val n = points.size
|
||||||
|
|
||||||
|
val P = mutableListOf<Double>()
|
||||||
|
var x0: Double
|
||||||
|
var y0: Double
|
||||||
|
var x1= points[n - 2]
|
||||||
|
var y1= points[n - 1]
|
||||||
|
var c0: Int
|
||||||
|
var c1: Int = regionCode(x1, y1)
|
||||||
|
var e0: Int? = null
|
||||||
|
var e1: Int? = null
|
||||||
|
|
||||||
|
for (j in 0 until n step 2) {
|
||||||
|
x0 = x1
|
||||||
|
y0 = y1
|
||||||
|
x1 = points[j]
|
||||||
|
y1 = points[j + 1]
|
||||||
|
c0 = c1
|
||||||
|
c1 = regionCode(x1, y1)
|
||||||
|
|
||||||
|
if (c0 == 0 && c1 == 0) {
|
||||||
|
e0 = e1
|
||||||
|
e1 = 0
|
||||||
|
|
||||||
|
P.add(x1)
|
||||||
|
P.add(y1)
|
||||||
|
} else {
|
||||||
|
var S: DoubleArray?
|
||||||
|
var sx0: Double
|
||||||
|
var sy0: Double
|
||||||
|
var sx1: Double
|
||||||
|
var sy1: Double
|
||||||
|
|
||||||
|
if (c0 == 0) {
|
||||||
|
S = clipSegment(x0, y0, x1, y1, c0, c1)
|
||||||
|
if (S == null) continue
|
||||||
|
// sx0 = S[0]
|
||||||
|
// sy0 = S[1]
|
||||||
|
sx1 = S[2]
|
||||||
|
sy1 = S[3]
|
||||||
|
} else {
|
||||||
|
S = clipSegment(x1, y1, x0, y0, c1, c0)
|
||||||
|
if (S == null) continue
|
||||||
|
sx1 = S[0]
|
||||||
|
sy1 = S[1]
|
||||||
|
sx0 = S[2]
|
||||||
|
sy0 = S[3]
|
||||||
|
|
||||||
|
e0 = e1
|
||||||
|
e1 = this.edgeCode(sx0, sy0)
|
||||||
|
|
||||||
|
if (e0.isTruthy() && e1.isTruthy()) this.edge(i, e0!!, e1, P, P.size)
|
||||||
|
|
||||||
|
P.add(sx0)
|
||||||
|
P.add(sy0)
|
||||||
|
}
|
||||||
|
|
||||||
|
e0 = e1
|
||||||
|
e1 = this.edgeCode(sx1, sy1);
|
||||||
|
|
||||||
|
if (e0.isTruthy() && e1.isTruthy()) this.edge(i, e0!!, e1, P, P.size);
|
||||||
|
|
||||||
|
P.add(sx1)
|
||||||
|
P.add(sy1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (P.isNotEmpty()) {
|
||||||
|
e0 = e1
|
||||||
|
e1 = this.edgeCode(P[0], P[1])
|
||||||
|
|
||||||
|
if (e0.isTruthy() && e1.isTruthy()) this.edge(i, e0!!, e1!!, P, P.size);
|
||||||
|
} else if (this.contains(i, (bounds.xmin + bounds.xmax) / 2, (bounds.ymin + bounds.ymax) / 2)) {
|
||||||
|
return mutableListOf(bounds.xmax, bounds.ymin, bounds.xmax, bounds.ymax, bounds.xmin, bounds.ymax, bounds.xmin, bounds.ymin)
|
||||||
|
} else {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return P
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clipSegment(x0: Double, y0: Double, x1: Double, y1: Double, c0: Int, c1: Int): DoubleArray? {
|
||||||
|
var nx0: Double = x0
|
||||||
|
var ny0: Double = y0
|
||||||
|
var nx1: Double = x1
|
||||||
|
var ny1: Double = y1
|
||||||
|
var nc0: Int = c0
|
||||||
|
var nc1: Int = c1
|
||||||
|
|
||||||
|
while(true) {
|
||||||
|
if (nc0 == 0 && nc1 == 0) return doubleArrayOf(nx0, ny0, nx1, ny1)
|
||||||
|
// SHAKY STUFF
|
||||||
|
if ((nc0 and nc1) != 0) return null
|
||||||
|
|
||||||
|
var x: Double
|
||||||
|
var y: Double
|
||||||
|
val c: Int = if (nc0 != 0) nc0 else nc1
|
||||||
|
|
||||||
|
when {
|
||||||
|
(c and 0b1000) != 0 -> {
|
||||||
|
x = nx0 + (nx1 - nx0) * (bounds.ymax - ny0) / (ny1 - ny0)
|
||||||
|
y = bounds.ymax;
|
||||||
|
}
|
||||||
|
(c and 0b0100) != 0 -> {
|
||||||
|
x = nx0 + (nx1 - nx0) * (bounds.ymin - ny0) / (ny1 - ny0)
|
||||||
|
y = bounds.ymin
|
||||||
|
}
|
||||||
|
(c and 0b0010) != 0 -> {
|
||||||
|
y = ny0 + (ny1 - ny0) * (bounds.xmax - nx0) / (nx1 - nx0)
|
||||||
|
x = bounds.xmax
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
y = ny0 + (ny1 - ny0) * (bounds.xmin - nx0) / (nx1 - nx0)
|
||||||
|
x = bounds.xmin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nc0 != 0) {
|
||||||
|
nx0 = x
|
||||||
|
ny0 = y
|
||||||
|
nc0 = this.regionCode(nx0, ny0)
|
||||||
|
} else {
|
||||||
|
nx1 = x
|
||||||
|
ny1 = y
|
||||||
|
nc1 = this.regionCode(nx1, ny1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun regionCode(x: Double, y: Double): Int {
|
||||||
|
val code = when {
|
||||||
|
x < bounds.xmin -> 0b0001
|
||||||
|
x > bounds.xmax -> 0b0010
|
||||||
|
else -> 0b0000
|
||||||
|
}
|
||||||
|
return code or when {
|
||||||
|
y < bounds.ymin -> 0b0100
|
||||||
|
y > bounds.ymax -> 0b1000
|
||||||
|
else -> 0b0000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private fun contains(i: Int, x: Double, y: Double): Boolean {
|
||||||
|
// if ((x = +x, x !== x) || (y = +y, y !== y)) return false;
|
||||||
|
return this.delaunay.step(i, x, y) == i;
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun edge(i: Int, e0: Int, e1: Int, p: MutableList<Double>, j: Int): Int {
|
||||||
|
var j = j
|
||||||
|
var e = e0
|
||||||
|
while(e != e1) {
|
||||||
|
var x: Double = Double.NaN
|
||||||
|
var y: Double = Double.NaN
|
||||||
|
|
||||||
|
when(e) {
|
||||||
|
0b0101 -> { // top-left
|
||||||
|
e = 0b0100
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
0b0100 -> { // top
|
||||||
|
e = 0b0110
|
||||||
|
x = bounds.xmax
|
||||||
|
y = bounds.ymin
|
||||||
|
break
|
||||||
|
}
|
||||||
|
0b0110 -> { // top-right
|
||||||
|
e = 0b0010
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
0b0010 -> { // right
|
||||||
|
e = 0b1010
|
||||||
|
x = bounds.xmax
|
||||||
|
y = bounds.ymax
|
||||||
|
break
|
||||||
|
}
|
||||||
|
0b1010 -> { // bottom-right
|
||||||
|
e = 0b1000
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
0b1000 -> { // bottom
|
||||||
|
e = 0b0001
|
||||||
|
x = bounds.xmin
|
||||||
|
y = bounds.ymax
|
||||||
|
break
|
||||||
|
}
|
||||||
|
0b1001 -> { // bottom-left
|
||||||
|
e = 0b0001
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
0b0001 -> { // left
|
||||||
|
e = 0b0101
|
||||||
|
x = bounds.xmin
|
||||||
|
y = bounds.ymin
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((p[j] != x || p[j + 1] != y) && contains(i, x, y)) {
|
||||||
|
p.add(j, y)
|
||||||
|
p.add(j, x)
|
||||||
|
j += 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p.size > 4) {
|
||||||
|
var idx = 0
|
||||||
|
|
||||||
|
while (idx < p.size) {
|
||||||
|
val j = (idx + 2) % p.size
|
||||||
|
val k = (idx + 4) % p.size
|
||||||
|
|
||||||
|
if (p[idx] == p[j] && p[j] == p[k]
|
||||||
|
|| p[idx + 1] == p[j + 1] && p[j + 1] == p[k + 1]) {
|
||||||
|
// SHAKY
|
||||||
|
p.removeAt(j)
|
||||||
|
p.removeAt(j)
|
||||||
|
idx -= 2
|
||||||
|
}
|
||||||
|
|
||||||
|
idx += 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return j
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun project(x0: Double, y0: Double, vx: Double, vy: Double): Vector2? {
|
||||||
|
var t = Double.POSITIVE_INFINITY
|
||||||
|
var c: Double
|
||||||
|
var x = Double.NaN
|
||||||
|
var y = Double.NaN
|
||||||
|
|
||||||
|
// top
|
||||||
|
if(vy < 0) {
|
||||||
|
if (y0 <= bounds.ymin) return null
|
||||||
|
c = (bounds.ymin - y0) / vy
|
||||||
|
|
||||||
|
if(c < t) {
|
||||||
|
t = c
|
||||||
|
|
||||||
|
y = bounds.ymin
|
||||||
|
x = x0 + c * vx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// bottom
|
||||||
|
else if (vy > 0) {
|
||||||
|
if (y0 >= bounds.ymax) return null
|
||||||
|
c = (bounds.ymax - y0) / vy
|
||||||
|
|
||||||
|
if( c < t) {
|
||||||
|
t = c
|
||||||
|
|
||||||
|
y = bounds.ymax
|
||||||
|
x = x0 + c * vx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// right
|
||||||
|
if (vx > 0) {
|
||||||
|
if (x0 >= bounds.xmax) return null
|
||||||
|
c = (bounds.xmax - x0) / vx
|
||||||
|
|
||||||
|
if (c < t) {
|
||||||
|
t = c
|
||||||
|
|
||||||
|
x = bounds.xmax
|
||||||
|
y = y0 + t * vy
|
||||||
|
}
|
||||||
|
// left
|
||||||
|
} else if (vx < 0) {
|
||||||
|
if (x0 <= bounds.xmin) return null
|
||||||
|
c = (bounds.xmin - x0) / vx
|
||||||
|
|
||||||
|
if (c < t) {
|
||||||
|
t = c
|
||||||
|
|
||||||
|
x = bounds.xmin
|
||||||
|
y = y0 + t * vy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(x.isNaN() || y.isNaN()) return null
|
||||||
|
|
||||||
|
return Vector2(x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun edgeCode(x: Double, y: Double): Int {
|
||||||
|
val code = when (x) {
|
||||||
|
bounds.xmin -> 0b0001
|
||||||
|
bounds.xmax -> 0b0010
|
||||||
|
else -> 0b0000
|
||||||
|
}
|
||||||
|
|
||||||
|
return code or when (y) {
|
||||||
|
bounds.ymin -> 0b0100
|
||||||
|
bounds.ymax -> 0b1000
|
||||||
|
else -> 0b0000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Int?.isTruthy(): Boolean {
|
||||||
|
return (this != null && this != 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> List<T>.mutableCopyOf(): MutableList<T> {
|
||||||
|
val original = this
|
||||||
|
return mutableListOf<T>().apply { addAll(original) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val Rectangle.xmin: Double
|
||||||
|
get() = this.corner.x
|
||||||
|
|
||||||
|
private val Rectangle.xmax: Double
|
||||||
|
get() = this.corner.x + width
|
||||||
|
|
||||||
|
private val Rectangle.ymin: Double
|
||||||
|
get() = this.corner.y
|
||||||
|
|
||||||
|
private val Rectangle.ymax: Double
|
||||||
|
get() = this.corner.y + height
|
||||||
|
|
||||||
|
private fun Double?.isFalsy() = this == null || this == -0.0 || this == 0.0 || isNaN()
|
||||||
@@ -54,6 +54,7 @@ include 'openrndr-demos',
|
|||||||
'orx-tensorflow-natives-windows',
|
'orx-tensorflow-natives-windows',
|
||||||
'orx-timer',
|
'orx-timer',
|
||||||
'orx-time-operators',
|
'orx-time-operators',
|
||||||
|
'orx-triangulation',
|
||||||
'orx-kinect-common',
|
'orx-kinect-common',
|
||||||
'orx-kinect-v1',
|
'orx-kinect-v1',
|
||||||
'orx-kinect-v1-natives-linux-arm64',
|
'orx-kinect-v1-natives-linux-arm64',
|
||||||
@@ -61,4 +62,5 @@ include 'openrndr-demos',
|
|||||||
'orx-kinect-v1-natives-macos',
|
'orx-kinect-v1-natives-macos',
|
||||||
'orx-kinect-v1-natives-windows',
|
'orx-kinect-v1-natives-windows',
|
||||||
'orx-kinect-v1-demo',
|
'orx-kinect-v1-demo',
|
||||||
'orx-video-profiles'
|
'orx-video-profiles'
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user