@rig-c/parts 1.11.0 → 1.13.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 +15 -11
- package/cli.ts +13 -4
- package/docs/AUTHORING.md +188 -32
- package/docs/STABILITY.md +3 -3
- package/package.json +2 -2
- package/skills/rig-parts/SKILL.md +8 -5
- package/src/assemble.ts +85 -2
- package/src/build.ts +16 -2
- package/src/check.ts +65 -4
- package/src/config.ts +22 -1
- package/src/heat.ts +42 -6
- package/src/mesh.ts +61 -0
- package/src/meshdraft.ts +43 -9
- package/src/propose.ts +55 -2
- package/src/rig.ts +19 -5
- package/src/squeeze.ts +274 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rig-c/parts",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.13.0",
|
|
4
4
|
"description": "AI-authored 2D character parts from one anime painting and its See-through layer decomposition: measured parts, weighted-mesh rig specs, verified through rig-c before anything is written. A CLI for agents that cannot see the image.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
},
|
|
61
61
|
"dependencies": {
|
|
62
62
|
"ag-psd": "^31.0.2",
|
|
63
|
-
"rig-c": "^2.
|
|
63
|
+
"rig-c": "^2.47.1"
|
|
64
64
|
},
|
|
65
65
|
"devDependencies": {
|
|
66
66
|
"@esotericsoftware/spine-core": "4.3.13",
|
|
@@ -82,10 +82,12 @@ stage writes are the whole interface.
|
|
|
82
82
|
keeps under the piece's edge; 1 px closed the seam on scarf) — and give the piece its own
|
|
83
83
|
`meshes` entry; the `cut:` line gives the split. At overlap 0 a cut through
|
|
84
84
|
opaque art leaves a seam `check` reads (AUTHORING §5).
|
|
85
|
-
A
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
85
|
+
A figure whose joints are not where the screen-y rules put them — reclining,
|
|
86
|
+
an arm raised, seated in a skirt that does not start at the hip: add
|
|
87
|
+
`--keypoints <file>` (body-18 joints in painting px, each observed, occluded
|
|
88
|
+
or missing; AUTHORING §3, *A posed figure*) so the neck, hip, chest and
|
|
89
|
+
sleeves follow the pose and LINT reads the joints, not screen y. A clean
|
|
90
|
+
LINT does not show a seated hip is right; the overlay does. Never write a joint you did not get from an
|
|
89
91
|
estimator or the painting: say `missing` and let the rule stand in.
|
|
90
92
|
6. Correct against `render/landmarks.png` and `note:` lines; a
|
|
91
93
|
`coverage … not checked` line is a bone no check read (the line says why),
|
|
@@ -109,7 +111,8 @@ stage writes are the whole interface.
|
|
|
109
111
|
8. Read `out/check/check.json` — the six judgement lines the same way as the seam
|
|
110
112
|
and the loop: a FAIL names the field to change (AUTHORING §6);
|
|
111
113
|
`TEXTURE_STRETCH` names the worst mesh triangle (slot, triangle, vertices, edge,
|
|
112
|
-
frame), so quote it even on a PASS
|
|
114
|
+
frame), so quote it even on a PASS; its FAIL line also names the bones the
|
|
115
|
+
edge's two vertices are weighted to and the idle chains they hang on. Quote
|
|
113
116
|
`RECOMPOSITE_HOLES` (uncovered px, hole count, the largest hole's box) in the
|
|
114
117
|
report whatever `PASS` says. The artifact is the three paths the build prints
|
|
115
118
|
last: `check/build/skeleton.json`, `skeleton.atlas` and the packed page. Report
|
package/src/assemble.ts
CHANGED
|
@@ -2654,6 +2654,14 @@ function fmt0(v: number): string {
|
|
|
2654
2654
|
* no role. One both runs hold is proposed once, from the full run, and a note
|
|
2655
2655
|
* says how to take the head run's too. No public example holds one, so no
|
|
2656
2656
|
* proposal of theirs moves.
|
|
2657
|
+
*
|
|
2658
|
+
* ⚖️ This port's too (issue #308): every tag a layer of either run holds that
|
|
2659
|
+
* no plan row or extend entry takes is named once, with why, run by run
|
|
2660
|
+
* (`unplannedNotes`), after the uncovered bodies and before the plausibility
|
|
2661
|
+
* drops. The plan is not changed. [observed] On the four public examples the
|
|
2662
|
+
* plan, the extend entries and every earlier note are byte for byte as
|
|
2663
|
+
* before; each gains one such note per tag left out (demo 6, scarf 9,
|
|
2664
|
+
* reference-demo 7, reference-sample 9).
|
|
2657
2665
|
*/
|
|
2658
2666
|
export function proposePlan(full: LayerSet, head: LayerSet, g: Geometry, minPx = PROPOSE_MIN_PX): PlanProposal {
|
|
2659
2667
|
const frame = checkGeometry(g, { full, head });
|
|
@@ -2778,8 +2786,83 @@ export function proposePlan(full: LayerSet, head: LayerSet, g: Geometry, minPx =
|
|
|
2778
2786
|
why,
|
|
2779
2787
|
);
|
|
2780
2788
|
const bodyNotes = left.map((u) => uncoveredNote(u, plan, minBody));
|
|
2781
|
-
|
|
2782
|
-
|
|
2789
|
+
const unplanned = unplannedNotes(runs, plan, extend, noted, figures, minPx, (run, tag) => (find(run, tag)?.opaqueRunPx ?? 0) * (run === 'head' ? headArea : fullArea), new Set(left.map((u) => u.tag)));
|
|
2790
|
+
// The reference's notes, then the uncovered bodies, then the layers in no row (issue #308), then the plausibility drops — a drop note is last, as it was.
|
|
2791
|
+
return { plan, extend_below_crop: extend, notes: [...notes, ...bodyNotes, ...unplanned, ...dropNotes] };
|
|
2792
|
+
}
|
|
2793
|
+
|
|
2794
|
+
/** What every `--propose-plan` note on a layer in no row says its pixel figures are. */
|
|
2795
|
+
export const UNPLANNED_PX = 'px: run pixels of alpha above 8 after ghost clean-up';
|
|
2796
|
+
|
|
2797
|
+
/**
|
|
2798
|
+
* One note per tag that a layer of either run holds and no plan row or
|
|
2799
|
+
* extend entry takes (issue #308), in the full run's draw order and then the
|
|
2800
|
+
* head run's, saying for each run why: it holds no such layer; the proposer
|
|
2801
|
+
* takes the tag from the other run only (`PROPOSE_HEAD_TAGS`); its layer is
|
|
2802
|
+
* empty, its opaque count below the floor (`PROPOSE_MIN_PX`); it crosses a
|
|
2803
|
+
* plausibility rule (that layer's own note, which follows, names its figures);
|
|
2804
|
+
* or, for a full-run `headwear` or `earwear`, the head run's was not below 0.8
|
|
2805
|
+
* of it in rig px. A tag every holding run of which a plausibility note
|
|
2806
|
+
* already names gets no second note. `nose`, which the proposer never plans
|
|
2807
|
+
* (the reference's rule), gets a note of its own: the counts, and the row that
|
|
2808
|
+
* lets `propose` read the head's pitch, the only reading in this package made
|
|
2809
|
+
* off a part tagged `nose` (`headFrameNotes`, `src/propose.ts`). The plan is
|
|
2810
|
+
* not changed.
|
|
2811
|
+
*/
|
|
2812
|
+
function unplannedNotes(
|
|
2813
|
+
runs: Record<Run, RunLayer[]>,
|
|
2814
|
+
plan: readonly PlanEntry[],
|
|
2815
|
+
extend: readonly Extend[],
|
|
2816
|
+
noted: ReadonlySet<string>,
|
|
2817
|
+
figures: Record<Run, LayerFigures[]>,
|
|
2818
|
+
minPx: number,
|
|
2819
|
+
rigPx: (run: Run, tag: string) => number,
|
|
2820
|
+
bodies: ReadonlySet<string>,
|
|
2821
|
+
): string[] {
|
|
2822
|
+
const inRow = new Set<string>([...plan.map((p) => p[2]), ...extend.map((e) => e.tag)]);
|
|
2823
|
+
const find = (run: Run, tag: string): RunLayer | undefined => runs[run].find((l) => l.tag === tag);
|
|
2824
|
+
const floor = minPx === PROPOSE_MIN_PX ? `the proposer's floor of ${minPx} (PROPOSE_MIN_PX, src/assemble.ts)` : `the floor of ${minPx} this proposal was given`;
|
|
2825
|
+
const out: string[] = [];
|
|
2826
|
+
const seen = new Set<string>();
|
|
2827
|
+
for (const { tag } of [...runs.full, ...runs.head]) {
|
|
2828
|
+
if (seen.has(tag) || inRow.has(tag)) continue;
|
|
2829
|
+
seen.add(tag);
|
|
2830
|
+
const held = (['full', 'head'] as const).filter((r) => find(r, tag) !== undefined);
|
|
2831
|
+
const body = bodies.has(tag) ? "; the full run's layer holds a body no planned part covers, named in its note above" : '';
|
|
2832
|
+
if (tag === 'nose') {
|
|
2833
|
+
const counts = (['full', 'head'] as const).map((r) => `${r} run ${find(r, tag)?.opaqueRunPx ?? 'no layer'}${find(r, tag) === undefined ? '' : ' px'}`).join(', ');
|
|
2834
|
+
const best = (['head', 'full'] as const).reduce((a, b) => ((find(b, tag)?.opaqueRunPx ?? 0) > (find(a, tag)?.opaqueRunPx ?? 0) ? b : a));
|
|
2835
|
+
const most = find(best, tag)?.opaqueRunPx ?? 0;
|
|
2836
|
+
const after = plan.some((p) => p[2] === 'face') ? ` after the row that takes "face"` : '';
|
|
2837
|
+
const how =
|
|
2838
|
+
most === 0
|
|
2839
|
+
? 'neither run\'s layer holds a pixel, so there is no nose part to add'
|
|
2840
|
+
: `propose reads the head's pitch (where the nose sits between the eye midpoint and the mouth) only off a part tagged nose, and without one prints "not read: no part tagged nose"; to have it read, add ["nose", "${best}", "nose"] to assemble.plan${after} (the ${best} run's layer holds the more of it) and run assemble and propose again`;
|
|
2841
|
+
out.push(`nose: in no plan row — the proposer never plans nose (the reference implementation's rule, which this port keeps); ${counts} (${UNPLANNED_PX}); ${how}${body}`);
|
|
2842
|
+
continue;
|
|
2843
|
+
}
|
|
2844
|
+
if (held.every((r) => noted.has(`${r}:${tag}`))) continue;
|
|
2845
|
+
const reason = (run: Run): string => {
|
|
2846
|
+
const l = find(run, tag);
|
|
2847
|
+
if (l === undefined) return `${run} run: no layer`;
|
|
2848
|
+
const px = `${run} run: ${l.opaqueRunPx} px`;
|
|
2849
|
+
const headTag = PROPOSE_HEAD_TAGS.includes(tag);
|
|
2850
|
+
const fallback = tag === 'headwear' || tag === 'earwear';
|
|
2851
|
+
if (run === 'full' && headTag && !fallback) return `${px}, but the proposer takes ${tag} from the head run only (PROPOSE_HEAD_TAGS, src/assemble.ts)`;
|
|
2852
|
+
if (run === 'head' && !headTag && readAuthorTag(tag) === null) return `${px}, but the proposer takes ${tag} from the full run only (it is not one of PROPOSE_HEAD_TAGS, src/assemble.ts)`;
|
|
2853
|
+
if (l.opaqueRunPx < minPx) return `${px}, empty: below ${floor}`;
|
|
2854
|
+
if (noted.has(`${run}:${tag}`)) {
|
|
2855
|
+
const f = figures[run].find((x) => x.name === tag) as LayerFigures;
|
|
2856
|
+
return `${px}, crosses ${implausibleRules(f).join(', ')} (named with its figures in the note below)`;
|
|
2857
|
+
}
|
|
2858
|
+
if (run === 'full' && fallback) {
|
|
2859
|
+
return `${px}, but the proposer takes ${tag} from the full run only when the head run's is below 0.8 of it in rig px (head run ${fmt0(rigPx('head', tag))}, full run ${fmt0(rigPx('full', tag))})`;
|
|
2860
|
+
}
|
|
2861
|
+
throw new Error(`unplannedNotes: no rule left ${run}:${tag} out of the plan, yet no row takes it`);
|
|
2862
|
+
};
|
|
2863
|
+
out.push(`${tag}: in no plan row — ${(['full', 'head'] as const).map(reason).join('; ')} (${UNPLANNED_PX})${body}`);
|
|
2864
|
+
}
|
|
2865
|
+
return out;
|
|
2783
2866
|
}
|
|
2784
2867
|
|
|
2785
2868
|
// ---------------------------------------------------------------------------
|
package/src/build.ts
CHANGED
|
@@ -227,7 +227,7 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
|
|
|
227
227
|
log(
|
|
228
228
|
` ${p.name.padEnd(11)} ${p.from.padEnd(16)} ${`${p.w}x${p.h}`.padEnd(9)} @${String(p.x).padStart(4)},${String(p.y).padStart(4)} ` +
|
|
229
229
|
`op=${String(p.opaque_px).padStart(6)} vis=${String(p.visible_px).padStart(6)} src=${String(p.source_px_taken).padStart(6)}/${String(p.projected_core_px).padStart(6)} ` +
|
|
230
|
-
`unproj=${String(p.visible_not_projected_px).padStart(5)} drift=${String(p.refused_drift_px).padStart(5)} merged=${p.merged_px} seam=${p.seam_override_px}`,
|
|
230
|
+
`unproj=${String(p.visible_not_projected_px).padStart(5)} drift=${String(p.refused_drift_px).padStart(5)} ${driftShare(p.refused_drift_px, p.projected_core_px)} merged=${p.merged_px} seam=${p.seam_override_px}`,
|
|
231
231
|
);
|
|
232
232
|
}
|
|
233
233
|
// Issue #256: one line per part from an author-made layer, so it never reads as See-through's. None without one.
|
|
@@ -253,6 +253,16 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
|
|
|
253
253
|
return result;
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
+
/**
|
|
257
|
+
* The scale the per-part line prints `drift=` with (issue #314): `refused_drift_px` counts the part's projection
|
|
258
|
+
* candidates (`projected_core_px`, the `src=` column's second figure) refused because See-through's colour sits more
|
|
259
|
+
* than the drift limit off the painting's, so it is read against them — `(<p>% of core)`, one decimal; a part with no
|
|
260
|
+
* candidate has nothing to refuse and says so. No bar reads either figure (AUTHORING §5, `assemble`).
|
|
261
|
+
*/
|
|
262
|
+
export function driftShare(refused: number, core: number): string {
|
|
263
|
+
return core === 0 ? '(no core)' : `(${((100 * refused) / core).toFixed(1)}% of core)`;
|
|
264
|
+
}
|
|
265
|
+
|
|
256
266
|
// ---------------------------------------------------------------------------
|
|
257
267
|
// rig
|
|
258
268
|
// ---------------------------------------------------------------------------
|
|
@@ -834,7 +844,11 @@ export function rigStage(input: RigStageInput, rigc: RigcRunner, scratch: string
|
|
|
834
844
|
const stray = c.strayIslands === 0 ? '' : ` left out ${c.strayIslands} island(s), ${c.strayPixels} px`;
|
|
835
845
|
const regions = m.regions.map((rg) => ` region ${rg.name}->${rg.bone} reaches ${rg.reached} (${rg.whole} whole)`).join('');
|
|
836
846
|
log(`${head}contour tol=${m.params.tolerance} margin=${m.params.margin} spacing=${m.params.spacing}${m.params.maxEdge === undefined ? '' : ` maxEdge=${m.params.maxEdge} (outline +${c.outlineCut ?? 0})`} ${infl} overshoot ${c.overshoot} min angle ${c.smallestAngle.value}${stray}${regions}`);
|
|
837
|
-
} else
|
|
847
|
+
} else {
|
|
848
|
+
// issue #303: only beside a declared stray, so a lattice line without one reads as it always did.
|
|
849
|
+
const stray = m.stray_cleared === undefined ? '' : ` stray ${m.stray} left out ${m.stray_cleared.islands} island(s), ${m.stray_cleared.pixels} of ${m.stray_cleared.art_pixels} art px`;
|
|
850
|
+
log(`${head}grid=${m.grid} ${infl} loop passes ${rig.loopPasses[m.part]}${stray}`);
|
|
851
|
+
}
|
|
838
852
|
}
|
|
839
853
|
const regions = rig.rig.slots.length - rig.meshReport.length;
|
|
840
854
|
const tracks = rig.motion.animations.idle.tracks;
|
package/src/check.ts
CHANGED
|
@@ -1948,7 +1948,7 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
|
|
|
1948
1948
|
// One reader for the one geometry file: the stretch reads its meshes, the still regions every slot and the head bone.
|
|
1949
1949
|
const geometryPath = join(idle.dir, GEOMETRY_FILE);
|
|
1950
1950
|
const geometry = readGeometry(geometryPath, idle.written);
|
|
1951
|
-
stretch = stretchLine(geometry, geometryPath, problems);
|
|
1951
|
+
stretch = stretchLine(geometry, geometryPath, problems, inp);
|
|
1952
1952
|
if (full !== null) still = stillLine(full, idle, geometry, geometryPath, problems, notPlanted);
|
|
1953
1953
|
}
|
|
1954
1954
|
|
|
@@ -3300,15 +3300,76 @@ function stretchAtText(s: { triangle: number; vertices: readonly number[]; edge:
|
|
|
3300
3300
|
return `triangle ${s.triangle} (vertices ${s.vertices.join(' ')}), edge ${s.edge.join('-')}, idle frame ${s.frame}`;
|
|
3301
3301
|
}
|
|
3302
3302
|
|
|
3303
|
+
/**
|
|
3304
|
+
* Issue #314: what a failing edge's two vertices hang on, read off rig.json and motion.json — so the cause reads off
|
|
3305
|
+
* the line rather than off a hand decoding of the weight runs. Each vertex's bones from the default skin's weights,
|
|
3306
|
+
* the largest two by weight (equal weights in rig.json's order), with how many it has; then every bone named, grouped
|
|
3307
|
+
* by the idle chain it hangs on — the nearest link at or above it of a chain `CHAIN_LAG` reads
|
|
3308
|
+
* ({@link chainFigures}: two or more rotate-keyed links at one period), each chain named once, by its first link — and
|
|
3309
|
+
* the bones on none. A mesh with no weights rides its slot's bone, and an attachment the skin does not hold, or a
|
|
3310
|
+
* vertex its weights do not reach, is said rather than guessed.
|
|
3311
|
+
*/
|
|
3312
|
+
export function edgeBonesText(rig: Record<string, unknown>, motion: Record<string, unknown>, slot: string, attachment: string, edge: readonly number[]): string {
|
|
3313
|
+
const skins = isRecord(rig.skins) ? rig.skins : {};
|
|
3314
|
+
const skin = isRecord(skins.default) ? skins.default : {};
|
|
3315
|
+
const onSlot = isRecord(skin[slot]) ? skin[slot] : {};
|
|
3316
|
+
const att = onSlot[attachment];
|
|
3317
|
+
if (!isRecord(att)) return `its weights are not read: rig.json's default skin holds no attachment "${attachment}" on slot "${slot}"`;
|
|
3318
|
+
if (!Array.isArray(att.weights)) {
|
|
3319
|
+
const sl = (Array.isArray(rig.slots) ? rig.slots : []).filter(isRecord).find((x) => x.name === slot);
|
|
3320
|
+
return `the mesh carries no weights, so vertex ${edge.join(' and vertex ')} ride slot "${slot}"'s bone ${sl !== undefined && typeof sl.bone === 'string' ? `"${sl.bone}"` : '(rig.json names none)'}`;
|
|
3321
|
+
}
|
|
3322
|
+
const weights = att.weights;
|
|
3323
|
+
const missing = edge.filter((v) => !Array.isArray(weights[v]));
|
|
3324
|
+
if (missing.length > 0) return `its weights are not read: rig.json's mesh "${attachment}" weights ${weights.length} vertices, and vertex ${missing.join(' and ')} is not among them`;
|
|
3325
|
+
const named: string[] = [];
|
|
3326
|
+
const parts = edge.map((v, i) => {
|
|
3327
|
+
const list = (weights[v] as unknown[]).filter(isRecord).filter((w) => typeof w.bone === 'string' && typeof w.weight === 'number') as Array<{ bone: string; weight: number }>;
|
|
3328
|
+
const top = [...list].sort((a, b) => b.weight - a.weight).slice(0, 2);
|
|
3329
|
+
for (const w of top) if (!named.includes(w.bone)) named.push(w.bone);
|
|
3330
|
+
const shown = top.map((w) => `"${w.bone}" ${w.weight}`).join(', ');
|
|
3331
|
+
return `${i === 0 ? `vertex ${v} is weighted to` : `vertex ${v} to`} ${shown}${list.length > 2 ? ` (the largest 2 of ${list.length})` : ''}`;
|
|
3332
|
+
});
|
|
3333
|
+
const bones = (Array.isArray(rig.bones) ? rig.bones : []).filter(isRecord);
|
|
3334
|
+
const parent = new Map(bones.filter((b) => typeof b.name === 'string').map((b) => [String(b.name), typeof b.parent === 'string' ? b.parent : null]));
|
|
3335
|
+
const firstLink = new Map<string, string>();
|
|
3336
|
+
for (const c of chainFigures(rig, motion).chains) if (c.length >= 2) for (const l of c) firstLink.set(l.bone, c[0].bone);
|
|
3337
|
+
const carries = new Map<string, string[]>();
|
|
3338
|
+
const none: string[] = [];
|
|
3339
|
+
for (const b of named) {
|
|
3340
|
+
let chain: string | null = null;
|
|
3341
|
+
const seen = new Set<string>();
|
|
3342
|
+
for (let x: string | null = b; x !== null && !seen.has(x); x = parent.get(x) ?? null) {
|
|
3343
|
+
const f = firstLink.get(x);
|
|
3344
|
+
if (f !== undefined) {
|
|
3345
|
+
chain = f;
|
|
3346
|
+
break;
|
|
3347
|
+
}
|
|
3348
|
+
seen.add(x);
|
|
3349
|
+
}
|
|
3350
|
+
if (chain === null) none.push(b);
|
|
3351
|
+
else carries.set(chain, [...(carries.get(chain) ?? []), b]);
|
|
3352
|
+
}
|
|
3353
|
+
const quoted = (l: readonly string[]): string => l.map((n) => `"${n}"`).join(', ');
|
|
3354
|
+
const chains =
|
|
3355
|
+
carries.size === 0
|
|
3356
|
+
? 'none of these bones hangs on a chain of the idle'
|
|
3357
|
+
: `on the idle's chains, ${[...carries].map(([c, l]) => `the chain from "${c}" carries ${quoted(l)}`).join(' and ')}${none.length === 0 ? '' : `, and ${quoted(none)} ${none.length === 1 ? 'hangs' : 'hang'} on none`}`;
|
|
3358
|
+
return `${parts.join(' and ')}; ${chains}`;
|
|
3359
|
+
}
|
|
3360
|
+
|
|
3303
3361
|
/**
|
|
3304
3362
|
* (6) No visible texture stretch: for every mesh triangle, each edge's length
|
|
3305
3363
|
* in each idle frame over its rest length (the setup pose's bones, no deform —
|
|
3306
3364
|
* which on both examples is the art's own proportions, the attachment's `uvs`
|
|
3307
3365
|
* times its size, to 2.4e-5). The figure is the rig's worst max(ratio,
|
|
3308
3366
|
* 1/ratio); each mesh's largest and smallest ratio, and where, are the detail.
|
|
3309
|
-
* SKIP when the render wrote no geometry file or the rig draws no mesh.
|
|
3367
|
+
* SKIP when the render wrote no geometry file or the rig draws no mesh. With
|
|
3368
|
+
* `bound`, the rig and motion `check` read, a failing edge's line also names the
|
|
3369
|
+
* bones its two vertices hang on and their chains ({@link edgeBonesText}, issue
|
|
3370
|
+
* #314).
|
|
3310
3371
|
*/
|
|
3311
|
-
export function stretchLine(geo: IdleGeometry | null, path: string, problems: Problem[]): JudgementLine {
|
|
3372
|
+
export function stretchLine(geo: IdleGeometry | null, path: string, problems: Problem[], bound?: Pick<CheckInputs, 'rig' | 'motion'>): JudgementLine {
|
|
3312
3373
|
if (geo === null) return skip(`${path} does not exist; \`rigc render --geometry\` (rig-c ${RIGC_GEOMETRY_VERSION} or later) writes it, and the skinned mesh vertices are read from it`);
|
|
3313
3374
|
if (geo.meshes.length === 0) return skip('the rig draws no mesh attachment (every slot is a region), so there is no triangle to stretch');
|
|
3314
3375
|
const figures = stretchFigures(geo.meshes, geo.frames);
|
|
@@ -3343,7 +3404,7 @@ export function stretchLine(geo: IdleGeometry | null, path: string, problems: Pr
|
|
|
3343
3404
|
problems.push({
|
|
3344
3405
|
code: 'CHECK_TEXTURE_STRETCH',
|
|
3345
3406
|
object: `mesh "${m.slot}" ${stretchAtText(at)}`,
|
|
3346
|
-
detail: `the edge is ${round3(at.ratio)} times its rest length (max(ratio, 1/ratio) ${severity}); <= ${TEXTURE_STRETCH_CEILING} is required — the texture on it is ${at.ratio >= 1 ? 'stretched' : 'squeezed'}: the bones this mesh is weighted to move apart (amplitudes too large down a chain), or a vertex blends bones that move against each other`,
|
|
3407
|
+
detail: `the edge is ${round3(at.ratio)} times its rest length (max(ratio, 1/ratio) ${severity}); <= ${TEXTURE_STRETCH_CEILING} is required${bound === undefined ? '' : `; ${edgeBonesText(bound.rig, bound.motion, m.slot, m.attachment, at.edge)}`} — the texture on it is ${at.ratio >= 1 ? 'stretched' : 'squeezed'}: the bones this mesh is weighted to move apart (amplitudes too large down a chain), or a vertex blends bones that move against each other`,
|
|
3347
3408
|
});
|
|
3348
3409
|
}
|
|
3349
3410
|
}
|
package/src/config.ts
CHANGED
|
@@ -278,6 +278,11 @@ export interface LatticeMeshSpec {
|
|
|
278
278
|
rule?: WeightRule;
|
|
279
279
|
/** The cap and the floor's form (issue #284); absent is 4 and the cut at 0.03, the rule as it always was. */
|
|
280
280
|
influences?: MeshInfluences;
|
|
281
|
+
/**
|
|
282
|
+
* The largest island, in art pixels, that may be left out of the lattice (issue #303) — the contour mode's
|
|
283
|
+
* `contour.stray`, with the same meaning (`latticeStray`, `src/mesh.ts`); absent leaves none out.
|
|
284
|
+
*/
|
|
285
|
+
stray?: number;
|
|
281
286
|
}
|
|
282
287
|
|
|
283
288
|
export interface ContourMeshSpec {
|
|
@@ -1665,7 +1670,7 @@ function checkMeshes(c: Check, v: Json, bones: Set<string>, chains: Map<string,
|
|
|
1665
1670
|
const at = `config.meshes.${part}`;
|
|
1666
1671
|
// The known keys keep their old order (grid, r, segments) with contour after them, and `r` and `segments` are
|
|
1667
1672
|
// required in the place and words the object check uses, so a lattice mesh's refusals read as they did.
|
|
1668
|
-
const m = c.object(at, spec, [], ['grid', 'r', 'segments', 'contour', 'auto', 'exponent', 'rule', 'ribs', 'influences']);
|
|
1673
|
+
const m = c.object(at, spec, [], ['grid', 'r', 'segments', 'contour', 'auto', 'exponent', 'rule', 'ribs', 'influences', 'stray']);
|
|
1669
1674
|
if (m === null) continue;
|
|
1670
1675
|
for (const key of ['r', 'segments']) if (!(key in m)) c.fail('CONFIG_FIELD_PRESENT', `${at}.${key}`, 'is absent and required');
|
|
1671
1676
|
const modes = ['grid', 'contour', 'auto'].filter((k) => k in m);
|
|
@@ -1707,6 +1712,7 @@ function checkMeshes(c: Check, v: Json, bones: Set<string>, chains: Map<string,
|
|
|
1707
1712
|
}
|
|
1708
1713
|
if ('ribs' in m) checkRibs(c, `${at}.ribs`, m, chains);
|
|
1709
1714
|
if ('influences' in m) checkMeshInfluences(c, `${at}.influences`, m);
|
|
1715
|
+
if ('stray' in m) checkLatticeStray(c, `${at}.stray`, m);
|
|
1710
1716
|
if ('auto' in m && typeof m.auto === 'object' && m.auto !== null && !Array.isArray(m.auto) && 'stimulus' in m.auto) {
|
|
1711
1717
|
checkStimulus(c, `${at}.auto.stimulus`, (m.auto as Record<string, Json>).stimulus, m, chains);
|
|
1712
1718
|
}
|
|
@@ -1775,6 +1781,21 @@ function checkMeshInfluences(c: Check, at: string, m: Record<string, Json>): voi
|
|
|
1775
1781
|
}
|
|
1776
1782
|
}
|
|
1777
1783
|
|
|
1784
|
+
/**
|
|
1785
|
+
* `meshes.<part>.stray` (issue #303): the lattice's largest island, in art pixels, that may be left out — a whole
|
|
1786
|
+
* number, 0 or more, as the contour mode's `contour.stray` is. The contour and automatic modes already state it in
|
|
1787
|
+
* their own place (`contour.stray`, `auto.source.stray`), so beside either it is refused by name
|
|
1788
|
+
* (`CONFIG_STRAY_MODE`) rather than read by nothing.
|
|
1789
|
+
*/
|
|
1790
|
+
function checkLatticeStray(c: Check, at: string, m: Record<string, Json>): void {
|
|
1791
|
+
const elsewhere = 'contour' in m ? ['a contour mesh (contour)', 'contour.stray'] : 'auto' in m ? ['an automatic mesh (auto)', 'auto.source.stray'] : null;
|
|
1792
|
+
if (elsewhere !== null && !('grid' in m)) {
|
|
1793
|
+
c.fail('CONFIG_STRAY_MODE', at, `is ${show(m.stray)} on ${elsewhere[0]}; that mode states the largest island it may leave out in ${elsewhere[1]}, which its trace reads — declare it there, or remove stray`);
|
|
1794
|
+
return;
|
|
1795
|
+
}
|
|
1796
|
+
c.int(at, m.stray, 0);
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1778
1799
|
/**
|
|
1779
1800
|
* `meshes.<part>.ribs` (issue #188): an object of chain name → `{ stations }`.
|
|
1780
1801
|
* Each key must be a chain this mesh's `segments` name (a rib is placed along
|
package/src/heat.ts
CHANGED
|
@@ -44,10 +44,19 @@
|
|
|
44
44
|
* silhouette, or one whose every reached pixel is nearer another bone. A
|
|
45
45
|
* bone named in `segments` is a bone the author said may pull the layer;
|
|
46
46
|
* giving it 0 everywhere would be a silent answer to a question the
|
|
47
|
-
* config asked.
|
|
47
|
+
* config asked. The rule is chosen per mesh (`meshes.<part>.rule`), so the
|
|
48
|
+
* refusal names the third remedy too (issue #303): this part keeps the
|
|
49
|
+
* distance rule — the one an author whose bone anchors the part from
|
|
50
|
+
* outside its art (a torso bone on a neck) can take without moving a bone
|
|
51
|
+
* another part placed or dropping the anchor.
|
|
48
52
|
* - `RIG_HEAT_ISLAND_SOURCE`: a 4-connected island of the silhouette that no
|
|
49
53
|
* bone has a source in. With no fixed temperature the equation has no
|
|
50
|
-
* answer there; nothing nearest is borrowed.
|
|
54
|
+
* answer there; nothing nearest is borrowed. When the caller can say the
|
|
55
|
+
* island is dust that its `stray` would leave out ({@link HeatDust}: the
|
|
56
|
+
* lattice, issue #303) the refusal names that field and the figure that
|
|
57
|
+
* leaves the island out instead of a segment nobody would place on it; an
|
|
58
|
+
* island above the dust line keeps the segment remedy, a real piece being
|
|
59
|
+
* a real authoring question.
|
|
51
60
|
* - `RIG_HEAT_CONVERGED`: above.
|
|
52
61
|
*
|
|
53
62
|
* Two runs on the same input write the same bytes: the claims, the sweep
|
|
@@ -55,6 +64,8 @@
|
|
|
55
64
|
*/
|
|
56
65
|
import type { Point } from './config.ts';
|
|
57
66
|
import { artMask, growSilhouette } from './contour.ts';
|
|
67
|
+
import { DUST_MAX_PX, DUST_MAX_SHARE, isDust } from './mesh.ts';
|
|
68
|
+
import { pyRound } from './round.ts';
|
|
58
69
|
import type { Problem } from './errors.ts';
|
|
59
70
|
import { connectedComponents, fillHoles, type Mask } from './raster/index.ts';
|
|
60
71
|
import { capAndFloor, type Influence, type InfluenceLimits, type Segment, segmentDistance } from './weights.ts';
|
|
@@ -120,11 +131,34 @@ export interface HeatRow {
|
|
|
120
131
|
|
|
121
132
|
const fmt = (v: number): string => String(Math.round(v * 1000) / 1000);
|
|
122
133
|
|
|
134
|
+
/**
|
|
135
|
+
* What a mesh's `stray` can say about a silhouette island no bone reaches (issue #303), handed in by the mode that has
|
|
136
|
+
* the field (the lattice): the field's path, its declared figure (null when absent), and the part's art islands it
|
|
137
|
+
* reads — the art px (`isDust`'s share is of them) and the largest island's area, which `stray` never leaves out. It
|
|
138
|
+
* is read only when an island is refused, so a build that solves never computes it.
|
|
139
|
+
*/
|
|
140
|
+
export interface HeatDust {
|
|
141
|
+
field: string;
|
|
142
|
+
declared: number | null;
|
|
143
|
+
artPixels: number;
|
|
144
|
+
largest: number;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The refusal's remedy for an island of `area` px no bone reaches: the stray field when the island is dust it would leave out, else a segment. */
|
|
148
|
+
function islandRemedy(area: number, dust: HeatDust | null): string {
|
|
149
|
+
if (dust !== null && isDust(area, dust.artPixels) && area < dust.largest) {
|
|
150
|
+
const now = dust.declared === null ? 'it is absent' : `it is ${dust.declared}`;
|
|
151
|
+
return `the island is dust — at most ${DUST_MAX_PX} px and at most ${pyRound(DUST_MAX_SHARE * 100, 6)} % of the part's ${dust.artPixels} art px, mesh-draft's dust line — which no bone is meant to sit on: declare ${dust.field} at ${area} or more (${now}), which leaves an island that small out of the mesh and the silhouette heat solves over`;
|
|
152
|
+
}
|
|
153
|
+
return `under rule "heat" every island needs a segment within ${HEAT_SOURCE_BAND} px of one of its pixel centres — nothing is borrowed from the nearest island`;
|
|
154
|
+
}
|
|
155
|
+
|
|
123
156
|
/**
|
|
124
157
|
* Solve bone heat over `sil` (part-image pixels) for `segs` (rig px; the image is the rig less `(ox, oy)`).
|
|
125
|
-
* `object` names the mesh in every refusal. Every problem found is returned at once.
|
|
158
|
+
* `object` names the mesh in every refusal. Every problem found is returned at once. `dust`, when the mesh's mode has
|
|
159
|
+
* a `stray` field, is read only to word an island refusal ({@link HeatDust}).
|
|
126
160
|
*/
|
|
127
|
-
export function boneHeat(object: string, sil: Mask, segs: readonly Segment[], ox: number, oy: number, limits: HeatLimits = HEAT_LIMITS): HeatField | Problem[] {
|
|
161
|
+
export function boneHeat(object: string, sil: Mask, segs: readonly Segment[], ox: number, oy: number, limits: HeatLimits = HEAT_LIMITS, dust: (() => HeatDust) | null = null): HeatField | Problem[] {
|
|
128
162
|
const { width: w, height: h, data } = sil;
|
|
129
163
|
const n = w * h;
|
|
130
164
|
const bones: string[] = [];
|
|
@@ -166,19 +200,21 @@ export function boneHeat(object: string, sil: Mask, segs: readonly Segment[], ox
|
|
|
166
200
|
problems.push({
|
|
167
201
|
code: 'RIG_HEAT_BONE_SOURCE',
|
|
168
202
|
object,
|
|
169
|
-
detail: `bone "${bone}" — its segment ${where} (rig px) ${why}, so it holds no source pixel; under rule "heat" every bound bone needs at least one: a silhouette pixel (${silPixels} px) whose centre lies within ${HEAT_SOURCE_BAND} px of the segment and nearer it than any other bound bone's. A bone with no source is not weighted 0 — move its segment onto the art,
|
|
203
|
+
detail: `bone "${bone}" — its segment ${where} (rig px) ${why}, so it holds no source pixel; under rule "heat" every bound bone needs at least one: a silhouette pixel (${silPixels} px) whose centre lies within ${HEAT_SOURCE_BAND} px of the segment and nearer it than any other bound bone's. A bone with no source is not weighted 0 — move its segment onto the art, take the bone out of segments, or keep this part on the distance rule (rule "distance", or no rule, on this mesh: the rule is chosen per mesh)`,
|
|
170
204
|
});
|
|
171
205
|
});
|
|
172
206
|
const cc = connectedComponents(sil, 4);
|
|
173
207
|
const sourced = new Uint8Array(cc.count);
|
|
174
208
|
for (let i = 0; i < n; i++) if (owner[i] >= 0) sourced[cc.labels[i]] = 1;
|
|
209
|
+
let dustRead: HeatDust | null = null;
|
|
175
210
|
for (let c = 1; c < cc.count; c++) {
|
|
176
211
|
if (sourced[c]) continue;
|
|
177
212
|
const st = cc.stats[c];
|
|
213
|
+
if (dust !== null && dustRead === null) dustRead = dust();
|
|
178
214
|
problems.push({
|
|
179
215
|
code: 'RIG_HEAT_ISLAND_SOURCE',
|
|
180
216
|
object,
|
|
181
|
-
detail: `the silhouette's 4-connected island of ${st.area} px at rig (${st.left + ox}, ${st.top + oy}) holds no bound bone's source pixel, so the heat equation has no fixed temperature there and no answer;
|
|
217
|
+
detail: `the silhouette's 4-connected island of ${st.area} px at rig (${st.left + ox}, ${st.top + oy}) holds no bound bone's source pixel, so the heat equation has no fixed temperature there and no answer; ${islandRemedy(st.area, dustRead)}`,
|
|
182
218
|
});
|
|
183
219
|
}
|
|
184
220
|
if (problems.length > 0) return problems;
|
package/src/mesh.ts
CHANGED
|
@@ -360,3 +360,64 @@ export function latticeMesh(part: string, art: Mask, grid: number, maxPasses: nu
|
|
|
360
360
|
}
|
|
361
361
|
return mesh;
|
|
362
362
|
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Dust (issue #265, Stage B's rule; moved here from `src/meshdraft.ts` by issue #303, so the rig stage's heat refusal
|
|
366
|
+
* and `mesh-draft` read one line): an extra 4-connected island of the art of at most this many art px …
|
|
367
|
+
*/
|
|
368
|
+
export const DUST_MAX_PX = 4;
|
|
369
|
+
|
|
370
|
+
/** … and at most this share of the part's art px. */
|
|
371
|
+
export const DUST_MAX_SHARE = 0.001;
|
|
372
|
+
|
|
373
|
+
/** Whether an island of `area` art px is dust in a part of `artPixels` art px ({@link DUST_MAX_PX}, {@link DUST_MAX_SHARE}). */
|
|
374
|
+
export function isDust(area: number, artPixels: number): boolean {
|
|
375
|
+
return area <= DUST_MAX_PX && area <= DUST_MAX_SHARE * artPixels;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** What a declared `meshes.<part>.stray` left out of a lattice's art (issue #303). */
|
|
379
|
+
export interface LatticeStray {
|
|
380
|
+
/** The art the lattice is built over: `art` itself when nothing was left out. */
|
|
381
|
+
art: Mask;
|
|
382
|
+
/** Islands left out, and their art px. */
|
|
383
|
+
islands: number;
|
|
384
|
+
pixels: number;
|
|
385
|
+
/** The art px of the part before anything was left out. */
|
|
386
|
+
artPixels: number;
|
|
387
|
+
/** The area of the largest island; an island this large is never left out. */
|
|
388
|
+
largest: number;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* The lattice's `stray` (issue #303), the contour mode's field with the same meaning — the largest island, in art
|
|
393
|
+
* pixels, that may be left out of the mesh — read over the same islands: the 4-connected islands of the art (alpha
|
|
394
|
+
* above {@link ART_ALPHA}). Every island of `stray` art px or fewer is cleared from the art the lattice and its heat
|
|
395
|
+
* silhouette are built over, except an island as large as the largest, which is never left out (two equal largest
|
|
396
|
+
* pieces both stay: leaving one would be a choice the declaration does not make). Unlike the contour mode, an island
|
|
397
|
+
* above the figure is not refused: the lattice bridges every island it keeps (step 2 above), so it is meshed as it
|
|
398
|
+
* always was. The part's image is not touched; `artCoverage` is read over all the art, the islands left out included.
|
|
399
|
+
*/
|
|
400
|
+
export function latticeStray(art: Mask, stray: number): LatticeStray {
|
|
401
|
+
const comp = connectedComponents(art, 4);
|
|
402
|
+
let largest = 0;
|
|
403
|
+
let artPixels = 0;
|
|
404
|
+
for (let l = 1; l < comp.count; l++) {
|
|
405
|
+
largest = Math.max(largest, comp.stats[l].area);
|
|
406
|
+
artPixels += comp.stats[l].area;
|
|
407
|
+
}
|
|
408
|
+
const out = new Uint8Array(comp.count);
|
|
409
|
+
let islands = 0;
|
|
410
|
+
let pixels = 0;
|
|
411
|
+
for (let l = 1; l < comp.count; l++) {
|
|
412
|
+
const a = comp.stats[l].area;
|
|
413
|
+
if (a <= stray && a < largest) {
|
|
414
|
+
out[l] = 1;
|
|
415
|
+
islands++;
|
|
416
|
+
pixels += a;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
if (islands === 0) return { art, islands, pixels, artPixels, largest };
|
|
420
|
+
const data = new Uint8Array(art.data);
|
|
421
|
+
for (let i = 0; i < data.length; i++) if (out[comp.labels[i]] === 1) data[i] = 0;
|
|
422
|
+
return { art: { width: art.width, height: art.height, data }, islands, pixels, artPixels, largest };
|
|
423
|
+
}
|
package/src/meshdraft.ts
CHANGED
|
@@ -68,7 +68,7 @@ import { BARS, type CheckReport, type PackShape, type PageEdges, type RigcRunner
|
|
|
68
68
|
import { type CharacterConfig, type ContourMeshSpec, type LatticeMeshSpec, loadConfigAndAnimations, type Segment } from './config.ts';
|
|
69
69
|
import { PartsError, type Problem, problemLine } from './errors.ts';
|
|
70
70
|
import { IDLE_FPS } from './motion.ts';
|
|
71
|
-
import { ART_ALPHA } from './mesh.ts';
|
|
71
|
+
import { ART_ALPHA, DUST_MAX_PX, DUST_MAX_SHARE, isDust } from './mesh.ts';
|
|
72
72
|
import { type PartsFile, readParts } from './parts.ts';
|
|
73
73
|
import { alphaAbove, connectedComponents } from './raster/index.ts';
|
|
74
74
|
import { readPng } from './raster/png.ts';
|
|
@@ -90,11 +90,11 @@ export const TOLERANCE_FIGURE_DIVISOR = 554;
|
|
|
90
90
|
/** The heights of the three figures the two divisors were measured on, rig px (demo, the trailer, scarf). */
|
|
91
91
|
export const CALIBRATION_HEIGHTS: readonly number[] = [1106, 1108, 1185];
|
|
92
92
|
|
|
93
|
-
/**
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
export
|
|
93
|
+
/**
|
|
94
|
+
* Dust (Stage B: 1–2 px dots were the measured case): `src/mesh.ts` holds the line since issue #303, which the rig
|
|
95
|
+
* stage's heat refusal reads too; re-exported here, where it was first written.
|
|
96
|
+
*/
|
|
97
|
+
export { DUST_MAX_PX, DUST_MAX_SHARE } from './mesh.ts';
|
|
98
98
|
|
|
99
99
|
/** The bend set's turn per link, degrees: the fallback Stage C stated, a physics constraint's reach not being derivable from it. */
|
|
100
100
|
export const BEND_DEGREES = 15;
|
|
@@ -118,6 +118,33 @@ export const MORE_TRIANGLES_FLAG = '--more-triangles';
|
|
|
118
118
|
*/
|
|
119
119
|
export const RELATIVE_SENTENCE = 'the gate is a judgment relative to this config\'s lattice, not an absolute quality: when the lattice coarsens, the reference moves with it';
|
|
120
120
|
|
|
121
|
+
/**
|
|
122
|
+
* What `mesh-draft` says of a part whose config entry is not a lattice (`contour` or `auto`, issue #307): it drafts
|
|
123
|
+
* lattice entries only, so such a part was neither drafted nor compared. Said because the answer to "which mode comes
|
|
124
|
+
* out smaller" is not the one the names suggest: on one figure (rig-c/trailer 45becfb) an `auto` reduction held to a
|
|
125
|
+
* large declared bend kept two to three times the triangles of the outline this command drafts (AUTHORING §3, *Which
|
|
126
|
+
* mode comes out smaller*).
|
|
127
|
+
*/
|
|
128
|
+
export const NOT_COMPARED_SENTENCE = 'mesh-draft drafts lattice entries only, so it drafted nothing for this part and compared it with nothing; whether a lattice, or the outline mesh-draft drafts over one, comes out smaller is not known from this run — build the part once on a lattice, run mesh-draft, and compare the rig stage\'s mesh lines before keeping this entry';
|
|
129
|
+
|
|
130
|
+
/** A config entry `mesh-draft` did not draft because it is not a lattice (issue #307), in config order. */
|
|
131
|
+
export interface NotCompared {
|
|
132
|
+
part: string;
|
|
133
|
+
mode: 'contour' | 'auto';
|
|
134
|
+
note: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The entries of `meshes` that are not lattices, in config order, each with its note ({@link NOT_COMPARED_SENTENCE}). */
|
|
138
|
+
export function notCompared(meshes: CharacterConfig['meshes']): NotCompared[] {
|
|
139
|
+
const out: NotCompared[] = [];
|
|
140
|
+
for (const [part, spec] of Object.entries(meshes)) {
|
|
141
|
+
if ('grid' in spec) continue;
|
|
142
|
+
const mode = 'auto' in spec ? 'auto' : 'contour';
|
|
143
|
+
out.push({ part, mode, note: `its entry is "${mode}", not a lattice: ${NOT_COMPARED_SENTENCE}` });
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
|
|
121
148
|
/** The five readings, in the order the gate reads them. */
|
|
122
149
|
export const READINGS = ['bars', 'idle', 'bend inverted', 'bend worst', 'triangles'] as const;
|
|
123
150
|
export type Reading = (typeof READINGS)[number];
|
|
@@ -182,7 +209,7 @@ export function dustOf(image: Raster): DustReading {
|
|
|
182
209
|
if (islands.length === 0) return { keep: `its image has no pixel with alpha above ${ART_ALPHA}`, islands };
|
|
183
210
|
if (islands.length > 1 && islands[0] === islands[1]) return { keep: `its art is ${islands.length} islands (${islands.join(', ')} px) of which two share the largest count, so no one island is the largest`, islands };
|
|
184
211
|
const extra = islands.slice(1);
|
|
185
|
-
const pieces = extra.filter((n) => !(n
|
|
212
|
+
const pieces = extra.filter((n) => !isDust(n, art));
|
|
186
213
|
if (pieces.length > 0) {
|
|
187
214
|
return {
|
|
188
215
|
keep: `its art is ${islands.length} islands (${islands.join(', ')} px); ${pieces.length} beside the largest ${pieces.length === 1 ? 'is a real piece' : 'are real pieces'}, not dust (at most ${DUST_MAX_PX} px and at most ${pyRound(DUST_MAX_SHARE * 100, 6)} % of the part's ${art} art px), and the contour mode meshes one island`,
|
|
@@ -534,6 +561,8 @@ export interface PartRow {
|
|
|
534
561
|
export interface MeshDraftResult {
|
|
535
562
|
rules: FigureRules;
|
|
536
563
|
rows: PartRow[];
|
|
564
|
+
/** The entries not drafted because they are not lattices (issue #307), in config order. */
|
|
565
|
+
notCompared: NotCompared[];
|
|
537
566
|
written: string[];
|
|
538
567
|
}
|
|
539
568
|
|
|
@@ -810,6 +839,7 @@ export function meshDraftStage(input: MeshDraftInput, rigc: RigcRunner, checkBin
|
|
|
810
839
|
}
|
|
811
840
|
const configText = splicedConfig(text, drafted);
|
|
812
841
|
const rowsOut = [...rows.values()];
|
|
842
|
+
const others = notCompared(cfg.meshes);
|
|
813
843
|
const report = {
|
|
814
844
|
spec: MESH_DRAFT_SPEC,
|
|
815
845
|
rules: {
|
|
@@ -834,6 +864,9 @@ export function meshDraftStage(input: MeshDraftInput, rigc: RigcRunner, checkBin
|
|
|
834
864
|
gate: r.gate,
|
|
835
865
|
note: r.note,
|
|
836
866
|
})),
|
|
867
|
+
// Issue #307: present only when the config holds an entry that is not a lattice, so a config of lattices alone
|
|
868
|
+
// writes the bytes it wrote before the key existed.
|
|
869
|
+
...(others.length === 0 ? {} : { not_compared: others }),
|
|
837
870
|
};
|
|
838
871
|
mkdirSync(input.out, { recursive: true });
|
|
839
872
|
const written = [join(input.out, 'config.json'), join(input.out, MESH_DRAFT_REPORT)];
|
|
@@ -845,14 +878,15 @@ export function meshDraftStage(input: MeshDraftInput, rigc: RigcRunner, checkBin
|
|
|
845
878
|
for (const g of r.gate) log(` ${g.pass ? 'pass' : 'FAIL'} ${g.reading}: ${g.text}`);
|
|
846
879
|
if (r.mode === 'grid' && r.gate.length === 0) log(` ${r.note}`);
|
|
847
880
|
}
|
|
881
|
+
for (const o of others) log(` NOT COMPARED ${o.part}: ${o.note}`);
|
|
848
882
|
const nDrafted = rowsOut.filter((r) => r.mode === 'contour').length;
|
|
849
883
|
const tri = (pick: (r: PartRow) => number): number => rowsOut.reduce((a, r) => a + pick(r), 0);
|
|
850
|
-
log(`rig-parts mesh-draft: ${nDrafted} of ${rowsOut.length} lattice part(s) drafted; their triangles ${tri((r) => r.lattice.triangles)} -> ${tri((r) => (r.mode === 'contour' && r.candidate !== null ? r.candidate.triangles : r.lattice.triangles))}, the figure's overdraw over them ${report.figure_overdraw.lattice} -> ${report.figure_overdraw.written}${input.moreTriangles === true ? ` (${MORE_TRIANGLES_FLAG}: triangles reported, not a line)` : ''}; the drafted config's check: ${final.report.figures.PASS ? 'PASS' : 'FAIL'}`);
|
|
884
|
+
log(`rig-parts mesh-draft: ${nDrafted} of ${rowsOut.length} lattice part(s) drafted; their triangles ${tri((r) => r.lattice.triangles)} -> ${tri((r) => (r.mode === 'contour' && r.candidate !== null ? r.candidate.triangles : r.lattice.triangles))}, the figure's overdraw over them ${report.figure_overdraw.lattice} -> ${report.figure_overdraw.written}${input.moreTriangles === true ? ` (${MORE_TRIANGLES_FLAG}: triangles reported, not a line)` : ''}${others.length === 0 ? '' : `; ${others.length} part(s) not a lattice, not drafted and not compared: ${others.map((o) => `${o.part} (${o.mode})`).join(', ')}`}; the drafted config's check: ${final.report.figures.PASS ? 'PASS' : 'FAIL'}`);
|
|
851
885
|
if (resolve(configDir) !== resolve(input.out)) {
|
|
852
886
|
for (const r of relativePaths(raw)) log(` note: ${r.at} is "${r.value}", relative to the config's directory; the written config keeps the text, so it resolves against ${input.out} — copy the file beside the written config, or the written config beside it`);
|
|
853
887
|
}
|
|
854
888
|
log(`rig-parts mesh-draft: wrote ${written.join(' and ')}`);
|
|
855
|
-
return { rules, rows: rowsOut, written };
|
|
889
|
+
return { rules, rows: rowsOut, notCompared: others, written };
|
|
856
890
|
}
|
|
857
891
|
|
|
858
892
|
/**
|