@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/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,28 +1775,39 @@ 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
  }
1795
+ function gridReach(grid, axis, center) {
1796
+ let reach = 0;
1797
+ for (let i = axis; i < grid.points.length; i += 2) {
1798
+ reach = Math.max(reach, Math.abs(grid.points[i] - center));
1799
+ }
1800
+ return reach;
1801
+ }
1782
1802
  function pinnedCylinderBend(local, radius, theta) {
1783
1803
  const alpha = Math.asin(Math.max(-1, Math.min(1, local / radius)));
1784
1804
  return radius * Math.sin(alpha + theta) - local - radius * Math.sin(theta);
1785
1805
  }
1786
1806
  function bakeHeadTurnGridWarp2DCentered(grid, parameterX, parameterY, centerX, centerY) {
1787
- const STOPS = [-HEAD_TURN_MAX_DEG, 0, HEAD_TURN_MAX_DEG];
1788
- const halfWidth = (grid.points[grid.cols * 2] - grid.points[0]) / 2;
1789
- const RADIUS_X = halfWidth * HEAD_CYLINDER_RADIUS_FACTOR;
1807
+ const STOPS = [...HEAD_TURN_STOPS];
1808
+ const RADIUS_X = gridReach(grid, 0, centerX) * HEAD_CYLINDER_RADIUS_FACTOR;
1809
+ const RADIUS_Y = gridReach(grid, 1, centerY) * HEAD_CYLINDER_RADIUS_FACTOR;
1790
1810
  const pointCount = grid.points.length / 2;
1791
- let halfHeight = 0;
1792
- for (let i = 0; i < pointCount; i++) {
1793
- halfHeight = Math.max(
1794
- halfHeight,
1795
- Math.abs(grid.points[i * 2 + 1] - centerY)
1796
- );
1797
- }
1798
- const RADIUS_Y = halfHeight * HEAD_CYLINDER_RADIUS_FACTOR;
1799
1811
  const DEG_TO_RAD = Math.PI / 180;
1800
1812
  const keyforms2d = [];
1801
1813
  for (const angleY of STOPS) {
@@ -1932,16 +1944,17 @@ function bindingsForRole(spec, role, cropW, cropH, options = {}) {
1932
1944
  }
1933
1945
  if (role === "hair_front" || role === "hair_back") {
1934
1946
  const isFront = role === "hair_front";
1935
- const depth = isFront ? HAIR_FRONT_DEPTH : HAIR_BACK_DEPTH;
1936
1947
  const parallax = [];
1937
- const shiftX = depth * (options.parallaxUnit ?? 0);
1938
- if (shiftX !== 0) {
1939
- parallax.push({
1940
- parameter: StandardParameter.AngleX,
1941
- channel: "translateX",
1942
- from: -shiftX,
1943
- to: shiftX
1944
- });
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
+ }
1945
1958
  }
1946
1959
  const shiftY = (isFront ? 0 : HAIR_BACK_NOD_DEPTH) * (options.parallaxUnitY ?? 0);
1947
1960
  if (shiftY !== 0) {
@@ -2004,6 +2017,7 @@ function bakeHairBackTurnWarp(mesh, parameter) {
2004
2017
  var HAIR_SWAY_TIP_FRACTION = 0.09;
2005
2018
  var HAIR_SWAY_CURL = 1.5;
2006
2019
  var HAIR_SWAY_RANGE = 20;
2020
+ var HAIR_SWAY_PEAK_FRACTION = 0.56;
2007
2021
  function bakeHairSwayWarp(mesh, parameter, tipShift, range) {
2008
2022
  let top = -Infinity;
2009
2023
  let bottom = Infinity;
@@ -2026,10 +2040,15 @@ function bakeHairSwayWarp(mesh, parameter, tipShift, range) {
2026
2040
  ]
2027
2041
  };
2028
2042
  }
2043
+ var HEAD_TURN_TRAVEL = 50;
2044
+ var BODY_TURN_FOLLOW = 0.3;
2045
+ var HEAD_BREATH_BOB = -12;
2046
+ var BODY_BREATH_FOLLOW = 0.5;
2029
2047
  function generateIkiFromLayerSet(layers, canvas) {
2030
2048
  validateLayerInputs(layers, canvas);
2031
2049
  const hasHair = layers.some((l) => l.role === "hair_front");
2032
2050
  const hasMouthOpen = layers.some((l) => l.role === "mouth_open");
2051
+ const bodyLayer = layers.find((l) => l.role === "body");
2033
2052
  const parameters = [
2034
2053
  {
2035
2054
  id: StandardParameter.MouthOpen,
@@ -2193,11 +2212,11 @@ function generateIkiFromLayerSet(layers, canvas) {
2193
2212
  const faceGridMinX = faceCenterX - halfW;
2194
2213
  const faceGridMaxX = faceCenterX + halfW;
2195
2214
  const faceGrid = {
2196
- cols: 4,
2197
- rows: 4,
2215
+ cols: FACE_GRID_CELLS,
2216
+ rows: FACE_GRID_CELLS,
2198
2217
  points: generateGridPoints(
2199
- 4,
2200
- 4,
2218
+ FACE_GRID_CELLS,
2219
+ FACE_GRID_CELLS,
2201
2220
  faceGridMinX,
2202
2221
  faceGridMaxX,
2203
2222
  unionMinY,
@@ -2228,21 +2247,20 @@ function generateIkiFromLayerSet(layers, canvas) {
2228
2247
  id: "headDeformer",
2229
2248
  pivot: neckPivot,
2230
2249
  bindings: [
2231
- {
2232
- parameter: StandardParameter.AngleX,
2233
- channel: "rotate",
2234
- from: 6,
2235
- to: -6
2236
- },
2250
+ // The turn is a pure yaw: no roll rides on AngleX. The sample model's
2251
+ // ±6° "lean into the turn" was tried here and dropped — rotating about
2252
+ // the neck pivot swings the crown (~500px above it) far more than the
2253
+ // chin, so the top of the head appeared to lunge ahead of the face on
2254
+ // every turn. Roll is AngleZ's job, below.
2237
2255
  {
2238
2256
  parameter: StandardParameter.AngleX,
2239
2257
  channel: "translateX",
2240
- from: -50,
2241
- to: 50
2258
+ from: -HEAD_TURN_TRAVEL,
2259
+ to: HEAD_TURN_TRAVEL
2242
2260
  },
2243
2261
  // 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.
2262
+ // rigid rotation would sum with the AngleZ roll below at diagonal
2263
+ // poses, collapsing pitch into roll.
2246
2264
  {
2247
2265
  parameter: StandardParameter.AngleY,
2248
2266
  channel: "translateY",
@@ -2251,10 +2269,10 @@ function generateIkiFromLayerSet(layers, canvas) {
2251
2269
  },
2252
2270
  // Tilt: the whole head rolls about the neck pivot, one degree per
2253
2271
  // 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.
2272
+ // CCW-positive, hence the flipped from/to) — Live2D's convention: in
2273
+ // its own sample motions AngleZ carries the sign of AngleX 214 times
2274
+ // out of 222, i.e. a head that leans into its turn tilts clockwise on
2275
+ // a turn to the viewer's right. The only rotate binding on the head.
2258
2276
  {
2259
2277
  parameter: StandardParameter.AngleZ,
2260
2278
  channel: "rotate",
@@ -2265,7 +2283,7 @@ function generateIkiFromLayerSet(layers, canvas) {
2265
2283
  parameter: StandardParameter.Breath,
2266
2284
  channel: "translateY",
2267
2285
  from: 0,
2268
- to: -12
2286
+ to: HEAD_BREATH_BOB
2269
2287
  }
2270
2288
  ]
2271
2289
  },
@@ -2281,6 +2299,34 @@ function generateIkiFromLayerSet(layers, canvas) {
2281
2299
  warp2d: faceWarp2d
2282
2300
  }
2283
2301
  ];
2302
+ if (bodyLayer) {
2303
+ const bt = bboxToTransform(
2304
+ bodyLayer.bbox,
2305
+ bodyLayer.canvasW,
2306
+ bodyLayer.canvasH,
2307
+ "body"
2308
+ );
2309
+ deformers.push({
2310
+ id: "bodyDeformer",
2311
+ pivot: { x: bt.x, y: bt.y - bodyLayer.cropH / 2 },
2312
+ bindings: [
2313
+ // Low-weight follow of the turn, same direction as the head.
2314
+ {
2315
+ parameter: StandardParameter.AngleX,
2316
+ channel: "translateX",
2317
+ from: -BODY_TURN_FOLLOW * HEAD_TURN_TRAVEL,
2318
+ to: BODY_TURN_FOLLOW * HEAD_TURN_TRAVEL
2319
+ },
2320
+ // Breath: follow the head's bob, same direction, half amplitude.
2321
+ {
2322
+ parameter: StandardParameter.Breath,
2323
+ channel: "translateY",
2324
+ from: 0,
2325
+ to: BODY_BREATH_FOLLOW * HEAD_BREATH_BOB
2326
+ }
2327
+ ]
2328
+ });
2329
+ }
2284
2330
  const eyeCreaseBySide = {};
2285
2331
  for (const layer of layers) {
2286
2332
  const side = ROLE_TABLE[layer.role].eyeSide;
@@ -2305,7 +2351,8 @@ function generateIkiFromLayerSet(layers, canvas) {
2305
2351
  });
2306
2352
  const deformerId = spec.deformer === "none" ? void 0 : spec.deformer;
2307
2353
  if (spec.mesh) {
2308
- const mesh = createPixelGridMesh(4, 4, cropW, cropH);
2354
+ const { cols, rows } = meshCellsFor(cropW, cropH);
2355
+ const mesh = createPixelGridMesh(cols, rows, cropW, cropH);
2309
2356
  const part = {
2310
2357
  id: role,
2311
2358
  color: [1, 1, 1, 1],
@@ -2320,7 +2367,25 @@ function generateIkiFromLayerSet(layers, canvas) {
2320
2367
  part.bindings = roleBindings;
2321
2368
  }
2322
2369
  if ((role === "hair_front" || role === "hair_back") && hasHair) {
2323
- const tipShift = HAIR_SWAY_TIP_FRACTION * cropH;
2370
+ let tipShift = HAIR_SWAY_TIP_FRACTION * cropH;
2371
+ let leadWarp;
2372
+ if (role === "hair_front") {
2373
+ const shift = HAIR_FRONT_DEPTH * parallaxUnit;
2374
+ const headroom = Math.min(
2375
+ faceGridMaxX - (t.x + cropW / 2),
2376
+ t.x - cropW / 2 - faceGridMinX
2377
+ ) - shift;
2378
+ tipShift = Math.min(
2379
+ tipShift,
2380
+ Math.max(0, headroom) / HAIR_SWAY_PEAK_FRACTION
2381
+ );
2382
+ leadWarp = bakeHairSwayWarp(
2383
+ mesh,
2384
+ StandardParameter.AngleX,
2385
+ shift,
2386
+ HEAD_TURN_MAX_DEG
2387
+ );
2388
+ }
2324
2389
  part.warps = [
2325
2390
  bakeHairSwayWarp(
2326
2391
  mesh,
@@ -2333,7 +2398,8 @@ function generateIkiFromLayerSet(layers, canvas) {
2333
2398
  StandardParameter.HairSwayZ,
2334
2399
  tipShift,
2335
2400
  HAIR_SWAY_RANGE
2336
- )
2401
+ ),
2402
+ ...leadWarp ? [leadWarp] : []
2337
2403
  ];
2338
2404
  }
2339
2405
  if (role === "hair_back") {