@knowvah/dot-engine 1.2.4 → 1.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@knowvah/dot-engine",
3
- "version": "1.2.4",
3
+ "version": "1.2.5",
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",
@@ -98,7 +98,6 @@
98
98
  "@typescript/native-preview": "^7.0.0-dev.20260622.1",
99
99
  "@vitest/coverage-v8": "^4.1.9",
100
100
  "@xmldom/xmldom": "^0.9.10",
101
- "canvas": "^3.2.3",
102
101
  "esbuild": "^0.28.1",
103
102
  "fontkit": "^2.0.4",
104
103
  "peggy": "^5.0.0",
@@ -106,6 +105,7 @@
106
105
  "tsx": "^4.23.1",
107
106
  "typedoc": "^0.28.20",
108
107
  "typedoc-plugin-markdown": "^4.12.0",
108
+ "@knowvah/vitepress-plugin-dot": "^1.0.0",
109
109
  "typedoc-vitepress-theme": "^1.1.3",
110
110
  "typescript": "^6.0.0",
111
111
  "vitepress": "^1.6.0",
@@ -181,29 +181,34 @@ function parseRatioKind(g: Graph): RatioKind | undefined {
181
181
 
182
182
  /**
183
183
  * Populate `g.info.drawing` for the ratio kinds whose layout reshape is ported
184
- * AND corpus-validated: `compress` (compressGraph x-NS, position-cluster.ts)
185
- * and `fill` (setAspect R_FILL, position-bbox.ts). `expand`/`value` have the
186
- * math in setAspect but no corpus coverage, and `auto` needs `idealsize`
187
- * (unported); all three stay deferred — leaving `drawing` unset keeps
188
- * setAspect/compressGraph a no-op for them.
184
+ * AND corpus-validated: `compress` (compressGraph x-NS, position-cluster.ts),
185
+ * `fill` (setAspect R_FILL, position-bbox.ts), and numeric `value` (setAspect
186
+ * R_VALUE; corpus coverage: 2621, `ratio=0.5625` — see
187
+ * `.agent-notes/2621-path-structure.md`). C stores the parsed ratio only for
188
+ * R_VALUE (`input.c setRatio: ratio = atof(p); if > 0`). `expand` has the math
189
+ * in setAspect but no corpus coverage, and `auto` needs `idealsize` (unported);
190
+ * both stay deferred — leaving `drawing` unset keeps setAspect a no-op for
191
+ * them.
189
192
  *
190
193
  * DELIBERATE DEVIATION from C, carried over verbatim from dot's old init: C
191
194
  * allocates GD_drawing unconditionally (`input.c:609`) and stores the parsed
192
195
  * ratio_kind for EVERY kind. Allocating it here would flip the
193
196
  * `drawing === undefined` guards at `dot/position-bbox.ts:142` (setAspect) and
194
- * `neato/set-aspect.ts:57`, activating the unvalidated expand/value/auto
195
- * reshapes and changing dot output. Scope is therefore unchanged by the
196
- * graph_init consolidation. See `.agent-notes/graph-init-consolidation.md`.
197
+ * `neato/set-aspect.ts:57`, activating the unvalidated expand/auto reshapes and
198
+ * changing dot output. Scope is therefore unchanged by the graph_init
199
+ * consolidation. See `.agent-notes/graph-init-consolidation.md`.
197
200
  *
198
201
  * @see lib/dotgen/position.c:set_aspect (904), compress_graph (501)
199
202
  * @see lib/common/input.c:576 setRatio, 693-694 (size)
200
203
  */
201
204
  function parseRatioDrawing(g: Graph): void {
202
205
  const kind = parseRatioKind(g);
203
- if (kind !== 'compress' && kind !== 'fill') return;
206
+ if (kind !== 'compress' && kind !== 'fill' && kind !== 'value') return;
204
207
  const sz = parseSizePoints(g.attrs.get('size'));
205
208
  g.info.drawing = makeDrawing({
206
209
  ratioKind: kind,
210
+ // C: GD_drawing(g)->ratio = atof(p), set only for R_VALUE (setRatio).
211
+ ratio: kind === 'value' ? Number.parseFloat(g.attrs.get('ratio')!) : 0,
207
212
  size: sz ? { x: sz.x, y: sz.y } : { x: 0, y: 0 },
208
213
  filled: sz?.filled ?? false,
209
214
  });
@@ -472,4 +472,29 @@ export function clipAndInstall(fe: Edge, hn: Node, ps: Point[], pn: number, info
472
472
  SplineClipHelper.arrowClip(fe, hn, ps, bounds, newspl, info);
473
473
  // C: update_bb_bz(&GD_bb(g), cp) — expand g's bb by each installed bezier.
474
474
  SplineClipHelper.copyToBezier(newspl, ps, bounds.start, bounds.end, g.info.bb);
475
+ if (clipDump) {
476
+ let cur = fe;
477
+ while (cur.info.to_orig != null && cur.info.edge_type !== 0) cur = cur.info.to_orig;
478
+ clipDump('S5_CLIP', cur.tail.name, cur.head.name, {
479
+ inputPn: pn,
480
+ bounds: { start: bounds.start, end: bounds.end },
481
+ sflag: newspl.sflag, eflag: newspl.eflag,
482
+ sp: { ...newspl.sp }, ep: { ...newspl.ep },
483
+ size: newspl.size,
484
+ // emit reads bz.size, not list.length (memory: bezier-emit-size-not-length)
485
+ list: newspl.list.slice(0, newspl.size).map(p => ({ ...p })),
486
+ });
487
+ }
488
+ }
489
+
490
+ /**
491
+ * Stage-dump hook for the clip/install output (diagnosis only; null in
492
+ * production — same pattern as splines-routespl.ts:setRouteDump).
493
+ */
494
+ type ClipDumpFn = (stage: string, tail: string, head: string, payload: unknown) => void;
495
+ let clipDump: ClipDumpFn | null = null;
496
+
497
+ /** Install (or clear) the clip-stage dump callback. Diagnosis only. */
498
+ export function setClipDump(fn: ClipDumpFn | null): void {
499
+ clipDump = fn;
475
500
  }
@@ -15,6 +15,50 @@ import { shortestPath, routeSpline, makePolyline } from '../pathplan/index.js';
15
15
  import type { Poly } from '../pathplan/types.js';
16
16
  import { INIT_DELTA, LOOP_TRIES, ROUTESPL_FUDGE } from './splines-constants.js';
17
17
 
18
+ // ---------------------------------------------------------------------------
19
+ // Stage-dump hook (diagnosis only; null in production — same pattern as
20
+ // mincross-order.ts:setMincrossTrace). The callback receives the routing
21
+ // stage name, the real edge's tail/head names (resolved through to_orig like
22
+ // C's routesplines_ realedge walk), and a stage payload; filtering and
23
+ // serialization are the callback's job. Browser-safe: no env reads here.
24
+ // ---------------------------------------------------------------------------
25
+
26
+ /** Stage payload consumer installed by a diagnosis harness. */
27
+ export type RouteDumpFn = (
28
+ stage: string, tail: string, head: string, payload: unknown,
29
+ ) => void;
30
+
31
+ let routeDump: RouteDumpFn | null = null;
32
+
33
+ /** Install (or clear) the routing stage-dump callback. Diagnosis only. */
34
+ export function setRouteDump(fn: RouteDumpFn | null): void {
35
+ routeDump = fn;
36
+ }
37
+
38
+ interface DumpEdgeLike {
39
+ tail?: { name?: string }; head?: { name?: string };
40
+ info?: { edge_type?: number; to_orig?: DumpEdgeLike };
41
+ }
42
+
43
+ function dumpIsVirtual(e: DumpEdgeLike): boolean {
44
+ return (e.info?.edge_type ?? 0) !== 0;
45
+ }
46
+
47
+ /** Resolve pp.data through to_orig to the real edge's names, as C does. */
48
+ function dumpEdgeNames(pp: Path): [string, string] {
49
+ let e = pp.data as DumpEdgeLike | null;
50
+ // C: for (realedge = pp->data; realedge && ED_edge_type(realedge) != NORMAL;
51
+ // realedge = ED_to_orig(realedge)); (NORMAL === 0)
52
+ while (e !== null && dumpIsVirtual(e)) e = e.info?.to_orig ?? null;
53
+ if (e === null) return ['?', '?'];
54
+ return [e.tail?.name ?? '?', e.head?.name ?? '?'];
55
+ }
56
+
57
+ /** Invoke the dump callback when installed; payload built lazily. */
58
+ function emitDump(stage: string, names: [string, string] | null, mk: () => unknown): void {
59
+ if (routeDump !== null && names !== null) routeDump(stage, names[0], names[1], mk());
60
+ }
61
+
18
62
  // ---------------------------------------------------------------------------
19
63
  // overlap helper
20
64
  // ---------------------------------------------------------------------------
@@ -353,8 +397,20 @@ function runLimitLoop(
353
397
 
354
398
  function routeSplinesInternal(pp: Path, polyline: boolean): Point[] | null {
355
399
  const boxes = pp.boxes;
400
+ const dumpNames = routeDump ? dumpEdgeNames(pp) : null;
401
+ emitDump('S1_INPUT', dumpNames, () => ({
402
+ nbox: pp.nbox,
403
+ boxes: pp.boxes.slice(0, pp.nbox).map(b => ({ ll: { ...b.ll }, ur: { ...b.ur } })),
404
+ start: { p: { ...pp.start.p }, theta: pp.start.theta, constrained: pp.start.constrained },
405
+ end: { p: { ...pp.end.p }, theta: pp.end.theta, constrained: pp.end.constrained },
406
+ }));
356
407
  if (checkPath(pp.nbox, boxes, pp)) return null;
357
408
  const effectiveBoxn = pp.nbox;
409
+ emitDump('S2_CHECKED', dumpNames, () => ({
410
+ nbox: effectiveBoxn,
411
+ boxes: boxes.slice(0, effectiveBoxn).map(b => ({ ll: { ...b.ll }, ur: { ...b.ur } })),
412
+ start: { p: { ...pp.start.p } }, end: { p: { ...pp.end.p } },
413
+ }));
358
414
 
359
415
  let flip = false;
360
416
  if (effectiveBoxn > 1 && boxes[0].ll.y > boxes[1].ll.y) {
@@ -389,6 +445,11 @@ function routeSplinesInternal(pp: Path, polyline: boolean): Point[] | null {
389
445
  console.warn('in routesplines, Pshortestpath failed');
390
446
  return null;
391
447
  }
448
+ emitDump('S3_POLY', dumpNames, () => ({
449
+ poly: polypoints.map(p => ({ ...p })),
450
+ eps: [{ ...eps[0] }, { ...eps[1] }],
451
+ pl: pl.map(p => ({ ...p })),
452
+ }));
392
453
 
393
454
  let ps: Point[];
394
455
  if (polyline) {
@@ -399,6 +460,11 @@ function routeSplinesInternal(pp: Path, polyline: boolean): Point[] | null {
399
460
  ps = routeSpline(edges, pl, evs);
400
461
  if (ps.length === 0) return null;
401
462
  }
463
+ emitDump('S4_SPL', dumpNames, () => ({
464
+ polyline,
465
+ evs: polyline ? undefined : buildConstraintVectors(pp).map(v => ({ ...v })),
466
+ ps: ps.map(p => ({ ...p })),
467
+ }));
402
468
 
403
469
  if (isTriviallyBounded(ps)) {
404
470
  applyTrivialBounds(boxes, effectiveBoxn, ps);