@rig-c/parts 1.38.0 → 1.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/check.ts CHANGED
@@ -1864,6 +1864,8 @@ export interface CheckFigures {
1864
1864
  /** Written only where parts.json holds a limb record with a `range` (issue #360): {@link SweepLine}. */
1865
1865
  [SWEEP_LINE]?: SweepLine;
1866
1866
  [MOTION_SEAM_LINE]?: MotionSeamLine;
1867
+ /** Written only when the rig declares a physics constraint and has an idle (issue #501): {@link ClothLine}, a warning, never a bar. */
1868
+ [CLOTH_LINE]?: ClothLine;
1867
1869
  RECOMPOSITE_HOLES: ReportedLine;
1868
1870
  /** Written only under `--source` (issue #77): the setup pose against the painting, REPORTED. */
1869
1871
  SETUP_POSE_VS_SOURCE?: ReportedLine;
@@ -2128,6 +2130,8 @@ export interface CheckReport {
2128
2130
  besideIdle: string[];
2129
2131
  /** `SEAM_OVER_MOTION` (issue #493): a warning, never a bar — in `check.json` only when a pair is declared; its SKIP is printed either way. */
2130
2132
  motionSeam: MotionSeamLine;
2133
+ /** `CLOTH_SETTLES` (issue #501): a warning, never a bar — in `check.json` only when the rig declares a physics constraint and has an idle; its SKIP is printed either way. */
2134
+ cloth: ClothLine;
2131
2135
  }
2132
2136
 
2133
2137
  /**
@@ -3400,6 +3404,165 @@ export function motionSeamWarnText(r: MotionSeamRow): string {
3400
3404
  return `WARN seam "${r.pair}" opens on ${r.opens.length} frame(s) ${frameRuns(r.opens)} max ${r.max_px} px in "${r.animation}" — over the one-cell floor${r.under_floor.length === 0 ? '' : `, ${r.under_floor.length} more frame(s) one cell thick not reported`}`;
3401
3405
  }
3402
3406
 
3407
+ // ---------------------------------------------------------------------------
3408
+ // cloth that comes to rest on the idle (issue #501; the line is {@link CLOTH_LINE}'s)
3409
+ // ---------------------------------------------------------------------------
3410
+
3411
+ /**
3412
+ * `CLOTH_SETTLES` (issue #501): every physics chain read by its tip on the
3413
+ * idle as `check` renders it (physics on, rigc's default environment) — a
3414
+ * **warning, never a bar**. The owner's ruling: cloth at rest while the
3415
+ * character stands can be intended, so the line names the chain, the time its
3416
+ * tip comes to rest and the floor, and moves neither the exit status nor
3417
+ * `check: PASS`; the summary counts it apart from the bars.
3418
+ *
3419
+ * Why it exists: `CHAIN_LAG` reads the idle's keyed chains off `motion.json`
3420
+ * and `TIP_OVER_ROOT` the idle's posed parts, and neither reads what a
3421
+ * physics constraint does with a chain nothing above it moves. A skirt hung
3422
+ * from `hip` under an idle that keys only the breath is pushed by rigc's
3423
+ * reset at frame 0 and then comes to rest, and every bar passes.
3424
+ *
3425
+ * A chain is a physics leaf — a bone carrying a physics constraint (rig.json's
3426
+ * `constraints` or motion.json's `physics`) with no such bone under it — and
3427
+ * the physics bones above it, as far as the run of them goes. Its tip is the
3428
+ * leaf's end (origin plus `length` along its world x axis), or its origin when
3429
+ * the leaf has no length. Its speed between two frames is the tip's distance
3430
+ * over their time; it **settles** at the first frame from which every speed
3431
+ * to the idle's last frame is under {@link CLOTH_SETTLE_FLOOR}, and the line
3432
+ * warns when one does — that is, before the loop's length is out.
3433
+ */
3434
+ export const CLOTH_LINE = 'CLOTH_SETTLES';
3435
+
3436
+ /**
3437
+ * The floor, rig px per second — half the slowest tip a keyed bone drives on
3438
+ * the public examples, the repository's floor rule (half the weaker figure).
3439
+ * Measured for issue #501 with this port's build of each example (rig-c
3440
+ * 2.60.0, `check`'s idle at 12 fps, physics on): over the 31 physics chains an
3441
+ * idle key drives — a keyed bone at or above the leaf (`demo` 12,
3442
+ * `reference-sample` 1, `scarf` 11, `wind` 7) — the slowest frame-to-frame tip
3443
+ * speed is `scarf`'s `skirt_l2`, 0.03388; the ten no key drives (`wind`'s
3444
+ * skirt, its eight trees and its swing, and `reference-sample`'s `hip_jiggle`)
3445
+ * fall under half of it, from 0 s (`hip_jiggle`, which never moves) to 2.75 s
3446
+ * on a 3.333 s idle, and end at 0.0025 rig px/s or less. `reference-demo`
3447
+ * declares no physics constraint.
3448
+ */
3449
+ export const CLOTH_SETTLE_FLOOR = 0.01694;
3450
+
3451
+ /** The floor as the console and `check.json` state it. */
3452
+ export const CLOTH_FLOOR_TEXT = `a chain settles at the first frame from which its tip moves slower than ${CLOTH_SETTLE_FLOOR} rig px/s to the idle's last frame — half the slowest tip an idle key drives on the public examples (scarf's skirt_l2, 0.03388 rig px/s)`;
3453
+
3454
+ /** The consumer's two levers, as the console and `check.json` state them. */
3455
+ export const CLOTH_LEVERS_TEXT = "a chain at rest moves again when the world or the body does: an environment at render (rigc render --environment, wind and gravity in time; AUTHORING §3, What moves them, has a worked schedule) or a consumer's animation that moves the bone it hangs from; the drafted idle keys no hip (rig-c/parts#502)";
3456
+
3457
+ /** One physics chain as the line reads it. */
3458
+ export interface ClothRow {
3459
+ /** The leaf: the physics bone with no physics bone under it. */
3460
+ tip: string;
3461
+ /** The topmost physics bone of the run the leaf ends. */
3462
+ root: string;
3463
+ /** Physics bones from root to tip. */
3464
+ links: number;
3465
+ /** What was tracked: the leaf's end, or its origin when it has no `length`. */
3466
+ point: 'end' | 'origin';
3467
+ /** The time of the frame from which the tip stays under the floor; null when it never does. */
3468
+ settles_at: number | null;
3469
+ /** The fastest and slowest frame-to-frame speeds of the point, rig px/s, four significant figures. */
3470
+ max_rig_px_per_s: number;
3471
+ min_rig_px_per_s: number;
3472
+ }
3473
+
3474
+ /** The line: `WARN` with a chain that settles, `CLEAR` with none, `SKIP` with no chain or no idle. */
3475
+ export interface ClothLine {
3476
+ status: 'WARN' | 'CLEAR' | 'SKIP';
3477
+ reason?: string;
3478
+ fps: number;
3479
+ frames: number;
3480
+ duration: number;
3481
+ floor_rig_px_per_s: number;
3482
+ floor: string;
3483
+ levers: string;
3484
+ /** Rows that settle: the WARN lines. */
3485
+ warnings: number;
3486
+ rows: ClothRow[];
3487
+ }
3488
+
3489
+ /**
3490
+ * The physics chains of a rig, leaf by leaf in rig.json's bone order: each
3491
+ * bone a physics constraint names with no such bone under it, and how far up
3492
+ * the run of physics bones above it goes.
3493
+ */
3494
+ export function clothChains(rig: Record<string, unknown>, physics: readonly LoopPhysics[]): Array<{ tip: string; root: string; links: number; length: number }> {
3495
+ const bones = (Array.isArray(rig.bones) ? rig.bones : []).filter(isRecord);
3496
+ const parent = new Map(bones.map((b) => [String(b.name), typeof b.parent === 'string' ? b.parent : null]));
3497
+ const length = new Map(bones.map((b) => [String(b.name), typeof b.length === 'number' && Number.isFinite(b.length) ? b.length : 0]));
3498
+ const held = new Set(physics.map((p) => p.bone));
3499
+ const above = (b: string, a: string): boolean => {
3500
+ const seen = new Set<string>();
3501
+ for (let p = parent.get(b) ?? null; p !== null && !seen.has(p); p = parent.get(p) ?? null) {
3502
+ if (p === a) return true;
3503
+ seen.add(p);
3504
+ }
3505
+ return false;
3506
+ };
3507
+ const out: Array<{ tip: string; root: string; links: number; length: number }> = [];
3508
+ for (const b of bones.map((x) => String(x.name))) {
3509
+ if (!held.has(b) || [...held].some((o) => o !== b && above(o, b))) continue;
3510
+ let root = b;
3511
+ let links = 1;
3512
+ for (let p = parent.get(b) ?? null; p !== null && held.has(p); p = parent.get(p) ?? null) {
3513
+ root = p;
3514
+ links++;
3515
+ }
3516
+ out.push({ tip: b, root, links, length: length.get(b) ?? 0 });
3517
+ }
3518
+ return out;
3519
+ }
3520
+
3521
+ /** One chain's row off its leaf's bone track: the point, the speeds, and when it settles under the floor. */
3522
+ export function clothRow(chain: { tip: string; root: string; links: number; length: number }, track: BoneTrack, floor: number = CLOTH_SETTLE_FLOOR): ClothRow {
3523
+ const L = chain.length;
3524
+ const pts = track.frames.map((w) => [w.worldX + L * w.a, w.worldY + L * w.c]);
3525
+ const speeds: number[] = [];
3526
+ for (let i = 1; i < pts.length; i++) {
3527
+ const dt = track.times[i] - track.times[i - 1];
3528
+ speeds.push(dt > 0 ? edgeLength(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]) / dt : Infinity);
3529
+ }
3530
+ let s = speeds.length;
3531
+ while (s > 0 && speeds[s - 1] < floor) s--;
3532
+ return {
3533
+ tip: chain.tip,
3534
+ root: chain.root,
3535
+ links: chain.links,
3536
+ point: L > 0 ? 'end' : 'origin',
3537
+ settles_at: speeds.length > 0 && s < speeds.length ? round3(track.times[s]) : null,
3538
+ max_rig_px_per_s: speeds.length === 0 ? 0 : sig4(Math.max(...speeds)),
3539
+ min_rig_px_per_s: speeds.length === 0 ? 0 : sig4(Math.min(...speeds)),
3540
+ };
3541
+ }
3542
+
3543
+ /** The line from its rows: WARN when a chain settles, CLEAR when none does, SKIP with the reason when nothing was read. */
3544
+ export function clothLine(rows: ClothRow[], fps: number, frames: number, duration: number, reason?: string): ClothLine {
3545
+ const warnings = rows.filter((r) => r.settles_at !== null).length;
3546
+ return {
3547
+ status: reason !== undefined ? 'SKIP' : warnings > 0 ? 'WARN' : 'CLEAR',
3548
+ ...(reason === undefined ? {} : { reason }),
3549
+ fps,
3550
+ frames,
3551
+ duration: round3(duration),
3552
+ floor_rig_px_per_s: CLOTH_SETTLE_FLOOR,
3553
+ floor: CLOTH_FLOOR_TEXT,
3554
+ levers: CLOTH_LEVERS_TEXT,
3555
+ warnings,
3556
+ rows,
3557
+ };
3558
+ }
3559
+
3560
+ /** One warning as the console prints it. */
3561
+ export function clothWarnText(r: ClothRow, duration: number): string {
3562
+ const chain = r.links === 1 ? `"${r.tip}"` : `"${r.root}" .. "${r.tip}"`;
3563
+ return `WARN chain ${chain} (${r.links} physics link(s)) settles at ${r.settles_at ?? ''} s of the ${round3(duration)} s idle — its tip's ${r.point} under ${CLOTH_SETTLE_FLOOR} rig px/s from there to the last frame (fastest on the idle ${r.max_rig_px_per_s} rig px/s)`;
3564
+ }
3565
+
3403
3566
  /**
3404
3567
  * One `CHECK_RIGC_GREEN` per line that says why `rigc <what>` was not green:
3405
3568
  * its FAIL and compile-error lines, or, when there are none, {@link causeLines}.
@@ -3711,11 +3874,13 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
3711
3874
  let chain: JudgementLine = skip(inp.noIdle ?? '');
3712
3875
  let stretch: JudgementLine = skip(inp.noIdle ?? '');
3713
3876
  let still: JudgementLine = skip(lacking);
3877
+ let idleGeometry: IdleGeometry | null = null;
3714
3878
  if (idle !== null) {
3715
3879
  chain = chainLine(inp, problems, reqFile === null ? NO_CHAIN_RESTS : { path: reqFile.path, rests: reqFile.rests });
3716
3880
  // One reader for the one geometry file: the stretch reads its meshes, the still regions every slot and the head bone.
3717
3881
  const geometryPath = join(idle.dir, GEOMETRY_FILE);
3718
3882
  const geometry = readGeometry(geometryPath, idle.written);
3883
+ idleGeometry = geometry;
3719
3884
  stretch = stretchLine(geometry, geometryPath, problems, inp);
3720
3885
  if (full !== null) still = stillLine(full, idle, geometry, geometryPath, problems, notPlanted);
3721
3886
  }
@@ -3847,6 +4012,26 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
3847
4012
  }
3848
4013
  }
3849
4014
 
4015
+ // 3f. every physics chain read by its tip on the idle as rendered above, physics on (issue #501): a warning, never a
4016
+ // bar; SKIP, named, with no idle or no physics constraint
4017
+ let cloth: ClothLine;
4018
+ if (idle === null) cloth = clothLine([], IDLE_FPS, 0, 0, inp.noIdle ?? 'no idle');
4019
+ else if (stripped === null) cloth = clothLine([], idle.fps, idle.frames.length, idle.duration, "the rig declares no physics constraint (rig.json's constraints, motion.json's physics), so there is no chain to read");
4020
+ else {
4021
+ const geometryPath = join(idle.dir, GEOMETRY_FILE);
4022
+ const rows: ClothRow[] = [];
4023
+ let unread: string | undefined;
4024
+ for (const c of clothChains(inp.rig, stripped.physics)) {
4025
+ const track = boneTrackOf(idleGeometry, geometryPath, idle, c.tip);
4026
+ if (typeof track === 'string') {
4027
+ unread = track;
4028
+ break;
4029
+ }
4030
+ rows.push(clothRow(c, track));
4031
+ }
4032
+ cloth = unread === undefined ? clothLine(rows, idle.fps, idle.frames.length, idle.duration) : clothLine([], idle.fps, idle.frames.length, idle.duration, unread);
4033
+ }
4034
+
3850
4035
  // 4. the setup pose as a one-key throwaway animation — the seam reads it against the parts, the source line against
3851
4036
  // the painting — and, beside it, the same pose with the eyes shut
3852
4037
  let seam: SeamFigures | null = null;
@@ -4027,6 +4212,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
4027
4212
  ...(limbs === null ? {} : { [LIMB_LINE]: limbs }),
4028
4213
  ...(sweep === null ? {} : { [SWEEP_LINE]: sweep }),
4029
4214
  ...(motionSeam.pairs.length === 0 ? {} : { [MOTION_SEAM_LINE]: motionSeam }),
4215
+ ...(stripped === null || idle === null ? {} : { [CLOTH_LINE]: cloth }),
4030
4216
  RECOMPOSITE_HOLES: inp.parts === null ? { status: 'SKIP', reason: inp.noParts ?? '' } : holesLine(inp.parts),
4031
4217
  ...(sourceRead === null ? {} : { [SOURCE_LINE]: sourceRead }),
4032
4218
  ...(Object.keys(skipped).length === 0 ? {} : { skipped }),
@@ -4111,6 +4297,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
4111
4297
  requirements: reqBlock,
4112
4298
  besideIdle: named,
4113
4299
  motionSeam,
4300
+ cloth,
4114
4301
  };
4115
4302
  }
4116
4303
 
package/src/meshdraft.ts CHANGED
@@ -575,6 +575,45 @@ export function splicedConfig(text: string, drafted: ReadonlyMap<string, unknown
575
575
  return out;
576
576
  }
577
577
 
578
+ /** A path in a config the loader resolves against the config's directory, and how to set it. */
579
+ export interface RelativeConfigPath {
580
+ at: string;
581
+ value: string;
582
+ set: (v: string) => void;
583
+ }
584
+
585
+ /**
586
+ * Each path in `o` (a parsed config) the loader resolves against the config's directory — `motion.animations_from`,
587
+ * `generation.control.skeleton` — where it is relative and names a file there.
588
+ */
589
+ export function relativeConfigPaths(configDir: string, o: Json): RelativeConfigPath[] {
590
+ const out: RelativeConfigPath[] = [];
591
+ const motion = o.motion as Json | undefined;
592
+ if (motion !== undefined && typeof motion.animations_from === 'string' && !isAbsolute(motion.animations_from) && existsSync(resolve(configDir, motion.animations_from))) {
593
+ out.push({ at: 'motion.animations_from', value: motion.animations_from, set: (v) => (motion.animations_from = v) });
594
+ }
595
+ const control = (o.generation as Json | undefined)?.control as Json | undefined;
596
+ if (control !== undefined && typeof control.skeleton === 'string' && !isAbsolute(control.skeleton) && existsSync(resolve(configDir, control.skeleton))) {
597
+ out.push({ at: 'generation.control.skeleton', value: control.skeleton, set: (v) => (control.skeleton = v) });
598
+ }
599
+ return out;
600
+ }
601
+
602
+ /**
603
+ * A config the stage builds, written as `<dir>/config.json`: `text` (the author's config at `configPath`) with
604
+ * `meshes.<part>` replaced for every part in `meshes`, and each of its {@link relativeConfigPaths} made absolute, so
605
+ * it resolves to the file the author's config names. `remesh` (issue #495) builds through the same door.
606
+ */
607
+ export function scratchConfig(configPath: string, text: string, dir: string, meshes: ReadonlyMap<string, unknown>): string {
608
+ const configDir = dirname(resolve(configPath));
609
+ const o = JSON.parse(text) as Json;
610
+ for (const r of relativeConfigPaths(configDir, o)) r.set(resolve(configDir, r.value));
611
+ o.meshes = { ...(o.meshes as Json), ...Object.fromEntries(meshes) };
612
+ mkdirSync(dir, { recursive: true });
613
+ writeFileSync(join(dir, 'config.json'), `${JSON.stringify(o, null, 2)}\n`);
614
+ return join(dir, 'config.json');
615
+ }
616
+
578
617
  // ---------------------------------------------------------------------------
579
618
  // the stage
580
619
  // ---------------------------------------------------------------------------
@@ -691,34 +730,7 @@ export function meshDraftStage(input: MeshDraftInput, rigc: RigcRunner, checkBin
691
730
  }
692
731
  log(`rig-parts mesh-draft: ${cfg.key}, figure ${rules.height} px (rows ${rules.top}..${rules.bottom}): tolerance and margin T = max(1, round(${rules.height} / ${TOLERANCE_FIGURE_DIVISOR})) = ${rules.tolerance}, spacing and longest outline edge S = round(${rules.height} / ${FIGURE_GRID_DIVISOR}) = ${rules.spacing}`);
693
732
 
694
- // The configs the stage builds are written in the scratch directory: each path the loader resolves against the
695
- // config's directory (motion.animations_from, generation.control.skeleton) is made absolute where it names a file
696
- // there, so it resolves to the file the author's config names.
697
- const configDir = dirname(resolve(input.config));
698
- const relativePaths = (o: Json): Array<{ at: string; value: string; set: (v: string) => void }> => {
699
- const out: Array<{ at: string; value: string; set: (v: string) => void }> = [];
700
- const motion = o.motion as Json | undefined;
701
- if (motion !== undefined && typeof motion.animations_from === 'string' && !isAbsolute(motion.animations_from) && existsSync(resolve(configDir, motion.animations_from))) {
702
- out.push({ at: 'motion.animations_from', value: motion.animations_from, set: (v) => (motion.animations_from = v) });
703
- }
704
- const control = (o.generation as Json | undefined)?.control as Json | undefined;
705
- if (control !== undefined && typeof control.skeleton === 'string' && !isAbsolute(control.skeleton) && existsSync(resolve(configDir, control.skeleton))) {
706
- out.push({ at: 'generation.control.skeleton', value: control.skeleton, set: (v) => (control.skeleton = v) });
707
- }
708
- return out;
709
- };
710
- const absolute = (o: Json): Json => {
711
- for (const r of relativePaths(o)) r.set(resolve(configDir, r.value));
712
- return o;
713
- };
714
- const configWith = (name: string, meshes: ReadonlyMap<string, unknown>): string => {
715
- const o = absolute(JSON.parse(text) as Json);
716
- o.meshes = { ...(o.meshes as Json), ...Object.fromEntries(meshes) };
717
- const dir = join(scratch, name);
718
- mkdirSync(dir, { recursive: true });
719
- writeFileSync(join(dir, 'config.json'), `${JSON.stringify(o, null, 2)}\n`);
720
- return join(dir, 'config.json');
721
- };
733
+ const configWith = (name: string, meshes: ReadonlyMap<string, unknown>): string => scratchConfig(input.config, text, join(scratch, name), meshes);
722
734
  function stage(name: string, meshes: ReadonlyMap<string, unknown>, check: false): { rigDir: string } | { refused: Problem[] };
723
735
  function stage(name: string, meshes: ReadonlyMap<string, unknown>, check: true): { rigDir: string; report: CheckReport } | { refused: Problem[] };
724
736
  function stage(name: string, meshes: ReadonlyMap<string, unknown>, check: boolean): { rigDir: string; report?: CheckReport } | { refused: Problem[] } {
@@ -1003,8 +1015,8 @@ export function meshDraftStage(input: MeshDraftInput, rigc: RigcRunner, checkBin
1003
1015
  const nDrafted = rowsOut.filter((r) => r.mode === 'contour').length;
1004
1016
  const tri = (pick: (r: PartRow) => number): number => rowsOut.reduce((a, r) => a + pick(r), 0);
1005
1017
  log(`rig-parts mesh-draft: ${nDrafted} of ${rowsOut.length} lattice part(s) drafted; their triangles ${tri((r) => r.lattice.triangles)} -> ${tri((r) => (r.mode === 'contour' && r.candidate !== null ? r.candidate.triangles : r.lattice.triangles))}, the figure's overdraw over them ${report.figure_overdraw.lattice} -> ${report.figure_overdraw.written}${input.moreTriangles === true ? ` (${MORE_TRIANGLES_FLAG}: triangles reported, not a line)` : ''}${others.length === 0 ? '' : `; ${others.length} part(s) not a lattice, not drafted and not compared: ${others.map((o) => `${o.part} (${o.mode})`).join(', ')}`}; the drafted config's check: ${final.report.figures.PASS ? 'PASS' : 'FAIL'}`);
1006
- if (resolve(configDir) !== resolve(input.out)) {
1007
- for (const r of relativePaths(raw)) log(` note: ${r.at} is "${r.value}", relative to the config's directory; the written config keeps the text, so it resolves against ${input.out} — copy the file beside the written config, or the written config beside it`);
1018
+ if (dirname(resolve(input.config)) !== resolve(input.out)) {
1019
+ for (const r of relativeConfigPaths(dirname(resolve(input.config)), raw)) log(` note: ${r.at} is "${r.value}", relative to the config's directory; the written config keeps the text, so it resolves against ${input.out} — copy the file beside the written config, or the written config beside it`);
1008
1020
  }
1009
1021
  log(`rig-parts mesh-draft: wrote ${written.join(' and ')}`);
1010
1022
  return { rules, rows: rowsOut, notCompared: others, written };