@ikijs/editor 0.2.1 → 0.3.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.d.mts +6 -3
- package/dist/index.d.ts +6 -3
- package/dist/index.js +97 -38
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +97 -38
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -56,9 +56,9 @@ leaves the document untouched.
|
|
|
56
56
|
|
|
57
57
|
`generateIkiFromLayerSet` turns role-named layers (`face`, `eye_L`, `eye_R`,
|
|
58
58
|
`mouth`, plus optional `iris_*`, `brow_*`, `lash_*`, `hair_front`, `hair_back`,
|
|
59
|
-
…) into a rigged model that blinks, gazes, opens its mouth, turns its head
|
|
60
|
-
emotes with its brows — including a
|
|
61
|
-
layer is present.
|
|
59
|
+
…) into a rigged model that blinks, gazes, opens its mouth, turns its head on a
|
|
60
|
+
torso that follows and breathes, and emotes with its brows — including a
|
|
61
|
+
hair-sway physics rig when a `hair_front` layer is present.
|
|
62
62
|
|
|
63
63
|
It takes **already-decoded** layer geometry (`LayerInput`), never pixels, which
|
|
64
64
|
is what keeps this package free of any image dependency: the editor app
|
package/dist/index.d.mts
CHANGED
|
@@ -900,9 +900,12 @@ declare function parseLayerRoles(fileNames: string[]): {
|
|
|
900
900
|
* - Emit the standard parameters (same ids/ranges as sample-model.ts), plus a
|
|
901
901
|
* conditional HairSwayX descriptor + hair-sway physics rig when a hair_front
|
|
902
902
|
* layer is present.
|
|
903
|
-
* - Build headDeformer (matrix, neck pivot, AngleX+Breath bindings)
|
|
904
|
-
*
|
|
905
|
-
*
|
|
903
|
+
* - Build headDeformer (matrix, neck pivot, AngleX+Breath bindings),
|
|
904
|
+
* bodyDeformer when a body layer is present (matrix, torso-base pivot,
|
|
905
|
+
* AngleX follow + Breath follow), and faceWarp (warp, FACE_GRID_CELLS²,
|
|
906
|
+
* baked cylinder warp center-relative on faceCenterX).
|
|
907
|
+
* - Mesh parts (spec.mesh===true) → width:1, height:1, pixel grid mesh sized by
|
|
908
|
+
* meshCellsFor + role bindings.
|
|
906
909
|
* - Static parts (spec.mesh===false) → width:cropW, height:cropH, no mesh.
|
|
907
910
|
* - Part ids equal the role string (deterministic, no crypto.randomUUID).
|
|
908
911
|
* - Return parseIkiModel(structuredClone(model)) — every caller gets a
|
package/dist/index.d.ts
CHANGED
|
@@ -900,9 +900,12 @@ declare function parseLayerRoles(fileNames: string[]): {
|
|
|
900
900
|
* - Emit the standard parameters (same ids/ranges as sample-model.ts), plus a
|
|
901
901
|
* conditional HairSwayX descriptor + hair-sway physics rig when a hair_front
|
|
902
902
|
* layer is present.
|
|
903
|
-
* - Build headDeformer (matrix, neck pivot, AngleX+Breath bindings)
|
|
904
|
-
*
|
|
905
|
-
*
|
|
903
|
+
* - Build headDeformer (matrix, neck pivot, AngleX+Breath bindings),
|
|
904
|
+
* bodyDeformer when a body layer is present (matrix, torso-base pivot,
|
|
905
|
+
* AngleX follow + Breath follow), and faceWarp (warp, FACE_GRID_CELLS²,
|
|
906
|
+
* baked cylinder warp center-relative on faceCenterX).
|
|
907
|
+
* - Mesh parts (spec.mesh===true) → width:1, height:1, pixel grid mesh sized by
|
|
908
|
+
* meshCellsFor + role bindings.
|
|
906
909
|
* - Static parts (spec.mesh===false) → width:cropW, height:cropH, no mesh.
|
|
907
910
|
* - Part ids equal the role string (deterministic, no crypto.randomUUID).
|
|
908
911
|
* - Return parseIkiModel(structuredClone(model)) — every caller gets a
|
package/dist/index.js
CHANGED
|
@@ -1668,11 +1668,12 @@ var ROLE_TABLE = {
|
|
|
1668
1668
|
// the turn through its own part warp (bakeHairBackTurnWarp) and swings its
|
|
1669
1669
|
// ends on the hair-sway warps.
|
|
1670
1670
|
hair_back: { deformer: "headDeformer", order: 0, mesh: true },
|
|
1671
|
-
// The torso.
|
|
1672
|
-
// about the neck pivot while the shoulders
|
|
1673
|
-
//
|
|
1674
|
-
//
|
|
1675
|
-
|
|
1671
|
+
// The torso. It rides its own rigid deformer, not the head's: the head turns
|
|
1672
|
+
// about the neck pivot while the shoulders follow at BODY_TURN_FOLLOW and
|
|
1673
|
+
// follow the head's breath bob, which is what keeps the character from
|
|
1674
|
+
// reading as a floating head. Drawn over the back hair so long hair falls
|
|
1675
|
+
// behind the shoulders.
|
|
1676
|
+
body: { deformer: "bodyDeformer", order: 5, mesh: false },
|
|
1676
1677
|
face: { deformer: "faceWarp", order: 10, mesh: true },
|
|
1677
1678
|
nose: { deformer: "faceWarp", order: 15, mesh: true },
|
|
1678
1679
|
blush_L: { deformer: "faceWarp", order: 20, mesh: true },
|
|
@@ -1833,8 +1834,20 @@ function createPixelGridMesh(cols, rows, w, h) {
|
|
|
1833
1834
|
}
|
|
1834
1835
|
return { vertices, uvs, indices };
|
|
1835
1836
|
}
|
|
1837
|
+
var MESH_CELL_PX = 64;
|
|
1838
|
+
var MESH_CELLS_MIN = 4;
|
|
1839
|
+
var MESH_CELLS_MAX = 8;
|
|
1840
|
+
function meshCellsFor(w, h) {
|
|
1841
|
+
const cells = (px) => Math.max(
|
|
1842
|
+
MESH_CELLS_MIN,
|
|
1843
|
+
Math.min(MESH_CELLS_MAX, Math.round(px / MESH_CELL_PX))
|
|
1844
|
+
);
|
|
1845
|
+
return { cols: cells(w), rows: cells(h) };
|
|
1846
|
+
}
|
|
1847
|
+
var FACE_GRID_CELLS = 6;
|
|
1836
1848
|
var HEAD_CYLINDER_RADIUS_FACTOR = 0.6 / 0.5;
|
|
1837
1849
|
var HEAD_TURN_MAX_DEG = 30;
|
|
1850
|
+
var HEAD_TURN_STOPS = [-30, -15, 0, 15, 30];
|
|
1838
1851
|
function headTurnParallaxUnit(gridHalfWidth) {
|
|
1839
1852
|
return gridHalfWidth * HEAD_CYLINDER_RADIUS_FACTOR * Math.sin(HEAD_TURN_MAX_DEG * (Math.PI / 180));
|
|
1840
1853
|
}
|
|
@@ -1850,7 +1863,7 @@ function pinnedCylinderBend(local, radius, theta) {
|
|
|
1850
1863
|
return radius * Math.sin(alpha + theta) - local - radius * Math.sin(theta);
|
|
1851
1864
|
}
|
|
1852
1865
|
function bakeHeadTurnGridWarp2DCentered(grid, parameterX, parameterY, centerX, centerY) {
|
|
1853
|
-
const STOPS = [
|
|
1866
|
+
const STOPS = [...HEAD_TURN_STOPS];
|
|
1854
1867
|
const RADIUS_X = gridReach(grid, 0, centerX) * HEAD_CYLINDER_RADIUS_FACTOR;
|
|
1855
1868
|
const RADIUS_Y = gridReach(grid, 1, centerY) * HEAD_CYLINDER_RADIUS_FACTOR;
|
|
1856
1869
|
const pointCount = grid.points.length / 2;
|
|
@@ -1990,16 +2003,17 @@ function bindingsForRole(spec, role, cropW, cropH, options = {}) {
|
|
|
1990
2003
|
}
|
|
1991
2004
|
if (role === "hair_front" || role === "hair_back") {
|
|
1992
2005
|
const isFront = role === "hair_front";
|
|
1993
|
-
const depth = isFront ? HAIR_FRONT_DEPTH : HAIR_BACK_DEPTH;
|
|
1994
2006
|
const parallax = [];
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2007
|
+
if (!isFront) {
|
|
2008
|
+
const shiftX = HAIR_BACK_DEPTH * (options.parallaxUnit ?? 0);
|
|
2009
|
+
if (shiftX !== 0) {
|
|
2010
|
+
parallax.push({
|
|
2011
|
+
parameter: import_format3.StandardParameter.AngleX,
|
|
2012
|
+
channel: "translateX",
|
|
2013
|
+
from: -shiftX,
|
|
2014
|
+
to: shiftX
|
|
2015
|
+
});
|
|
2016
|
+
}
|
|
2003
2017
|
}
|
|
2004
2018
|
const shiftY = (isFront ? 0 : HAIR_BACK_NOD_DEPTH) * (options.parallaxUnitY ?? 0);
|
|
2005
2019
|
if (shiftY !== 0) {
|
|
@@ -2046,14 +2060,18 @@ function bakeHairBackTurnWarp(mesh, parameter) {
|
|
|
2046
2060
|
const radius = halfWidth * HAIR_BACK_BEND_RADIUS_FACTOR;
|
|
2047
2061
|
const bulge = HAIR_BACK_FAR_BULGE * halfWidth;
|
|
2048
2062
|
const DEG_TO_RAD = Math.PI / 180;
|
|
2049
|
-
const keyforms =
|
|
2063
|
+
const keyforms = HEAD_TURN_STOPS.map((deg) => {
|
|
2050
2064
|
const theta = deg * DEG_TO_RAD;
|
|
2051
2065
|
const s = Math.sign(deg);
|
|
2066
|
+
const turnFraction = Math.abs(deg) / HEAD_TURN_MAX_DEG;
|
|
2067
|
+
const fullTheta = s * HEAD_TURN_MAX_DEG * DEG_TO_RAD;
|
|
2068
|
+
const bulgeAtStop = bulge * deg / HEAD_TURN_MAX_DEG;
|
|
2052
2069
|
const offsets = [];
|
|
2053
2070
|
for (let i = 0; i < mesh.vertices.length; i += 2) {
|
|
2054
2071
|
const x = mesh.vertices[i];
|
|
2055
2072
|
const far = Math.max(0, -s * x / halfWidth);
|
|
2056
|
-
|
|
2073
|
+
const bend = far > 0 ? turnFraction * pinnedCylinderBend(x, radius, fullTheta) : pinnedCylinderBend(x, radius, theta);
|
|
2074
|
+
offsets.push(bend - bulgeAtStop * far, 0);
|
|
2057
2075
|
}
|
|
2058
2076
|
return { value: deg, offsets };
|
|
2059
2077
|
});
|
|
@@ -2085,10 +2103,15 @@ function bakeHairSwayWarp(mesh, parameter, tipShift, range) {
|
|
|
2085
2103
|
]
|
|
2086
2104
|
};
|
|
2087
2105
|
}
|
|
2106
|
+
var HEAD_TURN_TRAVEL = 50;
|
|
2107
|
+
var BODY_TURN_FOLLOW = 0.3;
|
|
2108
|
+
var HEAD_BREATH_BOB = -12;
|
|
2109
|
+
var BODY_BREATH_FOLLOW = 0.5;
|
|
2088
2110
|
function generateIkiFromLayerSet(layers, canvas) {
|
|
2089
2111
|
validateLayerInputs(layers, canvas);
|
|
2090
2112
|
const hasHair = layers.some((l) => l.role === "hair_front");
|
|
2091
2113
|
const hasMouthOpen = layers.some((l) => l.role === "mouth_open");
|
|
2114
|
+
const bodyLayer = layers.find((l) => l.role === "body");
|
|
2092
2115
|
const parameters = [
|
|
2093
2116
|
{
|
|
2094
2117
|
id: import_format3.StandardParameter.MouthOpen,
|
|
@@ -2252,11 +2275,11 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2252
2275
|
const faceGridMinX = faceCenterX - halfW;
|
|
2253
2276
|
const faceGridMaxX = faceCenterX + halfW;
|
|
2254
2277
|
const faceGrid = {
|
|
2255
|
-
cols:
|
|
2256
|
-
rows:
|
|
2278
|
+
cols: FACE_GRID_CELLS,
|
|
2279
|
+
rows: FACE_GRID_CELLS,
|
|
2257
2280
|
points: generateGridPoints(
|
|
2258
|
-
|
|
2259
|
-
|
|
2281
|
+
FACE_GRID_CELLS,
|
|
2282
|
+
FACE_GRID_CELLS,
|
|
2260
2283
|
faceGridMinX,
|
|
2261
2284
|
faceGridMaxX,
|
|
2262
2285
|
unionMinY,
|
|
@@ -2287,21 +2310,20 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2287
2310
|
id: "headDeformer",
|
|
2288
2311
|
pivot: neckPivot,
|
|
2289
2312
|
bindings: [
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
},
|
|
2313
|
+
// The turn is a pure yaw: no roll rides on AngleX. The sample model's
|
|
2314
|
+
// ±6° "lean into the turn" was tried here and dropped — rotating about
|
|
2315
|
+
// the neck pivot swings the crown (~500px above it) far more than the
|
|
2316
|
+
// chin, so the top of the head appeared to lunge ahead of the face on
|
|
2317
|
+
// every turn. Roll is AngleZ's job, below.
|
|
2296
2318
|
{
|
|
2297
2319
|
parameter: import_format3.StandardParameter.AngleX,
|
|
2298
2320
|
channel: "translateX",
|
|
2299
|
-
from: -
|
|
2300
|
-
to:
|
|
2321
|
+
from: -HEAD_TURN_TRAVEL,
|
|
2322
|
+
to: HEAD_TURN_TRAVEL
|
|
2301
2323
|
},
|
|
2302
2324
|
// Nod: a vertical translate only. No rotate — a pitch expressed as a
|
|
2303
|
-
// rigid rotation would sum with the roll below at diagonal
|
|
2304
|
-
// collapsing pitch into roll.
|
|
2325
|
+
// rigid rotation would sum with the AngleZ roll below at diagonal
|
|
2326
|
+
// poses, collapsing pitch into roll.
|
|
2305
2327
|
{
|
|
2306
2328
|
parameter: import_format3.StandardParameter.AngleY,
|
|
2307
2329
|
channel: "translateY",
|
|
@@ -2310,10 +2332,10 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2310
2332
|
},
|
|
2311
2333
|
// Tilt: the whole head rolls about the neck pivot, one degree per
|
|
2312
2334
|
// degree. Positive AngleZ is clockwise on screen (engine rotate is
|
|
2313
|
-
// CCW-positive, hence the flipped from/to) — Live2D's convention
|
|
2314
|
-
//
|
|
2315
|
-
//
|
|
2316
|
-
//
|
|
2335
|
+
// CCW-positive, hence the flipped from/to) — Live2D's convention: in
|
|
2336
|
+
// its own sample motions AngleZ carries the sign of AngleX 214 times
|
|
2337
|
+
// out of 222, i.e. a head that leans into its turn tilts clockwise on
|
|
2338
|
+
// a turn to the viewer's right. The only rotate binding on the head.
|
|
2317
2339
|
{
|
|
2318
2340
|
parameter: import_format3.StandardParameter.AngleZ,
|
|
2319
2341
|
channel: "rotate",
|
|
@@ -2324,7 +2346,7 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2324
2346
|
parameter: import_format3.StandardParameter.Breath,
|
|
2325
2347
|
channel: "translateY",
|
|
2326
2348
|
from: 0,
|
|
2327
|
-
to:
|
|
2349
|
+
to: HEAD_BREATH_BOB
|
|
2328
2350
|
}
|
|
2329
2351
|
]
|
|
2330
2352
|
},
|
|
@@ -2340,6 +2362,34 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2340
2362
|
warp2d: faceWarp2d
|
|
2341
2363
|
}
|
|
2342
2364
|
];
|
|
2365
|
+
if (bodyLayer) {
|
|
2366
|
+
const bt = bboxToTransform(
|
|
2367
|
+
bodyLayer.bbox,
|
|
2368
|
+
bodyLayer.canvasW,
|
|
2369
|
+
bodyLayer.canvasH,
|
|
2370
|
+
"body"
|
|
2371
|
+
);
|
|
2372
|
+
deformers.push({
|
|
2373
|
+
id: "bodyDeformer",
|
|
2374
|
+
pivot: { x: bt.x, y: bt.y - bodyLayer.cropH / 2 },
|
|
2375
|
+
bindings: [
|
|
2376
|
+
// Low-weight follow of the turn, same direction as the head.
|
|
2377
|
+
{
|
|
2378
|
+
parameter: import_format3.StandardParameter.AngleX,
|
|
2379
|
+
channel: "translateX",
|
|
2380
|
+
from: -BODY_TURN_FOLLOW * HEAD_TURN_TRAVEL,
|
|
2381
|
+
to: BODY_TURN_FOLLOW * HEAD_TURN_TRAVEL
|
|
2382
|
+
},
|
|
2383
|
+
// Breath: follow the head's bob, same direction, half amplitude.
|
|
2384
|
+
{
|
|
2385
|
+
parameter: import_format3.StandardParameter.Breath,
|
|
2386
|
+
channel: "translateY",
|
|
2387
|
+
from: 0,
|
|
2388
|
+
to: BODY_BREATH_FOLLOW * HEAD_BREATH_BOB
|
|
2389
|
+
}
|
|
2390
|
+
]
|
|
2391
|
+
});
|
|
2392
|
+
}
|
|
2343
2393
|
const eyeCreaseBySide = {};
|
|
2344
2394
|
for (const layer of layers) {
|
|
2345
2395
|
const side = ROLE_TABLE[layer.role].eyeSide;
|
|
@@ -2364,7 +2414,8 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2364
2414
|
});
|
|
2365
2415
|
const deformerId = spec.deformer === "none" ? void 0 : spec.deformer;
|
|
2366
2416
|
if (spec.mesh) {
|
|
2367
|
-
const
|
|
2417
|
+
const { cols, rows } = meshCellsFor(cropW, cropH);
|
|
2418
|
+
const mesh = createPixelGridMesh(cols, rows, cropW, cropH);
|
|
2368
2419
|
const part = {
|
|
2369
2420
|
id: role,
|
|
2370
2421
|
color: [1, 1, 1, 1],
|
|
@@ -2380,6 +2431,7 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2380
2431
|
}
|
|
2381
2432
|
if ((role === "hair_front" || role === "hair_back") && hasHair) {
|
|
2382
2433
|
let tipShift = HAIR_SWAY_TIP_FRACTION * cropH;
|
|
2434
|
+
let leadWarp;
|
|
2383
2435
|
if (role === "hair_front") {
|
|
2384
2436
|
const shift = HAIR_FRONT_DEPTH * parallaxUnit;
|
|
2385
2437
|
const headroom = Math.min(
|
|
@@ -2390,6 +2442,12 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2390
2442
|
tipShift,
|
|
2391
2443
|
Math.max(0, headroom) / HAIR_SWAY_PEAK_FRACTION
|
|
2392
2444
|
);
|
|
2445
|
+
leadWarp = bakeHairSwayWarp(
|
|
2446
|
+
mesh,
|
|
2447
|
+
import_format3.StandardParameter.AngleX,
|
|
2448
|
+
shift,
|
|
2449
|
+
HEAD_TURN_MAX_DEG
|
|
2450
|
+
);
|
|
2393
2451
|
}
|
|
2394
2452
|
part.warps = [
|
|
2395
2453
|
bakeHairSwayWarp(
|
|
@@ -2403,7 +2461,8 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2403
2461
|
import_format3.StandardParameter.HairSwayZ,
|
|
2404
2462
|
tipShift,
|
|
2405
2463
|
HAIR_SWAY_RANGE
|
|
2406
|
-
)
|
|
2464
|
+
),
|
|
2465
|
+
...leadWarp ? [leadWarp] : []
|
|
2407
2466
|
];
|
|
2408
2467
|
}
|
|
2409
2468
|
if (role === "hair_back") {
|