Research-Stack/3-Mathematical-Models/bezier-kit/README.md

370 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# bezier-kit
[日本語 README](./README.ja.md)
A zero-dependency TypeScript library for cubic bezier curves. **Works with both 2D and 3D** through the same API — morphing, splitting, arc-length queries, and path generation.
**[View interactive demos → sumisonic.github.io/bezier-kit](https://sumisonic.github.io/bezier-kit/)**
- **`@sumisonic/bezier-kit-core`** — geometric operations (zero dependency, 2D / 3D)
- **`@sumisonic/bezier-kit-style`** — style-aware paths with colors, gradients, and strokes (depends on core, 2D only)
## Install
```bash
pnpm add @sumisonic/bezier-kit-core
# Add the style layer too if you need it:
pnpm add @sumisonic/bezier-kit-style
```
Also works with `npm install` / `yarn add`.
## Quick Start
```ts
import { createPathInterpolator, fromCatmullRom, type Point2D } from '@sumisonic/bezier-kit-core'
// Morph between two shapes
const pathA = fromCatmullRom<Point2D>([
{ x: 0, y: 0 },
{ x: 100, y: 50 },
{ x: 200, y: 0 },
])
const pathB = fromCatmullRom<Point2D>([
{ x: 0, y: 0 },
{ x: 100, y: -50 },
{ x: 200, y: 0 },
])
const interp = createPathInterpolator(pathA, pathB)
// Oscillate 0..1 every frame
requestAnimationFrame(function tick(t) {
const phase = (Math.cos(t / 400) + 1) / 2
const path = interp(phase)
// Draw `path` with Canvas 2D / SVG / three.js, etc.
requestAnimationFrame(tick)
})
```
Switching to 3D is just changing the type parameter to `Point3D`:
```ts
import { fromCatmullRom, createPathInterpolator, type Point3D } from '@sumisonic/bezier-kit-core'
const a = fromCatmullRom<Point3D>([
{ x: 0, y: 0, z: 0 },
{ x: 1, y: 1, z: 1 },
])
const interp = createPathInterpolator(a, b) // All arguments and return values are Point3D
```
Mixing 2D and 3D paths produces a **compile-time error**, so dimension mistakes are caught by the type system.
## Features
- **Morphing**: Interpolate between two paths with a single-time setup, then a ~1μs per-frame call. `t` values outside 01 work naturally for back/elastic easing
- **Automatic segment matching**: When `from` and `to` have different segment counts, `matchSegmentCount` equalizes them proportionally by arc length
- **Arc-length queries**: Get point and tangent vector at any arc-length ratio via `pointAtLength(path, ratio)` / `tangentAtLength(path, ratio)`
- **Arc-length splits**: Slice a path at any ratio with `createPathSplitter`; sub-paths can be further interpolated or split
- **Path generation from points**: `fromCatmullRom` (smooth spline) and `fromPolyline` (straight segments)
- **Frenet frames (3D, hot-path)**: twist-free (T, N, B) via double-reflection, written directly into a `Float32Array`. Useful for tube/ribbon rendering
- **Catmull-Rom Float32Array writers**: skip `BezierPath` object creation and go straight from control points to geometry with zero allocation
- **Styled paths** (`@sumisonic/bezier-kit-style`): animate 2D paths with color, gradient, and stroke using the same patterns
- **Dimension-safe at type level**: Mixing 2D and 3D calls is a compile-time error
## Main API
### core
```ts
import {
// Types
type Point2D,
type Point3D,
type BezierPath,
type BezierSegment,
type BBox2D,
type BBox3D,
// Path-level
createPathInterpolator,
createPathInterpolatorStrict,
createPathSplitter,
matchSegmentCount,
// Arc-length queries
pointAtLength,
tangentAtLength,
// Segment-level
pointAt,
tangentAt,
splitSegmentAt,
arcLengthTo,
segmentLength,
// Arc-length index (fast for many calls)
createArcLengthIndex,
arcLengthToParam,
// Bounding box
bbox,
// Construction from points
fromCatmullRom,
fromPolyline,
// Functor (2D ↔ 3D conversion, translation, etc.)
mapPoints,
// Math utilities
lerp,
clamp,
lerpPoint,
distance,
} from '@sumisonic/bezier-kit-core'
```
#### Morphing (prepare once, call per frame)
```ts
const interp = createPathInterpolator(pathA, pathB)
// t = 0 returns pathA, t = 1 returns pathB; extrapolates outside [0, 1]
const morphed = interp(t)
```
- **`t` can be outside 01** — useful for back/elastic easings that dip to `-0.2` or `1.2`
- When segment counts differ, they are matched automatically
Use `createPathInterpolatorStrict` if you want an error instead of automatic matching.
#### Point and tangent at arc-length ratio
```ts
const p = pointAtLength(path, 0.5) // Point on path at 50% arc length
const v = tangentAtLength(path, 0.5) // Tangent (direction) at the same position
// For 2D angles:
const angle = Math.atan2(v.y, v.x)
```
- **`ratio` is internally clamped to [0, 1]**, so out-of-range values are safe
- For many calls, build once with `createArcLengthIndex` and reuse `arcLengthToParam` for speed
#### Splitting by arc-length ratio
```ts
const split = createPathSplitter(path)
const [left, right] = split(0.3) // 30% / 70% split by arc length
```
The split point is **exactly shared** between the end of `left` and the start of `right` (De Casteljau splits are precise).
#### 2D → 3D conversion via `mapPoints`
```ts
// Promote a 2D path to 3D with z = 0
const path3d = mapPoints<Point2D, Point3D>(path2d, (p) => ({ x: p.x, y: p.y, z: 0 }))
// Or use it for any per-point transform (translation, scale, rotation, etc.)
const shifted = mapPoints<Point2D, Point2D>(path2d, (p) => ({ x: p.x + 10, y: p.y }))
```
#### Frenet frames (3D only, hot-path friendly)
Compute a twist-free `(T, N, B)` orthonormal basis along a 3D path using the double-reflection method. Useful for tube/ribbon rendering.
```ts
import {
FRENET_STRIDE,
FRENET_OFFSET,
writeFrenetFrames,
readFrenetFrame,
computeFrenetFrames,
} from '@sumisonic/bezier-kit-core'
// Debug / one-shot: get an array of frame objects
const frames = computeFrenetFrames(path, samples)
frames[0].tangent // normalized { x, y, z }
frames[0].normal // orthogonal to T
frames[0].binormal // = T × N
// Hot-path: write in-place into a Float32Array (zero allocation)
const framesBuffer = new Float32Array(samples * FRENET_STRIDE)
writeFrenetFrames(framesBuffer, path, samples)
// Read any component via stride + offset
const frameIdx = 5
const off = frameIdx * FRENET_STRIDE
const tx = framesBuffer[off + FRENET_OFFSET.TANGENT]
```
- **Twist-free**: minimal change of `N` between adjacent frames (double-reflection method)
- **Zero-alloc**: cubic Bezier formulas are inlined, no `pointAt`/`tangentAt` calls
- **Precision knob**: `{ arcLengthSamples: 64 }` (default 64)
- **`FRENET_STRIDE = 12`** and **`FRENET_OFFSET`** (POSITION=0, TANGENT=3, NORMAL=6, BINORMAL=9) are stable within a major version
#### Catmull-Rom Float32Array writers (hot-path friendly)
When control points live in a `Float32Array` (WebAudio / WebXR / WASM interop), you can skip the `BezierPath` object entirely and write segment numerics directly.
```ts
import {
CATMULL_ROM_SEGMENT_STRIDE,
CATMULL_ROM_SEGMENT_OFFSET,
writeCatmullRomSegments,
writeFrenetFramesFromCatmullRom,
} from '@sumisonic/bezier-kit-core'
// 20 control points → 19 segments (12 floats each: start xyz, cp1 xyz, cp2 xyz, end xyz)
const controlPoints = new Float32Array(20 * 3)
const segments = new Float32Array(19 * CATMULL_ROM_SEGMENT_STRIDE)
writeCatmullRomSegments(segments, controlPoints, 20)
// Or go straight from control points to Frenet frames
const samples = 101
const frames = new Float32Array(samples * FRENET_STRIDE)
writeFrenetFramesFromCatmullRom(frames, controlPoints, 20, samples)
```
- `CATMULL_ROM_SEGMENT_STRIDE = 12` and `CATMULL_ROM_SEGMENT_OFFSET` are stable within a major version
- Matches `fromCatmullRom` at float32 precision (within 1e-4)
### style (2D only)
```ts
import {
createStyledPathInterpolator,
createStyledPathSplitter,
matchStyledPathCount,
createStrokeLerp,
gradientToAbsolute,
remapGradient,
parseHexColor,
toHexColor,
lerpColor,
createColorLerp,
type StyledBezierPath,
type StrokeStyle,
type LinearGradient,
type GradientStop,
type ViewBox,
type StyledBezierData,
} from '@sumisonic/bezier-kit-style'
```
#### `createStrokeLerp` behavior
How each input combination is handled:
| Input | Output |
| ---------------------------- | --------------------------------------------------------------------- |
| Both `undefined` | Always `undefined` |
| Only one side has `stroke` | Static value (no interpolation) |
| Both have `width` | `width` is linearly interpolated |
| Only one side has `width` | Static value |
| Both have `color` | `color` is RGB-linearly interpolated |
| Only one side has `gradient` | Synthesized gradient from `color`, then interpolated |
| Both have `gradient` | Stop counts matched, then coordinates / colors / offsets interpolated |
## Rendering
This library is **renderer-agnostic** — it returns path data, and you handle drawing.
### Canvas 2D
```ts
const ctx = canvas.getContext('2d')
ctx.beginPath()
ctx.moveTo(path.start.x, path.start.y)
for (const seg of path.segments) {
ctx.bezierCurveTo(seg.cp1.x, seg.cp1.y, seg.cp2.x, seg.cp2.y, seg.end.x, seg.end.y)
}
ctx.stroke()
```
### SVG
```ts
const d =
`M ${path.start.x} ${path.start.y} ` +
path.segments.map((s) => `C ${s.cp1.x} ${s.cp1.y}, ${s.cp2.x} ${s.cp2.y}, ${s.end.x} ${s.end.y}`).join(' ')
// In React: <path d={d} stroke="currentColor" fill="none" />
```
### three.js (3D)
```ts
import * as THREE from 'three'
import { fromCatmullRom, pointAtLength, type Point3D } from '@sumisonic/bezier-kit-core'
const path = fromCatmullRom<Point3D>([
{ x: 0, y: 0, z: 0 },
{ x: 1, y: 1, z: 1 },
{ x: 2, y: 0, z: 2 },
])
const samples = 96
const points = Array.from({ length: samples }, (_, i) => {
const p = pointAtLength(path, i / (samples - 1))
return new THREE.Vector3(p.x, p.y, p.z)
})
const curve = new THREE.CatmullRomCurve3(points, false, 'catmullrom', 0.5)
const geometry = new THREE.TubeGeometry(curve, 96, 0.1, 16, false)
```
### Interactive demos
Check `examples/` in this repository:
- `canvas-morph` — 2D random-shape morphing + split (React + Canvas 2D)
- `threejs-tube` — 3D random-tube morphing + split (React + @react-three/fiber)
```bash
pnpm example:canvas
pnpm example:three
```
## Type hierarchy
```ts
type Point2D = { readonly x: number; readonly y: number }
type Point3D = { readonly x: number; readonly y: number; readonly z: number }
type Point = Point2D | Point3D
type BezierSegment<P extends Point> = { readonly cp1: P; readonly cp2: P; readonly end: P }
type BezierPath<P extends Point> = { readonly start: P; readonly segments: readonly BezierSegment<P>[] }
type BBox2D = { readonly minX; readonly minY; readonly width; readonly height }
type BBox3D = BBox2D & { readonly minZ; readonly depth }
type BBox<P extends Point> = P extends Point3D ? BBox3D : BBox2D // tracks input dimension
```
Write `BezierPath<Point2D>` / `BezierPath<Point3D>` **explicitly** (no default type parameter).
## Non-goals
- Self-intersection detection / boolean ops (intersect, subtract, etc.)
- SVG path `d` string parsing / serialization
- HSL / OKLCh color interpolation (RGB only)
- Quadratic bezier or elliptical arc segments (cubic only)
- Exact bbox via extremum analysis (current `bbox` is a convex-hull upper bound)
- 3D gradients / textures (style is 2D only)
## Comparison
- **[bezier-js](https://github.com/Pomax/bezierjs)**: Broad set of bezier operations (offset, projection, etc.). bezier-kit focuses on dynamic morphing and arc-length queries, plus 3D support
- **[paper.js](http://paperjs.org/)**: Full framework including rendering and editing. bezier-kit is data-layer only
- **three.js `CatmullRomCurve3`**: three.js-specific. bezier-kit is renderer-agnostic and works the same way in 2D and 3D
## License
MIT © [sumisonic](https://github.com/sumisonic)
## Contributing
See [CONTRIBUTING.md](./CONTRIBUTING.md).