@mark1russell7/highways 0.1.3 → 0.1.5

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/package.json CHANGED
@@ -9,7 +9,7 @@
9
9
  "directory": "packages/core"
10
10
  },
11
11
  "homepage": "https://mark1russell7.github.io/highways/",
12
- "version": "0.1.3",
12
+ "version": "0.1.5",
13
13
  "type": "module",
14
14
  "main": "./build/index.js",
15
15
  "types": "./build/index.d.ts",
@@ -0,0 +1,125 @@
1
+ import { CURVE_TYPES, curveSpace, sampleCurve, type CurveType } from "../curves/index.ts";
2
+ import { polylineLength } from "../geom.ts";
3
+ import { nodeIndex, type Graph, type Router } from "../model.ts";
4
+ import { nearestSide, SIDE_NORMAL } from "../plugs.ts";
5
+ import { curveStrategies } from "../strategies/builtins.ts";
6
+ import type { Strategy } from "../strategies/model.ts";
7
+ import { measureLinks, type MeasuredLink } from "./links.ts";
8
+
9
+ // The core runs in any host, so it reads the clock without the types of Node or the browser.
10
+ const now = (): number => (globalThis as { performance?: { now(): number } }).performance?.now() ?? Date.now();
11
+
12
+ /**
13
+ * One row of the benchmark of the curves: one curve type on the routes of one router on one fixture.
14
+ * The type `T` of the name of the curve is `CurveType` for `benchCurves`, and `string` for `benchCurveStrategies`.
15
+ */
16
+ export interface CurveBenchRow<T extends string = CurveType> {
17
+ readonly router: string;
18
+ readonly fixture: string;
19
+ readonly type: T;
20
+ /** The number of routes. */
21
+ readonly links: number;
22
+ /** The number of polylines that go through a box that is not one of their ends (`measureLinks`). */
23
+ readonly through: number;
24
+ /** The number of curves without a free space that go through such a box. */
25
+ readonly free: number;
26
+ /** The number of curves with the free space of the nodes (`curveSpace`) that go through such a box. */
27
+ readonly clamped: number;
28
+ /** The ratio of the length of all curves with the free space to the length of all polylines. */
29
+ readonly length: number;
30
+ /** The time to make all curves with the free space, in milliseconds. */
31
+ readonly ms: number;
32
+ }
33
+
34
+ /** The options of `benchCurves`. */
35
+ export interface CurveBenchOptions {
36
+ /** The radius of the types `rounded` and `arc`. The default is 12. */
37
+ readonly radius?: number;
38
+ /** The tolerance of the samples of the curves. The default is 0.25. */
39
+ readonly tolerance?: number;
40
+ }
41
+
42
+ /**
43
+ * This function routes a graph with a router and makes each curve type of each route.
44
+ * It counts the curves that go through a box, with and without the free space, and it compares their length to the polylines.
45
+ * The type `spline` leaves each plug along the normal of the nearest side of its node.
46
+ */
47
+ export function benchCurves(router: Router, fixture: string, graph: Graph, options: CurveBenchOptions = {}): CurveBenchRow[] {
48
+ // Each built-in curve strategy has the name of its curve type, so each row has a `CurveType`.
49
+ return benchCurveStrategies(
50
+ router,
51
+ fixture,
52
+ graph,
53
+ CURVE_TYPES.map((t) => curveStrategies[t]),
54
+ options,
55
+ ) as CurveBenchRow[];
56
+ }
57
+
58
+ /**
59
+ * This function does the work of `benchCurves` with a list of curve strategies, in place of the curve types.
60
+ * The option `radius` goes to each strategy. A strategy without such an option does not read it.
61
+ */
62
+ export function benchCurveStrategies(
63
+ router: Router,
64
+ fixture: string,
65
+ graph: Graph,
66
+ curves: readonly Strategy<"curve">[],
67
+ options: CurveBenchOptions = {},
68
+ ): CurveBenchRow<string>[] {
69
+ const tolerance = options.tolerance ?? 0.25;
70
+ const result = router.route(graph);
71
+ const byId = nodeIndex(graph);
72
+ const boxes = new Map(graph.nodes.map((n) => [n.id, n.box]));
73
+ const edges = new Map(graph.edges.map((e) => [e.id, e]));
74
+ const space = curveSpace(
75
+ graph.nodes.map((n) => n.box),
76
+ tolerance,
77
+ );
78
+ const routes = result.routes.flatMap((r) => {
79
+ const e = edges.get(r.edge);
80
+ const s = e && byId.get(e.source.node);
81
+ const t = e && byId.get(e.target.node);
82
+ if (!e || !s || !t || r.points.length < 2) return [];
83
+ const first = r.points[0]!;
84
+ const last = r.points[r.points.length - 1]!;
85
+ return [{ source: s.id, target: t.id, points: r.points, startNormal: SIDE_NORMAL[nearestSide(s.box, first)], endNormal: SIDE_NORMAL[nearestSide(t.box, last)] }];
86
+ });
87
+ const base = measureLinks(boxes, routes);
88
+ const ink = routes.reduce((sum, r) => sum + polylineLength(r.points), 0);
89
+ const radius = { radius: options.radius ?? 12 };
90
+ return curves.map((curve): CurveBenchRow<string> => {
91
+ const make = (withSpace: boolean): MeasuredLink[] =>
92
+ routes.map((r) => ({
93
+ source: r.source,
94
+ target: r.target,
95
+ points: sampleCurve(
96
+ curve.run({ points: r.points, startNormal: r.startNormal, endNormal: r.endNormal, ...(withSpace ? { space } : {}) }, radius),
97
+ tolerance,
98
+ ),
99
+ }));
100
+ const free = measureLinks(boxes, make(false));
101
+ const t0 = now();
102
+ const clamped = make(true);
103
+ const ms = now() - t0;
104
+ const length = clamped.reduce((sum, l) => sum + polylineLength(l.points), 0);
105
+ return {
106
+ router: router.name,
107
+ fixture,
108
+ type: curve.name,
109
+ links: routes.length,
110
+ through: base.through,
111
+ free: free.through,
112
+ clamped: measureLinks(boxes, clamped).through,
113
+ length: ink > 0 ? length / ink : 1,
114
+ ms,
115
+ };
116
+ });
117
+ }
118
+
119
+ /** This function writes the rows of `benchCurves` as a Markdown table. */
120
+ export function formatCurves(rows: readonly CurveBenchRow<string>[]): string {
121
+ const head = "| router | fixture | curve | routes | through, polyline | through, free | through, clamped | length | ms |";
122
+ const line = "|---|---|---|---:|---:|---:|---:|---:|---:|";
123
+ const body = rows.map((r) => `| ${r.router} | ${r.fixture} | ${r.type} | ${r.links} | ${r.through} | ${r.free} | ${r.clamped} | ${r.length.toFixed(3)} | ${r.ms.toFixed(1)} |`);
124
+ return [head, line, ...body].join("\n");
125
+ }
@@ -0,0 +1,190 @@
1
+ import { levelLinks } from "../levels.ts";
2
+ import type { Graph, Node, Result } from "../model.ts";
3
+ import { makeRoom, openGaps, roomLinks, type RoomOptions } from "../room/index.ts";
4
+ import { gapLanes, LANE_ORDERS, type LaneOrder } from "../room/lanes.ts";
5
+ import { sectionsRouter } from "../routers/sections.ts";
6
+ import { laneRoutes, runKey, segmentsOf } from "../segments.ts";
7
+ import { roomStrategies } from "../strategies/builtins.ts";
8
+ import type { Strategy } from "../strategies/model.ts";
9
+ import { measureLinks, measureRouteFaults, type LinkFaults } from "./links.ts";
10
+ import { measureQuality } from "./quality.ts";
11
+
12
+ // The core runs in any host, so it reads the clock without the types of Node or the browser.
13
+ const now = (): number => (globalThis as { performance?: { now(): number } }).performance?.now() ?? Date.now();
14
+
15
+ /** One row of the benchmark of the room: one state of a board, packed or open, and the measures of its links. */
16
+ export interface RoomBenchRow {
17
+ readonly fixture: string;
18
+ /** The state of the board: `packed`, `gutter` (each gap open to the gutter only), or a method of `roomDemand`. */
19
+ readonly board: string;
20
+ /** The area of the bounding box of the boxes, as a share of the area of the packed board. */
21
+ readonly area: number;
22
+ /** The faults of the links of the far view (`levelLinks` with each box as one item). */
23
+ readonly faults: LinkFaults;
24
+ /** The crossings of the links of the far view (`measureQuality`). */
25
+ readonly crossings: number;
26
+ /** The ink of the links of the far view. */
27
+ readonly ink: number;
28
+ /** The mean detour of the links of the far view. */
29
+ readonly detour: number;
30
+ /**
31
+ * The number of lane routes of the router `sections` that go through a box. Each shared segment opens to one lane for each edge,
32
+ * at the spacing of the options (`laneRoutes`).
33
+ */
34
+ readonly laneThrough: number;
35
+ /** The time of `makeRoom`, in milliseconds. It is 0 for the packed board. */
36
+ readonly ms: number;
37
+ }
38
+
39
+ /** This function gives the area of the bounding box of the nodes. */
40
+ function areaOf(nodes: readonly Node[]): number {
41
+ let x0 = Infinity;
42
+ let y0 = Infinity;
43
+ let x1 = -Infinity;
44
+ let y1 = -Infinity;
45
+ for (const n of nodes) {
46
+ x0 = Math.min(x0, n.box.x);
47
+ y0 = Math.min(y0, n.box.y);
48
+ x1 = Math.max(x1, n.box.x + n.box.w);
49
+ y1 = Math.max(y1, n.box.y + n.box.h);
50
+ }
51
+ return nodes.length ? (x1 - x0) * (y1 - y0) : 0;
52
+ }
53
+
54
+ /**
55
+ * This function measures the links of one board. It measures the links of the far view, with each node of the graph as one item of a level.
56
+ * With a lane order, each link of the far view gets its own lane in each gap (`gapLanes` at the lane width `spacing`).
57
+ * Without it, the links of a gap share the lines of the level links.
58
+ * The function also measures the lane routes of the router `sections` at the lane width `spacing`.
59
+ */
60
+ export function measureBoard(graph: Graph, spacing: number, order?: LaneOrder): Omit<RoomBenchRow, "fixture" | "board" | "area" | "ms"> {
61
+ const links = roomLinks(graph);
62
+ const items = graph.nodes.map((n) => ({ id: n.id, box: n.box, nodes: [n.id] }));
63
+ const level = levelLinks(
64
+ items,
65
+ links.map((l) => ({ source: l.source, target: l.target, count: l.count ?? 1 })),
66
+ );
67
+ const drawn = order ? gapLanes(graph.nodes, level, { spacing, order }) : level;
68
+ const boxes = new Map(graph.nodes.map((n) => [n.id, n.box]));
69
+ // `measureQuality` reads routes of edges, so each link of the far view stands for the first edge of its pair.
70
+ const edgeOf = new Map<string, string>();
71
+ for (const e of graph.edges) {
72
+ const key = `${e.source.node}\u0000${e.target.node}`;
73
+ if (!edgeOf.has(key)) edgeOf.set(key, e.id);
74
+ }
75
+ const asRoutes: Result = { routes: drawn.map((l) => ({ edge: edgeOf.get(`${l.source}\u0000${l.target}`)!, points: l.points })) };
76
+ const q = measureQuality(graph, asRoutes);
77
+ const routed = sectionsRouter({ name: "sections" }).route(graph);
78
+ const segments = segmentsOf(routed);
79
+ const open = new Set(segments.segments.filter((s) => s.count > 1).map((s) => runKey(s.edges)));
80
+ const lanes = laneRoutes(segments, open, spacing);
81
+ return {
82
+ faults: measureLinks(boxes, drawn),
83
+ crossings: q.crossings,
84
+ ink: q.ink,
85
+ detour: q.detour,
86
+ laneThrough: measureRouteFaults(graph, { routes: lanes }).through,
87
+ };
88
+ }
89
+
90
+ /**
91
+ * This function measures a packed board, the board with each gap open to the gutter only, and the board that each room strategy opens.
92
+ * The options are the same for each strategy, except `method`. The default strategies are the methods `routes` and `lines` of `roomDemand`.
93
+ */
94
+ export function benchRoom(
95
+ fixture: string,
96
+ graph: Graph,
97
+ options: RoomOptions = {},
98
+ methods: readonly Strategy<"room">[] = [roomStrategies.routes, roomStrategies.lines],
99
+ ): RoomBenchRow[] {
100
+ const spacing = options.spacing ?? 4;
101
+ const area0 = areaOf(graph.nodes);
102
+ const links = roomLinks(graph);
103
+ const row = (board: string, nodes: readonly Node[], ms: number): RoomBenchRow => ({
104
+ fixture,
105
+ board,
106
+ area: area0 > 0 ? areaOf(nodes) / area0 : 1,
107
+ ms,
108
+ ...measureBoard({ nodes, edges: graph.edges }, spacing),
109
+ });
110
+ const rows = [row("packed", graph.nodes, 0)];
111
+ const t0 = now();
112
+ const gutter = makeRoom(graph.nodes, links, { ...options, spacing: 0, method: "routes" });
113
+ rows.push(row("gutter", gutter.items, now() - t0));
114
+ const { method: _method, ...preset } = options;
115
+ for (const s of methods) {
116
+ const t1 = now();
117
+ const gaps = s.run({ items: graph.nodes, links }, preset);
118
+ const nodes = openGaps(graph.nodes, gaps, options.snap);
119
+ rows.push(row(s.name, nodes, now() - t1));
120
+ }
121
+ return rows;
122
+ }
123
+
124
+ /** One setting of a sweep of the room: the options of `makeRoom` and the lane order of the far view. */
125
+ export interface RoomSetting {
126
+ /** The name of the setting in the column `board`. */
127
+ readonly name: string;
128
+ readonly options: RoomOptions;
129
+ /** The lane order of the far view. Without it, the links of a gap share the lines of the level links. */
130
+ readonly lanes?: LaneOrder;
131
+ }
132
+
133
+ /**
134
+ * This function measures the board that `makeRoom` opens with each setting, with the method `routes`.
135
+ * The first row is the fair baseline: the board with each gap open to the gutter of `baseline` only, measured at the spacing of `baseline`.
136
+ */
137
+ export function sweepRoom(fixture: string, graph: Graph, settings: readonly RoomSetting[], baseline: RoomOptions = { gutter: 8, spacing: 4 }): RoomBenchRow[] {
138
+ const area0 = areaOf(graph.nodes);
139
+ const links = roomLinks(graph);
140
+ const row = (board: string, nodes: readonly Node[], ms: number, spacing: number, lanes?: LaneOrder): RoomBenchRow => ({
141
+ fixture,
142
+ board,
143
+ area: area0 > 0 ? areaOf(nodes) / area0 : 1,
144
+ ms,
145
+ ...measureBoard({ nodes, edges: graph.edges }, spacing, lanes),
146
+ });
147
+ const fair = makeRoom(graph.nodes, links, { ...baseline, spacing: 0, method: "routes", maxGrowth: Infinity, passes: 1 });
148
+ const rows = [row("gutter", fair.items, 0, baseline.spacing ?? 4)];
149
+ for (const s of settings) {
150
+ const t0 = now();
151
+ const open = makeRoom(graph.nodes, links, { ...s.options, method: "routes" });
152
+ rows.push(row(s.name, open.items, now() - t0, s.options.spacing ?? 4, s.lanes));
153
+ }
154
+ return rows;
155
+ }
156
+
157
+ /** The values of `maxGrowth` of the sweep of ROOM-A. */
158
+ export const GROWTH_SWEEP: readonly number[] = [0, 0.06, 0.09, 0.12, 0.15, 0.18, 0.21, 0.25, 0.3, 0.35, 0.5, Infinity];
159
+
160
+ /** This function gives the settings of ROOM-A: the area and the faults for each `maxGrowth`, at gutter 8 and spacing 4. */
161
+ export function growthSettings(values: readonly number[] = GROWTH_SWEEP): RoomSetting[] {
162
+ return values.map((m) => ({ name: `maxGrowth ${m}`, options: { gutter: 8, spacing: 4, maxGrowth: m } }));
163
+ }
164
+
165
+ /**
166
+ * This function gives the settings of ROOM-C: each lane order of the far view at each spacing, at gutter 8.
167
+ * The order `shared` is the far view without lanes, as before ROOM-C.
168
+ */
169
+ export function laneSettings(spacings: readonly number[] = [2, 4, 6, 8], orders: readonly LaneOrder[] = LANE_ORDERS): RoomSetting[] {
170
+ return spacings.flatMap((spacing) => [
171
+ { name: `shared spacing ${spacing}`, options: { gutter: 8, spacing } },
172
+ ...orders.map((lanes) => ({ name: `${lanes} spacing ${spacing}`, options: { gutter: 8, spacing }, lanes })),
173
+ ]);
174
+ }
175
+
176
+ /** This function gives the settings of ROOM-D: each gutter with each number of passes, at spacing 4. */
177
+ export function passSettings(gutters: readonly number[] = [4, 8, 12, 16], passes: readonly number[] = [1, 2]): RoomSetting[] {
178
+ return gutters.flatMap((gutter) => passes.map((p) => ({ name: `gutter ${gutter} passes ${p}`, options: { gutter, spacing: 4, passes: p } })));
179
+ }
180
+
181
+ /** This function writes the rows of `benchRoom` as a Markdown table. */
182
+ export function formatRoom(rows: readonly RoomBenchRow[]): string {
183
+ const head = "| fixture | board | area | links | through | outside | out and back | worst detour | crossings | ink | detour | lane through | ms |";
184
+ const line = "|---|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|";
185
+ const body = rows.map((r) => {
186
+ const f = r.faults;
187
+ return `| ${r.fixture} | ${r.board} | ${r.area.toFixed(3)} | ${f.links} | ${f.through} | ${f.outside} | ${f.backtrack} | ${f.worstDetour.toFixed(2)} | ${r.crossings} | ${Math.round(r.ink)} | ${r.detour.toFixed(3)} | ${r.laneThrough} | ${r.ms.toFixed(1)} |`;
188
+ });
189
+ return [head, line, ...body].join("\n");
190
+ }
package/src/cases.ts CHANGED
@@ -103,6 +103,34 @@ export function manyGroupsBoard(): Graph {
103
103
  return b.graph();
104
104
  }
105
105
 
106
+ /**
107
+ * This function makes the far view of a board after a host layout packs it.
108
+ * The board has 96 boxes of one size in a grid of 12 columns and 8 rows.
109
+ * The boxes touch, with no gap between them. Each box links to a box near it, and 48 long links join boxes far apart.
110
+ * The links have no channel between the boxes. The roadmap item ROOM measures this board before and after `makeRoom`.
111
+ *
112
+ * The board is not in `CASES`, because the digests of the router fix the output on each board of `CASES`.
113
+ */
114
+ export function packedGridBoard(): Graph {
115
+ const R = rng(11);
116
+ const b = new Board();
117
+ const cols = 12;
118
+ const rows = 8;
119
+ const w = 140;
120
+ const h = 90;
121
+ const id = (r: number, c: number): string => `p${r}-${c}`;
122
+ for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) b.node(id(r, c), c * w, r * h, w, h);
123
+ const step = (): number => Math.floor(R() * 5) - 2;
124
+ for (let r = 0; r < rows; r++)
125
+ for (let c = 0; c < cols; c++) {
126
+ const rr = Math.max(0, Math.min(rows - 1, r + step()));
127
+ const cc = Math.max(0, Math.min(cols - 1, c + step()));
128
+ b.link(id(r, c), id(rr, cc));
129
+ }
130
+ for (let n = 0; n < 48; n++) b.link(id(Math.floor(R() * rows), Math.floor(R() * cols)), id(Math.floor(R() * rows), Math.floor(R() * cols)));
131
+ return b.graph();
132
+ }
133
+
106
134
  /** The case boards of the benchmark. Each board has a few hundred nodes at most, so the benchmark races them on each run. */
107
135
  export const CASES: readonly CaseBoard[] = [
108
136
  { name: "case-dense-groups", defect: "Groups overlap at the far view, and level links go through other groups.", make: denseGroupsBoard },
@@ -0,0 +1,71 @@
1
+ import { dist, type Point } from "../../geom.ts";
2
+ import { distinctPoints, type CurveSegment } from "../model.ts";
3
+ import { fitSize, type CurveSpace } from "../space.ts";
4
+
5
+ /** The options of `arcCurve`. */
6
+ export interface ArcOptions {
7
+ /** The largest radius of the arc at a corner. The default is 12. */
8
+ readonly radius?: number;
9
+ /** The boxes that the curve must not go through. Without it, each corner gets the largest radius that fits on its legs. */
10
+ readonly space?: CurveSpace;
11
+ }
12
+
13
+ /** A turn smaller than this angle, in radians, is no corner. A turn nearer to a half turn than this angle stays sharp. */
14
+ const FLAT = 1e-6;
15
+
16
+ /**
17
+ * This function puts a circular arc at each inner vertex of a polyline. The arc is tangent to the two legs of its corner.
18
+ * The tangent points stay on the first half of each leg, so the ends of the polyline stay.
19
+ * With `space`, the function makes the radius of a corner smaller until the arc goes through no box that the polyline avoids.
20
+ * On a polyline with only horizontal and vertical legs, each arc is a quarter circle.
21
+ */
22
+ export function arcCurve(points: readonly Point[], options: ArcOptions = {}): CurveSegment[] {
23
+ const p = distinctPoints(points);
24
+ if (p.length === 0) return [];
25
+ const radius = options.radius ?? 12;
26
+ const out: CurveSegment[] = [{ kind: "move", to: p[0]! }];
27
+ for (let i = 1; i + 1 < p.length; i++) {
28
+ const a = p[i - 1]!;
29
+ const v = p[i]!;
30
+ const b = p[i + 1]!;
31
+ const la = dist(a, v);
32
+ const lb = dist(v, b);
33
+ const ux = (v.x - a.x) / la;
34
+ const uy = (v.y - a.y) / la;
35
+ const wx = (b.x - v.x) / lb;
36
+ const wy = (b.y - v.y) / lb;
37
+ const turn = Math.atan2(ux * wy - uy * wx, ux * wx + uy * wy);
38
+ if (Math.abs(turn) < FLAT || Math.PI - Math.abs(turn) < FLAT) {
39
+ out.push({ kind: "line", to: v });
40
+ continue;
41
+ }
42
+ // The tangent points are r·tan(|turn|/2) from the vertex.
43
+ const k = Math.tan(Math.abs(turn) / 2);
44
+ const side = Math.sign(turn);
45
+ const ends = (r: number): [Point, Point] => [
46
+ { x: v.x - ux * r * k, y: v.y - uy * r * k },
47
+ { x: v.x + wx * r * k, y: v.y + wy * r * k },
48
+ ];
49
+ const piece = (r: number): CurveSegment[] => {
50
+ const [p1, p2] = ends(r);
51
+ // The center is on the side of the turn, at the distance r from the first leg.
52
+ const center = { x: p1.x - side * uy * r, y: p1.y + side * ux * r };
53
+ return [{ kind: "arc", center, radius: r, start: Math.atan2(p1.y - center.y, p1.x - center.x), sweep: turn, to: p2 }];
54
+ };
55
+ const r = fitSize(
56
+ options.space,
57
+ Math.min(radius, Math.min(la, lb) / (2 * k)),
58
+ (s) => [ends(s)[0], v, ends(s)[1]],
59
+ (s) => [{ kind: "move", to: ends(s)[0] }, ...piece(s)],
60
+ );
61
+ if (r <= 0) {
62
+ out.push({ kind: "line", to: v });
63
+ continue;
64
+ }
65
+ const [p1] = ends(r);
66
+ if (dist(p1, out[out.length - 1]!.to) > 1e-9) out.push({ kind: "line", to: p1 });
67
+ out.push(...piece(r));
68
+ }
69
+ if (p.length > 1) out.push({ kind: "line", to: p[p.length - 1]! });
70
+ return out;
71
+ }
@@ -0,0 +1,48 @@
1
+ import type { Point } from "../geom.ts";
2
+ import { arcCurve } from "./arc/index.ts";
3
+ import type { CurveSegment, CurveType } from "./model.ts";
4
+ import { roundedCurve } from "./rounded/index.ts";
5
+ import type { CurveSpace } from "./space.ts";
6
+ import { splineCurve } from "./spline/index.ts";
7
+ import { straightCurve } from "./straight/index.ts";
8
+
9
+ export * from "./model.ts";
10
+ export { cubicAt, sampleCurve, toSvgPath } from "./sample.ts";
11
+ export { curveSpace, type CurveSpace } from "./space.ts";
12
+ export { straightCurve } from "./straight/index.ts";
13
+ export { roundedCurve, type RoundedOptions } from "./rounded/index.ts";
14
+ export { splineCurve, type SplineOptions } from "./spline/index.ts";
15
+ export { arcCurve, type ArcOptions } from "./arc/index.ts";
16
+
17
+ /** The options of `curveOf`. Each type reads only the options that it uses. */
18
+ export interface CurveOptions {
19
+ readonly type: CurveType;
20
+ /** The types `rounded` and `arc` read this radius of the corners. The default is 12. */
21
+ readonly radius?: number;
22
+ /** The type `spline` reads this tension, from 0 to 1. The default is 0. */
23
+ readonly tension?: number;
24
+ /** The type `spline` leaves the first point along this outward normal of its plug. */
25
+ readonly startNormal?: Point;
26
+ /** The type `spline` comes into the last point against this outward normal of its plug. */
27
+ readonly endNormal?: Point;
28
+ /** The boxes that the curve must not go through, from `curveSpace`. The type `straight` does not read it. */
29
+ readonly space?: CurveSpace;
30
+ }
31
+
32
+ /**
33
+ * This function gives the curve of a route: a polyline of a router, a lane of `laneRoutes` or a link of a zoom level.
34
+ * The curve starts at the first point and ends at the last point of the polyline.
35
+ * The output is a list of segments for any renderer. `toSvgPath` writes it as SVG path data, and `sampleCurve` gives points.
36
+ */
37
+ export function curveOf(points: readonly Point[], options: CurveOptions): CurveSegment[] {
38
+ switch (options.type) {
39
+ case "straight":
40
+ return straightCurve(points);
41
+ case "rounded":
42
+ return roundedCurve(points, options);
43
+ case "spline":
44
+ return splineCurve(points, options);
45
+ case "arc":
46
+ return arcCurve(points, options);
47
+ }
48
+ }
@@ -0,0 +1,64 @@
1
+ import type { Point } from "../geom.ts";
2
+
3
+ /**
4
+ * A type of curve for a route.
5
+ *
6
+ * - `straight`: the polyline as it is.
7
+ * - `rounded`: a quadratic Bezier curve at each corner, as `roundCorners` draws it.
8
+ * - `spline`: a centripetal Catmull-Rom spline through the points. It does not overshoot or make loops.
9
+ * - `arc`: a circular arc at each corner, tangent to the two legs.
10
+ */
11
+ export type CurveType = "straight" | "rounded" | "spline" | "arc";
12
+
13
+ /** The curve types, in the order of the benchmark. */
14
+ export const CURVE_TYPES: readonly CurveType[] = ["straight", "rounded", "spline", "arc"];
15
+
16
+ /** The start of a curve: the pen goes to `to` and draws nothing. */
17
+ export interface MoveSegment {
18
+ readonly kind: "move";
19
+ readonly to: Point;
20
+ }
21
+
22
+ /** A straight line from the end of the segment before to `to`. */
23
+ export interface LineSegment {
24
+ readonly kind: "line";
25
+ readonly to: Point;
26
+ }
27
+
28
+ /** A cubic Bezier curve from the end of the segment before to `to`, with the control points `c1` and `c2`. */
29
+ export interface CubicSegment {
30
+ readonly kind: "cubic";
31
+ readonly c1: Point;
32
+ readonly c2: Point;
33
+ readonly to: Point;
34
+ }
35
+
36
+ /**
37
+ * A circular arc from the end of the segment before to `to`. The arc has its center at `center` and the radius `radius`.
38
+ * It starts at the angle `start` and turns by `sweep` radians. A positive `sweep` turns from the x axis to the y axis.
39
+ * On a canvas, `ctx.arc(center.x, center.y, radius, start, start + sweep, sweep < 0)` draws it.
40
+ */
41
+ export interface ArcSegment {
42
+ readonly kind: "arc";
43
+ readonly center: Point;
44
+ readonly radius: number;
45
+ readonly start: number;
46
+ readonly sweep: number;
47
+ readonly to: Point;
48
+ }
49
+
50
+ /**
51
+ * A segment of a curve. A curve is a list of segments: one `move` first, then lines, cubic curves and arcs.
52
+ * A renderer maps the list to SVG path data, to canvas calls or to the vertices of a mesh.
53
+ */
54
+ export type CurveSegment = MoveSegment | LineSegment | CubicSegment | ArcSegment;
55
+
56
+ /** This function gives the points of a polyline without the points that are nearer than `eps` to the point before them. */
57
+ export function distinctPoints(points: readonly Point[], eps = 1e-9): Point[] {
58
+ const out: Point[] = [];
59
+ for (const p of points) {
60
+ const q = out[out.length - 1];
61
+ if (q === undefined || Math.hypot(p.x - q.x, p.y - q.y) > eps) out.push(p);
62
+ }
63
+ return out;
64
+ }
@@ -0,0 +1,60 @@
1
+ import { dist, type Point } from "../../geom.ts";
2
+ import { distinctPoints, type CurveSegment } from "../model.ts";
3
+ import { fitSize, type CurveSpace } from "../space.ts";
4
+
5
+ /** The options of `roundedCurve`. */
6
+ export interface RoundedOptions {
7
+ /** The largest distance from a corner to the start and to the end of its curve. The default is 12. */
8
+ readonly radius?: number;
9
+ /** The boxes that the curve must not go through. Without it, each corner gets the full radius. */
10
+ readonly space?: CurveSpace;
11
+ }
12
+
13
+ /**
14
+ * This function rounds each inner vertex of a polyline with a quadratic Bezier curve, as `roundCorners` does.
15
+ * The curve of a corner starts and ends at most `radius` from the vertex, and at most half of each leg. The ends of the polyline stay.
16
+ * The function gives each quadratic curve as the same cubic curve. With `space`, it makes the radius of a corner smaller until the curve
17
+ * goes through no box that the polyline avoids.
18
+ */
19
+ export function roundedCurve(points: readonly Point[], options: RoundedOptions = {}): CurveSegment[] {
20
+ const p = distinctPoints(points);
21
+ if (p.length === 0) return [];
22
+ const radius = options.radius ?? 12;
23
+ const out: CurveSegment[] = [{ kind: "move", to: p[0]! }];
24
+ for (let i = 1; i + 1 < p.length; i++) {
25
+ const a = p[i - 1]!;
26
+ const v = p[i]!;
27
+ const b = p[i + 1]!;
28
+ const la = dist(a, v);
29
+ const lb = dist(v, b);
30
+ const ends = (r: number): [Point, Point] => [
31
+ { x: v.x + ((a.x - v.x) * r) / la, y: v.y + ((a.y - v.y) * r) / la },
32
+ { x: v.x + ((b.x - v.x) * r) / lb, y: v.y + ((b.y - v.y) * r) / lb },
33
+ ];
34
+ const piece = (r: number): CurveSegment[] => {
35
+ const [p1, p2] = ends(r);
36
+ // A quadratic curve with the control point v is the cubic curve with the control points 2/3 of the way to v.
37
+ return [{ kind: "cubic", c1: { x: p1.x + (2 * (v.x - p1.x)) / 3, y: p1.y + (2 * (v.y - p1.y)) / 3 }, c2: { x: p2.x + (2 * (v.x - p2.x)) / 3, y: p2.y + (2 * (v.y - p2.y)) / 3 }, to: p2 }];
38
+ };
39
+ const r = fitSize(
40
+ options.space,
41
+ Math.min(radius, la / 2, lb / 2),
42
+ (s) => [ends(s)[0], v, ends(s)[1]],
43
+ (s) => [{ kind: "move", to: ends(s)[0] }, ...piece(s)],
44
+ );
45
+ if (r <= 0) {
46
+ out.push({ kind: "line", to: v });
47
+ continue;
48
+ }
49
+ const [p1] = ends(r);
50
+ if (dist(p1, at(out)) > 1e-9) out.push({ kind: "line", to: p1 });
51
+ out.push(...piece(r));
52
+ }
53
+ if (p.length > 1) out.push({ kind: "line", to: p[p.length - 1]! });
54
+ return out;
55
+ }
56
+
57
+ /** This function gives the end of the last segment. */
58
+ function at(segments: readonly CurveSegment[]): Point {
59
+ return segments[segments.length - 1]!.to;
60
+ }