@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.
- package/dist/asset-intent.d.ts +148 -0
- package/dist/asset-intent.d.ts.map +1 -0
- package/dist/asset-intent.js +145 -0
- package/dist/asset-intent.js.map +1 -0
- package/dist/asset-role-admission.d.ts +174 -0
- package/dist/asset-role-admission.d.ts.map +1 -0
- package/dist/asset-role-admission.js +381 -0
- package/dist/asset-role-admission.js.map +1 -0
- package/dist/asset-screening-effects.d.ts +95 -0
- package/dist/asset-screening-effects.d.ts.map +1 -0
- package/dist/asset-screening-effects.js +321 -0
- package/dist/asset-screening-effects.js.map +1 -0
- package/dist/asset-screening-pipeline.d.ts +132 -0
- package/dist/asset-screening-pipeline.d.ts.map +1 -0
- package/dist/asset-screening-pipeline.js +277 -0
- package/dist/asset-screening-pipeline.js.map +1 -0
- package/dist/cartoon-asset-profiles.d.ts +15 -0
- package/dist/cartoon-asset-profiles.d.ts.map +1 -0
- package/dist/cartoon-asset-profiles.js +46 -0
- package/dist/cartoon-asset-profiles.js.map +1 -0
- package/dist/cli-help.js +1 -1
- package/dist/cli.js +16 -2
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +390 -17
- package/dist/index.js.map +1 -1
- package/dist/pull-bridge/search.d.ts +4 -0
- package/dist/pull-bridge/search.d.ts.map +1 -1
- package/dist/pull-bridge/search.js +22 -0
- package/dist/pull-bridge/search.js.map +1 -1
- package/dist/pull-bridge.d.ts +20 -5
- package/dist/pull-bridge.d.ts.map +1 -1
- package/dist/pull-bridge.js +38 -1
- package/dist/pull-bridge.js.map +1 -1
- 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
|
-
|
|
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
|
|
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
|
-
|
|
1973
|
-
if (
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2156
|
-
|
|
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 [];
|