@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.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,16 +1906,757 @@ 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
2655
|
var HAIR_BACK_NOD_DEPTH = -0.135;
|
|
1904
2656
|
function headNodParallaxUnit(gridHalfHeight) {
|
|
1905
2657
|
return gridHalfHeight * HEAD_CYLINDER_RADIUS_FACTOR * Math.sin(HEAD_TURN_MAX_DEG * NOD_BEND * (Math.PI / 180));
|
|
1906
2658
|
}
|
|
1907
|
-
var
|
|
2659
|
+
var FEATURE_NOD_DEPTH = {
|
|
1908
2660
|
eye: 0.04,
|
|
1909
2661
|
iris: 0.04,
|
|
1910
2662
|
pupil: 0.04,
|
|
@@ -1916,16 +2668,23 @@ var FEATURE_DEPTH = {
|
|
|
1916
2668
|
mouth_open: 0.04,
|
|
1917
2669
|
nose: 0.08
|
|
1918
2670
|
};
|
|
1919
|
-
var HAIR_FRONT_NOD_DEPTH =
|
|
2671
|
+
var HAIR_FRONT_NOD_DEPTH = FEATURE_NOD_DEPTH.brow;
|
|
1920
2672
|
function roleFamily(role) {
|
|
1921
2673
|
return role.replace(/_[LR]$/, "");
|
|
1922
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
|
+
}
|
|
1923
2681
|
function featureParallaxBindings(role, options) {
|
|
1924
2682
|
if (!options.hasNose) return [];
|
|
1925
|
-
const
|
|
1926
|
-
if (
|
|
2683
|
+
const nodDepth = FEATURE_NOD_DEPTH[roleFamily(role)];
|
|
2684
|
+
if (nodDepth === void 0) return [];
|
|
1927
2685
|
const bindings = [];
|
|
1928
|
-
const
|
|
2686
|
+
const turnDepth = options.turnDepths?.[turnFamily(role)] ?? 0;
|
|
2687
|
+
const shiftX = turnDepth * (options.parallaxUnit ?? 0);
|
|
1929
2688
|
if (shiftX !== 0) {
|
|
1930
2689
|
bindings.push({
|
|
1931
2690
|
parameter: import_format3.StandardParameter.AngleX,
|
|
@@ -1934,7 +2693,7 @@ function featureParallaxBindings(role, options) {
|
|
|
1934
2693
|
to: shiftX
|
|
1935
2694
|
});
|
|
1936
2695
|
}
|
|
1937
|
-
const shiftY =
|
|
2696
|
+
const shiftY = nodDepth * (options.parallaxUnitY ?? 0);
|
|
1938
2697
|
if (shiftY !== 0) {
|
|
1939
2698
|
bindings.push({
|
|
1940
2699
|
parameter: import_format3.StandardParameter.AngleY,
|
|
@@ -2054,17 +2813,6 @@ function roleOwnBindings(spec, role, cropW, cropH, options) {
|
|
|
2054
2813
|
if (role === "hair_front" || role === "hair_back") {
|
|
2055
2814
|
const isFront = role === "hair_front";
|
|
2056
2815
|
const parallax = [];
|
|
2057
|
-
if (!isFront) {
|
|
2058
|
-
const shiftX = HAIR_BACK_DEPTH * (options.parallaxUnit ?? 0);
|
|
2059
|
-
if (shiftX !== 0) {
|
|
2060
|
-
parallax.push({
|
|
2061
|
-
parameter: import_format3.StandardParameter.AngleX,
|
|
2062
|
-
channel: "translateX",
|
|
2063
|
-
from: -shiftX,
|
|
2064
|
-
to: shiftX
|
|
2065
|
-
});
|
|
2066
|
-
}
|
|
2067
|
-
}
|
|
2068
2816
|
const frontDepth = options.hasNose ? HAIR_FRONT_NOD_DEPTH : 0;
|
|
2069
2817
|
const shiftY = (isFront ? frontDepth : HAIR_BACK_NOD_DEPTH) * (options.parallaxUnitY ?? 0);
|
|
2070
2818
|
if (shiftY !== 0) {
|
|
@@ -2098,35 +2846,68 @@ function bakeEyelidFoldWarp(mesh, parameter, creaseOffsetY, k) {
|
|
|
2098
2846
|
]
|
|
2099
2847
|
};
|
|
2100
2848
|
}
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
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
|
+
);
|
|
2109
2856
|
}
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
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];
|
|
2114
2874
|
const keyforms = HEAD_TURN_STOPS.map((deg) => {
|
|
2115
|
-
const
|
|
2116
|
-
const
|
|
2117
|
-
const
|
|
2118
|
-
|
|
2119
|
-
|
|
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
|
+
);
|
|
2120
2895
|
const offsets = [];
|
|
2121
2896
|
for (let i = 0; i < mesh.vertices.length; i += 2) {
|
|
2122
|
-
const x = mesh.vertices[i];
|
|
2123
|
-
const
|
|
2124
|
-
|
|
2125
|
-
|
|
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);
|
|
2126
2907
|
}
|
|
2127
2908
|
return { value: deg, offsets };
|
|
2128
2909
|
});
|
|
2129
|
-
return { parameter, keyforms };
|
|
2910
|
+
return { parameter: import_format3.StandardParameter.AngleX, keyforms };
|
|
2130
2911
|
}
|
|
2131
2912
|
var HAIR_SWAY_TIP_FRACTION = 0.09;
|
|
2132
2913
|
var HAIR_SWAY_CURL = 1.5;
|
|
@@ -2154,11 +2935,14 @@ function bakeHairSwayWarp(mesh, parameter, tipShift, range) {
|
|
|
2154
2935
|
]
|
|
2155
2936
|
};
|
|
2156
2937
|
}
|
|
2157
|
-
var
|
|
2938
|
+
var HEAD_TURN_TRAVEL_RATIO = 0.25;
|
|
2939
|
+
function headTurnTravel(faceHalfWidth) {
|
|
2940
|
+
return HEAD_TURN_TRAVEL_RATIO * faceHalfWidth;
|
|
2941
|
+
}
|
|
2158
2942
|
var BODY_TURN_FOLLOW = 0.3;
|
|
2159
2943
|
var HEAD_BREATH_BOB = -12;
|
|
2160
2944
|
var BODY_BREATH_FOLLOW = 0.5;
|
|
2161
|
-
function generateIkiFromLayerSet(layers, canvas) {
|
|
2945
|
+
function generateIkiFromLayerSet(layers, canvas, options = {}) {
|
|
2162
2946
|
validateLayerInputs(layers, canvas);
|
|
2163
2947
|
const hasHair = layers.some((l) => l.role === "hair_front");
|
|
2164
2948
|
const hasMouthOpen = layers.some((l) => l.role === "mouth_open");
|
|
@@ -2340,7 +3124,52 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2340
3124
|
};
|
|
2341
3125
|
const faceCenterY = faceTransform.y;
|
|
2342
3126
|
const halfH = Math.max(faceCenterY - unionMinY, unionMaxY - faceCenterY);
|
|
2343
|
-
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);
|
|
2344
3173
|
const parallaxUnitY = headNodParallaxUnit(halfH);
|
|
2345
3174
|
const faceBottom = faceTransform.y - faceCropH / 2;
|
|
2346
3175
|
const neckPivot = {
|
|
@@ -2353,26 +3182,27 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2353
3182
|
import_format3.StandardParameter.AngleX,
|
|
2354
3183
|
import_format3.StandardParameter.AngleY,
|
|
2355
3184
|
faceCenterX,
|
|
2356
|
-
faceCenterY
|
|
3185
|
+
faceCenterY,
|
|
3186
|
+
faceRadius,
|
|
3187
|
+
headTravel
|
|
2357
3188
|
);
|
|
2358
3189
|
const deformers = [
|
|
2359
3190
|
// headDeformer: rigid matrix rotating/translating the whole head about the
|
|
2360
|
-
// 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).
|
|
2361
3193
|
{
|
|
2362
3194
|
id: "headDeformer",
|
|
2363
3195
|
pivot: neckPivot,
|
|
2364
3196
|
bindings: [
|
|
2365
|
-
//
|
|
2366
|
-
//
|
|
2367
|
-
//
|
|
2368
|
-
//
|
|
2369
|
-
//
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
to: HEAD_TURN_TRAVEL
|
|
2375
|
-
},
|
|
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.
|
|
2376
3206
|
// Nod: a vertical translate only. No rotate — a pitch expressed as a
|
|
2377
3207
|
// rigid rotation would sum with the AngleZ roll below at diagonal
|
|
2378
3208
|
// poses, collapsing pitch into roll.
|
|
@@ -2425,12 +3255,13 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2425
3255
|
id: "bodyDeformer",
|
|
2426
3256
|
pivot: { x: bt.x, y: bt.y - bodyLayer.cropH / 2 },
|
|
2427
3257
|
bindings: [
|
|
2428
|
-
// 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.
|
|
2429
3260
|
{
|
|
2430
3261
|
parameter: import_format3.StandardParameter.AngleX,
|
|
2431
3262
|
channel: "translateX",
|
|
2432
|
-
from: -BODY_TURN_FOLLOW *
|
|
2433
|
-
to: BODY_TURN_FOLLOW *
|
|
3263
|
+
from: -BODY_TURN_FOLLOW * headTravel,
|
|
3264
|
+
to: BODY_TURN_FOLLOW * headTravel
|
|
2434
3265
|
},
|
|
2435
3266
|
// Breath: follow the head's bob, same direction, half amplitude.
|
|
2436
3267
|
{
|
|
@@ -2463,7 +3294,8 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2463
3294
|
hasMouthOpen,
|
|
2464
3295
|
parallaxUnit,
|
|
2465
3296
|
parallaxUnitY,
|
|
2466
|
-
hasNose
|
|
3297
|
+
hasNose,
|
|
3298
|
+
turnDepths: turn?.depths
|
|
2467
3299
|
});
|
|
2468
3300
|
const deformerId = spec.deformer === "none" ? void 0 : spec.deformer;
|
|
2469
3301
|
if (spec.mesh) {
|
|
@@ -2485,6 +3317,7 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2485
3317
|
if ((role === "hair_front" || role === "hair_back") && hasHair) {
|
|
2486
3318
|
let tipShift = HAIR_SWAY_TIP_FRACTION * cropH;
|
|
2487
3319
|
let leadWarp;
|
|
3320
|
+
let silhouetteWarp;
|
|
2488
3321
|
if (role === "hair_front") {
|
|
2489
3322
|
const shift = HAIR_FRONT_DEPTH * parallaxUnit;
|
|
2490
3323
|
const headroom = Math.min(
|
|
@@ -2501,6 +3334,17 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2501
3334
|
shift,
|
|
2502
3335
|
HEAD_TURN_MAX_DEG
|
|
2503
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
|
+
);
|
|
2504
3348
|
}
|
|
2505
3349
|
part.warps = [
|
|
2506
3350
|
bakeHairSwayWarp(
|
|
@@ -2515,13 +3359,8 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2515
3359
|
tipShift,
|
|
2516
3360
|
HAIR_SWAY_RANGE
|
|
2517
3361
|
),
|
|
2518
|
-
...leadWarp ? [leadWarp] : []
|
|
2519
|
-
|
|
2520
|
-
}
|
|
2521
|
-
if (role === "hair_back") {
|
|
2522
|
-
part.warps = [
|
|
2523
|
-
...part.warps ?? [],
|
|
2524
|
-
bakeHairBackTurnWarp(mesh, import_format3.StandardParameter.AngleX)
|
|
3362
|
+
...leadWarp ? [leadWarp] : [],
|
|
3363
|
+
...silhouetteWarp ? [silhouetteWarp] : []
|
|
2525
3364
|
];
|
|
2526
3365
|
}
|
|
2527
3366
|
if (spec.eyeSide !== void 0) {
|
|
@@ -2602,6 +3441,7 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2602
3441
|
AddPart,
|
|
2603
3442
|
AddPhysicsRig,
|
|
2604
3443
|
CaptureGridKeyform,
|
|
3444
|
+
DEFAULT_TURN_TARGETS,
|
|
2605
3445
|
DeleteDeformer,
|
|
2606
3446
|
DeletePart,
|
|
2607
3447
|
DeletePhysicsRig,
|
|
@@ -2621,6 +3461,7 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2621
3461
|
SetPartTransform,
|
|
2622
3462
|
SetPartWidth,
|
|
2623
3463
|
SetPhysicsRig,
|
|
3464
|
+
TurnTargetError,
|
|
2624
3465
|
UV_INSET_PX,
|
|
2625
3466
|
captureBindingEndpoint,
|
|
2626
3467
|
computeGridOffsets,
|