@rig-c/parts 1.28.0 → 1.29.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/limbs.ts CHANGED
@@ -92,8 +92,19 @@
92
92
  * cuff; any 4-connected island on a forearm piece that leaves its own hand's
93
93
  * keypoint region is transferred to the other forearm piece, so no slivers
94
94
  * are cut out of the front hand for the back arm.
95
+ * - **Crossed limbs** (issue #446, {@link limbCrossing},
96
+ * {@link assignCrossedLimbs}). When two limbs share a part and cross in an X,
97
+ * the covering limb may declare `split` (`line` or `edge: "painting"`):
98
+ * under `line`, rays from the covering limb's bone polyline split pixels at
99
+ * the declared polyline (and its matching half-width on the opposite side
100
+ * when one edge is given); under `edge: "painting"`, each ray from the
101
+ * covering limb's bone polyline walks outward through the covering limb's
102
+ * interior fill and stops at the outer edge of its dark stroke (`luma <=` the
103
+ * part's Otsu split) or transparent silhouette. Without `split`,
104
+ * {@link nearestLimb} stays as it was and `propose`'s `LINT` names the
105
+ * crossing.
95
106
  */
96
- import type { BoneEntry, Limb } from './config.ts';
107
+ import type { BoneEntry, Limb, LimbSplit } from './config.ts';
97
108
 
98
109
  export type Pt = [number, number];
99
110
 
@@ -600,3 +611,212 @@ export function assignOverlappingHands(back: ForearmShare, front: ForearmShare,
600
611
  }
601
612
  }
602
613
 
614
+ /**
615
+ * Issue #446: the crossing point `[x, y]` (rig px) where two limb polylines `a`
616
+ * and `b` cross in an X, or `null` when they do not cross. Two polylines cross
617
+ * when a segment of `a` and a segment of `b` intersect in their interiors
618
+ * (`0 < ta < 1` and `0 < tb < 1`), or meet at an interior joint vertex while
619
+ * the two polylines' start-to-end chords cross in their interiors.
620
+ */
621
+ export function limbCrossing(a: readonly Pt[], b: readonly Pt[]): Pt | null {
622
+ if (a.length < 2 || b.length < 2) return null;
623
+ const segHit = (p0: Pt, p1: Pt, q0: Pt, q1: Pt): { at: Pt; ta: number; tb: number } | null => {
624
+ const dax = p1[0] - p0[0];
625
+ const day = p1[1] - p0[1];
626
+ const dbx = q1[0] - q0[0];
627
+ const dby = q1[1] - q0[1];
628
+ const denom = dax * dby - day * dbx;
629
+ if (Math.abs(denom) <= 1e-9) return null;
630
+ const rx = q0[0] - p0[0];
631
+ const ry = q0[1] - p0[1];
632
+ const ta = (rx * dby - ry * dbx) / denom;
633
+ const tb = (rx * day - ry * dax) / denom;
634
+ if (ta < -1e-6 || ta > 1 + 1e-6 || tb < -1e-6 || tb > 1 + 1e-6) return null;
635
+ return { at: [p0[0] + ta * dax, p0[1] + ta * day], ta, tb };
636
+ };
637
+ const chordHit = segHit(a[0], a[a.length - 1], b[0], b[b.length - 1]);
638
+ if (chordHit === null || chordHit.ta <= 1e-6 || chordHit.ta >= 1 - 1e-6 || chordHit.tb <= 1e-6 || chordHit.tb >= 1 - 1e-6) return null;
639
+ for (let i = 0; i + 1 < a.length; i++) {
640
+ for (let j = 0; j + 1 < b.length; j++) {
641
+ const hit = segHit(a[i], a[i + 1], b[j], b[j + 1]);
642
+ if (hit !== null) return hit.at;
643
+ }
644
+ }
645
+ return chordHit.at;
646
+ }
647
+
648
+ /** The point on polyline `pts` nearest `(x, y)`. */
649
+ function closestOnPolyline(pts: readonly Pt[], x: number, y: number): Pt {
650
+ if (pts.length === 1) return [pts[0][0], pts[0][1]];
651
+ let bestD = Infinity;
652
+ let best: Pt = [pts[0][0], pts[0][1]];
653
+ for (let i = 0; i + 1 < pts.length; i++) {
654
+ const [ax, ay] = pts[i];
655
+ const dx = pts[i + 1][0] - ax;
656
+ const dy = pts[i + 1][1] - ay;
657
+ const len2 = dx * dx + dy * dy;
658
+ const t = len2 === 0 ? 0 : Math.max(0, Math.min(1, ((x - ax) * dx + (y - ay) * dy) / len2));
659
+ const qx = ax + t * dx;
660
+ const qy = ay + t * dy;
661
+ const d = (x - qx) ** 2 + (y - qy) ** 2;
662
+ if (d < bestD) {
663
+ bestD = d;
664
+ best = [qx, qy];
665
+ }
666
+ }
667
+ return best;
668
+ }
669
+
670
+ /**
671
+ * Issue #446: assign pixels of a shared part `r` between the covering limb
672
+ * (`coverIdx`, polyline `coverPoly`, declaring `split`) and the covered limb(s)
673
+ * (`underIndices`, polylines `underPolys`). Mutates `own` in place.
674
+ */
675
+ export function assignCrossedLimbs(
676
+ coverIdx: number,
677
+ coverPoly: readonly Pt[],
678
+ split: LimbSplit,
679
+ underIndices: readonly number[],
680
+ underPolys: ReadonlyArray<readonly Pt[]>,
681
+ r: { width: number; height: number; data: ArrayLike<number> },
682
+ opaqueAlphaAbove: number,
683
+ own: Int32Array,
684
+ ): void {
685
+ if (underIndices.length === 0 || coverPoly.length === 0) return;
686
+ const W = r.width;
687
+ const H = r.height;
688
+ const nearestOwn = new Int32Array(own);
689
+ const underOf = (x: number, y: number): number => underIndices[underIndices.length === 1 ? 0 : nearestLimb(underPolys, x, y)];
690
+
691
+ if (split.line !== undefined) {
692
+ const line = split.line;
693
+ for (let y = 0; y < H; y++) {
694
+ for (let x = 0; x < W; x++) {
695
+ const p = y * W + x;
696
+ if (r.data[p * 4 + 3] === 0) continue;
697
+ const px = x + 0.5;
698
+ const py = y + 0.5;
699
+ const c = closestOnPolyline(coverPoly, px, py);
700
+ const dx = px - c[0];
701
+ const dy = py - c[1];
702
+ const dP = Math.sqrt(dx * dx + dy * dy);
703
+ if (dP <= 1e-9) {
704
+ own[p] = coverIdx;
705
+ continue;
706
+ }
707
+ const ux = dx / dP;
708
+ const uy = dy / dP;
709
+ let hitPos: number | null = null;
710
+ let hitNeg: number | null = null;
711
+ for (let j = 0; j + 1 < line.length; j++) {
712
+ const sx = line[j + 1][0] - line[j][0];
713
+ const sy = line[j + 1][1] - line[j][1];
714
+ const denom = ux * sy - uy * sx;
715
+ if (Math.abs(denom) <= 1e-9) continue;
716
+ const rx = line[j][0] - c[0];
717
+ const ry = line[j][1] - c[1];
718
+ const s = (rx * sy - ry * sx) / denom;
719
+ const v = (rx * uy - ry * ux) / denom;
720
+ if (v < -1e-6 || v > 1 + 1e-6) continue;
721
+ if (s > 1e-6 && (hitPos === null || s < hitPos)) hitPos = s;
722
+ else if (s < -1e-6 && (hitNeg === null || -s < hitNeg)) hitNeg = -s;
723
+ }
724
+ if (hitPos !== null) {
725
+ own[p] = dP <= hitPos + 1e-9 ? coverIdx : underOf(x, y);
726
+ } else if (hitNeg !== null) {
727
+ own[p] = dP <= hitNeg + 1e-9 ? coverIdx : underOf(x, y);
728
+ }
729
+ }
730
+ }
731
+ return;
732
+ }
733
+
734
+ const hist = new Array<number>(256).fill(0);
735
+ for (let p = 0; p < W * H; p++) {
736
+ if (r.data[p * 4 + 3] > opaqueAlphaAbove) {
737
+ hist[luma601(r.data[p * 4], r.data[p * 4 + 1], r.data[p * 4 + 2])]++;
738
+ }
739
+ }
740
+ const strokeLumaMax = otsuDarkMax(hist);
741
+
742
+ const hasGapAlong = (c: Pt, px: number, py: number, dP: number): boolean => {
743
+ const steps = Math.max(1, Math.ceil(dP * 2));
744
+ const dx = px - c[0];
745
+ const dy = py - c[1];
746
+ for (let s = 0; s <= steps; s++) {
747
+ const sx = Math.min(W - 1, Math.max(0, Math.floor(c[0] + (dx * s) / steps)));
748
+ const sy = Math.min(H - 1, Math.max(0, Math.floor(c[1] + (dy * s) / steps)));
749
+ if (r.data[(sy * W + sx) * 4 + 3] <= opaqueAlphaAbove) return true;
750
+ }
751
+ return false;
752
+ };
753
+
754
+ let maxUncrossedHalfWidth = 0;
755
+ let maxFallbackHalfWidth = 0;
756
+ for (let y = 0; y < H; y++) {
757
+ for (let x = 0; x < W; x++) {
758
+ const p = y * W + x;
759
+ if (r.data[p * 4 + 3] <= opaqueAlphaAbove || nearestOwn[p] !== coverIdx) continue;
760
+ const px = x + 0.5;
761
+ const py = y + 0.5;
762
+ const c = closestOnPolyline(coverPoly, px, py);
763
+ const dP = Math.sqrt((px - c[0]) ** 2 + (py - c[1]) ** 2);
764
+ if (hasGapAlong(c, px, py, dP)) continue;
765
+ if (dP > maxFallbackHalfWidth) maxFallbackHalfWidth = dP;
766
+ const dUnder = Math.sqrt(Math.min(...underPolys.map((up) => polylineDist2(up, px, py))));
767
+ if (dUnder > dP + 1.5 && dP > maxUncrossedHalfWidth) maxUncrossedHalfWidth = dP;
768
+ }
769
+ }
770
+ if (maxUncrossedHalfWidth === 0) maxUncrossedHalfWidth = maxFallbackHalfWidth;
771
+
772
+ for (let y = 0; y < H; y++) {
773
+ for (let x = 0; x < W; x++) {
774
+ const p = y * W + x;
775
+ if (r.data[p * 4 + 3] === 0) continue;
776
+ const px = x + 0.5;
777
+ const py = y + 0.5;
778
+ const c = closestOnPolyline(coverPoly, px, py);
779
+ const dx = px - c[0];
780
+ const dy = py - c[1];
781
+ const dP = Math.sqrt(dx * dx + dy * dy);
782
+ if (dP <= 0.5) {
783
+ own[p] = coverIdx;
784
+ continue;
785
+ }
786
+ const steps = Math.max(1, Math.ceil(dP * 2));
787
+ let hitGap = false;
788
+ let seenFill = false;
789
+ let inStroke = false;
790
+ let exitedStroke = false;
791
+ for (let s = 0; s <= steps; s++) {
792
+ const sx = Math.min(W - 1, Math.max(0, Math.floor(c[0] + (dx * s) / steps)));
793
+ const sy = Math.min(H - 1, Math.max(0, Math.floor(c[1] + (dy * s) / steps)));
794
+ const sp = sy * W + sx;
795
+ if (r.data[sp * 4 + 3] <= opaqueAlphaAbove) {
796
+ hitGap = true;
797
+ break;
798
+ }
799
+ if (strokeLumaMax !== null) {
800
+ const l = luma601(r.data[sp * 4], r.data[sp * 4 + 1], r.data[sp * 4 + 2]);
801
+ if (l > strokeLumaMax) {
802
+ if (inStroke) {
803
+ exitedStroke = true;
804
+ break;
805
+ }
806
+ seenFill = true;
807
+ } else if (seenFill) {
808
+ inStroke = true;
809
+ }
810
+ }
811
+ }
812
+ if (hitGap) {
813
+ own[p] = nearestOwn[p];
814
+ } else if (strokeLumaMax !== null) {
815
+ own[p] = !exitedStroke && dP <= maxUncrossedHalfWidth + 1e-6 ? coverIdx : underOf(x, y);
816
+ } else {
817
+ own[p] = dP <= maxUncrossedHalfWidth + 1e-6 ? coverIdx : underOf(x, y);
818
+ }
819
+ }
820
+ }
821
+ }
822
+