Skip to content

FLineThickening.kt

src/jvmMain/kotlin/afield/geometry/path/FLineThickening.kt · 570 lines

Turning a line into an outline: offsets, ribbons and buffers of an FLine, at fixed or varying width.

byRibboning and byVariableRibboning return an FLineRibbon with left and right edges and a closed shell. byJavaOffsetting uses Java2D strokes; byJtsBuffering, byJtsOffsetting and byJtsVariableBuffering use JTS, with FLineStrokeJoin and FLineStrokeCap for the corner and end style. byJtsSplitting separates a self-intersecting line into an FLineSplit.

package and imports — 82 lines, from line 10
package afield.geometry.path

import afield.ui.visualization.l
import afield.ui.visualization.lines

import afield.ai.learning.*
import afield.ai.retrieval.*
import afield.animation.interpolation.*
import afield.animation.keyframes.*
import afield.animation.motion.*
import afield.app.commands.*
import afield.app.diagnostics.*
import afield.audio.analysis.*
import afield.audio.synth.*
import afield.data.collections.*
import afield.data.formats.*
import afield.data.graph.*
import afield.data.sorting.*
import afield.document.context.*
import afield.document.graph.*
import afield.document.properties.*
import afield.document.workspace.*
import afield.geometry.mesh.*
import afield.geometry.path.*
import afield.geometry.spatial.*
import afield.graphics.color.*
import afield.graphics.interaction.*
import afield.graphics.render.*
import afield.graphics.shader.*
import afield.graphics.text.*
import afield.graphics.vulkan.buffers.*
import afield.io.osc.*
import afield.io.process.*
import afield.lang.lambda.*
import afield.lang.python.*
import afield.lang.remotejs.*
import afield.lang.stacksl.*
import afield.math.autodiff.*
import afield.math.control.*
import afield.math.estimation.*
import afield.math.functions.*
import afield.math.linalg.*
import afield.math.optimization.*
import afield.math.statistics.*
import afield.math.units.*
import afield.media.image.*
import afield.media.video.*
import afield.runtime.execution.*
import afield.runtime.languages.*
import afield.runtime.lifecycle.*
import afield.simulation.agents.*
import afield.simulation.automata.*
import afield.simulation.particles.*
import afield.study.build.*
import afield.study.evidence.*
import afield.study.search.*
import afield.study.source.*
import afield.study.ui.*
import afield.ui.controls.*
import afield.ui.output.*
import afield.ui.visualization.*
import afield.geometry.path.java2d.FLinesAndJavaShapes
import org.locationtech.jts.geom.Coordinate
import org.locationtech.jts.geom.Geometry
import org.locationtech.jts.geom.GeometryCollection
import org.locationtech.jts.geom.GeometryFactory
import org.locationtech.jts.geom.LineString
import org.locationtech.jts.geom.MultiLineString
import org.locationtech.jts.operation.buffer.BufferOp
import org.locationtech.jts.operation.buffer.BufferParameters
import org.locationtech.jts.operation.buffer.OffsetCurve
import org.locationtech.jts.operation.buffer.VariableBuffer
import org.locationtech.jts.operation.linemerge.LineMerger
import org.locationtech.jts.operation.polygonize.Polygonizer
import org.locationtech.jts.operation.union.UnaryUnionOp
import java.awt.BasicStroke
import java.awt.Shape
import java.awt.geom.FlatteningPathIterator
import java.awt.geom.GeneralPath
import java.awt.geom.PathIterator
import kotlin.math.abs
import kotlin.math.sqrt

enum class FLineStrokeJoin(internal val awtJoin: Int, internal val jtsJoin: Int)

Join style for Java2D/JTS offset and buffer operations.

enum class FLineStrokeJoin(internal val awtJoin: Int, internal val jtsJoin: Int) {
    MITER(BasicStroke.JOIN_MITER, BufferParameters.JOIN_MITRE),
    ROUND(BasicStroke.JOIN_ROUND, BufferParameters.JOIN_ROUND),
    BEVEL(BasicStroke.JOIN_BEVEL, BufferParameters.JOIN_BEVEL)
}

enum class FLineStrokeCap(internal val awtCap: Int, internal val jtsCap: Int)

Cap style for Java2D/JTS offset and buffer operations.

enum class FLineStrokeCap(internal val awtCap: Int, internal val jtsCap: Int) {
    BUTT(BasicStroke.CAP_BUTT, BufferParameters.CAP_FLAT),
    ROUND(BasicStroke.CAP_ROUND, BufferParameters.CAP_ROUND),
    SQUARE(BasicStroke.CAP_SQUARE, BufferParameters.CAP_SQUARE)
}

data class FLineRibbon

Ribbon result split into its left edge, right edge, and closed shell.

data class FLineRibbon(
    val left: FLine,
    val right: FLine,
    val shell: FLine
) {

override fun toString(): String

    override fun toString(): String = prettySummary(
        "FLineRibbon",
        "left" to left,
        "right" to right,
        "shell" to shell
    )
}

data class FLineSplit

JTS split result: largest outer loop, interior loops, and open line components.

data class FLineSplit(
    val outerLoop: FLine?,
    val innerLoops: List<FLine>,
    val lines: List<FLine>
) {

override fun toString(): String

    override fun toString(): String = prettySummary(
        "FLineSplit",
        "outerLoop" to outerLoop,
        "innerLoops" to innerLoops.size,
        "lines" to lines.size
    )
}

fun FLine.byBidirectionalOffsetting

Create left/right offset edges at equal distance from the centerline.

fun FLine.byBidirectionalOffsetting(
    distance: Float,
    samples: Int = 200,
    sampler: FLineSampler? = null
): Pair<FLine, FLine> {
    val d = abs(distance)
    val ribbon = offsetRibbon(
        samples = samples,
        sampler = sampler,
        leftDistance = { _, _ -> d },
        rightDistance = { _, _ -> d }
    )
    return ribbon.left to ribbon.right
}

fun FLine.byVariableThicknessOffsetting

Create left/right offset edges with independent distance functions.

fun FLine.byVariableThicknessOffsetting(
    leftDistance: ParameterFunction,
    rightDistance: ParameterFunction = leftDistance,
    samples: Int = 200,
    sampler: FLineSampler? = null
): Pair<FLine, FLine> {
    val ribbon = offsetRibbon(
        samples = samples,
        sampler = sampler,
        leftDistance = { t, p -> leftDistance.evaluate(t, p).coerceAtLeast(0f) },
        rightDistance = { t, p -> rightDistance.evaluate(t, p).coerceAtLeast(0f) }
    )
    return ribbon.left to ribbon.right
}

fun FLine.byRibboning

Build a constant-thickness ribbon polygon around this line.

thickness is full ribbon width.

fun FLine.byRibboning(
    thickness: Float,
    samples: Int = 200,
    sampler: FLineSampler? = null
): FLine {
    val half = abs(thickness) * 0.5f
    return offsetRibbon(
        samples = samples,
        sampler = sampler,
        leftDistance = { _, _ -> half },
        rightDistance = { _, _ -> half }
    ).shell
}

fun FLine.byVariableRibboning

Build a variable-thickness ribbon polygon around this line.

thicknessFunc returns full ribbon width at each sample.

fun FLine.byVariableRibboning(
    thicknessFunc: ParameterFunction,
    samples: Int = 200,
    sampler: FLineSampler? = null
): FLine {
    return offsetRibbon(
        samples = samples,
        sampler = sampler,
        leftDistance = { t, p -> (thicknessFunc.evaluate(t, p) * 0.5f).coerceAtLeast(0f) },
        rightDistance = { t, p -> (thicknessFunc.evaluate(t, p) * 0.5f).coerceAtLeast(0f) }
    ).shell
}

fun FLine.byJavaOffsetting

Java2D stroke-based thickening using BasicStroke; good quality for 2D cases.

strokeWidth is full stroke width.

fun FLine.byJavaOffsetting(
    strokeWidth: Float,
    cap: FLineStrokeCap = FLineStrokeCap.ROUND,
    join: FLineStrokeJoin = FLineStrokeJoin.ROUND,
    miterLimit: Float = 10f
): FLine {
    val raw = FLinesAndJavaShapes.flineToJavaShape_notThickened(this)
    val stroked = BasicStroke(
        strokeWidth.coerceAtLeast(0f),
        cap.awtCap,
        join.awtJoin,
        miterLimit.coerceAtLeast(1f)
    ).createStrokedShape(raw)
    return FLinesAndJavaShapes.javaShapeToFLine(stroked)
}

fun FLine.byJtsBuffering

JTS buffer for this line (2D), returning filled geometry as an FLine.

fun FLine.byJtsBuffering(
    distance: Double,
    flatness: Double = 0.5,
    quadrantSegments: Int = 8,
    join: FLineStrokeJoin = FLineStrokeJoin.ROUND,
    cap: FLineStrokeCap = FLineStrokeCap.ROUND,
    miterLimit: Double = 5.0
): FLine {
    val splitter = JtsCurveSplitter(flatness = flatness)
    val multi = splitter.toMultiLineString(this) ?: return FLine()
    val params = BufferParameters().apply {
        setQuadrantSegments(quadrantSegments.coerceAtLeast(1))
        setJoinStyle(join.jtsJoin)
        setEndCapStyle(cap.jtsCap)
        setMitreLimit(miterLimit.coerceAtLeast(1.0))
    }
    val buffered = BufferOp.bufferOp(multi, distance, params)
    return splitter.geometryToFLine(buffered)
}

fun FLine.byJtsOffsetting

JTS offset-curve extraction (2D).

fun FLine.byJtsOffsetting(
    distance: Double,
    flatness: Double = 0.5
): FLine {
    val splitter = JtsCurveSplitter(flatness = flatness)
    val multi = splitter.toMultiLineString(this) ?: return FLine()
    val offset = OffsetCurve.getCurve(multi, distance)
    return splitter.geometryToFLine(offset)
}

fun FLine.byJtsVariableBuffering

JTS variable buffer sampled from this curve.

fun FLine.byJtsVariableBuffering(
    numSamples: Int = 128,
    distance: (Double) -> Double,
    sampler: FLineSampler? = null,
    flatness: Double = 0.5
): FLine {
    val pieces = this.pieces().filter { it.nodes.size > 1 }.ifEmpty {
        if (this.nodes.size > 1) listOf(this) else emptyList()
    }
    if (pieces.isEmpty()) return FLine()

    val geoms = mutableListOf<Geometry>()
    for (piece in pieces) {
        val sampled = dedupeSeries2D(piece.sampleSeries(numSamples.coerceAtLeast(2), sampler = sampler))
        if (sampled.size < 2) continue
        val coords = sampled.map { Coordinate(it.point.x.toDouble(), it.point.y.toDouble()) }.toTypedArray()
        val distances = DoubleArray(sampled.size) { i -> distance(sampled[i].t.toDouble()) }
        val g = VariableBuffer(JtsCurveSplitter.GF.createLineString(coords), distances).result
        if (!g.isEmpty) geoms += g
    }

    if (geoms.isEmpty()) return FLine()
    val merged = UnaryUnionOp.union(geoms)
    return JtsCurveSplitter(flatness = flatness).geometryToFLine(merged)
}

fun FLine.byJtsSplitting(flatness: Double = 0.5): FLineSplit

Split potentially self-crossing geometry into loops and open lines using JTS noding/polygonization.

body — 88 lines, from line 305
fun FLine.byJtsSplitting(flatness: Double = 0.5): FLineSplit {
    return JtsCurveSplitter(flatness = flatness).split(this)
}

private fun FLine.offsetRibbon(
    samples: Int,
    sampler: FLineSampler?,
    leftDistance: (Float, Vec3) -> Float,
    rightDistance: (Float, Vec3) -> Float
): FLineRibbon {
    val series = this.sampleSeries(samples.coerceAtLeast(2), sampler = sampler)
    if (series.size < 2) return FLineRibbon(FLine(), FLine(), FLine())

    val normals = planarNormals(series)
    val left = ArrayList<Vec3>(series.size)
    val right = ArrayList<Vec3>(series.size)

    for (i in series.indices) {
        val s = series[i]
        val n = normals[i]
        val dl = leftDistance(s.t, s.point).coerceAtLeast(0f)
        val dr = rightDistance(s.t, s.point).coerceAtLeast(0f)
        left += Vec3(s.point).add(Vec3(n).mul(dl))
        right += Vec3(s.point).sub(Vec3(n).mul(dr))
    }

    return FLineRibbon(
        left = toPolyline(left),
        right = toPolyline(right),
        shell = toRibbonShell(left, right)
    )
}

private fun toPolyline(points: List<Vec3>): FLine {
    val f = FLine()
    if (points.isEmpty()) return f
    f.moveTo(points.first())
    for (i in 1 until points.size) f.lineTo(points[i])
    return f
}

private fun toRibbonShell(left: List<Vec3>, right: List<Vec3>): FLine {
    val f = FLine()
    if (left.isEmpty() || right.isEmpty()) return f

    f.moveTo(left.first())
    for (i in 1 until left.size) f.lineTo(left[i])
    for (i in right.lastIndex downTo 0) f.lineTo(right[i])
    f.close()
    return f
}

private fun planarNormals(series: List<FLineSample>): List<Vec3> {
    if (series.isEmpty()) return emptyList()
    val normals = MutableList(series.size) { vec(0f, 1f, 0f) }
    var lastNormal = vec(0f, 1f, 0f)
    val n = series.lastIndex
    for (i in series.indices) {
        val pPrev = series[(i - 1).coerceAtLeast(0)].point
        val pNext = series[(i + 1).coerceAtMost(n)].point
        val tx = pNext.x - pPrev.x
        val ty = pNext.y - pPrev.y
        val len = sqrt(tx * tx + ty * ty)
        val candidate = if (len > 1e-6f) {
            vec(-ty / len, tx / len, 0f)
        } else {
            Vec3(lastNormal)
        }
        if (i > 0 && candidate.dot(normals[i - 1]) < 0f) candidate.mul(-1f)
        normals[i] = candidate
        lastNormal = Vec3(candidate)
    }
    return normals
}

private fun dedupeSeries2D(series: List<FLineSample>, eps: Float = 1e-4f): List<FLineSample> {
    if (series.isEmpty()) return emptyList()
    val out = ArrayList<FLineSample>(series.size)
    var last: FLineSample? = null
    for (s in series) {
        val l = last
        if (l == null || (abs(s.point.x - l.point.x) > eps || abs(s.point.y - l.point.y) > eps)) {
            out += s
            last = s
        }
    }
    return out
}

class JtsCurveSplitter(private val flatness: Double = 0.5)

JTS helper for splitting/flattening/shape conversion.

companion object

internal val GF = GeometryFactory()

        private const val EPS = 1e-6
    }

fun split(line: FLine): FLineSplit

body — 46 lines, from line 403
    fun split(line: FLine): FLineSplit {
        val multi = toMultiLineString(line) ?: return FLineSplit(null, emptyList(), emptyList())
        val noded = UnaryUnionOp.union(multi)
        val merged = mergeLineStrings(noded)

        val closedLoops = merged.filter { it.coordinates.size > 1 && it.coordinates.first().equals2D(it.coordinates.last()) }
        val openCurves = merged.filterNot { it.coordinates.size > 1 && it.coordinates.first().equals2D(it.coordinates.last()) }

        val polygonizer = Polygonizer(true)
        merged.forEach { polygonizer.add(it) }
        @Suppress("UNCHECKED_CAST")
        val polygons = polygonizer.polygons as Collection<org.locationtech.jts.geom.Polygon>

        var outerLoop: FLine? = null
        val innerLoops = mutableListOf<FLine>()
        if (polygons.isNotEmpty()) {
            val sorted = polygons.sortedByDescending { it.area }
            val outer = sorted.first()
            outerLoop = ringToFLine(outer.exteriorRing as LineString, close = true)

            // Holes in the largest polygon are explicit inner loops.
            for (i in 0 until outer.numInteriorRing) {
                innerLoops += ringToFLine(outer.getInteriorRingN(i) as LineString, close = true)
            }
            // Remaining polygons become additional inner loops.
            for (i in 1 until sorted.size) {
                innerLoops += ringToFLine(sorted[i].exteriorRing as LineString, close = true)
                for (h in 0 until sorted[i].numInteriorRing) {
                    innerLoops += ringToFLine(sorted[i].getInteriorRingN(h) as LineString, close = true)
                }
            }
        } else if (closedLoops.isNotEmpty()) {
            // If polygonization fails, still return the largest closed loop.
            val biggest = closedLoops.maxByOrNull { it.length }
            outerLoop = biggest?.let { ringToFLine(it, close = true) }
            for (loop in closedLoops) {
                if (loop !== biggest) innerLoops += ringToFLine(loop, close = true)
            }
        }

        val lines = openCurves
            .filter { it.length > EPS }
            .map { lineToFLine(it) }

        return FLineSplit(outerLoop = outerLoop, innerLoops = innerLoops, lines = lines)
    }

internal fun toMultiLineString(line: FLine): MultiLineString?

    internal fun toMultiLineString(line: FLine): MultiLineString? {
        val segments = flattenShape(FLinesAndJavaShapes.flineToJavaShape_notThickened(line))
        if (segments.isEmpty()) return null
        return GF.createMultiLineString(segments.toTypedArray())
    }

internal fun geometryToFLine(geom: Geometry): FLine

    internal fun geometryToFLine(geom: Geometry): FLine {
        return FLinesAndJavaShapes.javaShapeToFLine(geometryToGeneralPath(geom))
    }

internal fun geometryToGeneralPath(geom: Geometry): GeneralPath

body — 110 lines, from line 460
    internal fun geometryToGeneralPath(geom: Geometry): GeneralPath {
        val gp = GeneralPath()

        fun appendLineString(ls: LineString, close: Boolean) {
            val coords = ls.coordinates
            if (coords.isEmpty()) return
            gp.moveTo(coords[0].x.toFloat(), coords[0].y.toFloat())
            for (i in 1 until coords.size) gp.lineTo(coords[i].x.toFloat(), coords[i].y.toFloat())
            if (close) gp.closePath()
        }

        fun walk(g: Geometry) {
            when (g) {
                is org.locationtech.jts.geom.Polygon -> {
                    appendLineString(g.exteriorRing as LineString, close = true)
                    for (i in 0 until g.numInteriorRing) {
                        appendLineString(g.getInteriorRingN(i) as LineString, close = true)
                    }
                }

                is LineString -> appendLineString(g, close = false)
                is GeometryCollection -> for (i in 0 until g.numGeometries) walk(g.getGeometryN(i))
            }
        }

        walk(geom)
        return gp
    }

    private fun mergeLineStrings(geometry: Geometry): List<LineString> {
        val merger = LineMerger()

        fun addLines(g: Geometry) {
            when (g) {
                is LineString -> merger.add(g)
                is GeometryCollection -> for (i in 0 until g.numGeometries) addLines(g.getGeometryN(i))
            }
        }

        addLines(geometry)
        @Suppress("UNCHECKED_CAST")
        return (merger.mergedLineStrings as Collection<LineString>).toList()
    }

    private fun flattenShape(shape: Shape): List<LineString> {
        val pit = FlatteningPathIterator(shape.getPathIterator(null), flatness)
        val coords = DoubleArray(6)
        val out = mutableListOf<LineString>()
        val current = mutableListOf<Coordinate>()
        var moveStart: Coordinate? = null

        fun appendIfNotDuplicate(c: Coordinate) {
            val last = current.lastOrNull()
            if (last == null || !last.equals2D(c)) current += c
        }

        fun flush() {
            if (current.size > 1) {
                out += GF.createLineString(current.toTypedArray())
            }
            current.clear()
            moveStart = null
        }

        while (!pit.isDone) {
            when (pit.currentSegment(coords)) {
                PathIterator.SEG_MOVETO -> {
                    flush()
                    val c = Coordinate(coords[0], coords[1])
                    current += c
                    moveStart = c
                }

                PathIterator.SEG_LINETO -> appendIfNotDuplicate(Coordinate(coords[0], coords[1]))
                PathIterator.SEG_CLOSE -> {
                    val start = moveStart
                    if (start != null) appendIfNotDuplicate(Coordinate(start.x, start.y))
                    flush()
                }
            }
            pit.next()
        }
        flush()
        return out
    }

    private fun lineToFLine(line: LineString): FLine {
        val f = FLine()
        val coords = line.coordinates
        if (coords.isEmpty()) return f
        f.moveTo(coords[0].x.toFloat(), coords[0].y.toFloat())
        for (i in 1 until coords.size) {
            f.lineTo(coords[i].x.toFloat(), coords[i].y.toFloat())
        }
        return f
    }

    private fun ringToFLine(ring: LineString, close: Boolean): FLine {
        val f = FLine()
        val coords = ring.coordinates
        if (coords.isEmpty()) return f
        f.moveTo(coords[0].x.toFloat(), coords[0].y.toFloat())
        val last = if (close && coords.size > 1 && coords.first().equals2D(coords.last())) coords.lastIndex - 1 else coords.lastIndex
        for (i in 1..last) {
            f.lineTo(coords[i].x.toFloat(), coords[i].y.toFloat())
        }
        if (close) f.close()
        return f
    }
}