@rig-c/parts 1.15.0 → 1.17.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/README.md +4 -0
- package/cli.ts +62 -12
- package/docs/AUTHORING.md +454 -200
- package/docs/STABILITY.md +12 -8
- package/package.json +1 -1
- package/skills/rig-parts/SKILL.md +11 -2
- package/src/assemble.ts +325 -23
- package/src/autoamplitude.ts +23 -3
- package/src/autoenvelope.ts +17 -5
- package/src/build.ts +81 -8
- package/src/check.ts +632 -11
- package/src/config.ts +185 -8
- package/src/errors.ts +16 -0
- package/src/guide.ts +255 -0
- package/src/limbs.ts +188 -0
- package/src/parts.ts +89 -4
- package/src/propose.ts +137 -107
- package/src/rig.ts +43 -8
- package/src/scene.ts +2 -2
package/src/propose.ts
CHANGED
|
@@ -25,7 +25,9 @@
|
|
|
25
25
|
* - `topwear` -> two outer-robe chains, only when it hangs below the hip line;
|
|
26
26
|
* a side whose link falls off the art is dropped.
|
|
27
27
|
* - `handwear-*` -> two blobs: one sleeve chain per blob, its mesh taking that
|
|
28
|
-
* chain and a chest stub at the shoulder
|
|
28
|
+
* chain and nothing else (issue #347: a chest stub at the shoulder pinned the
|
|
29
|
+
* puff to the torso, so the part stretched when the chain turned; without it the
|
|
30
|
+
* part rides its chain rigidly); one blob: both chains and the chest
|
|
29
31
|
* midline, split at the eye axis — unless the blob is clasped hands (at the
|
|
30
32
|
* axis and at most half the shoulders' width), which ride `hip` as a region.
|
|
31
33
|
* - `front hair` -> three fringe chains + one lock chain per strand that hangs
|
|
@@ -117,7 +119,7 @@
|
|
|
117
119
|
import { existsSync } from 'node:fs';
|
|
118
120
|
import { join } from 'node:path';
|
|
119
121
|
import { drawText, GLYPH_H } from 'rig-c/tools/font5x7.ts';
|
|
120
|
-
import { type BoneEntry, type ConfigConstraint, type MeshSpec, parseConfig, type Point, type Segment, type SkeletonSections } from './config.ts';
|
|
122
|
+
import { type BoneEntry, type ConfigConstraint, type Limb, type MeshSpec, parseConfig, type Point, type Segment, type SkeletonSections } from './config.ts';
|
|
121
123
|
import { PartsError, type Problem, refuseIfAny } from './errors.ts';
|
|
122
124
|
import { eyeLineDegrees, type FrameFeature, headAxes, type HeadAxes, headFrame, type HeadFrame } from './headframe.ts';
|
|
123
125
|
import { type LayerSet, OPAQUE_ALPHA_ABOVE } from './layers.ts';
|
|
@@ -254,6 +256,8 @@ export interface Proposal {
|
|
|
254
256
|
bones: BoneEntry[];
|
|
255
257
|
meshes: Record<string, MeshSpec>;
|
|
256
258
|
regions: Record<string, string>;
|
|
259
|
+
/** Only under `--keypoints`, where an arm or a leg is drafted from its joints (issue #349): absent otherwise, so a proposal without one is the one written before it. */
|
|
260
|
+
limbs?: Record<string, Limb>;
|
|
257
261
|
motion: {
|
|
258
262
|
duration: number;
|
|
259
263
|
tracks: Array<ProposedSingleTrack | ProposedChainTrack>;
|
|
@@ -417,6 +421,7 @@ export function checkProposal(P: PartSet, p: Proposal): void {
|
|
|
417
421
|
bones: p.bones,
|
|
418
422
|
meshes: p.meshes,
|
|
419
423
|
regions: p.regions,
|
|
424
|
+
...(p.limbs === undefined ? {} : { limbs: p.limbs }),
|
|
420
425
|
motion: p.motion,
|
|
421
426
|
...(p.turn === undefined ? {} : { turn: p.turn }),
|
|
422
427
|
});
|
|
@@ -560,7 +565,20 @@ export const TURN_DEPTH_CAVEAT: readonly string[] = [
|
|
|
560
565
|
|
|
561
566
|
/** The proposal with `turn` written after `motion` and `notes` extended; the proposal itself when `turn` is null. */
|
|
562
567
|
export function withTurn(p: Proposal, draft: { turn: TurnSpec | null; notes: string[] }): Proposal {
|
|
563
|
-
|
|
568
|
+
// Issue #349: a part a limb takes is not made; each of its limb parts carries its depth instead.
|
|
569
|
+
let turn = draft.turn;
|
|
570
|
+
if (turn !== null && p.limbs !== undefined) {
|
|
571
|
+
const depths: Record<string, number> = {};
|
|
572
|
+
const into = new Map<string, string[]>();
|
|
573
|
+
for (const l of Object.values(p.limbs)) for (const sg of l.segments) into.set(sg.from, [...(into.get(sg.from) ?? []), sg.part]);
|
|
574
|
+
for (const [part, d] of Object.entries(turn.depths)) {
|
|
575
|
+
const parts = into.get(part);
|
|
576
|
+
if (parts === undefined) depths[part] = d;
|
|
577
|
+
else for (const q of parts) depths[q] = d;
|
|
578
|
+
}
|
|
579
|
+
turn = { pivot: turn.pivot, depths };
|
|
580
|
+
}
|
|
581
|
+
return { bones: p.bones, meshes: p.meshes, regions: p.regions, ...(p.limbs === undefined ? {} : { limbs: p.limbs }), motion: p.motion, ...(turn === null ? {} : { turn }), notes: [...p.notes, ...draft.notes] };
|
|
564
582
|
}
|
|
565
583
|
|
|
566
584
|
// ---------------------------------------------------------------------------
|
|
@@ -604,7 +622,7 @@ export class PartSet {
|
|
|
604
622
|
*/
|
|
605
623
|
unmarkedShares(): Array<{ from: string; parts: string[] }> {
|
|
606
624
|
const by = new Map<string, string[]>();
|
|
607
|
-
for (const p of this.layered()) if (p.cut_from === undefined) by.set(p.from, [...(by.get(p.from) ?? []), p.name]);
|
|
625
|
+
for (const p of this.layered()) if (p.cut_from === undefined && p.limb === undefined) by.set(p.from, [...(by.get(p.from) ?? []), p.name]);
|
|
608
626
|
return [...by.entries()].filter(([, names]) => names.length > 1).map(([from, parts]) => ({ from, parts }));
|
|
609
627
|
}
|
|
610
628
|
|
|
@@ -1128,8 +1146,24 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
1128
1146
|
detail: `share one layer and none of them records cut_from, so which is the plan part and which the piece an assemble.cuts entry took cannot be read off parts.json (it is in draw order, and a piece drawn "back" comes before its base); re-run assemble (1.6.0+) to record cut_from`,
|
|
1129
1147
|
})),
|
|
1130
1148
|
);
|
|
1149
|
+
// Issue #349: limbs are drafted from the parts before their cut — the layer each limb takes is what the rules read.
|
|
1150
|
+
const limbed = P.recs.filter((r) => r.limb !== undefined);
|
|
1151
|
+
refuseIfAny(
|
|
1152
|
+
limbed.length === 0
|
|
1153
|
+
? []
|
|
1154
|
+
: [
|
|
1155
|
+
{
|
|
1156
|
+
code: 'PROPOSE_LIMB_PARTS',
|
|
1157
|
+
object: `parts ${limbed.map((r) => `"${r.name}"`).join(', ')}`,
|
|
1158
|
+
detail: `were cut at their joints by config.limbs (from ${[...new Set(limbed.map((r) => `"${(r.limb as NonNullable<PartRecord['limb']>).of}"`))].join(', ')}), and propose drafts the limbs from the parts before the cut; assemble with the config's limbs left out, propose from that build, and keep the limbs block (and any caps, ranges) the config already holds — or read the config's bones with propose --from-config`,
|
|
1159
|
+
},
|
|
1160
|
+
],
|
|
1161
|
+
);
|
|
1131
1162
|
const bones: BoneEntry[] = [];
|
|
1132
1163
|
const records: BoneBasis[] = [];
|
|
1164
|
+
/** Issue #349: the limbs `--keypoints` drafts, and the parts they take (not made, so not given a mesh or a region). */
|
|
1165
|
+
const limbs: Record<string, Limb> = Object.create(null) as Record<string, Limb>;
|
|
1166
|
+
const consumed = new Set<string>();
|
|
1133
1167
|
// No prototype: a part may be named anything file-safe, "__proto__" included.
|
|
1134
1168
|
const meshes = Object.create(null) as Record<string, MeshSpec>;
|
|
1135
1169
|
const regions = Object.create(null) as Record<string, string>;
|
|
@@ -1764,30 +1798,37 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
1764
1798
|
} else if (armPlan !== null && armPlan.arms !== null) {
|
|
1765
1799
|
// Each chain runs shoulder -> elbow with its tip at the wrist: two links,
|
|
1766
1800
|
// the upper arm and the forearm, each with the amplitude the two-blob rule
|
|
1767
|
-
// gives its link of the same index. Its part's mesh takes the chain
|
|
1768
|
-
//
|
|
1769
|
-
// the
|
|
1801
|
+
// gives its link of the same index. Its part's mesh takes the chain alone,
|
|
1802
|
+
// as the two-blob rule's does since issue #347 (no chest stub: a stub on
|
|
1803
|
+
// chest pinned the puff, and the arm stretched it when it turned).
|
|
1770
1804
|
for (const a of armPlan.arms) {
|
|
1771
1805
|
const sgn = a.side === 'r' ? 1 : -1;
|
|
1772
1806
|
const used = (n: KeypointName): Placement => placement('joint', `the ${n} joint, ${usedAs((joints as RigJoints).joints[n])}`, { joints: [jointRef(n)] });
|
|
1773
1807
|
C(`sleeve_${a.side}`, 'chest', [a.shoulder, a.elbow], a.wrist, [used(`${a.side}_shoulder`), used(`${a.side}_elbow`)], used(`${a.side}_wrist`));
|
|
1774
1808
|
tracks.push({ chain: `sleeve_${a.side}`, amps: [sgn * 0.2, sgn * 0.6], period: 4.0, phase: 0.22 + (a.side === 'l' ? 0.05 : 0), lag: 0.08 });
|
|
1775
1809
|
}
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1810
|
+
// Issue #349: each arm is a limb — the upper arm on link 0 and the forearm (with the hand) on link 1, each rigid on
|
|
1811
|
+
// its link, cut at the elbow — not a mesh blended along the chain. Its handwear part is the layer the limb takes.
|
|
1812
|
+
for (const a of armPlan.arms) {
|
|
1813
|
+
limbs[`arm_${a.side}`] = {
|
|
1814
|
+
segments: [
|
|
1815
|
+
{ part: `upperarm_${a.side}`, from: a.part.name, bone: `sleeve_${a.side}0` },
|
|
1816
|
+
{ part: `forearm_${a.side}`, from: a.part.name, bone: `sleeve_${a.side}1`, joint: 'elbow' },
|
|
1817
|
+
],
|
|
1818
|
+
};
|
|
1819
|
+
regions[`upperarm_${a.side}`] = `sleeve_${a.side}0`;
|
|
1820
|
+
regions[`forearm_${a.side}`] = `sleeve_${a.side}1`;
|
|
1821
|
+
consumed.add(a.part.name);
|
|
1822
|
+
notes.push(
|
|
1823
|
+
`limb arm_${a.side}: ${a.part.name} (${a.part.from}) is cut at the ${a.side}_elbow joint into upperarm_${a.side} on sleeve_${a.side}0 and forearm_${a.side} (with the hand) on sleeve_${a.side}1, each rigid on its link with weight 1 — a bend is a rotation at the joint; assemble with this limbs block cuts the layer, and the forearm carries a cap past the elbow (its line counts the cap's pixels, and names any the layer does not hold)`,
|
|
1824
|
+
);
|
|
1785
1825
|
}
|
|
1786
1826
|
} else if (hw.length === 2 && sides.size === 2) {
|
|
1787
1827
|
sleeveRule = 'the two-blob sleeve rule (one chain per handwear blob, down its rows)';
|
|
1788
|
-
// Arms apart: two blobs. Each mesh gets ONLY its own sleeve chain
|
|
1789
|
-
//
|
|
1790
|
-
//
|
|
1828
|
+
// Arms apart: two blobs. Each mesh gets ONLY its own sleeve chain; the
|
|
1829
|
+
// one-blob recipe made hanging hands breathe with the sternum, and the short
|
|
1830
|
+
// chest segment at the shoulder this rule drafted until issue #347 pinned the
|
|
1831
|
+
// puff to the torso, so the sleeve stretched when its chain turned.
|
|
1791
1832
|
for (const [s, sgn] of SIDES) {
|
|
1792
1833
|
const p = (sides.get(s) as PartRecord[])[0];
|
|
1793
1834
|
const mask = P.alpha(p);
|
|
@@ -1809,10 +1850,9 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
1809
1850
|
C(`sleeve_${s}`, 'chest', pts, [orSaid(bandX(mask, bot - 6), pts[pts.length - 1][0], "no art in the band: the last link's x", tipWhy), bot - 3], why, tipWhy);
|
|
1810
1851
|
tracks.push({ chain: `sleeve_${s}`, amps: [sgn * 0.2, sgn * 0.6, sgn * 1.2], period: 4.0, phase: 0.22 + (s === 'l' ? 0.05 : 0), lag: 0.08 });
|
|
1811
1852
|
const [g, r] = gridR(p);
|
|
1812
|
-
|
|
1813
|
-
meshes[p.name] = { grid: g, r, segments: [['chest', rnd([sx, y0 - 14]), rnd([sx, y0 + 2])], `sleeve_${s}`] };
|
|
1853
|
+
meshes[p.name] = { grid: g, r, segments: [`sleeve_${s}`] };
|
|
1814
1854
|
}
|
|
1815
|
-
notes.push('handwear is two blobs: one sleeve chain per mesh,
|
|
1855
|
+
notes.push('handwear is two blobs: one sleeve chain per mesh, and nothing on chest');
|
|
1816
1856
|
} else if (hw.length > 0) {
|
|
1817
1857
|
sleeveRule = `the one-blob sleeve rule (both chains in one mesh, split at the eye axis x=${pyFixed(axis, 0)})`;
|
|
1818
1858
|
const segs: Segment[] = [['chest', rnd([chest[0], chest[1] - 40]), rnd([chest[0], hip[1]])]];
|
|
@@ -2003,33 +2043,64 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
2003
2043
|
for (const c of legPlan.chains) {
|
|
2004
2044
|
const J = (joints as RigJoints).joints;
|
|
2005
2045
|
const used = (n: KeypointName): Placement => placement('joint', `the ${n} joint, ${usedAs(J[n])}`, { joints: [jointRef(n)] });
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2046
|
+
C(c.chain, 'hip', [c.hip, c.knee], c.ankle, [used(`${c.side}_hip`), used(`${c.side}_knee`)], used(`${c.side}_ankle`));
|
|
2047
|
+
}
|
|
2048
|
+
// Issue #349: each leg is a limb — the thigh on link 0 and the shin on link 1, cut at the knee, and the foot below
|
|
2049
|
+
// the ankle on a bone of its own that does not take the shin's rotation, so the shoe stays level (issue #348). The
|
|
2050
|
+
// thigh rides its link alone: no hip stub pins it.
|
|
2051
|
+
const fw = P.byTag('footwear', undefined, true).filter((q) => q.opaque_px > 500);
|
|
2052
|
+
const nearFoot = new Map(fw.map((q) => [q, dilate(P.alpha(q), 2 * WRIST_ON_PART_PX + 1)]));
|
|
2053
|
+
// The feet are drafted only when every leg's ankle lies on exactly one footwear part: a shoe layer holding both
|
|
2054
|
+
// shoes split between one leg's foot and no other would hand that leg the other shoe.
|
|
2055
|
+
const legChains = legPlan.chains;
|
|
2056
|
+
const feetOn = legChains.map((c) => fw.filter((q) => isOn(nearFoot.get(q) as Mask, c.ankle[0], c.ankle[1])));
|
|
2057
|
+
const feetBound = fw.length > 0 && feetOn.every((on) => on.length === 1);
|
|
2058
|
+
legChains.forEach((c, ci) => {
|
|
2059
|
+
const s = c.side as 'r' | 'l';
|
|
2060
|
+
const J = (joints as RigJoints).joints;
|
|
2061
|
+
const segs: Limb['segments'] = [
|
|
2062
|
+
{ part: `thigh_${s}`, from: c.part.name, bone: `leg_${s}0` },
|
|
2063
|
+
{ part: `shin_${s}`, from: c.part.name, bone: `leg_${s}1`, joint: 'knee' },
|
|
2064
|
+
];
|
|
2065
|
+
regions[`thigh_${s}`] = `leg_${s}0`;
|
|
2066
|
+
regions[`shin_${s}`] = `leg_${s}1`;
|
|
2067
|
+
consumed.add(c.part.name);
|
|
2068
|
+
const on = feetOn[ci];
|
|
2069
|
+
let foot = '';
|
|
2070
|
+
if (feetBound) {
|
|
2071
|
+
B(`foot_${s}`, `leg_${s}1`, c.ankle, placement('joint', `the ${s}_ankle joint, ${usedAs(J[`${s}_ankle`])}`, { joints: [jointRef(`${s}_ankle`)] }));
|
|
2072
|
+
(bones[bones.length - 1] as { inherit?: string }).inherit = FOOT_INHERIT;
|
|
2073
|
+
segs.push({ part: `foot_${s}`, from: on[0].name, bone: `foot_${s}`, joint: 'ankle' });
|
|
2074
|
+
regions[`foot_${s}`] = `foot_${s}`;
|
|
2075
|
+
consumed.add(on[0].name);
|
|
2076
|
+
foot = `, and foot_${s}, ${on[0].name}'s share below the ankle, on bone foot_${s} at the ${s}_ankle joint under leg_${s}1 with inherit "${FOOT_INHERIT}" — the shoe keeps its setup angle while the leg turns (FEET_STAY_LEVEL)`;
|
|
2077
|
+
} else if (fw.length > 0) {
|
|
2078
|
+
const where = (k: number): string => {
|
|
2079
|
+
const o = feetOn[k];
|
|
2080
|
+
const g = legChains[k];
|
|
2081
|
+
if (o.length > 1) return `${g.side}_ankle lies on the art of ${o.length} footwear parts (${o.map((q) => q.name).join(', ')})`;
|
|
2082
|
+
if (o.length === 1) return `${g.side}_ankle lies on ${o[0].name}`;
|
|
2083
|
+
const d = fw.map((q) => ({ q, d: artDistance(P.alpha(q), g.ankle) })).sort((a, b) => a.d - b.d)[0];
|
|
2084
|
+
return `${g.side}_ankle at ${fmtAt(g.ankle)} lies on the art of no footwear part over 500 px within ${WRIST_ON_PART_PX} px — the nearest is ${d.q.name}, ${d.d} px from its art by the larger of the x and y gaps`;
|
|
2085
|
+
};
|
|
2086
|
+
foot = `; no foot segment: every leg's ankle must lie on exactly one footwear part for the feet to be drafted, and ${legChains.map((_, k) => where(k)).join('; ')} — move the ankle joint onto the shoe, or write the foot segment and its bone in config by hand`;
|
|
2087
|
+
} else foot = '; no foot segment: no footwear part is over 500 px';
|
|
2088
|
+
limbs[`leg_${s}`] = { segments: segs };
|
|
2089
|
+
notes.push(`limb leg_${s}: ${c.part.name} (${c.part.from}) is cut at the ${s}_knee joint into thigh_${s} on leg_${s}0 and shin_${s} on leg_${s}1, each rigid on its link with weight 1${foot}; the shin carries a cap past the knee`);
|
|
2090
|
+
});
|
|
2025
2091
|
notes.push(...legPlan.notes);
|
|
2026
2092
|
} else if (legPlan !== null && legPlan.why !== null) {
|
|
2027
2093
|
notes.push(`${lw.map((p) => p.name).join(', ')}: no leg chain — ${legPlan.why}; the trunk rule places ${lw.length === 1 ? 'it' : 'them'}`);
|
|
2028
2094
|
}
|
|
2029
2095
|
|
|
2096
|
+
// Issue #349: a part a limb takes is not made, so the rule that read its tag first (footwear rides root) gives it nothing.
|
|
2097
|
+
for (const name of consumed) {
|
|
2098
|
+
delete meshes[name];
|
|
2099
|
+
delete regions[name];
|
|
2100
|
+
}
|
|
2030
2101
|
// Anything no rule claimed rides its nearest trunk bone rigidly, so the rig stage never meets an unassigned part.
|
|
2031
2102
|
for (const p of P.recs) {
|
|
2032
|
-
if (!(p.name in meshes) && !(p.name in regions)) {
|
|
2103
|
+
if (!(p.name in meshes) && !(p.name in regions) && !consumed.has(p.name)) {
|
|
2033
2104
|
const cy = center(p)[1];
|
|
2034
2105
|
regions[p.name] = cy < neckY ? 'head' : cy < hip[1] ? 'chest' : 'hip';
|
|
2035
2106
|
if (splitFrom(p.from)[0] === PAINTING_RUN) {
|
|
@@ -2078,7 +2149,8 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
|
|
|
2078
2149
|
// cycles per idle (REFERENCE_IDLE_SECONDS).
|
|
2079
2150
|
const scale = IDLE_SECONDS / REFERENCE_IDLE_SECONDS;
|
|
2080
2151
|
const idleTracks = tracks.map((tr) => ({ ...tr, period: tr.period * scale }));
|
|
2081
|
-
|
|
2152
|
+
const drafted = Object.keys(limbs).length === 0 ? {} : { limbs };
|
|
2153
|
+
return { proposal: { bones, meshes, regions, ...drafted, motion: blink === undefined ? { duration: IDLE_SECONDS, tracks: idleTracks } : { duration: IDLE_SECONDS, tracks: idleTracks, blink }, notes }, basis: { frame, bones: records } };
|
|
2082
2154
|
}
|
|
2083
2155
|
|
|
2084
2156
|
/**
|
|
@@ -2191,6 +2263,13 @@ export const WRIST_ON_PART_PX = 15;
|
|
|
2191
2263
|
* when the wrist is within {@link WRIST_ON_PART_PX} of that part's art, else
|
|
2192
2264
|
* the distance `--wrist-snap` bound it across.
|
|
2193
2265
|
*/
|
|
2266
|
+
/**
|
|
2267
|
+
* The `inherit` a drafted foot bone takes (issue #349): the shin's position and not its rotation or reflection, so
|
|
2268
|
+
* the shoe keeps its setup angle while the leg is posed — the mode `FEET_STAY_LEVEL` names (issue #348). The
|
|
2269
|
+
* proposer drafts it; which mode a foot takes stays the author's.
|
|
2270
|
+
*/
|
|
2271
|
+
export const FOOT_INHERIT = 'noRotationOrReflection';
|
|
2272
|
+
|
|
2194
2273
|
interface JointArm {
|
|
2195
2274
|
side: 'r' | 'l';
|
|
2196
2275
|
shoulder: [number, number];
|
|
@@ -2291,40 +2370,18 @@ function armsFromJoints(P: PartSet, hw: readonly PartRecord[], joints: RigJoints
|
|
|
2291
2370
|
|
|
2292
2371
|
/**
|
|
2293
2372
|
* One leg chain drafted from joints (issue #241): its name, the side whose
|
|
2294
|
-
* joints it runs through
|
|
2295
|
-
*
|
|
2296
|
-
* its two links and tip in rig px, and the legwear part it meshes.
|
|
2373
|
+
* joints it runs through, its two links and tip in rig px, and the legwear
|
|
2374
|
+
* part its limb takes (issue #349).
|
|
2297
2375
|
*/
|
|
2298
2376
|
interface LegChain {
|
|
2299
2377
|
chain: string;
|
|
2300
|
-
side: 'r' | 'l'
|
|
2378
|
+
side: 'r' | 'l';
|
|
2301
2379
|
hip: [number, number];
|
|
2302
2380
|
knee: [number, number];
|
|
2303
2381
|
ankle: [number, number];
|
|
2304
2382
|
part: PartRecord;
|
|
2305
2383
|
}
|
|
2306
2384
|
|
|
2307
|
-
/**
|
|
2308
|
-
* Whether `m`'s art breaks along the straight line from `a` to `b`: walked
|
|
2309
|
-
* one pixel step at a time (the larger of the x and y gaps, in steps of at
|
|
2310
|
-
* most one pixel, each point read as {@link isOn} reads it), some point with
|
|
2311
|
-
* no art lies between two points with art. Art that only starts or stops
|
|
2312
|
-
* along the line — an end point off the art — is no break.
|
|
2313
|
-
*/
|
|
2314
|
-
function artBreaks(m: Mask, a: readonly [number, number], b: readonly [number, number]): boolean {
|
|
2315
|
-
const n = Math.ceil(Math.max(Math.abs(b[0] - a[0]), Math.abs(b[1] - a[1])));
|
|
2316
|
-
let seenArt = false;
|
|
2317
|
-
let gapAfterArt = false;
|
|
2318
|
-
for (let i = 0; i <= n; i++) {
|
|
2319
|
-
const t = n === 0 ? 0 : i / n;
|
|
2320
|
-
const on = isOn(m, a[0] + t * (b[0] - a[0]), a[1] + t * (b[1] - a[1]));
|
|
2321
|
-
if (on && gapAfterArt) return true;
|
|
2322
|
-
if (on) seenArt = true;
|
|
2323
|
-
else if (seenArt) gapAfterArt = true;
|
|
2324
|
-
}
|
|
2325
|
-
return false;
|
|
2326
|
-
}
|
|
2327
|
-
|
|
2328
2385
|
/**
|
|
2329
2386
|
* The leg chains a keypoint file drafts for the legwear parts, or why none
|
|
2330
2387
|
* is drafted (issue #241) — the sleeve's {@link armsFromJoints} for the legs.
|
|
@@ -2337,14 +2394,11 @@ function artBreaks(m: Mask, a: readonly [number, number], b: readonly [number, n
|
|
|
2337
2394
|
* drafted and `why` is `null`: the proposal is the one written before the
|
|
2338
2395
|
* rule existed.
|
|
2339
2396
|
*
|
|
2340
|
-
*
|
|
2341
|
-
*
|
|
2342
|
-
*
|
|
2343
|
-
*
|
|
2344
|
-
*
|
|
2345
|
-
* its art, two chains would pull one stretch of it two ways, and it gets one
|
|
2346
|
-
* chain, `leg`, through the midpoints of the hips, the knees and the ankles.
|
|
2347
|
-
* Each part gets one note saying which and why.
|
|
2397
|
+
* Every bound leg gets its own chain, `leg_<side>`, and becomes a limb
|
|
2398
|
+
* (issue #349): a part carrying both legs is split between the two limbs by
|
|
2399
|
+
* the cut itself (each pixel to the leg whose bones pass nearest it), so the
|
|
2400
|
+
* single midpoint chain the mesh needed when the legs touched (issue #241) is
|
|
2401
|
+
* not drafted. Each part gets one note saying which and why.
|
|
2348
2402
|
*/
|
|
2349
2403
|
function legsFromJoints(P: PartSet, lw: readonly PartRecord[], joints: RigJoints): { chains: LegChain[] | null; notes: string[]; why: string | null } {
|
|
2350
2404
|
const complete: Array<{ side: 'r' | 'l'; hip: [number, number]; knee: [number, number]; ankle: [number, number] }> = [];
|
|
@@ -2389,23 +2443,11 @@ function legsFromJoints(P: PartSet, lw: readonly PartRecord[], joints: RigJoints
|
|
|
2389
2443
|
notes.push(`${p.name} (${p.from}): one leg chain, leg_${g.side}, hip -> knee -> ankle from the ${g.side} joints — ${g.side}_ankle lies on its art and no ${other} leg is bound to it (${lack}); ${still}`);
|
|
2390
2444
|
continue;
|
|
2391
2445
|
}
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
const
|
|
2395
|
-
const anklesBreak = artBreaks(m, r.ankle, l.ankle);
|
|
2396
|
-
if (kneesBreak && anklesBreak) {
|
|
2397
|
-
for (const g of on) chains.push({ chain: `leg_${g.side}`, side: g.side, hip: g.hip, knee: g.knee, ankle: g.ankle, part: p });
|
|
2398
|
-
notes.push(
|
|
2399
|
-
`${p.name} (${p.from}): one chain per leg, leg_r and leg_l, sharing its mesh — both ankles lie on its art, and its art breaks along the line from r_knee to l_knee and along the line from r_ankle to l_ankle, so the legs are painted apart; each runs hip -> knee -> ankle from its side's joints, and ${still}`,
|
|
2400
|
-
);
|
|
2401
|
-
continue;
|
|
2402
|
-
}
|
|
2403
|
-
const mid = (a: [number, number], b: [number, number]): [number, number] => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
|
|
2404
|
-
chains.push({ chain: 'leg', side: null, hip: mid(r.hip, l.hip), knee: mid(r.knee, l.knee), ankle: mid(r.ankle, l.ankle), part: p });
|
|
2405
|
-
const unbroken = [kneesBreak ? null : 'from r_knee to l_knee', anklesBreak ? null : 'from r_ankle to l_ankle'].filter((s): s is string => s !== null);
|
|
2446
|
+
// Issue #349: two legs on one part are two limbs; the cut gives each pixel to the leg whose bones pass nearest it,
|
|
2447
|
+
// so the legs need not be painted apart, and no midpoint chain stands for both.
|
|
2448
|
+
for (const g of on) chains.push({ chain: `leg_${g.side}`, side: g.side, hip: g.hip, knee: g.knee, ankle: g.ankle, part: p });
|
|
2406
2449
|
notes.push(
|
|
2407
|
-
`${p.name} (${p.from}): one
|
|
2408
|
-
`the chain runs hip -> knee -> ankle through the midpoints of the two hips, the two knees and the two ankles, and ${still}`,
|
|
2450
|
+
`${p.name} (${p.from}): one chain per leg, leg_r and leg_l — both ankles lie on its art; each runs hip -> knee -> ankle from its side's joints, the two limbs split the part between them (each pixel to the leg whose bones pass nearest it), and ${still}`,
|
|
2409
2451
|
);
|
|
2410
2452
|
}
|
|
2411
2453
|
return { chains, notes, why: null };
|
|
@@ -2503,25 +2545,13 @@ function jointNotes(joints: RigJoints, f: JointNoteFacts): string[] {
|
|
|
2503
2545
|
const both = f.arms !== null && f.arms.length === 2 && f.arms[0].part === f.arms[1].part ? f.arms[0].part.name : null;
|
|
2504
2546
|
if (both !== null) {
|
|
2505
2547
|
out.push(
|
|
2506
|
-
`one handwear part holds both wrists: r_wrist and l_wrist both end on ${both}, so
|
|
2507
|
-
`to
|
|
2548
|
+
`one handwear part holds both wrists: r_wrist and l_wrist both end on ${both}, so limbs arm_r and arm_l both take it, and the cut gives each pixel to the arm whose bones pass nearest it; ` +
|
|
2549
|
+
`where the arms touch or cross in its art that split is a line no painting draws — to draw it yourself, cut one sleeve out of ${both} with an assemble.cuts entry (a polygon round it) and name the piece as that arm's from`,
|
|
2508
2550
|
);
|
|
2509
2551
|
}
|
|
2510
2552
|
// Issue #241: the knees and ankles a leg chain was drafted from, and the hips as its first link.
|
|
2511
2553
|
for (const c of f.legs ?? []) {
|
|
2512
|
-
const on = `the ankle
|
|
2513
|
-
if (c.side === null) {
|
|
2514
|
-
out.push(`chain leg: link 0 is the midpoint of r_hip and l_hip, ${fmtAt(c.hip)} — the point bone hip stands on`);
|
|
2515
|
-
for (const [joint, role, pair] of [
|
|
2516
|
-
['r_knee', 'link 1', 'r_knee and l_knee'],
|
|
2517
|
-
['l_knee', 'link 1', 'r_knee and l_knee'],
|
|
2518
|
-
['r_ankle', 'the tip', 'r_ankle and l_ankle'],
|
|
2519
|
-
['l_ankle', 'the tip', 'r_ankle and l_ankle'],
|
|
2520
|
-
] as const) {
|
|
2521
|
-
tell(joint, `${said(joint, J[joint])}: ${usedAs(J[joint])} for chain leg, ${role}, the midpoint of ${pair} — a point between two joints, not a joint (${on})`);
|
|
2522
|
-
}
|
|
2523
|
-
continue;
|
|
2524
|
-
}
|
|
2554
|
+
const on = `the ankle lies on ${c.part.name}`;
|
|
2525
2555
|
out.push(`chain leg_${c.side}: link 0 is the ${c.side}_hip joint, ${fmtAt(c.hip)}`);
|
|
2526
2556
|
for (const [joint, role] of [
|
|
2527
2557
|
[`${c.side}_knee`, 'link 1'],
|
package/src/rig.ts
CHANGED
|
@@ -108,6 +108,16 @@
|
|
|
108
108
|
* keys, a leaf twin of every bone a carried part binds, and one transform
|
|
109
109
|
* constraint per part carrying its twins `fraction · turn.x` in world x,
|
|
110
110
|
* after the config's own constraints. Without the field nothing is added.
|
|
111
|
+
* - **A bone's `inherit`**, only where config.bones declares one (issue
|
|
112
|
+
* #348): rig-c's mode name, written to that bone in rig.json as declared.
|
|
113
|
+
* The bone's setup rotation is then the turn from where rig-c's own poser
|
|
114
|
+
* puts it with a local rotation of 0 — under a mode that drops the parent's
|
|
115
|
+
* rotation that is not the parent's turn — so the bone still stands
|
|
116
|
+
* upright, or along its tip, at the setup pose. Where the idle gives the
|
|
117
|
+
* bone a control, the mode goes on the control, the bone that hangs from the
|
|
118
|
+
* declared parent, and the bone under it inherits normally: the idle's keys
|
|
119
|
+
* moved onto the control still turn the bone. Without the field no byte of
|
|
120
|
+
* any output moves.
|
|
111
121
|
*
|
|
112
122
|
* Every part image is padded by {@link PAD} transparent pixels on each side
|
|
113
123
|
* before it is meshed or placed, and the padded image is what `images/`
|
|
@@ -126,6 +136,7 @@ import { type AutoSpec, type BoneEntry, type WeightRule, type CharacterConfig, t
|
|
|
126
136
|
import { type BoneTransform, computeExactFrameTransforms, cropToSpineY, normaliseDegrees, toBoneLocal, toWorld } from './coords.ts';
|
|
127
137
|
import { type Problem, refuseIfAny } from './errors.ts';
|
|
128
138
|
import { eyeLineDegrees } from './headframe.ts';
|
|
139
|
+
import type { RigBoneInherit } from 'rig-c/src/rig.ts';
|
|
129
140
|
import { f32 } from 'rig-c/compile';
|
|
130
141
|
import type { MeshCounts, MeshQualityReport, MeshReductionInput, MotionAmplitude, ReducedMesh, Termination } from 'rig-c/mesh';
|
|
131
142
|
import { type AmplitudeRow, amplitudeRow, partAmplitude, withAmplitude } from './autoamplitude.ts';
|
|
@@ -382,6 +393,8 @@ interface Bone {
|
|
|
382
393
|
parent: string | null;
|
|
383
394
|
x: number;
|
|
384
395
|
y: number;
|
|
396
|
+
/** config.bones' `inherit`, on the bone that hangs from the declared parent (issue #348); absent when none is declared. */
|
|
397
|
+
inherit?: RigBoneInherit;
|
|
385
398
|
}
|
|
386
399
|
|
|
387
400
|
export interface RigBone {
|
|
@@ -393,6 +406,8 @@ export interface RigBone {
|
|
|
393
406
|
rotation?: number;
|
|
394
407
|
x: number;
|
|
395
408
|
y: number;
|
|
409
|
+
/** rig-c's inherit mode, written only where config.bones declares one (issue #348). */
|
|
410
|
+
inherit?: RigBoneInherit;
|
|
396
411
|
}
|
|
397
412
|
|
|
398
413
|
export interface WeightEntry {
|
|
@@ -944,7 +959,7 @@ export function buildRig(
|
|
|
944
959
|
chains.set(e.chain, names);
|
|
945
960
|
chainLines.set(e.chain, polyline);
|
|
946
961
|
} else {
|
|
947
|
-
B.set(e.name, { name: e.name, parent: e.parent, x: e.at[0], y: e.at[1] });
|
|
962
|
+
B.set(e.name, { name: e.name, parent: e.parent, x: e.at[0], y: e.at[1], ...(e.inherit === undefined ? {} : { inherit: e.inherit }) });
|
|
948
963
|
if (e.tip !== undefined) {
|
|
949
964
|
if (same(e.at, e.tip)) {
|
|
950
965
|
fail(
|
|
@@ -1103,9 +1118,10 @@ export function buildRig(
|
|
|
1103
1118
|
// rigc's line (`ctlRemedies`, src/build.ts).
|
|
1104
1119
|
refuseIfAny(problems);
|
|
1105
1120
|
for (const k of controls) {
|
|
1106
|
-
const b = B.get(k) as Bone;
|
|
1121
|
+
const { inherit, ...b } = B.get(k) as Bone;
|
|
1107
1122
|
const ctl = `${k}${CONTROL_SUFFIX}`;
|
|
1108
|
-
|
|
1123
|
+
// Issue #348: the declared mode belongs to the bone hanging from the declared parent, which is now the control.
|
|
1124
|
+
B.set(ctl, { name: ctl, parent: b.parent, x: b.x, y: b.y, ...(inherit === undefined ? {} : { inherit }) });
|
|
1109
1125
|
B.set(k, { ...b, parent: ctl });
|
|
1110
1126
|
}
|
|
1111
1127
|
moveKeysToControls(motion, controls);
|
|
@@ -1197,11 +1213,22 @@ export function buildRig(
|
|
|
1197
1213
|
const inherits = a === undefined && !faceRoots.has(b.name) && faceFramed.has(b.parent);
|
|
1198
1214
|
if (faceRoots.has(b.name) || inherits) faceFramed.add(b.name);
|
|
1199
1215
|
const target = a?.rotation ?? faceTurn.get(b.name) ?? (inherits ? up : 0);
|
|
1200
|
-
const rotation = places(normaliseDegrees(target - up));
|
|
1201
|
-
turn.set(b.name, places(up + rotation));
|
|
1202
1216
|
const frame = computeExactFrameTransforms(bones).get(b.parent) as BoneTransform;
|
|
1203
1217
|
const [x, y] = toBoneLocal(frame, spineX(b.x), spineY(b.y));
|
|
1204
|
-
|
|
1218
|
+
if (b.inherit === undefined) {
|
|
1219
|
+
const rotation = places(normaliseDegrees(target - up));
|
|
1220
|
+
turn.set(b.name, places(up + rotation));
|
|
1221
|
+
bones.push({ name: b.name, parent: b.parent, ...(a === undefined ? {} : { length: a.length }), ...(a !== undefined || rotation !== 0 ? { rotation } : {}), x: places(x), y: places(y) });
|
|
1222
|
+
continue;
|
|
1223
|
+
}
|
|
1224
|
+
// Issue #348: where rig-c's poser puts the bone with no rotation of its own, under its declared mode — the
|
|
1225
|
+
// parent's turn under `normal` and the two scale modes, 0 where the mode drops the parent's rotation — and the
|
|
1226
|
+
// turn from there to the target; its world turn is then read back off the same poser, not summed.
|
|
1227
|
+
const placedAt = { name: b.name, parent: b.parent, x: places(x), y: places(y), inherit: b.inherit };
|
|
1228
|
+
const posed = (r: number): BoneTransform => computeExactFrameTransforms([...bones, { ...placedAt, rotation: r }]).get(b.name) as BoneTransform;
|
|
1229
|
+
const rotation = places(normaliseDegrees(target - normaliseDegrees(posed(0).worldRotation)));
|
|
1230
|
+
turn.set(b.name, places(normaliseDegrees(posed(rotation).worldRotation)));
|
|
1231
|
+
bones.push({ name: b.name, parent: b.parent, ...(a === undefined ? {} : { length: a.length }), ...(a !== undefined || rotation !== 0 ? { rotation } : {}), x: places(x), y: places(y), inherit: b.inherit });
|
|
1205
1232
|
}
|
|
1206
1233
|
// ---- the head's up axis (issue #250) ----------------------------------
|
|
1207
1234
|
// `head` keeps its frame, so every key on it and under it — the idle, the blink's brow drop, an authored
|
|
@@ -1892,7 +1919,15 @@ export function flattenRig(rig: RigSpec, sense: 1 | -1 = 1): RigSpec {
|
|
|
1892
1919
|
const read = rig.bones.map((b) => ({ ...b, rotation: (b.rotation ?? 0) * sense }));
|
|
1893
1920
|
const world = computeExactFrameTransforms(read);
|
|
1894
1921
|
const turn = new Map<string, number>();
|
|
1895
|
-
|
|
1922
|
+
// A bone with a declared inherit mode (issue #348) is read off rig-c's poser, which honours the mode; every other is the sum, as before.
|
|
1923
|
+
for (const b of rig.bones) {
|
|
1924
|
+
turn.set(
|
|
1925
|
+
b.name,
|
|
1926
|
+
b.inherit === undefined
|
|
1927
|
+
? placed((b.parent === undefined ? 0 : (turn.get(b.parent) as number)) + (b.rotation ?? 0), OFFSET_PLACES)
|
|
1928
|
+
: placed(normaliseDegrees((world.get(b.name) as BoneTransform).worldRotation), OFFSET_PLACES),
|
|
1929
|
+
);
|
|
1930
|
+
}
|
|
1896
1931
|
const origin = (bone: string): BoneTransform => world.get(bone) as BoneTransform;
|
|
1897
1932
|
const out = (m: BoneTransform, x: number, y: number): [number, number] => {
|
|
1898
1933
|
const [wx, wy] = toWorld(m, x, y);
|
|
@@ -1902,7 +1937,7 @@ export function flattenRig(rig: RigSpec, sense: 1 | -1 = 1): RigSpec {
|
|
|
1902
1937
|
const bones: RigBone[] = rig.bones.filter((b) => b.name !== HEAD_UP_BONE).map((b) => {
|
|
1903
1938
|
if (b.parent === undefined) return { name: b.name, x: b.x, y: b.y };
|
|
1904
1939
|
const [x, y] = out(origin(b.parent), b.x, b.y);
|
|
1905
|
-
return { name: b.name, parent: b.parent, x, y };
|
|
1940
|
+
return { name: b.name, parent: b.parent, x, y, ...(b.inherit === undefined ? {} : { inherit: b.inherit }) };
|
|
1906
1941
|
});
|
|
1907
1942
|
const slotBone = new Map(rig.slots.map((s) => [s.name, s.bone]));
|
|
1908
1943
|
const skin: RigSpec['skins']['default'] = {};
|
package/src/scene.ts
CHANGED
|
@@ -95,7 +95,7 @@ import { checkStage, gateThroughRigc, type Log } from './build.ts';
|
|
|
95
95
|
import { ACCEPTABLE_BARS, barsNotMet, type CheckReport, DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, type PackMode, requirementsNotPass, type RigcRunner } from './check.ts';
|
|
96
96
|
import { CONSTRAINT_BONE_FIELDS, ROOT_BONE } from './config.ts';
|
|
97
97
|
import { cropToSpineY } from './coords.ts';
|
|
98
|
-
import { PartsError, type Problem,
|
|
98
|
+
import { failLine, PartsError, type Problem, refuseIfAny } from './errors.ts';
|
|
99
99
|
import type { MotionSpec } from './motion.ts';
|
|
100
100
|
import { readPng } from './raster/png.ts';
|
|
101
101
|
import { readRequirements, type RequirementsFile } from './requirements.ts';
|
|
@@ -1353,7 +1353,7 @@ export function composeStage(input: ComposeInput, run: ComposeRunners, log: Log)
|
|
|
1353
1353
|
const refused = (stage: ComposeStage, err: unknown, composed: ComposedScene | null = null): ComposeResult => {
|
|
1354
1354
|
if (!(err instanceof PartsError)) throw err;
|
|
1355
1355
|
const p = prefix(stage);
|
|
1356
|
-
for (const x of err.problems) p(
|
|
1356
|
+
for (const x of err.problems) p(failLine(x));
|
|
1357
1357
|
p(`refused: ${err.problems.length} problem(s)`);
|
|
1358
1358
|
log(`compose: stopped at ${stage}; nothing ${stage === 'compose' ? 'was written' : 'after the check was written'}`);
|
|
1359
1359
|
return { stoppedAt: stage, composed, check: null };
|