@aura3d/cli 1.4.5 → 1.5.0

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 (36) hide show
  1. package/dist/asset-intent.d.ts +148 -0
  2. package/dist/asset-intent.d.ts.map +1 -0
  3. package/dist/asset-intent.js +145 -0
  4. package/dist/asset-intent.js.map +1 -0
  5. package/dist/asset-role-admission.d.ts +174 -0
  6. package/dist/asset-role-admission.d.ts.map +1 -0
  7. package/dist/asset-role-admission.js +381 -0
  8. package/dist/asset-role-admission.js.map +1 -0
  9. package/dist/asset-screening-effects.d.ts +95 -0
  10. package/dist/asset-screening-effects.d.ts.map +1 -0
  11. package/dist/asset-screening-effects.js +321 -0
  12. package/dist/asset-screening-effects.js.map +1 -0
  13. package/dist/asset-screening-pipeline.d.ts +132 -0
  14. package/dist/asset-screening-pipeline.d.ts.map +1 -0
  15. package/dist/asset-screening-pipeline.js +277 -0
  16. package/dist/asset-screening-pipeline.js.map +1 -0
  17. package/dist/cartoon-asset-profiles.d.ts +15 -0
  18. package/dist/cartoon-asset-profiles.d.ts.map +1 -0
  19. package/dist/cartoon-asset-profiles.js +46 -0
  20. package/dist/cartoon-asset-profiles.js.map +1 -0
  21. package/dist/cli-help.js +1 -1
  22. package/dist/cli.js +16 -2
  23. package/dist/cli.js.map +1 -1
  24. package/dist/index.d.ts +8 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +390 -17
  27. package/dist/index.js.map +1 -1
  28. package/dist/pull-bridge/search.d.ts +4 -0
  29. package/dist/pull-bridge/search.d.ts.map +1 -1
  30. package/dist/pull-bridge/search.js +22 -0
  31. package/dist/pull-bridge/search.js.map +1 -1
  32. package/dist/pull-bridge.d.ts +20 -5
  33. package/dist/pull-bridge.d.ts.map +1 -1
  34. package/dist/pull-bridge.js +38 -1
  35. package/dist/pull-bridge.js.map +1 -1
  36. package/package.json +3 -3
package/dist/index.js CHANGED
@@ -2,11 +2,16 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSyn
2
2
  import { createHash } from "node:crypto";
3
3
  import { dirname, extname, join, relative, resolve } from "node:path";
4
4
  import { inflateSync } from "node:zlib";
5
+ import { admitAssetForRole } from "./asset-role-admission.js";
5
6
  import { DEFAULT_AURA_ASSET_MANIFEST, DEFAULT_AURA_ASSET_OUTPUT_DIR, DEFAULT_AURA_ASSET_PUBLIC_PATH, DEFAULT_AURA_ASSET_TYPEGEN } from "./asset-constants.js";
6
7
  import { shouldScanSource, validateAssetSource } from "./asset-source-validation.js";
7
8
  import { listAssets, readAssetManifest, writeAssetManifest, writeTypedAssets } from "./asset-manifest.js";
8
9
  export { animationCliAssetProfiles, getAnimationAssetProfileDefinition } from "./animation-asset-profiles.js";
9
10
  export { validateAnimationAssets, parseAnimationClipMap, DEFAULT_ANIMATION_ACTIONS } from "./animation-asset-validator.js";
11
+ export { formatScreeningReport, screenAssetCandidates } from "./asset-screening-pipeline.js";
12
+ export { createRetainedRenderProbe, createScreeningEffects, hashStagedFile, inspectGlbGeometry } from "./asset-screening-effects.js";
13
+ export { admissionRequirementForIntent, licensesForPolicy, licenseSatisfiesPolicy, searchQueriesForIntent, validateAssetIntent, MIN_INTENT_HERO_AZIMUTHS } from "./asset-intent.js";
14
+ export { admitAssetForRole, rankAssetCandidatesForRole, HERO_MIN_RENDERED_AZIMUTHS, HERO_MIN_TRIANGLES, MIN_VEHICLE_WHEEL_CORNERS } from "./asset-role-admission.js";
10
15
  export { DEFAULT_AURA_ASSET_MANIFEST, DEFAULT_AURA_ASSET_OUTPUT_DIR, DEFAULT_AURA_ASSET_PUBLIC_PATH, DEFAULT_AURA_ASSET_TYPEGEN } from "./asset-constants.js";
11
16
  export { listAssets, readAssetManifest, writeAssetManifest, writeTypedAssets } from "./asset-manifest.js";
12
17
  export function readRenderedProbeMetadata(options) {
@@ -122,7 +127,20 @@ export function addAsset(options) {
122
127
  id: options.name,
123
128
  type,
124
129
  format,
125
- source: normalizeRelativePath(relative(projectDir, sourcePath)),
130
+ /*
131
+ * Record a source path that still exists after the command finishes.
132
+ *
133
+ * `assets resolve` downloads into `mkdtempSync(...)` and hands that path to `addAsset`, so a naive
134
+ * `relative(projectDir, sourcePath)` stored something like
135
+ * `../aura3d-resolve-PAzWRU/carStaged.glb` -- a directory that is deleted moments later. That
136
+ * makes the manifest reference unresolvable provenance, and because the temp segment is random it
137
+ * also makes typegen output differ between two byte-identical resolves, which defeats content-hash
138
+ * comparison of generated artifacts.
139
+ *
140
+ * When the source lives outside the project, the durable record is the staged output the pipeline
141
+ * just wrote inside the project. In-project sources keep their real relative path.
142
+ */
143
+ source: normalizeRelativePath(relative(projectDir, sourcePath).startsWith("..") ? outputPath : relative(projectDir, sourcePath)),
126
144
  outputPath: normalizeRelativePath(outputPath),
127
145
  url: `${publicPath}${outputFileName}`,
128
146
  hash: `sha256-${hash}`,
@@ -137,7 +155,7 @@ export function addAsset(options) {
137
155
  skeleton: inspection.skeleton,
138
156
  morphTargets: inspection.morphTargets,
139
157
  hierarchy: inspection.hierarchy,
140
- provenance: createAssetProvenance(projectDir, sourcePath, options, mergeDetectedProvenance(existing?.provenance, inspection.provenance)),
158
+ provenance: createAssetProvenance(projectDir, sourcePath, options, mergeDetectedProvenance(existing?.provenance, inspection.provenance), outputPath),
141
159
  textures: inspection.textures,
142
160
  dependencies: inspection.dependencies,
143
161
  orientation: options.orientation ?? (existing?.orientation?.source === "manifest-override" && existing.orientation.assetHash === `sha256-${hash}`
@@ -504,8 +522,72 @@ function retainedGameSubjectCertificationBlockers(projectDir, manifest, asset, r
504
522
  if (createRoleAwareRenderedProbeWarnings(asset, role).length > 0) {
505
523
  blockers.push(`asset-certification:${label}-probe-readability-failed:${asset.id}`);
506
524
  }
525
+ blockers.push(...roleAdmissionCertificationBlockers(asset, role, label));
507
526
  return blockers;
508
527
  }
528
+ /**
529
+ * Additional certification blockers from role-aware asset admission.
530
+ *
531
+ * ## Why this is wired in here
532
+ *
533
+ * Every check above measures the *probe artifact*: its hash, dimensions, pixel counts and readability.
534
+ * None measures the *model*. A 792-triangle body shell with no wheels modelled passes all of them,
535
+ * because it is a large, well-lit, correctly-framed subject that produces a perfectly valid probe PNG.
536
+ * That is exactly how three unusable hero vehicles were certified in succession.
537
+ *
538
+ * `admitAssetForRole` is the single place that answers "is this model fit for this role?". Calling it
539
+ * here means certification and the standalone auditor cannot disagree about the same asset.
540
+ *
541
+ * ## Why it only *adds* blockers
542
+ *
543
+ * Admission is a strictly additional gate: it never clears an existing blocker. A vehicle that fails
544
+ * probe-hash freshness must still fail, whatever its geometry says.
545
+ *
546
+ * ## Why `unproven` is not a blocker here
547
+ *
548
+ * Manifest geometry metadata does not record wheel-corner counts or silhouette relationships, so
549
+ * admission legitimately reports those as `unproven` rather than failing. Treating `unproven` as a
550
+ * blocker would fail every currently-certified asset for missing metadata rather than for being unfit --
551
+ * a gate that fires on everything is a gate nobody can act on. Hard `blockers` are enforced; the
552
+ * `unproven` set is surfaced by `assets audit-geometry` and the screening pipeline, which do have
553
+ * rendered evidence to supply.
554
+ */
555
+ function roleAdmissionCertificationBlockers(asset, role, label) {
556
+ const bounds = asset.bounds;
557
+ if (!bounds || bounds.length < 3)
558
+ return [];
559
+ const admission = admitAssetForRole({
560
+ assetId: asset.id,
561
+ requirement: {
562
+ role: role === "vehicle" ? "hero-vehicle" : "playable-character",
563
+ // A release-quality hero must be textured; both roles are hero subjects.
564
+ requireTextured: true,
565
+ requireProvenance: true
566
+ },
567
+ geometry: {
568
+ partCount: Math.max(1, asset.nodeNames?.length ?? 1),
569
+ /*
570
+ * The manifest does not record a triangle count, so no triangle floor or budget is asserted here.
571
+ *
572
+ * Passing `0` would make every asset fail a hero triangle floor for *missing metadata* rather than
573
+ * for being unfit, so the requirement above deliberately omits `minTriangles`/`maxTriangles` and
574
+ * this value is only a placeholder for the part/material/texture checks that the manifest can
575
+ * actually support. Triangle-budget enforcement belongs to the screening pipeline, which inspects
576
+ * the pulled file directly.
577
+ */
578
+ triangles: 0,
579
+ bounds: [Number(bounds[0]), Number(bounds[1]), Number(bounds[2])],
580
+ materialCount: asset.materials?.length ?? 0,
581
+ textureCount: asset.textures?.length ?? 0,
582
+ ...(asset.boundsMetadata?.min?.[1] !== undefined ? { minY: Number(asset.boundsMetadata.min[1]) } : {})
583
+ },
584
+ provenance: {
585
+ ...(asset.provenance?.license ? { license: asset.provenance.license } : {}),
586
+ ...(asset.provenance?.author ? { author: asset.provenance.author } : {})
587
+ }
588
+ });
589
+ return admission.blockers.map((blocker) => `asset-certification:${label}-admission:${asset.id}:${blocker}`);
590
+ }
509
591
  export function scanAssets(options) {
510
592
  const projectDir = resolve(options.projectDir ?? process.cwd());
511
593
  const directory = resolve(projectDir, options.directory);
@@ -601,6 +683,7 @@ export function validateAssets(options = {}) {
601
683
  }
602
684
  if (release)
603
685
  warnings.push(...createDurableReleaseProvenanceWarnings(asset, provenance));
686
+ warnings.push(...createDerivedMetadataDriftWarnings(outputPath, asset));
604
687
  if (release)
605
688
  warnings.push(...createReleaseStructuredQualityWarnings(asset));
606
689
  if (release)
@@ -1966,14 +2049,170 @@ function uniqueStrings(values) {
1966
2049
  function uniqueNumbers(values) {
1967
2050
  return [...new Set(values)];
1968
2051
  }
1969
- function extractBoundsDetails(json) {
2052
+ function positionAccessors(json) {
2053
+ const accessors = json.accessors ?? [];
2054
+ const indices = new Set();
2055
+ for (const mesh of json.meshes ?? []) {
2056
+ for (const primitive of mesh.primitives ?? []) {
2057
+ const position = primitive.attributes?.POSITION;
2058
+ if (typeof position === "number")
2059
+ indices.add(position);
2060
+ }
2061
+ }
2062
+ // No mesh table: fall back to every accessor rather than reporting nothing.
2063
+ if (indices.size === 0)
2064
+ return accessors.filter((accessor) => accessor.min && accessor.max);
2065
+ return [...indices].map((index) => accessors[index]).filter((accessor) => Boolean(accessor));
2066
+ }
2067
+ function extractGltfSceneBounds(json) {
2068
+ const nodes = json.nodes ?? [];
2069
+ const meshes = json.meshes ?? [];
2070
+ const accessors = json.accessors ?? [];
2071
+ if (nodes.length === 0 || meshes.length === 0)
2072
+ return undefined;
1970
2073
  let min;
1971
2074
  let max;
1972
- for (const accessor of json.accessors ?? []) {
1973
- if (!accessor.min || !accessor.max || accessor.min.length < 3 || accessor.max.length < 3)
1974
- continue;
1975
- min = min ? [Math.min(min[0], accessor.min[0]), Math.min(min[1], accessor.min[1]), Math.min(min[2], accessor.min[2])] : [accessor.min[0], accessor.min[1], accessor.min[2]];
1976
- max = max ? [Math.max(max[0], accessor.max[0]), Math.max(max[1], accessor.max[1]), Math.max(max[2], accessor.max[2])] : [accessor.max[0], accessor.max[1], accessor.max[2]];
2075
+ const visit = (nodeIndex, parentMatrix, seen) => {
2076
+ if (seen.has(nodeIndex))
2077
+ return;
2078
+ const node = nodes[nodeIndex];
2079
+ if (!node)
2080
+ return;
2081
+ const worldMatrix = multiplyGltfMat4(parentMatrix, localGltfNodeMatrix(node));
2082
+ if (typeof node.mesh === "number") {
2083
+ for (const primitive of meshes[node.mesh]?.primitives ?? []) {
2084
+ const positionIndex = primitive.attributes?.POSITION;
2085
+ if (typeof positionIndex !== "number")
2086
+ continue;
2087
+ const accessor = accessors[positionIndex];
2088
+ if (!accessor?.min || !accessor?.max || accessor.min.length < 3 || accessor.max.length < 3)
2089
+ continue;
2090
+ // Transform all eight corners: transforming only min/max is wrong under
2091
+ // rotation, because the transformed min is not necessarily the new minimum.
2092
+ for (const corner of boxCorners(accessor.min, accessor.max)) {
2093
+ const world = transformGltfPoint(worldMatrix, corner);
2094
+ min = min ? [Math.min(min[0], world[0]), Math.min(min[1], world[1]), Math.min(min[2], world[2])] : [...world];
2095
+ max = max ? [Math.max(max[0], world[0]), Math.max(max[1], world[1]), Math.max(max[2], world[2])] : [...world];
2096
+ }
2097
+ }
2098
+ }
2099
+ const nextSeen = new Set(seen);
2100
+ nextSeen.add(nodeIndex);
2101
+ for (const child of node.children ?? [])
2102
+ visit(child, worldMatrix, nextSeen);
2103
+ };
2104
+ const sceneRoots = json.scenes?.[json.scene ?? 0]?.nodes;
2105
+ const roots = sceneRoots && sceneRoots.length > 0 ? sceneRoots : gltfImplicitRootNodes(nodes);
2106
+ for (const root of roots)
2107
+ visit(root, identityGltfMat4(), new Set());
2108
+ return min && max ? { min, max } : undefined;
2109
+ }
2110
+ function gltfImplicitRootNodes(nodes) {
2111
+ const children = new Set();
2112
+ for (const node of nodes) {
2113
+ for (const child of node.children ?? [])
2114
+ children.add(child);
2115
+ }
2116
+ const roots = nodes.map((_node, index) => index).filter((index) => !children.has(index));
2117
+ return roots.length > 0 ? roots : nodes.map((_node, index) => index);
2118
+ }
2119
+ function boxCorners(min, max) {
2120
+ const xs = [min[0] ?? 0, max[0] ?? 0];
2121
+ const ys = [min[1] ?? 0, max[1] ?? 0];
2122
+ const zs = [min[2] ?? 0, max[2] ?? 0];
2123
+ const corners = [];
2124
+ for (const x of xs)
2125
+ for (const y of ys)
2126
+ for (const z of zs)
2127
+ corners.push([x, y, z]);
2128
+ return corners;
2129
+ }
2130
+ function identityGltfMat4() {
2131
+ return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
2132
+ }
2133
+ /** Column-major, matching the glTF spec. */
2134
+ function localGltfNodeMatrix(node) {
2135
+ if (node.matrix && node.matrix.length === 16)
2136
+ return node.matrix;
2137
+ const translation = node.translation ?? [0, 0, 0];
2138
+ const rotation = node.rotation ?? [0, 0, 0, 1];
2139
+ const scale = node.scale ?? [1, 1, 1];
2140
+ const rotationMatrix = gltfQuaternionMatrix(rotation);
2141
+ const scaled = rotationMatrix.map((value, index) => {
2142
+ const column = Math.floor(index / 4);
2143
+ return column < 3 ? value * (scale[column] ?? 1) : value;
2144
+ });
2145
+ return [
2146
+ scaled[0] ?? 1, scaled[1] ?? 0, scaled[2] ?? 0, 0,
2147
+ scaled[4] ?? 0, scaled[5] ?? 1, scaled[6] ?? 0, 0,
2148
+ scaled[8] ?? 0, scaled[9] ?? 0, scaled[10] ?? 1, 0,
2149
+ translation[0] ?? 0, translation[1] ?? 0, translation[2] ?? 0, 1
2150
+ ];
2151
+ }
2152
+ function gltfQuaternionMatrix(quaternion) {
2153
+ const length = Math.hypot(quaternion[0] ?? 0, quaternion[1] ?? 0, quaternion[2] ?? 0, quaternion[3] ?? 1) || 1;
2154
+ const x = (quaternion[0] ?? 0) / length;
2155
+ const y = (quaternion[1] ?? 0) / length;
2156
+ const z = (quaternion[2] ?? 0) / length;
2157
+ const w = (quaternion[3] ?? 1) / length;
2158
+ return [
2159
+ 1 - 2 * (y * y + z * z), 2 * (x * y + z * w), 2 * (x * z - y * w), 0,
2160
+ 2 * (x * y - z * w), 1 - 2 * (x * x + z * z), 2 * (y * z + x * w), 0,
2161
+ 2 * (x * z + y * w), 2 * (y * z - x * w), 1 - 2 * (x * x + y * y), 0,
2162
+ 0, 0, 0, 1
2163
+ ];
2164
+ }
2165
+ function multiplyGltfMat4(left, right) {
2166
+ const out = new Array(16).fill(0);
2167
+ for (let column = 0; column < 4; column += 1) {
2168
+ for (let row = 0; row < 4; row += 1) {
2169
+ out[column * 4 + row] =
2170
+ (left[row] ?? 0) * (right[column * 4] ?? 0)
2171
+ + (left[4 + row] ?? 0) * (right[column * 4 + 1] ?? 0)
2172
+ + (left[8 + row] ?? 0) * (right[column * 4 + 2] ?? 0)
2173
+ + (left[12 + row] ?? 0) * (right[column * 4 + 3] ?? 0);
2174
+ }
2175
+ }
2176
+ return out;
2177
+ }
2178
+ function transformGltfPoint(matrix, point) {
2179
+ return [
2180
+ (matrix[0] ?? 0) * point[0] + (matrix[4] ?? 0) * point[1] + (matrix[8] ?? 0) * point[2] + (matrix[12] ?? 0),
2181
+ (matrix[1] ?? 0) * point[0] + (matrix[5] ?? 0) * point[1] + (matrix[9] ?? 0) * point[2] + (matrix[13] ?? 0),
2182
+ (matrix[2] ?? 0) * point[0] + (matrix[6] ?? 0) * point[1] + (matrix[10] ?? 0) * point[2] + (matrix[14] ?? 0)
2183
+ ];
2184
+ }
2185
+ /**
2186
+ * Scene-space bounds of a glTF asset.
2187
+ *
2188
+ * This walks the scene graph and transforms each mesh primitive's POSITION accessor
2189
+ * bounds into scene space before unioning them.
2190
+ *
2191
+ * The previous implementation unioned the raw `min`/`max` of *every* accessor in
2192
+ * mesh-local space. That was wrong twice over: it mixed in non-POSITION accessors
2193
+ * (normals, tangents, UVs, joint weights) whose ranges are unrelated to geometry
2194
+ * extent, and it ignored node transforms entirely. For a GLB whose meshes carry
2195
+ * non-identity node transforms the result did not describe the asset at all — for the
2196
+ * `robotcand` fixture it reported an extent of [30.3, 24.1, 15.3] against a real
2197
+ * scene-space extent of [15.9, 25.1, 10.0], a non-uniform ~1.9x/0.96x/1.5x error.
2198
+ *
2199
+ * Consumers rely on these bounds for framing and sizing (`camera.frameAsset`,
2200
+ * `targetHeight`/`targetLength`/`targetMaxDimension`), so a wrong value silently
2201
+ * mis-sizes or clips models.
2202
+ */
2203
+ function extractBoundsDetails(json) {
2204
+ const sceneBounds = extractGltfSceneBounds(json);
2205
+ let min = sceneBounds?.min;
2206
+ let max = sceneBounds?.max;
2207
+ if (!min || !max) {
2208
+ // Fall back to POSITION accessors in mesh-local space. Still better than mixing
2209
+ // in unrelated accessors, and it keeps assets without a usable scene graph working.
2210
+ for (const accessor of positionAccessors(json)) {
2211
+ if (!accessor.min || !accessor.max || accessor.min.length < 3 || accessor.max.length < 3)
2212
+ continue;
2213
+ min = min ? [Math.min(min[0], accessor.min[0]), Math.min(min[1], accessor.min[1]), Math.min(min[2], accessor.min[2])] : [accessor.min[0], accessor.min[1], accessor.min[2]];
2214
+ max = max ? [Math.max(max[0], accessor.max[0]), Math.max(max[1], accessor.max[1]), Math.max(max[2], accessor.max[2])] : [accessor.max[0], accessor.max[1], accessor.max[2]];
2215
+ }
1977
2216
  }
1978
2217
  if (!min || !max)
1979
2218
  return undefined;
@@ -2114,10 +2353,22 @@ function createAssetWarnings(path, inspection) {
2114
2353
  warnings.push("one or more materials are invisible or unreadable");
2115
2354
  return warnings;
2116
2355
  }
2117
- function createAssetProvenance(projectDir, sourcePath, options, detected) {
2356
+ function createAssetProvenance(projectDir, sourcePath, options, detected,
2357
+ /**
2358
+ * Durable in-project path the asset was staged to.
2359
+ *
2360
+ * Used instead of `sourcePath` when the source is outside the project. `assets resolve` downloads
2361
+ * into a `mkdtempSync` directory that is removed once the command returns, so recording that path
2362
+ * produced provenance pointing at a directory that no longer exists -- and because the temp segment
2363
+ * is random, two byte-identical resolves generated different typed output, defeating content-hash
2364
+ * comparison of generated artifacts.
2365
+ */
2366
+ durableOutputPath) {
2118
2367
  const evidence = [...new Set([...(options.provenanceEvidence ?? []), ...(detected?.evidence ?? [])].map((entry) => entry.trim()).filter(Boolean))];
2119
2368
  return {
2120
- sourcePath: normalizeRelativePath(relative(projectDir, sourcePath)),
2369
+ sourcePath: normalizeRelativePath(relative(projectDir, sourcePath).startsWith("..") && durableOutputPath
2370
+ ? durableOutputPath
2371
+ : relative(projectDir, sourcePath)),
2121
2372
  ...(options.sourcePage ?? detected?.sourcePage ? { sourcePage: options.sourcePage ?? detected?.sourcePage } : {}),
2122
2373
  ...(options.downloadUrl ?? detected?.downloadUrl ? { downloadUrl: options.downloadUrl ?? detected?.downloadUrl } : {}),
2123
2374
  ...(options.sourceUrl ?? detected?.sourceUrl ? { sourceUrl: options.sourceUrl ?? detected?.sourceUrl } : {}),
@@ -2132,7 +2383,16 @@ function createAssetProvenance(projectDir, sourcePath, options, detected) {
2132
2383
  ...(options.retrievedAt ?? detected?.retrievedAt ? { retrievedAt: options.retrievedAt ?? detected?.retrievedAt } : {}),
2133
2384
  ...(options.resolveCandidate ?? detected?.resolveCandidate ? { resolveCandidate: options.resolveCandidate ?? detected?.resolveCandidate } : {}),
2134
2385
  ...(evidence.length > 0 ? { evidence } : {}),
2135
- checkedAt: new Date().toISOString()
2386
+ /*
2387
+ * Honour the injectable `retrievedAt` so a deterministic resolve produces deterministic output.
2388
+ *
2389
+ * This was `new Date().toISOString()` unconditionally, which meant two byte-identical resolves of
2390
+ * the same candidate differed by a millisecond in generated typed output. `retrievedAt` already
2391
+ * exists precisely so a caller can pin the retrieval instant; using the wall clock here defeated it
2392
+ * and made generated artifacts non-comparable by content hash. Falls back to the wall clock when no
2393
+ * retrieval instant was supplied.
2394
+ */
2395
+ checkedAt: options.retrievedAt ?? detected?.retrievedAt ?? new Date().toISOString()
2136
2396
  };
2137
2397
  }
2138
2398
  /**
@@ -2148,23 +2408,75 @@ function mergeDetectedProvenance(existing, detected) {
2148
2408
  if (!existing)
2149
2409
  return detected;
2150
2410
  const evidence = [...(detected?.evidence ?? []), ...(existing.evidence ?? [])];
2411
+ // Licence identity fields merge as one group, not independently.
2412
+ //
2413
+ // Detected values come from the asset's own embedded metadata and are authoritative.
2414
+ // A GLB commonly embeds `license` without `licenseName`, so merging field-by-field
2415
+ // let a stale hand-recorded `licenseName` survive next to a corrected `license` and
2416
+ // produced self-contradictory licence metadata — for example `license:
2417
+ // "CC-BY-SA-4.0"` alongside `licenseName: "CC-BY-4.0"`, which understates a
2418
+ // share-alike obligation. When detection supplies a licence identity at all, the
2419
+ // whole group is taken from detection and any field it does not supply is derived or
2420
+ // dropped rather than inherited from the stale record.
2421
+ const detectedLicenseIdentity = nonEmpty(detected?.license) || nonEmpty(detected?.licenseName);
2422
+ const licenseFields = detectedLicenseIdentity
2423
+ ? {
2424
+ license: detected?.license,
2425
+ licenseName: detected?.licenseName ?? licenseNameFromLicense(detected?.license),
2426
+ licenseUrl: detected?.licenseUrl ?? licenseUrlFromLicense(detected?.license) ?? existing.licenseUrl,
2427
+ licenseRaw: detected?.licenseRaw ?? existing.licenseRaw
2428
+ }
2429
+ : {
2430
+ license: existing.license,
2431
+ licenseName: existing.licenseName,
2432
+ licenseUrl: existing.licenseUrl,
2433
+ licenseRaw: existing.licenseRaw
2434
+ };
2435
+ // Attribution belongs with the author it credits, for the same reason.
2436
+ const detectedAuthorIdentity = nonEmpty(detected?.author) || nonEmpty(detected?.attribution);
2437
+ const authorFields = detectedAuthorIdentity
2438
+ ? {
2439
+ author: detected?.author,
2440
+ attribution: detected?.attribution ?? attributionFromAuthor(detected?.author)
2441
+ }
2442
+ : { author: existing.author, attribution: existing.attribution };
2151
2443
  return {
2152
2444
  sourcePage: detected?.sourcePage ?? existing.sourcePage,
2153
2445
  downloadUrl: detected?.downloadUrl ?? existing.downloadUrl,
2154
2446
  sourceUrl: detected?.sourceUrl ?? existing.sourceUrl,
2155
- license: detected?.license ?? existing.license,
2156
- licenseName: detected?.licenseName ?? existing.licenseName,
2157
- licenseUrl: detected?.licenseUrl ?? existing.licenseUrl,
2158
- licenseRaw: detected?.licenseRaw ?? existing.licenseRaw,
2159
- author: detected?.author ?? existing.author,
2447
+ ...licenseFields,
2448
+ ...authorFields,
2160
2449
  sourceFamily: detected?.sourceFamily ?? existing.sourceFamily,
2161
- attribution: detected?.attribution ?? existing.attribution,
2162
2450
  sha256: detected?.sha256 ?? existing.sha256,
2163
2451
  retrievedAt: detected?.retrievedAt ?? existing.retrievedAt,
2164
2452
  resolveCandidate: detected?.resolveCandidate ?? existing.resolveCandidate,
2165
2453
  ...(evidence.length > 0 ? { evidence: [...new Set(evidence)] } : {})
2166
2454
  };
2167
2455
  }
2456
+ /** Extracts the SPDX-style identifier from a `"NAME (url)"` licence string. */
2457
+ function licenseNameFromLicense(license) {
2458
+ const trimmed = license?.trim();
2459
+ if (!trimmed)
2460
+ return undefined;
2461
+ const match = /^([^(]+)/.exec(trimmed);
2462
+ return match ? match[1].trim() : trimmed;
2463
+ }
2464
+ /** Extracts the licence URL from a `"NAME (url)"` licence string. */
2465
+ function licenseUrlFromLicense(license) {
2466
+ const trimmed = license?.trim();
2467
+ if (!trimmed)
2468
+ return undefined;
2469
+ const match = /\((https?:\/\/[^)]+)\)/.exec(trimmed);
2470
+ return match ? match[1] : undefined;
2471
+ }
2472
+ /** Extracts the display name from an `"Author (profile url)"` string. */
2473
+ function attributionFromAuthor(author) {
2474
+ const trimmed = author?.trim();
2475
+ if (!trimmed)
2476
+ return undefined;
2477
+ const match = /^([^(]+)/.exec(trimmed);
2478
+ return match ? match[1].trim() : trimmed;
2479
+ }
2168
2480
  function readExternalProvenance(projectDir, provenanceFile) {
2169
2481
  if (!provenanceFile)
2170
2482
  return new Map();
@@ -2260,6 +2572,67 @@ function createDurableReleaseProvenanceWarnings(asset, provenance) {
2260
2572
  warnings.push(`${asset.id}: durable provenance missing acquisition timestamp (retrievedAt).`);
2261
2573
  return warnings;
2262
2574
  }
2575
+ /**
2576
+ * Re-derive geometry metadata from the asset file and report drift.
2577
+ *
2578
+ * `assets validate` already proves the *file* is unchanged via its content hash, but nothing
2579
+ * proved that the metadata *derived* from that file still matches it. Those are different
2580
+ * claims: metadata written by an older inspector, or hand-edited, survives a green hash check
2581
+ * forever. A repo-wide audit found 79 assets whose manifest `bounds` disagreed with their own
2582
+ * GLB and 62 with no `hierarchy` block at all, many with Y and Z transposed because a Z-up
2583
+ * source was never normalized.
2584
+ *
2585
+ * That is not cosmetic. `bounds`/`boundsMetadata.grounded` feed grounding and auto-fit
2586
+ * decisions, and a transposed height is exactly the class of error behind defect 45, where a
2587
+ * vehicle was floating above its track because a height was read from the wrong axis.
2588
+ *
2589
+ * Reported as warnings rather than failures because release validation already promotes
2590
+ * warnings to blocking failures for release candidates, while non-release callers keep working.
2591
+ */
2592
+ function createDerivedMetadataDriftWarnings(outputPath, asset) {
2593
+ if (asset.type !== "model")
2594
+ return [];
2595
+ if (asset.format !== "glb" && asset.format !== "gltf")
2596
+ return [];
2597
+ if (!existsSync(outputPath))
2598
+ return [];
2599
+ let inspection;
2600
+ try {
2601
+ inspection = inspectAssetFile(outputPath, asset.format);
2602
+ }
2603
+ catch {
2604
+ // A file we cannot inspect is already reported by the hash and dependency checks.
2605
+ return [];
2606
+ }
2607
+ const warnings = [];
2608
+ const actualSize = inspection.boundsMetadata?.size ?? inspection.bounds;
2609
+ const storedSize = asset.boundsMetadata?.size ?? asset.bounds;
2610
+ if (actualSize && storedSize) {
2611
+ const drifted = actualSize.some((value, index) => Math.abs(value - (storedSize[index] ?? 0)) > 0.01);
2612
+ if (drifted) {
2613
+ warnings.push(`${asset.id}: manifest bounds [${storedSize.map((value) => value.toFixed(3)).join(", ")}] do not match the asset file [${actualSize.map((value) => value.toFixed(3)).join(", ")}]. Re-add the asset through the CLI to refresh derived metadata.`);
2614
+ }
2615
+ }
2616
+ else if (actualSize && !storedSize) {
2617
+ warnings.push(`${asset.id}: manifest is missing bounds metadata that the asset file provides.`);
2618
+ }
2619
+ const actualGrounded = inspection.boundsMetadata?.grounded;
2620
+ const storedGrounded = asset.boundsMetadata?.grounded;
2621
+ if (typeof actualGrounded === "boolean" && typeof storedGrounded === "boolean" && actualGrounded !== storedGrounded) {
2622
+ warnings.push(`${asset.id}: manifest records grounded=${storedGrounded} but the asset file is grounded=${actualGrounded}.`);
2623
+ }
2624
+ if (inspection.hierarchy && !asset.hierarchy) {
2625
+ warnings.push(`${asset.id}: manifest is missing the scene hierarchy inspection that the asset file provides.`);
2626
+ }
2627
+ else if (inspection.hierarchy && asset.hierarchy) {
2628
+ for (const key of ["nodeCount", "meshCount", "materialCount", "textureCount", "animationClipCount", "skinCount"]) {
2629
+ if (inspection.hierarchy[key] !== asset.hierarchy[key]) {
2630
+ warnings.push(`${asset.id}: manifest hierarchy.${key}=${String(asset.hierarchy[key])} but the asset file reports ${String(inspection.hierarchy[key])}.`);
2631
+ }
2632
+ }
2633
+ }
2634
+ return warnings;
2635
+ }
2263
2636
  function createReleaseStructuredQualityWarnings(asset) {
2264
2637
  if (asset.type !== "model")
2265
2638
  return [];