@rig-c/parts 1.6.0 → 1.8.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
@@ -27,9 +27,45 @@
27
27
  * numbers the reference wrote. A key the turn would re-aim — a translate
28
28
  * key under a turned parent, a scale or shear key on a turned bone — is
29
29
  * refused (`RIG_KEY_FRAME_UNTURNED`); rotate keys mean what they meant.
30
- * A single bone with a `tip` is not turned (its tip is still a segment
31
- * end for the weights); giving it a lever would be the same change on
32
- * that bone and its children, and is not made here.
30
+ * A single bone with a `tip` is turned the same way (issue #239): its
31
+ * `rotation` is the direction from its origin to its tip and its `length`
32
+ * that distance, the segment its weights are already measured along, so a
33
+ * `tip` the config states is never accepted and ignored. A tip on its own
34
+ * origin has no direction and is refused (`RIG_TIP_DISTINCT`).
35
+ * - **The face's axis** (issue #239). The five face bones a blink or an
36
+ * expression scales or translates, {@link FACE_BONES}, take the eye line's
37
+ * angle as their world rotation, so the blink's `scaley` squashes across
38
+ * the eye line and not across the page's horizontal on a tilted head. The
39
+ * line exists when parts.json holds the parts tagged `eyewhite-r` and
40
+ * `eyewhite-l` (the first of each, a cut's piece not counted — the parts
41
+ * `propose` stands `eye_r` and `eye_l` on) and config.bones declares both
42
+ * eye bones; it runs through the two eye bones' origins, read from the
43
+ * image-left one to the image-right one, so the angle is always the
44
+ * measured one, inside (−90°, 90°), which tag is which side does not enter
45
+ * it, and it is the line rigc's A51 draws. With one eyewhite or none, an
46
+ * eye bone missing, or two eye bones in one column, there is no line:
47
+ * {@link FaceAxis} says why and the face bones
48
+ * keep the page's axes. A face bone with its own `tip` takes the tip. A
49
+ * bone under a face bone that nothing else turns keeps its parent's axis,
50
+ * so a key on it means the face's axis too; the face bones' controls carry
51
+ * the turn as a link's do. Regions on these bones are turned back as every
52
+ * region is, so the setup pose is the same picture; a scale key on a face
53
+ * bone, and a translate key on a bone under one, mean the face's axes and
54
+ * are not refused. A translate key on a face bone itself still moves it
55
+ * along its parent's (the head's) axes — Spine's rule, which turning the
56
+ * bone does not change. With a line the rig spec also declares
57
+ * `invariants.axes` (rig-c 2.40.0): one entry, {@link FACE_AXIS_NAME},
58
+ * through `eye_r` and `eye_l`, holding the turned bones and their controls,
59
+ * which rigc's `A51_BONES_LIE_ALONG_THEIR_DECLARED_AXIS` checks. Its
60
+ * tolerance is measured on the bones as rigc emits them (see where it is
61
+ * written), the float floor; with no line no entry is written. A face bone
62
+ * with its own `tip` along the line is held too (issue #258), in a second
63
+ * entry, {@link FACE_TIPS_AXIS_NAME}: one whose tip lies within the
64
+ * whole-pixel rounding bound ({@link tipRoundingBound}) of the line is
65
+ * listed, its tolerance measured the same way, and one further off is its
66
+ * own axis, named with its gap in `FaceAxis.tipOff` — never silent. The
67
+ * roll the face bones stand on has one derivation, `src/headframe.ts`'s
68
+ * `eyeLineDegrees`, which `propose`'s head frame reads too (issue #250).
33
69
  * - **Meshes** over the parts named in `meshes`: a square lattice (`src/mesh.ts`)
34
70
  * weighted by distance to the mesh's candidate segments (`src/weights.ts`),
35
71
  * written in rigc's by-name `weights` form. The slot's bone is the first
@@ -84,6 +120,8 @@
84
120
  import { type AutoSpec, type BoneEntry, type WeightRule, type CharacterConfig, type ConfigConstraint, CONSTRAINT_FOLLOWS, constraintForRig, type ContourSpec, type Point, type RibsSpec, ROOT_BONE, weightsABone } from './config.ts';
85
121
  import { type BoneTransform, computeExactFrameTransforms, cropToSpineY, normaliseDegrees, toBoneLocal, toWorld } from './coords.ts';
86
122
  import { type Problem, refuseIfAny } from './errors.ts';
123
+ import { eyeLineDegrees } from './headframe.ts';
124
+ import { f32 } from 'rig-c/compile';
87
125
  import type { MeshCounts, MeshQualityReport, MeshReductionInput, MotionAmplitude, ReducedMesh, Termination } from 'rig-c/mesh';
88
126
  import { type AmplitudeRow, amplitudeRow, partAmplitude, withAmplitude } from './autoamplitude.ts';
89
127
  import { type ResidualRow, residualRow, slotEnvelope, withSkinning } from './autoenvelope.ts';
@@ -112,7 +150,8 @@ import { type ContourChainRibs, type ContourReport, contourMesh, type ContourReg
112
150
  import { type LocalInfluence, localInfluences } from './localweights.ts';
113
151
  import { artCoverage, ART_ALPHA, latticeMesh, ONE_LOOP_PASSES } from './mesh.ts';
114
152
  import { BLINK, blinkHoldMisses, blinkSpan, CONTROL_SUFFIX, controlledBones, IDLE_FPS, idleMotion, type MotionSpec, moveKeysToControls } from './motion.ts';
115
- import { PAINTING_RUN, type PartsFile, readFrom } from './parts.ts';
153
+ import { PAINTING_RUN, type PartsFile, readFrom, tagOf } from './parts.ts';
154
+ import { EYE_GROUP_TAGS } from './propose.ts';
116
155
  import { alphaAbove, crop, pad, type Raster } from './raster/index.ts';
117
156
  import { pyRound } from './round.ts';
118
157
  import { boneHeat, heatInfluences, heatRow, type HeatRow, latticeSilhouette, tracedSilhouette } from './heat.ts';
@@ -151,6 +190,117 @@ function places(v: number): number {
151
190
  return placed(v, OFFSET_PLACES);
152
191
  }
153
192
 
193
+ /**
194
+ * The face bones that take the face's axis (issue #239): the bones `propose`
195
+ * writes for the eyes, the brows and the mouth, which the blink and an
196
+ * expression scale or translate. Read by name, as the blink's groups name
197
+ * them; a config without one of them has nothing of that name to turn.
198
+ */
199
+ export const FACE_BONES: readonly string[] = ['eye_r', 'eye_l', 'brow_r', 'brow_l', 'mouth'];
200
+
201
+ /** The eye line the face bones are turned to, or why there is none (issue #239). */
202
+ export interface FaceAxis {
203
+ /** The eye line's angle in Spine degrees (counter-clockwise, y up), as written; null with no line. */
204
+ degrees: number | null;
205
+ /** The eyewhite parts that make the line exist, `eyewhite-r`'s first; fewer than two when they are what is missing. */
206
+ parts: string[];
207
+ /** The face bones turned to the line, in {@link FACE_BONES} order; empty with no line. */
208
+ turned: string[];
209
+ /** The face bones a `tip` of their own turns instead, in {@link FACE_BONES} order. */
210
+ ownTip: string[];
211
+ /** Why there is no line, or null. */
212
+ none: string | null;
213
+ /** The `invariants.axes` entry written for the line (issue #239, rig-c's `A51`), or null with the reason in {@link FaceAxis.noAxis}. */
214
+ axis: RigAxis | null;
215
+ /** Why no axes entry was written, or null when one was (or there is no line, which {@link FaceAxis.none} says). */
216
+ noAxis: string | null;
217
+ /** The `invariants.axes` entry holding the face bones whose own tip lies along the line within whole-pixel rounding (issue #258), or null when none does. */
218
+ tipAxis: RigAxis | null;
219
+ /** For each face bone whose own tip points further off the line than whole-pixel rounding allows, why it is not held (issue #258); in {@link FACE_BONES} order. */
220
+ tipOff: string[];
221
+ /** {@link HEAD_UP_BONE} as written (issue #250): its world rotation (the eye line + 90) and its length; null with the reason in {@link FaceAxis.headUpNone}, or with no line. */
222
+ headUp: { rotation: number; length: number } | null;
223
+ /** Why {@link HEAD_UP_BONE} was not written, when there is a line; null otherwise. */
224
+ headUpNone: string | null;
225
+ }
226
+
227
+ /** One `invariants.axes` entry: rig-c's `RigAxis`, held by its gate rule `A51_BONES_LIE_ALONG_THEIR_DECLARED_AXIS` (2.40.0). */
228
+ export interface RigAxis {
229
+ name: string;
230
+ through: [string, string];
231
+ bones: string[];
232
+ tolerance: number;
233
+ }
234
+
235
+ /** The name of the face's `invariants.axes` entry. */
236
+ export const FACE_AXIS_NAME = 'face';
237
+
238
+ /**
239
+ * The slotless bone the rig stage adds under `head` (issue #250): at head's origin, pointing along the face's up axis
240
+ * (the eye line + 90 degrees), as long as the eye midpoint lies from head's origin along it. `head` keeps its own frame,
241
+ * so no key on it or under it changes meaning; a rule that needs the head's frame (issue #249) reads this bone.
242
+ */
243
+ export const HEAD_UP_BONE = 'head_up';
244
+
245
+ /** The name of the `invariants.axes` entry for the face bones whose own tip lies along the eye line (issue #258). */
246
+ export const FACE_TIPS_AXIS_NAME = 'face_tips';
247
+
248
+ /**
249
+ * The most a bone's direction can sit off a line through whole-pixel
250
+ * rounding of its tip alone (issue #258): a tip written in whole pixels is
251
+ * within half a pixel in each coordinate, sqrt(2)/2 px, of the point on the
252
+ * line it stands for, so at `length` px from the origin its direction is
253
+ * within asin((sqrt(2)/2) / length) degrees. Null when the tip is closer than
254
+ * sqrt(2)/2 px, where rounding alone can point it anywhere.
255
+ */
256
+ export function tipRoundingBound(length: number): number | null {
257
+ const s = Math.SQRT1_2 / length;
258
+ return s >= 1 ? null : (Math.asin(s) * 180) / Math.PI;
259
+ }
260
+
261
+ /** One unit in the last place a rotation is written to ({@link OFFSET_PLACES}): the axes entry's tolerance stands this far above the gap it measures. */
262
+ const WRITTEN_UNIT = 10 ** -6;
263
+
264
+ /** Two directions' gap as lines, 0 to 90 degrees — rig-c's `lineGap` (A51), restated here because it is not on an entry this package imports. */
265
+ function lineGap(a: number, b: number): number {
266
+ const d = (((a - b) % 180) + 180) % 180;
267
+ return Math.min(d, 180 - d);
268
+ }
269
+
270
+ /** The bones the face's line runs through: rigc's A51 draws its line through their origins, and the face bones are turned to that same line. */
271
+ export const EYE_BONES: readonly [string, string] = ['eye_r', 'eye_l'];
272
+
273
+ /**
274
+ * The eye line (issue #239). It exists when parts.json holds an eye on each
275
+ * side — the first part tagged `eyewhite-r` and the first tagged `eyewhite-l`
276
+ * (a cut's piece is neither), the parts `propose` stands `eye_r` and `eye_l`
277
+ * on — and config.bones declares both eye bones. Its angle is read off the
278
+ * two eye bones' origins as the config writes them, image-left to
279
+ * image-right, in Spine's axes through the one y flip: the very line rigc's
280
+ * A51 draws through those bones, so the face bones stand on what the gate
281
+ * measures (the eyewhites' unrounded centres would sit up to a rounding off
282
+ * it — demo's half pixel was 0.47 degrees), and a moved eye bone moves the
283
+ * line with it. Null with the reason when an eyewhite or an eye bone is
284
+ * missing, or when the two eye bones share a column, so the line has no
285
+ * left-to-right direction to read.
286
+ */
287
+ export function eyeLine(parts: PartsFile, eyeBones: ReadonlyMap<string, Point>): { degrees: number; parts: [string, string] } | { degrees: null; parts: string[]; none: string } {
288
+ const H = parts.rig_size[1];
289
+ const found = EYE_GROUP_TAGS.map((tag) => ({ tag, part: parts.parts.find((p) => tagOf(p.from) === tag && p.cut_from === undefined) }));
290
+ const have = found.flatMap((f) => (f.part === undefined ? [] : [f.part]));
291
+ if (have.length < 2) {
292
+ const missing = found.filter((f) => f.part === undefined).map((f) => f.tag);
293
+ return { degrees: null, parts: have.map((p) => p.name), none: `parts.json holds no part tagged ${missing.join(' or ')} (a cut's piece is not counted), so there is no eye line` };
294
+ }
295
+ const names = have.map((p) => p.name);
296
+ const absent = EYE_BONES.filter((n) => !eyeBones.has(n));
297
+ if (absent.length > 0) return { degrees: null, parts: names, none: `config.bones declares no single bone ${absent.join(' or ')}, so there is no pair of eye bones for the eye line to run through` };
298
+ const [a, b] = EYE_BONES.map((n) => eyeBones.get(n) as Point);
299
+ if (a[0] === b[0]) return { degrees: null, parts: names, none: `${EYE_BONES[0]} ${pt(a)} and ${EYE_BONES[1]} ${pt(b)} share the column x = ${a[0]}, so the eye line has no left-to-right direction to read` };
300
+ // The one derivation of the roll (issue #250): propose's head frame reads the same function on the same origins.
301
+ return { degrees: eyeLineDegrees(a, b, H), parts: [names[0], names[1]] };
302
+ }
303
+
154
304
  /**
155
305
  * The weights a contour vertex is written with: a vertex no region reaches is
156
306
  * rounded as the lattice rounds (each to 5 places, the last `1 − sum(others)`),
@@ -249,7 +399,7 @@ export interface RigSpec {
249
399
  * the idle keys a mesh-driving bone. `detached`: written only when a
250
400
  * constraint follows a bone ({@link detachedRules}).
251
401
  */
252
- invariants?: { idleDrivesMeshes?: { why: string }; detached?: DetachedRule[]; deformMayFold?: Array<{ slot: string; why: string }> };
402
+ invariants?: { idleDrivesMeshes?: { why: string }; detached?: DetachedRule[]; deformMayFold?: Array<{ slot: string; why: string }>; axes?: RigAxis[] };
253
403
  }
254
404
 
255
405
  /** One `invariants.detached` entry: rig-c's `RigDetachedRule`, checked by its gate rule `A25`. */
@@ -541,6 +691,8 @@ export interface RigOutput {
541
691
  loopPasses: Record<string, number>;
542
692
  /** Per automatic mesh, in parts.json order: what the rig stage's motion gate compares (`src/automotion.ts`). Nothing written. */
543
693
  autoMotion: AutoMotionCase[];
694
+ /** The eye line the face bones were turned to, or why there is none (issue #239). */
695
+ faceAxis: FaceAxis;
544
696
  }
545
697
 
546
698
  /**
@@ -614,6 +766,8 @@ export function buildRig(
614
766
  const chains = new Map<string, string[]>();
615
767
  /** Each chain's points and tip, rig px, as declared (issue #188: the ribs are placed along them). */
616
768
  const chainLines = new Map<string, Point[]>();
769
+ /** The single bones that state a `tip` (issue #239: each is turned towards it). */
770
+ const singleTips = new Set<string>();
617
771
  cfg.bones.forEach((e: BoneEntry, i) => {
618
772
  if ('chain' in e) {
619
773
  const names: string[] = [];
@@ -641,7 +795,17 @@ export function buildRig(
641
795
  chainLines.set(e.chain, polyline);
642
796
  } else {
643
797
  B.set(e.name, { name: e.name, parent: e.parent, x: e.at[0], y: e.at[1] });
644
- if (e.tip !== undefined) tips.set(e.name, [e.tip[0], e.tip[1]]);
798
+ if (e.tip !== undefined) {
799
+ if (same(e.at, e.tip)) {
800
+ fail(
801
+ 'RIG_TIP_DISTINCT',
802
+ `config.bones[${i}] "${e.name}".tip`,
803
+ `is ${pt(e.tip)}, the bone's own origin; a bone is turned from its origin towards its tip (issue #239), and a tip on the origin has no direction — give a tip other than ${pt(e.at)}, or leave the tip out`,
804
+ );
805
+ }
806
+ tips.set(e.name, [e.tip[0], e.tip[1]]);
807
+ singleTips.add(e.name);
808
+ }
645
809
  }
646
810
  });
647
811
 
@@ -781,6 +945,8 @@ export function buildRig(
781
945
  fail('RIG_CONTROL_NAME_FREE', `bone "${k}"`, `is keyed by the idle and weighted to by a mesh, so it needs the control bone "${ctl}", and config.bones already declares a bone of that name`);
782
946
  }
783
947
  }
948
+ // The face's up-axis bone is the rig stage's (issue #250): a config bone of that name would be shadowed.
949
+ if (B.has(HEAD_UP_BONE)) fail('RIG_HEAD_UP_NAME_FREE', `bone "${HEAD_UP_BONE}"`, `is the bone the rig stage adds under head to carry the face's up axis (issue #250), and config.bones already declares a bone of that name; rename it`);
784
950
  // A two-bone ik over a link and its child link stops being one when the
785
951
  // child gets a control: `<child>_ctl` stands between them. rig-c 2.15.0
786
952
  // refuses that pair by name (issue #103); the rig stage adds the flag to
@@ -824,6 +990,42 @@ export function buildRig(
824
990
  if (controls.includes(l)) aim.set(`${l}${CONTROL_SUFFIX}`, a);
825
991
  }
826
992
  }
993
+ // A single bone with a tip points at it the same way, and its control with it (issue #239).
994
+ for (const n of singleTips) {
995
+ const b = B.get(n) as Bone;
996
+ const end = tips.get(n) as Point;
997
+ const dx = spineX(end[0]) - spineX(b.x);
998
+ const dy = spineY(end[1]) - spineY(b.y);
999
+ const a = { rotation: (Math.atan2(dy, dx) * 180) / Math.PI, length: pyRound(Math.sqrt(dx * dx + dy * dy), 3) };
1000
+ aim.set(n, a);
1001
+ if (controls.includes(n)) aim.set(`${n}${CONTROL_SUFFIX}`, a);
1002
+ }
1003
+ // ---- the face's axis (issue #239) --------------------------------------
1004
+ // Each face bone config.bones declares as a single bone with no tip of its own stands on the eye line, and its
1005
+ // control with it; a bone under a face-framed bone that nothing else turns keeps that frame.
1006
+ const eyeAt = new Map<string, Point>();
1007
+ for (const e of cfg.bones) if (!('chain' in e) && (EYE_BONES as readonly string[]).includes(e.name)) eyeAt.set(e.name, [e.at[0], e.at[1]]);
1008
+ const line = eyeLine(parts, eyeAt);
1009
+ const declared = FACE_BONES.filter((n) => cfg.bones.some((e) => !('chain' in e) && e.name === n));
1010
+ const faceAxis: FaceAxis = {
1011
+ degrees: line.degrees,
1012
+ parts: line.parts,
1013
+ turned: line.degrees === null ? [] : declared.filter((n) => !singleTips.has(n)),
1014
+ ownTip: declared.filter((n) => singleTips.has(n)),
1015
+ none: line.degrees === null ? line.none : null,
1016
+ axis: null,
1017
+ noAxis: null,
1018
+ tipAxis: null,
1019
+ tipOff: [],
1020
+ headUp: null,
1021
+ headUpNone: null,
1022
+ };
1023
+ const faceTurn = new Map<string, number>();
1024
+ for (const n of faceAxis.turned) {
1025
+ faceTurn.set(n, line.degrees as number);
1026
+ if (controls.includes(n)) faceTurn.set(`${n}${CONTROL_SUFFIX}`, line.degrees as number);
1027
+ }
1028
+ const faceRoots = new Set(declared.flatMap((n) => (controls.includes(n) ? [n, `${n}${CONTROL_SUFFIX}`] : [n])));
827
1029
  // Each bone's local rotation turns it from its parent's world rotation to
828
1030
  // the one it should have: its chain's direction, or 0 for every other bone,
829
1031
  // so a named bone under a chain link is turned back upright. `turn` is the
@@ -831,6 +1033,8 @@ export function buildRig(
831
1033
  // tree. Each local offset is the bone's origin carried into its parent's
832
1034
  // setup frame as written, through rigc's inverse (`src/coords.ts`).
833
1035
  const turn = new Map<string, number>();
1036
+ /** The bones whose frame is the face's (issue #239): the face bones, their controls, and what hangs under them unturned. */
1037
+ const faceFramed = new Set<string>();
834
1038
  const bones: RigBone[] = [];
835
1039
  for (const b of ordered) {
836
1040
  if (b.parent === null) {
@@ -840,14 +1044,105 @@ export function buildRig(
840
1044
  }
841
1045
  const up = turn.get(b.parent) as number;
842
1046
  const a = aim.get(b.name);
843
- const rotation = places(normaliseDegrees((a?.rotation ?? 0) - up));
1047
+ const inherits = a === undefined && !faceRoots.has(b.name) && faceFramed.has(b.parent);
1048
+ if (faceRoots.has(b.name) || inherits) faceFramed.add(b.name);
1049
+ const target = a?.rotation ?? faceTurn.get(b.name) ?? (inherits ? up : 0);
1050
+ const rotation = places(normaliseDegrees(target - up));
844
1051
  turn.set(b.name, places(up + rotation));
845
1052
  const frame = computeExactFrameTransforms(bones).get(b.parent) as BoneTransform;
846
1053
  const [x, y] = toBoneLocal(frame, spineX(b.x), spineY(b.y));
847
1054
  bones.push({ name: b.name, parent: b.parent, ...(a === undefined ? {} : { length: a.length }), ...(a !== undefined || rotation !== 0 ? { rotation } : {}), x: places(x), y: places(y) });
848
1055
  }
1056
+ // ---- the head's up axis (issue #250) ----------------------------------
1057
+ // `head` keeps its frame, so every key on it and under it — the idle, the blink's brow drop, an authored
1058
+ // animation's scale or translate — means what it meant. The face's frame is carried by one slotless bone under it,
1059
+ // {@link HEAD_UP_BONE}, at head's origin, turned to the eye line's angle + 90 (the same line, {@link eyeLine}) and
1060
+ // as long as the eye midpoint lies from head's origin along that axis: derived from the three bones the config
1061
+ // writes, so it moves with them. A rule that needs the head's frame reads it here; nothing binds to it or keys it.
1062
+ const headEntry = cfg.bones.find((e) => !('chain' in e) && e.name === 'head');
1063
+ if (line.degrees !== null && headEntry !== undefined && !('chain' in headEntry)) {
1064
+ const up = line.degrees + 90;
1065
+ const [eR, eL] = EYE_BONES.map((n) => eyeAt.get(n) as Point);
1066
+ const mid: Point = [(eR[0] + eL[0]) / 2, (eR[1] + eL[1]) / 2];
1067
+ const h = B.get('head') as Bone;
1068
+ const ux = Math.cos((up * Math.PI) / 180);
1069
+ const uy = Math.sin((up * Math.PI) / 180);
1070
+ const reach = (spineX(mid[0]) - spineX(h.x)) * ux + (spineY(mid[1]) - spineY(h.y)) * uy;
1071
+ if (reach <= 0) {
1072
+ faceAxis.headUpNone = `the eye midpoint ${pt(mid)} lies ${pyRound(-reach, 3)} px below head's origin ${pt([h.x, h.y])} along the face's up axis, so ${HEAD_UP_BONE} would have no length toward the crown; not written`;
1073
+ } else {
1074
+ const parentTurn = turn.get('head') as number;
1075
+ const rotation = places(normaliseDegrees(up - parentTurn));
1076
+ const length = pyRound(reach, 3);
1077
+ bones.push({ name: HEAD_UP_BONE, parent: 'head', length, rotation, x: 0, y: 0 });
1078
+ faceAxis.headUp = { rotation: places(parentTurn + rotation), length };
1079
+ }
1080
+ } else if (line.degrees !== null) faceAxis.headUpNone = 'config.bones declares no single bone "head" for it to hang from';
1081
+
849
1082
  const world = computeExactFrameTransforms(bones);
850
1083
 
1084
+ // ---- the face's axes entry (issue #239, rig-c 2.40.0's A51) -------------
1085
+ // The gate draws its line through the eye bones' setup origins, and the face bones were turned to the line through
1086
+ // the same two origins as the config writes them ({@link eyeLine}); what separates the two is only how the numbers
1087
+ // are stored. So the tolerance is measured, not chosen: the largest gap, as lines, between the line through
1088
+ // eye_r -> eye_l and any listed bone's world x axis, in the setup frames of the bones as rigc emits them (each x, y
1089
+ // and rotation through rig-c's own `f32`, the float32 the skeleton stores — 36.869898 is stored 1.6e-6 degrees off,
1090
+ // more than a 6-place unit), rounded up to the places a rotation is written to, plus one unit of that last place —
1091
+ // the float floor of the emitted rig, to the precision this stage writes. A bone turned further off is A51's.
1092
+ // Issue #258: a face bone with its own tip is the author's statement of its axis (#239) and is not turned here, but
1093
+ // a tip written to lie along the eye line is held too, in an entry of its own ({@link FACE_TIPS_AXIS_NAME}), so the
1094
+ // turned bones' entry keeps its float floor. Which tips are meant to lie along the line is read, not chosen: a tip
1095
+ // written in whole pixels sits at most half a pixel off in each coordinate, sqrt(2)/2 px, from the point of the line
1096
+ // it stands for, so its direction can sit up to asin((sqrt(2)/2) / length) degrees off the line through rounding
1097
+ // alone ({@link tipRoundingBound}); a tip within that bone's own bound is held, its entry's tolerance measured as the
1098
+ // turned bones' is, and a tip further off is an axis of its own, said by name in faceAxis.tipOff, never silent.
1099
+ if (line.degrees !== null && (faceAxis.turned.length > 0 || faceAxis.ownTip.length > 0)) {
1100
+ const through: [string, string] = [EYE_BONES[0], EYE_BONES[1]];
1101
+ const emitted = computeExactFrameTransforms(
1102
+ bones.map((bn) => ({ ...bn, x: f32(bn.x), y: f32(bn.y), ...(bn.rotation === undefined ? {} : { rotation: f32(bn.rotation) }) })),
1103
+ );
1104
+ const [a, b] = through.map((n) => emitted.get(n) as BoneTransform);
1105
+ const gateLine = (Math.atan2(b.worldY - a.worldY, b.worldX - a.worldX) * 180) / Math.PI;
1106
+ const gapOf = (n: string): number => {
1107
+ const t = emitted.get(n) as BoneTransform;
1108
+ return lineGap((Math.atan2(t.c, t.a) * 180) / Math.PI, gateLine);
1109
+ };
1110
+ const entry = (name: string, listed: string[]): { axis: RigAxis | null; gap: number } => {
1111
+ const gap = Math.max(...listed.map(gapOf));
1112
+ const tolerance = pyRound(Math.ceil(gap / WRITTEN_UNIT) * WRITTEN_UNIT + WRITTEN_UNIT, OFFSET_PLACES);
1113
+ return { axis: tolerance >= 90 ? null : { name, through, bones: listed, tolerance }, gap };
1114
+ };
1115
+ if (a.worldX === b.worldX && a.worldY === b.worldY) faceAxis.noAxis = `eye_r and eye_l share the world origin (${a.worldX}, ${a.worldY}), so they draw no line`;
1116
+ else {
1117
+ if (faceAxis.turned.length > 0) {
1118
+ const e = entry(FACE_AXIS_NAME, bones.map((bn) => bn.name).filter((n) => faceTurn.has(n)));
1119
+ if (e.axis === null) faceAxis.noAxis = `the face bones lie ${pyRound(e.gap, 6)} degrees off the line through eye_r and eye_l, and rigc takes a tolerance below 90 only`;
1120
+ else faceAxis.axis = e.axis;
1121
+ }
1122
+ const held: string[] = [];
1123
+ for (const n of faceAxis.ownTip) {
1124
+ const length = (aim.get(n) as { length: number }).length;
1125
+ const bound = tipRoundingBound(length);
1126
+ const gap = gapOf(n);
1127
+ if (bound !== null && gap <= bound) {
1128
+ held.push(n);
1129
+ if (controls.includes(n)) held.push(`${n}${CONTROL_SUFFIX}`);
1130
+ } else {
1131
+ faceAxis.tipOff.push(
1132
+ bound === null
1133
+ ? `${n}'s tip is ${length} px from its origin, under the sqrt(2)/2 px a whole-pixel tip can sit off the line, so no direction it states can be read as the line's; its own axis, not held`
1134
+ : `${n}'s tip points ${pyRound(gap, 6)} degrees off the line through eye_r and eye_l, more than the ${pyRound(bound, 6)} degrees a tip ${length} px from its origin can sit off it through whole-pixel rounding; its own axis, not held`,
1135
+ );
1136
+ }
1137
+ }
1138
+ if (held.length > 0) {
1139
+ // In the rig's bone order, as the turned bones' entry lists them.
1140
+ const e = entry(FACE_TIPS_AXIS_NAME, bones.map((bn) => bn.name).filter((n) => held.includes(n)));
1141
+ faceAxis.tipAxis = e.axis;
1142
+ }
1143
+ }
1144
+ }
1145
+
851
1146
  // ---- keys a turned frame would change ----------------------------------
852
1147
  // A rotate key turns a bone about its origin whatever its frame, so the
853
1148
  // idle's chain sways mean what they meant. A translate key moves a bone
@@ -866,7 +1161,8 @@ export function buildRig(
866
1161
  const translate = k.prop.startsWith('translate');
867
1162
  const frameBone = translate ? parent : k.bone;
868
1163
  const deg = turn.get(frameBone) as number;
869
- if (deg === 0) continue;
1164
+ // A key in a face-framed bone's frame means the face's axes: that is the point of the turn (issue #239).
1165
+ if (deg === 0 || faceFramed.has(frameBone)) continue;
870
1166
  const axis = k.prop.slice(-1);
871
1167
  fail(
872
1168
  'RIG_KEY_FRAME_UNTURNED',
@@ -1359,7 +1655,10 @@ export function buildRig(
1359
1655
  // issue #126 item 3: an automatic part whose author set motion.deformMayFold — written only then, so no other rig moves.
1360
1656
  const mayFold = autoMotion.filter((c) => c.motion?.deformMayFold === true).map((c) => ({ slot: c.part, why: DEFORM_MAY_FOLD_WHY }));
1361
1657
  if (mayFold.length > 0) rig.invariants = { ...rig.invariants, deformMayFold: mayFold };
1362
- return { rig, motion, meshReport, images: outImages, controls, meshKeyed, idleKeys, loopPasses, autoMotion };
1658
+ // issue #239: the face's line, written only when the face bones were turned to one, so no other rig moves.
1659
+ const axes = [faceAxis.axis, faceAxis.tipAxis].flatMap((x) => (x === null ? [] : [x]));
1660
+ if (axes.length > 0) rig.invariants = { ...rig.invariants, axes };
1661
+ return { rig, motion, meshReport, images: outImages, controls, meshKeyed, idleKeys, loopPasses, autoMotion, faceAxis };
1363
1662
  }
1364
1663
 
1365
1664
  /**
@@ -1377,7 +1676,11 @@ export function rigJsonText(value: unknown): string {
1377
1676
  * weight's bind `x, y` and each region's `x, y` is carried out of its frame as
1378
1677
  * written (rigc's `toWorld`, `src/coords.ts`) and back in as a difference from
1379
1678
  * the frame's origin, at the reference's 3 places; `length` and `rotation` are
1380
- * dropped. Everything else is copied.
1679
+ * dropped, and so is `invariants.axes` (issue #239): it holds bones to a
1680
+ * line they lie along only when turned, which the flat form undoes, and the
1681
+ * reference declared none; and so is {@link HEAD_UP_BONE} (issue #250), a
1682
+ * slotless bone that carries only a direction, which the reference never
1683
+ * wrote. Everything else is copied.
1381
1684
  *
1382
1685
  * It exists so the reference's outputs stay an oracle: the chain suite and
1383
1686
  * `scripts/rig_oracle.ts` compare `flattenRig(built)` with the reference's
@@ -1403,7 +1706,8 @@ export function flattenRig(rig: RigSpec, sense: 1 | -1 = 1): RigSpec {
1403
1706
  const [wx, wy] = toWorld(m, x, y);
1404
1707
  return [placed(wx - m.worldX, FLAT_PLACES), placed(wy - m.worldY, FLAT_PLACES)];
1405
1708
  };
1406
- const bones: RigBone[] = rig.bones.map((b) => {
1709
+ // The head's up-axis bone (issue #250) carries only a direction, which the flat form undoes; the reference wrote none.
1710
+ const bones: RigBone[] = rig.bones.filter((b) => b.name !== HEAD_UP_BONE).map((b) => {
1407
1711
  if (b.parent === undefined) return { name: b.name, x: b.x, y: b.y };
1408
1712
  const [x, y] = out(origin(b.parent), b.x, b.y);
1409
1713
  return { name: b.name, parent: b.parent, x, y };
@@ -1436,5 +1740,11 @@ export function flattenRig(rig: RigSpec, sense: 1 | -1 = 1): RigSpec {
1436
1740
  skin[slot] = flat;
1437
1741
  }
1438
1742
  refuseIfAny(problems);
1439
- return { ...rig, bones, skins: { default: skin } };
1743
+ const flat: RigSpec = { ...rig, bones, skins: { default: skin } };
1744
+ if (rig.invariants?.axes !== undefined) {
1745
+ const { axes: _axes, ...rest } = rig.invariants;
1746
+ if (Object.keys(rest).length > 0) flat.invariants = rest;
1747
+ else delete flat.invariants;
1748
+ }
1749
+ return flat;
1440
1750
  }
package/src/scene.ts CHANGED
@@ -17,6 +17,7 @@
17
17
  *
18
18
  * - **Names, totally.** Every bone, slot, attachment (skin entry), constraint
19
19
  * (its name and every bone or slot it names), `invariants.detached` bone,
20
+ * `invariants.axes` entry (its name, its `through` and its `bones`; issue #239),
20
21
  * track, group and easing of character `<id>` is renamed `<id>:<name>`
21
22
  * ({@link prefixed}); `root` is the one shared root. A name resolves to
22
23
  * exactly one character, and nothing resolves across characters without its
@@ -382,7 +383,7 @@ const BONE_KEYS = ['name', 'parent', 'length', 'rotation', 'x', 'y'];
382
383
  const SLOT_KEYS = ['name', 'bone', 'attachment'];
383
384
  const ATTACHMENT_KEYS = ['type', 'image', 'width', 'height', 'uvs', 'triangles', 'hull', 'weights', 'x', 'y', 'rotation'];
384
385
  const RIG_KEYS = ['spec', 'name', 'images', 'skeleton', 'bones', 'slots', 'skins', 'constraints', 'invariants'];
385
- const INVARIANT_KEYS = ['idleDrivesMeshes', 'detached'];
386
+ const INVARIANT_KEYS = ['idleDrivesMeshes', 'detached', 'axes'];
386
387
  const MOTION_KEYS = ['spec', 'archetype', 'cut', 'easings', 'groups', 'animations'];
387
388
  const IDLE_KEYS = ['duration', 'loop', 'note', 'tracks'];
388
389
  const TRACK_KEYS = ['bone', 'group', 'property', 'keys'];
@@ -965,6 +966,10 @@ export function composeScene(
965
966
  ownBone(b.id, d.bone, `character "${b.id}" rig.json invariants.detached[${k}].bone`);
966
967
  ownBone(b.id, d.notUnder, `character "${b.id}" rig.json invariants.detached[${k}].notUnder`);
967
968
  }
969
+ for (const [k, ax] of (b.rig.invariants?.axes ?? []).entries()) {
970
+ ax.through.forEach((n, j) => ownBone(b.id, n, `character "${b.id}" rig.json invariants.axes[${k}].through[${j}]`));
971
+ ax.bones.forEach((n, j) => ownBone(b.id, n, `character "${b.id}" rig.json invariants.axes[${k}].bones[${j}]`));
972
+ }
968
973
  }
969
974
  // The root is shared: a character that drives it would move every character.
970
975
  for (const b of ready) {
@@ -1030,6 +1035,7 @@ export function composeScene(
1030
1035
  }
1031
1036
  const constraints: Array<Record<string, unknown>> = [];
1032
1037
  const detached: NonNullable<NonNullable<RigSpec['invariants']>['detached']> = [];
1038
+ const axes: NonNullable<NonNullable<RigSpec['invariants']>['axes']> = [];
1033
1039
  scene.characters.forEach((c) => {
1034
1040
  const b = byId.get(c.id) as CharacterBuild;
1035
1041
  const id = c.id;
@@ -1090,6 +1096,8 @@ export function composeScene(
1090
1096
  constraints.push(out);
1091
1097
  }
1092
1098
  for (const d of b.rig.invariants?.detached ?? []) detached.push({ ...d, bone: P(id, d.bone), notUnder: P(id, d.notUnder) });
1099
+ // An axis entry's name is unique within one rig, and two characters each carry a "face": the name is prefixed too.
1100
+ for (const ax of b.rig.invariants?.axes ?? []) axes.push({ name: prefixed(id, ax.name), through: [P(id, ax.through[0]), P(id, ax.through[1])], bones: ax.bones.map((n) => P(id, n)), tolerance: ax.tolerance });
1093
1101
  });
1094
1102
  const slots: RigSpec['slots'] = [];
1095
1103
  if (scene.plate !== null && plate !== null) slots.push({ name: PLATE, bone: PLATE, attachment: PLATE });
@@ -1101,6 +1109,7 @@ export function composeScene(
1101
1109
  const invariants: NonNullable<RigSpec['invariants']> = {};
1102
1110
  if (whys.length === 1) invariants.idleDrivesMeshes = { why: whys[0] };
1103
1111
  if (detached.length > 0) invariants.detached = detached;
1112
+ if (axes.length > 0) invariants.axes = axes;
1104
1113
  if (Object.keys(invariants).length > 0) rig.invariants = invariants;
1105
1114
 
1106
1115
  // ---- the motion ---------------------------------------------------------------
@@ -1246,6 +1255,10 @@ export function unprefixedNames(rig: RigSpec, motion: MotionSpec, ids: readonly
1246
1255
  name('invariants.detached bone', d.bone, false);
1247
1256
  name('invariants.detached notUnder', d.notUnder, false);
1248
1257
  }
1258
+ for (const ax of rig.invariants?.axes ?? []) {
1259
+ name('invariants.axes name', ax.name, false);
1260
+ for (const n of [...ax.through, ...ax.bones]) name(`invariants.axes ${ax.name} bone`, n, false);
1261
+ }
1249
1262
  for (const [k, v] of Object.entries(motion.easings)) name('easing', k, false);
1250
1263
  for (const [g, members] of Object.entries(motion.groups)) {
1251
1264
  name('group', g, false);