fline_graph_connectivity.kt
src/jvmMain/kotlin/afield/data/graph/fline_graph_connectivity.kt · 474 lines
package and imports — 45 lines, from line 1
@file:afield.runtime.completion.CompletionCategory("Graphics")
package afield.data.graph
import afield.data.collections.get
import afield.data.collections.invoke
import afield.data.collections.s
import afield.data.collections.times
import afield.document.graph.minusAssign
import afield.document.graph.plusAssign
import afield.document.properties.compareTo
import afield.document.workspace.file
import afield.geometry.mesh.contains
import afield.graphics.render.rangeTo
import afield.graphics.shader.vertex
import afield.math.autodiff.compareTo
import afield.math.autodiff.mean
import afield.math.autodiff.minus
import afield.math.autodiff.plus
import afield.math.autodiff.times
import afield.math.linalg.invoke
import afield.math.linalg.remAssign
import afield.math.linalg.set
import afield.math.linalg.times
import afield.math.units.minus
import afield.math.units.plus
import afield.math.units.times
import afield.media.image.close
import afield.runtime.execution.start
import afield.audio.synth.*
import afield.graphics.vulkan.buffers.add
import afield.math.autodiff.div
import afield.math.autodiff.maxOf
import afield.math.autodiff.minOf
import afield.math.linalg.Vec3
import afield.math.linalg.x
import afield.math.units.div
import afield.media.image.open
import afield.runtime.execution.cell
import afield.runtime.execution.end
import afield.geometry.path.FLine
import kotlin.math.floor
import kotlin.math.max
import kotlin.math.sqrt
Bridge from drawn geometry to the graph algorithms: turns one or more FLines into a welded
point graph.
flineGraph, or the toFLineGraph extensions on FLine and Iterable<FLine>, adaptively
subdivides cubics down to cubicFlatness, samples every node, then welds samples closer than
epsilon using a uniform grid plus union-find (epsilon = 0 welds only bit-identical
positions). A welded vertex is the mean of the samples that reached it, and
FLineGraph.vertexSources records which line index and node instruction each of those samples
came from. Edges join consecutive samples, close() joins back to the subpath start,
self-edges are dropped and duplicates collapse to the shortest.
The result carries a PointGraph of GraphEdges — the same types as graph_connectivity.kt —
so the search, spanning-tree and analysis functions there apply directly to a drawing.
data class FLineGraphSource
A sampled source point that contributed to a welded FLineGraph vertex.
data class FLineGraphSource(
val lineIndex: Int,
val instruction: Float,
val position: Vec3
)
data class FLineGraph
Graph view of one or more FLines.
vertices are the welded positions produced from sampled line geometry.
graph contains edges between consecutive sampled positions, using the same
GraphEdge / PointGraph types as the graph connectivity helpers.
data class FLineGraph(
val vertices: List<Vec3>,
val graph: PointGraph,
val vertexSources: List<List<FLineGraphSource>>
) {
val edges: List<GraphEdge>
val edges: List<GraphEdge>
get() = graph.edges
val pointCount: Int
val pointCount: Int
get() = graph.pointCount
}
fun flineGraph
Linearize lines into a graph by adaptively flattening curves and welding
vertices whose distance is <= epsilon.
cubicFlatness defaults to epsilon. Lower it when you want denser curve
sampling without changing vertex welding behavior.
body — 66 lines, from line 98
fun flineGraph(
lines: Iterable<FLine>,
epsilon: Number = 1e-4f,
cubicFlatness: Number = epsilon,
maxCubicSubdivisions: Int = 12
): FLineGraph {
val weldEpsilon = epsilon.toFloat()
val flatness = cubicFlatness.toFloat()
require(weldEpsilon >= 0f && weldEpsilon.isFinite()) {
"flineGraph: epsilon must be finite and >= 0, found $epsilon"
}
require(flatness >= 0f && flatness.isFinite()) {
"flineGraph: cubicFlatness must be finite and >= 0, found $cubicFlatness"
}
require(maxCubicSubdivisions >= 0) {
"flineGraph: maxCubicSubdivisions must be >= 0, found $maxCubicSubdivisions"
}
val raw = sampleFLineGraph(lines, flatness.coerceAtLeast(1e-7f), maxCubicSubdivisions)
if (raw.points.isEmpty()) {
return FLineGraph(emptyList(), PointGraph(emptyList(), 0), emptyList())
}
val rawToVertex = weldFLineGraphVertices(raw.points, weldEpsilon)
val vertexCount = (rawToVertex.maxOrNull() ?: -1) + 1
val vertexSums = Array(vertexCount) { Vec3() }
val vertexCounts = IntArray(vertexCount)
val sourceBuckets = Array(vertexCount) { mutableListOf<FLineGraphSource>() }
raw.points.forEachIndexed { rawIndex, point ->
val vertexIndex = rawToVertex[rawIndex]
vertexSums[vertexIndex].add(point.position)
vertexCounts[vertexIndex]++
sourceBuckets[vertexIndex] += FLineGraphSource(
lineIndex = point.lineIndex,
instruction = point.instruction,
position = Vec3(point.position)
)
}
val vertices = List(vertexCount) { index ->
Vec3(vertexSums[index]).div(vertexCounts[index].toFloat())
}
val edgeByKey = linkedMapOf<Long, GraphEdge>()
for (edge in raw.edges) {
val a = rawToVertex[edge.a]
val b = rawToVertex[edge.b]
if (a == b) continue
val weight = distance3(vertices[a], vertices[b])
if (!weight.isFinite()) continue
val key = flineGraphEdgeKey(a, b)
val existing = edgeByKey[key]
if (existing == null || weight < existing.weight) {
edgeByKey[key] = GraphEdge(a, b, weight)
}
}
val graph = PointGraph(edgeByKey.values.toList(), vertices.size)
return FLineGraph(
vertices = vertices,
graph = graph,
vertexSources = sourceBuckets.map { it.toList() }
)
}
fun Iterable<FLine>.toFLineGraph
Convenience extension for flineGraph.
fun Iterable<FLine>.toFLineGraph(
epsilon: Number = 1e-4f,
cubicFlatness: Number = epsilon,
maxCubicSubdivisions: Int = 12
): FLineGraph = flineGraph(this, epsilon, cubicFlatness, maxCubicSubdivisions)
fun FLine.toFLineGraph
Convenience extension for a single FLine.
body — 297 lines, from line 177
fun FLine.toFLineGraph(
epsilon: Number = 1e-4f,
cubicFlatness: Number = epsilon,
maxCubicSubdivisions: Int = 12
): FLineGraph = listOf(this).toFLineGraph(epsilon, cubicFlatness, maxCubicSubdivisions)
private data class RawFLineGraphPoint(
val position: Vec3,
val lineIndex: Int,
val instruction: Float
)
private data class RawFLineGraphEdge(
val a: Int,
val b: Int
)
private data class RawFLineGraph(
val points: List<RawFLineGraphPoint>,
val edges: List<RawFLineGraphEdge>
)
private fun sampleFLineGraph(
lines: Iterable<FLine>,
cubicFlatness: Float,
maxCubicSubdivisions: Int
): RawFLineGraph {
val points = mutableListOf<RawFLineGraphPoint>()
val edges = mutableListOf<RawFLineGraphEdge>()
fun addPoint(position: Vec3, lineIndex: Int, instruction: Float): Int {
val index = points.size
points += RawFLineGraphPoint(Vec3(position), lineIndex, instruction)
return index
}
fun addEdge(a: Int, b: Int) {
if (a != b) edges += RawFLineGraphEdge(a, b)
}
lines.forEachIndexed { lineIndex, line ->
var current = Vec3()
var currentIndex = -1
var hasCurrent = false
var open = Vec3()
var openIndex = -1
var hasOpen = false
line.nodes.forEachIndexed { instructionIndex, node ->
val instruction = instructionIndex.toFloat()
when (node) {
is FLine.MoveTo -> {
current = Vec3(node.to)
currentIndex = addPoint(current, lineIndex, instruction)
hasCurrent = true
open = Vec3(current)
openIndex = currentIndex
hasOpen = true
}
is FLine.LineTo -> {
val next = Vec3(node.to)
val nextIndex = addPoint(next, lineIndex, instruction)
if (hasCurrent) {
addEdge(currentIndex, nextIndex)
} else {
open = Vec3(next)
openIndex = nextIndex
hasOpen = true
}
current = next
currentIndex = nextIndex
hasCurrent = true
}
is FLine.CubicTo -> {
val end = Vec3(node.to)
if (!hasCurrent) {
current = end
currentIndex = addPoint(current, lineIndex, instruction)
open = Vec3(current)
openIndex = currentIndex
hasOpen = true
hasCurrent = true
} else {
val samples = flattenCubic(
a = current,
c1 = node.c1,
c2 = node.c2,
b = end,
fromInstruction = max(0f, instruction - 1f),
toInstruction = instruction,
flatness = cubicFlatness,
maxSubdivisions = maxCubicSubdivisions
)
for (sample in samples) {
val nextIndex = addPoint(sample.position, lineIndex, sample.instruction)
addEdge(currentIndex, nextIndex)
current = Vec3(sample.position)
currentIndex = nextIndex
}
}
}
is FLine.Close -> {
if (hasCurrent && hasOpen) {
addEdge(currentIndex, openIndex)
current = Vec3(open)
currentIndex = openIndex
}
}
}
}
}
return RawFLineGraph(points, edges)
}
private data class FlattenedCubicPoint(
val position: Vec3,
val instruction: Float
)
private fun flattenCubic(
a: Vec3,
c1: Vec3,
c2: Vec3,
b: Vec3,
fromInstruction: Float,
toInstruction: Float,
flatness: Float,
maxSubdivisions: Int
): List<FlattenedCubicPoint> {
val out = mutableListOf<FlattenedCubicPoint>()
fun recurse(
p0: Vec3,
p1: Vec3,
p2: Vec3,
p3: Vec3,
t0: Float,
t1: Float,
depth: Int
) {
if (depth >= maxSubdivisions || cubicFlatness(p0, p1, p2, p3) <= flatness) {
out += FlattenedCubicPoint(Vec3(p3), t1)
return
}
val p01 = midpoint(p0, p1)
val p12 = midpoint(p1, p2)
val p23 = midpoint(p2, p3)
val p012 = midpoint(p01, p12)
val p123 = midpoint(p12, p23)
val p0123 = midpoint(p012, p123)
val tm = (t0 + t1) * 0.5f
recurse(p0, p01, p012, p0123, t0, tm, depth + 1)
recurse(p0123, p123, p23, p3, tm, t1, depth + 1)
}
recurse(Vec3(a), Vec3(c1), Vec3(c2), Vec3(b), fromInstruction, toInstruction, 0)
return out
}
private fun weldFLineGraphVertices(
points: List<RawFLineGraphPoint>,
epsilon: Float
): IntArray {
if (points.isEmpty()) return IntArray(0)
val unionFind = GraphWeldUnionFind(points.size)
if (epsilon == 0f) {
val exact = mutableMapOf<ExactVec3Key, Int>()
points.forEachIndexed { index, point ->
val previous = exact.putIfAbsent(ExactVec3Key(point.position), index)
if (previous != null) unionFind.union(previous, index)
}
} else {
val cells = mutableMapOf<GridCell3, MutableList<Int>>()
val neighborOffsets = (-1..1).flatMap { dx ->
(-1..1).flatMap { dy ->
(-1..1).map { dz -> GridCell3(dx, dy, dz) }
}
}
points.forEachIndexed { index, point ->
val cell = GridCell3.of(point.position, epsilon)
for (offset in neighborOffsets) {
val bucket = cells[GridCell3(cell.x + offset.x, cell.y + offset.y, cell.z + offset.z)] ?: continue
for (candidate in bucket) {
if (distance3(point.position, points[candidate].position) <= epsilon.toDouble()) {
unionFind.union(candidate, index)
}
}
}
cells.getOrPut(cell) { mutableListOf() } += index
}
}
val rootToVertex = linkedMapOf<Int, Int>()
val rawToVertex = IntArray(points.size)
points.indices.forEach { index ->
val root = unionFind.find(index)
rawToVertex[index] = rootToVertex.getOrPut(root) { rootToVertex.size }
}
return rawToVertex
}
private data class GridCell3(val x: Int, val y: Int, val z: Int) {
companion object {
fun of(point: Vec3, cellSize: Float): GridCell3 = GridCell3(
floor(point.x / cellSize).toInt(),
floor(point.y / cellSize).toInt(),
floor(point.z / cellSize).toInt()
)
}
}
private data class ExactVec3Key(val x: Int, val y: Int, val z: Int) {
constructor(point: Vec3) : this(
normalizedFloatBits(point.x),
normalizedFloatBits(point.y),
normalizedFloatBits(point.z)
)
}
private fun normalizedFloatBits(value: Float): Int =
if (value == 0f) 0 else value.toRawBits()
private class GraphWeldUnionFind(size: Int) {
private val parent = IntArray(size) { it }
private val rank = IntArray(size)
fun find(value: Int): Int {
if (parent[value] != value) parent[value] = find(parent[value])
return parent[value]
}
fun union(a: Int, b: Int) {
val rootA = find(a)
val rootB = find(b)
if (rootA == rootB) return
when {
rank[rootA] < rank[rootB] -> parent[rootA] = rootB
rank[rootA] > rank[rootB] -> parent[rootB] = rootA
else -> {
parent[rootB] = rootA
rank[rootA]++
}
}
}
}
private fun midpoint(a: Vec3, b: Vec3): Vec3 =
Vec3(
(a.x + b.x) * 0.5f,
(a.y + b.y) * 0.5f,
(a.z + b.z) * 0.5f
)
private fun cubicFlatness(a: Vec3, c1: Vec3, c2: Vec3, b: Vec3): Float =
sqrt(max(pointSegmentDistanceSquared(c1, a, b), pointSegmentDistanceSquared(c2, a, b))).toFloat()
private fun pointSegmentDistanceSquared(p: Vec3, a: Vec3, b: Vec3): Double {
val abx = b.x.toDouble() - a.x.toDouble()
val aby = b.y.toDouble() - a.y.toDouble()
val abz = b.z.toDouble() - a.z.toDouble()
val apx = p.x.toDouble() - a.x.toDouble()
val apy = p.y.toDouble() - a.y.toDouble()
val apz = p.z.toDouble() - a.z.toDouble()
val ab2 = abx * abx + aby * aby + abz * abz
if (ab2 <= 0.0) return apx * apx + apy * apy + apz * apz
val t = ((apx * abx + apy * aby + apz * abz) / ab2).coerceIn(0.0, 1.0)
val qx = a.x.toDouble() + t * abx
val qy = a.y.toDouble() + t * aby
val qz = a.z.toDouble() + t * abz
val dx = p.x.toDouble() - qx
val dy = p.y.toDouble() - qy
val dz = p.z.toDouble() - qz
return dx * dx + dy * dy + dz * dz
}
private fun distance3(a: Vec3, b: Vec3): Double {
val dx = a.x.toDouble() - b.x.toDouble()
val dy = a.y.toDouble() - b.y.toDouble()
val dz = a.z.toDouble() - b.z.toDouble()
return sqrt(dx * dx + dy * dy + dz * dz)
}
private fun flineGraphEdgeKey(a: Int, b: Int): Long {
val lo = minOf(a, b).toLong() and 0xffffffffL
val hi = maxOf(a, b).toLong() and 0xffffffffL
return (lo shl 32) or hi
}