@rig-c/parts 1.10.0 → 1.12.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/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 }));