@ikijs/editor 0.2.0 → 0.3.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.d.mts +6 -3
- package/dist/index.d.ts +6 -3
- package/dist/index.js +113 -47
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +113 -47
- 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,28 +1834,39 @@ 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
|
}
|
|
1854
|
+
function gridReach(grid, axis, center) {
|
|
1855
|
+
let reach = 0;
|
|
1856
|
+
for (let i = axis; i < grid.points.length; i += 2) {
|
|
1857
|
+
reach = Math.max(reach, Math.abs(grid.points[i] - center));
|
|
1858
|
+
}
|
|
1859
|
+
return reach;
|
|
1860
|
+
}
|
|
1841
1861
|
function pinnedCylinderBend(local, radius, theta) {
|
|
1842
1862
|
const alpha = Math.asin(Math.max(-1, Math.min(1, local / radius)));
|
|
1843
1863
|
return radius * Math.sin(alpha + theta) - local - radius * Math.sin(theta);
|
|
1844
1864
|
}
|
|
1845
1865
|
function bakeHeadTurnGridWarp2DCentered(grid, parameterX, parameterY, centerX, centerY) {
|
|
1846
|
-
const STOPS = [
|
|
1847
|
-
const
|
|
1848
|
-
const
|
|
1866
|
+
const STOPS = [...HEAD_TURN_STOPS];
|
|
1867
|
+
const RADIUS_X = gridReach(grid, 0, centerX) * HEAD_CYLINDER_RADIUS_FACTOR;
|
|
1868
|
+
const RADIUS_Y = gridReach(grid, 1, centerY) * HEAD_CYLINDER_RADIUS_FACTOR;
|
|
1849
1869
|
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;
|
|
1858
1870
|
const DEG_TO_RAD = Math.PI / 180;
|
|
1859
1871
|
const keyforms2d = [];
|
|
1860
1872
|
for (const angleY of STOPS) {
|
|
@@ -1991,16 +2003,17 @@ function bindingsForRole(spec, role, cropW, cropH, options = {}) {
|
|
|
1991
2003
|
}
|
|
1992
2004
|
if (role === "hair_front" || role === "hair_back") {
|
|
1993
2005
|
const isFront = role === "hair_front";
|
|
1994
|
-
const depth = isFront ? HAIR_FRONT_DEPTH : HAIR_BACK_DEPTH;
|
|
1995
2006
|
const parallax = [];
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
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
|
+
}
|
|
2004
2017
|
}
|
|
2005
2018
|
const shiftY = (isFront ? 0 : HAIR_BACK_NOD_DEPTH) * (options.parallaxUnitY ?? 0);
|
|
2006
2019
|
if (shiftY !== 0) {
|
|
@@ -2063,6 +2076,7 @@ function bakeHairBackTurnWarp(mesh, parameter) {
|
|
|
2063
2076
|
var HAIR_SWAY_TIP_FRACTION = 0.09;
|
|
2064
2077
|
var HAIR_SWAY_CURL = 1.5;
|
|
2065
2078
|
var HAIR_SWAY_RANGE = 20;
|
|
2079
|
+
var HAIR_SWAY_PEAK_FRACTION = 0.56;
|
|
2066
2080
|
function bakeHairSwayWarp(mesh, parameter, tipShift, range) {
|
|
2067
2081
|
let top = -Infinity;
|
|
2068
2082
|
let bottom = Infinity;
|
|
@@ -2085,10 +2099,15 @@ function bakeHairSwayWarp(mesh, parameter, tipShift, range) {
|
|
|
2085
2099
|
]
|
|
2086
2100
|
};
|
|
2087
2101
|
}
|
|
2102
|
+
var HEAD_TURN_TRAVEL = 50;
|
|
2103
|
+
var BODY_TURN_FOLLOW = 0.3;
|
|
2104
|
+
var HEAD_BREATH_BOB = -12;
|
|
2105
|
+
var BODY_BREATH_FOLLOW = 0.5;
|
|
2088
2106
|
function generateIkiFromLayerSet(layers, canvas) {
|
|
2089
2107
|
validateLayerInputs(layers, canvas);
|
|
2090
2108
|
const hasHair = layers.some((l) => l.role === "hair_front");
|
|
2091
2109
|
const hasMouthOpen = layers.some((l) => l.role === "mouth_open");
|
|
2110
|
+
const bodyLayer = layers.find((l) => l.role === "body");
|
|
2092
2111
|
const parameters = [
|
|
2093
2112
|
{
|
|
2094
2113
|
id: import_format3.StandardParameter.MouthOpen,
|
|
@@ -2252,11 +2271,11 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2252
2271
|
const faceGridMinX = faceCenterX - halfW;
|
|
2253
2272
|
const faceGridMaxX = faceCenterX + halfW;
|
|
2254
2273
|
const faceGrid = {
|
|
2255
|
-
cols:
|
|
2256
|
-
rows:
|
|
2274
|
+
cols: FACE_GRID_CELLS,
|
|
2275
|
+
rows: FACE_GRID_CELLS,
|
|
2257
2276
|
points: generateGridPoints(
|
|
2258
|
-
|
|
2259
|
-
|
|
2277
|
+
FACE_GRID_CELLS,
|
|
2278
|
+
FACE_GRID_CELLS,
|
|
2260
2279
|
faceGridMinX,
|
|
2261
2280
|
faceGridMaxX,
|
|
2262
2281
|
unionMinY,
|
|
@@ -2287,21 +2306,20 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2287
2306
|
id: "headDeformer",
|
|
2288
2307
|
pivot: neckPivot,
|
|
2289
2308
|
bindings: [
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
},
|
|
2309
|
+
// The turn is a pure yaw: no roll rides on AngleX. The sample model's
|
|
2310
|
+
// ±6° "lean into the turn" was tried here and dropped — rotating about
|
|
2311
|
+
// the neck pivot swings the crown (~500px above it) far more than the
|
|
2312
|
+
// chin, so the top of the head appeared to lunge ahead of the face on
|
|
2313
|
+
// every turn. Roll is AngleZ's job, below.
|
|
2296
2314
|
{
|
|
2297
2315
|
parameter: import_format3.StandardParameter.AngleX,
|
|
2298
2316
|
channel: "translateX",
|
|
2299
|
-
from: -
|
|
2300
|
-
to:
|
|
2317
|
+
from: -HEAD_TURN_TRAVEL,
|
|
2318
|
+
to: HEAD_TURN_TRAVEL
|
|
2301
2319
|
},
|
|
2302
2320
|
// 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.
|
|
2321
|
+
// rigid rotation would sum with the AngleZ roll below at diagonal
|
|
2322
|
+
// poses, collapsing pitch into roll.
|
|
2305
2323
|
{
|
|
2306
2324
|
parameter: import_format3.StandardParameter.AngleY,
|
|
2307
2325
|
channel: "translateY",
|
|
@@ -2310,10 +2328,10 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2310
2328
|
},
|
|
2311
2329
|
// Tilt: the whole head rolls about the neck pivot, one degree per
|
|
2312
2330
|
// degree. Positive AngleZ is clockwise on screen (engine rotate is
|
|
2313
|
-
// CCW-positive, hence the flipped from/to) — Live2D's convention
|
|
2314
|
-
//
|
|
2315
|
-
//
|
|
2316
|
-
//
|
|
2331
|
+
// CCW-positive, hence the flipped from/to) — Live2D's convention: in
|
|
2332
|
+
// its own sample motions AngleZ carries the sign of AngleX 214 times
|
|
2333
|
+
// out of 222, i.e. a head that leans into its turn tilts clockwise on
|
|
2334
|
+
// a turn to the viewer's right. The only rotate binding on the head.
|
|
2317
2335
|
{
|
|
2318
2336
|
parameter: import_format3.StandardParameter.AngleZ,
|
|
2319
2337
|
channel: "rotate",
|
|
@@ -2324,7 +2342,7 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2324
2342
|
parameter: import_format3.StandardParameter.Breath,
|
|
2325
2343
|
channel: "translateY",
|
|
2326
2344
|
from: 0,
|
|
2327
|
-
to:
|
|
2345
|
+
to: HEAD_BREATH_BOB
|
|
2328
2346
|
}
|
|
2329
2347
|
]
|
|
2330
2348
|
},
|
|
@@ -2340,6 +2358,34 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2340
2358
|
warp2d: faceWarp2d
|
|
2341
2359
|
}
|
|
2342
2360
|
];
|
|
2361
|
+
if (bodyLayer) {
|
|
2362
|
+
const bt = bboxToTransform(
|
|
2363
|
+
bodyLayer.bbox,
|
|
2364
|
+
bodyLayer.canvasW,
|
|
2365
|
+
bodyLayer.canvasH,
|
|
2366
|
+
"body"
|
|
2367
|
+
);
|
|
2368
|
+
deformers.push({
|
|
2369
|
+
id: "bodyDeformer",
|
|
2370
|
+
pivot: { x: bt.x, y: bt.y - bodyLayer.cropH / 2 },
|
|
2371
|
+
bindings: [
|
|
2372
|
+
// Low-weight follow of the turn, same direction as the head.
|
|
2373
|
+
{
|
|
2374
|
+
parameter: import_format3.StandardParameter.AngleX,
|
|
2375
|
+
channel: "translateX",
|
|
2376
|
+
from: -BODY_TURN_FOLLOW * HEAD_TURN_TRAVEL,
|
|
2377
|
+
to: BODY_TURN_FOLLOW * HEAD_TURN_TRAVEL
|
|
2378
|
+
},
|
|
2379
|
+
// Breath: follow the head's bob, same direction, half amplitude.
|
|
2380
|
+
{
|
|
2381
|
+
parameter: import_format3.StandardParameter.Breath,
|
|
2382
|
+
channel: "translateY",
|
|
2383
|
+
from: 0,
|
|
2384
|
+
to: BODY_BREATH_FOLLOW * HEAD_BREATH_BOB
|
|
2385
|
+
}
|
|
2386
|
+
]
|
|
2387
|
+
});
|
|
2388
|
+
}
|
|
2343
2389
|
const eyeCreaseBySide = {};
|
|
2344
2390
|
for (const layer of layers) {
|
|
2345
2391
|
const side = ROLE_TABLE[layer.role].eyeSide;
|
|
@@ -2364,7 +2410,8 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2364
2410
|
});
|
|
2365
2411
|
const deformerId = spec.deformer === "none" ? void 0 : spec.deformer;
|
|
2366
2412
|
if (spec.mesh) {
|
|
2367
|
-
const
|
|
2413
|
+
const { cols, rows } = meshCellsFor(cropW, cropH);
|
|
2414
|
+
const mesh = createPixelGridMesh(cols, rows, cropW, cropH);
|
|
2368
2415
|
const part = {
|
|
2369
2416
|
id: role,
|
|
2370
2417
|
color: [1, 1, 1, 1],
|
|
@@ -2379,7 +2426,25 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2379
2426
|
part.bindings = roleBindings;
|
|
2380
2427
|
}
|
|
2381
2428
|
if ((role === "hair_front" || role === "hair_back") && hasHair) {
|
|
2382
|
-
|
|
2429
|
+
let tipShift = HAIR_SWAY_TIP_FRACTION * cropH;
|
|
2430
|
+
let leadWarp;
|
|
2431
|
+
if (role === "hair_front") {
|
|
2432
|
+
const shift = HAIR_FRONT_DEPTH * parallaxUnit;
|
|
2433
|
+
const headroom = Math.min(
|
|
2434
|
+
faceGridMaxX - (t.x + cropW / 2),
|
|
2435
|
+
t.x - cropW / 2 - faceGridMinX
|
|
2436
|
+
) - shift;
|
|
2437
|
+
tipShift = Math.min(
|
|
2438
|
+
tipShift,
|
|
2439
|
+
Math.max(0, headroom) / HAIR_SWAY_PEAK_FRACTION
|
|
2440
|
+
);
|
|
2441
|
+
leadWarp = bakeHairSwayWarp(
|
|
2442
|
+
mesh,
|
|
2443
|
+
import_format3.StandardParameter.AngleX,
|
|
2444
|
+
shift,
|
|
2445
|
+
HEAD_TURN_MAX_DEG
|
|
2446
|
+
);
|
|
2447
|
+
}
|
|
2383
2448
|
part.warps = [
|
|
2384
2449
|
bakeHairSwayWarp(
|
|
2385
2450
|
mesh,
|
|
@@ -2392,7 +2457,8 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2392
2457
|
import_format3.StandardParameter.HairSwayZ,
|
|
2393
2458
|
tipShift,
|
|
2394
2459
|
HAIR_SWAY_RANGE
|
|
2395
|
-
)
|
|
2460
|
+
),
|
|
2461
|
+
...leadWarp ? [leadWarp] : []
|
|
2396
2462
|
];
|
|
2397
2463
|
}
|
|
2398
2464
|
if (role === "hair_back") {
|