@rig-c/parts 1.23.0 → 1.25.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/rig.ts CHANGED
@@ -132,11 +132,11 @@
132
132
  * touches the disk, and the same inputs give the same bytes (key order is the
133
133
  * order the objects are built in, and every number is rounded by `pyRound`).
134
134
  */
135
- import { type AutoSpec, type BoneEntry, type WeightRule, type CharacterConfig, type ConfigConstraint, CONSTRAINT_FOLLOWS, constraintForRig, type ContourSpec, type Point, type RibsSpec, ROOT_BONE, weightsABone } from './config.ts';
135
+ import { type AutoSpec, type BoneEntry, type WeightRule, type CharacterConfig, type ConfigConstraint, type ConfigStop, CONSTRAINT_FOLLOWS, constraintForRig, type ContourSpec, type Point, type RibsSpec, ROOT_BONE, stopForRig, weightsABone } from './config.ts';
136
136
  import { type BoneTransform, computeExactFrameTransforms, cropToSpineY, normaliseDegrees, toBoneLocal, toWorld } from './coords.ts';
137
137
  import { type Problem, refuseIfAny } from './errors.ts';
138
138
  import { eyeLineDegrees } from './headframe.ts';
139
- import type { RigBoneInherit } from 'rig-c/src/rig.ts';
139
+ import type { RigBoneInherit, RigStop } from 'rig-c/src/rig.ts';
140
140
  import { f32 } from 'rig-c/compile';
141
141
  import type { MeshCounts, MeshQualityReport, MeshReductionInput, MotionAmplitude, ReducedMesh, Termination } from 'rig-c/mesh';
142
142
  import { type AmplitudeRow, amplitudeRow, partAmplitude, withAmplitude } from './autoamplitude.ts';
@@ -450,6 +450,12 @@ export interface RigSpec {
450
450
  * when the config has the field.
451
451
  */
452
452
  constraints?: Array<Record<string, unknown>>;
453
+ /**
454
+ * `config.stops` (issue #404), in the order written, each with its `links`
455
+ * expanded from `chain` when omitted and its records and annotations left out
456
+ * ({@link stopForRig}). Written only when the config has the field.
457
+ */
458
+ stops?: RigStop[];
453
459
  /**
454
460
  * `idleDrivesMeshes`: written only under `idleKeys: 'direct'`, and only when
455
461
  * the idle keys a mesh-driving bone. `detached`: written only when a
@@ -1033,6 +1039,17 @@ export function buildRig(
1033
1039
  if (!byPart.has(part)) fail('RIG_PART_PRESENT', `config.regions.${part}`, `names the part "${part}", which is not in parts.json; ${where(part)}`);
1034
1040
  if (!B.has(bone)) fail('RIG_NAME_RESOLVES', `config.regions.${part}`, `names the bone "${bone}", which config.bones does not declare`);
1035
1041
  }
1042
+ if (cfg.stops !== undefined) {
1043
+ cfg.stops.forEach((s, i) => {
1044
+ const at = `config.stops[${i}]`;
1045
+ if (!chains.has(s.chain)) fail('RIG_NAME_RESOLVES', `${at}.chain`, `names the chain "${s.chain}", which config.bones does not declare`);
1046
+ const links = s.links ?? chains.get(s.chain) ?? [];
1047
+ links.forEach((l, k) => {
1048
+ if (!B.has(l)) fail('RIG_NAME_RESOLVES', `${at}.links[${k}]`, `names the bone "${l}", which config.bones does not declare`);
1049
+ });
1050
+ if (!B.has(s.against)) fail('RIG_NAME_RESOLVES', `${at}.against`, `names the bone "${s.against}", which config.bones does not declare`);
1051
+ });
1052
+ }
1036
1053
  for (const p of parts.parts) {
1037
1054
  const inM = p.name in cfg.meshes;
1038
1055
  const inR = p.name in cfg.regions;
@@ -1377,6 +1394,31 @@ export function buildRig(
1377
1394
  return [{ chain, points: line.map(([x, y]) => [x - ox, y - oy] as [number, number]), stations: declared.stations }];
1378
1395
  });
1379
1396
  };
1397
+ // Issue #417: on a declared dress (a topwear mesh carrying chest, hip and hip-rooted skirt tree chains, as
1398
+ // cloth.bottomwear.part drafts), skirt tree links carry no weight on vertices above the hip line (wy < hip.y): the
1399
+ // bodice is weighted by the torso's own bones (chest, hip, and any local region like a bust) at any influences cap;
1400
+ // below the hip line nothing changes.
1401
+ const hipChainLinks = new Set<string>();
1402
+ for (const links of chains.values()) {
1403
+ for (const l of links) {
1404
+ let cur: string | null = B.get(l)?.parent ?? null;
1405
+ while (cur !== null && cur !== 'hip' && cur !== 'chest') cur = B.get(cur)?.parent ?? null;
1406
+ if (cur === 'hip') hipChainLinks.add(l);
1407
+ }
1408
+ }
1409
+ const dressSegsAt = (p: PartsFile['parts'][number], segs: Segment[]): ((wy: number) => Segment[]) => {
1410
+ const hipBone = B.get('hip');
1411
+ if (hipBone === undefined) return () => segs;
1412
+ const isDress =
1413
+ tagOf(p.from) === 'topwear' &&
1414
+ segs.some((s) => s.bone === 'chest') &&
1415
+ segs.some((s) => s.bone === 'hip') &&
1416
+ segs.some((s) => hipChainLinks.has(s.bone));
1417
+ if (!isDress) return () => segs;
1418
+ const torso = segs.filter((s) => !hipChainLinks.has(s.bone));
1419
+ if (torso.length === 0) return () => segs;
1420
+ return (wy: number): Segment[] => (wy < hipBone.y ? torso : segs);
1421
+ };
1380
1422
  // A contour mesh (issue #84): `src/contour.ts` over the padded image, every refusal collected with the others;
1381
1423
  // the weights by `src/localweights.ts`; slot, uvs, bind positions and by-name weights written as the lattice's.
1382
1424
  // The config's region numbers are rig px; the part image's are rig px less (p.x − PAD, p.y − PAD) — a
@@ -1420,6 +1462,7 @@ export function buildRig(
1420
1462
  out.push(...heat);
1421
1463
  return null;
1422
1464
  }
1465
+ const segsAt = dressSegsAt(p, segs);
1423
1466
  const weights: WeightEntry[][] = [];
1424
1467
  const reached = regions.map(() => 0);
1425
1468
  const whole = regions.map(() => 0);
@@ -1431,7 +1474,8 @@ export function buildRig(
1431
1474
  cm.vertices.forEach(([vx, vy], vi) => {
1432
1475
  const wx = vx + ox;
1433
1476
  const wy = vy + oy;
1434
- const li = localInfluences([wx, wy], segs, r, regions, limits, exponent ?? DISTANCE_EXPONENT, heat === null ? undefined : heatInfluences(heat, [vx, vy], limits));
1477
+ const vSegs = segsAt(wy);
1478
+ const li = localInfluences([wx, wy], vSegs, r, regions, limits, exponent ?? DISTANCE_EXPONENT, heat === null ? undefined : heatInfluences(heat, [vx, vy], limits));
1435
1479
  if ('first' in li) {
1436
1480
  if (!overlapped) {
1437
1481
  out.push({
@@ -1445,7 +1489,7 @@ export function buildRig(
1445
1489
  }
1446
1490
  if (li.influences.length === 0) {
1447
1491
  // issue #284: a declared cut that keeps no bone (nothingKeptProblem says when that can be); a region's bone would be kept.
1448
- if (emptied === null) emptied = { vertex: vi, at: [wx, wy], capped: heat === null ? influences([wx, wy], segs, r, { maxInfluences: limits.maxInfluences, minWeight: 0 }, exponent ?? DISTANCE_EXPONENT) : heatInfluences(heat, [vx, vy], { maxInfluences: limits.maxInfluences, minWeight: 0 }) };
1492
+ if (emptied === null) emptied = { vertex: vi, at: [wx, wy], capped: heat === null ? influences([wx, wy], vSegs, r, { maxInfluences: limits.maxInfluences, minWeight: 0 }, exponent ?? DISTANCE_EXPONENT) : heatInfluences(heat, [vx, vy], { maxInfluences: limits.maxInfluences, minWeight: 0 }) };
1449
1493
  else emptiedMore++;
1450
1494
  return;
1451
1495
  }
@@ -1551,7 +1595,7 @@ export function buildRig(
1551
1595
  out.push(...heat);
1552
1596
  return null;
1553
1597
  }
1554
- const sw = sourceWeights(source.vertices, ox, oy, segs, r, spec, exponent ?? DISTANCE_EXPONENT, heat);
1598
+ const sw = sourceWeights(source.vertices, ox, oy, segs, r, spec, exponent ?? DISTANCE_EXPONENT, heat, dressSegsAt(p, segs));
1555
1599
  if ('overlap' in sw) {
1556
1600
  out.push({
1557
1601
  code: 'RIG_CONTOUR_REGIONS_OVERLAP',
@@ -1807,15 +1851,17 @@ export function buildRig(
1807
1851
  let maxInfl = 0;
1808
1852
  // issue #284: the mesh's declared cap and floor form, or DEFAULT_LIMITS itself when it declares none.
1809
1853
  const limits: InfluenceLimits = meshLimits(mesh.influences);
1854
+ const segsAt = dressSegsAt(p, segs);
1810
1855
  let emptied: { vertex: number; at: Point; capped: Influence[] } | null = null;
1811
1856
  let emptiedMore = 0;
1812
1857
  for (const [vi, [vx0, vy0]] of lm.vertices.entries()) {
1813
1858
  const wx = vx0 + ox;
1814
1859
  const wy = vy0 + oy;
1815
- const shares = heat === null ? influences([wx, wy], segs, mesh.r, limits, mesh.exponent ?? DISTANCE_EXPONENT) : heatInfluences(heat, [vx0, vy0], limits);
1860
+ const vSegs = segsAt(wy);
1861
+ const shares = heat === null ? influences([wx, wy], vSegs, mesh.r, limits, mesh.exponent ?? DISTANCE_EXPONENT) : heatInfluences(heat, [vx0, vy0], limits);
1816
1862
  if (shares.length === 0) {
1817
1863
  // issue #284: a declared cut that keeps no bone (nothingKeptProblem says when that can be).
1818
- if (emptied === null) emptied = { vertex: vi, at: [wx, wy], capped: heat === null ? influences([wx, wy], segs, mesh.r, { maxInfluences: limits.maxInfluences, minWeight: 0 }, mesh.exponent ?? DISTANCE_EXPONENT) : heatInfluences(heat, [vx0, vy0], { maxInfluences: limits.maxInfluences, minWeight: 0 }) };
1864
+ if (emptied === null) emptied = { vertex: vi, at: [wx, wy], capped: heat === null ? influences([wx, wy], vSegs, mesh.r, { maxInfluences: limits.maxInfluences, minWeight: 0 }, mesh.exponent ?? DISTANCE_EXPONENT) : heatInfluences(heat, [vx0, vy0], { maxInfluences: limits.maxInfluences, minWeight: 0 }) };
1819
1865
  else emptiedMore++;
1820
1866
  continue;
1821
1867
  }
@@ -1852,6 +1898,10 @@ export function buildRig(
1852
1898
  });
1853
1899
  }
1854
1900
  refuseIfAny(problems);
1901
+ // ---- leg stops (issue #404) --------------------------------------------
1902
+ // Only when the config has `stops`: each link after the first (and any bone hanging from a stopped link) hangs
1903
+ // from the drawn link `<link>_d` above it, and slots and weights on a stopped link bind `<link>_d`.
1904
+ const stops = cfg.stops === undefined ? null : applyStops(cfg.stops, chains, bones, slots, skin);
1855
1905
  // ---- the turn control (issue #251) -------------------------------------
1856
1906
  // Only when the config has `turn`: every other build adds nothing here, so no byte moves.
1857
1907
  const turned = cfg.turn === undefined ? null : applyTurn(cfg.turn, parts.parts.map((p) => p.name), bones, slots, skin, cfg.constraints ?? []);
@@ -1868,6 +1918,7 @@ export function buildRig(
1868
1918
  };
1869
1919
  if (cfg.constraints !== undefined) rig.constraints = cfg.constraints.map(constraintForRig);
1870
1920
  if (turned !== null) rig.constraints = [...(rig.constraints ?? []), ...turned.constraints];
1921
+ if (stops !== null) rig.stops = stops;
1871
1922
  const detached = [...detachedRules(cfg.constraints ?? []), ...(turned?.detached ?? [])];
1872
1923
  if (idleKeys === 'direct' && meshKeyed.length > 0) rig.invariants = { idleDrivesMeshes: { why: IDLE_DRIVES_MESHES_WHY } };
1873
1924
  if (detached.length > 0) rig.invariants = { ...rig.invariants, detached };
@@ -1880,6 +1931,49 @@ export function buildRig(
1880
1931
  return { rig, motion, meshReport, images: outImages, controls, meshKeyed, idleKeys, loopPasses, autoMotion, faceAxis, turn: turned?.rows ?? null };
1881
1932
  }
1882
1933
 
1934
+ /**
1935
+ * Re-parent every bone that hangs from a stopped link onto that link's drawn
1936
+ * link `<link>_d`, move slots and mesh weights from a stopped link onto
1937
+ * `<link>_d`, and return `rig.json`'s `stops` array (issue #404). At the setup
1938
+ * pose `<link>_d` has the same world origin and rotation as `<link>`, so every
1939
+ * local offset and bind coordinate is unchanged.
1940
+ */
1941
+ function applyStops(
1942
+ stops: readonly ConfigStop[],
1943
+ chains: ReadonlyMap<string, readonly string[]>,
1944
+ bones: RigBone[],
1945
+ slots: RigSpec['slots'],
1946
+ skin: RigSpec['skins']['default'],
1947
+ ): RigStop[] {
1948
+ const out: RigStop[] = [];
1949
+ const drawn = new Map<string, string>();
1950
+ for (const s of stops) {
1951
+ const links = s.links ?? chains.get(s.chain) ?? [];
1952
+ for (const l of links) drawn.set(l, `${l}_d`);
1953
+ out.push(stopForRig(s, links));
1954
+ }
1955
+ for (const b of bones) {
1956
+ const d = b.parent === undefined ? undefined : drawn.get(b.parent);
1957
+ if (d !== undefined) b.parent = d;
1958
+ }
1959
+ for (const s of slots) {
1960
+ const d = drawn.get(s.bone);
1961
+ if (d !== undefined) s.bone = d;
1962
+ }
1963
+ for (const atts of Object.values(skin)) {
1964
+ for (const att of Object.values(atts)) {
1965
+ if (!('type' in att)) continue;
1966
+ for (const v of att.weights) {
1967
+ for (const w of v) {
1968
+ const d = drawn.get(w.bone);
1969
+ if (d !== undefined) w.bone = d;
1970
+ }
1971
+ }
1972
+ }
1973
+ }
1974
+ return out;
1975
+ }
1976
+
1883
1977
  /**
1884
1978
  * The JSON text of a rig output file. One space of indent, as the reference
1885
1979
  * wrote, and a closing newline; the key order is the order the objects were
@@ -1926,7 +2020,10 @@ export function rigJsonText(value: unknown): string {
1926
2020
  */
1927
2021
  export function flattenRig(rig: RigSpec, sense: 1 | -1 = 1): RigSpec {
1928
2022
  const problems: Problem[] = [];
1929
- const read = rig.bones.map((b) => ({ ...b, rotation: (b.rotation ?? 0) * sense }));
2023
+ const undrawn = new Map<string, string>();
2024
+ for (const s of rig.stops ?? []) for (const l of s.links) undrawn.set(`${l}_d`, l);
2025
+ const realBone = (n: string): string => undrawn.get(n) ?? n;
2026
+ const read = rig.bones.map((b) => ({ ...b, ...(b.parent === undefined ? {} : { parent: realBone(b.parent) }), rotation: (b.rotation ?? 0) * sense }));
1930
2027
  const world = computeExactFrameTransforms(read);
1931
2028
  const turn = new Map<string, number>();
1932
2029
  // A bone with a declared inherit mode (issue #348) is read off rig-c's poser, which honours the mode; every other is the sum, as before.
@@ -1934,11 +2031,11 @@ export function flattenRig(rig: RigSpec, sense: 1 | -1 = 1): RigSpec {
1934
2031
  turn.set(
1935
2032
  b.name,
1936
2033
  b.inherit === undefined
1937
- ? placed((b.parent === undefined ? 0 : (turn.get(b.parent) as number)) + (b.rotation ?? 0), OFFSET_PLACES)
2034
+ ? placed((b.parent === undefined ? 0 : (turn.get(realBone(b.parent)) as number)) + (b.rotation ?? 0), OFFSET_PLACES)
1938
2035
  : placed(normaliseDegrees((world.get(b.name) as BoneTransform).worldRotation), OFFSET_PLACES),
1939
2036
  );
1940
2037
  }
1941
- const origin = (bone: string): BoneTransform => world.get(bone) as BoneTransform;
2038
+ const origin = (bone: string): BoneTransform => world.get(realBone(bone)) as BoneTransform;
1942
2039
  const out = (m: BoneTransform, x: number, y: number): [number, number] => {
1943
2040
  const [wx, wy] = toWorld(m, x, y);
1944
2041
  return [placed(wx - m.worldX, FLAT_PLACES), placed(wy - m.worldY, FLAT_PLACES)];
@@ -1962,12 +2059,12 @@ export function flattenRig(rig: RigSpec, sense: 1 | -1 = 1): RigSpec {
1962
2059
  })) };
1963
2060
  continue;
1964
2061
  }
1965
- const left = normaliseDegrees((turn.get(bone) as number) + (att.rotation ?? 0));
2062
+ const left = normaliseDegrees((turn.get(realBone(bone)) as number) + (att.rotation ?? 0));
1966
2063
  if (Math.abs(left) > 1e-6) {
1967
2064
  problems.push({
1968
2065
  code: 'FLATTEN_REGION_UPRIGHT',
1969
2066
  object: `skins.default.${slot}.${name}`,
1970
- detail: `rides "${bone}", turned ${turn.get(bone)} degrees, with rotation ${att.rotation ?? 'absent'}, so it is drawn turned ${placed(left, OFFSET_PLACES)} degrees; the flat form draws every region upright, so the region's rotation must cancel its bone's turn`,
2067
+ detail: `rides "${bone}", turned ${turn.get(realBone(bone))} degrees, with rotation ${att.rotation ?? 'absent'}, so it is drawn turned ${placed(left, OFFSET_PLACES)} degrees; the flat form draws every region upright, so the region's rotation must cancel its bone's turn`,
1971
2068
  });
1972
2069
  continue;
1973
2070
  }
@@ -1989,6 +2086,9 @@ export function flattenRig(rig: RigSpec, sense: 1 | -1 = 1): RigSpec {
1989
2086
  return { ...c, properties: { ...props, rotate: { ...rotate, offset: placed(offset - turned, OFFSET_PLACES) } } };
1990
2087
  });
1991
2088
  }
2089
+ if (rig.stops !== undefined) {
2090
+ flat.stops = rig.stops.map((s) => ({ ...s, at: out(origin(s.against), s.at[0], s.at[1]) }));
2091
+ }
1992
2092
  if (rig.invariants?.axes !== undefined) {
1993
2093
  const { axes: _axes, ...rest } = rig.invariants;
1994
2094
  if (Object.keys(rest).length > 0) flat.invariants = rest;
package/src/squeeze.ts CHANGED
@@ -37,7 +37,7 @@
37
37
  * its own figure too. A remedy whose figure is past the bar is not offered.
38
38
  */
39
39
  import { TEXTURE_STRETCH_CEILING } from './check.ts';
40
- import type { BoneEntry, MeshSpec, Motion, Point } from './config.ts';
40
+ import type { BoneEntry, ConfigConstraint, MeshSpec, Motion, Point } from './config.ts';
41
41
  import { stretchFigures, stretchSeverity, type StretchAt } from './instruments.ts';
42
42
  import { edgeLength } from './length.ts';
43
43
  import { ART_ALPHA, latticeMesh, latticeStray } from './mesh.ts';
@@ -77,6 +77,27 @@ export interface SqueezeFinding {
77
77
  turned: number | null;
78
78
  }
79
79
 
80
+ /**
81
+ * Issue #403: two sibling branch tips forked from the same trunk link on a lattice mesh whose cell-boundary weights
82
+ * pin adjacent vertices to different branch links (or whose fork gives the two branches unequal spring strength), so
83
+ * the triangles where the two branches' cells meet are squeezed under the environment or a hip sway.
84
+ */
85
+ export interface BranchSqueezeFinding {
86
+ mesh: string;
87
+ parent: string;
88
+ a: string;
89
+ b: string;
90
+ tipGap: number;
91
+ triangle: number;
92
+ edge: [number, number];
93
+ weightA: number;
94
+ weightB: number;
95
+ strengthA: number | null;
96
+ strengthB: number | null;
97
+ r: number;
98
+ grid: number;
99
+ }
100
+
80
101
  export interface SqueezeResult {
81
102
  findings: SqueezeFinding[];
82
103
  /** Pairs on one mesh this did not read, and why. */
@@ -272,3 +293,142 @@ export function chainSqueezes(
272
293
  }
273
294
  return { findings, unread, read };
274
295
  }
296
+
297
+ /**
298
+ * Issue #403: sibling branch chains forked from the same trunk link on a lattice mesh under the distance rule.
299
+ * When `m.r < m.grid` concentrates a cell-boundary edge's weights on each branch's tip link (above 0.85), or when
300
+ * physics constraints give two sibling branches of one fork unequal `strength` (such as `edge` 50 beside `branch` 100),
301
+ * the triangles where the two branches' cells meet squeeze under the environment or a hip sway. Returns one finding per
302
+ * unheld sibling pair on a fork; quiet on an unbranched mesh or when `split.hold_tip: true` holds the fork.
303
+ */
304
+ export function branchSqueezes(
305
+ spec: { bones: readonly BoneEntry[]; meshes: Readonly<Record<string, MeshSpec>>; constraints?: readonly ConfigConstraint[] },
306
+ parts: ReadonlyMap<string, { x: number; y: number; image: Raster }>,
307
+ ): BranchSqueezeFinding[] {
308
+ const out: BranchSqueezeFinding[] = [];
309
+ const chains = new Map<string, { points: Point[]; tip: Point; parent: string }>();
310
+ for (const e of spec.bones) if ('chain' in e) chains.set(e.chain, { points: e.points, tip: e.tip, parent: e.parent });
311
+ const linkOf = new Set<string>();
312
+ for (const [c, e] of chains) e.points.forEach((_, i) => linkOf.add(`${c}${i}`));
313
+ const strengthOf = new Map<string, number>();
314
+ for (const c of spec.constraints ?? []) {
315
+ if (c.type === 'physics' && typeof c.bone === 'string' && c.rotate !== undefined && typeof c.strength === 'number') {
316
+ strengthOf.set(c.bone, c.strength);
317
+ }
318
+ }
319
+
320
+ for (const [mname, m] of Object.entries(spec.meshes)) {
321
+ if ('contour' in m || 'auto' in m || m.rule === 'heat') continue;
322
+ const byFork = new Map<string, string[]>();
323
+ for (const sp of m.segments) {
324
+ if (typeof sp !== 'string') continue;
325
+ const c = chains.get(sp);
326
+ if (c === undefined || !linkOf.has(c.parent)) continue;
327
+ const list = byFork.get(c.parent) ?? [];
328
+ list.push(sp);
329
+ byFork.set(c.parent, list);
330
+ }
331
+ const forks = [...byFork.entries()].filter(([, list]) => list.length >= 2);
332
+ if (forks.length === 0) continue;
333
+ const part = parts.get(mname);
334
+ if (part === undefined) continue;
335
+ const segs: Segment[] = [];
336
+ let unresolved = false;
337
+ for (const sp of m.segments) {
338
+ if (typeof sp !== 'string') {
339
+ segs.push({ bone: sp[0], a: [sp[1][0], sp[1][1]], b: [sp[2][0], sp[2][1]] });
340
+ continue;
341
+ }
342
+ const c = chains.get(sp);
343
+ if (c !== undefined) {
344
+ const line = [...c.points, c.tip];
345
+ c.points.forEach((q, i) => segs.push({ bone: `${sp}${i}`, a: [q[0], q[1]], b: [line[i + 1][0], line[i + 1][1]] }));
346
+ continue;
347
+ }
348
+ const single = spec.bones.find((e) => 'name' in e && e.name === sp);
349
+ if (single === undefined || !('name' in single) || single.tip === undefined) {
350
+ unresolved = true;
351
+ break;
352
+ }
353
+ segs.push({ bone: sp, a: [single.at[0], single.at[1]], b: [single.tip[0], single.tip[1]] });
354
+ }
355
+ if (unresolved) continue;
356
+ const img = pad(part.image, PAD, PAD, PAD, PAD, [0, 0, 0, 0]);
357
+ const allArt = alphaAbove(img, ART_ALPHA);
358
+ const art = m.stray === undefined ? allArt : latticeStray(allArt, m.stray).art;
359
+ const lm = latticeMesh(mname, art, m.grid);
360
+ if ('code' in lm) continue;
361
+ const ox = part.x - PAD;
362
+ const oy = part.y - PAD;
363
+ const limits = meshLimits(m.influences);
364
+ const rest: Array<[number, number]> = lm.vertices.map(([x, y]) => [x + ox, y + oy]);
365
+ const weights = rest.map((p) => {
366
+ const shares = influences(p, segs, m.r, limits, m.exponent ?? DISTANCE_EXPONENT);
367
+ return shares.length === 0 ? [] : limits.floor === 'continuous' ? roundShares(shares) : latticeShares(shares);
368
+ });
369
+ const topOf = (vi: number): { bone: string; weight: number } | null => {
370
+ const w = weights[vi];
371
+ if (w.length === 0) return null;
372
+ return [...w].sort((p, q) => q.weight - p.weight)[0];
373
+ };
374
+ for (const [parent, siblings] of forks) {
375
+ for (let i = 0; i + 1 < siblings.length; i++) {
376
+ const aChain = siblings[i];
377
+ const bChain = siblings[i + 1];
378
+ const ca = chains.get(aChain) as { points: Point[]; tip: Point };
379
+ const cb = chains.get(bChain) as { points: Point[]; tip: Point };
380
+ const aLast = `${aChain}${ca.points.length - 1}`;
381
+ const bLast = `${bChain}${cb.points.length - 1}`;
382
+ let best: { triangle: number; edge: [number, number]; weightA: number; weightB: number; y: number } | null = null;
383
+ for (let t = 0; t + 2 < lm.triangles.length; t += 3) {
384
+ const tri = [lm.triangles[t], lm.triangles[t + 1], lm.triangles[t + 2]];
385
+ const triIdx = t / 3;
386
+ const meanY = (rest[tri[0]][1] + rest[tri[1]][1] + rest[tri[2]][1]) / 3;
387
+ for (const [u, v] of [
388
+ [tri[0], tri[1]],
389
+ [tri[1], tri[2]],
390
+ [tri[2], tri[0]],
391
+ ] as const) {
392
+ const tu = topOf(u);
393
+ const tv = topOf(v);
394
+ if (tu === null || tv === null) continue;
395
+ const match =
396
+ tu.bone === aLast && tv.bone === bLast
397
+ ? { edge: [u, v] as [number, number], weightA: tu.weight, weightB: tv.weight }
398
+ : tu.bone === bLast && tv.bone === aLast
399
+ ? { edge: [v, u] as [number, number], weightA: tv.weight, weightB: tu.weight }
400
+ : null;
401
+ if (match === null) continue;
402
+ const peak = Math.max(match.weightA, match.weightB);
403
+ const bestPeak = best === null ? -1 : Math.max(best.weightA, best.weightB);
404
+ if (best === null || peak > bestPeak || (peak === bestPeak && meanY > best.y)) {
405
+ best = { triangle: triIdx, edge: match.edge, weightA: match.weightA, weightB: match.weightB, y: meanY };
406
+ }
407
+ }
408
+ }
409
+ if (best === null) continue;
410
+ const strengthA = strengthOf.get(aLast) ?? null;
411
+ const strengthB = strengthOf.get(bLast) ?? null;
412
+ const unequalStrength = strengthA !== null && strengthB !== null && strengthA !== strengthB;
413
+ const narrowReach = m.r < m.grid && Math.max(best.weightA, best.weightB) > 0.85;
414
+ if (!unequalStrength && !narrowReach) continue;
415
+ out.push({
416
+ mesh: mname,
417
+ parent,
418
+ a: aLast,
419
+ b: bLast,
420
+ tipGap: edgeLength(ca.tip[0] - cb.tip[0], ca.tip[1] - cb.tip[1]),
421
+ triangle: best.triangle,
422
+ edge: best.edge,
423
+ weightA: best.weightA,
424
+ weightB: best.weightB,
425
+ strengthA,
426
+ strengthB,
427
+ r: m.r,
428
+ grid: m.grid,
429
+ });
430
+ }
431
+ }
432
+ }
433
+ return out;
434
+ }