#!/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)