@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@call-me-sensei/toonlab",
3
- "version": "0.4.21",
3
+ "version": "0.4.22",
4
4
  "description": "Anime-style game toolkit for Three.js with cel shading, vegetation, rocks, water, sky, clouds, procedural textures, and portable Lab artifacts",
5
5
  "license": "MIT",
6
6
  "author": "Hyperbond Studio PTE. LTD. (Call Me Sensei)",
@@ -61,6 +61,7 @@
61
61
  "src/catalog/officialCatalog.js",
62
62
  "src/catalog/officialCatalogLod.js",
63
63
  "src/catalog/officialCatalogPlacement.js",
64
+ "src/catalog/officialCatalogRockPackage.js",
64
65
  "src/core",
65
66
  "src/environment",
66
67
  "src/ground-shader",
@@ -278,6 +279,7 @@
278
279
  "golden:capture": "node scripts/capture-golden-fixtures.mjs",
279
280
  "golden:compare": "node scripts/compare-golden-captures.mjs",
280
281
  "verify:rockgen": "node scripts/verify-rockgen.mjs",
282
+ "verify:rock-dense-field-lod": "node scripts/verify-rock-dense-field-lod.mjs && node scripts/verify-official-catalog-lod.mjs",
281
283
  "verify:rock-generation-lab": "node scripts/verify-rock-generation-lab.mjs",
282
284
  "verify:rock-c7-library": "node scripts/verify-rock-c7-surface-library.mjs",
283
285
  "verify:rock-lab-lifecycle": "node scripts/verify-rock-lab-lifecycle.mjs",
@@ -362,7 +364,7 @@
362
364
  "verify:style-contract": "node scripts/verify-style-contract.mjs",
363
365
  "verify:style-labels-package": "node scripts/verify-packaged-style-labels.mjs",
364
366
  "verify:collision-package": "node scripts/verify-packaged-world-collision.mjs",
365
- "verify:official-catalog": "node scripts/verify-official-catalog-provider.mjs && node scripts/verify-official-catalog-runtime.mjs && node scripts/verify-official-catalog-lod.mjs && node scripts/verify-official-catalog-placement.mjs && node scripts/verify-playground-official-catalog.mjs && node scripts/verify-packaged-official-catalog.mjs",
367
+ "verify:official-catalog": "node scripts/verify-official-catalog-provider.mjs && node scripts/verify-official-catalog-rock-package.mjs && node scripts/verify-official-catalog-runtime.mjs && node scripts/verify-official-catalog-lod.mjs && node scripts/verify-official-catalog-placement.mjs && node scripts/verify-playground-official-catalog.mjs && node scripts/verify-packaged-official-catalog.mjs",
366
368
  "verify:system-style": "node scripts/verify-system-style-policy.mjs",
367
369
  "verify:release": "npm run verify:system-style && npm run verify:style-contract && npm run verify:style-labels-package && npm run verify:collision-package && npm run verify:scene-collision && npm run verify:official-catalog && npm run verify:public-labs && npm run verify:live-labs && npm run verify:rock-shader && npm run verify:rock-generation-lab && npm run verify:rock-lab-lifecycle && npm run verify:ground-shader && npm run verify:manufactured-materials && npm run verify:material-labeling:agent && npm run verify:material-labeling:mcp && npm run verify:water-lab && npm run verify:water && npm run verify:vegetation-labs && npm run verify:texgen && npm run verify:cloud-shader && npm run verify:sky-cloud-lab && npm run verify:world-quality && npm run verify:scatter && npm run verify:branch-tree && npm run verify:grass-package && npm run verify:character-package && npm run verify:locomotion-state && npm run verify:ground-stabilizer && npm run verify:water-interaction && npm run verify:walkable-character && npm run verify:playground-walkable && npm run verify:renderer-configuration && npm run verify:scene-scheduler && npm run verify:scene-surface && npm run verify:shared-shadow-receivers && npm run verify:scene-quality && npm run verify:quality-integration && npm run verify:lab-asset-provenance && npm run verify:api && npm run verify:types && npm run verify:mcp && npm run verify:skills && npm run verify:docs && npm run verify:local-database && npm run verify:package",
368
370
  "verify:generation": "node scripts/verify-generation.mjs",
@@ -2,3 +2,4 @@ export * from './officialCatalogProvider.js';
2
2
  export * from './officialCatalogAssetRuntime.js';
3
3
  export * from './officialCatalogLod.js';
4
4
  export * from './officialCatalogPlacement.js';
5
+ export * from './officialCatalogRockPackage.js';
@@ -14,6 +14,10 @@ import {
14
14
  rockSemanticMaterialTextures,
15
15
  } from '../rock-shader/rockSemanticMaterialRuntime.js';
16
16
  import { OFFICIAL_CATALOG_ASSET_VERSION } from './officialCatalogProvider.js';
17
+ import {
18
+ loadOfficialCatalogRockPackage,
19
+ loadOfficialCatalogRockShaderInput,
20
+ } from './officialCatalogRockPackage.js';
17
21
 
18
22
  const rendererPools = new WeakMap();
19
23
  const headlessPools = new Map();
@@ -141,18 +145,25 @@ async function defaultPrepareTextures(root, asset) {
141
145
  export function createOfficialCatalogAssetRuntime({
142
146
  decoderBasePath = '/',
143
147
  loadModel = loadModelAsset,
148
+ loadRockPackage = loadOfficialCatalogRockPackage,
149
+ loadRockShaderInput = loadOfficialCatalogRockShaderInput,
144
150
  prepareTextures = defaultPrepareTextures,
145
151
  provider,
146
152
  renderer = null,
153
+ rockTextureLoader = null,
147
154
  transcodersFactory = createModelAssetTranscoders,
148
155
  } = {}) {
149
156
  if (!provider || typeof provider.getAsset !== 'function') {
150
157
  throw new TypeError('Official catalog asset runtime requires a provider with getAsset().');
151
158
  }
152
159
  if (typeof loadModel !== 'function') throw new TypeError('loadModel must be a function.');
160
+ if (typeof loadRockPackage !== 'function') throw new TypeError('loadRockPackage must be a function.');
161
+ if (typeof loadRockShaderInput !== 'function') throw new TypeError('loadRockShaderInput must be a function.');
153
162
  if (typeof prepareTextures !== 'function') throw new TypeError('prepareTextures must be a function.');
154
163
 
155
164
  const assetPromises = new Map();
165
+ const rockPackagePromises = new Map();
166
+ const rockShaderInputPromises = new Map();
156
167
  const sourcePromises = new Map();
157
168
  const sourceEntries = new Map();
158
169
  const handles = new Set();
@@ -190,6 +201,53 @@ export function createOfficialCatalogAssetRuntime({
190
201
  return assetPromises.get(id);
191
202
  }
192
203
 
204
+ async function getAsset(assetOrId) {
205
+ assertActive();
206
+ return resolveAsset(assetOrId);
207
+ }
208
+
209
+ async function getRockPackage(assetOrId, options = {}) {
210
+ assertActive();
211
+ const asset = await resolveAsset(assetOrId);
212
+ assertActive();
213
+ const includeManifest = options.includeManifest === true;
214
+ const includeNatureProvenance = options.includeNatureProvenance === true;
215
+ const key = `${asset.identity}|manifest:${includeManifest}|nature:${includeNatureProvenance}`;
216
+ if (!rockPackagePromises.has(key)) {
217
+ const promise = Promise.resolve(loadRockPackage(asset, options)).catch((error) => {
218
+ if (rockPackagePromises.get(key) === promise) rockPackagePromises.delete(key);
219
+ throw error;
220
+ });
221
+ rockPackagePromises.set(key, promise);
222
+ }
223
+ return rockPackagePromises.get(key);
224
+ }
225
+
226
+ async function getRockShaderInput(assetOrId) {
227
+ assertActive();
228
+ const asset = await resolveAsset(assetOrId);
229
+ assertActive();
230
+ if (!asset.packageFiles?.materialConfigUrl) return null;
231
+ const key = asset.identity;
232
+ if (!rockShaderInputPromises.has(key)) {
233
+ const promise = (async () => {
234
+ const rockPackage = await getRockPackage(asset);
235
+ const options = rockTextureLoader ? { textureLoader: rockTextureLoader } : {};
236
+ const input = await loadRockShaderInput(rockPackage, options);
237
+ if (disposed) {
238
+ Object.values(input?.textures ?? {}).forEach((texture) => texture.dispose?.());
239
+ throw new Error('Official catalog asset runtime was disposed while loading rock textures.');
240
+ }
241
+ return input;
242
+ })().catch((error) => {
243
+ if (rockShaderInputPromises.get(key) === promise) rockShaderInputPromises.delete(key);
244
+ throw error;
245
+ });
246
+ rockShaderInputPromises.set(key, promise);
247
+ }
248
+ return rockShaderInputPromises.get(key);
249
+ }
250
+
193
251
  async function sourceFor(asset) {
194
252
  const key = asset.identity;
195
253
  if (!sourcePromises.has(key)) {
@@ -273,7 +331,26 @@ export function createOfficialCatalogAssetRuntime({
273
331
 
274
332
  function evict(assetOrId, { force = false } = {}) {
275
333
  const entry = findEntry(assetOrId);
276
- if (!entry) return false;
334
+ const identity = entry?.asset.identity ?? String(assetOrId?.identity ?? '');
335
+ const id = entry?.asset.id ?? String(assetOrId?.id ?? assetOrId ?? '');
336
+ const packageKeys = [...rockPackagePromises.keys()].filter((key) => (
337
+ (identity && key.startsWith(`${identity}|`)) || (!identity && key.includes(`:${id}@`))
338
+ ));
339
+ const shaderKeys = [...rockShaderInputPromises.keys()].filter((key) => (
340
+ key === identity || (!identity && key.includes(`:${id}@`))
341
+ ));
342
+ if (!entry && packageKeys.length === 0 && shaderKeys.length === 0) return false;
343
+ if (!entry) {
344
+ packageKeys.forEach((key) => rockPackagePromises.delete(key));
345
+ for (const key of shaderKeys) {
346
+ rockShaderInputPromises.get(key)?.then((input) => {
347
+ Object.values(input?.textures ?? {}).forEach((texture) => texture.dispose?.());
348
+ }).catch(() => {});
349
+ rockShaderInputPromises.delete(key);
350
+ }
351
+ assetPromises.delete(id);
352
+ return true;
353
+ }
277
354
  if (entry.refs > 0 && !force) return false;
278
355
  if (force) {
279
356
  for (const handle of [...handles]) {
@@ -284,6 +361,13 @@ export function createOfficialCatalogAssetRuntime({
284
361
  sourceEntries.delete(entry.asset.identity);
285
362
  sourcePromises.delete(entry.asset.identity);
286
363
  assetPromises.delete(entry.asset.id);
364
+ packageKeys.forEach((key) => rockPackagePromises.delete(key));
365
+ for (const key of shaderKeys) {
366
+ rockShaderInputPromises.get(key)?.then((input) => {
367
+ Object.values(input?.textures ?? {}).forEach((texture) => texture.dispose?.());
368
+ }).catch(() => {});
369
+ rockShaderInputPromises.delete(key);
370
+ }
287
371
  return true;
288
372
  }
289
373
 
@@ -297,6 +381,8 @@ export function createOfficialCatalogAssetRuntime({
297
381
  refs: entry.refs,
298
382
  }))),
299
383
  pendingAssets: sourcePromises.size - sourceEntries.size,
384
+ pendingRockPackages: rockPackagePromises.size,
385
+ cachedRockShaderInputs: rockShaderInputPromises.size,
300
386
  });
301
387
  }
302
388
 
@@ -305,10 +391,18 @@ export function createOfficialCatalogAssetRuntime({
305
391
  disposed = true;
306
392
  for (const handle of [...handles]) handle.release();
307
393
  await Promise.allSettled(sourcePromises.values());
394
+ const shaderInputs = await Promise.allSettled(rockShaderInputPromises.values());
395
+ for (const result of shaderInputs) {
396
+ if (result.status === 'fulfilled') {
397
+ Object.values(result.value?.textures ?? {}).forEach((texture) => texture.dispose?.());
398
+ }
399
+ }
308
400
  for (const entry of sourceEntries.values()) disposeResources(entry.resources);
309
401
  sourceEntries.clear();
310
402
  sourcePromises.clear();
311
403
  assetPromises.clear();
404
+ rockPackagePromises.clear();
405
+ rockShaderInputPromises.clear();
312
406
  transcoderLease?.release();
313
407
  transcoderLease = null;
314
408
  }
@@ -317,6 +411,9 @@ export function createOfficialCatalogAssetRuntime({
317
411
  acquireAsset,
318
412
  dispose,
319
413
  evict,
414
+ getAsset,
415
+ getRockPackage,
416
+ getRockShaderInput,
320
417
  get disposed() { return disposed; },
321
418
  provider,
322
419
  stats,
@@ -1,4 +1,4 @@
1
- import { Vector3 } from 'three';
1
+ import { Box3, Sphere, Vector3 } from 'three';
2
2
 
3
3
  const DEFAULT_LOD_NAME_PATTERN = /(?:^|[_\s.-])LOD[_\s.-]?(\d+)(?:$|[_\s.-])/iu;
4
4
 
@@ -97,16 +97,118 @@ export function selectCatalogLodLevel({
97
97
  return selected;
98
98
  }
99
99
 
100
+ function normalizePixelThresholds(thresholds, levelCount) {
101
+ const count = Math.max(Math.round(Number(levelCount) || 1), 1);
102
+ const source = Array.isArray(thresholds) ? thresholds : [];
103
+ const normalized = [];
104
+ for (let level = 0; level < count; level += 1) {
105
+ const fallback = level === 0 ? 240 : Math.max((normalized[level - 1] ?? 240) * 0.45, 1);
106
+ const value = Number(source[level]);
107
+ normalized.push(Number.isFinite(value) && value > 0 ? value : fallback);
108
+ }
109
+ for (let level = 1; level < normalized.length; level += 1) {
110
+ normalized[level] = Math.min(normalized[level], normalized[level - 1] - 0.001);
111
+ }
112
+ return Object.freeze(normalized);
113
+ }
114
+
115
+ /**
116
+ * Resolve an authored LOD from its projected on-screen diameter. A null result
117
+ * is an intentional cull decision, not a missing-asset failure.
118
+ */
119
+ export function selectCatalogLodLevelByProjectedPixels({
120
+ availableLevels,
121
+ cullBelowPixels = 0,
122
+ currentLevel = null,
123
+ hysteresis = 0,
124
+ pixelThresholds,
125
+ projectedPixels,
126
+ } = {}) {
127
+ const available = [...new Set((availableLevels ?? []).map(finiteLevel).filter((v) => v !== null))]
128
+ .sort((left, right) => left - right);
129
+ if (available.length === 0) return null;
130
+ const pixels = Number(projectedPixels);
131
+ if (!Number.isFinite(pixels) || pixels < 0) {
132
+ throw new RangeError('Projected LOD pixels must be a finite non-negative number.');
133
+ }
134
+ const margin = Number(hysteresis);
135
+ if (!Number.isFinite(margin) || margin < 0 || margin >= 0.5) {
136
+ throw new RangeError('Catalog LOD hysteresis must be a finite ratio in [0, 0.5).');
137
+ }
138
+ const cull = Math.max(Number(cullBelowPixels) || 0, 0);
139
+ const thresholds = normalizePixelThresholds(pixelThresholds, available.at(-1) + 1);
140
+ const current = finiteLevel(currentLevel);
141
+
142
+ if (pixels < cull) {
143
+ if (current !== null && available.includes(current) && margin > 0
144
+ && pixels >= cull * (1 - margin)) return current;
145
+ return null;
146
+ }
147
+
148
+ let requested = thresholds.length - 1;
149
+ for (let level = 0; level < thresholds.length; level += 1) {
150
+ if (pixels >= thresholds[level]) {
151
+ requested = level;
152
+ break;
153
+ }
154
+ }
155
+ const selected = available.filter((level) => level <= requested).at(-1) ?? available[0];
156
+ if (current === null || !available.includes(current) || margin === 0 || selected === current) {
157
+ return selected;
158
+ }
159
+
160
+ if (selected > current) {
161
+ const boundary = thresholds[current] ?? cull;
162
+ if (pixels >= boundary * (1 - margin)) return current;
163
+ } else {
164
+ const boundary = thresholds[selected] ?? thresholds[0];
165
+ if (pixels <= boundary * (1 + margin)) return current;
166
+ }
167
+ return selected;
168
+ }
169
+
170
+ /** Estimate projected object diameter for perspective and orthographic cameras. */
171
+ export function estimateCatalogProjectedPixels({
172
+ camera,
173
+ distance,
174
+ referenceDiameter,
175
+ viewportHeight,
176
+ } = {}) {
177
+ const pixelsHigh = Number(viewportHeight);
178
+ const diameter = Number(referenceDiameter);
179
+ if (!camera?.isCamera) throw new TypeError('Projected LOD estimation requires a Three.js camera.');
180
+ if (!Number.isFinite(pixelsHigh) || pixelsHigh <= 0) {
181
+ throw new RangeError('Projected LOD estimation requires a positive viewportHeight.');
182
+ }
183
+ if (!Number.isFinite(diameter) || diameter <= 0) {
184
+ throw new RangeError('Projected LOD estimation requires a positive referenceDiameter.');
185
+ }
186
+ if (camera.isOrthographicCamera) {
187
+ const span = Math.abs(Number(camera.top) - Number(camera.bottom)) / Math.max(Number(camera.zoom) || 1, 0.001);
188
+ return span > 0 ? diameter / span * pixelsHigh : Number.POSITIVE_INFINITY;
189
+ }
190
+ if (!camera.isPerspectiveCamera) {
191
+ throw new TypeError('Projected LOD estimation supports perspective and orthographic cameras.');
192
+ }
193
+ const range = Math.max(Number(distance) || 0, 0.0001);
194
+ const zoom = Math.max(Number(camera.zoom) || 1, 0.001);
195
+ const halfFov = Math.atan(Math.tan((Number(camera.fov) || 50) * Math.PI / 360) / zoom);
196
+ return diameter / (2 * range * Math.tan(halfFov)) * pixelsHigh;
197
+ }
198
+
100
199
  /**
101
200
  * Authored, renderer-independent LOD controller. The host calls update() with
102
201
  * either a direct distance or a camera. World scale is removed from camera
103
202
  * distance so a uniformly enlarged landmark changes LOD at proportional range.
104
203
  */
105
204
  export function createCatalogLodRuntime(root, {
205
+ cullBelowPixels = 0,
106
206
  distances = [0, 45, 120],
107
207
  hysteresis = 0,
108
208
  maxLevel = Number.POSITIVE_INFINITY,
109
209
  namePattern = DEFAULT_LOD_NAME_PATTERN,
210
+ pixelThresholds = null,
211
+ referenceDiameter = null,
110
212
  } = {}) {
111
213
  const bindings = collectCatalogLodBindings(root, { namePattern });
112
214
  const availableLevels = Object.freeze(
@@ -119,27 +221,41 @@ export function createCatalogLodRuntime(root, {
119
221
  }
120
222
  const worldPosition = new Vector3();
121
223
  const worldScale = new Vector3();
224
+ const bounds = new Box3();
225
+ const sphere = new Sphere();
226
+ const usesScreenSpace = Array.isArray(pixelThresholds);
227
+ let localReferenceDiameter = Number(referenceDiameter);
228
+ if (!Number.isFinite(localReferenceDiameter) || localReferenceDiameter <= 0) {
229
+ root.updateWorldMatrix?.(true, true);
230
+ bounds.setFromObject(root, true).getBoundingSphere(sphere);
231
+ root.getWorldScale(worldScale);
232
+ const scale = Math.max(Math.abs(worldScale.x), Math.abs(worldScale.y), Math.abs(worldScale.z), 0.001);
233
+ localReferenceDiameter = Math.max(sphere.radius * 2 / scale, 0.001);
234
+ }
235
+ const normalizedPixelThresholds = usesScreenSpace
236
+ ? normalizePixelThresholds(pixelThresholds, availableLevels.at(-1) + 1)
237
+ : null;
122
238
  let currentLevel = null;
123
239
  let disposed = false;
124
240
 
125
241
  function setLevel(level) {
126
- if (disposed || level === null || !availableLevels.includes(level)) return false;
242
+ if (disposed || (level !== null && !availableLevels.includes(level))) return false;
127
243
  if (currentLevel === level) return false;
128
244
  bindings.forEach((binding) => {
129
- binding.mesh.visible = binding.level === level;
245
+ binding.mesh.visible = level !== null && binding.level === level;
130
246
  });
131
247
  currentLevel = level;
132
248
  return true;
133
249
  }
134
250
 
135
251
  function resolveDistance({ camera = null, distance = null } = {}) {
252
+ root.updateWorldMatrix?.(true, false);
253
+ root.getWorldPosition(worldPosition);
254
+ root.getWorldScale(worldScale);
136
255
  if (Number.isFinite(distance)) return Math.max(distance, 0);
137
256
  if (!camera?.getWorldPosition && !camera?.position) {
138
257
  throw new TypeError('Catalog LOD update requires distance or camera.');
139
258
  }
140
- root.updateWorldMatrix?.(true, false);
141
- root.getWorldPosition(worldPosition);
142
- root.getWorldScale(worldScale);
143
259
  const cameraPosition = camera.getWorldPosition
144
260
  ? camera.getWorldPosition(new Vector3())
145
261
  : camera.position;
@@ -149,17 +265,42 @@ export function createCatalogLodRuntime(root, {
149
265
 
150
266
  function update(options = {}) {
151
267
  if (disposed || availableLevels.length === 0) return null;
152
- const distance = resolveDistance(options);
153
- const level = selectCatalogLodLevel({
154
- availableLevels,
155
- distance,
156
- distances: thresholds,
157
- currentLevel,
158
- hysteresis: hysteresisRatio,
159
- maxLevel,
160
- });
268
+ const hasDirectPixels = Number.isFinite(Number(options.projectedPixels));
269
+ const distance = hasDirectPixels && !Number.isFinite(options.distance) && !options.camera
270
+ ? null
271
+ : resolveDistance(options);
272
+ const projectedPixels = hasDirectPixels
273
+ ? Math.max(Number(options.projectedPixels), 0)
274
+ : usesScreenSpace
275
+ ? estimateCatalogProjectedPixels({
276
+ camera: options.camera,
277
+ distance,
278
+ referenceDiameter: localReferenceDiameter * Math.max(
279
+ Math.abs(worldScale.x), Math.abs(worldScale.y), Math.abs(worldScale.z), 0.001,
280
+ ),
281
+ viewportHeight: options.viewportHeight,
282
+ })
283
+ : null;
284
+ let level = usesScreenSpace
285
+ ? selectCatalogLodLevelByProjectedPixels({
286
+ availableLevels,
287
+ cullBelowPixels,
288
+ currentLevel,
289
+ hysteresis: hysteresisRatio,
290
+ pixelThresholds: normalizedPixelThresholds,
291
+ projectedPixels,
292
+ })
293
+ : selectCatalogLodLevel({
294
+ availableLevels,
295
+ distance,
296
+ distances: thresholds,
297
+ currentLevel,
298
+ hysteresis: hysteresisRatio,
299
+ maxLevel,
300
+ });
301
+ if (level !== null) level = Math.min(level, Math.max(Number(maxLevel) || 0, 0));
161
302
  const changed = setLevel(level);
162
- return Object.freeze({ changed, distance, level });
303
+ return Object.freeze({ changed, culled: level === null, distance, level, projectedPixels });
163
304
  }
164
305
 
165
306
  if (availableLevels.length > 0) setLevel(availableLevels[0]);
@@ -176,6 +317,9 @@ export function createCatalogLodRuntime(root, {
176
317
  get disposed() { return disposed; },
177
318
  get level() { return currentLevel; },
178
319
  setLevel,
320
+ cullBelowPixels: Math.max(Number(cullBelowPixels) || 0, 0),
321
+ pixelThresholds: normalizedPixelThresholds,
322
+ referenceDiameter: localReferenceDiameter,
179
323
  thresholds,
180
324
  hysteresis: hysteresisRatio,
181
325
  update,
@@ -20,7 +20,8 @@ import {
20
20
  resolveCatalogQualityOptions,
21
21
  resolveSceneQualityProfile,
22
22
  } from '../styles/sceneQualityProfiles.js';
23
- import { createCatalogLodRuntime } from './officialCatalogLod.js';
23
+ import { createDenseFieldRockLodRuntime } from '../rockgen/lod/rockDenseFieldPolicy.js';
24
+ import { collectCatalogLodBindings, createCatalogLodRuntime } from './officialCatalogLod.js';
24
25
 
25
26
  function vector3(value, fallback = [0, 0, 0]) {
26
27
  if (Array.isArray(value)) {
@@ -39,6 +40,17 @@ function rotation3(value) {
39
40
  return [0, Number(value) || 0, 0];
40
41
  }
41
42
 
43
+ function mergeSettings(base, patch) {
44
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) return base;
45
+ const output = { ...(base ?? {}) };
46
+ for (const [key, value] of Object.entries(patch)) {
47
+ output[key] = value && typeof value === 'object' && !Array.isArray(value)
48
+ ? mergeSettings(output[key], value)
49
+ : value;
50
+ }
51
+ return output;
52
+ }
53
+
42
54
  /**
43
55
  * Validates caller-supplied style textures without reclassifying the asset's
44
56
  * own UV-space maps as triplanar detail maps. applyRockShader reads the full
@@ -54,13 +66,13 @@ function catalogRockTextures(overrides = {}) {
54
66
  };
55
67
  }
56
68
 
57
- function styleAdapterFor(asset, root, { textures: overrides, variation }) {
69
+ function styleAdapterFor(asset, root, { settings: assetSettings, textures: overrides, variation }) {
58
70
  if (asset.domain !== 'natural.rock') return null;
59
71
  const resolved = catalogRockTextures(overrides);
60
72
  const report = { rejectedTextures: resolved.rejected };
61
73
  return Object.freeze({
62
74
  apply(subject, settings) {
63
- const result = applyRockShader(subject, settings, {
75
+ const result = applyRockShader(subject, mergeSettings(settings, assetSettings), {
64
76
  name: `ToonLab · ${asset.label}`,
65
77
  textures: resolved.textures,
66
78
  variation,
@@ -124,6 +136,7 @@ export async function loadOfficialCatalogAsset({
124
136
  styleBundle,
125
137
  targetId = null,
126
138
  textures = {},
139
+ usePublishedRockSurface = true,
127
140
  variation = 0,
128
141
  } = {}) {
129
142
  if (!assetRuntime?.acquireAsset) {
@@ -153,6 +166,7 @@ export async function loadOfficialCatalogAsset({
153
166
  let lod = null;
154
167
  let collisionRegistration = null;
155
168
  let inspectorRegistration = null;
169
+ let publishedRockSurface = null;
156
170
  try {
157
171
  metadata = collisionMetadataFor(asset, collision, collisionAdapter);
158
172
  labelStyleTarget(root, createStyleTargetLabel(asset.domain, {
@@ -160,17 +174,33 @@ export async function loadOfficialCatalogAsset({
160
174
  collision: metadata,
161
175
  targetId: id,
162
176
  }));
163
- adapter = styleAdapterFor(asset, root, { textures, variation });
177
+ if (usePublishedRockSurface
178
+ && asset.domain === 'natural.rock'
179
+ && typeof assetRuntime.getRockShaderInput === 'function') {
180
+ publishedRockSurface = await assetRuntime.getRockShaderInput(asset);
181
+ }
182
+ adapter = styleAdapterFor(asset, root, {
183
+ settings: publishedRockSurface?.settings,
184
+ textures: { ...(publishedRockSurface?.textures ?? {}), ...textures },
185
+ variation,
186
+ });
164
187
  style = await applyStyleBundle(styleBundle, {
165
188
  targets: [createStyleTarget(id, asset.domain, root, { adapter })],
166
189
  });
167
- lod = createCatalogLodRuntime(root, {
168
- distances: resolveCatalogLodDistancesForQuality(
169
- qualityProfile,
170
- asset.lod?.distances,
171
- ),
172
- maxLevel: maxLodLevel,
173
- });
190
+ const authoredLodLevels = [...new Set(
191
+ collectCatalogLodBindings(root).map(({ level }) => level),
192
+ )];
193
+ const usesDenseFieldRockLods = asset.domain === 'natural.rock'
194
+ && authoredLodLevels.includes(4);
195
+ lod = usesDenseFieldRockLods
196
+ ? createDenseFieldRockLodRuntime(root, { maxLevel: maxLodLevel })
197
+ : createCatalogLodRuntime(root, {
198
+ distances: resolveCatalogLodDistancesForQuality(
199
+ qualityProfile,
200
+ asset.lod?.distances,
201
+ ),
202
+ maxLevel: maxLodLevel,
203
+ });
174
204
  if (metadata.kind !== 'none') {
175
205
  collisionRegistration = await registerCollisionTarget({
176
206
  adapter: collisionAdapter,
@@ -191,6 +221,8 @@ export async function loadOfficialCatalogAsset({
191
221
  lod: {
192
222
  availableLevels: lod.availableLevels,
193
223
  enabled: true,
224
+ cullBelowPixels: lod.cullBelowPixels,
225
+ pixelThresholds: lod.pixelThresholds,
194
226
  thresholds: lod.thresholds,
195
227
  },
196
228
  },
@@ -221,6 +253,7 @@ export async function loadOfficialCatalogAsset({
221
253
  lod,
222
254
  normalization,
223
255
  object: root,
256
+ publishedRockSurface,
224
257
  quality: qualityProfile,
225
258
  /** Detail maps the artifact shipped that were too small to be usable. */
226
259
  get rejectedTextures() { return adapter?.report?.rejectedTextures ?? []; },
@@ -238,6 +271,13 @@ export async function loadOfficialCatalogAsset({
238
271
  get released() { return released; },
239
272
  style,
240
273
  targetId: id,
241
- updateLod(options) { return lod.update(options); },
274
+ updateLod(options = {}) {
275
+ if (!lod.pixelThresholds || options.projectedPixels !== undefined) return lod.update(options);
276
+ return lod.update({
277
+ ...options,
278
+ viewportHeight: options.viewportHeight
279
+ ?? (typeof globalThis.innerHeight === 'number' ? globalThis.innerHeight : 1080),
280
+ });
281
+ },
242
282
  });
243
283
  }