@ikijs/editor 0.2.1 → 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 +91 -36
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +91 -36
- 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) {
|
|
@@ -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],
|
|
@@ -2380,6 +2427,7 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2380
2427
|
}
|
|
2381
2428
|
if ((role === "hair_front" || role === "hair_back") && hasHair) {
|
|
2382
2429
|
let tipShift = HAIR_SWAY_TIP_FRACTION * cropH;
|
|
2430
|
+
let leadWarp;
|
|
2383
2431
|
if (role === "hair_front") {
|
|
2384
2432
|
const shift = HAIR_FRONT_DEPTH * parallaxUnit;
|
|
2385
2433
|
const headroom = Math.min(
|
|
@@ -2390,6 +2438,12 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2390
2438
|
tipShift,
|
|
2391
2439
|
Math.max(0, headroom) / HAIR_SWAY_PEAK_FRACTION
|
|
2392
2440
|
);
|
|
2441
|
+
leadWarp = bakeHairSwayWarp(
|
|
2442
|
+
mesh,
|
|
2443
|
+
import_format3.StandardParameter.AngleX,
|
|
2444
|
+
shift,
|
|
2445
|
+
HEAD_TURN_MAX_DEG
|
|
2446
|
+
);
|
|
2393
2447
|
}
|
|
2394
2448
|
part.warps = [
|
|
2395
2449
|
bakeHairSwayWarp(
|
|
@@ -2403,7 +2457,8 @@ function generateIkiFromLayerSet(layers, canvas) {
|
|
|
2403
2457
|
import_format3.StandardParameter.HairSwayZ,
|
|
2404
2458
|
tipShift,
|
|
2405
2459
|
HAIR_SWAY_RANGE
|
|
2406
|
-
)
|
|
2460
|
+
),
|
|
2461
|
+
...leadWarp ? [leadWarp] : []
|
|
2407
2462
|
];
|
|
2408
2463
|
}
|
|
2409
2464
|
if (role === "hair_back") {
|