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.
Files changed (281) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +274 -2
  3. package/THIRD_PARTY_NOTICES.md +90 -0
  4. package/bin/modelstage.mjs +137 -0
  5. package/config/diag-upload.example.json +7 -0
  6. package/config/models/live2d.assetbundls.json +1 -0
  7. package/config/models/live2d.azurlane.json +1 -0
  8. package/config/models/live2d.azurlane_bundle.json +1 -0
  9. package/config/models/live2d.azurlane_spine.json +1 -0
  10. package/config/models/live2d.bd2.json +1 -0
  11. package/config/models/live2d.bluearchive.json +1 -0
  12. package/config/models/live2d.live2d.json +1 -0
  13. package/config/models/live2d.nikke.json +1 -0
  14. package/config/models/live2d.nikke_2.json +1 -0
  15. package/config/models/live2d.npc.json +1 -0
  16. package/config/models/spine.assetbundls.json +1 -0
  17. package/config/models/spine.azurlane.json +1 -0
  18. package/config/models/spine.azurlane_bundle.json +1 -0
  19. package/config/models/spine.azurlane_spine.json +1 -0
  20. package/config/models/spine.bd2.json +1 -0
  21. package/config/models/spine.bluearchive.json +1 -0
  22. package/config/models/spine.live2d.json +1 -0
  23. package/config/models/spine.nikke.json +1 -0
  24. package/config/models/spine.nikke_2.json +1 -0
  25. package/config/models/spine.npc.json +1 -0
  26. package/config/names/NIKKE.json +1058 -0
  27. package/config/names/README.md +47 -0
  28. package/config/names//345/244/251/345/221/275/344/271/213/345/255/220.json +42897 -0
  29. package/config/names//346/243/225/350/211/262/345/260/230/345/237/2032.json +4016 -0
  30. package/config/names//347/242/247/350/223/235/350/210/252/347/272/277.json +38342 -0
  31. package/config/plugins.json +13 -0
  32. package/config/resources.example.json +40 -0
  33. package/package.json +63 -4
  34. package/plugins/unity-live2d-bundle/README.md +98 -0
  35. package/plugins/unity-live2d-bundle/index.js +891 -0
  36. package/plugins/unity-live2d-bundle/lib/anim-to-motion.js +402 -0
  37. package/plugins/unity-live2d-bundle/lib/bundle-probe.js +244 -0
  38. package/plugins/unity-live2d-bundle/lib/bytes.js +189 -0
  39. package/plugins/unity-live2d-bundle/lib/common-strings.js +46 -0
  40. package/plugins/unity-live2d-bundle/lib/dxt.js +197 -0
  41. package/plugins/unity-live2d-bundle/lib/errors.js +71 -0
  42. package/plugins/unity-live2d-bundle/lib/etc2.js +371 -0
  43. package/plugins/unity-live2d-bundle/lib/extract.js +242 -0
  44. package/plugins/unity-live2d-bundle/lib/lzma-d-min.LICENSE.txt +21 -0
  45. package/plugins/unity-live2d-bundle/lib/lzma-d-min.js +34 -0
  46. package/plugins/unity-live2d-bundle/lib/model3.js +137 -0
  47. package/plugins/unity-live2d-bundle/lib/png.js +424 -0
  48. package/plugins/unity-live2d-bundle/lib/serialized.js +233 -0
  49. package/plugins/unity-live2d-bundle/lib/texdecode.js +190 -0
  50. package/plugins/unity-live2d-bundle/lib/typetree.js +215 -0
  51. package/plugins/unity-live2d-bundle/lib/unityfs.js +396 -0
  52. package/plugins/unity-live2d-bundle/plugin.json +81 -0
  53. package/plugins/unity-live2d-bundle/profiles/unityfs-live2d.json +26 -0
  54. package/plugins/unity-spine-bundle/index.js +535 -0
  55. package/plugins/unity-spine-bundle/lib/bytes.js +189 -0
  56. package/plugins/unity-spine-bundle/lib/common-strings.js +46 -0
  57. package/plugins/unity-spine-bundle/lib/dxt.js +197 -0
  58. package/plugins/unity-spine-bundle/lib/errors.js +79 -0
  59. package/plugins/unity-spine-bundle/lib/etc2.js +371 -0
  60. package/plugins/unity-spine-bundle/lib/extract.js +242 -0
  61. package/plugins/unity-spine-bundle/lib/lzma-d-min.LICENSE.txt +21 -0
  62. package/plugins/unity-spine-bundle/lib/lzma-d-min.js +34 -0
  63. package/plugins/unity-spine-bundle/lib/png.js +424 -0
  64. package/plugins/unity-spine-bundle/lib/serialized.js +233 -0
  65. package/plugins/unity-spine-bundle/lib/texdecode.js +190 -0
  66. package/plugins/unity-spine-bundle/lib/typetree.js +215 -0
  67. package/plugins/unity-spine-bundle/lib/unityfs.js +396 -0
  68. package/plugins/unity-spine-bundle/plugin.json +75 -0
  69. package/src/server/README.md +69 -0
  70. package/src/server/assets/browse.mjs +56 -0
  71. package/src/server/assets/bundle-probe.mjs +94 -0
  72. package/src/server/assets/probe.mjs +184 -0
  73. package/src/server/core/config.mjs +173 -0
  74. package/src/server/core/env-num.mjs +60 -0
  75. package/src/server/core/env.mjs +49 -0
  76. package/src/server/core/fs-portable.mjs +37 -0
  77. package/src/server/core/log.mjs +107 -0
  78. package/src/server/core/paths.mjs +44 -0
  79. package/src/server/data/catalog.mjs +1091 -0
  80. package/src/server/data/entry-states.mjs +499 -0
  81. package/src/server/data/live2d.mjs +272 -0
  82. package/src/server/data/manifest-cache.mjs +58 -0
  83. package/src/server/data/manifest-stamp.mjs +135 -0
  84. package/src/server/data/model-meta.mjs +114 -0
  85. package/src/server/data/names.mjs +106 -0
  86. package/src/server/data/shape-probe-worker.mjs +33 -0
  87. package/src/server/data/shape-probe.mjs +626 -0
  88. package/src/server/data/spine.mjs +336 -0
  89. package/src/server/data/tree.mjs +126 -0
  90. package/src/server/http/atomic-json.mjs +145 -0
  91. package/src/server/http/background-solids.mjs +270 -0
  92. package/src/server/http/backgrounds.mjs +388 -0
  93. package/src/server/http/diag-upload.mjs +264 -0
  94. package/src/server/http/diag.mjs +173 -0
  95. package/src/server/http/ready.mjs +167 -0
  96. package/src/server/http/static.mjs +312 -0
  97. package/src/server/http/write.mjs +654 -0
  98. package/src/server/index.mjs +114 -0
  99. package/src/server/plugins/builtin-providers.json +30 -0
  100. package/src/server/plugins/registry.mjs +650 -0
  101. package/src/server/plugins/slot-catalog.mjs +286 -0
  102. package/src/server/routes/background-solids.mjs +56 -0
  103. package/src/server/routes/backgrounds.mjs +77 -0
  104. package/src/server/routes/config.mjs +38 -0
  105. package/src/server/routes/data.mjs +313 -0
  106. package/src/server/routes/diag.mjs +37 -0
  107. package/src/server/routes/entries.mjs +170 -0
  108. package/src/server/routes/ready.mjs +122 -0
  109. package/src/server/serve.mjs +396 -0
  110. package/src/server/unpack/capacity.mjs +217 -0
  111. package/src/server/unpack/mem-probe.mjs +27 -0
  112. package/src/server/unpack/plugin-runner.mjs +529 -0
  113. package/src/server/unpack/plugin-store.mjs +736 -0
  114. package/src/server/unpack/probe-store.mjs +73 -0
  115. package/src/server/unpack/routes.mjs +359 -0
  116. package/src/server/unpack/runtime/exec.mjs +558 -0
  117. package/src/server/unpack/runtime/index.mjs +141 -0
  118. package/src/server/unpack/runtime/node-vm.mjs +111 -0
  119. package/src/server/unpack/unpack-worker.mjs +105 -0
  120. package/src/server/unpack/worker-client.mjs +184 -0
  121. package/src/server/unpack/write-result.mjs +84 -0
  122. package/src/shared/bundle-shape.mjs +83 -0
  123. package/src/shared/entry-reasons.mjs +119 -0
  124. package/src/shared/names.mjs +45 -0
  125. package/src/shared/type-labels.mjs +31 -0
  126. package/src/web/README.md +193 -0
  127. package/src/web/app/anim-list.js +222 -0
  128. package/src/web/app/background-solids.js +150 -0
  129. package/src/web/app/backgrounds.js +152 -0
  130. package/src/web/app/camera-log.js +28 -0
  131. package/src/web/app/context.js +72 -0
  132. package/src/web/app/dir-picker.js +242 -0
  133. package/src/web/app/entry-report.js +71 -0
  134. package/src/web/app/memory-bars.js +91 -0
  135. package/src/web/app/memory-budget.js +228 -0
  136. package/src/web/app/memory-guard.js +204 -0
  137. package/src/web/app/model-actions.js +682 -0
  138. package/src/web/app/renderer-setting.js +70 -0
  139. package/src/web/app/root-manager.js +254 -0
  140. package/src/web/app/status.js +138 -0
  141. package/src/web/app/topbar-actions.js +357 -0
  142. package/src/web/app/transport-actions.js +297 -0
  143. package/src/web/app/ui-state.js +559 -0
  144. package/src/web/app/url-params.js +171 -0
  145. package/src/web/app/view-settings.js +186 -0
  146. package/src/web/components.css +524 -0
  147. package/src/web/debug/diag-hud.js +95 -0
  148. package/src/web/debug/drag.js +369 -0
  149. package/src/web/debug/log.js +320 -0
  150. package/src/web/debug/model-cmp.js +292 -0
  151. package/src/web/debug/panel.css +474 -0
  152. package/src/web/debug/panel.js +712 -0
  153. package/src/web/debug/race-probe.js +82 -0
  154. package/src/web/debug/upload.js +130 -0
  155. package/src/web/engine/adapters/atlas.js +47 -0
  156. package/src/web/engine/adapters/index.js +22 -0
  157. package/src/web/engine/adapters/renderer.js +223 -0
  158. package/src/web/engine/adapters/skeleton.js +105 -0
  159. package/src/web/engine/adapters/texture.js +64 -0
  160. package/src/web/engine/animation.js +181 -0
  161. package/src/web/engine/appearance.js +136 -0
  162. package/src/web/engine/camera-adapter.js +212 -0
  163. package/src/web/engine/effect-chain.js +138 -0
  164. package/src/web/engine/engine-state.js +120 -0
  165. package/src/web/engine/filters.js +224 -0
  166. package/src/web/engine/fit.js +197 -0
  167. package/src/web/engine/frame-gate.js +33 -0
  168. package/src/web/engine/gl-driver.js +56 -0
  169. package/src/web/engine/gl.js +77 -0
  170. package/src/web/engine/index.js +20 -0
  171. package/src/web/engine/loader.js +55 -0
  172. package/src/web/engine/model-info.js +26 -0
  173. package/src/web/engine/pipeline.js +200 -0
  174. package/src/web/engine/player.js +427 -0
  175. package/src/web/engine/pma/blend.js +76 -0
  176. package/src/web/engine/pma/detect.js +118 -0
  177. package/src/web/engine/pma/index.js +8 -0
  178. package/src/web/engine/pma/resolve.js +112 -0
  179. package/src/web/engine/pma/upload.js +127 -0
  180. package/src/web/engine/pma.js +5 -0
  181. package/src/web/engine/render-frame.js +93 -0
  182. package/src/web/engine/runtime-capabilities.js +209 -0
  183. package/src/web/engine/runtime.js +37 -0
  184. package/src/web/engine/skins.js +165 -0
  185. package/src/web/engine/textures.js +174 -0
  186. package/src/web/index.html +117 -0
  187. package/src/web/infra/api.js +531 -0
  188. package/src/web/infra/diag.js +509 -0
  189. package/src/web/infra/document.js +269 -0
  190. package/src/web/infra/dom.js +31 -0
  191. package/src/web/infra/events.mjs +27 -0
  192. package/src/web/infra/log.js +153 -0
  193. package/src/web/infra/mem-stats.js +112 -0
  194. package/src/web/infra/plugin-host-registration.mjs +285 -0
  195. package/src/web/infra/plugin-host-report.mjs +32 -0
  196. package/src/web/infra/plugin-host-unload.mjs +79 -0
  197. package/src/web/infra/plugin-host-unpack.mjs +899 -0
  198. package/src/web/infra/plugin-host-warmup.mjs +180 -0
  199. package/src/web/infra/plugin-host.js +628 -0
  200. package/src/web/infra/ready.js +461 -0
  201. package/src/web/infra/schedule.js +51 -0
  202. package/src/web/infra/shortcuts.js +129 -0
  203. package/src/web/infra/slot-registry.js +402 -0
  204. package/src/web/infra/vfs.js +247 -0
  205. package/src/web/live2d/bridge.js +400 -0
  206. package/src/web/live2d/model.js +321 -0
  207. package/src/web/live2d/overlay.js +142 -0
  208. package/src/web/live2d/pixi-app.js +111 -0
  209. package/src/web/live2d/playback-probe.js +92 -0
  210. package/src/web/live2d/probe-geometry.js +329 -0
  211. package/src/web/live2d/probe.js +119 -0
  212. package/src/web/live2d/release.js +66 -0
  213. package/src/web/live2d/runtime-adapter.js +289 -0
  214. package/src/web/live2d/stage-ctx.js +152 -0
  215. package/src/web/live2d/stage.js +204 -0
  216. package/src/web/live2d/vfs-settings.js +174 -0
  217. package/src/web/live2d/view.js +89 -0
  218. package/src/web/main.js +629 -0
  219. package/src/web/panel/background-list.js +373 -0
  220. package/src/web/panel/bg-source.js +291 -0
  221. package/src/web/panel/color-picker.css +199 -0
  222. package/src/web/panel/color-picker.js +278 -0
  223. package/src/web/panel/dialog.css +116 -0
  224. package/src/web/panel/dialog.js +173 -0
  225. package/src/web/panel/dom.js +35 -0
  226. package/src/web/panel/icons.js +58 -0
  227. package/src/web/panel/memory-bar.js +195 -0
  228. package/src/web/panel/panel-settings.js +1263 -0
  229. package/src/web/panel/panel.css +1304 -0
  230. package/src/web/panel/render-choice.js +104 -0
  231. package/src/web/panel/schema.js +784 -0
  232. package/src/web/panel/settings-io.js +291 -0
  233. package/src/web/panel/shell-controls.js +274 -0
  234. package/src/web/panel/slider.js +210 -0
  235. package/src/web/panel/toolbar-schema.js +111 -0
  236. package/src/web/panel/toolbar.js +195 -0
  237. package/src/web/panel/transport-picker.js +375 -0
  238. package/src/web/panel/transport.js +321 -0
  239. package/src/web/panel/view.js +553 -0
  240. package/src/web/plugin/unpack-worker.js +185 -0
  241. package/src/web/render/camera/constants.js +60 -0
  242. package/src/web/render/camera/index.js +123 -0
  243. package/src/web/render/camera/spine.js +124 -0
  244. package/src/web/render/capture.js +30 -0
  245. package/src/web/render/interaction.js +527 -0
  246. package/src/web/render/live2d.js +627 -0
  247. package/src/web/render/playback.js +47 -0
  248. package/src/web/render/reset-view.js +29 -0
  249. package/src/web/render/spine.js +386 -0
  250. package/src/web/render/surface-switch.js +176 -0
  251. package/src/web/render/surface.js +571 -0
  252. package/src/web/render/surfaces.js +70 -0
  253. package/src/web/roster/filter.js +31 -0
  254. package/src/web/roster/list.js +276 -0
  255. package/src/web/roster/persist.js +92 -0
  256. package/src/web/roster/reveal.js +77 -0
  257. package/src/web/roster/rows.js +248 -0
  258. package/src/web/roster/snapshot.js +39 -0
  259. package/src/web/roster/type-filter.js +97 -0
  260. package/src/web/roster/view.js +761 -0
  261. package/src/web/spine/canvas-api-groups.js +267 -0
  262. package/src/web/spine/canvas-api.js +97 -0
  263. package/src/web/spine/canvas-document.js +80 -0
  264. package/src/web/spine/canvas-engine.js +193 -0
  265. package/src/web/spine/canvas-entry.js +28 -0
  266. package/src/web/spine/canvas-pixels.js +36 -0
  267. package/src/web/spine/canvas-runtime.js +127 -0
  268. package/src/web/spine/canvas-view.js +108 -0
  269. package/src/web/spine/canvas.js +7 -0
  270. package/src/web/spine/runtime-hint.js +73 -0
  271. package/src/web/spine/runtimes.js +212 -0
  272. package/src/web/style.css +493 -0
  273. package/tool/lib/bundle-probe-node.mjs +121 -0
  274. package/tool/lib/paths.mjs +20 -0
  275. package/tool/lib/runtime-guides.mjs +65 -0
  276. package/tool/lib/runtime-package.mjs +403 -0
  277. package/tool/lib/spine-meta.mjs +227 -0
  278. package/tool/lib/spine-motions.mjs +104 -0
  279. package/tool/parse-jsonc.mjs +86 -0
  280. package/tool/resolve-live2d-runtimes.mjs +276 -0
  281. package/tool/spine-motion-rules.json +101 -0
@@ -0,0 +1,559 @@
1
+ // app/ui-state.js —— UI 状态的单一真相源(S1)
2
+ //
3
+ // 契约:docs/契约/状态.md
4
+ // R1 单一真相源:UI 显示值 == 生效值 == 工厂里的值(禁止控件自己留一份显示副本)
5
+ // R2 读回早于首次渲染:loadAll() 必须在第一次面板 DOM 构建之前完成(纯内存,不碰 DOM)
6
+ // R3 apply 与 render 分离:apply=下发给引擎/渲染器(幂等);render=更新已存在 DOM 的显示态(可重放)
7
+ // §三 任何面板重建完成后必须派发 panel:rebuilt + applyAll({render:true})(汇聚在 rebuildPanelFor)
8
+ // §四 能力不支持 ⇒ 保留值、不 apply、不 render,切回支持的面再生效(禁回落默认)
9
+ // §五 默认值唯一来源 = schema.js;本模块只派生、不另写一份,并提供启动一致性断言
10
+ //
11
+ // 为什么要有这一层(本次 bug 的直接教训):
12
+ // 旧实现里"显示态"只能靠一次性命令(panel.setChecked / setValue)同步,
13
+ // 面板 DOM 一旦重建(rebuildPanelFor),那些一次性同步就丢了,
14
+ // 于是出现"引擎在循环、开关显示关"。差的就是"按当前生效值重新渲染"这个能力。
15
+ //
16
+ // 依赖方向(**单向,防环**):本模块只依赖 schema/context/view-settings;
17
+ // "怎么 apply / 怎么 render" 由 panel-settings 在装配时 registerAppliers 注入(本模块不 import 业务模块)。
18
+
19
+ import { PANEL_GROUPS } from '../panel/schema.js';
20
+ import { state } from './context.js';
21
+ import { readViewSettings, saveViewSettings } from './view-settings.js';
22
+ import { stageLog } from './status.js';
23
+
24
+ /** 写盘去抖(ms):滑块的连续事件会产生大量写,攒一帧以上再落盘 */
25
+ const WRITE_DEBOUNCE_MS = 150;
26
+
27
+ /**
28
+ * 从 schema 派生"可持久化的面板控件"清单(默认值的唯一来源)。
29
+ *
30
+ * 只收录真正"有状态"的控件:
31
+ * · 排除 kind==='actions'(一次性按钮:导出/导入/恢复默认,存了就会在启动时重放)
32
+ * · 排除 kind==='readonly' / 'dynamic-list'(前者是显示值,后者内容由模型决定)
33
+ * 默认值取 schema 的 defaultValue(有取值域的控件)或 defaultOn(开关)。
34
+ */
35
+ export const SPEC = (() => {
36
+ const out = [];
37
+ for (const group of PANEL_GROUPS) {
38
+ for (const ctl of (group && group.controls) || []) {
39
+ if (!ctl || !ctl.id) continue;
40
+ if (ctl.kind === 'actions' || ctl.kind === 'readonly' || ctl.kind === 'dynamic-list') continue;
41
+ // 皮肤不持久化(与动作列表同一条思路):它的取值域由**当前模型**决定,
42
+ // 跨模型记一个皮肤名毫无意义(A 模型的 "00" 到 B 模型可能根本不存在),
43
+ // 而记住上一个模型的皮肤还会让"装载后自动挑一个能画出东西的皮肤"失效 ——
44
+ // 表现为刷新后又回到空白画面。每次装载重新挑最稳。
45
+ if (ctl.capabilityKey === 'skin') continue;
46
+ // 开关即使没写 defaultOn 也算"有状态":DOM 默认就是关(false)。
47
+ // `ctl-anim-loop` 正是这种写法——漏了它,本 bug 的控件根本进不了工厂。
48
+ const isSwitch = ctl.kind === 'switch';
49
+ const hasValue = ctl.defaultValue !== undefined;
50
+ if (!hasValue && !isSwitch) continue;
51
+ out.push({
52
+ id: String(ctl.id),
53
+ kind: ctl.kind,
54
+ label: ctl.label || ctl.id,
55
+ capabilityKey: ctl.capabilityKey || null,
56
+ // 取值域:分段控件用 ctl.values,下拉/胶囊常用 options:[{text,value}] ⇒ 两种都要认,
57
+ // 否则"非法值"校验会被静默跳过(实测:塞 uiTheme='nope' 竟然被接受)。
58
+ values: Array.isArray(ctl.values)
59
+ ? ctl.values.map(String)
60
+ : (Array.isArray(ctl.options)
61
+ ? ctl.options.map((o) => (o && typeof o === 'object' ? String(o.value) : String(o)))
62
+ : null),
63
+ defaultValue: hasValue ? ctl.defaultValue : ctl.defaultOn === true,
64
+ // 存储槽:主题/背景模式的值一直存在顶层(uiTheme / background.mode),
65
+ // 这里只**读**它们做显示真相,不再往 panel 段写一份(否则就是双份存储)
66
+ store: ctl.id === 'ctl-theme' ? 'theme' : (ctl.id === 'ctl-bg-mode' ? 'bgMode' : 'panel'),
67
+ });
68
+ }
69
+ }
70
+ return out;
71
+ })();
72
+
73
+ /**
74
+ * 动态补登记面板控件(插件在运行期提交的控件走这里)。
75
+ *
76
+ * 为什么必须登记进 SPEC 而不是另开一条存取路径:
77
+ * `set()` 对**未登记**的 id 会走 `defaultOf()` ⇒ 拿到 undefined ⇒ 值根本存不住,
78
+ * 而且不报错(表现为"选了刷新就回默认")。所以插件控件必须进同一份 SPEC,
79
+ * 才能复用 loadAll / applyAll / set / commit / flush 全套通道,不产生第二份真相。
80
+ *
81
+ * 幂等:已存在的 id 不覆盖(内置控件优先,插件不能顶掉内置项)。
82
+ * @param {Array<object>} ctls schema 形态的控件声明(与 PANEL_GROUPS 里的一致)
83
+ * @returns {number} 实际新增条数
84
+ */
85
+ export function registerSpecItems(ctls) {
86
+ let added = 0;
87
+ /** 拒绝登记的表单项(含原因):让"为什么没被工厂接管"当场可见 */
88
+ const skipped = [];
89
+ for (const ctl of ctls || []) {
90
+ const id = String((ctl && ctl.id) || '');
91
+ if (!id || byId.has(id)) continue;
92
+ const isSwitch = ctl.kind === 'toggle' || ctl.kind === 'switch';
93
+ const hasValue = ctl.defaultValue !== undefined;
94
+ const firstOption = Array.isArray(ctl.options) && ctl.options.length ? ctl.options[0] : undefined;
95
+ const item = {
96
+ id,
97
+ kind: ctl.kind,
98
+ label: ctl.label || id,
99
+ capabilityKey: ctl.capabilityKey || null,
100
+ values: Array.isArray(ctl.values)
101
+ ? ctl.values.map(String)
102
+ : (Array.isArray(ctl.options)
103
+ ? ctl.options.map((o) => (o && typeof o === 'object' ? String(o.value) : String(o)))
104
+ : null),
105
+ // 分段控件没写 defaultValue 时默认取第一项(与"第一个就是关/默认档"的既有惯例一致)
106
+ defaultValue: hasValue ? ctl.defaultValue : (isSwitch ? (ctl.defaultOn === true) : firstOption),
107
+ store: 'panel',
108
+ };
109
+ // 强制断言(设计 §1.5「默认值唯一来源」):进工厂的控件必须能算出默认值。
110
+ // 没有默认值 ⇒「恢复默认」会漏掉它、spec 断言也没法比对 ⇒ 宁可拒绝登记并在
111
+ // skipped 里报出来(让"为什么这个控件没被工厂接管"当场可见,而不是以后靠猜)。
112
+ if (item.defaultValue === undefined) {
113
+ skipped.push({ id, reason: '无法确定默认值(档位控件缺 options / 开关缺 defaultOn)' });
114
+ continue;
115
+ }
116
+ SPEC.push(item);
117
+ byId.set(id, item);
118
+ added += 1;
119
+ }
120
+ return { added, skipped };
121
+ }
122
+
123
+ /**
124
+ * 清掉存档里"确认已无主"的键(插件被真删掉之后留下的残留)。
125
+ *
126
+ * 什么时候才敢调:**插件声明全部交完之后**(`EVENTS.PLUGINS_LOADED`,见 infra/events.mjs)。
127
+ * 在那之前 SPEC 里可能只是"还没来得及登记",此时清理会误删用户已经设好的值。
128
+ * 调用时机由 panel-settings 保证(先 rebuildPanelFor 登记 SPEC,再调本函数)。
129
+ *
130
+ * 判据就是 SPEC:它是"当前所有有效控件"的唯一清单(内置 + 已加载插件)。
131
+ * 不在 SPEC 里的面板键,说明对应控件已经不存在了 ⇒ 值留着只会变成永远读不到的垃圾。
132
+ * @returns {string[]} 被清掉的键
133
+ */
134
+ export function pruneUnregistered() {
135
+ let saved = {};
136
+ try { saved = (readViewSettings() || {}).panel || {}; } catch { return []; }
137
+ const orphans = Object.keys(saved).filter((key) => !byId.has(key));
138
+ if (!orphans.length) return [];
139
+ const next = { ...saved };
140
+ for (const key of orphans) {
141
+ delete next[key];
142
+ values.delete(key);
143
+ }
144
+ try { saveViewSettings({ panel: next }); } catch { /* 存储不可用:内存里也已删掉 */ }
145
+ return orphans;
146
+ }
147
+
148
+ /** id → SPEC 声明 */
149
+ const byId = new Map(SPEC.map((it) => [it.id, it]));
150
+
151
+ /**
152
+ * 更新某个控件的**取值域**(运行时动态选项用)。
153
+ *
154
+ * 为什么必须有它:SPEC 的取值域是从 schema 的静态 `options` 推出来的,而有些控件的选项
155
+ * 是**装载模型后才填**的(皮肤列表:schema 里只声明了 `default` 占位,真实皮肤名
156
+ * 由 `panel.setOptions` 在运行时补进来)。
157
+ * 不把动态选项同步进域 ⇒ `set()` 的校验判它"非法"⇒ 回落默认值 ⇒
158
+ * 表现成"点了没反应"(实测:点皮肤 `00` 会切回 `default`,且连高亮都不动)。
159
+ * @param {string} id 控件 id
160
+ * @param {string[]} list 新的候选值(字符串化后保存)
161
+ * @returns {boolean} 该控件是否已登记
162
+ */
163
+ export function setDomain(id, list) {
164
+ const key = String(id);
165
+ const it = byId.get(key);
166
+ if (!it) return false;
167
+ it.values = Array.isArray(list) ? list.map(String) : null;
168
+ return true;
169
+ }
170
+
171
+ /** 当前值(唯一真相源;启动时由 loadAll 填充,用户改动由 set 更新) */
172
+ const values = new Map();
173
+
174
+ /** id → {apply, render} 应用器(由 panel-settings 装配时注入) */
175
+ const appliers = new Map();
176
+
177
+ /** 落盘调度 */
178
+ let writeTimer = 0;
179
+ /** 是否已成功读过一次(写入前的安全闸) */
180
+ let loadedOnce = false;
181
+ /** 最近一次被校验拒绝的项(诊断用:脏数据回落不该静默) */
182
+ const rejected = [];
183
+ /**
184
+ * 启动阶段"不参与 applyAll"的项(STARTUP_SKIP:如 MSAA,重建渲染上下文要 2~5 秒)。
185
+ * 它们的**值照常保存**,只是本轮不下发;面板重建时的 applyAll 也会跳过,行为与旧实现一致。
186
+ */
187
+ let applySkip = new Set();
188
+
189
+ /** 设置 applyAll 的跳过集合(启动时序用;传空集恢复全量) */
190
+ export function setApplySkip(ids) {
191
+ applySkip = ids instanceof Set ? new Set([...ids].map(String)) : new Set([...(ids || [])].map(String));
192
+ }
193
+
194
+ /** 读整个 debug 段(内部用) */
195
+ function debugSection() {
196
+ try {
197
+ const root = readViewSettings() || {};
198
+ return (root && typeof root.debug === 'object' && root.debug) ? root.debug : {};
199
+ } catch { return {}; }
200
+ }
201
+
202
+ /**
203
+ * 面板位置(收起态胶囊锚点)。
204
+ * @returns {{left:number, top:number}|null} 无记录/损坏返回 null(调用方用 CSS 默认位置)
205
+ */
206
+ export function getPanelPos() {
207
+ const v = debugSection().panelPos;
208
+ if (!v || !Number.isFinite(v.left) || !Number.isFinite(v.top)) return null;
209
+ return { left: v.left, top: v.top };
210
+ }
211
+
212
+ /** 保存面板位置(走同一条落盘路径) */
213
+ export function setPanelPos(pos) {
214
+ if (!pos || !Number.isFinite(pos.left) || !Number.isFinite(pos.top)) return false;
215
+ const next = { ...debugSection(), panelPos: { left: Math.round(pos.left), top: Math.round(pos.top) } };
216
+ try { saveViewSettings({ debug: next }); return true; } catch { return false; }
217
+ }
218
+
219
+ /** 调试日志四档(未存过 ⇒ null,调用方用默认三档) */
220
+ export function getLogLevels() {
221
+ const v = debugSection().logLevels;
222
+ return Array.isArray(v) ? v.filter((x) => typeof x === 'string') : null;
223
+ }
224
+
225
+ /** 保存四档选择 */
226
+ export function setLogLevels(levels) {
227
+ const arr = Array.isArray(levels) ? levels.filter((x) => typeof x === 'string') : [];
228
+ const next = { ...debugSection(), logLevels: arr };
229
+ try { saveViewSettings({ debug: next }); return true; } catch { return false; }
230
+ }
231
+
232
+
233
+ // ---------------------------------------------------------------- 布局 / 浮层收起 / 名册 三个独立槽位
234
+ //
235
+ // 三者各自独立存储(一个坏不连累另一个):
236
+ // layout → 左右栏显隐(布局偏好,与模型无关)
237
+ // debug → 浮层收起 + 位置 + 四档
238
+ // roster → 名册展开态与上次选中(**键名沿用历史命名,是当前唯一键**)
239
+
240
+ /** 布局槽默认值:无记录**一律全展开**(把"没有值"当成"用户选了收起"会让新用户以为界面坏了) */
241
+ const LAYOUT_DEFAULT = { leftVisible: true, rightVisible: true };
242
+
243
+ /**
244
+ * 读左右栏显隐偏好。
245
+ * @returns {{leftVisible:boolean, rightVisible:boolean}} 无记录/损坏 ⇒ 全展开
246
+ */
247
+ export function getLayout() {
248
+ let v = null;
249
+ try {
250
+ const root = readViewSettings() || {};
251
+ v = root.layout && typeof root.layout === 'object' ? root.layout : null;
252
+ } catch { v = null; }
253
+ if (!v) return { ...LAYOUT_DEFAULT };
254
+ return {
255
+ leftVisible: typeof v.leftVisible === 'boolean' ? v.leftVisible : LAYOUT_DEFAULT.leftVisible,
256
+ rightVisible: typeof v.rightVisible === 'boolean' ? v.rightVisible : LAYOUT_DEFAULT.rightVisible,
257
+ };
258
+ }
259
+
260
+ /**
261
+ * 保存左右栏显隐(可只传一项,另一项沿用当前值)。
262
+ * @param {{leftVisible?:boolean, rightVisible?:boolean}} patch
263
+ * @returns {{leftVisible:boolean, rightVisible:boolean}} 保存后的完整状态
264
+ */
265
+ export function setLayout(patch) {
266
+ const next = { ...getLayout(), ...(patch || {}) };
267
+ try { saveViewSettings({ layout: next }); } catch { /* 存储不可用就只在当前页面存活期内生效 */ }
268
+ return next;
269
+ }
270
+
271
+ /**
272
+ * 调试浮层是否收起。
273
+ * @returns {boolean} 无记录 ⇒ false(默认展开)
274
+ */
275
+ export function getDebugCollapsed() {
276
+ const v = debugSection().collapsed;
277
+ return typeof v === 'boolean' ? v : false;
278
+ }
279
+
280
+ /** 保存浮层收起状态(与位置/四档共用 debug 段,互不覆盖) */
281
+ export function setDebugCollapsed(collapsed) {
282
+ const next = { ...debugSection(), collapsed: !!collapsed };
283
+ try { saveViewSettings({ debug: next }); return true; } catch { return false; }
284
+ }
285
+
286
+ // ---------------------------------------------------------------- 名册键代理(结构不动)
287
+ /**
288
+ * 右栏背景「二级列表」当前展开的组('solid' | 'custom' | '')。
289
+ *
290
+ * 为什么要持久化:刷新后按钮状态能回填(档位由 ctl-bg-mode 的工厂值驱动),
291
+ * 但"展开哪一组"原来只是内存变量 ⇒ 刷新后列表收起了,用户以为丢了状态。
292
+ * @returns {'solid'|'custom'|''} 无记录/非法值 ⇒ ''(都不展开)
293
+ */
294
+ export function getBgOpen() {
295
+ let v = '';
296
+ try {
297
+ const root = readViewSettings() || {};
298
+ v = typeof root.bgOpen === 'string' ? root.bgOpen : '';
299
+ } catch { v = ''; }
300
+ return v === 'solid' || v === 'custom' ? v : '';
301
+ }
302
+
303
+ /** 保存"展开哪一组"('' = 都收起) */
304
+ export function setBgOpen(group) {
305
+ const g = group === 'solid' || group === 'custom' ? group : '';
306
+ try { saveViewSettings({ bgOpen: g }); } catch { /* 存储不可用就只在当前页面生效 */ }
307
+ return g;
308
+ }
309
+
310
+ /** 名册状态存储键(全站唯一名册状态键) */
311
+ export const TREE_STATE_KEY = 'modelstage.tree-state.v1';
312
+
313
+
314
+ /**
315
+ * 读名册状态(原样返回解析后的对象;缺省/损坏返回 {})。
316
+ * @returns {object}
317
+ */
318
+ export function getTreeState() {
319
+ try {
320
+ const raw = localStorage.getItem(TREE_STATE_KEY);
321
+ const obj = raw ? JSON.parse(raw) : null;
322
+ return obj && typeof obj === 'object' ? obj : {};
323
+ } catch { return {}; }
324
+ }
325
+
326
+ /**
327
+ * 写名册状态(原样写入调用方给的对象,不做字段增删)。
328
+ * @param {object} obj 调用方构造的完整对象
329
+ * @returns {boolean} 是否写入成功
330
+ */
331
+ export function setTreeState(obj) {
332
+ if (!obj || typeof obj !== 'object') return false;
333
+ try { localStorage.setItem(TREE_STATE_KEY, JSON.stringify(obj)); return true; } catch { return false; }
334
+ }
335
+
336
+ /** 某控件的默认值(SPEC 派生,等于 schema 的默认) */
337
+ export function defaultOf(id) {
338
+ const it = byId.get(String(id));
339
+ return it ? it.defaultValue : undefined;
340
+ }
341
+
342
+ /** 当前值(未 set 过且未 load 过 ⇒ 返回 undefined,调用方用 defaultOf 兜底) */
343
+ export function getValue(id) {
344
+ const key = String(id);
345
+ if (values.has(key)) return values.get(key);
346
+ return defaultOf(key);
347
+ }
348
+
349
+ /** 全量快照(供导出/断言) */
350
+ export function getAll() {
351
+ const out = {};
352
+ for (const it of SPEC) out[it.id] = getValue(it.id);
353
+ return out;
354
+ }
355
+
356
+ /** 装配注入:{ id: { apply(value), render(value) } } */
357
+ export function registerAppliers(map) {
358
+ for (const [id, fn] of Object.entries(map || {})) {
359
+ if (fn && typeof fn.apply === 'function') appliers.set(String(id), fn);
360
+ }
361
+ }
362
+
363
+ /**
364
+ * 校验一个值是否合法(类型与取值域)。不合法返回 false,由调用方回落默认。
365
+ * @param {{id:string, kind:string, values:string[]|null, defaultValue:unknown}} it
366
+ * @param {unknown} v
367
+ */
368
+ function valid(it, v) {
369
+ // ① **有取值域时以域为准**:域内即合法。
370
+ // 必须按字符串比较 —— 取值域可能是混合类型(如 DPR 的 ['auto', 1, 1.5, 2]),
371
+ // 若按 defaultValue 的类型('auto' ⇒ string)判,点 1/1.5/2 会被判非法并回落默认,
372
+ // 表现为"面板点不动"(这条曾把分段控件变成假接线)。
373
+ if (Array.isArray(it.values) && it.values.length) {
374
+ return it.values.map(String).includes(String(v));
375
+ }
376
+ // ② 无取值域(开关/滑块等):按默认值的类型校验
377
+ const d = it.defaultValue;
378
+ if (typeof d === 'boolean') return typeof v === 'boolean';
379
+ if (typeof d === 'number') return typeof v === 'number' && Number.isFinite(v);
380
+ if (typeof d === 'string') return typeof v === 'string';
381
+ return false;
382
+ }
383
+
384
+ /**
385
+ * 启动时一次性读回全部(**纯内存,不碰 DOM**)。
386
+ * 脏数据/类型不符 ⇒ 回落默认并记录(不抛、不阻塞启动)。
387
+ * @returns {{loaded:number, rejected:string[]}} 诊断信息
388
+ */
389
+ export function loadAll() {
390
+ let saved = {};
391
+ let root = {};
392
+ try { root = readViewSettings() || {}; } catch { root = {}; }
393
+ saved = (root && root.panel) || {};
394
+ values.clear();
395
+ rejected.length = 0;
396
+ for (const it of SPEC) {
397
+ // 按存储槽取值:主题/背景模式在顶层,其余在 panel 段
398
+ let v;
399
+ let has = false;
400
+ if (it.store === 'theme') { v = root.uiTheme; has = v !== undefined; }
401
+ else if (it.store === 'bgMode') { v = root.background && root.background.mode; has = v !== undefined; }
402
+ else if (it.id in saved) { v = saved[it.id]; has = true; }
403
+
404
+ if (!has) { values.set(it.id, it.defaultValue); continue; } // 没存过 ⇒ 用 schema 默认(S2:默认值由工厂补齐)
405
+ if (valid(it, v)) { values.set(it.id, v); continue; }
406
+ // 非法值:回落默认,但**仍然记进内存**。
407
+ // 为什么不能只跳过:flush() 会把 values 整包写回,跳过就等于把这些键从存储里删掉,
408
+ // 一条脏数据会连带清空用户其余设置(实测:塞 4 个脏值后 panel 被写成 {})。
409
+ values.set(it.id, it.defaultValue);
410
+ rejected.push(it.id);
411
+ }
412
+ // 插件控件是**运行期**才登记进 SPEC 的(插件脚本异步注入),而本函数在启动早期就读盘了 ——
413
+ // 那时它们的声明还不在 SPEC 里。若不把 panel 段里这些"暂时无主的键"也读进内存,
414
+ // 插件登记完成后 getValue 只能拿到默认值,表现为"设过的档位刷新后回到默认"。
415
+ // 这里原样保留(不合并不校验),等 registerSpecItems 登记后用同一套 getValue 取到。
416
+ for (const [key, val] of Object.entries(saved || {})) {
417
+ if (!byId.has(key)) values.set(key, val);
418
+ }
419
+ loadedOnce = true;
420
+ return { loaded: values.size, rejected: [...rejected] };
421
+ }
422
+
423
+ /**
424
+ * 某个控件当前是否"可生效":能力不支持的控件不会被构建(不进 panelWired)。
425
+ * 契约 §四:这类项**保留值、不 apply、不 render**,切回支持渲染面后由 applyAll 自然生效。
426
+ * @param {string} id
427
+ */
428
+ function canApplyNow(id) {
429
+ const wired = state.panelWired;
430
+ // 面板还没构建(null/空)⇒ 不拦(启动早期由调用顺序保证)
431
+ if (!Array.isArray(wired) || wired.length === 0) return true;
432
+ return wired.includes(id);
433
+ }
434
+
435
+ /**
436
+ * 把当前值下发给引擎/渲染器,并(可选)重放显示态。
437
+ *
438
+ * **幂等**:可以在每次面板重建后反复调用(契约 §三)。
439
+ * @param {{render?: boolean, only?: string[]}} [opts] render=true 同时更新已存在 DOM 的显示态
440
+ * @returns {{applied:string[], skipped:string[]}}
441
+ */
442
+ export function applyAll(opts = {}) {
443
+ const wantRender = opts.render === true;
444
+ const only = Array.isArray(opts.only) ? new Set(opts.only.map(String)) : null;
445
+ const applied = [];
446
+ const skipped = [];
447
+ for (const it of SPEC) {
448
+ if (only && !only.has(it.id)) continue;
449
+ if (applySkip.has(it.id)) { skipped.push(it.id); continue; }
450
+ const v = getValue(it.id);
451
+ if (v === undefined) continue;
452
+ if (!canApplyNow(it.id)) { skipped.push(it.id); continue; }
453
+ const fn = appliers.get(it.id);
454
+ if (!fn) { skipped.push(it.id); continue; }
455
+ try {
456
+ fn.apply(v);
457
+ if (wantRender && typeof fn.render === 'function') fn.render(v);
458
+ applied.push(it.id);
459
+ } catch {
460
+ skipped.push(it.id); // 单项失败不拖垮其它项(与旧 restorePanelSettings 的容错一致)
461
+ }
462
+ }
463
+ return { applied, skipped };
464
+ }
465
+
466
+ /** 立即落盘(幂等;visibilitychange 隐藏时也会调) */
467
+ export function flush() {
468
+ if (writeTimer) { clearTimeout(writeTimer); writeTimer = 0; }
469
+ // 从未成功读回就不写:否则一次启动异常/未初始化就把整个 panel 段写成 {}(用户设置全丢)
470
+ if (!loadedOnce) return false;
471
+ // 两个集合要分开:
472
+ // panel = 真正写进 panel 段的项(主题/背景模式由 applyTheme/applyBackground 写顶层,不重复存)
473
+ // snapshot = **全部**项的值,只作为 dataset 上的可观测快照(自动化判"点击是否生效"用它)
474
+ const panel = {};
475
+ const snapshot = {};
476
+ for (const it of SPEC) {
477
+ const v = values.get(it.id);
478
+ if (v === undefined) continue;
479
+ snapshot[it.id] = v;
480
+ if (it.store === 'panel') panel[it.id] = v;
481
+ }
482
+ // 未登记的键(插件控件:尚未登记,或插件已被卸载)不能在这里被抹掉 ——
483
+ // `saveViewSettings({panel})` 是整体替换语义,漏掉就等于"改别的设置把插件的值删了"。
484
+ for (const [key, val] of values.entries()) {
485
+ if (byId.has(key) || val === undefined) continue;
486
+ panel[key] = val;
487
+ snapshot[key] = val;
488
+ }
489
+ state.panelSettings = { ...panel };
490
+ try { document.documentElement.dataset.panelSetting = JSON.stringify(snapshot); } catch { /* 忽略 */ }
491
+ try { saveViewSettings({ panel }); } catch { /* 存储不可用就只保留内存态 */ }
492
+ return true;
493
+ }
494
+
495
+ /** 去抖落盘 */
496
+ function scheduleWrite() {
497
+ if (writeTimer) return;
498
+ writeTimer = setTimeout(() => { writeTimer = 0; flush(); }, WRITE_DEBOUNCE_MS);
499
+ }
500
+
501
+ /**
502
+ * 改一个值:校验 → 内存 → 生效 → 显示 → (去抖)落盘。
503
+ * @param {string} id
504
+ * @param {unknown} value
505
+ * @param {{persist?: boolean, render?: boolean}} [opts]
506
+ * @returns {unknown} 实际采用的值(非法时为默认值)
507
+ */
508
+ export function set(id, value, opts = {}) {
509
+ const key = String(id);
510
+ const it = byId.get(key);
511
+ const v = it && valid(it, value) ? value : defaultOf(key);
512
+ if (it) values.set(key, v);
513
+ const fn = appliers.get(key);
514
+ if (fn) {
515
+ try { fn.apply(v); } catch { /* 生效失败不影响记录 */ }
516
+ if (opts.render !== false && typeof fn.render === 'function') {
517
+ try { fn.render(v); } catch { /* 显示失败不影响生效 */ }
518
+ }
519
+ }
520
+ if (opts.persist !== false) scheduleWrite();
521
+ return v;
522
+ }
523
+
524
+ /**
525
+ * **只记录**一个已经生效的值(不再 apply)。
526
+ *
527
+ * 为什么需要它:渲染器可能把请求值钳制/回退(契约 INV-08),此时工厂里必须存"生效值"而不是"请求值"。
528
+ * 若走 set() 会再次调用 apply ⇒ 自递归(而且是把钳制后的值反复下发)。
529
+ * @param {string} id
530
+ * @param {unknown} value
531
+ * @param {{render?: boolean}} [opts]
532
+ * @returns {unknown} 实际记录的值
533
+ */
534
+ export function commit(id, value, opts = {}) {
535
+ const key = String(id);
536
+ const it = byId.get(key);
537
+ const v = it && valid(it, value) ? value : defaultOf(key);
538
+ if (it) values.set(key, v);
539
+ if (opts.render !== false) {
540
+ const fn = appliers.get(key);
541
+ if (fn && typeof fn.render === 'function') {
542
+ try { fn.render(v); } catch { /* 显示失败不影响记录 */ }
543
+ }
544
+ }
545
+ scheduleWrite();
546
+ return v;
547
+ }
548
+
549
+ /** 诊断快照(断言用) */
550
+ export function snapshot() {
551
+ return { values: getAll(), rejected: [...rejected], loadedOnce, specCount: SPEC.length };
552
+ }
553
+
554
+ // 关页/切后台时把去抖中的值落盘(否则"改完立刻关页面"会丢最后一次)
555
+ if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {
556
+ document.addEventListener('visibilitychange', () => {
557
+ if (document.visibilityState === 'hidden') flush();
558
+ });
559
+ }