@rig-c/parts 1.5.4 → 1.6.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/src/propose.ts CHANGED
@@ -43,7 +43,13 @@
43
43
  * including every `painting:` patch (`assemble.patches`), which has no tag,
44
44
  * so no rule above ever reads it: it is never a mesh or a chain, it moves
45
45
  * no bone the rules place, and the note says the bone is the config's to
46
- * name in `regions.<name>`.
46
+ * name in `regions.<name>`. A cut's piece (`assemble.cuts`, recorded with
47
+ * `cut_from`) goes the same way (issue #230): it shares its base's tag, and
48
+ * the rules that run once per part of a tag would name the base's bones and
49
+ * chains a second time. The one exception is the sleeve rule, which names
50
+ * its chains by side and takes a cut sleeve as a blob of its own (#199). A
51
+ * `parts.json` sharing a layer between parts with no `cut_from` (an
52
+ * assemble before 1.5.6) is refused, `PROPOSE_CUT_FROM_RECORDED`.
47
53
  *
48
54
  * What it cannot know is written into `notes` when it guessed, and otherwise
49
55
  * left to the person correcting: things hidden inside a layer (a sash tail
@@ -236,6 +242,54 @@ function konst(name: string, value: number): BasisConstant {
236
242
  return { name, value };
237
243
  }
238
244
 
245
+ /**
246
+ * The `assemble` section {@link checkProposal} hands the loader, rebuilt from
247
+ * parts.json: every See-through part a plan entry or a cut, every patch a
248
+ * patches entry. Exported so a control can read which part went back as the
249
+ * plan part and which as the piece (issue #230).
250
+ */
251
+ export function proposalAssemble(P: PartSet): Record<string, unknown> {
252
+ // A patch is not a plan entry; it goes back as the patches entry it came
253
+ // from, so the loader holds it to its own rule (a region, never a mesh).
254
+ // Its box, alpha and draw are the record's own box and placeholders the
255
+ // loader only type-checks: nothing here is written anywhere.
256
+ // A part that shares another's `from` is the piece of an `assemble.cuts`
257
+ // entry (issue #170) — the only way two parts share a layer — and goes back
258
+ // as a cut of its base, its polygon the record's box, a placeholder the
259
+ // loader only type-checks like the patches' fields. The piece names its base
260
+ // (`cut_from`, issue #230); file order cannot, as a piece drawn "back" comes
261
+ // before its base. A file with an unmarked shared layer (an assemble before
262
+ // 1.5.6, which propose refuses) goes back with the first part of the layer
263
+ // as the plan part, as it did before the field.
264
+ const run = P.recs.filter((r) => splitFrom(r.from)[0] !== PAINTING_RUN);
265
+ const painted = P.recs.filter((r) => splitFrom(r.from)[0] === PAINTING_RUN);
266
+ const firstOf = new Map<string, string>();
267
+ for (const r of run) if (r.cut_from === undefined && !firstOf.has(r.from)) firstOf.set(r.from, r.name);
268
+ const baseOf = (r: PartRecord): string => r.cut_from ?? (firstOf.get(r.from) as string);
269
+ const planned = run.filter((r) => r.cut_from === undefined && firstOf.get(r.from) === r.name);
270
+ const pieces = run.filter((r) => r.cut_from !== undefined || firstOf.get(r.from) !== r.name);
271
+ return {
272
+ rig_scale: 1,
273
+ plan: planned.map((r) => [r.name, ...splitFrom(r.from)]),
274
+ patches: painted.map((r) => ({ name: r.name, box: [r.x, r.y, r.x + r.w, r.y + r.h], alpha: 'box', draw: 'front' })),
275
+ ...(pieces.length === 0
276
+ ? {}
277
+ : {
278
+ cuts: pieces.map((r) => ({
279
+ from: baseOf(r),
280
+ into: r.name,
281
+ polygon: [
282
+ [r.x, r.y],
283
+ [r.x + r.w, r.y],
284
+ [r.x + r.w, r.y + r.h],
285
+ ],
286
+ draw: 'front',
287
+ overlap: 0,
288
+ })),
289
+ }),
290
+ };
291
+ }
292
+
239
293
  /**
240
294
  * Hold the proposal to the config contract before it is written: it is copied
241
295
  * into a config field by field, so a proposal the loader would refuse is a
@@ -246,42 +300,9 @@ function konst(name: string, value: number): BasisConstant {
246
300
  * refusals.
247
301
  */
248
302
  export function checkProposal(P: PartSet, p: Proposal): void {
249
- // A patch is not a plan entry; it goes back as the patches entry it came
250
- // from, so the loader holds it to its own rule (a region, never a mesh).
251
- // Its box, alpha and draw are the record's own box and placeholders the
252
- // loader only type-checks: nothing here is written anywhere.
253
- // A part whose `from` an earlier part already has is the piece of an
254
- // `assemble.cuts` entry (issue #170) — the only way two parts share a layer
255
- // — and goes back as a cut of that earlier part, its polygon the record's
256
- // box, a placeholder the loader only type-checks like the patches' fields.
257
- const run = P.recs.filter((r) => splitFrom(r.from)[0] !== PAINTING_RUN);
258
- const painted = P.recs.filter((r) => splitFrom(r.from)[0] === PAINTING_RUN);
259
- const firstOf = new Map<string, string>();
260
- for (const r of run) if (!firstOf.has(r.from)) firstOf.set(r.from, r.name);
261
- const planned = run.filter((r) => firstOf.get(r.from) === r.name);
262
- const pieces = run.filter((r) => firstOf.get(r.from) !== r.name);
263
303
  parseConfig({
264
304
  key: 'proposal',
265
- assemble: {
266
- rig_scale: 1,
267
- plan: planned.map((r) => [r.name, ...splitFrom(r.from)]),
268
- patches: painted.map((r) => ({ name: r.name, box: [r.x, r.y, r.x + r.w, r.y + r.h], alpha: 'box', draw: 'front' })),
269
- ...(pieces.length === 0
270
- ? {}
271
- : {
272
- cuts: pieces.map((r) => ({
273
- from: firstOf.get(r.from) as string,
274
- into: r.name,
275
- polygon: [
276
- [r.x, r.y],
277
- [r.x + r.w, r.y],
278
- [r.x + r.w, r.y + r.h],
279
- ],
280
- draw: 'front',
281
- overlap: 0,
282
- })),
283
- }),
284
- },
305
+ assemble: proposalAssemble(P),
285
306
  bones: p.bones,
286
307
  meshes: p.meshes,
287
308
  regions: p.regions,
@@ -312,14 +333,33 @@ export class PartSet {
312
333
  this.images = images;
313
334
  }
314
335
 
315
- /** Parts whose `from` tag is exactly `tag`, optionally from one run, in parts.json order. A `painting:` patch has no tag and is never one. */
316
- byTag(tag: string, run?: 'full' | 'head'): PartRecord[] {
336
+ /**
337
+ * Parts whose `from` tag is exactly `tag`, optionally from one run, in
338
+ * parts.json order. A `painting:` patch has no tag and is never one, and a
339
+ * cut's piece (`cut_from`, issue #230) is not one either unless `pieces` is
340
+ * set: it shares its base's layer, and a rule that runs once per part of a
341
+ * tag would run for its base a second time — the same chains named twice.
342
+ */
343
+ byTag(tag: string, run?: 'full' | 'head', pieces = false): PartRecord[] {
317
344
  return this.recs.filter((p) => {
318
345
  const [r, t] = splitFrom(p.from);
319
- return r !== PAINTING_RUN && t === tag && (run === undefined || r === run);
346
+ return r !== PAINTING_RUN && t === tag && (run === undefined || r === run) && (pieces || p.cut_from === undefined);
320
347
  });
321
348
  }
322
349
 
350
+ /**
351
+ * The layers more than one part holds with no `cut_from` to tell the piece
352
+ * from its base: a `parts.json` an `assemble` before 1.5.6 wrote with
353
+ * `assemble.cuts`. parts.json is in draw order and a piece drawn `"back"`
354
+ * comes before its base, so which part takes the layer's rule cannot be
355
+ * read off the file. Each entry is the layer and its parts, in file order.
356
+ */
357
+ unmarkedShares(): Array<{ from: string; parts: string[] }> {
358
+ const by = new Map<string, string[]>();
359
+ for (const p of this.layered()) if (p.cut_from === undefined) by.set(p.from, [...(by.get(p.from) ?? []), p.name]);
360
+ return [...by.entries()].filter(([, names]) => names.length > 1).map(([from, parts]) => ({ from, parts }));
361
+ }
362
+
323
363
  /** The See-through parts: every part but the `painting:` patches. */
324
364
  layered(): PartRecord[] {
325
365
  return this.recs.filter((p) => splitFrom(p.from)[0] !== PAINTING_RUN);
@@ -830,6 +870,16 @@ export function propose(P: PartSet, joints?: RigJoints, wristSnap: number | null
830
870
  * alone, and every value and note is the one written before it existed.
831
871
  */
832
872
  export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: number | null = null): { proposal: Proposal; basis: ProposalBasis } {
873
+ // Issue #230: a piece is not given its base's rule a second time, which
874
+ // needs to know which part is the piece. A file that cannot say is refused
875
+ // here, and only here: every other reader of parts.json still takes it.
876
+ refuseIfAny(
877
+ P.unmarkedShares().map(({ from, parts }) => ({
878
+ code: 'PROPOSE_CUT_FROM_RECORDED',
879
+ object: `parts ${parts.map((n) => `"${n}"`).join(', ')} (${from})`,
880
+ detail: `share one layer and none of them records cut_from, so which is the plan part and which the piece an assemble.cuts entry took cannot be read off parts.json (it is in draw order, and a piece drawn "back" comes before its base); re-run assemble (1.5.6+) to record cut_from`,
881
+ })),
882
+ );
833
883
  const bones: BoneEntry[] = [];
834
884
  const records: BoneBasis[] = [];
835
885
  // No prototype: a part may be named anything file-safe, "__proto__" included.
@@ -1018,6 +1068,8 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
1018
1068
  const eyeless: string[] = [];
1019
1069
  const eyelessSides = new Set<'r' | 'l'>();
1020
1070
  for (const p of P.layered()) {
1071
+ // A cut's piece takes no tag's role (issue #230): it falls to the trunk rule below, which says so.
1072
+ if (p.cut_from !== undefined) continue;
1021
1073
  const t = splitFrom(p.from)[1];
1022
1074
  let role: string | undefined = TAG_REGION[t];
1023
1075
  if (browOf.has(p.name)) role = browOf.get(p.name);
@@ -1383,7 +1435,9 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
1383
1435
  }
1384
1436
 
1385
1437
  // ---- sleeves
1386
- const hw = [...P.byTag('handwear-r'), ...P.byTag('handwear-l')].filter((p) => p.opaque_px > 500);
1438
+ // A cut's piece counts here (issue #230): the sleeve rule names its chains by side, not once per part, so a piece
1439
+ // never repeats one, and cutting one sleeve out of a both-arms part is how #199's note says to separate them.
1440
+ const hw = [...P.byTag('handwear-r', undefined, true), ...P.byTag('handwear-l', undefined, true)].filter((p) => p.opaque_px > 500);
1387
1441
  const sides = new Map<'r' | 'l', PartRecord[]>();
1388
1442
  for (const p of hw) {
1389
1443
  const s = center(p)[0] < axis ? 'r' : 'l';
@@ -1611,6 +1665,10 @@ export function proposeWithBasis(P: PartSet, joints?: RigJoints, wristSnap: numb
1611
1665
  regions[p.name] = cy < neckY ? 'head' : cy < hip[1] ? 'chest' : 'hip';
1612
1666
  if (splitFrom(p.from)[0] === PAINTING_RUN) {
1613
1667
  notes.push(`${p.name} (${p.from}): a painting patch has no tag, so no rule -> region on ${regions[p.name]}, the nearest trunk bone; config.regions.${p.name} is the bone it rides`);
1668
+ } else if (p.cut_from !== undefined) {
1669
+ notes.push(
1670
+ `${p.name} (${p.from}): a piece an assemble.cuts entry took from ${p.cut_from}, so not given that layer's rule a second time (it would name ${p.cut_from}'s bones and chains again) -> region on ${regions[p.name]}, the nearest trunk bone; config.regions.${p.name} is the bone it rides, and a mesh in config.meshes is the author's to give it`,
1671
+ );
1614
1672
  } else notes.push(`${p.name} (${p.from}): no rule -> region on ${regions[p.name]}`);
1615
1673
  }
1616
1674
  }
@@ -147,6 +147,28 @@ export interface RequirementsFile {
147
147
  fps: number;
148
148
  targets: SceneTarget[];
149
149
  requirements: Requirement[];
150
+ /**
151
+ * Issue #231: the parts the scene's figure does not stand on, by name — each
152
+ * a part `check` would otherwise read as a planted foot (a `footwear` part),
153
+ * which the feet halves of `BREATH_VISIBLE` and `STILL_REGIONS_DARK` then
154
+ * name as declared and do not hold still. Empty when the file does not
155
+ * declare it: every footwear part is a planted foot, as before.
156
+ */
157
+ not_planted: string[];
158
+ /**
159
+ * Issue #231: the swinging parts (`handwear`, `bottomwear`) the scene does
160
+ * not mean to swing — a piece cut off and left still — by name.
161
+ * `TIP_OVER_ROOT` names each in its line and does not hold it to the bar.
162
+ * Empty when not declared.
163
+ */
164
+ not_swinging: string[];
165
+ /**
166
+ * Issue #231: the swinging parts whose tip is up — a raised forearm, its
167
+ * chain rising from the elbow — by name. `TIP_OVER_ROOT` reads their upper
168
+ * half as the tip's, after checking that the chain carrying each does rise,
169
+ * and names each in its line. Empty when not declared: every part hangs.
170
+ */
171
+ rises: string[];
150
172
  }
151
173
 
152
174
  /** What `check --requirements` and `build --requirements` name, said once for the help and the refusals. */
@@ -182,10 +204,24 @@ const KIND_FIELDS: Record<RequirementKind, readonly string[]> = {
182
204
 
183
205
  const NAME = /^[A-Za-z][A-Za-z0-9_]*$/;
184
206
 
207
+ /** The three lists of parts a scene excepts from a bar that assumes a standing figure (issue #231), in the order they are read. */
208
+ export const EXCEPTION_LISTS = ['not_planted', 'not_swinging', 'rises'] as const;
209
+ type ExceptionList = (typeof EXCEPTION_LISTS)[number];
210
+
211
+ /** What each list's absence declares. */
212
+ const EXCEPTION_ABSENT: Record<ExceptionList, string> = {
213
+ not_planted: 'every footwear part a planted foot',
214
+ not_swinging: 'every handwear and bottomwear part one that swings',
215
+ rises: 'every swinging part one that hangs',
216
+ };
217
+
185
218
  /**
186
219
  * Read and shape-check a requirements file. Every problem is collected before
187
220
  * one refusal: the file, `spec`, `fps` (required, a positive number: no
188
- * default), `targets` (optional) and `requirements` (required, possibly empty),
221
+ * default), `targets` (optional), `requirements` (required, possibly empty)
222
+ * and `not_planted`, `not_swinging` and `rises` (each optional, issue #231: a
223
+ * non-empty list of distinct part names, no part in both of the last two;
224
+ * which parts they are is read against parts.json and the rig by `check`),
189
225
  * each entry's every field and bar by name, a key the format does not read,
190
226
  * and a name used twice. Resolving the names against the rig is
191
227
  * {@link resolveRequirements}'s.
@@ -209,7 +245,7 @@ export function readRequirements(path: string): RequirementsFile {
209
245
  problems.push({ code: 'REQUIREMENTS_FIELD', object, detail });
210
246
  };
211
247
  if (raw.spec !== REQUIREMENTS_SPEC) field(`${at} field "spec"`, `is ${JSON.stringify(raw.spec)}; ${JSON.stringify(REQUIREMENTS_SPEC)} is required`);
212
- for (const k of Object.keys(raw)) if (!['spec', 'fps', 'targets', 'requirements'].includes(k) && !freeKey(k, raw[k])) field(`${at} field "${k}"`, 'is not a field this format reads (spec, fps, targets, requirements; note or *_note as a string; x-<name> as a record)');
248
+ for (const k of Object.keys(raw)) if (!['spec', 'fps', 'targets', 'requirements', ...EXCEPTION_LISTS].includes(k) && !freeKey(k, raw[k])) field(`${at} field "${k}"`, 'is not a field this format reads (spec, fps, targets, requirements, not_planted, not_swinging, rises; note or *_note as a string; x-<name> as a record)');
213
249
  let fps = NaN;
214
250
  if (!(finite(raw.fps) && raw.fps > 0)) field(`${at} field "fps"`, `is ${JSON.stringify(raw.fps)}; a positive number of frames per second is required — every named animation is sampled at it, and there is no default`);
215
251
  else fps = raw.fps;
@@ -336,8 +372,27 @@ export function readRequirements(path: string): RequirementsFile {
336
372
  });
337
373
  }
338
374
  }
375
+ const lists = {} as Record<ExceptionList, string[]>;
376
+ for (const key of EXCEPTION_LISTS) {
377
+ const names: string[] = [];
378
+ lists[key] = names;
379
+ const v = raw[key];
380
+ if (v === undefined) continue;
381
+ if (!Array.isArray(v) || v.length === 0) {
382
+ field(`${at} field "${key}"`, `is ${JSON.stringify(v)?.slice(0, 60)}; a non-empty list of part names is required when the field is given — leave it out to declare ${EXCEPTION_ABSENT[key]}`);
383
+ continue;
384
+ }
385
+ v.forEach((n, i) => {
386
+ if (typeof n !== 'string' || n.length === 0) field(`${at} field "${key}"[${i}]`, `is ${JSON.stringify(n)?.slice(0, 40)}; a part name is required`);
387
+ else if (names.includes(n)) field(`${at} field "${key}"[${i}]`, `names "${n}" a second time; each part is declared once`);
388
+ else names.push(n);
389
+ });
390
+ }
391
+ lists.rises.forEach((n, i) => {
392
+ if (lists.not_swinging.includes(n)) field(`${at} field "rises"[${i}]`, `names "${n}", which "not_swinging" names too; a part that does not swing has no tip to read up or down`);
393
+ });
339
394
  refuseIfAny(problems);
340
- return { path: at, fps, targets, requirements };
395
+ return { path: at, fps, targets, requirements, not_planted: lists.not_planted, not_swinging: lists.not_swinging, rises: lists.rises };
341
396
  }
342
397
 
343
398
  // ---------------------------------------------------------------------------