@ikijs/editor 0.1.0 → 0.2.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
@@ -1,15 +1,15 @@
1
1
  # @ikijs/editor
2
2
 
3
- **Headless** editing core for [`.iki`](../format) models — a document with
3
+ **Headless** editing core for [`.iki`](https://github.com/zeikar/iki/tree/main/packages/format) models — a document with
4
4
  undo/redo, invertible edit commands, atlas layout + UV math, and the auto-rigger.
5
5
 
6
6
  This package ships no UI. It is the model and command layer an editor is built
7
7
  _on_, not an editor you can mount.
8
8
 
9
9
  Everything here is pure logic — no DOM, no canvas, no WebGL. It depends only on
10
- [`@ikijs/format`](../format), so the same core backs this repo's
10
+ [`@ikijs/format`](https://github.com/zeikar/iki/tree/main/packages/format), so the same core backs this repo's
11
11
  browser editor app (`examples/editor`), the Node MCP server
12
- ([`@ikijs/mcp`](../mcp)), and tests.
12
+ ([`@ikijs/mcp`](https://github.com/zeikar/iki/tree/main/packages/mcp)), and tests.
13
13
 
14
14
  ## Install
15
15
 
package/dist/index.js CHANGED
@@ -1663,14 +1663,25 @@ function createDefaultWarpDeformer(model) {
1663
1663
  // src/auto-rig.ts
1664
1664
  var import_format3 = require("@ikijs/format");
1665
1665
  var ROLE_TABLE = {
1666
- // hair_back stays rigid (silhouette behind the face); per-layer front/back
1667
- // depth parallax is a later slice.
1668
- hair_back: { deformer: "headDeformer", order: 0, mesh: false },
1666
+ // The silhouette behind the face. It rides the rigid head (not faceWarp,
1667
+ // whose grid it would stretch across the shoulders) as a MESH: it bends on
1668
+ // the turn through its own part warp (bakeHairBackTurnWarp) and swings its
1669
+ // ends on the hair-sway warps.
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 },
1669
1676
  face: { deformer: "faceWarp", order: 10, mesh: true },
1670
1677
  nose: { deformer: "faceWarp", order: 15, mesh: true },
1671
1678
  blush_L: { deformer: "faceWarp", order: 20, mesh: true },
1672
1679
  blush_R: { deformer: "faceWarp", order: 20, mesh: true },
1673
1680
  mouth: { deformer: "faceWarp", order: 25, mesh: true },
1681
+ // An OPTIONAL second mouth drawing, open. When present the two cross-fade on
1682
+ // MouthOpen instead of the closed one being stretched, which is the
1683
+ // difference between a portrait rig and one that can lip-sync.
1684
+ mouth_open: { deformer: "faceWarp", order: 26, mesh: true },
1674
1685
  eye_L: { deformer: "faceWarp", order: 30, mesh: true, eyeSide: "L" },
1675
1686
  eye_R: { deformer: "faceWarp", order: 30, mesh: true, eyeSide: "R" },
1676
1687
  iris_L: { deformer: "faceWarp", order: 31, mesh: true, eyeSide: "L" },
@@ -1822,29 +1833,63 @@ function createPixelGridMesh(cols, rows, w, h) {
1822
1833
  }
1823
1834
  return { vertices, uvs, indices };
1824
1835
  }
1825
- function bakeHeadTurnGridWarpCentered(grid, parameter, centerX) {
1826
- const ANGLES = [-30, 0, 30];
1836
+ var HEAD_CYLINDER_RADIUS_FACTOR = 0.6 / 0.5;
1837
+ var HEAD_TURN_MAX_DEG = 30;
1838
+ function headTurnParallaxUnit(gridHalfWidth) {
1839
+ return gridHalfWidth * HEAD_CYLINDER_RADIUS_FACTOR * Math.sin(HEAD_TURN_MAX_DEG * (Math.PI / 180));
1840
+ }
1841
+ function pinnedCylinderBend(local, radius, theta) {
1842
+ const alpha = Math.asin(Math.max(-1, Math.min(1, local / radius)));
1843
+ return radius * Math.sin(alpha + theta) - local - radius * Math.sin(theta);
1844
+ }
1845
+ function bakeHeadTurnGridWarp2DCentered(grid, parameterX, parameterY, centerX, centerY) {
1846
+ const STOPS = [-HEAD_TURN_MAX_DEG, 0, HEAD_TURN_MAX_DEG];
1827
1847
  const halfWidth = (grid.points[grid.cols * 2] - grid.points[0]) / 2;
1828
- const RADIUS = halfWidth * (0.6 / 0.5);
1848
+ const RADIUS_X = halfWidth * HEAD_CYLINDER_RADIUS_FACTOR;
1829
1849
  const pointCount = grid.points.length / 2;
1850
+ let halfHeight = 0;
1851
+ for (let i = 0; i < pointCount; i++) {
1852
+ halfHeight = Math.max(
1853
+ halfHeight,
1854
+ Math.abs(grid.points[i * 2 + 1] - centerY)
1855
+ );
1856
+ }
1857
+ const RADIUS_Y = halfHeight * HEAD_CYLINDER_RADIUS_FACTOR;
1830
1858
  const DEG_TO_RAD = Math.PI / 180;
1831
- const keyforms = ANGLES.map((angleDeg) => {
1832
- const theta = angleDeg * DEG_TO_RAD;
1833
- const offsets = [];
1834
- for (let i = 0; i < pointCount; i++) {
1835
- const x = grid.points[i * 2];
1836
- const localX = x - centerX;
1837
- const alpha = Math.asin(Math.max(-1, Math.min(1, localX / RADIUS)));
1838
- const xPrime = centerX + RADIUS * Math.sin(alpha + theta);
1839
- const dx = xPrime - x;
1840
- offsets.push(dx, 0);
1841
- }
1842
- return { value: angleDeg, offsets };
1843
- });
1844
- return { parameter, keyforms };
1859
+ const keyforms2d = [];
1860
+ for (const angleY of STOPS) {
1861
+ const thetaY = angleY * NOD_BEND * DEG_TO_RAD;
1862
+ for (const angleX of STOPS) {
1863
+ const thetaX = angleX * DEG_TO_RAD;
1864
+ const offsets = [];
1865
+ for (let i = 0; i < pointCount; i++) {
1866
+ offsets.push(
1867
+ pinnedCylinderBend(grid.points[i * 2] - centerX, RADIUS_X, thetaX),
1868
+ pinnedCylinderBend(
1869
+ grid.points[i * 2 + 1] - centerY,
1870
+ RADIUS_Y,
1871
+ thetaY
1872
+ )
1873
+ );
1874
+ }
1875
+ keyforms2d.push({ offsets });
1876
+ }
1877
+ }
1878
+ return {
1879
+ parameter: parameterX,
1880
+ parameterY,
1881
+ valuesX: STOPS,
1882
+ valuesY: STOPS,
1883
+ keyforms2d
1884
+ };
1845
1885
  }
1846
1886
  var EYE_STACK_PREFIXES = ["eye_", "iris_", "pupil_", "highlight_"];
1847
- function bindingsForRole(spec, role, cropW, cropH) {
1887
+ var HAIR_FRONT_DEPTH = 0.1;
1888
+ var HAIR_BACK_DEPTH = -0.08;
1889
+ var NOD_TRAVEL = 30;
1890
+ var NOD_BEND = 0.5;
1891
+ var HAIR_BACK_NOD_DEPTH = -0.07;
1892
+ function bindingsForRole(spec, role, cropW, cropH, options = {}) {
1848
1893
  const isEyeStack = EYE_STACK_PREFIXES.some((p) => role.startsWith(p));
1849
1894
  if (isEyeStack && spec.eyeSide !== void 0) {
1850
1895
  const isGazeRole = role.startsWith("iris_") || role.startsWith("pupil_") || role.startsWith("highlight_");
@@ -1866,22 +1911,47 @@ function bindingsForRole(spec, role, cropW, cropH) {
1866
1911
  }
1867
1912
  ];
1868
1913
  }
1914
+ const mouthForm = {
1915
+ // Mouth form: scaleX from -0.2 (pursed, param=-1) to 0.4 (wide, param=1).
1916
+ parameter: import_format3.StandardParameter.MouthForm,
1917
+ channel: "scaleX",
1918
+ from: -0.2,
1919
+ to: 0.4
1920
+ };
1869
1921
  if (role === "mouth") {
1922
+ if (options.hasMouthOpen) {
1923
+ return [
1924
+ {
1925
+ parameter: import_format3.StandardParameter.MouthOpen,
1926
+ channel: "opacity",
1927
+ from: 1,
1928
+ to: 0
1929
+ },
1930
+ mouthForm
1931
+ ];
1932
+ }
1870
1933
  return [
1871
1934
  // Mouth open: scaleY from 0 (closed, param=0) to 3 (wide open, param=1).
1935
+ // Only a fallback — it distorts, but it is better than a mouth that
1936
+ // cannot open at all when the layer set has no open drawing.
1872
1937
  {
1873
1938
  parameter: import_format3.StandardParameter.MouthOpen,
1874
1939
  channel: "scaleY",
1875
1940
  from: 0,
1876
1941
  to: 3
1877
1942
  },
1878
- // Mouth form: scaleX from -0.2 (pursed, param=-1) to 0.4 (wide, param=1).
1943
+ mouthForm
1944
+ ];
1945
+ }
1946
+ if (role === "mouth_open") {
1947
+ return [
1879
1948
  {
1880
- parameter: import_format3.StandardParameter.MouthForm,
1881
- channel: "scaleX",
1882
- from: -0.2,
1883
- to: 0.4
1884
- }
1949
+ parameter: import_format3.StandardParameter.MouthOpen,
1950
+ channel: "opacity",
1951
+ from: 0,
1952
+ to: 1
1953
+ },
1954
+ mouthForm
1885
1955
  ];
1886
1956
  }
1887
1957
  if (role === "brow_L" || role === "brow_R") {
@@ -1919,21 +1989,29 @@ function bindingsForRole(spec, role, cropW, cropH) {
1919
1989
  ];
1920
1990
  }
1921
1991
  }
1922
- if (role === "hair_front") {
1923
- return [
1924
- {
1925
- parameter: import_format3.StandardParameter.HairSwayX,
1926
- channel: "rotate",
1927
- from: -8,
1928
- to: 8
1929
- },
1930
- {
1931
- parameter: import_format3.StandardParameter.HairSwayX,
1992
+ if (role === "hair_front" || role === "hair_back") {
1993
+ const isFront = role === "hair_front";
1994
+ const depth = isFront ? HAIR_FRONT_DEPTH : HAIR_BACK_DEPTH;
1995
+ const parallax = [];
1996
+ const shiftX = depth * (options.parallaxUnit ?? 0);
1997
+ if (shiftX !== 0) {
1998
+ parallax.push({
1999
+ parameter: import_format3.StandardParameter.AngleX,
1932
2000
  channel: "translateX",
1933
- from: -10,
1934
- to: 10
1935
- }
1936
- ];
2001
+ from: -shiftX,
2002
+ to: shiftX
2003
+ });
2004
+ }
2005
+ const shiftY = (isFront ? 0 : HAIR_BACK_NOD_DEPTH) * (options.parallaxUnitY ?? 0);
2006
+ if (shiftY !== 0) {
2007
+ parallax.push({
2008
+ parameter: import_format3.StandardParameter.AngleY,
2009
+ channel: "translateY",
2010
+ from: -shiftY,
2011
+ to: shiftY
2012
+ });
2013
+ }
2014
+ return parallax;
1937
2015
  }
1938
2016
  return [];
1939
2017
  }
@@ -1956,9 +2034,61 @@ function bakeEyelidFoldWarp(mesh, parameter, creaseOffsetY, k) {
1956
2034
  ]
1957
2035
  };
1958
2036
  }
2037
+ var HAIR_BACK_BEND_RADIUS_FACTOR = 2.5;
2038
+ var HAIR_BACK_FAR_BULGE = 0.22;
2039
+ function bakeHairBackTurnWarp(mesh, parameter) {
2040
+ let left = Infinity;
2041
+ let right = -Infinity;
2042
+ for (let i = 0; i < mesh.vertices.length; i += 2) {
2043
+ left = Math.min(left, mesh.vertices[i]);
2044
+ right = Math.max(right, mesh.vertices[i]);
2045
+ }
2046
+ const halfWidth = (right - left) / 2;
2047
+ const radius = halfWidth * HAIR_BACK_BEND_RADIUS_FACTOR;
2048
+ const bulge = HAIR_BACK_FAR_BULGE * halfWidth;
2049
+ const DEG_TO_RAD = Math.PI / 180;
2050
+ const keyforms = [-HEAD_TURN_MAX_DEG, 0, HEAD_TURN_MAX_DEG].map((deg) => {
2051
+ const theta = deg * DEG_TO_RAD;
2052
+ const s = Math.sign(deg);
2053
+ const offsets = [];
2054
+ for (let i = 0; i < mesh.vertices.length; i += 2) {
2055
+ const x = mesh.vertices[i];
2056
+ const far = Math.max(0, -s * x / halfWidth);
2057
+ offsets.push(pinnedCylinderBend(x, radius, theta) - s * bulge * far, 0);
2058
+ }
2059
+ return { value: deg, offsets };
2060
+ });
2061
+ return { parameter, keyforms };
2062
+ }
2063
+ var HAIR_SWAY_TIP_FRACTION = 0.09;
2064
+ var HAIR_SWAY_CURL = 1.5;
2065
+ var HAIR_SWAY_RANGE = 20;
2066
+ function bakeHairSwayWarp(mesh, parameter, tipShift, range) {
2067
+ let top = -Infinity;
2068
+ let bottom = Infinity;
2069
+ for (let i = 1; i < mesh.vertices.length; i += 2) {
2070
+ top = Math.max(top, mesh.vertices[i]);
2071
+ bottom = Math.min(bottom, mesh.vertices[i]);
2072
+ }
2073
+ const height = top - bottom;
2074
+ const swing = [];
2075
+ for (let i = 0; i < mesh.vertices.length; i += 2) {
2076
+ const u = height > 0 ? (top - mesh.vertices[i + 1]) / height : 0;
2077
+ swing.push(tipShift * Math.pow(u, HAIR_SWAY_CURL), 0);
2078
+ }
2079
+ return {
2080
+ parameter,
2081
+ keyforms: [
2082
+ // `0 - v`, not `-v`: the pinned root row must stay a plain +0.
2083
+ { value: -range, offsets: swing.map((v) => 0 - v) },
2084
+ { value: range, offsets: swing }
2085
+ ]
2086
+ };
2087
+ }
1959
2088
  function generateIkiFromLayerSet(layers, canvas) {
1960
2089
  validateLayerInputs(layers, canvas);
1961
2090
  const hasHair = layers.some((l) => l.role === "hair_front");
2091
+ const hasMouthOpen = layers.some((l) => l.role === "mouth_open");
1962
2092
  const parameters = [
1963
2093
  {
1964
2094
  id: import_format3.StandardParameter.MouthOpen,
@@ -2009,6 +2139,20 @@ function generateIkiFromLayerSet(layers, canvas) {
2009
2139
  max: 30,
2010
2140
  default: 0
2011
2141
  },
2142
+ {
2143
+ id: import_format3.StandardParameter.AngleY,
2144
+ name: "Head Angle Y",
2145
+ min: -30,
2146
+ max: 30,
2147
+ default: 0
2148
+ },
2149
+ {
2150
+ id: import_format3.StandardParameter.AngleZ,
2151
+ name: "Head Angle Z",
2152
+ min: -30,
2153
+ max: 30,
2154
+ default: 0
2155
+ },
2012
2156
  {
2013
2157
  id: import_format3.StandardParameter.Breath,
2014
2158
  name: "Breath",
@@ -2046,13 +2190,22 @@ function generateIkiFromLayerSet(layers, canvas) {
2046
2190
  }
2047
2191
  ];
2048
2192
  if (hasHair) {
2049
- parameters.push({
2050
- id: import_format3.StandardParameter.HairSwayX,
2051
- name: "Hair Sway X",
2052
- min: -20,
2053
- max: 20,
2054
- default: 0
2055
- });
2193
+ parameters.push(
2194
+ {
2195
+ id: import_format3.StandardParameter.HairSwayX,
2196
+ name: "Hair Sway X",
2197
+ min: -HAIR_SWAY_RANGE,
2198
+ max: HAIR_SWAY_RANGE,
2199
+ default: 0
2200
+ },
2201
+ {
2202
+ id: import_format3.StandardParameter.HairSwayZ,
2203
+ name: "Hair Sway Z",
2204
+ min: -HAIR_SWAY_RANGE,
2205
+ max: HAIR_SWAY_RANGE,
2206
+ default: 0
2207
+ }
2208
+ );
2056
2209
  }
2057
2210
  const faceLayers = layers.filter((l) => l.role === "face");
2058
2211
  const faceLayer = faceLayers[0];
@@ -2110,16 +2263,22 @@ function generateIkiFromLayerSet(layers, canvas) {
2110
2263
  unionMaxY
2111
2264
  )
2112
2265
  };
2266
+ const faceCenterY = faceTransform.y;
2267
+ const halfH = Math.max(faceCenterY - unionMinY, unionMaxY - faceCenterY);
2268
+ const parallaxUnit = headTurnParallaxUnit(halfW);
2269
+ const parallaxUnitY = headTurnParallaxUnit(halfH);
2113
2270
  const faceBottom = faceTransform.y - faceCropH / 2;
2114
2271
  const neckPivot = {
2115
2272
  x: faceCenterX,
2116
2273
  y: faceBottom - faceCropH * 0.15
2117
2274
  // 15% below face bottom = neck
2118
2275
  };
2119
- const faceWarpBake = bakeHeadTurnGridWarpCentered(
2276
+ const faceWarp2d = bakeHeadTurnGridWarp2DCentered(
2120
2277
  faceGrid,
2121
2278
  import_format3.StandardParameter.AngleX,
2122
- faceCenterX
2279
+ import_format3.StandardParameter.AngleY,
2280
+ faceCenterX,
2281
+ faceCenterY
2123
2282
  );
2124
2283
  const deformers = [
2125
2284
  // headDeformer: rigid matrix rotating/translating the whole head about the
@@ -2140,6 +2299,27 @@ function generateIkiFromLayerSet(layers, canvas) {
2140
2299
  from: -50,
2141
2300
  to: 50
2142
2301
  },
2302
+ // 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.
2305
+ {
2306
+ parameter: import_format3.StandardParameter.AngleY,
2307
+ channel: "translateY",
2308
+ from: -NOD_TRAVEL,
2309
+ to: NOD_TRAVEL
2310
+ },
2311
+ // Tilt: the whole head rolls about the neck pivot, one degree per
2312
+ // 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.
2317
+ {
2318
+ parameter: import_format3.StandardParameter.AngleZ,
2319
+ channel: "rotate",
2320
+ from: 30,
2321
+ to: -30
2322
+ },
2143
2323
  {
2144
2324
  parameter: import_format3.StandardParameter.Breath,
2145
2325
  channel: "translateY",
@@ -2150,12 +2330,14 @@ function generateIkiFromLayerSet(layers, canvas) {
2150
2330
  },
2151
2331
  // faceWarp: cylinder-bend warp parented to headDeformer; grid is symmetric
2152
2332
  // about faceCenterX so the bake's cylinder axis aligns with the face center.
2333
+ // One 2D warp carries both the turn and the nod (a deformer holds either
2334
+ // `warps` or `warp2d`, never both).
2153
2335
  {
2154
2336
  kind: "warp",
2155
2337
  id: "faceWarp",
2156
2338
  parent: "headDeformer",
2157
2339
  grid: faceGrid,
2158
- warps: [faceWarpBake]
2340
+ warp2d: faceWarp2d
2159
2341
  }
2160
2342
  ];
2161
2343
  const eyeCreaseBySide = {};
@@ -2175,7 +2357,12 @@ function generateIkiFromLayerSet(layers, canvas) {
2175
2357
  const { role, bbox, cropW, cropH, canvasW, canvasH } = layer;
2176
2358
  const spec = ROLE_TABLE[role];
2177
2359
  const t = bboxToTransform(bbox, canvasW, canvasH, role);
2178
- const roleBindings = bindingsForRole(spec, role, cropW, cropH);
2360
+ const roleBindings = bindingsForRole(spec, role, cropW, cropH, {
2361
+ hasMouthOpen,
2362
+ parallaxUnit,
2363
+ parallaxUnitY
2364
+ });
2365
+ const deformerId = spec.deformer === "none" ? void 0 : spec.deformer;
2179
2366
  if (spec.mesh) {
2180
2367
  const mesh = createPixelGridMesh(4, 4, cropW, cropH);
2181
2368
  const part = {
@@ -2185,23 +2372,47 @@ function generateIkiFromLayerSet(layers, canvas) {
2185
2372
  height: 1,
2186
2373
  order: spec.order,
2187
2374
  transform: t,
2188
- deformer: spec.deformer,
2375
+ deformer: deformerId,
2189
2376
  mesh
2190
2377
  };
2191
2378
  if (roleBindings.length > 0) {
2192
2379
  part.bindings = roleBindings;
2193
2380
  }
2381
+ if ((role === "hair_front" || role === "hair_back") && hasHair) {
2382
+ const tipShift = HAIR_SWAY_TIP_FRACTION * cropH;
2383
+ part.warps = [
2384
+ bakeHairSwayWarp(
2385
+ mesh,
2386
+ import_format3.StandardParameter.HairSwayX,
2387
+ tipShift,
2388
+ HAIR_SWAY_RANGE
2389
+ ),
2390
+ bakeHairSwayWarp(
2391
+ mesh,
2392
+ import_format3.StandardParameter.HairSwayZ,
2393
+ tipShift,
2394
+ HAIR_SWAY_RANGE
2395
+ )
2396
+ ];
2397
+ }
2398
+ if (role === "hair_back") {
2399
+ part.warps = [
2400
+ ...part.warps ?? [],
2401
+ bakeHairBackTurnWarp(mesh, import_format3.StandardParameter.AngleX)
2402
+ ];
2403
+ }
2194
2404
  if (spec.eyeSide !== void 0) {
2195
2405
  const isLash = role.startsWith("lash_");
2196
2406
  if (role.startsWith("eye_") || isLash) {
2197
2407
  const openParam = spec.eyeSide === "L" ? import_format3.StandardParameter.EyeOpenLeft : import_format3.StandardParameter.EyeOpenRight;
2198
2408
  const creaseWorldY = eyeCreaseBySide[spec.eyeSide] ?? t.y - EYELID_FOLD_CREASE * cropH;
2409
+ const hasLash = layers.some((l) => l.role === `lash_${spec.eyeSide}`);
2199
2410
  part.warps = [
2200
2411
  bakeEyelidFoldWarp(
2201
2412
  mesh,
2202
2413
  openParam,
2203
2414
  creaseWorldY - t.y,
2204
- isLash ? LASH_FOLD_K : EYELID_FOLD_K
2415
+ isLash ? LASH_FOLD_K : hasLash ? 0 : EYELID_FOLD_K
2205
2416
  )
2206
2417
  ];
2207
2418
  } else {
@@ -2210,15 +2421,19 @@ function generateIkiFromLayerSet(layers, canvas) {
2210
2421
  }
2211
2422
  return part;
2212
2423
  } else {
2213
- return {
2424
+ const part = {
2214
2425
  id: role,
2215
2426
  color: [1, 1, 1, 1],
2216
2427
  width: cropW,
2217
2428
  height: cropH,
2218
2429
  order: spec.order,
2219
2430
  transform: t,
2220
- deformer: spec.deformer
2431
+ deformer: deformerId
2221
2432
  };
2433
+ if (roleBindings.length > 0) {
2434
+ part.bindings = roleBindings;
2435
+ }
2436
+ return part;
2222
2437
  }
2223
2438
  });
2224
2439
  const model = {
@@ -2229,9 +2444,12 @@ function generateIkiFromLayerSet(layers, canvas) {
2229
2444
  parameters,
2230
2445
  deformers,
2231
2446
  parts,
2232
- // Secondary motion: a spring lags AngleX onto HairSwayX so front hair sways
2233
- // behind the head turn (same constants as the hand-authored sample). Omitted
2234
- // when there is no front hair to drive.
2447
+ // Secondary motion: one spring lags AngleX onto HairSwayX so front hair
2448
+ // sways behind the head turn (same constants as the hand-authored sample),
2449
+ // and a second lags AngleZ onto HairSwayZ so it swings behind a tilt. Two
2450
+ // rigs because a rig has one input and one output (validator-enforced).
2451
+ // Both hair parts read the outputs through root-pinned sway warps.
2452
+ // Omitted when there is no front hair to drive.
2235
2453
  physics: hasHair ? [
2236
2454
  {
2237
2455
  id: "hairSway",
@@ -2240,6 +2458,14 @@ function generateIkiFromLayerSet(layers, canvas) {
2240
2458
  mass: 1,
2241
2459
  stiffness: 80,
2242
2460
  damping: 10
2461
+ },
2462
+ {
2463
+ id: "hairTilt",
2464
+ input: { parameter: import_format3.StandardParameter.AngleZ, weight: 1 },
2465
+ output: { parameter: import_format3.StandardParameter.HairSwayZ, scale: -10 },
2466
+ mass: 1,
2467
+ stiffness: 80,
2468
+ damping: 10
2243
2469
  }
2244
2470
  ] : void 0
2245
2471
  };