Skip to content

FLine examples (Field graphics)

FLine is Field’s core geometry container type: it’s basically an ordered list of drawing instructions (moveTo, lineTo, cubicTo, close) plus style properties (color, thickness, filled, stroked, pointed, and so on). This isn’t a new idea — it’s essentially a “path” like you’ll find in SVG, PDF, and PostScript. Unlike those formats, Field stores points in 3D when that makes sense.

The examples below run inside a box in Field. Many use lines(...) to show what they've drawn; others use a 3D viewer or build on an earlier example.

If you're new to FLines, start with seeing one, then try building a path and changing its style. There is much more here to return to when you need it. To draw a stroke by hand and have code read it, continue to Drawing and Notation.

Every example on this page is also in the document below; click a box to read its code.

Some syntax, before we get started

In Field sketches you’ll often see fline { ... }. It’s a small helper around Kotlin’s apply { ... }: create an FLine, run setup code on it, and return the line. A comparison:

:::static
// 1) Preferred in Field: build inside `fline { ... }`.
val f1 = fline {
    moveTo(20, 20)
    lineTo(120, 20)
    lineTo(120, 120)
    close()

    filled = true
    stroked = true
    thickness = 1.0
    color = vec(1.0, 1.0, 1.0, 0.25)
}

// 2) The explicit style: make a var and reuse it.
val f3 = FLine()
f3.moveTo(20, 20)
f3.lineTo(120, 20)
f3.lineTo(120, 120)
f3.close()
f3.filled = true
f3.stroked = true
f3.thickness = 1.0
f3.color = vec(1.0, 1.0, 1.0, 0.25)

// Show they really are the same geometry (offset so you can see them).
lines(f1 + vec(0, 0, 0), f3 + vec(180, 0, 0), w = 320.0, h = 170.0, fitToSize = true)

fline_examples-1

Seeing an FLine: static and editable viewers

There are two main ways to inspect FLine geometry in Field:

  • lines(...) renders an SVG-style 2D view in an output area.
  • threeLines(...) renders the same geometry in the Three.js viewer.

Both accept editable = true, which lets you change the underlying FLine in the view.

2D output: lines(...)

Use plain lines(...) when you want the usual fast 2D display: diagrams, plotted curves, path debugging, anything that lives in screen space.

:::static
fun Box.lines(
    vararg m: FLine,
    channel: String = "lines",
    w: Double = -1.0,
    h: Double = -1.0,
    fitToSize: Boolean = false,
    editable: Boolean = false,
)
:::static
val wave = fline {
    moveTo(40, 170)
    cubicTo(120, 40, 180, 40, 250, 170)
    cubicTo(320, 300, 380, 300, 460, 170)
    thickness = 2.0
    color = vec(0.98, 0.88, 0.28, 1.0)
}

val fill = fline {
    moveTo(40, 170)
    cubicTo(120, 40, 180, 40, 250, 170)
    cubicTo(320, 300, 380, 300, 460, 170)
    lineTo(460, 300)
    lineTo(40, 300)
    close()
    filled = true
    stroked = false
    color = vec(0.32, 0.82, 1.0, 0.18)
}

val nodes = wave.copy().apply {
    stroked = false
    pointed = true
    pointSize = 4.0
    color = vec(1.0, 0.5, 0.25, 1.0)
}

lines(fill, wave, nodes, w = 520.0, h = 320.0, fitToSize = true)

fline_examples-3

If you switch on editable = true, the same output becomes an editor. You can click points, edges, or filled regions, drag a selection, marquee-select, and press e to hide or reveal the edit UI.

:::static
val petal = fline {
    moveTo(0, -120)
    cubicTo(90, -100, 140, -20, 0, 120)
    cubicTo(-140, -20, -90, -100, 0, -120)
    close()
    filled = true
    stroked = true
    thickness = 1.0
    color = vec(0.95, 0.42, 0.6, 0.34)
}

val stem = fline {
    moveTo(0, 120)
    cubicTo(10, 180, -20, 230, 0, 300)
    thickness = 1.0
    color = vec(0.45, 0.95, 0.62, 1.0)
}

lines(petal, stem, w = 420.0, h = 420.0, fitToSize = true, editable = true)

fline_examples-4

The editor changes the FLine you passed in. Use it as a geometry sketchpad, a control-shape editor, or a quick way to bring a hand-tuned curve back into your code.

3D output: threeLines(...)

Use threeLines(...) when depth matters or when you want to orbit around the geometry instead of looking at a projected SVG. It draws ordinary 2D paths too, and takes 3D points directly.

:::static
fun Box.threeLines(
    lines: Collection<FLine>,
    channel: String = "debug",
    name: String = "threejs",
    height: Int = 420,
    samplesPerCubic: Int = 48,
    autoFrame: Boolean = true,
    editable: Boolean = false,
): ThreeGeometryViewState
:::static
val rings =
    (0 until 14).map { i ->
        val radius = 60.0 + i * 11.0
        val z = -90.0 + i * 14.0
        fline {
            val steps = 80
            breakNext()
            for (s in 0..steps) {
                val t = s / steps.toDouble() * Math.PI * 2.0
                val p = vec(Math.cos(t) * radius, Math.sin(t) * radius, z)
                lineTo(p)
            }
            close()
            color = vec(0.35 + i / 20.0, 0.85, 1.0 - i / 18.0, 0.7)
            thickness = 1.8
        }
    }

threeLines(rings, channel = "rings3d", height = 430, editable = false)

Static Three.js FLine viewer

If you pass editable = true, the Three.js viewer grows an edit mode alongside camera control. In camera mode you orbit as usual. In edit mode you can select and drag points, edges, and faces; press e to hide or reveal the edit chrome without leaving the view.

:::static
fun ribbon(z: Double, phase: Double, color: Vec4): FLine = fline {
    val steps = 42
    breakNext()
    for (i in 0..steps) {
        val t = i / steps.toDouble()
        val x = -220.0 + t * 440.0
        val y = Math.sin(t * Math.PI * 2.0 + phase) * 70.0
        val edge = Math.cos(t * Math.PI * 4.0 + phase) * 24.0
        lineTo(x, y - edge, z)
    }
    for (i in steps downTo 0) {
        val t = i / steps.toDouble()
        val x = -220.0 + t * 440.0
        val y = Math.sin(t * Math.PI * 2.0 + phase) * 70.0
        val edge = Math.cos(t * Math.PI * 4.0 + phase) * 24.0
        lineTo(x, y + edge, z)
    }
    close()
    filled = true
    stroked = true
    thickness = 1.4
    this.color = color
}

val ribbons = [
    ribbon(-50.0, 0.0, vec(1.0, 0.6, 0.24, 0.32)),
    ribbon(0.0, 0.8, vec(0.4, 0.92, 1.0, 0.28)),
    ribbon(55.0, 1.6, vec(0.72, 0.58, 1.0, 0.28)),
]

threeLines(ribbons, channel = "editable3d", height = 430, editable = true)

Editable Three.js FLine viewer

Use lines(...) for 2D inspection and editing, and threeLines(...) when you want to orbit or work with geometry in depth. The controls are similar across both views.

The 3D output area as an instrument: threeInspect(...)

How far apart are two parts? What does the assembly look like in section, or with its pieces pulled apart? threeInspect(...) adds measurement, section and explode controls to the threeLines(...) view. You can adjust them live without rerunning the box.

:::static
fun Box.threeInspect(
    lines: Collection<FLine>,
    channel: String = "debug",
    name: String = "inspector",
    height: Int = 520,
    samplesPerCubic: Int = 48,
    autoFrame: Boolean = true,
    groupBy: (FLine) -> String = { it.label ?: "" },
    explode: ThreeExplode = ThreeExplode(),
    section: ThreeSection = ThreeSection(),
    measurements: List<ThreeMeasurement> = emptyList(),
    units: String = "",
    decimals: Int = 2,
    pickToMeasure: Boolean = true,
): ThreeInspectorView

groupBy decides what counts as one part of the assembly, and it defaults to the FLine's label — so a figure that already labels its pieces explodes sensibly with no extra work.

Measuring

To put a dimension on the drawing, pass measurements. Each ThreeMeasurement names two elements (a face, a segment) and draws the shortest distance between them as a number with a line to each element, so it turns with the figure. The example below dimensions a wall's thickness and a conduit's clearance.

:::static
// A wall detail in millimetres, plus a conduit run threaded through it.
fun skin(name: String, x: Double, c: Vec4) = fline {
    moveTo(x, 0.0, 0.0)
    lineTo(x, 1600.0, 0.0)
    lineTo(x, 1600.0, 1800.0)
    lineTo(x, 0.0, 1800.0)
    close()
    label = name
    filled = true
    stroked = true
    thickness = 1.4
    color = c
}

val wall = [
    skin("sheathing", 0.0, vec(0.36, 0.46, 0.58, 0.5)),
    skin("studs", 90.0, vec(0.85, 0.62, 0.32, 0.28)),
    skin("services", 140.0, vec(0.35, 0.72, 0.86, 0.28)),
    skin("lining", 152.0, vec(0.90, 0.92, 0.96, 0.4)),
]

val conduit = fline {
    moveTo(120.0, 1300.0, 1500.0)
    lineTo(120.0, 500.0, 1500.0)
    label = "conduit"
    stroked = true
    thickness = 5.0
    color = vec(1.0, 0.45, 0.30, 1.0)
}

threeInspect(
    wall + conduit,
    channel = "wall-measured",
    height = 520,
    units = "mm",
    measurements = [
        // the build-up: sheathing face to lining face, which is 152 by construction
        ThreeMeasurement(
            ThreeMeasureElement.face(wall[0]),
            ThreeMeasureElement.face(wall[3]),
            "build-up ",
        ),
        // and the clearance from the conduit to the inside face of the lining,
        // which is 152 - 120 = 32 and is nowhere written down in the figure
        ThreeMeasurement(
            ThreeMeasureElement.segment(120.0, 1300.0, 1500.0, 120.0, 500.0, 1500.0),
            ThreeMeasureElement.face(wall[3]),
            "clear ",
        ),
    ],
)

Two dimensions drawn in the Three.js inspector

The distance is closest approach, not centre to centre, which on a panel drawing is a different number. It is measured on the unexploded geometry, so the explode slider does not change it; scrub the explode back to zero to read a dimension against what it dimensions. You can also click the ruler in the viewer's toolbar and pick two elements yourself.

Exploding

Exploding pulls the parts of an assembly apart with a slider, so you can see the layers hidden inside it. ThreeExplode has two modes:

  • radial moves each part away from the figure's centre. It suits an irregular assembly, like a routed conduit run.
  • axis moves the parts along one direction, evenly spaced in their order along it rather than in proportion to their distance. A layered wall needs this: with proportional spacing, four skins 12 mm apart stay in a clump while the far one leaves the frame.

The spacing is centred on the middle part, so the assembly stays in view as the slider moves.

:::static
fun skin(name: String, x: Double, c: Vec4) = fline {
    moveTo(x, 0.0, 0.0)
    lineTo(x, 2400.0, 0.0)
    lineTo(x, 2400.0, 2600.0)
    lineTo(x, 0.0, 2600.0)
    close()
    label = name
    filled = true
    stroked = true
    thickness = 1.4
    color = c
}

val wall = [
    skin("sheathing", 0.0, vec(0.36, 0.46, 0.58, 0.75)),
    skin("studs", 90.0, vec(0.85, 0.62, 0.32, 0.6)),
    skin("services", 140.0, vec(0.35, 0.72, 0.86, 0.6)),
    skin("lining", 152.0, vec(0.90, 0.92, 0.96, 0.7)),
]

val view = threeInspect(
    wall,
    channel = "wall-exploded",
    height = 520,
    units = "mm",
    explode = ThreeExplode(
        amount = 200.0,
        mode = "axis",
        axis = floatArrayOf(1f, 0f, 0f),
        sliderMax = 800.0,
    ),
)

/*
`amount` is a parameter, not a literal you retype: the view can be driven again.
view.explode(800.0)
view.explodeAlong(0.0, 0.0, 1.0, 600.0)
*/

A wall build-up exploded along its own axis

Sectioning

ThreeSection cuts the figure at a fraction of its own bounds measured along the section normal, so 0.42 means the same thing on a 3 m wall and a 30 mm bracket and the slider is meaningful without knowing the model's scale.

The viewer draws the cut outline brightly over the clipped figure, finding where the plane crosses each filled triangle and line segment. Hiding the front half reveals the inside; the outline shows where the section passes through it.

:::static
fun skin(name: String, x: Double, c: Vec4) = fline {
    moveTo(x, 0.0, 0.0)
    lineTo(x, 2400.0, 0.0)
    lineTo(x, 2400.0, 2600.0)
    lineTo(x, 0.0, 2600.0)
    close()
    label = name
    filled = true
    stroked = true
    thickness = 1.4
    color = c
}

val wall = [
    skin("sheathing", 0.0, vec(0.36, 0.46, 0.58, 0.7)),
    skin("studs", 90.0, vec(0.85, 0.62, 0.32, 0.5)),
    skin("services", 140.0, vec(0.35, 0.72, 0.86, 0.5)),
    skin("lining", 152.0, vec(0.90, 0.92, 0.96, 0.6)),
]

val conduit =
    (0 until 5).map { i ->
        fline {
            val y = 300.0 + i * 450.0
            moveTo(120.0, y, 0.0)
            lineTo(120.0, y, 2600.0)
            label = "conduit $i"
            stroked = true
            thickness = 4.0
            color = vec(1.0, 0.45, 0.30, 1.0)
        }
    }

threeInspect(
    wall + conduit,
    channel = "wall-sectioned",
    height = 520,
    units = "mm",
    section = ThreeSection(
        enabled = true,
        normal = floatArrayOf(0f, 0f, 1f),
        fraction = 0.62,
    ),
)

A section through the wall, with the cut outline drawn over it

The status line reports the total length of the cut and where the plane sits, both in the figure's own units — cut 9600 mm above is exactly four 2400-wide skins.

The section clips everything: fills, strokes and points.

A complete example

This box draws a wall detail with four skins and five conduit runs, two dimensions, and all three controls live. Run it and drag the explode slider. The same box is the three_inspector_wall starting point on the welcome screen.

:::static
/*
The three.js output area as an INSTRUMENT.

Run this box. A wall detail appears -- four skins and five conduit runs -- and
the viewer's toolbar has three things a 3D preview never had before:

  [ruler]    click it, then click two things in the scene. The CLOSEST APPROACH
             between them is drawn as a dimension, in millimetres. Not centroid
             to centroid: on a panel drawing those are different numbers, and
             only one of them is the one you would take to a saw. Click a third
             time to start over.

  [section]  turn it on and a second slider appears. The fills clip -- and the
             CUT is drawn over them in cyan, with a dot wherever a conduit
             crosses the plane. The status line, top right, reports the total
             length of the cut and where the plane sits. Hiding the front half
             shows you the inside of a shell; the cut line is the section.

  explode    drag it and the four skins come apart, spaced evenly by their
             ORDER through the wall rather than by how far apart they actually
             are -- 90, 50 and 12 mm, which under a distance-proportional
             explode would leave three of them in a clump. Drag it back to 0
             and the geometry is exactly what the server sent, bit for bit.

The two dimensions below are authored from Kotlin, so the figure carries them
from the moment it is drawn. `ThreeMeasureElement.face(line)` names a whole
panel; `.segment(...)` and `.point(...)` name the other two shapes a hit can
resolve to, which is not a coincidence -- a measurement you pick in the viewer
produces exactly these.
*/

fun skin(name: String, x: Double, c: Vec4) = fline {
    moveTo(x, 0.0, 0.0)
    lineTo(x, 1600.0, 0.0)
    lineTo(x, 1600.0, 1800.0)
    lineTo(x, 0.0, 1800.0)
    close()
    label = name
    filled = true
    stroked = true
    thickness = 1.4
    color = c
}

// x is the depth through the wall, in millimetres. 152 mm of build-up.
val wall = [
    skin("sheathing", 0.0, vec(0.36, 0.46, 0.58, 0.5)),
    skin("studs", 90.0, vec(0.85, 0.62, 0.32, 0.28)),
    skin("services", 140.0, vec(0.35, 0.72, 0.86, 0.28)),
    skin("lining", 152.0, vec(0.90, 0.92, 0.96, 0.4)),
]

val conduits =
    (0 until 5).map { i ->
        fline {
            val y = 200.0 + i * 320.0
            moveTo(120.0, y, 80.0)
            lineTo(120.0, y, 1720.0)
            label = "conduit $i"
            stroked = true
            thickness = 4.0
            color = vec(1.0, 0.45, 0.30, 1.0)
        }
    }

/*
`groupBy` decides what counts as one PART of the assembly. It defaults to the
FLine's label, which is why the five conduits fan out separately here; group
them together with `groupBy = { if (it.label?.startsWith("conduit") == true) "conduits" else it.label ?: "" }`.
*/
val view = threeInspect(
    wall + conduits,
    channel = "wall",
    height = 560,
    units = "mm",
    explode = ThreeExplode(mode = "axis", axis = floatArrayOf(1f, 0f, 0f), sliderMax = 1400.0),
    measurements = [
        // the build-up: 152 by construction, and the figure now says so
        ThreeMeasurement(
            ThreeMeasureElement.face(wall[0]),
            ThreeMeasureElement.face(wall[3]),
            "build-up ",
        ),
        // the clearance from the second conduit to the inside of the lining: 152 - 120 = 32,
        // which is written down nowhere in the geometry
        ThreeMeasurement(
            ThreeMeasureElement.segment(120.0, 520.0, 80.0, 120.0, 520.0, 1720.0),
            ThreeMeasureElement.face(wall[3]),
            "clear ",
        ),
    ],
)

/*
And because the explosion is a PARAMETER rather than a literal you retype, the
box can drive it. Uncomment any of these and re-run -- no rebuild of the
geometry, just a new question:

view.explode(700.0)                                  // pull it apart
view.explodeAlong(0.0, 0.0, 1.0, 500.0)              // ...along z instead
view.section(0.0, 0.0, 1.0, fraction = 0.55)         // cut it
view.clearSection()
view.measureFaces(wall[1], wall[2], "cavity ")       // one more dimension
view.clearMeasurements()
*/

println("wall: ${view.inspector().groups.size} assembly groups, ${view.inspector().measurements.size} dimensions")
println("drag the explode slider; press the ruler and click two things")

fline_examples-12

Callbacks: onClick and hoverColor

FLine can also be interactive without being editable. If you set onClick, Field registers a callback for that line. If you set hoverColor, the line brightens or changes color when the pointer moves over it.

The callback receives an FLineIntersection, a record of what was clicked: which line, which part of it (a node, a segment, or the inside of a closed shape), and where. Most callbacks only need closestVertexIndex or label:

:::static
enum class FLineHitKind {
    POINT,
    EDGE,
    FACE,
    UNKNOWN,
}

data class FLineIntersection(
    val closestVertexIndex: Int,
    val position: Vec3,
    val kind: FLineHitKind = FLineHitKind.UNKNOWN,
    val lineIndex: Int = -1, // which line of the ones you drew
    val label: String? = null, // that line's FLine.label
    val nodeIndex: Int = -1, // the node that IS the element
    val subPathIndex: Int = -1,
    val elementIndex: Int = -1, // edge 0, 1, 2... / face 0, 1...
    val u: Double = Double.NaN, // along the edge, or across the face
    val v: Double = Double.NaN, // up the face
    val onElement: Vec3? = null, // the point on the element, z included
)

enum class FLineFaceCoordinates {
    NONE,
    BILINEAR,
    BOUNDING_BOX,
}

var FLine.onClick: ((FLineIntersection) -> Unit)?
var FLine.hoverColor: Vec4?
var FLine.label: String?

This works in both the 2D lines(...) renderer and the Three.js threeLines(...) renderer.

2D callbacks

This draws a triangle and a closed curve. Hover over either to see it brighten, and click it to print its name and the nearest vertex below the drawing.

:::static
val status: (String) -> Unit = { message ->
    markdown("callback-log", "2D callback: ${message.md}")
}

status("click a shape")

val triangle = fline {
    moveTo(70, 250)
    lineTo(200, 40)
    lineTo(330, 250)
    close()
    filled = true
    stroked = true
    thickness = 1.5
    color = vec(1.0, 0.63, 0.24, 0.30)
    hoverColor = vec(1.0, 0.82, 0.42, 0.92)
    onClick = { hit -> status("triangle @ ${hit.closestVertexIndex}") }
}

val blob = fline {
    moveTo(420, 150)
    cubicTo(470, 30, 620, 40, 650, 150)
    cubicTo(620, 290, 470, 300, 420, 150)
    close()
    filled = true
    stroked = true
    thickness = 1.5
    color = vec(0.34, 0.88, 1.0, 0.24)
    hoverColor = vec(0.48, 1.0, 1.0, 0.92)
    onClick = { hit -> status("blob @ ${hit.closestVertexIndex}") }
}

lines(triangle, blob, channel = "callbacks2d", w = 760.0, h = 340.0, fitToSize = true)

fline-callbacks-2d

In practice this is useful for picking diagram elements, selecting generated forms, or turning a sketch into a tiny control surface without writing separate HTML.

3D callbacks

This stacks three rings at different depths in the Three.js viewer. Orbit the view, then click a ring to print which one you hit.

:::static
val status: (String) -> Unit = { message ->
    markdown("callback-log-3d", "3D callback: ${message.md}")
}

status("orbit, hover, then click")

fun ring(radius: Double, z: Double, color: Vec4, hover: Vec4, name: String): FLine = fline {
    val steps = 72
    breakNext()
    for (i in 0..steps) {
        val t = i / steps.toDouble() * Math.PI * 2.0
        val p = vec(Math.cos(t) * radius, Math.sin(t) * radius, z)
        lineTo(p)
    }
    close()
    thickness = 1.0
    this.color = color
    hoverColor = hover
    onClick = { hit -> status("$name @ ${hit.closestVertexIndex}") }
}

val stack = [
    ring(90.0, -80.0, vec(1.0, 0.58, 0.22, 0.70), vec(1.0, 0.82, 0.42, 1.0), "front ring"),
    ring(130.0, 0.0, vec(0.42, 0.92, 1.0, 0.70), vec(0.72, 1.0, 1.0, 1.0), "middle ring"),
    ring(170.0, 90.0, vec(0.74, 0.60, 1.0, 0.70), vec(0.92, 0.82, 1.0, 1.0), "back ring"),
]

threeLines(stack, channel = "callbacks3d", height = 430, editable = false)

fline-callbacks-3d

The 3D version is the same idea, just with ray-picking instead of SVG hit testing. That makes it useful for picking generated layers, debugging spatial structures, or attaching lightweight behavior to procedural geometry.

Where on the element you clicked

Both renderers fill in the whole FLineIntersection. Past closestVertexIndex and position it says where on the element you clicked: u runs along a segment (for a cubic, the curve parameter), and (u, v) runs across a face. onElement is the point on the element itself rather than under the pointer, so its z is interpolated along the element: a 3D line drawn into the flat 2D output still reports a depth. Every field past position is defaulted, so a two-field callback still compiles.

Two cautions before you read a number off it. u on a cubic is the Bézier parameter, not arc length, and on a curve whose control points crowd its start the two can be far apart: u = 0.5 can sit well short of halfway along. And (u, v) means different things on different faces, so the record's faceCoordinates says which. It is BILINEAR for a four-corner face, where (u, v) is a position on that panel (Field checks the solve, and falls back if it fails); BOUNDING_BOX for any other face, where it is a position in the face's bounding box; and NONE (with NaN) for a face with no plane.

lineIndex and label let one closure serve a whole figure instead of one closure per part. Box.inspect(lines) is that pattern packaged: it renders exactly what lines(...) renders and names whatever you click. The fline_hit_records starting point is a wall of panels to try it on.

Styling: color, stroked, filled, pointed

FLine geometry (where the points are) is separate from FLine styling (how it’s drawn). You’ll use these properties constantly:

:::static
// from afield.graphics.render.StandardDrawing
var FLine.color: Vec4
var FLine.thickness: Double
var FLine.filled: Boolean
var FLine.stroked: Boolean
var FLine.pointed: Boolean

var FLine.pointSize: Double
var FLine.Node.pointSize: Double

color is RGBA in sRGB (vec(r,g,b,a) with numbers between 0 and 1). Setting stroked to true draws the outline (the default), filled fills closed shapes (not the default), and pointed draws a dot at every node (useful for debugging, or for particle systems, whose debug view is built out of exactly this).

:::static
val shape = fline {
    moveTo(60, 60)
    cubicTo(120, 20, 300, 240, 360, 180)
    lineTo(360, 240)
    lineTo(60, 240)
    close()
}

val strokedOnly = shape.copy().apply {
    stroked = true
    filled = false
    thickness = 1.0
    color = vec(1.0, 1.0, 1.0, 1.0)
}

val filledOnly = shape.copy().apply {
    stroked = false
    filled = true
    color = vec(0.4, 1.0, 0.6, 0.25)
}

val debugPoints = shape.copy().apply {
    stroked = false
    filled = false
    pointed = true
    pointSize = 4.0
    color = vec(1.0, 0.5, 0.2, 1.0)
}

// side by side: each property alone, then all three together
lines(
    strokedOnly,
    filledOnly + vec(400, 0),
    debugPoints + vec(800, 0),
    filledOnly + vec(1200, 0),
    strokedOnly + vec(1200, 0),
    debugPoints + vec(1200, 0),
    w = 1000.0,
    h = 260.0,
    fitToSize = true,
)

fline_examples-17

Left to right: stroked alone, filled alone, pointed alone, and all three drawn together.

Building paths: moveTo, lineTo, cubicTo, close

These four calls are the core of FLine: you moveTo to place the pen, then add segments (lineTo / cubicTo), and optionally close() to seal a shape. Most other helpers ultimately build on these four calls.

moveTo(...) overloads

Moves the pen without drawing; starts a new subpath when followed by segments.

:::static
fun FLine.moveTo(x: Number, y: Number, z: Number = 0): FLine.Node
fun FLine.moveTo(v: Vec3): FLine.Node
fun FLine.moveTo(v: Vec2): FLine.Node
:::static
val a = vec(50, 50)
val b = vec(350, 200)

val f = fline {
    moveTo(a) // Vec2
    lineTo(b.x, b.y) // Numbers

    thickness = 1.0
    color = vec(0.9, 0.9, 1.0, 1.0)
}

lines(f, w = 400.0, h = 250.0, fitToSize = true)

fline_examples-19

lineTo(...) overloads

Adds a straight segment from the current point to the new point. Equivalent to .moveTo( ... ) if there isn't a current point.

:::static
fun FLine.lineTo(x: Number, y: Number, z: Number = 0): FLine.Node
fun FLine.lineTo(v: Vec3): FLine.Node
fun FLine.lineTo(v: Vec2): FLine.Node
:::static
val poly = fline {
    moveTo(50, 50)
    lineTo(350, 50)
    lineTo(350, 200)
    lineTo(50, 200)
    close()

    stroked = true
    filled = true
    thickness = 3.0
    color = vec(0.2, 0.9, 0.6, 0.35)
}

lines(poly, w = 420.0, h = 260.0, fitToSize = true)

fline_examples-21

cubicTo(...) (Bézier curves)

Adds a cubic Bézier curve segment (two control points plus an end point). Most smooth vector shapes are made of cubics.

:::static
fun FLine.cubicTo(
    x0: Number,
    y0: Number,
    z0: Number = 0,
    x1: Number,
    y1: Number,
    z1: Number = 0,
    x: Number,
    y: Number,
    z: Number = 0,
): FLine.Node

fun FLine.cubicTo(x0: Number, y0: Number, x1: Number, y1: Number, x: Number, y: Number): FLine.Node
fun FLine.cubicTo(v0: Vec3, v1: Vec3, v: Vec3): FLine.Node
:::static
// We'll draw the curve AND its control handles.
val p0 = vec(50, 160)
val c1 = vec(150, 20)
val c2 = vec(250, 300)
val p3 = vec(350, 160)

val curve = fline {
    moveTo(p0)
    cubicTo(c1.v3, c2.v3, p3.v3)
    thickness = 5.0
    color = vec(1.0, 0.8, 0.2, 1.0)
}

// The "handle lines" show how tangents are determined.
val handles = [
    seg(p0, c1, pointed = false, color = vec(1.0, 1.0, 1.0, 0.35)).apply { thickness = 2.0 },
    seg(p3, c2, pointed = false, color = vec(1.0, 1.0, 1.0, 0.35)).apply { thickness = 2.0 },
]

// Dots for endpoints and control points.
fun dot(p: Vec2, color: Vec4) = fline {
    moveTo(p.x, p.y)
    lineTo(p.x + 0.001, p.y + 0.001)
    pointed = true
    pointSize = 4.5
    stroked = false
    filled = true
    this.color = color
}

val pts = [
    dot(p0, vec(1.0, 1.0, 1.0, 1.0)),
    dot(p3, vec(1.0, 1.0, 1.0, 1.0)),
    dot(c1, vec(0.4, 1.0, 0.6, 1.0)),
    dot(c2, vec(0.4, 1.0, 0.6, 1.0)),
]

val curveLines: List<FLine> = [curve]
lines(handles + curveLines + pts, w = 420.0, h = 320.0, fitToSize = true)

fline_examples-23

close()

Closes the current subpath by drawing a segment back to the last moveTo.

:::static
fun FLine.close(): FLine.Node
:::static
val tri = fline {
    moveTo(200, 40)
    lineTo(360, 240)
    lineTo(40, 240)
    close()

    filled = true
    stroked = true
    thickness = 2.0
    color = vec(0.9, 0.4, 0.9, 0.35)
}

lines(tri, w = 420.0, h = 300.0, fitToSize = true)

fline_examples-25

Terser construction: pen operators, literals, and the data mini-language

For quick sketches, you can also use a shorter vocabulary. These forms build the same path instructions as moveTo, lineTo and cubicTo, so you can mix them freely.

:::static
/*
1) FLine.of — a polyline through the points (first point -> moveTo, the
   rest -> lineTo). Elements may be a mix of Number runs, Vec2 and Vec3.
*/
val poly = FLine.of(vec(0, 0), vec(1, 0), vec(1, 1))
val square = FLine.of(0, 0, 1, 0, 1, 1, 0, 1)

// 2) Pen operators — MEMBERS of FLine, so they only light up when an FLine is the
//    receiver, i.e. inside `fline { }`, `copy { }`. `-v` draws, `+v` lifts the pen.
operator fun Vec3.unaryMinus(): FLine // -v : lineTo (a dash looks like a segment); moveTo if the line is empty
operator fun Vec2.unaryMinus(): FLine
operator fun Vec3.unaryPlus(): FLine // +v : moveTo — pen up, start a new subpath
operator fun Vec2.unaryPlus(): FLine

// 3) A segment as a range, plus point/line appends
operator fun Vec3.rangeTo(other: Vec3): FLine // vec(a)..vec(b)  == a two-point line
operator fun Vec2.rangeTo(other: Vec2): FLine
operator fun FLine.plusAssign(p: Vec3) // f += vec(...)   appends a lineTo
operator fun FLine.plusAssign(p: Vec2) // (FLine += FLine still concatenates paths)

// 4) Node-scoped styling — style the node you just added, inline
fun FLine.moveTo(v: Vec3, style: FLine.Node.() -> Unit): FLine
fun FLine.lineTo(v: Vec3, style: FLine.Node.() -> Unit): FLine
fun FLine.cubicTo(c1: Vec3, c2: Vec3, to: Vec3, style: FLine.Node.() -> Unit): FLine

// 5) The `data` string mini-language (now variadic — no hand-rolled list)
fun FLine.data(format: String, vararg parameters: Any): FLine

The data format is a tiny path grammar: m moveTo, l lineTo, c cubicTo (3 points), s smoothTo, z close, ( ) groups, and a trailing + that greedily repeats a group over the remaining points. Parameters are packed into points (a run of numbers becomes x,y[,z]; Vec2/Vec3 pass through). So data("m l+", 0,0, 1,0, 1,1) is “move to the first point, then line to every point after it”.

:::static
// Pen operators inside fline { }: `-` draws, `+` lifts the pen to a new subpath.
val zig = fline {
    -vec(40, 40) // first point -> promoted to moveTo
    -vec(140, 40) // lineTo
    -vec(140, 140)
    +vec(200, 40) // pen up: a second subpath starts here
    -vec(300, 140)
    thickness = 3.0
    color = vec(0.98, 0.88, 0.28, 1.0)
}

// FLine.of: first point is a moveTo, the rest lineTo.
val tri = FLine.of(vec(60, 220), vec(180, 300), vec(300, 220))
tri.apply {
    close()
    thickness = 2.0
    color = vec(0.4, 1.0, 0.6, 1.0)
}

// `..` makes a segment; `+=` extends it with another point.
val seg = (vec(340, 40)..vec(460, 40)).apply {
    thickness = 2.0
    color = vec(1.0, 0.5, 0.25, 1.0)
}
seg += vec(460, 160)

// The `data` mini-language, driven variadically, with per-node styling on the box.
val box = FLine().data("m l l l z", 340, 200, 460, 200, 460, 300, 340, 300).apply {
    stroked = true
    color = vec(0.34, 0.82, 1.0, 1.0)
}

lines(zig, tri, seg, box, w = 520.0, h = 340.0, fitToSize = true)

fline_examples-27

Because + repeats a group over whatever points remain, one short format can stamp a whole pattern. Here m c+ z turns a list of points into a flower of cubic petals, with each c taking three of them:

:::static
// A little "flower" made from repeated cubic petals.
val f = fline {
    val petalPoints: List<Vec3> = [
        vec(200, 130, 0),

        // Each repetition of `c` consumes 3 points: c1, c2, end.
        vec(260, 40, 0),
        vec(340, 80, 0),
        vec(300, 130, 0),
        vec(340, 180, 0),
        vec(260, 220, 0),
        vec(200, 190, 0),
        vec(140, 220, 0),
        vec(60, 180, 0),
        vec(100, 130, 0),
        vec(60, 80, 0),
        vec(140, 40, 0),
        vec(200, 130, 0),
    ]
    data(format = "m c+ z", parameters = petalPoints)

    filled = true
    stroked = true
    thickness = 3.0
    color = vec(1.0, 0.7, 0.2, 0.35)
}

lines(f, w = 420.0, h = 260.0, fitToSize = true)

fline_examples-28

Node-scoped styling is handy when a single node needs to stand out — for example a colored, larger point at the end of a stroke:

:::static
fline {
    moveTo(vec(0, 0, 0))
    lineTo(vec(100, 0, 0)) {
        color = vec(1, 0.5, 0.2, 1)
        pointSize = 6.0
    }
    pointed = true
}

A note on scope: the +/- pen operators and the style-lambda overloads only resolve when an FLine is the receiver, which in practice means inside fline { } (or copy { }). FLine.of, .., and += work anywhere.

Multiple subpaths: breakNext() and pieces()

One FLine can contain several separate strokes, each beginning with a moveTo. Use breakNext to start another stroke while building a line, or pieces to split an existing line into separate FLines.

breakNext()

Forces the next segment call (lineTo, cubicTo) to start a new subpath (internally inserts a moveTo). Use it to build an FLine out of several separate strokes.

:::static
fun FLine.breakNext()
:::static
val f = fline {
    moveTo(50, 50)
    lineTo(350, 50)

    breakNext()
    lineTo(50, 200) // starts a new subpath
    lineTo(350, 200)

    thickness = 1.0
    color = vec(1.0, 1.0, 1.0, 1.0)
}

lines(f, w = 420.0, h = 260.0, fitToSize = true)

fline_examples-31

pieces()

Splits an FLine into separate FLines at each moveTo.

:::static
fun FLine.pieces(): List<FLine>
:::static
val f = fline {
    moveTo(50, 50)
    lineTo(350, 50)

    moveTo(50, 200)
    lineTo(350, 200)

    thickness = 5.0
    color = vec(0.7, 0.9, 1.0, 1.0)
}

// Each piece is its own FLine; colour them separately to show the split.
val colors = [vec(1.0, 0.5, 0.25, 1.0), vec(0.4, 1.0, 0.6, 1.0)]
val ps = f.pieces().mapIndexed { i, p -> p.apply { color = colors[i % colors.size] } }
lines(ps, w = 420.0, h = 260.0, fitToSize = true)

fline_examples-33

Copying and transforms

These are the everyday tools for reusing a shape without rebuilding it: copy() duplicates geometry, transform {} maps every node through a function, and the operator overloads (+, *, etc.) make common transforms concise.

copy() and transform(...)

Operation Behavior
fun FLine.copy(): FLine Returns a new line with copied nodes and properties.
fun FLine.transform(by: (Vec3) -> Vec3): FLine Returns a new line by applying by to every stored point, including Bézier control points. The original geometry is unchanged and drawing properties are copied.

copy() duplicates the geometry. transform maps every control point through a function (translate, scale, warp, …).

:::static
val base = fline {
    moveTo(60, 60)
    cubicTo(120, 20, 240, 200, 320, 140)
    thickness = 2.0
    color = vec(1.0, 1.0, 1.0, 0.35)
    pointed = true
    pointSize = 3.0
}

// Every stored point, control points included, goes through the function:
// here each one moves 60 down and spreads 20% wider.
val moved =
    base
        .transform { p -> vec(p.x * 1.2f, p.y + 60, p.z) }
        .apply {
            thickness = 4.0
            color = vec(1.0, 0.4, 0.4, 1.0)
        }

lines(base, moved, w = 420.0, h = 260.0, fitToSize = true)

fline_examples-34

Operator transforms: +, -, *

These are convenience transforms: translate (+/-), scale (* with a Number or Vec3), and rotate (* with a Quat).

:::static
operator fun FLine.plus(n: Vec3): FLine
operator fun FLine.minus(n: Vec3): FLine
operator fun FLine.times(n: Vec3): FLine
operator fun FLine.times(n: Quat): FLine
operator fun FLine.times(n: Number): FLine

operator fun List<FLine>.plus(offset: Vec3): List<FLine>
operator fun List<FLine>.times(scale: Number): List<FLine>
:::static
val square = fline {
    rect(0, 0, 100, 100)
    thickness = 3.0
    filled = true
    color = vec(0.3, 0.8, 1.0, 0.35)
}

val a = square + vec(20, 60, 0) // moved only
val b = ((square * 1.4) + vec(150, 40, 0)).apply { color = vec(0.4, 1.0, 0.6, 0.35) } // scaled 1.4x, then moved
val c = ((square * 0.7) + vec(320, 75, 0)).apply { color = vec(1.0, 0.7, 0.2, 0.35) } // scaled 0.7x, then moved

lines(a, b, c, w = 420.0, h = 260.0, fitToSize = true)

fline_examples-36

Quick path construction helpers

When you already have points (samples, mouse input, a polyline) or you want a specific primitive (like an arc), these helpers get you to an FLine quickly and readably.

Polylines: polylineFrom(...) and byAppendingPolyline(...)

Builds a line strip from points. Great for samples, plots, and quick geometry.

:::static
fun polylineFrom(points: List<Vec3>, closed: Boolean = false): FLine
fun polylineFrom(points: List<Vec2>, z: Number = 0, closed: Boolean = false): FLine

fun FLine.byAppendingPolyline(points: Iterable<Vec3>, closed: Boolean = false): FLine
fun FLine.byAppendingPolyline(points: Iterable<Vec2>, z: Number = 0, closed: Boolean = false): FLine
:::static
val pts =
    (0..80).map { i ->
        val x = 20 + i * 5
        val y = 120 + 60 * kotlin.math.sin(i * 0.2)
        vec(x, y)
    }

val wave = polylineFrom(pts, closed = false).apply {
    thickness = 3.0
    color = vec(1.0, 1.0, 1.0, 1.0)
}

lines(wave, w = 420.0, h = 260.0, fitToSize = true)

fline_examples-38

Arrows: arrow(...)

arrow(...) builds a real shaft plus one or two arrowheads, so it reads better in diagrams and annotations than faking direction with a fat point at the end of a line.

:::static
fun arrow(
    x0: Double,
    y0: Double,
    x1: Double,
    y1: Double,
    color: Vec4 = vec(1.0, 1.0, 1.0, 1.0),
    thickness: Double = 2.0,
    headLength: Double = Double.NaN,
    headWidth: Double = Double.NaN,
    startHead: Boolean = false,
    endHead: Boolean = true,
): FLine

fun arrow(
    from: Vec2,
    to: Vec2,
    color: Vec4 = vec(1.0, 1.0, 1.0, 1.0),
    thickness: Double = 2.0,
    headLength: Double = Double.NaN,
    headWidth: Double = Double.NaN,
    startHead: Boolean = false,
    endHead: Boolean = true,
): FLine
:::static
fun card(x: Double, y: Double, tint: Vec4) = fline {
    rect(x, y, 72, 54)
    filled = true
    stroked = true
    thickness = 2.0
    color = tint
}

val left = card(24.0, 102.0, vec(0.32, 0.84, 1.0, 0.18))
val top = card(174.0, 42.0, vec(1.0, 0.72, 0.24, 0.18))
val right = card(324.0, 152.0, vec(0.48, 1.0, 0.66, 0.18))

val flow = arrow(
    96.0,
    129.0,
    174.0,
    69.0,
    color = vec(0.92, 0.96, 1.0, 0.95),
    thickness = 3.0,
)

val feedback = arrow(
    vec(246.0, 69.0),
    vec(324.0, 179.0),
    color = vec(1.0, 0.72, 0.24, 0.95),
    thickness = 4.0,
    headLength = 22.0,
    headWidth = 16.0,
)

val sync = arrow(
    vec(324.0, 194.0),
    vec(96.0, 144.0),
    color = vec(0.46, 1.0, 0.72, 0.92),
    thickness = 2.6,
    headLength = 16.0,
    headWidth = 12.0,
    startHead = true,
    endHead = true,
)

lines(left, top, right, flow, feedback, sync, w = 420.0, h = 260.0, fitToSize = true)

fline_examples-40

Smooth polylines: cubic splines

This turns a list of points into a smooth cubic spline (Catmull–Rom spline, approximately). Reach for it first when you have gesture data and want a smooth curve through it.

:::static
fun cubicSplineFrom(points: List<Vec3>, closed: Boolean = false, tension: Float = 1f): FLine
fun cubicSplineFrom(points: List<Vec2>, z: Number = 0, closed: Boolean = false, tension: Float = 1f): FLine
:::static
val pts2 =
    (0..10).map { i ->
        val x = 40 + i * 34
        val y = 130 + 70 * kotlin.math.sin(i * 0.8)
        vec(x, y)
    }

val poly2 = polylineFrom(pts2, closed = false).apply {
    thickness = 2.0
    color = vec(1.0, 1.0, 1.0, 0.25)
    pointed = true
    pointSize = 3.0
}

val spline = cubicSplineFrom(pts2, closed = false, tension = 1f).apply {
    thickness = 5.0
    color = vec(0.7, 0.9, 1.0, 1.0)
}

lines(poly2, spline, w = 420.0, h = 260.0, fitToSize = true)

fline_examples-42

Incremental smoothing: smoothTo(...) and tcbTo(...)

You call these builders point-by-point (like lineTo), but they try to maintain smooth tangents. tcbTo gives you explicit control over the feel of the spline via Kochanek–Bartels (TCB) spline parameters.

:::static
fun FLine.smoothTo(endPoint: Vec3, smoothness: Float = 1f): FLine.Node
fun FLine.smoothTo(x: Number, y: Number, z: Number = 0, smoothness: Float = 1f): FLine.Node
fun FLine.smoothTo(v: Vec2, smoothness: Float = 1f): FLine.Node

fun FLine.tcbTo(endPoint: Vec3, tension: Float = 0f, continuity: Float = 0f, bias: Float = 0f): FLine.Node
fun FLine.tcbTo(
    x: Number,
    y: Number,
    z: Number = 0,
    tension: Float = 0f,
    continuity: Float = 0f,
    bias: Float = 0f,
): FLine.Node
fun FLine.tcbTo(v: Vec2, tension: Float = 0f, continuity: Float = 0f, bias: Float = 0f): FLine.Node
:::static
val s1 = fline {
    moveTo(60, 200)
    smoothTo(120, 60)
    smoothTo(220, 240)
    smoothTo(360, 120)
    thickness = 5.0
    color = vec(1.0, 0.7, 0.2, 0.9)
}

val s2 = fline {
    moveTo(60, 200)
    tcbTo(vec(120, 60), tension = 0.0f, continuity = -0.8f, bias = 0.0f)
    tcbTo(vec(220, 240), tension = 0.0f, continuity = -0.8f, bias = 0.0f)
    tcbTo(vec(360, 120), tension = 0.0f, continuity = -0.8f, bias = 0.0f)
    thickness = 5.0
    color = vec(0.4, 1.0, 0.6, 0.9)
}

// The four points both curves pass through.
val knots = polylineFrom([vec(60, 200), vec(120, 60), vec(220, 240), vec(360, 120)]).apply {
    thickness = 1.0
    color = vec(1.0, 1.0, 1.0, 0.25)
    pointed = true
    pointSize = 4.0
}

lines(knots, s1, s2, w = 420.0, h = 260.0, fitToSize = true)

fline_examples-44

Negative continuity pulls the green tcbTo curve toward a corner at each point; smoothTo (orange) stays rounded.

Biarcs: biarcTo(...) and biarcBetween(...)

A biarc is two circular arcs joined together. Use one when you want the smoothness of circular arcs without a cubic Bézier.

:::static
fun FLine.biarcTo(
    endPoint: Vec3,
    startTangent: Vec3? = null,
    endTangent: Vec3? = null,
    join: Float = 0.5f,
    joinTangentMix: Float = 0.5f,
    bulge: Float = 0f,
    segments: Int = 32,
    planeNormal: Vec3? = null,
    joinTangent: Vec3? = null,
): FLine.Node

fun FLine.biarcTo(
    endPoint: Vec2,
    startHeading: Float? = null,
    endHeading: Float? = null,
    join: Float = 0.5f,
    joinTangentMix: Float = 0.5f,
    bulge: Float = 0f,
    segments: Int = 32,
): FLine.Node

fun biarcBetween(
    start: Vec3,
    end: Vec3,
    startTangent: Vec3,
    endTangent: Vec3,
    join: Float = 0.5f,
    joinTangentMix: Float = 0.5f,
    bulge: Float = 0f,
    segments: Int = 32,
    planeNormal: Vec3? = null,
    joinTangent: Vec3? = null,
): FLine
:::static
val a = vec(70, 190)
val b = vec(350, 90)

val f1 = fline {
    moveTo(a)
    // Headings are in radians in the current drawing plane. With the default join this is the
    // classic biarc: two circular arcs, meeting both headings exactly.
    biarcTo(b, startHeading = 0.0f, endHeading = (-1.2f), segments = 40)
    thickness = 3.0
    color = vec(1.0, 0.7, 0.2, 0.9)
}

val f2 = fline {
    moveTo(a.x, a.y - 60)
    // Moving the join and bulging it sideways reshapes the pair; the join stays smooth.
    biarcTo(vec(b.x, b.y - 60), startHeading = 0.0f, endHeading = (-1.2f), join = 0.35f, bulge = -0.10f, segments = 40)
    thickness = 3.0
    color = vec(0.4, 1.0, 0.6, 0.9)
}

// Endpoints as dots
fun dot(p: Vec2, c: Vec4) = fline {
    moveTo(p)
    lineTo(p.x + 0.001, p.y + 0.001)
    pointed = true
    pointSize = 4.0
    stroked = false
    filled = true
    color = c
}

val white = vec(1, 1, 1, 1)
lines(
    f1,
    f2,
    dot(a, white),
    dot(b, white),
    dot(vec(a.x, a.y - 60), white),
    dot(vec(b.x, b.y - 60), white),
    w = 420.0,
    h = 260.0,
    fitToSize = true,
)

fline_examples-46

Arcs: arcTo(...)

Creates a circular arc that smoothly rounds the corner between the current point → p1 → p2 with a given radius.

:::static
fun FLine.arcTo(p1: Vec3, p2: Vec3, radius: Float, segments: Int = 32): FLine.Node
fun FLine.arcTo(p1: Vec2, p2: Vec2, radius: Number, segments: Int = 32): FLine.Node
fun FLine.arcTo(p1x: Number, p1y: Number, p2x: Number, p2y: Number, radius: Number, segments: Int = 32): FLine.Node
:::static
// The sharp path we are rounding, drawn faintly with its corners marked.
val sharp = fline {
    moveTo(60, 200)
    lineTo(60, 60)
    lineTo(360, 60)
    lineTo(360, 200)
    thickness = 1.0
    color = vec(1.0, 1.0, 1.0, 0.3)
    pointed = true
    pointSize = 4.0
}

// arcTo(corner, next, radius): a line toward `corner`, then an arc turning toward `next`.
val rounded = fline {
    moveTo(60, 200)
    arcTo(vec(60, 60), vec(360, 60), radius = 40)
    arcTo(vec(360, 60), vec(360, 200), radius = 40)
    lineTo(360, 200)

    thickness = 4.0
    color = vec(1.0, 0.9, 0.5, 1.0)
}

lines(sharp, rounded, w = 420.0, h = 260.0, fitToSize = true)

fline_examples-48

Circular arc through a point: circleThroughTo(...)

Creates an arc that passes through a given point and ends at end.

:::static
fun FLine.circleThroughTo(through: Vec3, end: Vec3, segments: Int = 32): FLine.Node
fun FLine.circleThroughTo(through: Vec2, end: Vec2, segments: Int = 32): FLine.Node
fun FLine.circleThroughTo(
    throughX: Number,
    throughY: Number,
    endX: Number,
    endY: Number,
    segments: Int = 32,
): FLine.Node
:::static
val f = fline {
    moveTo(60, 200)
    circleThroughTo(through = vec(220, 40), end = vec(360, 200), segments = 48)

    thickness = 5.0
    color = vec(0.6, 0.9, 1.0, 1.0)
}

// The three points that define the arc: start, through, end.
val defining = polylineFrom([vec(60, 200), vec(220, 40), vec(360, 200)]).apply {
    stroked = false
    pointed = true
    pointSize = 5.0
    color = vec(1.0, 1.0, 1.0, 1.0)
}

lines(f, defining, w = 420.0, h = 260.0, fitToSize = true)

fline_examples-50

2D transformations (geometry-aware)

These transforms are for 2D shapes in the XY plane: rotate around a centre, reflect across a line, shear. They save writing a custom transform { p -> ... }.

Rotate / scale / reflect / shear

Operation Behavior
fun FLine.byRotating2D(angle: Float, center: Vec2 = vec(0f, 0f)): FLine Rotates the XY geometry around center by an angle in radians, preserving Z. The default center is the origin; the result is a new line.
fun FLine.byScaling2D(scale: Float, center: Vec2 = vec(0f, 0f)): FLine Scales XY positions uniformly about center, leaving Z unchanged.
fun FLine.byScaling2D(scaleX: Float, scaleY: Float, center: Vec2 = vec(0f, 0f)): FLine Scales X and Y by separate factors about center, to stretch the shape. Z is unchanged.
fun FLine.byReflecting2D(linePoint1: Vec2, linePoint2: Vec2): FLine Mirrors XY positions across the infinite line through two distinct points. It preserves Z and returns a new line.
fun FLine.byShearing2D(shearX: Float, shearY: Float): FLine Adds shearX * y to X and shearY * x to Y, preserving Z. This skews the shape about the origin without changing the input line.

Each panel below draws the original house faintly with the result over it. From left to right: rotate by 0.5 rad about its centre, scale by 0.6, mirror across the vertical line x = 50 (drawn), and shear by shearX = 0.4.

:::static
// An asymmetric "house" (the roof peak is off-centre), so every transform shows which way it went.
fun house() = fline {
    moveTo(0, 0)
    lineTo(100, 0)
    lineTo(100, 60)
    lineTo(30, 90)
    lineTo(0, 60)
    close()
}

fun ghost(dx: Double) = (house() + vec(dx, 0.0)).apply {
    stroked = true
    filled = false
    thickness = 1.5
    color = vec(1.0, 1.0, 1.0, 0.35)
}

fun styled(f: FLine, dx: Double, c: Vec4) = (f + vec(dx, 0.0)).apply {
    stroked = true
    filled = true
    thickness = 2.5
    color = c
}

val center = vec(50f, 45f)

val rot = styled(house().byRotating2D(angle = 0.5f, center = center), 0.0, vec(1.0, 0.5, 0.2, 0.45))
val scaled = styled(house().byScaling2D(scale = 0.6f, center = center), 170.0, vec(0.7, 0.9, 1.0, 0.45))
val mirror = fline {
    moveTo(50 + 340, -20)
    lineTo(50 + 340, 110)
    thickness = 1.0
    color = vec(1.0, 1.0, 1.0, 0.5)
}
val reflected = styled(house().byReflecting2D(vec(50f, 0f), vec(50f, 100f)), 340.0, vec(0.8, 0.6, 1.0, 0.45))
val sheared = styled(house().byShearing2D(shearX = 0.4f, shearY = 0f), 510.0, vec(0.4, 1.0, 0.6, 0.45))

lines(
    ghost(0.0),
    rot,
    ghost(170.0),
    scaled,
    ghost(340.0),
    mirror,
    reflected,
    ghost(510.0),
    sheared,
    w = 720.0,
    h = 220.0,
    fitToSize = true,
)

fline_examples-51

3D transforms and projections

Operation Behavior
fun FLine.byRotating3D(angle: Float, axis: Vec3, center: Vec3 = vec(0f, 0f, 0f)): FLine Rotates the geometry about the chosen axis through center, using radians. Returns a new line so the source can be drawn alongside the rotated result.
fun FLine.byPerspectiveProjecting(cameraPos: Vec3, planePoint: Vec3 = vec(0f, 0f, 0f), planeNormal: Vec3 = vec(0f, 0f, 1f)): FLine Projects points along rays from cameraPos onto the plane defined by a point and normal. Points whose intersection is behind the camera are kept at their original positions.
fun FLine.byOrthographicProjecting(planePoint: Vec3 = vec(0f, 0f, 0f), planeNormal: Vec3 = vec(0f, 0f, 1f)): FLine Projects every point onto the plane along its normal. The default plane is Z = 0, and the result is a new line.

Field’s FLine nodes are 3D, so you can transform them in 3D and then project down into 2D for drawing.

:::static
// A little 3D helix, projected onto the Z=0 plane.
val helix = fline {
    moveTo(330, 130, -80)
    for (i in 1..160) {
        val t = i / 160.0
        val a = t * kotlin.math.PI * 6.0
        val r = 120.0
        val x = 210 + r * kotlin.math.cos(a)
        val y = 130 + r * kotlin.math.sin(a)
        val z = -80 + 160 * t
        lineTo(x, y, z)
    }
}

val rotated = helix.byRotating3D(angle = 0.7f, axis = vec(0f, 1f, 0f), center = vec(210f, 130f, 0f))

val ortho = rotated.byOrthographicProjecting(planePoint = vec(0f, 0f, 0f), planeNormal = vec(0f, 0f, 1f)).apply {
    thickness = 3.0
    color = vec(1.0, 1.0, 1.0, 0.45)
}

val persp = rotated.byPerspectiveProjecting(cameraPos = vec(210f, 130f, -320f)).apply {
    thickness = 4.5
    color = vec(1.0, 0.7, 0.2, 0.9)
}

lines(ortho, persp, w = 420.0, h = 260.0, fitToSize = true)

fline_examples-52

Sampling and traversal

Sample a path to get points you can use for animation, particle emission, evenly spaced markers or measurement. Cursor lets you walk the path directly; sampleBy... provides shorter calls for common sampling jobs.

Cursor: continuous traversal over segments

A Cursor walks along an FLine by node index (t) or arc-length distance (d). Sampling at a fixed distance, and many other geometry operations, are built on it.

:::static
fun FLine.cursor(tol: Float = 0.01f): afield.geometry.path.java2d.FLinesAndJavaShapes.Cursor
fun FLine.cursor(closestTo: Vec3): afield.geometry.path.java2d.FLinesAndJavaShapes.Cursor

Use Cursor to step by distance with setD, or to read the tangent, normal and curvature as you move along a curve.

:::static
// In afield.geometry.path.java2d.FLinesAndJavaShapes.Cursor
class Cursor {
    fun reset()

    fun hasNext(): Boolean

    // Parameterization
    val t: Float

    fun setT(q: Float): Cursor

    val d: Float

    fun setD(d: Float): Cursor

    fun lengthT(): Int

    fun lengthD(): Float

    // Geometry at current position
    fun position(): Vec3

    fun location(): FLineLocation? // everything below, at once

    fun tangent(): Vec3? // unit length

    fun normal(): Vec3? // principal normal, towards the centre; null on straight runs

    fun binormal(): Vec3? // tangent x normal; null on straight runs

    fun curvature(): Float // unsigned, 3D

    fun normal2D(): Vec2? // unit, (tangent.y, -tangent.x)

    fun signedCurvature2D(): Float // positive bending towards normal2D

    fun radiusOfCurvature(): Vec3? // from here to the centre of the osculating circle

    fun setNearest(point: Vec3): Cursor

    // Analysis helpers (mostly for cubics)
    fun extremalInnerPoints(): List<Float>

    fun tangentBreaks(): List<Float>

    fun inflectionPointsXY(): List<Float>

    // Editing / splitting
    fun split(): Pair<FLine?, FLine?>

    fun splitD(d: List<Float>): List<FLine>

    fun splitT(t: List<Float>): List<FLine>

    fun segment(): FLine.Node?
}

This example uses a Cursor to space dots by distance, and also draws short tangent tick-marks so you can see direction:

:::static
val f = fline {
    moveTo(50, 180)
    cubicTo(120, 20, 300, 280, 350, 120)
    thickness = 4.0
    color = vec(1.0, 1.0, 1.0, 1.0)
}

val c = f.cursor(tol = 0.01f)
val total = c.lengthD()

val dots = mutableListOf<FLine>()
val ticks = mutableListOf<FLine>()

fun dot(p: Vec3, color: Vec4) = fline {
    moveTo(p.x, p.y)
    lineTo(p.x + 0.001, p.y + 0.001)
    pointed = true
    pointSize = 1.0
    stroked = false
    filled = true
    this.color = color
}

val step = 24f
var d = 0f
while (d <= total) {
    c.setD(d)
    val p = c.position()
    dots.add(dot(p, vec(1.0, 0.4, 0.3, 1.0)))

    val t = c.tangent()
    if (t != null && t.lengthSquared() > 1e-9) {
        val dir = Vec3(t).normalize()
        val a = Vec3(p).sub(Vec3(dir).mul(12f))
        val b = Vec3(p).add(Vec3(dir).mul(12f))
        ticks.add(
            fline {
                moveTo(a)
                lineTo(b)
                thickness = 1.5
                color = vec(0.7, 0.9, 1.0, 0.8)
            },
        )
    }

    d += step
}

lines([f] + ticks + dots, w = 420.0, h = 260.0, fitToSize = true)

fline_examples-55

sampleByNumber(...) and sampleByDistance(...)

Samples points along the path either by count (steps) or by approximate arc-length spacing (distance).

:::static
fun FLine.sampleByNumber(steps: Int): List<Vec3>
fun FLine.sampleByDistance(distance: Float): List<Vec3>
:::static
fun curve(dy: Double) = fline {
    moveTo(60, 150 + dy)
    cubicTo(120, 20 + dy, 300, 260 + dy, 360, 120 + dy)
    thickness = 2.0
    color = vec(1.0, 1.0, 1.0, 0.4)
}

fun dot(p: Vec3, color: Vec4) = fline {
    moveTo(p.x, p.y)
    lineTo(p.x + 0.001, p.y + 0.001)
    pointed = true
    pointSize = 1.5
    stroked = false
    filled = true
    this.color = color
}

// Top: exactly 12 points, both ends included.
val top = curve(0.0)
val byNumber = top.sampleByNumber(12).map { dot(it, vec(1.0, 0.5, 0.2, 1.0)) }

// Bottom: one point every 40 units; the end is only hit if the length divides evenly.
val bottom = curve(200.0)
val byDistance = bottom.sampleByDistance(40f).map { dot(it, vec(0.4, 1.0, 0.6, 1.0)) }

lines([top, bottom] + byNumber + byDistance, w = 420.0, h = 420.0, fitToSize = true)

fline_examples-57

Both space their points evenly along the curve. sampleByNumber(12) (top) always lands on both ends; sampleByDistance(40f) (bottom) steps a fixed 40 units, so its last point can stop short of the end.

Geometry operations (simple → complex)

These functions take an existing FLine and return new geometry: smoothing/simplifying (cleanup), offsetting (stroke outlines), and dashing (segmenting). For all of the gesture-style filters (EMA / One Euro / alpha-beta / One-Dollar) and Gaussian-process interpolation, see Smoothing a hand-drawn stroke.

Simplify and smooth

Smoothing reduces jitter; simplifying removes points while keeping the shape within a tolerance. Together they turn a noisy polyline into a clean stroke.

:::static
fun FLine.byGaussianSmoothing(sigma: Float, samples: Int = 100): FLine
fun FLine.bySimplifying(epsilon: Float): FLine
:::static
val noisy = fline {
    moveTo(20, 130)
    for (i in 0..90) {
        val x = 20 + i * 4
        val y = 130 + 50 * kotlin.math.sin(i * 0.2) + 12 * kotlin.math.sin(i * 1.7)
        lineTo(x, y)
    }
    thickness = 2.0
    color = vec(1.0, 1.0, 1.0, 0.25)
}

val smooth = noisy.byGaussianSmoothing(sigma = 2.0f, samples = 120).apply {
    thickness = 2.5
    color = vec(0.4, 1.0, 0.6, 1.0)
}

// The red dots are the vertices that survive simplifying.
val simple = smooth.bySimplifying(epsilon = 2.0f).apply {
    thickness = 1.5
    pointed = true
    pointSize = 1.2
    color = vec(1.0, 0.4, 0.4, 1.0)
}

lines(noisy, smooth, simple, w = 420.0, h = 260.0, fitToSize = true)

fline_examples-59

Cleanup and resampling

For raw input: simplify away redundant detail, then rebuild the line with evenly spaced samples along arc length. If you need the raw samples themselves, sampleByNumber(...) and sampleByDistance(...) expose them directly.

:::static
fun FLine.bySimplifying(epsilon: Float): FLine
fun FLine.byArcLengthParameterizing(spacing: Float): FLine
fun FLine.sampleByNumber(steps: Int): List<Vec3>
fun FLine.sampleByDistance(distance: Float): List<Vec3>
:::static
val raw = fline {
    moveTo(40, 140)
    for (i in 1..240) {
        val t = i / 240.0
        val x = 40 + t * 340
        val y = 140 + 55 * kotlin.math.sin(t * 7.0 * kotlin.math.PI) + 8 * kotlin.math.sin(t * 60.0)
        lineTo(x, y)
    }
    thickness = 2.0
    color = vec(1.0, 1.0, 1.0, 0.25)
}

val simplified = raw.bySimplifying(epsilon = 1.5f).apply {
    thickness = 3.0
    color = vec(0.7, 0.9, 1.0, 0.75)
}

val resampled = simplified.byArcLengthParameterizing(spacing = 12f).apply {
    thickness = 1.5
    color = vec(1.0, 0.6, 0.2, 0.95)
    pointed = true
    pointSize = 1.0
    stroked = true
    filled = false
}

lines(raw, simplified, resampled, w = 420.0, h = 260.0, fitToSize = true)

fline_examples-61

Noise utilities (make it organic)

These reconstruct a new polyline by sampling your curve, perturbing the samples, and stitching them back together. byOrientedGaussianNoising and byPerlinNoising try to push mostly along the curve’s normal, which often looks more hand-drawn.

:::static
fun FLine.byGaussianNoising(amplitude: Float, seed: Long = System.currentTimeMillis(), samples: Int = 100): FLine
fun FLine.byOrientedGaussianNoising(
    normalAmplitude: Float,
    tangentAmplitude: Float = 0f,
    seed: Long = System.currentTimeMillis(),
    samples: Int = 100,
): FLine
fun FLine.byPerlinNoising(
    amplitude: Float,
    frequency: Float = 1f,
    octaves: Int = 4,
    seed: Long = System.currentTimeMillis(),
    samples: Int = 100,
): FLine
:::static
fun base(dy: Double) = fline {
    moveTo(0, dy)
    cubicTo(80, dy - 60, 220, dy + 60, 300, dy)
}

fun ghost(dy: Double) = base(dy).apply {
    thickness = 4.0
    color = vec(1.0, 1.0, 1.0, 0.25)
}

// One row per kind of noise, each over a faint copy of the clean curve.
val gauss = base(0.0).byGaussianNoising(amplitude = 6f, seed = 1L, samples = 140).apply {
    thickness = 2.5
    color = vec(1.0, 0.6, 0.2, 0.9)
}

val oriented =
    base(110.0)
        .byOrientedGaussianNoising(normalAmplitude = 10f, tangentAmplitude = 1.5f, seed = 2L, samples = 140)
        .apply {
            thickness = 2.5
            color = vec(0.4, 1.0, 0.6, 0.9)
        }

val perlin = base(220.0).byPerlinNoising(amplitude = 12f, frequency = 0.02f, octaves = 4, seed = 3L, samples = 140).apply {
    thickness = 2.5
    color = vec(0.7, 0.9, 1.0, 0.9)
}

lines(ghost(0.0), gauss, ghost(110.0), oriented, ghost(220.0), perlin, w = 420.0, h = 340.0, fitToSize = true)

fline_examples-63

Top to bottom: byGaussianNoising, byOrientedGaussianNoising (mostly across the curve), byPerlinNoising.

Smoothing a hand-drawn stroke

These functions resample your path and return a smoothed polyline. byGaussianProcessInterpolating fits a smooth curve through samples of the path; byEmaFiltering averages each point with the ones before it (an exponential moving average); byOneEuroFiltering smooths slow parts of the stroke heavily and fast parts lightly, so it removes jitter without lagging behind quick movements; byAlphaBetaFiltering tracks position and velocity together; and byDriverSmoothing steers a point that follows the path, rounding off its corners.

:::static
fun FLine.byGaussianProcessInterpolating(
    trainingSamples: Int = 48,
    samples: Int = 200,
    lengthScale: Float = 0.15f,
    variance: Float = 1f,
    noise: Float = 1e-3f,
    dryWet: Float = 1f,
    wetModulator: ParameterFunction? = null,
    drawSamples: Boolean = false,
    seed: Long? = null,
): FLine

fun FLine.byEmaFiltering(
    alpha: Float = 0.18f,
    samples: Int = 200,
    dryWet: Float = 1f,
    wetModulator: ParameterFunction? = null,
): FLine

fun FLine.byOneEuroFiltering(
    minCutoffHz: Float = 1.5f,
    beta: Float = 0.03f,
    dCutoffHz: Float = 1f,
    fpsHint: Float = 60f,
    samples: Int = 200,
    dryWet: Float = 1f,
    wetModulator: ParameterFunction? = null,
): FLine

fun FLine.byAlphaBetaFiltering(
    alpha: Float = 0.25f,
    beta: Float = 0.08f,
    fpsHint: Float = 90f,
    samples: Int = 200,
    dryWet: Float = 1f,
    wetModulator: ParameterFunction? = null,
): FLine

enum class FLineDriverTargetUpdateMode {
    FORWARD_ONLY,
    WINDOWED_NEAREST,
    STICKY_WINDOWED_NEAREST,
}

data class FLineDriverSmoothingConfig(
    maxAngularVelocity: Float = 0.3f,
    maxAngularAcceleration: Float = 0.06f,
    lookaheadSamples: Int = 4,
    forwardSearchSamples: Int = 18,
    backwardSearchSamples: Int = 6,
    backwardRetargetHysteresis: Float = 0.15f,
    linearStepScale: Float = 1f,
    targetUpdateMode: FLineDriverTargetUpdateMode = FLineDriverTargetUpdateMode.STICKY_WINDOWED_NEAREST,
)

fun FLine.byDriverSmoothing(
    config: FLineDriverSmoothingConfig = FLineDriverSmoothingConfig(),
    samples: Int = 200,
    dryWet: Float = 1f,
    wetModulator: ParameterFunction? = null,
): FLine

fun FLine.byOneDollarFiltering(
    targetSamples: Int = 64,
    targetSize: Float = 200f,
    rotateToZero: Boolean = true,
    dryWet: Float = 1f,
    wetModulator: ParameterFunction? = null,
): FLine
  • byEmaFiltering is a simple exponential moving average (cheap, effective, but it lags).
  • byOneEuroFiltering adapts smoothing based on speed (good for interactive cursors/gestures; see the 1€ filter).
  • byAlphaBetaFiltering is a light constant-velocity tracker (good for jitter + latency tradeoffs; alpha-beta filter).
  • byDriverSmoothing is a pursuit-style smoother: it samples the source, looks ahead on that sampled path, and steers a driver toward the target with bounded angular acceleration and bounded angular velocity. The search policy controls whether the driver can revisit earlier samples when they become closer.
  • byGaussianProcessInterpolating fits a GP along arc length and draws coherent whole-curve samples with drawSamples=true. For posterior variances, full XYZ covariance, uncertainty bands, kernel choice, and scalar fields over 1D/2D/3D inputs, use the Gaussian processes tutorial and FLine.gaussianProcess.
  • byOneDollarFiltering normalizes a gesture (rotate + scale + resample; the $1 Recognizer) so noisy input becomes more comparable.
:::static
fun raw(dx: Double, dy: Double) = fline {
    moveTo(dx, dy)
    for (i in 1..240) {
        val t = i / 240.0
        lineTo(dx + t * 340, dy + 55 * kotlin.math.sin(t * 7.0 * kotlin.math.PI) + 11 * kotlin.math.sin(t * 70.0))
    }
}

fun ghost(dx: Double, dy: Double) = raw(dx, dy).apply {
    thickness = 1.5
    color = vec(1.0, 1.0, 1.0, 0.25)
}

fun styled(f: FLine, c: Vec4) = f.apply {
    thickness = 3.0
    color = c
}

// A grid: each filter over a faint copy of the same noisy input.
val ema = styled(raw(0.0, 0.0).byEmaFiltering(alpha = 0.10f, samples = 220), vec(0.7, 0.9, 1.0, 0.95))
val oneEuro = styled(
    raw(400.0, 0.0)
        .byOneEuroFiltering(minCutoffHz = 1.0f, beta = 0.05f, dCutoffHz = 1.0f, fpsHint = 60f, samples = 220),
    vec(0.4, 1.0, 0.6, 0.95),
)
val ab = styled(
    raw(0.0, 170.0).byAlphaBetaFiltering(alpha = 0.25f, beta = 0.08f, fpsHint = 60f, samples = 220),
    vec(1.0, 0.6, 0.2, 0.95),
)
val gpMean = styled(
    raw(400.0, 170.0)
        .byGaussianProcessInterpolating(
            trainingSamples = 44,
            samples = 220,
            lengthScale = 0.11f,
            variance = 1f,
            noise = 0.02f,
            drawSamples = false,
        ),
    vec(1.0, 0.4, 0.4, 0.95),
)
// One-Dollar normalizes the gesture around the origin (rotated, scaled to 220, resampled), so it gets its own cell.
val oneDollar = styled(
    raw(0.0, 0.0).byOneDollarFiltering(targetSamples = 64, targetSize = 220f, rotateToZero = true),
    vec(0.8, 0.6, 1.0, 0.95),
) + vec(370, 340, 0)

lines(
    ghost(0.0, 0.0),
    ema,
    ghost(400.0, 0.0),
    oneEuro,
    ghost(0.0, 170.0),
    ab,
    ghost(400.0, 170.0),
    gpMean,
    oneDollar,
    w = 720.0,
    h = 480.0,
    fitToSize = true,
)

fline_examples-65

Row by row: EMA, One Euro; alpha-beta, the Gaussian-process mean; and below, the One-Dollar normalization of the same gesture.

Driver smoothing in 2D

:::static
val f = fline {
    moveTo(0, 0)
    cubicTo(10, 20, 30, 20, 20, 40)
    cubicTo(60, 20, 30, 20, 40, 20)
} * 3

val sweep =
    10.alphas { t ->
        f.byDriverSmoothing(
                config = FLineDriverSmoothingConfig(
                    maxAngularVelocity = 1f / (1f + t.f * 20f),
                    maxAngularAcceleration = 0.10f,
                    targetUpdateMode = FLineDriverTargetUpdateMode.FORWARD_ONLY,
                    forwardSearchSamples = 20,
                ),
                samples = 180,
            )
            .apply {
                filled = false
                stroked = true
                thickness = 3.0
                color = vec(
                    0.28 + t * 0.58,
                    0.94 - t * 0.24,
                    1.0 - t * 0.36,
                    0.90,
                )
            } + vec(t * 300.0, 0.0)
    }

lines(sweep, w = 720.0, h = 220.0, fitToSize = true)

fline_examples-66

Here one path is chased with different turning limits. On the left the driver can turn quickly, so it hugs the source. As maxAngularVelocity drops, the chased line has to commit earlier and sweep wider.

Driver smoothing in 3D

:::static
val f = fline {
    moveTo(0, 0, 0)
    cubicTo(30, 10, 20, 2, 30, 20, 20, 40)
    cubicTo(20, 60, 20, 2, 30, -50, 40, 20)
} * 3

val sweep3d =
    10.alphas { t ->
        f.byDriverSmoothing(
                config = FLineDriverSmoothingConfig(
                    maxAngularVelocity = 1f / (1f + t.f * 20f),
                    maxAngularAcceleration = 0.10f,
                    targetUpdateMode = FLineDriverTargetUpdateMode.FORWARD_ONLY,
                    forwardSearchSamples = 20,
                ),
                samples = 180,
            )
            .apply {
                filled = false
                stroked = true
                thickness = 3.0
                color = vec(
                    0.32 + t * 0.54,
                    0.86 - t * 0.22,
                    1.0 - t * 0.26,
                    0.88,
                )
            } + vec(t * 300.0, 0.0, 0.0)
    }

threeLines(sweep3d, channel = "driver3d", height = 430, editable = false)

fline_examples-67

Here the source curve bends through depth, and the smoothed curves follow it in 3D. The driver uses quaternions to turn towards its lookahead target, with bounds on angular acceleration and velocity.

Offsetting / parallel curves

Operation Behavior
fun FLine.byOffsetting(distance: Float, samples: Int = 50): FLine Samples the path and displaces each point along its XY normal by distance, returning a polyline of samples points per segment.
fun FLine.byCreatingParallelCurves(distance: Float, samples: Int = 50): Pair<FLine, FLine> Returns two offsets, first at -distance and then at +distance. Use the pair as the two sides of a stroke or ribbon.

Offsetting creates a curve parallel to the original at a given distance, for stroke outlines and tubes.

:::static
val base = fline {
    moveTo(60, 180)
    cubicTo(120, 20, 260, 280, 360, 120)
    thickness = 3.0
    color = vec(1.0, 1.0, 1.0, 1.0)
}

val (l, r) = base.byCreatingParallelCurves(distance = 12f, samples = 80)

val left = l.apply {
    color = vec(0.4, 1.0, 0.6, 1.0)
    thickness = 3.0
}
val right = r.apply {
    color = vec(1.0, 0.4, 0.4, 1.0)
    thickness = 3.0
}

lines(base, left, right, w = 420.0, h = 260.0, fitToSize = true)

fline_examples-68

Dashing

Breaks a path into multiple FLines representing dash segments. Both lengths are measured along the curve, and each dash follows the curve rather than cutting across it — resolution is the longest straight piece used to do that, so lower it if a dash around a tight bend still looks faceted.

:::static
fun FLine.byDashing(dashLength: Float, gapLength: Float, resolution: Float = 2f): List<FLine>
:::static
val f = fline {
    moveTo(50, 180)
    cubicTo(120, 20, 300, 280, 350, 120)
    thickness = 1.0
    color = vec(1.0, 1.0, 1.0, 1.0)
}

val dashes =
    f.byDashing(dashLength = 18f, gapLength = 10f).mapIndexed { i, seg ->
        seg.apply {
            thickness = 1.0
            color = if (i % 2 == 0) vec(1.0, 0.7, 0.2, 1.0) else vec(0.4, 1.0, 0.6, 1.0)
        }
    }

lines(dashes, w = 420.0, h = 260.0, fitToSize = true)

fline_examples-70

Intersections

To find where two curves cross in the xy plane, use crossings2D; selfCrossings2D finds loops and crossings inside a single stroke. Each hit has its point and its t on both lines; Crossings, bounds and orientation has the details.

:::static
fun FLine.crossings2D(other: FLine, tolerance: Float = 0.01f): List<FLineCrossing>
fun FLine.selfCrossings2D(tolerance: Float = 0.01f): List<FLineCrossing>
:::static
val a = fline {
    moveTo(40, 130)
    cubicTo(140, -40, 260, 300, 380, 130)
    thickness = 4.0
    color = vec(1.0, 1.0, 1.0, 1.0)
}

// A straight line that crosses the S three times.
val b = fline {
    moveTo(40, 100)
    lineTo(380, 160)
    thickness = 3.0
    color = vec(0.7, 0.9, 1.0, 0.9)
}

val hits = a.crossings2D(b, tolerance = 1f).map { it.point }
val hitDots = polylineFrom(hits.map { it.v2 }).apply {
    pointed = true
    pointSize = 2.0
    stroked = false
    filled = true
    color = vec(1.0, 0.5, 0.2, 1.0)
}

lines(a, b, hitDots, w = 420.0, h = 260.0, fitToSize = true)

fline_examples-72

Curvature + tangents/normals

Curvature is how fast the direction is turning. These helpers find corners and other features, and are useful for debugging and stylization.

:::static
fun FLine.curvatureAnalysis(samples: Int = 100): List<Pair<Vec3, Float>>
fun FLine.findHighCurvaturePoints(threshold: Float = 0.1f, samples: Int = 100): List<Vec3>
fun FLine.byCurvatureAdaptiveSampling(minSamples: Int = 20, maxSamples: Int = 200): FLine

fun FLine.byCreatingTangentLines(samples: Int = 20, length: Float = 10f): List<FLine>
fun FLine.byCreatingNormalLines(samples: Int = 20, length: Float = 10f): List<FLine>
:::static
val f = fline {
    moveTo(60, 190)
    cubicTo(120, 20, 260, 280, 360, 120)
    thickness = 2.0
    color = vec(1.0, 1.0, 1.0, 1.0)
}

val tangents = f.byCreatingTangentLines(samples = 18, length = 22f).onEach {
    it.thickness = 1.5
    it.color = vec(0.7, 0.9, 1.0, 0.7)
}

val normals = f.byCreatingNormalLines(samples = 18, length = 18f).onEach {
    it.thickness = 1.5
    it.color = vec(0.4, 1.0, 0.6, 0.7)
}

val highCurvature = f.findHighCurvaturePoints(threshold = 0.02f, samples = 120)
val corners =
    if (highCurvature.isEmpty()) emptyList()
    else
        [
            polylineFrom(highCurvature.map { it.v2 }).apply {
                pointed = true
                pointSize = 1.2
                stroked = false
                filled = true
                color = vec(1.0, 0.5, 0.2, 1.0)
            },
        ]

lines([f] + tangents + normals + corners, w = 420.0, h = 260.0, fitToSize = true)

fline_examples-74

Convex hull + inflections

byConvexHull2D() wraps a stroke’s points with a tight convex polygon (useful for quick outlines and coarse collision). Splitting at inflections breaks a curve into pieces that each bend one way.

:::static
fun FLine.byConvexHull2D(): FLine
fun FLine.bySplittingAtInflections(): List<FLine>
:::static
val scribble = fline {
    moveTo(60, 160)
    cubicTo(120, 10, 160, 280, 220, 120)
    cubicTo(280, -10, 320, 280, 360, 140)
    thickness = 3.0
    color = vec(1.0, 1.0, 1.0, 0.6)
}

val hull = scribble.byConvexHull2D().apply {
    filled = true
    stroked = true
    thickness = 2.0
    color = vec(0.4, 1.0, 0.6, 0.20)
}

val pieces =
    scribble.bySplittingAtInflections().mapIndexed { i, p ->
        p.apply {
            thickness = 4.0
            color = if (i % 2 == 0) vec(1.0, 0.5, 0.2, 0.65) else vec(0.7, 0.9, 1.0, 0.65)
        }
    }

// A dot where each piece begins: the inflections, plus the start.
val splits = pieces.map { p ->
    val s = p.sampleByNumber(2).first()
    fline {
        moveTo(s.x, s.y)
        lineTo(s.x + 0.001, s.y + 0.001)
        pointed = true
        pointSize = 1.5
        stroked = false
        color = vec(1.0, 1.0, 1.0, 1.0)
    }
}

lines([hull] + pieces + splits, w = 420.0, h = 260.0, fitToSize = true)

fline_examples-76

Parameterized operations (vary along the curve)

Sometimes you want an effect that changes along the path: offset more near one end, add noise only in the middle, or scale based on curvature. A ParameterFunction is a number that varies with position along the curve.

:::static
interface ParameterFunction {
    fun evaluate(t: Float, position: Vec3): Float
}

class ConstantParameter(val value: Float) : ParameterFunction
class LinearParameter(val start: Float, val end: Float) : ParameterFunction
class SineParameter(val amplitude: Float, val frequency: Float, val phase: Float = 0f, val offset: Float = 0f) :
    ParameterFunction
class DistanceParameter(val point: Vec3, val minValue: Float, val maxValue: Float, val maxDistance: Float) :
    ParameterFunction
class CurvatureParameter(val line: FLine, val minValue: Float, val maxValue: Float, val maxCurvature: Float = 1f) :
    ParameterFunction

fun parameterFromLambda(func: (t: Float, pos: Vec3) -> Float): ParameterFunction

fun FLine.byOffsettingParameterized(distanceFunc: ParameterFunction, samples: Int = 100): FLine
fun FLine.byNoisingParameterized(
    amplitudeFunc: ParameterFunction,
    seed: Long = System.currentTimeMillis(),
    samples: Int = 100,
): FLine
fun FLine.byScalingParameterized(scaleFunc: ParameterFunction, center: Vec2 = vec(0f, 0f), samples: Int = 100): FLine

This example varies a scale factor along the same base curve, about a fixed centre. A SineParameter makes the curve breathe in and out as you travel it, and a DistanceParameter makes it swell the farther a point sits from a chosen “quiet point”. (byOffsettingParameterized and byNoisingParameterized vary an offset or a jitter along the curve normal the same way; scaling is shown here because it reads cleanly in a single still.)

:::static
// A multi-segment base curve. byScalingParameterized samples along the whole
// path and scales each sample about a fixed centre by a per-position factor.
val base = fline {
    moveTo(60, 300)
    cubicTo(180, 40, 300, 40, 420, 300)
    cubicTo(520, 470, 660, 470, 760, 220)
}
val centre = vec(410f, 260f)

val clean = base.copy().apply {
    thickness = 4.0
    color = vec(1.0, 1.0, 1.0, 0.35)
}

// Scale oscillates along the path (0.72 .. 1.28): the curve breathes in and out.
val breathing =
    base
        .byScalingParameterized(
            scaleFunc = SineParameter(amplitude = 0.28f, frequency = 3.0f, phase = 0f, offset = 1.0f),
            center = centre,
            samples = 220,
        )
        .apply {
            thickness = 3.0
            color = vec(0.7, 0.9, 1.0, 0.9)
        }

// Scale grows with distance from a quiet point (0.9 near it .. 1.5 far away):
// the curve hugs the base near the point and swells away from it.
val quietPoint = vec(120, 120, 0)
val swell = DistanceParameter(point = quietPoint, minValue = 0.9f, maxValue = 1.5f, maxDistance = 620f)
val flared = base.byScalingParameterized(scaleFunc = swell, center = centre, samples = 220).apply {
    thickness = 4.5
    color = vec(1.0, 0.6, 0.2, 0.9)
}

val qp = fline {
    moveTo(quietPoint.x, quietPoint.y)
    lineTo(quietPoint.x + 0.001, quietPoint.y + 0.001)
    pointed = true
    pointSize = 6.0
    stroked = false
    filled = true
    color = vec(0.4, 1.0, 0.6, 1.0)
}

lines(clean, breathing, flared, qp, w = 900.0, h = 520.0, fitToSize = true)

fline-parameterized

Curve combination utilities

These helpers sample two curves and combine their points, or map one curve onto another using closest-point projection.

:::static
fun FLine.byAddingWith(other: FLine, samples: Int = 100): FLine
fun FLine.bySubtractingWith(other: FLine, samples: Int = 100): FLine
fun FLine.byMultiplyingWith(other: FLine, samples: Int = 100): FLine

fun FLine.byProjectingOnto(other: FLine, samples: Int = 100): FLine
fun FLine.byInverting(center: Vec2 = vec(0f, 0f), radius: Float = 100f, samples: Int = 100): FLine

byInverting is geometric inversion; if you want the bigger picture, see inversive geometry.

:::static
// A smooth target curve (a circle-ish polyline)
val center = vec(210.0, 130.0)
val radius = 95.0

val circle = fline {
    breakNext()
    for (i in 0..140) {
        val a = (i / 140.0) * kotlin.math.PI * 2.0
        val p = vec(center.x + radius * kotlin.math.cos(a), center.y + radius * kotlin.math.sin(a))
        lineTo(p)
    }
    thickness = 8.0
    color = vec(1.0, 1.0, 1.0, 0.35)
}

// A “messy” version of it
val messy = circle.byPerlinNoising(amplitude = 26f, frequency = 0.03f, octaves = 4, seed = 10L, samples = 180).apply {
    thickness = 2.5
    color = vec(1.0, 0.6, 0.2, 0.65)
}

// Project the messy samples back onto the circle: the green dots land on the pale band.
val projected = messy.byProjectingOnto(circle, samples = 60).apply {
    thickness = 1.5
    pointed = true
    pointSize = 3.0
    color = vec(0.4, 1.0, 0.6, 1.0)
}

lines(circle, messy, projected, w = 420.0, h = 260.0, fitToSize = true)

fline_examples-80

Wiggling one line along another: wiggledBy(...)

path.wiggledBy(wave) lays wave along path. The wave's x says how far along the path a point goes, and its y pushes the point out along the path's normal. A wave that doubles back in x doubles back along the path, so loops come through as loops.

:::static
fun FLine.wiggledBy(
    a: FLine,
    repeats: Int = 1,
    subtractChord: Boolean = true,
    amplitude: Number = 1.0,
    resolution: Int = 200,
    up: Vec3? = Vec3(0f, 0f, 1f),
): FLine

fun FLine.sweptFrames(samples: Int = 400, up: Vec3? = Vec3(0f, 0f, 1f)): SweptFrames?

repeats plays the wave back that many times, each copy over an equal share of the path. amplitude scales the sideways push. With subtractChord on (the default), the wave is read relative to the straight line from its start to its end, so a wave drawn on a slope wiggles just as well as a level one, and each copy starts and ends on the path. Turn it off and the wave is read in world axes: its y is measured from y = 0, so whatever height it gains by its end stays in the result.

Below, the same sloped wave goes along one curve three ways: once with the slope removed, eight times, and once with the slope kept (orange), which leaves the end of the line up off the curve.

:::static
// A wave drawn on a slope: two bumps and a little ripple.
val wave = fline {
    moveTo(0, 0)
    for (i in 1..120) {
        val x = i / 120.0
        lineTo(x, 0.6 * x + Math.sin(x * 4 * Math.PI) * 0.25 + Math.sin(x * 14 * Math.PI) * 0.05)
    }
}

val path = fline {
    moveTo(30, 60)
    cubicTo(160, -40, 260, 220, 400, 90)
    color = vec(1.0, 1.0, 1.0, 0.3)
}

val once = path.wiggledBy(wave, amplitude = 60).apply {
    color = vec(0.4, 0.9, 1.0, 0.9)
    thickness = 2.0
}
val eight = (path + vec(0.0, 110.0)).wiggledBy(wave, repeats = 8, amplitude = 25).apply {
    color = vec(0.5, 1.0, 0.6, 0.9)
    thickness = 2.0
}
val kept = (path + vec(0.0, 220.0)).wiggledBy(wave, subtractChord = false, amplitude = 60).apply {
    color = vec(1.0, 0.6, 0.2, 0.9)
    thickness = 2.0
}

lines(
    path,
    path + vec(0.0, 110.0),
    path + vec(0.0, 220.0),
    once,
    eight,
    kept,
    w = 440.0,
    h = 360.0,
    fitToSize = true,
)

fline_examples-82

In 3D the wave's y follows the path's normal and its z the binormal. With subtractChord = false those offsets are measured from y = z = 0, so a circle drawn around the origin winds a coil centred on the path. The frames come from the path's own shape (they turn as little as the path forces them to, starting from the side the path bends toward), so rotating the path turns the coil with it and the coil never flips. Only a straight path, which bends toward no side, takes its starting normal from up. path.sweptFrames() gives you the frames themselves, and its strength (0 to 1) says how clearly the path's shape chose the starting side.

:::static
// One turn of a circle around y = z = 0, with x running along: a coil when laid on a path.
val turn = fline {
    moveTo(0, 1, 0)
    for (i in 1..60) {
        val a = i / 60.0 * 2 * Math.PI
        lineTo(i / 60.0, Math.cos(a), Math.sin(a))
    }
}

val path = fline {
    moveTo(-150, 0, -60)
    cubicTo(-60, 160, 120, 60, -160, -120, 150, 40, 80)
    color = vec(1.0, 1.0, 1.0, 0.4)
}

val coil = path.wiggledBy(turn, repeats = 30, subtractChord = false, amplitude = 14, resolution = 60).apply {
    color = vec(0.4, 0.9, 1.0, 0.9)
    thickness = 1.5
}

threeLines(path, coil, channel = "coil3d", height = 430, editable = false)

fline_examples-83

Path operations and fitting

These are higher-level operations that treat an FLine as something you can edit, split, reverse, or fit/smooth into a new representation.

Reverse / blend / extract

Operation Behavior
fun FLine.byReversing(): FLine Reverses each subpath while preserving its closed or open status. Use it to change traversal direction without resampling the curve.
fun FLine.byBlendingWith(other: FLine, t: Float): FLine Samples both curves and linearly blends corresponding positions into a polyline: t = 0 selects the first curve and t = 1 the second. Use FLineInterpolator below when contour matching and preserved structure matter.
fun FLine.byExtracting(tStart: Float, tEnd: Float): FLine Returns the portion between two cursor parameters. These use segment coordinates, not a normalized fraction of total arc length; a start beyond the curve returns an empty line.
:::static
val a = fline {
    moveTo(0, 150)
    cubicTo(40, 0, 160, 200, 200, 50)
    thickness = 1.0
    color = vec(1.0, 1.0, 1.0, 0.35)
}

// 1: reversed. The same shape; the dot marks where it now starts.
val b = a.byReversing().apply {
    thickness = 2.0
    color = vec(1.0, 0.4, 0.4, 1.0)
}
val bStart = b.sampleByNumber(2).first()
val startDot = fline {
    moveTo(bStart.x, bStart.y)
    lineTo(bStart.x + 0.001, bStart.y + 0.001)
    pointed = true
    pointSize = 5.0
    stroked = false
    color = vec(1.0, 0.4, 0.4, 1.0)
}

// 2: half way between a and a flat line.
val flat = fline {
    moveTo(0, 150)
    lineTo(200, 150)
    thickness = 1.0
    color = vec(1.0, 1.0, 1.0, 0.35)
}
val mid = a.byBlendingWith(flat, t = 0.5f).apply {
    thickness = 2.0
    color = vec(0.4, 1.0, 0.6, 1.0)
}

// 3: the piece of the cubic between t = 0.3 and t = 0.8
val snippet = a.byExtracting(tStart = 0.3f, tEnd = 0.8f).apply {
    thickness = 3.0
    color = vec(1.0, 0.7, 0.2, 1.0)
}

val dx = vec(260.0, 0.0)
val dx2 = vec(520.0, 0.0)
lines(
    a,
    b,
    startDot,
    a + dx,
    flat + dx,
    mid + dx,
    a + dx2,
    snippet + dx2,
    w = 720.0,
    h = 220.0,
    fitToSize = true,
)

fline_examples-84

Left: the reversed line, starting at the dot. Middle: half way between the curve and a flat line. Right: the piece of the cubic between t = 0.3 and t = 0.8.

Contour-aware morphing: FLineInterpolator

FLineInterpolator resamples and matches two contours before morphing between them. This helps when the shapes have different point counts or their outlines start in different places. It works reliably on a single contour: one moveTo(...), with open contours as the safest default.

:::static
class FLineInterpolator(
    from: FLine,
    to: FLine,
    config: FLineInterpolationConfig = FLineInterpolationConfig(),
)

fun FLine.interpolatorTo(
    other: FLine,
    config: FLineInterpolationConfig = FLineInterpolationConfig(),
): FLineInterpolator

fun FLine.byInterpolatingTo(
    other: FLine,
    t: Float,
    config: FLineInterpolationConfig = FLineInterpolationConfig(),
    motion: FLineInterpolationMotion = config.motion,
): FLine

fun FLineInterpolator.lineAt(
    t: Float,
    motion: FLineInterpolationMotion = config.motion,
): FLine

Vanilla: one interpolator, several frames

:::static
// An S that morphs into a curl. They have different numbers of segments:
// the interpolator resamples both so that they can be matched point for point.
val f = fline {
    moveTo(0, 50)
    cubicTo(30, 0, 70, 100, 100, 50)
}

val f2 = fline {
    moveTo(0, 90)
    cubicTo(0, 0, 100, 0, 100, 60)
    cubicTo(100, 100, 45, 100, 45, 65)
}

fun ink(line: FLine, tint: Vec4, thick: Double) = line.apply {
    filled = false
    stroked = true
    thickness = thick
    color = tint
}

// blue at t = 0, through to orange at t = 1
fun tint(t: Float) = vec(0.4 + 0.6 * t, 0.85 - 0.25 * t, 1.0 - 0.75 * t, 1.0)

val n = 7
val step = 140.0

val ii = FLineInterpolator(f, f2)

// One frame per column, left to right, rather than all of them on top of each other.
val frames =
    (0 until n).map { i ->
        val t = i / (n - 1f)
        ink(ii.lineAt(t), tint(t), 3.0) + vec(i * step, 0.0)
    }

lines(frames, w = 720.0, h = 160.0, fitToSize = true)

fline_examples-86

This is the basic pattern: two contours in, one reusable interpolator out, then sample lineAt(t) as many times as you want. The seven frames run from t = 0 (the S, blue) to t = 1 (the curl, orange).

Spicy 1: rotation-aware matching

:::static
fun asymmetricStroke() =
    polylineFrom(
            [
                vec(-150, 20),
                vec(-80, -110),
                vec(-10, -30),
                vec(35, 120),
                vec(90, 40),
                vec(150, -60),
            ],
        )
        .apply {
            filled = false
            stroked = true
            thickness = 2.0
        }

val start = asymmetricStroke()
val end =
    asymmetricStroke()
        .byRotating2D(1.7f, center = vec(0f, 0f))
        .byScaling2D(0.78f, 1.24f, center = vec(0f, 0f))
        .byShearing2D(0.18f, -0.08f)

val vanilla = start.interpolatorTo(end)
val aligned = start.interpolatorTo(
    end,
    config = FLineInterpolationConfig(
        alignment = FLineInterpolationAlignment(
            rotationMode = FLineInterpolationRotationMode.FIT_LINE_2D,
            scaleMode = FLineInterpolationScaleMode.BOUNDS_DIAGONAL,
        ),
    ),
)

// Nine in-between shapes, t = 0.1 ... 0.9, shaded from the start's blue to the end's orange.
fun sweep(interpolator: FLineInterpolator) = (1..9).map { i ->
    val t = i / 10f
    interpolator.lineAt(t).apply {
        filled = false
        stroked = true
        thickness = 1.6
        color = vec(0.45 + 0.55 * t, 0.75 - 0.2 * t, 1.0 - 0.75 * t, 0.85)
    }
}

fun ghost(line: FLine, tint: Vec4) = line.copy().apply {
    filled = false
    stroked = true
    thickness = 1.8
    color = tint
}

val leftShift = vec(-190, 0, 0)
val rightShift = vec(190, 0, 0)

val panels = [
    fline {
        rect(-330, -160, 300, 320)
        filled = true
        stroked = false
        color = vec(0.12, 0.15, 0.19, 0.28)
    },
    fline {
        rect(30, -160, 300, 320)
        filled = true
        stroked = false
        color = vec(0.15, 0.12, 0.09, 0.28)
    },
]

val alignedLines: List<FLine> = [
    ghost(start, vec(0.76, 0.9, 1.0, 0.20)) + leftShift,
    ghost(end, vec(1.0, 0.8, 0.5, 0.20)) + leftShift,
    ghost(start, vec(0.76, 0.9, 1.0, 0.20)) + rightShift,
    ghost(end, vec(1.0, 0.8, 0.5, 0.20)) + rightShift,
] + sweep(vanilla).map { it + leftShift } + sweep(aligned).map { it + rightShift }
val comparison = panels + alignedLines

lines(comparison, w = 720.0, h = 360.0, fitToSize = true)

fline_examples-87

Both panels show nine in-between shapes from t = 0.1 (blue) to t = 0.9 (orange). On the left, the default interpolator moves each point straight to its partner, so the in-between shapes zigzag and collapse toward the centre as they cut across the rotation. On the right, FIT_LINE_2D aligns the shapes before matching points, so the in-between strokes sweep around more smoothly, though they still pinch near the centre.

Spicy 2: extra motion at evaluation time

:::static
// An S that morphs into a curl. They have different numbers of segments:
// the interpolator resamples both so that they can be matched point for point.
val f = fline {
    moveTo(0, 50)
    cubicTo(30, 0, 70, 100, 100, 50)
}

val f2 = fline {
    moveTo(0, 90)
    cubicTo(0, 0, 100, 0, 100, 60)
    cubicTo(100, 100, 45, 100, 45, 65)
}

fun ink(line: FLine, tint: Vec4, thick: Double) = line.apply {
    filled = false
    stroked = true
    thickness = thick
    color = tint
}

// blue at t = 0, through to orange at t = 1
fun tint(t: Float) = vec(0.4 + 0.6 * t, 0.85 - 0.25 * t, 1.0 - 0.75 * t, 1.0)

val n = 7
val step = 140.0

val plain = FLineInterpolator(f, f2)
val spun = FLineInterpolator(
    f,
    f2,
    FLineInterpolationConfig(
        motion = FLineInterpolationMotion(
            spatialSpinAngle = 1f,
            spatialSpinAxis = vec(0f, 1f, 0.25f),
            spatialSpinMode = FLineInterpolationSpinMode.MIDPOINT_RETURN,
        ),
    ),
)

// Both rows go through the same perspective camera, looking at the middle of the shapes.
fun seen(line: FLine) = line.byPerspectiveProjecting(cameraPos = vec(50f, 50f, -400f))

// Top row: the plain morph. Bottom row: the same morph with the spin.
val frames =
    (0 until n).flatMap { i ->
        val t = i / (n - 1f)
        val at = vec(i * step, 0.0)
        [
            ink(seen(plain.lineAt(t)), vec(1.0, 1.0, 1.0, 0.45), 2.0) + at,
            ink(seen(spun.lineAt(t)), tint(t), 3.0) + at + vec(0.0, 150.0),
        ]
    }

lines(frames, w = 720.0, h = 300.0, fitToSize = true)

fline_examples-88

This is the same morph with spatialSpinAngle = 1f in the interpolator config, and both rows go through a perspective projection. The top row is the plain morph, for comparison. In the bottom row the first and last frames still match the source and target, and the spin turns the line away from the camera only in the middle of the morph.

Circle fitting + subdivision

Operation Behavior
fun FLine.fitCircle(samples: Int = 20): Triple<Vec2, Float, Float> Fits a circle to samples of the XY curve and returns its center, radius and fitting error. This is an approximation rather than an exact recognition of circular arcs.
fun FLine.bySubdividing(levels: Int = 1): FLine Splits each line or cubic segment in half for every level, while retaining subpath boundaries and closure. Cubics use De Casteljau subdivision, preserving the curve rather than replacing it with a polyline.

Use fitting to summarize a shape, and subdivision when later editing needs more segments without changing its geometry.

Here a jittered ring is fitted, and one cubic is subdivided zero, one and two times, with its nodes drawn as dots:

// A rough, hand-drawn-looking circle: 40 points at a jittered radius around (60, 60).
val rnd = java.util.Random(4)
val rough = fline {
    for (i in 0..40) {
        val a = 2 * Math.PI * i / 40
        val r = 50 + 6 * rnd.nextGaussian()
        if (i == 0) moveTo(60 + r * Math.cos(a), 60 + r * Math.sin(a))
        else lineTo(60 + r * Math.cos(a), 60 + r * Math.sin(a))
    }
}
    .apply { color = vec(1.0, 1.0, 1.0, 0.8) }

val (centre, radius, error) = rough.fitCircle(samples = 80)
val fitted = FLine().circle(centre.x.toDouble(), centre.y.toDouble(), radius.toDouble()).apply {
    color = vec(1.0, 0.55, 0.2, 1.0)
    thickness = 2.0
}

// One cubic, subdivided 0, 1 and 2 times: the nodes multiply, the curve does not move.
val wave = fline {
    moveTo(0, 60)
    cubicTo(30, -10, 70, 130, 100, 60)
}
val levels =
    (0..2).flatMap { level ->
        val at = vec(170.0 + level * 150.0, 0.0)
        val sub = wave.bySubdividing(level)
        [
            sub.copy().apply { color = vec(0.32, 0.82, 1.0, 1.0) } + at,
            sub.copy().apply {
                stroked = false
                pointed = true
                pointSize = 1.2
                color = vec(1.0, 0.5, 0.25, 1.0)
            } + at,
        ]
    }

display(
    "fit",
    "fitCircle: centre (%.1f, %.1f), radius %.1f, error %.2f · bySubdividing levels 0, 1, 2"
        .format(centre.x, centre.y, radius, error),
)
lines([rough, fitted] + levels, w = 720.0, h = 220.0, fitToSize = true)

Left, a jagged grey ring with the orange circle fitCircle found drawn through it; right, the same blue S-curve three times, with two, three and five orange nodes on it, the curve unchanged; above, a line giving the fitted centre, radius and error

Advanced / experimental curves (FLineGeometry)

These are more specialized tools that are still extremely useful when you need them.

Euler spiral in 3D (clothoid-ish connection)

This builds the curve between two points that leaves along one direction and arrives along another, with curvature changing at a constant rate along its length. It’s good for motion paths and for joining strokes without a kink. The curve is solved in the plane of the two directions; if the end point is out of that plane, the curve climbs to it at a constant slope.

:::static
fun eulerSpiralBetween3D(
    start: Vec3,
    startDirection: Vec3,
    end: Vec3,
    endDirection: Vec3,
    samples: Int = 200,
    iterations: Int = 6,
    lengthScale: Float = 1f,
    headingScale: Float = 1f,
    turn: Float = 0f,
): FLine

fun FLine.byEulerSpiralTo(
    endPoint: Vec3,
    startDirection: Vec3? = null,
    endDirection: Vec3? = null,
    samples: Int = 200,
    iterations: Int = 6,
    lengthScale: Float = 1f,
    headingScale: Float = 1f,
    turn: Float = 0f,
): FLine

fun FLine.eulerSpiralTo(
    endPoint: Vec3,
    startDirection: Vec3? = null,
    endDirection: Vec3? = null,
    samples: Int = 200,
    iterations: Int = 6,
    lengthScale: Float = 1f,
    headingScale: Float = 1f,
    turn: Float = 0f,
): FLine.Node

This is essentially an Euler spiral / clothoid.

:::static
val start = vec(60, 180, 0)
val end = vec(360, 120, 0)

val f = fline {
    moveTo(start)
    // Start direction points right; end direction points up-left.
    eulerSpiralTo(
        endPoint = end,
        startDirection = vec(1f, 0f, 0f),
        endDirection = vec(-0.2f, -1f, 0f),
        samples = 160,
        iterations = 6,
    )
    thickness = 2.0
    color = vec(1.0, 0.7, 0.2, 1.0)
}

val endDirs = [
    seg(start.v2, (start + vec(60, 0, 0)).v2, pointed = false, color = vec(1.0, 1.0, 1.0, 0.25)).apply {
        thickness = 2.0
    },
    seg(end.v2, (end + vec(-15, -60, 0)).v2, pointed = false, color = vec(1.0, 1.0, 1.0, 0.25)).apply {
        thickness = 2.0
    },
]

val baseLine: List<FLine> = [f]
lines(endDirs + baseLine, w = 420.0, h = 260.0, fitToSize = true)

fline_examples-91

Hyperbezier (curvature-controlled stand-in for a cubic)

hyperbezierTo takes exactly the four points cubicTo takes — a start, two control points, an end — and draws a different curve through that polygon: a hyperbezier, whose curvature is a rational function of arc length rather than whatever a cubic's polynomial happens to give you. So it is a drop-in swap: change the call, keep the handles.

:::static
fun FLine.hyperbezierTo(
    control1: Vec3,
    control2: Vec3,
    endPoint: Vec3,
    tolerance: Number = 1e-4,
    maxSegments: Int = 16,
    cornerBias: Number = 1.0,
    armResponse: Number = 2.0
): FLine

fun FLine.byHyperbezierTo(control1: Vec3, control2: Vec3, endPoint: Vec3, ...): FLine

fun hyperbezierBetween(p0: Vec3, control1: Vec3, control2: Vec3, p3: Vec3, ...): FLine

fun hyperbezierThrough(
    points: List<Vec3>,
    tension: Number = 1.0,
    closed: Boolean = false,
    ...
): FLine

fun fitHyperbezierCubics(p0: Vec3, control1: Vec3, control2: Vec3, p3: Vec3, ...): HyperbezierFit

fun circleArm(angle: Double): Double

It hands back several ordinary CubicTo nodes fitted to that curve, which means nothing downstream has to know about any of it — renderers, boolean ops, offsetting and hit-testing all keep working. The joins are G1 by construction, because every one of them uses the hyperbezier's own tangent.

The interesting behaviour is what happens as the arms grow. circleArm(theta) is the arm length at which the curve is an exact circular arc — and an exact Euler spiral when the two tangent angles disagree. Push the arms out past that and a hyperbezier tightens towards a corner, where a cubic merely bulges.

:::static
// Six panels, reading left to right then top to bottom. The chord and the +/-45 degree
// end tangents are held fixed; only the arm length changes, as a multiple of circleArm.
val chord = 200.0
val theta = Math.PI / 4
val multiples = [0.4, 0.7, 1.0, 1.4, 1.9, 2.4]

val out = mutableListOf<FLine>()
multiples.forEachIndexed { i, multiple ->
    val arm = multiple * circleArm(theta)
    val ox = (i % 3) * (chord + 55.0)
    val oy = (i / 3) * 195.0
    val p0 = vec(ox, oy, 0)
    val p3 = vec(ox + chord, oy, 0)
    // y grows downwards in the 2D outputs, so the arms lift by going negative.
    val c1 = vec(ox + arm * chord * Math.cos(theta), oy - arm * chord * Math.sin(theta), 0)
    val c2 = vec(ox + chord - arm * chord * Math.cos(theta), oy - arm * chord * Math.sin(theta), 0)

    // the control polygon both curves are reading
    out += fline {
        moveTo(p0)
        lineTo(c1)
        lineTo(c2)
        lineTo(p3)
        thickness = 1.0
        color = vec(1.0, 1.0, 1.0, 0.18)
    }
    // the ordinary cubic through it
    out += fline {
        moveTo(p0)
        cubicTo(c1, c2, p3)
        thickness = 6.0
        color = vec(1.0, 1.0, 1.0, 0.22)
    }
    // the hyperbezier, as fitted cubics
    out += fline {
        moveTo(p0)
        hyperbezierTo(c1, c2, p3)
        thickness = 2.0
        color = vec(0.35, 0.66, 1.0, 1.0)
    }
}

lines(out, w = 700.0, h = 320.0, fitToSize = true)

fline_examples-93

The thick pale line is the plain cubic, the blue one is the hyperbezier, and the faint outline is the control polygon they are both reading. The first two panels have short arms and the curves are nearly flat and nearly identical. The third is circleArm exactly: here the hyperbezier is a true circular arc, and the cubic — which is only ever an approximation to one — is close enough to hide underneath it. The bottom row is where they part company. At 1.4x, 1.9x and 2.4x the circle arm the cubic is pulled up into a near-point at the control polygon's apex, while the hyperbezier stays lower and keeps its curvature spread along the arc.

The one thing the family cannot do is a cubic's second inflection, so a polygon that crosses itself in an S-of-an-S is not representable. Everything else about the shape is fair game.

Use fitHyperbezierCubics directly when you want the numbers rather than the line:

:::static
val fit = fitHyperbezierCubics(p0, c1, c2, p3, tolerance = 1e-4)
fit.segmentCount // how many cubics it took
fit.maxError // worst distance from the true curve, in world units
fit.toleranceMet // false if maxSegments ran out first
fit.solved // false if the solver stalled; cubics is then your input, unchanged
fit.cubics // the (control1, control2, end) triples

tolerance is a fraction of the chord length, so a fit does not change character with scale, and maxSegments is spent on whichever piece is currently worst — which matters for short-armed shapes, where all the error lives in a tight hook at each end and none of it in the middle.

hyperbezierThrough runs the same machinery over a whole point list, estimating tangents Catmull-Rom style and fitting each span:

:::static
val pts = [
    vec(40, 70, 0),
    vec(150, 200, 0),
    vec(260, 80, 0),
    vec(370, 210, 0),
    vec(470, 60, 0),
]

val loose = hyperbezierThrough(pts, tension = 1.0).apply {
    thickness = 2.5
    color = vec(0.35, 0.66, 1.0, 1.0)
}
val tight = hyperbezierThrough(pts, tension = 0.45).apply {
    thickness = 2.0
    color = vec(1.0, 0.62, 0.25, 0.9)
}

val dots = pts.map { p ->
    rect(p.x - 3.0, p.y - 3.0, 6.0, 6.0).toFLine(color = vec(1.0, 1.0, 1.0, 0.75), filled = true, stroked = false)
}

val curves: List<FLine> = [loose, tight]
lines(dots + curves, w = 520.0, h = 260.0, fitToSize = true)

fline_examples-95

Blue is the default tension = 1.0; orange is tension = 0.45, which shortens the estimated arms and pulls the curve tighter to the polyline. Raising tension past circleArm territory is what turns the corners sharp rather than round — the same dial as the panels above, applied to every span at once.

Boolean ops (2D)

Operation Behavior
fun FLine.byUnionWith(other: FLine, exact: Boolean = false): FLine Returns the union of the two filled XY regions using Java2D Area. The resulting boundary includes all regions covered by either input.
fun FLine.byDifferenceWith(other: FLine, exact: Boolean = false): FLine Subtracts the other filled XY region from this one, returning a new boundary. Swapping the two inputs changes which region is cut away.
fun FLine.byIntersectionWith(other: FLine, exact: Boolean = false): FLine Returns the filled XY region shared by both inputs. Disjoint regions produce an empty result.
fun FLine.byExclusiveOrWith(other: FLine, exact: Boolean = false): FLine Returns the XY regions covered by exactly one input. Overlap is removed from the result.
fun FLine.booleanExact(other: FLine, op: FLineBooleanOp): FLineBooleanResult The curve-preserving boolean (Union, Intersect, Subtract, Xor): line is the result, exact says whether it came from the exact engine or from the Area fallback, and fallbackReason says why.

By default these use Java2D Area (via FLinesAndJavaShapes). Both inputs are read as filled regions under the non-zero rule, whatever their thickness, and an open line is closed with a straight segment; pass stroke = true to use a styled input's stroke outline instead. Pass exact = true to get the curve-preserving engine described under Exact booleans and offsets below.

:::static
// The inputs' style does not enter the boolean; the results are styled below.
val a = fline {
    moveTo(80, 80)
    lineTo(260, 80)
    lineTo(260, 220)
    lineTo(80, 220)
    close()
}

val b = fline {
    moveTo(170, 40)
    lineTo(340, 140)
    lineTo(170, 240)
    lineTo(40, 140)
    close()
}

val u = a.byUnionWith(b).apply {
    color = vec(0.4, 1.0, 0.6, 0.40)
    filled = true
    stroked = true
    thickness = 3.0
}
val i = a.byIntersectionWith(b).apply {
    color = vec(1.0, 0.5, 0.2, 0.55)
    filled = true
    stroked = true
    thickness = 3.0
}

// Faint copies of the inputs to draw underneath.
fun ghost(f: FLine, offset: Vec2) = (f + offset).apply {
    filled = true
    stroked = true
    thickness = 1.0
    color = vec(1.0, 1.0, 1.0, 0.15)
}

// inputs on the left, then the union, then the intersection, each over faint copies of the inputs
val dx = vec(340.0, 0.0)
val dx2 = vec(680.0, 0.0)
val none = vec(0.0, 0.0)
lines(
    ghost(a, none),
    ghost(b, none),
    ghost(a, dx),
    ghost(b, dx),
    u + dx,
    ghost(a, dx2),
    ghost(b, dx2),
    i + dx2,
    w = 720.0,
    h = 220.0,
    fitToSize = true,
)

fline_examples-96

Left: the square and the diamond. Middle: their union (green). Right: their intersection (orange).

Exact booleans and offsets

booleanExact cuts both outlines exactly where they cross and keeps the pieces of the original curves. A cubic in the result is a piece of a cubic you drew, and a straight edge stays a LineTo. Area also keeps cubics, and it is just as accurate, but it splits every curve at its highest and lowest points, so its answers have more segments than they need. Each dot below is a node: the Area union of the blob and the circle has 9 segments and the exact one has 7. Before returning, the exact engine checks its answer's area against the Area answer. If they disagree, or the outline will not close into loops, you get the Area answer, and exact = false tells you so.

:::static
fun circle(cx: Double, cy: Double, r: Double) = FLine().circle(cx, cy, r)

fun blob(x: Double, y: Double) = fline {
    moveTo(x, y)
    cubicTo(x + 40, y - 30, x + 90, y + 10, x + 100, y + 50)
    cubicTo(x + 110, y + 90, x + 60, y + 70, x + 50, y + 110)
    cubicTo(x + 40, y + 150, x - 20, y + 120, x - 10, y + 80)
    cubicTo(x, y + 50, x - 30, y + 30, x, y)
    close()
}

fun dot(x: Float, y: Float, c: Vec4) = fline {
    circle(x, y, 2.5)
    filled = true
    stroked = false
    color = c
}

val out = mutableListOf<FLine>()
for ((row, op) in [FLineBooleanOp.Union, FLineBooleanOp.Subtract].withIndex()) {
    for (col in 0..1) {
        val x = 40.0 + col * 260
        val y = 40.0 + row * 190
        val a = blob(x + 20, y)
        val b = circle(x + 90, y + 60, 45.0)
        val r =
            if (col == 1) a.booleanExact(b, op).line
            else if (op == FLineBooleanOp.Union) a.byUnionWith(b) else a.byDifferenceWith(b)
        val c = if (col == 0) vec(1.0, 0.45, 0.25, 1.0) else vec(0.3, 0.8, 1.0, 1.0)
        r.filled = true
        r.stroked = true
        r.thickness = 1.5
        r.color = vec(c.x, c.y, c.z, 0.35)
        out.add(r)
        r.nodes.mapNotNull { it.toOrNull }.forEach { out.add(dot(it.x, it.y, c)) }
    }
}

lines(out, w = 460.0, h = 380.0, fitToSize = true)

The union (top) and difference (bottom) of a blob and a circle, orange through Area on the left with a dot on each of its nodes, blue through booleanExact on the right with fewer dots

Offsets are where the difference shows. byOffsetting samples the curve and joins the samples with straight lines, 50 per node, and leaves the corners open. byOffsettingExact returns cubics, typically one to four per input cubic, within tolerance of the true offset. It gives corners a join (FLineStrokeJoin.ROUND, MITER or BEVEL, with miterLimit). On a closed outline it removes the loops that form at concave corners and the parts that collapse when you shrink a shape. On an open line it trims the inner corners and cuts out the loops that form where the line bends tighter than the offset distance. Positive distances go the same way as in byOffsetting, towards normal2D.

Operation Behavior
fun FLine.byOffsettingExact(distance: Float, tolerance: Float = 0.01f, join: FLineStrokeJoin = FLineStrokeJoin.ROUND, miterLimit: Float = 4f): FLine Offsets every subpath by distance with cubic output, held within tolerance of the true offset, with join at corners. Closed subpaths come back as clean outlines; open ones as a single run each.
fun FLine.segmentCount2D(): Int The number of drawn, non-degenerate segments, the count the comparisons on this page use.
:::static
fun star(cx: Double, cy: Double) = fline {
    breakNext()
    for (i in 0 until 10) {
        val r = if (i % 2 == 0) 70.0 else 32.0
        val a = kotlin.math.PI * i / 5
        lineTo(cx + r * kotlin.math.cos(a), cy + r * kotlin.math.sin(a))
    }
    close()
}

fun sCurve(x: Double, y: Double) = fline {
    moveTo(x, y)
    cubicTo(x + 40, y - 55, x + 70, y + 55, x + 110, y)
    cubicTo(x + 140, y - 35, x + 170, y + 30, x + 200, y + 5)
    lineTo(x + 215, y + 55)
}

fun dot(x: Float, y: Float, c: Vec4) = fline {
    circle(x, y, 2.0)
    filled = true
    stroked = false
    color = c
}

val out = mutableListOf<FLine>()
for (col in 0..1) {
    val x = 30.0 + col * 280
    for (s in [star(x + 110, 100.0), sCurve(x, 270.0)]) {
        s.thickness = 1.0
        s.color = vec(1.0, 1.0, 1.0, 0.6)
        out.add(s)
        for (d in [12f, -12f]) {
            val o = if (col == 0) s.byOffsetting(d) else s.byOffsettingExact(d)
            val c = if (col == 0) vec(1.0, 0.45, 0.25, 1.0) else vec(0.3, 0.8, 1.0, 1.0)
            o.thickness = 1.5
            o.color = c
            out.add(o)
            o.nodes.mapNotNull { it.toOrNull }.forEach { out.add(dot(it.x, it.y, c)) }
        }
    }
}

lines(out, w = 560.0, h = 360.0, fitToSize = true)

A star and an S-shaped line, each offset both ways: orange through byOffsetting on the left, a dense run of dots with crossed lines at the star's corners, blue through byOffsettingExact on the right with rounded outer corners and a few dots

The star's offsets have 20 and 15 segments here, against 501 each from byOffsetting, and the S-curve's have 16 and 13 against 150. FLineExactOffsetTest measures sixteen cases, including a star shrunk until its points vanish and a glyph-like ring. At the default tolerance the largest distance error is under 0.01, where byOffsetting is off by 0.002 to 30 units. The closed results' areas are within 1e-4 of the shape grown or shrunk by a round brush.

How these numbers were measured

FLineExactBooleanTest builds a corpus of circles, overlapping rounded rectangles, a ring with a bar, a blob, and the hard cases (tangent circles, a shared edge, a shape with itself, nested circles), plus 60 seeded random blob pairs, under all four operations. For each result it measures how far points on the result's own segments stray from the nearest input curve (Area and exact both stay within about 1e-8 of the size, which is float storage), and the Hausdorff distance to the operation applied to polygons flattened to 1e-5 of the size. Exact answers came back for all 240 random cases, with 2141 segments against Area's 3020. The first plan assumed Area flattened its output to polylines; the measurements showed it does not, and that its only cost is the extra splits. The working notes are in dev_docs/fline_exact_booleans.md.

Queries and analysis

These don’t change geometry — they help you measure it. Use them for picking (nearest location), hit-testing (contains2D), and computing basic shape properties like area and centroid.

Measuring along a line

Ask for a point by how far along the line it is, and you get its whole local frame back: position, unit tangent, normal and curvature. This is what you want for placing things evenly along a path, orienting them to it, or reading off how tightly it bends.

Operation Behavior
fun FLine.length(): Float Total drawn length. Jumps between subpaths do not count; close() segments do.
fun FLine.locationAt(distance: Float): FLineLocation? The location distance units along the line, clamped to the line. null if nothing is drawn.
fun FLine.locationAtFraction(fraction: Float): FLineLocation? The same by fraction of the length: 0.5f is half way by distance, not by node.
fun FLine.nearestLocation(point: Vec3): FLineLocation? The location on the line nearest to point.
fun Cursor.location(): FLineLocation? The same answer from a cursor you are already walking with setD / setT / setNearest.

An FLineLocation has point, tangent (unit length), distance along the line and t, the same parameter cursor().setT(...) takes. It carries two kinds of normal:

  • normal, binormal and curvature are the 3D frame: normal points towards the centre of the bend in whatever plane the line is turning, and curvature is one over the radius. On a straight run there is no bend, so normal and binormal are null and curvature is 0.
  • normal2D and signedCurvature2D are for drawings in the xy plane. normal2D is the tangent turned a quarter turn, (tangent.y, -tangent.x), which on Field's canvas is to the left of the direction of travel; it exists on straight runs too. signedCurvature2D is positive when the line bends towards normal2D, so the centre of the bend is at point + normal2D / signedCurvature2D.

The example places thirteen locations evenly along a two-piece curve, draws each normal2D, and at three of them draws the circle with the curve's radius of curvature.

:::static
val curve = fline {
    moveTo(40, 210)
    cubicTo(90, 30, 230, 30, 260, 140)
    cubicTo(280, 220, 360, 230, 390, 90)
    thickness = 2.0
    color = vec(1.0, 1.0, 1.0, 1.0)
}

// thirteen locations, evenly spaced by distance
val stops = (0..12).mapNotNull { curve.locationAtFraction(it / 12f) }

val normals = stops.map { loc ->
    fline {
        moveTo(loc.point.x, loc.point.y)
        lineTo(loc.point.x + loc.normal2D.x * 22, loc.point.y + loc.normal2D.y * 22)
        thickness = 1.5
        color = vec(0.4, 0.9, 1.0, 1.0)
    }
}

// the circle that matches the bend, at three of them
val bends =
    [stops[3], stops[7], stops[10]].map { loc ->
        val r = 1.0 / loc.signedCurvature2D
        fline {
            circle(loc.point.x + loc.normal2D.x * r, loc.point.y + loc.normal2D.y * r, kotlin.math.abs(r))
            thickness = 1.0
            color = vec(1.0, 0.6, 0.2, 0.8)
        }
    }

lines([curve] + normals + bends, w = 420.0, h = 260.0, fitToSize = true)

fline_examples-99

The 3D frame is for lines that leave the xy plane. On a helix the principal normal points straight at the axis, the binormal tilts with the rise, and the curvature is the same everywhere. This one is built from cubics, a quarter turn each, so its curvature comes out within a few percent of a true helix's:

:::static
// a helix of radius 80 rising 30 units per turn, one cubic per quarter turn
val r = 80.0
val rise = 30.0
val k = 0.5522847 * r
val helix = fline {
    moveTo(r, 0, 0)
    for (q in 0 until 12) {
        val a0 = q * PI / 2
        val a1 = a0 + PI / 2
        val z0 = rise * q / 4
        val z1 = rise * (q + 1) / 4
        cubicTo(
            r * cos(a0) - k * sin(a0),
            r * sin(a0) + k * cos(a0),
            z0 + (z1 - z0) / 3,
            r * cos(a1) + k * sin(a1),
            r * sin(a1) - k * cos(a1),
            z0 + 2 * (z1 - z0) / 3,
            r * cos(a1),
            r * sin(a1),
            z1,
        )
    }
}

// look at it from the side and a little above: x across, z up, y into the page
fun view(p: Vec3) = vec(p.x + 0.35 * p.y, -p.z - 0.35 * p.y)

val drawn = fline {
    breakNext()
    helix.sampleByNumber(400).forEach { p -> lineTo(view(p)) }
    thickness = 1.5
    color = vec(1.0, 1.0, 1.0, 1.0)
}

// the 3D frame at 16 evenly spaced places: tangent orange, normal blue, binormal green
fun tick(p: Vec3, d: Vec3?, c: Vec4) = fline {
    if (d != null) {
        moveTo(view(p))
        lineTo(view(p + d * 18.0))
    }
    thickness = 2.0
    color = c
}
val frames =
    (0..15).flatMap { i ->
        val loc = helix.locationAtFraction(i / 15f)!!
        [
            tick(loc.point, loc.tangent, vec(1.0, 0.6, 0.2, 1.0)),
            tick(loc.point, loc.normal, vec(0.4, 0.8, 1.0, 1.0)),
            tick(loc.point, loc.binormal, vec(0.5, 1.0, 0.5, 1.0)),
        ]
    }

// a helix has constant curvature r / (r^2 + c^2), with c the rise per radian
val c = rise / (2 * PI)
val mid = helix.locationAtFraction(0.5f)!!
println("curvature %.5f, a true helix %.5f".format(mid.curvature, r / (r * r + c * c)))
println("normal points at the axis: %.3f".format(mid.normal!!.dot(vec(-mid.point.x, -mid.point.y, 0.0).normalize())))

lines([drawn] + frames, w = 420.0, h = 260.0, fitToSize = true)

fline_examples-100

Trimming and splitting by distance

trim keeps the part of a line between two distances, and splitAt cuts it in two. Curves are cut exactly, so the pieces sit on top of the original.

Operation Behavior
fun FLine.trim(from: Float, to: Float): FLine The piece from from to to units along the line. Both are clamped, and swapped if to < from.
fun FLine.splitAt(distance: Float): Pair<FLine, FLine> The line before and after distance. The two lengths add up to length().

Animate the second argument of trim to draw a line on over time. Here the thick blue run is the stretch from a quarter of the way to 60%, and the orange start is the first half of splitAt(100f):

:::static
val curve = fline {
    moveTo(40, 200)
    cubicTo(100, 20, 220, 20, 250, 130)
    lineTo(380, 60)
    thickness = 1.0
    color = vec(1.0, 1.0, 1.0, 0.35)
}

val total = curve.length()

// from a quarter to 60% of the way, by distance, drawn over the original
val middle = curve.trim(0.25f * total, 0.6f * total).apply {
    thickness = 5.0
    color = vec(0.4, 0.9, 1.0, 1.0)
}

// cut at 100 units: two pieces whose lengths add up to the whole
val (head, tail) = curve.splitAt(100f)
head.apply {
    thickness = 2.0
    color = vec(1.0, 0.6, 0.2, 1.0)
}

print("total ${total.f1} = ${head.length().f1} + ${tail.length().f1}")

lines(curve, head, middle, w = 420.0, h = 260.0, fitToSize = true)

fline_examples-101

Crossings, bounds and orientation

crossings2D finds where two lines cross in the xy plane and selfCrossings2D where one crosses itself, including the loop inside a single cubic. Each hit carries the point and its t on both lines, so you can cut there with cursor().setT(t).split().

Operation Behavior
fun FLine.crossings2D(other: FLine, tolerance: Float = 0.01f): List<FLineCrossing> Every crossing with other, sorted along this line. Each has point, t (on this line) and otherT.
fun FLine.selfCrossings2D(tolerance: Float = 0.01f): List<FLineCrossing> Every place the line crosses itself, with t < otherT. Corners where one segment meets the next are not crossings.
fun FLine.tightBounds2D(): Rect Bounds of what is drawn, hugging the curves. fast2DBounds() encloses the control points instead.
fun FLine.area2D(): Float Signed area, exact on curves. Each subpath adds its own; a counter wound the other way subtracts.
fun FLine.isClockwise2D(): Boolean Whether the line winds clockwise on screen, with y pointing down.
fun FLine.contains2D(point: Vec2, rule: FLineFillRule = FLineFillRule.NonZero): Boolean Point-in-shape that follows the curves, with FLineFillRule.NonZero or FLineFillRule.EvenOdd. Every subpath counts as closed.
fun FLine.windingNumber2D(point: Vec2): Int The winding number about point, following the curves; non-zero is inside.

The example finds where a looping curve crosses a ring and itself, and draws its tight bounds in green over the control-point bounds in grey:

:::static
val loop = fline {
    moveTo(60, 200)
    cubicTo(420, 40, 0, 40, 340, 210)
    thickness = 2.0
    color = vec(1.0, 1.0, 1.0, 1.0)
}

val ring = fline {
    circle(200, 130, 70)
    thickness = 2.0
    color = vec(0.6, 0.8, 1.0, 1.0)
}

// where the loop crosses the ring, and where it crosses itself
val hits = [loop.crossings2D(ring), loop.selfCrossings2D()].flatten().map { it.point }
val dots = polylineFrom(hits.map { it.v2 }).apply {
    pointed = true
    pointSize = 3.0
    stroked = false
    color = vec(1.0, 0.5, 0.2, 1.0)
}

// bounds that hug the curve, against the control-point bounds
val tight = fline {
    rect(loop.tightBounds2D())
    thickness = 1.0
    color = vec(0.4, 1.0, 0.6, 1.0)
}
val loose = fline {
    rect(loop.fast2DBounds())
    thickness = 1.0
    color = vec(1.0, 1.0, 1.0, 0.25)
}

print("${hits.size} crossings; clockwise on screen: ${ring.isClockwise2D()}")

lines(loose, tight, ring, loop, dots, w = 420.0, h = 260.0, fitToSize = true)

fline_examples-102

Nearest location

Operation Behavior
fun FLine.nearestLocation(point: Vec3): FLineLocation? The location on the curve nearest to point, by 3D distance, with its frame, distance and t.
fun FLine.cursor(closestTo: Vec3): Cursor A cursor parked there, to walk on from.

Finds the point on the curve nearest a query position.

:::static
val curve = fline {
    moveTo(50, 180)
    cubicTo(120, 20, 300, 280, 350, 120)
    thickness = 2.0
    color = vec(1.0, 1.0, 1.0, 1.0)
}

val query = vec(260, 60, 0)
val closest = curve.nearestLocation(query)!!.point

val marker = polylineFrom([query.v2, closest.v2]).apply {
    thickness = 2.0
    color = vec(1.0, 0.4, 0.4, 1.0)
    pointed = true
    pointSize = 3.0
}

lines(curve, marker, w = 420.0, h = 260.0, fitToSize = true)

fline_examples-103

Containment and area

Operation Behavior
fun FLine.contains2D(point: Vec2, rule: FLineFillRule = FLineFillRule.NonZero): Boolean Whether an xy point is inside, following the curves. Each open subpath is implicitly closed; jumps between subpaths do not form edges.
fun FLine.area2D(): Float Signed area in the xy plane, exact on cubics, subpaths adding.
fun FLine.centroid2D(): Vec2 The centroid of the polygon through the node end points. Near zero area it returns the average endpoint position, or the origin for an empty line.

Containment respects separate subpaths. centroid2D uses the end-point polygon, so on a curved outline it is approximate.

:::static
val poly = fline {
    moveTo(60, 60)
    lineTo(360, 80)
    lineTo(320, 220)
    lineTo(80, 200)
    close()

    filled = true
    stroked = true
    thickness = 3.0
    color = vec(0.7, 0.9, 1.0, 0.25)
}

val c = poly.centroid2D()
val center = fline {
    moveTo(c.x, c.y)
    lineTo(c.x + 0.001, c.y + 0.001) // tiny segment so it shows
    pointed = true
    pointSize = 5.0
    stroked = false
    filled = true
    color = vec(1.0, 0.5, 0.2, 1.0)
}

lines(poly, center, w = 420.0, h = 260.0, fitToSize = true)

fline_examples-104

Java2D bridge

Java's standard 2D library (java.awt.geom) already has shapes such as ellipses, rounded rectangles and arcs, and operations on them. These functions turn a Java2D Shape into an FLine and back, so you can draw any of them, or run an FLine through a Java2D operation. intersect returns the points where two lines cross.

:::static
// In afield.geometry.path.java2d.FLinesAndJavaShapes.Companion
fun flineToJavaShape(f: FLine): java.awt.Shape
fun flineToJavaShape(f: Collection<FLine>): java.awt.Shape
fun javaShapeToFLine(f: java.awt.Shape): FLine

fun intersect(a: FLine, b: FLine): List<Vec3>
:::static
import afield.geometry.path.java2d.FLinesAndJavaShapes
import java.awt.geom.Ellipse2D

val circleShape = Ellipse2D.Double(80.0, 60.0, 220.0, 220.0)
val circle = FLinesAndJavaShapes.javaShapeToFLine(circleShape).apply {
    thickness = 5.0
    color = vec(1.0, 1.0, 1.0, 1.0)
}

val diag = fline {
    moveTo(40, 260)
    lineTo(380, 40)
    thickness = 3.0
    color = vec(1.0, 0.4, 0.4, 1.0)
}

val hits = FLinesAndJavaShapes.intersect(circle, diag)
val hitPts = polylineFrom(hits.map { it.v2 }).apply {
    pointed = true
    pointSize = 4.0
    stroked = false
    filled = true
    color = vec(0.4, 1.0, 0.6, 1.0)
}

lines(circle, diag, hitPts, w = 420.0, h = 320.0, fitToSize = true)

fline_examples-106

Reference

Every public declaration used on this page is listed, with its KDoc, in the codebase reference under StandardDrawing.kt. The rest of the standard library is in what's already in the box.

Next on the Tour, Drawing and Notation draws by hand on the canvas and has a box read each stroke back as an FLine.