rig-parts 2.4.1 → 2.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/cli.ts +11 -9
- package/docs/AUTHORING.md +87 -6
- package/docs/STABILITY.md +8 -1
- package/package.json +2 -2
- package/src/assemble.ts +70 -19
- package/src/build.ts +17 -6
- package/src/check.ts +176 -20
- package/src/config.ts +12 -6
- package/src/paintingedges.ts +543 -0
- package/src/projectrule.ts +22 -0
- package/src/provenance.ts +1 -1
package/src/check.ts
CHANGED
|
@@ -7889,9 +7889,117 @@ const NO_TIP_EXCEPTIONS: TipExceptions = { path: '', not_swinging: [], rises: []
|
|
|
7889
7889
|
* the idle's posed geometry, not a render of the part. Until #118 the line
|
|
7890
7890
|
* read rendered pixels — figures in frame px under `root_px`/`tip_px` — so an
|
|
7891
7891
|
* old and a new check.json are told apart by this key and by the units in
|
|
7892
|
-
* the field names.
|
|
7892
|
+
* the field names. Issue #987: the halves are read in the frame of the bone
|
|
7893
|
+
* the part hangs from ({@link hangBone}), the world reading beside them
|
|
7894
|
+
* (`world_*`), so the key names the frame too.
|
|
7893
7895
|
*/
|
|
7894
|
-
export const TIP_INSTRUMENT = 'geometry';
|
|
7896
|
+
export const TIP_INSTRUMENT = 'geometry, carrier frame';
|
|
7897
|
+
|
|
7898
|
+
/**
|
|
7899
|
+
* The bone a part hangs from (issue #987): the deepest common ancestor-or-self of the bones its attachments are
|
|
7900
|
+
* bound to ({@link boundBones}), walked up while it is a link — a bone of a chain `CHAIN_LAG` reads, a bone a
|
|
7901
|
+
* physics constraint drives, or a lone swing (a rotate-keyed bone with no child bone but its control twin) — to the first bone that is not, so a chain-hung part is read against the body that
|
|
7902
|
+
* carries the chain and not against its own first link (no bone is read by its name). When the common bone is a
|
|
7903
|
+
* link's control twin (at its origin, unturned, unscaled, keyed by no idle track), the walk starts from the link; only
|
|
7904
|
+
* there, so a body bone that is its own `_ctl`'s twin (`chest`) is where the walk stops. Null when the part is bound to
|
|
7905
|
+
* nothing or the walk leaves the tree, and the part is then read in the world.
|
|
7906
|
+
*/
|
|
7907
|
+
export function hangBone(rig: Record<string, unknown>, motion: Record<string, unknown>, part: string): string | null {
|
|
7908
|
+
const bones = (Array.isArray(rig.bones) ? rig.bones : []).filter(isRecord).filter((b) => typeof b.name === 'string');
|
|
7909
|
+
const parent = new Map(bones.map((b) => [String(b.name), typeof b.parent === 'string' ? b.parent : null]));
|
|
7910
|
+
const upFrom = (b: string): string[] => {
|
|
7911
|
+
const path: string[] = [];
|
|
7912
|
+
for (let x: string | null = b; x !== null && !path.includes(x); x = parent.get(x) ?? null) path.push(x);
|
|
7913
|
+
return path;
|
|
7914
|
+
};
|
|
7915
|
+
const bound = [...boundBones(rig, part)];
|
|
7916
|
+
if (bound.length === 0) return null;
|
|
7917
|
+
const paths = bound.map(upFrom);
|
|
7918
|
+
const common = paths[0].find((b) => paths.every((p) => p.includes(b)));
|
|
7919
|
+
if (common === undefined) return null;
|
|
7920
|
+
// A link: a bone of a chain CHAIN_LAG reads, or a bone a physics constraint drives. The rig stage keys a chain's
|
|
7921
|
+
// `<link>_ctl` and binds the mesh to its child the physics drives, and a physics-only chain has no keys at all, so
|
|
7922
|
+
// both are walked up through until the body bone that carries them.
|
|
7923
|
+
const links = new Set(chainFigures(rig, motion).chains.filter((c) => c.length >= 2).flatMap((c) => c.map((l) => l.bone)));
|
|
7924
|
+
for (const c of Array.isArray(rig.constraints) ? rig.constraints : []) if (isRecord(c) && c.type === 'physics' && typeof c.bone === 'string') links.add(c.bone);
|
|
7925
|
+
// Every bone an idle track keys, a group track's members included (as chainFigures reads them); `rotated` keeps
|
|
7926
|
+
// the ones a rotate track keys.
|
|
7927
|
+
const idle = isRecord(motion.animations) && isRecord(motion.animations.idle) ? motion.animations.idle : {};
|
|
7928
|
+
const groups = isRecord(motion.groups) ? motion.groups : {};
|
|
7929
|
+
const keyedBones = new Set<string>();
|
|
7930
|
+
const rotated = new Set<string>();
|
|
7931
|
+
for (const t of (Array.isArray(idle.tracks) ? idle.tracks : []).filter(isRecord)) {
|
|
7932
|
+
const targets = typeof t.bone === 'string' ? [t.bone] : typeof t.group === 'string' && Array.isArray(groups[t.group]) ? (groups[t.group] as unknown[]).filter((m): m is string => typeof m === 'string') : [];
|
|
7933
|
+
for (const b of targets) {
|
|
7934
|
+
keyedBones.add(b);
|
|
7935
|
+
if (t.property === 'rotate') rotated.add(b);
|
|
7936
|
+
}
|
|
7937
|
+
}
|
|
7938
|
+
const setup = new Map(bones.map((b) => [String(b.name), b]));
|
|
7939
|
+
const children = new Map<string, string[]>();
|
|
7940
|
+
for (const [b, pa] of parent) if (pa !== null) children.set(pa, [...(children.get(pa) ?? []), b]);
|
|
7941
|
+
const kids = (b: string): string[] => children.get(b) ?? [];
|
|
7942
|
+
// At its parent's origin, unturned and unscaled, keyed by no idle track: the parent carries it whole.
|
|
7943
|
+
const atOrigin = (b: string): boolean => {
|
|
7944
|
+
const r = setup.get(b);
|
|
7945
|
+
if (r === undefined || keyedBones.has(b)) return false;
|
|
7946
|
+
const zero = (k: string, at: number): boolean => r[k] === undefined || r[k] === at;
|
|
7947
|
+
return zero('x', 0) && zero('y', 0) && zero('rotation', 0) && zero('scaleX', 1) && zero('scaleY', 1) && zero('shearX', 0) && zero('shearY', 0);
|
|
7948
|
+
};
|
|
7949
|
+
// A lone swing (#995): a rotate-keyed bone with no child bones, or whose only child is its control twin with none.
|
|
7950
|
+
// That is a bone that is the swing itself, and a one-link chain's control; a rotate-keyed body segment other bones
|
|
7951
|
+
// hang under (a pelvis carrying legs) is not one, so a part bound whole to it stays read against it.
|
|
7952
|
+
for (const b of rotated) {
|
|
7953
|
+
const k = kids(b);
|
|
7954
|
+
if (k.length === 0 || (k.length === 1 && atOrigin(k[0]) && kids(k[0]).length === 0)) links.add(b);
|
|
7955
|
+
}
|
|
7956
|
+
// The rig stage's control twin: a bone at its link's origin, unturned and unscaled, that nothing keys — the link carries it whole.
|
|
7957
|
+
const twinOfLink = (b: string): boolean => {
|
|
7958
|
+
const pa = parent.get(b) ?? null;
|
|
7959
|
+
return pa !== null && links.has(pa) && atOrigin(b);
|
|
7960
|
+
};
|
|
7961
|
+
const seen = new Set<string>();
|
|
7962
|
+
let at: string | null = twinOfLink(common) ? (parent.get(common) ?? null) : common;
|
|
7963
|
+
while (at !== null && links.has(at) && !seen.has(at)) {
|
|
7964
|
+
seen.add(at);
|
|
7965
|
+
at = parent.get(at) ?? null;
|
|
7966
|
+
}
|
|
7967
|
+
return at;
|
|
7968
|
+
}
|
|
7969
|
+
|
|
7970
|
+
/**
|
|
7971
|
+
* Each idle frame's posed vertices mapped into the setup place of `bone` (issue #987): world point p on frame i
|
|
7972
|
+
* goes to setup(bone) · frame_i(bone)⁻¹ · p, so what the bone's own motion carries is taken out and what moves
|
|
7973
|
+
* against it is left. Identity when `bone` is null; null when the geometry does not record the bone on every frame.
|
|
7974
|
+
*/
|
|
7975
|
+
export function carrierFrame(geo: IdleGeometry | null, bone: string | null): ((i: number, posed: readonly number[]) => number[]) | null {
|
|
7976
|
+
if (bone === null) return (_i, posed) => posed.slice();
|
|
7977
|
+
if (geo === null) return null;
|
|
7978
|
+
const world = (bs: unknown): BoneWorld | null => {
|
|
7979
|
+
const b = Array.isArray(bs) ? bs.filter(isRecord).find((x) => x.name === bone) : undefined;
|
|
7980
|
+
if (b === undefined) return null;
|
|
7981
|
+
const f = [b.a, b.b, b.c, b.d, b.worldX, b.worldY];
|
|
7982
|
+
if (!f.every((v) => typeof v === 'number' && Number.isFinite(v))) return null;
|
|
7983
|
+
return { a: b.a as number, b: b.b as number, c: b.c as number, d: b.d as number, worldX: b.worldX as number, worldY: b.worldY as number };
|
|
7984
|
+
};
|
|
7985
|
+
const s = world(geo.setupBones);
|
|
7986
|
+
const fs = geo.frameBones.map(world);
|
|
7987
|
+
if (s === null || fs.some((f) => f === null || f.a * f.d - f.b * f.c === 0)) return null;
|
|
7988
|
+
return (i, posed) => {
|
|
7989
|
+
const m = fs[i] as BoneWorld;
|
|
7990
|
+
const det = m.a * m.d - m.b * m.c;
|
|
7991
|
+
const out = posed.slice();
|
|
7992
|
+
for (let j = 0; j + 1 < out.length; j += 2) {
|
|
7993
|
+
const dx = posed[j] - m.worldX;
|
|
7994
|
+
const dy = posed[j + 1] - m.worldY;
|
|
7995
|
+
const lx = (m.d * dx - m.b * dy) / det;
|
|
7996
|
+
const ly = (-m.c * dx + m.a * dy) / det;
|
|
7997
|
+
out[j] = s.a * lx + s.b * ly + s.worldX;
|
|
7998
|
+
out[j + 1] = s.c * lx + s.d * ly + s.worldY;
|
|
7999
|
+
}
|
|
8000
|
+
return out;
|
|
8001
|
+
};
|
|
8002
|
+
}
|
|
7895
8003
|
|
|
7896
8004
|
/**
|
|
7897
8005
|
* (4) Each swinging part's tip travels further than its root: how far the art
|
|
@@ -7915,11 +8023,19 @@ export const TIP_INSTRUMENT = 'geometry';
|
|
|
7915
8023
|
* relation: tip / root at most {@link TIP_REST_RATIO_CEILING}. Its row says
|
|
7916
8024
|
* so first (`declared: "rests"`), the list is the line's first figure after
|
|
7917
8025
|
* the other two, and the ceiling follows the floor.
|
|
8026
|
+
*
|
|
8027
|
+
* Issue #987: the halves are read against the bone the part hangs from
|
|
8028
|
+
* ({@link hangBone}, through {@link carrierFrame}), so a chain that trails its
|
|
8029
|
+
* body's carry is not scored as a tip that moves less; the row names that bone
|
|
8030
|
+
* (`hang_from`) and keeps the world reading (`world_*`). A half that travels 0
|
|
8031
|
+
* at the line's precision is 0, so a part that rides its carrier whole reads
|
|
8032
|
+
* 0 / 0 and fails, not a ratio of round-off.
|
|
7918
8033
|
*/
|
|
7919
8034
|
function tipLine(inp: FullInputs, geometryPath: string, frames: number, problems: Problem[], excepted: TipExceptions = NO_TIP_EXCEPTIONS): JudgementLine {
|
|
7920
8035
|
const swing = partsTagged(inp.parts, SWING_TAGS);
|
|
7921
8036
|
if (swing.length === 0) return skip(`no part comes from a See-through ${tagWords(SWING_TAGS)} layer, so there is no hem or sleeve to swing`);
|
|
7922
8037
|
const poses = attachmentPoses(geometryPath, frames);
|
|
8038
|
+
const idleGeo = readGeometry(geometryPath, frames);
|
|
7923
8039
|
// Issue #231: a part declared `rises` is read up only when the chain carrying it does rise, measured at the setup pose.
|
|
7924
8040
|
// Issue #322: at its last link, the end the tip half is the art of; the row says what was read.
|
|
7925
8041
|
const risesRead = new Map<string, string>();
|
|
@@ -7964,44 +8080,65 @@ function tipLine(inp: FullInputs, geometryPath: string, frames: number, problems
|
|
|
7964
8080
|
const [rootHalf, tipHalf] = rises ? ['lower', 'upper'] : ['upper', 'lower'];
|
|
7965
8081
|
const art = placedArt(p.x, p.y, readPng(join(inp.partsDir, `${p.name}.png`)));
|
|
7966
8082
|
const shown = poses.map((f) => f.get(p.name) ?? null);
|
|
7967
|
-
const
|
|
8083
|
+
const w = halfTravels(art, p, inp.stage, shown, rises ? 'up' : 'down');
|
|
8084
|
+
// Issue #987: the halves are read in the frame of the bone the part hangs from; the world reading rides beside them.
|
|
8085
|
+
const hangFound = hangBone(inp.rig, inp.motion, p.name);
|
|
8086
|
+
const frameOf = carrierFrame(idleGeo, hangFound);
|
|
8087
|
+
const hang = frameOf === null ? null : hangFound;
|
|
8088
|
+
const toCarrier = frameOf ?? ((_i: number, posed: readonly number[]) => posed.slice());
|
|
8089
|
+
const held = shown.map((f, i) => (f === null ? null : { rest: f.rest, triangles: f.triangles, posed: toCarrier(i, f.posed) }));
|
|
8090
|
+
const c = hang === null ? w : halfTravels(art, p, inp.stage, held, rises ? 'up' : 'down');
|
|
7968
8091
|
// A part not declared writes the row it wrote before issue #231; a declared one says which half is its tip's.
|
|
7969
8092
|
const said = { ...(rests ? { declared: 'rests' } : {}), ...(rises ? { tip_half: tipHalf, tip_read: risesRead.get(p.name) as string } : {}) };
|
|
7970
|
-
if (
|
|
7971
|
-
|
|
8093
|
+
if (w.root === null || w.tip === null || c.root === null || c.tip === null) {
|
|
8094
|
+
const half = w.root === null || c.root === null ? rootHalf : tipHalf;
|
|
8095
|
+
rows.push({ part: p.name, ...said, unmeasured: shown[0] === null ? 'its slot shows no attachment on idle frame 0' : `on idle frame 0 the ${half} half of its box holds no art its attachment draws` });
|
|
7972
8096
|
continue;
|
|
7973
8097
|
}
|
|
7974
8098
|
measured++;
|
|
8099
|
+
// Issue #987: a half that does not move against its carrier, read at the line's precision, is 0 and not the
|
|
8100
|
+
// arithmetic's residue — a rigid part reads 0 / 0, not a ratio of two round-off figures.
|
|
8101
|
+
const t = { root: round3(c.root) === 0 ? 0 : c.root, tip: round3(c.tip) === 0 ? 0 : c.tip };
|
|
7975
8102
|
const ratio = t.root > 0 ? round3(t.tip / t.root) : null;
|
|
8103
|
+
const worldRatio = w.root > 0 ? round3(w.tip / w.root) : null;
|
|
7976
8104
|
// Issue #245: a resting tip is held to the opposite relation — no further than its root, at the line's precision.
|
|
7977
|
-
const ok = rests ? (ratio === null ?
|
|
7978
|
-
rows.push({ part: p.name, ...said, root_rig_px: round3(t.root), tip_rig_px: round3(t.tip), ratio });
|
|
8105
|
+
const ok = rests ? (ratio === null ? t.tip === 0 : ratio <= TIP_REST_RATIO_CEILING) : ratio === null ? t.tip > 0 : ratio >= TIP_RATIO_FLOOR;
|
|
8106
|
+
rows.push({ part: p.name, ...said, hang_from: hang, root_rig_px: round3(t.root), tip_rig_px: round3(t.tip), ratio, world_root_rig_px: round3(w.root), world_tip_rig_px: round3(w.tip), world_ratio: worldRatio });
|
|
8107
|
+
const against = hang === null ? 'in the world' : `against \`${hang}\`, the bone it hangs from`;
|
|
8108
|
+
const inWorld = hang === null ? '' : `; in the world ${round3(w.tip)} and ${round3(w.root)}, ratio ${worldRatio ?? 'undefined'}`;
|
|
8109
|
+
const travels = `the art in its ${tipHalf} half travels ${round3(t.tip)} rig px and in its ${rootHalf} half ${round3(t.root)} rig px ${against}`;
|
|
7979
8110
|
if (!ok && rests) {
|
|
7980
8111
|
failed = true;
|
|
7981
8112
|
problems.push({
|
|
7982
8113
|
code: 'CHECK_TIP_OVER_ROOT',
|
|
7983
8114
|
object: `part "${p.name}", box ${p.x},${p.y} ${p.w}x${p.h} rig px, posed by the idle's geometry, declared rests${rises ? ' and rises' : ''}`,
|
|
7984
|
-
detail:
|
|
8115
|
+
detail: `${travels} (ratio ${ratio ?? 'undefined: the root does not move'}${inWorld}); the scene file's rests declares its tip at rest, so a ratio <= ${TIP_REST_RATIO_CEILING} is required — the tip swings: the chain's amplitudes grow toward the tip, or the tip of the mesh is weighted to a bone the idle swings`,
|
|
7985
8116
|
});
|
|
7986
8117
|
} else if (!ok) {
|
|
7987
8118
|
failed = true;
|
|
7988
8119
|
// Issue #262: a root the breath carries is named with its carrier, not blamed on the chain.
|
|
7989
8120
|
// Issue #351: when the breath carries the root and the chain is also below the bar, or when the mesh is weighted
|
|
7990
8121
|
// off its chain without a breath carrier, name that too instead of only blaming the chain's amplitudes.
|
|
7991
|
-
|
|
8122
|
+
// Issue #987: only a breath under the hang bone moves the root against it; one at or above it is in the frame.
|
|
8123
|
+
const carry = breathCarrier(inp.rig, inp.motion, p.name, geometryPath, frames, { art, box: p, stage: inp.stage, shown, toward: rises ? 'up' : 'down', root: t.root, tip: t.tip, hang, toCarrier });
|
|
7992
8124
|
if (carry !== null) rows[rows.length - 1] = { ...rows[rows.length - 1], carried_by: carry.bone, breath_rig_px: round3(carry.breath), root_without_breath_rig_px: round3(carry.root), tip_without_breath_rig_px: round3(carry.tip) };
|
|
7993
8125
|
const offChain = carry === null && round3(t.tip) > 0 ? offChainClause(inp.rig, inp.motion, p.name) : null;
|
|
7994
|
-
const misplaced = rises || carry !== null || offChain !== null ? null : chainRootsBelowArtClause(inp.rig, inp.motion, p, inp.stage,
|
|
8126
|
+
const misplaced = rises || carry !== null || offChain !== null ? null : chainRootsBelowArtClause(inp.rig, inp.motion, p, inp.stage, idleGeo?.setupBones);
|
|
8127
|
+
const hangName = hang === null ? 'the bone the part hangs from' : `\`${hang}\`, the bone the part hangs from`;
|
|
8128
|
+
const held = carry === null ? '' : `the root travels with the breath (${round3(t.root)} px of ${round3(carry.breath)} px): it is carried by \`${carry.bone}\`, which moves it more than the tip; with \`${carry.keyed}\`'s translate held still the root travels ${round3(carry.root)} rig px and the tip ${round3(carry.tip)}`;
|
|
8129
|
+
const heldRatio = carry === null ? '' : `ratio ${carry.root > 0 ? round3(carry.tip / carry.root) : 'undefined: the root moves only with it'}`;
|
|
7995
8130
|
const why =
|
|
7996
8131
|
carry === null
|
|
7997
|
-
? `the chain's amplitudes do not grow toward the tip, or the mesh is not weighted to the chain${offChain === null ? '' : `; ${offChain}`}`
|
|
7998
|
-
: carry.
|
|
7999
|
-
? `the
|
|
8000
|
-
:
|
|
8132
|
+
? `${t.root === 0 && t.tip === 0 && hang !== null ? `nothing in it moves against \`${hang}\`: it rides that bone whole; ` : ''}the chain's amplitudes do not grow toward the tip, or the mesh is not weighted to the chain${offChain === null ? '' : `; ${offChain}`}`
|
|
8133
|
+
: carry.link
|
|
8134
|
+
? `${held}, which ${carry.passes ? `the bar passes (${heldRatio})` : `is still below the bar (${heldRatio}, < ${TIP_RATIO_FLOOR})`} — \`${carry.bone}\` is a link of the chain the part hangs on, so its segment stays: key \`${carry.keyed}\`'s translate on ${hangName} or a bone above it, where this reading takes it out, not under it${carry.passes ? '' : ", and grow the chain's amplitudes toward the tip"}`
|
|
8135
|
+
: carry.passes
|
|
8136
|
+
? `${held}, which the bar passes (${heldRatio}) — bind the root to ${hangName}, not to \`${carry.bone}\``
|
|
8137
|
+
: `${held}, which is still below the bar (${heldRatio}, < ${TIP_RATIO_FLOOR}) — drop the segment on \`${carry.bone}\` from \`meshes.${p.name}.segments\` (or bind the root to ${hangName}, not to \`${carry.bone}\`) and grow the chain's amplitudes toward the tip`;
|
|
8001
8138
|
problems.push({
|
|
8002
8139
|
code: 'CHECK_TIP_OVER_ROOT',
|
|
8003
8140
|
object: `part "${p.name}", box ${p.x},${p.y} ${p.w}x${p.h} rig px, posed by the idle's geometry${rises ? ', declared rises' : ''}`,
|
|
8004
|
-
detail:
|
|
8141
|
+
detail: `${travels} (ratio ${ratio ?? (hang === null ? 'undefined: nothing moves' : `undefined: nothing moves against \`${hang}\``)}${inWorld}); a ratio >= ${TIP_RATIO_FLOOR} is required — ${misplaced ?? why}`,
|
|
8005
8142
|
});
|
|
8006
8143
|
}
|
|
8007
8144
|
}
|
|
@@ -8109,7 +8246,13 @@ const BREATH_PROPS: readonly string[] = ['translate', 'translatex', 'translatey'
|
|
|
8109
8246
|
* them. The root travels with the breath when the breath adds more to the root's travel than to the tip's and the tip
|
|
8110
8247
|
* moves above 0 at the line's precision (round3); `passes` records whether, held still, the halves pass the bar
|
|
8111
8248
|
* (tip >= {@link TIP_RATIO_FLOOR} x root). The carrier named is the bone the part is bound to ({@link boundBones}) at
|
|
8112
|
-
* or under `K` that no other such bone is above
|
|
8249
|
+
* or under `K` that no other such bone is above. Null otherwise.
|
|
8250
|
+
*
|
|
8251
|
+
* Issue #987: the halves are read in the frame of the bone the part hangs from (`m.hang`), so only a breath keyed
|
|
8252
|
+
* strictly under that bone is read, and the held pose goes through the same frame (`m.toCarrier`). Before #987 a
|
|
8253
|
+
* sleeve whose chain rides `chest` was named carried by the chain's own first link, and the advice told the author to
|
|
8254
|
+
* drop that link's segment; the breath on `chest` is now in the frame and carries nothing. `link` says the carrier
|
|
8255
|
+
* named is a link of a chain `CHAIN_LAG` reads, whose segment the advice must not tell the author to drop.
|
|
8113
8256
|
*/
|
|
8114
8257
|
function breathCarrier(
|
|
8115
8258
|
rig: Record<string, unknown>,
|
|
@@ -8117,8 +8260,18 @@ function breathCarrier(
|
|
|
8117
8260
|
part: string,
|
|
8118
8261
|
geometryPath: string,
|
|
8119
8262
|
frames: number,
|
|
8120
|
-
m: {
|
|
8121
|
-
|
|
8263
|
+
m: {
|
|
8264
|
+
art: ArtMask;
|
|
8265
|
+
box: { x: number; y: number; w: number; h: number };
|
|
8266
|
+
stage: StageBox;
|
|
8267
|
+
shown: ReadonlyArray<PosedAttachment | null>;
|
|
8268
|
+
toward: 'down' | 'up';
|
|
8269
|
+
root: number;
|
|
8270
|
+
tip: number;
|
|
8271
|
+
hang: string | null;
|
|
8272
|
+
toCarrier: (i: number, posed: readonly number[]) => number[];
|
|
8273
|
+
},
|
|
8274
|
+
): { bone: string; keyed: string; breath: number; root: number; tip: number; passes: boolean; link: boolean } | null {
|
|
8122
8275
|
const anims = isRecord(motion.animations) ? motion.animations : {};
|
|
8123
8276
|
const idle = isRecord(anims.idle) ? anims.idle : {};
|
|
8124
8277
|
const keyed = [...new Set((Array.isArray(idle.tracks) ? idle.tracks : []).filter(isRecord).filter((t) => typeof t.bone === 'string' && typeof t.property === 'string' && BREATH_PROPS.includes(t.property)).map((t) => String(t.bone)))];
|
|
@@ -8154,7 +8307,10 @@ function breathCarrier(
|
|
|
8154
8307
|
return { a: b.a as number, b: b.b as number, c: b.c as number, d: b.d as number, worldX: b.worldX as number, worldY: b.worldY as number };
|
|
8155
8308
|
};
|
|
8156
8309
|
const bound = [...boundBones(rig, part)];
|
|
8310
|
+
const links = new Set(chainFigures(rig, motion).chains.filter((c) => c.length >= 2).flatMap((c) => c.map((l) => l.bone)));
|
|
8157
8311
|
for (const k of keyed) {
|
|
8312
|
+
// Issue #987: a breath at or above the hang bone is in the frame the halves are read in, so it carries nothing.
|
|
8313
|
+
if (m.hang !== null && (k === m.hang || !atOrUnder(k, m.hang))) continue;
|
|
8158
8314
|
const under = bound.filter((b) => atOrUnder(b, k));
|
|
8159
8315
|
const carrier = under.find((b) => !under.some((o) => o !== b && atOrUnder(b, o)));
|
|
8160
8316
|
if (carrier === undefined) continue;
|
|
@@ -8174,11 +8330,11 @@ function breathCarrier(
|
|
|
8174
8330
|
const breath = Math.max(...delta.map(([x, y]) => edgeLength(x, y)));
|
|
8175
8331
|
if (!(breath > 0)) continue;
|
|
8176
8332
|
const share = weights.map((v) => v.filter((w) => atOrUnder(String(w.bone), k)).reduce((s, w) => s + (w.weight as number), 0));
|
|
8177
|
-
const held = m.shown.map((f, i) => (f === null ? null : { rest: f.rest, triangles: f.triangles, posed: f.posed.map((v, j) => v - share[j >> 1] * delta[i][j & 1]) }));
|
|
8333
|
+
const held = m.shown.map((f, i) => (f === null ? null : { rest: f.rest, triangles: f.triangles, posed: m.toCarrier(i, f.posed.map((v, j) => v - share[j >> 1] * delta[i][j & 1])) }));
|
|
8178
8334
|
const t = halfTravels(m.art, m.box, m.stage, held, m.toward);
|
|
8179
8335
|
if (t.root === null || t.tip === null) continue;
|
|
8180
8336
|
if (!(m.root - t.root > Math.max(0, m.tip - t.tip)) || round3(t.tip) === 0) continue;
|
|
8181
|
-
return { bone: carrier, keyed: k, breath, root: t.root, tip: t.tip, passes: t.tip >= TIP_RATIO_FLOOR * t.root };
|
|
8337
|
+
return { bone: carrier, keyed: k, breath, root: t.root, tip: t.tip, passes: t.tip >= TIP_RATIO_FLOOR * t.root, link: links.has(carrier) };
|
|
8182
8338
|
}
|
|
8183
8339
|
return null;
|
|
8184
8340
|
}
|
package/src/config.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { SEAM_RULES, type SeamRule } from './seamrule.ts';
|
|
2
|
+
import { PROJECT_RULES, type ProjectRule } from './projectrule.ts';
|
|
2
3
|
import type { TakeLayers } from './takelayers.ts';
|
|
3
4
|
import { paintingExtensionProblems, type PaintingExtension } from './paintingextension.ts';
|
|
4
5
|
import { baseAnchorProblems, type BaseAnchor } from './baseanchor.ts';
|
|
@@ -304,6 +305,8 @@ export interface AlternateSetConfig {
|
|
|
304
305
|
export interface Assemble {
|
|
305
306
|
/** Optional per-character seam rule; an explicit CLI flag wins. */
|
|
306
307
|
seam?: SeamRule;
|
|
308
|
+
/** Optional per-character projection rule (issue #953); an explicit CLI flag wins. */
|
|
309
|
+
project?: ProjectRule;
|
|
307
310
|
/** Verified named replacements from another See-through run (#866). */
|
|
308
311
|
take?: TakeLayers[];
|
|
309
312
|
/** Issue #946: receipts for images edited in an image editor; absent, no file is read and no byte moves. */
|
|
@@ -1626,7 +1629,7 @@ export interface EarlyConfig {
|
|
|
1626
1629
|
depth?: DepthConfig;
|
|
1627
1630
|
key: string;
|
|
1628
1631
|
seethrough: SeeThrough;
|
|
1629
|
-
assemble: { rig_scale: number; seam?: SeamRule; take?: TakeLayers[]; editor_receipts?: EditorReceipts; head_face_underfill?: boolean; yield_refused?: boolean; keep_under_occluders?: string[]; extend_painting?: PaintingExtension[] };
|
|
1632
|
+
assemble: { rig_scale: number; seam?: SeamRule; project?: ProjectRule; take?: TakeLayers[]; editor_receipts?: EditorReceipts; head_face_underfill?: boolean; yield_refused?: boolean; keep_under_occluders?: string[]; extend_painting?: PaintingExtension[] };
|
|
1630
1633
|
}
|
|
1631
1634
|
|
|
1632
1635
|
/** What plain `assemble` reads: the early config plus the plan `--propose-plan` printed. */
|
|
@@ -1702,9 +1705,9 @@ function parseEarlyConfigFrom(raw: Json, door: EarlyDoor, base: string | null):
|
|
|
1702
1705
|
if (door === 'layers') {
|
|
1703
1706
|
if ('seethrough' in t) checkSeeThrough(c, t.seethrough);
|
|
1704
1707
|
if ('assemble' in t) {
|
|
1705
|
-
const a = c.object('config.assemble', t.assemble, sectionRequired(door, 'assemble'), ['plan', 'extend_below_crop', 'patches', 'cuts', 'alternates', 'head_face_underfill', 'yield_refused', 'keep_under_occluders', 'extend_painting', 'take', 'seam', 'editor_receipts']);
|
|
1708
|
+
const a = c.object('config.assemble', t.assemble, sectionRequired(door, 'assemble'), ['plan', 'extend_below_crop', 'patches', 'cuts', 'alternates', 'head_face_underfill', 'yield_refused', 'keep_under_occluders', 'extend_painting', 'take', 'seam', 'project', 'editor_receipts']);
|
|
1706
1709
|
if (a !== null) {
|
|
1707
|
-
|
|
1710
|
+
checkAssembleRules(c, a);
|
|
1708
1711
|
if ('rig_scale' in a) c.number('config.assemble.rig_scale', a.rig_scale, 'positive');
|
|
1709
1712
|
if ('head_face_underfill' in a && typeof a.head_face_underfill !== 'boolean') {
|
|
1710
1713
|
c.fail('CONFIG_FIELD_TYPE', 'config.assemble.head_face_underfill', `is ${show(a.head_face_underfill)}; true or false is required`);
|
|
@@ -2503,17 +2506,20 @@ function checkEditorReceipts(c: Check, value: Json): void {
|
|
|
2503
2506
|
});
|
|
2504
2507
|
}
|
|
2505
2508
|
|
|
2506
|
-
function
|
|
2509
|
+
function checkAssembleRules(c: Check, a: Record<string, Json>): void {
|
|
2507
2510
|
if ('seam' in a && !(SEAM_RULES as readonly unknown[]).includes(a.seam)) {
|
|
2508
2511
|
c.fail('CONFIG_FIELD_TYPE', 'config.assemble.seam', `is ${show(a.seam)}; one of ${SEAM_RULES.join(', ')} is required`);
|
|
2509
2512
|
}
|
|
2513
|
+
if ('project' in a && !(PROJECT_RULES as readonly unknown[]).includes(a.project)) {
|
|
2514
|
+
c.fail('CONFIG_FIELD_TYPE', 'config.assemble.project', `is ${show(a.project)}; one of ${PROJECT_RULES.join(', ')} is required`);
|
|
2515
|
+
}
|
|
2510
2516
|
}
|
|
2511
2517
|
|
|
2512
2518
|
function checkAssemble(c: Check, v: Json, door: 'assemble' | 'full'): { parts: string[]; patches: string[]; cuts: string[] } {
|
|
2513
2519
|
const required = sectionRequired(door, 'assemble');
|
|
2514
|
-
const a = c.object('config.assemble', v, required.filter((k) => k !== 'plan'), ['plan', 'extend_below_crop', 'patches', 'cuts', 'alternates', 'head_face_underfill', 'yield_refused', 'keep_under_occluders', 'extend_painting', 'take', 'seam', 'editor_receipts']);
|
|
2520
|
+
const a = c.object('config.assemble', v, required.filter((k) => k !== 'plan'), ['plan', 'extend_below_crop', 'patches', 'cuts', 'alternates', 'head_face_underfill', 'yield_refused', 'keep_under_occluders', 'extend_painting', 'take', 'seam', 'project', 'editor_receipts']);
|
|
2515
2521
|
if (a === null) return { parts: [], patches: [], cuts: [] };
|
|
2516
|
-
|
|
2522
|
+
checkAssembleRules(c, a);
|
|
2517
2523
|
if (required.includes('plan') && !('plan' in a)) {
|
|
2518
2524
|
c.fail('CONFIG_FIELD_PRESENT', 'config.assemble.plan', 'is absent and required; `assemble --propose-plan` prints one from the two runs — paste its plan and extend_below_crop into config.assemble');
|
|
2519
2525
|
}
|