@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
@@ -19,12 +19,13 @@ import {
19
19
  resolveRockgenPreset,
20
20
  } from './rockgenPresets.js';
21
21
  import { compileDocument } from './sdf/fieldCompiler.js';
22
+ import { resolveC7Projection } from './surface/c7GeologySurface.js';
22
23
 
23
24
  /** Document type tag stamped on saved rockgen project JSON. */
24
25
  export const ROCKGEN_PROJECT_DOCUMENT_TYPE = 'toonlab/rockgen-project';
25
26
 
26
27
  /** Current schema version for rockgen project documents. */
27
- export const ROCKGEN_PROJECT_SCHEMA_VERSION = 6;
28
+ export const ROCKGEN_PROJECT_SCHEMA_VERSION = 7;
28
29
 
29
30
  /** Sparse source-mesh edits are packed into the portable document so normal
30
31
  * sculpting remains below the hosted creation-document limit. Runtime state
@@ -72,6 +73,26 @@ function lodTrianglesOption(value, targetTriangles, lodRatios) {
72
73
 
73
74
  function surfacePackageOption(value) {
74
75
  if (!value || typeof value !== 'object') return null;
76
+ const c8SurfaceVersion = Number(value.version);
77
+ if (value.schema === 'toonlab/c8-first12-geology-surface'
78
+ && (c8SurfaceVersion === 1 || c8SurfaceVersion === 2)) {
79
+ const assetId = String(value.assetId ?? '').trim();
80
+ const geometrySha256 = String(value.geometrySha256 ?? '').toLowerCase();
81
+ const profileId = String(value.profileId ?? '').trim();
82
+ const mapResolution = Math.round(Number(value.mapResolution));
83
+ if (!assetId || !profileId || !/^[a-f0-9]{64}$/u.test(geometrySha256)
84
+ || !Number.isInteger(mapResolution) || mapResolution < 1024 || mapResolution > 4096) return null;
85
+ return {
86
+ ...structuredClone(value),
87
+ assetId,
88
+ geometrySha256,
89
+ mapResolution,
90
+ profileId,
91
+ schema: 'toonlab/c8-first12-geology-surface',
92
+ seed: Math.round(Number(value.seed) || 0) >>> 0,
93
+ version: c8SurfaceVersion,
94
+ };
95
+ }
75
96
  if (value.schema !== 'toonlab/c7-geology-surface' || Number(value.version) !== 1) return null;
76
97
  const geology = String(value.geology ?? '').trim();
77
98
  if (!geology) return null;
@@ -85,6 +106,146 @@ function surfacePackageOption(value) {
85
106
  };
86
107
  }
87
108
 
109
+ const SHA256_CONTENT_HASH = /^sha256:[0-9a-f]{64}$/u;
110
+
111
+ function contentHashOption(value) {
112
+ return SHA256_CONTENT_HASH.test(value ?? '') ? value : null;
113
+ }
114
+
115
+ function customMeshArtifactOption(value, role) {
116
+ if (!value || typeof value !== 'object' || value.role !== role) return null;
117
+ if (value.mimeType !== 'model/gltf-binary' || value.unit !== 'metre'
118
+ || Number(value.metreUnitScale) !== 1 || value.coordinateSystem !== 'right-handed-y-up') return null;
119
+ const dimensionsMetres = vector3Option(value.dimensionsMetres, [0, 0, 0]);
120
+ const min = vector3Option(value.boundsMetres?.min, [0, 0, 0]);
121
+ const max = vector3Option(value.boundsMetres?.max, [0, 0, 0]);
122
+ const contentHash = contentHashOption(value.contentHash);
123
+ const topologyHash = contentHashOption(value.topologyHash);
124
+ const uri = String(value.uri ?? '').trim();
125
+ const id = String(value.id ?? '').trim();
126
+ const byteLength = positiveInteger(value.byteLength, 0);
127
+ const geometryAudit = {
128
+ meshVertexCounts: Array.isArray(value.geometryAudit?.meshVertexCounts)
129
+ ? value.geometryAudit.meshVertexCounts.map((entry) => positiveInteger(entry, 0))
130
+ : [],
131
+ topologyHash: contentHashOption(value.geometryAudit?.topologyHash),
132
+ triangleCount: positiveInteger(value.geometryAudit?.triangleCount, 0),
133
+ vertexCount: positiveInteger(value.geometryAudit?.vertexCount, 0),
134
+ };
135
+ if (!contentHash || !topologyHash || !uri.toLowerCase().endsWith('.glb')
136
+ || !id || byteLength < 20
137
+ || dimensionsMetres.some((entry) => entry <= 0)
138
+ || !min.every((entry, axis) => entry < max[axis])
139
+ || !dimensionsMetres.every((entry, axis) => (
140
+ Math.abs(entry - (max[axis] - min[axis])) <= Math.max(1e-6, entry * 1e-5)
141
+ ))
142
+ || geometryAudit.topologyHash !== topologyHash
143
+ || geometryAudit.vertexCount < 4 || geometryAudit.triangleCount < 4
144
+ || geometryAudit.meshVertexCounts.length === 0
145
+ || geometryAudit.meshVertexCounts.some((entry) => entry <= 0)) return null;
146
+ return {
147
+ boundsMetres: { max, min },
148
+ byteLength,
149
+ contentHash,
150
+ coordinateSystem: 'right-handed-y-up',
151
+ dimensionsMetres,
152
+ geometryAudit: { ...geometryAudit, topologyHash },
153
+ id,
154
+ metreUnitScale: 1,
155
+ mimeType: 'model/gltf-binary',
156
+ role,
157
+ sourceRevision: positiveInteger(value.sourceRevision, 1),
158
+ topologyHash,
159
+ unit: 'metre',
160
+ uri,
161
+ };
162
+ }
163
+
164
+ function identityLandmarksOption(value) {
165
+ if (!Array.isArray(value)) return [];
166
+ return value.flatMap((entry) => {
167
+ const id = String(entry?.id ?? '').trim();
168
+ if (!id) return [];
169
+ return [{
170
+ id,
171
+ positionMetres: vector3Option(entry.positionMetres, [0, 0, 0]),
172
+ role: String(entry.role ?? 'identity').trim(),
173
+ }];
174
+ });
175
+ }
176
+
177
+ function semanticRegionsOption(value, landmarkIds) {
178
+ if (!Array.isArray(value)) return [];
179
+ return value.flatMap((entry) => {
180
+ const id = String(entry?.id ?? '').trim();
181
+ const attribute = String(entry?.selector?.attribute ?? '').trim();
182
+ const selectorValue = Math.round(Number(entry?.selector?.value));
183
+ if (!id || !/^_[A-Z0-9_]+$/u.test(attribute) || !Number.isInteger(selectorValue) || selectorValue < 0) return [];
184
+ return [{
185
+ id,
186
+ label: String(entry.label ?? id),
187
+ landmarkIds: Array.isArray(entry.landmarkIds)
188
+ ? entry.landmarkIds.map(String).filter((landmarkId) => landmarkIds.has(landmarkId))
189
+ : [],
190
+ role: String(entry.role ?? 'primary-mass'),
191
+ selector: { attribute, kind: 'gltf-attribute', value: selectorValue },
192
+ }];
193
+ });
194
+ }
195
+
196
+ function customMeshSourceOption(value, identityLandmarks) {
197
+ if (!value || value.schema !== 'toonlab/c8-custom-mesh-source' || Number(value.version) !== 1) return null;
198
+ if (value.authority !== 'toonlab-editable-control-plus-retained-high') return null;
199
+ const control = customMeshArtifactOption(value.control, 'editable-control');
200
+ const highDetail = customMeshArtifactOption(value.highDetail, 'retained-high-detail');
201
+ if (!control || !highDetail || !control.id || !highDetail.id || control.id === highDetail.id
202
+ || control.contentHash === highDetail.contentHash
203
+ || control.geometryAudit.meshVertexCounts.some((entry) => entry <= 0)
204
+ || highDetail.geometryAudit.meshVertexCounts.some((entry) => entry <= 0)
205
+ || control.geometryAudit.meshVertexCounts.reduce((sum, entry) => sum + entry, 0) !== control.geometryAudit.vertexCount
206
+ || highDetail.geometryAudit.meshVertexCounts.reduce((sum, entry) => sum + entry, 0) !== highDetail.geometryAudit.vertexCount
207
+ || highDetail.geometryAudit.vertexCount <= control.geometryAudit.vertexCount
208
+ || highDetail.geometryAudit.triangleCount <= control.geometryAudit.triangleCount
209
+ || control.byteLength < 20 || highDetail.byteLength < 20) return null;
210
+ const landmarkIds = new Set(identityLandmarks.map((landmark) => landmark.id));
211
+ if (landmarkIds.size < 3 || landmarkIds.size !== identityLandmarks.length) return null;
212
+ const semanticRegions = semanticRegionsOption(value.semanticRegions, landmarkIds);
213
+ const maximumDisplacementMetres = Number(value.editEnvelope?.maximumDisplacementMetres);
214
+ const maximumVariationStrength = Number(value.editEnvelope?.maximumVariationStrength);
215
+ const semanticRegionIds = new Set(semanticRegions.map((region) => region.id));
216
+ const semanticSelectors = new Set(semanticRegions.map((region) => `${region.selector.attribute}:${region.selector.value}`));
217
+ const supportToleranceMetres = Math.max(...control.dimensionsMetres) * 1e-5;
218
+ const highCorrespondenceToleranceMetres = Math.max(...control.dimensionsMetres) * 0.05;
219
+ if (semanticRegions.length < 3 || semanticRegionIds.size !== semanticRegions.length
220
+ || semanticSelectors.size !== semanticRegions.length
221
+ || !semanticRegions.some((region) => region.role === 'base-support')
222
+ || Math.abs(control.boundsMetres.min[1]) > Math.max(1e-6, supportToleranceMetres)
223
+ || ['min', 'max'].some((key) => control.boundsMetres[key].some((entry, axis) => (
224
+ Math.abs(entry - highDetail.boundsMetres[key][axis]) > highCorrespondenceToleranceMetres
225
+ )))
226
+ || !Number.isFinite(maximumDisplacementMetres)
227
+ || maximumDisplacementMetres <= 0 || !Number.isFinite(maximumVariationStrength)
228
+ || maximumVariationStrength <= 0 || maximumVariationStrength > 1) return null;
229
+ return {
230
+ authority: 'toonlab-editable-control-plus-retained-high',
231
+ control,
232
+ editEnvelope: {
233
+ maximumDisplacementMetres,
234
+ maximumVariationStrength,
235
+ mode: 'bounded-source-space',
236
+ },
237
+ highDetail,
238
+ schema: 'toonlab/c8-custom-mesh-source',
239
+ semanticRegions,
240
+ version: 1,
241
+ };
242
+ }
243
+
244
+ export function isRockSourceMeshReference(reference) {
245
+ return reference?.sourceMode === 'mesh-template'
246
+ || Boolean(reference?.sourceMode === 'c8-custom-mesh' && reference.customMeshSource);
247
+ }
248
+
88
249
  function bytesToBase64(bytes) {
89
250
  let binary = '';
90
251
  const chunkSize = 0x8000;
@@ -101,9 +262,13 @@ function base64ToBytes(value) {
101
262
  return bytes;
102
263
  }
103
264
 
104
- function unpackMeshEdits(value) {
105
- if (!value || typeof value !== 'object' || value.encoding !== ROCKGEN_MESH_EDIT_ENCODING) return [];
106
- if (typeof value.data !== 'string' || value.data.length === 0) return [];
265
+ function unpackMeshEdits(value, { strict = false } = {}) {
266
+ if (value === null || value === undefined) return [];
267
+ if (typeof value !== 'object' || value.encoding !== ROCKGEN_MESH_EDIT_ENCODING
268
+ || typeof value.data !== 'string' || value.data.length === 0) {
269
+ if (strict) throw new Error('C8 packed mesh edits require the exact supported encoding and non-empty data.');
270
+ return [];
271
+ }
107
272
  try {
108
273
  const bytes = base64ToBytes(value.data);
109
274
  const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
@@ -120,30 +285,73 @@ function unpackMeshEdits(value) {
120
285
  offset += 4;
121
286
  return result;
122
287
  };
123
- const operationCount = Math.min(readUint32(), ROCKGEN_MAX_MESH_EDIT_OPERATIONS);
288
+ const operationCount = readUint32();
289
+ if (operationCount > ROCKGEN_MAX_MESH_EDIT_OPERATIONS) throw new Error('operation count exceeds the portable limit');
290
+ if (value.operationCount !== undefined && Number(value.operationCount) !== operationCount) {
291
+ throw new Error('operation count metadata does not match the packed stream');
292
+ }
124
293
  const edits = [];
125
- let remaining = ROCKGEN_MAX_MESH_EDIT_DELTAS;
126
- for (let operationIndex = 0; operationIndex < operationCount && remaining > 0; operationIndex += 1) {
294
+ let totalDeltaCount = 0;
295
+ for (let operationIndex = 0; operationIndex < operationCount; operationIndex += 1) {
127
296
  const meshIndex = readUint32();
128
297
  const storedDeltaCount = readUint32();
129
- const keptDeltaCount = Math.min(storedDeltaCount, remaining);
298
+ if (storedDeltaCount === 0 || totalDeltaCount + storedDeltaCount > ROCKGEN_MAX_MESH_EDIT_DELTAS) {
299
+ throw new Error('delta count is empty or exceeds the portable limit');
300
+ }
130
301
  const deltas = [];
131
302
  for (let deltaIndex = 0; deltaIndex < storedDeltaCount; deltaIndex += 1) {
132
303
  const delta = [readUint32(), readFloat32(), readFloat32(), readFloat32()];
133
- if (deltaIndex < keptDeltaCount) deltas.push(delta);
304
+ if (!delta.slice(1).every(Number.isFinite)) throw new Error('packed delta contains a non-finite value');
305
+ deltas.push(delta);
134
306
  }
135
- if (deltas.length > 0) edits.push({ deltas, meshIndex });
136
- remaining -= deltas.length;
307
+ edits.push({ deltas, meshIndex });
308
+ totalDeltaCount += deltas.length;
309
+ }
310
+ if (value.deltaCount !== undefined && Number(value.deltaCount) !== totalDeltaCount) {
311
+ throw new Error('delta count metadata does not match the packed stream');
312
+ }
313
+ if (offset !== view.byteLength) {
314
+ throw new Error('packed mesh-edit stream contains trailing bytes');
137
315
  }
138
316
  return edits;
139
- } catch {
317
+ } catch (error) {
318
+ if (strict) throw new Error(`Invalid C8 packed mesh edits: ${error.message}`);
140
319
  return [];
141
320
  }
142
321
  }
143
322
 
144
- function meshEditsOption(value, packedValue = null) {
145
- const source = Array.isArray(value) ? value : unpackMeshEdits(packedValue);
323
+ function meshEditsOption(value, packedValue = null, { strict = false } = {}) {
324
+ if (strict && Array.isArray(value) && packedValue) {
325
+ throw new Error('C8 mesh edits must use either the unpacked or packed representation, never both.');
326
+ }
327
+ const source = Array.isArray(value) ? value : unpackMeshEdits(packedValue, { strict });
146
328
  if (!Array.isArray(source)) return [];
329
+ if (strict) {
330
+ if (source.length > ROCKGEN_MAX_MESH_EDIT_OPERATIONS) {
331
+ throw new Error('C8 sparse sculpt history exceeds the portable operation limit.');
332
+ }
333
+ let deltaCount = 0;
334
+ return source.map((entry) => {
335
+ if (!entry || typeof entry !== 'object' || !Array.isArray(entry.deltas) || entry.deltas.length === 0
336
+ || !Number.isInteger(entry.meshIndex) || entry.meshIndex < 0) {
337
+ throw new Error('C8 mesh edits require a non-negative integer mesh index and non-empty delta array.');
338
+ }
339
+ const deltas = entry.deltas.map((delta) => {
340
+ if (!Array.isArray(delta) || delta.length !== 4
341
+ || !Number.isInteger(delta[0]) || delta[0] < 0
342
+ || !delta.slice(1).every(Number.isFinite)
343
+ || (delta[1] === 0 && delta[2] === 0 && delta[3] === 0)) {
344
+ throw new Error('C8 mesh edits require exact [vertexIndex, x, y, z] finite non-zero deltas.');
345
+ }
346
+ deltaCount += 1;
347
+ if (deltaCount > ROCKGEN_MAX_MESH_EDIT_DELTAS) {
348
+ throw new Error('C8 sparse sculpt history exceeds the portable delta limit.');
349
+ }
350
+ return [delta[0], Number(delta[1]), Number(delta[2]), Number(delta[3])];
351
+ });
352
+ return { deltas, meshIndex: entry.meshIndex };
353
+ });
354
+ }
147
355
  let remaining = ROCKGEN_MAX_MESH_EDIT_DELTAS;
148
356
  return source.slice(-ROCKGEN_MAX_MESH_EDIT_OPERATIONS).flatMap((entry) => {
149
357
  if (remaining <= 0) return [];
@@ -167,6 +375,249 @@ function meshEditsOption(value, packedValue = null) {
167
375
  });
168
376
  }
169
377
 
378
+ function stableJsonValue(value) {
379
+ if (Array.isArray(value)) return `[${value.map(stableJsonValue).join(',')}]`;
380
+ if (value && typeof value === 'object') {
381
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJsonValue(value[key])}`).join(',')}}`;
382
+ }
383
+ return JSON.stringify(value);
384
+ }
385
+
386
+ function fnv1a64(value) {
387
+ let hash = 0xcbf29ce484222325n;
388
+ for (let index = 0; index < value.length; index += 1) {
389
+ hash ^= BigInt(value.charCodeAt(index));
390
+ hash = BigInt.asUintN(64, hash * 0x100000001b3n);
391
+ }
392
+ return hash.toString(16).padStart(16, '0');
393
+ }
394
+
395
+ /** Deterministic browser-safe identity for the complete saved C8 control/high/semantic authority. */
396
+ export function createC8ReferenceAuthoritySourceId(reference) {
397
+ if (reference?.sourceMode !== 'c8-custom-mesh' || !reference.customMeshSource) return null;
398
+ return `c8-authority-v1:fnv1a64:${fnv1a64(stableJsonValue(reference.customMeshSource))}`;
399
+ }
400
+
401
+ /** Deterministic browser-safe identity for the exact live C8 control geometry. */
402
+ export function createC8ReferenceGeometrySourceId(reference) {
403
+ if (reference?.sourceMode !== 'c8-custom-mesh' || !reference.customMeshSource?.control) return null;
404
+ const payload = {
405
+ authoritySourceId: createC8ReferenceAuthoritySourceId(reference),
406
+ controlContentHash: reference.customMeshSource.control.contentHash,
407
+ controlTopologyHash: reference.customMeshSource.control.topologyHash,
408
+ meshEdits: reference.meshEdits ?? [],
409
+ variation: Number(reference.variation) || 0,
410
+ variationSeed: Math.round(Number(reference.variationSeed) || 0) >>> 0,
411
+ };
412
+ return `c8-geometry-v2:fnv1a64:${fnv1a64(stableJsonValue(payload))}`;
413
+ }
414
+
415
+ /** Surface derivative identity, separate from geometry and explicit about the C7 recipe. */
416
+ export function createC8ReferenceSurfaceSourceId(reference) {
417
+ if (reference?.sourceMode !== 'c8-custom-mesh') return null;
418
+ const payload = {
419
+ authoritySourceId: createC8ReferenceAuthoritySourceId(reference),
420
+ geometrySourceId: reference.geometrySourceId ?? createC8ReferenceGeometrySourceId(reference),
421
+ geology: String(reference.geology ?? ''),
422
+ surfaceMode: reference.surfaceMode === 'generated' ? 'generated' : 'source',
423
+ surfacePackage: reference.surfacePackage ?? null,
424
+ topFinish: reference.topFinish ?? 'source',
425
+ };
426
+ return `c8-surface-v1:fnv1a64:${fnv1a64(stableJsonValue(payload))}`;
427
+ }
428
+
429
+ function c8SurfaceReprojectionOption(value, {
430
+ authoritySourceId,
431
+ controlContentHash,
432
+ geometrySourceId,
433
+ geology,
434
+ meshEditCount,
435
+ sourceId,
436
+ sourceRevision,
437
+ surfacePackage,
438
+ surfaceSourceId,
439
+ }) {
440
+ if (value === null || value === undefined) return null;
441
+ if (!value || typeof value !== 'object'
442
+ || value.schema !== 'toonlab/c8-surface-reprojection'
443
+ || Number(value.version) !== 1
444
+ || value.method !== 'deterministic-metre-triplanar-map-reprojection'
445
+ || value.trueHighToLowBake !== false) {
446
+ throw new Error('C8 surface reprojection metadata must use the deterministic metre-triplanar schema and can never claim a true high-to-low bake.');
447
+ }
448
+ if (Number(value.sourceRevision) !== sourceRevision) {
449
+ throw new Error('C8 surface reprojection metadata is stale for the saved source revision.');
450
+ }
451
+ if (value.controlContentHash && value.controlContentHash !== controlContentHash) {
452
+ throw new Error('C8 surface reprojection metadata is stale for the admitted control GLB.');
453
+ }
454
+ if (value.geometrySourceId && value.geometrySourceId !== geometrySourceId) {
455
+ throw new Error('C8 surface reprojection metadata is stale for the saved geometry content ID.');
456
+ }
457
+ if (value.authoritySourceId && value.authoritySourceId !== authoritySourceId) {
458
+ throw new Error('C8 surface reprojection metadata is stale for the complete control/high authority.');
459
+ }
460
+ if (value.surfaceSourceId && value.surfaceSourceId !== surfaceSourceId) {
461
+ throw new Error('C8 surface reprojection metadata is stale for the saved surface recipe.');
462
+ }
463
+ const dimensionsMetres = vector3Option(value.dimensionsMetres ?? value.projection?.dimensionsMetres, [0, 0, 0]);
464
+ if (dimensionsMetres.some((entry) => entry <= 0)) throw new Error('C8 surface reprojection requires positive metre dimensions.');
465
+ const projection = value.projection;
466
+ const scaleMetres = Number(projection?.scaleMetres);
467
+ const axisScale = vector3Option(projection?.axisScale, [0, 0, 0]);
468
+ if (projection?.mode !== 'world-metre-triplanar-isotropic'
469
+ || !Number.isFinite(scaleMetres) || scaleMetres <= 0
470
+ || axisScale.some((entry) => Math.abs(entry - scaleMetres) > 1e-9)) {
471
+ throw new Error('C8 surface reprojection must remain metre-isotropic on all three axes.');
472
+ }
473
+ const expectedProjection = resolveC7Projection({ dimensionsMetres, geology });
474
+ if (stableJsonValue(projection) !== stableJsonValue(expectedProjection)) {
475
+ throw new Error('C8 surface reprojection projection must be deterministically derived from its saved metre dimensions and geology.');
476
+ }
477
+ const mapResolution = Math.round(Number(value.mapResolution));
478
+ if (!Number.isInteger(mapResolution) || mapResolution < 32 || mapResolution > 2048) {
479
+ throw new Error('C8 surface reprojection resolution must be between 32 and 2048.');
480
+ }
481
+ const resolvedGeology = String(value.geology ?? '').trim();
482
+ if (!resolvedGeology || resolvedGeology !== geology) throw new Error('C8 surface reprojection geology does not match its saved surface package.');
483
+ const resolvedSeed = Math.round(Number(value.seed) || 0) >>> 0;
484
+ if (!surfacePackage || surfacePackage.geology !== resolvedGeology
485
+ || surfacePackage.mapResolution !== mapResolution || surfacePackage.seed !== resolvedSeed) {
486
+ throw new Error('C8 surface reprojection must exactly match the saved C7 geology surface package.');
487
+ }
488
+ const record = {
489
+ authoritySourceId,
490
+ controlContentHash,
491
+ dimensionsMetres,
492
+ editOperationCount: meshEditCount,
493
+ geology: resolvedGeology,
494
+ geometrySourceId,
495
+ mapResolution,
496
+ method: 'deterministic-metre-triplanar-map-reprojection',
497
+ projection: structuredClone(expectedProjection),
498
+ schema: 'toonlab/c8-surface-reprojection',
499
+ seed: resolvedSeed,
500
+ sourceContentId: geometrySourceId,
501
+ sourceId,
502
+ sourceRevision,
503
+ surfaceSourceId,
504
+ trueHighToLowBake: false,
505
+ version: 1,
506
+ };
507
+ return { ...record, reprojectionContentId: `c8-reprojection-v1:fnv1a64:${fnv1a64(stableJsonValue(record))}` };
508
+ }
509
+
510
+ function portableDerivedArtifactState(value, {
511
+ authoritySourceId,
512
+ customMeshSource,
513
+ geometrySourceId,
514
+ sourceRevision,
515
+ surfaceReprojection,
516
+ surfaceSourceId,
517
+ }) {
518
+ const source = value && typeof value === 'object' ? value : {};
519
+ const result = {};
520
+ for (const role of ['collision', 'geometricResidual', 'lods', 'pbrBake', 'runtimePackage']) {
521
+ const entry = source[role] && typeof source[role] === 'object' ? source[role] : {};
522
+ const status = entry.status === 'current' ? 'current' : 'stale';
523
+ if (status === 'current') {
524
+ const boundGeometryId = entry.geometrySourceId ?? entry.sourceGeometryContentId ?? entry.sourceContentId;
525
+ if (boundGeometryId !== geometrySourceId || !contentHashOption(entry.contentHash)
526
+ || entry.authoritySourceId !== authoritySourceId
527
+ || Number(entry.sourceRevision) !== sourceRevision) {
528
+ throw new Error(`Current C8 ${role} metadata must carry a content hash bound to the exact geometry source ID.`);
529
+ }
530
+ if (['pbrBake', 'runtimePackage'].includes(role) && entry.surfaceSourceId !== surfaceSourceId) {
531
+ throw new Error(`Current C8 ${role} metadata must be bound to the exact surface recipe ID.`);
532
+ }
533
+ if (role === 'pbrBake' && (entry.method !== 'blender-high-to-low-bake'
534
+ || entry.trueHighToLowBake !== true
535
+ || entry.highDetailContentHash !== customMeshSource.highDetail.contentHash)) {
536
+ throw new Error('A current C8 PBR bake must be an explicit Blender high-to-low bake bound to the retained high-detail GLB.');
537
+ }
538
+ if (role === 'geometricResidual' && (entry.method !== 'blender-signed-high-to-lod-residual'
539
+ || entry.highDetailContentHash !== customMeshSource.highDetail.contentHash)) {
540
+ throw new Error('A current C8 geometric residual must be an explicit Blender signed high-to-LOD residual bound to the retained high-detail GLB.');
541
+ }
542
+ }
543
+ result[role] = {
544
+ authoritySourceId,
545
+ contentHash: contentHashOption(entry.contentHash),
546
+ geometrySourceId,
547
+ reason: String(entry.reason ?? (status === 'current' ? 'verified-derived-artifact' : 'geometry-source-not-baked')),
548
+ sourceRevision,
549
+ status,
550
+ ...(['pbrBake', 'runtimePackage'].includes(role) ? { surfaceSourceId } : {}),
551
+ ...(role === 'pbrBake' && status === 'current' ? {
552
+ highDetailContentHash: customMeshSource.highDetail.contentHash,
553
+ method: 'blender-high-to-low-bake',
554
+ trueHighToLowBake: true,
555
+ } : {}),
556
+ ...(role === 'geometricResidual' && status === 'current' ? {
557
+ highDetailContentHash: customMeshSource.highDetail.contentHash,
558
+ method: 'blender-signed-high-to-lod-residual',
559
+ } : {}),
560
+ };
561
+ }
562
+ result.surfaceReprojection = surfaceReprojection
563
+ ? {
564
+ authoritySourceId,
565
+ contentHash: surfaceReprojection.reprojectionContentId,
566
+ geometrySourceId,
567
+ method: surfaceReprojection.method,
568
+ sourceRevision,
569
+ status: 'current',
570
+ surfaceSourceId,
571
+ trueHighToLowBake: false,
572
+ }
573
+ : {
574
+ authoritySourceId,
575
+ contentHash: null,
576
+ geometrySourceId,
577
+ reason: 'geometry-source-not-reprojected',
578
+ sourceRevision,
579
+ status: 'stale',
580
+ surfaceSourceId,
581
+ trueHighToLowBake: false,
582
+ };
583
+ return result;
584
+ }
585
+
586
+ function validateC8MeshEditSequence(meshEdits, customMeshSource) {
587
+ const maximumDisplacementMetres = customMeshSource.editEnvelope.maximumDisplacementMetres;
588
+ const meshVertexCounts = customMeshSource.control.geometryAudit.meshVertexCounts;
589
+ const accumulated = new Map();
590
+ let deltaCount = 0;
591
+ for (const edit of meshEdits) {
592
+ const meshVertexCount = meshVertexCounts[edit.meshIndex];
593
+ if (!Number.isInteger(meshVertexCount)) {
594
+ throw new Error(`C8 sparse sculpt references missing control mesh ${edit.meshIndex}.`);
595
+ }
596
+ const operationVertices = new Set();
597
+ for (const [vertexIndex, x, y, z] of edit.deltas) {
598
+ if (vertexIndex >= meshVertexCount) {
599
+ throw new Error(`C8 sparse sculpt references missing vertex ${vertexIndex} on control mesh ${edit.meshIndex}.`);
600
+ }
601
+ if (operationVertices.has(vertexIndex)) {
602
+ throw new Error('C8 sparse sculpt operations may target each vertex only once.');
603
+ }
604
+ operationVertices.add(vertexIndex);
605
+ deltaCount += 1;
606
+ const key = `${edit.meshIndex}:${vertexIndex}`;
607
+ const prior = accumulated.get(key) ?? [0, 0, 0];
608
+ const next = [prior[0] + x, prior[1] + y, prior[2] + z];
609
+ if (Math.hypot(...next) > maximumDisplacementMetres + 1e-9) {
610
+ throw new Error(`C8 sparse sculpt history exceeds the ${maximumDisplacementMetres} metre edit envelope.`);
611
+ }
612
+ accumulated.set(key, next);
613
+ }
614
+ }
615
+ if (meshEdits.length > ROCKGEN_MAX_MESH_EDIT_OPERATIONS || deltaCount > ROCKGEN_MAX_MESH_EDIT_DELTAS) {
616
+ throw new Error('C8 sparse sculpt history exceeds the portable operation or delta limit.');
617
+ }
618
+ return meshEdits;
619
+ }
620
+
170
621
  function packMeshEdits(value) {
171
622
  const edits = meshEditsOption(value);
172
623
  if (edits.length === 0) return null;
@@ -203,14 +654,22 @@ function packMeshEdits(value) {
203
654
 
204
655
  /**
205
656
  * Portable identity for a source-GLB project. Geometry stays outside the JSON
206
- * document; the document stores only the stable catalog id, deterministic
207
- * generation/deformation seed, and exact LOD contract needed to rebuild and
208
- * decode the first-party GLB source.
657
+ * document; the document stores either the stable catalog id or the exact C8
658
+ * control/high hashes, plus deterministic edit state needed to rebuild and
659
+ * decode the source GLB.
209
660
  */
210
661
  export function createRockReferenceIdentity(options = null) {
211
662
  if (!options || typeof options !== 'object') return null;
663
+ const c8Intent = options.sourceMode === 'c8-custom-mesh'
664
+ || (options.customMeshSource !== null && options.customMeshSource !== undefined);
212
665
  const id = String(options.id ?? '').trim();
213
- if (!id) return null;
666
+ if (!id) {
667
+ if (c8Intent) throw new Error('C8 custom-mesh documents require a stable source ID.');
668
+ return null;
669
+ }
670
+ if (c8Intent && options.sourceMode !== 'c8-custom-mesh') {
671
+ throw new Error('A C8 custom-mesh manifest cannot silently downgrade to a legacy mesh-template reference.');
672
+ }
214
673
  const lodRatios = lodRatiosOption(options.lodRatios);
215
674
  const lodTriangles = lodTrianglesOption(
216
675
  options.lodTriangles,
@@ -221,6 +680,97 @@ export function createRockReferenceIdentity(options = null) {
221
680
  .includes(options.topFinish)
222
681
  ? options.topFinish
223
682
  : 'source';
683
+ const identityLandmarks = identityLandmarksOption(options.identityLandmarks);
684
+ const customMeshSource = customMeshSourceOption(options.customMeshSource, identityLandmarks);
685
+ if (options.sourceMode === 'c8-custom-mesh' && !customMeshSource) {
686
+ throw new Error('C8 custom-mesh documents require valid content-bound control/high GLBs, landmarks, semantic regions, and edit bounds.');
687
+ }
688
+ if (options.sourceMode === 'c8-custom-mesh'
689
+ && !String(options.geology ?? options.surfacePackage?.geology ?? '').trim()) {
690
+ throw new Error('C8 custom-mesh documents require an explicit geology surface profile.');
691
+ }
692
+ const sourceMode = options.sourceMode === 'c8-custom-mesh' && customMeshSource
693
+ ? 'c8-custom-mesh'
694
+ : 'mesh-template';
695
+ const sourceRevision = positiveInteger(options.sourceRevision, 1);
696
+ if (sourceMode === 'c8-custom-mesh'
697
+ && (customMeshSource.control.sourceRevision > sourceRevision
698
+ || customMeshSource.highDetail.sourceRevision > sourceRevision)) {
699
+ throw new Error('C8 control/high artifacts cannot claim a future source revision.');
700
+ }
701
+ const surfacePackage = surfacePackageOption(options.surfacePackage);
702
+ const geology = String(options.geology ?? options.surfacePackage?.geology ?? '').trim();
703
+ if (sourceMode === 'c8-custom-mesh' && (!surfacePackage || surfacePackage.geology !== geology)) {
704
+ throw new Error('C8 custom-mesh documents require one exact saved C7 geology surface package.');
705
+ }
706
+ let meshEdits = meshEditsOption(options.meshEdits, options.meshEditsPacked, {
707
+ strict: sourceMode === 'c8-custom-mesh',
708
+ });
709
+ if (sourceMode === 'c8-custom-mesh') {
710
+ meshEdits = meshEdits.map((edit) => ({
711
+ ...edit,
712
+ deltas: edit.deltas.map(([vertexIndex, x, y, z]) => [vertexIndex, Math.fround(x), Math.fround(y), Math.fround(z)]),
713
+ }));
714
+ validateC8MeshEditSequence(meshEdits, customMeshSource);
715
+ }
716
+ const maximumVariationStrength = sourceMode === 'c8-custom-mesh'
717
+ ? customMeshSource.editEnvelope.maximumVariationStrength
718
+ : 1;
719
+ const variation = Math.min(Math.max(Number(options.variation) || 0, 0), maximumVariationStrength);
720
+ const variationSeed = Math.round(Number(options.variationSeed) || 0) >>> 0;
721
+ const surfaceMode = options.surfaceMode === 'generated' ? 'generated' : 'source';
722
+ const authoritySourceId = sourceMode === 'c8-custom-mesh'
723
+ ? createC8ReferenceAuthoritySourceId({ customMeshSource, sourceMode })
724
+ : null;
725
+ const geometrySourceId = sourceMode === 'c8-custom-mesh'
726
+ ? createC8ReferenceGeometrySourceId({
727
+ customMeshSource,
728
+ meshEdits,
729
+ sourceMode,
730
+ variation,
731
+ variationSeed,
732
+ })
733
+ : null;
734
+ const surfaceSourceId = sourceMode === 'c8-custom-mesh'
735
+ ? createC8ReferenceSurfaceSourceId({
736
+ customMeshSource,
737
+ geology,
738
+ geometrySourceId,
739
+ sourceMode,
740
+ surfaceMode,
741
+ surfacePackage,
742
+ topFinish,
743
+ })
744
+ : null;
745
+ if (sourceMode === 'c8-custom-mesh'
746
+ && ((options.authoritySourceId && options.authoritySourceId !== authoritySourceId)
747
+ || (options.geometrySourceId && options.geometrySourceId !== geometrySourceId)
748
+ || (options.surfaceSourceId && options.surfaceSourceId !== surfaceSourceId))) {
749
+ throw new Error('Saved C8 authority, geometry, or surface identity is stale for the admitted source and edits.');
750
+ }
751
+ const surfaceReprojection = sourceMode === 'c8-custom-mesh'
752
+ ? c8SurfaceReprojectionOption(options.surfaceReprojection, {
753
+ authoritySourceId,
754
+ controlContentHash: customMeshSource.control.contentHash,
755
+ geology,
756
+ geometrySourceId,
757
+ meshEditCount: meshEdits.length,
758
+ sourceId: id,
759
+ sourceRevision,
760
+ surfacePackage,
761
+ surfaceSourceId,
762
+ })
763
+ : null;
764
+ const derivedArtifactState = sourceMode === 'c8-custom-mesh'
765
+ ? portableDerivedArtifactState(options.derivedArtifactState, {
766
+ authoritySourceId,
767
+ customMeshSource,
768
+ geometrySourceId,
769
+ sourceRevision,
770
+ surfaceReprojection,
771
+ surfaceSourceId,
772
+ })
773
+ : null;
224
774
  return {
225
775
  archetype: String(options.archetype ?? '').trim(),
226
776
  catalogVersion: positiveInteger(options.catalogVersion, 1),
@@ -230,16 +780,29 @@ export function createRockReferenceIdentity(options = null) {
230
780
  ? lodTriangles.map((triangles) => triangles / lodTriangles[0])
231
781
  : lodRatios,
232
782
  lodTriangles,
233
- meshEdits: meshEditsOption(options.meshEdits, options.meshEditsPacked),
783
+ meshEdits,
784
+ ...(sourceMode === 'c8-custom-mesh' ? {
785
+ authoritySourceId,
786
+ customMeshSource,
787
+ derivedArtifactState,
788
+ geology,
789
+ geometrySourceId,
790
+ identityLandmarks,
791
+ label: String(options.label ?? id),
792
+ sourceRevision,
793
+ surfaceSourceId,
794
+ surfaceReprojection,
795
+ thumbnailUrl: String(options.thumbnailUrl ?? ''),
796
+ } : {}),
234
797
  role: String(options.role ?? '').trim(),
235
798
  series: String(options.series ?? '').trim(),
236
- sourceMode: 'mesh-template',
237
- surfacePackage: surfacePackageOption(options.surfacePackage),
238
- surfaceMode: options.surfaceMode === 'generated' ? 'generated' : 'source',
799
+ sourceMode,
800
+ surfacePackage,
801
+ surfaceMode,
239
802
  targetTriangles: positiveInteger(options.targetTriangles, lodTriangles[0] ?? 0),
240
803
  topFinish,
241
- variation: Math.min(Math.max(Number(options.variation) || 0, 0), 1),
242
- variationSeed: Math.round(Number(options.variationSeed) || 0) >>> 0,
804
+ variation,
805
+ variationSeed,
243
806
  };
244
807
  }
245
808
 
@@ -507,7 +1070,7 @@ export function computeDocumentBounds(document) {
507
1070
  /** Serializes a document to JSON (dropping the runtime `revision`). */
508
1071
  export function serializeRockDocument(document, { pretty = false } = {}) {
509
1072
  const { revision, ...serializable } = document;
510
- if (serializable.reference?.sourceMode === 'mesh-template') {
1073
+ if (isRockSourceMeshReference(serializable.reference)) {
511
1074
  const { meshEdits, ...reference } = serializable.reference;
512
1075
  serializable.reference = {
513
1076
  ...reference,
@@ -526,7 +1089,8 @@ export function serializeRockDocument(document, { pretty = false } = {}) {
526
1089
  // those deltas as float32 binary in portable JSON so normal sculpt sessions
527
1090
  // fit the hosted creation limit. v6 adds the optional deterministic C7 surface
528
1091
  // package identity used by the 480 source-mesh catalog. Existing
529
- // preset/custom projects remain reference-free.
1092
+ // preset/custom projects remain reference-free. v7 preserves the content-bound
1093
+ // C8 control/high GLBs, semantic regions, landmarks, and derivative state.
530
1094
  const MIGRATIONS = Object.freeze([
531
1095
  (document) => ({
532
1096
  ...document,
@@ -571,6 +1135,20 @@ const MIGRATIONS = Object.freeze([
571
1135
  : null,
572
1136
  schemaVersion: 6,
573
1137
  }),
1138
+ (document) => ({
1139
+ ...document,
1140
+ reference: document.reference && typeof document.reference === 'object'
1141
+ ? {
1142
+ ...document.reference,
1143
+ customMeshSource: document.reference.customMeshSource ?? null,
1144
+ derivedArtifactState: document.reference.derivedArtifactState ?? null,
1145
+ identityLandmarks: document.reference.identityLandmarks ?? [],
1146
+ sourceRevision: document.reference.sourceRevision ?? 1,
1147
+ surfaceReprojection: document.reference.surfaceReprojection ?? null,
1148
+ }
1149
+ : null,
1150
+ schemaVersion: 7,
1151
+ }),
574
1152
  ]);
575
1153
 
576
1154
  /**