@call-me-sensei/toonlab 0.5.0 → 0.5.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/NPM-LIBRARY.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # ToonLab npm library guide
2
2
 
3
3
  This guide is included in `@call-me-sensei/toonlab`. It is the package-first
4
- reference for version 0.5.0 and does not require access to the source
4
+ reference for version 0.5.1 and does not require access to the source
5
5
  repository.
6
6
 
7
7
  ## Install
@@ -143,7 +143,7 @@ surface detail, not displacement that replaces a geometric high-to-low bake.
143
143
 
144
144
  ## Load Nature Reference Rocks
145
145
 
146
- Version 0.5.0 understands both the original `rock-0001`–`rock-0480`
146
+ Version 0.5.1 understands both the original `rock-0001`–`rock-0480`
147
147
  identities and the Nature Reference Rocks catalog. Acquiring an asset
148
148
  directly returns its realistic PBR GLB. The one-call placement path additionally
149
149
  loads that asset's reviewed geology maps and `call_me_sensei` material settings
@@ -229,7 +229,7 @@ it uses the disk workspace passed through `--workspace`; no account is needed.
229
229
  "command": "npx",
230
230
  "args": [
231
231
  "-y",
232
- "--package=@call-me-sensei/toonlab@0.5.0",
232
+ "--package=@call-me-sensei/toonlab@0.5.1",
233
233
  "toonlab-mcp",
234
234
  "--workspace",
235
235
  "/absolute/path/to/your-game/.toonlab"
package/README.md CHANGED
@@ -19,7 +19,7 @@ Use ToonLab as a focused runtime library (`@call-me-sensei/toonlab` on npm), as
19
19
  an asset/preset authoring workspace, or as an asset-discovery service—not as a
20
20
  one-shot world generator.
21
21
 
22
- ## Versioned expectation: 0.5.0
22
+ ## Versioned expectation: 0.5.1
23
23
 
24
24
  For a correctly constructed and semantically labeled scene, one strict Call Me
25
25
  Sensei bundle application is expected to establish the supported visual
package/agents/PROMPTS.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # ToonLab agent prompts
2
2
 
3
3
  These prompts are included with the npm package. They use only the supported
4
- 0.5.0 runtime, agent skills, and MCP workflows.
4
+ 0.5.1 runtime, agent skills, and MCP workflows.
5
5
 
6
6
  ## New playable game
7
7
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@call-me-sensei/toonlab",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Anime-style game toolkit for Three.js with cel shading, vegetation, rocks, water, sky, clouds, procedural textures, and portable Lab artifacts",
5
5
  "license": "MIT",
6
6
  "author": "Hyperbond Studio PTE. LTD. (Call Me Sensei)",
@@ -561,7 +561,9 @@ function vertexBlend(u, features) {
561
561
 
562
562
  // View-space displacement of the hull: outward along the (baked smooth)
563
563
  // geometry normal by the role's width, corrected to a constant on-screen
564
- // width, and pushed back along the view ray on faces.
564
+ // width, and pushed back along the view ray by the role's depth push (faces:
565
+ // faceDepthPush by face weight), so inner edges of layered parts fail the
566
+ // depth test against the surface behind them and only silhouettes ink.
565
567
  function outlineViewOffset(u, features) {
566
568
  return (positionViewBase) => {
567
569
  const blend = vertexBlend(u, features);
@@ -586,7 +588,7 @@ function outlineViewOffset(u, features) {
586
588
  }
587
589
  width = min(width, u.outlineMaxWidth);
588
590
  const away = select(orthographic, vec3(0, 0, -1), normalize(positionViewBase));
589
- return normalView.mul(width).add(away.mul(u.outlineFaceDepthPush.mul(faceWeight)));
591
+ return normalView.mul(width).add(away.mul(blend('outlineDepthPush').add(u.outlineFaceDepthPush.mul(faceWeight))));
590
592
  };
591
593
  }
592
594
 
@@ -19,7 +19,15 @@ import { rasterizeTriangle, readTexturePixels, sampleTexturePixel } from './text
19
19
  // 2. The model's own skin tone, sampled from the cheek/chin band of the
20
20
  // front of the head — a region that is skin on essentially every
21
21
  // character. The band must itself look like skin (skinEvidence.js), so a
22
- // helmet visor or a robot face is not taken as one.
22
+ // helmet visor or a robot face is not taken as one; glasses or a lock of
23
+ // hair across the band may outnumber the skin in it, so a band that is
24
+ // mostly skin-coloured texels by a clear share still counts. Brown,
25
+ // auburn and blond hair is skin-coloured by hue too, and hair framing a
26
+ // chibi face fills much of the band: when the band's skin-coloured texels
27
+ // form two clearly different lightness groups and the crown's lightness
28
+ // matches the darker one, that group is hair and the lighter one is the
29
+ // skin, and from then on only texels on the skin's side of the lightness
30
+ // boundary count as skin-toned. Hair as light as the skin is not split.
23
31
  // 3. Face = the front face zone of the head. Below the brow line everything
24
32
  // is face (eyes, brows and mouth belong to it); above it only texels in
25
33
  // the model's skin tone (so bangs over the forehead are not face).
@@ -27,7 +35,8 @@ import { rasterizeTriangle, readTexturePixels, sampleTexturePixel } from './text
27
35
  // = body texels in the face's skin tone.
28
36
  //
29
37
  // The result is a normalized Uint8 vec4 `toonRoleWeights` (R skin, G face,
30
- // B hair) per vertex, consumed by the toon shader, and an estimated head frame
38
+ // B hair; a partition — they sum to at most 1, the rest is the material's own
39
+ // role) per vertex, consumed by the toon shader, and an estimated head frame
31
40
  // on the root for face lighting on unrigged models. When the head is low-poly
32
41
  // (a face of a few large polygons, where per-vertex weights smear the
33
42
  // face/hair boundary across whole triangles), the same classification is also
@@ -62,6 +71,19 @@ function colorSignature(rgba, target = [0, 0, 0]) {
62
71
  return target;
63
72
  }
64
73
 
74
+ // Oklab lightness (0 black – 1 white) of an sRGB texel. Perceptual, unlike the
75
+ // down-weighted log-luminance above: it is what separates skin from hair of
76
+ // the same warm hue.
77
+ function oklabLightness(rgba) {
78
+ const r = srgbToLinear(rgba[0]);
79
+ const g = srgbToLinear(rgba[1]);
80
+ const b = srgbToLinear(rgba[2]);
81
+ const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
82
+ const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
83
+ const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
84
+ return 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
85
+ }
86
+
65
87
  function signatureDistance(a, b) {
66
88
  const dr = a[0] - b[0];
67
89
  const dg = a[1] - b[1];
@@ -85,6 +107,79 @@ function collectTargets(root, isEligible) {
85
107
  return targets;
86
108
  }
87
109
 
110
+ // Texture (or material) colour at a vertex, 0-255 RGBA into `target`.
111
+ function sampleVertexColor(mesh, i, target) {
112
+ const material = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material;
113
+ const texture = material?.map;
114
+ const uv = mesh.geometry.attributes.uv;
115
+ const pixels = texture ? readTexturePixels(texture, { maxSize: 1024 }) : null;
116
+ if (!pixels || !uv) {
117
+ const color = material?.color ?? new THREE.Color(1, 1, 1);
118
+ target[0] = color.r * 255; target[1] = color.g * 255; target[2] = color.b * 255; target[3] = 255;
119
+ return target;
120
+ }
121
+ return sampleTexturePixel(pixels, texture, uv.getX(i), uv.getY(i), target);
122
+ }
123
+
124
+ function updateMapMatrices(meshes) {
125
+ for (const mesh of meshes) {
126
+ const material = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material;
127
+ if (material?.map?.matrixAutoUpdate) material.map.updateMatrix();
128
+ }
129
+ }
130
+
131
+ // Rest-pose facing. Every classification here works in the character frame
132
+ // (facing +Z), which a model states with `root.userData.toonForward`; without
133
+ // it +Z is assumed. A rig whose rest pose faces elsewhere while its animations
134
+ // turn it forward (the Sensei Run chibis face -X at rest) was analysed from
135
+ // the side of the head: the face put on one cheek and the hair over the eyes.
136
+ // The face is where the head's bright skin-coloured surface points: measured
137
+ // on five chibis (2026-10-13), its mean horizontal normal is 0.21-0.41 long
138
+ // and within 9 degrees of an axis; brown hair (skin-coloured by hue) is
139
+ // excluded by lightness. Rigged models only (the head comes from the skin
140
+ // weights); bald or masked heads give no clear direction and keep +Z. Light
141
+ // warm hair is not excluded (Yuxi's ash-grey hair, 2026-10-16, pointed it at
142
+ // -Z), so inferAutoRoleWeights consults it only after the +Z analysis fails.
143
+ const FORWARD_MIN_SAMPLES = 64;
144
+ const FORWARD_MIN_STRENGTH = 0.12;
145
+ const FORWARD_SKIN_MIN_LIGHTNESS = 0.6;
146
+
147
+ function estimateRestForward(root, targets, headBones) {
148
+ if (headBones.size === 0) return null;
149
+ updateMapMatrices(targets);
150
+ const rootInverse = new THREE.Matrix4().copy(root.matrixWorld).invert();
151
+ const normal = new THREE.Vector3();
152
+ const texel = [0, 0, 0, 255];
153
+ let sumX = 0;
154
+ let sumZ = 0;
155
+ let count = 0;
156
+ for (const mesh of targets) {
157
+ const headness = skinnedHeadness(mesh, headBones);
158
+ const normals = mesh.geometry.attributes.normal;
159
+ if (!headness || !normals) continue;
160
+ const normalMatrix = new THREE.Matrix3().getNormalMatrix(new THREE.Matrix4().multiplyMatrices(rootInverse, mesh.matrixWorld));
161
+ const stride = Math.max(1, Math.floor(normals.count / 20000));
162
+ for (let i = 0; i < normals.count; i += stride) {
163
+ if (headness[i] < 0.9) continue;
164
+ sampleVertexColor(mesh, i, texel);
165
+ if (!isSkinLikeColor(texel[0] / 255, texel[1] / 255, texel[2] / 255)) continue;
166
+ if (oklabLightness(texel) < FORWARD_SKIN_MIN_LIGHTNESS) continue;
167
+ normal.fromBufferAttribute(normals, i).applyMatrix3(normalMatrix).normalize();
168
+ sumX += normal.x;
169
+ sumZ += normal.z;
170
+ count += 1;
171
+ }
172
+ }
173
+ if (count < FORWARD_MIN_SAMPLES) return null;
174
+ const x = sumX / count;
175
+ const z = sumZ / count;
176
+ const strength = Math.hypot(x, z);
177
+ if (strength < FORWARD_MIN_STRENGTH) return null;
178
+ // Rigs are authored axis-aligned: the nearest horizontal axis.
179
+ const forward = Math.abs(x) > Math.abs(z) ? [Math.sign(x), 0, 0] : [0, 0, Math.sign(z)];
180
+ return { forward, samples: count, strength };
181
+ }
182
+
88
183
  function headBoneSet(headBone) {
89
184
  const bones = new Set();
90
185
  headBone?.traverse((node) => {
@@ -136,7 +231,23 @@ const FOOTPRINT_PERCENTILE = 0.8;
136
231
  // least this share of it (large painted features — a chibi nose and lips —
137
232
  // take the rest).
138
233
  const REFERENCE_MIN_SKIN_FRACTION = 0.3;
234
+ // When the band's median colour is not skin (glasses frames and lenses, or a
235
+ // lock of hair, cover much of it), a larger share of skin-coloured texels is
236
+ // required instead (Keisuke, glasses: 42 %; Akika, hair over the cheeks: 38 %).
237
+ const REFERENCE_MIN_SKIN_FRACTION_MIXED = 0.35;
139
238
  const SATURATED_REFERENCE_CHROMA = 0.2;
239
+ // Skin-coloured hair in the cheek band: its skin-coloured texels split into
240
+ // two lightness groups (1-D two-means on Oklab lightness) at least this far
241
+ // apart, the smaller holding at least SKIN_SPLIT_MIN_SHARE of them. Measured
242
+ // on six chibis with brown hair (2026-10-13): group centres skin 0.72–0.84,
243
+ // hair 0.32–0.46 (black hair is not skin-coloured and never splits). The
244
+ // boundary is softened by SKIN_SPLIT_SOFTNESS either side.
245
+ const SKIN_SPLIT_MIN_GAP = 0.2;
246
+ const SKIN_SPLIT_MIN_SHARE = 0.1;
247
+ const SKIN_SPLIT_SOFTNESS = 0.04;
248
+ // Crown: head texels this far above the head's centre (fraction of its height
249
+ // radius) give the hair's lightness.
250
+ const CROWN_Y = 0.7;
140
251
 
141
252
  function extendHeadAboveNeck(meshes, headBone, rootInverse) {
142
253
  const neckBone = headBone?.parent?.isBone ? headBone.parent : null;
@@ -170,6 +281,48 @@ function percentileOf(sorted, fraction) {
170
281
  return sorted[Math.min(sorted.length - 1, Math.max(0, Math.round((sorted.length - 1) * fraction)))];
171
282
  }
172
283
 
284
+ // Two lightness groups among the band's skin-coloured texels — skin and darker
285
+ // hair of the same warm hue — or null when they form one group. The darker
286
+ // group is the hair only when the crown's median lightness is nearer to it.
287
+ // Hair as light as the skin or lighter (ash, silver, platinum) is never split
288
+ // off: its crown matches the lighter group, which is then the skin itself
289
+ // (Yuxi, 2026-10-16: crown and cheeks both about 0.8, the darker group her
290
+ // lips and lashes), so such models keep the unsplit skin reference. (A bald
291
+ // head with a brown beard would be read the wrong way round; name its face
292
+ // material to skip automatic roles.)
293
+ function splitSkinFromHair(lightness, crownLightness) {
294
+ if (lightness.length < 32 || crownLightness.length < 16) return null;
295
+ const sorted = [...lightness].sort((a, b) => a - b);
296
+ let low = percentileOf(sorted, 0.2);
297
+ let high = percentileOf(sorted, 0.8);
298
+ let lowCount = 0;
299
+ for (let iteration = 0; iteration < 16; iteration += 1) {
300
+ const boundary = (low + high) / 2;
301
+ let lowSum = 0;
302
+ let highSum = 0;
303
+ lowCount = 0;
304
+ for (const value of sorted) {
305
+ if (value < boundary) {
306
+ lowSum += value;
307
+ lowCount += 1;
308
+ } else {
309
+ highSum += value;
310
+ }
311
+ }
312
+ if (lowCount === 0 || lowCount === sorted.length) return null;
313
+ low = lowSum / lowCount;
314
+ high = highSum / (sorted.length - lowCount);
315
+ }
316
+ const smallerShare = Math.min(lowCount, sorted.length - lowCount) / sorted.length;
317
+ if (high - low < SKIN_SPLIT_MIN_GAP || smallerShare < SKIN_SPLIT_MIN_SHARE) return null;
318
+ const crown = median(crownLightness);
319
+ if (Math.abs(crown - low) > Math.abs(crown - high)) return null;
320
+ const boundary = (low + high) / 2;
321
+ // 1 on the skin's (lighter) side of the boundary.
322
+ const weight = (value) => smoothstep(boundary - SKIN_SPLIT_SOFTNESS, boundary + SKIN_SPLIT_SOFTNESS, value);
323
+ return { boundary, groups: [low, high], hair: 'darker', isSkinSide: (value) => weight(value) >= 0.5, weight };
324
+ }
325
+
173
326
  // Centre and radii (root space, x/y) of the visible face: head vertices that
174
327
  // face forward, sit on the front half of the head and match the skin tone.
175
328
  function measureFaceFrame(meshes, headnessAt, isSkinToned, center, radiusX) {
@@ -320,11 +473,24 @@ function findNeckHeight(slices, minY, sliceHeight) {
320
473
  return minY + (neck + 0.5) * sliceHeight;
321
474
  }
322
475
 
476
+ // Analysis failures that mean "no skin where the face should be", after which
477
+ // an undeclared rest-pose facing is estimated and the analysis retried.
478
+ const RETRY_WITH_INFERRED_FORWARD = new Set(['no-skin-reference', 'reference-not-skin']);
479
+
323
480
  /**
324
481
  * Infers per-vertex role weights for the eligible meshes under `root`.
325
482
  * `isEligible(material)` selects materials whose roles are unknown.
326
483
  * Returns a report; `applied` is false (with a `reason`) when evidence is
327
484
  * insufficient, in which case nothing is modified.
485
+ *
486
+ * The analysis runs in the declared character frame (`root.userData.toonForward`,
487
+ * +Z when none is declared). Only when a model declares nothing and its +Z
488
+ * analysis finds no skin in the cheek band is its rest-pose facing estimated
489
+ * (estimateRestForward) and the analysis retried from that side. The estimate
490
+ * is a fallback, never an override: light warm hair (ash, silver, blond) is
491
+ * skin-coloured by hue and can outweigh the face in it. The inferred axis is
492
+ * kept, and written to the root so later bakes and head tracking agree, only
493
+ * when the retry succeeds.
328
494
  */
329
495
  export function inferAutoRoleWeights(root, {
330
496
  headBone = null,
@@ -335,10 +501,33 @@ export function inferAutoRoleWeights(root, {
335
501
  if (targets.length === 0) return { applied: false, reason: 'no-eligible-meshes' };
336
502
 
337
503
  root.updateMatrixWorld(true);
504
+ const headBones = headBoneSet(headBone);
505
+ const analyse = (forwardInferred) => analyseRoles(root, targets, headBone, headBones, skinTolerance, forwardInferred);
506
+ const result = analyse(null);
507
+ if (result.applied || !RETRY_WITH_INFERRED_FORWARD.has(result.reason)) return result;
508
+ if (Array.isArray(root.userData?.toonForward)) return result;
509
+
510
+ const estimate = estimateRestForward(root, targets, headBones);
511
+ if (!estimate || estimate.forward[2] === 1) return result;
512
+ const previousForward = root.userData.toonForward;
513
+ root.userData.toonForward = estimate.forward;
514
+ const retried = analyse(estimate);
515
+ if (retried.applied) return retried;
516
+ if (previousForward === undefined) delete root.userData.toonForward;
517
+ else root.userData.toonForward = previousForward;
518
+ return result;
519
+ }
520
+
521
+ /**
522
+ * One analysis in the root's current character frame. Returns before
523
+ * modifying anything when the evidence is insufficient.
524
+ * @param {THREE.Mesh[]} targets
525
+ * @param {{ forward: number[], samples: number, strength: number } | null} forwardInferred
526
+ */
527
+ function analyseRoles(root, targets, headBone, headBones, skinTolerance, forwardInferred) {
338
528
  // The character frame (facing +Z), not the root's raw frame: a VRM 0.x
339
529
  // root is turned 180° by its importer.
340
530
  const rootInverse = characterFrameInverse(root);
341
- const headBones = headBoneSet(headBone);
342
531
 
343
532
  // Gather every eligible vertex in root space once.
344
533
  const meshes = targets.map((mesh) => {
@@ -422,34 +611,22 @@ export function inferAutoRoleWeights(root, {
422
611
  const radiusY = Math.max((headBox.max.y - headBox.min.y) / 2, 1e-4);
423
612
 
424
613
  // Skin reference from the cheek/chin band on the front of the head.
425
- const sampleColor = (mesh, i, target) => {
426
- const material = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material;
427
- const texture = material?.map;
428
- const uv = mesh.geometry.attributes.uv;
429
- const pixels = texture ? readTexturePixels(texture, { maxSize: 1024 }) : null;
430
- if (!pixels || !uv) {
431
- const color = material?.color ?? new THREE.Color(1, 1, 1);
432
- target[0] = color.r * 255; target[1] = color.g * 255; target[2] = color.b * 255; target[3] = 255;
433
- return target;
434
- }
435
- return sampleTexturePixel(pixels, texture, uv.getX(i), uv.getY(i), target);
436
- };
437
- for (const { mesh } of meshes) {
438
- const material = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material;
439
- if (material?.map?.matrixAutoUpdate) material.map.updateMatrix();
440
- }
614
+ const sampleColor = sampleVertexColor;
615
+ updateMapMatrices(targets);
441
616
 
442
617
  const texel = [0, 0, 0, 255];
443
618
  const signature = [0, 0, 0];
444
- const reference = [[], [], []];
445
- let referenceSkinLike = 0;
619
+ // Skin-coloured band texels: signature and Oklab lightness each.
620
+ const bandSkin = [];
446
621
  const referenceRgb = [[], [], []];
622
+ const crownLightness = [];
447
623
  for (const entry of meshes) {
448
624
  const stride = Math.max(1, Math.floor(entry.points.length / 3 / 20000));
449
625
  for (let i = 0; i < entry.points.length / 3; i += stride) {
450
626
  if (headnessAt(entry, i) < 0.9) continue;
451
627
  const x = (entry.points[i * 3] - center.x) / radiusX;
452
628
  const y = (entry.points[i * 3 + 1] - center.y) / radiusY;
629
+ if (y > CROWN_Y) crownLightness.push(oklabLightness(sampleColor(entry.mesh, i, texel)));
453
630
  const facing = entry.normals[i * 3 + 2];
454
631
  if (facing < 0.55 || Math.abs(x) > 0.45 || y > -0.1 || y < -0.65) continue;
455
632
  colorSignature(sampleColor(entry.mesh, i, texel), signature);
@@ -458,20 +635,22 @@ export function inferAutoRoleWeights(root, {
458
635
  // a big red nose, dark lips and eyes in this zone otherwise pulled it
459
636
  // off the skin, leaving the forehead and outer cheeks part hair.
460
637
  if (!isSkinLikeColor(texel[0] / 255, texel[1] / 255, texel[2] / 255)) continue;
461
- referenceSkinLike += 1;
462
- reference[0].push(signature[0]);
463
- reference[1].push(signature[1]);
464
- reference[2].push(signature[2]);
638
+ bandSkin.push({ signature: [signature[0], signature[1], signature[2]], lightness: oklabLightness(texel) });
465
639
  }
466
640
  }
467
641
  const sampled = referenceRgb[0].length;
468
642
  if (sampled < 16) return { applied: false, reason: 'no-skin-reference' };
469
643
  const medianRgb = referenceRgb.map(median);
470
644
  const medianIsSkin = isSkinLikeColor(medianRgb[0] / 255, medianRgb[1] / 255, medianRgb[2] / 255);
471
- if (!medianIsSkin || reference[0].length < 16 || referenceSkinLike < sampled * REFERENCE_MIN_SKIN_FRACTION) {
472
- return { applied: false, medianRgb, reason: 'reference-not-skin', referenceSkinFraction: referenceSkinLike / sampled };
645
+ const minSkinFraction = medianIsSkin ? REFERENCE_MIN_SKIN_FRACTION : REFERENCE_MIN_SKIN_FRACTION_MIXED;
646
+ if (bandSkin.length < 16 || bandSkin.length < sampled * minSkinFraction) {
647
+ return { applied: false, medianRgb, reason: 'reference-not-skin', referenceSkinFraction: bandSkin.length / sampled };
473
648
  }
474
- const skinReference = reference.map(median);
649
+ const skinSplit = splitSkinFromHair(bandSkin.map((sample) => sample.lightness), crownLightness);
650
+ const skinSamples = skinSplit ? bandSkin.filter((sample) => skinSplit.isSkinSide(sample.lightness)) : bandSkin;
651
+ const skinReference = [0, 1, 2].map((k) => median(skinSamples.map((sample) => sample.signature[k])));
652
+ // 1 on the skin's side of a lightness split (always 1 without one).
653
+ const skinSideAt = (rgba) => (skinSplit ? skinSplit.weight(oklabLightness(rgba)) : 1);
475
654
  // Linear chromaticity spreads saturated colours apart: painted shading on an
476
655
  // orange-brown skin moves further in it than on pale skin. The tolerance
477
656
  // grows with the reference's own distance from neutral (pale and typical
@@ -485,7 +664,7 @@ export function inferAutoRoleWeights(root, {
485
664
  // so the scalp of a bald head is not all face.
486
665
  const faceFrame = measureFaceFrame(meshes, headnessAt, (entry, i) => {
487
666
  colorSignature(sampleColor(entry.mesh, i, texel), signature);
488
- return signatureDistance(signature, skinReference) <= tolerance;
667
+ return signatureDistance(signature, skinReference) <= tolerance && skinSideAt(texel) >= 0.5;
489
668
  }, center, radiusX);
490
669
  // The upper half fades out over the forehead rather than at a hard edge:
491
670
  // face shading follows the face map (light azimuth only) and the scalp
@@ -507,7 +686,7 @@ export function inferAutoRoleWeights(root, {
507
686
  const roles = [0, 0, 0];
508
687
  const classify = (px, py, pz, facing, headness, rgba) => {
509
688
  colorSignature(rgba, signature);
510
- const skinLike = 1 - smoothstep(tolerance * 0.6, tolerance, signatureDistance(signature, skinReference));
689
+ const skinLike = (1 - smoothstep(tolerance * 0.6, tolerance, signatureDistance(signature, skinReference))) * skinSideAt(rgba);
511
690
  const z = pz - center.z;
512
691
  // On the front of the face either by normal or by position: recessed eye
513
692
  // sockets, nose sides and lips face sideways, but sit well in front of the
@@ -515,10 +694,14 @@ export function inferAutoRoleWeights(root, {
515
694
  const front = Math.max(smoothstep(0.05, 0.35, facing), smoothstep(0.35, 0.6, z / radiusX));
516
695
  const frontZone = headness * ovalAt(px, py) * front * (z > -0.1 * radiusX ? 1 : 0);
517
696
  const face = frontZone * Math.max(lowerFaceAt(py), skinLike);
518
- // Skin includes the face: a skin-coloured texel is fully skin however
519
- // much of it is face. (A max() of the two left a part-face cheek only
520
- // part skin, the remainder shaded as cloth.)
521
- roles[0] = face + (1 - face) * skinLike;
697
+ // The weights partition the surface: the shader blends each role's full
698
+ // parameters by its weight and gives the material's own role the
699
+ // remainder (characterMaterial.js createBlend). Skin is the skin-coloured
700
+ // share that is not face, so a skin-coloured cheek is fully face plus skin
701
+ // with no cloth remainder, and a face texel is not also counted as skin
702
+ // (that summed the skin and face terminators, tones and outline widths,
703
+ // washing faces out).
704
+ roles[0] = (1 - face) * skinLike;
522
705
  roles[1] = face;
523
706
  roles[2] = headness * (1 - face) * (1 - skinLike);
524
707
  return roles;
@@ -581,9 +764,13 @@ export function inferAutoRoleWeights(root, {
581
764
  headVertexCount: headCount,
582
765
  meshes: meshes.length,
583
766
  faceFrame,
767
+ forwardInferred,
768
+ head: { center: center.toArray(), radiusX, radiusY },
584
769
  maskedMeshes,
585
770
  method: rigged ? 'skin-weights' : 'silhouette',
771
+ referenceSkinFraction: bandSkin.length / sampled,
586
772
  skinReference,
773
+ skinSplit: skinSplit ? { boundary: skinSplit.boundary, groups: skinSplit.groups, hair: skinSplit.hair } : null,
587
774
  skinTolerance: tolerance,
588
775
  };
589
776
  }
@@ -42,6 +42,7 @@ export const ROLE_PARAMETER_NAMES = Object.freeze([
42
42
  'specularThreshold',
43
43
  'specularSoftness',
44
44
  'outlineWidth',
45
+ 'outlineDepthPush',
45
46
  'ink',
46
47
  'inkSaturation',
47
48
  'outlineLightingMix',
@@ -101,6 +102,8 @@ function roleParameters(settings, role, { hasSpecularMask = false, paintedEyeHig
101
102
  outlineMaxBrightness: Math.max(lighting.min, lighting.max),
102
103
  outlineMinBrightness: lighting.min,
103
104
  outlineWidth: outline.enabled ? outline.width[role] : 0,
105
+ // Faces (and eyes) use faceDepthPush, applied by face weight in the hull shader.
106
+ outlineDepthPush: inkRole === 'face' ? 0 : outline.depthPush[inkRole] ?? 0,
104
107
  rimIntensity: rim.enabled ? rim.intensity[rimRole] : 0,
105
108
  rimSilhouette: role === 'hair' ? rim.silhouette.hair : role === 'eye' ? 0 : rim.silhouette.body,
106
109
  sceneShadowStrength: sceneShadow,
@@ -56,6 +56,15 @@ export const OUTLINE_FIELDS = Object.freeze({
56
56
  metal: lightingRole(0.2, 'metal'),
57
57
  }),
58
58
  faceDepthPush: numberField(0.02, 0, 0.2, 'Push face hull lines back in depth (metres) so they show only on the silhouette, not across nose and eyelids.'),
59
+ // The same push for the other roles. Layered parts (a jacket over a shirt, sleeves, separate hair locks) each
60
+ // carry their own hull, so without it every inner edge inks a full-width line across the body; pushed back,
61
+ // a part's hull only shows where the part stands clear of what is behind it (its silhouette).
62
+ depthPush: Object.freeze({
63
+ cloth: numberField(0, 0, 0.2, 'Push cloth hull lines back in depth (metres): inner edges of layered clothing stop inking across the body; silhouettes stay.'),
64
+ hair: numberField(0, 0, 0.2, 'Push hair hull lines back in depth (metres): edges of overlapping locks stop inking; the hair silhouette stays.'),
65
+ skin: numberField(0, 0, 0.2, 'Push body-skin hull lines back in depth (metres).'),
66
+ metal: numberField(0, 0, 0.2, 'Push metal hull lines back in depth (metres).'),
67
+ }),
59
68
  honourSourceOff: booleanField(true, 'Respect a source material\'s "no outline" (MMD edge flag off).'),
60
69
  widthMap: textureField('Per-texel width multiplier (also `userData.toonOutlineWidthMap`, MToon outline width texture).'),
61
70
  widthMapChannel: selectField(0, [0, 1, 2, 3], 'Width map channel.', { numeric: true, optionLabels: { 0: 'Red', 1: 'Green', 2: 'Blue', 3: 'Alpha' } }),
@@ -1168,12 +1168,17 @@ function runAutoRoles(root, toonSettings, materialRoleOverrides, modelFacts, rep
1168
1168
  report.add('warn', 'no-face-role', 'No material is recognised as a face and automatic roles are off; the face is shaded with the body terminator.');
1169
1169
  return null;
1170
1170
  }
1171
+ const declaredForward = Array.isArray(root.userData?.toonForward);
1171
1172
  const result = inferAutoRoleWeights(root, {
1172
1173
  headBone: findHeadBone(root),
1173
1174
  isEligible: (mat) => !(mat?.isMToonMaterial && mat.isOutline) &&
1174
1175
  AUTO_ROLE_ELIGIBLE.has(classifyMaterialRole(mat, materialRoleOverrides).role),
1175
1176
  skinTolerance: toonSettings.autoRoles.skinTolerance,
1176
1177
  });
1178
+ if (!declaredForward && Array.isArray(root.userData?.toonForward)) {
1179
+ const forward = root.userData.toonForward;
1180
+ report.add('warn', 'rest-forward-inferred', `The model declares no forward and its +Z analysis found no skin in the cheek band; its rest pose faces [${forward.join(', ')}] (inferred from the face) and that was written to root.userData.toonForward. Declare it on the model to make this explicit.`, { forward });
1181
+ }
1177
1182
  if (result.applied) {
1178
1183
  report.add('info', 'auto-roles-applied', `No material is named as a face; inferred face, skin and hair per vertex (${result.method}).`, result);
1179
1184
  } else {
package/src/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Single runtime source of truth for package consumers and ToonLab Pro/MCP.
2
2
  // Keep this value synchronized with package.json during release preparation.
3
- export const TOONLAB_VERSION = '0.5.0';
3
+ export const TOONLAB_VERSION = '0.5.1';
@@ -3,25 +3,21 @@
3
3
  * `isEligible(material)` selects materials whose roles are unknown.
4
4
  * Returns a report; `applied` is false (with a `reason`) when evidence is
5
5
  * insufficient, in which case nothing is modified.
6
+ *
7
+ * The analysis runs in the declared character frame (`root.userData.toonForward`,
8
+ * +Z when none is declared). Only when a model declares nothing and its +Z
9
+ * analysis finds no skin in the cheek band is its rest-pose facing estimated
10
+ * (estimateRestForward) and the analysis retried from that side. The estimate
11
+ * is a fallback, never an override: light warm hair (ash, silver, blond) is
12
+ * skin-coloured by hue and can outweigh the face in it. The inferred axis is
13
+ * kept, and written to the root so later bakes and head tracking agree, only
14
+ * when the retry succeeds.
6
15
  */
7
16
  export function inferAutoRoleWeights(root: any, { headBone, isEligible, skinTolerance, }?: {
8
17
  headBone?: any;
9
18
  isEligible?: () => boolean;
10
19
  skinTolerance?: number;
11
20
  }): {
12
- applied: boolean;
13
- reason: string;
14
- medianRgb?: undefined;
15
- referenceSkinFraction?: undefined;
16
- counts?: undefined;
17
- headVertexCount?: undefined;
18
- meshes?: undefined;
19
- faceFrame?: undefined;
20
- maskedMeshes?: undefined;
21
- method?: undefined;
22
- skinReference?: undefined;
23
- skinTolerance?: undefined;
24
- } | {
25
21
  applied: boolean;
26
22
  medianRgb: any[];
27
23
  reason: string;
@@ -30,9 +26,12 @@ export function inferAutoRoleWeights(root: any, { headBone, isEligible, skinTole
30
26
  headVertexCount?: undefined;
31
27
  meshes?: undefined;
32
28
  faceFrame?: undefined;
29
+ forwardInferred?: undefined;
30
+ head?: undefined;
33
31
  maskedMeshes?: undefined;
34
32
  method?: undefined;
35
33
  skinReference?: undefined;
34
+ skinSplit?: undefined;
36
35
  skinTolerance?: undefined;
37
36
  } | {
38
37
  applied: boolean;
@@ -49,12 +48,31 @@ export function inferAutoRoleWeights(root: any, { headBone, isEligible, skinTole
49
48
  x: number;
50
49
  y: number;
51
50
  };
51
+ forwardInferred: {
52
+ forward: number[];
53
+ samples: number;
54
+ strength: number;
55
+ };
56
+ head: {
57
+ center: THREE.Vector3Tuple;
58
+ radiusX: number;
59
+ radiusY: number;
60
+ };
52
61
  maskedMeshes: number;
53
62
  method: string;
63
+ referenceSkinFraction: number;
54
64
  skinReference: any[];
65
+ skinSplit: {
66
+ boundary: number;
67
+ groups: any[];
68
+ hair: string;
69
+ };
55
70
  skinTolerance: number;
56
71
  reason?: undefined;
57
72
  medianRgb?: undefined;
58
- referenceSkinFraction?: undefined;
73
+ } | {
74
+ applied: boolean;
75
+ reason: string;
59
76
  };
60
77
  export const AUTO_ROLE_ATTRIBUTE: "toonRoleWeights";
78
+ import * as THREE from 'three';
@@ -62,6 +62,12 @@ export const OUTLINE_FIELDS: Readonly<{
62
62
  }>;
63
63
  }>;
64
64
  faceDepthPush: import("./fieldSchema.js").SettingField;
65
+ depthPush: Readonly<{
66
+ cloth: import("./fieldSchema.js").SettingField;
67
+ hair: import("./fieldSchema.js").SettingField;
68
+ skin: import("./fieldSchema.js").SettingField;
69
+ metal: import("./fieldSchema.js").SettingField;
70
+ }>;
65
71
  honourSourceOff: import("./fieldSchema.js").SettingField;
66
72
  widthMap: import("./fieldSchema.js").SettingField;
67
73
  widthMapChannel: import("./fieldSchema.js").SettingField;
@@ -18,32 +18,6 @@ export function findPrimarySkinnedMesh(root: any): any;
18
18
  */
19
19
  export function applyToonShader(root: any, options?: {}): {
20
20
  autoRoles: {
21
- applied: boolean;
22
- reason: string;
23
- medianRgb?: undefined;
24
- referenceSkinFraction?: undefined;
25
- counts?: undefined;
26
- headVertexCount?: undefined;
27
- meshes?: undefined;
28
- faceFrame?: undefined;
29
- maskedMeshes?: undefined;
30
- method?: undefined;
31
- skinReference?: undefined;
32
- skinTolerance?: undefined;
33
- } | {
34
- applied: boolean;
35
- medianRgb: any[];
36
- reason: string;
37
- referenceSkinFraction: number;
38
- counts?: undefined;
39
- headVertexCount?: undefined;
40
- meshes?: undefined;
41
- faceFrame?: undefined;
42
- maskedMeshes?: undefined;
43
- method?: undefined;
44
- skinReference?: undefined;
45
- skinTolerance?: undefined;
46
- } | {
47
21
  applied: boolean;
48
22
  counts: {
49
23
  face: number;
@@ -58,13 +32,31 @@ export function applyToonShader(root: any, options?: {}): {
58
32
  x: number;
59
33
  y: number;
60
34
  };
35
+ forwardInferred: {
36
+ forward: number[];
37
+ samples: number;
38
+ strength: number;
39
+ };
40
+ head: {
41
+ center: THREE.Vector3Tuple;
42
+ radiusX: number;
43
+ radiusY: number;
44
+ };
61
45
  maskedMeshes: number;
62
46
  method: string;
47
+ referenceSkinFraction: number;
63
48
  skinReference: any[];
49
+ skinSplit: {
50
+ boundary: number;
51
+ groups: any[];
52
+ hair: string;
53
+ };
64
54
  skinTolerance: number;
65
55
  reason?: undefined;
66
56
  medianRgb?: undefined;
67
- referenceSkinFraction?: undefined;
57
+ } | {
58
+ applied: boolean;
59
+ reason: string;
68
60
  };
69
61
  convertedMeshCount: number;
70
62
  debugOutputMode: {
@@ -114,6 +106,7 @@ export const TOON_DEBUG_OUTPUT_MODES: Readonly<{
114
106
  }>;
115
107
  /** Debug view name → label. */
116
108
  export const TOON_DEBUG_OUTPUT_LABELS: any;
109
+ import * as THREE from 'three';
117
110
  export { alphaTestForMaterial, createAlphaSettings, DEFAULT_ALPHA_SETTINGS, resolveAlphaForMaterial, sourceOpacity, usesAlphaBlend, usesAlphaCutout } from "./settings/alphaSettings.js";
118
111
  export { BASE_TEXTURE_MATERIAL_COLOR_MODES, BASE_TEXTURE_SATURATION_MODES, createBaseTextureSettings, DEFAULT_BASE_TEXTURE_SETTINGS } from "./settings/baseTextureSettings.js";
119
112
  export { createFurSettings, DEFAULT_FUR_SETTINGS, materialUsesFur } from "./settings/furSettings.js";
@@ -1 +1 @@
1
- export const TOONLAB_VERSION: "0.5.0";
1
+ export const TOONLAB_VERSION: "0.5.1";