modelstage 0.0.0-stage → 0.5.28
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/LICENSE +21 -0
- package/README.md +274 -2
- package/THIRD_PARTY_NOTICES.md +90 -0
- package/bin/modelstage.mjs +137 -0
- package/config/diag-upload.example.json +7 -0
- package/config/models/live2d.assetbundls.json +1 -0
- package/config/models/live2d.azurlane.json +1 -0
- package/config/models/live2d.azurlane_bundle.json +1 -0
- package/config/models/live2d.azurlane_spine.json +1 -0
- package/config/models/live2d.bd2.json +1 -0
- package/config/models/live2d.bluearchive.json +1 -0
- package/config/models/live2d.live2d.json +1 -0
- package/config/models/live2d.nikke.json +1 -0
- package/config/models/live2d.nikke_2.json +1 -0
- package/config/models/live2d.npc.json +1 -0
- package/config/models/spine.assetbundls.json +1 -0
- package/config/models/spine.azurlane.json +1 -0
- package/config/models/spine.azurlane_bundle.json +1 -0
- package/config/models/spine.azurlane_spine.json +1 -0
- package/config/models/spine.bd2.json +1 -0
- package/config/models/spine.bluearchive.json +1 -0
- package/config/models/spine.live2d.json +1 -0
- package/config/models/spine.nikke.json +1 -0
- package/config/models/spine.nikke_2.json +1 -0
- package/config/models/spine.npc.json +1 -0
- package/config/names/NIKKE.json +1058 -0
- package/config/names/README.md +47 -0
- package/config/names//345/244/251/345/221/275/344/271/213/345/255/220.json +42897 -0
- package/config/names//346/243/225/350/211/262/345/260/230/345/237/2032.json +4016 -0
- package/config/names//347/242/247/350/223/235/350/210/252/347/272/277.json +38342 -0
- package/config/plugins.json +13 -0
- package/config/resources.example.json +40 -0
- package/package.json +63 -4
- package/plugins/unity-live2d-bundle/README.md +98 -0
- package/plugins/unity-live2d-bundle/index.js +891 -0
- package/plugins/unity-live2d-bundle/lib/anim-to-motion.js +402 -0
- package/plugins/unity-live2d-bundle/lib/bundle-probe.js +244 -0
- package/plugins/unity-live2d-bundle/lib/bytes.js +189 -0
- package/plugins/unity-live2d-bundle/lib/common-strings.js +46 -0
- package/plugins/unity-live2d-bundle/lib/dxt.js +197 -0
- package/plugins/unity-live2d-bundle/lib/errors.js +71 -0
- package/plugins/unity-live2d-bundle/lib/etc2.js +371 -0
- package/plugins/unity-live2d-bundle/lib/extract.js +242 -0
- package/plugins/unity-live2d-bundle/lib/lzma-d-min.LICENSE.txt +21 -0
- package/plugins/unity-live2d-bundle/lib/lzma-d-min.js +34 -0
- package/plugins/unity-live2d-bundle/lib/model3.js +137 -0
- package/plugins/unity-live2d-bundle/lib/png.js +424 -0
- package/plugins/unity-live2d-bundle/lib/serialized.js +233 -0
- package/plugins/unity-live2d-bundle/lib/texdecode.js +190 -0
- package/plugins/unity-live2d-bundle/lib/typetree.js +215 -0
- package/plugins/unity-live2d-bundle/lib/unityfs.js +396 -0
- package/plugins/unity-live2d-bundle/plugin.json +81 -0
- package/plugins/unity-live2d-bundle/profiles/unityfs-live2d.json +26 -0
- package/plugins/unity-spine-bundle/index.js +535 -0
- package/plugins/unity-spine-bundle/lib/bytes.js +189 -0
- package/plugins/unity-spine-bundle/lib/common-strings.js +46 -0
- package/plugins/unity-spine-bundle/lib/dxt.js +197 -0
- package/plugins/unity-spine-bundle/lib/errors.js +79 -0
- package/plugins/unity-spine-bundle/lib/etc2.js +371 -0
- package/plugins/unity-spine-bundle/lib/extract.js +242 -0
- package/plugins/unity-spine-bundle/lib/lzma-d-min.LICENSE.txt +21 -0
- package/plugins/unity-spine-bundle/lib/lzma-d-min.js +34 -0
- package/plugins/unity-spine-bundle/lib/png.js +424 -0
- package/plugins/unity-spine-bundle/lib/serialized.js +233 -0
- package/plugins/unity-spine-bundle/lib/texdecode.js +190 -0
- package/plugins/unity-spine-bundle/lib/typetree.js +215 -0
- package/plugins/unity-spine-bundle/lib/unityfs.js +396 -0
- package/plugins/unity-spine-bundle/plugin.json +75 -0
- package/src/server/README.md +69 -0
- package/src/server/assets/browse.mjs +56 -0
- package/src/server/assets/bundle-probe.mjs +94 -0
- package/src/server/assets/probe.mjs +184 -0
- package/src/server/core/config.mjs +173 -0
- package/src/server/core/env-num.mjs +60 -0
- package/src/server/core/env.mjs +49 -0
- package/src/server/core/fs-portable.mjs +37 -0
- package/src/server/core/log.mjs +107 -0
- package/src/server/core/paths.mjs +44 -0
- package/src/server/data/catalog.mjs +1091 -0
- package/src/server/data/entry-states.mjs +499 -0
- package/src/server/data/live2d.mjs +272 -0
- package/src/server/data/manifest-cache.mjs +58 -0
- package/src/server/data/manifest-stamp.mjs +135 -0
- package/src/server/data/model-meta.mjs +114 -0
- package/src/server/data/names.mjs +106 -0
- package/src/server/data/shape-probe-worker.mjs +33 -0
- package/src/server/data/shape-probe.mjs +626 -0
- package/src/server/data/spine.mjs +336 -0
- package/src/server/data/tree.mjs +126 -0
- package/src/server/http/atomic-json.mjs +145 -0
- package/src/server/http/background-solids.mjs +270 -0
- package/src/server/http/backgrounds.mjs +388 -0
- package/src/server/http/diag-upload.mjs +264 -0
- package/src/server/http/diag.mjs +173 -0
- package/src/server/http/ready.mjs +167 -0
- package/src/server/http/static.mjs +312 -0
- package/src/server/http/write.mjs +654 -0
- package/src/server/index.mjs +114 -0
- package/src/server/plugins/builtin-providers.json +30 -0
- package/src/server/plugins/registry.mjs +650 -0
- package/src/server/plugins/slot-catalog.mjs +286 -0
- package/src/server/routes/background-solids.mjs +56 -0
- package/src/server/routes/backgrounds.mjs +77 -0
- package/src/server/routes/config.mjs +38 -0
- package/src/server/routes/data.mjs +313 -0
- package/src/server/routes/diag.mjs +37 -0
- package/src/server/routes/entries.mjs +170 -0
- package/src/server/routes/ready.mjs +122 -0
- package/src/server/serve.mjs +396 -0
- package/src/server/unpack/capacity.mjs +217 -0
- package/src/server/unpack/mem-probe.mjs +27 -0
- package/src/server/unpack/plugin-runner.mjs +529 -0
- package/src/server/unpack/plugin-store.mjs +736 -0
- package/src/server/unpack/probe-store.mjs +73 -0
- package/src/server/unpack/routes.mjs +359 -0
- package/src/server/unpack/runtime/exec.mjs +558 -0
- package/src/server/unpack/runtime/index.mjs +141 -0
- package/src/server/unpack/runtime/node-vm.mjs +111 -0
- package/src/server/unpack/unpack-worker.mjs +105 -0
- package/src/server/unpack/worker-client.mjs +184 -0
- package/src/server/unpack/write-result.mjs +84 -0
- package/src/shared/bundle-shape.mjs +83 -0
- package/src/shared/entry-reasons.mjs +119 -0
- package/src/shared/names.mjs +45 -0
- package/src/shared/type-labels.mjs +31 -0
- package/src/web/README.md +193 -0
- package/src/web/app/anim-list.js +222 -0
- package/src/web/app/background-solids.js +150 -0
- package/src/web/app/backgrounds.js +152 -0
- package/src/web/app/camera-log.js +28 -0
- package/src/web/app/context.js +72 -0
- package/src/web/app/dir-picker.js +242 -0
- package/src/web/app/entry-report.js +71 -0
- package/src/web/app/memory-bars.js +91 -0
- package/src/web/app/memory-budget.js +228 -0
- package/src/web/app/memory-guard.js +204 -0
- package/src/web/app/model-actions.js +682 -0
- package/src/web/app/renderer-setting.js +70 -0
- package/src/web/app/root-manager.js +254 -0
- package/src/web/app/status.js +138 -0
- package/src/web/app/topbar-actions.js +357 -0
- package/src/web/app/transport-actions.js +297 -0
- package/src/web/app/ui-state.js +559 -0
- package/src/web/app/url-params.js +171 -0
- package/src/web/app/view-settings.js +186 -0
- package/src/web/components.css +524 -0
- package/src/web/debug/diag-hud.js +95 -0
- package/src/web/debug/drag.js +369 -0
- package/src/web/debug/log.js +320 -0
- package/src/web/debug/model-cmp.js +292 -0
- package/src/web/debug/panel.css +474 -0
- package/src/web/debug/panel.js +712 -0
- package/src/web/debug/race-probe.js +82 -0
- package/src/web/debug/upload.js +130 -0
- package/src/web/engine/adapters/atlas.js +47 -0
- package/src/web/engine/adapters/index.js +22 -0
- package/src/web/engine/adapters/renderer.js +223 -0
- package/src/web/engine/adapters/skeleton.js +105 -0
- package/src/web/engine/adapters/texture.js +64 -0
- package/src/web/engine/animation.js +181 -0
- package/src/web/engine/appearance.js +136 -0
- package/src/web/engine/camera-adapter.js +212 -0
- package/src/web/engine/effect-chain.js +138 -0
- package/src/web/engine/engine-state.js +120 -0
- package/src/web/engine/filters.js +224 -0
- package/src/web/engine/fit.js +197 -0
- package/src/web/engine/frame-gate.js +33 -0
- package/src/web/engine/gl-driver.js +56 -0
- package/src/web/engine/gl.js +77 -0
- package/src/web/engine/index.js +20 -0
- package/src/web/engine/loader.js +55 -0
- package/src/web/engine/model-info.js +26 -0
- package/src/web/engine/pipeline.js +200 -0
- package/src/web/engine/player.js +427 -0
- package/src/web/engine/pma/blend.js +76 -0
- package/src/web/engine/pma/detect.js +118 -0
- package/src/web/engine/pma/index.js +8 -0
- package/src/web/engine/pma/resolve.js +112 -0
- package/src/web/engine/pma/upload.js +127 -0
- package/src/web/engine/pma.js +5 -0
- package/src/web/engine/render-frame.js +93 -0
- package/src/web/engine/runtime-capabilities.js +209 -0
- package/src/web/engine/runtime.js +37 -0
- package/src/web/engine/skins.js +165 -0
- package/src/web/engine/textures.js +174 -0
- package/src/web/index.html +117 -0
- package/src/web/infra/api.js +531 -0
- package/src/web/infra/diag.js +509 -0
- package/src/web/infra/document.js +269 -0
- package/src/web/infra/dom.js +31 -0
- package/src/web/infra/events.mjs +27 -0
- package/src/web/infra/log.js +153 -0
- package/src/web/infra/mem-stats.js +112 -0
- package/src/web/infra/plugin-host-registration.mjs +285 -0
- package/src/web/infra/plugin-host-report.mjs +32 -0
- package/src/web/infra/plugin-host-unload.mjs +79 -0
- package/src/web/infra/plugin-host-unpack.mjs +899 -0
- package/src/web/infra/plugin-host-warmup.mjs +180 -0
- package/src/web/infra/plugin-host.js +628 -0
- package/src/web/infra/ready.js +461 -0
- package/src/web/infra/schedule.js +51 -0
- package/src/web/infra/shortcuts.js +129 -0
- package/src/web/infra/slot-registry.js +402 -0
- package/src/web/infra/vfs.js +247 -0
- package/src/web/live2d/bridge.js +400 -0
- package/src/web/live2d/model.js +321 -0
- package/src/web/live2d/overlay.js +142 -0
- package/src/web/live2d/pixi-app.js +111 -0
- package/src/web/live2d/playback-probe.js +92 -0
- package/src/web/live2d/probe-geometry.js +329 -0
- package/src/web/live2d/probe.js +119 -0
- package/src/web/live2d/release.js +66 -0
- package/src/web/live2d/runtime-adapter.js +289 -0
- package/src/web/live2d/stage-ctx.js +152 -0
- package/src/web/live2d/stage.js +204 -0
- package/src/web/live2d/vfs-settings.js +174 -0
- package/src/web/live2d/view.js +89 -0
- package/src/web/main.js +629 -0
- package/src/web/panel/background-list.js +373 -0
- package/src/web/panel/bg-source.js +291 -0
- package/src/web/panel/color-picker.css +199 -0
- package/src/web/panel/color-picker.js +278 -0
- package/src/web/panel/dialog.css +116 -0
- package/src/web/panel/dialog.js +173 -0
- package/src/web/panel/dom.js +35 -0
- package/src/web/panel/icons.js +58 -0
- package/src/web/panel/memory-bar.js +195 -0
- package/src/web/panel/panel-settings.js +1263 -0
- package/src/web/panel/panel.css +1304 -0
- package/src/web/panel/render-choice.js +104 -0
- package/src/web/panel/schema.js +784 -0
- package/src/web/panel/settings-io.js +291 -0
- package/src/web/panel/shell-controls.js +274 -0
- package/src/web/panel/slider.js +210 -0
- package/src/web/panel/toolbar-schema.js +111 -0
- package/src/web/panel/toolbar.js +195 -0
- package/src/web/panel/transport-picker.js +375 -0
- package/src/web/panel/transport.js +321 -0
- package/src/web/panel/view.js +553 -0
- package/src/web/plugin/unpack-worker.js +185 -0
- package/src/web/render/camera/constants.js +60 -0
- package/src/web/render/camera/index.js +123 -0
- package/src/web/render/camera/spine.js +124 -0
- package/src/web/render/capture.js +30 -0
- package/src/web/render/interaction.js +527 -0
- package/src/web/render/live2d.js +627 -0
- package/src/web/render/playback.js +47 -0
- package/src/web/render/reset-view.js +29 -0
- package/src/web/render/spine.js +386 -0
- package/src/web/render/surface-switch.js +176 -0
- package/src/web/render/surface.js +571 -0
- package/src/web/render/surfaces.js +70 -0
- package/src/web/roster/filter.js +31 -0
- package/src/web/roster/list.js +276 -0
- package/src/web/roster/persist.js +92 -0
- package/src/web/roster/reveal.js +77 -0
- package/src/web/roster/rows.js +248 -0
- package/src/web/roster/snapshot.js +39 -0
- package/src/web/roster/type-filter.js +97 -0
- package/src/web/roster/view.js +761 -0
- package/src/web/spine/canvas-api-groups.js +267 -0
- package/src/web/spine/canvas-api.js +97 -0
- package/src/web/spine/canvas-document.js +80 -0
- package/src/web/spine/canvas-engine.js +193 -0
- package/src/web/spine/canvas-entry.js +28 -0
- package/src/web/spine/canvas-pixels.js +36 -0
- package/src/web/spine/canvas-runtime.js +127 -0
- package/src/web/spine/canvas-view.js +108 -0
- package/src/web/spine/canvas.js +7 -0
- package/src/web/spine/runtime-hint.js +73 -0
- package/src/web/spine/runtimes.js +212 -0
- package/src/web/style.css +493 -0
- package/tool/lib/bundle-probe-node.mjs +121 -0
- package/tool/lib/paths.mjs +20 -0
- package/tool/lib/runtime-guides.mjs +65 -0
- package/tool/lib/runtime-package.mjs +403 -0
- package/tool/lib/spine-meta.mjs +227 -0
- package/tool/lib/spine-motions.mjs +104 -0
- package/tool/parse-jsonc.mjs +86 -0
- package/tool/resolve-live2d-runtimes.mjs +276 -0
- package/tool/spine-motion-rules.json +101 -0
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
// panel.js —— 右侧控制面板渲染(M3:控件齐全、布局就位、一律不接线)
|
|
2
|
+
//
|
|
3
|
+
// 做什么:按 panel-schema.js 的分组表渲染 9 个手风琴分组与组内控件。
|
|
4
|
+
// 接线边界(M3 铁律 1:不允许"看着能点却没反应"):
|
|
5
|
+
// · **手风琴开合是真实行为**(点一次真的展开/折叠,aria-expanded 同步翻转)——
|
|
6
|
+
// 这是 M3 验收明确要求逐个实操的部分;
|
|
7
|
+
// · **组内控件一律 disabled + data-pending="true"**,M6 统一接线,避免"接一半"打乱验收口径。
|
|
8
|
+
//
|
|
9
|
+
// 控件种类:
|
|
10
|
+
// segmented 分段按钮组(单选语义,用 aria-pressed 表达当前项,M6 才真正改值)
|
|
11
|
+
// switch 开关(role=switch + aria-checked)
|
|
12
|
+
// actions 一组动作按钮(导出/导入/恢复默认)
|
|
13
|
+
// dynamic-list 动态内容容器(由接线方按模型填充)
|
|
14
|
+
// note 纯说明文字
|
|
15
|
+
|
|
16
|
+
import { ACTION_ICONS, GROUP_ICONS, svgIcon } from './icons.js';
|
|
17
|
+
import { el, markDisabled, markPending } from './dom.js';
|
|
18
|
+
import { renderChips, renderListRow, renderSelect } from './render-choice.js';
|
|
19
|
+
import { PANEL_GROUPS } from './schema.js';
|
|
20
|
+
import { formatSliderValue, posOf, renderSlider } from './slider.js';
|
|
21
|
+
|
|
22
|
+
/** 元素工厂(与名册那边同名的本地小工具,避免跨模块只为建元素而互相依赖) */
|
|
23
|
+
/**
|
|
24
|
+
* 分段按钮组:一个 label + 若干选项按钮。
|
|
25
|
+
* 接线口径:控件带 `values` 才算已接线——按钮可点、默认项按 defaultValue 高亮、点击回传机器值;
|
|
26
|
+
* 不带的保持 disabled + data-pending(不许"看着能点却没反应")。
|
|
27
|
+
* @param {object} ctl
|
|
28
|
+
* @param {{onSetting: Function}} ctx
|
|
29
|
+
*/
|
|
30
|
+
/** 三态布尔 → 人话:null/undefined 是"没信息",与明确的 false(判为非预乘)意义完全不同 */
|
|
31
|
+
function triText(value, { yes, no, unknown }) {
|
|
32
|
+
if (value === true) return yes;
|
|
33
|
+
if (value === false) return no;
|
|
34
|
+
return unknown;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 实际生效的技术名 → 展示名(展示名映射到英文技术名) */
|
|
38
|
+
const EFFECTIVE_TEXT = { premultiplied: 'Premultiplied', straight: 'Straight', upload: 'Unpack' };
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 读当前 PMA 诊断数据(只读)。
|
|
42
|
+
*
|
|
43
|
+
* 数据分散在两处,必须合并(这是实测踩到的):引擎的 `pmaInfo` 只带
|
|
44
|
+
* `{effectiveMode, needPremultiply, blendMode, runtimeSupportsStraight, degraded}`,
|
|
45
|
+
* **不含** `atlasDeclared` / `pixelDetected` —— 那两个在 `pma.resolve` 这条 debug 日志的 detail 里
|
|
46
|
+
* (引擎装载时会打一条,记录"auto 到底判了什么、依据是什么")。
|
|
47
|
+
* 所以:先取最新一条 `pma.resolve` 的 detail 补这两个字段,再用 pmaInfo 覆盖"生效"类字段。
|
|
48
|
+
* @returns {{mode: string|null, info: object|null}}
|
|
49
|
+
*/
|
|
50
|
+
function readPmaDebug() {
|
|
51
|
+
if (typeof window === 'undefined') return { mode: null, info: null };
|
|
52
|
+
const web = window.__WEB__ || {};
|
|
53
|
+
let mode = null;
|
|
54
|
+
let info = null;
|
|
55
|
+
let resolved = null;
|
|
56
|
+
try {
|
|
57
|
+
if (typeof web.pmaDebug === 'function') {
|
|
58
|
+
const d = web.pmaDebug() || {};
|
|
59
|
+
mode = d.mode || null;
|
|
60
|
+
info = d.pmaInfo || null;
|
|
61
|
+
const logs = Array.isArray(d.logs) ? d.logs : [];
|
|
62
|
+
// 数组按时间正序:从后往前找。**优先取"带像素结论"的那条**——
|
|
63
|
+
// 切档也会打一条 pma.resolve,但那时不会重新抽样,pixelDetected 是 null;
|
|
64
|
+
// 若只看"最新一条"就会把『像素检测』显示成"未检测"(实测踩过)。
|
|
65
|
+
// 像素结论只在装载时判定一次,所以"最近一条 pixelDetected !== null"才是当前模型的证据。
|
|
66
|
+
let anyResolve = null;
|
|
67
|
+
for (let i = logs.length - 1; i >= 0; i -= 1) {
|
|
68
|
+
const det = logs[i] && logs[i].detail;
|
|
69
|
+
if (!det || det.atlasDeclared === undefined) continue;
|
|
70
|
+
if (!anyResolve) anyResolve = det;
|
|
71
|
+
if (det.pixelDetected !== null && det.pixelDetected !== undefined) { resolved = det; break; }
|
|
72
|
+
}
|
|
73
|
+
if (!resolved) resolved = anyResolve;
|
|
74
|
+
}
|
|
75
|
+
} catch { /* 读不到就当没有 */ }
|
|
76
|
+
if (!mode && !info && !resolved) {
|
|
77
|
+
// 退路:stats().pma 只有 { mode, effective, blend, degraded },没有 atlas/像素
|
|
78
|
+
try {
|
|
79
|
+
const st = typeof web.stage === 'object' && web.stage && typeof web.stage.stats === 'function' ? web.stage.stats() : null;
|
|
80
|
+
const pma = st && st.pma ? st.pma : null;
|
|
81
|
+
if (pma) { mode = pma.mode || null; info = { effectiveMode: pma.effective }; }
|
|
82
|
+
} catch { /* 忽略 */ }
|
|
83
|
+
}
|
|
84
|
+
const fromResolve = resolved
|
|
85
|
+
? {
|
|
86
|
+
atlasDeclared: resolved.atlasDeclared === undefined ? null : resolved.atlasDeclared,
|
|
87
|
+
pixelDetected: resolved.pixelDetected === undefined ? null : resolved.pixelDetected,
|
|
88
|
+
// resolve 里叫 effective(不带 Mode 后缀),统一成 effectiveMode
|
|
89
|
+
effectiveMode: resolved.effective || null,
|
|
90
|
+
runtimeSupportsStraight: resolved.runtimeSupportsStraight === undefined ? null : resolved.runtimeSupportsStraight,
|
|
91
|
+
}
|
|
92
|
+
: {};
|
|
93
|
+
// pmaInfo 优先(它才是"当前实际生效"的权威);它缺的字段由 resolve 那条补上
|
|
94
|
+
return { mode, info: Object.assign({}, fromResolve, info || {}) };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 生成 PMA 四行诊断块(只读,不可点)。
|
|
99
|
+
* 数据缺失时一律显示 '—'(未加载模型 / 引擎还没判定完),绝不抛错、绝不编值。
|
|
100
|
+
* @returns {HTMLElement} 带 data-ctl-diag="pma" 的容器(后续用 updatePmaDiag 原地刷新)
|
|
101
|
+
*/
|
|
102
|
+
function renderPmaDiag() {
|
|
103
|
+
const box = el('div', 'ctl-diag');
|
|
104
|
+
box.dataset.ctlDiag = 'pma';
|
|
105
|
+
// 固定四行:顺序(Atlas 声明 → 像素检测 → 直通支持 → 实际生效)
|
|
106
|
+
for (const key of ['atlas', 'pixel', 'straight', 'effective']) {
|
|
107
|
+
const row = el('div', 'ctl-diag-row');
|
|
108
|
+
row.dataset.diagKey = key;
|
|
109
|
+
const label = el('span', 'ctl-diag-label', { atlas: 'Atlas 声明:', pixel: '像素检测:', straight: '直通支持:', effective: '实际生效:' }[key]);
|
|
110
|
+
const value = el('span', 'ctl-diag-value', '—');
|
|
111
|
+
// "实际生效"是最终结论,最该被看到:主色 + 加重
|
|
112
|
+
if (key === 'effective') value.classList.add('is-primary');
|
|
113
|
+
row.append(label, value);
|
|
114
|
+
box.append(row);
|
|
115
|
+
}
|
|
116
|
+
updatePmaDiag(box);
|
|
117
|
+
return box;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 原地刷新四行(切换档位后立刻调用;模型装载/动作变化时由全局事件触发)。
|
|
122
|
+
* @param {Element|null} box renderPmaDiag 产出的容器
|
|
123
|
+
*/
|
|
124
|
+
export function updatePmaDiag(box) {
|
|
125
|
+
if (!box) return;
|
|
126
|
+
const { info } = readPmaDebug();
|
|
127
|
+
const set = (key, text) => {
|
|
128
|
+
const v = box.querySelector(`[data-diag-key="${key}"] .ctl-diag-value`);
|
|
129
|
+
if (v) v.textContent = text;
|
|
130
|
+
};
|
|
131
|
+
set('atlas', triText(info && info.atlasDeclared, { yes: '已预乘', no: '非预乘', unknown: '未知' }));
|
|
132
|
+
set('pixel', triText(info && info.pixelDetected, { yes: '已预乘', no: '非预乘', unknown: '未检测' }));
|
|
133
|
+
set('straight', triText(info && info.runtimeSupportsStraight, { yes: '支持', no: '不支持', unknown: '—' }));
|
|
134
|
+
const eff = info && info.effectiveMode ? String(info.effectiveMode) : '';
|
|
135
|
+
set('effective', EFFECTIVE_TEXT[eff] || '—');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** 刷新页面上所有 PMA 诊断块(面板重建后可能有多个残留,全刷最省心) */
|
|
139
|
+
export function refreshPmaDiag() {
|
|
140
|
+
if (typeof document === 'undefined') return;
|
|
141
|
+
for (const box of document.querySelectorAll('[data-ctl-diag="pma"]')) updatePmaDiag(box);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 切档后"期望生效的技术名":给轮询一个停止条件(auto 无法预知,用固定次数) */
|
|
145
|
+
const EXPECT_EFFECTIVE = { premultiplied: ['premultiplied'], straight: ['straight', 'upload'], upload: ['upload'] };
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* 切档后刷新四行:**轮询到真正生效为止**。
|
|
149
|
+
*
|
|
150
|
+
* 为什么不能只刷一次:切档会触发贴图重传(引擎侧是异步的),`onSetting` 返回时
|
|
151
|
+
* `pmaInfo.effectiveMode` 还是旧值——只刷一次就会显示"上一拍"的结论(实测:点「已预乘」后
|
|
152
|
+
* 仍显示 Unpack,再点「自动」才显示 Premultiplied,整整慢一步)。
|
|
153
|
+
* @param {Element|null} box 诊断块
|
|
154
|
+
* @param {string} value 刚点的档位(auto/premultiplied/straight/upload)
|
|
155
|
+
*/
|
|
156
|
+
function refreshPmaDiagUntil(box, value) {
|
|
157
|
+
const expect = EXPECT_EFFECTIVE[value] || null;
|
|
158
|
+
const t0 = Date.now();
|
|
159
|
+
let ticks = 0;
|
|
160
|
+
const tick = () => {
|
|
161
|
+
updatePmaDiag(box);
|
|
162
|
+
const info = readPmaDebug().info || {};
|
|
163
|
+
const eff = info.effectiveMode ? String(info.effectiveMode) : '';
|
|
164
|
+
ticks += 1;
|
|
165
|
+
// auto 的生效结果是推断出来的,无法预知 ⇒ 固定刷 5 拍(约 600ms)就够
|
|
166
|
+
const done = expect ? expect.includes(eff) || Date.now() - t0 > 2000 : ticks >= 5;
|
|
167
|
+
if (!done) setTimeout(tick, 120);
|
|
168
|
+
};
|
|
169
|
+
tick();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** 全局只注册一次:模型装载/动作变化时(app 层派发 panel:refresh-animations)顺带刷新诊断行 */
|
|
173
|
+
let pmaDiagWired = false;
|
|
174
|
+
function ensurePmaDiagRefresh() {
|
|
175
|
+
if (pmaDiagWired || typeof document === 'undefined') return;
|
|
176
|
+
pmaDiagWired = true;
|
|
177
|
+
document.addEventListener('panel:refresh-animations', () => refreshPmaDiag());
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function renderSegmented(ctl, ctx) {
|
|
181
|
+
// 是否可点由**接线方**(映射表)决定:带 values 只说明"有机器值可取",不代表已生效
|
|
182
|
+
const wired = ctx.isWired(ctl.id) && Array.isArray(ctl.values) && ctl.values.length === ctl.options.length;
|
|
183
|
+
const row = el('div', 'ctl-row');
|
|
184
|
+
row.append(el('span', 'ctl-label', ctl.label));
|
|
185
|
+
const group = el('div', 'ctl-seg');
|
|
186
|
+
group.dataset.ctl = ctl.id || '';
|
|
187
|
+
// 布局声明(目前只有 PMA 用 grid-2:四档排成 2×2,对齐既有口径)
|
|
188
|
+
if (ctl.layout) group.dataset.layout = ctl.layout;
|
|
189
|
+
// 该控件带只读诊断块(目前只有 PMA):在按钮组下方追加四行
|
|
190
|
+
ensurePmaDiagRefresh();
|
|
191
|
+
group.setAttribute('role', 'group');
|
|
192
|
+
group.setAttribute('aria-label', ctl.label);
|
|
193
|
+
const ivSeg = ctx && typeof ctx.initialValue === 'function' ? ctx.initialValue(ctl.id) : undefined;
|
|
194
|
+
const wantVal = ivSeg === undefined ? ctl.defaultValue : ivSeg;
|
|
195
|
+
const defaultIdx = wired && wantVal !== undefined ? ctl.values.indexOf(wantVal) : -1;
|
|
196
|
+
|
|
197
|
+
ctl.options.forEach((opt, i) => {
|
|
198
|
+
const item = typeof opt === 'string' ? { text: opt } : opt;
|
|
199
|
+
const btn = el('button', 'seg-btn', item.text);
|
|
200
|
+
btn.type = 'button';
|
|
201
|
+
btn.dataset.value = String(ctl.values[i]);
|
|
202
|
+
btn.setAttribute('aria-pressed', String(i === defaultIdx));
|
|
203
|
+
const hint = (ctl.titles && ctl.titles[i]) || item.title;
|
|
204
|
+
const optDisabled = Array.isArray(ctl.disabledValues) && ctl.disabledValues.includes(ctl.values[i]);
|
|
205
|
+
if (wired && !optDisabled) {
|
|
206
|
+
btn.title = hint || item.text;
|
|
207
|
+
btn.addEventListener('click', () => {
|
|
208
|
+
for (const other of group.children) other.setAttribute('aria-pressed', 'false');
|
|
209
|
+
btn.setAttribute('aria-pressed', 'true');
|
|
210
|
+
try { ctx.onSetting(ctl.id, ctl.values[i]); } catch { /* 接线失败不影响面板自身状态 */ }
|
|
211
|
+
// 诊断块跟着档位走:切换会重新判定(重新上传贴图),所以立刻刷新四行
|
|
212
|
+
if (ctl.diag === 'pma') refreshPmaDiagUntil(row.querySelector('[data-ctl-diag="pma"]'), String(ctl.values[i]));
|
|
213
|
+
});
|
|
214
|
+
} else if (optDisabled) {
|
|
215
|
+
// 单个取值被禁(如"自定义背景"这类尚未接线的选项):属开发中项,保持 pending 口径
|
|
216
|
+
btn.disabled = true;
|
|
217
|
+
markPending(btn, null);
|
|
218
|
+
btn.title = hint || '待接线(M6)';
|
|
219
|
+
} else {
|
|
220
|
+
markDisabled(btn, ctl, ctx);
|
|
221
|
+
}
|
|
222
|
+
group.append(btn);
|
|
223
|
+
});
|
|
224
|
+
row.append(group);
|
|
225
|
+
// 只读诊断块(PMA):档位按钮下方四行小字(Atlas 声明 / 像素检测 / 直通支持 / 实际生效)
|
|
226
|
+
if (ctl.diag === 'pma') row.append(renderPmaDiag());
|
|
227
|
+
// 需要读数行的控件(当前只有帧率):数值由 mountPanel 暴露的 setFps 更新
|
|
228
|
+
if (ctl.readout) {
|
|
229
|
+
const readout = el('span', 'ctl-readout', '—');
|
|
230
|
+
readout.dataset.readout = ctl.id;
|
|
231
|
+
readout.setAttribute('aria-live', 'polite');
|
|
232
|
+
row.append(readout);
|
|
233
|
+
}
|
|
234
|
+
return row;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* 开关:label + 开关(role=switch)。
|
|
239
|
+
* 未接线的开关**仍按 schema 声明默认态显示**(defaultOn),只是不可点——避免"默认值看起来是关"的错配。
|
|
240
|
+
*/
|
|
241
|
+
function renderSwitch(ctl, ctx) {
|
|
242
|
+
// 未声明能力的控件由 applyCapabilities 置 ctl.disabled:这里一并压制交互(禁用+原因,不静默)
|
|
243
|
+
const wired = ctx.isWired(ctl.id) && ctl.disabled !== true;
|
|
244
|
+
const row = el('div', 'ctl-row ctl-row-switch');
|
|
245
|
+
row.dataset.ctl = ctl.id || ''; // 与分段/下拉一致:外层行可被 [data-ctl] 定位
|
|
246
|
+
row.append(el('span', 'ctl-label', ctl.label));
|
|
247
|
+
const btn = el('button', 'switch-btn');
|
|
248
|
+
btn.id = ctl.id || '';
|
|
249
|
+
btn.type = 'button';
|
|
250
|
+
btn.setAttribute('role', 'switch');
|
|
251
|
+
{
|
|
252
|
+
const iv = ctx && typeof ctx.initialValue === 'function' ? ctx.initialValue(ctl.id) : undefined;
|
|
253
|
+
// 有用户值就用用户值,没有才用 schema 默认(契约 R2:读回早于首次渲染)
|
|
254
|
+
btn.setAttribute('aria-checked', String(iv === undefined ? !!ctl.defaultOn : !!iv));
|
|
255
|
+
}
|
|
256
|
+
if (wired) {
|
|
257
|
+
btn.title = ctl.title || ctl.label;
|
|
258
|
+
btn.addEventListener('click', () => {
|
|
259
|
+
// 从 DOM 派生下一个状态:外部同步(另一处开关)改过 aria-checked 后,这里仍能正确翻转
|
|
260
|
+
const next = btn.getAttribute('aria-checked') !== 'true';
|
|
261
|
+
btn.setAttribute('aria-checked', String(next));
|
|
262
|
+
try { ctx.onSetting(ctl.id, next); } catch { /* 接线失败不影响面板自身状态 */ }
|
|
263
|
+
});
|
|
264
|
+
} else {
|
|
265
|
+
markDisabled(btn, ctl, ctx);
|
|
266
|
+
}
|
|
267
|
+
btn.append(el('span', 'switch-knob'));
|
|
268
|
+
row.append(btn);
|
|
269
|
+
return row;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** 动作按钮组:导出设置 / 导入设置 / 恢复默认 */
|
|
273
|
+
function renderActions(ctl, ctx) {
|
|
274
|
+
const row = el('div', 'ctl-actions');
|
|
275
|
+
row.dataset.ctl = ctl.id || '';
|
|
276
|
+
const wired = !!(ctx && typeof ctx.isWired === 'function' && ctx.isWired(ctl.id));
|
|
277
|
+
const blocked = ctx && typeof ctx.disabledReason === 'function' ? ctx.disabledReason(ctl.id) : null;
|
|
278
|
+
for (const item of ctl.buttons) {
|
|
279
|
+
const def = typeof item === 'string' ? { text: item } : item;
|
|
280
|
+
const btn = el('button', 'mini-btn', def.text);
|
|
281
|
+
btn.type = 'button';
|
|
282
|
+
btn.dataset.action = def.text;
|
|
283
|
+
if (ACTION_ICONS[def.text]) { btn.prepend(svgIcon(ACTION_ICONS[def.text])); btn.classList.add('has-icon'); }
|
|
284
|
+
const reason = def.disabledReason || blocked; // 单按钮不可用时如实写原因,不冒充"待接线"
|
|
285
|
+
if (reason) { btn.disabled = true; btn.title = reason; btn.dataset.disabledReason = reason; } else if (wired) {
|
|
286
|
+
btn.title = def.title || def.text;
|
|
287
|
+
btn.addEventListener('click', () => { try { ctx.onSetting(ctl.id, def.text); } catch { /* 接线失败不影响面板状态 */ } });
|
|
288
|
+
} else { markPending(btn, null); btn.disabled = true; btn.title = '待接线(M6)'; }
|
|
289
|
+
row.append(btn);
|
|
290
|
+
}
|
|
291
|
+
return row;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** 动态内容占位:M6 会在这个容器里填真实条目 */
|
|
295
|
+
function renderDynamicList(ctl) {
|
|
296
|
+
// 容器由接线方按当前模型填充(动作列表);空态给提示
|
|
297
|
+
const box = el('div', 'ctl-dynamic ctl-list');
|
|
298
|
+
box.dataset.ctl = ctl.id || ctl.label || '';
|
|
299
|
+
box.dataset.role = 'dynamic';
|
|
300
|
+
box.append(el('p', 'ctl-note', ctl.note || '选中模型后显示'));
|
|
301
|
+
return box;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** 下拉(select):用于主题这类"项较多、单列占位"的选择 */
|
|
305
|
+
/** 只读行:label + 值(值由接线方写入,用于展示无法切换或纯信息类内容) */
|
|
306
|
+
function renderReadonly(ctl) {
|
|
307
|
+
const row = el('div', 'ctl-row ctl-row-readonly');
|
|
308
|
+
row.dataset.ctl = ctl.id || '';
|
|
309
|
+
row.append(el('span', 'ctl-label', ctl.label));
|
|
310
|
+
const v = el('span', 'ctl-value', '—');
|
|
311
|
+
v.dataset.readout = ctl.id || '';
|
|
312
|
+
if (ctl.note) v.title = ctl.note;
|
|
313
|
+
row.append(v);
|
|
314
|
+
return row;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
/** 按种类分派 */
|
|
319
|
+
function renderControl(ctl, ctx) {
|
|
320
|
+
switch (ctl.kind) {
|
|
321
|
+
case 'segmented': return renderSegmented(ctl, ctx);
|
|
322
|
+
case 'switch': return renderSwitch(ctl, ctx);
|
|
323
|
+
case 'actions': return renderActions(ctl, ctx);
|
|
324
|
+
case 'dynamic-list': return renderDynamicList(ctl);
|
|
325
|
+
case 'readonly': return renderReadonly(ctl);
|
|
326
|
+
case 'chips': return renderChips(ctl, ctx);
|
|
327
|
+
case 'slider': return renderSlider(ctl, ctx, el, markDisabled);
|
|
328
|
+
case 'select': return renderSelect(ctl, ctx);
|
|
329
|
+
default: return el('p', 'ctl-note', ctl.note || '');
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* 渲染一个手风琴分组。
|
|
335
|
+
* 开合状态存在 DOM 上(aria-expanded + class),M6 若需要持久化再抽到 store。
|
|
336
|
+
* @param {object} group
|
|
337
|
+
*/
|
|
338
|
+
function renderGroup(group, ctx) {
|
|
339
|
+
const section = el('section', 'acc');
|
|
340
|
+
section.dataset.group = group.id;
|
|
341
|
+
|
|
342
|
+
const trigger = el('button', 'acc-trigger');
|
|
343
|
+
trigger.type = 'button';
|
|
344
|
+
trigger.setAttribute('aria-expanded', String(!!group.defaultOpen));
|
|
345
|
+
trigger.setAttribute('aria-controls', `acc-body-${group.id}`);
|
|
346
|
+
// :标题前彩色图标(主色),箭头在右
|
|
347
|
+
if (GROUP_ICONS[group.id]) trigger.append(svgIcon(GROUP_ICONS[group.id]));
|
|
348
|
+
trigger.append(el('span', 'acc-title', group.title));
|
|
349
|
+
trigger.append(el('span', 'acc-arrow'));
|
|
350
|
+
|
|
351
|
+
const body = el('div', 'acc-body');
|
|
352
|
+
body.id = `acc-body-${group.id}`;
|
|
353
|
+
body.setAttribute('role', 'region');
|
|
354
|
+
body.setAttribute('aria-label', group.title);
|
|
355
|
+
for (const ctl of group.controls) {
|
|
356
|
+
// 单个控件渲染失败**不许**拖掉整组:以前是直接抛,异常会让这组 body 挂在半路、
|
|
357
|
+
// 最终整组从界面上消失(实测:一个插件控件的字段不全,导致整个「设置」组不见了)。
|
|
358
|
+
// 现在失败项就地渲染成一行说明,既不静默也不扩大影响面。
|
|
359
|
+
try {
|
|
360
|
+
body.append(renderControl(ctl, ctx));
|
|
361
|
+
} catch (err) {
|
|
362
|
+
const row = el('div', 'ctl-row ctl-row-broken');
|
|
363
|
+
row.dataset.ctl = (ctl && ctl.id) || '';
|
|
364
|
+
row.append(el('span', 'ctl-label', (ctl && ctl.label) || '控件'));
|
|
365
|
+
row.append(el('span', 'ctl-value', `渲染失败:${(err && err.message) || err}`));
|
|
366
|
+
body.append(row);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
body.hidden = !group.defaultOpen;
|
|
370
|
+
|
|
371
|
+
// 开合是真实行为:切换 aria-expanded 与 hidden,箭头方向由 CSS 跟着 aria 走
|
|
372
|
+
trigger.addEventListener('click', () => {
|
|
373
|
+
const open = trigger.getAttribute('aria-expanded') === 'true';
|
|
374
|
+
trigger.setAttribute('aria-expanded', String(!open));
|
|
375
|
+
body.hidden = open;
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
if (group.defaultOpen) section.classList.add('is-open');
|
|
379
|
+
section.append(trigger, body);
|
|
380
|
+
return section;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* 把面板挂到容器上。
|
|
385
|
+
* @param {{headEl: HTMLElement, bodyEl: HTMLElement, onSetting?: (id: string, value: any) => void}} mounts
|
|
386
|
+
* onSetting:已接线控件的回传口(面板只把"点了哪个选项"翻成机器值,具体生效由接线方决定)
|
|
387
|
+
* @returns {{groups: number, controls: number, setFps: (fps: number|null) => void}}
|
|
388
|
+
*/
|
|
389
|
+
export function mountPanel({ headEl, bodyEl, onSetting, wiredIds, groups, initialValue, onDomain }) {
|
|
390
|
+
// schema 可由调用方按渲染器能力过滤后传入;缺省用全量 PANEL_GROUPS(保持原有行为)
|
|
391
|
+
const schema = Array.isArray(groups) && groups.length ? groups : PANEL_GROUPS;
|
|
392
|
+
// 滑块 id → 控件声明:外部同步(恢复上次设置 / 生效值回显)时要按声明格式化读数
|
|
393
|
+
const sliderDefs = new Map();
|
|
394
|
+
for (const group of schema) {
|
|
395
|
+
for (const ctl of group.controls || []) {
|
|
396
|
+
if (ctl && ctl.kind === 'slider' && ctl.id) sliderDefs.set(ctl.id, ctl);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
headEl.replaceChildren();
|
|
400
|
+
/**
|
|
401
|
+
* 现查容器:面板可能在切面/重建时被换成新 DOM,
|
|
402
|
+
* 闭包里捕获的 bodyEl 会变成"不在文档里的旧节点"——刷上去看不到任何变化(实测过的坑)。
|
|
403
|
+
* 所有访问器一律用时就查;查不到(面板未构建/被隐藏)就安全返回。
|
|
404
|
+
* @param {string} selector 相对容器的选择器
|
|
405
|
+
* @returns {Element|null}
|
|
406
|
+
*/
|
|
407
|
+
function find(selector) {
|
|
408
|
+
const host = (typeof document !== 'undefined' && document.querySelector('#right-panel [data-ctl]'))
|
|
409
|
+
? document.querySelector('#right-panel') : bodyEl;
|
|
410
|
+
if (!host) return null;
|
|
411
|
+
return host.querySelector(selector);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
bodyEl.replaceChildren();
|
|
415
|
+
// 接线集合来自接线方(main.js 的映射表),与 schema 的 values 解耦:
|
|
416
|
+
// 这样"schema 加了取值域但还没接生效"的控件会保持 disabled,不会出现点了没反应。
|
|
417
|
+
const wiredSet = new Set(Array.isArray(wiredIds) ? wiredIds : []);
|
|
418
|
+
// 能力过滤结果(schema.applyCapabilities 写在控件上):disabled 的控件一律不可点并给 reason
|
|
419
|
+
const byId = new Map(); // id → 控件定义(setOptions 需要读 emptyText 之类的声明)
|
|
420
|
+
for (const group of schema) for (const ctl of group.controls || []) if (ctl && ctl.id) byId.set(ctl.id, ctl);
|
|
421
|
+
const disabledMap = new Map();
|
|
422
|
+
for (const group of schema) {
|
|
423
|
+
for (const ctl of group.controls || []) {
|
|
424
|
+
if (ctl && ctl.id && ctl.disabled) disabledMap.set(ctl.id, ctl.reason || '当前渲染器不支持');
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
const ctx = {
|
|
428
|
+
onSetting: typeof onSetting === 'function' ? onSetting : () => {},
|
|
429
|
+
/**
|
|
430
|
+
* 控件的**当前值**(来自 app/ui-state 工厂)。
|
|
431
|
+
* 契约 R2:首次渲染就要显示用户值,而不是"先渲染默认值再命令式补"——
|
|
432
|
+
* 后者会闪,且面板重建后必丢(本项目的 P0 bug 就是这么来的)。
|
|
433
|
+
*/
|
|
434
|
+
initialValue: typeof initialValue === 'function' ? initialValue : () => undefined,
|
|
435
|
+
isWired: (id) => !!id && wiredSet.has(id) && !disabledMap.has(id),
|
|
436
|
+
/** 能力层禁用原因(有值 ⇒ 该控件按"当前渲染器不支持"禁用) */
|
|
437
|
+
disabledReason: (id) => disabledMap.get(id) || null,
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
// 面板头只有标题:右栏的收起入口统一由顶栏「菜单」按钮负责,面板内不再重复一个收起按钮
|
|
441
|
+
const title = el('span', 'panel-title', '控制面板');
|
|
442
|
+
headEl.append(title);
|
|
443
|
+
|
|
444
|
+
for (const group of schema) bodyEl.append(renderGroup(group, ctx));
|
|
445
|
+
|
|
446
|
+
// 已接线控件的声明默认值:作为"面板即真相"的唯一来源,交给接线方在启动时下发
|
|
447
|
+
const defaults = {};
|
|
448
|
+
for (const group of schema) {
|
|
449
|
+
for (const ctl of group.controls) {
|
|
450
|
+
if (wiredSet.has(ctl.id) && ctl.defaultValue !== undefined) defaults[ctl.id] = ctl.defaultValue;
|
|
451
|
+
// 开关类没有取值域:默认态用 defaultOn 声明,同样由接线方启动时下发一次
|
|
452
|
+
else if (wiredSet.has(ctl.id) && ctl.kind === 'switch' && ctl.defaultOn !== undefined) defaults[ctl.id] = !!ctl.defaultOn;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** 只读行写入(运行时版本等信息由接线方给) */
|
|
457
|
+
function setReadonly(id, text) {
|
|
458
|
+
const node = find(`[data-readout="${id}"]`);
|
|
459
|
+
if (node) node.textContent = text || '—';
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** 动态重建预设按钮组(皮肤列表在换模型后要用新数据重建) */
|
|
463
|
+
function setOptions(id, options, selected) {
|
|
464
|
+
const box = find(`[data-ctl="${id}"]`);
|
|
465
|
+
if (!box || !Array.isArray(options)) return false;
|
|
466
|
+
// 选项是运行时才确定的(如皮肤列表)⇒ 必须把新的候选值同步给"值工厂"的取值域,
|
|
467
|
+
// 否则工厂按 schema 的静态占位(只有 default)判这些值非法、回落默认值,
|
|
468
|
+
// 表现成"点了没反应"(实测:点皮肤 00 会切回 default,连高亮都不动)。
|
|
469
|
+
if (typeof onDomain === 'function') {
|
|
470
|
+
try { onDomain(id, options.map((o) => String(o && typeof o === 'object' ? o.value : o))); } catch { /* 域同步失败不影响渲染 */ }
|
|
471
|
+
}
|
|
472
|
+
// 该项可能因"当前渲染面未声明该能力"而禁用:重建选项时必须**保留禁用态**,
|
|
473
|
+
// 否则装载模型后刷新皮肤列表会把禁用按钮换成可点按钮(表现为"换面后又能点了")。
|
|
474
|
+
const blocked = ctx && typeof ctx.disabledReason === 'function' ? ctx.disabledReason(id) : null;
|
|
475
|
+
box.replaceChildren();
|
|
476
|
+
const asList = box.classList.contains('ctl-list'); // 皮肤列表:列表行;其它:横向 chips
|
|
477
|
+
const ctlDef = byId.get(id) || null;
|
|
478
|
+
if (asList && options.length === 0 && ctlDef && ctlDef.emptyText) {
|
|
479
|
+
// 空态居中一行弱化小字(如"默认皮肤"),不留空白区
|
|
480
|
+
box.append(el('div', 'ctl-list-empty', ctlDef.emptyText));
|
|
481
|
+
return true;
|
|
482
|
+
}
|
|
483
|
+
for (const opt of options) {
|
|
484
|
+
const btn = asList
|
|
485
|
+
? renderListRow(opt, { selected: String(opt.value) === String(selected) })
|
|
486
|
+
: el('button', 'chip-btn', opt.text || opt.value);
|
|
487
|
+
btn.type = 'button';
|
|
488
|
+
btn.dataset.value = String(opt.value);
|
|
489
|
+
if (!asList) btn.setAttribute('aria-pressed', String(String(opt.value) === String(selected)));
|
|
490
|
+
if (blocked) {
|
|
491
|
+
btn.disabled = true;
|
|
492
|
+
btn.title = `${blocked}:${opt.text || opt.value}`;
|
|
493
|
+
btn.dataset.disabledReason = blocked;
|
|
494
|
+
box.append(btn);
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
btn.title = opt.text || opt.value;
|
|
498
|
+
btn.addEventListener('click', () => {
|
|
499
|
+
for (const other of box.children) other.setAttribute('aria-pressed', 'false');
|
|
500
|
+
btn.setAttribute('aria-pressed', 'true');
|
|
501
|
+
try { ctx.onSetting(id, opt.value); } catch { /* 接线失败保持选择 */ }
|
|
502
|
+
});
|
|
503
|
+
box.append(btn);
|
|
504
|
+
}
|
|
505
|
+
return true;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** 设置开关显示态(两处共用同一状态的开关要同步) */
|
|
509
|
+
function setChecked(id, on) {
|
|
510
|
+
const box = find(`[data-ctl="${id}"]`);
|
|
511
|
+
const btn = box ? box.querySelector('[role="switch"]') : null;
|
|
512
|
+
if (btn) btn.setAttribute('aria-checked', String(!!on));
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** 回填控件显示值(启动时按存储恢复:主题 / 背景模式等) */
|
|
516
|
+
function setValue(id, value) {
|
|
517
|
+
const box = find(`[data-ctl="${id}"]`);
|
|
518
|
+
if (box) {
|
|
519
|
+
const opts = [...box.children];
|
|
520
|
+
const idx = opts.findIndex((b) => b.dataset.value === String(value));
|
|
521
|
+
if (idx >= 0) {
|
|
522
|
+
opts.forEach((b, i) => b.setAttribute('aria-pressed', String(i === idx)));
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
const range = find(`input.ctl-slider-input#${id}`);
|
|
527
|
+
if (range) {
|
|
528
|
+
const def = sliderDefs.get(id);
|
|
529
|
+
// 非线性滑块要先把"数值"换算回"格位"再写 value(否则 1.5 会被当成第 1.5 格)
|
|
530
|
+
range.value = String(Math.round(def ? posOf(def, value) : Number(value)));
|
|
531
|
+
const ro = find(`[data-readout="${id}"]`);
|
|
532
|
+
// 读数格式化按 schema 声明(单位/0 值语义都在那里),不在这里猜
|
|
533
|
+
if (ro) ro.textContent = def ? formatSliderValue(def, value) : String(value);
|
|
534
|
+
return true;
|
|
535
|
+
}
|
|
536
|
+
const sel = find(`select[data-ctl-select="${id}"]`);
|
|
537
|
+
if (sel && [...sel.options].some((o) => o.value === String(value))) sel.value = String(value);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** 帧率读数(实测值;null 表示暂无数据),由接线方按需刷新 */
|
|
541
|
+
function setFps(fps) {
|
|
542
|
+
const node = find('[data-readout="ctl-fps"]');
|
|
543
|
+
if (!node) return;
|
|
544
|
+
// 说明写进 title:设备渲染能力不足时,60/30 两档的实测帧率会看不出差异,这是性能而非接线问题
|
|
545
|
+
node.title = '实测帧率。受设备性能限制:渲染上限低于所选上限时,60/30 两档看不出差异';
|
|
546
|
+
node.textContent = typeof fps === 'number' && fps > 0 ? `当前 ${Math.round(fps)} FPS` : '—';
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// 统计口径:面板头 + 组内所有非手风琴控件,供断言核对控件数
|
|
550
|
+
const controls = (headEl.querySelectorAll('button, select, input').length)
|
|
551
|
+
+ (bodyEl.querySelectorAll('button:not(.acc-trigger), select, input, [role="switch"]').length);
|
|
552
|
+
return { groups: schema.length, controls, setFps, setReadonly, setValue, setOptions, setChecked, defaults };
|
|
553
|
+
}
|