@rig-c/parts 1.5.5 → 1.7.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 +16 -9
- package/cli.ts +19 -6
- package/docs/AUTHORING.md +110 -18
- package/docs/STABILITY.md +2 -2
- package/package.json +2 -2
- package/src/assemble.ts +329 -9
- package/src/build.ts +163 -11
- package/src/check.ts +224 -28
- package/src/instruments.ts +34 -10
- package/src/parts.ts +39 -2
- package/src/propose.ts +374 -45
- package/src/requirements.ts +58 -3
- package/src/rig.ts +214 -11
- package/src/scene.ts +14 -1
package/src/instruments.ts
CHANGED
|
@@ -186,6 +186,23 @@ export interface HalfTravels {
|
|
|
186
186
|
tip: number | null;
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
+
/** Where a chain's tip lies from its root on the screen (issue #231). */
|
|
190
|
+
export type Toward = 'down' | 'up' | 'left' | 'right';
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Where a chain's tip is from its root (issue #231): the vector from its first
|
|
194
|
+
* link's origin to its last link's at the setup pose, in the world (y up),
|
|
195
|
+
* read along whichever axis it runs further on — `down`/`up` when |dy| > |dx|,
|
|
196
|
+
* `left`/`right` when |dx| > |dy|. Null when it runs as far on both, which
|
|
197
|
+
* includes a chain whose links share one origin. `check` holds a part the
|
|
198
|
+
* scene declares `rises` to it: the chain carrying it must read `up`.
|
|
199
|
+
*/
|
|
200
|
+
export function chainToward(dx: number, dy: number): Toward | null {
|
|
201
|
+
if (Math.abs(dy) > Math.abs(dx)) return dy < 0 ? 'down' : 'up';
|
|
202
|
+
if (Math.abs(dx) > Math.abs(dy)) return dx > 0 ? 'right' : 'left';
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
|
|
189
206
|
/** A polygon as x, y pairs. */
|
|
190
207
|
type Polygon = number[];
|
|
191
208
|
|
|
@@ -348,17 +365,24 @@ function artPieces(art: ArtMask, a: PosedTriangles, stage: StageBox): ArtPieces
|
|
|
348
365
|
* Art outside every rest triangle is not drawn by the runtime and is not
|
|
349
366
|
* counted. A frame on which the slot shows nothing (null) is skipped; a half
|
|
350
367
|
* with no art on frame 0 is null. Each distinct rest attachment is cut once.
|
|
368
|
+
*
|
|
369
|
+
* `toward` is where the tip is from the root (issue #231): `down` — the upper
|
|
370
|
+
* half is the root's and the lower the tip's, the reading before #231 and the
|
|
371
|
+
* default — or `up`, the two swapped, for a part the scene declares `rises`.
|
|
351
372
|
*/
|
|
352
|
-
export function halfTravels(art: ArtMask, box: { x: number; y: number; w: number; h: number }, stage: StageBox, frames: ReadonlyArray<PosedTriangles | null
|
|
373
|
+
export function halfTravels(art: ArtMask, box: { x: number; y: number; w: number; h: number }, stage: StageBox, frames: ReadonlyArray<PosedTriangles | null>, toward: 'down' | 'up' = 'down'): HalfTravels {
|
|
353
374
|
const H = stage.height;
|
|
354
375
|
const bx0 = stage.x + box.x;
|
|
355
376
|
const bx1 = stage.x + box.x + box.w;
|
|
356
377
|
const top = stage.y + cropToSpineY(box.y, H);
|
|
357
378
|
const mid = stage.y + cropToSpineY(box.y + box.h / 2, H);
|
|
358
379
|
const bottom = stage.y + cropToSpineY(box.y + box.h, H);
|
|
359
|
-
|
|
380
|
+
// Each band is a rectangle of the world, [x0, y0, x1, y1] (y up).
|
|
381
|
+
const upper = [bx0, mid, bx1, top] as const;
|
|
382
|
+
const lower = [bx0, bottom, bx1, mid] as const;
|
|
383
|
+
const bands = toward === 'down' ? { root: upper, tip: lower } : { root: lower, tip: upper };
|
|
360
384
|
const cut = new Map<readonly number[], ArtPieces>();
|
|
361
|
-
const centroidIn = (a: PosedTriangles, y0: number, y1: number): [number, number] | null => {
|
|
385
|
+
const centroidIn = (a: PosedTriangles, x0: number, y0: number, x1: number, y1: number): [number, number] | null => {
|
|
362
386
|
let pc = cut.get(a.rest);
|
|
363
387
|
if (pc === undefined) {
|
|
364
388
|
pc = artPieces(art, a, stage);
|
|
@@ -384,13 +408,13 @@ export function halfTravels(art: ArtMask, box: { x: number; y: number; w: number
|
|
|
384
408
|
const xmax = Math.max(px, px + e1x, px + e2x);
|
|
385
409
|
const ymin = Math.min(py, py + e1y, py + e2y);
|
|
386
410
|
const ymax = Math.max(py, py + e1y, py + e2y);
|
|
387
|
-
if (xmax <=
|
|
411
|
+
if (xmax <= x0 || xmin >= x1 || ymax <= y0 || ymin >= y1) continue;
|
|
388
412
|
const R = a.rest;
|
|
389
413
|
const restDet = (R[2 * j] - R[2 * i]) * (R[2 * k + 1] - R[2 * i + 1]) - (R[2 * k] - R[2 * i]) * (R[2 * j + 1] - R[2 * i + 1]);
|
|
390
414
|
const posedDet = e1x * e2y - e2x * e1y;
|
|
391
415
|
// The posed area of a rest area: |posed det / rest det| times it.
|
|
392
416
|
const scale = Math.abs(posedDet / restDet);
|
|
393
|
-
if (xmin >=
|
|
417
|
+
if (xmin >= x0 && xmax <= x1 && ymin >= y0 && ymax <= y1) {
|
|
394
418
|
const w = pc.area[t] * scale;
|
|
395
419
|
const l1 = pc.centroid[2 * t];
|
|
396
420
|
const l2 = pc.centroid[2 * t + 1];
|
|
@@ -402,7 +426,7 @@ export function halfTravels(art: ArtMask, box: { x: number; y: number; w: number
|
|
|
402
426
|
for (const bary of pc.pieces[t]) {
|
|
403
427
|
const poly: Polygon = [];
|
|
404
428
|
for (let q = 0; q < bary.length; q += 2) poly.push(px + bary[q] * e1x + bary[q + 1] * e2x, py + bary[q] * e1y + bary[q + 1] * e2y);
|
|
405
|
-
const c = clipRect(poly,
|
|
429
|
+
const c = clipRect(poly, x0, y0, x1, y1);
|
|
406
430
|
if (c.length < 6) continue;
|
|
407
431
|
const [pa, pmx, pmy] = polygonMoments(c);
|
|
408
432
|
// A triangle the pose turns over winds its pieces the other way; what is drawn is the area either way.
|
|
@@ -414,19 +438,19 @@ export function halfTravels(art: ArtMask, box: { x: number; y: number; w: number
|
|
|
414
438
|
}
|
|
415
439
|
return A > 0 ? [MX / A, MY / A] : null;
|
|
416
440
|
};
|
|
417
|
-
const travel = (
|
|
441
|
+
const travel = (band: readonly [number, number, number, number]): number | null => {
|
|
418
442
|
const first = frames[0];
|
|
419
|
-
const c0 = first === null ? null : centroidIn(first,
|
|
443
|
+
const c0 = first === null ? null : centroidIn(first, ...band);
|
|
420
444
|
if (c0 === null) return null;
|
|
421
445
|
let max = 0;
|
|
422
446
|
for (const f of frames) {
|
|
423
|
-
const c = f === null ? null : centroidIn(f,
|
|
447
|
+
const c = f === null ? null : centroidIn(f, ...band);
|
|
424
448
|
if (c === null) continue;
|
|
425
449
|
max = Math.max(max, Math.hypot(c[0] - c0[0], c[1] - c0[1]));
|
|
426
450
|
}
|
|
427
451
|
return max;
|
|
428
452
|
};
|
|
429
|
-
return { root: travel(bands.root
|
|
453
|
+
return { root: travel(bands.root), tip: travel(bands.tip) };
|
|
430
454
|
}
|
|
431
455
|
|
|
432
456
|
// ---------------------------------------------------------------------------
|
package/src/parts.ts
CHANGED
|
@@ -35,6 +35,13 @@
|
|
|
35
35
|
* issue #119). They are not in `opaque_px`: the counts above are taken
|
|
36
36
|
* after it.
|
|
37
37
|
*
|
|
38
|
+
* One field names a part rather than counting pixels: `cut_from`, written only
|
|
39
|
+
* on a piece an `assemble.cuts` entry made (issue #230) — the plan part it was
|
|
40
|
+
* cut from, which shares its `from`. The record is in draw order, and a piece
|
|
41
|
+
* drawn `"back"` comes before its base, so order cannot say which of two parts
|
|
42
|
+
* sharing a layer is the piece; this field does. A `parts.json` written before
|
|
43
|
+
* 1.6.0 has none, and still reads.
|
|
44
|
+
*
|
|
38
45
|
* ⭐ **A patch is 100 % source.** Every pixel of a `painting:` part is the
|
|
39
46
|
* painting's own, so it is all visible (the painting shows it; nothing of it
|
|
40
47
|
* is synthesis), all candidates and all taken: `visible_px =
|
|
@@ -71,6 +78,12 @@ import { readTag } from './tags.ts';
|
|
|
71
78
|
export interface PartRecord {
|
|
72
79
|
name: string;
|
|
73
80
|
from: string;
|
|
81
|
+
/**
|
|
82
|
+
* Written only on a piece an `assemble.cuts` entry made (issue #230): the
|
|
83
|
+
* name of the plan part it was cut from, which has the same `from`. Absent
|
|
84
|
+
* on every other part, and in a `parts.json` written before 1.6.0.
|
|
85
|
+
*/
|
|
86
|
+
cut_from?: string;
|
|
74
87
|
x: number;
|
|
75
88
|
y: number;
|
|
76
89
|
w: number;
|
|
@@ -132,6 +145,7 @@ const BORDER_KEYS = ['part', 'px'] as const;
|
|
|
132
145
|
export const PART_KEYS = [
|
|
133
146
|
'name',
|
|
134
147
|
'from',
|
|
148
|
+
'cut_from',
|
|
135
149
|
'x',
|
|
136
150
|
'y',
|
|
137
151
|
'w',
|
|
@@ -149,10 +163,12 @@ export const PART_KEYS = [
|
|
|
149
163
|
] as const;
|
|
150
164
|
/** The one part field that is not a count: a list of `{part, px}`, written only when it is not empty. */
|
|
151
165
|
const FRINGE_KEY = 'fringe_pushed_back';
|
|
152
|
-
|
|
166
|
+
/** The one part field that names another part: a cut's piece names its base, written only on a piece. */
|
|
167
|
+
const CUT_FROM_KEY = 'cut_from';
|
|
168
|
+
const COUNT_KEYS = PART_KEYS.slice(2).filter((k) => k !== FRINGE_KEY && k !== CUT_FROM_KEY) as ReadonlyArray<(typeof PART_KEYS)[number]>;
|
|
153
169
|
/** The port's own counts: all three or none (none = a record the reference wrote). */
|
|
154
170
|
export const VISIBILITY_KEYS = ['visible_px', 'occluded_px', 'visible_not_projected_px'] as const;
|
|
155
|
-
const REQUIRED_KEYS = PART_KEYS.filter((k) => !(VISIBILITY_KEYS as readonly string[]).includes(k) && k !== FRINGE_KEY);
|
|
171
|
+
const REQUIRED_KEYS = PART_KEYS.filter((k) => !(VISIBILITY_KEYS as readonly string[]).includes(k) && k !== FRINGE_KEY && k !== CUT_FROM_KEY);
|
|
156
172
|
|
|
157
173
|
/** Where a part came from: a See-through run and a v3 tag, or the painting itself for a patch. */
|
|
158
174
|
export type From = { run: 'full' | 'head'; tag: string } | { run: 'painting'; patch: string };
|
|
@@ -318,6 +334,27 @@ function checkParts(raw: unknown, path: string): PartsFile {
|
|
|
318
334
|
}
|
|
319
335
|
});
|
|
320
336
|
}
|
|
337
|
+
if (Array.isArray(o.parts)) {
|
|
338
|
+
// A cut's piece (issue #230) names its base: a part of this file, with the
|
|
339
|
+
// same "<run>:<tag>", that is not itself a piece (a cut of a cut has no
|
|
340
|
+
// single plan part behind it; the config loader refuses one).
|
|
341
|
+
const recs = o.parts.filter((p): p is Record<string, unknown> => typeof p === 'object' && p !== null && !Array.isArray(p));
|
|
342
|
+
const byName = new Map<unknown, Record<string, unknown>>(recs.map((r) => [r.name, r]));
|
|
343
|
+
for (const r of recs) {
|
|
344
|
+
if (!(CUT_FROM_KEY in r)) continue;
|
|
345
|
+
const object = `part "${String(r.name)}" field "${CUT_FROM_KEY}"`;
|
|
346
|
+
const base = r[CUT_FROM_KEY];
|
|
347
|
+
if (typeof base !== 'string' || base === '') {
|
|
348
|
+
fail('PARTS_FIELD_TYPE', object, `is ${show(base)}; the name of the part the cut took it from is required (the field is absent on a part no cut made)`);
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
const b = byName.get(base);
|
|
352
|
+
if (b === undefined) fail('PARTS_CUT_FROM_KNOWN', object, `names "${base}", which is no part of this file; the plan part the cut took this piece from is required`);
|
|
353
|
+
else if (base === r.name) fail('PARTS_CUT_FROM_KNOWN', object, `names the piece itself; the plan part the cut took it from is required`);
|
|
354
|
+
else if (CUT_FROM_KEY in b) fail('PARTS_CUT_FROM_KNOWN', object, `names "${base}", which is itself a piece (cut from ${show(b[CUT_FROM_KEY])}); a plan part is required, as a cut of a cut has no single layer behind it`);
|
|
355
|
+
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`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
321
358
|
if ('recomposite' in o && sizeOk) {
|
|
322
359
|
const names = Array.isArray(o.parts) ? new Set(o.parts.map((p) => (typeof p === 'object' && p !== null ? (p as Record<string, unknown>).name : undefined)).filter((n): n is string => typeof n === 'string')) : new Set<string>();
|
|
323
360
|
checkRecomposite(o.recomposite, path, size as [number, number], names, fail);
|