@mark1russell7/highways 0.1.5 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +293 -8
- package/build/index.d.ts +1774 -236
- package/build/index.d.ts.map +1 -1
- package/build/index.js +9307 -3090
- package/build/index.js.map +1 -1
- package/package.json +2 -1
- package/src/bench/compare.ts +145 -0
- package/src/bench/curves.ts +514 -2
- package/src/bench/links.ts +28 -6
- package/src/bench/measures/bundles.ts +119 -0
- package/src/bench/measures/curves.ts +211 -0
- package/src/bench/measures/groups.ts +122 -0
- package/src/bench/measures/index.ts +9 -0
- package/src/bench/measures/levels.ts +103 -0
- package/src/bench/measures/pareto.ts +37 -0
- package/src/bench/measures/room.ts +43 -0
- package/src/bench/measures/routes.ts +226 -0
- package/src/bench/measures/stability.ts +104 -0
- package/src/bench/measures/stats.ts +114 -0
- package/src/bench/properties.ts +18 -2
- package/src/bench/room.ts +209 -2
- package/src/bench/run.ts +86 -11
- package/src/bench/sections.ts +121 -0
- package/src/bench/suite.ts +781 -0
- package/src/cases.ts +41 -5
- package/src/curves/clothoid/index.ts +150 -0
- package/src/curves/corner.ts +116 -0
- package/src/curves/fit.ts +79 -0
- package/src/curves/highway/index.ts +636 -0
- package/src/curves/index.ts +16 -1
- package/src/curves/model.ts +4 -2
- package/src/curves/smooth/index.ts +50 -0
- package/src/curves/space.ts +76 -0
- package/src/groups/cut/index.ts +292 -0
- package/src/groups/index.ts +4 -0
- package/src/groups/kd/index.ts +207 -0
- package/src/groups/keys/index.ts +81 -0
- package/src/groups/quadtree/index.ts +10 -0
- package/src/index.ts +7 -0
- package/src/levels.ts +178 -9
- package/src/model.ts +2 -0
- package/src/pick.ts +36 -0
- package/src/room/bends.ts +203 -0
- package/src/room/index.ts +28 -0
- package/src/room/levels/index.ts +155 -0
- package/src/routers/sections.ts +78 -55
- package/src/routers/shared.ts +7 -2
- package/src/routers/trunks.ts +131 -6
- package/src/sections.ts +93 -4
- package/src/segments.ts +81 -31
- package/src/strategies/builtins.ts +153 -7
- package/src/strategies/index.ts +1 -0
- package/src/strategies/model.ts +29 -7
- package/src/strategies/options.ts +296 -0
- package/src/strategies/schemas.ts +221 -0
- package/src/visibility.ts +190 -1
package/src/cases.ts
CHANGED
|
@@ -103,6 +103,18 @@ export function manyGroupsBoard(): Graph {
|
|
|
103
103
|
return b.graph();
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/** The options of `packedGridBoard`. */
|
|
107
|
+
export interface PackedGridOptions {
|
|
108
|
+
/** The seed of the links. The default is 11. */
|
|
109
|
+
readonly seed?: number;
|
|
110
|
+
/** The number of columns. The default is 12. */
|
|
111
|
+
readonly cols?: number;
|
|
112
|
+
/** The number of rows. The default is 8. */
|
|
113
|
+
readonly rows?: number;
|
|
114
|
+
/** The number of long links between boxes far apart. The default is 48. */
|
|
115
|
+
readonly long?: number;
|
|
116
|
+
}
|
|
117
|
+
|
|
106
118
|
/**
|
|
107
119
|
* This function makes the far view of a board after a host layout packs it.
|
|
108
120
|
* The board has 96 boxes of one size in a grid of 12 columns and 8 rows.
|
|
@@ -110,12 +122,14 @@ export function manyGroupsBoard(): Graph {
|
|
|
110
122
|
* The links have no channel between the boxes. The roadmap item ROOM measures this board before and after `makeRoom`.
|
|
111
123
|
*
|
|
112
124
|
* The board is not in `CASES`, because the digests of the router fix the output on each board of `CASES`.
|
|
125
|
+
* The options make a family of such boards for the measure suite. Without options, the board does not change.
|
|
113
126
|
*/
|
|
114
|
-
export function packedGridBoard(): Graph {
|
|
115
|
-
const R = rng(11);
|
|
127
|
+
export function packedGridBoard(options: PackedGridOptions = {}): Graph {
|
|
128
|
+
const R = rng(options.seed ?? 11);
|
|
116
129
|
const b = new Board();
|
|
117
|
-
const cols = 12;
|
|
118
|
-
const rows = 8;
|
|
130
|
+
const cols = options.cols ?? 12;
|
|
131
|
+
const rows = options.rows ?? 8;
|
|
132
|
+
const long = options.long ?? 48;
|
|
119
133
|
const w = 140;
|
|
120
134
|
const h = 90;
|
|
121
135
|
const id = (r: number, c: number): string => `p${r}-${c}`;
|
|
@@ -127,7 +141,29 @@ export function packedGridBoard(): Graph {
|
|
|
127
141
|
const cc = Math.max(0, Math.min(cols - 1, c + step()));
|
|
128
142
|
b.link(id(r, c), id(rr, cc));
|
|
129
143
|
}
|
|
130
|
-
for (let n = 0; n <
|
|
144
|
+
for (let n = 0; n < long; n++) b.link(id(Math.floor(R() * rows), Math.floor(R() * cols)), id(Math.floor(R() * rows), Math.floor(R() * cols)));
|
|
145
|
+
return b.graph();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* This function makes a packed board with three zoom levels of groups. The board has 256 touching boxes in a grid of 16 columns and 16 rows.
|
|
150
|
+
* The quadtree of `buildSections` splits it into 4, 16 and 64 groups. Each box links to a box near it, and 64 long links join boxes far apart.
|
|
151
|
+
* At each level, the boxes of the groups touch, so the level links have no channel. The roadmap item ROOM2 measures this board.
|
|
152
|
+
*
|
|
153
|
+
* The board is not in `CASES`, because the digests of the router fix the output on each board of `CASES`.
|
|
154
|
+
*/
|
|
155
|
+
export function nestedGridBoard(): Graph {
|
|
156
|
+
const R = rng(13);
|
|
157
|
+
const b = new Board();
|
|
158
|
+
const side = 16;
|
|
159
|
+
const w = 120;
|
|
160
|
+
const h = 80;
|
|
161
|
+
const id = (r: number, c: number): string => `n${r}-${c}`;
|
|
162
|
+
for (let r = 0; r < side; r++) for (let c = 0; c < side; c++) b.node(id(r, c), c * w, r * h, w, h);
|
|
163
|
+
const step = (): number => Math.floor(R() * 5) - 2;
|
|
164
|
+
const clamp = (v: number): number => Math.max(0, Math.min(side - 1, v));
|
|
165
|
+
for (let r = 0; r < side; r++) for (let c = 0; c < side; c++) b.link(id(r, c), id(clamp(r + step()), clamp(c + step())));
|
|
166
|
+
for (let n = 0; n < 64; n++) b.link(id(Math.floor(R() * side), Math.floor(R() * side)), id(Math.floor(R() * side), Math.floor(R() * side)));
|
|
131
167
|
return b.graph();
|
|
132
168
|
}
|
|
133
169
|
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { Point } from "../../geom.ts";
|
|
2
|
+
import { cornerTangent, spiralCorners, type SpiralShape } from "../corner.ts";
|
|
3
|
+
import { fitPieces, type CubicPiece } from "../fit.ts";
|
|
4
|
+
import type { CurveSegment } from "../model.ts";
|
|
5
|
+
import { cubicAt } from "../sample.ts";
|
|
6
|
+
import type { CurveSpace } from "../space.ts";
|
|
7
|
+
|
|
8
|
+
/** The options of `clothoidCurve`. */
|
|
9
|
+
export interface ClothoidOptions {
|
|
10
|
+
/** The radius of the arc in the middle of each corner: the smallest radius of the corner. The default is 12. */
|
|
11
|
+
readonly radius?: number;
|
|
12
|
+
/** The share of the turn of a corner in its two clothoids, from 0 (a circular arc) to 1 (no arc). The default is 0.5. */
|
|
13
|
+
readonly spiral?: number;
|
|
14
|
+
/** The largest distance from the cubic pieces to the true clothoid, in units of the plane. The default is 0.25. */
|
|
15
|
+
readonly tolerance?: number;
|
|
16
|
+
/** The boxes that the curve must not go through. Without it, each corner gets the largest radius that fits on its legs. */
|
|
17
|
+
readonly space?: CurveSpace;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The largest number of cubic pieces of one clothoid. */
|
|
21
|
+
export const MAX_CLOTHOID_PIECES = 8;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* This function gives the point at the arc length `s` of the clothoid that starts at the origin along the x axis.
|
|
25
|
+
* The tangent angle is `θ(s) = s² / (2 a2)`, so the curvature `s / a2` grows linearly with the length.
|
|
26
|
+
* The Fresnel integrals give the point. Their power series converge fast for the turns of a corner.
|
|
27
|
+
*/
|
|
28
|
+
export function clothoidPoint(s: number, a2: number): Point {
|
|
29
|
+
const theta = (s * s) / (2 * a2);
|
|
30
|
+
const t2 = theta * theta;
|
|
31
|
+
let x = 0;
|
|
32
|
+
let y = 0;
|
|
33
|
+
// The term of x is (-1)^n θ^(2n) / ((2n)! (4n + 1)). The term of y is (-1)^n θ^(2n+1) / ((2n+1)! (4n + 3)).
|
|
34
|
+
let px = 1;
|
|
35
|
+
let py = theta;
|
|
36
|
+
for (let n = 0; n < 40; n++) {
|
|
37
|
+
const tx = px / (4 * n + 1);
|
|
38
|
+
const ty = py / (4 * n + 3);
|
|
39
|
+
x += tx;
|
|
40
|
+
y += ty;
|
|
41
|
+
if (Math.abs(tx) < 1e-17 && Math.abs(ty) < 1e-17) break;
|
|
42
|
+
px *= -t2 / ((2 * n + 1) * (2 * n + 2));
|
|
43
|
+
py *= -t2 / ((2 * n + 2) * (2 * n + 3));
|
|
44
|
+
}
|
|
45
|
+
return { x: s * x, y: s * y };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The cubic pieces of one clothoid at the radius 1, and their largest distance to the clothoid. */
|
|
49
|
+
interface ClothoidFit {
|
|
50
|
+
readonly pieces: readonly CubicPiece[];
|
|
51
|
+
readonly error: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The fits of the clothoids of recent corners, by the turn and the spiral share. Each fit comes when a corner first needs it. The cache empties when it gets large. */
|
|
55
|
+
const fits = new Map<string, (n: number) => ClothoidFit>();
|
|
56
|
+
|
|
57
|
+
/** The number of points of the dense polyline that measures the error of a fit. */
|
|
58
|
+
const DENSE = 256;
|
|
59
|
+
|
|
60
|
+
/** This function gives the distance from the point `q` to the part of the polyline `p` from the index `from` to the index `to`. */
|
|
61
|
+
function distanceTo(q: Point, p: readonly Point[], from: number, to: number): number {
|
|
62
|
+
let best = Infinity;
|
|
63
|
+
for (let i = Math.max(1, from); i <= Math.min(p.length - 1, to); i++) {
|
|
64
|
+
const a = p[i - 1]!;
|
|
65
|
+
const b = p[i]!;
|
|
66
|
+
const dx = b.x - a.x;
|
|
67
|
+
const dy = b.y - a.y;
|
|
68
|
+
const l2 = dx * dx + dy * dy;
|
|
69
|
+
const t = l2 > 0 ? Math.max(0, Math.min(1, ((q.x - a.x) * dx + (q.y - a.y) * dy) / l2)) : 0;
|
|
70
|
+
best = Math.min(best, Math.hypot(a.x + t * dx - q.x, a.y + t * dy - q.y));
|
|
71
|
+
}
|
|
72
|
+
return best;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* This function gives the fits of the clothoid that turns by `theta` and ends with the curvature 1, with 1 to `MAX_CLOTHOID_PIECES` pieces.
|
|
77
|
+
* Each piece is G2 with its neighbours. The error of a fit is the largest distance from 16 samples of each piece to a dense polyline of the clothoid.
|
|
78
|
+
* A sample looks only at the part of the polyline near its arc length. A smaller part gives a larger distance, so the error does not get smaller.
|
|
79
|
+
*/
|
|
80
|
+
function clothoidFits(theta: number): (n: number) => ClothoidFit {
|
|
81
|
+
const length = 2 * theta;
|
|
82
|
+
const a2 = length;
|
|
83
|
+
const at = (t: number): { p: Point; d: Point; dd: Point } => {
|
|
84
|
+
const s = t * length;
|
|
85
|
+
const angle = (s * s) / (2 * a2);
|
|
86
|
+
const k = s / a2;
|
|
87
|
+
const d = { x: length * Math.cos(angle), y: length * Math.sin(angle) };
|
|
88
|
+
return { p: clothoidPoint(s, a2), d, dd: { x: -d.y * length * k, y: d.x * length * k } };
|
|
89
|
+
};
|
|
90
|
+
const dense: Point[] = [];
|
|
91
|
+
for (let i = 0; i <= DENSE; i++) dense.push(clothoidPoint((i / DENSE) * length, a2));
|
|
92
|
+
const done: ClothoidFit[] = [];
|
|
93
|
+
return (n) => {
|
|
94
|
+
const known = done[n];
|
|
95
|
+
if (known) return known;
|
|
96
|
+
const pieces = fitPieces(at, n);
|
|
97
|
+
const window = Math.ceil(DENSE / n / 2) + 8;
|
|
98
|
+
let error = 0;
|
|
99
|
+
let from: Point = { x: 0, y: 0 };
|
|
100
|
+
pieces.forEach((q, k) => {
|
|
101
|
+
for (let i = 1; i < 16; i++) {
|
|
102
|
+
const mid = Math.round(((k + i / 16) / n) * DENSE);
|
|
103
|
+
error = Math.max(error, distanceTo(cubicAt(from, q.c1, q.c2, q.to, i / 16), dense, mid - window, mid + window));
|
|
104
|
+
}
|
|
105
|
+
from = q.to;
|
|
106
|
+
});
|
|
107
|
+
const fit = { pieces, error };
|
|
108
|
+
done[n] = fit;
|
|
109
|
+
return fit;
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** This function gives the shape of a corner of `clothoidCurve` with the turn `turn` at the radius 1. */
|
|
114
|
+
export function clothoidShape(turn: number, spiral: number, tolerance: number): SpiralShape {
|
|
115
|
+
const theta = (Math.max(0, Math.min(1, spiral)) * turn) / 2;
|
|
116
|
+
if (theta < 1e-9) return { tangent: Math.tan(turn / 2), theta: 0, pieces: () => [], end: { x: 0, y: 0 } };
|
|
117
|
+
const key = `${turn}|${spiral}`;
|
|
118
|
+
let all = fits.get(key);
|
|
119
|
+
if (all === undefined) {
|
|
120
|
+
if (fits.size >= 256) fits.clear();
|
|
121
|
+
all = clothoidFits(theta);
|
|
122
|
+
fits.set(key, all);
|
|
123
|
+
}
|
|
124
|
+
const fit = all;
|
|
125
|
+
const end = clothoidPoint(2 * theta, 2 * theta);
|
|
126
|
+
return {
|
|
127
|
+
tangent: cornerTangent(turn, theta, end),
|
|
128
|
+
theta,
|
|
129
|
+
end,
|
|
130
|
+
// The error of a fit scales with the radius. The corner takes the fewest pieces that keep the error within the tolerance.
|
|
131
|
+
pieces: (radius) => {
|
|
132
|
+
for (let n = 1; n < MAX_CLOTHOID_PIECES; n++) if (fit(n).error * radius <= tolerance) return fit(n).pieces;
|
|
133
|
+
return fit(MAX_CLOTHOID_PIECES).pieces;
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* This function rounds each inner vertex of a polyline with a clothoid, a circular arc and a second clothoid (F6 of the note CURVES).
|
|
140
|
+
* The curvature of a clothoid grows linearly with its length, from 0 on the leg to `1 / radius` on the arc. This is the transition curve of roads and railways.
|
|
141
|
+
* Each clothoid turns by `spiral` times half of the turn. G2 cubic pieces draw it within `tolerance` of the true clothoid.
|
|
142
|
+
*
|
|
143
|
+
* The ends of each corner stay on the first half of each leg, so the ends of the polyline stay.
|
|
144
|
+
* With `space`, the radius of a corner shrinks until its corner triangle overlaps no box that the polyline avoids.
|
|
145
|
+
*/
|
|
146
|
+
export function clothoidCurve(points: readonly Point[], options: ClothoidOptions = {}): CurveSegment[] {
|
|
147
|
+
const spiral = options.spiral ?? 0.5;
|
|
148
|
+
const tolerance = Math.max(1e-3, options.tolerance ?? 0.25);
|
|
149
|
+
return spiralCorners(points, { radius: options.radius ?? 12, ...(options.space ? { space: options.space } : {}) }, (turn) => clothoidShape(turn, spiral, tolerance));
|
|
150
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { dist, type Point } from "../geom.ts";
|
|
2
|
+
import type { CubicPiece } from "./fit.ts";
|
|
3
|
+
import { distinctPoints, type CurveSegment } from "./model.ts";
|
|
4
|
+
import { fitCorner, type CurveSpace } from "./space.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The shape of a symmetric corner with a spiral, an arc and a second spiral, at the smallest radius 1.
|
|
8
|
+
* The local frame of the first spiral starts at the tangent point on the leg in. Its x axis goes along the leg, and its y axis goes to the side of the turn.
|
|
9
|
+
*/
|
|
10
|
+
export interface SpiralShape {
|
|
11
|
+
/** The tangent length of the corner at the radius 1: the distance from the vertex to each end of the corner. */
|
|
12
|
+
readonly tangent: number;
|
|
13
|
+
/** The turn of each spiral, in radians. The arc turns by the rest of the corner. */
|
|
14
|
+
readonly theta: number;
|
|
15
|
+
/** The cubic pieces of the first spiral at the radius 1, from the origin of the local frame to the start of the arc. */
|
|
16
|
+
readonly pieces: (radius: number) => readonly CubicPiece[];
|
|
17
|
+
/** The end of the first spiral at the radius 1: the start of the arc. */
|
|
18
|
+
readonly end: Point;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A turn smaller than this angle, in radians, is no corner. A turn nearer to a half turn than this angle stays sharp. */
|
|
22
|
+
const FLAT = 1e-6;
|
|
23
|
+
|
|
24
|
+
/** The options of `spiralCorners`. */
|
|
25
|
+
export interface SpiralCornerOptions {
|
|
26
|
+
/** The largest smallest radius of a corner. */
|
|
27
|
+
readonly radius: number;
|
|
28
|
+
/** The boxes that the curve must not go through. Without it, each corner gets the largest radius that fits on its legs. */
|
|
29
|
+
readonly space?: CurveSpace;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* This function puts a symmetric corner at each inner vertex of a polyline: a spiral, an arc and a second spiral.
|
|
34
|
+
* The function `shapeOf` gives the shape of a corner from its turn, at the radius 1. A larger radius scales the shape.
|
|
35
|
+
* The ends of each corner stay on the first half of each leg, so the ends of the polyline stay.
|
|
36
|
+
* With `space`, the radius of a corner shrinks until its corner triangle overlaps no box that the polyline avoids.
|
|
37
|
+
*/
|
|
38
|
+
export function spiralCorners(points: readonly Point[], options: SpiralCornerOptions, shapeOf: (turn: number) => SpiralShape): CurveSegment[] {
|
|
39
|
+
const p = distinctPoints(points);
|
|
40
|
+
if (p.length === 0) return [];
|
|
41
|
+
const out: CurveSegment[] = [{ kind: "move", to: p[0]! }];
|
|
42
|
+
for (let i = 1; i + 1 < p.length; i++) {
|
|
43
|
+
const a = p[i - 1]!;
|
|
44
|
+
const v = p[i]!;
|
|
45
|
+
const b = p[i + 1]!;
|
|
46
|
+
const la = dist(a, v);
|
|
47
|
+
const lb = dist(v, b);
|
|
48
|
+
const u = { x: (v.x - a.x) / la, y: (v.y - a.y) / la };
|
|
49
|
+
const w = { x: (b.x - v.x) / lb, y: (b.y - v.y) / lb };
|
|
50
|
+
const turn = Math.atan2(u.x * w.y - u.y * w.x, u.x * w.x + u.y * w.y);
|
|
51
|
+
if (Math.abs(turn) < FLAT || Math.PI - Math.abs(turn) < FLAT) {
|
|
52
|
+
out.push({ kind: "line", to: v });
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const shape = shapeOf(Math.abs(turn));
|
|
56
|
+
const side = Math.sign(turn);
|
|
57
|
+
const ends = (r: number): [Point, Point] => [
|
|
58
|
+
{ x: v.x - u.x * r * shape.tangent, y: v.y - u.y * r * shape.tangent },
|
|
59
|
+
{ x: v.x + w.x * r * shape.tangent, y: v.y + w.y * r * shape.tangent },
|
|
60
|
+
];
|
|
61
|
+
const piece = (r: number): CurveSegment[] => cornerSegments(shape, r, ends(r), u, w, side, Math.abs(turn));
|
|
62
|
+
const r = fitCorner(
|
|
63
|
+
options.space,
|
|
64
|
+
Math.min(Math.max(0, options.radius), Math.min(la, lb) / (2 * shape.tangent)),
|
|
65
|
+
(s) => [ends(s)[0], v, ends(s)[1]],
|
|
66
|
+
(s) => [{ kind: "move", to: ends(s)[0] }, ...piece(s)],
|
|
67
|
+
);
|
|
68
|
+
if (r <= 0) {
|
|
69
|
+
out.push({ kind: "line", to: v });
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const [p1] = ends(r);
|
|
73
|
+
if (dist(p1, out[out.length - 1]!.to) > 1e-9) out.push({ kind: "line", to: p1 });
|
|
74
|
+
out.push(...piece(r));
|
|
75
|
+
}
|
|
76
|
+
if (p.length > 1) out.push({ kind: "line", to: p[p.length - 1]! });
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* This function gives the segments of one corner at the radius `r`, from the tangent point `p1` to the tangent point `p2`.
|
|
82
|
+
* The first spiral is in the frame of the leg in. The second spiral is the mirror image of the first, in the frame of the leg out, in the reverse order.
|
|
83
|
+
*/
|
|
84
|
+
function cornerSegments(shape: SpiralShape, r: number, [p1, p2]: [Point, Point], u: Point, w: Point, side: number, turn: number): CurveSegment[] {
|
|
85
|
+
// The y axis of each frame goes to the side of the turn: the left normal of the leg, times the sign of the turn.
|
|
86
|
+
const n1 = { x: -u.y * side, y: u.x * side };
|
|
87
|
+
const n2 = { x: -w.y * side, y: w.x * side };
|
|
88
|
+
const map1 = (q: Point): Point => ({ x: p1.x + (u.x * q.x + n1.x * q.y) * r, y: p1.y + (u.y * q.x + n1.y * q.y) * r });
|
|
89
|
+
const map2 = (q: Point): Point => ({ x: p2.x + (-w.x * q.x + n2.x * q.y) * r, y: p2.y + (-w.y * q.x + n2.y * q.y) * r });
|
|
90
|
+
const out: CurveSegment[] = [];
|
|
91
|
+
const pieces = shape.theta > 0 ? shape.pieces(r) : [];
|
|
92
|
+
for (const q of pieces) out.push({ kind: "cubic", c1: map1(q.c1), c2: map1(q.c2), to: map1(q.to) });
|
|
93
|
+
const sweep = turn - 2 * shape.theta;
|
|
94
|
+
if (sweep > 1e-9) {
|
|
95
|
+
const from = map1(shape.end);
|
|
96
|
+
// The center of the arc is at the distance r from the end of the spiral, to the side of the turn.
|
|
97
|
+
const center = map1({ x: shape.end.x - Math.sin(shape.theta), y: shape.end.y + Math.cos(shape.theta) });
|
|
98
|
+
out.push({ kind: "arc", center, radius: r, start: Math.atan2(from.y - center.y, from.x - center.x), sweep: side * sweep, to: map2(shape.end) });
|
|
99
|
+
}
|
|
100
|
+
for (let k = pieces.length - 1; k >= 0; k--) {
|
|
101
|
+
const q = pieces[k]!;
|
|
102
|
+
const start = k === 0 ? { x: 0, y: 0 } : pieces[k - 1]!.to;
|
|
103
|
+
out.push({ kind: "cubic", c1: map2(q.c2), c2: map2(q.c1), to: k === 0 ? p2 : map2(start) });
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* This function gives the tangent length of a symmetric corner at the radius 1. The spiral of the corner turns by `theta` and ends at `end`.
|
|
110
|
+
* The center of the arc is on the bisector of the corner: `T = k + (1 + p) tan(turn / 2)`, with the shift `p` and the offset `k` of the arc.
|
|
111
|
+
*/
|
|
112
|
+
export function cornerTangent(turn: number, theta: number, end: Point): number {
|
|
113
|
+
const k = end.x - Math.sin(theta);
|
|
114
|
+
const p = end.y - (1 - Math.cos(theta));
|
|
115
|
+
return k + (1 + p) * Math.tan(turn / 2);
|
|
116
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { Point } from "../geom.ts";
|
|
2
|
+
|
|
3
|
+
/** One end of a piece of a smooth curve: the point, the unit tangent and the signed curvature. */
|
|
4
|
+
export interface CurveEnd {
|
|
5
|
+
readonly p: Point;
|
|
6
|
+
readonly t: Point;
|
|
7
|
+
readonly k: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** The control points of a cubic Bezier piece after its start point. */
|
|
11
|
+
export interface CubicPiece {
|
|
12
|
+
readonly c1: Point;
|
|
13
|
+
readonly c2: Point;
|
|
14
|
+
readonly to: Point;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const cross = (a: Point, b: Point): number => a.x * b.y - a.y * b.x;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* This function fits one cubic Bezier piece to two ends of a curve with G2 Hermite data.
|
|
21
|
+
* The piece starts at `a.p` along `a.t` with the curvature `a.k`, and it ends at `b.p` along `b.t` with the curvature `b.k`.
|
|
22
|
+
*
|
|
23
|
+
* The handles have the lengths `h0` and `h1`. The curvature at the start is `(2/3) (cross(t0, D) - h1 cross(t0, t1)) / h0^2`,
|
|
24
|
+
* and at the end `(2/3) (cross(D, t1) - h0 cross(t0, t1)) / h1^2`, with `D = b.p - a.p`.
|
|
25
|
+
* Newton steps solve these two equations, from the handles `guess`.
|
|
26
|
+
* The function gives `undefined` when the steps do not converge to two positive handles. The caller then uses the C1 handles.
|
|
27
|
+
*/
|
|
28
|
+
export function fitG2(a: CurveEnd, b: CurveEnd, guess: readonly [number, number]): CubicPiece | undefined {
|
|
29
|
+
const D = { x: b.p.x - a.p.x, y: b.p.y - a.p.y };
|
|
30
|
+
const s = cross(a.t, b.t);
|
|
31
|
+
const c0 = cross(a.t, D);
|
|
32
|
+
const c1 = cross(D, b.t);
|
|
33
|
+
let [h0, h1] = guess;
|
|
34
|
+
const scale = Math.max(1e-12, Math.hypot(D.x, D.y));
|
|
35
|
+
for (let i = 0; i < 30; i++) {
|
|
36
|
+
const f1 = a.k * h0 * h0 - (2 / 3) * (c0 - h1 * s);
|
|
37
|
+
const f2 = b.k * h1 * h1 - (2 / 3) * (c1 - h0 * s);
|
|
38
|
+
if (Math.abs(f1) < 1e-13 * scale && Math.abs(f2) < 1e-13 * scale) break;
|
|
39
|
+
// The Jacobian of (f1, f2) by (h0, h1).
|
|
40
|
+
const j11 = 2 * a.k * h0;
|
|
41
|
+
const j12 = (2 / 3) * s;
|
|
42
|
+
const j21 = (2 / 3) * s;
|
|
43
|
+
const j22 = 2 * b.k * h1;
|
|
44
|
+
const det = j11 * j22 - j12 * j21;
|
|
45
|
+
if (Math.abs(det) < 1e-18) return undefined;
|
|
46
|
+
h0 -= (f1 * j22 - f2 * j12) / det;
|
|
47
|
+
h1 -= (j11 * f2 - j21 * f1) / det;
|
|
48
|
+
if (!Number.isFinite(h0) || !Number.isFinite(h1)) return undefined;
|
|
49
|
+
}
|
|
50
|
+
const f1 = a.k * h0 * h0 - (2 / 3) * (c0 - h1 * s);
|
|
51
|
+
const f2 = b.k * h1 * h1 - (2 / 3) * (c1 - h0 * s);
|
|
52
|
+
if (!(h0 > 0 && h1 > 0) || Math.abs(f1) > 1e-9 * scale || Math.abs(f2) > 1e-9 * scale) return undefined;
|
|
53
|
+
// A handle longer than the chord gives a loop or a bulge. The C1 handles are better then.
|
|
54
|
+
if (h0 > 1.5 * scale || h1 > 1.5 * scale) return undefined;
|
|
55
|
+
return { c1: { x: a.p.x + a.t.x * h0, y: a.p.y + a.t.y * h0 }, c2: { x: b.p.x - b.t.x * h1, y: b.p.y - b.t.y * h1 }, to: b.p };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* This function gives `n` cubic Bezier pieces of a smooth curve `at(t)`, with `t` from 0 to 1. The pieces have equal steps of `t`.
|
|
60
|
+
* The function `at` gives the point, the first derivative and the second derivative.
|
|
61
|
+
* Each piece is G2 with its neighbours where `fitG2` finds handles. Elsewhere it has the C1 handles of the derivatives.
|
|
62
|
+
*/
|
|
63
|
+
export function fitPieces(at: (t: number) => { p: Point; d: Point; dd: Point }, n: number): CubicPiece[] {
|
|
64
|
+
const end = (t: number): CurveEnd & { speed: number } => {
|
|
65
|
+
const { p, d, dd } = at(t);
|
|
66
|
+
const speed = Math.hypot(d.x, d.y);
|
|
67
|
+
return { p, t: { x: d.x / speed, y: d.y / speed }, k: cross(d, dd) / speed ** 3, speed };
|
|
68
|
+
};
|
|
69
|
+
const out: CubicPiece[] = [];
|
|
70
|
+
let a = end(0);
|
|
71
|
+
for (let i = 1; i <= n; i++) {
|
|
72
|
+
const b = end(i / n);
|
|
73
|
+
const h0 = a.speed / (3 * n);
|
|
74
|
+
const h1 = b.speed / (3 * n);
|
|
75
|
+
out.push(fitG2(a, b, [h0, h1]) ?? { c1: { x: a.p.x + a.t.x * h0, y: a.p.y + a.t.y * h0 }, c2: { x: b.p.x - b.t.x * h1, y: b.p.y - b.t.y * h1 }, to: b.p });
|
|
76
|
+
a = b;
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|