@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/cli.ts +2 -1
- package/docs/AUTHORING.md +48 -5
- package/docs/STABILITY.md +5 -3
- package/package.json +2 -2
- package/src/assemble.ts +25 -6
- package/src/automesh.ts +2 -1
- package/src/check.ts +184 -33
- package/src/cloth.ts +72 -3
- package/src/config.ts +107 -6
- package/src/instruments.ts +32 -0
- package/src/limbs.ts +171 -0
- package/src/propose.ts +269 -32
- package/src/rig.ts +112 -12
- package/src/squeeze.ts +161 -1
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
|
|
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],
|
|
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
|
|
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],
|
|
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
|
|
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
|
+
}
|