@call-me-sensei/toonlab 0.4.19 → 0.4.21

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.
Files changed (123) hide show
  1. package/NPM-LIBRARY.md +230 -0
  2. package/README.md +78 -36
  3. package/agents/PROMPTS.md +91 -0
  4. package/agents/README.md +9 -14
  5. package/agents/claude/CLAUDE.md +8 -0
  6. package/agents/codex/AGENTS.md +161 -0
  7. package/agents/cursor/toonlab.mdc +37 -0
  8. package/agents/references/mcp-asset-discovery.md +12 -9
  9. package/agents/references/runtime-entry-points.md +5 -4
  10. package/agents/skills/claude/asset-sourcing/SKILL.md +9 -6
  11. package/agents/skills/claude/rock-ground-shaders/SKILL.md +16 -0
  12. package/agents/skills/claude/vegetation-sky/SKILL.md +7 -4
  13. package/agents/skills/codex/asset-sourcing/SKILL.md +9 -6
  14. package/agents/skills/codex/rock-ground-shaders/SKILL.md +16 -0
  15. package/agents/skills/codex/vegetation-sky/SKILL.md +7 -4
  16. package/cli/toonlab.mjs +3 -2
  17. package/database/fal-patina.mjs +131 -0
  18. package/database/generation-service.mjs +108 -7
  19. package/database/migrations/0007_creation_revisions.sql +80 -0
  20. package/database/migrations/0008_creation_revision_hardening.sql +98 -0
  21. package/database/migrations/0009_creation_revision_delete_integrity.sql +11 -0
  22. package/database/migrations/0010_external_catalog_assets.sql +28 -0
  23. package/database/providers.mjs +195 -16
  24. package/database/repository.mjs +518 -70
  25. package/database/seeds/catalog/0003_2026-08-open-assets.sql +159086 -0
  26. package/database/seeds/catalog/0004_2026-08-c7-rocks.sql +39405 -0
  27. package/mcp/public-catalog.mjs +4 -52
  28. package/mcp/server.mjs +175 -12
  29. package/mcp/vite-plugin.mjs +78 -2
  30. package/package.json +32 -12
  31. package/scripts/generate-catalog-seed.mjs +34 -7
  32. package/src/asset-policy/catalogLicenses.js +64 -6
  33. package/src/assetlib/loadImported.js +15 -1
  34. package/src/catalog/officialCatalogAssetRuntime.js +13 -2
  35. package/src/catalog/officialCatalogLod.js +34 -1
  36. package/src/catalog/officialCatalogPlacement.js +29 -14
  37. package/src/character/animationRetarget.js +2 -0
  38. package/src/character/characterRig.js +6 -1
  39. package/src/character/characterRuntime.js +175 -7
  40. package/src/environment/environmentMaterialAdapter.js +30 -0
  41. package/src/environment/environmentPresets.js +90 -0
  42. package/src/environment/environmentSunShadowPass.js +42 -0
  43. package/src/environment/toonLabSurfaceLighting.js +108 -10
  44. package/src/environment/urbanPropMaterial.js +104 -2
  45. package/src/lighting/lightingSystem.js +65 -10
  46. package/src/renderer/index.js +7 -0
  47. package/src/renderer/styleComparison.js +985 -0
  48. package/src/rock-shader/index.js +5 -0
  49. package/src/rock-shader/rockGeometryDetail.js +701 -0
  50. package/src/rock-shader/rockMaterial.js +782 -39
  51. package/src/rock-shader/rockRegionRuntime.js +185 -0
  52. package/src/rock-shader/rockSemanticMaterialRuntime.js +337 -0
  53. package/src/rock-shader/rockShaderRuntime.js +354 -67
  54. package/src/rock-shader/rockShaderSettings.js +246 -16
  55. package/src/rock-shader/rockTangentIntegrity.js +136 -0
  56. package/src/rock-shader/rockTextureIntegrity.js +115 -0
  57. package/src/rockgen/index.js +1 -0
  58. package/src/rockgen/rockDocument.js +27 -3
  59. package/src/rockgen/surface/c7GeologySurface.js +372 -0
  60. package/src/shaders-tsl/water.js +8 -0
  61. package/src/sky/cloudShadow.js +25 -1
  62. package/src/sky/skySystem.js +9 -0
  63. package/src/styles/index.js +23 -0
  64. package/src/styles/neutralStylePresets.js +712 -0
  65. package/src/styles/sceneStyleRuntime.js +51 -4
  66. package/src/styles/styleAdapters.js +13 -2
  67. package/src/styles/styleBundle.js +1 -1
  68. package/src/texgen/evaluateTexture.js +30 -3
  69. package/src/texgen/textureGenerators.js +63 -31
  70. package/src/texgen/textureSettings.js +25 -8
  71. package/src/toon/toonSettings.js +96 -2
  72. package/src/vegetation/branchTree.js +327 -42
  73. package/src/vegetation/index.js +24 -2
  74. package/src/vegetation/scatter.js +372 -1
  75. package/src/vegetation/stylizedTree.js +312 -126
  76. package/src/vegetation/stylizedTreeFoliage.js +229 -7
  77. package/src/vegetation/treeSurfaceTextures.js +328 -0
  78. package/src/version.js +1 -1
  79. package/src/water/waterSettings.js +15 -7
  80. package/types/asset-policy/catalogLicenses.d.ts +2 -0
  81. package/types/asset-policy/index.d.ts +2 -0
  82. package/types/assetlib/loadImported.d.ts +3 -1
  83. package/types/catalog/officialCatalogLod.d.ts +63 -5
  84. package/types/character/characterRig.d.ts +59 -0
  85. package/types/character/characterRuntime.d.ts +1 -0
  86. package/types/environment/environmentMaterialAdapter.d.ts +3 -1
  87. package/types/environment/toonLabSurfaceLighting.d.ts +20 -3
  88. package/types/index.d.ts +89 -7
  89. package/types/lighting/lightingSystem.d.ts +7 -0
  90. package/types/react/index.d.ts +20 -0
  91. package/types/renderer/index.d.ts +1 -0
  92. package/types/renderer/styleComparison.d.ts +409 -0
  93. package/types/rock-shader/index.d.ts +5 -0
  94. package/types/rock-shader/rockGeometryDetail.d.ts +300 -0
  95. package/types/rock-shader/rockMaterial.d.ts +3 -0
  96. package/types/rock-shader/rockRegionRuntime.d.ts +86 -0
  97. package/types/rock-shader/rockSemanticMaterialRuntime.d.ts +29 -0
  98. package/types/rock-shader/rockShaderRuntime.d.ts +2 -0
  99. package/types/rock-shader/rockShaderSettings.d.ts +19 -5
  100. package/types/rock-shader/rockTangentIntegrity.d.ts +51 -0
  101. package/types/rock-shader/rockTextureIntegrity.d.ts +61 -0
  102. package/types/rockgen/index.d.ts +1 -0
  103. package/types/rockgen/rockDocument.d.ts +29 -1
  104. package/types/rockgen/surface/c7GeologySurface.d.ts +8 -0
  105. package/types/sky/cloudShadow.d.ts +1 -0
  106. package/types/styles/index.d.ts +1 -0
  107. package/types/styles/neutralStylePresets.d.ts +944 -0
  108. package/types/styles/sceneStyleRuntime.d.ts +20 -0
  109. package/types/vegetation/branchTree.d.ts +4 -0
  110. package/types/vegetation/index.d.ts +9 -7
  111. package/types/vegetation/scatter.d.ts +1 -0
  112. package/types/vegetation/stylizedTree.d.ts +1 -2
  113. package/types/vegetation/stylizedTreeFoliage.d.ts +1 -0
  114. package/types/version.d.ts +1 -1
  115. package/agents/references/geology-playbook.md +0 -118
  116. package/agents/skills/claude/karst-cliff-construction/SKILL.md +0 -258
  117. package/agents/skills/claude/outdoor-world/SKILL.md +0 -492
  118. package/agents/skills/claude/outdoor-world/references/coastal-landform-formation.md +0 -100
  119. package/agents/skills/codex/karst-cliff-construction/SKILL.md +0 -258
  120. package/agents/skills/codex/outdoor-world/SKILL.md +0 -492
  121. package/agents/skills/codex/outdoor-world/references/coastal-landform-formation.md +0 -100
  122. package/src/vegetation/legacyTreePresets.js +0 -120
  123. package/types/vegetation/legacyTreePresets.d.ts +0 -22
@@ -13,6 +13,11 @@ import { createSkyParams } from '../sky/skyParams.js';
13
13
  import { environmentCloudShadow } from '../sky/cloudShadow.js';
14
14
  import { PRESETS as SKY_PRESETS } from '../sky/skyPresets.js';
15
15
  import { resolveSkySystemStyleSnapshot } from '../sky/skyStyleSnapshots.js';
16
+ import {
17
+ azimuthOf,
18
+ elevationOf,
19
+ sunDirectionAt,
20
+ } from '../sky/sunDriver.js';
16
21
  import { applyStyleBundle } from './styleApplication.js';
17
22
  import { applySceneQualityProfile } from './sceneQualityApplication.js';
18
23
  import { createStyleTarget } from './styleAdapters.js';
@@ -51,6 +56,22 @@ function sunDirectionFromFrame(frame) {
51
56
  return [x / length, y / length, z / length];
52
57
  }
53
58
 
59
+ function alignSkyCloudShadowToLighting(sky, direction) {
60
+ sky?.setCloudShadowDirection?.(direction);
61
+ }
62
+
63
+ function skySunPoseForFrame(skyParams, frame) {
64
+ const direction = sunDirectionAt(
65
+ frame.hour / 24,
66
+ skyParams.time?.latitude,
67
+ skyParams.time?.azimuth,
68
+ );
69
+ return {
70
+ azimuth: azimuthOf(direction),
71
+ elevation: elevationOf(direction),
72
+ };
73
+ }
74
+
54
75
  async function applySkySystemBundle(
55
76
  sky,
56
77
  settings,
@@ -62,7 +83,12 @@ async function applySkySystemBundle(
62
83
  const coordinated = {
63
84
  sun: {
64
85
  ...current.sun,
65
- direction: sunDirectionFromFrame(lightingFrame),
86
+ // The SkySystem's sun must follow its real celestial arc so its Dawn,
87
+ // Day, Sunset, and Night palette weights can see the sun cross the
88
+ // horizon. The gameplay lighting frame deliberately keeps a blue night
89
+ // key above the ground; feeding that direction into the sky made every
90
+ // visible dome render as daytime cyan.
91
+ ...skySunPoseForFrame(current, lightingFrame),
66
92
  },
67
93
  time: {
68
94
  ...current.time,
@@ -87,6 +113,7 @@ async function applySkySystemBundle(
87
113
  ...(snapshot ? { style: snapshot.cloudStyle } : {}),
88
114
  },
89
115
  }));
116
+ alignSkyCloudShadowToLighting(sky, sunDirectionFromFrame(lightingFrame));
90
117
  }
91
118
 
92
119
  function lightingRequestsShadows(settings) {
@@ -341,7 +368,14 @@ export function createSceneStyleRuntime({
341
368
  shadows: {
342
369
  enabled: shadowsEnabled,
343
370
  native: renderer?.shadowMap?.enabled === true,
344
- sharedPass: Boolean(shadowPass?.shadowTexture),
371
+ // `sharedPass` used to be `Boolean(shadowPass?.shadowTexture)`, which
372
+ // is true as soon as the pass allocates a target — so a scene with no
373
+ // shadow-casting sun, or no caster, reported healthy shadows and
374
+ // shipped with none (D19-041). It now reflects whether the pass has
375
+ // actually drawn casters into a published map, and `sharedPassHealth`
376
+ // says why not.
377
+ sharedPass: shadowPass?.health?.ok === true,
378
+ sharedPassHealth: shadowPass?.health ?? null,
345
379
  updatesPerSecond: qualityProfile.quality.shadows.maxUpdatesPerSecond,
346
380
  },
347
381
  sky: activeSky ? {
@@ -815,12 +849,25 @@ export function createSceneStyleRuntime({
815
849
  if (isSkySystem(activeSky)) {
816
850
  activeSky.setTimeOfDay(frame.hour, {
817
851
  autoAdvanceSecondsPerDay: 0,
818
- sunDirection: sunDirectionFromFrame(frame),
819
852
  });
853
+ alignSkyCloudShadowToLighting(activeSky, sunDirectionFromFrame(frame));
820
854
  }
821
855
  return frame;
822
856
  },
823
857
 
858
+ setSunDirection(direction = null) {
859
+ const resolved = lighting.setSunDirection(direction);
860
+ if (isSkySystem(activeSky)) {
861
+ activeSky.setTimeOfDay(lighting.frame.hour, {
862
+ autoAdvanceSecondsPerDay: 0,
863
+ sunDirection: resolved,
864
+ });
865
+ alignSkyCloudShadowToLighting(activeSky, resolved);
866
+ }
867
+ shadowPass?.invalidate?.();
868
+ return resolved;
869
+ },
870
+
824
871
  /**
825
872
  * Replaces the physical SkySystem condition while preserving the active
826
873
  * style bundle's sky/cloud presentation and coordinated lighting frame.
@@ -842,8 +889,8 @@ export function createSceneStyleRuntime({
842
889
  } else {
843
890
  activeSky.setTimeOfDay(frame.hour, {
844
891
  autoAdvanceSecondsPerDay: 0,
845
- sunDirection: sunDirectionFromFrame(frame),
846
892
  });
893
+ alignSkyCloudShadowToLighting(activeSky, sunDirectionFromFrame(frame));
847
894
  }
848
895
  }
849
896
  return activeSky.toParams?.() ?? null;
@@ -2,7 +2,7 @@ import { applyToonShader } from '../toon/toonMaterialAdapter.js';
2
2
  import { applyEnvironmentShader } from '../environment/environmentMaterialAdapter.js';
3
3
  import {
4
4
  applyUrbanPropShaderProfile,
5
- classifyUrbanPropSurface,
5
+ classifyUrbanMaterial,
6
6
  createUrbanAnimePropMaterial,
7
7
  createUrbanAnimePropNodeMaterial,
8
8
  createUrbanPropShaderControls,
@@ -49,8 +49,19 @@ function applyManufacturedSurface(root, settings, { target } = {}) {
49
49
  root?.traverse?.((object) => {
50
50
  if (!object?.isMesh || !object.material) return;
51
51
  const materials = Array.isArray(object.material) ? object.material : [object.material];
52
+ // `classifyUrbanPropSurface` returns a PROFILE ID string, and the adapter's
53
+ // `classification` parameter takes a classification OBJECT. Passing the
54
+ // string meant `classificationForAdapter` saw a non-object, found no
55
+ // `surface` either, and fell all the way through to its
56
+ // `paintedMetal` default — so every material converted through the
57
+ // documented style-bundle path resolved to one profile regardless of its
58
+ // semantic role. §8 requires the opposite ("every material receives
59
+ // semantic ToonLab roles before the Manufactured Surface shader is
60
+ // applied"), and the manifest written by
61
+ // `applyManufacturedMaterialManifest` was reaching this call and then
62
+ // being discarded one frame later. See D19-080.
52
63
  const next = materials.map((material) => createMaterial(material, {
53
- classification: classifyUrbanPropSurface(object, material),
64
+ classification: classifyUrbanMaterial(object, material),
54
65
  controls,
55
66
  }));
56
67
  object.material = Array.isArray(object.material) ? next : next[0];
@@ -665,7 +665,7 @@ export async function fetchStyleBundle(ref, {
665
665
  if (firstParty) {
666
666
  return { document: firstParty, settings: resolveStyleBundleSettings(firstParty) };
667
667
  }
668
- const url = /^https?:\/\//.test(ref)
668
+ const url = /^(?:https?:\/\/|\/)/.test(ref)
669
669
  ? ref
670
670
  : `${baseUrl.replace(/\/+$/, '')}/api/v1/bundles/${encodeURIComponent(ref)}`;
671
671
  const response = await fetchImpl(url, { headers: { accept: 'application/json' } });
@@ -10,7 +10,7 @@
10
10
  // color grading, glow, and all PBR derivation keep working on top. Decoding
11
11
  // stays the caller's job so this module remains DOM-free.
12
12
 
13
- import { hashCombine } from '../rockgen/noise/prng.js';
13
+ import { hash3u, hashCombine } from '../rockgen/noise/prng.js';
14
14
  import { periodicFbm2 } from './noise2.js';
15
15
  import { compileTextureLayer } from './textureGenerators.js';
16
16
  import { createTextureSettings } from './textureSettings.js';
@@ -90,6 +90,15 @@ function overlayOp(a, b) {
90
90
  return a < 0.5 ? 2 * a * b : 1 - 2 * (1 - a) * (1 - b);
91
91
  }
92
92
 
93
+ /**
94
+ * Independent [0, 1) draw from a pattern cell id. The legacy per-cell tint read
95
+ * two overlapping 16-bit slices of the same hash for hue and value, which tied a
96
+ * cell's hue to its brightness; a fresh salted hash per channel breaks that.
97
+ */
98
+ function cellUnit(id, salt) {
99
+ return hash3u(salt, id | 0, (id >>> 16) | 0, 0) / 4294967296;
100
+ }
101
+
93
102
  function blendHeight(mode, h, d) {
94
103
  switch (mode) {
95
104
  case 'add': return clamp01(h + (d - 0.5));
@@ -356,6 +365,12 @@ export async function evaluateTextureMaps(rawSettings, {
356
365
  const emissive = settings.emissive;
357
366
  const jitterSeed = hashCombine(seed, 0x11a77);
358
367
  const hueSeed = hashCombine(seed, 0x22b33);
368
+ // Per-cell tint variety (0 = legacy). Above 0 the cell's hue and value come
369
+ // from independent salted hashes and the painterly drift keeps running inside
370
+ // the cell, so N modules stop reading as N flat swatches (D19-057).
371
+ const cellVariety = Math.min(1, Math.max(0, color.jitterCellVariety ?? 0));
372
+ const cellValueSalt = hashCombine(seed, 0x33c1e);
373
+ const cellHueSalt = hashCombine(seed, 0x44d2f);
359
374
  const jitterScale = Math.max(2, Math.round(color.jitterScale));
360
375
  const cavityTint = toLinear(color.cavityTint);
361
376
  const sheenTint = toLinear(color.sheenTint);
@@ -443,7 +458,13 @@ export async function evaluateTextureMaps(rawSettings, {
443
458
  if (color.jitterValue > 0) {
444
459
  let jn;
445
460
  if (color.jitterCells && baseCell !== null) {
446
- jn = ((baseCell >>> 8) & 0xffff) / 65536;
461
+ jn = cellVariety > 0
462
+ ? cellUnit(baseCell, cellValueSalt)
463
+ : ((baseCell >>> 8) & 0xffff) / 65536;
464
+ if (cellVariety > 0) {
465
+ const drift = periodicFbm2(jitterSeed, u * jitterScale, v * jitterScale, jitterScale, jitterScale, 2, 0.5) * 0.5 + 0.5;
466
+ jn = clamp01(jn + (drift - 0.5) * cellVariety * 0.6);
467
+ }
447
468
  } else {
448
469
  jn = periodicFbm2(jitterSeed, u * jitterScale, v * jitterScale, jitterScale, jitterScale, 2, 0.5) * 0.5 + 0.5;
449
470
  }
@@ -458,7 +479,13 @@ export async function evaluateTextureMaps(rawSettings, {
458
479
  if (color.jitterHue > 0) {
459
480
  let hn;
460
481
  if (color.jitterCells && baseCell !== null) {
461
- hn = (baseCell & 0xffff) / 65536;
482
+ hn = cellVariety > 0
483
+ ? cellUnit(baseCell, cellHueSalt)
484
+ : (baseCell & 0xffff) / 65536;
485
+ if (cellVariety > 0) {
486
+ const drift = periodicFbm2(hueSeed, u * jitterScale, v * jitterScale, jitterScale, jitterScale, 2, 0.5) * 0.5 + 0.5;
487
+ hn = clamp01(hn + (drift - 0.5) * cellVariety * 0.6);
488
+ }
462
489
  } else {
463
490
  hn = periodicFbm2(hueSeed, u * jitterScale, v * jitterScale, jitterScale, jitterScale, 2, 0.5) * 0.5 + 0.5;
464
491
  }
@@ -123,19 +123,49 @@ function makeCellular(project) {
123
123
  // Grid patterns (bricks, tiles, checker, weave, ...)
124
124
  // ---------------------------------------------------------------------------
125
125
 
126
- /** Distance-to-edge bevel profile for rectangular cells. */
127
- function faceProfile(lu, lv, gap, bevel) {
128
- const d = Math.min(lu, 1 - lu, lv, 1 - lv);
129
- const g = gap * 0.5;
130
- return smoothstep(g, g + Math.max(0.004, bevel), d);
126
+ /**
127
+ * Distance-to-edge bevel profile for rectangular cells.
128
+ *
129
+ * `gap` and `bevel` are cell-local fractions, so a non-square cell (a plank,
130
+ * a 200x100 subway tile, a clapboard) would otherwise get a joint whose world
131
+ * width differs per axis by the cell aspect ratio. `sx`/`sy` scale the joint
132
+ * per axis so an elongated module can carry an even mortar/groove line;
133
+ * authors drive them from the layer's existing `stretchX`/`stretchY` fields.
134
+ *
135
+ * At sx = sy = 1 this is exactly the original single-axis form: smoothstep is
136
+ * monotonic, so min(smoothstep(du), smoothstep(dv)) === smoothstep(min(du, dv)).
137
+ */
138
+ function faceProfile(lu, lv, gap, bevel, sx = 1, sy = 1) {
139
+ const du = Math.min(lu, 1 - lu);
140
+ const dv = Math.min(lv, 1 - lv);
141
+ const gu = gap * 0.5 * sx;
142
+ const gv = gap * 0.5 * sy;
143
+ const bu = Math.max(0.004, bevel * sx);
144
+ const bv = Math.max(0.004, bevel * sy);
145
+ return Math.min(
146
+ smoothstep(gu, gu + bu, du),
147
+ smoothstep(gv, gv + bv, dv),
148
+ );
149
+ }
150
+
151
+ /**
152
+ * Joint anisotropy from a layer's stretch fields (1 = isotropic). The clamp
153
+ * mirrors the schema range for `stretchX`/`stretchY`; at 0.125 a module may run
154
+ * 8:1 (a 1.6 m deck board in a 200 mm run) and still carry an even joint.
155
+ */
156
+ function jointAspect(params) {
157
+ const sx = Number.isFinite(params.stretchX) ? Math.min(16, Math.max(0.125, params.stretchX)) : 1;
158
+ const sy = Number.isFinite(params.stretchY) ? Math.min(16, Math.max(0.125, params.stretchY)) : 1;
159
+ return { sx, sy };
131
160
  }
132
161
 
133
162
  function compileBricks(params, seed, offsetMode) {
134
- const cols = intIn(params.columns ?? 4, 1, 64, 4);
135
- const rows = intIn(params.rows ?? 8, 1, 64, 8);
163
+ const cols = intIn(params.columns ?? 4, 1, 256, 4);
164
+ const rows = intIn(params.rows ?? 8, 1, 256, 8);
136
165
  const gap = Math.min(0.4, Math.max(0, params.gap ?? 0.06));
137
166
  const bevel = Math.min(0.5, Math.max(0, params.bevel ?? 0.12));
138
167
  const variation = clamp01(params.cellVariation ?? 0.35);
168
+ const { sx, sy } = jointAspect(params);
139
169
  return (u, v) => {
140
170
  const y = v * rows;
141
171
  const row = Math.floor(y);
@@ -143,14 +173,14 @@ function compileBricks(params, seed, offsetMode) {
143
173
  const x = u * cols + shift;
144
174
  const col = Math.floor(x);
145
175
  const id = hash3u(seed, ((col % cols) + cols) % cols, ((row % rows) + rows) % rows, 0);
146
- const value = faceProfile(fract(x), fract(y), gap, bevel);
176
+ const value = faceProfile(fract(x), fract(y), gap, bevel, sx, sy);
147
177
  return { v: withCellVariation(value, id, variation, seed), cell: id };
148
178
  };
149
179
  }
150
180
 
151
181
  function compileChecker(params, seed) {
152
- const cols = intIn(params.columns ?? 4, 1, 64, 4);
153
- const rows = intIn(params.rows ?? 4, 1, 64, 4);
182
+ const cols = intIn(params.columns ?? 4, 1, 256, 4);
183
+ const rows = intIn(params.rows ?? 4, 1, 256, 4);
154
184
  return (u, v) => {
155
185
  const x = Math.floor(u * cols);
156
186
  const y = Math.floor(v * rows);
@@ -160,21 +190,21 @@ function compileChecker(params, seed) {
160
190
  }
161
191
 
162
192
  function compileGrid(params) {
163
- const cols = intIn(params.columns ?? 8, 1, 64, 8);
164
- const rows = intIn(params.rows ?? 8, 1, 64, 8);
193
+ const cols = intIn(params.columns ?? 8, 1, 256, 8);
194
+ const rows = intIn(params.rows ?? 8, 1, 256, 8);
165
195
  const gap = Math.min(0.4, Math.max(0.005, params.gap ?? 0.06));
166
196
  const bevel = Math.min(0.5, Math.max(0.004, params.bevel ?? 0.06));
197
+ const { sx, sy } = jointAspect(params);
167
198
  return (u, v) => {
168
199
  const lu = fract(u * cols);
169
200
  const lv = fract(v * rows);
170
- const d = Math.min(lu, 1 - lu, lv, 1 - lv);
171
- return { v: smoothstep(gap * 0.5, gap * 0.5 + bevel, d), cell: null };
201
+ return { v: faceProfile(lu, lv, gap, bevel, sx, sy), cell: null };
172
202
  };
173
203
  }
174
204
 
175
205
  function compileStripes(params, seed) {
176
- const cols = intIn(params.columns ?? 8, 1, 64, 8);
177
- const rows = intIn(params.rows ?? 8, 1, 64, 8);
206
+ const cols = intIn(params.columns ?? 8, 1, 256, 8);
207
+ const rows = intIn(params.rows ?? 8, 1, 256, 8);
178
208
  const variation = clamp01(params.cellVariation ?? 0);
179
209
  return (u, v) => {
180
210
  const y = v * rows;
@@ -197,8 +227,8 @@ function compileStripes(params, seed) {
197
227
  }
198
228
 
199
229
  function compileChevron(params) {
200
- const cols = intIn(params.columns ?? 8, 1, 64, 8);
201
- const rows = intIn(params.rows ?? 8, 1, 64, 8);
230
+ const cols = intIn(params.columns ?? 8, 1, 256, 8);
231
+ const rows = intIn(params.rows ?? 8, 1, 256, 8);
202
232
  return (u, v) => {
203
233
  const zig = Math.abs(fract(v * rows) * 2 - 1);
204
234
  const t = fract(u * cols + zig);
@@ -207,8 +237,8 @@ function compileChevron(params) {
207
237
  }
208
238
 
209
239
  function compileWeave(params, seed) {
210
- const cols = intIn(params.columns ?? 10, 1, 64, 10);
211
- const rows = intIn(params.rows ?? 10, 1, 64, 10);
240
+ const cols = intIn(params.columns ?? 10, 1, 256, 10);
241
+ const rows = intIn(params.rows ?? 10, 1, 256, 10);
212
242
  const gap = Math.min(0.45, Math.max(0.02, params.gap ?? 0.16));
213
243
  return (u, v) => {
214
244
  const x = u * cols;
@@ -231,8 +261,8 @@ function compileWeave(params, seed) {
231
261
  }
232
262
 
233
263
  function compileBasketWeave(params, seed) {
234
- const cols = intIn(params.columns ?? 6, 1, 48, 6);
235
- const rows = intIn(params.rows ?? 6, 1, 48, 6);
264
+ const cols = intIn(params.columns ?? 6, 1, 256, 6);
265
+ const rows = intIn(params.rows ?? 6, 1, 256, 6);
236
266
  const gap = Math.min(0.4, Math.max(0, params.gap ?? 0.08));
237
267
  const bevel = Math.min(0.5, Math.max(0.01, params.bevel ?? 0.1));
238
268
  const slats = 3;
@@ -257,7 +287,7 @@ function compileBasketWeave(params, seed) {
257
287
  }
258
288
 
259
289
  function compileHex(params, seed) {
260
- const cols = intIn(params.columns ?? 6, 1, 48, 6);
290
+ const cols = intIn(params.columns ?? 6, 1, 256, 6);
261
291
  // A staggered lattice with 2:sqrt(3) aspect produces regular hexagons
262
292
  // out of plain nearest-center Voronoi. Rows derive from columns so the
263
293
  // lattice stays periodic in the unit tile.
@@ -299,8 +329,8 @@ function compileHex(params, seed) {
299
329
  }
300
330
 
301
331
  function compileScales(params, seed) {
302
- const cols = intIn(params.columns ?? 8, 1, 48, 8);
303
- const rows = intIn(params.rows ?? 12, 1, 64, 12);
332
+ const cols = intIn(params.columns ?? 8, 1, 256, 8);
333
+ const rows = intIn(params.rows ?? 12, 1, 256, 12);
304
334
  const variation = clamp01(params.cellVariation ?? 0.25);
305
335
  return (u, v) => {
306
336
  // Staggered rows of overlapping shingles: the row above clips the one
@@ -328,8 +358,8 @@ function compileScales(params, seed) {
328
358
  }
329
359
 
330
360
  function compileDots(params, seed) {
331
- const cols = intIn(params.columns ?? 8, 1, 64, 8);
332
- const rows = intIn(params.rows ?? 8, 1, 64, 8);
361
+ const cols = intIn(params.columns ?? 8, 1, 256, 8);
362
+ const rows = intIn(params.rows ?? 8, 1, 256, 8);
333
363
  const radius = Math.min(0.48, Math.max(0.02, 0.5 - (params.gap ?? 0.18)));
334
364
  const soft = Math.min(0.5, Math.max(0.01, params.bevel ?? 0.08));
335
365
  const jitter = clamp01(params.cellJitter ?? 0);
@@ -383,7 +413,7 @@ function compileMarble(params, seed) {
383
413
  const { px, py } = periods(params);
384
414
  const octaves = intIn(params.detail ?? 4, 1, 8, 4);
385
415
  const sharp = 0.5 + (params.grain ?? 0.5) * 6;
386
- const veins = intIn(params.rings ?? 3, 1, 24, 3);
416
+ const veins = intIn(params.rings ?? 3, 1, 128, 3);
387
417
  return (u, v) => {
388
418
  const turb = periodicTurbulence2(seed, u * px, v * py, px, py, octaves, 0.55);
389
419
  const phase = fract(u * veins + turb * 1.35);
@@ -394,7 +424,7 @@ function compileMarble(params, seed) {
394
424
 
395
425
  function compileWoodGrain(params, seed) {
396
426
  const { px, py } = periods(params);
397
- const rings = intIn(params.rings ?? 6, 1, 32, 6);
427
+ const rings = intIn(params.rings ?? 6, 1, 128, 6);
398
428
  const grain = clamp01(params.grain ?? 0.5);
399
429
  const octaves = intIn(params.detail ?? 3, 1, 8, 3);
400
430
  const streakSeed = hashCombine(seed, 0xbeef);
@@ -441,7 +471,9 @@ function compileFlat() {
441
471
 
442
472
  const NOISE_USES = ['scale', 'detail', 'detailGain', 'stretchX', 'stretchY', 'warp', 'warpScale'];
443
473
  const CELL_USES = ['scale', 'stretchX', 'stretchY', 'cellJitter', 'cellVariation', 'edgeWidth', 'warp', 'warpScale'];
444
- const GRID_USES = ['columns', 'rows', 'gap', 'bevel', 'cellVariation', 'warp', 'warpScale'];
474
+ // stretchX/stretchY scale the joint width per axis so non-square modules
475
+ // (planks, 200x100 subway tile) can carry an even mortar line.
476
+ const GRID_USES = ['columns', 'rows', 'gap', 'bevel', 'cellVariation', 'stretchX', 'stretchY', 'warp', 'warpScale'];
445
477
 
446
478
  export const TEXTURE_GENERATORS = Object.freeze({
447
479
  fbm: { label: 'Soft noise (fbm)', category: 'noise', uses: NOISE_USES, compile: makeNoise(periodicFbm2, true) },
@@ -484,7 +516,7 @@ export const TEXTURE_GENERATORS = Object.freeze({
484
516
  tiles: { label: 'Tiles (stacked)', category: 'pattern', uses: GRID_USES, compile: (p, s) => compileBricks(p, s, 'stacked') },
485
517
  hex: { label: 'Hex tiles', category: 'pattern', uses: ['columns', 'gap', 'bevel', 'cellVariation', 'warp', 'warpScale'], compile: compileHex },
486
518
  checker: { label: 'Checker', category: 'pattern', uses: ['columns', 'rows', 'warp', 'warpScale'], compile: compileChecker },
487
- grid: { label: 'Grid lines', category: 'pattern', uses: ['columns', 'rows', 'gap', 'bevel', 'warp', 'warpScale'], compile: compileGrid },
519
+ grid: { label: 'Grid lines', category: 'pattern', uses: ['columns', 'rows', 'gap', 'bevel', 'stretchX', 'stretchY', 'warp', 'warpScale'], compile: compileGrid },
488
520
  stripes: { label: 'Stripes', category: 'pattern', uses: ['columns', 'rows', 'cellVariation', 'warp', 'warpScale'], compile: compileStripes },
489
521
  chevron: { label: 'Chevron zigzag', category: 'pattern', uses: ['columns', 'rows', 'warp', 'warpScale'], compile: compileChevron },
490
522
  weave: { label: 'Cloth weave', category: 'pattern', uses: ['columns', 'rows', 'gap', 'warp', 'warpScale'], compile: compileWeave },
@@ -34,22 +34,31 @@ const generatorField = (defaultValue, description) => sel(
34
34
  // generator reads is declared in TEXTURE_GENERATORS[id].uses — UIs filter
35
35
  // on that so only meaningful sliders show.
36
36
  const layerParamFields = ({ advancedShape = false } = {}) => [
37
- num('scale', 'Scale', 1, 64, 1, 6, 'Feature cells across the tile. Higher = finer features.'),
37
+ // Feature size is a property of the (scale, world tile) pairing, not of the
38
+ // map: a tile bound at T metres resolves features of T / scale. At the old
39
+ // cap of 64 a 2.4 m deck tile could not describe anything finer than 3.8 cm,
40
+ // which is coarser than plank grain, asphalt aggregate or paver texture — the
41
+ // cap, not the resolution, was setting the floor on feature size.
42
+ num('scale', 'Scale', 1, 256, 1, 6, 'Feature cells across the tile. Higher = finer features. At a 2 m world tile, 200 resolves 1 cm detail.'),
38
43
  bool('rotate90', 'Rotate 90°', false, 'Turns the pattern a quarter turn (planks run vertical, strata run horizontal). Tiling stays exact.', { advanced: true }),
39
44
  num('detail', 'Detail octaves', 1, 8, 1, 4, 'Fractal octaves layered into the noise.'),
40
45
  num('detailGain', 'Detail strength', 0.15, 0.85, 0.01, 0.5, 'How much each finer octave contributes.', { advanced: true }),
41
- num('stretchX', 'Stretch X', 0.25, 8, 0.05, 1, 'Horizontal anisotropy (brushed metal, wood planks).', { advanced: true }),
42
- num('stretchY', 'Stretch Y', 0.25, 8, 0.05, 1, 'Vertical anisotropy (drips, strata, fibers).', { advanced: true }),
46
+ // Naming note: these multiply the FEATURE PERIOD COUNT on each axis, so
47
+ // raising one makes features finer on that axis and therefore elongated
48
+ // along the other. stretchX > 1 reads as vertical streaks, not horizontal
49
+ // ones. The old labels claimed the opposite.
50
+ num('stretchX', 'X frequency', 0.125, 16, 0.05, 1, 'Multiplies feature periods across U. Higher = finer in U, so features elongate along V and read as VERTICAL streaks (drips, fibers, strata). On grid patterns it instead widens the joint on the U axis.', { advanced: true }),
51
+ num('stretchY', 'Y frequency', 0.125, 16, 0.05, 1, 'Multiplies feature periods down V. Higher = finer in V, so features elongate along U and read as HORIZONTAL streaks (brushed metal, boards lying across the frame). On grid patterns it instead widens the joint on the V axis.', { advanced: true }),
43
52
  num('warp', 'Warp', 0, 1, 0.01, 0, 'Domain warp: melts straight features into organic meanders.'),
44
53
  num('warpScale', 'Warp scale', 1, 32, 1, 3, 'Frequency of the warp field.', { advanced: true }),
45
- num('columns', 'Columns', 1, 64, 1, 4, 'Pattern cells across the tile.', { advanced: advancedShape }),
46
- num('rows', 'Rows', 1, 64, 1, 8, 'Pattern cells down the tile.', { advanced: advancedShape }),
54
+ num('columns', 'Columns', 1, 256, 1, 4, 'Pattern cells across the tile.', { advanced: advancedShape }),
55
+ num('rows', 'Rows', 1, 256, 1, 8, 'Pattern cells down the tile.', { advanced: advancedShape }),
47
56
  num('gap', 'Gap width', 0, 0.4, 0.005, 0.06, 'Mortar/groove width between pattern cells.', { advanced: advancedShape }),
48
57
  num('bevel', 'Bevel', 0, 0.5, 0.005, 0.12, 'Edge ramp from groove up to the cell face.', { advanced: advancedShape }),
49
58
  num('cellJitter', 'Cell jitter', 0, 1, 0.01, 1, 'Randomizes cell centers: 0 = perfect grid, 1 = organic.', { advanced: true }),
50
59
  num('cellVariation', 'Cell variation', 0, 1, 0.01, 0.35, 'Per-cell brightness variance (brick tint shifts).'),
51
60
  num('edgeWidth', 'Edge width', 0.01, 0.6, 0.005, 0.12, 'Width of cracks / caustic filaments / speckle chips.', { advanced: advancedShape }),
52
- num('rings', 'Rings / veins', 1, 32, 1, 6, 'Ring or vein count across the tile (wood, marble).', { advanced: advancedShape }),
61
+ num('rings', 'Rings / veins', 1, 128, 1, 6, 'Ring or vein count across the tile (wood, marble).', { advanced: advancedShape }),
53
62
  num('grain', 'Grain', 0, 1, 0.01, 0.5, 'Streak amount (wood) or vein sharpness (marble).', { advanced: advancedShape }),
54
63
  ];
55
64
 
@@ -115,8 +124,16 @@ const RAW_FIELD_SCHEMA = {
115
124
  num('rampSmooth', 'Band smoothness', 0, 1, 0.01, 1, '1 = smooth gradient, 0 = hard cel bands between the five stops.'),
116
125
  num('jitterHue', 'Hue jitter', 0, 0.5, 0.005, 0.04, 'Painterly hue drift across the surface.'),
117
126
  num('jitterValue', 'Value jitter', 0, 0.5, 0.005, 0.08, 'Painterly brightness drift across the surface.'),
118
- num('jitterScale', 'Jitter scale', 2, 64, 1, 24, 'Frequency of the painterly drift.', { advanced: true }),
119
- bool('jitterCells', 'Jitter per cell', false, 'Applies drift per pattern cell (per brick / plank / scale) instead of smoothly.'),
127
+ num('jitterScale', 'Jitter scale', 2, 256, 1, 24, 'Frequency of the painterly drift.', { advanced: true }),
128
+ bool('jitterCells', 'Jitter per cell', false, 'Applies drift per pattern cell (per brick / plank / scale) instead of smoothly. Reads the BASE layer\'s cell id, so it is a no-op when the base pattern has no cells.'),
129
+ // A periodic tile can only ever hold `columns * rows` cell ids, and the
130
+ // legacy path derived hue and value from two overlapping 16-bit slices of
131
+ // the same hash — so a 4x4 paver tile showed 16 flat tints whose hue and
132
+ // value moved together. Raising this decorrelates hue from value with
133
+ // independent hashes and keeps the painterly drift alive inside each cell,
134
+ // so modules stop reading as N flat swatches. 0 keeps the legacy result
135
+ // byte-for-byte.
136
+ num('jitterCellVariety', 'Per-cell variety', 0, 1, 0.01, 0, 'Enriches per-cell drift: decorrelates each cell\'s hue from its value and keeps painterly drift running inside the cell, so a tile with N modules stops reading as N flat tints. 0 = legacy flat per-cell tint.'),
120
137
  num('cavity', 'Cavity shading', 0, 1, 0.01, 0.35, 'Darkens crevices toward the cavity tint — the hand-painted occlusion read.'),
121
138
  col('cavityTint', 'Cavity tint', [0.13, 0.09, 0.08], 'Color the crevices sink toward.', { advanced: true }),
122
139
  num('sheen', 'Ridge sheen', 0, 1, 0.01, 0.18, 'Screens the sheen tint over ridges and edges — worn highlight.'),
@@ -77,6 +77,100 @@ export const TOON_PRESET_IDS = Object.freeze({
77
77
 
78
78
  const DEFAULT_TOON_PRESET_SETTINGS = Object.freeze({});
79
79
 
80
+ /**
81
+ * The Call Me Sensei house look, authored against premium anime-game character
82
+ * rendering rather than left at the library baseline.
83
+ *
84
+ * The baseline is deliberately conservative: it has to look sane on any imported
85
+ * GLB/VRM, so its outlines are thin, its speculars are near-off and its shadows
86
+ * are close to neutral. That produces a character who is *cel shaded* but whose
87
+ * material families all read the same — skin, cloth, leather and metal separate
88
+ * only by albedo. This preset is the opposite trade: it assumes a hero character
89
+ * under art direction and spends contrast on telling those families apart.
90
+ *
91
+ * Every value below is a deliberate delta from `DEFAULT_*_SETTINGS`; anything not
92
+ * listed is intentionally inherited. The three axes that carry the separation:
93
+ *
94
+ * 1. **Outline weight by role.** Contour is the single strongest anime material
95
+ * cue. Costume and metal carry a heavy near-neutral line, hair carries a
96
+ * weighty warm-dark line (the baseline's 0.00055 is invisible past two metres),
97
+ * skin carries a thin warm line, and the face carries none at all — a line
98
+ * across a cel-shaded face reads as dirt, not drawing.
99
+ * 2. **Specular by role.** Cloth stays matte, hair takes a broad anisotropic
100
+ * band, and metal takes a tight bright hit. Leather is reached per-material
101
+ * through `specular.sourceMaskMode: 'source'` plus a `toonSpecularMaskMap`,
102
+ * because a fifth family cannot be expressed through roles alone.
103
+ * 3. **Coloured shadow families.** Shadows shift blue-violet and gain saturation
104
+ * instead of just going dark, and the terminator gains a warmer, more
105
+ * saturated transition band — the value grouping §4 asks for.
106
+ */
107
+ const CALL_ME_SENSEI_TOON_PRESET_SETTINGS = Object.freeze({
108
+ // Slightly earlier, slightly tighter terminator so the light/shadow split is a
109
+ // drawn shape with a clean edge rather than a soft gradient.
110
+ celShade: Object.freeze({
111
+ bodyCelMidPoint: 0.04,
112
+ bodyCelSoftness: 0.036,
113
+ }),
114
+ // Occlusion under collars, chins, cuffs and hems is what stops a toon character
115
+ // reading as a decal on the plate.
116
+ contactShadow: Object.freeze({
117
+ faceStrength: 0.46,
118
+ strength: 0.62,
119
+ }),
120
+ hairHighlight: Object.freeze({
121
+ intensity: 0.22,
122
+ shadowFloor: 0.32,
123
+ sideBandPower: 2.2,
124
+ strandPower: 8,
125
+ }),
126
+ outline: Object.freeze({
127
+ defaultLightingMix: 0.22,
128
+ defaultMaxBrightness: 0.3,
129
+ defaultMinBrightness: 0.035,
130
+ defaultTintColor: [0.22, 0.21, 0.28],
131
+ defaultWidth: 0.0024,
132
+ hairLightingMix: 0.1,
133
+ hairMaxBrightness: 0.42,
134
+ hairMinBrightness: 0.07,
135
+ hairTintColor: [0.3, 0.24, 0.27],
136
+ hairWidth: 0.0016,
137
+ metalLightingMix: 0.2,
138
+ metalMaxBrightness: 0.32,
139
+ metalTintColor: [0.2, 0.2, 0.26],
140
+ metalWidth: 0.0022,
141
+ skinTintColor: [0.55, 0.3, 0.3],
142
+ skinWidth: 0.0011,
143
+ }),
144
+ rimLight: Object.freeze({
145
+ defaultIntensity: 0.16,
146
+ defaultTintColor: [0.79, 0.88, 1],
147
+ hairIntensity: 0.3,
148
+ skinIntensity: 0.15,
149
+ }),
150
+ // Cloth stays matte (a low, broad, late-onset lobe), hair broadens, metal takes
151
+ // a bright tight hit. `sourceMaskMode: 'source'` is what lets a single costume
152
+ // role still contain a leather family: give that material a
153
+ // `userData.toonSpecularMaskMap` and it gets the highlight, its neighbours do not.
154
+ specular: Object.freeze({
155
+ defaultIntensity: 0.09,
156
+ defaultMidPoint: 0.74,
157
+ defaultPower: 48,
158
+ hairIntensity: 0.24,
159
+ hairPower: 44,
160
+ metalIntensity: 0.5,
161
+ sourceMaskMode: 'source',
162
+ }),
163
+ // Shadows move blue-violet and gain chroma rather than only losing value; the
164
+ // terminator keeps a warmer, more saturated band against them.
165
+ shadowColor: Object.freeze({
166
+ selfShadowAreaHueOffset: -0.022,
167
+ selfShadowAreaSaturationBoost: 0.34,
168
+ selfShadowAreaValueMul: 0.63,
169
+ transitionAreaHueOffset: 0.018,
170
+ transitionAreaSaturationBoost: 0.5,
171
+ }),
172
+ });
173
+
80
174
  export const TOON_SETTING_DEFAULTS = Object.freeze({
81
175
  alpha: DEFAULT_ALPHA_SETTINGS,
82
176
  averageShadow: DEFAULT_AVERAGE_SHADOW_SETTINGS,
@@ -470,9 +564,9 @@ const BUILT_IN_TOON_PRESETS = Object.freeze({
470
564
  settings: DEFAULT_TOON_PRESET_SETTINGS,
471
565
  }),
472
566
  [TOON_PRESET_IDS.callMeSensei]: Object.freeze({
473
- description: 'Team-maintained house style preset. It starts identical to Default and can evolve separately.',
567
+ description: 'Team-maintained house style. Role-weighted outlines, per-role specular and coloured shadow families so skin, hair, cloth, leather and metal read as distinct materials.',
474
568
  label: 'Call Me Sensei',
475
- settings: { ...DEFAULT_TOON_PRESET_SETTINGS },
569
+ settings: CALL_ME_SENSEI_TOON_PRESET_SETTINGS,
476
570
  }),
477
571
  [TOON_PRESET_IDS.showcase]: Object.freeze({
478
572
  description: 'Every optional feature turned on with demo values — a one-click way to see the full feature set. Not a production look.',