dsh-mc-art-panel 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -26,6 +26,61 @@ dsh plugin --profile web add /path/to/dsh-mc-art/panel # 或 npm 上的包名
26
26
  name: dsh-mc-art-panel
27
27
  ```
28
28
 
29
+ ## 这个包带什么
30
+
31
+ 装一个包,面板、模式、两个 skill 一起到位——不需要再跑仓库里的安装器,也不会往你的
32
+ 用户目录写任何东西:
33
+
34
+ ```
35
+ panel/ 包根
36
+ ├── lib/ 宿主与客户端(由 build.mjs 从 tools/mcart-plugin/ 生成)
37
+ ├── cordis.patch.yml ① 把面板插进组合 ② 把包内预设目录注册成 agent-presets 的一个 root
38
+ └── preset/mc-studio/ 「MC 模组工作室」模式
39
+ ├── agent.cordis.yml 预设的宿主组合(自带 skill-filesystem 行)
40
+ ├── preset.yml 名字与说明
41
+ └── skills/ 随预设走的 skill:mc-mod、mc-art
42
+ ```
43
+
44
+ **多大**:解包后约 1.1 MB(压缩后 306 kB)——`lib/` 250 KB、`mc-art` skill 826 KB
45
+ (引擎 `mc_art/` + 60 个测试 + 文档;它就是一份完整可离线用的美术引擎)、`mc-mod` skill 41 KB、
46
+ 模式本身 17 KB。
47
+
48
+ `panel/preset/` 是**打包时生成的**(`prepare` → `panel/vendor.mjs`),仓库里不留副本:
49
+ 所以这个仓库不携带 mc-art 的内容(它是独立仓库)。
50
+
51
+ **里面没有任何美术素材**:参考贴图/模型是引擎在运行时扫 asset root、版本 jar、mods 得到的
52
+ (那是 `mc-art` 的设计,不是打包进来的资源)。砍掉的是纯垃圾:`.git`、`__pycache__`、`*.pyc`、
53
+ `.pytest_cache`、`.cache`(含 `references/.cache/` 那份派生缓存——它跟机器和资产相关,
54
+ 随包发还可能给出过期结果)。排除之前磁盘副本虚胖到 2.4 MB;npm 打包时本来也会排掉一部分,
55
+ 但那不该是唯一防线。
56
+
57
+ 两处机制,都照 DSH 出厂预设的做法:
58
+
59
+ - **模式**:`cordis.patch.yml` 给 `agent-presets` 那一行加了一个 root(`trust: system`),
60
+ 路径用 `!!js` 从 `baseUrl` 算 —— 在 profile 组合里 **`baseUrl` 就是 profile 目录**(实测),
61
+ 所以 `node_modules/dsh-mc-art-panel/preset` 正好落在装好的包上。
62
+ - **skill**:预设自己带 `skills/`,用
63
+ `process.getBuiltinModule('node:url').fileURLToPath(new URL('skills/', baseUrl))` 定位
64
+ (预设组合里 `baseUrl` 是**预设自己的目录**)。这正是出厂 `cordis` 预设带它那两份
65
+ composition skill 的方式;web 组合里基础的 `skill-filesystem` 行是 disabled 的,
66
+ **本地 skill 归预设管**。
67
+
68
+ `cert`:`mc-art` 是独立仓库(有自己的历史),这里放的是它的一份**快照**(发布时从克隆复制,
69
+ 约 2.9 MB)。想跟上游最新:跑仓库里的 `install.mjs`,它会把 mc-art clone/更新到
70
+ `~/.dsh/skills/mc-art`;`~/.dsh/skills` 是默认 skill 根,两边不会冲突(同名时预设层优先)。
71
+
72
+ 怎么验(本机真跑过):用 DSH 自己的发现逻辑扫装好的包 ——
73
+
74
+ ```bash
75
+ # 在某个装了这个包的 profile 里
76
+ node -e 'import("/…/@deepseek-ai/dsh-agent-presets/lib/index.js").then(async (m) => {
77
+ const r = await m.discoverPresets([{ path: "/…/profiles/<名>/node_modules/dsh-mc-art-panel/preset", trust: "system" }],
78
+ new URL("file:///…/@deepseek-ai/dsh/"))
79
+ console.log(r.map((p) => p.id + " / " + p.name + " / broken=" + (p.broken ?? "no")).join("\n"))
80
+ })'
81
+ # → mc-studio / MC 模组工作室 / broken=no
82
+ ```
83
+
29
84
  ## 同一份源码,两种送达
30
85
 
31
86
  `lib/` 是**生成物**(`node build.mjs`),来源是仓库里的 `tools/mcart-plugin/{host,client}.js`
package/build.mjs CHANGED
@@ -40,6 +40,15 @@ const STRIPPER = join(HERE, '..', 'tools', 'strip_comments.py')
40
40
  * 客户端那一半永远 "import failed",页面上什么都不出现。
41
41
  * 所以包名只从 package.json 读一份,绝不手写。
42
42
  */
43
+ /** 生成物里不该出现的东西(相对任一路径都成立)。 */
44
+ export function isJunk(path) {
45
+ const parts = String(path).split(/[\\/]/)
46
+ const junkDirs = ['.git', '__pycache__', '.cache', '.pytest_cache', '.mypy_cache', '.ruff_cache',
47
+ '.venv', 'node_modules', '.DS_Store', '.ipynb_checkpoints']
48
+ if (parts.some((part) => junkDirs.includes(part))) return true
49
+ return /\.(pyc|pyo|egg-info|log|swp)$/.test(String(path))
50
+ }
51
+
43
52
  export const PACKAGE_NAME = JSON.parse(readFileSync(join(HERE, 'package.json'), 'utf8')).name
44
53
  if (typeof PACKAGE_NAME !== 'string' || PACKAGE_NAME === '') {
45
54
  throw new Error('panel/package.json 里没有有效的 name,客户端 bundle 不知道该注册成什么 id')
@@ -224,8 +233,9 @@ export function vendored() {
224
233
  for (const [from, to] of pairs) {
225
234
  rmSync(to, { recursive: true, force: true })
226
235
  mkdirSync(dirname(to), { recursive: true })
227
- // 不要 .git(发布物里没有历史;npm 也会把它排除掉,与其不一致不如自己排除)
228
- cpSync(from, to, { recursive: true, filter: (src) => !src.split(/[\\/]/).includes('.git') })
236
+ // 只带该带的东西:.git(没有历史)、__pycache__ / *.pyc(机器相关)、.cache(缓存)。
237
+ // npm 打包时本来也会排掉其中一些,但那不该是"能不能出垃圾"的唯一防线。
238
+ cpSync(from, to, { recursive: true, filter: (src) => !isJunk(src) })
229
239
  }
230
240
  return pairs
231
241
  }
package/lib/client.js CHANGED
@@ -9,7 +9,7 @@ window.__ModuleLoader__.load({
9
9
 
10
10
  // 真包世界里 React 是 require 来的;动态插件世界里它是注入的绑定。
11
11
  var React = require('react')
12
- var SOURCE = "const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]]\nconst add = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]]\nconst mul = (a, k) => [a[0] * k, a[1] * k, a[2] * k]\nconst dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]\nconst cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]\nconst vlen = (a) => Math.sqrt(dot(a, a))\nfunction unit(a) { const n = vlen(a); return n < 1e-12 ? [0, 0, 0] : mul(a, 1 / n) }\n\n\nfunction rotateAbout(point, origin, axis, degrees) {\n if (!degrees) return point\n const angle = (degrees * Math.PI) / 180\n const p = sub(point, origin)\n const c = Math.cos(angle)\n const s = Math.sin(angle)\n let r\n if (axis === 'x') r = [p[0], p[1] * c - p[2] * s, p[1] * s + p[2] * c]\n else if (axis === 'y') r = [p[0] * c + p[2] * s, p[1], -p[0] * s + p[2] * c]\n else r = [p[0] * c - p[1] * s, p[0] * s + p[1] * c, p[2]]\n return add(r, origin)\n}\n\n\nfunction boxOfQuads(quads) {\n const min = [Infinity, Infinity, Infinity]\n const max = [-Infinity, -Infinity, -Infinity]\n for (const quad of quads || []) for (const p of quad.p) for (let i = 0; i < 3; i++) {\n if (p[i] < min[i]) min[i] = p[i]\n if (p[i] > max[i]) max[i] = p[i]\n }\n if (!isFinite(min[0])) return { min: [-1, -1, -1], max: [1, 1, 1] }\n return { min: min, max: max }\n}\n\nfunction makeCamera(position, target, width, height, fovY) {\n const forward = unit(sub(target, position))\n const right = unit(cross(forward, [0, 1, 0]))\n const up = cross(right, forward)\n const focal = height / 2 / Math.tan(fovY / 2)\n return {\n project: function (point) {\n const offset = sub(point, position)\n const x = dot(offset, right)\n const y = dot(offset, up)\n const z = dot(offset, forward)\n if (z <= 0.02) return null\n return [width / 2 + (x * focal) / z, height / 2 - (y * focal) / z, z]\n },\n }\n}\n\nfunction orbitCamera(box, yaw, pitch, zoom, width, height, fovY) {\n const centre = mul(add(box.min, box.max), 0.5)\n const radius = Math.max(vlen(sub(box.max, box.min)) / 2, 0.05)\n const fit = radius / Math.sin(fovY / 2) / zoom\n const cp = Math.cos(pitch)\n const direction = [Math.sin(yaw) * cp, Math.sin(pitch), Math.cos(yaw) * cp]\n return makeCamera(add(centre, mul(direction, fit * 1.05)), centre, width, height, fovY)\n}\n\nconst EMPTY_ANIMATIONS = {}\n\nfunction animationRow(animation, timeMs) {\n if (animation === undefined || animation === null) return 0\n const order = Array.isArray(animation.order) && animation.order.length > 0 ? animation.order : null\n const frames = order === null ? animation.frames : order.length\n if (!(frames > 1)) return 0\n const ticks = Math.max(1, animation.frametime || 1)\n const step = Math.floor((timeMs || 0) / (ticks * 50))\n const index = ((step % frames) + frames) % frames\n return order === null ? index : order[index]\n}\n\nfunction stripOf(texture) {\n const animation = texture === undefined ? undefined : texture.animation\n if (animation === undefined || animation === null) return null\n const strip = animation.strip\n if (!(strip > 1) || texture.width <= 0) return null\n if (texture.height !== strip * texture.width) return null\n return strip\n}\n\nfunction renderScene(options) {\n const width = options.width\n const height = options.height\n const background = options.background\n const camera = options.camera\n const colour = options.colour === undefined ? new Float64Array(width * height * 4) : options.colour\n const depth = options.depth === undefined ? new Float64Array(width * height) : options.depth\n const overlay = options.overlay === undefined ? null : options.overlay\n const tint = options.tint === undefined ? null : options.tint\n const backgroundAlpha = options.backgroundAlpha === undefined ? 1 : options.backgroundAlpha\n const orthographic = options.orthographic === true\n if (overlay === null) {\n for (let i = 0; i < width * height; i++) {\n colour[i * 4] = background[0] / 255\n colour[i * 4 + 1] = background[1] / 255\n colour[i * 4 + 2] = background[2] / 255\n colour[i * 4 + 3] = backgroundAlpha\n depth[i] = Infinity\n }\n }\n const pick = options.pick === undefined ? null : options.pick\n for (let quadIndex = 0; quadIndex < options.quads.length; quadIndex++) {\n const quad = options.quads[quadIndex]\n const texture = options.textures[quad.tex]\n if (texture === undefined) continue\n const mode = quad.mode || 'opaque'\n const shade = quad.shade === undefined ? 1 : quad.shade\n for (const indices of [[0, 1, 2], [0, 2, 3]]) {\n const points = [quad.p[indices[0]], quad.p[indices[1]], quad.p[indices[2]]]\n const uvs = [quad.uv[indices[0]], quad.uv[indices[1]], quad.uv[indices[2]]]\n const screen = []\n let visible = true\n for (const point of points) {\n const projected = camera.project(point)\n if (projected === null) { visible = false; break }\n screen.push(projected)\n }\n if (!visible) continue\n const s0 = screen[0], s1 = screen[1], s2 = screen[2]\n const minx = Math.max(0, Math.floor(Math.min(s0[0], s1[0], s2[0])))\n const maxx = Math.min(width - 1, Math.ceil(Math.max(s0[0], s1[0], s2[0])))\n const miny = Math.max(0, Math.floor(Math.min(s0[1], s1[1], s2[1])))\n const maxy = Math.min(height - 1, Math.ceil(Math.max(s0[1], s1[1], s2[1])))\n if (minx > maxx || miny > maxy) continue\n const area = (s1[0] - s0[0]) * (s2[1] - s0[1]) - (s2[0] - s0[0]) * (s1[1] - s0[1])\n if (Math.abs(area) < 1e-9) continue\n const tw = texture.width\n const th = texture.height\n const data = texture.data\n const blend = mode === 'blend'\n const animation = (options.animations || EMPTY_ANIMATIONS)[quad.tex]\n const strip = stripOf({ width: tw, height: th, animation: animation })\n const row = strip === null ? 0 : animationRow(animation, options.timeMs)\n for (let y = miny; y <= maxy; y++) {\n const gy = y + 0.5\n for (let x = minx; x <= maxx; x++) {\n const gx = x + 0.5\n const w0 = ((s1[0] - gx) * (s2[1] - gy) - (s2[0] - gx) * (s1[1] - gy)) / area\n const w1 = ((s2[0] - gx) * (s0[1] - gy) - (s0[0] - gx) * (s2[1] - gy)) / area\n const w2 = 1 - w0 - w1\n if (w0 < -1e-6 || w1 < -1e-6 || w2 < -1e-6) continue\n let zs, u, v\n if (orthographic) {\n zs = w0 * s0[2] + w1 * s1[2] + w2 * s2[2]\n if (!(zs > 1e-9)) continue\n u = w0 * uvs[0][0] + w1 * uvs[1][0] + w2 * uvs[2][0]\n v = w0 * uvs[0][1] + w1 * uvs[1][1] + w2 * uvs[2][1]\n } else {\n const inv = w0 / s0[2] + w1 / s1[2] + w2 / s2[2]\n if (!(inv > 1e-9)) continue\n zs = 1 / inv\n u = (w0 * uvs[0][0] / s0[2] + w1 * uvs[1][0] / s1[2] + w2 * uvs[2][0] / s2[2]) / inv\n v = (w0 * uvs[0][1] / s0[2] + w1 * uvs[1][1] / s1[2] + w2 * uvs[2][1] / s2[2]) / inv\n }\n let tx = Math.trunc(u * tw)\n let ty = strip === null ? Math.trunc(v * th) : row * tw + Math.trunc(v * tw)\n if (tx < 0) tx = 0; else if (tx > tw - 1) tx = tw - 1\n if (ty < 0) ty = 0; else if (ty > th - 1) ty = th - 1\n const index = (ty * tw + tx) * 4\n const alpha = data[index + 3] / 255\n if (overlay === null) {\n if (blend) {\n if (!(alpha > 0)) continue\n if (!(zs <= depth[y * width + x])) continue\n } else {\n if (!(alpha >= 0.5)) continue\n if (!(zs < depth[y * width + x])) continue\n }\n depth[y * width + x] = zs\n if (pick !== null) pick[y * width + x] = quadIndex\n } else if (!(zs <= depth[y * width + x] + 1e-6)) {\n continue\n }\n const target = (y * width + x) * 4\n const r = tint === null ? (data[index] / 255) * shade : tint[0]\n const g = tint === null ? (data[index + 1] / 255) * shade : tint[1]\n const b = tint === null ? (data[index + 2] / 255) * shade : tint[2]\n if (overlay !== null) {\n colour[target] = r * overlay + colour[target] * (1 - overlay)\n colour[target + 1] = g * overlay + colour[target + 1] * (1 - overlay)\n colour[target + 2] = b * overlay + colour[target + 2] * (1 - overlay)\n } else if (blend) {\n colour[target] = r * alpha + colour[target] * (1 - alpha)\n colour[target + 1] = g * alpha + colour[target + 1] * (1 - alpha)\n colour[target + 2] = b * alpha + colour[target + 2] * (1 - alpha)\n } else {\n colour[target] = r\n colour[target + 1] = g\n colour[target + 2] = b\n }\n colour[target + 3] = 1\n }\n }\n }\n }\n const bytes = new Uint8ClampedArray(width * height * 4)\n for (let i = 0; i < bytes.length; i++) {\n const value = Math.round(colour[i] * 255)\n bytes[i] = value < 0 ? 0 : (value > 255 ? 255 : value)\n }\n return bytes\n}\n\n\nconst FACE_AXIS = { down: 'y', up: 'y', north: 'z', south: 'z', west: 'x', east: 'x' }\nconst FACE_OPPOSITE = { down: 'up', up: 'down', north: 'south', south: 'north', west: 'east', east: 'west' }\nconst HORIZONTAL_FACES = { north: true, south: true, west: true, east: true }\n\nconst VALUE_LABEL = {\n down: '下', up: '上', north: '北', south: '南', west: '西', east: '东',\n x: 'X 轴', y: 'Y 轴', z: 'Z 轴', none: '无',\n true: '是', false: '否',\n top: '上半', bottom: '下半',\n straight: '直', inner_left: '内左', inner_right: '内右',\n outer_left: '外左', outer_right: '外右',\n save: '保存', load: '读取', corner: '角', data: '数据',\n}\nconst AXIS_LABEL = {\n facing: '朝向', axis: '朝向(轴)', half: '上下', rotation: '旋转',\n conditional: '有条件', shape: '形状', mode: '模式',\n powered: '通电', locked: '锁定', delay: '延迟', open: '开合',\n}\n\nfunction labelOfValue(value) { return VALUE_LABEL[value] === undefined ? String(value) : VALUE_LABEL[value] }\nfunction labelOfAxis(name) { return AXIS_LABEL[name] === undefined ? String(name) : AXIS_LABEL[name] }\n\nfunction withKey(table, name, value) {\n const next = Object.assign({}, table || {})\n next[name] = value\n return next\n}\nfunction withoutKey(table, name) {\n const next = Object.assign({}, table || {})\n delete next[name]\n return next\n}\n\n\nfunction canonicalVariant(parts) {\n return Object.keys(parts).sort().map((name) => name + '=' + parts[name]).join(',')\n}\n\n\nfunction deriveVariant(axes, face, hitY, flip) {\n const out = {}\n const horizontal = HORIZONTAL_FACES[face] === true\n for (const axis of axes) {\n const name = axis.name\n if (name === 'axis') {\n if (FACE_AXIS[face] !== undefined) out[name] = FACE_AXIS[face]\n continue\n }\n if (name === 'facing') {\n const value = flip && horizontal ? FACE_OPPOSITE[face] : face\n if (value !== undefined && axis.values.indexOf(value) >= 0) out[name] = value\n continue\n }\n if (name === 'half') {\n const value = face === 'down' ? 'top' : (face === 'up' ? 'bottom' : (hitY > 0.5 ? 'top' : 'bottom'))\n if (axis.values.indexOf(value) >= 0) out[name] = value\n continue\n }\n }\n return out\n}\n\n\nfunction variantFor(axes, keys, defaults, face, hitY, flip, overrides) {\n if (!Array.isArray(axes) || axes.length === 0) return null\n const derived = deriveVariant(axes, face, hitY, flip)\n const fallback = defaults || {}\n const parts = {}\n for (const axis of axes) {\n const hand = (overrides || {})[axis.name]\n const value = hand !== undefined ? hand : (derived[axis.name] !== undefined ? derived[axis.name] : fallback[axis.name])\n if (value !== undefined) parts[axis.name] = value\n }\n if (Object.keys(parts).length !== axes.length) return null\n const key = canonicalVariant(parts)\n return (keys || []).indexOf(key) >= 0 ? key : null\n}\n\n\nfunction aroundOf(cells, at) {\n const out = []\n if (!Array.isArray(cells) || !Array.isArray(at)) return out\n for (const cell of cells) {\n if (cell === null || typeof cell !== 'object' || !Array.isArray(cell.at)) continue\n if (typeof cell.block !== 'string' || cell.block === '') continue\n const d = [cell.at[0] - at[0], cell.at[1] - at[1], cell.at[2] - at[2]]\n if (d[0] === 0 && d[1] === 0 && d[2] === 0) continue\n if (Math.abs(d[0]) > 1 || Math.abs(d[1]) > 1 || Math.abs(d[2]) > 1) continue\n const item = { d: d, block: cell.block }\n if (typeof cell.variant === 'string' && cell.variant !== '') item.variant = cell.variant\n out.push(item)\n }\n return out\n}\n\n\nconst GUI_FACE_SHADE = { up: 1.0, east: 0.637, north: 0.435,\n down: 0.4, south: 0.4, west: 0.4 }\nconst GUI_FACE_SHADE_FLAT = { up: 1.0, east: 0.417, north: 0.624,\n down: 0.4, south: 0.841, west: 0.543 }\n\n\nfunction guiItemRotation(quads, rotation, table) {\n const yaw = rotation === undefined || rotation === null ? 0 : (rotation[1] || 0)\n const pitch = rotation === undefined || rotation === null ? 0 : (rotation[0] || 0)\n const shade = table === undefined || table === null ? GUI_FACE_SHADE : table\n return quads.map((quad) => {\n let p = quad.p\n if (yaw) p = p.map((point) => rotateAbout(point, [0.5, 0.5, 0.5], 'y', yaw))\n if (pitch) p = p.map((point) => rotateAbout(point, [0.5, 0.5, 0.5], 'x', pitch))\n const lit = quad.shaded === false ? (quad.shade === undefined ? 1.0 : quad.shade)\n : (shade[quad.face] === undefined ? 1.0 : shade[quad.face])\n return { p: p, uv: quad.uv, tex: quad.tex, shade: lit, mode: quad.mode,\n face: quad.face, shaded: quad.shaded }\n })\n}\n\n\nfunction iconCamera(quads, width, height, fill) {\n const box = boxOfQuads(quads)\n const spanX = Math.max(box.max[0] - box.min[0], 1e-6)\n const spanY = Math.max(box.max[1] - box.min[1], 1e-6)\n const ppu = Math.min(width, height) * (fill === undefined ? 0.98 : fill) / Math.max(spanX, spanY)\n const centre = [(box.min[0] + box.max[0]) / 2, (box.min[1] + box.max[1]) / 2]\n const mid = (box.min[2] + box.max[2]) / 2\n return {\n project: function (point) {\n return [width / 2 + (point[0] - centre[0]) * ppu,\n height / 2 - (point[1] - centre[1]) * ppu,\n mid + 1 - point[2] + 0.5]\n },\n }\n}\n\n\nfunction drawItemIcon(canvas, recipe, frameIndex, decoded) {\n const width = canvas.width, height = canvas.height\n const paint = canvas.getContext('2d')\n paint.clearRect(0, 0, width, height)\n if (recipe === null || recipe === undefined) return\n if (recipe.shape === 'iso') {\n const hasDisplay = recipe.display !== null && recipe.display !== undefined\n const rotation = hasDisplay ? (recipe.display.rotation || [0, 0, 0]) : [0, 0, 0]\n const quads = guiItemRotation(recipe.quads || [], rotation,\n hasDisplay ? GUI_FACE_SHADE : GUI_FACE_SHADE_FLAT)\n if (quads.length === 0) return\n const bytes = renderScene({ width: width, height: height,\n background: [0, 0, 0], backgroundAlpha: 0, orthographic: true,\n camera: iconCamera(quads, width, height, 0.98), quads: quads,\n textures: decoded, animations: recipe.animations || {},\n timeMs: animTicks * 50 })\n const frame = paint.createImageData(width, height)\n frame.data.set(bytes)\n paint.putImageData(frame, 0, 0)\n return\n }\n const frames = recipe.frames && recipe.frames.length > 0 ? recipe.frames : null\n const layers = frames === null ? (recipe.layers || [])\n : (frames[((frameIndex || 0) % frames.length + frames.length) % frames.length].layers || [])\n const out = new Uint8ClampedArray(width * height * 4)\n for (const id of layers) {\n const texture = decoded === undefined ? undefined : decoded[id]\n if (texture === undefined || texture.data === undefined) continue\n const sw = texture.width\n const sh = texture.height\n if (!(sw > 0 && sh > 0)) continue\n const animation = (recipe.animations || {})[id]\n const strip = stripOf({ width: sw, height: sh, animation: animation })\n const top = strip === null ? 0 : animationRow(animation, animTicks * 50) * sw\n const rows = strip === null ? sh : sw\n for (let y = 0; y < height; y++) {\n const sy = top + Math.floor((y * rows) / height)\n if (sy < 0 || sy >= sh) continue\n for (let x = 0; x < width; x++) {\n const sx = Math.floor((x * sw) / width)\n const from = (sy * sw + sx) * 4\n const alpha = texture.data[from + 3] / 255\n if (alpha <= 0) continue\n const to = (y * width + x) * 4\n out[to] = texture.data[from] * alpha + out[to] * (1 - alpha)\n out[to + 1] = texture.data[from + 1] * alpha + out[to + 1] * (1 - alpha)\n out[to + 2] = texture.data[from + 2] * alpha + out[to + 2] * (1 - alpha)\n out[to + 3] = Math.max(out[to + 3], texture.data[from + 3])\n }\n }\n }\n const frame = paint.createImageData(width, height)\n frame.data.set(out)\n paint.putImageData(frame, 0, 0)\n}\n\n\nfunction clearViewport(canvas, width, height) {\n canvas.width = width\n canvas.height = height\n const paint = canvas.getContext('2d')\n if (paint !== null && paint !== undefined) paint.clearRect(0, 0, width, height)\n}\n\n\nfunction itemIconUrl(recipe, decoded, size) {\n if (recipe === null || recipe === undefined) return null\n const canvas = document.createElement('canvas')\n canvas.width = size\n canvas.height = size\n drawItemIcon(canvas, recipe, 0, decoded)\n return canvas.toDataURL('image/png')\n}\n\n\nfunction faceHitY(quad, pixelX, pixelY, camera) {\n if (quad === undefined || camera === undefined || camera === null) return 0.5\n const screen = []\n for (const point of quad.p) {\n const projected = camera.project(point)\n if (projected === null) return 0.5\n screen.push(projected)\n }\n for (const triangle of [[0, 1, 2], [0, 2, 3]]) {\n const a = screen[triangle[0]], b = screen[triangle[1]], c = screen[triangle[2]]\n const area = (b[0] - a[0]) * (c[1] - a[1]) - (c[0] - a[0]) * (b[1] - a[1])\n if (Math.abs(area) < 1e-9) continue\n const w0 = ((b[0] - pixelX) * (c[1] - pixelY) - (c[0] - pixelX) * (b[1] - pixelY)) / area\n const w1 = ((c[0] - pixelX) * (a[1] - pixelY) - (a[0] - pixelX) * (c[1] - pixelY)) / area\n const w2 = 1 - w0 - w1\n if (w0 < -0.02 || w1 < -0.02 || w2 < -0.02) continue\n const weights = [w0, w1, w2]\n let y = 0\n for (let i = 0; i < 3; i++) y += quad.p[triangle[i]][1] * weights[i]\n const ys = quad.p.map((point) => point[1])\n const min = Math.min(ys[0], ys[1], ys[2], ys[3])\n const max = Math.max(ys[0], ys[1], ys[2], ys[3])\n return max - min < 1e-6 ? 0.5 : (y - min) / (max - min)\n }\n return 0.5\n}\n\n\nfunction variantSummary(axes, props) {\n const parts = []\n for (const axis of axes) {\n const value = props[axis.name]\n if (value === undefined) continue\n parts.push(labelOfAxis(axis.name) + ' ' + labelOfValue(value))\n }\n return parts.length === 0 ? '按方块默认' : parts.join(' · ')\n}\n\nconst CSS = [\n '.mcart-root{display:flex;flex-direction:column;gap:8px;padding:10px;height:100%;box-sizing:border-box;overflow-y:auto;overflow-x:hidden;font-size:12px}',\n '.mcart-bar{display:flex;align-items:center;gap:6px;flex-wrap:wrap}',\n '.mcart-title{font-size:13px;font-weight:600}',\n '.mcart-sub{opacity:.6;font-size:11px}',\n '.mcart-path{opacity:.75;font-size:11px;word-break:break-all;flex:1;min-width:0}',\n '.mcart-list{overflow:auto;border:1px solid rgba(127,127,127,.25);border-radius:8px;padding:4px;max-height:220px}',\n '.mcart-group{font-size:10px;letter-spacing:.05em;text-transform:uppercase;opacity:.55;padding:6px 6px 2px}',\n '.mcart-item{display:block;width:100%;text-align:left;background:none;border:0;border-radius:6px;color:inherit;font:inherit;font-size:12px;padding:4px 6px;cursor:pointer}',\n '.mcart-item:hover{background:rgba(127,127,127,.16)}',\n '.mcart-item[data-on=\"1\"]{background:rgba(127,127,127,.28);font-weight:600}',\n '.mcart-id{font-size:10px;opacity:.45;margin-left:4px}',\n '.mcart-stage{flex:none;display:flex;flex-direction:column;gap:6px}',\n '.mcart-canvas{width:100%;height:240px;display:block;border-radius:8px;background:#1a181c;border:1px solid rgba(127,127,127,.25);cursor:grab;touch-action:none}',\n '.mcart-canvas:active{cursor:grabbing}',\n '.mcart-btn{background:rgba(127,127,127,.14);border:1px solid rgba(127,127,127,.3);border-radius:6px;color:inherit;font:inherit;font-size:11px;padding:3px 8px;cursor:pointer;flex:none}',\n '.mcart-btn:hover{background:rgba(127,127,127,.26)}',\n '.mcart-btn[disabled]{opacity:.35;cursor:default}',\n '.mcart-icon{background:none;border:0;color:inherit;font:inherit;font-size:13px;line-height:1;padding:2px 6px;border-radius:6px;cursor:pointer}',\n '.mcart-icon:hover{background:rgba(127,127,127,.2)}',\n '.mcart-input{flex:1;min-width:0;background:rgba(0,0,0,.18);border:1px solid rgba(127,127,127,.3);border-radius:6px;color:inherit;font:inherit;font-size:11px;padding:4px 6px}',\n '.mcart-card{border:1px solid rgba(127,127,127,.22);border-radius:10px;padding:10px;display:flex;flex-direction:column;gap:8px;background:rgba(127,127,127,.04)}',\n '.mcart-row{display:flex;align-items:center;gap:6px;border-radius:6px;padding:2px 4px}',\n '.mcart-row:hover{background:rgba(127,127,127,.14)}',\n '.mcart-grow{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',\n '.mcart-projects{max-height:190px;overflow:auto;display:flex;flex-direction:column;gap:1px;border:1px solid rgba(127,127,127,.18);border-radius:8px;padding:3px}',\n '.mcart-switch{display:flex;align-items:flex-start;gap:6px;cursor:pointer;padding:2px 0}',\n '.mcart-switch input{margin:1px 0 0 0;flex:none}',\n '.mcart-hint{font-size:10px;opacity:.5;margin-left:19px}',\n '.mcart-mods{border:1px solid rgba(127,127,127,.18);border-radius:8px;padding:3px 6px;display:flex;flex-direction:column;gap:1px;max-height:160px;overflow:auto}',\n '.mcart-chip{font-size:10px;padding:1px 6px;border-radius:999px;background:rgba(127,127,127,.16)}',\n '.mcart-note{opacity:.66;font-size:11px}',\n '.mcart-bad{font-size:11px;color:#e0a06c;word-break:break-all}',\n '.mcart-err{font-size:11px;color:#e06c6c;word-break:break-all}',\n '.mcart-edit{display:flex;flex-direction:column;gap:6px}',\n '.mcart-edit-canvas{display:block;width:100%;height:auto;max-height:52vh;object-fit:contain;image-rendering:pixelated;border-radius:4px;background:#111;cursor:crosshair;touch-action:none}',\n '.mcart-tools{display:flex;gap:4px;flex-wrap:wrap;align-items:center}',\n '.mcart-tool[data-on=\"1\"]{background:rgba(127,127,127,.42);font-weight:600}',\n '.mcart-swatches{display:flex;gap:3px;flex-wrap:wrap;max-height:56px;overflow:auto}',\n '.mcart-swatch{width:16px;height:16px;border-radius:3px;border:1px solid rgba(127,127,127,.5);cursor:pointer;padding:0;flex:none}',\n '.mcart-color{width:22px;height:22px;border-radius:4px;border:1px solid rgba(127,127,127,.5);flex:none}',\n '.mcart-tabs{display:flex;gap:4px;flex-wrap:wrap;align-items:center}',\n '.mcart-picker{width:36px;height:24px;padding:0;border:1px solid rgba(127,127,127,.5);border-radius:4px;background:none;flex:none}',\n '.mcart-alpha{flex:1;min-width:60px}',\n '.mcart-select{flex:1;min-width:0;background:rgba(0,0,0,.18);border:1px solid rgba(127,127,127,.3);border-radius:6px;color:inherit;font:inherit;font-size:11px;padding:3px 4px}',\n '.mcart-hotbar{display:flex;gap:3px;flex-wrap:wrap;max-height:124px;overflow:auto}',\n '.mcart-slot{width:26px;height:26px;border:1px solid rgba(127,127,127,.4);border-radius:4px;background:none;background-size:cover;image-rendering:pixelated;padding:0;flex:none;cursor:pointer}',\n '.mcart-slot:hover{background-color:rgba(127,127,127,.22)}',\n '.mcart-slot[data-on=\"1\"]{outline:2px solid currentColor;outline-offset:1px}',\n '.mcart-slotq{font-size:13px;line-height:1;opacity:.6}',\n '.mcart-icon[data-on=\"1\"]{background:rgba(127,127,127,.3)}',\n '.mcart-viewport{position:relative;flex:none}',\n '.mcart-zoom{position:absolute;right:6px;top:6px;display:flex;gap:3px;opacity:.72}',\n '.mcart-zoom:hover{opacity:1}',\n '.mcart-zoombtn{width:22px;height:22px;padding:0;display:flex;align-items:center;justify-content:center;font-size:12px;line-height:1;background:rgba(20,20,24,.62);border-color:rgba(200,200,210,.45)}',\n '.mcart-hud{position:absolute;left:0;right:0;bottom:0;display:flex;flex-direction:column;align-items:center;gap:4px;padding:0 6px 6px;pointer-events:none}',\n '.mcart-hudname{font-size:11px;padding:1px 8px;border-radius:999px;background:rgba(20,20,24,.72);border:1px solid rgba(200,200,210,.28);text-shadow:0 1px 2px rgba(0,0,0,.85)}',\n '.mcart-hudbar{display:flex;align-items:center;gap:3px;padding:3px;border-radius:8px;background:rgba(20,20,24,.64);border:1px solid rgba(200,200,210,.32);pointer-events:auto}',\n '.mcart-hudslot{width:34px;height:34px;padding:0;flex:none;display:flex;align-items:center;justify-content:center;border-radius:4px;border:1px solid rgba(120,120,130,.45);background:rgba(0,0,0,.32);cursor:pointer}',\n '.mcart-hudslot:hover{border-color:rgba(240,240,250,.8);background:rgba(255,255,255,.14)}',\n '.mcart-hudslot[data-on=\"1\"]{border-color:#f2f2f6;box-shadow:inset 0 0 0 1px #f2f2f6,0 0 6px rgba(0,0,0,.6)}',\n '.mcart-hudicon{width:30px;height:30px;display:block;image-rendering:pixelated}',\n '.mcart-hudempty{background:rgba(0,0,0,.22);border-color:rgba(120,120,130,.26);cursor:default}',\n '.mcart-hudpage{width:16px;height:34px;padding:0;flex:none;font:inherit;font-size:12px;line-height:1;color:inherit;background:none;border:0;cursor:pointer;opacity:.7}',\n '.mcart-hudpage:hover{opacity:1}',\n '.mcart-hudpage[disabled]{opacity:.22;cursor:default}',\n '.mcart-poster{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);image-rendering:pixelated;pointer-events:none}',\n '.mcart-grip{display:flex;align-items:center;justify-content:center;height:13px;cursor:ns-resize;touch-action:none}',\n '.mcart-gripbar{width:46px;height:4px;border-radius:999px;background:rgba(127,127,127,.42)}',\n '.mcart-grip:hover .mcart-gripbar{background:rgba(210,210,220,.8)}',\n '.mcart-grip:active .mcart-gripbar{background:rgba(235,235,245,.95)}',\n '.mcart-src{display:flex;align-items:center;gap:6px}',\n '.mcart-ref{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:0 0 6px;padding:6px 10px;border-radius:8px;background:rgba(127,127,127,.14);border:1px solid rgba(127,127,127,.3);font-size:12px}',\n '.mcart-ref-path{opacity:.66;word-break:break-all}',\n].join('')\n\nconst CATEGORIES = [\n { key: 'biome', label: '群系' },\n { key: 'structure', label: '结构' },\n { key: 'entity', label: '实体' },\n { key: 'block', label: '方块' },\n]\n\nconst TAB_ID = 'mc-art.atlas'\n\nconst decoded = {}\nconst failedTex = {}\nconst indexes = {}\nconst imageNodes = {}\nconst drag = { active: false, x: 0, y: 0, moved: 0 }\nlet pickBuffer = null\nlet ghostAsk = ''\nconst icons = {}\nconst iconTried = {}\nconst itemRecipes = {}\nconst itemUrls = {}\nconst hudCanvases = {}\nconst TINT_REMOVE = [0.85, 0.2, 0.18]\nconst painting = { active: false }\n\nconst EDIT_ZOOM = 18\nconst EDIT_UNDO = 24\nconst EDIT_PALETTE = 64\n\nlet hudFollowed = ''\nconst VIEW_H = 240\nconst VIEW_H_MIN = 140\nconst VIEW_H_MAX = 760\nconst gripDrag = { active: false, y: 0, origin: 0 }\nconst clampViewH = (value) => Math.max(VIEW_H_MIN, Math.min(VIEW_H_MAX, Math.round(value)))\n\nfunction texturePoint(rect, target, clientX, clientY) {\n if (rect === undefined || rect === null) return null\n if (!(rect.width > 0) || !(rect.height > 0)) return null\n if (!(target.w > 0) || !(target.h > 0)) return null\n const scale = Math.min(rect.width / target.w, rect.height / target.h)\n if (!(scale > 0)) return null\n const left = rect.left + (rect.width - target.w * scale) / 2\n const top = rect.top + (rect.height - target.h * scale) / 2\n return [Math.floor((clientX - left) / scale), Math.floor((clientY - top) / scale)]\n}\n\nconst PAGE_SIZE = 20\nconst ICON_BATCH = 80\nconst ITEM_PAGE = 40\nconst HUD_SLOTS = 9\n\n\nfunction failureOf(reply) {\n if (reply === null || reply === undefined) return '没有返回结果'\n if (reply.error === undefined || reply.error === null) return null\n return String(reply.error)\n}\nfunction countsOf(list, keyOf, labelOf) {\n const out = {}\n for (const entry of list || []) {\n const key = keyOf(entry) || ''\n if (key === '') continue\n if (out[key] === undefined) out[key] = { count: 0, label: labelOf(entry) || key }\n out[key].count += 1\n }\n return out\n}\n\nfunction cssColour(colour) {\n if (colour === null || colour === undefined) return 'transparent'\n return 'rgba(' + colour[0] + ',' + colour[1] + ',' + colour[2] + ',' + (colour[3] / 255) + ')'\n}\n\nfunction hexOf(colour) {\n const c = colour === null || colour === undefined ? [0, 0, 0, 255] : colour\n const two = (v) => ('0' + Math.max(0, Math.min(255, Math.round(v))).toString(16)).slice(-2)\n return '#' + two(c[0]) + two(c[1]) + two(c[2])\n}\n\nfunction fromHex(text, alpha) {\n const raw = String(text === undefined || text === null ? '' : text).replace('#', '')\n const a = alpha === undefined ? 255 : alpha\n if (raw.length !== 6) return [0, 0, 0, a]\n return [parseInt(raw.slice(0, 2), 16), parseInt(raw.slice(2, 4), 16), parseInt(raw.slice(4, 6), 16), a]\n}\n\n\nfunction faceLabel(faces) {\n const list = faces === undefined || faces === null ? [] : faces\n if (list.length === 0) return '贴图'\n const top = list.indexOf('up') >= 0\n const bottom = list.indexOf('down') >= 0\n const side = list.some((face) => face === 'north' || face === 'south' || face === 'west' || face === 'east')\n const parts = []\n if (top) parts.push('顶面')\n if (side) parts.push('侧面')\n if (bottom) parts.push('底面')\n return parts.length === 0 ? '贴图' : parts.join('+')\n}\n\nfunction sameColour(a, b) {\n return a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3]\n}\n\n\nfunction addToPalette(palette, colour, limit) {\n if (colour === null || colour === undefined) return palette\n if (palette.some((c) => sameColour(c, colour))) return palette\n const next = palette.concat([colour])\n return next.length > limit ? next.slice(next.length - limit) : next\n}\n\nfunction readPixel(pixels, width, x, y) {\n const at = (y * width + x) * 4\n return [pixels[at], pixels[at + 1], pixels[at + 2], pixels[at + 3]]\n}\n\nfunction writePixel(pixels, width, x, y, colour) {\n const at = (y * width + x) * 4\n pixels[at] = colour[0]\n pixels[at + 1] = colour[1]\n pixels[at + 2] = colour[2]\n pixels[at + 3] = colour[3]\n}\n\n\nfunction paletteOf(pixels, limit) {\n const seen = []\n const known = {}\n for (let i = 0; i < pixels.length; i += 4) {\n if (pixels[i + 3] === 0) continue\n const key = pixels[i] + ',' + pixels[i + 1] + ',' + pixels[i + 2] + ',' + pixels[i + 3]\n if (known[key] === true) continue\n known[key] = true\n seen.push([pixels[i], pixels[i + 1], pixels[i + 2], pixels[i + 3]])\n if (seen.length >= limit) break\n }\n return seen\n}\n\nlet canvasGeneration = 0\nlet lastDrawKey = ''\nlet lastCamera = null\nlet animTicks = 0\n\nconst roots = {}\nconst listeners = []\nlet pending = null\nfunction setPending(next) {\n pending = next\n const copy = listeners.slice()\n for (const listener of copy) listener()\n}\nfunction usePending() {\n const pair = React.useState(pending)\n React.useEffect(() => {\n const listener = () => pair[1](pending)\n listeners.push(listener)\n return () => {\n const at = listeners.indexOf(listener)\n if (at >= 0) listeners.splice(at, 1)\n }\n }, [])\n return pair[0]\n}\n\nreturn {\n inject: ['timer'],\n apply(ctx) {\n const slots = ctx.get('slots')\n if (slots === undefined) return\n const timer = ctx.timer\n ctx.effect(() => styles.insert(CSS))\n\n const tabs = ctx.get('sidebarRightTabs')\n const sidebarRight = ctx.get('sidebarRight')\n const inRightColumn = tabs !== undefined && sidebarRight !== undefined\n\n function Atlas(props) {\n const sessionId = props.sessionId === undefined ? '' : String(props.sessionId)\n const indexPair = React.useState(null)\n const failurePair = React.useState(null)\n const noticePair = React.useState(null)\n const nearbyPair = React.useState(null)\n const choicePair = React.useState(null)\n const scenePair = React.useState(null)\n const tickPair = React.useState(0)\n const yawPair = React.useState(0.72)\n const pitchPair = React.useState(0.34)\n const zoomPair = React.useState(1)\n const canvasPair = React.useState(null)\n const scratchPair = React.useState(null)\n const sizePair = React.useState([340, 260])\n const viewHPair = React.useState(VIEW_H)\n const posterPair = React.useState(null)\n const itemMsgPair = React.useState(null)\n const savedSettingsPair = React.useState(null)\n const busyPair = React.useState(false)\n const pickingPair = React.useState(false)\n const draftPair = React.useState('')\n const settingsPair = React.useState(null)\n const savePair = React.useState(null)\n const modsOpenPair = React.useState(false)\n const rootPair = React.useState(roots[sessionId] === undefined ? '' : roots[sessionId])\n const index = indexPair[0], setIndex = indexPair[1]\n const failure = failurePair[0], setFailure = failurePair[1]\n const notice = noticePair[0], setNotice = noticePair[1]\n const nearby = nearbyPair[0], setNearby = nearbyPair[1]\n const choice = choicePair[0], setChoice = choicePair[1]\n const scene = scenePair[0], setScene = scenePair[1]\n const tick = tickPair[0], setTick = tickPair[1]\n const yaw = yawPair[0], setYaw = yawPair[1]\n const pitch = pitchPair[0], setPitch = pitchPair[1]\n const zoom = zoomPair[0], setZoom = zoomPair[1]\n const canvas = canvasPair[0], setCanvas = canvasPair[1]\n const scratch = scratchPair[0], setScratch = scratchPair[1]\n const size = sizePair[0], setSize = sizePair[1]\n const viewH = viewHPair[0], setViewH = viewHPair[1]\n const posterCanvas = posterPair[0], setPosterCanvas = posterPair[1]\n const itemMsg = itemMsgPair[0], setItemMsg = itemMsgPair[1]\n const savedSettings = savedSettingsPair[0], setSavedSettings = savedSettingsPair[1]\n const busy = busyPair[0], setBusy = busyPair[1]\n const picking = pickingPair[0], setPicking = pickingPair[1]\n const draft = draftPair[0], setDraft = draftPair[1]\n const settings = settingsPair[0], setSettings = settingsPair[1]\n const saveState = savePair[0], setSaveState = savePair[1]\n const modsOpen = modsOpenPair[0], setModsOpen = modsOpenPair[1]\n const root = rootPair[0], setRoot = rootPair[1]\n const editPair = React.useState(null)\n const undoPair = React.useState([])\n const editTickPair = React.useState(0)\n const editCanvasPair = React.useState(null)\n const editScratchPair = React.useState(null)\n const editBusyPair = React.useState(false)\n const editMsgPair = React.useState(null)\n const voxelPair = React.useState(null)\n const hoverPair = React.useState(null)\n const ghostPair = React.useState(null)\n const iconTickPair = React.useState(0)\n const voxel = voxelPair[0], setVoxel = voxelPair[1]\n const hover = hoverPair[0], setHover = hoverPair[1]\n const ghost = ghostPair[0], setGhost = ghostPair[1]\n const iconTick = iconTickPair[0], setIconTick = iconTickPair[1]\n const edit = editPair[0], setEdit = editPair[1]\n const undo = undoPair[0], setUndo = undoPair[1]\n const editTick = editTickPair[0], setEditTick = editTickPair[1]\n const editCanvas = editCanvasPair[0], setEditCanvas = editCanvasPair[1]\n const editScratch = editScratchPair[0], setEditScratch = editScratchPair[1]\n const editBusy = editBusyPair[0], setEditBusy = editBusyPair[1]\n const editMsg = editMsgPair[0], setEditMsg = editMsgPair[1]\n const refSourcesPair = React.useState([])\n const refSources = refSourcesPair[0], setRefSources = refSourcesPair[1]\n const itemPair = React.useState(null)\n const item = itemPair[0], setItem = itemPair[1]\n const iconPair = React.useState(null)\n const iconPick = iconPair[0], setIconPick = iconPair[1]\n const itemOpenPair = React.useState(false)\n const itemOpen = itemOpenPair[0], setItemOpen = itemOpenPair[1]\n const hudPagePair = React.useState(1)\n const hudPage = hudPagePair[0], setHudPage = hudPagePair[1]\n const previewPair = React.useState(null)\n const previewItem = previewPair[0], setPreviewItem = previewPair[1]\n const filterPair = React.useState('')\n const filter = filterPair[0], setFilter = filterPair[1]\n const pickGroupPair = React.useState('')\n const pickGroup = pickGroupPair[0], setPickGroup = pickGroupPair[1]\n const pickFamilyPair = React.useState('')\n const pickFamily = pickFamilyPair[0], setPickFamily = pickFamilyPair[1]\n const pickPagePair = React.useState(1)\n const pickPage = pickPagePair[0], setPickPage = pickPagePair[1]\n\n function activeProjectId() {\n if (choice !== null && choice.project !== undefined && choice.project !== null) return choice.project\n const projects = index === null ? [] : (index.projects || [])\n return projects.length > 0 ? projects[0].id : null\n }\n\n function open(target, where) {\n setBusy(true)\n setChoice(target)\n setPreviewItem(null)\n painting.active = false\n setEdit(null)\n setUndo([])\n setEditMsg(null)\n setVoxel(null)\n setHover(null)\n setGhost(null)\n const want = { root: where, project: target.project, kind: target.kind, id: target.id, have: Object.keys(decoded) }\n host.call('atlas.scene', want).then((result) => {\n setBusy(false)\n if (result === null || result === undefined) { setFailure('scene returned nothing'); return }\n if (result.error !== undefined) { setFailure(result.error); setScene(null); return }\n setFailure(null)\n for (const key of Object.keys(imageNodes)) delete imageNodes[key]\n setScene(result)\n }).catch((error) => { setBusy(false); setFailure(String(error && error.message ? error.message : error)) })\n }\n\n function firstOfProject(project) {\n if (project === undefined || project === null) return null\n for (const category of CATEGORIES) {\n const items = (project.items && project.items[category.key]) || []\n if (items.length > 0) return { project: project.id, kind: category.key, id: items[0].id }\n }\n return null\n }\n\n function firstPick(result) {\n const projects = result.projects || []\n for (const category of CATEGORIES) {\n for (const project of projects) {\n const items = (project.items && project.items[category.key]) || []\n if (items.length > 0) return { project: project.id, kind: category.key, id: items[0].id }\n }\n }\n return null\n }\n\n function scan(where, force, wantProject) {\n if (where === undefined || where === '') { setIndex(null); return }\n if (force !== true && indexes[where] !== undefined) {\n const cached = indexes[where]\n setIndex(cached)\n setFailure(null)\n const project = wantProject === undefined || wantProject === null ? null : (cached.projects || []).filter((p) => p.id === wantProject)[0]\n const first = project !== null && project !== undefined ? firstOfProject(project) : firstPick(cached)\n if (first !== null) open(first, where)\n return\n }\n setBusy(true)\n setFailure(null)\n host.call('atlas.scan', { root: where }).then((result) => {\n setBusy(false)\n if (result === null || result === undefined) { setFailure('scan returned nothing'); return }\n setIndex(result)\n if (result.error !== undefined) { setFailure(result.error); return }\n const projects = result.projects || []\n if (projects.length === 0) { setFailure('这个目录里没有找到资源包或 mc-art.atlas.json'); setScene(null); return }\n setFailure(null)\n indexes[where] = result\n const project = wantProject === undefined || wantProject === null ? null : projects.filter((p) => p.id === wantProject)[0]\n const first = project !== null && project !== undefined ? firstOfProject(project) : firstPick(result)\n if (first !== null) open(first, where)\n }).catch((error) => { setBusy(false); setFailure(String(error && error.message ? error.message : error)) })\n }\n\n function commitRoot(next) {\n roots[sessionId] = next\n setRoot(next)\n setDraft('')\n setNotice(null)\n setSettings(null)\n scan(next, false, null)\n }\n\n function openNearby(rootPath, projectId) {\n roots[sessionId] = rootPath\n setRoot(rootPath)\n setNotice(null)\n scan(rootPath, false, projectId)\n }\n\n function runPicker(start, onPicked) {\n if (picking === true) return\n setPicking(true)\n setNotice('正在打开系统的目录选择器…')\n host.call('atlas.pickDirectory', { start: start })\n .then((result) => {\n setPicking(false)\n if (result === null || result === undefined) { setNotice('目录选择器没有返回结果。'); return }\n if (result.error !== undefined) { setNotice(String(result.error)); return }\n if (result.supported === false) {\n setNotice(String(result.detail || '这个环境没有可用的目录选择器,') + '请在下面的输入框里直接填路径。')\n return\n }\n if (result.cancelled === true) { setNotice(null); return }\n if (typeof result.path === 'string' && result.path.length > 0) { setNotice(null); onPicked(result.path); return }\n setNotice('目录选择器没有返回路径。')\n })\n .catch((error) => { setPicking(false); setNotice('目录选择器失败:' + String(error && error.message ? error.message : error)) })\n }\n\n function pickDirectory() {\n runPicker(root !== '' ? root : (nearby !== null ? nearby.cwd : ''), commitRoot)\n }\n\n function loadSettings() {\n const projectId = activeProjectId()\n if (projectId === null) return\n setSaveState(null)\n setModsOpen(false)\n setSettings({ loading: true })\n host.call('atlas.settings', { root: root, project: projectId }).then((result) => {\n if (result === null || result === undefined || result.error !== undefined) {\n setSettings({ error: result !== null && result !== undefined && result.error !== undefined ? result.error : '设置读取失败' })\n return\n }\n setSettings(Object.assign({ loading: false }, result))\n }).catch((error) => setSettings({ error: String(error && error.message ? error.message : error) }))\n }\n\n function openSettings() {\n if (settings !== null) { setSettings(null); return }\n loadSettings()\n }\n\n function saveSettings(next, rescan) {\n const modMap = {}\n for (const item of next.mods || []) modMap[item.name] = item.on === true\n setSaveState('保存中…')\n host.call('atlas.saveSettings', {\n root: root, project: next.project, directory: next.directory,\n includeGenerated: next.includeGenerated, includeMods: next.includeMods, mods: modMap,\n }).then((result) => {\n const failed = result !== null && result !== undefined && result.error !== undefined\n setSaveState(failed ? ('保存失败:' + result.error) : '已保存')\n if (failed) { setSavedSettings(null); return }\n const on = (next.mods || []).filter((item) => item.on === true).length\n setSavedSettings({\n path: result.path === undefined ? '' : String(result.path),\n note: '参考目录改成了 ' + (next.directory === '' ? '(空)' : next.directory)\n + ';' + on + '/' + (next.mods || []).length + ' 个 mod 开着,'\n + (next.includeGenerated === false ? '不含' : '含') + '本项目自己的贴图。请按这个重做',\n })\n if (rescan === true) loadSettings()\n }).catch((error) => {\n setSavedSettings(null)\n setSaveState('保存失败:' + String(error && error.message ? error.message : error))\n })\n }\n\n function patchSettings(patch, rescan) {\n if (settings === null) return\n const next = Object.assign({}, settings, patch)\n setSettings(next)\n saveSettings(next, rescan)\n }\n\n function toggleMod(name, on) {\n if (settings === null) return\n const mods = (settings.mods || []).map((item) => (item.name === name ? Object.assign({}, item, { on: on }) : item))\n const next = Object.assign({}, settings, { mods: mods })\n setSettings(next)\n saveSettings(next)\n }\n\n React.useEffect(() => {\n if (root !== '') { scan(root, false, null); return }\n host.call('atlas.session', { sessionId: sessionId })\n .then((result) => {\n const cwd = result !== null && result !== undefined && typeof result.cwd === 'string' && result.cwd.length > 0 ? result.cwd : null\n if (cwd === null) return null\n setNearby({ cwd: cwd, loading: true, projects: [], error: null })\n return host.call('atlas.projects', { path: cwd })\n })\n .then((result) => {\n if (result === null || result === undefined) return\n if (result.error !== undefined) { setNearby({ cwd: result.base || '', loading: false, projects: [], error: result.error }); return }\n setNearby({ cwd: result.base, loading: false, projects: result.projects || [], error: null })\n })\n .catch((error) => setNearby({ cwd: '', loading: false, projects: [], error: String(error && error.message ? error.message : error) }))\n }, [])\n\n React.useEffect(() => {\n if (canvas === null) return\n const width = canvas.clientWidth || 340\n const height = canvas.clientHeight || 240\n if (width !== size[0] || height !== size[1]) setSize([width, height])\n })\n\n React.useEffect(() => {\n if (posterCanvas === null || posterRecipe === null) return\n const frames = (posterRecipe.frames || []).length\n const frame = frames > 1 ? Math.floor(animTicks / 2) % frames : 0\n drawItemIcon(posterCanvas, posterRecipe, frame, decoded)\n })\n\n React.useEffect(() => {\n if (canvas === null) return\n function onWheel(event) {\n event.preventDefault()\n setZoom((previous) => Math.max(0.35, Math.min(3, previous * (event.deltaY > 0 ? 0.92 : 1.08))))\n }\n canvas.addEventListener('wheel', onWheel, { passive: false })\n return () => canvas.removeEventListener('wheel', onWheel)\n }, [canvas])\n\n function zoomBy(factor) {\n setZoom((previous) => Math.max(0.35, Math.min(3, previous * factor)))\n }\n\n function projectNamespaceOf(id) {\n const projects = index === null ? [] : (index.projects || [])\n const found = projects.filter((entry) => entry.id === id)[0]\n return found === undefined ? '' : found.namespace\n }\n\n const hudView = () => {\n const list = itemFiltered()\n const pages = Math.max(1, Math.ceil(list.length / HUD_SLOTS))\n const page = Math.min(Math.max(1, hudPage), pages)\n return { list: list.slice((page - 1) * HUD_SLOTS, page * HUD_SLOTS),\n page: page, pages: pages, total: list.length }\n }\n const hudOn = previewItem !== null ? previewItem.id\n : (choice !== null && choice.kind === 'block' ? choice.id : '')\n const hudPickedEntry = hudOn === '' ? undefined\n : itemFiltered().filter((entry) => entry.id === hudOn)[0]\n const hudPicked = previewItem !== null\n ? (hudPickedEntry === undefined ? previewItem.id : hudPickedEntry.name)\n : (hudPickedEntry !== undefined ? hudPickedEntry.name\n : (choice === null ? '' : (choice.title || choice.id)))\n const hudKey = item === null ? '' : [item.source, item.namespace, item.filter,\n item.form, item.family].join('|')\n React.useEffect(() => {\n if (hudOn === '') return\n const token = hudKey + '#' + hudOn\n if (token === hudFollowed) return\n hudFollowed = token\n const at = itemFiltered().map((entry) => entry.id).indexOf(hudOn)\n if (at < 0) return\n const page = Math.floor(at / HUD_SLOTS) + 1\n if (page !== hudView().page) setHudPage(page)\n }, [hudKey, hudOn])\n const hudAnimated = item !== null && itemFiltered().some((entry) => {\n const recipe = itemRecipes[itemKey(item.namespace, entry.id)]\n return recipe !== undefined && recipe !== null\n && (((recipe.frames || []).length > 1) || Object.keys(recipe.animations || {}).length > 0)\n })\n const animating = (scene !== null && Object.keys(scene.animations || {}).length > 0)\n || (ghost !== null && Object.keys(ghost.animations || {}).length > 0)\n || hudAnimated\n React.useEffect(() => {\n if (!animating) return undefined\n const dispose = timer.interval(() => {\n animTicks += 1\n setTick((previous) => previous + 1)\n }, 50)\n return () => { if (typeof dispose === 'function') dispose() }\n }, [animating])\n\n React.useEffect(() => {\n const ours = projectNamespaceOf(activeProjectId())\n if (ours === '') return\n if (item === null) { loadItems('project', ours, 1, '', '', ''); return }\n if (item.source === 'project' && item.namespace !== ours) loadItems('project', ours, 1, '', '', '')\n }, [index === null ? '' : (index.projects || []).map((project) => project.id).join(','),\n activeProjectId(), item === null ? '' : item.source + '|' + item.namespace])\n\n React.useEffect(() => {\n if (scene === null && item === null) return\n const needed = (scene === null ? [] : (scene.textureIds || [])).slice()\n const waiting = needed.slice()\n if (ghost !== null && Array.isArray(ghost.textureIds)) {\n for (const id of ghost.textureIds) if (needed.indexOf(id) < 0) needed.push(id)\n }\n for (const recipe of iconRecipesInUse()) {\n for (const id of (recipe.textureIds || [])) if (needed.indexOf(id) < 0) needed.push(id)\n }\n let changed = false\n if (scratch !== null) {\n for (const id of needed) {\n if (decoded[id] !== undefined || failedTex[id] === true) continue\n const node = imageNodes[id]\n if (node === undefined || node.complete !== true) continue\n changed = true\n if (!node.naturalWidth || !node.naturalHeight) { failedTex[id] = true; continue }\n const neededWidth = Math.max(scratch.width, node.naturalWidth)\n const neededHeight = Math.max(scratch.height, node.naturalHeight)\n if (neededWidth !== scratch.width || neededHeight !== scratch.height) {\n scratch.width = neededWidth\n scratch.height = neededHeight\n }\n const paint = scratch.getContext('2d')\n paint.clearRect(0, 0, scratch.width, scratch.height)\n paint.drawImage(node, 0, 0)\n const frame = paint.getImageData(0, 0, node.naturalWidth, node.naturalHeight)\n decoded[id] = { width: frame.width, height: frame.height, data: frame.data }\n }\n }\n if (changed) { setTick(tick + 1); return }\n if (scene === null) {\n if (canvas === null) return\n const emptyKey = 'empty|' + size[0] + '|' + size[1] + '|' + canvasGeneration\n if (emptyKey === lastDrawKey) return\n lastDrawKey = emptyKey\n clearViewport(canvas, size[0], size[1])\n return\n }\n if (canvas === null) return\n for (const id of waiting) {\n if (decoded[id] === undefined && failedTex[id] !== true) return\n }\n const ghostKey = ghost === null ? '-' : (ghost.block + '@' + ghost.at.join(',') + ':' + ghost.quads.length)\n const hoverKey = voxel === null || hover === null ? '-' : (hover.cell + ':' + hover.face)\n const animations = Object.assign({}, scene.animations || {},\n ghost === null || ghost.animations === undefined ? {} : ghost.animations)\n const animTime = animTicks * 50\n const animKey = Object.keys(animations).sort()\n .map((id) => id + ':' + animationRow(animations[id], animTime)).join(',')\n const key = [scene.kind, scene.id, scene.quads.length, size[0], size[1], canvasGeneration,\n yaw.toFixed(4), pitch.toFixed(4), zoom.toFixed(4), ghostKey, hoverKey, animKey].join('|')\n if (key === lastDrawKey) return\n lastDrawKey = key\n const box = scene.box || { min: [-1, -1, -1], max: [1, 1, 1] }\n const camera = orbitCamera(box, yaw, pitch, zoom, size[0], size[1], Math.PI / 4)\n lastCamera = camera\n const buffer = new Int32Array(size[0] * size[1])\n buffer.fill(-1)\n const colourBuf = new Float64Array(size[0] * size[1] * 4)\n const depthBuf = new Float64Array(size[0] * size[1])\n const pass = { width: size[0], height: size[1], background: [26, 24, 28], camera: camera,\n colour: colourBuf, depth: depthBuf, textures: decoded, animations: animations, timeMs: animTime }\n const bytes = renderScene(Object.assign({ quads: scene.quads, pick: buffer }, pass))\n pickBuffer = buffer\n if (voxel !== null && hover !== null) {\n const marked = scene.quads.filter((quad) => quad.pick !== undefined && quad.pick !== null\n && quad.pick.cell === hover.cell)\n if (marked.length > 0) {\n renderScene(Object.assign({ quads: marked, overlay: 0.45, tint: TINT_REMOVE }, pass))\n }\n const ghostQuads = ghost === null || !Array.isArray(ghost.quads) ? [] : ghost.quads\n const usable = ghostQuads.some((quad) => decoded[quad.tex] !== undefined)\n if (usable) {\n renderScene(Object.assign({ quads: ghostQuads, overlay: 0.5, tint: null }, pass))\n }\n }\n for (let i = 0; i < bytes.length; i++) {\n const value = Math.round(colourBuf[i] * 255)\n bytes[i] = value < 0 ? 0 : (value > 255 ? 255 : value)\n }\n canvas.width = size[0]\n canvas.height = size[1]\n const paint = canvas.getContext('2d')\n const frame = paint.createImageData(size[0], size[1])\n frame.data.set(bytes)\n paint.putImageData(frame, 0, 0)\n })\n\n\n function iconRecipesInUse() {\n const out = []\n if (item === null) return out\n const seen = {}\n const add = (entry) => {\n const key = itemKey(item.namespace, entry.id)\n if (seen[key] === true) return\n const recipe = itemRecipes[key]\n if (recipe === undefined || recipe === null) return\n seen[key] = true\n out.push(recipe)\n }\n for (const entry of hudView().list) add(entry)\n for (const entry of itemView().list) add(entry)\n return out\n }\n\n React.useEffect(() => {\n if (item === null || item.facts.length === 0) return\n ensureItemPage()\n }, [item === null ? '' : [item.namespace, item.page, hudPage, item.filter, item.form,\n item.family, item.facts.length].join('|')])\n\n React.useEffect(() => {\n if (item === null) return\n let added = 0\n for (const entry of itemView().list) {\n const key = itemKey(item.namespace, entry.id)\n if (itemUrls[key] !== undefined) continue\n const recipe = itemRecipes[key]\n if (recipe === undefined || recipe === null || recipe.shape === 'none') continue\n let ready = true\n for (const id of (recipe.textureIds || [])) {\n if (decoded[id] === undefined && failedTex[id] !== true) { ready = false; break }\n }\n if (!ready) continue\n const url = itemIconUrl(recipe, decoded, 32)\n if (url === null) continue\n itemUrls[key] = url\n added += 1\n }\n if (added > 0) setIconTick(iconTick + 1)\n })\n\n React.useEffect(() => {\n if (item === null) return\n for (const entry of hudView().list) {\n const key = itemKey(item.namespace, entry.id)\n const canvas = hudCanvases[key]\n if (canvas === undefined || canvas === null) continue\n const recipe = itemRecipes[key]\n const frames = recipe === undefined || recipe === null ? 0 : (recipe.frames || []).length\n const frame = frames > 1 ? Math.floor(animTicks / 2) % frames : 0\n drawItemIcon(canvas, recipe, frame, decoded)\n }\n })\n\n\n function slotNote(recipe) {\n if (recipe === undefined || recipe === null) return null\n if (recipe.shape === 'none') {\n return '这个物品是代码画的,没有可用的图标模型'\n + (recipe.error ? ' —— ' + String(recipe.error) : '')\n }\n if (recipe.shape === 'iso' && (recipe.display === null || recipe.display === undefined)) {\n return '物品模型指向几何模型,模型链里没有 display.gui:游戏里物品栏也是这样画的。'\n + '把它改成 item/generated(layer0 指向那张贴图)就有正常的平面图标了'\n }\n const absent = (recipe.missingModels || []).filter((name) => String(name).indexOf('内置:') !== 0)\n if (absent.length > 0) return '模型链缺 ' + absent.join('、')\n return null\n }\n\n\n function slotFacts(recipe) {\n if (recipe === undefined || recipe === null) return '还没取到这个物品的图标'\n const parts = [recipe.shape === 'iso' ? '等距' : '平铺']\n if (recipe.display !== null && recipe.display !== undefined) {\n parts.push('display.gui ' + JSON.stringify(recipe.display.rotation))\n }\n const absent = []\n const builtin = []\n for (const name of (recipe.missingModels || [])) {\n if (String(name).indexOf('内置:') === 0) builtin.push(String(name).slice(3))\n else absent.push(String(name))\n }\n if (absent.length > 0) parts.push('模型链缺 ' + absent.join('、'))\n if (builtin.length > 0) {\n parts.push('原版母模型用了内置的 ' + builtin.join('、') + '(设了参考目录就从游戏里读)')\n }\n return parts.join(' · ')\n }\n\n function blockChoices() {\n const out = []\n const seen = {}\n const add = (name, title) => {\n if (typeof name !== 'string' || name === '' || seen[name] === true) return\n seen[name] = true\n out.push({ name: name, title: title || name })\n }\n const projects = index === null ? [] : (index.projects || [])\n const current = activeProjectId()\n const currentProject = projects.filter((item) => item.id === current)[0]\n const fallback = currentProject === undefined ? '' : currentProject.namespace\n const titleOf = {}\n for (const project of projects) {\n const named = project.id === current\n for (const item of ((project.items && project.items.block) || [])) {\n const qualified = project.namespace + ':' + item.id\n titleOf[qualified] = named ? item.title : (project.title + ' · ' + item.title)\n add(qualified, titleOf[qualified])\n }\n }\n for (const cell of (scene !== null && Array.isArray(scene.cells) ? scene.cells : [])) {\n const raw = String(cell.block)\n const qualified = raw.indexOf(':') >= 0 || fallback === '' ? raw : (fallback + ':' + raw)\n add(qualified, titleOf[qualified] || qualified)\n }\n return out\n }\n\n function openVoxel() {\n if (scene === null || !Array.isArray(scene.cells)) {\n setEditMsg('这个资产没有可编辑的格子')\n return\n }\n const choices = blockChoices()\n loadRefSources()\n resetPick()\n painting.active = false\n setHover(null)\n setGhost(null)\n setEdit(null)\n setFilter('')\n setVoxel({\n kind: scene.kind, id: scene.id, title: scene.title || scene.id,\n cells: scene.cells.map((cell) => {\n const kept = { block: cell.block, at: [cell.at[0], cell.at[1], cell.at[2]] }\n if (typeof cell.variant === 'string' && cell.variant !== '') kept.variant = cell.variant\n return kept\n }),\n block: choices.length > 0 ? choices[0].name : '', choices: choices,\n source: 'project', variants: [], axes: [], defaults: {}, flip: false, overrides: {}, manual: false,\n undo: [], busy: false, msg: null,\n })\n }\n\n function loadRefSources() {\n setRefSources([])\n host.call('atlas.refNamespaces', { root: root, project: activeProjectId() })\n .then((result) => {\n if (result === null || result === undefined || result.error !== undefined) return\n setRefSources(result.namespaces || [])\n }).catch(() => { })\n }\n\n function itemKey(namespace, id) { return namespace + ':' + id }\n\n function loadItems(source, namespace, page, filter, form, family) {\n setItem({ source: source, namespace: namespace, facts: [],\n page: page === undefined ? 1 : page,\n filter: filter === undefined ? '' : filter, form: form === undefined ? '' : form,\n family: family === undefined ? '' : family, busy: true, msg: null,\n choices: item === null || item.choices === undefined ? [] : item.choices })\n host.call('atlas.refItems', { root: root, project: activeProjectId(),\n source: source, namespace: namespace })\n .then((result) => {\n const failed = failureOf(result)\n if (failed !== null) {\n setItem((previous) => Object.assign({}, previous, { busy: false,\n msg: '取不到物品:' + failed }))\n return\n }\n const facts = (result.items || []).filter((entry) => entry.parentOnly !== true)\n setItem((previous) => Object.assign({}, previous, { busy: false, facts: facts,\n version: result.version,\n msg: facts.length + ' 个物品' + (result.version === undefined ? '' : ' · ' + result.version) }))\n }).catch((error) => {\n setItem((previous) => Object.assign({}, previous, { busy: false,\n msg: '取不到物品:' + String(error && error.message ? error.message : error) }))\n })\n }\n\n function openItems() {\n if (itemOpen) { setItemOpen(false); return }\n setItemOpen(true)\n loadRefSources()\n setIconPick(null)\n const ours = projectNamespaceOf(activeProjectId())\n const stale = item === null || item.facts.length === 0\n || (item.source === 'project' && ours !== '' && item.namespace !== ours)\n if (!stale) return\n if (ours !== '') { loadItems('project', ours, 1, '', '', ''); return }\n const namespace = refSources.length > 0 ? refSources[0].name : 'minecraft'\n loadItems('reference', namespace, 1, '', '', '')\n }\n\n\n function itemSearch() {\n return item === null ? '' : String(item.filter).trim().toLowerCase()\n }\n\n\n function itemFiltered() {\n if (item === null) return []\n const needle = itemSearch()\n if (needle !== '') return item.facts.filter((entry) => String(entry.name).toLowerCase().indexOf(needle) >= 0\n || String(entry.id).toLowerCase().indexOf(needle) >= 0)\n return item.facts.filter((entry) => (item.form === '' || entry.form === item.form)\n && (item.family === '' || entry.family === item.family))\n }\n\n function itemView() {\n const list = itemFiltered()\n const total = list.length\n const pages = Math.max(1, Math.ceil(total / ITEM_PAGE))\n const page = Math.min(Math.max(1, item.page), pages)\n return { list: list.slice((page - 1) * ITEM_PAGE, page * ITEM_PAGE), page: page, pages: pages, total: total }\n }\n\n function ensureItemPage() {\n if (item === null) return\n const missing = []\n const want = (list) => {\n for (const entry of list) {\n const key = itemKey(item.namespace, entry.id)\n if (itemRecipes[key] === undefined && missing.indexOf(entry.id) < 0) missing.push(entry.id)\n }\n }\n want(itemView().list)\n want(hudView().list)\n if (missing.length === 0) return\n host.call('atlas.itemIcons', { root: root, project: activeProjectId(),\n source: item.source, namespace: item.namespace, items: missing }).then((result) => {\n if (failureOf(result) !== null) return\n let added = 0\n for (const id of Object.keys(result.items || {})) {\n const recipe = result.items[id]\n const key = itemKey(item.namespace, id)\n if (itemRecipes[key] !== undefined) continue\n itemRecipes[key] = recipe\n added += 1\n }\n if (added > 0) setIconTick(iconTick + 1)\n }).catch(() => { })\n }\n\n\n function projectHasBlock(id) {\n const project = index === null ? null\n : (index.projects || []).filter((entry) => entry.id === activeProjectId())[0]\n if (project === null || project === undefined) return false\n return ((project.items && project.items.block) || []).some((entry) => entry.id === id)\n }\n\n function pickItem(entry) {\n const namespace = item === null ? '' : item.namespace\n setIconPick({ namespace: namespace, id: entry.id })\n if (item !== null && item.source === 'project') {\n if (!projectHasBlock(entry.id)) {\n setPreviewItem({ source: 'project', namespace: namespace, id: entry.id, localOnly: true })\n setScene(null)\n setVoxel(null)\n setHover(null)\n setGhost(null)\n setEdit(null)\n setFailure(null)\n return\n }\n setPreviewItem(null)\n open({ project: activeProjectId(), kind: 'block', id: entry.id }, root)\n return\n }\n openReferenceItem(entry)\n }\n\n function openReferenceItem(entry) {\n const reference = (item === null ? '' : item.namespace) + ':' + entry.id\n setPreviewItem({ source: 'reference', namespace: item === null ? '' : item.namespace, id: entry.id })\n host.call('atlas.preview', { root: root, project: activeProjectId(), block: reference,\n at: [0, 0, 0], variant: null, have: Object.keys(decoded) }).then((result) => {\n const failed = failureOf(result)\n if (failed !== null) {\n setItem((previous) => previous === null ? previous : Object.assign({}, previous,\n { msg: '3D 取不到:' + failed }))\n return\n }\n lastDrawKey = ''\n setFailure(null)\n setScene({ kind: 'block', id: reference, title: entry.name || entry.id,\n quads: result.quads, textureIds: result.textureIds, textures: result.textures,\n animations: result.animations || {}, cells: null, palette: [], errors: [],\n box: boxOfQuads(result.quads), preview: true })\n setVoxel(null)\n setHover(null)\n setGhost(null)\n }).catch((error) => {\n setItem((previous) => previous === null ? previous : Object.assign({}, previous,\n { msg: '3D 取不到:' + String(error && error.message ? error.message : error) }))\n })\n }\n\n function pickSource(source) {\n if (voxel === null) return\n if (source === 'project') {\n const choices = blockChoices()\n setFilter('')\n resetPick()\n setVoxel(Object.assign({}, voxel, { source: 'project', choices: choices,\n block: choices.length > 0 ? choices[0].name : '' }))\n setGhost(null)\n return\n }\n const namespace = source.slice(4)\n setVoxel(Object.assign({}, voxel, { source: source, msg: '正在读取 ' + namespace + ' 的方块…' }))\n host.call('atlas.refBlocks', { root: root, project: activeProjectId(), namespace: namespace })\n .then((result) => {\n if (result === null || result === undefined || result.error !== undefined) {\n setVoxel(Object.assign({}, voxel, { msg: '取不到 ' + namespace + ' 的方块:'\n + String(result && result.error ? result.error : '没有返回结果') }))\n return\n }\n const choices = (result.blocks || []).map((item) => ({ name: namespace + ':' + item.id,\n title: item.name, group: item.group || '', groupLabel: item.groupLabel || '',\n family: item.family || '' }))\n if (choices.length === 0) {\n setVoxel(Object.assign({}, voxel, { msg: namespace + ' 里没有方块' }))\n return\n }\n setFilter('')\n resetPick()\n setVoxel(Object.assign({}, voxel, { source: source, choices: choices,\n block: choices[0].name, msg: '参考 ' + namespace + ' · ' + choices.length + ' 个方块' }))\n setGhost(null)\n }).catch((error) => {\n setVoxel(Object.assign({}, voxel, { msg: '取不到方块:' + String(error && error.message ? error.message : error) }))\n })\n }\n\n function searchMatch(item, needle) {\n return String(item.title).toLowerCase().indexOf(needle) >= 0\n || String(item.name).toLowerCase().indexOf(needle) >= 0\n }\n\n function groupOfItem(item) { return item.group || '' }\n function familyOfItem(item) { return item.family || '其他' }\n\n\n function countsBy(keyOf, within) {\n const counts = {}\n for (const item of (voxel === null ? [] : voxel.choices)) {\n if (within !== null && within(item) !== true) continue\n const value = keyOf(item) || ''\n counts[value] = (counts[value] || 0) + 1\n }\n return counts\n }\n\n\n function choiceView() {\n if (voxel === null) return { list: [], page: 1, pages: 1, total: 0 }\n const needle = String(filter).trim().toLowerCase()\n let list = voxel.choices\n if (needle !== '') {\n list = list.filter((item) => searchMatch(item, needle))\n } else if (pickGroup !== '' || pickFamily !== '') {\n list = list.filter((item) =>\n (pickGroup === '' || groupOfItem(item) === pickGroup)\n && (pickFamily === '' || familyOfItem(item) === pickFamily))\n }\n const total = list.length\n const pages = Math.max(1, Math.ceil(total / PAGE_SIZE))\n const shownPage = Math.min(Math.max(1, pickPage), pages)\n return { list: list.slice((shownPage - 1) * PAGE_SIZE, shownPage * PAGE_SIZE),\n page: shownPage, pages: pages, total: total }\n }\n\n function resetPick() {\n setPickGroup('')\n setPickFamily('')\n setPickPage(1)\n }\n\n function releaseRefs() {\n const keep = []\n for (const cell of (voxel === null ? [] : voxel.cells)) {\n const raw = String(cell.block)\n const colon = raw.indexOf(':')\n let qualified = null\n if (colon >= 0) qualified = raw\n else if (voxel !== null && voxel.source !== 'project') qualified = voxel.source.slice(4) + ':' + raw\n if (qualified === null) continue\n keep.push(qualified)\n if (typeof cell.variant === 'string' && cell.variant !== '') {\n keep.push(qualified + '@' + cell.variant)\n }\n }\n if (voxel !== null && voxel.source !== 'project' && voxel.block !== '') {\n keep.push(voxel.block)\n if (ghost !== null && ghost.block === voxel.block\n && typeof ghost.variant === 'string' && ghost.variant !== '') {\n keep.push(voxel.block + '@' + ghost.variant)\n }\n }\n host.call('atlas.releaseRefs', { keep: keep }).catch(() => { })\n }\n\n function voxelWith(cells, x, y, z, block, variant) {\n const out = cells.filter((cell) => !(cell.at[0] === x && cell.at[1] === y && cell.at[2] === z))\n if (block !== null && block !== '') {\n const cell = { block: block, at: [x, y, z] }\n if (typeof variant === 'string' && variant !== '') cell.variant = variant\n out.push(cell)\n }\n return out\n }\n\n\n function voxelTargetAt(event) {\n if (voxel === null || scene === null || canvas === null || pickBuffer === null) return null\n const rect = canvas.getBoundingClientRect()\n if (rect.width === 0 || rect.height === 0) return null\n const px = Math.floor((event.clientX - rect.left) / rect.width * canvas.width)\n const py = Math.floor((event.clientY - rect.top) / rect.height * canvas.height)\n if (px < 0 || py < 0 || px >= canvas.width || py >= canvas.height) return null\n const quadIndex = pickBuffer[py * canvas.width + px]\n if (quadIndex === undefined || quadIndex < 0) return null\n const quad = scene.quads[quadIndex]\n const pick = quad === undefined ? undefined : quad.pick\n if (pick === undefined || pick === null) return null\n const cell = (scene.cells || [])[pick.cell]\n if (cell === undefined) return null\n const hitY = faceHitY(quad, px + 0.5, py + 0.5, lastCamera)\n return { cell: pick.cell, face: pick.face, at: cell.at, block: cell.block, hitY: hitY }\n }\n\n\n function placementAt(target) {\n if (target === null) return null\n const step = (scene === null ? {} : (scene.faceStep || {}))[target.face]\n if (step === undefined) return null\n const at = [target.at[0] + step[0], target.at[1] + step[1], target.at[2] + step[2]]\n const occupied = (voxel === null ? [] : voxel.cells).some((cell) =>\n cell.at[0] === at[0] && cell.at[1] === at[1] && cell.at[2] === at[2])\n return occupied ? null : at\n }\n\n React.useEffect(() => {\n if (voxel === null) return\n const source = voxel.source === undefined ? 'project' : voxel.source\n const missing = []\n for (const item of choiceView().list) {\n if (icons[item.name] !== undefined || iconTried[item.name] === true) continue\n if (missing.indexOf(item.name) < 0) missing.push(item.name)\n }\n if (missing.length === 0) return\n const batch = missing.slice(0, ICON_BATCH)\n for (const name of batch) iconTried[name] = true\n let request\n if (source === 'project') {\n request = host.call('atlas.icons', { root: root, project: activeProjectId(), blocks: batch })\n } else {\n const namespace = String(source).slice(4)\n const ids = batch.filter((name) => name.indexOf(namespace + ':') === 0)\n .map((name) => name.slice(namespace.length + 1))\n if (ids.length === 0) return\n request = host.call('atlas.refIcons', { root: root, project: activeProjectId(),\n namespace: namespace, blocks: ids })\n }\n request.then((result) => {\n if (result === null || result === undefined || result.error !== undefined) return\n let added = 0\n for (const key of Object.keys(result.icons || {})) { icons[key] = result.icons[key]; added += 1 }\n if (added > 0) setIconTick(iconTick + 1)\n }).catch(() => { })\n }, [voxel === null ? '' : (voxel.source + '|' + voxel.kind + ':' + voxel.id + '|' + voxel.choices.length),\n filter, pickGroup, pickFamily, pickPage, iconTick])\n\n React.useEffect(() => {\n if (voxel === null) { ghostAsk = ''; if (ghost !== null) setGhost(null); return }\n const target = hover === null ? null : placementAt(hover)\n if (target === null) { ghostAsk = ''; if (ghost !== null) setGhost(null); return }\n const face = hover === null || hover.face === undefined ? 'north' : hover.face\n const hitY = hover === null || hover.hitY === undefined ? 0.5 : hover.hitY\n const wantVariant = variantFor(voxel.axes, voxel.variants, voxel.defaults, face, hitY,\n voxel.flip === true, voxel.overrides)\n const around = aroundOf(voxel.cells, target)\n const wantKey = voxel.block + '|' + String(wantVariant) + '|' + target.join(',')\n + '|' + JSON.stringify(around)\n if (ghost !== null && ghostAsk === wantKey) return\n ghostAsk = wantKey\n setGhost({ block: voxel.block, at: target, variant: wantVariant, quads: [], textures: {},\n textureIds: [], loading: true })\n host.call('atlas.preview', {\n root: root, project: activeProjectId(), block: voxel.block, at: target,\n variant: wantVariant, have: Object.keys(decoded), around: around,\n }).then((result) => {\n if (result === null || result === undefined || result.error !== undefined) {\n setGhost({ block: voxel.block, at: target, variant: wantVariant, quads: [], textures: {},\n textureIds: [], error: String(result && result.error ? result.error : '没有返回结果') })\n return\n }\n setGhost(Object.assign({ loading: false }, result))\n }).catch((error) => {\n setGhost({ block: voxel.block, at: target, variant: wantVariant, quads: [], textures: {},\n textureIds: [], error: String(error && error.message ? error.message : error) })\n })\n })\n\n const variantKey = voxel === null ? '' : (voxel.source + '|' + voxel.block)\n React.useEffect(() => {\n if (voxel === null || voxel.block === '') return undefined\n let live = true\n host.call('atlas.preview', {\n root: root, project: activeProjectId(), block: voxel.block, at: [0, 0, 0],\n variant: null, have: Object.keys(decoded),\n }).then((result) => {\n if (!live) return\n const keys = result === null || result === undefined || !Array.isArray(result.variants)\n ? [] : result.variants\n const axes = result === null || result === undefined || !Array.isArray(result.axes)\n ? [] : result.axes\n const defaults = result === null || result === undefined || typeof result.defaults !== 'object'\n || result.defaults === null ? {} : result.defaults\n setVoxel((previous) => {\n if (previous === null || previous.block !== voxel.block) return previous\n if ((previous.variants || []).join(',') === keys.join(',')\n && JSON.stringify(previous.axes || []) === JSON.stringify(axes)) return previous\n return Object.assign({}, previous,\n { variants: keys, axes: axes, defaults: defaults, flip: false, overrides: {}, manual: false })\n })\n }).catch(() => { })\n return () => { live = false }\n }, [variantKey])\n\n function refreshVoxel(state) {\n host.call('atlas.scene', {\n root: root, project: activeProjectId(), kind: state.kind, id: state.id,\n cells: state.cells, have: Object.keys(decoded),\n neighbours: state.neighbours !== false,\n }).then((result) => {\n if (result === null || result === undefined || result.error !== undefined) {\n setVoxel(Object.assign({}, state, { busy: false,\n msg: '预览失败:' + String(result && result.error ? result.error : '没有返回结果') }))\n return\n }\n setVoxel(Object.assign({}, state, { busy: false }))\n lastDrawKey = ''\n setScene(result)\n releaseRefs()\n }).catch((error) => {\n setVoxel(Object.assign({}, state, { busy: false, msg: '预览失败:' + String(error && error.message ? error.message : error) }))\n })\n }\n\n function voxelApply(cells, note) {\n if (voxel === null) return\n const next = Object.assign({}, voxel, {\n cells: cells, busy: true, msg: note,\n undo: voxel.undo.concat([voxel.cells]).slice(-16),\n })\n setVoxel(next)\n refreshVoxel(next)\n }\n\n function voxelUndo() {\n if (voxel === null || voxel.undo.length === 0) return\n const next = Object.assign({}, voxel, {\n cells: voxel.undo[voxel.undo.length - 1],\n undo: voxel.undo.slice(0, voxel.undo.length - 1),\n msg: '撤销', busy: true,\n })\n setVoxel(next)\n refreshVoxel(next)\n }\n\n function voxelSave() {\n if (voxel === null || voxel.busy) return\n setVoxel(Object.assign({}, voxel, { busy: true, msg: '保存中…' }))\n host.call('atlas.saveVoxel', {\n root: root, project: activeProjectId(), kind: voxel.kind, id: voxel.id, cells: voxel.cells,\n }).then((result) => {\n if (result === null || result === undefined || result.error !== undefined) {\n setVoxel(Object.assign({}, voxel, { busy: false,\n msg: '保存失败:' + String(result && result.error ? result.error : '没有返回结果') }))\n return\n }\n delete indexes[root]\n setVoxel(Object.assign({}, voxel, { busy: false,\n msg: '已保存 ' + result.cells + ' 格 -> ' + String(result.file).split('/').pop() }))\n }).catch((error) => {\n setVoxel(Object.assign({}, voxel, { busy: false, msg: '保存失败:' + String(error && error.message ? error.message : error) }))\n })\n }\n\n function voxelClick(event, action) {\n if (voxel === null || scene === null || canvas === null || pickBuffer === null) return\n const target = voxelTargetAt(event)\n if (target === null) {\n setVoxel(Object.assign({}, voxel, { msg: '那里是空的,点方块上' }))\n return\n }\n const pick = { cell: target.cell, face: target.face }\n const cell = (scene.cells || [])[pick.cell]\n if (cell === undefined) {\n setVoxel(Object.assign({}, voxel, { msg: '格子索引对不上了,刷新一下' }))\n return\n }\n if (action === 'remove') {\n voxelApply(voxelWith(voxel.cells, cell.at[0], cell.at[1], cell.at[2], null, null), '挖掉 ' + cell.block)\n return\n }\n const step = (scene.faceStep || {})[pick.face]\n if (step === undefined) {\n setVoxel(Object.assign({}, voxel, { msg: '认不出这个面:' + String(pick.face) }))\n return\n }\n const placed = variantFor(voxel.axes, voxel.variants, voxel.defaults, pick.face,\n target.hitY === undefined ? 0.5 : target.hitY, voxel.flip === true, voxel.overrides)\n voxelApply(voxelWith(voxel.cells, cell.at[0] + step[0], cell.at[1] + step[1], cell.at[2] + step[2],\n voxel.block, placed), '放置 ' + voxel.block)\n }\n\n function voxelCard() {\n if (voxel === null) return null\n const rows = []\n rows.push(React.createElement('div', { className: 'mcart-bar', key: 'head' },\n React.createElement('span', { className: 'mcart-title' }, '搭建'),\n React.createElement('span', { className: 'mcart-sub' }, (voxel.title || '') + ' · ' + voxel.cells.length + ' 格')))\n rows.push(React.createElement('div', { className: 'mcart-src', key: 'filter' },\n React.createElement('input', {\n className: 'mcart-input', placeholder: '搜方块名或 id(共 ' + voxel.choices.length + ' 个)',\n value: filter, onChange: (event) => { setFilter(event.target.value); setPickPage(1) },\n }),\n filter === '' ? null : React.createElement('button', { className: 'mcart-btn', type: 'button',\n onClick: () => { setFilter(''); setPickPage(1) } }, '清空')))\n\n const derivedOn = voxel.neighbours !== false\n rows.push(React.createElement('div', { className: 'mcart-tabs', key: 'neighbours' },\n React.createElement('span', { className: 'mcart-note' }, '连接'),\n React.createElement('button', {\n className: 'mcart-btn mcart-tool', type: 'button',\n 'data-on': derivedOn ? '1' : '0',\n title: '墙/栅栏/玻璃板:原版按邻居决定画哪几段。关掉就是抽取器给的那份并集(全部画上),不是\"算不出来\"。',\n onClick: () => {\n const next = Object.assign({}, voxel, { neighbours: voxel.neighbours === false })\n setVoxel(next)\n refreshVoxel(next)\n },\n }, derivedOn ? '按邻居推导' : '全部画上')))\n\n const axes = Array.isArray(voxel.axes) ? voxel.axes : []\n if (axes.length > 0) {\n const face = hover === null || hover.face === undefined ? null : hover.face\n const hitY = hover === null || hover.hitY === undefined ? 0.5 : hover.hitY\n const derived = face === null ? {} : deriveVariant(axes, face, hitY, voxel.flip === true)\n const summary = []\n summary.push(React.createElement('span', { className: 'mcart-note', key: '__what' },\n face === null\n ? '朝向由你点的那个面决定——悬停到方块上试试'\n : '点 ' + labelOfValue(face) + ' 面 → ' + variantSummary(axes, derived)))\n if (derived.facing !== undefined) {\n summary.push(React.createElement('button', {\n key: '__flip', type: 'button', className: 'mcart-btn mcart-tool',\n 'data-on': voxel.flip === true ? '1' : '0',\n title: '熔炉这类是朝着你,楼梯/活板门那类正相反——盘上没写,只能你说了算',\n onClick: () => setVoxel(Object.assign({}, voxel, { flip: voxel.flip !== true })),\n }, '翻转 180°'))\n }\n if (Object.keys(voxel.overrides || {}).length > 0) {\n summary.push(React.createElement('button', {\n key: '__reset', type: 'button', className: 'mcart-btn mcart-tool',\n onClick: () => setVoxel(Object.assign({}, voxel, { overrides: {} })),\n }, '恢复自动'))\n }\n summary.push(React.createElement('button', {\n key: '__manual', type: 'button', className: 'mcart-btn mcart-tool',\n 'data-on': voxel.manual === true ? '1' : '0',\n onClick: () => setVoxel(Object.assign({}, voxel, { manual: voxel.manual !== true })),\n }, voxel.manual === true ? '收起 ▴' : '手动 ▾'))\n rows.push(React.createElement('div', { className: 'mcart-tabs', key: 'orientation' },\n React.createElement('span', { className: 'mcart-note' }, '朝向'), summary))\n\n if (voxel.manual === true) {\n for (const axis of axes) {\n const picked = (voxel.overrides || {})[axis.name]\n const chips = [React.createElement('button', {\n key: '__auto', type: 'button', className: 'mcart-btn mcart-tool',\n 'data-on': picked === undefined ? '1' : '0',\n onClick: () => setVoxel(Object.assign({}, voxel,\n { overrides: withoutKey(voxel.overrides, axis.name) })),\n }, '自动')]\n for (const value of axis.values) {\n chips.push(React.createElement('button', {\n key: value, type: 'button', className: 'mcart-btn mcart-tool',\n 'data-on': picked === value ? '1' : '0',\n onClick: () => setVoxel(Object.assign({}, voxel,\n { overrides: withKey(voxel.overrides, axis.name, value) })),\n }, labelOfValue(value)))\n }\n rows.push(React.createElement('div', { className: 'mcart-tabs', key: 'axis:' + axis.name },\n React.createElement('span', { className: 'mcart-note' }, labelOfAxis(axis.name)), chips))\n }\n if (axes.some((axis) => axis.name === 'shape')) {\n rows.push(React.createElement('div', { className: 'mcart-hint', key: 'shapenote' },\n '形状(内角/外角)在原版里是看邻居算出来的,这里还没做——默认给你 straight,不是不敢算就说算了。'))\n }\n rows.push(React.createElement('div', { className: 'mcart-hint', key: 'oriennote' },\n '不选就是自动。摆放时按你点的面和位置推导,幽灵预览里看到的就是要放上去的。'))\n }\n }\n\n const needle = String(filter).trim().toLowerCase()\n if (needle === '') {\n const groupCounts = countsBy(groupOfItem, null)\n const groupKeys = Object.keys(groupCounts).filter((key) => key !== '').sort()\n if (groupKeys.length > 0) {\n const chips = [React.createElement('button', {\n key: '__all', type: 'button', className: 'mcart-btn mcart-tool',\n 'data-on': pickGroup === '' ? '1' : '0',\n onClick: () => { setPickGroup(''); setPickFamily(''); setPickPage(1) },\n }, '全部 ' + voxel.choices.length)]\n for (const key of groupKeys) {\n const label = (voxel.choices.filter((item) => groupOfItem(item) === key)[0] || {}).groupLabel || key\n chips.push(React.createElement('button', {\n key: key, type: 'button', className: 'mcart-btn mcart-tool',\n 'data-on': pickGroup === key ? '1' : '0',\n onClick: () => { setPickGroup(key); setPickFamily(''); setPickPage(1) },\n }, label + ' ' + groupCounts[key]))\n }\n rows.push(React.createElement('div', { className: 'mcart-tabs', key: 'groups' },\n React.createElement('span', { className: 'mcart-note' }, '分类'), chips))\n }\n const familyCounts = countsBy(familyOfItem,\n pickGroup === '' ? null : (item) => groupOfItem(item) === pickGroup)\n const familyKeys = Object.keys(familyCounts).sort()\n if (familyKeys.length > 1) {\n const chips = [React.createElement('button', {\n key: '__all', type: 'button', className: 'mcart-btn mcart-tool',\n 'data-on': pickFamily === '' ? '1' : '0',\n onClick: () => { setPickFamily(''); setPickPage(1) },\n }, '全部 ' + Object.keys(familyCounts).reduce((sum, key) => sum + familyCounts[key], 0))]\n for (const key of familyKeys) {\n chips.push(React.createElement('button', {\n key: key, type: 'button', className: 'mcart-btn mcart-tool',\n 'data-on': pickFamily === key ? '1' : '0',\n onClick: () => { setPickFamily(key); setPickPage(1) },\n }, key + ' ' + familyCounts[key]))\n }\n rows.push(React.createElement('div', { className: 'mcart-tabs', key: 'families' },\n React.createElement('span', { className: 'mcart-note' }, '形状'), chips))\n }\n }\n\n const view = choiceView()\n rows.push(React.createElement('div', { className: 'mcart-bar', key: 'pager' },\n React.createElement('button', { className: 'mcart-btn', type: 'button', disabled: view.page <= 1,\n onClick: () => setPickPage(view.page - 1) }, '‹ 上一页'),\n React.createElement('span', { className: 'mcart-note' },\n '第 ' + view.page + ' / ' + view.pages + ' 页 · 共 ' + view.total + ' 个'),\n React.createElement('button', { className: 'mcart-btn', type: 'button', disabled: view.page >= view.pages,\n onClick: () => setPickPage(view.page + 1) }, '下一页 ›'),\n React.createElement('input', {\n className: 'mcart-input', type: 'number', min: 1, max: view.pages, value: view.page,\n style: { maxWidth: '66px' },\n onChange: (event) => setPickPage(Number(event.target.value) || 1),\n })))\n\n rows.push(React.createElement('div', { className: 'mcart-hotbar', key: 'hotbar' },\n view.list.map((item) => React.createElement('button', {\n key: item.name, type: 'button', className: 'mcart-slot',\n 'data-on': item.name === voxel.block ? '1' : '0',\n title: item.title + '\\n' + item.name,\n style: icons[item.name] === undefined ? {} : { backgroundImage: 'url(' + icons[item.name] + ')' },\n onClick: () => { setVoxel(Object.assign({}, voxel, { block: item.name })); setGhost(null) },\n }))))\n if (refSources.length > 0) {\n const options = [React.createElement('option', { key: 'project', value: 'project' }, '本项目')]\n for (const item of refSources) {\n options.push(React.createElement('option', { key: 'ref:' + item.name, value: 'ref:' + item.name },\n item.name + '(' + item.blocks + ' 个方块)'))\n }\n rows.push(React.createElement('div', { className: 'mcart-src', key: 'source' },\n React.createElement('span', { className: 'mcart-note' }, '方块来源'),\n React.createElement('select', {\n className: 'mcart-select',\n value: voxel.source === undefined ? 'project' : voxel.source,\n onChange: (event) => pickSource(event.target.value),\n }, options)))\n }\n const chosen = voxel.choices.filter((item) => item.name === voxel.block)[0]\n rows.push(React.createElement('div', { className: 'mcart-bar', key: 'chosen' },\n React.createElement('span', { className: 'mcart-note mcart-grow' },\n '手持:' + (chosen === undefined ? voxel.block : chosen.title) + '(' + voxel.block + ')')))\n rows.push(React.createElement('div', { className: 'mcart-tools', key: 'tools' },\n React.createElement('button', { className: 'mcart-btn', type: 'button', disabled: voxel.undo.length === 0 || voxel.busy,\n onClick: voxelUndo }, '撤销 ' + voxel.undo.length),\n React.createElement('button', { className: 'mcart-btn', type: 'button', disabled: voxel.busy,\n onClick: voxelSave }, voxel.busy ? '…' : '保存'),\n React.createElement('button', { className: 'mcart-btn', type: 'button',\n onClick: () => { setVoxel(null); setHover(null); setGhost(null); painting.active = false } }, '关闭')))\n rows.push(React.createElement('div', { className: 'mcart-hint', key: 'how' },\n '像原版一样:左键破坏,右键放置;拖动仍然是旋转。半透明的方块就是将要放上去的位置,红色高亮是要破坏的那一格。'))\n rows.push(React.createElement('div', { className: 'mcart-bar', key: 'msg' },\n React.createElement('span', { className: 'mcart-note mcart-grow' }, voxel.msg === null ? '' : String(voxel.msg))))\n if (ghost !== null && ghost.error !== undefined) {\n rows.push(React.createElement('div', { className: 'mcart-err', key: 'ghosterr' }, '预览拿不到:' + String(ghost.error)))\n }\n return React.createElement('div', { className: 'mcart-card', key: 'voxel' }, rows)\n }\n\n\n\n function hudBar() {\n const view = hudView()\n const slots = []\n for (const entry of view.list) {\n const key = itemKey(item.namespace, entry.id)\n const recipe = itemRecipes[key]\n const note = slotNote(recipe)\n slots.push(React.createElement('button', {\n key: key, type: 'button', className: 'mcart-hudslot',\n 'data-on': hudOn === entry.id ? '1' : '0',\n title: entry.name + '(' + entry.id + ')'\n + (entry.formLabel ? '\\n' + entry.formLabel : '')\n + '\\n' + slotFacts(recipe) + (note === null ? '' : '\\n' + note),\n onClick: () => pickItem(entry),\n }, React.createElement('canvas', {\n className: 'mcart-hudicon', width: 32, height: 32,\n ref: (node) => {\n if (node === null || node === undefined) delete hudCanvases[key]\n else hudCanvases[key] = node\n },\n })))\n }\n while (slots.length < HUD_SLOTS) {\n slots.push(React.createElement('span', { key: 'empty:' + slots.length,\n className: 'mcart-hudslot mcart-hudempty' }))\n }\n return React.createElement('div', { className: 'mcart-hud', key: 'hud' },\n hudPicked === '' ? null : React.createElement('div', { className: 'mcart-hudname' },\n hudPicked + (view.pages > 1 ? ' · ' + view.page + '/' + view.pages : '')),\n React.createElement('div', { className: 'mcart-hudbar' },\n view.pages > 1 ? React.createElement('button', { className: 'mcart-hudpage', type: 'button',\n disabled: view.page <= 1, title: '上一页物品',\n onClick: () => setHudPage(view.page - 1) }, '‹') : null,\n slots,\n view.pages > 1 ? React.createElement('button', { className: 'mcart-hudpage', type: 'button',\n disabled: view.page >= view.pages,\n title: '下一页物品(第 ' + view.page + ' / ' + view.pages + ' 页)',\n onClick: () => setHudPage(view.page + 1) }, '›') : null))\n }\n\n function itemCard() {\n if (item === null) return null\n const rows = []\n rows.push(React.createElement('div', { className: 'mcart-bar', key: 'head' },\n React.createElement('span', { className: 'mcart-title' }, '物品'),\n React.createElement('span', { className: 'mcart-sub' },\n item.source === 'project' ? '本项目' : item.namespace),\n React.createElement('span', { className: 'mcart-note mcart-grow' },\n '点一格就放到上面的 3D 里看'),\n React.createElement('button', { className: 'mcart-btn', type: 'button',\n onClick: () => { setItemOpen(false); setIconPick(null) } }, '收起')))\n const options = []\n const ours = projectNamespaceOf(activeProjectId())\n if (ours !== '') {\n options.push(React.createElement('option', { key: 'project', value: 'project' },\n '本项目 ' + ours + '(' + (item.source === 'project' ? item.facts.length : '…') + ')'))\n }\n for (const entry of refSources) {\n options.push(React.createElement('option', { key: 'ref:' + entry.name, value: 'ref:' + entry.name },\n entry.name + '(' + entry.blocks + ' 个方块)'))\n }\n if (options.length === 0) {\n options.push(React.createElement('option', { key: item.namespace, value: 'ref:' + item.namespace },\n item.namespace))\n }\n rows.push(React.createElement('div', { className: 'mcart-src', key: 'source' },\n React.createElement('span', { className: 'mcart-note' }, '来源'),\n React.createElement('select', {\n className: 'mcart-select',\n value: item.source === 'project' ? 'project' : 'ref:' + item.namespace,\n onChange: (event) => {\n const value = String(event.target.value)\n setIconPick(null)\n setPreviewItem(null)\n if (value === 'project') loadItems('project', ours === '' ? 'minecraft' : ours, 1, '', '', '')\n else loadItems('reference', value.slice(4), 1, '', '', '')\n },\n }, options),\n React.createElement('input', {\n className: 'mcart-input', placeholder: '搜物品名或 id',\n value: item.filter,\n onChange: (event) => setItem(Object.assign({}, item, { filter: event.target.value, page: 1 })),\n })))\n\n const formCounts = countsOf(item.facts, (entry) => entry.form, (entry) => entry.formLabel || entry.form)\n const forms = Object.keys(formCounts).sort((a, b) => formCounts[b].count - formCounts[a].count)\n if (forms.length > 0 && itemSearch() === '') {\n const chips = [React.createElement('button', {\n key: '__all', type: 'button', className: 'mcart-btn mcart-tool',\n 'data-on': item.form === '' ? '1' : '0',\n onClick: () => setItem(Object.assign({}, item, { form: '', family: '', page: 1 })),\n }, '全部 ' + item.facts.length)]\n for (const key of forms) {\n chips.push(React.createElement('button', {\n key: key, type: 'button', className: 'mcart-btn mcart-tool',\n 'data-on': item.form === key ? '1' : '0',\n onClick: () => setItem(Object.assign({}, item, { form: key, family: '', page: 1 })),\n }, formCounts[key].label + ' ' + formCounts[key].count))\n }\n rows.push(React.createElement('div', { className: 'mcart-tabs', key: 'forms' },\n React.createElement('span', { className: 'mcart-note' }, '展示形式'), chips))\n }\n const within = item.form === '' ? item.facts\n : item.facts.filter((entry) => entry.form === item.form)\n const familyCounts = countsOf(within, (entry) => entry.family, (entry) => entry.family)\n const families = Object.keys(familyCounts).sort((a, b) => familyCounts[b].count - familyCounts[a].count)\n if (families.length > 1 && itemSearch() === '') {\n const chips = [React.createElement('button', {\n key: '__all', type: 'button', className: 'mcart-btn mcart-tool',\n 'data-on': item.family === '' ? '1' : '0',\n onClick: () => setItem(Object.assign({}, item, { family: '', page: 1 })),\n }, '全部 ' + within.length)]\n for (const key of families) {\n chips.push(React.createElement('button', {\n key: key, type: 'button', className: 'mcart-btn mcart-tool',\n 'data-on': item.family === key ? '1' : '0',\n onClick: () => setItem(Object.assign({}, item, { family: key, page: 1 })),\n }, key + ' ' + familyCounts[key].count))\n }\n rows.push(React.createElement('div', { className: 'mcart-tabs', key: 'families' },\n React.createElement('span', { className: 'mcart-note' }, '细分'), chips))\n }\n\n const view = itemView()\n rows.push(React.createElement('div', { className: 'mcart-bar', key: 'pager' },\n React.createElement('button', { className: 'mcart-btn', type: 'button', disabled: view.page <= 1,\n onClick: () => setItem(Object.assign({}, item, { page: view.page - 1 })) }, '‹ 上一页'),\n React.createElement('span', { className: 'mcart-note' },\n '第 ' + view.page + ' / ' + view.pages + ' 页 · 共 ' + view.total + ' 个'),\n React.createElement('button', { className: 'mcart-btn', type: 'button', disabled: view.page >= view.pages,\n onClick: () => setItem(Object.assign({}, item, { page: view.page + 1 })) }, '下一页 ›')))\n\n const slots = []\n for (const entry of view.list) {\n const key = itemKey(item.namespace, entry.id)\n const url = itemUrls[key]\n const recipe = itemRecipes[key]\n const blank = recipe !== undefined && recipe !== null && recipe.shape === 'none'\n slots.push(React.createElement('button', {\n key: entry.id, type: 'button', className: 'mcart-slot',\n 'data-on': iconPick !== null && iconPick.id === entry.id ? '1' : '0',\n title: entry.name + '\\n' + entry.id + '\\n' + (entry.formLabel || entry.form || '')\n + (blank ? '\\n这个物品是代码画的,没有可用的图标模型' : ''),\n style: url === undefined || url === null ? {} : { backgroundImage: 'url(' + url + ')' },\n onClick: () => pickItem(entry),\n }, blank ? React.createElement('span', { className: 'mcart-slotq' }, '?') : null))\n }\n rows.push(React.createElement('div', { className: 'mcart-hotbar', key: 'slots' }, slots))\n\n const picked = iconPick === null ? null\n : view.list.filter((entry) => entry.id === iconPick.id)[0]\n if (picked !== undefined && picked !== null) {\n const recipe = itemRecipes[itemKey(iconPick.namespace, iconPick.id)]\n const frames = recipe === undefined || recipe === null ? 0 : (recipe.frames || []).length\n rows.push(React.createElement('div', { className: 'mcart-bar', key: 'chosen' },\n React.createElement('span', { className: 'mcart-note mcart-grow' },\n picked.name + '(' + picked.id + ')· ' + (picked.formLabel || picked.form || '')\n + (frames > 1 ? ' · ' + frames + ' 帧(原版按 overrides 换模型)' : '')\n + (recipe !== undefined && recipe !== null && recipe.error ? ' · ' + String(recipe.error) : ''))))\n }\n rows.push(React.createElement('div', { className: 'mcart-bar', key: 'msg' },\n React.createElement('span', { className: 'mcart-note mcart-grow' }, item.msg === null ? '' : String(item.msg))))\n return React.createElement('div', { className: 'mcart-card', key: 'items' }, rows)\n }\n\n function facesForScene() {\n const out = {}\n for (const quad of (scene === null ? [] : (scene.quads || []))) {\n if (quad.pick) continue\n if (typeof quad.tex !== 'string' || typeof quad.face !== 'string') continue\n if (out[quad.tex] === undefined) out[quad.tex] = []\n if (out[quad.tex].indexOf(quad.face) < 0) out[quad.tex].push(quad.face)\n }\n return out\n }\n\n\n function widestTexture(ids, byTexture) {\n let best = 0\n for (let i = 1; i < ids.length; i++) {\n const here = (byTexture[ids[i]] || []).length\n const there = (byTexture[ids[best]] || []).length\n if (here > there) best = i\n }\n return best\n }\n\n function makeEdit(ids, index, facesByTexture) {\n const frame = decoded[ids[index]]\n const copy = new Uint8ClampedArray(frame.data)\n const byTexture = facesByTexture || {}\n return {\n ids: ids, index: index, w: frame.width, h: frame.height,\n pixels: copy, palette: paletteOf(copy, 32), tool: 'pencil', color: null,\n faces: byTexture[ids[index]] || [],\n facesByTexture: byTexture,\n }\n }\n\n\n function itemAsset() {\n if (scene !== null || item === null || hudOn === '') return null\n return { namespace: item.namespace, id: hudOn, source: item.source,\n recipe: pickedRecipe, title: hudPicked === '' ? hudOn : hudPicked }\n }\n\n function openEditor() {\n const asset = itemAsset()\n if (scene === null && asset === null) return\n if (asset !== null) {\n setVoxel(null)\n const ids = asset.recipe === null ? []\n : (asset.recipe.textureIds || []).filter((id) => decoded[id] !== undefined)\n if (ids.length === 0) {\n setEdit(null)\n setItemMsg(asset.title + ' 还没有解码成功的贴图,改不了')\n return\n }\n setItemMsg(null)\n setEditMsg(null)\n setUndo([])\n painting.active = false\n setEdit(makeEdit(ids, widestTexture(ids, {}), {}))\n return\n }\n if (scene.kind === 'biome' || scene.kind === 'structure') { openVoxel(); return }\n if (scene.kind !== 'block' && scene.kind !== 'entity') {\n setEditMsg('这个类型还不能改:' + String(scene.kind))\n return\n }\n setVoxel(null)\n const ids = (scene.textureIds || []).filter((id) => decoded[id] !== undefined)\n if (ids.length === 0) {\n setEdit(null)\n setEditMsg('这个资产没有解码成功的贴图,改不了')\n return\n }\n setEditMsg(null)\n setUndo([])\n painting.active = false\n const byTexture = facesForScene()\n setEdit(makeEdit(ids, widestTexture(ids, byTexture), byTexture))\n }\n\n function closeEditor() {\n painting.active = false\n setEdit(null)\n setUndo([])\n setEditMsg(null)\n }\n\n function undoOnce() {\n if (edit === null || undo.length === 0) return\n const previous = undo[undo.length - 1]\n setUndo(undo.slice(0, undo.length - 1))\n setEdit(Object.assign({}, edit, { pixels: new Uint8ClampedArray(previous) }))\n }\n\n function pushUndo() {\n if (edit === null || edit.pixels === undefined) return\n const next = undo.concat([new Uint8ClampedArray(edit.pixels)])\n setUndo(next.length > EDIT_UNDO ? next.slice(next.length - EDIT_UNDO) : next)\n }\n\n function pixelAt(event, target) {\n return texturePoint(event.currentTarget.getBoundingClientRect(), target,\n event.clientX, event.clientY)\n }\n\n function applyTool(x, y, first) {\n if (edit === null || edit.pixels === undefined) return\n const w = edit.w, h = edit.h, pixels = edit.pixels\n if (x < 0 || y < 0 || x >= w || y >= h) return\n if (edit.tool === 'picker') {\n const picked = readPixel(pixels, w, x, y)\n setEdit(Object.assign({}, edit, { color: picked[3] === 0 ? null : picked }))\n return\n }\n if (first === true) pushUndo()\n if (edit.tool === 'fill') {\n const from = readPixel(pixels, w, x, y)\n const to = edit.color || [0, 0, 0, 255]\n if (!sameColour(from, to)) {\n const stack = [[x, y]]\n const visited = {}\n while (stack.length > 0) {\n const point = stack.pop()\n const cx = point[0], cy = point[1]\n if (cx < 0 || cy < 0 || cx >= w || cy >= h) continue\n const key = cy * w + cx\n if (visited[key] === true) continue\n visited[key] = true\n if (!sameColour(readPixel(pixels, w, cx, cy), from)) continue\n writePixel(pixels, w, cx, cy, to)\n stack.push([cx + 1, cy], [cx - 1, cy], [cx, cy + 1], [cx, cy - 1])\n }\n }\n } else if (edit.tool === 'eraser') {\n writePixel(pixels, w, x, y, [0, 0, 0, 0])\n } else {\n writePixel(pixels, w, x, y, edit.color || [0, 0, 0, 255])\n }\n setEdit(Object.assign({}, edit))\n }\n\n function saveEdit() {\n if (edit === null || edit.pixels === undefined || editBusy) return\n if (editScratch === null) { setEditMsg('画布还没准备好'); return }\n setEditBusy(true)\n setEditMsg(null)\n editScratch.width = edit.w\n editScratch.height = edit.h\n const paint = editScratch.getContext('2d')\n const frame = paint.createImageData(edit.w, edit.h)\n frame.data.set(edit.pixels)\n paint.putImageData(frame, 0, 0)\n const url = editScratch.toDataURL('image/png')\n const base64 = url.slice(url.indexOf(',') + 1)\n host.call('atlas.saveTexture', {\n root: root, project: activeProjectId(), path: edit.ids[edit.index], base64: base64,\n }).then((result) => {\n setEditBusy(false)\n if (result === null || result === undefined || result.error !== undefined) {\n setEditMsg('保存失败:' + String(result && result.error ? result.error : '没有返回结果'))\n return\n }\n const target = edit.ids[edit.index]\n decoded[target] = { width: edit.w, height: edit.h, data: edit.pixels }\n delete failedTex[target]\n lastDrawKey = ''\n setEdit(Object.assign({}, edit, { palette: paletteOf(edit.pixels, 32) }))\n setEditMsg('已保存 ' + target.split('/').pop() + '(' + result.bytes + ' 字节)')\n setEditTick(editTick + 1)\n }).catch((error) => {\n setEditBusy(false)\n setEditMsg('保存失败:' + String(error && error.message ? error.message : error))\n })\n }\n\n function editorCard() {\n if (edit === null && editMsg === null) return null\n const rows = []\n if (edit === null) {\n rows.push(React.createElement('div', { className: 'mcart-bar', key: 'lost' },\n React.createElement('span', { className: 'mcart-bad mcart-grow' }, String(editMsg)),\n React.createElement('button', { className: 'mcart-btn', type: 'button',\n onClick: () => setEditMsg(null) }, '知道了')))\n return React.createElement('div', { className: 'mcart-card', key: 'editor' }, rows)\n }\n rows.push(React.createElement('div', { className: 'mcart-bar', key: 'head' },\n React.createElement('span', { className: 'mcart-title' }, '改贴图'),\n React.createElement('span', { className: 'mcart-sub' },\n faceLabel(edit.faces) + ' · ' + edit.w + '×' + edit.h)))\n if (edit.ids.length > 1) {\n rows.push(React.createElement('div', { className: 'mcart-tabs', key: 'tabs' },\n edit.ids.map((id, index) => React.createElement('button', {\n key: id, type: 'button', className: 'mcart-btn mcart-tool',\n 'data-on': index === edit.index ? '1' : '0',\n onClick: () => {\n setUndo([]); setEditMsg(null); painting.active = false\n setEdit(makeEdit(edit.ids, index, edit.facesByTexture))\n },\n }, faceLabel((edit.facesByTexture || {})[id]) + ' · ' + id.split('/').pop().replace(/\\.png$/, '')))))\n }\n rows.push(React.createElement('div', { className: 'mcart-tools', key: 'tools' },\n [['pencil', '铅笔'], ['eraser', '橡皮'], ['fill', '油漆桶'], ['picker', '吸管']].map((pair) =>\n React.createElement('button', {\n key: pair[0], type: 'button', className: 'mcart-btn mcart-tool',\n 'data-on': edit.tool === pair[0] ? '1' : '0',\n onClick: () => setEdit(Object.assign({}, edit, { tool: pair[0] })),\n }, pair[1])),\n React.createElement('button', { className: 'mcart-btn', type: 'button', disabled: undo.length === 0,\n onClick: undoOnce }, '撤销')))\n const current = edit.color || [0, 0, 0, 255]\n const inPalette = edit.palette.some((c) => sameColour(c, current))\n rows.push(React.createElement('div', { className: 'mcart-bar', key: 'current' },\n React.createElement('input', {\n type: 'color', className: 'mcart-picker', value: hexOf(current),\n title: '取色板以外随便挑',\n onChange: (event) => setEdit(Object.assign({}, edit,\n { color: fromHex(event.target.value, current[3]) })),\n }),\n React.createElement('button', {\n className: 'mcart-btn', type: 'button', disabled: inPalette,\n title: '把当前颜色加进色板',\n onClick: () => setEdit(Object.assign({}, edit,\n { palette: addToPalette(edit.palette, current, EDIT_PALETTE) })),\n }, inPalette ? '已在色板' : '+ 色板'),\n React.createElement('input', {\n type: 'range', className: 'mcart-alpha', min: 0, max: 255, value: current[3],\n onChange: (event) => setEdit(Object.assign({}, edit,\n { color: [current[0], current[1], current[2], Number(event.target.value)] })),\n }),\n React.createElement('span', { className: 'mcart-note' }, hexOf(current)),\n React.createElement('span', { className: 'mcart-note mcart-grow' },\n '不透明度 ' + current[3] + ' · 撤销 ' + undo.length)))\n rows.push(React.createElement('div', { className: 'mcart-swatches', key: 'swatches' },\n edit.palette.map((colour) => React.createElement('button', {\n key: colour.join(','), type: 'button', className: 'mcart-swatch',\n style: { background: cssColour(colour) }, title: cssColour(colour),\n onClick: () => setEdit(Object.assign({}, edit, { color: colour })),\n }))))\n rows.push(React.createElement('canvas', {\n key: 'canvas', className: 'mcart-edit-canvas',\n ref: (node) => setEditCanvas(node),\n onPointerDown: (event) => {\n const point = pixelAt(event, edit)\n if (point === null) return\n painting.active = true\n if (event.currentTarget.setPointerCapture) {\n try { event.currentTarget.setPointerCapture(event.pointerId) } catch (error) { }\n }\n applyTool(point[0], point[1], true)\n },\n onPointerMove: (event) => {\n if (!painting.active) return\n const point = pixelAt(event, edit)\n if (point !== null) applyTool(point[0], point[1], false)\n },\n onPointerUp: () => { painting.active = false },\n onPointerLeave: () => { painting.active = false },\n }))\n rows.push(React.createElement('div', { className: 'mcart-bar', key: 'foot' },\n React.createElement('button', { className: 'mcart-btn', type: 'button', disabled: editBusy,\n onClick: saveEdit }, editBusy ? '保存中…' : '保存'),\n React.createElement('button', { className: 'mcart-btn', type: 'button', onClick: closeEditor }, '关闭'),\n React.createElement('span', { className: 'mcart-note mcart-grow' },\n (edit.ids[edit.index] || '').split('/').pop())))\n rows.push(React.createElement('div', { className: 'mcart-bar', key: 'msg' },\n React.createElement('span', { className: 'mcart-note mcart-grow' }, editMsg === null ? '' : String(editMsg))))\n return React.createElement('div', { className: 'mcart-card', key: 'editor' }, rows)\n }\n\n React.useEffect(() => {\n if (edit === null || edit.pixels === undefined) return\n if (editCanvas === null || editScratch === null) return\n editScratch.width = edit.w\n editScratch.height = edit.h\n const source = editScratch.getContext('2d')\n const frame = source.createImageData(edit.w, edit.h)\n frame.data.set(edit.pixels)\n source.putImageData(frame, 0, 0)\n const zoom = Math.max(2, Math.min(EDIT_ZOOM, 320 / Math.max(edit.w, edit.h)))\n editCanvas.width = edit.w * zoom\n editCanvas.height = edit.h * zoom\n const view = editCanvas.getContext('2d')\n view.imageSmoothingEnabled = false\n view.clearRect(0, 0, editCanvas.width, editCanvas.height)\n view.drawImage(editScratch, 0, 0, editCanvas.width, editCanvas.height)\n view.strokeStyle = 'rgba(127,127,127,.30)'\n view.lineWidth = 1\n for (let x = 0; x <= edit.w; x++) {\n view.beginPath(); view.moveTo(x * zoom + 0.5, 0); view.lineTo(x * zoom + 0.5, editCanvas.height); view.stroke()\n }\n for (let y = 0; y <= edit.h; y++) {\n view.beginPath(); view.moveTo(0, y * zoom + 0.5); view.lineTo(editCanvas.width, y * zoom + 0.5); view.stroke()\n }\n })\n\n function complain() {\n if (scene !== null) {\n setPending({ path: scene.ref || '', title: scene.title || '' })\n return\n }\n const asset = itemAsset()\n if (asset === null) return\n const path = asset.recipe === null || asset.recipe.modelPath === undefined\n ? '' : String(asset.recipe.modelPath)\n if (path === '') {\n setItemMsg(asset.title + ' 在参考的 jar 里,没有可以引用的文件')\n return\n }\n setItemMsg(null)\n setPending({ path: path, title: asset.title })\n }\n\n function settingsCard() {\n if (settings === null) return null\n const rows = []\n if (settings.loading === true) {\n rows.push(React.createElement('div', { className: 'mcart-note', key: 'l' }, '读取设置…'))\n } else if (settings.error !== undefined) {\n rows.push(React.createElement('div', { className: 'mcart-err', key: 'e' }, String(settings.error)))\n } else {\n rows.push(React.createElement('div', { className: 'mcart-bar', key: 'head' },\n React.createElement('span', { className: 'mcart-title' }, '设置'),\n React.createElement('span', { className: 'mcart-sub' }, settings.title || settings.project || ''),\n ))\n rows.push(React.createElement('div', { className: 'mcart-note', key: 'reflabel' }, '参考目录'))\n rows.push(React.createElement('div', { className: 'mcart-path', key: 'refpath' }, settings.directory || '(未设置)'))\n const dirButtons = [\n React.createElement('button', { className: 'mcart-btn', type: 'button', key: 'pick', disabled: picking,\n onClick: () => runPicker(settings.directory || root, (picked) => patchSettings({ directory: picked }, true)) },\n picking ? '对话框已打开…' : '选择目录…'),\n ]\n rows.push(React.createElement('div', { className: 'mcart-bar', key: 'dirbuttons' }, dirButtons))\n if (settings.directory !== '' && settings.directory !== undefined) {\n if (settings.scanError !== null && settings.scanError !== undefined) {\n rows.push(React.createElement('div', { className: 'mcart-err', key: 'scanerr' }, '没读到里面的资源:' + String(settings.scanError)))\n } else {\n rows.push(React.createElement('div', { className: 'mcart-hint', key: 'shape' },\n '识别为 ' + (settings.shape || '?') + ' · ' + (settings.textures || 0) + ' 张贴图 · '\n + ((settings.sources || []).length) + ' 个资源文件'))\n }\n }\n for (const hint of (settings.directory === '' || settings.directory === undefined ? (settings.detected || []) : []).slice(0, 3)) {\n rows.push(React.createElement('div', { className: 'mcart-bar', key: 'det' + hint },\n React.createElement('span', { className: 'mcart-note mcart-grow' }, '检测到 ' + hint),\n React.createElement('button', { className: 'mcart-btn', type: 'button',\n onClick: () => patchSettings({ directory: hint }, true) }, '用它'),\n ))\n }\n if ((settings.directory === '' || settings.directory === undefined) && (settings.detected || []).length === 0) {\n rows.push(React.createElement('div', { className: 'mcart-hint', key: 'nodir' },\n '没设置就不参考外部资源。可以指向 .minecraft、某个版本目录、mods 目录,或单个 jar。'))\n }\n\n rows.push(React.createElement('label', { className: 'mcart-switch', key: 'gen' },\n React.createElement('input', { type: 'checkbox', checked: settings.includeGenerated !== false,\n onChange: (event) => patchSettings({ includeGenerated: event.target.checked }) }),\n React.createElement('span', null, '参考本目录已生成内容'),\n ))\n rows.push(React.createElement('div', { className: 'mcart-hint', key: 'genhint' }, '有助于保持生成风格一致'))\n\n rows.push(React.createElement('label', { className: 'mcart-switch', key: 'mods' },\n React.createElement('input', { type: 'checkbox', checked: settings.includeMods !== false,\n onChange: (event) => patchSettings({ includeMods: event.target.checked }) }),\n React.createElement('span', null, '参考目录中的模组资源文件'),\n ))\n\n if (settings.includeMods !== false) {\n const mods = settings.mods || []\n const missing = settings.directory === '' || settings.directory === undefined\n const message = missing ? '还没指定参考目录'\n : (mods.length === 0 ? '参考目录里没有找到任何资源命名空间' : ('发现 ' + mods.length + ' 个命名空间'))\n rows.push(React.createElement('div', { className: 'mcart-bar', key: 'modbar' },\n React.createElement('span', { className: 'mcart-note' }, message),\n mods.length === 0 ? null : React.createElement('button', { className: 'mcart-btn', type: 'button',\n onClick: () => setModsOpen(!modsOpen) }, modsOpen ? '收起 ▴' : '展开 ▾'),\n ))\n if (modsOpen) {\n const list = []\n for (const item of mods) {\n const from = (item.from || []).slice(0, 2).join('、')\n list.push(React.createElement('label', { className: 'mcart-switch', key: item.name },\n React.createElement('input', { type: 'checkbox', checked: item.on === true,\n onChange: (event) => toggleMod(item.name, event.target.checked) }),\n React.createElement('span', { className: 'mcart-grow' }, item.name,\n from === '' ? null : React.createElement('span', { className: 'mcart-id' }, from)),\n React.createElement('span', { className: 'mcart-id' }, item.count + ' 张贴图'),\n ))\n }\n rows.push(React.createElement('div', { className: 'mcart-mods', key: 'modlist' }, list))\n }\n }\n\n rows.push(React.createElement('div', { className: 'mcart-bar', key: 'foot' },\n React.createElement('span', { className: 'mcart-note mcart-grow' }, settings.file || ''),\n React.createElement('span', { className: 'mcart-bad' }, saveState === null ? '' : saveState),\n ))\n if (savedSettings !== null && savedSettings.path !== '') {\n rows.push(React.createElement('div', { className: 'mcart-bar', key: 'tell' },\n React.createElement('span', { className: 'mcart-note mcart-grow' },\n '已保存,要让 AI 按新设置重做的话:'),\n React.createElement('button', { className: 'mcart-btn', type: 'button',\n onClick: () => setPending({ path: savedSettings.path, note: savedSettings.note }) },\n '告诉 AI'),\n React.createElement('button', { className: 'mcart-btn', type: 'button',\n onClick: () => setSavedSettings(null) }, '不用了'),\n ))\n }\n }\n return React.createElement('div', { className: 'mcart-card', key: 'settings' }, rows)\n }\n\n if (root === '') {\n const card = []\n card.push(React.createElement('div', { className: 'mcart-note', key: 'hint' }, '未指定资产路径'))\n card.push(React.createElement('div', { className: 'mcart-bar', key: 'input' },\n React.createElement('input', {\n className: 'mcart-input', placeholder: '/path/to/project',\n value: draft, onChange: (event) => setDraft(event.target.value),\n onKeyDown: (event) => { if (event.key === 'Enter' && draft !== '') commitRoot(draft) },\n }),\n React.createElement('button', { className: 'mcart-btn', type: 'button',\n onClick: () => { if (draft !== '') commitRoot(draft) } }, '使用'),\n ))\n\n if (nearby !== null) {\n if (nearby.loading === true) {\n card.push(React.createElement('div', { className: 'mcart-note', key: 'scanning' }, '正在找本会话目录里的项目…'))\n } else if (nearby.error !== null) {\n card.push(React.createElement('div', { className: 'mcart-err', key: 'nerr' }, String(nearby.error)))\n } else if (nearby.projects.length > 0) {\n card.push(React.createElement('div', { className: 'mcart-note', key: 'found' }, '在 ' + nearby.cwd + ' 里找到:'))\n const rows = []\n for (const project of nearby.projects) {\n rows.push(React.createElement('div', { className: 'mcart-row', key: project.root + '/' + project.id },\n React.createElement('span', { className: 'mcart-grow' }, project.title,\n project.title === project.id ? null : React.createElement('span', { className: 'mcart-id' }, project.id)),\n React.createElement('button', { className: 'mcart-btn', type: 'button',\n onClick: () => openNearby(project.root, project.id) }, '打开'),\n ))\n }\n card.push(React.createElement('div', { className: 'mcart-projects', key: 'projects' }, rows))\n } else {\n card.push(React.createElement('div', { className: 'mcart-note', key: 'none' },\n '在 ' + nearby.cwd + ' 里没找到 mc 资产项目。'))\n }\n }\n\n card.push(React.createElement('div', { className: 'mcart-bar', key: 'buttons' },\n React.createElement('button', { className: 'mcart-btn', type: 'button', disabled: picking,\n onClick: pickDirectory }, picking ? '对话框已打开…' : '选择目录…'),\n nearby !== null && nearby.cwd !== '' ? React.createElement('button', { className: 'mcart-btn', type: 'button',\n onClick: () => commitRoot(nearby.cwd) }, '用本会话目录') : null,\n ))\n\n if (notice !== null) card.push(React.createElement('div', { className: 'mcart-bad', key: 'notice' }, String(notice)))\n if (failure !== null) card.push(React.createElement('div', { className: 'mcart-err', key: 'failure' }, String(failure)))\n return React.createElement('div', { className: 'mcart-root' },\n React.createElement('div', { className: 'mcart-bar' },\n React.createElement('span', { className: 'mcart-title' }, 'MC 资产'),\n React.createElement('span', { className: 'mcart-sub' }, inRightColumn ? '右侧栏' : '中央面板'),\n ),\n React.createElement('div', { className: 'mcart-card' }, card),\n )\n }\n\n const projects = index === null ? [] : (index.projects || [])\n const menu = []\n for (const project of projects) {\n menu.push(React.createElement('div', { className: 'mcart-group', key: 'p' + project.id }, project.title))\n for (const category of CATEGORIES) {\n const items = (project.items && project.items[category.key]) || []\n if (items.length === 0) continue\n menu.push(React.createElement('div', { className: 'mcart-group', key: 'p' + project.id + category.key },\n category.label + ' · ' + items.length))\n for (const item of items) {\n const on = choice !== null && choice.project === project.id && choice.kind === category.key && choice.id === item.id\n menu.push(React.createElement('button', {\n key: project.id + '/' + category.key + '/' + item.id, type: 'button',\n className: 'mcart-item', 'data-on': on ? '1' : '0',\n onClick: () => open({ project: project.id, kind: category.key, id: item.id }, root),\n }, item.title,\n item.title === item.id ? null : React.createElement('span', { className: 'mcart-id' }, item.id)))\n }\n }\n }\n\n const stage = []\n const animRows = []\n if (scene !== null) {\n const ids = scene.textureIds || []\n const okCount = ids.filter((id) => decoded[id] !== undefined).length\n const bad = ids.filter((id) => failedTex[id] === true).length\n stage.push(React.createElement('div', { className: 'mcart-bar', key: 'row' },\n React.createElement('span', { className: 'mcart-note' },\n scene.quads.length + ' 面 · 贴图 ' + okCount + '/' + ids.length),\n React.createElement('button', { className: 'mcart-btn', type: 'button',\n onClick: () => { setYaw(0.72); setPitch(0.34); setZoom(1) } }, '重置'),\n React.createElement('button', { className: 'mcart-btn', type: 'button', onClick: openEditor }, '✎ 手动修改'),\n React.createElement('button', { className: 'mcart-btn', type: 'button', onClick: complain }, '@ 提意见'),\n ))\n if (bad > 0) stage.push(React.createElement('div', { className: 'mcart-bad', key: 'bad' }, bad + ' 张贴图解码失败'))\n if (scene.errors && scene.errors.length > 0) {\n stage.push(React.createElement('div', { className: 'mcart-bad', key: 'notes' }, scene.errors.join(';')))\n }\n const animIndex = scene === null ? {} : (scene.animations || {})\n for (const id of Object.keys(animIndex).sort()) {\n const tex = decoded[id]\n const size = tex === undefined ? '还没解码' : (tex.width + '×' + tex.height)\n const strip = tex === undefined ? null\n : stripOf({ width: tex.width, height: tex.height, animation: animIndex[id] })\n const row = animationRow(animIndex[id], animTicks * 50)\n animRows.push(React.createElement('div', { className: 'mcart-hint', key: 'anim:' + id },\n '动画 ' + id + ' · 解码 ' + size + ' · 声明 ' + String(animIndex[id].strip) + ' 行'\n + ' · 判定 ' + (tex === undefined ? '等解码' : (strip === null ? '不是条带 ← 会糊' : '单帧 ' + strip))\n + ' · 现在抽第 ' + String(row) + ' 行'))\n }\n for (const id of (scene === null ? [] : (scene.textureIds || []))) {\n if (animIndex[id] !== undefined) continue\n const tex = decoded[id]\n if (tex === undefined) continue\n if (!(tex.height > tex.width && tex.height % tex.width === 0)) continue\n animRows.push(React.createElement('div', { className: 'mcart-hint', key: 'noanim:' + id },\n '形状像条带但没有动画描述 ' + id + ' · 解码 ' + tex.width + '×' + tex.height + ' ← 会糊'))\n }\n if (scene.palette && scene.palette.length > 0) {\n stage.push(React.createElement('div', { className: 'mcart-bar', key: 'palette' },\n scene.palette.slice(0, 8).map((entry) => React.createElement('span', { className: 'mcart-chip', key: entry.block },\n (entry.label || entry.block) + ' ×' + entry.count)),\n ))\n }\n }\n const pickedRecipe = hudOn === '' || item === null ? null : itemRecipes[itemKey(item.namespace, hudOn)]\n if (scene === null) {\n const asset = itemAsset()\n if (asset !== null) {\n const ids = asset.recipe === null ? [] : (asset.recipe.textureIds || [])\n const okCount = ids.filter((id) => decoded[id] !== undefined).length\n stage.push(React.createElement('div', { className: 'mcart-bar', key: 'itemrow' },\n React.createElement('span', { className: 'mcart-note' },\n '物品 · 贴图 ' + okCount + '/' + ids.length\n + (asset.source === 'project' ? '' : '(参考)')),\n React.createElement('button', { className: 'mcart-btn', type: 'button', onClick: openEditor }, '✎ 手动修改'),\n React.createElement('button', { className: 'mcart-btn', type: 'button', onClick: complain }, '@ 提意见'),\n ))\n }\n }\n const posterRecipe = scene === null && pickedRecipe !== null ? pickedRecipe : null\n const posterSide = Math.max(48, Math.min(size[0], size[1]) - 24)\n stage.push(React.createElement('div', { className: 'mcart-viewport', key: 'viewport' },\n React.createElement('canvas', {\n key: 'canvas', className: 'mcart-canvas',\n style: { height: viewH + 'px' },\n ref: (node) => { canvasGeneration += 1; setCanvas(node) },\n onPointerDown: (event) => {\n drag.active = true\n drag.moved = 0\n drag.x = event.clientX\n drag.y = event.clientY\n if (event.currentTarget.setPointerCapture) {\n try { event.currentTarget.setPointerCapture(event.pointerId) } catch (error) { }\n }\n },\n onPointerMove: (event) => {\n if (!drag.active) {\n if (voxel !== null) {\n const target = voxelTargetAt(event)\n if (target === null) { if (hover !== null) setHover(null) }\n else if (hover === null || hover.cell !== target.cell || hover.face !== target.face) setHover(target)\n }\n return\n }\n const dx = event.clientX - drag.x\n const dy = event.clientY - drag.y\n drag.x = event.clientX\n drag.y = event.clientY\n drag.moved += Math.abs(dx) + Math.abs(dy)\n setYaw((previous) => previous - dx * 0.011)\n setPitch((previous) => Math.max(-1.35, Math.min(1.35, previous + dy * 0.011)))\n },\n onPointerUp: (event) => {\n const dragDistance = drag.moved\n drag.active = false\n if (dragDistance > 4 || voxel === null) return\n voxelClick(event, event.button === 2 ? 'place' : 'remove')\n },\n onContextMenu: (event) => { if (event.preventDefault) event.preventDefault() },\n onPointerLeave: () => { drag.active = false },\n }),\n React.createElement('div', { className: 'mcart-zoom' },\n React.createElement('button', { className: 'mcart-btn mcart-zoombtn', type: 'button',\n title: '放大', onClick: () => zoomBy(1.15) }, '+'),\n React.createElement('button', { className: 'mcart-btn mcart-zoombtn', type: 'button',\n title: '缩小', onClick: () => zoomBy(1 / 1.15) }, '-'),\n React.createElement('button', { className: 'mcart-btn mcart-zoombtn', type: 'button',\n title: '复位视角(旋转与缩放一起)',\n onClick: () => { setYaw(0.72); setPitch(0.34); setZoom(1) } }, '⟲'),\n ),\n posterRecipe === null ? null : React.createElement('canvas', {\n key: 'poster', className: 'mcart-poster',\n width: posterSide, height: posterSide,\n style: { width: posterSide + 'px', height: posterSide + 'px' },\n title: hudPicked,\n ref: (node) => setPosterCanvas(node),\n }),\n hudBar()))\n\n stage.push(React.createElement('div', { className: 'mcart-grip', key: 'grip',\n title: '上下拖动调整 3D + 物品栏的高度,双击复位',\n onDoubleClick: () => setViewH(VIEW_H),\n onPointerDown: (event) => {\n gripDrag.active = true\n gripDrag.y = event.clientY\n gripDrag.origin = viewH\n if (event.currentTarget.setPointerCapture) {\n try { event.currentTarget.setPointerCapture(event.pointerId) } catch (error) { }\n }\n },\n onPointerMove: (event) => {\n if (!gripDrag.active) return\n setViewH(clampViewH(gripDrag.origin + (event.clientY - gripDrag.y)))\n },\n onPointerUp: () => { gripDrag.active = false },\n onPointerCancel: () => { gripDrag.active = false },\n }, React.createElement('span', { className: 'mcart-gripbar' })))\n\n const iconNotes = []\n if (itemMsg !== null) iconNotes.push(String(itemMsg))\n const pickedNote = slotNote(pickedRecipe)\n if (pickedNote !== null) iconNotes.push(pickedNote)\n if (previewItem !== null && previewItem.localOnly === true) {\n iconNotes.push((hudPicked === '' ? previewItem.id : hudPicked)\n + ' 没有方块模型(它是物品,不是方块),所以 3D 里没有东西可看;它的样子就是物品栏里那一格')\n }\n if (choice !== null && choice.kind !== 'block') {\n iconNotes.push(choice.kind === 'entity'\n ? '实体在游戏里没有物品图标(刷怪蛋的蛋色写在代码里,包里没有)'\n : (choice.kind === 'biome' ? '群系' : '多方块结构') + '在游戏里不是物品')\n }\n if (iconNotes.length > 0) {\n stage.push(React.createElement('div', { className: 'mcart-note', key: 'iconnote' }, iconNotes.join(';')))\n }\n\n if (animRows.length > 0) {\n stage.push(React.createElement('div', { className: 'mcart-mods', key: 'anims' }, animRows))\n }\n\n const sources = []\n const allTextures = Object.assign({}, scene === null ? {} : scene.textures,\n ghost === null || ghost.textures === undefined ? {} : ghost.textures)\n for (const recipe of iconRecipesInUse()) {\n if (recipe.textures !== undefined && recipe.textures !== null) Object.assign(allTextures, recipe.textures)\n }\n if (scene !== null || item !== null) {\n for (const id of Object.keys(allTextures)) {\n if (decoded[id] !== undefined) continue\n sources.push(React.createElement('img', {\n key: id, src: allTextures[id], alt: '', style: { display: 'none' },\n ref: (node) => {\n if (node === null || node === undefined) delete imageNodes[id]\n else imageNodes[id] = node\n },\n onLoad: () => setTick(tick + 1),\n onError: () => { failedTex[id] = true; setTick(tick + 1) },\n }))\n }\n }\n\n return React.createElement('div', { className: 'mcart-root' },\n React.createElement('div', { className: 'mcart-bar' },\n React.createElement('span', { className: 'mcart-title' }, 'MC 资产'),\n React.createElement('span', { className: 'mcart-sub' },\n busy ? '加载中…' : (inRightColumn ? '右侧栏' : '中央面板')),\n React.createElement('button', { className: 'mcart-btn mcart-tool', type: 'button',\n title: '物品浏览器:来源、搜索、展示形式都在这里;下面那排物品栏跟着一起筛',\n 'data-on': itemOpen ? '1' : '0',\n onClick: openItems }, itemOpen ? '收起列表' : '物品列表'),\n React.createElement('button', { className: 'mcart-icon', type: 'button', title: '设置',\n onClick: openSettings }, settings === null ? '⚙' : '⚙▾'),\n ),\n settings === null ? null : settingsCard(),\n React.createElement('div', { className: 'mcart-bar' },\n React.createElement('span', { className: 'mcart-path' }, root),\n React.createElement('button', { className: 'mcart-btn', type: 'button',\n onClick: () => { setRoot(''); setIndex(null); setItem(null); setItemOpen(false); setIconPick(null); setPreviewItem(null); setHudPage(1); setScene(null); setSettings(null); setEdit(null); setVoxel(null); setHover(null); setGhost(null) } }, '换项目'),\n React.createElement('button', { className: 'mcart-btn', type: 'button', onClick: () => scan(root, true, null) }, '刷新'),\n ),\n failure === null ? null : React.createElement('div', { className: 'mcart-err' }, String(failure)),\n React.createElement('div', { className: 'mcart-list' }, menu),\n editorCard(),\n voxelCard(),\n itemOpen ? itemCard() : null,\n React.createElement('div', { className: 'mcart-stage' }, stage),\n React.createElement('div', { style: { display: 'none' } },\n React.createElement('canvas', { ref: setScratch, width: 256, height: 256 }),\n React.createElement('canvas', { ref: setEditScratch, width: 16, height: 16 }),\n sources,\n ),\n )\n }\n\n function TabTitle() {\n return React.createElement('span', null, 'MC 资产')\n }\n\n function ReferenceBar(props) {\n const current = usePending()\n const draft = props.useInput !== undefined ? props.useInput((state) => state.draft) : ''\n if (current === null || current.path === '') return null\n const note = current.note === undefined || current.note === null ? '' : String(current.note)\n return React.createElement('div', { className: 'mcart-ref' },\n React.createElement('span', null, '引用给 AI'),\n React.createElement('span', { className: 'mcart-ref-path' }, '@' + current.path),\n note === '' ? null : React.createElement('span', { className: 'mcart-ref-path' }, note),\n React.createElement('button', {\n className: 'mcart-btn', type: 'button',\n onClick: () => {\n const text = draft === undefined || draft === null ? '' : String(draft)\n const base = text.length > 0 ? text.replace(/\\s+$/, '') + ' ' : ''\n const tail = '@' + current.path + (note === '' ? ' ' : ' ' + note)\n if (props.inputActions !== undefined) props.inputActions.setDraft(base + tail + ' ')\n setPending(null)\n },\n }, '插入输入框'),\n React.createElement('button', { className: 'mcart-btn', type: 'button', onClick: () => setPending(null) }, '取消'),\n )\n }\n\n slots.inject('conversation.input.dock', () => slots.register(\n { name: 'conversation.input.dock', id: 'mc-art-ref', order: 15 },\n (props) => React.createElement(ReferenceBar, { useInput: props.useInput, inputActions: props.inputActions }),\n ))\n\n if (inRightColumn) {\n ctx.effect(() => tabs.register({\n id: TAB_ID,\n kind: TAB_ID,\n priority: 'extension',\n title: () => 'MC 资产',\n guide: [{\n id: TAB_ID + '.open',\n order: 40,\n title: () => 'MC 资产预览',\n description: () => '在右侧栏里看这个项目的方块 / 实体 / 群系 / 多方块结构',\n }],\n }))\n slots.inject('sidebar.right.pane.tab', () => slots.register(\n { name: 'sidebar.right.pane.tab', key: TAB_ID },\n (props) => React.createElement(Atlas, { sessionId: props.sessionId }),\n ))\n slots.inject('sidebar.right.pane.tab.title', () => slots.register(\n { name: 'sidebar.right.pane.tab.title', key: TAB_ID },\n () => React.createElement(TabTitle, null),\n ))\n } else {\n slots.inject('main', () => slots.register(\n { name: 'main', key: 'mcarts' },\n () => React.createElement(Atlas, { sessionId: '' }),\n ))\n slots.inject('sidebar.panellist', () => slots.register(\n { name: 'sidebar.panellist', id: 'mcarts', order: 12, label: 'MC 资产' },\n () => React.createElement('span', null, 'MC'),\n ))\n }\n },\n}\n"
12
+ var SOURCE = "const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]]\nconst add = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]]\nconst mul = (a, k) => [a[0] * k, a[1] * k, a[2] * k]\nconst dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]\nconst cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]\nconst vlen = (a) => Math.sqrt(dot(a, a))\nfunction unit(a) { const n = vlen(a); return n < 1e-12 ? [0, 0, 0] : mul(a, 1 / n) }\n\n\nfunction rotateAbout(point, origin, axis, degrees) {\n if (!degrees) return point\n const angle = (degrees * Math.PI) / 180\n const p = sub(point, origin)\n const c = Math.cos(angle)\n const s = Math.sin(angle)\n let r\n if (axis === 'x') r = [p[0], p[1] * c - p[2] * s, p[1] * s + p[2] * c]\n else if (axis === 'y') r = [p[0] * c + p[2] * s, p[1], -p[0] * s + p[2] * c]\n else r = [p[0] * c - p[1] * s, p[0] * s + p[1] * c, p[2]]\n return add(r, origin)\n}\n\n\nfunction boxOfQuads(quads) {\n const min = [Infinity, Infinity, Infinity]\n const max = [-Infinity, -Infinity, -Infinity]\n for (const quad of quads || []) for (const p of quad.p) for (let i = 0; i < 3; i++) {\n if (p[i] < min[i]) min[i] = p[i]\n if (p[i] > max[i]) max[i] = p[i]\n }\n if (!isFinite(min[0])) return { min: [-1, -1, -1], max: [1, 1, 1] }\n return { min: min, max: max }\n}\n\nfunction makeCamera(position, target, width, height, fovY) {\n const forward = unit(sub(target, position))\n const right = unit(cross(forward, [0, 1, 0]))\n const up = cross(right, forward)\n const focal = height / 2 / Math.tan(fovY / 2)\n return {\n project: function (point) {\n const offset = sub(point, position)\n const x = dot(offset, right)\n const y = dot(offset, up)\n const z = dot(offset, forward)\n if (z <= 0.02) return null\n return [width / 2 + (x * focal) / z, height / 2 - (y * focal) / z, z]\n },\n }\n}\n\nfunction orbitCamera(box, yaw, pitch, zoom, width, height, fovY) {\n const centre = mul(add(box.min, box.max), 0.5)\n const radius = Math.max(vlen(sub(box.max, box.min)) / 2, 0.05)\n const fit = radius / Math.sin(fovY / 2) / zoom\n const cp = Math.cos(pitch)\n const direction = [Math.sin(yaw) * cp, Math.sin(pitch), Math.cos(yaw) * cp]\n return makeCamera(add(centre, mul(direction, fit * 1.05)), centre, width, height, fovY)\n}\n\nconst EMPTY_ANIMATIONS = {}\n\nfunction animationRow(animation, timeMs) {\n if (animation === undefined || animation === null) return 0\n const order = Array.isArray(animation.order) && animation.order.length > 0 ? animation.order : null\n const frames = order === null ? animation.frames : order.length\n if (!(frames > 1)) return 0\n const ticks = Math.max(1, animation.frametime || 1)\n const step = Math.floor((timeMs || 0) / (ticks * 50))\n const index = ((step % frames) + frames) % frames\n return order === null ? index : order[index]\n}\n\nfunction stripOf(texture) {\n const animation = texture === undefined ? undefined : texture.animation\n if (animation === undefined || animation === null) return null\n const strip = animation.strip\n if (!(strip > 1) || texture.width <= 0) return null\n if (texture.height !== strip * texture.width) return null\n return strip\n}\n\nfunction renderScene(options) {\n const width = options.width\n const height = options.height\n const background = options.background\n const camera = options.camera\n const colour = options.colour === undefined ? new Float64Array(width * height * 4) : options.colour\n const depth = options.depth === undefined ? new Float64Array(width * height) : options.depth\n const overlay = options.overlay === undefined ? null : options.overlay\n const tint = options.tint === undefined ? null : options.tint\n const backgroundAlpha = options.backgroundAlpha === undefined ? 1 : options.backgroundAlpha\n const orthographic = options.orthographic === true\n if (overlay === null) {\n for (let i = 0; i < width * height; i++) {\n colour[i * 4] = background[0] / 255\n colour[i * 4 + 1] = background[1] / 255\n colour[i * 4 + 2] = background[2] / 255\n colour[i * 4 + 3] = backgroundAlpha\n depth[i] = Infinity\n }\n }\n const pick = options.pick === undefined ? null : options.pick\n for (let quadIndex = 0; quadIndex < options.quads.length; quadIndex++) {\n const quad = options.quads[quadIndex]\n const texture = options.textures[quad.tex]\n if (texture === undefined) continue\n const mode = quad.mode || 'opaque'\n const shade = quad.shade === undefined ? 1 : quad.shade\n for (const indices of [[0, 1, 2], [0, 2, 3]]) {\n const points = [quad.p[indices[0]], quad.p[indices[1]], quad.p[indices[2]]]\n const uvs = [quad.uv[indices[0]], quad.uv[indices[1]], quad.uv[indices[2]]]\n const screen = []\n let visible = true\n for (const point of points) {\n const projected = camera.project(point)\n if (projected === null) { visible = false; break }\n screen.push(projected)\n }\n if (!visible) continue\n const s0 = screen[0], s1 = screen[1], s2 = screen[2]\n const minx = Math.max(0, Math.floor(Math.min(s0[0], s1[0], s2[0])))\n const maxx = Math.min(width - 1, Math.ceil(Math.max(s0[0], s1[0], s2[0])))\n const miny = Math.max(0, Math.floor(Math.min(s0[1], s1[1], s2[1])))\n const maxy = Math.min(height - 1, Math.ceil(Math.max(s0[1], s1[1], s2[1])))\n if (minx > maxx || miny > maxy) continue\n const area = (s1[0] - s0[0]) * (s2[1] - s0[1]) - (s2[0] - s0[0]) * (s1[1] - s0[1])\n if (Math.abs(area) < 1e-9) continue\n const tw = texture.width\n const th = texture.height\n const data = texture.data\n const blend = mode === 'blend'\n const animation = (options.animations || EMPTY_ANIMATIONS)[quad.tex]\n const strip = stripOf({ width: tw, height: th, animation: animation })\n const row = strip === null ? 0 : animationRow(animation, options.timeMs)\n for (let y = miny; y <= maxy; y++) {\n const gy = y + 0.5\n for (let x = minx; x <= maxx; x++) {\n const gx = x + 0.5\n const w0 = ((s1[0] - gx) * (s2[1] - gy) - (s2[0] - gx) * (s1[1] - gy)) / area\n const w1 = ((s2[0] - gx) * (s0[1] - gy) - (s0[0] - gx) * (s2[1] - gy)) / area\n const w2 = 1 - w0 - w1\n if (w0 < -1e-6 || w1 < -1e-6 || w2 < -1e-6) continue\n let zs, u, v\n if (orthographic) {\n zs = w0 * s0[2] + w1 * s1[2] + w2 * s2[2]\n if (!(zs > 1e-9)) continue\n u = w0 * uvs[0][0] + w1 * uvs[1][0] + w2 * uvs[2][0]\n v = w0 * uvs[0][1] + w1 * uvs[1][1] + w2 * uvs[2][1]\n } else {\n const inv = w0 / s0[2] + w1 / s1[2] + w2 / s2[2]\n if (!(inv > 1e-9)) continue\n zs = 1 / inv\n u = (w0 * uvs[0][0] / s0[2] + w1 * uvs[1][0] / s1[2] + w2 * uvs[2][0] / s2[2]) / inv\n v = (w0 * uvs[0][1] / s0[2] + w1 * uvs[1][1] / s1[2] + w2 * uvs[2][1] / s2[2]) / inv\n }\n let tx = Math.trunc(u * tw)\n let ty = strip === null ? Math.trunc(v * th) : row * tw + Math.trunc(v * tw)\n if (tx < 0) tx = 0; else if (tx > tw - 1) tx = tw - 1\n if (ty < 0) ty = 0; else if (ty > th - 1) ty = th - 1\n const index = (ty * tw + tx) * 4\n const alpha = data[index + 3] / 255\n if (overlay === null) {\n if (blend) {\n if (!(alpha > 0)) continue\n if (!(zs <= depth[y * width + x])) continue\n } else {\n if (!(alpha >= 0.5)) continue\n if (!(zs < depth[y * width + x])) continue\n }\n depth[y * width + x] = zs\n if (pick !== null) pick[y * width + x] = quadIndex\n } else if (!(zs <= depth[y * width + x] + 1e-6)) {\n continue\n }\n const target = (y * width + x) * 4\n const r = tint === null ? (data[index] / 255) * shade : tint[0]\n const g = tint === null ? (data[index + 1] / 255) * shade : tint[1]\n const b = tint === null ? (data[index + 2] / 255) * shade : tint[2]\n if (overlay !== null) {\n colour[target] = r * overlay + colour[target] * (1 - overlay)\n colour[target + 1] = g * overlay + colour[target + 1] * (1 - overlay)\n colour[target + 2] = b * overlay + colour[target + 2] * (1 - overlay)\n } else if (blend) {\n colour[target] = r * alpha + colour[target] * (1 - alpha)\n colour[target + 1] = g * alpha + colour[target + 1] * (1 - alpha)\n colour[target + 2] = b * alpha + colour[target + 2] * (1 - alpha)\n } else {\n colour[target] = r\n colour[target + 1] = g\n colour[target + 2] = b\n }\n colour[target + 3] = 1\n }\n }\n }\n }\n const bytes = new Uint8ClampedArray(width * height * 4)\n for (let i = 0; i < bytes.length; i++) {\n const value = Math.round(colour[i] * 255)\n bytes[i] = value < 0 ? 0 : (value > 255 ? 255 : value)\n }\n return bytes\n}\n\n\nconst FACE_AXIS = { down: 'y', up: 'y', north: 'z', south: 'z', west: 'x', east: 'x' }\nconst FACE_OPPOSITE = { down: 'up', up: 'down', north: 'south', south: 'north', west: 'east', east: 'west' }\nconst HORIZONTAL_FACES = { north: true, south: true, west: true, east: true }\n\nconst VALUE_LABEL = {\n down: '下', up: '上', north: '北', south: '南', west: '西', east: '东',\n x: 'X 轴', y: 'Y 轴', z: 'Z 轴', none: '无',\n true: '是', false: '否',\n top: '上半', bottom: '下半',\n straight: '直', inner_left: '内左', inner_right: '内右',\n outer_left: '外左', outer_right: '外右',\n save: '保存', load: '读取', corner: '角', data: '数据',\n}\nconst AXIS_LABEL = {\n facing: '朝向', axis: '朝向(轴)', half: '上下', rotation: '旋转',\n conditional: '有条件', shape: '形状', mode: '模式',\n powered: '通电', locked: '锁定', delay: '延迟', open: '开合',\n}\n\nfunction labelOfValue(value) { return VALUE_LABEL[value] === undefined ? String(value) : VALUE_LABEL[value] }\nfunction labelOfAxis(name) { return AXIS_LABEL[name] === undefined ? String(name) : AXIS_LABEL[name] }\n\nfunction withKey(table, name, value) {\n const next = Object.assign({}, table || {})\n next[name] = value\n return next\n}\nfunction withoutKey(table, name) {\n const next = Object.assign({}, table || {})\n delete next[name]\n return next\n}\n\n\nfunction canonicalVariant(parts) {\n return Object.keys(parts).sort().map((name) => name + '=' + parts[name]).join(',')\n}\n\n\nfunction deriveVariant(axes, face, hitY, flip) {\n const out = {}\n const horizontal = HORIZONTAL_FACES[face] === true\n for (const axis of axes) {\n const name = axis.name\n if (name === 'axis') {\n if (FACE_AXIS[face] !== undefined) out[name] = FACE_AXIS[face]\n continue\n }\n if (name === 'facing') {\n const value = flip && horizontal ? FACE_OPPOSITE[face] : face\n if (value !== undefined && axis.values.indexOf(value) >= 0) out[name] = value\n continue\n }\n if (name === 'half') {\n const value = face === 'down' ? 'top' : (face === 'up' ? 'bottom' : (hitY > 0.5 ? 'top' : 'bottom'))\n if (axis.values.indexOf(value) >= 0) out[name] = value\n continue\n }\n }\n return out\n}\n\n\nfunction variantFor(axes, keys, defaults, face, hitY, flip, overrides) {\n if (!Array.isArray(axes) || axes.length === 0) return null\n const derived = deriveVariant(axes, face, hitY, flip)\n const fallback = defaults || {}\n const parts = {}\n for (const axis of axes) {\n const hand = (overrides || {})[axis.name]\n const value = hand !== undefined ? hand : (derived[axis.name] !== undefined ? derived[axis.name] : fallback[axis.name])\n if (value !== undefined) parts[axis.name] = value\n }\n if (Object.keys(parts).length !== axes.length) return null\n const key = canonicalVariant(parts)\n return (keys || []).indexOf(key) >= 0 ? key : null\n}\n\n\nfunction aroundOf(cells, at) {\n const out = []\n if (!Array.isArray(cells) || !Array.isArray(at)) return out\n for (const cell of cells) {\n if (cell === null || typeof cell !== 'object' || !Array.isArray(cell.at)) continue\n if (typeof cell.block !== 'string' || cell.block === '') continue\n const d = [cell.at[0] - at[0], cell.at[1] - at[1], cell.at[2] - at[2]]\n if (d[0] === 0 && d[1] === 0 && d[2] === 0) continue\n if (Math.abs(d[0]) > 1 || Math.abs(d[1]) > 1 || Math.abs(d[2]) > 1) continue\n const item = { d: d, block: cell.block }\n if (typeof cell.variant === 'string' && cell.variant !== '') item.variant = cell.variant\n out.push(item)\n }\n return out\n}\n\n\nconst GUI_FACE_SHADE = { up: 1.0, east: 0.637, north: 0.435,\n down: 0.4, south: 0.4, west: 0.4 }\nconst GUI_FACE_SHADE_FLAT = { up: 1.0, east: 0.417, north: 0.624,\n down: 0.4, south: 0.841, west: 0.543 }\n\n\nfunction guiItemRotation(quads, rotation, table) {\n const yaw = rotation === undefined || rotation === null ? 0 : (rotation[1] || 0)\n const pitch = rotation === undefined || rotation === null ? 0 : (rotation[0] || 0)\n const shade = table === undefined || table === null ? GUI_FACE_SHADE : table\n return quads.map((quad) => {\n let p = quad.p\n if (yaw) p = p.map((point) => rotateAbout(point, [0.5, 0.5, 0.5], 'y', yaw))\n if (pitch) p = p.map((point) => rotateAbout(point, [0.5, 0.5, 0.5], 'x', pitch))\n const lit = quad.shaded === false ? (quad.shade === undefined ? 1.0 : quad.shade)\n : (shade[quad.face] === undefined ? 1.0 : shade[quad.face])\n return { p: p, uv: quad.uv, tex: quad.tex, shade: lit, mode: quad.mode,\n face: quad.face, shaded: quad.shaded }\n })\n}\n\n\nfunction iconCamera(quads, width, height, fill) {\n const box = boxOfQuads(quads)\n const spanX = Math.max(box.max[0] - box.min[0], 1e-6)\n const spanY = Math.max(box.max[1] - box.min[1], 1e-6)\n const ppu = Math.min(width, height) * (fill === undefined ? 0.98 : fill) / Math.max(spanX, spanY)\n const centre = [(box.min[0] + box.max[0]) / 2, (box.min[1] + box.max[1]) / 2]\n const mid = (box.min[2] + box.max[2]) / 2\n return {\n project: function (point) {\n return [width / 2 + (point[0] - centre[0]) * ppu,\n height / 2 - (point[1] - centre[1]) * ppu,\n mid + 1 - point[2] + 0.5]\n },\n }\n}\n\n\nfunction drawItemIcon(canvas, recipe, frameIndex, decoded) {\n const width = canvas.width, height = canvas.height\n const paint = canvas.getContext('2d')\n paint.clearRect(0, 0, width, height)\n if (recipe === null || recipe === undefined) return\n if (recipe.shape === 'iso') {\n const hasDisplay = recipe.display !== null && recipe.display !== undefined\n const rotation = hasDisplay ? (recipe.display.rotation || [0, 0, 0]) : [0, 0, 0]\n const quads = guiItemRotation(recipe.quads || [], rotation,\n hasDisplay ? GUI_FACE_SHADE : GUI_FACE_SHADE_FLAT)\n if (quads.length === 0) return\n const bytes = renderScene({ width: width, height: height,\n background: [0, 0, 0], backgroundAlpha: 0, orthographic: true,\n camera: iconCamera(quads, width, height, 0.98), quads: quads,\n textures: decoded, animations: recipe.animations || {},\n timeMs: animTicks * 50 })\n const frame = paint.createImageData(width, height)\n frame.data.set(bytes)\n paint.putImageData(frame, 0, 0)\n return\n }\n const frames = recipe.frames && recipe.frames.length > 0 ? recipe.frames : null\n const layers = frames === null ? (recipe.layers || [])\n : (frames[((frameIndex || 0) % frames.length + frames.length) % frames.length].layers || [])\n const out = new Uint8ClampedArray(width * height * 4)\n for (const id of layers) {\n const texture = decoded === undefined ? undefined : decoded[id]\n if (texture === undefined || texture.data === undefined) continue\n const sw = texture.width\n const sh = texture.height\n if (!(sw > 0 && sh > 0)) continue\n const animation = (recipe.animations || {})[id]\n const strip = stripOf({ width: sw, height: sh, animation: animation })\n const top = strip === null ? 0 : animationRow(animation, animTicks * 50) * sw\n const rows = strip === null ? sh : sw\n for (let y = 0; y < height; y++) {\n const sy = top + Math.floor((y * rows) / height)\n if (sy < 0 || sy >= sh) continue\n for (let x = 0; x < width; x++) {\n const sx = Math.floor((x * sw) / width)\n const from = (sy * sw + sx) * 4\n const alpha = texture.data[from + 3] / 255\n if (alpha <= 0) continue\n const to = (y * width + x) * 4\n out[to] = texture.data[from] * alpha + out[to] * (1 - alpha)\n out[to + 1] = texture.data[from + 1] * alpha + out[to + 1] * (1 - alpha)\n out[to + 2] = texture.data[from + 2] * alpha + out[to + 2] * (1 - alpha)\n out[to + 3] = Math.max(out[to + 3], texture.data[from + 3])\n }\n }\n }\n const frame = paint.createImageData(width, height)\n frame.data.set(out)\n paint.putImageData(frame, 0, 0)\n}\n\n\nfunction clearViewport(canvas, width, height) {\n canvas.width = width\n canvas.height = height\n const paint = canvas.getContext('2d')\n if (paint !== null && paint !== undefined) paint.clearRect(0, 0, width, height)\n}\n\n\nfunction itemIconUrl(recipe, decoded, size) {\n if (recipe === null || recipe === undefined) return null\n const canvas = document.createElement('canvas')\n canvas.width = size\n canvas.height = size\n drawItemIcon(canvas, recipe, 0, decoded)\n return canvas.toDataURL('image/png')\n}\n\n\nfunction faceHitY(quad, pixelX, pixelY, camera) {\n if (quad === undefined || camera === undefined || camera === null) return 0.5\n const screen = []\n for (const point of quad.p) {\n const projected = camera.project(point)\n if (projected === null) return 0.5\n screen.push(projected)\n }\n for (const triangle of [[0, 1, 2], [0, 2, 3]]) {\n const a = screen[triangle[0]], b = screen[triangle[1]], c = screen[triangle[2]]\n const area = (b[0] - a[0]) * (c[1] - a[1]) - (c[0] - a[0]) * (b[1] - a[1])\n if (Math.abs(area) < 1e-9) continue\n const w0 = ((b[0] - pixelX) * (c[1] - pixelY) - (c[0] - pixelX) * (b[1] - pixelY)) / area\n const w1 = ((c[0] - pixelX) * (a[1] - pixelY) - (a[0] - pixelX) * (c[1] - pixelY)) / area\n const w2 = 1 - w0 - w1\n if (w0 < -0.02 || w1 < -0.02 || w2 < -0.02) continue\n const weights = [w0, w1, w2]\n let y = 0\n for (let i = 0; i < 3; i++) y += quad.p[triangle[i]][1] * weights[i]\n const ys = quad.p.map((point) => point[1])\n const min = Math.min(ys[0], ys[1], ys[2], ys[3])\n const max = Math.max(ys[0], ys[1], ys[2], ys[3])\n return max - min < 1e-6 ? 0.5 : (y - min) / (max - min)\n }\n return 0.5\n}\n\n\nfunction variantSummary(axes, props) {\n const parts = []\n for (const axis of axes) {\n const value = props[axis.name]\n if (value === undefined) continue\n parts.push(labelOfAxis(axis.name) + ' ' + labelOfValue(value))\n }\n return parts.length === 0 ? '按方块默认' : parts.join(' · ')\n}\n\nconst CSS = [\n '.mcart-root{display:flex;flex-direction:column;gap:8px;padding:10px;height:100%;box-sizing:border-box;overflow-y:auto;overflow-x:hidden;font-size:12px}',\n '.mcart-bar{display:flex;align-items:center;gap:6px;flex-wrap:wrap}',\n '.mcart-title{font-size:13px;font-weight:600}',\n '.mcart-sub{opacity:.6;font-size:11px}',\n '.mcart-path{opacity:.75;font-size:11px;word-break:break-all;flex:1;min-width:0}',\n '.mcart-list{overflow:auto;border:1px solid rgba(127,127,127,.25);border-radius:8px;padding:4px;max-height:220px}',\n '.mcart-group{font-size:10px;letter-spacing:.05em;text-transform:uppercase;opacity:.55;padding:6px 6px 2px}',\n '.mcart-item{display:block;width:100%;text-align:left;background:none;border:0;border-radius:6px;color:inherit;font:inherit;font-size:12px;padding:4px 6px;cursor:pointer}',\n '.mcart-item:hover{background:rgba(127,127,127,.16)}',\n '.mcart-item[data-on=\"1\"]{background:rgba(127,127,127,.28);font-weight:600}',\n '.mcart-id{font-size:10px;opacity:.45;margin-left:4px}',\n '.mcart-stage{flex:none;display:flex;flex-direction:column;gap:6px}',\n '.mcart-canvas{width:100%;height:240px;display:block;border-radius:8px;background:#1a181c;border:1px solid rgba(127,127,127,.25);cursor:grab;touch-action:none}',\n '.mcart-canvas:active{cursor:grabbing}',\n '.mcart-btn{background:rgba(127,127,127,.14);border:1px solid rgba(127,127,127,.3);border-radius:6px;color:inherit;font:inherit;font-size:11px;padding:3px 8px;cursor:pointer;flex:none}',\n '.mcart-btn:hover{background:rgba(127,127,127,.26)}',\n '.mcart-btn[disabled]{opacity:.35;cursor:default}',\n '.mcart-icon{background:none;border:0;color:inherit;font:inherit;font-size:13px;line-height:1;padding:2px 6px;border-radius:6px;cursor:pointer}',\n '.mcart-icon:hover{background:rgba(127,127,127,.2)}',\n '.mcart-input{flex:1;min-width:0;background:rgba(0,0,0,.18);border:1px solid rgba(127,127,127,.3);border-radius:6px;color:inherit;font:inherit;font-size:11px;padding:4px 6px}',\n '.mcart-card{border:1px solid rgba(127,127,127,.22);border-radius:10px;padding:10px;display:flex;flex-direction:column;gap:8px;background:rgba(127,127,127,.04)}',\n '.mcart-row{display:flex;align-items:center;gap:6px;border-radius:6px;padding:2px 4px}',\n '.mcart-row:hover{background:rgba(127,127,127,.14)}',\n '.mcart-grow{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',\n '.mcart-projects{max-height:190px;overflow:auto;display:flex;flex-direction:column;gap:1px;border:1px solid rgba(127,127,127,.18);border-radius:8px;padding:3px}',\n '.mcart-switch{display:flex;align-items:flex-start;gap:6px;cursor:pointer;padding:2px 0}',\n '.mcart-switch input{margin:1px 0 0 0;flex:none}',\n '.mcart-hint{font-size:10px;opacity:.5;margin-left:19px}',\n '.mcart-mods{border:1px solid rgba(127,127,127,.18);border-radius:8px;padding:3px 6px;display:flex;flex-direction:column;gap:1px;max-height:160px;overflow:auto}',\n '.mcart-chip{font-size:10px;padding:1px 6px;border-radius:999px;background:rgba(127,127,127,.16)}',\n '.mcart-note{opacity:.66;font-size:11px}',\n '.mcart-bad{font-size:11px;color:#e0a06c;word-break:break-all}',\n '.mcart-err{font-size:11px;color:#e06c6c;word-break:break-all}',\n '.mcart-edit{display:flex;flex-direction:column;gap:6px}',\n '.mcart-edit-canvas{display:block;width:100%;height:auto;max-height:52vh;object-fit:contain;image-rendering:pixelated;border-radius:4px;background:#111;cursor:crosshair;touch-action:none}',\n '.mcart-tools{display:flex;gap:4px;flex-wrap:wrap;align-items:center}',\n '.mcart-tool[data-on=\"1\"]{background:rgba(127,127,127,.42);font-weight:600}',\n '.mcart-swatches{display:flex;gap:3px;flex-wrap:wrap;max-height:56px;overflow:auto}',\n '.mcart-swatch{width:16px;height:16px;border-radius:3px;border:1px solid rgba(127,127,127,.5);cursor:pointer;padding:0;flex:none}',\n '.mcart-color{width:22px;height:22px;border-radius:4px;border:1px solid rgba(127,127,127,.5);flex:none}',\n '.mcart-tabs{display:flex;gap:4px;flex-wrap:wrap;align-items:center}',\n '.mcart-picker{width:36px;height:24px;padding:0;border:1px solid rgba(127,127,127,.5);border-radius:4px;background:none;flex:none}',\n '.mcart-alpha{flex:1;min-width:60px}',\n '.mcart-select{flex:1;min-width:0;background:rgba(0,0,0,.18);border:1px solid rgba(127,127,127,.3);border-radius:6px;color:inherit;font:inherit;font-size:11px;padding:3px 4px}',\n '.mcart-hotbar{display:flex;gap:3px;flex-wrap:wrap;max-height:124px;overflow:auto}',\n '.mcart-slot{width:26px;height:26px;border:1px solid rgba(127,127,127,.4);border-radius:4px;background:none;background-size:cover;image-rendering:pixelated;padding:0;flex:none;cursor:pointer}',\n '.mcart-slot:hover{background-color:rgba(127,127,127,.22)}',\n '.mcart-slot[data-on=\"1\"]{outline:2px solid currentColor;outline-offset:1px}',\n '.mcart-slotq{font-size:13px;line-height:1;opacity:.6}',\n '.mcart-icon[data-on=\"1\"]{background:rgba(127,127,127,.3)}',\n '.mcart-viewport{position:relative;flex:none}',\n '.mcart-zoom{position:absolute;right:6px;top:6px;display:flex;gap:3px;opacity:.72}',\n '.mcart-zoom:hover{opacity:1}',\n '.mcart-zoombtn{width:22px;height:22px;padding:0;display:flex;align-items:center;justify-content:center;font-size:12px;line-height:1;background:rgba(20,20,24,.62);border-color:rgba(200,200,210,.45)}',\n '.mcart-hud{position:absolute;left:0;right:0;bottom:0;display:flex;flex-direction:column;align-items:center;gap:4px;padding:0 6px 6px;pointer-events:none}',\n '.mcart-hudname{font-size:11px;padding:1px 8px;border-radius:999px;background:rgba(20,20,24,.72);border:1px solid rgba(200,200,210,.28);text-shadow:0 1px 2px rgba(0,0,0,.85)}',\n '.mcart-hudbar{display:flex;align-items:center;gap:3px;padding:3px;border-radius:8px;background:rgba(20,20,24,.64);border:1px solid rgba(200,200,210,.32);pointer-events:auto}',\n '.mcart-hudslot{width:34px;height:34px;padding:0;flex:none;display:flex;align-items:center;justify-content:center;border-radius:4px;border:1px solid rgba(120,120,130,.45);background:rgba(0,0,0,.32);cursor:pointer}',\n '.mcart-hudslot:hover{border-color:rgba(240,240,250,.8);background:rgba(255,255,255,.14)}',\n '.mcart-hudslot[data-on=\"1\"]{border-color:#f2f2f6;box-shadow:inset 0 0 0 1px #f2f2f6,0 0 6px rgba(0,0,0,.6)}',\n '.mcart-hudicon{width:30px;height:30px;display:block;image-rendering:pixelated}',\n '.mcart-hudempty{background:rgba(0,0,0,.22);border-color:rgba(120,120,130,.26);cursor:default}',\n '.mcart-hudpage{width:16px;height:34px;padding:0;flex:none;font:inherit;font-size:12px;line-height:1;color:inherit;background:none;border:0;cursor:pointer;opacity:.7}',\n '.mcart-hudpage:hover{opacity:1}',\n '.mcart-hudpage[disabled]{opacity:.22;cursor:default}',\n '.mcart-poster{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);image-rendering:pixelated;pointer-events:none}',\n '.mcart-grip{display:flex;align-items:center;justify-content:center;height:13px;cursor:ns-resize;touch-action:none}',\n '.mcart-gripbar{width:46px;height:4px;border-radius:999px;background:rgba(127,127,127,.42)}',\n '.mcart-grip:hover .mcart-gripbar{background:rgba(210,210,220,.8)}',\n '.mcart-grip:active .mcart-gripbar{background:rgba(235,235,245,.95)}',\n '.mcart-src{display:flex;align-items:center;gap:6px}',\n '.mcart-ref{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:0 0 6px;padding:6px 10px;border-radius:8px;background:rgba(127,127,127,.14);border:1px solid rgba(127,127,127,.3);font-size:12px}',\n '.mcart-ref-path{opacity:.66;word-break:break-all}',\n].join('')\n\nconst CATEGORIES = [\n { key: 'biome', label: '群系' },\n { key: 'structure', label: '结构' },\n { key: 'entity', label: '实体' },\n { key: 'block', label: '方块' },\n]\n\nconst TAB_ID = 'mc-art.atlas'\n\nconst decoded = {}\nconst failedTex = {}\nconst indexes = {}\nconst imageNodes = {}\nconst drag = { active: false, x: 0, y: 0, moved: 0 }\nlet pickBuffer = null\nlet ghostAsk = ''\nconst icons = {}\nconst iconTried = {}\nconst itemRecipes = {}\nconst itemUrls = {}\nconst hudCanvases = {}\nconst TINT_REMOVE = [0.85, 0.2, 0.18]\nconst painting = { active: false }\n\nconst EDIT_ZOOM = 18\nconst EDIT_UNDO = 24\nconst EDIT_PALETTE = 64\n\nlet hudFollowed = ''\nconst VIEW_H = 240\nconst VIEW_H_MIN = 140\nconst VIEW_H_MAX = 760\nconst gripDrag = { active: false, y: 0, origin: 0 }\nconst clampViewH = (value) => Math.max(VIEW_H_MIN, Math.min(VIEW_H_MAX, Math.round(value)))\n\nfunction texturePoint(rect, target, clientX, clientY) {\n if (rect === undefined || rect === null) return null\n if (!(rect.width > 0) || !(rect.height > 0)) return null\n if (!(target.w > 0) || !(target.h > 0)) return null\n const scale = Math.min(rect.width / target.w, rect.height / target.h)\n if (!(scale > 0)) return null\n const left = rect.left + (rect.width - target.w * scale) / 2\n const top = rect.top + (rect.height - target.h * scale) / 2\n return [Math.floor((clientX - left) / scale), Math.floor((clientY - top) / scale)]\n}\n\nconst PAGE_SIZE = 20\nconst ICON_BATCH = 80\nconst ITEM_PAGE = 40\nconst HUD_SLOTS = 9\n\n\nfunction failureOf(reply) {\n if (reply === null || reply === undefined) return '没有返回结果'\n if (reply.error === undefined || reply.error === null) return null\n return String(reply.error)\n}\nfunction countsOf(list, keyOf, labelOf) {\n const out = {}\n for (const entry of list || []) {\n const key = keyOf(entry) || ''\n if (key === '') continue\n if (out[key] === undefined) out[key] = { count: 0, label: labelOf(entry) || key }\n out[key].count += 1\n }\n return out\n}\n\nfunction cssColour(colour) {\n if (colour === null || colour === undefined) return 'transparent'\n return 'rgba(' + colour[0] + ',' + colour[1] + ',' + colour[2] + ',' + (colour[3] / 255) + ')'\n}\n\nfunction hexOf(colour) {\n const c = colour === null || colour === undefined ? [0, 0, 0, 255] : colour\n const two = (v) => ('0' + Math.max(0, Math.min(255, Math.round(v))).toString(16)).slice(-2)\n return '#' + two(c[0]) + two(c[1]) + two(c[2])\n}\n\nfunction fromHex(text, alpha) {\n const raw = String(text === undefined || text === null ? '' : text).replace('#', '')\n const a = alpha === undefined ? 255 : alpha\n if (raw.length !== 6) return [0, 0, 0, a]\n return [parseInt(raw.slice(0, 2), 16), parseInt(raw.slice(2, 4), 16), parseInt(raw.slice(4, 6), 16), a]\n}\n\n\nfunction faceLabel(faces) {\n const list = faces === undefined || faces === null ? [] : faces\n if (list.length === 0) return '贴图'\n const top = list.indexOf('up') >= 0\n const bottom = list.indexOf('down') >= 0\n const side = list.some((face) => face === 'north' || face === 'south' || face === 'west' || face === 'east')\n const parts = []\n if (top) parts.push('顶面')\n if (side) parts.push('侧面')\n if (bottom) parts.push('底面')\n return parts.length === 0 ? '贴图' : parts.join('+')\n}\n\nfunction sameColour(a, b) {\n return a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3]\n}\n\n\nfunction addToPalette(palette, colour, limit) {\n if (colour === null || colour === undefined) return palette\n if (palette.some((c) => sameColour(c, colour))) return palette\n const next = palette.concat([colour])\n return next.length > limit ? next.slice(next.length - limit) : next\n}\n\nfunction readPixel(pixels, width, x, y) {\n const at = (y * width + x) * 4\n return [pixels[at], pixels[at + 1], pixels[at + 2], pixels[at + 3]]\n}\n\nfunction writePixel(pixels, width, x, y, colour) {\n const at = (y * width + x) * 4\n pixels[at] = colour[0]\n pixels[at + 1] = colour[1]\n pixels[at + 2] = colour[2]\n pixels[at + 3] = colour[3]\n}\n\n\nfunction paletteOf(pixels, limit) {\n const seen = []\n const known = {}\n for (let i = 0; i < pixels.length; i += 4) {\n if (pixels[i + 3] === 0) continue\n const key = pixels[i] + ',' + pixels[i + 1] + ',' + pixels[i + 2] + ',' + pixels[i + 3]\n if (known[key] === true) continue\n known[key] = true\n seen.push([pixels[i], pixels[i + 1], pixels[i + 2], pixels[i + 3]])\n if (seen.length >= limit) break\n }\n return seen\n}\n\nlet canvasGeneration = 0\nlet lastDrawKey = ''\nlet lastCamera = null\nlet animTicks = 0\n\nconst roots = {}\nconst listeners = []\nlet pending = null\nfunction setPending(next) {\n pending = next\n const copy = listeners.slice()\n for (const listener of copy) listener()\n}\nfunction usePending() {\n const pair = React.useState(pending)\n React.useEffect(() => {\n const listener = () => pair[1](pending)\n listeners.push(listener)\n return () => {\n const at = listeners.indexOf(listener)\n if (at >= 0) listeners.splice(at, 1)\n }\n }, [])\n return pair[0]\n}\n\nreturn {\n inject: ['timer'],\n apply(ctx) {\n const slots = ctx.get('slots')\n if (slots === undefined) return\n const timer = ctx.timer\n ctx.effect(() => styles.insert(CSS))\n\n const tabs = ctx.get('sidebarRightTabs')\n const sidebarRight = ctx.get('sidebarRight')\n function slotHasEntries(name) {\n try {\n if (typeof slots.entries !== 'function') return false\n const list = slots.entries(name)\n return Array.isArray(list) && list.length > 0\n } catch (error) {\n return false\n }\n }\n const inRightColumn = tabs !== undefined &&\n (sidebarRight !== undefined || slotHasEntries('sidebar.right.pane.tab'))\n\n function Atlas(props) {\n const sessionId = props.sessionId === undefined ? '' : String(props.sessionId)\n const indexPair = React.useState(null)\n const failurePair = React.useState(null)\n const noticePair = React.useState(null)\n const nearbyPair = React.useState(null)\n const choicePair = React.useState(null)\n const scenePair = React.useState(null)\n const tickPair = React.useState(0)\n const yawPair = React.useState(0.72)\n const pitchPair = React.useState(0.34)\n const zoomPair = React.useState(1)\n const canvasPair = React.useState(null)\n const scratchPair = React.useState(null)\n const sizePair = React.useState([340, 260])\n const viewHPair = React.useState(VIEW_H)\n const posterPair = React.useState(null)\n const itemMsgPair = React.useState(null)\n const savedSettingsPair = React.useState(null)\n const busyPair = React.useState(false)\n const pickingPair = React.useState(false)\n const draftPair = React.useState('')\n const settingsPair = React.useState(null)\n const savePair = React.useState(null)\n const modsOpenPair = React.useState(false)\n const rootPair = React.useState(roots[sessionId] === undefined ? '' : roots[sessionId])\n const index = indexPair[0], setIndex = indexPair[1]\n const failure = failurePair[0], setFailure = failurePair[1]\n const notice = noticePair[0], setNotice = noticePair[1]\n const nearby = nearbyPair[0], setNearby = nearbyPair[1]\n const choice = choicePair[0], setChoice = choicePair[1]\n const scene = scenePair[0], setScene = scenePair[1]\n const tick = tickPair[0], setTick = tickPair[1]\n const yaw = yawPair[0], setYaw = yawPair[1]\n const pitch = pitchPair[0], setPitch = pitchPair[1]\n const zoom = zoomPair[0], setZoom = zoomPair[1]\n const canvas = canvasPair[0], setCanvas = canvasPair[1]\n const scratch = scratchPair[0], setScratch = scratchPair[1]\n const size = sizePair[0], setSize = sizePair[1]\n const viewH = viewHPair[0], setViewH = viewHPair[1]\n const posterCanvas = posterPair[0], setPosterCanvas = posterPair[1]\n const itemMsg = itemMsgPair[0], setItemMsg = itemMsgPair[1]\n const savedSettings = savedSettingsPair[0], setSavedSettings = savedSettingsPair[1]\n const busy = busyPair[0], setBusy = busyPair[1]\n const picking = pickingPair[0], setPicking = pickingPair[1]\n const draft = draftPair[0], setDraft = draftPair[1]\n const settings = settingsPair[0], setSettings = settingsPair[1]\n const saveState = savePair[0], setSaveState = savePair[1]\n const modsOpen = modsOpenPair[0], setModsOpen = modsOpenPair[1]\n const root = rootPair[0], setRoot = rootPair[1]\n const editPair = React.useState(null)\n const undoPair = React.useState([])\n const editTickPair = React.useState(0)\n const editCanvasPair = React.useState(null)\n const editScratchPair = React.useState(null)\n const editBusyPair = React.useState(false)\n const editMsgPair = React.useState(null)\n const voxelPair = React.useState(null)\n const hoverPair = React.useState(null)\n const ghostPair = React.useState(null)\n const iconTickPair = React.useState(0)\n const voxel = voxelPair[0], setVoxel = voxelPair[1]\n const hover = hoverPair[0], setHover = hoverPair[1]\n const ghost = ghostPair[0], setGhost = ghostPair[1]\n const iconTick = iconTickPair[0], setIconTick = iconTickPair[1]\n const edit = editPair[0], setEdit = editPair[1]\n const undo = undoPair[0], setUndo = undoPair[1]\n const editTick = editTickPair[0], setEditTick = editTickPair[1]\n const editCanvas = editCanvasPair[0], setEditCanvas = editCanvasPair[1]\n const editScratch = editScratchPair[0], setEditScratch = editScratchPair[1]\n const editBusy = editBusyPair[0], setEditBusy = editBusyPair[1]\n const editMsg = editMsgPair[0], setEditMsg = editMsgPair[1]\n const refSourcesPair = React.useState([])\n const refSources = refSourcesPair[0], setRefSources = refSourcesPair[1]\n const itemPair = React.useState(null)\n const item = itemPair[0], setItem = itemPair[1]\n const iconPair = React.useState(null)\n const iconPick = iconPair[0], setIconPick = iconPair[1]\n const itemOpenPair = React.useState(false)\n const itemOpen = itemOpenPair[0], setItemOpen = itemOpenPair[1]\n const hudPagePair = React.useState(1)\n const hudPage = hudPagePair[0], setHudPage = hudPagePair[1]\n const previewPair = React.useState(null)\n const previewItem = previewPair[0], setPreviewItem = previewPair[1]\n const filterPair = React.useState('')\n const filter = filterPair[0], setFilter = filterPair[1]\n const pickGroupPair = React.useState('')\n const pickGroup = pickGroupPair[0], setPickGroup = pickGroupPair[1]\n const pickFamilyPair = React.useState('')\n const pickFamily = pickFamilyPair[0], setPickFamily = pickFamilyPair[1]\n const pickPagePair = React.useState(1)\n const pickPage = pickPagePair[0], setPickPage = pickPagePair[1]\n\n function activeProjectId() {\n if (choice !== null && choice.project !== undefined && choice.project !== null) return choice.project\n const projects = index === null ? [] : (index.projects || [])\n return projects.length > 0 ? projects[0].id : null\n }\n\n function open(target, where) {\n setBusy(true)\n setChoice(target)\n setPreviewItem(null)\n painting.active = false\n setEdit(null)\n setUndo([])\n setEditMsg(null)\n setVoxel(null)\n setHover(null)\n setGhost(null)\n const want = { root: where, project: target.project, kind: target.kind, id: target.id, have: Object.keys(decoded) }\n host.call('atlas.scene', want).then((result) => {\n setBusy(false)\n if (result === null || result === undefined) { setFailure('scene returned nothing'); return }\n if (result.error !== undefined) { setFailure(result.error); setScene(null); return }\n setFailure(null)\n for (const key of Object.keys(imageNodes)) delete imageNodes[key]\n setScene(result)\n }).catch((error) => { setBusy(false); setFailure(String(error && error.message ? error.message : error)) })\n }\n\n function firstOfProject(project) {\n if (project === undefined || project === null) return null\n for (const category of CATEGORIES) {\n const items = (project.items && project.items[category.key]) || []\n if (items.length > 0) return { project: project.id, kind: category.key, id: items[0].id }\n }\n return null\n }\n\n function firstPick(result) {\n const projects = result.projects || []\n for (const category of CATEGORIES) {\n for (const project of projects) {\n const items = (project.items && project.items[category.key]) || []\n if (items.length > 0) return { project: project.id, kind: category.key, id: items[0].id }\n }\n }\n return null\n }\n\n function scan(where, force, wantProject) {\n if (where === undefined || where === '') { setIndex(null); return }\n if (force !== true && indexes[where] !== undefined) {\n const cached = indexes[where]\n setIndex(cached)\n setFailure(null)\n const project = wantProject === undefined || wantProject === null ? null : (cached.projects || []).filter((p) => p.id === wantProject)[0]\n const first = project !== null && project !== undefined ? firstOfProject(project) : firstPick(cached)\n if (first !== null) open(first, where)\n return\n }\n setBusy(true)\n setFailure(null)\n host.call('atlas.scan', { root: where }).then((result) => {\n setBusy(false)\n if (result === null || result === undefined) { setFailure('scan returned nothing'); return }\n setIndex(result)\n if (result.error !== undefined) { setFailure(result.error); return }\n const projects = result.projects || []\n if (projects.length === 0) { setFailure('这个目录里没有找到资源包或 mc-art.atlas.json'); setScene(null); return }\n setFailure(null)\n indexes[where] = result\n const project = wantProject === undefined || wantProject === null ? null : projects.filter((p) => p.id === wantProject)[0]\n const first = project !== null && project !== undefined ? firstOfProject(project) : firstPick(result)\n if (first !== null) open(first, where)\n }).catch((error) => { setBusy(false); setFailure(String(error && error.message ? error.message : error)) })\n }\n\n function commitRoot(next) {\n roots[sessionId] = next\n setRoot(next)\n setDraft('')\n setNotice(null)\n setSettings(null)\n scan(next, false, null)\n }\n\n function openNearby(rootPath, projectId) {\n roots[sessionId] = rootPath\n setRoot(rootPath)\n setNotice(null)\n scan(rootPath, false, projectId)\n }\n\n function runPicker(start, onPicked) {\n if (picking === true) return\n setPicking(true)\n setNotice('正在打开系统的目录选择器…')\n host.call('atlas.pickDirectory', { start: start })\n .then((result) => {\n setPicking(false)\n if (result === null || result === undefined) { setNotice('目录选择器没有返回结果。'); return }\n if (result.error !== undefined) { setNotice(String(result.error)); return }\n if (result.supported === false) {\n setNotice(String(result.detail || '这个环境没有可用的目录选择器,') + '请在下面的输入框里直接填路径。')\n return\n }\n if (result.cancelled === true) { setNotice(null); return }\n if (typeof result.path === 'string' && result.path.length > 0) { setNotice(null); onPicked(result.path); return }\n setNotice('目录选择器没有返回路径。')\n })\n .catch((error) => { setPicking(false); setNotice('目录选择器失败:' + String(error && error.message ? error.message : error)) })\n }\n\n function pickDirectory() {\n runPicker(root !== '' ? root : (nearby !== null ? nearby.cwd : ''), commitRoot)\n }\n\n function loadSettings() {\n const projectId = activeProjectId()\n if (projectId === null) return\n setSaveState(null)\n setModsOpen(false)\n setSettings({ loading: true })\n host.call('atlas.settings', { root: root, project: projectId }).then((result) => {\n if (result === null || result === undefined || result.error !== undefined) {\n setSettings({ error: result !== null && result !== undefined && result.error !== undefined ? result.error : '设置读取失败' })\n return\n }\n setSettings(Object.assign({ loading: false }, result))\n }).catch((error) => setSettings({ error: String(error && error.message ? error.message : error) }))\n }\n\n function openSettings() {\n if (settings !== null) { setSettings(null); return }\n loadSettings()\n }\n\n function saveSettings(next, rescan) {\n const modMap = {}\n for (const item of next.mods || []) modMap[item.name] = item.on === true\n setSaveState('保存中…')\n host.call('atlas.saveSettings', {\n root: root, project: next.project, directory: next.directory,\n includeGenerated: next.includeGenerated, includeMods: next.includeMods, mods: modMap,\n }).then((result) => {\n const failed = result !== null && result !== undefined && result.error !== undefined\n setSaveState(failed ? ('保存失败:' + result.error) : '已保存')\n if (failed) { setSavedSettings(null); return }\n const on = (next.mods || []).filter((item) => item.on === true).length\n setSavedSettings({\n path: result.path === undefined ? '' : String(result.path),\n note: '参考目录改成了 ' + (next.directory === '' ? '(空)' : next.directory)\n + ';' + on + '/' + (next.mods || []).length + ' 个 mod 开着,'\n + (next.includeGenerated === false ? '不含' : '含') + '本项目自己的贴图。请按这个重做',\n })\n if (rescan === true) loadSettings()\n }).catch((error) => {\n setSavedSettings(null)\n setSaveState('保存失败:' + String(error && error.message ? error.message : error))\n })\n }\n\n function patchSettings(patch, rescan) {\n if (settings === null) return\n const next = Object.assign({}, settings, patch)\n setSettings(next)\n saveSettings(next, rescan)\n }\n\n function toggleMod(name, on) {\n if (settings === null) return\n const mods = (settings.mods || []).map((item) => (item.name === name ? Object.assign({}, item, { on: on }) : item))\n const next = Object.assign({}, settings, { mods: mods })\n setSettings(next)\n saveSettings(next)\n }\n\n React.useEffect(() => {\n if (root !== '') { scan(root, false, null); return }\n host.call('atlas.session', { sessionId: sessionId })\n .then((result) => {\n const cwd = result !== null && result !== undefined && typeof result.cwd === 'string' && result.cwd.length > 0 ? result.cwd : null\n if (cwd === null) return null\n setNearby({ cwd: cwd, loading: true, projects: [], error: null })\n return host.call('atlas.projects', { path: cwd })\n })\n .then((result) => {\n if (result === null || result === undefined) return\n if (result.error !== undefined) { setNearby({ cwd: result.base || '', loading: false, projects: [], error: result.error }); return }\n setNearby({ cwd: result.base, loading: false, projects: result.projects || [], error: null })\n })\n .catch((error) => setNearby({ cwd: '', loading: false, projects: [], error: String(error && error.message ? error.message : error) }))\n }, [])\n\n React.useEffect(() => {\n if (canvas === null) return\n const width = canvas.clientWidth || 340\n const height = canvas.clientHeight || 240\n if (width !== size[0] || height !== size[1]) setSize([width, height])\n })\n\n React.useEffect(() => {\n if (posterCanvas === null || posterRecipe === null) return\n const frames = (posterRecipe.frames || []).length\n const frame = frames > 1 ? Math.floor(animTicks / 2) % frames : 0\n drawItemIcon(posterCanvas, posterRecipe, frame, decoded)\n })\n\n React.useEffect(() => {\n if (canvas === null) return\n function onWheel(event) {\n event.preventDefault()\n setZoom((previous) => Math.max(0.35, Math.min(3, previous * (event.deltaY > 0 ? 0.92 : 1.08))))\n }\n canvas.addEventListener('wheel', onWheel, { passive: false })\n return () => canvas.removeEventListener('wheel', onWheel)\n }, [canvas])\n\n function zoomBy(factor) {\n setZoom((previous) => Math.max(0.35, Math.min(3, previous * factor)))\n }\n\n function projectNamespaceOf(id) {\n const projects = index === null ? [] : (index.projects || [])\n const found = projects.filter((entry) => entry.id === id)[0]\n return found === undefined ? '' : found.namespace\n }\n\n const hudView = () => {\n const list = itemFiltered()\n const pages = Math.max(1, Math.ceil(list.length / HUD_SLOTS))\n const page = Math.min(Math.max(1, hudPage), pages)\n return { list: list.slice((page - 1) * HUD_SLOTS, page * HUD_SLOTS),\n page: page, pages: pages, total: list.length }\n }\n const hudOn = previewItem !== null ? previewItem.id\n : (choice !== null && choice.kind === 'block' ? choice.id : '')\n const hudPickedEntry = hudOn === '' ? undefined\n : itemFiltered().filter((entry) => entry.id === hudOn)[0]\n const hudPicked = previewItem !== null\n ? (hudPickedEntry === undefined ? previewItem.id : hudPickedEntry.name)\n : (hudPickedEntry !== undefined ? hudPickedEntry.name\n : (choice === null ? '' : (choice.title || choice.id)))\n const hudKey = item === null ? '' : [item.source, item.namespace, item.filter,\n item.form, item.family].join('|')\n React.useEffect(() => {\n if (hudOn === '') return\n const token = hudKey + '#' + hudOn\n if (token === hudFollowed) return\n hudFollowed = token\n const at = itemFiltered().map((entry) => entry.id).indexOf(hudOn)\n if (at < 0) return\n const page = Math.floor(at / HUD_SLOTS) + 1\n if (page !== hudView().page) setHudPage(page)\n }, [hudKey, hudOn])\n const hudAnimated = item !== null && itemFiltered().some((entry) => {\n const recipe = itemRecipes[itemKey(item.namespace, entry.id)]\n return recipe !== undefined && recipe !== null\n && (((recipe.frames || []).length > 1) || Object.keys(recipe.animations || {}).length > 0)\n })\n const animating = (scene !== null && Object.keys(scene.animations || {}).length > 0)\n || (ghost !== null && Object.keys(ghost.animations || {}).length > 0)\n || hudAnimated\n React.useEffect(() => {\n if (!animating) return undefined\n const dispose = timer.interval(() => {\n animTicks += 1\n setTick((previous) => previous + 1)\n }, 50)\n return () => { if (typeof dispose === 'function') dispose() }\n }, [animating])\n\n React.useEffect(() => {\n const ours = projectNamespaceOf(activeProjectId())\n if (ours === '') return\n if (item === null) { loadItems('project', ours, 1, '', '', ''); return }\n if (item.source === 'project' && item.namespace !== ours) loadItems('project', ours, 1, '', '', '')\n }, [index === null ? '' : (index.projects || []).map((project) => project.id).join(','),\n activeProjectId(), item === null ? '' : item.source + '|' + item.namespace])\n\n React.useEffect(() => {\n if (scene === null && item === null) return\n const needed = (scene === null ? [] : (scene.textureIds || [])).slice()\n const waiting = needed.slice()\n if (ghost !== null && Array.isArray(ghost.textureIds)) {\n for (const id of ghost.textureIds) if (needed.indexOf(id) < 0) needed.push(id)\n }\n for (const recipe of iconRecipesInUse()) {\n for (const id of (recipe.textureIds || [])) if (needed.indexOf(id) < 0) needed.push(id)\n }\n let changed = false\n if (scratch !== null) {\n for (const id of needed) {\n if (decoded[id] !== undefined || failedTex[id] === true) continue\n const node = imageNodes[id]\n if (node === undefined || node.complete !== true) continue\n changed = true\n if (!node.naturalWidth || !node.naturalHeight) { failedTex[id] = true; continue }\n const neededWidth = Math.max(scratch.width, node.naturalWidth)\n const neededHeight = Math.max(scratch.height, node.naturalHeight)\n if (neededWidth !== scratch.width || neededHeight !== scratch.height) {\n scratch.width = neededWidth\n scratch.height = neededHeight\n }\n const paint = scratch.getContext('2d')\n paint.clearRect(0, 0, scratch.width, scratch.height)\n paint.drawImage(node, 0, 0)\n const frame = paint.getImageData(0, 0, node.naturalWidth, node.naturalHeight)\n decoded[id] = { width: frame.width, height: frame.height, data: frame.data }\n }\n }\n if (changed) { setTick(tick + 1); return }\n if (scene === null) {\n if (canvas === null) return\n const emptyKey = 'empty|' + size[0] + '|' + size[1] + '|' + canvasGeneration\n if (emptyKey === lastDrawKey) return\n lastDrawKey = emptyKey\n clearViewport(canvas, size[0], size[1])\n return\n }\n if (canvas === null) return\n for (const id of waiting) {\n if (decoded[id] === undefined && failedTex[id] !== true) return\n }\n const ghostKey = ghost === null ? '-' : (ghost.block + '@' + ghost.at.join(',') + ':' + ghost.quads.length)\n const hoverKey = voxel === null || hover === null ? '-' : (hover.cell + ':' + hover.face)\n const animations = Object.assign({}, scene.animations || {},\n ghost === null || ghost.animations === undefined ? {} : ghost.animations)\n const animTime = animTicks * 50\n const animKey = Object.keys(animations).sort()\n .map((id) => id + ':' + animationRow(animations[id], animTime)).join(',')\n const key = [scene.kind, scene.id, scene.quads.length, size[0], size[1], canvasGeneration,\n yaw.toFixed(4), pitch.toFixed(4), zoom.toFixed(4), ghostKey, hoverKey, animKey].join('|')\n if (key === lastDrawKey) return\n lastDrawKey = key\n const box = scene.box || { min: [-1, -1, -1], max: [1, 1, 1] }\n const camera = orbitCamera(box, yaw, pitch, zoom, size[0], size[1], Math.PI / 4)\n lastCamera = camera\n const buffer = new Int32Array(size[0] * size[1])\n buffer.fill(-1)\n const colourBuf = new Float64Array(size[0] * size[1] * 4)\n const depthBuf = new Float64Array(size[0] * size[1])\n const pass = { width: size[0], height: size[1], background: [26, 24, 28], camera: camera,\n colour: colourBuf, depth: depthBuf, textures: decoded, animations: animations, timeMs: animTime }\n const bytes = renderScene(Object.assign({ quads: scene.quads, pick: buffer }, pass))\n pickBuffer = buffer\n if (voxel !== null && hover !== null) {\n const marked = scene.quads.filter((quad) => quad.pick !== undefined && quad.pick !== null\n && quad.pick.cell === hover.cell)\n if (marked.length > 0) {\n renderScene(Object.assign({ quads: marked, overlay: 0.45, tint: TINT_REMOVE }, pass))\n }\n const ghostQuads = ghost === null || !Array.isArray(ghost.quads) ? [] : ghost.quads\n const usable = ghostQuads.some((quad) => decoded[quad.tex] !== undefined)\n if (usable) {\n renderScene(Object.assign({ quads: ghostQuads, overlay: 0.5, tint: null }, pass))\n }\n }\n for (let i = 0; i < bytes.length; i++) {\n const value = Math.round(colourBuf[i] * 255)\n bytes[i] = value < 0 ? 0 : (value > 255 ? 255 : value)\n }\n canvas.width = size[0]\n canvas.height = size[1]\n const paint = canvas.getContext('2d')\n const frame = paint.createImageData(size[0], size[1])\n frame.data.set(bytes)\n paint.putImageData(frame, 0, 0)\n })\n\n\n function iconRecipesInUse() {\n const out = []\n if (item === null) return out\n const seen = {}\n const add = (entry) => {\n const key = itemKey(item.namespace, entry.id)\n if (seen[key] === true) return\n const recipe = itemRecipes[key]\n if (recipe === undefined || recipe === null) return\n seen[key] = true\n out.push(recipe)\n }\n for (const entry of hudView().list) add(entry)\n for (const entry of itemView().list) add(entry)\n return out\n }\n\n React.useEffect(() => {\n if (item === null || item.facts.length === 0) return\n ensureItemPage()\n }, [item === null ? '' : [item.namespace, item.page, hudPage, item.filter, item.form,\n item.family, item.facts.length].join('|')])\n\n React.useEffect(() => {\n if (item === null) return\n let added = 0\n for (const entry of itemView().list) {\n const key = itemKey(item.namespace, entry.id)\n if (itemUrls[key] !== undefined) continue\n const recipe = itemRecipes[key]\n if (recipe === undefined || recipe === null || recipe.shape === 'none') continue\n let ready = true\n for (const id of (recipe.textureIds || [])) {\n if (decoded[id] === undefined && failedTex[id] !== true) { ready = false; break }\n }\n if (!ready) continue\n const url = itemIconUrl(recipe, decoded, 32)\n if (url === null) continue\n itemUrls[key] = url\n added += 1\n }\n if (added > 0) setIconTick(iconTick + 1)\n })\n\n React.useEffect(() => {\n if (item === null) return\n for (const entry of hudView().list) {\n const key = itemKey(item.namespace, entry.id)\n const canvas = hudCanvases[key]\n if (canvas === undefined || canvas === null) continue\n const recipe = itemRecipes[key]\n const frames = recipe === undefined || recipe === null ? 0 : (recipe.frames || []).length\n const frame = frames > 1 ? Math.floor(animTicks / 2) % frames : 0\n drawItemIcon(canvas, recipe, frame, decoded)\n }\n })\n\n\n function slotNote(recipe) {\n if (recipe === undefined || recipe === null) return null\n if (recipe.shape === 'none') {\n return '这个物品是代码画的,没有可用的图标模型'\n + (recipe.error ? ' —— ' + String(recipe.error) : '')\n }\n if (recipe.shape === 'iso' && (recipe.display === null || recipe.display === undefined)) {\n return '物品模型指向几何模型,模型链里没有 display.gui:游戏里物品栏也是这样画的。'\n + '把它改成 item/generated(layer0 指向那张贴图)就有正常的平面图标了'\n }\n const absent = (recipe.missingModels || []).filter((name) => String(name).indexOf('内置:') !== 0)\n if (absent.length > 0) return '模型链缺 ' + absent.join('、')\n return null\n }\n\n\n function slotFacts(recipe) {\n if (recipe === undefined || recipe === null) return '还没取到这个物品的图标'\n const parts = [recipe.shape === 'iso' ? '等距' : '平铺']\n if (recipe.display !== null && recipe.display !== undefined) {\n parts.push('display.gui ' + JSON.stringify(recipe.display.rotation))\n }\n const absent = []\n const builtin = []\n for (const name of (recipe.missingModels || [])) {\n if (String(name).indexOf('内置:') === 0) builtin.push(String(name).slice(3))\n else absent.push(String(name))\n }\n if (absent.length > 0) parts.push('模型链缺 ' + absent.join('、'))\n if (builtin.length > 0) {\n parts.push('原版母模型用了内置的 ' + builtin.join('、') + '(设了参考目录就从游戏里读)')\n }\n return parts.join(' · ')\n }\n\n function blockChoices() {\n const out = []\n const seen = {}\n const add = (name, title) => {\n if (typeof name !== 'string' || name === '' || seen[name] === true) return\n seen[name] = true\n out.push({ name: name, title: title || name })\n }\n const projects = index === null ? [] : (index.projects || [])\n const current = activeProjectId()\n const currentProject = projects.filter((item) => item.id === current)[0]\n const fallback = currentProject === undefined ? '' : currentProject.namespace\n const titleOf = {}\n for (const project of projects) {\n const named = project.id === current\n for (const item of ((project.items && project.items.block) || [])) {\n const qualified = project.namespace + ':' + item.id\n titleOf[qualified] = named ? item.title : (project.title + ' · ' + item.title)\n add(qualified, titleOf[qualified])\n }\n }\n for (const cell of (scene !== null && Array.isArray(scene.cells) ? scene.cells : [])) {\n const raw = String(cell.block)\n const qualified = raw.indexOf(':') >= 0 || fallback === '' ? raw : (fallback + ':' + raw)\n add(qualified, titleOf[qualified] || qualified)\n }\n return out\n }\n\n function openVoxel() {\n if (scene === null || !Array.isArray(scene.cells)) {\n setEditMsg('这个资产没有可编辑的格子')\n return\n }\n const choices = blockChoices()\n loadRefSources()\n resetPick()\n painting.active = false\n setHover(null)\n setGhost(null)\n setEdit(null)\n setFilter('')\n setVoxel({\n kind: scene.kind, id: scene.id, title: scene.title || scene.id,\n cells: scene.cells.map((cell) => {\n const kept = { block: cell.block, at: [cell.at[0], cell.at[1], cell.at[2]] }\n if (typeof cell.variant === 'string' && cell.variant !== '') kept.variant = cell.variant\n return kept\n }),\n block: choices.length > 0 ? choices[0].name : '', choices: choices,\n source: 'project', variants: [], axes: [], defaults: {}, flip: false, overrides: {}, manual: false,\n undo: [], busy: false, msg: null,\n })\n }\n\n function loadRefSources() {\n setRefSources([])\n host.call('atlas.refNamespaces', { root: root, project: activeProjectId() })\n .then((result) => {\n if (result === null || result === undefined || result.error !== undefined) return\n setRefSources(result.namespaces || [])\n }).catch(() => { })\n }\n\n function itemKey(namespace, id) { return namespace + ':' + id }\n\n function loadItems(source, namespace, page, filter, form, family) {\n setItem({ source: source, namespace: namespace, facts: [],\n page: page === undefined ? 1 : page,\n filter: filter === undefined ? '' : filter, form: form === undefined ? '' : form,\n family: family === undefined ? '' : family, busy: true, msg: null,\n choices: item === null || item.choices === undefined ? [] : item.choices })\n host.call('atlas.refItems', { root: root, project: activeProjectId(),\n source: source, namespace: namespace })\n .then((result) => {\n const failed = failureOf(result)\n if (failed !== null) {\n setItem((previous) => Object.assign({}, previous, { busy: false,\n msg: '取不到物品:' + failed }))\n return\n }\n const facts = (result.items || []).filter((entry) => entry.parentOnly !== true)\n setItem((previous) => Object.assign({}, previous, { busy: false, facts: facts,\n version: result.version,\n msg: facts.length + ' 个物品' + (result.version === undefined ? '' : ' · ' + result.version) }))\n }).catch((error) => {\n setItem((previous) => Object.assign({}, previous, { busy: false,\n msg: '取不到物品:' + String(error && error.message ? error.message : error) }))\n })\n }\n\n function openItems() {\n if (itemOpen) { setItemOpen(false); return }\n setItemOpen(true)\n loadRefSources()\n setIconPick(null)\n const ours = projectNamespaceOf(activeProjectId())\n const stale = item === null || item.facts.length === 0\n || (item.source === 'project' && ours !== '' && item.namespace !== ours)\n if (!stale) return\n if (ours !== '') { loadItems('project', ours, 1, '', '', ''); return }\n const namespace = refSources.length > 0 ? refSources[0].name : 'minecraft'\n loadItems('reference', namespace, 1, '', '', '')\n }\n\n\n function itemSearch() {\n return item === null ? '' : String(item.filter).trim().toLowerCase()\n }\n\n\n function itemFiltered() {\n if (item === null) return []\n const needle = itemSearch()\n if (needle !== '') return item.facts.filter((entry) => String(entry.name).toLowerCase().indexOf(needle) >= 0\n || String(entry.id).toLowerCase().indexOf(needle) >= 0)\n return item.facts.filter((entry) => (item.form === '' || entry.form === item.form)\n && (item.family === '' || entry.family === item.family))\n }\n\n function itemView() {\n const list = itemFiltered()\n const total = list.length\n const pages = Math.max(1, Math.ceil(total / ITEM_PAGE))\n const page = Math.min(Math.max(1, item.page), pages)\n return { list: list.slice((page - 1) * ITEM_PAGE, page * ITEM_PAGE), page: page, pages: pages, total: total }\n }\n\n function ensureItemPage() {\n if (item === null) return\n const missing = []\n const want = (list) => {\n for (const entry of list) {\n const key = itemKey(item.namespace, entry.id)\n if (itemRecipes[key] === undefined && missing.indexOf(entry.id) < 0) missing.push(entry.id)\n }\n }\n want(itemView().list)\n want(hudView().list)\n if (missing.length === 0) return\n host.call('atlas.itemIcons', { root: root, project: activeProjectId(),\n source: item.source, namespace: item.namespace, items: missing }).then((result) => {\n if (failureOf(result) !== null) return\n let added = 0\n for (const id of Object.keys(result.items || {})) {\n const recipe = result.items[id]\n const key = itemKey(item.namespace, id)\n if (itemRecipes[key] !== undefined) continue\n itemRecipes[key] = recipe\n added += 1\n }\n if (added > 0) setIconTick(iconTick + 1)\n }).catch(() => { })\n }\n\n\n function projectHasBlock(id) {\n const project = index === null ? null\n : (index.projects || []).filter((entry) => entry.id === activeProjectId())[0]\n if (project === null || project === undefined) return false\n return ((project.items && project.items.block) || []).some((entry) => entry.id === id)\n }\n\n function pickItem(entry) {\n const namespace = item === null ? '' : item.namespace\n setIconPick({ namespace: namespace, id: entry.id })\n if (item !== null && item.source === 'project') {\n if (!projectHasBlock(entry.id)) {\n setPreviewItem({ source: 'project', namespace: namespace, id: entry.id, localOnly: true })\n setScene(null)\n setVoxel(null)\n setHover(null)\n setGhost(null)\n setEdit(null)\n setFailure(null)\n return\n }\n setPreviewItem(null)\n open({ project: activeProjectId(), kind: 'block', id: entry.id }, root)\n return\n }\n openReferenceItem(entry)\n }\n\n function openReferenceItem(entry) {\n const reference = (item === null ? '' : item.namespace) + ':' + entry.id\n setPreviewItem({ source: 'reference', namespace: item === null ? '' : item.namespace, id: entry.id })\n host.call('atlas.preview', { root: root, project: activeProjectId(), block: reference,\n at: [0, 0, 0], variant: null, have: Object.keys(decoded) }).then((result) => {\n const failed = failureOf(result)\n if (failed !== null) {\n setItem((previous) => previous === null ? previous : Object.assign({}, previous,\n { msg: '3D 取不到:' + failed }))\n return\n }\n lastDrawKey = ''\n setFailure(null)\n setScene({ kind: 'block', id: reference, title: entry.name || entry.id,\n quads: result.quads, textureIds: result.textureIds, textures: result.textures,\n animations: result.animations || {}, cells: null, palette: [], errors: [],\n box: boxOfQuads(result.quads), preview: true })\n setVoxel(null)\n setHover(null)\n setGhost(null)\n }).catch((error) => {\n setItem((previous) => previous === null ? previous : Object.assign({}, previous,\n { msg: '3D 取不到:' + String(error && error.message ? error.message : error) }))\n })\n }\n\n function pickSource(source) {\n if (voxel === null) return\n if (source === 'project') {\n const choices = blockChoices()\n setFilter('')\n resetPick()\n setVoxel(Object.assign({}, voxel, { source: 'project', choices: choices,\n block: choices.length > 0 ? choices[0].name : '' }))\n setGhost(null)\n return\n }\n const namespace = source.slice(4)\n setVoxel(Object.assign({}, voxel, { source: source, msg: '正在读取 ' + namespace + ' 的方块…' }))\n host.call('atlas.refBlocks', { root: root, project: activeProjectId(), namespace: namespace })\n .then((result) => {\n if (result === null || result === undefined || result.error !== undefined) {\n setVoxel(Object.assign({}, voxel, { msg: '取不到 ' + namespace + ' 的方块:'\n + String(result && result.error ? result.error : '没有返回结果') }))\n return\n }\n const choices = (result.blocks || []).map((item) => ({ name: namespace + ':' + item.id,\n title: item.name, group: item.group || '', groupLabel: item.groupLabel || '',\n family: item.family || '' }))\n if (choices.length === 0) {\n setVoxel(Object.assign({}, voxel, { msg: namespace + ' 里没有方块' }))\n return\n }\n setFilter('')\n resetPick()\n setVoxel(Object.assign({}, voxel, { source: source, choices: choices,\n block: choices[0].name, msg: '参考 ' + namespace + ' · ' + choices.length + ' 个方块' }))\n setGhost(null)\n }).catch((error) => {\n setVoxel(Object.assign({}, voxel, { msg: '取不到方块:' + String(error && error.message ? error.message : error) }))\n })\n }\n\n function searchMatch(item, needle) {\n return String(item.title).toLowerCase().indexOf(needle) >= 0\n || String(item.name).toLowerCase().indexOf(needle) >= 0\n }\n\n function groupOfItem(item) { return item.group || '' }\n function familyOfItem(item) { return item.family || '其他' }\n\n\n function countsBy(keyOf, within) {\n const counts = {}\n for (const item of (voxel === null ? [] : voxel.choices)) {\n if (within !== null && within(item) !== true) continue\n const value = keyOf(item) || ''\n counts[value] = (counts[value] || 0) + 1\n }\n return counts\n }\n\n\n function choiceView() {\n if (voxel === null) return { list: [], page: 1, pages: 1, total: 0 }\n const needle = String(filter).trim().toLowerCase()\n let list = voxel.choices\n if (needle !== '') {\n list = list.filter((item) => searchMatch(item, needle))\n } else if (pickGroup !== '' || pickFamily !== '') {\n list = list.filter((item) =>\n (pickGroup === '' || groupOfItem(item) === pickGroup)\n && (pickFamily === '' || familyOfItem(item) === pickFamily))\n }\n const total = list.length\n const pages = Math.max(1, Math.ceil(total / PAGE_SIZE))\n const shownPage = Math.min(Math.max(1, pickPage), pages)\n return { list: list.slice((shownPage - 1) * PAGE_SIZE, shownPage * PAGE_SIZE),\n page: shownPage, pages: pages, total: total }\n }\n\n function resetPick() {\n setPickGroup('')\n setPickFamily('')\n setPickPage(1)\n }\n\n function releaseRefs() {\n const keep = []\n for (const cell of (voxel === null ? [] : voxel.cells)) {\n const raw = String(cell.block)\n const colon = raw.indexOf(':')\n let qualified = null\n if (colon >= 0) qualified = raw\n else if (voxel !== null && voxel.source !== 'project') qualified = voxel.source.slice(4) + ':' + raw\n if (qualified === null) continue\n keep.push(qualified)\n if (typeof cell.variant === 'string' && cell.variant !== '') {\n keep.push(qualified + '@' + cell.variant)\n }\n }\n if (voxel !== null && voxel.source !== 'project' && voxel.block !== '') {\n keep.push(voxel.block)\n if (ghost !== null && ghost.block === voxel.block\n && typeof ghost.variant === 'string' && ghost.variant !== '') {\n keep.push(voxel.block + '@' + ghost.variant)\n }\n }\n host.call('atlas.releaseRefs', { keep: keep }).catch(() => { })\n }\n\n function voxelWith(cells, x, y, z, block, variant) {\n const out = cells.filter((cell) => !(cell.at[0] === x && cell.at[1] === y && cell.at[2] === z))\n if (block !== null && block !== '') {\n const cell = { block: block, at: [x, y, z] }\n if (typeof variant === 'string' && variant !== '') cell.variant = variant\n out.push(cell)\n }\n return out\n }\n\n\n function voxelTargetAt(event) {\n if (voxel === null || scene === null || canvas === null || pickBuffer === null) return null\n const rect = canvas.getBoundingClientRect()\n if (rect.width === 0 || rect.height === 0) return null\n const px = Math.floor((event.clientX - rect.left) / rect.width * canvas.width)\n const py = Math.floor((event.clientY - rect.top) / rect.height * canvas.height)\n if (px < 0 || py < 0 || px >= canvas.width || py >= canvas.height) return null\n const quadIndex = pickBuffer[py * canvas.width + px]\n if (quadIndex === undefined || quadIndex < 0) return null\n const quad = scene.quads[quadIndex]\n const pick = quad === undefined ? undefined : quad.pick\n if (pick === undefined || pick === null) return null\n const cell = (scene.cells || [])[pick.cell]\n if (cell === undefined) return null\n const hitY = faceHitY(quad, px + 0.5, py + 0.5, lastCamera)\n return { cell: pick.cell, face: pick.face, at: cell.at, block: cell.block, hitY: hitY }\n }\n\n\n function placementAt(target) {\n if (target === null) return null\n const step = (scene === null ? {} : (scene.faceStep || {}))[target.face]\n if (step === undefined) return null\n const at = [target.at[0] + step[0], target.at[1] + step[1], target.at[2] + step[2]]\n const occupied = (voxel === null ? [] : voxel.cells).some((cell) =>\n cell.at[0] === at[0] && cell.at[1] === at[1] && cell.at[2] === at[2])\n return occupied ? null : at\n }\n\n React.useEffect(() => {\n if (voxel === null) return\n const source = voxel.source === undefined ? 'project' : voxel.source\n const missing = []\n for (const item of choiceView().list) {\n if (icons[item.name] !== undefined || iconTried[item.name] === true) continue\n if (missing.indexOf(item.name) < 0) missing.push(item.name)\n }\n if (missing.length === 0) return\n const batch = missing.slice(0, ICON_BATCH)\n for (const name of batch) iconTried[name] = true\n let request\n if (source === 'project') {\n request = host.call('atlas.icons', { root: root, project: activeProjectId(), blocks: batch })\n } else {\n const namespace = String(source).slice(4)\n const ids = batch.filter((name) => name.indexOf(namespace + ':') === 0)\n .map((name) => name.slice(namespace.length + 1))\n if (ids.length === 0) return\n request = host.call('atlas.refIcons', { root: root, project: activeProjectId(),\n namespace: namespace, blocks: ids })\n }\n request.then((result) => {\n if (result === null || result === undefined || result.error !== undefined) return\n let added = 0\n for (const key of Object.keys(result.icons || {})) { icons[key] = result.icons[key]; added += 1 }\n if (added > 0) setIconTick(iconTick + 1)\n }).catch(() => { })\n }, [voxel === null ? '' : (voxel.source + '|' + voxel.kind + ':' + voxel.id + '|' + voxel.choices.length),\n filter, pickGroup, pickFamily, pickPage, iconTick])\n\n React.useEffect(() => {\n if (voxel === null) { ghostAsk = ''; if (ghost !== null) setGhost(null); return }\n const target = hover === null ? null : placementAt(hover)\n if (target === null) { ghostAsk = ''; if (ghost !== null) setGhost(null); return }\n const face = hover === null || hover.face === undefined ? 'north' : hover.face\n const hitY = hover === null || hover.hitY === undefined ? 0.5 : hover.hitY\n const wantVariant = variantFor(voxel.axes, voxel.variants, voxel.defaults, face, hitY,\n voxel.flip === true, voxel.overrides)\n const around = aroundOf(voxel.cells, target)\n const wantKey = voxel.block + '|' + String(wantVariant) + '|' + target.join(',')\n + '|' + JSON.stringify(around)\n if (ghost !== null && ghostAsk === wantKey) return\n ghostAsk = wantKey\n setGhost({ block: voxel.block, at: target, variant: wantVariant, quads: [], textures: {},\n textureIds: [], loading: true })\n host.call('atlas.preview', {\n root: root, project: activeProjectId(), block: voxel.block, at: target,\n variant: wantVariant, have: Object.keys(decoded), around: around,\n }).then((result) => {\n if (result === null || result === undefined || result.error !== undefined) {\n setGhost({ block: voxel.block, at: target, variant: wantVariant, quads: [], textures: {},\n textureIds: [], error: String(result && result.error ? result.error : '没有返回结果') })\n return\n }\n setGhost(Object.assign({ loading: false }, result))\n }).catch((error) => {\n setGhost({ block: voxel.block, at: target, variant: wantVariant, quads: [], textures: {},\n textureIds: [], error: String(error && error.message ? error.message : error) })\n })\n })\n\n const variantKey = voxel === null ? '' : (voxel.source + '|' + voxel.block)\n React.useEffect(() => {\n if (voxel === null || voxel.block === '') return undefined\n let live = true\n host.call('atlas.preview', {\n root: root, project: activeProjectId(), block: voxel.block, at: [0, 0, 0],\n variant: null, have: Object.keys(decoded),\n }).then((result) => {\n if (!live) return\n const keys = result === null || result === undefined || !Array.isArray(result.variants)\n ? [] : result.variants\n const axes = result === null || result === undefined || !Array.isArray(result.axes)\n ? [] : result.axes\n const defaults = result === null || result === undefined || typeof result.defaults !== 'object'\n || result.defaults === null ? {} : result.defaults\n setVoxel((previous) => {\n if (previous === null || previous.block !== voxel.block) return previous\n if ((previous.variants || []).join(',') === keys.join(',')\n && JSON.stringify(previous.axes || []) === JSON.stringify(axes)) return previous\n return Object.assign({}, previous,\n { variants: keys, axes: axes, defaults: defaults, flip: false, overrides: {}, manual: false })\n })\n }).catch(() => { })\n return () => { live = false }\n }, [variantKey])\n\n function refreshVoxel(state) {\n host.call('atlas.scene', {\n root: root, project: activeProjectId(), kind: state.kind, id: state.id,\n cells: state.cells, have: Object.keys(decoded),\n neighbours: state.neighbours !== false,\n }).then((result) => {\n if (result === null || result === undefined || result.error !== undefined) {\n setVoxel(Object.assign({}, state, { busy: false,\n msg: '预览失败:' + String(result && result.error ? result.error : '没有返回结果') }))\n return\n }\n setVoxel(Object.assign({}, state, { busy: false }))\n lastDrawKey = ''\n setScene(result)\n releaseRefs()\n }).catch((error) => {\n setVoxel(Object.assign({}, state, { busy: false, msg: '预览失败:' + String(error && error.message ? error.message : error) }))\n })\n }\n\n function voxelApply(cells, note) {\n if (voxel === null) return\n const next = Object.assign({}, voxel, {\n cells: cells, busy: true, msg: note,\n undo: voxel.undo.concat([voxel.cells]).slice(-16),\n })\n setVoxel(next)\n refreshVoxel(next)\n }\n\n function voxelUndo() {\n if (voxel === null || voxel.undo.length === 0) return\n const next = Object.assign({}, voxel, {\n cells: voxel.undo[voxel.undo.length - 1],\n undo: voxel.undo.slice(0, voxel.undo.length - 1),\n msg: '撤销', busy: true,\n })\n setVoxel(next)\n refreshVoxel(next)\n }\n\n function voxelSave() {\n if (voxel === null || voxel.busy) return\n setVoxel(Object.assign({}, voxel, { busy: true, msg: '保存中…' }))\n host.call('atlas.saveVoxel', {\n root: root, project: activeProjectId(), kind: voxel.kind, id: voxel.id, cells: voxel.cells,\n }).then((result) => {\n if (result === null || result === undefined || result.error !== undefined) {\n setVoxel(Object.assign({}, voxel, { busy: false,\n msg: '保存失败:' + String(result && result.error ? result.error : '没有返回结果') }))\n return\n }\n delete indexes[root]\n setVoxel(Object.assign({}, voxel, { busy: false,\n msg: '已保存 ' + result.cells + ' 格 -> ' + String(result.file).split('/').pop() }))\n }).catch((error) => {\n setVoxel(Object.assign({}, voxel, { busy: false, msg: '保存失败:' + String(error && error.message ? error.message : error) }))\n })\n }\n\n function voxelClick(event, action) {\n if (voxel === null || scene === null || canvas === null || pickBuffer === null) return\n const target = voxelTargetAt(event)\n if (target === null) {\n setVoxel(Object.assign({}, voxel, { msg: '那里是空的,点方块上' }))\n return\n }\n const pick = { cell: target.cell, face: target.face }\n const cell = (scene.cells || [])[pick.cell]\n if (cell === undefined) {\n setVoxel(Object.assign({}, voxel, { msg: '格子索引对不上了,刷新一下' }))\n return\n }\n if (action === 'remove') {\n voxelApply(voxelWith(voxel.cells, cell.at[0], cell.at[1], cell.at[2], null, null), '挖掉 ' + cell.block)\n return\n }\n const step = (scene.faceStep || {})[pick.face]\n if (step === undefined) {\n setVoxel(Object.assign({}, voxel, { msg: '认不出这个面:' + String(pick.face) }))\n return\n }\n const placed = variantFor(voxel.axes, voxel.variants, voxel.defaults, pick.face,\n target.hitY === undefined ? 0.5 : target.hitY, voxel.flip === true, voxel.overrides)\n voxelApply(voxelWith(voxel.cells, cell.at[0] + step[0], cell.at[1] + step[1], cell.at[2] + step[2],\n voxel.block, placed), '放置 ' + voxel.block)\n }\n\n function voxelCard() {\n if (voxel === null) return null\n const rows = []\n rows.push(React.createElement('div', { className: 'mcart-bar', key: 'head' },\n React.createElement('span', { className: 'mcart-title' }, '搭建'),\n React.createElement('span', { className: 'mcart-sub' }, (voxel.title || '') + ' · ' + voxel.cells.length + ' 格')))\n rows.push(React.createElement('div', { className: 'mcart-src', key: 'filter' },\n React.createElement('input', {\n className: 'mcart-input', placeholder: '搜方块名或 id(共 ' + voxel.choices.length + ' 个)',\n value: filter, onChange: (event) => { setFilter(event.target.value); setPickPage(1) },\n }),\n filter === '' ? null : React.createElement('button', { className: 'mcart-btn', type: 'button',\n onClick: () => { setFilter(''); setPickPage(1) } }, '清空')))\n\n const derivedOn = voxel.neighbours !== false\n rows.push(React.createElement('div', { className: 'mcart-tabs', key: 'neighbours' },\n React.createElement('span', { className: 'mcart-note' }, '连接'),\n React.createElement('button', {\n className: 'mcart-btn mcart-tool', type: 'button',\n 'data-on': derivedOn ? '1' : '0',\n title: '墙/栅栏/玻璃板:原版按邻居决定画哪几段。关掉就是抽取器给的那份并集(全部画上),不是\"算不出来\"。',\n onClick: () => {\n const next = Object.assign({}, voxel, { neighbours: voxel.neighbours === false })\n setVoxel(next)\n refreshVoxel(next)\n },\n }, derivedOn ? '按邻居推导' : '全部画上')))\n\n const axes = Array.isArray(voxel.axes) ? voxel.axes : []\n if (axes.length > 0) {\n const face = hover === null || hover.face === undefined ? null : hover.face\n const hitY = hover === null || hover.hitY === undefined ? 0.5 : hover.hitY\n const derived = face === null ? {} : deriveVariant(axes, face, hitY, voxel.flip === true)\n const summary = []\n summary.push(React.createElement('span', { className: 'mcart-note', key: '__what' },\n face === null\n ? '朝向由你点的那个面决定——悬停到方块上试试'\n : '点 ' + labelOfValue(face) + ' 面 → ' + variantSummary(axes, derived)))\n if (derived.facing !== undefined) {\n summary.push(React.createElement('button', {\n key: '__flip', type: 'button', className: 'mcart-btn mcart-tool',\n 'data-on': voxel.flip === true ? '1' : '0',\n title: '熔炉这类是朝着你,楼梯/活板门那类正相反——盘上没写,只能你说了算',\n onClick: () => setVoxel(Object.assign({}, voxel, { flip: voxel.flip !== true })),\n }, '翻转 180°'))\n }\n if (Object.keys(voxel.overrides || {}).length > 0) {\n summary.push(React.createElement('button', {\n key: '__reset', type: 'button', className: 'mcart-btn mcart-tool',\n onClick: () => setVoxel(Object.assign({}, voxel, { overrides: {} })),\n }, '恢复自动'))\n }\n summary.push(React.createElement('button', {\n key: '__manual', type: 'button', className: 'mcart-btn mcart-tool',\n 'data-on': voxel.manual === true ? '1' : '0',\n onClick: () => setVoxel(Object.assign({}, voxel, { manual: voxel.manual !== true })),\n }, voxel.manual === true ? '收起 ▴' : '手动 ▾'))\n rows.push(React.createElement('div', { className: 'mcart-tabs', key: 'orientation' },\n React.createElement('span', { className: 'mcart-note' }, '朝向'), summary))\n\n if (voxel.manual === true) {\n for (const axis of axes) {\n const picked = (voxel.overrides || {})[axis.name]\n const chips = [React.createElement('button', {\n key: '__auto', type: 'button', className: 'mcart-btn mcart-tool',\n 'data-on': picked === undefined ? '1' : '0',\n onClick: () => setVoxel(Object.assign({}, voxel,\n { overrides: withoutKey(voxel.overrides, axis.name) })),\n }, '自动')]\n for (const value of axis.values) {\n chips.push(React.createElement('button', {\n key: value, type: 'button', className: 'mcart-btn mcart-tool',\n 'data-on': picked === value ? '1' : '0',\n onClick: () => setVoxel(Object.assign({}, voxel,\n { overrides: withKey(voxel.overrides, axis.name, value) })),\n }, labelOfValue(value)))\n }\n rows.push(React.createElement('div', { className: 'mcart-tabs', key: 'axis:' + axis.name },\n React.createElement('span', { className: 'mcart-note' }, labelOfAxis(axis.name)), chips))\n }\n if (axes.some((axis) => axis.name === 'shape')) {\n rows.push(React.createElement('div', { className: 'mcart-hint', key: 'shapenote' },\n '形状(内角/外角)在原版里是看邻居算出来的,这里还没做——默认给你 straight,不是不敢算就说算了。'))\n }\n rows.push(React.createElement('div', { className: 'mcart-hint', key: 'oriennote' },\n '不选就是自动。摆放时按你点的面和位置推导,幽灵预览里看到的就是要放上去的。'))\n }\n }\n\n const needle = String(filter).trim().toLowerCase()\n if (needle === '') {\n const groupCounts = countsBy(groupOfItem, null)\n const groupKeys = Object.keys(groupCounts).filter((key) => key !== '').sort()\n if (groupKeys.length > 0) {\n const chips = [React.createElement('button', {\n key: '__all', type: 'button', className: 'mcart-btn mcart-tool',\n 'data-on': pickGroup === '' ? '1' : '0',\n onClick: () => { setPickGroup(''); setPickFamily(''); setPickPage(1) },\n }, '全部 ' + voxel.choices.length)]\n for (const key of groupKeys) {\n const label = (voxel.choices.filter((item) => groupOfItem(item) === key)[0] || {}).groupLabel || key\n chips.push(React.createElement('button', {\n key: key, type: 'button', className: 'mcart-btn mcart-tool',\n 'data-on': pickGroup === key ? '1' : '0',\n onClick: () => { setPickGroup(key); setPickFamily(''); setPickPage(1) },\n }, label + ' ' + groupCounts[key]))\n }\n rows.push(React.createElement('div', { className: 'mcart-tabs', key: 'groups' },\n React.createElement('span', { className: 'mcart-note' }, '分类'), chips))\n }\n const familyCounts = countsBy(familyOfItem,\n pickGroup === '' ? null : (item) => groupOfItem(item) === pickGroup)\n const familyKeys = Object.keys(familyCounts).sort()\n if (familyKeys.length > 1) {\n const chips = [React.createElement('button', {\n key: '__all', type: 'button', className: 'mcart-btn mcart-tool',\n 'data-on': pickFamily === '' ? '1' : '0',\n onClick: () => { setPickFamily(''); setPickPage(1) },\n }, '全部 ' + Object.keys(familyCounts).reduce((sum, key) => sum + familyCounts[key], 0))]\n for (const key of familyKeys) {\n chips.push(React.createElement('button', {\n key: key, type: 'button', className: 'mcart-btn mcart-tool',\n 'data-on': pickFamily === key ? '1' : '0',\n onClick: () => { setPickFamily(key); setPickPage(1) },\n }, key + ' ' + familyCounts[key]))\n }\n rows.push(React.createElement('div', { className: 'mcart-tabs', key: 'families' },\n React.createElement('span', { className: 'mcart-note' }, '形状'), chips))\n }\n }\n\n const view = choiceView()\n rows.push(React.createElement('div', { className: 'mcart-bar', key: 'pager' },\n React.createElement('button', { className: 'mcart-btn', type: 'button', disabled: view.page <= 1,\n onClick: () => setPickPage(view.page - 1) }, '‹ 上一页'),\n React.createElement('span', { className: 'mcart-note' },\n '第 ' + view.page + ' / ' + view.pages + ' 页 · 共 ' + view.total + ' 个'),\n React.createElement('button', { className: 'mcart-btn', type: 'button', disabled: view.page >= view.pages,\n onClick: () => setPickPage(view.page + 1) }, '下一页 ›'),\n React.createElement('input', {\n className: 'mcart-input', type: 'number', min: 1, max: view.pages, value: view.page,\n style: { maxWidth: '66px' },\n onChange: (event) => setPickPage(Number(event.target.value) || 1),\n })))\n\n rows.push(React.createElement('div', { className: 'mcart-hotbar', key: 'hotbar' },\n view.list.map((item) => React.createElement('button', {\n key: item.name, type: 'button', className: 'mcart-slot',\n 'data-on': item.name === voxel.block ? '1' : '0',\n title: item.title + '\\n' + item.name,\n style: icons[item.name] === undefined ? {} : { backgroundImage: 'url(' + icons[item.name] + ')' },\n onClick: () => { setVoxel(Object.assign({}, voxel, { block: item.name })); setGhost(null) },\n }))))\n if (refSources.length > 0) {\n const options = [React.createElement('option', { key: 'project', value: 'project' }, '本项目')]\n for (const item of refSources) {\n options.push(React.createElement('option', { key: 'ref:' + item.name, value: 'ref:' + item.name },\n item.name + '(' + item.blocks + ' 个方块)'))\n }\n rows.push(React.createElement('div', { className: 'mcart-src', key: 'source' },\n React.createElement('span', { className: 'mcart-note' }, '方块来源'),\n React.createElement('select', {\n className: 'mcart-select',\n value: voxel.source === undefined ? 'project' : voxel.source,\n onChange: (event) => pickSource(event.target.value),\n }, options)))\n }\n const chosen = voxel.choices.filter((item) => item.name === voxel.block)[0]\n rows.push(React.createElement('div', { className: 'mcart-bar', key: 'chosen' },\n React.createElement('span', { className: 'mcart-note mcart-grow' },\n '手持:' + (chosen === undefined ? voxel.block : chosen.title) + '(' + voxel.block + ')')))\n rows.push(React.createElement('div', { className: 'mcart-tools', key: 'tools' },\n React.createElement('button', { className: 'mcart-btn', type: 'button', disabled: voxel.undo.length === 0 || voxel.busy,\n onClick: voxelUndo }, '撤销 ' + voxel.undo.length),\n React.createElement('button', { className: 'mcart-btn', type: 'button', disabled: voxel.busy,\n onClick: voxelSave }, voxel.busy ? '…' : '保存'),\n React.createElement('button', { className: 'mcart-btn', type: 'button',\n onClick: () => { setVoxel(null); setHover(null); setGhost(null); painting.active = false } }, '关闭')))\n rows.push(React.createElement('div', { className: 'mcart-hint', key: 'how' },\n '像原版一样:左键破坏,右键放置;拖动仍然是旋转。半透明的方块就是将要放上去的位置,红色高亮是要破坏的那一格。'))\n rows.push(React.createElement('div', { className: 'mcart-bar', key: 'msg' },\n React.createElement('span', { className: 'mcart-note mcart-grow' }, voxel.msg === null ? '' : String(voxel.msg))))\n if (ghost !== null && ghost.error !== undefined) {\n rows.push(React.createElement('div', { className: 'mcart-err', key: 'ghosterr' }, '预览拿不到:' + String(ghost.error)))\n }\n return React.createElement('div', { className: 'mcart-card', key: 'voxel' }, rows)\n }\n\n\n\n function hudBar() {\n const view = hudView()\n const slots = []\n for (const entry of view.list) {\n const key = itemKey(item.namespace, entry.id)\n const recipe = itemRecipes[key]\n const note = slotNote(recipe)\n slots.push(React.createElement('button', {\n key: key, type: 'button', className: 'mcart-hudslot',\n 'data-on': hudOn === entry.id ? '1' : '0',\n title: entry.name + '(' + entry.id + ')'\n + (entry.formLabel ? '\\n' + entry.formLabel : '')\n + '\\n' + slotFacts(recipe) + (note === null ? '' : '\\n' + note),\n onClick: () => pickItem(entry),\n }, React.createElement('canvas', {\n className: 'mcart-hudicon', width: 32, height: 32,\n ref: (node) => {\n if (node === null || node === undefined) delete hudCanvases[key]\n else hudCanvases[key] = node\n },\n })))\n }\n while (slots.length < HUD_SLOTS) {\n slots.push(React.createElement('span', { key: 'empty:' + slots.length,\n className: 'mcart-hudslot mcart-hudempty' }))\n }\n return React.createElement('div', { className: 'mcart-hud', key: 'hud' },\n hudPicked === '' ? null : React.createElement('div', { className: 'mcart-hudname' },\n hudPicked + (view.pages > 1 ? ' · ' + view.page + '/' + view.pages : '')),\n React.createElement('div', { className: 'mcart-hudbar' },\n view.pages > 1 ? React.createElement('button', { className: 'mcart-hudpage', type: 'button',\n disabled: view.page <= 1, title: '上一页物品',\n onClick: () => setHudPage(view.page - 1) }, '‹') : null,\n slots,\n view.pages > 1 ? React.createElement('button', { className: 'mcart-hudpage', type: 'button',\n disabled: view.page >= view.pages,\n title: '下一页物品(第 ' + view.page + ' / ' + view.pages + ' 页)',\n onClick: () => setHudPage(view.page + 1) }, '›') : null))\n }\n\n function itemCard() {\n if (item === null) return null\n const rows = []\n rows.push(React.createElement('div', { className: 'mcart-bar', key: 'head' },\n React.createElement('span', { className: 'mcart-title' }, '物品'),\n React.createElement('span', { className: 'mcart-sub' },\n item.source === 'project' ? '本项目' : item.namespace),\n React.createElement('span', { className: 'mcart-note mcart-grow' },\n '点一格就放到上面的 3D 里看'),\n React.createElement('button', { className: 'mcart-btn', type: 'button',\n onClick: () => { setItemOpen(false); setIconPick(null) } }, '收起')))\n const options = []\n const ours = projectNamespaceOf(activeProjectId())\n if (ours !== '') {\n options.push(React.createElement('option', { key: 'project', value: 'project' },\n '本项目 ' + ours + '(' + (item.source === 'project' ? item.facts.length : '…') + ')'))\n }\n for (const entry of refSources) {\n options.push(React.createElement('option', { key: 'ref:' + entry.name, value: 'ref:' + entry.name },\n entry.name + '(' + entry.blocks + ' 个方块)'))\n }\n if (options.length === 0) {\n options.push(React.createElement('option', { key: item.namespace, value: 'ref:' + item.namespace },\n item.namespace))\n }\n rows.push(React.createElement('div', { className: 'mcart-src', key: 'source' },\n React.createElement('span', { className: 'mcart-note' }, '来源'),\n React.createElement('select', {\n className: 'mcart-select',\n value: item.source === 'project' ? 'project' : 'ref:' + item.namespace,\n onChange: (event) => {\n const value = String(event.target.value)\n setIconPick(null)\n setPreviewItem(null)\n if (value === 'project') loadItems('project', ours === '' ? 'minecraft' : ours, 1, '', '', '')\n else loadItems('reference', value.slice(4), 1, '', '', '')\n },\n }, options),\n React.createElement('input', {\n className: 'mcart-input', placeholder: '搜物品名或 id',\n value: item.filter,\n onChange: (event) => setItem(Object.assign({}, item, { filter: event.target.value, page: 1 })),\n })))\n\n const formCounts = countsOf(item.facts, (entry) => entry.form, (entry) => entry.formLabel || entry.form)\n const forms = Object.keys(formCounts).sort((a, b) => formCounts[b].count - formCounts[a].count)\n if (forms.length > 0 && itemSearch() === '') {\n const chips = [React.createElement('button', {\n key: '__all', type: 'button', className: 'mcart-btn mcart-tool',\n 'data-on': item.form === '' ? '1' : '0',\n onClick: () => setItem(Object.assign({}, item, { form: '', family: '', page: 1 })),\n }, '全部 ' + item.facts.length)]\n for (const key of forms) {\n chips.push(React.createElement('button', {\n key: key, type: 'button', className: 'mcart-btn mcart-tool',\n 'data-on': item.form === key ? '1' : '0',\n onClick: () => setItem(Object.assign({}, item, { form: key, family: '', page: 1 })),\n }, formCounts[key].label + ' ' + formCounts[key].count))\n }\n rows.push(React.createElement('div', { className: 'mcart-tabs', key: 'forms' },\n React.createElement('span', { className: 'mcart-note' }, '展示形式'), chips))\n }\n const within = item.form === '' ? item.facts\n : item.facts.filter((entry) => entry.form === item.form)\n const familyCounts = countsOf(within, (entry) => entry.family, (entry) => entry.family)\n const families = Object.keys(familyCounts).sort((a, b) => familyCounts[b].count - familyCounts[a].count)\n if (families.length > 1 && itemSearch() === '') {\n const chips = [React.createElement('button', {\n key: '__all', type: 'button', className: 'mcart-btn mcart-tool',\n 'data-on': item.family === '' ? '1' : '0',\n onClick: () => setItem(Object.assign({}, item, { family: '', page: 1 })),\n }, '全部 ' + within.length)]\n for (const key of families) {\n chips.push(React.createElement('button', {\n key: key, type: 'button', className: 'mcart-btn mcart-tool',\n 'data-on': item.family === key ? '1' : '0',\n onClick: () => setItem(Object.assign({}, item, { family: key, page: 1 })),\n }, key + ' ' + familyCounts[key].count))\n }\n rows.push(React.createElement('div', { className: 'mcart-tabs', key: 'families' },\n React.createElement('span', { className: 'mcart-note' }, '细分'), chips))\n }\n\n const view = itemView()\n rows.push(React.createElement('div', { className: 'mcart-bar', key: 'pager' },\n React.createElement('button', { className: 'mcart-btn', type: 'button', disabled: view.page <= 1,\n onClick: () => setItem(Object.assign({}, item, { page: view.page - 1 })) }, '‹ 上一页'),\n React.createElement('span', { className: 'mcart-note' },\n '第 ' + view.page + ' / ' + view.pages + ' 页 · 共 ' + view.total + ' 个'),\n React.createElement('button', { className: 'mcart-btn', type: 'button', disabled: view.page >= view.pages,\n onClick: () => setItem(Object.assign({}, item, { page: view.page + 1 })) }, '下一页 ›')))\n\n const slots = []\n for (const entry of view.list) {\n const key = itemKey(item.namespace, entry.id)\n const url = itemUrls[key]\n const recipe = itemRecipes[key]\n const blank = recipe !== undefined && recipe !== null && recipe.shape === 'none'\n slots.push(React.createElement('button', {\n key: entry.id, type: 'button', className: 'mcart-slot',\n 'data-on': iconPick !== null && iconPick.id === entry.id ? '1' : '0',\n title: entry.name + '\\n' + entry.id + '\\n' + (entry.formLabel || entry.form || '')\n + (blank ? '\\n这个物品是代码画的,没有可用的图标模型' : ''),\n style: url === undefined || url === null ? {} : { backgroundImage: 'url(' + url + ')' },\n onClick: () => pickItem(entry),\n }, blank ? React.createElement('span', { className: 'mcart-slotq' }, '?') : null))\n }\n rows.push(React.createElement('div', { className: 'mcart-hotbar', key: 'slots' }, slots))\n\n const picked = iconPick === null ? null\n : view.list.filter((entry) => entry.id === iconPick.id)[0]\n if (picked !== undefined && picked !== null) {\n const recipe = itemRecipes[itemKey(iconPick.namespace, iconPick.id)]\n const frames = recipe === undefined || recipe === null ? 0 : (recipe.frames || []).length\n rows.push(React.createElement('div', { className: 'mcart-bar', key: 'chosen' },\n React.createElement('span', { className: 'mcart-note mcart-grow' },\n picked.name + '(' + picked.id + ')· ' + (picked.formLabel || picked.form || '')\n + (frames > 1 ? ' · ' + frames + ' 帧(原版按 overrides 换模型)' : '')\n + (recipe !== undefined && recipe !== null && recipe.error ? ' · ' + String(recipe.error) : ''))))\n }\n rows.push(React.createElement('div', { className: 'mcart-bar', key: 'msg' },\n React.createElement('span', { className: 'mcart-note mcart-grow' }, item.msg === null ? '' : String(item.msg))))\n return React.createElement('div', { className: 'mcart-card', key: 'items' }, rows)\n }\n\n function facesForScene() {\n const out = {}\n for (const quad of (scene === null ? [] : (scene.quads || []))) {\n if (quad.pick) continue\n if (typeof quad.tex !== 'string' || typeof quad.face !== 'string') continue\n if (out[quad.tex] === undefined) out[quad.tex] = []\n if (out[quad.tex].indexOf(quad.face) < 0) out[quad.tex].push(quad.face)\n }\n return out\n }\n\n\n function widestTexture(ids, byTexture) {\n let best = 0\n for (let i = 1; i < ids.length; i++) {\n const here = (byTexture[ids[i]] || []).length\n const there = (byTexture[ids[best]] || []).length\n if (here > there) best = i\n }\n return best\n }\n\n function makeEdit(ids, index, facesByTexture) {\n const frame = decoded[ids[index]]\n const copy = new Uint8ClampedArray(frame.data)\n const byTexture = facesByTexture || {}\n return {\n ids: ids, index: index, w: frame.width, h: frame.height,\n pixels: copy, palette: paletteOf(copy, 32), tool: 'pencil', color: null,\n faces: byTexture[ids[index]] || [],\n facesByTexture: byTexture,\n }\n }\n\n\n function itemAsset() {\n if (scene !== null || item === null || hudOn === '') return null\n return { namespace: item.namespace, id: hudOn, source: item.source,\n recipe: pickedRecipe, title: hudPicked === '' ? hudOn : hudPicked }\n }\n\n function openEditor() {\n const asset = itemAsset()\n if (scene === null && asset === null) return\n if (asset !== null) {\n setVoxel(null)\n const ids = asset.recipe === null ? []\n : (asset.recipe.textureIds || []).filter((id) => decoded[id] !== undefined)\n if (ids.length === 0) {\n setEdit(null)\n setItemMsg(asset.title + ' 还没有解码成功的贴图,改不了')\n return\n }\n setItemMsg(null)\n setEditMsg(null)\n setUndo([])\n painting.active = false\n setEdit(makeEdit(ids, widestTexture(ids, {}), {}))\n return\n }\n if (scene.kind === 'biome' || scene.kind === 'structure') { openVoxel(); return }\n if (scene.kind !== 'block' && scene.kind !== 'entity') {\n setEditMsg('这个类型还不能改:' + String(scene.kind))\n return\n }\n setVoxel(null)\n const ids = (scene.textureIds || []).filter((id) => decoded[id] !== undefined)\n if (ids.length === 0) {\n setEdit(null)\n setEditMsg('这个资产没有解码成功的贴图,改不了')\n return\n }\n setEditMsg(null)\n setUndo([])\n painting.active = false\n const byTexture = facesForScene()\n setEdit(makeEdit(ids, widestTexture(ids, byTexture), byTexture))\n }\n\n function closeEditor() {\n painting.active = false\n setEdit(null)\n setUndo([])\n setEditMsg(null)\n }\n\n function undoOnce() {\n if (edit === null || undo.length === 0) return\n const previous = undo[undo.length - 1]\n setUndo(undo.slice(0, undo.length - 1))\n setEdit(Object.assign({}, edit, { pixels: new Uint8ClampedArray(previous) }))\n }\n\n function pushUndo() {\n if (edit === null || edit.pixels === undefined) return\n const next = undo.concat([new Uint8ClampedArray(edit.pixels)])\n setUndo(next.length > EDIT_UNDO ? next.slice(next.length - EDIT_UNDO) : next)\n }\n\n function pixelAt(event, target) {\n return texturePoint(event.currentTarget.getBoundingClientRect(), target,\n event.clientX, event.clientY)\n }\n\n function applyTool(x, y, first) {\n if (edit === null || edit.pixels === undefined) return\n const w = edit.w, h = edit.h, pixels = edit.pixels\n if (x < 0 || y < 0 || x >= w || y >= h) return\n if (edit.tool === 'picker') {\n const picked = readPixel(pixels, w, x, y)\n setEdit(Object.assign({}, edit, { color: picked[3] === 0 ? null : picked }))\n return\n }\n if (first === true) pushUndo()\n if (edit.tool === 'fill') {\n const from = readPixel(pixels, w, x, y)\n const to = edit.color || [0, 0, 0, 255]\n if (!sameColour(from, to)) {\n const stack = [[x, y]]\n const visited = {}\n while (stack.length > 0) {\n const point = stack.pop()\n const cx = point[0], cy = point[1]\n if (cx < 0 || cy < 0 || cx >= w || cy >= h) continue\n const key = cy * w + cx\n if (visited[key] === true) continue\n visited[key] = true\n if (!sameColour(readPixel(pixels, w, cx, cy), from)) continue\n writePixel(pixels, w, cx, cy, to)\n stack.push([cx + 1, cy], [cx - 1, cy], [cx, cy + 1], [cx, cy - 1])\n }\n }\n } else if (edit.tool === 'eraser') {\n writePixel(pixels, w, x, y, [0, 0, 0, 0])\n } else {\n writePixel(pixels, w, x, y, edit.color || [0, 0, 0, 255])\n }\n setEdit(Object.assign({}, edit))\n }\n\n function saveEdit() {\n if (edit === null || edit.pixels === undefined || editBusy) return\n if (editScratch === null) { setEditMsg('画布还没准备好'); return }\n setEditBusy(true)\n setEditMsg(null)\n editScratch.width = edit.w\n editScratch.height = edit.h\n const paint = editScratch.getContext('2d')\n const frame = paint.createImageData(edit.w, edit.h)\n frame.data.set(edit.pixels)\n paint.putImageData(frame, 0, 0)\n const url = editScratch.toDataURL('image/png')\n const base64 = url.slice(url.indexOf(',') + 1)\n host.call('atlas.saveTexture', {\n root: root, project: activeProjectId(), path: edit.ids[edit.index], base64: base64,\n }).then((result) => {\n setEditBusy(false)\n if (result === null || result === undefined || result.error !== undefined) {\n setEditMsg('保存失败:' + String(result && result.error ? result.error : '没有返回结果'))\n return\n }\n const target = edit.ids[edit.index]\n decoded[target] = { width: edit.w, height: edit.h, data: edit.pixels }\n delete failedTex[target]\n lastDrawKey = ''\n setEdit(Object.assign({}, edit, { palette: paletteOf(edit.pixels, 32) }))\n setEditMsg('已保存 ' + target.split('/').pop() + '(' + result.bytes + ' 字节)')\n setEditTick(editTick + 1)\n }).catch((error) => {\n setEditBusy(false)\n setEditMsg('保存失败:' + String(error && error.message ? error.message : error))\n })\n }\n\n function editorCard() {\n if (edit === null && editMsg === null) return null\n const rows = []\n if (edit === null) {\n rows.push(React.createElement('div', { className: 'mcart-bar', key: 'lost' },\n React.createElement('span', { className: 'mcart-bad mcart-grow' }, String(editMsg)),\n React.createElement('button', { className: 'mcart-btn', type: 'button',\n onClick: () => setEditMsg(null) }, '知道了')))\n return React.createElement('div', { className: 'mcart-card', key: 'editor' }, rows)\n }\n rows.push(React.createElement('div', { className: 'mcart-bar', key: 'head' },\n React.createElement('span', { className: 'mcart-title' }, '改贴图'),\n React.createElement('span', { className: 'mcart-sub' },\n faceLabel(edit.faces) + ' · ' + edit.w + '×' + edit.h)))\n if (edit.ids.length > 1) {\n rows.push(React.createElement('div', { className: 'mcart-tabs', key: 'tabs' },\n edit.ids.map((id, index) => React.createElement('button', {\n key: id, type: 'button', className: 'mcart-btn mcart-tool',\n 'data-on': index === edit.index ? '1' : '0',\n onClick: () => {\n setUndo([]); setEditMsg(null); painting.active = false\n setEdit(makeEdit(edit.ids, index, edit.facesByTexture))\n },\n }, faceLabel((edit.facesByTexture || {})[id]) + ' · ' + id.split('/').pop().replace(/\\.png$/, '')))))\n }\n rows.push(React.createElement('div', { className: 'mcart-tools', key: 'tools' },\n [['pencil', '铅笔'], ['eraser', '橡皮'], ['fill', '油漆桶'], ['picker', '吸管']].map((pair) =>\n React.createElement('button', {\n key: pair[0], type: 'button', className: 'mcart-btn mcart-tool',\n 'data-on': edit.tool === pair[0] ? '1' : '0',\n onClick: () => setEdit(Object.assign({}, edit, { tool: pair[0] })),\n }, pair[1])),\n React.createElement('button', { className: 'mcart-btn', type: 'button', disabled: undo.length === 0,\n onClick: undoOnce }, '撤销')))\n const current = edit.color || [0, 0, 0, 255]\n const inPalette = edit.palette.some((c) => sameColour(c, current))\n rows.push(React.createElement('div', { className: 'mcart-bar', key: 'current' },\n React.createElement('input', {\n type: 'color', className: 'mcart-picker', value: hexOf(current),\n title: '取色板以外随便挑',\n onChange: (event) => setEdit(Object.assign({}, edit,\n { color: fromHex(event.target.value, current[3]) })),\n }),\n React.createElement('button', {\n className: 'mcart-btn', type: 'button', disabled: inPalette,\n title: '把当前颜色加进色板',\n onClick: () => setEdit(Object.assign({}, edit,\n { palette: addToPalette(edit.palette, current, EDIT_PALETTE) })),\n }, inPalette ? '已在色板' : '+ 色板'),\n React.createElement('input', {\n type: 'range', className: 'mcart-alpha', min: 0, max: 255, value: current[3],\n onChange: (event) => setEdit(Object.assign({}, edit,\n { color: [current[0], current[1], current[2], Number(event.target.value)] })),\n }),\n React.createElement('span', { className: 'mcart-note' }, hexOf(current)),\n React.createElement('span', { className: 'mcart-note mcart-grow' },\n '不透明度 ' + current[3] + ' · 撤销 ' + undo.length)))\n rows.push(React.createElement('div', { className: 'mcart-swatches', key: 'swatches' },\n edit.palette.map((colour) => React.createElement('button', {\n key: colour.join(','), type: 'button', className: 'mcart-swatch',\n style: { background: cssColour(colour) }, title: cssColour(colour),\n onClick: () => setEdit(Object.assign({}, edit, { color: colour })),\n }))))\n rows.push(React.createElement('canvas', {\n key: 'canvas', className: 'mcart-edit-canvas',\n ref: (node) => setEditCanvas(node),\n onPointerDown: (event) => {\n const point = pixelAt(event, edit)\n if (point === null) return\n painting.active = true\n if (event.currentTarget.setPointerCapture) {\n try { event.currentTarget.setPointerCapture(event.pointerId) } catch (error) { }\n }\n applyTool(point[0], point[1], true)\n },\n onPointerMove: (event) => {\n if (!painting.active) return\n const point = pixelAt(event, edit)\n if (point !== null) applyTool(point[0], point[1], false)\n },\n onPointerUp: () => { painting.active = false },\n onPointerLeave: () => { painting.active = false },\n }))\n rows.push(React.createElement('div', { className: 'mcart-bar', key: 'foot' },\n React.createElement('button', { className: 'mcart-btn', type: 'button', disabled: editBusy,\n onClick: saveEdit }, editBusy ? '保存中…' : '保存'),\n React.createElement('button', { className: 'mcart-btn', type: 'button', onClick: closeEditor }, '关闭'),\n React.createElement('span', { className: 'mcart-note mcart-grow' },\n (edit.ids[edit.index] || '').split('/').pop())))\n rows.push(React.createElement('div', { className: 'mcart-bar', key: 'msg' },\n React.createElement('span', { className: 'mcart-note mcart-grow' }, editMsg === null ? '' : String(editMsg))))\n return React.createElement('div', { className: 'mcart-card', key: 'editor' }, rows)\n }\n\n React.useEffect(() => {\n if (edit === null || edit.pixels === undefined) return\n if (editCanvas === null || editScratch === null) return\n editScratch.width = edit.w\n editScratch.height = edit.h\n const source = editScratch.getContext('2d')\n const frame = source.createImageData(edit.w, edit.h)\n frame.data.set(edit.pixels)\n source.putImageData(frame, 0, 0)\n const zoom = Math.max(2, Math.min(EDIT_ZOOM, 320 / Math.max(edit.w, edit.h)))\n editCanvas.width = edit.w * zoom\n editCanvas.height = edit.h * zoom\n const view = editCanvas.getContext('2d')\n view.imageSmoothingEnabled = false\n view.clearRect(0, 0, editCanvas.width, editCanvas.height)\n view.drawImage(editScratch, 0, 0, editCanvas.width, editCanvas.height)\n view.strokeStyle = 'rgba(127,127,127,.30)'\n view.lineWidth = 1\n for (let x = 0; x <= edit.w; x++) {\n view.beginPath(); view.moveTo(x * zoom + 0.5, 0); view.lineTo(x * zoom + 0.5, editCanvas.height); view.stroke()\n }\n for (let y = 0; y <= edit.h; y++) {\n view.beginPath(); view.moveTo(0, y * zoom + 0.5); view.lineTo(editCanvas.width, y * zoom + 0.5); view.stroke()\n }\n })\n\n function complain() {\n if (scene !== null) {\n setPending({ path: scene.ref || '', title: scene.title || '' })\n return\n }\n const asset = itemAsset()\n if (asset === null) return\n const path = asset.recipe === null || asset.recipe.modelPath === undefined\n ? '' : String(asset.recipe.modelPath)\n if (path === '') {\n setItemMsg(asset.title + ' 在参考的 jar 里,没有可以引用的文件')\n return\n }\n setItemMsg(null)\n setPending({ path: path, title: asset.title })\n }\n\n function settingsCard() {\n if (settings === null) return null\n const rows = []\n if (settings.loading === true) {\n rows.push(React.createElement('div', { className: 'mcart-note', key: 'l' }, '读取设置…'))\n } else if (settings.error !== undefined) {\n rows.push(React.createElement('div', { className: 'mcart-err', key: 'e' }, String(settings.error)))\n } else {\n rows.push(React.createElement('div', { className: 'mcart-bar', key: 'head' },\n React.createElement('span', { className: 'mcart-title' }, '设置'),\n React.createElement('span', { className: 'mcart-sub' }, settings.title || settings.project || ''),\n ))\n rows.push(React.createElement('div', { className: 'mcart-note', key: 'reflabel' }, '参考目录'))\n rows.push(React.createElement('div', { className: 'mcart-path', key: 'refpath' }, settings.directory || '(未设置)'))\n const dirButtons = [\n React.createElement('button', { className: 'mcart-btn', type: 'button', key: 'pick', disabled: picking,\n onClick: () => runPicker(settings.directory || root, (picked) => patchSettings({ directory: picked }, true)) },\n picking ? '对话框已打开…' : '选择目录…'),\n ]\n rows.push(React.createElement('div', { className: 'mcart-bar', key: 'dirbuttons' }, dirButtons))\n if (settings.directory !== '' && settings.directory !== undefined) {\n if (settings.scanError !== null && settings.scanError !== undefined) {\n rows.push(React.createElement('div', { className: 'mcart-err', key: 'scanerr' }, '没读到里面的资源:' + String(settings.scanError)))\n } else {\n rows.push(React.createElement('div', { className: 'mcart-hint', key: 'shape' },\n '识别为 ' + (settings.shape || '?') + ' · ' + (settings.textures || 0) + ' 张贴图 · '\n + ((settings.sources || []).length) + ' 个资源文件'))\n }\n }\n for (const hint of (settings.directory === '' || settings.directory === undefined ? (settings.detected || []) : []).slice(0, 3)) {\n rows.push(React.createElement('div', { className: 'mcart-bar', key: 'det' + hint },\n React.createElement('span', { className: 'mcart-note mcart-grow' }, '检测到 ' + hint),\n React.createElement('button', { className: 'mcart-btn', type: 'button',\n onClick: () => patchSettings({ directory: hint }, true) }, '用它'),\n ))\n }\n if ((settings.directory === '' || settings.directory === undefined) && (settings.detected || []).length === 0) {\n rows.push(React.createElement('div', { className: 'mcart-hint', key: 'nodir' },\n '没设置就不参考外部资源。可以指向 .minecraft、某个版本目录、mods 目录,或单个 jar。'))\n }\n\n rows.push(React.createElement('label', { className: 'mcart-switch', key: 'gen' },\n React.createElement('input', { type: 'checkbox', checked: settings.includeGenerated !== false,\n onChange: (event) => patchSettings({ includeGenerated: event.target.checked }) }),\n React.createElement('span', null, '参考本目录已生成内容'),\n ))\n rows.push(React.createElement('div', { className: 'mcart-hint', key: 'genhint' }, '有助于保持生成风格一致'))\n\n rows.push(React.createElement('label', { className: 'mcart-switch', key: 'mods' },\n React.createElement('input', { type: 'checkbox', checked: settings.includeMods !== false,\n onChange: (event) => patchSettings({ includeMods: event.target.checked }) }),\n React.createElement('span', null, '参考目录中的模组资源文件'),\n ))\n\n if (settings.includeMods !== false) {\n const mods = settings.mods || []\n const missing = settings.directory === '' || settings.directory === undefined\n const message = missing ? '还没指定参考目录'\n : (mods.length === 0 ? '参考目录里没有找到任何资源命名空间' : ('发现 ' + mods.length + ' 个命名空间'))\n rows.push(React.createElement('div', { className: 'mcart-bar', key: 'modbar' },\n React.createElement('span', { className: 'mcart-note' }, message),\n mods.length === 0 ? null : React.createElement('button', { className: 'mcart-btn', type: 'button',\n onClick: () => setModsOpen(!modsOpen) }, modsOpen ? '收起 ▴' : '展开 ▾'),\n ))\n if (modsOpen) {\n const list = []\n for (const item of mods) {\n const from = (item.from || []).slice(0, 2).join('、')\n list.push(React.createElement('label', { className: 'mcart-switch', key: item.name },\n React.createElement('input', { type: 'checkbox', checked: item.on === true,\n onChange: (event) => toggleMod(item.name, event.target.checked) }),\n React.createElement('span', { className: 'mcart-grow' }, item.name,\n from === '' ? null : React.createElement('span', { className: 'mcart-id' }, from)),\n React.createElement('span', { className: 'mcart-id' }, item.count + ' 张贴图'),\n ))\n }\n rows.push(React.createElement('div', { className: 'mcart-mods', key: 'modlist' }, list))\n }\n }\n\n rows.push(React.createElement('div', { className: 'mcart-bar', key: 'foot' },\n React.createElement('span', { className: 'mcart-note mcart-grow' }, settings.file || ''),\n React.createElement('span', { className: 'mcart-bad' }, saveState === null ? '' : saveState),\n ))\n if (savedSettings !== null && savedSettings.path !== '') {\n rows.push(React.createElement('div', { className: 'mcart-bar', key: 'tell' },\n React.createElement('span', { className: 'mcart-note mcart-grow' },\n '已保存,要让 AI 按新设置重做的话:'),\n React.createElement('button', { className: 'mcart-btn', type: 'button',\n onClick: () => setPending({ path: savedSettings.path, note: savedSettings.note }) },\n '告诉 AI'),\n React.createElement('button', { className: 'mcart-btn', type: 'button',\n onClick: () => setSavedSettings(null) }, '不用了'),\n ))\n }\n }\n return React.createElement('div', { className: 'mcart-card', key: 'settings' }, rows)\n }\n\n if (root === '') {\n const card = []\n card.push(React.createElement('div', { className: 'mcart-note', key: 'hint' }, '未指定资产路径'))\n card.push(React.createElement('div', { className: 'mcart-bar', key: 'input' },\n React.createElement('input', {\n className: 'mcart-input', placeholder: '/path/to/project',\n value: draft, onChange: (event) => setDraft(event.target.value),\n onKeyDown: (event) => { if (event.key === 'Enter' && draft !== '') commitRoot(draft) },\n }),\n React.createElement('button', { className: 'mcart-btn', type: 'button',\n onClick: () => { if (draft !== '') commitRoot(draft) } }, '使用'),\n ))\n\n if (nearby !== null) {\n if (nearby.loading === true) {\n card.push(React.createElement('div', { className: 'mcart-note', key: 'scanning' }, '正在找本会话目录里的项目…'))\n } else if (nearby.error !== null) {\n card.push(React.createElement('div', { className: 'mcart-err', key: 'nerr' }, String(nearby.error)))\n } else if (nearby.projects.length > 0) {\n card.push(React.createElement('div', { className: 'mcart-note', key: 'found' }, '在 ' + nearby.cwd + ' 里找到:'))\n const rows = []\n for (const project of nearby.projects) {\n rows.push(React.createElement('div', { className: 'mcart-row', key: project.root + '/' + project.id },\n React.createElement('span', { className: 'mcart-grow' }, project.title,\n project.title === project.id ? null : React.createElement('span', { className: 'mcart-id' }, project.id)),\n React.createElement('button', { className: 'mcart-btn', type: 'button',\n onClick: () => openNearby(project.root, project.id) }, '打开'),\n ))\n }\n card.push(React.createElement('div', { className: 'mcart-projects', key: 'projects' }, rows))\n } else {\n card.push(React.createElement('div', { className: 'mcart-note', key: 'none' },\n '在 ' + nearby.cwd + ' 里没找到 mc 资产项目。'))\n }\n }\n\n card.push(React.createElement('div', { className: 'mcart-bar', key: 'buttons' },\n React.createElement('button', { className: 'mcart-btn', type: 'button', disabled: picking,\n onClick: pickDirectory }, picking ? '对话框已打开…' : '选择目录…'),\n nearby !== null && nearby.cwd !== '' ? React.createElement('button', { className: 'mcart-btn', type: 'button',\n onClick: () => commitRoot(nearby.cwd) }, '用本会话目录') : null,\n ))\n\n if (notice !== null) card.push(React.createElement('div', { className: 'mcart-bad', key: 'notice' }, String(notice)))\n if (failure !== null) card.push(React.createElement('div', { className: 'mcart-err', key: 'failure' }, String(failure)))\n return React.createElement('div', { className: 'mcart-root' },\n React.createElement('div', { className: 'mcart-bar' },\n React.createElement('span', { className: 'mcart-title' }, 'MC 资产'),\n React.createElement('span', { className: 'mcart-sub' }, inRightColumn ? '右侧栏' : '中央面板'),\n ),\n React.createElement('div', { className: 'mcart-card' }, card),\n )\n }\n\n const projects = index === null ? [] : (index.projects || [])\n const menu = []\n for (const project of projects) {\n menu.push(React.createElement('div', { className: 'mcart-group', key: 'p' + project.id }, project.title))\n for (const category of CATEGORIES) {\n const items = (project.items && project.items[category.key]) || []\n if (items.length === 0) continue\n menu.push(React.createElement('div', { className: 'mcart-group', key: 'p' + project.id + category.key },\n category.label + ' · ' + items.length))\n for (const item of items) {\n const on = choice !== null && choice.project === project.id && choice.kind === category.key && choice.id === item.id\n menu.push(React.createElement('button', {\n key: project.id + '/' + category.key + '/' + item.id, type: 'button',\n className: 'mcart-item', 'data-on': on ? '1' : '0',\n onClick: () => open({ project: project.id, kind: category.key, id: item.id }, root),\n }, item.title,\n item.title === item.id ? null : React.createElement('span', { className: 'mcart-id' }, item.id)))\n }\n }\n }\n\n const stage = []\n const animRows = []\n if (scene !== null) {\n const ids = scene.textureIds || []\n const okCount = ids.filter((id) => decoded[id] !== undefined).length\n const bad = ids.filter((id) => failedTex[id] === true).length\n stage.push(React.createElement('div', { className: 'mcart-bar', key: 'row' },\n React.createElement('span', { className: 'mcart-note' },\n scene.quads.length + ' 面 · 贴图 ' + okCount + '/' + ids.length),\n React.createElement('button', { className: 'mcart-btn', type: 'button',\n onClick: () => { setYaw(0.72); setPitch(0.34); setZoom(1) } }, '重置'),\n React.createElement('button', { className: 'mcart-btn', type: 'button', onClick: openEditor }, '✎ 手动修改'),\n React.createElement('button', { className: 'mcart-btn', type: 'button', onClick: complain }, '@ 提意见'),\n ))\n if (bad > 0) stage.push(React.createElement('div', { className: 'mcart-bad', key: 'bad' }, bad + ' 张贴图解码失败'))\n if (scene.errors && scene.errors.length > 0) {\n stage.push(React.createElement('div', { className: 'mcart-bad', key: 'notes' }, scene.errors.join(';')))\n }\n const animIndex = scene === null ? {} : (scene.animations || {})\n for (const id of Object.keys(animIndex).sort()) {\n const tex = decoded[id]\n const size = tex === undefined ? '还没解码' : (tex.width + '×' + tex.height)\n const strip = tex === undefined ? null\n : stripOf({ width: tex.width, height: tex.height, animation: animIndex[id] })\n const row = animationRow(animIndex[id], animTicks * 50)\n animRows.push(React.createElement('div', { className: 'mcart-hint', key: 'anim:' + id },\n '动画 ' + id + ' · 解码 ' + size + ' · 声明 ' + String(animIndex[id].strip) + ' 行'\n + ' · 判定 ' + (tex === undefined ? '等解码' : (strip === null ? '不是条带 ← 会糊' : '单帧 ' + strip))\n + ' · 现在抽第 ' + String(row) + ' 行'))\n }\n for (const id of (scene === null ? [] : (scene.textureIds || []))) {\n if (animIndex[id] !== undefined) continue\n const tex = decoded[id]\n if (tex === undefined) continue\n if (!(tex.height > tex.width && tex.height % tex.width === 0)) continue\n animRows.push(React.createElement('div', { className: 'mcart-hint', key: 'noanim:' + id },\n '形状像条带但没有动画描述 ' + id + ' · 解码 ' + tex.width + '×' + tex.height + ' ← 会糊'))\n }\n if (scene.palette && scene.palette.length > 0) {\n stage.push(React.createElement('div', { className: 'mcart-bar', key: 'palette' },\n scene.palette.slice(0, 8).map((entry) => React.createElement('span', { className: 'mcart-chip', key: entry.block },\n (entry.label || entry.block) + ' ×' + entry.count)),\n ))\n }\n }\n const pickedRecipe = hudOn === '' || item === null ? null : itemRecipes[itemKey(item.namespace, hudOn)]\n if (scene === null) {\n const asset = itemAsset()\n if (asset !== null) {\n const ids = asset.recipe === null ? [] : (asset.recipe.textureIds || [])\n const okCount = ids.filter((id) => decoded[id] !== undefined).length\n stage.push(React.createElement('div', { className: 'mcart-bar', key: 'itemrow' },\n React.createElement('span', { className: 'mcart-note' },\n '物品 · 贴图 ' + okCount + '/' + ids.length\n + (asset.source === 'project' ? '' : '(参考)')),\n React.createElement('button', { className: 'mcart-btn', type: 'button', onClick: openEditor }, '✎ 手动修改'),\n React.createElement('button', { className: 'mcart-btn', type: 'button', onClick: complain }, '@ 提意见'),\n ))\n }\n }\n const posterRecipe = scene === null && pickedRecipe !== null ? pickedRecipe : null\n const posterSide = Math.max(48, Math.min(size[0], size[1]) - 24)\n stage.push(React.createElement('div', { className: 'mcart-viewport', key: 'viewport' },\n React.createElement('canvas', {\n key: 'canvas', className: 'mcart-canvas',\n style: { height: viewH + 'px' },\n ref: (node) => { canvasGeneration += 1; setCanvas(node) },\n onPointerDown: (event) => {\n drag.active = true\n drag.moved = 0\n drag.x = event.clientX\n drag.y = event.clientY\n if (event.currentTarget.setPointerCapture) {\n try { event.currentTarget.setPointerCapture(event.pointerId) } catch (error) { }\n }\n },\n onPointerMove: (event) => {\n if (!drag.active) {\n if (voxel !== null) {\n const target = voxelTargetAt(event)\n if (target === null) { if (hover !== null) setHover(null) }\n else if (hover === null || hover.cell !== target.cell || hover.face !== target.face) setHover(target)\n }\n return\n }\n const dx = event.clientX - drag.x\n const dy = event.clientY - drag.y\n drag.x = event.clientX\n drag.y = event.clientY\n drag.moved += Math.abs(dx) + Math.abs(dy)\n setYaw((previous) => previous - dx * 0.011)\n setPitch((previous) => Math.max(-1.35, Math.min(1.35, previous + dy * 0.011)))\n },\n onPointerUp: (event) => {\n const dragDistance = drag.moved\n drag.active = false\n if (dragDistance > 4 || voxel === null) return\n voxelClick(event, event.button === 2 ? 'place' : 'remove')\n },\n onContextMenu: (event) => { if (event.preventDefault) event.preventDefault() },\n onPointerLeave: () => { drag.active = false },\n }),\n React.createElement('div', { className: 'mcart-zoom' },\n React.createElement('button', { className: 'mcart-btn mcart-zoombtn', type: 'button',\n title: '放大', onClick: () => zoomBy(1.15) }, '+'),\n React.createElement('button', { className: 'mcart-btn mcart-zoombtn', type: 'button',\n title: '缩小', onClick: () => zoomBy(1 / 1.15) }, '-'),\n React.createElement('button', { className: 'mcart-btn mcart-zoombtn', type: 'button',\n title: '复位视角(旋转与缩放一起)',\n onClick: () => { setYaw(0.72); setPitch(0.34); setZoom(1) } }, '⟲'),\n ),\n posterRecipe === null ? null : React.createElement('canvas', {\n key: 'poster', className: 'mcart-poster',\n width: posterSide, height: posterSide,\n style: { width: posterSide + 'px', height: posterSide + 'px' },\n title: hudPicked,\n ref: (node) => setPosterCanvas(node),\n }),\n hudBar()))\n\n stage.push(React.createElement('div', { className: 'mcart-grip', key: 'grip',\n title: '上下拖动调整 3D + 物品栏的高度,双击复位',\n onDoubleClick: () => setViewH(VIEW_H),\n onPointerDown: (event) => {\n gripDrag.active = true\n gripDrag.y = event.clientY\n gripDrag.origin = viewH\n if (event.currentTarget.setPointerCapture) {\n try { event.currentTarget.setPointerCapture(event.pointerId) } catch (error) { }\n }\n },\n onPointerMove: (event) => {\n if (!gripDrag.active) return\n setViewH(clampViewH(gripDrag.origin + (event.clientY - gripDrag.y)))\n },\n onPointerUp: () => { gripDrag.active = false },\n onPointerCancel: () => { gripDrag.active = false },\n }, React.createElement('span', { className: 'mcart-gripbar' })))\n\n const iconNotes = []\n if (itemMsg !== null) iconNotes.push(String(itemMsg))\n const pickedNote = slotNote(pickedRecipe)\n if (pickedNote !== null) iconNotes.push(pickedNote)\n if (previewItem !== null && previewItem.localOnly === true) {\n iconNotes.push((hudPicked === '' ? previewItem.id : hudPicked)\n + ' 没有方块模型(它是物品,不是方块),所以 3D 里没有东西可看;它的样子就是物品栏里那一格')\n }\n if (choice !== null && choice.kind !== 'block') {\n iconNotes.push(choice.kind === 'entity'\n ? '实体在游戏里没有物品图标(刷怪蛋的蛋色写在代码里,包里没有)'\n : (choice.kind === 'biome' ? '群系' : '多方块结构') + '在游戏里不是物品')\n }\n if (iconNotes.length > 0) {\n stage.push(React.createElement('div', { className: 'mcart-note', key: 'iconnote' }, iconNotes.join(';')))\n }\n\n if (animRows.length > 0) {\n stage.push(React.createElement('div', { className: 'mcart-mods', key: 'anims' }, animRows))\n }\n\n const sources = []\n const allTextures = Object.assign({}, scene === null ? {} : scene.textures,\n ghost === null || ghost.textures === undefined ? {} : ghost.textures)\n for (const recipe of iconRecipesInUse()) {\n if (recipe.textures !== undefined && recipe.textures !== null) Object.assign(allTextures, recipe.textures)\n }\n if (scene !== null || item !== null) {\n for (const id of Object.keys(allTextures)) {\n if (decoded[id] !== undefined) continue\n sources.push(React.createElement('img', {\n key: id, src: allTextures[id], alt: '', style: { display: 'none' },\n ref: (node) => {\n if (node === null || node === undefined) delete imageNodes[id]\n else imageNodes[id] = node\n },\n onLoad: () => setTick(tick + 1),\n onError: () => { failedTex[id] = true; setTick(tick + 1) },\n }))\n }\n }\n\n return React.createElement('div', { className: 'mcart-root' },\n React.createElement('div', { className: 'mcart-bar' },\n React.createElement('span', { className: 'mcart-title' }, 'MC 资产'),\n React.createElement('span', { className: 'mcart-sub' },\n busy ? '加载中…' : (inRightColumn ? '右侧栏' : '中央面板')),\n React.createElement('button', { className: 'mcart-btn mcart-tool', type: 'button',\n title: '物品浏览器:来源、搜索、展示形式都在这里;下面那排物品栏跟着一起筛',\n 'data-on': itemOpen ? '1' : '0',\n onClick: openItems }, itemOpen ? '收起列表' : '物品列表'),\n React.createElement('button', { className: 'mcart-icon', type: 'button', title: '设置',\n onClick: openSettings }, settings === null ? '⚙' : '⚙▾'),\n ),\n settings === null ? null : settingsCard(),\n React.createElement('div', { className: 'mcart-bar' },\n React.createElement('span', { className: 'mcart-path' }, root),\n React.createElement('button', { className: 'mcart-btn', type: 'button',\n onClick: () => { setRoot(''); setIndex(null); setItem(null); setItemOpen(false); setIconPick(null); setPreviewItem(null); setHudPage(1); setScene(null); setSettings(null); setEdit(null); setVoxel(null); setHover(null); setGhost(null) } }, '换项目'),\n React.createElement('button', { className: 'mcart-btn', type: 'button', onClick: () => scan(root, true, null) }, '刷新'),\n ),\n failure === null ? null : React.createElement('div', { className: 'mcart-err' }, String(failure)),\n React.createElement('div', { className: 'mcart-list' }, menu),\n editorCard(),\n voxelCard(),\n itemOpen ? itemCard() : null,\n React.createElement('div', { className: 'mcart-stage' }, stage),\n React.createElement('div', { style: { display: 'none' } },\n React.createElement('canvas', { ref: setScratch, width: 256, height: 256 }),\n React.createElement('canvas', { ref: setEditScratch, width: 16, height: 16 }),\n sources,\n ),\n )\n }\n\n function TabTitle() {\n return React.createElement('span', null, 'MC 资产')\n }\n\n function ReferenceBar(props) {\n const current = usePending()\n const draft = props.useInput !== undefined ? props.useInput((state) => state.draft) : ''\n if (current === null || current.path === '') return null\n const note = current.note === undefined || current.note === null ? '' : String(current.note)\n return React.createElement('div', { className: 'mcart-ref' },\n React.createElement('span', null, '引用给 AI'),\n React.createElement('span', { className: 'mcart-ref-path' }, '@' + current.path),\n note === '' ? null : React.createElement('span', { className: 'mcart-ref-path' }, note),\n React.createElement('button', {\n className: 'mcart-btn', type: 'button',\n onClick: () => {\n const text = draft === undefined || draft === null ? '' : String(draft)\n const base = text.length > 0 ? text.replace(/\\s+$/, '') + ' ' : ''\n const tail = '@' + current.path + (note === '' ? ' ' : ' ' + note)\n if (props.inputActions !== undefined) props.inputActions.setDraft(base + tail + ' ')\n setPending(null)\n },\n }, '插入输入框'),\n React.createElement('button', { className: 'mcart-btn', type: 'button', onClick: () => setPending(null) }, '取消'),\n )\n }\n\n slots.inject('conversation.input.dock', () => slots.register(\n { name: 'conversation.input.dock', id: 'mc-art-ref', order: 15 },\n (props) => React.createElement(ReferenceBar, { useInput: props.useInput, inputActions: props.inputActions }),\n ))\n\n if (inRightColumn) {\n ctx.effect(() => tabs.register({\n id: TAB_ID,\n kind: TAB_ID,\n priority: 'extension',\n title: () => 'MC 资产',\n guide: [{\n id: TAB_ID + '.open',\n order: 40,\n title: () => 'MC 资产预览',\n description: () => '在右侧栏里看这个项目的方块 / 实体 / 群系 / 多方块结构',\n }],\n }))\n slots.inject('sidebar.right.pane.tab', () => slots.register(\n { name: 'sidebar.right.pane.tab', key: TAB_ID },\n (props) => React.createElement(Atlas, { sessionId: props.sessionId }),\n ))\n slots.inject('sidebar.right.pane.tab.title', () => slots.register(\n { name: 'sidebar.right.pane.tab.title', key: TAB_ID },\n () => React.createElement(TabTitle, null),\n ))\n } else {\n slots.inject('main', () => slots.register(\n { name: 'main', key: 'mcarts' },\n () => React.createElement(Atlas, { sessionId: '' }),\n ))\n slots.inject('sidebar.panellist', () => slots.register(\n { name: 'sidebar.panellist', id: 'mcarts', order: 12, label: 'MC 资产' },\n () => React.createElement('span', null, 'MC'),\n ))\n }\n },\n}\n"
13
13
 
14
14
  // 三个垫片,对齐动态插件里注入的那三个名字。
15
15
  function ensureStyles(css) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mc-art-panel",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "MC 资产面板 + 「MC 模组工作室」模式 + mc-mod skill:3D+物品栏、像素/结构编辑器、@ 提意见、GameTest 判定闭环",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -62,7 +62,8 @@
62
62
  "access": "public"
63
63
  },
64
64
  "scripts": {
65
- "prepublishOnly": "node verify-build.mjs && node entry-test.mjs",
66
- "verify": "node verify-build.mjs && node entry-test.mjs"
65
+ "prepublishOnly": "node build.mjs && node verify-build.mjs && node entry-test.mjs",
66
+ "verify": "node verify-build.mjs && node entry-test.mjs",
67
+ "prepare": "node vendor.mjs"
67
68
  }
68
69
  }