@rig-c/parts 1.27.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/config.ts CHANGED
@@ -93,7 +93,7 @@ import { GRID, MAX_SIDE } from './contour.ts';
93
93
  import { type Problem, refuseIfAny } from './errors.ts';
94
94
  import { isSkeletonName, KEYPOINT_NAMES, type KeypointName, type PosePoints, SKELETON_NAMES, type SkeletonName } from './skeleton.ts';
95
95
  import { acceptedTagNames, AUTHOR_LABEL_RULE, AUTHOR_PREFIX, readLayerTag } from './tags.ts';
96
- import { TURN_BONE, type TurnSpec, turnNearest } from './turn.ts';
96
+ import { FACE_DEFORM_FRACTIONS, FACE_DEFORM_RANGES, type FaceDeformKey, TURN_BONE, type TurnSpec, turnNearest } from './turn.ts';
97
97
  import { FLOOR_FORMS, type FloorForm, type MeshInfluences } from './weights.ts';
98
98
 
99
99
  export type Point = [number, number];
@@ -139,9 +139,29 @@ export interface Generation {
139
139
  control?: Control;
140
140
  }
141
141
 
142
+ export type ArmSplitTag = 'handwear-r' | 'handwear-l';
143
+ export const ARM_SPLIT_TAGS: readonly ArmSplitTag[] = ['handwear-r', 'handwear-l'];
144
+
145
+ /**
146
+ * Issue #439: an optional declaration on `config.seethrough` that separates
147
+ * overlapping arms in the full run into `handwear-r` (smaller image x, the
148
+ * character's right as they face the viewer) and `handwear-l` (larger image x,
149
+ * the character's left) in the draw order `order` states, back to front.
150
+ * Exactly one of `x` (an integer column on the full run's canvas) or `line`
151
+ * (a polyline of at least two `[x, y]` integer points on the full run's
152
+ * canvas) is given.
153
+ */
154
+ export interface ArmSplit {
155
+ x?: number;
156
+ line?: Array<[number, number]>;
157
+ order: ['handwear-r', 'handwear-l'] | ['handwear-l', 'handwear-r'];
158
+ }
159
+
142
160
  export interface SeeThrough {
143
161
  /** Square crop `[x0, y0, x1, y1]` in source pixels for the second, head-only run. */
144
162
  head_box?: [number, number, number, number];
163
+ /** Issue #439: arm separation when both arms overlap in the full run. */
164
+ arm_split?: ArmSplit;
145
165
  resolution: number;
146
166
  steps: number;
147
167
  seed: number;
@@ -701,9 +721,21 @@ export interface LimbSegment {
701
721
  cut_band?: number;
702
722
  }
703
723
 
724
+ /**
725
+ * Issue #446: a per-limb split declared on the covering limb where two limbs
726
+ * share one part and cross: either `line` (a polyline of two or more `[x, y]`
727
+ * points in rig px along the covering limb's seam) or `edge: "painting"`
728
+ * (traces the covering limb's painted stroke or silhouette across the crossing).
729
+ */
730
+ export interface LimbSplit {
731
+ line?: Array<[number, number]>;
732
+ edge?: 'painting';
733
+ }
734
+
704
735
  /** A limb (issue #349): its segments, upper first; each segment's bone is the child of the one before. */
705
736
  export interface Limb {
706
737
  segments: LimbSegment[];
738
+ split?: LimbSplit;
707
739
  }
708
740
 
709
741
  export interface CharacterConfig {
@@ -1571,10 +1603,10 @@ function checkGeneration(c: Check, v: Json): void {
1571
1603
  }
1572
1604
 
1573
1605
  function checkSeeThrough(c: Check, v: Json): void {
1574
- const s = c.object('config.seethrough', v, ['resolution', 'steps', 'seed', 'offload'], ['head_box']);
1606
+ const s = c.object('config.seethrough', v, ['resolution', 'steps', 'seed', 'offload'], ['head_box', 'arm_split']);
1575
1607
  if (s === null) return;
1576
1608
  const p = 'config.seethrough';
1577
- if ('resolution' in s) c.int(`${p}.resolution`, s.resolution, 1);
1609
+ const res = 'resolution' in s && c.int(`${p}.resolution`, s.resolution, 1) ? (s.resolution as number) : null;
1578
1610
  if ('steps' in s) c.int(`${p}.steps`, s.steps, 1);
1579
1611
  if ('seed' in s) c.int(`${p}.seed`, s.seed, 0);
1580
1612
  if ('offload' in s && typeof s.offload !== 'boolean') c.fail('CONFIG_FIELD_TYPE', `${p}.offload`, `is ${show(s.offload)}; true or false is required`);
@@ -1589,6 +1621,55 @@ function checkSeeThrough(c: Check, v: Json): void {
1589
1621
  }
1590
1622
  }
1591
1623
  }
1624
+ if ('arm_split' in s) checkArmSplit(c, `${p}.arm_split`, s.arm_split, res);
1625
+ }
1626
+
1627
+ function checkArmSplit(c: Check, path: string, v: Json, resolution: number | null): void {
1628
+ const a = c.object(path, v, ['order'], ['x', 'line']);
1629
+ if (a === null) return;
1630
+ if (!('x' in a) && !('line' in a)) {
1631
+ c.fail('CONFIG_FIELD_PRESENT', path, 'has neither x nor line; a split column ("x") or a polyline ("line") is required to separate the arms');
1632
+ } else if ('x' in a && 'line' in a) {
1633
+ c.fail('CONFIG_FIELD_TYPE', `${path}.line`, 'is set alongside x; give either a split column ("x") or a polyline ("line"), not both');
1634
+ }
1635
+ if ('x' in a && c.number(`${path}.x`, a.x, 'non-negative')) {
1636
+ const x = a.x as number;
1637
+ if (resolution !== null && x > resolution) {
1638
+ c.fail('CONFIG_FIELD_TYPE', `${path}.x`, `is ${x}; a column inside the ${resolution}x${resolution} run canvas (0 <= x <= ${resolution}) is required`);
1639
+ }
1640
+ }
1641
+ if ('line' in a) {
1642
+ const l = a.line;
1643
+ if (!Array.isArray(l) || l.length < 2) {
1644
+ c.fail('CONFIG_FIELD_TYPE', `${path}.line`, `is ${show(l)}; an array of at least 2 [x, y] points in run pixels is required`);
1645
+ } else {
1646
+ l.forEach((pt, i) => {
1647
+ const ok = Array.isArray(pt) && pt.length === 2 && pt.every((n) => typeof n === 'number' && Number.isFinite(n));
1648
+ if (!ok) {
1649
+ c.fail('CONFIG_FIELD_TYPE', `${path}.line[${i}]`, `is ${show(pt)}; a point [x, y] in run pixels is required`);
1650
+ return;
1651
+ }
1652
+ const [px, py] = pt as [number, number];
1653
+ if (px < 0 || py < 0 || (resolution !== null && (px > resolution || py > resolution))) {
1654
+ c.fail(
1655
+ 'CONFIG_FIELD_TYPE',
1656
+ `${path}.line[${i}]`,
1657
+ `is [${px}, ${py}]; ${resolution !== null ? `a point inside the ${resolution}x${resolution} run canvas (0 <= x, y <= ${resolution})` : 'x >= 0 and y >= 0'} is required`,
1658
+ );
1659
+ }
1660
+ });
1661
+ }
1662
+ }
1663
+ if ('order' in a) {
1664
+ const o = a.order;
1665
+ const valid =
1666
+ Array.isArray(o) &&
1667
+ o.length === 2 &&
1668
+ ((o[0] === 'handwear-r' && o[1] === 'handwear-l') || (o[0] === 'handwear-l' && o[1] === 'handwear-r'));
1669
+ if (!valid) {
1670
+ c.fail('CONFIG_FIELD_TYPE', `${path}.order`, `is ${show(o)}; ["handwear-r", "handwear-l"] or ["handwear-l", "handwear-r"] (back to front) is required`);
1671
+ }
1672
+ }
1592
1673
  }
1593
1674
 
1594
1675
  function checkRunTag(c: Check, path: string, run: Json, tag: Json): void {
@@ -2309,10 +2390,39 @@ function checkLimbs(c: Check, v: Json, made: { parts: string[]; patches: string[
2309
2390
  }
2310
2391
  const sources = new Set([...made.parts, ...made.cuts]);
2311
2392
  const firstAt = new Map<string, string>();
2393
+ const limbFroms = new Map<string, Set<string>>();
2394
+ const splitLimbs: string[] = [];
2312
2395
  for (const [limb, spec] of Object.entries(v)) {
2313
2396
  const at = `config.limbs.${limb}`;
2314
- const o = c.object(at, spec, ['segments'], []);
2315
- if (o === null || !('segments' in o)) continue;
2397
+ const o = c.object(at, spec, ['segments'], ['split']);
2398
+ if (o === null) continue;
2399
+ if ('split' in o) {
2400
+ const sp = c.object(`${at}.split`, o.split, [], ['line', 'edge']);
2401
+ if (sp !== null) {
2402
+ const hasLine = 'line' in sp;
2403
+ const hasEdge = 'edge' in sp;
2404
+ if ((hasLine && hasEdge) || (!hasLine && !hasEdge)) {
2405
+ c.fail('CONFIG_FIELD_TYPE', `${at}.split`, `is ${show(o.split)}; either "line" (a polyline of two or more [x, y] points in rig px) or "edge": "painting" is required, not both and not neither`);
2406
+ } else if (hasLine) {
2407
+ const pts = sp.line;
2408
+ const ok =
2409
+ Array.isArray(pts) &&
2410
+ pts.length >= 2 &&
2411
+ pts.every((p) => Array.isArray(p) && p.length === 2 && typeof p[0] === 'number' && Number.isFinite(p[0]) && typeof p[1] === 'number' && Number.isFinite(p[1]));
2412
+ if (!ok) {
2413
+ c.fail('CONFIG_FIELD_TYPE', `${at}.split.line`, `is ${show(pts)}; an array of two or more [x, y] points in rig px is required`);
2414
+ } else {
2415
+ const typed = pts as Array<[number, number]>;
2416
+ if (typed.every((p) => p[0] === typed[0][0] && p[1] === typed[0][1])) {
2417
+ c.fail('CONFIG_FIELD_TYPE', `${at}.split.line`, `is ${show(pts)}; a polyline with non-zero length is required — all points stand on [${typed[0].join(', ')}]`);
2418
+ } else splitLimbs.push(limb);
2419
+ }
2420
+ } else if (sp.edge !== 'painting') {
2421
+ c.fail('CONFIG_FIELD_TYPE', `${at}.split.edge`, `is ${show(sp.edge)}; "painting" is required — traces the covering limb's edge across the crossing from the painting's own stroke or silhouette`);
2422
+ } else splitLimbs.push(limb);
2423
+ }
2424
+ }
2425
+ if (!('segments' in o)) continue;
2316
2426
  if (!Array.isArray(o.segments) || o.segments.length < 2) {
2317
2427
  c.fail('CONFIG_FIELD_TYPE', `${at}.segments`, `is ${show(o.segments)}; an array of two segments or more, upper first, is required — a limb is cut at a joint, and a joint has a segment on each side`);
2318
2428
  continue;
@@ -2382,6 +2492,7 @@ function checkLimbs(c: Check, v: Json, made: { parts: string[]; patches: string[
2382
2492
  }
2383
2493
  }
2384
2494
  });
2495
+ limbFroms.set(limb, new Set(froms.filter((f): f is string => f !== null)));
2385
2496
  froms.forEach((f, i) => {
2386
2497
  if (f === null || i === 0 || froms[i - 1] === f) return;
2387
2498
  const back = froms.slice(0, i - 1).lastIndexOf(f);
@@ -2399,6 +2510,20 @@ function checkLimbs(c: Check, v: Json, made: { parts: string[]; patches: string[
2399
2510
  });
2400
2511
  }
2401
2512
  }
2513
+ for (const limb of splitLimbs) {
2514
+ const myFroms = limbFroms.get(limb) ?? new Set<string>();
2515
+ if (myFroms.size === 0) continue;
2516
+ const sharers = [...limbFroms.entries()].filter(([other, fs]) => other !== limb && [...myFroms].some((f) => fs.has(f)));
2517
+ if (sharers.length === 0) {
2518
+ c.fail('CONFIG_FIELD_TYPE', `config.limbs.${limb}.split`, `is declared on "${limb}", whose from part(s) (${[...myFroms].map((f) => `"${f}"`).join(', ')}) no other limb takes; a per-limb split divides a part shared by two limbs`);
2519
+ } else {
2520
+ const otherSplit = sharers.find(([other]) => splitLimbs.includes(other));
2521
+ if (otherSplit !== undefined) {
2522
+ const sharedFrom = [...myFroms].find((f) => otherSplit[1].has(f)) as string;
2523
+ c.fail('CONFIG_FIELD_TYPE', `config.limbs.${limb}.split`, `is declared on "${limb}" and on "${otherSplit[0]}", which both take "${sharedFrom}"; declare split on the covering limb alone`);
2524
+ }
2525
+ }
2526
+ }
2402
2527
  return out;
2403
2528
  }
2404
2529
 
@@ -2975,7 +3100,7 @@ function checkScene(c: Check, v: Json): void {
2975
3100
 
2976
3101
  function checkTurn(c: Check, v: Json, attached: Set<string>, bones: Set<string>): void {
2977
3102
  const at = 'config.turn';
2978
- const o = c.object(at, v, ['pivot', 'depths'], []);
3103
+ const o = c.object(at, v, ['pivot', 'depths'], ['deform']);
2979
3104
  if (o === null) return;
2980
3105
  const pivot = 'pivot' in o && c.number(`${at}.pivot`, o.pivot) ? (o.pivot as number) : null;
2981
3106
  const depths: Record<string, number> = {};
@@ -2996,6 +3121,35 @@ function checkTurn(c: Check, v: Json, attached: Set<string>, bones: Set<string>)
2996
3121
  }
2997
3122
  }
2998
3123
  }
3124
+ if ('deform' in o) {
3125
+ const df = o.deform;
3126
+ if (!isPlainObject(df)) {
3127
+ c.fail('CONFIG_FIELD_TYPE', `${at}.deform`, `is ${show(df)}; an object of face deform key -> per-degree fraction is required`);
3128
+ } else {
3129
+ const known = Object.keys(FACE_DEFORM_FRACTIONS);
3130
+ for (const [key, val] of Object.entries(df)) {
3131
+ if (isRecordKey(key)) continue;
3132
+ if (key === 'note' || key.endsWith('_note')) {
3133
+ if (typeof val !== 'string') c.fail('CONFIG_FIELD_TYPE', `${at}.deform.${key}`, `is ${show(val)}; an annotation is a string`);
3134
+ continue;
3135
+ }
3136
+ if (!Object.hasOwn(FACE_DEFORM_FRACTIONS, key)) {
3137
+ c.fail('CONFIG_KEY_KNOWN', `${at}.deform.${key}`, `is not a known face deform fraction; expected one of ${known.join(', ')} — ${DOORS}`);
3138
+ continue;
3139
+ }
3140
+ if (c.number(`${at}.deform.${key}`, val)) {
3141
+ const [lo, hi] = FACE_DEFORM_RANGES[key as FaceDeformKey];
3142
+ if (val < lo || val > hi) {
3143
+ c.fail(
3144
+ 'CONFIG_TURN_DEFORM_RANGE',
3145
+ `${at}.deform.${key}`,
3146
+ `is ${val}; a per-degree fraction in [${lo}, ${hi}] is required (default ${FACE_DEFORM_FRACTIONS[key as FaceDeformKey]})`,
3147
+ );
3148
+ }
3149
+ }
3150
+ }
3151
+ }
3152
+ }
2999
3153
  if (pivot !== null && depthsOk) {
3000
3154
  const nearest = turnNearest(depths);
3001
3155
  if (nearest !== null && !(pivot > nearest.depth)) {
package/src/coords.ts CHANGED
@@ -22,5 +22,5 @@
22
22
  * `flattenRig` goes back through `toWorld`. No rotation matrix is written out
23
23
  * by hand in this package.
24
24
  */
25
- export { computeExactFrameTransforms, computeWorldTransforms, cropToSpineY, normaliseDegrees, toBoneLocal, toWorld } from 'rig-c/src/transform.ts';
25
+ export { computeExactFrameTransforms, computeWorldTransforms, cropToSpineY, normaliseDegrees, toBoneLocal, toBoneLocalVector, toWorld } from 'rig-c/src/transform.ts';
26
26
  export type { BoneTransform } from 'rig-c/src/transform.ts';
@@ -79,6 +79,7 @@ export interface BasisFile {
79
79
  * `derived` placement names at least one bone; a constant is a finite number.
80
80
  */
81
81
  export function checkBasis(P: PartSet, proposal: Proposal, basis: ProposalBasis): void {
82
+ P = P.beforeLimbs();
82
83
  const problems: Problem[] = [];
83
84
  const names = [...expand(proposal.bones).byName.keys()];
84
85
  const bones = new Set(names);
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
+
package/src/motion.ts CHANGED
@@ -145,16 +145,26 @@ export interface MotionSpec {
145
145
  animations: { idle: { duration: number; loop: true; note: string; tracks: MotionTrack[] } };
146
146
  }
147
147
 
148
+ const FACE_DEFORM_HOOK = new WeakMap<MotionSpec, (merged: MotionSpec & { animations: Record<string, unknown> }) => void>();
149
+
150
+ /** Attach a face-surface-deform hook to `motion` so {@link withAnimations} applies it when `turn`/`tilt` slider animations are merged (issue #436). */
151
+ export function bindFaceSurfaceDeform(motion: MotionSpec, hook: (merged: MotionSpec & { animations: Record<string, unknown> }) => void): void {
152
+ FACE_DEFORM_HOOK.set(motion, hook);
153
+ }
154
+
148
155
  /**
149
156
  * The motion `motion.json` holds: the idle, and after it every animation the
150
- * file `config.motion.animations_from` names, as read (issue #183) — nothing
151
- * added, nothing re-keyed, nothing judged; each is rigc's to accept at the
152
- * gate. With none, `motion` itself, so a config that names no file writes the
153
- * bytes it wrote before the field existed.
157
+ * file `config.motion.animations_from` names, as read (issue #183) — plus, when
158
+ * those animations hold `turn` or `tilt` slider `derive` keys, the Set B ×2.5
159
+ * face surface deform (issue #436). With none, `motion` itself, so a config
160
+ * that names no file writes the bytes it wrote before the field existed.
154
161
  */
155
162
  export function withAnimations(motion: MotionSpec, extra: Readonly<Record<string, unknown>> | null): MotionSpec | Record<string, unknown> {
156
163
  if (extra === null) return motion;
157
- return { ...motion, animations: { ...motion.animations, ...extra } };
164
+ const merged = { ...motion, animations: { ...motion.animations, ...extra } };
165
+ const hook = FACE_DEFORM_HOOK.get(motion);
166
+ if (hook !== undefined) hook(merged);
167
+ return merged;
158
168
  }
159
169
 
160
170
  /** One sine channel over an idle of `duration` seconds. */