@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/dist/index.mjs CHANGED
@@ -1609,11 +1609,12 @@ var ROLE_TABLE = {
1609
1609
  // the turn through its own part warp (bakeHairBackTurnWarp) and swings its
1610
1610
  // ends on the hair-sway warps.
1611
1611
  hair_back: { deformer: "headDeformer", order: 0, mesh: true },
1612
- // The torso. Alone among the roles it hangs from NO deformer: the head turns
1613
- // about the neck pivot while the shoulders stay put, which is what keeps the
1614
- // character from reading as a floating head. Drawn over the back hair so long
1615
- // hair falls behind the shoulders.
1616
- body: { deformer: "none", order: 5, mesh: false },
1612
+ // The torso. It rides its own rigid deformer, not the head's: the head turns
1613
+ // about the neck pivot while the shoulders follow at BODY_TURN_FOLLOW and
1614
+ // follow the head's breath bob, which is what keeps the character from
1615
+ // reading as a floating head. Drawn over the back hair so long hair falls
1616
+ // behind the shoulders.
1617
+ body: { deformer: "bodyDeformer", order: 5, mesh: false },
1617
1618
  face: { deformer: "faceWarp", order: 10, mesh: true },
1618
1619
  nose: { deformer: "faceWarp", order: 15, mesh: true },
1619
1620
  blush_L: { deformer: "faceWarp", order: 20, mesh: true },
@@ -1774,8 +1775,20 @@ function createPixelGridMesh(cols, rows, w, h) {
1774
1775
  }
1775
1776
  return { vertices, uvs, indices };
1776
1777
  }
1778
+ var MESH_CELL_PX = 64;
1779
+ var MESH_CELLS_MIN = 4;
1780
+ var MESH_CELLS_MAX = 8;
1781
+ function meshCellsFor(w, h) {
1782
+ const cells = (px) => Math.max(
1783
+ MESH_CELLS_MIN,
1784
+ Math.min(MESH_CELLS_MAX, Math.round(px / MESH_CELL_PX))
1785
+ );
1786
+ return { cols: cells(w), rows: cells(h) };
1787
+ }
1788
+ var FACE_GRID_CELLS = 6;
1777
1789
  var HEAD_CYLINDER_RADIUS_FACTOR = 0.6 / 0.5;
1778
1790
  var HEAD_TURN_MAX_DEG = 30;
1791
+ var HEAD_TURN_STOPS = [-30, -15, 0, 15, 30];
1779
1792
  function headTurnParallaxUnit(gridHalfWidth) {
1780
1793
  return gridHalfWidth * HEAD_CYLINDER_RADIUS_FACTOR * Math.sin(HEAD_TURN_MAX_DEG * (Math.PI / 180));
1781
1794
  }
@@ -1791,7 +1804,7 @@ function pinnedCylinderBend(local, radius, theta) {
1791
1804
  return radius * Math.sin(alpha + theta) - local - radius * Math.sin(theta);
1792
1805
  }
1793
1806
  function bakeHeadTurnGridWarp2DCentered(grid, parameterX, parameterY, centerX, centerY) {
1794
- const STOPS = [-HEAD_TURN_MAX_DEG, 0, HEAD_TURN_MAX_DEG];
1807
+ const STOPS = [...HEAD_TURN_STOPS];
1795
1808
  const RADIUS_X = gridReach(grid, 0, centerX) * HEAD_CYLINDER_RADIUS_FACTOR;
1796
1809
  const RADIUS_Y = gridReach(grid, 1, centerY) * HEAD_CYLINDER_RADIUS_FACTOR;
1797
1810
  const pointCount = grid.points.length / 2;
@@ -1931,16 +1944,17 @@ function bindingsForRole(spec, role, cropW, cropH, options = {}) {
1931
1944
  }
1932
1945
  if (role === "hair_front" || role === "hair_back") {
1933
1946
  const isFront = role === "hair_front";
1934
- const depth = isFront ? HAIR_FRONT_DEPTH : HAIR_BACK_DEPTH;
1935
1947
  const parallax = [];
1936
- const shiftX = depth * (options.parallaxUnit ?? 0);
1937
- if (shiftX !== 0) {
1938
- parallax.push({
1939
- parameter: StandardParameter.AngleX,
1940
- channel: "translateX",
1941
- from: -shiftX,
1942
- to: shiftX
1943
- });
1948
+ if (!isFront) {
1949
+ const shiftX = HAIR_BACK_DEPTH * (options.parallaxUnit ?? 0);
1950
+ if (shiftX !== 0) {
1951
+ parallax.push({
1952
+ parameter: StandardParameter.AngleX,
1953
+ channel: "translateX",
1954
+ from: -shiftX,
1955
+ to: shiftX
1956
+ });
1957
+ }
1944
1958
  }
1945
1959
  const shiftY = (isFront ? 0 : HAIR_BACK_NOD_DEPTH) * (options.parallaxUnitY ?? 0);
1946
1960
  if (shiftY !== 0) {
@@ -1987,14 +2001,18 @@ function bakeHairBackTurnWarp(mesh, parameter) {
1987
2001
  const radius = halfWidth * HAIR_BACK_BEND_RADIUS_FACTOR;
1988
2002
  const bulge = HAIR_BACK_FAR_BULGE * halfWidth;
1989
2003
  const DEG_TO_RAD = Math.PI / 180;
1990
- const keyforms = [-HEAD_TURN_MAX_DEG, 0, HEAD_TURN_MAX_DEG].map((deg) => {
2004
+ const keyforms = HEAD_TURN_STOPS.map((deg) => {
1991
2005
  const theta = deg * DEG_TO_RAD;
1992
2006
  const s = Math.sign(deg);
2007
+ const turnFraction = Math.abs(deg) / HEAD_TURN_MAX_DEG;
2008
+ const fullTheta = s * HEAD_TURN_MAX_DEG * DEG_TO_RAD;
2009
+ const bulgeAtStop = bulge * deg / HEAD_TURN_MAX_DEG;
1993
2010
  const offsets = [];
1994
2011
  for (let i = 0; i < mesh.vertices.length; i += 2) {
1995
2012
  const x = mesh.vertices[i];
1996
2013
  const far = Math.max(0, -s * x / halfWidth);
1997
- offsets.push(pinnedCylinderBend(x, radius, theta) - s * bulge * far, 0);
2014
+ const bend = far > 0 ? turnFraction * pinnedCylinderBend(x, radius, fullTheta) : pinnedCylinderBend(x, radius, theta);
2015
+ offsets.push(bend - bulgeAtStop * far, 0);
1998
2016
  }
1999
2017
  return { value: deg, offsets };
2000
2018
  });
@@ -2026,10 +2044,15 @@ function bakeHairSwayWarp(mesh, parameter, tipShift, range) {
2026
2044
  ]
2027
2045
  };
2028
2046
  }
2047
+ var HEAD_TURN_TRAVEL = 50;
2048
+ var BODY_TURN_FOLLOW = 0.3;
2049
+ var HEAD_BREATH_BOB = -12;
2050
+ var BODY_BREATH_FOLLOW = 0.5;
2029
2051
  function generateIkiFromLayerSet(layers, canvas) {
2030
2052
  validateLayerInputs(layers, canvas);
2031
2053
  const hasHair = layers.some((l) => l.role === "hair_front");
2032
2054
  const hasMouthOpen = layers.some((l) => l.role === "mouth_open");
2055
+ const bodyLayer = layers.find((l) => l.role === "body");
2033
2056
  const parameters = [
2034
2057
  {
2035
2058
  id: StandardParameter.MouthOpen,
@@ -2193,11 +2216,11 @@ function generateIkiFromLayerSet(layers, canvas) {
2193
2216
  const faceGridMinX = faceCenterX - halfW;
2194
2217
  const faceGridMaxX = faceCenterX + halfW;
2195
2218
  const faceGrid = {
2196
- cols: 4,
2197
- rows: 4,
2219
+ cols: FACE_GRID_CELLS,
2220
+ rows: FACE_GRID_CELLS,
2198
2221
  points: generateGridPoints(
2199
- 4,
2200
- 4,
2222
+ FACE_GRID_CELLS,
2223
+ FACE_GRID_CELLS,
2201
2224
  faceGridMinX,
2202
2225
  faceGridMaxX,
2203
2226
  unionMinY,
@@ -2228,21 +2251,20 @@ function generateIkiFromLayerSet(layers, canvas) {
2228
2251
  id: "headDeformer",
2229
2252
  pivot: neckPivot,
2230
2253
  bindings: [
2231
- {
2232
- parameter: StandardParameter.AngleX,
2233
- channel: "rotate",
2234
- from: 6,
2235
- to: -6
2236
- },
2254
+ // The turn is a pure yaw: no roll rides on AngleX. The sample model's
2255
+ // ±6° "lean into the turn" was tried here and dropped — rotating about
2256
+ // the neck pivot swings the crown (~500px above it) far more than the
2257
+ // chin, so the top of the head appeared to lunge ahead of the face on
2258
+ // every turn. Roll is AngleZ's job, below.
2237
2259
  {
2238
2260
  parameter: StandardParameter.AngleX,
2239
2261
  channel: "translateX",
2240
- from: -50,
2241
- to: 50
2262
+ from: -HEAD_TURN_TRAVEL,
2263
+ to: HEAD_TURN_TRAVEL
2242
2264
  },
2243
2265
  // Nod: a vertical translate only. No rotate — a pitch expressed as a
2244
- // rigid rotation would sum with the roll below at diagonal poses,
2245
- // collapsing pitch into roll.
2266
+ // rigid rotation would sum with the AngleZ roll below at diagonal
2267
+ // poses, collapsing pitch into roll.
2246
2268
  {
2247
2269
  parameter: StandardParameter.AngleY,
2248
2270
  channel: "translateY",
@@ -2251,10 +2273,10 @@ function generateIkiFromLayerSet(layers, canvas) {
2251
2273
  },
2252
2274
  // Tilt: the whole head rolls about the neck pivot, one degree per
2253
2275
  // degree. Positive AngleZ is clockwise on screen (engine rotate is
2254
- // CCW-positive, hence the flipped from/to) — Live2D's convention, and
2255
- // the same sense as the lean above: in Live2D's own sample motions
2256
- // AngleZ carries the sign of AngleX 214 times out of 222. The two
2257
- // rotations sum, which is correct for two rolls about one pivot.
2276
+ // CCW-positive, hence the flipped from/to) — Live2D's convention: in
2277
+ // its own sample motions AngleZ carries the sign of AngleX 214 times
2278
+ // out of 222, i.e. a head that leans into its turn tilts clockwise on
2279
+ // a turn to the viewer's right. The only rotate binding on the head.
2258
2280
  {
2259
2281
  parameter: StandardParameter.AngleZ,
2260
2282
  channel: "rotate",
@@ -2265,7 +2287,7 @@ function generateIkiFromLayerSet(layers, canvas) {
2265
2287
  parameter: StandardParameter.Breath,
2266
2288
  channel: "translateY",
2267
2289
  from: 0,
2268
- to: -12
2290
+ to: HEAD_BREATH_BOB
2269
2291
  }
2270
2292
  ]
2271
2293
  },
@@ -2281,6 +2303,34 @@ function generateIkiFromLayerSet(layers, canvas) {
2281
2303
  warp2d: faceWarp2d
2282
2304
  }
2283
2305
  ];
2306
+ if (bodyLayer) {
2307
+ const bt = bboxToTransform(
2308
+ bodyLayer.bbox,
2309
+ bodyLayer.canvasW,
2310
+ bodyLayer.canvasH,
2311
+ "body"
2312
+ );
2313
+ deformers.push({
2314
+ id: "bodyDeformer",
2315
+ pivot: { x: bt.x, y: bt.y - bodyLayer.cropH / 2 },
2316
+ bindings: [
2317
+ // Low-weight follow of the turn, same direction as the head.
2318
+ {
2319
+ parameter: StandardParameter.AngleX,
2320
+ channel: "translateX",
2321
+ from: -BODY_TURN_FOLLOW * HEAD_TURN_TRAVEL,
2322
+ to: BODY_TURN_FOLLOW * HEAD_TURN_TRAVEL
2323
+ },
2324
+ // Breath: follow the head's bob, same direction, half amplitude.
2325
+ {
2326
+ parameter: StandardParameter.Breath,
2327
+ channel: "translateY",
2328
+ from: 0,
2329
+ to: BODY_BREATH_FOLLOW * HEAD_BREATH_BOB
2330
+ }
2331
+ ]
2332
+ });
2333
+ }
2284
2334
  const eyeCreaseBySide = {};
2285
2335
  for (const layer of layers) {
2286
2336
  const side = ROLE_TABLE[layer.role].eyeSide;
@@ -2305,7 +2355,8 @@ function generateIkiFromLayerSet(layers, canvas) {
2305
2355
  });
2306
2356
  const deformerId = spec.deformer === "none" ? void 0 : spec.deformer;
2307
2357
  if (spec.mesh) {
2308
- const mesh = createPixelGridMesh(4, 4, cropW, cropH);
2358
+ const { cols, rows } = meshCellsFor(cropW, cropH);
2359
+ const mesh = createPixelGridMesh(cols, rows, cropW, cropH);
2309
2360
  const part = {
2310
2361
  id: role,
2311
2362
  color: [1, 1, 1, 1],
@@ -2321,6 +2372,7 @@ function generateIkiFromLayerSet(layers, canvas) {
2321
2372
  }
2322
2373
  if ((role === "hair_front" || role === "hair_back") && hasHair) {
2323
2374
  let tipShift = HAIR_SWAY_TIP_FRACTION * cropH;
2375
+ let leadWarp;
2324
2376
  if (role === "hair_front") {
2325
2377
  const shift = HAIR_FRONT_DEPTH * parallaxUnit;
2326
2378
  const headroom = Math.min(
@@ -2331,6 +2383,12 @@ function generateIkiFromLayerSet(layers, canvas) {
2331
2383
  tipShift,
2332
2384
  Math.max(0, headroom) / HAIR_SWAY_PEAK_FRACTION
2333
2385
  );
2386
+ leadWarp = bakeHairSwayWarp(
2387
+ mesh,
2388
+ StandardParameter.AngleX,
2389
+ shift,
2390
+ HEAD_TURN_MAX_DEG
2391
+ );
2334
2392
  }
2335
2393
  part.warps = [
2336
2394
  bakeHairSwayWarp(
@@ -2344,7 +2402,8 @@ function generateIkiFromLayerSet(layers, canvas) {
2344
2402
  StandardParameter.HairSwayZ,
2345
2403
  tipShift,
2346
2404
  HAIR_SWAY_RANGE
2347
- )
2405
+ ),
2406
+ ...leadWarp ? [leadWarp] : []
2348
2407
  ];
2349
2408
  }
2350
2409
  if (role === "hair_back") {