@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
@@ -156,6 +156,78 @@ export function traceLeafShapePath(ctx, shape, length, width, outline = null) {
156
156
  if (i === 0) ctx.moveTo(x, y);
157
157
  else ctx.lineTo(x, y);
158
158
  }
159
+ } else if (shape === 'palmate') {
160
+ // Acer palmatum. Seven deeply divided lobes, sinuses cutting almost to
161
+ // the petiole, each lobe lanceolate with a serrated margin.
162
+ //
163
+ // This is NOT a variant of 'maple' above: that outline is a five-point
164
+ // sugar-maple star with blunt lobes and shallow sinuses, which is a
165
+ // correct Acer saccharum and a wrong Acer palmatum. The Japanese maple's
166
+ // whole identity is that the crown reads as lace rather than as a mass of
167
+ // leaf blades, and at 85 mm the difference between a five-point star and
168
+ // a seven-lobe dissected leaf is the difference between the species.
169
+ // The fan is nearly a full circle: on a real A. palmatum the outer pair of
170
+ // lobes swings back past horizontal, almost touching the petiole, which is
171
+ // what gives the leaf its star read rather than a hand read. A narrow fan
172
+ // is the single most common way a stylized "maple" ends up looking like a
173
+ // palm frond instead.
174
+ const lobes = 7;
175
+ const spread = 4.9; // full fan, radians (~281 deg)
176
+ const sinus = 0.11; // sinus depth, as a radial fraction
177
+ const half = (spread / (lobes - 1)) * 0.34;
178
+ const steps = 10; // even → symmetric serration
179
+ let started = false;
180
+ const emit = (angle, radial) => {
181
+ const x = Math.sin(angle) * width * radial;
182
+ const y = -Math.cos(angle) * length * radial;
183
+ if (started) ctx.lineTo(x, y);
184
+ else { ctx.moveTo(x, y); started = true; }
185
+ };
186
+ for (let i = 0; i < lobes; i += 1) {
187
+ const axis = ((i / (lobes - 1)) * 2 - 1) * spread * 0.5;
188
+ for (let side = -1; side <= 1; side += 2) {
189
+ for (let k = 0; k <= steps; k += 1) {
190
+ // Left margin runs outward, right margin runs back in, so the
191
+ // outline stays a single continuous closed loop.
192
+ const step = side < 0 ? k : steps - k;
193
+ const t = step / steps;
194
+ // Lanceolate profile: pinched at the sinus, widest around 40% out,
195
+ // drawn to a point at the tip.
196
+ const body = Math.sin(Math.PI * Math.min(1, 0.08 + t * 0.9)) ** 0.62;
197
+ // Alternating teeth along the margin.
198
+ const tooth = step % 2 === 0 ? 1 : 0.74;
199
+ emit(axis + side * half * body * tooth, sinus + (0.5 - sinus) * t);
200
+ }
201
+ }
202
+ }
203
+ } else if (shape === 'needle-fascicle') {
204
+ // Pinus thunbergii. A black pine's foliage unit is a FASCICLE: two stiff
205
+ // needles bound at a common sheath, diverging into a narrow V. Drawing a
206
+ // pine from single 'needle' blades gives a scatter of loose hairs; the
207
+ // paired V is what makes a card read as pine rather than as fur, and it
208
+ // is also what carries the species' characteristic stiffness.
209
+ // Black pine needles are 6-12 cm long and about 1.5 mm across, held stiff
210
+ // and nearly parallel — the pair diverges by well under 20 deg. A wide V
211
+ // reads as a grass tussock, not as a conifer.
212
+ //
213
+ // The thickness is deliberately far above botanical scale. A card that is
214
+ // 0.5 m across renders about 40 px tall at the distance a garden pine is
215
+ // actually seen from, and a true-scale needle is then a quarter of a pixel:
216
+ // the mip chain averages it below the alpha cutoff and the whole card
217
+ // collapses into a soft lobed blob — which is exactly what the first
218
+ // version did, and it made the pine read as a broadleaf. Stylized conifers
219
+ // exaggerate needle width for the same reason hand-painted ones do.
220
+ const t = width * 0.23;
221
+ const reach = width * 0.17;
222
+ const sheathTop = length * 0.3;
223
+ ctx.moveTo(-t * 1.7, length * 0.5);
224
+ ctx.lineTo(-reach - t * 0.3, -length * 0.5);
225
+ ctx.lineTo(-reach + t * 0.3, -length * 0.48);
226
+ ctx.lineTo(-t * 0.3, sheathTop);
227
+ ctx.lineTo(t * 0.3, sheathTop);
228
+ ctx.lineTo(reach - t * 0.3, -length * 0.48);
229
+ ctx.lineTo(reach + t * 0.3, -length * 0.5);
230
+ ctx.lineTo(t * 1.7, length * 0.5);
159
231
  } else if (shape === 'gingko' || shape === 'fan') {
160
232
  // Fan: narrow stem base opening to a wide notched top edge.
161
233
  ctx.moveTo(0, length * 0.5);
@@ -173,9 +245,22 @@ export function traceLeafShapePath(ctx, shape, length, width, outline = null) {
173
245
  }
174
246
 
175
247
  export const LEAF_SHAPE_PRESETS = Object.freeze([
176
- 'teardrop', 'round', 'oak', 'maple', 'gingko', 'needle',
248
+ 'teardrop', 'round', 'oak', 'maple', 'palmate', 'gingko', 'needle',
249
+ 'needle-fascicle',
177
250
  ]);
178
251
 
252
+ /**
253
+ * Shapes whose card is a needle spray rather than a broadleaf cluster.
254
+ *
255
+ * Needles are an order of magnitude longer than they are wide, and on a real
256
+ * shoot they radiate from the twig rather than facing every direction at
257
+ * once. Painting them with the broadleaf arrangement below gives a fuzzy
258
+ * felted disc; keying the arrangement off the shape keeps every existing
259
+ * broadleaf sprite byte-identical while letting a conifer card look like a
260
+ * conifer.
261
+ */
262
+ export const NEEDLE_LEAF_SHAPES = Object.freeze(['needle', 'needle-fascicle']);
263
+
179
264
  export function createLeafSpriteTexture({
180
265
  size = 512,
181
266
  leafCount = 170,
@@ -202,6 +287,76 @@ export function createLeafSpriteTexture({
202
287
  ctx.restore();
203
288
  };
204
289
 
290
+ const needleSpray = NEEDLE_LEAF_SHAPES.includes(shape);
291
+
292
+ if (needleSpray) {
293
+ // A conifer card is not a scatter of organs over a disc. Needles are borne
294
+ // on SHOOTS: each year's growth is a short candle, and the fascicles stand
295
+ // out from that candle along its own axis. Distributing them over a disc
296
+ // and pointing each one at the card centre — the broadleaf arrangement —
297
+ // gives a sea-urchin starburst, which is what the first version of this
298
+ // card looked like. Building a handful of shoots and hanging needles off
299
+ // each one is what turns the card back into a bough.
300
+ // Enough shoots, started at enough different radii, that their needle
301
+ // masses OVERLAP. Six evenly spaced candles left visible sky between them
302
+ // and the card read as an asterisk of brushes; a real bough is a
303
+ // continuous mass with structure inside it, not a ring of separate
304
+ // brooms.
305
+ const shootCount = 9;
306
+ const shoots = Array.from({ length: shootCount }, (_, index) => ({
307
+ axis: (index / shootCount) * Math.PI * 2 + (rng() - 0.5) * 0.9,
308
+ base: size * (-0.02 + rng() * 0.14),
309
+ reach: size * (0.2 + rng() * 0.2),
310
+ // Shoots nearer the rim carry the crisp, bright silhouette needles.
311
+ lift: rng(),
312
+ }));
313
+ // Fewer, bolder fascicles. Packing the card with hundreds of hairline
314
+ // needles reads as felt once mipped; a stylized bough wants a countable
315
+ // number of confident strokes.
316
+ const perShoot = Math.max(5, Math.round((leafCount * 0.5) / shootCount));
317
+ const needles = [];
318
+ for (const shoot of shoots) {
319
+ for (let i = 0; i < perShoot; i += 1) {
320
+ // Along the candle, packed toward its outer half the way a real
321
+ // year's growth is.
322
+ const along = Math.pow(rng(), 0.72);
323
+ const radius = shoot.base + along * shoot.reach;
324
+ // Fascicles stand out from the shoot at a shallow angle, alternating
325
+ // to either side, so the candle keeps a visible axis.
326
+ const side = i % 2 === 0 ? 1 : -1;
327
+ const flare = (0.24 + rng() * 0.42) * side;
328
+ const lateral = (rng() - 0.5) * size * 0.07;
329
+ needles.push({
330
+ x: center + Math.cos(shoot.axis) * radius - Math.sin(shoot.axis) * lateral,
331
+ y: center + Math.sin(shoot.axis) * radius + Math.cos(shoot.axis) * lateral,
332
+ orientation: shoot.axis - Math.PI / 2 + flare,
333
+ depth: radius,
334
+ edgeT: Math.min(radius / (size * 0.4), 1),
335
+ pick: rng(),
336
+ spin: rng(),
337
+ shade: rng(),
338
+ lift: shoot.lift,
339
+ });
340
+ }
341
+ }
342
+ needles.sort((a, b) => a.depth - b.depth);
343
+ for (const needle of needles) {
344
+ const length = size * (0.24 + needle.pick * 0.16);
345
+ const width = length * (0.4 + needle.spin * 0.16);
346
+ const luminance = THREE.MathUtils.lerp(
347
+ 0.46 + needle.shade * 0.2,
348
+ 0.74 + needle.shade * 0.26,
349
+ Math.min(1, needle.edgeT * 0.7 + needle.lift * 0.4),
350
+ );
351
+ drawLeaf(needle.x, needle.y, length, width, needle.orientation, luminance);
352
+ }
353
+ const texture = new THREE.CanvasTexture(canvas);
354
+ texture.name = `ToonLabLeafSprite.${shape}`;
355
+ texture.colorSpace = THREE.NoColorSpace;
356
+ texture.anisotropy = 4;
357
+ return texture;
358
+ }
359
+
205
360
  const leaves = [];
206
361
  for (let i = 0; i < leafCount; i += 1) {
207
362
  const angle = rng() * Math.PI * 2;
@@ -231,6 +386,7 @@ export function createLeafSpriteTexture({
231
386
  });
232
387
 
233
388
  const texture = new THREE.CanvasTexture(canvas);
389
+ texture.name = `ToonLabLeafSprite.${shape}`;
234
390
  texture.colorSpace = THREE.NoColorSpace;
235
391
  texture.anisotropy = 4;
236
392
  return texture;
@@ -464,6 +620,31 @@ export function createTreeFoliageGeometry({
464
620
  sprayLayers = 3,
465
621
  spraySpread = 0.8,
466
622
  sprayThickness = 0.18,
623
+ // layered-sprays only. 0 (the default, and every historical tree) builds
624
+ // each spray disc ACROSS THE TWIG: the disc's thickness axis is the branch
625
+ // growth direction, so a twig rising at 50 deg carries a disc tilted at
626
+ // 50 deg. Summed over a few hundred tips that is a dome of randomly tilted
627
+ // discs — which is the correct read for most broadleaves.
628
+ //
629
+ // It is the wrong read for a tree whose identity IS horizontality. An Acer
630
+ // palmatum, a dogwood, a layered Japanese hornbeam: the leaf layer is flat
631
+ // WITH THE GROUND, not with the twig that carries it. 1 rotates each spray
632
+ // basis all the way to world horizontal, so every tuft becomes a flat plate
633
+ // however its twig is oriented, and the crown reads as stacked layers you
634
+ // can see between instead of a shell you cannot.
635
+ //
636
+ // Only the DISC is realigned. Placement along the twig (the tuft's offset
637
+ // from its attachment) still follows the branch, so foliage stays where the
638
+ // wood puts it and the skeleton keeps its authority over the silhouette.
639
+ plateAlignment = 0,
640
+ // Drops the OUTER edge of each spray disc, as a fraction of the disc's own
641
+ // radius, on a quadratic falloff from its centre. A perfectly flat plate is
642
+ // a machined shelf: it is what a Cornus controversa or an acacia reads as,
643
+ // and the reason a plated crown can look manufactured rather than grown.
644
+ // Real tiers sag under their own weight and their outer twigs hang, so the
645
+ // tier has a soft drooping edge instead of a cut one. 0 keeps the plate
646
+ // flat, which is every historical tree.
647
+ plateDroop = 0,
467
648
  whorlArms = 6,
468
649
  whorlRadius = 0.48,
469
650
  frondCount = 7,
@@ -741,6 +922,41 @@ export function createTreeFoliageGeometry({
741
922
  : new THREE.Vector3(0, 1, 0);
742
923
  const branchRight = new THREE.Vector3().crossVectors(growth, basisReference).normalize();
743
924
  const branchForward = new THREE.Vector3().crossVectors(branchRight, growth).normalize();
925
+ // The spray disc's own basis. At plateAlignment 0 these are exactly the
926
+ // branch vectors above and nothing moves; at 1 the disc normal is world
927
+ // up and the tuft is a horizontal plate. The blend is on the NORMAL, so
928
+ // partial values tilt the plate rather than shearing it.
929
+ const tuftPlateAlignment = THREE.MathUtils.clamp(
930
+ override?.plateAlignment ?? attachment.plateAlignment ?? plateAlignment,
931
+ 0,
932
+ 1,
933
+ );
934
+ const tuftPlateDroop = Math.max(
935
+ override?.plateDroop ?? attachment.plateDroop ?? plateDroop,
936
+ 0,
937
+ );
938
+ let sprayNormal = growth;
939
+ let sprayRight = branchRight;
940
+ let sprayForward = branchForward;
941
+ if (tuftPlateAlignment > 0) {
942
+ const worldUp = new THREE.Vector3(0, growth.y < 0 ? -1 : 1, 0);
943
+ sprayNormal = growth.clone().lerp(worldUp, tuftPlateAlignment);
944
+ // growth and worldUp are antipodal only when the twig points exactly
945
+ // down, which the sign flip above already rules out; a degenerate
946
+ // lerp result can still appear at float precision.
947
+ if (sprayNormal.lengthSq() < 1e-8) sprayNormal.copy(worldUp);
948
+ sprayNormal.normalize();
949
+ // The plate's long axis follows its own limb's heading, so a plate is
950
+ // elongated ALONG the branch that carries it (which is what a real
951
+ // layered crown does) instead of every plate sharing one world axis —
952
+ // a squash direction repeated over 300 tufts is visible as a grain.
953
+ sprayRight = new THREE.Vector3(growth.x, 0, growth.z);
954
+ if (sprayRight.lengthSq() < 1e-6) sprayRight.copy(branchRight);
955
+ sprayRight.projectOnPlane(sprayNormal);
956
+ if (sprayRight.lengthSq() < 1e-6) sprayRight.copy(branchRight).projectOnPlane(sprayNormal);
957
+ sprayRight.normalize();
958
+ sprayForward = new THREE.Vector3().crossVectors(sprayRight, sprayNormal).normalize();
959
+ }
744
960
  for (let i = 0; i < tuftCards; i += 1) {
745
961
  let cardSizeScale = 1;
746
962
  let cardShape = [1, 1];
@@ -754,17 +970,23 @@ export function createTreeFoliageGeometry({
754
970
  const angle = tuftRng() * Math.PI * 2;
755
971
  cardCenter.copy(attachment.position)
756
972
  .addScaledVector(
757
- branchRight,
973
+ sprayRight,
758
974
  Math.cos(angle) * radialT * spraySpread * foliageSprayScale,
759
975
  )
760
976
  .addScaledVector(
761
- branchForward,
977
+ sprayForward,
762
978
  Math.sin(angle) * radialT * spraySpread * foliageSprayScale * 0.62,
763
979
  )
764
- .addScaledVector(
765
- growth,
766
- layerT * sprayThickness * foliageSprayScale + tuftRadius * 0.16,
767
- );
980
+ .addScaledVector(sprayNormal, layerT * sprayThickness * foliageSprayScale)
981
+ // Placement along the twig stays on the BRANCH axis whatever the
982
+ // disc is aligned to: the tuft sits at the end of its own shoot.
983
+ .addScaledVector(growth, tuftRadius * 0.16);
984
+ if (tuftPlateDroop > 0) {
985
+ // Quadratic in the radial coordinate, so the plate is level over
986
+ // its inner half and only the rim hangs.
987
+ cardCenter.y -= tuftPlateDroop * radialT * radialT
988
+ * spraySpread * foliageSprayScale;
989
+ }
768
990
  cardSizeScale = (0.88 + tuftRng() * 0.2)
769
991
  * (0.88 + foliageSprayScale * 0.12);
770
992
  if (individualBroadleafCards && organType === 'broad-leaf') {
@@ -25,6 +25,74 @@ export const TREE_SURFACE_PROFILES = Object.freeze({
25
25
  textureVersion: TREE_SURFACE_TEXTURE_VERSION,
26
26
  uvRepeat: Object.freeze([1.5, 1]),
27
27
  }),
28
+ // Smooth-barked broadleaf. Beech has no fissures at all: the read is soft
29
+ // mottled patches and faint vertical shading on a pale grey-green ground,
30
+ // which is why it needs its own generator rather than a re-tinted oak.
31
+ // Softer light bands than the fissured profiles, because there is no deep
32
+ // relief for a hard terminator to sit in.
33
+ 'beech-smooth-v1': Object.freeze({
34
+ id: 'beech-smooth-v1',
35
+ label: 'Stylized smooth beech',
36
+ shader: Object.freeze({
37
+ bandSoftness: 0.115,
38
+ shadowFloor: 0.54,
39
+ skyFillStrength: 0.07,
40
+ }),
41
+ textureVersion: TREE_SURFACE_TEXTURE_VERSION,
42
+ uvRepeat: Object.freeze([1.25, 1]),
43
+ }),
44
+ // Papery pale bark with dark horizontal lenticel dashes and the occasional
45
+ // shed-limb patch. The lenticels are the entire silhouette read, so they
46
+ // stay high-contrast and the ground stays near-white.
47
+ 'birch-papery-v1': Object.freeze({
48
+ id: 'birch-papery-v1',
49
+ label: 'Stylized papery birch',
50
+ shader: Object.freeze({
51
+ bandSoftness: 0.13,
52
+ shadowFloor: 0.6,
53
+ skyFillStrength: 0.085,
54
+ }),
55
+ textureVersion: TREE_SURFACE_TEXTURE_VERSION,
56
+ uvRepeat: Object.freeze([1.25, 1]),
57
+ }),
58
+ // Acer palmatum. The read is a fine-grained, cool grey-brown cylinder
59
+ // carrying close vertical striae and scattered pale lenticels, with only a
60
+ // shallow fissure where an old multi-stem trunk has started to split. It is
61
+ // neither smooth like beech nor plated like oak, and a re-tinted oak reads
62
+ // far too coarse on a 4 m tree standing 3 m from the lens.
63
+ 'maple-striated-v1': Object.freeze({
64
+ id: 'maple-striated-v1',
65
+ label: 'Stylized striated Japanese maple',
66
+ shader: Object.freeze({
67
+ bandSoftness: 0.1,
68
+ shadowFloor: 0.5,
69
+ skyFillStrength: 0.065,
70
+ }),
71
+ textureVersion: TREE_SURFACE_TEXTURE_VERSION,
72
+ uvRepeat: Object.freeze([1.35, 1]),
73
+ }),
74
+ // Pinus thunbergii. Black pine bark is the opposite of a ridge-and-furrow
75
+ // profile: thick irregular PLATES, blocky in both axes, separated by deep
76
+ // near-black fissures, with warm ochre showing in the splits. The plate
77
+ // boundaries are cellular, not sinusoidal, which is why this needs its own
78
+ // generator rather than a darker oak — a warped sine gives long vertical
79
+ // ridges and reads as elm at any distance the garden camera works at.
80
+ 'pine-plated-v1': Object.freeze({
81
+ id: 'pine-plated-v1',
82
+ label: 'Stylized plated Japanese black pine',
83
+ shader: Object.freeze({
84
+ bandSoftness: 0.065,
85
+ // Higher than a dark bark would suggest, on purpose. A cloud-pruned
86
+ // pine's bare limbs cross its own crown in full view, and they are
87
+ // thin: at the default floor a dark plated bark on a 3 cm twig turned
88
+ // shadow-side goes to near-black and reads as a crack scribbled across
89
+ // the foliage rather than as wood.
90
+ shadowFloor: 0.6,
91
+ skyFillStrength: 0.1,
92
+ }),
93
+ textureVersion: TREE_SURFACE_TEXTURE_VERSION,
94
+ uvRepeat: Object.freeze([1.6, 1.15]),
95
+ }),
28
96
  'bamboo-waxy-v1': Object.freeze({
29
97
  id: 'bamboo-waxy-v1',
30
98
  label: 'Stylized waxy bamboo culm',
@@ -64,6 +132,45 @@ export const TREE_SURFACE_PROFILE_DEFAULTS = Object.freeze({
64
132
  call_me_sensei: 'call-me-sensei-bark-v1',
65
133
  });
66
134
 
135
+ /**
136
+ * Short bark names an author actually reaches for, mapped onto profile ids.
137
+ *
138
+ * Tree Lab has always spoken in species words ('beech', 'birch', 'oak'), and
139
+ * recipes get authored in the same vocabulary. Without this table those names
140
+ * resolve to nothing and the trunk silently ships bare, so the alias set is
141
+ * part of the contract rather than a convenience.
142
+ */
143
+ export const TREE_SURFACE_PROFILE_ALIASES = Object.freeze({
144
+ beech: 'beech-smooth-v1',
145
+ birch: 'birch-papery-v1',
146
+ oak: 'oak-fissured-v1',
147
+ maple: 'maple-striated-v1',
148
+ acer: 'maple-striated-v1',
149
+ 'japanese-maple': 'maple-striated-v1',
150
+ pine: 'pine-plated-v1',
151
+ 'black-pine': 'pine-plated-v1',
152
+ 'japanese-black-pine': 'pine-plated-v1',
153
+ bamboo: 'bamboo-waxy-v1',
154
+ yucca: 'yucca-fibrous-v1',
155
+ saguaro: 'saguaro-waxy-v1',
156
+ classic: 'call-me-sensei-bark-v1',
157
+ call_me_sensei: 'call-me-sensei-bark-v1',
158
+ 'call-me-sensei': 'call-me-sensei-bark-v1',
159
+ });
160
+
161
+ /**
162
+ * Resolve a bark reference — a profile id or a short alias — to a profile id.
163
+ * Returns null for anything unregistered so callers can fall back to their own
164
+ * default instead of throwing on a caller-owned asset string.
165
+ */
166
+ export function resolveTreeSurfaceProfileId(reference) {
167
+ if (typeof reference !== 'string') return null;
168
+ const key = reference.trim();
169
+ if (!key) return null;
170
+ if (TREE_SURFACE_PROFILES[key]) return key;
171
+ return TREE_SURFACE_PROFILE_ALIASES[key.toLowerCase()] ?? null;
172
+ }
173
+
67
174
  export function getTreeSurfaceProfileOptions() {
68
175
  return Object.values(TREE_SURFACE_PROFILES).map(({ id, label }) => ({
69
176
  id,
@@ -122,6 +229,45 @@ function quantize(value, steps) {
122
229
  / Math.max(steps - 1, 1);
123
230
  }
124
231
 
232
+ /**
233
+ * Tiling cellular (Worley) noise on a jittered lattice.
234
+ *
235
+ * Returns the nearest and second-nearest feature distances plus a stable
236
+ * per-cell id. Plated bark — pine, and the plated end of the oak family — is
237
+ * a *cellular* pattern: irregular blocks bounded on all sides, each block
238
+ * weathered to its own tone. Warped sine bands cannot express that; they only
239
+ * ever produce long ridges. Periodic in both axes so the trunk tile still
240
+ * wraps, which the sine-based generators above already rely on.
241
+ */
242
+ function periodicCellular(x, y, periodX, periodY, seed) {
243
+ const xi = Math.floor(x);
244
+ const yi = Math.floor(y);
245
+ let best = Infinity;
246
+ let second = Infinity;
247
+ let bestId = 0;
248
+ for (let dy = -1; dy <= 1; dy += 1) {
249
+ for (let dx = -1; dx <= 1; dx += 1) {
250
+ const cx = xi + dx;
251
+ const cy = yi + dy;
252
+ const wx = ((cx % periodX) + periodX) % periodX;
253
+ const wy = ((cy % periodY) + periodY) % periodY;
254
+ const jx = hash2d(wx, wy, seed);
255
+ const jy = hash2d(wx, wy, seed + 911);
256
+ const fx = cx + 0.15 + jx * 0.7;
257
+ const fy = cy + 0.15 + jy * 0.7;
258
+ const distance = Math.hypot(x - fx, y - fy);
259
+ if (distance < best) {
260
+ second = best;
261
+ best = distance;
262
+ bestId = hash2d(wx, wy, seed + 1733);
263
+ } else if (distance < second) {
264
+ second = distance;
265
+ }
266
+ }
267
+ }
268
+ return { edge: second - best, first: best, id: bestId };
269
+ }
270
+
125
271
  function oakFissuredPixel(u, v, seed) {
126
272
  // Large vertical plates and a small number of cross-breaks carry the
127
273
  // silhouette-scale bark read. Fine photographic pores are intentionally
@@ -155,6 +301,184 @@ function oakFissuredPixel(u, v, seed) {
155
301
  ];
156
302
  }
157
303
 
304
+ function beechSmoothPixel(u, v, seed) {
305
+ // Fagus bark is smooth: no fissures, no plates, no cross-breaks. What reads
306
+ // at silhouette scale is soft overlapping mottle patches on a pale grey
307
+ // ground, a faint vertical grain from the trunk's own taper, and sparse
308
+ // darker healed scars. Quantized like the other profiles so it stays inside
309
+ // ToonLab's broad stylized light bands.
310
+ const broad = periodicValueNoise(u * 3, v * 4, 3, 4, seed + 229);
311
+ const mottle = periodicValueNoise(u * 6, v * 5, 6, 5, seed + 233);
312
+ const fine = periodicValueNoise(u * 13, v * 9, 13, 9, seed + 239);
313
+
314
+ // Faint vertical shading bands — a smooth cylinder, not a carved one.
315
+ const grainWarp = (broad - 0.5) * 0.22;
316
+ const grain = (Math.sin((u * 4 + grainWarp) * Math.PI * 2) * 0.5 + 0.5) * 0.06;
317
+
318
+ // Sparse healed scars: small, soft, and far apart. They are the only dark
319
+ // marks on a smooth trunk, so they are deliberately rare.
320
+ const scarNoise = periodicValueNoise(u * 9, v * 11, 9, 11, seed + 241);
321
+ const scar = THREE.MathUtils.smoothstep(scarNoise, 0.87, 0.98) * 0.16;
322
+
323
+ const tone = quantize(0.46 + broad * 0.28 + mottle * 0.18 + fine * 0.06, 5);
324
+ const shade = THREE.MathUtils.clamp(0.74 + tone * 0.34 + grain - scar, 0.62, 1.04);
325
+ // Pale grey-green ground: green and blue close together, red held under both.
326
+ const coolLift = quantize(periodicValueNoise(u * 2, v * 3, 2, 3, seed + 251), 4);
327
+ return [
328
+ (0.62 + coolLift * 0.035) * shade,
329
+ (0.63 + coolLift * 0.03) * shade,
330
+ (0.58 + coolLift * 0.04) * shade,
331
+ ];
332
+ }
333
+
334
+ function birchPaperyPixel(u, v, seed) {
335
+ // Betula reads as a near-white papery ground carrying dark horizontal
336
+ // lenticel dashes. The dashes are the whole identity, so they keep hard
337
+ // edges and high contrast while the ground stays almost flat.
338
+ const broad = periodicValueNoise(u * 4, v * 3, 4, 3, seed + 257);
339
+ const paper = periodicValueNoise(u * 11, v * 7, 11, 7, seed + 263);
340
+
341
+ // Lenticels: short horizontal strokes, banded in v, broken up in u so they
342
+ // do not run all the way around the trunk.
343
+ const bandNoise = periodicValueNoise(u * 5, v * 13, 5, 13, seed + 269);
344
+ const bandPhase = Math.abs(
345
+ ((v * 14 + (bandNoise - 0.5) * 0.5) % 1 + 1) % 1 - 0.5,
346
+ ) * 2;
347
+ const bandMask = 1 - THREE.MathUtils.smoothstep(bandPhase, 0.05, 0.16);
348
+ const breakNoise = periodicValueNoise(u * 17, v * 13, 17, 13, seed + 271);
349
+ const lenticel = bandMask * THREE.MathUtils.smoothstep(breakNoise, 0.36, 0.6);
350
+
351
+ // Occasional large dark patch where a limb was shed.
352
+ const shedNoise = periodicValueNoise(u * 3, v * 5, 3, 5, seed + 277);
353
+ const shed = THREE.MathUtils.smoothstep(shedNoise, 0.84, 0.99);
354
+
355
+ const tone = quantize(0.78 + broad * 0.12 + paper * 0.1, 4);
356
+ const shade = THREE.MathUtils.clamp(
357
+ 0.86 + tone * 0.22 - lenticel * 0.52 - shed * 0.3,
358
+ 0.3,
359
+ 1.05,
360
+ );
361
+ // Warm-white paper; the dashes darken all three channels together so they
362
+ // read as value, not as a hue shift.
363
+ const warmLift = quantize(periodicValueNoise(u * 2, v * 4, 2, 4, seed + 281), 3);
364
+ return [
365
+ (0.89 + warmLift * 0.03) * shade,
366
+ (0.87 + warmLift * 0.025) * shade,
367
+ (0.81 + warmLift * 0.02) * shade,
368
+ ];
369
+ }
370
+
371
+ function mapleStriatedPixel(u, v, seed) {
372
+ // Acer palmatum. Three things carry the read and nothing else should
373
+ // compete with them: close vertical striae (fine pale lines, ~2 mm apart on
374
+ // the real thing), a soft grey-brown mottle underneath, and scattered pale
375
+ // lenticels. A single shallow fissure family is allowed for the old
376
+ // multi-stem trunks the garden uses, but at a fraction of oak's depth —
377
+ // maple is a fine-grained bark and reading it as fissured is the classic
378
+ // way a stylized maple ends up looking like a small oak.
379
+ const broad = periodicValueNoise(u * 3, v * 4, 3, 4, seed + 307);
380
+ const mottle = periodicValueNoise(u * 7, v * 6, 7, 6, seed + 311);
381
+
382
+ // Close vertical striae. Two incommensurate frequencies keep them from
383
+ // reading as a comb; the warp keeps them from reading as a ruled grid.
384
+ const striaWarp = (broad - 0.5) * 0.28 + Math.sin(v * Math.PI * 6) * 0.02;
385
+ const striaA = Math.sin((u * 26 + striaWarp) * Math.PI * 2) * 0.5 + 0.5;
386
+ const striaB = Math.sin((u * 41 - striaWarp * 0.7) * Math.PI * 2) * 0.5 + 0.5;
387
+ const stria = striaA * 0.68 + striaB * 0.32;
388
+
389
+ // Shallow age fissures: sparse, narrow, and much weaker than oak's.
390
+ const fissureWarp = (mottle - 0.5) * 0.4;
391
+ const fissureCoordinate = u * 7 + fissureWarp;
392
+ const fissurePhase = Math.abs(
393
+ (fissureCoordinate - Math.floor(fissureCoordinate)) - 0.5,
394
+ ) * 2;
395
+ const fissure = (1 - THREE.MathUtils.smoothstep(fissurePhase, 0.03, 0.1))
396
+ * THREE.MathUtils.smoothstep(broad, 0.52, 0.86);
397
+
398
+ // Lenticels: small pale flecks, brighter than the ground, sparse.
399
+ const lenticelNoise = periodicValueNoise(u * 15, v * 19, 15, 19, seed + 313);
400
+ const lenticel = THREE.MathUtils.smoothstep(lenticelNoise, 0.9, 0.99) * 0.2;
401
+
402
+ const tone = quantize(0.3 + broad * 0.24 + mottle * 0.18 + stria * 0.2, 5);
403
+ const shade = THREE.MathUtils.clamp(
404
+ 0.6 + tone * 0.42 + stria * 0.14 + lenticel - fissure * 0.26,
405
+ 0.42,
406
+ 1.02,
407
+ );
408
+ // Cool grey-brown, and genuinely dark: an Acer palmatum trunk is a middle
409
+ // value, not a pale one. A washed-out ground was the first version's real
410
+ // failure — against the deep-green field of §2 a pale trunk pulls the eye
411
+ // to the wood instead of to the autumn canopy it is supposed to support.
412
+ // Red only a little above green, blue held back but not starved, which is
413
+ // what separates maple from both the warm oak family and the near-neutral
414
+ // beech.
415
+ const warmLift = quantize(periodicValueNoise(u * 2, v * 3, 2, 3, seed + 317), 4);
416
+ return [
417
+ (0.52 + warmLift * 0.05) * shade,
418
+ (0.46 + warmLift * 0.035) * shade,
419
+ (0.42 + warmLift * 0.035) * shade,
420
+ ];
421
+ }
422
+
423
+ function pinePlatedPixel(u, v, seed) {
424
+ // Pinus thunbergii. Thick irregular plates, blocky in BOTH axes, separated
425
+ // by deep near-black fissures with warm ochre showing in the splits. The
426
+ // plates are cellular, so they come from periodicCellular rather than from
427
+ // a warped band; each cell is weathered to its own tone, which is the whole
428
+ // close-camera read. A second, finer cellular pass breaks the large plates
429
+ // into the scaly sub-plates the species is named for.
430
+ const broad = periodicValueNoise(u * 4, v * 5, 4, 5, seed + 331);
431
+ const warpX = (broad - 0.5) * 0.55;
432
+ const warpY = (periodicValueNoise(u * 5, v * 4, 5, 4, seed + 337) - 0.5) * 0.45;
433
+
434
+ // Large plates. The lattice is sampled at 7 x 6 over a tile that is itself
435
+ // 1:2, so a cell spans roughly twice the trunk height it spans in
436
+ // circumference and the plates come out clearly ELONGATED along the trunk.
437
+ // Equal periods gave near-round cells, which read as dried mud rather than
438
+ // as pine, and that was the first version's real failure.
439
+ const plate = periodicCellular(u * 7 + warpX, v * 6 + warpY, 7, 6, seed + 347);
440
+ // Scales inside each plate.
441
+ const scale = periodicCellular(u * 15 + warpX * 0.5, v * 13 + warpY * 0.5, 15, 13, seed + 353);
442
+
443
+ // Fissure = the cell boundary. Deep and hard-edged for the plate network,
444
+ // shallow and soft for the scale network. Narrower than the first version:
445
+ // a black pine's fissures are deep, not wide, and widening them turns the
446
+ // plate network into a grout grid.
447
+ const plateFissure = 1 - THREE.MathUtils.smoothstep(plate.edge, 0.02, 0.1);
448
+ const scaleFissure = (1 - THREE.MathUtils.smoothstep(scale.edge, 0.015, 0.075)) * 0.36;
449
+ const fissure = Math.min(1, plateFissure + scaleFissure * (1 - plateFissure));
450
+
451
+ // Per-plate weathering. Quantized hard so plates group into ToonLab's broad
452
+ // value bands instead of dissolving into per-pixel grain.
453
+ const plateTone = quantize(plate.id, 4);
454
+ const scaleTone = quantize(scale.id, 3);
455
+ const grain = periodicValueNoise(u * 21, v * 27, 21, 27, seed + 359);
456
+
457
+ // The tile is a MULTIPLIER, not a final colour: the woody material takes
458
+ // `map x trunk.color` and then tone-maps it. Authoring black pine at the
459
+ // absolute value real black pine bark reads at put the tile around byte 76
460
+ // — half the value of every other profile in this file — and the trunk came
461
+ // out a featureless black cylinder in which none of the plate work was
462
+ // visible at 85 mm. The plate-to-fissure CONTRAST is what carries the
463
+ // species; the overall level has to sit with its neighbours.
464
+ const shade = THREE.MathUtils.clamp(
465
+ 0.72 + plateTone * 0.34 + scaleTone * 0.1 + grain * 0.07 - fissure * 0.46,
466
+ 0.32,
467
+ 1.12,
468
+ );
469
+ // Dark grey-charcoal plate faces; the fissures let a little warm brown
470
+ // through, so red is lifted exactly where the value drops. Without any
471
+ // warmth the trunk reads as wet slate — but the first version overdid it
472
+ // and the fissure network glowed orange like fired brick, so the lift is
473
+ // now a fraction of what it was and stays under the value drop.
474
+ const ochre = fissure * (0.35 + plateTone * 0.4);
475
+ return [
476
+ (0.45 + ochre * 0.17) * shade,
477
+ (0.44 + ochre * 0.08) * shade,
478
+ (0.43 + ochre * 0.02) * shade,
479
+ ];
480
+ }
481
+
158
482
  function bambooWaxyPixel(u, v, seed) {
159
483
  // Moso culms read as smooth gray-green cylinders with restrained vertical
160
484
  // striation and irregular wax bloom. Nodes remain semantic ring geometry;
@@ -244,6 +568,10 @@ function surfacePixel(profileId, u, v, seed) {
244
568
  if (profileId === 'call-me-sensei-bark-v1' || profileId === 'oak-fissured-v1') {
245
569
  return oakFissuredPixel(u, v, seed);
246
570
  }
571
+ if (profileId === 'beech-smooth-v1') return beechSmoothPixel(u, v, seed);
572
+ if (profileId === 'birch-papery-v1') return birchPaperyPixel(u, v, seed);
573
+ if (profileId === 'maple-striated-v1') return mapleStriatedPixel(u, v, seed);
574
+ if (profileId === 'pine-plated-v1') return pinePlatedPixel(u, v, seed);
247
575
  if (profileId === 'bamboo-waxy-v1') return bambooWaxyPixel(u, v, seed);
248
576
  if (profileId === 'yucca-fibrous-v1') return yuccaFibrousPixel(u, v, seed);
249
577
  if (profileId === 'saguaro-waxy-v1') return saguaroWaxyPixel(u, v, seed);
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.4.19';
3
+ export const TOONLAB_VERSION = '0.4.21';