rig-c 2.30.0 → 2.31.1

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.
@@ -55,7 +55,8 @@
55
55
  */
56
56
  import { createHash } from 'node:crypto';
57
57
  import { MeshReductionError, r6 } from './mesh.ts';
58
- import { uvCarriers, type Carrier } from './meshcarriers.ts';
58
+ import { areaBand, triangleAreas } from './areaband.ts';
59
+ import { carrierIn, resolveCarriers, uvCarriers, type Carrier } from './meshcarriers.ts';
59
60
  import type { AttachmentRef, DeformTimelineInput, SourceFrame, SourceMesh, WorstSample } from './meshquality.ts';
60
61
 
61
62
  type Pt = readonly [number, number];
@@ -248,23 +249,7 @@ export function validateSkinning(who: string, skinning: unknown): void {
248
249
  }
249
250
  });
250
251
  }
251
- const envelope = skinning.envelope;
252
- if (!isObject(envelope) || !Array.isArray(envelope.bones)) refuse('REDUCE_INPUT_MISSING', `${who}: skinning.envelope is ${shown(envelope)}; required { reference, bones: [{ bone, linear, pivot, translation }] }`);
253
- if (typeof envelope.reference !== 'string' || envelope.reference === '') refuse('REDUCE_INPUT_MISSING', `${who}: skinning.envelope.reference is ${shown(envelope.reference)}; required a non-empty bone name — the slot's bone (Q4)`);
254
- const seen = new Set<string>();
255
- envelope.bones.forEach((entry: unknown, i: number) => {
256
- const at = `skinning.envelope.bones[${i}]`;
257
- if (!isObject(entry) || typeof entry.bone !== 'string' || entry.bone === '') refuse('REDUCE_INPUT_MISSING', `${who}: ${at} is ${shown(entry)}; required { bone: a non-empty name, linear, pivot, translation }`);
258
- if (entry.bone === envelope.reference) refuse('REDUCE_INPUT_MISSING', `${who}: ${at}.bone is "${entry.bone}", the envelope's reference; required every bone but the reference — the reference's own terms are 0 by definition`);
259
- if (seen.has(entry.bone)) refuse('REDUCE_INPUT_MISSING', `${who}: ${at}.bone "${entry.bone}" is declared twice; required one entry per bone`);
260
- seen.add(entry.bone);
261
- if (!isFiniteNumber(entry.linear) || entry.linear < 0) refuse('REDUCE_INPUT_MISSING', `${who}: ${at}.linear is ${shown(entry.linear)}; required a finite number, 0 or more (εk ≥ ‖Ak − A0‖, dimensionless)`);
262
- if (!isPoint(entry.pivot)) refuse('REDUCE_INPUT_MISSING', `${who}: ${at}.pivot is ${shown(entry.pivot)}; required a pair of finite drawing px`);
263
- if (!isFiniteNumber(entry.translation) || entry.translation < 0) refuse('REDUCE_INPUT_MISSING', `${who}: ${at}.translation is ${shown(entry.translation)}; required a finite number of drawing px, 0 or more`);
264
- });
265
- const bound = skinning.maxResidual;
266
- if (bound === undefined) refuse('REDUCE_INPUT_MISSING', `${who}: skinning.maxResidual is missing; required a finite number of drawing px, 0 or more, or null (declared absent: measured, reported undeclared) — no field has a default`);
267
- if (bound !== null && (!isFiniteNumber(bound) || bound < 0)) refuse('REDUCE_INPUT_MISSING', `${who}: skinning.maxResidual is ${shown(bound)}; required a finite number of drawing px, 0 or more, or null`);
252
+ validateEnvelopeAndBound(who, 'skinning', skinning.envelope, skinning.maxResidual);
268
253
  const deform = skinning.deform;
269
254
  if (!Array.isArray(deform)) refuse('REDUCE_INPUT_MISSING', `${who}: skinning.deform is ${shown(deform)}; required the deform timelines keyed on the attachment or its linked meshes (empty when none)`);
270
255
  deform.forEach((t: unknown, i: number) => {
@@ -274,6 +259,29 @@ export function validateSkinning(who: string, skinning: unknown): void {
274
259
  });
275
260
  }
276
261
 
262
+ /**
263
+ * The envelope and the bound of a residual declaration, refused `REDUCE_INPUT_MISSING` naming the path under `at` —
264
+ * `skinning` on a measurement, `targets.skinning` on a reduction (issue #1295), so the two refuse one value in the
265
+ * same words.
266
+ */
267
+ function validateEnvelopeAndBound(who: string, at: string, envelope: unknown, bound: unknown): void {
268
+ if (!isObject(envelope) || !Array.isArray(envelope.bones)) refuse('REDUCE_INPUT_MISSING', `${who}: ${at}.envelope is ${shown(envelope)}; required { reference, bones: [{ bone, linear, pivot, translation }] }`);
269
+ if (typeof envelope.reference !== 'string' || envelope.reference === '') refuse('REDUCE_INPUT_MISSING', `${who}: ${at}.envelope.reference is ${shown(envelope.reference)}; required a non-empty bone name — the slot's bone (Q4)`);
270
+ const seen = new Set<string>();
271
+ envelope.bones.forEach((entry: unknown, i: number) => {
272
+ const entryAt = `${at}.envelope.bones[${i}]`;
273
+ if (!isObject(entry) || typeof entry.bone !== 'string' || entry.bone === '') refuse('REDUCE_INPUT_MISSING', `${who}: ${entryAt} is ${shown(entry)}; required { bone: a non-empty name, linear, pivot, translation }`);
274
+ if (entry.bone === envelope.reference) refuse('REDUCE_INPUT_MISSING', `${who}: ${entryAt}.bone is "${entry.bone}", the envelope's reference; required every bone but the reference — the reference's own terms are 0 by definition`);
275
+ if (seen.has(entry.bone)) refuse('REDUCE_INPUT_MISSING', `${who}: ${entryAt}.bone "${entry.bone}" is declared twice; required one entry per bone`);
276
+ seen.add(entry.bone);
277
+ if (!isFiniteNumber(entry.linear) || entry.linear < 0) refuse('REDUCE_INPUT_MISSING', `${who}: ${entryAt}.linear is ${shown(entry.linear)}; required a finite number, 0 or more (εk ≥ ‖Ak − A0‖, dimensionless)`);
278
+ if (!isPoint(entry.pivot)) refuse('REDUCE_INPUT_MISSING', `${who}: ${entryAt}.pivot is ${shown(entry.pivot)}; required a pair of finite drawing px`);
279
+ if (!isFiniteNumber(entry.translation) || entry.translation < 0) refuse('REDUCE_INPUT_MISSING', `${who}: ${entryAt}.translation is ${shown(entry.translation)}; required a finite number of drawing px, 0 or more`);
280
+ });
281
+ if (bound === undefined) refuse('REDUCE_INPUT_MISSING', `${who}: ${at}.maxResidual is missing; required a finite number of drawing px, 0 or more, or null (declared absent: measured, reported undeclared) — no field has a default`);
282
+ if (bound !== null && (!isFiniteNumber(bound) || bound < 0)) refuse('REDUCE_INPUT_MISSING', `${who}: ${at}.maxResidual is ${shown(bound)}; required a finite number of drawing px, 0 or more, or null`);
283
+ }
284
+
277
285
  // ---------------------------------------------------------------------------
278
286
  // the source's identity
279
287
  // ---------------------------------------------------------------------------
@@ -314,6 +322,12 @@ export interface SampleTerms {
314
322
  moment: Array<[number, number]>;
315
323
  }
316
324
 
325
+ /** What `termsAt` reads of a mesh: its setup points and its bindings — a `SourceMesh`, or a reduction's working mesh by id. */
326
+ export interface TermsMesh {
327
+ points: ReadonlyArray<readonly [number, number]>;
328
+ weights: ReadonlyArray<ReadonlyArray<{ bone: string; weight: number }>> | null;
329
+ }
330
+
317
331
  /** §3's sample set over art bits at the threshold: every art pixel centre in row order, then the source hull's UVs. */
318
332
  export function skinningSamplesOf(artBits: Uint8Array, width: number, height: number, source: SourceMesh): { samples: SkinningSample[]; art: number } {
319
333
  const samples: SkinningSample[] = [];
@@ -331,7 +345,7 @@ export function skinningSamplesOf(artBits: Uint8Array, width: number, height: nu
331
345
  * A mesh's terms at a carrier — p, and per bone w̄ and the moment s = Σᵢ βᵢ wᵢₖ (xᵢ − p) — over `bones` (index by name).
332
346
  * Shares of a bone `bones` does not name are not read: the coverage check refuses such a mesh before this runs.
333
347
  */
334
- export function termsAt(mesh: SourceMesh, c: Carrier, bones: ReadonlyMap<string, number>): SampleTerms {
348
+ export function termsAt(mesh: TermsMesh, c: Pick<Carrier, 'corners' | 'bary'>, bones: ReadonlyMap<string, number>): SampleTerms {
335
349
  const n = bones.size;
336
350
  const wbar = new Array<number>(n).fill(0);
337
351
  const moment: Array<[number, number]> = Array.from({ length: n }, () => [0, 0]);
@@ -359,6 +373,47 @@ export function termsAt(mesh: SourceMesh, c: Carrier, bones: ReadonlyMap<string,
359
373
  return { p: [px, py], wbar, moment };
360
374
  }
361
375
 
376
+ /** The envelope as the arithmetic reads it: bones in envelope order, the reference last with εk and τk 0 (it enters only the weight-field diagnostic). */
377
+ export function envelopeTerms(envelope: SkinningEnvelope): { index: Map<string, number>; eps: number[]; tau: number[]; pivot: Pt[] } {
378
+ const index = new Map<string, number>();
379
+ envelope.bones.forEach((b, i) => index.set(b.bone, i));
380
+ index.set(envelope.reference, envelope.bones.length);
381
+ const eps = [...envelope.bones.map((b) => b.linear), 0];
382
+ const tau = [...envelope.bones.map((b) => b.translation), 0];
383
+ const pivot: Pt[] = [...envelope.bones.map((b) => b.pivot), [0, 0]];
384
+ return { index, eps, tau, pivot };
385
+ }
386
+
387
+ /**
388
+ * One sample's value of the residual from the two meshes' terms there — Σₖ εₖ |Δsₖ| + Σₖ |Δw̄ₖ| (εₖ |pʳ − cₖ| + τₖ), each
389
+ * bone's two shares, and the field-only differences. The one place the arithmetic is written: the measurement and the
390
+ * reduction's carried form (`SkinningCarry`, issue #1295) both call it, so equal terms give the same bits.
391
+ */
392
+ export function sampleResidual(
393
+ r: SampleTerms,
394
+ c: SampleTerms,
395
+ eps: readonly number[],
396
+ tau: readonly number[],
397
+ pivot: readonly Pt[],
398
+ plant: SkinningPlant | null = null,
399
+ ): { total: number; cov: number[]; lev: number[]; l1: number; lInf: number } {
400
+ const K = eps.length;
401
+ let total = 0;
402
+ let l1 = 0;
403
+ let lInf = 0;
404
+ const cov = new Array<number>(K).fill(0);
405
+ const lev = new Array<number>(K).fill(0);
406
+ for (let k = 0; k < K; k++) {
407
+ const dw = Math.abs(c.wbar[k] - r.wbar[k]);
408
+ l1 += dw;
409
+ lInf = Math.max(lInf, dw);
410
+ if (plant !== 'drop-covariance') cov[k] = eps[k] * Math.hypot(c.moment[k][0] - r.moment[k][0], c.moment[k][1] - r.moment[k][1]);
411
+ if (plant !== 'drop-lever') lev[k] = dw * (eps[k] * Math.hypot(r.p[0] - pivot[k][0], r.p[1] - pivot[k][1]) + tau[k]);
412
+ total += cov[k] + lev[k];
413
+ }
414
+ return { total, cov, lev, l1, lInf };
415
+ }
416
+
362
417
  // ---------------------------------------------------------------------------
363
418
  // the measurement
364
419
  // ---------------------------------------------------------------------------
@@ -392,15 +447,27 @@ function nameOf(ref: AttachmentRef): string {
392
447
  return `${ref.skin ?? '(no skin)'}/${ref.slot}/${ref.attachment}`;
393
448
  }
394
449
 
450
+ /** What the measurement's contract checks leave the value to be read over: the samples, and the contract's own figures. */
451
+ export interface SkinningContract {
452
+ samples: SkinningSample[];
453
+ art: number;
454
+ who: string;
455
+ slack: [number, number];
456
+ vertexGap: number;
457
+ weightSumGap: number;
458
+ }
459
+
395
460
  /**
396
- * The residual over the §3 sample set, or the reason it is refused or not measurable — the order of the checks is
397
- * the order a fix has to happen in: what the motion is, which bones bind, the shares, the setup positions, the
398
- * samples. Internal: on `rig-c/mesh` only through `export *`.
461
+ * The residual's contract checked, in the order a fix has to happen in — what the motion is, which bones bind, the
462
+ * shares, the setup positions, the art floor — or the reading that refuses or cannot measure it. Every check
463
+ * `skinningResidual` makes before it carries a sample; the reduction (issue #1295) reads it once at admission.
464
+ * Internal: on `rig-c/mesh` only through `export *`.
399
465
  */
400
- export function skinningResidual(args: SkinningMeasureArgs, plant: SkinningPlant | null = null): SkinningReading {
401
- const { attachment, frame, candidate, skinning } = args;
466
+ export function skinningContract(args: SkinningMeasureArgs, plant: SkinningPlant | null = null): SkinningReading | SkinningContract {
467
+ const { frame, candidate, skinning } = args;
402
468
  const source = skinning.source.mesh;
403
469
  const envelope = skinning.envelope;
470
+ const attachment = args.attachment;
404
471
  const who = `attachment ${nameOf(attachment)}`;
405
472
  const { samples, art } = skinningSamplesOf(args.artBits, args.maskWidth, args.maskHeight, source);
406
473
  const out = (state: 'refused' | 'not-measurable', reason: string): SkinningReading => ({ state, reason, samples: samples.length, art });
@@ -486,6 +553,22 @@ export function skinningResidual(args: SkinningMeasureArgs, plant: SkinningPlant
486
553
  if (art < args.minArtSamples) {
487
554
  return out('not-measurable', `${who} has ${art} art sample(s) at alpha >= ${args.threshold}; required at least ${args.minArtSamples} (minArtSamples, P9) — the source hull's UVs are samples and do not count towards it`);
488
555
  }
556
+ return { samples, art, who, slack, vertexGap, weightSumGap };
557
+ }
558
+
559
+ /**
560
+ * The residual over the §3 sample set, or the reason it is refused or not measurable — the order of the checks is
561
+ * the order a fix has to happen in: what the motion is, which bones bind, the shares, the setup positions, the
562
+ * samples. Internal: on `rig-c/mesh` only through `export *`.
563
+ */
564
+ export function skinningResidual(args: SkinningMeasureArgs, plant: SkinningPlant | null = null): SkinningReading {
565
+ const contract = skinningContract(args, plant);
566
+ if ('state' in contract) return contract;
567
+ const { attachment, candidate, skinning } = args;
568
+ const source = skinning.source.mesh;
569
+ const envelope = skinning.envelope;
570
+ const { samples, art, who, slack, vertexGap, weightSumGap } = contract;
571
+ const out = (state: 'refused' | 'not-measurable', reason: string): SkinningReading => ({ state, reason, samples: samples.length, art });
489
572
  let sourceCarriers: Array<Carrier | null>;
490
573
  let candidateCarriers: Array<Carrier | null>;
491
574
  try {
@@ -497,13 +580,7 @@ export function skinningResidual(args: SkinningMeasureArgs, plant: SkinningPlant
497
580
  }
498
581
 
499
582
  // The bones in envelope order, the reference last (its εk and τk are 0, so it enters only the weight-field diagnostic).
500
- const index = new Map<string, number>();
501
- envelope.bones.forEach((b, i) => index.set(b.bone, i));
502
- index.set(envelope.reference, envelope.bones.length);
503
- const eps = [...envelope.bones.map((b) => b.linear), 0];
504
- const tau = [...envelope.bones.map((b) => b.translation), 0];
505
- const pivot: Pt[] = [...envelope.bones.map((b) => b.pivot), [0, 0]];
506
- const K = eps.length;
583
+ const { index, eps, tau, pivot } = envelopeTerms(envelope);
507
584
 
508
585
  const uncarried = {
509
586
  source: samples.filter((_s, j) => sourceCarriers[j] === null).map((s) => s.uv),
@@ -525,19 +602,7 @@ export function skinningResidual(args: SkinningMeasureArgs, plant: SkinningPlant
525
602
  const r = termsAt(source, cs, index);
526
603
  const c = termsAt(candidate, cc, index);
527
604
  sampleGap = Math.max(sampleGap, Math.hypot(c.p[0] - r.p[0], c.p[1] - r.p[1]));
528
- let total = 0;
529
- let l1 = 0;
530
- let lInf = 0;
531
- const cov = new Array<number>(K).fill(0);
532
- const lev = new Array<number>(K).fill(0);
533
- for (let k = 0; k < K; k++) {
534
- const dw = Math.abs(c.wbar[k] - r.wbar[k]);
535
- l1 += dw;
536
- lInf = Math.max(lInf, dw);
537
- if (plant !== 'drop-covariance') cov[k] = eps[k] * Math.hypot(c.moment[k][0] - r.moment[k][0], c.moment[k][1] - r.moment[k][1]);
538
- if (plant !== 'drop-lever') lev[k] = dw * (eps[k] * Math.hypot(r.p[0] - pivot[k][0], r.p[1] - pivot[k][1]) + tau[k]);
539
- total += cov[k] + lev[k];
540
- }
605
+ const { total, cov, lev, l1, lInf } = sampleResidual(r, c, eps, tau, pivot, plant);
541
606
  l1Max = Math.max(l1Max, l1);
542
607
  lInfMax = Math.max(lInfMax, lInf);
543
608
  if (total > worst) {
@@ -706,3 +771,629 @@ function envelopeBoneWith(ranges: EnvelopeBoneRanges, plant: EnvelopePlant | nul
706
771
  }
707
772
  return { bone: (ranges.chain[ranges.chain.length - 1] as BoneMotionRange).bone, linear: S * composed, pivot: [c[0], c[1]], translation: S * delta };
708
773
  }
774
+
775
+ // ---------------------------------------------------------------------------
776
+ // the residual carried through a reduction (issue #1295)
777
+ // ---------------------------------------------------------------------------
778
+
779
+ /**
780
+ * `ReductionTargets.skinning` (issue #1295, §7 mechanism 1, the reducer half): the envelope and the bound every step of
781
+ * a reduction is held to, measured against the reduction's own `source` — the original, fixed for the call. A number
782
+ * for `maxResidual` vetoes every removal, boundary run and post-pass whose residual is over it; `null` declares the
783
+ * bound absent — no step reads it and the result's row is `undeclared`.
784
+ */
785
+ export interface ReductionSkinning {
786
+ envelope: SkinningEnvelope;
787
+ maxResidual: number | null;
788
+ }
789
+
790
+ /**
791
+ * `ReductionTargets.skinning` (issue #1295): left out or `null` is accepted as it stands; anything else is
792
+ * `{ envelope, maxResidual }` in full — the measurement's own envelope and bound, refused in its words under
793
+ * `targets.skinning` — else `REDUCE_INPUT_MISSING` naming the path, before any work. The source is the reduction's
794
+ * own `source`, so it is not restated. Internal: on `rig-c/mesh` only through `export *`.
795
+ */
796
+ export function validateReductionSkinning(who: string, value: unknown): void {
797
+ if (value === undefined || value === null) return;
798
+ if (!isObject(value)) {
799
+ refuse('REDUCE_INPUT_MISSING', `${who}: targets.skinning is ${shown(value)}; required { envelope: { reference, bones }, maxResidual }, null, or the field left out — the source it is measured against is the reduction's own source`);
800
+ }
801
+ validateEnvelopeAndBound(who, 'targets.skinning', value.envelope, value.maxResidual);
802
+ }
803
+
804
+ /** A reduction's working mesh as the carried residual reads it: vertices by stable id, read live — the reduction appends to these arrays. */
805
+ export interface CarriedMesh {
806
+ pos: ReadonlyArray<readonly [number, number]>;
807
+ uv: ReadonlyArray<readonly [number, number]>;
808
+ weights: ReadonlyArray<ReadonlyArray<{ bone: string; weight: number }>>;
809
+ }
810
+
811
+ /**
812
+ * Faults planted in the carried residual, for the `mesh-compare` suite's controls (MQ134 onward); a reduction plants
813
+ * none. `stale-carrier`: the samples of a removed triangle are not re-carried, so they keep its carrier and value.
814
+ * `lost-maximum`: when the sample that held the largest value is recomputed, the largest is taken over the recomputed
815
+ * samples alone. `no-rollback`: a refused trial leaves its values in place. `previous-candidate`: each accepted trial
816
+ * becomes the baseline, so the next is measured against the previous accepted candidate rather than the source.
817
+ */
818
+ export type CarryPlant = 'stale-carrier' | 'lost-maximum' | 'no-rollback' | 'previous-candidate';
819
+
820
+ /** What the carried residual computed — the work a control and §7 read; the report carries none of it. */
821
+ export interface CarryTally {
822
+ /** Trials made: one per removal, run or post-pass the static rows let through, and one per refinement insertion. */
823
+ trials: number;
824
+ commits: number;
825
+ rollbacks: number;
826
+ /** Samples whose carrier was searched again and whose value was recomputed, over every trial. */
827
+ samplesRecomputed: number;
828
+ /** Sample × triangle containment tests made by those searches. */
829
+ containmentTests: number;
830
+ trianglesRemoved: number;
831
+ trianglesAdded: number;
832
+ /** Trials that re-carried every sample — the bounded fallback, taken only when the area band moved a triangle it did not change. */
833
+ fullRecarries: number;
834
+ /** The most samples one trial recomputed. */
835
+ peakAffected: number;
836
+ }
837
+
838
+ /** The carried residual's reading of the working mesh: a value on the `r6` grid, or why there is none. */
839
+ export type CarryReading = { state: 'measured'; value: number } | { state: 'refused' | 'not-measurable'; reason: string };
840
+
841
+ /** What a control reads when it holds the carried state to the full recompute (`SkinningCarry.compareWithFull`). */
842
+ export interface CarryCheck {
843
+ /** Every sample's value within `CARRY_TOLERANCE`, the same samples measured, and the same uncarried counts. */
844
+ equal: boolean;
845
+ /** The largest |carried − full| over samples both measure. */
846
+ sampleDiff: number;
847
+ /** Samples measured by one and not the other. */
848
+ measuredMismatch: number;
849
+ carried: { max: number; measured: number; uncarried: number; reading: CarryReading };
850
+ full: { max: number; measured: number; uncarried: number };
851
+ }
852
+
853
+ /**
854
+ * How far a carried sample value may sit from the full recompute's, px. The two read the same terms through
855
+ * `sampleResidual`; they differ only in the order a triangle's corners are summed (the working mesh's order against
856
+ * the canonical mesh's) and, for a sample on an edge two triangles share, in which of them carries it — float
857
+ * rounding of terms of order 10² px. Measured on the controls (MQ135) and stated there.
858
+ */
859
+ export const CARRY_TOLERANCE = 1e-9;
860
+
861
+ type Tri = readonly [number, number, number];
862
+
863
+ /** A triangle's identity across trials: its corners rotated to start at the smallest id, winding kept. */
864
+ function triKey(t: Tri): string {
865
+ const k = t[0] < t[1] ? (t[0] < t[2] ? 0 : 2) : t[1] < t[2] ? 1 : 2;
866
+ return `${t[k]},${t[(k + 1) % 3]},${t[(k + 2) % 3]}`;
867
+ }
868
+
869
+ interface CarryLogEntry {
870
+ j: number;
871
+ slot: number;
872
+ b0: number;
873
+ b1: number;
874
+ b2: number;
875
+ value: number;
876
+ }
877
+
878
+ interface Pending {
879
+ removed: number[];
880
+ firstAdded: number;
881
+ log: CarryLogEntry[];
882
+ appended: Array<[number, number]>;
883
+ measured: number;
884
+ uncarried: number;
885
+ refused: string | null;
886
+ broken: string | null;
887
+ plantMax: { value: number; at: number };
888
+ /** Under `previous-candidate`: the candidate terms of each sample the trial recomputed, made the baseline on commit. */
889
+ rebase: Array<[number, SampleTerms | null]>;
890
+ checked: number[];
891
+ /** A fallback trial: every list is rebuilt from the carriers, and rebuilt again on rollback; with the live flags it moved. */
892
+ fallback: boolean;
893
+ liveWas: Array<[number, boolean]>;
894
+ }
895
+
896
+ /**
897
+ * `MQ_SKINNING_RESIDUAL` carried through a reduction's steps (issue #1295) — the value `skinningResidual` reads of the
898
+ * working mesh against the fixed original source, kept per sample so that a step recomputes only what it changed.
899
+ *
900
+ * - **Fixed for the call:** the §3 samples, each one's carrier in the source and the source's terms there (one
901
+ * `uvCarriers` over the source), and the envelope. The baseline is the source supplied, never a previous candidate.
902
+ * - **Carried per sample:** the candidate's carrier (triangle, barycentric) and the sample's value; per triangle, the
903
+ * samples it carries; a max tree over the values, so the largest — the row's value — is re-derived in O(log n) when
904
+ * the sample that held it is recomputed, without a scan.
905
+ * - **A trial** (`trial`) diffs the working triangles against the carried ones: the samples of every removed triangle,
906
+ * and the uncarried samples inside an added triangle's box, are searched again among the added triangles (and,
907
+ * for a sample a removal leaves outside them, among the unchanged triangles that share a vertex with a removed
908
+ * one); everything else is untouched. `commit` keeps it; `rollback` restores every carrier, value, list, count and
909
+ * the maximum. Work per trial: the triangles' keys (one pass over the triangles) and the affected samples times
910
+ * the triangles searched for them.
911
+ * - **The bounded fallback:** the live set — triangles whose UV area clears `areaBand`, the band `uvCarriers` reads —
912
+ * depends on the largest triangle, so a trial that moves an unchanged triangle across the band re-carries every
913
+ * sample, counted in `fullRecarries`; it is the only full scan, and it is taken only then.
914
+ *
915
+ * Pure: no clock, no randomness, no runtime; every loop runs in a fixed order.
916
+ */
917
+ export class SkinningCarry {
918
+ readonly tally: CarryTally = { trials: 0, commits: 0, rollbacks: 0, samplesRecomputed: 0, containmentTests: 0, trianglesRemoved: 0, trianglesAdded: 0, fullRecarries: 0, peakAffected: 0 };
919
+ private readonly n: number;
920
+ private readonly K: number;
921
+ private readonly index: Map<string, number>;
922
+ private readonly eps: number[];
923
+ private readonly tau: number[];
924
+ private readonly pivot: Pt[];
925
+ private readonly su: Float64Array;
926
+ private readonly sv: Float64Array;
927
+ private readonly art: number;
928
+ private readonly pixelSample: Int32Array;
929
+ private readonly maskWidth: number;
930
+ private readonly maskHeight: number;
931
+ /** The source's terms per sample, or null where the source does not carry the sample — fixed for the call. */
932
+ private readonly sourceTerms: ReadonlyArray<SampleTerms | null>;
933
+ /** What each sample is measured against: the source's terms (only the `previous-candidate` plant moves it). */
934
+ private readonly base: Array<SampleTerms | null>;
935
+ private readonly cSlot: Int32Array;
936
+ private readonly cBary: Float64Array;
937
+ private readonly value: Float64Array;
938
+ private readonly size: number;
939
+ private readonly tree: Int32Array;
940
+ private slotCorners: Array<[number, number, number]> = [];
941
+ private slotKey: string[] = [];
942
+ private slotArea: number[] = [];
943
+ private slotLive: boolean[] = [];
944
+ private slotBox: number[] = [];
945
+ private slotSamples: number[][] = [];
946
+ private readonly keyToSlot = new Map<string, number>();
947
+ private measured = 0;
948
+ private uncarried = 0;
949
+ private broken: string | null = null;
950
+ private readonly checked = new Set<number>();
951
+ private pending: Pending | null = null;
952
+ private plantMax = { value: -1, at: -1 };
953
+ private stamp: Int32Array;
954
+ private stampAt = 0;
955
+
956
+ constructor(
957
+ private readonly who: string,
958
+ private readonly mesh: CarriedMesh,
959
+ private readonly nSource: number,
960
+ triangles: ReadonlyArray<Tri>,
961
+ source: SourceMesh,
962
+ envelope: SkinningEnvelope,
963
+ artBits: Uint8Array,
964
+ maskWidth: number,
965
+ maskHeight: number,
966
+ private readonly frame: { width: number; height: number },
967
+ private readonly plant: CarryPlant | null = null,
968
+ ) {
969
+ const { samples, art } = skinningSamplesOf(artBits, maskWidth, maskHeight, source);
970
+ const terms = envelopeTerms(envelope);
971
+ this.index = terms.index;
972
+ this.eps = terms.eps;
973
+ this.tau = terms.tau;
974
+ this.pivot = terms.pivot;
975
+ this.K = this.eps.length;
976
+ this.n = samples.length;
977
+ this.art = art;
978
+ this.maskWidth = maskWidth;
979
+ this.maskHeight = maskHeight;
980
+ this.su = new Float64Array(this.n);
981
+ this.sv = new Float64Array(this.n);
982
+ this.pixelSample = new Int32Array(maskWidth * maskHeight).fill(-1);
983
+ samples.forEach((s, j) => {
984
+ this.su[j] = s.uv[0];
985
+ this.sv[j] = s.uv[1];
986
+ if (s.pixel !== null) this.pixelSample[s.pixel[1] * maskWidth + s.pixel[0]] = j;
987
+ });
988
+ // The source's carriers and terms, once: the measurement's own `uvCarriers` and `termsAt`.
989
+ const sourceCarriers = uvCarriers(source.uvs, source.triangles, samples, `${who}: source`);
990
+ this.sourceTerms = sourceCarriers.map((c) => (c === null ? null : termsAt(source, c, this.index)));
991
+ this.base = [...this.sourceTerms];
992
+ // The candidate starts as the working mesh handed in — the source, before any step.
993
+ this.cSlot = new Int32Array(this.n).fill(-1);
994
+ this.cBary = new Float64Array(this.n * 3);
995
+ this.value = new Float64Array(this.n).fill(-1);
996
+ let size = 1;
997
+ while (size < Math.max(1, this.n)) size *= 2;
998
+ this.size = size;
999
+ this.tree = new Int32Array(2 * size).fill(-1);
1000
+ this.stamp = new Int32Array(this.n);
1001
+ const flatUv: number[] = [];
1002
+ for (let v = 0; v < mesh.uv.length; v++) flatUv.push(mesh.uv[v][0], mesh.uv[v][1]);
1003
+ const flatTri: number[] = [];
1004
+ for (const t of triangles) flatTri.push(t[0], t[1], t[2]);
1005
+ const areas = triangleAreas(flatUv, flatTri);
1006
+ const band = areaBand(areas, flatUv);
1007
+ triangles.forEach((t, i) => this.addSlot(t, areas[i], band));
1008
+ for (let s = 0; s < this.slotCorners.length; s++) this.keyToSlot.set(this.slotKey[s], s);
1009
+ // The working mesh handed in is the source itself (the reduction builds the carry before any step), so its carriers
1010
+ // are the source's — one search, not two; any other mesh is searched as it is.
1011
+ const same = flatTri.length === source.triangles.length && flatTri.every((v, i) => v === source.triangles[i]) && flatUv.length === source.uvs.length && flatUv.every((v, i) => v === source.uvs[i]);
1012
+ const candidateCarriers = same ? sourceCarriers : uvCarriers(flatUv, flatTri, samples, `${who}: candidate`);
1013
+ candidateCarriers.forEach((c, j) => {
1014
+ if (c === null) {
1015
+ this.uncarried++;
1016
+ return;
1017
+ }
1018
+ this.cSlot[j] = c.triangle;
1019
+ this.cBary[j * 3] = c.bary[0];
1020
+ this.cBary[j * 3 + 1] = c.bary[1];
1021
+ this.cBary[j * 3 + 2] = c.bary[2];
1022
+ this.slotSamples[c.triangle].push(j);
1023
+ // The candidate is the source and carries the sample in the same triangle: its terms are the source's, and
1024
+ // `sampleResidual` of equal terms is 0 exactly — so it is not recomputed.
1025
+ this.setValue(j, same ? (this.base[j] === null ? -1 : 0) : this.valueAt(j));
1026
+ });
1027
+ this.plantMax = this.treeMax();
1028
+ }
1029
+
1030
+ // --- the max tree ---------------------------------------------------------
1031
+
1032
+ private better(a: number, b: number): number {
1033
+ if (a < 0) return b;
1034
+ if (b < 0) return a;
1035
+ return this.value[b] > this.value[a] ? b : a;
1036
+ }
1037
+
1038
+ /** Set a sample's value; the tree is updated unless the constructor is still filling it. */
1039
+ private setValue(j: number, v: number): void {
1040
+ const was = this.value[j];
1041
+ if (was >= 0) this.measured--;
1042
+ this.value[j] = v;
1043
+ if (v >= 0) this.measured++;
1044
+ let i = this.size + j;
1045
+ this.tree[i] = v >= 0 ? j : -1;
1046
+ for (i >>= 1; i >= 1; i >>= 1) this.tree[i] = this.better(this.tree[2 * i], this.tree[2 * i + 1]);
1047
+ }
1048
+
1049
+ private treeMax(): { value: number; at: number } {
1050
+ const at = this.tree[1];
1051
+ return at < 0 ? { value: -1, at: -1 } : { value: this.value[at], at };
1052
+ }
1053
+
1054
+ // --- slots ----------------------------------------------------------------
1055
+
1056
+ private addSlot(t: Tri, area: number, band: number): number {
1057
+ const s = this.slotCorners.length;
1058
+ this.slotCorners.push([t[0], t[1], t[2]]);
1059
+ this.slotKey.push(triKey(t));
1060
+ this.slotArea.push(area);
1061
+ this.slotLive.push(Math.abs(area) > band);
1062
+ const xs = [this.mesh.uv[t[0]][0], this.mesh.uv[t[1]][0], this.mesh.uv[t[2]][0]];
1063
+ const ys = [this.mesh.uv[t[0]][1], this.mesh.uv[t[1]][1], this.mesh.uv[t[2]][1]];
1064
+ this.slotBox.push(Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys));
1065
+ this.slotSamples.push([]);
1066
+ return s;
1067
+ }
1068
+
1069
+ private uvArea(t: Tri): number {
1070
+ const uv = this.mesh.uv;
1071
+ const x0 = uv[t[0]][0];
1072
+ const y0 = uv[t[0]][1];
1073
+ return 0.5 * ((uv[t[1]][0] - x0) * (uv[t[2]][1] - y0) - (uv[t[2]][0] - x0) * (uv[t[1]][1] - y0));
1074
+ }
1075
+
1076
+ private test(j: number, s: number): Carrier | null {
1077
+ this.tally.containmentTests++;
1078
+ const c = this.slotCorners[s];
1079
+ const uv = this.mesh.uv;
1080
+ return carrierIn(this.su[j], this.sv[j], this.slotBox[s * 4], this.slotBox[s * 4 + 1], this.slotBox[s * 4 + 2], this.slotBox[s * 4 + 3], c[0], c[1], c[2], (v) => uv[v][0], (v) => uv[v][1], s);
1081
+ }
1082
+
1083
+ // --- values ---------------------------------------------------------------
1084
+
1085
+ private candidateTerms(j: number): SampleTerms | null {
1086
+ const s = this.cSlot[j];
1087
+ if (s < 0) return null;
1088
+ return termsAt({ points: this.mesh.pos, weights: this.mesh.weights }, { corners: this.slotCorners[s], bary: [this.cBary[j * 3], this.cBary[j * 3 + 1], this.cBary[j * 3 + 2]] }, this.index);
1089
+ }
1090
+
1091
+ private valueAt(j: number): number {
1092
+ const r = this.base[j];
1093
+ const c = this.candidateTerms(j);
1094
+ if (r === null || c === null) return -1;
1095
+ return sampleResidual(r, c, this.eps, this.tau, this.pivot).total;
1096
+ }
1097
+
1098
+ // --- a trial --------------------------------------------------------------
1099
+
1100
+ /**
1101
+ * The working triangles after a proposed operation, carried provisionally: the reading of the mesh they make. Ends
1102
+ * in exactly one `commit` or `rollback`.
1103
+ */
1104
+ trial(triangles: ReadonlyArray<Tri>): CarryReading {
1105
+ if (this.pending !== null) throw new Error('SkinningCarry: a trial was begun while another was pending');
1106
+ this.tally.trials++;
1107
+ const seen = new Uint8Array(this.slotCorners.length);
1108
+ const added: Tri[] = [];
1109
+ let largest = 0;
1110
+ let coordinate = 0;
1111
+ const uv = this.mesh.uv;
1112
+ for (const t of triangles) {
1113
+ const s = this.keyToSlot.get(triKey(t));
1114
+ if (s === undefined) added.push(t);
1115
+ else {
1116
+ seen[s] = 1;
1117
+ largest = Math.max(largest, Math.abs(this.slotArea[s]));
1118
+ }
1119
+ for (let k = 0; k < 3; k++) coordinate = Math.max(coordinate, Math.abs(uv[t[k]][0]), Math.abs(uv[t[k]][1]));
1120
+ }
1121
+ const addedAreas = added.map((t) => this.uvArea(t));
1122
+ for (const a of addedAreas) largest = Math.max(largest, Math.abs(a));
1123
+ // areaBand's two bands over the candidate: the relative one and the float32 noise of its UVs.
1124
+ const band = Math.max(largest * 1e-6, 4 * coordinate * coordinate * 2 ** -24);
1125
+ const removed: number[] = [];
1126
+ let moved = false;
1127
+ for (const s of this.keyToSlot.values()) {
1128
+ if (!seen[s]) removed.push(s);
1129
+ else if (Math.abs(this.slotArea[s]) > band !== this.slotLive[s]) moved = true;
1130
+ }
1131
+ removed.sort((a, b) => a - b);
1132
+ const pending: Pending = {
1133
+ removed,
1134
+ firstAdded: this.slotCorners.length,
1135
+ log: [],
1136
+ appended: [],
1137
+ measured: this.measured,
1138
+ uncarried: this.uncarried,
1139
+ refused: null,
1140
+ broken: this.broken,
1141
+ plantMax: { ...this.plantMax },
1142
+ rebase: [],
1143
+ checked: [],
1144
+ fallback: moved,
1145
+ liveWas: [],
1146
+ };
1147
+ this.pending = pending;
1148
+ this.tally.trianglesRemoved += removed.length;
1149
+ this.tally.trianglesAdded += added.length;
1150
+ const addedSlots = added.map((t, i) => this.addSlot(t, addedAreas[i], band));
1151
+ const isRemoved = new Uint8Array(this.slotCorners.length);
1152
+ for (const s of removed) isRemoved[s] = 1;
1153
+
1154
+ // The contract an inserted vertex has to meet, as the measurement checks every vertex of the candidate.
1155
+ for (const s of addedSlots) for (const v of this.slotCorners[s]) if (v >= this.nSource && !this.checked.has(v)) this.checkInserted(v, pending);
1156
+
1157
+ const oldMax = this.treeMax();
1158
+ const affected: number[] = [];
1159
+ this.stampAt++;
1160
+ const take = (j: number): void => {
1161
+ if (this.stamp[j] === this.stampAt) return;
1162
+ this.stamp[j] = this.stampAt;
1163
+ affected.push(j);
1164
+ };
1165
+ if (moved) {
1166
+ // The bounded fallback: an unchanged triangle crossed the band, so every carrier is searched again.
1167
+ this.tally.fullRecarries++;
1168
+ for (let s = 0; s < pending.firstAdded; s++) {
1169
+ if (!seen[s]) continue;
1170
+ pending.liveWas.push([s, this.slotLive[s]]);
1171
+ this.slotLive[s] = Math.abs(this.slotArea[s]) > band;
1172
+ }
1173
+ for (let j = 0; j < this.n; j++) take(j);
1174
+ } else {
1175
+ if (this.plant !== 'stale-carrier') for (const s of removed) for (const j of this.slotSamples[s]) take(j);
1176
+ for (const s of addedSlots) {
1177
+ if (!this.slotLive[s]) continue;
1178
+ const [x0, y0, x1, y1] = [this.slotBox[s * 4], this.slotBox[s * 4 + 1], this.slotBox[s * 4 + 2], this.slotBox[s * 4 + 3]];
1179
+ const W = this.maskWidth;
1180
+ const H = this.maskHeight;
1181
+ const xa = Math.max(0, Math.floor(x0 * W - 0.5) - 1);
1182
+ const xb = Math.min(W - 1, Math.ceil(x1 * W - 0.5) + 1);
1183
+ const ya = Math.max(0, Math.floor(y0 * H - 0.5) - 1);
1184
+ const yb = Math.min(H - 1, Math.ceil(y1 * H - 0.5) + 1);
1185
+ for (let y = ya; y <= yb; y++) {
1186
+ for (let x = xa; x <= xb; x++) {
1187
+ const j = this.pixelSample[y * W + x];
1188
+ if (j >= 0 && this.cSlot[j] === -1) take(j);
1189
+ }
1190
+ }
1191
+ for (let j = this.art; j < this.n; j++) {
1192
+ if (this.cSlot[j] !== -1) continue;
1193
+ if (this.su[j] < x0 - 1e-9 || this.su[j] > x1 + 1e-9 || this.sv[j] < y0 - 1e-9 || this.sv[j] > y1 + 1e-9) continue;
1194
+ take(j);
1195
+ }
1196
+ }
1197
+ }
1198
+ affected.sort((a, b) => a - b);
1199
+ this.tally.peakAffected = Math.max(this.tally.peakAffected, affected.length);
1200
+ this.tally.samplesRecomputed += affected.length;
1201
+
1202
+ // Where a sample a removal leaves outside every added triangle may still lie: on an unchanged triangle that shares a
1203
+ // vertex with a removed one. Computed once, when first needed.
1204
+ let around: number[] | null = null;
1205
+ const neighbourhood = (): number[] => {
1206
+ if (around !== null) return around;
1207
+ const corners = new Set<number>();
1208
+ for (const s of removed) for (const v of this.slotCorners[s]) corners.add(v);
1209
+ around = [];
1210
+ for (const s of this.keyToSlot.values()) if (seen[s] && this.slotLive[s] && this.slotCorners[s].some((v) => corners.has(v))) around.push(s);
1211
+ around.sort((a, b) => a - b);
1212
+ return around;
1213
+ };
1214
+ const liveAdded = addedSlots.filter((s) => this.slotLive[s]);
1215
+ const allLive = moved ? [...this.keyToSlot.values()].filter((s) => seen[s] && this.slotLive[s]).concat(liveAdded).sort((a, b) => a - b) : null;
1216
+ for (const j of affected) {
1217
+ const old = this.cSlot[j];
1218
+ const hits: Carrier[] = [];
1219
+ if (allLive !== null) {
1220
+ for (const s of allLive) {
1221
+ const h = this.test(j, s);
1222
+ if (h !== null) hits.push(h);
1223
+ }
1224
+ } else {
1225
+ for (const s of liveAdded) {
1226
+ const h = this.test(j, s);
1227
+ if (h !== null) hits.push(h);
1228
+ }
1229
+ if (hits.length === 0 && old >= 0 && isRemoved[old]) {
1230
+ for (const s of neighbourhood()) {
1231
+ const h = this.test(j, s);
1232
+ if (h !== null) hits.push(h);
1233
+ }
1234
+ }
1235
+ }
1236
+ let carrier: Carrier | null;
1237
+ try {
1238
+ carrier = resolveCarriers(this.su[j], this.sv[j], hits, `${this.who}: candidate (working vertex ids)`);
1239
+ } catch (err) {
1240
+ if (!(err instanceof MeshReductionError)) throw err;
1241
+ if (pending.refused === null) pending.refused = `SKINNING_UV_CARRIER_NOT_UNIQUE: ${err.message}`;
1242
+ carrier = hits[0];
1243
+ }
1244
+ pending.log.push({ j, slot: old, b0: this.cBary[j * 3], b1: this.cBary[j * 3 + 1], b2: this.cBary[j * 3 + 2], value: this.value[j] });
1245
+ const now = carrier === null ? -1 : carrier.triangle;
1246
+ if (old === -1 && now !== -1) this.uncarried--;
1247
+ if (old !== -1 && now === -1) this.uncarried++;
1248
+ this.cSlot[j] = now;
1249
+ if (carrier !== null) {
1250
+ this.cBary[j * 3] = carrier.bary[0];
1251
+ this.cBary[j * 3 + 1] = carrier.bary[1];
1252
+ this.cBary[j * 3 + 2] = carrier.bary[2];
1253
+ if (!moved) {
1254
+ this.slotSamples[now].push(j);
1255
+ if (now < pending.firstAdded) pending.appended.push([now, j]);
1256
+ }
1257
+ }
1258
+ this.setValue(j, this.valueAt(j));
1259
+ if (this.plant === 'previous-candidate') pending.rebase.push([j, this.candidateTerms(j)]);
1260
+ }
1261
+ if (moved) this.rebuildLists();
1262
+ if (this.plant === 'lost-maximum') {
1263
+ let best = { value: -1, at: -1 };
1264
+ for (const j of affected) if (this.value[j] > best.value) best = { value: this.value[j], at: j };
1265
+ const recomputed = affected.includes(oldMax.at);
1266
+ this.plantMax = recomputed ? best : best.value > this.plantMax.value ? best : this.plantMax;
1267
+ } else this.plantMax = this.treeMax();
1268
+ return this.reading();
1269
+ }
1270
+
1271
+ private checkInserted(v: number, pending: Pending): void {
1272
+ this.checked.add(v);
1273
+ pending.checked.push(v);
1274
+ const shares = this.mesh.weights[v];
1275
+ const sum = shares.reduce((s, b) => s + b.weight, 0);
1276
+ const p = this.mesh.pos[v];
1277
+ const u = this.mesh.uv[v];
1278
+ const slack: [number, number] = [2 * R6_HALF_STEP * (1 + this.frame.width), 2 * R6_HALF_STEP * (1 + this.frame.height)];
1279
+ if (Math.abs(sum - 1) > WEIGHT_SUM_SLACK) {
1280
+ this.broken ??= `SKINNING_WEIGHT_SUM: ${this.who}: the vertex the refinement inserted at (${p[0]}, ${p[1]}) has shares summing to ${sum}; required 1 within ${WEIGHT_SUM_SLACK} (half a step of the 6-decimal weight grid)`;
1281
+ } else if (Math.abs(p[0] - u[0] * this.frame.width) > slack[0] || Math.abs(p[1] - u[1] * this.frame.height) > slack[1]) {
1282
+ this.broken ??= `SKINNING_SETUP_MISMATCH: ${this.who}: the vertex the refinement inserted at (${p[0]}, ${p[1]}) has UV (${u[0]}, ${u[1]}), at (${r6(u[0] * this.frame.width)}, ${r6(u[1] * this.frame.height)}) through the ${this.frame.width}x${this.frame.height} px frame; required within (${slack[0]}, ${slack[1]}) px per axis`;
1283
+ }
1284
+ }
1285
+
1286
+ /** The reading of the state as it stands — after a trial, its provisional state. */
1287
+ reading(): CarryReading {
1288
+ if (this.broken !== null) return { state: 'refused', reason: this.broken };
1289
+ if (this.pending !== null && this.pending.refused !== null) return { state: 'refused', reason: this.pending.refused };
1290
+ if (this.measured === 0) return { state: 'not-measurable', reason: `${this.who}: no sample is carried by both the source's and the candidate's UV triangles, so there is nothing to bound` };
1291
+ return { state: 'measured', value: r6(this.plantMax.value) };
1292
+ }
1293
+
1294
+ /** Every triangle's sample list read again off the carriers — the fallback's, and its rollback's. */
1295
+ private rebuildLists(): void {
1296
+ for (let s = 0; s < this.slotSamples.length; s++) this.slotSamples[s] = [];
1297
+ for (let j = 0; j < this.n; j++) if (this.cSlot[j] >= 0) this.slotSamples[this.cSlot[j]].push(j);
1298
+ }
1299
+
1300
+ /** Keep the pending trial. */
1301
+ commit(): void {
1302
+ const p = this.pending;
1303
+ if (p === null) throw new Error('SkinningCarry: commit with no trial pending');
1304
+ for (const s of p.removed) {
1305
+ this.keyToSlot.delete(this.slotKey[s]);
1306
+ this.slotSamples[s] = [];
1307
+ }
1308
+ for (let s = p.firstAdded; s < this.slotCorners.length; s++) this.keyToSlot.set(this.slotKey[s], s);
1309
+ for (const [j, terms] of p.rebase) {
1310
+ if (this.base[j] === null) continue;
1311
+ this.base[j] = terms;
1312
+ this.setValue(j, this.valueAt(j));
1313
+ }
1314
+ if (p.rebase.length > 0) this.plantMax = this.treeMax();
1315
+ this.pending = null;
1316
+ this.tally.commits++;
1317
+ }
1318
+
1319
+ /** Undo the pending trial: every carrier, value, list, count and the maximum as they were before it. */
1320
+ rollback(): void {
1321
+ const p = this.pending;
1322
+ if (p === null) throw new Error('SkinningCarry: rollback with no trial pending');
1323
+ for (let i = p.appended.length - 1; i >= 0; i--) this.slotSamples[p.appended[i][0]].pop();
1324
+ for (let i = p.log.length - 1; i >= 0; i--) {
1325
+ const e = p.log[i];
1326
+ this.cSlot[e.j] = e.slot;
1327
+ this.cBary[e.j * 3] = e.b0;
1328
+ this.cBary[e.j * 3 + 1] = e.b1;
1329
+ this.cBary[e.j * 3 + 2] = e.b2;
1330
+ if (this.plant !== 'no-rollback') this.setValue(e.j, e.value);
1331
+ }
1332
+ if (this.plant !== 'no-rollback') this.measured = p.measured;
1333
+ this.uncarried = p.uncarried;
1334
+ const keep = p.firstAdded;
1335
+ this.slotCorners.length = keep;
1336
+ this.slotKey.length = keep;
1337
+ this.slotArea.length = keep;
1338
+ this.slotLive.length = keep;
1339
+ this.slotBox.length = keep * 4;
1340
+ this.slotSamples.length = keep;
1341
+ for (const [s, live] of p.liveWas) this.slotLive[s] = live;
1342
+ if (p.fallback) this.rebuildLists();
1343
+ for (const v of p.checked) this.checked.delete(v);
1344
+ this.broken = p.broken;
1345
+ if (this.plant !== 'no-rollback') this.plantMax = p.plantMax;
1346
+ this.pending = null;
1347
+ this.tally.rollbacks++;
1348
+ }
1349
+
1350
+ /** Bytes the carried state holds: the typed arrays, the baseline terms, the triangles and their sample lists. */
1351
+ memoryBytes(): number {
1352
+ const typed = this.su.byteLength + this.sv.byteLength + this.pixelSample.byteLength + this.cSlot.byteLength + this.cBary.byteLength + this.value.byteLength + this.tree.byteLength + this.stamp.byteLength;
1353
+ let based = 0;
1354
+ for (const b of this.base) if (b !== null) based++;
1355
+ const terms = based * 8 * (2 + 3 * this.K);
1356
+ let listed = 0;
1357
+ for (const s of this.keyToSlot.values()) listed += this.slotSamples[s].length;
1358
+ const slots = this.keyToSlot.size * 8 * (3 + 1 + 4) + listed * 8;
1359
+ return typed + terms + slots;
1360
+ }
1361
+
1362
+ /**
1363
+ * The carried state held to the full recompute over `candidate`, the canonical form of the working mesh as it now
1364
+ * stands: every sample carried in it by `uvCarriers`, its terms by `termsAt` and its value by `sampleResidual` — the
1365
+ * measurement's own path, sample for sample.
1366
+ */
1367
+ compareWithFull(candidate: SourceMesh): CarryCheck {
1368
+ const samples: Array<{ uv: [number, number] }> = [];
1369
+ for (let j = 0; j < this.n; j++) samples.push({ uv: [this.su[j], this.sv[j]] });
1370
+ const carriers = uvCarriers(candidate.uvs, candidate.triangles, samples, `${this.who}: candidate`);
1371
+ let sampleDiff = 0;
1372
+ let measuredMismatch = 0;
1373
+ let fullMax = -1;
1374
+ let fullMeasured = 0;
1375
+ let fullUncarried = 0;
1376
+ let carriedMax = -1;
1377
+ for (let j = 0; j < this.n; j++) {
1378
+ const c = carriers[j];
1379
+ if (c === null) fullUncarried++;
1380
+ const r = this.sourceTerms[j];
1381
+ const full = r === null || c === null ? -1 : sampleResidual(r, termsAt(candidate, c, this.index), this.eps, this.tau, this.pivot).total;
1382
+ if (full >= 0) fullMeasured++;
1383
+ fullMax = Math.max(fullMax, full);
1384
+ carriedMax = Math.max(carriedMax, this.value[j]);
1385
+ if ((full >= 0) !== (this.value[j] >= 0)) measuredMismatch++;
1386
+ else if (full >= 0) sampleDiff = Math.max(sampleDiff, Math.abs(full - this.value[j]));
1387
+ }
1388
+ const reading = this.reading();
1389
+ const readMax = reading.state === 'measured' ? this.plantMax.value : -1;
1390
+ const equal = measuredMismatch === 0 && sampleDiff <= CARRY_TOLERANCE && fullUncarried === this.uncarried && fullMeasured === this.measured && Math.abs(readMax - fullMax) <= CARRY_TOLERANCE;
1391
+ return {
1392
+ equal,
1393
+ sampleDiff,
1394
+ measuredMismatch,
1395
+ carried: { max: readMax, measured: this.measured, uncarried: this.uncarried, reading },
1396
+ full: { max: fullMax, measured: fullMeasured, uncarried: fullUncarried },
1397
+ };
1398
+ }
1399
+ }