import io.data2viz.color.* import io.data2viz.geom.* import io.data2viz.math.* import io.data2viz.viz.* import io.data2viz.geom.Point import io.data2viz.geom.Polygon import io.data2viz.geom.contains import io.data2viz.geom.polygonHull import io.data2viz.math.Angle import io.data2viz.math.PI import io.data2viz.math.deg import kotlin.math.atan2 import kotlin.math.cos import kotlin.math.sin import kotlin.math.sqrt import kotlin.random.Random internal var vizWidth = 800.0 internal var vizHeight = 800.0 lateinit var model: LineOfSightModel fun main() { lineOfSightViz().bindRendererOnNewCanvas() } fun lineOfSightViz(): Viz = viz { width = vizWidth height = vizHeight val backgroundLayer = activeLayer val polygonsLayer = layer() val lineOfSightLayer = layer() model = LineOfSightModel(LineOfSightConfig(width, height)) fun buildBackground() { backgroundLayer.clear() backgroundLayer.rect { fill = 0x232c3b.col x = .0 y = .0 width = vizWidth height = vizHeight } } fun buildPolygons(polygons: List<Polygon>) { polygonsLayer.clear() with(polygonsLayer) { polygons.forEach { polygon -> path { fill = Colors.Web.black stroke = null moveTo(polygon.points.first().x, polygon.points.first().y) (1 until polygon.points.size).forEach { lineTo(polygon.points[it].x, polygon.points[it].y) } closePath() } } } } buildBackground() buildPolygons(model.polygons) val radialGradient = lightGradient() animation { lineOfSightLayer.clear() model.moveLight() radialGradient.center = model.lightPos val points = model.getSightPolygon().points lineOfSightLayer.path { moveTo(points.first().x, points.first().y) fill = radialGradient stroke = null points.forEach { point -> lineTo(point.x, point.y) } closePath() } } onResize { newWidth, newHeight -> vizWidth = newWidth vizHeight = newHeight width = vizWidth height = vizHeight model = LineOfSightModel(LineOfSightConfig(width, height)) buildBackground() buildPolygons(model.polygons) } } private fun lightGradient(): RadialGradient { val lightColor = 0xFFFFFF.col val fromColor = 0xFFFF00.col val endColor = Colors.rgb(0xFFFF00, 0.pct) return Colors.Gradient.radial(model.lightPos, .7 * vizWidth) .withColor(lightColor, 0.pct) .andColor(lightColor, 1.pct) .andColor(fromColor, 2.pct) .andColor(endColor, 100.pct) } data class LineOfSightConfig( val width: Double, val height: Double, val polygonNb: Int = 20, val polygonSize: Double = 0.15, val randomPointsNb: Int = 8 ) class LineOfSightModel(config: LineOfSightConfig) { var lightPos: Point /** * Randomly created polygons */ val polygons: List<Polygon> /** * All corners: view corners + polygons corners */ private val corners: List<Point> /** * All segments: view segments + polygons segments */ private val segments: List<Segment> private var xSpeed: Double = .0 private var ySpeed: Double = .0 private val viewAsPolygon: Polygon init { polygons = createPolygons(config.polygonNb, config.polygonSize, config.randomPointsNb) viewAsPolygon = Polygon( listOf( Point(.0, .0), Point(vizWidth, .0), Point( vizWidth, vizHeight ), Point(.0, vizHeight) ) ) corners = viewAsPolygon.points + polygons.flatMap { it.points } segments = viewAsPolygon.segments() + polygons.flatMap { it.segments() } lightPos = findInitialRandomPointOutsideOf(polygons) newSpeed(Random.nextDouble() * PI * 2) } private fun createPolygons(polygonNb: Int, polygonSize: Double, randomPointsNb: Int): List<Polygon> { // build random polygons val polygons = (1..polygonNb).map { val center = Point( Random.nextDouble() * vizWidth * (1.0 - polygonSize), Random.nextDouble() * vizHeight * (1.0 - polygonSize) ) val points = (1..randomPointsNb).map { _ -> Point( center.x + (Random.nextDouble() * (vizWidth * polygonSize)).coerceIn(.0, vizWidth), center.y + (Random.nextDouble() * (vizHeight * polygonSize)).coerceIn(.0, vizHeight) ) } polygonHull(points) }.toMutableList() // if a polygon touch another one, fuse the two polygons mergeAdjacentPolygons(polygons) return polygons } private fun mergeAdjacentPolygons(polygons: MutableList<Polygon>) { polygons.forEachIndexed { index, polygon -> polygons.forEachIndexed { otherIndex, otherPolygon -> if (index != otherIndex) { polygon.points.forEach { point -> if (otherPolygon.contains(point)) { polygons[index] = polygonHull(listOf(polygon.points, otherPolygon.points).flatMap { it }) polygons.removeAt(otherIndex) mergeAdjacentPolygons(polygons) return } } } } } } fun getSightPolygon(): Polygon { val allAngles = corners.flatMap { val rad = atan2(it.y - lightPos.y, it.x - lightPos.x) listOf( Angle(rad), Angle(rad + .00001), Angle(rad - .00001) ) } val intersections = mutableListOf<Intersection>() allAngles.forEach { angle -> val dx = angle.cos val dy = angle.sin val ray = Segment(lightPos, Point(lightPos.x + dx, lightPos.y + dy)) // Find CLOSEST intersection var closestIntersection: Intersection? = null segments .mapNotNull { segment -> ray.intersect(segment) } .forEach { intersection -> if (closestIntersection == null || (intersection.param < closestIntersection!!.param)) { closestIntersection = intersection } } // Intersect angle closestIntersection?.let { it.angle = angle // Add to list of intersections intersections.add(it) } } intersections.sortBy { it.angle.rad } // Polygon is intersects, in order of angle return Polygon(intersections.map { it.point }) } private tailrec fun findInitialRandomPointOutsideOf(polygons: List<Polygon>): Point { val pos = Point( Random.nextDouble() * vizWidth, Random.nextDouble() * vizHeight ) val insidePolygon = polygons.any { it.contains(pos) } return if (insidePolygon) findInitialRandomPointOutsideOf(polygons) else pos } internal fun moveLight() { var newPos = Point(lightPos.x + xSpeed, lightPos.y + ySpeed) // check for polygon collision for next position (on the extent or one of the polygons on screen) val collidedPolygon = if (viewAsPolygon.contains(newPos)) polygons.find { it.contains(newPos) } else viewAsPolygon // if collision is detected, compute rebound and recompute next position if (collidedPolygon != null) { // compute movement vector val movement = Segment(lightPos, newPos) val collidedSegment = collidedPolygon.segments().find { movement.isIntersection(it) } if (collidedSegment != null) { // find intersection and compute normal vector of collided segment val dx = collidedSegment.to.x - collidedSegment.from.x val dy = collidedSegment.to.y - collidedSegment.from.y val intersection = movement.intersect(collidedSegment)!!.point val normal = Segment(intersection, Point(intersection.x - dy, intersection.y + dx)) val dx1 = movement.from.x - movement.to.x val dx2 = normal.from.x - normal.to.x val dy1 = movement.from.y - movement.to.y val dy2 = normal.from.y - normal.to.y val movementAngle = atan2(dy1, dx1) val normalAngle = atan2(dy2, dx2) newSpeed(normalAngle + (normalAngle - movementAngle)) newPos = Point(lightPos.x + xSpeed, lightPos.y + ySpeed) } } lightPos = newPos } private fun newSpeed(currentDirection:Double) { xSpeed = cos(currentDirection) * 0.004 * vizWidth ySpeed = sin(currentDirection) * 0.004 * vizHeight } } private fun Polygon.segments(): List<Segment> = List(points.size) { i -> Segment( points[i], if (i == points.size - 1) points[0] else points[i + 1] ) } /** * Todo move to core? */ data class Segment(val from: Point, val to: Point) { /** * @return the point of intersection or null if rays are parallel. */ fun intersect(other: Segment): Intersection? { val px1 = from.x val py1 = from.y val dx1 = to.x - from.x val dy1 = to.y - from.y val px2 = other.from.x val py2 = other.from.y val dx2 = other.to.x - other.from.x val dy2 = other.to.y - other.from.y // Are they parallel? If so, no intersect val l1 = sqrt(dx1 * dx1 + dy1 * dy1) val l2 = sqrt(dx2 * dx2 + dy2 * dy2) if (((dx1 / l1) == (dx2 / l2)) && ((dy1 / l1) == (dy2 / l2))) return null // SOLVE FOR T1 & T2 val t2 = (dx1 * (py2 - py1) + dy1 * (px1 - px2)) / (dx2 * dy1 - dy2 * dx1) val t1 = (px2 + dx2 * t2 - px1) / dx1 //todo dx1 can be 0 => division by 0 !! // Must be within parametic whatevers for RAY/SEGMENT if (t1 < 0) return null if (t2 < 0 || t2 > 1) return null return Intersection(Point(px1 + dx1 * t1, py1 + dy1 * t1), t1) } fun isIntersection(other: Segment): Boolean { val dx1 = to.x - from.x val dy1 = to.y - from.y val dx2 = other.to.x - other.from.x val dy2 = other.to.y - other.from.y val denom = (dx1 * dy2) - (dx2 * dy1) if (denom == .0) return false // colinear val denomPositive = denom > 0 val s02x = from.x - other.from.x val s02y = from.y - other.from.y val sNumer = dx1 * s02y - dy1 * s02x if ((sNumer < 0) == denomPositive) return false // No collision val tNumer = dx2 * s02y - dy2 * s02x if ((tNumer < 0) == denomPositive) return false // No collision if (((sNumer > denom) == denomPositive) || ((tNumer > denom) == denomPositive)) return false // No collision // Collision detected return true } } data class Intersection(val point: Point, val param: Double) { var angle: Angle = 0.deg }