chore: checkpoint web Blender migration source, tools and docs
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled

Accumulated uncommitted work: GHOST Web backend, WebGL/GPU web layer,
web workflow engine and protocol, tooling, docs and task records.
Large generated evidence, user-testing archives, build snapshots and
imported test assets remain untracked.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
mes123456
2026-11-10 11:18:01 +08:00
parent 122142c26c
commit f3e21c6b98
6887 changed files with 257154 additions and 12294 deletions

View File

@@ -23,6 +23,8 @@ export type GLBWarningCode =
| "GLB_NON_MESH_UNMAPPED"
| "GLB_VOLUME_UNSUPPORTED"
| "NON_MESH_ATTRIBUTE_LOSS"
| "MESH_ATTRIBUTE_LOSS"
| "MESH_NORMAL_MAPPING_INVALID"
| "NO_EXPORTABLE_GEOMETRY";
export interface GLBExportWarning {
@@ -141,8 +143,9 @@ function matrixInverse(matrix: readonly number[]): number[] {
}
function convertMatrix(matrix: readonly number[]): number[] {
const basis = [1, 0, 0, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 0, 0, 1];
return matrixMultiply(matrixMultiply(basis, Array.from(matrix)), basis);
const basis = [1, 0, 0, 0, 0, 0, -1, 0, 0, 1, 0, 0, 0, 0, 0, 1];
const inverse = [1, 0, 0, 0, 0, 0, 1, 0, 0, -1, 0, 0, 0, 0, 0, 1];
return matrixMultiply(matrixMultiply(basis, Array.from(matrix)), inverse);
}
function convertPosition(values: ArrayLike<number>): Float32Array {
@@ -180,7 +183,7 @@ function convertQuaternion(values: readonly number[], source: "EULER" | "QUATERN
return [blender[0], blender[2], -blender[1], blender[3]];
}
function geometryArray<T extends Float32Array | Uint32Array>(payload: MeshGeometryBuffer | undefined, summary: MeshSummaryIR, key: "positions" | "indices" | "normals" | "uvs" | "colors" | "triangleMaterialIndices"): T | undefined {
function geometryArray<T extends Float32Array | Uint32Array>(payload: MeshGeometryBuffer | undefined, summary: MeshSummaryIR, key: "positions" | "indices" | "normals" | "splitNormals" | "uvs" | "colors" | "triangleMaterialIndices"): T | undefined {
if (payload) {
const data = payload[key];
if (data) return new (key === "indices" || key === "triangleMaterialIndices" ? Uint32Array : Float32Array)(data) as T;
@@ -317,6 +320,7 @@ function materialJSON(material: MaterialIR, textureIndexByImageId: ReadonlyMap<s
function meshWarnings(snapshot: SceneSnapshotIR, geometryBuffers: readonly MeshGeometryBuffer[], assetBuffers: readonly GLBAssetBuffer[]): GLBExportWarning[] {
const warnings: GLBExportWarning[] = [];
for (const node of snapshot.nodes) if (node.type === 'CAMERA' || node.type === 'LIGHT') warnings.push({ code: 'GLB_NON_MESH_UNMAPPED', severity: 'warning', id: node.id, message: `${node.name}: ${node.type} data is exported as an empty hierarchy node; keep .blend to retain camera and light settings` });
const bufferIds = new Set(geometryBuffers.map((geometry) => geometry.meshId));
const assetIds = new Set(assetBuffers.map((asset) => asset.assetId));
let exportableGeometry = 0;
@@ -324,7 +328,15 @@ function meshWarnings(snapshot: SceneSnapshotIR, geometryBuffers: readonly MeshG
if (mesh.geometryStatus === "summary-only") warnings.push({ code: "SUMMARY_ONLY_MESH", severity: "error", message: `Mesh ${mesh.name} has summary-only geometry`, id: mesh.id });
const geometryId = mesh.geometryBufferId ?? mesh.id;
if (mesh.geometryStatus === "binary" && !bufferIds.has(geometryId)) warnings.push({ code: "MISSING_GEOMETRY_BUFFER", severity: "error", message: `Mesh ${mesh.name} has no transferable geometry buffer`, id: mesh.id });
const normalPayload = geometryBuffers.find(buffer => buffer.meshId === geometryId), split = geometryArray<Float32Array>(normalPayload, mesh, 'splitNormals');
const corners = normalPayload?.triangleCornerIndices ? new Uint32Array(normalPayload.triangleCornerIndices) : mesh.triangleCornerIndices;
if (split && (!corners || corners.length !== (mesh.triangleCount ?? 0) * 3 || split.length % 3 !== 0 || split.some(value => !Number.isFinite(value)) || Array.from(corners).some(corner => !Number.isInteger(corner) || corner < 0 || corner * 3 + 2 >= split.length))) warnings.push({code:'MESH_NORMAL_MAPPING_INVALID',severity:'error',id:mesh.id,message:`Mesh ${mesh.name} has invalid corner-normal mapping`});
if (mesh.geometryStatus === "available" || bufferIds.has(geometryId)) exportableGeometry++;
const mappedAttributes = new Set(['position', 'material_index', 'sharp_face', 'sharp_edge', 'custom_normal', mesh.activeUVMap ?? mesh.uvLayers?.[0]?.name ?? 'UVMap']);
const colorLayers = (mesh.attributes ?? []).filter(attribute => ['FLOAT_COLOR', 'BYTE_COLOR'].includes(attribute.dataType));
if (colorLayers.length === 1 && geometryArray<Float32Array>(geometryBuffers.find(buffer => buffer.meshId === geometryId), mesh, 'colors')) mappedAttributes.add(colorLayers[0].name);
const extraAttributes = (mesh.attributes ?? []).filter(attribute => !attribute.name.startsWith('.') && !mappedAttributes.has(attribute.name));
if (extraAttributes.length) warnings.push({ code: 'MESH_ATTRIBUTE_LOSS', severity: 'warning', id: mesh.id, message: `Mesh ${mesh.name}: GLB exports standard vertex channels; keep .blend to retain named attribute layers ${extraAttributes.map(attribute => attribute.name).join(', ')}` });
if (mesh.modifierStack?.some((modifier) => modifier.enabled)) warnings.push({ code: "MODIFIER_STACK_NOT_BAKED", severity: "warning", message: `Mesh ${mesh.name} has enabled modifiers that are not baked for export`, id: mesh.id });
if (mesh.skinWeights) {
const armature = mesh.skinWeights.armatureId ? snapshot.armatures?.find((candidate) => candidate.id === mesh.skinWeights?.armatureId) : undefined;
@@ -431,15 +443,18 @@ function buildGLB(snapshot: SceneSnapshotIR, geometryBuffers: readonly MeshGeome
if (indicesSource.some((index) => index >= positionsSource.length / 3)) continue;
const normalsSource = linePrimitive || pointPrimitive ? undefined : geometryArray<Float32Array>(payload, summary, "normals");
const cornerSource = payload?.triangleCornerIndices ? new Uint32Array(payload.triangleCornerIndices) : summary.triangleCornerIndices ? new Uint32Array(summary.triangleCornerIndices) : undefined;
const splitNormals = linePrimitive || pointPrimitive ? undefined : geometryArray<Float32Array>(payload, summary, "splitNormals");
const hasSplitNormals = Boolean(splitNormals && cornerSource && cornerSource.length === indicesSource.length && cornerSource.every(corner => corner * 3 + 2 < splitNormals.length));
const exportNormals = hasSplitNormals ? splitNormals : normalsSource;
const uvsSource = geometryArray<Float32Array>(payload, summary, "uvs");
const colorsSource = geometryArray<Float32Array>(payload, summary, "colors");
const materialSource = linePrimitive || pointPrimitive ? undefined : geometryArray<Uint32Array>(payload, summary, "triangleMaterialIndices");
const vertexCount = positionsSource.length / 3;
const hasCornerUVs = Boolean(uvsSource && uvsSource.length === indicesSource.length * 2 && uvsSource.length !== vertexCount * 2);
const hasCornerColors = Boolean(colorsSource && colorsSource.length === indicesSource.length * 4 && colorsSource.length !== vertexCount * 4);
const deindexed = Boolean((cornerSource && (uvsSource || colorsSource)) || hasCornerUVs || hasCornerColors);
const deindexed = Boolean(hasSplitNormals || (cornerSource && (uvsSource || colorsSource)) || hasCornerUVs || hasCornerColors);
const positions = deindexed ? new Float32Array(indicesSource.length * 3) : convertPosition(positionsSource);
const normals = normalsSource ? (deindexed ? new Float32Array(indicesSource.length * 3) : convertNormal(normalsSource)) : undefined;
const normals = exportNormals ? (deindexed ? new Float32Array(indicesSource.length * 3) : convertNormal(exportNormals)) : undefined;
const uvs = uvsSource ? new Float32Array((deindexed ? indicesSource.length : positionsSource.length / 3) * 2) : undefined;
const colors = colorsSource ? new Float32Array((deindexed ? indicesSource.length : positionsSource.length / 3) * 4) : undefined;
const hasSkin = Boolean(summary.skinWeights && summary.skinWeights.indices.length === vertexCount * 4 && summary.skinWeights.weights.length === vertexCount * 4 && summary.skinWeights.armatureId);
@@ -452,10 +467,11 @@ function buildGLB(snapshot: SceneSnapshotIR, geometryBuffers: readonly MeshGeome
positions[corner * 3] = positionsSource[vertex * 3];
positions[corner * 3 + 1] = positionsSource[vertex * 3 + 2];
positions[corner * 3 + 2] = -positionsSource[vertex * 3 + 1];
if (normals && normalsSource) {
normals[corner * 3] = normalsSource[vertex * 3];
normals[corner * 3 + 1] = normalsSource[vertex * 3 + 2];
normals[corner * 3 + 2] = -normalsSource[vertex * 3 + 1];
if (normals && exportNormals) {
const normalIndex = hasSplitNormals ? cornerSource![corner] : vertex;
normals[corner * 3] = exportNormals[normalIndex * 3];
normals[corner * 3 + 1] = exportNormals[normalIndex * 3 + 2];
normals[corner * 3 + 2] = -exportNormals[normalIndex * 3 + 1];
}
const sourceCorner = cornerSource?.[corner] ?? vertex;
if (uvs && uvsSource && sourceCorner * 2 + 1 < uvsSource.length) uvs.set(uvsSource.subarray(sourceCorner * 2, sourceCorner * 2 + 2), corner * 2);
@@ -484,6 +500,7 @@ function buildGLB(snapshot: SceneSnapshotIR, geometryBuffers: readonly MeshGeome
}
const positionAccessor = appendAccessor(parts, binaryLength, bufferViews, accessors, positions, "VEC3", TARGET_ARRAY_BUFFER); binaryLength = positionAccessor.length;
const normalAccessor = normals ? appendAccessor(parts, binaryLength, bufferViews, accessors, normals, "VEC3", TARGET_ARRAY_BUFFER) : undefined; if (normalAccessor) binaryLength = normalAccessor.length;
if (uvs) for (let component = 1; component < uvs.length; component += 2) uvs[component] = 1 - uvs[component];
const uvAccessor = uvs ? appendAccessor(parts, binaryLength, bufferViews, accessors, uvs, "VEC2", TARGET_ARRAY_BUFFER) : undefined; if (uvAccessor) binaryLength = uvAccessor.length;
const colorAccessor = colors ? appendAccessor(parts, binaryLength, bufferViews, accessors, colors, "VEC4", TARGET_ARRAY_BUFFER) : undefined; if (colorAccessor) binaryLength = colorAccessor.length;
const jointAccessor = joints ? appendAccessor(parts, binaryLength, bufferViews, accessors, joints, "VEC4", TARGET_ARRAY_BUFFER) : undefined; if (jointAccessor) binaryLength = jointAccessor.length;