@rig-c/parts 1.8.0 → 1.9.1
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 +7 -6
- package/cli.ts +26 -4
- package/docs/AUTHORING.md +27 -9
- package/docs/STABILITY.md +9 -4
- package/package.json +1 -1
- package/src/autoenvelope.ts +2 -1
- package/src/automotion.ts +41 -0
- package/src/build.ts +21 -4
- package/src/check.ts +114 -10
- package/src/config.ts +75 -1
- package/src/headframe.ts +2 -1
- package/src/instruments.ts +9 -5
- package/src/length.ts +21 -0
- package/src/propose.ts +158 -44
- package/src/requirements.ts +3 -2
- package/src/rig.ts +112 -2
- package/src/skeleton.ts +2 -1
- package/src/structure.ts +5 -4
- package/src/turn.ts +99 -0
package/src/rig.ts
CHANGED
|
@@ -103,6 +103,11 @@
|
|
|
103
103
|
* bone a constraint follows is declared detached from the bones it drives
|
|
104
104
|
* ({@link detachedRules}, `invariants.detached`, rigc's `A25`). Without the
|
|
105
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.
|
|
106
111
|
*
|
|
107
112
|
* Every part image is padded by {@link PAD} transparent pixels on each side
|
|
108
113
|
* before it is meshed or placed, and the padded image is what `images/`
|
|
@@ -155,6 +160,7 @@ import { EYE_GROUP_TAGS } from './propose.ts';
|
|
|
155
160
|
import { alphaAbove, crop, pad, type Raster } from './raster/index.ts';
|
|
156
161
|
import { pyRound } from './round.ts';
|
|
157
162
|
import { boneHeat, heatInfluences, heatRow, type HeatRow, latticeSilhouette, tracedSilhouette } from './heat.ts';
|
|
163
|
+
import { TURN_BONE, type TurnSpec, turnConstraintName, turnFractions, turnTwin } from './turn.ts';
|
|
158
164
|
import { DEFAULT_LIMITS, DISTANCE_EXPONENT, type Influence, influences, type Segment } from './weights.ts';
|
|
159
165
|
|
|
160
166
|
/** Transparent pixels added round every part image — the reference's `PAD`. */
|
|
@@ -441,6 +447,104 @@ export function detachedRules(constraints: readonly ConfigConstraint[]): Detache
|
|
|
441
447
|
return out;
|
|
442
448
|
}
|
|
443
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
|
+
|
|
444
548
|
/**
|
|
445
549
|
* Where the idle's keys on a mesh-driving bone go (`rig --idle-keys`).
|
|
446
550
|
*
|
|
@@ -693,6 +797,8 @@ export interface RigOutput {
|
|
|
693
797
|
autoMotion: AutoMotionCase[];
|
|
694
798
|
/** The eye line the face bones were turned to, or why there is none (issue #239). */
|
|
695
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;
|
|
696
802
|
}
|
|
697
803
|
|
|
698
804
|
/**
|
|
@@ -1637,6 +1743,9 @@ export function buildRig(
|
|
|
1637
1743
|
});
|
|
1638
1744
|
}
|
|
1639
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 ?? []);
|
|
1640
1749
|
|
|
1641
1750
|
const name = `${cfg.key}_painting`;
|
|
1642
1751
|
const rig: RigSpec = {
|
|
@@ -1649,7 +1758,8 @@ export function buildRig(
|
|
|
1649
1758
|
skins: { default: skin },
|
|
1650
1759
|
};
|
|
1651
1760
|
if (cfg.constraints !== undefined) rig.constraints = cfg.constraints.map(constraintForRig);
|
|
1652
|
-
|
|
1761
|
+
if (turned !== null) rig.constraints = [...(rig.constraints ?? []), ...turned.constraints];
|
|
1762
|
+
const detached = [...detachedRules(cfg.constraints ?? []), ...(turned?.detached ?? [])];
|
|
1653
1763
|
if (idleKeys === 'direct' && meshKeyed.length > 0) rig.invariants = { idleDrivesMeshes: { why: IDLE_DRIVES_MESHES_WHY } };
|
|
1654
1764
|
if (detached.length > 0) rig.invariants = { ...rig.invariants, detached };
|
|
1655
1765
|
// issue #126 item 3: an automatic part whose author set motion.deformMayFold — written only then, so no other rig moves.
|
|
@@ -1658,7 +1768,7 @@ export function buildRig(
|
|
|
1658
1768
|
// issue #239: the face's line, written only when the face bones were turned to one, so no other rig moves.
|
|
1659
1769
|
const axes = [faceAxis.axis, faceAxis.tipAxis].flatMap((x) => (x === null ? [] : [x]));
|
|
1660
1770
|
if (axes.length > 0) rig.invariants = { ...rig.invariants, axes };
|
|
1661
|
-
return { rig, motion, meshReport, images: outImages, controls, meshKeyed, idleKeys, loopPasses, autoMotion, faceAxis };
|
|
1771
|
+
return { rig, motion, meshReport, images: outImages, controls, meshKeyed, idleKeys, loopPasses, autoMotion, faceAxis, turn: turned?.rows ?? null };
|
|
1662
1772
|
}
|
|
1663
1773
|
|
|
1664
1774
|
/**
|
package/src/skeleton.ts
CHANGED
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
* latent size scales every point by `w / 832`, `h / 1216`.
|
|
36
36
|
*/
|
|
37
37
|
import { newRaster, type Raster } from './raster/types.ts';
|
|
38
|
+
import { edgeLength } from './length.ts';
|
|
38
39
|
|
|
39
40
|
export type SkeletonName = 'stand_sides' | 'stand_clasp';
|
|
40
41
|
|
|
@@ -391,7 +392,7 @@ export function renderSkeleton(pose: SkeletonName | PosePoints, w: number, h: nu
|
|
|
391
392
|
const [x1, y1] = kp[i1 - 1];
|
|
392
393
|
const [x2, y2] = kp[i2 - 1];
|
|
393
394
|
const angle = Math.atan2(y1 - y2, x1 - x2) * RAD_TO_DEG;
|
|
394
|
-
const poly = ellipsePoly((x1 + x2) / 2, (y1 + y2) / 2,
|
|
395
|
+
const poly = ellipsePoly((x1 + x2) / 2, (y1 + y2) / 2, edgeLength(x1 - x2, y1 - y2) / 2, sw, angle);
|
|
395
396
|
const c = COLORS[i];
|
|
396
397
|
pilFillPolygon(r, poly, [Math.trunc(c[0] * 0.6), Math.trunc(c[1] * 0.6), Math.trunc(c[2] * 0.6)]);
|
|
397
398
|
});
|
package/src/structure.ts
CHANGED
|
@@ -74,6 +74,7 @@ import { parseRigSpec, RIG_SPEC_VERSION, type RigBone as RigcBone, type RigSpec
|
|
|
74
74
|
import { type ConfigConstraint, CONSTRAINT_FOLLOWS, parseConfig, parseProposalSections, type Point, ROOT_BONE, type SkeletonSections, weightsABone } from './config.ts';
|
|
75
75
|
import { computeExactFrameTransforms, cropToSpineY, normaliseDegrees, toWorld } from './coords.ts';
|
|
76
76
|
import { PartsError, type Problem, refuseIfAny } from './errors.ts';
|
|
77
|
+
import { edgeLength } from './length.ts';
|
|
77
78
|
|
|
78
79
|
// ---------------------------------------------------------------------------
|
|
79
80
|
// the normal form
|
|
@@ -633,16 +634,16 @@ function pairRow(l: NormalBone, r: NormalBone, left: Skeleton, right: Skeleton,
|
|
|
633
634
|
return sides;
|
|
634
635
|
};
|
|
635
636
|
const originWhy = geometric(l.originWhy, r.originWhy);
|
|
636
|
-
const origin: Figure = originWhy !== null || l.origin === null || r.origin === null ? { skip: originWhy ?? '' } : { value:
|
|
637
|
+
const origin: Figure = originWhy !== null || l.origin === null || r.origin === null ? { skip: originWhy ?? '' } : { value: edgeLength(carry(l.origin)[0] - r.origin[0], carry(l.origin)[1] - r.origin[1]) };
|
|
637
638
|
const tipWhy = geometric(l.tipWhy, r.tipWhy);
|
|
638
639
|
const lo = l.origin;
|
|
639
640
|
const ro = r.origin;
|
|
640
641
|
const lt = l.tip;
|
|
641
642
|
const rt = r.tip;
|
|
642
643
|
const both = lo !== null && ro !== null && lt !== null && rt !== null;
|
|
643
|
-
const tip: Figure = tipWhy !== null || !both ? { skip: tipWhy ?? '' } : { value:
|
|
644
|
-
const ll = lo !== null && lt !== null ?
|
|
645
|
-
const rl = ro !== null && rt !== null ?
|
|
644
|
+
const tip: Figure = tipWhy !== null || !both ? { skip: tipWhy ?? '' } : { value: edgeLength(carry(lt)[0] - rt[0], carry(lt)[1] - rt[1]) };
|
|
645
|
+
const ll = lo !== null && lt !== null ? edgeLength(lt[0] - lo[0], lt[1] - lo[1]) : null;
|
|
646
|
+
const rl = ro !== null && rt !== null ? edgeLength(rt[0] - ro[0], rt[1] - ro[1]) : null;
|
|
646
647
|
const difference: Figure = tipWhy !== null || ll === null || rl === null ? { skip: tipWhy ?? '' } : { value: rl - scale * ll };
|
|
647
648
|
const dir: Figure = tipWhy !== null || !both ? { skip: tipWhy ?? '' } : { value: normaliseDegrees(direction(ro, rt) - direction(lo, lt)) };
|
|
648
649
|
return { left: l.name, right: r.name, origin, parent: parentVerdict(l, r, left, right, fwd, inv), tip, length: { left: ll, right: rl, difference }, direction: dir };
|
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
|
+
}
|