ntk 7.6.1 → 7.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/image.js +12 -0
- package/lib/index.js +7 -1
- package/lib/maskcluster.js +191 -0
- package/lib/renderingcontext_2d.js +590 -175
- package/package.json +1 -1
package/lib/image.js
CHANGED
|
@@ -79,6 +79,18 @@ export class Image {
|
|
|
79
79
|
return picture;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* The Pixmap those pixels live in on `app` (uploading on first use, like
|
|
84
|
+
* `picture`). What it is for is building a *second* Picture over the same
|
|
85
|
+
* upload — `ctx.createPattern` needs a repeating one, and changing the
|
|
86
|
+
* cached picture's attributes instead would change how `drawImage` samples
|
|
87
|
+
* this image everywhere else.
|
|
88
|
+
*/
|
|
89
|
+
pixmap(app) {
|
|
90
|
+
this.picture(app);
|
|
91
|
+
return this._uploads.get(app).pixmap;
|
|
92
|
+
}
|
|
93
|
+
|
|
82
94
|
/** free server-side copies of this image (safe to draw again afterwards) */
|
|
83
95
|
destroy() {
|
|
84
96
|
for (const { pixmap, picture } of this._uploads.values()) {
|
package/lib/index.js
CHANGED
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
defaultRasterizer,
|
|
48
48
|
setDefaultRasterizer
|
|
49
49
|
} from './rasterize.js';
|
|
50
|
+
import { DEFAULT_MASK_POLICY } from './maskcluster.js';
|
|
50
51
|
import { DEFAULT_SHAPE_POLICY } from './shapeglyphs.js';
|
|
51
52
|
import { TextLayout } from './text/layout.js';
|
|
52
53
|
import HtmlView from './widgets/htmlview.js';
|
|
@@ -61,7 +62,7 @@ import { cssColor, cssLength } from './widgets/css.js';
|
|
|
61
62
|
// comes last on purpose: it wraps the 'opengl' factory the indirect one just
|
|
62
63
|
// registered, so that the backend-neutral name can dispatch on glPolicy.
|
|
63
64
|
import './renderingcontext_x11.js';
|
|
64
|
-
import './renderingcontext_2d.js';
|
|
65
|
+
import { CanvasGradient, CanvasPattern } from './renderingcontext_2d.js';
|
|
65
66
|
import './renderingcontext_opengl.js';
|
|
66
67
|
import './renderingcontext_gles.js';
|
|
67
68
|
|
|
@@ -237,6 +238,10 @@ export {
|
|
|
237
238
|
Pixmap,
|
|
238
239
|
Picture,
|
|
239
240
|
Surface,
|
|
241
|
+
// fill/stroke styles the 2d context hands back (docs/context-2d.md) —
|
|
242
|
+
// exported for `instanceof`, not to be constructed directly
|
|
243
|
+
CanvasGradient,
|
|
244
|
+
CanvasPattern,
|
|
240
245
|
Image,
|
|
241
246
|
ImageData,
|
|
242
247
|
pixelLayout,
|
|
@@ -260,6 +265,7 @@ export {
|
|
|
260
265
|
defaultRasterizer,
|
|
261
266
|
setDefaultRasterizer,
|
|
262
267
|
DEFAULT_RASTER_POLICY,
|
|
268
|
+
DEFAULT_MASK_POLICY,
|
|
263
269
|
DEFAULT_SHAPE_POLICY,
|
|
264
270
|
TextLayout,
|
|
265
271
|
HtmlView,
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// Splitting one drawing's coverage mask into a few, when its pieces are
|
|
2
|
+
// scattered (issue #264).
|
|
3
|
+
//
|
|
4
|
+
// A fill or stroke rasterizes coverage into one a8 mask sized to the
|
|
5
|
+
// drawing's ink bounding box, and that mask costs width x height whatever
|
|
6
|
+
// the coverage inside it is. For one shape the bound is right. For a path
|
|
7
|
+
// holding N disjoint subpaths the bound is their *union*, so batching N
|
|
8
|
+
// draws into one path trades N small masks for one big one — which wins
|
|
9
|
+
// when the pieces span the box anyway (long edges of a graph) and loses
|
|
10
|
+
// badly when they do not (its handle dots): at 1100x700, 735 batched edge
|
|
11
|
+
// strokes cost 3.9 MB -> 1.3 MB of mask and 53% less frame time, while 19
|
|
12
|
+
// edges plus 40 small discs batched the same way cost ~0.75 MB *more* and
|
|
13
|
+
// 40% more frame time than drawing them singly.
|
|
14
|
+
//
|
|
15
|
+
// Rather than leave that cliff to every caller, the boxes go through here
|
|
16
|
+
// first and the mask is emitted once per cluster. The partition is by gaps
|
|
17
|
+
// only: a cut is legal where nothing straddles it, which is what keeps
|
|
18
|
+
// every cluster box disjoint from every other. Disjoint boxes are what make
|
|
19
|
+
// the split invisible — no pixel is composited twice (a translucent colour
|
|
20
|
+
// would blend twice at any overlap), and the winding number a fill asks for
|
|
21
|
+
// is unchanged, because a closed subpath contributes nothing to the winding
|
|
22
|
+
// of a point outside its own box.
|
|
23
|
+
//
|
|
24
|
+
// Cutting is not free: each extra mask is a few more requests. So a cut has
|
|
25
|
+
// to pay for itself in mask area, and the cheapest useful unit of area is
|
|
26
|
+
// the policy's `minSaving`. That single rule bounds the outcome from both
|
|
27
|
+
// sides — every cut removes at least `minSaving` pixels of mask, so the
|
|
28
|
+
// number of clusters can never exceed the union area divided by it.
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* When one drawing's mask is worth splitting into several.
|
|
32
|
+
*
|
|
33
|
+
* - `minSaving` — mask pixels a cut has to remove to be worth the extra
|
|
34
|
+
* mask pass it costs. 64x64 is the same "small enough not to think about"
|
|
35
|
+
* box `DEFAULT_RASTER_POLICY.maxArea` uses: below it, an extra pass is
|
|
36
|
+
* dearer than the area it would save.
|
|
37
|
+
* - `maxMasks` — hard cap on clusters per drawing, so a pathological path
|
|
38
|
+
* (thousands of scattered dots) cannot turn one drawing into thousands of
|
|
39
|
+
* request groups. Cuts are taken most-valuable-first, so the cap keeps the
|
|
40
|
+
* ones that matter.
|
|
41
|
+
*
|
|
42
|
+
* `{ maxMasks: 1 }` — or `minSaving: Infinity` — disables the split, which
|
|
43
|
+
* is what a composite op that writes outside its coverage gets.
|
|
44
|
+
*/
|
|
45
|
+
export const DEFAULT_MASK_POLICY = {
|
|
46
|
+
minSaving: 64 * 64,
|
|
47
|
+
maxMasks: 32
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** the policy for one app, merged over the defaults */
|
|
51
|
+
export function maskPolicyOf(app) {
|
|
52
|
+
return app.maskPolicy
|
|
53
|
+
? { ...DEFAULT_MASK_POLICY, ...app.maskPolicy }
|
|
54
|
+
: DEFAULT_MASK_POLICY;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const area = (b) => b.w * b.h;
|
|
58
|
+
|
|
59
|
+
/** The smallest {x, y, w, h} box holding both of two boxes. */
|
|
60
|
+
export function unionBox(a, b) {
|
|
61
|
+
const x = Math.min(a.x, b.x);
|
|
62
|
+
const y = Math.min(a.y, b.y);
|
|
63
|
+
return {
|
|
64
|
+
x,
|
|
65
|
+
y,
|
|
66
|
+
w: Math.max(a.x + a.w, b.x + b.w) - x,
|
|
67
|
+
h: Math.max(a.y + a.h, b.y + b.h) - y
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function unionOf(boxes, order) {
|
|
72
|
+
let out = { ...boxes[order[0]] };
|
|
73
|
+
for (let i = 1; i < order.length; ++i) out = unionBox(out, boxes[order[i]]);
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The most valuable gap cut of one group along one axis, or null when the
|
|
79
|
+
* group has no gap on it.
|
|
80
|
+
*
|
|
81
|
+
* `order` is the group's members sorted by that axis' start. A cut between
|
|
82
|
+
* members i and i+1 is legal only where nothing straddles it — the next box
|
|
83
|
+
* has to start at or past the far edge of every box before it, which for a
|
|
84
|
+
* union box is just its own far edge. What the cut is worth is the mask area
|
|
85
|
+
* it removes: the parent box less the two halves.
|
|
86
|
+
*/
|
|
87
|
+
function bestGap(boxes, order, box, axis) {
|
|
88
|
+
const n = order.length;
|
|
89
|
+
const start = axis === 'x' ? 'x' : 'y';
|
|
90
|
+
const size = axis === 'x' ? 'w' : 'h';
|
|
91
|
+
|
|
92
|
+
// suffix[i] is the union box of order[i..n-1]; the prefix is carried
|
|
93
|
+
// along the scan below
|
|
94
|
+
const suffix = new Array(n);
|
|
95
|
+
suffix[n - 1] = { ...boxes[order[n - 1]] };
|
|
96
|
+
for (let i = n - 2; i >= 0; --i) {
|
|
97
|
+
suffix[i] = unionBox(boxes[order[i]], suffix[i + 1]);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let best = null;
|
|
101
|
+
let prefix = { ...boxes[order[0]] };
|
|
102
|
+
for (let i = 0; i + 1 < n; ++i) {
|
|
103
|
+
if (i > 0) prefix = unionBox(prefix, boxes[order[i]]);
|
|
104
|
+
const next = boxes[order[i + 1]];
|
|
105
|
+
if (next[start] < prefix[start] + prefix[size]) continue; // straddled
|
|
106
|
+
const saving = area(box) - area(prefix) - area(suffix[i + 1]);
|
|
107
|
+
if (!best || saving > best.saving) {
|
|
108
|
+
best = { axis, at: i, saving, left: prefix, right: suffix[i + 1] };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return best;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** a group of pieces, its box, and the best cut available to it */
|
|
115
|
+
function makeNode(boxes, byX, byY, box) {
|
|
116
|
+
const node = { box: box ?? unionOf(boxes, byX), byX, byY, split: null };
|
|
117
|
+
if (byX.length > 1) {
|
|
118
|
+
const x = bestGap(boxes, byX, node.box, 'x');
|
|
119
|
+
const y = bestGap(boxes, byY, node.box, 'y');
|
|
120
|
+
node.split = !x ? y : !y || x.saving >= y.saving ? x : y;
|
|
121
|
+
}
|
|
122
|
+
return node;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** the two halves of `node`, each with its own next-best cut */
|
|
126
|
+
function splitNode(boxes, node) {
|
|
127
|
+
const { axis, at, left, right } = node.split;
|
|
128
|
+
const cut = axis === 'x' ? node.byX : node.byY;
|
|
129
|
+
const other = axis === 'x' ? node.byY : node.byX;
|
|
130
|
+
const inLeft = new Set(cut.slice(0, at + 1));
|
|
131
|
+
// the other axis' order survives the filter, so neither half is re-sorted
|
|
132
|
+
const leftOther = other.filter((i) => inLeft.has(i));
|
|
133
|
+
const rightOther = other.filter((i) => !inLeft.has(i));
|
|
134
|
+
const leftCut = cut.slice(0, at + 1);
|
|
135
|
+
const rightCut = cut.slice(at + 1);
|
|
136
|
+
return axis === 'x'
|
|
137
|
+
? [
|
|
138
|
+
makeNode(boxes, leftCut, leftOther, left),
|
|
139
|
+
makeNode(boxes, rightCut, rightOther, right)
|
|
140
|
+
]
|
|
141
|
+
: [
|
|
142
|
+
makeNode(boxes, leftOther, leftCut, left),
|
|
143
|
+
makeNode(boxes, rightOther, rightCut, right)
|
|
144
|
+
];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Partition one drawing's pieces into mask clusters.
|
|
149
|
+
*
|
|
150
|
+
* @param {Array<{x, y, w, h}>} boxes one integer box per piece — a fill's
|
|
151
|
+
* subpaths, a stroke's islands of triangles — already clamped to the
|
|
152
|
+
* surface. Boxes may overlap; overlapping ones always end up in the same
|
|
153
|
+
* cluster.
|
|
154
|
+
* @param {{minSaving: number, maxMasks: number}} [policy]
|
|
155
|
+
* @returns {Array<{x, y, w, h, items: number[]}>} clusters, each with the
|
|
156
|
+
* indices of the pieces it holds. Cluster boxes are pairwise disjoint,
|
|
157
|
+
* and their union is the union of `boxes`.
|
|
158
|
+
*/
|
|
159
|
+
export function clusterBoxes(boxes, policy = DEFAULT_MASK_POLICY) {
|
|
160
|
+
const n = boxes.length;
|
|
161
|
+
if (!n) return [];
|
|
162
|
+
// merged here as well as in maskPolicyOf, so that a partial policy from a
|
|
163
|
+
// direct caller ({ maxMasks: 1 }) cannot silently zero the other field
|
|
164
|
+
const { minSaving, maxMasks: cap } = { ...DEFAULT_MASK_POLICY, ...policy };
|
|
165
|
+
const maxMasks = Math.max(1, cap | 0);
|
|
166
|
+
const all = boxes.map((_, i) => i);
|
|
167
|
+
if (n === 1 || maxMasks === 1 || !(minSaving < Infinity)) {
|
|
168
|
+
return [{ ...unionOf(boxes, all), items: all }];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const byX = all.slice().sort((a, b) => boxes[a].x - boxes[b].x || a - b);
|
|
172
|
+
const byY = all.slice().sort((a, b) => boxes[a].y - boxes[b].y || a - b);
|
|
173
|
+
const nodes = [makeNode(boxes, byX, byY)];
|
|
174
|
+
// most-valuable cut first, so a maxMasks cap keeps the cuts that matter
|
|
175
|
+
while (nodes.length < maxMasks) {
|
|
176
|
+
let pick = -1;
|
|
177
|
+
let best = minSaving;
|
|
178
|
+
for (let i = 0; i < nodes.length; ++i) {
|
|
179
|
+
const split = nodes[i].split;
|
|
180
|
+
if (split && split.saving >= best) {
|
|
181
|
+
best = split.saving;
|
|
182
|
+
pick = i;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (pick < 0) break;
|
|
186
|
+
const [a, b] = splitNode(boxes, nodes[pick]);
|
|
187
|
+
nodes[pick] = a;
|
|
188
|
+
nodes.push(b);
|
|
189
|
+
}
|
|
190
|
+
return nodes.map((node) => ({ ...node.box, items: node.byX }));
|
|
191
|
+
}
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
pixelLayout,
|
|
12
12
|
toStraightRgba,
|
|
13
13
|
} from "./imagedata.js";
|
|
14
|
+
import { clusterBoxes, maskPolicyOf, unionBox } from "./maskcluster.js";
|
|
14
15
|
import {
|
|
15
16
|
Path2D,
|
|
16
17
|
arcSegmentCount,
|
|
@@ -26,6 +27,7 @@ import {
|
|
|
26
27
|
import Picture from "./picture.js";
|
|
27
28
|
import Pixmap from "./pixmap.js";
|
|
28
29
|
import { routeRaster } from "./rasterize.js";
|
|
30
|
+
import { Surface } from "./surface.js";
|
|
29
31
|
import {
|
|
30
32
|
BL,
|
|
31
33
|
BR,
|
|
@@ -48,6 +50,22 @@ import { TextLayout } from "./text/layout.js";
|
|
|
48
50
|
import { reorderRuns } from "./text/shape.js";
|
|
49
51
|
import { trapezoidize } from "./trapezoid.js";
|
|
50
52
|
|
|
53
|
+
/**
|
|
54
|
+
* The subpaths of a flattened path that can enclose area, as the flat
|
|
55
|
+
* `[x0, y0, …]` lists both the local rasterizer and the trapezoidizer take,
|
|
56
|
+
* with the edge count the routing policy asks for.
|
|
57
|
+
*/
|
|
58
|
+
function fillableShapes(polys) {
|
|
59
|
+
const shapes = [];
|
|
60
|
+
let edges = 0;
|
|
61
|
+
for (const p of polys) {
|
|
62
|
+
if (p.pts.length < 6) continue;
|
|
63
|
+
shapes.push(p.pts);
|
|
64
|
+
edges += p.pts.length / 2;
|
|
65
|
+
}
|
|
66
|
+
return { shapes, edges };
|
|
67
|
+
}
|
|
68
|
+
|
|
51
69
|
/** The overlap of two {x, y, w, h} boxes, or null when they have none. */
|
|
52
70
|
function intersectBox(a, b) {
|
|
53
71
|
const x = Math.max(a.x, b.x);
|
|
@@ -103,12 +121,92 @@ const gcRegistry = new FinalizationRegistry(({ X, gcs }) => {
|
|
|
103
121
|
* the type. A `RenderingContext2d` deliberately does not match: its `picture`
|
|
104
122
|
* is a property rather than a method, and it keeps its own branch below.
|
|
105
123
|
*/
|
|
106
|
-
/** a fillStyle with a single colour behind it, as opposed to a gradient
|
|
107
|
-
* caller-supplied Picture */
|
|
124
|
+
/** a fillStyle with a single colour behind it, as opposed to a gradient, a
|
|
125
|
+
* pattern or a caller-supplied Picture */
|
|
108
126
|
function isPlainColor(style) {
|
|
109
127
|
return typeof style === "string" || Array.isArray(style);
|
|
110
128
|
}
|
|
111
129
|
|
|
130
|
+
// `createPattern` repetitions -> XRender Repeat modes. The canvas spec's
|
|
131
|
+
// per-axis 'repeat-x'/'repeat-y' have no mode here (see createPattern);
|
|
132
|
+
// 'pad' and 'reflect' are the two XRender modes the spec has no name for.
|
|
133
|
+
const REPEAT_MODES = {
|
|
134
|
+
repeat: 1, // Repeat.Normal
|
|
135
|
+
"no-repeat": 0, // Repeat.None
|
|
136
|
+
pad: 2, // clamp to the edge pixels
|
|
137
|
+
reflect: 3, // mirror every other tile
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const PATTERN_DOCS =
|
|
141
|
+
"https://github.com/sidorares/ntk/blob/master/docs/context-2d.md#patterns";
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* What a pattern tiles: a drawable holding the tile plus the picture format
|
|
145
|
+
* to read it through. A repeating source Picture is created over it rather
|
|
146
|
+
* than the source's own picture being changed, so tiling a `Surface` leaves
|
|
147
|
+
* `drawImage` of that same surface exactly as it was.
|
|
148
|
+
*/
|
|
149
|
+
function patternSourceOf(app, source) {
|
|
150
|
+
const Render = app.display.Render;
|
|
151
|
+
const formatFor = (depth) =>
|
|
152
|
+
depth === 32 ? Render.rgba32 : depth === 8 ? "a8" : Render.rgb24;
|
|
153
|
+
|
|
154
|
+
let drawable = null;
|
|
155
|
+
let width;
|
|
156
|
+
let height;
|
|
157
|
+
let format;
|
|
158
|
+
if (source instanceof Surface) {
|
|
159
|
+
if (source.app !== app) {
|
|
160
|
+
throw new Error(
|
|
161
|
+
"createPattern: the Surface belongs to a different X connection",
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
drawable = source.pixmap;
|
|
165
|
+
format = source.format === "a8" ? "a8" : Render.rgba32;
|
|
166
|
+
({ width, height } = source);
|
|
167
|
+
} else if (source instanceof Image) {
|
|
168
|
+
drawable = source.pixmap(app);
|
|
169
|
+
format = Render.rgba32;
|
|
170
|
+
({ width, height } = source);
|
|
171
|
+
} else if (source && typeof source.id === "number") {
|
|
172
|
+
// a Drawable: a Pixmap, or a Window (through its backing pixmap, which
|
|
173
|
+
// is where a double-buffered window's current pixels actually are)
|
|
174
|
+
drawable = source._backing || source;
|
|
175
|
+
const depth = drawable.depth ?? source.depth;
|
|
176
|
+
if (!depth) {
|
|
177
|
+
throw new Error(
|
|
178
|
+
"createPattern: the drawable's depth is not known yet — await the window's geometry, or pass a Surface",
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
format = formatFor(depth);
|
|
182
|
+
width = drawable.width ?? source.width;
|
|
183
|
+
height = drawable.height ?? source.height;
|
|
184
|
+
} else {
|
|
185
|
+
throw new Error(
|
|
186
|
+
"createPattern: expected a Surface, an Image, a Pixmap or a Window as the tile, got " +
|
|
187
|
+
(source === null ? "null" : typeof source),
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
if (format === "a8") {
|
|
191
|
+
throw new Error(
|
|
192
|
+
"createPattern: a coverage (a8) tile has no colour to paint with — XRender would " +
|
|
193
|
+
"sample it as black. Draw the tile into an argb32 Surface and tile that, or keep " +
|
|
194
|
+
`the a8 one and use ctx.drawImage, which paints it in the current fillStyle. ${PATTERN_DOCS}`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
return { drawable, format, width, height };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Point a pattern's picture transform at the transform in force for this
|
|
202
|
+
* fill — patterns are painted in user space, so the CTM is part of the
|
|
203
|
+
* mapping. Returns false when nothing would be painted (a singular matrix),
|
|
204
|
+
* true for anything else, including every non-pattern style.
|
|
205
|
+
*/
|
|
206
|
+
function preparePattern(src, m) {
|
|
207
|
+
return src instanceof CanvasPattern ? src._sync(m) : true;
|
|
208
|
+
}
|
|
209
|
+
|
|
112
210
|
function isPictureSource(image) {
|
|
113
211
|
return (
|
|
114
212
|
image instanceof Image ||
|
|
@@ -136,6 +234,30 @@ const GCO_TO_PICTOP = {
|
|
|
136
234
|
lighter: "Add",
|
|
137
235
|
};
|
|
138
236
|
|
|
237
|
+
// The ops whose result is the destination wherever the mask is zero. Only
|
|
238
|
+
// those may have a drawing's mask split into several boxes (maskcluster.js):
|
|
239
|
+
// what the split gives up is the gaps between the boxes, and for these ops
|
|
240
|
+
// the single-box version would not have changed those pixels either. `copy`,
|
|
241
|
+
// `source-in`, `destination-in`, `source-out` and `destination-atop` write
|
|
242
|
+
// the source — or nothing — across the whole box, so they keep one.
|
|
243
|
+
const MASK_BOUNDED_OPS = [
|
|
244
|
+
"Over",
|
|
245
|
+
"OverReverse",
|
|
246
|
+
"OutReverse",
|
|
247
|
+
"Atop",
|
|
248
|
+
"Xor",
|
|
249
|
+
"Add",
|
|
250
|
+
];
|
|
251
|
+
|
|
252
|
+
// and what they are clustered with instead: one mask, exactly as before
|
|
253
|
+
const ONE_MASK = { minSaving: Infinity, maxMasks: 1 };
|
|
254
|
+
|
|
255
|
+
// How far apart two of a stroke's triangles have to be to start a new piece
|
|
256
|
+
// (_trisPieces). Two mask boxes a pixel or two apart are not worth
|
|
257
|
+
// splitting — each carries a pixel of antialiasing slack of its own — so
|
|
258
|
+
// this only has to be small next to anything `minSaving` would pay for.
|
|
259
|
+
const PIECE_SLACK = 2;
|
|
260
|
+
|
|
139
261
|
// extrude-polyline has no round caps/joins: 'round' extrudes as butt/bevel
|
|
140
262
|
// and the missing coverage is unioned in afterwards as triangle-fan disks
|
|
141
263
|
// (see _strokePolys)
|
|
@@ -284,6 +406,12 @@ class RenderingContext2d {
|
|
|
284
406
|
// a silent perf cliff, so hits and misses-by-reason are always counted.
|
|
285
407
|
// NTK_DEBUG_SHAPES=1 prints the process-wide aggregate at exit.
|
|
286
408
|
this.shapeStats = { hits: 0, misses: {} };
|
|
409
|
+
// Mask cost, for the same reason (issue #264): a drawing whose pieces
|
|
410
|
+
// are scattered pays for the box around all of them unless the mask is
|
|
411
|
+
// split, and neither the pixels nor the split show up anywhere else.
|
|
412
|
+
// `masks` counts mask passes, `pixels` their total area, `split` the
|
|
413
|
+
// drawings that took more than one.
|
|
414
|
+
this.maskStats = { masks: 0, pixels: 0, split: 0 };
|
|
287
415
|
this._stack = [];
|
|
288
416
|
this._clips = []; // [{ polys, rule }] in device space, already stacked
|
|
289
417
|
this._gco = "source-over";
|
|
@@ -458,7 +586,11 @@ class RenderingContext2d {
|
|
|
458
586
|
const c = parseColor(value);
|
|
459
587
|
return this.createSolidPicture(c[0], c[1], c[2], c[3]);
|
|
460
588
|
}
|
|
461
|
-
if (
|
|
589
|
+
if (
|
|
590
|
+
value instanceof Picture ||
|
|
591
|
+
value instanceof CanvasGradient ||
|
|
592
|
+
value instanceof CanvasPattern
|
|
593
|
+
) {
|
|
462
594
|
return value;
|
|
463
595
|
}
|
|
464
596
|
throw new Error("Unknown fill style");
|
|
@@ -763,11 +895,9 @@ class RenderingContext2d {
|
|
|
763
895
|
});
|
|
764
896
|
}
|
|
765
897
|
|
|
766
|
-
_rasterizePolys(picture,
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
if (!flat.length) return;
|
|
770
|
-
const traps = trapezoidize(flat, dx, dy, [], rule);
|
|
898
|
+
_rasterizePolys(picture, shapes, rule, dx = 0, dy = 0) {
|
|
899
|
+
if (!shapes.length) return;
|
|
900
|
+
const traps = trapezoidize(shapes, dx, dy, [], rule);
|
|
771
901
|
// stay under the server's maximum request size
|
|
772
902
|
const chunk = 4000 * 6;
|
|
773
903
|
for (let i = 0; i < traps.length; i += chunk) {
|
|
@@ -840,46 +970,34 @@ class RenderingContext2d {
|
|
|
840
970
|
}
|
|
841
971
|
|
|
842
972
|
/**
|
|
843
|
-
*
|
|
844
|
-
*
|
|
845
|
-
|
|
846
|
-
/**
|
|
847
|
-
* Device-space bounding box of flattened polys, with a pixel of slack for
|
|
848
|
-
* the antialiased edge, clamped to the surface. Null when nothing lands
|
|
849
|
-
* on it.
|
|
973
|
+
* Device-space bounding box of one flat `[x0, y0, …]` point list, with a
|
|
974
|
+
* pixel of slack for the antialiased edge, clamped to the surface. Null
|
|
975
|
+
* when nothing lands on it.
|
|
850
976
|
*/
|
|
851
|
-
|
|
977
|
+
_pointsBBox(pts) {
|
|
852
978
|
let minX = Infinity;
|
|
853
979
|
let minY = Infinity;
|
|
854
980
|
let maxX = -Infinity;
|
|
855
981
|
let maxY = -Infinity;
|
|
856
|
-
for (
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
if (pts[i + 1] < minY) minY = pts[i + 1];
|
|
862
|
-
if (pts[i + 1] > maxY) maxY = pts[i + 1];
|
|
863
|
-
}
|
|
982
|
+
for (let i = 0; i < pts.length; i += 2) {
|
|
983
|
+
if (pts[i] < minX) minX = pts[i];
|
|
984
|
+
if (pts[i] > maxX) maxX = pts[i];
|
|
985
|
+
if (pts[i + 1] < minY) minY = pts[i + 1];
|
|
986
|
+
if (pts[i + 1] > maxY) maxY = pts[i + 1];
|
|
864
987
|
}
|
|
865
988
|
if (maxX === -Infinity) return null;
|
|
866
989
|
return this._clampBBox(minX, minY, maxX, maxY);
|
|
867
990
|
}
|
|
868
991
|
|
|
869
|
-
/** the same
|
|
870
|
-
|
|
871
|
-
let
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
if (tris[i] < minX) minX = tris[i];
|
|
877
|
-
if (tris[i] > maxX) maxX = tris[i];
|
|
878
|
-
if (tris[i + 1] < minY) minY = tris[i + 1];
|
|
879
|
-
if (tris[i + 1] > maxY) maxY = tris[i + 1];
|
|
992
|
+
/** the same over every subpath of a flattened path, as one box */
|
|
993
|
+
_polysBBox(polys) {
|
|
994
|
+
let out = null;
|
|
995
|
+
for (const poly of polys) {
|
|
996
|
+
const b = this._pointsBBox(poly.pts);
|
|
997
|
+
if (!b) continue;
|
|
998
|
+
out = out ? unionBox(out, b) : b;
|
|
880
999
|
}
|
|
881
|
-
|
|
882
|
-
return this._clampBBox(minX, minY, maxX, maxY);
|
|
1000
|
+
return out;
|
|
883
1001
|
}
|
|
884
1002
|
|
|
885
1003
|
// a pixel of slack for the antialiased edge, clamped to the surface
|
|
@@ -892,57 +1010,74 @@ class RenderingContext2d {
|
|
|
892
1010
|
return { x, y, w, h };
|
|
893
1011
|
}
|
|
894
1012
|
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
1013
|
+
/**
|
|
1014
|
+
* The mask boxes one drawing's pieces are painted through: one per cluster
|
|
1015
|
+
* of them, whose union is the drawing's bounding box.
|
|
1016
|
+
*
|
|
1017
|
+
* Everything the mask does is bounded to those boxes rather than to the
|
|
1018
|
+
* whole surface. On the wire it makes no difference — a Composite request
|
|
1019
|
+
* is the same size either way — but it is the difference between the
|
|
1020
|
+
* server touching a 34x34 box and a 400x400 one per fill. Where the pieces
|
|
1021
|
+
* are *scattered*, their union is a poor bound in the same way, and
|
|
1022
|
+
* `clusterBoxes` cuts it into the few boxes the ink is actually in (see
|
|
1023
|
+
* maskcluster.js). Stale mask content outside a box is never composited,
|
|
1024
|
+
* so clearing only the boxes is safe.
|
|
1025
|
+
*/
|
|
1026
|
+
_maskClusters(pieces, op) {
|
|
1027
|
+
const clusters = clusterBoxes(
|
|
1028
|
+
pieces,
|
|
1029
|
+
this._maskBounded(op) ? maskPolicyOf(this.window.app) : ONE_MASK,
|
|
1030
|
+
);
|
|
1031
|
+
if (clusters.length > 1) this.maskStats.split++;
|
|
1032
|
+
return clusters;
|
|
1033
|
+
}
|
|
911
1034
|
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
}
|
|
920
|
-
if (
|
|
921
|
-
!this._uploadCoverage({ polys: flat, rule, dx: -b.x, dy: -b.y }, b, edges)
|
|
922
|
-
) {
|
|
923
|
-
R.FillRectangles(
|
|
924
|
-
R.PictOp.Src,
|
|
925
|
-
this.fillMask.id,
|
|
926
|
-
[0, 0, 0, 0],
|
|
927
|
-
[b.x, b.y, b.w, b.h],
|
|
928
|
-
);
|
|
929
|
-
this._rasterizePolys(this.fillMask, polys, rule);
|
|
930
|
-
}
|
|
1035
|
+
/** whether `op` leaves the destination alone where the mask is zero */
|
|
1036
|
+
_maskBounded(op) {
|
|
1037
|
+
this._boundedOps ??= new Set(
|
|
1038
|
+
MASK_BOUNDED_OPS.map((name) => this.Render.PictOp[name]),
|
|
1039
|
+
);
|
|
1040
|
+
return this._boundedOps.has(op);
|
|
1041
|
+
}
|
|
931
1042
|
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
1043
|
+
/**
|
|
1044
|
+
* The tail every masked fill and stroke ends in: coverage for one box into
|
|
1045
|
+
* the scratch a8 mask, scaled by `alpha`, intersected with the clip, and
|
|
1046
|
+
* the source composited through it.
|
|
1047
|
+
*
|
|
1048
|
+
* `job` is what a Rasterizer takes minus the box (docs/context-2d.md) —
|
|
1049
|
+
* `{ polys, rule }` or `{ triangles }`; `server` rasterizes the same
|
|
1050
|
+
* geometry into the mask server-side, for when the local rasterizer is not
|
|
1051
|
+
* the cheaper route or declines.
|
|
1052
|
+
*
|
|
1053
|
+
* @returns {boolean} whether anything was composited
|
|
1054
|
+
*/
|
|
1055
|
+
_paintThroughMask(b, job, { edges, src, op, alpha, server }) {
|
|
1056
|
+
const R = this.Render;
|
|
1057
|
+
// A rectangular clip narrows where the coverage is *composited*; the
|
|
1058
|
+
// mask content outside it is stale by the same argument as outside `b`,
|
|
1059
|
+
// and a box the clip rejects outright needs no coverage at all. A stack
|
|
1060
|
+
// whose mask was dropped by restore() may still hold a poly — _clipRect
|
|
1061
|
+
// says null then, and the mask comes back on demand.
|
|
936
1062
|
let out = b;
|
|
937
1063
|
if (this._clips.length && !this.clipMask) {
|
|
938
1064
|
const cr = this._clipRect();
|
|
939
1065
|
if (cr) {
|
|
940
1066
|
out = intersectBox(b, cr);
|
|
941
|
-
if (!out) return;
|
|
1067
|
+
if (!out) return false;
|
|
942
1068
|
} else {
|
|
943
1069
|
this._requireClipMask();
|
|
944
1070
|
}
|
|
945
1071
|
}
|
|
1072
|
+
if (!this._uploadCoverage({ ...job, dx: -b.x, dy: -b.y }, b, edges)) {
|
|
1073
|
+
R.FillRectangles(
|
|
1074
|
+
R.PictOp.Src,
|
|
1075
|
+
this.fillMask.id,
|
|
1076
|
+
[0, 0, 0, 0],
|
|
1077
|
+
[b.x, b.y, b.w, b.h],
|
|
1078
|
+
);
|
|
1079
|
+
server();
|
|
1080
|
+
}
|
|
946
1081
|
if (alpha < 1) {
|
|
947
1082
|
// In with a constant color scales the a8 coverage by that alpha
|
|
948
1083
|
R.FillRectangles(
|
|
@@ -985,7 +1120,59 @@ class RenderingContext2d {
|
|
|
985
1120
|
out.w,
|
|
986
1121
|
out.h,
|
|
987
1122
|
);
|
|
988
|
-
this.
|
|
1123
|
+
this.maskStats.masks++;
|
|
1124
|
+
this.maskStats.pixels += out.w * out.h;
|
|
1125
|
+
return true;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
/**
|
|
1129
|
+
* Core fill: rasterize device-space polys into the scratch a8 mask,
|
|
1130
|
+
* scale by globalAlpha, intersect with the clip, composite the source.
|
|
1131
|
+
*/
|
|
1132
|
+
_fillPolys(polys, rule, { src = null, op = null, alpha = null } = {}) {
|
|
1133
|
+
if (!polys.length) return;
|
|
1134
|
+
src = src ?? this._backgroundPicture;
|
|
1135
|
+
op = op ?? this._op();
|
|
1136
|
+
alpha = alpha ?? this.globalAlpha;
|
|
1137
|
+
if (alpha <= 0) return;
|
|
1138
|
+
if (!preparePattern(src, this._m)) return;
|
|
1139
|
+
|
|
1140
|
+
// one box per subpath, so a path holding disjoint ones can be masked as
|
|
1141
|
+
// the pieces it is rather than as the box around all of them
|
|
1142
|
+
const shapes = [];
|
|
1143
|
+
const pieces = [];
|
|
1144
|
+
for (const p of polys) {
|
|
1145
|
+
if (p.pts.length < 6) continue;
|
|
1146
|
+
const b = this._pointsBBox(p.pts);
|
|
1147
|
+
if (!b) continue;
|
|
1148
|
+
shapes.push(p.pts);
|
|
1149
|
+
pieces.push(b);
|
|
1150
|
+
}
|
|
1151
|
+
if (!shapes.length) return;
|
|
1152
|
+
|
|
1153
|
+
this._ensureFillMask();
|
|
1154
|
+
let painted = false;
|
|
1155
|
+
for (const cluster of this._maskClusters(pieces, op)) {
|
|
1156
|
+
const flat = [];
|
|
1157
|
+
let edges = 0;
|
|
1158
|
+
for (const i of cluster.items) {
|
|
1159
|
+
flat.push(shapes[i]);
|
|
1160
|
+
edges += shapes[i].length / 2;
|
|
1161
|
+
}
|
|
1162
|
+
painted =
|
|
1163
|
+
this._paintThroughMask(
|
|
1164
|
+
cluster,
|
|
1165
|
+
{ polys: flat, rule },
|
|
1166
|
+
{
|
|
1167
|
+
edges,
|
|
1168
|
+
src,
|
|
1169
|
+
op,
|
|
1170
|
+
alpha,
|
|
1171
|
+
server: () => this._rasterizePolys(this.fillMask, flat, rule),
|
|
1172
|
+
},
|
|
1173
|
+
) || painted;
|
|
1174
|
+
}
|
|
1175
|
+
if (painted) this._markDirty();
|
|
989
1176
|
}
|
|
990
1177
|
|
|
991
1178
|
_strokePolys(polys, { src = null } = {}) {
|
|
@@ -1325,14 +1512,22 @@ class RenderingContext2d {
|
|
|
1325
1512
|
const chunk = 4000 * 6;
|
|
1326
1513
|
if (direct) {
|
|
1327
1514
|
for (let i = 0; i < tris.length; i += chunk) {
|
|
1515
|
+
const batch = tris.slice(i, i + chunk);
|
|
1516
|
+
// RENDER aligns the source with the *first triangle's first vertex*,
|
|
1517
|
+
// not with the destination: the source is sampled at
|
|
1518
|
+
// (srcX + x - floor(tris[0].x)). Passing that vertex back is what
|
|
1519
|
+
// makes source coordinates equal destination coordinates, the same
|
|
1520
|
+
// convention drawGlyphRuns and compositeTraps use — without it every
|
|
1521
|
+
// non-constant stroke style (a gradient, a pattern) is offset by
|
|
1522
|
+
// wherever the stroke happens to start, and shifts as it moves.
|
|
1328
1523
|
this.Render.Triangles(
|
|
1329
1524
|
op,
|
|
1330
1525
|
src.id,
|
|
1331
|
-
0,
|
|
1332
|
-
|
|
1526
|
+
Math.floor(batch[0]),
|
|
1527
|
+
Math.floor(batch[1]),
|
|
1333
1528
|
this.picture.id,
|
|
1334
1529
|
this.Render.a8,
|
|
1335
|
-
|
|
1530
|
+
batch,
|
|
1336
1531
|
);
|
|
1337
1532
|
}
|
|
1338
1533
|
this._markDirty();
|
|
@@ -1342,91 +1537,117 @@ class RenderingContext2d {
|
|
|
1342
1537
|
// Render coverage into the scratch mask, then composite through it so
|
|
1343
1538
|
// the stroke honors clip / globalAlpha / composite op.
|
|
1344
1539
|
//
|
|
1345
|
-
// Bounded to the stroke's
|
|
1346
|
-
//
|
|
1347
|
-
//
|
|
1348
|
-
//
|
|
1349
|
-
//
|
|
1350
|
-
//
|
|
1351
|
-
//
|
|
1540
|
+
// Bounded to the stroke's islands, for the same reason _fillPolys is: on
|
|
1541
|
+
// the wire a Composite is the same size either way, but this branch runs
|
|
1542
|
+
// once per stroke, and a wall of 400 round-capped icons is 3200 of them.
|
|
1543
|
+
// Clearing and compositing the whole surface each time was ~6 Gpx a frame
|
|
1544
|
+
// and took 1.9 s on XQuartz where the bounded version takes 37 ms
|
|
1545
|
+
// (react-x11#148). Stale mask content outside the boxes is never
|
|
1546
|
+
// composited, so clearing only them is safe.
|
|
1352
1547
|
this._ensureFillMask();
|
|
1353
|
-
const
|
|
1354
|
-
if (!
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1548
|
+
const pieces = this._trisPieces(tris);
|
|
1549
|
+
if (!pieces.length) return;
|
|
1550
|
+
// the soup itself when it is one piece holding every triangle — the
|
|
1551
|
+
// common case (one polyline is one piece), and worth not copying
|
|
1552
|
+
const whole =
|
|
1553
|
+
pieces.length === 1 &&
|
|
1554
|
+
pieces[0].start === 0 &&
|
|
1555
|
+
pieces[0].end === tris.length;
|
|
1556
|
+
let painted = false;
|
|
1557
|
+
for (const cluster of this._maskClusters(pieces, op)) {
|
|
1558
|
+
const batch = whole ? tris : this._trisOf(tris, pieces, cluster.items);
|
|
1559
|
+
painted =
|
|
1560
|
+
this._paintThroughMask(
|
|
1561
|
+
cluster,
|
|
1562
|
+
{ triangles: batch },
|
|
1563
|
+
{
|
|
1564
|
+
edges: batch.length / 2,
|
|
1565
|
+
src,
|
|
1566
|
+
op,
|
|
1567
|
+
alpha: this.globalAlpha,
|
|
1568
|
+
server: () => {
|
|
1569
|
+
const opaque = this.createSolidPicture(0, 0, 0, 1);
|
|
1570
|
+
for (let i = 0; i < batch.length; i += chunk) {
|
|
1571
|
+
this.Render.Triangles(
|
|
1572
|
+
this.Render.PictOp.Add,
|
|
1573
|
+
opaque.id,
|
|
1574
|
+
0,
|
|
1575
|
+
0,
|
|
1576
|
+
this.fillMask.id,
|
|
1577
|
+
this.Render.a8,
|
|
1578
|
+
batch.slice(i, i + chunk),
|
|
1579
|
+
);
|
|
1580
|
+
}
|
|
1581
|
+
},
|
|
1582
|
+
},
|
|
1583
|
+
) || painted;
|
|
1584
|
+
}
|
|
1585
|
+
if (painted) this._markDirty();
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
/**
|
|
1589
|
+
* A stroke's triangle soup as the islands it is made of: consecutive
|
|
1590
|
+
* triangles are coalesced while they stay within `PIECE_SLACK` of each
|
|
1591
|
+
* other, which collapses a polyline — body, caps, join disks and all —
|
|
1592
|
+
* into one piece and leaves a batch of separate strokes as one piece each.
|
|
1593
|
+
* Each piece carries the `[start, end)` span of `tris` it owns.
|
|
1594
|
+
*
|
|
1595
|
+
* Coalescing is always safe: merging boxes can only widen a mask, while
|
|
1596
|
+
* splitting overlapping coverage apart is what would double-blend it —
|
|
1597
|
+
* and `clusterBoxes` cannot split overlapping boxes anyway. So this is a
|
|
1598
|
+
* linear pre-pass whose only job is to hand the clustering a handful of
|
|
1599
|
+
* boxes instead of thousands of triangles.
|
|
1600
|
+
*
|
|
1601
|
+
* A piece that misses the surface entirely is dropped, leaving a hole in
|
|
1602
|
+
* the spans — which is why the caller gathers a batch from the spans it
|
|
1603
|
+
* has rather than assuming they tile `tris`.
|
|
1604
|
+
*/
|
|
1605
|
+
_trisPieces(tris) {
|
|
1606
|
+
const pieces = [];
|
|
1607
|
+
let open = null;
|
|
1608
|
+
const close = () => {
|
|
1609
|
+
if (!open) return;
|
|
1610
|
+
const b = this._clampBBox(open.minX, open.minY, open.maxX, open.maxY);
|
|
1611
|
+
if (b) pieces.push({ ...b, start: open.start, end: open.end });
|
|
1612
|
+
open = null;
|
|
1613
|
+
};
|
|
1614
|
+
for (let i = 0; i < tris.length; i += 6) {
|
|
1615
|
+
const minX = Math.min(tris[i], tris[i + 2], tris[i + 4]);
|
|
1616
|
+
const maxX = Math.max(tris[i], tris[i + 2], tris[i + 4]);
|
|
1617
|
+
const minY = Math.min(tris[i + 1], tris[i + 3], tris[i + 5]);
|
|
1618
|
+
const maxY = Math.max(tris[i + 1], tris[i + 3], tris[i + 5]);
|
|
1619
|
+
if (
|
|
1620
|
+
open &&
|
|
1621
|
+
minX - PIECE_SLACK <= open.maxX &&
|
|
1622
|
+
open.minX - PIECE_SLACK <= maxX &&
|
|
1623
|
+
minY - PIECE_SLACK <= open.maxY &&
|
|
1624
|
+
open.minY - PIECE_SLACK <= maxY
|
|
1625
|
+
) {
|
|
1626
|
+
if (minX < open.minX) open.minX = minX;
|
|
1627
|
+
if (maxX > open.maxX) open.maxX = maxX;
|
|
1628
|
+
if (minY < open.minY) open.minY = minY;
|
|
1629
|
+
if (maxY > open.maxY) open.maxY = maxY;
|
|
1630
|
+
open.end = i + 6;
|
|
1631
|
+
continue;
|
|
1389
1632
|
}
|
|
1633
|
+
close();
|
|
1634
|
+
open = { minX, minY, maxX, maxY, start: i, end: i + 6 };
|
|
1390
1635
|
}
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
this.fillMask.id,
|
|
1405
|
-
out.x,
|
|
1406
|
-
out.y,
|
|
1407
|
-
0,
|
|
1408
|
-
0,
|
|
1409
|
-
out.x,
|
|
1410
|
-
out.y,
|
|
1411
|
-
out.w,
|
|
1412
|
-
out.h,
|
|
1413
|
-
);
|
|
1636
|
+
close();
|
|
1637
|
+
return pieces;
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
/** the triangles of the given pieces, gathered into one soup */
|
|
1641
|
+
_trisOf(tris, pieces, items) {
|
|
1642
|
+
let n = 0;
|
|
1643
|
+
for (const i of items) n += pieces[i].end - pieces[i].start;
|
|
1644
|
+
const out = new Array(n);
|
|
1645
|
+
let w = 0;
|
|
1646
|
+
for (const i of items) {
|
|
1647
|
+
const piece = pieces[i];
|
|
1648
|
+
for (let k = piece.start; k < piece.end; ++k) out[w++] = tris[k];
|
|
1414
1649
|
}
|
|
1415
|
-
|
|
1416
|
-
op,
|
|
1417
|
-
src.id,
|
|
1418
|
-
this.fillMask.id,
|
|
1419
|
-
this.picture.id,
|
|
1420
|
-
out.x,
|
|
1421
|
-
out.y,
|
|
1422
|
-
out.x,
|
|
1423
|
-
out.y,
|
|
1424
|
-
out.x,
|
|
1425
|
-
out.y,
|
|
1426
|
-
out.w,
|
|
1427
|
-
out.h,
|
|
1428
|
-
);
|
|
1429
|
-
this._markDirty();
|
|
1650
|
+
return out;
|
|
1430
1651
|
}
|
|
1431
1652
|
|
|
1432
1653
|
/**
|
|
@@ -1463,6 +1684,7 @@ class RenderingContext2d {
|
|
|
1463
1684
|
drawGlyphs(op, src, positioned) {
|
|
1464
1685
|
const app = this.window.app;
|
|
1465
1686
|
const R = this.Render;
|
|
1687
|
+
if (!preparePattern(src, this._m)) return;
|
|
1466
1688
|
if (!this._clips.length) {
|
|
1467
1689
|
drawGlyphRuns(app, op, src.id, this.picture.id, positioned);
|
|
1468
1690
|
this._markDirty();
|
|
@@ -1684,6 +1906,7 @@ class RenderingContext2d {
|
|
|
1684
1906
|
|
|
1685
1907
|
fillRect(x, y, w, h) {
|
|
1686
1908
|
if (matIsIdentity(this._m)) {
|
|
1909
|
+
if (!preparePattern(this._backgroundPicture, this._m)) return;
|
|
1687
1910
|
const mask = this._compositeMask();
|
|
1688
1911
|
this.Render.Composite(
|
|
1689
1912
|
this._op(),
|
|
@@ -2390,7 +2613,13 @@ class RenderingContext2d {
|
|
|
2390
2613
|
[0, 0, 0, 0],
|
|
2391
2614
|
[0, 0, bb.w, bb.h],
|
|
2392
2615
|
);
|
|
2393
|
-
this._rasterizePolys(
|
|
2616
|
+
this._rasterizePolys(
|
|
2617
|
+
tmpMask,
|
|
2618
|
+
fillableShapes(entry.polys).shapes,
|
|
2619
|
+
entry.rule,
|
|
2620
|
+
-bb.x,
|
|
2621
|
+
-bb.y,
|
|
2622
|
+
);
|
|
2394
2623
|
}
|
|
2395
2624
|
R.Composite(
|
|
2396
2625
|
op,
|
|
@@ -2429,14 +2658,8 @@ class RenderingContext2d {
|
|
|
2429
2658
|
_uploadClipCoverage(tmpPixmap, entry, bb) {
|
|
2430
2659
|
const rasterizer = this.window.app.rasterizer;
|
|
2431
2660
|
if (!rasterizer) return false;
|
|
2432
|
-
const
|
|
2433
|
-
|
|
2434
|
-
for (const p of entry.polys) {
|
|
2435
|
-
if (p.pts.length < 6) continue;
|
|
2436
|
-
flat.push(p.pts);
|
|
2437
|
-
edges += p.pts.length / 2;
|
|
2438
|
-
}
|
|
2439
|
-
if (!flat.length) return false;
|
|
2661
|
+
const { shapes, edges } = fillableShapes(entry.polys);
|
|
2662
|
+
if (!shapes.length) return false;
|
|
2440
2663
|
if (
|
|
2441
2664
|
routeRaster(bb.w, bb.h, edges, this.window.app.rasterPolicy) !== "local"
|
|
2442
2665
|
) {
|
|
@@ -2444,7 +2667,7 @@ class RenderingContext2d {
|
|
|
2444
2667
|
}
|
|
2445
2668
|
|
|
2446
2669
|
const coverage = rasterizer.rasterize({
|
|
2447
|
-
polys:
|
|
2670
|
+
polys: shapes,
|
|
2448
2671
|
rule: entry.rule,
|
|
2449
2672
|
dx: -bb.x,
|
|
2450
2673
|
dy: -bb.y,
|
|
@@ -2748,6 +2971,35 @@ class RenderingContext2d {
|
|
|
2748
2971
|
return new CanvasGradient("conical", this, x0, y0, angle);
|
|
2749
2972
|
}
|
|
2750
2973
|
|
|
2974
|
+
/**
|
|
2975
|
+
* A tiled paint: `source` repeated across whatever it fills, by the server,
|
|
2976
|
+
* in the one composite the fill already costs (issue #263).
|
|
2977
|
+
*
|
|
2978
|
+
* const tile = new Surface(app, { width: 24, height: 24 });
|
|
2979
|
+
* tile.render((c) => { c.fillStyle = '#333'; c.fillRect(0, 0, 1, 1); });
|
|
2980
|
+
* ctx.fillStyle = ctx.createPattern(tile, 'repeat');
|
|
2981
|
+
* ctx.fillRect(0, 0, ctx.width, ctx.height); // one request, no mask
|
|
2982
|
+
*
|
|
2983
|
+
* That is the difference between a background grid costing one repeating
|
|
2984
|
+
* picture and costing a pane-sized coverage mask: drawn as thousands of
|
|
2985
|
+
* tiny subpaths, a dot grid rasterizes client-side into an a8 mask the
|
|
2986
|
+
* size of its own bounding box — which for a background *is* the pane —
|
|
2987
|
+
* then uploads and composites it, every frame.
|
|
2988
|
+
*
|
|
2989
|
+
* `source` is a `Surface` (pixels the server drew), an `Image` (pixels
|
|
2990
|
+
* uploaded from the client), a `Pixmap` or a `Window`.
|
|
2991
|
+
*
|
|
2992
|
+
* `repetition` is `'repeat'` (the default), `'no-repeat'`, or the two
|
|
2993
|
+
* XRender modes the canvas spec has no name for: `'pad'` (clamp to the
|
|
2994
|
+
* edge pixels) and `'reflect'` (mirror every other tile). The spec's
|
|
2995
|
+
* per-axis `'repeat-x'`/`'repeat-y'` are not among them — XRender repeats
|
|
2996
|
+
* a source picture on both axes or on neither — and asking for one throws
|
|
2997
|
+
* with the clip-to-a-strip equivalent.
|
|
2998
|
+
*/
|
|
2999
|
+
createPattern(source, repetition = "repeat") {
|
|
3000
|
+
return new CanvasPattern(this.window.app, source, repetition);
|
|
3001
|
+
}
|
|
3002
|
+
|
|
2751
3003
|
/** the pixel layout of whatever this context currently draws into */
|
|
2752
3004
|
get _layout() {
|
|
2753
3005
|
const depth =
|
|
@@ -2986,8 +3238,13 @@ class RenderingContext2d {
|
|
|
2986
3238
|
* colour to fold into; `_drawCoverage` routes those through the scratch. */
|
|
2987
3239
|
_coverageSource() {
|
|
2988
3240
|
const style = this._fillStyle;
|
|
2989
|
-
if (this.globalAlpha >= 1 || !isPlainColor(style))
|
|
2990
|
-
|
|
3241
|
+
if (this.globalAlpha >= 1 || !isPlainColor(style)) {
|
|
3242
|
+
// a pattern whose transform collapsed paints nothing, and "nothing" as
|
|
3243
|
+
// a source is transparent black — no call site here has to know
|
|
3244
|
+
return preparePattern(this._backgroundPicture, this._m)
|
|
3245
|
+
? this._backgroundPicture
|
|
3246
|
+
: this.createSolidPicture(0, 0, 0, 0);
|
|
3247
|
+
}
|
|
2991
3248
|
const c = parseColor(style);
|
|
2992
3249
|
return this.createSolidPicture(c[0], c[1], c[2], c[3] * this.globalAlpha);
|
|
2993
3250
|
}
|
|
@@ -3478,9 +3735,167 @@ class CanvasGradient {
|
|
|
3478
3735
|
}
|
|
3479
3736
|
}
|
|
3480
3737
|
|
|
3738
|
+
/**
|
|
3739
|
+
* The fill/stroke style `ctx.createPattern` returns: a tile and how it
|
|
3740
|
+
* repeats, backed by one repeating XRender source picture.
|
|
3741
|
+
*
|
|
3742
|
+
* The picture is created on first use and freed by `destroy()` (or by the
|
|
3743
|
+
* GC, through `Picture`'s finalizer). The tile it reads is *not* the
|
|
3744
|
+
* pattern's to free: destroying the `Surface`/`Image` it came from is safe
|
|
3745
|
+
* while the pattern lives — X keeps pixmap storage alive as long as a
|
|
3746
|
+
* picture references it — but the pixels stop tracking anything drawn after.
|
|
3747
|
+
*
|
|
3748
|
+
* A pattern is bound to the connection, not to the context that made it, so
|
|
3749
|
+
* one grid tile serves every window on the app.
|
|
3750
|
+
*/
|
|
3751
|
+
class CanvasPattern {
|
|
3752
|
+
constructor(app, source, repetition = "repeat") {
|
|
3753
|
+
const name = repetition ?? "repeat";
|
|
3754
|
+
const repeat = REPEAT_MODES[name];
|
|
3755
|
+
if (repeat === undefined) {
|
|
3756
|
+
const known = Object.keys(REPEAT_MODES)
|
|
3757
|
+
.map((k) => `'${k}'`)
|
|
3758
|
+
.join(", ");
|
|
3759
|
+
const axis = name === "repeat-x" || name === "repeat-y";
|
|
3760
|
+
throw new Error(
|
|
3761
|
+
`createPattern: unsupported repetition ${JSON.stringify(name)}` +
|
|
3762
|
+
(axis
|
|
3763
|
+
? " — XRender repeats a source picture on both axes or on neither, with no" +
|
|
3764
|
+
" per-axis mode to map this one to. Tile with 'repeat' and bound the fill" +
|
|
3765
|
+
" to the one row/column of tiles instead: ctx.fillRect(x, y, w, tile.height)" +
|
|
3766
|
+
" repeats horizontally and nowhere else."
|
|
3767
|
+
: "") +
|
|
3768
|
+
`. Supported: ${known}. ${PATTERN_DOCS}`,
|
|
3769
|
+
);
|
|
3770
|
+
}
|
|
3771
|
+
const { drawable, format, width, height } = patternSourceOf(app, source);
|
|
3772
|
+
|
|
3773
|
+
this.app = app;
|
|
3774
|
+
this.Render = app.display.Render;
|
|
3775
|
+
this.source = source;
|
|
3776
|
+
this.repetition = name;
|
|
3777
|
+
this.width = width;
|
|
3778
|
+
this.height = height;
|
|
3779
|
+
this._repeat = repeat;
|
|
3780
|
+
this._drawable = drawable;
|
|
3781
|
+
this._format = format;
|
|
3782
|
+
// pattern space -> user space, the canvas `CanvasPattern.setTransform`
|
|
3783
|
+
// matrix. The picture transform is its inverse, composed with the CTM.
|
|
3784
|
+
this._m = [1, 0, 0, 1, 0, 0];
|
|
3785
|
+
this._picture = null;
|
|
3786
|
+
// what the server currently holds: a fresh picture is untransformed and
|
|
3787
|
+
// filtered nearest, so an untransformed fill costs no extra request
|
|
3788
|
+
this._applied = [1, 0, 0, 1, 0, 0];
|
|
3789
|
+
this._filter = "nearest";
|
|
3790
|
+
}
|
|
3791
|
+
|
|
3792
|
+
/**
|
|
3793
|
+
* Position/scale/rotate the tile, canvas-style: `matrix` maps pattern
|
|
3794
|
+
* space to user space, as `[a, b, c, d, e, f]` or a DOMMatrix-shaped
|
|
3795
|
+
* `{a, b, c, d, e, f}`. Translating by the scroll offset is what keeps a
|
|
3796
|
+
* grid glued to the content under it.
|
|
3797
|
+
*/
|
|
3798
|
+
setTransform(matrix) {
|
|
3799
|
+
const m = Array.isArray(matrix)
|
|
3800
|
+
? matrix
|
|
3801
|
+
: [matrix.a, matrix.b, matrix.c, matrix.d, matrix.e, matrix.f];
|
|
3802
|
+
if (m.length < 6 || m.some((v) => !Number.isFinite(Number(v)))) {
|
|
3803
|
+
throw new Error(
|
|
3804
|
+
"CanvasPattern.setTransform: expected [a, b, c, d, e, f] or {a, b, c, d, e, f} of finite numbers",
|
|
3805
|
+
);
|
|
3806
|
+
}
|
|
3807
|
+
this._m = m.slice(0, 6).map(Number);
|
|
3808
|
+
return this;
|
|
3809
|
+
}
|
|
3810
|
+
|
|
3811
|
+
/** the repeating source Picture, created on first use */
|
|
3812
|
+
get picture() {
|
|
3813
|
+
if (!this._picture) {
|
|
3814
|
+
this._picture = new Picture(this.app, {
|
|
3815
|
+
drawable: this._drawable,
|
|
3816
|
+
format: this._format,
|
|
3817
|
+
repeat: this._repeat,
|
|
3818
|
+
});
|
|
3819
|
+
}
|
|
3820
|
+
return this._picture;
|
|
3821
|
+
}
|
|
3822
|
+
|
|
3823
|
+
/** the Picture id, which is all a fill needs of a style */
|
|
3824
|
+
get id() {
|
|
3825
|
+
return this.picture.id;
|
|
3826
|
+
}
|
|
3827
|
+
|
|
3828
|
+
/**
|
|
3829
|
+
* Make the server-side mapping match `ctm ∘ patternMatrix`. XRender's
|
|
3830
|
+
* picture transform runs the other way — it takes a coordinate in the
|
|
3831
|
+
* composite's source space (which every fill here keeps equal to device
|
|
3832
|
+
* space) to a texel — so it is the inverse.
|
|
3833
|
+
*
|
|
3834
|
+
* Returns false when that composition collapses (a zero scale), which
|
|
3835
|
+
* paints nothing, exactly as the canvas spec says.
|
|
3836
|
+
*/
|
|
3837
|
+
_sync(ctm) {
|
|
3838
|
+
const m = matMultiply(ctm, this._m);
|
|
3839
|
+
const inv = matInvert(m);
|
|
3840
|
+
if (!inv) return false;
|
|
3841
|
+
const a = this._applied;
|
|
3842
|
+
if (
|
|
3843
|
+
inv[0] !== a[0] ||
|
|
3844
|
+
inv[1] !== a[1] ||
|
|
3845
|
+
inv[2] !== a[2] ||
|
|
3846
|
+
inv[3] !== a[3] ||
|
|
3847
|
+
inv[4] !== a[4] ||
|
|
3848
|
+
inv[5] !== a[5]
|
|
3849
|
+
) {
|
|
3850
|
+
this.Render.SetPictureTransform(this.id, [
|
|
3851
|
+
inv[0],
|
|
3852
|
+
inv[2],
|
|
3853
|
+
inv[4],
|
|
3854
|
+
inv[1],
|
|
3855
|
+
inv[3],
|
|
3856
|
+
inv[5],
|
|
3857
|
+
0,
|
|
3858
|
+
0,
|
|
3859
|
+
1,
|
|
3860
|
+
]);
|
|
3861
|
+
this._applied = inv;
|
|
3862
|
+
}
|
|
3863
|
+
// A tile landing on whole pixels wants its own pixels back, not a blend
|
|
3864
|
+
// of them: nearest is both exact and cheaper. Anything else is resampled.
|
|
3865
|
+
const filter =
|
|
3866
|
+
m[0] === 1 &&
|
|
3867
|
+
m[1] === 0 &&
|
|
3868
|
+
m[2] === 0 &&
|
|
3869
|
+
m[3] === 1 &&
|
|
3870
|
+
Number.isInteger(m[4]) &&
|
|
3871
|
+
Number.isInteger(m[5])
|
|
3872
|
+
? "nearest"
|
|
3873
|
+
: "bilinear";
|
|
3874
|
+
if (filter !== this._filter) {
|
|
3875
|
+
this.picture.setFilter(filter);
|
|
3876
|
+
this._filter = filter;
|
|
3877
|
+
}
|
|
3878
|
+
return true;
|
|
3879
|
+
}
|
|
3880
|
+
|
|
3881
|
+
/** free the repeating picture; the tile it read is the caller's */
|
|
3882
|
+
destroy() {
|
|
3883
|
+
if (this._picture) {
|
|
3884
|
+
this._picture.destroy();
|
|
3885
|
+
this._picture = null;
|
|
3886
|
+
}
|
|
3887
|
+
this._applied = [1, 0, 0, 1, 0, 0];
|
|
3888
|
+
this._filter = "nearest";
|
|
3889
|
+
}
|
|
3890
|
+
|
|
3891
|
+
[Symbol.dispose]() {
|
|
3892
|
+
this.destroy();
|
|
3893
|
+
}
|
|
3894
|
+
}
|
|
3895
|
+
|
|
3481
3896
|
// register context
|
|
3482
3897
|
Drawable.renderingContextFactory["2d"] = (window) =>
|
|
3483
3898
|
new RenderingContext2d(window);
|
|
3484
3899
|
|
|
3485
3900
|
export default RenderingContext2d;
|
|
3486
|
-
export { CanvasGradient };
|
|
3901
|
+
export { CanvasGradient, CanvasPattern };
|