@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.js
CHANGED
|
@@ -26,6 +26,7 @@ __export(index_exports, {
|
|
|
26
26
|
AddPart: () => AddPart,
|
|
27
27
|
AddPhysicsRig: () => AddPhysicsRig,
|
|
28
28
|
CaptureGridKeyform: () => CaptureGridKeyform,
|
|
29
|
+
DEFAULT_TURN_TARGETS: () => DEFAULT_TURN_TARGETS,
|
|
29
30
|
DeleteDeformer: () => DeleteDeformer,
|
|
30
31
|
DeletePart: () => DeletePart,
|
|
31
32
|
DeletePhysicsRig: () => DeletePhysicsRig,
|
|
@@ -45,6 +46,7 @@ __export(index_exports, {
|
|
|
45
46
|
SetPartTransform: () => SetPartTransform,
|
|
46
47
|
SetPartWidth: () => SetPartWidth,
|
|
47
48
|
SetPhysicsRig: () => SetPhysicsRig,
|
|
49
|
+
TurnTargetError: () => TurnTargetError,
|
|
48
50
|
UV_INSET_PX: () => UV_INSET_PX,
|
|
49
51
|
captureBindingEndpoint: () => captureBindingEndpoint,
|
|
50
52
|
computeGridOffsets: () => computeGridOffsets,
|
|
@@ -1664,9 +1666,10 @@ function createDefaultWarpDeformer(model) {
|
|
|
1664
1666
|
var import_format3 = require("@ikijs/format");
|
|
1665
1667
|
var ROLE_TABLE = {
|
|
1666
1668
|
// The silhouette behind the face. It rides the rigid head (not faceWarp,
|
|
1667
|
-
// whose grid it would stretch across the shoulders) as a MESH
|
|
1668
|
-
//
|
|
1669
|
-
//
|
|
1669
|
+
// whose grid it would stretch across the shoulders) as a MESH, so its ends
|
|
1670
|
+
// can swing on the hair-sway warps. It holds the head's outline STILL
|
|
1671
|
+
// through the turn — the head it hangs off no longer travels and it has no
|
|
1672
|
+
// turn warp of its own — while the face slides inside it.
|
|
1670
1673
|
hair_back: { deformer: "headDeformer", order: 0, mesh: true },
|
|
1671
1674
|
// The torso. It rides its own rigid deformer, not the head's: the head turns
|
|
1672
1675
|
// about the neck pivot while the shoulders follow at BODY_TURN_FOLLOW and
|
|
@@ -1848,8 +1851,8 @@ var FACE_GRID_CELLS = 6;
|
|
|
1848
1851
|
var HEAD_CYLINDER_RADIUS_FACTOR = 0.6 / 0.5;
|
|
1849
1852
|
var HEAD_TURN_MAX_DEG = 30;
|
|
1850
1853
|
var HEAD_TURN_STOPS = [-30, -15, 0, 15, 30];
|
|
1851
|
-
function headTurnParallaxUnit(
|
|
1852
|
-
return
|
|
1854
|
+
function headTurnParallaxUnit(radius) {
|
|
1855
|
+
return radius * Math.sin(HEAD_TURN_MAX_DEG * (Math.PI / 180));
|
|
1853
1856
|
}
|
|
1854
1857
|
function gridReach(grid, axis, center) {
|
|
1855
1858
|
let reach = 0;
|
|
@@ -1862,9 +1865,16 @@ function pinnedCylinderBend(local, radius, theta) {
|
|
|
1862
1865
|
const alpha = Math.asin(Math.max(-1, Math.min(1, local / radius)));
|
|
1863
1866
|
return radius * Math.sin(alpha + theta) - local - radius * Math.sin(theta);
|
|
1864
1867
|
}
|
|
1865
|
-
function
|
|
1868
|
+
function boundedCylinderBend(local, radius, theta) {
|
|
1869
|
+
const bound = radius / HEAD_CYLINDER_RADIUS_FACTOR;
|
|
1870
|
+
const onSurface = Math.max(-bound, Math.min(bound, local));
|
|
1871
|
+
return pinnedCylinderBend(onSurface, radius, theta);
|
|
1872
|
+
}
|
|
1873
|
+
function turnSlide(travel, angleX) {
|
|
1874
|
+
return travel * angleX / HEAD_TURN_MAX_DEG;
|
|
1875
|
+
}
|
|
1876
|
+
function bakeHeadTurnGridWarp2DCentered(grid, parameterX, parameterY, centerX, centerY, radiusX, travel) {
|
|
1866
1877
|
const STOPS = [...HEAD_TURN_STOPS];
|
|
1867
|
-
const RADIUS_X = gridReach(grid, 0, centerX) * HEAD_CYLINDER_RADIUS_FACTOR;
|
|
1868
1878
|
const RADIUS_Y = gridReach(grid, 1, centerY) * HEAD_CYLINDER_RADIUS_FACTOR;
|
|
1869
1879
|
const pointCount = grid.points.length / 2;
|
|
1870
1880
|
const DEG_TO_RAD = Math.PI / 180;
|
|
@@ -1873,10 +1883,11 @@ function bakeHeadTurnGridWarp2DCentered(grid, parameterX, parameterY, centerX, c
|
|
|
1873
1883
|
const thetaY = angleY * NOD_BEND * DEG_TO_RAD;
|
|
1874
1884
|
for (const angleX of STOPS) {
|
|
1875
1885
|
const thetaX = angleX * DEG_TO_RAD;
|
|
1886
|
+
const slide = turnSlide(travel, angleX);
|
|
1876
1887
|
const offsets = [];
|
|
1877
1888
|
for (let i = 0; i < pointCount; i++) {
|
|
1878
1889
|
offsets.push(
|
|
1879
|
-
|
|
1890
|
+
boundedCylinderBend(grid.points[i * 2] - centerX, radiusX, thetaX) + slide,
|
|
1880
1891
|
pinnedCylinderBend(
|
|
1881
1892
|
grid.points[i * 2 + 1] - centerY,
|
|
1882
1893
|
RADIUS_Y,
|
|
@@ -1895,13 +1906,811 @@ function bakeHeadTurnGridWarp2DCentered(grid, parameterX, parameterY, centerX, c
|
|
|
1895
1906
|
keyforms2d
|
|
1896
1907
|
};
|
|
1897
1908
|
}
|
|
1909
|
+
function turnColumnMap(grid, faceCenterX, radiusX, angleX, travel) {
|
|
1910
|
+
if (Math.abs(angleX) > HEAD_TURN_MAX_DEG) {
|
|
1911
|
+
throw new Error(
|
|
1912
|
+
`auto-rig: turnColumnMap: angleX ${angleX}\xB0 is outside the turn's \xB1${HEAD_TURN_MAX_DEG}\xB0 range`
|
|
1913
|
+
);
|
|
1914
|
+
}
|
|
1915
|
+
const theta = angleX * (Math.PI / 180);
|
|
1916
|
+
const slide = turnSlide(travel, angleX);
|
|
1917
|
+
const restX = [];
|
|
1918
|
+
const warpedX = [];
|
|
1919
|
+
for (let col = 0; col <= grid.cols; col++) {
|
|
1920
|
+
const x = grid.points[col * 2];
|
|
1921
|
+
restX.push(x);
|
|
1922
|
+
warpedX.push(
|
|
1923
|
+
x + boundedCylinderBend(x - faceCenterX, radiusX, theta) + slide
|
|
1924
|
+
);
|
|
1925
|
+
}
|
|
1926
|
+
const cellFor = (v, edges) => {
|
|
1927
|
+
for (let c = 0; c < grid.cols; c++) {
|
|
1928
|
+
if (v < edges[c + 1]) return c;
|
|
1929
|
+
}
|
|
1930
|
+
return grid.cols - 1;
|
|
1931
|
+
};
|
|
1932
|
+
return {
|
|
1933
|
+
restX,
|
|
1934
|
+
warpedX,
|
|
1935
|
+
mapX(x) {
|
|
1936
|
+
const c = cellFor(x, restX);
|
|
1937
|
+
const s = Math.max(
|
|
1938
|
+
0,
|
|
1939
|
+
Math.min(1, (x - restX[c]) / (restX[c + 1] - restX[c]))
|
|
1940
|
+
);
|
|
1941
|
+
return warpedX[c] + (warpedX[c + 1] - warpedX[c]) * s;
|
|
1942
|
+
},
|
|
1943
|
+
invertX(X) {
|
|
1944
|
+
const c = cellFor(X, warpedX);
|
|
1945
|
+
const s = Math.max(
|
|
1946
|
+
0,
|
|
1947
|
+
Math.min(1, (X - warpedX[c]) / (warpedX[c + 1] - warpedX[c]))
|
|
1948
|
+
);
|
|
1949
|
+
return restX[c] + (restX[c + 1] - restX[c]) * s;
|
|
1950
|
+
}
|
|
1951
|
+
};
|
|
1952
|
+
}
|
|
1953
|
+
var TurnTargetError = class extends Error {
|
|
1954
|
+
constructor(message) {
|
|
1955
|
+
super(message);
|
|
1956
|
+
this.name = "TurnTargetError";
|
|
1957
|
+
}
|
|
1958
|
+
};
|
|
1959
|
+
var DEFAULT_TURN_TARGETS = {
|
|
1960
|
+
eyeShift: 0.22,
|
|
1961
|
+
farEyeRatio: 0.67,
|
|
1962
|
+
silhouetteRatio: 1
|
|
1963
|
+
};
|
|
1964
|
+
var NOSE_SHIFT_SHARE = 1.36;
|
|
1965
|
+
var MOUTH_SHIFT_SHARE = 1.18;
|
|
1966
|
+
var TURN_RATIO_MAX = 1.5;
|
|
1967
|
+
var SILHOUETTE_RATIO_MIN = 0.5;
|
|
1968
|
+
function resolveTurnTargets(targets = {}) {
|
|
1969
|
+
const eyeShift = targets.eyeShift ?? DEFAULT_TURN_TARGETS.eyeShift;
|
|
1970
|
+
const check = (field, value, ok, expected) => {
|
|
1971
|
+
if (value === void 0) return;
|
|
1972
|
+
if (!Number.isFinite(value) || !ok(value)) {
|
|
1973
|
+
const derived = (field === "noseShift" || field === "mouthShift") && targets[field] === void 0;
|
|
1974
|
+
const from = derived ? `, derived from turnTargets.eyeShift (${eyeShift}),` : "";
|
|
1975
|
+
throw new TurnTargetError(
|
|
1976
|
+
`auto-rig: turnTargets.${field} (${value})${from} must be ${expected}`
|
|
1977
|
+
);
|
|
1978
|
+
}
|
|
1979
|
+
};
|
|
1980
|
+
const farEyeRatio = targets.farEyeRatio ?? DEFAULT_TURN_TARGETS.farEyeRatio;
|
|
1981
|
+
const silhouetteRatio = targets.silhouetteRatio ?? DEFAULT_TURN_TARGETS.silhouetteRatio;
|
|
1982
|
+
const noseShift = targets.noseShift ?? NOSE_SHIFT_SHARE * eyeShift;
|
|
1983
|
+
const mouthShift = targets.mouthShift ?? MOUTH_SHIFT_SHARE * eyeShift;
|
|
1984
|
+
const isShift = (v) => Math.abs(v) <= 1;
|
|
1985
|
+
const shiftRange = "a fraction of the head half-width, |value| <= 1";
|
|
1986
|
+
check("eyeShift", eyeShift, isShift, shiftRange);
|
|
1987
|
+
check(
|
|
1988
|
+
"farEyeRatio",
|
|
1989
|
+
farEyeRatio,
|
|
1990
|
+
(v) => v > 0 && v <= TURN_RATIO_MAX,
|
|
1991
|
+
`in (0, ${TURN_RATIO_MAX}]`
|
|
1992
|
+
);
|
|
1993
|
+
check(
|
|
1994
|
+
"silhouetteRatio",
|
|
1995
|
+
silhouetteRatio,
|
|
1996
|
+
(v) => v >= SILHOUETTE_RATIO_MIN && v <= TURN_RATIO_MAX,
|
|
1997
|
+
`in [${SILHOUETTE_RATIO_MIN}, ${TURN_RATIO_MAX}]`
|
|
1998
|
+
);
|
|
1999
|
+
check("noseShift", noseShift, isShift, shiftRange);
|
|
2000
|
+
check("mouthShift", mouthShift, isShift, shiftRange);
|
|
2001
|
+
check("headHalfWidth", targets.headHalfWidth, (v) => v > 0, "positive");
|
|
2002
|
+
const defaulted = new Set(
|
|
2003
|
+
[
|
|
2004
|
+
"eyeShift",
|
|
2005
|
+
"farEyeRatio",
|
|
2006
|
+
"silhouetteRatio",
|
|
2007
|
+
"noseShift",
|
|
2008
|
+
"mouthShift"
|
|
2009
|
+
].filter((field) => targets[field] === void 0)
|
|
2010
|
+
);
|
|
2011
|
+
return {
|
|
2012
|
+
eyeShift,
|
|
2013
|
+
farEyeRatio,
|
|
2014
|
+
silhouetteRatio,
|
|
2015
|
+
noseShift,
|
|
2016
|
+
mouthShift,
|
|
2017
|
+
headHalfWidth: targets.headHalfWidth,
|
|
2018
|
+
defaulted
|
|
2019
|
+
};
|
|
2020
|
+
}
|
|
2021
|
+
function turnLandmarks(layers) {
|
|
2022
|
+
const markOf = (role) => {
|
|
2023
|
+
const layer = layers.find((l) => l.role === role);
|
|
2024
|
+
if (!layer) return void 0;
|
|
2025
|
+
const t = bboxToTransform(layer.bbox, layer.canvasW, layer.canvasH, role);
|
|
2026
|
+
return { x: t.x, y: t.y, w: layer.cropW };
|
|
2027
|
+
};
|
|
2028
|
+
const pairOf = (left, right) => {
|
|
2029
|
+
const l = markOf(left);
|
|
2030
|
+
const r = markOf(right);
|
|
2031
|
+
return l && r ? [l, r] : void 0;
|
|
2032
|
+
};
|
|
2033
|
+
const one = (role) => {
|
|
2034
|
+
const m = markOf(role);
|
|
2035
|
+
return m ? [m] : [];
|
|
2036
|
+
};
|
|
2037
|
+
return {
|
|
2038
|
+
eye: pairOf("eye_L", "eye_R") ?? [],
|
|
2039
|
+
nose: one("nose"),
|
|
2040
|
+
mouth: one("mouth")
|
|
2041
|
+
};
|
|
2042
|
+
}
|
|
2043
|
+
var TURN_BISECT_STEPS = 40;
|
|
2044
|
+
var TURN_DEPTH_EPS = 1e-6;
|
|
2045
|
+
function solveTurnDepthSigned(target, landmarks, unit, map, holdEdgeX, plateEdgeX) {
|
|
2046
|
+
if (landmarks.length === 0) {
|
|
2047
|
+
throw new Error("auto-rig: solveTurnDepth: no landmark to slide");
|
|
2048
|
+
}
|
|
2049
|
+
const farEdge = Math.min(...landmarks.map((l) => l.x - l.w / 2));
|
|
2050
|
+
const floor = Math.max(map.invertX(holdEdgeX), map.restX[0], plateEdgeX);
|
|
2051
|
+
const cap = Math.max(0, (farEdge - floor) / unit);
|
|
2052
|
+
const achieved = (d) => landmarks.reduce((sum, l) => sum + (map.mapX(l.x - d * unit) - l.x), 0) / landmarks.length;
|
|
2053
|
+
const atCap = achieved(cap);
|
|
2054
|
+
const atRest = achieved(0);
|
|
2055
|
+
const attainable = [atCap, atRest];
|
|
2056
|
+
if (target > atRest + TURN_DEPTH_EPS)
|
|
2057
|
+
return { reached: false, depth: 0, achieved: atRest, attainable };
|
|
2058
|
+
if (target < atCap - TURN_DEPTH_EPS)
|
|
2059
|
+
return { reached: false, depth: cap, achieved: atCap, attainable };
|
|
2060
|
+
const clampedTarget = Math.min(Math.max(target, atCap), atRest);
|
|
2061
|
+
let lo = 0;
|
|
2062
|
+
let hi = cap;
|
|
2063
|
+
for (let i = 0; i < TURN_BISECT_STEPS; i++) {
|
|
2064
|
+
const mid = (lo + hi) / 2;
|
|
2065
|
+
if (achieved(mid) > clampedTarget) lo = mid;
|
|
2066
|
+
else hi = mid;
|
|
2067
|
+
}
|
|
2068
|
+
const depth = (lo + hi) / 2;
|
|
2069
|
+
return { reached: true, depth, achieved: achieved(depth), attainable };
|
|
2070
|
+
}
|
|
2071
|
+
var TURN_SWEEP_MAX_FACTOR = 8;
|
|
2072
|
+
var TURN_SWEEP_SAMPLES = 32;
|
|
2073
|
+
var HOLD_CLEARANCE = 1;
|
|
2074
|
+
var TURN_HOLD_SAMPLES = 16;
|
|
2075
|
+
var TURN_HOLD_SATURATION_PASSES = 4;
|
|
2076
|
+
function hairFrontHoldTarget(x, faceCenterX, faceHalfWidth, holdBase, holdEdge, map) {
|
|
2077
|
+
const local = x - faceCenterX;
|
|
2078
|
+
const dist = Math.abs(local);
|
|
2079
|
+
const side = Math.sign(local);
|
|
2080
|
+
const sideIdx = side < 0 ? 0 : 1;
|
|
2081
|
+
if (dist <= faceHalfWidth) return map.mapX(x);
|
|
2082
|
+
if (dist <= holdBase) {
|
|
2083
|
+
const inner = map.mapX(faceCenterX + side * faceHalfWidth);
|
|
2084
|
+
const u = (dist - faceHalfWidth) / (holdBase - faceHalfWidth);
|
|
2085
|
+
return inner + (faceCenterX + side * holdEdge[sideIdx] - inner) * u;
|
|
2086
|
+
}
|
|
2087
|
+
return x + side * (holdEdge[sideIdx] - holdBase);
|
|
2088
|
+
}
|
|
2089
|
+
function hairFrontLandingAt(x, hairFront, faceCenterX, faceHalfWidth, holdBase, cappedHoldEdge, map, leadTipShift, leadRow) {
|
|
2090
|
+
const { cols, rows } = meshCellsFor(hairFront.cropW, hairFront.cropH);
|
|
2091
|
+
const preBindAt = (col2) => {
|
|
2092
|
+
const colX = hairFront.x - hairFront.cropW / 2 + col2 / cols * hairFront.cropW;
|
|
2093
|
+
const target = hairFrontHoldTarget(
|
|
2094
|
+
colX,
|
|
2095
|
+
faceCenterX,
|
|
2096
|
+
faceHalfWidth,
|
|
2097
|
+
holdBase,
|
|
2098
|
+
cappedHoldEdge,
|
|
2099
|
+
map
|
|
2100
|
+
);
|
|
2101
|
+
return map.invertX(target);
|
|
2102
|
+
};
|
|
2103
|
+
const leadAt = (row2) => -leadTipShift * Math.pow(row2 / rows, HAIR_SWAY_CURL);
|
|
2104
|
+
const bracket = (v, cells) => {
|
|
2105
|
+
const lo = Math.max(0, Math.min(cells, Math.floor(v)));
|
|
2106
|
+
return {
|
|
2107
|
+
lo,
|
|
2108
|
+
hi: Math.max(0, Math.min(cells, lo + 1)),
|
|
2109
|
+
t: Math.max(0, Math.min(1, v - lo))
|
|
2110
|
+
};
|
|
2111
|
+
};
|
|
2112
|
+
const col = bracket(
|
|
2113
|
+
(x - hairFront.x + hairFront.cropW / 2) / hairFront.cropW * cols,
|
|
2114
|
+
cols
|
|
2115
|
+
);
|
|
2116
|
+
const row = bracket(leadRow * rows, rows);
|
|
2117
|
+
const landingAt = (c, r) => map.mapX(preBindAt(c) + leadAt(r));
|
|
2118
|
+
const tl = landingAt(col.lo, row.lo);
|
|
2119
|
+
const br = landingAt(col.hi, row.hi);
|
|
2120
|
+
if (col.t <= row.t) {
|
|
2121
|
+
const bl = landingAt(col.lo, row.hi);
|
|
2122
|
+
return tl + row.t * (bl - tl) + col.t * (br - bl);
|
|
2123
|
+
}
|
|
2124
|
+
const tr = landingAt(col.hi, row.lo);
|
|
2125
|
+
return tl + col.t * (tr - tl) + row.t * (br - tr);
|
|
2126
|
+
}
|
|
2127
|
+
function shellTravelCap(faceCenterX, faceHalfWidth, askedTravel, bendOnlyMapAt, holdEdgeAt) {
|
|
2128
|
+
let cap = Infinity;
|
|
2129
|
+
for (const deg of HEAD_TURN_STOPS) {
|
|
2130
|
+
if (deg === 0) continue;
|
|
2131
|
+
const map = bendOnlyMapAt(deg);
|
|
2132
|
+
const slideSign = turnSlide(askedTravel, deg) < 0 ? -1 : 1;
|
|
2133
|
+
const towardReach = Math.max(
|
|
2134
|
+
...[-1, 1].map(
|
|
2135
|
+
(side) => slideSign * (map.mapX(faceCenterX + side * faceHalfWidth) - faceCenterX)
|
|
2136
|
+
)
|
|
2137
|
+
);
|
|
2138
|
+
cap = Math.min(
|
|
2139
|
+
cap,
|
|
2140
|
+
Math.max(0, holdEdgeAt(deg, slideSign) - HOLD_CLEARANCE - towardReach) * HEAD_TURN_MAX_DEG / Math.abs(deg)
|
|
2141
|
+
);
|
|
2142
|
+
}
|
|
2143
|
+
return cap;
|
|
2144
|
+
}
|
|
2145
|
+
function evaluateTurnCandidate(ctx, radius) {
|
|
2146
|
+
const bendOnlyMapAt = (deg) => turnColumnMap(ctx.faceGrid, ctx.faceCenterX, radius, deg, 0);
|
|
2147
|
+
const measuredEdgeAt = (side) => {
|
|
2148
|
+
const candidates = side < 0 ? ctx.headEdges?.left : ctx.headEdges?.right;
|
|
2149
|
+
if (candidates === void 0 || candidates.length === 0) return void 0;
|
|
2150
|
+
const xs = candidates.map((c) => c.x);
|
|
2151
|
+
return side < 0 ? Math.min(...xs) : Math.max(...xs);
|
|
2152
|
+
};
|
|
2153
|
+
const shellHalfWidthAt = (side) => {
|
|
2154
|
+
const edge = measuredEdgeAt(side);
|
|
2155
|
+
return edge === void 0 ? ctx.headHalfWidth : side * (edge - ctx.faceCenterX);
|
|
2156
|
+
};
|
|
2157
|
+
const unit = headTurnParallaxUnit(radius);
|
|
2158
|
+
const leadTipShift = ctx.hairFrontLeadFraction === void 0 ? 0 : HAIR_FRONT_DEPTH * unit;
|
|
2159
|
+
const leadRow = ctx.hairFrontLeadFraction ?? 0;
|
|
2160
|
+
const evaluateHold = (ratio) => {
|
|
2161
|
+
const holdEdgeFrom = (base) => (deg) => base * (1 + (ratio - 1) * Math.abs(deg) / HEAD_TURN_MAX_DEG);
|
|
2162
|
+
const travel2 = ctx.headHalfWidth === void 0 ? ctx.travel : Math.min(
|
|
2163
|
+
ctx.travel,
|
|
2164
|
+
shellTravelCap(
|
|
2165
|
+
ctx.faceCenterX,
|
|
2166
|
+
ctx.faceHalfWidth,
|
|
2167
|
+
ctx.travel,
|
|
2168
|
+
bendOnlyMapAt,
|
|
2169
|
+
(deg, side) => {
|
|
2170
|
+
const rest = shellHalfWidthAt(side);
|
|
2171
|
+
return Math.min(rest, holdEdgeFrom(rest)(deg));
|
|
2172
|
+
}
|
|
2173
|
+
)
|
|
2174
|
+
);
|
|
2175
|
+
const columnMapAt = (deg) => turnColumnMap(ctx.faceGrid, ctx.faceCenterX, radius, deg, travel2);
|
|
2176
|
+
const holdBase2 = ctx.headHalfWidth ?? plateReach(ctx.faceCenterX, ctx.faceHalfWidth, columnMapAt) + HOLD_CLEARANCE;
|
|
2177
|
+
const holdEdgeAt2 = holdEdgeFrom(holdBase2);
|
|
2178
|
+
let foldLowerBound = -Infinity;
|
|
2179
|
+
let folds = false;
|
|
2180
|
+
for (const deg of HEAD_TURN_STOPS) {
|
|
2181
|
+
if (deg === 0) continue;
|
|
2182
|
+
const reach = plateReachAt(
|
|
2183
|
+
columnMapAt(deg),
|
|
2184
|
+
ctx.faceCenterX,
|
|
2185
|
+
ctx.faceHalfWidth
|
|
2186
|
+
);
|
|
2187
|
+
foldLowerBound = Math.max(
|
|
2188
|
+
foldLowerBound,
|
|
2189
|
+
1 + ((reach + HOLD_CLEARANCE) / holdBase2 - 1) * (HEAD_TURN_MAX_DEG / Math.abs(deg))
|
|
2190
|
+
);
|
|
2191
|
+
if (holdEdgeAt2(deg) <= reach) folds = true;
|
|
2192
|
+
}
|
|
2193
|
+
const map2 = columnMapAt(-HEAD_TURN_MAX_DEG);
|
|
2194
|
+
const requestedHoldEdge = holdEdgeAt2(HEAD_TURN_MAX_DEG);
|
|
2195
|
+
const gridReach2 = [
|
|
2196
|
+
Math.abs(map2.warpedX[0] - ctx.faceCenterX),
|
|
2197
|
+
Math.abs(map2.warpedX[map2.warpedX.length - 1] - ctx.faceCenterX)
|
|
2198
|
+
];
|
|
2199
|
+
const cappedHoldEdge = [
|
|
2200
|
+
Math.min(requestedHoldEdge, gridReach2[0] - HOLD_CLEARANCE),
|
|
2201
|
+
Math.min(requestedHoldEdge, gridReach2[1] - HOLD_CLEARANCE)
|
|
2202
|
+
];
|
|
2203
|
+
const restAt = (side) => {
|
|
2204
|
+
const edge = measuredEdgeAt(side);
|
|
2205
|
+
if (edge !== void 0) return edge;
|
|
2206
|
+
return ctx.headHalfWidth !== void 0 || ctx.hairFrontSilhouette === void 0 ? ctx.faceCenterX + side * holdBase2 : ctx.hairFrontSilhouette.x + side * ctx.hairFrontSilhouette.cropW / 2;
|
|
2207
|
+
};
|
|
2208
|
+
const hairFrontAt = (x) => ctx.hairFrontSilhouette === void 0 ? x : hairFrontLandingAt(
|
|
2209
|
+
x,
|
|
2210
|
+
ctx.hairFrontSilhouette,
|
|
2211
|
+
ctx.faceCenterX,
|
|
2212
|
+
ctx.faceHalfWidth,
|
|
2213
|
+
holdBase2,
|
|
2214
|
+
cappedHoldEdge,
|
|
2215
|
+
map2,
|
|
2216
|
+
leadTipShift,
|
|
2217
|
+
leadRow
|
|
2218
|
+
);
|
|
2219
|
+
const landingOfRole = (role, x) => {
|
|
2220
|
+
if (role === "hair_front") return hairFrontAt(x);
|
|
2221
|
+
if (role === "hair_back") {
|
|
2222
|
+
return x;
|
|
2223
|
+
}
|
|
2224
|
+
if (role === "body") {
|
|
2225
|
+
return x - BODY_TURN_FOLLOW * travel2;
|
|
2226
|
+
}
|
|
2227
|
+
if (role === "face" || FEATURE_NOD_DEPTH[roleFamily(role)] !== void 0) {
|
|
2228
|
+
return map2.mapX(x);
|
|
2229
|
+
}
|
|
2230
|
+
throw new Error(
|
|
2231
|
+
`auto-rig: evaluateTurnCandidate: unrecognised role "${role}"`
|
|
2232
|
+
);
|
|
2233
|
+
};
|
|
2234
|
+
const landingAt = (side) => {
|
|
2235
|
+
const candidates = side < 0 ? ctx.headEdges?.left : ctx.headEdges?.right;
|
|
2236
|
+
if (candidates === void 0 || candidates.length === 0) {
|
|
2237
|
+
return hairFrontAt(restAt(side));
|
|
2238
|
+
}
|
|
2239
|
+
const landings = candidates.map((c) => landingOfRole(c.role, c.x));
|
|
2240
|
+
return side < 0 ? Math.min(...landings) : Math.max(...landings);
|
|
2241
|
+
};
|
|
2242
|
+
return {
|
|
2243
|
+
ratio,
|
|
2244
|
+
travel: travel2,
|
|
2245
|
+
map: map2,
|
|
2246
|
+
holdBase: holdBase2,
|
|
2247
|
+
holdEdgeAt: holdEdgeAt2,
|
|
2248
|
+
folds,
|
|
2249
|
+
foldLowerBound,
|
|
2250
|
+
gridReach: gridReach2,
|
|
2251
|
+
// How far the silhouette's own centre drifts off the face centre at full
|
|
2252
|
+
// turn, against how far it already sat off centre at rest (zero unless
|
|
2253
|
+
// hair_front's crop is itself off-centre) — the held shell keeps its own
|
|
2254
|
+
// place while the face slides inside it, so this is the drift the hold
|
|
2255
|
+
// and the bangs' lead leave behind, and the slide itself shows up in the
|
|
2256
|
+
// eye cue instead of cancelling out of it.
|
|
2257
|
+
// `measure_turn_reference` reads the eye pair against THIS moved centre,
|
|
2258
|
+
// not the face centre, so it is what the eye cue is corrected by.
|
|
2259
|
+
silhouetteCenterShift: (landingAt(1) + landingAt(-1)) / 2 - (restAt(1) + restAt(-1)) / 2,
|
|
2260
|
+
// What a render actually shows: the real landing span over the real rest
|
|
2261
|
+
// span, the same pair silhouetteCenterShift reads — the measure the
|
|
2262
|
+
// hold is FITTED against, see this function's own doc.
|
|
2263
|
+
renderedSilhouetteRatio: (landingAt(1) - landingAt(-1)) / (restAt(1) - restAt(-1)),
|
|
2264
|
+
restSpan: restAt(1) - restAt(-1)
|
|
2265
|
+
};
|
|
2266
|
+
};
|
|
2267
|
+
const saturatedHold = () => {
|
|
2268
|
+
let hold2 = evaluateHold(1);
|
|
2269
|
+
for (let i = 0; i < TURN_HOLD_SATURATION_PASSES; i++) {
|
|
2270
|
+
const needed = (Math.max(hold2.gridReach[0], hold2.gridReach[1]) - HOLD_CLEARANCE) / hold2.holdBase;
|
|
2271
|
+
if (needed <= hold2.ratio) break;
|
|
2272
|
+
hold2 = evaluateHold(needed);
|
|
2273
|
+
}
|
|
2274
|
+
return hold2;
|
|
2275
|
+
};
|
|
2276
|
+
const holdRange = () => {
|
|
2277
|
+
const ceiling = saturatedHold();
|
|
2278
|
+
if (ceiling.folds) return void 0;
|
|
2279
|
+
let floor = evaluateHold(Math.min(ceiling.foldLowerBound, ceiling.ratio));
|
|
2280
|
+
if (floor.folds) {
|
|
2281
|
+
let lo = floor;
|
|
2282
|
+
let hi = ceiling;
|
|
2283
|
+
for (let i = 0; i < TURN_BISECT_STEPS; i++) {
|
|
2284
|
+
const mid = evaluateHold((lo.ratio + hi.ratio) / 2);
|
|
2285
|
+
if (mid.folds) lo = mid;
|
|
2286
|
+
else hi = mid;
|
|
2287
|
+
}
|
|
2288
|
+
floor = hi;
|
|
2289
|
+
}
|
|
2290
|
+
const samples = [floor];
|
|
2291
|
+
for (let i = 1; i < TURN_HOLD_SAMPLES; i++) {
|
|
2292
|
+
const sample = evaluateHold(
|
|
2293
|
+
floor.ratio + (ceiling.ratio - floor.ratio) * i / TURN_HOLD_SAMPLES
|
|
2294
|
+
);
|
|
2295
|
+
if (!sample.folds) samples.push(sample);
|
|
2296
|
+
}
|
|
2297
|
+
samples.push(ceiling);
|
|
2298
|
+
return samples;
|
|
2299
|
+
};
|
|
2300
|
+
const silhouetteMiss = (span) => {
|
|
2301
|
+
const lower = Math.max(span?.[0] ?? Infinity, SILHOUETTE_RATIO_MIN);
|
|
2302
|
+
const upper = Math.min(span?.[1] ?? -Infinity, TURN_RATIO_MAX);
|
|
2303
|
+
return lower > upper ? { blocked: "silhouetteRatio", lower: Infinity, upper: -Infinity } : { blocked: "silhouetteRatio", lower, upper };
|
|
2304
|
+
};
|
|
2305
|
+
const asked = ctx.silhouetteRatio;
|
|
2306
|
+
const natural = evaluateHold(asked);
|
|
2307
|
+
const eps = TURN_DEPTH_EPS / natural.restSpan;
|
|
2308
|
+
let hold;
|
|
2309
|
+
let silhouetteClamped = false;
|
|
2310
|
+
if (!natural.folds && Math.abs(natural.renderedSilhouetteRatio - asked) <= eps) {
|
|
2311
|
+
hold = natural;
|
|
2312
|
+
} else {
|
|
2313
|
+
const samples = holdRange();
|
|
2314
|
+
if (samples === void 0) return silhouetteMiss();
|
|
2315
|
+
const rendered = samples.map((h) => h.renderedSilhouetteRatio);
|
|
2316
|
+
const span = [
|
|
2317
|
+
Math.min(...rendered),
|
|
2318
|
+
Math.max(...rendered)
|
|
2319
|
+
];
|
|
2320
|
+
const nearer = (a, b) => {
|
|
2321
|
+
const ea = Math.abs(a.renderedSilhouetteRatio - asked);
|
|
2322
|
+
const eb = Math.abs(b.renderedSilhouetteRatio - asked);
|
|
2323
|
+
if (ea <= eps && eb <= eps) {
|
|
2324
|
+
return Math.abs(a.ratio - asked) <= Math.abs(b.ratio - asked) ? a : b;
|
|
2325
|
+
}
|
|
2326
|
+
return ea <= eb ? a : b;
|
|
2327
|
+
};
|
|
2328
|
+
if (asked < span[0] - eps || asked > span[1] + eps) {
|
|
2329
|
+
if (!ctx.clampSilhouetteRatio) return silhouetteMiss(span);
|
|
2330
|
+
const wanted = asked < span[0] ? span[0] : span[1];
|
|
2331
|
+
hold = samples.reduce(
|
|
2332
|
+
(a, b) => Math.abs(b.renderedSilhouetteRatio - wanted) < Math.abs(a.renderedSilhouetteRatio - wanted) ? b : a
|
|
2333
|
+
);
|
|
2334
|
+
silhouetteClamped = true;
|
|
2335
|
+
} else {
|
|
2336
|
+
let best = samples.reduce(nearer);
|
|
2337
|
+
let lo;
|
|
2338
|
+
let hi;
|
|
2339
|
+
for (let i = 0; i + 1 < samples.length; i++) {
|
|
2340
|
+
const a = samples[i].renderedSilhouetteRatio - asked;
|
|
2341
|
+
const b = samples[i + 1].renderedSilhouetteRatio - asked;
|
|
2342
|
+
if (a * b <= 0) {
|
|
2343
|
+
lo = samples[i];
|
|
2344
|
+
hi = samples[i + 1];
|
|
2345
|
+
break;
|
|
2346
|
+
}
|
|
2347
|
+
}
|
|
2348
|
+
for (let i = 0; lo !== void 0 && hi !== void 0; i++) {
|
|
2349
|
+
if (i >= TURN_BISECT_STEPS || Math.abs(best.renderedSilhouetteRatio - asked) <= eps) {
|
|
2350
|
+
break;
|
|
2351
|
+
}
|
|
2352
|
+
const mid = evaluateHold((lo.ratio + hi.ratio) / 2);
|
|
2353
|
+
if (!mid.folds) best = nearer(best, mid);
|
|
2354
|
+
if ((mid.renderedSilhouetteRatio - asked) * (lo.renderedSilhouetteRatio - asked) > 0) {
|
|
2355
|
+
lo = mid;
|
|
2356
|
+
} else {
|
|
2357
|
+
hi = mid;
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
hold = best;
|
|
2361
|
+
}
|
|
2362
|
+
}
|
|
2363
|
+
if (hold.folds) {
|
|
2364
|
+
throw new Error(
|
|
2365
|
+
"auto-rig: evaluateTurnCandidate: the hold it settled on folds"
|
|
2366
|
+
);
|
|
2367
|
+
}
|
|
2368
|
+
const {
|
|
2369
|
+
travel,
|
|
2370
|
+
map,
|
|
2371
|
+
holdBase,
|
|
2372
|
+
holdEdgeAt,
|
|
2373
|
+
silhouetteCenterShift,
|
|
2374
|
+
renderedSilhouetteRatio
|
|
2375
|
+
} = hold;
|
|
2376
|
+
const holdEdgeX = ctx.faceCenterX - holdEdgeAt(HEAD_TURN_MAX_DEG);
|
|
2377
|
+
const eye = solveTurnDepthSigned(
|
|
2378
|
+
silhouetteCenterShift - ctx.eyeShift * ctx.hh,
|
|
2379
|
+
ctx.landmarks.eye,
|
|
2380
|
+
unit,
|
|
2381
|
+
map,
|
|
2382
|
+
holdEdgeX,
|
|
2383
|
+
ctx.plateEdgeX
|
|
2384
|
+
);
|
|
2385
|
+
if (!eye.reached && !ctx.clampEyeShift) {
|
|
2386
|
+
return {
|
|
2387
|
+
blocked: "eyeShift",
|
|
2388
|
+
// eyeShift is a MAGNITUDE (the sign is ignored — see TurnTargets), so a
|
|
2389
|
+
// negative lower bound here would advertise a value the field's own
|
|
2390
|
+
// contract already rules out; 0 is the true floor.
|
|
2391
|
+
offeredShift: [
|
|
2392
|
+
Math.max(0, (-eye.attainable[1] + silhouetteCenterShift) / ctx.hh),
|
|
2393
|
+
(-eye.attainable[0] + silhouetteCenterShift) / ctx.hh
|
|
2394
|
+
]
|
|
2395
|
+
};
|
|
2396
|
+
}
|
|
2397
|
+
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;
|
|
2398
|
+
const far = ctx.landmarks.eye.reduce((a, b) => b.x < a.x ? b : a);
|
|
2399
|
+
const near = ctx.landmarks.eye.reduce((a, b) => b.x > a.x ? b : a);
|
|
2400
|
+
return {
|
|
2401
|
+
radius,
|
|
2402
|
+
ratio: scaleOf(far) / scaleOf(near),
|
|
2403
|
+
unit,
|
|
2404
|
+
travel,
|
|
2405
|
+
map,
|
|
2406
|
+
holdBase,
|
|
2407
|
+
holdEdgeAt,
|
|
2408
|
+
holdEdgeX,
|
|
2409
|
+
eye,
|
|
2410
|
+
silhouetteClamped,
|
|
2411
|
+
silhouetteCenterShift,
|
|
2412
|
+
renderedSilhouetteRatio
|
|
2413
|
+
};
|
|
2414
|
+
}
|
|
2415
|
+
function sweepTurnRadii(ctx) {
|
|
2416
|
+
const minRadius = ctx.faceHalfWidth * HEAD_CYLINDER_RADIUS_FACTOR;
|
|
2417
|
+
const maxRadius = ctx.faceHalfWidth * TURN_SWEEP_MAX_FACTOR;
|
|
2418
|
+
const candidates = [];
|
|
2419
|
+
let heldRatioLower = Infinity;
|
|
2420
|
+
let heldRatioUpper = -Infinity;
|
|
2421
|
+
let offeredShift;
|
|
2422
|
+
for (let i = 0; i < TURN_SWEEP_SAMPLES; i++) {
|
|
2423
|
+
const radius = minRadius * Math.pow(maxRadius / minRadius, i / (TURN_SWEEP_SAMPLES - 1));
|
|
2424
|
+
const result = evaluateTurnCandidate(ctx, radius);
|
|
2425
|
+
if (!("blocked" in result)) {
|
|
2426
|
+
candidates.push({ ...result, sweepIndex: i });
|
|
2427
|
+
} else if (result.blocked === "silhouetteRatio") {
|
|
2428
|
+
heldRatioLower = Math.min(heldRatioLower, result.lower);
|
|
2429
|
+
heldRatioUpper = Math.max(heldRatioUpper, result.upper);
|
|
2430
|
+
} else {
|
|
2431
|
+
offeredShift = offeredShift ? [
|
|
2432
|
+
Math.min(offeredShift[0], result.offeredShift[0]),
|
|
2433
|
+
Math.max(offeredShift[1], result.offeredShift[1])
|
|
2434
|
+
] : result.offeredShift;
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2437
|
+
return { candidates, heldRatioLower, heldRatioUpper, offeredShift };
|
|
2438
|
+
}
|
|
2439
|
+
function ratioClusters(candidates) {
|
|
2440
|
+
const clusters = [];
|
|
2441
|
+
let run = [];
|
|
2442
|
+
const close = () => {
|
|
2443
|
+
if (run.length === 0) return;
|
|
2444
|
+
const ratios = run.map((c) => c.ratio);
|
|
2445
|
+
clusters.push({
|
|
2446
|
+
candidates: run,
|
|
2447
|
+
ratios: [Math.min(...ratios), Math.max(...ratios)]
|
|
2448
|
+
});
|
|
2449
|
+
run = [];
|
|
2450
|
+
};
|
|
2451
|
+
for (const candidate of candidates) {
|
|
2452
|
+
const previous = run[run.length - 1];
|
|
2453
|
+
if (previous !== void 0 && candidate.sweepIndex !== previous.sweepIndex + 1) {
|
|
2454
|
+
close();
|
|
2455
|
+
}
|
|
2456
|
+
run.push(candidate);
|
|
2457
|
+
}
|
|
2458
|
+
close();
|
|
2459
|
+
return clusters;
|
|
2460
|
+
}
|
|
2461
|
+
function fitTurnRadius(ctx, candidates, target) {
|
|
2462
|
+
let lo = candidates[0];
|
|
2463
|
+
let hi = candidates[candidates.length - 1];
|
|
2464
|
+
for (let i = candidates.length - 2; i >= 0; i--) {
|
|
2465
|
+
if ((candidates[i].ratio - target) * (candidates[i + 1].ratio - target) <= 0) {
|
|
2466
|
+
lo = candidates[i];
|
|
2467
|
+
hi = candidates[i + 1];
|
|
2468
|
+
break;
|
|
2469
|
+
}
|
|
2470
|
+
}
|
|
2471
|
+
for (let i = 0; i < TURN_BISECT_STEPS; i++) {
|
|
2472
|
+
const mid = evaluateTurnCandidate(ctx, Math.sqrt(lo.radius * hi.radius));
|
|
2473
|
+
if ("blocked" in mid) break;
|
|
2474
|
+
if ((mid.ratio - target) * (lo.ratio - target) <= 0) hi = mid;
|
|
2475
|
+
else lo = mid;
|
|
2476
|
+
}
|
|
2477
|
+
return Math.abs(lo.ratio - target) <= Math.abs(hi.ratio - target) ? lo : hi;
|
|
2478
|
+
}
|
|
2479
|
+
function solveFeatureDepths(ctx, targets, best, eyeShift) {
|
|
2480
|
+
const clamped = [];
|
|
2481
|
+
const solved = {};
|
|
2482
|
+
const families = [
|
|
2483
|
+
["noseShift", ctx.landmarks.nose, NOSE_SHIFT_SHARE],
|
|
2484
|
+
["mouthShift", ctx.landmarks.mouth, MOUTH_SHIFT_SHARE]
|
|
2485
|
+
];
|
|
2486
|
+
for (const [field, marks, share] of families) {
|
|
2487
|
+
const m = Math.abs(
|
|
2488
|
+
targets.defaulted.has(field) ? share * eyeShift : targets[field]
|
|
2489
|
+
);
|
|
2490
|
+
const solution = solveTurnDepthSigned(
|
|
2491
|
+
best.silhouetteCenterShift - m * ctx.hh,
|
|
2492
|
+
marks,
|
|
2493
|
+
best.unit,
|
|
2494
|
+
best.map,
|
|
2495
|
+
best.holdEdgeX,
|
|
2496
|
+
ctx.plateEdgeX
|
|
2497
|
+
);
|
|
2498
|
+
if (!solution.reached) {
|
|
2499
|
+
if (!targets.defaulted.has(field)) {
|
|
2500
|
+
return {
|
|
2501
|
+
blocked: field,
|
|
2502
|
+
// noseShift/mouthShift are MAGNITUDES too (see the correction
|
|
2503
|
+
// above), so a negative lower bound here would advertise a value
|
|
2504
|
+
// the field's own contract already rules out; 0 is the true floor.
|
|
2505
|
+
attainable: [
|
|
2506
|
+
Math.max(
|
|
2507
|
+
0,
|
|
2508
|
+
(-solution.attainable[1] + best.silhouetteCenterShift) / ctx.hh
|
|
2509
|
+
),
|
|
2510
|
+
(-solution.attainable[0] + best.silhouetteCenterShift) / ctx.hh
|
|
2511
|
+
]
|
|
2512
|
+
};
|
|
2513
|
+
}
|
|
2514
|
+
clamped.push(field);
|
|
2515
|
+
}
|
|
2516
|
+
solved[field] = solution.depth;
|
|
2517
|
+
}
|
|
2518
|
+
return {
|
|
2519
|
+
nose: solved.noseShift,
|
|
2520
|
+
mouth: solved.mouthShift,
|
|
2521
|
+
clamped
|
|
2522
|
+
};
|
|
2523
|
+
}
|
|
2524
|
+
function solveTurnModel(targets, landmarks, faceGrid, faceCenterX, faceHalfWidth, hairFront, headEdges) {
|
|
2525
|
+
if (targets.headHalfWidth !== void 0 && targets.headHalfWidth <= faceHalfWidth) {
|
|
2526
|
+
throw new TurnTargetError(
|
|
2527
|
+
`auto-rig: turnTargets.headHalfWidth (${targets.headHalfWidth}) must be wider than the face plate's own half-width (${faceHalfWidth})`
|
|
2528
|
+
);
|
|
2529
|
+
}
|
|
2530
|
+
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;
|
|
2531
|
+
const hairFrontLeadFraction = hairFront === void 0 || eyeRowY === void 0 ? void 0 : Math.max(
|
|
2532
|
+
0,
|
|
2533
|
+
Math.min(
|
|
2534
|
+
1,
|
|
2535
|
+
(hairFront.centerY + hairFront.cropH / 2 - eyeRowY) / hairFront.cropH
|
|
2536
|
+
)
|
|
2537
|
+
);
|
|
2538
|
+
const ctx = {
|
|
2539
|
+
landmarks,
|
|
2540
|
+
faceGrid,
|
|
2541
|
+
faceCenterX,
|
|
2542
|
+
faceHalfWidth,
|
|
2543
|
+
plateEdgeX: faceCenterX - faceHalfWidth,
|
|
2544
|
+
// What this plate asks the turn for; each candidate caps it to its own
|
|
2545
|
+
// shell — see TurnSolveContext.travel.
|
|
2546
|
+
travel: headTurnTravel(faceHalfWidth),
|
|
2547
|
+
headHalfWidth: targets.headHalfWidth,
|
|
2548
|
+
// The pixels the shift fractions are fractions of. Without a measured head
|
|
2549
|
+
// the face plate stands in for it — see TurnTargets.headHalfWidth.
|
|
2550
|
+
hh: targets.headHalfWidth ?? faceHalfWidth,
|
|
2551
|
+
silhouetteRatio: targets.silhouetteRatio,
|
|
2552
|
+
// The sign is ignored (see TurnTargets.eyeShift) — taken here, once, so
|
|
2553
|
+
// every downstream use already has the magnitude.
|
|
2554
|
+
eyeShift: Math.abs(targets.eyeShift),
|
|
2555
|
+
clampEyeShift: targets.defaulted.has("eyeShift"),
|
|
2556
|
+
clampSilhouetteRatio: targets.defaulted.has("silhouetteRatio"),
|
|
2557
|
+
hairFrontLeadFraction,
|
|
2558
|
+
hairFrontSilhouette: hairFront === void 0 ? void 0 : { x: hairFront.x, cropW: hairFront.cropW, cropH: hairFront.cropH },
|
|
2559
|
+
// A companion to a MEASURED headHalfWidth only — see its own doc.
|
|
2560
|
+
headEdges: targets.headHalfWidth === void 0 ? void 0 : headEdges
|
|
2561
|
+
};
|
|
2562
|
+
const clamped = [];
|
|
2563
|
+
const clampInto = (value, [lo, hi]) => Math.min(Math.max(value, lo), hi);
|
|
2564
|
+
const pass = sweepTurnRadii(ctx);
|
|
2565
|
+
if (pass.candidates.length === 0) {
|
|
2566
|
+
return pass.offeredShift ? {
|
|
2567
|
+
unreachable: true,
|
|
2568
|
+
field: "eyeShift",
|
|
2569
|
+
value: targets.eyeShift,
|
|
2570
|
+
attainable: pass.offeredShift
|
|
2571
|
+
} : {
|
|
2572
|
+
unreachable: true,
|
|
2573
|
+
field: "silhouetteRatio",
|
|
2574
|
+
value: targets.silhouetteRatio,
|
|
2575
|
+
// Two different bounds, not one number twice, and computed the
|
|
2576
|
+
// same way regardless of which ratio was actually rejected: both
|
|
2577
|
+
// are ratios a RENDER could have shown (the measure the caller's
|
|
2578
|
+
// own number came from), the narrowest and the widest any radius
|
|
2579
|
+
// could be fitted to, with every ratio between them one it can be
|
|
2580
|
+
// fitted to exactly; see evaluateTurnCandidate, which clips each
|
|
2581
|
+
// radius' own range to the domain `resolveTurnTargets` accepts
|
|
2582
|
+
// before it gets here. Absent when that left every radius with
|
|
2583
|
+
// nothing: no ratio a caller is allowed to ask for renders at all.
|
|
2584
|
+
attainable: pass.heldRatioLower === Infinity || pass.heldRatioUpper === -Infinity ? void 0 : [pass.heldRatioLower, pass.heldRatioUpper]
|
|
2585
|
+
};
|
|
2586
|
+
}
|
|
2587
|
+
const clusters = ratioClusters(pass.candidates);
|
|
2588
|
+
const distanceTo = (c) => Math.max(
|
|
2589
|
+
c.ratios[0] - targets.farEyeRatio,
|
|
2590
|
+
targets.farEyeRatio - c.ratios[1],
|
|
2591
|
+
0
|
|
2592
|
+
);
|
|
2593
|
+
let farEyeRatio = targets.farEyeRatio;
|
|
2594
|
+
let cluster = clusters.find(
|
|
2595
|
+
(c) => farEyeRatio >= c.ratios[0] - TURN_DEPTH_EPS && farEyeRatio <= c.ratios[1] + TURN_DEPTH_EPS
|
|
2596
|
+
);
|
|
2597
|
+
if (cluster === void 0) {
|
|
2598
|
+
cluster = clusters.reduce(
|
|
2599
|
+
(a, b) => distanceTo(b) < distanceTo(a) ? b : a
|
|
2600
|
+
);
|
|
2601
|
+
if (!targets.defaulted.has("farEyeRatio")) {
|
|
2602
|
+
return {
|
|
2603
|
+
unreachable: true,
|
|
2604
|
+
field: "farEyeRatio",
|
|
2605
|
+
value: targets.farEyeRatio,
|
|
2606
|
+
attainable: cluster.ratios
|
|
2607
|
+
};
|
|
2608
|
+
}
|
|
2609
|
+
farEyeRatio = clampInto(farEyeRatio, cluster.ratios);
|
|
2610
|
+
clamped.push("farEyeRatio");
|
|
2611
|
+
}
|
|
2612
|
+
const best = fitTurnRadius(ctx, cluster.candidates, farEyeRatio);
|
|
2613
|
+
if (Math.abs(best.ratio - farEyeRatio) > TURN_DEPTH_EPS) {
|
|
2614
|
+
if (!targets.defaulted.has("farEyeRatio")) {
|
|
2615
|
+
return {
|
|
2616
|
+
unreachable: true,
|
|
2617
|
+
field: "farEyeRatio",
|
|
2618
|
+
value: targets.farEyeRatio,
|
|
2619
|
+
attainable: cluster.ratios
|
|
2620
|
+
};
|
|
2621
|
+
}
|
|
2622
|
+
if (!clamped.includes("farEyeRatio")) clamped.push("farEyeRatio");
|
|
2623
|
+
}
|
|
2624
|
+
if (!best.eye.reached) clamped.push("eyeShift");
|
|
2625
|
+
if (best.silhouetteClamped) clamped.push("silhouetteRatio");
|
|
2626
|
+
const eyeShift = (-best.eye.achieved + best.silhouetteCenterShift) / ctx.hh;
|
|
2627
|
+
const features = solveFeatureDepths(ctx, targets, best, eyeShift);
|
|
2628
|
+
if ("blocked" in features) {
|
|
2629
|
+
return {
|
|
2630
|
+
unreachable: true,
|
|
2631
|
+
field: features.blocked,
|
|
2632
|
+
value: targets[features.blocked],
|
|
2633
|
+
attainable: features.attainable
|
|
2634
|
+
};
|
|
2635
|
+
}
|
|
2636
|
+
return {
|
|
2637
|
+
unreachable: false,
|
|
2638
|
+
radius: best.radius,
|
|
2639
|
+
holdBase: best.holdBase,
|
|
2640
|
+
holdEdgeAt: best.holdEdgeAt,
|
|
2641
|
+
travel: best.travel,
|
|
2642
|
+
depths: { eye: best.eye.depth, nose: features.nose, mouth: features.mouth },
|
|
2643
|
+
achieved: {
|
|
2644
|
+
eyeShift,
|
|
2645
|
+
farEyeRatio: best.ratio,
|
|
2646
|
+
silhouetteRatio: best.renderedSilhouetteRatio
|
|
2647
|
+
},
|
|
2648
|
+
clamped: [...clamped, ...features.clamped]
|
|
2649
|
+
};
|
|
2650
|
+
}
|
|
1898
2651
|
var EYE_STACK_PREFIXES = ["eye_", "iris_", "pupil_", "highlight_"];
|
|
1899
2652
|
var HAIR_FRONT_DEPTH = 0.1;
|
|
1900
|
-
var HAIR_BACK_DEPTH = -0.08;
|
|
1901
2653
|
var NOD_TRAVEL = 30;
|
|
1902
2654
|
var NOD_BEND = 0.5;
|
|
1903
|
-
var HAIR_BACK_NOD_DEPTH = -0.
|
|
2655
|
+
var HAIR_BACK_NOD_DEPTH = -0.135;
|
|
2656
|
+
function headNodParallaxUnit(gridHalfHeight) {
|
|
2657
|
+
return gridHalfHeight * HEAD_CYLINDER_RADIUS_FACTOR * Math.sin(HEAD_TURN_MAX_DEG * NOD_BEND * (Math.PI / 180));
|
|
2658
|
+
}
|
|
2659
|
+
var FEATURE_NOD_DEPTH = {
|
|
2660
|
+
eye: 0.04,
|
|
2661
|
+
iris: 0.04,
|
|
2662
|
+
pupil: 0.04,
|
|
2663
|
+
highlight: 0.04,
|
|
2664
|
+
lash: 0.04,
|
|
2665
|
+
brow: 0.04,
|
|
2666
|
+
blush: 0.04,
|
|
2667
|
+
mouth: 0.04,
|
|
2668
|
+
mouth_open: 0.04,
|
|
2669
|
+
nose: 0.08
|
|
2670
|
+
};
|
|
2671
|
+
var HAIR_FRONT_NOD_DEPTH = FEATURE_NOD_DEPTH.brow;
|
|
2672
|
+
function roleFamily(role) {
|
|
2673
|
+
return role.replace(/_[LR]$/, "");
|
|
2674
|
+
}
|
|
2675
|
+
function turnFamily(role) {
|
|
2676
|
+
const family = roleFamily(role);
|
|
2677
|
+
if (family === "nose") return "nose";
|
|
2678
|
+
if (family === "mouth" || family === "mouth_open") return "mouth";
|
|
2679
|
+
return "eye";
|
|
2680
|
+
}
|
|
2681
|
+
function featureParallaxBindings(role, options) {
|
|
2682
|
+
if (!options.hasNose) return [];
|
|
2683
|
+
const nodDepth = FEATURE_NOD_DEPTH[roleFamily(role)];
|
|
2684
|
+
if (nodDepth === void 0) return [];
|
|
2685
|
+
const bindings = [];
|
|
2686
|
+
const turnDepth = options.turnDepths?.[turnFamily(role)] ?? 0;
|
|
2687
|
+
const shiftX = turnDepth * (options.parallaxUnit ?? 0);
|
|
2688
|
+
if (shiftX !== 0) {
|
|
2689
|
+
bindings.push({
|
|
2690
|
+
parameter: import_format3.StandardParameter.AngleX,
|
|
2691
|
+
channel: "translateX",
|
|
2692
|
+
from: -shiftX,
|
|
2693
|
+
to: shiftX
|
|
2694
|
+
});
|
|
2695
|
+
}
|
|
2696
|
+
const shiftY = nodDepth * (options.parallaxUnitY ?? 0);
|
|
2697
|
+
if (shiftY !== 0) {
|
|
2698
|
+
bindings.push({
|
|
2699
|
+
parameter: import_format3.StandardParameter.AngleY,
|
|
2700
|
+
channel: "translateY",
|
|
2701
|
+
from: -shiftY,
|
|
2702
|
+
to: shiftY
|
|
2703
|
+
});
|
|
2704
|
+
}
|
|
2705
|
+
return bindings;
|
|
2706
|
+
}
|
|
1904
2707
|
function bindingsForRole(spec, role, cropW, cropH, options = {}) {
|
|
2708
|
+
return [
|
|
2709
|
+
...roleOwnBindings(spec, role, cropW, cropH, options),
|
|
2710
|
+
...featureParallaxBindings(role, options)
|
|
2711
|
+
];
|
|
2712
|
+
}
|
|
2713
|
+
function roleOwnBindings(spec, role, cropW, cropH, options) {
|
|
1905
2714
|
const isEyeStack = EYE_STACK_PREFIXES.some((p) => role.startsWith(p));
|
|
1906
2715
|
if (isEyeStack && spec.eyeSide !== void 0) {
|
|
1907
2716
|
const isGazeRole = role.startsWith("iris_") || role.startsWith("pupil_") || role.startsWith("highlight_");
|
|
@@ -2004,18 +2813,8 @@ function bindingsForRole(spec, role, cropW, cropH, options = {}) {
|
|
|
2004
2813
|
if (role === "hair_front" || role === "hair_back") {
|
|
2005
2814
|
const isFront = role === "hair_front";
|
|
2006
2815
|
const parallax = [];
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
if (shiftX !== 0) {
|
|
2010
|
-
parallax.push({
|
|
2011
|
-
parameter: import_format3.StandardParameter.AngleX,
|
|
2012
|
-
channel: "translateX",
|
|
2013
|
-
from: -shiftX,
|
|
2014
|
-
to: shiftX
|
|
2015
|
-
});
|
|
2016
|
-
}
|
|
2017
|
-
}
|
|
2018
|
-
const shiftY = (isFront ? 0 : HAIR_BACK_NOD_DEPTH) * (options.parallaxUnitY ?? 0);
|
|
2816
|
+
const frontDepth = options.hasNose ? HAIR_FRONT_NOD_DEPTH : 0;
|
|
2817
|
+
const shiftY = (isFront ? frontDepth : HAIR_BACK_NOD_DEPTH) * (options.parallaxUnitY ?? 0);
|
|
2019
2818
|
if (shiftY !== 0) {
|
|
2020
2819
|
parallax.push({
|
|
2021
2820
|
parameter: import_format3.StandardParameter.AngleY,
|
|
@@ -2047,35 +2846,68 @@ function bakeEyelidFoldWarp(mesh, parameter, creaseOffsetY, k) {
|
|
|
2047
2846
|
]
|
|
2048
2847
|
};
|
|
2049
2848
|
}
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
right = Math.max(right, mesh.vertices[i]);
|
|
2849
|
+
function plateReach(faceCenterX, faceHalfWidth, columnMapAt) {
|
|
2850
|
+
let reach = 0;
|
|
2851
|
+
for (const deg of HEAD_TURN_STOPS) {
|
|
2852
|
+
reach = Math.max(
|
|
2853
|
+
reach,
|
|
2854
|
+
plateReachAt(columnMapAt(deg), faceCenterX, faceHalfWidth)
|
|
2855
|
+
);
|
|
2058
2856
|
}
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2857
|
+
return reach;
|
|
2858
|
+
}
|
|
2859
|
+
function plateReachAt(map, faceCenterX, faceHalfWidth) {
|
|
2860
|
+
let reach = 0;
|
|
2861
|
+
for (const side of [-1, 1]) {
|
|
2862
|
+
const dest = map.mapX(faceCenterX + side * faceHalfWidth);
|
|
2863
|
+
reach = Math.max(reach, Math.abs(dest - faceCenterX));
|
|
2864
|
+
}
|
|
2865
|
+
return reach;
|
|
2866
|
+
}
|
|
2867
|
+
function bakeHairFrontSilhouetteWarp(mesh, partX, faceCenterX, faceHalfWidth, holdBase, holdEdgeAt, columnMapAt) {
|
|
2868
|
+
if (holdEdgeAt(0) !== holdBase) {
|
|
2869
|
+
throw new Error(
|
|
2870
|
+
`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`
|
|
2871
|
+
);
|
|
2872
|
+
}
|
|
2873
|
+
const SIDES = [-1, 1];
|
|
2063
2874
|
const keyforms = HEAD_TURN_STOPS.map((deg) => {
|
|
2064
|
-
const
|
|
2065
|
-
const
|
|
2066
|
-
const
|
|
2067
|
-
|
|
2068
|
-
|
|
2875
|
+
const map = columnMapAt(deg);
|
|
2876
|
+
const requestedHoldEdge = holdEdgeAt(deg);
|
|
2877
|
+
const plateDest = SIDES.map(
|
|
2878
|
+
(side) => map.mapX(faceCenterX + side * faceHalfWidth)
|
|
2879
|
+
);
|
|
2880
|
+
const reached = plateReachAt(map, faceCenterX, faceHalfWidth);
|
|
2881
|
+
if (requestedHoldEdge <= reached) {
|
|
2882
|
+
const side = Math.abs(plateDest[1] - faceCenterX) > Math.abs(plateDest[0] - faceCenterX) ? 1 : -1;
|
|
2883
|
+
throw new Error(
|
|
2884
|
+
`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`
|
|
2885
|
+
);
|
|
2886
|
+
}
|
|
2887
|
+
const holdEdge = deg === 0 ? [requestedHoldEdge, requestedHoldEdge] : SIDES.map(
|
|
2888
|
+
(_side, idx) => Math.min(
|
|
2889
|
+
requestedHoldEdge,
|
|
2890
|
+
Math.abs(
|
|
2891
|
+
map.warpedX[idx === 0 ? 0 : map.warpedX.length - 1] - faceCenterX
|
|
2892
|
+
) - HOLD_CLEARANCE
|
|
2893
|
+
)
|
|
2894
|
+
);
|
|
2069
2895
|
const offsets = [];
|
|
2070
2896
|
for (let i = 0; i < mesh.vertices.length; i += 2) {
|
|
2071
|
-
const x = mesh.vertices[i];
|
|
2072
|
-
const
|
|
2073
|
-
|
|
2074
|
-
|
|
2897
|
+
const x = partX + mesh.vertices[i];
|
|
2898
|
+
const target = hairFrontHoldTarget(
|
|
2899
|
+
x,
|
|
2900
|
+
faceCenterX,
|
|
2901
|
+
faceHalfWidth,
|
|
2902
|
+
holdBase,
|
|
2903
|
+
holdEdge,
|
|
2904
|
+
map
|
|
2905
|
+
);
|
|
2906
|
+
offsets.push(map.invertX(target) - x, 0);
|
|
2075
2907
|
}
|
|
2076
2908
|
return { value: deg, offsets };
|
|
2077
2909
|
});
|
|
2078
|
-
return { parameter, keyforms };
|
|
2910
|
+
return { parameter: import_format3.StandardParameter.AngleX, keyforms };
|
|
2079
2911
|
}
|
|
2080
2912
|
var HAIR_SWAY_TIP_FRACTION = 0.09;
|
|
2081
2913
|
var HAIR_SWAY_CURL = 1.5;
|
|
@@ -2103,14 +2935,18 @@ function bakeHairSwayWarp(mesh, parameter, tipShift, range) {
|
|
|
2103
2935
|
]
|
|
2104
2936
|
};
|
|
2105
2937
|
}
|
|
2106
|
-
var
|
|
2938
|
+
var HEAD_TURN_TRAVEL_RATIO = 0.25;
|
|
2939
|
+
function headTurnTravel(faceHalfWidth) {
|
|
2940
|
+
return HEAD_TURN_TRAVEL_RATIO * faceHalfWidth;
|
|
2941
|
+
}
|
|
2107
2942
|
var BODY_TURN_FOLLOW = 0.3;
|
|
2108
2943
|
var HEAD_BREATH_BOB = -12;
|
|
2109
2944
|
var BODY_BREATH_FOLLOW = 0.5;
|
|
2110
|
-
function generateIkiFromLayerSet(layers, canvas) {
|
|
2945
|
+
function generateIkiFromLayerSet(layers, canvas, options = {}) {
|
|
2111
2946
|
validateLayerInputs(layers, canvas);
|
|
2112
2947
|
const hasHair = layers.some((l) => l.role === "hair_front");
|
|
2113
2948
|
const hasMouthOpen = layers.some((l) => l.role === "mouth_open");
|
|
2949
|
+
const hasNose = layers.some((l) => l.role === "nose");
|
|
2114
2950
|
const bodyLayer = layers.find((l) => l.role === "body");
|
|
2115
2951
|
const parameters = [
|
|
2116
2952
|
{
|
|
@@ -2288,8 +3124,53 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2288
3124
|
};
|
|
2289
3125
|
const faceCenterY = faceTransform.y;
|
|
2290
3126
|
const halfH = Math.max(faceCenterY - unionMinY, unionMaxY - faceCenterY);
|
|
2291
|
-
const
|
|
2292
|
-
const
|
|
3127
|
+
const faceHalfWidth = faceLayer.cropW / 2;
|
|
3128
|
+
const hairFrontLayer = layers.find((l) => l.role === "hair_front");
|
|
3129
|
+
const turn = hasNose ? solveTurnModel(
|
|
3130
|
+
resolveTurnTargets(options.turnTargets),
|
|
3131
|
+
turnLandmarks(layers),
|
|
3132
|
+
faceGrid,
|
|
3133
|
+
faceCenterX,
|
|
3134
|
+
faceHalfWidth,
|
|
3135
|
+
hairFrontLayer && (() => {
|
|
3136
|
+
const t = bboxToTransform(
|
|
3137
|
+
hairFrontLayer.bbox,
|
|
3138
|
+
hairFrontLayer.canvasW,
|
|
3139
|
+
hairFrontLayer.canvasH,
|
|
3140
|
+
"hair_front"
|
|
3141
|
+
);
|
|
3142
|
+
return {
|
|
3143
|
+
x: t.x,
|
|
3144
|
+
centerY: t.y,
|
|
3145
|
+
cropW: hairFrontLayer.cropW,
|
|
3146
|
+
cropH: hairFrontLayer.cropH
|
|
3147
|
+
};
|
|
3148
|
+
})(),
|
|
3149
|
+
options.headEdges
|
|
3150
|
+
) : void 0;
|
|
3151
|
+
if (turn?.unreachable) {
|
|
3152
|
+
throw new TurnTargetError(
|
|
3153
|
+
turn.attainable === void 0 ? (
|
|
3154
|
+
// Only a silhouette can land here: nothing the field's own accepted
|
|
3155
|
+
// range allows renders on this layer set, so there is no interval to
|
|
3156
|
+
// offer instead — see `silhouetteMiss` in evaluateTurnCandidate.
|
|
3157
|
+
`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}]`
|
|
3158
|
+
) : `auto-rig: turnTargets.${turn.field} ${turn.value} is unreachable for this layer set (attainable ${turn.attainable[0]}\u2026${turn.attainable[1]})`
|
|
3159
|
+
);
|
|
3160
|
+
}
|
|
3161
|
+
if (turn) {
|
|
3162
|
+
options.onTurnSolved?.({
|
|
3163
|
+
radius: turn.radius,
|
|
3164
|
+
holdBase: turn.holdBase,
|
|
3165
|
+
depths: turn.depths,
|
|
3166
|
+
achieved: turn.achieved,
|
|
3167
|
+
clamped: turn.clamped
|
|
3168
|
+
});
|
|
3169
|
+
}
|
|
3170
|
+
const faceRadius = turn?.radius ?? halfW * HEAD_CYLINDER_RADIUS_FACTOR;
|
|
3171
|
+
const headTravel = turn?.travel ?? headTurnTravel(faceHalfWidth);
|
|
3172
|
+
const parallaxUnit = headTurnParallaxUnit(faceRadius);
|
|
3173
|
+
const parallaxUnitY = headNodParallaxUnit(halfH);
|
|
2293
3174
|
const faceBottom = faceTransform.y - faceCropH / 2;
|
|
2294
3175
|
const neckPivot = {
|
|
2295
3176
|
x: faceCenterX,
|
|
@@ -2301,26 +3182,27 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2301
3182
|
import_format3.StandardParameter.AngleX,
|
|
2302
3183
|
import_format3.StandardParameter.AngleY,
|
|
2303
3184
|
faceCenterX,
|
|
2304
|
-
faceCenterY
|
|
3185
|
+
faceCenterY,
|
|
3186
|
+
faceRadius,
|
|
3187
|
+
headTravel
|
|
2305
3188
|
);
|
|
2306
3189
|
const deformers = [
|
|
2307
3190
|
// headDeformer: rigid matrix rotating/translating the whole head about the
|
|
2308
|
-
// neck pivot
|
|
3191
|
+
// neck pivot. Same bindings as sample-model.ts apart from the turn, which
|
|
3192
|
+
// moves the face inside the head rather than the head itself (see below).
|
|
2309
3193
|
{
|
|
2310
3194
|
id: "headDeformer",
|
|
2311
3195
|
pivot: neckPivot,
|
|
2312
3196
|
bindings: [
|
|
2313
|
-
//
|
|
2314
|
-
//
|
|
2315
|
-
//
|
|
2316
|
-
//
|
|
2317
|
-
//
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
to: HEAD_TURN_TRAVEL
|
|
2323
|
-
},
|
|
3197
|
+
// No AngleX binding: the turn moves NOTHING rigidly. Its sideways
|
|
3198
|
+
// travel is baked into faceWarp's own grid (see
|
|
3199
|
+
// bakeHeadTurnGridWarp2DCentered's `travel`) so the face slides inside
|
|
3200
|
+
// a silhouette the bangs hold; a translate here would take the hair
|
|
3201
|
+
// shell with it. Nor is the turn a roll — the sample model's ±6° "lean
|
|
3202
|
+
// into the turn" was tried here and dropped, since rotating about the
|
|
3203
|
+
// neck pivot swings the crown (~500px above it) far more than the chin,
|
|
3204
|
+
// so the top of the head appeared to lunge ahead of the face on every
|
|
3205
|
+
// turn. Roll is AngleZ's job, below.
|
|
2324
3206
|
// Nod: a vertical translate only. No rotate — a pitch expressed as a
|
|
2325
3207
|
// rigid rotation would sum with the AngleZ roll below at diagonal
|
|
2326
3208
|
// poses, collapsing pitch into roll.
|
|
@@ -2373,12 +3255,13 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2373
3255
|
id: "bodyDeformer",
|
|
2374
3256
|
pivot: { x: bt.x, y: bt.y - bodyLayer.cropH / 2 },
|
|
2375
3257
|
bindings: [
|
|
2376
|
-
// Low-weight follow of the turn, same direction as the
|
|
3258
|
+
// Low-weight follow of the turn, same direction as the face's own
|
|
3259
|
+
// slide — the only rigid part of the travel left.
|
|
2377
3260
|
{
|
|
2378
3261
|
parameter: import_format3.StandardParameter.AngleX,
|
|
2379
3262
|
channel: "translateX",
|
|
2380
|
-
from: -BODY_TURN_FOLLOW *
|
|
2381
|
-
to: BODY_TURN_FOLLOW *
|
|
3263
|
+
from: -BODY_TURN_FOLLOW * headTravel,
|
|
3264
|
+
to: BODY_TURN_FOLLOW * headTravel
|
|
2382
3265
|
},
|
|
2383
3266
|
// Breath: follow the head's bob, same direction, half amplitude.
|
|
2384
3267
|
{
|
|
@@ -2410,7 +3293,9 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2410
3293
|
const roleBindings = bindingsForRole(spec, role, cropW, cropH, {
|
|
2411
3294
|
hasMouthOpen,
|
|
2412
3295
|
parallaxUnit,
|
|
2413
|
-
parallaxUnitY
|
|
3296
|
+
parallaxUnitY,
|
|
3297
|
+
hasNose,
|
|
3298
|
+
turnDepths: turn?.depths
|
|
2414
3299
|
});
|
|
2415
3300
|
const deformerId = spec.deformer === "none" ? void 0 : spec.deformer;
|
|
2416
3301
|
if (spec.mesh) {
|
|
@@ -2432,6 +3317,7 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2432
3317
|
if ((role === "hair_front" || role === "hair_back") && hasHair) {
|
|
2433
3318
|
let tipShift = HAIR_SWAY_TIP_FRACTION * cropH;
|
|
2434
3319
|
let leadWarp;
|
|
3320
|
+
let silhouetteWarp;
|
|
2435
3321
|
if (role === "hair_front") {
|
|
2436
3322
|
const shift = HAIR_FRONT_DEPTH * parallaxUnit;
|
|
2437
3323
|
const headroom = Math.min(
|
|
@@ -2448,6 +3334,17 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2448
3334
|
shift,
|
|
2449
3335
|
HEAD_TURN_MAX_DEG
|
|
2450
3336
|
);
|
|
3337
|
+
const columnMapAt = (deg) => turnColumnMap(faceGrid, faceCenterX, faceRadius, deg, headTravel);
|
|
3338
|
+
const holdBase = turn?.holdBase ?? plateReach(faceCenterX, faceHalfWidth, columnMapAt) + HOLD_CLEARANCE;
|
|
3339
|
+
silhouetteWarp = bakeHairFrontSilhouetteWarp(
|
|
3340
|
+
mesh,
|
|
3341
|
+
t.x,
|
|
3342
|
+
faceCenterX,
|
|
3343
|
+
faceHalfWidth,
|
|
3344
|
+
holdBase,
|
|
3345
|
+
turn?.holdEdgeAt ?? (() => holdBase),
|
|
3346
|
+
columnMapAt
|
|
3347
|
+
);
|
|
2451
3348
|
}
|
|
2452
3349
|
part.warps = [
|
|
2453
3350
|
bakeHairSwayWarp(
|
|
@@ -2462,13 +3359,8 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2462
3359
|
tipShift,
|
|
2463
3360
|
HAIR_SWAY_RANGE
|
|
2464
3361
|
),
|
|
2465
|
-
...leadWarp ? [leadWarp] : []
|
|
2466
|
-
|
|
2467
|
-
}
|
|
2468
|
-
if (role === "hair_back") {
|
|
2469
|
-
part.warps = [
|
|
2470
|
-
...part.warps ?? [],
|
|
2471
|
-
bakeHairBackTurnWarp(mesh, import_format3.StandardParameter.AngleX)
|
|
3362
|
+
...leadWarp ? [leadWarp] : [],
|
|
3363
|
+
...silhouetteWarp ? [silhouetteWarp] : []
|
|
2472
3364
|
];
|
|
2473
3365
|
}
|
|
2474
3366
|
if (spec.eyeSide !== void 0) {
|
|
@@ -2549,6 +3441,7 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2549
3441
|
AddPart,
|
|
2550
3442
|
AddPhysicsRig,
|
|
2551
3443
|
CaptureGridKeyform,
|
|
3444
|
+
DEFAULT_TURN_TARGETS,
|
|
2552
3445
|
DeleteDeformer,
|
|
2553
3446
|
DeletePart,
|
|
2554
3447
|
DeletePhysicsRig,
|
|
@@ -2568,6 +3461,7 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2568
3461
|
SetPartTransform,
|
|
2569
3462
|
SetPartWidth,
|
|
2570
3463
|
SetPhysicsRig,
|
|
3464
|
+
TurnTargetError,
|
|
2571
3465
|
UV_INSET_PX,
|
|
2572
3466
|
captureBindingEndpoint,
|
|
2573
3467
|
computeGridOffsets,
|