@rig-c/parts 1.39.0 → 1.41.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
@@ -132,13 +132,14 @@
132
132
  * touches the disk, and the same inputs give the same bytes (key order is the
133
133
  * order the objects are built in, and every number is rounded by `pyRound`).
134
134
  */
135
- import { type AutoSpec, type BoneEntry, type WeightRule, type CharacterConfig, type ConfigConstraint, type ConfigStop, CONSTRAINT_FOLLOWS, constraintForRig, contourRegionsOf, type ContourSpec, DEFAULT_BUST_BONE_L, DEFAULT_BUST_BONE_R, type Point, type RibsSpec, ROOT_BONE, stopForRig, weightsABone } from './config.ts';
135
+ import { type AutoSpec, type BoneEntry, type InfluenceMeshSpec, type WeightRule, type CharacterConfig, type ConfigConstraint, type ConfigStop, CONSTRAINT_FOLLOWS, constraintForRig, contourRegionsOf, type ContourSpec, DEFAULT_BUST_BONE_L, DEFAULT_BUST_BONE_R, type Point, type RibsSpec, ROOT_BONE, stopForRig, weightsABone } from './config.ts';
136
136
  import { type BoneTransform, computeExactFrameTransforms, cropToSpineY, normaliseDegrees, toBoneLocal, toWorld } from './coords.ts';
137
137
  import { type Problem, refuseIfAny } from './errors.ts';
138
138
  import { eyeLineDegrees } from './headframe.ts';
139
139
  import type { RigBoneInherit, RigStop } from 'rig-c/src/rig.ts';
140
140
  import { f32 } from 'rig-c/compile';
141
141
  import type { MeshCounts, MeshQualityReport, MeshReductionInput, MotionAmplitude, ReducedMesh, Termination } from 'rig-c/mesh';
142
+ import { buildSegmentsLattice, type EntrySegment, influenceShares, segmentDistance as rigcSegmentDistance, type SegmentsLattice, type SegmentsLatticeReport } from 'rig-c/mesh';
142
143
  import { type AmplitudeRow, amplitudeRow, partAmplitude, withAmplitude } from './autoamplitude.ts';
143
144
  import { type ResidualRow, residualRow, slotEnvelope, withSkinning } from './autoenvelope.ts';
144
145
  import { type AutoMotionCase, type AutoSearch, DEFORM_MAY_FOLD_WHY, type MotionDeformation, type StimulusPose, stimulusAnimationNames } from './automotion.ts';
@@ -708,7 +709,70 @@ export type RigCommand = 'rig' | 'build';
708
709
  export const IDLE_DRIVES_MESHES_WHY = 'painting rig: the idle is meant to deform the meshes it keys (rig-parts rig --idle-keys direct)';
709
710
 
710
711
  /** One row of `mesh_report.json`: a lattice mesh's row is as it always was; a contour mesh's says so. */
711
- export type MeshReport = LatticeMeshReport | ContourMeshReport | AutoMeshReport;
712
+ export type MeshReport = LatticeMeshReport | ContourMeshReport | AutoMeshReport | InfluenceMeshReport;
713
+
714
+ /**
715
+ * An influence mesh's row (issue #378): the lattice's counts and coverage as every row has them, the block as declared,
716
+ * rig-c's lattice report, and the reach — the farthest vertex's distance to its nearest link and where, the vertices
717
+ * the cap dropped a link from (`capped`, more links in reach than `max`) and the most links in reach at one vertex
718
+ * (`widest`, the `max` that would drop none): the two figures rig-c's own `reach` line prints for its generator.
719
+ */
720
+ export interface InfluenceMeshReport {
721
+ part: string;
722
+ vertices: number;
723
+ triangles: number;
724
+ hull: number;
725
+ bones: string[];
726
+ max_influences: number;
727
+ mean_influences: number;
728
+ art_coverage: number;
729
+ mode: 'influence';
730
+ influence: InfluenceMeshSpec['influence'];
731
+ reach: { farthest: number; at: [number, number]; nearest: string; capped: number; widest: number };
732
+ lattice: SegmentsLatticeReport;
733
+ }
734
+
735
+ /**
736
+ * The alpha a pixel must reach to hold a cell of an influence mesh's lattice: this package's art, alpha above
737
+ * {@link ART_ALPHA} (`src/mesh.ts`), which the lattice's own cells and every coverage figure read — so the two
738
+ * modes' coverage is measured over the same art (issue #378, bar 1).
739
+ */
740
+ export const INFLUENCE_THRESHOLD = ART_ALPHA + 1;
741
+
742
+ /** The farthest lattice vertex from its nearest link, part px (issue #378, bar 4): `segmentDistance`, rig-c's. */
743
+ export function influenceReach(points: ReadonlyArray<readonly [number, number]>, segs: readonly EntrySegment[], names: readonly string[]): { distance: number; vertex: number; at: [number, number]; bone: string } {
744
+ let best = { distance: -1, vertex: -1, at: [0, 0] as [number, number], bone: '' };
745
+ points.forEach(([x, y], vi) => {
746
+ let d = Infinity;
747
+ let bone = 0;
748
+ for (const s of segs) {
749
+ const e = rigcSegmentDistance([x, y], s.a, s.b);
750
+ if (e < d) {
751
+ d = e;
752
+ bone = s.bone;
753
+ }
754
+ }
755
+ if (d > best.distance) best = { distance: d, vertex: vi, at: [x, y], bone: names[bone] };
756
+ });
757
+ return best;
758
+ }
759
+
760
+ /**
761
+ * Whether a declared radius reaches a vertex `distance` px from its nearest link: a link pulls only when it is NEARER
762
+ * than radius (rig-c's `d < radius`; the pull is 0 at d = radius), so a radius equal to the distance does not.
763
+ */
764
+ export function influenceReachHolds(radius: number, distance: number): boolean {
765
+ return radius > distance;
766
+ }
767
+
768
+ /** `RIG_INFLUENCE_REACH`: the radius does not exceed the farthest vertex's distance to its nearest link. */
769
+ export function influenceReachProblem(part: string, radius: number, reach: { distance: number; vertex: number; at: [number, number]; bone: string }, ox: number, oy: number): Problem {
770
+ return {
771
+ code: 'RIG_INFLUENCE_REACH',
772
+ object: `config.meshes.${part}.influence.radius`,
773
+ detail: `is ${radius}; vertex ${reach.vertex} of the part's lattice, at (${pyRound(reach.at[0] + ox, 4)}, ${pyRound(reach.at[1] + oy, 4)}) in rig px, is ${pyRound(reach.distance, 4)} px from its nearest link ("${reach.bone}"), and a link pulls a vertex only when it is nearer than radius — a radius above ${pyRound(reach.distance, 4)} is required, or a segment nearer that part of the art`,
774
+ };
775
+ }
712
776
 
713
777
  export interface LatticeMeshReport {
714
778
  part: string;
@@ -1161,6 +1225,9 @@ export function buildRig(
1161
1225
  return { bone, a: [b.x, b.y], b: end };
1162
1226
  };
1163
1227
  const meshSegments = new Map<string, Segment[]>();
1228
+ // issue #378: which `segments` entry each resolved segment came from — rig-c's `bones` entries, which an influence
1229
+ // mesh's `across: false` keeps apart. Every mode resolves them; only the influence mode reads this.
1230
+ const meshSegmentEntries = new Map<string, number[]>();
1164
1231
  for (const [part, m] of Object.entries(cfg.meshes)) {
1165
1232
  if ('contour' in m) {
1166
1233
  (m.contour.regions ?? []).forEach((rg, i) => {
@@ -1181,25 +1248,26 @@ export function buildRig(
1181
1248
  });
1182
1249
  }
1183
1250
  const out: Segment[] = [];
1251
+ const entries: number[] = [];
1184
1252
  m.segments.forEach((sp, i) => {
1185
1253
  const at = `config.meshes.${part}.segments[${i}]`;
1254
+ const push = (s: Segment | null): void => {
1255
+ if (s === null) return;
1256
+ out.push(s);
1257
+ entries.push(i);
1258
+ };
1186
1259
  if (typeof sp === 'string') {
1187
1260
  const links = chains.get(sp);
1188
1261
  if (links !== undefined) {
1189
- for (const l of links) {
1190
- const s = seg(l, `${at} (chain "${sp}" link "${l}")`);
1191
- if (s !== null) out.push(s);
1192
- }
1193
- } else {
1194
- const s = seg(sp, at);
1195
- if (s !== null) out.push(s);
1196
- }
1262
+ for (const l of links) push(seg(l, `${at} (chain "${sp}" link "${l}")`));
1263
+ } else push(seg(sp, at));
1197
1264
  } else {
1198
1265
  if (!B.has(sp[0])) fail('RIG_NAME_RESOLVES', `${at}[0]`, `names the bone "${sp[0]}", which config.bones does not declare`);
1199
- else out.push({ bone: sp[0], a: [sp[1][0], sp[1][1]], b: [sp[2][0], sp[2][1]] });
1266
+ else push({ bone: sp[0], a: [sp[1][0], sp[1][1]], b: [sp[2][0], sp[2][1]] });
1200
1267
  }
1201
1268
  });
1202
1269
  meshSegments.set(part, out);
1270
+ meshSegmentEntries.set(part, entries);
1203
1271
  }
1204
1272
 
1205
1273
  // ---- parts ↔ config ----------------------------------------------------
@@ -1723,6 +1791,87 @@ export function buildRig(
1723
1791
  },
1724
1792
  };
1725
1793
  };
1794
+ // An influence mesh (issue #378): rig-c's segments lattice and its influence pull, both computed by rig-c's own
1795
+ // promised functions (`buildSegmentsLattice`, `segmentDistance`, `influenceShares`, rig-c/core#1450) in the padded
1796
+ // part's pixels — the frame the generator reads — and written as explicit weights, bound exactly as a lattice
1797
+ // vertex is. Before any share is taken the radius is held to the farthest vertex's nearest link
1798
+ // (`RIG_INFLUENCE_REACH`): a vertex no link reaches would be refused by rig-c's own rule, and the figure that
1799
+ // fixes it is that distance.
1800
+ const influenceAttachment = (
1801
+ p: PartsFile['parts'][number],
1802
+ img: Raster,
1803
+ file: string,
1804
+ spec: InfluenceMeshSpec,
1805
+ segs: readonly Segment[],
1806
+ entries: readonly number[],
1807
+ out: Problem[],
1808
+ ): { attachment: MeshAttachment; report: InfluenceMeshReport } | null => {
1809
+ const ox = p.x - PAD;
1810
+ const oy = p.y - PAD;
1811
+ const w = img.width;
1812
+ const h = img.height;
1813
+ const at = `config.meshes.${p.name}.influence`;
1814
+ const alpha = new Uint8Array(w * h);
1815
+ for (let i = 0; i < alpha.length; i++) alpha[i] = img.data[i * 4 + 3];
1816
+ let lattice: SegmentsLattice;
1817
+ try {
1818
+ lattice = buildSegmentsLattice({ mask: { width: w, height: h, alpha }, threshold: INFLUENCE_THRESHOLD, cell: spec.influence.cell });
1819
+ } catch (err) {
1820
+ out.push({ code: 'RIG_INFLUENCE_LATTICE', object: `${at}.cell`, detail: `rig-c's segments lattice refused part "${p.name}" at cell ${spec.influence.cell}: ${(err as Error).message}` });
1821
+ return null;
1822
+ }
1823
+ const names = [...new Set(segs.map((s) => s.bone))];
1824
+ const index = new Map(names.map((n, k) => [n, k]));
1825
+ const local: EntrySegment[] = segs.map((s, k) => ({ bone: index.get(s.bone) as number, entry: entries[k], a: [s.a[0] - ox, s.a[1] - oy], b: [s.b[0] - ox, s.b[1] - oy] }));
1826
+ const reach = influenceReach(lattice.points, local, names);
1827
+ if (!influenceReachHolds(spec.influence.radius, reach.distance)) {
1828
+ out.push(influenceReachProblem(p.name, spec.influence.radius, reach, ox, oy));
1829
+ return null;
1830
+ }
1831
+ const weights: WeightEntry[][] = [];
1832
+ let infl = 0;
1833
+ let maxInfl = 0;
1834
+ let capped = 0;
1835
+ let widest = 0;
1836
+ for (const [vi, [x, y]] of lattice.points.entries()) {
1837
+ const r = influenceShares([x, y], local, spec.influence);
1838
+ if ('nearest' in r) {
1839
+ // Not reached after the reach check passed: a defect here, not an input to word.
1840
+ throw new Error(`influenceAttachment: vertex ${vi} of "${p.name}" is reached by no link after the radius held`);
1841
+ }
1842
+ if (r.inReach > spec.influence.max) capped++;
1843
+ widest = Math.max(widest, r.inReach);
1844
+ const wx = x + ox;
1845
+ const wy = y + oy;
1846
+ const ent: WeightEntry[] = r.weights.map((e) => {
1847
+ const bone = names[e.control ?? 0];
1848
+ const [bx, by] = toBoneLocal(world.get(bone) as BoneTransform, spineX(wx), spineY(wy));
1849
+ return { bone, x: places(bx), y: places(by), weight: e.weight };
1850
+ });
1851
+ weights.push(ent);
1852
+ infl += ent.length;
1853
+ maxInfl = Math.max(maxInfl, ent.length);
1854
+ }
1855
+ const vertices = lattice.points.map(([x, y]) => [x, y] as Point);
1856
+ const n = vertices.length;
1857
+ return {
1858
+ attachment: { type: 'mesh', image: file, width: w, height: h, uvs: lattice.uvs, triangles: lattice.triangles, hull: lattice.hullVertices, weights },
1859
+ report: {
1860
+ part: p.name,
1861
+ vertices: n,
1862
+ triangles: lattice.triangles.length / 3,
1863
+ hull: lattice.hullVertices,
1864
+ bones: [...names].sort(),
1865
+ max_influences: maxInfl,
1866
+ mean_influences: pyRound(infl / n, 2),
1867
+ art_coverage: pyRound(artCoverage({ vertices, triangles: lattice.triangles }, alphaAbove(img, ART_ALPHA)), 5),
1868
+ mode: 'influence',
1869
+ influence: { ...spec.influence },
1870
+ reach: { farthest: pyRound(reach.distance, 4), at: [pyRound(reach.at[0] + ox, 4), pyRound(reach.at[1] + oy, 4)], nearest: reach.bone, capped, widest },
1871
+ lattice: { ...lattice.report },
1872
+ },
1873
+ };
1874
+ };
1726
1875
  // An automatic mesh (issue #126, item 2): the contour source at alpha 1 and above, rig-c's reduceMesh, and
1727
1876
  // the result used only when accepted (`src/automesh.ts`). Its weights are rig-c's by name — a survivor's are
1728
1877
  // the source's bit for bit, an inserted vertex's interpolated — rounded by roundShares (the author's minWeight
@@ -2005,6 +2154,14 @@ export function buildRig(
2005
2154
  meshReport.push(row.report);
2006
2155
  continue;
2007
2156
  }
2157
+ if ('influence' in mesh) {
2158
+ const row = influenceAttachment(p, img, file, mesh, segs, meshSegmentEntries.get(p.name) as number[], problems);
2159
+ if (row === null) continue;
2160
+ skin[p.name] = { [p.name]: row.attachment };
2161
+ slots.push({ name: p.name, bone: segs[0].bone, attachment: p.name });
2162
+ meshReport.push(row.report);
2163
+ continue;
2164
+ }
2008
2165
  const allArt = alphaAbove(img, ART_ALPHA);
2009
2166
  // issue #303: under a declared `stray`, the islands it leaves out are cleared from the art the lattice and its heat
2010
2167
  // silhouette are built over; absent, `art` is `allArt` itself and nothing downstream moves.
@@ -2221,7 +2378,7 @@ export function buildRig(
2221
2378
  segs = yOverlapping.length > 0 ? yOverlapping : [segs[0]];
2222
2379
  }
2223
2380
  const allArt = alphaAbove(img, ART_ALPHA);
2224
- const strayVal = 'auto' in mesh ? mesh.auto.source.stray : ('contour' in mesh ? mesh.contour.stray : mesh.stray);
2381
+ const strayVal = 'auto' in mesh ? mesh.auto.source.stray : 'contour' in mesh ? mesh.contour.stray : 'influence' in mesh ? undefined : mesh.stray;
2225
2382
  const stray = strayVal === undefined ? null : latticeStray(allArt, strayVal);
2226
2383
  const art = stray === null ? allArt : stray.art;
2227
2384
  const grid = 'grid' in mesh ? mesh.grid : 28;
@@ -2237,7 +2394,7 @@ export function buildRig(
2237
2394
  const h = img.height;
2238
2395
  const limits: InfluenceLimits = meshLimits('influences' in mesh ? mesh.influences : undefined);
2239
2396
  const segsAt = dressSegsAt(p, segs);
2240
- const exponent = mesh.exponent ?? DISTANCE_EXPONENT;
2397
+ const exponent = ('exponent' in mesh ? mesh.exponent : undefined) ?? DISTANCE_EXPONENT;
2241
2398
  let touchesArt: boolean[] | null = null;
2242
2399
  if (drawn !== undefined && limits.maxInfluences === 1) {
2243
2400
  const lc = latticeCells(art, grid);
package/src/squeeze.ts CHANGED
@@ -187,8 +187,8 @@ export function chainSqueezes(
187
187
  const pairs: Array<[string, string]> = [];
188
188
  for (let i = 0; i < keyed.length; i++) for (let j = i + 1; j < keyed.length; j++) pairs.push([keyed[i], keyed[j]]);
189
189
  const names = pairs.map(([a, b]) => `${a} and ${b}`).join(', ');
190
- if ('contour' in m || 'auto' in m) {
191
- unread.push(`mesh ${JSON.stringify(mname)} (${names}) is a ${'contour' in m ? 'contour' : 'automatic'} mesh; only a lattice is built here`);
190
+ if ('contour' in m || 'auto' in m || 'influence' in m) {
191
+ unread.push(`mesh ${JSON.stringify(mname)} (${names}) is a ${'contour' in m ? 'contour' : 'auto' in m ? 'automatic' : 'influence'} mesh; only a lattice is built here`);
192
192
  continue;
193
193
  }
194
194
  if (m.rule === 'heat') {
@@ -318,7 +318,7 @@ export function branchSqueezes(
318
318
  }
319
319
 
320
320
  for (const [mname, m] of Object.entries(spec.meshes)) {
321
- if ('contour' in m || 'auto' in m || m.rule === 'heat' || m.r >= m.grid) continue;
321
+ if ('contour' in m || 'auto' in m || 'influence' in m || m.rule === 'heat' || m.r >= m.grid) continue;
322
322
  const byFork = new Map<string, string[]>();
323
323
  for (const sp of m.segments) {
324
324
  if (typeof sp !== 'string') continue;