@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/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
- const detached = detachedRules(cfg.constraints ?? []);
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, Math.hypot(x1 - x2, y1 - y2) / 2, sw, angle);
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: Math.hypot(carry(l.origin)[0] - r.origin[0], carry(l.origin)[1] - r.origin[1]) };
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: Math.hypot(carry(lt)[0] - rt[0], carry(lt)[1] - rt[1]) };
644
- const ll = lo !== null && lt !== null ? Math.hypot(lt[0] - lo[0], lt[1] - lo[1]) : null;
645
- const rl = ro !== null && rt !== null ? Math.hypot(rt[0] - ro[0], rt[1] - ro[1]) : 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
+ }