@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.
@@ -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>): HalfTravels {
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
- const bands = { root: [mid, top], tip: [bottom, mid] } as const;
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 <= bx0 || xmin >= bx1 || ymax <= y0 || ymin >= y1) continue;
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 >= bx0 && xmax <= bx1 && ymin >= y0 && ymax <= y1) {
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, bx0, y0, bx1, y1);
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 = (y0: number, y1: number): number | null => {
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, y0, y1);
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, y0, y1);
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[0], bands.root[1]), tip: travel(bands.tip[0], bands.tip[1]) };
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
- const COUNT_KEYS = PART_KEYS.slice(2).filter((k) => k !== FRINGE_KEY) as ReadonlyArray<(typeof PART_KEYS)[number]>;
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);