dsh-mc-art-panel 0.1.0
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 +98 -0
- package/build.mjs +208 -0
- package/cordis.patch.yml +5 -0
- package/lib/client.js +53 -0
- package/lib/index.js +74 -0
- package/package.json +66 -0
package/README.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# dsh-mc-art-panel
|
|
2
|
+
|
|
3
|
+
MC 资产面板的**真包**形态:装进 profile 就在,不用在会话里发射动态插件。
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
dsh plugin --profile web add /path/to/dsh-mc-art/panel # 或 npm 上的包名
|
|
7
|
+
# 重启 DSH → 右侧栏出现「MC 资产」
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## 它凭什么"装完就在"
|
|
11
|
+
|
|
12
|
+
`package.json` 里三个字段决定一切(形状照 `@deepseek-ai/dsh-*` 的包抄的):
|
|
13
|
+
|
|
14
|
+
```json
|
|
15
|
+
"main": "lib/index.js",
|
|
16
|
+
"exports": { "./client": "./lib/client.js" },
|
|
17
|
+
"dsh": { "bundle": { "patch": "./cordis.patch.yml" }, "client": { "platform": "web", "immediately": true, "inject": [...] } }
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`dsh plugin … add` 把包记进 profile 的 `dsh.profile.bundles`,启动时 `cordis.patch.yml`
|
|
21
|
+
把自己的那一行插进组合——`dsh --profile <名> --dump-config` 里能看到:
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
# == dsh-mc-art-panel
|
|
25
|
+
- id: mcart-panel
|
|
26
|
+
name: dsh-mc-art-panel
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## 同一份源码,两种送达
|
|
30
|
+
|
|
31
|
+
`lib/` 是**生成物**(`node build.mjs`),来源是仓库里的 `tools/mcart-plugin/{host,client}.js`
|
|
32
|
+
——和动态插件用的是同一份源码。这个包只是把两半各包一层壳:
|
|
33
|
+
|
|
34
|
+
| | 动态插件 | 真包 |
|
|
35
|
+
|---|---|---|
|
|
36
|
+
| 宿主 `harness.handle` | 运行时注入 | `lib/index.js` 收成一张表 + 一条 `POST /api/mcart/call` 派发路由 |
|
|
37
|
+
| 客户端 `React` / `host` / `styles` | 运行时注入 | `require('react')` / `fetch` / 插 `<style>` |
|
|
38
|
+
|
|
39
|
+
所以面板的行为改动**只改一处**(那两份源码),两边都跟着变。
|
|
40
|
+
|
|
41
|
+
### 客户端模块的 id 就是包名(踩过一次)
|
|
42
|
+
|
|
43
|
+
`lib/client.js` 里的 `window.__ModuleLoader__.load({ id })`,那个 id **必须是包名**
|
|
44
|
+
(`dsh-mc-art-panel`)。dsh 的 client-modules 按启动图里那一行的 id 去 factories 里找模块,
|
|
45
|
+
找不到只在页面顶上写一行 `Failed to load plugins`:
|
|
46
|
+
|
|
47
|
+
```js
|
|
48
|
+
// dsh-client-modules/lib/client.js
|
|
49
|
+
if (!this.factories.has(id)) throw new Error(`bundle ${url} loaded without registering "${id}" via __ModuleLoader__.load`)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
曾经这里抄成了宿主行的名字 `mcart-panel`:包装得上、`--dump-config` 里有那一行、
|
|
53
|
+
两个入口单独测都过,**页面上却什么都不出现**。现在 id 从 `package.json` 的 `name` 读一份
|
|
54
|
+
(`build.mjs` 里的 `PACKAGE_NAME`),`entry-test.mjs` 与 `serve-check.mjs` 都拿同一个真相来判。
|
|
55
|
+
|
|
56
|
+
### shell 在 Windows 上是 PowerShell,不是 bash
|
|
57
|
+
|
|
58
|
+
面板的宿主半要起 Python、要写二进制贴图,这些都经过 DSH 的 shell 服务。而 DSH 按平台换 shell:
|
|
59
|
+
POSIX 上是 `bash -c`,**Windows 上是 `pwsh … -Command <整串>`**(`dsh-base` 的 `cordis.patch.yml`
|
|
60
|
+
里 bash 那几行在 win32 上 disabled、pwsh 那几行启用)。所以 `$(printf … | base64 -d)`、`rm -f`、
|
|
61
|
+
`mv -f`、`command -v` 一个都不能写死——宿主现在探一次方言,然后按方言拼:
|
|
62
|
+
|
|
63
|
+
| 要做的事 | POSIX | PowerShell |
|
|
64
|
+
|---|---|---|
|
|
65
|
+
| 传一个参数 | `"$(printf %s <base64> | base64 -d)"`(绕开所有引号问题) | `'值'`(单引号里 `''` 表示一个 `'`) |
|
|
66
|
+
| 判断有没有某个命令 | `command -v x` | `(Get-Command 'x' -ErrorAction SilentlyContinue) -ne $null` |
|
|
67
|
+
| 删 / 移 | `rm -f` / `mv -f` | `Remove-Item -Force` / `Move-Item -Force` |
|
|
68
|
+
| 把 base64 解成文件 | `base64 -d < 暂存 > 目标` | `[IO.File]::WriteAllBytes(目标, [Convert]::FromBase64String([IO.File]::ReadAllText(暂存)))` |
|
|
69
|
+
|
|
70
|
+
**图片数据不进命令行**:Windows 的命令行总长上限约 32767 字符,一张 128×128 贴图的 base64
|
|
71
|
+
就有几十 KB。所以 base64 先用 `fs.writeText` 当文本落成一个暂存文件(同一条 sandbox 策略),
|
|
72
|
+
shell 只做"把这个文件解成字节"这一件事——命令行里永远只有路径。
|
|
73
|
+
|
|
74
|
+
`node tools/mcart-plugin/shell-dialect-test.js` 验这件事:它把 shell 桩装成 Windows PowerShell,
|
|
75
|
+
捕获宿主真正发出的命令,然后**逐条交给真的 Windows PowerShell 执行**,最后比对贴图字节;
|
|
76
|
+
`--fault` 让探针谎报 POSIX,要求门禁变红(实测:正常 12 项全绿,谎报 8 项红)。
|
|
77
|
+
|
|
78
|
+
## 门禁
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
node panel/verify-build.mjs # lib/ 与源码去注释后重新生成的结果逐字节比对(挡漂移)
|
|
82
|
+
node panel/entry-test.mjs # 两个入口真的加载/apply/派发;含一次真注入
|
|
83
|
+
node panel/serve-check.mjs --url http://127.0.0.1:3099 --token <token> # 真送达:对着跑着的实例查
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`serve-check.mjs` 管的是前两个门禁够不着的那一层——**装进 profile 之后,浏览器到底收到没有**。
|
|
87
|
+
它对着一个真在跑的实例:读首页启动图里我们那一行 → 取 bundle 真实字节与 `lib/client.js`
|
|
88
|
+
逐字符比对 → 用真浏览器打开页面,要求我们的客户端半留下激活痕迹
|
|
89
|
+
(它 apply 时会插一个 `<style data-plugin="dsh-mc-art-panel">`)且没有 `Failed to load plugins`。
|
|
90
|
+
|
|
91
|
+
临时实例的起法(验证完就该删):
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
dsh plugin --profile mcart-check add /path/to/dsh-mc-art/panel
|
|
95
|
+
# 把 "@deepseek-ai/dsh-web-app" 加进那个 profile 的 package.json 的 dsh.profile.bundles
|
|
96
|
+
dsh --profile mcart-check --port 3099 --no-open # 启动日志里会打带 token 的 URL
|
|
97
|
+
node panel/serve-check.mjs --url http://127.0.0.1:3099 --token <那串 token>
|
|
98
|
+
```
|
package/build.mjs
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 由 tools/mcart-plugin/{host,client}.js 生成这个包的 lib/。
|
|
4
|
+
*
|
|
5
|
+
* 为什么要有这一步:面板要能用**两种方式**送达运行时装起来——
|
|
6
|
+
*
|
|
7
|
+
* 1. 动态插件(会话里 `cordis_define` + `cordis_run`):两半是"函数体字符串",
|
|
8
|
+
* 运行时注入的绑定(harness / React / host / styles …)当参数传进去;
|
|
9
|
+
* 2. 真包(`dsh plugin --profile <名> add` 装进 profile):宿主入口是 ESM 模块,
|
|
10
|
+
* 客户端入口是 `window.__ModuleLoader__.load({ id, factory })` 形式的 bundle。
|
|
11
|
+
*
|
|
12
|
+
* 两种方式的**源码是同一份**(tools/mcart-plugin/ 里那两个注释版文件),
|
|
13
|
+
* 这个脚本只负责把两半各自包成 (2) 需要的外壳:
|
|
14
|
+
*
|
|
15
|
+
* * 宿主:把 `harness.handle(name, fn)` 收成一张表,再用一条 HTTP 路由
|
|
16
|
+
* `POST /api/mcart/call {name,args}` 统一派发——20 个 handler 一个都不用改;
|
|
17
|
+
* * 客户端:`require('react')` 拿 React、`fetch` 实现 `host.call`、
|
|
18
|
+
* `styles.insert` 插 <style>,然后把同一段源码 `new Function(...)` 出来。
|
|
19
|
+
*
|
|
20
|
+
* 于是"同一份代码两种送达"不需要维护两份实现(这是这个仓库最在意的事)。
|
|
21
|
+
* `lib/` 是生成物且要随包发布,所以另有 verify-build.mjs 逐字节比对,防止漂移。
|
|
22
|
+
*/
|
|
23
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
24
|
+
import { spawnSync } from 'node:child_process'
|
|
25
|
+
import { dirname, join } from 'node:path'
|
|
26
|
+
import { fileURLToPath } from 'node:url'
|
|
27
|
+
|
|
28
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
29
|
+
const SOURCES = join(HERE, '..', 'tools', 'mcart-plugin')
|
|
30
|
+
const STRIPPER = join(HERE, '..', 'tools', 'strip_comments.py')
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 包名 = 客户端模块的**注册 id**。
|
|
34
|
+
*
|
|
35
|
+
* 这条不能抄错:dsh 的 client-modules 按启动图里那一行的 id 去 factories 里找模块
|
|
36
|
+
* (`register` 存 `stripClientSuffix(registration.id)`,`arrive` 里
|
|
37
|
+
* `if (!this.factories.has(id)) throw ... loaded without registering "${id}"`),
|
|
38
|
+
* 而图里的 id 就是包名。曾经这里写成宿主行的名字 `mcart-panel`,结果包能装、
|
|
39
|
+
* 客户端那一半永远 "import failed",页面上什么都不出现。
|
|
40
|
+
* 所以包名只从 package.json 读一份,绝不手写。
|
|
41
|
+
*/
|
|
42
|
+
export const PACKAGE_NAME = JSON.parse(readFileSync(join(HERE, 'package.json'), 'utf8')).name
|
|
43
|
+
if (typeof PACKAGE_NAME !== 'string' || PACKAGE_NAME === '') {
|
|
44
|
+
throw new Error('panel/package.json 里没有有效的 name,客户端 bundle 不知道该注册成什么 id')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 去掉注释:复用仓库里那一份实现(它是"注释怎么算"的唯一真相)。 */
|
|
48
|
+
export function strip(file) {
|
|
49
|
+
for (const python of ['python3', 'python']) {
|
|
50
|
+
const done = spawnSync(python, [STRIPPER, file], { encoding: 'utf8' })
|
|
51
|
+
if (done.error !== undefined && done.error.code === 'ENOENT') continue
|
|
52
|
+
if (done.status !== 0) throw new Error(`${python} ${STRIPPER} 失败:${done.stderr}`)
|
|
53
|
+
return done.stdout
|
|
54
|
+
}
|
|
55
|
+
throw new Error('没有 python3 / python,跑不了 strip_comments.py')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 宿主入口:ESM 模块 = Cordis 插件。 */
|
|
59
|
+
export function hostModule(hostSource) {
|
|
60
|
+
return `// 生成物:由 panel/build.mjs 从 tools/mcart-plugin/host.js 生成 —— 不要手改。
|
|
61
|
+
// 改行为请改那份源码,然后 \`node panel/build.mjs\`(verify-build.mjs 会挡住漂移)。
|
|
62
|
+
const SOURCE = ${JSON.stringify(hostSource)}
|
|
63
|
+
|
|
64
|
+
export const name = 'mcart-panel'
|
|
65
|
+
// 宿主半自己用 ctx.get(...) 取 fs / sessions / shell / webServer,不硬依赖任何服务。
|
|
66
|
+
export const inject = []
|
|
67
|
+
|
|
68
|
+
export function apply(ctx) {
|
|
69
|
+
// 动态插件里有 harness.handle;真包里没有,所以这里做一个同形状的垫片:
|
|
70
|
+
// 收成一张表,再由下面那条路由统一派发。host.js 一行都不用改。
|
|
71
|
+
const handlers = {}
|
|
72
|
+
const harness = {
|
|
73
|
+
handle(method, handler) {
|
|
74
|
+
handlers[method] = handler
|
|
75
|
+
return () => { delete handlers[method] }
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
const plugin = new Function('harness', 'console', 'TextEncoder', 'btoa', 'atob', SOURCE)(
|
|
79
|
+
harness, console, TextEncoder, btoa, atob)
|
|
80
|
+
if (plugin === null || typeof plugin !== 'object' || typeof plugin.apply !== 'function') {
|
|
81
|
+
throw new Error('mcart 宿主源码没有返回一个带 apply 的插件')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 一条路由,全部信道。客户端那边 host.call(name, args) 走的就是它。
|
|
85
|
+
const MAX_BYTES = 16 * 1024 * 1024
|
|
86
|
+
ctx.inject(['webServer'], (httpCtx) => {
|
|
87
|
+
httpCtx.webServer.register({
|
|
88
|
+
kind: 'exact',
|
|
89
|
+
path: '/api/mcart/call',
|
|
90
|
+
handler: (req, res) => {
|
|
91
|
+
function send(status, payload) {
|
|
92
|
+
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
|
|
93
|
+
res.end(JSON.stringify(payload))
|
|
94
|
+
}
|
|
95
|
+
if ((req.method ?? 'GET').toUpperCase() !== 'POST') {
|
|
96
|
+
send(405, { error: '这条路由只收 POST' })
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
const chunks = []
|
|
100
|
+
let size = 0
|
|
101
|
+
req.on('data', (chunk) => {
|
|
102
|
+
size += chunk.length
|
|
103
|
+
if (size > MAX_BYTES) { req.destroy(); return }
|
|
104
|
+
chunks.push(chunk)
|
|
105
|
+
})
|
|
106
|
+
req.on('end', async () => {
|
|
107
|
+
let request
|
|
108
|
+
try {
|
|
109
|
+
request = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')
|
|
110
|
+
} catch (error) {
|
|
111
|
+
send(400, { error: '请求不是 JSON:' + String(error && error.message ? error.message : error) })
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
const name = typeof request.name === 'string' ? request.name : ''
|
|
115
|
+
const handler = handlers[name]
|
|
116
|
+
if (typeof handler !== 'function') {
|
|
117
|
+
send(404, { error: '宿主没有这个方法:' + name })
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
const value = await handler(request.args === undefined ? null : request.args)
|
|
122
|
+
send(200, value === undefined ? null : value)
|
|
123
|
+
} catch (error) {
|
|
124
|
+
send(500, { error: String(error && error.message ? error.message : error) })
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
req.on('error', () => { send(400, { error: '请求读失败' }) })
|
|
128
|
+
},
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
return plugin.apply(ctx)
|
|
133
|
+
}
|
|
134
|
+
`
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** 客户端入口:__ModuleLoader__ 形式的 bundle。 */
|
|
138
|
+
export function clientBundle(clientSource) {
|
|
139
|
+
return `// 生成物:由 panel/build.mjs 从 tools/mcart-plugin/client.js 生成 —— 不要手改。
|
|
140
|
+
window.__ModuleLoader__.load({
|
|
141
|
+
// 必须是包名:client-modules 拿启动图里那一行的 id 来 factories 里找它。
|
|
142
|
+
id: ${JSON.stringify(PACKAGE_NAME)},
|
|
143
|
+
factory: function (require) {
|
|
144
|
+
var module = { exports: {} }
|
|
145
|
+
var exports = module.exports
|
|
146
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
147
|
+
|
|
148
|
+
// 真包世界里 React 是 require 来的;动态插件世界里它是注入的绑定。
|
|
149
|
+
var React = require('react')
|
|
150
|
+
var SOURCE = ${JSON.stringify(clientSource)}
|
|
151
|
+
|
|
152
|
+
// 三个垫片,对齐动态插件里注入的那三个名字。
|
|
153
|
+
function ensureStyles(css) {
|
|
154
|
+
var id = ${JSON.stringify(PACKAGE_NAME + ':styles')}
|
|
155
|
+
if (typeof document === 'undefined') return function () {}
|
|
156
|
+
var tag = document.querySelector('style[data-plugin-css=' + JSON.stringify(id) + ']')
|
|
157
|
+
if (tag === null) {
|
|
158
|
+
tag = document.createElement('style')
|
|
159
|
+
tag.dataset.plugin = ${JSON.stringify(PACKAGE_NAME)}
|
|
160
|
+
tag.dataset.pluginCss = id
|
|
161
|
+
tag.textContent = css
|
|
162
|
+
document.head.appendChild(tag)
|
|
163
|
+
}
|
|
164
|
+
return function () { if (tag !== null && tag.parentNode !== null) tag.parentNode.removeChild(tag) }
|
|
165
|
+
}
|
|
166
|
+
var styles = { insert: ensureStyles }
|
|
167
|
+
var host = {
|
|
168
|
+
call: function (method, args) {
|
|
169
|
+
return fetch('/api/mcart/call', {
|
|
170
|
+
method: 'POST',
|
|
171
|
+
headers: { 'Content-Type': 'application/json' },
|
|
172
|
+
body: JSON.stringify({ name: String(method), args: args === undefined ? null : args }),
|
|
173
|
+
}).then(function (response) {
|
|
174
|
+
return response.json().then(function (value) { return value })
|
|
175
|
+
}).catch(function (error) {
|
|
176
|
+
// 不抛:面板里到处都是 failureOf(reply),返回一个 {error} 更好处理。
|
|
177
|
+
return { error: '面板与宿主的信道断了:' + String(error && error.message ? error.message : error) }
|
|
178
|
+
})
|
|
179
|
+
},
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
var plugin = new Function('React', 'host', 'styles', 'console', SOURCE)(React, host, styles, console)
|
|
183
|
+
if (plugin === null || typeof plugin !== 'object' || typeof plugin.apply !== 'function') {
|
|
184
|
+
throw new Error('mcart 客户端源码没有返回一个带 apply 的插件')
|
|
185
|
+
}
|
|
186
|
+
exports.name = plugin.name === undefined ? ${JSON.stringify(PACKAGE_NAME)} : plugin.name
|
|
187
|
+
exports.inject = plugin.inject === undefined ? [] : plugin.inject
|
|
188
|
+
exports.apply = plugin.apply
|
|
189
|
+
return module.exports
|
|
190
|
+
},
|
|
191
|
+
})
|
|
192
|
+
`
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function build() {
|
|
196
|
+
const host = strip(join(SOURCES, 'host.js'))
|
|
197
|
+
const client = strip(join(SOURCES, 'client.js'))
|
|
198
|
+
mkdirSync(join(HERE, 'lib'), { recursive: true })
|
|
199
|
+
writeFileSync(join(HERE, 'lib', 'index.js'), hostModule(host))
|
|
200
|
+
writeFileSync(join(HERE, 'lib', 'client.js'), clientBundle(client))
|
|
201
|
+
return { host, client }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (process.argv[1] !== undefined && process.argv[1].endsWith('build.mjs')) {
|
|
205
|
+
const out = build()
|
|
206
|
+
process.stdout.write(`已生成 panel/lib/index.js(宿主源码 ${out.host.length} 字符)` +
|
|
207
|
+
` 与 panel/lib/client.js(客户端源码 ${out.client.length} 字符)\n`)
|
|
208
|
+
}
|
package/cordis.patch.yml
ADDED
package/lib/client.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// 生成物:由 panel/build.mjs 从 tools/mcart-plugin/client.js 生成 —— 不要手改。
|
|
2
|
+
window.__ModuleLoader__.load({
|
|
3
|
+
// 必须是包名:client-modules 拿启动图里那一行的 id 来 factories 里找它。
|
|
4
|
+
id: "dsh-mc-art-panel",
|
|
5
|
+
factory: function (require) {
|
|
6
|
+
var module = { exports: {} }
|
|
7
|
+
var exports = module.exports
|
|
8
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
9
|
+
|
|
10
|
+
// 真包世界里 React 是 require 来的;动态插件世界里它是注入的绑定。
|
|
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"
|
|
13
|
+
|
|
14
|
+
// 三个垫片,对齐动态插件里注入的那三个名字。
|
|
15
|
+
function ensureStyles(css) {
|
|
16
|
+
var id = "dsh-mc-art-panel:styles"
|
|
17
|
+
if (typeof document === 'undefined') return function () {}
|
|
18
|
+
var tag = document.querySelector('style[data-plugin-css=' + JSON.stringify(id) + ']')
|
|
19
|
+
if (tag === null) {
|
|
20
|
+
tag = document.createElement('style')
|
|
21
|
+
tag.dataset.plugin = "dsh-mc-art-panel"
|
|
22
|
+
tag.dataset.pluginCss = id
|
|
23
|
+
tag.textContent = css
|
|
24
|
+
document.head.appendChild(tag)
|
|
25
|
+
}
|
|
26
|
+
return function () { if (tag !== null && tag.parentNode !== null) tag.parentNode.removeChild(tag) }
|
|
27
|
+
}
|
|
28
|
+
var styles = { insert: ensureStyles }
|
|
29
|
+
var host = {
|
|
30
|
+
call: function (method, args) {
|
|
31
|
+
return fetch('/api/mcart/call', {
|
|
32
|
+
method: 'POST',
|
|
33
|
+
headers: { 'Content-Type': 'application/json' },
|
|
34
|
+
body: JSON.stringify({ name: String(method), args: args === undefined ? null : args }),
|
|
35
|
+
}).then(function (response) {
|
|
36
|
+
return response.json().then(function (value) { return value })
|
|
37
|
+
}).catch(function (error) {
|
|
38
|
+
// 不抛:面板里到处都是 failureOf(reply),返回一个 {error} 更好处理。
|
|
39
|
+
return { error: '面板与宿主的信道断了:' + String(error && error.message ? error.message : error) }
|
|
40
|
+
})
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
var plugin = new Function('React', 'host', 'styles', 'console', SOURCE)(React, host, styles, console)
|
|
45
|
+
if (plugin === null || typeof plugin !== 'object' || typeof plugin.apply !== 'function') {
|
|
46
|
+
throw new Error('mcart 客户端源码没有返回一个带 apply 的插件')
|
|
47
|
+
}
|
|
48
|
+
exports.name = plugin.name === undefined ? "dsh-mc-art-panel" : plugin.name
|
|
49
|
+
exports.inject = plugin.inject === undefined ? [] : plugin.inject
|
|
50
|
+
exports.apply = plugin.apply
|
|
51
|
+
return module.exports
|
|
52
|
+
},
|
|
53
|
+
})
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// 生成物:由 panel/build.mjs 从 tools/mcart-plugin/host.js 生成 —— 不要手改。
|
|
2
|
+
// 改行为请改那份源码,然后 `node panel/build.mjs`(verify-build.mjs 会挡住漂移)。
|
|
3
|
+
const SOURCE = "const FACE_SHADE = { up: 1.0, down: 0.5, north: 0.8, south: 0.8, east: 0.6, west: 0.6 }\nconst FACE_ORDER = ['down', 'up', 'north', 'south', 'west', 'east']\n\nconst 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\nfunction faceCorners(x1, y1, z1, x2, y2, z2, face) {\n if (face === 'down') return [[x1, y1, z2], [x1, y1, z1], [x2, y1, z1], [x2, y1, z2]]\n if (face === 'up') return [[x1, y2, z1], [x1, y2, z2], [x2, y2, z2], [x2, y2, z1]]\n if (face === 'north') return [[x2, y2, z1], [x2, y1, z1], [x1, y1, z1], [x1, y2, z1]]\n if (face === 'south') return [[x1, y2, z2], [x1, y1, z2], [x2, y1, z2], [x2, y2, z2]]\n if (face === 'west') return [[x1, y2, z1], [x1, y1, z1], [x1, y1, z2], [x1, y2, z2]]\n return [[x2, y2, z2], [x2, y1, z2], [x2, y1, z1], [x2, y2, z1]]\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\nconst FACE_VECTORS = {\n down: [0, -1, 0], up: [0, 1, 0], north: [0, 0, -1],\n south: [0, 0, 1], west: [-1, 0, 0], east: [1, 0, 0],\n}\n\nfunction faceAfter(face, x, y) {\n const vector = FACE_VECTORS[face]\n if (vector === undefined) return face\n let p = vector\n if (x) p = rotateAbout(p, [0, 0, 0], 'x', x)\n if (y) p = rotateAbout(p, [0, 0, 0], 'y', -y)\n for (const name of FACE_ORDER) {\n const want = FACE_VECTORS[name]\n if (Math.abs(p[0] - want[0]) < 1e-6 && Math.abs(p[1] - want[1]) < 1e-6 && Math.abs(p[2] - want[2]) < 1e-6) return name\n }\n return face\n}\n\nfunction rotateQuads(quads, rotation) {\n if (rotation === undefined || rotation === null) return quads\n const x = rotation.x || 0\n const y = rotation.y || 0\n if (x === 0 && y === 0) return quads\n return quads.map((quad) => {\n let p = x ? quad.p.map((point) => rotateAbout(point, [0.5, 0.5, 0.5], 'x', x)) : quad.p\n p = y ? p.map((point) => rotateAbout(point, [0.5, 0.5, 0.5], 'y', -y)) : p\n const face = faceAfter(quad.face, x, y)\n const shade = quad.shaded === false ? 1.0 : (FACE_SHADE[face] === undefined ? quad.shade : FACE_SHADE[face])\n const turned = { p: p, uv: quad.uv, tex: quad.tex, shade: shade, mode: quad.mode,\n face: face, shaded: quad.shaded }\n if (quad.pick !== undefined) turned.pick = quad.pick\n return turned\n })\n}\n\nfunction degenerate(corners) {\n for (let i = 0; i < 4; i++) {\n for (let j = i + 1; j < 4; j++) {\n const a = corners[i], b = corners[j]\n if (a[0] === b[0] && a[1] === b[1] && a[2] === b[2]) return true\n }\n }\n return false\n}\n\nfunction quadsFromElements(elements, report) {\n const quads = []\n for (const element of elements) {\n const x1 = element.from[0], y1 = element.from[1], z1 = element.from[2]\n const x2 = element.to[0], y2 = element.to[1], z2 = element.to[2]\n const rotation = element.rotation\n const emitted = {}\n for (const face of Object.keys(element.faces || {})) {\n const data = element.faces[face]\n if (data === undefined || data.tex === undefined) continue\n let corners = faceCorners(x1, y1, z1, x2, y2, z2, face)\n if (rotation) {\n corners = corners.map((p) => rotateAbout(p, rotation.origin || [0, 0, 0], rotation.axis || 'y', rotation.angle || 0))\n }\n corners = corners.map((p) => [p[0] / 16, p[1] / 16, p[2] / 16])\n if (degenerate(corners)) {\n if (report !== undefined) report('退化面 ' + face + ':四角有重合,已跳过(几何代码写错了)')\n continue\n }\n const uv = data.uv || [0, 0, 16, 16]\n const base = [[uv[0], uv[1]], [uv[0], uv[3]], [uv[2], uv[3]], [uv[2], uv[1]]]\n const steps = Math.trunc(data.rotation || 0) / 90\n const uvs = [0, 1, 2, 3].map((i) => {\n const pick = base[((i - steps) % 4 + 4) % 4]\n return [pick[0] / 16, pick[1] / 16]\n })\n const fingerprint = corners.map((p) => p[0].toFixed(6) + ',' + p[1].toFixed(6) + ',' + p[2].toFixed(6)).sort().join('|')\n + '#' + uvs.map((pair) => pair[0] + ',' + pair[1]).join('|') + '#' + data.tex\n if (emitted[fingerprint] === true) continue\n emitted[fingerprint] = true\n quads.push({ p: corners, uv: uvs, tex: data.tex, shade: element.shade === false ? 1.0 : FACE_SHADE[face],\n mode: 'opaque', face: face, shaded: element.shade !== false })\n }\n }\n return quads\n}\n\nfunction translateQuads(quads, offset) {\n if (!offset || (offset[0] === 0 && offset[1] === 0 && offset[2] === 0)) return quads\n return quads.map((quad) => ({\n p: quad.p.map((p) => [p[0] + offset[0], p[1] + offset[1], p[2] + offset[2]]),\n uv: quad.uv, tex: quad.tex, shade: quad.shade, mode: quad.mode, face: quad.face,\n shaded: quad.shaded,\n }))\n}\n\nfunction cubeElements(pick) {\n const faces = {}\n for (const face of FACE_ORDER) faces[face] = { texture: pick[face], uv: [0, 0, 16, 16] }\n return [{ from: [0, 0, 0], to: [16, 16, 16], faces: faces }]\n}\n\nconst CROSS_ELEMENTS = [\n { from: [0.8, 0, 8], to: [15.2, 16, 8], rotation: { origin: [8, 8, 8], axis: 'y', angle: 45, rescale: true }, shade: false,\n faces: { north: { uv: [0, 0, 16, 16], texture: '#cross' }, south: { uv: [0, 0, 16, 16], texture: '#cross' } } },\n { from: [8, 0, 0.8], to: [8, 16, 15.2], rotation: { origin: [8, 8, 8], axis: 'y', angle: 45, rescale: true }, shade: false,\n faces: { west: { uv: [0, 0, 16, 16], texture: '#cross' }, east: { uv: [0, 0, 16, 16], texture: '#cross' } } },\n]\n\nconst VANILLA_PARENTS = {\n 'block/block': { textures: {}, gui_light: 'side', display: { gui: {\n rotation: [30, 225, 0], translation: [0, 0, 0], scale: [0.625, 0.625, 0.625] } } },\n 'block/cube': { parent: 'block/block', textures: { particle: '#north' },\n elements: cubeElements({ down: '#down', up: '#up', north: '#north',\n south: '#south', west: '#west', east: '#east' }) },\n 'block/cube_all': { parent: 'block/cube', textures: { particle: '#all', down: '#all', up: '#all',\n north: '#all', south: '#all', west: '#all', east: '#all' } },\n 'block/cube_column': { parent: 'block/cube', textures: { particle: '#side', end: '#end', side: '#side',\n down: '#end', up: '#end', north: '#side', south: '#side', west: '#side', east: '#side' } },\n 'block/cube_bottom_top': { parent: 'block/cube', textures: { particle: '#side', bottom: '#bottom',\n top: '#top', side: '#side', down: '#bottom', up: '#top', north: '#side',\n south: '#side', west: '#side', east: '#side' } },\n 'block/cross': { textures: { particle: '#cross' }, elements: CROSS_ELEMENTS },\n 'block/tinted_cross': { parent: 'block/cross', textures: {} },\n}\n\nfunction resolveBlockModel(model, loadParent, resolveTexture) {\n const chain = []\n let node = model\n const seen = {}\n for (let depth = 0; depth < 8 && node !== undefined; depth++) {\n chain.push(node)\n const parent = node.parent\n if (typeof parent !== 'string' || seen[parent] === true) break\n seen[parent] = true\n node = loadParent(parent) || VANILLA_PARENTS[parent] || { textures: {} }\n }\n const textures = {}\n for (let i = chain.length - 1; i >= 0; i--) {\n const table = chain[i].textures\n if (table) for (const key of Object.keys(table)) textures[key] = table[key]\n }\n let elements\n for (const candidate of chain) {\n if (Array.isArray(candidate.elements) && candidate.elements.length > 0) { elements = candidate.elements; break }\n }\n if (elements === undefined) return undefined\n const dereference = (reference) => {\n let name = String(reference === undefined ? '' : reference)\n for (let i = 0; i < 8 && name.charAt(0) === '#'; i++) {\n const next = textures[name.slice(1)]\n if (next === undefined) break\n name = String(next)\n }\n return resolveTexture(name)\n }\n return elements.map((element) => {\n const faces = {}\n for (const face of Object.keys(element.faces || {})) {\n const data = element.faces[face]\n const tex = dereference(data.texture)\n if (tex === undefined) continue\n faces[face] = { tex: tex, uv: data.uv || [0, 0, 16, 16], rotation: data.rotation || 0 }\n }\n return { from: element.from, to: element.to, rotation: element.rotation, shade: element.shade, faces: faces }\n })\n}\n\nconst FOOT_OFFSET = 1.5078125\nconst LIGHT0 = unit([0.2, 1.0, -0.7])\nconst LIGHT1 = unit([-0.2, 1.0, 0.7])\nfunction toWorld(point) { return [point[0] / 16, FOOT_OFFSET - point[1] / 16, -point[2] / 16] }\nfunction entityShade(normal) {\n return 0.4 + 0.6 * (Math.max(0, dot(normal, LIGHT0)) + Math.max(0, dot(normal, LIGHT1)))\n}\nfunction place(point, pivot, rotation) {\n let p = point\n for (const axis of ['z', 'y', 'x']) {\n const degrees = rotation === undefined ? 0 : (rotation[axis] || 0)\n if (degrees) p = rotateAbout(p, [0, 0, 0], axis, degrees)\n }\n return add(p, pivot)\n}\nfunction modelCorners(x1, y1, z1, x2, y2, z2) {\n return { A: [x1, y1, z1], B: [x2, y1, z1], C: [x2, y2, z1], D: [x1, y2, z1],\n E: [x1, y1, z2], F: [x2, y1, z2], G: [x2, y2, z2], H: [x1, y2, z2] }\n}\nconst ENTITY_QUADS = [\n ['right', 'FBCG', (u, v, w, h, d) => [u + d + w, v + d, u + d + w + d, v + d + h]],\n ['left', 'AEHD', (u, v, w, h, d) => [u, v + d, u + d, v + d + h]],\n ['top', 'FEAB', (u, v, w, h, d) => [u + d, v, u + d + w, v + d]],\n ['bottom', 'CDHG', (u, v, w, h, d) => [u + d + w, v + d, u + d + w + w, v]],\n ['front', 'BADC', (u, v, w, h, d) => [u + d, v + d, u + d + w, v + d + h]],\n ['back', 'EFGH', (u, v, w, h, d) => [u + 2 * d + w, v + d, u + 2 * d + w + w, v + d + h]],\n]\n\nfunction entityQuads(spec, texId, mode) {\n const quads = []\n const texW = (spec.tex && spec.tex[0]) || 64\n const texH = (spec.tex && spec.tex[1]) || 32\n for (const part of spec.parts) {\n const pivot = part.pivot || [0, 0, 0]\n const rotation = part.rot || {}\n for (const box of part.boxes) {\n const ox = box.at[0], oy = box.at[1], oz = box.at[2]\n const w = box.w, h = box.h, d = box.d\n const inflate = box.inflate || 0\n const table = modelCorners(ox - inflate, oy - inflate, oz - inflate, ox + w + inflate, oy + h + inflate, oz + d + inflate)\n for (const entry of ENTITY_QUADS) {\n const keys = entry[1]\n const rect = entry[2](box.u, box.v, w, h, d)\n const modelPts = [0, 1, 2, 3].map((i) => place(table[keys.charAt(i)], pivot, rotation))\n const normal = unit(toWorld(cross(sub(modelPts[2], modelPts[1]), sub(modelPts[0], modelPts[1]))))\n const u1 = rect[0], v1 = rect[1], u2 = rect[2], v2 = rect[3]\n const uvs = [[u2, v1], [u1, v1], [u1, v2], [u2, v2]].map((pair) => [pair[0] / texW, pair[1] / texH])\n quads.push({ p: modelPts.map(toWorld), uv: uvs, tex: texId, shade: entityShade(normal), mode: mode })\n }\n }\n }\n return quads\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\nconst BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'\nfunction toBase64(bytes) {\n let out = ''\n let i = 0\n for (; i + 2 < bytes.length; i += 3) {\n const n = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2]\n out += BASE64[(n >> 18) & 63] + BASE64[(n >> 12) & 63] + BASE64[(n >> 6) & 63] + BASE64[n & 63]\n }\n const rest = bytes.length - i\n if (rest === 1) {\n const n = bytes[i] << 16\n out += BASE64[(n >> 18) & 63] + BASE64[(n >> 12) & 63] + '=='\n } else if (rest === 2) {\n const n = (bytes[i] << 16) | (bytes[i + 1] << 8)\n out += BASE64[(n >> 18) & 63] + BASE64[(n >> 12) & 63] + BASE64[(n >> 6) & 63] + '='\n }\n return out\n}\n\nconst SQUOTE = String.fromCharCode(39)\nconst BACKSLASH = String.fromCharCode(92)\nconst PICK_BEGIN = 'MCART_PICK_BEGIN'\nconst PICK_END = 'MCART_PICK_END'\n\nfunction utf16leBase64(text) {\n const bytes = []\n for (let i = 0; i < text.length; i++) {\n const code = text.charCodeAt(i)\n bytes.push(code & 255, (code >> 8) & 255)\n }\n return toBase64(Uint8Array.from(bytes))\n}\n\nfunction base64OfString(text) {\n return toBase64(new TextEncoder().encode(String(text)))\n}\n\nfunction quoteRaw(b64) {\n return SQUOTE + b64 + SQUOTE\n}\n\nfunction shellDecoded(value) {\n return '\"$(printf %s ' + quoteRaw(base64OfString(value)) + ' | base64 -d)\"'\n}\n\nfunction parentOf(path) {\n const normalized = String(path).replace(/[\\\\/]+$/, '')\n const cut = Math.max(normalized.lastIndexOf('/'), normalized.lastIndexOf(BACKSLASH))\n if (cut < 0) return null\n if (cut === 0) return '/'\n const head = normalized.slice(0, cut)\n const isDrive = head.length === 2 && head.charAt(1) === ':'\n return isDrive ? head + BACKSLASH : head\n}\n\nfunction betweenMarkers(text) {\n const raw = String(text === undefined || text === null ? '' : text)\n const begin = raw.indexOf(PICK_BEGIN)\n const end = raw.lastIndexOf(PICK_END)\n if (begin < 0 || end < 0 || end <= begin) return ''\n return raw.slice(begin + PICK_BEGIN.length, end).trim()\n}\n\nfunction beforeStderr(text) {\n const raw = String(text === undefined || text === null ? '' : text)\n const cut = raw.indexOf('[stderr]')\n return (cut >= 0 ? raw.slice(0, cut) : raw).trim()\n}\n\nfunction releaseUnused(referenceBlocks, keep, referenceTextures, referenceAnimations) {\n const doomed = []\n for (const key of Array.from(referenceBlocks.keys())) {\n if (key.indexOf('list:') === 0) continue\n if (keep[key] === true) continue\n doomed.push(key)\n }\n const stillUsed = {}\n for (const key of Array.from(referenceBlocks.keys())) {\n if (key.indexOf('list:') === 0) continue\n if (doomed.indexOf(key) >= 0) continue\n const entry = referenceBlocks.get(key)\n if (entry === undefined || entry.load === undefined || entry.load.textures === undefined) continue\n for (const value of entry.load.textures.values()) stillUsed[value] = true\n }\n let dropped = 0\n for (const key of doomed) {\n const entry = referenceBlocks.get(key)\n if (entry !== undefined && entry.load !== undefined && entry.load.textures !== undefined) {\n for (const value of entry.load.textures.values()) {\n if (stillUsed[value] === true) continue\n referenceTextures.delete(value)\n referenceAnimations.delete(value)\n }\n }\n referenceBlocks.delete(key)\n dropped += 1\n }\n let held = 0\n for (const key of referenceBlocks.keys()) if (key.indexOf('list:') !== 0) held += 1\n return { dropped: dropped, held: held }\n}\n\nreturn {\n apply(ctx) {\n const fs = ctx.get('fs')\n const sessions = ctx.get('sessions')\n const shell = ctx.get('shell')\n const errors = []\n let preloads = new Map()\n let langs = new Map()\n const indexCache = new Map()\n const textureCache = {}\n const referenceTextures = new Map()\n const referenceAnimations = new Map()\n const referenceBlocks = new Map()\n const refSignatures = new Map()\n let hasWslpath = null\n\n function note(message) {\n if (errors.indexOf(message) < 0) errors.push(message)\n }\n\n function policyFor(workspaceRoot) {\n if (typeof workspaceRoot !== 'string' || workspaceRoot === '') return undefined\n return { mode: 'workspace-write', workspaceRoot: workspaceRoot }\n }\n\n function labelFor(namespace, kind, id) {\n const table = langs.get(namespace)\n if (table === undefined) return id\n const tries = []\n if (kind === 'project') tries.push('mc-art.project.' + namespace)\n else if (kind === 'structure') tries.push('mc-art.structure.' + namespace + '.' + id, 'structure.' + namespace + '.' + id)\n else tries.push(kind + '.' + namespace + '.' + id)\n for (const key of tries) {\n const found = table[key]\n if (typeof found === 'string' && found.length > 0) return found\n }\n return id\n }\n\n function rel(root, absolute) {\n const prefix = root.charAt(root.length - 1) === '/' ? root : root + '/'\n return absolute.indexOf(prefix) === 0 ? absolute.slice(prefix.length) : absolute\n }\n\n async function listDir(path) {\n if (fs === undefined) return []\n try {\n const target = await fs.resolve(path)\n const info = await fs.stat(target)\n if (info === undefined || info.type !== 'directory') return []\n return await fs.listDir(target)\n } catch (error) { return [] }\n }\n\n async function readJson(path) {\n if (fs === undefined) return undefined\n try {\n const target = await fs.resolve(path)\n const info = await fs.stat(target)\n if (info === undefined || info.type !== 'file') return undefined\n return JSON.parse(await fs.readText(target))\n } catch (error) { return undefined }\n }\n\n async function statOf(path) {\n if (fs === undefined) return undefined\n try {\n const target = await fs.resolve(path)\n return await fs.stat(target)\n } catch (error) { return undefined }\n }\n\n async function dirSignature(path) {\n const entries = await listDir(path)\n const parts = []\n for (const entry of entries) {\n parts.push(entry.name + ':' + (entry.version === undefined ? '-' : String(entry.version)) + ':' + (entry.size === undefined ? '-' : String(entry.size)))\n }\n parts.sort()\n return parts.join(',')\n }\n\n async function signatureOf(root) {\n const pieces = []\n const top = await listDir(root)\n for (const entry of top) {\n if (entry.type !== 'directory' || entry.name.charAt(0) === '.') continue\n const dir = root + '/' + entry.name\n const atlasInfo = await statOf(dir + '/mc-art.atlas.json')\n pieces.push('A' + entry.name + ':' + (atlasInfo === undefined ? 'none' : String(atlasInfo.version) + ':' + String(atlasInfo.size)))\n const assets = await listDir(dir + '/pack/assets')\n for (const ns of assets) {\n if (ns.type !== 'directory') continue\n const base = dir + '/pack/assets/' + ns.name\n pieces.push(await dirSignature(base + '/blockstates'))\n pieces.push(await dirSignature(base + '/models/block'))\n pieces.push(await dirSignature(base + '/lang'))\n for (const kind of ['block', 'blocks', 'entity', 'item']) pieces.push(await dirSignature(base + '/textures/' + kind))\n }\n }\n return pieces.join('|')\n }\n\n async function textureUrl(path) {\n const info = await statOf(path)\n if (info === undefined || info.type !== 'file') return undefined\n const key = path + '@' + String(info.version) + ':' + String(info.size)\n if (textureCache[key] !== undefined) return textureCache[key]\n try {\n const target = await fs.resolve(path)\n const bytes = await fs.readBytes(target, undefined, 8 * 1024 * 1024)\n const url = 'data:image/png;base64,' + toBase64(bytes)\n textureCache[key] = url\n return url\n } catch (error) { return undefined }\n }\n\n async function textureUrlFor(entry) {\n if (typeof entry !== 'string' || entry === '') return undefined\n if (entry.slice(0, 4) === 'ref:') return referenceTextures.get(entry)\n return await textureUrl(entry)\n }\n\n function animationsFor(ids) {\n const out = {}\n for (const id of ids) {\n const animation = referenceAnimations.get(id)\n if (animation !== undefined) out[id] = animation\n }\n return out\n }\n\n async function preload(dir, namespace) {\n const assets = dir + '/pack/assets/' + namespace\n const models = new Map()\n for (const entry of await listDir(assets + '/models/block')) {\n if (entry.type !== 'file' || !/\\.json$/.test(entry.name)) continue\n const parsed = await readJson(assets + '/models/block/' + entry.name)\n if (parsed !== undefined) models.set('block/' + entry.name.replace(/\\.json$/, ''), parsed)\n }\n const textures = new Map()\n for (const kind of ['block', 'blocks', 'entity', 'item']) {\n for (const entry of await listDir(assets + '/textures/' + kind)) {\n if (entry.type !== 'file' || !/\\.png$/.test(entry.name)) continue\n textures.set(namespace + ':' + kind + '/' + entry.name.replace(/\\.png$/, ''),\n assets + '/textures/' + kind + '/' + entry.name)\n }\n }\n return { dir: dir, namespace: namespace, assets: assets, models: models, textures: textures }\n }\n\n async function readLang(assets) {\n const merged = {}\n for (const locale of ['en_us', 'zh_cn']) {\n const parsed = await readJson(assets + '/lang/' + locale + '.json')\n if (parsed === undefined || parsed === null || typeof parsed !== 'object') continue\n for (const key of Object.keys(parsed)) {\n const value = parsed[key]\n if (typeof value === 'string' && value.length > 0) merged[key] = value\n }\n }\n return merged\n }\n\n function texturePath(load, reference) {\n let name = String(reference === undefined ? '' : reference)\n let namespace = load.namespace\n const colon = name.indexOf(':')\n if (colon >= 0) { namespace = name.slice(0, colon); name = name.slice(colon + 1) }\n const own = load.textures.get(namespace + ':' + name)\n if (own !== undefined) return own\n const other = preloads.get(namespace)\n return other === undefined ? undefined : other.textures.get(namespace + ':' + name)\n }\n\n function elementsOf(load, modelName) {\n let key = modelName\n const colon = key.indexOf(':')\n if (colon >= 0) key = key.slice(colon + 1)\n const model = load.models.get(key)\n if (model === undefined) return undefined\n return resolveBlockModel(model, (id) => {\n let parentKey = id\n const c = parentKey.indexOf(':')\n if (c >= 0) parentKey = parentKey.slice(c + 1)\n return load.models.get(parentKey)\n }, (reference) => texturePath(load, reference))\n }\n\n async function blockIds(load) {\n const out = []\n for (const entry of await listDir(load.assets + '/blockstates')) {\n if (entry.type !== 'file' || !/\\.json$/.test(entry.name)) continue\n const id = entry.name.replace(/\\.json$/, '')\n const state = await readJson(load.assets + '/blockstates/' + entry.name)\n let model = id\n if (state !== undefined && state.variants !== undefined) {\n const keys = Object.keys(state.variants)\n if (keys.length > 0) {\n let value = state.variants[keys[0]]\n if (Array.isArray(value)) value = value[0]\n if (value !== undefined && typeof value.model === 'string') model = value.model\n }\n }\n out.push({ id: id, model: model })\n }\n return out\n }\n\n async function build(root) {\n const staged = []\n const nextPreloads = new Map()\n const nextLangs = new Map()\n for (const entry of await listDir(root)) {\n if (entry.type !== 'directory' || entry.name.charAt(0) === '.') continue\n const dir = root + '/' + entry.name\n const atlas = await readJson(dir + '/mc-art.atlas.json')\n const assets = await listDir(dir + '/pack/assets')\n const namespaces = []\n for (const candidate of assets) if (candidate.type === 'directory') namespaces.push(candidate.name)\n if (namespaces.length === 0 && atlas === undefined) continue\n const namespace = (atlas && typeof atlas.namespace === 'string' && namespaces.indexOf(atlas.namespace) >= 0)\n ? atlas.namespace : namespaces[0]\n if (namespace === undefined) continue\n const load = await preload(dir, namespace)\n nextPreloads.set(namespace, load)\n nextLangs.set(namespace, await readLang(load.assets))\n staged.push({ entry: entry.name, dir: dir, atlas: atlas, namespace: namespace, load: load })\n }\n preloads = nextPreloads\n langs = nextLangs\n\n const projects = []\n for (const item of staged) {\n const namespace = item.namespace\n const atlas = item.atlas\n const blocks = []\n for (const block of await blockIds(item.load)) {\n blocks.push({ id: block.id, title: labelFor(namespace, 'block', block.id) })\n }\n projects.push({\n id: item.entry, title: labelFor(namespace, 'project', item.entry),\n namespace: namespace, root: root, dir: item.dir, blocks: blocks,\n entities: (atlas && atlas.entities ? atlas.entities : []).map((e) => ({ id: e.id, title: labelFor(namespace, 'entity', e.id) })),\n biomes: (atlas && atlas.biomes ? atlas.biomes : []).map((b) => ({ id: b.id, title: labelFor(namespace, 'biome', b.id) })),\n structures: (atlas && atlas.structures ? atlas.structures : []).map((s) => ({ id: s.id, title: labelFor(namespace, 'structure', s.id) })),\n })\n }\n return { projects: projects, preloads: nextPreloads, langs: nextLangs }\n }\n\n async function indexOf(root) {\n const signature = await signatureOf(root)\n const cached = indexCache.get(root)\n if (cached !== undefined && cached.signature === signature) {\n preloads = cached.preloads\n langs = cached.langs\n return { projects: cached.projects, cached: true }\n }\n const built = await build(root)\n indexCache.set(root, { signature: signature, projects: built.projects, preloads: built.preloads, langs: built.langs })\n return { projects: built.projects, cached: false }\n }\n\n async function projectFor(root, projectId) {\n if (root === '' || projectId === '') return undefined\n const index = await indexOf(root)\n return index.projects.filter((item) => item.id === projectId)[0]\n }\n\n async function looksLikeProject(dir) {\n if ((await statOf(dir + '/mc-art.atlas.json')) !== undefined) return true\n const assets = await listDir(dir + '/pack/assets')\n return assets.length > 0\n }\n\n function pythonHint() {\n return pythonLauncher === '' ? PYTHON_CANDIDATES[0] : pythonLauncher\n }\n\n async function runScanner(scanner, tokens, timeoutMs, workspaceRoot, maxBytes) {\n const python = await resolvePython(workspaceRoot)\n if (python === null) {\n return { error: '找不到 Python(试过 ' + PYTHON_CANDIDATES.join(' / ') +\n ')。抽取器是 Python 写的,先装一个 Python 3 并让它进 PATH;' +\n 'Windows 上装完通常叫 python 或 py。' }\n }\n const dialect = await currentShell(workspaceRoot)\n const argument = (tokens === undefined ? [] : tokens).map((token) => dialect.word(token)).join(' ')\n const result = await runShell(python + ' ' + dialect.word(scanner) + (argument === '' ? '' : ' ' + argument),\n timeoutMs, policyFor(workspaceRoot), maxBytes)\n if (result.exitCode !== 0) {\n const detail = beforeStderr(result.err === undefined || result.err === null ? '' : result.err)\n return { error: '扫描脚本退出码 ' + result.exitCode + (detail === '' ? ',没有任何错误输出' : ':' + detail) }\n }\n try {\n return JSON.parse(beforeStderr(result.text))\n } catch (error) {\n return { error: '扫描脚本的输出不是 JSON:' + String(result.text).slice(0, 200) }\n }\n }\n\n async function referenceSignature(dir) {\n const pieces = [await dirSignature(dir), await dirSignature(dir + '/mods'), await dirSignature(dir + '/assets')]\n for (const version of await listDir(dir + '/versions')) {\n if (version.type !== 'directory') continue\n pieces.push(version.name)\n pieces.push(await dirSignature(dir + '/versions/' + version.name))\n pieces.push(await dirSignature(dir + '/versions/' + version.name + '/mods'))\n }\n return pieces.join('|')\n }\n\n const scanCache = new Map()\n async function scanReference(scanner, dir) {\n const empty = { root: dir, shape: '', namespaces: [], sources: [], textures: 0, error: null }\n if (dir === '') return empty\n if (scanner === null || scanner === undefined) {\n return Object.assign({}, empty, { error: '找不到扫描脚本 ' + SCAN_SCRIPT + '(从项目目录往上找了 5 层都没有)。' })\n }\n const signature = await referenceSignature(dir)\n const cached = scanCache.get(dir)\n if (cached !== undefined && cached.signature === signature) return cached.result\n const parsed = await runScanner(scanner, [dir], 180000, dir)\n if (parsed.error !== undefined) return Object.assign({}, empty, { error: String(parsed.error) })\n const result = { root: parsed.root, shape: parsed.shape || '', namespaces: parsed.namespaces || [],\n sources: parsed.sources || [], textures: parsed.textures || 0, error: null }\n scanCache.set(dir, { signature: signature, result: result })\n return result\n }\n\n async function detectGameRoots() {\n const out = []\n const skip = { Public: true, Default: true, 'Default User': true, 'All Users': true }\n const consider = async (path) => {\n const info = await statOf(path)\n if (info !== undefined && info.type === 'directory' && out.indexOf(path) < 0) out.push(path)\n }\n await consider('/root/.minecraft')\n for (const root of ['/mnt/c/Users', '/home']) {\n for (const user of await listDir(root)) {\n if (user.type !== 'directory' || skip[user.name] === true) continue\n const home = root + '/' + user.name\n await consider(home + '/.minecraft')\n await consider(home + '/AppData/Roaming/.minecraft')\n if (root !== '/mnt/c/Users') continue\n let seen = 0\n for (const child of await listDir(home)) {\n if (child.type !== 'directory' || child.name.charAt(0) === '.' || skip[child.name] === true) continue\n seen += 1\n if (seen > 80) break\n await consider(home + '/' + child.name + '/.minecraft')\n }\n }\n }\n return out.slice(0, 5)\n }\n\n async function ensure(namespace) {\n if (preloads.has(namespace)) return preloads.get(namespace)\n for (const other of preloads.values()) if (other.namespace === namespace) return other\n return undefined\n }\n\n function normalizeCells(input) {\n const out = []\n if (!Array.isArray(input)) return out\n for (const cell of input) {\n if (!cell || typeof cell.block !== 'string' || cell.block === '') continue\n if (!Array.isArray(cell.at) || cell.at.length < 3) continue\n const at = [0, 1, 2].map((i) => Math.trunc(Number(cell.at[i])))\n if (!at.every((value) => isFinite(value))) continue\n const cellVariant = typeof cell.variant === 'string' && cell.variant !== '' ? cell.variant : null\n const kept = { block: cell.block, at: at }\n if (cellVariant !== null) kept.variant = cellVariant\n out.push(kept)\n }\n return out\n }\n\n const FACE_STEP = {\n down: [0, -1, 0], up: [0, 1, 0], north: [0, 0, -1],\n south: [0, 0, 1], west: [-1, 0, 0], east: [1, 0, 0],\n }\n\n async function referenceDirectory(project) {\n const stored = (await readJson(project.dir + '/mc-art.settings.json')) || {}\n const reference = stored.reference !== null && typeof stored.reference === 'object' ? stored.reference : {}\n return typeof reference.directory === 'string' ? reference.directory : ''\n }\n\n async function refSignatureOf(dir) {\n const cached = refSignatures.get(dir)\n const now = Date.now()\n if (cached !== undefined && now - cached.at < 5000) return cached.value\n const value = await referenceSignature(dir)\n refSignatures.set(dir, { at: now, value: value })\n return value\n }\n\n function virtualLoad(namespace, parsed) {\n const models = new Map()\n for (const key of Object.keys(parsed.models || {})) {\n const colon = key.indexOf(':')\n const path = colon >= 0 ? key.slice(colon + 1) : key\n if (!models.has(path)) models.set(path, parsed.models[key])\n }\n const textures = new Map()\n const files = parsed.textureFiles || {}\n for (const reference of Object.keys(parsed.textures || {})) {\n const colon = reference.indexOf(':')\n let textureNamespace = colon >= 0 ? reference.slice(0, colon) : namespace\n let texturePath = colon >= 0 ? reference.slice(colon + 1) : reference\n const file = files[reference]\n if (typeof file === 'string') {\n const parts = file.split('/')\n if (parts.length > 3 && parts[0] === 'assets') {\n textureNamespace = parts[1]\n texturePath = parts.slice(3).join('/').replace(/\\.png$/, '')\n }\n }\n const key = textureNamespace + ':' + texturePath\n const refKey = 'ref:' + key\n textures.set(key, refKey)\n referenceTextures.set(refKey, 'data:image/png;base64,' + parsed.textures[reference])\n const animation = (parsed.animations || {})[reference]\n if (animation !== undefined && animation !== null) referenceAnimations.set(refKey, animation)\n else referenceAnimations.delete(refKey)\n }\n return { dir: null, namespace: namespace, assets: null, models: models,\n textures: textures, virtual: true }\n }\n\n function refEntry(namespace, block) {\n return referenceBlocks.get(namespace + ':' + block)\n }\n\n function refNameOf(namespace, block) {\n const entry = refEntry(namespace, block)\n return entry === undefined ? undefined : entry.name\n }\n\n\n async function referenceLoadItem(root, project, namespace, item, source) {\n const fromProject = source === 'project'\n const key = 'item:' + (fromProject ? 'project:' : '') + namespace + ':' + item\n const resolved = await itemRoots(project, source)\n if (resolved.error !== undefined) return { error: resolved.error }\n const signature = await rootsSignature(resolved.roots)\n const cached = referenceBlocks.get(key)\n if (cached !== undefined && cached.signature === signature) return cached\n const extractor = await findTool(project.dir, EXTRACT_SCRIPT)\n if (extractor === null || extractor === undefined) {\n return { error: '找不到 ' + EXTRACT_SCRIPT + '(从项目目录往上找了 5 层都没有)。' }\n }\n const parsed = await runScanner(extractor,\n rootArgs(resolved.roots).concat(['--item', namespace + ':' + item]),\n 120000, project.dir, REFERENCE_MAX_BYTES)\n if (parsed === undefined || (parsed.error !== undefined && parsed.error !== null)) {\n return { error: String(parsed === undefined ? '抽取脚本没有返回任何东西' : parsed.error) }\n }\n const entry = {\n signature: signature, namespace: namespace, item: item, source: source,\n name: parsed.name || item, version: parsed.version,\n shape: parsed.shape, light: parsed.light, display: parsed.display || null,\n form: parsed.form, family: parsed.family, formLabel: parsed.formLabel,\n layers: parsed.layers || [], frames: parsed.frames || [],\n framesTruncated: parsed.framesTruncated === true,\n modelRefs: parsed.modelRefs || [], modelRotations: parsed.modelRotations || [],\n rotation: parsed.variantRotation === undefined ? null : parsed.variantRotation,\n parsed: parsed,\n }\n referenceBlocks.set(key, entry)\n return entry\n }\n\n function rootArgs(roots) {\n const out = []\n for (const one of roots) out.push('--root', one)\n return out\n }\n\n async function rootsSignature(roots) {\n const parts = []\n for (const one of roots) parts.push(await refSignatureOf(one))\n return parts.join('|')\n }\n\n const editablePaths = new Map()\n\n function itemHandle(namespace, reference) {\n const text = String(reference === undefined || reference === null ? '' : reference)\n return 'ref:' + (text.indexOf(':') >= 0 ? text : namespace + ':' + text)\n }\n\n async function itemIcon(namespace, parsed, have, project) {\n const load = virtualLoad(namespace, parsed)\n const out = {\n namespace: namespace, item: parsed.item, name: parsed.name || parsed.item,\n shape: parsed.shape, light: parsed.light, display: parsed.display || null,\n form: parsed.form, family: parsed.family, formLabel: parsed.formLabel,\n named: parsed.named === true,\n layers: [], frames: [], framesTruncated: parsed.framesTruncated === true,\n quads: [], error: parsed.error === undefined ? null : parsed.error,\n missingModels: parsed.missingModels || [],\n }\n const ids = []\n const add = (id) => { if (ids.indexOf(id) < 0) ids.push(id) }\n if (parsed.shape === 'iso') {\n const entry = { modelRef: parsed.modelRef || (parsed.modelRefs || [])[0] || null,\n modelRefs: parsed.modelRefs || [], modelRotations: parsed.modelRotations || [],\n rotation: parsed.variantRotation === undefined ? null : parsed.variantRotation,\n multipart: false, whenList: [] }\n const quads = quadsOfEntry(load, entry, note)\n out.quads = quads === undefined ? [] : quads\n for (const quad of out.quads) add(quad.tex)\n } else {\n out.layers = (parsed.layers || []).map((reference) => itemHandle(namespace, reference))\n for (const frame of parsed.frames || []) {\n out.frames.push({ predicate: frame.predicate || {},\n layers: (frame.layers || []).map((reference) => itemHandle(namespace, reference)) })\n }\n for (const id of out.layers) add(id)\n for (const frame of out.frames) for (const id of frame.layers) add(id)\n }\n out.textureIds = ids\n out.modelPath = ''\n if (project !== undefined) {\n const model = project.dir + '/pack/assets/' + namespace + '/models/item/' + parsed.item + '.json'\n if ((await statOf(model)) !== undefined) out.modelPath = rel(project.root, model)\n }\n const layerRefs = (parsed.layers || []).concat(\n (parsed.frames || []).reduce((all, frame) => all.concat(frame.layers || []), []))\n for (const reference of layerRefs) {\n const file = (parsed.textureFiles || {})[reference]\n if (typeof file !== 'string' || file === '') continue\n const full = project === undefined ? undefined : project.dir + '/pack/' + file\n if (full === undefined || (await statOf(full)) === undefined) continue\n editablePaths.set(itemHandle(namespace, reference), full)\n }\n const already = {}\n if (Array.isArray(have)) for (const path of have) already[path] = true\n const textures = {}\n for (const id of ids) {\n if (already[id] === true) continue\n const url = await textureUrlFor(id)\n if (url !== undefined) textures[id] = url\n }\n out.textures = textures\n out.animations = animationsFor(ids)\n return out\n }\n\n async function itemFactsList(project, namespace, source) {\n const resolved = await itemRoots(project, source)\n if (resolved.error !== undefined) return { error: resolved.error }\n const extractor = await findTool(project.dir, EXTRACT_SCRIPT)\n if (extractor === null || extractor === undefined) return { error: '找不到 ' + EXTRACT_SCRIPT }\n const parsed = await runScanner(extractor,\n rootArgs(resolved.roots).concat(['--list', '--namespace', namespace, '--kind', 'item']),\n 180000, project.dir, REFERENCE_MAX_BYTES)\n if (parsed === undefined || parsed.error !== undefined) {\n return { error: String(parsed === undefined ? '没有返回任何东西' : parsed.error) }\n }\n return { namespace: namespace, version: parsed.version, items: parsed.items || [] }\n }\n\n async function itemIconPage(project, namespace, items, source) {\n const resolved = await itemRoots(project, source)\n if (resolved.error !== undefined) return { error: resolved.error }\n const extractor = await findTool(project.dir, EXTRACT_SCRIPT)\n if (extractor === null || extractor === undefined) return { error: '找不到 ' + EXTRACT_SCRIPT }\n const parsed = await runScanner(extractor,\n rootArgs(resolved.roots).concat(['--items', items.join(','), '--namespace', namespace]),\n 180000, project.dir, REFERENCE_MAX_BYTES)\n if (parsed === undefined || parsed.error !== undefined) {\n return { error: String(parsed === undefined ? '没有返回任何东西' : parsed.error) }\n }\n const out = {}\n for (const item of Object.keys(parsed.items || {})) {\n out[item] = await itemIcon(namespace, parsed.items[item], [], project)\n }\n return { namespace: namespace, version: parsed.version, items: out }\n }\n\n async function referenceLoad(root, project, namespace, block, variant) {\n const wanted = typeof variant === 'string' && variant !== '' ? variant : null\n const key = namespace + ':' + block + (wanted === null ? '' : '@' + wanted)\n const directory = await referenceDirectory(project)\n if (directory === '') {\n return { error: '还没有设置参考目录,取不到原版或模组的方块。在设置里指定 .minecraft 或版本目录。' }\n }\n const signature = await refSignatureOf(directory)\n const cached = referenceBlocks.get(key)\n if (cached !== undefined && cached.signature === signature) return cached\n const extractor = await findTool(project.dir, EXTRACT_SCRIPT)\n if (extractor === null || extractor === undefined) {\n return { error: '找不到 ' + EXTRACT_SCRIPT + '(从项目目录往上找了 5 层都没有)。' }\n }\n const blockArg = namespace + ':' + block\n const tokens = ['--root', directory, '--block', blockArg]\n if (wanted !== null) tokens.push('--variant', wanted)\n const dialect = await currentShell(project.dir)\n const command = pythonHint() + ' ' + dialect.word(extractor) + ' ' +\n tokens.map((token) => dialect.word(token)).join(' ')\n const parsed = await runScanner(extractor, tokens, 120000, project.dir, REFERENCE_MAX_BYTES)\n if (parsed === undefined || parsed.error !== undefined) {\n return { error: String(parsed === undefined ? '抽取脚本没有返回任何东西' : parsed.error) }\n }\n const refs = parsed.modelRefs || []\n if (refs.length === 0) return { error: '抽出来的方块没有可用的模型:' + key }\n const entry = {\n signature: signature, namespace: namespace, block: block,\n name: parsed.name || block, nameKey: parsed.nameKey, version: parsed.version,\n variant: parsed.variant === undefined ? null : parsed.variant,\n variantKeys: parsed.variantKeys || [],\n variantAxes: parsed.variantAxes || [],\n variantDefaults: parsed.variantDefaults || {},\n rotation: parsed.variantRotation === undefined ? null : parsed.variantRotation,\n modelRef: refs[0], modelRefs: refs,\n modelRotations: parsed.modelRotations || [],\n whenList: parsed.multipartWhens || [],\n multipart: parsed.multipart === true,\n load: virtualLoad(namespace, parsed), command: command,\n }\n referenceBlocks.set(key, entry)\n return entry\n }\n\n function entryFamily(entry, id) {\n if (entry === undefined || entry === null) return 'other'\n const axes = Array.isArray(entry.variantAxes) ? entry.variantAxes : []\n for (const axis of axes) {\n if (axis !== null && typeof axis === 'object' && axis.name === 'in_wall') return 'gate'\n }\n if (entry.multipart !== true) return 'other'\n const whens = Array.isArray(entry.whenList) ? entry.whenList : []\n let sawUp = false\n let sawFalse = false\n let sawTrue = false\n for (const when of whens) {\n for (const key of Object.keys(when)) {\n if (key === 'up') { sawUp = true; continue }\n if (key !== 'north' && key !== 'east' && key !== 'south' && key !== 'west') continue\n if (when[key] === 'false') sawFalse = true\n else sawTrue = true\n }\n }\n let shape = 'other'\n if (sawUp) shape = 'wall'\n else if (sawFalse) shape = 'pane'\n else if (sawTrue) shape = 'fence'\n if (shape === 'other') return 'other'\n const text = String(id === undefined || id === null ? '' : id)\n const name = text.slice(text.indexOf(':') + 1)\n if (shape === 'wall') return /(^|_)wall$/.test(name) ? 'wall' : 'other'\n if (shape === 'fence') return /(^|_)fence$/.test(name) ? 'fence' : 'other'\n return (/(^|_)pane$/.test(name) || /(^|_)bars$/.test(name)) ? 'pane' : 'other'\n }\n\n function isFullCubeEntry(load, entry) {\n const refs = Array.isArray(entry.modelRefs) && entry.modelRefs.length > 0\n ? entry.modelRefs : [entry.modelRef]\n const elements = elementsOf(load, refs[0])\n if (!Array.isArray(elements) || elements.length !== 1) return false\n const element = elements[0]\n if (element === undefined || element.rotation !== undefined) return false\n const from = element.from\n const to = element.to\n if (!Array.isArray(from) || !Array.isArray(to)) return false\n return from[0] === 0 && from[1] === 0 && from[2] === 0\n && to[0] === 16 && to[1] === 16 && to[2] === 16\n }\n\n const EXCEPT_18 = { barrier: 1, carved_pumpkin: 1, jack_o_lantern: 1, melon: 1, pumpkin: 1 }\n function isException18(id) {\n const text = String(id)\n const name = text.slice(text.indexOf(':') + 1)\n if (EXCEPT_18[name] === 1) return true\n if (/_leaves$/.test(name)) return true // LeavesBlock\n return name === 'shulker_box' || /_shulker_box$/.test(name) // BlockTags.SHULKER_BOXES\n }\n const EXCEPT_12 = { beacon: 1, cauldron: 1, glass: 1, glowstone: 1, ice: 1, sea_lantern: 1,\n stained_glass: 1, piston: 1, sticky_piston: 1, piston_head: 1, barrier: 1,\n melon_block: 1, pumpkin: 1, lit_pumpkin: 1 }\n function isException12(id) {\n const text = String(id)\n const name = text.slice(text.indexOf(':') + 1)\n if (EXCEPT_12[name] === 1) return true\n if (/_leaves$/.test(name)) return true // BlockLeaves\n if (/_trapdoor$/.test(name)) return true // BlockTrapDoor\n if (/_stained_glass$/.test(name)) return true // BlockStainedGlass\n return name === 'shulker_box' || /_shulker_box$/.test(name) // BlockShulkerBox\n }\n\n function isOldVersion(version) {\n const text = String(version === undefined || version === null ? '' : version)\n return text.indexOf('1.12') >= 0 || text.indexOf('1.11') >= 0 || text.indexOf('1.10') >= 0\n }\n\n function axisOfDirection(name) {\n if (name === 'north' || name === 'south') return 'z'\n if (name === 'east' || name === 'west') return 'x'\n return 'y'\n }\n\n function variantValueOf(variant, name) {\n if (typeof variant !== 'string' || variant === '') return null\n const parts = variant.split(',')\n for (const part of parts) {\n const at = part.indexOf('=')\n if (at > 0 && part.slice(0, at) === name) return part.slice(at + 1)\n }\n return null\n }\n\n function connectsTo(version, self, neighbour, dir) {\n const absent = neighbour === null || neighbour === undefined\n if (isOldVersion(version)) {\n let face = 'undefined'\n if (!absent) {\n if (neighbour.family === 'wall') face = 'poleThick'\n else if (neighbour.family === 'fence') face = 'pole'\n else if (neighbour.family === 'pane') face = 'poleThin'\n else if (neighbour.family !== 'gate') face = neighbour.cube === true ? 'solid' : 'undefined'\n else if (!gateAlongAxis(neighbour, dir)) face = 'undefined'\n else face = 'pole'\n }\n const exception = absent ? false : isException12(neighbour.id)\n if (self.family === 'pane') return (!exception && face === 'solid') || face === 'poleThin'\n const flag = face === (self.family === 'wall' ? 'poleThick' : 'pole')\n return (!exception && face !== 'solid') || flag\n }\n if (absent) return false\n const exception = isException18(neighbour.id)\n const solid = neighbour.cube === true && !exception\n if (neighbour.family === 'gate') {\n return gateAlongAxis(neighbour, dir)\n }\n if (self.family === 'wall') {\n return neighbour.family === 'wall' || solid || neighbour.family === 'pane'\n }\n if (self.family === 'fence') {\n return solid || (neighbour.family === 'fence' && self.wooden === fenceWooden(neighbour.id))\n }\n if (self.family === 'pane') {\n return solid || neighbour.family === 'pane' || neighbour.family === 'wall'\n }\n return false\n }\n\n function gateAlongAxis(neighbour, dir) {\n const facing = variantValueOf(neighbour.variant, 'facing')\n return facing === null || axisOfDirection(facing) === axisOfDirection(dir)\n }\n\n function multipartValues(version, self, at) {\n const names = []\n for (const when of self.whens) {\n for (const key of Object.keys(when)) if (names.indexOf(key) < 0) names.push(key)\n }\n const back = { north: 'south', south: 'north', east: 'west', west: 'east' }\n const steps = { north: [0, 0, -1], east: [1, 0, 0], south: [0, 0, 1], west: [-1, 0, 0] }\n const sides = {}\n for (const name of Object.keys(steps)) {\n const step = steps[name]\n sides[name] = connectsTo(version, self, at(step[0], 0, step[2]), back[name])\n }\n const v12 = isOldVersion(version)\n const values = {}\n for (const name of Object.keys(sides)) {\n if (names.indexOf(name) < 0) continue\n if (self.family === 'wall' && !v12) {\n const over = at(0, 1, 0)\n const covered = over !== null && over.cube === true\n values[name] = sides[name] ? (covered ? 'tall' : 'low') : 'none'\n } else {\n values[name] = sides[name] ? 'true' : 'false'\n }\n }\n if (names.indexOf('up') < 0) return values\n const above = at(0, 1, 0)\n if (self.family === 'wall' && v12) {\n const flag = (sides.south && !sides.west && sides.north && sides.east)\n || (!sides.south && sides.west && !sides.north && sides.east)\n values.up = flag ? (above === null ? 'false' : 'true') : 'true'\n return values\n }\n let raise = above !== null && above.family === 'wall'\n if (!raise) {\n const none = (name) => values[name] === undefined || values[name] === 'none'\n const asym = (none('north') && none('south') && none('east') && none('west'))\n || (none('north') !== none('south')) || (none('east') !== none('west'))\n const tallPair = (values.north === 'tall' && values.south === 'tall')\n || (values.east === 'tall' && values.west === 'tall')\n if (asym) raise = true\n else if (tallPair) raise = false\n else raise = above !== null && above.cube === true\n }\n values.up = raise ? 'true' : 'false'\n return values\n }\n\n function whenMatches(when, values) {\n for (const key of Object.keys(when)) {\n const have = values[key]\n if (have === undefined) continue\n if (String(have) !== String(when[key])) return false\n }\n return true\n }\n\n function fenceWooden(id) {\n const text = String(id)\n return !/nether_brick_fence$/.test(text.slice(text.indexOf(':') + 1))\n }\n\n function keepFlags(entry, values) {\n const whens = Array.isArray(entry.whenList) ? entry.whenList : []\n const refs = Array.isArray(entry.modelRefs) ? entry.modelRefs : []\n if (entry.multipart !== true || whens.length !== refs.length || refs.length === 0) return undefined\n for (const when of whens) {\n for (const key of Object.keys(when)) {\n if (values[key] === undefined) return undefined\n }\n }\n return whens.map((when) => whenMatches(when, values))\n }\n\n async function entryFor(root, project, reference, variant) {\n const colon = reference.indexOf(':')\n const namespace = colon >= 0 ? reference.slice(0, colon) : project.namespace\n const name = colon >= 0 ? reference.slice(colon + 1) : reference\n if (name === '') return { error: '方块名是空的' }\n const load = await ensure(namespace)\n if (load !== undefined) {\n return { load: load, id: namespace + ':' + name,\n entry: { modelRef: 'block/' + name, rotation: null, multipart: false, version: null } }\n }\n const ref = await referenceLoad(root, project, namespace, name, variant)\n if (ref.error !== undefined) return { error: ref.error }\n return { load: ref.load, id: ref.namespace + ':' + ref.block, entry: ref }\n }\n\n function describeEntry(load, entry, id, variant) {\n if (entry === null || entry === undefined) return null\n if (entry.cubeMemo === undefined) entry.cubeMemo = isFullCubeEntry(load, entry)\n return { id: id, family: entryFamily(entry, id), cube: entry.cubeMemo === true,\n variant: variant === undefined ? null : variant,\n version: entry.version === undefined ? null : entry.version }\n }\n\n function neatKey(at) {\n return at[0] + ',' + at[1] + ',' + at[2]\n }\n\n\n function quadsOfEntry(load, entry, report, keep) {\n const refs = Array.isArray(entry.modelRefs) && entry.modelRefs.length > 0\n ? entry.modelRefs : [entry.modelRef]\n const rotations = Array.isArray(entry.modelRotations) ? entry.modelRotations : []\n let out = []\n const missed = []\n for (let index = 0; index < refs.length; index++) {\n if (keep !== undefined && keep[index] !== true) continue\n const elements = elementsOf(load, refs[index])\n if (elements === undefined) { missed.push(refs[index]); continue }\n const own = index < rotations.length ? rotations[index] : null\n out = out.concat(rotateQuads(quadsFromElements(elements, report), own))\n }\n if (out.length === 0) return undefined\n if (missed.length > 0 && report !== undefined) report('模型链里缺了:' + missed.join('、'))\n return rotateQuads(out, entry.rotation)\n }\n\n async function blockQuadsAt(root, project, block, at, variant, around) {\n const found = await entryFor(root, project, String(block), variant)\n if (found.error !== undefined) {\n return { error: found.error.indexOf('方块名是空的') >= 0\n ? found.error : '找不到 ' + String(block) + ' 的模型(' + found.error + ')' }\n }\n const load = found.load\n const entry = found.entry\n let keep\n if (entry.multipart === true) {\n const whens = Array.isArray(entry.whenList) ? entry.whenList : []\n const refs = Array.isArray(entry.modelRefs) ? entry.modelRefs : []\n const family = entryFamily(entry, found.id)\n if (whens.length > 0 && whens.length === refs.length && family !== 'other') {\n const id = found.id\n const known = new Map()\n for (const item of Array.isArray(around) ? around : []) {\n if (item === null || typeof item !== 'object') continue\n if (!Array.isArray(item.d) || item.d.length < 3) continue\n if (typeof item.block !== 'string' || item.block === '') continue\n const offset = [Math.trunc(item.d[0]), Math.trunc(item.d[1]), Math.trunc(item.d[2])]\n const other = await entryFor(root, project, item.block,\n typeof item.variant === 'string' && item.variant !== '' ? item.variant : null)\n if (other.error !== undefined) continue\n const colon = item.block.indexOf(':')\n const otherId = colon >= 0 ? item.block : project.namespace + ':' + item.block\n known.set(neatKey(offset), describeEntry(other.load, other.entry, otherId,\n typeof item.variant === 'string' && item.variant !== '' ? item.variant : null))\n }\n const self = { id: id, family: entryFamily(entry, id), whens: whens, wooden: fenceWooden(id) }\n const values = multipartValues(entry.version, self,\n (dx, dy, dz) => known.get(neatKey([dx, dy, dz])) || null)\n keep = keepFlags(entry, values)\n }\n }\n const quads = quadsOfEntry(load, entry, note, keep)\n if (quads === undefined) return { error: '找不到 ' + String(block) + ' 的模型' }\n const axes = Array.isArray(entry.variantAxes) ? entry.variantAxes : []\n return { quads: translateQuads(quads, at),\n variants: entry.variantKeys || [], axes: axes, defaults: entry.variantDefaults || {},\n variant: entry.rotation === null || entry.rotation === undefined ? null : variant,\n multipart: entry.multipart === true, derived: keep !== undefined && keep !== null }\n }\n\n async function payload(root, project, kind, id, have, cellsOverride, neighbours) {\n const dir = project.dir\n const namespace = project.namespace\n const neighbourAware = neighbours !== false\n const load = preloads.get(namespace)\n if (load === undefined) return { error: 'project not loaded: ' + namespace }\n let quads = []\n let refs = []\n let palette = []\n let cells = null\n\n if (kind === 'block') {\n const found = (await blockIds(load)).filter((block) => block.id === id)[0]\n if (found === undefined) return { error: 'block not found: ' + id }\n const elements = elementsOf(load, found.model)\n if (elements === undefined) return { error: 'cannot resolve model for ' + id }\n quads = quadsFromElements(elements, note)\n refs = [rel(root, load.assets + '/textures/block/' + id + '.png'),\n rel(root, load.assets + '/models/block/' + id + '.json'),\n rel(root, load.assets + '/blockstates/' + id + '.json')]\n } else if (kind === 'entity') {\n const source = (project.entities || []).filter((item) => item.id === id)[0]\n if (source === undefined) return { error: 'entity not found: ' + id }\n const full = (await readJson(dir + '/mc-art.atlas.json')) || {}\n const spec = (full.entities || []).filter((item) => item.id === id)[0]\n if (spec === undefined) return { error: 'entity spec missing: ' + id }\n for (const layer of spec.layers || []) {\n const path = load.assets + '/textures/' + layer.texture\n quads = quads.concat(entityQuads(layer.model, path, layer.mode || 'opaque'))\n refs.push(rel(root, path))\n }\n refs.push(rel(root, dir + '/mc-art.atlas.json'))\n refs = refs.concat((spec.refs || []).map((r) => rel(root, dir + '/' + r)))\n } else {\n const full = (await readJson(dir + '/mc-art.atlas.json')) || {}\n const list = kind === 'biome' ? (full.biomes || []) : (full.structures || [])\n const item = list.filter((entry) => entry.id === id)[0]\n if (item === undefined) return { error: kind + ' not found: ' + id }\n const source = cellsOverride === undefined || cellsOverride === null\n ? (item.cells || []) : normalizeCells(cellsOverride)\n const byCell = new Map()\n for (const cell of source) {\n if (!cell || !Array.isArray(cell.at) || typeof cell.block !== 'string') continue\n const key = cell.at[0] + ',' + cell.at[1] + ',' + cell.at[2]\n const previous = byCell.get(key)\n if (previous !== undefined && previous.block !== cell.block) {\n note('格子重叠 ' + key + ':' + previous.block + ' 与 ' + cell.block + ',以后者为准')\n }\n const kept = { block: cell.block, at: cell.at }\n if (typeof cell.variant === 'string' && cell.variant !== '') kept.variant = cell.variant\n byCell.set(key, kept)\n }\n cells = Array.from(byCell.values())\n const cache = new Map()\n const counts = new Map()\n const slots = []\n const atCell = new Map()\n for (let index = 0; index < cells.length; index++) {\n const cell = cells[index]\n const reference = String(cell.block)\n const colon = reference.indexOf(':')\n const cellNamespace = colon >= 0 ? reference.slice(0, colon) : namespace\n const cellBlock = colon >= 0 ? reference.slice(colon + 1) : reference\n const fullName = cellNamespace + ':' + cellBlock\n counts.set(fullName, (counts.get(fullName) || 0) + 1)\n const cellKey = fullName + (cell.variant === null || cell.variant === undefined ? '' : '@' + cell.variant)\n let slot = cache.get(cellKey)\n if (slot === undefined) {\n slot = { load: undefined, entry: null, union: undefined,\n id: fullName, variant: cell.variant === undefined ? null : cell.variant }\n const found = await entryFor(root, project, reference, cell.variant)\n if (found.error !== undefined) note('取不到 ' + fullName + ':' + found.error)\n else { slot.load = found.load; slot.entry = found.entry; slot.id = found.id }\n cache.set(cellKey, slot)\n }\n slots.push(slot)\n atCell.set(neatKey(cell.at), slot)\n }\n const offsetAt = (index, dx, dy, dz) => {\n const cell = cells[index]\n const slot = atCell.get(neatKey([cell.at[0] + dx, cell.at[1] + dy, cell.at[2] + dz]))\n if (slot === undefined || slot.entry === null || slot.load === undefined) return null\n return describeEntry(slot.load, slot.entry, slot.id, slot.variant)\n }\n const unionOf = (slot) => {\n if (slot.union === undefined) {\n const built = quadsOfEntry(slot.load, slot.entry, note)\n slot.union = built === undefined ? [] : built\n if (slot.union.length === 0) note('no model for cell block ' + slot.id)\n }\n return slot.union\n }\n for (let index = 0; index < cells.length; index++) {\n const cell = cells[index]\n const slot = slots[index]\n let cellQuads = []\n if (slot.load !== undefined && slot.entry !== null) {\n let keep\n if (neighbourAware && slot.entry.multipart === true) {\n const whens = Array.isArray(slot.entry.whenList) ? slot.entry.whenList : []\n const family = entryFamily(slot.entry, slot.id)\n if (whens.length > 0 && family !== 'other') {\n const self = { id: slot.id, family: family, whens: whens,\n wooden: fenceWooden(slot.id) }\n const values = multipartValues(slot.entry.version, self,\n (dx, dy, dz) => offsetAt(index, dx, dy, dz))\n keep = keepFlags(slot.entry, values)\n }\n }\n const built = keep === undefined\n ? unionOf(slot) : quadsOfEntry(slot.load, slot.entry, note, keep)\n cellQuads = built === undefined ? [] : built\n }\n const tagged = translateQuads(cellQuads, cell.at).map((quad) => ({\n p: quad.p, uv: quad.uv, tex: quad.tex, shade: quad.shade, mode: quad.mode,\n shaded: quad.shaded, pick: { cell: index, face: quad.face || '' },\n }))\n quads = quads.concat(tagged)\n }\n palette = Array.from(counts.keys()).map((key) => {\n const at = key.indexOf(':')\n const cellNamespace = at >= 0 ? key.slice(0, at) : namespace\n const cellBlock = at >= 0 ? key.slice(at + 1) : key\n const fromReference = refNameOf(cellNamespace, cellBlock)\n return { block: key, label: fromReference || labelFor(cellNamespace, 'block', cellBlock), count: counts.get(key) }\n })\n palette.sort((a, b) => b.count - a.count)\n refs = [rel(root, dir + '/mc-art.atlas.json')].concat((item.refs || []).map((r) => rel(root, dir + '/' + r)))\n }\n\n const textureIds = []\n const seenTex = {}\n for (const quad of quads) if (seenTex[quad.tex] !== true) { seenTex[quad.tex] = true; textureIds.push(quad.tex) }\n const already = {}\n if (Array.isArray(have)) for (const path of have) already[path] = true\n const textures = {}\n for (const path of textureIds) {\n if (already[path] === true) continue\n const url = await textureUrlFor(path)\n if (url !== undefined) textures[path] = url\n }\n return { kind: kind, id: id, title: labelFor(namespace, kind, id),\n project: project.title || project.id,\n quads: quads, textureIds: textureIds, textures: textures,\n animations: animationsFor(textureIds),\n cells: cells, faceStep: FACE_STEP,\n refs: refs, ref: refs[0], palette: palette, box: boxOfQuads(quads), errors: errors.slice(0, 8) }\n }\n\n async function runShell(command, timeoutMs, sandboxPolicy, maxBytes) {\n if (shell === undefined) return { exitCode: null, text: '', err: 'no shell service' }\n const request = { command: command, timeoutMs: timeoutMs === undefined ? 20000 : timeoutMs,\n stdoutMaxBytes: maxBytes === undefined ? 64 * 1024 : maxBytes }\n if (sandboxPolicy !== undefined) request.sandboxPolicy = sandboxPolicy\n const spec = shell.resolve(request)\n const result = await shell.run(spec)\n return { exitCode: result.exitCode, text: result.stdout.text, err: result.stderr.text }\n }\n\n async function available(tool) {\n const dialect = await currentShell('')\n const result = await runShell(dialect.available(tool), 10000)\n return result.exitCode === 0 && String(result.text).trim().length > 0\n }\n\n const PS_QUOTE = (value) => \"'\" + String(value).split(\"'\").join(\"''\") + \"'\"\n const SHELLS = {\n posix: {\n name: 'posix',\n word: (value) => shellDecoded(value),\n available: (tool) => 'command -v ' + tool,\n remove: (path) => 'rm -f ' + shellDecoded(path),\n move: (from, to) => 'mv -f ' + shellDecoded(from) + ' ' + shellDecoded(to),\n decodeFile: (source, target) =>\n 'base64 -d < ' + shellDecoded(source) + ' > ' + shellDecoded(target),\n writeInline: (target, base64) =>\n 'printf %s ' + quoteRaw(base64) + ' | base64 -d > ' + shellDecoded(target),\n },\n pwsh: {\n name: 'pwsh',\n word: (value) => PS_QUOTE(value),\n available: (tool) => '(Get-Command ' + PS_QUOTE(tool) + ' -ErrorAction SilentlyContinue) -ne $null',\n remove: (path) => 'Remove-Item -LiteralPath ' + PS_QUOTE(path) + ' -Force -ErrorAction SilentlyContinue',\n move: (from, to) => 'Move-Item -LiteralPath ' + PS_QUOTE(from) +\n ' -Destination ' + PS_QUOTE(to) + ' -Force',\n decodeFile: (source, target) => '[IO.File]::WriteAllBytes(' + PS_QUOTE(target) +\n ', [Convert]::FromBase64String([IO.File]::ReadAllText(' + PS_QUOTE(source) + ')))',\n writeInline: (target, base64) => '[IO.File]::WriteAllBytes(' + PS_QUOTE(target) +\n ', [Convert]::FromBase64String(' + PS_QUOTE(base64) + '))',\n },\n }\n let shellDialect = null\n async function currentShell(workspaceRoot) {\n if (shellDialect !== null) return SHELLS[shellDialect]\n const probe = await runShell('echo \"mcart-shell:$($PSVersionTable.PSVersion.Major)\"', 20000,\n policyFor(workspaceRoot), 4096)\n if (probe.exitCode !== 0) return SHELLS.posix\n shellDialect = /^mcart-shell:\\d+/.test(String(probe.text).trim()) ? 'pwsh' : 'posix'\n return SHELLS[shellDialect]\n }\n\n\n async function writeDecodedFile(target, base64, workspaceRoot, timeoutMs) {\n const dialect = await currentShell(workspaceRoot)\n const policy = policyFor(workspaceRoot)\n const staging = target + '.mcart-b64'\n const staged = await writeTextFile(staging, base64, workspaceRoot)\n if (staged.ok !== true) {\n return { ok: false, exitCode: null, err: String(staged.detail), step: 1, steps: 2 }\n }\n const done = await runShell(dialect.decodeFile(staging, target), timeoutMs, policy)\n const cleanup = await runShell(dialect.remove(staging), 15000, policy)\n if (done.exitCode !== 0) {\n return { ok: false, exitCode: done.exitCode, err: done.err, step: 2, steps: 2 }\n }\n return { ok: true, steps: 2, dialect: dialect.name, stagingRemoved: cleanup.exitCode === 0 }\n }\n\n\n async function shellFileOp(workspaceRoot, build, timeoutMs) {\n const dialect = await currentShell(workspaceRoot)\n const done = await runShell(build(dialect), timeoutMs, policyFor(workspaceRoot))\n return { ok: done.exitCode === 0, exitCode: done.exitCode, err: done.err }\n }\n\n async function runDialog(command, sandboxPolicy) {\n if (shell === undefined) return ''\n const request = { command: command, stdoutMaxBytes: 64 * 1024 }\n if (sandboxPolicy !== undefined) request.sandboxPolicy = sandboxPolicy\n const spec = shell.resolve(request)\n const process = await shell.start(spec)\n await process.done\n return String(process.readOutput().delta)\n }\n\n async function convertPath(flag, value) {\n const trimmed = String(value === undefined || value === null ? '' : value).trim()\n if (trimmed === '') return null\n if (hasWslpath === null) hasWslpath = await available('wslpath')\n if (hasWslpath !== true) return null\n const result = await runShell('wslpath ' + flag + ' ' + shellDecoded(trimmed), 15000)\n if (result.exitCode !== 0) return null\n const converted = beforeStderr(result.text)\n return converted === '' ? null : converted\n }\n\n async function writeTextFile(path, text, workspaceRoot) {\n const policy = policyFor(workspaceRoot)\n let fsDetail = null\n if (fs !== undefined) {\n try {\n const target = await fs.resolve(path)\n await fs.writeText(target, text, undefined, undefined, policy)\n return { ok: true, via: 'fs' }\n } catch (error) {\n fsDetail = String(error && error.message ? error.message : error)\n }\n } else {\n fsDetail = 'no fs service'\n }\n if (shell !== undefined) {\n const dialect = await currentShell(workspaceRoot)\n const result = await runShell(dialect.writeInline(path, base64OfString(text)), 20000, policy)\n if (result.exitCode === 0) return { ok: true, via: 'shell' }\n const detail = beforeStderr(result.err === undefined || result.err === null ? '' : result.err)\n return { ok: false, detail: 'fs: ' + fsDetail + ' | shell exit=' + result.exitCode + (detail === '' ? '' : ' err=' + detail) }\n }\n return { ok: false, detail: 'fs: ' + fsDetail + ' | no shell service' }\n }\n\n ctx.effect(() => harness.handle('atlas.pickDirectory', async (args) => {\n const request = args || {}\n const start = typeof request.start === 'string' ? request.start : ''\n if (shell === undefined) return { supported: false, detail: '宿主没有 shell 服务,' }\n try {\n if (await available('powershell.exe')) {\n const winStart = await convertPath('-w', start)\n const lines = [\n '$ProgressPreference = \"SilentlyContinue\"',\n 'Add-Type -AssemblyName System.Windows.Forms | Out-Null',\n '$d = New-Object System.Windows.Forms.FolderBrowserDialog',\n \"$d.Description = '选择目录'\",\n '$d.ShowNewFolderButton = $false',\n ]\n if (winStart !== null) lines.push(\"$d.SelectedPath = '\" + winStart.split(\"'\").join(\"''\") + \"'\")\n lines.push('if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::Out.Write(\"' + PICK_BEGIN + '\" + $d.SelectedPath + \"' + PICK_END + '\") }')\n const output = await runDialog('powershell.exe -NoProfile -STA -EncodedCommand ' + utf16leBase64(lines.join('\\n')), policyFor(start))\n const picked = betweenMarkers(output)\n if (picked === '') return { supported: true, cancelled: true, via: 'windows' }\n const converted = await convertPath('-u', picked)\n const usable = converted !== null ? converted : picked\n const info = await statOf(usable)\n if (info !== undefined && info.type === 'directory') return { path: usable, raw: picked, via: 'windows' }\n return { supported: true, error: '你选的是 ' + picked + ',但宿主打不开它(转换后:' + usable + ')。' }\n }\n const linuxPickers = [\n { tool: 'zenity', command: 'zenity --file-selection --directory --title=\"选择目录\"' },\n { tool: 'yad', command: 'yad --file --directory --title=\"选择目录\"' },\n { tool: 'kdialog', command: 'kdialog --getexistingdirectory \"$HOME\" \"选择目录\"' },\n ]\n for (const picker of linuxPickers) {\n if (!(await available(picker.tool))) continue\n const picked = beforeStderr(await runDialog(picker.command))\n if (picked === '') return { supported: true, cancelled: true, via: picker.tool }\n const info = await statOf(picked)\n if (info !== undefined && info.type === 'directory') return { path: picked, raw: picked, via: picker.tool }\n return { supported: true, error: '选择器返回了 ' + picked + ',但打不开这个目录。' }\n }\n return { supported: false, detail: '这个环境既没有 powershell.exe(Windows/WSL)也没有 zenity/kdialog/yad(Linux 桌面),' }\n } catch (error) {\n return { error: '目录选择器出错:' + String(error && error.message ? error.message : error) }\n }\n }))\n\n ctx.effect(() => harness.handle('atlas.settings', async (args) => {\n const request = args || {}\n const root = typeof request.root === 'string' ? request.root : ''\n const projectId = typeof request.project === 'string' ? request.project : ''\n try {\n const project = await projectFor(root, projectId)\n if (project === undefined) return { error: '找不到项目:' + projectId }\n const file = project.dir + '/mc-art.settings.json'\n const stored = (await readJson(file)) || {}\n const reference = stored.reference !== null && typeof stored.reference === 'object' ? stored.reference : {}\n const chosen = reference.mods !== null && typeof reference.mods === 'object' ? reference.mods : {}\n const directory = typeof reference.directory === 'string' ? reference.directory : ''\n const scanner = await findScanner(project.dir)\n const scanned = await scanReference(scanner, directory)\n const detected = directory === '' ? await detectGameRoots() : []\n return {\n path: rel(root, file),\n project: project.id, title: project.title, file: file,\n directory: directory,\n shape: scanned.shape, textures: scanned.textures,\n sources: (scanned.sources || []).slice(0, 80),\n scanError: scanned.error,\n scanner: scanner,\n detected: detected,\n includeGenerated: reference.includeGenerated !== false,\n includeMods: reference.includeMods !== false,\n mods: (scanned.namespaces || []).map((item) => ({ name: item.name, count: item.count,\n from: item.from || [], on: chosen[item.name] !== false })),\n }\n } catch (error) {\n return { error: String(error && error.message ? error.message : error) }\n }\n }))\n\n ctx.effect(() => harness.handle('atlas.saveSettings', async (args) => {\n const request = args || {}\n const root = typeof request.root === 'string' ? request.root : ''\n const projectId = typeof request.project === 'string' ? request.project : ''\n try {\n const project = await projectFor(root, projectId)\n if (project === undefined) return { error: '找不到项目:' + projectId }\n const mods = {}\n if (request.mods !== null && typeof request.mods === 'object') {\n for (const key of Object.keys(request.mods)) {\n if (typeof request.mods[key] === 'boolean') mods[key] = request.mods[key]\n }\n }\n const file = project.dir + '/mc-art.settings.json'\n const stored = (await readJson(file)) || {}\n const storedReference = stored.reference !== null && typeof stored.reference === 'object'\n ? stored.reference : {}\n const payload = Object.assign({}, stored, {\n schema: 'mc-art.settings/1',\n reference: Object.assign({}, storedReference, {\n directory: typeof request.directory === 'string' ? request.directory : '',\n includeGenerated: request.includeGenerated !== false,\n includeMods: request.includeMods !== false,\n mods: mods,\n }),\n })\n const outcome = await writeTextFile(file, JSON.stringify(payload, null, 2) + '\\n', root)\n if (outcome.ok !== true) return { error: String(outcome.detail) }\n return { saved: true, file: file, path: rel(root, file), via: outcome.via }\n } catch (error) {\n return { error: String(error && error.message ? error.message : error) }\n }\n }))\n\n const PNG_MAGIC = [137, 80, 78, 71, 13, 10, 26, 10]\n\n ctx.effect(() => harness.handle('atlas.saveTexture', async (args) => {\n const request = args || {}\n const root = typeof request.root === 'string' ? request.root : ''\n const projectId = typeof request.project === 'string' ? request.project : ''\n const path = typeof request.path === 'string' ? request.path : ''\n const base64 = typeof request.base64 === 'string' ? request.base64 : ''\n if (path === '' || base64 === '') return { error: '缺少贴图路径或图像数据' }\n try {\n const project = await projectFor(root, projectId)\n if (project === undefined) return { error: '找不到项目:' + projectId }\n let target = path\n if (path.slice(0, 4) === 'ref:') {\n const resolved = editablePaths.get(path)\n if (resolved === undefined) {\n const pack0 = project.dir + '/pack/assets/'\n return { error: '这张贴图不在这个项目的资源包里(它在参考的 jar 里),改不了:' + path }\n }\n target = resolved\n }\n const pack = project.dir + '/pack/assets/'\n if (target.indexOf(pack) !== 0) {\n return { error: '这张贴图不在这个项目的资源包里,拒绝写:' + target }\n }\n if (shell === undefined) return { error: '宿主没有 shell 服务,写不了二进制文件' }\n const temp = target + '.mcart-tmp'\n const policy = policyFor(root)\n async function discard(reason) {\n const cleanup = await shellFileOp(root, (dialect) => dialect.remove(temp), 15000)\n return { error: reason + (cleanup.ok === true ? '(原文件没有被改动)' : '(临时文件也没清掉:' + temp + ')') }\n }\n const written = await writeDecodedFile(temp, base64, root, 30000)\n if (written.ok !== true) {\n const detail = beforeStderr(written.err === undefined || written.err === null ? '' : written.err)\n return await discard('写入失败(退出码 ' + written.exitCode + ',第 ' + written.step + '/' + written.steps + ' 步)' +\n (detail === '' ? ',没有错误输出' : ':' + detail))\n }\n const staged = await statOf(temp)\n if (staged === undefined || staged.type !== 'file') return await discard('写完之后读不到临时文件')\n if (staged.size === undefined || staged.size === 0) return await discard('写出来的是个空文件')\n const bytes = await fs.readBytes(await fs.resolve(temp), undefined, 8 * 1024 * 1024)\n for (let i = 0; i < PNG_MAGIC.length; i++) {\n if (bytes[i] !== PNG_MAGIC[i]) {\n return await discard('写出来的不是 PNG(开头 ' + Array.prototype.slice.call(bytes, 0, 8).join(',') + ')')\n }\n }\n const moved = await shellFileOp(root, (dialect) => dialect.move(temp, target), 20000)\n if (moved.ok !== true) {\n const detail = beforeStderr(moved.err === undefined || moved.err === null ? '' : moved.err)\n return await discard('换上新图失败(退出码 ' + moved.exitCode + ')' + (detail === '' ? '' : ':' + detail))\n }\n const info = await statOf(target)\n if (info === undefined || info.type !== 'file') return { error: '换上新图之后读不到它:' + target }\n return { saved: true, path: target, bytes: info.size }\n } catch (error) {\n return { error: String(error && error.message ? error.message : error) }\n }\n }))\n\n ctx.effect(() => harness.handle('atlas.saveVoxel', async (args) => {\n const request = args || {}\n const root = typeof request.root === 'string' ? request.root : ''\n const projectId = typeof request.project === 'string' ? request.project : ''\n const kind = request.kind === 'biome' ? 'biome' : (request.kind === 'structure' ? 'structure' : '')\n const id = typeof request.id === 'string' ? request.id : ''\n if (kind === '' || id === '') return { error: '只有群系和多方块结构能这样改' }\n const cells = normalizeCells(request.cells)\n if (cells.length === 0) return { error: '一格都没有,拒绝把结构写空' }\n try {\n const project = await projectFor(root, projectId)\n if (project === undefined) return { error: '找不到项目:' + projectId }\n const taken = {}\n for (const cell of cells) {\n const at = cell.at.join(',')\n if (taken[at] === true) return { error: '同一个格子放了两个方块:' + at }\n taken[at] = true\n }\n const file = project.dir + '/mc-art.atlas.json'\n const atlas = await readJson(file)\n if (atlas === undefined) return { error: '读不了图谱:' + file }\n const key = kind === 'biome' ? 'biomes' : 'structures'\n const list = Array.isArray(atlas[key]) ? atlas[key] : []\n const item = list.filter((entry) => entry.id === id)[0]\n if (item === undefined) return { error: '图谱里没有这个 ' + kind + ':' + id }\n item.cells = cells\n const outcome = await writeTextFile(file, JSON.stringify(atlas, null, 2) + '\\n', root)\n if (outcome.ok !== true) return { error: String(outcome.detail) }\n return { saved: true, file: file, kind: kind, id: id, cells: cells.length, via: outcome.via }\n } catch (error) {\n return { error: String(error && error.message ? error.message : error) }\n }\n }))\n\n ctx.effect(() => harness.handle('atlas.preview', async (args) => {\n const request = args || {}\n const root = typeof request.root === 'string' ? request.root : ''\n const projectId = typeof request.project === 'string' ? request.project : ''\n const block = typeof request.block === 'string' ? request.block : ''\n const at = Array.isArray(request.at) && request.at.length >= 3\n ? [Math.trunc(request.at[0]), Math.trunc(request.at[1]), Math.trunc(request.at[2])] : [0, 0, 0]\n const variant = typeof request.variant === 'string' && request.variant !== '' ? request.variant : null\n const around = Array.isArray(request.around) ? request.around.slice(0, 32) : []\n if (block === '') return { error: '预览需要方块名' }\n try {\n const project = await projectFor(root, projectId)\n if (project === undefined) return { error: '找不到项目:' + projectId }\n const built = await blockQuadsAt(root, project, block, at, variant, around)\n if (built.error !== undefined) return { error: built.error }\n const textureIds = []\n const seen = {}\n for (const quad of built.quads) if (seen[quad.tex] !== true) { seen[quad.tex] = true; textureIds.push(quad.tex) }\n const already = {}\n if (Array.isArray(request.have)) for (const path of request.have) already[path] = true\n const textures = {}\n for (const path of textureIds) {\n if (already[path] === true) continue\n const url = await textureUrlFor(path)\n if (url !== undefined) textures[path] = url\n }\n return { block: block, at: at, quads: built.quads, textureIds: textureIds, textures: textures,\n animations: animationsFor(textureIds),\n variants: built.variants || [], axes: built.axes || [], defaults: built.defaults || {},\n multipart: built.multipart === true, derived: built.derived === true,\n variant: built.variant === undefined ? null : built.variant }\n } catch (error) {\n return { error: String(error && error.message ? error.message : error) }\n }\n }))\n\n ctx.effect(() => harness.handle('atlas.icons', async (args) => {\n const request = args || {}\n const root = typeof request.root === 'string' ? request.root : ''\n const projectId = typeof request.project === 'string' ? request.project : ''\n const wanted = Array.isArray(request.blocks) ? request.blocks.slice(0, 120) : []\n try {\n const project = await projectFor(root, projectId)\n if (project === undefined) return { error: '找不到项目:' + projectId }\n const icons = {}\n const failed = []\n for (const block of wanted) {\n if (typeof block !== 'string' || block === '' || icons[block] !== undefined) continue\n const reference = block\n const colon = reference.indexOf(':')\n const namespace = colon >= 0 ? reference.slice(0, colon) : project.namespace\n const name = colon >= 0 ? reference.slice(colon + 1) : reference\n const load = await ensure(namespace)\n if (load === undefined) { failed.push(reference + '(资源包 ' + namespace + ' 没载入)'); continue }\n const elements = elementsOf(load, 'block/' + name)\n if (elements === undefined) { failed.push(reference + '(没有模型)'); continue }\n const first = elements.length > 0 && elements[0].faces !== undefined\n ? Object.keys(elements[0].faces)[0] : undefined\n const tex = first === undefined ? undefined : elements[0].faces[first].tex\n const url = tex === undefined ? undefined : await textureUrlFor(tex)\n if (url === undefined) { failed.push(reference + '(没有贴图)'); continue }\n icons[block] = url\n }\n return { icons: icons, failed: failed.slice(0, 6) }\n } catch (error) {\n return { error: String(error && error.message ? error.message : error) }\n }\n }))\n\n ctx.effect(() => harness.handle('atlas.scan', async (args) => {\n const request = args || {}\n const root = typeof request.root === 'string' ? request.root : ''\n if (root === '') return { rootSpecified: false, projects: [], errors: [] }\n errors.length = 0\n const index = await indexOf(root)\n const projects = index.projects.map((project) => ({\n id: project.id, title: project.title, namespace: project.namespace, root: project.root,\n items: {\n biome: project.biomes.map((item) => ({ id: item.id, title: item.title })),\n structure: project.structures.map((item) => ({ id: item.id, title: item.title })),\n entity: project.entities.map((item) => ({ id: item.id, title: item.title })),\n block: project.blocks.map((item) => ({ id: item.id, title: item.title })),\n },\n }))\n return { rootSpecified: true, root: root, projects: projects, errors: errors.slice(0, 8), cached: index.cached }\n }))\n\n ctx.effect(() => harness.handle('atlas.projects', async (args) => {\n const request = args || {}\n const base = typeof request.path === 'string' ? request.path : ''\n if (base === '') return { error: '没有给定目录' }\n try {\n const parents = []\n const addParent = (value) => { if (value !== null && value !== undefined && parents.indexOf(value) < 0) parents.push(value) }\n if (await looksLikeProject(base)) addParent(parentOf(base))\n const entries = await listDir(base)\n for (const entry of entries) {\n if (entry.type !== 'directory') continue\n const child = entry.target !== undefined && typeof entry.target.displayPath === 'string' && entry.target.displayPath.length > 0\n ? entry.target.displayPath : base + '/' + entry.name\n if (await looksLikeProject(child)) addParent(base)\n }\n const results = []\n for (const parent of parents) {\n const index = await indexOf(parent)\n for (const project of index.projects) {\n results.push({ root: parent, id: project.id, title: project.title, namespace: project.namespace, dir: project.dir })\n }\n }\n return { base: base, projects: results }\n } catch (error) {\n return { error: String(error && error.message ? error.message : error) }\n }\n }))\n\n ctx.effect(() => harness.handle('atlas.scene', async (args) => {\n const request = args || {}\n const root = typeof request.root === 'string' ? request.root : ''\n if (root === '') return { error: 'no asset root specified' }\n try {\n const index = await indexOf(root)\n const project = index.projects.filter((item) => item.id === request.project)[0]\n if (project === undefined) return { error: 'project not found: ' + request.project }\n return await payload(root, project, request.kind, request.id, request.have, request.cells,\n request.neighbours)\n } catch (error) {\n return { error: String(error && error.message ? error.message : error) }\n }\n }))\n\n ctx.effect(() => harness.handle('atlas.session', async (args) => {\n const request = args || {}\n try {\n const id = typeof request.sessionId === 'string' ? request.sessionId : ''\n const session = (sessions === undefined || id === '') ? undefined : sessions.get(id)\n if (session === undefined) return { cwd: null }\n const cwd = session.header.cwd\n return { cwd: typeof cwd === 'string' ? cwd : null }\n } catch (error) {\n return { cwd: null, error: String(error && error.message ? error.message : error) }\n }\n }))\n\n ctx.effect(() => harness.handle('atlas.refNamespaces', async (args) => {\n const request = args || {}\n const root = typeof request.root === 'string' ? request.root : ''\n const projectId = typeof request.project === 'string' ? request.project : ''\n try {\n const project = await projectFor(root, projectId)\n if (project === undefined) return { error: '找不到项目:' + projectId }\n const directory = await referenceDirectory(project)\n if (directory === '') return { namespaces: [], directory: '', reason: '没有设置参考目录' }\n const extractor = await findTool(project.dir, EXTRACT_SCRIPT)\n if (extractor === null || extractor === undefined) return { error: '找不到 ' + EXTRACT_SCRIPT }\n const parsed = await runScanner(extractor, ['--root', directory, '--namespaces'],\n 120000, project.dir, REFERENCE_MAX_BYTES)\n if (parsed === undefined || parsed.error !== undefined) {\n return { error: String(parsed === undefined ? '没有返回任何东西' : parsed.error) }\n }\n const out = (parsed.namespaces || []).filter((item) => item.blocks > 0)\n return { directory: directory, version: parsed.version, versionWhy: parsed.versionWhy,\n namespaces: out, extractor: extractor }\n } catch (error) {\n return { error: String(error && error.message ? error.message : error) }\n }\n }))\n\n ctx.effect(() => harness.handle('atlas.refItems', async (args) => {\n const request = args || {}\n const root = typeof request.root === 'string' ? request.root : ''\n const projectId = typeof request.project === 'string' ? request.project : ''\n const namespace = typeof request.namespace === 'string' ? request.namespace : ''\n const source = request.source === 'project' ? 'project' : 'reference'\n if (namespace === '') return { error: '没有指定命名空间' }\n try {\n const project = await projectFor(root, projectId)\n if (project === undefined) return { error: '找不到项目:' + projectId }\n return await itemFactsList(project, namespace, source)\n } catch (error) {\n return { error: String(error && error.message ? error.message : error) }\n }\n }))\n\n ctx.effect(() => harness.handle('atlas.itemIcons', async (args) => {\n const request = args || {}\n const root = typeof request.root === 'string' ? request.root : ''\n const projectId = typeof request.project === 'string' ? request.project : ''\n const namespace = typeof request.namespace === 'string' ? request.namespace : ''\n const wanted = Array.isArray(request.items) ? request.items.slice(0, 60) : []\n const source = request.source === 'project' ? 'project' : 'reference'\n if (namespace === '' || wanted.length === 0) return { items: {} }\n try {\n const project = await projectFor(root, projectId)\n if (project === undefined) return { error: '找不到项目:' + projectId }\n return await itemIconPage(project, namespace, wanted.filter((id) => typeof id === 'string' && id !== ''), source)\n } catch (error) {\n return { error: String(error && error.message ? error.message : error) }\n }\n }))\n\n ctx.effect(() => harness.handle('atlas.icon', async (args) => {\n const request = args || {}\n const root = typeof request.root === 'string' ? request.root : ''\n const projectId = typeof request.project === 'string' ? request.project : ''\n const namespace = typeof request.namespace === 'string' ? request.namespace : ''\n const item = typeof request.item === 'string' ? request.item : ''\n const source = request.source === 'project' ? 'project' : 'reference'\n if (namespace === '' || item === '') return { error: '图标需要一个命名空间和一个物品名' }\n try {\n const project = await projectFor(root, projectId)\n if (project === undefined) return { error: '找不到项目:' + projectId }\n const entry = await referenceLoadItem(root, project, namespace, item, source)\n if (entry.error !== undefined) return { error: entry.error }\n return await itemIcon(namespace, entry.parsed, request.have, project)\n } catch (error) {\n return { error: String(error && error.message ? error.message : error) }\n }\n }))\n\n ctx.effect(() => harness.handle('atlas.refBlocks', async (args) => {\n const request = args || {}\n const root = typeof request.root === 'string' ? request.root : ''\n const projectId = typeof request.project === 'string' ? request.project : ''\n const namespace = typeof request.namespace === 'string' ? request.namespace : ''\n if (namespace === '') return { error: '没有指定命名空间' }\n try {\n const project = await projectFor(root, projectId)\n if (project === undefined) return { error: '找不到项目:' + projectId }\n const directory = await referenceDirectory(project)\n if (directory === '') return { error: '还没有设置参考目录' }\n const extractor = await findTool(project.dir, EXTRACT_SCRIPT)\n if (extractor === null || extractor === undefined) return { error: '找不到 ' + EXTRACT_SCRIPT }\n const signature = await refSignatureOf(directory)\n const cacheKey = 'list:' + namespace\n const cached = referenceBlocks.get(cacheKey)\n if (cached !== undefined && cached.signature === signature) return cached.value\n const parsed = await runScanner(extractor, ['--root', directory, '--list', '--namespace', namespace],\n 120000, project.dir, REFERENCE_MAX_BYTES)\n if (parsed === undefined || parsed.error !== undefined) {\n return { error: String(parsed === undefined ? '没有返回任何东西' : parsed.error) }\n }\n const value = { namespace: namespace, version: parsed.version, blocks: parsed.blocks || [] }\n referenceBlocks.set(cacheKey, { signature: signature, value: value })\n return value\n } catch (error) {\n return { error: String(error && error.message ? error.message : error) }\n }\n }))\n\n ctx.effect(() => harness.handle('atlas.refIcons', async (args) => {\n const request = args || {}\n const root = typeof request.root === 'string' ? request.root : ''\n const projectId = typeof request.project === 'string' ? request.project : ''\n const namespace = typeof request.namespace === 'string' ? request.namespace : ''\n const wanted = Array.isArray(request.blocks) ? request.blocks.slice(0, 120) : []\n if (namespace === '' || wanted.length === 0) return { icons: {}, names: {} }\n try {\n const project = await projectFor(root, projectId)\n if (project === undefined) return { error: '找不到项目:' + projectId }\n const directory = await referenceDirectory(project)\n if (directory === '') return { error: '还没有设置参考目录' }\n const extractor = await findTool(project.dir, EXTRACT_SCRIPT)\n if (extractor === null || extractor === undefined) return { error: '找不到 ' + EXTRACT_SCRIPT }\n const parsed = await runScanner(extractor,\n ['--root', directory, '--namespace', namespace, '--icons', wanted.join(',')],\n 180000, project.dir, REFERENCE_MAX_BYTES)\n if (parsed === undefined || parsed.error !== undefined) {\n return { error: String(parsed === undefined ? '没有返回任何东西' : parsed.error) }\n }\n const icons = {}\n const names = {}\n for (const id of Object.keys(parsed.icons || {})) {\n const item = parsed.icons[id]\n icons[namespace + ':' + id] = 'data:image/png;base64,' + item.png\n names[namespace + ':' + id] = item.name\n }\n return { icons: icons, names: names, failed: parsed.failed || [], version: parsed.version }\n } catch (error) {\n return { error: String(error && error.message ? error.message : error) }\n }\n }))\n\n ctx.effect(() => harness.handle('atlas.releaseRefs', async (args) => {\n const request = args || {}\n const keep = {}\n if (Array.isArray(request.keep)) for (const key of request.keep) keep[String(key)] = true\n return releaseUnused(referenceBlocks, keep, referenceTextures, referenceAnimations)\n }))\n\n console.log('mc-art atlas host ready (unqualified cell blocks resolve to their project; vanilla and mod blocks are extracted on demand)')\n },\n}\n"
|
|
4
|
+
|
|
5
|
+
export const name = 'mcart-panel'
|
|
6
|
+
// 宿主半自己用 ctx.get(...) 取 fs / sessions / shell / webServer,不硬依赖任何服务。
|
|
7
|
+
export const inject = []
|
|
8
|
+
|
|
9
|
+
export function apply(ctx) {
|
|
10
|
+
// 动态插件里有 harness.handle;真包里没有,所以这里做一个同形状的垫片:
|
|
11
|
+
// 收成一张表,再由下面那条路由统一派发。host.js 一行都不用改。
|
|
12
|
+
const handlers = {}
|
|
13
|
+
const harness = {
|
|
14
|
+
handle(method, handler) {
|
|
15
|
+
handlers[method] = handler
|
|
16
|
+
return () => { delete handlers[method] }
|
|
17
|
+
},
|
|
18
|
+
}
|
|
19
|
+
const plugin = new Function('harness', 'console', 'TextEncoder', 'btoa', 'atob', SOURCE)(
|
|
20
|
+
harness, console, TextEncoder, btoa, atob)
|
|
21
|
+
if (plugin === null || typeof plugin !== 'object' || typeof plugin.apply !== 'function') {
|
|
22
|
+
throw new Error('mcart 宿主源码没有返回一个带 apply 的插件')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// 一条路由,全部信道。客户端那边 host.call(name, args) 走的就是它。
|
|
26
|
+
const MAX_BYTES = 16 * 1024 * 1024
|
|
27
|
+
ctx.inject(['webServer'], (httpCtx) => {
|
|
28
|
+
httpCtx.webServer.register({
|
|
29
|
+
kind: 'exact',
|
|
30
|
+
path: '/api/mcart/call',
|
|
31
|
+
handler: (req, res) => {
|
|
32
|
+
function send(status, payload) {
|
|
33
|
+
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
|
|
34
|
+
res.end(JSON.stringify(payload))
|
|
35
|
+
}
|
|
36
|
+
if ((req.method ?? 'GET').toUpperCase() !== 'POST') {
|
|
37
|
+
send(405, { error: '这条路由只收 POST' })
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
const chunks = []
|
|
41
|
+
let size = 0
|
|
42
|
+
req.on('data', (chunk) => {
|
|
43
|
+
size += chunk.length
|
|
44
|
+
if (size > MAX_BYTES) { req.destroy(); return }
|
|
45
|
+
chunks.push(chunk)
|
|
46
|
+
})
|
|
47
|
+
req.on('end', async () => {
|
|
48
|
+
let request
|
|
49
|
+
try {
|
|
50
|
+
request = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')
|
|
51
|
+
} catch (error) {
|
|
52
|
+
send(400, { error: '请求不是 JSON:' + String(error && error.message ? error.message : error) })
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
const name = typeof request.name === 'string' ? request.name : ''
|
|
56
|
+
const handler = handlers[name]
|
|
57
|
+
if (typeof handler !== 'function') {
|
|
58
|
+
send(404, { error: '宿主没有这个方法:' + name })
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
const value = await handler(request.args === undefined ? null : request.args)
|
|
63
|
+
send(200, value === undefined ? null : value)
|
|
64
|
+
} catch (error) {
|
|
65
|
+
send(500, { error: String(error && error.message ? error.message : error) })
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
req.on('error', () => { send(400, { error: '请求读失败' }) })
|
|
69
|
+
},
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
return plugin.apply(ctx)
|
|
74
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-mc-art-panel",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MC 资产面板:3D+物品栏、像素/结构编辑器、@ 提意见;配套 mc-mod / mc-art 两个 skill 与 GameTest 判定闭环",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./lib/index.js",
|
|
9
|
+
"./client": "./lib/client.js",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"lib",
|
|
14
|
+
"cordis.patch.yml",
|
|
15
|
+
"build.mjs",
|
|
16
|
+
"README.md"
|
|
17
|
+
],
|
|
18
|
+
"keywords": [
|
|
19
|
+
"deepseek-harness",
|
|
20
|
+
"dsh",
|
|
21
|
+
"dsh-plugin",
|
|
22
|
+
"minecraft",
|
|
23
|
+
"minecraft-mod",
|
|
24
|
+
"mc-art",
|
|
25
|
+
"gametest"
|
|
26
|
+
],
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"dsh": {
|
|
29
|
+
"bundle": {
|
|
30
|
+
"patch": "./cordis.patch.yml"
|
|
31
|
+
},
|
|
32
|
+
"entryIds": [
|
|
33
|
+
"mcart-panel"
|
|
34
|
+
],
|
|
35
|
+
"client": {
|
|
36
|
+
"inject": [
|
|
37
|
+
"@deepseek-ai/dsh-cordis-client-runner",
|
|
38
|
+
"@deepseek-ai/dsh-client-ui-renderer",
|
|
39
|
+
"@deepseek-ai/dsh-api-session-controller",
|
|
40
|
+
"@deepseek-ai/dsh-client-ui-conversation"
|
|
41
|
+
],
|
|
42
|
+
"platform": "web",
|
|
43
|
+
"immediately": true
|
|
44
|
+
},
|
|
45
|
+
"compatibility": {
|
|
46
|
+
"dsh": ">=0.1.0-rc.8 <0.2.0",
|
|
47
|
+
"node": ">=20"
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"repository": {
|
|
51
|
+
"type": "git",
|
|
52
|
+
"url": "git+https://github.com/GMH13552/dsh-mc-art.git",
|
|
53
|
+
"directory": "panel"
|
|
54
|
+
},
|
|
55
|
+
"homepage": "https://github.com/GMH13552/dsh-mc-art#readme",
|
|
56
|
+
"bugs": {
|
|
57
|
+
"url": "https://github.com/GMH13552/dsh-mc-art/issues"
|
|
58
|
+
},
|
|
59
|
+
"publishConfig": {
|
|
60
|
+
"access": "public"
|
|
61
|
+
},
|
|
62
|
+
"scripts": {
|
|
63
|
+
"prepublishOnly": "node verify-build.mjs && node entry-test.mjs",
|
|
64
|
+
"verify": "node verify-build.mjs && node entry-test.mjs"
|
|
65
|
+
}
|
|
66
|
+
}
|