@rig-c/parts 1.29.0 → 1.31.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/cli.ts +40 -7
- package/docs/AUTHORING.md +21 -8
- package/docs/STABILITY.md +4 -3
- package/package.json +1 -1
- package/src/build.ts +214 -6
- package/src/check.ts +6 -4
- package/src/cloth.ts +60 -8
- package/src/config.ts +85 -16
- package/src/parts.ts +53 -12
- package/src/propose.ts +148 -32
- package/src/rig.ts +118 -2
package/src/build.ts
CHANGED
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
44
44
|
import { dirname, join, resolve } from 'node:path';
|
|
45
45
|
import { type AnimFrame, EncodeError, encodeApng, encodeIndexedApng, INDEXED_DEFAULTS } from './apng.ts';
|
|
46
|
-
import { assemble, type AssembleResult, cutLines, hiddenLines, SINGLE_RUN_FACE_TAGS, driftLine, limbLines, FIGURE_MASK_FILE, figuresLine, holeKindsLine, holeLines, type ProjectRule, scenePhrase, type SeamRule, stageFields, uncoveredLine } from './assemble.ts';
|
|
46
|
+
import { assemble, type AssembleResult, cutLines, hiddenLines, SINGLE_RUN_FACE_TAGS, singleRunHeadBox, driftLine, limbLines, FIGURE_MASK_FILE, figuresLine, holeKindsLine, holeLines, type ProjectRule, scenePhrase, type SeamRule, stageFields, uncoveredLine } from './assemble.ts';
|
|
47
47
|
import { allocationClause, type Reducer, stageBClause, unboundedClause } from './automesh.ts';
|
|
48
48
|
import { residualClause } from './autoenvelope.ts';
|
|
49
49
|
import { type AutoMotionCase, failingRows, idleSchedule, localRow, motionClause, motionDeformation, motionDocument, motionInput, motionRowText, motionStimulus, motionVerdict, noStimulusProblem, type ReplayCandidate, runComparison, sourceSelfFailure, stimulusMotion, withStimulus } from './automotion.ts';
|
|
@@ -71,18 +71,18 @@ import {
|
|
|
71
71
|
untestedIntervals,
|
|
72
72
|
} from './autoreplay.ts';
|
|
73
73
|
import { besideIdleLine, FOLD_LINE, type FoldsLine, LEVEL_LINE, type LevelLine, LIMB_LINE, type LimbLine, STRESS_LINE, SWEEP_LINE, type SweepLine, buildGateLines, causeLines, IDLE_FPS, IDLE_MAX_PX, maxRgbDiff, REQUIREMENTS_DIR, type CheckReport, type PackLine, DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, GATE_PROFILE, JUDGEMENT_LINES, type JudgementLine, packedBuildArgs, packedBuildLabel, type PackMode, type PackShape, type PageEdges, readFrameSet, rigcFailed, rigcIdentity, rigcRanClause, REPORTED_LINES, type ReportedLine, type RigcRunner, runCheck, SEAM_DIR, SEAM_MEAN_BAR, seamPairText, seamRigPxText, SOURCE_LINE, SEAM_PX_BAR, SEAM_PX_LEVEL, SEAM_PX_LEVEL_HIGH, SPINEBOY_YARDSTICK, STILL_DIR } from './check.ts';
|
|
74
|
-
import { type Cut, type Limb, loadConfig, loadConfigAndAnimations, loadEarlyConfig } from './config.ts';
|
|
74
|
+
import { type Cut, type Extend, type Limb, loadConfig, loadConfigAndAnimations, loadEarlyConfig, type Patch, type PlanEntry } from './config.ts';
|
|
75
75
|
import { failLine, PartsError, type Problem, refuseIfAny } from './errors.ts';
|
|
76
76
|
import type { MeshQualityReport, MotionSchedule } from 'rig-c/mesh';
|
|
77
77
|
import { encodeGif } from './gif.ts';
|
|
78
78
|
import { CONTROL_SUFFIX, withAnimations } from './motion.ts';
|
|
79
79
|
import type { PaletteError } from './palette.ts';
|
|
80
80
|
import { type LayerSet, readLayers } from './layers.ts';
|
|
81
|
-
import { type PartRecord, readParts, type SingleRunPad, tagOf, writeParts } from './parts.ts';
|
|
81
|
+
import { type AlternatePartsRecord, type PartRecord, readParts, type RecompositeRecord, type SingleRunPad, tagOf, writeParts } from './parts.ts';
|
|
82
82
|
import { encodePngBytes, readPng, writePng } from './raster/png.ts';
|
|
83
83
|
import type { Raster } from './raster/types.ts';
|
|
84
84
|
import { readRequirements, type RequirementLine, summaryText } from './requirements.ts';
|
|
85
|
-
import { type AutoMeshReport, buildRig, DEFAULT_IDLE_KEYS, type FaceAxis, type IdleKeys, type MeshReport, type RigCommand, rigJsonText, type RigOutput, type RigSpec, type StrayCleared, type TurnRow } from './rig.ts';
|
|
85
|
+
import { type AutoMeshReport, buildRig, DEFAULT_IDLE_KEYS, type FaceAxis, type IdleKeys, type MeshReport, type RigCommand, rigJsonText, type RigOutput, type RigSpec, SETS_FILE, type StrayCleared, type TurnRow } from './rig.ts';
|
|
86
86
|
import { AUTHOR_NO_ROLE, isAuthorTag } from './tags.ts';
|
|
87
87
|
import { TURN_BONE } from './turn.ts';
|
|
88
88
|
import { pyRound } from './round.ts';
|
|
@@ -130,6 +130,14 @@ export function readRuns(full: string, head: string | null): { full: LayerSet; h
|
|
|
130
130
|
return { full: f as LayerSet, head: h as LayerSet, single: head === null };
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
/** One `--alt <name>=<source>:<full>[:<head>]` argument (issue #454). */
|
|
134
|
+
export interface AlternateCliInput {
|
|
135
|
+
name: string;
|
|
136
|
+
source: string;
|
|
137
|
+
full: string;
|
|
138
|
+
head?: string;
|
|
139
|
+
}
|
|
140
|
+
|
|
133
141
|
export interface AssembleStageInput {
|
|
134
142
|
source: string;
|
|
135
143
|
full: string;
|
|
@@ -138,6 +146,8 @@ export interface AssembleStageInput {
|
|
|
138
146
|
config: string;
|
|
139
147
|
seam: SeamRule;
|
|
140
148
|
project: ProjectRule;
|
|
149
|
+
/** `--alt` entries, merged with `config.assemble.alternates` by set name (issue #454). */
|
|
150
|
+
alts?: readonly AlternateCliInput[];
|
|
141
151
|
}
|
|
142
152
|
|
|
143
153
|
/** Where the assemble stage writes: `parts.json`, the directory of part PNGs, the recomposite, and its error map. */
|
|
@@ -246,6 +256,191 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
|
|
|
246
256
|
const under = readUnders(input.config, fields.cuts);
|
|
247
257
|
const caps = fields.limbs === undefined ? undefined : readCaps(input.config, fields.limbs);
|
|
248
258
|
const result = assemble({ source: src, full: runs.full, head: runs.head, ...fields, under, ...(caps === undefined ? {} : { caps }), seamRule: input.seam, projectRule: input.project });
|
|
259
|
+
|
|
260
|
+
// Issue #454: alternate painting sets, assembled before any file is written.
|
|
261
|
+
const configDir = dirname(resolve(input.config));
|
|
262
|
+
const cliAlts = input.alts ?? [];
|
|
263
|
+
const cfgAlts = cfg.assemble.alternates ?? [];
|
|
264
|
+
const cliByName = new Map<string, AlternateCliInput>();
|
|
265
|
+
for (const a of cliAlts) cliByName.set(a.name, a);
|
|
266
|
+
const mergedAlts: Array<{
|
|
267
|
+
name: string;
|
|
268
|
+
at: string;
|
|
269
|
+
source?: string;
|
|
270
|
+
full?: string;
|
|
271
|
+
head?: string;
|
|
272
|
+
parts?: string[];
|
|
273
|
+
hides?: string[];
|
|
274
|
+
head_box?: [number, number, number, number];
|
|
275
|
+
cuts?: Cut[];
|
|
276
|
+
}> = cfgAlts.map((a, i) => {
|
|
277
|
+
const cli = cliByName.get(a.name);
|
|
278
|
+
if (cli !== undefined) cliByName.delete(a.name);
|
|
279
|
+
return {
|
|
280
|
+
name: a.name,
|
|
281
|
+
at: `config.assemble.alternates[${i}] (set "${a.name}")`,
|
|
282
|
+
source: cli?.source ?? (a.source === undefined ? undefined : resolve(configDir, a.source)),
|
|
283
|
+
full: cli?.full ?? (a.full === undefined ? undefined : resolve(configDir, a.full)),
|
|
284
|
+
head: cli !== undefined ? cli.head : a.head === undefined ? undefined : resolve(configDir, a.head),
|
|
285
|
+
parts: a.parts,
|
|
286
|
+
hides: a.hides,
|
|
287
|
+
head_box: a.head_box,
|
|
288
|
+
cuts: a.cuts,
|
|
289
|
+
};
|
|
290
|
+
});
|
|
291
|
+
for (const cli of cliByName.values()) {
|
|
292
|
+
mergedAlts.push({
|
|
293
|
+
name: cli.name,
|
|
294
|
+
at: `--alt ${cli.name}`,
|
|
295
|
+
source: cli.source,
|
|
296
|
+
full: cli.full,
|
|
297
|
+
head: cli.head,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const altProblems: Problem[] = [];
|
|
302
|
+
const altBuilt: Array<{ record: AlternatePartsRecord; images: Array<{ name: string; image: Raster }> }> = [];
|
|
303
|
+
const baseSlots = result.parts.parts.map((p) => p.name);
|
|
304
|
+
for (const alt of mergedAlts) {
|
|
305
|
+
if (alt.source === undefined || alt.full === undefined) {
|
|
306
|
+
altProblems.push({
|
|
307
|
+
code: 'ASSEMBLE_ALT_INPUT_PRESENT',
|
|
308
|
+
object: alt.at,
|
|
309
|
+
detail: 'is missing source or full; declare source and full on the alternate set in config.assemble.alternates, or supply --alt <name>=<source>:<full>[:<head>]',
|
|
310
|
+
});
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (!existsSync(alt.source)) {
|
|
314
|
+
altProblems.push({
|
|
315
|
+
code: 'ASSEMBLE_ALT_INPUT_PRESENT',
|
|
316
|
+
object: `${alt.source} (alternate set "${alt.name}")`,
|
|
317
|
+
detail: 'no such file; the alternate painting is required',
|
|
318
|
+
});
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
if (!existsSync(alt.full)) {
|
|
322
|
+
altProblems.push({
|
|
323
|
+
code: 'ASSEMBLE_ALT_INPUT_PRESENT',
|
|
324
|
+
object: `${alt.full} (alternate set "${alt.name}")`,
|
|
325
|
+
detail: 'no such file or directory; the alternate full run is required',
|
|
326
|
+
});
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
if (alt.head !== undefined && !existsSync(alt.head)) {
|
|
330
|
+
altProblems.push({
|
|
331
|
+
code: 'ASSEMBLE_ALT_INPUT_PRESENT',
|
|
332
|
+
object: `${alt.head} (alternate set "${alt.name}")`,
|
|
333
|
+
detail: 'no such file or directory; the alternate head run is required when named',
|
|
334
|
+
});
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
const altSrc = readSource(alt.source);
|
|
338
|
+
if (altSrc.width !== src.width || altSrc.height !== src.height) {
|
|
339
|
+
altProblems.push({
|
|
340
|
+
code: 'ASSEMBLE_ALT_CANVAS_SIZE',
|
|
341
|
+
object: `${alt.source} (alternate set "${alt.name}")`,
|
|
342
|
+
detail: `is ${altSrc.width}x${altSrc.height}, while the primary painting ${input.source} is ${src.width}x${src.height}; an alternate painting must share the primary painting's canvas size so its parts land in the same rig frame`,
|
|
343
|
+
});
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
const altRuns = readRuns(alt.full, alt.head ?? null);
|
|
347
|
+
const hidesSet = new Set(alt.hides ?? []);
|
|
348
|
+
const declaredSlots = alt.parts !== undefined ? alt.parts.filter((p) => !hidesSet.has(p)) : baseSlots.filter((p) => !hidesSet.has(p));
|
|
349
|
+
const coveredSet = new Set(declaredSlots);
|
|
350
|
+
const rawCuts = alt.cuts !== undefined ? alt.cuts : fields.cuts.filter((c) => coveredSet.has(c.into) && coveredSet.has(c.from)).map((c) => ({ ...c, under: undefined }));
|
|
351
|
+
const altLimbs: Record<string, Limb> = {};
|
|
352
|
+
if (fields.limbs !== undefined) {
|
|
353
|
+
for (const [ln, limb] of Object.entries(fields.limbs)) {
|
|
354
|
+
if (limb.segments.every((sg) => coveredSet.has(sg.part))) {
|
|
355
|
+
altLimbs[ln] = { ...limb, segments: limb.segments.map((sg) => ({ ...sg, cap: undefined })) };
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
const neededPlan = new Set<string>();
|
|
360
|
+
for (const [name] of fields.plan) {
|
|
361
|
+
if (
|
|
362
|
+
coveredSet.has(name) ||
|
|
363
|
+
rawCuts.some((c) => c.from === name) ||
|
|
364
|
+
Object.values(altLimbs).some((l) => l.segments.some((sg) => sg.from === name))
|
|
365
|
+
) {
|
|
366
|
+
neededPlan.add(name);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
const altPlan: PlanEntry[] = fields.plan
|
|
370
|
+
.filter(([name]) => neededPlan.has(name))
|
|
371
|
+
.map(([name, run, tag]): PlanEntry => (altRuns.single ? [name, 'full', tag] : [name, run, tag]));
|
|
372
|
+
let missingLayer = false;
|
|
373
|
+
for (const [name, run, tag] of altPlan) {
|
|
374
|
+
const l = altRuns[run].layers.find((x) => x.name === tag);
|
|
375
|
+
if (l === undefined || l.opaquePx === 0) {
|
|
376
|
+
altProblems.push({
|
|
377
|
+
code: 'ASSEMBLE_ALT_INPUT_PRESENT',
|
|
378
|
+
object: `alternate set "${alt.name}" (part "${name}")`,
|
|
379
|
+
detail: `takes ${run}:${tag}, which ${l === undefined ? 'is absent' : 'has 0 opaque pixels'} in ${altRuns[run].source}; every covered part of the alternate set must be present in its runs, or listed in hides`,
|
|
380
|
+
});
|
|
381
|
+
missingLayer = true;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
if (missingLayer) continue;
|
|
385
|
+
const altPlanNames = new Set(altPlan.map((e) => e[0]));
|
|
386
|
+
const remapDraw = (d: Patch['draw']): Patch['draw'] => {
|
|
387
|
+
if (typeof d !== 'object') return d;
|
|
388
|
+
if (altPlanNames.has(d.before)) return d;
|
|
389
|
+
const baseIdx = fields.plan.findIndex((e) => e[0] === d.before);
|
|
390
|
+
for (let i = Math.max(0, baseIdx); i < fields.plan.length; i++) {
|
|
391
|
+
if (altPlanNames.has(fields.plan[i][0])) return { before: fields.plan[i][0] };
|
|
392
|
+
}
|
|
393
|
+
return 'front';
|
|
394
|
+
};
|
|
395
|
+
const altExtend: Extend[] = altRuns.single ? [] : fields.extend.filter((e) => altPlanNames.has(e.part));
|
|
396
|
+
const altPatches: Patch[] = fields.patches.filter((p) => coveredSet.has(p.name)).map((p) => ({ ...p, draw: remapDraw(p.draw) }));
|
|
397
|
+
const altCuts: Cut[] = rawCuts.map((c) => ({ ...c, draw: remapDraw(c.draw) }));
|
|
398
|
+
const altUnder = alt.cuts !== undefined ? readUnders(input.config, altCuts) : altCuts.map(() => null);
|
|
399
|
+
const altHeadBox = alt.head_box ?? (altRuns.single ? singleRunHeadBox(altSrc.width, altSrc.height, fields.resolution) : fields.headBox);
|
|
400
|
+
const altRes = assemble({
|
|
401
|
+
source: altSrc,
|
|
402
|
+
full: altRuns.full,
|
|
403
|
+
head: altRuns.head,
|
|
404
|
+
resolution: fields.resolution,
|
|
405
|
+
headBox: altHeadBox,
|
|
406
|
+
rigScale: fields.rigScale,
|
|
407
|
+
plan: altPlan,
|
|
408
|
+
extend: altExtend,
|
|
409
|
+
patches: altPatches,
|
|
410
|
+
cuts: altCuts,
|
|
411
|
+
under: altUnder,
|
|
412
|
+
...(Object.keys(altLimbs).length > 0 ? { limbs: altLimbs, bones: fields.bones } : {}),
|
|
413
|
+
...(fields.scene === undefined ? {} : { scene: fields.scene }),
|
|
414
|
+
...(fields.armSplit !== undefined && altPlan.some((e) => e[1] === 'full' && (e[2] === 'handwear-r' || e[2] === 'handwear-l')) ? { armSplit: fields.armSplit } : {}),
|
|
415
|
+
seamRule: input.seam,
|
|
416
|
+
projectRule: input.project,
|
|
417
|
+
});
|
|
418
|
+
const keptImages = altRes.images
|
|
419
|
+
.filter(({ record }) => coveredSet.has(record.name))
|
|
420
|
+
.map(({ record, image }) => ({ name: `${alt.name}_${record.name}`, image }));
|
|
421
|
+
const keptRecords = altRes.parts.parts.filter((p) => coveredSet.has(p.name));
|
|
422
|
+
const slots: Record<string, string> = {};
|
|
423
|
+
for (const r of keptRecords) slots[r.name] = `${alt.name}_${r.name}`;
|
|
424
|
+
altBuilt.push({
|
|
425
|
+
record: {
|
|
426
|
+
name: alt.name,
|
|
427
|
+
...(alt.parts === undefined ? {} : { parts: [...alt.parts] }),
|
|
428
|
+
...(alt.hides === undefined || alt.hides.length === 0 ? {} : { hides: [...alt.hides] }),
|
|
429
|
+
slots,
|
|
430
|
+
records: keptRecords,
|
|
431
|
+
ghost_px: altRes.parts.ghost_px,
|
|
432
|
+
recomposite: altRes.parts.recomposite as RecompositeRecord,
|
|
433
|
+
...(altRes.parts.scene === undefined ? {} : { scene: altRes.parts.scene }),
|
|
434
|
+
...(altRes.parts.single_run_pad === undefined ? {} : { single_run_pad: altRes.parts.single_run_pad }),
|
|
435
|
+
},
|
|
436
|
+
images: keptImages,
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
refuseIfAny(altProblems);
|
|
440
|
+
if (altBuilt.length > 0) {
|
|
441
|
+
result.parts.alternates = altBuilt.map((b) => b.record);
|
|
442
|
+
}
|
|
443
|
+
|
|
249
444
|
// Emit only after green: every refusal above has already thrown.
|
|
250
445
|
mkdirSync(outs.partsDir, { recursive: true });
|
|
251
446
|
mkdirSync(dirname(outs.partsJson), { recursive: true });
|
|
@@ -253,6 +448,9 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
|
|
|
253
448
|
mkdirSync(dirname(outs.errorMap), { recursive: true });
|
|
254
449
|
writeParts(outs.partsJson, result.parts);
|
|
255
450
|
for (const { record, image } of result.images) writePng(join(outs.partsDir, `${record.name}.png`), image);
|
|
451
|
+
for (const b of altBuilt) {
|
|
452
|
+
for (const { name, image } of b.images) writePng(join(outs.partsDir, `${name}.png`), image);
|
|
453
|
+
}
|
|
256
454
|
writePng(outs.recomposite, result.recomposite);
|
|
257
455
|
writePng(outs.errorMap, result.errorMap);
|
|
258
456
|
// Issue #242: under config.scene, the figure the recomposite was read inside, beside the recomposite.
|
|
@@ -820,11 +1018,19 @@ export function rigStage(input: RigStageInput, rigc: RigcRunner, scratch: string
|
|
|
820
1018
|
const png = join(input.parts, 'parts', `${p.name}.png`);
|
|
821
1019
|
if (existsSync(png)) images.set(p.name, readPng(png));
|
|
822
1020
|
}
|
|
1021
|
+
for (const alt of parts.alternates ?? []) {
|
|
1022
|
+
for (const p of alt.records) {
|
|
1023
|
+
const attName = `${alt.name}_${p.name}`;
|
|
1024
|
+
const png = join(input.parts, 'parts', `${attName}.png`);
|
|
1025
|
+
if (existsSync(png)) images.set(attName, readPng(png));
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
823
1028
|
const first = buildRig(cfg, parts, images, undefined, input.idleKeys ?? DEFAULT_IDLE_KEYS, input.reduce);
|
|
824
1029
|
const textsOf = (r: RigOutput): Array<[string, string]> => [
|
|
825
1030
|
['rig.json', rigJsonText(r.rig)],
|
|
826
1031
|
['motion.json', rigJsonText(withAnimations(r.motion, from === null ? null : from.table))],
|
|
827
1032
|
['mesh_report.json', rigJsonText(r.meshReport)],
|
|
1033
|
+
...(r.sets === undefined ? [] : [[SETS_FILE, rigJsonText(r.sets)] as [string, string]]),
|
|
828
1034
|
];
|
|
829
1035
|
const firstTexts = textsOf(first);
|
|
830
1036
|
// The gate runs before the lines are printed, and its lines print where they always did: an automatic part's motion
|
|
@@ -855,7 +1061,7 @@ export function rigStage(input: RigStageInput, rigc: RigcRunner, scratch: string
|
|
|
855
1061
|
const replays = new Map<string, ReplayRow | MultiIntervalRow>();
|
|
856
1062
|
if (settled) {
|
|
857
1063
|
const swapped = JSON.parse(rigJsonText(first.rig)) as RigSpec;
|
|
858
|
-
for (const k of chosen) swapped.skins.default[k.part] = { [k.part]: k.candidate.attachment };
|
|
1064
|
+
for (const k of chosen) swapped.skins.default[k.part] = { ...swapped.skins.default[k.part], [k.part]: k.candidate.attachment };
|
|
859
1065
|
rig = { ...first, rig: swapped, meshReport: first.meshReport.map((m) => chosen.find((k) => k.part === m.part)?.candidate.row ?? m) };
|
|
860
1066
|
texts = textsOf(rig);
|
|
861
1067
|
let model: string | null = null;
|
|
@@ -1499,6 +1705,8 @@ export interface BuildInput {
|
|
|
1499
1705
|
seamPairs?: boolean;
|
|
1500
1706
|
/** `--keep-stills`, forwarded to the check as `check` takes it (issue #257): the setup-pose still under `check/setup_still/`; absent, nothing moves. */
|
|
1501
1707
|
keepStills?: boolean;
|
|
1708
|
+
/** `--alt`, forwarded to the assemble stage as `assemble` takes it (issue #454). */
|
|
1709
|
+
alts?: readonly AlternateCliInput[];
|
|
1502
1710
|
}
|
|
1503
1711
|
|
|
1504
1712
|
/** What `build` does with one flag `check` takes: hands it to its check stage, takes it as its own, or refuses it because build supplies the value itself. */
|
|
@@ -1617,7 +1825,7 @@ export function build(input: BuildInput, run: BuildRunners, log: Log): BuildResu
|
|
|
1617
1825
|
// The same for the requirements file's own shape; its names resolve against the rig, in the check, before it builds.
|
|
1618
1826
|
if (input.requirements !== undefined) readRequirements(input.requirements);
|
|
1619
1827
|
assembleStage(
|
|
1620
|
-
{ source: input.source, full: input.full, head: input.head, config: input.config, seam: input.seam, project: input.project },
|
|
1828
|
+
{ source: input.source, full: input.full, head: input.head, config: input.config, seam: input.seam, project: input.project, ...(input.alts === undefined ? {} : { alts: input.alts }) },
|
|
1621
1829
|
{ partsJson: join(out, 'parts.json'), partsDir: join(out, 'parts'), recomposite: join(out, 'recomposite_rig.png'), errorMap: join(out, ERROR_MAP_FILE) },
|
|
1622
1830
|
prefixed('assemble'),
|
|
1623
1831
|
);
|
package/src/check.ts
CHANGED
|
@@ -2569,7 +2569,7 @@ export function stressPlan(rig: Record<string, unknown>, motion: Record<string,
|
|
|
2569
2569
|
continue;
|
|
2570
2570
|
}
|
|
2571
2571
|
const onSlot = skin[p.name];
|
|
2572
|
-
const atts: unknown[] = isRecord(onSlot) ? Object.values(onSlot) : [];
|
|
2572
|
+
const atts: unknown[] = isRecord(onSlot) ? (isRecord(onSlot[p.name]) ? [onSlot[p.name]] : Object.values(onSlot)) : [];
|
|
2573
2573
|
const weighted = atts.filter((a): a is Record<string, unknown> & { weights: unknown[] } => isRecord(a) && Array.isArray(a.weights));
|
|
2574
2574
|
if (weighted.length === 0) {
|
|
2575
2575
|
notRead.push({ part: p.name, reason: 'it is not a weighted mesh, so it rides one bone whole and cannot stretch' });
|
|
@@ -4252,7 +4252,7 @@ function offChainClause(rig: Record<string, unknown>, motion: Record<string, unk
|
|
|
4252
4252
|
const skins = isRecord(rig.skins) ? rig.skins : {};
|
|
4253
4253
|
const skin = isRecord(skins.default) ? skins.default : {};
|
|
4254
4254
|
const onSlot = skin[part];
|
|
4255
|
-
const atts: unknown[] = isRecord(onSlot) ? Object.values(onSlot) : [];
|
|
4255
|
+
const atts: unknown[] = isRecord(onSlot) ? (isRecord(onSlot[part]) ? [onSlot[part]] : Object.values(onSlot)) : [];
|
|
4256
4256
|
const weighted = atts.filter((a): a is Record<string, unknown> & { weights: unknown[] } => isRecord(a) && Array.isArray(a.weights));
|
|
4257
4257
|
if (weighted.length === 0) return null;
|
|
4258
4258
|
const { found } = chainsCarrying(rig, motion, part);
|
|
@@ -4295,7 +4295,8 @@ function boundBones(rig: Record<string, unknown>, part: string): Set<string> {
|
|
|
4295
4295
|
const slot = (Array.isArray(rig.slots) ? rig.slots : []).filter(isRecord).find((sl) => sl.name === part);
|
|
4296
4296
|
const skins = isRecord(rig.skins) ? rig.skins : {};
|
|
4297
4297
|
const skin = isRecord(skins.default) ? skins.default : {};
|
|
4298
|
-
const
|
|
4298
|
+
const onSlot = skin[part];
|
|
4299
|
+
const atts = isRecord(onSlot) ? (isRecord(onSlot[part]) ? [onSlot[part]] : Object.values(onSlot)) : [];
|
|
4299
4300
|
const bound = new Set<string>();
|
|
4300
4301
|
for (const a of atts) {
|
|
4301
4302
|
if (isRecord(a) && Array.isArray(a.weights)) for (const v of a.weights) for (const w of Array.isArray(v) ? v : []) if (isRecord(w) && typeof w.bone === 'string') bound.add(w.bone);
|
|
@@ -4345,7 +4346,8 @@ function breathCarrier(
|
|
|
4345
4346
|
// The one weighted mesh the part draws, its weights in the order of the geometry's vertices.
|
|
4346
4347
|
const skins = isRecord(rig.skins) ? rig.skins : {};
|
|
4347
4348
|
const skin = isRecord(skins.default) ? skins.default : {};
|
|
4348
|
-
const
|
|
4349
|
+
const onSlot = skin[part];
|
|
4350
|
+
const atts = isRecord(onSlot) ? (isRecord(onSlot[part]) ? [onSlot[part]] : Object.values(onSlot)) : [];
|
|
4349
4351
|
const first = m.shown[0];
|
|
4350
4352
|
if (atts.length !== 1 || first === null || first === undefined) return null;
|
|
4351
4353
|
const att = atts[0];
|
package/src/cloth.ts
CHANGED
|
@@ -107,11 +107,13 @@ export type ClothRecipes = Partial<Record<ClothTarget, ClothRecipe>>;
|
|
|
107
107
|
|
|
108
108
|
/**
|
|
109
109
|
* 📐 The fallback tree count when `draftTrees` is called without `chains` or
|
|
110
|
-
* `masses` (issues #390, #445). When `chains` is omitted on
|
|
110
|
+
* `masses` (issues #390, #445, #461). When `chains` is omitted on
|
|
111
111
|
* `config.cloth."back hair"`, `propose` derives the count per side from the
|
|
112
|
-
* visible hair masses ({@link backHairMasses}):
|
|
113
|
-
* back-hair part's alpha below the root row (merged
|
|
114
|
-
* grid cell),
|
|
112
|
+
* visible hair masses ({@link backHairMasses}, {@link backHairMassTrees}):
|
|
113
|
+
* connected components of the back-hair part's alpha below the root row (merged
|
|
114
|
+
* when they touch within one grid cell), with `max(1, floor(w / (4 * grid)) + 1)`
|
|
115
|
+
* trees per mass across its root-row width `w`, rooted at that mass's own top
|
|
116
|
+
* row and inside its columns.
|
|
115
117
|
*/
|
|
116
118
|
export const RECIPE_BACK_HAIR_CHAINS = 2;
|
|
117
119
|
|
|
@@ -185,13 +187,16 @@ export interface TreeChain {
|
|
|
185
187
|
/** Which outer sides of the piece are edges: both (a skirt, a lock), or one side of the eye axis (back hair). */
|
|
186
188
|
export type EdgeSides = 'both' | 'r' | 'l';
|
|
187
189
|
|
|
188
|
-
/** Issue #445: one visible hair mass's own frame for a
|
|
190
|
+
/** Issue #445, #461: one visible hair mass's own frame for a tree rooted at that mass's top row. */
|
|
189
191
|
export interface TreeMassInput {
|
|
190
192
|
rootY: number;
|
|
191
193
|
tipY: number;
|
|
192
194
|
xAt: (y: number, frac: number) => number | null;
|
|
193
195
|
fallbackX: (frac: number) => number;
|
|
194
196
|
frame: string;
|
|
197
|
+
/** Issue #461: tree index within this mass (0-based, default 0) and total trees in this mass (default 1). */
|
|
198
|
+
treeInMass?: number;
|
|
199
|
+
treesInMass?: number;
|
|
195
200
|
}
|
|
196
201
|
|
|
197
202
|
export interface TreeColumnTip {
|
|
@@ -213,7 +218,7 @@ export interface TreeInput {
|
|
|
213
218
|
/** What the cell's frame is, for the rules: "skirt's art", "the r side of back's art". */
|
|
214
219
|
frame: string;
|
|
215
220
|
edges: EdgeSides;
|
|
216
|
-
/** Issue #445: when present, one entry per
|
|
221
|
+
/** Issue #445: when present, one entry per drafted back-hair tree; tree k sits inside `masses[k]` rather than cell [k/n, (k+1)/n] of the whole side. */
|
|
217
222
|
masses?: readonly TreeMassInput[];
|
|
218
223
|
/**
|
|
219
224
|
* Issue #438: when present, resolves the tip row (and an optional `xAt` bounded to that column's cloth rows) for
|
|
@@ -267,7 +272,9 @@ export function draftTrees(input: TreeInput, r: ClothRecipe): TreeChain[] {
|
|
|
267
272
|
};
|
|
268
273
|
for (const k of activeTrees) {
|
|
269
274
|
const mass = input.masses?.[k];
|
|
270
|
-
const
|
|
275
|
+
const tInMass = mass?.treeInMass ?? 0;
|
|
276
|
+
const nInMass = mass?.treesInMass ?? 1;
|
|
277
|
+
const fc = mass === undefined ? (k + 0.5) / n : (tInMass + 0.5) / nInMass;
|
|
271
278
|
const trunkCol = trunkCols.get(k) ?? null;
|
|
272
279
|
const branchCols: Array<TreeColumnTip | null> = [];
|
|
273
280
|
if (into > 1 && mass === undefined && input.columnTip !== undefined) {
|
|
@@ -314,7 +321,7 @@ export function draftTrees(input: TreeInput, r: ClothRecipe): TreeChain[] {
|
|
|
314
321
|
out.push(trunk);
|
|
315
322
|
if (into === 1) continue;
|
|
316
323
|
for (let b = 0; b < into; b++) {
|
|
317
|
-
const fb = mass === undefined ? (k + (b + 0.5) / into) / n : (b + 0.5) / into;
|
|
324
|
+
const fb = mass === undefined ? (k + (b + 0.5) / into) / n : (tInMass + (b + 0.5) / into) / nInMass;
|
|
318
325
|
const bCol = branchCols[b] ?? trunkCol;
|
|
319
326
|
const bTipY = bCol?.tipY ?? trunkTipY;
|
|
320
327
|
const bRows = bTipY === trunkTipY ? rows : linkRows(input.rootY, bTipY, r.links, ratio);
|
|
@@ -764,3 +771,48 @@ export function backHairMasses(mask: Mask, side: 'r' | 'l', axis: number, y0: nu
|
|
|
764
771
|
out.sort((a, b) => a.minX - b.minX);
|
|
765
772
|
return out;
|
|
766
773
|
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* 📐 The horizontal influence reach of one back-hair tree in mesh grid cells: 4
|
|
777
|
+
* (2 grid cells on each side of the trunk, issue #461).
|
|
778
|
+
*
|
|
779
|
+
* Under the segment weight rule `w = 1 / (d + r)^2` (`src/weights.ts`) with
|
|
780
|
+
* `partGrid`'s falloff `r ≈ 0.42 * grid` and cutoff `MIN_WEIGHT = 0.03`, a
|
|
781
|
+
* tree's weight relative to `d = 0` (`(r / (d + r))^2`) drops to `MIN_WEIGHT`
|
|
782
|
+
* at `d = (1 / sqrt(0.03) - 1) * r = 4.7735 * 0.42 * grid = 2.0049 * grid ≈ 2 * grid`
|
|
783
|
+
* on each side of the trunk — a horizontal span of `4 * grid` per tree. Beyond
|
|
784
|
+
* that span a single tree leaves outer columns weighted to the static `head`
|
|
785
|
+
* segment while adjacent columns move with the tree, stretching the triangles
|
|
786
|
+
* across the boundary.
|
|
787
|
+
*/
|
|
788
|
+
export const BACK_HAIR_TREE_REACH_CELLS = 4;
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* Opaque pixel count of a visible hair mass `m` along its own root row `m.minY`
|
|
792
|
+
* (issue #461).
|
|
793
|
+
*/
|
|
794
|
+
export function backHairMassRootWidth(m: BackHairMass): number {
|
|
795
|
+
const W = m.mask.width;
|
|
796
|
+
const H = m.mask.height;
|
|
797
|
+
if (m.minY < 0 || m.minY >= H) return 0;
|
|
798
|
+
let w = 0;
|
|
799
|
+
for (let x = m.minX; x <= m.maxX; x++) {
|
|
800
|
+
if (m.mask.data[m.minY * W + x] !== 0) w++;
|
|
801
|
+
}
|
|
802
|
+
return w > 0 ? w : m.maxX - m.minX + 1;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* Number of trees placed inside one visible hair mass `m` on a `grid` px mesh
|
|
807
|
+
* (issue #461): `max(1, floor(w / (4 * grid)) + 1)` where `w` is the mass's
|
|
808
|
+
* root-row opaque width ({@link backHairMassRootWidth}).
|
|
809
|
+
*
|
|
810
|
+
* A narrow mass (`w < 4 * grid`) gets 1 tree; a wide undivided tail spanning
|
|
811
|
+
* `4 * grid <= w < 8 * grid` gets 2 trees; `8 * grid <= w < 12 * grid` gets 3.
|
|
812
|
+
*/
|
|
813
|
+
export function backHairMassTrees(m: BackHairMass, grid: number): number {
|
|
814
|
+
const step = Math.max(1, BACK_HAIR_TREE_REACH_CELLS * grid);
|
|
815
|
+
const w = backHairMassRootWidth(m);
|
|
816
|
+
return Math.max(1, Math.floor(w / step) + 1);
|
|
817
|
+
}
|
|
818
|
+
|
package/src/config.ts
CHANGED
|
@@ -245,6 +245,17 @@ export interface Cut {
|
|
|
245
245
|
under_fill?: 'colour';
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
+
export interface AlternateSetConfig {
|
|
249
|
+
name: string;
|
|
250
|
+
source?: string;
|
|
251
|
+
full?: string;
|
|
252
|
+
head?: string;
|
|
253
|
+
parts?: string[];
|
|
254
|
+
hides?: string[];
|
|
255
|
+
head_box?: [number, number, number, number];
|
|
256
|
+
cuts?: Cut[];
|
|
257
|
+
}
|
|
258
|
+
|
|
248
259
|
export interface Assemble {
|
|
249
260
|
/** Rig pixels per source pixel. */
|
|
250
261
|
rig_scale: number;
|
|
@@ -252,6 +263,7 @@ export interface Assemble {
|
|
|
252
263
|
extend_below_crop?: Extend[];
|
|
253
264
|
patches?: Patch[];
|
|
254
265
|
cuts?: Cut[];
|
|
266
|
+
alternates?: AlternateSetConfig[];
|
|
255
267
|
}
|
|
256
268
|
|
|
257
269
|
export interface SingleBone {
|
|
@@ -1112,7 +1124,7 @@ function parseEarlyConfigFrom(raw: Json, door: EarlyDoor, base: string | null):
|
|
|
1112
1124
|
if (door === 'layers') {
|
|
1113
1125
|
if ('seethrough' in t) checkSeeThrough(c, t.seethrough);
|
|
1114
1126
|
if ('assemble' in t) {
|
|
1115
|
-
const a = c.object('config.assemble', t.assemble, sectionRequired(door, 'assemble'), ['plan', 'extend_below_crop', 'patches', 'cuts']);
|
|
1127
|
+
const a = c.object('config.assemble', t.assemble, sectionRequired(door, 'assemble'), ['plan', 'extend_below_crop', 'patches', 'cuts', 'alternates']);
|
|
1116
1128
|
if (a !== null && 'rig_scale' in a) c.number('config.assemble.rig_scale', a.rig_scale, 'positive');
|
|
1117
1129
|
}
|
|
1118
1130
|
}
|
|
@@ -1122,10 +1134,14 @@ function parseEarlyConfigFrom(raw: Json, door: EarlyDoor, base: string | null):
|
|
|
1122
1134
|
checkProposedRigScale(c, t);
|
|
1123
1135
|
if ('scene' in t) checkScene(c, t.scene);
|
|
1124
1136
|
// Issue #349: the limbs' joints are their bones' origins, so the bones are read with them, and only then.
|
|
1137
|
+
let limbs: LimbNames | null = null;
|
|
1125
1138
|
if ('limbs' in t) {
|
|
1126
1139
|
if (!('bones' in t)) c.fail('CONFIG_FIELD_PRESENT', 'config.bones', 'is absent; config.limbs names bones, and each joint is its bone\'s origin, so assemble reads the bones with the limbs');
|
|
1127
1140
|
const names = 'bones' in t ? checkBones(c, t.bones) : null;
|
|
1128
|
-
checkLimbs(c, t.limbs, made, names);
|
|
1141
|
+
limbs = checkLimbs(c, t.limbs, made, names);
|
|
1142
|
+
}
|
|
1143
|
+
if (isPlainObject(t.assemble) && 'alternates' in t.assemble) {
|
|
1144
|
+
checkAlternates(c, t.assemble.alternates, made, limbs);
|
|
1129
1145
|
}
|
|
1130
1146
|
}
|
|
1131
1147
|
if ('cloth' in t) checkCloth(c, t.cloth);
|
|
@@ -1276,6 +1292,9 @@ function parseConfigFrom(raw: Json, base: string | null): { config: CharacterCon
|
|
|
1276
1292
|
checkProposedRigScale(c, t);
|
|
1277
1293
|
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>() };
|
|
1278
1294
|
const limbs = 'limbs' in t ? checkLimbs(c, t.limbs, { parts, patches, cuts }, 'bones' in t ? names : null) : null;
|
|
1295
|
+
if (isPlainObject(t.assemble) && 'alternates' in t.assemble) {
|
|
1296
|
+
checkAlternates(c, t.assemble.alternates, { parts, patches, cuts }, limbs);
|
|
1297
|
+
}
|
|
1279
1298
|
if ('meshes' in t) checkMeshes(c, t.meshes, names.bones, names.chains);
|
|
1280
1299
|
if ('regions' in t) checkRegions(c, t.regions, names.bones);
|
|
1281
1300
|
if ('meshes' in t && 'regions' in t && 'assemble' in t) checkCoverage(c, [...parts, ...cuts], patches, t.meshes, t.regions, limbs);
|
|
@@ -1602,6 +1621,17 @@ function checkGeneration(c: Check, v: Json): void {
|
|
|
1602
1621
|
}
|
|
1603
1622
|
}
|
|
1604
1623
|
|
|
1624
|
+
function checkHeadBox(c: Check, path: string, b: Json): void {
|
|
1625
|
+
const ints = Array.isArray(b) && b.length === 4 && b.every((n) => typeof n === 'number' && Number.isInteger(n));
|
|
1626
|
+
if (!ints) c.fail('CONFIG_FIELD_TYPE', path, `is ${show(b)}; [x0, y0, x1, y1] in integer source pixels is required`);
|
|
1627
|
+
else {
|
|
1628
|
+
const [x0, y0, x1, y1] = b as number[];
|
|
1629
|
+
if (!(x1 > x0 && y1 > y0 && x1 - x0 === y1 - y0)) {
|
|
1630
|
+
c.fail('CONFIG_HEAD_BOX_SQUARE', path, `is ${x1 - x0}x${y1 - y0}; a non-empty square is required (the head run is fed a square crop)`);
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1605
1635
|
function checkSeeThrough(c: Check, v: Json): void {
|
|
1606
1636
|
const s = c.object('config.seethrough', v, ['resolution', 'steps', 'seed', 'offload'], ['head_box', 'arm_split']);
|
|
1607
1637
|
if (s === null) return;
|
|
@@ -1610,17 +1640,7 @@ function checkSeeThrough(c: Check, v: Json): void {
|
|
|
1610
1640
|
if ('steps' in s) c.int(`${p}.steps`, s.steps, 1);
|
|
1611
1641
|
if ('seed' in s) c.int(`${p}.seed`, s.seed, 0);
|
|
1612
1642
|
if ('offload' in s && typeof s.offload !== 'boolean') c.fail('CONFIG_FIELD_TYPE', `${p}.offload`, `is ${show(s.offload)}; true or false is required`);
|
|
1613
|
-
if ('head_box' in s) {
|
|
1614
|
-
const b = s.head_box;
|
|
1615
|
-
const ints = Array.isArray(b) && b.length === 4 && b.every((n) => typeof n === 'number' && Number.isInteger(n));
|
|
1616
|
-
if (!ints) c.fail('CONFIG_FIELD_TYPE', `${p}.head_box`, `is ${show(b)}; [x0, y0, x1, y1] in integer source pixels is required`);
|
|
1617
|
-
else {
|
|
1618
|
-
const [x0, y0, x1, y1] = b as number[];
|
|
1619
|
-
if (!(x1 > x0 && y1 > y0 && x1 - x0 === y1 - y0)) {
|
|
1620
|
-
c.fail('CONFIG_HEAD_BOX_SQUARE', `${p}.head_box`, `is ${x1 - x0}x${y1 - y0}; a non-empty square is required (the head run is fed a square crop)`);
|
|
1621
|
-
}
|
|
1622
|
-
}
|
|
1623
|
-
}
|
|
1643
|
+
if ('head_box' in s) checkHeadBox(c, `${p}.head_box`, s.head_box);
|
|
1624
1644
|
if ('arm_split' in s) checkArmSplit(c, `${p}.arm_split`, s.arm_split, res);
|
|
1625
1645
|
}
|
|
1626
1646
|
|
|
@@ -1689,7 +1709,7 @@ function checkRunTag(c: Check, path: string, run: Json, tag: Json): void {
|
|
|
1689
1709
|
*/
|
|
1690
1710
|
function checkAssemble(c: Check, v: Json, door: 'assemble' | 'full'): { parts: string[]; patches: string[]; cuts: string[] } {
|
|
1691
1711
|
const required = sectionRequired(door, 'assemble');
|
|
1692
|
-
const a = c.object('config.assemble', v, required.filter((k) => k !== 'plan'), ['plan', 'extend_below_crop', 'patches', 'cuts']);
|
|
1712
|
+
const a = c.object('config.assemble', v, required.filter((k) => k !== 'plan'), ['plan', 'extend_below_crop', 'patches', 'cuts', 'alternates']);
|
|
1693
1713
|
if (a === null) return { parts: [], patches: [], cuts: [] };
|
|
1694
1714
|
if (required.includes('plan') && !('plan' in a)) {
|
|
1695
1715
|
c.fail('CONFIG_FIELD_PRESENT', 'config.assemble.plan', 'is absent and required; `assemble --propose-plan` prints one from the two runs — paste its plan and extend_below_crop into config.assemble');
|
|
@@ -1743,8 +1763,7 @@ function checkAssemble(c: Check, v: Json, door: 'assemble' | 'full'): { parts: s
|
|
|
1743
1763
|
* one polygon. `into` is a new part name: not a plan part, not a patch, not
|
|
1744
1764
|
* another cut's. Returns the names the cuts make, in order.
|
|
1745
1765
|
*/
|
|
1746
|
-
function checkCuts(c: Check, v: Json, plan: string[], patches: string[]): string[] {
|
|
1747
|
-
const p = 'config.assemble.cuts';
|
|
1766
|
+
function checkCuts(c: Check, v: Json, plan: string[], patches: string[], p = 'config.assemble.cuts'): string[] {
|
|
1748
1767
|
const names: string[] = [];
|
|
1749
1768
|
if (!c.array(p, v, false)) return names;
|
|
1750
1769
|
const seen = new Map<string, number>();
|
|
@@ -1796,6 +1815,56 @@ function checkCuts(c: Check, v: Json, plan: string[], patches: string[]): string
|
|
|
1796
1815
|
return names;
|
|
1797
1816
|
}
|
|
1798
1817
|
|
|
1818
|
+
function checkAlternates(
|
|
1819
|
+
c: Check,
|
|
1820
|
+
v: Json,
|
|
1821
|
+
made: { parts: string[]; patches: string[]; cuts: string[] },
|
|
1822
|
+
limbs: LimbNames | null,
|
|
1823
|
+
): void {
|
|
1824
|
+
const p = 'config.assemble.alternates';
|
|
1825
|
+
if (!c.array(p, v, true)) return;
|
|
1826
|
+
const allSlots = new Set<string>([...made.parts, ...made.patches, ...made.cuts, ...(limbs?.parts.keys() ?? [])]);
|
|
1827
|
+
const seenNames = new Map<string, number>();
|
|
1828
|
+
(v as Json[]).forEach((entry, i) => {
|
|
1829
|
+
const at = `${p}[${i}]`;
|
|
1830
|
+
const e = c.object(at, entry, ['name'], ['source', 'full', 'head', 'parts', 'hides', 'head_box', 'cuts']);
|
|
1831
|
+
if (e === null) return;
|
|
1832
|
+
if ('name' in e && c.string(`${at}.name`, e.name)) {
|
|
1833
|
+
if (!partNameOk(e.name)) c.fail('CONFIG_PART_NAME', `${at}.name`, `names the set ${show(e.name)}; a set name is a file prefix, so it may not be empty, start with "." or hold a slash`);
|
|
1834
|
+
if (seenNames.has(e.name)) c.fail('CONFIG_PART_UNIQUE', `${at}.name`, `names the set "${e.name}" again (first at alternates[${seenNames.get(e.name)}])`);
|
|
1835
|
+
else seenNames.set(e.name, i);
|
|
1836
|
+
}
|
|
1837
|
+
for (const k of ['source', 'full', 'head'] as const) {
|
|
1838
|
+
if (k in e) c.string(`${at}.${k}`, e[k]);
|
|
1839
|
+
}
|
|
1840
|
+
if ('head_box' in e) checkHeadBox(c, `${at}.head_box`, e.head_box);
|
|
1841
|
+
const altCuts = 'cuts' in e ? checkCuts(c, e.cuts, made.parts, [], `${at}.cuts`) : [];
|
|
1842
|
+
const validParts = new Set<string>([...allSlots, ...altCuts]);
|
|
1843
|
+
if ('parts' in e && c.array(`${at}.parts`, e.parts, true)) {
|
|
1844
|
+
const seen = new Set<string>();
|
|
1845
|
+
(e.parts as Json[]).forEach((pn, k) => {
|
|
1846
|
+
if (!c.string(`${at}.parts[${k}]`, pn)) return;
|
|
1847
|
+
if (!validParts.has(pn)) {
|
|
1848
|
+
c.fail('CONFIG_NAME_RESOLVES', `${at}.parts[${k}]`, `is ${show(pn)}; a slot of the base rig (or a part made by this alternate's cuts) is required`);
|
|
1849
|
+
} else if (seen.has(pn)) {
|
|
1850
|
+
c.fail('CONFIG_PART_UNIQUE', `${at}.parts[${k}]`, `repeats "${pn}"; each covered part is named once`);
|
|
1851
|
+
} else seen.add(pn);
|
|
1852
|
+
});
|
|
1853
|
+
}
|
|
1854
|
+
if ('hides' in e && c.array(`${at}.hides`, e.hides, false)) {
|
|
1855
|
+
const seen = new Set<string>();
|
|
1856
|
+
(e.hides as Json[]).forEach((hn, k) => {
|
|
1857
|
+
if (!c.string(`${at}.hides[${k}]`, hn)) return;
|
|
1858
|
+
if (!allSlots.has(hn)) {
|
|
1859
|
+
c.fail('CONFIG_NAME_RESOLVES', `${at}.hides[${k}]`, `is ${show(hn)}; a slot of the base rig is required`);
|
|
1860
|
+
} else if (seen.has(hn)) {
|
|
1861
|
+
c.fail('CONFIG_PART_UNIQUE', `${at}.hides[${k}]`, `repeats "${hn}"; each hidden slot is named once`);
|
|
1862
|
+
} else seen.add(hn);
|
|
1863
|
+
});
|
|
1864
|
+
}
|
|
1865
|
+
});
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1799
1868
|
/**
|
|
1800
1869
|
* `assemble.patches`: each entry `{name, box, alpha, draw}`, refused field by
|
|
1801
1870
|
* field. What the config alone cannot know — whether the box lies inside the
|