@call-me-sensei/toonlab 0.4.21 → 0.4.22

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 (31) hide show
  1. package/NPM-LIBRARY.md +51 -2
  2. package/README.md +4 -3
  3. package/agents/PROMPTS.md +1 -1
  4. package/database/apply-one-catalog-seed.mjs +63 -0
  5. package/database/seeds/catalog/0005_2026-08-c8-first12.sql +1101 -0
  6. package/database/seeds/catalog/0006_2026-09-c8-first100.sql +14245 -0
  7. package/database/seeds/catalog/0007_2026-09-c8-first100-primary-model.sql +14845 -0
  8. package/package.json +4 -2
  9. package/src/catalog/officialCatalog.js +1 -0
  10. package/src/catalog/officialCatalogAssetRuntime.js +98 -1
  11. package/src/catalog/officialCatalogLod.js +160 -16
  12. package/src/catalog/officialCatalogPlacement.js +52 -12
  13. package/src/catalog/officialCatalogProvider.js +79 -8
  14. package/src/catalog/officialCatalogRockPackage.js +172 -0
  15. package/src/rock-shader/rockMaterial.js +205 -28
  16. package/src/rock-shader/rockShaderRuntime.js +17 -1
  17. package/src/rock-shader/rockShaderSettings.js +16 -0
  18. package/src/rockgen/lod/index.js +1 -0
  19. package/src/rockgen/lod/rockDenseFieldPolicy.js +143 -0
  20. package/src/rockgen/rockDocument.js +604 -26
  21. package/src/version.js +1 -1
  22. package/types/catalog/officialCatalog.d.ts +1 -0
  23. package/types/catalog/officialCatalogPlacement.d.ts +4 -0
  24. package/types/catalog/officialCatalogRockPackage.d.ts +68 -0
  25. package/types/index.d.ts +24 -0
  26. package/types/rock-shader/rockMaterial.d.ts +3 -0
  27. package/types/rock-shader/rockShaderSettings.d.ts +2 -0
  28. package/types/rockgen/lod/index.d.ts +1 -0
  29. package/types/rockgen/lod/rockDenseFieldPolicy.d.ts +181 -0
  30. package/types/rockgen/rockDocument.d.ts +579 -68
  31. package/types/version.d.ts +1 -1
@@ -10,8 +10,21 @@ export const OFFICIAL_CATALOG_PROVIDER_TRANSPORTS = Object.freeze([
10
10
  'public-rock',
11
11
  ]);
12
12
 
13
- const ROCK_ID = /^rock-\d{4}$/u;
13
+ const LEGACY_ROCK_ID = /^rock-\d{4}$/u;
14
+ const NATURE_REFERENCE_ROCK_ID = /^rock-c8-[a-z0-9]+(?:-[a-z0-9]+)*$/u;
14
15
  const ROCK_RECIPE_KIND = 'toonlab/rock-recipe';
16
+ const ROCK_GALLERY_RECIPE_SCHEMA = 'toonlab/rock-gallery-recipe';
17
+
18
+ function isRockId(value) {
19
+ return LEGACY_ROCK_ID.test(value) || NATURE_REFERENCE_ROCK_ID.test(value);
20
+ }
21
+
22
+ function isRockRecipe(value) {
23
+ return isRecord(value) && (
24
+ value.kind === ROCK_RECIPE_KIND
25
+ || value.schema === ROCK_GALLERY_RECIPE_SCHEMA
26
+ );
27
+ }
15
28
 
16
29
  function isRecord(value) {
17
30
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
@@ -61,6 +74,15 @@ function artifactName(artifact) {
61
74
  return String(artifact?.name ?? artifact?.relative_path ?? artifact?.relativePath ?? '');
62
75
  }
63
76
 
77
+ function artifactKey(value) {
78
+ return String(value ?? '').trim().replace(/^\.\//u, '').toLowerCase();
79
+ }
80
+
81
+ function artifactByName(artifacts, ...names) {
82
+ const wanted = new Set(names.map(artifactKey));
83
+ return artifacts.find((artifact) => wanted.has(artifactKey(artifactName(artifact)))) ?? null;
84
+ }
85
+
64
86
  function isModelArtifact(artifact) {
65
87
  const name = artifactName(artifact).toLowerCase();
66
88
  const contentType = String(artifact?.contentType ?? artifact?.content_type ?? '').toLowerCase();
@@ -107,7 +129,7 @@ export function normalizeOfficialCatalogAsset(rawAsset, {
107
129
  const source = String(rawAsset.source ?? metadata.source ?? expectedSource ?? '').trim();
108
130
  const kind = String(rawAsset.kind ?? 'model').trim();
109
131
  const catalog = String(metadata.catalog ?? rawAsset.catalog ?? (
110
- recipe?.kind === ROCK_RECIPE_KIND ? 'rocks' : ''
132
+ isRockRecipe(recipe) ? 'rocks' : ''
111
133
  )).trim().toLowerCase();
112
134
  const revision = Number(rawAsset.revision ?? metadata.revision);
113
135
  const recipeHash = String(rawAsset.recipeHash ?? metadata.recipeHash ?? '').trim();
@@ -115,7 +137,7 @@ export function normalizeOfficialCatalogAsset(rawAsset, {
115
137
  ...(Array.isArray(rawAsset.artifacts) ? rawAsset.artifacts : []),
116
138
  ...(Array.isArray(rawAsset.files) ? rawAsset.files : []),
117
139
  ].filter(isRecord);
118
- const modelArtifact = artifacts.find((entry) => artifactName(entry) === 'rock.glb')
140
+ const modelArtifact = artifactByName(artifacts, 'rock.glb')
119
141
  ?? artifacts.find(isModelArtifact)
120
142
  ?? null;
121
143
  const modelReference = rawAsset.download_url
@@ -137,9 +159,11 @@ export function normalizeOfficialCatalogAsset(rawAsset, {
137
159
  if (kind !== 'model') errors.push(`kind must be model, received ${kind || '(empty)'}`);
138
160
  if (!modelReference) errors.push('a model download URL is required');
139
161
  if (source === 'toonlab-rock') {
140
- if (!ROCK_ID.test(id)) errors.push('ToonLab rock id must match rock-0001');
162
+ if (!isRockId(id)) errors.push('ToonLab rock id must match rock-0001 or rock-c8-geology-subtype');
141
163
  if (catalog !== 'rocks') errors.push(`catalog must be rocks, received ${catalog || '(empty)'}`);
142
- if (recipe?.kind !== ROCK_RECIPE_KIND) errors.push(`recipe kind must be ${ROCK_RECIPE_KIND}`);
164
+ if (!isRockRecipe(recipe)) {
165
+ errors.push(`recipe must use ${ROCK_RECIPE_KIND} or ${ROCK_GALLERY_RECIPE_SCHEMA}`);
166
+ }
143
167
  if (!Number.isInteger(revision) || revision < 1) errors.push('positive integer revision is required');
144
168
  if (!recipeHash) errors.push('recipeHash is required');
145
169
  }
@@ -157,15 +181,61 @@ export function normalizeOfficialCatalogAsset(rawAsset, {
157
181
  sha256: String(artifact.sha256 ?? ''),
158
182
  url: resolveOfficialCatalogUrl(artifactUrl(artifact), baseUrl),
159
183
  }));
184
+ const artifactMap = Object.freeze(Object.fromEntries(normalizedArtifacts
185
+ .filter((artifact) => artifact.name && artifact.url)
186
+ .map((artifact) => [artifact.name, artifact])));
187
+ const artifactUrlByName = (...names) => {
188
+ const artifact = artifactByName(normalizedArtifacts, ...names);
189
+ return artifact?.url ?? null;
190
+ };
160
191
  const release = String(rawAsset.release ?? metadata.release ?? releaseFromUrl(modelUrl) ?? '').trim() || null;
161
192
  const domain = catalog === 'rocks' ? 'natural.rock' : String(metadata.domain ?? '').trim() || null;
162
- const lod = isRecord(recipe?.lod) ? frozenCopy(recipe.lod) : null;
193
+ const recipeLod = isRecord(recipe?.lod) ? recipe.lod : null;
194
+ const metadataLod = isRecord(rawAsset.lod ?? metadata.lod) ? rawAsset.lod ?? metadata.lod : null;
195
+ const lodLevels = Array.isArray(recipe?.output?.lods)
196
+ ? recipe.output.lods.map(String)
197
+ : Array.isArray(metadataLod?.levels)
198
+ ? metadataLod.levels.map(String)
199
+ : [];
200
+ const lodUrls = Object.freeze(Array.from({ length: 5 }, (_, level) => (
201
+ artifactUrlByName(`lod${level}.glb`, `lod/LOD${level}.glb`)
202
+ )));
203
+ const hasLodPackage = lodUrls.some(Boolean) || lodLevels.length > 0;
204
+ const lod = recipeLod || metadataLod || hasLodPackage
205
+ ? Object.freeze({
206
+ ...frozenCopy(recipeLod ?? metadataLod ?? {}),
207
+ cullBelowPixels: Number(
208
+ recipe?.output?.cullBelowPixels
209
+ ?? metadataLod?.cullBelowPixels
210
+ ?? 0,
211
+ ) || 0,
212
+ levels: Object.freeze(lodLevels),
213
+ urls: lodUrls,
214
+ })
215
+ : null;
216
+ const packageFiles = Object.freeze({
217
+ collisionModelUrl: artifactUrlByName('collision.glb'),
218
+ controlModelUrl: artifactUrlByName('control.glb'),
219
+ lodModelUrls: lodUrls,
220
+ manifestUrl: artifactUrlByName('manifest.json'),
221
+ materialConfigUrl: artifactUrlByName('material-config.json'),
222
+ natureProvenanceUrl: artifactUrlByName('nature-provenance.json', 'nature/provenance.json'),
223
+ natureReferenceUrl: artifactUrlByName('nature-reference.jpg', 'nature/reference.jpg'),
224
+ realisticModelUrl: modelUrl,
225
+ realisticPreviewUrl: artifactUrlByName('realistic-preview.png'),
226
+ recipeUrl: artifactUrlByName('recipe.json'),
227
+ retainedHighModelUrl: artifactUrlByName('retained-high.glb', 'high.glb'),
228
+ stylizedModelUrl: artifactUrlByName('rock-stylized.glb'),
229
+ stylizedPreviewUrl: artifactUrlByName('stylized-preview.png'),
230
+ });
231
+ const collisionMetadata = rawAsset.collision ?? metadata.collision;
163
232
 
164
233
  return Object.freeze({
165
234
  artifacts: Object.freeze(normalizedArtifacts),
235
+ artifactMap,
166
236
  catalog,
167
- collision: isRecord(rawAsset.collision ?? metadata.collision)
168
- ? frozenCopy(rawAsset.collision ?? metadata.collision)
237
+ collision: isRecord(collisionMetadata) && typeof collisionMetadata.kind === 'string'
238
+ ? frozenCopy(collisionMetadata)
169
239
  : null,
170
240
  domain,
171
241
  id,
@@ -175,6 +245,7 @@ export function normalizeOfficialCatalogAsset(rawAsset, {
175
245
  lod,
176
246
  metadata: frozenCopy(metadata),
177
247
  modelUrl,
248
+ packageFiles,
178
249
  provider,
179
250
  provenance: Object.freeze({
180
251
  modelSha256: String(modelArtifact?.sha256 ?? metadata.modelSha256 ?? ''),
@@ -0,0 +1,172 @@
1
+ import {
2
+ LinearFilter,
3
+ LinearMipmapLinearFilter,
4
+ NoColorSpace,
5
+ RepeatWrapping,
6
+ SRGBColorSpace,
7
+ TextureLoader,
8
+ } from 'three';
9
+
10
+ export const OFFICIAL_ROCK_GALLERY_RECIPE_SCHEMA = 'toonlab/rock-gallery-recipe';
11
+ export const OFFICIAL_ROCK_MATERIAL_SCHEMA = 'toonlab.pro-rock-material';
12
+
13
+ function isRecord(value) {
14
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
15
+ }
16
+
17
+ function frozenCopy(value) {
18
+ if (Array.isArray(value)) return Object.freeze(value.map(frozenCopy));
19
+ if (!isRecord(value)) return value;
20
+ return Object.freeze(Object.fromEntries(
21
+ Object.entries(value).map(([key, entry]) => [key, frozenCopy(entry)]),
22
+ ));
23
+ }
24
+
25
+ function artifactName(value) {
26
+ return String(value ?? '').trim().replace(/^\.\//u, '').toLowerCase();
27
+ }
28
+
29
+ export function findOfficialCatalogArtifact(asset, ...names) {
30
+ const wanted = new Set(names.map(artifactName));
31
+ return asset?.artifacts?.find((artifact) => wanted.has(artifactName(artifact.name))) ?? null;
32
+ }
33
+
34
+ export function getOfficialCatalogArtifactUrl(asset, ...names) {
35
+ return findOfficialCatalogArtifact(asset, ...names)?.url ?? null;
36
+ }
37
+
38
+ async function fetchJson(fetchImpl, url, label) {
39
+ if (!url) return null;
40
+ if (typeof fetchImpl !== 'function') throw new TypeError(`${label} requires fetch().`);
41
+ const response = await fetchImpl(url, { headers: { accept: 'application/json' } });
42
+ if (!response?.ok) {
43
+ throw new Error(`${label} returned HTTP ${response?.status ?? 'unknown'}: ${url}`);
44
+ }
45
+ try {
46
+ return await response.json();
47
+ } catch (cause) {
48
+ throw new Error(`${label} returned invalid JSON: ${url}`, { cause });
49
+ }
50
+ }
51
+
52
+ function validateRecipe(asset, recipe) {
53
+ if (!recipe) return null;
54
+ if (recipe.schema !== OFFICIAL_ROCK_GALLERY_RECIPE_SCHEMA || recipe.version !== 1) {
55
+ throw new Error(`${asset.id} has an unsupported editable rock recipe.`);
56
+ }
57
+ if (recipe.generator?.unit !== 'metre') {
58
+ throw new Error(`${asset.id} editable recipe must use metres.`);
59
+ }
60
+ return frozenCopy(recipe);
61
+ }
62
+
63
+ function validateMaterialConfig(asset, materialConfig) {
64
+ if (!materialConfig) return null;
65
+ if (materialConfig.schema !== OFFICIAL_ROCK_MATERIAL_SCHEMA || materialConfig.version !== 2) {
66
+ throw new Error(`${asset.id} has an unsupported Call Me Sensei material package.`);
67
+ }
68
+ if (materialConfig.assetId !== asset.id || materialConfig.shader?.preset !== 'call_me_sensei') {
69
+ throw new Error(`${asset.id} material package failed identity or preset validation.`);
70
+ }
71
+ if (!isRecord(materialConfig.textures)) {
72
+ throw new Error(`${asset.id} material package has no texture bindings.`);
73
+ }
74
+ return frozenCopy(materialConfig);
75
+ }
76
+
77
+ /**
78
+ * Load the immutable documents associated with a nature-reference rock.
79
+ * Legacy 480 rocks remain valid and simply return null for documents they do
80
+ * not publish.
81
+ */
82
+ export async function loadOfficialCatalogRockPackage(asset, {
83
+ fetchImpl = globalThis.fetch?.bind(globalThis),
84
+ includeManifest = false,
85
+ includeNatureProvenance = false,
86
+ } = {}) {
87
+ if (!asset || asset.domain !== 'natural.rock') {
88
+ throw new TypeError('Official rock package loading requires a normalized natural.rock asset.');
89
+ }
90
+ const files = asset.packageFiles ?? {};
91
+ const [recipeDocument, materialDocument, manifest, natureProvenance] = await Promise.all([
92
+ files.recipeUrl ? fetchJson(fetchImpl, files.recipeUrl, `${asset.id} recipe`) : asset.recipe,
93
+ fetchJson(fetchImpl, files.materialConfigUrl, `${asset.id} material package`),
94
+ includeManifest ? fetchJson(fetchImpl, files.manifestUrl, `${asset.id} manifest`) : null,
95
+ includeNatureProvenance
96
+ ? fetchJson(fetchImpl, files.natureProvenanceUrl, `${asset.id} nature provenance`)
97
+ : null,
98
+ ]);
99
+ const recipe = recipeDocument?.schema === OFFICIAL_ROCK_GALLERY_RECIPE_SCHEMA
100
+ ? validateRecipe(asset, recipeDocument)
101
+ : frozenCopy(asset.recipe);
102
+ const materialConfig = validateMaterialConfig(asset, materialDocument);
103
+ return Object.freeze({
104
+ asset,
105
+ files,
106
+ manifest: frozenCopy(manifest),
107
+ materialConfig,
108
+ natureProvenance: frozenCopy(natureProvenance),
109
+ recipe,
110
+ });
111
+ }
112
+
113
+ function configureRockTexture(texture, descriptor, role) {
114
+ texture.name = `ToonLab ${role} · ${descriptor.url}`;
115
+ texture.colorSpace = descriptor.srgb === true ? SRGBColorSpace : NoColorSpace;
116
+ texture.flipY = false;
117
+ texture.wrapS = RepeatWrapping;
118
+ texture.wrapT = RepeatWrapping;
119
+ texture.magFilter = LinearFilter;
120
+ texture.minFilter = LinearMipmapLinearFilter;
121
+ texture.generateMipmaps = true;
122
+ texture.userData.toonlabOfficialRockRole = role;
123
+ texture.userData.toonlabOfficialRockUrl = descriptor.url;
124
+ texture.needsUpdate = true;
125
+ return texture;
126
+ }
127
+
128
+ /** Load the exact texture slots consumed by applyRockShader(). */
129
+ export async function loadOfficialCatalogRockShaderInput(rockPackage, {
130
+ textureLoader = new TextureLoader(),
131
+ } = {}) {
132
+ const config = rockPackage?.materialConfig;
133
+ if (!config) return null;
134
+ const textures = {};
135
+ await Promise.all(Object.entries(config.textures).map(async ([role, descriptor]) => {
136
+ if (!descriptor?.url) throw new Error(`${config.assetId}/${role} has no immutable texture URL.`);
137
+ const texture = await textureLoader.loadAsync(descriptor.url);
138
+ textures[role] = configureRockTexture(texture, descriptor, role);
139
+ }));
140
+ return Object.freeze({
141
+ materialConfig: config,
142
+ settings: frozenCopy(config.shader?.settings ?? {}),
143
+ textures: Object.freeze(textures),
144
+ });
145
+ }
146
+
147
+ /**
148
+ * Portable hand-off for Rock Lab or another editor. Geometry derivatives are
149
+ * deliberately separate: edits target control.glb, while retained high, LODs,
150
+ * collision, and baked/runtime outputs can be regenerated or replaced.
151
+ */
152
+ export function createOfficialCatalogRockEditorDescriptor(asset, rockPackage = null) {
153
+ if (!asset || asset.domain !== 'natural.rock') {
154
+ throw new TypeError('Official rock editor descriptors require a normalized natural.rock asset.');
155
+ }
156
+ const recipe = rockPackage?.recipe ?? asset.recipe;
157
+ const files = asset.packageFiles ?? {};
158
+ if (recipe?.schema !== OFFICIAL_ROCK_GALLERY_RECIPE_SCHEMA) return null;
159
+ return Object.freeze({
160
+ assetId: asset.id,
161
+ controlModelUrl: files.controlModelUrl,
162
+ cullBelowPixels: asset.lod?.cullBelowPixels ?? recipe.output?.cullBelowPixels ?? 0,
163
+ editing: frozenCopy(recipe.editing ?? {}),
164
+ lodModelUrls: asset.lod?.urls ?? files.lodModelUrls ?? Object.freeze([]),
165
+ materialConfigUrl: files.materialConfigUrl,
166
+ recipe: frozenCopy(recipe),
167
+ recipeUrl: files.recipeUrl,
168
+ retainedHighModelUrl: files.retainedHighModelUrl,
169
+ collisionModelUrl: files.collisionModelUrl,
170
+ worldUnitMetres: recipe.generator?.unit === 'metre' ? 1 : null,
171
+ });
172
+ }
@@ -131,6 +131,8 @@ export const TOONLAB_ROCK_PROFILE_DEFAULTS = Object.freeze({
131
131
  distanceScale: 1,
132
132
  }),
133
133
  base: Object.freeze({
134
+ mode: 'triplanar',
135
+ upAxis: 'y',
134
136
  scale: 1,
135
137
  tint: Object.freeze([1, 1, 1]),
136
138
  saturation: 1,
@@ -335,6 +337,14 @@ function bool(value, fallback) {
335
337
  return fallback;
336
338
  }
337
339
 
340
+ function projectionMode(value, fallback = 'triplanar') {
341
+ return value === 'directional-bedding' || value === 'triplanar' ? value : fallback;
342
+ }
343
+
344
+ function projectionUpAxis(value, fallback = 'y') {
345
+ return value === 'x' || value === 'y' || value === 'z' ? value : fallback;
346
+ }
347
+
338
348
  function color3(value, fallback) {
339
349
  if (Array.isArray(value)) {
340
350
  return [
@@ -401,6 +411,8 @@ export function normalizeToonLabRockProfile(profile = {}) {
401
411
  ),
402
412
  },
403
413
  base: {
414
+ mode: projectionMode(base.mode, defaults.base.mode),
415
+ upAxis: projectionUpAxis(base.upAxis, defaults.base.upAxis),
404
416
  scale: finite(base.scale, defaults.base.scale),
405
417
  tint: color3(base.tint, defaults.base.tint),
406
418
  saturation: finite(base.saturation, defaults.base.saturation),
@@ -944,6 +956,87 @@ export function toonLabTriplanarColor(map, scale, blend = 1, coordinates = { zSi
944
956
  .add(mapNode.sample(projected.xy).rgb.mul(weights.z));
945
957
  }
946
958
 
959
+ const DIRECTIONAL_BEDDING_AXES = Object.freeze({
960
+ x: Object.freeze({
961
+ lateral: Object.freeze([
962
+ Object.freeze({ faceAxis: 'y', uAxis: 'z', uv: 'zx' }),
963
+ Object.freeze({ faceAxis: 'z', uAxis: 'y', uv: 'yx' }),
964
+ ]),
965
+ suppressedProjectionAxis: 'x',
966
+ upAxis: 'x',
967
+ }),
968
+ y: Object.freeze({
969
+ lateral: Object.freeze([
970
+ Object.freeze({ faceAxis: 'x', uAxis: 'z', uv: 'zy' }),
971
+ Object.freeze({ faceAxis: 'z', uAxis: 'x', uv: 'xy' }),
972
+ ]),
973
+ suppressedProjectionAxis: 'y',
974
+ upAxis: 'y',
975
+ }),
976
+ z: Object.freeze({
977
+ lateral: Object.freeze([
978
+ Object.freeze({ faceAxis: 'x', uAxis: 'y', uv: 'yz' }),
979
+ Object.freeze({ faceAxis: 'y', uAxis: 'x', uv: 'xz' }),
980
+ ]),
981
+ suppressedProjectionAxis: 'z',
982
+ upAxis: 'z',
983
+ }),
984
+ });
985
+
986
+ /**
987
+ * Describes the compile-time directional-bedding graph. The returned data is
988
+ * deliberately plain CPU data so authoring tools and focused verifiers can
989
+ * prove which axis is suppressed without compiling a WebGPU pipeline.
990
+ */
991
+ export function describeToonLabDirectionalBeddingProjection(upAxis = 'y') {
992
+ const resolved = projectionUpAxis(upAxis, 'y');
993
+ const descriptor = DIRECTIONAL_BEDDING_AXES[resolved];
994
+ return {
995
+ lateral: descriptor.lateral.map((entry) => ({ ...entry })),
996
+ suppressedProjectionAxis: descriptor.suppressedProjectionAxis,
997
+ upAxis: descriptor.upAxis,
998
+ };
999
+ }
1000
+
1001
+ function directionalBeddingState(scale, blend, coordinates, upAxis) {
1002
+ const descriptor = DIRECTIONAL_BEDDING_AXES[projectionUpAxis(upAxis, 'y')];
1003
+ const projected = toonLabSourcePosition(coordinates).div(safeScale(scale));
1004
+ const geometryNormal = toonLabSourceGeometryNormal(coordinates);
1005
+ const exponent = float(Math.max(finite(blend, 1), 0.000001));
1006
+ const upWeight = pow(
1007
+ max(abs(geometryNormal[descriptor.upAxis]), TOONLAB_FLOAT_EPSILON),
1008
+ exponent,
1009
+ );
1010
+ const lateralWeights = descriptor.lateral.map(({ faceAxis }) => pow(
1011
+ max(abs(geometryNormal[faceAxis]), TOONLAB_FLOAT_EPSILON),
1012
+ exponent,
1013
+ ).add(upWeight.mul(0.5)));
1014
+ const denominator = max(lateralWeights[0].add(lateralWeights[1]), 0.000001);
1015
+ return {
1016
+ descriptor,
1017
+ geometryNormal,
1018
+ projected,
1019
+ weights: lateralWeights.map((weight) => weight.div(denominator)),
1020
+ };
1021
+ }
1022
+
1023
+ export function toonLabDirectionalBeddingColor(
1024
+ map,
1025
+ scale,
1026
+ blend = 1,
1027
+ coordinates = { zSign: 1 },
1028
+ upAxis = 'y',
1029
+ ) {
1030
+ const mapNode = texture(map);
1031
+ const state = directionalBeddingState(scale, blend, coordinates, upAxis);
1032
+ const sample = ({ uv: swizzle }) => mapNode.sample(vec2(
1033
+ state.projected[swizzle[0]],
1034
+ state.projected[swizzle[1]],
1035
+ )).rgb;
1036
+ return sample(state.descriptor.lateral[0]).mul(state.weights[0])
1037
+ .add(sample(state.descriptor.lateral[1]).mul(state.weights[1]));
1038
+ }
1039
+
947
1040
  function toonLabSideProjection(map, scale, projectionContrast, {
948
1041
  negativeScale = true,
949
1042
  clampResult = true,
@@ -966,7 +1059,18 @@ function toonLabSideProjection(map, scale, projectionContrast, {
966
1059
  }
967
1060
 
968
1061
  function toonLabRockProjection(map, profile) {
969
- return profile.base.sideOnly
1062
+ // Directional bedding has explicit precedence. `sideOnly` remains the exact
1063
+ // legacy graph for old presets, but cannot re-enable the suppressed top-axis
1064
+ // sample on a bedded C8 surface.
1065
+ return profile.base.mode === 'directional-bedding'
1066
+ ? toonLabDirectionalBeddingColor(
1067
+ map,
1068
+ profile.base.scale,
1069
+ profile.base.projectionContrast,
1070
+ profile.coordinates,
1071
+ profile.base.upAxis,
1072
+ )
1073
+ : profile.base.sideOnly
970
1074
  ? toonLabSideProjection(map, profile.base.scale, profile.base.projectionContrast, {
971
1075
  coordinates: profile.coordinates,
972
1076
  })
@@ -993,13 +1097,18 @@ function normalGreenSignForTexture(map, profileSign) {
993
1097
  return finite(profileSign, 1) * importerSign;
994
1098
  }
995
1099
 
1100
+ function safeNormalizeDirection(direction, fallback = vec3(0, 0, 1)) {
1101
+ const valid = step(float(1e-12), dot(direction, direction));
1102
+ return normalize(mix(fallback, direction, valid));
1103
+ }
1104
+
996
1105
  function worldNormalToTangent(worldNormal) {
997
1106
  const viewNormal = transformNormalByViewMatrix(worldNormal, cameraViewMatrix);
998
- return normalize(transpose(TBNViewMatrix).mul(viewNormal));
1107
+ return safeNormalizeDirection(transpose(TBNViewMatrix).mul(viewNormal));
999
1108
  }
1000
1109
 
1001
1110
  export function tangentNormalToView(tangentNormal) {
1002
- return normalize(TBNViewMatrix.mul(tangentNormal));
1111
+ return safeNormalizeDirection(TBNViewMatrix.mul(tangentNormal));
1003
1112
  }
1004
1113
 
1005
1114
  export function toonLabTriplanarNormal(map, scale, blend, greenSign, coordinates) {
@@ -1027,6 +1136,52 @@ export function toonLabTriplanarNormal(map, scale, blend, greenSign, coordinates
1027
1136
  return worldNormalToTangent(worldNormal);
1028
1137
  }
1029
1138
 
1139
+ function directionalBeddingNormalCandidate(decoded, geometryNormal, entry, upAxis) {
1140
+ const components = {
1141
+ x: geometryNormal.x,
1142
+ y: geometryNormal.y,
1143
+ z: geometryNormal.z,
1144
+ };
1145
+ components[entry.faceAxis] = abs(decoded.z).mul(geometryNormal[entry.faceAxis]);
1146
+ components[entry.uAxis] = decoded.x.add(geometryNormal[entry.uAxis]);
1147
+ components[upAxis] = decoded.y.add(geometryNormal[upAxis]);
1148
+ return vec3(components.x, components.y, components.z);
1149
+ }
1150
+
1151
+ export function toonLabDirectionalBeddingNormal(
1152
+ map,
1153
+ scale,
1154
+ blend,
1155
+ greenSign,
1156
+ coordinates,
1157
+ upAxis = 'y',
1158
+ ) {
1159
+ const state = directionalBeddingState(scale, blend, coordinates, upAxis);
1160
+ const mapNode = texture(map);
1161
+ const candidates = state.descriptor.lateral.map((entry) => {
1162
+ const decoded = decodeToonLabNormal(mapNode.sample(vec2(
1163
+ state.projected[entry.uv[0]],
1164
+ state.projected[entry.uv[1]],
1165
+ )).rgb, greenSign);
1166
+ return directionalBeddingNormalCandidate(
1167
+ decoded,
1168
+ state.geometryNormal,
1169
+ entry,
1170
+ state.descriptor.upAxis,
1171
+ );
1172
+ });
1173
+ const sourceWorldNormal = safeNormalizeDirection(
1174
+ candidates[0].mul(state.weights[0]).add(candidates[1].mul(state.weights[1])),
1175
+ state.geometryNormal,
1176
+ );
1177
+ const worldNormal = safeNormalizeDirection(vec3(
1178
+ sourceWorldNormal.x,
1179
+ sourceWorldNormal.y,
1180
+ sourceWorldNormal.z.mul(coordinates.zSign),
1181
+ ), state.geometryNormal);
1182
+ return worldNormalToTangent(worldNormal);
1183
+ }
1184
+
1030
1185
  function toonLabNormalBlend(a, b) {
1031
1186
  // S_Rock's Normal Blend node uses BlendMode.Default, not RNM.
1032
1187
  return normalize(vec3(a.xy.add(b.xy), a.z.mul(b.z)));
@@ -1286,12 +1441,14 @@ export function createToonRockMaterial({
1286
1441
  // The macro projection carries the authored silhouette-scale color breakup;
1287
1442
  // a second metre-scale octave preserves readable rock surface detail close
1288
1443
  // to the camera without changing that macro identity.
1289
- const nearDetailSample = toonLabTriplanarColor(
1290
- textures.rock,
1291
- resolvedProfile.base.nearDetailScale,
1292
- resolvedProfile.base.projectionContrast,
1293
- resolvedProfile.coordinates,
1294
- );
1444
+ const nearDetailProfile = {
1445
+ ...resolvedProfile,
1446
+ base: {
1447
+ ...resolvedProfile.base,
1448
+ scale: resolvedProfile.base.nearDetailScale,
1449
+ },
1450
+ };
1451
+ const nearDetailSample = toonLabRockProjection(textures.rock, nearDetailProfile);
1295
1452
  const nearDetailValue = dot(nearDetailSample, vec3(0.2126, 0.7152, 0.0722));
1296
1453
  const nearDetailFade = clamp(
1297
1454
  radialDistance.div(resolvedProfile.base.nearDetailDistance),
@@ -1320,16 +1477,24 @@ export function createToonRockMaterial({
1320
1477
  );
1321
1478
 
1322
1479
  if (resolvedProfile.base.striping.enabled) {
1323
- const stripeProjected = toonLabSideProjection(
1324
- textures.stripe,
1325
- resolvedProfile.base.striping.scale,
1326
- resolvedProfile.base.projectionContrast,
1327
- {
1328
- negativeScale: false,
1329
- clampResult: false,
1330
- coordinates: resolvedProfile.coordinates,
1331
- },
1332
- );
1480
+ const stripeProjected = resolvedProfile.base.mode === 'directional-bedding'
1481
+ ? toonLabDirectionalBeddingColor(
1482
+ textures.stripe,
1483
+ resolvedProfile.base.striping.scale,
1484
+ resolvedProfile.base.projectionContrast,
1485
+ resolvedProfile.coordinates,
1486
+ resolvedProfile.base.upAxis,
1487
+ )
1488
+ : toonLabSideProjection(
1489
+ textures.stripe,
1490
+ resolvedProfile.base.striping.scale,
1491
+ resolvedProfile.base.projectionContrast,
1492
+ {
1493
+ negativeScale: false,
1494
+ clampResult: false,
1495
+ coordinates: resolvedProfile.coordinates,
1496
+ },
1497
+ );
1333
1498
  const stripeOpacity = clamp(
1334
1499
  toonLabContrast(stripeProjected.r, resolvedProfile.base.striping.contrast),
1335
1500
  0,
@@ -1811,16 +1976,28 @@ export function createToonRockMaterial({
1811
1976
  : vec3(0, 0, 1);
1812
1977
  let combinedNormal = stylizedNormal;
1813
1978
  if (textures.rockNormal) {
1814
- const crackNormal = toonLabTriplanarNormal(
1815
- textures.rockNormal,
1816
- resolvedProfile.base.scale,
1817
- resolvedProfile.base.projectionContrast,
1818
- normalGreenSignForTexture(
1979
+ const crackNormal = resolvedProfile.base.mode === 'directional-bedding'
1980
+ ? toonLabDirectionalBeddingNormal(
1819
1981
  textures.rockNormal,
1820
- resolvedProfile.normals.normalGreenSign,
1821
- ),
1822
- resolvedProfile.coordinates,
1823
- );
1982
+ resolvedProfile.base.scale,
1983
+ resolvedProfile.base.projectionContrast,
1984
+ normalGreenSignForTexture(
1985
+ textures.rockNormal,
1986
+ resolvedProfile.normals.normalGreenSign,
1987
+ ),
1988
+ resolvedProfile.coordinates,
1989
+ resolvedProfile.base.upAxis,
1990
+ )
1991
+ : toonLabTriplanarNormal(
1992
+ textures.rockNormal,
1993
+ resolvedProfile.base.scale,
1994
+ resolvedProfile.base.projectionContrast,
1995
+ normalGreenSignForTexture(
1996
+ textures.rockNormal,
1997
+ resolvedProfile.normals.normalGreenSign,
1998
+ ),
1999
+ resolvedProfile.coordinates,
2000
+ );
1824
2001
  const normalFade = clamp(
1825
2002
  radialDistance.div(Math.max(
1826
2003
  resolvedProfile.normals.distance * authoredDistanceScale,
@@ -226,6 +226,8 @@ export function rockShaderSettingsToProfile(input = {}) {
226
226
 
227
227
  return normalizeToonLabRockProfile({
228
228
  base: {
229
+ mode: projection.mode,
230
+ upAxis: projection.upAxis,
229
231
  scale: projection.scale,
230
232
  projectionContrast: projection.projectionContrast,
231
233
  sideOnly: projection.sideOnly,
@@ -373,6 +375,11 @@ function createMaterialForSource({
373
375
  materialProfile.shoreline = structuredClone(profile.shoreline);
374
376
  materialProfile.base = {
375
377
  ...materialProfile.base,
378
+ // Projection choice belongs to the edited C8 surface package/runtime
379
+ // binding, not to a shared semantic material set. These strings select
380
+ // a CPU-authored TSL graph; they are deliberately not shader uniforms.
381
+ mode: profile.base.mode,
382
+ upAxis: profile.base.upAxis,
376
383
  closeTintDistance: profile.base.closeTintDistance,
377
384
  distantTint: profile.base.distantTint,
378
385
  distantTintMix: profile.base.distantTintMix,
@@ -529,6 +536,12 @@ function createMaterialForSource({
529
536
  },
530
537
  sourceTextureCount: rockMaterial.userData.toonlabSourceTextureIds.length,
531
538
  };
539
+ rockMaterial.userData.toonlabRockProjectionContract = {
540
+ mapRoles: ['BaseColor', 'NearDetail', 'Smoothness', 'NormalGL', 'AO'],
541
+ mode: materialProfile.base.mode,
542
+ sideOnly: materialProfile.base.sideOnly,
543
+ upAxis: materialProfile.base.upAxis,
544
+ };
532
545
  if (semantic) {
533
546
  rockMaterial.userData.toonLabRockTextureComposition.materialSetId = semantic.materialSetId;
534
547
  rockMaterial.userData.toonLabRockTextureComposition.surfaceState = surfaceState;
@@ -560,8 +573,11 @@ function createMaterialForSource({
560
573
  : materialProfile.layers?.[activeLayer]?.roughness ?? null,
561
574
  };
562
575
  rockMaterial.userData.toonlabRockProjectionContract = {
576
+ ...rockMaterial.userData.toonlabRockProjectionContract,
563
577
  normalBlend: 'whiteout-world-to-tangent',
564
- primary: 'world-space-triplanar',
578
+ primary: materialProfile.base.mode === 'directional-bedding'
579
+ ? 'world-space-directional-bedding'
580
+ : 'world-space-triplanar',
565
581
  scaleCompensation: 'absolute-world-position',
566
582
  texelDensityInvariantUnderNonUniformScale: true,
567
583
  };