@rig-c/parts 1.15.0 → 1.17.0

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/limbs.ts ADDED
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Limbs cut at their joints (issue #349): the geometry `assemble` cuts a
3
+ * declared limb's layer by, and the cap each lower segment carries past its
4
+ * joint. Pure arithmetic on rig pixels (y down, origin top-left, the space of
5
+ * `parts.json` and of `config.bones`); no clock, no randomness.
6
+ *
7
+ * - **Where a bone is.** A single bone sits at its `at` and ends at its `tip`
8
+ * (no tip: no end); a chain's link `k` sits at `points[k]` and ends at
9
+ * `points[k + 1]`, the last link at `tip` ({@link bonePlaces}).
10
+ * - **Which limb a pixel belongs to.** Every pixel of a part a limb takes
11
+ * goes to one limb: the one whose polyline — its segments' bone origins in
12
+ * order, then the last bone's end when it has one — passes nearest the
13
+ * pixel's centre, by squared Euclidean distance to the polyline's pieces; a
14
+ * tie goes to the limb `config.limbs` declares first ({@link nearestLimb}).
15
+ * Two legs in one `legwear` layer are split this way, with no polygon.
16
+ * - **The joint line.** The joint between segments `k − 1` and `k` is the
17
+ * origin of segment `k`'s bone. Its line runs through that point,
18
+ * perpendicular to the limb's axis there: the sum of the unit directions of
19
+ * the upper bone (its origin to the joint) and the lower bone (the joint to
20
+ * its end, when it has one; the upper's alone when it has none) — on a
21
+ * straight limb the plain perpendicular, on a bent one the bisector
22
+ * ({@link jointLine}). A pixel is past the line when its centre's dot
23
+ * product with the axis, from the joint, is above 0; a centre on the line
24
+ * stays on the upper side.
25
+ * - **Which segment a pixel belongs to.** Among the consecutive segments of a
26
+ * limb that take one part, a pixel starts on the first and moves down one
27
+ * segment for each joint line it is past, in order, stopping at the first it
28
+ * is not past ({@link segmentOf}). A segment whose part is not the previous
29
+ * segment's takes its limb's share of its own part whole: no line is cut
30
+ * between two layers (the shin's legwear and the foot's footwear).
31
+ * - **The limb's width at a joint.** The pixels of the limb's art (alpha above
32
+ * 8, both segments' parts) whose centre lies within half a pixel of the
33
+ * joint line, read along the line: the run of them through the joint — the
34
+ * pixel nearest the joint must lie within 1 px of it, and the run grows while
35
+ * neighbours along the line are at most 1.5 px apart (a diagonal line's
36
+ * centres are up to sqrt(2) apart) — from its first to its last, plus one
37
+ * pixel ({@link limbWidth}). The cap's radius is half of it.
38
+ * - **The cap.** The pixels whose centre lies within the radius of the joint
39
+ * and on the upper side of its line, inside the box
40
+ * {@link capBox} prints: the overlap the lower segment carries so a bend
41
+ * shows no gap. The tool invents none of them: each is the config's cap
42
+ * image's where that image is not transparent, else the layer's where the
43
+ * limb has art there, else missing — counted, and named in the limb line.
44
+ */
45
+ import type { BoneEntry, Limb } from './config.ts';
46
+
47
+ export type Pt = [number, number];
48
+
49
+ /** A bone's origin and, when it has one, its end, in rig px. */
50
+ export interface BonePlace {
51
+ at: Pt;
52
+ end: Pt | null;
53
+ }
54
+
55
+ /** Where every bone `bones` declares sits and ends (module comment). */
56
+ export function bonePlaces(bones: readonly BoneEntry[]): Map<string, BonePlace> {
57
+ const out = new Map<string, BonePlace>();
58
+ for (const b of bones) {
59
+ if ('chain' in b) {
60
+ b.points.forEach((p, k) => out.set(`${b.chain}${k}`, { at: [p[0], p[1]], end: k + 1 < b.points.length ? [b.points[k + 1][0], b.points[k + 1][1]] : [b.tip[0], b.tip[1]] }));
61
+ } else out.set(b.name, { at: [b.at[0], b.at[1]], end: b.tip === undefined ? null : [b.tip[0], b.tip[1]] });
62
+ }
63
+ return out;
64
+ }
65
+
66
+ /** A limb's polyline: its segments' bone origins in order, then the last bone's end when it has one. */
67
+ export function limbPolyline(limb: Limb, places: ReadonlyMap<string, BonePlace>): Pt[] {
68
+ const pts = limb.segments.map((s) => (places.get(s.bone) as BonePlace).at);
69
+ const last = places.get(limb.segments[limb.segments.length - 1].bone) as BonePlace;
70
+ return last.end === null ? pts : [...pts, last.end];
71
+ }
72
+
73
+ /** The squared distance from `(x, y)` to the polyline `pts` (one point: to that point). */
74
+ export function polylineDist2(pts: readonly Pt[], x: number, y: number): number {
75
+ if (pts.length === 1) return (x - pts[0][0]) ** 2 + (y - pts[0][1]) ** 2;
76
+ let best = Infinity;
77
+ for (let i = 0; i + 1 < pts.length; i++) {
78
+ const [ax, ay] = pts[i];
79
+ const dx = pts[i + 1][0] - ax;
80
+ const dy = pts[i + 1][1] - ay;
81
+ const len2 = dx * dx + dy * dy;
82
+ const t = len2 === 0 ? 0 : Math.max(0, Math.min(1, ((x - ax) * dx + (y - ay) * dy) / len2));
83
+ const d = (x - ax - t * dx) ** 2 + (y - ay - t * dy) ** 2;
84
+ if (d < best) best = d;
85
+ }
86
+ return best;
87
+ }
88
+
89
+ /** The index, among `lines`, of the polyline nearest the pixel `(px, py)`'s centre; the first wins a tie. */
90
+ export function nearestLimb(lines: ReadonlyArray<readonly Pt[]>, px: number, py: number): number {
91
+ let best = 0;
92
+ let bestD = Infinity;
93
+ lines.forEach((l, i) => {
94
+ const d = polylineDist2(l, px + 0.5, py + 0.5);
95
+ if (d < bestD) {
96
+ bestD = d;
97
+ best = i;
98
+ }
99
+ });
100
+ return best;
101
+ }
102
+
103
+ /** A joint's line: through `at`, perpendicular to the unit `axis` (pointing from the upper segment to the lower). */
104
+ export interface JointLine {
105
+ at: Pt;
106
+ axis: Pt;
107
+ }
108
+
109
+ /** The unit vector along `(x, y)`, or null when it has no length. */
110
+ function unit(x: number, y: number): Pt | null {
111
+ const l = Math.sqrt(x * x + y * y);
112
+ return l === 0 ? null : [x / l, y / l];
113
+ }
114
+
115
+ /**
116
+ * The joint line between a bone whose origin is `upper` and one whose origin
117
+ * is `at` and whose end is `end` (null: none) — module comment. Null when no
118
+ * axis exists: the upper bone has no length, or the limb folds straight back
119
+ * on itself.
120
+ */
121
+ export function jointLine(upper: Pt, at: Pt, end: Pt | null): JointLine | null {
122
+ const u = unit(at[0] - upper[0], at[1] - upper[1]);
123
+ if (u === null) return null;
124
+ const w = end === null ? null : unit(end[0] - at[0], end[1] - at[1]);
125
+ const a = w === null ? u : unit(u[0] + w[0], u[1] + w[1]);
126
+ return a === null ? null : { at, axis: a };
127
+ }
128
+
129
+ /** Whether the pixel `(px, py)`'s centre lies past the line (on the lower segment's side); on the line is not past. */
130
+ export function pastJoint(line: JointLine, px: number, py: number): boolean {
131
+ return (px + 0.5 - line.at[0]) * line.axis[0] + (py + 0.5 - line.at[1]) * line.axis[1] > 0;
132
+ }
133
+
134
+ /**
135
+ * The segment, among `first .. last` (consecutive segments taking one part),
136
+ * a pixel belongs to: `first`, moved down one for each joint line in order it
137
+ * is past, stopping at the first it is not. `lines[k]` is the line above
138
+ * segment `k`.
139
+ */
140
+ export function segmentOf(lines: ReadonlyArray<JointLine | null>, first: number, last: number, px: number, py: number): number {
141
+ let k = first;
142
+ while (k < last && pastJoint(lines[k + 1] as JointLine, px, py)) k++;
143
+ return k;
144
+ }
145
+
146
+ /**
147
+ * The limb's width at a joint (module comment): `art(x, y)` says whether the
148
+ * limb has art at a pixel; the box `[x0, y0, x1, y1]` (inclusive) bounds the
149
+ * search. Null when no pixel of the run lies within 1 px of the joint.
150
+ */
151
+ export function limbWidth(line: JointLine, art: (x: number, y: number) => boolean, box: readonly [number, number, number, number]): number | null {
152
+ const [ax, ay] = line.axis;
153
+ const ts: number[] = [];
154
+ for (let y = box[1]; y <= box[3]; y++) {
155
+ for (let x = box[0]; x <= box[2]; x++) {
156
+ const dx = x + 0.5 - line.at[0];
157
+ const dy = y + 0.5 - line.at[1];
158
+ if (Math.abs(dx * ax + dy * ay) > 0.5 || !art(x, y)) continue;
159
+ ts.push(-dx * ay + dy * ax);
160
+ }
161
+ }
162
+ if (ts.length === 0) return null;
163
+ ts.sort((a, b) => a - b);
164
+ let near = 0;
165
+ ts.forEach((t, i) => {
166
+ if (Math.abs(t) < Math.abs(ts[near])) near = i;
167
+ });
168
+ if (Math.abs(ts[near]) > 1) return null;
169
+ let lo = near;
170
+ let hi = near;
171
+ while (lo > 0 && ts[lo] - ts[lo - 1] <= 1.5) lo--;
172
+ while (hi + 1 < ts.length && ts[hi + 1] - ts[hi] <= 1.5) hi++;
173
+ return ts[hi] - ts[lo] + 1;
174
+ }
175
+
176
+ /** The box `[x, y, w, h]` (rig px) of the pixels whose centre lies within `radius` of `at`: the cap image's size and place. */
177
+ export function capBox(at: Pt, radius: number): [number, number, number, number] {
178
+ const x0 = Math.ceil(at[0] - radius - 0.5);
179
+ const y0 = Math.ceil(at[1] - radius - 0.5);
180
+ const x1 = Math.floor(at[0] + radius - 0.5);
181
+ const y1 = Math.floor(at[1] + radius - 0.5);
182
+ return [x0, y0, x1 - x0 + 1, y1 - y0 + 1];
183
+ }
184
+
185
+ /** Whether the pixel `(px, py)`'s centre lies within `radius` of `at`. */
186
+ export function inDisc(at: Pt, radius: number, px: number, py: number): boolean {
187
+ return (px + 0.5 - at[0]) ** 2 + (py + 0.5 - at[1]) ** 2 <= radius * radius;
188
+ }
package/src/parts.ts CHANGED
@@ -42,7 +42,10 @@
42
42
  * cut from, which shares its `from`. The record is in draw order, and a piece
43
43
  * drawn `"back"` comes before its base, so order cannot say which of two parts
44
44
  * sharing a layer is the piece; this field does. A `parts.json` written before
45
- * 1.6.0 has none, and still reads.
45
+ * 1.6.0 has none, and still reads. `limb`, written only on a part a
46
+ * `config.limbs` segment made (issue #349), names the limb, the segment's
47
+ * index (0 the upper), the part it was cut from (`of`), the one bone it rides
48
+ * and, below a joint, the joint and its cap ({@link LimbRecord}).
46
49
  *
47
50
  * ⭐ **A patch is 100 % source.** Every pixel of a `painting:` part is the
48
51
  * painting's own, so it is all visible (the painting shows it; nothing of it
@@ -104,6 +107,49 @@ export interface PartRecord {
104
107
  seam_override_px: number;
105
108
  /** Absent when the part had no fringe pushed back (and in a `parts.json` the reference wrote). */
106
109
  fringe_pushed_back?: Array<{ part: string; px: number }>;
110
+ /** Written only on a part a `config.limbs` segment made (issue #349). */
111
+ limb?: LimbRecord;
112
+ }
113
+
114
+ /**
115
+ * A limb segment's provenance (issue #349): the limb, the segment's index
116
+ * (0 the upper), the part its pixels were cut from (`of`), the bone it rides
117
+ * with weight 1, and, on a segment below a joint, the joint's name and its cap.
118
+ */
119
+ export interface LimbRecord {
120
+ name: string;
121
+ segment: number;
122
+ of: string;
123
+ bone: string;
124
+ joint?: string;
125
+ cap?: CapRecord;
126
+ }
127
+
128
+ /**
129
+ * The cap a lower segment carries past its joint (issue #349): the disc's
130
+ * centre (the joint, rig px), its radius (half the limb's width across the
131
+ * joint line, `width`), the box its image covers `[x, y, w, h]`, and its
132
+ * pixels on the upper side of the joint line counted by where each came from
133
+ * — the layer (`layer_px`), the config's cap image (`image_px`, only with one)
134
+ * — or none (`missing_px`: no pixel in the layer and none in an image).
135
+ */
136
+ export interface CapRecord {
137
+ at: [number, number];
138
+ width: number;
139
+ radius: number;
140
+ box: [number, number, number, number];
141
+ layer_px: number;
142
+ image_px?: number;
143
+ missing_px: number;
144
+ }
145
+
146
+ const LIMB_KEYS = ['name', 'segment', 'of', 'bone', 'joint', 'cap'] as const;
147
+ const CAP_KEYS = ['at', 'width', 'radius', 'box', 'layer_px', 'image_px', 'missing_px'] as const;
148
+
149
+ /** The limb record with its keys in the fixed order, so two runs write the same bytes. */
150
+ function orderedLimb(l: LimbRecord): Record<string, unknown> {
151
+ const cap = l.cap === undefined ? undefined : Object.fromEntries(CAP_KEYS.filter((k) => (l.cap as CapRecord)[k] !== undefined).map((k) => [k, (l.cap as CapRecord)[k]]));
152
+ return Object.fromEntries(LIMB_KEYS.filter((k) => (k === 'cap' ? cap : l[k]) !== undefined).map((k) => [k, k === 'cap' ? cap : l[k]]));
107
153
  }
108
154
 
109
155
  /** One listed hole: its area, its box in rig pixels, and the parts beside it (`src/assemble.ts` `uncoveredHoles`). */
@@ -178,15 +224,18 @@ export const PART_KEYS = [
178
224
  'merged_px',
179
225
  'seam_override_px',
180
226
  'fringe_pushed_back',
227
+ 'limb',
181
228
  ] as const;
182
229
  /** The one part field that is not a count: a list of `{part, px}`, written only when it is not empty. */
183
230
  const FRINGE_KEY = 'fringe_pushed_back';
184
231
  /** The one part field that names another part: a cut's piece names its base, written only on a piece. */
185
232
  const CUT_FROM_KEY = 'cut_from';
186
- const COUNT_KEYS = PART_KEYS.slice(2).filter((k) => k !== FRINGE_KEY && k !== CUT_FROM_KEY) as ReadonlyArray<(typeof PART_KEYS)[number]>;
233
+ /** The one part field that is a record of its own: a limb segment's provenance (issue #349), written only on one. */
234
+ const LIMB_KEY = 'limb';
235
+ const COUNT_KEYS = PART_KEYS.slice(2).filter((k) => k !== FRINGE_KEY && k !== CUT_FROM_KEY && k !== LIMB_KEY) as ReadonlyArray<(typeof PART_KEYS)[number]>;
187
236
  /** The port's own counts: all three or none (none = a record the reference wrote). */
188
237
  export const VISIBILITY_KEYS = ['visible_px', 'occluded_px', 'visible_not_projected_px'] as const;
189
- const REQUIRED_KEYS = PART_KEYS.filter((k) => !(VISIBILITY_KEYS as readonly string[]).includes(k) && k !== FRINGE_KEY && k !== CUT_FROM_KEY);
238
+ const REQUIRED_KEYS = PART_KEYS.filter((k) => !(VISIBILITY_KEYS as readonly string[]).includes(k) && k !== FRINGE_KEY && k !== CUT_FROM_KEY && k !== LIMB_KEY);
190
239
 
191
240
  /** Where a part came from: a See-through run and a v3 tag or an author-made layer's `author:<label>` (issue #256), or the painting itself for a patch. */
192
241
  export type From = { run: 'full' | 'head'; tag: string } | { run: 'painting'; patch: string };
@@ -214,7 +263,7 @@ export function serializeParts(file: PartsFile): string {
214
263
  rig_size: file.rig_size,
215
264
  scale_rig_per_source: file.scale_rig_per_source,
216
265
  parts: file.parts.map((p) =>
217
- Object.fromEntries(PART_KEYS.filter((k) => p[k] !== undefined).map((k) => [k, k === FRINGE_KEY ? (p.fringe_pushed_back ?? []).map((q) => ({ part: q.part, px: q.px })) : p[k]])),
266
+ Object.fromEntries(PART_KEYS.filter((k) => p[k] !== undefined).map((k) => [k, k === FRINGE_KEY ? (p.fringe_pushed_back ?? []).map((q) => ({ part: q.part, px: q.px })) : k === LIMB_KEY ? orderedLimb(p.limb as LimbRecord) : p[k]])),
218
267
  ),
219
268
  ghost_px: Object.fromEntries(Object.keys(file.ghost_px).sort().map((k) => [k, file.ghost_px[k]])),
220
269
  ...(file.recomposite === undefined
@@ -374,6 +423,42 @@ function checkParts(raw: unknown, path: string): PartsFile {
374
423
  else if (b.from !== r.from) fail('PARTS_CUT_FROM_KNOWN', object, `names "${base}", whose from is ${show(b.from)}; this piece's is ${show(r.from)}, and a piece shares its base's layer`);
375
424
  }
376
425
  }
426
+ if (Array.isArray(o.parts)) {
427
+ // A limb segment (issue #349) names its limb, its index, the part it was cut from, its bone, and below a joint the joint and its cap.
428
+ for (const p of o.parts) {
429
+ if (typeof p !== 'object' || p === null || Array.isArray(p) || !(LIMB_KEY in p)) continue;
430
+ const r = p as Record<string, unknown>;
431
+ const object = `part "${String(r.name)}" field "${LIMB_KEY}"`;
432
+ const l = r[LIMB_KEY];
433
+ if (typeof l !== 'object' || l === null || Array.isArray(l)) {
434
+ fail('PARTS_FIELD_TYPE', object, `is ${show(l)}; an object {name, segment, of, bone, joint?, cap?} is required`);
435
+ continue;
436
+ }
437
+ const q = l as Record<string, unknown>;
438
+ for (const k of Object.keys(q)) if (!(LIMB_KEYS as readonly string[]).includes(k)) fail('PARTS_KEY_KNOWN', `${object} key "${k}"`, `is not a limb field; known: ${LIMB_KEYS.join(', ')}`);
439
+ for (const k of ['name', 'of', 'bone'] as const) if (!(typeof q[k] === 'string' && q[k] !== '')) fail('PARTS_FIELD_TYPE', `${object} "${k}"`, `is ${show(q[k])}; a non-empty string is required`);
440
+ if (!isCount(q.segment)) fail('PARTS_FIELD_TYPE', `${object} "segment"`, `is ${show(q.segment)}; the segment's index, a non-negative integer, is required`);
441
+ const below = isCount(q.segment) && q.segment > 0;
442
+ if (below !== ('joint' in q) || below !== ('cap' in q)) fail('PARTS_FIELD_PRESENT', object, `segment ${show(q.segment)} with${'joint' in q ? '' : 'out'} a joint and with${'cap' in q ? '' : 'out'} a cap; a segment below a joint (index above 0) has both, the upper segment neither`);
443
+ if ('joint' in q && !(typeof q.joint === 'string' && q.joint !== '')) fail('PARTS_FIELD_TYPE', `${object} "joint"`, `is ${show(q.joint)}; the joint's name is required`);
444
+ if ('cap' in q) {
445
+ const c = q.cap;
446
+ const co = typeof c === 'object' && c !== null && !Array.isArray(c) ? (c as Record<string, unknown>) : null;
447
+ const pt = (v: unknown, n: number): boolean => Array.isArray(v) && v.length === n && v.every((x) => typeof x === 'number' && Number.isFinite(x));
448
+ const ok =
449
+ co !== null &&
450
+ Object.keys(co).every((k) => (CAP_KEYS as readonly string[]).includes(k)) &&
451
+ pt(co.at, 2) &&
452
+ typeof co.width === 'number' &&
453
+ typeof co.radius === 'number' &&
454
+ pt(co.box, 4) &&
455
+ isCount(co.layer_px) &&
456
+ isCount(co.missing_px) &&
457
+ (!('image_px' in co) || isCount(co.image_px));
458
+ if (!ok) fail('PARTS_FIELD_TYPE', `${object} "cap"`, `is ${show(c)}; {at: [x, y], width, radius, box: [x, y, w, h], layer_px, image_px?, missing_px} is required`);
459
+ }
460
+ }
461
+ }
377
462
  if ('scene' in o) {
378
463
  const sc = o.scene;
379
464
  if (typeof sc !== 'object' || sc === null || Array.isArray(sc)) fail('PARTS_FIELD_TYPE', `${path} field "scene"`, `is ${show(sc)}; an object is required`);