@rig-c/parts 1.25.0 → 1.27.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/config.ts CHANGED
@@ -256,6 +256,8 @@ export interface ChainBones {
256
256
  parent: string;
257
257
  points: Point[];
258
258
  tip: Point;
259
+ /** Expanded link name -> rig-c inherit mode (issue #369); absent links keep normal inheritance. */
260
+ link_inherit?: Record<string, RigBoneInherit>;
259
261
  }
260
262
 
261
263
  export type BoneEntry = SingleBone | ChainBones;
@@ -725,6 +727,8 @@ export interface CharacterConfig {
725
727
  scene?: SceneSpec;
726
728
  /** The cloth and hair recipe `propose --cloth` drafts from (issue #364, `src/cloth.ts`): read by propose only, so no byte of any rig output moves with it. */
727
729
  cloth?: ClothRecipes;
730
+ /** The `assemble.rig_scale` (`parts.json`'s `scale_rig_per_source`) `propose` drafted the rig sections at (issue #354): absent on a hand-authored config, held equal to `assemble.rig_scale` when present. */
731
+ proposed_rig_scale?: number;
728
732
  }
729
733
 
730
734
  /** `config.scene` (issue #242): the declaration that the painting is a scene. It holds no field today. */
@@ -1029,9 +1033,11 @@ export interface AssembleConfig {
1029
1033
  /** Issue #349: read with `bones` (the joints are the bones' origins) when present. */
1030
1034
  limbs?: Record<string, Limb>;
1031
1035
  bones?: BoneEntry[];
1036
+ /** Issue #354: the `rig_scale` `propose` drafted the rig sections at, held equal to `assemble.rig_scale` when present. */
1037
+ proposed_rig_scale?: number;
1032
1038
  }
1033
1039
 
1034
- const TOP_KEYS = ['key', 'generation', 'seethrough', 'assemble', 'limbs', 'bones', 'meshes', 'regions', 'motion', 'constraints', 'stops', 'turn', 'scene', 'cloth'] as const;
1040
+ const TOP_KEYS = ['key', 'generation', 'seethrough', 'assemble', 'limbs', 'bones', 'meshes', 'regions', 'motion', 'constraints', 'stops', 'turn', 'scene', 'cloth', 'proposed_rig_scale'] as const;
1035
1041
 
1036
1042
  /**
1037
1043
  * The one partial entry point. It validates exactly what its door requires —
@@ -1081,6 +1087,7 @@ function parseEarlyConfigFrom(raw: Json, door: EarlyDoor, base: string | null):
1081
1087
  if (door === 'assemble') {
1082
1088
  if ('seethrough' in t) checkSeeThrough(c, t.seethrough);
1083
1089
  const made = 'assemble' in t ? checkAssemble(c, t.assemble, door) : { parts: [], patches: [], cuts: [] };
1090
+ checkProposedRigScale(c, t);
1084
1091
  if ('scene' in t) checkScene(c, t.scene);
1085
1092
  // Issue #349: the limbs' joints are their bones' origins, so the bones are read with them, and only then.
1086
1093
  if ('limbs' in t) {
@@ -1234,6 +1241,7 @@ function parseConfigFrom(raw: Json, base: string | null): { config: CharacterCon
1234
1241
  if ('generation' in t) checkGeneration(c, t.generation);
1235
1242
  if ('seethrough' in t) checkSeeThrough(c, t.seethrough);
1236
1243
  const { parts, patches, cuts } = 'assemble' in t ? checkAssemble(c, t.assemble, 'full') : { parts: [], patches: [], cuts: [] };
1244
+ checkProposedRigScale(c, t);
1237
1245
  const names = 'bones' in t ? checkBones(c, t.bones) : { bones: new Set<string>([ROOT_BONE]), chains: new Map<string, number>(), parents: new Map<string, string>() };
1238
1246
  const limbs = 'limbs' in t ? checkLimbs(c, t.limbs, { parts, patches, cuts }, 'bones' in t ? names : null) : null;
1239
1247
  if ('meshes' in t) checkMeshes(c, t.meshes, names.bones, names.chains);
@@ -1252,6 +1260,29 @@ function parseConfigFrom(raw: Json, base: string | null): { config: CharacterCon
1252
1260
  return { config: raw as CharacterConfig, animations };
1253
1261
  }
1254
1262
 
1263
+ /**
1264
+ * Issue #354: `propose` records `parts.json`'s `scale_rig_per_source` on the
1265
+ * proposal as `proposed_rig_scale`, and pasting the proposal into `config.json`
1266
+ * carries it. When present, `assemble`, `rig` and `build` compare it against
1267
+ * `assemble.rig_scale` and refuse a mismatch: the bones, mesh spacings, segment
1268
+ * coordinates and motion amplitudes are in the proposed rig canvas's pixels and
1269
+ * do not scale when `assemble.rig_scale` changes. A hand-authored config with
1270
+ * no `proposed_rig_scale` is not refused.
1271
+ */
1272
+ function checkProposedRigScale(c: Check, t: Record<string, Json>): void {
1273
+ if (!('proposed_rig_scale' in t)) return;
1274
+ if (!c.number('config.proposed_rig_scale', t.proposed_rig_scale, 'positive')) return;
1275
+ const a = isPlainObject(t.assemble) ? t.assemble : null;
1276
+ if (a === null || !('rig_scale' in a) || !(typeof a.rig_scale === 'number' && Number.isFinite(a.rig_scale) && a.rig_scale > 0)) return;
1277
+ if (t.proposed_rig_scale !== a.rig_scale) {
1278
+ c.fail(
1279
+ 'CONFIG_PROPOSED_RIG_SCALE',
1280
+ 'config.proposed_rig_scale',
1281
+ `is ${t.proposed_rig_scale}, and config.assemble.rig_scale is ${a.rig_scale} — the bones, meshes, limbs and motion were proposed at rig_scale ${t.proposed_rig_scale} and are in that rig canvas's pixels, so they do not scale when assemble.rig_scale changes; re-run assemble and propose at rig_scale ${a.rig_scale} and paste the new proposal, or restore config.assemble.rig_scale to ${t.proposed_rig_scale}`,
1282
+ );
1283
+ }
1284
+ }
1285
+
1255
1286
  function isPlainObject(v: Json): v is Record<string, Json> {
1256
1287
  return typeof v === 'object' && v !== null && !Array.isArray(v);
1257
1288
  }
@@ -1404,9 +1435,9 @@ export interface SkeletonSections {
1404
1435
  /**
1405
1436
  * A `proposal.json` as `propose` writes it — `bones`, `meshes`, `regions`,
1406
1437
  * `motion` and `notes` (strings), `limbs` when `propose --keypoints` drafted
1407
- * them (issue #349) and `constraints` when `propose --cloth` did (issue #364;
1438
+ * them (issue #349), `constraints` when `propose --cloth` did (issue #364;
1408
1439
  * both read here since issue #372, whose `wind` example tracks such a
1409
- * proposal) — under the full loader's own rules for those sections (issue
1440
+ * proposal), and `proposed_rig_scale` (issue #354) — under the full loader's own rules for those sections (issue
1410
1441
  * #85). There is no plan to hold the meshes, regions and limbs against, so
1411
1442
  * the coverage rule (every plan part exactly one of a mesh or a region) is
1412
1443
  * not run, and a limb segment's `from` is held to no plan part (the parts
@@ -1414,9 +1445,9 @@ export interface SkeletonSections {
1414
1445
  * both before writing (`checkProposal`). A key the loader would not take is
1415
1446
  * refused by name, as in a config. Refusals name the fields `proposal.…`.
1416
1447
  */
1417
- export function parseProposalSections(raw: Json): SkeletonSections & { limbs?: Record<string, Limb>; constraints?: ConfigConstraint[]; stops?: ConfigStop[] } {
1448
+ export function parseProposalSections(raw: Json): SkeletonSections & { limbs?: Record<string, Limb>; constraints?: ConfigConstraint[]; stops?: ConfigStop[]; proposed_rig_scale?: number } {
1418
1449
  const c = new Check();
1419
- const top = c.object('config', raw, ['bones', 'meshes', 'regions', 'motion'], ['notes', 'limbs', 'constraints', 'stops']);
1450
+ const top = c.object('config', raw, ['bones', 'meshes', 'regions', 'motion'], ['notes', 'limbs', 'constraints', 'stops', 'proposed_rig_scale']);
1420
1451
  if (top !== null) {
1421
1452
  const names = 'bones' in top ? checkBones(c, top.bones) : { bones: new Set<string>([ROOT_BONE]), chains: new Map<string, number>(), parents: new Map<string, string>() };
1422
1453
  if ('limbs' in top) {
@@ -1430,10 +1461,11 @@ export function parseProposalSections(raw: Json): SkeletonSections & { limbs?: R
1430
1461
  if ('motion' in top) checkMotion(c, top.motion, names.bones, names.chains, 'regions' in top ? top.regions : undefined);
1431
1462
  if ('constraints' in top) checkConstraints(c, top.constraints, names.bones, names.parents);
1432
1463
  if ('stops' in top) checkStops(c, top.stops, names.bones, names.chains);
1464
+ if ('proposed_rig_scale' in top) c.number('config.proposed_rig_scale', top.proposed_rig_scale, 'positive');
1433
1465
  if ('notes' in top && c.array('config.notes', top.notes, false)) (top.notes as Json[]).forEach((n, i) => c.string(`config.notes[${i}]`, n, false));
1434
1466
  }
1435
1467
  refuseIfAny(c.problems.map((p) => ({ ...p, object: p.object.replace(/^config\b/, 'proposal') })));
1436
- return raw as SkeletonSections & { limbs?: Record<string, Limb>; constraints?: ConfigConstraint[]; stops?: ConfigStop[] };
1468
+ return raw as SkeletonSections & { limbs?: Record<string, Limb>; constraints?: ConfigConstraint[]; stops?: ConfigStop[]; proposed_rig_scale?: number };
1437
1469
  }
1438
1470
 
1439
1471
  /**
@@ -1761,7 +1793,7 @@ function checkBones(c: Check, v: Json): { bones: Set<string>; chains: Map<string
1761
1793
  const at = `config.bones[${i}]`;
1762
1794
  const isChain = typeof entry === 'object' && entry !== null && 'chain' in entry;
1763
1795
  if (isChain) {
1764
- const b = c.object(at, entry, ['chain', 'parent', 'points', 'tip'], []);
1796
+ const b = c.object(at, entry, ['chain', 'parent', 'points', 'tip'], ['link_inherit']);
1765
1797
  if (b === null) return;
1766
1798
  if ('parent' in b && c.string(`${at}.parent`, b.parent) && !bones.has(b.parent)) {
1767
1799
  c.fail('CONFIG_NAME_RESOLVES', `${at}.parent`, `names "${b.parent}", which is not "${ROOT_BONE}" or a bone declared above it; parents come first`);
@@ -1779,6 +1811,21 @@ function checkBones(c: Check, v: Json): { bones: Set<string>; chains: Map<string
1779
1811
  else if (typeof b.parent === 'string' && !parents.has(`${name}0`)) parents.set(`${name}0`, b.parent);
1780
1812
  });
1781
1813
  chains.set(name, points.length);
1814
+ if ('link_inherit' in b) {
1815
+ const path = `${at}.link_inherit`;
1816
+ const links = points.map((_, k) => `${name}${k}`);
1817
+ if (!isPlainObject(b.link_inherit)) {
1818
+ c.fail('CONFIG_FIELD_TYPE', path, `is ${show(b.link_inherit)}; an object mapping this chain's expanded link names to rig-c inherit modes is required`);
1819
+ } else {
1820
+ for (const [link, mode] of Object.entries(b.link_inherit)) {
1821
+ const field = `${path}.${link}`;
1822
+ if (!links.includes(link)) c.fail('CONFIG_NAME_RESOLVES', field, `names "${link}", which is not a link of chain "${name}"; its declared links are ${links.map((n) => `"${n}"`).join(', ')}`);
1823
+ if (c.string(field, mode) && !(RIG_BONE_INHERIT as readonly string[]).includes(mode)) {
1824
+ c.fail('CONFIG_BONE_INHERIT', field, `is ${show(mode)}; one of rig-c's inherit modes, spelled as its table spells them, is required — ${RIG_BONE_INHERIT.join(', ')} (rig-c's RigBone.inherit; nothing here folds a case or maps a name)`);
1825
+ }
1826
+ }
1827
+ }
1828
+ }
1782
1829
  }
1783
1830
  return;
1784
1831
  }
@@ -2766,7 +2813,7 @@ function attachedParts(meshes: Json, regions: Json): Set<string> {
2766
2813
  * - `part` on a tag other than `bottomwear` (`CONFIG_CLOTH_PART_ON_BOTTOMWEAR`):
2767
2814
  * the field hands the skirt's recipe to a part of another tag (issue #402),
2768
2815
  * and only the skirt has a recipe that part can take; whether the part
2769
- * exists, and is a `topwear` that hangs below the hip, is read off the parts
2816
+ * exists, and is a `topwear` part or its cut piece hanging below the hip, is read off the assembled parts
2770
2817
  * by `propose` (`PROPOSE_CLOTH_PART`);
2771
2818
  * - a strength, declared or the recipe's default, whose spring is unstable at
2772
2819
  * a damping the recipe runs at — k = strength·massInverse·step² at or past
@@ -2795,8 +2842,9 @@ function checkCloth(c: Check, v: Json): void {
2795
2842
  for (const tag of CLOTH_TARGETS) {
2796
2843
  if (!(tag in v)) continue;
2797
2844
  const path = `${at}.${JSON.stringify(tag)}`;
2798
- const r = c.object(path, v[tag], ['chains', 'links', 'wind', 'gravity'], ['split', 'length_ratio', 'strength', 'damping', 'alternate_damping', 'translate', 'swing', 'part']);
2845
+ const r = c.object(path, v[tag], ['links', 'wind', 'gravity'], ['chains', 'split', 'length_ratio', 'strength', 'damping', 'alternate_damping', 'translate', 'swing', 'part', 'parts']);
2799
2846
  if (r === null) continue;
2847
+ if (tag !== 'back hair' && !('chains' in r)) c.fail('CONFIG_FIELD_PRESENT', `${path}.chains`, 'is absent and required');
2800
2848
  const chains = 'chains' in r && c.int(`${path}.chains`, r.chains, 1) ? (r.chains as number) : null;
2801
2849
  const links = 'links' in r && c.int(`${path}.links`, r.links, 1) ? (r.links as number) : null;
2802
2850
  if ('split' in r) {
@@ -2849,11 +2897,32 @@ function checkCloth(c: Check, v: Json): void {
2849
2897
  if ('translate' in r && c.number(`${path}.translate`, r.translate, 'unit') && r.translate === 0) {
2850
2898
  c.fail('CONFIG_FIELD_TYPE', `${path}.translate`, 'is 0; a mix above 0 and at most 1 is required (leave translate out for none)');
2851
2899
  }
2900
+ const checkPartList = (fieldPath: string, list: Json): void => {
2901
+ if (!c.array(fieldPath, list, true)) return;
2902
+ const seen = new Set<string>();
2903
+ (list as Json[]).forEach((item, i) => {
2904
+ if (c.string(`${fieldPath}[${i}]`, item)) {
2905
+ const s = item as string;
2906
+ if (seen.has(s)) {
2907
+ c.fail('CONFIG_CLOTH_PARTS_UNIQUE', `${fieldPath}[${i}]`, `repeats "${s}"; each part in the list must be named once, in order from root to tip`);
2908
+ }
2909
+ seen.add(s);
2910
+ }
2911
+ });
2912
+ };
2913
+ if ('part' in r && 'parts' in r) {
2914
+ c.fail('CONFIG_CLOTH_PARTS_UNIQUE', `${path}.parts`, 'is declared beside part; declare either parts (a list of parts from root to tip) or part, not both');
2915
+ }
2852
2916
  if ('part' in r) {
2853
- if (tag !== 'bottomwear') {
2854
- c.fail('CONFIG_CLOTH_PART_ON_BOTTOMWEAR', `${path}.part`, `is declared on "${tag}"; part hands the skirt's recipe (bottomwear) to a topwear part that holds a skirt (a dress) — leave it out here`);
2917
+ if (Array.isArray(r.part)) {
2918
+ checkPartList(`${path}.part`, r.part);
2919
+ } else if (tag !== 'bottomwear') {
2920
+ c.fail('CONFIG_CLOTH_PART_ON_BOTTOMWEAR', `${path}.part`, `is declared on "${tag}"; part hands the skirt's recipe (bottomwear) to a topwear part or its cut skirt piece (a dress) — leave it out here`);
2855
2921
  } else c.string(`${path}.part`, r.part);
2856
2922
  }
2923
+ if ('parts' in r) {
2924
+ checkPartList(`${path}.parts`, r.parts);
2925
+ }
2857
2926
  if ('swing' in r) {
2858
2927
  if (tag !== 'bottomwear') {
2859
2928
  c.fail('CONFIG_CLOTH_SWING_SCALE_PER_TREE', `${path}.swing`, `is declared on "${tag}"; the shared swing is the skirt's (bottomwear) — leave it out here`);
package/src/headbox.ts CHANGED
@@ -42,6 +42,20 @@ export const HEAD_LAYER_MIN_PX = 150;
42
42
 
43
43
  export type Box = [number, number, number, number];
44
44
 
45
+ export interface HeadBoxOptions {
46
+ /** Whether `"back hair"` is included in the union (issue #351); default `true`. */
47
+ backHair?: boolean;
48
+ }
49
+
50
+ export interface HeadBoxWithoutBackHair {
51
+ /** The square box's side in source px without `"back hair"`. */
52
+ edge: number;
53
+ /** The box without `"back hair"` before clamping to the painting. */
54
+ unclamped: Box;
55
+ /** The box without `"back hair"` held inside the painting, or `null` when `edge` exceeds a canvas side. */
56
+ head_box: Box | null;
57
+ }
58
+
45
59
  export interface HeadBoxProposal {
46
60
  /** The square box, inside the painting: what goes into `seethrough.head_box`. */
47
61
  head_box: Box;
@@ -49,8 +63,10 @@ export interface HeadBoxProposal {
49
63
  unclamped: Box;
50
64
  /** How far the box was moved to stay inside, in source px. */
51
65
  shift: [number, number];
52
- /** The layers that contributed, with their opaque pixel counts after the clean-up. */
66
+ /** The layers that contributed, with their opaque pixel counts after the ghost clean-up. */
53
67
  layers: Array<[string, number]>;
68
+ /** Present when `"back hair"` contributed (with `backHair !== false`, issue #351): the box without `"back hair"`. */
69
+ without_back_hair?: HeadBoxWithoutBackHair;
54
70
  }
55
71
 
56
72
  /**
@@ -90,7 +106,7 @@ function cleanedOpaque(pixels: { width: number; height: number; data: Uint8Clamp
90
106
  * `source.w` x `source.h` pixels. Every refusal names the value found and the
91
107
  * value required.
92
108
  */
93
- export function proposeHeadBox(full: LayerSet, source: { w: number; h: number }): HeadBoxProposal {
109
+ export function proposeHeadBox(full: LayerSet, source: { w: number; h: number }, opts?: HeadBoxOptions): HeadBoxProposal {
94
110
  const problems: Problem[] = [];
95
111
  if (!(Number.isInteger(source.w) && Number.isInteger(source.h) && source.w > 0 && source.h > 0)) {
96
112
  problems.push({ code: 'HEADBOX_CANVAS_SIZE', object: '--canvas', detail: `is ${source.w}x${source.h}; the painting's size in positive integer pixels is required` });
@@ -102,12 +118,14 @@ export function proposeHeadBox(full: LayerSet, source: { w: number; h: number })
102
118
 
103
119
  const pad = squarePad(source.w, source.h);
104
120
  const k = pad.side / full.canvas.w;
121
+ const includeBackHair = opts?.backHair !== false;
105
122
  // The union does not depend on the order the layers are read in; a name
106
123
  // that appears twice is last-wins in draw order (the reference's dict was
107
124
  // last-wins in manifest order, and no wrapper output read so far repeats one).
108
125
  const boxes = new Map<string, { left: number; top: number; xs: number[]; ys: number[] }>();
109
126
  for (const L of full.layers) {
110
127
  if (!(HEAD_LAYERS as readonly string[]).includes(L.name)) continue;
128
+ if (!includeBackHair && L.name === 'back hair') continue;
111
129
  const { xs, ys } = cleanedOpaque(L.pixels);
112
130
  if (xs.length < HEAD_LAYER_MIN_PX) continue;
113
131
  boxes.set(L.name, { left: L.left, top: L.top, xs, ys });
@@ -130,44 +148,60 @@ export function proposeHeadBox(full: LayerSet, source: { w: number; h: number })
130
148
  if (f.top + y > fy1) fy1 = f.top + y;
131
149
  }
132
150
  const cut = fy1 + 0.35 * (fy1 - fy0);
133
- let x0 = Infinity;
134
- let x1 = -Infinity;
135
- let y0 = Infinity;
136
- let y1 = -Infinity;
137
- for (const b of boxes.values()) {
138
- for (let i = 0; i < b.xs.length; i++) {
139
- const y = b.top + b.ys[i];
140
- if (y > cut) continue;
141
- const x = b.left + b.xs[i];
142
- if (x < x0) x0 = x;
143
- if (x > x1) x1 = x;
144
- if (y < y0) y0 = y;
145
- if (y > y1) y1 = y;
151
+ const measureUnion = (entries: Iterable<{ left: number; top: number; xs: number[]; ys: number[] }>): { edge: number; unclamped: Box; shift: [number, number]; head_box: Box | null } => {
152
+ let x0 = Infinity;
153
+ let x1 = -Infinity;
154
+ let y0 = Infinity;
155
+ let y1 = -Infinity;
156
+ for (const b of entries) {
157
+ for (let i = 0; i < b.xs.length; i++) {
158
+ const y = b.top + b.ys[i];
159
+ if (y > cut) continue;
160
+ const x = b.left + b.xs[i];
161
+ if (x < x0) x0 = x;
162
+ if (x > x1) x1 = x;
163
+ if (y < y0) y0 = y;
164
+ if (y > y1) y1 = y;
165
+ }
146
166
  }
147
- }
148
- const size = Math.max(x1 - x0, y1 - y0) * 1.12;
149
- const cx = (x0 + x1) / 2;
150
- const cy = (y0 + y1) / 2;
151
- // Each edge rounded as the reference rounds it; then the side is the larger
152
- // of the two rounded extents, so the box is square, never holds less than
153
- // the reference's, and equals it wherever the reference's was square.
154
- const ex = [pyRound((cx - size / 2) * k), pyRound((cx + size / 2) * k)];
155
- const ey = [pyRound((cy - size / 2) * k), pyRound((cy + size / 2) * k)];
156
- const edge = Math.max(ex[1] - ex[0], ey[1] - ey[0]);
157
- const bx = ex[0] - pad.left;
158
- const by = ey[0] - pad.top;
159
- const unclamped: Box = [bx, by, bx + edge, by + edge];
160
- if (edge > source.w || edge > source.h) {
167
+ const size = Math.max(x1 - x0, y1 - y0) * 1.12;
168
+ const cx = (x0 + x1) / 2;
169
+ const cy = (y0 + y1) / 2;
170
+ // Each edge rounded as the reference rounds it; then the side is the larger
171
+ // of the two rounded extents, so the box is square, never holds less than
172
+ // the reference's, and equals it wherever the reference's was square.
173
+ const ex = [pyRound((cx - size / 2) * k), pyRound((cx + size / 2) * k)];
174
+ const ey = [pyRound((cy - size / 2) * k), pyRound((cy + size / 2) * k)];
175
+ const edge = Math.max(ex[1] - ex[0], ey[1] - ey[0]);
176
+ const bx = ex[0] - pad.left;
177
+ const by = ey[0] - pad.top;
178
+ const unclamped: Box = [bx, by, bx + edge, by + edge];
179
+ if (edge > source.w || edge > source.h) return { edge, unclamped, shift: [0, 0], head_box: null };
180
+ const hold = (lo: number, extent: number): number => (lo < 0 ? -lo : lo + edge > extent ? extent - edge - lo : 0);
181
+ const shift: [number, number] = [hold(bx, source.w), hold(by, source.h)];
182
+ const head_box: Box = [bx + shift[0], by + shift[1], bx + shift[0] + edge, by + shift[1] + edge];
183
+ return { edge, unclamped, shift, head_box };
184
+ };
185
+ const main = measureUnion(boxes.values());
186
+ const without = includeBackHair && boxes.has('back hair') ? measureUnion([...boxes.entries()].filter(([n]) => n !== 'back hair').map(([, b]) => b)) : null;
187
+ if (main.head_box === null) {
188
+ const hairNote =
189
+ without !== null && without.edge < main.edge
190
+ ? ` ("back hair" grew the side from ${without.edge} to ${main.edge} px; without it${without.head_box !== null ? `, [${without.head_box.join(', ')}] (${without.edge}x${without.edge}) fits —` : ''} pass --no-back-hair)`
191
+ : '';
161
192
  refuseIfAny([
162
193
  {
163
194
  code: 'HEADBOX_FITS_CANVAS',
164
195
  object: 'head box',
165
- detail: `is ${edge}x${edge} source px, proposed at [${unclamped.join(', ')}]; the ${source.w}x${source.h} painting cannot hold it at that size, and shrinking it would cut the head`,
196
+ detail: `is ${main.edge}x${main.edge} source px, proposed at [${main.unclamped.join(', ')}]; the ${source.w}x${source.h} painting cannot hold it at that size, and shrinking it would cut the head${hairNote}`,
166
197
  },
167
198
  ]);
168
199
  }
169
- const hold = (lo: number, extent: number): number => (lo < 0 ? -lo : lo + edge > extent ? extent - edge - lo : 0);
170
- const shift: [number, number] = [hold(bx, source.w), hold(by, source.h)];
171
- const head_box: Box = [bx + shift[0], by + shift[1], bx + shift[0] + edge, by + shift[1] + edge];
172
- return { head_box, unclamped, shift, layers: [...boxes.entries()].map(([n, b]) => [n, b.xs.length]) };
200
+ return {
201
+ head_box: main.head_box as Box,
202
+ unclamped: main.unclamped,
203
+ shift: main.shift,
204
+ layers: [...boxes.entries()].map(([n, b]) => [n, b.xs.length]),
205
+ ...(without === null ? {} : { without_back_hair: { edge: without.edge, unclamped: without.unclamped, head_box: without.head_box } }),
206
+ };
173
207
  }