@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 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, and
60
- emotes with its brows — including a hair-sway physics rig when a `hair_front`
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) and faceWarp
904
- * (warp, 4×4, baked cylinder warp center-relative on faceCenterX).
905
- * - Mesh parts (spec.mesh===true) width:1, height:1, pixel grid mesh 4×4 + role bindings.
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) and faceWarp
904
- * (warp, 4×4, baked cylinder warp center-relative on faceCenterX).
905
- * - Mesh parts (spec.mesh===true) width:1, height:1, pixel grid mesh 4×4 + role bindings.
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. 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 },
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 = [-HEAD_TURN_MAX_DEG, 0, HEAD_TURN_MAX_DEG];
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
- const shiftX = depth * (options.parallaxUnit ?? 0);
1996
- if (shiftX !== 0) {
1997
- parallax.push({
1998
- parameter: import_format3.StandardParameter.AngleX,
1999
- channel: "translateX",
2000
- from: -shiftX,
2001
- to: shiftX
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: 4,
2256
- rows: 4,
2274
+ cols: FACE_GRID_CELLS,
2275
+ rows: FACE_GRID_CELLS,
2257
2276
  points: generateGridPoints(
2258
- 4,
2259
- 4,
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
- parameter: import_format3.StandardParameter.AngleX,
2292
- channel: "rotate",
2293
- from: 6,
2294
- to: -6
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: -50,
2300
- to: 50
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 poses,
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, 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.
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: -12
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 mesh = createPixelGridMesh(4, 4, cropW, cropH);
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") {