@knowvah/dot-engine 2.0.2 → 2.2.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.
Files changed (39) hide show
  1. package/dist/async/render-async.d.ts.map +1 -1
  2. package/dist/common/arith.d.ts +6 -0
  3. package/dist/common/arith.d.ts.map +1 -1
  4. package/dist/common/libm-hypot.d.ts +3 -0
  5. package/dist/common/libm-hypot.d.ts.map +1 -0
  6. package/dist/common/poly-sizing.d.ts.map +1 -1
  7. package/dist/common/textmeasure-factory.d.ts +2 -1
  8. package/dist/common/textmeasure-factory.d.ts.map +1 -1
  9. package/dist/common/textmeasure.d.ts +1 -1
  10. package/dist/common/textmeasure.d.ts.map +1 -1
  11. package/dist/index.js +97 -59
  12. package/dist/index.js.map +4 -4
  13. package/dist/layout/dot/splines-label.d.ts.map +1 -1
  14. package/dist/layout/fdp/derive.d.ts.map +1 -1
  15. package/dist/layout/fdp/fdp-model.d.ts +4 -4
  16. package/dist/layout/fdp/normalize.d.ts.map +1 -1
  17. package/dist/layout/fdp/tlayout-init.d.ts.map +1 -1
  18. package/dist/layout/fdp/tlayout-parms.d.ts +4 -1
  19. package/dist/layout/fdp/tlayout-parms.d.ts.map +1 -1
  20. package/dist/layout/fdp/tlayout.d.ts +3 -4
  21. package/dist/layout/fdp/tlayout.d.ts.map +1 -1
  22. package/dist/layout/fdp/xlayout.d.ts.map +1 -1
  23. package/dist/render.js +95 -57
  24. package/dist/render.js.map +4 -4
  25. package/package.json +1 -1
  26. package/src/async/render-async.ts +6 -3
  27. package/src/common/arith.ts +9 -0
  28. package/src/common/libm-hypot.ts +40 -0
  29. package/src/common/poly-sizing.ts +12 -11
  30. package/src/common/textmeasure-factory.ts +20 -1
  31. package/src/common/textmeasure.ts +1 -1
  32. package/src/layout/dot/splines-label.ts +2 -1
  33. package/src/layout/fdp/derive.ts +5 -2
  34. package/src/layout/fdp/fdp-model.ts +4 -4
  35. package/src/layout/fdp/normalize.ts +2 -1
  36. package/src/layout/fdp/tlayout-init.ts +8 -6
  37. package/src/layout/fdp/tlayout-parms.ts +5 -2
  38. package/src/layout/fdp/tlayout.ts +18 -17
  39. package/src/layout/fdp/xlayout.ts +26 -22
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@knowvah/dot-engine",
3
- "version": "2.0.2",
3
+ "version": "2.2.0",
4
4
  "description": "Faithful TypeScript port of Graphviz (dot, neato, fdp, sfdp, circo, twopi, osage, patchwork) — no C: no native binary, no WASM. Oracle-verified against the C implementation, runs in the browser.",
5
5
  "keywords": [
6
6
  "graphviz",
@@ -63,11 +63,14 @@ export interface AsyncSvgResult {
63
63
  fontIssues: FontIssue[];
64
64
  }
65
65
 
66
- /** `document.fonts` when a document exposes one (absent in Node and Workers). */
66
+ /**
67
+ * `document.fonts` in a page, else `self.fonts` (WorkerGlobalScope.fonts) in a
68
+ * Worker; undefined in Node. @see plans/async-api/DESIGN.md D5
69
+ */
67
70
  function defaultFontSet(): FontSetLike | undefined {
68
71
  const doc: unknown = Reflect.get(globalThis, 'document');
69
- if (typeof doc !== 'object' || doc === null) return undefined;
70
- const fonts: unknown = Reflect.get(doc, 'fonts');
72
+ const owner: unknown = typeof doc === 'object' && doc !== null ? doc : globalThis;
73
+ const fonts: unknown = Reflect.get(owner as object, 'fonts');
71
74
  if (typeof fonts !== 'object' || fonts === null) return undefined;
72
75
  return fonts as FontSetLike; // structural: FontFaceSet.load
73
76
  }
@@ -37,3 +37,12 @@
37
37
  export function cround(v: number): number {
38
38
  return v >= 0 ? Math.floor(v + 0.5) : Math.ceil(v - 0.5);
39
39
  }
40
+
41
+ /**
42
+ * Degrees to radians in C's operation order, `(deg)/180.0 * M_PI`.
43
+ * `deg * Math.PI / 180` rounds differently for some angles (e.g. 33°).
44
+ * @see lib/common/arith.h:49 (RADIANS)
45
+ */
46
+ export function RADIANS(deg: number): number {
47
+ return (deg / 180.0) * Math.PI;
48
+ }
@@ -0,0 +1,40 @@
1
+ // SPDX-License-Identifier: EPL-2.0
2
+
3
+ /**
4
+ * `hypot(a, b)` as the native build host's libm computes it, bit for bit.
5
+ *
6
+ * Graphviz calls libm `hypot`; V8's `Math.hypot` and `sqrt(a*a + b*b)` each
7
+ * round differently in the last bit for some inputs, and fdp's force loop
8
+ * amplifies one-ulp differences into visibly different layouts. The reference
9
+ * build's libm (macOS arm64 libSystem) matches this model exactly on 400k
10
+ * sampled inputs, including subnormal, near-overflow, zero, inf and NaN
11
+ * operands: order the magnitudes, rescale by 2^∓600 outside [2^-500, 2^500],
12
+ * then `sqrt(fma(small, small, big*big))`. It is within one ulp of the
13
+ * correctly rounded result (glibc's hypot can differ in that last bit).
14
+ *
15
+ * @see docs/known-divergences.md (host libm)
16
+ */
17
+
18
+ import { fma } from './fma.js';
19
+
20
+ /** Rescale thresholds and factor of the reference libm. */
21
+ const BIG = 2 ** 500;
22
+ const SMALL = 2 ** -500;
23
+ const SCALE_UP = 2 ** 600;
24
+ const SCALE_DOWN = 2 ** -600;
25
+
26
+ /** libm `hypot(a, b)` of the native build host. */
27
+ export function libmHypot(a: number, b: number): number {
28
+ if (a === Infinity || a === -Infinity || b === Infinity || b === -Infinity) return Infinity;
29
+ let x = Math.abs(a);
30
+ let y = Math.abs(b);
31
+ if (x < y) [x, y] = [y, x];
32
+ if (x > BIG) return core(x * SCALE_DOWN, y * SCALE_DOWN) * SCALE_UP;
33
+ if (x < SMALL) return core(x * SCALE_UP, y * SCALE_UP) * SCALE_DOWN;
34
+ return core(x, y);
35
+ }
36
+
37
+ /** sqrt(y·y + x·x) with the y·y product fused (x ≥ y ≥ 0). */
38
+ function core(x: number, y: number): number {
39
+ return Math.sqrt(fma(y, y, x * x));
40
+ }
@@ -22,6 +22,8 @@ import type { Point } from '../model/geom.js';
22
22
  import { STAR, CYLINDER } from './shapeData.js';
23
23
  import { starVertices } from './poly-vertices.js';
24
24
  import { fma } from './fma.js';
25
+ import { RADIANS } from './arith.js';
26
+ import { libmHypot } from './libm-hypot.js';
25
27
 
26
28
  /** Whitespace in points around labels / between peripheries. @see lib/common/const.h:GAP */
27
29
  export const GAP = 4;
@@ -276,16 +278,15 @@ export interface PolyGeom {
276
278
 
277
279
  /** Distort, skew, orient, and scale one unit vertex. @see shapes.c:poly_init */
278
280
  function transformUnitVertex(R: Point, g: PolyGeom, c: { skewdist: number; gdistortion: number; gskew: number }, bb: Point): Point {
279
- const D = { x: R.x * (c.skewdist + R.y * c.gdistortion) + R.y * c.gskew, y: R.y };
280
- const alpha = (g.orientation * Math.PI) / 180 + Math.atan2(D.y, D.x);
281
- // C uses libm hypot(D.x, D.y). V8's Math.hypot is a scaled algorithm that
282
- // returns a result 1 ULP off from libm hypot for in-range polygon vertices
283
- // (e.g. hypot(0.35355…, 0.35355…): libm = 0.5 exactly, Math.hypot = 0.5+1ULP),
284
- // whereas the naive sqrt(x²+y²) reproduces libm's value bit-for-bit here.
285
- // That ULP inflated ND_ht by ~7e-15, and pack.c genPoly's GRID(=ceil) rounded
286
- // an exactly-on-boundary component height up a cell, reordering polyomino
287
- // packing and swapping two circo/osage components. @see shapes.c:poly_init
288
- const r = Math.sqrt(D.x * D.x + D.y * D.y);
281
+ // clang -ffp-contract=on fuses both multiply-adds of this one statement
282
+ // (the first product of the outer sum, as elsewhere in the port).
283
+ const D = { x: fma(R.x, fma(R.y, c.gdistortion, c.skewdist), R.y * c.gskew), y: R.y };
284
+ const alpha = RADIANS(g.orientation) + Math.atan2(D.y, D.x);
285
+ // C uses libm hypot(D.x, D.y); libmHypot reproduces the host libm bit for bit.
286
+ // Neither V8's Math.hypot (hypot(0.35355339059327379, same) = 0.5+1ULP, which once
287
+ // inflated ND_ht by ~7e-15 and swapped two packed circo/osage components) nor
288
+ // sqrt(x²+y²) (off on e.g. the orientation=20 box) does. @see shapes.c:poly_init
289
+ const r = libmHypot(D.x, D.y);
289
290
  return { x: r * Math.cos(alpha) * bb.x, y: r * Math.sin(alpha) * bb.y };
290
291
  }
291
292
 
@@ -302,7 +303,7 @@ export function polygonVertices(
302
303
  const sectorangle = (2 * Math.PI) / g.sides;
303
304
  const sidelength = Math.sin(sectorangle / 2);
304
305
  const c = {
305
- skewdist: Math.hypot(Math.abs(g.distortion) + Math.abs(g.skew), 1),
306
+ skewdist: libmHypot(Math.abs(g.distortion) + Math.abs(g.skew), 1),
306
307
  gdistortion: (g.distortion * SQRT2) / Math.cos(sectorangle / 2),
307
308
  gskew: g.skew / 2,
308
309
  };
@@ -6,7 +6,8 @@
6
6
  * 1. explicit override via setTextMeasurer (tests, host-faithful Node opt-in)
7
7
  * 2. GV_TEXT_MEASURER=estimate|lut — test/CI hook to force a specific measurer
8
8
  * 3. browser (document present) — CanvasTextMeasurer over the page canvas
9
- * (host-faithful: the same font the browser renders the SVG with)
9
+ * (host-faithful: the same font the browser renders the SVG with);
10
+ * Worker (no document) — the same over an OffscreenCanvas
10
11
  * 4. Node default — EstimateTextMeasurer (deterministic; matches
11
12
  * graphviz's own headless estimate_textspan_size)
12
13
  *
@@ -77,6 +78,21 @@ function adviseHostFaithful(): void {
77
78
  );
78
79
  }
79
80
 
81
+ /**
82
+ * A CanvasTextMeasurer over an OffscreenCanvas 2d context, for Workers (no
83
+ * `document`); undefined where OffscreenCanvas is missing or unusable (Node).
84
+ * @see plans/async-api/DESIGN.md D5
85
+ */
86
+ function offscreenMeasurer(): CanvasTextMeasurer | undefined {
87
+ if (typeof OffscreenCanvas === 'undefined') return undefined;
88
+ try {
89
+ const ctx2d = new OffscreenCanvas(0, 0).getContext('2d');
90
+ return ctx2d ? new CanvasTextMeasurer(ctx2d) : undefined;
91
+ } catch {
92
+ return undefined; // OffscreenCanvas present but no 2d context
93
+ }
94
+ }
95
+
80
96
  /** Resolve the text measurer for this render (see resolution order above). */
81
97
  export function createMeasurer(): TextMeasurer {
82
98
  if (override) return override;
@@ -88,6 +104,9 @@ export function createMeasurer(): TextMeasurer {
88
104
  const ctx2d = document.createElement('canvas').getContext('2d');
89
105
  if (ctx2d) return new CanvasTextMeasurer(ctx2d);
90
106
  } catch { /* canvas unavailable */ }
107
+ } else {
108
+ const offscreen = offscreenMeasurer(); // Worker: no document, same fonts API
109
+ if (offscreen) return offscreen;
91
110
  }
92
111
  adviseHostFaithful(); // Node, no host canvas → built-in deterministic metrics
93
112
  // Node default = EstimateTextMeasurer: the deterministic reference that matches
@@ -285,7 +285,7 @@ export class CanvasTextMeasurer implements TextMeasurer {
285
285
  */
286
286
  private readonly fonts = new Map<string, string>();
287
287
 
288
- constructor(private readonly ctx: CanvasRenderingContext2D) {}
288
+ constructor(private readonly ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D) {}
289
289
 
290
290
  measure(
291
291
  text: string,
@@ -16,6 +16,7 @@ import { VIRTUAL } from './fastgr.js';
16
16
  import { IGNORED } from './rank.js';
17
17
  import { lateDouble } from '../../common/nodeinit.js';
18
18
  import { nodesInSeq } from './decomp.js';
19
+ import { RADIANS } from '../../common/arith.js';
19
20
 
20
21
  // ---------------------------------------------------------------------------
21
22
  // TextLabel interface (minimal — full type deferred to Batch 5b)
@@ -286,7 +287,7 @@ function writePortlabelPos(
286
287
  distanceStr: string,
287
288
  ): void {
288
289
  const angleDeg = lateDouble(angleStr, PORT_LABEL_ANGLE, -180.0);
289
- const angle = Math.atan2(pf.y - pe.y, pf.x - pe.x) + (Math.PI / 180) * angleDeg;
290
+ const angle = Math.atan2(pf.y - pe.y, pf.x - pe.x) + RADIANS(angleDeg);
290
291
  const distMult = lateDouble(distanceStr || undefined, 1.0, 0.0);
291
292
  const dist = PORT_LABEL_DISTANCE * distMult;
292
293
  l.pos.x = pe.x + dist * Math.cos(angle);
@@ -21,6 +21,7 @@ import { Node } from '../../model/node.js';
21
21
  import { Edge } from '../../model/edge.js';
22
22
  import type { Box } from '../../model/geom.js';
23
23
  import { doGraphLabel } from '../dot/graph-label.js';
24
+ import { nodesInSeq } from '../dot/decomp.js';
24
25
  import { layoutMeasurer } from '../../common/nodeinit.js';
25
26
  import { inputscaleDivisor } from '../../common/utils-inputscale.js';
26
27
  import {
@@ -145,7 +146,9 @@ function deriveClusterNodes(g: Graph, dg: Graph, infop: LayoutInfo): void {
145
146
  * @see layout.c:deriveGraph (remaining-nodes loop)
146
147
  */
147
148
  function deriveRealNodes(g: Graph, dg: Graph): boolean {
148
- for (const n of g.nodes.values()) {
149
+ // agfstnode/agnxtnode: a cluster subgraph iterates in node sequence order,
150
+ // not statement order; it fixes derived-node creation (and so id) order.
151
+ for (const n of nodesInSeq(g)) {
149
152
  if (getDnode(n) !== null) continue;
150
153
  const par = getParent(n);
151
154
  if (par !== null && par !== gdata(g).parent) {
@@ -195,7 +198,7 @@ function deriveEdge(dg: Graph, byPair: Map<string, Edge>, tl: Node, hd: Node): E
195
198
 
196
199
  /** Derived edges. @see layout.c:deriveGraph (edge loop) */
197
200
  function deriveEdges(g: Graph, dg: Graph, byPair: Map<string, Edge>): void {
198
- for (const n of g.nodes.values()) {
201
+ for (const n of nodesInSeq(g)) { // agfstnode order, as in deriveRealNodes
199
202
  const tl = getDnode(n)!;
200
203
  for (const e of n.outEdges(g)) {
201
204
  const hd = getDnode(e.head)!;
@@ -4,11 +4,11 @@
4
4
  * fdp engine data model — gdata, dndata, bport_t, xparams, and the
5
5
  * accessor macros of the C private interface.
6
6
  *
7
- * Spec read at the 15.0.0 tag (post-tag fdpgen commits add an Mlimit
8
- * branch and float reorderings the golden refs do not have).
7
+ * Matches Graphviz after 15.0.0 (fdpParms.Mlimit lives in
8
+ * tlayout-parms.ts).
9
9
  *
10
- * @see lib/fdpgen/fdp.h (15.0.0)
11
- * @see lib/fdpgen/xlayout.h (15.0.0)
10
+ * @see lib/fdpgen/fdp.h
11
+ * @see lib/fdpgen/xlayout.h
12
12
  */
13
13
 
14
14
  import type { Graph } from '../../model/graph.js';
@@ -14,6 +14,7 @@
14
14
  import type { Graph } from '../../model/graph.js';
15
15
  import type { Node } from '../../model/node.js';
16
16
  import { aggetGraph } from './fdp-model.js';
17
+ import { RADIANS } from '../../common/arith.js';
17
18
 
18
19
  /** mapbool for the "normalize" attr. @see lib/common/utils.c:mapbool */
19
20
  function mapbool(s: string): boolean {
@@ -34,7 +35,7 @@ function angleSet(g: Graph): number | null {
34
35
  if (a === undefined || a === '') return null;
35
36
  const ang = parseAngle(a);
36
37
  if (ang === null) return null;
37
- return (wrapDegrees(ang) / 180.0) * Math.PI; // RADIANS(a) = a/180.0*M_PI
38
+ return RADIANS(wrapDegrees(ang));
38
39
  }
39
40
 
40
41
  /** strtod (leading whitespace, sign) with the mapbool fallback of angleSet. */
@@ -18,6 +18,7 @@ import type { Node } from '../../model/node.js';
18
18
  import type { Edge } from '../../model/edge.js';
19
19
  import type { Point } from '../../model/geom.js';
20
20
  import { srand48, drand48 } from '../../common/random.js';
21
+ import { fma } from '../../common/fma.js';
21
22
  import {
22
23
  type Bport,
23
24
  dndata,
@@ -53,8 +54,9 @@ function placeByNeighbors(g: Graph, np: Node, ctr: Point): void {
53
54
  np.info.pos![0] = p.x;
54
55
  np.info.pos![1] = p.y;
55
56
  } else if (cnt === 1) {
56
- np.info.pos![0] = 0.98 * p.x + 0.1 * ctr.x;
57
- np.info.pos![1] = 0.9 * p.y + 0.1 * ctr.y;
57
+ // contracted (-ffp-contract=on): fmadd of the first product
58
+ np.info.pos![0] = fma(0.98, p.x, 0.1 * ctr.x);
59
+ np.info.pos![1] = fma(0.9, p.y, 0.1 * ctr.y);
58
60
  } else {
59
61
  const angle = Math.PI * 2.0 * drand48();
60
62
  const radius = 0.9 * drand48();
@@ -76,8 +78,8 @@ function averagePlacedNeighbors(g: Graph, np: Node, p: Point): number {
76
78
  const op = ep.head === np ? ep.tail : ep.head;
77
79
  if (!hasPos(op)) continue;
78
80
  if (cnt) {
79
- p.x = (p.x * cnt + op.info.pos![0]!) / (cnt + 1);
80
- p.y = (p.y * cnt + op.info.pos![1]!) / (cnt + 1);
81
+ p.x = fma(p.x, cnt, op.info.pos![0]!) / (cnt + 1); // contracted
82
+ p.y = fma(p.y, cnt, op.info.pos![1]!) / (cnt + 1);
81
83
  } else {
82
84
  p.x = op.info.pos![0]!;
83
85
  p.y = op.info.pos![1]!;
@@ -162,8 +164,8 @@ function sizeBoundary(width: number, height: number): void {
162
164
  function placeWithPorts(g: Graph, pp: Bport[], ctr: Point): void {
163
165
  for (const port of pp) { /* position ports on ellipse */
164
166
  const np = port.n;
165
- np.info.pos![0] = parms.Wd * Math.cos(port.alpha) + ctr.x;
166
- np.info.pos![1] = parms.Ht * Math.sin(port.alpha) + ctr.y;
167
+ np.info.pos![0] = fma(parms.Wd, Math.cos(port.alpha), ctr.x); // contracted
168
+ np.info.pos![1] = fma(parms.Ht, Math.sin(port.alpha), ctr.y);
167
169
  dndata(np).pinned = P_SET;
168
170
  }
169
171
  for (const np of g.nodes.values()) {
@@ -9,7 +9,7 @@
9
9
  *
10
10
  * @see lib/fdpgen/tlayout.c (15.0.0) — parms_t, fdp_initParams,
11
11
  * init_params, cool, reset_params
12
- * @see lib/common/globals.c (15.0.0) — fdpParms defaults
12
+ * @see lib/common/globals.c — fdpParms defaults
13
13
  */
14
14
 
15
15
  import type { Graph } from '../../model/graph.js';
@@ -41,7 +41,7 @@ export const INIT_RANDOM = 2;
41
41
  const DFLT_SMODE = INIT_RANDOM;
42
42
 
43
43
  // ---------------------------------------------------------------------------
44
- // fdp_parms defaults — @see lib/common/globals.c (15.0.0)
44
+ // fdp_parms defaults — @see lib/common/globals.c
45
45
  // ---------------------------------------------------------------------------
46
46
 
47
47
  /**
@@ -61,6 +61,9 @@ export const fdpParms = {
61
61
  Tfact: 1.0,
62
62
  K: -1.0,
63
63
  T0: -1.0,
64
+ /** Distance beyond which repulsion is 0; HUGE_VAL unless `dot -Lm` (no library
65
+ * equivalent). @see lib/fdpgen/fdp.h:fdpParms_s.Mlimit */
66
+ Mlimit: Infinity,
64
67
  };
65
68
 
66
69
  // ---------------------------------------------------------------------------
@@ -4,9 +4,8 @@
4
4
  * fdp initial layout — grid-accelerated Fruchterman–Reingold on point
5
5
  * nodes with boundary ports.
6
6
  *
7
- * Spec read at the 15.0.0 tag: the post-tag tree reorders doRep floats
8
- * (hypot vs sqrt(x²·y²)) and adds an Mlimit cutoff; the golden refs are
9
- * 15.0.0 output, so neither is ported.
7
+ * Repulsion follows Graphviz after 15.0.0: doRep takes the hypot distance
8
+ * (the host libm's, see libm-hypot.ts) and honours the Mlimit cutoff.
10
9
  *
11
10
  * All math is double precision (unlike neato's float32 stress kernel).
12
11
  * Force-accumulation order is load-bearing: node iteration is the
@@ -16,13 +15,14 @@
16
15
  * Parameter state lives in tlayout-parms.ts; initial placement in
17
16
  * tlayout-init.ts.
18
17
  *
19
- * @see lib/fdpgen/tlayout.c (15.0.0)
18
+ * @see lib/fdpgen/tlayout.c
20
19
  */
21
20
 
22
21
  import type { Graph } from '../../model/graph.js';
23
22
  import type { Node } from '../../model/node.js';
24
23
  import type { Edge } from '../../model/edge.js';
25
24
  import { fma, fms } from '../../common/fma.js';
25
+ import { libmHypot } from '../../common/libm-hypot.js';
26
26
  import { Grid, type Cell } from './grid.js';
27
27
  import {
28
28
  type Bport,
@@ -33,7 +33,7 @@ import {
33
33
  gdata,
34
34
  P_FIX,
35
35
  } from './fdp-model.js';
36
- import { parms, cool, initParams, resetParams } from './tlayout-parms.js';
36
+ import { parms, cool, initParams, resetParams, fdpParms } from './tlayout-parms.js';
37
37
  import { initPositions } from './tlayout-init.js';
38
38
  import { crand } from '../../common/crand.js';
39
39
 
@@ -66,25 +66,26 @@ export function coincidentDelta(): { xdelta: number; ydelta: number } {
66
66
  }
67
67
 
68
68
  /**
69
- * Repulsive force = K²/d (useNew) or K²/d².
69
+ * Repulsive force = K²/d² (useNew) or K²/d, zero beyond Mlimit.
70
70
  * @see lib/fdpgen/tlayout.c:doRep
71
71
  */
72
72
  function doRep(
73
- p: Node, q: Node, xdelta: number, ydelta: number, dist2: number,
73
+ p: Node, q: Node, xdelta: number, ydelta: number, dist: number,
74
74
  ): void {
75
75
  let force: number;
76
76
 
77
- if (dist2 === 0.0) {
78
- const d = coincidentDelta();
77
+ if (!(dist > 0)) {
78
+ const d = coincidentDelta(); // C re-rolls rand() until hypot > 0
79
79
  xdelta = d.xdelta;
80
80
  ydelta = d.ydelta;
81
- dist2 = fma(xdelta, xdelta, ydelta * ydelta);
81
+ dist = libmHypot(xdelta, ydelta);
82
82
  }
83
- if (parms.useNew) {
84
- const dist = Math.sqrt(dist2);
85
- force = parms.K * parms.K / (dist * dist2);
83
+ if (dist > fdpParms.Mlimit) {
84
+ force = 0;
85
+ } else if (parms.useNew) {
86
+ force = parms.K * parms.K / (dist * dist * dist);
86
87
  } else {
87
- force = parms.K * parms.K / dist2;
88
+ force = parms.K * parms.K / (dist * dist);
88
89
  }
89
90
  if (isPort(p) && isPort(q)) force *= 10.0;
90
91
  const dq = disp(q);
@@ -100,7 +101,7 @@ function doRep(
100
101
  function applyRep(p: Node, q: Node): void {
101
102
  const xdelta = q.info.pos![0]! - p.info.pos![0]!;
102
103
  const ydelta = q.info.pos![1]! - p.info.pos![1]!;
103
- doRep(p, q, xdelta, ydelta, fma(xdelta, xdelta, ydelta * ydelta));
104
+ doRep(p, q, xdelta, ydelta, libmHypot(xdelta, ydelta));
104
105
  }
105
106
 
106
107
  /** @see lib/fdpgen/tlayout.c:doNeighbor */
@@ -111,8 +112,8 @@ function doNeighbor(grid: Grid, i: number, j: number, nodes: Node[]): void {
111
112
  for (const q of cellp.nodes) {
112
113
  const xdelta = q.info.pos![0]! - p.info.pos![0]!;
113
114
  const ydelta = q.info.pos![1]! - p.info.pos![1]!;
114
- const dist2 = fma(xdelta, xdelta, ydelta * ydelta);
115
- if (dist2 < parms.Cell * parms.Cell) doRep(p, q, xdelta, ydelta, dist2);
115
+ const dist = libmHypot(xdelta, ydelta);
116
+ if (dist < parms.Cell) doRep(p, q, xdelta, ydelta, dist);
116
117
  }
117
118
  }
118
119
  }
@@ -4,8 +4,8 @@
4
4
  * fdp expansion layout: grow the point-node layout so sized nodes do
5
5
  * not overlap, by force iteration with overlap-aware repulsion.
6
6
  *
7
- * Spec read at the 15.0.0 tag (the post-tag Mlimit force cutoff is
8
- * deliberately NOT ported — the golden refs predate it).
7
+ * Follows Graphviz after 15.0.0: repulsion uses the hypot distance (the
8
+ * host libm's, see libm-hypot.ts) and the Mlimit cutoff.
9
9
  *
10
10
  * The default overlap attribute is "9:prism": up to 9 x_layout tries,
11
11
  * then removeOverlapAs with the remaining mode. x_layout does NOT always
@@ -14,7 +14,7 @@
14
14
  * fdpAdjust, the scale family via scAdjust. On the GTS reference build
15
15
  * overlap=false resolves to AM_PRISM (value 1000), not a no-op.
16
16
  *
17
- * @see lib/fdpgen/xlayout.c (15.0.0)
17
+ * @see lib/fdpgen/xlayout.c
18
18
  */
19
19
 
20
20
  import { RenderError } from '../../errors.js';
@@ -31,6 +31,9 @@ import {
31
31
  P_PIN,
32
32
  } from './fdp-model.js';
33
33
  import { coincidentDelta } from './tlayout.js';
34
+ import { fdpParms } from './tlayout-parms.js';
35
+ import { libmHypot } from '../../common/libm-hypot.js';
36
+ import { fma, fms } from '../../common/fma.js';
34
37
  import { normalizeG } from './normalize.js';
35
38
  import { simpleScale } from '../neato/simple-scale.js';
36
39
 
@@ -76,7 +79,7 @@ function ht2(n: Node): number {
76
79
 
77
80
  /** Expanded radius of n. @see lib/fdpgen/xlayout.c:RAD */
78
81
  function rad(n: Node): number {
79
- return Math.hypot(wd2(n), ht2(n));
82
+ return libmHypot(wd2(n), ht2(n));
80
83
  }
81
84
 
82
85
  /**
@@ -129,31 +132,32 @@ function cntOverlaps(g: Graph): number {
129
132
  // ---------------------------------------------------------------------------
130
133
 
131
134
  /**
132
- * Overlap-aware repulsion; returns 1 if the pair overlaps.
133
- * The delta/dist2 computation of C's applyRep is inlined here; the
134
- * C doRep/applyRep split exists for the rand() re-roll loop on
135
- * coincident nodes (coincidentDelta), shared with tlayout.
135
+ * Overlap-aware repulsion; returns 1 if the pair overlaps. Zero beyond
136
+ * Mlimit. C's applyRep (delta + hypot) is inlined; its doRep re-rolls rand()
137
+ * deltas while the distance is not positive (coincidentDelta).
136
138
  * @see lib/fdpgen/xlayout.c:doRep
137
139
  * @see lib/fdpgen/xlayout.c:applyRep
138
140
  */
139
141
  function applyRep(p: Node, q: Node, rs: RepStrength): number {
140
142
  let xdelta = q.info.pos![0]! - p.info.pos![0]!;
141
143
  let ydelta = q.info.pos![1]! - p.info.pos![1]!;
142
- let dist2 = xdelta * xdelta + ydelta * ydelta;
143
- if (dist2 === 0.0) {
144
+ let dist = libmHypot(xdelta, ydelta);
145
+ if (!(dist > 0)) {
144
146
  const d = coincidentDelta();
145
147
  xdelta = d.xdelta;
146
148
  ydelta = d.ydelta;
147
- dist2 = xdelta * xdelta + ydelta * ydelta;
149
+ dist = libmHypot(xdelta, ydelta);
148
150
  }
149
151
  const ov = overlap(p, q) ? 1 : 0;
150
- const force = ov ? rs.ov / dist2 : rs.nonov / dist2;
152
+ let force = ov ? rs.ov / (dist * dist) : rs.nonov / (dist * dist);
153
+ if (dist > fdpParms.Mlimit) force = 0;
151
154
  const dq = disp(q);
152
155
  const dp = disp(p);
153
- dq[0] += xdelta * force;
154
- dq[1] += ydelta * force;
155
- dp[0] -= xdelta * force;
156
- dp[1] -= ydelta * force;
156
+ // arm64 contraction (-ffp-contract=on): DISP ± delta·force are fmadd/fmsub
157
+ dq[0] = fma(xdelta, force, dq[0]!);
158
+ dq[1] = fma(ydelta, force, dq[1]!);
159
+ dp[0] = fms(xdelta, force, dp[0]!);
160
+ dp[1] = fms(ydelta, force, dp[1]!);
157
161
  return ov;
158
162
  }
159
163
 
@@ -165,16 +169,16 @@ function applyAttr(p: Node, q: Node): void {
165
169
  if (overlap(p, q)) return;
166
170
  const xdelta = q.info.pos![0]! - p.info.pos![0]!;
167
171
  const ydelta = q.info.pos![1]! - p.info.pos![1]!;
168
- const dist = Math.hypot(xdelta, ydelta);
172
+ const dist = libmHypot(xdelta, ydelta);
169
173
  const din = rad(p) + rad(q);
170
174
  const dout = dist - din;
171
175
  const force = dout * dout / ((xParams.K + din) * dist);
172
176
  const dq = disp(q);
173
177
  const dp = disp(p);
174
- dq[0] -= xdelta * force;
175
- dq[1] -= ydelta * force;
176
- dp[0] += xdelta * force;
177
- dp[1] += ydelta * force;
178
+ dq[0] = fms(xdelta, force, dq[0]!);
179
+ dq[1] = fms(ydelta, force, dq[1]!);
180
+ dp[0] = fma(xdelta, force, dp[0]!);
181
+ dp[1] = fma(ydelta, force, dp[1]!);
178
182
  }
179
183
 
180
184
  // ---------------------------------------------------------------------------
@@ -204,7 +208,7 @@ function moveNodes(g: Graph, temp: number): void {
204
208
  if (dndata(n).pinned === P_PIN) continue;
205
209
  const dx = disp(n)[0];
206
210
  const dy = disp(n)[1];
207
- const len2 = dx * dx + dy * dy;
211
+ const len2 = fma(dx, dx, dy * dy); // contracted, as in tlayout
208
212
  if (len2 < temp2) {
209
213
  n.info.pos![0]! += dx;
210
214
  n.info.pos![1]! += dy;