唯杰地图 logo唯杰地图
VJMAP
EN

check-scada-scene.mjs

vjvp/scripts/check-scada-scene.mjs 8.4 KB201 行
原文
#!/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)