#!/usr/bin/env node /** * 组态 2.5D 场景几何体检(校验器查不出的「看起来不对」)。 * * 三条规则来自渲染实现: * 1. 静态图片图元走 `image-fit:contain`,只有「盒宽高比 >= 图片宽高比」时图片才按高铺满、 * 底边与盒底齐平;否则上下留白居中,设备看起来悬空。按值换图取所有候选图里最大的比例。 * 2. 精灵图图元渲染用 preserveAspectRatio="none",单帧被拉伸铺满盒——盒宽高比必须等于 * 单帧宽高比(sheetW/cols : sheetH/rows),否则设备横向或纵向变形。 * 3. `iso-platform` 画的是内接菱形而非矩形(顶点在盒四边中点),盒子四角是空的。 * 设备底边中点必须落在某块地台的菱形内,否则悬在地台外。 * * 用法: * node vjvp/scripts/check-scada-scene.mjs <工程.vpproj.json> [...] * 退出码:有 ERROR 时为 1。 * * `/scada-images/...` 按以下顺序定位素材目录(首个存在者生效): * 环境变量 VP_SCADA_IMAGES 指定的目录(推荐:指向 VP 部署目录下的 scada-images) * <当前目录>/pb_public/scada-images (embedded 部署布局) * <当前目录>/scada-images * <当前目录>/data/images (旧布局兼容) */ import fs from 'node:fs' import path from 'node:path' const cwd = process.cwd() const imagesRoot = [ process.env.VP_SCADA_IMAGES, path.join(cwd, 'pb_public', 'scada-images'), path.join(cwd, 'scada-images'), path.join(cwd, 'data', 'images'), ].filter(Boolean).find((p) => fs.existsSync(p)) ?? path.join(cwd, 'pb_public', 'scada-images') /** 盒宽高比比图片比例大多少仍算贴合(超出说明左右留白过大,设备显小) */ const MAX_SIDE_SLACK = 1.35 /** 精灵图单帧比例允许的相对误差 */ const SPRITE_TOLERANCE = 0.03 /** 读 PNG/JPEG 的像素尺寸(只读文件头,免依赖) */ function imageSize(file) { const buf = fs.readFileSync(file) if (buf.length > 24 && buf.toString('ascii', 1, 4) === 'PNG') { return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) } } if (buf[0] === 0xff && buf[1] === 0xd8) { let i = 2 while (i < buf.length) { if (buf[i] !== 0xff) { i++; continue } const marker = buf[i + 1] if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) { return { height: buf.readUInt16BE(i + 5), width: buf.readUInt16BE(i + 7) } } i += 2 + buf.readUInt16BE(i + 2) } } return null } /** 运行时地址 `/scada-images/x/y.png` → 素材目录内文件;非 /scada-images/ 地址返回 null(跳过) */ function resolveImage(url) { const m = /^\/scada-images\/(.+)$/.exec(String(url || '').trim()) if (!m) return null const file = path.join(imagesRoot, m[1]) return fs.existsSync(file) ? file : { missing: file } } /** 「按值换图」规则里的全部候选地址(每行 `值1|值2=地址`,只按首个 = 切分) */ function imageRuleUrls(rules) { return String(rules || '') .split('\n') .map((line) => line.slice(line.indexOf('=') + 1).trim()) .filter(Boolean) } const GROUND_SHAPES = new Set(['iso-platform', 'iso-zone', 'iso-road']) /** 大屏装饰图元是画布内的浮层 HUD,本就不站在地台上,不参与落地检查 */ const DECOR_SHAPES = new Set(['panel-frame', 'title-bar', 'subtitle-bar', 'side-trim', 'bottom-trim', 'status-pill']) /** iso-platform 顶面菱形:中心 + 两个半轴(见 scadaShapes.ts 的 draw) */ function platformDiamond(o) { const w = Number(o['geo-w'] ?? 0) const h = Number(o['geo-h'] ?? 0) const thickness = Math.max(4, Math.min(h * 0.22, 16)) const dh = h - thickness return { cx: Number(o['geo-x'] ?? 0) + w / 2, cy: Number(o['geo-y'] ?? 0) + dh / 2, rx: w / 2, ry: dh / 2 } } function diamondScore(d, px, py) { return Math.abs(px - d.cx) / d.rx + Math.abs(py - d.cy) / d.ry } /** 深度遍历,收集所有 scada-2d 组件 */ function collectScada(node, out = []) { if (!node || typeof node !== 'object') return out if (node.type === 'scada-2d') out.push(node) for (const child of node.widgets ?? []) collectScada(child, out) if (node.unitTemplate) collectScada(node.unitTemplate, out) return out } function checkScada(scada, label, errors, warnings) { const pieces = scada.pieces ?? [] const platforms = pieces .filter((p) => p.type === 'scada-node' && p.options?.shape === 'iso-platform') .map((p) => ({ uid: p.uid, d: platformDiamond(p.options) })) for (const piece of pieces) { if (piece.type !== 'scada-node') continue const o = piece.options ?? {} const shape = String(o.shape ?? '') const where = `${label} · ${piece.name || piece.uid}` const w = Number(o['geo-w'] ?? 0) const h = Number(o['geo-h'] ?? 0) if (!(w > 0 && h > 0)) continue const boxAspect = w / h if (shape === 'image') { const urls = o['image-by-value'] ? [o['image-url'], ...imageRuleUrls(o['image-rules'])] : [o['image-url']] let worst = 0 let worstUrl = '' for (const url of urls) { const resolved = resolveImage(url) if (!resolved) continue if (resolved.missing) { errors.push(`${where}: 图片不存在 ${url}`) continue } const size = imageSize(resolved) if (!size) continue const cols = o['sprite-enabled'] ? Math.max(1, Number(o['sprite-cols'] ?? 1)) : 1 const rows = o['sprite-enabled'] ? Math.max(1, Number(o['sprite-rows'] ?? 1)) : 1 const aspect = size.width / cols / (size.height / rows) if (aspect > worst) { worst = aspect; worstUrl = String(url) } } if (!worst) continue if (o['sprite-enabled']) { const drift = Math.abs(boxAspect - worst) / worst if (drift > SPRITE_TOLERANCE) { const want = (h * worst).toFixed(0) errors.push( `${where}: 精灵图单帧被拉伸变形,盒 ${w}×${h}(比例 ${boxAspect.toFixed(3)}) vs 单帧比例 ${worst.toFixed(3)};` + `高 ${h} 时宽应为 ${want}`, ) } continue } if (boxAspect < worst - 1e-6) { const want = (h * worst).toFixed(0) errors.push( `${where}: 盒比例 ${boxAspect.toFixed(3)} < 图片比例 ${worst.toFixed(3)}(${path.basename(worstUrl)}),` + `contain 会上下留白使设备悬空;高 ${h} 时宽至少 ${want}`, ) } else if (boxAspect > worst * MAX_SIDE_SLACK) { warnings.push(`${where}: 盒比例 ${boxAspect.toFixed(3)} 远宽于图片 ${worst.toFixed(3)},左右留白过大,设备显小`) } } if (platforms.length && !GROUND_SHAPES.has(shape) && !DECOR_SHAPES.has(shape)) { const px = Number(o['geo-x'] ?? 0) + w / 2 const py = Number(o['geo-y'] ?? 0) + h const best = platforms.reduce( (acc, p) => { const score = diamondScore(p.d, px, py) return score < acc.score ? { score, uid: p.uid } : acc }, { score: Infinity, uid: '' }, ) if (best.score > 1) { errors.push(`${where}: 底边中点 (${px.toFixed(0)}, ${py.toFixed(0)}) 落在地台菱形外(超出 ${((best.score - 1) * 100).toFixed(0)}%),设备会悬空`) } } } } const files = process.argv.slice(2) if (!files.length) { console.error('usage: node check-scada-scene.mjs <工程.vpproj.json> [...]') process.exit(2) } const errors = [] const warnings = [] for (const file of files) { const abs = path.resolve(process.cwd(), file) const envelope = JSON.parse(fs.readFileSync(abs, 'utf8')) const screens = [envelope.project?.screen, ...(envelope.project?.screens ?? []), ...(envelope.project?.windows ?? [])].filter(Boolean) const scadas = screens.flatMap((s) => collectScada(s)) if (!scadas.length) { warnings.push(`${path.basename(abs)}: 没有 scada-2d 组件`) continue } for (const scada of scadas) checkScada(scada, `${path.basename(abs)}/${scada.name || scada.uid}`, errors, warnings) } for (const w of warnings) console.log(`warn ${w}`) for (const e of errors) console.log(`ERROR ${e}`) console.log(`\ncheck-scada-scene: ${errors.length} error(s), ${warnings.length} warning(s)`) process.exit(errors.length ? 1 : 0)