@rig-c/parts 1.25.0 → 1.26.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/headbox.ts CHANGED
@@ -42,6 +42,20 @@ export const HEAD_LAYER_MIN_PX = 150;
42
42
 
43
43
  export type Box = [number, number, number, number];
44
44
 
45
+ export interface HeadBoxOptions {
46
+ /** Whether `"back hair"` is included in the union (issue #351); default `true`. */
47
+ backHair?: boolean;
48
+ }
49
+
50
+ export interface HeadBoxWithoutBackHair {
51
+ /** The square box's side in source px without `"back hair"`. */
52
+ edge: number;
53
+ /** The box without `"back hair"` before clamping to the painting. */
54
+ unclamped: Box;
55
+ /** The box without `"back hair"` held inside the painting, or `null` when `edge` exceeds a canvas side. */
56
+ head_box: Box | null;
57
+ }
58
+
45
59
  export interface HeadBoxProposal {
46
60
  /** The square box, inside the painting: what goes into `seethrough.head_box`. */
47
61
  head_box: Box;
@@ -49,8 +63,10 @@ export interface HeadBoxProposal {
49
63
  unclamped: Box;
50
64
  /** How far the box was moved to stay inside, in source px. */
51
65
  shift: [number, number];
52
- /** The layers that contributed, with their opaque pixel counts after the clean-up. */
66
+ /** The layers that contributed, with their opaque pixel counts after the ghost clean-up. */
53
67
  layers: Array<[string, number]>;
68
+ /** Present when `"back hair"` contributed (with `backHair !== false`, issue #351): the box without `"back hair"`. */
69
+ without_back_hair?: HeadBoxWithoutBackHair;
54
70
  }
55
71
 
56
72
  /**
@@ -90,7 +106,7 @@ function cleanedOpaque(pixels: { width: number; height: number; data: Uint8Clamp
90
106
  * `source.w` x `source.h` pixels. Every refusal names the value found and the
91
107
  * value required.
92
108
  */
93
- export function proposeHeadBox(full: LayerSet, source: { w: number; h: number }): HeadBoxProposal {
109
+ export function proposeHeadBox(full: LayerSet, source: { w: number; h: number }, opts?: HeadBoxOptions): HeadBoxProposal {
94
110
  const problems: Problem[] = [];
95
111
  if (!(Number.isInteger(source.w) && Number.isInteger(source.h) && source.w > 0 && source.h > 0)) {
96
112
  problems.push({ code: 'HEADBOX_CANVAS_SIZE', object: '--canvas', detail: `is ${source.w}x${source.h}; the painting's size in positive integer pixels is required` });
@@ -102,12 +118,14 @@ export function proposeHeadBox(full: LayerSet, source: { w: number; h: number })
102
118
 
103
119
  const pad = squarePad(source.w, source.h);
104
120
  const k = pad.side / full.canvas.w;
121
+ const includeBackHair = opts?.backHair !== false;
105
122
  // The union does not depend on the order the layers are read in; a name
106
123
  // that appears twice is last-wins in draw order (the reference's dict was
107
124
  // last-wins in manifest order, and no wrapper output read so far repeats one).
108
125
  const boxes = new Map<string, { left: number; top: number; xs: number[]; ys: number[] }>();
109
126
  for (const L of full.layers) {
110
127
  if (!(HEAD_LAYERS as readonly string[]).includes(L.name)) continue;
128
+ if (!includeBackHair && L.name === 'back hair') continue;
111
129
  const { xs, ys } = cleanedOpaque(L.pixels);
112
130
  if (xs.length < HEAD_LAYER_MIN_PX) continue;
113
131
  boxes.set(L.name, { left: L.left, top: L.top, xs, ys });
@@ -130,44 +148,60 @@ export function proposeHeadBox(full: LayerSet, source: { w: number; h: number })
130
148
  if (f.top + y > fy1) fy1 = f.top + y;
131
149
  }
132
150
  const cut = fy1 + 0.35 * (fy1 - fy0);
133
- let x0 = Infinity;
134
- let x1 = -Infinity;
135
- let y0 = Infinity;
136
- let y1 = -Infinity;
137
- for (const b of boxes.values()) {
138
- for (let i = 0; i < b.xs.length; i++) {
139
- const y = b.top + b.ys[i];
140
- if (y > cut) continue;
141
- const x = b.left + b.xs[i];
142
- if (x < x0) x0 = x;
143
- if (x > x1) x1 = x;
144
- if (y < y0) y0 = y;
145
- if (y > y1) y1 = y;
151
+ const measureUnion = (entries: Iterable<{ left: number; top: number; xs: number[]; ys: number[] }>): { edge: number; unclamped: Box; shift: [number, number]; head_box: Box | null } => {
152
+ let x0 = Infinity;
153
+ let x1 = -Infinity;
154
+ let y0 = Infinity;
155
+ let y1 = -Infinity;
156
+ for (const b of entries) {
157
+ for (let i = 0; i < b.xs.length; i++) {
158
+ const y = b.top + b.ys[i];
159
+ if (y > cut) continue;
160
+ const x = b.left + b.xs[i];
161
+ if (x < x0) x0 = x;
162
+ if (x > x1) x1 = x;
163
+ if (y < y0) y0 = y;
164
+ if (y > y1) y1 = y;
165
+ }
146
166
  }
147
- }
148
- const size = Math.max(x1 - x0, y1 - y0) * 1.12;
149
- const cx = (x0 + x1) / 2;
150
- const cy = (y0 + y1) / 2;
151
- // Each edge rounded as the reference rounds it; then the side is the larger
152
- // of the two rounded extents, so the box is square, never holds less than
153
- // the reference's, and equals it wherever the reference's was square.
154
- const ex = [pyRound((cx - size / 2) * k), pyRound((cx + size / 2) * k)];
155
- const ey = [pyRound((cy - size / 2) * k), pyRound((cy + size / 2) * k)];
156
- const edge = Math.max(ex[1] - ex[0], ey[1] - ey[0]);
157
- const bx = ex[0] - pad.left;
158
- const by = ey[0] - pad.top;
159
- const unclamped: Box = [bx, by, bx + edge, by + edge];
160
- if (edge > source.w || edge > source.h) {
167
+ const size = Math.max(x1 - x0, y1 - y0) * 1.12;
168
+ const cx = (x0 + x1) / 2;
169
+ const cy = (y0 + y1) / 2;
170
+ // Each edge rounded as the reference rounds it; then the side is the larger
171
+ // of the two rounded extents, so the box is square, never holds less than
172
+ // the reference's, and equals it wherever the reference's was square.
173
+ const ex = [pyRound((cx - size / 2) * k), pyRound((cx + size / 2) * k)];
174
+ const ey = [pyRound((cy - size / 2) * k), pyRound((cy + size / 2) * k)];
175
+ const edge = Math.max(ex[1] - ex[0], ey[1] - ey[0]);
176
+ const bx = ex[0] - pad.left;
177
+ const by = ey[0] - pad.top;
178
+ const unclamped: Box = [bx, by, bx + edge, by + edge];
179
+ if (edge > source.w || edge > source.h) return { edge, unclamped, shift: [0, 0], head_box: null };
180
+ const hold = (lo: number, extent: number): number => (lo < 0 ? -lo : lo + edge > extent ? extent - edge - lo : 0);
181
+ const shift: [number, number] = [hold(bx, source.w), hold(by, source.h)];
182
+ const head_box: Box = [bx + shift[0], by + shift[1], bx + shift[0] + edge, by + shift[1] + edge];
183
+ return { edge, unclamped, shift, head_box };
184
+ };
185
+ const main = measureUnion(boxes.values());
186
+ const without = includeBackHair && boxes.has('back hair') ? measureUnion([...boxes.entries()].filter(([n]) => n !== 'back hair').map(([, b]) => b)) : null;
187
+ if (main.head_box === null) {
188
+ const hairNote =
189
+ without !== null && without.edge < main.edge
190
+ ? ` ("back hair" grew the side from ${without.edge} to ${main.edge} px; without it${without.head_box !== null ? `, [${without.head_box.join(', ')}] (${without.edge}x${without.edge}) fits —` : ''} pass --no-back-hair)`
191
+ : '';
161
192
  refuseIfAny([
162
193
  {
163
194
  code: 'HEADBOX_FITS_CANVAS',
164
195
  object: 'head box',
165
- detail: `is ${edge}x${edge} source px, proposed at [${unclamped.join(', ')}]; the ${source.w}x${source.h} painting cannot hold it at that size, and shrinking it would cut the head`,
196
+ detail: `is ${main.edge}x${main.edge} source px, proposed at [${main.unclamped.join(', ')}]; the ${source.w}x${source.h} painting cannot hold it at that size, and shrinking it would cut the head${hairNote}`,
166
197
  },
167
198
  ]);
168
199
  }
169
- const hold = (lo: number, extent: number): number => (lo < 0 ? -lo : lo + edge > extent ? extent - edge - lo : 0);
170
- const shift: [number, number] = [hold(bx, source.w), hold(by, source.h)];
171
- const head_box: Box = [bx + shift[0], by + shift[1], bx + shift[0] + edge, by + shift[1] + edge];
172
- return { head_box, unclamped, shift, layers: [...boxes.entries()].map(([n, b]) => [n, b.xs.length]) };
200
+ return {
201
+ head_box: main.head_box as Box,
202
+ unclamped: main.unclamped,
203
+ shift: main.shift,
204
+ layers: [...boxes.entries()].map(([n, b]) => [n, b.xs.length]),
205
+ ...(without === null ? {} : { without_back_hair: { edge: without.edge, unclamped: without.unclamped, head_box: without.head_box } }),
206
+ };
173
207
  }
package/src/propose.ts CHANGED
@@ -148,7 +148,7 @@ import { TURN_BONE, TURN_CARRIER_TAG, TURN_PLACES, TURN_RIDER_OFFSET, TURN_RIDER
148
148
  import { edgeLength } from './length.ts';
149
149
  import { limbWidth } from './limbs.ts';
150
150
  import { type BranchSqueezeFinding, branchSqueezes, chainSqueezes, type SqueezeFinding } from './squeeze.ts';
151
- import { RECIPE_LENGTH_RATIO, chainConstraints, clothNote, type ClothRecipe, type ClothRecipes, type ClothTarget, CLOTH_TARGETS, draftTrees, swingName, type Tail, tailOverlap, type TailOverlap, type TailSpan, treeConstraints, type TreeChain, type TreeInput } from './cloth.ts';
151
+ import { RECIPE_BACK_HAIR_CHAINS, RECIPE_LENGTH_RATIO, chainConstraints, clothNote, type ClothRecipe, type ClothRecipes, type ClothTarget, CLOTH_TARGETS, draftTrees, swingName, type Tail, tailOverlap, type TailOverlap, type TailSpan, treeConstraints, type TreeChain, type TreeInput } from './cloth.ts';
152
152
  import { TEXTURE_STRETCH_CEILING } from './check.ts';
153
153
 
154
154
  // ---------------------------------------------------------------------------
@@ -278,20 +278,23 @@ export interface Proposal {
278
278
  constraints?: ConfigConstraint[];
279
279
  /** Only with `propose --full`/`--head` (issue #251, {@link draftTurn}); absent otherwise, so a proposal without them is the one written before it. */
280
280
  turn?: TurnSpec;
281
+ /** The `assemble.rig_scale` (`parts.json`'s `scale_rig_per_source`) its coordinates were drafted at (issue #354). */
282
+ proposed_rig_scale: number;
281
283
  notes: string[];
282
284
  }
283
285
 
284
286
  /**
285
287
  * Every section of a proposal that is a config section, in the order the
286
288
  * proposal writes them: what an author copies into the config (issue #375).
287
- * `notes` is the proposal's own and is copied nowhere. Two of them are written
288
- * only on request — `limbs` under `--keypoints`, `turn` under `--full`/`--head`
289
- * — and a `limbs` block left behind is refused at the next step, by the name of
290
- * every part it cuts. {@link checkProposal} reads the proposal through the config
291
- * loader by this list, the type below refuses a {@link Proposal} key it does not
292
- * hold, and the selftest's `TY18` holds every paste sentence in the documents to it.
289
+ * `notes` is the proposal's own and is copied nowhere. Three of them are written
290
+ * only on request — `limbs` under `--keypoints`, `constraints` under `--cloth`,
291
+ * `turn` under `--full`/`--head` — and a `limbs` block left behind is refused
292
+ * at the next step, by the name of every part it cuts. {@link checkProposal} reads
293
+ * the proposal through the config loader by this list, the type below refuses a
294
+ * {@link Proposal} key it does not hold, and the selftest's `TY18` holds every
295
+ * paste sentence in the documents to it.
293
296
  */
294
- export const PROPOSAL_CONFIG_SECTIONS = ['bones', 'meshes', 'regions', 'limbs', 'motion', 'constraints', 'turn'] as const satisfies ReadonlyArray<Exclude<keyof Proposal, 'notes'>>;
297
+ export const PROPOSAL_CONFIG_SECTIONS = ['bones', 'meshes', 'regions', 'limbs', 'motion', 'constraints', 'turn', 'proposed_rig_scale'] as const satisfies ReadonlyArray<Exclude<keyof Proposal, 'notes'>>;
295
298
 
296
299
  /** A {@link Proposal} key that is neither `notes` nor in {@link PROPOSAL_CONFIG_SECTIONS}; `never` while the list is whole. */
297
300
  type UnlistedProposalSection = Exclude<keyof Proposal, 'notes' | (typeof PROPOSAL_CONFIG_SECTIONS)[number]>;
@@ -414,7 +417,7 @@ export function proposalAssemble(P: PartSet): Record<string, unknown> {
414
417
  const planned = run.filter((r) => r.cut_from === undefined && firstOf.get(r.from) === r.name);
415
418
  const pieces = run.filter((r) => r.cut_from !== undefined || firstOf.get(r.from) !== r.name);
416
419
  return {
417
- rig_scale: 1,
420
+ rig_scale: P.rigScale,
418
421
  plan: planned.map((r) => [r.name, ...splitFrom(r.from)]),
419
422
  patches: painted.map((r) => ({ name: r.name, box: [r.x, r.y, r.x + r.w, r.y + r.h], alpha: 'box', draw: 'front' })),
420
423
  ...(pieces.length === 0
@@ -611,7 +614,7 @@ export function withTurn(p: Proposal, draft: { turn: TurnSpec | null; notes: str
611
614
  }
612
615
  turn = { pivot: turn.pivot, depths };
613
616
  }
614
- return { bones: p.bones, meshes: p.meshes, regions: p.regions, ...(p.limbs === undefined ? {} : { limbs: p.limbs }), motion: p.motion, ...(p.constraints === undefined ? {} : { constraints: p.constraints }), ...(turn === null ? {} : { turn }), notes: [...p.notes, ...draft.notes] };
617
+ return { bones: p.bones, meshes: p.meshes, regions: p.regions, ...(p.limbs === undefined ? {} : { limbs: p.limbs }), motion: p.motion, ...(p.constraints === undefined ? {} : { constraints: p.constraints }), ...(turn === null ? {} : { turn }), proposed_rig_scale: p.proposed_rig_scale, notes: [...p.notes, ...draft.notes] };
615
618
  }
616
619
 
617
620
  // ---------------------------------------------------------------------------
@@ -622,12 +625,14 @@ export function withTurn(p: Proposal, draft: { turn: TurnSpec | null; notes: str
622
625
  export class PartSet {
623
626
  readonly W: number;
624
627
  readonly H: number;
628
+ readonly rigScale: number;
625
629
  readonly recs: readonly PartRecord[];
626
630
  readonly images: ReadonlyMap<string, Raster>;
627
631
  private readonly masks = new Map<string, Mask>();
628
632
 
629
633
  constructor(file: PartsFile, images: ReadonlyMap<string, Raster>) {
630
634
  [this.W, this.H] = file.rig_size;
635
+ this.rigScale = file.scale_rig_per_source;
631
636
  this.recs = file.parts;
632
637
  this.images = images;
633
638
  }
@@ -1377,7 +1382,7 @@ export function proposeWithBasis(
1377
1382
  const tails: Tail[] = [];
1378
1383
  const T = (p: PartRecord, tag: ClothTarget, r: ClothRecipe, input: TreeInput): TreeChain[] => {
1379
1384
  const trees = draftTrees(input, r);
1380
- const constants = [konst('cloth_chains', r.chains), konst('cloth_links', r.links), konst('length_ratio', roundTo(r.length_ratio ?? RECIPE_LENGTH_RATIO, 6))];
1385
+ const constants = [konst('cloth_chains', r.chains ?? RECIPE_BACK_HAIR_CHAINS), konst('cloth_links', r.links), konst('length_ratio', roundTo(r.length_ratio ?? RECIPE_LENGTH_RATIO, 6))];
1381
1386
  for (const t of trees) {
1382
1387
  C(
1383
1388
  t.chain,
@@ -1534,13 +1539,69 @@ export function proposeWithBasis(
1534
1539
  } else if (recipeOf('back hair') !== null) {
1535
1540
  // Issue #364: the recipe's trees, per side of the eye axis (twin tails are a side each), from the eye line to
1536
1541
  // 4 px above the part's bottom, in place of the two keyed chains.
1537
- const rcp = recipeOf('back hair') as ClothRecipe;
1542
+ // Issue #390: the tree count per side derives from the tail's opaque width at the root row (`floor(width / grid)`
1543
+ // on each side, and every tree root inside the 15 px dilated art); a declared count that does not fit is refused
1544
+ // by name (`PROPOSE_CLOTH_BACK_HAIR_CHAINS`).
1545
+ const rawRcp = recipeOf('back hair') as ClothRecipe;
1538
1546
  const mask = P.alpha(p);
1547
+ const dilated = dilate(mask, 31);
1548
+ const y0 = pyRound(eyeY);
1549
+ const sideOpaqueWidth = (s: 'r' | 'l'): number => {
1550
+ if (y0 < 0 || y0 >= mask.height) return 0;
1551
+ let w = 0;
1552
+ for (let x = 0; x < mask.width; x++) {
1553
+ if ((s === 'r' ? x < axis : x >= axis) && mask.data[y0 * mask.width + x]) w++;
1554
+ }
1555
+ return w;
1556
+ };
1557
+ const prefixOf = (s: 'r' | 'l'): string => (P.headFirst('back hair').length === 1 ? `hairback_${s}` : `hairback_${p.name}_${s}`);
1558
+ const offRootsAt = (cnt: number, s: 'r' | 'l'): Array<{ bone: string; at: Point; alpha: number }> => {
1559
+ const pref = prefixOf(s);
1560
+ const im = P.images.get(p.name) as Raster;
1561
+ const off: Array<{ bone: string; at: Point; alpha: number }> = [];
1562
+ for (let k = 0; k < cnt; k++) {
1563
+ const fc = (k + 0.5) / cnt;
1564
+ const rx = pyRound(bandX(mask, eyeY, { frac: fc, side: s, axis }) ?? axis);
1565
+ if (!isOn(dilated, rx, y0)) {
1566
+ const lx = rx - p.x;
1567
+ const ly = y0 - p.y;
1568
+ const alpha = lx >= 0 && lx < p.w && ly >= 0 && ly < p.h ? im.data[(ly * p.w + lx) * 4 + 3] : 0;
1569
+ off.push({ bone: `${pref}${k}_0`, at: [rx, y0], alpha });
1570
+ }
1571
+ }
1572
+ return off;
1573
+ };
1574
+ const wR = sideOpaqueWidth('r');
1575
+ const wL = sideOpaqueWidth('l');
1576
+ const widthCap = Math.max(1, Math.min(Math.floor(wR / g), Math.floor(wL / g)));
1577
+ let maxFit = 1;
1578
+ for (let cnt = widthCap; cnt >= 1; cnt--) {
1579
+ if (offRootsAt(cnt, 'r').length === 0 && offRootsAt(cnt, 'l').length === 0) {
1580
+ maxFit = cnt;
1581
+ break;
1582
+ }
1583
+ }
1584
+ if (rawRcp.chains !== undefined) {
1585
+ const offRoots = [...offRootsAt(rawRcp.chains, 'r'), ...offRootsAt(rawRcp.chains, 'l')];
1586
+ if (rawRcp.chains > widthCap || offRoots.length > 0) {
1587
+ throw new PartsError([
1588
+ {
1589
+ code: 'PROPOSE_CLOTH_BACK_HAIR_CHAINS',
1590
+ object: 'config.cloth."back hair".chains',
1591
+ detail:
1592
+ `declares ${rawRcp.chains} tree(s) per side on "${p.name}", and at the root row y=${y0} the tail is ${wR} px wide on the r side and ${wL} px on the l side on a ${g} px mesh grid (at most ${maxFit} tree(s) per side fit inside the art` +
1593
+ (offRoots.length > 0 ? `; off the art: ${offRoots.map((o) => `${o.bone} at [${o.at[0]}, ${o.at[1]}] (alpha ${o.alpha})`).join(', ')}` : '') +
1594
+ `) — set chains to at most ${maxFit}, or leave chains out to derive it from the tail's width`,
1595
+ },
1596
+ ]);
1597
+ }
1598
+ }
1599
+ const rcp: ClothRecipe = { ...rawRcp, chains: rawRcp.chains ?? Math.min(RECIPE_BACK_HAIR_CHAINS, maxFit) };
1539
1600
  const segs: Segment[] = [['head', rnd(head), rnd([axis, top + 13])]];
1540
1601
  const all: TreeChain[] = [];
1541
1602
  let n = 0;
1542
1603
  for (const [side] of SIDES) {
1543
- const prefix = P.headFirst('back hair').length === 1 ? `hairback_${side}` : `hairback_${p.name}_${side}`;
1604
+ const prefix = prefixOf(side);
1544
1605
  const trees = T(p, 'back hair', rcp, {
1545
1606
  prefix,
1546
1607
  parent: 'head',
@@ -2119,9 +2180,10 @@ export function proposeWithBasis(
2119
2180
  throw new PartsError([{ code: 'PROPOSE_CLOTH_PART', object: 'config.cloth."bottomwear".part', detail }]);
2120
2181
  };
2121
2182
  const q = P.recs.find((x) => x.name === name);
2122
- if (q === undefined) return refuse(`names "${name}", which is no part in parts.json; a topwear part whose art hangs below the hip (a dress) is required`);
2123
- if (q.cut_from !== undefined) return refuse(`names "${name}", a piece an assemble.cuts entry took from ${q.cut_from}; name the part the plan made, ${q.cut_from}, or leave part out`);
2124
- if (!tw.includes(q)) {
2183
+ if (q === undefined) return refuse(`names "${name}", which is no part in parts.json; a topwear part or its cut skirt piece whose art hangs below the hip (a dress) is required`);
2184
+ // An explicit name may select a topwear cut piece; automatic tag rules still exclude pieces.
2185
+ const [run, tag] = splitFrom(q.from);
2186
+ if (run === PAINTING_RUN || tag !== 'topwear') {
2125
2187
  return refuse(
2126
2188
  `names "${name}", from ${q.from}; ${splitFrom(q.from)[1] === 'bottomwear' ? 'a bottomwear part takes the recipe by its tag already — leave part out' : "only a topwear part takes the skirt's recipe by name (a skirt See-through returned inside topwear, a dress)"}`,
2127
2189
  );
@@ -2161,8 +2223,8 @@ export function proposeWithBasis(
2161
2223
  ['chest', rnd(along(hip, trunk.u, artExtent(mask, p, hip, trunk.u)[0])), rnd(along(hip, trunk.u, -20))],
2162
2224
  ['hip', rnd(along(hip, trunk.u, -20)), rnd(along(hip, trunk.u, 100))],
2163
2225
  ];
2164
- if (bw.length > 0 && bot < hip[1] + TOPWEAR_HANGS_FACE_HEIGHTS * fh) {
2165
- // A bodice that stops at the waist: robe chains down to the hem would fall off the art.
2226
+ if ((bw.length > 0 || namedSkirt?.cut_from === p.name) && bot < hip[1] + TOPWEAR_HANGS_FACE_HEIGHTS * fh) {
2227
+ // A bodice above bottomwear, including its explicitly named cut skirt: robe chains would fall off the art.
2166
2228
  meshes[p.name] = { grid: g, r, segments: segs };
2167
2229
  notes.push(`${p.name} ends at y=${bot} (hip ${pyFixed(hip[1], 0)}): no robe chains -- add a sash chain by hand if one hangs`);
2168
2230
  continue;
@@ -2515,7 +2577,7 @@ export function proposeWithBasis(
2515
2577
  }
2516
2578
  const constrained = clothConstraints.length === 0 ? {} : { constraints: clothConstraints };
2517
2579
  return {
2518
- proposal: { bones, meshes, regions, ...drafted, motion: blink === undefined ? { duration: IDLE_SECONDS, tracks: idleTracks } : { duration: IDLE_SECONDS, tracks: idleTracks, blink }, ...constrained, notes },
2580
+ proposal: { bones, meshes, regions, ...drafted, motion: blink === undefined ? { duration: IDLE_SECONDS, tracks: idleTracks } : { duration: IDLE_SECONDS, tracks: idleTracks, blink }, ...constrained, proposed_rig_scale: P.rigScale, notes },
2519
2581
  basis: { frame, bones: records },
2520
2582
  };
2521
2583
  }
package/src/rig.ts CHANGED
@@ -388,6 +388,57 @@ export function roundShares(list: readonly Influence[]): Influence[] {
388
388
  return kept;
389
389
  }
390
390
 
391
+ /**
392
+ * Issue #392: on a lattice mesh whose segments include sibling branch chains
393
+ * forked from the same chain link with `r >= grid` (`split.hold_tip: true`),
394
+ * the distance rule still concentrates weights along the fork row where the
395
+ * trunk splits into branches and across adjacent trees' ruffled hem steps.
396
+ * Four Laplacian passes (`FORK_SMOOTH_PASSES`, self share 0.5, floor 0.005, cap
397
+ * 8 influences) diffuse each vertex's shares across its triangle neighbours
398
+ * before rounding with {@link roundShares}.
399
+ */
400
+ export const FORK_SMOOTH_PASSES = 4;
401
+ export const FORK_SMOOTH_SELF = 0.5;
402
+ export const FORK_SMOOTH_FLOOR = 0.005;
403
+ export const FORK_SMOOTH_MAX_INFLUENCES = 8;
404
+
405
+ export function smoothForkShares(raw: ReadonlyArray<readonly Influence[]>, triangles: readonly number[]): Influence[][] {
406
+ const adj: number[][] = raw.map(() => []);
407
+ const addEdge = (u: number, v: number): void => {
408
+ if (!adj[u].includes(v)) adj[u].push(v);
409
+ if (!adj[v].includes(u)) adj[v].push(u);
410
+ };
411
+ for (let i = 0; i + 2 < triangles.length; i += 3) {
412
+ const a = triangles[i];
413
+ const b = triangles[i + 1];
414
+ const c = triangles[i + 2];
415
+ addEdge(a, b);
416
+ addEdge(b, c);
417
+ addEdge(c, a);
418
+ }
419
+ let cur: Influence[][] = raw.map((s) => [...s]);
420
+ for (let p = 0; p < FORK_SMOOTH_PASSES; p++) {
421
+ cur = cur.map((own, vi) => {
422
+ const nb = adj[vi];
423
+ if (nb.length === 0) return own;
424
+ const m = new Map<string, number>();
425
+ for (const { bone, weight } of own) m.set(bone, weight * FORK_SMOOTH_SELF);
426
+ const nbShare = (1 - FORK_SMOOTH_SELF) / nb.length;
427
+ for (const u of nb) {
428
+ for (const { bone, weight } of cur[u]) m.set(bone, (m.get(bone) ?? 0) + weight * nbShare);
429
+ }
430
+ const kept = [...m.entries()]
431
+ .filter(([, w]) => w >= FORK_SMOOTH_FLOOR)
432
+ .sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
433
+ .slice(0, FORK_SMOOTH_MAX_INFLUENCES);
434
+ const sum = kept.reduce((s, [, w]) => s + w, 0);
435
+ return roundShares(kept.map(([bone, w]) => ({ bone, weight: w / sum })));
436
+ });
437
+ }
438
+ return cur;
439
+ }
440
+
441
+
391
442
  interface Bone {
392
443
  name: string;
393
444
  parent: string | null;
@@ -957,7 +1008,8 @@ export function buildRig(
957
1008
  }
958
1009
  e.points.forEach(([x, y], k) => {
959
1010
  const n = `${e.chain}${k}`;
960
- B.set(n, { name: n, parent: k === 0 ? e.parent : names[names.length - 1], x, y });
1011
+ const inherit = e.link_inherit?.[n];
1012
+ B.set(n, { name: n, parent: k === 0 ? e.parent : names[names.length - 1], x, y, ...(inherit === undefined ? {} : { inherit }) });
961
1013
  if (names.length > 0) child.set(names[names.length - 1], [x, y]);
962
1014
  names.push(n);
963
1015
  });
@@ -1854,6 +1906,7 @@ export function buildRig(
1854
1906
  const segsAt = dressSegsAt(p, segs);
1855
1907
  let emptied: { vertex: number; at: Point; capped: Influence[] } | null = null;
1856
1908
  let emptiedMore = 0;
1909
+ const rawShares: Influence[][] = [];
1857
1910
  for (const [vi, [vx0, vy0]] of lm.vertices.entries()) {
1858
1911
  const wx = vx0 + ox;
1859
1912
  const wy = vy0 + oy;
@@ -1865,7 +1918,26 @@ export function buildRig(
1865
1918
  else emptiedMore++;
1866
1919
  continue;
1867
1920
  }
1868
- const ent: WeightEntry[] = (limits.floor === 'continuous' ? roundShares(shares) : latticeShares(shares)).map(({ bone, weight }) => {
1921
+ rawShares.push(limits.floor === 'continuous' ? roundShares(shares) : latticeShares(shares));
1922
+ }
1923
+ if (emptied !== null) {
1924
+ problems.push(nothingKeptProblem(p.name, limits.floor, emptied, emptiedMore));
1925
+ continue;
1926
+ }
1927
+ const allChainLinks = new Set([...chains.values()].flat());
1928
+ const forkCounts = new Map<string, number>();
1929
+ for (const sp of mesh.segments) {
1930
+ if (typeof sp !== 'string') continue;
1931
+ const links = chains.get(sp);
1932
+ const par = links !== undefined && links.length > 0 ? (B.get(links[0])?.parent ?? null) : null;
1933
+ if (par !== null && allChainLinks.has(par)) forkCounts.set(par, (forkCounts.get(par) ?? 0) + 1);
1934
+ }
1935
+ const heldFork = heat === null && mesh.r >= mesh.grid && mesh.influences === undefined && [...forkCounts.values()].some((c) => c >= 2);
1936
+ const finalShares = heldFork ? smoothForkShares(rawShares, lm.triangles) : rawShares;
1937
+ for (const [vi, [vx0, vy0]] of lm.vertices.entries()) {
1938
+ const wx = vx0 + ox;
1939
+ const wy = vy0 + oy;
1940
+ const ent: WeightEntry[] = finalShares[vi].map(({ bone, weight }) => {
1869
1941
  const [x, y] = toBoneLocal(world.get(bone) as BoneTransform, spineX(wx), spineY(wy));
1870
1942
  return { bone, x: places(x), y: places(y), weight };
1871
1943
  });
@@ -1873,10 +1945,6 @@ export function buildRig(
1873
1945
  infl += ent.length;
1874
1946
  maxInfl = Math.max(maxInfl, ent.length);
1875
1947
  }
1876
- if (emptied !== null) {
1877
- problems.push(nothingKeptProblem(p.name, limits.floor, emptied, emptiedMore));
1878
- continue;
1879
- }
1880
1948
  const uvs: number[] = [];
1881
1949
  for (const [x, y] of lm.vertices) uvs.push(pyRound(x / w, 6), pyRound(y / h, 6));
1882
1950
  skin[p.name] = { [p.name]: { type: 'mesh', image: file, width: w, height: h, uvs, triangles: lm.triangles, hull: lm.hull, weights } };