@ikijs/editor 0.7.0 → 0.8.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
@@ -123,7 +123,12 @@ decodes with canvas, `@ikijs/mcp` decodes with `sharp`, and both feed the same
123
123
  pure function — including the optional `rowHalfWidths` (one entry per crop row,
124
124
  half that row's opaque span in canvas px), which `@ikijs/mcp` measures for the
125
125
  face so the plate turns on a radius that varies by row; absent, it turns on one
126
- constant radius.
126
+ constant radius. It also takes the optional `denseCore`, the tight box of a
127
+ layer's alpha ≥ 128 pixels in image coords, not grown, which `@ikijs/mcp`
128
+ measures for the nose: its centre and width are the nose's turn landmark, so a
129
+ shaded nose is fitted by its drawing rather than its soft feather, and its top
130
+ edge at its centre x is the bridge top the nose tilts 6° about on the turn, its
131
+ tip toward the far side; absent, the crop stands in for both.
127
132
 
128
133
  `*_L` / `*_R` are the **character's** sides — `eye_L` is the character's left
129
134
  eye, which appears on the viewer's right.
package/dist/index.d.mts CHANGED
@@ -888,6 +888,24 @@ interface LayerInput {
888
888
  * [0, cropW / 2], the most an inclusive span inside the crop can be.
889
889
  */
890
890
  rowHalfWidths?: number[];
891
+ /**
892
+ * The tight box of the layer's alpha ≥ 128 pixels, in image coords like
893
+ * `bbox` and NOT grown: the drawing a viewer reads inside a soft feather,
894
+ * which the crop's alpha ≥ 8 rule takes in whole. Optional, and only the
895
+ * NOSE's is read: its centre and width are the nose's turn landmark
896
+ * (`turnLandmarks`), so a shaded nose is fitted by the drawing rather than
897
+ * by its feather, and its top edge at its centre x is the bridge top the
898
+ * nose tilts about on the turn (`NOSE_TURN_TILT_DEG`). Absent — as the
899
+ * editor's own import leaves it — the crop stands in for both. Validated
900
+ * before anything reads it: a plain object whose x, y, w and h are finite,
901
+ * w and h positive, and the box inside `bbox`.
902
+ */
903
+ denseCore?: {
904
+ x: number;
905
+ y: number;
906
+ w: number;
907
+ h: number;
908
+ };
891
909
  }
892
910
  /**
893
911
  * Map an array of raw filenames to canonical `{ role, fileName }` pairs.
@@ -949,12 +967,18 @@ interface IrisStrand {
949
967
  *
950
968
  * What happens when the layer set cannot reach a target depends on where the
951
969
  * target came from. A number the CALLER passed is a measurement, so an
952
- * unreachable one throws, naming the field and what was on offer. A number this
953
- * module filled in — a default, or a nose/mouth derived from the eyes — is a
954
- * style prior, not a promise about this character: it is CLAMPED to what the
955
- * layer set can do and the rig is built. The alternative is a generator that
956
- * refuses its own defaults, which would leave a perfectly good layer set with
957
- * no model at all. `solveTurnModel` reports which fields it clamped.
970
+ * unreachable one throws, naming the field and what was on offer — except a
971
+ * `silhouetteRatio` when no ratio in its own [0.5, 1.5] range renders on this
972
+ * layer set: that throw names the field and the whole range instead of an
973
+ * offer, since there is none narrower. Leaving the field out still rigs,
974
+ * because a defaulted one clamps.
975
+ *
976
+ * A number this module filled in — a default, or a nose/mouth derived from the
977
+ * eyes — is a style prior, not a promise about this character: it is CLAMPED
978
+ * to what the layer set can do and the rig is built. The alternative is a
979
+ * generator that refuses its own defaults, which would leave a perfectly good
980
+ * layer set with no model at all. `solveTurnModel` reports which fields it
981
+ * clamped.
958
982
  *
959
983
  * One measurement is clamped too: an `eyeShift` past the room the art leaves
960
984
  * the far eye — the face plate's edge, and the bangs' side strand when
package/dist/index.d.ts CHANGED
@@ -888,6 +888,24 @@ interface LayerInput {
888
888
  * [0, cropW / 2], the most an inclusive span inside the crop can be.
889
889
  */
890
890
  rowHalfWidths?: number[];
891
+ /**
892
+ * The tight box of the layer's alpha ≥ 128 pixels, in image coords like
893
+ * `bbox` and NOT grown: the drawing a viewer reads inside a soft feather,
894
+ * which the crop's alpha ≥ 8 rule takes in whole. Optional, and only the
895
+ * NOSE's is read: its centre and width are the nose's turn landmark
896
+ * (`turnLandmarks`), so a shaded nose is fitted by the drawing rather than
897
+ * by its feather, and its top edge at its centre x is the bridge top the
898
+ * nose tilts about on the turn (`NOSE_TURN_TILT_DEG`). Absent — as the
899
+ * editor's own import leaves it — the crop stands in for both. Validated
900
+ * before anything reads it: a plain object whose x, y, w and h are finite,
901
+ * w and h positive, and the box inside `bbox`.
902
+ */
903
+ denseCore?: {
904
+ x: number;
905
+ y: number;
906
+ w: number;
907
+ h: number;
908
+ };
891
909
  }
892
910
  /**
893
911
  * Map an array of raw filenames to canonical `{ role, fileName }` pairs.
@@ -949,12 +967,18 @@ interface IrisStrand {
949
967
  *
950
968
  * What happens when the layer set cannot reach a target depends on where the
951
969
  * target came from. A number the CALLER passed is a measurement, so an
952
- * unreachable one throws, naming the field and what was on offer. A number this
953
- * module filled in — a default, or a nose/mouth derived from the eyes — is a
954
- * style prior, not a promise about this character: it is CLAMPED to what the
955
- * layer set can do and the rig is built. The alternative is a generator that
956
- * refuses its own defaults, which would leave a perfectly good layer set with
957
- * no model at all. `solveTurnModel` reports which fields it clamped.
970
+ * unreachable one throws, naming the field and what was on offer — except a
971
+ * `silhouetteRatio` when no ratio in its own [0.5, 1.5] range renders on this
972
+ * layer set: that throw names the field and the whole range instead of an
973
+ * offer, since there is none narrower. Leaving the field out still rigs,
974
+ * because a defaulted one clamps.
975
+ *
976
+ * A number this module filled in — a default, or a nose/mouth derived from the
977
+ * eyes — is a style prior, not a promise about this character: it is CLAMPED
978
+ * to what the layer set can do and the rig is built. The alternative is a
979
+ * generator that refuses its own defaults, which would leave a perfectly good
980
+ * layer set with no model at all. `solveTurnModel` reports which fields it
981
+ * clamped.
958
982
  *
959
983
  * One measurement is clamped too: an `eyeShift` past the room the art leaves
960
984
  * the far eye — the face plate's edge, and the bangs' side strand when
package/dist/index.js CHANGED
@@ -1837,6 +1837,45 @@ function validateLayerInputs(layers, canvas) {
1837
1837
  }
1838
1838
  }
1839
1839
  }
1840
+ if (layer.denseCore !== void 0) {
1841
+ const where = `auto-rig: validateLayerInputs: role "${role}" denseCore`;
1842
+ const core = layer.denseCore;
1843
+ if (typeof core !== "object" || core === null || Array.isArray(core)) {
1844
+ throw new Error(`${where} must be a plain object { x, y, w, h }`);
1845
+ }
1846
+ for (const field of ["x", "y", "w", "h"]) {
1847
+ const v = core[field];
1848
+ const size = field === "w" || field === "h";
1849
+ if (typeof v !== "number" || !Number.isFinite(v) || size && v <= 0) {
1850
+ throw new Error(
1851
+ `${where}.${field} is ${String(v)}, not a finite number${size ? " > 0" : ""}`
1852
+ );
1853
+ }
1854
+ }
1855
+ const { x, y, w, h } = layer.denseCore;
1856
+ const right = bbox.x + bbox.w;
1857
+ const bottom = bbox.y + bbox.h;
1858
+ if (x < bbox.x) {
1859
+ throw new Error(
1860
+ `${where}.x (${x}) lies left of the bbox's left edge (${bbox.x})`
1861
+ );
1862
+ }
1863
+ if (y < bbox.y) {
1864
+ throw new Error(
1865
+ `${where}.y (${y}) lies above the bbox's top edge (${bbox.y})`
1866
+ );
1867
+ }
1868
+ if (x + w > right) {
1869
+ throw new Error(
1870
+ `${where}.w (${w}) puts its right edge at ${x + w}, past the bbox's right edge (${right})`
1871
+ );
1872
+ }
1873
+ if (y + h > bottom) {
1874
+ throw new Error(
1875
+ `${where}.h (${h}) puts its bottom edge at ${y + h}, past the bbox's bottom edge (${bottom})`
1876
+ );
1877
+ }
1878
+ }
1840
1879
  }
1841
1880
  }
1842
1881
  function validateStrandEdges(layers, strandEdges) {
@@ -2013,6 +2052,7 @@ function meshCellsFor(w, h) {
2013
2052
  }
2014
2053
  var FACE_PLATE_CELLS = 10;
2015
2054
  var FEATURE_GRID_CELLS = 4;
2055
+ var MOUTH_GRID_CELL_PX = 32;
2016
2056
  var TURN_LATTICE_CELL_PX = 16;
2017
2057
  var GRID_MARGIN = 0.12;
2018
2058
  var HEAD_CYLINDER_RADIUS_FACTOR = 0.6 / 0.5;
@@ -2163,31 +2203,33 @@ function turnColumnMap(grid, faceCenterX, radiusX, angleX, travel) {
2163
2203
  x + boundedCylinderBend(x - faceCenterX, radiusX, theta) + slide
2164
2204
  );
2165
2205
  }
2166
- const cellFor = (v, edges) => {
2206
+ const cellFor = (x) => {
2167
2207
  let lo = 0;
2168
2208
  let hi = grid.cols - 1;
2169
2209
  while (lo < hi) {
2170
2210
  const mid = lo + hi >> 1;
2171
- if (v < edges[mid + 1]) hi = mid;
2211
+ if (x < restX[mid + 1]) hi = mid;
2172
2212
  else lo = mid + 1;
2173
2213
  }
2174
2214
  return lo;
2175
2215
  };
2176
- const sample = (edges, values, v) => {
2177
- const c = cellFor(v, edges);
2178
- const s = Math.max(
2179
- 0,
2180
- Math.min(1, (v - edges[c]) / (edges[c + 1] - edges[c]))
2181
- );
2182
- if (s === 0) return values[c];
2183
- if (s === 1) return values[c + 1];
2184
- return values[c] + (values[c + 1] - values[c]) * s;
2185
- };
2186
2216
  return {
2187
2217
  restX,
2188
2218
  warpedX,
2189
- mapX: (x) => sample(restX, warpedX, x),
2190
- invertX: (X) => sample(warpedX, restX, X)
2219
+ // Piecewise-linear read of `warpedX` over `restX`, the within-cell
2220
+ // fraction clamped to [0,1] as sampleWarpGrid clamps (s, t). A node reads
2221
+ // its own warped x: `warpedX[c] + Δ·1` is `warpedX[c + 1]` only to an
2222
+ // ulp, and the columns a bake reads are nodes.
2223
+ mapX: (x) => {
2224
+ const c = cellFor(x);
2225
+ const s = Math.max(
2226
+ 0,
2227
+ Math.min(1, (x - restX[c]) / (restX[c + 1] - restX[c]))
2228
+ );
2229
+ if (s === 0) return warpedX[c];
2230
+ if (s === 1) return warpedX[c + 1];
2231
+ return warpedX[c] + (warpedX[c + 1] - warpedX[c]) * s;
2232
+ }
2191
2233
  };
2192
2234
  }
2193
2235
  function renderedLandingX(part, vertexLanding, px, py) {
@@ -2293,7 +2335,7 @@ function plateLandingOn(plate, mapAt) {
2293
2335
  y
2294
2336
  );
2295
2337
  }
2296
- function plateGuardRowsFor(face, hairFront) {
2338
+ function plateGuardRowsFor(face, hairFront, widestY = -Infinity) {
2297
2339
  const rowsOf = (part, y) => {
2298
2340
  const { rows } = meshCellsFor(part.cropW, part.cropH);
2299
2341
  return Array.from(
@@ -2308,7 +2350,7 @@ function plateGuardRowsFor(face, hairFront) {
2308
2350
  return [
2309
2351
  ...faceRows,
2310
2352
  ...rowsOf(hairFront, hairFront.centerY).map(
2311
- (y) => Math.max(bottom, Math.min(top, y))
2353
+ (y) => Math.max(bottom, Math.min(top, Math.max(y, widestY)))
2312
2354
  )
2313
2355
  ];
2314
2356
  }
@@ -2320,7 +2362,7 @@ function plateGuardsOf(carriers, faceHalfWidth, profile, hairFront) {
2320
2362
  return {
2321
2363
  plate,
2322
2364
  edgeAt: profile === void 0 ? () => faceHalfWidth : profile.at,
2323
- plateGuardRows: plateGuardRowsFor(plate.part, hairFront)
2365
+ plateGuardRows: plateGuardRowsFor(plate.part, hairFront, profile?.widestY)
2324
2366
  };
2325
2367
  }
2326
2368
  var TurnTargetError = class extends Error {
@@ -2391,12 +2433,17 @@ function resolveTurnTargets(targets = {}) {
2391
2433
  defaulted
2392
2434
  };
2393
2435
  }
2436
+ function noseDrawnBox(layer) {
2437
+ return layer.denseCore ?? layer.bbox;
2438
+ }
2394
2439
  function turnLandmarks(layers, carriers) {
2395
2440
  const markOf = (role) => {
2396
2441
  const layer = layers.find((l) => l.role === role);
2397
2442
  if (!layer) return void 0;
2398
- const t = bboxToTransform(layer.bbox, layer.canvasW, layer.canvasH, role);
2399
- const mark = { x: t.x, y: t.y, w: layer.cropW };
2443
+ const drawn = role === "nose" ? noseDrawnBox(layer) : layer.bbox;
2444
+ const t = bboxToTransform(drawn, layer.canvasW, layer.canvasH, role);
2445
+ const w = drawn === layer.bbox ? layer.cropW : drawn.w;
2446
+ const mark = { x: t.x, y: t.y, w };
2400
2447
  const carrier = carriers?.get(role);
2401
2448
  if (carrier !== void 0) mark.carrier = carrier;
2402
2449
  return mark;
@@ -2493,14 +2540,15 @@ var TURN_SWEEP_SAMPLES = 32;
2493
2540
  var HOLD_CLEARANCE = 1;
2494
2541
  var TURN_HOLD_SAMPLES = 16;
2495
2542
  var TURN_SETTLE_PASSES = 24;
2496
- function hairFrontHoldTarget(x, y, deg, faceCenterX, plateLandingAt, edgeAt, holdBase, holdEdge) {
2543
+ function hairFrontHoldTarget(x, y, deg, faceCenterX, plateLandingAt, edgeAt, holdBase, holdEdge, widestY = -Infinity) {
2497
2544
  const local = x - faceCenterX;
2498
2545
  const dist = Math.abs(local);
2499
2546
  const side = Math.sign(local);
2500
- const painted = edgeAt(y);
2501
- if (dist <= painted) return plateLandingAt(deg, x, y);
2547
+ const row = Math.max(y, widestY);
2548
+ const painted = edgeAt(row);
2549
+ if (dist <= painted) return plateLandingAt(deg, x, row);
2502
2550
  if (dist <= holdBase) {
2503
- const inner = plateLandingAt(deg, faceCenterX + side * painted, y);
2551
+ const inner = plateLandingAt(deg, faceCenterX + side * painted, row);
2504
2552
  const u = (dist - painted) / (holdBase - painted);
2505
2553
  return inner + (faceCenterX + side * holdEdge - inner) * u;
2506
2554
  }
@@ -2605,7 +2653,8 @@ function evaluateTurnCandidate(ctx, radius) {
2605
2653
  plateLandingAt,
2606
2654
  ctx.edgeAt,
2607
2655
  hold.holdBase,
2608
- hold.holdEdgeAt(deg)
2656
+ hold.holdEdgeAt(deg),
2657
+ ctx.profile?.widestY
2609
2658
  ),
2610
2659
  leadShift
2611
2660
  );
@@ -3347,6 +3396,7 @@ function solveTurnModel(targets, landmarks, lattice, faceCenterX, faceHalfWidth,
3347
3396
  var EYE_STACK_PREFIXES = ["eye_", "iris_", "pupil_", "highlight_"];
3348
3397
  var HAIR_FRONT_DEPTH = 0.1;
3349
3398
  var MOUTH_TURN_TILT_DEG = 5;
3399
+ var NOSE_TURN_TILT_DEG = 6;
3350
3400
  var NOD_TRAVEL = 30;
3351
3401
  var NOD_BEND = 0.5;
3352
3402
  var HAIR_BACK_NOD_DEPTH = -0.135;
@@ -3579,7 +3629,7 @@ function plateReachAt(plateLandingAt, faceCenterX, edgeAt, plateGuardRows, deg)
3579
3629
  )
3580
3630
  );
3581
3631
  }
3582
- function bakeHairFrontSilhouetteWarp(mesh, partX, partY, faceCenterX, plateLandingAt, edgeAt, holdBase, holdEdgeAt, plateGuardRows) {
3632
+ function bakeHairFrontSilhouetteWarp(mesh, partX, partY, faceCenterX, plateLandingAt, edgeAt, holdBase, holdEdgeAt, plateGuardRows, widestY = -Infinity) {
3583
3633
  if (holdEdgeAt(0) !== holdBase) {
3584
3634
  throw new Error(
3585
3635
  `auto-rig: bakeHairFrontSilhouetteWarp: holdEdgeAt(0) is ${holdEdgeAt(0)}, not the hold edge's own rest distance ${holdBase}, so the bangs would move in the rest pose`
@@ -3617,7 +3667,8 @@ function bakeHairFrontSilhouetteWarp(mesh, partX, partY, faceCenterX, plateLandi
3617
3667
  plateLandingAt,
3618
3668
  edgeAt,
3619
3669
  holdBase,
3620
- holdEdge
3670
+ holdEdge,
3671
+ widestY
3621
3672
  ) - x,
3622
3673
  0
3623
3674
  );
@@ -3796,7 +3847,7 @@ function defaultPoseOf(bindings) {
3796
3847
  return binding.from + (binding.to - binding.from) * t;
3797
3848
  });
3798
3849
  }
3799
- function groupGridFor(members, cells, turnShiftPx, nodShiftPx) {
3850
+ function groupGridFor(members, cells, turnShiftPx, nodShiftPx, cellPx) {
3800
3851
  let minX = Infinity;
3801
3852
  let maxX = -Infinity;
3802
3853
  let minY = Infinity;
@@ -3831,24 +3882,50 @@ function groupGridFor(members, cells, turnShiftPx, nodShiftPx) {
3831
3882
  }
3832
3883
  }
3833
3884
  }
3885
+ const extentMinX = minX;
3886
+ const extentMaxX = maxX;
3834
3887
  minX -= turnShiftPx;
3835
3888
  maxX += turnShiftPx;
3836
3889
  minY -= nodShiftPx;
3837
3890
  maxY += nodShiftPx;
3838
3891
  const marginX = (maxX - minX) * GRID_MARGIN + 1;
3839
3892
  const marginY = (maxY - minY) * GRID_MARGIN + 1;
3840
- return {
3841
- cols: cells,
3842
- rows: cells,
3843
- points: generateGridPoints(
3844
- cells,
3845
- cells,
3846
- minX - marginX,
3847
- maxX + marginX,
3848
- minY - marginY,
3849
- maxY + marginY
3850
- )
3851
- };
3893
+ const points = generateGridPoints(
3894
+ cells,
3895
+ cells,
3896
+ minX - marginX,
3897
+ maxX + marginX,
3898
+ minY - marginY,
3899
+ maxY + marginY
3900
+ );
3901
+ if (cellPx === void 0) return { cols: cells, rows: cells, points };
3902
+ const stride = cells + 1;
3903
+ const uniformXs = Array.from({ length: stride }, (_, c) => points[c * 2]);
3904
+ const ys = Array.from(
3905
+ { length: stride },
3906
+ (_, r) => points[r * stride * 2 + 1]
3907
+ );
3908
+ const xs = [uniformXs[0]];
3909
+ for (let c = 0; c < cells; c++) {
3910
+ const left = uniformXs[c];
3911
+ const right = uniformXs[c + 1];
3912
+ const from = Math.max(left, extentMinX);
3913
+ const to = Math.min(right, extentMaxX);
3914
+ if (right - left > cellPx && from < to) {
3915
+ const steps = Math.ceil((to - from) / cellPx);
3916
+ if (from > left) xs.push(from);
3917
+ for (let k = 1; k < steps; k++) {
3918
+ xs.push(from + k * (to - from) / steps);
3919
+ }
3920
+ if (to < right) xs.push(to);
3921
+ }
3922
+ xs.push(right);
3923
+ }
3924
+ const merged = [];
3925
+ for (const y of ys) {
3926
+ for (const x of xs) merged.push(x, y);
3927
+ }
3928
+ return { cols: xs.length - 1, rows: cells, points: merged };
3852
3929
  }
3853
3930
  function eyelidFoldFor(layers, layer, mesh, t) {
3854
3931
  const side = ROLE_TABLE[layer.role].eyeSide;
@@ -3949,7 +4026,8 @@ function turnSetup(layers) {
3949
4026
  own,
3950
4027
  isPlate ? FACE_PLATE_CELLS : FEATURE_GRID_CELLS,
3951
4028
  isPlate ? 0 : turnShift[turnFamily(role)],
3952
- nodShiftPx
4029
+ nodShiftPx,
4030
+ group === "mouthWarp" ? MOUTH_GRID_CELL_PX : void 0
3953
4031
  )
3954
4032
  );
3955
4033
  }
@@ -3959,11 +4037,31 @@ function turnSetup(layers) {
3959
4037
  y: mouthAt.y,
3960
4038
  tiltDeg: MOUTH_TURN_TILT_DEG
3961
4039
  };
4040
+ const noseLayer = layers.find((l) => l.role === "nose");
4041
+ let noseAnchor;
4042
+ if (noseLayer) {
4043
+ const drawn = noseDrawnBox(noseLayer);
4044
+ const at = bboxToTransform(
4045
+ drawn,
4046
+ noseLayer.canvasW,
4047
+ noseLayer.canvasH,
4048
+ "nose"
4049
+ );
4050
+ noseAnchor = {
4051
+ x: at.x,
4052
+ y: at.y + drawn.h / 2,
4053
+ tiltDeg: NOSE_TURN_TILT_DEG
4054
+ };
4055
+ }
3962
4056
  const carriers = new Map(
3963
- members.map((m) => [
3964
- m.role,
3965
- m.group === "mouthWarp" ? { grid: groupGrids.get(m.group), part: m.part, anchor: mouthAnchor } : { grid: groupGrids.get(m.group), part: m.part }
3966
- ])
4057
+ members.map((m) => {
4058
+ const grid = groupGrids.get(m.group);
4059
+ const anchor = m.group === "mouthWarp" ? mouthAnchor : m.group === "noseWarp" ? noseAnchor : void 0;
4060
+ return [
4061
+ m.role,
4062
+ anchor === void 0 ? { grid, part: m.part } : { grid, part: m.part, anchor }
4063
+ ];
4064
+ })
3967
4065
  );
3968
4066
  const maxShift = Math.max(turnShift.eye, turnShift.nose, turnShift.mouth);
3969
4067
  let reach = 0;
@@ -4269,7 +4367,8 @@ function generateIkiFromLayerSet(layers, canvas, options = {}) {
4269
4367
  edgeAt,
4270
4368
  holdBase,
4271
4369
  turn?.holdEdgeAt ?? (() => holdBase),
4272
- plateGuardRows
4370
+ plateGuardRows,
4371
+ profile?.widestY
4273
4372
  )
4274
4373
  );
4275
4374
  ownWarps.push(