ntk 7.2.0 → 7.3.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.
@@ -0,0 +1,408 @@
1
+ // The swap chain behind a direct rendering context: GPU buffers in, X pixmaps
2
+ // out, and the bookkeeping that keeps the two in step.
3
+ //
4
+ // A frame's life:
5
+ //
6
+ // draw with GL -> surface.swap() -> dma-buf fd (first time only)
7
+ // -> DRI3.PixmapFromBuffer -> a pixmap for that buffer
8
+ // -> Present.Pixmap -> shown at the next vblank
9
+ // <- PresentIdleNotify -> the buffer is ours again
10
+ //
11
+ // No pixel data crosses the socket: the descriptor is passed once per buffer,
12
+ // and every later frame in that buffer is a Present of a pixmap the server
13
+ // already has.
14
+ //
15
+ // Two rules shape the code. **A buffer belongs to the server until IdleNotify
16
+ // says otherwise** — drawing into one before then paints whatever is on
17
+ // screen — so a buffer is released back to GBM there and nowhere else. And
18
+ // **a pixmap must exist before it is presented**: a new buffer's import is
19
+ // confirmed by a round trip before the first Present names it, or the Present
20
+ // would fail with BadPixmap. A new buffer appears a handful of times per
21
+ // generation (and never again in steady state), so that round trip is a
22
+ // startup cost, not a per-frame one.
23
+
24
+ import { GLError, glError } from './gl.js';
25
+
26
+ // bpp is 32 for both formats we use; depth is what tells them apart (24 =
27
+ // XRGB, no alpha; 32 = ARGB, per-pixel alpha the compositor blends)
28
+ const BPP = 32;
29
+
30
+ /**
31
+ * One generation of buffers: a GBM surface at one size, and the pixmap each
32
+ * of its buffers was imported as.
33
+ *
34
+ * A resize cannot just drop the old surface — the server may still be
35
+ * scanning out one of its buffers — so the old generation is retired and
36
+ * torn down as its buffers come back idle.
37
+ */
38
+ class Generation {
39
+ constructor(surface, width, height, linear) {
40
+ this.surface = surface;
41
+ this.width = width;
42
+ this.height = height;
43
+ this.linear = linear;
44
+ this.pixmaps = new Map(); // buffer key -> { pixmap, busy }
45
+ this.retired = false;
46
+ }
47
+
48
+ /** buffers the server still holds */
49
+ get busyCount() {
50
+ let n = 0;
51
+ for (const entry of this.pixmaps.values()) if (entry.busy) n++;
52
+ return n;
53
+ }
54
+ }
55
+
56
+ export class GLSwapchain {
57
+ /**
58
+ * @param {object} opts
59
+ * @param {import('./window.js').default} opts.window the drawable presented to
60
+ * @param {object} opts.gpu an x11-dri Gpu
61
+ * @param {object} opts.dri the x11-dri module
62
+ * @param {object} opts.DRI3 node-x11 dri3 extension
63
+ * @param {object} opts.Present node-x11 present extension
64
+ * @param {number} opts.depth the window's depth (24 or 32)
65
+ * @param {object} opts.policy resolved glPolicy
66
+ */
67
+ constructor({ window, gpu, dri, DRI3, Present, depth, policy }) {
68
+ this.window = window;
69
+ this.X = window.X;
70
+ this.gpu = gpu;
71
+ this.dri = dri;
72
+ this.DRI3 = DRI3;
73
+ this.Present = Present;
74
+ this.depth = depth;
75
+ this.policy = policy;
76
+
77
+ this.generation = null;
78
+ this.retired = [];
79
+ this.serial = 0;
80
+ this.inFlight = 0;
81
+ /** an import is being confirmed; no frame may swap until it lands */
82
+ this.importing = false;
83
+ /** the last swap found every buffer busy — a frame is owed once one frees */
84
+ this.starved = false;
85
+ this.destroyed = false;
86
+ this.error = null;
87
+ /** called when a frame became possible again (idle buffer, import done) */
88
+ this.onReady = null;
89
+ /** called once the first buffer has been imported, or it failed */
90
+ this.onValidated = null;
91
+ this._validated = false;
92
+
93
+ this._eid = this.X.AllocID();
94
+ this.Present.SelectInput(
95
+ this._eid,
96
+ window.id,
97
+ Present.EventMask.CompleteNotify | Present.EventMask.IdleNotify
98
+ );
99
+ }
100
+
101
+ /**
102
+ * Point the chain at a size. Returns the GBM surface to draw into, creating
103
+ * a new generation when the size changed.
104
+ */
105
+ surfaceFor(width, height) {
106
+ const w = Math.max(1, Math.round(width));
107
+ const h = Math.max(1, Math.round(height));
108
+ const gen = this.generation;
109
+ if (gen && gen.width === w && gen.height === h) return gen.surface;
110
+ this._retire(gen);
111
+ // a generation that had to go linear keeps that layout: whatever made the
112
+ // server refuse the tiled buffer has not changed since
113
+ return this._newGeneration(w, h, gen ? gen.linear : false);
114
+ }
115
+
116
+ _newGeneration(w, h, linear) {
117
+ const use = linear ? this.dri.GBM_USE.RENDERING | this.dri.GBM_USE.LINEAR : undefined;
118
+ let surface;
119
+ try {
120
+ surface = use === undefined ? this.gpu.createSurface(w, h) : this.gpu.createSurface(w, h, use);
121
+ } catch (err) {
122
+ throw glError(GLError.CONTEXT_FAILED, `could not create a ${w}x${h} GPU surface: ${err.message}`, null, err);
123
+ }
124
+ this.generation = new Generation(surface, w, h, linear);
125
+ return surface;
126
+ }
127
+
128
+ /** Can a frame be drawn and presented right now? */
129
+ canRender() {
130
+ if (this.destroyed || this.error) return false;
131
+ if (this.importing) return false;
132
+ return this.inFlight < this.policy.maxInFlight;
133
+ }
134
+
135
+ /**
136
+ * Show what was just drawn.
137
+ *
138
+ * Returns false when the frame could not go out — every buffer still held
139
+ * by the server, or an import in flight. The frame is not lost: `onReady`
140
+ * fires when one is free again, and the caller draws it then.
141
+ */
142
+ swap() {
143
+ if (this.destroyed || this.error || this.importing) return false;
144
+ const gen = this.generation;
145
+ if (!gen) return false;
146
+
147
+ let out;
148
+ try {
149
+ out = gen.surface.swap();
150
+ } catch (err) {
151
+ this._fail(glError(GLError.CONTEXT_FAILED, `GPU buffer swap failed: ${err.message}`, null, err));
152
+ return false;
153
+ }
154
+ // every buffer is still on the server's side of the fence
155
+ if (!out) {
156
+ this.starved = true;
157
+ return false;
158
+ }
159
+ this.starved = false;
160
+
161
+ const known = gen.pixmaps.get(out.key);
162
+ if (known) {
163
+ this._present(gen, out.key, known);
164
+ return true;
165
+ }
166
+ this._import(gen, out, true);
167
+ return true;
168
+ }
169
+
170
+ /**
171
+ * Prove the path before anything is drawn: take a buffer, hand its
172
+ * descriptor to the server, and give it straight back.
173
+ *
174
+ * `ready` has to be answerable *before* the first frame — a caller deciding
175
+ * whether to render a fallback cannot be asked to render a frame first, and
176
+ * `await gl.ready` before drawing is the obvious thing to write. Importing
177
+ * without presenting settles it at no cost: the import is what every part of
178
+ * the path has to survive (a descriptor over the socket, a server willing to
179
+ * take this device's buffers), showing it is not, and the pixmap stays
180
+ * cached for whichever frame draws into that buffer later.
181
+ */
182
+ validate() {
183
+ if (this._validated || this.destroyed || this.error) return;
184
+ const gen = this.generation;
185
+ if (!gen) return;
186
+ let out;
187
+ try {
188
+ out = gen.surface.swap();
189
+ } catch (err) {
190
+ return this._fail(glError(GLError.CONTEXT_FAILED, `GPU buffer swap failed: ${err.message}`, null, err));
191
+ }
192
+ // a fresh surface always has a buffer to give, and a new one is new
193
+ if (!out) return;
194
+ if (!out.isNew) return this._validate(null);
195
+ this._import(gen, out, false);
196
+ }
197
+
198
+ /**
199
+ * A buffer the server has not seen: hand over its descriptor, and only
200
+ * present it once the server confirms it took it — a Present naming a
201
+ * pixmap whose import failed is a BadPixmap, which would bury the real
202
+ * error under a second one.
203
+ *
204
+ * @param {boolean} present show this buffer once it is imported, or hand it
205
+ * straight back (the validation path, which has drawn nothing worth
206
+ * showing)
207
+ */
208
+ _import(gen, out, present) {
209
+ const pixmap = this.X.AllocID();
210
+ const entry = { pixmap, busy: false };
211
+ this.importing = true;
212
+ this.DRI3.PixmapFromBuffer(
213
+ pixmap,
214
+ this.window.id,
215
+ {
216
+ fd: out.fd,
217
+ width: out.width,
218
+ height: out.height,
219
+ stride: out.stride,
220
+ depth: this.depth,
221
+ bpp: BPP
222
+ },
223
+ (err) => {
224
+ this.importing = false;
225
+ if (this.destroyed) return;
226
+ if (err) return this._importFailed(gen, out, present, err);
227
+ gen.pixmaps.set(out.key, entry);
228
+ this._validate(null);
229
+ // the generation may have been retired while this was in flight; its
230
+ // buffer is still ours to give back, and the pixmap still needs freeing
231
+ if (gen.retired) {
232
+ this._releaseBuffer(gen, out.key, entry);
233
+ this._sweep(gen);
234
+ } else if (present) {
235
+ this._present(gen, out.key, entry);
236
+ } else {
237
+ this._releaseBuffer(gen, out.key, entry);
238
+ }
239
+ this.onReady?.();
240
+ }
241
+ );
242
+ this.X.flush?.();
243
+ }
244
+
245
+ /**
246
+ * The server would not take the buffer. Almost always this means client and
247
+ * server are on different DRM devices and the tiled layout means nothing to
248
+ * the one displaying it — which a linear buffer fixes, at the cost of some
249
+ * bandwidth. Worth one retry before giving up on the whole backend.
250
+ */
251
+ _importFailed(gen, out, present, err) {
252
+ if (this.policy.linearFallback && !gen.linear && !this._validated) {
253
+ const { width, height } = gen;
254
+ this._retire(gen);
255
+ try {
256
+ this._newGeneration(width, height, true);
257
+ } catch (retryErr) {
258
+ return this._fail(retryErr);
259
+ }
260
+ // the retry has to happen here: on the validation path there is no
261
+ // frame loop yet to come back and try again
262
+ if (!present) this.validate();
263
+ this.onReady?.();
264
+ return;
265
+ }
266
+ this._fail(
267
+ glError(
268
+ GLError.IMPORT_FAILED,
269
+ `the X server refused the GPU buffer (${err.message}), so there is nothing to show`,
270
+ 'Client and server are probably on different DRM devices. Name the server\'s\n' +
271
+ "device with glPolicy: { devicePath: '/dev/dri/renderD###' }, or fall back to\n" +
272
+ "indirect GLX with glPolicy: 'indirect'.",
273
+ err
274
+ )
275
+ );
276
+ }
277
+
278
+ _present(gen, key, entry) {
279
+ entry.busy = true;
280
+ this.inFlight++;
281
+ // targetMsc 0 is "the next vblank", and no Option.Copy: a swap chain wants
282
+ // the server to flip when it can, which is the copy-free path. IdleNotify
283
+ // is what says the buffer came back, whichever it did.
284
+ this.Present.Pixmap(this.window.id, entry.pixmap, { serial: ++this.serial });
285
+ this.X.flush?.();
286
+ }
287
+
288
+ /** Present's events for this window: CompleteNotify and IdleNotify. */
289
+ handleEvent(ev) {
290
+ if (this.destroyed) return;
291
+ const P = this.Present;
292
+ if (ev.evtype === P.events.IdleNotify) return this._onIdle(ev);
293
+ }
294
+
295
+ _onIdle(ev) {
296
+ for (const gen of this._generations()) {
297
+ for (const [key, entry] of gen.pixmaps) {
298
+ if (entry.pixmap !== ev.pixmap || !entry.busy) continue;
299
+ this.inFlight = Math.max(0, this.inFlight - 1);
300
+ this._releaseBuffer(gen, key, entry);
301
+ // every retired generation, not just this buffer's: a sweep can be
302
+ // held back by an import in flight, and this is where it gets another
303
+ // chance to finish
304
+ this._sweepRetired();
305
+ // a frame that could not draw for want of a buffer now can
306
+ if (this.starved || this.inFlight < this.policy.maxInFlight) this.onReady?.();
307
+ return;
308
+ }
309
+ }
310
+ }
311
+
312
+ /** Give a buffer back to GBM so the next frame can be drawn into it. */
313
+ _releaseBuffer(gen, key, entry) {
314
+ entry.busy = false;
315
+ try {
316
+ gen.surface.release(key);
317
+ } catch {
318
+ // a destroyed surface has nothing to take back
319
+ }
320
+ }
321
+
322
+ *_generations() {
323
+ if (this.generation) yield this.generation;
324
+ yield* this.retired;
325
+ }
326
+
327
+ /**
328
+ * Resolve the "does this actually work" promise, once. The first successful
329
+ * import proves the whole path — addon, device, descriptor passing, and a
330
+ * server willing to take the buffer.
331
+ */
332
+ _validate(err) {
333
+ if (this._validated) return;
334
+ this._validated = true;
335
+ this.onValidated?.(err);
336
+ }
337
+
338
+ _fail(err) {
339
+ if (this.error) return;
340
+ this.error = err;
341
+ this._validate(err);
342
+ }
343
+
344
+ _retire(gen) {
345
+ if (!gen || gen.retired) return;
346
+ gen.retired = true;
347
+ this.retired.push(gen);
348
+ this.generation = null;
349
+ this._sweep(gen);
350
+ }
351
+
352
+ _sweepRetired() {
353
+ for (const gen of [...this.retired]) this._sweep(gen);
354
+ }
355
+
356
+ /** Free what a retired generation no longer needs; drop it when empty. */
357
+ _sweep(gen) {
358
+ for (const [key, entry] of gen.pixmaps) {
359
+ if (entry.busy) continue;
360
+ gen.pixmaps.delete(key);
361
+ this._freePixmap(entry.pixmap);
362
+ }
363
+ if (gen.pixmaps.size || this.importing) return;
364
+ const index = this.retired.indexOf(gen);
365
+ if (index >= 0) this.retired.splice(index, 1);
366
+ try {
367
+ gen.surface.destroy();
368
+ } catch {
369
+ // already gone
370
+ }
371
+ }
372
+
373
+ _freePixmap(pixmap) {
374
+ try {
375
+ this.X.FreePixmap(pixmap);
376
+ } catch {
377
+ // the connection is closing; the server frees everything with it
378
+ }
379
+ }
380
+
381
+ destroy() {
382
+ if (this.destroyed) return;
383
+ this.destroyed = true;
384
+ this.onReady = null;
385
+ this.onValidated = null;
386
+ for (const gen of this._generations()) {
387
+ for (const entry of gen.pixmaps.values()) this._freePixmap(entry.pixmap);
388
+ gen.pixmaps.clear();
389
+ try {
390
+ gen.surface.destroy();
391
+ } catch {
392
+ // already gone
393
+ }
394
+ }
395
+ this.generation = null;
396
+ this.retired.length = 0;
397
+ if (this._eid) {
398
+ try {
399
+ this.Present.SelectInput(this._eid, this.window.id, this.Present.EventMask.NoEvent);
400
+ } catch {
401
+ // the window or the connection is already gone
402
+ }
403
+ this._eid = 0;
404
+ }
405
+ }
406
+ }
407
+
408
+ export default GLSwapchain;
package/lib/index.js CHANGED
@@ -3,6 +3,7 @@ import x11 from 'x11';
3
3
  import App from './app.js';
4
4
  import Clipboard from './clipboard.js';
5
5
  import { BLANK_CURSOR, CursorCache, cursorNames, cursorShapes, resolveCursorShape } from './cursor.js';
6
+ import { DEFAULT_GL_POLICY, GLError, GL_MODES, resolveGLPolicy, wantsDirect } from './gl.js';
6
7
  import { GLXError } from './glx.js';
7
8
  import Window from './window.js';
8
9
  import { loadLayout } from './yoga.js';
@@ -46,10 +47,13 @@ import { tokenize as highlightCode } from './widgets/highlight.js';
46
47
  import { cssColorStraight, premultiply } from './color.js';
47
48
  import { cssColor, cssLength } from './widgets/css.js';
48
49
 
49
- // rendering context modules register themselves on Drawable
50
+ // rendering context modules register themselves on Drawable. The direct one
51
+ // comes last on purpose: it wraps the 'opengl' factory the indirect one just
52
+ // registered, so that the backend-neutral name can dispatch on glPolicy.
50
53
  import './renderingcontext_x11.js';
51
54
  import './renderingcontext_2d.js';
52
55
  import './renderingcontext_opengl.js';
56
+ import './renderingcontext_gles.js';
53
57
 
54
58
  // One socket write per frame instead of one per request (x11 >= 3.6). A frame
55
59
  // is emitted in one synchronous run of _runFrame() and ends with the frame
@@ -80,7 +84,11 @@ const DEFAULT_BUFFER_REQUESTS = { maxSize: 64 * 1024 };
80
84
  * { rasterizer, rasterPolicy } — where small fills and strokes are
81
85
  * rasterized, and the thresholds for that choice (see docs/context-2d.md) —
82
86
  * { glxVisual } — a visual id for getContext('opengl') to use instead of
83
- * querying the server for one — and
87
+ * querying the server for one —
88
+ * { glPolicy } — which OpenGL backend to draw through: `'indirect'` (the
89
+ * default, indirect GLX), `'auto'` (direct rendering where it is available),
90
+ * `'direct'` or `'off'`; an object sets the direct backend's knobs too (see
91
+ * docs/context-gles.md) — and
84
92
  * { onXError } — called with X protocol errors no request callback
85
93
  * claimed (default: console.warn).
86
94
  * x11's { bufferRequests } defaults to a 64 KB output buffer here, which
@@ -118,6 +126,11 @@ export function createClient(options, callback) {
118
126
  // so a later setDefaultFontSource still takes effect.
119
127
  defaultFontSource();
120
128
  }
129
+ // Same reasoning for the GL backend: a policy that names no real mode is
130
+ // a rejected connect, not an exception thrown from inside a connection
131
+ // callback later on — and asking now means a bad NTK_GL_POLICY is caught
132
+ // even by an app that never mentions GL.
133
+ const glPolicy = resolveGLPolicy(appOptions);
121
134
 
122
135
  x11.createClient(x11Options, (error, display) => {
123
136
  if (error) return reject(error);
@@ -162,7 +175,20 @@ export function createClient(options, callback) {
162
175
  });
163
176
  updateKeyboardMapping(display.min_keycode, display.max_keycode);
164
177
 
165
- resolve(new App(display, appOptions));
178
+ const app = new App(display, appOptions);
179
+ // Under a policy that could pick the direct backend, find out
180
+ // during the handshake whether it can: two extension queries,
181
+ // running alongside the round trips already in flight here, so
182
+ // that getContext() can choose a backend synchronously later.
183
+ // Nothing to do under the default policy, which is why the common
184
+ // case pays nothing for this.
185
+ if (!wantsDirect(glPolicy)) return resolve(app);
186
+ app.glCapabilities().then(
187
+ () => resolve(app),
188
+ // a probe that throws is a probe that found nothing, and GL is
189
+ // not what the connection is for
190
+ () => resolve(app)
191
+ );
166
192
  });
167
193
  });
168
194
  });
@@ -232,7 +258,11 @@ export {
232
258
  decodeKey,
233
259
  groupForState,
234
260
  // the `code` on a failed GL setup: branch on it rather than on the message
235
- GLXError
261
+ GLXError,
262
+ // the same, for the direct backend, plus what glPolicy accepts
263
+ GLError,
264
+ GL_MODES,
265
+ DEFAULT_GL_POLICY
236
266
  };
237
267
  // The layout engine ntk lays HtmlView out with — downstream layout consumers
238
268
  // (e.g. the react-x11 renderer) must import it from here rather than from
package/lib/path.js CHANGED
@@ -732,6 +732,13 @@ export function transformCommands(cmds, m) {
732
732
  // --------------------------------------------------------------------------
733
733
  // flattening
734
734
 
735
+ /** how far a control point's projection `e` falls outside [0, chord²], times |chord| */
736
+ function overshoot(e, chord2) {
737
+ if (e < 0) return -e;
738
+ if (e > chord2) return e - chord2;
739
+ return 0;
740
+ }
741
+
735
742
  function addCubic(pts, x0, y0, x1, y1, x2, y2, x3, y3, tol2, depth) {
736
743
  const dx = x3 - x0;
737
744
  const dy = y3 - y0;
@@ -753,6 +760,27 @@ function addCubic(pts, x0, y0, x1, y1, x2, y2, x3, y3, tol2, depth) {
753
760
  // over-subdivided a quarter circle ~2x at any radius (issue #213).
754
761
  const err = (0.75 * Math.max(Math.abs(d1), Math.abs(d2))) ** 2;
755
762
  flat = err <= tol2 * chord2;
763
+ // That bound is on the offset *perpendicular* to the chord, and says
764
+ // nothing about how far along it the curve travels: a cubic whose
765
+ // handles point back down the chord runs past an endpoint and returns,
766
+ // staying arbitrarily close to the chord's *line* the whole way. The
767
+ // chord is then within tolerance as a set of points but is traversed in
768
+ // the wrong direction, which is invisible in a fill and catastrophic in
769
+ // a stroke — the join to the next segment becomes a ~180° cusp and its
770
+ // miter shoots off to infinity (issue #233).
771
+ //
772
+ // Project each control point onto the chord: the longitudinal
773
+ // coordinate is itself a cubic in Bernstein form with control values
774
+ // 0, e1, e2, |chord|², so it stays inside the chord's span whenever
775
+ // those do. `overshoot` is the distance past an end times |chord|,
776
+ // which is what makes it comparable to the perpendicular term without
777
+ // a square root.
778
+ if (flat) {
779
+ const e1 = (x1 - x0) * dx + (y1 - y0) * dy;
780
+ const e2 = (x2 - x0) * dx + (y2 - y0) * dy;
781
+ const over = Math.max(overshoot(e1, chord2), overshoot(e2, chord2));
782
+ flat = over * over <= tol2 * chord2;
783
+ }
756
784
  }
757
785
  if (flat || depth >= 18) {
758
786
  pts.push(x3, y3);
@@ -1010,8 +1010,9 @@ class RenderingContext2d {
1010
1010
  // extrude-polyline pushes the first and last points outward along the
1011
1011
  // line, which on a closed loop extends the seam over band it already
1012
1012
  // covers — invisible against an opaque colour, a double-blended edge
1013
- // against a translucent one.
1014
- const closedStroke =
1013
+ // against a translucent one. Runs cut at an escaping join (see
1014
+ // escapingJoins) want butt ends for the same reason.
1015
+ const buttStroke =
1015
1016
  cap === "butt"
1016
1017
  ? stroke
1017
1018
  : extrudePolyline({
@@ -1065,6 +1066,108 @@ class RenderingContext2d {
1065
1066
  // bevel-to-arc gap depth for turn angle θ: r * (1 - cos(θ/2))
1066
1067
  if (r * (1 - Math.sqrt((1 + dot) / 2)) > 0.05) addDisk(b[0], b[1]);
1067
1068
  };
1069
+ /**
1070
+ * Interior vertices whose join extrude-polyline cannot be trusted with,
1071
+ * ascending; null — the common case — for a polyline with none.
1072
+ *
1073
+ * Whatever the join style, it closes the *inner* side of a join at the
1074
+ * intersection of the two inner offsets, r/cos(φ/2) from the vertex for
1075
+ * a turn of φ: 'bevel' bevels the outer side and still emits that
1076
+ * point, and `miterLimit` only chooses which side gets bevelled. As φ
1077
+ * approaches a reversal the intersection runs away to infinity, so a
1078
+ * hairpin — a cusp in a curve, a polyline that doubles back — threw a
1079
+ * spike hundreds of pixels off the path that no join style and no miter
1080
+ * limit could reach (issue #233).
1081
+ *
1082
+ * The intersection is legitimate only while it stays inside the two
1083
+ * segments, which it does when each is at least r·tan(φ/2) long: that
1084
+ * is how far back along both the point sits. Where they are shorter it
1085
+ * is ink outside the path, and the run is cut at that vertex instead —
1086
+ * both sides then end butt on it, so the inner corner is the union of
1087
+ * the two rectangles, and the outer side gets addJoinWedge.
1088
+ */
1089
+ const escapingJoins = (run) => {
1090
+ let cuts = null;
1091
+ for (let i = 1; i < run.length - 1; i++) {
1092
+ const ax = run[i][0] - run[i - 1][0];
1093
+ const ay = run[i][1] - run[i - 1][1];
1094
+ const bx = run[i + 1][0] - run[i][0];
1095
+ const by = run[i + 1][1] - run[i][1];
1096
+ // This runs over every vertex of every stroke and almost never
1097
+ // fires, so it is written to answer "no" in multiplications alone:
1098
+ // squared lengths, and the raw (unnormalized) dot and cross, which
1099
+ // give tan(φ/2) = cross / (|a||b| + dot) directly.
1100
+ const la = ax * ax + ay * ay;
1101
+ const lb = bx * bx + by * by;
1102
+ if (!la || !lb) continue;
1103
+ const shortest = la < lb ? la : lb;
1104
+ const dot = ax * bx + ay * by;
1105
+ // a turn of 90° or less has tan(φ/2) <= 1, and so cannot reach past
1106
+ // a segment that is already at least r long
1107
+ if (dot >= 0 && r * r <= shortest) continue;
1108
+ const cross = ax * by - ay * bx;
1109
+ // r·tan(φ/2) > min(|a|, |b|), squared. A non-positive denominator is
1110
+ // the reversal the tangent is infinite at.
1111
+ const denom = Math.sqrt(la * lb) + dot;
1112
+ if (denom <= 0 || (r * cross) ** 2 > shortest * denom * denom) {
1113
+ (cuts ??= []).push(i);
1114
+ }
1115
+ }
1116
+ return cuts;
1117
+ };
1118
+ /**
1119
+ * The outer side of the join at `b`, which cutting the run leaves to us:
1120
+ * the wedge between the two segments' outer offsets. A miter within the
1121
+ * limit fills it to the tip, everything else bevels — the same choice
1122
+ * the extruder would have made, on geometry that stays put.
1123
+ */
1124
+ const addJoinWedge = (a, b, c) => {
1125
+ const l1 = Math.hypot(b[0] - a[0], b[1] - a[1]);
1126
+ const l2 = Math.hypot(c[0] - b[0], c[1] - b[1]);
1127
+ if (!l1 || !l2) return;
1128
+ const ux = (b[0] - a[0]) / l1;
1129
+ const uy = (b[1] - a[1]) / l1;
1130
+ const vx = (c[0] - b[0]) / l2;
1131
+ const vy = (c[1] - b[1]) / l2;
1132
+ // outer side: away from the turn. A zero cross product is a straight
1133
+ // run or an exact reversal, and neither leaves a wedge to fill.
1134
+ const cross = ux * vy - uy * vx;
1135
+ if (!cross) return;
1136
+ // each segment's own offset at b: the normal r out on the outer side
1137
+ const s = cross > 0 ? -r : r;
1138
+ const p1x = b[0] - uy * s;
1139
+ const p1y = b[1] + ux * s;
1140
+ const p2x = b[0] - vy * s;
1141
+ const p2y = b[1] + vx * s;
1142
+ const dot = Math.max(-1, Math.min(1, ux * vx + uy * vy));
1143
+ const ratio = 1 / Math.sqrt((1 + dot) / 2); // miter length / r
1144
+ if (join === "miter" && ratio <= this.miterLimit) {
1145
+ // the tip is r·ratio along the bisector, where the two offsets meet
1146
+ const mx = p1x + p2x - 2 * b[0];
1147
+ const my = p1y + p2y - 2 * b[1];
1148
+ const ml = Math.hypot(mx, my);
1149
+ const tx = b[0] + (mx / ml) * r * ratio;
1150
+ const ty = b[1] + (my / ml) * r * ratio;
1151
+ tris.push(b[0], b[1], p1x, p1y, tx, ty);
1152
+ tris.push(b[0], b[1], tx, ty, p2x, p2y);
1153
+ return;
1154
+ }
1155
+ tris.push(b[0], b[1], p1x, p1y, p2x, p2y);
1156
+ };
1157
+ const emit = (run, extruder) => {
1158
+ const mesh = extruder.build(run);
1159
+ for (const tri of mesh.cells) {
1160
+ for (let i = 0; i < 3; ++i) {
1161
+ tris.push(mesh.positions[tri[i]][0], mesh.positions[tri[i]][1]);
1162
+ }
1163
+ }
1164
+ };
1165
+ // a square cap's extension of end point `p` away from its neighbour `q`
1166
+ const capOut = (p, q) => {
1167
+ const d = Math.hypot(p[0] - q[0], p[1] - q[1]);
1168
+ if (!d) return p;
1169
+ return [p[0] + ((p[0] - q[0]) / d) * r, p[1] + ((p[1] - q[1]) / d) * r];
1170
+ };
1068
1171
  // one polyline through extrusion + round-geometry post-processing;
1069
1172
  // closed loops carry the seam point at both ends and get no caps
1070
1173
  const extrudeRun = (pts, closed) => {
@@ -1091,11 +1194,30 @@ class RenderingContext2d {
1091
1194
  ];
1092
1195
  pts = [mid, ...pts.slice(1), mid];
1093
1196
  }
1094
- const mesh = (closed ? closedStroke : stroke).build(pts);
1095
- for (const tri of mesh.cells) {
1096
- for (let i = 0; i < 3; ++i) {
1097
- tris.push(mesh.positions[tri[i]][0], mesh.positions[tri[i]][1]);
1197
+ const cuts = escapingJoins(pts);
1198
+ if (!cuts) {
1199
+ emit(pts, closed ? buttStroke : stroke);
1200
+ } else {
1201
+ // Extrude the pieces between the cuts, each ending butt on the cut
1202
+ // vertex it shares with the next. That leaves the run's own two ends
1203
+ // to us as well: extrude-polyline squares both ends of whatever it
1204
+ // is handed, so a square cap is applied here instead.
1205
+ const last = pts.length - 1;
1206
+ let from = 0;
1207
+ for (const to of [...cuts, last]) {
1208
+ const run = pts.slice(from, to + 1);
1209
+ if (!closed && cap === "square") {
1210
+ if (from === 0) run[0] = capOut(run[0], run[1]);
1211
+ if (to === last)
1212
+ run[run.length - 1] = capOut(
1213
+ run[run.length - 1],
1214
+ run[run.length - 2],
1215
+ );
1216
+ }
1217
+ emit(run, buttStroke);
1218
+ from = to;
1098
1219
  }
1220
+ for (const i of cuts) addJoinWedge(pts[i - 1], pts[i], pts[i + 1]);
1099
1221
  }
1100
1222
  if (roundJoin) {
1101
1223
  // every real vertex is interior now, the seam included: a closed