@rig-c/parts 1.20.0 → 1.22.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/README.md +7 -2
- package/docs/AUTHORING.md +57 -6
- package/docs/MIGRATION.md +19 -0
- package/docs/PAINTING.md +4 -3
- package/docs/STABILITY.md +4 -4
- package/package.json +1 -1
- package/src/assemble.ts +181 -26
- package/src/build.ts +19 -1
- package/src/check.ts +387 -5
- package/src/config.ts +75 -16
- package/src/limbs.ts +248 -5
- package/src/parts.ts +96 -6
- package/src/rig.ts +21 -0
- package/src/structure.ts +2 -1
package/src/limbs.ts
CHANGED
|
@@ -41,6 +41,46 @@
|
|
|
41
41
|
* shows no gap. The tool invents none of them: each is the config's cap
|
|
42
42
|
* image's where that image is not transparent, else the layer's where the
|
|
43
43
|
* limb has art there, else missing — counted, and named in the limb line.
|
|
44
|
+
* - **`cap_shape: "disc"`** (issue #360, items 10 and 12; opt-in on the
|
|
45
|
+
* segment below the joint). The radius is the limb's half-width about the
|
|
46
|
+
* joint, not half its width: the distance from the joint to the nearer end
|
|
47
|
+
* of the same run along the joint line, each end half a pixel past its
|
|
48
|
+
* centre ({@link halfWidthAbout}) — so the disc lies on the limb's art
|
|
49
|
+
* across the line however far off the run's middle the bone sits. The cap
|
|
50
|
+
* takes, after the image's and the layer's, the upper segment's art at a
|
|
51
|
+
* pixel the lower's layer has none of: the whole half-disc comes from the
|
|
52
|
+
* art. The upper piece's corner at the cut is kept where it is: on a joint
|
|
53
|
+
* off the limb's middle, a corner trimmed to the disc leaves a notch at
|
|
54
|
+
* rest when nothing takes it, or a stub out of the bent lower piece's
|
|
55
|
+
* outline when the lower takes it (issue #360, unit B's sheets).
|
|
56
|
+
* - **`edge: "feather"`** (issue #360, item 9; opt-in on the segment below the
|
|
57
|
+
* joint). The overlap band is the disc about the joint that the lower
|
|
58
|
+
* piece covers at every turn ({@link coveredRadius}: the cap's disc, less
|
|
59
|
+
* any pixel the cap is missing and a bilinear sample's reach). Inside it,
|
|
60
|
+
* on the upper side of the line, the upper piece's alpha is scaled by
|
|
61
|
+
* {@link featherWeight}: 0 on the line, 1 on the band's arc. The band fades
|
|
62
|
+
* out across its width over art that stays opaque under it at any angle,
|
|
63
|
+
* so at rest the two pieces composite to what they did and a bend never
|
|
64
|
+
* shows through it what lies behind the limb.
|
|
65
|
+
* - **`cut_band: N`** (issue #360, item 14; opt-in on the segment below a
|
|
66
|
+
* joint between two segments that take one part). The cut is placed, not
|
|
67
|
+
* moved by hand: of the lines parallel to the joint line at whole-pixel
|
|
68
|
+
* offsets `-N .. N` along the axis, the one that crosses the fewest stroke
|
|
69
|
+
* pixels ({@link placeCut}). A line crosses the pixels a joint line runs
|
|
70
|
+
* through, as {@link limbRun} reads them: the limb's art whose centre lies
|
|
71
|
+
* within half a pixel of it, across the run of art through its point. A
|
|
72
|
+
* **stroke pixel** is one of those, over the whole band, whose luma (the
|
|
73
|
+
* integer BT.601 `(299 R + 587 G + 114 B + 500) / 1000` truncated, the
|
|
74
|
+
* luma `recompositeErrorMap` in `src/assemble.ts` reads) is at or below the
|
|
75
|
+
* Otsu split of their luma histogram ({@link otsuDarkMax}: the dark class
|
|
76
|
+
* of the two that maximise the between-class variance). The split is
|
|
77
|
+
* derived from the band's own art and no number is typed: art of one luma
|
|
78
|
+
* has no split and so no stroke. A tie goes to the line nearer the joint,
|
|
79
|
+
* then to the one on the upper side, so with no stroke in the band the
|
|
80
|
+
* joint line itself is chosen. The bone does not move: the joint, the
|
|
81
|
+
* cap's disc and the limb's width stay where the bone's origin puts them;
|
|
82
|
+
* only the line that splits the pixels between the two pieces moves, and
|
|
83
|
+
* the cap takes the disc's pixels on the upper side of that line.
|
|
44
84
|
*/
|
|
45
85
|
import type { BoneEntry, Limb } from './config.ts';
|
|
46
86
|
|
|
@@ -144,11 +184,12 @@ export function segmentOf(lines: ReadonlyArray<JointLine | null>, first: number,
|
|
|
144
184
|
}
|
|
145
185
|
|
|
146
186
|
/**
|
|
147
|
-
* The limb's
|
|
148
|
-
*
|
|
149
|
-
*
|
|
187
|
+
* The limb's run of art along a joint line (module comment): the offsets
|
|
188
|
+
* along the line, from the joint, of the first and the last pixel centre of
|
|
189
|
+
* the run through the joint, `[first, last]` with `first <= last`. Null when
|
|
190
|
+
* no pixel of the run lies within 1 px of the joint.
|
|
150
191
|
*/
|
|
151
|
-
export function
|
|
192
|
+
export function limbRun(line: JointLine, art: (x: number, y: number) => boolean, box: readonly [number, number, number, number]): [number, number] | null {
|
|
152
193
|
const [ax, ay] = line.axis;
|
|
153
194
|
const ts: number[] = [];
|
|
154
195
|
for (let y = box[1]; y <= box[3]; y++) {
|
|
@@ -159,6 +200,11 @@ export function limbWidth(line: JointLine, art: (x: number, y: number) => boolea
|
|
|
159
200
|
ts.push(-dx * ay + dy * ax);
|
|
160
201
|
}
|
|
161
202
|
}
|
|
203
|
+
return runOfOffsets(ts);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The run through 0 of the offsets `ts` along a line ({@link limbRun}): sorted, the nearest within 1 px, grown while neighbours are at most 1.5 apart. Sorts `ts` in place. */
|
|
207
|
+
function runOfOffsets(ts: number[]): [number, number] | null {
|
|
162
208
|
if (ts.length === 0) return null;
|
|
163
209
|
ts.sort((a, b) => a - b);
|
|
164
210
|
let near = 0;
|
|
@@ -170,7 +216,204 @@ export function limbWidth(line: JointLine, art: (x: number, y: number) => boolea
|
|
|
170
216
|
let hi = near;
|
|
171
217
|
while (lo > 0 && ts[lo] - ts[lo - 1] <= 1.5) lo--;
|
|
172
218
|
while (hi + 1 < ts.length && ts[hi + 1] - ts[hi] <= 1.5) hi++;
|
|
173
|
-
return ts[
|
|
219
|
+
return [ts[lo], ts[hi]];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** The limb's width at a joint (module comment): its run's first to last pixel centre, plus one pixel. Null with no run. */
|
|
223
|
+
export function limbWidth(line: JointLine, art: (x: number, y: number) => boolean, box: readonly [number, number, number, number]): number | null {
|
|
224
|
+
const r = limbRun(line, art, box);
|
|
225
|
+
return r === null ? null : r[1] - r[0] + 1;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The limb's half-width about the joint (issue #360, `cap_shape: "disc"`): the
|
|
230
|
+
* distance from the joint to the nearer end of its run, each end half a pixel
|
|
231
|
+
* past its centre — `min(last + 0.5, 0.5 - first)`. Half the width when the
|
|
232
|
+
* joint sits in the middle of the run; less when it sits off it. Zero or below
|
|
233
|
+
* when the joint sits on the run's edge.
|
|
234
|
+
*/
|
|
235
|
+
export function halfWidthAbout(run: readonly [number, number]): number {
|
|
236
|
+
return Math.min(run[1] + 0.5, 0.5 - run[0]);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Where a pixel's centre lies from a joint: `along` the axis (above 0 past the line), and `across` it (the run's offset). */
|
|
240
|
+
export function jointOffsets(line: JointLine, px: number, py: number): { along: number; across: number } {
|
|
241
|
+
const dx = px + 0.5 - line.at[0];
|
|
242
|
+
const dy = py + 0.5 - line.at[1];
|
|
243
|
+
return { along: dx * line.axis[0] + dy * line.axis[1], across: -dx * line.axis[1] + dy * line.axis[0] };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The radius the feathered edge fades over (issue #360, `edge: "feather"`):
|
|
248
|
+
* the disc about the joint the lower piece covers at every turn. It is the
|
|
249
|
+
* distance from the joint to the nearest pixel centre the lower piece does not
|
|
250
|
+
* hold at alpha 255 (`opaque` false; searched out to `radius` + 2), less
|
|
251
|
+
* sqrt(2), the farthest a bilinear sample reaches to its four texels — so a
|
|
252
|
+
* pixel within it, turned by any angle about the joint, still samples only
|
|
253
|
+
* the lower's opaque art — and at most `radius`. Zero or below: no pixel is
|
|
254
|
+
* covered at every turn, and nothing is feathered.
|
|
255
|
+
*/
|
|
256
|
+
export function coveredRadius(at: Pt, radius: number, opaque: (x: number, y: number) => boolean): number {
|
|
257
|
+
const [x0, y0, w, h] = capBox(at, radius + 2);
|
|
258
|
+
let near = radius + 2;
|
|
259
|
+
for (let y = y0; y < y0 + h; y++) {
|
|
260
|
+
for (let x = x0; x < x0 + w; x++) {
|
|
261
|
+
if (opaque(x, y)) continue;
|
|
262
|
+
const d = Math.sqrt((x + 0.5 - at[0]) ** 2 + (y + 0.5 - at[1]) ** 2);
|
|
263
|
+
if (d < near) near = d;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return Math.min(radius, near - Math.SQRT2);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The feathered edge's weight on the upper piece (issue #360, `edge:
|
|
271
|
+
* "feather"`) at a pixel inside the joint's disc on the upper side of its line:
|
|
272
|
+
* its distance above the line over the disc's height there,
|
|
273
|
+
* `-along / sqrt(radius² - across²)` — 0 on the line, 1 on the disc's arc, so
|
|
274
|
+
* the overlap band fades out across its width. 1 where the disc has no height.
|
|
275
|
+
*/
|
|
276
|
+
export function featherWeight(radius: number, along: number, across: number, offset = 0): number {
|
|
277
|
+
const h2 = radius * radius - across * across;
|
|
278
|
+
if (h2 <= 0) return 1;
|
|
279
|
+
// Issue #360 (cut_band): with the cut `offset` px along the axis, the fade runs from the cut's line to the arc.
|
|
280
|
+
if (offset !== 0) {
|
|
281
|
+
const span = offset + Math.sqrt(h2);
|
|
282
|
+
return span <= 0 ? 1 : Math.max(0, Math.min(1, (offset - along) / span));
|
|
283
|
+
}
|
|
284
|
+
return Math.max(0, Math.min(1, -along / Math.sqrt(h2)));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** The integer BT.601 luma of a colour, `(299 R + 587 G + 114 B + 500) / 1000` truncated (the one `recompositeErrorMap` reads). */
|
|
288
|
+
export function luma601(r: number, g: number, b: number): number {
|
|
289
|
+
return Math.floor((299 * r + 587 * g + 114 * b + 500) / 1000);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* The Otsu split of a luma histogram (`hist[v]` pixels of luma `v`): the
|
|
294
|
+
* largest luma of the dark class, among the splits `luma <= T | luma > T`
|
|
295
|
+
* with a pixel on each side, that maximises the between-class variance
|
|
296
|
+
* `(T's dark sum · n − dark count · total sum)² / (dark count · light count)`
|
|
297
|
+
* (the variance times n², which ranks the splits alike). Compared exactly, in
|
|
298
|
+
* integers; a tie goes to the lower `T`. Null when no split has a pixel on
|
|
299
|
+
* each side — every pixel of one luma — or when every split's variance is 0.
|
|
300
|
+
*/
|
|
301
|
+
export function otsuDarkMax(hist: ArrayLike<number>): number | null {
|
|
302
|
+
let n = 0;
|
|
303
|
+
let sum = 0;
|
|
304
|
+
for (let v = 0; v < hist.length; v++) {
|
|
305
|
+
n += hist[v];
|
|
306
|
+
sum += v * hist[v];
|
|
307
|
+
}
|
|
308
|
+
const N = BigInt(n);
|
|
309
|
+
const S = BigInt(sum);
|
|
310
|
+
let best: number | null = null;
|
|
311
|
+
let bestNum = 0n;
|
|
312
|
+
let bestDen = 1n;
|
|
313
|
+
let n0 = 0;
|
|
314
|
+
let s0 = 0;
|
|
315
|
+
for (let t = 0; t + 1 < hist.length; t++) {
|
|
316
|
+
n0 += hist[t];
|
|
317
|
+
s0 += t * hist[t];
|
|
318
|
+
const n1 = n - n0;
|
|
319
|
+
if (n0 === 0 || n1 === 0) continue;
|
|
320
|
+
const d = BigInt(s0) * N - BigInt(n0) * S;
|
|
321
|
+
const num = d * d;
|
|
322
|
+
const den = BigInt(n0) * BigInt(n1);
|
|
323
|
+
if (num === 0n) continue;
|
|
324
|
+
if (best === null || num * bestDen > bestNum * den) {
|
|
325
|
+
best = t;
|
|
326
|
+
bestNum = num;
|
|
327
|
+
bestDen = den;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return best;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** A joint line moved `offset` px along its axis (issue #360, `cut_band`): the same axis, through `at + offset · axis`. */
|
|
334
|
+
export function shiftedLine(line: JointLine, offset: number): JointLine {
|
|
335
|
+
return offset === 0 ? line : { at: [line.at[0] + offset * line.axis[0], line.at[1] + offset * line.axis[1]], axis: line.axis };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** One line of a cut's band (issue #360, `cut_band`): its offset along the axis from the joint, and the stroke pixels it crosses. */
|
|
339
|
+
export interface CutCandidate {
|
|
340
|
+
offset: number;
|
|
341
|
+
crossed: number;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Where `cut_band` put the cut (module comment), or why it could not. */
|
|
345
|
+
export type CutPlacement =
|
|
346
|
+
| {
|
|
347
|
+
ok: true;
|
|
348
|
+
/** The chosen line's offset along the axis from the joint (above 0: past it, toward the lower segment). */
|
|
349
|
+
offset: number;
|
|
350
|
+
line: JointLine;
|
|
351
|
+
crossed: number;
|
|
352
|
+
/** The stroke pixels the joint line itself crosses. */
|
|
353
|
+
jointCrossed: number;
|
|
354
|
+
/** The Otsu split's dark class's largest luma, or null when the band's art has no split (no stroke). */
|
|
355
|
+
strokeLumaMax: number | null;
|
|
356
|
+
/** The art pixels the band's lines cross, counted once each. */
|
|
357
|
+
bandPx: number;
|
|
358
|
+
candidates: CutCandidate[];
|
|
359
|
+
}
|
|
360
|
+
| { ok: false; offset: number };
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Place a joint's cut within `band` px of its line (module comment, `cut_band`):
|
|
364
|
+
* every whole offset `-band .. band` is a candidate line; each crosses the
|
|
365
|
+
* art pixels (`art`) whose centre lies within half a pixel of it, across the
|
|
366
|
+
* run of art through its point ({@link limbRun}); the stroke pixels are those
|
|
367
|
+
* at or below the Otsu split of the luma (`luma`) of every pixel the band's
|
|
368
|
+
* lines cross. The fewest stroke pixels wins, then the smaller offset, then the
|
|
369
|
+
* negative one (the upper side). `{ok: false, offset}` names the offset nearest
|
|
370
|
+
* the joint (the negative one on a tie) whose line crosses no run of art: the
|
|
371
|
+
* band reaches past the limb there, and a band of `|offset| - 1` would not.
|
|
372
|
+
*/
|
|
373
|
+
export function placeCut(
|
|
374
|
+
line: JointLine,
|
|
375
|
+
band: number,
|
|
376
|
+
art: (x: number, y: number) => boolean,
|
|
377
|
+
luma: (x: number, y: number) => number,
|
|
378
|
+
box: readonly [number, number, number, number],
|
|
379
|
+
): CutPlacement {
|
|
380
|
+
const W = box[2] - box[0] + 1;
|
|
381
|
+
// One pass over the box: the art within band + 1 px of the joint line, a margin past every candidate's half pixel.
|
|
382
|
+
const near: Array<[number, number]> = [];
|
|
383
|
+
for (let y = box[1]; y <= box[3]; y++) {
|
|
384
|
+
for (let x = box[0]; x <= box[2]; x++) {
|
|
385
|
+
if (Math.abs(jointOffsets(line, x, y).along) <= band + 1 && art(x, y)) near.push([x, y]);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
const crossedBy: Array<number[] | null> = [];
|
|
389
|
+
for (let t = -band; t <= band; t++) {
|
|
390
|
+
const l = shiftedLine(line, t);
|
|
391
|
+
// The pixels limbRun reads on this line, with their offsets across it.
|
|
392
|
+
const on = near.map(([x, y]) => ({ x, y, o: jointOffsets(l, x, y) })).filter((q) => Math.abs(q.o.along) <= 0.5);
|
|
393
|
+
const run = runOfOffsets(on.map((q) => q.o.across));
|
|
394
|
+
crossedBy.push(run === null ? null : on.filter((q) => q.o.across >= run[0] && q.o.across <= run[1]).map((q) => (q.y - box[1]) * W + (q.x - box[0])));
|
|
395
|
+
}
|
|
396
|
+
// A line off the limb's art is no candidate: the one nearest the joint is named (the upper side first on a tie).
|
|
397
|
+
const off = crossedBy.map((px, i) => (px === null ? i - band : null)).filter((t): t is number => t !== null);
|
|
398
|
+
if (off.length > 0) return { ok: false, offset: off.reduce((a, t) => (Math.abs(t) < Math.abs(a) ? t : a)) };
|
|
399
|
+
const lines = crossedBy as number[][];
|
|
400
|
+
const lumaAt = (p: number): number => luma(box[0] + (p % W), box[1] + Math.floor(p / W));
|
|
401
|
+
const seen = new Set<number>();
|
|
402
|
+
const hist = new Array<number>(256).fill(0);
|
|
403
|
+
for (const px of lines) {
|
|
404
|
+
for (const p of px) {
|
|
405
|
+
if (seen.has(p)) continue;
|
|
406
|
+
seen.add(p);
|
|
407
|
+
hist[lumaAt(p)]++;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
const split = otsuDarkMax(hist);
|
|
411
|
+
const candidates = lines.map((px, i) => ({ offset: i - band, crossed: split === null ? 0 : px.filter((p) => lumaAt(p) <= split).length }));
|
|
412
|
+
let best = candidates[band];
|
|
413
|
+
for (const c of candidates) {
|
|
414
|
+
if (c.crossed < best.crossed || (c.crossed === best.crossed && (Math.abs(c.offset) < Math.abs(best.offset) || (Math.abs(c.offset) === Math.abs(best.offset) && c.offset < best.offset)))) best = c;
|
|
415
|
+
}
|
|
416
|
+
return { ok: true, offset: best.offset, line: shiftedLine(line, best.offset), crossed: best.crossed, jointCrossed: candidates[band].crossed, strokeLumaMax: split, bandPx: seen.size, candidates };
|
|
174
417
|
}
|
|
175
418
|
|
|
176
419
|
/** 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. */
|
package/src/parts.ts
CHANGED
|
@@ -45,7 +45,8 @@
|
|
|
45
45
|
* 1.6.0 has none, and still reads. `limb`, written only on a part a
|
|
46
46
|
* `config.limbs` segment made (issue #349), names the limb, the segment's
|
|
47
47
|
* index (0 the upper), the part it was cut from (`of`), the one bone it rides
|
|
48
|
-
* and, below a joint, the joint
|
|
48
|
+
* and, below a joint, the joint, its declared `range` when there is one
|
|
49
|
+
* (issue #360) and its cap ({@link LimbRecord}).
|
|
49
50
|
*
|
|
50
51
|
* ⭐ **A patch is 100 % source.** Every pixel of a `painting:` part is the
|
|
51
52
|
* painting's own, so it is all visible (the painting shows it; nothing of it
|
|
@@ -118,7 +119,9 @@ export interface PartRecord {
|
|
|
118
119
|
/**
|
|
119
120
|
* A limb segment's provenance (issue #349): the limb, the segment's index
|
|
120
121
|
* (0 the upper), the part its pixels were cut from (`of`), the bone it rides
|
|
121
|
-
* with weight 1, and, on a segment below a joint, the joint's name
|
|
122
|
+
* with weight 1, and, on a segment below a joint, the joint's name, the
|
|
123
|
+
* `range` its config segment declares (issue #360: `[min, max]` degrees from
|
|
124
|
+
* rest, written only when declared; `check` poses the joint there) and its cap.
|
|
122
125
|
*/
|
|
123
126
|
export interface LimbRecord {
|
|
124
127
|
name: string;
|
|
@@ -126,6 +129,7 @@ export interface LimbRecord {
|
|
|
126
129
|
of: string;
|
|
127
130
|
bone: string;
|
|
128
131
|
joint?: string;
|
|
132
|
+
range?: [number, number];
|
|
129
133
|
cap?: CapRecord;
|
|
130
134
|
}
|
|
131
135
|
|
|
@@ -147,10 +151,66 @@ export interface CapRecord {
|
|
|
147
151
|
/** Issue #359: with `image_px`, the hidden-art layer the cap's image came from (`<run>:<layer>:hidden`), when it came from one rather than the config's cap file. */
|
|
148
152
|
image_layer?: string;
|
|
149
153
|
missing_px: number;
|
|
154
|
+
/**
|
|
155
|
+
* Issue #360, written only under the segment's `cap_shape: "disc"`: the
|
|
156
|
+
* shape; the cap's pixels taken from the upper segment's art where the
|
|
157
|
+
* lower's layer had none (`upper_px`). `radius` is then the half-width about the
|
|
158
|
+
* joint, at most half of `width`.
|
|
159
|
+
*/
|
|
160
|
+
shape?: 'disc';
|
|
161
|
+
upper_px?: number;
|
|
162
|
+
/**
|
|
163
|
+
* Issue #360, written only under the segment's `edge: "feather"`: the edge,
|
|
164
|
+
* the radius of the band it fades over (the disc the lower piece covers at
|
|
165
|
+
* every turn, `coveredRadius` in `src/limbs.ts`), and the upper piece's
|
|
166
|
+
* pixels whose alpha it lowered.
|
|
167
|
+
*/
|
|
168
|
+
edge?: 'feather';
|
|
169
|
+
feather_radius?: number;
|
|
170
|
+
feathered_px?: number;
|
|
171
|
+
/**
|
|
172
|
+
* Issue #360 (item 14), written only under the segment's `cut_band`: the
|
|
173
|
+
* band; the chosen line's offset along the axis from the joint (above 0:
|
|
174
|
+
* past it, toward the lower segment) and a point it passes through; the
|
|
175
|
+
* stroke pixels it crosses and those the joint line crosses; the art
|
|
176
|
+
* pixels the band's lines cross, each once; and the stroke class's largest
|
|
177
|
+
* luma, absent when the band's art has no split (`placeCut` in
|
|
178
|
+
* `src/limbs.ts`). `at`, `width` and `radius` stay the joint's.
|
|
179
|
+
*/
|
|
180
|
+
cut_band?: number;
|
|
181
|
+
cut_offset?: number;
|
|
182
|
+
cut_at?: [number, number];
|
|
183
|
+
cut_crossed?: number;
|
|
184
|
+
joint_crossed?: number;
|
|
185
|
+
band_px?: number;
|
|
186
|
+
stroke_luma_max?: number;
|
|
150
187
|
}
|
|
151
188
|
|
|
152
|
-
const LIMB_KEYS = ['name', 'segment', 'of', 'bone', 'joint', 'cap'] as const;
|
|
153
|
-
const CAP_KEYS = [
|
|
189
|
+
const LIMB_KEYS = ['name', 'segment', 'of', 'bone', 'joint', 'range', 'cap'] as const;
|
|
190
|
+
const CAP_KEYS = [
|
|
191
|
+
'at',
|
|
192
|
+
'width',
|
|
193
|
+
'radius',
|
|
194
|
+
'box',
|
|
195
|
+
'layer_px',
|
|
196
|
+
'image_px',
|
|
197
|
+
'image_layer',
|
|
198
|
+
'missing_px',
|
|
199
|
+
'shape',
|
|
200
|
+
'upper_px',
|
|
201
|
+
'edge',
|
|
202
|
+
'feather_radius',
|
|
203
|
+
'feathered_px',
|
|
204
|
+
'cut_band',
|
|
205
|
+
'cut_offset',
|
|
206
|
+
'cut_at',
|
|
207
|
+
'cut_crossed',
|
|
208
|
+
'joint_crossed',
|
|
209
|
+
'band_px',
|
|
210
|
+
'stroke_luma_max',
|
|
211
|
+
] as const;
|
|
212
|
+
/** The cap keys only `cut_band` writes (issue #360, item 14). */
|
|
213
|
+
const CUT_KEYS = ['cut_offset', 'cut_at', 'cut_crossed', 'joint_crossed', 'band_px', 'stroke_luma_max'] as const;
|
|
154
214
|
|
|
155
215
|
/** The limb record with its keys in the fixed order, so two runs write the same bytes. */
|
|
156
216
|
function orderedLimb(l: LimbRecord): Record<string, unknown> {
|
|
@@ -470,6 +530,14 @@ function checkParts(raw: unknown, path: string): PartsFile {
|
|
|
470
530
|
const below = isCount(q.segment) && q.segment > 0;
|
|
471
531
|
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`);
|
|
472
532
|
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`);
|
|
533
|
+
// Issue #360: a declared range rides below a joint only, and holds the loader's rule — two degrees, rest in it.
|
|
534
|
+
if ('range' in q) {
|
|
535
|
+
const r = q.range;
|
|
536
|
+
if (!below) fail('PARTS_FIELD_PRESENT', `${object} "range"`, `is on segment ${show(q.segment)}; a range is the bend of a joint, so only a segment below one (index above 0) carries it`);
|
|
537
|
+
else if (!(Array.isArray(r) && r.length === 2 && r.every((n) => typeof n === 'number' && Number.isFinite(n)) && (r[0] as number) <= 0 && 0 <= (r[1] as number))) {
|
|
538
|
+
fail('PARTS_FIELD_TYPE', `${object} "range"`, `is ${show(r)}; [min, max], two finite degrees from rest with min <= 0 <= max, is required — config.limbs' rule (CONFIG_LIMB_RANGE)`);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
473
541
|
if ('cap' in q) {
|
|
474
542
|
const c = q.cap;
|
|
475
543
|
const co = typeof c === 'object' && c !== null && !Array.isArray(c) ? (c as Record<string, unknown>) : null;
|
|
@@ -484,8 +552,30 @@ function checkParts(raw: unknown, path: string): PartsFile {
|
|
|
484
552
|
isCount(co.layer_px) &&
|
|
485
553
|
isCount(co.missing_px) &&
|
|
486
554
|
(!('image_px' in co) || isCount(co.image_px)) &&
|
|
487
|
-
(!('image_layer' in co) || ('image_px' in co && typeof co.image_layer === 'string' && co.image_layer !== ''))
|
|
488
|
-
|
|
555
|
+
(!('image_layer' in co) || ('image_px' in co && typeof co.image_layer === 'string' && co.image_layer !== '')) &&
|
|
556
|
+
// Issue #360: a disc cap carries its count, a feathered edge its band and count, and neither carries them alone.
|
|
557
|
+
('shape' in co ? co.shape === 'disc' && isCount(co.upper_px) : !('upper_px' in co)) &&
|
|
558
|
+
('edge' in co ? co.edge === 'feather' && typeof co.feather_radius === 'number' && Number.isFinite(co.feather_radius) && co.feather_radius >= 0 && isCount(co.feathered_px) : !('feather_radius' in co) && !('feathered_px' in co)) &&
|
|
559
|
+
// Issue #360 (item 14): a placed cut carries its band, line and counts, the line inside the band; none of them rides alone.
|
|
560
|
+
('cut_band' in co
|
|
561
|
+
? isCount(co.cut_band) &&
|
|
562
|
+
co.cut_band >= 1 &&
|
|
563
|
+
Number.isInteger(co.cut_offset) &&
|
|
564
|
+
Math.abs(co.cut_offset as number) <= co.cut_band &&
|
|
565
|
+
pt(co.cut_at, 2) &&
|
|
566
|
+
isCount(co.cut_crossed) &&
|
|
567
|
+
isCount(co.joint_crossed) &&
|
|
568
|
+
isCount(co.band_px) &&
|
|
569
|
+
(co.cut_crossed as number) <= (co.joint_crossed as number) &&
|
|
570
|
+
(!('stroke_luma_max' in co) || (isCount(co.stroke_luma_max) && co.stroke_luma_max <= 255))
|
|
571
|
+
: CUT_KEYS.every((k) => !(k in co)));
|
|
572
|
+
if (!ok) {
|
|
573
|
+
fail(
|
|
574
|
+
'PARTS_FIELD_TYPE',
|
|
575
|
+
`${object} "cap"`,
|
|
576
|
+
`is ${show(c)}; {at: [x, y], width, radius, box: [x, y, w, h], layer_px, image_px?, image_layer? (with image_px), missing_px, shape? ("disc", with upper_px), edge? ("feather", with feather_radius and feathered_px), cut_band? (with cut_offset inside the band, cut_at, cut_crossed at most joint_crossed, band_px, stroke_luma_max? 0..255)} is required`,
|
|
577
|
+
);
|
|
578
|
+
}
|
|
489
579
|
}
|
|
490
580
|
}
|
|
491
581
|
}
|
package/src/rig.ts
CHANGED
|
@@ -1911,6 +1911,16 @@ export function rigJsonText(value: unknown): string {
|
|
|
1911
1911
|
* (`FLATTEN_REGION_UPRIGHT`) rather than flattened into an upright one the
|
|
1912
1912
|
* oriented rig does not draw.
|
|
1913
1913
|
*
|
|
1914
|
+
* A transform constraint that reads its source's **local** rotation and adds
|
|
1915
|
+
* it to its targets' local rotations (`localSource`, `localTarget`,
|
|
1916
|
+
* `additive`: the cloth recipe's swing, issue #364) reads the source's setup
|
|
1917
|
+
* rotation, which the flat form drops; spine-core takes the source's rotation
|
|
1918
|
+
* less the `rotate` property's `offset`, so the flat form's offset is the
|
|
1919
|
+
* offset less that setup rotation, and the targets turn by the same amount in
|
|
1920
|
+
* both forms (issue #372: copied as written, the swing turned every skirt tree
|
|
1921
|
+
* 90 degrees in the flat form of the `wind` example). Every other constraint
|
|
1922
|
+
* is copied as written.
|
|
1923
|
+
*
|
|
1914
1924
|
* `sense` is the direction rotations are read in: 1, always, but for the
|
|
1915
1925
|
* planted control that shows a flatten turning the wrong way is caught.
|
|
1916
1926
|
*/
|
|
@@ -1968,6 +1978,17 @@ export function flattenRig(rig: RigSpec, sense: 1 | -1 = 1): RigSpec {
|
|
|
1968
1978
|
}
|
|
1969
1979
|
refuseIfAny(problems);
|
|
1970
1980
|
const flat: RigSpec = { ...rig, bones, skins: { default: skin } };
|
|
1981
|
+
if (rig.constraints !== undefined) {
|
|
1982
|
+
const setup = new Map(read.map((b) => [b.name, b.rotation]));
|
|
1983
|
+
flat.constraints = rig.constraints.map((c) => {
|
|
1984
|
+
const props = c.properties as Record<string, Record<string, unknown>> | undefined;
|
|
1985
|
+
const rotate = props?.rotate;
|
|
1986
|
+
const turned = typeof c.source === 'string' ? (setup.get(c.source) ?? 0) : 0;
|
|
1987
|
+
if (c.type !== 'transform' || c.localSource !== true || c.localTarget !== true || c.additive !== true || rotate === undefined || turned === 0) return c;
|
|
1988
|
+
const offset = typeof rotate.offset === 'number' ? rotate.offset : 0;
|
|
1989
|
+
return { ...c, properties: { ...props, rotate: { ...rotate, offset: placed(offset - turned, OFFSET_PLACES) } } };
|
|
1990
|
+
});
|
|
1991
|
+
}
|
|
1971
1992
|
if (rig.invariants?.axes !== undefined) {
|
|
1972
1993
|
const { axes: _axes, ...rest } = rig.invariants;
|
|
1973
1994
|
if (Object.keys(rest).length > 0) flat.invariants = rest;
|
package/src/structure.ts
CHANGED
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
* - a **config**: an object with a `key`, read by the full config loader;
|
|
16
16
|
* - a **proposal**: an object with `bones` and no `key` or `spec`, as
|
|
17
17
|
* `propose` writes it, read by `parseProposalSections` (the loader's rules
|
|
18
|
-
* for bones, meshes, regions and motion
|
|
18
|
+
* for bones, meshes, regions and motion, and for the constraints
|
|
19
|
+
* `propose --cloth` writes).
|
|
19
20
|
*
|
|
20
21
|
* Anything else is refused by name. A file's name is never read.
|
|
21
22
|
*
|