@rig-c/parts 1.31.0 → 1.33.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,14 +164,15 @@ 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
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 { applyFaceSurfaceDeform, TURN_BONE, type TurnSpec, turnConstraintName, turnFractions, turnTwin } from './turn.ts';
174
+ import type { PosePoints } from './skeleton.ts';
175
+ import { applyFaceSurfaceDeform, FACE_RATED_BAND, 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
 
177
178
  /** Transparent pixels added round every part image — the reference's `PAD`. */
@@ -907,6 +908,7 @@ export const SETS_FILE = 'sets.json';
907
908
  */
908
909
  export interface AttachmentSetsFile {
909
910
  spec: typeof SETS_SPEC;
911
+ threshold?: { yaw: number; pitch: number };
910
912
  sets: Record<string, AlternateSetMap>;
911
913
  }
912
914
 
@@ -933,6 +935,93 @@ export interface RigOutput {
933
935
  sets?: AttachmentSetsFile;
934
936
  }
935
937
 
938
+ /**
939
+ * Issue #465: pose a rig's bones so each bone named in `drawn` reaches that
940
+ * world rotation (in Spine degrees, CCW, y up), adjusting its local `rotation`
941
+ * from its parent's posed transform. Bones are walked in parent-first order.
942
+ */
943
+ export function poseRigBonesForDrawn(
944
+ bones: readonly RigBone[],
945
+ drawn: Readonly<Record<string, number>>,
946
+ stops?: ReadonlyArray<{ links: readonly string[] }>,
947
+ ): RigBone[] {
948
+ const undrawn = new Map<string, string>();
949
+ for (const s of stops ?? []) for (const l of s.links) undrawn.set(`${l}_d`, l);
950
+ const realBone = (n: string): string => undrawn.get(n) ?? n;
951
+ const posed: RigBone[] = [];
952
+ const posedRead: RigBone[] = [];
953
+ for (const b of bones) {
954
+ const readB: RigBone = { ...b, ...(b.parent === undefined ? {} : { parent: realBone(b.parent) }) };
955
+ const target = drawn[b.name];
956
+ if (target === undefined) {
957
+ posed.push({ ...b });
958
+ posedRead.push(readB);
959
+ continue;
960
+ }
961
+ const m0 = computeExactFrameTransforms([...posedRead, { ...readB, rotation: 0 }]).get(b.name) as BoneTransform;
962
+ const rotation = places(normaliseDegrees(target - normaliseDegrees(m0.worldRotation)));
963
+ posed.push({ ...b, rotation });
964
+ posedRead.push({ ...readB, rotation });
965
+ }
966
+ return posed;
967
+ }
968
+
969
+ /**
970
+ * Issue #465: derive the alternate's `drawn` bone world rotations (in Spine
971
+ * degrees, CCW, y up) from its body-18 skeleton read (`pts`, in rig pixels, y
972
+ * down) against the base rig's setup bone origins (`B`) and setup world turns
973
+ * (`turn`).
974
+ */
975
+ export function deriveAlternateDrawn(
976
+ pts: PosePoints,
977
+ B: ReadonlyMap<string, { x: number; y: number }>,
978
+ turn: ReadonlyMap<string, number>,
979
+ CX: number,
980
+ H: number,
981
+ ): Record<string, number> {
982
+ const sx = (x: number): number => x - CX;
983
+ const sy = (y: number): number => cropToSpineY(y, H);
984
+ const deg = (a: readonly [number, number], b: readonly [number, number]): number =>
985
+ (Math.atan2(sy(b[1]) - sy(a[1]), sx(b[0]) - sx(a[0])) * 180) / Math.PI;
986
+ const mid = (a: readonly [number, number], b: readonly [number, number]): [number, number] => [
987
+ (a[0] + b[0]) / 2,
988
+ (a[1] + b[1]) / 2,
989
+ ];
990
+ const out: Record<string, number> = {};
991
+ if (B.has('chest') && B.has('hip') && B.has('sleeve_r0') && B.has('sleeve_l0')) {
992
+ const hip = B.get('hip') as { x: number; y: number };
993
+ const sr0 = B.get('sleeve_r0') as { x: number; y: number };
994
+ const sl0 = B.get('sleeve_l0') as { x: number; y: number };
995
+ const setupTrunkAim = deg([hip.x, hip.y], mid([sr0.x, sr0.y], [sl0.x, sl0.y]));
996
+ const altTrunkAim = deg(mid(pts.r_hip, pts.l_hip), mid(pts.r_shoulder, pts.l_shoulder));
997
+ out.chest = places(normaliseDegrees((turn.get('chest') ?? 0) + (altTrunkAim - setupTrunkAim)));
998
+ }
999
+ if (B.has('head') && B.has('eye_r') && B.has('eye_l')) {
1000
+ const er = B.get('eye_r') as { x: number; y: number };
1001
+ const el = B.get('eye_l') as { x: number; y: number };
1002
+ const setupEyeAim = deg([er.x, er.y], [el.x, el.y]);
1003
+ const altEyeAim = deg(pts.r_eye, pts.l_eye);
1004
+ out.head = places(normaliseDegrees((turn.get('head') ?? 0) + (altEyeAim - setupEyeAim)));
1005
+ }
1006
+ const limbPairs: ReadonlyArray<readonly [string, readonly [number, number], readonly [number, number]]> = [
1007
+ ['sleeve_r0', pts.r_shoulder, pts.r_elbow],
1008
+ ['sleeve_r1', pts.r_elbow, pts.r_wrist],
1009
+ ['sleeve_l0', pts.l_shoulder, pts.l_elbow],
1010
+ ['sleeve_l1', pts.l_elbow, pts.l_wrist],
1011
+ ['leg_r0', pts.r_hip, pts.r_knee],
1012
+ ['leg_r1', pts.r_knee, pts.r_ankle],
1013
+ ['leg_l0', pts.l_hip, pts.l_knee],
1014
+ ['leg_l1', pts.l_knee, pts.l_ankle],
1015
+ ];
1016
+ for (const [bone, a, b] of limbPairs) {
1017
+ if (B.has(bone)) out[bone] = places(normaliseDegrees(deg(a, b)));
1018
+ }
1019
+ for (const foot of ['foot_r', 'foot_l'] as const) {
1020
+ if (B.has(foot)) out[foot] = places(normaliseDegrees(turn.get(foot) ?? 0));
1021
+ }
1022
+ return out;
1023
+ }
1024
+
936
1025
  /**
937
1026
  * `RIG_BLINK_HOLD_SPANS_A_FRAME` (issue #32): the eyes' hold must put an
938
1027
  * idle frame inside the closed window for every `blink.t`, or the idle
@@ -1982,15 +2071,51 @@ export function buildRig(
1982
2071
  });
1983
2072
  }
1984
2073
  refuseIfAny(problems);
1985
- // ---- alternate painting sets (issue #454) -------------------------------
2074
+ // ---- alternate painting sets (issues #454, #465) ------------------------
1986
2075
  // Only when `parts.alternates` is present: each alternate set's `<set>_<part>`
1987
2076
  // attachments are added to `skins.default[<part>]` on the existing bones and
1988
2077
  // slots before `applyStops` and `applyTurn`, so stopped links and turn twins
1989
- // carry the alternate attachments alongside the base ones.
2078
+ // carry the alternate attachments alongside the base ones. When the alternate
2079
+ // carries a skeleton read (`alt.skeleton`) or explicit `drawn` angles, its
2080
+ // attachments are bound in the posed bone frames (`altWorld`) and `sets.json`
2081
+ // records `drawn` so a retarget does not pose the art a second time.
1990
2082
  let sets: AttachmentSetsFile | undefined;
1991
2083
  if (parts.alternates !== undefined && parts.alternates.length > 0) {
1992
2084
  const setEntries: Record<string, AlternateSetMap> = {};
1993
- for (const alt of parts.alternates) {
2085
+ for (const [altIdx, alt] of parts.alternates.entries()) {
2086
+ const cfgAlt = cfg.alternates?.find((a) => a.name === alt.name);
2087
+ const explicitDrawn = alt.drawn ?? cfgAlt?.drawn;
2088
+ if (explicitDrawn !== undefined) {
2089
+ for (const boneName of Object.keys(explicitDrawn)) {
2090
+ if (!B.has(boneName)) {
2091
+ fail('RIG_NAME_RESOLVES', `config.alternates[${altIdx}].drawn.${boneName}`, `names the bone "${boneName}", which config.bones does not declare`);
2092
+ }
2093
+ }
2094
+ }
2095
+ refuseIfAny(problems);
2096
+ const derived = alt.skeleton === undefined ? undefined : deriveAlternateDrawn(alt.skeleton, B, turn, CX, H);
2097
+ const drawn: Record<string, number> | undefined =
2098
+ derived === undefined && explicitDrawn === undefined
2099
+ ? undefined
2100
+ : {
2101
+ ...(derived ?? {}),
2102
+ ...Object.fromEntries(Object.entries(explicitDrawn ?? {}).map(([k, v]) => [k, places(normaliseDegrees(v))])),
2103
+ };
2104
+ const posedBones = drawn === undefined ? null : poseRigBonesForDrawn(bones, drawn);
2105
+ const altWorld = posedBones === null ? world : computeExactFrameTransforms(posedBones);
2106
+ const altTurn = new Map<string, number>();
2107
+ if (posedBones !== null) {
2108
+ for (const b of posedBones) {
2109
+ altTurn.set(b.name, places(normaliseDegrees((altWorld.get(b.name) as BoneTransform).worldRotation)));
2110
+ }
2111
+ } else {
2112
+ for (const [k, v] of turn) altTurn.set(k, v);
2113
+ }
2114
+ const altRegion = (image: string, bone: string, cx: number, cy: number): RegionAttachment => {
2115
+ const [x, y] = toBoneLocal(altWorld.get(bone) as BoneTransform, spineX(cx), spineY(cy));
2116
+ const deg = altTurn.get(bone) as number;
2117
+ return deg === 0 ? { image, x: places(x), y: places(y) } : { image, x: places(x), y: places(y), rotation: places(normaliseDegrees(-deg)) };
2118
+ };
1994
2119
  for (const p of alt.records) {
1995
2120
  const attName = `${alt.name}_${p.name}`;
1996
2121
  const rawImg = images.get(attName);
@@ -2001,14 +2126,45 @@ export function buildRig(
2001
2126
  const mesh = cfg.meshes[p.name];
2002
2127
  if (mesh === undefined) {
2003
2128
  const bone = B.get(cfg.regions[p.name]) as Bone;
2004
- skin[p.name][attName] = region(file, bone.name, p.x + p.w / 2, p.y + p.h / 2);
2129
+ skin[p.name][attName] = altRegion(file, bone.name, p.x + p.w / 2, p.y + p.h / 2);
2005
2130
  continue;
2006
2131
  }
2007
2132
  const fullSegs = meshSegments.get(p.name) as Segment[];
2008
2133
  const baseAtt = skin[p.name]?.[p.name];
2009
2134
  const baseBones = baseAtt !== undefined && 'type' in baseAtt ? new Set(baseAtt.weights.flatMap((v) => v.map((e) => e.bone))) : null;
2010
2135
  const filteredSegs = baseBones !== null && baseBones.size > 0 ? fullSegs.filter((s) => baseBones.has(s.bone)) : fullSegs;
2011
- const segs = filteredSegs.length > 0 ? filteredSegs : fullSegs;
2136
+ let segs = filteredSegs.length > 0 ? filteredSegs : fullSegs;
2137
+ if (drawn !== undefined) {
2138
+ const movePt = (bone: string, pt0: Point): Point => {
2139
+ const m0 = world.get(bone) as BoneTransform;
2140
+ const m1 = altWorld.get(bone) as BoneTransform;
2141
+ const [lx, ly] = toBoneLocal(m0, spineX(pt0[0]), spineY(pt0[1]));
2142
+ const [wx, wy] = toWorld(m1, lx, ly);
2143
+ return [wx + CX, cropToSpineY(wy, H)];
2144
+ };
2145
+ const pt = (q: readonly [number, number]): Point => [q[0], q[1]];
2146
+ const skSegs: Record<string, [Point, Point]> | null =
2147
+ alt.skeleton === undefined
2148
+ ? null
2149
+ : {
2150
+ sleeve_r0: [pt(alt.skeleton.r_shoulder), pt(alt.skeleton.r_elbow)],
2151
+ sleeve_r1: [pt(alt.skeleton.r_elbow), pt(alt.skeleton.r_wrist)],
2152
+ sleeve_l0: [pt(alt.skeleton.l_shoulder), pt(alt.skeleton.l_elbow)],
2153
+ sleeve_l1: [pt(alt.skeleton.l_elbow), pt(alt.skeleton.l_wrist)],
2154
+ leg_r0: [pt(alt.skeleton.r_hip), pt(alt.skeleton.r_knee)],
2155
+ leg_r1: [pt(alt.skeleton.r_knee), pt(alt.skeleton.r_ankle)],
2156
+ leg_l0: [pt(alt.skeleton.l_hip), pt(alt.skeleton.l_knee)],
2157
+ leg_l1: [pt(alt.skeleton.l_knee), pt(alt.skeleton.l_ankle)],
2158
+ };
2159
+ segs = segs.map((s) => {
2160
+ const skPair = skSegs?.[s.bone];
2161
+ 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) };
2162
+ });
2163
+ const nonSkirt = segs.filter((s) => !hipChainLinks.has(s.bone));
2164
+ if (nonSkirt.length > 0) segs = nonSkirt;
2165
+ 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);
2166
+ segs = yOverlapping.length > 0 ? yOverlapping : [segs[0]];
2167
+ }
2012
2168
  const allArt = alphaAbove(img, ART_ALPHA);
2013
2169
  const strayVal = 'auto' in mesh ? mesh.auto.source.stray : ('contour' in mesh ? mesh.contour.stray : mesh.stray);
2014
2170
  const stray = strayVal === undefined ? null : latticeStray(allArt, strayVal);
@@ -2017,7 +2173,7 @@ export function buildRig(
2017
2173
  const r = 'r' in mesh ? mesh.r : 80;
2018
2174
  const lm = latticeMesh(attName, art, grid, maxLoopPasses);
2019
2175
  if ('code' in lm) {
2020
- skin[p.name][attName] = region(file, segs[0].bone, p.x + p.w / 2, p.y + p.h / 2);
2176
+ skin[p.name][attName] = altRegion(file, segs[0].bone, p.x + p.w / 2, p.y + p.h / 2);
2021
2177
  continue;
2022
2178
  }
2023
2179
  const ox = p.x - PAD;
@@ -2027,6 +2183,22 @@ export function buildRig(
2027
2183
  const limits: InfluenceLimits = meshLimits('influences' in mesh ? mesh.influences : undefined);
2028
2184
  const segsAt = dressSegsAt(p, segs);
2029
2185
  const exponent = mesh.exponent ?? DISTANCE_EXPONENT;
2186
+ let touchesArt: boolean[] | null = null;
2187
+ if (drawn !== undefined && limits.maxInfluences === 1) {
2188
+ const lc = latticeCells(art, grid);
2189
+ touchesArt = lm.vertices.map(([vx0, vy0]) => {
2190
+ const ii = Math.round(vx0 / grid);
2191
+ const jj = Math.round(vy0 / grid);
2192
+ for (const di of [-1, 0]) {
2193
+ for (const dj of [-1, 0]) {
2194
+ const ci = ii + di;
2195
+ const cj = jj + dj;
2196
+ if (ci >= 0 && ci < lc.nx && cj >= 0 && cj < lc.ny && lc.cells.data[cj * lc.nx + ci] === 1) return true;
2197
+ }
2198
+ }
2199
+ return false;
2200
+ });
2201
+ }
2030
2202
  const rawShares: Influence[][] = [];
2031
2203
  for (const [vx0, vy0] of lm.vertices) {
2032
2204
  const wx = vx0 + ox;
@@ -2038,12 +2210,26 @@ export function buildRig(
2038
2210
  }
2039
2211
  rawShares.push(limits.floor === 'continuous' ? roundShares(shares) : latticeShares(shares));
2040
2212
  }
2213
+ if (touchesArt !== null) {
2214
+ let firstArtBone: string | null = null;
2215
+ for (let vi = 0; vi < lm.vertices.length; vi++) {
2216
+ if (touchesArt[vi] && rawShares[vi].length > 0) {
2217
+ firstArtBone = rawShares[vi][0].bone;
2218
+ break;
2219
+ }
2220
+ }
2221
+ if (firstArtBone !== null) {
2222
+ for (let vi = 0; vi < lm.vertices.length; vi++) {
2223
+ if (!touchesArt[vi]) rawShares[vi] = [{ bone: firstArtBone, weight: 1 }];
2224
+ }
2225
+ }
2226
+ }
2041
2227
  const weights: WeightEntry[][] = [];
2042
2228
  for (const [vi, [vx0, vy0]] of lm.vertices.entries()) {
2043
2229
  const wx = vx0 + ox;
2044
2230
  const wy = vy0 + oy;
2045
2231
  const ent: WeightEntry[] = rawShares[vi].map(({ bone, weight }) => {
2046
- const [x, y] = toBoneLocal(world.get(bone) as BoneTransform, spineX(wx), spineY(wy));
2232
+ const [x, y] = toBoneLocal(altWorld.get(bone) as BoneTransform, spineX(wx), spineY(wy));
2047
2233
  return { bone, x: places(x), y: places(y), weight };
2048
2234
  });
2049
2235
  weights.push(ent);
@@ -2062,13 +2248,22 @@ export function buildRig(
2062
2248
  for (const slotName of Object.keys(alt.slots)) {
2063
2249
  if (stills[slotName] !== undefined && !hideList.includes(`${slotName}${STILL_SUFFIX}`)) hideList.push(`${slotName}${STILL_SUFFIX}`);
2064
2250
  }
2251
+ const coversFace = Object.hasOwn(slotMap, 'face');
2065
2252
  setEntries[alt.name] = {
2066
2253
  slots: slotMap,
2067
2254
  ...(hideList.length > 0 ? { hides: hideList } : {}),
2255
+ ...(drawn === undefined ? {} : { drawn }),
2256
+ ...(coversFace && cfg.turn !== undefined ? { threshold: { ...FACE_RATED_BAND } } : {}),
2068
2257
  };
2069
2258
  }
2070
- sets = { spec: SETS_SPEC, sets: setEntries };
2259
+ const anyCoversFace = Object.values(setEntries).some((e) => Object.hasOwn(e.slots, 'face'));
2260
+ sets = {
2261
+ spec: SETS_SPEC,
2262
+ ...(anyCoversFace ? { threshold: { ...FACE_RATED_BAND } } : {}),
2263
+ sets: setEntries,
2264
+ };
2071
2265
  }
2266
+ const hasAlternateFaceSet = sets !== undefined && Object.values(sets.sets).some((e) => Object.hasOwn(e.slots, 'face'));
2072
2267
  // ---- leg stops (issue #404) --------------------------------------------
2073
2268
  // Only when the config has `stops`: each link after the first (and any bone hanging from a stopped link) hangs
2074
2269
  // from the drawn link `<link>_d` above it, and slots and weights on a stopped link bind `<link>_d`.
@@ -2108,7 +2303,15 @@ export function buildRig(
2108
2303
  opaqueWidth: facePart?.w,
2109
2304
  opaqueHeight: facePart?.h,
2110
2305
  overrides: cfg.turn?.deform,
2306
+ hasAlternateFaceSet,
2111
2307
  });
2308
+ if (sets !== undefined && (merged.animations.turn !== undefined || merged.animations.tilt !== undefined)) {
2309
+ for (const entry of Object.values(sets.sets)) {
2310
+ if (Object.hasOwn(entry.slots, 'face')) {
2311
+ entry.threshold = { ...FACE_RATED_BAND };
2312
+ }
2313
+ }
2314
+ }
2112
2315
  });
2113
2316
  return {
2114
2317
  rig,