@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/README.md +1 -1
- package/cli.ts +20 -5
- package/docs/AUTHORING.md +61 -38
- package/docs/MIGRATION.md +1 -1
- package/docs/STABILITY.md +7 -7
- package/package.json +1 -1
- package/skills/rig-parts/SKILL.md +1 -1
- package/src/assemble.ts +131 -25
- package/src/build.ts +23 -9
- package/src/check.ts +220 -40
- package/src/cloth.ts +45 -6
- package/src/config.ts +80 -11
- package/src/headbox.ts +68 -34
- package/src/propose.ts +506 -41
- package/src/rig.ts +74 -6
- package/src/scene.ts +87 -7
- package/src/squeeze.ts +1 -1
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)
|
|
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`
|
|
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], ['
|
|
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 (
|
|
2854
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
for (
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
}
|