Guide

Installation

npm i @baku89/pave

Basic usage

Simple Example

As it supports ES modules, you can load it using the import statement. Symbols such as Path or CubicBezier can be used both as types and as modules (namespaces) consisting of functions related to those types.

import {Path} from '@baku89/pave'

const circle = Path.circle([0, 0], 100)

// For SVG's path element
const d = Path.toSVG(circle)
svgPathElement.setAttribute('d', d)

// For Canvas API
const path2d = Path.toPath2D(circle)
context.stroke(path2d)

Immutability

Pave is functional programming-oriented library and all data is plain and immutable. Information associated with a path, such as the length of the path or its bounding box, is obtained using functions instead of accessing it as a property of the path data itself.

const length = Path.length(rect)
const bounds = Path.bounds(rect)
const normal = Path.normalAtTime(rect, 0.5)

These functions are appropriately memoized, so even if called multiple times for the same path, not all calculations are re-executed. However, if you make destructive changes to the path data and then call these functions, you may not get the correct results.

Therefore, when you want to perform procedural operations such as modifying path data or adding new vertices to the path, you will take one of the following three methods:

  1. Use utility functions that always generate new path data (such as Path.moveTo and Path.lineTo similar to the Canvas API)
  2. Use Path.pen to draw path data in order of vertices
  3. Use a library for manipulating immutable data structures such as immeropen in new window
// 1. Use utility functions
let p = Path.moveTo(Path.empty, [10, 10])
p = Path.lineTo(p, [20, 20])
p = Path.cubicBezierTo(p, [80, 30], [0, 40], [50, 50])
p = Path.closePath(p)

// 2. Use Path.pen()
const p = Path.pen()
	.moveTo([10, 10])
	.lineTo([20, 20])
	.cubicBezierTo([80, 30], [0, 40], [50, 50])
	.close()
	.get()

// 3. Example using immer
import {produce} from 'immer'

const pathA = Path.arc([50, 50], 40, 0, 90)
const pathB = produce(pathA, draft => {
	draft.curves[0].closed = true
})

Or use a library for manipulating immutable data structures such as immeropen in new window:

import {produce} from 'immer'

const pathA = Path.arc([50, 50], 40, 0, 180)
const pathB = produce(pathA, draft => {
	draft.curves[0].closed = true
})

Vector and Transform

In Pave, vectors and matrices are represented as plain 1D arrays of numbers. For example, a position is [x, y], and a 2D affine transformation is [a, b, c, d, tx, ty]. These data can be manipulated using libraries such as Linearlyopen in new window or gl-matrixopen in new window, but the latter allows mutable value changes, so it is recommended to use Linearly, which is designed to work with immutable data like Pave.

Angle

In Pave, angles are represented in degrees. JavaScript’s standard Math and Canvas2DRenderingContext use radians, so if you need to use those functions, convert them to radians by using utilities like Linearly’s rad function. Note that angles in Linearly are also represented in degrees, so calculations in degrees are possible, such as scalar.cos(90) === 0.

import {vec2, mat2d} from 'linearly'

const c = Path.ellipse(vec2.zero, vec2.of(20, 30))
const t = Path.transform(c, mat2d.fromTranslation([50, 50]))

Path Data Structure

In Pave, the representation of paths is not a sequence of drawing commands against a stateful context like the SVG ‘d’ attribute or Canvas API, but always based on vertices. This means there are no operations like moveTo (the M command in SVG) or closePath (the Z in SVG); paths are always composed of a list of tuples of vertex positions and interpolation commands from the last vertex.

Also, the path data structure has the following hierarchy, similar to how in 3D data, meshes consist of collections of polygons, and polygons are formed from collections of vertices.

Path Structure
  • Path: A single Curve or a compound path composed of multiple Curves. It is the most common type to represent shapes in Pave.
  • Curve: Represents a open or closed single stroke.
  • Vertex: Each vertex that makes up the stroke, which consists of end point, command type, and argments for the interpolation.
  • Command: Arguments of interpolation commands excluding the end point.

If you are familiar with TypeScript, it might be easier to understand by looking at the type definitions.

type Path = {paths: Curves[]; fillRule: 'nonzero' | 'evenodd'}
type Curve = {vertices: Vertex[]; closed: boolean}

type Vertex = VertexL | VertexC | VertexA
type VertexL = {point: vec2; command: 'L'}
type VertexC = {
	point: vec2
	command: 'C'
	args: [control1: vec2, control2: vec2]
}
type VertexA = {
	point: vec2
	command: 'A'
	args: [radii: vec2, xRot: vec2, largeArc: boolean, sweep: boolean]
}

In addition to the above hierarchy, there is also a type called Segment, which is a cut-out part of a Curve corresponding to a single command. Unlike Vertex, it includes the start position of interpolation.

type Segment = Vertex & {start: vec2}

Location Representation on Paths

To represent a specific position on a segment, you can use the following three representations:

  • Unit: A relative position to the start and end points on the segment. This is the default representation in Pave. It takes a value in the range [0, 1].
  • Offset: A representation based on the distance from the start point. 0 corresponds to the start point, and the length of the segment corresponds to the end point.
  • Time: A representation based on the parameter of the mathematical curve used in the segment. It takes a value in the range [0, 1]. Note that unlike the other two position representations, time may not be evenly distributed on the segment.
type UnitSegmentLocation = number | {unit: number}
type OffsetSegmentLocation = {offset: number}
type TimeSegmentLocation = {time: number}

type SegmentLocation =
	| UnitSegmentLocation
	| OffsetSegmentLocation
	| TimeSegmentLocation

Locations on curves consisting of multiple segments, such as Curve or Path, can be represented by specifying the index of the vertex or curve in addition to the above representations. If not specified, in the case of unit or offset, it is treated as a relative position in the range of [0, max] to the total curve length, and in the case of time, it is treated as a value of the parameter evenly divided by the number of segments. (For example, in the case of a path consisting of two segments, {time: 0.25} corresponds to {time: 0.5} in the first segment.)

type UnitPathLocation =
	| number
	| {
			unit: number
			vertexIndex?: number
			curveIndex?: number
	  }

type OffsetPathLocation = {
	offset: number
	vertexIndex?: number
	curveIndex?: number
}

type TimePathLocation = {
	time: number
	vertexIndex?: number
	curveIndex?: number
}

type PathLocation = UnitPathLocation | OffsetPathLocation | TimePathLocation

TIP

範囲外の値を指定した場合、自動的にクランプされます。ただし、unitやoffset、timeが-最大値 <= x < 0の範囲で負の値を取る場合、該当するカーブの終点を基準に絶対値だけオフセットした位置について取得されます。これはArray.at()open in new windowの挙動とも似ています。

If you specify a value that is out of range, it will be automatically clamped. However, if you specify a negative value in the range of -max <= x < 0 , the position will be obtained by offsetting the absolute value of the location from the end point of the corresponding curve. This behavior is similar to Array.at()open in new window.

Also, if a position representation can be interpreted as both the end point of a segment and the start point of a subsequent segment, the start point of the later segment takes precedence. For example, in a path consisting of two separate lines, {time: 0.5} may refer to both the end point of the first line and the start point of the second line, but the start point of the second line is prioritized by this rule. If you want to refer to the end point of the first line, you need to specify it explicitly as {time: 1, curveIndex: 0}.