@rig-c/parts 1.7.0 → 1.9.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 +13 -12
- package/cli.ts +33 -5
- package/docs/AUTHORING.md +61 -15
- package/docs/STABILITY.md +9 -4
- package/package.json +1 -1
- package/src/assemble.ts +199 -16
- package/src/build.ts +27 -4
- package/src/check.ts +120 -14
- package/src/config.ts +75 -1
- package/src/headframe.ts +207 -0
- package/src/motion.ts +20 -3
- package/src/propose.ts +517 -18
- package/src/rig.ts +236 -19
- package/src/turn.ts +99 -0
package/src/rig.ts
CHANGED
|
@@ -58,7 +58,14 @@
|
|
|
58
58
|
* through `eye_r` and `eye_l`, holding the turned bones and their controls,
|
|
59
59
|
* which rigc's `A51_BONES_LIE_ALONG_THEIR_DECLARED_AXIS` checks. Its
|
|
60
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.
|
|
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).
|
|
62
69
|
* - **Meshes** over the parts named in `meshes`: a square lattice (`src/mesh.ts`)
|
|
63
70
|
* weighted by distance to the mesh's candidate segments (`src/weights.ts`),
|
|
64
71
|
* written in rigc's by-name `weights` form. The slot's bone is the first
|
|
@@ -96,6 +103,11 @@
|
|
|
96
103
|
* bone a constraint follows is declared detached from the bones it drives
|
|
97
104
|
* ({@link detachedRules}, `invariants.detached`, rigc's `A25`). Without the
|
|
98
105
|
* field, no byte of any output moves.
|
|
106
|
+
* - **The turn control**, only when the config has `turn` (issue #251,
|
|
107
|
+
* `src/turn.ts`, {@link applyTurn}): a bone `turn` under `root` nothing
|
|
108
|
+
* keys, a leaf twin of every bone a carried part binds, and one transform
|
|
109
|
+
* constraint per part carrying its twins `fraction · turn.x` in world x,
|
|
110
|
+
* after the config's own constraints. Without the field nothing is added.
|
|
99
111
|
*
|
|
100
112
|
* Every part image is padded by {@link PAD} transparent pixels on each side
|
|
101
113
|
* before it is meshed or placed, and the padded image is what `images/`
|
|
@@ -113,6 +125,7 @@
|
|
|
113
125
|
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';
|
|
114
126
|
import { type BoneTransform, computeExactFrameTransforms, cropToSpineY, normaliseDegrees, toBoneLocal, toWorld } from './coords.ts';
|
|
115
127
|
import { type Problem, refuseIfAny } from './errors.ts';
|
|
128
|
+
import { eyeLineDegrees } from './headframe.ts';
|
|
116
129
|
import { f32 } from 'rig-c/compile';
|
|
117
130
|
import type { MeshCounts, MeshQualityReport, MeshReductionInput, MotionAmplitude, ReducedMesh, Termination } from 'rig-c/mesh';
|
|
118
131
|
import { type AmplitudeRow, amplitudeRow, partAmplitude, withAmplitude } from './autoamplitude.ts';
|
|
@@ -147,6 +160,7 @@ import { EYE_GROUP_TAGS } from './propose.ts';
|
|
|
147
160
|
import { alphaAbove, crop, pad, type Raster } from './raster/index.ts';
|
|
148
161
|
import { pyRound } from './round.ts';
|
|
149
162
|
import { boneHeat, heatInfluences, heatRow, type HeatRow, latticeSilhouette, tracedSilhouette } from './heat.ts';
|
|
163
|
+
import { TURN_BONE, type TurnSpec, turnConstraintName, turnFractions, turnTwin } from './turn.ts';
|
|
150
164
|
import { DEFAULT_LIMITS, DISTANCE_EXPONENT, type Influence, influences, type Segment } from './weights.ts';
|
|
151
165
|
|
|
152
166
|
/** Transparent pixels added round every part image — the reference's `PAD`. */
|
|
@@ -206,6 +220,14 @@ export interface FaceAxis {
|
|
|
206
220
|
axis: RigAxis | null;
|
|
207
221
|
/** Why no axes entry was written, or null when one was (or there is no line, which {@link FaceAxis.none} says). */
|
|
208
222
|
noAxis: string | null;
|
|
223
|
+
/** 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. */
|
|
224
|
+
tipAxis: RigAxis | null;
|
|
225
|
+
/** 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. */
|
|
226
|
+
tipOff: string[];
|
|
227
|
+
/** {@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. */
|
|
228
|
+
headUp: { rotation: number; length: number } | null;
|
|
229
|
+
/** Why {@link HEAD_UP_BONE} was not written, when there is a line; null otherwise. */
|
|
230
|
+
headUpNone: string | null;
|
|
209
231
|
}
|
|
210
232
|
|
|
211
233
|
/** One `invariants.axes` entry: rig-c's `RigAxis`, held by its gate rule `A51_BONES_LIE_ALONG_THEIR_DECLARED_AXIS` (2.40.0). */
|
|
@@ -219,6 +241,29 @@ export interface RigAxis {
|
|
|
219
241
|
/** The name of the face's `invariants.axes` entry. */
|
|
220
242
|
export const FACE_AXIS_NAME = 'face';
|
|
221
243
|
|
|
244
|
+
/**
|
|
245
|
+
* The slotless bone the rig stage adds under `head` (issue #250): at head's origin, pointing along the face's up axis
|
|
246
|
+
* (the eye line + 90 degrees), as long as the eye midpoint lies from head's origin along it. `head` keeps its own frame,
|
|
247
|
+
* so no key on it or under it changes meaning; a rule that needs the head's frame (issue #249) reads this bone.
|
|
248
|
+
*/
|
|
249
|
+
export const HEAD_UP_BONE = 'head_up';
|
|
250
|
+
|
|
251
|
+
/** The name of the `invariants.axes` entry for the face bones whose own tip lies along the eye line (issue #258). */
|
|
252
|
+
export const FACE_TIPS_AXIS_NAME = 'face_tips';
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The most a bone's direction can sit off a line through whole-pixel
|
|
256
|
+
* rounding of its tip alone (issue #258): a tip written in whole pixels is
|
|
257
|
+
* within half a pixel in each coordinate, sqrt(2)/2 px, of the point on the
|
|
258
|
+
* line it stands for, so at `length` px from the origin its direction is
|
|
259
|
+
* within asin((sqrt(2)/2) / length) degrees. Null when the tip is closer than
|
|
260
|
+
* sqrt(2)/2 px, where rounding alone can point it anywhere.
|
|
261
|
+
*/
|
|
262
|
+
export function tipRoundingBound(length: number): number | null {
|
|
263
|
+
const s = Math.SQRT1_2 / length;
|
|
264
|
+
return s >= 1 ? null : (Math.asin(s) * 180) / Math.PI;
|
|
265
|
+
}
|
|
266
|
+
|
|
222
267
|
/** 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. */
|
|
223
268
|
const WRITTEN_UNIT = 10 ** -6;
|
|
224
269
|
|
|
@@ -258,10 +303,8 @@ export function eyeLine(parts: PartsFile, eyeBones: ReadonlyMap<string, Point>):
|
|
|
258
303
|
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` };
|
|
259
304
|
const [a, b] = EYE_BONES.map((n) => eyeBones.get(n) as Point);
|
|
260
305
|
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` };
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
const dy = cropToSpineY(r[1], H) - cropToSpineY(l[1], H);
|
|
264
|
-
return { degrees: (Math.atan2(dy, dx) * 180) / Math.PI, parts: [names[0], names[1]] };
|
|
306
|
+
// The one derivation of the roll (issue #250): propose's head frame reads the same function on the same origins.
|
|
307
|
+
return { degrees: eyeLineDegrees(a, b, H), parts: [names[0], names[1]] };
|
|
265
308
|
}
|
|
266
309
|
|
|
267
310
|
/**
|
|
@@ -404,6 +447,104 @@ export function detachedRules(constraints: readonly ConfigConstraint[]): Detache
|
|
|
404
447
|
return out;
|
|
405
448
|
}
|
|
406
449
|
|
|
450
|
+
/** One carried part of the turn control (issue #251): its depth, its fraction, and the twins that carry it. */
|
|
451
|
+
export interface TurnRow {
|
|
452
|
+
part: string;
|
|
453
|
+
depth: number;
|
|
454
|
+
fraction: number;
|
|
455
|
+
/** Each bone the part's slots and weights bind to, with the leaf twin that now carries it; empty when the fraction is 0 — the part stays with the pivot. */
|
|
456
|
+
twins: Array<{ bone: string; twin: string }>;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* The turn control (issue #251, `src/turn.ts`), added to a built rig in place:
|
|
461
|
+
* the control {@link TURN_BONE} under `root` at its origin, nothing keying it;
|
|
462
|
+
* for each part `config.turn.depths` lists, in parts.json order, a leaf twin of
|
|
463
|
+
* every bone the part's slots bind (its region's bone, a blink.still piece's
|
|
464
|
+
* bone, a mesh's slot bone and every bone its weights name) — an identity child,
|
|
465
|
+
* x 0, y 0, no rotation, so its world transform is its bone's exactly and every
|
|
466
|
+
* idle key, physics step or constraint on the bone reaches it — the part's
|
|
467
|
+
* slots and weights moved onto the twins, and one transform constraint per part
|
|
468
|
+
* that adds `fraction · turn.x` to each twin's world x (`localSource`, read
|
|
469
|
+
* off the control's local x; world target; `additive`).
|
|
470
|
+
*
|
|
471
|
+
* Why its own twins and not the `<bone>_ctl` controls: a control is a parent,
|
|
472
|
+
* keyed by the idle, and carries every part on its bone and every bone under
|
|
473
|
+
* it, so it cannot hold two parts at two depths; a leaf twin per part and bone
|
|
474
|
+
* moves that part and nothing else, and nothing else keys or constrains it. A
|
|
475
|
+
* mesh moves as a whole because its weights sum to 1 and every twin it binds
|
|
476
|
+
* moves by the same world offset.
|
|
477
|
+
*
|
|
478
|
+
* At rest the control's x is 0, so each constraint adds `0 · fraction` to
|
|
479
|
+
* world x. At rest: with `turn` declared, no pixel differs by more than 1
|
|
480
|
+
* level at dial 0 (not byte-identical; the cause is float noise in the
|
|
481
|
+
* identity child bones, and the constraints add nothing); without `turn`,
|
|
482
|
+
* byte-identical. The constraints come
|
|
483
|
+
* after the config's own, so a physics constraint on a bone has run before its
|
|
484
|
+
* twin is moved. A part at the pivot depth (fraction 0) gets no twin and no
|
|
485
|
+
* constraint. A name the turn needs that is taken is refused by name
|
|
486
|
+
* (`RIG_TURN_NAME_FREE`).
|
|
487
|
+
*/
|
|
488
|
+
function applyTurn(
|
|
489
|
+
turn: TurnSpec,
|
|
490
|
+
order: readonly string[],
|
|
491
|
+
bones: RigBone[],
|
|
492
|
+
slots: RigSpec['slots'],
|
|
493
|
+
skin: RigSpec['skins']['default'],
|
|
494
|
+
constraints: readonly ConfigConstraint[],
|
|
495
|
+
): { constraints: Array<Record<string, unknown>>; detached: DetachedRule[]; rows: TurnRow[] } {
|
|
496
|
+
const problems: Problem[] = [];
|
|
497
|
+
const taken = new Set(bones.map((b) => b.name));
|
|
498
|
+
const transforms = new Set(constraints.filter((c) => c.type === 'transform').map((c) => c.name));
|
|
499
|
+
if (taken.has(TURN_BONE)) problems.push({ code: 'RIG_TURN_NAME_FREE', object: `bone "${TURN_BONE}"`, detail: 'is the control the rig stage adds for config.turn, and the rig already has a bone of that name; rename it' });
|
|
500
|
+
const rows: TurnRow[] = [];
|
|
501
|
+
for (const f of turnFractions(turn, order)) {
|
|
502
|
+
const row: TurnRow = { ...f, twins: [] };
|
|
503
|
+
rows.push(row);
|
|
504
|
+
if (f.fraction === 0) continue;
|
|
505
|
+
const mine = slots.filter((sl) => sl.name === f.part || sl.name === `${f.part}${STILL_SUFFIX}`);
|
|
506
|
+
const bound: string[] = [];
|
|
507
|
+
const bind = (b: string): void => {
|
|
508
|
+
if (!bound.includes(b)) bound.push(b);
|
|
509
|
+
};
|
|
510
|
+
for (const sl of mine) {
|
|
511
|
+
bind(sl.bone);
|
|
512
|
+
for (const att of Object.values(skin[sl.name] ?? {})) if ('type' in att) for (const v of att.weights) for (const w of v) bind(w.bone);
|
|
513
|
+
}
|
|
514
|
+
for (const b of bound) {
|
|
515
|
+
const twin = turnTwin(f.part, b);
|
|
516
|
+
if (taken.has(twin)) problems.push({ code: 'RIG_TURN_NAME_FREE', object: `bone "${twin}"`, detail: `is the twin that carries part "${f.part}" on "${b}" under config.turn, and the rig already has a bone of that name; rename it` });
|
|
517
|
+
taken.add(twin);
|
|
518
|
+
row.twins.push({ bone: b, twin });
|
|
519
|
+
}
|
|
520
|
+
const cname = turnConstraintName(f.part);
|
|
521
|
+
if (transforms.has(cname)) problems.push({ code: 'RIG_TURN_NAME_FREE', object: `transform constraint "${cname}"`, detail: `is the constraint that carries part "${f.part}" under config.turn, and config.constraints already names a transform constraint so; rename it` });
|
|
522
|
+
}
|
|
523
|
+
refuseIfAny(problems);
|
|
524
|
+
bones.push({ name: TURN_BONE, parent: ROOT_BONE, x: 0, y: 0 });
|
|
525
|
+
const out: Array<Record<string, unknown>> = [];
|
|
526
|
+
const detached: DetachedRule[] = [];
|
|
527
|
+
for (const row of rows) {
|
|
528
|
+
if (row.twins.length === 0) continue;
|
|
529
|
+
const twinOf = new Map(row.twins.map((t) => [t.bone, t.twin]));
|
|
530
|
+
for (const t of row.twins) bones.push({ name: t.twin, parent: t.bone, x: 0, y: 0 });
|
|
531
|
+
for (const sl of slots) {
|
|
532
|
+
if (sl.name !== row.part && sl.name !== `${row.part}${STILL_SUFFIX}`) continue;
|
|
533
|
+
sl.bone = twinOf.get(sl.bone) as string;
|
|
534
|
+
const atts = skin[sl.name] ?? {};
|
|
535
|
+
for (const [k, att] of Object.entries(atts)) {
|
|
536
|
+
if ('type' in att) atts[k] = { ...att, weights: att.weights.map((v) => v.map((w) => ({ ...w, bone: twinOf.get(w.bone) as string }))) };
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
const cname = turnConstraintName(row.part);
|
|
540
|
+
out.push({ type: 'transform', name: cname, bones: row.twins.map((t) => t.twin), source: TURN_BONE, localSource: true, additive: true, properties: { x: { to: { x: { scale: row.fraction } } } } });
|
|
541
|
+
for (const t of row.twins) {
|
|
542
|
+
detached.push({ bone: TURN_BONE, notUnder: t.twin, why: `rig-parts: "${TURN_BONE}" is the source of transform constraint "${cname}" (config.turn, issue #251), and "${t.twin}" is a bone it drives; under it, driving it would move what it follows` });
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
return { constraints: out, detached, rows };
|
|
546
|
+
}
|
|
547
|
+
|
|
407
548
|
/**
|
|
408
549
|
* Where the idle's keys on a mesh-driving bone go (`rig --idle-keys`).
|
|
409
550
|
*
|
|
@@ -656,6 +797,8 @@ export interface RigOutput {
|
|
|
656
797
|
autoMotion: AutoMotionCase[];
|
|
657
798
|
/** The eye line the face bones were turned to, or why there is none (issue #239). */
|
|
658
799
|
faceAxis: FaceAxis;
|
|
800
|
+
/** The turn control's parts (issue #251), in parts.json order; null when the config has no `turn`. */
|
|
801
|
+
turn: TurnRow[] | null;
|
|
659
802
|
}
|
|
660
803
|
|
|
661
804
|
/**
|
|
@@ -908,6 +1051,8 @@ export function buildRig(
|
|
|
908
1051
|
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`);
|
|
909
1052
|
}
|
|
910
1053
|
}
|
|
1054
|
+
// The face's up-axis bone is the rig stage's (issue #250): a config bone of that name would be shadowed.
|
|
1055
|
+
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`);
|
|
911
1056
|
// A two-bone ik over a link and its child link stops being one when the
|
|
912
1057
|
// child gets a control: `<child>_ctl` stands between them. rig-c 2.15.0
|
|
913
1058
|
// refuses that pair by name (issue #103); the rig stage adds the flag to
|
|
@@ -976,6 +1121,10 @@ export function buildRig(
|
|
|
976
1121
|
none: line.degrees === null ? line.none : null,
|
|
977
1122
|
axis: null,
|
|
978
1123
|
noAxis: null,
|
|
1124
|
+
tipAxis: null,
|
|
1125
|
+
tipOff: [],
|
|
1126
|
+
headUp: null,
|
|
1127
|
+
headUpNone: null,
|
|
979
1128
|
};
|
|
980
1129
|
const faceTurn = new Map<string, number>();
|
|
981
1130
|
for (const n of faceAxis.turned) {
|
|
@@ -1010,6 +1159,32 @@ export function buildRig(
|
|
|
1010
1159
|
const [x, y] = toBoneLocal(frame, spineX(b.x), spineY(b.y));
|
|
1011
1160
|
bones.push({ name: b.name, parent: b.parent, ...(a === undefined ? {} : { length: a.length }), ...(a !== undefined || rotation !== 0 ? { rotation } : {}), x: places(x), y: places(y) });
|
|
1012
1161
|
}
|
|
1162
|
+
// ---- the head's up axis (issue #250) ----------------------------------
|
|
1163
|
+
// `head` keeps its frame, so every key on it and under it — the idle, the blink's brow drop, an authored
|
|
1164
|
+
// animation's scale or translate — means what it meant. The face's frame is carried by one slotless bone under it,
|
|
1165
|
+
// {@link HEAD_UP_BONE}, at head's origin, turned to the eye line's angle + 90 (the same line, {@link eyeLine}) and
|
|
1166
|
+
// as long as the eye midpoint lies from head's origin along that axis: derived from the three bones the config
|
|
1167
|
+
// writes, so it moves with them. A rule that needs the head's frame reads it here; nothing binds to it or keys it.
|
|
1168
|
+
const headEntry = cfg.bones.find((e) => !('chain' in e) && e.name === 'head');
|
|
1169
|
+
if (line.degrees !== null && headEntry !== undefined && !('chain' in headEntry)) {
|
|
1170
|
+
const up = line.degrees + 90;
|
|
1171
|
+
const [eR, eL] = EYE_BONES.map((n) => eyeAt.get(n) as Point);
|
|
1172
|
+
const mid: Point = [(eR[0] + eL[0]) / 2, (eR[1] + eL[1]) / 2];
|
|
1173
|
+
const h = B.get('head') as Bone;
|
|
1174
|
+
const ux = Math.cos((up * Math.PI) / 180);
|
|
1175
|
+
const uy = Math.sin((up * Math.PI) / 180);
|
|
1176
|
+
const reach = (spineX(mid[0]) - spineX(h.x)) * ux + (spineY(mid[1]) - spineY(h.y)) * uy;
|
|
1177
|
+
if (reach <= 0) {
|
|
1178
|
+
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`;
|
|
1179
|
+
} else {
|
|
1180
|
+
const parentTurn = turn.get('head') as number;
|
|
1181
|
+
const rotation = places(normaliseDegrees(up - parentTurn));
|
|
1182
|
+
const length = pyRound(reach, 3);
|
|
1183
|
+
bones.push({ name: HEAD_UP_BONE, parent: 'head', length, rotation, x: 0, y: 0 });
|
|
1184
|
+
faceAxis.headUp = { rotation: places(parentTurn + rotation), length };
|
|
1185
|
+
}
|
|
1186
|
+
} else if (line.degrees !== null) faceAxis.headUpNone = 'config.bones declares no single bone "head" for it to hang from';
|
|
1187
|
+
|
|
1013
1188
|
const world = computeExactFrameTransforms(bones);
|
|
1014
1189
|
|
|
1015
1190
|
// ---- the face's axes entry (issue #239, rig-c 2.40.0's A51) -------------
|
|
@@ -1020,23 +1195,57 @@ export function buildRig(
|
|
|
1020
1195
|
// and rotation through rig-c's own `f32`, the float32 the skeleton stores — 36.869898 is stored 1.6e-6 degrees off,
|
|
1021
1196
|
// more than a 6-place unit), rounded up to the places a rotation is written to, plus one unit of that last place —
|
|
1022
1197
|
// the float floor of the emitted rig, to the precision this stage writes. A bone turned further off is A51's.
|
|
1023
|
-
|
|
1198
|
+
// Issue #258: a face bone with its own tip is the author's statement of its axis (#239) and is not turned here, but
|
|
1199
|
+
// 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
|
|
1200
|
+
// turned bones' entry keeps its float floor. Which tips are meant to lie along the line is read, not chosen: a tip
|
|
1201
|
+
// written in whole pixels sits at most half a pixel off in each coordinate, sqrt(2)/2 px, from the point of the line
|
|
1202
|
+
// it stands for, so its direction can sit up to asin((sqrt(2)/2) / length) degrees off the line through rounding
|
|
1203
|
+
// alone ({@link tipRoundingBound}); a tip within that bone's own bound is held, its entry's tolerance measured as the
|
|
1204
|
+
// turned bones' is, and a tip further off is an axis of its own, said by name in faceAxis.tipOff, never silent.
|
|
1205
|
+
if (line.degrees !== null && (faceAxis.turned.length > 0 || faceAxis.ownTip.length > 0)) {
|
|
1024
1206
|
const through: [string, string] = [EYE_BONES[0], EYE_BONES[1]];
|
|
1025
|
-
const listed = bones.map((b) => b.name).filter((n) => faceTurn.has(n));
|
|
1026
1207
|
const emitted = computeExactFrameTransforms(
|
|
1027
1208
|
bones.map((bn) => ({ ...bn, x: f32(bn.x), y: f32(bn.y), ...(bn.rotation === undefined ? {} : { rotation: f32(bn.rotation) }) })),
|
|
1028
1209
|
);
|
|
1029
1210
|
const [a, b] = through.map((n) => emitted.get(n) as BoneTransform);
|
|
1030
1211
|
const gateLine = (Math.atan2(b.worldY - a.worldY, b.worldX - a.worldX) * 180) / Math.PI;
|
|
1212
|
+
const gapOf = (n: string): number => {
|
|
1213
|
+
const t = emitted.get(n) as BoneTransform;
|
|
1214
|
+
return lineGap((Math.atan2(t.c, t.a) * 180) / Math.PI, gateLine);
|
|
1215
|
+
};
|
|
1216
|
+
const entry = (name: string, listed: string[]): { axis: RigAxis | null; gap: number } => {
|
|
1217
|
+
const gap = Math.max(...listed.map(gapOf));
|
|
1218
|
+
const tolerance = pyRound(Math.ceil(gap / WRITTEN_UNIT) * WRITTEN_UNIT + WRITTEN_UNIT, OFFSET_PLACES);
|
|
1219
|
+
return { axis: tolerance >= 90 ? null : { name, through, bones: listed, tolerance }, gap };
|
|
1220
|
+
};
|
|
1031
1221
|
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`;
|
|
1032
1222
|
else {
|
|
1033
|
-
|
|
1034
|
-
const
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1223
|
+
if (faceAxis.turned.length > 0) {
|
|
1224
|
+
const e = entry(FACE_AXIS_NAME, bones.map((bn) => bn.name).filter((n) => faceTurn.has(n)));
|
|
1225
|
+
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`;
|
|
1226
|
+
else faceAxis.axis = e.axis;
|
|
1227
|
+
}
|
|
1228
|
+
const held: string[] = [];
|
|
1229
|
+
for (const n of faceAxis.ownTip) {
|
|
1230
|
+
const length = (aim.get(n) as { length: number }).length;
|
|
1231
|
+
const bound = tipRoundingBound(length);
|
|
1232
|
+
const gap = gapOf(n);
|
|
1233
|
+
if (bound !== null && gap <= bound) {
|
|
1234
|
+
held.push(n);
|
|
1235
|
+
if (controls.includes(n)) held.push(`${n}${CONTROL_SUFFIX}`);
|
|
1236
|
+
} else {
|
|
1237
|
+
faceAxis.tipOff.push(
|
|
1238
|
+
bound === null
|
|
1239
|
+
? `${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`
|
|
1240
|
+
: `${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`,
|
|
1241
|
+
);
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
if (held.length > 0) {
|
|
1245
|
+
// In the rig's bone order, as the turned bones' entry lists them.
|
|
1246
|
+
const e = entry(FACE_TIPS_AXIS_NAME, bones.map((bn) => bn.name).filter((n) => held.includes(n)));
|
|
1247
|
+
faceAxis.tipAxis = e.axis;
|
|
1248
|
+
}
|
|
1040
1249
|
}
|
|
1041
1250
|
}
|
|
1042
1251
|
|
|
@@ -1534,6 +1743,9 @@ export function buildRig(
|
|
|
1534
1743
|
});
|
|
1535
1744
|
}
|
|
1536
1745
|
refuseIfAny(problems);
|
|
1746
|
+
// ---- the turn control (issue #251) -------------------------------------
|
|
1747
|
+
// Only when the config has `turn`: every other build adds nothing here, so no byte moves.
|
|
1748
|
+
const turned = cfg.turn === undefined ? null : applyTurn(cfg.turn, parts.parts.map((p) => p.name), bones, slots, skin, cfg.constraints ?? []);
|
|
1537
1749
|
|
|
1538
1750
|
const name = `${cfg.key}_painting`;
|
|
1539
1751
|
const rig: RigSpec = {
|
|
@@ -1546,15 +1758,17 @@ export function buildRig(
|
|
|
1546
1758
|
skins: { default: skin },
|
|
1547
1759
|
};
|
|
1548
1760
|
if (cfg.constraints !== undefined) rig.constraints = cfg.constraints.map(constraintForRig);
|
|
1549
|
-
|
|
1761
|
+
if (turned !== null) rig.constraints = [...(rig.constraints ?? []), ...turned.constraints];
|
|
1762
|
+
const detached = [...detachedRules(cfg.constraints ?? []), ...(turned?.detached ?? [])];
|
|
1550
1763
|
if (idleKeys === 'direct' && meshKeyed.length > 0) rig.invariants = { idleDrivesMeshes: { why: IDLE_DRIVES_MESHES_WHY } };
|
|
1551
1764
|
if (detached.length > 0) rig.invariants = { ...rig.invariants, detached };
|
|
1552
1765
|
// issue #126 item 3: an automatic part whose author set motion.deformMayFold — written only then, so no other rig moves.
|
|
1553
1766
|
const mayFold = autoMotion.filter((c) => c.motion?.deformMayFold === true).map((c) => ({ slot: c.part, why: DEFORM_MAY_FOLD_WHY }));
|
|
1554
1767
|
if (mayFold.length > 0) rig.invariants = { ...rig.invariants, deformMayFold: mayFold };
|
|
1555
1768
|
// issue #239: the face's line, written only when the face bones were turned to one, so no other rig moves.
|
|
1556
|
-
|
|
1557
|
-
|
|
1769
|
+
const axes = [faceAxis.axis, faceAxis.tipAxis].flatMap((x) => (x === null ? [] : [x]));
|
|
1770
|
+
if (axes.length > 0) rig.invariants = { ...rig.invariants, axes };
|
|
1771
|
+
return { rig, motion, meshReport, images: outImages, controls, meshKeyed, idleKeys, loopPasses, autoMotion, faceAxis, turn: turned?.rows ?? null };
|
|
1558
1772
|
}
|
|
1559
1773
|
|
|
1560
1774
|
/**
|
|
@@ -1574,7 +1788,9 @@ export function rigJsonText(value: unknown): string {
|
|
|
1574
1788
|
* the frame's origin, at the reference's 3 places; `length` and `rotation` are
|
|
1575
1789
|
* dropped, and so is `invariants.axes` (issue #239): it holds bones to a
|
|
1576
1790
|
* line they lie along only when turned, which the flat form undoes, and the
|
|
1577
|
-
* reference declared none
|
|
1791
|
+
* reference declared none; and so is {@link HEAD_UP_BONE} (issue #250), a
|
|
1792
|
+
* slotless bone that carries only a direction, which the reference never
|
|
1793
|
+
* wrote. Everything else is copied.
|
|
1578
1794
|
*
|
|
1579
1795
|
* It exists so the reference's outputs stay an oracle: the chain suite and
|
|
1580
1796
|
* `scripts/rig_oracle.ts` compare `flattenRig(built)` with the reference's
|
|
@@ -1600,7 +1816,8 @@ export function flattenRig(rig: RigSpec, sense: 1 | -1 = 1): RigSpec {
|
|
|
1600
1816
|
const [wx, wy] = toWorld(m, x, y);
|
|
1601
1817
|
return [placed(wx - m.worldX, FLAT_PLACES), placed(wy - m.worldY, FLAT_PLACES)];
|
|
1602
1818
|
};
|
|
1603
|
-
|
|
1819
|
+
// The head's up-axis bone (issue #250) carries only a direction, which the flat form undoes; the reference wrote none.
|
|
1820
|
+
const bones: RigBone[] = rig.bones.filter((b) => b.name !== HEAD_UP_BONE).map((b) => {
|
|
1604
1821
|
if (b.parent === undefined) return { name: b.name, x: b.x, y: b.y };
|
|
1605
1822
|
const [x, y] = out(origin(b.parent), b.x, b.y);
|
|
1606
1823
|
return { name: b.name, parent: b.parent, x, y };
|
package/src/turn.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The turn control (issue #251): one bone nothing keys, and each part carried
|
|
3
|
+
* sideways a fraction of that bone's offset, the fraction ordered by the part's
|
|
4
|
+
* See-through depth — nearest furthest, the pivot depth staying put.
|
|
5
|
+
*
|
|
6
|
+
* 🧭 **The law is a turn's, not a chosen curve.** A figure turned by a small
|
|
7
|
+
* angle θ about a vertical axis standing at depth `pivot`, seen without
|
|
8
|
+
* perspective, moves each point sideways by `sin θ · (pivot − depth)` in depth
|
|
9
|
+
* units: linear in depth, zero at the pivot, opposite signs on either side of
|
|
10
|
+
* it. The depth unit's size in pixels is nothing See-through states, so the
|
|
11
|
+
* dial is normalised by the nearest part instead:
|
|
12
|
+
*
|
|
13
|
+
* fraction(part) = (pivot − depth(part)) / (pivot − depth(nearest))
|
|
14
|
+
*
|
|
15
|
+
* The nearest part (the smallest `depth_median`, larger being further back)
|
|
16
|
+
* travels exactly the control's offset, the pivot depth 0, a part behind the
|
|
17
|
+
* pivot the other way. The dial's unit is therefore the nearest part's travel
|
|
18
|
+
* in rig px, and how far a consumer turns it is the consumer's.
|
|
19
|
+
*
|
|
20
|
+
* ⚖️ **The pivot `propose` drafts** ({@link turnPivot}) is the mean of the
|
|
21
|
+
* carried parts' depths weighted by their `opaque_px`: the one depth at which
|
|
22
|
+
* `Σ opaque_px · fraction = 0`, so the painted pixels' centroid does not slide
|
|
23
|
+
* under the dial — the figure turns about its own middle rather than drifting
|
|
24
|
+
* with it. It is a config field (`turn.pivot`), so a person corrects it there;
|
|
25
|
+
* the rig stage recomputes every fraction from it.
|
|
26
|
+
*
|
|
27
|
+
* Depths are See-through's per-layer `depth_median` only. Issue #250 measured
|
|
28
|
+
* that the depth maps do not carry relief inside a layer, so a part moves as
|
|
29
|
+
* one piece. Pure: no file, no clock.
|
|
30
|
+
*/
|
|
31
|
+
import { pyRound } from './round.ts';
|
|
32
|
+
|
|
33
|
+
/** The control bone the rig stage adds under `root` when the config has `turn`: nothing keys it, and its local x is the dial. */
|
|
34
|
+
export const TURN_BONE = 'turn';
|
|
35
|
+
|
|
36
|
+
/** The places a pivot and a fraction are written to. */
|
|
37
|
+
export const TURN_PLACES = 6;
|
|
38
|
+
|
|
39
|
+
/** `config.turn`: the pivot depth and each carried part's See-through `depth_median`. */
|
|
40
|
+
export interface TurnSpec {
|
|
41
|
+
pivot: number;
|
|
42
|
+
depths: Record<string, number>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** One carried part: its depth and the fraction of the control's offset it travels. */
|
|
46
|
+
export interface TurnFraction {
|
|
47
|
+
part: string;
|
|
48
|
+
depth: number;
|
|
49
|
+
fraction: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The nearest part of a turn: the smallest depth, the first such part in `depths`' key order. */
|
|
53
|
+
export function turnNearest(depths: Readonly<Record<string, number>>): { part: string; depth: number } | null {
|
|
54
|
+
let best: { part: string; depth: number } | null = null;
|
|
55
|
+
for (const [part, depth] of Object.entries(depths)) if (best === null || depth < best.depth) best = { part, depth };
|
|
56
|
+
return best;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* `(pivot − depth) / (pivot − nearest)`, to {@link TURN_PLACES} places, with a
|
|
61
|
+
* negative zero written as 0. The caller holds `pivot > nearest` (the loader
|
|
62
|
+
* refuses anything else, `CONFIG_TURN_PIVOT_BEHIND_NEAREST`).
|
|
63
|
+
*/
|
|
64
|
+
export function turnFraction(pivot: number, nearest: number, depth: number): number {
|
|
65
|
+
const f = pyRound((pivot - depth) / (pivot - nearest), TURN_PLACES);
|
|
66
|
+
return f === 0 ? 0 : f;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Every carried part's fraction, in `order` (parts.json's draw order); a name in `order` the spec does not list is not carried. */
|
|
70
|
+
export function turnFractions(spec: TurnSpec, order: readonly string[]): TurnFraction[] {
|
|
71
|
+
const nearest = turnNearest(spec.depths);
|
|
72
|
+
if (nearest === null) return [];
|
|
73
|
+
return order.filter((p) => Object.hasOwn(spec.depths, p)).map((part) => ({ part, depth: spec.depths[part], fraction: turnFraction(spec.pivot, nearest.depth, spec.depths[part]) }));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The pivot `propose` drafts: `Σ area · depth / Σ area` over the carried
|
|
78
|
+
* parts, to {@link TURN_PLACES} places — the depth at which the painted
|
|
79
|
+
* pixels' centroid does not move under the dial. Null with no area.
|
|
80
|
+
*/
|
|
81
|
+
export function turnPivot(rows: ReadonlyArray<{ depth: number; area: number }>): number | null {
|
|
82
|
+
let a = 0;
|
|
83
|
+
let ad = 0;
|
|
84
|
+
for (const r of rows) {
|
|
85
|
+
a += r.area;
|
|
86
|
+
ad += r.area * r.depth;
|
|
87
|
+
}
|
|
88
|
+
return a > 0 ? pyRound(ad / a, TURN_PLACES) : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The leaf bone that carries `part`'s share of `bone` under the turn: an identity child of `bone`, so it follows everything `bone` does. */
|
|
92
|
+
export function turnTwin(part: string, bone: string): string {
|
|
93
|
+
return `${TURN_BONE}_${part}_${bone}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The transform constraint that carries `part`. */
|
|
97
|
+
export function turnConstraintName(part: string): string {
|
|
98
|
+
return `${TURN_BONE}_${part}`;
|
|
99
|
+
}
|