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 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.85.0",
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.82.0"
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) hit.declared.push(declared);
4432
- else out.push({ pair: `${a}|${b}`, a, b, declared: [declared] });
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
+ }