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/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 t = halfTravels(art, p, inp.stage, shown, rises ? 'up' : 'down');
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 (t.root === null || t.tip === null) {
7971
- rows.push({ part: p.name, ...said, unmeasured: shown[0] === null ? 'its slot shows no attachment on idle frame 0' : `on idle frame 0 the ${t.root === null ? rootHalf : tipHalf} half of its box holds no art its attachment draws` });
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 ? round3(t.tip) === 0 : ratio <= TIP_REST_RATIO_CEILING) : ratio === null ? t.tip > 0 : ratio >= TIP_RATIO_FLOOR;
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: `the art in its ${tipHalf} half travels ${round3(t.tip)} rig px and in its ${rootHalf} half ${round3(t.root)} rig px (ratio ${ratio ?? 'undefined: the root does not move'}); 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`,
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
- 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 });
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, readGeometry(geometryPath, frames)?.setupBones);
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.passes
7999
- ? `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)}, which the bar passes (ratio ${carry.root > 0 ? round3(carry.tip / carry.root) : 'undefined: the root moves only with it'}) — bind the root to the bone the part hangs from, not to \`${carry.bone}\``
8000
- : `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)}, which is still below the bar (ratio ${carry.root > 0 ? round3(carry.tip / carry.root) : 'undefined: the root moves only with it'}, < ${TIP_RATIO_FLOOR}) — drop the segment on \`${carry.bone}\` from \`meshes.${p.name}.segments\` (or bind the root to the bone the part hangs from, not to \`${carry.bone}\`) and grow the chain's amplitudes toward the tip`;
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: `the art in its ${tipHalf} half travels ${round3(t.tip)} rig px and in its ${rootHalf} half ${round3(t.root)} rig px (ratio ${ratio ?? 'undefined: nothing moves'}); a ratio >= ${TIP_RATIO_FLOOR} is required — ${misplaced ?? why}`,
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 (the sleeve's links ride `chest`, which is the carrier). Null otherwise.
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: { art: ArtMask; box: { x: number; y: number; w: number; h: number }; stage: StageBox; shown: ReadonlyArray<PosedAttachment | null>; toward: 'down' | 'up'; root: number; tip: number },
8121
- ): { bone: string; keyed: string; breath: number; root: number; tip: number; passes: boolean } | null {
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
- checkSeam(c, a);
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 checkSeam(c: Check, a: Record<string, Json>): void {
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
- checkSeam(c, a);
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
  }