@rig-c/parts 1.30.0 → 1.32.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/rig.ts CHANGED
@@ -164,13 +164,14 @@ import {
164
164
  } from './automesh.ts';
165
165
  import { type ContourChainRibs, type ContourReport, contourMesh, type ContourRegion, type ContourRib } from './contour.ts';
166
166
  import { type LocalInfluence, localInfluences } from './localweights.ts';
167
- import { artCoverage, ART_ALPHA, latticeMesh, latticeStray, ONE_LOOP_PASSES } from './mesh.ts';
167
+ import { artCoverage, ART_ALPHA, latticeCells, latticeMesh, latticeStray, ONE_LOOP_PASSES } from './mesh.ts';
168
168
  import { bindFaceSurfaceDeform, BLINK, blinkHoldMisses, blinkSpan, CONTROL_SUFFIX, controlledBones, IDLE_FPS, idleMotion, type MotionSpec, moveKeysToControls } from './motion.ts';
169
- import { PAINTING_RUN, type PartsFile, readFrom, tagOf } from './parts.ts';
169
+ import { type AlternateSetMap, PAINTING_RUN, type PartsFile, readFrom, tagOf } from './parts.ts';
170
170
  import { EYE_GROUP_TAGS } from './propose.ts';
171
171
  import { alphaAbove, crop, pad, type Raster } from './raster/index.ts';
172
172
  import { pyRound } from './round.ts';
173
173
  import { boneHeat, type HeatDust, heatInfluences, HEAT_LIMITS, heatRow, type HeatRow, latticeSilhouette, tracedSilhouette } from './heat.ts';
174
+ import type { PosePoints } from './skeleton.ts';
174
175
  import { applyFaceSurfaceDeform, TURN_BONE, type TurnSpec, turnConstraintName, turnFractions, turnTwin } from './turn.ts';
175
176
  import { DISTANCE_EXPONENT, type FloorForm, type Influence, influences, type InfluenceLimits, type MeshInfluences, meshLimits, type Segment } from './weights.ts';
176
177
 
@@ -896,6 +897,20 @@ export interface AutoMeshReport {
896
897
  motion_report?: unknown;
897
898
  }
898
899
 
900
+ export const SETS_SPEC = 'rig-parts-sets/1';
901
+ export const SETS_FILE = 'sets.json';
902
+
903
+ /**
904
+ * `sets.json` (issue #454): the machine-readable map from each alternate
905
+ * painting set to its `<set>_<part>` attachment on each slot it covers and
906
+ * the slots it hides. Written beside `rig.json` only when `parts.json` carries
907
+ * `alternates`.
908
+ */
909
+ export interface AttachmentSetsFile {
910
+ spec: typeof SETS_SPEC;
911
+ sets: Record<string, AlternateSetMap>;
912
+ }
913
+
899
914
  export interface RigOutput {
900
915
  rig: RigSpec;
901
916
  motion: MotionSpec;
@@ -915,6 +930,95 @@ export interface RigOutput {
915
930
  faceAxis: FaceAxis;
916
931
  /** The turn control's parts (issue #251), in parts.json order; null when the config has no `turn`. */
917
932
  turn: TurnRow[] | null;
933
+ /** `sets.json` (issue #454), present only when `parts.alternates` is non-empty. */
934
+ sets?: AttachmentSetsFile;
935
+ }
936
+
937
+ /**
938
+ * Issue #465: pose a rig's bones so each bone named in `drawn` reaches that
939
+ * world rotation (in Spine degrees, CCW, y up), adjusting its local `rotation`
940
+ * from its parent's posed transform. Bones are walked in parent-first order.
941
+ */
942
+ export function poseRigBonesForDrawn(
943
+ bones: readonly RigBone[],
944
+ drawn: Readonly<Record<string, number>>,
945
+ stops?: ReadonlyArray<{ links: readonly string[] }>,
946
+ ): RigBone[] {
947
+ const undrawn = new Map<string, string>();
948
+ for (const s of stops ?? []) for (const l of s.links) undrawn.set(`${l}_d`, l);
949
+ const realBone = (n: string): string => undrawn.get(n) ?? n;
950
+ const posed: RigBone[] = [];
951
+ const posedRead: RigBone[] = [];
952
+ for (const b of bones) {
953
+ const readB: RigBone = { ...b, ...(b.parent === undefined ? {} : { parent: realBone(b.parent) }) };
954
+ const target = drawn[b.name];
955
+ if (target === undefined) {
956
+ posed.push({ ...b });
957
+ posedRead.push(readB);
958
+ continue;
959
+ }
960
+ const m0 = computeExactFrameTransforms([...posedRead, { ...readB, rotation: 0 }]).get(b.name) as BoneTransform;
961
+ const rotation = places(normaliseDegrees(target - normaliseDegrees(m0.worldRotation)));
962
+ posed.push({ ...b, rotation });
963
+ posedRead.push({ ...readB, rotation });
964
+ }
965
+ return posed;
966
+ }
967
+
968
+ /**
969
+ * Issue #465: derive the alternate's `drawn` bone world rotations (in Spine
970
+ * degrees, CCW, y up) from its body-18 skeleton read (`pts`, in rig pixels, y
971
+ * down) against the base rig's setup bone origins (`B`) and setup world turns
972
+ * (`turn`).
973
+ */
974
+ export function deriveAlternateDrawn(
975
+ pts: PosePoints,
976
+ B: ReadonlyMap<string, { x: number; y: number }>,
977
+ turn: ReadonlyMap<string, number>,
978
+ CX: number,
979
+ H: number,
980
+ ): Record<string, number> {
981
+ const sx = (x: number): number => x - CX;
982
+ const sy = (y: number): number => cropToSpineY(y, H);
983
+ const deg = (a: readonly [number, number], b: readonly [number, number]): number =>
984
+ (Math.atan2(sy(b[1]) - sy(a[1]), sx(b[0]) - sx(a[0])) * 180) / Math.PI;
985
+ const mid = (a: readonly [number, number], b: readonly [number, number]): [number, number] => [
986
+ (a[0] + b[0]) / 2,
987
+ (a[1] + b[1]) / 2,
988
+ ];
989
+ const out: Record<string, number> = {};
990
+ if (B.has('chest') && B.has('hip') && B.has('sleeve_r0') && B.has('sleeve_l0')) {
991
+ const hip = B.get('hip') as { x: number; y: number };
992
+ const sr0 = B.get('sleeve_r0') as { x: number; y: number };
993
+ const sl0 = B.get('sleeve_l0') as { x: number; y: number };
994
+ const setupTrunkAim = deg([hip.x, hip.y], mid([sr0.x, sr0.y], [sl0.x, sl0.y]));
995
+ const altTrunkAim = deg(mid(pts.r_hip, pts.l_hip), mid(pts.r_shoulder, pts.l_shoulder));
996
+ out.chest = places(normaliseDegrees((turn.get('chest') ?? 0) + (altTrunkAim - setupTrunkAim)));
997
+ }
998
+ if (B.has('head') && B.has('eye_r') && B.has('eye_l')) {
999
+ const er = B.get('eye_r') as { x: number; y: number };
1000
+ const el = B.get('eye_l') as { x: number; y: number };
1001
+ const setupEyeAim = deg([er.x, er.y], [el.x, el.y]);
1002
+ const altEyeAim = deg(pts.r_eye, pts.l_eye);
1003
+ out.head = places(normaliseDegrees((turn.get('head') ?? 0) + (altEyeAim - setupEyeAim)));
1004
+ }
1005
+ const limbPairs: ReadonlyArray<readonly [string, readonly [number, number], readonly [number, number]]> = [
1006
+ ['sleeve_r0', pts.r_shoulder, pts.r_elbow],
1007
+ ['sleeve_r1', pts.r_elbow, pts.r_wrist],
1008
+ ['sleeve_l0', pts.l_shoulder, pts.l_elbow],
1009
+ ['sleeve_l1', pts.l_elbow, pts.l_wrist],
1010
+ ['leg_r0', pts.r_hip, pts.r_knee],
1011
+ ['leg_r1', pts.r_knee, pts.r_ankle],
1012
+ ['leg_l0', pts.l_hip, pts.l_knee],
1013
+ ['leg_l1', pts.l_knee, pts.l_ankle],
1014
+ ];
1015
+ for (const [bone, a, b] of limbPairs) {
1016
+ if (B.has(bone)) out[bone] = places(normaliseDegrees(deg(a, b)));
1017
+ }
1018
+ for (const foot of ['foot_r', 'foot_l'] as const) {
1019
+ if (B.has(foot)) out[foot] = places(normaliseDegrees(turn.get(foot) ?? 0));
1020
+ }
1021
+ return out;
918
1022
  }
919
1023
 
920
1024
  /**
@@ -1966,6 +2070,191 @@ export function buildRig(
1966
2070
  });
1967
2071
  }
1968
2072
  refuseIfAny(problems);
2073
+ // ---- alternate painting sets (issues #454, #465) ------------------------
2074
+ // Only when `parts.alternates` is present: each alternate set's `<set>_<part>`
2075
+ // attachments are added to `skins.default[<part>]` on the existing bones and
2076
+ // slots before `applyStops` and `applyTurn`, so stopped links and turn twins
2077
+ // carry the alternate attachments alongside the base ones. When the alternate
2078
+ // carries a skeleton read (`alt.skeleton`) or explicit `drawn` angles, its
2079
+ // attachments are bound in the posed bone frames (`altWorld`) and `sets.json`
2080
+ // records `drawn` so a retarget does not pose the art a second time.
2081
+ let sets: AttachmentSetsFile | undefined;
2082
+ if (parts.alternates !== undefined && parts.alternates.length > 0) {
2083
+ const setEntries: Record<string, AlternateSetMap> = {};
2084
+ for (const [altIdx, alt] of parts.alternates.entries()) {
2085
+ const cfgAlt = cfg.alternates?.find((a) => a.name === alt.name);
2086
+ const explicitDrawn = alt.drawn ?? cfgAlt?.drawn;
2087
+ if (explicitDrawn !== undefined) {
2088
+ for (const boneName of Object.keys(explicitDrawn)) {
2089
+ if (!B.has(boneName)) {
2090
+ fail('RIG_NAME_RESOLVES', `config.alternates[${altIdx}].drawn.${boneName}`, `names the bone "${boneName}", which config.bones does not declare`);
2091
+ }
2092
+ }
2093
+ }
2094
+ refuseIfAny(problems);
2095
+ const derived = alt.skeleton === undefined ? undefined : deriveAlternateDrawn(alt.skeleton, B, turn, CX, H);
2096
+ const drawn: Record<string, number> | undefined =
2097
+ derived === undefined && explicitDrawn === undefined
2098
+ ? undefined
2099
+ : {
2100
+ ...(derived ?? {}),
2101
+ ...Object.fromEntries(Object.entries(explicitDrawn ?? {}).map(([k, v]) => [k, places(normaliseDegrees(v))])),
2102
+ };
2103
+ const posedBones = drawn === undefined ? null : poseRigBonesForDrawn(bones, drawn);
2104
+ const altWorld = posedBones === null ? world : computeExactFrameTransforms(posedBones);
2105
+ const altTurn = new Map<string, number>();
2106
+ if (posedBones !== null) {
2107
+ for (const b of posedBones) {
2108
+ altTurn.set(b.name, places(normaliseDegrees((altWorld.get(b.name) as BoneTransform).worldRotation)));
2109
+ }
2110
+ } else {
2111
+ for (const [k, v] of turn) altTurn.set(k, v);
2112
+ }
2113
+ const altRegion = (image: string, bone: string, cx: number, cy: number): RegionAttachment => {
2114
+ const [x, y] = toBoneLocal(altWorld.get(bone) as BoneTransform, spineX(cx), spineY(cy));
2115
+ const deg = altTurn.get(bone) as number;
2116
+ return deg === 0 ? { image, x: places(x), y: places(y) } : { image, x: places(x), y: places(y), rotation: places(normaliseDegrees(-deg)) };
2117
+ };
2118
+ for (const p of alt.records) {
2119
+ const attName = `${alt.name}_${p.name}`;
2120
+ const rawImg = images.get(attName);
2121
+ if (rawImg === undefined) continue;
2122
+ const img = pad(rawImg, PAD, PAD, PAD, PAD, [0, 0, 0, 0]);
2123
+ const file = `${attName}.png`;
2124
+ outImages.push([file, img]);
2125
+ const mesh = cfg.meshes[p.name];
2126
+ if (mesh === undefined) {
2127
+ const bone = B.get(cfg.regions[p.name]) as Bone;
2128
+ skin[p.name][attName] = altRegion(file, bone.name, p.x + p.w / 2, p.y + p.h / 2);
2129
+ continue;
2130
+ }
2131
+ const fullSegs = meshSegments.get(p.name) as Segment[];
2132
+ const baseAtt = skin[p.name]?.[p.name];
2133
+ const baseBones = baseAtt !== undefined && 'type' in baseAtt ? new Set(baseAtt.weights.flatMap((v) => v.map((e) => e.bone))) : null;
2134
+ const filteredSegs = baseBones !== null && baseBones.size > 0 ? fullSegs.filter((s) => baseBones.has(s.bone)) : fullSegs;
2135
+ let segs = filteredSegs.length > 0 ? filteredSegs : fullSegs;
2136
+ if (drawn !== undefined) {
2137
+ const movePt = (bone: string, pt0: Point): Point => {
2138
+ const m0 = world.get(bone) as BoneTransform;
2139
+ const m1 = altWorld.get(bone) as BoneTransform;
2140
+ const [lx, ly] = toBoneLocal(m0, spineX(pt0[0]), spineY(pt0[1]));
2141
+ const [wx, wy] = toWorld(m1, lx, ly);
2142
+ return [wx + CX, cropToSpineY(wy, H)];
2143
+ };
2144
+ const pt = (q: readonly [number, number]): Point => [q[0], q[1]];
2145
+ const skSegs: Record<string, [Point, Point]> | null =
2146
+ alt.skeleton === undefined
2147
+ ? null
2148
+ : {
2149
+ sleeve_r0: [pt(alt.skeleton.r_shoulder), pt(alt.skeleton.r_elbow)],
2150
+ sleeve_r1: [pt(alt.skeleton.r_elbow), pt(alt.skeleton.r_wrist)],
2151
+ sleeve_l0: [pt(alt.skeleton.l_shoulder), pt(alt.skeleton.l_elbow)],
2152
+ sleeve_l1: [pt(alt.skeleton.l_elbow), pt(alt.skeleton.l_wrist)],
2153
+ leg_r0: [pt(alt.skeleton.r_hip), pt(alt.skeleton.r_knee)],
2154
+ leg_r1: [pt(alt.skeleton.r_knee), pt(alt.skeleton.r_ankle)],
2155
+ leg_l0: [pt(alt.skeleton.l_hip), pt(alt.skeleton.l_knee)],
2156
+ leg_l1: [pt(alt.skeleton.l_knee), pt(alt.skeleton.l_ankle)],
2157
+ };
2158
+ segs = segs.map((s) => {
2159
+ const skPair = skSegs?.[s.bone];
2160
+ return skPair !== undefined ? { bone: s.bone, a: skPair[0], b: skPair[1] } : { bone: s.bone, a: movePt(s.bone, s.a), b: movePt(s.bone, s.b) };
2161
+ });
2162
+ const nonSkirt = segs.filter((s) => !hipChainLinks.has(s.bone));
2163
+ if (nonSkirt.length > 0) segs = nonSkirt;
2164
+ const yOverlapping = segs.filter((s) => Math.max(s.a[1], s.b[1]) >= p.y && Math.min(s.a[1], s.b[1]) <= p.y + p.h);
2165
+ segs = yOverlapping.length > 0 ? yOverlapping : [segs[0]];
2166
+ }
2167
+ const allArt = alphaAbove(img, ART_ALPHA);
2168
+ const strayVal = 'auto' in mesh ? mesh.auto.source.stray : ('contour' in mesh ? mesh.contour.stray : mesh.stray);
2169
+ const stray = strayVal === undefined ? null : latticeStray(allArt, strayVal);
2170
+ const art = stray === null ? allArt : stray.art;
2171
+ const grid = 'grid' in mesh ? mesh.grid : 28;
2172
+ const r = 'r' in mesh ? mesh.r : 80;
2173
+ const lm = latticeMesh(attName, art, grid, maxLoopPasses);
2174
+ if ('code' in lm) {
2175
+ skin[p.name][attName] = altRegion(file, segs[0].bone, p.x + p.w / 2, p.y + p.h / 2);
2176
+ continue;
2177
+ }
2178
+ const ox = p.x - PAD;
2179
+ const oy = p.y - PAD;
2180
+ const w = img.width;
2181
+ const h = img.height;
2182
+ const limits: InfluenceLimits = meshLimits('influences' in mesh ? mesh.influences : undefined);
2183
+ const segsAt = dressSegsAt(p, segs);
2184
+ const exponent = mesh.exponent ?? DISTANCE_EXPONENT;
2185
+ let touchesArt: boolean[] | null = null;
2186
+ if (drawn !== undefined && limits.maxInfluences === 1) {
2187
+ const lc = latticeCells(art, grid);
2188
+ touchesArt = lm.vertices.map(([vx0, vy0]) => {
2189
+ const ii = Math.round(vx0 / grid);
2190
+ const jj = Math.round(vy0 / grid);
2191
+ for (const di of [-1, 0]) {
2192
+ for (const dj of [-1, 0]) {
2193
+ const ci = ii + di;
2194
+ const cj = jj + dj;
2195
+ if (ci >= 0 && ci < lc.nx && cj >= 0 && cj < lc.ny && lc.cells.data[cj * lc.nx + ci] === 1) return true;
2196
+ }
2197
+ }
2198
+ return false;
2199
+ });
2200
+ }
2201
+ const rawShares: Influence[][] = [];
2202
+ for (const [vx0, vy0] of lm.vertices) {
2203
+ const wx = vx0 + ox;
2204
+ const wy = vy0 + oy;
2205
+ const vSegs = segsAt(wy);
2206
+ let shares = influences([wx, wy], vSegs, r, limits, exponent);
2207
+ if (shares.length === 0) {
2208
+ shares = influences([wx, wy], vSegs, Infinity, { maxInfluences: 1, minWeight: 0 }, exponent);
2209
+ }
2210
+ rawShares.push(limits.floor === 'continuous' ? roundShares(shares) : latticeShares(shares));
2211
+ }
2212
+ if (touchesArt !== null) {
2213
+ let firstArtBone: string | null = null;
2214
+ for (let vi = 0; vi < lm.vertices.length; vi++) {
2215
+ if (touchesArt[vi] && rawShares[vi].length > 0) {
2216
+ firstArtBone = rawShares[vi][0].bone;
2217
+ break;
2218
+ }
2219
+ }
2220
+ if (firstArtBone !== null) {
2221
+ for (let vi = 0; vi < lm.vertices.length; vi++) {
2222
+ if (!touchesArt[vi]) rawShares[vi] = [{ bone: firstArtBone, weight: 1 }];
2223
+ }
2224
+ }
2225
+ }
2226
+ const weights: WeightEntry[][] = [];
2227
+ for (const [vi, [vx0, vy0]] of lm.vertices.entries()) {
2228
+ const wx = vx0 + ox;
2229
+ const wy = vy0 + oy;
2230
+ const ent: WeightEntry[] = rawShares[vi].map(({ bone, weight }) => {
2231
+ const [x, y] = toBoneLocal(altWorld.get(bone) as BoneTransform, spineX(wx), spineY(wy));
2232
+ return { bone, x: places(x), y: places(y), weight };
2233
+ });
2234
+ weights.push(ent);
2235
+ }
2236
+ const uvs: number[] = [];
2237
+ for (const [x, y] of lm.vertices) uvs.push(pyRound(x / w, 6), pyRound(y / h, 6));
2238
+ skin[p.name][attName] = { type: 'mesh', image: file, width: w, height: h, uvs, triangles: lm.triangles, hull: lm.hull, weights };
2239
+ }
2240
+ const slotMap: Record<string, string> = {};
2241
+ for (const [slotName, att] of Object.entries(alt.slots)) slotMap[slotName] = att;
2242
+ const hideList: string[] = [];
2243
+ for (const h of alt.hides ?? []) {
2244
+ if (!hideList.includes(h)) hideList.push(h);
2245
+ if (stills[h] !== undefined && !hideList.includes(`${h}${STILL_SUFFIX}`)) hideList.push(`${h}${STILL_SUFFIX}`);
2246
+ }
2247
+ for (const slotName of Object.keys(alt.slots)) {
2248
+ if (stills[slotName] !== undefined && !hideList.includes(`${slotName}${STILL_SUFFIX}`)) hideList.push(`${slotName}${STILL_SUFFIX}`);
2249
+ }
2250
+ setEntries[alt.name] = {
2251
+ slots: slotMap,
2252
+ ...(hideList.length > 0 ? { hides: hideList } : {}),
2253
+ ...(drawn === undefined ? {} : { drawn }),
2254
+ };
2255
+ }
2256
+ sets = { spec: SETS_SPEC, sets: setEntries };
2257
+ }
1969
2258
  // ---- leg stops (issue #404) --------------------------------------------
1970
2259
  // Only when the config has `stops`: each link after the first (and any bone hanging from a stopped link) hangs
1971
2260
  // from the drawn link `<link>_d` above it, and slots and weights on a stopped link bind `<link>_d`.
@@ -2007,7 +2296,20 @@ export function buildRig(
2007
2296
  overrides: cfg.turn?.deform,
2008
2297
  });
2009
2298
  });
2010
- return { rig, motion, meshReport, images: outImages, controls, meshKeyed, idleKeys, loopPasses, autoMotion, faceAxis, turn: turned?.rows ?? null };
2299
+ return {
2300
+ rig,
2301
+ motion,
2302
+ meshReport,
2303
+ images: outImages,
2304
+ controls,
2305
+ meshKeyed,
2306
+ idleKeys,
2307
+ loopPasses,
2308
+ autoMotion,
2309
+ faceAxis,
2310
+ turn: turned?.rows ?? null,
2311
+ ...(sets === undefined ? {} : { sets }),
2312
+ };
2011
2313
  }
2012
2314
 
2013
2315
  /**