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.
- package/package.json +1 -1
- package/src/meshcarriers.ts +116 -42
- package/src/meshquality.ts +16 -1
- package/src/meshreduce.ts +462 -59
- package/src/meshskinning.ts +735 -44
package/src/meshskinning.ts
CHANGED
|
@@ -55,7 +55,8 @@
|
|
|
55
55
|
*/
|
|
56
56
|
import { createHash } from 'node:crypto';
|
|
57
57
|
import { MeshReductionError, r6 } from './mesh.ts';
|
|
58
|
-
import {
|
|
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
|
-
|
|
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:
|
|
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
|
|
397
|
-
* the
|
|
398
|
-
*
|
|
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
|
|
401
|
-
const {
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|