rig-parts 1.9.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 CHANGED
@@ -41,10 +41,10 @@ from the full run, because the twin tails leave the head crop sideways (a plan e
41
41
  <code>loop: idle 41 frame(s) at 12 fps, f0000 vs f0040 (t = 3.333333333333333s): max |d| 0 (0 required)</code><br/>
42
42
  <code>seam: setup pose at 654x1216, scale 0.9438: mean |d| 0.147 (&lt;= 1.0), 0 px over 40, 0.00 rig px (the count / s²; &lt;= 50), 0 px over 80 (reported)</code><br/>
43
43
  <code>pack: skeleton.png 1051x972, 23 region(s), 96.9% covered, padding 2, page edges free, shape polygon</code><br/>
44
- The loop is that build's indexed APNG, 40 frames at 12 fps, 725,845 bytes, one
44
+ The loop is that build's indexed APNG, 40 frames at 12 fps, 704,009 bytes, one
45
45
  256-entry palette at a measured error of max 70, mean 0.631 per channel over every
46
- frame (the lossless APNG beside it, the exactness record, is 4,514,756 bytes; the
47
- GIF, at the same error, 784,074). The rig's hair, skirt, sleeves, bows and earrings
46
+ frame (the lossless APNG beside it, the exactness record, is 4,446,263 bytes; the
47
+ GIF, at the same error, 760,294). The rig's hair, skirt, sleeves, bows and earrings
48
48
  carry physics, which rigc's render starts from rest, so the idle is played three
49
49
  periods ahead and the loop is the fourth: the frame one period after its first differs
50
50
  from it by max 7 levels, 0 px over 40 (<code>--lead-in</code>). Frame 29 (2.417 s) is the
package/docs/AUTHORING.md CHANGED
@@ -97,7 +97,7 @@ read by no CPU stage).
97
97
  | `meshes.<part>.ribs` | rig | **authored**, optional (issue #188), in the `contour` and `auto` modes: `{<chain>: {stations}, …}`, each key a chain this mesh's `segments` name. Each link of the chain gets a row of vertices across the part at its origin and at `stations` points evenly between its origin and its end (`stations`, a whole number, 0 or more, required; a chain of L links carries L × (stations + 1) ribs), each rib a row of vertices across the part, kept as a row of locked edges; the rule is "Ribs along a chain" under "A contour mesh". Absent places none and changes no byte. Refused: a lattice mesh with ribs (`CONFIG_RIBS_MODE` — its vertices are its grid's corners), a key that is not one of the mesh's chains (`CONFIG_NAME_RESOLVES`), an object naming no chain or a `stations` that is not a whole number from 0 (`CONFIG_FIELD_TYPE`); at the rig stage `CONTOUR_RIB_ART`, `CONTOUR_RIB_LINK`, `CONTOUR_RIB_CROSSING`, `CONTOUR_RIB_EDGE` (the contour refusals). The mesh's `mesh_report.json` row gains `ribs` — the declaration, the ribs placed and the vertices they hold — and its contour report (the source's, in `auto`) lists each rib's vertices |
98
98
  | `regions.<part>` | rig | **proposed**: the bone a rigid part rides. Every plan part is exactly one of a mesh or a region (`CONFIG_PART_ATTACHED`) |
99
99
  | `motion.duration` | rig; check (the loop is measured at this time) | proposed as 40 frames at 12 fps, `40/12` s, written `3.3333333333333335` (issue #252; the reference proposed 4 s, and every period it drafted is scaled with it so each track keeps its cycles per idle); a whole number of 1/12 s ticks, because `check` renders at 12 fps and compares frame 0 with the frame at this time — read within 10^-9 s (`TIME_SLACK`, issue #244), since a whole number of frames is often not exact in binary: rigc's 40th frame sits at `40 × (1/12)` = `3.333333333333333`. Half a frame off is `CHECK_LOOP_LAST_FRAME_AT_DURATION` |
100
- | `motion.tracks` | rig | **proposed**, then tuned. Single `{bone, prop, amp, period, phase, base?}` or chain `{chain, amps, period, phase, lag}` — one amplitude per link, link `i` at phase `phase + lag·i`. Every `period` must divide `duration` (`CONFIG_PERIOD_DIVIDES_DURATION`). A bone property is keyed by one track (`CONFIG_BONE_PROPERTY_KEYED_ONCE`): a chain keys `rotate` on every link, the blink's `eyes` group `scaley` and its `brows` group `translatey` on every member, so a single track on any of those is a second track on it — rigc would refuse it one stage later, `animation "idle" has two tracks on eye.scaley`. The proposed breath (issue #252) is `chest` `translatey` travelling 2.5 % of the `topwear` part's height peak to peak (amplitude half of it to two decimals, `base` the amplitude, so the chest rises from the setup pose and returns) — height, because the travel is along y; the `breath:` note prints the fraction, the part, its height and the pixels. No `topwear` part: no height to scale by, so the breath keeps the reference's amplitude 1.3 (2.6 px peak to peak) and the note says so — scale it to the figure. `chest` `scalex` and the `head` and `neck` rolls are a ratio and angles and are proposed the same on every figure. A chain link is turned along its chain (the `bones` row), so a `translatex`/`translatey` key on a bone whose parent is a turned link, or a `scale`/`shear` key on a turned link, would move or stretch along the link and not along the picture's axis: the rig stage refuses it (`RIG_KEY_FRAME_UNTURNED`); `rotate` is keyed on any bone |
100
+ | `motion.tracks` | rig | **proposed**, then tuned. Single `{bone, prop, amp, period, phase, base?}` or chain `{chain, amps, period, phase, lag}` — one amplitude per link, link `i` at phase `phase + lag·i`. Every `period` must divide `duration` (`CONFIG_PERIOD_DIVIDES_DURATION`). A bone property is keyed by one track (`CONFIG_BONE_PROPERTY_KEYED_ONCE`): a chain keys `rotate` on every link, the blink's `eyes` group `scaley` and its `brows` group `translatey` on every member, so a single track on any of those is a second track on it — rigc would refuse it one stage later, `animation "idle" has two tracks on eye.scaley`. The proposed breath (issues #252, #270) is `chest` `translatey` travelling 2.5 % of the torso's length peak to peak — the distance from the `neck` bone to the `hip` bone as the proposal writes them, the run the chest sits half way along, whichever rule placed them — (amplitude half of it to two decimals, `base` the amplitude, so the chest rises from the setup pose and returns); the `breath:` note prints the fraction, the length, the two bones and the pixels. Until #270 it was 2.5 % of the `topwear` part's height, and a sweater to the thighs (`scarf`, 690 px) breathed 2.9 times as deep as `demo`; on neck → hip `scarf` reads 431 px (10.78 px) and `demo` 192 px (4.80 px). Neck and hip on one point: no length to scale by, so the breath keeps the reference's amplitude 1.3 (2.6 px peak to peak) and the note says so — scale it to the figure. `chest` `scalex` and the `head` and `neck` rolls are a ratio and angles and are proposed the same on every figure. A chain link is turned along its chain (the `bones` row), so a `translatex`/`translatey` key on a bone whose parent is a turned link, or a `scale`/`shear` key on a turned link, would move or stretch along the link and not along the picture's axis: the rig stage refuses it (`RIG_KEY_FRAME_UNTURNED`); `rotate` is keyed on any bone |
101
101
  | `motion.blink` | rig | **proposed**: `{t, eyes, brows, squash, brow_drop}`; the whole blink, `t` to `t + 0.364` s (`t + 0.314` s without brows: the brows' keys are the ones that end last), must fit inside the idle (`RIG_BLINK_INSIDE_IDLE`). The `eyes` group's `scaley` squashes every part on the eye bones about the bone's origin, the eyewhite's centre. Optional: a figure with no `eyewhite-r`/`-l` part has no eye bone, so `propose` writes no `blink` and notes `no blink: no eyewhite part (looked for: eyewhite-r, eyewhite-l), …`; with eyes and no `eyebrow-r`/`-l` part it writes the blink without `brows` and `brow_drop` and notes `blink without brows: …`. `brows` and `brow_drop` are stated together or not at all (`CONFIG_BLINK_BROWS_PAIRED`), and a group that names no bone is refused (`CONFIG_BLINK_GROUP_MEMBERS`) — rigc would refuse it one stage later, `group "eyes" declares no members` — as is a group that names one bone twice (`CONFIG_BLINK_GROUP_UNIQUE`; rigc: `group "eyes" names member "eye" twice`). An `irides-*` or `eyelash-*` part on a side with no eyewhite has no eye bone to ride: `propose` places it on `head` as a region, which the blink does not move, and notes `no eyewhite part for eye_r (looked for: eyewhite-r): iris (head:irides-r) is placed on head as a region, …`. The timing is fixed, not a field (table below) |
102
102
  | `motion.blink.still` | rig | **proposed** only for a lash `propose` notes (below), then checked: `{<part>: {row, bone}}` — the rows of that region part above `row` (rig px, y down) are drawn by a second slot `<part>_still` on `bone` and do not blink; rows from `row` down keep the part's slot and blink. The part must be a region on a bone `eyes` names, `bone` one it does not (`CONFIG_STILL_OFF_THE_BLINK`), and `row` a row of the part with no art across its whole width (`RIG_STILL_ROW_CLEAR`) |
103
103
  | `motion.animations_from` | rig; check (named, not measured) | optional, never proposed (issue #183): a path, relative to the config file's directory, to a file in rig-c's motion-spec shape, `{"spec": "rigc-motion/1", "animations": {"<name>": {"duration": …, "tracks": [...]}, …}}` — an expression, a gesture, anything beside the idle. On every `rig` and `build` each of its animations is written into `rig/motion.json` after `idle`, as read: nothing added, re-keyed or judged here, and each is rigc's to accept or refuse at the rig stage's gate, in rigc's words. It may name what `motion.json` already holds — the bones `bones` declares, the easings `shut` and `open`, the groups `eyes` and `brows` when the blink writes them — and nothing else from the file reaches it. The loader refuses, every problem at once: no such file or one that cannot be read (`CONFIG_FILE_PRESENT`), text that is not JSON (`CONFIG_IS_JSON`), a file that is not an object, a `spec` other than `"rigc-motion/1"`, an `animations` that is not a table of objects (`CONFIG_FIELD_TYPE`), any other key — rig-c's `easings`, `groups`, `setup`, `physics`, `mix`, `archetype` and `cut` included, which would be read and not written (`CONFIG_KEY_KNOWN`), an animation named `idle` (`CONFIG_ANIMATION_NAME_FREE`), and a key written twice in one object of the file, which JSON would fold into its last value without a word (`CONFIG_KEY_UNIQUE`). `check` measures the idle alone and prints one line naming the others (`beside the idle: …`). Absent, `motion.json` holds the idle alone, byte for byte as before |
@@ -946,7 +946,7 @@ safe is the author's to choose here.
946
946
  | `AUTO_MESH_TERMINATION` | rig-c returned no mesh: `invalid-input` (the source fails its own `sourceBounds`, a region it refuses, protected influences over the cap), `unsupported-topology`, or `budget-exhausted` with `none-met-the-targets`; or, in the acceptance loop, a replay that does not end as rig-c's contract promises (`replayed-to-accepted-step` at the step asked, `candidatesTried` the full run's `acceptedAt[n − 1].step`, its `acceptedAt` the full run's first n operations, each the same `step`, `kind`, `count` and `sourceVertices`) |
947
947
  | `AUTO_MESH_ACCEPTED` | rig-c returned a mesh that is not accepted; the detail names every declared row not passing and the constraint that stopped it |
948
948
  | `AUTO_MESH_NO_STIMULUS` | neither the idle nor a declared `stimulus` moves some of the part's bound bones against the others, so no frame deforms it — the detail names the bones with no motion against each other and the field to declare; missing stimulus is not a pass; declare `meshes.<part>.auto.stimulus`, put the part in `contour` or `grid` mode, or key a bone it binds |
949
- | `AUTO_MESH_MOTION` | the reduced mesh against its source on the idle is not accepted and the acceptance loop wrote no step in its place: "no reduction passes the motion bound" (the full result's failing rows and every probe named; under `motion.selection`, "… among the tested candidates", with `maxProbes` and the termination), or the chosen step passes on the grid frames and not on the whole idle (its selection and held-out values and worst frames named); when the source itself is not accepted against itself, the full result's rows as before |
949
+ | `AUTO_MESH_MOTION` | the reduced mesh against its source on the idle is not accepted and the acceptance loop wrote no step in its place: "no reduction passes the motion bound" (the full result's failing rows and every probe named; under `motion.selection`, "… among the tested candidates", with `maxProbes` and the termination), or the chosen step passes on the grid frames and not on the whole idle (its selection and held-out values and worst frames named); when the source itself is not accepted against itself, one sentence first (issue #280): "the unreduced source itself fails <where> (<row> <value> against <bound>), so no replayed step can pass — change the source (its spacing or its outline) or lower the stimulus", `<where>` a declared stimulus by its animation's name (`at stimulus hem +150`), `at idle frame <id>`, or `in its geometry` for a geometry row, the stimulus remedy only when it was read at one; then the full result's rows, for the record. No search runs, and the stage's `replay "<part>"` line names the same place |
950
950
  | `AUTO_MESH_SELECTION_BUDGET` | `motion.selection.maxProbes` is below ⌈log₂(N − I)⌉, the bisection the multi-interval search runs whole first; N, I and the number required named; nothing replayed |
951
951
  | `AUTO_MESH_SELECTION_ROLES` | a multi-interval choice whose probes read a frame the final comparison holds out, or one it does not declare `selection`; the frames named, the held-out claim withdrawn |
952
952
  | `AUTO_MESH_MOTION_INPUT` | the comparison could not be made: rig-c refused its input (its code carried — `COMPARE_REFERENCE_FAILS` when the source fails its own `sourceBounds`, `COMPARE_INPUTS_DIFFER`, …), the reference build is red at rigc's gate, or a gate build wrote no `skeleton.model.json`; in the acceptance loop, a replayed step's build that rigc's gate refuses |
@@ -1286,9 +1286,11 @@ entry, no dithering, filter None) — and is the small file to show. `idle.gif`
1286
1286
  (`loop --out x.gif`) is the same median cut in a GIF. The indexed APNG and the GIF
1287
1287
  print their palette error, per channel over R, G and B of every frame (and alpha's
1288
1288
  max for the APNG); a figure is a measurement of the file, not a bar. On the demo
1289
- (the README's animation since issue #224's follow-up: `build --loop --lead-in 3`), on
1290
- the 40-frame idle of issue #252 (rig-c 2.40.0): 4,514,756 B lossless, 725,845 B indexed
1291
- and 784,074 B GIF, both palette files at max 70, mean 0.631; on the 4 s idle before it
1289
+ (the README's animation since issue #224's follow-up: `build --loop --lead-in 3`), with
1290
+ the breath of issue #270 (2.5 % of neck -> hip, 4.80 px peak to peak): 4,446,263 B lossless,
1291
+ 704,009 B indexed and 760,294 B GIF, both palette files at max 70, mean 0.631; on the
1292
+ 40-frame idle of issue #252 (rig-c 2.40.0, the breath 2.5 % of the topwear's height, 5.92 px):
1293
+ 4,514,756 B lossless, 725,845 B indexed and 784,074 B GIF, at the same error; on the 4 s idle before it
1292
1294
  (rig-c 2.33.0): 5,148,992, 727,486 and 781,526 B at max 69, mean 0.630 — the two share one quantiser, so their error is the same by
1293
1295
  construction and the size is the difference. From issue #227 until then, `build --loop`
1294
1296
  with default flags, before the demo carried physics: 5,832,634, 827,857 and 889,063 B
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rig-parts",
3
- "version": "1.9.0",
3
+ "version": "1.9.1",
4
4
  "description": "AI-authored 2D character parts from one anime painting and its See-through layer decomposition: measured parts, weighted-mesh rig specs, verified through rig-c before anything is written. A CLI for agents that cannot see the image.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -82,6 +82,7 @@
82
82
  import { type BoneMotionRange, type EnvelopeBoneRanges, MeshReductionError, type MeshQualityReport, type MeshReductionInput, type SkinningEnvelope, type SkinningEnvelopeBone, skinningEnvelopeBone } from 'rig-c/mesh';
83
83
  import type { ConfigConstraint } from './config.ts';
84
84
  import type { MotionKey, MotionSpec } from './motion.ts';
85
+ import { edgeLength } from './length.ts';
85
86
 
86
87
  /** What the derivation reads off the rig besides the part: the bones (name, parent), each bone's setup joint in crop px, the idle as written, the constraints. */
87
88
  export interface EnvelopeBasis {
@@ -168,7 +169,7 @@ export function boneRange(basis: EnvelopeBasis, bone: string, origin: readonly [
168
169
  rotate,
169
170
  scaleX,
170
171
  scaleY,
171
- translate: Math.hypot(tx, ty),
172
+ translate: edgeLength(tx, ty),
172
173
  setup: { ...SETUP },
173
174
  };
174
175
  }
package/src/automotion.ts CHANGED
@@ -344,6 +344,47 @@ export function motionRowText(r: MeasureRow): string {
344
344
  return `${r.code}${region} ${r.state}: ${r.reason ?? ''}`;
345
345
  }
346
346
 
347
+ /**
348
+ * Where a row of a comparison was read at its worst, in the words a reader can act on (issue #280), with its
349
+ * preposition: a declared stimulus by its animation's name (`at stimulus tail_l -10`, the frame rig-c ids
350
+ * `stimulus tail_l -10@explicit@0`), an idle frame as `at idle frame <id>`, a geometry row — read on the mesh, at
351
+ * no frame — as `in its geometry`, and a motion row that names no frame as `on the idle`.
352
+ */
353
+ export function readingPlace(r: MeasureRow, stimulus: readonly StimulusPose[] | null, geometry: boolean): { text: string; stimulus: boolean } {
354
+ const id = r.worst?.frame?.id;
355
+ if (id === undefined) return { text: geometry ? 'in its geometry' : 'on the idle', stimulus: false };
356
+ const name = (stimulus ?? []).flatMap((st) => stimulusAnimationNames(st)).find((n) => explicitFrameId(n) === id);
357
+ return name === undefined ? { text: `at idle frame ${id}`, stimulus: false } : { text: `at ${name}`, stimulus: true };
358
+ }
359
+
360
+ /**
361
+ * Issue #280: the source compared with itself failed, so no replayed step can pass — the one sentence that says
362
+ * so (`the unreduced source itself fails <where> (<row> <value> against <bound>), so …`): where (a declared stimulus by name, or the idle frame), each failing row and its value against its bound,
363
+ * and the remedy, which is the source's (its spacing or outline), or the stimulus's when the failure is read at
364
+ * one. A row the source fails against itself is an absolute reading (`MQ_INVERSION` counts the source's own
365
+ * flipped triangles), which is why no reduction of it can pass. Null when the comparison has no failing row.
366
+ */
367
+ export function sourceSelfFailure(self: MeshQualityReport, stimulus: readonly StimulusPose[] | null): { where: string; sentence: string } | null {
368
+ const rows = failingRows(self);
369
+ if (rows.length === 0) return null;
370
+ const geometryRows = new Set(self.candidates[0]?.geometry?.rows ?? []);
371
+ const places = rows.map((r) => readingPlace(r, stimulus, geometryRows.has(r)));
372
+ const distinct = [...new Set(places.map((p) => p.text))];
373
+ const where = distinct.join(', ');
374
+ // One place: the rows say only their values; several: each row says its own.
375
+ const read = rows
376
+ .map((r, i) => {
377
+ const at = distinct.length > 1 ? ` ${places[i].text}` : '';
378
+ return r.state === 'fail' || r.state === 'pass' || r.state === 'undeclared' ? `${r.code} ${r.value}${r.bound === null ? '' : ` against ${r.bound.op} ${r.bound.value}`}${at}` : `${r.code} ${r.state}${at}: ${r.reason ?? ''}`;
379
+ })
380
+ .join('; ');
381
+ const atStimulus = places.some((p) => p.stimulus);
382
+ return {
383
+ where,
384
+ sentence: `the unreduced source itself fails ${where} (${read}), so no replayed step can pass — change the source (its spacing or its outline)${atStimulus ? ' or lower the stimulus' : ''}`,
385
+ };
386
+ }
387
+
347
388
  /** The candidate's rows with a declared bound that did not pass, geometry then motion. */
348
389
  export function failingRows(report: MeshQualityReport): MeasureRow[] {
349
390
  const c = report.candidates[0];
package/src/build.ts CHANGED
@@ -46,7 +46,7 @@ import { type AnimFrame, EncodeError, encodeApng, encodeIndexedApng, INDEXED_DEF
46
46
  import { assemble, type AssembleResult, cutLines, driftLine, figuresLine, holeLines, type ProjectRule, type SeamRule, stageFields, uncoveredLine } from './assemble.ts';
47
47
  import { allocationClause, type Reducer, stageBClause, unboundedClause } from './automesh.ts';
48
48
  import { residualClause } from './autoenvelope.ts';
49
- import { type AutoMotionCase, failingRows, idleSchedule, localRow, motionClause, motionDeformation, motionDocument, motionInput, motionRowText, motionStimulus, motionVerdict, noStimulusProblem, type ReplayCandidate, runComparison, stimulusMotion, withStimulus } from './automotion.ts';
49
+ import { type AutoMotionCase, failingRows, idleSchedule, localRow, motionClause, motionDeformation, motionDocument, motionInput, motionRowText, motionStimulus, motionVerdict, noStimulusProblem, type ReplayCandidate, runComparison, sourceSelfFailure, stimulusMotion, withStimulus } from './automotion.ts';
50
50
  import {
51
51
  bisectAccepted,
52
52
  budgetProblem,
@@ -582,8 +582,12 @@ export function replaySearch(
582
582
  const self = runComparison(c.object, motionInput(c, motion, refModel, refModel, probeSchedule));
583
583
  if ('code' in self) return refuse(self);
584
584
  if (motionVerdict(c.object, self) !== null) {
585
- lines.push(`replay "${c.part}": the unreduced source is not accepted against itself on the grid frames; no search runs`);
586
- return { part: c.part, chosen: null, problems: run.problems, lines };
585
+ // Issue #280: the refusal says, in its own sentence, that the source fails itself — where, the row, the remedy;
586
+ // the reduction's reading follows it for the record. The line names the stimulus where it is read at one.
587
+ const why = sourceSelfFailure(self, c.stimulus);
588
+ lines.push(`replay "${c.part}": the unreduced source is not accepted against itself ${why === null ? 'on its selection frames' : why.where}; no search runs`);
589
+ const problems = why === null ? run.problems : run.problems.map((p) => (p.code === 'AUTO_MESH_MOTION' ? { ...p, detail: `${why.sentence}. The reduction's own reading, for the record: ${p.detail}` } : p));
590
+ return { part: c.part, chosen: null, problems, lines };
587
591
  }
588
592
  const selection = motion.selection;
589
593
  if (selection !== undefined && selection.maxProbes < maxReplays(N, I)) return refuse(budgetProblem(c.object, selection.maxProbes, N, I));
package/src/check.ts CHANGED
@@ -149,6 +149,7 @@ import { readPng, writePng } from './raster/png.ts';
149
149
  import { newFloatImage, newRaster, type Raster } from './raster/types.ts';
150
150
  import type { BaseTag } from './tags.ts';
151
151
  import { type ScaleTranslate, warpAffine } from './raster/warp.ts';
152
+ import { edgeLength } from './length.ts';
152
153
 
153
154
  // ---------------------------------------------------------------------------
154
155
  // the bars
@@ -2564,7 +2565,7 @@ function breathCarrier(
2564
2565
  const a0 = added[0];
2565
2566
  if (a0 === null || added.some((d) => d === null)) continue;
2566
2567
  const delta = (added as Array<[number, number]>).map(([x, y]) => [x - a0[0], y - a0[1]] as const);
2567
- const breath = Math.max(...delta.map(([x, y]) => Math.hypot(x, y)));
2568
+ const breath = Math.max(...delta.map(([x, y]) => edgeLength(x, y)));
2568
2569
  if (!(breath > 0)) continue;
2569
2570
  const share = weights.map((v) => v.filter((w) => atOrUnder(String(w.bone), k)).reduce((s, w) => s + (w.weight as number), 0));
2570
2571
  const held = m.shown.map((f, i) => (f === null ? null : { rest: f.rest, triangles: f.triangles, posed: f.posed.map((v, j) => v - share[j >> 1] * delta[i][j & 1]) }));
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
@@ -131,6 +131,7 @@ 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
133
  import { TURN_BONE, turnFractions, turnNearest, turnPivot, type TurnSpec } from './turn.ts';
134
+ import { edgeLength } from './length.ts';
134
135
 
135
136
  // ---------------------------------------------------------------------------
136
137
  // the output shape — config-shaped, key order as the reference writes it
@@ -188,20 +189,20 @@ export const IDLE_SECONDS = IDLE_FRAMES / IDLE_FPS;
188
189
  */
189
190
  export const REFERENCE_IDLE_SECONDS = 4;
190
191
  /**
191
- * The breath's travel, peak to peak, as a fraction of the torso part's height
192
- * (issue #252). The size is the height because the breath is a `translatey`:
193
- * its travel is read along y, against the part's extent along y. HQ's
194
- * calibration: about 2.5 %. On the trailer's seated character the torso part
195
- * (`topwear`) is 267 px tall and the reference's fixed 2.6 px peak to peak
196
- * (amplitude 1.3, the base 1.3 so the chest rises from its setup pose and
197
- * returns) was 0.97 % of it, read by its owner as a still paper doll; the
198
- * 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`.
199
202
  */
200
203
  export const BREATH_FRACTION = 0.025;
201
- /** 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. */
202
205
  export const REFERENCE_BREATH_AMP = 1.3;
203
- /** The See-through tag of the torso part, whose height the breath is a fraction of. */
204
- export const BREATH_SIZE_TAG = 'topwear';
205
206
 
206
207
  export interface Proposal {
207
208
  bones: BoneEntry[];
@@ -1270,10 +1271,12 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
1270
1271
  if (Object.keys(still).length > 0) blink.still = still;
1271
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`);
1272
1273
  }
1273
- // Issue #252: the breath's travel is BREATH_FRACTION of the torso part's height, not the reference's fixed 1.3 px
1274
- // amplitude. Its base stays its amplitude (the chest rises from the setup pose and returns). The other three tracks
1275
- // are an angle and a ratio and stay as drafted. A rig with no torso part keeps the reference's amplitude, and a note.
1276
- 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'));
1277
1280
  notes.push(breath.note);
1278
1281
  tracks.push(
1279
1282
  breath.track,
@@ -1680,7 +1683,7 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
1680
1683
  const [g, r] = gridR(p);
1681
1684
  const segs: Segment[] = [];
1682
1685
  for (const a of armPlan.arms.filter((q) => q.part === p)) {
1683
- 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]);
1684
1687
  const d: [number, number] = [(a.elbow[0] - a.shoulder[0]) / len, (a.elbow[1] - a.shoulder[1]) / len];
1685
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}`);
1686
1689
  }
@@ -1919,7 +1922,7 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
1919
1922
  const segs: Segment[] = [];
1920
1923
  for (const c of legPlan.chains.filter((q) => q.part === p)) {
1921
1924
  // The sleeve's chest stub, laid along the thigh from the hip: 14 px above the hip to 2 px below it.
1922
- 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]);
1923
1926
  const d: [number, number] = [(c.knee[0] - c.hip[0]) / len, (c.knee[1] - c.hip[1]) / len];
1924
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);
1925
1928
  }
@@ -1978,31 +1981,32 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
1978
1981
  }
1979
1982
 
1980
1983
  /**
1981
- * The drafted breath (issue #252): `chest` `translatey`, its travel peak to
1982
- * peak {@link BREATH_FRACTION} of the height of the {@link BREATH_SIZE_TAG}
1983
- * layer — the box around every part from it, cut pieces included, since a cut
1984
- * does not shorten the torso — amplitude half of it to two decimals, and the
1985
- * base the amplitude. With no such part there is no height to take a fraction
1986
- * of, and inventing one is not done: the track keeps the reference's
1987
- * 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.
1988
1995
  */
1989
- export function breathTrack(P: PartSet): { track: ProposedSingleTrack; note: string } {
1990
- const parts = P.byTag(BREATH_SIZE_TAG, undefined, true);
1991
- 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) {
1992
2000
  const a = REFERENCE_BREATH_AMP;
1993
2001
  return {
1994
2002
  track: { bone: 'chest', prop: 'translatey', amp: a, period: REFERENCE_IDLE_SECONDS, phase: 0.0, base: a },
1995
- 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`,
1996
2004
  };
1997
2005
  }
1998
- const top = Math.min(...parts.map((p) => p.y));
1999
- const bottom = Math.max(...parts.map((p) => p.y + p.h));
2000
- const height = bottom - top;
2001
- const amp = roundTo((BREATH_FRACTION * height) / 2, 2);
2002
- const layers = [...new Set(parts.map((p) => p.from))].join(', ');
2006
+ const amp = roundTo((BREATH_FRACTION * length) / 2, 2);
2003
2007
  return {
2004
2008
  track: { bone: 'chest', prop: 'translatey', amp, period: REFERENCE_IDLE_SECONDS, phase: 0.0, base: amp },
2005
- 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`,
2006
2010
  };
2007
2011
  }
2008
2012
 
@@ -2025,7 +2029,7 @@ interface TrunkLine {
2025
2029
  function trunkLineOf(neck: readonly [number, number], hip: readonly [number, number]): TrunkLine | null {
2026
2030
  const dx = hip[0] - neck[0];
2027
2031
  const dy = hip[1] - neck[1];
2028
- const len = Math.hypot(dx, dy);
2032
+ const len = edgeLength(dx, dy);
2029
2033
  if (len === 0) return null;
2030
2034
  const u: [number, number] = [dx / len, dy / len];
2031
2035
  return { u, v: [u[1], -u[0]] };
@@ -2418,7 +2422,7 @@ function jointNotes(joints: RigJoints, f: JointNoteFacts): string[] {
2418
2422
  if (j.at === null) return;
2419
2423
  const e = f.eyes[i];
2420
2424
  if (e === null) tell(n, `${said(n, j)}: no bone takes it — an eye bone comes only from an eyewhite part`);
2421
- 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`);
2422
2426
  });
2423
2427
  const rest = KEYPOINT_NAMES.filter((n) => !told.has(n));
2424
2428
  if (rest.length > 0) out.push(`no rule reads ${rest.map((n) => said(n, J[n])).join(', ')} — no bone is authored from them`);
@@ -2985,8 +2989,8 @@ export function lint(
2985
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 });
2986
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) });
2987
2991
  if (hip !== null) {
2988
- const dNeck = Math.hypot(hip[0] - T.neck[0], hip[1] - T.neck[1]);
2989
- 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]);
2990
2994
  if (dNeck < dHips) findings.push({ kind: 'hip-nearer-neck', hip, neck: T.neck, hips: T.hips, dNeck, dHips });
2991
2995
  }
2992
2996
  } else {
@@ -3003,7 +3007,7 @@ export function lint(
3003
3007
  for (const c of basis.chains) {
3004
3008
  if (c.line === null) continue;
3005
3009
  const { from, to } = c.line;
3006
- const len = Math.hypot(to[0] - from[0], to[1] - from[1]);
3010
+ const len = edgeLength(to[0] - from[0], to[1] - from[1]);
3007
3011
  const e = spec.bones.find((b) => 'chain' in b && b.chain === c.chain);
3008
3012
  if (e === undefined || !('chain' in e)) continue;
3009
3013
  const pts: Array<[string, Point]> = [...e.points.map((q, i): [string, Point] => [`${c.chain}${i}`, q]), [`${c.chain} tip`, e.tip]];
@@ -3215,7 +3219,7 @@ export function compare(proposal: readonly BoneEntry[], config: readonly BoneEnt
3215
3219
  const rows: Array<[string, number]> = [];
3216
3220
  for (const [n, cb] of b) {
3217
3221
  const pb = a.get(n);
3218
- 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)]);
3219
3223
  }
3220
3224
  return { rows, onlyConfig: [...b.keys()].filter((n) => !a.has(n)), onlyProposal: [...a.keys()].filter((n) => !b.has(n)) };
3221
3225
  }
@@ -3284,7 +3288,7 @@ function line(r: Raster, a: readonly [number, number], b: readonly [number, numb
3284
3288
  function dot(r: Raster, cx: number, cy: number): void {
3285
3289
  for (let dy = -4; dy <= 4; dy++) {
3286
3290
  for (let dx = -4; dx <= 4; dx++) {
3287
- const d = Math.hypot(dx, dy);
3291
+ const d = edgeLength(dx, dy);
3288
3292
  if (d <= 3.5) setPx(r, Math.round(cx) + dx, Math.round(cy) + dy, [255, 0, 0]);
3289
3293
  else if (d <= 4.5) setPx(r, Math.round(cx) + dx, Math.round(cy) + dy, [0, 0, 0]);
3290
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;
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 };