@call-me-sensei/toonlab 0.4.21 → 0.4.23

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 (129) hide show
  1. package/NPM-LIBRARY.md +87 -27
  2. package/README.md +11 -10
  3. package/agents/PROMPTS.md +17 -3
  4. package/agents/README.md +13 -3
  5. package/agents/claude/CLAUDE.md +3 -0
  6. package/agents/codex/AGENTS.md +112 -159
  7. package/agents/cursor/toonlab.mdc +7 -3
  8. package/agents/examples/game-foundation.mjs +104 -0
  9. package/agents/references/game-lifecycle.md +57 -0
  10. package/agents/references/runtime-entry-points.md +26 -0
  11. package/agents/references/style-bundles.md +4 -1
  12. package/agents/skills/claude/asset-sourcing/SKILL.md +7 -4
  13. package/agents/skills/claude/environment/SKILL.md +4 -0
  14. package/agents/skills/claude/game-dev/SKILL.md +63 -87
  15. package/agents/skills/claude/lighting/SKILL.md +36 -0
  16. package/agents/skills/claude/post-processing/SKILL.md +8 -3
  17. package/agents/skills/claude/rock-ground-shaders/SKILL.md +10 -15
  18. package/agents/skills/claude/rockgen/SKILL.md +52 -21
  19. package/agents/skills/claude/scene-style-application/SKILL.md +4 -0
  20. package/agents/skills/claude/style-presets/SKILL.md +11 -8
  21. package/agents/skills/claude/toon-shading/SKILL.md +4 -0
  22. package/agents/skills/claude/vegetation-sky/SKILL.md +13 -7
  23. package/agents/skills/claude/visual-verification/SKILL.md +45 -119
  24. package/agents/skills/claude/water/SKILL.md +6 -2
  25. package/agents/skills/codex/asset-sourcing/SKILL.md +7 -4
  26. package/agents/skills/codex/environment/SKILL.md +4 -0
  27. package/agents/skills/codex/game-dev/SKILL.md +63 -87
  28. package/agents/skills/codex/lighting/SKILL.md +36 -0
  29. package/agents/skills/codex/post-processing/SKILL.md +8 -3
  30. package/agents/skills/codex/rock-ground-shaders/SKILL.md +10 -15
  31. package/agents/skills/codex/rockgen/SKILL.md +52 -21
  32. package/agents/skills/codex/scene-style-application/SKILL.md +4 -0
  33. package/agents/skills/codex/style-presets/SKILL.md +11 -8
  34. package/agents/skills/codex/toon-shading/SKILL.md +4 -0
  35. package/agents/skills/codex/vegetation-sky/SKILL.md +13 -7
  36. package/agents/skills/codex/visual-verification/SKILL.md +45 -119
  37. package/agents/skills/codex/water/SKILL.md +6 -2
  38. package/database/apply-one-catalog-seed.mjs +63 -0
  39. package/database/providers.mjs +1 -0
  40. package/database/seeds/catalog/0005_2026-08-c8-first12.sql +1101 -0
  41. package/database/seeds/catalog/0006_2026-09-c8-first100.sql +14245 -0
  42. package/database/seeds/catalog/0007_2026-09-c8-first100-primary-model.sql +14845 -0
  43. package/mcp/lab-management.mjs +11 -8
  44. package/mcp/public-catalog.mjs +0 -16
  45. package/mcp/tree-lab-contract.mjs +2381 -0
  46. package/mcp/vite-plugin.mjs +5 -1
  47. package/package.json +29 -18
  48. package/src/catalog/officialCatalog.js +1 -0
  49. package/src/catalog/officialCatalogAssetRuntime.js +98 -1
  50. package/src/catalog/officialCatalogLod.js +160 -16
  51. package/src/catalog/officialCatalogPlacement.js +52 -12
  52. package/src/catalog/officialCatalogProvider.js +79 -8
  53. package/src/catalog/officialCatalogRockPackage.js +172 -0
  54. package/src/cloud/cloudReprojection.js +40 -7
  55. package/src/cloud/cloudVolume.js +44 -7
  56. package/src/cloud/noise/baseShapeVolume.js +8 -6
  57. package/src/cloud/noise/cirrusMap.js +2 -1
  58. package/src/cloud/noise/curlNoise.js +2 -1
  59. package/src/cloud/noise/erosionVolume.js +2 -1
  60. package/src/cloud/noise/textureCache.js +77 -0
  61. package/src/cloud/noise/weatherMap.js +2 -1
  62. package/src/core/sha256.js +83 -0
  63. package/src/ground-shader/groundShaderMaterial.js +12 -3
  64. package/src/rock-shader/rockMaterial.js +205 -28
  65. package/src/rock-shader/rockShaderRuntime.js +17 -1
  66. package/src/rock-shader/rockShaderSettings.js +16 -0
  67. package/src/rockgen/index.js +9 -0
  68. package/src/rockgen/lod/index.js +1 -0
  69. package/src/rockgen/lod/rockDenseFieldPolicy.js +143 -0
  70. package/src/rockgen/rockDocument.js +899 -35
  71. package/src/rockgen/surface/c7GeologySurface.js +39 -9
  72. package/src/rockgen/surface/naturalRockSurface.js +1948 -0
  73. package/src/shaders-tsl/chunks/projected-water-caustics.js +14 -2
  74. package/src/shaders-tsl/chunks/water-foam.js +6 -6
  75. package/src/shaders-tsl/chunks/water-lighting.js +28 -5
  76. package/src/shaders-tsl/chunks/water-shore-state.js +5 -2
  77. package/src/shaders-tsl/chunks/water-waves.js +10 -5
  78. package/src/shaders-tsl/flower.js +7 -4
  79. package/src/shaders-tsl/grass.js +4 -3
  80. package/src/shaders-tsl/water-shore-state-simulation.js +3 -2
  81. package/src/shaders-tsl/water.js +209 -41
  82. package/src/sky/skySystem.js +30 -7
  83. package/src/toon/settings/perspectiveRemovalSettings.js +1 -1
  84. package/src/toon/toonSettings.js +74 -0
  85. package/src/vegetation/stylizedFlower.js +6 -0
  86. package/src/version.js +1 -1
  87. package/src/water/waterDetailSpectrum.js +91 -0
  88. package/src/water/waterDynamics.js +422 -0
  89. package/src/water/waterFoamParticles.js +196 -0
  90. package/src/water/waterFoamTexture.js +57 -0
  91. package/src/water/waterHydrodynamics.js +209 -0
  92. package/src/water/waterMaterial.js +1 -0
  93. package/src/water/waterRenderExtension.js +107 -0
  94. package/src/water/waterScenePasses.js +14 -6
  95. package/src/water/waterSettings.js +53 -26
  96. package/src/water/waterShoreMaterial.js +38 -14
  97. package/src/water/waterSpectralOcean.js +137 -0
  98. package/src/water/waterStageSettings.js +19 -1
  99. package/src/water/waterSurface.js +62 -18
  100. package/src/water/waterUnderwaterAtmosphere.js +30 -3
  101. package/types/catalog/officialCatalog.d.ts +1 -0
  102. package/types/catalog/officialCatalogPlacement.d.ts +4 -0
  103. package/types/catalog/officialCatalogRockPackage.d.ts +68 -0
  104. package/types/cloud/noise/baseShapeVolume.d.ts +1 -1
  105. package/types/cloud/noise/textureCache.d.ts +12 -0
  106. package/types/core/sha256.d.ts +2 -0
  107. package/types/index.d.ts +30 -0
  108. package/types/rock-shader/rockMaterial.d.ts +3 -0
  109. package/types/rock-shader/rockShaderSettings.d.ts +2 -0
  110. package/types/rockgen/index.d.ts +1 -0
  111. package/types/rockgen/lod/index.d.ts +1 -0
  112. package/types/rockgen/lod/rockDenseFieldPolicy.d.ts +181 -0
  113. package/types/rockgen/rockDocument.d.ts +654 -71
  114. package/types/rockgen/surface/naturalRockSurface.d.ts +100 -0
  115. package/types/shaders-tsl/chunks/projected-water-caustics.d.ts +7 -1
  116. package/types/shaders-tsl/chunks/water-lighting.d.ts +4 -3
  117. package/types/shaders-tsl/water.d.ts +2 -1
  118. package/types/version.d.ts +1 -1
  119. package/types/water/waterDetailSpectrum.d.ts +13 -0
  120. package/types/water/waterDynamics.d.ts +89 -0
  121. package/types/water/waterFoamParticles.d.ts +32 -0
  122. package/types/water/waterFoamTexture.d.ts +1 -0
  123. package/types/water/waterHydrodynamics.d.ts +49 -0
  124. package/types/water/waterRenderExtension.d.ts +16 -0
  125. package/types/water/waterScenePasses.d.ts +1 -1
  126. package/types/water/waterSettings.d.ts +1 -0
  127. package/types/water/waterSpectralOcean.d.ts +17 -0
  128. package/types/water/waterStageSettings.d.ts +1 -1
  129. package/types/water/waterUnderwaterAtmosphere.d.ts +19 -4
@@ -19,18 +19,19 @@ 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 = 9;
28
29
 
29
- /** Sparse source-mesh edits are packed into the portable document so normal
30
- * sculpting remains below the hosted creation-document limit. Runtime state
31
- * is still the convenient array form consumed by the editor and compiler. */
30
+ /** Legacy MCP request budgets. The interactive Rock Lab editor no longer uses
31
+ * these as sculpt, drill, persistence, or replay ceilings. */
32
32
  export const ROCKGEN_MAX_MESH_EDIT_OPERATIONS = 200;
33
33
  export const ROCKGEN_MAX_MESH_EDIT_DELTAS = 10_000;
34
+ export const ROCKGEN_MAX_MESH_CUT_OPERATIONS = 64;
34
35
  export const ROCKGEN_MESH_EDIT_ENCODING = 'base64-f32le-v1';
35
36
 
36
37
  const COMBINE_OPS = Object.freeze(['union', 'smoothUnion', 'subtract', 'intersect']);
@@ -72,6 +73,49 @@ 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
+ }
96
+ const schema = String(value.schema ?? '').trim();
97
+ const profileId = String(value.profileId ?? '').trim();
98
+ const assetId = String(value.assetId ?? '').trim();
99
+ const projectionMode = String(value.projection?.mode ?? '').trim();
100
+ const projectionScaleMetres = Number(value.projection?.scaleMetres);
101
+ const genericMapResolution = Math.round(Number(value.mapResolution));
102
+ if (schema.startsWith('toonlab/')
103
+ && Number.isInteger(c8SurfaceVersion) && c8SurfaceVersion >= 1
104
+ && assetId && profileId
105
+ && ['triplanar', 'directional-bedding', 'world-metre-triplanar-isotropic'].includes(projectionMode)
106
+ && Number.isFinite(projectionScaleMetres) && projectionScaleMetres > 0
107
+ && Number.isInteger(genericMapResolution)
108
+ && genericMapResolution >= 32 && genericMapResolution <= 4096) {
109
+ return {
110
+ ...structuredClone(value),
111
+ assetId,
112
+ mapResolution: genericMapResolution,
113
+ profileId,
114
+ schema,
115
+ seed: Math.round(Number(value.seed) || 0) >>> 0,
116
+ version: c8SurfaceVersion,
117
+ };
118
+ }
75
119
  if (value.schema !== 'toonlab/c7-geology-surface' || Number(value.version) !== 1) return null;
76
120
  const geology = String(value.geology ?? '').trim();
77
121
  if (!geology) return null;
@@ -85,6 +129,146 @@ function surfacePackageOption(value) {
85
129
  };
86
130
  }
87
131
 
132
+ const SHA256_CONTENT_HASH = /^sha256:[0-9a-f]{64}$/u;
133
+
134
+ function contentHashOption(value) {
135
+ return SHA256_CONTENT_HASH.test(value ?? '') ? value : null;
136
+ }
137
+
138
+ function customMeshArtifactOption(value, role) {
139
+ if (!value || typeof value !== 'object' || value.role !== role) return null;
140
+ if (value.mimeType !== 'model/gltf-binary' || value.unit !== 'metre'
141
+ || Number(value.metreUnitScale) !== 1 || value.coordinateSystem !== 'right-handed-y-up') return null;
142
+ const dimensionsMetres = vector3Option(value.dimensionsMetres, [0, 0, 0]);
143
+ const min = vector3Option(value.boundsMetres?.min, [0, 0, 0]);
144
+ const max = vector3Option(value.boundsMetres?.max, [0, 0, 0]);
145
+ const contentHash = contentHashOption(value.contentHash);
146
+ const topologyHash = contentHashOption(value.topologyHash);
147
+ const uri = String(value.uri ?? '').trim();
148
+ const id = String(value.id ?? '').trim();
149
+ const byteLength = positiveInteger(value.byteLength, 0);
150
+ const geometryAudit = {
151
+ meshVertexCounts: Array.isArray(value.geometryAudit?.meshVertexCounts)
152
+ ? value.geometryAudit.meshVertexCounts.map((entry) => positiveInteger(entry, 0))
153
+ : [],
154
+ topologyHash: contentHashOption(value.geometryAudit?.topologyHash),
155
+ triangleCount: positiveInteger(value.geometryAudit?.triangleCount, 0),
156
+ vertexCount: positiveInteger(value.geometryAudit?.vertexCount, 0),
157
+ };
158
+ if (!contentHash || !topologyHash || !uri.toLowerCase().endsWith('.glb')
159
+ || !id || byteLength < 20
160
+ || dimensionsMetres.some((entry) => entry <= 0)
161
+ || !min.every((entry, axis) => entry < max[axis])
162
+ || !dimensionsMetres.every((entry, axis) => (
163
+ Math.abs(entry - (max[axis] - min[axis])) <= Math.max(1e-6, entry * 1e-5)
164
+ ))
165
+ || geometryAudit.topologyHash !== topologyHash
166
+ || geometryAudit.vertexCount < 4 || geometryAudit.triangleCount < 4
167
+ || geometryAudit.meshVertexCounts.length === 0
168
+ || geometryAudit.meshVertexCounts.some((entry) => entry <= 0)) return null;
169
+ return {
170
+ boundsMetres: { max, min },
171
+ byteLength,
172
+ contentHash,
173
+ coordinateSystem: 'right-handed-y-up',
174
+ dimensionsMetres,
175
+ geometryAudit: { ...geometryAudit, topologyHash },
176
+ id,
177
+ metreUnitScale: 1,
178
+ mimeType: 'model/gltf-binary',
179
+ role,
180
+ sourceRevision: positiveInteger(value.sourceRevision, 1),
181
+ topologyHash,
182
+ unit: 'metre',
183
+ uri,
184
+ };
185
+ }
186
+
187
+ function identityLandmarksOption(value) {
188
+ if (!Array.isArray(value)) return [];
189
+ return value.flatMap((entry) => {
190
+ const id = String(entry?.id ?? '').trim();
191
+ if (!id) return [];
192
+ return [{
193
+ id,
194
+ positionMetres: vector3Option(entry.positionMetres, [0, 0, 0]),
195
+ role: String(entry.role ?? 'identity').trim(),
196
+ }];
197
+ });
198
+ }
199
+
200
+ function semanticRegionsOption(value, landmarkIds) {
201
+ if (!Array.isArray(value)) return [];
202
+ return value.flatMap((entry) => {
203
+ const id = String(entry?.id ?? '').trim();
204
+ const attribute = String(entry?.selector?.attribute ?? '').trim();
205
+ const selectorValue = Math.round(Number(entry?.selector?.value));
206
+ if (!id || !/^_[A-Z0-9_]+$/u.test(attribute) || !Number.isInteger(selectorValue) || selectorValue < 0) return [];
207
+ return [{
208
+ id,
209
+ label: String(entry.label ?? id),
210
+ landmarkIds: Array.isArray(entry.landmarkIds)
211
+ ? entry.landmarkIds.map(String).filter((landmarkId) => landmarkIds.has(landmarkId))
212
+ : [],
213
+ role: String(entry.role ?? 'primary-mass'),
214
+ selector: { attribute, kind: 'gltf-attribute', value: selectorValue },
215
+ }];
216
+ });
217
+ }
218
+
219
+ function customMeshSourceOption(value, identityLandmarks) {
220
+ if (!value || value.schema !== 'toonlab/c8-custom-mesh-source' || Number(value.version) !== 1) return null;
221
+ if (value.authority !== 'toonlab-editable-control-plus-retained-high') return null;
222
+ const control = customMeshArtifactOption(value.control, 'editable-control');
223
+ const highDetail = customMeshArtifactOption(value.highDetail, 'retained-high-detail');
224
+ if (!control || !highDetail || !control.id || !highDetail.id || control.id === highDetail.id
225
+ || control.contentHash === highDetail.contentHash
226
+ || control.geometryAudit.meshVertexCounts.some((entry) => entry <= 0)
227
+ || highDetail.geometryAudit.meshVertexCounts.some((entry) => entry <= 0)
228
+ || control.geometryAudit.meshVertexCounts.reduce((sum, entry) => sum + entry, 0) !== control.geometryAudit.vertexCount
229
+ || highDetail.geometryAudit.meshVertexCounts.reduce((sum, entry) => sum + entry, 0) !== highDetail.geometryAudit.vertexCount
230
+ || highDetail.geometryAudit.vertexCount <= control.geometryAudit.vertexCount
231
+ || highDetail.geometryAudit.triangleCount <= control.geometryAudit.triangleCount
232
+ || control.byteLength < 20 || highDetail.byteLength < 20) return null;
233
+ const landmarkIds = new Set(identityLandmarks.map((landmark) => landmark.id));
234
+ if (landmarkIds.size < 3 || landmarkIds.size !== identityLandmarks.length) return null;
235
+ const semanticRegions = semanticRegionsOption(value.semanticRegions, landmarkIds);
236
+ const maximumDisplacementMetres = Number(value.editEnvelope?.maximumDisplacementMetres);
237
+ const maximumVariationStrength = Number(value.editEnvelope?.maximumVariationStrength);
238
+ const semanticRegionIds = new Set(semanticRegions.map((region) => region.id));
239
+ const semanticSelectors = new Set(semanticRegions.map((region) => `${region.selector.attribute}:${region.selector.value}`));
240
+ const supportToleranceMetres = Math.max(...control.dimensionsMetres) * 1e-5;
241
+ const highCorrespondenceToleranceMetres = Math.max(...control.dimensionsMetres) * 0.05;
242
+ if (semanticRegions.length < 3 || semanticRegionIds.size !== semanticRegions.length
243
+ || semanticSelectors.size !== semanticRegions.length
244
+ || !semanticRegions.some((region) => region.role === 'base-support')
245
+ || Math.abs(control.boundsMetres.min[1]) > Math.max(1e-6, supportToleranceMetres)
246
+ || ['min', 'max'].some((key) => control.boundsMetres[key].some((entry, axis) => (
247
+ Math.abs(entry - highDetail.boundsMetres[key][axis]) > highCorrespondenceToleranceMetres
248
+ )))
249
+ || !Number.isFinite(maximumDisplacementMetres)
250
+ || maximumDisplacementMetres <= 0 || !Number.isFinite(maximumVariationStrength)
251
+ || maximumVariationStrength <= 0 || maximumVariationStrength > 1) return null;
252
+ return {
253
+ authority: 'toonlab-editable-control-plus-retained-high',
254
+ control,
255
+ editEnvelope: {
256
+ maximumDisplacementMetres,
257
+ maximumVariationStrength,
258
+ mode: 'bounded-source-space',
259
+ },
260
+ highDetail,
261
+ schema: 'toonlab/c8-custom-mesh-source',
262
+ semanticRegions,
263
+ version: 1,
264
+ };
265
+ }
266
+
267
+ export function isRockSourceMeshReference(reference) {
268
+ return reference?.sourceMode === 'mesh-template'
269
+ || Boolean(reference?.sourceMode === 'c8-custom-mesh' && reference.customMeshSource);
270
+ }
271
+
88
272
  function bytesToBase64(bytes) {
89
273
  let binary = '';
90
274
  const chunkSize = 0x8000;
@@ -101,9 +285,13 @@ function base64ToBytes(value) {
101
285
  return bytes;
102
286
  }
103
287
 
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 [];
288
+ function unpackMeshEdits(value, { strict = false } = {}) {
289
+ if (value === null || value === undefined) return [];
290
+ if (typeof value !== 'object' || value.encoding !== ROCKGEN_MESH_EDIT_ENCODING
291
+ || typeof value.data !== 'string' || value.data.length === 0) {
292
+ if (strict) throw new Error('C8 packed mesh edits require the exact supported encoding and non-empty data.');
293
+ return [];
294
+ }
107
295
  try {
108
296
  const bytes = base64ToBytes(value.data);
109
297
  const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
@@ -120,35 +308,65 @@ function unpackMeshEdits(value) {
120
308
  offset += 4;
121
309
  return result;
122
310
  };
123
- const operationCount = Math.min(readUint32(), ROCKGEN_MAX_MESH_EDIT_OPERATIONS);
311
+ const operationCount = readUint32();
312
+ if (value.operationCount !== undefined && Number(value.operationCount) !== operationCount) {
313
+ throw new Error('operation count metadata does not match the packed stream');
314
+ }
124
315
  const edits = [];
125
- let remaining = ROCKGEN_MAX_MESH_EDIT_DELTAS;
126
- for (let operationIndex = 0; operationIndex < operationCount && remaining > 0; operationIndex += 1) {
316
+ let totalDeltaCount = 0;
317
+ for (let operationIndex = 0; operationIndex < operationCount; operationIndex += 1) {
127
318
  const meshIndex = readUint32();
128
319
  const storedDeltaCount = readUint32();
129
- const keptDeltaCount = Math.min(storedDeltaCount, remaining);
320
+ if (storedDeltaCount === 0) throw new Error('delta count is empty');
130
321
  const deltas = [];
131
322
  for (let deltaIndex = 0; deltaIndex < storedDeltaCount; deltaIndex += 1) {
132
323
  const delta = [readUint32(), readFloat32(), readFloat32(), readFloat32()];
133
- if (deltaIndex < keptDeltaCount) deltas.push(delta);
324
+ if (!delta.slice(1).every(Number.isFinite)) throw new Error('packed delta contains a non-finite value');
325
+ deltas.push(delta);
134
326
  }
135
- if (deltas.length > 0) edits.push({ deltas, meshIndex });
136
- remaining -= deltas.length;
327
+ edits.push({ deltas, meshIndex });
328
+ totalDeltaCount += deltas.length;
329
+ }
330
+ if (value.deltaCount !== undefined && Number(value.deltaCount) !== totalDeltaCount) {
331
+ throw new Error('delta count metadata does not match the packed stream');
332
+ }
333
+ if (offset !== view.byteLength) {
334
+ throw new Error('packed mesh-edit stream contains trailing bytes');
137
335
  }
138
336
  return edits;
139
- } catch {
337
+ } catch (error) {
338
+ if (strict) throw new Error(`Invalid C8 packed mesh edits: ${error.message}`);
140
339
  return [];
141
340
  }
142
341
  }
143
342
 
144
- function meshEditsOption(value, packedValue = null) {
145
- const source = Array.isArray(value) ? value : unpackMeshEdits(packedValue);
343
+ function meshEditsOption(value, packedValue = null, { strict = false } = {}) {
344
+ if (strict && Array.isArray(value) && packedValue) {
345
+ throw new Error('C8 mesh edits must use either the unpacked or packed representation, never both.');
346
+ }
347
+ const source = Array.isArray(value) ? value : unpackMeshEdits(packedValue, { strict });
146
348
  if (!Array.isArray(source)) return [];
147
- let remaining = ROCKGEN_MAX_MESH_EDIT_DELTAS;
148
- return source.slice(-ROCKGEN_MAX_MESH_EDIT_OPERATIONS).flatMap((entry) => {
149
- if (remaining <= 0) return [];
349
+ if (strict) {
350
+ return source.map((entry) => {
351
+ if (!entry || typeof entry !== 'object' || !Array.isArray(entry.deltas) || entry.deltas.length === 0
352
+ || !Number.isInteger(entry.meshIndex) || entry.meshIndex < 0) {
353
+ throw new Error('C8 mesh edits require a non-negative integer mesh index and non-empty delta array.');
354
+ }
355
+ const deltas = entry.deltas.map((delta) => {
356
+ if (!Array.isArray(delta) || delta.length !== 4
357
+ || !Number.isInteger(delta[0]) || delta[0] < 0
358
+ || !delta.slice(1).every(Number.isFinite)
359
+ || (delta[1] === 0 && delta[2] === 0 && delta[3] === 0)) {
360
+ throw new Error('C8 mesh edits require exact [vertexIndex, x, y, z] finite non-zero deltas.');
361
+ }
362
+ return [delta[0], Number(delta[1]), Number(delta[2]), Number(delta[3])];
363
+ });
364
+ return { deltas, meshIndex: entry.meshIndex };
365
+ });
366
+ }
367
+ return source.flatMap((entry) => {
150
368
  if (!entry || typeof entry !== 'object' || !Array.isArray(entry.deltas)) return [];
151
- const deltas = entry.deltas.slice(0, remaining).flatMap((delta) => {
369
+ const deltas = entry.deltas.flatMap((delta) => {
152
370
  if (!Array.isArray(delta) || delta.length < 4) return [];
153
371
  const vertexIndex = Math.round(Number(delta[0]));
154
372
  const x = Number(delta[1]);
@@ -159,7 +377,6 @@ function meshEditsOption(value, packedValue = null) {
159
377
  return [[vertexIndex, x, y, z]];
160
378
  });
161
379
  if (deltas.length === 0) return [];
162
- remaining -= deltas.length;
163
380
  return [{
164
381
  deltas,
165
382
  meshIndex: Math.max(0, Math.round(Number(entry.meshIndex) || 0)),
@@ -167,6 +384,476 @@ function meshEditsOption(value, packedValue = null) {
167
384
  });
168
385
  }
169
386
 
387
+ function meshCutsOption(value, { strict = false } = {}) {
388
+ if (!Array.isArray(value)) return [];
389
+ return value.flatMap((entry) => {
390
+ if (!entry || typeof entry !== 'object') {
391
+ if (strict) throw new Error('Drill cuts must be objects.');
392
+ return [];
393
+ }
394
+ const point = Array.isArray(entry.point) ? entry.point.slice(0, 3).map(Number) : [];
395
+ const normal = Array.isArray(entry.normal) ? entry.normal.slice(0, 3).map(Number) : [];
396
+ const radius = Number(entry.radius);
397
+ const roughness = Math.min(Math.max(Number(entry.roughness) || 0, 0), 1);
398
+ const seed = Math.round(Number(entry.seed) || 0) >>> 0;
399
+ const depth = Number(entry.depth);
400
+ const meshIndex = Math.round(Number(entry.meshIndex));
401
+ const valid = point.length === 3 && normal.length === 3
402
+ && [...point, ...normal, radius, depth, meshIndex].every(Number.isFinite)
403
+ && Math.hypot(...normal) > 1e-6
404
+ && meshIndex >= 0 && radius >= 0.01
405
+ && depth >= 0.01;
406
+ if (!valid) {
407
+ if (strict) throw new Error('Drill cuts require finite point/normal vectors, mesh index, radius, and depth.');
408
+ return [];
409
+ }
410
+ const length = Math.hypot(...normal);
411
+ return [{
412
+ depth,
413
+ meshIndex,
414
+ normal: normal.map((component) => component / length),
415
+ point,
416
+ radius,
417
+ roughness,
418
+ seed,
419
+ through: Boolean(entry.through),
420
+ }];
421
+ });
422
+ }
423
+
424
+ const ROCKGEN_MESH_SNAPSHOT_FLOAT_ENCODING = 'f32-base64-le';
425
+ const ROCKGEN_MESH_SNAPSHOT_INDEX_ENCODING = 'u32-base64-le';
426
+
427
+ function unpackMeshSnapshots(value, { strict = false } = {}) {
428
+ if (value === null || value === undefined) return [];
429
+ const reject = (message) => {
430
+ if (strict) throw new Error(message);
431
+ return [];
432
+ };
433
+ if (!Array.isArray(value)) return reject('Packed mesh snapshots must be an array.');
434
+ try {
435
+ return value.map((entry) => {
436
+ const attributes = {};
437
+ for (const [name, attribute] of Object.entries(entry?.attributes ?? {})) {
438
+ if (attribute?.encoding !== ROCKGEN_MESH_SNAPSHOT_FLOAT_ENCODING
439
+ || typeof attribute.data !== 'string' || !Number.isInteger(attribute.count)
440
+ || !Number.isInteger(attribute.itemSize) || attribute.count <= 0 || attribute.itemSize <= 0) {
441
+ throw new Error(`packed attribute ${name || '(unnamed)'} is invalid`);
442
+ }
443
+ const bytes = base64ToBytes(attribute.data);
444
+ if (bytes.byteLength !== attribute.count * attribute.itemSize * 4) {
445
+ throw new Error(`packed attribute ${name} has the wrong byte length`);
446
+ }
447
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
448
+ const data = Array.from(
449
+ { length: attribute.count * attribute.itemSize },
450
+ (_, index) => view.getFloat32(index * 4, true),
451
+ );
452
+ attributes[name] = {
453
+ data,
454
+ itemSize: attribute.itemSize,
455
+ normalized: Boolean(attribute.normalized),
456
+ };
457
+ }
458
+ let index = null;
459
+ if (entry?.index !== null && entry?.index !== undefined) {
460
+ if (entry.index.encoding !== ROCKGEN_MESH_SNAPSHOT_INDEX_ENCODING
461
+ || typeof entry.index.data !== 'string' || !Number.isInteger(entry.index.count)
462
+ || entry.index.count <= 0) throw new Error('packed indices are invalid');
463
+ const bytes = base64ToBytes(entry.index.data);
464
+ if (bytes.byteLength !== entry.index.count * 4) throw new Error('packed indices have the wrong byte length');
465
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
466
+ index = Array.from({ length: entry.index.count }, (_, offset) => view.getUint32(offset * 4, true));
467
+ }
468
+ return {
469
+ attributes,
470
+ groups: entry?.groups,
471
+ index,
472
+ meshIndex: entry?.meshIndex,
473
+ ...(entry?.variationOrigin ? { variationOrigin: variationOriginOption(entry.variationOrigin) } : {}),
474
+ ...(entry?.fillPatches?.length ? { fillPatches: entry.fillPatches.map((patch) => ({
475
+ point: patch.point,
476
+ geometry: unpackMeshSnapshots([{ ...patch.geometry, fillPatches: [] }], { strict })[0],
477
+ })) } : {}),
478
+ };
479
+ });
480
+ } catch (error) {
481
+ return reject(`Invalid packed mesh snapshot: ${error.message}.`);
482
+ }
483
+ }
484
+
485
+ function variationSettingsOption(value = {}) {
486
+ const settings = {};
487
+ if (value?.version === 2) settings.version = 2;
488
+ if (value?.scope !== undefined) settings.scope = value.scope === 'component' ? 'component' : 'whole';
489
+ if (value?.anchorBase !== undefined) settings.anchorBase = value.anchorBase !== false;
490
+ if (value?.locks) settings.locks = {};
491
+ for (const key of ['width', 'height', 'depth', 'leanX', 'leanZ', 'twist', 'taper', 'bulge', 'noiseAmplitude', 'noiseFrequency']) {
492
+ if (Number.isFinite(value?.[key])) settings[key] = value[key];
493
+ if (value?.locks?.[key]) settings.locks[key] = true;
494
+ }
495
+ return settings;
496
+ }
497
+
498
+ function variationOriginOption(value) {
499
+ return value ? { strength: Number(value.strength) || 0, seed: Math.round(Number(value.seed) || 0) >>> 0,
500
+ settings: variationSettingsOption(value.settings) } : undefined;
501
+ }
502
+
503
+ function meshSnapshotsOption(value, packedValue = null, { strict = false } = {}) {
504
+ const source = Array.isArray(value) ? value : unpackMeshSnapshots(packedValue, { strict });
505
+ if (!Array.isArray(source)) return [];
506
+ return source.flatMap((entry) => {
507
+ const reject = (message) => {
508
+ if (strict) throw new Error(message);
509
+ return [];
510
+ };
511
+ if (!entry || typeof entry !== 'object' || !Number.isInteger(entry.meshIndex) || entry.meshIndex < 0
512
+ || !entry.attributes || typeof entry.attributes !== 'object') {
513
+ return reject('Mesh snapshots require a non-negative mesh index and geometry attributes.');
514
+ }
515
+ const attributes = {};
516
+ for (const [name, attribute] of Object.entries(entry.attributes)) {
517
+ const itemSize = Math.round(Number(attribute?.itemSize));
518
+ const data = Array.isArray(attribute?.data) ? attribute.data.map(Number) : [];
519
+ if (!name || itemSize < 1 || itemSize > 4 || data.length === 0
520
+ || data.length % itemSize !== 0 || !data.every(Number.isFinite)) {
521
+ return reject(`Mesh snapshot attribute ${name || '(unnamed)'} is invalid.`);
522
+ }
523
+ attributes[name] = { data, itemSize, normalized: Boolean(attribute.normalized) };
524
+ }
525
+ const position = attributes.position;
526
+ if (!position || position.itemSize !== 3) return reject('Mesh snapshots require a vec3 position attribute.');
527
+ const vertexCount = position.data.length / 3;
528
+ const index = entry.index === null || entry.index === undefined
529
+ ? null
530
+ : Array.isArray(entry.index) ? entry.index.map(Number) : [];
531
+ if (index && (index.length === 0 || index.some((value) => (
532
+ !Number.isInteger(value) || value < 0 || value >= vertexCount
533
+ )))) return reject('Mesh snapshot indices must reference existing vertices.');
534
+ const groups = Array.isArray(entry.groups) ? entry.groups.flatMap((group) => {
535
+ const start = Math.round(Number(group?.start));
536
+ const count = Math.round(Number(group?.count));
537
+ const materialIndex = Math.max(0, Math.round(Number(group?.materialIndex) || 0));
538
+ return start >= 0 && count > 0 ? [{ count, materialIndex, start }] : [];
539
+ }) : [];
540
+ const fillPatches = (Array.isArray(entry.fillPatches) ? entry.fillPatches : []).flatMap((patch) => {
541
+ if (!Array.isArray(patch?.point) || patch.point.length !== 3 || !patch.point.every(Number.isFinite)) {
542
+ return reject('Fill patches require a finite three-dimensional point.');
543
+ }
544
+ const geometry = meshSnapshotsOption([{ ...patch.geometry, fillPatches: [] }], null, { strict })[0];
545
+ return geometry ? [{ point: [...patch.point], geometry }] : [];
546
+ });
547
+ return [{ attributes, groups, index, meshIndex: entry.meshIndex,
548
+ ...(entry.variationOrigin ? { variationOrigin: variationOriginOption(entry.variationOrigin) } : {}),
549
+ ...(fillPatches.length ? { fillPatches } : {}),
550
+ }];
551
+ });
552
+ }
553
+
554
+ function packMeshSnapshots(value) {
555
+ return meshSnapshotsOption(value).map((entry) => {
556
+ const attributes = Object.fromEntries(Object.entries(entry.attributes).map(([name, attribute]) => {
557
+ const bytes = new Uint8Array(attribute.data.length * 4);
558
+ const view = new DataView(bytes.buffer);
559
+ for (let index = 0; index < attribute.data.length; index += 1) {
560
+ view.setFloat32(index * 4, attribute.data[index], true);
561
+ }
562
+ return [name, {
563
+ count: attribute.data.length / attribute.itemSize,
564
+ data: bytesToBase64(bytes),
565
+ encoding: ROCKGEN_MESH_SNAPSHOT_FLOAT_ENCODING,
566
+ itemSize: attribute.itemSize,
567
+ normalized: attribute.normalized,
568
+ }];
569
+ }));
570
+ let index = null;
571
+ if (entry.index) {
572
+ const bytes = new Uint8Array(entry.index.length * 4);
573
+ const view = new DataView(bytes.buffer);
574
+ for (let offset = 0; offset < entry.index.length; offset += 1) {
575
+ view.setUint32(offset * 4, entry.index[offset], true);
576
+ }
577
+ index = {
578
+ count: entry.index.length,
579
+ data: bytesToBase64(bytes),
580
+ encoding: ROCKGEN_MESH_SNAPSHOT_INDEX_ENCODING,
581
+ };
582
+ }
583
+ return {
584
+ attributes,
585
+ groups: entry.groups,
586
+ index,
587
+ meshIndex: entry.meshIndex,
588
+ ...(entry.variationOrigin ? { variationOrigin: variationOriginOption(entry.variationOrigin) } : {}),
589
+ ...(entry.fillPatches?.length ? { fillPatches: entry.fillPatches.map((patch) => ({
590
+ point: patch.point,
591
+ geometry: packMeshSnapshots([patch.geometry])[0],
592
+ })) } : {}),
593
+ };
594
+ });
595
+ }
596
+
597
+ function meshOperationOrderOption(value, meshEdits, meshCuts, { strict = false } = {}) {
598
+ const fallback = [
599
+ ...meshEdits.map((_, index) => ({ index, type: 'sculpt' })),
600
+ ...meshCuts.map((_, index) => ({ index, type: 'drill' })),
601
+ ];
602
+ if (!Array.isArray(value)) return fallback;
603
+ const seen = new Set();
604
+ const normalized = [];
605
+ for (const entry of value) {
606
+ const type = entry?.type === 'drill' ? 'drill' : entry?.type === 'sculpt' ? 'sculpt' : null;
607
+ const index = Math.round(Number(entry?.index));
608
+ const limit = type === 'drill' ? meshCuts.length : meshEdits.length;
609
+ const key = `${type}:${index}`;
610
+ if (!type || !Number.isFinite(index) || index < 0 || index >= limit || seen.has(key)) {
611
+ if (strict) throw new Error('Mesh operation order contains an invalid or duplicate operation reference.');
612
+ return fallback;
613
+ }
614
+ seen.add(key);
615
+ normalized.push({ index, type });
616
+ }
617
+ if (normalized.length !== fallback.length) {
618
+ if (strict) throw new Error('Mesh operation order must reference every sculpt and drill operation exactly once.');
619
+ return fallback;
620
+ }
621
+ return normalized;
622
+ }
623
+
624
+ function stableJsonValue(value) {
625
+ if (Array.isArray(value)) return `[${value.map(stableJsonValue).join(',')}]`;
626
+ if (value && typeof value === 'object') {
627
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJsonValue(value[key])}`).join(',')}}`;
628
+ }
629
+ return JSON.stringify(value);
630
+ }
631
+
632
+ function fnv1a64(value) {
633
+ let hash = 0xcbf29ce484222325n;
634
+ for (let index = 0; index < value.length; index += 1) {
635
+ hash ^= BigInt(value.charCodeAt(index));
636
+ hash = BigInt.asUintN(64, hash * 0x100000001b3n);
637
+ }
638
+ return hash.toString(16).padStart(16, '0');
639
+ }
640
+
641
+ /** Deterministic browser-safe identity for the complete saved C8 control/high/semantic authority. */
642
+ export function createC8ReferenceAuthoritySourceId(reference) {
643
+ if (reference?.sourceMode !== 'c8-custom-mesh' || !reference.customMeshSource) return null;
644
+ return `c8-authority-v1:fnv1a64:${fnv1a64(stableJsonValue(reference.customMeshSource))}`;
645
+ }
646
+
647
+ /** Deterministic browser-safe identity for the exact live C8 control geometry. */
648
+ export function createC8ReferenceGeometrySourceId(reference) {
649
+ if (reference?.sourceMode !== 'c8-custom-mesh' || !reference.customMeshSource?.control) return null;
650
+ const payload = {
651
+ authoritySourceId: createC8ReferenceAuthoritySourceId(reference),
652
+ controlContentHash: reference.customMeshSource.control.contentHash,
653
+ controlTopologyHash: reference.customMeshSource.control.topologyHash,
654
+ meshCuts: reference.meshCuts ?? [],
655
+ meshEdits: reference.meshEdits ?? [],
656
+ meshOperationOrder: reference.meshOperationOrder ?? [],
657
+ meshSnapshots: reference.meshSnapshots ?? [],
658
+ variation: Number(reference.variation) || 0,
659
+ variationSeed: Math.round(Number(reference.variationSeed) || 0) >>> 0,
660
+ ...(reference.variationSettings ? { variationSettings: variationSettingsOption(reference.variationSettings) } : {}),
661
+ };
662
+ return `c8-geometry-v2:fnv1a64:${fnv1a64(stableJsonValue(payload))}`;
663
+ }
664
+
665
+ /** Surface derivative identity, separate from geometry and explicit about the C7 recipe. */
666
+ export function createC8ReferenceSurfaceSourceId(reference) {
667
+ if (reference?.sourceMode !== 'c8-custom-mesh') return null;
668
+ const payload = {
669
+ authoritySourceId: createC8ReferenceAuthoritySourceId(reference),
670
+ geometrySourceId: reference.geometrySourceId ?? createC8ReferenceGeometrySourceId(reference),
671
+ geology: String(reference.geology ?? ''),
672
+ surfaceMode: reference.surfaceMode === 'generated' ? 'generated' : 'source',
673
+ surfacePackage: reference.surfacePackage ?? null,
674
+ topFinish: reference.topFinish ?? 'source',
675
+ };
676
+ return `c8-surface-v1:fnv1a64:${fnv1a64(stableJsonValue(payload))}`;
677
+ }
678
+
679
+ function c8SurfaceReprojectionOption(value, {
680
+ authoritySourceId,
681
+ controlContentHash,
682
+ geometrySourceId,
683
+ geology,
684
+ meshEditCount,
685
+ sourceId,
686
+ sourceRevision,
687
+ surfacePackage,
688
+ surfaceSourceId,
689
+ }) {
690
+ if (value === null || value === undefined) return null;
691
+ if (!value || typeof value !== 'object'
692
+ || value.schema !== 'toonlab/c8-surface-reprojection'
693
+ || Number(value.version) !== 1
694
+ || value.method !== 'deterministic-metre-triplanar-map-reprojection'
695
+ || value.trueHighToLowBake !== false) {
696
+ throw new Error('C8 surface reprojection metadata must use the deterministic metre-triplanar schema and can never claim a true high-to-low bake.');
697
+ }
698
+ if (Number(value.sourceRevision) !== sourceRevision) {
699
+ throw new Error('C8 surface reprojection metadata is stale for the saved source revision.');
700
+ }
701
+ if (value.controlContentHash && value.controlContentHash !== controlContentHash) {
702
+ throw new Error('C8 surface reprojection metadata is stale for the admitted control GLB.');
703
+ }
704
+ if (value.geometrySourceId && value.geometrySourceId !== geometrySourceId) {
705
+ throw new Error('C8 surface reprojection metadata is stale for the saved geometry content ID.');
706
+ }
707
+ if (value.authoritySourceId && value.authoritySourceId !== authoritySourceId) {
708
+ throw new Error('C8 surface reprojection metadata is stale for the complete control/high authority.');
709
+ }
710
+ if (value.surfaceSourceId && value.surfaceSourceId !== surfaceSourceId) {
711
+ throw new Error('C8 surface reprojection metadata is stale for the saved surface recipe.');
712
+ }
713
+ const dimensionsMetres = vector3Option(value.dimensionsMetres ?? value.projection?.dimensionsMetres, [0, 0, 0]);
714
+ if (dimensionsMetres.some((entry) => entry <= 0)) throw new Error('C8 surface reprojection requires positive metre dimensions.');
715
+ const projection = value.projection;
716
+ const scaleMetres = Number(projection?.scaleMetres);
717
+ const axisScale = vector3Option(projection?.axisScale, [0, 0, 0]);
718
+ if (projection?.mode !== 'world-metre-triplanar-isotropic'
719
+ || !Number.isFinite(scaleMetres) || scaleMetres <= 0
720
+ || axisScale.some((entry) => Math.abs(entry - scaleMetres) > 1e-9)) {
721
+ throw new Error('C8 surface reprojection must remain metre-isotropic on all three axes.');
722
+ }
723
+ const expectedProjection = resolveC7Projection({ dimensionsMetres, geology });
724
+ if (stableJsonValue(projection) !== stableJsonValue(expectedProjection)) {
725
+ throw new Error('C8 surface reprojection projection must be deterministically derived from its saved metre dimensions and geology.');
726
+ }
727
+ const mapResolution = Math.round(Number(value.mapResolution));
728
+ if (!Number.isInteger(mapResolution) || mapResolution < 32 || mapResolution > 2048) {
729
+ throw new Error('C8 surface reprojection resolution must be between 32 and 2048.');
730
+ }
731
+ const resolvedGeology = String(value.geology ?? '').trim();
732
+ if (!resolvedGeology || resolvedGeology !== geology) throw new Error('C8 surface reprojection geology does not match its saved surface package.');
733
+ const resolvedSeed = Math.round(Number(value.seed) || 0) >>> 0;
734
+ if (!surfacePackage || surfacePackage.geology !== resolvedGeology
735
+ || surfacePackage.mapResolution !== mapResolution || surfacePackage.seed !== resolvedSeed) {
736
+ throw new Error('C8 surface reprojection must exactly match the saved C7 geology surface package.');
737
+ }
738
+ const record = {
739
+ authoritySourceId,
740
+ controlContentHash,
741
+ dimensionsMetres,
742
+ editOperationCount: meshEditCount,
743
+ geology: resolvedGeology,
744
+ geometrySourceId,
745
+ mapResolution,
746
+ method: 'deterministic-metre-triplanar-map-reprojection',
747
+ projection: structuredClone(expectedProjection),
748
+ schema: 'toonlab/c8-surface-reprojection',
749
+ seed: resolvedSeed,
750
+ sourceContentId: geometrySourceId,
751
+ sourceId,
752
+ sourceRevision,
753
+ surfaceSourceId,
754
+ trueHighToLowBake: false,
755
+ version: 1,
756
+ };
757
+ return { ...record, reprojectionContentId: `c8-reprojection-v1:fnv1a64:${fnv1a64(stableJsonValue(record))}` };
758
+ }
759
+
760
+ function portableDerivedArtifactState(value, {
761
+ authoritySourceId,
762
+ customMeshSource,
763
+ geometrySourceId,
764
+ sourceRevision,
765
+ surfaceReprojection,
766
+ surfaceSourceId,
767
+ }) {
768
+ const source = value && typeof value === 'object' ? value : {};
769
+ const result = {};
770
+ for (const role of ['collision', 'geometricResidual', 'lods', 'pbrBake', 'runtimePackage']) {
771
+ const entry = source[role] && typeof source[role] === 'object' ? source[role] : {};
772
+ const status = entry.status === 'current' ? 'current' : 'stale';
773
+ if (status === 'current') {
774
+ const boundGeometryId = entry.geometrySourceId ?? entry.sourceGeometryContentId ?? entry.sourceContentId;
775
+ if (boundGeometryId !== geometrySourceId || !contentHashOption(entry.contentHash)
776
+ || entry.authoritySourceId !== authoritySourceId
777
+ || Number(entry.sourceRevision) !== sourceRevision) {
778
+ throw new Error(`Current C8 ${role} metadata must carry a content hash bound to the exact geometry source ID.`);
779
+ }
780
+ if (['pbrBake', 'runtimePackage'].includes(role) && entry.surfaceSourceId !== surfaceSourceId) {
781
+ throw new Error(`Current C8 ${role} metadata must be bound to the exact surface recipe ID.`);
782
+ }
783
+ if (role === 'pbrBake' && (entry.method !== 'blender-high-to-low-bake'
784
+ || entry.trueHighToLowBake !== true
785
+ || entry.highDetailContentHash !== customMeshSource.highDetail.contentHash)) {
786
+ throw new Error('A current C8 PBR bake must be an explicit Blender high-to-low bake bound to the retained high-detail GLB.');
787
+ }
788
+ if (role === 'geometricResidual' && (entry.method !== 'blender-signed-high-to-lod-residual'
789
+ || entry.highDetailContentHash !== customMeshSource.highDetail.contentHash)) {
790
+ 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.');
791
+ }
792
+ }
793
+ result[role] = {
794
+ authoritySourceId,
795
+ contentHash: contentHashOption(entry.contentHash),
796
+ geometrySourceId,
797
+ reason: String(entry.reason ?? (status === 'current' ? 'verified-derived-artifact' : 'geometry-source-not-baked')),
798
+ sourceRevision,
799
+ status,
800
+ ...(['pbrBake', 'runtimePackage'].includes(role) ? { surfaceSourceId } : {}),
801
+ ...(role === 'pbrBake' && status === 'current' ? {
802
+ highDetailContentHash: customMeshSource.highDetail.contentHash,
803
+ method: 'blender-high-to-low-bake',
804
+ trueHighToLowBake: true,
805
+ } : {}),
806
+ ...(role === 'geometricResidual' && status === 'current' ? {
807
+ highDetailContentHash: customMeshSource.highDetail.contentHash,
808
+ method: 'blender-signed-high-to-lod-residual',
809
+ } : {}),
810
+ };
811
+ }
812
+ result.surfaceReprojection = surfaceReprojection
813
+ ? {
814
+ authoritySourceId,
815
+ contentHash: surfaceReprojection.reprojectionContentId,
816
+ geometrySourceId,
817
+ method: surfaceReprojection.method,
818
+ sourceRevision,
819
+ status: 'current',
820
+ surfaceSourceId,
821
+ trueHighToLowBake: false,
822
+ }
823
+ : {
824
+ authoritySourceId,
825
+ contentHash: null,
826
+ geometrySourceId,
827
+ reason: 'geometry-source-not-reprojected',
828
+ sourceRevision,
829
+ status: 'stale',
830
+ surfaceSourceId,
831
+ trueHighToLowBake: false,
832
+ };
833
+ return result;
834
+ }
835
+
836
+ function validateC8MeshEditSequence(meshEdits, customMeshSource) {
837
+ const meshVertexCounts = customMeshSource.control.geometryAudit.meshVertexCounts;
838
+ for (const edit of meshEdits) {
839
+ const meshVertexCount = meshVertexCounts[edit.meshIndex];
840
+ if (!Number.isInteger(meshVertexCount)) {
841
+ throw new Error(`C8 sparse sculpt references missing control mesh ${edit.meshIndex}.`);
842
+ }
843
+ const operationVertices = new Set();
844
+ for (const [vertexIndex, x, y, z] of edit.deltas) {
845
+ if (vertexIndex >= meshVertexCount) {
846
+ throw new Error(`C8 sparse sculpt references missing vertex ${vertexIndex} on control mesh ${edit.meshIndex}.`);
847
+ }
848
+ if (operationVertices.has(vertexIndex)) {
849
+ throw new Error('C8 sparse sculpt operations may target each vertex only once.');
850
+ }
851
+ operationVertices.add(vertexIndex);
852
+ }
853
+ }
854
+ return meshEdits;
855
+ }
856
+
170
857
  function packMeshEdits(value) {
171
858
  const edits = meshEditsOption(value);
172
859
  if (edits.length === 0) return null;
@@ -203,14 +890,22 @@ function packMeshEdits(value) {
203
890
 
204
891
  /**
205
892
  * 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.
893
+ * document; the document stores either the stable catalog id or the exact C8
894
+ * control/high hashes, plus deterministic edit state needed to rebuild and
895
+ * decode the source GLB.
209
896
  */
210
897
  export function createRockReferenceIdentity(options = null) {
211
898
  if (!options || typeof options !== 'object') return null;
899
+ const c8Intent = options.sourceMode === 'c8-custom-mesh'
900
+ || (options.customMeshSource !== null && options.customMeshSource !== undefined);
212
901
  const id = String(options.id ?? '').trim();
213
- if (!id) return null;
902
+ if (!id) {
903
+ if (c8Intent) throw new Error('C8 custom-mesh documents require a stable source ID.');
904
+ return null;
905
+ }
906
+ if (c8Intent && options.sourceMode !== 'c8-custom-mesh') {
907
+ throw new Error('A C8 custom-mesh manifest cannot silently downgrade to a legacy mesh-template reference.');
908
+ }
214
909
  const lodRatios = lodRatiosOption(options.lodRatios);
215
910
  const lodTriangles = lodTrianglesOption(
216
911
  options.lodTriangles,
@@ -221,6 +916,115 @@ export function createRockReferenceIdentity(options = null) {
221
916
  .includes(options.topFinish)
222
917
  ? options.topFinish
223
918
  : 'source';
919
+ const identityLandmarks = identityLandmarksOption(options.identityLandmarks);
920
+ const customMeshSource = customMeshSourceOption(options.customMeshSource, identityLandmarks);
921
+ if (options.sourceMode === 'c8-custom-mesh' && !customMeshSource) {
922
+ throw new Error('C8 custom-mesh documents require valid content-bound control/high GLBs, landmarks, semantic regions, and edit bounds.');
923
+ }
924
+ if (options.sourceMode === 'c8-custom-mesh'
925
+ && !String(options.geology ?? options.surfacePackage?.geology ?? '').trim()) {
926
+ throw new Error('C8 custom-mesh documents require an explicit geology surface profile.');
927
+ }
928
+ const sourceMode = options.sourceMode === 'c8-custom-mesh' && customMeshSource
929
+ ? 'c8-custom-mesh'
930
+ : 'mesh-template';
931
+ const sourceRevision = positiveInteger(options.sourceRevision, 1);
932
+ if (sourceMode === 'c8-custom-mesh'
933
+ && (customMeshSource.control.sourceRevision > sourceRevision
934
+ || customMeshSource.highDetail.sourceRevision > sourceRevision)) {
935
+ throw new Error('C8 control/high artifacts cannot claim a future source revision.');
936
+ }
937
+ const surfacePackage = surfacePackageOption(options.surfacePackage);
938
+ const geology = String(options.geology ?? options.surfacePackage?.geology ?? '').trim();
939
+ if (sourceMode === 'c8-custom-mesh' && (!surfacePackage || surfacePackage.geology !== geology)) {
940
+ throw new Error('C8 custom-mesh documents require one exact saved C7 geology surface package.');
941
+ }
942
+ const meshCuts = meshCutsOption(options.meshCuts, { strict: true });
943
+ const meshSnapshots = meshSnapshotsOption(options.meshSnapshots, options.meshSnapshotsPacked, {
944
+ strict: options.sourceMode === 'c8-custom-mesh',
945
+ });
946
+ // Sparse edits made after a Boolean cut address the cut result rather than
947
+ // the admitted control topology, so they cannot be range-checked against
948
+ // the original manifest here. Runtime replay still audits every operation.
949
+ const hasCuts = meshCuts.length > 0;
950
+ const hasSnapshots = meshSnapshots.length > 0;
951
+ let meshEdits = meshEditsOption(options.meshEdits, options.meshEditsPacked, {
952
+ strict: sourceMode === 'c8-custom-mesh' && !hasCuts && !hasSnapshots,
953
+ });
954
+ if (sourceMode === 'c8-custom-mesh') {
955
+ meshEdits = meshEdits.map((edit) => ({
956
+ ...edit,
957
+ deltas: edit.deltas.map(([vertexIndex, x, y, z]) => [vertexIndex, Math.fround(x), Math.fround(y), Math.fround(z)]),
958
+ }));
959
+ if (!hasCuts && !hasSnapshots) validateC8MeshEditSequence(meshEdits, customMeshSource);
960
+ }
961
+ const meshOperationOrder = meshOperationOrderOption(
962
+ options.meshOperationOrder,
963
+ meshEdits,
964
+ meshCuts,
965
+ { strict: true },
966
+ );
967
+ const maximumVariationStrength = sourceMode === 'c8-custom-mesh'
968
+ ? customMeshSource.editEnvelope.maximumVariationStrength
969
+ : 1;
970
+ const variation = Math.min(Math.max(Number(options.variation) || 0, 0), maximumVariationStrength);
971
+ const variationSeed = Math.round(Number(options.variationSeed) || 0) >>> 0;
972
+ const surfaceMode = options.surfaceMode === 'generated' ? 'generated' : 'source';
973
+ const authoritySourceId = sourceMode === 'c8-custom-mesh'
974
+ ? createC8ReferenceAuthoritySourceId({ customMeshSource, sourceMode })
975
+ : null;
976
+ const geometrySourceId = sourceMode === 'c8-custom-mesh'
977
+ ? createC8ReferenceGeometrySourceId({
978
+ customMeshSource,
979
+ meshCuts,
980
+ meshEdits,
981
+ meshOperationOrder,
982
+ meshSnapshots,
983
+ sourceMode,
984
+ variation,
985
+ variationSeed,
986
+ })
987
+ : null;
988
+ const surfaceSourceId = sourceMode === 'c8-custom-mesh'
989
+ ? createC8ReferenceSurfaceSourceId({
990
+ customMeshSource,
991
+ geology,
992
+ geometrySourceId,
993
+ sourceMode,
994
+ surfaceMode,
995
+ surfacePackage,
996
+ topFinish,
997
+ })
998
+ : null;
999
+ if (sourceMode === 'c8-custom-mesh'
1000
+ && ((options.authoritySourceId && options.authoritySourceId !== authoritySourceId)
1001
+ || (options.geometrySourceId && options.geometrySourceId !== geometrySourceId)
1002
+ || (options.surfaceSourceId && options.surfaceSourceId !== surfaceSourceId))) {
1003
+ throw new Error('Saved C8 authority, geometry, or surface identity is stale for the admitted source and edits.');
1004
+ }
1005
+ const surfaceReprojection = sourceMode === 'c8-custom-mesh'
1006
+ ? c8SurfaceReprojectionOption(options.surfaceReprojection, {
1007
+ authoritySourceId,
1008
+ controlContentHash: customMeshSource.control.contentHash,
1009
+ geology,
1010
+ geometrySourceId,
1011
+ meshEditCount: meshOperationOrder.length + meshSnapshots.length,
1012
+ sourceId: id,
1013
+ sourceRevision,
1014
+ surfacePackage,
1015
+ surfaceSourceId,
1016
+ })
1017
+ : null;
1018
+ const derivedArtifactState = sourceMode === 'c8-custom-mesh'
1019
+ ? portableDerivedArtifactState(options.derivedArtifactState, {
1020
+ authoritySourceId,
1021
+ customMeshSource,
1022
+ geometrySourceId,
1023
+ sourceRevision,
1024
+ surfaceReprojection,
1025
+ surfaceSourceId,
1026
+ })
1027
+ : null;
224
1028
  return {
225
1029
  archetype: String(options.archetype ?? '').trim(),
226
1030
  catalogVersion: positiveInteger(options.catalogVersion, 1),
@@ -230,16 +1034,33 @@ export function createRockReferenceIdentity(options = null) {
230
1034
  ? lodTriangles.map((triangles) => triangles / lodTriangles[0])
231
1035
  : lodRatios,
232
1036
  lodTriangles,
233
- meshEdits: meshEditsOption(options.meshEdits, options.meshEditsPacked),
1037
+ meshCuts,
1038
+ meshEdits,
1039
+ meshOperationOrder,
1040
+ meshSnapshots,
1041
+ ...(sourceMode === 'c8-custom-mesh' ? {
1042
+ authoritySourceId,
1043
+ customMeshSource,
1044
+ derivedArtifactState,
1045
+ geology,
1046
+ geometrySourceId,
1047
+ identityLandmarks,
1048
+ label: String(options.label ?? id),
1049
+ sourceRevision,
1050
+ surfaceSourceId,
1051
+ surfaceReprojection,
1052
+ thumbnailUrl: String(options.thumbnailUrl ?? ''),
1053
+ } : {}),
234
1054
  role: String(options.role ?? '').trim(),
235
1055
  series: String(options.series ?? '').trim(),
236
- sourceMode: 'mesh-template',
237
- surfacePackage: surfacePackageOption(options.surfacePackage),
238
- surfaceMode: options.surfaceMode === 'generated' ? 'generated' : 'source',
1056
+ sourceMode,
1057
+ surfacePackage,
1058
+ surfaceMode,
239
1059
  targetTriangles: positiveInteger(options.targetTriangles, lodTriangles[0] ?? 0),
240
1060
  topFinish,
241
- variation: Math.min(Math.max(Number(options.variation) || 0, 0), 1),
242
- variationSeed: Math.round(Number(options.variationSeed) || 0) >>> 0,
1061
+ variation,
1062
+ variationSeed,
1063
+ ...(options.variationSettings ? { variationSettings: variationSettingsOption(options.variationSettings) } : {}),
243
1064
  };
244
1065
  }
245
1066
 
@@ -507,11 +1328,14 @@ export function computeDocumentBounds(document) {
507
1328
  /** Serializes a document to JSON (dropping the runtime `revision`). */
508
1329
  export function serializeRockDocument(document, { pretty = false } = {}) {
509
1330
  const { revision, ...serializable } = document;
510
- if (serializable.reference?.sourceMode === 'mesh-template') {
511
- const { meshEdits, ...reference } = serializable.reference;
1331
+ if (isRockSourceMeshReference(serializable.reference)) {
1332
+ const { meshEdits, meshSnapshots, ...reference } = serializable.reference;
512
1333
  serializable.reference = {
513
1334
  ...reference,
514
1335
  ...(meshEdits?.length > 0 ? { meshEditsPacked: packMeshEdits(meshEdits) } : { meshEdits: [] }),
1336
+ ...(meshSnapshots?.length > 0
1337
+ ? { meshSnapshotsPacked: packMeshSnapshots(meshSnapshots) }
1338
+ : { meshSnapshots: [] }),
515
1339
  };
516
1340
  }
517
1341
  return JSON.stringify(serializable, null, pretty ? 2 : undefined);
@@ -526,7 +1350,12 @@ export function serializeRockDocument(document, { pretty = false } = {}) {
526
1350
  // those deltas as float32 binary in portable JSON so normal sculpt sessions
527
1351
  // fit the hosted creation limit. v6 adds the optional deterministic C7 surface
528
1352
  // package identity used by the 480 source-mesh catalog. Existing
529
- // preset/custom projects remain reference-free.
1353
+ // preset/custom projects remain reference-free. v7 preserves the content-bound
1354
+ // C8 control/high GLBs, semantic regions, landmarks, and derivative state. v8
1355
+ // adds ordered, replayable Boolean drill cuts alongside sparse sculpt edits.
1356
+ // v9 packs topology-changing geometry snapshots into portable base64 typed
1357
+ // arrays so local drafts and hosted project payloads do not overflow JSON
1358
+ // storage after remesh, union, subdivision, or fracture operations.
530
1359
  const MIGRATIONS = Object.freeze([
531
1360
  (document) => ({
532
1361
  ...document,
@@ -571,6 +1400,41 @@ const MIGRATIONS = Object.freeze([
571
1400
  : null,
572
1401
  schemaVersion: 6,
573
1402
  }),
1403
+ (document) => ({
1404
+ ...document,
1405
+ reference: document.reference && typeof document.reference === 'object'
1406
+ ? {
1407
+ ...document.reference,
1408
+ customMeshSource: document.reference.customMeshSource ?? null,
1409
+ derivedArtifactState: document.reference.derivedArtifactState ?? null,
1410
+ identityLandmarks: document.reference.identityLandmarks ?? [],
1411
+ sourceRevision: document.reference.sourceRevision ?? 1,
1412
+ surfaceReprojection: document.reference.surfaceReprojection ?? null,
1413
+ }
1414
+ : null,
1415
+ schemaVersion: 7,
1416
+ }),
1417
+ (document) => ({
1418
+ ...document,
1419
+ reference: document.reference && typeof document.reference === 'object'
1420
+ ? {
1421
+ ...document.reference,
1422
+ meshCuts: document.reference.meshCuts ?? [],
1423
+ meshOperationOrder: document.reference.meshOperationOrder ?? null,
1424
+ }
1425
+ : null,
1426
+ schemaVersion: 8,
1427
+ }),
1428
+ (document) => ({
1429
+ ...document,
1430
+ reference: document.reference && typeof document.reference === 'object'
1431
+ ? {
1432
+ ...document.reference,
1433
+ meshSnapshots: document.reference.meshSnapshots ?? [],
1434
+ }
1435
+ : null,
1436
+ schemaVersion: 9,
1437
+ }),
574
1438
  ]);
575
1439
 
576
1440
  /**