Files
SpineParticlesWeb/src/export/spineJsonValidator.ts
T
2026-10-22 18:13:43 +08:00

180 lines
10 KiB
TypeScript

import {
BoundingBoxAttachment as BoundingBoxAttachment43,
ClippingAttachment as ClippingAttachment43,
MeshAttachment as MeshAttachment43,
PathAttachment as PathAttachment43,
PointAttachment as PointAttachment43,
RegionAttachment as RegionAttachment43,
SkeletonJson as SkeletonJson43,
TextureRegion as TextureRegion43,
type AttachmentLoader as AttachmentLoader43,
type Sequence as Sequence43,
type Skin as Skin43,
} from '@spine-particle/spine-core-4.3'
import {
BoundingBoxAttachment as BoundingBoxAttachment42,
ClippingAttachment as ClippingAttachment42,
MeshAttachment as MeshAttachment42,
PathAttachment as PathAttachment42,
PointAttachment as PointAttachment42,
RegionAttachment as RegionAttachment42,
SkeletonJson as SkeletonJson42,
type AttachmentLoader as AttachmentLoader42,
type Skin as Skin42,
} from '@esotericsoftware/spine-core'
import { normalizeSpine38JsonFor42 } from '../spine/spine38Compatibility'
import { normalizeSpineVersion, supportedSpineVersionsText, type SupportedSpineVersion } from '../spine/spineVersion'
import type { SpineJsonMap } from './spineJsonTypes'
import { translate } from '../i18n'
class ExportValidationAttachmentLoader42 implements AttachmentLoader42 {
newRegionAttachment(_skin: Skin42, name: string, path: string, _sequence: any) { return new RegionAttachment42(name, path) }
newMeshAttachment(_skin: Skin42, name: string, path: string, _sequence: any) { return new MeshAttachment42(name, path) }
newBoundingBoxAttachment(_skin: Skin42, name: string) { return new BoundingBoxAttachment42(name) }
newPathAttachment(_skin: Skin42, name: string) { return new PathAttachment42(name) }
newPointAttachment(_skin: Skin42, name: string) { return new PointAttachment42(name) }
newClippingAttachment(_skin: Skin42, name: string) { return new ClippingAttachment42(name) }
}
class ExportValidationAttachmentLoader43 implements AttachmentLoader43 {
private sequence(sequence: Sequence43) {
for (let index = 0; index < sequence.regions.length; index++) {
if (sequence.regions[index]) continue
const region = new TextureRegion43()
region.u = 0; region.v = 0; region.u2 = 1; region.v2 = 1
region.width = 1; region.height = 1; region.originalWidth = 1; region.originalHeight = 1
region.offsetX = 0; region.offsetY = 0; region.degrees = 0
sequence.regions[index] = region
}
return sequence
}
newRegionAttachment(_skin: Skin43, _placeholder: string, name: string, _path: string, sequence: Sequence43) { return new RegionAttachment43(name, this.sequence(sequence)) }
newMeshAttachment(_skin: Skin43, _placeholder: string, name: string, _path: string, sequence: Sequence43) { return new MeshAttachment43(name, this.sequence(sequence)) }
newBoundingBoxAttachment(_skin: Skin43, _placeholder: string, name: string) { return new BoundingBoxAttachment43(name) }
newPathAttachment(_skin: Skin43, _placeholder: string, name: string) { return new PathAttachment43(name) }
newPointAttachment(_skin: Skin43, _placeholder: string, name: string) { return new PointAttachment43(name) }
newClippingAttachment(_skin: Skin43, _placeholder: string, name: string) { return new ClippingAttachment43(name) }
}
const VALID_INHERIT = new Set(['normal', 'onlyTranslation', 'noRotationOrReflection', 'noScale', 'noScaleOrReflection'])
function validateMesh(mesh: Record<string, any>, attachmentName: string, boneCount: number) {
const uvs = mesh.uvs
const vertices = mesh.vertices
const triangles = mesh.triangles
if (!Array.isArray(uvs) || !uvs.length || uvs.length % 2) throw new Error(translate('errors.invalidTrailUV', { name: attachmentName }))
if (!Array.isArray(vertices) || !vertices.length) throw new Error(translate('errors.missingTrailVertices', { name: attachmentName }))
const vertexCount = uvs.length / 2
if (!Array.isArray(triangles) || triangles.some((index) => !Number.isInteger(index) || index < 0 || index >= vertexCount)) {
throw new Error(translate('errors.trailIndexOutOfRange', { name: attachmentName }))
}
const hull = Number(mesh.hull) || 0
if (!Number.isInteger(hull) || hull < 0 || hull > vertexCount) throw new Error(translate('errors.invalidTrailHull', { name: attachmentName }))
if (Array.isArray(mesh.edges) && mesh.edges.some((index) => !Number.isInteger(index) || index < 0 || index >= vertexCount * 2 || index % 2)) {
throw new Error(translate('errors.trailHullIndexOutOfRange', { name: attachmentName }))
}
// 未加权 Mesh 每个顶点只有 x/y;拖尾必须是逐顶点的骨骼权重编码。
if (vertices.length === uvs.length) return
let offset = 0
let decodedVertices = 0
while (offset < vertices.length) {
const influences = vertices[offset++]
if (!Number.isInteger(influences) || influences < 1) throw new Error(translate('errors.invalidInfluenceCount', { name: attachmentName }))
let weightSum = 0
for (let influence = 0; influence < influences; influence++) {
const boneIndex = vertices[offset++]
const x = vertices[offset++]
const y = vertices[offset++]
const weight = vertices[offset++]
if (!Number.isInteger(boneIndex) || boneIndex < 0 || boneIndex >= boneCount) throw new Error(translate('errors.missingBoneReference', { name: attachmentName }))
if (![x, y, weight].every(Number.isFinite) || weight < 0) throw new Error(translate('errors.invalidWeight', { name: attachmentName }))
weightSum += weight
}
if (Math.abs(weightSum - 1) > 0.001) throw new Error(translate('errors.weightSum', { name: attachmentName }))
decodedVertices++
}
if (offset !== vertices.length || decodedVertices !== vertexCount) throw new Error(translate('errors.vertexCountMismatch', { name: attachmentName }))
}
function validateEditorImportStructure(root: Record<string, any>, version: SupportedSpineVersion) {
const bones = Array.isArray(root.bones) ? root.bones : []
const boneByName = new Map(bones.map((bone) => [String(bone.name || ''), bone]))
const weightedMeshSlots = new Set<string>()
for (const bone of bones) {
if (bone.inherit != null && !VALID_INHERIT.has(bone.inherit)) throw new Error(translate('errors.invalidInherit', { name: bone.name || '', inherit: bone.inherit, version }))
// 拖尾骨骼命名:系统名_序号_trail_资源id_骨骼序号,必须直接挂在粒子系统骨骼下。
if (/_trail_\d+_\d+$/.test(String(bone.name || ''))) {
const parent = boneByName.get(String(bone.parent || ''))
if (!parent || /_trail_\d+_\d+$/.test(String(parent.name || ''))) {
throw new Error(translate('errors.trailBoneParent', { name: bone.name || '' }))
}
}
}
for (const skin of Array.isArray(root.skins) ? root.skins : []) {
const attachments = skin?.attachments || {}
for (const slotName of Object.keys(attachments)) {
for (const attachmentName of Object.keys(attachments[slotName] || {})) {
const attachment = attachments[slotName][attachmentName]
if (attachment?.type === 'mesh') {
validateMesh(attachment, attachmentName, bones.length)
if (attachment.vertices?.length !== attachment.uvs?.length) weightedMeshSlots.add(slotName)
}
}
}
}
for (const animation of Object.values(root.animations || {}) as Array<Record<string, any>>) {
for (const slotName of weightedMeshSlots) {
const attachmentKeys = animation?.slots?.[slotName]?.attachment
if (Array.isArray(attachmentKeys) && attachmentKeys.some((key) => !key?.name)) {
throw new Error(translate('errors.trailSlotEmpty', { name: slotName }))
}
}
}
}
function validateSpine38Structure(root: Record<string, any>) {
if (String(root?.skeleton?.spine || '') !== '3.8') throw new Error(translate('errors.spine38Version'))
for (const bone of root.bones || []) {
if (bone.inherit != null) throw new Error(translate('errors.spine38Inherit', { name: bone.name || '' }))
}
for (const animation of Object.values(root.animations || {}) as SpineJsonMap[]) {
for (const slot of Object.values(animation.slots || {}) as SpineJsonMap[]) {
if (slot.rgba || slot.rgba2) throw new Error(translate('errors.spine38SlotColor'))
for (const frame of slot.attachment || []) {
if (!Object.prototype.hasOwnProperty.call(frame, 'name')) throw new Error(translate('errors.spine38AttachmentName'))
}
}
for (const bone of Object.values(animation.bones || {}) as SpineJsonMap[]) {
for (const frame of bone.rotate || []) {
if (frame.value != null || frame.angle == null) throw new Error(translate('errors.spine38RotationAngle'))
}
}
const stack: unknown[] = [animation]
while (stack.length) {
const value = stack.pop()
if (!value || typeof value !== 'object') continue
if (!Array.isArray(value) && Array.isArray((value as SpineJsonMap).curve)) throw new Error(translate('errors.spine38Curve'))
stack.push(...Object.values(value))
}
}
}
/**
* 下载前使用项目内置运行时解析共同结构,提前拦截无效 JSON。
* 目标版本由 skeleton.spine 决定;校验不会改写导出的版本字段。
*/
export function validateSpineJson(text: string, expectedVersion?: SupportedSpineVersion) {
const root = JSON.parse(text)
const version = normalizeSpineVersion(root?.skeleton?.spine)
if (!version) throw new Error(translate('errors.unsupportedExportVersion', { versions: supportedSpineVersionsText() }))
if (expectedVersion && version !== expectedVersion) throw new Error(translate('errors.exportVersionMismatch', { expected: expectedVersion, actual: version }))
validateEditorImportStructure(root, version)
if (version === '3.8') validateSpine38Structure(root)
const runtimeRoot = version === '3.8' ? normalizeSpine38JsonFor42(JSON.parse(JSON.stringify(root))) : root
const data = version === '4.3'
? new SkeletonJson43(new ExportValidationAttachmentLoader43()).readSkeletonData(runtimeRoot)
: new SkeletonJson42(new ExportValidationAttachmentLoader42()).readSkeletonData(runtimeRoot)
if (!data.bones.length) throw new Error(translate('errors.exportMissingBones'))
return { version, bones: data.bones.length, slots: data.slots.length, animations: data.animations.length }
}