@ikijs/editor 0.1.0 → 0.2.1
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 +3 -3
- package/dist/index.js +297 -60
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +297 -60
- package/dist/index.mjs.map +1 -1
- package/package.json +17 -3
package/dist/index.mjs
CHANGED
|
@@ -1604,14 +1604,25 @@ import {
|
|
|
1604
1604
|
parseIkiModel as parseIkiModel3
|
|
1605
1605
|
} from "@ikijs/format";
|
|
1606
1606
|
var ROLE_TABLE = {
|
|
1607
|
-
//
|
|
1608
|
-
//
|
|
1609
|
-
|
|
1607
|
+
// The silhouette behind the face. It rides the rigid head (not faceWarp,
|
|
1608
|
+
// whose grid it would stretch across the shoulders) as a MESH: it bends on
|
|
1609
|
+
// the turn through its own part warp (bakeHairBackTurnWarp) and swings its
|
|
1610
|
+
// ends on the hair-sway warps.
|
|
1611
|
+
hair_back: { deformer: "headDeformer", order: 0, mesh: true },
|
|
1612
|
+
// The torso. Alone among the roles it hangs from NO deformer: the head turns
|
|
1613
|
+
// about the neck pivot while the shoulders stay put, which is what keeps the
|
|
1614
|
+
// character from reading as a floating head. Drawn over the back hair so long
|
|
1615
|
+
// hair falls behind the shoulders.
|
|
1616
|
+
body: { deformer: "none", order: 5, mesh: false },
|
|
1610
1617
|
face: { deformer: "faceWarp", order: 10, mesh: true },
|
|
1611
1618
|
nose: { deformer: "faceWarp", order: 15, mesh: true },
|
|
1612
1619
|
blush_L: { deformer: "faceWarp", order: 20, mesh: true },
|
|
1613
1620
|
blush_R: { deformer: "faceWarp", order: 20, mesh: true },
|
|
1614
1621
|
mouth: { deformer: "faceWarp", order: 25, mesh: true },
|
|
1622
|
+
// An OPTIONAL second mouth drawing, open. When present the two cross-fade on
|
|
1623
|
+
// MouthOpen instead of the closed one being stretched, which is the
|
|
1624
|
+
// difference between a portrait rig and one that can lip-sync.
|
|
1625
|
+
mouth_open: { deformer: "faceWarp", order: 26, mesh: true },
|
|
1615
1626
|
eye_L: { deformer: "faceWarp", order: 30, mesh: true, eyeSide: "L" },
|
|
1616
1627
|
eye_R: { deformer: "faceWarp", order: 30, mesh: true, eyeSide: "R" },
|
|
1617
1628
|
iris_L: { deformer: "faceWarp", order: 31, mesh: true, eyeSide: "L" },
|
|
@@ -1763,29 +1774,62 @@ function createPixelGridMesh(cols, rows, w, h) {
|
|
|
1763
1774
|
}
|
|
1764
1775
|
return { vertices, uvs, indices };
|
|
1765
1776
|
}
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1777
|
+
var HEAD_CYLINDER_RADIUS_FACTOR = 0.6 / 0.5;
|
|
1778
|
+
var HEAD_TURN_MAX_DEG = 30;
|
|
1779
|
+
function headTurnParallaxUnit(gridHalfWidth) {
|
|
1780
|
+
return gridHalfWidth * HEAD_CYLINDER_RADIUS_FACTOR * Math.sin(HEAD_TURN_MAX_DEG * (Math.PI / 180));
|
|
1781
|
+
}
|
|
1782
|
+
function gridReach(grid, axis, center) {
|
|
1783
|
+
let reach = 0;
|
|
1784
|
+
for (let i = axis; i < grid.points.length; i += 2) {
|
|
1785
|
+
reach = Math.max(reach, Math.abs(grid.points[i] - center));
|
|
1786
|
+
}
|
|
1787
|
+
return reach;
|
|
1788
|
+
}
|
|
1789
|
+
function pinnedCylinderBend(local, radius, theta) {
|
|
1790
|
+
const alpha = Math.asin(Math.max(-1, Math.min(1, local / radius)));
|
|
1791
|
+
return radius * Math.sin(alpha + theta) - local - radius * Math.sin(theta);
|
|
1792
|
+
}
|
|
1793
|
+
function bakeHeadTurnGridWarp2DCentered(grid, parameterX, parameterY, centerX, centerY) {
|
|
1794
|
+
const STOPS = [-HEAD_TURN_MAX_DEG, 0, HEAD_TURN_MAX_DEG];
|
|
1795
|
+
const RADIUS_X = gridReach(grid, 0, centerX) * HEAD_CYLINDER_RADIUS_FACTOR;
|
|
1796
|
+
const RADIUS_Y = gridReach(grid, 1, centerY) * HEAD_CYLINDER_RADIUS_FACTOR;
|
|
1770
1797
|
const pointCount = grid.points.length / 2;
|
|
1771
1798
|
const DEG_TO_RAD = Math.PI / 180;
|
|
1772
|
-
const
|
|
1773
|
-
|
|
1774
|
-
const
|
|
1775
|
-
for (
|
|
1776
|
-
const
|
|
1777
|
-
const
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1799
|
+
const keyforms2d = [];
|
|
1800
|
+
for (const angleY of STOPS) {
|
|
1801
|
+
const thetaY = angleY * NOD_BEND * DEG_TO_RAD;
|
|
1802
|
+
for (const angleX of STOPS) {
|
|
1803
|
+
const thetaX = angleX * DEG_TO_RAD;
|
|
1804
|
+
const offsets = [];
|
|
1805
|
+
for (let i = 0; i < pointCount; i++) {
|
|
1806
|
+
offsets.push(
|
|
1807
|
+
pinnedCylinderBend(grid.points[i * 2] - centerX, RADIUS_X, thetaX),
|
|
1808
|
+
pinnedCylinderBend(
|
|
1809
|
+
grid.points[i * 2 + 1] - centerY,
|
|
1810
|
+
RADIUS_Y,
|
|
1811
|
+
thetaY
|
|
1812
|
+
)
|
|
1813
|
+
);
|
|
1814
|
+
}
|
|
1815
|
+
keyforms2d.push({ offsets });
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
return {
|
|
1819
|
+
parameter: parameterX,
|
|
1820
|
+
parameterY,
|
|
1821
|
+
valuesX: STOPS,
|
|
1822
|
+
valuesY: STOPS,
|
|
1823
|
+
keyforms2d
|
|
1824
|
+
};
|
|
1786
1825
|
}
|
|
1787
1826
|
var EYE_STACK_PREFIXES = ["eye_", "iris_", "pupil_", "highlight_"];
|
|
1788
|
-
|
|
1827
|
+
var HAIR_FRONT_DEPTH = 0.1;
|
|
1828
|
+
var HAIR_BACK_DEPTH = -0.08;
|
|
1829
|
+
var NOD_TRAVEL = 30;
|
|
1830
|
+
var NOD_BEND = 0.5;
|
|
1831
|
+
var HAIR_BACK_NOD_DEPTH = -0.07;
|
|
1832
|
+
function bindingsForRole(spec, role, cropW, cropH, options = {}) {
|
|
1789
1833
|
const isEyeStack = EYE_STACK_PREFIXES.some((p) => role.startsWith(p));
|
|
1790
1834
|
if (isEyeStack && spec.eyeSide !== void 0) {
|
|
1791
1835
|
const isGazeRole = role.startsWith("iris_") || role.startsWith("pupil_") || role.startsWith("highlight_");
|
|
@@ -1807,22 +1851,47 @@ function bindingsForRole(spec, role, cropW, cropH) {
|
|
|
1807
1851
|
}
|
|
1808
1852
|
];
|
|
1809
1853
|
}
|
|
1854
|
+
const mouthForm = {
|
|
1855
|
+
// Mouth form: scaleX from -0.2 (pursed, param=-1) to 0.4 (wide, param=1).
|
|
1856
|
+
parameter: StandardParameter.MouthForm,
|
|
1857
|
+
channel: "scaleX",
|
|
1858
|
+
from: -0.2,
|
|
1859
|
+
to: 0.4
|
|
1860
|
+
};
|
|
1810
1861
|
if (role === "mouth") {
|
|
1862
|
+
if (options.hasMouthOpen) {
|
|
1863
|
+
return [
|
|
1864
|
+
{
|
|
1865
|
+
parameter: StandardParameter.MouthOpen,
|
|
1866
|
+
channel: "opacity",
|
|
1867
|
+
from: 1,
|
|
1868
|
+
to: 0
|
|
1869
|
+
},
|
|
1870
|
+
mouthForm
|
|
1871
|
+
];
|
|
1872
|
+
}
|
|
1811
1873
|
return [
|
|
1812
1874
|
// Mouth open: scaleY from 0 (closed, param=0) to 3 (wide open, param=1).
|
|
1875
|
+
// Only a fallback — it distorts, but it is better than a mouth that
|
|
1876
|
+
// cannot open at all when the layer set has no open drawing.
|
|
1813
1877
|
{
|
|
1814
1878
|
parameter: StandardParameter.MouthOpen,
|
|
1815
1879
|
channel: "scaleY",
|
|
1816
1880
|
from: 0,
|
|
1817
1881
|
to: 3
|
|
1818
1882
|
},
|
|
1819
|
-
|
|
1883
|
+
mouthForm
|
|
1884
|
+
];
|
|
1885
|
+
}
|
|
1886
|
+
if (role === "mouth_open") {
|
|
1887
|
+
return [
|
|
1820
1888
|
{
|
|
1821
|
-
parameter: StandardParameter.
|
|
1822
|
-
channel: "
|
|
1823
|
-
from:
|
|
1824
|
-
to:
|
|
1825
|
-
}
|
|
1889
|
+
parameter: StandardParameter.MouthOpen,
|
|
1890
|
+
channel: "opacity",
|
|
1891
|
+
from: 0,
|
|
1892
|
+
to: 1
|
|
1893
|
+
},
|
|
1894
|
+
mouthForm
|
|
1826
1895
|
];
|
|
1827
1896
|
}
|
|
1828
1897
|
if (role === "brow_L" || role === "brow_R") {
|
|
@@ -1860,21 +1929,29 @@ function bindingsForRole(spec, role, cropW, cropH) {
|
|
|
1860
1929
|
];
|
|
1861
1930
|
}
|
|
1862
1931
|
}
|
|
1863
|
-
if (role === "hair_front") {
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
{
|
|
1872
|
-
parameter: StandardParameter.HairSwayX,
|
|
1932
|
+
if (role === "hair_front" || role === "hair_back") {
|
|
1933
|
+
const isFront = role === "hair_front";
|
|
1934
|
+
const depth = isFront ? HAIR_FRONT_DEPTH : HAIR_BACK_DEPTH;
|
|
1935
|
+
const parallax = [];
|
|
1936
|
+
const shiftX = depth * (options.parallaxUnit ?? 0);
|
|
1937
|
+
if (shiftX !== 0) {
|
|
1938
|
+
parallax.push({
|
|
1939
|
+
parameter: StandardParameter.AngleX,
|
|
1873
1940
|
channel: "translateX",
|
|
1874
|
-
from: -
|
|
1875
|
-
to:
|
|
1876
|
-
}
|
|
1877
|
-
|
|
1941
|
+
from: -shiftX,
|
|
1942
|
+
to: shiftX
|
|
1943
|
+
});
|
|
1944
|
+
}
|
|
1945
|
+
const shiftY = (isFront ? 0 : HAIR_BACK_NOD_DEPTH) * (options.parallaxUnitY ?? 0);
|
|
1946
|
+
if (shiftY !== 0) {
|
|
1947
|
+
parallax.push({
|
|
1948
|
+
parameter: StandardParameter.AngleY,
|
|
1949
|
+
channel: "translateY",
|
|
1950
|
+
from: -shiftY,
|
|
1951
|
+
to: shiftY
|
|
1952
|
+
});
|
|
1953
|
+
}
|
|
1954
|
+
return parallax;
|
|
1878
1955
|
}
|
|
1879
1956
|
return [];
|
|
1880
1957
|
}
|
|
@@ -1897,9 +1974,62 @@ function bakeEyelidFoldWarp(mesh, parameter, creaseOffsetY, k) {
|
|
|
1897
1974
|
]
|
|
1898
1975
|
};
|
|
1899
1976
|
}
|
|
1977
|
+
var HAIR_BACK_BEND_RADIUS_FACTOR = 2.5;
|
|
1978
|
+
var HAIR_BACK_FAR_BULGE = 0.22;
|
|
1979
|
+
function bakeHairBackTurnWarp(mesh, parameter) {
|
|
1980
|
+
let left = Infinity;
|
|
1981
|
+
let right = -Infinity;
|
|
1982
|
+
for (let i = 0; i < mesh.vertices.length; i += 2) {
|
|
1983
|
+
left = Math.min(left, mesh.vertices[i]);
|
|
1984
|
+
right = Math.max(right, mesh.vertices[i]);
|
|
1985
|
+
}
|
|
1986
|
+
const halfWidth = (right - left) / 2;
|
|
1987
|
+
const radius = halfWidth * HAIR_BACK_BEND_RADIUS_FACTOR;
|
|
1988
|
+
const bulge = HAIR_BACK_FAR_BULGE * halfWidth;
|
|
1989
|
+
const DEG_TO_RAD = Math.PI / 180;
|
|
1990
|
+
const keyforms = [-HEAD_TURN_MAX_DEG, 0, HEAD_TURN_MAX_DEG].map((deg) => {
|
|
1991
|
+
const theta = deg * DEG_TO_RAD;
|
|
1992
|
+
const s = Math.sign(deg);
|
|
1993
|
+
const offsets = [];
|
|
1994
|
+
for (let i = 0; i < mesh.vertices.length; i += 2) {
|
|
1995
|
+
const x = mesh.vertices[i];
|
|
1996
|
+
const far = Math.max(0, -s * x / halfWidth);
|
|
1997
|
+
offsets.push(pinnedCylinderBend(x, radius, theta) - s * bulge * far, 0);
|
|
1998
|
+
}
|
|
1999
|
+
return { value: deg, offsets };
|
|
2000
|
+
});
|
|
2001
|
+
return { parameter, keyforms };
|
|
2002
|
+
}
|
|
2003
|
+
var HAIR_SWAY_TIP_FRACTION = 0.09;
|
|
2004
|
+
var HAIR_SWAY_CURL = 1.5;
|
|
2005
|
+
var HAIR_SWAY_RANGE = 20;
|
|
2006
|
+
var HAIR_SWAY_PEAK_FRACTION = 0.56;
|
|
2007
|
+
function bakeHairSwayWarp(mesh, parameter, tipShift, range) {
|
|
2008
|
+
let top = -Infinity;
|
|
2009
|
+
let bottom = Infinity;
|
|
2010
|
+
for (let i = 1; i < mesh.vertices.length; i += 2) {
|
|
2011
|
+
top = Math.max(top, mesh.vertices[i]);
|
|
2012
|
+
bottom = Math.min(bottom, mesh.vertices[i]);
|
|
2013
|
+
}
|
|
2014
|
+
const height = top - bottom;
|
|
2015
|
+
const swing = [];
|
|
2016
|
+
for (let i = 0; i < mesh.vertices.length; i += 2) {
|
|
2017
|
+
const u = height > 0 ? (top - mesh.vertices[i + 1]) / height : 0;
|
|
2018
|
+
swing.push(tipShift * Math.pow(u, HAIR_SWAY_CURL), 0);
|
|
2019
|
+
}
|
|
2020
|
+
return {
|
|
2021
|
+
parameter,
|
|
2022
|
+
keyforms: [
|
|
2023
|
+
// `0 - v`, not `-v`: the pinned root row must stay a plain +0.
|
|
2024
|
+
{ value: -range, offsets: swing.map((v) => 0 - v) },
|
|
2025
|
+
{ value: range, offsets: swing }
|
|
2026
|
+
]
|
|
2027
|
+
};
|
|
2028
|
+
}
|
|
1900
2029
|
function generateIkiFromLayerSet(layers, canvas) {
|
|
1901
2030
|
validateLayerInputs(layers, canvas);
|
|
1902
2031
|
const hasHair = layers.some((l) => l.role === "hair_front");
|
|
2032
|
+
const hasMouthOpen = layers.some((l) => l.role === "mouth_open");
|
|
1903
2033
|
const parameters = [
|
|
1904
2034
|
{
|
|
1905
2035
|
id: StandardParameter.MouthOpen,
|
|
@@ -1950,6 +2080,20 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
1950
2080
|
max: 30,
|
|
1951
2081
|
default: 0
|
|
1952
2082
|
},
|
|
2083
|
+
{
|
|
2084
|
+
id: StandardParameter.AngleY,
|
|
2085
|
+
name: "Head Angle Y",
|
|
2086
|
+
min: -30,
|
|
2087
|
+
max: 30,
|
|
2088
|
+
default: 0
|
|
2089
|
+
},
|
|
2090
|
+
{
|
|
2091
|
+
id: StandardParameter.AngleZ,
|
|
2092
|
+
name: "Head Angle Z",
|
|
2093
|
+
min: -30,
|
|
2094
|
+
max: 30,
|
|
2095
|
+
default: 0
|
|
2096
|
+
},
|
|
1953
2097
|
{
|
|
1954
2098
|
id: StandardParameter.Breath,
|
|
1955
2099
|
name: "Breath",
|
|
@@ -1987,13 +2131,22 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
1987
2131
|
}
|
|
1988
2132
|
];
|
|
1989
2133
|
if (hasHair) {
|
|
1990
|
-
parameters.push(
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
2134
|
+
parameters.push(
|
|
2135
|
+
{
|
|
2136
|
+
id: StandardParameter.HairSwayX,
|
|
2137
|
+
name: "Hair Sway X",
|
|
2138
|
+
min: -HAIR_SWAY_RANGE,
|
|
2139
|
+
max: HAIR_SWAY_RANGE,
|
|
2140
|
+
default: 0
|
|
2141
|
+
},
|
|
2142
|
+
{
|
|
2143
|
+
id: StandardParameter.HairSwayZ,
|
|
2144
|
+
name: "Hair Sway Z",
|
|
2145
|
+
min: -HAIR_SWAY_RANGE,
|
|
2146
|
+
max: HAIR_SWAY_RANGE,
|
|
2147
|
+
default: 0
|
|
2148
|
+
}
|
|
2149
|
+
);
|
|
1997
2150
|
}
|
|
1998
2151
|
const faceLayers = layers.filter((l) => l.role === "face");
|
|
1999
2152
|
const faceLayer = faceLayers[0];
|
|
@@ -2051,16 +2204,22 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2051
2204
|
unionMaxY
|
|
2052
2205
|
)
|
|
2053
2206
|
};
|
|
2207
|
+
const faceCenterY = faceTransform.y;
|
|
2208
|
+
const halfH = Math.max(faceCenterY - unionMinY, unionMaxY - faceCenterY);
|
|
2209
|
+
const parallaxUnit = headTurnParallaxUnit(halfW);
|
|
2210
|
+
const parallaxUnitY = headTurnParallaxUnit(halfH);
|
|
2054
2211
|
const faceBottom = faceTransform.y - faceCropH / 2;
|
|
2055
2212
|
const neckPivot = {
|
|
2056
2213
|
x: faceCenterX,
|
|
2057
2214
|
y: faceBottom - faceCropH * 0.15
|
|
2058
2215
|
// 15% below face bottom = neck
|
|
2059
2216
|
};
|
|
2060
|
-
const
|
|
2217
|
+
const faceWarp2d = bakeHeadTurnGridWarp2DCentered(
|
|
2061
2218
|
faceGrid,
|
|
2062
2219
|
StandardParameter.AngleX,
|
|
2063
|
-
|
|
2220
|
+
StandardParameter.AngleY,
|
|
2221
|
+
faceCenterX,
|
|
2222
|
+
faceCenterY
|
|
2064
2223
|
);
|
|
2065
2224
|
const deformers = [
|
|
2066
2225
|
// headDeformer: rigid matrix rotating/translating the whole head about the
|
|
@@ -2081,6 +2240,27 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2081
2240
|
from: -50,
|
|
2082
2241
|
to: 50
|
|
2083
2242
|
},
|
|
2243
|
+
// Nod: a vertical translate only. No rotate — a pitch expressed as a
|
|
2244
|
+
// rigid rotation would sum with the roll below at diagonal poses,
|
|
2245
|
+
// collapsing pitch into roll.
|
|
2246
|
+
{
|
|
2247
|
+
parameter: StandardParameter.AngleY,
|
|
2248
|
+
channel: "translateY",
|
|
2249
|
+
from: -NOD_TRAVEL,
|
|
2250
|
+
to: NOD_TRAVEL
|
|
2251
|
+
},
|
|
2252
|
+
// Tilt: the whole head rolls about the neck pivot, one degree per
|
|
2253
|
+
// degree. Positive AngleZ is clockwise on screen (engine rotate is
|
|
2254
|
+
// CCW-positive, hence the flipped from/to) — Live2D's convention, and
|
|
2255
|
+
// the same sense as the lean above: in Live2D's own sample motions
|
|
2256
|
+
// AngleZ carries the sign of AngleX 214 times out of 222. The two
|
|
2257
|
+
// rotations sum, which is correct for two rolls about one pivot.
|
|
2258
|
+
{
|
|
2259
|
+
parameter: StandardParameter.AngleZ,
|
|
2260
|
+
channel: "rotate",
|
|
2261
|
+
from: 30,
|
|
2262
|
+
to: -30
|
|
2263
|
+
},
|
|
2084
2264
|
{
|
|
2085
2265
|
parameter: StandardParameter.Breath,
|
|
2086
2266
|
channel: "translateY",
|
|
@@ -2091,12 +2271,14 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2091
2271
|
},
|
|
2092
2272
|
// faceWarp: cylinder-bend warp parented to headDeformer; grid is symmetric
|
|
2093
2273
|
// about faceCenterX so the bake's cylinder axis aligns with the face center.
|
|
2274
|
+
// One 2D warp carries both the turn and the nod (a deformer holds either
|
|
2275
|
+
// `warps` or `warp2d`, never both).
|
|
2094
2276
|
{
|
|
2095
2277
|
kind: "warp",
|
|
2096
2278
|
id: "faceWarp",
|
|
2097
2279
|
parent: "headDeformer",
|
|
2098
2280
|
grid: faceGrid,
|
|
2099
|
-
|
|
2281
|
+
warp2d: faceWarp2d
|
|
2100
2282
|
}
|
|
2101
2283
|
];
|
|
2102
2284
|
const eyeCreaseBySide = {};
|
|
@@ -2116,7 +2298,12 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2116
2298
|
const { role, bbox, cropW, cropH, canvasW, canvasH } = layer;
|
|
2117
2299
|
const spec = ROLE_TABLE[role];
|
|
2118
2300
|
const t = bboxToTransform(bbox, canvasW, canvasH, role);
|
|
2119
|
-
const roleBindings = bindingsForRole(spec, role, cropW, cropH
|
|
2301
|
+
const roleBindings = bindingsForRole(spec, role, cropW, cropH, {
|
|
2302
|
+
hasMouthOpen,
|
|
2303
|
+
parallaxUnit,
|
|
2304
|
+
parallaxUnitY
|
|
2305
|
+
});
|
|
2306
|
+
const deformerId = spec.deformer === "none" ? void 0 : spec.deformer;
|
|
2120
2307
|
if (spec.mesh) {
|
|
2121
2308
|
const mesh = createPixelGridMesh(4, 4, cropW, cropH);
|
|
2122
2309
|
const part = {
|
|
@@ -2126,23 +2313,58 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2126
2313
|
height: 1,
|
|
2127
2314
|
order: spec.order,
|
|
2128
2315
|
transform: t,
|
|
2129
|
-
deformer:
|
|
2316
|
+
deformer: deformerId,
|
|
2130
2317
|
mesh
|
|
2131
2318
|
};
|
|
2132
2319
|
if (roleBindings.length > 0) {
|
|
2133
2320
|
part.bindings = roleBindings;
|
|
2134
2321
|
}
|
|
2322
|
+
if ((role === "hair_front" || role === "hair_back") && hasHair) {
|
|
2323
|
+
let tipShift = HAIR_SWAY_TIP_FRACTION * cropH;
|
|
2324
|
+
if (role === "hair_front") {
|
|
2325
|
+
const shift = HAIR_FRONT_DEPTH * parallaxUnit;
|
|
2326
|
+
const headroom = Math.min(
|
|
2327
|
+
faceGridMaxX - (t.x + cropW / 2),
|
|
2328
|
+
t.x - cropW / 2 - faceGridMinX
|
|
2329
|
+
) - shift;
|
|
2330
|
+
tipShift = Math.min(
|
|
2331
|
+
tipShift,
|
|
2332
|
+
Math.max(0, headroom) / HAIR_SWAY_PEAK_FRACTION
|
|
2333
|
+
);
|
|
2334
|
+
}
|
|
2335
|
+
part.warps = [
|
|
2336
|
+
bakeHairSwayWarp(
|
|
2337
|
+
mesh,
|
|
2338
|
+
StandardParameter.HairSwayX,
|
|
2339
|
+
tipShift,
|
|
2340
|
+
HAIR_SWAY_RANGE
|
|
2341
|
+
),
|
|
2342
|
+
bakeHairSwayWarp(
|
|
2343
|
+
mesh,
|
|
2344
|
+
StandardParameter.HairSwayZ,
|
|
2345
|
+
tipShift,
|
|
2346
|
+
HAIR_SWAY_RANGE
|
|
2347
|
+
)
|
|
2348
|
+
];
|
|
2349
|
+
}
|
|
2350
|
+
if (role === "hair_back") {
|
|
2351
|
+
part.warps = [
|
|
2352
|
+
...part.warps ?? [],
|
|
2353
|
+
bakeHairBackTurnWarp(mesh, StandardParameter.AngleX)
|
|
2354
|
+
];
|
|
2355
|
+
}
|
|
2135
2356
|
if (spec.eyeSide !== void 0) {
|
|
2136
2357
|
const isLash = role.startsWith("lash_");
|
|
2137
2358
|
if (role.startsWith("eye_") || isLash) {
|
|
2138
2359
|
const openParam = spec.eyeSide === "L" ? StandardParameter.EyeOpenLeft : StandardParameter.EyeOpenRight;
|
|
2139
2360
|
const creaseWorldY = eyeCreaseBySide[spec.eyeSide] ?? t.y - EYELID_FOLD_CREASE * cropH;
|
|
2361
|
+
const hasLash = layers.some((l) => l.role === `lash_${spec.eyeSide}`);
|
|
2140
2362
|
part.warps = [
|
|
2141
2363
|
bakeEyelidFoldWarp(
|
|
2142
2364
|
mesh,
|
|
2143
2365
|
openParam,
|
|
2144
2366
|
creaseWorldY - t.y,
|
|
2145
|
-
isLash ? LASH_FOLD_K : EYELID_FOLD_K
|
|
2367
|
+
isLash ? LASH_FOLD_K : hasLash ? 0 : EYELID_FOLD_K
|
|
2146
2368
|
)
|
|
2147
2369
|
];
|
|
2148
2370
|
} else {
|
|
@@ -2151,15 +2373,19 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2151
2373
|
}
|
|
2152
2374
|
return part;
|
|
2153
2375
|
} else {
|
|
2154
|
-
|
|
2376
|
+
const part = {
|
|
2155
2377
|
id: role,
|
|
2156
2378
|
color: [1, 1, 1, 1],
|
|
2157
2379
|
width: cropW,
|
|
2158
2380
|
height: cropH,
|
|
2159
2381
|
order: spec.order,
|
|
2160
2382
|
transform: t,
|
|
2161
|
-
deformer:
|
|
2383
|
+
deformer: deformerId
|
|
2162
2384
|
};
|
|
2385
|
+
if (roleBindings.length > 0) {
|
|
2386
|
+
part.bindings = roleBindings;
|
|
2387
|
+
}
|
|
2388
|
+
return part;
|
|
2163
2389
|
}
|
|
2164
2390
|
});
|
|
2165
2391
|
const model = {
|
|
@@ -2170,9 +2396,12 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2170
2396
|
parameters,
|
|
2171
2397
|
deformers,
|
|
2172
2398
|
parts,
|
|
2173
|
-
// Secondary motion:
|
|
2174
|
-
// behind the head turn (same constants as the hand-authored sample)
|
|
2175
|
-
//
|
|
2399
|
+
// Secondary motion: one spring lags AngleX onto HairSwayX so front hair
|
|
2400
|
+
// sways behind the head turn (same constants as the hand-authored sample),
|
|
2401
|
+
// and a second lags AngleZ onto HairSwayZ so it swings behind a tilt. Two
|
|
2402
|
+
// rigs because a rig has one input and one output (validator-enforced).
|
|
2403
|
+
// Both hair parts read the outputs through root-pinned sway warps.
|
|
2404
|
+
// Omitted when there is no front hair to drive.
|
|
2176
2405
|
physics: hasHair ? [
|
|
2177
2406
|
{
|
|
2178
2407
|
id: "hairSway",
|
|
@@ -2181,6 +2410,14 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2181
2410
|
mass: 1,
|
|
2182
2411
|
stiffness: 80,
|
|
2183
2412
|
damping: 10
|
|
2413
|
+
},
|
|
2414
|
+
{
|
|
2415
|
+
id: "hairTilt",
|
|
2416
|
+
input: { parameter: StandardParameter.AngleZ, weight: 1 },
|
|
2417
|
+
output: { parameter: StandardParameter.HairSwayZ, scale: -10 },
|
|
2418
|
+
mass: 1,
|
|
2419
|
+
stiffness: 80,
|
|
2420
|
+
damping: 10
|
|
2184
2421
|
}
|
|
2185
2422
|
] : void 0
|
|
2186
2423
|
};
|