@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/NOTICE.md +23 -10
- package/README.md +56 -55
- package/cli.ts +10 -2
- package/docs/AUTHORING.md +196 -114
- package/docs/STABILITY.md +2 -2
- package/package.json +1 -1
- package/skills/rig-parts/SKILL.md +2 -2
- package/src/assemble.ts +329 -9
- package/src/build.ts +2 -1
- package/src/check.ts +224 -28
- package/src/instruments.ts +34 -10
- package/src/parts.ts +39 -2
- package/src/propose.ts +97 -39
- package/src/requirements.ts +58 -3
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
|
-
/**
|
|
316
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/requirements.ts
CHANGED
|
@@ -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)
|
|
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
|
// ---------------------------------------------------------------------------
|