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 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. The write line counts the
261
- entries the previous draft vouched for. Without --from nothing changes.
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
- const from = fromArg === undefined ? '' : `; --from ${fromArg}: ${written.vouched} ${written.vouched === 1 ? 'entry' : 'entries'} taken as the previous recipe's draft`;
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. The arms are the sleeve chains and
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 or the line on which its legs are one column. With no
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). The write line ends `--from <path>: n entries taken as the previous recipe's
1842
- draft`. `meshes.<part>` and `regions.<part>` are unchanged by `--from`: a mesh the
1843
- recipe drafts is replaced whole already, and one the two drafts agree at is not touched.
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`). The written config is the recipe's draft, not a verdict on it: on
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.0",
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": {
@@ -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(path: readonly string[], identity: (e: unknown) => string | null, drafted: readonly unknown[], plain: readonly unknown[], input: readonly unknown[], conflicts: Problem[], previous: readonly unknown[] | null): ClothEdit[] {
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 owned = [...D.keys()].filter((k) => !P.has(k) || canon(P.get(k)) !== canon(D.get(k)));
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; no flag or environment reaches it.
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 (v === null || s === undefined || !Array.isArray(b) || !Array.isArray(mine) || canon(c) === canon(b)) return;
236
- edits.push(...entryEdits(path, s.identity, b, b, mine, conflicts, prevAt(path)));
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
- for (const k of [...Object.keys(b), ...Object.keys(a).filter((k) => !(k in b))]) {
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
- const figure = joints === undefined ? null : figureLayerOf(P, hw, lwTagged, joints, { hip, chest, neck: neckAt });
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
- ): { part: PartRecord | null; arms: boolean; legs: boolean; note: string } | null {
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,