@rig-c/parts 1.15.0 → 1.17.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.
@@ -58,7 +58,10 @@
58
58
  * setup by; an unkeyed property is [0, 0], [1, 1] or 0. `pivot` is the
59
59
  * bone's setup joint in the part's drawing frame (crop px less the part's
60
60
  * padded origin, y down). `setup` is the rig's as written: this package
61
- * writes no bone scale, shear or inherit mode, so `{ 1, 1, 0, 0, 'normal' }`.
61
+ * writes no bone scale or shear, so `{ 1, 1, 0, 0, 'normal' }` — with the
62
+ * bone's own `inherit` where config.bones declares one (issue #348), handed
63
+ * to rig-c as written, which refuses any mode but `normal` by name
64
+ * (`SKINNING_RANGE_UNSUPPORTED`): the residual is then not measurable.
62
65
  * A shear key has no field in `BoneMotionRange`, so a bone keyed by one
63
66
  * makes the residual not measurable (`ENVELOPE_SHEAR_KEYED`); a property
64
67
  * the idle writes and none of the above names is refused the same way.
@@ -86,7 +89,7 @@ import { edgeLength } from './length.ts';
86
89
 
87
90
  /** What the derivation reads off the rig besides the part: the bones (name, parent), each bone's setup joint in crop px, the idle as written, the constraints. */
88
91
  export interface EnvelopeBasis {
89
- bones: ReadonlyArray<{ name: string; parent?: string }>;
92
+ bones: ReadonlyArray<{ name: string; parent?: string; inherit?: string }>;
90
93
  joints: ReadonlyMap<string, readonly [number, number]>;
91
94
  motion: MotionSpec;
92
95
  constraints: readonly ConfigConstraint[];
@@ -105,8 +108,11 @@ export type EnvelopeDerivation = { envelope: SkinningEnvelope } | { stops: Envel
105
108
  /** Where a range comes from: the bone's keys, or the kind of the constraint that drives it — which rig-c refuses by name. */
106
109
  type RangeSource = 'keys' | 'ik' | 'transform' | 'path' | 'physics';
107
110
 
108
- /** A `BoneMotionRange` whose `source` may name a constraint kind: rig-c reads `source` at run time and certifies `'keys'` alone. */
109
- type DeclaredRange = Omit<BoneMotionRange, 'source'> & { source: RangeSource };
111
+ /**
112
+ * A `BoneMotionRange` whose `source` may name a constraint kind, and whose `setup.inherit` may name a declared mode
113
+ * (issue #348): rig-c reads both at run time and certifies `'keys'` and `'normal'` alone.
114
+ */
115
+ type DeclaredRange = Omit<BoneMotionRange, 'source' | 'setup'> & { source: RangeSource; setup: Omit<BoneMotionRange['setup'], 'inherit'> & { inherit: string } };
110
116
 
111
117
  /**
112
118
  * rig-c's helper over ranges whose `source` may be a constraint kind. rig-c's type admits only the one value it
@@ -136,6 +142,12 @@ function trackValues(keys: readonly MotionKey[]): number[] {
136
142
 
137
143
  const SETUP: BoneMotionRange['setup'] = { scaleX: 1, scaleY: 1, shearX: 0, shearY: 0, inherit: 'normal' };
138
144
 
145
+ /** The bone's declared inherit mode as the rig writes it (issue #348), or nothing, so {@link SETUP}'s `normal` stands. */
146
+ function inheritOf(basis: EnvelopeBasis, bone: string): { inherit: string } | Record<string, never> {
147
+ const mode = basis.bones.find((b) => b.name === bone)?.inherit;
148
+ return mode === undefined ? {} : { inherit: mode };
149
+ }
150
+
139
151
  /** One bone's declared range off the idle, or the stop that names why it has none. */
140
152
  export function boneRange(basis: EnvelopeBasis, bone: string, origin: readonly [number, number], driven: ReadonlyMap<string, RangeSource>): DeclaredRange | EnvelopeStop {
141
153
  const joint = basis.joints.get(bone);
@@ -170,7 +182,7 @@ export function boneRange(basis: EnvelopeBasis, bone: string, origin: readonly [
170
182
  scaleX,
171
183
  scaleY,
172
184
  translate: edgeLength(tx, ty),
173
- setup: { ...SETUP },
185
+ setup: { ...SETUP, ...inheritOf(basis, bone) },
174
186
  };
175
187
  }
176
188
 
package/src/build.ts CHANGED
@@ -43,7 +43,7 @@
43
43
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
44
44
  import { dirname, join, resolve } from 'node:path';
45
45
  import { type AnimFrame, EncodeError, encodeApng, encodeIndexedApng, INDEXED_DEFAULTS } from './apng.ts';
46
- import { assemble, type AssembleResult, cutLines, driftLine, FIGURE_MASK_FILE, figuresLine, holeLines, type ProjectRule, scenePhrase, type SeamRule, stageFields, uncoveredLine } from './assemble.ts';
46
+ import { assemble, type AssembleResult, cutLines, driftLine, limbLines, FIGURE_MASK_FILE, figuresLine, holeLines, type ProjectRule, scenePhrase, type SeamRule, stageFields, uncoveredLine } from './assemble.ts';
47
47
  import { allocationClause, type Reducer, stageBClause, unboundedClause } from './automesh.ts';
48
48
  import { residualClause } from './autoenvelope.ts';
49
49
  import { type AutoMotionCase, failingRows, idleSchedule, localRow, motionClause, motionDeformation, motionDocument, motionInput, motionRowText, motionStimulus, motionVerdict, noStimulusProblem, type ReplayCandidate, runComparison, sourceSelfFailure, stimulusMotion, withStimulus } from './automotion.ts';
@@ -70,9 +70,9 @@ import {
70
70
  splitSchedule,
71
71
  untestedIntervals,
72
72
  } from './autoreplay.ts';
73
- import { besideIdleLine, FOLD_LINE, type FoldsLine, buildGateLines, causeLines, IDLE_FPS, IDLE_MAX_PX, maxRgbDiff, REQUIREMENTS_DIR, type CheckReport, type PackLine, DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, JUDGEMENT_LINES, type JudgementLine, packedBuildArgs, packedBuildLabel, type PackMode, type PackShape, type PageEdges, readFrameSet, rigcFailed, rigcIdentity, rigcRanClause, REPORTED_LINES, type ReportedLine, type RigcRunner, runCheck, SEAM_DIR, SEAM_MEAN_BAR, seamPairText, seamRigPxText, SOURCE_LINE, SEAM_PX_BAR, SEAM_PX_LEVEL, SEAM_PX_LEVEL_HIGH, SPINEBOY_YARDSTICK, STILL_DIR } from './check.ts';
74
- import { type Cut, loadConfig, loadConfigAndAnimations, loadEarlyConfig } from './config.ts';
75
- import { PartsError, type Problem, problemLine, refuseIfAny } from './errors.ts';
73
+ import { besideIdleLine, FOLD_LINE, type FoldsLine, LEVEL_LINE, type LevelLine, LIMB_LINE, type LimbLine, STRESS_LINE, buildGateLines, causeLines, IDLE_FPS, IDLE_MAX_PX, maxRgbDiff, REQUIREMENTS_DIR, type CheckReport, type PackLine, DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, JUDGEMENT_LINES, type JudgementLine, packedBuildArgs, packedBuildLabel, type PackMode, type PackShape, type PageEdges, readFrameSet, rigcFailed, rigcIdentity, rigcRanClause, REPORTED_LINES, type ReportedLine, type RigcRunner, runCheck, SEAM_DIR, SEAM_MEAN_BAR, seamPairText, seamRigPxText, SOURCE_LINE, SEAM_PX_BAR, SEAM_PX_LEVEL, SEAM_PX_LEVEL_HIGH, SPINEBOY_YARDSTICK, STILL_DIR } from './check.ts';
74
+ import { type Cut, type Limb, loadConfig, loadConfigAndAnimations, loadEarlyConfig } from './config.ts';
75
+ import { failLine, PartsError, type Problem, refuseIfAny } from './errors.ts';
76
76
  import type { MeshQualityReport, MotionSchedule } from 'rig-c/mesh';
77
77
  import { encodeGif } from './gif.ts';
78
78
  import { CONTROL_SUFFIX, withAnimations } from './motion.ts';
@@ -200,6 +200,36 @@ export function readUnders(configPath: string, cuts: readonly Cut[]): Array<Rast
200
200
  return out;
201
201
  }
202
202
 
203
+ /**
204
+ * Each limb segment's `cap` image (issue #349), read from its path relative to
205
+ * the config's directory and keyed `<limb>/<segment index>`. Every one is read
206
+ * before one refusal names them all: no such file
207
+ * (`ASSEMBLE_LIMB_CAP_PRESENT`), or a file that does not decode as a PNG
208
+ * (`ASSEMBLE_LIMB_CAP_DECODES`). The stage holds the image to the cap's box.
209
+ */
210
+ export function readCaps(configPath: string, limbs: Readonly<Record<string, Limb>>): Map<string, Raster> {
211
+ const problems: Problem[] = [];
212
+ const out = new Map<string, Raster>();
213
+ for (const [l, limb] of Object.entries(limbs)) {
214
+ limb.segments.forEach((sg, k) => {
215
+ if (sg.cap === undefined) return;
216
+ const file = resolve(dirname(resolve(configPath)), sg.cap);
217
+ const object = `config.limbs.${l}.segments[${k}].cap (part "${sg.part}")`;
218
+ if (!existsSync(file) || !statSync(file).isFile()) {
219
+ problems.push({ code: 'ASSEMBLE_LIMB_CAP_PRESENT', object, detail: `names ${file}, which is no such file; a PNG of the cap, its path relative to the config's directory, is required` });
220
+ return;
221
+ }
222
+ try {
223
+ out.set(`${l}/${k}`, readPng(file));
224
+ } catch (err) {
225
+ problems.push({ code: 'ASSEMBLE_LIMB_CAP_DECODES', object, detail: `${file}: ${(err as Error).message.split('\n')[0]}; a PNG is required` });
226
+ }
227
+ });
228
+ }
229
+ refuseIfAny(problems);
230
+ return out;
231
+ }
232
+
203
233
  /** Read, assemble, and write only after every refusal has had its chance. Throws a PartsError on a refusal, having written nothing. */
204
234
  export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs, log: Log): AssembleResult {
205
235
  const src = readSource(input.source);
@@ -208,7 +238,8 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
208
238
  // regions and motion, so it requires only what it reads (issue #20).
209
239
  const fields = stageFields(loadEarlyConfig(input.config, 'assemble'));
210
240
  const under = readUnders(input.config, fields.cuts);
211
- const result = assemble({ source: src, full: runs.full, head: runs.head, ...fields, under, seamRule: input.seam, projectRule: input.project });
241
+ const caps = fields.limbs === undefined ? undefined : readCaps(input.config, fields.limbs);
242
+ const result = assemble({ source: src, full: runs.full, head: runs.head, ...fields, under, ...(caps === undefined ? {} : { caps }), seamRule: input.seam, projectRule: input.project });
212
243
  // Emit only after green: every refusal above has already thrown.
213
244
  mkdirSync(outs.partsDir, { recursive: true });
214
245
  mkdirSync(dirname(outs.partsJson), { recursive: true });
@@ -234,6 +265,8 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
234
265
  for (const l of authorPartLines(result.parts.parts)) log(l);
235
266
  // One line per plan part an assemble.cuts entry cut, before the fringe lines: the cut is made first. None without cuts.
236
267
  for (const l of cutLines(result.cuts)) log(l);
268
+ // Issue #349: one line per part a limb took, and one per joint with its cap's pixels. None without limbs.
269
+ for (const l of limbLines(result.limbs)) log(l);
237
270
  for (const l of fringeLines(result.parts.parts)) log(l);
238
271
  const total = (k: 'opaque_px' | 'visible_px' | 'occluded_px' | 'source_px_taken' | 'visible_not_projected_px'): number => result.parts.parts.reduce((a, p) => a + (p[k] ?? 0), 0);
239
272
  const [op, vis, occ, taken, unproj] = (['opaque_px', 'visible_px', 'occluded_px', 'source_px_taken', 'visible_not_projected_px'] as const).map(total);
@@ -1046,6 +1079,40 @@ export function foldsLines(line: FoldsLine): string[] {
1046
1079
  return [head, ...line.folding.map((r) => ` folds ${r.line}${r.declared === null ? '' : ` — declared in may_fold: ${r.declared}`}`)];
1047
1080
  }
1048
1081
 
1082
+ /**
1083
+ * The feet-level line (issue #348) as the console prints it: the status, what was read, the bar and whose it is, then
1084
+ * each foot on a line of its own — its bone, what that hangs from, how many frames posed it, and the worst turn with
1085
+ * the bone that was turned and the bones it came through. A SKIP prints its reason.
1086
+ */
1087
+ export function levelLines(line: LevelLine): string[] {
1088
+ const physics = line.physics_left_out === 0 ? '' : `, its ${line.physics_left_out} physics constraint(s) left out`;
1089
+ const read = `${line.animations.length} animation(s) read by rigc render --folds --geometry at ${line.fps} fps${physics} (${line.animations.join(', ')})`;
1090
+ const head =
1091
+ line.status === 'SKIP'
1092
+ ? `${LEVEL_LINE}: SKIP — ${String(line.reason)}; ${read}`
1093
+ : `${LEVEL_LINE}: ${line.status} — ${read}: each foot's drawing within ±${line.bar_degrees} degrees of its setup pose while a bone above it is turned, ${line.bar}`;
1094
+ return [
1095
+ head,
1096
+ ...line.feet.map((f) => {
1097
+ const at = `foot "${f.part}" on "${f.bone}" (hangs from "${f.hangs_from}"): ${f.status}, ${f.frames_posed} posed frame(s)`;
1098
+ if (f.worst === null) return `${at}, so nothing was measured`;
1099
+ const w = f.worst;
1100
+ return `${at}; worst ${w.degrees} degrees in "${w.animation}" frame ${w.frame} (t = ${w.time}s), "${w.turned}" turned ${w.turned_degrees}, through ${w.through.map((x) => `${x.bone} (inherit ${x.inherit})`).join(', ')}`;
1101
+ }),
1102
+ ];
1103
+ }
1104
+
1105
+ /**
1106
+ * The limb line (issue #349) as the console prints it: the status and the bar, then each limb part on a line of its
1107
+ * own — its limb, its bone, and on a FAIL every other bone it is bound to.
1108
+ */
1109
+ export function limbCheckLines(line: LimbLine): string[] {
1110
+ return [
1111
+ `${LIMB_LINE}: ${line.status} — ${line.parts.length} limb part(s), each bound in rig.json to its one bone with weight 1 (a region on it, or a mesh weighted to it alone)`,
1112
+ ...line.parts.map((r) => `limb "${r.part}" (${r.limb}) on "${r.bone}": ${r.status}${r.others.length === 0 ? '' : `, also weighted to ${r.others.map((b) => `"${b}"`).join(', ')}`}`),
1113
+ ];
1114
+ }
1115
+
1049
1116
  /** A requirement's line as the console prints it: its name and status, the reason first when NOT MEASURABLE, then its figures as check.json holds them. */
1050
1117
  export function requirementText(name: string, line: RequirementLine): string {
1051
1118
  const rest = Object.entries(line).filter(([k]) => k !== 'status' && k !== 'reason');
@@ -1072,7 +1139,7 @@ export function checkStage(input: CheckStageInput, rigc: RigcRunner, bin: string
1072
1139
  if (r.idle === null) log(` loop: SKIP — ${fig.skipped?.loop ?? ''}`);
1073
1140
  else log(` ${loopCheckLine(r.idle, fig.loop_max_diff, fig.loop_physics?.length ?? 0)}`);
1074
1141
  if (r.loopPhysics !== null) log(` ${r.loopPhysics}`);
1075
- const beside = besideIdleLine(r.besideIdle);
1142
+ const beside = besideIdleLine(r.besideIdle, r.figures[LEVEL_LINE] !== undefined);
1076
1143
  if (beside !== null) log(` ${beside}`);
1077
1144
  if (r.seamViewport === null || fig.seam_mean === null) log(` seam: SKIP — ${fig.skipped?.seam ?? ''}`);
1078
1145
  else {
@@ -1086,6 +1153,12 @@ export function checkStage(input: CheckStageInput, rigc: RigcRunner, bin: string
1086
1153
  for (const name of JUDGEMENT_LINES) log(` ${judgementLine(name, fig[name])}`);
1087
1154
  const folds = fig[FOLD_LINE];
1088
1155
  if (folds !== undefined) for (const l of foldsLines(folds)) log(` ${l}`);
1156
+ const level = fig[LEVEL_LINE];
1157
+ if (level !== undefined) for (const [i, l] of levelLines(level).entries()) log(` ${i === 0 ? '' : ' '}${l}`);
1158
+ const stress = fig[STRESS_LINE];
1159
+ if (stress !== undefined) log(` ${judgementLine(STRESS_LINE, stress)}`);
1160
+ const limbs = fig[LIMB_LINE];
1161
+ if (limbs !== undefined) for (const [i, l] of limbCheckLines(limbs).entries()) log(` ${i === 0 ? '' : ' '}${l}`);
1089
1162
  for (const name of REPORTED_LINES) log(` ${judgementLine(name, fig[name])}`);
1090
1163
  const vsSource = fig[SOURCE_LINE];
1091
1164
  if (vsSource !== undefined) log(` ${judgementLine(SOURCE_LINE, vsSource)}`);
@@ -1095,7 +1168,7 @@ export function checkStage(input: CheckStageInput, rigc: RigcRunner, bin: string
1095
1168
  }
1096
1169
  log(` gate: spine-html ${fig.gate_spine_html_green ? 'green' : 'RED'} (${fig.rigc_entry.entry}${fig.rigc_entry.spine_core === null ? ', rigc\'s own validator' : `, the spine-core ${fig.rigc_entry.spine_core} round trip`})`);
1097
1170
  log(` wrote ${r.written.map((w) => join(input.out, w)).join(', ')}, ${join(input.out, 'build')}/${r.idle === null ? '' : `, ${join(input.out, 'idle_frames')}/`}${r.requirements === null ? '' : `, ${join(input.out, REQUIREMENTS_DIR)}/`}${fig.seam_pairs === undefined ? '' : `, ${join(input.out, SEAM_DIR)}/`}${existsSync(join(input.out, STILL_DIR)) ? `, ${join(input.out, STILL_DIR)}/` : ''}`);
1098
- for (const p of r.problems) log(` FAIL ${problemLine(p)}`);
1171
+ for (const p of r.problems) log(failLine(p));
1099
1172
  const reqNotPass = r.requirements === null ? 0 : r.requirements.summary.declared - r.requirements.summary.pass;
1100
1173
  const notMet = r.requirements === null ? `${r.problems.length} bar(s) not met` : `${r.problems.length - reqNotPass} bar(s) not met, ${reqNotPass} declared requirement(s) not PASS`;
1101
1174
  log(`${fig.PASS ? 'check: PASS' : `check: FAIL — ${notMet}`}; ${barsSummary(r)}`);
@@ -1466,7 +1539,7 @@ export function build(input: BuildInput, run: BuildRunners, log: Log): BuildResu
1466
1539
  const refused = (stage: BuildStage, err: unknown, check: CheckReport | null = null): BuildResult => {
1467
1540
  if (!(err instanceof PartsError)) throw err;
1468
1541
  const say = prefixed(stage);
1469
- for (const p of err.problems) say(` FAIL ${problemLine(p)}`);
1542
+ for (const p of err.problems) say(failLine(p));
1470
1543
  say(`refused: ${err.problems.length} problem(s)`);
1471
1544
  log(`build: stopped at ${stage}; no later stage ran`);
1472
1545
  return { stoppedAt: stage, check, artifact: [] };