@rig-c/parts 1.13.0 → 1.14.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 +7 -2
- package/docs/AUTHORING.md +134 -30
- package/docs/STABILITY.md +4 -4
- package/package.json +1 -1
- package/src/build.ts +68 -11
- package/src/check.ts +417 -77
- package/src/config.ts +7 -1
- package/src/contour.ts +350 -14
- package/src/instruments.ts +6 -5
- package/src/meshdraft.ts +107 -8
- package/src/requirements.ts +67 -5
- package/src/rig.ts +3 -3
- 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 } 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,24 @@ 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
|
+
|
|
549
612
|
export interface PartRow {
|
|
550
613
|
part: string;
|
|
551
614
|
/** What the written config holds for the part. */
|
|
552
615
|
mode: 'contour' | 'grid';
|
|
553
616
|
lattice: MeshFigure & { grid: number; art_px: number };
|
|
554
617
|
/** 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;
|
|
618
|
+
candidate: (MeshFigure & { mesh: ContourMeshSpec; bridges?: BridgeFigure }) | null;
|
|
556
619
|
readings: PartReadings | null;
|
|
557
620
|
gate: GateLine[];
|
|
558
621
|
note: string;
|
|
@@ -566,6 +629,17 @@ export interface MeshDraftResult {
|
|
|
566
629
|
written: string[];
|
|
567
630
|
}
|
|
568
631
|
|
|
632
|
+
/** A bridged part's {@link BridgeFigure} off `<rigDir>/mesh_report.json`, or null when the report holds none for it. */
|
|
633
|
+
export function bridgesOf(rigDir: string, part: string, art: number): BridgeFigure | null {
|
|
634
|
+
const path = join(rigDir, 'mesh_report.json');
|
|
635
|
+
if (!existsSync(path)) return null;
|
|
636
|
+
const rows = JSON.parse(readFileSync(path, 'utf8')) as Array<{ part: string; contour?: { bridges?: ContourBridges } }>;
|
|
637
|
+
const b = rows.find((r) => r.part === part)?.contour?.bridges;
|
|
638
|
+
if (b === undefined) return null;
|
|
639
|
+
const area = b.joins.reduce((t, j) => t + j.area, 0);
|
|
640
|
+
return { units: b.units, joins: b.joins.map((j) => ({ span: j.span, area_px2: j.area })), area_px2: area, overdraw: art === 0 ? 0 : pyRound(area / art, 4) };
|
|
641
|
+
}
|
|
642
|
+
|
|
569
643
|
/** The mesh's area over the art's: the triangles' area in image px over the image's art px. */
|
|
570
644
|
export function overdrawOf(att: MeshAttachment, art: number): number {
|
|
571
645
|
let twice = 0;
|
|
@@ -660,6 +734,7 @@ export function meshDraftStage(input: MeshDraftInput, rigc: RigcRunner, checkBin
|
|
|
660
734
|
// 2. Candidates for every lattice entry; a real piece keeps the lattice.
|
|
661
735
|
const rows = new Map<string, PartRow>();
|
|
662
736
|
const candidates = new Map<string, ContourMeshSpec>();
|
|
737
|
+
const joined = new Map<string, { stray: number | undefined; real: number[]; bone: string }>();
|
|
663
738
|
for (const [part, spec] of Object.entries(cfg.meshes)) {
|
|
664
739
|
if (!('grid' in spec)) continue;
|
|
665
740
|
const img = images.get(part);
|
|
@@ -673,16 +748,36 @@ export function meshDraftStage(input: MeshDraftInput, rigc: RigcRunner, checkBin
|
|
|
673
748
|
}
|
|
674
749
|
const dust = dustOf(img);
|
|
675
750
|
if ('keep' in dust) {
|
|
676
|
-
|
|
751
|
+
// Issue #326: islands carried by one bone are drafted per island and joined by bridges.
|
|
752
|
+
const join = islandsJoin(dust.islands, att);
|
|
753
|
+
if (join === null || 'keep' in join) {
|
|
754
|
+
row.note = `keeps its lattice: ${dust.keep}${join === null ? '' : `; ${join.keep}`}`;
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
joined.set(part, join);
|
|
758
|
+
candidates.set(part, candidateMesh(spec, rules, join.stray, true));
|
|
677
759
|
continue;
|
|
678
760
|
}
|
|
679
761
|
candidates.set(part, candidateMesh(spec, rules, dust.stray));
|
|
680
762
|
}
|
|
681
763
|
const dustText = (part: string): string => {
|
|
764
|
+
const j = joined.get(part);
|
|
765
|
+
if (j !== undefined) {
|
|
766
|
+
const img = images.get(part) as Raster;
|
|
767
|
+
const dust = (dustOf(img) as { islands: number[] }).islands.slice(j.real.length);
|
|
768
|
+
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))}`;
|
|
769
|
+
}
|
|
682
770
|
const img = images.get(part);
|
|
683
771
|
const d = img === undefined ? null : dustOf(img);
|
|
684
772
|
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
773
|
};
|
|
774
|
+
const row = (part: string): PartRow => rows.get(part) as PartRow;
|
|
775
|
+
const bridgeText = (r: PartRow): string => {
|
|
776
|
+
const j = joined.get(r.part) as { real: number[]; bone: string };
|
|
777
|
+
const b = r.candidate?.bridges;
|
|
778
|
+
const joins = b === undefined ? 'its bridges were not read' : `${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})`;
|
|
779
|
+
return `its ${j.real.length} real islands (${j.real.join(', ')} px) 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)`;
|
|
780
|
+
};
|
|
686
781
|
|
|
687
782
|
// 3. The drafted rig must build: a refusal laid on a part keeps that part's lattice.
|
|
688
783
|
const keep = (part: string, reading: Reading, text: string): void => {
|
|
@@ -784,6 +879,10 @@ export function meshDraftStage(input: MeshDraftInput, rigc: RigcRunner, checkBin
|
|
|
784
879
|
const att = attachmentOf(draftRig, part);
|
|
785
880
|
const img = images.get(part) as Raster;
|
|
786
881
|
row.candidate = { triangles: att.triangles.length / 3, overdraw: overdrawOf(att, artPixels(img)), mesh: candidates.get(part) as ContourMeshSpec };
|
|
882
|
+
if (joined.has(part)) {
|
|
883
|
+
const b = bridgesOf(draftedRig.rigDir, part, artPixels(img));
|
|
884
|
+
if (b !== null) row.candidate.bridges = b;
|
|
885
|
+
}
|
|
787
886
|
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
887
|
row.readings = readings;
|
|
789
888
|
const v = gateVerdict({ ...readings, bars: [] }, input.moreTriangles === true);
|
|
@@ -857,7 +956,7 @@ export function meshDraftStage(input: MeshDraftInput, rigc: RigcRunner, checkBin
|
|
|
857
956
|
part: r.part,
|
|
858
957
|
mode: r.mode,
|
|
859
958
|
lattice: r.lattice,
|
|
860
|
-
candidate: r.candidate === null ? null : { triangles: r.candidate.triangles, overdraw: r.candidate.overdraw },
|
|
959
|
+
candidate: r.candidate === null ? null : { triangles: r.candidate.triangles, overdraw: r.candidate.overdraw, ...(r.candidate.bridges === undefined ? {} : { bridges: r.candidate.bridges }) },
|
|
861
960
|
// Under --more-triangles only: what accepting a costlier candidate trades, triangles against overdraw.
|
|
862
961
|
...(input.moreTriangles === true && r.candidate !== null ? { delta: { triangles: r.candidate.triangles - r.lattice.triangles, overdraw: pyRound(r.candidate.overdraw - r.lattice.overdraw, 4) } } : {}),
|
|
863
962
|
readings: r.readings,
|
|
@@ -873,7 +972,7 @@ export function meshDraftStage(input: MeshDraftInput, rigc: RigcRunner, checkBin
|
|
|
873
972
|
writeFileSync(written[0], configText);
|
|
874
973
|
writeFileSync(written[1], `${JSON.stringify(report, null, 2)}\n`);
|
|
875
974
|
for (const r of rowsOut) {
|
|
876
|
-
const cand = r.candidate === null ? '' : `; candidate ${r.candidate.triangles} triangle(s), overdraw ${r.candidate.overdraw}`;
|
|
975
|
+
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)`}`;
|
|
877
976
|
log(` ${r.mode === 'contour' ? 'DRAFTED' : 'LATTICE'} ${r.part}: lattice grid ${r.lattice.grid} ${r.lattice.triangles} triangle(s), overdraw ${r.lattice.overdraw}${cand}`);
|
|
878
977
|
for (const g of r.gate) log(` ${g.pass ? 'pass' : 'FAIL'} ${g.reading}: ${g.text}`);
|
|
879
978
|
if (r.mode === 'grid' && r.gate.length === 0) log(` ${r.note}`);
|
package/src/requirements.ts
CHANGED
|
@@ -179,6 +179,23 @@ 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 a named animation on purpose (a sleeve hidden behind the body as
|
|
185
|
+
* the arm crosses it), each by slot and animation with the author's why.
|
|
186
|
+
* `ANIMATIONS_DO_NOT_FOLD` names each such fold with its why and does not
|
|
187
|
+
* hold it to the bar, and fails an entry whose slot does not fold in that
|
|
188
|
+
* animation, as rig-c refuses an exemption that exempts nothing. Empty when
|
|
189
|
+
* not declared: no fold in a named animation is meant.
|
|
190
|
+
*/
|
|
191
|
+
may_fold: MayFold[];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** One declared fold (issue #320): a slot, a named animation, and why the fold is the art's. */
|
|
195
|
+
export interface MayFold {
|
|
196
|
+
slot: string;
|
|
197
|
+
animation: string;
|
|
198
|
+
why: string;
|
|
182
199
|
}
|
|
183
200
|
|
|
184
201
|
/** What `check --requirements` and `build --requirements` name, said once for the help and the refusals. */
|
|
@@ -257,7 +274,7 @@ export function readRequirements(path: string): RequirementsFile {
|
|
|
257
274
|
problems.push({ code: 'REQUIREMENTS_FIELD', object, detail });
|
|
258
275
|
};
|
|
259
276
|
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)');
|
|
277
|
+
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
278
|
let fps = NaN;
|
|
262
279
|
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
280
|
else fps = raw.fps;
|
|
@@ -407,8 +424,31 @@ export function readRequirements(path: string): RequirementsFile {
|
|
|
407
424
|
lists.rests.forEach((n, i) => {
|
|
408
425
|
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
426
|
});
|
|
427
|
+
// 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.
|
|
428
|
+
const mayFold: MayFold[] = [];
|
|
429
|
+
if (raw.may_fold !== undefined) {
|
|
430
|
+
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`);
|
|
431
|
+
else {
|
|
432
|
+
raw.may_fold.forEach((e, i) => {
|
|
433
|
+
const where = `${at} field "may_fold"[${i}]`;
|
|
434
|
+
if (!isRecord(e)) {
|
|
435
|
+
field(where, `is ${JSON.stringify(e)?.slice(0, 40)}; { "slot": <name>, "animation": <name>, "why": <text> } is required`);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
const before = problems.length;
|
|
439
|
+
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)');
|
|
440
|
+
for (const k of ['slot', 'animation', 'why'] as const) {
|
|
441
|
+
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`}`);
|
|
442
|
+
}
|
|
443
|
+
if (problems.length !== before) return;
|
|
444
|
+
const entry: MayFold = { slot: e.slot as string, animation: e.animation as string, why: e.why as string };
|
|
445
|
+
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`);
|
|
446
|
+
else mayFold.push(entry);
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
}
|
|
410
450
|
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 };
|
|
451
|
+
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
452
|
}
|
|
413
453
|
|
|
414
454
|
// ---------------------------------------------------------------------------
|
|
@@ -541,6 +581,21 @@ export function resolveRequirements(file: RequirementsFile, rig: Record<string,
|
|
|
541
581
|
}
|
|
542
582
|
}
|
|
543
583
|
|
|
584
|
+
// Issue #320: each declared fold names a slot that holds a mesh and an animation beside the idle, the ones ANIMATIONS_DO_NOT_FOLD reads.
|
|
585
|
+
file.may_fold.forEach((m, i) => {
|
|
586
|
+
const object = `may_fold[${i}] (slot "${m.slot}", animation "${m.animation}")`;
|
|
587
|
+
if (m.animation === 'idle') problems.push({ code: 'REQUIREMENTS_MAY_FOLD', object, detail: 'names the idle, which ANIMATIONS_DO_NOT_FOLD does not read — it reads the animations beside the idle, so declaring it excepts nothing' });
|
|
588
|
+
else if (!(m.animation in anims)) unresolved(object, `names animation "${m.animation}", which motion.json does not declare; it declares ${listed(animNames)}`);
|
|
589
|
+
const slots = (Array.isArray(rig.slots) ? rig.slots : []).filter(isRecord).map((s) => s.name);
|
|
590
|
+
if (!slots.includes(m.slot)) {
|
|
591
|
+
unresolved(object, `names slot "${m.slot}", which rig.json does not declare`);
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
const meshes = meshAttachmentsOf(rig, m.slot);
|
|
595
|
+
if (typeof meshes === 'string') problems.push({ code: 'REQUIREMENTS_RESOLVES', object, detail: meshes });
|
|
596
|
+
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` });
|
|
597
|
+
});
|
|
598
|
+
|
|
544
599
|
const root = bones.find((b) => b.name === rootBone);
|
|
545
600
|
const measured = new Set(file.requirements.map((r) => r.animation));
|
|
546
601
|
const placed = new Set<string>();
|
|
@@ -1009,8 +1064,14 @@ export function followLine(r: FollowRequirement, declared: Poses, released: Pose
|
|
|
1009
1064
|
};
|
|
1010
1065
|
}
|
|
1011
1066
|
|
|
1012
|
-
/**
|
|
1013
|
-
|
|
1067
|
+
/**
|
|
1068
|
+
* `stretch`: `TEXTURE_STRETCH`'s per-mesh figure for one mesh over the named animation's frames — its worst
|
|
1069
|
+
* max(ratio, 1/ratio) and where. With `heldBy`, the line also carries `held_by` (issue #321): what the worst edge's two
|
|
1070
|
+
* vertices hang on. `check` passes `edgeBonesText` (`src/check.ts`), the function `CHECK_TEXTURE_STRETCH` names them
|
|
1071
|
+
* with since #314, so the two lines cannot drift; it is handed in rather than imported because `src/check.ts` imports
|
|
1072
|
+
* this module.
|
|
1073
|
+
*/
|
|
1074
|
+
export function stretchRequirementLine(r: StretchRequirement, meshes: readonly MeshRest[], frames: readonly GeometryPose[], p: Poses, heldBy?: (edge: readonly number[]) => string): RequirementLine {
|
|
1014
1075
|
const mesh = meshes.find((m) => m.slot === r.slot && m.attachment === r.attachment);
|
|
1015
1076
|
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
1077
|
const [m] = stretchFigures([mesh], frames);
|
|
@@ -1032,6 +1093,7 @@ export function stretchRequirementLine(r: StretchRequirement, meshes: readonly M
|
|
|
1032
1093
|
triangles: m.triangles,
|
|
1033
1094
|
severity: round6(severity),
|
|
1034
1095
|
worst: `triangle ${at.triangle} (vertices ${at.vertices.join(' ')}), edge ${at.edge.join('-')}, ratio ${round6(at.ratio)}`,
|
|
1096
|
+
...(heldBy === undefined ? {} : { held_by: heldBy(at.edge) }),
|
|
1035
1097
|
at: fi < 0 ? `frame ${at.frame}` : frameText(p, fi),
|
|
1036
1098
|
max_ratio: round6(m.max.ratio),
|
|
1037
1099
|
min_ratio: round6(m.min.ratio),
|
|
@@ -1274,7 +1336,7 @@ function failText(r: Requirement, l: RequirementLine): string {
|
|
|
1274
1336
|
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
1337
|
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
1338
|
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)}`;
|
|
1339
|
+
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
1340
|
if (r.kind === 'seam') return `largest opening ${String(l.largest_px)} px at ${String(l.at)}, ${String(l.pair)}`;
|
|
1279
1341
|
return `measured fraction ${String(l.fraction_measured)} over ${String(l.frames_counted)} frame(s) that reach the least drive`;
|
|
1280
1342
|
}
|
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;
|
|
@@ -1460,7 +1460,7 @@ export function buildRig(
|
|
|
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
|
|