@mark1russell7/highways 0.1.3 → 0.1.4

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.4",
13
13
  "type": "module",
14
14
  "main": "./build/index.js",
15
15
  "types": "./build/index.d.ts",
@@ -0,0 +1,98 @@
1
+ import { curveOf, 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 { measureLinks, type MeasuredLink } from "./links.ts";
6
+
7
+ // The core runs in any host, so it reads the clock without the types of Node or the browser.
8
+ const now = (): number => (globalThis as { performance?: { now(): number } }).performance?.now() ?? Date.now();
9
+
10
+ /** One row of the benchmark of the curves: one curve type on the routes of one router on one fixture. */
11
+ export interface CurveBenchRow {
12
+ readonly router: string;
13
+ readonly fixture: string;
14
+ readonly type: CurveType;
15
+ /** The number of routes. */
16
+ readonly links: number;
17
+ /** The number of polylines that go through a box that is not one of their ends (`measureLinks`). */
18
+ readonly through: number;
19
+ /** The number of curves without a free space that go through such a box. */
20
+ readonly free: number;
21
+ /** The number of curves with the free space of the nodes (`curveSpace`) that go through such a box. */
22
+ readonly clamped: number;
23
+ /** The ratio of the length of all curves with the free space to the length of all polylines. */
24
+ readonly length: number;
25
+ /** The time to make all curves with the free space, in milliseconds. */
26
+ readonly ms: number;
27
+ }
28
+
29
+ /** The options of `benchCurves`. */
30
+ export interface CurveBenchOptions {
31
+ /** The radius of the types `rounded` and `arc`. The default is 12. */
32
+ readonly radius?: number;
33
+ /** The tolerance of the samples of the curves. The default is 0.25. */
34
+ readonly tolerance?: number;
35
+ }
36
+
37
+ /**
38
+ * This function routes a graph with a router and makes each curve type of each route.
39
+ * It counts the curves that go through a box, with and without the free space, and it compares their length to the polylines.
40
+ * The type `spline` leaves each plug along the normal of the nearest side of its node.
41
+ */
42
+ export function benchCurves(router: Router, fixture: string, graph: Graph, options: CurveBenchOptions = {}): CurveBenchRow[] {
43
+ const tolerance = options.tolerance ?? 0.25;
44
+ const result = router.route(graph);
45
+ const byId = nodeIndex(graph);
46
+ const boxes = new Map(graph.nodes.map((n) => [n.id, n.box]));
47
+ const edges = new Map(graph.edges.map((e) => [e.id, e]));
48
+ const space = curveSpace(
49
+ graph.nodes.map((n) => n.box),
50
+ tolerance,
51
+ );
52
+ const routes = result.routes.flatMap((r) => {
53
+ const e = edges.get(r.edge);
54
+ const s = e && byId.get(e.source.node);
55
+ const t = e && byId.get(e.target.node);
56
+ if (!e || !s || !t || r.points.length < 2) return [];
57
+ const first = r.points[0]!;
58
+ const last = r.points[r.points.length - 1]!;
59
+ return [{ source: s.id, target: t.id, points: r.points, startNormal: SIDE_NORMAL[nearestSide(s.box, first)], endNormal: SIDE_NORMAL[nearestSide(t.box, last)] }];
60
+ });
61
+ const base = measureLinks(boxes, routes);
62
+ const ink = routes.reduce((sum, r) => sum + polylineLength(r.points), 0);
63
+ return CURVE_TYPES.map((type): CurveBenchRow => {
64
+ const make = (withSpace: boolean): MeasuredLink[] =>
65
+ routes.map((r) => ({
66
+ source: r.source,
67
+ target: r.target,
68
+ points: sampleCurve(
69
+ curveOf(r.points, { type, radius: options.radius ?? 12, startNormal: r.startNormal, endNormal: r.endNormal, ...(withSpace ? { space } : {}) }),
70
+ tolerance,
71
+ ),
72
+ }));
73
+ const free = measureLinks(boxes, make(false));
74
+ const t0 = now();
75
+ const clamped = make(true);
76
+ const ms = now() - t0;
77
+ const length = clamped.reduce((sum, l) => sum + polylineLength(l.points), 0);
78
+ return {
79
+ router: router.name,
80
+ fixture,
81
+ type,
82
+ links: routes.length,
83
+ through: base.through,
84
+ free: free.through,
85
+ clamped: measureLinks(boxes, clamped).through,
86
+ length: ink > 0 ? length / ink : 1,
87
+ ms,
88
+ };
89
+ });
90
+ }
91
+
92
+ /** This function writes the rows of `benchCurves` as a Markdown table. */
93
+ export function formatCurves(rows: readonly CurveBenchRow[]): string {
94
+ const head = "| router | fixture | curve | routes | through, polyline | through, free | through, clamped | length | ms |";
95
+ const line = "|---|---|---|---:|---:|---:|---:|---:|---:|";
96
+ 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)} |`);
97
+ return [head, line, ...body].join("\n");
98
+ }
@@ -0,0 +1,122 @@
1
+ import { levelLinks } from "../levels.ts";
2
+ import type { Graph, Node, Result } from "../model.ts";
3
+ import { makeRoom, roomLinks, type RoomOptions } from "../room/index.ts";
4
+ import { sectionsRouter } from "../routers/sections.ts";
5
+ import { laneRoutes, runKey, segmentsOf } from "../segments.ts";
6
+ import { measureLinks, measureRouteFaults, type LinkFaults } from "./links.ts";
7
+ import { measureQuality } from "./quality.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
+ /** One row of the benchmark of the room: one state of a board, packed or open, and the measures of its links. */
13
+ export interface RoomBenchRow {
14
+ readonly fixture: string;
15
+ /** The state of the board: `packed`, `gutter` (each gap open to the gutter only), or a method of `roomDemand`. */
16
+ readonly board: string;
17
+ /** The area of the bounding box of the boxes, as a share of the area of the packed board. */
18
+ readonly area: number;
19
+ /** The faults of the links of the far view (`levelLinks` with each box as one item). */
20
+ readonly faults: LinkFaults;
21
+ /** The crossings of the links of the far view (`measureQuality`). */
22
+ readonly crossings: number;
23
+ /** The ink of the links of the far view. */
24
+ readonly ink: number;
25
+ /** The mean detour of the links of the far view. */
26
+ readonly detour: number;
27
+ /**
28
+ * The number of lane routes of the router `sections` that go through a box. Each shared segment opens to one lane for each edge,
29
+ * at the spacing of the options (`laneRoutes`).
30
+ */
31
+ readonly laneThrough: number;
32
+ /** The time of `makeRoom`, in milliseconds. It is 0 for the packed board. */
33
+ readonly ms: number;
34
+ }
35
+
36
+ /** This function gives the area of the bounding box of the nodes. */
37
+ function areaOf(nodes: readonly Node[]): number {
38
+ let x0 = Infinity;
39
+ let y0 = Infinity;
40
+ let x1 = -Infinity;
41
+ let y1 = -Infinity;
42
+ for (const n of nodes) {
43
+ x0 = Math.min(x0, n.box.x);
44
+ y0 = Math.min(y0, n.box.y);
45
+ x1 = Math.max(x1, n.box.x + n.box.w);
46
+ y1 = Math.max(y1, n.box.y + n.box.h);
47
+ }
48
+ return nodes.length ? (x1 - x0) * (y1 - y0) : 0;
49
+ }
50
+
51
+ /**
52
+ * 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.
53
+ * It also measures the lane routes of the router `sections` at the lane width `spacing`.
54
+ */
55
+ export function measureBoard(graph: Graph, spacing: number): Omit<RoomBenchRow, "fixture" | "board" | "area" | "ms"> {
56
+ const links = roomLinks(graph);
57
+ const items = graph.nodes.map((n) => ({ id: n.id, box: n.box, nodes: [n.id] }));
58
+ const drawn = levelLinks(
59
+ items,
60
+ links.map((l) => ({ source: l.source, target: l.target, count: l.count ?? 1 })),
61
+ );
62
+ const boxes = new Map(graph.nodes.map((n) => [n.id, n.box]));
63
+ // `measureQuality` reads routes of edges, so each link of the far view stands for the first edge of its pair.
64
+ const edgeOf = new Map<string, string>();
65
+ for (const e of graph.edges) {
66
+ const key = `${e.source.node}\u0000${e.target.node}`;
67
+ if (!edgeOf.has(key)) edgeOf.set(key, e.id);
68
+ }
69
+ const asRoutes: Result = { routes: drawn.map((l) => ({ edge: edgeOf.get(`${l.source}\u0000${l.target}`)!, points: l.points })) };
70
+ const q = measureQuality(graph, asRoutes);
71
+ const routed = sectionsRouter({ name: "sections" }).route(graph);
72
+ const segments = segmentsOf(routed);
73
+ const open = new Set(segments.segments.filter((s) => s.count > 1).map((s) => runKey(s.edges)));
74
+ const lanes = laneRoutes(segments, open, spacing);
75
+ return {
76
+ faults: measureLinks(boxes, drawn),
77
+ crossings: q.crossings,
78
+ ink: q.ink,
79
+ detour: q.detour,
80
+ laneThrough: measureRouteFaults(graph, { routes: lanes }).through,
81
+ };
82
+ }
83
+
84
+ /**
85
+ * This function measures a packed board, the board with each gap open to the gutter only, and the board that `makeRoom` opens with each method.
86
+ * The options of `makeRoom` are the same for each method, except `method`.
87
+ */
88
+ export function benchRoom(fixture: string, graph: Graph, options: RoomOptions = {}): RoomBenchRow[] {
89
+ const spacing = options.spacing ?? 4;
90
+ const area0 = areaOf(graph.nodes);
91
+ const links = roomLinks(graph);
92
+ const row = (board: string, nodes: readonly Node[], ms: number): RoomBenchRow => ({
93
+ fixture,
94
+ board,
95
+ area: area0 > 0 ? areaOf(nodes) / area0 : 1,
96
+ ms,
97
+ ...measureBoard({ nodes, edges: graph.edges }, spacing),
98
+ });
99
+ const rows = [row("packed", graph.nodes, 0)];
100
+ const states: [string, RoomOptions][] = [
101
+ ["gutter", { ...options, spacing: 0, method: "routes" }],
102
+ ["routes", { ...options, method: "routes" }],
103
+ ["lines", { ...options, method: "lines" }],
104
+ ];
105
+ for (const [board, o] of states) {
106
+ const t0 = now();
107
+ const room = makeRoom(graph.nodes, links, o);
108
+ rows.push(row(board, room.items, now() - t0));
109
+ }
110
+ return rows;
111
+ }
112
+
113
+ /** This function writes the rows of `benchRoom` as a Markdown table. */
114
+ export function formatRoom(rows: readonly RoomBenchRow[]): string {
115
+ const head = "| fixture | board | area | links | through | outside | out and back | worst detour | crossings | ink | detour | lane through | ms |";
116
+ const line = "|---|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|";
117
+ const body = rows.map((r) => {
118
+ const f = r.faults;
119
+ 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)} |`;
120
+ });
121
+ return [head, line, ...body].join("\n");
122
+ }
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
+ }
@@ -0,0 +1,73 @@
1
+ import type { Point } from "../geom.ts";
2
+ import type { CurveSegment } from "./model.ts";
3
+
4
+ /** The largest number of points that `sampleCurve` gives for one segment. */
5
+ const MAX_STEPS = 1024;
6
+
7
+ /**
8
+ * This function gives a polyline that follows the curve. The distance from each part of the curve to the polyline is `tolerance` or less.
9
+ * The polyline starts at the point of the `move` and goes through the end of each segment.
10
+ * Hit tests, measures and renderers that draw only lines use this polyline.
11
+ */
12
+ export function sampleCurve(segments: readonly CurveSegment[], tolerance = 0.5): Point[] {
13
+ const tol = Math.max(1e-6, tolerance);
14
+ const out: Point[] = [];
15
+ let at: Point | undefined;
16
+ for (const s of segments) {
17
+ if (s.kind === "move" || at === undefined) {
18
+ out.push(s.to);
19
+ at = s.to;
20
+ continue;
21
+ }
22
+ if (s.kind === "cubic") {
23
+ // The chord of a step of 1/n of the parameter is within 3·D/(4·n²) of the curve. D is the largest second difference of the control points.
24
+ const d = Math.max(
25
+ Math.hypot(at.x - 2 * s.c1.x + s.c2.x, at.y - 2 * s.c1.y + s.c2.y),
26
+ Math.hypot(s.c1.x - 2 * s.c2.x + s.to.x, s.c1.y - 2 * s.c2.y + s.to.y),
27
+ );
28
+ const n = Math.min(MAX_STEPS, Math.max(1, Math.ceil(Math.sqrt((3 * d) / (4 * tol)))));
29
+ for (let k = 1; k < n; k++) out.push(cubicAt(at, s.c1, s.c2, s.to, k / n));
30
+ } else if (s.kind === "arc") {
31
+ // The chord of an angle φ is within r·(1 - cos(φ/2)) of the arc.
32
+ const step = s.radius <= tol ? Math.PI : 2 * Math.acos(1 - tol / s.radius);
33
+ const n = Math.min(MAX_STEPS, Math.max(1, Math.ceil(Math.abs(s.sweep) / step)));
34
+ for (let k = 1; k < n; k++) {
35
+ const a = s.start + (s.sweep * k) / n;
36
+ out.push({ x: s.center.x + s.radius * Math.cos(a), y: s.center.y + s.radius * Math.sin(a) });
37
+ }
38
+ }
39
+ out.push(s.to);
40
+ at = s.to;
41
+ }
42
+ return out;
43
+ }
44
+
45
+ /** This function gives the point at the parameter `t` of the cubic Bezier curve with the control points `p0`, `p1`, `p2` and `p3`. */
46
+ export function cubicAt(p0: Point, p1: Point, p2: Point, p3: Point, t: number): Point {
47
+ const u = 1 - t;
48
+ const a = u * u * u;
49
+ const b = 3 * u * u * t;
50
+ const c = 3 * u * t * t;
51
+ const d = t * t * t;
52
+ return { x: a * p0.x + b * p1.x + c * p2.x + d * p3.x, y: a * p0.y + b * p1.y + c * p2.y + d * p3.y };
53
+ }
54
+
55
+ /**
56
+ * This function writes the segments as the data of an SVG path: the commands `M`, `L`, `C` and `A`.
57
+ * Each number has at most `digits` digits after the point.
58
+ */
59
+ export function toSvgPath(segments: readonly CurveSegment[], digits = 2): string {
60
+ const n = (v: number): string => {
61
+ const s = String(Number(v.toFixed(digits)));
62
+ return s === "-0" ? "0" : s;
63
+ };
64
+ const p = (q: Point): string => `${n(q.x)} ${n(q.y)}`;
65
+ const parts: string[] = [];
66
+ for (const s of segments) {
67
+ if (s.kind === "move") parts.push(`M ${p(s.to)}`);
68
+ else if (s.kind === "line") parts.push(`L ${p(s.to)}`);
69
+ else if (s.kind === "cubic") parts.push(`C ${p(s.c1)} ${p(s.c2)} ${p(s.to)}`);
70
+ else parts.push(`A ${n(s.radius)} ${n(s.radius)} 0 ${Math.abs(s.sweep) > Math.PI ? 1 : 0} ${s.sweep > 0 ? 1 : 0} ${p(s.to)}`);
71
+ }
72
+ return parts.join(" ");
73
+ }
@@ -0,0 +1,91 @@
1
+ import { TOLERANCE } from "../bench/properties.ts";
2
+ import { inflate, segmentHitsBox, type Box, type Point } from "../geom.ts";
3
+ import { Grid } from "../grid.ts";
4
+ import type { CurveSegment } from "./model.ts";
5
+ import { sampleCurve } from "./sample.ts";
6
+
7
+ /**
8
+ * The free space of a board: the boxes that a curve must not go through.
9
+ * Make it one time for a board with `curveSpace`, and give it to each call of `curveOf`.
10
+ */
11
+ export interface CurveSpace {
12
+ /** The boxes of the board. */
13
+ readonly boxes: readonly Box[];
14
+ /** The tolerance of the samples of a curve, in units of the plane. */
15
+ readonly tolerance: number;
16
+ /**
17
+ * This method tells if the polyline `q` goes through a box that the polyline `p` does not go through.
18
+ * A polyline goes through a box when a part of it longer than `TOLERANCE` is inside the box made smaller by `TOLERANCE`.
19
+ */
20
+ addsHit(q: readonly Point[], p: readonly Point[]): boolean;
21
+ }
22
+
23
+ /**
24
+ * This function makes the free space of a board from its boxes. The boxes are the nodes, or the items of a zoom level.
25
+ * The curves sample each piece within `tolerance` of the curve for the test.
26
+ */
27
+ export function curveSpace(boxes: readonly Box[], tolerance = 0.25): CurveSpace {
28
+ const inner = boxes.map((b) => inflate(b, -TOLERANCE));
29
+ let size = 0;
30
+ for (const b of boxes) size += Math.max(b.w, b.h);
31
+ // The buckets grow with the boxes, as the buckets of `measureLinks` do.
32
+ const grid = new Grid<number>(Math.max(180, (2 * size) / Math.max(1, boxes.length)));
33
+ inner.forEach((b, i) => grid.add(i, b));
34
+ const hits = (p: readonly Point[]): Set<number> => {
35
+ const out = new Set<number>();
36
+ for (let i = 1; i < p.length; i++) {
37
+ const a = p[i - 1]!;
38
+ const b = p[i]!;
39
+ grid.querySegment(a, b, 0, (id) => {
40
+ if (!out.has(id) && segmentHitsBox(a, b, inner[id]!, TOLERANCE)) out.add(id);
41
+ });
42
+ }
43
+ return out;
44
+ };
45
+ return {
46
+ boxes,
47
+ tolerance,
48
+ addsHit(q: readonly Point[], p: readonly Point[]): boolean {
49
+ let before: Set<number> | undefined;
50
+ for (let i = 1; i < q.length; i++) {
51
+ const a = q[i - 1]!;
52
+ const b = q[i]!;
53
+ const hit = grid.someSegment(a, b, 0, (id) => {
54
+ if (!segmentHitsBox(a, b, inner[id]!, TOLERANCE)) return false;
55
+ before ??= hits(p);
56
+ return !before.has(id);
57
+ });
58
+ if (hit) return true;
59
+ }
60
+ return false;
61
+ },
62
+ };
63
+ }
64
+
65
+ /**
66
+ * This function finds the largest size from `max` down to 0 that keeps a piece of a curve clear.
67
+ * A clear piece goes through no box that its polyline avoids.
68
+ *
69
+ * The function `piece` gives the segments of the piece at a size. They start with a `move`.
70
+ * The polyline `chord` is the part of the original polyline that the piece replaces.
71
+ * The function tries `max` first, then it halves the interval `steps` times. The result is 0 when no size of the search is clear.
72
+ */
73
+ export function fitSize(
74
+ space: CurveSpace | undefined,
75
+ max: number,
76
+ chord: (size: number) => readonly Point[],
77
+ piece: (size: number) => readonly CurveSegment[],
78
+ steps = 8,
79
+ ): number {
80
+ if (space === undefined || max <= 0) return Math.max(0, max);
81
+ const clear = (size: number): boolean => !space.addsHit(sampleCurve(piece(size), space.tolerance), chord(size));
82
+ if (clear(max)) return max;
83
+ let lo = 0;
84
+ let hi = max;
85
+ for (let i = 0; i < steps; i++) {
86
+ const mid = (lo + hi) / 2;
87
+ if (clear(mid)) lo = mid;
88
+ else hi = mid;
89
+ }
90
+ return lo;
91
+ }