ntk 6.6.0 → 6.7.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/lib/index.js CHANGED
@@ -36,6 +36,7 @@ import {
36
36
  defaultRasterizer,
37
37
  setDefaultRasterizer
38
38
  } from './rasterize.js';
39
+ import { DEFAULT_SHAPE_POLICY } from './shapeglyphs.js';
39
40
  import { TextLayout } from './text/layout.js';
40
41
  import HtmlView from './widgets/htmlview.js';
41
42
  import SvgView from './widgets/svgview.js';
@@ -214,6 +215,7 @@ export {
214
215
  defaultRasterizer,
215
216
  setDefaultRasterizer,
216
217
  DEFAULT_RASTER_POLICY,
218
+ DEFAULT_SHAPE_POLICY,
217
219
  TextLayout,
218
220
  HtmlView,
219
221
  SvgView,
package/lib/path.js CHANGED
@@ -45,6 +45,100 @@ export function matIsIdentity(m) {
45
45
  return m[0] === 1 && m[1] === 0 && m[2] === 0 && m[3] === 1 && m[4] === 0 && m[5] === 0;
46
46
  }
47
47
 
48
+ // --------------------------------------------------------------------------
49
+ // arc tags
50
+ //
51
+ // A cubic emitted by ellipseCubics carries the arc it approximates, so the
52
+ // flattener can subdivide the arc itself instead of rediscovering its shape
53
+ // by bisecting the cubic (issue #213). The tag is the centre-plus-axis-
54
+ // vectors form:
55
+ //
56
+ // P(t) = (cx, cy) + u·cos t + v·sin t, t from t0 to t1
57
+ //
58
+ // u and v are the semi-axis *vectors*, which is what makes the tag survive
59
+ // transforms: an affine map takes this form to the same form — the centre
60
+ // moves, u and v go through the linear part — where radii plus a rotation
61
+ // angle would only survive a similarity, and a shear or a non-uniform scale
62
+ // would have to drop the tag and fall back.
63
+ //
64
+ // The tag also carries its own start point (sx, sy), which the lowering
65
+ // computes anyway. Flattening a cubic starts from the path's current point,
66
+ // so the arc route is only valid when that point is where the arc begins —
67
+ // true of every way a tagged cubic can be built here, and checked rather
68
+ // than assumed, since a path that broke the invariant would otherwise be
69
+ // drawn quietly wrong instead of falling back to bisection.
70
+ //
71
+ // Tags are treated as immutable: transformArc always builds a new one, so
72
+ // Path2D copies and addPath can share them by reference.
73
+
74
+ /** the flatness tolerance, in output pixels, everything here defaults to */
75
+ export const FLATTEN_TOLERANCE = 0.25;
76
+
77
+ // how many chords an arc may be split into, whatever the arithmetic says.
78
+ // Only reachable through a degenerate tolerance (0 or negative); a real
79
+ // drawing at tol 0.25 asks for ~800 chords for a full circle the size of
80
+ // the largest addressable surface.
81
+ const MAX_ARC_SEGMENTS = 4096;
82
+
83
+ /**
84
+ * The fewest equal chords that approximate an arc within `tol`.
85
+ *
86
+ * A chord spanning angle θ of a circle of radius R misses the arc by the
87
+ * sagitta R·(1 - cos(θ/2)) at its midpoint, so the largest angle a chord may
88
+ * span is 2·acos(1 - tol/R) and the count follows by division. Recursive
89
+ * bisection can only land on powers of two and so overshoots this by up to
90
+ * 2x — an arc needing 9 chords used to get 16.
91
+ *
92
+ * @param {number} sweep total angle covered, radians (unsigned)
93
+ * @param {number} radius the arc's radius — for an ellipse, the largest
94
+ * singular value of its axis matrix (see arcScale), which bounds the
95
+ * sagitta everywhere on the curve
96
+ * @param {number} tol allowed deviation, in the same units as radius
97
+ */
98
+ export function arcSegmentCount(sweep, radius, tol = FLATTEN_TOLERANCE) {
99
+ if (!(sweep > 0) || !(radius > 0)) return 1;
100
+ // tol >= 2R: the whole circle is within tolerance of a single chord
101
+ const cos = 1 - tol / radius;
102
+ if (!(cos > -1)) return 1;
103
+ const theta = 2 * Math.acos(Math.min(1, cos));
104
+ if (!(theta > 0)) return MAX_ARC_SEGMENTS; // tol <= 0: as fine as allowed
105
+ return Math.max(1, Math.min(MAX_ARC_SEGMENTS, Math.ceil(sweep / theta)));
106
+ }
107
+
108
+ /**
109
+ * The radius to measure an arc tag's sagitta against: the largest singular
110
+ * value of `[u | v]`, i.e. how far the map from the unit circle can stretch
111
+ * a distance. For a circle that is exactly its radius; for an ellipse it is
112
+ * the semi-major axis, which bounds the deviation everywhere on the curve
113
+ * (|A·d| <= σmax·|d|) and so is safe, if conservative on eccentric ones.
114
+ *
115
+ * σ1² + σ2² = ‖A‖_F² and σ1·σ2 = |det A| give it in closed form.
116
+ */
117
+ function arcScale({ ux, uy, vx, vy }) {
118
+ const frob = ux * ux + uy * uy + vx * vx + vy * vy;
119
+ const det = ux * vy - uy * vx;
120
+ const disc = Math.sqrt(Math.max(0, frob * frob - 4 * det * det));
121
+ return Math.sqrt((frob + disc) / 2);
122
+ }
123
+
124
+ /** an arc tag through an affine map — the centre and start point move, the
125
+ * axes go through the linear part; exact for every affine, shear and
126
+ * reflection included */
127
+ function transformArc(a, m) {
128
+ return {
129
+ cx: m[0] * a.cx + m[2] * a.cy + m[4],
130
+ cy: m[1] * a.cx + m[3] * a.cy + m[5],
131
+ ux: m[0] * a.ux + m[2] * a.uy,
132
+ uy: m[1] * a.ux + m[3] * a.uy,
133
+ vx: m[0] * a.vx + m[2] * a.vy,
134
+ vy: m[1] * a.vx + m[3] * a.vy,
135
+ sx: m[0] * a.sx + m[2] * a.sy + m[4],
136
+ sy: m[1] * a.sx + m[3] * a.sy + m[5],
137
+ t0: a.t0,
138
+ t1: a.t1
139
+ };
140
+ }
141
+
48
142
  // --------------------------------------------------------------------------
49
143
  // elliptical arcs -> cubics
50
144
 
@@ -53,6 +147,9 @@ export function matIsIdentity(m) {
53
147
  * radii (rx, ry), rotated by phi, from angle a0 sweeping by da (signed,
54
148
  * |da| <= 2π). Returns { start: {x, y}, cmds: [{type:'C', ...}, ...] };
55
149
  * segments are split to <= 90° so the approximation error stays tiny.
150
+ *
151
+ * Each command carries an `arc` tag (see above) describing the piece of the
152
+ * true arc it stands for, which is what `flattenPath` subdivides.
56
153
  */
57
154
  export function ellipseCubics(cx, cy, rx, ry, phi, a0, da) {
58
155
  const cosPhi = Math.cos(phi);
@@ -68,6 +165,12 @@ export function ellipseCubics(cx, cy, rx, ry, phi, a0, da) {
68
165
  return [cosPhi * x - sinPhi * y, sinPhi * x + cosPhi * y];
69
166
  };
70
167
 
168
+ // the ellipse's semi-axis vectors, the form the arc tag keeps
169
+ const ux = rx * cosPhi;
170
+ const uy = rx * sinPhi;
171
+ const vx = -ry * sinPhi;
172
+ const vy = ry * cosPhi;
173
+
71
174
  const [sx, sy] = point(a0);
72
175
  const cmds = [];
73
176
  const n = Math.max(1, Math.ceil(Math.abs(da) / (Math.PI / 2)));
@@ -87,7 +190,8 @@ export function ellipseCubics(cx, cy, rx, ry, phi, a0, da) {
87
190
  x2: x3 - k * dx3,
88
191
  y2: y3 - k * dy3,
89
192
  x: x3,
90
- y: y3
193
+ y: y3,
194
+ arc: { cx, cy, ux, uy, vx, vy, sx: x0, sy: y0, t0: a, t1: b }
91
195
  });
92
196
  a = b;
93
197
  }
@@ -416,12 +520,22 @@ export class Path2D {
416
520
  this._y = null;
417
521
  this._sx = null; // subpath start
418
522
  this._sy = null;
523
+ // When the whole path is exactly one roundRect(), this records what
524
+ // roundRect knew — { x, y, w, h, radii: [tl, tr, br, bl] } with the
525
+ // radii already normalized — so the 2d context can recognize the shape
526
+ // and route it to corner glyphs + FillRectangles instead of polygon
527
+ // rasterization (issue #211). Any other path verb clears it. The 2d
528
+ // context parks a bail-out reason in _roundRectMiss when it records a
529
+ // box the fast path can never take (e.g. under rotation).
530
+ this._roundRect = null;
531
+ this._roundRectMiss = null;
419
532
  if (init instanceof Path2D) {
420
533
  this._cmds = init._cmds.map((c) => ({ ...c }));
421
534
  this._x = init._x;
422
535
  this._y = init._y;
423
536
  this._sx = init._sx;
424
537
  this._sy = init._sy;
538
+ this._roundRect = init._roundRect;
425
539
  } else if (typeof init === 'string') {
426
540
  this._append(parseSvgPath(init));
427
541
  }
@@ -443,6 +557,10 @@ export class Path2D {
443
557
  }
444
558
 
445
559
  _append(cmds) {
560
+ // every mutation funnels through here; a path that is no longer exactly
561
+ // one roundRect() loses the tag (roundRect itself re-sets it at the end)
562
+ this._roundRect = null;
563
+ this._roundRectMiss = null;
446
564
  for (const c of cmds) {
447
565
  this._cmds.push(c);
448
566
  this._track(c);
@@ -532,7 +650,18 @@ export class Path2D {
532
650
  }
533
651
 
534
652
  roundRect(x, y, w, h, radii) {
535
- if (typeof radii === 'number' && radii === 0) return this.rect(x, y, w, h);
653
+ // The tag is set only when this call is the whole path: recorded after
654
+ // the geometry lands (the builder calls below clear it via _append) and
655
+ // only if the path held nothing before.
656
+ const wasEmpty = this._cmds.length === 0;
657
+ if (typeof radii === 'number' && radii === 0) {
658
+ this.rect(x, y, w, h);
659
+ if (wasEmpty && w >= 0 && h >= 0) {
660
+ const zero = { x: 0, y: 0 };
661
+ this._roundRect = { x, y, w, h, radii: [zero, zero, zero, zero] };
662
+ }
663
+ return;
664
+ }
536
665
  const [tl, tr, br, bl] = normalizeRadii(Math.abs(w), Math.abs(h), radii);
537
666
  if (w < 0 || h < 0) {
538
667
  // degenerate: fall back to a plain rect on flipped geometry
@@ -549,6 +678,7 @@ export class Path2D {
549
678
  if (tl.x || tl.y) this.ellipse(x + tl.x, y + tl.y, tl.x, tl.y, 0, Math.PI, Math.PI * 1.5);
550
679
  this.closePath();
551
680
  this.moveTo(x, y);
681
+ if (wasEmpty) this._roundRect = { x, y, w, h, radii: [tl, tr, br, bl] };
552
682
  }
553
683
 
554
684
  /** append another path, optionally transformed ([a,b,c,d,e,f] or {a..f}) */
@@ -578,7 +708,12 @@ export function transformCommands(cmds, m) {
578
708
  const [x1, y1] = matApply(m, c.x1, c.y1);
579
709
  const [x2, y2] = matApply(m, c.x2, c.y2);
580
710
  const [x, y] = matApply(m, c.x, c.y);
581
- out.push({ type: 'C', x1, y1, x2, y2, x, y });
711
+ const out_ = { type: 'C', x1, y1, x2, y2, x, y };
712
+ // an arc tag transforms exactly under any affine, so baking the CTM
713
+ // into the commands (which is what the context's default path does)
714
+ // keeps the arc route available downstream
715
+ if (c.arc) out_.arc = transformArc(c.arc, m);
716
+ out.push(out_);
582
717
  break;
583
718
  }
584
719
  case 'Q': {
@@ -604,7 +739,6 @@ function addCubic(pts, x0, y0, x1, y1, x2, y2, x3, y3, tol2, depth) {
604
739
  // cross products = (distance of control point from the chord) * |chord|
605
740
  const d1 = (x1 - x0) * dy - (y1 - y0) * dx;
606
741
  const d2 = (x2 - x0) * dy - (y2 - y0) * dx;
607
- const err = (Math.abs(d1) + Math.abs(d2)) ** 2;
608
742
  let flat;
609
743
  if (chord2 < 1e-9) {
610
744
  // degenerate chord: flat only if the control points are also close
@@ -612,7 +746,12 @@ function addCubic(pts, x0, y0, x1, y1, x2, y2, x3, y3, tol2, depth) {
612
746
  const c2 = (x2 - x0) ** 2 + (y2 - y0) ** 2;
613
747
  flat = Math.max(c1, c2) <= tol2;
614
748
  } else {
615
- // (dist1 + dist2)² <= tol² — absolute flatness in output pixels
749
+ // The curve's distance from its chord is bounded by (3/4)·max(dist1,
750
+ // dist2): B(t) - chord(t) = 3t(1-t)·((1-t)·D1 + t·D2), and 3t(1-t)
751
+ // peaks at 3/4. Testing dist1 + dist2 here (as this used to) is up to
752
+ // 8/3 stricter than that bound on symmetric cubics — every arc — which
753
+ // over-subdivided a quarter circle ~2x at any radius (issue #213).
754
+ const err = (0.75 * Math.max(Math.abs(d1), Math.abs(d2))) ** 2;
616
755
  flat = err <= tol2 * chord2;
617
756
  }
618
757
  if (flat || depth >= 18) {
@@ -636,14 +775,64 @@ function addCubic(pts, x0, y0, x1, y1, x2, y2, x3, y3, tol2, depth) {
636
775
  addCubic(pts, mx, my, bcx, bcy, cx, cy, x3, y3, tol2, depth + 1);
637
776
  }
638
777
 
778
+ /**
779
+ * Chords of the arc a tagged cubic stands for, appended to `pts`. Returns
780
+ * false — flatten the cubic instead — when the path's current point is not
781
+ * where the arc begins, so a caller that spliced commands together still
782
+ * gets a curve from where it actually is.
783
+ *
784
+ * The count comes from the sagitta formula rather than from bisecting the
785
+ * cubic, so an arc gets the fewest chords its tolerance allows instead of
786
+ * the next power of two (issue #213). The points are evaluated on the arc
787
+ * itself — closer to the geometry the caller asked for than the cubic
788
+ * proxy, whose own error is ~2.7e-4·R for the <=90° pieces used here.
789
+ *
790
+ * The last point is the command's own endpoint rather than a re-evaluated
791
+ * one, so consecutive segments still meet exactly, and an SVG arc whose
792
+ * final point was snapped to the requested endpoint keeps that snap.
793
+ */
794
+ function addArcChords(pts, cmd, m, tol, x0, y0) {
795
+ const arc = m ? transformArc(cmd.arc, m) : cmd.arc;
796
+ const scale = arcScale(arc);
797
+ // The two agree bit-for-bit when the lowering produced both, so this only
798
+ // fires on spliced paths — and on the ~1e-13 drift where an SVG arc's
799
+ // endpoint was snapped and the next arc starts from the snapped value.
800
+ const slack = 1e-6 * (1 + scale);
801
+ if (Math.abs(x0 - arc.sx) > slack || Math.abs(y0 - arc.sy) > slack) {
802
+ return false;
803
+ }
804
+ const sweep = arc.t1 - arc.t0;
805
+ const n = arcSegmentCount(Math.abs(sweep), scale, tol);
806
+ for (let i = 1; i < n; i++) {
807
+ const t = arc.t0 + (sweep * i) / n;
808
+ const cos = Math.cos(t);
809
+ const sin = Math.sin(t);
810
+ pts.push(
811
+ arc.cx + arc.ux * cos + arc.vx * sin,
812
+ arc.cy + arc.uy * cos + arc.vy * sin
813
+ );
814
+ }
815
+ if (m) {
816
+ const [x, y] = matApply(m, cmd.x, cmd.y);
817
+ pts.push(x, y);
818
+ } else {
819
+ pts.push(cmd.x, cmd.y);
820
+ }
821
+ return true;
822
+ }
823
+
639
824
  /**
640
825
  * Flatten normalized commands into polylines. `m` (optional affine) is
641
826
  * applied to control points before subdivision, so the flatness tolerance
642
827
  * `tol` (default 0.25) is measured in output/device pixels.
643
828
  *
829
+ * Cubics that came from an arc carry a tag and are subdivided from their own
830
+ * geometry (see addArcChords); genuine beziers — hand-built paths, SVG curve
831
+ * commands, font outlines — take the adaptive bisection route.
832
+ *
644
833
  * @returns {Array<{pts: number[], closed: boolean}>} flat [x0,y0,x1,y1,…]
645
834
  */
646
- export function flattenPath(cmds, m = null, tol = 0.25) {
835
+ export function flattenPath(cmds, m = null, tol = FLATTEN_TOLERANCE) {
647
836
  if (m && matIsIdentity(m)) m = null;
648
837
  const tol2 = tol * tol;
649
838
  const polys = [];
@@ -668,6 +857,7 @@ export function flattenPath(cmds, m = null, tol = 0.25) {
668
857
  if (!pts) break;
669
858
  const x0 = pts.pts[pts.pts.length - 2];
670
859
  const y0 = pts.pts[pts.pts.length - 1];
860
+ if (c.arc && addArcChords(pts.pts, c, m, tol, x0, y0)) break;
671
861
  const [x1, y1] = p(c.x1, c.y1);
672
862
  const [x2, y2] = p(c.x2, c.y2);
673
863
  const [x, y] = p(c.x, c.y);
@@ -13,6 +13,7 @@ import {
13
13
  } from "./imagedata.js";
14
14
  import {
15
15
  Path2D,
16
+ arcSegmentCount,
16
17
  flattenPath,
17
18
  transformCommands,
18
19
  ellipseSegments,
@@ -25,7 +26,24 @@ import {
25
26
  import Picture from "./picture.js";
26
27
  import Pixmap from "./pixmap.js";
27
28
  import { routeRaster } from "./rasterize.js";
28
- import { compositeTraps, drawGlyphRuns } from "./text/glyphs.js";
29
+ import {
30
+ BL,
31
+ BR,
32
+ TL,
33
+ TR,
34
+ cornerKey,
35
+ countShapeHit,
36
+ countShapeMiss,
37
+ getShapeGlyphPage,
38
+ roundRectBandRects,
39
+ shapePolicyOf,
40
+ trimShapeGlyphs,
41
+ } from "./shapeglyphs.js";
42
+ import {
43
+ compositeTraps,
44
+ drawGlyphRuns,
45
+ encodeGlyphItems,
46
+ } from "./text/glyphs.js";
29
47
  import { TextLayout } from "./text/layout.js";
30
48
  import { reorderRuns } from "./text/shape.js";
31
49
  import { trapezoidize } from "./trapezoid.js";
@@ -245,6 +263,10 @@ class RenderingContext2d {
245
263
 
246
264
  this._path = new Path2D();
247
265
  this._m = [1, 0, 0, 1, 0, 0];
266
+ // Rounded-rect fast-path observability (issue #211): every bail-out is
267
+ // a silent perf cliff, so hits and misses-by-reason are always counted.
268
+ // NTK_DEBUG_SHAPES=1 prints the process-wide aggregate at exit.
269
+ this.shapeStats = { hits: 0, misses: {} };
248
270
  this._stack = [];
249
271
  this._clips = []; // [{ polys, rule }] in device space, already stacked
250
272
  this._gco = "source-over";
@@ -653,7 +675,28 @@ class RenderingContext2d {
653
675
  roundRect(x, y, w, h, radii) {
654
676
  const tmp = new Path2D();
655
677
  tmp.roundRect(x, y, w, h, radii);
678
+ const wasEmpty = this._path._cmds.length === 0;
656
679
  this._path.addPath(tmp, this._m);
680
+ // Re-derive the recognition tag in device space (addPath cleared it): it
681
+ // survives only when this roundRect is the whole path and the CTM is a
682
+ // pure translation at record time — the default path bakes the transform
683
+ // into its commands here, so this is the last moment the box exists.
684
+ if (!tmp._roundRect || !wasEmpty) return;
685
+ const m = this._m;
686
+ if (m[0] === 1 && m[1] === 0 && m[2] === 0 && m[3] === 1) {
687
+ const t = tmp._roundRect;
688
+ this._path._roundRect = {
689
+ x: t.x + m[4],
690
+ y: t.y + m[5],
691
+ w: t.w,
692
+ h: t.h,
693
+ radii: t.radii,
694
+ };
695
+ } else {
696
+ // a box recorded under rotation/scale can never take the glyph route;
697
+ // leave the reason behind so fill()/stroke() count the bail-out
698
+ this._path._roundRectMiss = "transform";
699
+ }
657
700
  }
658
701
 
659
702
  // args: ([path], [fillRule]) — returns device-space polys + rule
@@ -933,12 +976,29 @@ class RenderingContext2d {
933
976
  const thickness = this.lineWidth * scale;
934
977
  const roundCap = this.lineCap === "round";
935
978
  const roundJoin = this.lineJoin === "round";
979
+ const cap = LINE_CAP[this.lineCap] || "butt";
980
+ const join = LINE_JOIN[this.lineJoin] || "miter";
936
981
  const stroke = extrudePolyline({
937
982
  thickness,
938
- cap: LINE_CAP[this.lineCap] || "butt",
939
- join: LINE_JOIN[this.lineJoin] || "miter",
983
+ cap,
984
+ join,
940
985
  miterLimit: this.miterLimit,
941
986
  });
987
+ // A closed subpath has no ends, so the canvas spec gives it no caps.
988
+ // That cannot share the extruder above: with lineCap 'square'
989
+ // extrude-polyline pushes the first and last points outward along the
990
+ // line, which on a closed loop extends the seam over band it already
991
+ // covers — invisible against an opaque colour, a double-blended edge
992
+ // against a translucent one.
993
+ const closedStroke =
994
+ cap === "butt"
995
+ ? stroke
996
+ : extrudePolyline({
997
+ thickness,
998
+ cap: "butt",
999
+ join,
1000
+ miterLimit: this.miterLimit,
1001
+ });
942
1002
  // dash distances are user-space lengths; scale them like the line width
943
1003
  const dash = this._lineDash.length
944
1004
  ? this._lineDash.map((d) => d * scale)
@@ -952,7 +1012,12 @@ class RenderingContext2d {
952
1012
  // cap, and a disk at an interior vertex fills the bevel notch
953
1013
  let hasRound = false;
954
1014
  const r = thickness / 2;
955
- const diskSegs = Math.max(8, Math.min(32, Math.ceil(r * 2)));
1015
+ // A disk is an arc like any other: the sagitta formula sizes it to the
1016
+ // flatness tolerance instead of a floor of 8 (which spent 16 triangles
1017
+ // on the half-pixel cap of a 1px line) and a ceiling of 32 (which was
1018
+ // coarse enough to show on a fat one). Three is the fewest that
1019
+ // enclose any area at all.
1020
+ const diskSegs = Math.max(3, arcSegmentCount(2 * Math.PI, r));
956
1021
  const addDisk = (x, y) => {
957
1022
  hasRound = true;
958
1023
  let ex = x + r;
@@ -988,18 +1053,35 @@ class RenderingContext2d {
988
1053
  return;
989
1054
  }
990
1055
  if (pts.length < 2) return;
991
- const mesh = stroke.build(pts);
1056
+ if (closed && pts.length >= 3) {
1057
+ // Move the seam to the middle of the first edge. extrude-polyline
1058
+ // treats every polyline as open — it butt-extrudes both ends and
1059
+ // emits no join between them — so a closed loop lost the join at
1060
+ // whichever vertex it happened to start on: a stroked rectangle
1061
+ // came out with three square corners and a notched one, the notch
1062
+ // being a quarter of the line width square. Cutting the loop in
1063
+ // the middle of a straight edge instead leaves the two ends
1064
+ // collinear, so they meet exactly, and makes every real vertex
1065
+ // interior, so extrude-polyline gives each the join it asked for
1066
+ // — miter, bevel and round alike.
1067
+ const mid = [
1068
+ (pts[0][0] + pts[1][0]) / 2,
1069
+ (pts[0][1] + pts[1][1]) / 2,
1070
+ ];
1071
+ pts = [mid, ...pts.slice(1), mid];
1072
+ }
1073
+ const mesh = (closed ? closedStroke : stroke).build(pts);
992
1074
  for (const tri of mesh.cells) {
993
1075
  for (let i = 0; i < 3; ++i) {
994
1076
  tris.push(mesh.positions[tri[i]][0], mesh.positions[tri[i]][1]);
995
1077
  }
996
1078
  }
997
1079
  if (roundJoin) {
1080
+ // every real vertex is interior now, the seam included: a closed
1081
+ // run was rotated to break at a collinear point, which needs no
1082
+ // join disk of its own
998
1083
  for (let i = 1; i < pts.length - 1; i++)
999
1084
  maybeJoinDisk(pts[i - 1], pts[i], pts[i + 1]);
1000
- // the seam of a closed loop is a join too (pts[0] === pts[last])
1001
- if (closed && pts.length > 2)
1002
- maybeJoinDisk(pts[pts.length - 2], pts[0], pts[1]);
1003
1085
  }
1004
1086
  if (roundCap && !closed) {
1005
1087
  addDisk(pts[0][0], pts[0][1]);
@@ -1428,17 +1510,34 @@ class RenderingContext2d {
1428
1510
  }
1429
1511
 
1430
1512
  strokeRect(x, y, w, h) {
1513
+ const m = this._m;
1514
+ if (m[0] === 1 && m[1] === 0 && m[2] === 0 && m[3] === 1) {
1515
+ const zero = { x: 0, y: 0 };
1516
+ const box = {
1517
+ x: x + m[4],
1518
+ y: y + m[5],
1519
+ w,
1520
+ h,
1521
+ radii: [zero, zero, zero, zero],
1522
+ };
1523
+ if (this._tryStrokeBox(box)) return;
1524
+ } else {
1525
+ this._shapeMiss("transform");
1526
+ }
1431
1527
  const tmp = new Path2D();
1432
1528
  tmp.rect(x, y, w, h);
1433
1529
  this._strokePolys(flattenPath(tmp._cmds, this._m));
1434
1530
  }
1435
1531
 
1436
1532
  fill(...args) {
1533
+ if (this._tryRoundRectFill(args)) return;
1437
1534
  const { polys, rule } = this._polysFor(args);
1438
1535
  this._fillPolys(polys, rule);
1439
1536
  }
1440
1537
 
1441
1538
  stroke(...args) {
1539
+ const box = this._shapeBoxFor(args);
1540
+ if (box && this._tryStrokeBox(box)) return;
1442
1541
  const polys =
1443
1542
  args[0] instanceof Path2D
1444
1543
  ? flattenPath(args[0]._cmds, this._m)
@@ -1446,6 +1545,342 @@ class RenderingContext2d {
1446
1545
  this._strokePolys(polys);
1447
1546
  }
1448
1547
 
1548
+ // ------------------------------------------------------------------
1549
+ // rounded-rect fast path (issue #211)
1550
+ //
1551
+ // A fill/stroke of an axis-aligned rounded rect on integer geometry is
1552
+ // emitted as corner glyphs + FillRectangles — the box's only curved ink
1553
+ // rides the glyph path, cached server-side after first use, and nothing
1554
+ // is rasterized or uploaded afterwards. The pieces partition the pixels
1555
+ // (glyphs own their integer-cut corner boxes, rects the strips between),
1556
+ // so translucent colours are safe with no mask and no accumulation. Every
1557
+ // condition below that fails falls through to the polygon route untouched,
1558
+ // counting the reason in `shapeStats`.
1559
+
1560
+ /** count one fast-path bail-out; always returns false for tail-calling */
1561
+ _shapeMiss(reason) {
1562
+ const misses = this.shapeStats.misses;
1563
+ misses[reason] = (misses[reason] || 0) + 1;
1564
+ countShapeMiss(reason);
1565
+ return false;
1566
+ }
1567
+
1568
+ /**
1569
+ * The device-space rounded-rect a fill/stroke argument list describes, or
1570
+ * null. The default path carries its tag in device space already (recorded
1571
+ * under a translate-only CTM); a Path2D argument is user-space, so the CTM
1572
+ * must be translate-only *now*.
1573
+ */
1574
+ _shapeBoxFor(args) {
1575
+ const path = args[0] instanceof Path2D ? args[0] : this._path;
1576
+ const tag = path._roundRect;
1577
+ if (path === this._path) {
1578
+ if (tag) return tag;
1579
+ // a roundRect that could not be tagged at record time left the reason
1580
+ if (path._roundRectMiss) this._shapeMiss(path._roundRectMiss);
1581
+ return null;
1582
+ }
1583
+ if (!tag) return null;
1584
+ const m = this._m;
1585
+ if (!(m[0] === 1 && m[1] === 0 && m[2] === 0 && m[3] === 1)) {
1586
+ this._shapeMiss("transform");
1587
+ return null;
1588
+ }
1589
+ return {
1590
+ x: tag.x + m[4],
1591
+ y: tag.y + m[5],
1592
+ w: tag.w,
1593
+ h: tag.h,
1594
+ radii: tag.radii,
1595
+ };
1596
+ }
1597
+
1598
+ /** premultiplied solid colour with globalAlpha folded in — scaling all
1599
+ * four components of a premultiplied colour is exactly what compositing
1600
+ * it at that opacity means */
1601
+ _foldedColor(style) {
1602
+ const c = parseColor(style);
1603
+ const a = this.globalAlpha;
1604
+ return a >= 1 ? c : [c[0] * a, c[1] * a, c[2] * a, c[3] * a];
1605
+ }
1606
+
1607
+ _tryRoundRectFill(args) {
1608
+ const box = this._shapeBoxFor(args);
1609
+ if (!box) return false;
1610
+ // maxRadius <= 0 is the documented off switch (NTK_NO_SHAPE_GLYPHS
1611
+ // sets it too): everything falls through, including zero-radius boxes
1612
+ const policy = shapePolicyOf(this.window.app);
1613
+ if (policy.maxRadius <= 0) return this._shapeMiss("radius-cap");
1614
+ // (a rounded rect is one simple closed curve, so the fill rule cannot
1615
+ // change what it covers — the rule argument needs no inspection)
1616
+ if (!isPlainColor(this._fillStyle)) return this._shapeMiss("gradient");
1617
+ if (this._gco !== "source-over") return this._shapeMiss("composite-op");
1618
+ const clip = this._clips.length ? this._clipRect() : null;
1619
+ if (this._clips.length && !clip) return this._shapeMiss("clip-mask");
1620
+
1621
+ const { x, y, w, h, radii } = box;
1622
+ if (
1623
+ !Number.isInteger(x) ||
1624
+ !Number.isInteger(y) ||
1625
+ !Number.isInteger(w) ||
1626
+ !Number.isInteger(h)
1627
+ ) {
1628
+ return this._shapeMiss("fractional");
1629
+ }
1630
+ for (const r of radii) {
1631
+ if (!Number.isInteger(r.x) || !Number.isInteger(r.y))
1632
+ return this._shapeMiss("fractional");
1633
+ if (r.x > policy.maxRadius || r.y > policy.maxRadius)
1634
+ return this._shapeMiss("radius-cap");
1635
+ }
1636
+ // wire coordinates are INT16, and glyph position deltas too
1637
+ if (
1638
+ x < -32768 ||
1639
+ y < -32768 ||
1640
+ x + w > 32767 ||
1641
+ y + h > 32767 ||
1642
+ w > 32767 ||
1643
+ h > 32767
1644
+ ) {
1645
+ return this._shapeMiss("geometry");
1646
+ }
1647
+ const [tl, tr, br, bl] = radii;
1648
+ // effective corner boxes — a corner with either radius 0 is square
1649
+ const c = {
1650
+ tlw: tl.x && tl.y ? tl.x : 0,
1651
+ tlh: tl.x && tl.y ? tl.y : 0,
1652
+ trw: tr.x && tr.y ? tr.x : 0,
1653
+ trh: tr.x && tr.y ? tr.y : 0,
1654
+ blw: bl.x && bl.y ? bl.x : 0,
1655
+ blh: bl.x && bl.y ? bl.y : 0,
1656
+ brw: br.x && br.y ? br.x : 0,
1657
+ brh: br.x && br.y ? br.y : 0,
1658
+ };
1659
+ // diagonally opposite corner boxes must not overlap or the partition
1660
+ // would paint pixels twice (same-edge pairs are bounded by the radii
1661
+ // normalization, but the diagonal is not)
1662
+ if (
1663
+ (c.tlw + c.brw > w && c.tlh + c.brh > h) ||
1664
+ (c.trw + c.blw > w && c.trh + c.blh > h)
1665
+ ) {
1666
+ return this._shapeMiss("geometry");
1667
+ }
1668
+ if (this.globalAlpha <= 0 || w <= 0 || h <= 0) {
1669
+ // nothing to draw — and the fallback would draw nothing too
1670
+ countShapeHit();
1671
+ this.shapeStats.hits++;
1672
+ return true;
1673
+ }
1674
+
1675
+ const corners = [];
1676
+ if (c.tlw) {
1677
+ corners.push({
1678
+ key: cornerKey("fill", c.tlw, c.tlh, 0, TL),
1679
+ kind: "fill", rx: c.tlw, ry: c.tlh, bw: 0, corner: TL,
1680
+ px: x, py: y,
1681
+ });
1682
+ }
1683
+ if (c.trw) {
1684
+ corners.push({
1685
+ key: cornerKey("fill", c.trw, c.trh, 0, TR),
1686
+ kind: "fill", rx: c.trw, ry: c.trh, bw: 0, corner: TR,
1687
+ px: x + w - c.trw, py: y,
1688
+ });
1689
+ }
1690
+ if (c.blw) {
1691
+ corners.push({
1692
+ key: cornerKey("fill", c.blw, c.blh, 0, BL),
1693
+ kind: "fill", rx: c.blw, ry: c.blh, bw: 0, corner: BL,
1694
+ px: x, py: y + h - c.blh,
1695
+ });
1696
+ }
1697
+ if (c.brw) {
1698
+ corners.push({
1699
+ key: cornerKey("fill", c.brw, c.brh, 0, BR),
1700
+ kind: "fill", rx: c.brw, ry: c.brh, bw: 0, corner: BR,
1701
+ px: x + w - c.brw, py: y + h - c.brh,
1702
+ });
1703
+ }
1704
+ const rects = roundRectBandRects(x, y, w, h, c);
1705
+ this._emitShapeGlyphs(corners, rects, this._foldedColor(this._fillStyle), clip);
1706
+ return true;
1707
+ }
1708
+
1709
+ /**
1710
+ * Stroke fast path for a device-space box (from a roundRect tag or
1711
+ * strokeRect). Beyond the fill's conditions: uniform lineWidth, no
1712
+ * dashes — and the half-pixel one that matters: the stroke band must land
1713
+ * on pixel boundaries. A 1px border drawn the correct way (path inset by
1714
+ * bw/2, so the band [X, X+1] sits on integers) passes; a 1px stroke on
1715
+ * integer path coordinates is genuinely a two-row 50% band and must keep
1716
+ * falling back, because the fast path would not reproduce it.
1717
+ */
1718
+ _tryStrokeBox(box) {
1719
+ // the same off switch as the fill's — see _tryRoundRectFill
1720
+ const policy = shapePolicyOf(this.window.app);
1721
+ if (policy.maxRadius <= 0) return this._shapeMiss("radius-cap");
1722
+ if (!isPlainColor(this._strokeStyle)) return this._shapeMiss("gradient");
1723
+ if (this._gco !== "source-over") return this._shapeMiss("composite-op");
1724
+ if (this._lineDash.length) return this._shapeMiss("dashes");
1725
+ const clip = this._clips.length ? this._clipRect() : null;
1726
+ if (this._clips.length && !clip) return this._shapeMiss("clip-mask");
1727
+
1728
+ const bw = this.lineWidth;
1729
+ if (!Number.isFinite(bw) || bw <= 0) return this._shapeMiss("geometry");
1730
+ const { x, y, w, h, radii } = box;
1731
+ if (!(w > 0 && h > 0)) return this._shapeMiss("geometry");
1732
+ // one circular radius for all four corners: the ring glyph is keyed on
1733
+ // a single (r, bw) and mixed or elliptical corners fall back
1734
+ const r0 = radii[0];
1735
+ for (const r of radii) {
1736
+ if (r.x !== r0.x || r.y !== r0.y) return this._shapeMiss("radii-mix");
1737
+ }
1738
+ if (r0.x !== r0.y) return this._shapeMiss("radii-mix");
1739
+ const r = r0.x;
1740
+
1741
+ const X0 = x - bw / 2; // outer band corner
1742
+ const Y0 = y - bw / 2;
1743
+ const W = w + bw; // outer band size
1744
+ const H = h + bw;
1745
+ if (
1746
+ !Number.isInteger(X0) ||
1747
+ !Number.isInteger(Y0) ||
1748
+ !Number.isInteger(W) ||
1749
+ !Number.isInteger(H) ||
1750
+ !Number.isInteger(r)
1751
+ ) {
1752
+ return this._shapeMiss("fractional");
1753
+ }
1754
+ if (
1755
+ X0 < -32768 ||
1756
+ Y0 < -32768 ||
1757
+ X0 + W > 32767 ||
1758
+ Y0 + H > 32767 ||
1759
+ W > 32767 ||
1760
+ H > 32767
1761
+ ) {
1762
+ return this._shapeMiss("geometry");
1763
+ }
1764
+ if (this.globalAlpha <= 0) {
1765
+ countShapeHit();
1766
+ this.shapeStats.hits++;
1767
+ return true;
1768
+ }
1769
+ const color = this._foldedColor(this._strokeStyle);
1770
+
1771
+ if (r === 0) {
1772
+ // square corners lower to four FillRectangles with no glyphs at all —
1773
+ // but only when the miter join actually squares them (the default);
1774
+ // bevel or round corners look different and keep the polygon route
1775
+ if (this.lineJoin !== "miter" || this.miterLimit < Math.SQRT2)
1776
+ return this._shapeMiss("join");
1777
+ if (W < 2 * bw || H < 2 * bw) return this._shapeMiss("geometry");
1778
+ const rects = [X0, Y0, W, bw, X0, Y0 + H - bw, W, bw];
1779
+ if (H > 2 * bw) {
1780
+ rects.push(X0, Y0 + bw, bw, H - 2 * bw);
1781
+ rects.push(X0 + W - bw, Y0 + bw, bw, H - 2 * bw);
1782
+ }
1783
+ this._emitShapeGlyphs([], rects, color, clip);
1784
+ return true;
1785
+ }
1786
+
1787
+ // a border thicker than the corner radius swallows the arc's centre;
1788
+ // the ring glyph does not model that, so it stays on the polygon route
1789
+ if (r - bw / 2 < 0) return this._shapeMiss("geometry");
1790
+ const K = Math.ceil(r + bw / 2); // corner glyph box, integer cut lines
1791
+ if (K > policy.maxRadius) return this._shapeMiss("radius-cap");
1792
+ if (2 * K > W || 2 * K > H) return this._shapeMiss("geometry");
1793
+
1794
+ const corners = [
1795
+ {
1796
+ key: cornerKey("stroke", r, r, bw, TL),
1797
+ kind: "stroke", rx: r, ry: r, bw, corner: TL,
1798
+ px: X0, py: Y0,
1799
+ },
1800
+ {
1801
+ key: cornerKey("stroke", r, r, bw, TR),
1802
+ kind: "stroke", rx: r, ry: r, bw, corner: TR,
1803
+ px: X0 + W - K, py: Y0,
1804
+ },
1805
+ {
1806
+ key: cornerKey("stroke", r, r, bw, BL),
1807
+ kind: "stroke", rx: r, ry: r, bw, corner: BL,
1808
+ px: X0, py: Y0 + H - K,
1809
+ },
1810
+ {
1811
+ key: cornerKey("stroke", r, r, bw, BR),
1812
+ kind: "stroke", rx: r, ry: r, bw, corner: BR,
1813
+ px: X0 + W - K, py: Y0 + H - K,
1814
+ },
1815
+ ];
1816
+ const rects = [];
1817
+ if (W > 2 * K) {
1818
+ rects.push(X0 + K, Y0, W - 2 * K, bw);
1819
+ rects.push(X0 + K, Y0 + H - bw, W - 2 * K, bw);
1820
+ }
1821
+ if (H > 2 * K) {
1822
+ rects.push(X0, Y0 + K, bw, H - 2 * K);
1823
+ rects.push(X0 + W - bw, Y0 + K, bw, H - 2 * K);
1824
+ }
1825
+ this._emitShapeGlyphs(corners, rects, color, clip);
1826
+ return true;
1827
+ }
1828
+
1829
+ /**
1830
+ * Emit one recognized box: ensure the corner glyphs, one CompositeGlyphs
1831
+ * run for them, one FillRectangles for the strips — inside a server-side
1832
+ * clip rectangle when the clip stack is rectangular (the way glyph runs
1833
+ * for text already do it).
1834
+ */
1835
+ _emitShapeGlyphs(corners, rects, color, clip) {
1836
+ countShapeHit();
1837
+ this.shapeStats.hits++;
1838
+ if (clip && (clip.w === 0 || clip.h === 0)) return; // clipped away
1839
+ const R = this.Render;
1840
+ const app = this.window.app;
1841
+ if (clip) {
1842
+ R.SetPictureClipRectangles(this.picture.id, 0, 0, [
1843
+ clip.x,
1844
+ clip.y,
1845
+ clip.w,
1846
+ clip.h,
1847
+ ]);
1848
+ }
1849
+ if (corners.length) {
1850
+ const page = getShapeGlyphPage(app);
1851
+ page.ensure(corners);
1852
+ const items = [];
1853
+ for (const spec of corners) {
1854
+ items.push({
1855
+ gs: page.glyphset.id,
1856
+ lid: page.entry(spec.key).lid,
1857
+ adv: 0,
1858
+ x: spec.px,
1859
+ y: spec.py,
1860
+ });
1861
+ }
1862
+ const encoded = encodeGlyphItems(items, page.bits);
1863
+ const src = app.solidPicture(color[0], color[1], color[2], color[3]);
1864
+ R.CompositeGlyphs(
1865
+ encoded.bits,
1866
+ R.PictOp.Over,
1867
+ src.id,
1868
+ this.picture.id,
1869
+ 0,
1870
+ encoded.gsid,
1871
+ items[0].x,
1872
+ items[0].y,
1873
+ encoded.elts,
1874
+ );
1875
+ trimShapeGlyphs(app);
1876
+ }
1877
+ if (rects.length) {
1878
+ R.FillRectangles(R.PictOp.Over, this.picture.id, color, rects);
1879
+ }
1880
+ if (clip) this._resetPictureClip();
1881
+ this._markDirty();
1882
+ }
1883
+
1449
1884
  /**
1450
1885
  * Intersect the clip region with the given path (or the current path).
1451
1886
  * Cleared by restore() to the state at the matching save() — like the
@@ -0,0 +1,327 @@
1
+ // Rounded-rectangle fast path (issue #211): the server-side corner-glyph
2
+ // cache and the geometry helpers behind it.
3
+ //
4
+ // A recognized `fill()`/`stroke()` of an axis-aligned rounded rect is not
5
+ // rasterized as a polygon at all: its only curved ink — the corners — is
6
+ // emitted as XRender *glyphs* (the one path every server accelerates, cached
7
+ // server-side after first use), and the straight runs between them as
8
+ // FillRectangles. The 2d context owns the recognition and the emission
9
+ // (lib/renderingcontext_2d.js); this module owns the cache and the corner
10
+ // bitmaps.
11
+ //
12
+ // The pieces partition the pixels: corner glyphs own their integer-cut
13
+ // boxes, rects own the strips between, and no pixel is painted twice — which
14
+ // is what makes translucent colours safe with no mask format and no
15
+ // accumulation tricks. Each corner rasterizes the true geometry inside its
16
+ // region through the same ellipse lowering and coverage accumulator the
17
+ // polygon fallback uses, so the two routes agree to the last antialiased
18
+ // step.
19
+ //
20
+ // Corners are the whole cache: keyed by (kind, rx, ry, borderWidth, corner),
21
+ // so width and height are *not* in the key — an animating progress bar or
22
+ // pill keeps all its glyphs while the rects stretch. The server additionally
23
+ // deduplicates glyph bitmaps across clients by content hash, so every app
24
+ // using the same radii shares storage.
25
+ //
26
+ // Mirrored corners: only the top-left corner of a (kind, rx, ry, bw) family
27
+ // is ever rasterized; the other three are byte-level mirror flips of that
28
+ // bitmap (see `orientCorner`). RENDER has no per-glyph transform, so the
29
+ // *server* still needs all four bitmaps — the flip saves the three extra
30
+ // rasterizations and makes the four corners of a box pixel-exact mirror
31
+ // images of each other, which direct per-corner rasterization does not
32
+ // guarantee (floating-point coverage accumulation is not symmetric under
33
+ // reflection).
34
+
35
+ import GlyphSet from './glyphset.js';
36
+ import { ellipseSegments, flattenPath } from './path.js';
37
+ import { rasterizePolys } from './rasterize.js';
38
+
39
+ /**
40
+ * Shape rendering policy, overridable per app via `app.shapePolicy`
41
+ * (partial objects are fine — `app.shapePolicy = { maxRadius: 0 }` disables
42
+ * the fast path entirely, which is also what `NTK_NO_SHAPE_GLYPHS=1` does).
43
+ *
44
+ * - `maxRadius` — corners with a radius (or stroke-band extent) above this
45
+ * fall back to the polygon route. Glyph-atlas behaviour past ~64px is
46
+ * driver-specific; the conservative default keeps every plausible widget
47
+ * inside and every atlas happy.
48
+ * - `cacheBytes` — budget for uploaded corner bitmaps per connection. A
49
+ * design system's whole population is 8 glyphs x r^2 bytes per (r, bw) —
50
+ * typically 10-20KB — so even this small budget is generous. Exceeding it
51
+ * (an adversarial animated-radius load) resets the page; steady states
52
+ * never do.
53
+ */
54
+ export const DEFAULT_SHAPE_POLICY = {
55
+ maxRadius: 64,
56
+ cacheBytes: 256 << 10
57
+ };
58
+
59
+ const envDisabled =
60
+ typeof process !== 'undefined' && process.env && process.env.NTK_NO_SHAPE_GLYPHS;
61
+
62
+ export function shapePolicyOf(app) {
63
+ const policy = app.shapePolicy
64
+ ? { ...DEFAULT_SHAPE_POLICY, ...app.shapePolicy }
65
+ : DEFAULT_SHAPE_POLICY;
66
+ return envDisabled ? { ...policy, maxRadius: 0 } : policy;
67
+ }
68
+
69
+ // corner codes, also the two flip bits: bit 0 = mirrored in x, bit 1 = in y
70
+ export const TL = 0;
71
+ export const TR = 1;
72
+ export const BL = 2;
73
+ export const BR = 3;
74
+
75
+ /** cache key of one corner glyph. `bw` is 0 for fills. */
76
+ export function cornerKey(kind, rx, ry, bw, corner) {
77
+ return `${kind}|${rx}|${ry}|${bw}|${corner}`;
78
+ }
79
+
80
+ /**
81
+ * Rasterize the top-left master bitmap of a corner family.
82
+ *
83
+ * kind 'fill': the quarter disc — the corner box is rx x ry with the ellipse
84
+ * centred on its bottom-right point; everything inside the arc has full
85
+ * coverage, so the box's straight cut edges land on interior pixels.
86
+ *
87
+ * kind 'stroke': the quarter ring — the stroke band around a corner of path
88
+ * radius `r = rx` with line width `bw`. The box is K x K, K = ceil(r + bw/2),
89
+ * anchored on the *outer* corner of the band; the ink is the annulus sector
90
+ * plus the straight band continuations out to the box edges, so the box
91
+ * edges cut the band where its pixels are fully covered whatever half-pixel
92
+ * the arc ends on (a 1px border's path sits on half-integers).
93
+ *
94
+ * The arc is lowered exactly the way the polygon fallback lowers it —
95
+ * `ellipseSegments` cubics flattened by `flattenPath` at the default
96
+ * tolerance, rasterized by the same coverage accumulator — so the fast path
97
+ * agrees with the fallback to the last antialiased step.
98
+ *
99
+ * @returns {{ w, h, stride, data: Uint8Array }} unpadded w x h coverage
100
+ */
101
+ export function rasterizeCornerMaster(kind, rx, ry, bw) {
102
+ let w;
103
+ let h;
104
+ let cmds;
105
+ if (kind === 'fill') {
106
+ w = rx;
107
+ h = ry;
108
+ const arc = ellipseSegments(rx, ry, rx, ry, 0, Math.PI, Math.PI * 1.5);
109
+ cmds = [
110
+ { type: 'M', x: arc.start.x, y: arc.start.y }, // (0, ry)
111
+ ...arc.cmds, // -> (rx, 0)
112
+ { type: 'L', x: rx, y: ry },
113
+ { type: 'Z' }
114
+ ];
115
+ } else {
116
+ const r = rx;
117
+ const ro = r + bw / 2;
118
+ const ri = r - bw / 2;
119
+ const K = Math.ceil(ro);
120
+ w = K;
121
+ h = K;
122
+ const outer = ellipseSegments(ro, ro, ro, ro, 0, Math.PI, Math.PI * 1.5);
123
+ // the inner arc runs backwards, closing the ring; at ri = 0 it collapses
124
+ // to the centre point and the ink is the whole quarter disc
125
+ const inner = ellipseSegments(ro, ro, ri, ri, 0, Math.PI * 1.5, Math.PI, true);
126
+ cmds = [
127
+ { type: 'M', x: 0, y: K },
128
+ { type: 'L', x: 0, y: ro },
129
+ ...outer.cmds, // -> (ro, 0)
130
+ { type: 'L', x: K, y: 0 },
131
+ { type: 'L', x: K, y: bw },
132
+ { type: 'L', x: inner.start.x, y: inner.start.y }, // (ro, bw)
133
+ ...inner.cmds, // -> (bw, ro)
134
+ { type: 'L', x: bw, y: K },
135
+ { type: 'Z' }
136
+ ];
137
+ }
138
+ const polys = [];
139
+ for (const poly of flattenPath(cmds)) if (poly.pts.length >= 6) polys.push(poly.pts);
140
+ const data = rasterizePolys(polys, w, h, 'nonzero');
141
+ return { w, h, stride: (w + 3) & ~3, data };
142
+ }
143
+
144
+ /**
145
+ * One oriented corner bitmap from the top-left master: a byte-level mirror
146
+ * flip, padded to the 4-byte row stride AddGlyphs wants. Flipping bytes
147
+ * rather than re-rasterizing mirrored geometry keeps the four corners exact
148
+ * mirror images and costs a copy instead of a coverage accumulation.
149
+ */
150
+ export function orientCorner(master, corner) {
151
+ const { w, h, stride, data } = master;
152
+ const out = Buffer.alloc(stride * h);
153
+ const flipX = (corner & 1) !== 0;
154
+ const flipY = (corner & 2) !== 0;
155
+ for (let y = 0; y < h; y++) {
156
+ const srow = (flipY ? h - 1 - y : y) * w;
157
+ const orow = y * stride;
158
+ if (flipX) {
159
+ for (let x = 0; x < w; x++) out[orow + x] = data[srow + (w - 1 - x)];
160
+ } else {
161
+ out.set(data.subarray(srow, srow + w), orow);
162
+ }
163
+ }
164
+ return out;
165
+ }
166
+
167
+ /**
168
+ * Server-side cache of corner glyphs — one page per app, all radii and
169
+ * border widths together, mirroring `GlyphPage` for text (text/glyphs.js).
170
+ *
171
+ * Local ids are compact and sequential so CompositeGlyphs uses the 8-bit
172
+ * encoding for the first 256 distinct corners (a shape population never
173
+ * leaves it in practice); new corners upload lazily in one AddGlyphs batch
174
+ * per draw that introduces any.
175
+ */
176
+ export class ShapeGlyphPage {
177
+ constructor(app) {
178
+ this.app = app;
179
+ this.glyphset = new GlyphSet(app);
180
+ this.entries = new Map(); // cornerKey -> { lid }
181
+ this.bytes = 0; // uploaded bitmap bytes, for the cache budget
182
+ }
183
+
184
+ /** bits per glyph id needed to address this page */
185
+ get bits() {
186
+ return this.entries.size <= 256 ? 8 : 16;
187
+ }
188
+
189
+ /**
190
+ * Ensure the corners of one drawing are uploaded. `specs` is
191
+ * [{ key, kind, rx, ry, bw, corner }] — a spec whose key is present costs
192
+ * a Map hit and nothing else. Within one batch the top-left master of each
193
+ * (kind, rx, ry, bw) family rasterizes once and the other corners mirror
194
+ * it (see `orientCorner`).
195
+ */
196
+ ensure(specs) {
197
+ let batch = null;
198
+ let masters = null;
199
+ for (const spec of specs) {
200
+ if (this.entries.has(spec.key)) continue;
201
+ const lid = this.entries.size;
202
+ if (lid >= 65536) {
203
+ // 2^16 corners on one connection — unreachable (the cache budget
204
+ // resets the page long before), but fail loudly rather than corrupt
205
+ throw new Error('shape glyph page overflow');
206
+ }
207
+ const mkey = `${spec.kind}|${spec.rx}|${spec.ry}|${spec.bw}`;
208
+ if (!masters) masters = new Map();
209
+ let master = masters.get(mkey);
210
+ if (!master) {
211
+ master = rasterizeCornerMaster(spec.kind, spec.rx, spec.ry, spec.bw);
212
+ masters.set(mkey, master);
213
+ }
214
+ const image = orientCorner(master, spec.corner);
215
+ this.entries.set(spec.key, { lid });
216
+ if (!batch) batch = [];
217
+ batch.push({
218
+ id: lid,
219
+ // width is the padded stride, like the text page: node-x11 then
220
+ // ships the buffer as-is instead of re-padding a copy, and the spare
221
+ // zero columns composite as no-ops under Over
222
+ width: master.stride,
223
+ height: master.h,
224
+ // x/y 0: the glyph's top-left lands exactly on its elt position
225
+ x: 0,
226
+ y: 0,
227
+ // corners do not advance a pen. node-x11 expects offX/offY in 26.6
228
+ // fixed point and mutates the objects it is given, which is why
229
+ // these are fresh one-shot objects (see AGENTS.md)
230
+ offX: 0,
231
+ offY: 0,
232
+ image
233
+ });
234
+ this.bytes += image.length;
235
+ }
236
+ if (batch) this.glyphset.addGlyphs(batch);
237
+ }
238
+
239
+ entry(key) {
240
+ return this.entries.get(key);
241
+ }
242
+
243
+ /** free the server-side glyphset (budget reset / shutdown) */
244
+ destroy() {
245
+ this.glyphset.destroy();
246
+ this.entries.clear();
247
+ this.bytes = 0;
248
+ }
249
+ }
250
+
251
+ /** the per-app page, created on first use */
252
+ export function getShapeGlyphPage(app) {
253
+ if (!app._shapeGlyphPage) app._shapeGlyphPage = new ShapeGlyphPage(app);
254
+ return app._shapeGlyphPage;
255
+ }
256
+
257
+ /**
258
+ * Enforce the cache budget after a draw. Corner populations are so small
259
+ * that per-glyph LRU would be bookkeeping for its own sake — over budget
260
+ * (an adversarial animated-radius load) the whole page resets and the
261
+ * corners still in use re-upload on the next draw, bounding server memory
262
+ * at `cacheBytes` plus one animation step. FreeGlyphSet is queued after any
263
+ * CompositeGlyphs already issued, so mid-frame resets are ordering-safe.
264
+ */
265
+ export function trimShapeGlyphs(app, policy = shapePolicyOf(app)) {
266
+ const page = app._shapeGlyphPage;
267
+ if (page && page.bytes > policy.cacheBytes) {
268
+ page.destroy();
269
+ app._shapeGlyphPage = null;
270
+ }
271
+ }
272
+
273
+ /**
274
+ * The strips of a rounded-rect fill that are not corner glyphs, as a flat
275
+ * FillRectangles list. Rows are cut at every corner-box edge; each band
276
+ * spans between the corner boxes that reach it. Uniform radii produce the
277
+ * classic 3 bands; fully general per-corner radii at most 5.
278
+ *
279
+ * `c` holds the effective corner box sizes { tlw, tlh, trw, trh, blw, blh,
280
+ * brw, brh } (zero for square corners). Caller guarantees same-edge sums fit
281
+ * (normalizeRadii does) and that diagonally opposite boxes do not overlap.
282
+ */
283
+ export function roundRectBandRects(x, y, w, h, c) {
284
+ const cuts = [...new Set([0, c.tlh, c.trh, h - c.blh, h - c.brh, h])]
285
+ .filter((v) => v >= 0 && v <= h)
286
+ .sort((a, b) => a - b);
287
+ const rects = [];
288
+ for (let i = 0; i + 1 < cuts.length; i++) {
289
+ const y0 = cuts[i];
290
+ const y1 = cuts[i + 1];
291
+ if (y1 <= y0) continue;
292
+ const left = Math.max(y0 < c.tlh ? c.tlw : 0, y1 > h - c.blh ? c.blw : 0);
293
+ const right = Math.max(y0 < c.trh ? c.trw : 0, y1 > h - c.brh ? c.brw : 0);
294
+ const bandW = w - left - right;
295
+ if (bandW > 0) rects.push(x + left, y + y0, bandW, y1 - y0);
296
+ }
297
+ return rects;
298
+ }
299
+
300
+ // process-wide observability: every bail-out is a silent perf cliff, so the
301
+ // counters are always collected (cheap increments) and printed at exit under
302
+ // NTK_DEBUG_SHAPES=1. Contexts carry their own copies for per-surface
303
+ // inspection (ctx.shapeStats).
304
+ export const shapeGlyphCounters = { hits: 0, misses: {} };
305
+
306
+ export function countShapeMiss(reason) {
307
+ shapeGlyphCounters.misses[reason] = (shapeGlyphCounters.misses[reason] || 0) + 1;
308
+ }
309
+
310
+ export function countShapeHit() {
311
+ shapeGlyphCounters.hits++;
312
+ }
313
+
314
+ if (
315
+ typeof process !== 'undefined' &&
316
+ process.env &&
317
+ process.env.NTK_DEBUG_SHAPES &&
318
+ typeof process.on === 'function'
319
+ ) {
320
+ process.on('exit', () => {
321
+ const { hits, misses } = shapeGlyphCounters;
322
+ const parts = Object.entries(misses).map(([k, v]) => `${k}: ${v}`);
323
+ console.error(
324
+ `ntk shapes: ${hits} fast-path draws, misses { ${parts.join(', ')} }`
325
+ );
326
+ });
327
+ }
package/lib/window.js CHANGED
@@ -348,6 +348,9 @@ export default class Window extends Drawable {
348
348
  this._foreign = !!args.id;
349
349
  this._backingOptOut = args.backingStore === false;
350
350
  this._backing = null;
351
+ // What the backing store clears to, and the window attribute it mirrors.
352
+ // Undefined means "nothing was asked for" — see _clearColour.
353
+ this._clearPixel = args.backgroundPixel;
351
354
  this._dirty = false;
352
355
  this._presentPending = false;
353
356
 
@@ -696,9 +699,60 @@ export default class Window extends Drawable {
696
699
  });
697
700
  }
698
701
 
702
+ /**
703
+ * The pixel the backing store's unpainted area is cleared to.
704
+ *
705
+ * A pixmap's contents start *undefined* — whatever was in that memory — so
706
+ * something has to be written before it can be presented, and the same goes
707
+ * for the region a grow adds. What that something is follows the window's
708
+ * own `backgroundPixel`, which is exactly what the server would paint into
709
+ * exposed area on a window with no backing store. Without one: transparent
710
+ * for a depth-32 ARGB window, and the screen's white otherwise.
711
+ */
712
+ _clearColour() {
713
+ if (this._clearPixel !== undefined) return this._clearPixel;
714
+ const depth = this.depth || this.display.screen[0].root_depth;
715
+ return depth === 32 ? 0 : this.display.screen[0].white_pixel;
716
+ }
717
+
718
+ /**
719
+ * The window's background colour, as an X pixel value.
720
+ *
721
+ * A double-buffered window has two of them and they have to agree: the X
722
+ * window attribute the server paints into exposed area, and the colour the
723
+ * backing store clears to. Setting one and not the other is why a resize
724
+ * could show white on a window that draws itself dark.
725
+ *
726
+ * The backing store's *headroom* — the area a future grow will expand
727
+ * into, which by definition holds nothing yet — is recoloured too, so a
728
+ * later resize does not expose the previous colour.
729
+ */
730
+ setBackgroundPixel(pixel) {
731
+ this._clearPixel = pixel;
732
+ if (this._destroyed) return this;
733
+ if (this._clearGc) this.X.ChangeGC(this._clearGc, { foreground: pixel });
734
+ if (!this._foreign) {
735
+ this.X.ChangeWindowAttributes(this.id, { backgroundPixel: pixel }, () => {});
736
+ }
737
+ const backing = this._backing;
738
+ if (backing && this._clearGc) {
739
+ const rects = [];
740
+ if (backing.width > this.width) {
741
+ rects.push(this.width, 0, backing.width - this.width, backing.height);
742
+ }
743
+ if (backing.height > this.height) {
744
+ rects.push(0, this.height, this.width, backing.height - this.height);
745
+ }
746
+ if (rects.length) this.X.PolyFillRectangle(backing.id, this._clearGc, rects);
747
+ }
748
+ return this;
749
+ }
750
+
699
751
  // grow-only backing pixmap (re)allocation, with headroom (see
700
- // BACKING_GRANULARITY); new area is cleared to the window's background
701
- // (white for opaque windows, fully transparent for depth-32 ARGB ones)
752
+ // BACKING_GRANULARITY). What is already drawn is carried across and only
753
+ // the region the grow adds is cleared (see _clearColour) — clearing the
754
+ // whole pixmap made the *entire* window flash the clear colour between a
755
+ // resize and the redraw, not just the new strip.
702
756
  _allocBacking(w, h) {
703
757
  const cur = this._backing;
704
758
  if (cur && cur.width >= w && cur.height >= h) return;
@@ -715,11 +769,21 @@ export default class Window extends Drawable {
715
769
  if (!this._clearGc) {
716
770
  this._clearGc = this.X.AllocID();
717
771
  this.X.CreateGC(this._clearGc, pixmap.id, {
718
- foreground: depth === 32 ? 0 : this.display.screen[0].white_pixel,
772
+ foreground: this._clearColour(),
719
773
  graphicsExposures: 0
720
774
  });
721
775
  }
722
- this.X.PolyFillRectangle(pixmap.id, this._clearGc, [0, 0, newW, newH]);
776
+ if (cur) {
777
+ // The new pixmap is strictly larger, so the old content lands at the
778
+ // same coordinates and only the L-shaped remainder is new.
779
+ this.X.CopyArea(cur.id, pixmap.id, this._clearGc, 0, 0, 0, 0, cur.width, cur.height);
780
+ const rects = [];
781
+ if (newW > cur.width) rects.push(cur.width, 0, newW - cur.width, newH);
782
+ if (newH > cur.height) rects.push(0, cur.height, cur.width, newH - cur.height);
783
+ if (rects.length) this.X.PolyFillRectangle(pixmap.id, this._clearGc, rects);
784
+ } else {
785
+ this.X.PolyFillRectangle(pixmap.id, this._clearGc, [0, 0, newW, newH]);
786
+ }
723
787
  if (cur) cur.destroy();
724
788
  this._backing = pixmap;
725
789
  this._backingValid = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "6.6.0",
3
+ "version": "6.7.0",
4
4
  "description": "Desktop UI toolkit for X11 with canvas-like 2d and OpenGL rendering",
5
5
  "author": "Andrey Sidorov <sidorares@yandex.ru>",
6
6
  "license": "MIT",