@ikijs/editor 0.3.1 → 0.5.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/README.md +30 -1
- package/dist/index.d.mts +115 -2
- package/dist/index.d.ts +115 -2
- package/dist/index.js +966 -72
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +964 -72
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -1605,9 +1605,10 @@ import {
|
|
|
1605
1605
|
} from "@ikijs/format";
|
|
1606
1606
|
var ROLE_TABLE = {
|
|
1607
1607
|
// The silhouette behind the face. It rides the rigid head (not faceWarp,
|
|
1608
|
-
// whose grid it would stretch across the shoulders) as a MESH
|
|
1609
|
-
//
|
|
1610
|
-
//
|
|
1608
|
+
// whose grid it would stretch across the shoulders) as a MESH, so its ends
|
|
1609
|
+
// can swing on the hair-sway warps. It holds the head's outline STILL
|
|
1610
|
+
// through the turn — the head it hangs off no longer travels and it has no
|
|
1611
|
+
// turn warp of its own — while the face slides inside it.
|
|
1611
1612
|
hair_back: { deformer: "headDeformer", order: 0, mesh: true },
|
|
1612
1613
|
// The torso. It rides its own rigid deformer, not the head's: the head turns
|
|
1613
1614
|
// about the neck pivot while the shoulders follow at BODY_TURN_FOLLOW and
|
|
@@ -1789,8 +1790,8 @@ var FACE_GRID_CELLS = 6;
|
|
|
1789
1790
|
var HEAD_CYLINDER_RADIUS_FACTOR = 0.6 / 0.5;
|
|
1790
1791
|
var HEAD_TURN_MAX_DEG = 30;
|
|
1791
1792
|
var HEAD_TURN_STOPS = [-30, -15, 0, 15, 30];
|
|
1792
|
-
function headTurnParallaxUnit(
|
|
1793
|
-
return
|
|
1793
|
+
function headTurnParallaxUnit(radius) {
|
|
1794
|
+
return radius * Math.sin(HEAD_TURN_MAX_DEG * (Math.PI / 180));
|
|
1794
1795
|
}
|
|
1795
1796
|
function gridReach(grid, axis, center) {
|
|
1796
1797
|
let reach = 0;
|
|
@@ -1803,9 +1804,16 @@ function pinnedCylinderBend(local, radius, theta) {
|
|
|
1803
1804
|
const alpha = Math.asin(Math.max(-1, Math.min(1, local / radius)));
|
|
1804
1805
|
return radius * Math.sin(alpha + theta) - local - radius * Math.sin(theta);
|
|
1805
1806
|
}
|
|
1806
|
-
function
|
|
1807
|
+
function boundedCylinderBend(local, radius, theta) {
|
|
1808
|
+
const bound = radius / HEAD_CYLINDER_RADIUS_FACTOR;
|
|
1809
|
+
const onSurface = Math.max(-bound, Math.min(bound, local));
|
|
1810
|
+
return pinnedCylinderBend(onSurface, radius, theta);
|
|
1811
|
+
}
|
|
1812
|
+
function turnSlide(travel, angleX) {
|
|
1813
|
+
return travel * angleX / HEAD_TURN_MAX_DEG;
|
|
1814
|
+
}
|
|
1815
|
+
function bakeHeadTurnGridWarp2DCentered(grid, parameterX, parameterY, centerX, centerY, radiusX, travel) {
|
|
1807
1816
|
const STOPS = [...HEAD_TURN_STOPS];
|
|
1808
|
-
const RADIUS_X = gridReach(grid, 0, centerX) * HEAD_CYLINDER_RADIUS_FACTOR;
|
|
1809
1817
|
const RADIUS_Y = gridReach(grid, 1, centerY) * HEAD_CYLINDER_RADIUS_FACTOR;
|
|
1810
1818
|
const pointCount = grid.points.length / 2;
|
|
1811
1819
|
const DEG_TO_RAD = Math.PI / 180;
|
|
@@ -1814,10 +1822,11 @@ function bakeHeadTurnGridWarp2DCentered(grid, parameterX, parameterY, centerX, c
|
|
|
1814
1822
|
const thetaY = angleY * NOD_BEND * DEG_TO_RAD;
|
|
1815
1823
|
for (const angleX of STOPS) {
|
|
1816
1824
|
const thetaX = angleX * DEG_TO_RAD;
|
|
1825
|
+
const slide = turnSlide(travel, angleX);
|
|
1817
1826
|
const offsets = [];
|
|
1818
1827
|
for (let i = 0; i < pointCount; i++) {
|
|
1819
1828
|
offsets.push(
|
|
1820
|
-
|
|
1829
|
+
boundedCylinderBend(grid.points[i * 2] - centerX, radiusX, thetaX) + slide,
|
|
1821
1830
|
pinnedCylinderBend(
|
|
1822
1831
|
grid.points[i * 2 + 1] - centerY,
|
|
1823
1832
|
RADIUS_Y,
|
|
@@ -1836,13 +1845,811 @@ function bakeHeadTurnGridWarp2DCentered(grid, parameterX, parameterY, centerX, c
|
|
|
1836
1845
|
keyforms2d
|
|
1837
1846
|
};
|
|
1838
1847
|
}
|
|
1848
|
+
function turnColumnMap(grid, faceCenterX, radiusX, angleX, travel) {
|
|
1849
|
+
if (Math.abs(angleX) > HEAD_TURN_MAX_DEG) {
|
|
1850
|
+
throw new Error(
|
|
1851
|
+
`auto-rig: turnColumnMap: angleX ${angleX}\xB0 is outside the turn's \xB1${HEAD_TURN_MAX_DEG}\xB0 range`
|
|
1852
|
+
);
|
|
1853
|
+
}
|
|
1854
|
+
const theta = angleX * (Math.PI / 180);
|
|
1855
|
+
const slide = turnSlide(travel, angleX);
|
|
1856
|
+
const restX = [];
|
|
1857
|
+
const warpedX = [];
|
|
1858
|
+
for (let col = 0; col <= grid.cols; col++) {
|
|
1859
|
+
const x = grid.points[col * 2];
|
|
1860
|
+
restX.push(x);
|
|
1861
|
+
warpedX.push(
|
|
1862
|
+
x + boundedCylinderBend(x - faceCenterX, radiusX, theta) + slide
|
|
1863
|
+
);
|
|
1864
|
+
}
|
|
1865
|
+
const cellFor = (v, edges) => {
|
|
1866
|
+
for (let c = 0; c < grid.cols; c++) {
|
|
1867
|
+
if (v < edges[c + 1]) return c;
|
|
1868
|
+
}
|
|
1869
|
+
return grid.cols - 1;
|
|
1870
|
+
};
|
|
1871
|
+
return {
|
|
1872
|
+
restX,
|
|
1873
|
+
warpedX,
|
|
1874
|
+
mapX(x) {
|
|
1875
|
+
const c = cellFor(x, restX);
|
|
1876
|
+
const s = Math.max(
|
|
1877
|
+
0,
|
|
1878
|
+
Math.min(1, (x - restX[c]) / (restX[c + 1] - restX[c]))
|
|
1879
|
+
);
|
|
1880
|
+
return warpedX[c] + (warpedX[c + 1] - warpedX[c]) * s;
|
|
1881
|
+
},
|
|
1882
|
+
invertX(X) {
|
|
1883
|
+
const c = cellFor(X, warpedX);
|
|
1884
|
+
const s = Math.max(
|
|
1885
|
+
0,
|
|
1886
|
+
Math.min(1, (X - warpedX[c]) / (warpedX[c + 1] - warpedX[c]))
|
|
1887
|
+
);
|
|
1888
|
+
return restX[c] + (restX[c + 1] - restX[c]) * s;
|
|
1889
|
+
}
|
|
1890
|
+
};
|
|
1891
|
+
}
|
|
1892
|
+
var TurnTargetError = class extends Error {
|
|
1893
|
+
constructor(message) {
|
|
1894
|
+
super(message);
|
|
1895
|
+
this.name = "TurnTargetError";
|
|
1896
|
+
}
|
|
1897
|
+
};
|
|
1898
|
+
var DEFAULT_TURN_TARGETS = {
|
|
1899
|
+
eyeShift: 0.22,
|
|
1900
|
+
farEyeRatio: 0.67,
|
|
1901
|
+
silhouetteRatio: 1
|
|
1902
|
+
};
|
|
1903
|
+
var NOSE_SHIFT_SHARE = 1.36;
|
|
1904
|
+
var MOUTH_SHIFT_SHARE = 1.18;
|
|
1905
|
+
var TURN_RATIO_MAX = 1.5;
|
|
1906
|
+
var SILHOUETTE_RATIO_MIN = 0.5;
|
|
1907
|
+
function resolveTurnTargets(targets = {}) {
|
|
1908
|
+
const eyeShift = targets.eyeShift ?? DEFAULT_TURN_TARGETS.eyeShift;
|
|
1909
|
+
const check = (field, value, ok, expected) => {
|
|
1910
|
+
if (value === void 0) return;
|
|
1911
|
+
if (!Number.isFinite(value) || !ok(value)) {
|
|
1912
|
+
const derived = (field === "noseShift" || field === "mouthShift") && targets[field] === void 0;
|
|
1913
|
+
const from = derived ? `, derived from turnTargets.eyeShift (${eyeShift}),` : "";
|
|
1914
|
+
throw new TurnTargetError(
|
|
1915
|
+
`auto-rig: turnTargets.${field} (${value})${from} must be ${expected}`
|
|
1916
|
+
);
|
|
1917
|
+
}
|
|
1918
|
+
};
|
|
1919
|
+
const farEyeRatio = targets.farEyeRatio ?? DEFAULT_TURN_TARGETS.farEyeRatio;
|
|
1920
|
+
const silhouetteRatio = targets.silhouetteRatio ?? DEFAULT_TURN_TARGETS.silhouetteRatio;
|
|
1921
|
+
const noseShift = targets.noseShift ?? NOSE_SHIFT_SHARE * eyeShift;
|
|
1922
|
+
const mouthShift = targets.mouthShift ?? MOUTH_SHIFT_SHARE * eyeShift;
|
|
1923
|
+
const isShift = (v) => Math.abs(v) <= 1;
|
|
1924
|
+
const shiftRange = "a fraction of the head half-width, |value| <= 1";
|
|
1925
|
+
check("eyeShift", eyeShift, isShift, shiftRange);
|
|
1926
|
+
check(
|
|
1927
|
+
"farEyeRatio",
|
|
1928
|
+
farEyeRatio,
|
|
1929
|
+
(v) => v > 0 && v <= TURN_RATIO_MAX,
|
|
1930
|
+
`in (0, ${TURN_RATIO_MAX}]`
|
|
1931
|
+
);
|
|
1932
|
+
check(
|
|
1933
|
+
"silhouetteRatio",
|
|
1934
|
+
silhouetteRatio,
|
|
1935
|
+
(v) => v >= SILHOUETTE_RATIO_MIN && v <= TURN_RATIO_MAX,
|
|
1936
|
+
`in [${SILHOUETTE_RATIO_MIN}, ${TURN_RATIO_MAX}]`
|
|
1937
|
+
);
|
|
1938
|
+
check("noseShift", noseShift, isShift, shiftRange);
|
|
1939
|
+
check("mouthShift", mouthShift, isShift, shiftRange);
|
|
1940
|
+
check("headHalfWidth", targets.headHalfWidth, (v) => v > 0, "positive");
|
|
1941
|
+
const defaulted = new Set(
|
|
1942
|
+
[
|
|
1943
|
+
"eyeShift",
|
|
1944
|
+
"farEyeRatio",
|
|
1945
|
+
"silhouetteRatio",
|
|
1946
|
+
"noseShift",
|
|
1947
|
+
"mouthShift"
|
|
1948
|
+
].filter((field) => targets[field] === void 0)
|
|
1949
|
+
);
|
|
1950
|
+
return {
|
|
1951
|
+
eyeShift,
|
|
1952
|
+
farEyeRatio,
|
|
1953
|
+
silhouetteRatio,
|
|
1954
|
+
noseShift,
|
|
1955
|
+
mouthShift,
|
|
1956
|
+
headHalfWidth: targets.headHalfWidth,
|
|
1957
|
+
defaulted
|
|
1958
|
+
};
|
|
1959
|
+
}
|
|
1960
|
+
function turnLandmarks(layers) {
|
|
1961
|
+
const markOf = (role) => {
|
|
1962
|
+
const layer = layers.find((l) => l.role === role);
|
|
1963
|
+
if (!layer) return void 0;
|
|
1964
|
+
const t = bboxToTransform(layer.bbox, layer.canvasW, layer.canvasH, role);
|
|
1965
|
+
return { x: t.x, y: t.y, w: layer.cropW };
|
|
1966
|
+
};
|
|
1967
|
+
const pairOf = (left, right) => {
|
|
1968
|
+
const l = markOf(left);
|
|
1969
|
+
const r = markOf(right);
|
|
1970
|
+
return l && r ? [l, r] : void 0;
|
|
1971
|
+
};
|
|
1972
|
+
const one = (role) => {
|
|
1973
|
+
const m = markOf(role);
|
|
1974
|
+
return m ? [m] : [];
|
|
1975
|
+
};
|
|
1976
|
+
return {
|
|
1977
|
+
eye: pairOf("eye_L", "eye_R") ?? [],
|
|
1978
|
+
nose: one("nose"),
|
|
1979
|
+
mouth: one("mouth")
|
|
1980
|
+
};
|
|
1981
|
+
}
|
|
1982
|
+
var TURN_BISECT_STEPS = 40;
|
|
1983
|
+
var TURN_DEPTH_EPS = 1e-6;
|
|
1984
|
+
function solveTurnDepthSigned(target, landmarks, unit, map, holdEdgeX, plateEdgeX) {
|
|
1985
|
+
if (landmarks.length === 0) {
|
|
1986
|
+
throw new Error("auto-rig: solveTurnDepth: no landmark to slide");
|
|
1987
|
+
}
|
|
1988
|
+
const farEdge = Math.min(...landmarks.map((l) => l.x - l.w / 2));
|
|
1989
|
+
const floor = Math.max(map.invertX(holdEdgeX), map.restX[0], plateEdgeX);
|
|
1990
|
+
const cap = Math.max(0, (farEdge - floor) / unit);
|
|
1991
|
+
const achieved = (d) => landmarks.reduce((sum, l) => sum + (map.mapX(l.x - d * unit) - l.x), 0) / landmarks.length;
|
|
1992
|
+
const atCap = achieved(cap);
|
|
1993
|
+
const atRest = achieved(0);
|
|
1994
|
+
const attainable = [atCap, atRest];
|
|
1995
|
+
if (target > atRest + TURN_DEPTH_EPS)
|
|
1996
|
+
return { reached: false, depth: 0, achieved: atRest, attainable };
|
|
1997
|
+
if (target < atCap - TURN_DEPTH_EPS)
|
|
1998
|
+
return { reached: false, depth: cap, achieved: atCap, attainable };
|
|
1999
|
+
const clampedTarget = Math.min(Math.max(target, atCap), atRest);
|
|
2000
|
+
let lo = 0;
|
|
2001
|
+
let hi = cap;
|
|
2002
|
+
for (let i = 0; i < TURN_BISECT_STEPS; i++) {
|
|
2003
|
+
const mid = (lo + hi) / 2;
|
|
2004
|
+
if (achieved(mid) > clampedTarget) lo = mid;
|
|
2005
|
+
else hi = mid;
|
|
2006
|
+
}
|
|
2007
|
+
const depth = (lo + hi) / 2;
|
|
2008
|
+
return { reached: true, depth, achieved: achieved(depth), attainable };
|
|
2009
|
+
}
|
|
2010
|
+
var TURN_SWEEP_MAX_FACTOR = 8;
|
|
2011
|
+
var TURN_SWEEP_SAMPLES = 32;
|
|
2012
|
+
var HOLD_CLEARANCE = 1;
|
|
2013
|
+
var TURN_HOLD_SAMPLES = 16;
|
|
2014
|
+
var TURN_HOLD_SATURATION_PASSES = 4;
|
|
2015
|
+
function hairFrontHoldTarget(x, faceCenterX, faceHalfWidth, holdBase, holdEdge, map) {
|
|
2016
|
+
const local = x - faceCenterX;
|
|
2017
|
+
const dist = Math.abs(local);
|
|
2018
|
+
const side = Math.sign(local);
|
|
2019
|
+
const sideIdx = side < 0 ? 0 : 1;
|
|
2020
|
+
if (dist <= faceHalfWidth) return map.mapX(x);
|
|
2021
|
+
if (dist <= holdBase) {
|
|
2022
|
+
const inner = map.mapX(faceCenterX + side * faceHalfWidth);
|
|
2023
|
+
const u = (dist - faceHalfWidth) / (holdBase - faceHalfWidth);
|
|
2024
|
+
return inner + (faceCenterX + side * holdEdge[sideIdx] - inner) * u;
|
|
2025
|
+
}
|
|
2026
|
+
return x + side * (holdEdge[sideIdx] - holdBase);
|
|
2027
|
+
}
|
|
2028
|
+
function hairFrontLandingAt(x, hairFront, faceCenterX, faceHalfWidth, holdBase, cappedHoldEdge, map, leadTipShift, leadRow) {
|
|
2029
|
+
const { cols, rows } = meshCellsFor(hairFront.cropW, hairFront.cropH);
|
|
2030
|
+
const preBindAt = (col2) => {
|
|
2031
|
+
const colX = hairFront.x - hairFront.cropW / 2 + col2 / cols * hairFront.cropW;
|
|
2032
|
+
const target = hairFrontHoldTarget(
|
|
2033
|
+
colX,
|
|
2034
|
+
faceCenterX,
|
|
2035
|
+
faceHalfWidth,
|
|
2036
|
+
holdBase,
|
|
2037
|
+
cappedHoldEdge,
|
|
2038
|
+
map
|
|
2039
|
+
);
|
|
2040
|
+
return map.invertX(target);
|
|
2041
|
+
};
|
|
2042
|
+
const leadAt = (row2) => -leadTipShift * Math.pow(row2 / rows, HAIR_SWAY_CURL);
|
|
2043
|
+
const bracket = (v, cells) => {
|
|
2044
|
+
const lo = Math.max(0, Math.min(cells, Math.floor(v)));
|
|
2045
|
+
return {
|
|
2046
|
+
lo,
|
|
2047
|
+
hi: Math.max(0, Math.min(cells, lo + 1)),
|
|
2048
|
+
t: Math.max(0, Math.min(1, v - lo))
|
|
2049
|
+
};
|
|
2050
|
+
};
|
|
2051
|
+
const col = bracket(
|
|
2052
|
+
(x - hairFront.x + hairFront.cropW / 2) / hairFront.cropW * cols,
|
|
2053
|
+
cols
|
|
2054
|
+
);
|
|
2055
|
+
const row = bracket(leadRow * rows, rows);
|
|
2056
|
+
const landingAt = (c, r) => map.mapX(preBindAt(c) + leadAt(r));
|
|
2057
|
+
const tl = landingAt(col.lo, row.lo);
|
|
2058
|
+
const br = landingAt(col.hi, row.hi);
|
|
2059
|
+
if (col.t <= row.t) {
|
|
2060
|
+
const bl = landingAt(col.lo, row.hi);
|
|
2061
|
+
return tl + row.t * (bl - tl) + col.t * (br - bl);
|
|
2062
|
+
}
|
|
2063
|
+
const tr = landingAt(col.hi, row.lo);
|
|
2064
|
+
return tl + col.t * (tr - tl) + row.t * (br - tr);
|
|
2065
|
+
}
|
|
2066
|
+
function shellTravelCap(faceCenterX, faceHalfWidth, askedTravel, bendOnlyMapAt, holdEdgeAt) {
|
|
2067
|
+
let cap = Infinity;
|
|
2068
|
+
for (const deg of HEAD_TURN_STOPS) {
|
|
2069
|
+
if (deg === 0) continue;
|
|
2070
|
+
const map = bendOnlyMapAt(deg);
|
|
2071
|
+
const slideSign = turnSlide(askedTravel, deg) < 0 ? -1 : 1;
|
|
2072
|
+
const towardReach = Math.max(
|
|
2073
|
+
...[-1, 1].map(
|
|
2074
|
+
(side) => slideSign * (map.mapX(faceCenterX + side * faceHalfWidth) - faceCenterX)
|
|
2075
|
+
)
|
|
2076
|
+
);
|
|
2077
|
+
cap = Math.min(
|
|
2078
|
+
cap,
|
|
2079
|
+
Math.max(0, holdEdgeAt(deg, slideSign) - HOLD_CLEARANCE - towardReach) * HEAD_TURN_MAX_DEG / Math.abs(deg)
|
|
2080
|
+
);
|
|
2081
|
+
}
|
|
2082
|
+
return cap;
|
|
2083
|
+
}
|
|
2084
|
+
function evaluateTurnCandidate(ctx, radius) {
|
|
2085
|
+
const bendOnlyMapAt = (deg) => turnColumnMap(ctx.faceGrid, ctx.faceCenterX, radius, deg, 0);
|
|
2086
|
+
const measuredEdgeAt = (side) => {
|
|
2087
|
+
const candidates = side < 0 ? ctx.headEdges?.left : ctx.headEdges?.right;
|
|
2088
|
+
if (candidates === void 0 || candidates.length === 0) return void 0;
|
|
2089
|
+
const xs = candidates.map((c) => c.x);
|
|
2090
|
+
return side < 0 ? Math.min(...xs) : Math.max(...xs);
|
|
2091
|
+
};
|
|
2092
|
+
const shellHalfWidthAt = (side) => {
|
|
2093
|
+
const edge = measuredEdgeAt(side);
|
|
2094
|
+
return edge === void 0 ? ctx.headHalfWidth : side * (edge - ctx.faceCenterX);
|
|
2095
|
+
};
|
|
2096
|
+
const unit = headTurnParallaxUnit(radius);
|
|
2097
|
+
const leadTipShift = ctx.hairFrontLeadFraction === void 0 ? 0 : HAIR_FRONT_DEPTH * unit;
|
|
2098
|
+
const leadRow = ctx.hairFrontLeadFraction ?? 0;
|
|
2099
|
+
const evaluateHold = (ratio) => {
|
|
2100
|
+
const holdEdgeFrom = (base) => (deg) => base * (1 + (ratio - 1) * Math.abs(deg) / HEAD_TURN_MAX_DEG);
|
|
2101
|
+
const travel2 = ctx.headHalfWidth === void 0 ? ctx.travel : Math.min(
|
|
2102
|
+
ctx.travel,
|
|
2103
|
+
shellTravelCap(
|
|
2104
|
+
ctx.faceCenterX,
|
|
2105
|
+
ctx.faceHalfWidth,
|
|
2106
|
+
ctx.travel,
|
|
2107
|
+
bendOnlyMapAt,
|
|
2108
|
+
(deg, side) => {
|
|
2109
|
+
const rest = shellHalfWidthAt(side);
|
|
2110
|
+
return Math.min(rest, holdEdgeFrom(rest)(deg));
|
|
2111
|
+
}
|
|
2112
|
+
)
|
|
2113
|
+
);
|
|
2114
|
+
const columnMapAt = (deg) => turnColumnMap(ctx.faceGrid, ctx.faceCenterX, radius, deg, travel2);
|
|
2115
|
+
const holdBase2 = ctx.headHalfWidth ?? plateReach(ctx.faceCenterX, ctx.faceHalfWidth, columnMapAt) + HOLD_CLEARANCE;
|
|
2116
|
+
const holdEdgeAt2 = holdEdgeFrom(holdBase2);
|
|
2117
|
+
let foldLowerBound = -Infinity;
|
|
2118
|
+
let folds = false;
|
|
2119
|
+
for (const deg of HEAD_TURN_STOPS) {
|
|
2120
|
+
if (deg === 0) continue;
|
|
2121
|
+
const reach = plateReachAt(
|
|
2122
|
+
columnMapAt(deg),
|
|
2123
|
+
ctx.faceCenterX,
|
|
2124
|
+
ctx.faceHalfWidth
|
|
2125
|
+
);
|
|
2126
|
+
foldLowerBound = Math.max(
|
|
2127
|
+
foldLowerBound,
|
|
2128
|
+
1 + ((reach + HOLD_CLEARANCE) / holdBase2 - 1) * (HEAD_TURN_MAX_DEG / Math.abs(deg))
|
|
2129
|
+
);
|
|
2130
|
+
if (holdEdgeAt2(deg) <= reach) folds = true;
|
|
2131
|
+
}
|
|
2132
|
+
const map2 = columnMapAt(-HEAD_TURN_MAX_DEG);
|
|
2133
|
+
const requestedHoldEdge = holdEdgeAt2(HEAD_TURN_MAX_DEG);
|
|
2134
|
+
const gridReach2 = [
|
|
2135
|
+
Math.abs(map2.warpedX[0] - ctx.faceCenterX),
|
|
2136
|
+
Math.abs(map2.warpedX[map2.warpedX.length - 1] - ctx.faceCenterX)
|
|
2137
|
+
];
|
|
2138
|
+
const cappedHoldEdge = [
|
|
2139
|
+
Math.min(requestedHoldEdge, gridReach2[0] - HOLD_CLEARANCE),
|
|
2140
|
+
Math.min(requestedHoldEdge, gridReach2[1] - HOLD_CLEARANCE)
|
|
2141
|
+
];
|
|
2142
|
+
const restAt = (side) => {
|
|
2143
|
+
const edge = measuredEdgeAt(side);
|
|
2144
|
+
if (edge !== void 0) return edge;
|
|
2145
|
+
return ctx.headHalfWidth !== void 0 || ctx.hairFrontSilhouette === void 0 ? ctx.faceCenterX + side * holdBase2 : ctx.hairFrontSilhouette.x + side * ctx.hairFrontSilhouette.cropW / 2;
|
|
2146
|
+
};
|
|
2147
|
+
const hairFrontAt = (x) => ctx.hairFrontSilhouette === void 0 ? x : hairFrontLandingAt(
|
|
2148
|
+
x,
|
|
2149
|
+
ctx.hairFrontSilhouette,
|
|
2150
|
+
ctx.faceCenterX,
|
|
2151
|
+
ctx.faceHalfWidth,
|
|
2152
|
+
holdBase2,
|
|
2153
|
+
cappedHoldEdge,
|
|
2154
|
+
map2,
|
|
2155
|
+
leadTipShift,
|
|
2156
|
+
leadRow
|
|
2157
|
+
);
|
|
2158
|
+
const landingOfRole = (role, x) => {
|
|
2159
|
+
if (role === "hair_front") return hairFrontAt(x);
|
|
2160
|
+
if (role === "hair_back") {
|
|
2161
|
+
return x;
|
|
2162
|
+
}
|
|
2163
|
+
if (role === "body") {
|
|
2164
|
+
return x - BODY_TURN_FOLLOW * travel2;
|
|
2165
|
+
}
|
|
2166
|
+
if (role === "face" || FEATURE_NOD_DEPTH[roleFamily(role)] !== void 0) {
|
|
2167
|
+
return map2.mapX(x);
|
|
2168
|
+
}
|
|
2169
|
+
throw new Error(
|
|
2170
|
+
`auto-rig: evaluateTurnCandidate: unrecognised role "${role}"`
|
|
2171
|
+
);
|
|
2172
|
+
};
|
|
2173
|
+
const landingAt = (side) => {
|
|
2174
|
+
const candidates = side < 0 ? ctx.headEdges?.left : ctx.headEdges?.right;
|
|
2175
|
+
if (candidates === void 0 || candidates.length === 0) {
|
|
2176
|
+
return hairFrontAt(restAt(side));
|
|
2177
|
+
}
|
|
2178
|
+
const landings = candidates.map((c) => landingOfRole(c.role, c.x));
|
|
2179
|
+
return side < 0 ? Math.min(...landings) : Math.max(...landings);
|
|
2180
|
+
};
|
|
2181
|
+
return {
|
|
2182
|
+
ratio,
|
|
2183
|
+
travel: travel2,
|
|
2184
|
+
map: map2,
|
|
2185
|
+
holdBase: holdBase2,
|
|
2186
|
+
holdEdgeAt: holdEdgeAt2,
|
|
2187
|
+
folds,
|
|
2188
|
+
foldLowerBound,
|
|
2189
|
+
gridReach: gridReach2,
|
|
2190
|
+
// How far the silhouette's own centre drifts off the face centre at full
|
|
2191
|
+
// turn, against how far it already sat off centre at rest (zero unless
|
|
2192
|
+
// hair_front's crop is itself off-centre) — the held shell keeps its own
|
|
2193
|
+
// place while the face slides inside it, so this is the drift the hold
|
|
2194
|
+
// and the bangs' lead leave behind, and the slide itself shows up in the
|
|
2195
|
+
// eye cue instead of cancelling out of it.
|
|
2196
|
+
// `measure_turn_reference` reads the eye pair against THIS moved centre,
|
|
2197
|
+
// not the face centre, so it is what the eye cue is corrected by.
|
|
2198
|
+
silhouetteCenterShift: (landingAt(1) + landingAt(-1)) / 2 - (restAt(1) + restAt(-1)) / 2,
|
|
2199
|
+
// What a render actually shows: the real landing span over the real rest
|
|
2200
|
+
// span, the same pair silhouetteCenterShift reads — the measure the
|
|
2201
|
+
// hold is FITTED against, see this function's own doc.
|
|
2202
|
+
renderedSilhouetteRatio: (landingAt(1) - landingAt(-1)) / (restAt(1) - restAt(-1)),
|
|
2203
|
+
restSpan: restAt(1) - restAt(-1)
|
|
2204
|
+
};
|
|
2205
|
+
};
|
|
2206
|
+
const saturatedHold = () => {
|
|
2207
|
+
let hold2 = evaluateHold(1);
|
|
2208
|
+
for (let i = 0; i < TURN_HOLD_SATURATION_PASSES; i++) {
|
|
2209
|
+
const needed = (Math.max(hold2.gridReach[0], hold2.gridReach[1]) - HOLD_CLEARANCE) / hold2.holdBase;
|
|
2210
|
+
if (needed <= hold2.ratio) break;
|
|
2211
|
+
hold2 = evaluateHold(needed);
|
|
2212
|
+
}
|
|
2213
|
+
return hold2;
|
|
2214
|
+
};
|
|
2215
|
+
const holdRange = () => {
|
|
2216
|
+
const ceiling = saturatedHold();
|
|
2217
|
+
if (ceiling.folds) return void 0;
|
|
2218
|
+
let floor = evaluateHold(Math.min(ceiling.foldLowerBound, ceiling.ratio));
|
|
2219
|
+
if (floor.folds) {
|
|
2220
|
+
let lo = floor;
|
|
2221
|
+
let hi = ceiling;
|
|
2222
|
+
for (let i = 0; i < TURN_BISECT_STEPS; i++) {
|
|
2223
|
+
const mid = evaluateHold((lo.ratio + hi.ratio) / 2);
|
|
2224
|
+
if (mid.folds) lo = mid;
|
|
2225
|
+
else hi = mid;
|
|
2226
|
+
}
|
|
2227
|
+
floor = hi;
|
|
2228
|
+
}
|
|
2229
|
+
const samples = [floor];
|
|
2230
|
+
for (let i = 1; i < TURN_HOLD_SAMPLES; i++) {
|
|
2231
|
+
const sample = evaluateHold(
|
|
2232
|
+
floor.ratio + (ceiling.ratio - floor.ratio) * i / TURN_HOLD_SAMPLES
|
|
2233
|
+
);
|
|
2234
|
+
if (!sample.folds) samples.push(sample);
|
|
2235
|
+
}
|
|
2236
|
+
samples.push(ceiling);
|
|
2237
|
+
return samples;
|
|
2238
|
+
};
|
|
2239
|
+
const silhouetteMiss = (span) => {
|
|
2240
|
+
const lower = Math.max(span?.[0] ?? Infinity, SILHOUETTE_RATIO_MIN);
|
|
2241
|
+
const upper = Math.min(span?.[1] ?? -Infinity, TURN_RATIO_MAX);
|
|
2242
|
+
return lower > upper ? { blocked: "silhouetteRatio", lower: Infinity, upper: -Infinity } : { blocked: "silhouetteRatio", lower, upper };
|
|
2243
|
+
};
|
|
2244
|
+
const asked = ctx.silhouetteRatio;
|
|
2245
|
+
const natural = evaluateHold(asked);
|
|
2246
|
+
const eps = TURN_DEPTH_EPS / natural.restSpan;
|
|
2247
|
+
let hold;
|
|
2248
|
+
let silhouetteClamped = false;
|
|
2249
|
+
if (!natural.folds && Math.abs(natural.renderedSilhouetteRatio - asked) <= eps) {
|
|
2250
|
+
hold = natural;
|
|
2251
|
+
} else {
|
|
2252
|
+
const samples = holdRange();
|
|
2253
|
+
if (samples === void 0) return silhouetteMiss();
|
|
2254
|
+
const rendered = samples.map((h) => h.renderedSilhouetteRatio);
|
|
2255
|
+
const span = [
|
|
2256
|
+
Math.min(...rendered),
|
|
2257
|
+
Math.max(...rendered)
|
|
2258
|
+
];
|
|
2259
|
+
const nearer = (a, b) => {
|
|
2260
|
+
const ea = Math.abs(a.renderedSilhouetteRatio - asked);
|
|
2261
|
+
const eb = Math.abs(b.renderedSilhouetteRatio - asked);
|
|
2262
|
+
if (ea <= eps && eb <= eps) {
|
|
2263
|
+
return Math.abs(a.ratio - asked) <= Math.abs(b.ratio - asked) ? a : b;
|
|
2264
|
+
}
|
|
2265
|
+
return ea <= eb ? a : b;
|
|
2266
|
+
};
|
|
2267
|
+
if (asked < span[0] - eps || asked > span[1] + eps) {
|
|
2268
|
+
if (!ctx.clampSilhouetteRatio) return silhouetteMiss(span);
|
|
2269
|
+
const wanted = asked < span[0] ? span[0] : span[1];
|
|
2270
|
+
hold = samples.reduce(
|
|
2271
|
+
(a, b) => Math.abs(b.renderedSilhouetteRatio - wanted) < Math.abs(a.renderedSilhouetteRatio - wanted) ? b : a
|
|
2272
|
+
);
|
|
2273
|
+
silhouetteClamped = true;
|
|
2274
|
+
} else {
|
|
2275
|
+
let best = samples.reduce(nearer);
|
|
2276
|
+
let lo;
|
|
2277
|
+
let hi;
|
|
2278
|
+
for (let i = 0; i + 1 < samples.length; i++) {
|
|
2279
|
+
const a = samples[i].renderedSilhouetteRatio - asked;
|
|
2280
|
+
const b = samples[i + 1].renderedSilhouetteRatio - asked;
|
|
2281
|
+
if (a * b <= 0) {
|
|
2282
|
+
lo = samples[i];
|
|
2283
|
+
hi = samples[i + 1];
|
|
2284
|
+
break;
|
|
2285
|
+
}
|
|
2286
|
+
}
|
|
2287
|
+
for (let i = 0; lo !== void 0 && hi !== void 0; i++) {
|
|
2288
|
+
if (i >= TURN_BISECT_STEPS || Math.abs(best.renderedSilhouetteRatio - asked) <= eps) {
|
|
2289
|
+
break;
|
|
2290
|
+
}
|
|
2291
|
+
const mid = evaluateHold((lo.ratio + hi.ratio) / 2);
|
|
2292
|
+
if (!mid.folds) best = nearer(best, mid);
|
|
2293
|
+
if ((mid.renderedSilhouetteRatio - asked) * (lo.renderedSilhouetteRatio - asked) > 0) {
|
|
2294
|
+
lo = mid;
|
|
2295
|
+
} else {
|
|
2296
|
+
hi = mid;
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
hold = best;
|
|
2300
|
+
}
|
|
2301
|
+
}
|
|
2302
|
+
if (hold.folds) {
|
|
2303
|
+
throw new Error(
|
|
2304
|
+
"auto-rig: evaluateTurnCandidate: the hold it settled on folds"
|
|
2305
|
+
);
|
|
2306
|
+
}
|
|
2307
|
+
const {
|
|
2308
|
+
travel,
|
|
2309
|
+
map,
|
|
2310
|
+
holdBase,
|
|
2311
|
+
holdEdgeAt,
|
|
2312
|
+
silhouetteCenterShift,
|
|
2313
|
+
renderedSilhouetteRatio
|
|
2314
|
+
} = hold;
|
|
2315
|
+
const holdEdgeX = ctx.faceCenterX - holdEdgeAt(HEAD_TURN_MAX_DEG);
|
|
2316
|
+
const eye = solveTurnDepthSigned(
|
|
2317
|
+
silhouetteCenterShift - ctx.eyeShift * ctx.hh,
|
|
2318
|
+
ctx.landmarks.eye,
|
|
2319
|
+
unit,
|
|
2320
|
+
map,
|
|
2321
|
+
holdEdgeX,
|
|
2322
|
+
ctx.plateEdgeX
|
|
2323
|
+
);
|
|
2324
|
+
if (!eye.reached && !ctx.clampEyeShift) {
|
|
2325
|
+
return {
|
|
2326
|
+
blocked: "eyeShift",
|
|
2327
|
+
// eyeShift is a MAGNITUDE (the sign is ignored — see TurnTargets), so a
|
|
2328
|
+
// negative lower bound here would advertise a value the field's own
|
|
2329
|
+
// contract already rules out; 0 is the true floor.
|
|
2330
|
+
offeredShift: [
|
|
2331
|
+
Math.max(0, (-eye.attainable[1] + silhouetteCenterShift) / ctx.hh),
|
|
2332
|
+
(-eye.attainable[0] + silhouetteCenterShift) / ctx.hh
|
|
2333
|
+
]
|
|
2334
|
+
};
|
|
2335
|
+
}
|
|
2336
|
+
const scaleOf = (l) => (map.mapX(l.x + l.w / 2 - eye.depth * unit) - map.mapX(l.x - l.w / 2 - eye.depth * unit)) / l.w;
|
|
2337
|
+
const far = ctx.landmarks.eye.reduce((a, b) => b.x < a.x ? b : a);
|
|
2338
|
+
const near = ctx.landmarks.eye.reduce((a, b) => b.x > a.x ? b : a);
|
|
2339
|
+
return {
|
|
2340
|
+
radius,
|
|
2341
|
+
ratio: scaleOf(far) / scaleOf(near),
|
|
2342
|
+
unit,
|
|
2343
|
+
travel,
|
|
2344
|
+
map,
|
|
2345
|
+
holdBase,
|
|
2346
|
+
holdEdgeAt,
|
|
2347
|
+
holdEdgeX,
|
|
2348
|
+
eye,
|
|
2349
|
+
silhouetteClamped,
|
|
2350
|
+
silhouetteCenterShift,
|
|
2351
|
+
renderedSilhouetteRatio
|
|
2352
|
+
};
|
|
2353
|
+
}
|
|
2354
|
+
function sweepTurnRadii(ctx) {
|
|
2355
|
+
const minRadius = ctx.faceHalfWidth * HEAD_CYLINDER_RADIUS_FACTOR;
|
|
2356
|
+
const maxRadius = ctx.faceHalfWidth * TURN_SWEEP_MAX_FACTOR;
|
|
2357
|
+
const candidates = [];
|
|
2358
|
+
let heldRatioLower = Infinity;
|
|
2359
|
+
let heldRatioUpper = -Infinity;
|
|
2360
|
+
let offeredShift;
|
|
2361
|
+
for (let i = 0; i < TURN_SWEEP_SAMPLES; i++) {
|
|
2362
|
+
const radius = minRadius * Math.pow(maxRadius / minRadius, i / (TURN_SWEEP_SAMPLES - 1));
|
|
2363
|
+
const result = evaluateTurnCandidate(ctx, radius);
|
|
2364
|
+
if (!("blocked" in result)) {
|
|
2365
|
+
candidates.push({ ...result, sweepIndex: i });
|
|
2366
|
+
} else if (result.blocked === "silhouetteRatio") {
|
|
2367
|
+
heldRatioLower = Math.min(heldRatioLower, result.lower);
|
|
2368
|
+
heldRatioUpper = Math.max(heldRatioUpper, result.upper);
|
|
2369
|
+
} else {
|
|
2370
|
+
offeredShift = offeredShift ? [
|
|
2371
|
+
Math.min(offeredShift[0], result.offeredShift[0]),
|
|
2372
|
+
Math.max(offeredShift[1], result.offeredShift[1])
|
|
2373
|
+
] : result.offeredShift;
|
|
2374
|
+
}
|
|
2375
|
+
}
|
|
2376
|
+
return { candidates, heldRatioLower, heldRatioUpper, offeredShift };
|
|
2377
|
+
}
|
|
2378
|
+
function ratioClusters(candidates) {
|
|
2379
|
+
const clusters = [];
|
|
2380
|
+
let run = [];
|
|
2381
|
+
const close = () => {
|
|
2382
|
+
if (run.length === 0) return;
|
|
2383
|
+
const ratios = run.map((c) => c.ratio);
|
|
2384
|
+
clusters.push({
|
|
2385
|
+
candidates: run,
|
|
2386
|
+
ratios: [Math.min(...ratios), Math.max(...ratios)]
|
|
2387
|
+
});
|
|
2388
|
+
run = [];
|
|
2389
|
+
};
|
|
2390
|
+
for (const candidate of candidates) {
|
|
2391
|
+
const previous = run[run.length - 1];
|
|
2392
|
+
if (previous !== void 0 && candidate.sweepIndex !== previous.sweepIndex + 1) {
|
|
2393
|
+
close();
|
|
2394
|
+
}
|
|
2395
|
+
run.push(candidate);
|
|
2396
|
+
}
|
|
2397
|
+
close();
|
|
2398
|
+
return clusters;
|
|
2399
|
+
}
|
|
2400
|
+
function fitTurnRadius(ctx, candidates, target) {
|
|
2401
|
+
let lo = candidates[0];
|
|
2402
|
+
let hi = candidates[candidates.length - 1];
|
|
2403
|
+
for (let i = candidates.length - 2; i >= 0; i--) {
|
|
2404
|
+
if ((candidates[i].ratio - target) * (candidates[i + 1].ratio - target) <= 0) {
|
|
2405
|
+
lo = candidates[i];
|
|
2406
|
+
hi = candidates[i + 1];
|
|
2407
|
+
break;
|
|
2408
|
+
}
|
|
2409
|
+
}
|
|
2410
|
+
for (let i = 0; i < TURN_BISECT_STEPS; i++) {
|
|
2411
|
+
const mid = evaluateTurnCandidate(ctx, Math.sqrt(lo.radius * hi.radius));
|
|
2412
|
+
if ("blocked" in mid) break;
|
|
2413
|
+
if ((mid.ratio - target) * (lo.ratio - target) <= 0) hi = mid;
|
|
2414
|
+
else lo = mid;
|
|
2415
|
+
}
|
|
2416
|
+
return Math.abs(lo.ratio - target) <= Math.abs(hi.ratio - target) ? lo : hi;
|
|
2417
|
+
}
|
|
2418
|
+
function solveFeatureDepths(ctx, targets, best, eyeShift) {
|
|
2419
|
+
const clamped = [];
|
|
2420
|
+
const solved = {};
|
|
2421
|
+
const families = [
|
|
2422
|
+
["noseShift", ctx.landmarks.nose, NOSE_SHIFT_SHARE],
|
|
2423
|
+
["mouthShift", ctx.landmarks.mouth, MOUTH_SHIFT_SHARE]
|
|
2424
|
+
];
|
|
2425
|
+
for (const [field, marks, share] of families) {
|
|
2426
|
+
const m = Math.abs(
|
|
2427
|
+
targets.defaulted.has(field) ? share * eyeShift : targets[field]
|
|
2428
|
+
);
|
|
2429
|
+
const solution = solveTurnDepthSigned(
|
|
2430
|
+
best.silhouetteCenterShift - m * ctx.hh,
|
|
2431
|
+
marks,
|
|
2432
|
+
best.unit,
|
|
2433
|
+
best.map,
|
|
2434
|
+
best.holdEdgeX,
|
|
2435
|
+
ctx.plateEdgeX
|
|
2436
|
+
);
|
|
2437
|
+
if (!solution.reached) {
|
|
2438
|
+
if (!targets.defaulted.has(field)) {
|
|
2439
|
+
return {
|
|
2440
|
+
blocked: field,
|
|
2441
|
+
// noseShift/mouthShift are MAGNITUDES too (see the correction
|
|
2442
|
+
// above), so a negative lower bound here would advertise a value
|
|
2443
|
+
// the field's own contract already rules out; 0 is the true floor.
|
|
2444
|
+
attainable: [
|
|
2445
|
+
Math.max(
|
|
2446
|
+
0,
|
|
2447
|
+
(-solution.attainable[1] + best.silhouetteCenterShift) / ctx.hh
|
|
2448
|
+
),
|
|
2449
|
+
(-solution.attainable[0] + best.silhouetteCenterShift) / ctx.hh
|
|
2450
|
+
]
|
|
2451
|
+
};
|
|
2452
|
+
}
|
|
2453
|
+
clamped.push(field);
|
|
2454
|
+
}
|
|
2455
|
+
solved[field] = solution.depth;
|
|
2456
|
+
}
|
|
2457
|
+
return {
|
|
2458
|
+
nose: solved.noseShift,
|
|
2459
|
+
mouth: solved.mouthShift,
|
|
2460
|
+
clamped
|
|
2461
|
+
};
|
|
2462
|
+
}
|
|
2463
|
+
function solveTurnModel(targets, landmarks, faceGrid, faceCenterX, faceHalfWidth, hairFront, headEdges) {
|
|
2464
|
+
if (targets.headHalfWidth !== void 0 && targets.headHalfWidth <= faceHalfWidth) {
|
|
2465
|
+
throw new TurnTargetError(
|
|
2466
|
+
`auto-rig: turnTargets.headHalfWidth (${targets.headHalfWidth}) must be wider than the face plate's own half-width (${faceHalfWidth})`
|
|
2467
|
+
);
|
|
2468
|
+
}
|
|
2469
|
+
const eyeRowY = landmarks.eye.length === 2 && landmarks.eye[0].y !== void 0 && landmarks.eye[1].y !== void 0 ? (landmarks.eye[0].y + landmarks.eye[1].y) / 2 : void 0;
|
|
2470
|
+
const hairFrontLeadFraction = hairFront === void 0 || eyeRowY === void 0 ? void 0 : Math.max(
|
|
2471
|
+
0,
|
|
2472
|
+
Math.min(
|
|
2473
|
+
1,
|
|
2474
|
+
(hairFront.centerY + hairFront.cropH / 2 - eyeRowY) / hairFront.cropH
|
|
2475
|
+
)
|
|
2476
|
+
);
|
|
2477
|
+
const ctx = {
|
|
2478
|
+
landmarks,
|
|
2479
|
+
faceGrid,
|
|
2480
|
+
faceCenterX,
|
|
2481
|
+
faceHalfWidth,
|
|
2482
|
+
plateEdgeX: faceCenterX - faceHalfWidth,
|
|
2483
|
+
// What this plate asks the turn for; each candidate caps it to its own
|
|
2484
|
+
// shell — see TurnSolveContext.travel.
|
|
2485
|
+
travel: headTurnTravel(faceHalfWidth),
|
|
2486
|
+
headHalfWidth: targets.headHalfWidth,
|
|
2487
|
+
// The pixels the shift fractions are fractions of. Without a measured head
|
|
2488
|
+
// the face plate stands in for it — see TurnTargets.headHalfWidth.
|
|
2489
|
+
hh: targets.headHalfWidth ?? faceHalfWidth,
|
|
2490
|
+
silhouetteRatio: targets.silhouetteRatio,
|
|
2491
|
+
// The sign is ignored (see TurnTargets.eyeShift) — taken here, once, so
|
|
2492
|
+
// every downstream use already has the magnitude.
|
|
2493
|
+
eyeShift: Math.abs(targets.eyeShift),
|
|
2494
|
+
clampEyeShift: targets.defaulted.has("eyeShift"),
|
|
2495
|
+
clampSilhouetteRatio: targets.defaulted.has("silhouetteRatio"),
|
|
2496
|
+
hairFrontLeadFraction,
|
|
2497
|
+
hairFrontSilhouette: hairFront === void 0 ? void 0 : { x: hairFront.x, cropW: hairFront.cropW, cropH: hairFront.cropH },
|
|
2498
|
+
// A companion to a MEASURED headHalfWidth only — see its own doc.
|
|
2499
|
+
headEdges: targets.headHalfWidth === void 0 ? void 0 : headEdges
|
|
2500
|
+
};
|
|
2501
|
+
const clamped = [];
|
|
2502
|
+
const clampInto = (value, [lo, hi]) => Math.min(Math.max(value, lo), hi);
|
|
2503
|
+
const pass = sweepTurnRadii(ctx);
|
|
2504
|
+
if (pass.candidates.length === 0) {
|
|
2505
|
+
return pass.offeredShift ? {
|
|
2506
|
+
unreachable: true,
|
|
2507
|
+
field: "eyeShift",
|
|
2508
|
+
value: targets.eyeShift,
|
|
2509
|
+
attainable: pass.offeredShift
|
|
2510
|
+
} : {
|
|
2511
|
+
unreachable: true,
|
|
2512
|
+
field: "silhouetteRatio",
|
|
2513
|
+
value: targets.silhouetteRatio,
|
|
2514
|
+
// Two different bounds, not one number twice, and computed the
|
|
2515
|
+
// same way regardless of which ratio was actually rejected: both
|
|
2516
|
+
// are ratios a RENDER could have shown (the measure the caller's
|
|
2517
|
+
// own number came from), the narrowest and the widest any radius
|
|
2518
|
+
// could be fitted to, with every ratio between them one it can be
|
|
2519
|
+
// fitted to exactly; see evaluateTurnCandidate, which clips each
|
|
2520
|
+
// radius' own range to the domain `resolveTurnTargets` accepts
|
|
2521
|
+
// before it gets here. Absent when that left every radius with
|
|
2522
|
+
// nothing: no ratio a caller is allowed to ask for renders at all.
|
|
2523
|
+
attainable: pass.heldRatioLower === Infinity || pass.heldRatioUpper === -Infinity ? void 0 : [pass.heldRatioLower, pass.heldRatioUpper]
|
|
2524
|
+
};
|
|
2525
|
+
}
|
|
2526
|
+
const clusters = ratioClusters(pass.candidates);
|
|
2527
|
+
const distanceTo = (c) => Math.max(
|
|
2528
|
+
c.ratios[0] - targets.farEyeRatio,
|
|
2529
|
+
targets.farEyeRatio - c.ratios[1],
|
|
2530
|
+
0
|
|
2531
|
+
);
|
|
2532
|
+
let farEyeRatio = targets.farEyeRatio;
|
|
2533
|
+
let cluster = clusters.find(
|
|
2534
|
+
(c) => farEyeRatio >= c.ratios[0] - TURN_DEPTH_EPS && farEyeRatio <= c.ratios[1] + TURN_DEPTH_EPS
|
|
2535
|
+
);
|
|
2536
|
+
if (cluster === void 0) {
|
|
2537
|
+
cluster = clusters.reduce(
|
|
2538
|
+
(a, b) => distanceTo(b) < distanceTo(a) ? b : a
|
|
2539
|
+
);
|
|
2540
|
+
if (!targets.defaulted.has("farEyeRatio")) {
|
|
2541
|
+
return {
|
|
2542
|
+
unreachable: true,
|
|
2543
|
+
field: "farEyeRatio",
|
|
2544
|
+
value: targets.farEyeRatio,
|
|
2545
|
+
attainable: cluster.ratios
|
|
2546
|
+
};
|
|
2547
|
+
}
|
|
2548
|
+
farEyeRatio = clampInto(farEyeRatio, cluster.ratios);
|
|
2549
|
+
clamped.push("farEyeRatio");
|
|
2550
|
+
}
|
|
2551
|
+
const best = fitTurnRadius(ctx, cluster.candidates, farEyeRatio);
|
|
2552
|
+
if (Math.abs(best.ratio - farEyeRatio) > TURN_DEPTH_EPS) {
|
|
2553
|
+
if (!targets.defaulted.has("farEyeRatio")) {
|
|
2554
|
+
return {
|
|
2555
|
+
unreachable: true,
|
|
2556
|
+
field: "farEyeRatio",
|
|
2557
|
+
value: targets.farEyeRatio,
|
|
2558
|
+
attainable: cluster.ratios
|
|
2559
|
+
};
|
|
2560
|
+
}
|
|
2561
|
+
if (!clamped.includes("farEyeRatio")) clamped.push("farEyeRatio");
|
|
2562
|
+
}
|
|
2563
|
+
if (!best.eye.reached) clamped.push("eyeShift");
|
|
2564
|
+
if (best.silhouetteClamped) clamped.push("silhouetteRatio");
|
|
2565
|
+
const eyeShift = (-best.eye.achieved + best.silhouetteCenterShift) / ctx.hh;
|
|
2566
|
+
const features = solveFeatureDepths(ctx, targets, best, eyeShift);
|
|
2567
|
+
if ("blocked" in features) {
|
|
2568
|
+
return {
|
|
2569
|
+
unreachable: true,
|
|
2570
|
+
field: features.blocked,
|
|
2571
|
+
value: targets[features.blocked],
|
|
2572
|
+
attainable: features.attainable
|
|
2573
|
+
};
|
|
2574
|
+
}
|
|
2575
|
+
return {
|
|
2576
|
+
unreachable: false,
|
|
2577
|
+
radius: best.radius,
|
|
2578
|
+
holdBase: best.holdBase,
|
|
2579
|
+
holdEdgeAt: best.holdEdgeAt,
|
|
2580
|
+
travel: best.travel,
|
|
2581
|
+
depths: { eye: best.eye.depth, nose: features.nose, mouth: features.mouth },
|
|
2582
|
+
achieved: {
|
|
2583
|
+
eyeShift,
|
|
2584
|
+
farEyeRatio: best.ratio,
|
|
2585
|
+
silhouetteRatio: best.renderedSilhouetteRatio
|
|
2586
|
+
},
|
|
2587
|
+
clamped: [...clamped, ...features.clamped]
|
|
2588
|
+
};
|
|
2589
|
+
}
|
|
1839
2590
|
var EYE_STACK_PREFIXES = ["eye_", "iris_", "pupil_", "highlight_"];
|
|
1840
2591
|
var HAIR_FRONT_DEPTH = 0.1;
|
|
1841
|
-
var HAIR_BACK_DEPTH = -0.08;
|
|
1842
2592
|
var NOD_TRAVEL = 30;
|
|
1843
2593
|
var NOD_BEND = 0.5;
|
|
1844
|
-
var HAIR_BACK_NOD_DEPTH = -0.
|
|
2594
|
+
var HAIR_BACK_NOD_DEPTH = -0.135;
|
|
2595
|
+
function headNodParallaxUnit(gridHalfHeight) {
|
|
2596
|
+
return gridHalfHeight * HEAD_CYLINDER_RADIUS_FACTOR * Math.sin(HEAD_TURN_MAX_DEG * NOD_BEND * (Math.PI / 180));
|
|
2597
|
+
}
|
|
2598
|
+
var FEATURE_NOD_DEPTH = {
|
|
2599
|
+
eye: 0.04,
|
|
2600
|
+
iris: 0.04,
|
|
2601
|
+
pupil: 0.04,
|
|
2602
|
+
highlight: 0.04,
|
|
2603
|
+
lash: 0.04,
|
|
2604
|
+
brow: 0.04,
|
|
2605
|
+
blush: 0.04,
|
|
2606
|
+
mouth: 0.04,
|
|
2607
|
+
mouth_open: 0.04,
|
|
2608
|
+
nose: 0.08
|
|
2609
|
+
};
|
|
2610
|
+
var HAIR_FRONT_NOD_DEPTH = FEATURE_NOD_DEPTH.brow;
|
|
2611
|
+
function roleFamily(role) {
|
|
2612
|
+
return role.replace(/_[LR]$/, "");
|
|
2613
|
+
}
|
|
2614
|
+
function turnFamily(role) {
|
|
2615
|
+
const family = roleFamily(role);
|
|
2616
|
+
if (family === "nose") return "nose";
|
|
2617
|
+
if (family === "mouth" || family === "mouth_open") return "mouth";
|
|
2618
|
+
return "eye";
|
|
2619
|
+
}
|
|
2620
|
+
function featureParallaxBindings(role, options) {
|
|
2621
|
+
if (!options.hasNose) return [];
|
|
2622
|
+
const nodDepth = FEATURE_NOD_DEPTH[roleFamily(role)];
|
|
2623
|
+
if (nodDepth === void 0) return [];
|
|
2624
|
+
const bindings = [];
|
|
2625
|
+
const turnDepth = options.turnDepths?.[turnFamily(role)] ?? 0;
|
|
2626
|
+
const shiftX = turnDepth * (options.parallaxUnit ?? 0);
|
|
2627
|
+
if (shiftX !== 0) {
|
|
2628
|
+
bindings.push({
|
|
2629
|
+
parameter: StandardParameter.AngleX,
|
|
2630
|
+
channel: "translateX",
|
|
2631
|
+
from: -shiftX,
|
|
2632
|
+
to: shiftX
|
|
2633
|
+
});
|
|
2634
|
+
}
|
|
2635
|
+
const shiftY = nodDepth * (options.parallaxUnitY ?? 0);
|
|
2636
|
+
if (shiftY !== 0) {
|
|
2637
|
+
bindings.push({
|
|
2638
|
+
parameter: StandardParameter.AngleY,
|
|
2639
|
+
channel: "translateY",
|
|
2640
|
+
from: -shiftY,
|
|
2641
|
+
to: shiftY
|
|
2642
|
+
});
|
|
2643
|
+
}
|
|
2644
|
+
return bindings;
|
|
2645
|
+
}
|
|
1845
2646
|
function bindingsForRole(spec, role, cropW, cropH, options = {}) {
|
|
2647
|
+
return [
|
|
2648
|
+
...roleOwnBindings(spec, role, cropW, cropH, options),
|
|
2649
|
+
...featureParallaxBindings(role, options)
|
|
2650
|
+
];
|
|
2651
|
+
}
|
|
2652
|
+
function roleOwnBindings(spec, role, cropW, cropH, options) {
|
|
1846
2653
|
const isEyeStack = EYE_STACK_PREFIXES.some((p) => role.startsWith(p));
|
|
1847
2654
|
if (isEyeStack && spec.eyeSide !== void 0) {
|
|
1848
2655
|
const isGazeRole = role.startsWith("iris_") || role.startsWith("pupil_") || role.startsWith("highlight_");
|
|
@@ -1945,18 +2752,8 @@ function bindingsForRole(spec, role, cropW, cropH, options = {}) {
|
|
|
1945
2752
|
if (role === "hair_front" || role === "hair_back") {
|
|
1946
2753
|
const isFront = role === "hair_front";
|
|
1947
2754
|
const parallax = [];
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
if (shiftX !== 0) {
|
|
1951
|
-
parallax.push({
|
|
1952
|
-
parameter: StandardParameter.AngleX,
|
|
1953
|
-
channel: "translateX",
|
|
1954
|
-
from: -shiftX,
|
|
1955
|
-
to: shiftX
|
|
1956
|
-
});
|
|
1957
|
-
}
|
|
1958
|
-
}
|
|
1959
|
-
const shiftY = (isFront ? 0 : HAIR_BACK_NOD_DEPTH) * (options.parallaxUnitY ?? 0);
|
|
2755
|
+
const frontDepth = options.hasNose ? HAIR_FRONT_NOD_DEPTH : 0;
|
|
2756
|
+
const shiftY = (isFront ? frontDepth : HAIR_BACK_NOD_DEPTH) * (options.parallaxUnitY ?? 0);
|
|
1960
2757
|
if (shiftY !== 0) {
|
|
1961
2758
|
parallax.push({
|
|
1962
2759
|
parameter: StandardParameter.AngleY,
|
|
@@ -1988,35 +2785,68 @@ function bakeEyelidFoldWarp(mesh, parameter, creaseOffsetY, k) {
|
|
|
1988
2785
|
]
|
|
1989
2786
|
};
|
|
1990
2787
|
}
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
right = Math.max(right, mesh.vertices[i]);
|
|
2788
|
+
function plateReach(faceCenterX, faceHalfWidth, columnMapAt) {
|
|
2789
|
+
let reach = 0;
|
|
2790
|
+
for (const deg of HEAD_TURN_STOPS) {
|
|
2791
|
+
reach = Math.max(
|
|
2792
|
+
reach,
|
|
2793
|
+
plateReachAt(columnMapAt(deg), faceCenterX, faceHalfWidth)
|
|
2794
|
+
);
|
|
1999
2795
|
}
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2796
|
+
return reach;
|
|
2797
|
+
}
|
|
2798
|
+
function plateReachAt(map, faceCenterX, faceHalfWidth) {
|
|
2799
|
+
let reach = 0;
|
|
2800
|
+
for (const side of [-1, 1]) {
|
|
2801
|
+
const dest = map.mapX(faceCenterX + side * faceHalfWidth);
|
|
2802
|
+
reach = Math.max(reach, Math.abs(dest - faceCenterX));
|
|
2803
|
+
}
|
|
2804
|
+
return reach;
|
|
2805
|
+
}
|
|
2806
|
+
function bakeHairFrontSilhouetteWarp(mesh, partX, faceCenterX, faceHalfWidth, holdBase, holdEdgeAt, columnMapAt) {
|
|
2807
|
+
if (holdEdgeAt(0) !== holdBase) {
|
|
2808
|
+
throw new Error(
|
|
2809
|
+
`auto-rig: bakeHairFrontSilhouetteWarp: holdEdgeAt(0) is ${holdEdgeAt(0)}, not the hold edge's own rest distance ${holdBase}, so the bangs would move in the rest pose`
|
|
2810
|
+
);
|
|
2811
|
+
}
|
|
2812
|
+
const SIDES = [-1, 1];
|
|
2004
2813
|
const keyforms = HEAD_TURN_STOPS.map((deg) => {
|
|
2005
|
-
const
|
|
2006
|
-
const
|
|
2007
|
-
const
|
|
2008
|
-
|
|
2009
|
-
|
|
2814
|
+
const map = columnMapAt(deg);
|
|
2815
|
+
const requestedHoldEdge = holdEdgeAt(deg);
|
|
2816
|
+
const plateDest = SIDES.map(
|
|
2817
|
+
(side) => map.mapX(faceCenterX + side * faceHalfWidth)
|
|
2818
|
+
);
|
|
2819
|
+
const reached = plateReachAt(map, faceCenterX, faceHalfWidth);
|
|
2820
|
+
if (requestedHoldEdge <= reached) {
|
|
2821
|
+
const side = Math.abs(plateDest[1] - faceCenterX) > Math.abs(plateDest[0] - faceCenterX) ? 1 : -1;
|
|
2822
|
+
throw new Error(
|
|
2823
|
+
`auto-rig: bakeHairFrontSilhouetteWarp: at ${deg}\xB0 on the ${side < 0 ? "-x" : "+x"} side the hold edge sits ${requestedHoldEdge} from the face centre but the plate's edge maps to ${reached}, so the ramp between them would fold`
|
|
2824
|
+
);
|
|
2825
|
+
}
|
|
2826
|
+
const holdEdge = deg === 0 ? [requestedHoldEdge, requestedHoldEdge] : SIDES.map(
|
|
2827
|
+
(_side, idx) => Math.min(
|
|
2828
|
+
requestedHoldEdge,
|
|
2829
|
+
Math.abs(
|
|
2830
|
+
map.warpedX[idx === 0 ? 0 : map.warpedX.length - 1] - faceCenterX
|
|
2831
|
+
) - HOLD_CLEARANCE
|
|
2832
|
+
)
|
|
2833
|
+
);
|
|
2010
2834
|
const offsets = [];
|
|
2011
2835
|
for (let i = 0; i < mesh.vertices.length; i += 2) {
|
|
2012
|
-
const x = mesh.vertices[i];
|
|
2013
|
-
const
|
|
2014
|
-
|
|
2015
|
-
|
|
2836
|
+
const x = partX + mesh.vertices[i];
|
|
2837
|
+
const target = hairFrontHoldTarget(
|
|
2838
|
+
x,
|
|
2839
|
+
faceCenterX,
|
|
2840
|
+
faceHalfWidth,
|
|
2841
|
+
holdBase,
|
|
2842
|
+
holdEdge,
|
|
2843
|
+
map
|
|
2844
|
+
);
|
|
2845
|
+
offsets.push(map.invertX(target) - x, 0);
|
|
2016
2846
|
}
|
|
2017
2847
|
return { value: deg, offsets };
|
|
2018
2848
|
});
|
|
2019
|
-
return { parameter, keyforms };
|
|
2849
|
+
return { parameter: StandardParameter.AngleX, keyforms };
|
|
2020
2850
|
}
|
|
2021
2851
|
var HAIR_SWAY_TIP_FRACTION = 0.09;
|
|
2022
2852
|
var HAIR_SWAY_CURL = 1.5;
|
|
@@ -2044,14 +2874,18 @@ function bakeHairSwayWarp(mesh, parameter, tipShift, range) {
|
|
|
2044
2874
|
]
|
|
2045
2875
|
};
|
|
2046
2876
|
}
|
|
2047
|
-
var
|
|
2877
|
+
var HEAD_TURN_TRAVEL_RATIO = 0.25;
|
|
2878
|
+
function headTurnTravel(faceHalfWidth) {
|
|
2879
|
+
return HEAD_TURN_TRAVEL_RATIO * faceHalfWidth;
|
|
2880
|
+
}
|
|
2048
2881
|
var BODY_TURN_FOLLOW = 0.3;
|
|
2049
2882
|
var HEAD_BREATH_BOB = -12;
|
|
2050
2883
|
var BODY_BREATH_FOLLOW = 0.5;
|
|
2051
|
-
function generateIkiFromLayerSet(layers, canvas) {
|
|
2884
|
+
function generateIkiFromLayerSet(layers, canvas, options = {}) {
|
|
2052
2885
|
validateLayerInputs(layers, canvas);
|
|
2053
2886
|
const hasHair = layers.some((l) => l.role === "hair_front");
|
|
2054
2887
|
const hasMouthOpen = layers.some((l) => l.role === "mouth_open");
|
|
2888
|
+
const hasNose = layers.some((l) => l.role === "nose");
|
|
2055
2889
|
const bodyLayer = layers.find((l) => l.role === "body");
|
|
2056
2890
|
const parameters = [
|
|
2057
2891
|
{
|
|
@@ -2229,8 +3063,53 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2229
3063
|
};
|
|
2230
3064
|
const faceCenterY = faceTransform.y;
|
|
2231
3065
|
const halfH = Math.max(faceCenterY - unionMinY, unionMaxY - faceCenterY);
|
|
2232
|
-
const
|
|
2233
|
-
const
|
|
3066
|
+
const faceHalfWidth = faceLayer.cropW / 2;
|
|
3067
|
+
const hairFrontLayer = layers.find((l) => l.role === "hair_front");
|
|
3068
|
+
const turn = hasNose ? solveTurnModel(
|
|
3069
|
+
resolveTurnTargets(options.turnTargets),
|
|
3070
|
+
turnLandmarks(layers),
|
|
3071
|
+
faceGrid,
|
|
3072
|
+
faceCenterX,
|
|
3073
|
+
faceHalfWidth,
|
|
3074
|
+
hairFrontLayer && (() => {
|
|
3075
|
+
const t = bboxToTransform(
|
|
3076
|
+
hairFrontLayer.bbox,
|
|
3077
|
+
hairFrontLayer.canvasW,
|
|
3078
|
+
hairFrontLayer.canvasH,
|
|
3079
|
+
"hair_front"
|
|
3080
|
+
);
|
|
3081
|
+
return {
|
|
3082
|
+
x: t.x,
|
|
3083
|
+
centerY: t.y,
|
|
3084
|
+
cropW: hairFrontLayer.cropW,
|
|
3085
|
+
cropH: hairFrontLayer.cropH
|
|
3086
|
+
};
|
|
3087
|
+
})(),
|
|
3088
|
+
options.headEdges
|
|
3089
|
+
) : void 0;
|
|
3090
|
+
if (turn?.unreachable) {
|
|
3091
|
+
throw new TurnTargetError(
|
|
3092
|
+
turn.attainable === void 0 ? (
|
|
3093
|
+
// Only a silhouette can land here: nothing the field's own accepted
|
|
3094
|
+
// range allows renders on this layer set, so there is no interval to
|
|
3095
|
+
// offer instead — see `silhouetteMiss` in evaluateTurnCandidate.
|
|
3096
|
+
`auto-rig: turnTargets.${turn.field} ${turn.value} is unreachable for this layer set, and so is every ${turn.field} in [${SILHOUETTE_RATIO_MIN}, ${TURN_RATIO_MAX}]`
|
|
3097
|
+
) : `auto-rig: turnTargets.${turn.field} ${turn.value} is unreachable for this layer set (attainable ${turn.attainable[0]}\u2026${turn.attainable[1]})`
|
|
3098
|
+
);
|
|
3099
|
+
}
|
|
3100
|
+
if (turn) {
|
|
3101
|
+
options.onTurnSolved?.({
|
|
3102
|
+
radius: turn.radius,
|
|
3103
|
+
holdBase: turn.holdBase,
|
|
3104
|
+
depths: turn.depths,
|
|
3105
|
+
achieved: turn.achieved,
|
|
3106
|
+
clamped: turn.clamped
|
|
3107
|
+
});
|
|
3108
|
+
}
|
|
3109
|
+
const faceRadius = turn?.radius ?? halfW * HEAD_CYLINDER_RADIUS_FACTOR;
|
|
3110
|
+
const headTravel = turn?.travel ?? headTurnTravel(faceHalfWidth);
|
|
3111
|
+
const parallaxUnit = headTurnParallaxUnit(faceRadius);
|
|
3112
|
+
const parallaxUnitY = headNodParallaxUnit(halfH);
|
|
2234
3113
|
const faceBottom = faceTransform.y - faceCropH / 2;
|
|
2235
3114
|
const neckPivot = {
|
|
2236
3115
|
x: faceCenterX,
|
|
@@ -2242,26 +3121,27 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2242
3121
|
StandardParameter.AngleX,
|
|
2243
3122
|
StandardParameter.AngleY,
|
|
2244
3123
|
faceCenterX,
|
|
2245
|
-
faceCenterY
|
|
3124
|
+
faceCenterY,
|
|
3125
|
+
faceRadius,
|
|
3126
|
+
headTravel
|
|
2246
3127
|
);
|
|
2247
3128
|
const deformers = [
|
|
2248
3129
|
// headDeformer: rigid matrix rotating/translating the whole head about the
|
|
2249
|
-
// neck pivot
|
|
3130
|
+
// neck pivot. Same bindings as sample-model.ts apart from the turn, which
|
|
3131
|
+
// moves the face inside the head rather than the head itself (see below).
|
|
2250
3132
|
{
|
|
2251
3133
|
id: "headDeformer",
|
|
2252
3134
|
pivot: neckPivot,
|
|
2253
3135
|
bindings: [
|
|
2254
|
-
//
|
|
2255
|
-
//
|
|
2256
|
-
//
|
|
2257
|
-
//
|
|
2258
|
-
//
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
to: HEAD_TURN_TRAVEL
|
|
2264
|
-
},
|
|
3136
|
+
// No AngleX binding: the turn moves NOTHING rigidly. Its sideways
|
|
3137
|
+
// travel is baked into faceWarp's own grid (see
|
|
3138
|
+
// bakeHeadTurnGridWarp2DCentered's `travel`) so the face slides inside
|
|
3139
|
+
// a silhouette the bangs hold; a translate here would take the hair
|
|
3140
|
+
// shell with it. Nor is the turn a roll — the sample model's ±6° "lean
|
|
3141
|
+
// into the turn" was tried here and dropped, since rotating about the
|
|
3142
|
+
// neck pivot swings the crown (~500px above it) far more than the chin,
|
|
3143
|
+
// so the top of the head appeared to lunge ahead of the face on every
|
|
3144
|
+
// turn. Roll is AngleZ's job, below.
|
|
2265
3145
|
// Nod: a vertical translate only. No rotate — a pitch expressed as a
|
|
2266
3146
|
// rigid rotation would sum with the AngleZ roll below at diagonal
|
|
2267
3147
|
// poses, collapsing pitch into roll.
|
|
@@ -2314,12 +3194,13 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2314
3194
|
id: "bodyDeformer",
|
|
2315
3195
|
pivot: { x: bt.x, y: bt.y - bodyLayer.cropH / 2 },
|
|
2316
3196
|
bindings: [
|
|
2317
|
-
// Low-weight follow of the turn, same direction as the
|
|
3197
|
+
// Low-weight follow of the turn, same direction as the face's own
|
|
3198
|
+
// slide — the only rigid part of the travel left.
|
|
2318
3199
|
{
|
|
2319
3200
|
parameter: StandardParameter.AngleX,
|
|
2320
3201
|
channel: "translateX",
|
|
2321
|
-
from: -BODY_TURN_FOLLOW *
|
|
2322
|
-
to: BODY_TURN_FOLLOW *
|
|
3202
|
+
from: -BODY_TURN_FOLLOW * headTravel,
|
|
3203
|
+
to: BODY_TURN_FOLLOW * headTravel
|
|
2323
3204
|
},
|
|
2324
3205
|
// Breath: follow the head's bob, same direction, half amplitude.
|
|
2325
3206
|
{
|
|
@@ -2351,7 +3232,9 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2351
3232
|
const roleBindings = bindingsForRole(spec, role, cropW, cropH, {
|
|
2352
3233
|
hasMouthOpen,
|
|
2353
3234
|
parallaxUnit,
|
|
2354
|
-
parallaxUnitY
|
|
3235
|
+
parallaxUnitY,
|
|
3236
|
+
hasNose,
|
|
3237
|
+
turnDepths: turn?.depths
|
|
2355
3238
|
});
|
|
2356
3239
|
const deformerId = spec.deformer === "none" ? void 0 : spec.deformer;
|
|
2357
3240
|
if (spec.mesh) {
|
|
@@ -2373,6 +3256,7 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2373
3256
|
if ((role === "hair_front" || role === "hair_back") && hasHair) {
|
|
2374
3257
|
let tipShift = HAIR_SWAY_TIP_FRACTION * cropH;
|
|
2375
3258
|
let leadWarp;
|
|
3259
|
+
let silhouetteWarp;
|
|
2376
3260
|
if (role === "hair_front") {
|
|
2377
3261
|
const shift = HAIR_FRONT_DEPTH * parallaxUnit;
|
|
2378
3262
|
const headroom = Math.min(
|
|
@@ -2389,6 +3273,17 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2389
3273
|
shift,
|
|
2390
3274
|
HEAD_TURN_MAX_DEG
|
|
2391
3275
|
);
|
|
3276
|
+
const columnMapAt = (deg) => turnColumnMap(faceGrid, faceCenterX, faceRadius, deg, headTravel);
|
|
3277
|
+
const holdBase = turn?.holdBase ?? plateReach(faceCenterX, faceHalfWidth, columnMapAt) + HOLD_CLEARANCE;
|
|
3278
|
+
silhouetteWarp = bakeHairFrontSilhouetteWarp(
|
|
3279
|
+
mesh,
|
|
3280
|
+
t.x,
|
|
3281
|
+
faceCenterX,
|
|
3282
|
+
faceHalfWidth,
|
|
3283
|
+
holdBase,
|
|
3284
|
+
turn?.holdEdgeAt ?? (() => holdBase),
|
|
3285
|
+
columnMapAt
|
|
3286
|
+
);
|
|
2392
3287
|
}
|
|
2393
3288
|
part.warps = [
|
|
2394
3289
|
bakeHairSwayWarp(
|
|
@@ -2403,13 +3298,8 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2403
3298
|
tipShift,
|
|
2404
3299
|
HAIR_SWAY_RANGE
|
|
2405
3300
|
),
|
|
2406
|
-
...leadWarp ? [leadWarp] : []
|
|
2407
|
-
|
|
2408
|
-
}
|
|
2409
|
-
if (role === "hair_back") {
|
|
2410
|
-
part.warps = [
|
|
2411
|
-
...part.warps ?? [],
|
|
2412
|
-
bakeHairBackTurnWarp(mesh, StandardParameter.AngleX)
|
|
3301
|
+
...leadWarp ? [leadWarp] : [],
|
|
3302
|
+
...silhouetteWarp ? [silhouetteWarp] : []
|
|
2413
3303
|
];
|
|
2414
3304
|
}
|
|
2415
3305
|
if (spec.eyeSide !== void 0) {
|
|
@@ -2489,6 +3379,7 @@ export {
|
|
|
2489
3379
|
AddPart,
|
|
2490
3380
|
AddPhysicsRig,
|
|
2491
3381
|
CaptureGridKeyform,
|
|
3382
|
+
DEFAULT_TURN_TARGETS,
|
|
2492
3383
|
DeleteDeformer,
|
|
2493
3384
|
DeletePart,
|
|
2494
3385
|
DeletePhysicsRig,
|
|
@@ -2508,6 +3399,7 @@ export {
|
|
|
2508
3399
|
SetPartTransform,
|
|
2509
3400
|
SetPartWidth,
|
|
2510
3401
|
SetPhysicsRig,
|
|
3402
|
+
TurnTargetError,
|
|
2511
3403
|
UV_INSET_PX,
|
|
2512
3404
|
captureBindingEndpoint,
|
|
2513
3405
|
computeGridOffsets,
|