ntk 6.6.1 → 7.0.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.
@@ -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,357 @@ 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 border drawn the correct way (path inset by bw/2,
1714
+ * so the band [X, X+bw] sits on integers) passes at any width, odd or even;
1715
+ * a 1px stroke on integer path coordinates is genuinely a two-row 50% band
1716
+ * and must keep 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
+ // What the route needs is that the *ink* is pixel-aligned: the band's
1746
+ // four outer edges on integers, which is what the four tests below say,
1747
+ // and an integer width so the straight runs are whole rows and columns
1748
+ // of FillRectangles (fractional extents would be truncated on the wire —
1749
+ // and a fractional lineWidth passes the outer-band tests, e.g. x = 0.75
1750
+ // with bw = 1.5, so this clause carries real weight).
1751
+ //
1752
+ // The *path* radius is free. The corner glyph box is cut at
1753
+ // K = ceil(r + bw/2), and from the arc's tangent point out to that cut
1754
+ // the band is already its own straight continuation — bw whole rows —
1755
+ // so a fractional radius rides inside the glyph, which rasterizes on the
1756
+ // device pixel grid, exactly as it does on the polygon route. This is
1757
+ // what a border inset by bw/2 needs: nesting inside a background corner
1758
+ // of radius R means a path radius of R - bw/2, half-integer for every
1759
+ // odd width (issue #217).
1760
+ if (
1761
+ !Number.isInteger(X0) ||
1762
+ !Number.isInteger(Y0) ||
1763
+ !Number.isInteger(W) ||
1764
+ !Number.isInteger(H) ||
1765
+ !Number.isInteger(bw)
1766
+ ) {
1767
+ return this._shapeMiss("fractional");
1768
+ }
1769
+ if (
1770
+ X0 < -32768 ||
1771
+ Y0 < -32768 ||
1772
+ X0 + W > 32767 ||
1773
+ Y0 + H > 32767 ||
1774
+ W > 32767 ||
1775
+ H > 32767
1776
+ ) {
1777
+ return this._shapeMiss("geometry");
1778
+ }
1779
+ if (this.globalAlpha <= 0) {
1780
+ countShapeHit();
1781
+ this.shapeStats.hits++;
1782
+ return true;
1783
+ }
1784
+ const color = this._foldedColor(this._strokeStyle);
1785
+
1786
+ if (r === 0) {
1787
+ // square corners lower to four FillRectangles with no glyphs at all —
1788
+ // but only when the miter join actually squares them (the default);
1789
+ // bevel or round corners look different and keep the polygon route
1790
+ if (this.lineJoin !== "miter" || this.miterLimit < Math.SQRT2)
1791
+ return this._shapeMiss("join");
1792
+ if (W < 2 * bw || H < 2 * bw) return this._shapeMiss("geometry");
1793
+ const rects = [X0, Y0, W, bw, X0, Y0 + H - bw, W, bw];
1794
+ if (H > 2 * bw) {
1795
+ rects.push(X0, Y0 + bw, bw, H - 2 * bw);
1796
+ rects.push(X0 + W - bw, Y0 + bw, bw, H - 2 * bw);
1797
+ }
1798
+ this._emitShapeGlyphs([], rects, color, clip);
1799
+ return true;
1800
+ }
1801
+
1802
+ // a border thicker than the corner radius swallows the arc's centre;
1803
+ // the ring glyph does not model that, so it stays on the polygon route
1804
+ if (r - bw / 2 < 0) return this._shapeMiss("geometry");
1805
+ const K = Math.ceil(r + bw / 2); // corner glyph box, integer cut lines
1806
+ if (K > policy.maxRadius) return this._shapeMiss("radius-cap");
1807
+ if (2 * K > W || 2 * K > H) return this._shapeMiss("geometry");
1808
+
1809
+ const corners = [
1810
+ {
1811
+ key: cornerKey("stroke", r, r, bw, TL),
1812
+ kind: "stroke", rx: r, ry: r, bw, corner: TL,
1813
+ px: X0, py: Y0,
1814
+ },
1815
+ {
1816
+ key: cornerKey("stroke", r, r, bw, TR),
1817
+ kind: "stroke", rx: r, ry: r, bw, corner: TR,
1818
+ px: X0 + W - K, py: Y0,
1819
+ },
1820
+ {
1821
+ key: cornerKey("stroke", r, r, bw, BL),
1822
+ kind: "stroke", rx: r, ry: r, bw, corner: BL,
1823
+ px: X0, py: Y0 + H - K,
1824
+ },
1825
+ {
1826
+ key: cornerKey("stroke", r, r, bw, BR),
1827
+ kind: "stroke", rx: r, ry: r, bw, corner: BR,
1828
+ px: X0 + W - K, py: Y0 + H - K,
1829
+ },
1830
+ ];
1831
+ const rects = [];
1832
+ if (W > 2 * K) {
1833
+ rects.push(X0 + K, Y0, W - 2 * K, bw);
1834
+ rects.push(X0 + K, Y0 + H - bw, W - 2 * K, bw);
1835
+ }
1836
+ if (H > 2 * K) {
1837
+ rects.push(X0, Y0 + K, bw, H - 2 * K);
1838
+ rects.push(X0 + W - bw, Y0 + K, bw, H - 2 * K);
1839
+ }
1840
+ this._emitShapeGlyphs(corners, rects, color, clip);
1841
+ return true;
1842
+ }
1843
+
1844
+ /**
1845
+ * Emit one recognized box: ensure the corner glyphs, one CompositeGlyphs
1846
+ * run for them, one FillRectangles for the strips — inside a server-side
1847
+ * clip rectangle when the clip stack is rectangular (the way glyph runs
1848
+ * for text already do it).
1849
+ */
1850
+ _emitShapeGlyphs(corners, rects, color, clip) {
1851
+ countShapeHit();
1852
+ this.shapeStats.hits++;
1853
+ if (clip && (clip.w === 0 || clip.h === 0)) return; // clipped away
1854
+ const R = this.Render;
1855
+ const app = this.window.app;
1856
+ if (clip) {
1857
+ R.SetPictureClipRectangles(this.picture.id, 0, 0, [
1858
+ clip.x,
1859
+ clip.y,
1860
+ clip.w,
1861
+ clip.h,
1862
+ ]);
1863
+ }
1864
+ if (corners.length) {
1865
+ const page = getShapeGlyphPage(app);
1866
+ page.ensure(corners);
1867
+ const items = [];
1868
+ for (const spec of corners) {
1869
+ items.push({
1870
+ gs: page.glyphset.id,
1871
+ lid: page.entry(spec.key).lid,
1872
+ adv: 0,
1873
+ x: spec.px,
1874
+ y: spec.py,
1875
+ });
1876
+ }
1877
+ const encoded = encodeGlyphItems(items, page.bits);
1878
+ const src = app.solidPicture(color[0], color[1], color[2], color[3]);
1879
+ R.CompositeGlyphs(
1880
+ encoded.bits,
1881
+ R.PictOp.Over,
1882
+ src.id,
1883
+ this.picture.id,
1884
+ 0,
1885
+ encoded.gsid,
1886
+ items[0].x,
1887
+ items[0].y,
1888
+ encoded.elts,
1889
+ );
1890
+ trimShapeGlyphs(app);
1891
+ }
1892
+ if (rects.length) {
1893
+ R.FillRectangles(R.PictOp.Over, this.picture.id, color, rects);
1894
+ }
1895
+ if (clip) this._resetPictureClip();
1896
+ this._markDirty();
1897
+ }
1898
+
1449
1899
  /**
1450
1900
  * Intersect the clip region with the given path (or the current path).
1451
1901
  * Cleared by restore() to the state at the matching save() — like the