@ikijs/editor 0.4.0 → 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 +916 -75
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +914 -75
- 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,16 +1845,757 @@ 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
2594
|
var HAIR_BACK_NOD_DEPTH = -0.135;
|
|
1845
2595
|
function headNodParallaxUnit(gridHalfHeight) {
|
|
1846
2596
|
return gridHalfHeight * HEAD_CYLINDER_RADIUS_FACTOR * Math.sin(HEAD_TURN_MAX_DEG * NOD_BEND * (Math.PI / 180));
|
|
1847
2597
|
}
|
|
1848
|
-
var
|
|
2598
|
+
var FEATURE_NOD_DEPTH = {
|
|
1849
2599
|
eye: 0.04,
|
|
1850
2600
|
iris: 0.04,
|
|
1851
2601
|
pupil: 0.04,
|
|
@@ -1857,16 +2607,23 @@ var FEATURE_DEPTH = {
|
|
|
1857
2607
|
mouth_open: 0.04,
|
|
1858
2608
|
nose: 0.08
|
|
1859
2609
|
};
|
|
1860
|
-
var HAIR_FRONT_NOD_DEPTH =
|
|
2610
|
+
var HAIR_FRONT_NOD_DEPTH = FEATURE_NOD_DEPTH.brow;
|
|
1861
2611
|
function roleFamily(role) {
|
|
1862
2612
|
return role.replace(/_[LR]$/, "");
|
|
1863
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
|
+
}
|
|
1864
2620
|
function featureParallaxBindings(role, options) {
|
|
1865
2621
|
if (!options.hasNose) return [];
|
|
1866
|
-
const
|
|
1867
|
-
if (
|
|
2622
|
+
const nodDepth = FEATURE_NOD_DEPTH[roleFamily(role)];
|
|
2623
|
+
if (nodDepth === void 0) return [];
|
|
1868
2624
|
const bindings = [];
|
|
1869
|
-
const
|
|
2625
|
+
const turnDepth = options.turnDepths?.[turnFamily(role)] ?? 0;
|
|
2626
|
+
const shiftX = turnDepth * (options.parallaxUnit ?? 0);
|
|
1870
2627
|
if (shiftX !== 0) {
|
|
1871
2628
|
bindings.push({
|
|
1872
2629
|
parameter: StandardParameter.AngleX,
|
|
@@ -1875,7 +2632,7 @@ function featureParallaxBindings(role, options) {
|
|
|
1875
2632
|
to: shiftX
|
|
1876
2633
|
});
|
|
1877
2634
|
}
|
|
1878
|
-
const shiftY =
|
|
2635
|
+
const shiftY = nodDepth * (options.parallaxUnitY ?? 0);
|
|
1879
2636
|
if (shiftY !== 0) {
|
|
1880
2637
|
bindings.push({
|
|
1881
2638
|
parameter: StandardParameter.AngleY,
|
|
@@ -1995,17 +2752,6 @@ function roleOwnBindings(spec, role, cropW, cropH, options) {
|
|
|
1995
2752
|
if (role === "hair_front" || role === "hair_back") {
|
|
1996
2753
|
const isFront = role === "hair_front";
|
|
1997
2754
|
const parallax = [];
|
|
1998
|
-
if (!isFront) {
|
|
1999
|
-
const shiftX = HAIR_BACK_DEPTH * (options.parallaxUnit ?? 0);
|
|
2000
|
-
if (shiftX !== 0) {
|
|
2001
|
-
parallax.push({
|
|
2002
|
-
parameter: StandardParameter.AngleX,
|
|
2003
|
-
channel: "translateX",
|
|
2004
|
-
from: -shiftX,
|
|
2005
|
-
to: shiftX
|
|
2006
|
-
});
|
|
2007
|
-
}
|
|
2008
|
-
}
|
|
2009
2755
|
const frontDepth = options.hasNose ? HAIR_FRONT_NOD_DEPTH : 0;
|
|
2010
2756
|
const shiftY = (isFront ? frontDepth : HAIR_BACK_NOD_DEPTH) * (options.parallaxUnitY ?? 0);
|
|
2011
2757
|
if (shiftY !== 0) {
|
|
@@ -2039,35 +2785,68 @@ function bakeEyelidFoldWarp(mesh, parameter, creaseOffsetY, k) {
|
|
|
2039
2785
|
]
|
|
2040
2786
|
};
|
|
2041
2787
|
}
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
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
|
+
);
|
|
2050
2795
|
}
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
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];
|
|
2055
2813
|
const keyforms = HEAD_TURN_STOPS.map((deg) => {
|
|
2056
|
-
const
|
|
2057
|
-
const
|
|
2058
|
-
const
|
|
2059
|
-
|
|
2060
|
-
|
|
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
|
+
);
|
|
2061
2834
|
const offsets = [];
|
|
2062
2835
|
for (let i = 0; i < mesh.vertices.length; i += 2) {
|
|
2063
|
-
const x = mesh.vertices[i];
|
|
2064
|
-
const
|
|
2065
|
-
|
|
2066
|
-
|
|
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);
|
|
2067
2846
|
}
|
|
2068
2847
|
return { value: deg, offsets };
|
|
2069
2848
|
});
|
|
2070
|
-
return { parameter, keyforms };
|
|
2849
|
+
return { parameter: StandardParameter.AngleX, keyforms };
|
|
2071
2850
|
}
|
|
2072
2851
|
var HAIR_SWAY_TIP_FRACTION = 0.09;
|
|
2073
2852
|
var HAIR_SWAY_CURL = 1.5;
|
|
@@ -2095,11 +2874,14 @@ function bakeHairSwayWarp(mesh, parameter, tipShift, range) {
|
|
|
2095
2874
|
]
|
|
2096
2875
|
};
|
|
2097
2876
|
}
|
|
2098
|
-
var
|
|
2877
|
+
var HEAD_TURN_TRAVEL_RATIO = 0.25;
|
|
2878
|
+
function headTurnTravel(faceHalfWidth) {
|
|
2879
|
+
return HEAD_TURN_TRAVEL_RATIO * faceHalfWidth;
|
|
2880
|
+
}
|
|
2099
2881
|
var BODY_TURN_FOLLOW = 0.3;
|
|
2100
2882
|
var HEAD_BREATH_BOB = -12;
|
|
2101
2883
|
var BODY_BREATH_FOLLOW = 0.5;
|
|
2102
|
-
function generateIkiFromLayerSet(layers, canvas) {
|
|
2884
|
+
function generateIkiFromLayerSet(layers, canvas, options = {}) {
|
|
2103
2885
|
validateLayerInputs(layers, canvas);
|
|
2104
2886
|
const hasHair = layers.some((l) => l.role === "hair_front");
|
|
2105
2887
|
const hasMouthOpen = layers.some((l) => l.role === "mouth_open");
|
|
@@ -2281,7 +3063,52 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2281
3063
|
};
|
|
2282
3064
|
const faceCenterY = faceTransform.y;
|
|
2283
3065
|
const halfH = Math.max(faceCenterY - unionMinY, unionMaxY - faceCenterY);
|
|
2284
|
-
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);
|
|
2285
3112
|
const parallaxUnitY = headNodParallaxUnit(halfH);
|
|
2286
3113
|
const faceBottom = faceTransform.y - faceCropH / 2;
|
|
2287
3114
|
const neckPivot = {
|
|
@@ -2294,26 +3121,27 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2294
3121
|
StandardParameter.AngleX,
|
|
2295
3122
|
StandardParameter.AngleY,
|
|
2296
3123
|
faceCenterX,
|
|
2297
|
-
faceCenterY
|
|
3124
|
+
faceCenterY,
|
|
3125
|
+
faceRadius,
|
|
3126
|
+
headTravel
|
|
2298
3127
|
);
|
|
2299
3128
|
const deformers = [
|
|
2300
3129
|
// headDeformer: rigid matrix rotating/translating the whole head about the
|
|
2301
|
-
// 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).
|
|
2302
3132
|
{
|
|
2303
3133
|
id: "headDeformer",
|
|
2304
3134
|
pivot: neckPivot,
|
|
2305
3135
|
bindings: [
|
|
2306
|
-
//
|
|
2307
|
-
//
|
|
2308
|
-
//
|
|
2309
|
-
//
|
|
2310
|
-
//
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
to: HEAD_TURN_TRAVEL
|
|
2316
|
-
},
|
|
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.
|
|
2317
3145
|
// Nod: a vertical translate only. No rotate — a pitch expressed as a
|
|
2318
3146
|
// rigid rotation would sum with the AngleZ roll below at diagonal
|
|
2319
3147
|
// poses, collapsing pitch into roll.
|
|
@@ -2366,12 +3194,13 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2366
3194
|
id: "bodyDeformer",
|
|
2367
3195
|
pivot: { x: bt.x, y: bt.y - bodyLayer.cropH / 2 },
|
|
2368
3196
|
bindings: [
|
|
2369
|
-
// 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.
|
|
2370
3199
|
{
|
|
2371
3200
|
parameter: StandardParameter.AngleX,
|
|
2372
3201
|
channel: "translateX",
|
|
2373
|
-
from: -BODY_TURN_FOLLOW *
|
|
2374
|
-
to: BODY_TURN_FOLLOW *
|
|
3202
|
+
from: -BODY_TURN_FOLLOW * headTravel,
|
|
3203
|
+
to: BODY_TURN_FOLLOW * headTravel
|
|
2375
3204
|
},
|
|
2376
3205
|
// Breath: follow the head's bob, same direction, half amplitude.
|
|
2377
3206
|
{
|
|
@@ -2404,7 +3233,8 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2404
3233
|
hasMouthOpen,
|
|
2405
3234
|
parallaxUnit,
|
|
2406
3235
|
parallaxUnitY,
|
|
2407
|
-
hasNose
|
|
3236
|
+
hasNose,
|
|
3237
|
+
turnDepths: turn?.depths
|
|
2408
3238
|
});
|
|
2409
3239
|
const deformerId = spec.deformer === "none" ? void 0 : spec.deformer;
|
|
2410
3240
|
if (spec.mesh) {
|
|
@@ -2426,6 +3256,7 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2426
3256
|
if ((role === "hair_front" || role === "hair_back") && hasHair) {
|
|
2427
3257
|
let tipShift = HAIR_SWAY_TIP_FRACTION * cropH;
|
|
2428
3258
|
let leadWarp;
|
|
3259
|
+
let silhouetteWarp;
|
|
2429
3260
|
if (role === "hair_front") {
|
|
2430
3261
|
const shift = HAIR_FRONT_DEPTH * parallaxUnit;
|
|
2431
3262
|
const headroom = Math.min(
|
|
@@ -2442,6 +3273,17 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2442
3273
|
shift,
|
|
2443
3274
|
HEAD_TURN_MAX_DEG
|
|
2444
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
|
+
);
|
|
2445
3287
|
}
|
|
2446
3288
|
part.warps = [
|
|
2447
3289
|
bakeHairSwayWarp(
|
|
@@ -2456,13 +3298,8 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2456
3298
|
tipShift,
|
|
2457
3299
|
HAIR_SWAY_RANGE
|
|
2458
3300
|
),
|
|
2459
|
-
...leadWarp ? [leadWarp] : []
|
|
2460
|
-
|
|
2461
|
-
}
|
|
2462
|
-
if (role === "hair_back") {
|
|
2463
|
-
part.warps = [
|
|
2464
|
-
...part.warps ?? [],
|
|
2465
|
-
bakeHairBackTurnWarp(mesh, StandardParameter.AngleX)
|
|
3301
|
+
...leadWarp ? [leadWarp] : [],
|
|
3302
|
+
...silhouetteWarp ? [silhouetteWarp] : []
|
|
2466
3303
|
];
|
|
2467
3304
|
}
|
|
2468
3305
|
if (spec.eyeSide !== void 0) {
|
|
@@ -2542,6 +3379,7 @@ export {
|
|
|
2542
3379
|
AddPart,
|
|
2543
3380
|
AddPhysicsRig,
|
|
2544
3381
|
CaptureGridKeyform,
|
|
3382
|
+
DEFAULT_TURN_TARGETS,
|
|
2545
3383
|
DeleteDeformer,
|
|
2546
3384
|
DeletePart,
|
|
2547
3385
|
DeletePhysicsRig,
|
|
@@ -2561,6 +3399,7 @@ export {
|
|
|
2561
3399
|
SetPartTransform,
|
|
2562
3400
|
SetPartWidth,
|
|
2563
3401
|
SetPhysicsRig,
|
|
3402
|
+
TurnTargetError,
|
|
2564
3403
|
UV_INSET_PX,
|
|
2565
3404
|
captureBindingEndpoint,
|
|
2566
3405
|
computeGridOffsets,
|