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 +2 -0
- package/lib/path.js +196 -6
- package/lib/renderingcontext_2d.js +443 -8
- package/lib/shapeglyphs.js +327 -0
- package/lib/window.js +68 -4
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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 =
|
|
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 {
|
|
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
|
|
939
|
-
join
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
701
|
-
//
|
|
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:
|
|
772
|
+
foreground: this._clearColour(),
|
|
719
773
|
graphicsExposures: 0
|
|
720
774
|
});
|
|
721
775
|
}
|
|
722
|
-
|
|
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;
|