@knowvah/dot-engine 1.2.1 → 1.2.3

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.1",
3
+ "version": "1.2.3",
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",
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import type { Point } from '../model/geom.js';
12
+ import { ccwrotatepf, cwrotatepf } from '../model/geom.js';
12
13
  import type { GenResult } from './arrows-shapes-util.js';
13
14
  import {
14
15
  vsub, vadd, miterShape, negUnit, backwardDelta, axialProjection,
@@ -217,7 +218,25 @@ function curveControlPoints(p: Point, v: Point, w: Point, flag: number): Point[]
217
218
  }
218
219
 
219
220
  /** @see lib/common/arrows.c:arrow_type_curve */
220
- export function genCurve(p0: Point, u: Point, _arrowsize: number, penwidth: number, flag: number): GenResult {
221
+ export function genCurve(
222
+ p0: Point, u: Point, _arrowsize: number, penwidth: number, flag: number,
223
+ rankdir = 0,
224
+ ): GenResult {
225
+ // C generates arrowheads at RENDER time, in the final (post-rankdir) frame;
226
+ // the port generates during routing and postproc rotates the stored ops.
227
+ // That round-trip is exact for every other generator, but this formula is
228
+ // not rotation-equivariant (0.95 shortens the x arm only; the y arm comes
229
+ // from AF0/AF3 verbatim), so run it in the final frame and rotate back.
230
+ // The rankdir maps are pure sign/swaps — rankdir 0 (TB) is untouched.
231
+ if (rankdir !== 0) {
232
+ const rot = rankdir * 90;
233
+ const fin = genCurve(ccwrotatepf(p0, rot), ccwrotatepf(u, rot), _arrowsize, penwidth, flag);
234
+ return {
235
+ ops: fin.ops.map((op) =>
236
+ 'points' in op ? { ...op, points: op.points.map((pt) => cwrotatepf(pt, rot)) } : op),
237
+ q: cwrotatepf(fin.q, rot),
238
+ };
239
+ }
221
240
  const arrowwidth = penwidth > 4 ? (0.5 * penwidth) / 4 : 0.5;
222
241
  const noShift = (flag & ARR_MOD_INV) || (u.x === 0 && u.y === 0);
223
242
  const p = noShift ? p0 : vsub(p0, backwardDelta(u, penwidth));
@@ -319,6 +319,7 @@ export function arrowLength(comps: ResolvedArrow[], arrowsize: number, penwidth:
319
319
  */
320
320
  export function dispatchSimple(
321
321
  r: ResolvedArrow, tip: Point, dir: Point, arrowsize: number, penwidth: number,
322
+ rankdir = 0,
322
323
  ): GenResult {
323
324
  const flag = arrowFlag(r);
324
325
  const u = componentU(dir, r.lenfact, arrowsize);
@@ -328,7 +329,9 @@ export function dispatchSimple(
328
329
  case ARR_TYPE_BOX: return genBox(tip, u, arrowsize, penwidth, flag);
329
330
  case ARR_TYPE_DIAMOND: return genDiamond(tip, u, arrowsize, penwidth, flag);
330
331
  case ARR_TYPE_DOT: return genDot(tip, u, arrowsize, penwidth, flag);
331
- case ARR_TYPE_CURVE: return genCurve(tip, u, arrowsize, penwidth, flag);
332
+ // curve is the one generator that is not rotation-equivariant; it needs
333
+ // the rankdir to reproduce C's render-time (final-frame) generation.
334
+ case ARR_TYPE_CURVE: return genCurve(tip, u, arrowsize, penwidth, flag, rankdir);
332
335
  case ARR_TYPE_GAP: return genGap(tip, u, arrowsize, penwidth, flag);
333
336
  case ARR_TYPE_NORM:
334
337
  default:
@@ -345,6 +348,7 @@ export function dispatchSimple(
345
348
  */
346
349
  export function arrowDrawOps(
347
350
  comps: ResolvedArrow[], tip: Point, dir: Point, arrowsize: number, penwidth: number,
351
+ rankdir = 0,
348
352
  ): ArrowDrawOp[] {
349
353
  const ops: ArrowDrawOp[] = [];
350
354
  let p = tip;
@@ -352,7 +356,7 @@ export function arrowDrawOps(
352
356
  for (let i = 0; i < n; i++) {
353
357
  const r = comps[i];
354
358
  if ((r.type & ARR_TYPE_MASK) === ARR_TYPE_NONE) break;
355
- const res = dispatchSimple(r, p, dir, arrowsize, penwidth);
359
+ const res = dispatchSimple(r, p, dir, arrowsize, penwidth, rankdir);
356
360
  ops.push(...res.ops);
357
361
  p = res.q;
358
362
  }
@@ -201,6 +201,7 @@ function pointSizeResult(
201
201
  return {
202
202
  ...gvNodesize(sz, sz, false),
203
203
  outlineW: szOutline, outlineH: szOutline, baseW: sz0, baseH: sz0,
204
+ fixedshape: false, // C point_init never sets option.fixedshape
204
205
  };
205
206
  }
206
207
 
@@ -262,17 +263,20 @@ function initNodeFromLabel(n: Node, g: Graph, measurer: TextMeasurer): boolean {
262
263
  // C poly_init installs the ATTR-RESOLVED polygon into ND_shape_info
263
264
  // (sides/orientation/skew/distortion/peripheries/regular); edge
264
265
  // clipping and rendering read it.
265
- assignShapeInfo(n, effectivePolygon(poly, params));
266
+ assignShapeInfo(n, effectivePolygon(poly, params, sz.fixedshape));
266
267
  return true;
267
268
  }
268
269
 
269
270
  /** The attr-resolved polygon poly_init stores. @see shapes.c:poly_init (poly->... assignments) */
270
- function effectivePolygon(poly: PolygonT, p: PolySizeParams): PolygonT {
271
+ function effectivePolygon(poly: PolygonT, p: PolySizeParams, fixedshape: boolean): PolygonT {
271
272
  let sides = p.sides;
272
273
  // ellipses with distortion/skew become 120-gons
273
274
  if (sides <= 2 && (p.distortion !== 0 || p.skew !== 0)) sides = 120;
274
275
  return {
275
276
  ...poly,
277
+ // C: poly->option.fixedshape = true inside the fixedsize="shape" branch;
278
+ // poly-inside and computeVertices key the shape-box/width split on it.
279
+ option: { ...poly.option, fixedshape },
276
280
  regular: p.regular,
277
281
  peripheries: p.peripheries,
278
282
  sides,
@@ -32,7 +32,7 @@ export function sameSide(p0: Point, p1: Point, L0: Point, L1: Point): boolean {
32
32
  }
33
33
 
34
34
  /** Vertex-extent box of a fixedshape polygon. @see lib/common/shapes.c:polyBB */
35
- function fixedShapeSize(poly: PolygonT): { w: number; h: number } {
35
+ export function fixedShapeSize(poly: PolygonT): { w: number; h: number } {
36
36
  let xmax = 0;
37
37
  let ymax = 0;
38
38
  for (const v of poly.vertices ?? []) {
@@ -50,13 +50,10 @@ interface InsideScale {
50
50
  boxURy: number;
51
51
  }
52
52
 
53
- /** Node size + outline extents (points) for the inside scale. */
54
- function insideDims(n: Node, poly: PolygonT, xsize: number, ysize: number):
53
+ /** Node size + outline extents (points) for the inside scale (non-fixedshape;
54
+ * the fixedshape branch short-circuits in insideScale). */
55
+ function insideDims(n: Node, xsize: number, ysize: number):
55
56
  { w: number; h: number; ow: number; oh: number } {
56
- if (poly.option.fixedshape) {
57
- const { w, h } = fixedShapeSize(poly);
58
- return { w, h, ow: w, oh: h };
59
- }
60
57
  const w = 72 * (n.info.width || xsize / 72);
61
58
  const h = 72 * (n.info.height || ysize / 72);
62
59
  // Nodes that skipped poly_init (no measurer) have no outline dims.
@@ -67,10 +64,26 @@ function insideDims(n: Node, poly: PolygonT, xsize: number, ysize: number):
67
64
  function insideScale(n: Node, poly: PolygonT): InsideScale {
68
65
  // C swaps the node extents for flipped (LR/RL) layouts. @see poly_inside (GD_flip)
69
66
  const flip = n.root.info.flip === true;
67
+ // C's fixedshape branch derives xsize/ysize from the polyBB shape box
68
+ // itself (NOT ND_lw/rw/ht, which are label-grown there), so the scale
69
+ // collapses to 1 (swapped under flip) and the boundary is the pure vertex
70
+ // extent with no penwidth outline growth.
71
+ // @see shapes.c:poly_inside (option.fixedshape branch)
72
+ if (poly.option.fixedshape) {
73
+ const { w, h } = fixedShapeSize(poly);
74
+ const xsize = flip ? h : w;
75
+ const ysize = flip ? w : h;
76
+ return {
77
+ scalex: xsize !== 0 ? w / xsize : w,
78
+ scaley: ysize !== 0 ? h / ysize : h,
79
+ boxURx: w / 2,
80
+ boxURy: h / 2,
81
+ };
82
+ }
70
83
  const lwrw = n.info.lw + n.info.rw;
71
84
  const xsize = flip ? n.info.ht : lwrw;
72
85
  const ysize = flip ? lwrw : n.info.ht;
73
- const { w, h, ow, oh } = insideDims(n, poly, xsize, ysize);
86
+ const { w, h, ow, oh } = insideDims(n, xsize, ysize);
74
87
  return {
75
88
  scalex: xsize !== 0 ? w / xsize : w,
76
89
  scaley: ysize !== 0 ? h / ysize : h,
@@ -110,6 +110,14 @@ export interface PolySizeResult extends NodeSize {
110
110
  */
111
111
  baseW: number;
112
112
  baseH: number;
113
+ /**
114
+ * fixedsize="shape": the drawn shape stays at the attr box while
115
+ * width/height grow to max(label, shape). Installed on the polygon
116
+ * descriptor as `option.fixedshape`; vertex generation and insideness
117
+ * must read the base box, not the label-grown width.
118
+ * @see lib/common/shapes.c:poly_init (poly->option.fixedshape)
119
+ */
120
+ fixedshape: boolean;
113
121
  /**
114
122
  * Justification borders — the space available for the label within the node
115
123
  * box. `emit_label` reads this (not `dimen`) for `labelloc` top/bottom and
@@ -628,6 +636,7 @@ function assembleResult(
628
636
  outlineW: Math.max(dimen.x, grown.outline.x),
629
637
  outlineH: Math.max(dimen.y, grown.outline.y),
630
638
  baseW: grown.base.x, baseH: grown.base.y,
639
+ fixedshape,
631
640
  };
632
641
  }
633
642
  return {
@@ -635,5 +644,6 @@ function assembleResult(
635
644
  outlineW: grown.outline.x,
636
645
  outlineH: grown.outline.y,
637
646
  baseW: grown.base.x, baseH: grown.base.y,
647
+ fixedshape,
638
648
  };
639
649
  }
@@ -100,11 +100,32 @@ export function generalPolyVertices(poly: PolygonT, w: number, h: number): Point
100
100
  * which grows bb to the outermost ring after placing rings outward.
101
101
  * @see lib/common/shapes.c:poly_init (ellipse peripheries, :2173-2184)
102
102
  */
103
- function ellipseRings(w: number, h: number, peripheries: number): Point[] {
103
+ function ellipseRings(
104
+ w: number,
105
+ h: number,
106
+ peripheries: number,
107
+ base?: { w: number; h: number },
108
+ ): Point[] {
109
+ if (base === undefined) {
110
+ const out: Point[] = [];
111
+ for (let j = 0; j < peripheries; j++) {
112
+ const inset = (peripheries - 1 - j) * GAP;
113
+ out.push(...ellipseVertices(w - 2 * inset, h - 2 * inset));
114
+ }
115
+ return out;
116
+ }
117
+ // C's forward order: the base ring at the shape box, each further ring
118
+ // stepping the half-extent OUTWARD by GAP. Needed under fixedshape, where
119
+ // the stored width is label-grown and the inset-from-width form above
120
+ // cannot recover the shape box. @see lib/common/shapes.c:poly_init
121
+ // (sides < 3: vertices[0/1] = ±bb/2, then P.x += GAP per periphery)
104
122
  const out: Point[] = [];
123
+ let px = base.w / 2;
124
+ let py = base.h / 2;
105
125
  for (let j = 0; j < peripheries; j++) {
106
- const inset = (peripheries - 1 - j) * GAP;
107
- out.push(...ellipseVertices(w - 2 * inset, h - 2 * inset));
126
+ out.push({ x: -px, y: -py }, { x: px, y: py });
127
+ px += GAP;
128
+ py += GAP;
108
129
  }
109
130
  return out;
110
131
  }
@@ -221,13 +242,25 @@ export function computeVertices(
221
242
  base?: { w: number; h: number },
222
243
  ): Point[] {
223
244
  const sides = poly.sides;
245
+ // C builds vertices from the constrained shape box (bb) BEFORE the
246
+ // fixedshape branch sets ND_width/ND_height to max(label, bb)
247
+ // (shapes.c:poly_init tail), so under fixedsize="shape" the stored width
248
+ // is label-grown while the drawn shape stays at the attr box. Substitute
249
+ // the stored base box (= that bb) as the vertex source; every other case
250
+ // has width == shape box and is untouched.
251
+ if (poly.option.fixedshape && base !== undefined) {
252
+ w = base.w;
253
+ h = base.h;
254
+ }
224
255
  // Cylinder is a 19-point bezier control polygon, not a regular 19-gon.
225
256
  if (poly.option.shape === CYLINDER) return cylinderVertices(w, h);
226
257
  // C generates one ring even for peripheries=0 (outp >= 1); the draw
227
258
  // loop is what skips it. @see lib/common/shapes.c:poly_init (outp)
228
259
  const peripheries = Math.max(poly.peripheries, 1);
229
260
  if (poly.option.shape === STAR) return starRings({ w, h, base }, peripheries);
230
- if (sides <= 2) return ellipseRings(w, h, peripheries);
261
+ if (sides <= 2) {
262
+ return ellipseRings(w, h, peripheries, poly.option.fixedshape ? base : undefined);
263
+ }
231
264
  // C's isBox test: right angles only (diamond = orientation 45).
232
265
  if (sides === 4 && Math.abs(poly.orientation % 90) < 0.5
233
266
  && poly.distortion === 0 && poly.skew === 0) {
@@ -56,7 +56,11 @@ export function arrowheadPolygon(arrowTip: Point, arrowDir: Point, penwidth = 1.
56
56
  // ---------------------------------------------------------------------------
57
57
 
58
58
  /** Minimal edge shape exposing the arrow-related attributes. */
59
- interface ArrowAttrEdge { attrs: Map<string, string> }
59
+ interface ArrowAttrEdge {
60
+ attrs: Map<string, string>;
61
+ /** Present on real edges; the curve generator needs the rankdir frame. */
62
+ tail?: { root?: { info?: { rankdir?: number } } };
63
+ }
60
64
 
61
65
  /** The arrowhead/arrowtail name for an edge end (default "normal"). */
62
66
  export function edgeArrowName(e: ArrowAttrEdge, end: 'head' | 'tail'): string {
@@ -81,7 +85,8 @@ export function arrowDrawOpsForEnd(
81
85
  e: ArrowAttrEdge, end: 'head' | 'tail', tip: Point, dir: Point, penwidth: number,
82
86
  ): ArrowDrawOp[] {
83
87
  const comps = parseArrow(edgeArrowName(e, end)).map(resolveArrowType);
88
+ const rankdir = (e.tail?.root?.info?.rankdir ?? 0) & 0x3;
84
89
  // Pass the RAW direction (not normalized): componentU reproduces arrow_gen's
85
90
  // EPSILON normalization, which is relative to the raw shaft magnitude.
86
- return arrowDrawOps(comps, tip, dir, edgeArrowsize(e), penwidth);
91
+ return arrowDrawOps(comps, tip, dir, edgeArrowsize(e), penwidth, rankdir);
87
92
  }
@@ -15,6 +15,8 @@ import type { Graph } from '../../model/graph.js';
15
15
  import type { Node } from '../../model/node.js';
16
16
  import type { Edge as GraphEdge } from '../../model/edge.js';
17
17
  import { nodeAttr } from '../../common/poly-init.js';
18
+ import { fixedShapeSize } from '../../common/poly-inside.js';
19
+ import type { PolygonT } from '../../common/types.js';
18
20
  import type { Point, Bezier, Spline } from '../../model/geom.js';
19
21
  import { routeSpline } from '../../pathplan/route.js';
20
22
  import type { Edge as BarrierEdge } from '../../pathplan/types.js';
@@ -69,6 +71,23 @@ export function nodeBoxOf(n: Node, g: Graph): NodeBox {
69
71
  const rb = recordClipBox(n);
70
72
  if (rb) return { center: n.info.coord, ...rb, isEllipse: false, penwidth };
71
73
  }
74
+ // C poly_inside sizes the boundary from polyBB (the vertex extent) under
75
+ // fixedshape — ND_lw/rw/ht are label-grown there and must not clip the
76
+ // edge, and the boundary carries NO penwidth outline growth.
77
+ // @see lib/common/shapes.c:poly_inside (option.fixedshape branch)
78
+ const si = n.info.shape_info as PolygonT | undefined;
79
+ if (si?.option?.fixedshape === true && si.vertices) {
80
+ const ext = fixedShapeSize(si);
81
+ const flip = g.root.info.flip === true;
82
+ const w = flip ? ext.h : ext.w;
83
+ const h = flip ? ext.w : ext.h;
84
+ return {
85
+ center: n.info.coord,
86
+ lw: w / 2, rw: w / 2, ht: h,
87
+ isEllipse: ELLIPSE_SHAPES.has(shapeName),
88
+ penwidth: 0,
89
+ };
90
+ }
72
91
  return {
73
92
  center: n.info.coord,
74
93
  lw: n.info.lw > 0 ? n.info.lw : 27,
package/src/model/geom.ts CHANGED
@@ -144,6 +144,20 @@ export function ccwrotatepf(p: Point, ccwrot: number): Point {
144
144
  }
145
145
  }
146
146
 
147
+ /**
148
+ * Clockwise counterpart — the exact inverse of {@link ccwrotatepf} per case.
149
+ * @see lib/common/geom.c:cwrotatepf
150
+ */
151
+ export function cwrotatepf(p: Point, cwrot: number): Point {
152
+ switch (cwrot) {
153
+ case 0: return p;
154
+ case 90: return { x: p.y, y: -p.x };
155
+ case 180: return { x: p.x, y: -p.y };
156
+ case 270: return { x: p.y, y: p.x };
157
+ default: return p;
158
+ }
159
+ }
160
+
147
161
  /**
148
162
  * Returns true if box `b0` completely contains box `b1`.
149
163
  *