@rig-c/parts 0.0.0-stage → 1.5.4

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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE.md +134 -0
  3. package/README.md +433 -2
  4. package/bin/rig-parts.cjs +43 -0
  5. package/cli.ts +1240 -0
  6. package/docs/AUTHORING.md +2387 -0
  7. package/docs/MIGRATION.md +150 -0
  8. package/docs/STABILITY.md +262 -0
  9. package/package.json +70 -4
  10. package/skills/rig-parts/SKILL.md +167 -0
  11. package/src/apng.ts +381 -0
  12. package/src/assemble.ts +2259 -0
  13. package/src/autoamplitude.ts +194 -0
  14. package/src/autoenvelope.ts +404 -0
  15. package/src/automesh.ts +585 -0
  16. package/src/automotion.ts +447 -0
  17. package/src/autoreplay.ts +516 -0
  18. package/src/build.ts +1240 -0
  19. package/src/check.ts +2942 -0
  20. package/src/comfy/client.ts +245 -0
  21. package/src/comfy/index.ts +19 -0
  22. package/src/comfy/painting.ts +136 -0
  23. package/src/comfy/seethrough.ts +171 -0
  24. package/src/config.ts +2283 -0
  25. package/src/contour.ts +1901 -0
  26. package/src/coords.ts +26 -0
  27. package/src/diagnostics.ts +198 -0
  28. package/src/errors.ts +39 -0
  29. package/src/gif.ts +213 -0
  30. package/src/graphs.ts +302 -0
  31. package/src/headbox.ts +173 -0
  32. package/src/heat.ts +313 -0
  33. package/src/inputs.ts +100 -0
  34. package/src/instruments.ts +1145 -0
  35. package/src/keypoints.ts +296 -0
  36. package/src/layers.ts +598 -0
  37. package/src/localweights.ts +217 -0
  38. package/src/mesh.ts +362 -0
  39. package/src/motion.ts +311 -0
  40. package/src/palette.ts +208 -0
  41. package/src/parts.ts +410 -0
  42. package/src/propose.ts +2540 -0
  43. package/src/pyfmt.ts +135 -0
  44. package/src/raster/blur.ts +80 -0
  45. package/src/raster/components.ts +165 -0
  46. package/src/raster/composite.ts +81 -0
  47. package/src/raster/index.ts +9 -0
  48. package/src/raster/morph.ts +116 -0
  49. package/src/raster/png.ts +82 -0
  50. package/src/raster/poly.ts +166 -0
  51. package/src/raster/resize.ts +301 -0
  52. package/src/raster/types.ts +89 -0
  53. package/src/raster/warp.ts +121 -0
  54. package/src/requirements.ts +1240 -0
  55. package/src/rig.ts +1440 -0
  56. package/src/round.ts +62 -0
  57. package/src/scene.ts +1414 -0
  58. package/src/sheet.ts +128 -0
  59. package/src/skeleton.ts +400 -0
  60. package/src/structure.ts +858 -0
  61. package/src/tags.ts +121 -0
  62. package/src/weights.ts +124 -0
  63. package/workflows/seethrough.json +68 -0
package/src/heat.ts ADDED
@@ -0,0 +1,313 @@
1
+ /**
2
+ * Bone heat: a mesh's weights as the heat equilibrium over the part's own
3
+ * silhouette (issue #161, the reduced shape). An author opts a mesh in with
4
+ * `meshes.<part>.rule: "heat"`; absent, or `"distance"`, the mesh is weighted
5
+ * by `influences()` (`src/weights.ts`) and nothing here runs.
6
+ *
7
+ * ## The rule, as a definition
8
+ *
9
+ * 1. **The domain** is a set of the part image's pixels, the silhouette the
10
+ * mesh's mode reads (the rig stage hands it in): the lattice's art — alpha
11
+ * above 8 — with its holes filled ({@link latticeSilhouette}); for the
12
+ * contour and the automatic modes, the silhouette the outline was traced
13
+ * from, which is the kept art with its holes filled and grown by the
14
+ * declared margin ({@link tracedSilhouette}, the same calls
15
+ * `contourOutline` makes). Neighbours are 4-connected; a neighbour off the
16
+ * silhouette is no neighbour, so no heat crosses the boundary.
17
+ * 2. **Sources.** A silhouette pixel whose centre lies within
18
+ * {@link HEAT_SOURCE_BAND} px of one of a bound bone's segments is that
19
+ * bone's source; a pixel two bones reach goes to the bone whose segment is
20
+ * nearer its centre, a tie to the bone listed first among the segments.
21
+ * The band is above √2 / 2, so every pixel a segment passes through is
22
+ * reached. Per bone, the field is 1 on its own sources, 0 on every other
23
+ * bone's, and the discrete Laplace equation (each free pixel the mean of
24
+ * its neighbours) everywhere else.
25
+ * 3. **The solve** is successive over-relaxation — Gauss–Seidel, sweeping the
26
+ * free pixels in row-major order, each update over-relaxed by
27
+ * `omega = 2 / (1 + π / L)`, L the image's longer side (the small-angle
28
+ * form of the optimum `2 / (1 + sin(π / L))`: `sin`'s last bit is the
29
+ * engine's, a division is IEEE's) — from 0, until the largest change in
30
+ * one sweep is under {@link HEAT_TOLERANCE}. A bone that has not got there
31
+ * in {@link HEAT_MAX_ITERATIONS} sweeps is refused (`RIG_HEAT_CONVERGED`),
32
+ * never written half-solved. Float64 throughout; distances are
33
+ * `segmentDistance`'s, not `Math.hypot`.
34
+ * 4. **A vertex** reads every bone's field at the silhouette pixel whose
35
+ * centre is nearest it (a tie to the first in raster order) — a vertex on a
36
+ * pixel corner, as every lattice vertex is, touches four — keeps the bones
37
+ * whose value there is above 0, and takes the cap and the floor the distance
38
+ * rule takes (`capAndFloor`, the mesh's `InfluenceLimits`).
39
+ *
40
+ * ## What is refused rather than weighted 0
41
+ *
42
+ * - `RIG_HEAT_BONE_SOURCE`: a bound bone that holds no source pixel — a
43
+ * zero-length control bone off the art, a segment wholly off the
44
+ * silhouette, or one whose every reached pixel is nearer another bone. A
45
+ * bone named in `segments` is a bone the author said may pull the layer;
46
+ * giving it 0 everywhere would be a silent answer to a question the
47
+ * config asked.
48
+ * - `RIG_HEAT_ISLAND_SOURCE`: a 4-connected island of the silhouette that no
49
+ * bone has a source in. With no fixed temperature the equation has no
50
+ * answer there; nothing nearest is borrowed.
51
+ * - `RIG_HEAT_CONVERGED`: above.
52
+ *
53
+ * Two runs on the same input write the same bytes: the claims, the sweep
54
+ * order and the arithmetic are fixed, and nothing reads a clock.
55
+ */
56
+ import type { Point } from './config.ts';
57
+ import { artMask, growSilhouette } from './contour.ts';
58
+ import type { Problem } from './errors.ts';
59
+ import { connectedComponents, fillHoles, type Mask } from './raster/index.ts';
60
+ import { capAndFloor, type Influence, type InfluenceLimits, type Segment, segmentDistance } from './weights.ts';
61
+
62
+ /** The solve stops when the largest change in one sweep is under this — Stage A's figure (`tools/weight_rule_survey.ts`). */
63
+ export const HEAT_TOLERANCE = 1e-7;
64
+
65
+ /** The most sweeps one bone's field may take; past it the mesh is refused (`RIG_HEAT_CONVERGED`). Stage A's figure. */
66
+ export const HEAT_MAX_ITERATIONS = 20000;
67
+
68
+ /** A pixel is a bone's source when its centre lies within this of the bone's segment, px: above √2 / 2, so every pixel a segment crosses is reached. Stage A's figure. */
69
+ export const HEAT_SOURCE_BAND = 0.75;
70
+
71
+ /** The sweep, in the words the report row echoes. */
72
+ export const HEAT_SWEEP = 'gauss-seidel, row-major, over-relaxed';
73
+
74
+ export interface HeatLimits {
75
+ tolerance: number;
76
+ maxIterations: number;
77
+ }
78
+
79
+ export const HEAT_LIMITS: HeatLimits = { tolerance: HEAT_TOLERANCE, maxIterations: HEAT_MAX_ITERATIONS };
80
+
81
+ /** The lattice's silhouette: its art (alpha above 8, the mask `latticeMesh` reads) with holes filled, as the lattice fills them. */
82
+ export function latticeSilhouette(art: Mask): Mask {
83
+ return fillHoles(art);
84
+ }
85
+
86
+ /** The contour and automatic modes' silhouette: the traced mask's art at `threshold`, holes filled, grown by `margin` — the silhouette `contourOutline` traces. */
87
+ export function tracedSilhouette(mask: { width: number; height: number; alpha: Uint8Array }, threshold: number, margin: number): Mask {
88
+ return growSilhouette(fillHoles(artMask(mask, threshold)), margin).mask;
89
+ }
90
+
91
+ /** One mesh's solved fields. */
92
+ export interface HeatField {
93
+ silhouette: Mask;
94
+ /** Bound bones, in first-appearance order among the segments. */
95
+ bones: string[];
96
+ /** One field per bone, row-major over the image; 0 off the silhouette. */
97
+ fields: Float64Array[];
98
+ /** Per bone: source pixels held, and sweeps taken. */
99
+ sources: number[];
100
+ iterations: number[];
101
+ omega: number;
102
+ /** The largest |value − mean of its neighbours| over the free pixels, over every bone, after the solve. */
103
+ residual: number;
104
+ islands: number;
105
+ }
106
+
107
+ /** The row a heat mesh adds to its `mesh_report.json` entry, so the declaration and the solve it ran are read back. */
108
+ export interface HeatRow {
109
+ rule: 'heat';
110
+ tolerance: number;
111
+ max_iterations: number;
112
+ source_band: number;
113
+ sweep: string;
114
+ omega: number;
115
+ silhouette_pixels: number;
116
+ islands: number;
117
+ bones: Array<{ bone: string; sources: number; iterations: number }>;
118
+ residual: number;
119
+ }
120
+
121
+ const fmt = (v: number): string => String(Math.round(v * 1000) / 1000);
122
+
123
+ /**
124
+ * Solve bone heat over `sil` (part-image pixels) for `segs` (rig px; the image is the rig less `(ox, oy)`).
125
+ * `object` names the mesh in every refusal. Every problem found is returned at once.
126
+ */
127
+ export function boneHeat(object: string, sil: Mask, segs: readonly Segment[], ox: number, oy: number, limits: HeatLimits = HEAT_LIMITS): HeatField | Problem[] {
128
+ const { width: w, height: h, data } = sil;
129
+ const n = w * h;
130
+ const bones: string[] = [];
131
+ for (const s of segs) if (!bones.includes(s.bone)) bones.push(s.bone);
132
+ const K = bones.length;
133
+ const local = segs.map((s) => ({ k: bones.indexOf(s.bone), a: [s.a[0] - ox, s.a[1] - oy] as Point, b: [s.b[0] - ox, s.b[1] - oy] as Point }));
134
+ const owner = new Int32Array(n).fill(-1);
135
+ const ownD = new Float64Array(n).fill(Infinity);
136
+ const reached = new Array<number>(K).fill(0);
137
+ let silPixels = 0;
138
+ for (let i = 0; i < n; i++) {
139
+ if (data[i] !== 1) continue;
140
+ silPixels++;
141
+ const p: Point = [(i % w) + 0.5, Math.floor(i / w) + 0.5];
142
+ const near = new Float64Array(K).fill(Infinity);
143
+ for (const s of local) {
144
+ const d = segmentDistance(p, s.a, s.b);
145
+ if (d < near[s.k]) near[s.k] = d;
146
+ }
147
+ for (let k = 0; k < K; k++) {
148
+ if (near[k] > HEAT_SOURCE_BAND) continue;
149
+ reached[k]++;
150
+ if (near[k] < ownD[i]) {
151
+ owner[i] = k;
152
+ ownD[i] = near[k];
153
+ }
154
+ }
155
+ }
156
+ const sources = new Array<number>(K).fill(0);
157
+ for (let i = 0; i < n; i++) if (owner[i] >= 0) sources[owner[i]]++;
158
+ const problems: Problem[] = [];
159
+ bones.forEach((bone, k) => {
160
+ if (sources[k] > 0) return;
161
+ const where = local
162
+ .filter((s) => s.k === k)
163
+ .map((s) => `(${fmt(s.a[0] + ox)}, ${fmt(s.a[1] + oy)})->(${fmt(s.b[0] + ox)}, ${fmt(s.b[1] + oy)})`)
164
+ .join(', ');
165
+ const why = reached[k] === 0 ? 'reaches no pixel of the silhouette' : `reaches ${reached[k]} silhouette pixel(s), every one nearer another bound bone's segment`;
166
+ problems.push({
167
+ code: 'RIG_HEAT_BONE_SOURCE',
168
+ object,
169
+ detail: `bone "${bone}" — its segment ${where} (rig px) ${why}, so it holds no source pixel; under rule "heat" every bound bone needs at least one: a silhouette pixel (${silPixels} px) whose centre lies within ${HEAT_SOURCE_BAND} px of the segment and nearer it than any other bound bone's. A bone with no source is not weighted 0 — move its segment onto the art, or take the bone out of segments`,
170
+ });
171
+ });
172
+ const cc = connectedComponents(sil, 4);
173
+ const sourced = new Uint8Array(cc.count);
174
+ for (let i = 0; i < n; i++) if (owner[i] >= 0) sourced[cc.labels[i]] = 1;
175
+ for (let c = 1; c < cc.count; c++) {
176
+ if (sourced[c]) continue;
177
+ const st = cc.stats[c];
178
+ problems.push({
179
+ code: 'RIG_HEAT_ISLAND_SOURCE',
180
+ object,
181
+ detail: `the silhouette's 4-connected island of ${st.area} px at rig (${st.left + ox}, ${st.top + oy}) holds no bound bone's source pixel, so the heat equation has no fixed temperature there and no answer; under rule "heat" every island needs a segment within ${HEAT_SOURCE_BAND} px of one of its pixel centres — nothing is borrowed from the nearest island`,
182
+ });
183
+ }
184
+ if (problems.length > 0) return problems;
185
+
186
+ // The free pixels in raster order, each with its 4-neighbours on the silhouette (left, right, up, down: the order the sum is taken in).
187
+ const free: number[] = [];
188
+ const start: number[] = [0];
189
+ const nbr: number[] = [];
190
+ for (let i = 0; i < n; i++) {
191
+ if (data[i] !== 1 || owner[i] >= 0) continue;
192
+ const x = i % w;
193
+ const y = Math.floor(i / w);
194
+ if (x > 0 && data[i - 1] === 1) nbr.push(i - 1);
195
+ if (x < w - 1 && data[i + 1] === 1) nbr.push(i + 1);
196
+ if (y > 0 && data[i - w] === 1) nbr.push(i - w);
197
+ if (y < h - 1 && data[i + w] === 1) nbr.push(i + w);
198
+ free.push(i);
199
+ start.push(nbr.length);
200
+ }
201
+ const F = Int32Array.from(free);
202
+ const S = Int32Array.from(start);
203
+ const N = Int32Array.from(nbr);
204
+ const omega = 2 / (1 + Math.PI / Math.max(w, h));
205
+ const fields: Float64Array[] = [];
206
+ const iterations: number[] = [];
207
+ let residual = 0;
208
+ for (let k = 0; k < K; k++) {
209
+ const x = new Float64Array(n);
210
+ for (let i = 0; i < n; i++) if (owner[i] === k) x[i] = 1;
211
+ let it = 0;
212
+ let done = F.length === 0;
213
+ let last = 0;
214
+ while (!done && it < limits.maxIterations) {
215
+ it++;
216
+ let maxD = 0;
217
+ for (let j = 0; j < F.length; j++) {
218
+ const i = F[j];
219
+ const a = S[j];
220
+ const b = S[j + 1];
221
+ let s = 0;
222
+ for (let q = a; q < b; q++) s += x[N[q]];
223
+ const nx = x[i] + omega * (s / (b - a) - x[i]);
224
+ const d = Math.abs(nx - x[i]);
225
+ if (d > maxD) maxD = d;
226
+ x[i] = nx;
227
+ }
228
+ last = maxD;
229
+ if (maxD < limits.tolerance) done = true;
230
+ }
231
+ if (!done) {
232
+ problems.push({
233
+ code: 'RIG_HEAT_CONVERGED',
234
+ object,
235
+ detail: `bone "${bones[k]}"'s field still changed by ${last} in its sweep ${it}; the solve stops at a change under ${limits.tolerance} within ${limits.maxIterations} sweeps (${F.length} free pixel(s), omega ${omega}). Nothing half-solved is written — a smaller part, or the distance rule, is what remains`,
236
+ });
237
+ }
238
+ for (let j = 0; j < F.length; j++) {
239
+ let s = 0;
240
+ for (let q = S[j]; q < S[j + 1]; q++) s += x[N[q]];
241
+ const r = Math.abs(x[F[j]] - s / (S[j + 1] - S[j]));
242
+ if (r > residual) residual = r;
243
+ }
244
+ fields.push(x);
245
+ iterations.push(it);
246
+ }
247
+ if (problems.length > 0) return problems;
248
+ return { silhouette: sil, bones, fields, sources, iterations, omega, residual, islands: cc.count - 1 };
249
+ }
250
+
251
+ /** The silhouette pixel whose centre is nearest `p` (part-image px), a tie to the first in raster order; -1 on an empty silhouette. */
252
+ export function nearestSilhouettePixel(sil: Mask, p: Point): number {
253
+ const { width: w, height: h, data } = sil;
254
+ const fx = Math.floor(p[0]);
255
+ const fy = Math.floor(p[1]);
256
+ let best = -1;
257
+ let bd = Infinity;
258
+ // A pixel in the ring at Chebyshev distance k from (fx, fy) lies at least k − 0.5 from p, so once the best is
259
+ // strictly nearer than that, no ring further out can hold a nearer or an equally near pixel.
260
+ const reach = w + h + Math.abs(fx) + Math.abs(fy);
261
+ for (let k = 0; k <= reach; k++) {
262
+ if (best >= 0 && k > 0 && bd < (k - 0.5) * (k - 0.5)) break;
263
+ for (let y = fy - k; y <= fy + k; y++) {
264
+ if (y < 0 || y >= h) continue;
265
+ const edgeRow = y === fy - k || y === fy + k;
266
+ for (let x = fx - k; x <= fx + k; x += edgeRow ? 1 : 2 * k) {
267
+ if (x >= 0 && x < w) {
268
+ const i = y * w + x;
269
+ if (data[i] === 1) {
270
+ const dx = x + 0.5 - p[0];
271
+ const dy = y + 0.5 - p[1];
272
+ const d = dx * dx + dy * dy;
273
+ if (d < bd || (d === bd && i < best)) {
274
+ bd = d;
275
+ best = i;
276
+ }
277
+ }
278
+ }
279
+ if (k === 0) break;
280
+ }
281
+ }
282
+ }
283
+ return best;
284
+ }
285
+
286
+ /** Bone heat's influences on the vertex at `p` (part-image px): the fields at its pixel above 0, capped and floored as the distance rule's are. */
287
+ export function heatInfluences(field: HeatField, p: Point, limits: InfluenceLimits): Influence[] {
288
+ const i = nearestSilhouettePixel(field.silhouette, p);
289
+ const raw: Array<[string, number]> = [];
290
+ if (i >= 0) field.bones.forEach((bone, k) => {
291
+ const v = field.fields[k][i];
292
+ if (v > 0) raw.push([bone, v]);
293
+ });
294
+ return capAndFloor(raw, limits);
295
+ }
296
+
297
+ /** The report row of a solved field. */
298
+ export function heatRow(field: HeatField, limits: HeatLimits = HEAT_LIMITS): HeatRow {
299
+ let px = 0;
300
+ for (const v of field.silhouette.data) if (v === 1) px++;
301
+ return {
302
+ rule: 'heat',
303
+ tolerance: limits.tolerance,
304
+ max_iterations: limits.maxIterations,
305
+ source_band: HEAT_SOURCE_BAND,
306
+ sweep: HEAT_SWEEP,
307
+ omega: field.omega,
308
+ silhouette_pixels: px,
309
+ islands: field.islands,
310
+ bones: field.bones.map((bone, k) => ({ bone, sources: field.sources[k], iterations: field.iterations[k] })),
311
+ residual: field.residual,
312
+ };
313
+ }
package/src/inputs.ts ADDED
@@ -0,0 +1,100 @@
1
+ /**
2
+ * The two images See-through is fed, cut from the painting.
3
+ *
4
+ * - **full** — the painting centred on a WHITE square whose side is the
5
+ * painting's longer side (See-through's own pad is black, and a black margin
6
+ * reads to it as part of the figure): a portrait painting is padded left and
7
+ * right, a landscape one top and bottom, each by `squarePad`. The reference
8
+ * pasted at `((side - w) // 2, 0)` on a square of side `h`, which is centred
9
+ * only because its paintings are portrait; for those the vertical pad is 0
10
+ * and the bytes are the reference's. The pad is on See-through's input only:
11
+ * `proposeHeadBox` (`src/headbox.ts`) and `assemble` (`src/assemble.ts`) map
12
+ * the full run back through the same `squarePad`, x and y alike, so the rig
13
+ * stays in painting pixels with no padding in it.
14
+ * - **head** — `seethrough.head_box` cut from the painting at its exact size.
15
+ * Written only when the config sets a head box; the config loader has
16
+ * already refused a box that is not a non-empty square.
17
+ *
18
+ * Pure raster. The reference converted the painting to RGB, which DROPS alpha
19
+ * rather than compositing it — a translucent pixel would have kept whatever
20
+ * colour sat under it. Neither dropping nor compositing is a value the input
21
+ * states, so a painting with any pixel below alpha 255 is refused.
22
+ *
23
+ * Measured against the reference's own `st_input_full.png` and
24
+ * `st_input_head.png` for the public examples: the selftest's
25
+ * `inputs-examples` suite compares them pixel for pixel whenever
26
+ * `bun run fetch-examples` has put them on disk.
27
+ */
28
+ import type { CharacterConfig } from './config.ts';
29
+ import { type Problem, refuseIfAny } from './errors.ts';
30
+ import { crop, pad } from './raster/composite.ts';
31
+ import type { Raster } from './raster/types.ts';
32
+
33
+ export interface SeeThroughInputs {
34
+ full: Raster;
35
+ /** The pad added on the left, in source px (the right gets the remainder); 0 for a landscape painting. */
36
+ padLeft: number;
37
+ /** The pad added on top, in source px (the bottom gets the remainder); 0 for a portrait painting. */
38
+ padTop: number;
39
+ head: Raster | null;
40
+ headBox: readonly [number, number, number, number] | null;
41
+ }
42
+
43
+ const WHITE: readonly [number, number, number, number] = [255, 255, 255, 255];
44
+
45
+ /** Where a painting sits on the square the full run is fed, in source px. */
46
+ export interface SquarePad {
47
+ /** The square's side: the painting's longer side. */
48
+ side: number;
49
+ /** The margin on the left; the right gets `side - w - left`. */
50
+ left: number;
51
+ /** The margin on top; the bottom gets `side - h - top`. */
52
+ top: number;
53
+ }
54
+
55
+ /**
56
+ * The one derivation of the full run's pad, read by every stage that maps the
57
+ * full run back to the painting. Each margin floors, as the reference's
58
+ * `(side - w) // 2` does, so an odd remainder goes to the right or the bottom.
59
+ * At most one of the two margins is non-zero.
60
+ */
61
+ export function squarePad(w: number, h: number): SquarePad {
62
+ const side = Math.max(w, h);
63
+ return { side, left: Math.floor((side - w) / 2), top: Math.floor((side - h) / 2) };
64
+ }
65
+
66
+ export function makeInputs(painting: Raster, cfg: Pick<CharacterConfig, 'seethrough'>, label: string): SeeThroughInputs {
67
+ const problems: Problem[] = [];
68
+ const { width: w, height: h } = painting;
69
+ let translucent = 0;
70
+ let firstAt = -1;
71
+ for (let i = 0; i < w * h; i++) {
72
+ if (painting.data[i * 4 + 3] !== 255) {
73
+ translucent++;
74
+ if (firstAt === -1) firstAt = i;
75
+ }
76
+ }
77
+ if (translucent > 0) {
78
+ problems.push({
79
+ code: 'INPUTS_PAINTING_OPAQUE',
80
+ object: label,
81
+ detail: `has ${translucent} pixel(s) below alpha 255 (first at ${firstAt % w},${Math.floor(firstAt / w)}); an opaque painting is required, because neither dropping the alpha nor compositing it is stated by the input`,
82
+ });
83
+ }
84
+ const box = cfg.seethrough?.head_box ?? null;
85
+ if (box !== null) {
86
+ const [x0, y0, x1, y1] = box;
87
+ if (x0 < 0 || y0 < 0 || x1 > w || y1 > h) {
88
+ problems.push({
89
+ code: 'INPUTS_HEAD_BOX_INSIDE',
90
+ object: 'config.seethrough.head_box',
91
+ detail: `is [${box.join(', ')}] and the painting is ${w}x${h}; a box inside the painting is required (the crop is not padded)`,
92
+ });
93
+ }
94
+ }
95
+ refuseIfAny(problems);
96
+ const { side, left, top } = squarePad(w, h);
97
+ const full = pad(painting, left, top, side - w - left, side - h - top, WHITE);
98
+ const head = box === null ? null : crop(painting, box[0], box[1], box[2] - box[0], box[3] - box[1]);
99
+ return { full, padLeft: left, padTop: top, head, headBox: box };
100
+ }