rig-parts 1.55.0 → 1.55.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli.ts +9 -5
- package/docs/AUTHORING.md +32 -7
- package/package.json +1 -1
- package/src/clothconfig.ts +78 -11
- package/src/propose.ts +76 -4
package/cli.ts
CHANGED
|
@@ -31,7 +31,7 @@ import { basisLines, branchSqueezeLine, checkProposal, compare, compareLines, DE
|
|
|
31
31
|
import { BASIS_FILE, BASIS_SPEC, basisFile, basisLine, coverageLines, serializeBasis } from './src/diagnostics.ts';
|
|
32
32
|
import { BEND_DEGREES, BEND_WORST_SHARE, DUST_MAX_PX, DUST_MAX_SHARE, IDLE_BOUND_PX, MESH_DRAFT_REPORT, MESH_DRAFT_SPEC, meshDraftStage, MORE_TRIANGLES_FLAG, relativeConfigPaths, TOLERANCE_FIGURE_DIVISOR } from './src/meshdraft.ts';
|
|
33
33
|
import { meshStatsStage } from './src/meshstats.ts';
|
|
34
|
-
import { chainsLeftBehind, clothConfigEdits, editPaths, previousCount, writtenClothConfig } from './src/clothconfig.ts';
|
|
34
|
+
import { chainsLeftBehind, clothConfigEdits, editPaths, plainCount, previousCount, writtenClothConfig } from './src/clothconfig.ts';
|
|
35
35
|
import { INPUT_CHECK_FILE, INPUT_CHECK_SPEC, PROVENANCE_FILE, REMESH_SPEC, remeshStage } from './src/remesh.ts';
|
|
36
36
|
import { RETUNE_FILE, RETUNE_SPEC, retuneStage } from './src/retune.ts';
|
|
37
37
|
import { checkPainting } from './src/paintcheck.ts';
|
|
@@ -257,8 +257,11 @@ usage:
|
|
|
257
257
|
entry equal to that previous recipe's draft is the recipe's, replaced
|
|
258
258
|
(or removed) as one equal to the draft without the recipe is; an entry
|
|
259
259
|
equal to none of the three drafts is still refused by name, its field
|
|
260
|
-
read against the previous recipe's draft.
|
|
261
|
-
|
|
260
|
+
read against the previous recipe's draft. An entry the previous recipe
|
|
261
|
+
drafted that the recipe now leaves plain (a tag whose recipe was
|
|
262
|
+
dropped) goes back to the draft without the recipe (issue #630). The
|
|
263
|
+
write line counts the entries the previous draft vouched for and the
|
|
264
|
+
entries returned to the plain draft. Without --from nothing changes.
|
|
262
265
|
|
|
263
266
|
rig-parts propose --parts <dir> --source <painting.png> --out <dir> --from-config <config.json>
|
|
264
267
|
Draw the config's CURRENT bones instead (<out>/render/landmarks_config.png
|
|
@@ -1429,7 +1432,7 @@ function cmdPropose(args: string[]): number {
|
|
|
1429
1432
|
// Issue #571: with --from, the same parts drafted a third time with the previous recipe.
|
|
1430
1433
|
const previous = previousCloth === null ? null : proposeWithBasis(P, joints, wristSnap, gridRule, previousCloth).proposal;
|
|
1431
1434
|
const edits = clothConfigEdits(drafted, plain, JSON.parse(text) as unknown, false, previous);
|
|
1432
|
-
return { at: writeArg, input: text, text: writtenClothConfig(clothArg, text, edits), paths: editPaths(edits), vouched: previousCount(edits), relative: relativeConfigPaths(dirname(resolve(clothArg)), JSON.parse(text) as Record<string, unknown>) };
|
|
1435
|
+
return { at: writeArg, input: text, text: writtenClothConfig(clothArg, text, edits), paths: editPaths(edits), vouched: previousCount(edits), returned: plainCount(edits), relative: relativeConfigPaths(dirname(resolve(clothArg)), JSON.parse(text) as Record<string, unknown>) };
|
|
1433
1436
|
})();
|
|
1434
1437
|
// Issue #572: the keyed chains CHAIN_LAG reads only once the recipe took its trees' idle keys — in the config written
|
|
1435
1438
|
// against the --cloth config with --write-config, else in the proposal against the draft without the recipe.
|
|
@@ -1467,7 +1470,8 @@ function cmdPropose(args: string[]): number {
|
|
|
1467
1470
|
printLint(P, prop, joints, [...draftLint, ...leftBehind.findings]);
|
|
1468
1471
|
console.log(basisLine(bfile, out));
|
|
1469
1472
|
if (written !== null && clothArg !== undefined) {
|
|
1470
|
-
|
|
1473
|
+
// Issue #630: the entries a recipe dropped since --from gives back to the draft without the recipe.
|
|
1474
|
+
const from = fromArg === undefined ? '' : `; --from ${fromArg}: ${written.vouched} ${written.vouched === 1 ? 'entry' : 'entries'} taken as the previous recipe's draft, ${written.returned} ${written.returned === 1 ? 'entry' : 'entries'} returned to the plain draft`;
|
|
1471
1475
|
console.log(
|
|
1472
1476
|
written.paths.length > 0
|
|
1473
1477
|
? `wrote ${written.at}: ${clothArg}'s text with ${written.paths.join(', ')} spliced in from the draft; every other byte as read${from}`
|
package/docs/AUTHORING.md
CHANGED
|
@@ -370,7 +370,17 @@ the larger of the x and y gaps, every joint of those limbs and the origins of
|
|
|
370
370
|
`hip`, `chest` and `neck` — and, when both legs are drafted, whose art breaks on the
|
|
371
371
|
line from `r_knee` to `l_knee` and on the line from `r_ankle` to `l_ankle` (the
|
|
372
372
|
reading `PAINTING_LEGS_APART` makes): a robe over both legs is one column there,
|
|
373
|
-
and would be split into legs it does not have.
|
|
373
|
+
and would be split into legs it does not have. Within 15 px is not on the art: each
|
|
374
|
+
cut the draft makes on the layer — the trunk's at `chest` and `neck`, each arm's at
|
|
375
|
+
its elbow, each leg's at its knee — must lie on that limb's own art within 1 px
|
|
376
|
+
along its joint line, read by assemble's own test (the run `ASSEMBLE_LIMB_JOINT_ON_ART`
|
|
377
|
+
refuses a cut without, each pixel the limb's whose bones pass nearest it), or the
|
|
378
|
+
draft is a config assemble refuses (issue #629, selftest `PR560`). A part a cloth
|
|
379
|
+
recipe names (`config.cloth.<tag>.part` or `.parts`) is no candidate either: it
|
|
380
|
+
keeps the recipe's mesh — a limb takes every pixel of its part, so cut into limbs
|
|
381
|
+
the recipe's trees would move no artwork — and a note names it and the recipe;
|
|
382
|
+
when it is the only part that holds the joints, `PROPOSE_FIGURE_LAYER` refuses it by
|
|
383
|
+
name (issue #631, `PR561`). The arms are the sleeve chains and
|
|
374
384
|
`arm_<s>` limbs and the legs the `leg_<s>` chains and limbs above, on that part,
|
|
375
385
|
and the trunk is a limb of its own, `trunk` — `trunk_hip` on `hip`, `trunk_chest`
|
|
376
386
|
on `chest` below the joint at chest's origin, `trunk_neck` on `neck` past the
|
|
@@ -387,7 +397,8 @@ Two topwear parts that both hold everything are decided by their opaque px in th
|
|
|
387
397
|
31x31 squares about those points (the square the 15 px tolerance reads), and the
|
|
388
398
|
note adds both counts; a tie is refused, `PROPOSE_FIGURE_LAYER`. A topwear part that
|
|
389
399
|
misses one gets no limb, keeps the topwear rule, and a `figure layer: …` note names
|
|
390
|
-
the first point it misses
|
|
400
|
+
the first point it misses, the line on which its legs are one column, or the cut
|
|
401
|
+
whose joint line misses its art, with the nearest pixel on that line and how far. With no
|
|
391
402
|
topwear part over 500 px nothing is said and nothing moves. A garment figure whose
|
|
392
403
|
tags carry its limbs is drafted byte for byte as before: the public examples'
|
|
393
404
|
proposals are unchanged. The fixture is `fixtures/mannequin.ts`: one `topwear`
|
|
@@ -1838,13 +1849,27 @@ recipe is: replaced by the draft, or removed where the draft no longer holds the
|
|
|
1838
1849
|
refused, `PROPOSE_WRITE_CONFIG_CONFLICT`, the detail reading "it equals neither the draft,
|
|
1839
1850
|
the draft without the recipe, nor the previous recipe's draft" and naming the first field
|
|
1840
1851
|
that differs from the previous recipe's draft (the entry a person most likely edited
|
|
1841
|
-
from).
|
|
1842
|
-
|
|
1843
|
-
|
|
1852
|
+
from). **A dropped recipe (issue #630).** A change can remove one tag's recipe and
|
|
1853
|
+
keep another's (`back hair` taken out of `cloth`, `bottomwear` left in). That tag's
|
|
1854
|
+
entries are then the draft without the recipe again, so every entry the previous
|
|
1855
|
+
recipe drafted and the recipe now leaves to the plain draft goes back to it: its plain
|
|
1856
|
+
chains and their tracks inserted (beside their drafted neighbours, as above), its trees
|
|
1857
|
+
and their constraints removed, its `meshes.<part>` (and any `regions.<part>`) replaced
|
|
1858
|
+
by the plain draft's — where the input holds that entry as the previous recipe's draft
|
|
1859
|
+
wrote it, or lacks it in a section it holds. A hand edit to one of those entries is
|
|
1860
|
+
refused by name as any other, `PROPOSE_WRITE_CONFIG_CONFLICT` (`config.meshes.hair_back`
|
|
1861
|
+
in CL221, the field read against the previous recipe's draft); the tags the change keeps
|
|
1862
|
+
do not move. Before #630 the write exited 0 with the dropped tag's old mesh kept and its
|
|
1863
|
+
plain chains missing (CL222 keeps that writer as the selftest's mutant). The write line
|
|
1864
|
+
ends `--from <path>: n entries taken as the previous recipe's draft, m entries returned
|
|
1865
|
+
to the plain draft`. Apart from a dropped recipe's entries, `meshes.<part>` and
|
|
1866
|
+
`regions.<part>` are unchanged by `--from`: a mesh the recipe drafts is replaced whole
|
|
1867
|
+
already, and one all three drafts agree at is not touched.
|
|
1844
1868
|
Without `--from` every rule and every byte is the writer's before #571; with a `--from`
|
|
1845
1869
|
whose recipe equals `--cloth`'s the previous draft is the draft, and the written config
|
|
1846
1870
|
is byte for byte the one written without it (CL209–CL212 hold each on the cloth fixture,
|
|
1847
|
-
CL213–CL215 on `wind
|
|
1871
|
+
CL213–CL215 on `wind`; CL220–CL223 the dropped recipe on the cloth fixture, CL224 on
|
|
1872
|
+
`wind`, where dropping `back hair` writes the run's proposal value for value). The written config is the recipe's draft, not a verdict on it: on
|
|
1848
1873
|
`wind`, `links` 3 → 4 writes the skirt entries of its proposal value for value, and that
|
|
1849
1874
|
draft fails `CHECK_TIP_OVER_ROOT` on the skirt in `build`; 3 → 2 builds green (CL215). A `meshes.<part>` the
|
|
1850
1875
|
recipe drafts is still the draft's whole, as a hand-edited lattice does not survive
|
|
@@ -3238,7 +3263,7 @@ says why each requirement exists and what breaks without it.
|
|
|
3238
3263
|
| <a id="PROPOSE_FACE_PRESENT"></a>`PROPOSE_FACE_PRESENT` | no part comes from a `face` layer, and the face-less fallback cannot derive a face box either: it needs the head run's `neck` and at least one head-run `front hair`/`back hair` part whose top is above the neck's, and the message names the half that is missing. With both, `propose` does not refuse — it derives the box and says so in its first note (§5) | `assemble.plan`: take the face, or the head run's hair and neck |
|
|
3239
3264
|
| <a id="PROPOSE_CUT_FROM_RECORDED"></a>`PROPOSE_CUT_FROM_RECORDED` | two parts share one layer and none of them records `cut_from`: a `parts.json` an `assemble` before 1.6.0 wrote with `assemble.cuts`. The records are in draw order, so which is the plan part and which the piece cannot be read off the file; the message names the parts and the layer. Every other reader still takes the file | `--parts`: re-run assemble (1.6.0+) to record `cut_from` |
|
|
3240
3265
|
| <a id="PROPOSE_LIMB_PARTS"></a>`PROPOSE_LIMB_PARTS` | `parts.json` holds a standalone or incomplete limb part `config.limbs` cut at its joints (issues #349, #440) that cannot be reassembled into the source part `of` (a missing segment or `cap` record, or `of` still present beside the limb parts); a complete `assemble` output with `config.limbs` reconstructs its pre-limb parts in the same run | `--parts` a complete `assemble` output (every segment of each limb, with its `cap` record) or a build assembled with `config.limbs` left out; `propose --from-config` reads the config's bones on either build |
|
|
3241
|
-
| <a id="PROPOSE_FIGURE_LAYER"></a>`PROPOSE_FIGURE_LAYER` | with `--keypoints` (issue #577): a limb is drafted from the figure layer (no handwear or legwear part over 500 px for it), and two or more `topwear` parts each hold every joint and the trunk bones' origins within 15 px with the same opaque px in the 31x31 squares about them, so nothing says which is the figure (§3, *A figure of one material*) | leave the others out of `assemble.plan`, or give the arms and legs their own handwear and legwear layers |
|
|
3266
|
+
| <a id="PROPOSE_FIGURE_LAYER"></a>`PROPOSE_FIGURE_LAYER` | with `--keypoints` (issue #577): a limb is drafted from the figure layer (no handwear or legwear part over 500 px for it), and two or more `topwear` parts each hold every joint and the trunk bones' origins within 15 px with the same opaque px in the 31x31 squares about them, so nothing says which is the figure; or the only `topwear` parts that hold them are named by a cloth recipe (`config.cloth.<tag>.part` or `.parts`, issue #631), whose mesh a limb would take (§3, *A figure of one material*) | leave the others out of `assemble.plan`, or give the arms and legs their own handwear and legwear layers; for a recipe's part, take it out of the recipe |
|
|
3242
3267
|
| <a id="PROPOSE_CLOTH_DRAFTED"></a>`PROPOSE_CLOTH_DRAFTED` | with `--cloth` (issue #364): the config's `cloth` block holds a recipe for a tag the proposer drafts no chain on — no part has that tag, or every `back hair` part ends above the shoulders (a bun bone, no chain). A `bottomwear` recipe with no `bottomwear` part names every `topwear` part shaped like a skirt (issue #402) | leave that recipe out, or assemble the layer it is for, or declare the named part in `cloth.bottomwear.part` |
|
|
3243
3268
|
| <a id="PROPOSE_CLOTH_PART"></a>`PROPOSE_CLOTH_PART` | with `--cloth` (issues #402, #437): `cloth.bottomwear.part` (a string) names no part, a part or cut piece whose source is not `topwear` (a `bottomwear` part takes the recipe by its tag already), or a target whose box ends above the hip + 0.25 face heights, the topwear rule's waist line; or `cloth.<tag>.parts` (or `part` as a list, issue #437) names a part not in `parts.json`, a part no link of the drafted tree crosses (`spanCrosses == 0`), or parts out of root-to-tip depth order along the chain | name the `topwear` part or its cut skirt piece that holds the skirt (or leave `part` out), or list only parts in `parts.json` that the drafted tree links cross in root-to-tip order |
|
|
3244
3269
|
| <a id="PROPOSE_WRITE_CONFIG_FRESH"></a>`PROPOSE_WRITE_CONFIG_FRESH` | with `--write-config` (issue #537): the out path exists, or is a file the run writes itself (`proposal.json`, `basis.json` under `--out`); the config is written to a new file, never over the `--cloth` config or an earlier one | name a file that does not exist yet, beside the `--cloth` config |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rig-parts",
|
|
3
|
-
"version": "1.55.
|
|
3
|
+
"version": "1.55.1",
|
|
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": {
|
package/src/clothconfig.ts
CHANGED
|
@@ -44,6 +44,11 @@ export interface ClothEdit extends ConfigEdit {
|
|
|
44
44
|
label: string;
|
|
45
45
|
/** Issue #571: the input's entry was the previous recipe's draft (`--from`), and only that made it the recipe's. */
|
|
46
46
|
previous?: true;
|
|
47
|
+
/**
|
|
48
|
+
* Issue #630: the entry is written back to the draft without the recipe (`--from`): the previous recipe drafted it
|
|
49
|
+
* and the recipe now does not — a tag whose recipe was dropped while another was kept.
|
|
50
|
+
*/
|
|
51
|
+
plain?: true;
|
|
47
52
|
}
|
|
48
53
|
|
|
49
54
|
/**
|
|
@@ -105,8 +110,23 @@ function shown(v: unknown): string {
|
|
|
105
110
|
* recipe's as `I` equal to `P` is — an owned name is replaced, and a name in `V` and not in `D` is removed — and a
|
|
106
111
|
* conflict is an `I` equal to none of `D`, `P` and `V`, its first differing field read against `V` where `V` holds the
|
|
107
112
|
* name. Without `previous` every rule, and every word of the refusal, is the one above.
|
|
113
|
+
*
|
|
114
|
+
* Issue #630: with `V`, a name `D` holds equal to `P` that `V` lacks or holds differently — an entry the previous
|
|
115
|
+
* recipe drafted (or replaced) and this one leaves to the draft without the recipe, as for a tag whose recipe was
|
|
116
|
+
* dropped while another was kept — is owned too: inserted where the input lacks it, replaced where the input equals
|
|
117
|
+
* `V`, and a conflict where it equals neither. Such an edit carries `plain`. `keepDropped` is the selftest's mutant
|
|
118
|
+
* only (the writer before #630, which never owned it); no flag or environment reaches it.
|
|
108
119
|
*/
|
|
109
|
-
function entryEdits(
|
|
120
|
+
function entryEdits(
|
|
121
|
+
path: readonly string[],
|
|
122
|
+
identity: (e: unknown) => string | null,
|
|
123
|
+
drafted: readonly unknown[],
|
|
124
|
+
plain: readonly unknown[],
|
|
125
|
+
input: readonly unknown[],
|
|
126
|
+
conflicts: Problem[],
|
|
127
|
+
previous: readonly unknown[] | null,
|
|
128
|
+
keepDropped = false,
|
|
129
|
+
): ClothEdit[] {
|
|
110
130
|
const section = path.join('.');
|
|
111
131
|
const D = byName(drafted, identity, 'drafted', section);
|
|
112
132
|
const P = byName(plain, identity, 'plain', section);
|
|
@@ -116,7 +136,10 @@ function entryEdits(path: readonly string[], identity: (e: unknown) => string |
|
|
|
116
136
|
keys.forEach((k, i) => {
|
|
117
137
|
if (k !== null) where.set(k, [...(where.get(k) ?? []), i]);
|
|
118
138
|
});
|
|
119
|
-
const
|
|
139
|
+
const recipes = new Set([...D.keys()].filter((k) => !P.has(k) || canon(P.get(k)) !== canon(D.get(k))));
|
|
140
|
+
// Issue #630: a plain entry the previous recipe drafted differently or took away, which this recipe leaves plain.
|
|
141
|
+
const returned = new Set(V === null || keepDropped ? [] : [...D.keys()].filter((k) => !recipes.has(k) && (!V.has(k) || canon(V.get(k)) !== canon(D.get(k)))));
|
|
142
|
+
const owned = [...D.keys()].filter((k) => recipes.has(k) || returned.has(k));
|
|
120
143
|
const removed = [...P.keys(), ...(V === null ? [] : [...V.keys()].filter((k) => !P.has(k)))].filter((k) => !D.has(k));
|
|
121
144
|
const replace: string[] = [];
|
|
122
145
|
const remove: string[] = [];
|
|
@@ -166,7 +189,7 @@ function entryEdits(path: readonly string[], identity: (e: unknown) => string |
|
|
|
166
189
|
vouched.add(k);
|
|
167
190
|
} else conflict(k, at[0], false);
|
|
168
191
|
}
|
|
169
|
-
const mark = (k: string): { previous?: true } => (vouched.has(k) ? { previous: true } : {});
|
|
192
|
+
const mark = (k: string): { previous?: true; plain?: true } => ({ ...(vouched.has(k) ? { previous: true as const } : {}), ...(returned.has(k) ? { plain: true as const } : {}) });
|
|
170
193
|
const edits: ClothEdit[] = [];
|
|
171
194
|
const work = [...keys];
|
|
172
195
|
for (const k of replace) edits.push({ path: [...path, work.indexOf(k)], value: D.get(k), label: `${section}["${k}"] replaced`, ...mark(k) });
|
|
@@ -186,7 +209,7 @@ function entryEdits(path: readonly string[], identity: (e: unknown) => string |
|
|
|
186
209
|
break;
|
|
187
210
|
}
|
|
188
211
|
}
|
|
189
|
-
edits.push({ path: [...path, pos], value: D.get(k), insert: true, label: `${section}["${k}"] inserted
|
|
212
|
+
edits.push({ path: [...path, pos], value: D.get(k), insert: true, label: `${section}["${k}"] inserted`, ...mark(k) });
|
|
190
213
|
work.splice(pos, 0, k);
|
|
191
214
|
}
|
|
192
215
|
return edits;
|
|
@@ -206,10 +229,20 @@ function entryEdits(path: readonly string[], identity: (e: unknown) => string |
|
|
|
206
229
|
* object entry (`meshes.<part>`, `regions.<part>`) the recipe drafts is replaced whole already, so `previous` changes
|
|
207
230
|
* nothing there. Null — no `--from` — is the writer before #571, rule for rule and byte for byte.
|
|
208
231
|
*
|
|
232
|
+
* Issue #630: where the two drafts agree and the previous draft differs — what the previous recipe drafted and this one
|
|
233
|
+
* leaves to the draft without the recipe, a tag whose recipe was dropped while another was kept — the entry goes back
|
|
234
|
+
* to the plain draft: in an array section by {@link entryEdits}' rule, and at any other entry (`meshes.<part>`,
|
|
235
|
+
* `regions.<part>`, a section the input holds whole) when the input equals the previous draft there or lacks the entry
|
|
236
|
+
* in an object it holds; an input equal to neither the draft nor the previous draft there is a hand edit, refused by
|
|
237
|
+
* name. Each such edit that writes the plain draft's value carries `plain` (a removal does not, as an array name the
|
|
238
|
+
* previous recipe drafted and the plain draft lacks is removed without it). A section the input lacks is left to the
|
|
239
|
+
* input, as before.
|
|
240
|
+
*
|
|
209
241
|
* `whole` is the selftest's mutant only (issue #552): `bones`, `constraints` and `motion.tracks` replaced whole, as
|
|
210
|
-
* the #537 writer did;
|
|
242
|
+
* the #537 writer did; `keepDropped` is the selftest's mutant only (issue #630): the writer before it, which leaves a
|
|
243
|
+
* dropped recipe's entries as the previous draft wrote them. No flag or environment reaches either.
|
|
211
244
|
*/
|
|
212
|
-
export function clothConfigEdits(drafted: Proposal, plain: Proposal, input: unknown, whole = false, previous: Proposal | null = null): ClothEdit[] {
|
|
245
|
+
export function clothConfigEdits(drafted: Proposal, plain: Proposal, input: unknown, whole = false, previous: Proposal | null = null, keepDropped = false): ClothEdit[] {
|
|
213
246
|
const edits: ClothEdit[] = [];
|
|
214
247
|
const conflicts: Problem[] = [];
|
|
215
248
|
const d = JSON.parse(JSON.stringify(drafted)) as Record<string, unknown>;
|
|
@@ -224,27 +257,56 @@ export function clothConfigEdits(drafted: Proposal, plain: Proposal, input: unkn
|
|
|
224
257
|
const one = (path: readonly string[], a: unknown, b: unknown): void => {
|
|
225
258
|
const s = arrayOf(path);
|
|
226
259
|
const mine = valueAt(input, path);
|
|
227
|
-
if (s !== undefined && Array.isArray(b) && (a === undefined || Array.isArray(a)) && Array.isArray(mine)) edits.push(...entryEdits(path, s.identity, b, Array.isArray(a) ? a : [], mine, conflicts, prevAt(path)));
|
|
260
|
+
if (s !== undefined && Array.isArray(b) && (a === undefined || Array.isArray(a)) && Array.isArray(mine)) edits.push(...entryEdits(path, s.identity, b, Array.isArray(a) ? a : [], mine, conflicts, prevAt(path), keepDropped));
|
|
228
261
|
else edits.push({ path, value: b, label: path.join('.') });
|
|
229
262
|
};
|
|
230
263
|
// Issue #571: an array section the two drafts agree at, where the previous draft differs — its entries the previous recipe drafted and this one does not.
|
|
264
|
+
// Issue #630: and any other entry there, which goes back to the draft (the plain draft's value, the two agreeing).
|
|
231
265
|
const prevOnly = (path: readonly string[], b: unknown): void => {
|
|
266
|
+
if (v === null) return;
|
|
232
267
|
const s = arrayOf(path);
|
|
233
268
|
const mine = valueAt(input, path);
|
|
234
269
|
const c = valueAt(v, path);
|
|
235
|
-
if (
|
|
236
|
-
|
|
270
|
+
if (canon(c) === canon(b)) return;
|
|
271
|
+
if (s !== undefined && (Array.isArray(b) || b === undefined) && Array.isArray(mine)) {
|
|
272
|
+
const drafts = Array.isArray(b) ? b : [];
|
|
273
|
+
edits.push(...entryEdits(path, s.identity, drafts, drafts, mine, conflicts, prevAt(path), keepDropped));
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (keepDropped || s !== undefined) return;
|
|
277
|
+
// The entry's own object must be the input's: a section the input lacks is the input's to lack, as before #630.
|
|
278
|
+
const holder = path.length === 1 ? input : valueAt(input, path.slice(0, -1));
|
|
279
|
+
if (!isPlain(holder) || (path.length === 1 && mine === undefined)) return;
|
|
280
|
+
const where = `config.${path.join('.')}`;
|
|
281
|
+
if (canon(mine) === canon(b)) return;
|
|
282
|
+
if (mine === undefined || canon(mine) === canon(c)) {
|
|
283
|
+
edits.push({ path, value: b, label: `${path.join('.')} ${b === undefined ? 'removed' : mine === undefined ? 'inserted' : 'replaced'}`, ...(b !== undefined ? { plain: true as const } : {}), ...(mine !== undefined ? { previous: true as const } : {}) });
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
const field = firstField(mine, c);
|
|
287
|
+
const at = (e: unknown, f: string): string => (isPlain(e) && e[f] !== undefined ? `${f} ${shown(e[f])}` : `no ${f}`);
|
|
288
|
+
conflicts.push({
|
|
289
|
+
code: 'PROPOSE_WRITE_CONFIG_CONFLICT',
|
|
290
|
+
object: where,
|
|
291
|
+
detail: `${path.join('.')} is the previous recipe's, the recipe now leaves it to the draft without the recipe, and it was changed by hand: it equals neither the draft nor the previous recipe's draft: it holds ${field === null ? shown(mine) : at(mine, field)} where the previous recipe's draft writes ${c === undefined ? 'no such entry' : field === null ? shown(c) : at(c, field)} and the draft writes ${b === undefined ? 'no such entry' : field === null ? shown(b) : at(b, field)}, so the write would drop the hand edit; take the draft by removing the hand edit (or the entry), or keep the recipe for that part (its config.cloth block)`,
|
|
292
|
+
});
|
|
293
|
+
};
|
|
294
|
+
const prevKeys = (section: string, have: readonly string[]): string[] => {
|
|
295
|
+
const pv = v === null ? undefined : v[section];
|
|
296
|
+
return isPlain(pv) ? Object.keys(pv).filter((k) => !have.includes(k)) : [];
|
|
237
297
|
};
|
|
238
298
|
for (const section of PROPOSAL_CONFIG_SECTIONS) {
|
|
239
299
|
const a = p[section];
|
|
240
300
|
const b = d[section];
|
|
241
301
|
if (JSON.stringify(a) === JSON.stringify(b)) {
|
|
242
|
-
if (isPlain(b)) for (const k of Object.keys(b)) prevOnly([section, k], b[k]);
|
|
302
|
+
if (isPlain(b)) for (const k of [...Object.keys(b), ...prevKeys(section, Object.keys(b))]) prevOnly([section, k], b[k]);
|
|
303
|
+
else if (b === undefined && isPlain(v === null ? undefined : v[section])) for (const k of prevKeys(section, [])) prevOnly([section, k], undefined);
|
|
243
304
|
else prevOnly([section], b);
|
|
244
305
|
continue;
|
|
245
306
|
}
|
|
246
307
|
if (isPlain(a) && isPlain(b)) {
|
|
247
|
-
|
|
308
|
+
const keys = [...Object.keys(b), ...Object.keys(a).filter((k) => !(k in b))];
|
|
309
|
+
for (const k of [...keys, ...prevKeys(section, keys)]) {
|
|
248
310
|
if (JSON.stringify(a[k]) !== JSON.stringify(b[k])) one([section, k], a[k], b[k]);
|
|
249
311
|
else prevOnly([section, k], b[k]);
|
|
250
312
|
}
|
|
@@ -278,6 +340,11 @@ export function previousCount(edits: readonly ClothEdit[]): number {
|
|
|
278
340
|
return edits.filter((e) => e.previous === true).length;
|
|
279
341
|
}
|
|
280
342
|
|
|
343
|
+
/** Issue #630: how many edits wrote an entry back to the draft without the recipe — a dropped recipe's entries. */
|
|
344
|
+
export function plainCount(edits: readonly ClothEdit[]): number {
|
|
345
|
+
return edits.filter((e) => e.plain === true).length;
|
|
346
|
+
}
|
|
347
|
+
|
|
281
348
|
/** What each edit names, in order: what the write line prints. */
|
|
282
349
|
export function editPaths(edits: readonly ClothEdit[]): string[] {
|
|
283
350
|
return edits.map((e) => e.label);
|
package/src/propose.ts
CHANGED
|
@@ -154,7 +154,7 @@ import { configRoles, type RoleOf } from './structure.ts';
|
|
|
154
154
|
import { AUTHOR_NO_ROLE, isAuthorTag } from './tags.ts';
|
|
155
155
|
import { TURN_BONE, TURN_CARRIER_TAG, TURN_PLACES, TURN_RIDER_OFFSET, TURN_RIDER_TAGS, turnFractions, turnNearest, turnPivot, turnRiderDepth, type TurnSpec } from './turn.ts';
|
|
156
156
|
import { edgeLength } from './length.ts';
|
|
157
|
-
import { bonePlaces, jointLine, limbCrossing, limbPolyline, limbRun, limbWidth, nearestLimb, nearestOnLine, offLineFigure, type Pt } from './limbs.ts';
|
|
157
|
+
import { bonePlaces, jointLine, type JointLine, limbCrossing, limbPolyline, limbRun, limbWidth, nearestLimb, nearestOnLine, offLineFigure, type Pt } from './limbs.ts';
|
|
158
158
|
import { type BranchSqueezeFinding, branchSqueezes, chainSqueezes, type SqueezeFinding } from './squeeze.ts';
|
|
159
159
|
import { backHairMasses, type BackHairMass, backHairMassTrees, INFLUENCE_RADIUS_CELLS, RECIPE_BACK_HAIR_CHAINS, RECIPE_LENGTH_RATIO, chainConstraints, clothNote, type ClothRecipe, type ClothRecipes, type ClothTarget, CLOTH_TARGETS, draftTrees, spanCrosses, swingName, type Tail, tailOverlap, type TailOverlap, type TailSpan, treeConstraints, type TreeChain, type TreeInput, type TreeMassInput } from './cloth.ts';
|
|
160
160
|
import { TEXTURE_STRETCH_CEILING } from './check.ts';
|
|
@@ -2588,7 +2588,18 @@ export function proposeWithBasis(
|
|
|
2588
2588
|
// legwear part carries its limbs. With joints, when the tagged route has no part for a complete limb, the one topwear
|
|
2589
2589
|
// part that holds every such limb's joints is the figure layer: the trunk and those limbs split it by the cut.
|
|
2590
2590
|
const lwTagged = P.byTag('legwear', undefined, true).filter((p) => p.opaque_px > 500);
|
|
2591
|
-
|
|
2591
|
+
// Issue #631: a part a cloth recipe names is that recipe's mesh, so it is never the figure layer.
|
|
2592
|
+
const clothNamed = new Map<string, string>();
|
|
2593
|
+
for (const t of CLOTH_TARGETS) {
|
|
2594
|
+
const r = cloth?.[t];
|
|
2595
|
+
if (r === undefined) continue;
|
|
2596
|
+
for (const [field, v] of [['part', r.part], ['parts', r.parts]] as const) {
|
|
2597
|
+
if (v === undefined) continue;
|
|
2598
|
+
for (const n of typeof v === 'string' ? [v] : v) if (!clothNamed.has(n)) clothNamed.set(n, `config.cloth.${JSON.stringify(t)}.${field}`);
|
|
2599
|
+
}
|
|
2600
|
+
}
|
|
2601
|
+
const figure = joints === undefined ? null : figureLayerOf(P, hw, lwTagged, joints, { hip, chest, neck: neckAt }, clothNamed);
|
|
2602
|
+
if (figure !== null) notes.push(...figure.cloth);
|
|
2592
2603
|
if (figure !== null && figure.part === null) notes.push(figure.note);
|
|
2593
2604
|
const figPart = figure === null ? null : figure.part;
|
|
2594
2605
|
if (figure !== null && figPart !== null) {
|
|
@@ -3944,6 +3955,14 @@ function legsFromJoints(P: PartSet, lw: readonly PartRecord[], joints: RigJoints
|
|
|
3944
3955
|
* by `linePixels`): a garment hung over both legs is one column there, and
|
|
3945
3956
|
* would be split into legs it does not have. Only `topwear`: `bottomwear`
|
|
3946
3957
|
* would split a skirt into legs, and the head run's tags carry the head;
|
|
3958
|
+
* - **and on the art at every cut** (issue #629): the trunk's at `chest` and
|
|
3959
|
+
* `neck`, each arm's at its elbow, each leg's at its knee, read by
|
|
3960
|
+
* assemble's own test (`ASSEMBLE_LIMB_JOINT_ON_ART`, {@link limbRun} on the
|
|
3961
|
+
* joint line) — the 15 px above does not put a joint line on the art;
|
|
3962
|
+
* - **not a part a cloth recipe names** (issue #631, `clothNamed`: part name
|
|
3963
|
+
* to the field naming it): it keeps the recipe's mesh, and a note says so;
|
|
3964
|
+
* when only such parts hold the joints, `PROPOSE_FIGURE_LAYER` refuses them
|
|
3965
|
+
* by name — cut into limbs, the recipe's trees would move no artwork;
|
|
3947
3966
|
* - **one candidate** is the figure layer;
|
|
3948
3967
|
* - **several**: the one with the most opaque px in the
|
|
3949
3968
|
* `(2 x WRIST_ON_PART_PX + 1)`-square about each of those points, summed (the
|
|
@@ -3962,7 +3981,8 @@ function figureLayerOf(
|
|
|
3962
3981
|
lw: readonly PartRecord[],
|
|
3963
3982
|
joints: RigJoints,
|
|
3964
3983
|
trunk: { hip: [number, number]; chest: [number, number]; neck: [number, number] },
|
|
3965
|
-
|
|
3984
|
+
clothNamed: ReadonlyMap<string, string>,
|
|
3985
|
+
): { part: PartRecord | null; arms: boolean; legs: boolean; note: string; cloth: string[] } | null {
|
|
3966
3986
|
const J = joints.joints;
|
|
3967
3987
|
const given = (a: KeypointName, b: KeypointName, c: KeypointName): boolean => {
|
|
3968
3988
|
const p = J[a].at;
|
|
@@ -3995,13 +4015,64 @@ function figureLayerOf(
|
|
|
3995
4015
|
return `${p.name}'s art does not break on the line from ${a} to ${b}: the legs are one column there, as a garment over both legs is`;
|
|
3996
4016
|
}
|
|
3997
4017
|
}
|
|
4018
|
+
return cutOffArt(p);
|
|
4019
|
+
};
|
|
4020
|
+
// Issue #629: each cut the draft would make on the layer — the trunk's at chest and neck, each arm's at its elbow,
|
|
4021
|
+
// each leg's at its knee — read as assemble's cap reads it (`cutLimbs`, src/assemble.ts): the joint line through the
|
|
4022
|
+
// lower bone's origin ({@link jointLine}), and the run of the limb's own art along it within 1 px ({@link limbRun}),
|
|
4023
|
+
// each pixel the limb's whose polyline passes nearest it ({@link nearestLimb}), the limbs in the order the draft
|
|
4024
|
+
// writes them. The points above lying within 15 px of the art does not put the joint line on it.
|
|
4025
|
+
const cutOffArt = (p: PartRecord): string | null => {
|
|
4026
|
+
const m = P.alpha(p);
|
|
4027
|
+
const box = [0, 0, P.W - 1, P.H - 1] as const;
|
|
4028
|
+
const arms = armSides.length > 0 ? (armsFromJoints(P, [p], joints, null, false).arms ?? []) : [];
|
|
4029
|
+
const legs = legSides.length > 0 ? (legsFromJoints(P, [p], joints).chains ?? []) : [];
|
|
4030
|
+
const [h, c, n] = [rnd(trunk.hip), rnd(trunk.chest), rnd(trunk.neck)];
|
|
4031
|
+
const limbs: Array<{ limb: string; poly: Pt[]; cuts: Array<{ joint: string; line: JointLine | null }> }> = [
|
|
4032
|
+
{ limb: 'trunk', poly: [h, c, n], cuts: [{ joint: 'chest', line: jointLine(h, c, n) }, { joint: 'neck', line: jointLine(c, n, null) }] },
|
|
4033
|
+
...arms.map((a) => {
|
|
4034
|
+
const [s, e, t] = [rnd(a.shoulder), rnd(a.elbow), rnd(a.tip)];
|
|
4035
|
+
return { limb: `arm_${a.side}`, poly: [s, e, t], cuts: [{ joint: `${a.side}_elbow`, line: jointLine(s, e, t) }] };
|
|
4036
|
+
}),
|
|
4037
|
+
...legs.map((g) => {
|
|
4038
|
+
const [hp, k, an] = [rnd(g.hip), rnd(g.knee), rnd(g.ankle)];
|
|
4039
|
+
return { limb: `leg_${g.side}`, poly: [hp, k, an], cuts: [{ joint: `${g.side}_knee`, line: jointLine(hp, k, an) }] };
|
|
4040
|
+
}),
|
|
4041
|
+
];
|
|
4042
|
+
const polys = limbs.map((l) => l.poly);
|
|
4043
|
+
for (const [i, l] of limbs.entries()) {
|
|
4044
|
+
const art = (x: number, y: number): boolean => x >= 0 && y >= 0 && x < P.W && y < P.H && m.data[y * P.W + x] === 1 && nearestLimb(polys, x, y) === i;
|
|
4045
|
+
for (const { joint, line } of l.cuts) {
|
|
4046
|
+
// A cut with no joint line is assemble's ASSEMBLE_LIMB_JOINT, named there; this reads only where the line is.
|
|
4047
|
+
if (line === null || limbRun(line, art, box) !== null) continue;
|
|
4048
|
+
return `${p.name}'s art is not on the ${joint} cut of ${l.limb}: the joint at [${line.at.join(', ')}] lies on none of ${l.limb}'s art within 1 px along its joint line (${offLineFigure(nearestOnLine(line, art, box))}), where assemble refuses the cut (ASSEMBLE_LIMB_JOINT_ON_ART)`;
|
|
4049
|
+
}
|
|
4050
|
+
}
|
|
3998
4051
|
return null;
|
|
3999
4052
|
};
|
|
4000
4053
|
const read = tw.map((p) => ({ p, miss: misses(p) }));
|
|
4001
|
-
const holds = read.filter((r) => r.miss === null).map((r) => r.p);
|
|
4002
4054
|
const what = `every joint of ${limbNames.join(', ')} and the origins of hip, chest and neck`;
|
|
4055
|
+
// Issue #631: a part a cloth recipe names explicitly is that recipe's mesh; a limb takes every pixel of its part, so as
|
|
4056
|
+
// the figure layer its mesh would be dropped and the recipe's trees would control no artwork. It is no candidate.
|
|
4057
|
+
const named = read.filter((r) => r.miss === null && clothNamed.has(r.p.name)).map((r) => r.p);
|
|
4058
|
+
const holds = read.filter((r) => r.miss === null && !clothNamed.has(r.p.name)).map((r) => r.p);
|
|
4059
|
+
const recipeOf = (p: PartRecord): string => clothNamed.get(p.name) as string;
|
|
4060
|
+
if (named.length > 0 && holds.length === 0) {
|
|
4061
|
+
throw new PartsError([
|
|
4062
|
+
{
|
|
4063
|
+
code: 'PROPOSE_FIGURE_LAYER',
|
|
4064
|
+
object: `${named.length === 1 ? 'part' : 'parts'} ${named.map((p) => `"${p.name}"`).join(', ')} (topwear)`,
|
|
4065
|
+
detail: `${why}, and only ${named.length === 1 ? 'it holds' : 'they hold'} within ${WRIST_ON_PART_PX} px ${what}, but ${named.map((p) => `${recipeOf(p)} names "${p.name}"`).join(' and ')}: a cloth recipe's part keeps the recipe's mesh, and a limb takes every pixel of its part, so cut into limbs the recipe's trees would control no artwork (issue #631); a figure layer no cloth recipe names is required — give the arms and legs their own handwear and legwear layers, or take the part out of the recipe`,
|
|
4066
|
+
},
|
|
4067
|
+
]);
|
|
4068
|
+
}
|
|
4069
|
+
const cloth = named.map(
|
|
4070
|
+
(p) =>
|
|
4071
|
+
`${p.name} (${p.from}): not the figure layer, though its art holds within ${WRIST_ON_PART_PX} px ${what} — ${recipeOf(p)} names it, so it keeps the cloth recipe's mesh: a limb takes every pixel of its part, and cut into limbs the recipe's trees would control no artwork (issue #631)`,
|
|
4072
|
+
);
|
|
4003
4073
|
if (holds.length === 0) {
|
|
4004
4074
|
return {
|
|
4075
|
+
cloth,
|
|
4005
4076
|
part: null,
|
|
4006
4077
|
arms: false,
|
|
4007
4078
|
legs: false,
|
|
@@ -4033,6 +4104,7 @@ function figureLayerOf(
|
|
|
4033
4104
|
const fig = scored[0].p;
|
|
4034
4105
|
const over = scored.length > 1 ? `; of the ${scored.length} topwear parts that hold them it has the most opaque px in the ${2 * half + 1}x${2 * half + 1} squares about those points, ${scored.map((c) => `${c.p.name} ${c.n}`).join(', ')}` : '';
|
|
4035
4106
|
return {
|
|
4107
|
+
cloth,
|
|
4036
4108
|
part: fig,
|
|
4037
4109
|
arms: armSides.length > 0,
|
|
4038
4110
|
legs: legSides.length > 0,
|