@rig-c/parts 1.9.1 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/rig.ts CHANGED
@@ -161,7 +161,7 @@ import { alphaAbove, crop, pad, type Raster } from './raster/index.ts';
161
161
  import { pyRound } from './round.ts';
162
162
  import { boneHeat, heatInfluences, heatRow, type HeatRow, latticeSilhouette, tracedSilhouette } from './heat.ts';
163
163
  import { TURN_BONE, type TurnSpec, turnConstraintName, turnFractions, turnTwin } from './turn.ts';
164
- import { DEFAULT_LIMITS, DISTANCE_EXPONENT, type Influence, influences, type Segment } from './weights.ts';
164
+ import { DISTANCE_EXPONENT, type FloorForm, type Influence, influences, type InfluenceLimits, type MeshInfluences, meshLimits, type Segment } from './weights.ts';
165
165
 
166
166
  /** Transparent pixels added round every part image — the reference's `PAD`. */
167
167
  export const PAD = 4;
@@ -309,12 +309,47 @@ export function eyeLine(parts: PartsFile, eyeBones: ReadonlyMap<string, Point>):
309
309
 
310
310
  /**
311
311
  * The weights a contour vertex is written with: a vertex no region reaches is
312
- * rounded as the lattice rounds (each to 5 places, the last `1 − sum(others)`),
313
- * one a region reaches by {@link roundShares}.
312
+ * rounded as the lattice rounds ({@link latticeShares}), one a region reaches
313
+ * by {@link roundShares} — and so is every vertex of a mesh that declares the
314
+ * continuous floor (issue #284), whose shares, like a region's ramp, reach
315
+ * below 0.03 and may round to 0.
314
316
  */
315
- export function writtenShares(li: LocalInfluence): Influence[] {
316
- if (li.region >= 0) return roundShares(li.influences);
317
- const shares = li.influences.map(({ bone, weight }) => ({ bone, weight: pyRound(weight, 5) }));
317
+ export function writtenShares(li: LocalInfluence, floor?: FloorForm): Influence[] {
318
+ if (li.region >= 0 || floor === 'continuous') return roundShares(li.influences);
319
+ return latticeShares(li.influences);
320
+ }
321
+
322
+ /**
323
+ * The refusal for a mesh some of whose vertices the cut leaves with no bone (issue #284): the first such vertex, with
324
+ * its capped shares before the floor, and how many more there are.
325
+ *
326
+ * When it can happen, by arithmetic: the capped shares of a vertex are normalised, so over k kept bones they sum to 1
327
+ * and the largest is at least 1/k. The cut keeps a share of at least 0.03 and the continuous floor one above 0.03, so a
328
+ * vertex keeps no bone only if 1/k ≤ 0.03, that is k ≥ 34 (1/33 = 0.0303… keeps its largest under both forms). At the
329
+ * default cap of 4 the largest share is at least 0.25, so a mesh that declares nothing never reaches this; only a
330
+ * declared cap of 34 or more, or `null`, over 34 or more bones whose shares are all at or below the floor does — and a
331
+ * share that is not a number (raw values summing to 0) lands here too rather than in the file. Rare, so refused by
332
+ * name rather than left to a crash in the rounding.
333
+ */
334
+ export function nothingKeptProblem(part: string, floor: FloorForm | undefined, first: { vertex: number; at: Point; capped: readonly Influence[] }, more: number): Problem {
335
+ const largest = first.capped.length === 0 ? null : Math.max(...first.capped.map((e) => e.weight));
336
+ const form = floor === 'continuous' ? 'the continuous floor (0.03 subtracted from every share) keeps none at or below 0.03' : 'the cut drops every share below 0.03';
337
+ return {
338
+ code: 'RIG_INFLUENCES_KEPT',
339
+ object: `config.meshes.${part}.influences`,
340
+ detail: `vertex ${first.vertex} at rig (${first.at[0]}, ${first.at[1]})${more > 0 ? ` and ${more} more` : ''} keeps no bone: its ${first.capped.length} capped share(s) have the largest ${largest === null || !Number.isFinite(largest) ? String(largest) : pyRound(largest, 6)}, and ${form}; a largest share above 0.03 is required, which every vertex has when 33 or fewer bones are kept (the largest of k shares is at least 1/k) — declare maxInfluences 33 or less, or name fewer bones in segments`,
341
+ };
342
+ }
343
+
344
+ /**
345
+ * The lattice's rounding: each weight to 5 places, the last entry written as
346
+ * `1 − sum(others)`. Safe because under the cut every kept entry is at least
347
+ * `MIN_WEIGHT`, so the last cannot be taken negative; a mesh that declares the
348
+ * continuous floor is rounded by {@link roundShares} instead (issue #284, as
349
+ * Stage E wrote it: `docs/evidence/weight-cap.md`).
350
+ */
351
+ export function latticeShares(list: readonly Influence[]): Influence[] {
352
+ const shares = list.map(({ bone, weight }) => ({ bone, weight: pyRound(weight, 5) }));
318
353
  let others = 0;
319
354
  for (let k = 0; k < shares.length - 1; k++) others += shares[k].weight;
320
355
  shares[shares.length - 1].weight = pyRound(1 - others, 5);
@@ -613,6 +648,8 @@ export interface LatticeMeshReport {
613
648
  grid: number;
614
649
  /** The distance rule's exponent the mesh declared (issue #161); absent when it declared none and ran at 2. */
615
650
  exponent?: number;
651
+ /** The cap and floor form the mesh declared, as declared (issue #284); absent when it declared none and ran at 4 and the cut. */
652
+ influences?: MeshInfluences;
616
653
  /** Only under `rule: "heat"` (issue #161): the rule and the solve it ran. Absent otherwise, so a distance mesh writes the row it always wrote. */
617
654
  heat?: HeatRow;
618
655
  }
@@ -636,9 +673,12 @@ export interface ContourMeshReport {
636
673
  mode: 'contour';
637
674
  /** The distance rule's exponent the mesh declared (issue #161); absent when it declared none and ran at 2. */
638
675
  exponent?: number;
676
+ /** As the lattice row's (issue #284). */
677
+ influences?: MeshInfluences;
639
678
  /** Only when the mesh declared ribs (issue #188): what it declared and what was placed. Absent otherwise, so a mesh without ribs writes the row it always wrote. */
640
679
  ribs?: RibsRow;
641
- params: { tolerance: number; margin: number; spacing: number; budget: number | null; stray: number | null };
680
+ /** `maxEdge` only when the mesh declared it (issue #265), so a mesh without it writes the row it always wrote. */
681
+ params: { tolerance: number; margin: number; spacing: number; maxEdge?: number; budget: number | null; stray: number | null };
642
682
  contour: ContourReport;
643
683
  regions: Array<{ name: string; bone: string; reached: number; whole: number }>;
644
684
  /** Only under `rule: "heat"` (issue #161), as the lattice row's. */
@@ -1320,8 +1360,10 @@ export function buildRig(
1320
1360
  segs: Segment[],
1321
1361
  rule: WeightRule,
1322
1362
  ribSpec: { segments: ReadonlyArray<string | readonly [string, Point, Point]>; ribs?: RibsSpec },
1363
+ declared: MeshInfluences | undefined,
1323
1364
  out: Problem[],
1324
1365
  ): { attachment: MeshAttachment; report: ContourMeshReport } | null => {
1366
+ const limits = meshLimits(declared);
1325
1367
  const ox = p.x - PAD;
1326
1368
  const oy = p.y - PAD;
1327
1369
  const w = img.width;
@@ -1335,7 +1377,7 @@ export function buildRig(
1335
1377
  : { name: rg.name, shape: 'polygon', points: rg.points.map(([x, y]) => [x - ox, y - oy] as [number, number]), spacing: rg.spacing, band: rg.band },
1336
1378
  );
1337
1379
  const ribs = chainRibs(ribSpec.segments, ribSpec.ribs, ox, oy);
1338
- const cm = contourMesh(p.name, { width: w, height: h, alpha }, { threshold: ART_ALPHA, tolerance: spec.tolerance, margin: spec.margin, spacing: spec.spacing, budget: spec.budget, stray: spec.stray, regions: inPart, ...(ribs.length === 0 ? {} : { ribs }) });
1380
+ const cm = contourMesh(p.name, { width: w, height: h, alpha }, { threshold: ART_ALPHA, tolerance: spec.tolerance, margin: spec.margin, spacing: spec.spacing, ...(spec.maxEdge === undefined ? {} : { maxEdge: spec.maxEdge }), budget: spec.budget, stray: spec.stray, regions: inPart, ...(ribs.length === 0 ? {} : { ribs }) });
1339
1381
  if (Array.isArray(cm)) {
1340
1382
  out.push(...cm);
1341
1383
  return null;
@@ -1353,10 +1395,12 @@ export function buildRig(
1353
1395
  let infl = 0;
1354
1396
  let maxInfl = 0;
1355
1397
  let overlapped = false;
1398
+ let emptied: { vertex: number; at: Point; capped: Influence[] } | null = null;
1399
+ let emptiedMore = 0;
1356
1400
  cm.vertices.forEach(([vx, vy], vi) => {
1357
1401
  const wx = vx + ox;
1358
1402
  const wy = vy + oy;
1359
- const li = localInfluences([wx, wy], segs, r, regions, DEFAULT_LIMITS, exponent ?? DISTANCE_EXPONENT, heat === null ? undefined : heatInfluences(heat, [vx, vy], DEFAULT_LIMITS));
1403
+ const li = localInfluences([wx, wy], segs, r, regions, limits, exponent ?? DISTANCE_EXPONENT, heat === null ? undefined : heatInfluences(heat, [vx, vy], limits));
1360
1404
  if ('first' in li) {
1361
1405
  if (!overlapped) {
1362
1406
  out.push({
@@ -1368,11 +1412,17 @@ export function buildRig(
1368
1412
  overlapped = true;
1369
1413
  return;
1370
1414
  }
1415
+ if (li.influences.length === 0) {
1416
+ // issue #284: a declared cut that keeps no bone (nothingKeptProblem says when that can be); a region's bone would be kept.
1417
+ if (emptied === null) emptied = { vertex: vi, at: [wx, wy], capped: heat === null ? influences([wx, wy], segs, r, { maxInfluences: limits.maxInfluences, minWeight: 0 }, exponent ?? DISTANCE_EXPONENT) : heatInfluences(heat, [vx, vy], { maxInfluences: limits.maxInfluences, minWeight: 0 }) };
1418
+ else emptiedMore++;
1419
+ return;
1420
+ }
1371
1421
  if (li.region >= 0) {
1372
1422
  reached[li.region]++;
1373
1423
  if (li.g >= 1) whole[li.region]++;
1374
1424
  }
1375
- const shares = writtenShares(li);
1425
+ const shares = writtenShares(li, limits.floor);
1376
1426
  const ent: WeightEntry[] = shares.map(({ bone, weight }) => {
1377
1427
  const [x, y] = toBoneLocal(world.get(bone) as BoneTransform, spineX(wx), spineY(wy));
1378
1428
  return { bone, x: places(x), y: places(y), weight };
@@ -1382,6 +1432,10 @@ export function buildRig(
1382
1432
  maxInfl = Math.max(maxInfl, ent.length);
1383
1433
  });
1384
1434
  if (overlapped) return null;
1435
+ if (emptied !== null) {
1436
+ out.push(nothingKeptProblem(p.name, limits.floor, emptied, emptiedMore));
1437
+ return null;
1438
+ }
1385
1439
  const uvs: number[] = [];
1386
1440
  for (const [x, y] of cm.vertices) uvs.push(pyRound(x / w, 6), pyRound(y / h, 6));
1387
1441
  const bones = new Set(segs.map((s) => s.bone));
@@ -1400,8 +1454,9 @@ export function buildRig(
1400
1454
  art_coverage: pyRound(rep.coveredArtPixels / (rep.artPixels + rep.strayPixels), 5),
1401
1455
  mode: 'contour',
1402
1456
  ...(exponent === undefined ? {} : { exponent }),
1457
+ ...(declared === undefined ? {} : { influences: declared }),
1403
1458
  ...(ribSpec.ribs === undefined ? {} : { ribs: ribsRow(ribSpec.ribs, cm.ribs ?? []) }),
1404
- params: { tolerance: spec.tolerance, margin: spec.margin, spacing: spec.spacing, budget: spec.budget ?? null, stray: spec.stray ?? null },
1459
+ params: { tolerance: spec.tolerance, margin: spec.margin, spacing: spec.spacing, ...(spec.maxEdge === undefined ? {} : { maxEdge: spec.maxEdge }), budget: spec.budget ?? null, stray: spec.stray ?? null },
1405
1460
  contour: rep,
1406
1461
  regions: regions.map((rg, k) => ({ name: rg.name, bone: rg.bone, reached: reached[k], whole: whole[k] })),
1407
1462
  ...(heat === null ? {} : { heat: heatRow(heat) }),
@@ -1683,7 +1738,7 @@ export function buildRig(
1683
1738
  continue;
1684
1739
  }
1685
1740
  if ('contour' in mesh) {
1686
- const row = contourAttachment(p, img, file, mesh.contour, mesh.r, mesh.exponent, segs, mesh.rule ?? 'distance', mesh, problems);
1741
+ const row = contourAttachment(p, img, file, mesh.contour, mesh.r, mesh.exponent, segs, mesh.rule ?? 'distance', mesh, mesh.influences, problems);
1687
1742
  if (row === null) continue;
1688
1743
  skin[p.name] = { [p.name]: row.attachment };
1689
1744
  slots.push({ name: p.name, bone: segs[0].bone, attachment: p.name });
@@ -1710,20 +1765,32 @@ export function buildRig(
1710
1765
  const weights: WeightEntry[][] = [];
1711
1766
  let infl = 0;
1712
1767
  let maxInfl = 0;
1713
- for (const [vx0, vy0] of lm.vertices) {
1768
+ // issue #284: the mesh's declared cap and floor form, or DEFAULT_LIMITS itself when it declares none.
1769
+ const limits: InfluenceLimits = meshLimits(mesh.influences);
1770
+ let emptied: { vertex: number; at: Point; capped: Influence[] } | null = null;
1771
+ let emptiedMore = 0;
1772
+ for (const [vi, [vx0, vy0]] of lm.vertices.entries()) {
1714
1773
  const wx = vx0 + ox;
1715
1774
  const wy = vy0 + oy;
1716
- const ent: WeightEntry[] = (heat === null ? influences([wx, wy], segs, mesh.r, undefined, mesh.exponent ?? DISTANCE_EXPONENT) : heatInfluences(heat, [vx0, vy0], DEFAULT_LIMITS)).map(({ bone, weight }) => {
1775
+ const shares = heat === null ? influences([wx, wy], segs, mesh.r, limits, mesh.exponent ?? DISTANCE_EXPONENT) : heatInfluences(heat, [vx0, vy0], limits);
1776
+ if (shares.length === 0) {
1777
+ // issue #284: a declared cut that keeps no bone (nothingKeptProblem says when that can be).
1778
+ if (emptied === null) emptied = { vertex: vi, at: [wx, wy], capped: heat === null ? influences([wx, wy], segs, mesh.r, { maxInfluences: limits.maxInfluences, minWeight: 0 }, mesh.exponent ?? DISTANCE_EXPONENT) : heatInfluences(heat, [vx0, vy0], { maxInfluences: limits.maxInfluences, minWeight: 0 }) };
1779
+ else emptiedMore++;
1780
+ continue;
1781
+ }
1782
+ const ent: WeightEntry[] = (limits.floor === 'continuous' ? roundShares(shares) : latticeShares(shares)).map(({ bone, weight }) => {
1717
1783
  const [x, y] = toBoneLocal(world.get(bone) as BoneTransform, spineX(wx), spineY(wy));
1718
- return { bone, x: places(x), y: places(y), weight: pyRound(weight, 5) };
1784
+ return { bone, x: places(x), y: places(y), weight };
1719
1785
  });
1720
- let others = 0;
1721
- for (let k = 0; k < ent.length - 1; k++) others += ent[k].weight;
1722
- ent[ent.length - 1].weight = pyRound(1 - others, 5);
1723
1786
  weights.push(ent);
1724
1787
  infl += ent.length;
1725
1788
  maxInfl = Math.max(maxInfl, ent.length);
1726
1789
  }
1790
+ if (emptied !== null) {
1791
+ problems.push(nothingKeptProblem(p.name, limits.floor, emptied, emptiedMore));
1792
+ continue;
1793
+ }
1727
1794
  const uvs: number[] = [];
1728
1795
  for (const [x, y] of lm.vertices) uvs.push(pyRound(x / w, 6), pyRound(y / h, 6));
1729
1796
  skin[p.name] = { [p.name]: { type: 'mesh', image: file, width: w, height: h, uvs, triangles: lm.triangles, hull: lm.hull, weights } };
@@ -1739,6 +1806,7 @@ export function buildRig(
1739
1806
  art_coverage: pyRound(artCoverage(lm, art), 5),
1740
1807
  grid: mesh.grid,
1741
1808
  ...(mesh.exponent === undefined ? {} : { exponent: mesh.exponent }),
1809
+ ...(mesh.influences === undefined ? {} : { influences: mesh.influences }),
1742
1810
  ...(heat === null ? {} : { heat: heatRow(heat) }),
1743
1811
  });
1744
1812
  }
package/src/tags.ts CHANGED
@@ -26,6 +26,22 @@
26
26
  * Nothing downstream classifies a part by its NAME. The role of a part — what
27
27
  * a proposer may do with it — is read from the tag it came from, which is why
28
28
  * `parts.json` records `from` for every part.
29
+ *
30
+ * ⭐ **An author-made layer** (issue #256) is a layer the author split or
31
+ * painted by hand outside the tool — a hand laid over a hip taken out of
32
+ * `handwear`, a loop of hair over a shoulder taken out of `back hair` — and
33
+ * added to a run. It is named `author:<label>` ({@link AUTHOR_PREFIX}), the
34
+ * label the author's own. It is not a See-through tag and carries no role:
35
+ * {@link readTag} never reads it, so no rule that reads roles from tags ever
36
+ * sees it, and every reader that admits a layer reads it through
37
+ * {@link readLayerTag}, which says `group: "author"`. Why a prefix and this
38
+ * one: a single reserved name would let a run hold one such layer (a tag
39
+ * names one layer, `LAYERS_NAME_UNIQUE`, and one layer makes one part); no
40
+ * v3 tag holds a colon, so the prefix cannot be read as a See-through tag or
41
+ * as one of its `-r`/`-l` halves; and `<origin>:<name>` is already how
42
+ * `parts.json` says "not a See-through layer" for a patch (`painting:<name>`).
43
+ * Every reader of a part's `from` splits at the first colon, so
44
+ * `full:author:hand` is the full run's layer `author:hand`.
29
45
  */
30
46
 
31
47
  export const BODY_TAGS = [
@@ -119,3 +135,73 @@ export function pairOf(name: string): string | null {
119
135
  if (t === null || t.side === null) return null;
120
136
  return `${t.base}-${t.side === 'r' ? 'l' : 'r'}`;
121
137
  }
138
+
139
+ /** The prefix of an author-made layer's name (issue #256): `author:<label>`. */
140
+ export const AUTHOR_PREFIX = 'author:';
141
+
142
+ /** An author-made layer's name read: the label after {@link AUTHOR_PREFIX}, and no tag, so no role. */
143
+ export interface AuthorTagReading {
144
+ /** The name as the layer carries it: `author:hand`. */
145
+ name: string;
146
+ /** What follows the prefix: the author's own word, read by nothing as a role. */
147
+ label: string;
148
+ group: 'author';
149
+ }
150
+
151
+ /** What a layer name reads as: a See-through v3 tag, or an author-made layer's name. */
152
+ export type LayerTagReading = TagReading | AuthorTagReading;
153
+
154
+ /** What an author-made layer's label must be, as a refusal quotes it. */
155
+ export const AUTHOR_LABEL_RULE = 'a label that is not empty, does not start with "." or whitespace, does not end with whitespace, and holds no slash, backslash or colon';
156
+
157
+ /**
158
+ * Whether `label` may follow {@link AUTHOR_PREFIX}. It becomes part of a part
159
+ * name the plan proposes and of a file name the wrapper form may look for
160
+ * (`parts/<name>.png`), so it is held to what a part name may be, and it holds
161
+ * no colon, so the name keeps exactly one.
162
+ */
163
+ export function authorLabelOk(label: string): boolean {
164
+ return label !== '' && !label.startsWith('.') && label.trim() === label && !/[\\/:]/.test(label);
165
+ }
166
+
167
+ /** Read a layer name as an author-made layer's, or `null` when it is not one (no prefix, or a label {@link authorLabelOk} refuses). */
168
+ export function readAuthorTag(name: string): AuthorTagReading | null {
169
+ if (!name.startsWith(AUTHOR_PREFIX)) return null;
170
+ const label = name.slice(AUTHOR_PREFIX.length);
171
+ return authorLabelOk(label) ? { name, label, group: 'author' } : null;
172
+ }
173
+
174
+ /** Read a layer name as a v3 tag or an author-made layer's name, or `null` when it is neither. */
175
+ export function readLayerTag(name: string): LayerTagReading | null {
176
+ return readTag(name) ?? readAuthorTag(name);
177
+ }
178
+
179
+ /** Whether a tag string names an author-made layer. */
180
+ export function isAuthorTag(name: string): boolean {
181
+ return readAuthorTag(name) !== null;
182
+ }
183
+
184
+ /**
185
+ * Why `name` is no layer tag, as the detail of a `*_TAG_KNOWN` refusal: an
186
+ * `author:` name whose label is refused says what the label must be; any
187
+ * other names the accepted v3 tags and the author form.
188
+ */
189
+ export function layerTagRequirement(name: string): string {
190
+ if (name.startsWith(AUTHOR_PREFIX)) {
191
+ return `is an author-made layer's name with the label ${JSON.stringify(name.slice(AUTHOR_PREFIX.length))}; ${AUTHOR_LABEL_RULE} is required after "${AUTHOR_PREFIX}"`;
192
+ }
193
+ return `is not a See-through v3 tag; one of ${acceptedTagNames().join(', ')} is required, or "${AUTHOR_PREFIX}<label>" for a layer the author made`;
194
+ }
195
+
196
+ /**
197
+ * What every stage says of an author-made layer (issue #256), so it never
198
+ * reads as a See-through layer: `layers` prints it per layer, `assemble` per
199
+ * part, and `--propose-plan` and `propose` carry it in their notes.
200
+ */
201
+ export const AUTHOR_NO_ROLE =
202
+ 'an author-made layer, not a See-through one: no tag, so no role — it rides the bone the config gives it (config.regions.<part>, or a mesh in config.meshes), and its draw position is its place in config.assemble.plan';
203
+
204
+ /** The `layers` line for an author-made layer. */
205
+ export function authorLayerLine(name: string): string {
206
+ return `author-made: layer "${name}" — ${AUTHOR_NO_ROLE}`;
207
+ }
package/src/turn.ts CHANGED
@@ -30,6 +30,37 @@
30
30
  */
31
31
  import { pyRound } from './round.ts';
32
32
 
33
+ /**
34
+ * The layers that ride on the face (issue #271): See-through makes no depth
35
+ * of their own for them, so `propose` does not read theirs. The eye group —
36
+ * eyewhite, irides, eyelash, eyebrow — is cut from one depth map predicted for
37
+ * the combined eyes layer (`compose_list` in See-through's
38
+ * `common/utils/inference_utils.py`, commit df019de), which is why
39
+ * `eyewhite-r` and `eyewhite-l` read the same median in every run measured
40
+ * for #251; nose and mouth are clamped by See-through to the face's median
41
+ * minus {@link TURN_RIDER_OFFSET} when they read behind it (the same file,
42
+ * lines 528-531). Their own medians disagreed across the two runs by up to
43
+ * 0.474 (demo `eyebrow-r`, #251's table), and the disagreement swapped their
44
+ * order against the face. Matched on the layer's base tag, either side.
45
+ */
46
+ export const TURN_RIDER_TAGS: readonly string[] = ['eyewhite', 'irides', 'eyelash', 'eyebrow', 'nose', 'mouth'];
47
+
48
+ /** The tag of the part the riders take their depth from. */
49
+ export const TURN_CARRIER_TAG = 'face';
50
+
51
+ /** See-through's own offset between a clamped face feature and the face (`face − 0.001`, the file named at {@link TURN_RIDER_TAGS}). */
52
+ export const TURN_RIDER_OFFSET = 0.001;
53
+
54
+ /**
55
+ * A rider's depth: its carrier's, {@link TURN_RIDER_OFFSET} nearer when the
56
+ * rider is drawn after the carrier (above it) and as much further when it is
57
+ * drawn before (under it), so the draw order the depths imply is the one
58
+ * parts.json holds.
59
+ */
60
+ export function turnRiderDepth(carrier: number, above: boolean): number {
61
+ return above ? carrier - TURN_RIDER_OFFSET : carrier + TURN_RIDER_OFFSET;
62
+ }
63
+
33
64
  /** The control bone the rig stage adds under `root` when the config has `turn`: nothing keys it, and its local x is the dial. */
34
65
  export const TURN_BONE = 'turn';
35
66
 
package/src/weights.ts CHANGED
@@ -18,6 +18,13 @@
18
18
  * rounding. Ties in the sort keep the order the bones first appeared among the
19
19
  * segments (Python's `sorted` is stable, and so is `Array.prototype.sort`).
20
20
  *
21
+ * The cap and the floor's form are the author's per mesh in the lattice and
22
+ * contour modes (issue #284, `meshes.<part>.influences`): a declared
23
+ * `maxInfluences` (a whole number, or `null`: no cap) replaces the 4, and
24
+ * `floor: "continuous"` replaces the drop at 0.03 by the continuous floor
25
+ * ({@link FloorForm}). Absent, both are the constants above, and every byte is
26
+ * the one written before the field existed.
27
+ *
21
28
  * Distances are the reference's arithmetic, operation for operation — the
22
29
  * projection `t = clip(((p - a) . ab) / |ab|^2, 0, 1)` and `sqrt(dx^2 + dy^2)`,
23
30
  * not `Math.hypot`, whose last bit differs — because a weight rounded at the
@@ -31,12 +38,36 @@
31
38
  */
32
39
  import type { Point } from './config.ts';
33
40
 
34
- /** At most this many bones pull one vertex — Spine's usual budget and the reference's `[:4]`. */
41
+ /**
42
+ * At most this many bones pull one vertex where a mesh declares no cap: the reference implementation's `[:4]`,
43
+ * carried over as a port constant, and nothing more. The format sets no limit — spine-core 4.3.13 reads a weighted
44
+ * vertex as a bone count followed by that many bindings, with no bound on the count (`SkeletonJson.js`,
45
+ * `SkeletonBinary.js`, and `computeWorldVertices` in `attachments/Attachment.js`) — and rig-c 2.40.0 neither refuses
46
+ * nor warns above four: its one assertion on a mesh's weights, `A20_MESH_WEIGHTS_COHERENT`, checks that they are
47
+ * coherent (every vertex bound, every weight finite and non-negative, each vertex summing to 1), not how many there
48
+ * are. Read and cited in `docs/evidence/weight-cap.md` (issue #284, Stage E). A mesh may declare its own cap
49
+ * (`meshes.<part>.influences.maxInfluences`).
50
+ */
35
51
  export const MAX_INFLUENCES = 4;
36
52
 
37
- /** A normalised weight under this is dropped before the final normalisation — the reference's `>= 0.03`. */
53
+ /**
54
+ * A normalised weight under this is dropped before the final normalisation — the reference's `>= 0.03`. Under the
55
+ * continuous floor ({@link FloorForm}) it is subtracted from every kept share instead.
56
+ */
38
57
  export const MIN_WEIGHT = 0.03;
39
58
 
59
+ /**
60
+ * The floor's form (issue #284): `"cut"` — the reference's, and what an absent form means: the capped shares,
61
+ * normalised, any under the floor dropped, the rest normalised again — or `"continuous"`: the capped shares,
62
+ * normalised, the floor subtracted from every one, those at or below zero dropped, the rest normalised again. Under
63
+ * the continuous floor a share reaches zero exactly where the cut would drop it, so a bone fades out of a vertex
64
+ * instead of leaving it in one step; this is Stage A's "continuous floor" (`tools/tear_survey.ts`), the form Stage E
65
+ * swept at every cap (`docs/evidence/weight-cap.md`).
66
+ */
67
+ export type FloorForm = 'cut' | 'continuous';
68
+
69
+ export const FLOOR_FORMS: readonly FloorForm[] = ['cut', 'continuous'];
70
+
40
71
  /**
41
72
  * The exponent of the distance rule where a mesh declares none: `1/(d + r)²`,
42
73
  * the reference's rule. At this value the weight is computed as
@@ -76,20 +107,42 @@ export interface Influence {
76
107
 
77
108
  /**
78
109
  * The cap and the floor `influences()` applies. The lattice and the contour
79
- * mode always run at {@link DEFAULT_LIMITS} (the reference's `[:4]` and
80
- * `>= 0.03`); the automatic mode (`src/automesh.ts`, issue #126) passes the
81
- * author's numbers instead, because rig-c's reduction is handed explicit
82
- * influence limits on every weighted call and the 0.03 floor is not one the
83
- * author wrote (P19). The arithmetic is the same either way, so with the
84
- * defaults every byte the two older modes write is unchanged.
110
+ * mode run at {@link DEFAULT_LIMITS} (the reference's `[:4]` and `>= 0.03`)
111
+ * unless the mesh declares `influences` (issue #284; {@link meshLimits}); the
112
+ * automatic mode (`src/automesh.ts`, issue #126) passes the author's numbers
113
+ * instead, because rig-c's reduction is handed explicit influence limits on
114
+ * every weighted call and the 0.03 floor is not one the author wrote (P19).
115
+ * The arithmetic is the same either way, so with the defaults every byte the
116
+ * two older modes write is unchanged.
85
117
  */
86
118
  export interface InfluenceLimits {
87
119
  maxInfluences: number;
88
120
  minWeight: number;
121
+ /** The floor's form; absent is `"cut"`, the arithmetic as it always was. */
122
+ floor?: FloorForm;
89
123
  }
90
124
 
91
125
  export const DEFAULT_LIMITS: InfluenceLimits = { maxInfluences: MAX_INFLUENCES, minWeight: MIN_WEIGHT };
92
126
 
127
+ /** `meshes.<part>.influences` in the lattice and contour modes (issue #284): either field may be absent. */
128
+ export interface MeshInfluences {
129
+ /** A whole number, 1 or more, or `null`, no cap — every bone the mesh's segments name is kept (the config's spelling of a bound declared absent, as `maxOvershoot` and `weightJump`). Absent: {@link MAX_INFLUENCES}. */
130
+ maxInfluences?: number | null;
131
+ /** Absent: `"cut"`. */
132
+ floor?: FloorForm;
133
+ }
134
+
135
+ /**
136
+ * The limits a lattice or contour mesh runs at: {@link DEFAULT_LIMITS} itself when it declares no `influences`, so
137
+ * nothing about such a mesh changes; otherwise its declared cap (`null` is a count no vertex reaches) and floor form,
138
+ * with the floor's value {@link MIN_WEIGHT} either way — the declaration chooses the form of the cut, not where it falls.
139
+ */
140
+ export function meshLimits(declared: MeshInfluences | undefined): InfluenceLimits {
141
+ if (declared === undefined) return DEFAULT_LIMITS;
142
+ const cap = declared.maxInfluences;
143
+ return { maxInfluences: cap === undefined ? MAX_INFLUENCES : cap === null ? Number.MAX_SAFE_INTEGER : cap, minWeight: MIN_WEIGHT, floor: declared.floor ?? 'cut' };
144
+ }
145
+
93
146
  /**
94
147
  * The influences on one vertex at `p` (rig pixels), before rounding, under
95
148
  * `w = 1/(d + r)^exponent`. The rig stage passes every mesh's exponent
@@ -110,14 +163,19 @@ export function influences(p: Point, segments: readonly Segment[], r: number, li
110
163
 
111
164
  /**
112
165
  * The cap and the floor, on raw per-bone values in first-appearance order: sorted (stable), the top
113
- * `maxInfluences` normalised, those under `minWeight` dropped, the rest normalised again. The distance rule
114
- * above and bone heat (`src/heat.ts`, issue #161) both end here, so the two rules share one cap and one floor.
166
+ * `maxInfluences` normalised, then the floor in its form ({@link FloorForm}) — under `"cut"` those under `minWeight`
167
+ * dropped, under `"continuous"` `minWeight` subtracted from each and those at or below zero dropped — and the rest
168
+ * normalised again. The distance rule above and bone heat (`src/heat.ts`, issue #161) both end here, so the two
169
+ * rules share one cap and one floor.
115
170
  */
116
171
  export function capAndFloor(raw: ReadonlyArray<readonly [string, number]>, limits: InfluenceLimits): Influence[] {
117
172
  const top = [...raw].sort((x, y) => y[1] - x[1]).slice(0, limits.maxInfluences);
118
173
  let s = 0;
119
174
  for (const [, v] of top) s += v;
120
- const kept = top.map(([bone, v]) => ({ bone, weight: v / s })).filter((e) => e.weight >= limits.minWeight);
175
+ const kept =
176
+ limits.floor === 'continuous'
177
+ ? top.map(([bone, v]) => ({ bone, weight: v / s - limits.minWeight })).filter((e) => e.weight > 0)
178
+ : top.map(([bone, v]) => ({ bone, weight: v / s })).filter((e) => e.weight >= limits.minWeight);
121
179
  let s2 = 0;
122
180
  for (const e of kept) s2 += e.weight;
123
181
  return kept.map((e) => ({ bone: e.bone, weight: e.weight / s2 }));