@rig-c/parts 1.15.0 → 1.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/cli.ts +62 -12
- package/docs/AUTHORING.md +454 -200
- package/docs/STABILITY.md +12 -8
- package/package.json +1 -1
- package/skills/rig-parts/SKILL.md +11 -2
- package/src/assemble.ts +325 -23
- package/src/autoamplitude.ts +23 -3
- package/src/autoenvelope.ts +17 -5
- package/src/build.ts +81 -8
- package/src/check.ts +632 -11
- package/src/config.ts +185 -8
- package/src/errors.ts +16 -0
- package/src/guide.ts +255 -0
- package/src/limbs.ts +188 -0
- package/src/parts.ts +89 -4
- package/src/propose.ts +137 -107
- package/src/rig.ts +43 -8
- package/src/scene.ts +2 -2
package/src/config.ts
CHANGED
|
@@ -86,7 +86,7 @@
|
|
|
86
86
|
*/
|
|
87
87
|
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
88
88
|
import { dirname, resolve } from 'node:path';
|
|
89
|
-
import { RIG_SKIN_CONSTRAINT_KEYS, type RigConstraint, type RigSkinConstraintKey } from 'rig-c/src/rig.ts';
|
|
89
|
+
import { RIG_BONE_INHERIT, RIG_SKIN_CONSTRAINT_KEYS, type RigBoneInherit, type RigConstraint, type RigSkinConstraintKey } from 'rig-c/src/rig.ts';
|
|
90
90
|
import { GRID, MAX_SIDE } from './contour.ts';
|
|
91
91
|
import { type Problem, refuseIfAny } from './errors.ts';
|
|
92
92
|
import { isSkeletonName, KEYPOINT_NAMES, type KeypointName, type PosePoints, SKELETON_NAMES, type SkeletonName } from './skeleton.ts';
|
|
@@ -227,6 +227,15 @@ export interface SingleBone {
|
|
|
227
227
|
parent: string;
|
|
228
228
|
at: Point;
|
|
229
229
|
tip?: Point;
|
|
230
|
+
/**
|
|
231
|
+
* What the bone takes from its parent's transform (issue #348): one of rig-c's
|
|
232
|
+
* own mode names (`RIG_BONE_INHERIT`, rig-c's `RigBone.inherit`), passed to
|
|
233
|
+
* rig.json as written. Absent, rig.json carries no `inherit` and the bone
|
|
234
|
+
* takes the parent's whole transform (rig-c's default, `normal`). A spelling
|
|
235
|
+
* outside rig-c's table is refused by name (`CONFIG_BONE_INHERIT`): the
|
|
236
|
+
* runtime would fold the first letter, and this loader folds and maps nothing.
|
|
237
|
+
*/
|
|
238
|
+
inherit?: RigBoneInherit;
|
|
230
239
|
}
|
|
231
240
|
|
|
232
241
|
export interface ChainBones {
|
|
@@ -627,12 +636,37 @@ export interface ConfigConstraint {
|
|
|
627
636
|
[field: string]: unknown;
|
|
628
637
|
}
|
|
629
638
|
|
|
639
|
+
/**
|
|
640
|
+
* One segment of a limb (issue #349): the part it makes, the part whose
|
|
641
|
+
* pixels it takes (`from`: a plan part or a cut's piece), and the one bone it
|
|
642
|
+
* rides with weight 1. Every segment after the first sits below a joint at
|
|
643
|
+
* its bone's origin: `joint` names it, `range` is the bend the joint may take
|
|
644
|
+
* from rest, `[min, max]` degrees (metadata: read by the stress readers, not
|
|
645
|
+
* by rig-c), and `cap` an image, relative to the config's directory, of the
|
|
646
|
+
* cap that overlaps the joint (`limbCaps` in `src/assemble.ts`).
|
|
647
|
+
*/
|
|
648
|
+
export interface LimbSegment {
|
|
649
|
+
part: string;
|
|
650
|
+
from: string;
|
|
651
|
+
bone: string;
|
|
652
|
+
joint?: string;
|
|
653
|
+
range?: [number, number];
|
|
654
|
+
cap?: string;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** A limb (issue #349): its segments, upper first; each segment's bone is the child of the one before. */
|
|
658
|
+
export interface Limb {
|
|
659
|
+
segments: LimbSegment[];
|
|
660
|
+
}
|
|
661
|
+
|
|
630
662
|
export interface CharacterConfig {
|
|
631
663
|
key: string;
|
|
632
664
|
note?: string;
|
|
633
665
|
generation?: Generation;
|
|
634
666
|
seethrough?: SeeThrough;
|
|
635
667
|
assemble: Assemble;
|
|
668
|
+
/** Limbs cut at their joints (issue #349): absent, no part is cut at a joint and no byte of any output moves. */
|
|
669
|
+
limbs?: Record<string, Limb>;
|
|
636
670
|
bones: BoneEntry[];
|
|
637
671
|
meshes: Record<string, MeshSpec>;
|
|
638
672
|
regions: Record<string, string>;
|
|
@@ -925,9 +959,12 @@ export interface AssembleConfig {
|
|
|
925
959
|
seethrough: SeeThrough;
|
|
926
960
|
assemble: Assemble;
|
|
927
961
|
scene?: SceneSpec;
|
|
962
|
+
/** Issue #349: read with `bones` (the joints are the bones' origins) when present. */
|
|
963
|
+
limbs?: Record<string, Limb>;
|
|
964
|
+
bones?: BoneEntry[];
|
|
928
965
|
}
|
|
929
966
|
|
|
930
|
-
const TOP_KEYS = ['key', 'generation', 'seethrough', 'assemble', 'bones', 'meshes', 'regions', 'motion', 'constraints', 'turn', 'scene'] as const;
|
|
967
|
+
const TOP_KEYS = ['key', 'generation', 'seethrough', 'assemble', 'limbs', 'bones', 'meshes', 'regions', 'motion', 'constraints', 'turn', 'scene'] as const;
|
|
931
968
|
|
|
932
969
|
/**
|
|
933
970
|
* The one partial entry point. It validates exactly what its door requires —
|
|
@@ -976,8 +1013,14 @@ function parseEarlyConfigFrom(raw: Json, door: EarlyDoor, base: string | null):
|
|
|
976
1013
|
}
|
|
977
1014
|
if (door === 'assemble') {
|
|
978
1015
|
if ('seethrough' in t) checkSeeThrough(c, t.seethrough);
|
|
979
|
-
|
|
1016
|
+
const made = 'assemble' in t ? checkAssemble(c, t.assemble, door) : { parts: [], patches: [], cuts: [] };
|
|
980
1017
|
if ('scene' in t) checkScene(c, t.scene);
|
|
1018
|
+
// Issue #349: the limbs' joints are their bones' origins, so the bones are read with them, and only then.
|
|
1019
|
+
if ('limbs' in t) {
|
|
1020
|
+
if (!('bones' in t)) c.fail('CONFIG_FIELD_PRESENT', 'config.bones', 'is absent; config.limbs names bones, and each joint is its bone\'s origin, so assemble reads the bones with the limbs');
|
|
1021
|
+
const names = 'bones' in t ? checkBones(c, t.bones) : null;
|
|
1022
|
+
checkLimbs(c, t.limbs, made, names);
|
|
1023
|
+
}
|
|
981
1024
|
}
|
|
982
1025
|
const pose = base === null ? null : poseBeside(c, t.generation, base);
|
|
983
1026
|
refuseIfAny(c.problems);
|
|
@@ -1124,9 +1167,10 @@ function parseConfigFrom(raw: Json, base: string | null): { config: CharacterCon
|
|
|
1124
1167
|
if ('seethrough' in t) checkSeeThrough(c, t.seethrough);
|
|
1125
1168
|
const { parts, patches, cuts } = 'assemble' in t ? checkAssemble(c, t.assemble, 'full') : { parts: [], patches: [], cuts: [] };
|
|
1126
1169
|
const names = 'bones' in t ? checkBones(c, t.bones) : { bones: new Set<string>([ROOT_BONE]), chains: new Map<string, number>(), parents: new Map<string, string>() };
|
|
1170
|
+
const limbs = 'limbs' in t ? checkLimbs(c, t.limbs, { parts, patches, cuts }, 'bones' in t ? names : null) : null;
|
|
1127
1171
|
if ('meshes' in t) checkMeshes(c, t.meshes, names.bones, names.chains);
|
|
1128
1172
|
if ('regions' in t) checkRegions(c, t.regions, names.bones);
|
|
1129
|
-
if ('meshes' in t && 'regions' in t && 'assemble' in t) checkCoverage(c, [...parts, ...cuts], patches, t.meshes, t.regions);
|
|
1173
|
+
if ('meshes' in t && 'regions' in t && 'assemble' in t) checkCoverage(c, [...parts, ...cuts], patches, t.meshes, t.regions, limbs);
|
|
1130
1174
|
if ('motion' in t) checkMotion(c, t.motion, names.bones, names.chains, 'regions' in t ? t.regions : undefined);
|
|
1131
1175
|
if ('constraints' in t) checkConstraints(c, t.constraints, names.bones, names.parents);
|
|
1132
1176
|
if ('turn' in t) checkTurn(c, t.turn, attachedParts(t.meshes, t.regions), names.bones);
|
|
@@ -1651,8 +1695,16 @@ function checkBones(c: Check, v: Json): { bones: Set<string>; chains: Map<string
|
|
|
1651
1695
|
}
|
|
1652
1696
|
return;
|
|
1653
1697
|
}
|
|
1654
|
-
const b = c.object(at, entry, ['name', 'parent', 'at'], ['tip']);
|
|
1698
|
+
const b = c.object(at, entry, ['name', 'parent', 'at'], ['tip', 'inherit']);
|
|
1655
1699
|
if (b === null) return;
|
|
1700
|
+
// Issue #348: rig-c's own mode names, as its table spells them; nothing is folded, mapped or defaulted.
|
|
1701
|
+
if ('inherit' in b && c.string(`${at}.inherit`, b.inherit) && !(RIG_BONE_INHERIT as readonly string[]).includes(b.inherit)) {
|
|
1702
|
+
c.fail(
|
|
1703
|
+
'CONFIG_BONE_INHERIT',
|
|
1704
|
+
`${at}.inherit`,
|
|
1705
|
+
`is ${show(b.inherit)}; one of rig-c's inherit modes, spelled as its table spells them, is required — ${RIG_BONE_INHERIT.join(', ')} (rig-c's RigBone.inherit; nothing here folds a case or maps a name)`,
|
|
1706
|
+
);
|
|
1707
|
+
}
|
|
1656
1708
|
if ('parent' in b && c.string(`${at}.parent`, b.parent) && !bones.has(b.parent)) {
|
|
1657
1709
|
c.fail('CONFIG_NAME_RESOLVES', `${at}.parent`, `names "${b.parent}", which is not "${ROOT_BONE}" or a bone declared above it; parents come first`);
|
|
1658
1710
|
}
|
|
@@ -2087,14 +2139,139 @@ function checkRegions(c: Check, v: Json, bones: Set<string>): void {
|
|
|
2087
2139
|
}
|
|
2088
2140
|
}
|
|
2089
2141
|
|
|
2142
|
+
/** What the loader read off `config.limbs` (issue #349): each limb part with its bone, and each part a limb takes, with the first limb taking it. */
|
|
2143
|
+
interface LimbNames {
|
|
2144
|
+
parts: Map<string, { bone: string; at: string }>;
|
|
2145
|
+
consumed: Map<string, string>;
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
/**
|
|
2149
|
+
* `config.limbs` (issue #349): `{<limb>: {segments: [{part, from, bone}, {part,
|
|
2150
|
+
* from, bone, joint, range?, cap?}, …]}}`, upper segment first. Refused field
|
|
2151
|
+
* by field: `part` a new part name (`CONFIG_PART_NAME`, `CONFIG_PART_UNIQUE`
|
|
2152
|
+
* against the plan, the patches, the cuts and every other limb part); `from`
|
|
2153
|
+
* a plan part or a cut's piece (`CONFIG_NAME_RESOLVES`), the segments of one
|
|
2154
|
+
* limb that take one part consecutive (`CONFIG_LIMB_FROM_RUN`: the cut
|
|
2155
|
+
* between them is a joint line, and a joint line has two sides); `bone` a
|
|
2156
|
+
* declared bone, each segment's bone the child of the one before
|
|
2157
|
+
* (`CONFIG_LIMB_BONES_CHAIN`: a bend is a rotation of the lower bone about
|
|
2158
|
+
* the joint, its origin); the first segment has no joint, every other names
|
|
2159
|
+
* one (`CONFIG_KEY_KNOWN`, `CONFIG_FIELD_PRESENT`); `range` two finite
|
|
2160
|
+
* degrees `[min, max]` with `min <= 0 <= max` (`CONFIG_LIMB_RANGE`: the range
|
|
2161
|
+
* is measured from rest, so rest is in it); `cap` a non-empty path. A limb
|
|
2162
|
+
* has two segments or more. With `names` null (no `bones`), bones are not
|
|
2163
|
+
* resolved. Returns the limb parts and the parts the limbs take.
|
|
2164
|
+
*/
|
|
2165
|
+
function checkLimbs(c: Check, v: Json, made: { parts: string[]; patches: string[]; cuts: string[] }, names: { bones: Set<string>; parents: Map<string, string> } | null): LimbNames {
|
|
2166
|
+
const out: LimbNames = { parts: new Map(), consumed: new Map() };
|
|
2167
|
+
if (!isPlainObject(v)) {
|
|
2168
|
+
c.fail('CONFIG_FIELD_TYPE', 'config.limbs', `is ${show(v)}; an object of limb name -> {"segments": [...]} is required`);
|
|
2169
|
+
return out;
|
|
2170
|
+
}
|
|
2171
|
+
const sources = new Set([...made.parts, ...made.cuts]);
|
|
2172
|
+
const firstAt = new Map<string, string>();
|
|
2173
|
+
for (const [limb, spec] of Object.entries(v)) {
|
|
2174
|
+
const at = `config.limbs.${limb}`;
|
|
2175
|
+
const o = c.object(at, spec, ['segments'], []);
|
|
2176
|
+
if (o === null || !('segments' in o)) continue;
|
|
2177
|
+
if (!Array.isArray(o.segments) || o.segments.length < 2) {
|
|
2178
|
+
c.fail('CONFIG_FIELD_TYPE', `${at}.segments`, `is ${show(o.segments)}; an array of two segments or more, upper first, is required — a limb is cut at a joint, and a joint has a segment on each side`);
|
|
2179
|
+
continue;
|
|
2180
|
+
}
|
|
2181
|
+
const segs = o.segments as Json[];
|
|
2182
|
+
const froms: Array<string | null> = [];
|
|
2183
|
+
const bones: Array<string | null> = [];
|
|
2184
|
+
segs.forEach((entry, i) => {
|
|
2185
|
+
const sat = `${at}.segments[${i}]`;
|
|
2186
|
+
const e = c.object(sat, entry, ['part', 'from', 'bone'], ['joint', 'range', 'cap']);
|
|
2187
|
+
froms.push(null);
|
|
2188
|
+
bones.push(null);
|
|
2189
|
+
if (e === null) return;
|
|
2190
|
+
// The upper segment sits below no joint; every other sits below one, which it names.
|
|
2191
|
+
if (i === 0) {
|
|
2192
|
+
for (const k of ['joint', 'range', 'cap'].filter((q) => q in e)) c.fail('CONFIG_KEY_KNOWN', `${sat}.${k}`, `is not a field on the upper segment; it sits below no joint, so ${k} belongs to the segment below the joint it names (segments[1] and on)`);
|
|
2193
|
+
} else if (!('joint' in e)) c.fail('CONFIG_FIELD_PRESENT', `${sat}.joint`, 'is absent and required; a segment after the first sits below a joint, at its bone\'s origin, and names it');
|
|
2194
|
+
if ('part' in e && c.string(`${sat}.part`, e.part)) {
|
|
2195
|
+
const name = e.part;
|
|
2196
|
+
if (!partNameOk(name)) c.fail('CONFIG_PART_NAME', `${sat}.part`, `names the part ${show(name)}; a part name is a file name, so it may not be empty, start with "." or hold a slash`);
|
|
2197
|
+
else if (made.parts.includes(name) || made.patches.includes(name) || made.cuts.includes(name)) {
|
|
2198
|
+
c.fail('CONFIG_PART_UNIQUE', `${sat}.part`, `names the part "${name}", which ${made.parts.includes(name) ? 'assemble.plan' : made.patches.includes(name) ? 'assemble.patches' : 'assemble.cuts'} already makes; a limb segment makes a part of its own`);
|
|
2199
|
+
} else if (firstAt.has(name)) c.fail('CONFIG_PART_UNIQUE', `${sat}.part`, `names the part "${name}" again (first at ${firstAt.get(name)})`);
|
|
2200
|
+
else {
|
|
2201
|
+
firstAt.set(name, sat);
|
|
2202
|
+
if ('bone' in e && typeof e.bone === 'string') out.parts.set(name, { bone: e.bone, at: `config.limbs.${limb} segment ${i}` });
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
if ('from' in e) {
|
|
2206
|
+
if (typeof e.from !== 'string' || !sources.has(e.from)) {
|
|
2207
|
+
c.fail('CONFIG_NAME_RESOLVES', `${sat}.from`, `is ${show(e.from)}; a part named in assemble.plan or made by an assemble.cuts entry is required — the layer the segment's pixels are cut from`);
|
|
2208
|
+
} else {
|
|
2209
|
+
froms[i] = e.from;
|
|
2210
|
+
if (!out.consumed.has(e.from)) out.consumed.set(e.from, limb);
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
if ('bone' in e && c.string(`${sat}.bone`, e.bone)) {
|
|
2214
|
+
if (names !== null && !names.bones.has(e.bone)) c.fail('CONFIG_NAME_RESOLVES', `${sat}.bone`, `is "${e.bone}"; a bone config.bones declares is required (a chain's links are <chain>0, <chain>1, …)`);
|
|
2215
|
+
else bones[i] = e.bone;
|
|
2216
|
+
}
|
|
2217
|
+
if ('joint' in e) c.string(`${sat}.joint`, e.joint);
|
|
2218
|
+
if ('range' in e) {
|
|
2219
|
+
const r = e.range;
|
|
2220
|
+
const ok = Array.isArray(r) && r.length === 2 && r.every((n) => typeof n === 'number' && Number.isFinite(n));
|
|
2221
|
+
if (!ok) c.fail('CONFIG_FIELD_TYPE', `${sat}.range`, `is ${show(r)}; [min, max], two finite numbers of degrees from rest, is required`);
|
|
2222
|
+
else {
|
|
2223
|
+
const [lo, hi] = r as [number, number];
|
|
2224
|
+
if (!(lo <= 0 && 0 <= hi)) c.fail('CONFIG_LIMB_RANGE', `${sat}.range`, `is [${lo}, ${hi}]; min <= 0 <= max is required — the range is the bend from rest, and rest (0) is a pose the joint takes`);
|
|
2225
|
+
}
|
|
2226
|
+
}
|
|
2227
|
+
if ('cap' in e && !(typeof e.cap === 'string' && e.cap !== '')) {
|
|
2228
|
+
c.fail('CONFIG_FIELD_TYPE', `${sat}.cap`, `is ${show(e.cap)}; a non-empty path to a PNG is required, relative to the config's directory — the cap's image over the box assemble's limb line prints`);
|
|
2229
|
+
}
|
|
2230
|
+
});
|
|
2231
|
+
froms.forEach((f, i) => {
|
|
2232
|
+
if (f === null || i === 0 || froms[i - 1] === f) return;
|
|
2233
|
+
const back = froms.slice(0, i - 1).lastIndexOf(f);
|
|
2234
|
+
if (back >= 0) {
|
|
2235
|
+
c.fail('CONFIG_LIMB_FROM_RUN', `${at}.segments[${i}].from`, `is "${f}", which segments[${back}] takes too with another part between them; the segments that take one part must be consecutive — the cut between two of them is the joint line, and it has two sides`);
|
|
2236
|
+
}
|
|
2237
|
+
});
|
|
2238
|
+
if (names !== null) {
|
|
2239
|
+
bones.forEach((b, i) => {
|
|
2240
|
+
if (i === 0 || b === null || bones[i - 1] === null) return;
|
|
2241
|
+
const parent = names.parents.get(b);
|
|
2242
|
+
if (parent !== bones[i - 1]) {
|
|
2243
|
+
c.fail('CONFIG_LIMB_BONES_CHAIN', `${at}.segments[${i}].bone`, `is "${b}", whose parent is ${parent === undefined ? 'not declared' : `"${parent}"`}; "${bones[i - 1]}", the bone of segments[${i - 1}], is required — the lower segment turns about the joint on a bone that hangs from the upper's`);
|
|
2244
|
+
}
|
|
2245
|
+
});
|
|
2246
|
+
}
|
|
2247
|
+
}
|
|
2248
|
+
return out;
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2090
2251
|
/**
|
|
2091
2252
|
* Every plan part — and every cut's piece, which the caller lists with them —
|
|
2092
2253
|
* is exactly one of a mesh or a region, every patch is a region, and every
|
|
2093
2254
|
* mesh or region is one of those parts or a patch.
|
|
2094
2255
|
*/
|
|
2095
|
-
function checkCoverage(c: Check,
|
|
2256
|
+
function checkCoverage(c: Check, made: string[], patches: string[], meshes: Json, regions: Json, limbs: LimbNames | null = null): void {
|
|
2096
2257
|
const m = typeof meshes === 'object' && meshes !== null ? Object.keys(meshes) : [];
|
|
2097
2258
|
const r = typeof regions === 'object' && regions !== null ? Object.keys(regions) : [];
|
|
2259
|
+
// Issue #349: a part a limb takes is cut into the limb's parts and is not made itself; the limb's parts are.
|
|
2260
|
+
const parts = limbs === null ? made : [...made.filter((n) => !limbs.consumed.has(n)), ...limbs.parts.keys()];
|
|
2261
|
+
if (limbs !== null) {
|
|
2262
|
+
for (const name of [...m, ...r]) {
|
|
2263
|
+
const by = limbs.consumed.get(name);
|
|
2264
|
+
if (by !== undefined) {
|
|
2265
|
+
c.fail('CONFIG_NAME_RESOLVES', `config.${m.includes(name) ? 'meshes' : 'regions'}.${name}`, `names "${name}", which config.limbs.${by} cuts into its parts at the joints, so no part of that name is made — attach the limb's parts instead`);
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
for (const [part, seg] of limbs.parts) {
|
|
2269
|
+
const bone = isPlainObject(regions) ? regions[part] : undefined;
|
|
2270
|
+
if (typeof bone === 'string' && bone !== seg.bone) {
|
|
2271
|
+
c.fail('CONFIG_LIMB_BONE', `config.regions.${part}`, `is "${bone}"; "${part}" is ${seg.at}, which rides "${seg.bone}" with weight 1, so config.regions.${part} "${seg.bone}" is required`);
|
|
2272
|
+
}
|
|
2273
|
+
}
|
|
2274
|
+
}
|
|
2098
2275
|
for (const patch of patches) {
|
|
2099
2276
|
if (m.includes(patch)) c.fail('CONFIG_PART_ATTACHED', `part "${patch}"`, `is an assemble.patches entry and has a meshes entry; a patch is a region — drop config.meshes.${patch} and name its bone in config.regions.${patch}`);
|
|
2100
2277
|
else if (!r.includes(patch)) c.fail('CONFIG_PART_ATTACHED', `part "${patch}"`, `is an assemble.patches entry with no regions entry; a patch is a region, so config.regions.${patch} naming its bone is required`);
|
|
@@ -2106,8 +2283,8 @@ function checkCoverage(c: Check, parts: string[], patches: string[], meshes: Jso
|
|
|
2106
2283
|
if (inM && inR) c.fail('CONFIG_PART_ATTACHED', `part "${part}"`, 'has both a meshes entry and a regions entry; exactly one is required');
|
|
2107
2284
|
}
|
|
2108
2285
|
for (const name of [...m, ...r]) {
|
|
2109
|
-
if (!parts.includes(name) && !patches.includes(name)) {
|
|
2110
|
-
c.fail('CONFIG_NAME_RESOLVES', `config.${m.includes(name) ? 'meshes' : 'regions'}.${name}`, 'names a part that neither assemble.plan nor assemble.patches makes (nor an assemble.cuts entry)');
|
|
2286
|
+
if (!parts.includes(name) && !patches.includes(name) && !(limbs?.consumed.has(name) ?? false)) {
|
|
2287
|
+
c.fail('CONFIG_NAME_RESOLVES', `config.${m.includes(name) ? 'meshes' : 'regions'}.${name}`, 'names a part that neither assemble.plan nor assemble.patches makes (nor an assemble.cuts entry, nor config.limbs)');
|
|
2111
2288
|
}
|
|
2112
2289
|
}
|
|
2113
2290
|
}
|
package/src/errors.ts
CHANGED
|
@@ -23,6 +23,22 @@ export function problemLine(p: Problem): string {
|
|
|
23
23
|
return `${p.code}: ${p.object} — ${p.detail}`;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* The end of every FAIL line: the command that prints the guide's row for its
|
|
28
|
+
* rule (issue #343). This is the one place that writes it, so every line has the
|
|
29
|
+
* same form, and rig-c's lines have the same form with its own command. A line
|
|
30
|
+
* that quotes a rigc failure keeps rigc's pointer inside the quote, and this one
|
|
31
|
+
* goes after it.
|
|
32
|
+
*/
|
|
33
|
+
export function guidePointer(code: string): string {
|
|
34
|
+
return ` (see rig-parts guide ${code})`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A refusal as it is printed: ` FAIL RULE: object — detail (see rig-parts guide RULE)`. */
|
|
38
|
+
export function failLine(p: Problem): string {
|
|
39
|
+
return ` FAIL ${problemLine(p)}${guidePointer(p.code)}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
26
42
|
export class PartsError extends Error {
|
|
27
43
|
readonly problems: readonly Problem[];
|
|
28
44
|
|
package/src/guide.ts
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rig-parts guide`: a slice of `docs/AUTHORING.md`, read at the moment of the
|
|
3
|
+
* call (issue #343).
|
|
4
|
+
*
|
|
5
|
+
* The guide is one file, and it is the only copy. An agent building a rig
|
|
6
|
+
* reads a failure line, `FAIL RULE: object — detail (see rig-parts guide
|
|
7
|
+
* RULE)`, and needs the row that maps RULE to what has to change. It should
|
|
8
|
+
* not need the other 330 KB around that row. Every function here takes the
|
|
9
|
+
* guide's text and returns lines cut from it. Nothing is generated or kept
|
|
10
|
+
* apart from the file, so there is nothing to drift.
|
|
11
|
+
*
|
|
12
|
+
* Four readings, each defined on the text alone:
|
|
13
|
+
*
|
|
14
|
+
* - **A ledger table** is a Markdown table, outside a code fence, whose header's
|
|
15
|
+
* first cell is exactly `rule` (a refusal or a warning) or `line` (a line
|
|
16
|
+
* `check` prints). Other tables are not read.
|
|
17
|
+
* - **A row's names** are the backticked tokens in its first cell that are
|
|
18
|
+
* rule names ({@link RULE_NAME}). A wildcard such as `PARTS_*` names
|
|
19
|
+
* nothing. Each name sits on exactly one row in the whole guide; the
|
|
20
|
+
* selftest's `TY16` holds that against every name the source can print.
|
|
21
|
+
* - **A row's anchors** are the `<a id="NAME"></a>` tags at the start of its
|
|
22
|
+
* first cell, one per name. github.com keeps them as `user-content-<name>`,
|
|
23
|
+
* and its script lowercases a link's fragment before it looks one up, so
|
|
24
|
+
* `AUTHORING.md#NAME` lands on the row.
|
|
25
|
+
* - **`§n`** is the `## n.` heading and everything up to the next `##`.
|
|
26
|
+
* **`§n.m`** is the m-th `###` under it, and **`§n.m.k`** the k-th `####`
|
|
27
|
+
* under that, each counted at read time, up to the next heading of its level
|
|
28
|
+
* or above. The guide's headings carry no number. `guide §n` and `guide
|
|
29
|
+
* §n.m` print the numbers they counted beside each child's size.
|
|
30
|
+
*
|
|
31
|
+
* A refusal and the bar it enforces are two names, each with its own row:
|
|
32
|
+
* `CHECK_<LINE>` in a `rule` table, `<LINE>` in a `line` table. `guide` prints
|
|
33
|
+
* the asked name's row first, then `paired with <OTHER>:` and the other one's.
|
|
34
|
+
*
|
|
35
|
+
* The printed form is the one HQ settled for both packages on issue #343:
|
|
36
|
+
* `docs/AUTHORING.md §<addr>, line <N> — under "<heading>"` over each row;
|
|
37
|
+
* `docs/AUTHORING.md §<addr> "<title>", lines <a>–<b>, <X.X> KB` over a
|
|
38
|
+
* section, its children listed beneath in rig-c's columns; an unknown name or
|
|
39
|
+
* section one coded FAIL line on stderr, with rig-c's three-name ranking.
|
|
40
|
+
*/
|
|
41
|
+
import type { Problem } from './errors.ts';
|
|
42
|
+
|
|
43
|
+
/** The guide's path inside the package, beside `cli.ts`. */
|
|
44
|
+
export const GUIDE_DOC = 'docs/AUTHORING.md';
|
|
45
|
+
|
|
46
|
+
/** A rule name: upper-case words joined by underscores (`USAGE` and `CHECK_LOOP_CLOSES` alike). */
|
|
47
|
+
export const RULE_NAME = /^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*$/;
|
|
48
|
+
|
|
49
|
+
/** How many of the nearest names an unknown name's refusal lists (the form shared with rig-c). */
|
|
50
|
+
export const NEAREST_LISTED = 3;
|
|
51
|
+
|
|
52
|
+
/** The two header words that make a table a ledger table, and what its rows are. */
|
|
53
|
+
export const LEDGER_HEADS = ['rule', 'line'] as const;
|
|
54
|
+
export type LedgerKind = (typeof LEDGER_HEADS)[number];
|
|
55
|
+
|
|
56
|
+
/** A `##` section, a `###` or a `####`: its number, its title, its lines (1-based, end exclusive) and its size. */
|
|
57
|
+
export interface GuideSection {
|
|
58
|
+
/** `"6"` for `## 6.`, `"6.3"` for the third `###` under it, `"3.6.1"` for the first `####` under §3.6. */
|
|
59
|
+
number: string;
|
|
60
|
+
/** The heading's text as written (a `##` keeps its `n. `). */
|
|
61
|
+
title: string;
|
|
62
|
+
start: number;
|
|
63
|
+
end: number;
|
|
64
|
+
/** UTF-8 bytes from the heading's line to the next heading of its level or above, each line with its newline. */
|
|
65
|
+
bytes: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** One ledger row. */
|
|
69
|
+
export interface LedgerRow {
|
|
70
|
+
kind: LedgerKind;
|
|
71
|
+
/** The row's line, 1-based. */
|
|
72
|
+
line: number;
|
|
73
|
+
/** The table's header line and its separator line, as written. */
|
|
74
|
+
header: [string, string];
|
|
75
|
+
/** The row as written. */
|
|
76
|
+
text: string;
|
|
77
|
+
/** The rule names in the first cell, in the order written. */
|
|
78
|
+
names: string[];
|
|
79
|
+
/** The ids of the `<a id="…"></a>` tags at the start of the first cell, in order. */
|
|
80
|
+
anchors: string[];
|
|
81
|
+
/** The deepest numbered section the row sits in (`§n`, `§n.m` or `§n.m.k`). */
|
|
82
|
+
section: GuideSection | null;
|
|
83
|
+
/** The nearest heading above the row, its text as written. */
|
|
84
|
+
heading: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function utf8Bytes(s: string): number {
|
|
88
|
+
return new TextEncoder().encode(s).length;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Each line, with whether it sits inside a code fence (a fence's own lines count as inside). */
|
|
92
|
+
function fenced(lines: readonly string[]): boolean[] {
|
|
93
|
+
let inside = false;
|
|
94
|
+
return lines.map((l) => {
|
|
95
|
+
if (l.startsWith('```')) {
|
|
96
|
+
inside = !inside;
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
return inside;
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Every `## n.` section, every `###` under one, and every `####` under those, numbered as {@link GuideSection} says. */
|
|
104
|
+
export function guideSections(text: string): GuideSection[] {
|
|
105
|
+
const lines = text.split('\n');
|
|
106
|
+
const inFence = fenced(lines);
|
|
107
|
+
const heads: Array<{ level: number; title: string; line: number }> = [];
|
|
108
|
+
lines.forEach((l, i) => {
|
|
109
|
+
const h = inFence[i] ? null : /^(#{1,6}) (.*)$/.exec(l);
|
|
110
|
+
if (h !== null) heads.push({ level: h[1].length, title: h[2], line: i + 1 });
|
|
111
|
+
});
|
|
112
|
+
const out: GuideSection[] = [];
|
|
113
|
+
const counts = [0, 0, 0];
|
|
114
|
+
let chapter: string | null = null;
|
|
115
|
+
heads.forEach((h, k) => {
|
|
116
|
+
if (h.level <= 2) {
|
|
117
|
+
chapter = h.level === 2 ? (/^(\d+)\. /.exec(h.title)?.[1] ?? null) : null;
|
|
118
|
+
counts.fill(0);
|
|
119
|
+
} else if (h.level === 3) {
|
|
120
|
+
counts[1]++;
|
|
121
|
+
counts[2] = 0;
|
|
122
|
+
} else if (h.level === 4) counts[2]++;
|
|
123
|
+
if (chapter === null || h.level > 4 || (h.level === 4 && counts[1] === 0)) return;
|
|
124
|
+
const number = h.level === 2 ? chapter : h.level === 3 ? `${chapter}.${counts[1]}` : `${chapter}.${counts[1]}.${counts[2]}`;
|
|
125
|
+
const next = heads.slice(k + 1).find((x) => x.level <= h.level);
|
|
126
|
+
const end = next === undefined ? lines.length + 1 : next.line;
|
|
127
|
+
const title = h.title;
|
|
128
|
+
const body = lines.slice(h.line - 1, end - 1).join('\n');
|
|
129
|
+
out.push({ number, title, start: h.line, end, bytes: utf8Bytes(body) + (next === undefined ? 0 : 1) });
|
|
130
|
+
});
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The ledger rows, in the order written. */
|
|
135
|
+
export function ledgerRows(text: string): LedgerRow[] {
|
|
136
|
+
const lines = text.split('\n');
|
|
137
|
+
const inFence = fenced(lines);
|
|
138
|
+
const sections = guideSections(text);
|
|
139
|
+
const rows: LedgerRow[] = [];
|
|
140
|
+
let heading = '';
|
|
141
|
+
let header: [string, string] | null = null;
|
|
142
|
+
let kind: LedgerKind | null = null;
|
|
143
|
+
let inTable = false;
|
|
144
|
+
lines.forEach((l, i) => {
|
|
145
|
+
if (!inFence[i] && /^#{1,6} /.test(l)) heading = l.replace(/^#{1,6} /, '');
|
|
146
|
+
if (inFence[i] || !l.startsWith('|')) {
|
|
147
|
+
inTable = false;
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const first = (l.split('|')[1] ?? '').trim();
|
|
151
|
+
if (!inTable) {
|
|
152
|
+
inTable = true;
|
|
153
|
+
kind = (LEDGER_HEADS as readonly string[]).includes(first) ? (first as LedgerKind) : null;
|
|
154
|
+
header = [l, lines[i + 1] ?? ''];
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (kind === null || header === null || /^:?-+:?$/.test(first)) return;
|
|
158
|
+
const names = [...first.matchAll(/`([^`]+)`/g)].map((x) => x[1]).filter((t) => RULE_NAME.test(t));
|
|
159
|
+
if (names.length === 0) return;
|
|
160
|
+
const lead = /^((?:<a id="[^"]*"><\/a>)*)/.exec(first)?.[1] ?? '';
|
|
161
|
+
const anchors = [...lead.matchAll(/<a id="([^"]*)"><\/a>/g)].map((x) => x[1]);
|
|
162
|
+
const line = i + 1;
|
|
163
|
+
const holding = sections.filter((s) => s.start <= line && line < s.end).sort((a, b) => b.number.split('.').length - a.number.split('.').length);
|
|
164
|
+
rows.push({ kind, line, header, text: l, names, anchors, section: holding[0] ?? null, heading });
|
|
165
|
+
});
|
|
166
|
+
return rows;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function editDistance(a: string, b: string): number {
|
|
170
|
+
let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
171
|
+
for (let i = 1; i <= a.length; i++) {
|
|
172
|
+
const cur = [i];
|
|
173
|
+
for (let j = 1; j <= b.length; j++) cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
174
|
+
prev = cur;
|
|
175
|
+
}
|
|
176
|
+
return prev[b.length];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The {@link NEAREST_LISTED} names of `known` nearest `asked`, case aside — rig-c's ranking, the one the shared form
|
|
181
|
+
* fixes: a name `asked` is the start of (or, from three characters, a part of) first; then by edit distance less the
|
|
182
|
+
* prefix the two share; then by name. A list, never a substitute.
|
|
183
|
+
*/
|
|
184
|
+
export function nearestNames(known: readonly string[], asked: string): string[] {
|
|
185
|
+
const key = asked.toUpperCase();
|
|
186
|
+
const shared = (name: string): number => {
|
|
187
|
+
let k = 0;
|
|
188
|
+
while (k < key.length && k < name.length && key[k] === name[k]) k++;
|
|
189
|
+
return k;
|
|
190
|
+
};
|
|
191
|
+
const score = (name: string): number => {
|
|
192
|
+
const up = name.toUpperCase();
|
|
193
|
+
return up.startsWith(key) || (key.length >= 3 && up.includes(key)) ? -Infinity : editDistance(key, up) - shared(up);
|
|
194
|
+
};
|
|
195
|
+
return [...new Set(known)]
|
|
196
|
+
.map((name) => ({ name, d: score(name) }))
|
|
197
|
+
.sort((a, b) => a.d - b.d || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
|
198
|
+
.slice(0, NEAREST_LISTED)
|
|
199
|
+
.map((x) => x.name);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** A size as the shared form prints it: KB to one decimal. */
|
|
203
|
+
export function kb(bytes: number): string {
|
|
204
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** One row's block: where it is, then the row as written. */
|
|
208
|
+
function rowBlock(row: LedgerRow): string[] {
|
|
209
|
+
return [`${GUIDE_DOC}${row.section === null ? '' : ` §${row.section.number}`}, line ${row.line} — under "${row.heading}"`, row.text];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** The other half of a refusal–bar pair: `CHECK_<LINE>` and `<LINE>`, each when the guide has its row. */
|
|
213
|
+
function pairOf(name: string, rows: readonly LedgerRow[]): { row: LedgerRow; name: string } | null {
|
|
214
|
+
const other = name.startsWith('CHECK_') ? name.slice('CHECK_'.length) : `CHECK_${name}`;
|
|
215
|
+
const want: LedgerKind = name.startsWith('CHECK_') ? 'line' : 'rule';
|
|
216
|
+
const row = rows.find((r) => r.kind === want && r.names.includes(other));
|
|
217
|
+
return row === undefined ? null : { row, name: other };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** What `guide` answers: the lines to print, or the refusal. */
|
|
221
|
+
export type GuideAnswer = { lines: string[] } | { problem: Problem };
|
|
222
|
+
|
|
223
|
+
/** `guide <RULE>`: the row naming it, then `paired with <OTHER>:` and its pair's; or `GUIDE_RULE_KNOWN` with the nearest names. */
|
|
224
|
+
export function guideRule(text: string, name: string): GuideAnswer {
|
|
225
|
+
const rows = ledgerRows(text);
|
|
226
|
+
const own = rows.find((r) => r.names.includes(name));
|
|
227
|
+
if (own === undefined) {
|
|
228
|
+
return { problem: { code: 'GUIDE_RULE_KNOWN', object: `"${name}"`, detail: `no row in ${GUIDE_DOC} names it; the nearest: ${nearestNames(rows.flatMap((r) => r.names), name).join(', ')}` } };
|
|
229
|
+
}
|
|
230
|
+
const pair = pairOf(name, rows);
|
|
231
|
+
return { lines: [...rowBlock(own), ...(pair === null ? [] : [`paired with ${pair.name}:`, ...rowBlock(pair.row)])] };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** `guide §n`, `§n.m` or `§n.m.k`: a line naming the slice, its children's listing when it has children, then the slice. */
|
|
235
|
+
export function guideSection(text: string, ask: string): GuideAnswer {
|
|
236
|
+
const sections = guideSections(text);
|
|
237
|
+
const depth = (s: GuideSection): number => s.number.split('.').length;
|
|
238
|
+
const childrenOf = (n: string | null): GuideSection[] => sections.filter((s) => (n === null ? depth(s) === 1 : s.number.startsWith(`${n}.`) && depth(s) === n.split('.').length + 1));
|
|
239
|
+
const m = /^§(\d+(?:\.\d+){0,2})$/.exec(ask);
|
|
240
|
+
const hit = m === null ? undefined : sections.find((s) => s.number === m[1]);
|
|
241
|
+
if (hit === undefined) {
|
|
242
|
+
// The deepest address the asked one shares, and what it holds: never a section put in its place.
|
|
243
|
+
const asked = m === null ? [] : m[1].split('.');
|
|
244
|
+
let parent: GuideSection | undefined;
|
|
245
|
+
for (let d = asked.length - 1; d >= 1 && parent === undefined; d--) parent = sections.find((s) => s.number === asked.slice(0, d).join('.'));
|
|
246
|
+
const kids = childrenOf(parent?.number ?? null);
|
|
247
|
+
const list = kids.length === 0 ? 'none' : kids.map((s) => `§${s.number}`).join(', ');
|
|
248
|
+
const deepest = parent === undefined ? `the guide itself, ${GUIDE_DOC}` : `§${parent.number} "${parent.title}"`;
|
|
249
|
+
return { problem: { code: 'GUIDE_SECTION_KNOWN', object: `"${ask}"`, detail: `no such section; the deepest that exists is ${deepest}, whose sections are: ${list}` } };
|
|
250
|
+
}
|
|
251
|
+
const lines = text.split('\n');
|
|
252
|
+
const head = [`${GUIDE_DOC} §${hit.number} "${hit.title}", lines ${hit.start}–${hit.end - 1}, ${kb(hit.bytes)}`];
|
|
253
|
+
for (const s of childrenOf(hit.number)) head.push(`§${s.number.padEnd(6)} ${kb(s.bytes).padStart(9)} ${s.title}`);
|
|
254
|
+
return { lines: [...head, '', lines.slice(hit.start - 1, hit.end - 1).join('\n').replace(/\n+$/, '')] };
|
|
255
|
+
}
|