@rig-c/parts 1.23.0 → 1.25.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/cloth.ts CHANGED
@@ -48,6 +48,9 @@
48
48
  * hip, by the skirt's own rule, in place of the robe chains the topwear rule
49
49
  * drafts; its art above the hip stays on the chest. The proposer never picks
50
50
  * such a part by itself: it names a candidate, and the author declares it.
51
+ * A `bottomwear` part lying on that part's tree links (a hem returned as a
52
+ * layer of its own) rides them, as a layer on a tail does ({@link tailOverlap}),
53
+ * and gets no trees of its own (issue #416): one skirt, one set of trees.
51
54
  * - **Swing** (`bottomwear` only). `swing.scales` declares one shared pendulum:
52
55
  * a bone `<prefix>swing` under the attachment at the centre of the root row,
53
56
  * one link long, with physics; each tree's first link takes the swing's
@@ -62,7 +65,9 @@
62
65
  * Pure: no clock, no randomness; every value is a function of its inputs.
63
66
  */
64
67
  import type { ConfigConstraint, Point } from './config.ts';
68
+ import type { Mask } from './raster/types.ts';
65
69
  import { pyRound } from './round.ts';
70
+ import { segmentDistance } from './weights.ts';
66
71
 
67
72
  /** The tags a `cloth` recipe may name: the parts the proposer drafts chains on that hang. */
68
73
  export const CLOTH_TARGETS = ['bottomwear', 'back hair', 'front hair'] as const;
@@ -79,7 +84,7 @@ export interface ClothStrength {
79
84
  export interface ClothRecipe {
80
85
  chains: number;
81
86
  links: number;
82
- split?: { at: number; into: number };
87
+ split?: { at: number; into: number; hold_tip?: boolean };
83
88
  length_ratio?: number;
84
89
  strength?: ClothStrength;
85
90
  damping?: number;
@@ -255,7 +260,7 @@ export function draftTrees(input: TreeInput, r: ClothRecipe): TreeChain[] {
255
260
  depths: [],
256
261
  last: [],
257
262
  };
258
- const edge = outerSide(k, b);
263
+ const edge = r.split?.hold_tip === true ? false : outerSide(k, b);
259
264
  for (let j = trunkLinks; j < r.links; j++) {
260
265
  ch.points.push(j === trunkLinks ? trunk.tip : pt(rows[j], fb));
261
266
  ch.why.push(j === trunkLinks ? `the fork: the trunk ${trunk.chain}'s tip` : `${rowRule(j)}, ${pyRound(fb, 6)} of the way across ${input.frame} in the 24-row band there (branch ${b} of ${into} of tree ${k})`);
@@ -381,10 +386,74 @@ export function clothNote(part: string, tag: ClothTarget, trees: readonly TreeCh
381
386
  const defaults: string[] = [];
382
387
  if (r.length_ratio === undefined) defaults.push(`length_ratio ${RECIPE_LENGTH_RATIO}`);
383
388
  if (r.strength === undefined) defaults.push(`strength ${RECIPE_STRENGTH.trunk}/${RECIPE_STRENGTH.branch}/${RECIPE_STRENGTH.edge}`);
384
- const shape = r.split === undefined ? `${r.links} link(s) each` : `a ${r.split.at}-link trunk forking into ${r.split.into} branches, ${r.links} links root to tip`;
389
+ const shape = r.split === undefined ? `${r.links} link(s) each` : `a ${r.split.at}-link trunk forking into ${r.split.into} branches${r.split.hold_tip === true ? ' (tip held)' : ''}, ${r.links} links root to tip`;
385
390
  return (
386
391
  `${part} (${via === undefined ? tag : `${via}, which config.cloth."${tag}".part names`}): config.cloth."${tag}" -> ${r.chains} tree(s)${extra}, ${shape} (${trees.map((t) => t.chain).join(', ')}); ` +
387
392
  `${constraints} constraint(s), physics on every link, wind ${r.wind[0]} -> ${r.wind[1]} and gravity ${r.gravity[0]} -> ${r.gravity[1]} root to tip (multipliers of the consumer's environment; no direction is drafted), no idle key` +
388
393
  (defaults.length === 0 ? '' : `; the recipe's own defaults, measured on its synthetic fixture (AUTHORING §3): ${defaults.join(', ')}`)
389
394
  );
390
395
  }
396
+
397
+ /**
398
+ * Issue #391: a part wrapped round a tail the recipe drafted rides that tail's links. One span of the tail: a segment
399
+ * of the back hair's mesh as the recipe drafts it — the head's fixed segment, or one tree link from its origin to the
400
+ * next link (the last to the chain's tip), named by the link's bone.
401
+ */
402
+ export interface TailSpan {
403
+ bone: string;
404
+ a: Point;
405
+ b: Point;
406
+ /** Whether the span is a tree link (false: the head's fixed segment). */
407
+ link: boolean;
408
+ }
409
+
410
+ /** A tail the recipe drafted: the back hair part, its opaque mask on the rig canvas, and its mesh's spans in segment order. */
411
+ export interface Tail {
412
+ part: string;
413
+ mask: Mask;
414
+ spans: TailSpan[];
415
+ }
416
+
417
+ /** Where a part's opaque pixels lie against a drafted tail. */
418
+ export interface TailOverlap {
419
+ /** Per span, in the tail's order: the part's pixels over the tail's art whose nearest span it is. */
420
+ counts: number[];
421
+ /** The part's pixels over the tail's art (the sum of `counts`). */
422
+ over: number;
423
+ /** The part's opaque pixels in all. */
424
+ total: number;
425
+ }
426
+
427
+ /**
428
+ * Which span of the tail each of the part's pixels over the tail's art belongs to: every pixel where both masks are
429
+ * set is given to the span nearest it — the distance the weights read (`segmentDistance`, `src/weights.ts`), so a
430
+ * span here is the art the tail's own weights give mostly to that bone — the first in the tail's order on a tie. No
431
+ * threshold: a span the part touches by one pixel is one the part overlaps, and the counts are said in the note.
432
+ * Pure; the part's mask and the tail's are on the same canvas.
433
+ */
434
+ export function tailOverlap(tail: Tail, part: Mask, box: { x: number; y: number; w: number; h: number }): TailOverlap {
435
+ const counts = tail.spans.map(() => 0);
436
+ let over = 0;
437
+ let total = 0;
438
+ const W = part.width;
439
+ for (let y = box.y; y < box.y + box.h; y++) {
440
+ for (let x = box.x; x < box.x + box.w; x++) {
441
+ const i = y * W + x;
442
+ if (part.data[i] === 0) continue;
443
+ total++;
444
+ if (tail.mask.data[i] === 0) continue;
445
+ over++;
446
+ let best = 0;
447
+ let bd = Infinity;
448
+ tail.spans.forEach((s, k) => {
449
+ const d = segmentDistance([x, y], s.a, s.b);
450
+ if (d < bd) {
451
+ bd = d;
452
+ best = k;
453
+ }
454
+ });
455
+ counts[best]++;
456
+ }
457
+ }
458
+ return { counts, over, total };
459
+ }
package/src/config.ts CHANGED
@@ -86,7 +86,7 @@
86
86
  */
87
87
  import { existsSync, readFileSync, statSync } from 'node:fs';
88
88
  import { dirname, resolve } from 'node:path';
89
- import { RIG_BONE_INHERIT, RIG_SKIN_CONSTRAINT_KEYS, type RigBoneInherit, type RigConstraint, type RigSkinConstraintKey } from 'rig-c/src/rig.ts';
89
+ import { RIG_BONE_INHERIT, RIG_SKIN_CONSTRAINT_KEYS, type RigBoneInherit, type RigConstraint, type RigSkinConstraintKey, type RigStop } from 'rig-c/src/rig.ts';
90
90
  import { pyRound } from './round.ts';
91
91
  import { CLOTH_TARGETS, type ClothRecipes, RECIPE_STRENGTH, RIGC_DEFAULT_DAMPING, springStable } from './cloth.ts';
92
92
  import { GRID, MAX_SIDE } from './contour.ts';
@@ -648,6 +648,26 @@ export interface ConfigConstraint {
648
648
  [field: string]: unknown;
649
649
  }
650
650
 
651
+ /**
652
+ * One entry of `config.stops` (issue #404): rig-c's `RigStop` (`rig-c` 2.56.0),
653
+ * a cloth chain's rotation clamped against a leg bone so the skirt cannot swing
654
+ * into the leg. `chain` names a chain declared in `config.bones`; `links` is
655
+ * the stopped links along it (when omitted, every link of `chain` in link
656
+ * order); `against` is the leg bone the point rides on; `at` is `[x, y]` in
657
+ * `against`'s local space; `inward` and `outward` are the first link's clamp
658
+ * bounds in degrees (positive away from `against`), and `lower` the lower
659
+ * links' bounds when more than one link is stopped.
660
+ */
661
+ export interface ConfigStop {
662
+ chain: string;
663
+ links?: string[];
664
+ against: string;
665
+ at: Point;
666
+ inward: number;
667
+ outward: number;
668
+ lower?: { inward: number; outward: number };
669
+ }
670
+
651
671
  /**
652
672
  * One segment of a limb (issue #349): the part it makes, the part whose
653
673
  * pixels it takes (`from`: a plan part or a cut's piece), and the one bone it
@@ -697,6 +717,8 @@ export interface CharacterConfig {
697
717
  regions: Record<string, string>;
698
718
  motion: Motion;
699
719
  constraints?: ConfigConstraint[];
720
+ /** Leg stops (issue #404, rig-c 2.56.0's `RigStop`): absent, no stop is added and no byte of any output moves. */
721
+ stops?: ConfigStop[];
700
722
  /** The turn control (issue #251, `src/turn.ts`): absent, no control is added and no byte of any output moves. */
701
723
  turn?: TurnSpec;
702
724
  /** The painting is a scene (issue #242): absent, no byte of any output moves. */
@@ -760,6 +782,23 @@ export function constraintForRig(c: ConfigConstraint): Record<string, unknown> {
760
782
  return out;
761
783
  }
762
784
 
785
+ /**
786
+ * A stop as the rig stage hands it to rigc (issue #404): its fields in
787
+ * `RigStop` order, `links` expanded from `chain` when omitted, and any records
788
+ * (`x-…`) or annotations (`note`, `…_note`) left out.
789
+ */
790
+ export function stopForRig(s: ConfigStop, links: readonly string[]): RigStop {
791
+ return {
792
+ chain: s.chain,
793
+ links: [...links],
794
+ against: s.against,
795
+ at: [s.at[0], s.at[1]],
796
+ inward: s.inward,
797
+ outward: s.outward,
798
+ ...(s.lower === undefined ? {} : { lower: { inward: s.lower.inward, outward: s.lower.outward } }),
799
+ };
800
+ }
801
+
763
802
  /** The single-value bone properties a sine track may drive — rigc's one-channel bone timelines. */
764
803
  export const SINGLE_PROPS = ['rotate', 'translatex', 'translatey', 'scalex', 'scaley', 'shearx', 'sheary'] as const;
765
804
  export type SingleProp = (typeof SINGLE_PROPS)[number];
@@ -992,7 +1031,7 @@ export interface AssembleConfig {
992
1031
  bones?: BoneEntry[];
993
1032
  }
994
1033
 
995
- const TOP_KEYS = ['key', 'generation', 'seethrough', 'assemble', 'limbs', 'bones', 'meshes', 'regions', 'motion', 'constraints', 'turn', 'scene', 'cloth'] as const;
1034
+ const TOP_KEYS = ['key', 'generation', 'seethrough', 'assemble', 'limbs', 'bones', 'meshes', 'regions', 'motion', 'constraints', 'stops', 'turn', 'scene', 'cloth'] as const;
996
1035
 
997
1036
  /**
998
1037
  * The one partial entry point. It validates exactly what its door requires —
@@ -1202,6 +1241,7 @@ function parseConfigFrom(raw: Json, base: string | null): { config: CharacterCon
1202
1241
  if ('meshes' in t && 'regions' in t && 'assemble' in t) checkCoverage(c, [...parts, ...cuts], patches, t.meshes, t.regions, limbs);
1203
1242
  if ('motion' in t) checkMotion(c, t.motion, names.bones, names.chains, 'regions' in t ? t.regions : undefined);
1204
1243
  if ('constraints' in t) checkConstraints(c, t.constraints, names.bones, names.parents);
1244
+ if ('stops' in t) checkStops(c, t.stops, names.bones, names.chains);
1205
1245
  if ('turn' in t) checkTurn(c, t.turn, attachedParts(t.meshes, t.regions), names.bones);
1206
1246
  if ('scene' in t) checkScene(c, t.scene);
1207
1247
  if ('cloth' in t) checkCloth(c, t.cloth);
@@ -1374,9 +1414,9 @@ export interface SkeletonSections {
1374
1414
  * both before writing (`checkProposal`). A key the loader would not take is
1375
1415
  * refused by name, as in a config. Refusals name the fields `proposal.…`.
1376
1416
  */
1377
- export function parseProposalSections(raw: Json): SkeletonSections & { limbs?: Record<string, Limb>; constraints?: ConfigConstraint[] } {
1417
+ export function parseProposalSections(raw: Json): SkeletonSections & { limbs?: Record<string, Limb>; constraints?: ConfigConstraint[]; stops?: ConfigStop[] } {
1378
1418
  const c = new Check();
1379
- const top = c.object('config', raw, ['bones', 'meshes', 'regions', 'motion'], ['notes', 'limbs', 'constraints']);
1419
+ const top = c.object('config', raw, ['bones', 'meshes', 'regions', 'motion'], ['notes', 'limbs', 'constraints', 'stops']);
1380
1420
  if (top !== null) {
1381
1421
  const names = 'bones' in top ? checkBones(c, top.bones) : { bones: new Set<string>([ROOT_BONE]), chains: new Map<string, number>(), parents: new Map<string, string>() };
1382
1422
  if ('limbs' in top) {
@@ -1389,10 +1429,11 @@ export function parseProposalSections(raw: Json): SkeletonSections & { limbs?: R
1389
1429
  if ('regions' in top) checkRegions(c, top.regions, names.bones);
1390
1430
  if ('motion' in top) checkMotion(c, top.motion, names.bones, names.chains, 'regions' in top ? top.regions : undefined);
1391
1431
  if ('constraints' in top) checkConstraints(c, top.constraints, names.bones, names.parents);
1432
+ if ('stops' in top) checkStops(c, top.stops, names.bones, names.chains);
1392
1433
  if ('notes' in top && c.array('config.notes', top.notes, false)) (top.notes as Json[]).forEach((n, i) => c.string(`config.notes[${i}]`, n, false));
1393
1434
  }
1394
1435
  refuseIfAny(c.problems.map((p) => ({ ...p, object: p.object.replace(/^config\b/, 'proposal') })));
1395
- return raw as SkeletonSections & { limbs?: Record<string, Limb>; constraints?: ConfigConstraint[] };
1436
+ return raw as SkeletonSections & { limbs?: Record<string, Limb>; constraints?: ConfigConstraint[]; stops?: ConfigStop[] };
1396
1437
  }
1397
1438
 
1398
1439
  /**
@@ -2614,6 +2655,65 @@ function checkConstraints(c: Check, v: Json, bones: Set<string>, parents: Map<st
2614
2655
  });
2615
2656
  }
2616
2657
 
2658
+ /**
2659
+ * `config.stops` (issue #404): rig-c 2.56.0's `RigStop` entries, each holding
2660
+ * a swinging chain off a leg bone (`against`). `chain` resolves against the
2661
+ * chains `config.bones` declares; `links` (when given) and `against` resolve
2662
+ * against the bones `config.bones` declares (`CONFIG_NAME_RESOLVES`).
2663
+ */
2664
+ function checkStops(c: Check, v: Json, bones: Set<string>, chains: Map<string, number>): void {
2665
+ const p = 'config.stops';
2666
+ if (!c.array(p, v, false)) return;
2667
+ const declaredBones = [...bones].join(', ');
2668
+ const declaredChains = [...chains.keys()].join(', ') || 'none';
2669
+ (v as Json[]).forEach((entry, i) => {
2670
+ const at = `${p}[${i}]`;
2671
+ const o = c.object(at, entry, ['chain', 'against', 'at', 'inward', 'outward'], ['links', 'lower']);
2672
+ if (o === null) return;
2673
+ let chainLinks: number | undefined;
2674
+ if ('chain' in o && c.string(`${at}.chain`, o.chain)) {
2675
+ chainLinks = chains.get(o.chain);
2676
+ if (chainLinks === undefined) {
2677
+ c.fail('CONFIG_NAME_RESOLVES', `${at}.chain`, `names "${o.chain}", which config.bones does not declare as a chain; the chains that exist: ${declaredChains}`);
2678
+ }
2679
+ }
2680
+ let linkCount = chainLinks ?? 0;
2681
+ if ('links' in o && c.array(`${at}.links`, o.links, true)) {
2682
+ const list = o.links as Json[];
2683
+ linkCount = list.length;
2684
+ list.forEach((l, k) => {
2685
+ if (c.string(`${at}.links[${k}]`, l) && !bones.has(l)) {
2686
+ c.fail('CONFIG_NAME_RESOLVES', `${at}.links[${k}]`, `names "${l}", which config.bones does not declare; the bones that exist: ${declaredBones}`);
2687
+ }
2688
+ });
2689
+ }
2690
+ if ('against' in o && c.string(`${at}.against`, o.against) && !bones.has(o.against)) {
2691
+ c.fail('CONFIG_NAME_RESOLVES', `${at}.against`, `names "${o.against}", which config.bones does not declare; the bones that exist: ${declaredBones}`);
2692
+ }
2693
+ if ('at' in o) c.point(`${at}.at`, o.at);
2694
+ const inward = 'inward' in o && c.number(`${at}.inward`, o.inward) ? (o.inward as number) : null;
2695
+ const outward = 'outward' in o && c.number(`${at}.outward`, o.outward) ? (o.outward as number) : null;
2696
+ if (inward !== null && outward !== null && inward > outward) {
2697
+ c.fail('CONFIG_FIELD_TYPE', `${at}.inward`, `is ${inward}, above outward ${outward}; inward <= outward is required`);
2698
+ }
2699
+ if ('lower' in o) {
2700
+ const lo = c.object(`${at}.lower`, o.lower, ['inward', 'outward'], []);
2701
+ if (lo !== null) {
2702
+ const li = 'inward' in lo && c.number(`${at}.lower.inward`, lo.inward) ? (lo.inward as number) : null;
2703
+ const loOut = 'outward' in lo && c.number(`${at}.lower.outward`, lo.outward) ? (lo.outward as number) : null;
2704
+ if (li !== null && loOut !== null && li > loOut) {
2705
+ c.fail('CONFIG_FIELD_TYPE', `${at}.lower.inward`, `is ${li}, above lower.outward ${loOut}; inward <= outward is required`);
2706
+ }
2707
+ }
2708
+ if (linkCount === 1) {
2709
+ c.fail('CONFIG_FIELD_TYPE', `${at}.lower`, `is ${show(o.lower)} on a 1-link stop; lower holds the links below the first, so leave lower out when links has 1 bone`);
2710
+ }
2711
+ } else if (linkCount > 1) {
2712
+ c.fail('CONFIG_FIELD_PRESENT', `${at}.lower`, `is absent and required when the stop holds ${linkCount} links (more than 1); state lower: { inward, outward } for the links below the first`);
2713
+ }
2714
+ });
2715
+ }
2716
+
2617
2717
  /** The parts `meshes` and `regions` attach, read off whatever they hold; `turn.depths` may name only these. */
2618
2718
  function attachedParts(meshes: Json, regions: Json): Set<string> {
2619
2719
  const out = new Set<string>();
@@ -2700,10 +2800,11 @@ function checkCloth(c: Check, v: Json): void {
2700
2800
  const chains = 'chains' in r && c.int(`${path}.chains`, r.chains, 1) ? (r.chains as number) : null;
2701
2801
  const links = 'links' in r && c.int(`${path}.links`, r.links, 1) ? (r.links as number) : null;
2702
2802
  if ('split' in r) {
2703
- const sp = c.object(`${path}.split`, r.split, ['at', 'into'], []);
2803
+ const sp = c.object(`${path}.split`, r.split, ['at', 'into'], ['hold_tip']);
2704
2804
  if (sp !== null) {
2705
2805
  const sAt = 'at' in sp && c.int(`${path}.split.at`, sp.at, 1) ? (sp.at as number) : null;
2706
2806
  if ('into' in sp) c.int(`${path}.split.into`, sp.into, 2);
2807
+ if ('hold_tip' in sp && typeof sp.hold_tip !== 'boolean') c.fail('CONFIG_FIELD_TYPE', `${path}.split.hold_tip`, `is ${show(sp.hold_tip)}; true or false is required`);
2707
2808
  if (sAt !== null && links !== null && sAt >= links) {
2708
2809
  c.fail('CONFIG_CLOTH_SPLIT_BEFORE_TIP', `${path}.split.at`, `is ${sAt} and links is ${links}; the trunk's ${sAt} link(s) would leave no link for the branches — split.at below links is required`);
2709
2810
  }
@@ -137,6 +137,38 @@ export function heatField(frames: readonly Raster[]): Uint8Array {
137
137
  return d;
138
138
  }
139
139
 
140
+ /**
141
+ * The breath alone (issue #411): each pixel's largest per-channel RGB change
142
+ * that the breath makes, read off the idle rendered twice on one grid — with
143
+ * its breath tracks (`withBreath`) and without them (`withoutBreath`). At
144
+ * frame t the breath's own change at a pixel is (with_t - with_0) -
145
+ * (without_t - without_0): motion that is the same in both renders (a keyed
146
+ * cloth swing, a physics response to the world, a head turn) cancels, and so
147
+ * does a constant offset the breath holds from frame 0 on, while physics that
148
+ * the breath sets moving stays. The field is that change's largest magnitude
149
+ * over the frames and the three channels, capped at 255 — {@link heatField}'s
150
+ * reading when the render without the breath does not move. Both lists hold
151
+ * the same number of frames of one size; the caller holds the grid.
152
+ */
153
+ export function breathHeat(withBreath: readonly Raster[], withoutBreath: readonly Raster[]): Uint8Array {
154
+ const w0 = withBreath[0].data;
155
+ const o0 = withoutBreath[0].data;
156
+ const n = withBreath[0].width * withBreath[0].height;
157
+ const d = new Uint8Array(n);
158
+ withBreath.forEach((f, i) => {
159
+ const w = f.data;
160
+ const o = withoutBreath[i].data;
161
+ for (let p = 0; p < n; p++) {
162
+ for (let c = 0; c < 3; c++) {
163
+ const k = p * 4 + c;
164
+ const v = Math.min(255, Math.abs(w[k] - w0[k] - (o[k] - o0[k])));
165
+ if (v > d[p]) d[p] = v;
166
+ }
167
+ }
168
+ });
169
+ return d;
170
+ }
171
+
140
172
  export interface RegionHeat {
141
173
  /** Pixels measured. */
142
174
  px: number;
package/src/limbs.ts CHANGED
@@ -81,6 +81,17 @@
81
81
  * cap's disc and the limb's width stay where the bone's origin puts them;
82
82
  * only the line that splits the pixels between the two pieces moves, and
83
83
  * the cap takes the disc's pixels on the upper side of that line.
84
+ * - **Overlapping hands** (issue #400, {@link assignOverlappingHands}). When
85
+ * two forearm segments (`joint: "elbow"`, with a wrist end) share a part,
86
+ * each hand's keypoint region is the disc of radius half its forearm's
87
+ * length (`L / 2`) about its wrist, and its cuff is the line across the
88
+ * forearm at `L - L / 2 = L / 2` from the elbow, where that disc meets the
89
+ * bone's axis. When the two hand discs overlap, the overlapping hand region
90
+ * goes to the front piece (the limb `config.limbs` declares later, which
91
+ * `limbOrder` draws in front) in one piece, and the back piece ends at its
92
+ * cuff; any 4-connected island on a forearm piece that leaves its own hand's
93
+ * keypoint region is transferred to the other forearm piece, so no slivers
94
+ * are cut out of the front hand for the back arm.
84
95
  */
85
96
  import type { BoneEntry, Limb } from './config.ts';
86
97
 
@@ -429,3 +440,163 @@ export function capBox(at: Pt, radius: number): [number, number, number, number]
429
440
  export function inDisc(at: Pt, radius: number, px: number, py: number): boolean {
430
441
  return (px + 0.5 - at[0]) ** 2 + (py + 0.5 - at[1]) ** 2 <= radius * radius;
431
442
  }
443
+
444
+ /** A forearm segment on a shared part, for {@link assignOverlappingHands} (issue #400). */
445
+ export interface ForearmShare {
446
+ /** The limb's index in `Object.keys(limbs)`. */
447
+ limb: number;
448
+ /** The forearm segment's index in `limbs[limb].segments`. */
449
+ seg: number;
450
+ /** The elbow bone origin, in rig px. */
451
+ elbow: Pt;
452
+ /** The wrist bone end (`tip`), in rig px. */
453
+ wrist: Pt;
454
+ /** Whether pixel `(x, y)` on `limb` falls on this forearm segment (`segmentOf === seg`). */
455
+ isSeg: (x: number, y: number) => boolean;
456
+ }
457
+
458
+ /**
459
+ * Issue #400: when two forearm segments (`back` and `front`, in `config.limbs`
460
+ * order so `front` is drawn over `back`) share a part, each hand's keypoint
461
+ * region is the disc of radius `r = L / 2` about its wrist (`L` the forearm
462
+ * bone's length), and `back`'s cuff is the line across the forearm at
463
+ * `L - r = L / 2` from `back.elbow`. When the two wrist discs overlap, `back`'s
464
+ * pixels past its cuff (or inside `front`'s hand disc) go to `front`'s forearm
465
+ * in one piece so `back` ends at its cuff; and any 4-connected island on a
466
+ * forearm piece that leaves its own hand's keypoint region goes to the other
467
+ * forearm piece. Mutates `own` and `segOverride` in place.
468
+ */
469
+ export function assignOverlappingHands(back: ForearmShare, front: ForearmShare, W: number, H: number, own: Int32Array, segOverride: Int32Array): void {
470
+ const dx0 = back.wrist[0] - back.elbow[0];
471
+ const dy0 = back.wrist[1] - back.elbow[1];
472
+ const L0 = Math.sqrt(dx0 * dx0 + dy0 * dy0);
473
+ const dx1 = front.wrist[0] - front.elbow[0];
474
+ const dy1 = front.wrist[1] - front.elbow[1];
475
+ const L1 = Math.sqrt(dx1 * dx1 + dy1 * dy1);
476
+ if (L0 === 0 || L1 === 0) return;
477
+ const r0 = L0 / 2;
478
+ const r1 = L1 / 2;
479
+ const u0x = dx0 / L0;
480
+ const u0y = dy0 / L0;
481
+ const cuff0 = L0 - r0;
482
+ const proj0 = (x: number, y: number): number => (x + 0.5 - back.elbow[0]) * u0x + (y + 0.5 - back.elbow[1]) * u0y;
483
+ const onSeg = (f: ForearmShare, p: number): boolean => own[p] === f.limb && (segOverride[p] === f.seg || (segOverride[p] === -1 && f.isSeg(p % W, Math.floor(p / W))));
484
+ const wristsOverlap = (back.wrist[0] - front.wrist[0]) ** 2 + (back.wrist[1] - front.wrist[1]) ** 2 <= (r0 + r1) ** 2;
485
+ if (wristsOverlap) {
486
+ let cuffHasArt = false;
487
+ for (let y = 0; y < H && !cuffHasArt; y++) {
488
+ for (let x = 0; x < W && !cuffHasArt; x++) {
489
+ if (onSeg(back, y * W + x) && proj0(x, y) <= cuff0) cuffHasArt = true;
490
+ }
491
+ }
492
+ if (cuffHasArt) {
493
+ for (let y = 0; y < H; y++) {
494
+ for (let x = 0; x < W; x++) {
495
+ const p = y * W + x;
496
+ if (onSeg(back, p) && (proj0(x, y) > cuff0 || inDisc(front.wrist, r1, x, y)) && !inDisc(back.elbow, r0 / 2, x, y)) {
497
+ own[p] = front.limb;
498
+ segOverride[p] = front.seg;
499
+ } else if (own[p] === back.limb && inDisc(front.wrist, r1, x, y) && !inDisc(back.elbow, r0, x, y)) {
500
+ own[p] = front.limb;
501
+ segOverride[p] = front.seg;
502
+ }
503
+ }
504
+ }
505
+ }
506
+ }
507
+ const componentsOf = (f: ForearmShare): number[][] => {
508
+ const seen = new Uint8Array(W * H);
509
+ const comps: number[][] = [];
510
+ for (let p = 0; p < W * H; p++) {
511
+ if (seen[p] === 1 || !onSeg(f, p)) continue;
512
+ seen[p] = 1;
513
+ const comp: number[] = [p];
514
+ for (let h = 0; h < comp.length; h++) {
515
+ const q = comp[h];
516
+ const x = q % W;
517
+ const y = (q - x) / W;
518
+ for (const [nx, ny] of [
519
+ [x - 1, y],
520
+ [x + 1, y],
521
+ [x, y - 1],
522
+ [x, y + 1],
523
+ ]) {
524
+ if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
525
+ const np = ny * W + nx;
526
+ if (seen[np] === 0 && onSeg(f, np)) {
527
+ seen[np] = 1;
528
+ comp.push(np);
529
+ }
530
+ }
531
+ }
532
+ comps.push(comp);
533
+ }
534
+ return comps;
535
+ };
536
+ const elbowDist2 = (comp: readonly number[], elbow: Pt): number => {
537
+ let best = Infinity;
538
+ for (const p of comp) {
539
+ const x = p % W;
540
+ const y = (p - x) / W;
541
+ const d = (x + 0.5 - elbow[0]) ** 2 + (y + 0.5 - elbow[1]) ** 2;
542
+ if (d < best) best = d;
543
+ }
544
+ return best;
545
+ };
546
+ const backComps = componentsOf(back);
547
+ if (backComps.length > 1) {
548
+ let main = 0;
549
+ let mainD = elbowDist2(backComps[0], back.elbow);
550
+ for (let i = 1; i < backComps.length; i++) {
551
+ const d = elbowDist2(backComps[i], back.elbow);
552
+ if (d < mainD) {
553
+ mainD = d;
554
+ main = i;
555
+ }
556
+ }
557
+ backComps.forEach((comp, i) => {
558
+ if (i === main) return;
559
+ const leavesBack = comp.some((p) => !inDisc(back.wrist, r0, p % W, Math.floor(p / W)));
560
+ const inFront = comp.some((p) => inDisc(front.wrist, r1, p % W, Math.floor(p / W)));
561
+ if (wristsOverlap || leavesBack || inFront) {
562
+ for (const p of comp) {
563
+ own[p] = front.limb;
564
+ segOverride[p] = front.seg;
565
+ }
566
+ }
567
+ });
568
+ }
569
+ const frontComps = componentsOf(front);
570
+ if (frontComps.length > 1) {
571
+ let main = 0;
572
+ let mainD = elbowDist2(frontComps[0], front.elbow);
573
+ for (let i = 1; i < frontComps.length; i++) {
574
+ const d = elbowDist2(frontComps[i], front.elbow);
575
+ if (d < mainD) {
576
+ mainD = d;
577
+ main = i;
578
+ }
579
+ }
580
+ frontComps.forEach((comp, i) => {
581
+ if (i === main) return;
582
+ const leavesFront = comp.some((p) => !inDisc(front.wrist, r1, p % W, Math.floor(p / W)));
583
+ const touchesBack = comp.some((p) => {
584
+ const x = p % W;
585
+ const y = (p - x) / W;
586
+ return (
587
+ (x > 0 && onSeg(back, p - 1)) ||
588
+ (x + 1 < W && onSeg(back, p + 1)) ||
589
+ (y > 0 && onSeg(back, p - W)) ||
590
+ (y + 1 < H && onSeg(back, p + W))
591
+ );
592
+ });
593
+ if (leavesFront && touchesBack) {
594
+ for (const p of comp) {
595
+ own[p] = back.limb;
596
+ segOverride[p] = back.seg;
597
+ }
598
+ }
599
+ });
600
+ }
601
+ }
602
+