spine-html 0.5.2 → 0.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.
@@ -1,4 +1,6 @@
1
1
  import { BlendMode, ClippingAttachment, MeshAttachment, RegionAttachment, } from '@esotericsoftware/spine-core';
2
+ import { coreCompatFor } from './coreCompat.js';
3
+ import { straightAlphaSource } from './DomTexture.js';
2
4
  import { getMeshGlBlitter } from './MeshGlBlitter.js';
3
5
  const regionVertices = new Float32Array(8);
4
6
  const SVG_NS = 'http://www.w3.org/2000/svg';
@@ -25,6 +27,56 @@ function expandPoint(x, y, cx, cy, amount) {
25
27
  const s = amount / len;
26
28
  return [x + dx * s, y + dy * s];
27
29
  }
30
+ /**
31
+ * Formats one clip-path coordinate, rounded to 1/1000 of the element's local
32
+ * unit so that a pose which recomputes to a bit-different float does not
33
+ * defeat the write cache.
34
+ *
35
+ * Why 1/1000: the local unit is an atlas unit for a rigid `<img>` (the matrix
36
+ * denominator) and a CSS pixel for a mesh canvas, so the worst error this can
37
+ * add on screen is half a quantum times the element's local-to-screen scale.
38
+ * At a 50× upscale — far past anything a skeleton is posed at — that is
39
+ * 0.025 px, an order of magnitude under one device pixel at dpr 3, while
40
+ * still sitting three orders of magnitude above the f32 recomputation jitter
41
+ * of coordinates that run 10²–10³ units. Shortest-round-trip number
42
+ * formatting then keeps every coordinate at three decimals or fewer.
43
+ */
44
+ function clipCoord(v) {
45
+ return `${clipNum(v)}px`;
46
+ }
47
+ /**
48
+ * The same quantized number without a unit, for `path()` data — SVG path
49
+ * coordinates are user units and a `px` suffix there is a parse error.
50
+ */
51
+ function clipNum(v) {
52
+ return Math.round(v * 1000) / 1000;
53
+ }
54
+ /**
55
+ * Bound past which a local coordinate is treated as degenerate rather than
56
+ * written. Two reasons, and the second is the hard one: a near-singular
57
+ * element matrix sends the inverse — and with it these coordinates — toward
58
+ * infinity, and JavaScript switches to exponential notation at 1e21, which is
59
+ * not valid CSS. The browser would then drop the whole declaration and the
60
+ * element would render UNCLIPPED, which is the one failure mode a clip must
61
+ * never have. A billion local units is already far outside anything a
62
+ * skeleton poses, so nothing expressible is lost by stopping here.
63
+ */
64
+ const CLIP_COORD_LIMIT = 1e9;
65
+ /**
66
+ * Does `endSlot` still lie ahead of `index` in the draw order? A null end slot
67
+ * never does, and neither does the clip's own slot (the scan starts past it)
68
+ * nor one already passed — which is exactly why each of those clips runs to
69
+ * the end of the draw order instead of ending.
70
+ */
71
+ function endsAhead(drawOrder, index, endSlot) {
72
+ if (!endSlot)
73
+ return false;
74
+ for (let i = index + 1, n = drawOrder.length; i < n; i++) {
75
+ if (drawOrder[i].data === endSlot)
76
+ return true;
77
+ }
78
+ return false;
79
+ }
28
80
  const BLEND_CSS = {
29
81
  [BlendMode.Normal]: '',
30
82
  [BlendMode.Additive]: 'plus-lighter',
@@ -49,9 +101,9 @@ const BLEND_CSS = {
49
101
  * RGB tint (skeleton × slot × attachment color) is applied per element with
50
102
  * an SVG feColorMatrix reference filter — exact channel multiply, works the
51
103
  * same on <img> and <canvas>. Dark (two-color) tint is not expressible that
52
- * way and is unsupported. Clipping attachments are deliberately unsupported
53
- * (transparent layered parts make them unnecessary); they are counted in
54
- * clipSkipCount.
104
+ * way and is unsupported. Clipping attachments become a CSS clip-path per
105
+ * element, in that element's own local frame, on spine-core's semantics — one
106
+ * active clip at a time, ending at the end slot inclusive (see `clipping`).
55
107
  *
56
108
  * Coordinate mapping: Spine is Y-up, CSS is Y-down — world Y is negated.
57
109
  * spine-core computes everything (bones, constraints, physics, deformed
@@ -60,8 +112,42 @@ const BLEND_CSS = {
60
112
  export class SpineHtmlRenderer {
61
113
  root;
62
114
  regionImages;
63
- /** Clipping attachments encountered (visible but unsupported). */
115
+ /**
116
+ * Apply clipping attachments (default). Each element drawn inside an active
117
+ * clip gets a CSS `clip-path` in its own local frame — an element-level
118
+ * feature, so both mesh backends see exactly the same thing and neither
119
+ * raster path knows clipping exists.
120
+ *
121
+ * spine-core's semantics, not an approximation of them: one clip is active
122
+ * at a time (a second clipping attachment met while one is active is
123
+ * ignored, as SkeletonClipping.clipStart does), a clip applies to the slots
124
+ * drawn after its own slot through its end slot inclusive, and an end slot
125
+ * that is the clip's own slot — or one already passed — never ends it, so
126
+ * the clip runs to the end of the draw order. That last case is the
127
+ * whole-skeleton clip, and it takes a fast path: one `clip-path` on the root
128
+ * instead of one per element. The root is the caller's element, so its
129
+ * inline `clip-path` is borrowed — saved on the first write and restored
130
+ * when the clip stops covering the frame, when `clipping` goes false, and by
131
+ * `dispose()`.
132
+ *
133
+ * False restores the pre-0.6 behaviour: clips are counted in clipSkipCount
134
+ * and nothing is clipped, with any clip-path this renderer wrote removed.
135
+ */
136
+ clipping = true;
137
+ /** Clipping attachments applied last frame. */
138
+ clipCount = 0;
139
+ /**
140
+ * Clipping attachments NOT applied last frame: `clipping` is false, another
141
+ * clip was already active (spine-core ignores the second one), the slot's
142
+ * bone is inactive, or the polygon has fewer than three points.
143
+ */
64
144
  clipSkipCount = 0;
145
+ /**
146
+ * `clip-path` style writes performed last frame, across slot elements and
147
+ * the root, clears included. A static polygon over static elements settles
148
+ * at zero: every clip-path is cached per element exactly as `transform` is.
149
+ */
150
+ clipWriteCount = 0;
65
151
  /** Mesh canvases rasterized last frame. */
66
152
  meshCount = 0;
67
153
  /** Mesh canvases that reused their previous raster last frame. */
@@ -105,6 +191,13 @@ export class SpineHtmlRenderer {
105
191
  meshBackend = 'canvas2d';
106
192
  /** Backend that actually rasterized the mesh tier during the last render(). */
107
193
  meshBackendActive = 'canvas2d';
194
+ /**
195
+ * How this renderer reaches spine-core, picked from the first skeleton it is
196
+ * handed and then reused — see coreCompat.ts. Null until then, because the
197
+ * constructor has no skeleton to look at and the installed core is not
198
+ * knowable from the root element.
199
+ */
200
+ core = null;
108
201
  views = new Map();
109
202
  pendingJobs = [];
110
203
  pendingViews = [];
@@ -116,6 +209,25 @@ export class SpineHtmlRenderer {
116
209
  glPages = null;
117
210
  scratchVertices = new Float32Array(256);
118
211
  tintDefs = null;
212
+ /** The clip in force at the current point of the draw order, if any. */
213
+ clipAttachment = null;
214
+ clipEndSlot = null;
215
+ clipInverse = false;
216
+ /** True while the active clip is served by one clip-path on the root. */
217
+ clipOnRoot = false;
218
+ /** Whether any element has been drawn yet this frame (the fast-path test). */
219
+ drewAnything = false;
220
+ /** The active clip polygon in CSS space (Y already negated), flat x,y. */
221
+ clipPolygon = new Float64Array(0);
222
+ clipPolygonLength = 0;
223
+ /**
224
+ * The root's inline `clip-path` before this renderer borrowed it; null means
225
+ * not borrowed. The root belongs to the caller, so the fast path gives back
226
+ * exactly the string it found.
227
+ */
228
+ rootClipSaved = null;
229
+ /** Last clip-path string written to the root, to skip no-op style writes. */
230
+ rootClipWritten = '';
119
231
  /**
120
232
  * @param root Positioned element (e.g. position:absolute) that becomes the
121
233
  * skeleton origin. The renderer only appends slot elements to it — layout
@@ -216,20 +328,49 @@ export class SpineHtmlRenderer {
216
328
  return next;
217
329
  }
218
330
  render(skeleton) {
331
+ // One feature detection per renderer, never per slot and never per frame.
332
+ const core = (this.core ??= coreCompatFor(skeleton));
333
+ this.clipCount = 0;
219
334
  this.clipSkipCount = 0;
335
+ this.clipWriteCount = 0;
220
336
  this.meshCount = 0;
221
337
  this.meshReuseCount = 0;
222
338
  this.canvasReallocCount = 0;
223
339
  this.triangleCount = 0;
340
+ this.clipAttachment = null;
341
+ this.clipEndSlot = null;
342
+ this.clipInverse = false;
343
+ this.clipOnRoot = false;
344
+ this.drewAnything = false;
224
345
  const blitter = this.meshBackend === 'webgl' ? getMeshGlBlitter() : null;
225
346
  this.meshBackendActive = blitter ? 'webgl' : 'canvas2d';
226
- const drawOrder = skeleton.drawOrder.appliedPose;
347
+ const drawOrder = core.drawOrder(skeleton);
227
348
  for (let i = 0, n = drawOrder.length; i < n; i++) {
228
349
  const slot = drawOrder[i];
229
- const pose = slot.appliedPose;
350
+ const pose = core.pose(slot);
230
351
  const attachment = pose.attachment;
352
+ // The official draw loops take the clipping attachment before anything
353
+ // else and in this order: end whatever clip this slot closes, start this
354
+ // slot's clip, then `continue` — skipping the per-slot clipEnd at the
355
+ // bottom. That `continue` is the whole own-slot rule: the slot that
356
+ // STARTS a clip is never offered to clipEnd afterwards, so an end slot
357
+ // equal to the clip's own slot ends nothing and the clip runs to the end
358
+ // of the draw order. [spine-core 4.3.13 dist/SkeletonRendererCore.js,
359
+ // and dist/SkeletonRenderer.js of @esotericsoftware/spine-webgl 4.3.13.]
360
+ if (attachment instanceof ClippingAttachment) {
361
+ this.clipEnd(slot);
362
+ // An inactive bone means the official loop never reaches the clipping
363
+ // branch at all, so no clip starts.
364
+ if (slot.bone.active)
365
+ this.clipStart(skeleton, slot, attachment, drawOrder, i);
366
+ else
367
+ this.clipSkipCount++;
368
+ this.hide(slot);
369
+ continue;
370
+ }
231
371
  if (!slot.bone.active) {
232
372
  this.hide(slot);
373
+ this.clipEnd(slot);
233
374
  continue;
234
375
  }
235
376
  if (attachment instanceof RegionAttachment) {
@@ -239,11 +380,15 @@ export class SpineHtmlRenderer {
239
380
  this.renderMesh(skeleton, slot, pose, attachment, i);
240
381
  }
241
382
  else {
242
- if (attachment instanceof ClippingAttachment)
243
- this.clipSkipCount++;
244
383
  this.hide(slot);
245
384
  }
385
+ // After the slot is drawn: the end slot is inside its own clip.
386
+ this.clipEnd(slot);
246
387
  }
388
+ // Only a clip that covered the whole frame keeps the root's style; every
389
+ // other frame hands the caller's inline value back.
390
+ if (!this.clipOnRoot)
391
+ this.releaseRootClip();
247
392
  if (this.pendingJobs.length) {
248
393
  if (!blitter || !blitter.flush(this.pendingJobs)) {
249
394
  // Context lost mid-frame: rasterize this batch on the 2d path so the
@@ -251,7 +396,10 @@ export class SpineHtmlRenderer {
251
396
  for (let i = 0; i < this.pendingJobs.length; i++) {
252
397
  const job = this.pendingJobs[i];
253
398
  this.pendingViews[i].meshBackendDrawn = 'canvas2d';
254
- this.rasterizeMesh2d(job.canvas, job.page, job.vertices, job.uvs, job.triangles, job.ratio);
399
+ this.rasterizeMesh2d(job.canvas,
400
+ // The 2d path wants straight alpha where the upload wanted it
401
+ // premultiplied; the derivation is cached, so this costs a lookup.
402
+ straightAlphaSource(job.page, job.pma), job.vertices, job.uvs, job.triangles, job.ratio);
255
403
  }
256
404
  }
257
405
  this.pendingJobs.length = 0;
@@ -260,8 +408,9 @@ export class SpineHtmlRenderer {
260
408
  }
261
409
  /**
262
410
  * Removes every element this renderer added to the root (slot elements and
263
- * the tint filter defs), and hands the atlas pages it uploaded back to the
264
- * shared GL blitter the one resource here that outlives the instance, so
411
+ * the tint filter defs), restores the root's own inline `clip-path` if the
412
+ * whole-skeleton clip path borrowed it, and hands the atlas pages it
413
+ * uploaded back to the shared GL blitter — the one resource here that outlives the instance, so
265
414
  * the one that has to be given back explicitly (GPU memory, and no garbage
266
415
  * collector feels pressure from it). The pages are reference-counted there:
267
416
  * a page another live renderer still draws stays uploaded, and the texture
@@ -276,6 +425,9 @@ export class SpineHtmlRenderer {
276
425
  * releases nothing a second time.
277
426
  */
278
427
  dispose() {
428
+ // The root is the caller's element and outlives this renderer, so the
429
+ // borrowed inline clip-path goes back before the slot elements go.
430
+ this.releaseRootClip();
279
431
  for (const view of this.views.values())
280
432
  view.el.remove();
281
433
  this.views.clear();
@@ -291,8 +443,8 @@ export class SpineHtmlRenderer {
291
443
  }
292
444
  // --- rigid tier -----------------------------------------------------------
293
445
  renderRegion(skeleton, slot, pose, attachment, zIndex) {
294
- const sequence = attachment.sequence;
295
- const region = sequence.regions[sequence.resolveIndex(pose)];
446
+ const core = this.core;
447
+ const region = core.regionAt(attachment, slot, core.sequenceIndex(attachment, pose));
296
448
  const regionImage = region && this.regionImages.get(region.name);
297
449
  if (!regionImage) {
298
450
  this.hide(slot);
@@ -306,7 +458,7 @@ export class SpineHtmlRenderer {
306
458
  img.width = regionImage.width;
307
459
  img.height = regionImage.height;
308
460
  }
309
- attachment.computeWorldVertices(slot, attachment.getOffsets(pose), regionVertices, 0, 2);
461
+ core.regionWorldVertices(attachment, slot, pose, regionVertices);
310
462
  // Corner order from spine-core is BL, UL, UR, BR — derived from
311
463
  // computeUVs, whose per-vertex UVs are (u,v2), (u,v), (u2,v), (u2,v2).
312
464
  // (The br/bl/ul/ur comments inside computeWorldVertices are stale.)
@@ -321,23 +473,30 @@ export class SpineHtmlRenderer {
321
473
  const c = (blx - ulx) / h;
322
474
  const d = (bly - uly) / h;
323
475
  this.setTransform(view, `matrix(${a},${b},${c},${d},${ulx},${uly})`);
476
+ // Rigid elements carry a full affine, so the clip polygon rides its
477
+ // inverse into the local (atlas-unit) frame the <img> box spans.
478
+ this.applyClip(view, a, b, c, d, ulx, uly, w, h);
324
479
  this.applyCommon(view, slot, pose, attachment.color, skeleton, zIndex);
325
480
  }
326
481
  // --- deform tier ----------------------------------------------------------
327
482
  renderMesh(skeleton, slot, pose, attachment, zIndex) {
328
- const sequence = attachment.sequence;
329
- const sequenceIndex = sequence.resolveIndex(pose);
330
- const region = sequence.regions[sequenceIndex];
483
+ const core = this.core;
484
+ const sequenceIndex = core.sequenceIndex(attachment, pose);
485
+ const region = core.regionAt(attachment, slot, sequenceIndex);
331
486
  const page = region?.texture?.getImage();
332
- if (!page) {
487
+ if (!region || !page) {
333
488
  this.hide(slot);
334
489
  return;
335
490
  }
491
+ // Which alpha convention the page's texels are in. The two backends consume
492
+ // opposite ones — GL wants them premultiplied, `drawImage` wants them
493
+ // straight — so this decides what each is handed, never what it does.
494
+ const pma = region.page.pma;
336
495
  const count = attachment.worldVerticesLength;
337
496
  if (this.scratchVertices.length < count)
338
497
  this.scratchVertices = new Float32Array(count);
339
498
  const vertices = this.scratchVertices;
340
- attachment.computeWorldVertices(skeleton, slot, 0, count, vertices, 0, 2);
499
+ core.vertexWorldVertices(attachment, skeleton, slot, 0, count, vertices, 0, 2);
341
500
  // World bounds (in CSS coords: Y negated).
342
501
  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
343
502
  for (let v = 0; v < count; v += 2) {
@@ -423,6 +582,11 @@ export class SpineHtmlRenderer {
423
582
  rel[v + 1] = y;
424
583
  }
425
584
  this.setTransform(view, `translate(${minX}px,${minY}px)`);
585
+ // A mesh canvas is posed by a translate only, so its local frame is the
586
+ // CSS-space polygon minus that translate. The clip never reaches the
587
+ // raster: it is not part of the dirty signature, and a mesh whose vertices
588
+ // held still still reuses its raster under a moving clip.
589
+ this.applyClip(view, 1, 0, 0, 1, minX, minY, view.canvasW / ratio, view.canvasH / ratio);
426
590
  if (!dirty) {
427
591
  this.meshReuseCount++;
428
592
  }
@@ -432,7 +596,7 @@ export class SpineHtmlRenderer {
432
596
  view.meshExpand = this.triangleExpand;
433
597
  view.meshVertexCount = count;
434
598
  view.meshBackendDrawn = this.meshBackendActive;
435
- const uvs = sequence.getUVs(sequenceIndex);
599
+ const uvs = core.meshUVs(attachment, sequenceIndex);
436
600
  const triangles = attachment.triangles;
437
601
  if (this.meshBackendActive === 'webgl') {
438
602
  // Queued, not drawn: render() flushes the whole batch through the
@@ -441,6 +605,7 @@ export class SpineHtmlRenderer {
441
605
  this.pendingJobs.push({
442
606
  canvas,
443
607
  page,
608
+ pma,
444
609
  vertices: rel,
445
610
  uvs,
446
611
  triangles,
@@ -452,7 +617,7 @@ export class SpineHtmlRenderer {
452
617
  this.retainPage(page);
453
618
  }
454
619
  else {
455
- this.rasterizeMesh2d(canvas, page, rel, uvs, triangles, ratio);
620
+ this.rasterizeMesh2d(canvas, straightAlphaSource(page, pma), rel, uvs, triangles, ratio);
456
621
  }
457
622
  this.meshCount++;
458
623
  this.triangleCount += triangles.length / 3;
@@ -493,7 +658,9 @@ export class SpineHtmlRenderer {
493
658
  // [0, size] and texel i covers [i, i+1). drawImage-under-a-transform and
494
659
  // texture2D() both read that frame, so both backends land on the same
495
660
  // source pixels. (No half-texel term: the affine below *maps* corners, it
496
- // does not sample — see drawTriangle.)
661
+ // does not sample — see drawTriangle.) `page` is the page image, or its
662
+ // straight-alpha derivation on a premultiplied one; the derivation is the
663
+ // image's own natural size, so the frame is the same either way.
497
664
  const uw = page.width;
498
665
  const uh = page.height;
499
666
  for (let t = 0; t < triangles.length; t += 3) {
@@ -639,6 +806,158 @@ export class SpineHtmlRenderer {
639
806
  ctx.drawImage(img, sx, sy, sw, sh, sx, sy, sw, sh);
640
807
  ctx.restore();
641
808
  }
809
+ // --- clipping tier ---------------------------------------------------------
810
+ /**
811
+ * Begins a clip, mirroring SkeletonClipping.clipStart — whose first line is
812
+ * `if (this.clipAttachment) return`, so a second clipping attachment met
813
+ * while one is active is ignored rather than nested.
814
+ *
815
+ * The polygon is spine-core's: computeWorldVertices gives it in world space
816
+ * each frame, and the only thing done to it here is the Y negation the rest
817
+ * of the renderer already applies. It is NOT convexified or triangulated the
818
+ * way the CPU clipper does — CSS `polygon()` takes a concave polygon
819
+ * directly, so the decomposition that exists to feed a triangle rasterizer
820
+ * has no job here.
821
+ */
822
+ clipStart(skeleton, slot, clip, drawOrder, index) {
823
+ const count = clip.worldVerticesLength;
824
+ if (this.clipAttachment || !this.clipping || count < 6) {
825
+ this.clipSkipCount++;
826
+ return;
827
+ }
828
+ const core = this.core;
829
+ if (this.scratchVertices.length < count)
830
+ this.scratchVertices = new Float32Array(count);
831
+ const world = this.scratchVertices;
832
+ core.vertexWorldVertices(clip, skeleton, slot, 0, count, world, 0, 2);
833
+ if (this.clipPolygon.length < count)
834
+ this.clipPolygon = new Float64Array(count);
835
+ const poly = this.clipPolygon;
836
+ for (let v = 0; v < count; v += 2) {
837
+ poly[v] = world[v];
838
+ poly[v + 1] = -world[v + 1]; // Spine is Y-up, CSS is Y-down.
839
+ }
840
+ this.clipPolygonLength = count;
841
+ this.clipAttachment = clip;
842
+ this.clipEndSlot = clip.endSlot;
843
+ this.clipInverse = core.inverse(clip);
844
+ this.clipCount++;
845
+ // Whole-skeleton fast path: nothing was drawn before this clip started and
846
+ // it never ends, so every element of this frame is inside it — one
847
+ // clip-path on the root beats one per element. An inverse clip is
848
+ // excluded: its CSS form needs an outer ring around the element's own box,
849
+ // and the root is an origin element with no box to use.
850
+ this.clipOnRoot =
851
+ !this.drewAnything && !this.clipInverse && !endsAhead(drawOrder, index, clip.endSlot);
852
+ if (this.clipOnRoot)
853
+ this.writeRootClip(this.rootClipPath());
854
+ }
855
+ /**
856
+ * Ends the active clip if this slot is its end slot, called after the slot
857
+ * has been drawn so that the end slot is itself clipped. Every other slot is
858
+ * ignored, exactly as SkeletonClipping.clipEnd ignores it.
859
+ *
860
+ * This cannot fire for a clip on the root fast path: that path is only taken
861
+ * when no slot ahead of the clip carries its end slot.
862
+ */
863
+ clipEnd(slot) {
864
+ if (this.clipAttachment && this.clipEndSlot === slot.data) {
865
+ this.clipAttachment = null;
866
+ this.clipEndSlot = null;
867
+ }
868
+ }
869
+ /**
870
+ * Writes (or clears) this element's clip-path. `a…f` is the element's own
871
+ * CSS transform, and the world polygon is carried through its INVERSE:
872
+ * clip-path is resolved in the element's local frame and then transformed
873
+ * along with the element, so the polygon has to arrive there already.
874
+ *
875
+ * `transform-origin` is `0 0` on every slot element (see view()), so the
876
+ * matrix maps local (0,0) to (e,f) with no origin term to undo, and
877
+ * `left/top: 0` puts the border box — clip-path's reference box — on that
878
+ * same local origin.
879
+ */
880
+ applyClip(view, a, b, c, d, e, f, boxW, boxH) {
881
+ if (!this.clipAttachment || this.clipOnRoot) {
882
+ this.setClipPath(view, '');
883
+ return;
884
+ }
885
+ const det = a * d - b * c;
886
+ // Singular: the element is collapsed onto a line or a point and draws
887
+ // nothing, so there is no clip to express and no style write to pay for.
888
+ if (det === 0)
889
+ return;
890
+ const inv = 1 / det;
891
+ const poly = this.clipPolygon;
892
+ const n = this.clipPolygonLength;
893
+ // An inverse clip shows what is OUTSIDE the polygon. That shape has a hole
894
+ // in it, so it needs TWO rings — an outer one and the polygon — and
895
+ // `polygon()` cannot carry two: it is one closed ring, and listing a box
896
+ // and a polygon inside it just makes one self-intersecting ring whose
897
+ // even-odd fill leaves a wedge of the seam between them (measured, on a
898
+ // corner of spineboy's boot). `path()` takes subpaths, so an inverse clip
899
+ // is written as two of them with the even-odd rule. The outer ring is the
900
+ // element's own box: the element paints nothing outside it anyway, so
901
+ // box-minus-polygon is exactly the visible part of the inverse region.
902
+ const inverse = this.clipInverse;
903
+ let s = inverse
904
+ ? `path(evenodd,'M0 0H${clipNum(boxW)}V${clipNum(boxH)}H0Z`
905
+ : 'polygon(';
906
+ for (let v = 0; v < n; v += 2) {
907
+ const dx = poly[v] - e;
908
+ const dy = poly[v + 1] - f;
909
+ const lx = (d * dx - c * dy) * inv;
910
+ const ly = (a * dy - b * dx) * inv;
911
+ // Near-singular matrices blow the inverse up; see CLIP_COORD_LIMIT for
912
+ // why an unwritable coordinate must not become a written-and-ignored one.
913
+ if (!(Math.abs(lx) <= CLIP_COORD_LIMIT) || !(Math.abs(ly) <= CLIP_COORD_LIMIT))
914
+ return;
915
+ if (inverse) {
916
+ s += `${v > 0 ? 'L' : 'M'}${clipNum(lx)} ${clipNum(ly)}`;
917
+ }
918
+ else {
919
+ s += `${v > 0 ? ',' : ''}${clipCoord(lx)} ${clipCoord(ly)}`;
920
+ }
921
+ }
922
+ this.setClipPath(view, inverse ? `${s}Z')` : `${s})`);
923
+ }
924
+ setClipPath(view, clipPath) {
925
+ if (view.clipPath === clipPath)
926
+ return;
927
+ view.clipPath = clipPath;
928
+ view.el.style.clipPath = clipPath;
929
+ this.clipWriteCount++;
930
+ }
931
+ /** The active polygon in the root's own frame — no element transform. */
932
+ rootClipPath() {
933
+ const poly = this.clipPolygon;
934
+ let s = 'polygon(';
935
+ for (let v = 0, n = this.clipPolygonLength; v < n; v += 2) {
936
+ if (v > 0)
937
+ s += ',';
938
+ s += `${clipCoord(poly[v])} ${clipCoord(poly[v + 1])}`;
939
+ }
940
+ return `${s})`;
941
+ }
942
+ writeRootClip(clipPath) {
943
+ // Borrow the caller's inline value once, so releaseRootClip gives back
944
+ // exactly what was there. Reading `.style` is CSSOM, not a layout read.
945
+ if (this.rootClipSaved === null)
946
+ this.rootClipSaved = this.root.style.clipPath;
947
+ if (this.rootClipWritten === clipPath)
948
+ return;
949
+ this.rootClipWritten = clipPath;
950
+ this.root.style.clipPath = clipPath;
951
+ this.clipWriteCount++;
952
+ }
953
+ releaseRootClip() {
954
+ if (this.rootClipSaved === null)
955
+ return;
956
+ this.root.style.clipPath = this.rootClipSaved;
957
+ this.rootClipSaved = null;
958
+ this.rootClipWritten = '';
959
+ this.clipWriteCount++;
960
+ }
642
961
  // --- shared plumbing -------------------------------------------------------
643
962
  setTransform(view, transform) {
644
963
  if (view.transform !== transform) {
@@ -647,6 +966,9 @@ export class SpineHtmlRenderer {
647
966
  }
648
967
  }
649
968
  applyCommon(view, slot, pose, attachmentColor, skeleton, zIndex) {
969
+ // This element is about to be visible: the whole-skeleton fast path is
970
+ // only open to a clip that starts before anything has been drawn.
971
+ this.drewAnything = true;
650
972
  const sc = skeleton.color;
651
973
  const pc = pose.color;
652
974
  if (view.zIndex !== zIndex) {
@@ -739,6 +1061,7 @@ export class SpineHtmlRenderer {
739
1061
  opacity: 1,
740
1062
  blendMode: BlendMode.Normal,
741
1063
  transform: '',
1064
+ clipPath: '',
742
1065
  tintR: 1,
743
1066
  tintG: 1,
744
1067
  tintB: 1,
@@ -761,10 +1084,17 @@ export class SpineHtmlRenderer {
761
1084
  }
762
1085
  hide(slot) {
763
1086
  const view = this.views.get(slot);
764
- if (view && view.visible) {
1087
+ if (!view)
1088
+ return;
1089
+ if (view.visible) {
765
1090
  view.visible = false;
766
1091
  view.el.style.display = 'none';
767
1092
  }
1093
+ // A hidden element keeps no clip-path: one left behind would sit there
1094
+ // stale until the slot draws again, and "nothing is clipped" has to be
1095
+ // true of the DOM, not only of what gets painted. Cached like every other
1096
+ // write, so the sweep costs one write per element, once.
1097
+ this.setClipPath(view, '');
768
1098
  }
769
1099
  }
770
1100
  //# sourceMappingURL=SpineHtmlRenderer.js.map