@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/docs/AUTHORING.md +15 -5
- package/docs/STABILITY.md +4 -2
- package/package.json +1 -1
- package/src/assemble.ts +286 -13
- package/src/check.ts +23 -5
- package/src/cloth.ts +297 -29
- package/src/config.ts +160 -6
- package/src/coords.ts +1 -1
- package/src/diagnostics.ts +1 -0
- package/src/limbs.ts +221 -1
- package/src/motion.ts +15 -5
- package/src/propose.ts +618 -37
- package/src/rig.ts +13 -2
- package/src/turn.ts +665 -1
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
|
-
|
|
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
|
|
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';
|
package/src/diagnostics.ts
CHANGED
|
@@ -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) —
|
|
151
|
-
*
|
|
152
|
-
*
|
|
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
|
-
|
|
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. */
|