@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/config.ts CHANGED
@@ -55,6 +55,11 @@
55
55
  * object the loader vouches for, and the rig stage leaves them out of what it
56
56
  * hands rigc ({@link constraintForRig}).
57
57
  *
58
+ * 🧭 **`turn` is this package's (issue #251, `src/turn.ts`).** An optional
59
+ * `{pivot, depths}`: each carried part's See-through `depth_median` and the
60
+ * depth that stays put, both stated — `propose --full/--head` drafts them —
61
+ * from which the rig stage derives every fraction ({@link checkTurn}).
62
+ *
58
63
  * 🔗 **`motion.animations_from` is rig-c's too, verbatim (issue #183).** An
59
64
  * optional path, relative to the config file's directory, to a file in
60
65
  * rig-c's motion-spec shape whose `animations` are written into `motion.json`
@@ -75,6 +80,7 @@ import { GRID, MAX_SIDE } from './contour.ts';
75
80
  import { type Problem, refuseIfAny } from './errors.ts';
76
81
  import { isSkeletonName, KEYPOINT_NAMES, type KeypointName, type PosePoints, SKELETON_NAMES, type SkeletonName } from './skeleton.ts';
77
82
  import { acceptedTagNames, readTag } from './tags.ts';
83
+ import { TURN_BONE, type TurnSpec, turnNearest } from './turn.ts';
78
84
 
79
85
  export type Point = [number, number];
80
86
 
@@ -604,6 +610,8 @@ export interface CharacterConfig {
604
610
  regions: Record<string, string>;
605
611
  motion: Motion;
606
612
  constraints?: ConfigConstraint[];
613
+ /** The turn control (issue #251, `src/turn.ts`): absent, no control is added and no byte of any output moves. */
614
+ turn?: TurnSpec;
607
615
  }
608
616
 
609
617
  /** The fields of one constraint kind, as rig-c's interface for that kind declares them. */
@@ -885,7 +893,7 @@ export interface AssembleConfig {
885
893
  assemble: Assemble;
886
894
  }
887
895
 
888
- const TOP_KEYS = ['key', 'generation', 'seethrough', 'assemble', 'bones', 'meshes', 'regions', 'motion', 'constraints'] as const;
896
+ const TOP_KEYS = ['key', 'generation', 'seethrough', 'assemble', 'bones', 'meshes', 'regions', 'motion', 'constraints', 'turn'] as const;
889
897
 
890
898
  /**
891
899
  * The one partial entry point. It validates exactly what its door requires —
@@ -1086,6 +1094,7 @@ function parseConfigFrom(raw: Json, base: string | null): { config: CharacterCon
1086
1094
  if ('meshes' in t && 'regions' in t && 'assemble' in t) checkCoverage(c, [...parts, ...cuts], patches, t.meshes, t.regions);
1087
1095
  if ('motion' in t) checkMotion(c, t.motion, names.bones, names.chains, 'regions' in t ? t.regions : undefined);
1088
1096
  if ('constraints' in t) checkConstraints(c, t.constraints, names.bones, names.parents);
1097
+ if ('turn' in t) checkTurn(c, t.turn, attachedParts(t.meshes, t.regions), names.bones);
1089
1098
  const from = isPlainObject(t.motion) ? t.motion.animations_from : undefined;
1090
1099
  const animations = base !== null && typeof from === 'string' && from !== '' ? readAnimationsFrom(c, resolve(base, from)) : null;
1091
1100
  if (base !== null) poseBeside(c, t.generation, base);
@@ -2281,3 +2290,68 @@ function checkConstraints(c: Check, v: Json, bones: Set<string>, parents: Map<st
2281
2290
  }
2282
2291
  });
2283
2292
  }
2293
+
2294
+ /** The parts `meshes` and `regions` attach, read off whatever they hold; `turn.depths` may name only these. */
2295
+ function attachedParts(meshes: Json, regions: Json): Set<string> {
2296
+ const out = new Set<string>();
2297
+ for (const v of [meshes, regions]) if (isPlainObject(v)) for (const k of Object.keys(v)) out.add(k);
2298
+ return out;
2299
+ }
2300
+
2301
+ /**
2302
+ * `config.turn` (issue #251, `src/turn.ts`): `pivot`, the depth that stays put
2303
+ * when the control moves, and `depths`, each carried part's See-through
2304
+ * `depth_median` as `propose` read it. Neither has a default: a config without
2305
+ * `turn` gets no control, and one with it states both.
2306
+ *
2307
+ * - `CONFIG_FIELD_TYPE` / `CONFIG_FIELD_PRESENT` / `CONFIG_KEY_KNOWN`: not an
2308
+ * object; `pivot` or `depths` absent; another key (records and annotations
2309
+ * aside); a pivot or a depth that is not a finite number; `depths` not an
2310
+ * object or empty.
2311
+ * - `CONFIG_NAME_RESOLVES`: a `depths` key that names no part `meshes` or
2312
+ * `regions` attaches. Its keys are part names, so no annotation or record
2313
+ * door is open there.
2314
+ * - `CONFIG_TURN_PIVOT_BEHIND_NEAREST`: a pivot at or in front of the nearest
2315
+ * part's depth. The law divides by `pivot − nearest`, so at the nearest depth
2316
+ * it has no value, and in front of it every part would travel against the
2317
+ * dial — no part would be nearer than what stays put.
2318
+ * - `CONFIG_TURN_BONE_FREE`: `bones` declares a bone named {@link TURN_BONE},
2319
+ * the control the rig stage adds.
2320
+ */
2321
+ function checkTurn(c: Check, v: Json, attached: Set<string>, bones: Set<string>): void {
2322
+ const at = 'config.turn';
2323
+ const o = c.object(at, v, ['pivot', 'depths'], []);
2324
+ if (o === null) return;
2325
+ const pivot = 'pivot' in o && c.number(`${at}.pivot`, o.pivot) ? (o.pivot as number) : null;
2326
+ const depths: Record<string, number> = {};
2327
+ let depthsOk = false;
2328
+ if ('depths' in o) {
2329
+ const d = o.depths;
2330
+ if (!isPlainObject(d) || Object.keys(d).length === 0) {
2331
+ c.fail('CONFIG_FIELD_TYPE', `${at}.depths`, `is ${show(d)}; a non-empty object of part name -> See-through depth_median is required`);
2332
+ } else {
2333
+ depthsOk = true;
2334
+ for (const [part, depth] of Object.entries(d)) {
2335
+ if (!attached.has(part)) {
2336
+ c.fail('CONFIG_NAME_RESOLVES', `${at}.depths.${part}`, `names "${part}", which neither config.meshes nor config.regions attaches; a part the rig draws is required — a depth for a part nobody draws carries nothing`);
2337
+ depthsOk = false;
2338
+ }
2339
+ if (c.number(`${at}.depths.${part}`, depth)) depths[part] = depth;
2340
+ else depthsOk = false;
2341
+ }
2342
+ }
2343
+ }
2344
+ if (pivot !== null && depthsOk) {
2345
+ const nearest = turnNearest(depths);
2346
+ if (nearest !== null && !(pivot > nearest.depth)) {
2347
+ c.fail(
2348
+ 'CONFIG_TURN_PIVOT_BEHIND_NEAREST',
2349
+ `${at}.pivot`,
2350
+ `is ${pivot}; the nearest part, "${nearest.part}", lies at depth ${nearest.depth}, and a pivot above that (further back) is required — each part travels (pivot − depth) / (pivot − ${nearest.depth}) of the control's offset, which has no value at the nearest depth and turns every part against the dial in front of it`,
2351
+ );
2352
+ }
2353
+ }
2354
+ if (bones.has(TURN_BONE)) {
2355
+ c.fail('CONFIG_TURN_BONE_FREE', `bone "${TURN_BONE}"`, `is the control the rig stage adds under root for config.turn, and config.bones already declares a bone of that name; rename it`);
2356
+ }
2357
+ }
package/src/headframe.ts CHANGED
@@ -34,6 +34,7 @@
34
34
  import type { Point } from './config.ts';
35
35
  import { cropToSpineY } from './coords.ts';
36
36
  import type { Mask } from './raster/types.ts';
37
+ import { edgeLength } from './length.ts';
37
38
 
38
39
  /**
39
40
  * The eye line's angle in Spine degrees (counter-clockwise, y up) through two
@@ -167,7 +168,7 @@ export function headFrame(
167
168
  const [l, r] = eyes[0][0] < eyes[1][0] ? [eyes[0], eyes[1]] : [eyes[1], eyes[0]];
168
169
  const dx = r[0] - l[0];
169
170
  const dy = r[1] - l[1];
170
- const bar = Math.hypot(dx, dy);
171
+ const bar = edgeLength(dx, dy);
171
172
  const u: [number, number] = [dx / bar, dy / bar];
172
173
  // The bar's perpendicular toward the chin: u turned a quarter clockwise as drawn (y down).
173
174
  const n: [number, number] = [-u[1], u[0]];
@@ -23,6 +23,7 @@
23
23
  */
24
24
  import type { StageBox, Viewport } from './check.ts';
25
25
  import { cropToSpineY } from './coords.ts';
26
+ import { edgeLength } from './length.ts';
26
27
  import { OPAQUE_ALPHA_ABOVE } from './layers.ts';
27
28
  import { type PartRecord, type PartsFile, tagOf } from './parts.ts';
28
29
  import type { Raster } from './raster/types.ts';
@@ -446,7 +447,7 @@ export function halfTravels(art: ArtMask, box: { x: number; y: number; w: number
446
447
  for (const f of frames) {
447
448
  const c = f === null ? null : centroidIn(f, ...band);
448
449
  if (c === null) continue;
449
- max = Math.max(max, Math.hypot(c[0] - c0[0], c[1] - c0[1]));
450
+ max = Math.max(max, edgeLength(c[0] - c0[0], c[1] - c0[1]));
450
451
  }
451
452
  return max;
452
453
  };
@@ -508,7 +509,7 @@ export function readSine(keys: readonly Key[], duration: number): SineReading |
508
509
  }
509
510
  a *= 2 / n;
510
511
  b *= 2 / n;
511
- const mag = Math.hypot(a, b);
512
+ const mag = edgeLength(a, b);
512
513
  if (mag > best.mag) best = { j, a, b, mag };
513
514
  }
514
515
  if (best.j === 0) return null;
@@ -780,6 +781,9 @@ export function stretchSeverity(ratio: number): number {
780
781
  return ratio >= 1 ? ratio : 1 / ratio;
781
782
  }
782
783
 
784
+ /** An edge's length: {@link edgeLength} of `src/length.ts` (issues #270, #279), re-exported where the stretch reader first used it. */
785
+ export { edgeLength } from './length.ts';
786
+
783
787
  /**
784
788
  * For every mesh in `rest`, every triangle's three edges in every frame that
785
789
  * shows the attachment: deformed length over rest length, the largest and the
@@ -792,7 +796,7 @@ export function stretchFigures(rest: readonly MeshRest[], frames: readonly Geome
792
796
  return rest.map((m) => {
793
797
  const t = m.triangles;
794
798
  const v0 = m.vertices;
795
- const len = (v: readonly number[], a: number, b: number): number => Math.hypot(v[2 * a] - v[2 * b], v[2 * a + 1] - v[2 * b + 1]);
799
+ const len = (v: readonly number[], a: number, b: number): number => edgeLength(v[2 * a] - v[2 * b], v[2 * a + 1] - v[2 * b + 1]);
796
800
  const out: MeshStretch = { slot: m.slot, attachment: m.attachment, triangles: t.length / 3, frames: 0, max: null, min: null, degenerate: [] };
797
801
  const restLen: number[] = [];
798
802
  for (let i = 0; i < t.length; i += 3) {
@@ -1032,7 +1036,7 @@ export function stillReading(art: ArtMask, stage: StageBox, frames: ReadonlyArra
1032
1036
  const e2x = d[2 * l] - x0;
1033
1037
  const e2y = d[2 * l + 1] - y0;
1034
1038
  for (const bary of pc.pieces[t]) {
1035
- for (let q = 0; q < bary.length; q += 2) fmax = Math.max(fmax, Math.hypot(x0 + bary[q] * e1x + bary[q + 1] * e2x, y0 + bary[q] * e1y + bary[q + 1] * e2y));
1039
+ for (let q = 0; q < bary.length; q += 2) fmax = Math.max(fmax, edgeLength(x0 + bary[q] * e1x + bary[q + 1] * e2x, y0 + bary[q] * e1y + bary[q + 1] * e2y));
1036
1040
  }
1037
1041
  const cx = x0 + pc.centroid[2 * t] * e1x + pc.centroid[2 * t + 1] * e2x;
1038
1042
  const cy = y0 + pc.centroid[2 * t] * e1y + pc.centroid[2 * t + 1] * e2y;
@@ -1101,7 +1105,7 @@ export function crossingOf(art: ArtMask, stage: StageBox, frames: ReadonlyArray<
1101
1105
  if (Math.max(Math.abs(u - u0), Math.abs(v - v0)) !== r || inR(u, v)) continue;
1102
1106
  const sx0 = stage.x + u;
1103
1107
  const sy1 = stage.y + cropToSpineY(v, H);
1104
- best = Math.min(best, Math.hypot(Math.max(sx0 - x, 0, x - sx0 - 1), Math.max(sy1 - 1 - y, 0, y - sy1)));
1108
+ best = Math.min(best, edgeLength(Math.max(sx0 - x, 0, x - sx0 - 1), Math.max(sy1 - 1 - y, 0, y - sy1)));
1105
1109
  }
1106
1110
  }
1107
1111
  }
package/src/length.ts ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The one length this package writes (issues #270, #279): `sqrt(dx * dx + dy * dy)`.
3
+ *
4
+ * Not `Math.hypot`. The language does not promise how `Math.hypot` rounds, and
5
+ * Bun 1.4.2 rounds it differently on two platforms: on the demo's `earring`,
6
+ * edge 11-10 at idle frame 10 (dx 34.26716181006313, dy 0.5713103861783111),
7
+ * it is 34.27192399143071 on macOS and 34.27192399143072 on Linux, measured,
8
+ * with the posed vertices bit-identical on both. That last bit decided which of
9
+ * two edges 2 ulp apart held a mesh's largest ratio, so `TEXTURE_STRETCH`'s
10
+ * `max_at` named one triangle on one machine and another on the other (#276).
11
+ * A multiply, an add and a square root are each correctly rounded by IEEE 754,
12
+ * so this gives the same bits everywhere (34.27192399143071 on both, measured).
13
+ * At pixel scale `Math.hypot`'s guard against overflow is not needed: a
14
+ * coordinate would have to pass about 1e154 for the squares to overflow.
15
+ *
16
+ * The tree rule `TY15` refuses a call of `Math.hypot` anywhere under `src/` and in
17
+ * `cli.ts`, so every length is this one.
18
+ */
19
+ export function edgeLength(dx: number, dy: number): number {
20
+ return Math.sqrt(dx * dx + dy * dy);
21
+ }
package/src/propose.ts CHANGED
@@ -99,8 +99,8 @@
99
99
  *
100
100
  * The idle departs from the reference's too (issue #252): it is
101
101
  * {@link IDLE_FRAMES} frames at 12 fps rather than 4 s, every period scaled
102
- * with it, and the chest's breath travels {@link BREATH_FRACTION} of the torso
103
- * part's height ({@link breathTrack}) rather than a fixed 1.3 px amplitude, so
102
+ * with it, and the chest's breath travels {@link BREATH_FRACTION} of the torso's
103
+ * length, neck to hip ({@link breathTrack}; issue #270), rather than a fixed 1.3 px amplitude, so
104
104
  * no tracked proposal is the reference's output any more in its `motion`.
105
105
  *
106
106
  * Issue #86 adds what the proposer and lint looked at, and changes nothing
@@ -117,7 +117,7 @@ import { drawText, GLYPH_H } from 'rig-c/tools/font5x7.ts';
117
117
  import { type BoneEntry, type ConfigConstraint, type MeshSpec, parseConfig, type Point, type Segment, type SkeletonSections } from './config.ts';
118
118
  import { PartsError, type Problem, refuseIfAny } from './errors.ts';
119
119
  import { eyeLineDegrees, type FrameFeature, headAxes, type HeadAxes, headFrame, type HeadFrame } from './headframe.ts';
120
- import { OPAQUE_ALPHA_ABOVE } from './layers.ts';
120
+ import { type LayerSet, OPAQUE_ALPHA_ABOVE } from './layers.ts';
121
121
  import { PAINTING_RUN, type PartRecord, type PartsFile, readParts } from './parts.ts';
122
122
  import { connectedComponents } from './raster/components.ts';
123
123
  import { dilate, morphGradient } from './raster/morph.ts';
@@ -130,6 +130,8 @@ import { IDLE_FPS } from './motion.ts';
130
130
  import { pyRound as roundTo } from './round.ts';
131
131
  import { KEYPOINT_NAMES, type KeypointName } from './skeleton.ts';
132
132
  import { configRoles, type RoleOf } from './structure.ts';
133
+ import { TURN_BONE, turnFractions, turnNearest, turnPivot, type TurnSpec } from './turn.ts';
134
+ import { edgeLength } from './length.ts';
133
135
 
134
136
  // ---------------------------------------------------------------------------
135
137
  // the output shape — config-shaped, key order as the reference writes it
@@ -187,20 +189,20 @@ export const IDLE_SECONDS = IDLE_FRAMES / IDLE_FPS;
187
189
  */
188
190
  export const REFERENCE_IDLE_SECONDS = 4;
189
191
  /**
190
- * The breath's travel, peak to peak, as a fraction of the torso part's height
191
- * (issue #252). The size is the height because the breath is a `translatey`:
192
- * its travel is read along y, against the part's extent along y. HQ's
193
- * calibration: about 2.5 %. On the trailer's seated character the torso part
194
- * (`topwear`) is 267 px tall and the reference's fixed 2.6 px peak to peak
195
- * (amplitude 1.3, the base 1.3 so the chest rises from its setup pose and
196
- * returns) was 0.97 % of it, read by its owner as a still paper doll; the
197
- * production scene rig that replaced it breathes 6.4 px, 2.4 %.
192
+ * The breath's travel, peak to peak, as a fraction of the torso (issue #252):
193
+ * HQ's calibration, about 2.5 %. Issue #252 took the torso as the `topwear`
194
+ * part's height; on the trailer's seated character that is 267 px, and the
195
+ * reference's fixed 2.6 px peak to peak (amplitude 1.3, the base 1.3 so the
196
+ * chest rises from its setup pose and returns) was 0.97 % of it, read by its
197
+ * owner as a still paper doll; the production scene rig that replaced it
198
+ * breathes 6.4 px, 2.4 %. Issue #270 measures the torso as the trunk the rig
199
+ * has instead — the neck bone to the hip bone, {@link breathTrack} — because a
200
+ * garment's box is not the body: `scarf`'s sweater runs to the thighs (690 px)
201
+ * and breathed 2.9 times as deep as `demo`.
198
202
  */
199
203
  export const BREATH_FRACTION = 0.025;
200
- /** The breath's amplitude the reference drafted on every figure, kept only where there is no torso part to scale by. */
204
+ /** The breath's amplitude the reference drafted on every figure, kept only where the trunk has no length to scale by. */
201
205
  export const REFERENCE_BREATH_AMP = 1.3;
202
- /** The See-through tag of the torso part, whose height the breath is a fraction of. */
203
- export const BREATH_SIZE_TAG = 'topwear';
204
206
 
205
207
  export interface Proposal {
206
208
  bones: BoneEntry[];
@@ -212,6 +214,8 @@ export interface Proposal {
212
214
  /** Absent when no part feeds the `eyes` group ({@link EYE_GROUP_TAGS}): a blink with nothing to blink is not written. */
213
215
  blink?: ProposedBlink;
214
216
  };
217
+ /** Only with `propose --full`/`--head` (issue #251, {@link draftTurn}); absent otherwise, so a proposal without them is the one written before it. */
218
+ turn?: TurnSpec;
215
219
  notes: string[];
216
220
  }
217
221
 
@@ -368,6 +372,7 @@ export function checkProposal(P: PartSet, p: Proposal): void {
368
372
  meshes: p.meshes,
369
373
  regions: p.regions,
370
374
  motion: p.motion,
375
+ ...(p.turn === undefined ? {} : { turn: p.turn }),
371
376
  });
372
377
  }
373
378
 
@@ -376,6 +381,98 @@ export function serializeProposal(p: Proposal): string {
376
381
  return `${JSON.stringify(p, null, 2)}\n`;
377
382
  }
378
383
 
384
+ // ---------------------------------------------------------------------------
385
+ // the turn control (issue #251)
386
+ // ---------------------------------------------------------------------------
387
+
388
+ /** The See-through runs `propose --full/--head` reads depths from, by run. */
389
+ export type TurnRuns = Partial<Record<'full' | 'head', LayerSet>>;
390
+
391
+ /**
392
+ * Draft `turn` (issue #251, `src/turn.ts`) from the parts and the See-through
393
+ * runs they came from: each part's depth is its layer's `depth_median` — the
394
+ * layer of its run named by its tag, a cut's piece its base's — and the pivot
395
+ * is {@link turnPivot} over the parts' `opaque_px`. A `painting:` patch has no
396
+ * layer and so no depth: it is not carried, and a note names it. What a depth
397
+ * cannot be read for is refused by name, every one at once:
398
+ *
399
+ * - `PROPOSE_TURN_RUN_GIVEN`: a part from a run whose layers were not given.
400
+ * - `PROPOSE_TURN_LAYER_PRESENT`: the run given holds no layer of the part's tag.
401
+ * - `PROPOSE_TURN_DEPTH_PRESENT`: the layer carries no depth (a PSD carries none).
402
+ *
403
+ * With no part to carry, or every carried part at one depth (nothing nearer
404
+ * than the pivot), no `turn` is drafted and a note says why. Otherwise one note
405
+ * states the pivot and its rule, the law, and every part's fraction with the
406
+ * depth it came from.
407
+ */
408
+ export function draftTurn(P: PartSet, runs: TurnRuns): { turn: TurnSpec | null; notes: string[] } {
409
+ const problems: Problem[] = [];
410
+ const rows: Array<{ part: string; depth: number; area: number }> = [];
411
+ const patches: string[] = [];
412
+ const unread = new Map<string, string[]>();
413
+ for (const r of P.recs) {
414
+ const [run, tag] = splitFrom(r.from);
415
+ if (run === PAINTING_RUN) {
416
+ patches.push(r.name);
417
+ continue;
418
+ }
419
+ const set = run === 'full' || run === 'head' ? runs[run] : undefined;
420
+ if (set === undefined) {
421
+ unread.set(run, [...(unread.get(run) ?? []), r.name]);
422
+ continue;
423
+ }
424
+ const layer = set.layers.find((l) => l.name === tag);
425
+ if (layer === undefined) {
426
+ problems.push({ code: 'PROPOSE_TURN_LAYER_PRESENT', object: `part "${r.name}" (${r.from})`, detail: `came from the ${run} run's "${tag}" layer, and ${set.source} holds no layer of that name; the run its parts were assembled from is required` });
427
+ continue;
428
+ }
429
+ if (layer.depth === null) {
430
+ problems.push({ code: 'PROPOSE_TURN_DEPTH_PRESENT', object: `part "${r.name}" (${r.from})`, detail: `its layer in ${set.source} carries no depth_median (a ${set.form} input); the turn orders parts by See-through's per-layer depth, so a run that carries one is required` });
431
+ continue;
432
+ }
433
+ rows.push({ part: r.name, depth: layer.depth, area: r.opaque_px });
434
+ }
435
+ for (const [run, names] of unread) {
436
+ problems.push({ code: 'PROPOSE_TURN_RUN_GIVEN', object: `the ${run} run`, detail: `part(s) ${names.map((n) => `"${n}"`).join(', ')} came from it and its layers were not given; --${run} <dir | layers.json> is required to read their depth` });
437
+ }
438
+ refuseIfAny(problems);
439
+ const notes: string[] = [];
440
+ if (patches.length > 0) {
441
+ notes.push(`turn: painting patch(es) ${patches.map((n) => `"${n}"`).join(', ')} carry no See-through depth and are not carried — each moves only as its bone does, as the pivot depth does; a value in config.turn.depths carries one`);
442
+ }
443
+ const pivot = turnPivot(rows);
444
+ const depths = Object.create(null) as Record<string, number>;
445
+ for (const r of rows) depths[r.part] = r.depth;
446
+ const nearest = turnNearest(depths);
447
+ if (pivot === null || nearest === null) {
448
+ notes.push('turn: no part carries a See-through depth with any opaque pixel, so no turn control is drafted');
449
+ return { turn: null, notes };
450
+ }
451
+ if (!(pivot > nearest.depth)) {
452
+ notes.push(`turn: every carried part lies at depth ${nearest.depth}, so none is nearer than the pivot and no turn control is drafted`);
453
+ return { turn: null, notes };
454
+ }
455
+ const turn: TurnSpec = { pivot, depths };
456
+ const fr = turnFractions(turn, rows.map((r) => r.part));
457
+ notes.push(
458
+ `turn: control "${TURN_BONE}" under root, keyed by nothing — its x is the dial. Pivot depth ${pivot}: the mean of the ${rows.length} carried parts' See-through depth_median weighted by opaque_px, the one depth at which the painted pixels' centroid does not slide (correct it in turn.pivot). ` +
459
+ `Each part travels (pivot - depth) / (pivot - ${nearest.depth}) of the control's x, "${nearest.part}" (nearest) all of it: ${fr.map((f) => `${f.part} ${f.fraction} (depth ${f.depth})`).join(', ')}`,
460
+ );
461
+ notes.push(...TURN_DEPTH_CAVEAT);
462
+ return { turn, notes };
463
+ }
464
+
465
+ /** The notes beside every drafted turn (issue #251): what a See-through depth is, where it misleads, and that the two runs' axes are not reconciled. */
466
+ export const TURN_DEPTH_CAVEAT: readonly string[] = [
467
+ "turn: depth is the See-through layer's median and each part takes its own run's axis; a seated or forward-reaching limb can read further back than it is (trailer legwear 0.770 sits behind the pivot)",
468
+ "turn: the two runs' depth axes are not reconciled (parts#271)",
469
+ ];
470
+
471
+ /** The proposal with `turn` written after `motion` and `notes` extended; the proposal itself when `turn` is null. */
472
+ export function withTurn(p: Proposal, draft: { turn: TurnSpec | null; notes: string[] }): Proposal {
473
+ return { bones: p.bones, meshes: p.meshes, regions: p.regions, motion: p.motion, ...(draft.turn === null ? {} : { turn: draft.turn }), notes: [...p.notes, ...draft.notes] };
474
+ }
475
+
379
476
  // ---------------------------------------------------------------------------
380
477
  // the parts, as masks
381
478
  // ---------------------------------------------------------------------------
@@ -1174,10 +1271,12 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
1174
1271
  if (Object.keys(still).length > 0) blink.still = still;
1175
1272
  if (browBonesProposed.length === 0) notes.push(`blink without brows: no eyebrow part (looked for: ${BROW_GROUP_TAGS.join(', ')}), so the blink has no brows group and no brow_drop`);
1176
1273
  }
1177
- // Issue #252: the breath's travel is BREATH_FRACTION of the torso part's height, not the reference's fixed 1.3 px
1178
- // amplitude. Its base stays its amplitude (the chest rises from the setup pose and returns). The other three tracks
1179
- // are an angle and a ratio and stay as drafted. A rig with no torso part keeps the reference's amplitude, and a note.
1180
- const breath = breathTrack(P);
1274
+ // Issue #252: the breath's travel is BREATH_FRACTION of the torso, not the reference's fixed 1.3 px amplitude; issue
1275
+ // #270: the torso is the neck -> hip run as the two bones are written above, not a garment's height. Its base stays
1276
+ // its amplitude (the chest rises from the setup pose and returns). The other three tracks are an angle and a ratio
1277
+ // and stay as drafted. A trunk of no length (neck and hip on one point) keeps the reference's amplitude, and a note.
1278
+ const writtenAt = (n: string): Point => (bones.find((b) => 'name' in b && b.name === n) as { at: Point }).at;
1279
+ const breath = breathTrack(writtenAt('neck'), writtenAt('hip'));
1181
1280
  notes.push(breath.note);
1182
1281
  tracks.push(
1183
1282
  breath.track,
@@ -1584,7 +1683,7 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
1584
1683
  const [g, r] = gridR(p);
1585
1684
  const segs: Segment[] = [];
1586
1685
  for (const a of armPlan.arms.filter((q) => q.part === p)) {
1587
- const len = Math.hypot(a.elbow[0] - a.shoulder[0], a.elbow[1] - a.shoulder[1]);
1686
+ const len = edgeLength(a.elbow[0] - a.shoulder[0], a.elbow[1] - a.shoulder[1]);
1588
1687
  const d: [number, number] = [(a.elbow[0] - a.shoulder[0]) / len, (a.elbow[1] - a.shoulder[1]) / len];
1589
1688
  segs.push(['chest', rnd([a.shoulder[0] - 14 * d[0], a.shoulder[1] - 14 * d[1]]), rnd([a.shoulder[0] + 2 * d[0], a.shoulder[1] + 2 * d[1]])], `sleeve_${a.side}`);
1590
1689
  }
@@ -1762,6 +1861,20 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
1762
1861
  ['hip', rnd(along(waist, trunk.v, s0 + 0.09 * (s1 - s0))), rnd(along(waist, trunk.v, s0 + 0.91 * (s1 - s0)))],
1763
1862
  ];
1764
1863
  }
1864
+ // Issue #262: a skirt whose art starts below the chest bone hangs from the waist. Its root is the hip line's,
1865
+ // and the chest segment goes: with it the breath carries the waistband (a vertex near the chest segment's
1866
+ // lower end takes chest weight), so the root travels with the breath while the hem, on the skirt's chains,
1867
+ // does not. A bottomwear reaching up past the chest (the long robe the hip rule above reads the waist for) has
1868
+ // art on the chest and keeps the segment. Read along the trunk where there is one, down the screen otherwise.
1869
+ const down: readonly [number, number] = trunk === null ? [0, 1] : trunk.u;
1870
+ const artTop = artExtent(mask, p, hip, down)[0];
1871
+ const chestAt = (chest[0] - hip[0]) * down[0] + (chest[1] - hip[1]) * down[1];
1872
+ if (artTop >= chestAt) {
1873
+ segs = segs.filter((s) => typeof s === 'string' || s[0] !== 'chest');
1874
+ notes.push(
1875
+ `${p.name} (${p.from}): its art starts ${pyFixed(artTop - chestAt, 1)} px below chest's origin, so it hangs from the waist — its mesh takes the hip line and its skirt chains, no chest segment, and the breath does not carry its root`,
1876
+ );
1877
+ }
1765
1878
  for (const [tag, f, ph] of [
1766
1879
  ['skirt_r', 0.3, 0.3],
1767
1880
  ['skirt_c', 0.5, 0.34],
@@ -1809,7 +1922,7 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
1809
1922
  const segs: Segment[] = [];
1810
1923
  for (const c of legPlan.chains.filter((q) => q.part === p)) {
1811
1924
  // The sleeve's chest stub, laid along the thigh from the hip: 14 px above the hip to 2 px below it.
1812
- const len = Math.hypot(c.knee[0] - c.hip[0], c.knee[1] - c.hip[1]);
1925
+ const len = edgeLength(c.knee[0] - c.hip[0], c.knee[1] - c.hip[1]);
1813
1926
  const d: [number, number] = [(c.knee[0] - c.hip[0]) / len, (c.knee[1] - c.hip[1]) / len];
1814
1927
  segs.push(['hip', rnd([c.hip[0] - 14 * d[0], c.hip[1] - 14 * d[1]]), rnd([c.hip[0] + 2 * d[0], c.hip[1] + 2 * d[1]])], c.chain);
1815
1928
  }
@@ -1868,31 +1981,32 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
1868
1981
  }
1869
1982
 
1870
1983
  /**
1871
- * The drafted breath (issue #252): `chest` `translatey`, its travel peak to
1872
- * peak {@link BREATH_FRACTION} of the height of the {@link BREATH_SIZE_TAG}
1873
- * layer — the box around every part from it, cut pieces included, since a cut
1874
- * does not shorten the torso — amplitude half of it to two decimals, and the
1875
- * base the amplitude. With no such part there is no height to take a fraction
1876
- * of, and inventing one is not done: the track keeps the reference's
1877
- * amplitude, {@link REFERENCE_BREATH_AMP}, and the note says so.
1984
+ * The drafted breath (issues #252, #270): `chest` `translatey`, its travel peak
1985
+ * to peak {@link BREATH_FRACTION} of the torso's length — the distance from the
1986
+ * `neck` bone's origin to the `hip` bone's, as the proposal writes them (so the
1987
+ * figure can be recomputed from proposal.json): the run the chest is placed
1988
+ * half way along and the trunk segments are laid along (issue #243), whichever
1989
+ * rule placed the two ends (joints, the face and bottomwear, the waist, or the
1990
+ * figure's proportion — the hip's own note names it). Amplitude half of it to
1991
+ * two decimals, and the base the amplitude. When neck and hip are one point the
1992
+ * trunk has no length to take a fraction of, and inventing one is not done: the
1993
+ * track keeps the reference's amplitude, {@link REFERENCE_BREATH_AMP}, and the
1994
+ * note says so.
1878
1995
  */
1879
- export function breathTrack(P: PartSet): { track: ProposedSingleTrack; note: string } {
1880
- const parts = P.byTag(BREATH_SIZE_TAG, undefined, true);
1881
- if (parts.length === 0) {
1996
+ export function breathTrack(neck: readonly [number, number], hip: readonly [number, number]): { track: ProposedSingleTrack; note: string } {
1997
+ const length = edgeLength(hip[0] - neck[0], hip[1] - neck[1]);
1998
+ const run = `neck ${pyIntList(neck)} -> hip ${pyIntList(hip)}`;
1999
+ if (length === 0) {
1882
2000
  const a = REFERENCE_BREATH_AMP;
1883
2001
  return {
1884
2002
  track: { bone: 'chest', prop: 'translatey', amp: a, period: REFERENCE_IDLE_SECONDS, phase: 0.0, base: a },
1885
- note: `breath: no ${BREATH_SIZE_TAG} part, so no torso height to take a fraction of: chest translatey keeps the reference's amplitude ${a} (${pyFixed(2 * a, 2)} px peak to peak), not ${pyFixed(BREATH_FRACTION * 100, 1)} % of a torso — scale it to the figure`,
2003
+ note: `breath: ${run} are one point, so the trunk has no length to take a fraction of: chest translatey keeps the reference's amplitude ${a} (${pyFixed(2 * a, 2)} px peak to peak), not ${pyFixed(BREATH_FRACTION * 100, 1)} % of a torso — scale it to the figure`,
1886
2004
  };
1887
2005
  }
1888
- const top = Math.min(...parts.map((p) => p.y));
1889
- const bottom = Math.max(...parts.map((p) => p.y + p.h));
1890
- const height = bottom - top;
1891
- const amp = roundTo((BREATH_FRACTION * height) / 2, 2);
1892
- const layers = [...new Set(parts.map((p) => p.from))].join(', ');
2006
+ const amp = roundTo((BREATH_FRACTION * length) / 2, 2);
1893
2007
  return {
1894
2008
  track: { bone: 'chest', prop: 'translatey', amp, period: REFERENCE_IDLE_SECONDS, phase: 0.0, base: amp },
1895
- note: `breath: chest translatey ${pyFixed(2 * amp, 2)} px peak to peak (amp ${pyFixed(amp, 2)}, base ${pyFixed(amp, 2)}) = ${pyFixed(BREATH_FRACTION * 100, 1)} % of the torso's height, ${height} px (layer ${layers}, y ${top}..${bottom}), once per ${IDLE_FRAMES}-frame idle at ${IDLE_FPS} fps`,
2009
+ note: `breath: chest translatey ${pyFixed(2 * amp, 2)} px peak to peak (amp ${pyFixed(amp, 2)}, base ${pyFixed(amp, 2)}) = ${pyFixed(BREATH_FRACTION * 100, 1)} % of the torso's length, ${pyFixed(length, 1)} px (${run}), once per ${IDLE_FRAMES}-frame idle at ${IDLE_FPS} fps`,
1896
2010
  };
1897
2011
  }
1898
2012
 
@@ -1915,7 +2029,7 @@ interface TrunkLine {
1915
2029
  function trunkLineOf(neck: readonly [number, number], hip: readonly [number, number]): TrunkLine | null {
1916
2030
  const dx = hip[0] - neck[0];
1917
2031
  const dy = hip[1] - neck[1];
1918
- const len = Math.hypot(dx, dy);
2032
+ const len = edgeLength(dx, dy);
1919
2033
  if (len === 0) return null;
1920
2034
  const u: [number, number] = [dx / len, dy / len];
1921
2035
  return { u, v: [u[1], -u[0]] };
@@ -2308,7 +2422,7 @@ function jointNotes(joints: RigJoints, f: JointNoteFacts): string[] {
2308
2422
  if (j.at === null) return;
2309
2423
  const e = f.eyes[i];
2310
2424
  if (e === null) tell(n, `${said(n, j)}: no bone takes it — an eye bone comes only from an eyewhite part`);
2311
- else tell(n, `${said(n, j)}: bone ${e.bone} is measured off ${e.part} (its box centre ${fmtAt(e.at)}) and is not moved; the joint is ${pyFixed(Math.hypot(j.at[0] - e.at[0], j.at[1] - e.at[1]), 1)} px from it`);
2425
+ else tell(n, `${said(n, j)}: bone ${e.bone} is measured off ${e.part} (its box centre ${fmtAt(e.at)}) and is not moved; the joint is ${pyFixed(edgeLength(j.at[0] - e.at[0], j.at[1] - e.at[1]), 1)} px from it`);
2312
2426
  });
2313
2427
  const rest = KEYPOINT_NAMES.filter((n) => !told.has(n));
2314
2428
  if (rest.length > 0) out.push(`no rule reads ${rest.map((n) => said(n, J[n])).join(', ')} — no bone is authored from them`);
@@ -2875,8 +2989,8 @@ export function lint(
2875
2989
  if (chest !== null && !(along(chest) > 0 && along(chest) < 1)) findings.push({ kind: 'chest-not-between', chest, t: along(chest), neck: T.neck, hips: T.hips });
2876
2990
  if (hip !== null && chest !== null && along(hip) <= along(chest)) findings.push({ kind: 'hip-not-past-chest', hip, chest, tHip: along(hip), tChest: along(chest) });
2877
2991
  if (hip !== null) {
2878
- const dNeck = Math.hypot(hip[0] - T.neck[0], hip[1] - T.neck[1]);
2879
- const dHips = Math.hypot(hip[0] - T.hips[0], hip[1] - T.hips[1]);
2992
+ const dNeck = edgeLength(hip[0] - T.neck[0], hip[1] - T.neck[1]);
2993
+ const dHips = edgeLength(hip[0] - T.hips[0], hip[1] - T.hips[1]);
2880
2994
  if (dNeck < dHips) findings.push({ kind: 'hip-nearer-neck', hip, neck: T.neck, hips: T.hips, dNeck, dHips });
2881
2995
  }
2882
2996
  } else {
@@ -2893,7 +3007,7 @@ export function lint(
2893
3007
  for (const c of basis.chains) {
2894
3008
  if (c.line === null) continue;
2895
3009
  const { from, to } = c.line;
2896
- const len = Math.hypot(to[0] - from[0], to[1] - from[1]);
3010
+ const len = edgeLength(to[0] - from[0], to[1] - from[1]);
2897
3011
  const e = spec.bones.find((b) => 'chain' in b && b.chain === c.chain);
2898
3012
  if (e === undefined || !('chain' in e)) continue;
2899
3013
  const pts: Array<[string, Point]> = [...e.points.map((q, i): [string, Point] => [`${c.chain}${i}`, q]), [`${c.chain} tip`, e.tip]];
@@ -3105,7 +3219,7 @@ export function compare(proposal: readonly BoneEntry[], config: readonly BoneEnt
3105
3219
  const rows: Array<[string, number]> = [];
3106
3220
  for (const [n, cb] of b) {
3107
3221
  const pb = a.get(n);
3108
- if (pb !== undefined) rows.push([n, Math.hypot(pb.x - cb.x, pb.y - cb.y)]);
3222
+ if (pb !== undefined) rows.push([n, edgeLength(pb.x - cb.x, pb.y - cb.y)]);
3109
3223
  }
3110
3224
  return { rows, onlyConfig: [...b.keys()].filter((n) => !a.has(n)), onlyProposal: [...a.keys()].filter((n) => !b.has(n)) };
3111
3225
  }
@@ -3174,7 +3288,7 @@ function line(r: Raster, a: readonly [number, number], b: readonly [number, numb
3174
3288
  function dot(r: Raster, cx: number, cy: number): void {
3175
3289
  for (let dy = -4; dy <= 4; dy++) {
3176
3290
  for (let dx = -4; dx <= 4; dx++) {
3177
- const d = Math.hypot(dx, dy);
3291
+ const d = edgeLength(dx, dy);
3178
3292
  if (d <= 3.5) setPx(r, Math.round(cx) + dx, Math.round(cy) + dy, [255, 0, 0]);
3179
3293
  else if (d <= 4.5) setPx(r, Math.round(cx) + dx, Math.round(cy) + dy, [0, 0, 0]);
3180
3294
  }
@@ -55,6 +55,7 @@ import { refuseIfAny } from './errors.ts';
55
55
  import { type BoneWorld, type GeometryPose, type MeshRest, stretchFigures, stretchSeverity } from './instruments.ts';
56
56
  import { ART_ALPHA } from './mesh.ts';
57
57
  import { alphaAbove, type Raster } from './raster/types.ts';
58
+ import { edgeLength } from './length.ts';
58
59
 
59
60
  /** The file's `spec`. */
60
61
  export const REQUIREMENTS_SPEC = 'spine-parts-requirements/1';
@@ -820,7 +821,7 @@ export function contactLine(r: ContactRequirement, p: Poses, facts: RigFacts): R
820
821
  for (let i = 0; i < p.times.length; i++) {
821
822
  const a = pointOf(track.frames[i], r.point, len);
822
823
  const b = at(i);
823
- const d = Math.hypot(a[0] - b[0], a[1] - b[1]);
824
+ const d = edgeLength(a[0] - b[0], a[1] - b[1]);
824
825
  if (d > worst) {
825
826
  worst = d;
826
827
  wi = i;
@@ -1214,7 +1215,7 @@ export function seamLine(r: SeamRequirement, pairs: readonly SeamPair[], frames:
1214
1215
  continue;
1215
1216
  }
1216
1217
  read++;
1217
- const o = Math.hypot(da[0] - db[0], da[1] - db[1]);
1218
+ const o = edgeLength(da[0] - db[0], da[1] - db[1]);
1218
1219
  if (o > worst) {
1219
1220
  worst = o;
1220
1221
  wi = i;