@rig-c/parts 1.13.1 → 1.15.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 +14 -8
- package/docs/AUTHORING.md +139 -34
- package/docs/MIGRATION.md +62 -0
- package/docs/STABILITY.md +4 -4
- package/package.json +1 -1
- package/src/build.ts +76 -17
- package/src/check.ts +496 -80
- package/src/config.ts +7 -1
- package/src/contour.ts +386 -14
- package/src/instruments.ts +6 -5
- package/src/meshdraft.ts +130 -9
- package/src/requirements.ts +68 -5
- package/src/rig.ts +4 -4
- package/src/scene.ts +2 -2
package/src/meshdraft.ts
CHANGED
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
* - the interior grid at spacing S = round(H / {@link FIGURE_GRID_DIVISOR});
|
|
14
14
|
* - dust left out: an extra 4-connected island of the art (alpha above `ART_ALPHA`) of at most {@link DUST_MAX_PX} px
|
|
15
15
|
* and at most {@link DUST_MAX_SHARE} of the part's art is dust, and a `stray` equal to the largest such island leaves
|
|
16
|
-
* it out. A part with any other extra island holds a real piece
|
|
16
|
+
* it out. A part with any other extra island holds a real piece ({@link dustOf}): issue #326 drafts it per island,
|
|
17
|
+
* `bridges: true`, only when every island is carried by the same bones ({@link islandsJoin}: every vertex of the
|
|
18
|
+
* part's lattice bound to one bone, the same one); otherwise it keeps its lattice, and the note names the bones;
|
|
17
19
|
* - `r`, `segments`, `exponent`, `rule` and `influences` as the lattice entry states them.
|
|
18
20
|
*
|
|
19
21
|
* The constants are the ones issue #265's stages measured (Stage A passed every bar at c = 37 on three examples; T 2
|
|
@@ -68,6 +70,7 @@ import { BARS, type CheckReport, type PackShape, type PageEdges, type RigcRunner
|
|
|
68
70
|
import { type CharacterConfig, type ContourMeshSpec, type LatticeMeshSpec, loadConfigAndAnimations, type Segment } from './config.ts';
|
|
69
71
|
import { PartsError, type Problem, problemLine } from './errors.ts';
|
|
70
72
|
import { IDLE_FPS } from './motion.ts';
|
|
73
|
+
import { type ContourBridges, FAINT_ALPHA } from './contour.ts';
|
|
71
74
|
import { ART_ALPHA, DUST_MAX_PX, DUST_MAX_SHARE, isDust } from './mesh.ts';
|
|
72
75
|
import { type PartsFile, readParts } from './parts.ts';
|
|
73
76
|
import { alphaAbove, connectedComponents } from './raster/index.ts';
|
|
@@ -219,10 +222,13 @@ export function dustOf(image: Raster): DustReading {
|
|
|
219
222
|
return { stray: extra.length === 0 ? undefined : extra[0], islands, dust: extra };
|
|
220
223
|
}
|
|
221
224
|
|
|
222
|
-
/**
|
|
223
|
-
|
|
225
|
+
/**
|
|
226
|
+
* The candidate for one lattice entry: the outline mesh at the figure's numbers, its weights' fields as the lattice
|
|
227
|
+
* states them. `bridges` (issue #326): every real island meshed and joined by bridges ({@link islandsJoin}).
|
|
228
|
+
*/
|
|
229
|
+
export function candidateMesh(lattice: LatticeMeshSpec, rules: Pick<FigureRules, 'tolerance' | 'spacing'>, stray: number | undefined, bridges = false): ContourMeshSpec {
|
|
224
230
|
return {
|
|
225
|
-
contour: { tolerance: rules.tolerance, margin: rules.tolerance, spacing: rules.spacing, maxEdge: rules.spacing, ...(stray === undefined ? {} : { stray }) },
|
|
231
|
+
contour: { tolerance: rules.tolerance, margin: rules.tolerance, spacing: rules.spacing, maxEdge: rules.spacing, ...(stray === undefined ? {} : { stray }), ...(bridges ? { bridges: true as const } : {}) },
|
|
226
232
|
r: lattice.r,
|
|
227
233
|
segments: lattice.segments,
|
|
228
234
|
...(lattice.exponent === undefined ? {} : { exponent: lattice.exponent }),
|
|
@@ -231,6 +237,52 @@ export function candidateMesh(lattice: LatticeMeshSpec, rules: Pick<FigureRules,
|
|
|
231
237
|
};
|
|
232
238
|
}
|
|
233
239
|
|
|
240
|
+
/** What the same-bones test reads off a part's lattice as the rig stage weighted it (issue #326). */
|
|
241
|
+
export interface CarriedBy {
|
|
242
|
+
/** Every bone a vertex is bound to, sorted. */
|
|
243
|
+
bones: string[];
|
|
244
|
+
/** The lattice's vertices, and how many are bound to more than one bone. */
|
|
245
|
+
vertices: number;
|
|
246
|
+
blended: number;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** The bones a mesh attachment's vertices are bound to ({@link CarriedBy}). */
|
|
250
|
+
export function carriedBy(att: Pick<MeshAttachment, 'weights'>): CarriedBy {
|
|
251
|
+
const bones = new Set<string>();
|
|
252
|
+
let blended = 0;
|
|
253
|
+
for (const w of att.weights) {
|
|
254
|
+
for (const e of w) bones.add(e.bone);
|
|
255
|
+
if (w.length > 1) blended++;
|
|
256
|
+
}
|
|
257
|
+
return { bones: [...bones].sort(), vertices: att.weights.length, blended };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Issue #326 (HQ's ruling on Stage A): a part whose art holds a second real island is drafted per island, joined by
|
|
262
|
+
* bridges, **only when every island is carried by the same bones**. The test, read off the part's lattice as the rig
|
|
263
|
+
* stage weighted it ({@link carriedBy}): every vertex is bound to one bone, and it is the same bone for every
|
|
264
|
+
* vertex. Then every point of the part — each island and every bridge between them — moves with that one bone, so no
|
|
265
|
+
* island moves against another and no bridge stretches. Anything else keeps the lattice, and the sentence says which
|
|
266
|
+
* bones; no two-bone join is drafted, measured or not (the ruling: nothing measured needs it).
|
|
267
|
+
*
|
|
268
|
+
* `islands` is {@link dustOf}'s reading of the same image. Returns the `stray` the candidate declares (the largest
|
|
269
|
+
* dust island: every real island is larger than any dust island on one part, since dust is at most
|
|
270
|
+
* {@link DUST_MAX_PX} px and a share of at most {@link DUST_MAX_SHARE}) and the real islands' px; or why the lattice
|
|
271
|
+
* is kept. A reading that is not a real piece or a tie for the largest — no art, or one island — is not this path's.
|
|
272
|
+
*/
|
|
273
|
+
export function islandsJoin(islands: readonly number[], lattice: Pick<MeshAttachment, 'weights'>): { stray: number | undefined; real: number[]; bone: string } | { keep: string } | null {
|
|
274
|
+
if (islands.length < 2) return null;
|
|
275
|
+
const art = islands.reduce((a, b) => a + b, 0);
|
|
276
|
+
const real = islands.filter((n, i) => i === 0 || n === islands[0] || !isDust(n, art));
|
|
277
|
+
const dust = islands.filter((n, i) => !(i === 0 || n === islands[0] || !isDust(n, art)));
|
|
278
|
+
if (real.length < 2) return null;
|
|
279
|
+
const c = carriedBy(lattice);
|
|
280
|
+
if (c.bones.length === 1 && c.blended === 0) return { stray: dust.length === 0 ? undefined : dust[0], real, bone: c.bones[0] };
|
|
281
|
+
return {
|
|
282
|
+
keep: `its ${real.length} real islands are carried by different bones — the lattice's ${c.vertices} vertices are bound to ${c.bones.map((b) => `"${b}"`).join(', ')}${c.blended > 0 ? `, ${c.blended} of them to more than one` : ''} — and mesh-draft joins islands by bridges only when every vertex is bound to one and the same bone, so that no island moves against another and no bridge between them stretches (issue #326)`,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
234
286
|
/** The dense reference's lattice cell, px: Stage C's sample stride, the least whole number keeping a part's art at or under {@link DENSE_MAX_SAMPLES} samples. */
|
|
235
287
|
export function denseGrid(artPx: number): number {
|
|
236
288
|
return Math.max(1, Math.ceil(Math.sqrt(artPx / DENSE_MAX_SAMPLES)));
|
|
@@ -546,13 +598,30 @@ export interface MeshFigure {
|
|
|
546
598
|
overdraw: number;
|
|
547
599
|
}
|
|
548
600
|
|
|
601
|
+
/** A bridged candidate's bridges (issue #326), off the rig stage's mesh report: the silhouettes, each join, and their area over the part's art. */
|
|
602
|
+
export interface BridgeFigure {
|
|
603
|
+
/** The grown silhouettes, numbered by art px: each one's islands' art px. */
|
|
604
|
+
units: number[][];
|
|
605
|
+
joins: Array<{ span: [number, number]; area_px2: number }>;
|
|
606
|
+
/** Σ of the joins' area, px². */
|
|
607
|
+
area_px2: number;
|
|
608
|
+
/** That area over the part's art px: the share of the candidate's overdraw the bridges draw (4 decimals). */
|
|
609
|
+
overdraw: number;
|
|
610
|
+
/**
|
|
611
|
+
* Only when one was left out (HQ's ruling on #326): each faint island — every pixel's alpha under `FAINT_ALPHA`
|
|
612
|
+
* (`src/contour.ts`) — with its art px, its brightest pixel's alpha and its box in part-image px. Not meshed, so not
|
|
613
|
+
* drawn.
|
|
614
|
+
*/
|
|
615
|
+
faint?: Array<{ art_px: number; max_alpha: number; box: [number, number, number, number] }>;
|
|
616
|
+
}
|
|
617
|
+
|
|
549
618
|
export interface PartRow {
|
|
550
619
|
part: string;
|
|
551
620
|
/** What the written config holds for the part. */
|
|
552
621
|
mode: 'contour' | 'grid';
|
|
553
622
|
lattice: MeshFigure & { grid: number; art_px: number };
|
|
554
623
|
/** Null when no candidate was drafted for it (a real piece, or the stage refused it before any reading). */
|
|
555
|
-
candidate: (MeshFigure & { mesh: ContourMeshSpec }) | null;
|
|
624
|
+
candidate: (MeshFigure & { mesh: ContourMeshSpec; bridges?: BridgeFigure }) | null;
|
|
556
625
|
readings: PartReadings | null;
|
|
557
626
|
gate: GateLine[];
|
|
558
627
|
note: string;
|
|
@@ -566,6 +635,23 @@ export interface MeshDraftResult {
|
|
|
566
635
|
written: string[];
|
|
567
636
|
}
|
|
568
637
|
|
|
638
|
+
/** A bridged part's {@link BridgeFigure} off `<rigDir>/mesh_report.json`, or null when the report holds none for it. */
|
|
639
|
+
export function bridgesOf(rigDir: string, part: string, art: number): BridgeFigure | null {
|
|
640
|
+
const path = join(rigDir, 'mesh_report.json');
|
|
641
|
+
if (!existsSync(path)) return null;
|
|
642
|
+
const rows = JSON.parse(readFileSync(path, 'utf8')) as Array<{ part: string; contour?: { bridges?: ContourBridges } }>;
|
|
643
|
+
const b = rows.find((r) => r.part === part)?.contour?.bridges;
|
|
644
|
+
if (b === undefined) return null;
|
|
645
|
+
const area = b.joins.reduce((t, j) => t + j.area, 0);
|
|
646
|
+
return {
|
|
647
|
+
units: b.units,
|
|
648
|
+
joins: b.joins.map((j) => ({ span: j.span, area_px2: j.area })),
|
|
649
|
+
area_px2: area,
|
|
650
|
+
overdraw: art === 0 ? 0 : pyRound(area / art, 4),
|
|
651
|
+
...(b.faint === undefined ? {} : { faint: b.faint.map((f) => ({ art_px: f.art, max_alpha: f.maxAlpha, box: f.box })) }),
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
|
|
569
655
|
/** The mesh's area over the art's: the triangles' area in image px over the image's art px. */
|
|
570
656
|
export function overdrawOf(att: MeshAttachment, art: number): number {
|
|
571
657
|
let twice = 0;
|
|
@@ -660,6 +746,7 @@ export function meshDraftStage(input: MeshDraftInput, rigc: RigcRunner, checkBin
|
|
|
660
746
|
// 2. Candidates for every lattice entry; a real piece keeps the lattice.
|
|
661
747
|
const rows = new Map<string, PartRow>();
|
|
662
748
|
const candidates = new Map<string, ContourMeshSpec>();
|
|
749
|
+
const joined = new Map<string, { stray: number | undefined; real: number[]; bone: string }>();
|
|
663
750
|
for (const [part, spec] of Object.entries(cfg.meshes)) {
|
|
664
751
|
if (!('grid' in spec)) continue;
|
|
665
752
|
const img = images.get(part);
|
|
@@ -673,16 +760,45 @@ export function meshDraftStage(input: MeshDraftInput, rigc: RigcRunner, checkBin
|
|
|
673
760
|
}
|
|
674
761
|
const dust = dustOf(img);
|
|
675
762
|
if ('keep' in dust) {
|
|
676
|
-
|
|
763
|
+
// Issue #326: islands carried by one bone are drafted per island and joined by bridges.
|
|
764
|
+
const join = islandsJoin(dust.islands, att);
|
|
765
|
+
if (join === null || 'keep' in join) {
|
|
766
|
+
row.note = `keeps its lattice: ${dust.keep}${join === null ? '' : `; ${join.keep}`}`;
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
joined.set(part, join);
|
|
770
|
+
candidates.set(part, candidateMesh(spec, rules, join.stray, true));
|
|
677
771
|
continue;
|
|
678
772
|
}
|
|
679
773
|
candidates.set(part, candidateMesh(spec, rules, dust.stray));
|
|
680
774
|
}
|
|
681
775
|
const dustText = (part: string): string => {
|
|
776
|
+
const j = joined.get(part);
|
|
777
|
+
if (j !== undefined) {
|
|
778
|
+
const img = images.get(part) as Raster;
|
|
779
|
+
const dust = (dustOf(img) as { islands: number[] }).islands.slice(j.real.length);
|
|
780
|
+
return `${dust.length === 0 ? 'no dust' : `stray ${j.stray as number} leaves out ${dust.length} dust island(s) of ${dust.join(', ')} px`}; ${bridgeText(row(part))}`;
|
|
781
|
+
}
|
|
682
782
|
const img = images.get(part);
|
|
683
783
|
const d = img === undefined ? null : dustOf(img);
|
|
684
784
|
return d === null || 'keep' in d || d.dust.length === 0 ? 'no dust' : `stray ${d.stray as number} leaves out ${d.dust.length} dust island(s) of ${d.dust.join(', ')} px`;
|
|
685
785
|
};
|
|
786
|
+
const row = (part: string): PartRow => rows.get(part) as PartRow;
|
|
787
|
+
const faintText = (faint: NonNullable<BridgeFigure['faint']>): string =>
|
|
788
|
+
`LEFT OUT as faint, every pixel under alpha ${FAINT_ALPHA}: ${faint.map((f) => `${f.art_px} px (brightest alpha ${f.max_alpha}, at part-image px ${f.box[0]},${f.box[1]} ${f.box[2]}x${f.box[3]})`).join('; ')} — not meshed, so not drawn; if those pixels matter to you, keep the lattice for this part`;
|
|
789
|
+
const bridgeText = (r: PartRow): string => {
|
|
790
|
+
const j = joined.get(r.part) as { real: number[]; bone: string };
|
|
791
|
+
const b = r.candidate?.bridges;
|
|
792
|
+
const joins =
|
|
793
|
+
b === undefined
|
|
794
|
+
? 'its bridges were not read'
|
|
795
|
+
: b.joins.length === 0
|
|
796
|
+
? 'no bridge'
|
|
797
|
+
: `${b.joins.length} bridge(s) of ${b.joins.map((x) => `${x.span[0]} and ${x.span[1]} px`).join('; ')} span, ${b.area_px2} px² over no art (overdraw ${b.overdraw} of the candidate's ${r.candidate?.overdraw}; overdraw is reported, never a reason to leave art out)`;
|
|
798
|
+
const faint = b?.faint ?? [];
|
|
799
|
+
const visible = j.real.length - faint.length;
|
|
800
|
+
return `its ${j.real.length} real islands (${j.real.join(', ')} px)${faint.length === 0 ? '' : `: ${faintText(faint)}; the ${visible} visible`} traced as ${b === undefined ? 'its' : b.units.length} grown silhouette(s) and joined into one outline by ${joins}, every vertex bound to the one bone "${j.bone}" (issue #326)`;
|
|
801
|
+
};
|
|
686
802
|
|
|
687
803
|
// 3. The drafted rig must build: a refusal laid on a part keeps that part's lattice.
|
|
688
804
|
const keep = (part: string, reading: Reading, text: string): void => {
|
|
@@ -784,6 +900,10 @@ export function meshDraftStage(input: MeshDraftInput, rigc: RigcRunner, checkBin
|
|
|
784
900
|
const att = attachmentOf(draftRig, part);
|
|
785
901
|
const img = images.get(part) as Raster;
|
|
786
902
|
row.candidate = { triangles: att.triangles.length / 3, overdraw: overdrawOf(att, artPixels(img)), mesh: candidates.get(part) as ContourMeshSpec };
|
|
903
|
+
if (joined.has(part)) {
|
|
904
|
+
const b = bridgesOf(draftedRig.rigDir, part, artPixels(img));
|
|
905
|
+
if (b !== null) row.candidate.bridges = b;
|
|
906
|
+
}
|
|
787
907
|
const readings: PartReadings = { bars: null, idle: idleReading(idle, part), bend: read === null || typeof read === 'string' ? read : { links, ...read }, triangles: { lattice: row.lattice.triangles, candidate: row.candidate.triangles } };
|
|
788
908
|
row.readings = readings;
|
|
789
909
|
const v = gateVerdict({ ...readings, bars: [] }, input.moreTriangles === true);
|
|
@@ -834,7 +954,8 @@ export function meshDraftStage(input: MeshDraftInput, rigc: RigcRunner, checkBin
|
|
|
834
954
|
drafted.set(row.part, { ...cand, note: row.note });
|
|
835
955
|
} else if (row.note === '') {
|
|
836
956
|
const failed = row.gate.filter((x) => !x.pass);
|
|
837
|
-
|
|
957
|
+
const faint = row.candidate?.bridges?.faint ?? [];
|
|
958
|
+
row.note = `keeps its lattice at grid ${row.lattice.grid}: ${failed.length === 0 ? 'no candidate was read' : failed.map((x) => `${x.reading} — ${x.text}`).join('; ')}${faint.length === 0 ? '' : `; on the bridged candidate those readings read: ${faintText(faint)}`}`;
|
|
838
959
|
}
|
|
839
960
|
}
|
|
840
961
|
const configText = splicedConfig(text, drafted);
|
|
@@ -857,7 +978,7 @@ export function meshDraftStage(input: MeshDraftInput, rigc: RigcRunner, checkBin
|
|
|
857
978
|
part: r.part,
|
|
858
979
|
mode: r.mode,
|
|
859
980
|
lattice: r.lattice,
|
|
860
|
-
candidate: r.candidate === null ? null : { triangles: r.candidate.triangles, overdraw: r.candidate.overdraw },
|
|
981
|
+
candidate: r.candidate === null ? null : { triangles: r.candidate.triangles, overdraw: r.candidate.overdraw, ...(r.candidate.bridges === undefined ? {} : { bridges: r.candidate.bridges }) },
|
|
861
982
|
// Under --more-triangles only: what accepting a costlier candidate trades, triangles against overdraw.
|
|
862
983
|
...(input.moreTriangles === true && r.candidate !== null ? { delta: { triangles: r.candidate.triangles - r.lattice.triangles, overdraw: pyRound(r.candidate.overdraw - r.lattice.overdraw, 4) } } : {}),
|
|
863
984
|
readings: r.readings,
|
|
@@ -873,7 +994,7 @@ export function meshDraftStage(input: MeshDraftInput, rigc: RigcRunner, checkBin
|
|
|
873
994
|
writeFileSync(written[0], configText);
|
|
874
995
|
writeFileSync(written[1], `${JSON.stringify(report, null, 2)}\n`);
|
|
875
996
|
for (const r of rowsOut) {
|
|
876
|
-
const cand = r.candidate === null ? '' : `; candidate ${r.candidate.triangles} triangle(s), overdraw ${r.candidate.overdraw}`;
|
|
997
|
+
const cand = r.candidate === null ? '' : `; candidate ${r.candidate.triangles} triangle(s), overdraw ${r.candidate.overdraw}${r.candidate.bridges === undefined ? '' : ` (${r.candidate.bridges.joins.length} bridge(s), ${r.candidate.bridges.area_px2} px² over no art: overdraw ${r.candidate.bridges.overdraw} of it${(r.candidate.bridges.faint ?? []).map((f) => `; LEFT OUT faint ${f.art_px} px, brightest alpha ${f.max_alpha} < ${FAINT_ALPHA}`).join('')})`}`;
|
|
877
998
|
log(` ${r.mode === 'contour' ? 'DRAFTED' : 'LATTICE'} ${r.part}: lattice grid ${r.lattice.grid} ${r.lattice.triangles} triangle(s), overdraw ${r.lattice.overdraw}${cand}`);
|
|
878
999
|
for (const g of r.gate) log(` ${g.pass ? 'pass' : 'FAIL'} ${g.reading}: ${g.text}`);
|
|
879
1000
|
if (r.mode === 'grid' && r.gate.length === 0) log(` ${r.note}`);
|
package/src/requirements.ts
CHANGED
|
@@ -179,6 +179,24 @@ export interface RequirementsFile {
|
|
|
179
179
|
* Empty when not declared: every swinging part's tip swings.
|
|
180
180
|
*/
|
|
181
181
|
rests: string[];
|
|
182
|
+
/**
|
|
183
|
+
* Issue #320: the folds the scene means — a slot whose mesh turns a triangle
|
|
184
|
+
* over in an animation, the idle included (issue #333), on purpose (a sleeve
|
|
185
|
+
* hidden behind the body as the arm crosses it), each by slot and animation
|
|
186
|
+
* with the author's why.
|
|
187
|
+
* `ANIMATIONS_DO_NOT_FOLD` names each such fold with its why and does not
|
|
188
|
+
* hold it to the bar, and fails an entry whose slot does not fold in that
|
|
189
|
+
* animation, as rig-c refuses an exemption that exempts nothing. Empty when
|
|
190
|
+
* not declared: no fold in a named animation is meant.
|
|
191
|
+
*/
|
|
192
|
+
may_fold: MayFold[];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** One declared fold (issue #320): a slot, a named animation, and why the fold is the art's. */
|
|
196
|
+
export interface MayFold {
|
|
197
|
+
slot: string;
|
|
198
|
+
animation: string;
|
|
199
|
+
why: string;
|
|
182
200
|
}
|
|
183
201
|
|
|
184
202
|
/** What `check --requirements` and `build --requirements` name, said once for the help and the refusals. */
|
|
@@ -257,7 +275,7 @@ export function readRequirements(path: string): RequirementsFile {
|
|
|
257
275
|
problems.push({ code: 'REQUIREMENTS_FIELD', object, detail });
|
|
258
276
|
};
|
|
259
277
|
if (raw.spec !== REQUIREMENTS_SPEC) field(`${at} field "spec"`, `is ${JSON.stringify(raw.spec)}; ${JSON.stringify(REQUIREMENTS_SPEC)} is required`);
|
|
260
|
-
for (const k of Object.keys(raw)) if (!['spec', 'fps', 'targets', 'requirements', ...EXCEPTION_LISTS].includes(k) && !freeKey(k, raw[k])) field(`${at} field "${k}"`, 'is not a field this format reads (spec, fps, targets, requirements, not_planted, not_swinging, rises, rests; note or *_note as a string; x-<name> as a record)');
|
|
278
|
+
for (const k of Object.keys(raw)) if (!['spec', 'fps', 'targets', 'requirements', ...EXCEPTION_LISTS, 'may_fold'].includes(k) && !freeKey(k, raw[k])) field(`${at} field "${k}"`, 'is not a field this format reads (spec, fps, targets, requirements, not_planted, not_swinging, rises, rests, may_fold; note or *_note as a string; x-<name> as a record)');
|
|
261
279
|
let fps = NaN;
|
|
262
280
|
if (!(finite(raw.fps) && raw.fps > 0)) field(`${at} field "fps"`, `is ${JSON.stringify(raw.fps)}; a positive number of frames per second is required — every named animation is sampled at it, and there is no default`);
|
|
263
281
|
else fps = raw.fps;
|
|
@@ -407,8 +425,31 @@ export function readRequirements(path: string): RequirementsFile {
|
|
|
407
425
|
lists.rests.forEach((n, i) => {
|
|
408
426
|
if (lists.not_swinging.includes(n)) field(`${at} field "rests"[${i}]`, `names "${n}", which "not_swinging" names too; a part declared not swinging is not measured, so there is no tip to hold at rest — leave it in one list`);
|
|
409
427
|
});
|
|
428
|
+
// Issue #320: may_fold, a non-empty list of { slot, animation, why }, each pair once; which slots and animations they are is read against the rig by resolveRequirements.
|
|
429
|
+
const mayFold: MayFold[] = [];
|
|
430
|
+
if (raw.may_fold !== undefined) {
|
|
431
|
+
if (!Array.isArray(raw.may_fold) || raw.may_fold.length === 0) field(`${at} field "may_fold"`, `is ${JSON.stringify(raw.may_fold)?.slice(0, 60)}; a non-empty list of { "slot", "animation", "why" } is required when the field is given — leave it out to declare that no fold in a named animation is meant`);
|
|
432
|
+
else {
|
|
433
|
+
raw.may_fold.forEach((e, i) => {
|
|
434
|
+
const where = `${at} field "may_fold"[${i}]`;
|
|
435
|
+
if (!isRecord(e)) {
|
|
436
|
+
field(where, `is ${JSON.stringify(e)?.slice(0, 40)}; { "slot": <name>, "animation": <name>, "why": <text> } is required`);
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
const before = problems.length;
|
|
440
|
+
for (const k of Object.keys(e)) if (!['slot', 'animation', 'why'].includes(k) && !freeKey(k, e[k])) field(`${where} field "${k}"`, 'is not a field a may_fold entry reads (slot, animation, why)');
|
|
441
|
+
for (const k of ['slot', 'animation', 'why'] as const) {
|
|
442
|
+
if (typeof e[k] !== 'string' || (e[k] as string).trim().length === 0) field(`${where} field "${k}"`, `is ${JSON.stringify(e[k])}; ${k === 'why' ? 'the reason the fold is the art\'s, in words, is required — an exception without one is a silenced bar' : `a ${k} name is required`}`);
|
|
443
|
+
}
|
|
444
|
+
if (problems.length !== before) return;
|
|
445
|
+
const entry: MayFold = { slot: e.slot as string, animation: e.animation as string, why: e.why as string };
|
|
446
|
+
if (mayFold.some((m) => m.slot === entry.slot && m.animation === entry.animation)) field(where, `declares slot "${entry.slot}" in animation "${entry.animation}" a second time; each pair is declared once`);
|
|
447
|
+
else mayFold.push(entry);
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
}
|
|
410
451
|
refuseIfAny(problems);
|
|
411
|
-
return { path: at, fps, targets, requirements, not_planted: lists.not_planted, not_swinging: lists.not_swinging, rises: lists.rises, rests: lists.rests };
|
|
452
|
+
return { path: at, fps, targets, requirements, not_planted: lists.not_planted, not_swinging: lists.not_swinging, rises: lists.rises, rests: lists.rests, may_fold: mayFold };
|
|
412
453
|
}
|
|
413
454
|
|
|
414
455
|
// ---------------------------------------------------------------------------
|
|
@@ -541,6 +582,21 @@ export function resolveRequirements(file: RequirementsFile, rig: Record<string,
|
|
|
541
582
|
}
|
|
542
583
|
}
|
|
543
584
|
|
|
585
|
+
// Issues #320, #333: each declared fold names a slot that holds a mesh and an animation motion.json declares — the idle
|
|
586
|
+
// included, since ANIMATIONS_DO_NOT_FOLD reads every animation.
|
|
587
|
+
file.may_fold.forEach((m, i) => {
|
|
588
|
+
const object = `may_fold[${i}] (slot "${m.slot}", animation "${m.animation}")`;
|
|
589
|
+
if (!(m.animation in anims)) unresolved(object, `names animation "${m.animation}", which motion.json does not declare; it declares ${listed(animNames)}`);
|
|
590
|
+
const slots = (Array.isArray(rig.slots) ? rig.slots : []).filter(isRecord).map((s) => s.name);
|
|
591
|
+
if (!slots.includes(m.slot)) {
|
|
592
|
+
unresolved(object, `names slot "${m.slot}", which rig.json does not declare`);
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
const meshes = meshAttachmentsOf(rig, m.slot);
|
|
596
|
+
if (typeof meshes === 'string') problems.push({ code: 'REQUIREMENTS_RESOLVES', object, detail: meshes });
|
|
597
|
+
else if (meshes.length === 0) problems.push({ code: 'REQUIREMENTS_MAY_FOLD', object, detail: `names slot "${m.slot}", which no skin in rig.json gives a mesh attachment; a region is one quad and cannot fold, so declaring it excepts nothing` });
|
|
598
|
+
});
|
|
599
|
+
|
|
544
600
|
const root = bones.find((b) => b.name === rootBone);
|
|
545
601
|
const measured = new Set(file.requirements.map((r) => r.animation));
|
|
546
602
|
const placed = new Set<string>();
|
|
@@ -1009,8 +1065,14 @@ export function followLine(r: FollowRequirement, declared: Poses, released: Pose
|
|
|
1009
1065
|
};
|
|
1010
1066
|
}
|
|
1011
1067
|
|
|
1012
|
-
/**
|
|
1013
|
-
|
|
1068
|
+
/**
|
|
1069
|
+
* `stretch`: `TEXTURE_STRETCH`'s per-mesh figure for one mesh over the named animation's frames — its worst
|
|
1070
|
+
* max(ratio, 1/ratio) and where. With `heldBy`, the line also carries `held_by` (issue #321): what the worst edge's two
|
|
1071
|
+
* vertices hang on. `check` passes `edgeBonesText` (`src/check.ts`), the function `CHECK_TEXTURE_STRETCH` names them
|
|
1072
|
+
* with since #314, so the two lines cannot drift; it is handed in rather than imported because `src/check.ts` imports
|
|
1073
|
+
* this module.
|
|
1074
|
+
*/
|
|
1075
|
+
export function stretchRequirementLine(r: StretchRequirement, meshes: readonly MeshRest[], frames: readonly GeometryPose[], p: Poses, heldBy?: (edge: readonly number[]) => string): RequirementLine {
|
|
1014
1076
|
const mesh = meshes.find((m) => m.slot === r.slot && m.attachment === r.attachment);
|
|
1015
1077
|
if (mesh === undefined) return notMeasurable('stretch', r.animation, `geometry.json's rest pose holds no mesh "${r.attachment}" on slot "${r.slot}", so no frame shows it`);
|
|
1016
1078
|
const [m] = stretchFigures([mesh], frames);
|
|
@@ -1032,6 +1094,7 @@ export function stretchRequirementLine(r: StretchRequirement, meshes: readonly M
|
|
|
1032
1094
|
triangles: m.triangles,
|
|
1033
1095
|
severity: round6(severity),
|
|
1034
1096
|
worst: `triangle ${at.triangle} (vertices ${at.vertices.join(' ')}), edge ${at.edge.join('-')}, ratio ${round6(at.ratio)}`,
|
|
1097
|
+
...(heldBy === undefined ? {} : { held_by: heldBy(at.edge) }),
|
|
1035
1098
|
at: fi < 0 ? `frame ${at.frame}` : frameText(p, fi),
|
|
1036
1099
|
max_ratio: round6(m.max.ratio),
|
|
1037
1100
|
min_ratio: round6(m.min.ratio),
|
|
@@ -1274,7 +1337,7 @@ function failText(r: Requirement, l: RequirementLine): string {
|
|
|
1274
1337
|
if (r.kind === 'contact') return `largest distance ${String(l.largest_px)} px at ${String(l.at)}, between ${String(l.of)} and ${String(l.against)}`;
|
|
1275
1338
|
if (r.kind === 'aim') return `largest angle ${String(l.largest_degrees)} degrees at ${String(l.at)}, between ${String(l.of)} and the line to ${String(l.against)}`;
|
|
1276
1339
|
if (r.kind === 'range') return `least ${String(l.least_degrees)} degrees at ${String(l.least_at)}, greatest ${String(l.greatest_degrees)} at ${String(l.greatest_at)}`;
|
|
1277
|
-
if (r.kind === 'stretch') return `max(ratio, 1/ratio) ${String(l.severity)} at ${String(l.at)}, ${String(l.worst)}`;
|
|
1340
|
+
if (r.kind === 'stretch') return `max(ratio, 1/ratio) ${String(l.severity)} at ${String(l.at)}, ${String(l.worst)}${typeof l.held_by === 'string' ? `; ${l.held_by}` : ''}`;
|
|
1278
1341
|
if (r.kind === 'seam') return `largest opening ${String(l.largest_px)} px at ${String(l.at)}, ${String(l.pair)}`;
|
|
1279
1342
|
return `measured fraction ${String(l.fraction_measured)} over ${String(l.frames_counted)} frame(s) that reach the least drive`;
|
|
1280
1343
|
}
|
package/src/rig.ts
CHANGED
|
@@ -682,7 +682,7 @@ export interface ContourMeshReport {
|
|
|
682
682
|
/** Only when the mesh declared ribs (issue #188): what it declared and what was placed. Absent otherwise, so a mesh without ribs writes the row it always wrote. */
|
|
683
683
|
ribs?: RibsRow;
|
|
684
684
|
/** `maxEdge` only when the mesh declared it (issue #265), so a mesh without it writes the row it always wrote. */
|
|
685
|
-
params: { tolerance: number; margin: number; spacing: number; maxEdge?: number; budget: number | null; stray: number | null };
|
|
685
|
+
params: { tolerance: number; margin: number; spacing: number; maxEdge?: number; budget: number | null; stray: number | null; bridges?: true };
|
|
686
686
|
contour: ContourReport;
|
|
687
687
|
regions: Array<{ name: string; bone: string; reached: number; whole: number }>;
|
|
688
688
|
/** Only under `rule: "heat"` (issue #161), as the lattice row's. */
|
|
@@ -1381,7 +1381,7 @@ export function buildRig(
|
|
|
1381
1381
|
: { name: rg.name, shape: 'polygon', points: rg.points.map(([x, y]) => [x - ox, y - oy] as [number, number]), spacing: rg.spacing, band: rg.band },
|
|
1382
1382
|
);
|
|
1383
1383
|
const ribs = chainRibs(ribSpec.segments, ribSpec.ribs, ox, oy);
|
|
1384
|
-
const cm = contourMesh(p.name, { width: w, height: h, alpha }, { threshold: ART_ALPHA, tolerance: spec.tolerance, margin: spec.margin, spacing: spec.spacing, ...(spec.maxEdge === undefined ? {} : { maxEdge: spec.maxEdge }), budget: spec.budget, stray: spec.stray, regions: inPart, ...(ribs.length === 0 ? {} : { ribs }) });
|
|
1384
|
+
const cm = contourMesh(p.name, { width: w, height: h, alpha }, { threshold: ART_ALPHA, tolerance: spec.tolerance, margin: spec.margin, spacing: spec.spacing, ...(spec.maxEdge === undefined ? {} : { maxEdge: spec.maxEdge }), budget: spec.budget, stray: spec.stray, ...(spec.bridges === undefined ? {} : { bridges: spec.bridges }), regions: inPart, ...(ribs.length === 0 ? {} : { ribs }) });
|
|
1385
1385
|
if (Array.isArray(cm)) {
|
|
1386
1386
|
out.push(...cm);
|
|
1387
1387
|
return null;
|
|
@@ -1455,12 +1455,12 @@ export function buildRig(
|
|
|
1455
1455
|
bones: [...bones].sort(),
|
|
1456
1456
|
max_influences: maxInfl,
|
|
1457
1457
|
mean_influences: pyRound(infl / cm.vertices.length, 2),
|
|
1458
|
-
art_coverage: pyRound(rep.coveredArtPixels / (rep.artPixels + rep.strayPixels), 5),
|
|
1458
|
+
art_coverage: pyRound(rep.coveredArtPixels / (rep.artPixels + rep.strayPixels + (rep.bridges?.faint ?? []).reduce((t, f) => t + f.art, 0)), 5),
|
|
1459
1459
|
mode: 'contour',
|
|
1460
1460
|
...(exponent === undefined ? {} : { exponent }),
|
|
1461
1461
|
...(declared === undefined ? {} : { influences: declared }),
|
|
1462
1462
|
...(ribSpec.ribs === undefined ? {} : { ribs: ribsRow(ribSpec.ribs, cm.ribs ?? []) }),
|
|
1463
|
-
params: { tolerance: spec.tolerance, margin: spec.margin, spacing: spec.spacing, ...(spec.maxEdge === undefined ? {} : { maxEdge: spec.maxEdge }), budget: spec.budget ?? null, stray: spec.stray ?? null },
|
|
1463
|
+
params: { tolerance: spec.tolerance, margin: spec.margin, spacing: spec.spacing, ...(spec.maxEdge === undefined ? {} : { maxEdge: spec.maxEdge }), budget: spec.budget ?? null, stray: spec.stray ?? null, ...(spec.bridges === undefined ? {} : { bridges: spec.bridges }) },
|
|
1464
1464
|
contour: rep,
|
|
1465
1465
|
regions: regions.map((rg, k) => ({ name: rg.name, bone: rg.bone, reached: reached[k], whole: whole[k] })),
|
|
1466
1466
|
...(heat === null ? {} : { heat: heatRow(heat) }),
|
package/src/scene.ts
CHANGED
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
93
93
|
import { dirname, join, resolve } from 'node:path';
|
|
94
94
|
import { checkStage, gateThroughRigc, type Log } from './build.ts';
|
|
95
|
-
import { ACCEPTABLE_BARS,
|
|
95
|
+
import { ACCEPTABLE_BARS, barsNotMet, type CheckReport, DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, type PackMode, requirementsNotPass, type RigcRunner } from './check.ts';
|
|
96
96
|
import { CONSTRAINT_BONE_FIELDS, ROOT_BONE } from './config.ts';
|
|
97
97
|
import { cropToSpineY } from './coords.ts';
|
|
98
98
|
import { PartsError, type Problem, problemLine, refuseIfAny } from './errors.ts';
|
|
@@ -1421,7 +1421,7 @@ export function composeStage(input: ComposeInput, run: ComposeRunners, log: Log)
|
|
|
1421
1421
|
log(`compose: stopped at check; the composed rig's check is PASS${acceptedLine} (the composed rig and scene.json are written; the scene is for looking, not shipping)`);
|
|
1422
1422
|
return { stoppedAt: 'check', composed, check: report };
|
|
1423
1423
|
}
|
|
1424
|
-
log(`compose: PASS — ${report.bars.measured.length} of ${
|
|
1424
|
+
log(`compose: PASS — ${report.bars.measured.length} of ${report.bars.measured.length + report.bars.skipped.length} bar(s) measured; the packed atlas under ${join(input.out, 'check', 'build')} is the artifact`);
|
|
1425
1425
|
return { stoppedAt: null, composed, check: report };
|
|
1426
1426
|
}
|
|
1427
1427
|
|