rig-parts 1.85.0 → 1.86.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/docs/AUTHORING.md +22 -0
- package/package.json +2 -2
- package/src/build.ts +3 -2
- package/src/check.ts +31 -6
- package/src/jointcontact.ts +59 -0
package/docs/AUTHORING.md
CHANGED
|
@@ -5120,6 +5120,28 @@ wind (1, 0), then (40, 0) from 0.5 s, reads `TEXTURE_STRETCH` 32.181 (FAIL, `hai
|
|
|
5120
5120
|
triangle 258), two meshes folding under `ANIMATIONS_DO_NOT_FOLD` (FAIL; none without the
|
|
5121
5121
|
flag) and 8 `CLOTH_SETTLES` warnings for 9 (selftest `CK515`).
|
|
5122
5122
|
|
|
5123
|
+
**Joint material contact** (issue #894). Each played `SEAM_OVER_MOTION.rows[]` adds
|
|
5124
|
+
`joint_contact`: `status` (`REPORTED` or `SKIP`), `joints` (declared limb joint
|
|
5125
|
+
labels or a proven body root's bone), and, when observed, the frame counts
|
|
5126
|
+
`joined`, `separated` and `unavailable`, plus `reasons` with each unavailable
|
|
5127
|
+
reason and its observed frame indices. `JOINT_CONTACT` prints one informational
|
|
5128
|
+
line per pair and animation; it is never a bar and changes no PASS, warning or
|
|
5129
|
+
exit status. It counts core 2.84.0's `jointContact.frames` from the same seam
|
|
5130
|
+
render. Core reads edge-connected half-opacity material: initially overlapping
|
|
5131
|
+
single-component pieces are joined when their posed union connects by an edge
|
|
5132
|
+
or overlap, separated when it does not. A diagonal contact is separated. Missing
|
|
5133
|
+
setup overlap, absent drawings, disconnected material or multiple fragments are
|
|
5134
|
+
unavailable with core's reason. A pair without a declared named joint is
|
|
5135
|
+
unavailable even if its arbitrary slot drawings connect. An absent core contact
|
|
5136
|
+
field is unavailable when the frame count is known; absent frame counts or zero
|
|
5137
|
+
samples are SKIP with a reason, never invented joined frames. Unavailable is
|
|
5138
|
+
never added to joined. No pair or animation is a named SKIP in the output.
|
|
5139
|
+
Contact reports material connectivity at core's texel grid, not anatomical
|
|
5140
|
+
intent, sub-cell contact or the absence of local holes. Raw gap, ghost, floor
|
|
5141
|
+
and body-root verdicts keep their existing producer readings. Core 2.83.0's
|
|
5142
|
+
visible-ghost and local-support gap definitions are inherited by the 2.84.0 pin;
|
|
5143
|
+
contact counts do not replace or reinterpret them.
|
|
5144
|
+
|
|
5123
5145
|
**Physics seam environments** (issue #591). Default: wind [1, 0], gravity [0, 1]. Still: wind [0, 0], gravity [0, 1]. In rig-play, environment(null) returns to Default; still air requires an explicit wind [0, 0]. For a physics rig with readable declared pairs and animations, SEAM_OVER_MOTION adds optional `worlds.default` and `worlds.still` readings. They are informational raw gap/ghost cell counts (including cells below the warning floor), never new bars or warnings; neither changes PASS, exit status or the bar count. Each world records its vectors, status and rows; each row names its pair, animation, drawn/banded frames and gap/ghost maximum and worst frame, cell size and world box. Per-frame hits are not embedded in check.json. A rendered world's `dir` is relative to check.json (`seam_motion/default` or `seam_motion/still`): it retains the complete rigc render, including frames.json, frame images and each animation's seam.json with all raw hits. The directories are published with check.json only after the readings are validated; scratch is removed, and an unrendered SKIP has no directory reference. The original reading and any custom `--environment` schedule keep their existing meaning; the two reference worlds are independent of that schedule. A rig with no wind response reports Still as SKIP with its reason; no band is SKIP, never a measured zero. With no readable declared pair or animation no worlds block is written. The same declared pair list is used for both reference worlds: neighbouring limb segments, explicit seam requirements and the proven body-root declarations described below. Thigh/hem pairs are not inferred. Adding reference worlds to a nonphysics reading stays byte-identical, with no extra key, line or render; issue #686 separately adds body-root declarations and their active-reading bar. Reference renders start physics at frame 0 at 12 fps; they do not certify a composed player scene or another sampling rate. Malformed sidecars or renderer failures remain named refusals.
|
|
5124
5146
|
|
|
5125
5147
|
**Steady-wind seam band** (`--wind-band`, issue #590). `check --wind-band <spec.json>` (or `build --wind-band <spec.json>`, forwarded) sweeps steady horizontal wind across a caller-declared grid and reports the contiguous `[x_lo, x_hi]` band in which each declared seam pair holds `gap <= max_gap` and `ghost <= max_ghost` across every animation in `motion.json`. Every field in `{ "spec": "rig-parts-wind-band/1", "min", "max", "step", "fps", "max_gap", "max_ghost", "pairs" }` is required — nothing falls back to `SEAM_OVER_MOTION`'s pairs, one-cell floor, or fps. At each swept step `x = min + k * step`, `check` runs `rigc render --seam <pairs> --fps <fps> --max 16 --environment` with a single-step steady schedule `{ "spec": "rigc-environment/1", "steps": [{ "t": 0, "wind": [x, 0], "gravity": [0, 1] }] }` (`seam.json` cells are in each pair's coarser drawing texels, independent of `--max`). **What it does not measure:** the sweep is steady wind (`wind: [x, 0], gravity: [0, 1]` at `t = 0`), not a time-varying gust (`x ± a` at 0.2 Hz); transient overshoot from a changing wind is not read by a steady sweep, and the band is documented and reported as the steady-wind band (`mode: "steady"`, `gravity: [0, 1]`). It is a reported line (`status: "REPORTED"`), never a bar: it prints one `WIND_BAND:` summary line plus one `WIND_BAND:` line per pair that breaks inside `[min, max]`, and writes `WIND_BAND` in `check.json` before `PASS`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rig-parts",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.86.0",
|
|
4
4
|
"description": "AI-authored 2D character parts from one anime painting and its See-through layer decomposition: measured parts, weighted-mesh rig specs, verified through rig-c before anything is written. A CLI for agents that cannot see the image.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
},
|
|
68
68
|
"dependencies": {
|
|
69
69
|
"ag-psd": "^31.0.2",
|
|
70
|
-
"@rig-c/core": "2.
|
|
70
|
+
"@rig-c/core": "2.84.0"
|
|
71
71
|
},
|
|
72
72
|
"devDependencies": {
|
|
73
73
|
"@esotericsoftware/spine-core": "4.3.13",
|
package/src/build.ts
CHANGED
|
@@ -81,7 +81,7 @@ import {
|
|
|
81
81
|
splitSchedule,
|
|
82
82
|
untestedIntervals,
|
|
83
83
|
} from './autoreplay.ts';
|
|
84
|
-
import { besideIdleLine, checkSummaryLine, inputFileRecord, type InputFileRecord, CLOTH_LINE, type ClothLine, clothWarnText, frameRuns, MOTION_SEAM_LINE, type MotionSeamLine, type MotionSeamPlant, motionSeamWarnText, FOLD_LINE, type FoldsLine, LEVEL_LINE, type LevelLine, LIMB_LINE, type LimbLine, STRESS_LINE, SWEEP_LINE, type SweepLine, buildGateLines, causeLines, environmentLine, IDLE_FPS, IDLE_MAX_PX, maxRgbDiff, REQUIREMENTS_DIR, type CheckReport, type PackLine, DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, GATE_PROFILE, JUDGEMENT_LINES, type JudgementLine, packedBuildArgs, packedBuildLabel, type PackMode, readCheckEnvironment, readWindBand, 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, WIND_BAND_LINE, windBandLines } from './check.ts';
|
|
84
|
+
import { besideIdleLine, checkSummaryLine, inputFileRecord, type InputFileRecord, CLOTH_LINE, type ClothLine, clothWarnText, frameRuns, MOTION_SEAM_LINE, type MotionSeamLine, type MotionSeamPlant, motionSeamWarnText, motionJointContactLines, FOLD_LINE, type FoldsLine, LEVEL_LINE, type LevelLine, LIMB_LINE, type LimbLine, STRESS_LINE, SWEEP_LINE, type SweepLine, buildGateLines, causeLines, environmentLine, IDLE_FPS, IDLE_MAX_PX, maxRgbDiff, REQUIREMENTS_DIR, type CheckReport, type PackLine, DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, GATE_PROFILE, JUDGEMENT_LINES, type JudgementLine, packedBuildArgs, packedBuildLabel, type PackMode, readCheckEnvironment, readWindBand, 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, WIND_BAND_LINE, windBandLines } from './check.ts';
|
|
85
85
|
import { type Cut, type Extend, type Limb, loadConfigAndAnimations, loadConfigWithBytes, loadEarlyConfig, type Patch, type PlanEntry } from './config.ts';
|
|
86
86
|
import { coreCompileGuideLine, failLine, PartsError, type Problem, refuseIfAny } from './errors.ts';
|
|
87
87
|
import type { MeshQualityReport, MotionSchedule } from '@rig-c/core/mesh';
|
|
@@ -1824,7 +1824,7 @@ export function sweepCheckLines(line: SweepLine, steps: ReadonlyMap<string, { le
|
|
|
1824
1824
|
export function motionSeamLines(line: MotionSeamLine, plant: MotionSeamPlant = {}): string[] {
|
|
1825
1825
|
const unmeasured = (line.roots?.unmeasured ?? []).map(root => `root pair for ${root.limb} (${root.part}, bone "${root.bone}", parent "${root.parent}", candidates ${root.candidates.join('/')}): SKIP — ${root.reason}`);
|
|
1826
1826
|
const rootStatus = line.roots === undefined ? '' : '; body-root gap-open+under-ghost gate ' + line.roots.status + ' (' + line.roots.pairs.length + ' pair(s)); ghost ownership from resolved draw order' + (line.roots.reason === undefined ? '' : ' — ' + line.roots.reason);
|
|
1827
|
-
if (line.worlds === undefined && line.status === 'SKIP' && line.rows.length === 0 && line.pairs.every((p) => p.unread === undefined)) return [`${MOTION_SEAM_LINE}: SKIP — ${String(line.reason)}${unmeasured.length === 0 ? '' : rootStatus}`, ...unmeasured];
|
|
1827
|
+
if (line.worlds === undefined && line.status === 'SKIP' && line.rows.length === 0 && line.pairs.every((p) => p.unread === undefined)) return [`${MOTION_SEAM_LINE}: SKIP — ${String(line.reason)}${unmeasured.length === 0 ? '' : rootStatus}`, ...unmeasured, ...motionJointContactLines(line)];
|
|
1828
1828
|
const read = `${line.pairs.length} declared pair(s) in ${line.animations.length} animation(s) (${line.animations.join(', ')}), read by rigc render --seam at ${line.fps} fps on the build as played`;
|
|
1829
1829
|
const head =
|
|
1830
1830
|
line.status === 'SKIP'
|
|
@@ -1832,6 +1832,7 @@ export function motionSeamLines(line: MotionSeamLine, plant: MotionSeamPlant = {
|
|
|
1832
1832
|
: `${MOTION_SEAM_LINE}: ${line.status} — ${read}: ${line.warnings} warning(s), never a bar${rootStatus}; ${line.floor}`;
|
|
1833
1833
|
return [
|
|
1834
1834
|
head,
|
|
1835
|
+
...motionJointContactLines(line),
|
|
1835
1836
|
...unmeasured,
|
|
1836
1837
|
...line.pairs.filter((p) => p.unread !== undefined).map((p) => `seam "${p.pair}" (${p.declared.join('; ')}): not read — ${String(p.unread)}`),
|
|
1837
1838
|
...line.rows.filter((r) => r.status === 'skip').map((r) => `seam "${r.pair}" in "${r.animation}": SKIP — rigc read no band on any of the ${r.drawn} frame(s) both slots draw, so there is no seam to read`),
|
package/src/check.ts
CHANGED
|
@@ -83,6 +83,7 @@ import { baseAnchorMoves, type AnchorRegion } from './baseanchor.ts';
|
|
|
83
83
|
* the last idle frame is refused unless it sits at t = duration (within
|
|
84
84
|
* `TIME_SLACK`, issue #244).
|
|
85
85
|
*/
|
|
86
|
+
import { jointContactReading, type JointContactReading } from './jointcontact.ts';
|
|
86
87
|
import { createHash } from 'node:crypto';
|
|
87
88
|
import { createRequire } from 'node:module';
|
|
88
89
|
import type { Viewport as RootCoverageViewport } from '@rig-c/core/render-shared';
|
|
@@ -4341,6 +4342,8 @@ export interface MotionSeamPair {
|
|
|
4341
4342
|
a: string;
|
|
4342
4343
|
b: string;
|
|
4343
4344
|
declared: string[];
|
|
4345
|
+
/** Explicit limb joint labels or a proven body root's bone; never inferred for arbitrary seams. */
|
|
4346
|
+
joints?: string[];
|
|
4344
4347
|
/** Why the pair is not read: a slot `rig.json` does not have. */
|
|
4345
4348
|
unread?: string;
|
|
4346
4349
|
/** Issue #686: segment-zero provenance and its immediate named body carrier. */
|
|
@@ -4374,6 +4377,8 @@ export interface MotionSeamSelection {
|
|
|
4374
4377
|
export interface MotionSeamRow {
|
|
4375
4378
|
pair: string;
|
|
4376
4379
|
animation: string;
|
|
4380
|
+
/** Report-only core connectivity, independent of raw counts and verdicts. */
|
|
4381
|
+
joint_contact?: JointContactReading;
|
|
4377
4382
|
/** rigc's: `skip` when no frame has a band to read — nothing measured, not a seam that held. */
|
|
4378
4383
|
status: 'read' | 'skip';
|
|
4379
4384
|
/** Frames both slots draw, and of those the frames read (rigc's `drawn`, `banded`). */
|
|
@@ -4426,10 +4431,12 @@ export function motionSeamPairs(parts: PartsFile, seams: ReadonlyArray<{ name: s
|
|
|
4426
4431
|
export function motionSeamSelection(parts: PartsFile, seams: ReadonlyArray<{ name: string; part: string; neighbour: string }>, rig?: Record<string, unknown>, readSetup?: (candidates: readonly MotionSeamPair[]) => readonly MotionRootSetupRow[]): MotionSeamSelection {
|
|
4427
4432
|
const out: MotionSeamPair[] = [];
|
|
4428
4433
|
const unmeasuredRoots: MotionUnmeasuredRoot[] = [];
|
|
4429
|
-
const add = (a: string, b: string, declared: string): void => {
|
|
4434
|
+
const add = (a: string, b: string, declared: string, joint?: string): void => {
|
|
4430
4435
|
const hit = out.find((p) => (p.a === a && p.b === b) || (p.a === b && p.b === a));
|
|
4431
|
-
if (hit !== undefined)
|
|
4432
|
-
|
|
4436
|
+
if (hit !== undefined) {
|
|
4437
|
+
hit.declared.push(declared);
|
|
4438
|
+
if (joint !== undefined && !hit.joints?.includes(joint)) (hit.joints ??= []).push(joint);
|
|
4439
|
+
} else out.push({ pair: `${a}|${b}`, a, b, declared: [declared], ...(joint === undefined ? {} : { joints: [joint] }) });
|
|
4433
4440
|
};
|
|
4434
4441
|
const limbs: string[] = [];
|
|
4435
4442
|
for (const p of parts.parts) if (p.limb !== undefined && !limbs.includes(p.limb.name)) limbs.push(p.limb.name);
|
|
@@ -4437,7 +4444,7 @@ export function motionSeamSelection(parts: PartsFile, seams: ReadonlyArray<{ nam
|
|
|
4437
4444
|
const segs = parts.parts.filter((p) => p.limb?.name === limb).sort((x, y) => (x.limb as LimbRecord).segment - (y.limb as LimbRecord).segment);
|
|
4438
4445
|
for (let i = 1; i < segs.length; i++) {
|
|
4439
4446
|
const lower = segs[i].limb as LimbRecord;
|
|
4440
|
-
add(segs[i - 1].name, segs[i].name, `config.limbs.${limb} segment ${lower.segment}${lower.joint === undefined ? '' : `, joint "${lower.joint}"`}
|
|
4447
|
+
add(segs[i - 1].name, segs[i].name, `config.limbs.${limb} segment ${lower.segment}${lower.joint === undefined ? '' : `, joint "${lower.joint}"`}`, lower.joint);
|
|
4441
4448
|
}
|
|
4442
4449
|
}
|
|
4443
4450
|
for (const r of seams) add(r.part, r.neighbour, `requirement "${r.name}" (seam)`);
|
|
@@ -4473,7 +4480,7 @@ export function motionSeamSelection(parts: PartsFile, seams: ReadonlyArray<{ nam
|
|
|
4473
4480
|
}
|
|
4474
4481
|
}
|
|
4475
4482
|
for (const root of selected) {
|
|
4476
|
-
add(root.a, root.b, root.declared[0]);
|
|
4483
|
+
add(root.a, root.b, root.declared[0], root.root?.bone);
|
|
4477
4484
|
const pair = out.find(p => (p.a === root.a && p.b === root.b) || (p.a === root.b && p.b === root.a));
|
|
4478
4485
|
if (pair !== undefined) pair.root = root.root;
|
|
4479
4486
|
}
|
|
@@ -4578,7 +4585,7 @@ function motionRootCandidates(parts: PartsFile, rig: Record<string, unknown>): M
|
|
|
4578
4585
|
}
|
|
4579
4586
|
for (const { part: body } of eligible) out.push({ pair: body.name + '|' + root.name, a: body.name, b: root.name,
|
|
4580
4587
|
declared: ['config.limbs.' + limb + ' segment 0, body carrier "' + parent + '" from ' + body.from],
|
|
4581
|
-
root: { limb, bone: l.bone, parent } });
|
|
4588
|
+
joints: [l.bone], root: { limb, bone: l.bone, parent } });
|
|
4582
4589
|
}
|
|
4583
4590
|
refuseIfAny(problems);
|
|
4584
4591
|
return out;
|
|
@@ -4923,11 +4930,14 @@ function readSeamRows(dir: string, animations: readonly string[], fps: number, p
|
|
|
4923
4930
|
}
|
|
4924
4931
|
}
|
|
4925
4932
|
}
|
|
4933
|
+
const contact = setup ? undefined : jointContactReading(p.jointContact, want.joints ?? [], set.written ?? set.sampled, at, problems);
|
|
4934
|
+
if (contact === null) return;
|
|
4926
4935
|
worldRows?.push({ pair: want.pair, animation, status: p.status, drawn: p.drawn, banded: p.banded, gap: kinds.gap, ghost: kinds.ghost });
|
|
4927
4936
|
const opens = [...over.keys()].sort((x, y) => x - y);
|
|
4928
4937
|
rows.push({
|
|
4929
4938
|
pair: want.pair,
|
|
4930
4939
|
animation,
|
|
4940
|
+
...(setup ? {} : { joint_contact: contact }),
|
|
4931
4941
|
status: p.status,
|
|
4932
4942
|
drawn: p.drawn,
|
|
4933
4943
|
banded: p.banded,
|
|
@@ -5081,6 +5091,21 @@ export function enrichMotionSeamPoses(dir: string, rig: Record<string, unknown>,
|
|
|
5081
5091
|
}
|
|
5082
5092
|
}
|
|
5083
5093
|
|
|
5094
|
+
/** One informational line per observed pair/animation; missing readings are named SKIP. */
|
|
5095
|
+
export function motionJointContactLines(line: MotionSeamLine): string[] {
|
|
5096
|
+
if (line.rows.length === 0) return ['JOINT_CONTACT: SKIP — ' + (line.reason ?? 'no sampled pair/animation reading')];
|
|
5097
|
+
const lines = line.rows.map(row => {
|
|
5098
|
+
const c = row.joint_contact;
|
|
5099
|
+
const head = 'JOINT_CONTACT: "' + row.pair + '" in "' + row.animation + '"';
|
|
5100
|
+
if (c === undefined || c.status === 'SKIP') return head + ': SKIP — ' + (c?.reason ?? 'no contact observation');
|
|
5101
|
+
return head + ', joint ' + (c.joints.length === 0 ? 'unavailable' : c.joints.map(n => JSON.stringify(n)).join(', ')) +
|
|
5102
|
+
': joined ' + c.joined + ', separated ' + c.separated + ', unavailable ' + c.unavailable + ' frame(s)' +
|
|
5103
|
+
(c.reasons.length === 0 ? '' : '; ' + c.reasons.map(r => r.reason + (r.frames === undefined ? '' : ' on frames ' + frameRuns(r.frames))).join('; ')) + '; report only';
|
|
5104
|
+
});
|
|
5105
|
+
for (const pair of line.pairs) if (!line.rows.some(row => row.pair === pair.pair)) lines.push('JOINT_CONTACT: "' + pair.pair + '": SKIP — ' + (pair.unread ?? line.reason ?? 'no sampled animation'));
|
|
5106
|
+
return lines;
|
|
5107
|
+
}
|
|
5108
|
+
|
|
5084
5109
|
/** One warning as the console prints it (issue #493's form, the floor's clause after it). */
|
|
5085
5110
|
export function motionSeamWarnText(r: MotionSeamRow): string {
|
|
5086
5111
|
const pose = r.pose_at_worst;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** Report core's material connectivity; no count here participates in a bar. */
|
|
2
|
+
import { PartsError, type Problem } from './errors.ts';
|
|
3
|
+
|
|
4
|
+
export interface JointContactReading {
|
|
5
|
+
status: 'REPORTED' | 'SKIP';
|
|
6
|
+
joints: string[];
|
|
7
|
+
joined?: number;
|
|
8
|
+
separated?: number;
|
|
9
|
+
unavailable?: number;
|
|
10
|
+
reasons: Array<{ reason: string; frames?: number[] }>;
|
|
11
|
+
reason?: string;
|
|
12
|
+
}
|
|
13
|
+
const record = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
14
|
+
|
|
15
|
+
/** Count only producer observations; invalid input adds a problem and yields no reading when a collector is supplied. */
|
|
16
|
+
export function jointContactReading(raw: unknown, joints: readonly string[], sampled: unknown, object: string, problems?: Problem[]): JointContactReading | null {
|
|
17
|
+
const names = [...new Set(joints)];
|
|
18
|
+
const fail = (detail: string): null => {
|
|
19
|
+
const problem = { code: 'FRAMES_SIDECAR', object, detail: 'jointContact: ' + detail };
|
|
20
|
+
if (problems === undefined) throw new PartsError([problem]);
|
|
21
|
+
problems.push(problem);
|
|
22
|
+
return null;
|
|
23
|
+
};
|
|
24
|
+
const knownCount = typeof sampled === 'number' && Number.isSafeInteger(sampled) && sampled >= 0 ? sampled : undefined;
|
|
25
|
+
if (raw === undefined) {
|
|
26
|
+
const reason = names.length === 0 ? 'pair has no declared named joint' : 'core seam reading has no jointContact.frames';
|
|
27
|
+
return knownCount === undefined || knownCount === 0
|
|
28
|
+
? { status: 'SKIP', joints: names, reasons: [], reason: knownCount === 0 ? 'no sampled frames' : 'sample count unavailable: ' + reason }
|
|
29
|
+
: { status: 'REPORTED', joints: names, joined: 0, separated: 0, unavailable: knownCount, reasons: [{ reason }] };
|
|
30
|
+
}
|
|
31
|
+
if (!record(raw) || !Array.isArray(raw.frames)) return fail('requires { frames: [...] }');
|
|
32
|
+
if (knownCount !== undefined && raw.frames.length !== knownCount) return fail('has ' + raw.frames.length + ' frame(s); frames.json records ' + knownCount);
|
|
33
|
+
const frames: Array<{ index: number; time: number; status: 'joined' | 'separated' | 'unavailable'; reason: string | null }> = [];
|
|
34
|
+
for (const f of raw.frames) {
|
|
35
|
+
if (!record(f) || typeof f.index !== 'number' || !Number.isSafeInteger(f.index) || f.index < 0 ||
|
|
36
|
+
typeof f.time !== 'number' || !Number.isFinite(f.time) || f.time < 0 ||
|
|
37
|
+
(f.status !== 'joined' && f.status !== 'separated' && f.status !== 'unavailable') ||
|
|
38
|
+
(f.status === 'unavailable' ? typeof f.reason !== 'string' || f.reason.trim() === '' : f.reason !== null)) return fail('requires nonnegative index/time, joined/separated with null reason, or unavailable with a nonempty reason');
|
|
39
|
+
const previous = frames.at(-1);
|
|
40
|
+
if (previous !== undefined && (f.index <= previous.index || f.time <= previous.time)) return fail('frame indices and times must increase strictly');
|
|
41
|
+
frames.push({ index: f.index, time: f.time, status: f.status, reason: f.reason as string | null });
|
|
42
|
+
}
|
|
43
|
+
if (frames.length === 0) return { status: 'SKIP', joints: names, reasons: [], reason: 'no sampled frames' };
|
|
44
|
+
const out: JointContactReading = { status: 'REPORTED', joints: names, joined: 0, separated: 0, unavailable: 0, reasons: [] };
|
|
45
|
+
for (const f of frames) {
|
|
46
|
+
const status = names.length === 0 ? 'unavailable' : f.status;
|
|
47
|
+
const reason = names.length === 0 ? 'pair has no declared named joint' : f.reason;
|
|
48
|
+
if (status === 'joined') out.joined = (out.joined ?? 0) + 1;
|
|
49
|
+
else if (status === 'separated') out.separated = (out.separated ?? 0) + 1;
|
|
50
|
+
else {
|
|
51
|
+
out.unavailable = (out.unavailable ?? 0) + 1;
|
|
52
|
+
const why = reason as string;
|
|
53
|
+
let hit = out.reasons.find(r => r.reason === why);
|
|
54
|
+
if (hit === undefined) { hit = { reason: why, frames: [] }; out.reasons.push(hit); }
|
|
55
|
+
hit.frames?.push(f.index);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|