@proc-geo/core
v0.2.1
Published
Procedural geometry algorithms in pure TypeScript — straight skeleton, random polygon generation, pen-stroke to spline fitting, and D0L L-systems
Downloads
251
Maintainers
Readme
@proc-geo/core
Procedural geometry algorithms in pure TypeScript. No React, no browser APIs, no canvas — just functions over plain
{ x, y } data, so the same code runs in Node, a worker, or the browser.
npm install @proc-geo/coreShips ESM + CJS with bundled type declarations. The only runtime dependency is
loglevel.
Modules
| Module | What it does | |---------------------|-------------------------------------------------------------------------------------| | Straight skeleton | Computes the straight skeleton of a simple polygon, including reflex/split events. | | Random polygon | Generates random non-self-intersecting polygons from tunable parameters. | | Stroke → spline | Turns raw pointer-capture samples into a fitted cubic Bézier spline. | | D0L system | Deterministic context-free L-systems with turtle interpretation. |
Everything is exported from the package root:
import { runAlgorithmV5, runStrokePipeline, generateRandomPolygon } from '@proc-geo/core';Straight skeleton
The straight skeleton is the locus traced by a polygon's vertices as its edges move inward at equal speed. It underpins roof generation, straight-line polygon offsetting, and medial-axis approximation.
import { runAlgorithmV5 } from '@proc-geo/core';
import type { Vector2 } from '@proc-geo/core';
// Clockwise winding — see the warning below.
const polygon: Vector2[] = [
{ x: 0, y: 0 },
{ x: 0, y: 120 },
{ x: 200, y: 120 },
{ x: 200, y: 0 },
];
const context = runAlgorithmV5(polygon);
const graph = context.graph; // nodes + edges of the completed skeletonFor this rectangle the result has 6 nodes and 10 edges: the 4 original corners plus the two ridge nodes where the bisectors meet.
Input must be wound clockwise. Counter-clockwise input does not throw — the solver logs
Skeleton remains incompleteatwarnlevel and returns a graph containing only the original boundary, with no interior edges. If your winding is not already guaranteed, normalize first:import { ensureClockwiseSkeleton } from '@proc-geo/core'; const context = runAlgorithmV5(ensureClockwiseSkeleton(polygon));Note that "clockwise" is measured in the coordinate system you supply. Screen coordinates put y downward, so a polygon that looks clockwise on screen is counter-clockwise numerically.
isClockwiseis exported if you want to check.
runAlgorithmV5 takes at least three vertices and throws below that. Self-intersecting input is decomposed
automatically (decomposePolygon) and the sub-results merged (mergeSkeletonGraphs).
The returned StraightSkeletonSolverContext exposes the graph plus the solver's edge-lookup helpers. Exterior edges
(the original polygon boundary) and interior edges (the bisector rays generated during the run) are tracked separately;
interior edge IDs begin at graph.numExteriorNodes.
Stepping through a run
For visualization or debugging, runAlgorithmV5Stepped returns snapshots instead of just the final state, and reports
failure by return value rather than throwing:
const { snapshots, error } = runAlgorithmV5Stepped(polygon);Logging
The solver logs through loglevel under the skeleton:* namespaces, defaulting to warn:
import { setSkeletonLogLevel } from '@proc-geo/core';
setSkeletonLogLevel('debug'); // 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'silent'Random polygon
Generates a random simple polygon by walking edges with randomized lengths and turn angles, retrying until the result is non-self-intersecting.
import { generateRandomPolygon, DEFAULT_PARAMS } from '@proc-geo/core';
const polygon = generateRandomPolygon({
...DEFAULT_PARAMS,
maxEdges: 12,
});RandomPolygonParams takes edgeLength and angleDelta as { min, max, variance } ranges plus a maxEdges cap.
variance blends between a uniform distribution (0) and one peaked at the range's center (1). The optional second
and third arguments set the start position and the retry limit (default 10); if every attempt fails, a small triangle
is returned rather than throwing.
Pair it with @proc-geo/test-fixtures for 35 named polygons covering known tricky cases.
Stroke → spline
A four-stage pipeline that converts raw pointer samples into a fitted cubic Bézier spline. Each stage is independently
configurable and every stage has a pass-through variant, so you can isolate one stage's effect.
import { runStrokePipeline, DEFAULT_STROKE_PIPELINE_CONFIG } from '@proc-geo/core';
import type { StrokePoint } from '@proc-geo/core';
// Captured from pointermove: t is the event timestamp in ms.
const raw: StrokePoint[] = [
{ x: 10, y: 10, t: 0 },
{ x: 24, y: 31, t: 16 },
// …
];
const result = runStrokePipeline(raw, DEFAULT_STROKE_PIPELINE_CONFIG);
result.fit?.segments; // CubicBezier[]
result.fit?.maxError; // worst deviation from the input pointsStages and variants
| Stage | Variants |
|-------------------|-----------------------------------------------------------------|
| smoothing | pass-through, moving-average, gaussian, one-euro, chaikin |
| simplification | pass-through, rdp, resample |
| cornerDetection | pass-through, angle-threshold |
| fitting | pass-through, schneider, catmull-rom |
Each variant is a discriminated union member carrying its own parameters, so the type checker enforces that
{ variant: 'gaussian' } supplies sigma and nothing else:
const result = runStrokePipeline(raw, {
smoothing: { variant: 'one-euro', minCutoff: 1, beta: 0.005 },
simplification: { variant: 'rdp', epsilon: 2 },
cornerDetection: { variant: 'angle-threshold', thresholdDeg: 60, span: 4 },
fitting: { variant: 'schneider', errorTolerance: 4 },
});SMOOTHING_VARIANT_DEFAULTS and its three siblings provide a sensible starting config per variant — useful for
building a UI where changing a dropdown swaps in fresh parameters.
Notes on the individual stages:
one-eurois the 1€ filter (Casiez et al. 2012): velocity-adaptive, so it smooths hard when the pen is slow and lags little when it is fast. It reads thettimestamps and, being causal, does not pin the final point. The kernel smoothers (moving-average,gaussian) shrink their window at the ends and so pin both endpoints exactly.schneideris Schneider's classic least-squares fitting with recursive subdivision, adding segments untilerrorToleranceis met.catmull-rominterpolates every input point exactly (zero error) — pick it when the curve must pass through the samples rather than approximate them.- Corner detection feeds hard breakpoints into fitting. Sections between corners are fitted independently, so tangents are one-sided and the curve creases at a corner instead of rounding it off.
Correspondence and morphing
result.correspondence is index-matched to raw: entry i is where raw point i lands on the final curve. This
holds even when a stage changes the point count (rdp, resample, chaikin) — the pipeline falls back to
arc-length-fraction mapping in that case. It makes animating the cleanup a one-liner:
import { lerpStroke } from '@proc-geo/core';
const midway = lerpStroke(raw, result.correspondence, 0.5);The intermediate stage outputs (smoothed, simplified, corners) are all returned too, so you can render the
pipeline stage by stage.
D0L system
Deterministic, context-free Lindenmayer systems with a turtle-graphics interpreter. An alphabet of user-defined
letters rewrites over generations; each letter resolves to a sequence of the six turtle keywords
F, +, -, [, ], f.
import {
compileDolSystem,
generateDolSystem,
interpretDolSystem,
} from '@proc-geo/core';
const system = compileDolSystem({
alphabet: { A: ['F'], B: ['F'] },
productions: { A: ['B', '[', '+', 'A', ']', '-', 'A'], B: ['B', 'B'] },
axiom: ['A'],
turtle: { stepLength: 10, angleDelta: 25, generationScaling: 0.9 },
maxIterations: 8,
});
const generated = generateDolSystem(system, 5);
const { paths, bounds } = interpretDolSystem(generated, {
stepLength: 10,
angleDelta: 25,
generationScaling: 0.9,
});compileDolSystem validates the configuration and throws DolSystemValidationError — carrying an errors array of
{ field, message } — when it fails. Letters missing from productions receive an identity rule.
generateDolSystem(system, generations, maxWordLength?, skipProvenance?) accepts a word-length cap to bound runaway
growth. interpretDolSystem returns paths as an array of polylines — a new polyline starts after each ] pop — plus
the overall bounds. Each Segment carries the letter it descended from, so output can be styled by which rule
produced it.
Related packages
@proc-geo/test-fixtures— 35 named polygon fixtures for regression testing and benchmarking.@proc-geo/dashboard— React components (Mantine + Konva) for exploring all four modules interactively.
License
MIT © Will Buchanan
