@rig-c/parts 1.8.0 → 1.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -6
- package/cli.ts +26 -4
- package/docs/AUTHORING.md +27 -9
- package/docs/STABILITY.md +9 -4
- package/package.json +1 -1
- package/src/autoenvelope.ts +2 -1
- package/src/automotion.ts +41 -0
- package/src/build.ts +21 -4
- package/src/check.ts +114 -10
- package/src/config.ts +75 -1
- package/src/headframe.ts +2 -1
- package/src/instruments.ts +9 -5
- package/src/length.ts +21 -0
- package/src/propose.ts +158 -44
- package/src/requirements.ts +3 -2
- package/src/rig.ts +112 -2
- package/src/skeleton.ts +2 -1
- package/src/structure.ts +5 -4
- package/src/turn.ts +99 -0
package/src/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 =
|
|
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]];
|
package/src/instruments.ts
CHANGED
|
@@ -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,
|
|
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 =
|
|
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 =>
|
|
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,
|
|
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,
|
|
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
|
-
*
|
|
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
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
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
|
|
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
|
|
1178
|
-
//
|
|
1179
|
-
//
|
|
1180
|
-
|
|
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 =
|
|
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 =
|
|
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 (
|
|
1872
|
-
* peak {@link BREATH_FRACTION} of the
|
|
1873
|
-
*
|
|
1874
|
-
*
|
|
1875
|
-
*
|
|
1876
|
-
*
|
|
1877
|
-
*
|
|
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(
|
|
1880
|
-
const
|
|
1881
|
-
|
|
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:
|
|
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
|
|
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
|
|
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 =
|
|
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(
|
|
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 =
|
|
2879
|
-
const dHips =
|
|
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 =
|
|
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,
|
|
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 =
|
|
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
|
}
|
package/src/requirements.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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;
|