@knowvah/dot-engine 2.3.0 → 2.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.
Files changed (118) hide show
  1. package/dist/common/arith.d.ts +12 -0
  2. package/dist/common/arith.d.ts.map +1 -1
  3. package/dist/common/edge-in-box.d.ts +31 -0
  4. package/dist/common/edge-in-box.d.ts.map +1 -0
  5. package/dist/common/htmltable-emit.d.ts +1 -1
  6. package/dist/common/htmltable-emit.d.ts.map +1 -1
  7. package/dist/common/make-label.d.ts +3 -1
  8. package/dist/common/make-label.d.ts.map +1 -1
  9. package/dist/common/poly-gencode.d.ts +12 -1
  10. package/dist/common/poly-gencode.d.ts.map +1 -1
  11. package/dist/gvc/device.d.ts.map +1 -1
  12. package/dist/gvc/emit-gates.d.ts +35 -0
  13. package/dist/gvc/emit-gates.d.ts.map +1 -0
  14. package/dist/gvc/job.d.ts +4 -0
  15. package/dist/gvc/job.d.ts.map +1 -1
  16. package/dist/gvc/viewport.d.ts +13 -0
  17. package/dist/gvc/viewport.d.ts.map +1 -1
  18. package/dist/index.js +1030 -742
  19. package/dist/index.js.map +4 -4
  20. package/dist/layout/circo/blockpath.d.ts +1 -38
  21. package/dist/layout/circo/blockpath.d.ts.map +1 -1
  22. package/dist/layout/circo/crossings.d.ts +59 -0
  23. package/dist/layout/circo/crossings.d.ts.map +1 -0
  24. package/dist/layout/dot/edge-route-chain.d.ts +5 -3
  25. package/dist/layout/dot/edge-route-chain.d.ts.map +1 -1
  26. package/dist/layout/dot/edge-route.d.ts.map +1 -1
  27. package/dist/layout/dot/index.d.ts +10 -2
  28. package/dist/layout/dot/index.d.ts.map +1 -1
  29. package/dist/layout/dot/ortho-adapter.d.ts +7 -3
  30. package/dist/layout/dot/ortho-adapter.d.ts.map +1 -1
  31. package/dist/layout/dot/pack-components.d.ts +4 -1
  32. package/dist/layout/dot/pack-components.d.ts.map +1 -1
  33. package/dist/layout/dot/splines-flat-multi.d.ts +7 -5
  34. package/dist/layout/dot/splines-flat-multi.d.ts.map +1 -1
  35. package/dist/layout/dot/splines-flat.d.ts +2 -13
  36. package/dist/layout/dot/splines-flat.d.ts.map +1 -1
  37. package/dist/layout/dot/splines-route.d.ts.map +1 -1
  38. package/dist/layout/neato/constraint-adjust.d.ts +32 -0
  39. package/dist/layout/neato/constraint-adjust.d.ts.map +1 -1
  40. package/dist/layout/neato/multispline.d.ts.map +1 -1
  41. package/dist/layout/neato/splines.d.ts.map +1 -1
  42. package/dist/layout/pack/poly-place.d.ts.map +1 -1
  43. package/dist/layout/sfdp/__fixtures__/pcp-rotate-native.d.ts +23 -0
  44. package/dist/layout/sfdp/__fixtures__/pcp-rotate-native.d.ts.map +1 -0
  45. package/dist/layout/sfdp/spring-driver.d.ts +6 -0
  46. package/dist/layout/sfdp/spring-driver.d.ts.map +1 -1
  47. package/dist/ortho/index.d.ts +4 -1
  48. package/dist/ortho/index.d.ts.map +1 -1
  49. package/dist/ortho/maze.d.ts +5 -2
  50. package/dist/ortho/maze.d.ts.map +1 -1
  51. package/dist/ortho/partition.d.ts +8 -1
  52. package/dist/ortho/partition.d.ts.map +1 -1
  53. package/dist/ortho/trap-segment.d.ts +2 -1
  54. package/dist/ortho/trap-segment.d.ts.map +1 -1
  55. package/dist/ortho/trapezoid.d.ts.map +1 -1
  56. package/dist/parser/index.d.ts +0 -7
  57. package/dist/parser/index.d.ts.map +1 -1
  58. package/dist/parser/number-scan.d.ts +31 -0
  59. package/dist/parser/number-scan.d.ts.map +1 -0
  60. package/dist/pathplan/route.d.ts.map +1 -1
  61. package/dist/pathplan/solvers.d.ts +15 -3
  62. package/dist/pathplan/solvers.d.ts.map +1 -1
  63. package/dist/render/dot/edge-draw.d.ts +12 -0
  64. package/dist/render/dot/edge-draw.d.ts.map +1 -1
  65. package/dist/render/svg-graph.d.ts +6 -5
  66. package/dist/render/svg-graph.d.ts.map +1 -1
  67. package/dist/render/svg.d.ts +2 -21
  68. package/dist/render/svg.d.ts.map +1 -1
  69. package/dist/render.js +1030 -742
  70. package/dist/render.js.map +4 -4
  71. package/dist/xdot/parse.d.ts.map +1 -1
  72. package/package.json +1 -1
  73. package/src/common/arith.ts +25 -0
  74. package/src/common/edge-in-box.ts +54 -0
  75. package/src/common/graph-init.ts +3 -3
  76. package/src/common/htmltable-emit.ts +4 -2
  77. package/src/common/make-label.ts +3 -1
  78. package/src/common/poly-gencode.ts +18 -4
  79. package/src/gvc/device.ts +8 -41
  80. package/src/gvc/edge-labels.ts +30 -13
  81. package/src/gvc/emit-gates.ts +89 -0
  82. package/src/gvc/job.ts +4 -0
  83. package/src/gvc/viewport.ts +29 -0
  84. package/src/layout/circo/blockpath.ts +6 -125
  85. package/src/layout/circo/crossings.ts +171 -0
  86. package/src/layout/dot/__fixtures__/2784.dot +1 -0
  87. package/src/layout/dot/__fixtures__/2784.native-16.1.svg +48 -0
  88. package/src/layout/dot/edge-route-chain.ts +11 -5
  89. package/src/layout/dot/edge-route.ts +19 -8
  90. package/src/layout/dot/index.ts +14 -4
  91. package/src/layout/dot/ortho-adapter.ts +11 -6
  92. package/src/layout/dot/pack-components.ts +11 -4
  93. package/src/layout/dot/splines-flat-multi.ts +21 -8
  94. package/src/layout/dot/splines-flat.ts +8 -35
  95. package/src/layout/dot/splines-route.ts +10 -2
  96. package/src/layout/dot/splines.ts +6 -6
  97. package/src/layout/neato/constraint-adjust.ts +10 -4
  98. package/src/layout/neato/multispline.ts +26 -9
  99. package/src/layout/neato/splines.ts +7 -7
  100. package/src/layout/pack/poly-place.ts +9 -2
  101. package/src/layout/sfdp/__fixtures__/pcp-rotate-native.ts +94 -0
  102. package/src/layout/sfdp/spring-driver.ts +45 -13
  103. package/src/ortho/index.ts +22 -13
  104. package/src/ortho/maze.ts +8 -2
  105. package/src/ortho/partition.ts +17 -2
  106. package/src/ortho/trap-segment.ts +47 -24
  107. package/src/ortho/trapezoid.ts +24 -15
  108. package/src/parser/dot.js +345 -380
  109. package/src/parser/dot.pegjs +25 -10
  110. package/src/parser/index.ts +15 -0
  111. package/src/parser/number-scan.ts +118 -0
  112. package/src/pathplan/route.ts +79 -34
  113. package/src/pathplan/solvers.ts +78 -50
  114. package/src/render/dot/edge-draw.ts +25 -0
  115. package/src/render/svg-graph.ts +77 -47
  116. package/src/render/svg-helpers.ts +2 -2
  117. package/src/render/svg.ts +4 -41
  118. package/src/xdot/parse.ts +19 -5
@@ -57,24 +57,36 @@ Stmt
57
57
  // grammar.y: attrtype : T_graph | T_node | T_edge
58
58
  // grammar.y: graphattrdefs : attrassignment (bare key=value at graph level)
59
59
  AttrStmt
60
- = t:AttrType _ attrs:AttrList
61
- { return { type: "attr", target: t, attrs: attrs }; }
60
+ = t:AttrType _ m:OptMacroName attrs:AttrListReq
61
+ {
62
+ // grammar.y:attrstmt calls nomacros() when a macro name is present;
63
+ // the attributes are still applied unchanged.
64
+ if (m) console.error("Warning: attribute macros not implemented");
65
+ return { type: "attr", target: t, attrs: attrs };
66
+ }
62
67
  / a:AttrAssignment
63
68
  { return { type: "assign", key: a.key, value: a.value }; }
64
69
 
70
+ // grammar.y: optmacroname : atom '=' | /* empty */
71
+ // @see grammar.y:nomacros -- agwarningf("attribute macros not implemented")
72
+ OptMacroName
73
+ = Atom _ "=" _ { return true; }
74
+ / "" { return false; }
75
+
65
76
  // grammar.y: attrtype : T_graph | T_node | T_edge
66
77
  AttrType
67
78
  = GraphKw { return "graph"; }
68
79
  / NodeKw { return "node"; }
69
80
  / EdgeKw { return "edge"; }
70
81
 
71
- // grammar.y: optattr : attrlist | /* empty */
72
- // grammar.y: attrlist : optattr '[' optattrdefs ']'
73
- AttrList
82
+ // grammar.y: attrlist : optattr '[' optattrdefs ']' (one or more bracket groups)
83
+ AttrListReq
74
84
  = _ "[" _ defs:AttrDefs _ "]" rest:AttrList
75
85
  { return defs.concat(rest); }
76
- / _ "[" _ defs:AttrDefs _ "]"
77
- { return defs; }
86
+
87
+ // grammar.y: optattr : attrlist | /* empty */
88
+ AttrList
89
+ = AttrListReq
78
90
  / "" { return []; }
79
91
 
80
92
  // grammar.y: optattrdefs : optattrdefs attrdefs | /* empty */
@@ -215,10 +227,13 @@ Name
215
227
  = first:[A-Za-z_\x80-￿] rest:[A-Za-z_0-9\x80-￿]*
216
228
  { return first + rest.join(""); }
217
229
 
218
- // scan.l: NUMBER = [-]?(({DIGIT}+(\.{DIGIT}*)?)|(\.{DIGIT}+))
219
- // Returned as string per spec requirement.
230
+ // scan.l: NUMBER = [-]?(({DIGIT}+(\.{DIGIT}*)?)|(\.{DIGIT}+))(\.|{LETTER})?
231
+ // There is no exponent: `1e5` lexes as NUMBER `1e`, which chkNum splits into
232
+ // `1` and the NAME `e5`. The optional trailing '.'/LETTER is always split off
233
+ // by chkNum (yyless), so it is never part of the atom; its warning is emitted
234
+ // by number-scan.ts. Returned as string per spec requirement.
220
235
  NumericLiteral
221
- = s:( "-"? ( [0-9]+ ( "." [0-9]* )? / "." [0-9]+ ) ( [eE] [+-]? [0-9]+ )? )
236
+ = s:( "-"? ( [0-9]+ ( "." [0-9]* )? / "." [0-9]+ ) )
222
237
  { return Array.isArray(s) ? s.flat(Infinity).filter(x => x !== null && x !== undefined).join("") : String(s); }
223
238
 
224
239
  // ── Case-insensitive keywords ─────────────────────────────────────────────────
@@ -13,6 +13,7 @@ import type { GraphKind } from '../model/graph.js';
13
13
  import type { Edge } from '../model/edge.js';
14
14
  import { isHtmlValue, htmlValueContent } from '../common/html-string.js';
15
15
  import { buildFromAst } from './builder.js';
16
+ import { scanNumberAmbiguities, numberAmbiguityMessage } from './number-scan.js';
16
17
  import type { ParsedGraph } from './ast.js';
17
18
  import type { GvErrorCode, GvExpectation } from '../errors.js';
18
19
  import { DotEngineError, friendlyMessageFor, invalidArgType } from '../errors.js';
@@ -223,6 +224,18 @@ export function isPeggyError(err: unknown): err is {
223
224
  * is too deep
224
225
  * @throws TypeError `ERR_INVALID_ARG_TYPE` if `src` is not a string
225
226
  */
227
+ /**
228
+ * Emit scan.l's chkNum warnings for every badly delimited number the lexer
229
+ * reaches: all of them on success, those at or before a syntax error otherwise
230
+ * (C's lexer stops there). @see lib/cgraph/scan.l:chkNum
231
+ */
232
+ function warnNumberAmbiguities(src: string, upTo: number): void {
233
+ for (const a of scanNumberAmbiguities(src, Stripper.strip(src))) {
234
+ if (a.offset > upTo) break;
235
+ console.error(numberAmbiguityMessage(a));
236
+ }
237
+ }
238
+
226
239
  export function parse(src: string): Graph {
227
240
  if (typeof src !== 'string') {
228
241
  // Runtime guard for JS callers (the TS signature already forbids this):
@@ -235,6 +248,7 @@ export function parse(src: string): Graph {
235
248
  } catch (err: unknown) {
236
249
  if (isPeggyError(err)) {
237
250
  const start = err.location.start;
251
+ warnNumberAmbiguities(src, start.offset);
238
252
  const code: GvErrorCode =
239
253
  err.found === null ? 'SYNTAX_UNEXPECTED_EOF' : 'SYNTAX_ERROR';
240
254
  throw new ParseError(
@@ -256,6 +270,7 @@ export function parse(src: string): Graph {
256
270
  }
257
271
  throw err;
258
272
  }
273
+ warnNumberAmbiguities(src, src.length);
259
274
  validateEdgeOperators(src, ast.directed);
260
275
  return buildFromAst(ast);
261
276
  }
@@ -0,0 +1,118 @@
1
+ // SPDX-License-Identifier: EPL-2.0
2
+
3
+ /**
4
+ * scan.l's chkNum diagnostics. The NUMBER regexp allows one trailing '.' or
5
+ * LETTER so the lexer can catch a number run into a name (`00splines`) or a
6
+ * second dot (`1.2.3`); chkNum then warns and yyless() hands the last
7
+ * character back, splitting the text into two tokens. The PEG grammar already
8
+ * yields the split tokens; this pass reproduces the warnings, which a
9
+ * backtracking PEG action would emit unreliably (an alternative that fails
10
+ * after matching a number would re-run it).
11
+ *
12
+ * The walk runs over Stripper output (comments and string interiors blanked,
13
+ * same length, newlines kept), lexing the INITIAL-state tokens that can
14
+ * contain or abut a number: NAME, NUMBER, and the `--` / `->` edge operators.
15
+ *
16
+ * @see lib/cgraph/scan.l:106-110 (LETTER, DIGIT, NAME, NUMBER), :135, chkNum
17
+ */
18
+
19
+ /** One chkNum warning: the matched NUMBER text and its line. */
20
+ export interface NumberAmbiguity {
21
+ text: string;
22
+ line: number;
23
+ offset: number;
24
+ }
25
+
26
+ const ASCII_DEL = 0x7f;
27
+
28
+ /** scan.l LETTER `[A-Za-z_\200-\377]`: any non-ASCII character counts. */
29
+ function isLetter(c: string | undefined): boolean {
30
+ if (c === undefined) return false;
31
+ return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || c === '_'
32
+ || c.charCodeAt(0) > ASCII_DEL;
33
+ }
34
+
35
+ function isDigit(c: string | undefined): boolean {
36
+ return c !== undefined && c >= '0' && c <= '9';
37
+ }
38
+
39
+ /** True when a NUMBER starts at i (sign, digits, or a dot before a digit). */
40
+ function startsNumber(s: string, i: number): boolean {
41
+ const c = s[i];
42
+ if (isDigit(c)) return true;
43
+ if (c === '.') return isDigit(s[i + 1]);
44
+ if (c !== '-') return false;
45
+ return isDigit(s[i + 1]) || (s[i + 1] === '.' && isDigit(s[i + 2]));
46
+ }
47
+
48
+ /** End index of the longest NUMBER match starting at i (scan.l:109). */
49
+ function matchNumber(s: string, i: number): number {
50
+ let j = s[i] === '-' ? i + 1 : i;
51
+ if (s[j] === '.') {
52
+ j++;
53
+ while (isDigit(s[j])) j++;
54
+ } else {
55
+ while (isDigit(s[j])) j++;
56
+ if (s[j] === '.') {
57
+ j++;
58
+ while (isDigit(s[j])) j++;
59
+ }
60
+ }
61
+ if (s[j] === '.' || isLetter(s[j])) j++;
62
+ return j;
63
+ }
64
+
65
+ /** scan.l twoDots: the first '.' is not the last character. */
66
+ function twoDots(text: string): boolean {
67
+ const dot = text.indexOf('.');
68
+ return dot !== -1 && dot !== text.length - 1;
69
+ }
70
+
71
+ /** scan.l chkNum: the last character is a letter, or a second '.'. */
72
+ function chkNum(text: string): boolean {
73
+ const c = text[text.length - 1]!;
74
+ return (!isDigit(c) && c !== '.') || (c === '.' && twoDots(text));
75
+ }
76
+
77
+ /**
78
+ * Lex one INITIAL token at i; returns the next index and, for a badly
79
+ * delimited NUMBER, its text (the next index is then yyless'd back by one).
80
+ */
81
+ function lexToken(s: string, i: number): { next: number; bad: string | null } {
82
+ if (isLetter(s[i])) {
83
+ let j = i + 1;
84
+ while (isLetter(s[j]) || isDigit(s[j])) j++;
85
+ return { next: j, bad: null };
86
+ }
87
+ if (s[i] === '-' && (s[i + 1] === '-' || s[i + 1] === '>')) return { next: i + 2, bad: null };
88
+ if (!startsNumber(s, i)) return { next: i + 1, bad: null };
89
+ const j = matchNumber(s, i);
90
+ const text = s.slice(i, j);
91
+ return chkNum(text) ? { next: j - 1, bad: text } : { next: j, bad: null };
92
+ }
93
+
94
+ /**
95
+ * Every badly delimited NUMBER in `src`, in lexing order. `stripped` is
96
+ * Stripper.strip(src) (same length); tokens are lexed from it, lines are
97
+ * counted in `src` (the stripper blanks newlines inside comments and strings,
98
+ * which scan.l still counts). @see lib/cgraph/scan.l:chkNum
99
+ */
100
+ export function scanNumberAmbiguities(src: string, stripped: string): NumberAmbiguity[] {
101
+ const out: NumberAmbiguity[] = [];
102
+ let line = 1;
103
+ let lineAt = 0;
104
+ for (let i = 0; i < stripped.length;) {
105
+ const { next, bad } = lexToken(stripped, i);
106
+ if (bad !== null) {
107
+ for (; lineAt < i; lineAt++) if (src[lineAt] === '\n') line++;
108
+ out.push({ text: bad, line, offset: i });
109
+ }
110
+ i = next;
111
+ }
112
+ return out;
113
+ }
114
+
115
+ /** agwarningf text for one chkNum ambiguity; a parsed string is "input". */
116
+ export function numberAmbiguityMessage(a: NumberAmbiguity): string {
117
+ return `Warning: syntax ambiguity - badly delimited number '${a.text}' in line ${a.line} of input splits into two tokens`;
118
+ }
@@ -3,6 +3,8 @@
3
3
 
4
4
  import type { Point, Poly, Edge } from './types.js';
5
5
  import { solve3 } from './solvers.js';
6
+ import { fma, fms } from '../common/fma.js';
7
+ import { libmHypot } from '../common/libm-hypot.js';
6
8
 
7
9
  const EPSILON1 = 1e-3;
8
10
  const EPSILON2 = 1e-6;
@@ -18,28 +20,53 @@ class RouteHelper {
18
20
  static B1(t: number): number { const u = 1-t; return 3*t*u*u; }
19
21
  static B2(t: number): number { const u = 1-t; return 3*t*t*u; }
20
22
  static B3(t: number): number { return t*t*t; }
21
- static B01(t: number): number { const u = 1-t; return u*u*(u+3*t); }
22
- static B23(t: number): number { const u = 1-t; return t*t*(3*u+t); }
23
+ /** @see lib/pathplan/route.c:B01 */
24
+ static B01(t: number): number {
25
+ const u = 1-t;
26
+ // clang -ffp-contract=on: fmadd at route.c:488 (`tmp + 3*t`)
27
+ return (u*u) * fma(3, t, u);
28
+ }
29
+ /** @see lib/pathplan/route.c:B23 */
30
+ static B23(t: number): number {
31
+ const u = 1-t;
32
+ // clang -ffp-contract=on: fmadd at route.c:494 (`3*tmp + t`)
33
+ return (t*t) * fma(3, u, t);
34
+ }
23
35
 
36
+ /** @see lib/pathplan/route.c:normv */
24
37
  static normv(v: Point): Point {
25
- const d = v.x*v.x + v.y*v.y;
38
+ // clang -ffp-contract=on: fmadd at route.c:413 (v.x*v.x fused, v.y*v.y rounded)
39
+ const d = fma(v.x, v.x, v.y*v.y);
26
40
  if (d > 1e-6) { const s = Math.sqrt(d); return { x: v.x/s, y: v.y/s }; }
27
41
  return v;
28
42
  }
29
43
  static ptAdd(a: Point, b: Point): Point { return { x: a.x+b.x, y: a.y+b.y }; }
30
44
  static ptSub(a: Point, b: Point): Point { return { x: a.x-b.x, y: a.y-b.y }; }
31
45
  static ptScale(p: Point, c: number): Point { return { x: p.x*c, y: p.y*c }; }
32
- static ptDot(a: Point, b: Point): number { return a.x*b.x + a.y*b.y; }
33
- static ptDist(a: Point, b: Point): number { return Math.hypot(a.x-b.x, a.y-b.y); }
46
+ /** @see lib/pathplan/route.c:dot */
47
+ static ptDot(a: Point, b: Point): number {
48
+ // clang -ffp-contract=on: fmadd at route.c:459 (p1.x*p2.x fused, p1.y*p2.y rounded)
49
+ return fma(a.x, b.x, a.y*b.y);
50
+ }
51
+ /** @see lib/pathplan/route.c:dist (libm hypot, no contraction) */
52
+ static ptDist(a: Point, b: Point): number { return libmHypot(a.x-b.x, a.y-b.y); }
34
53
  static distN(pts: Point[]): number {
35
54
  let rv = 0;
36
55
  for (let i = 1; i < pts.length; i++) rv += RouteHelper.ptDist(pts[i], pts[i-1]);
37
56
  return rv;
38
57
  }
39
58
 
59
+ /**
60
+ * The cubic's coefficients come out of catastrophic cancellation when the
61
+ * spline is tangent to a barrier (tests/2646.dot), so clang's contraction
62
+ * here decides solve3's root count; mirror it exactly.
63
+ * @see lib/pathplan/route.c:points2coeff
64
+ */
40
65
  static points2coeff(v0: number, v1: number, v2: number, v3: number, c: number[]): void {
41
- c[3] = v3 + 3*v1 - (v0 + 3*v2);
42
- c[2] = 3*v0 + 3*v2 - 6*v1;
66
+ // clang -ffp-contract=on: fmadd x2 at route.c:397 (`v3 + 3*v1`, `v0 + 3*v2`; outer sub rounded)
67
+ c[3] = fma(3, v1, v3) - fma(3, v2, v0);
68
+ // clang -ffp-contract=on: fmadd at route.c:398 (`3*v0` fused onto rounded `3*v2`, then `- 6*v1` fused)
69
+ c[2] = fma(-6, v1, fma(3, v0, 3*v2));
43
70
  c[1] = 3*(v1 - v0);
44
71
  c[0] = v0;
45
72
  }
@@ -79,7 +106,8 @@ class RouteHelper {
79
106
  const tv = xr[i];
80
107
  if (tv >= 0 && tv <= 1) {
81
108
  RouteHelper.points2coeff(sps[0].y, sps[1].y, sps[2].y, sps[3].y, sc);
82
- const sv = (sc[0] + tv*(sc[1] + tv*(sc[2] + tv*sc[3])) - lc.yc0) / lc.yc1;
109
+ // clang -ffp-contract=on: fmadd x3 at route.c:360-361 (Horner `sv`; `(sv - ycoeff[0]) / ycoeff[1]` plain)
110
+ const sv = (fma(tv, fma(tv, fma(tv, sc[3], sc[2]), sc[1]), sc[0]) - lc.yc0) / lc.yc1;
83
111
  if (sv >= 0 && sv <= 1) RouteHelper.addRoot(tv, acc);
84
112
  }
85
113
  }
@@ -89,23 +117,27 @@ class RouteHelper {
89
117
  static silGeneral(sps: Point[], lc: LineCoeff, acc: RootAccum): number {
90
118
  const sc = [0,0,0,0], xr = [0,0,0];
91
119
  const rat = lc.yc1 / lc.xc1;
120
+ // clang -ffp-contract=on: fmsub x4 at route.c:371-372 (`sps[k].y - rat*sps[k].x`)
92
121
  RouteHelper.points2coeff(
93
- sps[0].y - rat*sps[0].x, sps[1].y - rat*sps[1].x,
94
- sps[2].y - rat*sps[2].x, sps[3].y - rat*sps[3].x, sc);
95
- sc[0] += rat*lc.xc0 - lc.yc0;
122
+ fms(rat, sps[0].x, sps[0].y), fms(rat, sps[1].x, sps[1].y),
123
+ fms(rat, sps[2].x, sps[2].y), fms(rat, sps[3].x, sps[3].y), sc);
124
+ // clang -ffp-contract=on: fnmsub at route.c:374 (`rat*xcoeff[0] - ycoeff[0]`; `+=` rounded)
125
+ sc[0] += fma(rat, lc.xc0, -lc.yc0);
96
126
  const xn = solve3(sc, xr);
97
127
  if (xn === 4) return 4;
98
128
  for (let i = 0; i < xn; i++) {
99
129
  const tv = xr[i];
100
130
  if (tv >= 0 && tv <= 1) {
101
131
  RouteHelper.points2coeff(sps[0].x, sps[1].x, sps[2].x, sps[3].x, sc);
102
- const sv = (sc[0] + tv*(sc[1] + tv*(sc[2] + tv*sc[3])) - lc.xc0) / lc.xc1;
132
+ // clang -ffp-contract=on: fmadd x3 at route.c:383-384 (Horner `sv`; `(sv - xcoeff[0]) / xcoeff[1]` plain)
133
+ const sv = (fma(tv, fma(tv, fma(tv, sc[3], sc[2]), sc[1]), sc[0]) - lc.xc0) / lc.xc1;
103
134
  if (sv >= 0 && sv <= 1) RouteHelper.addRoot(tv, acc);
104
135
  }
105
136
  }
106
137
  return acc.n;
107
138
  }
108
139
 
140
+ /** @see lib/pathplan/route.c:splineintersectsline */
109
141
  static splineIntersectsLine(sps: Point[], lps: Point[], roots: number[]): number {
110
142
  const lc: LineCoeff = {
111
143
  xc0: lps[0].x, xc1: lps[1].x - lps[0].x,
@@ -156,8 +188,13 @@ class RouteHelper {
156
188
  x0 += RouteHelper.ptDot(tnas[i].a[0], tmp);
157
189
  x1 += RouteHelper.ptDot(tnas[i].a[1], tmp);
158
190
  }
159
- const det01 = c00*c11 - c01*c01;
160
- const detX1 = x0*c11 - x1*c01, det0X = c00*x1 - c01*x0;
191
+ // C's c[1][0] is a copy of c[0][1] (route.c:174), so c10 == c01 here.
192
+ // clang -ffp-contract=on: fnmul+fmadd at route.c:181 (c00*c11 fused, c10*c01 rounded)
193
+ const det01 = fma(c00, c11, -(c01*c01));
194
+ // clang -ffp-contract=on: fnmul+fmadd at route.c:182 (c00*x1 fused, c01*x0 rounded)
195
+ const det0X = fma(c00, x1, -(c01*x0));
196
+ // clang -ffp-contract=on: fnmul+fmadd at route.c:183 (x0*c11 fused, x1*c01 rounded)
197
+ const detX1 = fma(x0, c11, -(x1*c01));
161
198
  if (Math.abs(det01) >= 1e-6) { scale0 = detX1/det01; scale3 = det0X/det01; }
162
199
  if (Math.abs(det01) < 1e-6 || scale0 <= 0 || scale3 <= 0) {
163
200
  const d = RouteHelper.ptDist(inps[0], inps[n-1]) / 3;
@@ -179,6 +216,27 @@ class RouteHelper {
179
216
  return tnas;
180
217
  }
181
218
 
219
+ /**
220
+ * The max-deviation split of `reallyroutespline` (route.c:130-143): the
221
+ * interior polyline point farthest from the fitted bezier.
222
+ *
223
+ * Faithful contract. On a geometrically symmetric channel two interior
224
+ * deviations tie to within an ulp, so the split point (and the whole
225
+ * spline) is decided by C's exact arithmetic, which this mirrors bit for
226
+ * bit against the native arm64 oracle:
227
+ * - `dist` is libm `hypot` (route.c:448) -> `libmHypot` via `ptDist`;
228
+ * - the test is C's strict `(d = dist(p, inps[i])) > maxd` (route.c:139),
229
+ * so an exact tie keeps the first index;
230
+ * - clang's default `-ffp-contract=on` fuses the bezier eval at
231
+ * route.c:137-138 as `fma(B3,p2, fma(B2,cp2, fma(B0,p1, B1*cp1)))`; the
232
+ * B0..B3 weights and cp1/cp2 (route.c:130-131) are not fused. Fusing the
233
+ * other product first is NOT equivalent (flips pt-issue-23-w9 and 32).
234
+ * C is therefore not translation-equivariant on such ties (the winner
235
+ * depends on absolute-coordinate rounding), and neither is this port.
236
+ * This supersedes the former tolerant tie-break (accepted divergence A3).
237
+ * Contraction map: .agent-notes/pathplan-fma-contraction-map.md.
238
+ * @see lib/pathplan/route.c:reallyroutespline
239
+ */
182
240
  static findMaxDev(inps: Point[], tnas: Tna[], ends: SplineEndpoints): number {
183
241
  const { p0, v0, p1, v1 } = ends;
184
242
  const cp1 = RouteHelper.ptAdd(p0, RouteHelper.ptScale(v0, 1/3));
@@ -186,27 +244,14 @@ class RouteHelper {
186
244
  let maxi = -1, maxd = -1;
187
245
  for (let i = 1; i < inps.length - 1; i++) {
188
246
  const t = tnas[i].t;
189
- const px = RouteHelper.B0(t)*p0.x + RouteHelper.B1(t)*cp1.x + RouteHelper.B2(t)*cp2.x + RouteHelper.B3(t)*p1.x;
190
- const py = RouteHelper.B0(t)*p0.y + RouteHelper.B1(t)*cp1.y + RouteHelper.B2(t)*cp2.y + RouteHelper.B3(t)*p1.y;
247
+ const b0 = RouteHelper.B0(t), b1 = RouteHelper.B1(t);
248
+ const b2 = RouteHelper.B2(t), b3 = RouteHelper.B3(t);
249
+ // clang -ffp-contract=on: fmadd at route.c:137 (x3; B1*cp1.x rounded first)
250
+ const px = fma(b3, p1.x, fma(b2, cp2.x, fma(b0, p0.x, b1*cp1.x)));
251
+ // clang -ffp-contract=on: fmadd at route.c:138 (x3; B1*cp1.y rounded first)
252
+ const py = fma(b3, p1.y, fma(b2, cp2.y, fma(b0, p0.y, b1*cp1.y)));
191
253
  const d = RouteHelper.ptDist({ x: px, y: py }, inps[i]);
192
- // C `reallyroutespline` uses strict `if (d > maxd)`. On a geometrically
193
- // SYMMETRIC channel the two interior deviations are an exact tie, and the
194
- // winner is then decided by ~1e-14 cancellation noise in the
195
- // absolute-coordinate bezier eval above — whose sign depends on absolute
196
- // position. So C is NOT consistent: it splits two translation-congruent
197
- // arcs toward opposite corners (e.g. 2368 `376->76` mirrors `256->436`),
198
- // because Apple's macOS libm `hypot` (which generated the oracle) is a
199
- // proprietary ~1-ULP-divergent implementation that no portable hypot
200
- // reproduces. We deliberately keep the tolerant tie-break: a true tie
201
- // always resolves to the first index, so the port is TRANSLATION-
202
- // EQUIVARIANT (same channel anywhere -> same spline). This matches C where
203
- // C's noise also keeps-first (#241_0 5:ne->8:nw, and the cnt=3 oracle
204
- // case) and consciously diverges where C's noise keeps-second (2368
205
- // `376->76`, the lone surviving residual: structural-match, maxΔ ~10pt on
206
- // one flat-edge arc). See .agent-notes/2368-residual-flat-label-ranksep.md
207
- // and the pow precedent (src/common/arm-pow.ts) for the same Apple-libm
208
- // boundary. @see lib/pathplan/route.c:reallyroutespline (max-deviation split)
209
- if (d > maxd * (1 + 1e-10) + 1e-10) { maxd = d; maxi = i; }
254
+ if (d > maxd) { maxd = d; maxi = i; }
210
255
  }
211
256
  return maxi;
212
257
  }
@@ -1,65 +1,93 @@
1
1
  // SPDX-License-Identifier: EPL-2.0
2
- /** @see lib/pathplan/solvers.c */
2
+ /**
3
+ * @see lib/pathplan/solvers.c
4
+ *
5
+ * The native oracle's libpathplan is compiled by clang with the default
6
+ * `-ffp-contract=on`, which fuses five multiply-adds in this file into single
7
+ * fmadd/fmsub/fnmsub instructions (solve2 is inlined into solve3). Those sites
8
+ * are mirrored with the software `fma`; every other operation is plain IEEE
9
+ * double, in C's evaluation order. Contraction map:
10
+ * .agent-notes/pathplan-fma-contraction-map.md.
11
+ */
3
12
 
13
+ import { fma } from '../common/fma.js';
14
+
15
+ /** @see lib/pathplan/solvers.c:EPS */
4
16
  const EPS = 1e-7;
5
17
 
6
- class Solver {
7
- static aeq0(x: number): boolean {
8
- return x < EPS && x > -EPS;
9
- }
18
+ /** @see lib/pathplan/solvers.c:AEQ0 */
19
+ function aeq0(x: number): boolean {
20
+ return x < EPS && x > -EPS;
21
+ }
10
22
 
11
- static solve1(c: number[], r: number[]): number {
12
- if (Solver.aeq0(c[1])) return Solver.aeq0(c[0]) ? 4 : 0;
13
- r[0] = -c[0] / c[1];
14
- return 1;
15
- }
23
+ /**
24
+ * Solve cubic coeff[3]*x^3 + coeff[2]*x^2 + coeff[1]*x + coeff[0] = 0.
25
+ * Returns the root count; 4 means every x satisfies.
26
+ * @see lib/pathplan/solvers.c:solve3
27
+ */
28
+ export function solve3(coeff: number[], roots: number[]): number {
29
+ const a = coeff[3], b = coeff[2], c = coeff[1], d = coeff[0];
30
+ if (aeq0(a)) return solve2(coeff, roots);
31
+ const bOver3a = b / (3 * a), cOverA = c / a, dOverA = d / a;
16
32
 
17
- static solve2(c: number[], r: number[]): number {
18
- if (Solver.aeq0(c[2])) return Solver.solve1(c, r);
19
- const b2a = c[1] / (2 * c[2]);
20
- const disc = b2a * b2a - c[0] / c[2];
21
- if (disc < 0) return 0;
22
- if (disc > 0) {
23
- r[0] = -b2a + Math.sqrt(disc);
24
- r[1] = -2 * b2a - r[0];
25
- return 2;
26
- }
27
- r[0] = -b2a;
28
- return 1;
29
- }
33
+ let p = bOver3a * bOver3a;
34
+ // clang -ffp-contract=on: fnmul+fmadd at solvers.c:41 (`2*b_over_3a*p` fused,
35
+ // `b_over_3a*c_over_a` rounded; `+ d_over_a` is a separate rounded add)
36
+ const q = dOverA + fma(2 * bOver3a, p, -(bOver3a * cOverA));
37
+ p = cOverA / 3 - p;
38
+ // clang -ffp-contract=on: fmadd at solvers.c:43 (q*q fused, 4*p*p*p rounded)
39
+ const disc = fma(q, q, 4 * p * p * p);
30
40
 
31
- static solve3neg(disc: number, q: number, b3a: number, r: number[]): number {
32
- const rr = 0.5 * Math.sqrt(-disc + q * q);
41
+ let rootn: number;
42
+ if (disc < 0) {
43
+ // clang -ffp-contract=on: fnmsub at solvers.c:46 (`-disc + q*q`)
44
+ const r = 0.5 * Math.sqrt(fma(q, q, -disc));
33
45
  const theta = Math.atan2(Math.sqrt(-disc), -q);
34
- const temp = 2 * Math.cbrt(rr);
35
- r[0] = temp * Math.cos(theta / 3) - b3a;
36
- r[1] = temp * Math.cos((theta + 2 * Math.PI) / 3) - b3a;
37
- r[2] = temp * Math.cos((theta - 2 * Math.PI) / 3) - b3a;
38
- return 3;
46
+ const temp = 2 * Math.cbrt(r);
47
+ roots[0] = temp * Math.cos(theta / 3);
48
+ roots[1] = temp * Math.cos((theta + Math.PI + Math.PI) / 3);
49
+ roots[2] = temp * Math.cos((theta - Math.PI - Math.PI) / 3);
50
+ rootn = 3;
51
+ } else {
52
+ const alpha = 0.5 * (Math.sqrt(disc) - q);
53
+ const beta = -q - alpha;
54
+ roots[0] = Math.cbrt(alpha) + Math.cbrt(beta);
55
+ if (disc > 0) rootn = 1;
56
+ else { roots[1] = roots[2] = -0.5 * roots[0]; rootn = 3; }
39
57
  }
40
58
 
41
- static solve3pos(disc: number, q: number, b3a: number, r: number[]): number {
42
- const al = 0.5 * (Math.sqrt(disc) - q);
43
- r[0] = Math.cbrt(al) + Math.cbrt(-q - al) - b3a;
44
- if (disc > 0) return 1;
45
- r[1] = r[2] = -0.5 * (r[0] + b3a) - b3a;
46
- return 3;
47
- }
59
+ for (let i = 0; i < rootn; i++) roots[i] -= bOver3a;
60
+ return rootn;
61
+ }
62
+
63
+ /** @see lib/pathplan/solvers.c:solve2 */
64
+ function solve2(coeff: number[], roots: number[]): number {
65
+ const a = coeff[2], b = coeff[1], c = coeff[0];
66
+ if (aeq0(a)) return solve1(coeff, roots);
67
+ const bOver2a = b / (2 * a);
68
+ const cOverA = c / a;
48
69
 
49
- static solve3(c: number[], r: number[]): number {
50
- const a = c[3], b = c[2], cv = c[1], d = c[0];
51
- if (Solver.aeq0(a)) return Solver.solve2(c, r);
52
- const b3a = b / (3 * a), p0 = b3a * b3a;
53
- const q = 2 * b3a * p0 - b3a * cv / a + d / a;
54
- const p = cv / (3 * a) - p0;
55
- const disc = q * q + 4 * p * p * p;
56
- return disc < 0 ? Solver.solve3neg(disc, q, b3a, r)
57
- : Solver.solve3pos(disc, q, b3a, r);
70
+ // clang -ffp-contract=on: fmadd at solvers.c:80 (`b_over_2a*b_over_2a - c_over_a`)
71
+ const disc = fma(bOver2a, bOver2a, -cOverA);
72
+ if (disc < 0) {
73
+ return 0;
74
+ } else if (disc > 0) {
75
+ roots[0] = -bOver2a + Math.sqrt(disc);
76
+ // clang -ffp-contract=on: fnmsub at solvers.c:85 (`-2*b_over_2a - roots[0]`)
77
+ roots[1] = fma(-2, bOver2a, -roots[0]);
78
+ return 2;
58
79
  }
80
+ roots[0] = -bOver2a;
81
+ return 1;
59
82
  }
60
83
 
61
- /** Solve cubic c[3]*x^3 + c[2]*x^2 + c[1]*x + c[0] = 0.
62
- * Returns root count; 4 means all x satisfy. @see lib/pathplan/solvers.c:solve3 */
63
- export function solve3(coeff: number[], roots: number[]): number {
64
- return Solver.solve3(coeff, roots);
84
+ /** @see lib/pathplan/solvers.c:solve1 (no contraction) */
85
+ function solve1(coeff: number[], roots: number[]): number {
86
+ const a = coeff[1], b = coeff[0];
87
+ if (aeq0(a)) {
88
+ if (aeq0(b)) return 4;
89
+ return 0;
90
+ }
91
+ roots[0] = -b / a;
92
+ return 1;
65
93
  }
@@ -18,6 +18,7 @@ import type { ArrowDrawOp } from '../../common/arrows-types.js';
18
18
  import type { GVColor } from '../../common/color.js';
19
19
  import type { RenderJob } from '../../gvc/job.js';
20
20
  import { EmitState } from '../../gvc/job.js';
21
+ import { PenType } from '../../gvc/context.js';
21
22
  import { resolveRenderColor } from '../color-resolve.js';
22
23
  import { taper, taperfun } from '../../common/taper.js';
23
24
  import { orthoRoundedRadius } from '../svg-helpers.js';
@@ -31,6 +32,9 @@ import { DotWriterBase } from './agwrite.js';
31
32
  /** Pen colour when a colour-list entry is empty. @see lib/common/emit.c:2485 */
32
33
  const DEFAULT_COLOR = 'black';
33
34
 
35
+ /** gvc->defaultlinestyle as style tokens. @see lib/common/emit.c:105 */
36
+ const DEFAULT_LINE_STYLE: readonly string[] = ['solid', 'setlinewidth(1)'];
37
+
34
38
  /**
35
39
  * One arrow primitive as an xdot op. Lifted out of emitArrows' loop unchanged —
36
40
  * the four cases and their `filled` variants are exactly arrow_gen's.
@@ -64,6 +68,27 @@ export abstract class EdgeDrawBase extends DotWriterBase {
64
68
  protected penwidth: number[] = new Array(12).fill(1);
65
69
  protected abstract styleOp(job: RenderJob): string;
66
70
  protected abstract penOp(job: RenderJob): string;
71
+ abstract polyline(pts: Point[], job: RenderJob): void;
72
+
73
+ /**
74
+ * emit_attachment's draw calls (decorate=true): gvrender_set_style with the
75
+ * default line style sets the obj's rawstyle/pen/penwidth (setlinewidth 1),
76
+ * gvrender_set_pencolor sets the pen to the label fontcolor (first entry of
77
+ * a color list), then gvrender_polyline → xdot_polyline emits the style ops
78
+ * (`S 5 -solid`; setlinewidth is filtered), the pen color and `L 3`.
79
+ * @see lib/common/emit.c:emit_attachment (1886-1893)
80
+ * @see lib/gvc/gvrender.c:gvrender_set_style / gvrender_set_pencolor
81
+ */
82
+ attachmentPolyline(pts: Point[], pencolor: string, job: RenderJob): void {
83
+ const obj = job.obj;
84
+ if (obj !== null) {
85
+ obj.rawStyle = [...DEFAULT_LINE_STYLE];
86
+ obj.pen = PenType.Solid;
87
+ obj.penWidth = 1;
88
+ obj.penColor = resolveRenderColor(pencolor.split(':')[0]!);
89
+ }
90
+ this.polyline(pts, job);
91
+ }
67
92
 
68
93
  /**
69
94
  * The plain single-colour spline branch of beginEdge — lifted verbatim so the