@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 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) {
@@ -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 = [-HEAD_TURN_MAX_DEG, 0, HEAD_TURN_MAX_DEG].map((deg) => {
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
- offsets.push(pinnedCylinderBend(x, radius, theta) - s * bulge * far, 0);
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: 4,
2256
- rows: 4,
2278
+ cols: FACE_GRID_CELLS,
2279
+ rows: FACE_GRID_CELLS,
2257
2280
  points: generateGridPoints(
2258
- 4,
2259
- 4,
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
- parameter: import_format3.StandardParameter.AngleX,
2292
- channel: "rotate",
2293
- from: 6,
2294
- to: -6
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: -50,
2300
- to: 50
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 poses,
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, 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.
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: -12
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 mesh = createPixelGridMesh(4, 4, cropW, cropH);
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") {