@mark1russell7/highways 0.3.0 → 0.4.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 +26 -2
- package/build/index.d.ts +698 -12
- package/build/index.d.ts.map +1 -1
- package/build/index.js +2353 -55
- package/build/index.js.map +1 -1
- package/package.json +3 -1
- package/src/bench/compare.ts +41 -6
- package/src/bench/screen/forms.ts +372 -0
- package/src/bench/screen/generators.ts +54 -0
- package/src/bench/screen/hash.ts +129 -0
- package/src/bench/screen/index.ts +6 -0
- package/src/bench/screen/measures.ts +114 -0
- package/src/bench/screen/screen.ts +390 -0
- package/src/bench/screen/set.ts +136 -0
- package/src/bench/suite.ts +71 -13
- package/src/highways/corridors/index.ts +305 -0
- package/src/highways/follow.ts +155 -0
- package/src/highways/index.ts +7 -0
- package/src/highways/keys/index.ts +15 -0
- package/src/highways/model.ts +111 -0
- package/src/highways/result.ts +34 -0
- package/src/highways/router.ts +40 -0
- package/src/highways/shared-ways/index.ts +15 -0
- package/src/index.ts +2 -0
- package/src/strategies/builtins.ts +82 -6
- package/src/strategies/model.ts +36 -6
- package/src/strategies/schemas.ts +13 -0
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.
|
|
12
|
+
"version": "0.4.0",
|
|
13
13
|
"type": "module",
|
|
14
14
|
"main": "./build/index.js",
|
|
15
15
|
"types": "./build/index.d.ts",
|
|
@@ -41,6 +41,8 @@
|
|
|
41
41
|
"bench:join": "node scripts/bench-join.ts",
|
|
42
42
|
"profile": "node scripts/profile.ts",
|
|
43
43
|
"levels": "node scripts/levels.ts",
|
|
44
|
+
"screen": "node scripts/screen.ts",
|
|
45
|
+
"screen:inputs": "node scripts/screen-inputs.ts",
|
|
44
46
|
"test:watch": "vitest",
|
|
45
47
|
"typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.test.json"
|
|
46
48
|
}
|
package/src/bench/compare.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import type { CurveSegment } from "../curves/model.ts";
|
|
2
|
+
import { polylineLength } from "../geom.ts";
|
|
2
3
|
import type { Graph, Result } from "../model.ts";
|
|
3
4
|
import type { Section } from "../sections.ts";
|
|
5
|
+
import { runKey, segmentsOf } from "../segments.ts";
|
|
4
6
|
import { STRATEGIES } from "../strategies/builtins.ts";
|
|
5
|
-
import { asRouter, getStrategy, listStrategies, type Registry, type Step } from "../strategies/model.ts";
|
|
7
|
+
import { asRouter, getStrategy, listStrategies, type LaneBoard, type Registry, type Step } from "../strategies/model.ts";
|
|
6
8
|
import { peakCurvature } from "./curves.ts";
|
|
9
|
+
import { measureLevelFaults, sumFaults } from "./links.ts";
|
|
7
10
|
import { paretoFront, type Objective } from "./measures/pareto.ts";
|
|
8
11
|
import { boardUnit } from "./measures/routes.ts";
|
|
9
12
|
import { changeGraph, routeStability } from "./measures/stability.ts";
|
|
@@ -20,8 +23,30 @@ function medianOf(values: readonly number[]): number {
|
|
|
20
23
|
return a === b ? a : (a + b) / 2;
|
|
21
24
|
}
|
|
22
25
|
|
|
23
|
-
/**
|
|
24
|
-
|
|
26
|
+
/**
|
|
27
|
+
* This function gives the measures of the level links of a result: the links through a box (`Z3.through`) and the length of all links (`Z3.length`).
|
|
28
|
+
* A result without a level with groups gives no measure.
|
|
29
|
+
*/
|
|
30
|
+
function levelLinkMeasures(result: Result): Record<string, number> {
|
|
31
|
+
const levels = (result.levels ?? []).filter((l) => l.items.some((i) => i.nodes.length > 1));
|
|
32
|
+
if (levels.length === 0) return {};
|
|
33
|
+
const length = levels.reduce((sum, l) => sum + l.links.reduce((s, k) => s + polylineLength(k.points), 0), 0);
|
|
34
|
+
return { "Z3.through": sumFaults(measureLevelFaults(result)).through, "Z3.length": length };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** This function gives the lanes of a result with each shared segment open. A result without shared segments gives `undefined`. */
|
|
38
|
+
function laneBoard(result: Result, lanes: { readonly width: number; readonly maxWidth?: number }): LaneBoard | undefined {
|
|
39
|
+
const graph = segmentsOf(result);
|
|
40
|
+
const open = new Set(graph.segments.filter((s) => s.count > 1).map((s) => runKey(s.edges)));
|
|
41
|
+
if (open.size === 0) return undefined;
|
|
42
|
+
return { graph, open, width: lanes.width, ...(lanes.maxWidth === undefined ? {} : { maxWidth: lanes.maxWidth }) };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The steps that `compareStep` compares. The step `room` takes a packed board, not a graph, so it is not in the list.
|
|
47
|
+
* The step `highway` gives a plan, not routes. The screen routes each plan with `follow` and compares it as a router.
|
|
48
|
+
*/
|
|
49
|
+
export type CompareStep = Exclude<Step, "room" | "highway">;
|
|
25
50
|
|
|
26
51
|
/** The steps that `compareStep` compares, in the order of the pipeline. */
|
|
27
52
|
export const COMPARE_STEPS: readonly CompareStep[] = ["group", "router", "curve"];
|
|
@@ -34,6 +59,11 @@ export interface CompareOptions {
|
|
|
34
59
|
readonly options?: Readonly<Record<string, object>>;
|
|
35
60
|
/** The router whose routes the step `curve` draws. The default is `corners`, as in `runSuite`. */
|
|
36
61
|
readonly curveRouter?: string;
|
|
62
|
+
/**
|
|
63
|
+
* With this value, the step `curve` opens each shared segment of the routes of `curveRouter`, and each curve draws the lanes of `laneRoutes`.
|
|
64
|
+
* So the curve `highway` bends the lanes of a bundle as one. Without it, each curve draws the routes. A router without shared segments gives the routes in both cases.
|
|
65
|
+
*/
|
|
66
|
+
readonly lanes?: { readonly width: number; readonly maxWidth?: number };
|
|
37
67
|
/** The seed of the small change of the graph for the stability measures (Z10 and G9). The default is 1. */
|
|
38
68
|
readonly seed?: number;
|
|
39
69
|
/** The route stability (Z10) routes a changed graph again, so the function measures it only up to this number of nodes. The default is 1000. */
|
|
@@ -50,7 +80,10 @@ export interface CompareRow {
|
|
|
50
80
|
readonly violations: number;
|
|
51
81
|
/** The time of the step, in milliseconds. For the step `router`, it is the median of two routes. */
|
|
52
82
|
readonly ms: number;
|
|
53
|
-
/**
|
|
83
|
+
/**
|
|
84
|
+
* The measures, by the keys of `runSuite`, and `ms`. The step `router` adds `ink`, and the step `curve` adds `C5.peak`.
|
|
85
|
+
* A router with zoom levels also gets `Z3.through` and `Z3.length`, the measures of its level links.
|
|
86
|
+
*/
|
|
54
87
|
readonly values: Readonly<Record<string, number>>;
|
|
55
88
|
/** The reason when the strategy did not run, for example a graph over its `maxNodes`. */
|
|
56
89
|
readonly skipped?: string;
|
|
@@ -79,7 +112,7 @@ export interface StepComparison {
|
|
|
79
112
|
|
|
80
113
|
/**
|
|
81
114
|
* This function runs each strategy of one step on one graph and measures it with the measures of `runSuite`. Then it gives the Pareto front.
|
|
82
|
-
* The step `router` routes the graph with each router. The step `curve` draws the routes of `curveRouter` with each curve
|
|
115
|
+
* The step `router` routes the graph with each router. The step `curve` draws the routes of `curveRouter` with each curve, or their lanes with the option `lanes`.
|
|
83
116
|
* The step `group` routes the graph with `sections` on the tree of each grouping rule.
|
|
84
117
|
*
|
|
85
118
|
* The same graph and the same options give the same measures. Only the times change.
|
|
@@ -98,7 +131,7 @@ export function compareStep(step: CompareStep, graph: Graph, options: CompareOpt
|
|
|
98
131
|
if (graph.nodes.length > (strategy.maxNodes ?? Infinity)) return { step, strategy: strategy.name, violations: 0, ms: 0, values: {}, skipped: `over ${strategy.maxNodes} nodes` };
|
|
99
132
|
const router = asRouter(strategy, own[strategy.name]);
|
|
100
133
|
const { row, result } = benchRun(router, name, graph, 2);
|
|
101
|
-
const values: Record<string, number> = { ...routeMeasures(graph, result, row), ink: row.quality.ink, ms: row.ms };
|
|
134
|
+
const values: Record<string, number> = { ...routeMeasures(graph, result, row), ...levelLinkMeasures(result), ink: row.quality.ink, ms: row.ms };
|
|
102
135
|
if (change) {
|
|
103
136
|
const s = routeStability(graph, result, router.route(change.graph), { seed });
|
|
104
137
|
values["Z10.mean"] = s.mean;
|
|
@@ -111,11 +144,13 @@ export function compareStep(step: CompareStep, graph: Graph, options: CompareOpt
|
|
|
111
144
|
const router = asRouter(getStrategy(registry, "router", options.curveRouter ?? "corners"));
|
|
112
145
|
const result = router.route(graph);
|
|
113
146
|
const u = boardUnit(graph);
|
|
147
|
+
const lanes = options.lanes && laneBoard(result, options.lanes);
|
|
114
148
|
const runs = curveRuns(graph, result, listStrategies(registry, "curve"), {
|
|
115
149
|
...at,
|
|
116
150
|
smooth: new Set(["rounded", "arc", "spline", "smooth", "clothoid", "highway"]),
|
|
117
151
|
normals: new Set(["spline"]),
|
|
118
152
|
curveOptions: own,
|
|
153
|
+
...(lanes ? { lanes } : {}),
|
|
119
154
|
});
|
|
120
155
|
rows = runs.map(({ row, drawn, edges }): CompareRow => {
|
|
121
156
|
const peaks = drawn.map((d) => peakCurvature(d) * u);
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import type { Edge, Graph, Node, Port, Side } from "../../model.ts";
|
|
2
|
+
import { documentHash, round3 } from "./hash.ts";
|
|
3
|
+
|
|
4
|
+
/** The side of a port in an input file. The router calls the same sides `north`, `east`, `south` and `west`. */
|
|
5
|
+
export type LayoutSide = "top" | "right" | "bottom" | "left";
|
|
6
|
+
|
|
7
|
+
/** A port of a box in an input file. */
|
|
8
|
+
export interface LayoutPort {
|
|
9
|
+
readonly id: string;
|
|
10
|
+
/** The side of the box. Without it and without `x` and `y`, the router picks the side. */
|
|
11
|
+
readonly side?: LayoutSide;
|
|
12
|
+
/** The fixed place of the port, from the top-left corner of the box. The router does not move a port with `x` and `y`. */
|
|
13
|
+
readonly x?: number;
|
|
14
|
+
readonly y?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A point of a region: `[x, y]`. The y axis points down. */
|
|
18
|
+
export type RegionPoint = readonly [number, number];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* An area of the plane. The y axis points down. A ring of a polygon closes itself: its last point joins its first point.
|
|
22
|
+
* A hole of a polygon does not touch the outer ring. The router reads only the bounding box of a region.
|
|
23
|
+
*/
|
|
24
|
+
export type Region =
|
|
25
|
+
| { readonly kind: "rect"; readonly x: number; readonly y: number; readonly w: number; readonly h: number }
|
|
26
|
+
| { readonly kind: "polygon"; readonly points: readonly RegionPoint[]; readonly holes?: readonly (readonly RegionPoint[])[] }
|
|
27
|
+
| { readonly kind: "circle"; readonly cx: number; readonly cy: number; readonly r: number }
|
|
28
|
+
| { readonly kind: "ellipse"; readonly cx: number; readonly cy: number; readonly rx: number; readonly ry: number };
|
|
29
|
+
|
|
30
|
+
/** An area that the boxes and the routes must not use. The router uses the bounding box of its region as a box without edges. */
|
|
31
|
+
export interface LayoutObstacle {
|
|
32
|
+
readonly id: string;
|
|
33
|
+
readonly region: Region;
|
|
34
|
+
/** The cost to move the obstacle. Only the packer reads it. */
|
|
35
|
+
readonly move?: { readonly cost: number };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A box of an input file. */
|
|
39
|
+
export interface LayoutBox {
|
|
40
|
+
readonly id: string;
|
|
41
|
+
readonly w: number;
|
|
42
|
+
readonly h: number;
|
|
43
|
+
/** The position of the top-left corner. The packer writes it. The router reads it. */
|
|
44
|
+
readonly x?: number;
|
|
45
|
+
readonly y?: number;
|
|
46
|
+
/** The id of the box that holds this box. */
|
|
47
|
+
readonly parent?: string;
|
|
48
|
+
/** The group of the box. */
|
|
49
|
+
readonly group?: string;
|
|
50
|
+
/** This value is `true` when the box is an obstacle: the packer keeps its `x` and `y`. The router ignores it. */
|
|
51
|
+
readonly locked?: true;
|
|
52
|
+
/** The reading order that the caller wants. The router ignores it. */
|
|
53
|
+
readonly rank?: number;
|
|
54
|
+
readonly ports?: readonly LayoutPort[];
|
|
55
|
+
/** The shape of the box, relative to the top-left corner of the box. The router ignores it and uses `w` and `h` as the bounding box. */
|
|
56
|
+
readonly shape?: Region;
|
|
57
|
+
/** The free data of the node. The grouping rule `keys` reads `data.group`. */
|
|
58
|
+
readonly data?: Readonly<Record<string, unknown>>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** An edge of an input file. `sourcePort` and `targetPort` name a port of the box at that end. */
|
|
62
|
+
export interface LayoutEdge {
|
|
63
|
+
readonly id: string;
|
|
64
|
+
readonly source: string;
|
|
65
|
+
readonly target: string;
|
|
66
|
+
readonly sourcePort?: string;
|
|
67
|
+
readonly targetPort?: string;
|
|
68
|
+
readonly data?: Readonly<Record<string, unknown>>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The change of a variant against its base input. */
|
|
72
|
+
export type LayoutChange = "add" | "remove" | "resize" | "reorder" | "packed";
|
|
73
|
+
|
|
74
|
+
/** The origin of an input: `made-up`, `published:<set>` or `known-best:<construction>`. */
|
|
75
|
+
export type LayoutSource = "made-up" | `published:${string}` | `known-best:${string}`;
|
|
76
|
+
|
|
77
|
+
/** The generator of an input. The same name, version, parameters and seed give the same file, byte for byte. */
|
|
78
|
+
export interface LayoutGenerator {
|
|
79
|
+
readonly name: string;
|
|
80
|
+
readonly version: number;
|
|
81
|
+
readonly params: Readonly<Record<string, unknown>>;
|
|
82
|
+
readonly seed: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The cell of an input in the taxonomy of the corpus, with one value for each dimension.
|
|
87
|
+
* The packer reads the first six dimensions. The router reads the last four.
|
|
88
|
+
*/
|
|
89
|
+
export interface LayoutCell {
|
|
90
|
+
readonly containerSize?: "fixed" | "freeWidth" | "freeAspect";
|
|
91
|
+
readonly containerShape?: "rect" | "convex" | "polygon" | "circle" | "ellipse" | "holes";
|
|
92
|
+
readonly obstacles?: "none" | "center" | "scattered" | "wall" | "corridors" | "touching" | "edge";
|
|
93
|
+
readonly sizes?: "identical" | "uniform" | "heavyTail" | "bimodal" | "extremeAspect" | "nearIdentical";
|
|
94
|
+
readonly count?: "1" | "10" | "100" | "1k" | "10k";
|
|
95
|
+
readonly mode?: "offline" | "online" | "incremental";
|
|
96
|
+
readonly edgeDensity?: "sparse" | "medium" | "dense";
|
|
97
|
+
readonly edgeShape?: "random" | "clustered" | "hubChain" | "longRange" | "tree" | "bundled";
|
|
98
|
+
readonly groups?: "none" | "flat" | "nested";
|
|
99
|
+
readonly ports?: "free" | "sides" | "fixed";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The outer rectangle of a packing. The router ignores it. */
|
|
103
|
+
export interface LayoutContainer {
|
|
104
|
+
readonly w?: number;
|
|
105
|
+
readonly h?: number;
|
|
106
|
+
/** `none`: the packer picks the size. `width`: the width stays. `height`: the height stays. `size`: the width and the height stay. */
|
|
107
|
+
readonly fixed: "none" | "width" | "height" | "size";
|
|
108
|
+
/** The ratio `w / h` that the packer aims for. */
|
|
109
|
+
readonly aspect?: number;
|
|
110
|
+
/** The shape of the container. */
|
|
111
|
+
readonly shape?: Region;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The identity of an input in a set: the set, the family, the seed, and the base of a variant. */
|
|
115
|
+
export interface LayoutInputHead {
|
|
116
|
+
/** The id: `<set>/<family>/s<seed>`. */
|
|
117
|
+
readonly id: string;
|
|
118
|
+
readonly set: string;
|
|
119
|
+
readonly family: string;
|
|
120
|
+
readonly seed: number;
|
|
121
|
+
/** The id of the base input of a variant. The stability measures compare the run on the variant with the run on its base. */
|
|
122
|
+
readonly base?: string;
|
|
123
|
+
readonly change?: LayoutChange;
|
|
124
|
+
/** The place of the input in a change sequence. */
|
|
125
|
+
readonly step?: number;
|
|
126
|
+
/** The generator of the input. The default is `null`: a hand-made input. */
|
|
127
|
+
readonly generator?: LayoutGenerator | null;
|
|
128
|
+
/** The origin of the input. The default is `made-up`. */
|
|
129
|
+
readonly source?: LayoutSource;
|
|
130
|
+
readonly cell?: LayoutCell;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* An input file of the form `layout-input/1`. The packer and the router read the same files.
|
|
135
|
+
* The file `docs/LAYOUT-RECORDS.md` gives the form. The repository `packing` keeps a twin of that document.
|
|
136
|
+
*/
|
|
137
|
+
export interface LayoutInput extends LayoutInputHead {
|
|
138
|
+
readonly schema: "layout-input/1";
|
|
139
|
+
/** The origin of the input. The data of this repository is made-up, because the repository is public. */
|
|
140
|
+
readonly source: LayoutSource;
|
|
141
|
+
/** The generator of the input, or `null` for a hand-made input. */
|
|
142
|
+
readonly generator: LayoutGenerator | null;
|
|
143
|
+
/** The first 16 hex characters of the SHA-256 of the canonical file without this field. `documentHash` calculates it. */
|
|
144
|
+
readonly hash: string;
|
|
145
|
+
readonly container?: LayoutContainer;
|
|
146
|
+
/** The space between two boxes. The router ignores it. */
|
|
147
|
+
readonly gap?: number;
|
|
148
|
+
/** The areas that the boxes and the routes must not use. */
|
|
149
|
+
readonly obstacles?: readonly LayoutObstacle[];
|
|
150
|
+
readonly boxes: readonly LayoutBox[];
|
|
151
|
+
readonly edges: readonly LayoutEdge[];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** One run of one strategy on one input and one seed: a line of the form `layout-record/1`. */
|
|
155
|
+
export interface LayoutRecord {
|
|
156
|
+
readonly schema: "layout-record/1";
|
|
157
|
+
/** `pack`, `route` or `joint`. The screen of this package writes `route`. */
|
|
158
|
+
readonly stage: "pack" | "route" | "joint";
|
|
159
|
+
readonly step: string;
|
|
160
|
+
readonly strategy: string;
|
|
161
|
+
/** The options of the run: the defaults of the strategy with the options of the caller. A function is the text `function:<name>`. */
|
|
162
|
+
readonly options: Readonly<Record<string, unknown>>;
|
|
163
|
+
/** The corpus version, for example `screen-v1`. */
|
|
164
|
+
readonly corpus: string;
|
|
165
|
+
/** The id of the input. */
|
|
166
|
+
readonly input: string;
|
|
167
|
+
/** The `hash` of the input file. */
|
|
168
|
+
readonly inputHash: string;
|
|
169
|
+
readonly set: string;
|
|
170
|
+
readonly seed: number;
|
|
171
|
+
readonly nodes: number;
|
|
172
|
+
readonly edges: number;
|
|
173
|
+
/** The measures, by the names of `MEASURES`. A measure without a finite value is not in the record. */
|
|
174
|
+
readonly measures: Readonly<Record<string, number>>;
|
|
175
|
+
/** The count of the breaks of each hard rule. A good run has 0 for each rule. */
|
|
176
|
+
readonly hard: Readonly<Record<string, number>>;
|
|
177
|
+
/** The tolerance of each hard rule of the run, for example `{ through: 0.5 }`. */
|
|
178
|
+
readonly tolerance?: Readonly<Record<string, number>>;
|
|
179
|
+
/** This value is `true` when two runs on the same input give the same output. */
|
|
180
|
+
readonly deterministic: boolean;
|
|
181
|
+
/** The id of the prediction of this run, from the predictions file. */
|
|
182
|
+
readonly prediction?: string;
|
|
183
|
+
/** The time of the run, in milliseconds. Only the times of CI compare. */
|
|
184
|
+
readonly ms: number;
|
|
185
|
+
/** The commit of the code that made the record. */
|
|
186
|
+
readonly commit?: string;
|
|
187
|
+
/** The reason when the strategy did not run, for example an input over its `maxNodes`. Then the record has no measures. */
|
|
188
|
+
readonly skipped?: string;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The comparison of a prediction. Without it, `<=` applies to a measure where lower is better, and `>=` to the other measures. */
|
|
192
|
+
export type PredictionOp = "<" | "<=" | "=" | ">=" | ">";
|
|
193
|
+
|
|
194
|
+
/** A prediction about a run: one line of a predictions file. A commit adds the file before the run starts. */
|
|
195
|
+
export interface LayoutPrediction {
|
|
196
|
+
readonly schema?: "layout-prediction/1";
|
|
197
|
+
readonly id: string;
|
|
198
|
+
readonly strategy: string;
|
|
199
|
+
readonly set: string;
|
|
200
|
+
/** The id of one input. Without it, the prediction is about the median of the measure over the set. */
|
|
201
|
+
readonly input?: string;
|
|
202
|
+
/** The name of the measure, from `MEASURES`. */
|
|
203
|
+
readonly measure: string;
|
|
204
|
+
readonly expected: number;
|
|
205
|
+
readonly op?: PredictionOp;
|
|
206
|
+
readonly reason: string;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const TO_FILE: Readonly<Record<Side, LayoutSide>> = { north: "top", east: "right", south: "bottom", west: "left" };
|
|
210
|
+
const TO_MODEL: Readonly<Record<LayoutSide, Side>> = { top: "north", right: "east", bottom: "south", left: "west" };
|
|
211
|
+
|
|
212
|
+
/** This function gives the side of a box that holds a point, relative to the top-left corner of the box. */
|
|
213
|
+
function sideAt(p: { x: number; y: number }, w: number, h: number): LayoutSide {
|
|
214
|
+
const d: [LayoutSide, number][] = [
|
|
215
|
+
["top", Math.abs(p.y)],
|
|
216
|
+
["right", Math.abs(w - p.x)],
|
|
217
|
+
["bottom", Math.abs(h - p.y)],
|
|
218
|
+
["left", Math.abs(p.x)],
|
|
219
|
+
];
|
|
220
|
+
return d.reduce((a, b) => (b[1] < a[1] ? b : a))[0];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** This function gives 0 for -0, because JSON writes -0 as 0. Then a file and the graph that it comes from hold the same numbers. */
|
|
224
|
+
const num = round3;
|
|
225
|
+
|
|
226
|
+
function portToFile(p: Port, n: Node): LayoutPort {
|
|
227
|
+
const side = p.side ? TO_FILE[p.side] : p.at ? sideAt(p.at, n.box.w, n.box.h) : undefined;
|
|
228
|
+
return { id: p.id, ...(side ? { side } : {}), ...(p.at ? { x: num(p.at.x), y: num(p.at.y) } : {}) };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function portToModel(p: LayoutPort): Port {
|
|
232
|
+
if (p.x !== undefined && p.y !== undefined) return { id: p.id, at: { x: p.x, y: p.y } };
|
|
233
|
+
return { id: p.id, ...(p.side ? { side: TO_MODEL[p.side] } : {}) };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* This function writes a graph as an input file of the form `layout-input/1`, with its hash.
|
|
238
|
+
* A fixed plug gets `x`, `y` and the side that holds it. `inputGraph` reads the file back.
|
|
239
|
+
*/
|
|
240
|
+
export function graphInput(graph: Graph, head: LayoutInputHead): LayoutInput {
|
|
241
|
+
const boxes = graph.nodes.map(
|
|
242
|
+
(n): LayoutBox => ({
|
|
243
|
+
id: n.id,
|
|
244
|
+
w: num(n.box.w),
|
|
245
|
+
h: num(n.box.h),
|
|
246
|
+
x: num(n.box.x),
|
|
247
|
+
y: num(n.box.y),
|
|
248
|
+
...(n.ports ? { ports: n.ports.map((p) => portToFile(p, n)) } : {}),
|
|
249
|
+
...(n.data ? { data: n.data } : {}),
|
|
250
|
+
}),
|
|
251
|
+
);
|
|
252
|
+
const edges = graph.edges.map(
|
|
253
|
+
(e): LayoutEdge => ({
|
|
254
|
+
id: e.id,
|
|
255
|
+
source: e.source.node,
|
|
256
|
+
target: e.target.node,
|
|
257
|
+
...(e.source.port !== undefined ? { sourcePort: e.source.port } : {}),
|
|
258
|
+
...(e.target.port !== undefined ? { targetPort: e.target.port } : {}),
|
|
259
|
+
...(e.data ? { data: e.data } : {}),
|
|
260
|
+
}),
|
|
261
|
+
);
|
|
262
|
+
const body = {
|
|
263
|
+
schema: "layout-input/1" as const,
|
|
264
|
+
id: head.id,
|
|
265
|
+
set: head.set,
|
|
266
|
+
family: head.family,
|
|
267
|
+
seed: head.seed,
|
|
268
|
+
...(head.base !== undefined ? { base: head.base } : {}),
|
|
269
|
+
...(head.change !== undefined ? { change: head.change } : {}),
|
|
270
|
+
...(head.step !== undefined ? { step: head.step } : {}),
|
|
271
|
+
source: head.source ?? "made-up",
|
|
272
|
+
generator: head.generator ?? null,
|
|
273
|
+
...(head.cell !== undefined ? { cell: head.cell } : {}),
|
|
274
|
+
boxes,
|
|
275
|
+
edges,
|
|
276
|
+
};
|
|
277
|
+
return { ...body, hash: documentHash(body) };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* This function gives the bounding box of a region: the smallest box that holds it.
|
|
282
|
+
*/
|
|
283
|
+
export function regionBounds(region: Region): { x: number; y: number; w: number; h: number } {
|
|
284
|
+
if (region.kind === "rect") return { x: region.x, y: region.y, w: region.w, h: region.h };
|
|
285
|
+
if (region.kind === "circle") return { x: region.cx - region.r, y: region.cy - region.r, w: 2 * region.r, h: 2 * region.r };
|
|
286
|
+
if (region.kind === "ellipse") return { x: region.cx - region.rx, y: region.cy - region.ry, w: 2 * region.rx, h: 2 * region.ry };
|
|
287
|
+
const xs = region.points.map((p) => p[0]);
|
|
288
|
+
const ys = region.points.map((p) => p[1]);
|
|
289
|
+
const x = Math.min(...xs);
|
|
290
|
+
const y = Math.min(...ys);
|
|
291
|
+
return { x, y, w: Math.max(...xs) - x, h: Math.max(...ys) - y };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* This function reads the graph of the router from an input file. The router reads the positions, the ports, the edges and the data.
|
|
296
|
+
* Each obstacle becomes a node without edges, with the bounding box of its region, after the boxes.
|
|
297
|
+
* The router ignores the other fields of the packer: `container`, `gap`, `locked`, `rank`, the `shape` of a box and the `move` of an obstacle.
|
|
298
|
+
* A box without `x` and `y` is an error.
|
|
299
|
+
*/
|
|
300
|
+
export function inputGraph(input: LayoutInput): Graph {
|
|
301
|
+
const nodes = input.boxes.map((b): Node => {
|
|
302
|
+
if (b.x === undefined || b.y === undefined) throw new Error(`The box "${b.id}" of the input "${input.id}" has no position. The router needs a placed input.`);
|
|
303
|
+
return { id: b.id, box: { x: b.x, y: b.y, w: b.w, h: b.h }, ...(b.ports ? { ports: b.ports.map(portToModel) } : {}), ...(b.data ? { data: b.data } : {}) };
|
|
304
|
+
});
|
|
305
|
+
for (const o of input.obstacles ?? []) nodes.push({ id: o.id, box: regionBounds(o.region) });
|
|
306
|
+
const edges = input.edges.map(
|
|
307
|
+
(e): Edge => ({
|
|
308
|
+
id: e.id,
|
|
309
|
+
source: { node: e.source, ...(e.sourcePort !== undefined ? { port: e.sourcePort } : {}) },
|
|
310
|
+
target: { node: e.target, ...(e.targetPort !== undefined ? { port: e.targetPort } : {}) },
|
|
311
|
+
...(e.data ? { data: e.data } : {}),
|
|
312
|
+
}),
|
|
313
|
+
);
|
|
314
|
+
return { nodes, edges };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* This function writes an input file as text: the keys in sorted order, and one box or one edge on each line.
|
|
319
|
+
* `JSON.parse` of the text gives the input again.
|
|
320
|
+
*/
|
|
321
|
+
export function formatInput(input: LayoutInput): string {
|
|
322
|
+
const o = input as unknown as Record<string, unknown>;
|
|
323
|
+
const keys = Object.keys(o)
|
|
324
|
+
.filter((k) => o[k] !== undefined)
|
|
325
|
+
.sort();
|
|
326
|
+
const lines = keys.map((k) => {
|
|
327
|
+
const v = o[k];
|
|
328
|
+
if (Array.isArray(v)) return ` ${JSON.stringify(k)}: [${v.length === 0 ? "]" : `\n${v.map((x) => ` ${canonical(x)}`).join(",\n")}\n ]`}`;
|
|
329
|
+
return ` ${JSON.stringify(k)}: ${canonical(v)}`;
|
|
330
|
+
});
|
|
331
|
+
return `{\n${lines.join(",\n")}\n}\n`;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** This function writes a value as canonical JSON with a space after each colon and comma, for a line of a file. */
|
|
335
|
+
function canonical(value: unknown): string {
|
|
336
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
|
|
337
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(", ")}]`;
|
|
338
|
+
const o = value as Record<string, unknown>;
|
|
339
|
+
const keys = Object.keys(o)
|
|
340
|
+
.filter((k) => o[k] !== undefined)
|
|
341
|
+
.sort();
|
|
342
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}: ${canonical(o[k])}`).join(", ")}}`;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** This function writes records as JSON Lines: one record on each line. */
|
|
346
|
+
export function recordLines(records: readonly LayoutRecord[]): string {
|
|
347
|
+
return records.map((r) => JSON.stringify(r) + "\n").join("");
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** This function reads JSON Lines. It ignores an empty line. */
|
|
351
|
+
export function parseLines<T>(text: string): T[] {
|
|
352
|
+
return text
|
|
353
|
+
.split(/\r?\n/)
|
|
354
|
+
.filter((l) => l.trim().length > 0)
|
|
355
|
+
.map((l) => JSON.parse(l) as T);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* This function gives a copy of a value that JSON keeps.
|
|
360
|
+
* A function becomes the text `function:<name>`, and a number that is not finite becomes its text.
|
|
361
|
+
*/
|
|
362
|
+
export function jsonSafe(value: unknown): unknown {
|
|
363
|
+
if (typeof value === "function") return `function:${(value as { name?: string }).name || "anonymous"}`;
|
|
364
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : String(value);
|
|
365
|
+
if (value === null || typeof value !== "object") return value;
|
|
366
|
+
if (Array.isArray(value)) return value.map(jsonSafe);
|
|
367
|
+
if (value instanceof Set) return [...value].map(jsonSafe);
|
|
368
|
+
if (value instanceof Map) return Object.fromEntries([...value].map(([k, v]) => [String(k), jsonSafe(v)]));
|
|
369
|
+
const out: Record<string, unknown> = {};
|
|
370
|
+
for (const [k, v] of Object.entries(value)) if (v !== undefined) out[k] = jsonSafe(v);
|
|
371
|
+
return out;
|
|
372
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { LayoutInput, LayoutObstacle } from "./forms.ts";
|
|
2
|
+
import { documentHash, mulberry32, stepSeed } from "./hash.ts";
|
|
3
|
+
|
|
4
|
+
/** This function gives a whole number from `lo` to `hi`, both included, from a generator of random numbers. */
|
|
5
|
+
export function randomInt(r: () => number, lo: number, hi: number): number {
|
|
6
|
+
return lo + Math.floor(r() * (hi - lo + 1));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** The parameters of the generator `rect-scattered`. */
|
|
10
|
+
export interface RectScatteredParams {
|
|
11
|
+
/** The number of boxes. */
|
|
12
|
+
readonly count: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* This function makes the input of the shared generator `rect-scattered`, version 1: a fixed rectangle container, scattered obstacles and boxes without positions.
|
|
17
|
+
* The repository `packing` makes the same file, byte for byte. A test in each repository pins the hashes of the seeds 1, 2 and 3.
|
|
18
|
+
* The steps are `container`, `obstacles` and `boxes`. Each step has its own generator from `stepSeed`.
|
|
19
|
+
*/
|
|
20
|
+
export function rectScattered(seed: number, params: RectScatteredParams, set = "xcheck-v1"): LayoutInput {
|
|
21
|
+
let r = mulberry32(stepSeed(seed, "container"));
|
|
22
|
+
const w = randomInt(r, 600, 1000);
|
|
23
|
+
const h = randomInt(r, 400, 800);
|
|
24
|
+
r = mulberry32(stepSeed(seed, "obstacles"));
|
|
25
|
+
const n = randomInt(r, 3, 8);
|
|
26
|
+
const obstacles: LayoutObstacle[] = [];
|
|
27
|
+
for (let i = 0; i < n; i++) {
|
|
28
|
+
const ow = randomInt(r, 40, 160);
|
|
29
|
+
const oh = randomInt(r, 40, 160);
|
|
30
|
+
const x = randomInt(r, 0, w - ow);
|
|
31
|
+
const y = randomInt(r, 0, h - oh);
|
|
32
|
+
obstacles.push({ id: `o${i}`, region: { kind: "rect", x, y, w: ow, h: oh } });
|
|
33
|
+
}
|
|
34
|
+
r = mulberry32(stepSeed(seed, "boxes"));
|
|
35
|
+
const boxes = Array.from({ length: params.count }, (_, i) => {
|
|
36
|
+
const bw = randomInt(r, 20, 120);
|
|
37
|
+
const bh = randomInt(r, 20, 80);
|
|
38
|
+
return { id: `b${i}`, w: bw, h: bh };
|
|
39
|
+
});
|
|
40
|
+
const body = {
|
|
41
|
+
schema: "layout-input/1" as const,
|
|
42
|
+
id: `${set}/rect-scattered/s${seed}`,
|
|
43
|
+
set,
|
|
44
|
+
family: "rect-scattered",
|
|
45
|
+
seed,
|
|
46
|
+
source: "made-up" as const,
|
|
47
|
+
generator: { name: "rect-scattered", version: 1, params: { count: params.count }, seed },
|
|
48
|
+
container: { w, h, fixed: "size" as const },
|
|
49
|
+
obstacles,
|
|
50
|
+
boxes,
|
|
51
|
+
edges: [],
|
|
52
|
+
};
|
|
53
|
+
return { ...body, hash: documentHash(body) };
|
|
54
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// The core runs in any host, so this file has its own SHA-256. It needs no API of Node or of the browser.
|
|
2
|
+
|
|
3
|
+
const K: readonly number[] = [
|
|
4
|
+
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
|
|
5
|
+
0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
|
|
6
|
+
0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
|
|
7
|
+
0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
|
8
|
+
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
/** This function gives the bytes of a string in UTF-8. */
|
|
12
|
+
export function utf8(text: string): Uint8Array {
|
|
13
|
+
const out: number[] = [];
|
|
14
|
+
for (const ch of text) {
|
|
15
|
+
const c = ch.codePointAt(0)!;
|
|
16
|
+
if (c < 0x80) out.push(c);
|
|
17
|
+
else if (c < 0x800) out.push(0xc0 | (c >> 6), 0x80 | (c & 63));
|
|
18
|
+
else if (c < 0x10000) out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63));
|
|
19
|
+
else out.push(0xf0 | (c >> 18), 0x80 | ((c >> 12) & 63), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63));
|
|
20
|
+
}
|
|
21
|
+
return Uint8Array.from(out);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** This function gives the SHA-256 digest of a string, as 64 lowercase hex characters. It encodes the string in UTF-8. */
|
|
25
|
+
export function sha256Hex(text: string): string {
|
|
26
|
+
const bytes = utf8(text);
|
|
27
|
+
const bits = bytes.length * 8;
|
|
28
|
+
const total = Math.ceil((bytes.length + 9) / 64) * 64;
|
|
29
|
+
const data = new Uint8Array(total);
|
|
30
|
+
data.set(bytes);
|
|
31
|
+
data[bytes.length] = 0x80;
|
|
32
|
+
const view = new DataView(data.buffer);
|
|
33
|
+
view.setUint32(total - 8, Math.floor(bits / 0x100000000));
|
|
34
|
+
view.setUint32(total - 4, bits >>> 0);
|
|
35
|
+
const h = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19];
|
|
36
|
+
const w = new Uint32Array(64);
|
|
37
|
+
const rotr = (x: number, n: number): number => (x >>> n) | (x << (32 - n));
|
|
38
|
+
for (let off = 0; off < total; off += 64) {
|
|
39
|
+
for (let i = 0; i < 16; i++) w[i] = view.getUint32(off + 4 * i);
|
|
40
|
+
for (let i = 16; i < 64; i++) {
|
|
41
|
+
const a = w[i - 15]!;
|
|
42
|
+
const b = w[i - 2]!;
|
|
43
|
+
const s0 = rotr(a, 7) ^ rotr(a, 18) ^ (a >>> 3);
|
|
44
|
+
const s1 = rotr(b, 17) ^ rotr(b, 19) ^ (b >>> 10);
|
|
45
|
+
w[i] = (w[i - 16]! + s0 + w[i - 7]! + s1) >>> 0;
|
|
46
|
+
}
|
|
47
|
+
let [a, b, c, d, e, f, g, k] = h as [number, number, number, number, number, number, number, number];
|
|
48
|
+
for (let i = 0; i < 64; i++) {
|
|
49
|
+
const s1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
|
|
50
|
+
const ch = (e & f) ^ (~e & g);
|
|
51
|
+
const t1 = (k + s1 + ch + K[i]! + w[i]!) >>> 0;
|
|
52
|
+
const s0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
|
|
53
|
+
const maj = (a & b) ^ (a & c) ^ (b & c);
|
|
54
|
+
const t2 = (s0 + maj) >>> 0;
|
|
55
|
+
k = g;
|
|
56
|
+
g = f;
|
|
57
|
+
f = e;
|
|
58
|
+
e = (d + t1) >>> 0;
|
|
59
|
+
d = c;
|
|
60
|
+
c = b;
|
|
61
|
+
b = a;
|
|
62
|
+
a = (t1 + t2) >>> 0;
|
|
63
|
+
}
|
|
64
|
+
h[0] = (h[0]! + a) >>> 0;
|
|
65
|
+
h[1] = (h[1]! + b) >>> 0;
|
|
66
|
+
h[2] = (h[2]! + c) >>> 0;
|
|
67
|
+
h[3] = (h[3]! + d) >>> 0;
|
|
68
|
+
h[4] = (h[4]! + e) >>> 0;
|
|
69
|
+
h[5] = (h[5]! + f) >>> 0;
|
|
70
|
+
h[6] = (h[6]! + g) >>> 0;
|
|
71
|
+
h[7] = (h[7]! + k) >>> 0;
|
|
72
|
+
}
|
|
73
|
+
return h.map((x) => x.toString(16).padStart(8, "0")).join("");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* This function writes a value as canonical JSON: the keys of each object in sorted order, and no whitespace.
|
|
78
|
+
* It drops a key whose value is `undefined`, as `JSON.stringify` does.
|
|
79
|
+
*/
|
|
80
|
+
export function canonicalJson(value: unknown): string {
|
|
81
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
|
|
82
|
+
if (Array.isArray(value)) return `[${value.map((v) => (v === undefined ? "null" : canonicalJson(v))).join(",")}]`;
|
|
83
|
+
const o = value as Record<string, unknown>;
|
|
84
|
+
const keys = Object.keys(o)
|
|
85
|
+
.filter((k) => o[k] !== undefined)
|
|
86
|
+
.sort();
|
|
87
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(o[k])}`).join(",")}}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* This function gives the hash of a JSON document: the first 16 hex characters of the SHA-256 of its canonical JSON.
|
|
92
|
+
* The document does not include its own field `hash`. The function removes that field before it calculates the hash.
|
|
93
|
+
*/
|
|
94
|
+
export function documentHash(value: object): string {
|
|
95
|
+
const { hash: _hash, ...rest } = value as { hash?: unknown };
|
|
96
|
+
return sha256Hex(canonicalJson(rest)).slice(0, 16);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** This function gives the 32-bit FNV-1a hash of the UTF-8 bytes of a text, as an unsigned integer. */
|
|
100
|
+
export function fnv1a32(text: string): number {
|
|
101
|
+
let h = 0x811c9dc5;
|
|
102
|
+
for (const byte of utf8(text)) {
|
|
103
|
+
h ^= byte;
|
|
104
|
+
h = Math.imul(h, 0x01000193);
|
|
105
|
+
}
|
|
106
|
+
return h >>> 0;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** This function gives the seed of one step of a generator: `fnv1a32("<seed>:<step>")`. */
|
|
110
|
+
export function stepSeed(seed: number, step: string): number {
|
|
111
|
+
return fnv1a32(`${seed}:${step}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** This function gives the generator `mulberry32` for a seed. Each call gives the next number from 0 to below 1. */
|
|
115
|
+
export function mulberry32(seed: number): () => number {
|
|
116
|
+
let a = seed >>> 0;
|
|
117
|
+
return (): number => {
|
|
118
|
+
a = (a + 0x6d2b79f5) >>> 0;
|
|
119
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
120
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
121
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** This function rounds a number to 3 decimals, as the files of a corpus keep it. The value `-0` becomes `0`. */
|
|
126
|
+
export function round3(value: number): number {
|
|
127
|
+
const r = Math.round(value * 1000) / 1000;
|
|
128
|
+
return r === 0 ? 0 : r;
|
|
129
|
+
}
|