@ikijs/editor 0.1.0 → 0.2.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 +3 -3
- package/dist/index.js +285 -59
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +285 -59
- package/dist/index.mjs.map +1 -1
- package/package.json +17 -3
package/README.md
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
# @ikijs/editor
|
|
2
2
|
|
|
3
|
-
**Headless** editing core for [`.iki`](
|
|
3
|
+
**Headless** editing core for [`.iki`](https://github.com/zeikar/iki/tree/main/packages/format) models — a document with
|
|
4
4
|
undo/redo, invertible edit commands, atlas layout + UV math, and the auto-rigger.
|
|
5
5
|
|
|
6
6
|
This package ships no UI. It is the model and command layer an editor is built
|
|
7
7
|
_on_, not an editor you can mount.
|
|
8
8
|
|
|
9
9
|
Everything here is pure logic — no DOM, no canvas, no WebGL. It depends only on
|
|
10
|
-
[`@ikijs/format`](
|
|
10
|
+
[`@ikijs/format`](https://github.com/zeikar/iki/tree/main/packages/format), so the same core backs this repo's
|
|
11
11
|
browser editor app (`examples/editor`), the Node MCP server
|
|
12
|
-
([`@ikijs/mcp`](
|
|
12
|
+
([`@ikijs/mcp`](https://github.com/zeikar/iki/tree/main/packages/mcp)), and tests.
|
|
13
13
|
|
|
14
14
|
## Install
|
|
15
15
|
|
package/dist/index.js
CHANGED
|
@@ -1663,14 +1663,25 @@ function createDefaultWarpDeformer(model) {
|
|
|
1663
1663
|
// src/auto-rig.ts
|
|
1664
1664
|
var import_format3 = require("@ikijs/format");
|
|
1665
1665
|
var ROLE_TABLE = {
|
|
1666
|
-
//
|
|
1667
|
-
//
|
|
1668
|
-
|
|
1666
|
+
// The silhouette behind the face. It rides the rigid head (not faceWarp,
|
|
1667
|
+
// whose grid it would stretch across the shoulders) as a MESH: it bends on
|
|
1668
|
+
// the turn through its own part warp (bakeHairBackTurnWarp) and swings its
|
|
1669
|
+
// ends on the hair-sway warps.
|
|
1670
|
+
hair_back: { deformer: "headDeformer", order: 0, mesh: true },
|
|
1671
|
+
// The torso. Alone among the roles it hangs from NO deformer: the head turns
|
|
1672
|
+
// about the neck pivot while the shoulders stay put, which is what keeps the
|
|
1673
|
+
// character from reading as a floating head. Drawn over the back hair so long
|
|
1674
|
+
// hair falls behind the shoulders.
|
|
1675
|
+
body: { deformer: "none", order: 5, mesh: false },
|
|
1669
1676
|
face: { deformer: "faceWarp", order: 10, mesh: true },
|
|
1670
1677
|
nose: { deformer: "faceWarp", order: 15, mesh: true },
|
|
1671
1678
|
blush_L: { deformer: "faceWarp", order: 20, mesh: true },
|
|
1672
1679
|
blush_R: { deformer: "faceWarp", order: 20, mesh: true },
|
|
1673
1680
|
mouth: { deformer: "faceWarp", order: 25, mesh: true },
|
|
1681
|
+
// An OPTIONAL second mouth drawing, open. When present the two cross-fade on
|
|
1682
|
+
// MouthOpen instead of the closed one being stretched, which is the
|
|
1683
|
+
// difference between a portrait rig and one that can lip-sync.
|
|
1684
|
+
mouth_open: { deformer: "faceWarp", order: 26, mesh: true },
|
|
1674
1685
|
eye_L: { deformer: "faceWarp", order: 30, mesh: true, eyeSide: "L" },
|
|
1675
1686
|
eye_R: { deformer: "faceWarp", order: 30, mesh: true, eyeSide: "R" },
|
|
1676
1687
|
iris_L: { deformer: "faceWarp", order: 31, mesh: true, eyeSide: "L" },
|
|
@@ -1822,29 +1833,63 @@ function createPixelGridMesh(cols, rows, w, h) {
|
|
|
1822
1833
|
}
|
|
1823
1834
|
return { vertices, uvs, indices };
|
|
1824
1835
|
}
|
|
1825
|
-
|
|
1826
|
-
|
|
1836
|
+
var HEAD_CYLINDER_RADIUS_FACTOR = 0.6 / 0.5;
|
|
1837
|
+
var HEAD_TURN_MAX_DEG = 30;
|
|
1838
|
+
function headTurnParallaxUnit(gridHalfWidth) {
|
|
1839
|
+
return gridHalfWidth * HEAD_CYLINDER_RADIUS_FACTOR * Math.sin(HEAD_TURN_MAX_DEG * (Math.PI / 180));
|
|
1840
|
+
}
|
|
1841
|
+
function pinnedCylinderBend(local, radius, theta) {
|
|
1842
|
+
const alpha = Math.asin(Math.max(-1, Math.min(1, local / radius)));
|
|
1843
|
+
return radius * Math.sin(alpha + theta) - local - radius * Math.sin(theta);
|
|
1844
|
+
}
|
|
1845
|
+
function bakeHeadTurnGridWarp2DCentered(grid, parameterX, parameterY, centerX, centerY) {
|
|
1846
|
+
const STOPS = [-HEAD_TURN_MAX_DEG, 0, HEAD_TURN_MAX_DEG];
|
|
1827
1847
|
const halfWidth = (grid.points[grid.cols * 2] - grid.points[0]) / 2;
|
|
1828
|
-
const
|
|
1848
|
+
const RADIUS_X = halfWidth * HEAD_CYLINDER_RADIUS_FACTOR;
|
|
1829
1849
|
const pointCount = grid.points.length / 2;
|
|
1850
|
+
let halfHeight = 0;
|
|
1851
|
+
for (let i = 0; i < pointCount; i++) {
|
|
1852
|
+
halfHeight = Math.max(
|
|
1853
|
+
halfHeight,
|
|
1854
|
+
Math.abs(grid.points[i * 2 + 1] - centerY)
|
|
1855
|
+
);
|
|
1856
|
+
}
|
|
1857
|
+
const RADIUS_Y = halfHeight * HEAD_CYLINDER_RADIUS_FACTOR;
|
|
1830
1858
|
const DEG_TO_RAD = Math.PI / 180;
|
|
1831
|
-
const
|
|
1832
|
-
|
|
1833
|
-
const
|
|
1834
|
-
for (
|
|
1835
|
-
const
|
|
1836
|
-
const
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1859
|
+
const keyforms2d = [];
|
|
1860
|
+
for (const angleY of STOPS) {
|
|
1861
|
+
const thetaY = angleY * NOD_BEND * DEG_TO_RAD;
|
|
1862
|
+
for (const angleX of STOPS) {
|
|
1863
|
+
const thetaX = angleX * DEG_TO_RAD;
|
|
1864
|
+
const offsets = [];
|
|
1865
|
+
for (let i = 0; i < pointCount; i++) {
|
|
1866
|
+
offsets.push(
|
|
1867
|
+
pinnedCylinderBend(grid.points[i * 2] - centerX, RADIUS_X, thetaX),
|
|
1868
|
+
pinnedCylinderBend(
|
|
1869
|
+
grid.points[i * 2 + 1] - centerY,
|
|
1870
|
+
RADIUS_Y,
|
|
1871
|
+
thetaY
|
|
1872
|
+
)
|
|
1873
|
+
);
|
|
1874
|
+
}
|
|
1875
|
+
keyforms2d.push({ offsets });
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
return {
|
|
1879
|
+
parameter: parameterX,
|
|
1880
|
+
parameterY,
|
|
1881
|
+
valuesX: STOPS,
|
|
1882
|
+
valuesY: STOPS,
|
|
1883
|
+
keyforms2d
|
|
1884
|
+
};
|
|
1845
1885
|
}
|
|
1846
1886
|
var EYE_STACK_PREFIXES = ["eye_", "iris_", "pupil_", "highlight_"];
|
|
1847
|
-
|
|
1887
|
+
var HAIR_FRONT_DEPTH = 0.1;
|
|
1888
|
+
var HAIR_BACK_DEPTH = -0.08;
|
|
1889
|
+
var NOD_TRAVEL = 30;
|
|
1890
|
+
var NOD_BEND = 0.5;
|
|
1891
|
+
var HAIR_BACK_NOD_DEPTH = -0.07;
|
|
1892
|
+
function bindingsForRole(spec, role, cropW, cropH, options = {}) {
|
|
1848
1893
|
const isEyeStack = EYE_STACK_PREFIXES.some((p) => role.startsWith(p));
|
|
1849
1894
|
if (isEyeStack && spec.eyeSide !== void 0) {
|
|
1850
1895
|
const isGazeRole = role.startsWith("iris_") || role.startsWith("pupil_") || role.startsWith("highlight_");
|
|
@@ -1866,22 +1911,47 @@ function bindingsForRole(spec, role, cropW, cropH) {
|
|
|
1866
1911
|
}
|
|
1867
1912
|
];
|
|
1868
1913
|
}
|
|
1914
|
+
const mouthForm = {
|
|
1915
|
+
// Mouth form: scaleX from -0.2 (pursed, param=-1) to 0.4 (wide, param=1).
|
|
1916
|
+
parameter: import_format3.StandardParameter.MouthForm,
|
|
1917
|
+
channel: "scaleX",
|
|
1918
|
+
from: -0.2,
|
|
1919
|
+
to: 0.4
|
|
1920
|
+
};
|
|
1869
1921
|
if (role === "mouth") {
|
|
1922
|
+
if (options.hasMouthOpen) {
|
|
1923
|
+
return [
|
|
1924
|
+
{
|
|
1925
|
+
parameter: import_format3.StandardParameter.MouthOpen,
|
|
1926
|
+
channel: "opacity",
|
|
1927
|
+
from: 1,
|
|
1928
|
+
to: 0
|
|
1929
|
+
},
|
|
1930
|
+
mouthForm
|
|
1931
|
+
];
|
|
1932
|
+
}
|
|
1870
1933
|
return [
|
|
1871
1934
|
// Mouth open: scaleY from 0 (closed, param=0) to 3 (wide open, param=1).
|
|
1935
|
+
// Only a fallback — it distorts, but it is better than a mouth that
|
|
1936
|
+
// cannot open at all when the layer set has no open drawing.
|
|
1872
1937
|
{
|
|
1873
1938
|
parameter: import_format3.StandardParameter.MouthOpen,
|
|
1874
1939
|
channel: "scaleY",
|
|
1875
1940
|
from: 0,
|
|
1876
1941
|
to: 3
|
|
1877
1942
|
},
|
|
1878
|
-
|
|
1943
|
+
mouthForm
|
|
1944
|
+
];
|
|
1945
|
+
}
|
|
1946
|
+
if (role === "mouth_open") {
|
|
1947
|
+
return [
|
|
1879
1948
|
{
|
|
1880
|
-
parameter: import_format3.StandardParameter.
|
|
1881
|
-
channel: "
|
|
1882
|
-
from:
|
|
1883
|
-
to:
|
|
1884
|
-
}
|
|
1949
|
+
parameter: import_format3.StandardParameter.MouthOpen,
|
|
1950
|
+
channel: "opacity",
|
|
1951
|
+
from: 0,
|
|
1952
|
+
to: 1
|
|
1953
|
+
},
|
|
1954
|
+
mouthForm
|
|
1885
1955
|
];
|
|
1886
1956
|
}
|
|
1887
1957
|
if (role === "brow_L" || role === "brow_R") {
|
|
@@ -1919,21 +1989,29 @@ function bindingsForRole(spec, role, cropW, cropH) {
|
|
|
1919
1989
|
];
|
|
1920
1990
|
}
|
|
1921
1991
|
}
|
|
1922
|
-
if (role === "hair_front") {
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
{
|
|
1931
|
-
parameter: import_format3.StandardParameter.HairSwayX,
|
|
1992
|
+
if (role === "hair_front" || role === "hair_back") {
|
|
1993
|
+
const isFront = role === "hair_front";
|
|
1994
|
+
const depth = isFront ? HAIR_FRONT_DEPTH : HAIR_BACK_DEPTH;
|
|
1995
|
+
const parallax = [];
|
|
1996
|
+
const shiftX = depth * (options.parallaxUnit ?? 0);
|
|
1997
|
+
if (shiftX !== 0) {
|
|
1998
|
+
parallax.push({
|
|
1999
|
+
parameter: import_format3.StandardParameter.AngleX,
|
|
1932
2000
|
channel: "translateX",
|
|
1933
|
-
from: -
|
|
1934
|
-
to:
|
|
1935
|
-
}
|
|
1936
|
-
|
|
2001
|
+
from: -shiftX,
|
|
2002
|
+
to: shiftX
|
|
2003
|
+
});
|
|
2004
|
+
}
|
|
2005
|
+
const shiftY = (isFront ? 0 : HAIR_BACK_NOD_DEPTH) * (options.parallaxUnitY ?? 0);
|
|
2006
|
+
if (shiftY !== 0) {
|
|
2007
|
+
parallax.push({
|
|
2008
|
+
parameter: import_format3.StandardParameter.AngleY,
|
|
2009
|
+
channel: "translateY",
|
|
2010
|
+
from: -shiftY,
|
|
2011
|
+
to: shiftY
|
|
2012
|
+
});
|
|
2013
|
+
}
|
|
2014
|
+
return parallax;
|
|
1937
2015
|
}
|
|
1938
2016
|
return [];
|
|
1939
2017
|
}
|
|
@@ -1956,9 +2034,61 @@ function bakeEyelidFoldWarp(mesh, parameter, creaseOffsetY, k) {
|
|
|
1956
2034
|
]
|
|
1957
2035
|
};
|
|
1958
2036
|
}
|
|
2037
|
+
var HAIR_BACK_BEND_RADIUS_FACTOR = 2.5;
|
|
2038
|
+
var HAIR_BACK_FAR_BULGE = 0.22;
|
|
2039
|
+
function bakeHairBackTurnWarp(mesh, parameter) {
|
|
2040
|
+
let left = Infinity;
|
|
2041
|
+
let right = -Infinity;
|
|
2042
|
+
for (let i = 0; i < mesh.vertices.length; i += 2) {
|
|
2043
|
+
left = Math.min(left, mesh.vertices[i]);
|
|
2044
|
+
right = Math.max(right, mesh.vertices[i]);
|
|
2045
|
+
}
|
|
2046
|
+
const halfWidth = (right - left) / 2;
|
|
2047
|
+
const radius = halfWidth * HAIR_BACK_BEND_RADIUS_FACTOR;
|
|
2048
|
+
const bulge = HAIR_BACK_FAR_BULGE * halfWidth;
|
|
2049
|
+
const DEG_TO_RAD = Math.PI / 180;
|
|
2050
|
+
const keyforms = [-HEAD_TURN_MAX_DEG, 0, HEAD_TURN_MAX_DEG].map((deg) => {
|
|
2051
|
+
const theta = deg * DEG_TO_RAD;
|
|
2052
|
+
const s = Math.sign(deg);
|
|
2053
|
+
const offsets = [];
|
|
2054
|
+
for (let i = 0; i < mesh.vertices.length; i += 2) {
|
|
2055
|
+
const x = mesh.vertices[i];
|
|
2056
|
+
const far = Math.max(0, -s * x / halfWidth);
|
|
2057
|
+
offsets.push(pinnedCylinderBend(x, radius, theta) - s * bulge * far, 0);
|
|
2058
|
+
}
|
|
2059
|
+
return { value: deg, offsets };
|
|
2060
|
+
});
|
|
2061
|
+
return { parameter, keyforms };
|
|
2062
|
+
}
|
|
2063
|
+
var HAIR_SWAY_TIP_FRACTION = 0.09;
|
|
2064
|
+
var HAIR_SWAY_CURL = 1.5;
|
|
2065
|
+
var HAIR_SWAY_RANGE = 20;
|
|
2066
|
+
function bakeHairSwayWarp(mesh, parameter, tipShift, range) {
|
|
2067
|
+
let top = -Infinity;
|
|
2068
|
+
let bottom = Infinity;
|
|
2069
|
+
for (let i = 1; i < mesh.vertices.length; i += 2) {
|
|
2070
|
+
top = Math.max(top, mesh.vertices[i]);
|
|
2071
|
+
bottom = Math.min(bottom, mesh.vertices[i]);
|
|
2072
|
+
}
|
|
2073
|
+
const height = top - bottom;
|
|
2074
|
+
const swing = [];
|
|
2075
|
+
for (let i = 0; i < mesh.vertices.length; i += 2) {
|
|
2076
|
+
const u = height > 0 ? (top - mesh.vertices[i + 1]) / height : 0;
|
|
2077
|
+
swing.push(tipShift * Math.pow(u, HAIR_SWAY_CURL), 0);
|
|
2078
|
+
}
|
|
2079
|
+
return {
|
|
2080
|
+
parameter,
|
|
2081
|
+
keyforms: [
|
|
2082
|
+
// `0 - v`, not `-v`: the pinned root row must stay a plain +0.
|
|
2083
|
+
{ value: -range, offsets: swing.map((v) => 0 - v) },
|
|
2084
|
+
{ value: range, offsets: swing }
|
|
2085
|
+
]
|
|
2086
|
+
};
|
|
2087
|
+
}
|
|
1959
2088
|
function generateIkiFromLayerSet(layers, canvas) {
|
|
1960
2089
|
validateLayerInputs(layers, canvas);
|
|
1961
2090
|
const hasHair = layers.some((l) => l.role === "hair_front");
|
|
2091
|
+
const hasMouthOpen = layers.some((l) => l.role === "mouth_open");
|
|
1962
2092
|
const parameters = [
|
|
1963
2093
|
{
|
|
1964
2094
|
id: import_format3.StandardParameter.MouthOpen,
|
|
@@ -2009,6 +2139,20 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2009
2139
|
max: 30,
|
|
2010
2140
|
default: 0
|
|
2011
2141
|
},
|
|
2142
|
+
{
|
|
2143
|
+
id: import_format3.StandardParameter.AngleY,
|
|
2144
|
+
name: "Head Angle Y",
|
|
2145
|
+
min: -30,
|
|
2146
|
+
max: 30,
|
|
2147
|
+
default: 0
|
|
2148
|
+
},
|
|
2149
|
+
{
|
|
2150
|
+
id: import_format3.StandardParameter.AngleZ,
|
|
2151
|
+
name: "Head Angle Z",
|
|
2152
|
+
min: -30,
|
|
2153
|
+
max: 30,
|
|
2154
|
+
default: 0
|
|
2155
|
+
},
|
|
2012
2156
|
{
|
|
2013
2157
|
id: import_format3.StandardParameter.Breath,
|
|
2014
2158
|
name: "Breath",
|
|
@@ -2046,13 +2190,22 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2046
2190
|
}
|
|
2047
2191
|
];
|
|
2048
2192
|
if (hasHair) {
|
|
2049
|
-
parameters.push(
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2193
|
+
parameters.push(
|
|
2194
|
+
{
|
|
2195
|
+
id: import_format3.StandardParameter.HairSwayX,
|
|
2196
|
+
name: "Hair Sway X",
|
|
2197
|
+
min: -HAIR_SWAY_RANGE,
|
|
2198
|
+
max: HAIR_SWAY_RANGE,
|
|
2199
|
+
default: 0
|
|
2200
|
+
},
|
|
2201
|
+
{
|
|
2202
|
+
id: import_format3.StandardParameter.HairSwayZ,
|
|
2203
|
+
name: "Hair Sway Z",
|
|
2204
|
+
min: -HAIR_SWAY_RANGE,
|
|
2205
|
+
max: HAIR_SWAY_RANGE,
|
|
2206
|
+
default: 0
|
|
2207
|
+
}
|
|
2208
|
+
);
|
|
2056
2209
|
}
|
|
2057
2210
|
const faceLayers = layers.filter((l) => l.role === "face");
|
|
2058
2211
|
const faceLayer = faceLayers[0];
|
|
@@ -2110,16 +2263,22 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2110
2263
|
unionMaxY
|
|
2111
2264
|
)
|
|
2112
2265
|
};
|
|
2266
|
+
const faceCenterY = faceTransform.y;
|
|
2267
|
+
const halfH = Math.max(faceCenterY - unionMinY, unionMaxY - faceCenterY);
|
|
2268
|
+
const parallaxUnit = headTurnParallaxUnit(halfW);
|
|
2269
|
+
const parallaxUnitY = headTurnParallaxUnit(halfH);
|
|
2113
2270
|
const faceBottom = faceTransform.y - faceCropH / 2;
|
|
2114
2271
|
const neckPivot = {
|
|
2115
2272
|
x: faceCenterX,
|
|
2116
2273
|
y: faceBottom - faceCropH * 0.15
|
|
2117
2274
|
// 15% below face bottom = neck
|
|
2118
2275
|
};
|
|
2119
|
-
const
|
|
2276
|
+
const faceWarp2d = bakeHeadTurnGridWarp2DCentered(
|
|
2120
2277
|
faceGrid,
|
|
2121
2278
|
import_format3.StandardParameter.AngleX,
|
|
2122
|
-
|
|
2279
|
+
import_format3.StandardParameter.AngleY,
|
|
2280
|
+
faceCenterX,
|
|
2281
|
+
faceCenterY
|
|
2123
2282
|
);
|
|
2124
2283
|
const deformers = [
|
|
2125
2284
|
// headDeformer: rigid matrix rotating/translating the whole head about the
|
|
@@ -2140,6 +2299,27 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2140
2299
|
from: -50,
|
|
2141
2300
|
to: 50
|
|
2142
2301
|
},
|
|
2302
|
+
// Nod: a vertical translate only. No rotate — a pitch expressed as a
|
|
2303
|
+
// rigid rotation would sum with the roll below at diagonal poses,
|
|
2304
|
+
// collapsing pitch into roll.
|
|
2305
|
+
{
|
|
2306
|
+
parameter: import_format3.StandardParameter.AngleY,
|
|
2307
|
+
channel: "translateY",
|
|
2308
|
+
from: -NOD_TRAVEL,
|
|
2309
|
+
to: NOD_TRAVEL
|
|
2310
|
+
},
|
|
2311
|
+
// Tilt: the whole head rolls about the neck pivot, one degree per
|
|
2312
|
+
// degree. Positive AngleZ is clockwise on screen (engine rotate is
|
|
2313
|
+
// CCW-positive, hence the flipped from/to) — Live2D's convention, and
|
|
2314
|
+
// the same sense as the lean above: in Live2D's own sample motions
|
|
2315
|
+
// AngleZ carries the sign of AngleX 214 times out of 222. The two
|
|
2316
|
+
// rotations sum, which is correct for two rolls about one pivot.
|
|
2317
|
+
{
|
|
2318
|
+
parameter: import_format3.StandardParameter.AngleZ,
|
|
2319
|
+
channel: "rotate",
|
|
2320
|
+
from: 30,
|
|
2321
|
+
to: -30
|
|
2322
|
+
},
|
|
2143
2323
|
{
|
|
2144
2324
|
parameter: import_format3.StandardParameter.Breath,
|
|
2145
2325
|
channel: "translateY",
|
|
@@ -2150,12 +2330,14 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2150
2330
|
},
|
|
2151
2331
|
// faceWarp: cylinder-bend warp parented to headDeformer; grid is symmetric
|
|
2152
2332
|
// about faceCenterX so the bake's cylinder axis aligns with the face center.
|
|
2333
|
+
// One 2D warp carries both the turn and the nod (a deformer holds either
|
|
2334
|
+
// `warps` or `warp2d`, never both).
|
|
2153
2335
|
{
|
|
2154
2336
|
kind: "warp",
|
|
2155
2337
|
id: "faceWarp",
|
|
2156
2338
|
parent: "headDeformer",
|
|
2157
2339
|
grid: faceGrid,
|
|
2158
|
-
|
|
2340
|
+
warp2d: faceWarp2d
|
|
2159
2341
|
}
|
|
2160
2342
|
];
|
|
2161
2343
|
const eyeCreaseBySide = {};
|
|
@@ -2175,7 +2357,12 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2175
2357
|
const { role, bbox, cropW, cropH, canvasW, canvasH } = layer;
|
|
2176
2358
|
const spec = ROLE_TABLE[role];
|
|
2177
2359
|
const t = bboxToTransform(bbox, canvasW, canvasH, role);
|
|
2178
|
-
const roleBindings = bindingsForRole(spec, role, cropW, cropH
|
|
2360
|
+
const roleBindings = bindingsForRole(spec, role, cropW, cropH, {
|
|
2361
|
+
hasMouthOpen,
|
|
2362
|
+
parallaxUnit,
|
|
2363
|
+
parallaxUnitY
|
|
2364
|
+
});
|
|
2365
|
+
const deformerId = spec.deformer === "none" ? void 0 : spec.deformer;
|
|
2179
2366
|
if (spec.mesh) {
|
|
2180
2367
|
const mesh = createPixelGridMesh(4, 4, cropW, cropH);
|
|
2181
2368
|
const part = {
|
|
@@ -2185,23 +2372,47 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2185
2372
|
height: 1,
|
|
2186
2373
|
order: spec.order,
|
|
2187
2374
|
transform: t,
|
|
2188
|
-
deformer:
|
|
2375
|
+
deformer: deformerId,
|
|
2189
2376
|
mesh
|
|
2190
2377
|
};
|
|
2191
2378
|
if (roleBindings.length > 0) {
|
|
2192
2379
|
part.bindings = roleBindings;
|
|
2193
2380
|
}
|
|
2381
|
+
if ((role === "hair_front" || role === "hair_back") && hasHair) {
|
|
2382
|
+
const tipShift = HAIR_SWAY_TIP_FRACTION * cropH;
|
|
2383
|
+
part.warps = [
|
|
2384
|
+
bakeHairSwayWarp(
|
|
2385
|
+
mesh,
|
|
2386
|
+
import_format3.StandardParameter.HairSwayX,
|
|
2387
|
+
tipShift,
|
|
2388
|
+
HAIR_SWAY_RANGE
|
|
2389
|
+
),
|
|
2390
|
+
bakeHairSwayWarp(
|
|
2391
|
+
mesh,
|
|
2392
|
+
import_format3.StandardParameter.HairSwayZ,
|
|
2393
|
+
tipShift,
|
|
2394
|
+
HAIR_SWAY_RANGE
|
|
2395
|
+
)
|
|
2396
|
+
];
|
|
2397
|
+
}
|
|
2398
|
+
if (role === "hair_back") {
|
|
2399
|
+
part.warps = [
|
|
2400
|
+
...part.warps ?? [],
|
|
2401
|
+
bakeHairBackTurnWarp(mesh, import_format3.StandardParameter.AngleX)
|
|
2402
|
+
];
|
|
2403
|
+
}
|
|
2194
2404
|
if (spec.eyeSide !== void 0) {
|
|
2195
2405
|
const isLash = role.startsWith("lash_");
|
|
2196
2406
|
if (role.startsWith("eye_") || isLash) {
|
|
2197
2407
|
const openParam = spec.eyeSide === "L" ? import_format3.StandardParameter.EyeOpenLeft : import_format3.StandardParameter.EyeOpenRight;
|
|
2198
2408
|
const creaseWorldY = eyeCreaseBySide[spec.eyeSide] ?? t.y - EYELID_FOLD_CREASE * cropH;
|
|
2409
|
+
const hasLash = layers.some((l) => l.role === `lash_${spec.eyeSide}`);
|
|
2199
2410
|
part.warps = [
|
|
2200
2411
|
bakeEyelidFoldWarp(
|
|
2201
2412
|
mesh,
|
|
2202
2413
|
openParam,
|
|
2203
2414
|
creaseWorldY - t.y,
|
|
2204
|
-
isLash ? LASH_FOLD_K : EYELID_FOLD_K
|
|
2415
|
+
isLash ? LASH_FOLD_K : hasLash ? 0 : EYELID_FOLD_K
|
|
2205
2416
|
)
|
|
2206
2417
|
];
|
|
2207
2418
|
} else {
|
|
@@ -2210,15 +2421,19 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2210
2421
|
}
|
|
2211
2422
|
return part;
|
|
2212
2423
|
} else {
|
|
2213
|
-
|
|
2424
|
+
const part = {
|
|
2214
2425
|
id: role,
|
|
2215
2426
|
color: [1, 1, 1, 1],
|
|
2216
2427
|
width: cropW,
|
|
2217
2428
|
height: cropH,
|
|
2218
2429
|
order: spec.order,
|
|
2219
2430
|
transform: t,
|
|
2220
|
-
deformer:
|
|
2431
|
+
deformer: deformerId
|
|
2221
2432
|
};
|
|
2433
|
+
if (roleBindings.length > 0) {
|
|
2434
|
+
part.bindings = roleBindings;
|
|
2435
|
+
}
|
|
2436
|
+
return part;
|
|
2222
2437
|
}
|
|
2223
2438
|
});
|
|
2224
2439
|
const model = {
|
|
@@ -2229,9 +2444,12 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2229
2444
|
parameters,
|
|
2230
2445
|
deformers,
|
|
2231
2446
|
parts,
|
|
2232
|
-
// Secondary motion:
|
|
2233
|
-
// behind the head turn (same constants as the hand-authored sample)
|
|
2234
|
-
//
|
|
2447
|
+
// Secondary motion: one spring lags AngleX onto HairSwayX so front hair
|
|
2448
|
+
// sways behind the head turn (same constants as the hand-authored sample),
|
|
2449
|
+
// and a second lags AngleZ onto HairSwayZ so it swings behind a tilt. Two
|
|
2450
|
+
// rigs because a rig has one input and one output (validator-enforced).
|
|
2451
|
+
// Both hair parts read the outputs through root-pinned sway warps.
|
|
2452
|
+
// Omitted when there is no front hair to drive.
|
|
2235
2453
|
physics: hasHair ? [
|
|
2236
2454
|
{
|
|
2237
2455
|
id: "hairSway",
|
|
@@ -2240,6 +2458,14 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2240
2458
|
mass: 1,
|
|
2241
2459
|
stiffness: 80,
|
|
2242
2460
|
damping: 10
|
|
2461
|
+
},
|
|
2462
|
+
{
|
|
2463
|
+
id: "hairTilt",
|
|
2464
|
+
input: { parameter: import_format3.StandardParameter.AngleZ, weight: 1 },
|
|
2465
|
+
output: { parameter: import_format3.StandardParameter.HairSwayZ, scale: -10 },
|
|
2466
|
+
mass: 1,
|
|
2467
|
+
stiffness: 80,
|
|
2468
|
+
damping: 10
|
|
2243
2469
|
}
|
|
2244
2470
|
] : void 0
|
|
2245
2471
|
};
|