@rig-c/parts 1.5.5 → 1.7.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/README.md +16 -9
- package/cli.ts +19 -6
- package/docs/AUTHORING.md +110 -18
- package/docs/STABILITY.md +2 -2
- package/package.json +2 -2
- package/src/assemble.ts +329 -9
- package/src/build.ts +163 -11
- package/src/check.ts +224 -28
- package/src/instruments.ts +34 -10
- package/src/parts.ts +39 -2
- package/src/propose.ts +374 -45
- package/src/requirements.ts +58 -3
- package/src/rig.ts +214 -11
- package/src/scene.ts +14 -1
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, figuresLine, holeLines, type ProjectRule, type SeamRule, stageFields } from './assemble.ts';
|
|
46
|
+
import { assemble, type AssembleResult, cutLines, figuresLine, holeLines, type ProjectRule, 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, stimulusMotion, withStimulus } from './automotion.ts';
|
|
@@ -70,7 +70,7 @@ import {
|
|
|
70
70
|
splitSchedule,
|
|
71
71
|
untestedIntervals,
|
|
72
72
|
} from './autoreplay.ts';
|
|
73
|
-
import { BARS, besideIdleLine, causeLines, 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, 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';
|
|
73
|
+
import { BARS, besideIdleLine, 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, 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
74
|
import { type Cut, loadConfig, loadConfigAndAnimations, loadEarlyConfig } from './config.ts';
|
|
75
75
|
import { PartsError, type Problem, problemLine, refuseIfAny } from './errors.ts';
|
|
76
76
|
import type { MeshQualityReport, MotionSchedule } from 'rig-c/mesh';
|
|
@@ -82,7 +82,8 @@ import { type PartRecord, readParts, writeParts } from './parts.ts';
|
|
|
82
82
|
import { encodePngBytes, readPng, writePng } from './raster/png.ts';
|
|
83
83
|
import type { Raster } from './raster/types.ts';
|
|
84
84
|
import { readRequirements, type RequirementLine, summaryText } from './requirements.ts';
|
|
85
|
-
import { type AutoMeshReport, buildRig, DEFAULT_IDLE_KEYS, type IdleKeys, type MeshReport, type RigCommand, rigJsonText, type RigOutput, type RigSpec, type StrayCleared } from './rig.ts';
|
|
85
|
+
import { type AutoMeshReport, buildRig, DEFAULT_IDLE_KEYS, type FaceAxis, type IdleKeys, type MeshReport, type RigCommand, rigJsonText, type RigOutput, type RigSpec, type StrayCleared } from './rig.ts';
|
|
86
|
+
import { pyRound } from './round.ts';
|
|
86
87
|
|
|
87
88
|
/** Where a stage's lines go. The commands hand it `console.log`; `build` hands it a prefixing wrapper. */
|
|
88
89
|
export type Log = (line: string) => void;
|
|
@@ -228,6 +229,7 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
|
|
|
228
229
|
log(` ghost px removed: ${ghosts} over ${Object.keys(result.parts.ghost_px).length} layer(s)`);
|
|
229
230
|
log(figuresLine(result.figures));
|
|
230
231
|
for (const l of holeLines(result.figures)) log(l);
|
|
232
|
+
for (const u of result.uncovered) log(uncoveredLine(u));
|
|
231
233
|
log(`wrote ${outs.partsJson}, ${result.images.length} PNG(s) in ${outs.partsDir}, ${outs.recomposite}, ${outs.errorMap} (uncovered error px red, covered error px blue, the painting grey)`);
|
|
232
234
|
return result;
|
|
233
235
|
}
|
|
@@ -818,6 +820,7 @@ export function rigStage(input: RigStageInput, rigc: RigcRunner, scratch: string
|
|
|
818
820
|
` bones ${rig.rig.bones.length} (${rig.controls.length} control) slots ${rig.rig.slots.length} meshes ${rig.meshReport.length} regions ${regions} vertices ${vertices}; idle ${rig.motion.animations.idle.duration} s, ${tracks.length} track(s), ${keys} key(s)`,
|
|
819
821
|
);
|
|
820
822
|
if (from !== null) log(` animations beside the idle, from ${from.file} as written: ${Object.keys(from.table).join(', ') || 'none'}`);
|
|
823
|
+
log(faceAxisLine(rig.faceAxis));
|
|
821
824
|
log(
|
|
822
825
|
rig.idleKeys === 'ctl'
|
|
823
826
|
? ` idle keys ctl: ${rig.meshKeyed.length} mesh-driving bone(s) keyed by the idle, each keyed through a same-origin <bone>_ctl parent`
|
|
@@ -940,6 +943,24 @@ export function barsSummary(r: CheckReport): string {
|
|
|
940
943
|
return `${measured.length} of ${BARS.length} bar(s) measured, ${skipped.length} skipped${skipped.length === 0 ? '' : ` (${skipped.map((s) => s.bar).join(', ')})`}`;
|
|
941
944
|
}
|
|
942
945
|
|
|
946
|
+
/**
|
|
947
|
+
* The rig stage's face-axis line (issue #239): the eye line's angle, the parts it runs between and the face bones
|
|
948
|
+
* turned to it, or why there is no line and the face bones keep the page's axes — the note a one-eyed painting gets.
|
|
949
|
+
* With a line it also names the `invariants.axes` entry written for rigc's A51, its tolerance, or why none was.
|
|
950
|
+
*/
|
|
951
|
+
export function faceAxisLine(f: FaceAxis): string {
|
|
952
|
+
const own = f.ownTip.length === 0 ? '' : `; ${f.ownTip.join(', ')} ${f.ownTip.length === 1 ? 'takes its' : 'take their'} own tip`;
|
|
953
|
+
if (f.degrees === null) return ` face axis: none — ${f.none}; ${f.ownTip.length === 0 ? 'the face bones keep' : 'the other face bones keep'} the page's axes${own}`;
|
|
954
|
+
const turned = f.turned.length === 0 ? 'no face bone to turn' : `${f.turned.join(', ')} turned to it`;
|
|
955
|
+
const held =
|
|
956
|
+
f.axis !== null
|
|
957
|
+
? `; invariants.axes "${f.axis.name}" holds ${f.axis.bones.join(', ')} to the line through ${f.axis.through.join(' -> ')}, tolerance ${f.axis.tolerance} degrees (rigc's A51)`
|
|
958
|
+
: f.noAxis !== null
|
|
959
|
+
? `; no invariants.axes entry: ${f.noAxis}`
|
|
960
|
+
: '';
|
|
961
|
+
return ` face axis: ${pyRound(f.degrees, 6)} degrees, the line through the eye bones eye_r and eye_l (on ${f.parts[0]} and ${f.parts[1]}); ${turned}${own}${held}`;
|
|
962
|
+
}
|
|
963
|
+
|
|
943
964
|
/** A judgement line as the console prints it: its name, its status, then its figures and bars as check.json holds them (a SKIP prints its reason). */
|
|
944
965
|
export function judgementLine(name: string, line: JudgementLine | ReportedLine): string {
|
|
945
966
|
if (line.status === 'SKIP') return `${name}: SKIP — ${String(line.reason)}`;
|
|
@@ -1026,19 +1047,141 @@ export interface LoopResult {
|
|
|
1026
1047
|
error: PaletteError | null;
|
|
1027
1048
|
}
|
|
1028
1049
|
|
|
1050
|
+
/**
|
|
1051
|
+
* The idle `build --loop --lead-in <n>` renders (issue #224's follow-up):
|
|
1052
|
+
* `idle` played n + 1 times back to back, of which the loop keeps the last
|
|
1053
|
+
* period. rigc's render resets physics at frame 0, so an idle whose rig
|
|
1054
|
+
* carries a physics constraint does not end where it starts; played on, a
|
|
1055
|
+
* spring the idle drives settles into the idle's period, and the last period
|
|
1056
|
+
* of the run is that steady state.
|
|
1057
|
+
*/
|
|
1058
|
+
export const LEAD_IN_ANIMATION = 'idle_lead_in';
|
|
1059
|
+
|
|
1060
|
+
/** Which frames of a lead-in run the loop keeps: the last of `periods + 1` periods. */
|
|
1061
|
+
export interface LoopWindow {
|
|
1062
|
+
periods: number;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
const LEAD_IN_ANIMATION_KEYS = ['duration', 'loop', 'note', 'tracks'];
|
|
1066
|
+
|
|
1067
|
+
/**
|
|
1068
|
+
* `motion` with {@link LEAD_IN_ANIMATION} added: every idle track's keys
|
|
1069
|
+
* repeated `periods + 1` times, each copy shifted by one idle duration, its
|
|
1070
|
+
* Bezier `curve` times with it, and the key at the end of a copy dropped for
|
|
1071
|
+
* the next copy's first key at the same time. Refused by name
|
|
1072
|
+
* (`LOOP_LEAD_IN_IDLE`), every problem at once, when the motion has no idle,
|
|
1073
|
+
* already has an animation of that name, or the idle is not a loop this can
|
|
1074
|
+
* repeat without inventing a value: a field besides duration, loop, note and
|
|
1075
|
+
* tracks; a track whose keys do not start at 0 and end at the duration; or one
|
|
1076
|
+
* whose last value is not its first.
|
|
1077
|
+
*/
|
|
1078
|
+
export function repeatIdle(motion: Record<string, unknown>, periods: number): Record<string, unknown> {
|
|
1079
|
+
const problems: Problem[] = [];
|
|
1080
|
+
const fail = (object: string, detail: string): void => {
|
|
1081
|
+
problems.push({ code: 'LOOP_LEAD_IN_IDLE', object, detail });
|
|
1082
|
+
};
|
|
1083
|
+
const anims = motion.animations;
|
|
1084
|
+
const table = typeof anims === 'object' && anims !== null && !Array.isArray(anims) ? (anims as Record<string, unknown>) : null;
|
|
1085
|
+
const idle = table?.idle;
|
|
1086
|
+
if (table === null || typeof idle !== 'object' || idle === null || Array.isArray(idle)) {
|
|
1087
|
+
fail('motion.json animations.idle', 'is absent; the lead-in repeats the idle');
|
|
1088
|
+
refuseIfAny(problems);
|
|
1089
|
+
}
|
|
1090
|
+
const t = table as Record<string, unknown>;
|
|
1091
|
+
const a = idle as Record<string, unknown>;
|
|
1092
|
+
if (LEAD_IN_ANIMATION in t) fail(`motion.json animations.${LEAD_IN_ANIMATION}`, 'exists; the lead-in writes an animation of that name and never replaces one');
|
|
1093
|
+
for (const k of Object.keys(a)) if (!LEAD_IN_ANIMATION_KEYS.includes(k)) fail(`motion.json animations.idle.${k}`, `is a field the lead-in does not repeat; the idle it repeats holds ${LEAD_IN_ANIMATION_KEYS.join(', ')}`);
|
|
1094
|
+
const d = a.duration;
|
|
1095
|
+
if (typeof d !== 'number' || !(d > 0)) fail('motion.json animations.idle.duration', `is ${JSON.stringify(d)}; a positive number is required`);
|
|
1096
|
+
const tracks = Array.isArray(a.tracks) ? a.tracks : [];
|
|
1097
|
+
const duration = d as number;
|
|
1098
|
+
const out: unknown[] = [];
|
|
1099
|
+
tracks.forEach((track, i) => {
|
|
1100
|
+
const tr = track as Record<string, unknown>;
|
|
1101
|
+
const keys = Array.isArray(tr.keys) ? (tr.keys as Array<Record<string, unknown>>) : [];
|
|
1102
|
+
const at = `motion.json animations.idle.tracks[${i}]`;
|
|
1103
|
+
const first = keys[0];
|
|
1104
|
+
const last = keys[keys.length - 1];
|
|
1105
|
+
if (first === undefined || first.t !== 0 || last.t !== duration) {
|
|
1106
|
+
fail(at, `keys run from ${String(first?.t)} to ${String(last?.t)}; a track the lead-in repeats starts at 0 and ends at the duration ${duration}`);
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
1109
|
+
if (JSON.stringify(first.v) !== JSON.stringify(last.v)) {
|
|
1110
|
+
fail(at, `ends at ${JSON.stringify(last.v)} and starts at ${JSON.stringify(first.v)}; repeated, the idle would jump where one copy meets the next`);
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
const repeated: Array<Record<string, unknown>> = [];
|
|
1114
|
+
for (let r = 0; r <= periods; r++) {
|
|
1115
|
+
const shift = r * duration;
|
|
1116
|
+
keys.forEach((k, j) => {
|
|
1117
|
+
if (r < periods && j === keys.length - 1) return;
|
|
1118
|
+
const moved: Record<string, unknown> = { ...k, t: (k.t as number) + shift };
|
|
1119
|
+
if (Array.isArray(k.curve)) moved.curve = (k.curve as number[]).map((c, n) => (n % 2 === 0 ? c + shift : c));
|
|
1120
|
+
repeated.push(moved);
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
out.push({ ...tr, keys: repeated });
|
|
1124
|
+
});
|
|
1125
|
+
refuseIfAny(problems);
|
|
1126
|
+
return { ...motion, animations: { ...t, [LEAD_IN_ANIMATION]: { duration: duration * (periods + 1), loop: false, tracks: out } } };
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
/**
|
|
1130
|
+
* Render {@link LEAD_IN_ANIMATION} of the rig in `rigDir` as `check` renders
|
|
1131
|
+
* the idle (the packed build, `IDLE_FPS`, `IDLE_MAX_PX`), every other
|
|
1132
|
+
* animation kept so the viewport is fitted as for the idle frames. Writes
|
|
1133
|
+
* under `dir` and returns the frame set's directory.
|
|
1134
|
+
*/
|
|
1135
|
+
export function leadInFrames(rigDir: string, dir: string, periods: number, rigc: RigcRunner, mode: PackMode): string {
|
|
1136
|
+
const rig = JSON.parse(readFileSync(join(rigDir, 'rig.json'), 'utf8')) as Record<string, unknown>;
|
|
1137
|
+
const motion = repeatIdle(JSON.parse(readFileSync(join(rigDir, 'motion.json'), 'utf8')) as Record<string, unknown>, periods);
|
|
1138
|
+
const images = typeof rig.images === 'string' ? resolve(rigDir, rig.images) : rig.images;
|
|
1139
|
+
mkdirSync(dir, { recursive: true });
|
|
1140
|
+
writeFileSync(join(dir, 'rig.json'), JSON.stringify({ ...rig, images }));
|
|
1141
|
+
writeFileSync(join(dir, 'motion.json'), JSON.stringify(motion));
|
|
1142
|
+
const what = `the idle played ${periods + 1} times (${LEAD_IN_ANIMATION})`;
|
|
1143
|
+
const b = rigc(['build', '--rig', join(dir, 'rig.json'), '--motion', join(dir, 'motion.json'), '--out', join(dir, 'build'), ...packedBuildArgs(mode)]);
|
|
1144
|
+
if (b.status !== 0) refuseIfAny(rigcFailed(`${packedBuildLabel(mode)} (${what})`, b, buildGateLines(b.out)));
|
|
1145
|
+
const r = rigc(['render', '--candidate', join(dir, 'build'), '--animation', LEAD_IN_ANIMATION, '--fps', String(IDLE_FPS), '--max', String(IDLE_MAX_PX), '--out', join(dir, 'frames')]);
|
|
1146
|
+
if (r.status !== 0) refuseIfAny(rigcFailed(`render --animation ${LEAD_IN_ANIMATION} (${what})`, r, r.out.split('\n').filter((l) => l.includes('FAIL') || l.includes('rigc:'))));
|
|
1147
|
+
return join(dir, 'frames');
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
/** Pixels whose largest RGB channel difference between `a` and `b` is above `level`. */
|
|
1151
|
+
function pixelsOver(a: Raster, b: Raster, level: number): number {
|
|
1152
|
+
let n = 0;
|
|
1153
|
+
for (let p = 0; p < a.width * a.height; p++) {
|
|
1154
|
+
let m = 0;
|
|
1155
|
+
for (let c = 0; c < 3; c++) m = Math.max(m, Math.abs(a.data[p * 4 + c] - b.data[p * 4 + c]));
|
|
1156
|
+
if (m > level) n++;
|
|
1157
|
+
}
|
|
1158
|
+
return n;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1029
1161
|
/**
|
|
1030
1162
|
* Encode a rigc frame set as a looping APNG, indexed APNG or GIF and write it.
|
|
1031
1163
|
* An encoder refusal is a PartsError coded `LOOP_ENCODE`, so it prints like
|
|
1032
1164
|
* every other.
|
|
1033
1165
|
*/
|
|
1034
|
-
export function loopStage(frames: string, out: string, format: LoopFormat, log: Log): LoopResult {
|
|
1166
|
+
export function loopStage(frames: string, out: string, format: LoopFormat, log: Log, window?: LoopWindow): LoopResult {
|
|
1035
1167
|
const set = readFrameSet(frames);
|
|
1036
1168
|
const images = set.frames.map((fr) => fr.image);
|
|
1037
1169
|
log(`rig-parts loop: ${set.dir} -> ${out}`);
|
|
1038
1170
|
log(` ${images.length} frame(s) ${images[0]?.width ?? 0}x${images[0]?.height ?? 0} at ${set.fps} fps (from frames.json), animation ${set.animation ?? '(none)'}`);
|
|
1039
1171
|
let used = images;
|
|
1040
1172
|
const lastName = set.frames[set.frames.length - 1]?.name ?? '';
|
|
1041
|
-
if (
|
|
1173
|
+
if (window !== undefined) {
|
|
1174
|
+
const per = (images.length - 1) / (window.periods + 1);
|
|
1175
|
+
if (!Number.isInteger(per) || per < 1) {
|
|
1176
|
+
throw new PartsError([{ code: 'LOOP_LEAD_IN_IDLE', object: set.dir, detail: `holds ${images.length} frame(s), which is not ${window.periods + 1} whole period(s) and the end frame; the lead-in keeps the last period` }]);
|
|
1177
|
+
}
|
|
1178
|
+
const start = window.periods * per;
|
|
1179
|
+
const end = start + per;
|
|
1180
|
+
const jump = maxRgbDiff(images[start], images[end]);
|
|
1181
|
+
used = images.slice(start, end);
|
|
1182
|
+
const name = (i: number): string => set.frames[i].name.replace('.png', '');
|
|
1183
|
+
log(` lead-in: ${window.periods} period(s) of the idle played ahead, physics running; ${name(start)}–${name(end - 1)} kept (${per} frame(s), the last period); ${name(end)}, one period after ${name(start)}, differs from it by max |d| ${jump.max}, ${pixelsOver(images[start], images[end], 40)} px over 40 — the jump at the wrap`);
|
|
1184
|
+
} else if (images.length > 1 && sameRgba(images[0].data, images[images.length - 1].data)) {
|
|
1042
1185
|
used = images.slice(0, -1);
|
|
1043
1186
|
log(` ${lastName} equals f0000.png byte for byte, so it is dropped: the loop wraps onto frame 0, and showing it twice would hold that pose for two ticks`);
|
|
1044
1187
|
} else if (images.length > 1) {
|
|
@@ -1102,6 +1245,12 @@ export interface BuildInput {
|
|
|
1102
1245
|
requirements?: string;
|
|
1103
1246
|
/** `--idle-keys`, forwarded to the rig stage as `rig` takes it (issue #95); {@link DEFAULT_IDLE_KEYS} when absent. */
|
|
1104
1247
|
idleKeys?: IdleKeys;
|
|
1248
|
+
/**
|
|
1249
|
+
* `--lead-in <n>`, with `loop` only: the loop is encoded from the last of n + 1
|
|
1250
|
+
* idle periods played back to back ({@link leadInFrames}) instead of from
|
|
1251
|
+
* `check/idle_frames/`; absent, the loop is as before.
|
|
1252
|
+
*/
|
|
1253
|
+
leadIn?: number;
|
|
1105
1254
|
}
|
|
1106
1255
|
|
|
1107
1256
|
/**
|
|
@@ -1115,7 +1264,7 @@ export interface BuildRunners {
|
|
|
1115
1264
|
check: RigcRunner;
|
|
1116
1265
|
/** Where the check's rigc binary is, for its header line. */
|
|
1117
1266
|
checkBin: string;
|
|
1118
|
-
/** A directory the rig stage may stage its gate in; emptied before and after. */
|
|
1267
|
+
/** A directory the rig stage may stage its gate in, and the loop its lead-in render after it; emptied before and after each. */
|
|
1119
1268
|
scratch: string;
|
|
1120
1269
|
}
|
|
1121
1270
|
|
|
@@ -1177,7 +1326,7 @@ export function build(input: BuildInput, run: BuildRunners, log: Log): BuildResu
|
|
|
1177
1326
|
for (const p of BUILD_OWNS) rmSync(join(out, p), { recursive: true, force: true });
|
|
1178
1327
|
// The idle-keys value is named only when it is not the default, so a build without the flag, or with the default, prints the line it printed before issue #95.
|
|
1179
1328
|
const idleKeys = input.idleKeys ?? DEFAULT_IDLE_KEYS;
|
|
1180
|
-
log(`rig-parts build: ${input.config} -> ${out}, seam rule ${input.seam}, projection rule ${input.project}, page edges ${input.pageEdges}, pack shape ${input.packShape ?? DEFAULT_PACK_SHAPE}${idleKeys === DEFAULT_IDLE_KEYS ? '' : `, idle keys ${idleKeys}`}${input.loop ?
|
|
1329
|
+
log(`rig-parts build: ${input.config} -> ${out}, seam rule ${input.seam}, projection rule ${input.project}, page edges ${input.pageEdges}, pack shape ${input.packShape ?? DEFAULT_PACK_SHAPE}${idleKeys === DEFAULT_IDLE_KEYS ? '' : `, idle keys ${idleKeys}`}${input.loop ? `, with the idle loop${input.leadIn === undefined ? '' : ` after a lead-in of ${input.leadIn} period(s)`}` : ''}`);
|
|
1181
1330
|
|
|
1182
1331
|
try {
|
|
1183
1332
|
// build runs rig next, which needs the whole config, so the full loader is
|
|
@@ -1214,12 +1363,15 @@ export function build(input: BuildInput, run: BuildRunners, log: Log): BuildResu
|
|
|
1214
1363
|
}
|
|
1215
1364
|
|
|
1216
1365
|
if (input.loop) {
|
|
1217
|
-
const frames = join(out, 'check', 'idle_frames');
|
|
1218
1366
|
try {
|
|
1219
1367
|
const say = prefixed('loop');
|
|
1220
|
-
const
|
|
1221
|
-
|
|
1222
|
-
const
|
|
1368
|
+
const window = input.leadIn === undefined ? undefined : { periods: input.leadIn };
|
|
1369
|
+
if (window !== undefined) rmSync(run.scratch, { recursive: true, force: true });
|
|
1370
|
+
const frames = window === undefined ? join(out, 'check', 'idle_frames') : leadInFrames(join(out, 'rig'), run.scratch, window.periods, run.check, { pageEdges: input.pageEdges, packShape: input.packShape ?? DEFAULT_PACK_SHAPE });
|
|
1371
|
+
const lossless = loopStage(frames, join(out, 'idle.png'), 'apng', say, window);
|
|
1372
|
+
const indexed = loopStage(frames, join(out, 'idle-indexed.png'), 'indexed', say, window);
|
|
1373
|
+
const gif = loopStage(frames, join(out, 'idle.gif'), 'gif', say, window);
|
|
1374
|
+
if (window !== undefined) rmSync(run.scratch, { recursive: true, force: true });
|
|
1223
1375
|
const fig = (e: PaletteError | null): string => (e === null ? 'lossless' : `max ${e.max}, mean ${e.mean.toFixed(3)}`);
|
|
1224
1376
|
say(`loop: idle.png ${lossless.bytes} B (${fig(lossless.error)}); idle-indexed.png ${indexed.bytes} B (${fig(indexed.error)}); idle.gif ${gif.bytes} B (${fig(gif.error)}) — palette error per channel over all frames`);
|
|
1225
1377
|
} catch (err) {
|
package/src/check.ts
CHANGED
|
@@ -111,6 +111,8 @@ import {
|
|
|
111
111
|
topmostIndex,
|
|
112
112
|
TORSO_TAGS,
|
|
113
113
|
halfTravels,
|
|
114
|
+
chainToward,
|
|
115
|
+
type Toward,
|
|
114
116
|
type WorldBox,
|
|
115
117
|
} from './instruments.ts';
|
|
116
118
|
import { type PartRecord, type PartsFile, readParts } from './parts.ts';
|
|
@@ -1757,6 +1759,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
|
|
|
1757
1759
|
const file = reqFile as RequirementsFile;
|
|
1758
1760
|
const ready = inp;
|
|
1759
1761
|
take(() => resolveRequirements(file, ready.rig, ready.motion, ready.rootBone));
|
|
1762
|
+
take(() => refuseIfAny(exceptionProblems(file, ready.parts, ready.noParts)));
|
|
1760
1763
|
// A seam's pairs are read off parts.json's art (issue #111); both resolvers' problems are named in one refusal.
|
|
1761
1764
|
if (file.requirements.some((r) => r.kind === 'seam')) {
|
|
1762
1765
|
const parts = ready.parts;
|
|
@@ -1873,12 +1876,14 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
|
|
|
1873
1876
|
if (r.status !== 0) refuseIfAny(rigcFailed(`render --animation idle --slot ${ps.map((p) => p.name).join(',')}`, r, r.out.split('\n').filter((l) => l.includes('FAIL') || l.includes('rigc:'))));
|
|
1874
1877
|
return readFrameSet(dir);
|
|
1875
1878
|
};
|
|
1879
|
+
// Issue #231: the footwear parts the scene file declares not planted; none without --requirements or the field.
|
|
1880
|
+
const notPlanted = reqFile === null ? [] : reqFile.not_planted;
|
|
1876
1881
|
let breath: JudgementLine = skip(lacking);
|
|
1877
1882
|
let tip: JudgementLine = skip(lacking);
|
|
1878
1883
|
if (full !== null) {
|
|
1879
1884
|
try {
|
|
1880
|
-
breath = breathLine(full, isolated, problems);
|
|
1881
|
-
if (idle !== null) tip = tipLine(full, join(idle.dir, GEOMETRY_FILE), idle.written, problems);
|
|
1885
|
+
breath = breathLine(full, isolated, problems, notPlanted);
|
|
1886
|
+
if (idle !== null) tip = tipLine(full, join(idle.dir, GEOMETRY_FILE), idle.written, problems, reqFile === null ? NO_TIP_EXCEPTIONS : { path: reqFile.path, not_swinging: reqFile.not_swinging, rises: reqFile.rises });
|
|
1882
1887
|
} finally {
|
|
1883
1888
|
rmSync(isoDir, { recursive: true, force: true });
|
|
1884
1889
|
}
|
|
@@ -1892,7 +1897,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
|
|
|
1892
1897
|
const geometryPath = join(idle.dir, GEOMETRY_FILE);
|
|
1893
1898
|
const geometry = readGeometry(geometryPath, idle.written);
|
|
1894
1899
|
stretch = stretchLine(geometry, geometryPath, problems);
|
|
1895
|
-
if (full !== null) still = stillLine(full, idle, geometry, geometryPath, problems);
|
|
1900
|
+
if (full !== null) still = stillLine(full, idle, geometry, geometryPath, problems, notPlanted);
|
|
1896
1901
|
}
|
|
1897
1902
|
|
|
1898
1903
|
// 4. the setup pose as a one-key throwaway animation — the seam reads it against the parts, the source line against
|
|
@@ -2242,22 +2247,98 @@ function quoteParts(ps: readonly PartRecord[]): string {
|
|
|
2242
2247
|
|
|
2243
2248
|
type Isolate = (label: string, ps: readonly PartRecord[]) => FrameSet;
|
|
2244
2249
|
|
|
2245
|
-
/**
|
|
2246
|
-
|
|
2250
|
+
/**
|
|
2251
|
+
* The scene file's three exceptions (issue #231), read against parts.json
|
|
2252
|
+
* before anything is built: each name must be a part parts.json lists
|
|
2253
|
+
* (`REQUIREMENTS_RESOLVES`), and one the bar it relaxes reads — a `footwear`
|
|
2254
|
+
* part for `not_planted` (`REQUIREMENTS_NOT_PLANTED`), a `handwear` or
|
|
2255
|
+
* `bottomwear` part for `not_swinging` and `rises`
|
|
2256
|
+
* (`REQUIREMENTS_TIP_EXCEPTION`): an exception that excepts nothing is a miss,
|
|
2257
|
+
* refused by name. With no parts.json every name is refused. Whether a `rises`
|
|
2258
|
+
* part's chain does rise is read off the idle's geometry by `TIP_OVER_ROOT`.
|
|
2259
|
+
*/
|
|
2260
|
+
export function exceptionProblems(file: Pick<RequirementsFile, 'not_planted' | 'not_swinging' | 'rises' | 'path'>, parts: PartsFile | null, noParts: string | null): Problem[] {
|
|
2261
|
+
const out: Problem[] = [];
|
|
2262
|
+
// Issue #231: not_swinging and rises name swinging parts, the ones TIP_OVER_ROOT reads; whether a rises part's chain does rise is read off the idle's geometry, in the line.
|
|
2263
|
+
for (const key of ['not_swinging', 'rises'] as const) {
|
|
2264
|
+
file[key].forEach((name, i) => {
|
|
2265
|
+
const object = `${file.path} field "${key}"[${i}]`;
|
|
2266
|
+
if (parts === null) {
|
|
2267
|
+
out.push({ code: 'REQUIREMENTS_TIP_EXCEPTION', object, detail: `names part "${name}", and the swinging parts are chosen by parts.json's See-through tags, of which none was read: ${noParts ?? 'no parts.json'}` });
|
|
2268
|
+
return;
|
|
2269
|
+
}
|
|
2270
|
+
const rec = parts.parts.find((p) => p.name === name);
|
|
2271
|
+
if (rec === undefined) {
|
|
2272
|
+
out.push({ code: 'REQUIREMENTS_RESOLVES', object, detail: `names part "${name}", which parts.json does not list; it lists ${parts.parts.map((p) => `"${p.name}"`).join(', ') || 'none'}` });
|
|
2273
|
+
return;
|
|
2274
|
+
}
|
|
2275
|
+
const swing = partsTagged(parts, SWING_TAGS).map((p) => p.name);
|
|
2276
|
+
if (!swing.includes(name)) {
|
|
2277
|
+
out.push({
|
|
2278
|
+
code: 'REQUIREMENTS_TIP_EXCEPTION',
|
|
2279
|
+
object,
|
|
2280
|
+
detail: `names part "${name}" (from "${rec.from}"), which TIP_OVER_ROOT does not read; only a part from a See-through ${tagWords(SWING_TAGS)} layer is (${swing.map((n) => `"${n}"`).join(', ') || 'none here'}), so declaring it excepts nothing`,
|
|
2281
|
+
});
|
|
2282
|
+
}
|
|
2283
|
+
});
|
|
2284
|
+
}
|
|
2285
|
+
file.not_planted.forEach((name, i) => {
|
|
2286
|
+
const object = `${file.path} field "not_planted"[${i}]`;
|
|
2287
|
+
if (parts === null) {
|
|
2288
|
+
out.push({ code: 'REQUIREMENTS_NOT_PLANTED', object, detail: `names part "${name}", and the feet are chosen by parts.json's See-through tags, of which none was read: ${noParts ?? 'no parts.json'}` });
|
|
2289
|
+
return;
|
|
2290
|
+
}
|
|
2291
|
+
const rec = parts.parts.find((p) => p.name === name);
|
|
2292
|
+
if (rec === undefined) {
|
|
2293
|
+
out.push({ code: 'REQUIREMENTS_RESOLVES', object, detail: `names part "${name}", which parts.json does not list; it lists ${parts.parts.map((p) => `"${p.name}"`).join(', ') || 'none'}` });
|
|
2294
|
+
return;
|
|
2295
|
+
}
|
|
2296
|
+
const feet = partsTagged(parts, FEET_TAGS).map((p) => p.name);
|
|
2297
|
+
if (!feet.includes(name)) {
|
|
2298
|
+
out.push({
|
|
2299
|
+
code: 'REQUIREMENTS_NOT_PLANTED',
|
|
2300
|
+
object,
|
|
2301
|
+
detail: `names part "${name}" (from "${rec.from}"), which is not read as a planted foot; only a part from a See-through ${tagWords(FEET_TAGS)} layer is (${feet.map((n) => `"${n}"`).join(', ') || 'none here'}), so declaring it excepts nothing`,
|
|
2302
|
+
});
|
|
2303
|
+
}
|
|
2304
|
+
});
|
|
2305
|
+
return out;
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
/** The feet a line holds still (issue #231): the `footwear` parts less those the scene file declares not planted, and the declared ones, in parts.json's order. */
|
|
2309
|
+
function plantedFeet(parts: PartsFile, notPlanted: readonly string[]): { all: PartRecord[]; held: PartRecord[]; declared: string[] } {
|
|
2310
|
+
const all = partsTagged(parts, FEET_TAGS);
|
|
2311
|
+
return { all, held: all.filter((p) => !notPlanted.includes(p.name)), declared: all.filter((p) => notPlanted.includes(p.name)).map((p) => p.name) };
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
/** The sentence a feet half carries when every footwear part is declared not planted (issue #231). */
|
|
2315
|
+
function noneHeldText(declared: readonly string[]): string {
|
|
2316
|
+
return `every ${tagWords(FEET_TAGS)} part (${declared.map((n) => `"${n}"`).join(', ')}) is declared not planted by the scene file's not_planted, so there are no feet to hold still`;
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
/**
|
|
2320
|
+
* (1) The torso moves while the feet do not: each rendered alone, heat over
|
|
2321
|
+
* its own box. The feet are the `footwear` parts less those the scene file
|
|
2322
|
+
* declares `not_planted` (issue #231); a declaration is the line's first
|
|
2323
|
+
* figure, `declared_not_planted`, so the line never reads as though the feet
|
|
2324
|
+
* it names were held still. With every foot declared, the torso is measured
|
|
2325
|
+
* and the feet figures are an empty list and null.
|
|
2326
|
+
*/
|
|
2327
|
+
function breathLine(inp: FullInputs, isolated: Isolate, problems: Problem[], notPlanted: readonly string[] = []): JudgementLine {
|
|
2247
2328
|
const torso = partsTagged(inp.parts, TORSO_TAGS);
|
|
2248
|
-
const feet =
|
|
2249
|
-
if (torso.length === 0) return skip(`no part comes from a See-through ${tagWords(TORSO_TAGS)} layer, so there is no torso to see breathe`);
|
|
2250
|
-
if (
|
|
2329
|
+
const { all: allFeet, held: feet, declared } = plantedFeet(inp.parts, notPlanted);
|
|
2330
|
+
if (torso.length === 0) return skip(`no part comes from a See-through ${tagWords(TORSO_TAGS)} layer, so there is no torso to see breathe${declared.length === 0 ? '' : `; ${declared.map((n) => `"${n}"`).join(', ')} declared not planted`}`);
|
|
2331
|
+
if (allFeet.length === 0) return skip(`no part comes from a See-through ${tagWords(FEET_TAGS)} layer, so there are no feet to hold still`);
|
|
2251
2332
|
const H = inp.parts.rig_size[1];
|
|
2252
2333
|
const ts = isolated('torso', torso);
|
|
2253
|
-
const fs = isolated('feet', feet);
|
|
2334
|
+
const fs = feet.length === 0 ? null : isolated('feet', feet);
|
|
2254
2335
|
const tb = frameBox(torso, H, inp.stage, ts.viewport);
|
|
2255
|
-
const fb = frameBox(feet, H, inp.stage, fs.viewport);
|
|
2256
|
-
if (tb === null || fb === null) return skip(`the ${tb === null ? 'torso' : 'feet'} box falls outside the idle frame`);
|
|
2336
|
+
const fb = fs === null ? null : frameBox(feet, H, inp.stage, fs.viewport);
|
|
2337
|
+
if (tb === null || (fs !== null && fb === null)) return skip(`the ${tb === null ? 'torso' : 'feet'} box falls outside the idle frame`);
|
|
2257
2338
|
const th = regionHeat(heatField(ts.frames.map((f) => f.image)), ts.viewport.pixelWidth, tb);
|
|
2258
|
-
const fh = regionHeat(heatField(fs.frames.map((f) => f.image)), fs.viewport.pixelWidth, fb);
|
|
2339
|
+
const fh = fs === null || fb === null ? null : regionHeat(heatField(fs.frames.map((f) => f.image)), fs.viewport.pixelWidth, fb);
|
|
2259
2340
|
const mean = round3(th.mean);
|
|
2260
|
-
const ok = mean >= BREATH_TORSO_MEAN_FLOOR && fh.max <= BREATH_FEET_MAX_CEILING;
|
|
2341
|
+
const ok = mean >= BREATH_TORSO_MEAN_FLOOR && (fh === null || fh.max <= BREATH_FEET_MAX_CEILING);
|
|
2261
2342
|
if (mean < BREATH_TORSO_MEAN_FLOOR) {
|
|
2262
2343
|
problems.push({
|
|
2263
2344
|
code: 'CHECK_BREATH_VISIBLE',
|
|
@@ -2265,24 +2346,34 @@ function breathLine(inp: FullInputs, isolated: Isolate, problems: Problem[]): Ju
|
|
|
2265
2346
|
detail: `heat mean ${mean}/255 over the idle; >= ${BREATH_TORSO_MEAN_FLOOR} is required — the chest's breath track moves too little, or the torso is not weighted to the bone it keys`,
|
|
2266
2347
|
});
|
|
2267
2348
|
}
|
|
2268
|
-
if (fh.max > BREATH_FEET_MAX_CEILING) {
|
|
2349
|
+
if (fh !== null && fb !== null && fh.max > BREATH_FEET_MAX_CEILING) {
|
|
2269
2350
|
problems.push({
|
|
2270
2351
|
code: 'CHECK_BREATH_VISIBLE',
|
|
2271
|
-
object: `feet ${quoteParts(feet)} rendered alone, frame box ${boxLabel(fb)}`,
|
|
2352
|
+
object: `feet ${quoteParts(feet)} rendered alone, frame box ${boxLabel(fb)}${declared.length === 0 ? '' : ` (${declared.map((n) => `"${n}"`).join(', ')} declared not planted)`}`,
|
|
2272
2353
|
detail: `heat max ${fh.max}/255 over the idle; <= ${BREATH_FEET_MAX_CEILING} is required — the feet move: their slot rides, or their mesh is weighted to, a bone the idle keys`,
|
|
2273
2354
|
});
|
|
2274
2355
|
}
|
|
2275
2356
|
return {
|
|
2276
2357
|
status: ok ? 'PASS' : 'FAIL',
|
|
2358
|
+
...(declared.length === 0 ? {} : { declared_not_planted: declared }),
|
|
2277
2359
|
torso_parts: torso.map((p) => p.name),
|
|
2278
2360
|
torso_heat_mean: mean,
|
|
2279
2361
|
torso_floor: BREATH_TORSO_MEAN_FLOOR,
|
|
2280
2362
|
feet_parts: feet.map((p) => p.name),
|
|
2281
|
-
feet_heat_max: fh.max,
|
|
2363
|
+
feet_heat_max: fh === null ? null : fh.max,
|
|
2282
2364
|
feet_ceiling: BREATH_FEET_MAX_CEILING,
|
|
2283
2365
|
};
|
|
2284
2366
|
}
|
|
2285
2367
|
|
|
2368
|
+
/** The scene file's two exceptions to `TIP_OVER_ROOT` (issue #231), and the file they were read from, which a refusal names. */
|
|
2369
|
+
export interface TipExceptions {
|
|
2370
|
+
path: string;
|
|
2371
|
+
not_swinging: readonly string[];
|
|
2372
|
+
rises: readonly string[];
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2375
|
+
const NO_TIP_EXCEPTIONS: TipExceptions = { path: '', not_swinging: [], rises: [] };
|
|
2376
|
+
|
|
2286
2377
|
/**
|
|
2287
2378
|
* What `TIP_OVER_ROOT` is measured with, written into its line (issue #118):
|
|
2288
2379
|
* the idle's posed geometry, not a render of the part. Until #118 the line
|
|
@@ -2301,37 +2392,139 @@ export const TIP_INSTRUMENT = 'geometry';
|
|
|
2301
2392
|
* binary-coverage centroid per half; every root half on the three examples
|
|
2302
2393
|
* travels under a frame pixel, where that centroid scatters by up to 0.3 px
|
|
2303
2394
|
* with the grid, so the ratio was set by the grid.
|
|
2395
|
+
*
|
|
2396
|
+
* Issue #231: the scene file's `not_swinging` parts are left out, each row
|
|
2397
|
+
* saying so, and its `rises` parts are read with the halves swapped once the
|
|
2398
|
+
* chain carrying each is measured to rise ({@link chainDirection}; refused by
|
|
2399
|
+
* name otherwise); both lists are the line's first figures. Without them the
|
|
2400
|
+
* line is the one written before.
|
|
2304
2401
|
*/
|
|
2305
|
-
function tipLine(inp: FullInputs, geometryPath: string, frames: number, problems: Problem[]): JudgementLine {
|
|
2402
|
+
function tipLine(inp: FullInputs, geometryPath: string, frames: number, problems: Problem[], excepted: TipExceptions = NO_TIP_EXCEPTIONS): JudgementLine {
|
|
2306
2403
|
const swing = partsTagged(inp.parts, SWING_TAGS);
|
|
2307
2404
|
if (swing.length === 0) return skip(`no part comes from a See-through ${tagWords(SWING_TAGS)} layer, so there is no hem or sleeve to swing`);
|
|
2308
2405
|
const poses = attachmentPoses(geometryPath, frames);
|
|
2406
|
+
// Issue #231: a part declared `rises` is read up only when the chain carrying it does rise, measured at the setup pose.
|
|
2407
|
+
if (excepted.rises.length > 0) {
|
|
2408
|
+
const geo = readGeometry(geometryPath, frames);
|
|
2409
|
+
const origins = new Map<string, { x: number; y: number }>();
|
|
2410
|
+
for (const b of Array.isArray(geo?.setupBones) ? geo.setupBones : []) {
|
|
2411
|
+
if (isRecord(b) && typeof b.name === 'string' && typeof b.worldX === 'number' && typeof b.worldY === 'number') origins.set(b.name, { x: b.worldX, y: b.worldY });
|
|
2412
|
+
}
|
|
2413
|
+
const refused: Problem[] = [];
|
|
2414
|
+
excepted.rises.forEach((name, i) => {
|
|
2415
|
+
const dir = chainDirection(inp.rig, inp.motion, name, origins);
|
|
2416
|
+
if ('toward' in dir && dir.toward === 'up') return;
|
|
2417
|
+
refused.push({
|
|
2418
|
+
code: 'REQUIREMENTS_TIP_EXCEPTION',
|
|
2419
|
+
object: `${excepted.path} field "rises"[${i}]`,
|
|
2420
|
+
detail: `names part "${name}", whose tip is not up: ${'none' in dir ? dir.none : `its chain runs ${dir.toward} — ${dir.chains.join('; ')}`}; a part declared rises must be carried by a chain whose last link's origin is above its first's at the setup pose`,
|
|
2421
|
+
});
|
|
2422
|
+
});
|
|
2423
|
+
refuseIfAny(refused);
|
|
2424
|
+
}
|
|
2309
2425
|
const rows: Array<Record<string, unknown>> = [];
|
|
2310
2426
|
let measured = 0;
|
|
2311
2427
|
let failed = false;
|
|
2312
2428
|
for (const p of swing) {
|
|
2429
|
+
if (excepted.not_swinging.includes(p.name)) {
|
|
2430
|
+
rows.push({ part: p.name, unmeasured: "declared not swinging by the scene file's not_swinging, so it is not held to the bar" });
|
|
2431
|
+
continue;
|
|
2432
|
+
}
|
|
2433
|
+
const rises = excepted.rises.includes(p.name);
|
|
2434
|
+
const [rootHalf, tipHalf] = rises ? ['lower', 'upper'] : ['upper', 'lower'];
|
|
2313
2435
|
const art = placedArt(p.x, p.y, readPng(join(inp.partsDir, `${p.name}.png`)));
|
|
2314
2436
|
const shown = poses.map((f) => f.get(p.name) ?? null);
|
|
2315
|
-
const t = halfTravels(art, p, inp.stage, shown);
|
|
2437
|
+
const t = halfTravels(art, p, inp.stage, shown, rises ? 'up' : 'down');
|
|
2438
|
+
// A part not declared writes the row it wrote before issue #231; a declared one says which half is its tip's.
|
|
2439
|
+
const said = rises ? { tip_half: tipHalf } : {};
|
|
2316
2440
|
if (t.root === null || t.tip === null) {
|
|
2317
|
-
rows.push({ part: p.name, unmeasured: shown[0] === null ? 'its slot shows no attachment on idle frame 0' : `on idle frame 0 the ${t.root === null ?
|
|
2441
|
+
rows.push({ part: p.name, ...said, unmeasured: shown[0] === null ? 'its slot shows no attachment on idle frame 0' : `on idle frame 0 the ${t.root === null ? rootHalf : tipHalf} half of its box holds no art its attachment draws` });
|
|
2318
2442
|
continue;
|
|
2319
2443
|
}
|
|
2320
2444
|
measured++;
|
|
2321
2445
|
const ratio = t.root > 0 ? round3(t.tip / t.root) : null;
|
|
2322
2446
|
const ok = ratio === null ? t.tip > 0 : ratio >= TIP_RATIO_FLOOR;
|
|
2323
|
-
rows.push({ part: p.name, root_rig_px: round3(t.root), tip_rig_px: round3(t.tip), ratio });
|
|
2447
|
+
rows.push({ part: p.name, ...said, root_rig_px: round3(t.root), tip_rig_px: round3(t.tip), ratio });
|
|
2324
2448
|
if (!ok) {
|
|
2325
2449
|
failed = true;
|
|
2326
2450
|
problems.push({
|
|
2327
2451
|
code: 'CHECK_TIP_OVER_ROOT',
|
|
2328
|
-
object: `part "${p.name}", box ${p.x},${p.y} ${p.w}x${p.h} rig px, posed by the idle's geometry`,
|
|
2329
|
-
detail: `the art in its
|
|
2452
|
+
object: `part "${p.name}", box ${p.x},${p.y} ${p.w}x${p.h} rig px, posed by the idle's geometry${rises ? ', declared rises' : ''}`,
|
|
2453
|
+
detail: `the art in its ${tipHalf} half travels ${round3(t.tip)} rig px and in its ${rootHalf} half ${round3(t.root)} rig px (ratio ${ratio ?? 'undefined: nothing moves'}); a ratio >= ${TIP_RATIO_FLOOR} is required — the chain's amplitudes do not grow toward the tip, or the mesh is not weighted to the chain`,
|
|
2330
2454
|
});
|
|
2331
2455
|
}
|
|
2332
2456
|
}
|
|
2457
|
+
const declared = {
|
|
2458
|
+
...(excepted.not_swinging.length === 0 ? {} : { declared_not_swinging: swing.filter((p) => excepted.not_swinging.includes(p.name)).map((p) => p.name) }),
|
|
2459
|
+
...(excepted.rises.length === 0 ? {} : { declared_rises: swing.filter((p) => excepted.rises.includes(p.name)).map((p) => p.name) }),
|
|
2460
|
+
};
|
|
2333
2461
|
if (measured === 0) return { status: 'SKIP', reason: `none of ${quoteParts(swing)} could be measured: ${rows.map((r) => `${String(r.part)}: ${String(r.unmeasured)}`).join('; ')}` };
|
|
2334
|
-
return { status: failed ? 'FAIL' : 'PASS', instrument: TIP_INSTRUMENT, parts: rows, ratio_floor: TIP_RATIO_FLOOR };
|
|
2462
|
+
return { status: failed ? 'FAIL' : 'PASS', ...declared, instrument: TIP_INSTRUMENT, parts: rows, ratio_floor: TIP_RATIO_FLOOR };
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
/**
|
|
2466
|
+
* Which way the chain carrying a part runs (issue #231), for a part the scene
|
|
2467
|
+
* declares `rises`: the bones its attachments are bound to in the default skin
|
|
2468
|
+
* (a mesh's weights, or its slot's bone); the leaves among them — those with
|
|
2469
|
+
* no other of them below — walked up the bone tree to the first link of a
|
|
2470
|
+
* chain `CHAIN_LAG` reads ({@link chainFigures}: two or more rotate-keyed
|
|
2471
|
+
* links, each under the one before at one period); each chain so found runs
|
|
2472
|
+
* from its first link's setup-pose origin to its last link's
|
|
2473
|
+
* ({@link chainToward}). `{ none }`, the reason, when no leaf is under a chain,
|
|
2474
|
+
* when a chain runs as far across as along, or when the part's chains run
|
|
2475
|
+
* different ways.
|
|
2476
|
+
*/
|
|
2477
|
+
export function chainDirection(rig: Record<string, unknown>, motion: Record<string, unknown>, part: string, origins: ReadonlyMap<string, { x: number; y: number }>): { toward: Toward; chains: string[] } | { none: string } {
|
|
2478
|
+
const bones = (Array.isArray(rig.bones) ? rig.bones : []).filter(isRecord).filter((b) => typeof b.name === 'string');
|
|
2479
|
+
const parent = new Map(bones.map((b) => [String(b.name), typeof b.parent === 'string' ? b.parent : null]));
|
|
2480
|
+
const slot = (Array.isArray(rig.slots) ? rig.slots : []).filter(isRecord).find((sl) => sl.name === part);
|
|
2481
|
+
const skins = isRecord(rig.skins) ? rig.skins : {};
|
|
2482
|
+
const skin = isRecord(skins.default) ? skins.default : {};
|
|
2483
|
+
const atts = isRecord(skin[part]) ? Object.values(skin[part]) : [];
|
|
2484
|
+
const bound = new Set<string>();
|
|
2485
|
+
for (const a of atts) {
|
|
2486
|
+
if (isRecord(a) && Array.isArray(a.weights)) for (const v of a.weights) for (const w of Array.isArray(v) ? v : []) if (isRecord(w) && typeof w.bone === 'string') bound.add(w.bone);
|
|
2487
|
+
if (!(isRecord(a) && Array.isArray(a.weights)) && slot !== undefined && typeof slot.bone === 'string') bound.add(slot.bone);
|
|
2488
|
+
}
|
|
2489
|
+
const under = (b: string, anc: string): boolean => {
|
|
2490
|
+
const seen = new Set<string>();
|
|
2491
|
+
for (let x = parent.get(b) ?? null; x !== null && !seen.has(x); x = parent.get(x) ?? null) {
|
|
2492
|
+
if (x === anc) return true;
|
|
2493
|
+
seen.add(x);
|
|
2494
|
+
}
|
|
2495
|
+
return false;
|
|
2496
|
+
};
|
|
2497
|
+
const leaves = [...bound].filter((b) => ![...bound].some((o) => o !== b && under(o, b)));
|
|
2498
|
+
const chains = chainFigures(rig, motion).chains.filter((c) => c.length >= 2);
|
|
2499
|
+
const chainOf = new Map<string, string[]>();
|
|
2500
|
+
for (const c of chains) for (const l of c) chainOf.set(l.bone, c.map((x) => x.bone));
|
|
2501
|
+
const found: string[][] = [];
|
|
2502
|
+
for (const leaf of leaves) {
|
|
2503
|
+
const seen = new Set<string>();
|
|
2504
|
+
for (let b: string | null = leaf; b !== null && !seen.has(b); b = parent.get(b) ?? null) {
|
|
2505
|
+
seen.add(b);
|
|
2506
|
+
const c = chainOf.get(b);
|
|
2507
|
+
if (c !== undefined) {
|
|
2508
|
+
if (!found.some((f) => f[0] === c[0])) found.push(c);
|
|
2509
|
+
break;
|
|
2510
|
+
}
|
|
2511
|
+
}
|
|
2512
|
+
}
|
|
2513
|
+
const boundText = [...bound].map((b) => `"${b}"`).join(', ') || 'no bone';
|
|
2514
|
+
if (found.length === 0) return { none: `it rides no chain: none of the bones it is bound to (${boundText}) is under a chain of rotate-keyed links` };
|
|
2515
|
+
const read = found.map((c) => {
|
|
2516
|
+
const a = origins.get(c[0]);
|
|
2517
|
+
const z = origins.get(c[c.length - 1]);
|
|
2518
|
+
const label = c.join(' > ');
|
|
2519
|
+
if (a === undefined || z === undefined) return { label, toward: null, text: `${label}: no setup-pose origin for ${a === undefined ? c[0] : c[c.length - 1]} in the idle's geometry` };
|
|
2520
|
+
const dx = z.x - a.x;
|
|
2521
|
+
const dy = z.y - a.y;
|
|
2522
|
+
const toward = chainToward(dx, dy);
|
|
2523
|
+
return { label, toward, text: `${label} ${toward ?? 'runs as far across as along'} (${round3(dx)}, ${round3(dy)} world px)` };
|
|
2524
|
+
});
|
|
2525
|
+
const ways = [...new Set(read.map((r) => r.toward))];
|
|
2526
|
+
if (ways.length !== 1 || ways[0] === null) return { none: `its chains do not run one way: ${read.map((r) => r.text).join('; ')}` };
|
|
2527
|
+
return { toward: ways[0], chains: read.map((r) => r.text) };
|
|
2335
2528
|
}
|
|
2336
2529
|
|
|
2337
2530
|
/**
|
|
@@ -2474,10 +2667,10 @@ interface StillHalf {
|
|
|
2474
2667
|
* features go; `footwear` on top) is reported per slot, `crossing`, with no
|
|
2475
2668
|
* bar ({@link crossingOf}). No render grid enters either half.
|
|
2476
2669
|
*/
|
|
2477
|
-
export function stillLine(inp: FullInputs, idle: FrameSet, geo: IdleGeometry | null, geometryPath: string, problems: Problem[]): JudgementLine {
|
|
2670
|
+
export function stillLine(inp: FullInputs, idle: FrameSet, geo: IdleGeometry | null, geometryPath: string, problems: Problem[], notPlanted: readonly string[] = []): JudgementLine {
|
|
2478
2671
|
const face = partsTagged(inp.parts, FACE_TAGS);
|
|
2479
|
-
const feet =
|
|
2480
|
-
if (face.length === 0 &&
|
|
2672
|
+
const { all: allFeet, held: feet, declared } = plantedFeet(inp.parts, notPlanted);
|
|
2673
|
+
if (face.length === 0 && allFeet.length === 0) return skip(`no part comes from a See-through ${tagWords([...FACE_TAGS, ...FEET_TAGS])} layer, so there is no still region to read`);
|
|
2481
2674
|
if (geo === null) return skip(`${geometryPath} does not exist; \`rigc render --geometry\` (rig-c ${RIGC_GEOMETRY_VERSION} or later) writes it, and the still regions are read off it`);
|
|
2482
2675
|
const H = inp.parts.rig_size[1];
|
|
2483
2676
|
const W = inp.parts.rig_size[0];
|
|
@@ -2618,7 +2811,8 @@ export function stillLine(inp: FullInputs, idle: FrameSet, geo: IdleGeometry | n
|
|
|
2618
2811
|
};
|
|
2619
2812
|
|
|
2620
2813
|
const feetHalf = (): Record<string, unknown> | string => {
|
|
2621
|
-
if (
|
|
2814
|
+
if (allFeet.length === 0) return `no part comes from a See-through ${tagWords(FEET_TAGS)} layer`;
|
|
2815
|
+
if (feet.length === 0) return noneHeldText(declared);
|
|
2622
2816
|
const names = feet.map((p) => p.name);
|
|
2623
2817
|
const h = half(names, firstOf(names), poses.map(() => null), 1, 0, regionOf(new Set(feet.map((p) => index.get(p.name) as number)), []));
|
|
2624
2818
|
for (const o of h.over) {
|
|
@@ -2635,7 +2829,9 @@ export function stillLine(inp: FullInputs, idle: FrameSet, geo: IdleGeometry | n
|
|
|
2635
2829
|
const t = feetHalf();
|
|
2636
2830
|
if (typeof f === 'string' && typeof t === 'string') return skip(`face: ${f}; feet: ${t}`);
|
|
2637
2831
|
const failed = problems.some((q) => q.code === 'CHECK_STILL_REGIONS_DARK');
|
|
2638
|
-
|
|
2832
|
+
// Issue #231: a declaration is the feet half's first figure, measured or not.
|
|
2833
|
+
const said = declared.length === 0 ? {} : { declared_not_planted: declared };
|
|
2834
|
+
return { status: failed ? 'FAIL' : 'PASS', face: typeof f === 'string' ? { unmeasured: f } : f, feet: typeof t === 'string' ? { ...said, unmeasured: t } : { ...said, ...t } };
|
|
2639
2835
|
}
|
|
2640
2836
|
|
|
2641
2837
|
function fmt3(x: number): string {
|