@pioneer_zmc/dsh-workbench 0.2.1

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 (168) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +98 -0
  3. package/README.zh.md +100 -0
  4. package/client.js +122 -0
  5. package/cordis.patch.yml +8 -0
  6. package/dist/check-building.js +1669 -0
  7. package/dist/check-room.js +980 -0
  8. package/dist/index.js +2873 -0
  9. package/package.json +60 -0
  10. package/skills/specdev-building/SKILL.md +85 -0
  11. package/skills/specdev-building/assets/layouts/court.json +53 -0
  12. package/skills/specdev-building/assets/layouts/row.json +48 -0
  13. package/skills/specdev-building/assets/layouts/single.json +27 -0
  14. package/skills/specdev-building/assets/layouts/tower-9.json +71 -0
  15. package/skills/specdev-building/assets/layouts/tower.json +59 -0
  16. package/skills/specdev-building/references/blueprint-contract.md +126 -0
  17. package/skills/specdev-building/references/diagnostics.md +101 -0
  18. package/skills/specdev-building/references/layouts.md +112 -0
  19. package/skills/specdev-business/SKILL.md +91 -0
  20. package/skills/specdev-business/assets/plan-template.md +66 -0
  21. package/skills/specdev-business/assets/spec-template.md +111 -0
  22. package/skills/specdev-business/assets/tasks-template.md +36 -0
  23. package/skills/specdev-business/references/change.md +117 -0
  24. package/skills/specdev-business/references/evidence-review.md +125 -0
  25. package/skills/specdev-business/references/implementation.md +67 -0
  26. package/skills/specdev-business/references/project-contract.md +211 -0
  27. package/skills/specdev-business/references/source-map.md +110 -0
  28. package/skills/specdev-business/references/specification.md +93 -0
  29. package/skills/specdev-business/references/workflow.md +126 -0
  30. package/skills/specdev-room/SKILL.md +77 -0
  31. package/skills/specdev-room/assets/intro-room.json +14 -0
  32. package/skills/specdev-room/references/diagnostics.md +21 -0
  33. package/skills/specdev-room/references/room-contract.md +59 -0
  34. package/vendor/archify-renderer/VERSION.md +52 -0
  35. package/vendor/archify-renderer/archify/LICENSE +22 -0
  36. package/vendor/archify-renderer/archify/THIRD_PARTY_NOTICES.md +56 -0
  37. package/vendor/archify-renderer/archify/assets/template.html +14787 -0
  38. package/vendor/archify-renderer/archify/bin/archify.mjs +1990 -0
  39. package/vendor/archify-renderer/archify/renderers/shared/brand-marks.mjs +563 -0
  40. package/vendor/archify-renderer/archify/renderers/shared/cli.mjs +218 -0
  41. package/vendor/archify-renderer/archify/renderers/shared/desktop-readability.mjs +26 -0
  42. package/vendor/archify-renderer/archify/renderers/shared/diagnostics.mjs +127 -0
  43. package/vendor/archify-renderer/archify/renderers/shared/engineering-profiles.mjs +157 -0
  44. package/vendor/archify-renderer/archify/renderers/shared/generated-brand-marks.mjs +2003 -0
  45. package/vendor/archify-renderer/archify/renderers/shared/generated-validators.mjs +13 -0
  46. package/vendor/archify-renderer/archify/renderers/shared/geometry.mjs +1423 -0
  47. package/vendor/archify-renderer/archify/renderers/shared/i18n.mjs +594 -0
  48. package/vendor/archify-renderer/archify/renderers/shared/legend.mjs +217 -0
  49. package/vendor/archify-renderer/archify/renderers/shared/output-path.mjs +321 -0
  50. package/vendor/archify-renderer/archify/renderers/shared/repository-evidence.mjs +235 -0
  51. package/vendor/archify-renderer/archify/renderers/shared/text-fit.mjs +49 -0
  52. package/vendor/archify-renderer/archify/renderers/shared/utils.mjs +232 -0
  53. package/vendor/archify-renderer/archify/renderers/shared/validator.mjs +86 -0
  54. package/vendor/archify-renderer/archify/renderers/workflow/render-workflow.mjs +35 -0
  55. package/vendor/archify-renderer/archify/renderers/workflow/workflow-compiler.mjs +4400 -0
  56. package/vendor/archify-renderer/archify/renderers/workflow/workflow-migration-geometry.mjs +144 -0
  57. package/vendor/archify-renderer/archify/schemas/common.schema.json +115 -0
  58. package/vendor/archify-renderer/archify/schemas/workflow.schema.json +428 -0
  59. package/vendor/archify-renderer/archify/scripts/check-render-output.mjs +835 -0
  60. package/vendor/archify-renderer/shims/process.mjs +18 -0
  61. package/vendor/archify-renderer/stubs/child_process.mjs +2 -0
  62. package/vendor/archify-renderer/stubs/crypto.mjs +3 -0
  63. package/vendor/archify-renderer/stubs/dns-promises.mjs +2 -0
  64. package/vendor/archify-renderer/stubs/fs.mjs +3 -0
  65. package/vendor/archify-renderer/stubs/http.mjs +3 -0
  66. package/vendor/archify-renderer/stubs/https.mjs +3 -0
  67. package/vendor/archify-renderer/stubs/net.mjs +3 -0
  68. package/vendor/archify-renderer/stubs/path.mjs +3 -0
  69. package/vendor/archify-renderer/stubs/url.mjs +3 -0
  70. package/web/assets/building-kit.js +1157 -0
  71. package/web/assets/building-look.js +185 -0
  72. package/web/assets/building-plan.js +495 -0
  73. package/web/assets/building-scene.js +529 -0
  74. package/web/assets/building-walk.js +248 -0
  75. package/web/assets/building.js +859 -0
  76. package/web/assets/business.js +124 -0
  77. package/web/assets/chart-link.js +165 -0
  78. package/web/assets/common.js +302 -0
  79. package/web/assets/details.js +197 -0
  80. package/web/assets/evidence.js +30 -0
  81. package/web/assets/furniture/SOURCES.md +24 -0
  82. package/web/assets/furniture/assets/ceramic-display-stand-01/asset.json +25 -0
  83. package/web/assets/furniture/assets/ceramic-display-stand-01/model.js +116 -0
  84. package/web/assets/furniture/assets/ceramic-display-stand-01/preview.webp +0 -0
  85. package/web/assets/furniture/assets/ceramic-floor-lamp-01/asset.json +61 -0
  86. package/web/assets/furniture/assets/ceramic-floor-lamp-01/model.js +56 -0
  87. package/web/assets/furniture/assets/ceramic-floor-lamp-01/preview.webp +0 -0
  88. package/web/assets/furniture/assets/ceramic-low-cabinet-01/asset.json +81 -0
  89. package/web/assets/furniture/assets/ceramic-low-cabinet-01/model.js +67 -0
  90. package/web/assets/furniture/assets/ceramic-low-cabinet-01/preview.webp +0 -0
  91. package/web/assets/furniture/assets/ceramic-vase-01/asset.json +21 -0
  92. package/web/assets/furniture/assets/ceramic-vase-01/model.js +119 -0
  93. package/web/assets/furniture/assets/ceramic-vase-01/preview.webp +0 -0
  94. package/web/assets/furniture/assets/fairy-bed-01/asset.json +61 -0
  95. package/web/assets/furniture/assets/fairy-bed-01/model.js +63 -0
  96. package/web/assets/furniture/assets/fairy-bed-01/preview.webp +0 -0
  97. package/web/assets/furniture/assets/fairy-book-stack-01/asset.json +21 -0
  98. package/web/assets/furniture/assets/fairy-book-stack-01/model.js +71 -0
  99. package/web/assets/furniture/assets/fairy-book-stack-01/preview.webp +0 -0
  100. package/web/assets/furniture/assets/fairy-bookshelf-01/asset.json +32 -0
  101. package/web/assets/furniture/assets/fairy-bookshelf-01/model.js +168 -0
  102. package/web/assets/furniture/assets/fairy-bookshelf-01/preview.webp +0 -0
  103. package/web/assets/furniture/assets/fairy-desk-01/asset.json +25 -0
  104. package/web/assets/furniture/assets/fairy-desk-01/model.js +132 -0
  105. package/web/assets/furniture/assets/fairy-desk-01/preview.webp +0 -0
  106. package/web/assets/furniture/assets/fairy-notice-board-01/asset.json +23 -0
  107. package/web/assets/furniture/assets/fairy-notice-board-01/model.js +125 -0
  108. package/web/assets/furniture/assets/fairy-notice-board-01/preview.webp +0 -0
  109. package/web/assets/furniture/assets/fairy-potted-plant-01/asset.json +21 -0
  110. package/web/assets/furniture/assets/fairy-potted-plant-01/model.js +128 -0
  111. package/web/assets/furniture/assets/fairy-potted-plant-01/preview.webp +0 -0
  112. package/web/assets/furniture/assets/fairy-reading-chair-01/asset.json +61 -0
  113. package/web/assets/furniture/assets/fairy-reading-chair-01/model.js +66 -0
  114. package/web/assets/furniture/assets/fairy-reading-chair-01/preview.webp +0 -0
  115. package/web/assets/furniture/assets/fairy-table-lamp-01/asset.json +21 -0
  116. package/web/assets/furniture/assets/fairy-table-lamp-01/model.js +182 -0
  117. package/web/assets/furniture/assets/fairy-table-lamp-01/preview.webp +0 -0
  118. package/web/assets/furniture/catalog.js +137 -0
  119. package/web/assets/furniture/index.json +110 -0
  120. package/web/assets/furniture/registry.js +72 -0
  121. package/web/assets/furniture-page.css +68 -0
  122. package/web/assets/furniture-page.js +302 -0
  123. package/web/assets/home.js +65 -0
  124. package/web/assets/node-detail.js +148 -0
  125. package/web/assets/panel-link.js +47 -0
  126. package/web/assets/read.js +746 -0
  127. package/web/assets/room-scene.js +386 -0
  128. package/web/assets/room.css +287 -0
  129. package/web/assets/room.js +437 -0
  130. package/web/assets/rooms/bindings.js +449 -0
  131. package/web/assets/rooms/ceramic-reflection.js +193 -0
  132. package/web/assets/rooms/ceramic-room.js +302 -0
  133. package/web/assets/rooms/door.js +35 -0
  134. package/web/assets/rooms/fairy-room.js +492 -0
  135. package/web/assets/rooms/furniture-assembly.js +113 -0
  136. package/web/assets/rooms/navigation.js +262 -0
  137. package/web/assets/rooms/placeholder-catalog.json +71 -0
  138. package/web/assets/rooms/placeholder-furniture.js +76 -0
  139. package/web/assets/rooms/placement.js +588 -0
  140. package/web/assets/rooms/reader.js +441 -0
  141. package/web/assets/rooms/reading-gate.js +157 -0
  142. package/web/assets/rooms/room-lighting.js +50 -0
  143. package/web/assets/rooms/templates.json +50 -0
  144. package/web/assets/rooms/walk-frame.js +64 -0
  145. package/web/assets/rooms/walk-session.js +138 -0
  146. package/web/assets/save-result.js +89 -0
  147. package/web/assets/screenshots/business.png +0 -0
  148. package/web/assets/screenshots/node-detail.png +0 -0
  149. package/web/assets/screenshots/project-home.png +0 -0
  150. package/web/assets/screenshots/workflow.png +0 -0
  151. package/web/assets/showcase.js +232 -0
  152. package/web/assets/style.css +836 -0
  153. package/web/assets/template.js +7 -0
  154. package/web/assets/theme.js +29 -0
  155. package/web/assets/three/OrbitControls.js +1972 -0
  156. package/web/assets/three/VERSION.md +12 -0
  157. package/web/assets/three/three.core.js +60586 -0
  158. package/web/assets/three/three.module.js +19719 -0
  159. package/web/assets/workspaces.css +21 -0
  160. package/web/assets/workspaces.js +81 -0
  161. package/web/building.html +131 -0
  162. package/web/business.html +43 -0
  163. package/web/furniture.html +47 -0
  164. package/web/index.html +57 -0
  165. package/web/read.html +107 -0
  166. package/web/room.html +146 -0
  167. package/web/showcase.html +50 -0
  168. package/web/workspaces.html +32 -0
@@ -0,0 +1,859 @@
1
+ // 建筑总览页逻辑:读 /api/building(清单是内容依据、蓝图是空间安排)。
2
+ // 目录面板、状态页与诊断列表在本文档装配;三维视图由 building-scene.js 用 Three.js
3
+ // 消费服务端算好的 model/present——页面不重复计算建筑几何,楼层剖看只是可见性过滤。
4
+ // 建筑生成成功后整页切到"全窗口"形态(body.building-live):页面不滚动,三维视图占满
5
+ // 浏览器窗口,右侧资料目录改成浮在视口上的抽屉,开关在操作行右端(状态记在本机;
6
+ // 进探索时临时收起、退出后收回作者的选择——见 setDirectoryOpen 与 syncWalkUi)。
7
+ // 房内打开资料:点业务门牌/房间目录给该业务的介绍与资料清单,点项目牌给项目介绍;
8
+ // 文档正文在面板里读,流程图把现有阅读页装进面板(带同一个工作区参数)。
9
+ // 打开面板时暂停漫游输入,关闭后位置与视角原样;换蓝图时旧资料整套作废。
10
+ // 点门牌的拾取由场景按 building-walk.js 的 pickSign 判定(被墙挡住、被剖看藏起的不算),
11
+ // 阅读页里的 ESC 经 panel-link.js 转回来关面板。
12
+ import {
13
+ $,
14
+ el,
15
+ wsUrl,
16
+ workspaceParamEmpty,
17
+ renderEmptyWorkspaceParam,
18
+ fetchJson,
19
+ renderGuide,
20
+ renderRepoState,
21
+ renderRepoLine,
22
+ repoLineText,
23
+ pageTitle,
24
+ setStatus,
25
+ showError,
26
+ } from './common.js'
27
+ import { parseDocument, splitInline } from './details.js'
28
+ import { listenMaterialClose } from './panel-link.js'
29
+
30
+ /** 建造阶段的展示文案(标签+一句说明)。阶段语义在蓝图契约里,这里只有文案。 */
31
+ const PHASE_LABELS = {
32
+ design: ['设计审批', '显示规划框架、门牌与资料入口;没有实体墙或家具。'],
33
+ foundation: ['基础', '地基已落,仍以规划框架呈现房间。'],
34
+ structure: ['主体结构', '梁柱与楼板成形,墙顶尚未围合。'],
35
+ enclosure: ['屋面围护', '屋顶与墙体围合;墙的呈现按蓝图中的证据声明区分。'],
36
+ services: ['水电暖通', '本轮不生成虚构管线,构件与围护阶段相同。'],
37
+ interior: ['内装', '本轮不生成家具,构件与围护阶段相同。'],
38
+ accepted: ['验收入住', '完整外观。'],
39
+ }
40
+
41
+ /**
42
+ * 三维场景模块按需动态加载:诊断、空态与配置指引页不拉 Three.js。
43
+ * 加载口可注入(页面回归在无浏览器的 jsdom 里替换它,动态 import 在那里不可用);
44
+ * 生产路径用浏览器动态 import,importmap 解析裸名 'three'。
45
+ */
46
+ function loadSceneModule() {
47
+ if (typeof globalThis.__loadBuildingScene === 'function') return globalThis.__loadBuildingScene()
48
+ return import('/specdev-workbench/assets/building-scene.js')
49
+ }
50
+
51
+ let scene = null
52
+ let sceneFailed = null
53
+ let walking = false
54
+ // 房内打开资料的状态:当前蓝图的资料清单、面板里正读的那份资料、是否由我们暂停了漫游输入、
55
+ // 文档取数代次(面板换目标或又点了一份资料时,晚到的正文不许写进已经换过的阅读区),
56
+ // 以及打开面板时焦点在哪儿(关掉时还回去:读资料会隐藏目录,焦点不能留在看不见的按钮上)。
57
+ let currentCatalog = new Map()
58
+ let panelMaterialKey = ''
59
+ let pausedWalk = false
60
+ let readSeq = 0
61
+ let panelReturnFocus = null
62
+ // 面板里最近一次获得焦点的元素:进探索会把焦点夺给画布,面板还开着时要送回来。
63
+ let panelFocusInside = null
64
+
65
+ async function ensureScene() {
66
+ if (scene || sceneFailed) return scene
67
+ try {
68
+ const module = await loadSceneModule()
69
+ scene = module.createBuildingScene($('viewport'), {
70
+ onWalkMode: (mode) => syncWalkUi(mode === 'walk'),
71
+ onWalkMove: showWalkLocation,
72
+ onWalkBlocked: showWalkBlocked,
73
+ onSignClick: openSignTarget,
74
+ })
75
+ window.addEventListener('pagehide', () => scene?.dispose(), { once: true })
76
+ return scene
77
+ } catch (error) {
78
+ sceneFailed = error
79
+ $('sceneFallback').hidden = false
80
+ $('sceneFallbackReason').textContent = error instanceof Error ? error.message : String(error)
81
+ $('walkToggle').disabled = true
82
+ $('walkToggle').title = '三维视图不可用,无法进入探索'
83
+ setStatus('三维视图不可用', 'warn')
84
+ return null
85
+ }
86
+ }
87
+
88
+ /** 探索界面的开关:按钮文案、方向按钮区、提示行与"回到总览"的显隐在一处同步。 */
89
+ function syncWalkUi(next) {
90
+ // 模式没变时也会被再叫一次(场景的 onWalkMode 回调与 onWalkToggle 各叫一次):
91
+ // 目录那一段只认真正的模式切换,否则第二次"进入"会把"是我们收起的"标记冲掉,
92
+ // 退出探索时就收不回作者原来的选择。
93
+ const wasWalking = walking
94
+ walking = next
95
+ $('walkToggle').textContent = next ? '返回总览' : '进入探索'
96
+ $('walkToggle').setAttribute('aria-pressed', String(next))
97
+ $('walkHud').hidden = !next
98
+ $('resetView').hidden = next
99
+ $('sceneHint').textContent = next ? '拖动环顾 · WASD/方向键或屏幕按钮移动 · Shift 加速 · 点门牌看资料' : '拖动环绕 · 滚轮缩放 · 点目录定位房间 · 点门牌看资料'
100
+ if (!next) $('walkLocation').textContent = ''
101
+ // 退出探索(含场景自己安全退出)时清掉"是我们暂停的"标记:机位已经回总览,
102
+ // 关掉资料面板时不该再去恢复一次输入。
103
+ if (!next) pausedWalk = false
104
+ // 进得去就把上一次"进不去"的说明收起来(它是给当前这个建筑版本的)。
105
+ if (next) $('walkNotice').hidden = true
106
+ // 探索是"人在房子里"的全屏状态:进去时把资料目录临时收起(别挡着视野),退出时把作者的
107
+ // 选择收回来;他自己在探索里开回来的就不再替他收(onDirectoryToggle 会清掉这个标记)。
108
+ if (next !== wasWalking) {
109
+ if (next) {
110
+ directoryAutoCollapsed = directoryOpen
111
+ if (directoryOpen) setDirectoryOpen(false, { persist: false })
112
+ } else if (directoryAutoCollapsed) {
113
+ directoryAutoCollapsed = false
114
+ setDirectoryOpen(true)
115
+ }
116
+ }
117
+ }
118
+
119
+ /** 进不了探索的两种原因与建议说法(场景给的 reason 只这两类,其它情况按"入口不可用"兜底)。 */
120
+ const WALK_BLOCKED_TEXT = {
121
+ 'entry-blocked': ['入口站不住(离墙太近或已被构件占住)', '在蓝图里把入口挪离墙、或把墙体调薄后再试'],
122
+ 'no-walkable-ground': ['当前建筑没有可行走的地面', ''],
123
+ }
124
+
125
+ /** 场景进不去探索(入口站不住或没有行走面):在视口里如实说明,并区分"没进去"与"已退出"。
126
+ * 单独用视口提示而不是状态行:换表现时状态行随后会被阶段/预览口径覆盖,这里不会被冲掉。 */
127
+ function showWalkBlocked({ reason, whileWalking } = {}) {
128
+ const [what, advice] = WALK_BLOCKED_TEXT[reason] || ['入口不可用', '']
129
+ const notice = $('walkNotice')
130
+ notice.textContent = `${what},${whileWalking ? '已退出探索' : '没有进入探索'}${advice ? `;${advice}` : ''}。`
131
+ notice.hidden = false
132
+ }
133
+
134
+ /** 探索中的所在位置(场景跨空间时报一次):楼层的名字来自当前蓝图。 */
135
+ function showWalkLocation(space) {
136
+ const floor = space ? currentModel?.floors.find((item) => item.id === space.floorId) : null
137
+ $('walkLocation').textContent = space ? `所在:${floor?.name || space.floorId} · ${space.name}` : '所在:楼梯'
138
+ }
139
+
140
+ // ── 资料目录:全窗口形态下浮在视口右侧,收起后建筑占满整页 ────────────────
141
+
142
+ /** 目录开关的存储键;读法与主题同口径:存储不可用时本页照常切换,只是记不住。 */
143
+ const DIRECTORY_KEY = 'archify-building-directory'
144
+
145
+ function storedDirectoryOpen() {
146
+ try { return localStorage.getItem(DIRECTORY_KEY) !== 'closed' } catch { return true }
147
+ }
148
+
149
+ let directoryOpen = storedDirectoryOpen()
150
+ // 进探索时替作者临时收起过:退出探索要把他的选择收回来(他自己中途开过就不再回滚)。
151
+ let directoryAutoCollapsed = false
152
+
153
+ /** 目录的展开/收起:抽屉位移、开关文案、提示与横幅的让位宽度都由这一个状态定
154
+ * (宽度在 CSS 里按 buildingStage 的 data-directory 算,这里不量像素)。
155
+ * persist=false 只用于"进探索时临时收起"——那是模式的临时动作,不该改作者存的偏好。 */
156
+ function setDirectoryOpen(open, { persist = true } = {}) {
157
+ directoryOpen = open
158
+ $('buildingStage').dataset.directory = open ? 'open' : 'closed'
159
+ const toggle = $('directoryToggle')
160
+ toggle.textContent = open ? '收起目录' : '资料目录'
161
+ toggle.setAttribute('aria-expanded', String(open))
162
+ toggle.title = open
163
+ ? '收起右侧资料目录,让建筑占满窗口'
164
+ : '展开右侧资料目录:项目信息、当前建造阶段与逐间资料入口'
165
+ if (!persist) return
166
+ try { localStorage.setItem(DIRECTORY_KEY, open ? 'open' : 'closed') } catch { /* 本页切换不依赖存储。 */ }
167
+ }
168
+
169
+ /** 点开关:作者手动动过之后,进探索时的临时收起不再回滚(他的选择优先)。 */
170
+ function onDirectoryToggle() {
171
+ directoryAutoCollapsed = false
172
+ setDirectoryOpen(!directoryOpen)
173
+ }
174
+
175
+ /** 抽屉与资料卡片都要给操作行让位:量一次操作行的实际高度报给 CSS(--toolbar-h)。
176
+ * 窄屏换行、放大字号都会让它变高,固定像素迟早会压住收起开关;量不到高度(没有布局的
177
+ * 环境)就不写,留着 CSS 的默认档。
178
+ * 同时量"完整外观预览"横幅的实际高度(--banner-h):窄屏它会折行,读资料时卡片要按它
179
+ * 留出底部空档,抽屉也要让开它,才不会被压住。 */
180
+ function syncLayoutMetrics() {
181
+ const stage = $('buildingStage')
182
+ const toolbar = document.querySelector('.view-toolbar')
183
+ const toolbarHeight = Math.ceil(toolbar?.getBoundingClientRect?.().height || 0)
184
+ if (toolbarHeight > 0) stage.style.setProperty('--toolbar-h', `${toolbarHeight}px`)
185
+ const banner = $('previewBanner')
186
+ const bannerHeight = banner.hidden ? 0 : Math.ceil(banner.getBoundingClientRect?.().height || 0)
187
+ stage.style.setProperty('--banner-h', `${bannerHeight}px`)
188
+ }
189
+
190
+ // ── 房内打开资料:介绍+资料清单+正文/流程图都在这块面板里 ─────────────
191
+
192
+ /** 资料条目的稳定身份(与绑定层同口径:文档=登记路径、流程图=图 id),只用于标记在读的那份。 */
193
+ const materialKey = (entry) => (entry.kind === 'document' ? `doc:${entry.path}` : `chart:${entry.chartId}`)
194
+
195
+ /** 读资料时暂停漫游输入:不退出探索(机位不回总览),只是不再走动。 */
196
+ function pauseWalkForReading() {
197
+ if (!walking || pausedWalk) return
198
+ pausedWalk = Boolean(scene?.pauseWalk?.())
199
+ }
200
+
201
+ /** 是否正在读资料(资料面板开着):阅读中不许走动,也不许换掉当前读的那份。 */
202
+ const isReading = () => !$('materialPanel').hidden
203
+
204
+ /** 阅读中不许走动:把正按着的屏幕方向按钮松开——人可能正按住走路时点开了资料,
205
+ * 面板外露出的按钮又不再接事件(CSS 收起),不主动松开就会一直走下去。 */
206
+ function releaseWalkKeys() {
207
+ for (const button of document.querySelectorAll('[data-walk-key][data-pressed]')) {
208
+ delete button.dataset.pressed
209
+ scene?.walkKey(button.dataset.walkKey, false)
210
+ }
211
+ }
212
+
213
+ /** 关掉面板把焦点还给打开它的那个按钮(目录这时已经回来了);原处不在了(换蓝图会把目录
214
+ * 整套重建)就退到目录容器上——别把焦点丢在没有落点的地方,也别塞给一个已经脱离文档的
215
+ * 按钮。开面板时焦点本来就不在页面里(body)的不算触发点,那就什么都不动。 */
216
+ function restorePanelFocus() {
217
+ const back = panelReturnFocus
218
+ panelReturnFocus = null
219
+ if (!back || back === document.body || typeof back.focus !== 'function') return
220
+ if (back.isConnected) back.focus()
221
+ else $('rooms').focus?.()
222
+ }
223
+
224
+ /** 焦点送回面板里(进探索会把焦点给画布:那是"进去就能敲 WASD"的既有行为,但面板还开着时
225
+ * 不该把人留在阅读之外)。回到面板里最近一次获得焦点的地方,没有就回到关闭按钮。 */
226
+ function focusInsidePanel() {
227
+ const target = panelFocusInside?.isConnected ? panelFocusInside : $('materialClose')
228
+ target.focus()
229
+ }
230
+
231
+ /** 关闭资料后只恢复自己暂停的那一次输入:位置与视角都由场景原样保留,人接着原地走。 */
232
+ function resumeWalkAfterReading() {
233
+ if (!pausedWalk) return
234
+ pausedWalk = false
235
+ scene?.resumeWalk?.()
236
+ }
237
+
238
+ /** 业务房 → 清单里的资料:门牌、目录卡与面板都取这一份,口径一致。 */
239
+ function roomBusiness(spaceId) {
240
+ const room = currentModel?.rooms.find((item) => item.id === spaceId)
241
+ if (!room) return null
242
+ const entries = (room.businessId && currentCatalog.get(room.businessId)?.entries) || []
243
+ return { room, entries }
244
+ }
245
+
246
+ function setPanelHead(kind, title) {
247
+ $('materialKind').textContent = kind
248
+ $('materialTitle').textContent = title
249
+ }
250
+
251
+ function setPanelIntro(text) {
252
+ const intro = $('materialIntro')
253
+ intro.textContent = text || ''
254
+ intro.hidden = !text
255
+ }
256
+
257
+ /** 资料清单:每份资料一个按钮,点了就在右边读(文档=正文,流程图=现有阅读页)。 */
258
+ function renderMaterialList(entries, emptyText) {
259
+ const list = $('materialList')
260
+ list.textContent = ''
261
+ list.hidden = entries.length === 0
262
+ $('materialListLabel').hidden = entries.length === 0
263
+ $('materialCount').textContent = entries.length ? `${entries.length} 份` : ''
264
+ const empty = $('materialEmpty')
265
+ empty.hidden = entries.length > 0
266
+ empty.textContent = entries.length ? '' : emptyText
267
+ for (const entry of entries) {
268
+ const item = el('li')
269
+ const button = el('button', 'material-item')
270
+ button.type = 'button'
271
+ button.dataset.kind = entry.kind
272
+ button.dataset.material = materialKey(entry)
273
+ button.textContent = `${entry.kind === 'document' ? '文档' : '流程图'} · ${entry.title}`
274
+ button.addEventListener('click', () => openMaterial(entry))
275
+ item.appendChild(button)
276
+ list.appendChild(item)
277
+ }
278
+ markActiveMaterial()
279
+ }
280
+
281
+ function markActiveMaterial() {
282
+ for (const button of document.querySelectorAll('#materialList .material-item')) {
283
+ if (panelMaterialKey && button.dataset.material === panelMaterialKey) button.setAttribute('aria-current', 'true')
284
+ else button.removeAttribute('aria-current')
285
+ }
286
+ }
287
+
288
+ /** 清空阅读区:停掉正在读的阅读页与在途文档请求(换资料、换目标、关面板都走这里)。 */
289
+ function clearReader() {
290
+ readSeq += 1
291
+ const body = $('materialReaderBody')
292
+ body.textContent = ''
293
+ body.hidden = true
294
+ const frame = $('materialReaderFrame')
295
+ if (frame.hasAttribute('src')) frame.setAttribute('src', 'about:blank')
296
+ frame.hidden = true
297
+ const status = $('materialReaderStatus')
298
+ status.hidden = true
299
+ status.textContent = ''
300
+ status.dataset.kind = ''
301
+ }
302
+
303
+ /** 阅读区的说明行:kind 为空=不是错误(正在读取用 info)。 */
304
+ function setReaderNote(kind, text) {
305
+ const status = $('materialReaderStatus')
306
+ status.hidden = !text
307
+ status.textContent = text || ''
308
+ status.dataset.kind = kind || ''
309
+ }
310
+
311
+ /** 回到"还没挑资料"的状态(换业务、换项目、关面板都走这里)。 */
312
+ function resetReaderHint(text) {
313
+ clearReader()
314
+ panelMaterialKey = ''
315
+ $('materialReaderTitle').hidden = true
316
+ const hint = $('materialReaderHint')
317
+ hint.textContent = text
318
+ hint.hidden = false
319
+ markActiveMaterial()
320
+ }
321
+
322
+ /** 打开一份资料:文档取正文在面板里显示;流程图把现有阅读页装进面板。 */
323
+ function openMaterial(entry) {
324
+ panelMaterialKey = materialKey(entry)
325
+ markActiveMaterial()
326
+ clearReader()
327
+ const title = $('materialReaderTitle')
328
+ title.textContent = `${entry.kind === 'document' ? '文档' : '流程图'} · ${entry.title}`
329
+ title.hidden = false
330
+ $('materialReaderHint').hidden = true
331
+ return entry.kind === 'workflow' ? openWorkflowReader(entry) : openDocumentReader(entry)
332
+ }
333
+
334
+ /** 接口失败时优先说后端给的中文原因(JSON 错误体的 error 字段),拿不到再按 HTTP 状态说。 */
335
+ async function failureReason(response) {
336
+ try {
337
+ const body = await response.json()
338
+ if (body && typeof body.error === 'string' && body.error) return body.error
339
+ } catch { /* 正文不是 JSON(认证层的纯文本错误页):按状态码说。 */ }
340
+ return `HTTP ${response.status}`
341
+ }
342
+
343
+ async function openDocumentReader(entry) {
344
+ const seq = readSeq
345
+ setReaderNote('info', '正在读取文档…')
346
+ try {
347
+ const response = await fetch(wsUrl(entry.href))
348
+ if (!response.ok) throw new Error(await failureReason(response))
349
+ const text = await response.text()
350
+ // 面板已经换了目标或又点了别的资料:晚到的正文不写进阅读区。
351
+ if (seq !== readSeq) return
352
+ setReaderNote('', '')
353
+ renderDocumentBody(text)
354
+ } catch (error) {
355
+ if (seq !== readSeq) return
356
+ setReaderNote('bad', `读不到这份资料:${error.message || String(error)}。这份资料来自业务清单登记,修好或重新登记后刷新本页即可。`)
357
+ }
358
+ }
359
+
360
+ /** 流程图用现有阅读页(iframe):入口就是绑定层装配好的那份,工作区参数由 wsUrl 补齐;
361
+ * 阅读页自己的交互(版本条、节点详情、保存版本)与在独立标签里打开时完全一样。
362
+ * 先自己取一次这个入口再交给 iframe——iframe 导航失败不触发 error 事件(浏览器不用它
363
+ * 报告这类失败,见 panel-link.js 的说明),只把 src 一设就会留下一片空白;
364
+ * 取不到(认证层拒绝、路由 500、图被删)在这里就如实报,取到了则由阅读页按自己那套说法
365
+ * 显示数据问题(图读不开、编号冲突)。 */
366
+ async function openWorkflowReader(entry) {
367
+ const seq = readSeq
368
+ const url = wsUrl(entry.href)
369
+ setReaderNote('info', '正在打开阅读页…')
370
+ let response
371
+ try {
372
+ response = await fetch(url)
373
+ } catch (error) {
374
+ if (seq !== readSeq) return
375
+ return setReaderNote('bad', `读不到这份流程图:阅读页没有打开(${error.message || String(error)})。请检查该图是否还在工作区里,或刷新本页重试。`)
376
+ }
377
+ if (seq !== readSeq) return
378
+ if (!response.ok) {
379
+ const reason = await failureReason(response)
380
+ // 读错误正文也是等待:这期间用户可能又点了别的资料或关掉了面板,
381
+ // 晚到的原因不许写进已经换过的阅读区。
382
+ if (seq !== readSeq) return
383
+ return setReaderNote('bad', `读不到这份流程图:阅读页打不开(${reason})。请检查该图是否还在工作区里,或刷新本页重试。`)
384
+ }
385
+ if (seq !== readSeq) return
386
+ // 说明留到阅读页 load 再收起:那之前它是"正在打开",比一片空白诚实。
387
+ const frame = $('materialReaderFrame')
388
+ frame.hidden = false
389
+ frame.setAttribute('src', url)
390
+ }
391
+
392
+ /** 文档正文按块呈现:标题成标题、列表成列表,行内 **加粗**/`代码` 复用 details.js 的口径。
393
+ * 只认最常见的写法(见 parseDocument),不假装是完整 Markdown 渲染。 */
394
+ function renderDocumentBody(text) {
395
+ const host = $('materialReaderBody')
396
+ host.textContent = ''
397
+ const blocks = parseDocument(text)
398
+ if (!blocks.length) {
399
+ const empty = el('p', 'material-doc-empty')
400
+ empty.textContent = '这份文档没有正文(文件是空的)。'
401
+ host.appendChild(empty)
402
+ }
403
+ let list = null
404
+ for (const block of blocks) {
405
+ if (block.kind === 'bullet') {
406
+ if (!list) {
407
+ list = el('ul', 'material-doc-list')
408
+ host.appendChild(list)
409
+ }
410
+ const item = el('li')
411
+ appendInline(item, block.text)
412
+ list.appendChild(item)
413
+ continue
414
+ }
415
+ list = null
416
+ if (block.kind === 'heading') {
417
+ const heading = el('h3', 'material-doc-heading')
418
+ heading.dataset.level = String(block.level)
419
+ appendInline(heading, block.text)
420
+ host.appendChild(heading)
421
+ continue
422
+ }
423
+ const paragraph = el('p', 'material-doc-paragraph')
424
+ appendInline(paragraph, block.text)
425
+ host.appendChild(paragraph)
426
+ }
427
+ host.hidden = false
428
+ }
429
+
430
+ /** 行内标记拼成 DOM,不经 innerHTML。 */
431
+ function appendInline(host, text) {
432
+ for (const part of splitInline(text)) {
433
+ if (part.strong) {
434
+ const node = el('strong')
435
+ node.textContent = part.text
436
+ host.appendChild(node)
437
+ } else if (part.code) {
438
+ const node = el('code')
439
+ node.textContent = part.text
440
+ host.appendChild(node)
441
+ } else {
442
+ host.appendChild(document.createTextNode(part.text))
443
+ }
444
+ }
445
+ }
446
+
447
+ function showMaterialPanel() {
448
+ const panel = $('materialPanel')
449
+ // 打开面板时焦点在哪儿:多半是目录里的那枚资料按钮,而读资料时目录会被隐藏——
450
+ // 关掉面板要把焦点还回去(键盘用户不该停在一个看不见的按钮上)。
451
+ if (panel.hidden) panelReturnFocus = document.activeElement
452
+ panelFocusInside = null // 上一轮读的那份可能已经不在了:这一轮从关闭按钮重新记
453
+ panel.hidden = false
454
+ // 读资料时目录连开关一起让位:面板是浮在视口上的卡片,目录留在后面只露半截。
455
+ // 这是显示规则(CSS 认 data-reading),不动作者存的展开/收起选择,关掉面板就回来。
456
+ $('buildingStage').dataset.reading = 'true'
457
+ // 阅读中不许走动:正按着的屏幕方向按钮先松开,再停掉漫游输入(暂停只挡键盘与拖动)。
458
+ releaseWalkKeys()
459
+ pauseWalkForReading()
460
+ // 焦点交接:面板里的关闭按钮(面板不是模态,焦点不被困住)。
461
+ $('materialClose').focus()
462
+ }
463
+
464
+ /** 打开业务面板:该业务的介绍与资料清单(业务门牌与房间目录都走这里)。
465
+ * material 非空时直接读那一份——目录卡里的资料入口点进来就是这种情形。 */
466
+ function openBusinessPanel(spaceId, { material = null } = {}) {
467
+ const found = roomBusiness(spaceId)
468
+ if (!found) return
469
+ const floor = currentModel?.floors.find((item) => item.id === found.room.floorId)
470
+ setPanelHead(`业务资料${floor ? ` · ${floor.name}` : ''}`, found.room.name)
471
+ setPanelIntro(found.room.intro || '')
472
+ renderMaterialList(
473
+ found.entries,
474
+ found.room.directory === false ? '资料目录已按蓝图关闭(directory: false)。' : '本业务暂无登记的文档或流程图。',
475
+ )
476
+ resetReaderHint(found.entries.length ? '在左边挑一份资料,正文与流程图都在这里读。' : '这里还没有可读的资料。')
477
+ showMaterialPanel()
478
+ if (material) openMaterial(material)
479
+ }
480
+
481
+ /** 打开项目介绍(项目牌):项目资料按业务登记,这里如实说去哪儿读。 */
482
+ function openProjectPanel() {
483
+ setPanelHead('项目介绍', currentModel?.project?.name || '当前项目')
484
+ setPanelIntro(currentModel?.project?.description || '项目尚未填写介绍。')
485
+ renderMaterialList([], '项目资料按业务登记:走进房间、点业务门牌,就能读该业务的介绍与资料。')
486
+ resetReaderHint('这里只有项目介绍;资料在各业务房间里。')
487
+ showMaterialPanel()
488
+ }
489
+
490
+ /** 点三维里的牌子:业务门牌打开该业务的介绍与资料清单,项目牌打开项目介绍。
491
+ * 探索中点门牌不退出探索——只是暂停输入,关掉面板原地接着走。 */
492
+ function openSignTarget({ spaceId } = {}) {
493
+ if (spaceId) openBusinessPanel(spaceId)
494
+ else openProjectPanel()
495
+ }
496
+
497
+ /** 关掉资料面板:停掉在途请求与阅读页,恢复漫游输入——位置与视角都不动,人接着原地走。 */
498
+ function closeMaterialPanel() {
499
+ const panel = $('materialPanel')
500
+ if (panel.hidden) return
501
+ resetReaderHint('在左边挑一份资料,正文与流程图都在这里读。')
502
+ panel.hidden = true
503
+ delete $('buildingStage').dataset.reading
504
+ resumeWalkAfterReading()
505
+ // 恢复输入会把焦点给画布;键盘用户是从哪枚按钮进来的就还回哪儿(目录这时已经回来了)。
506
+ restorePanelFocus()
507
+ }
508
+
509
+ /** 进入/退出探索。进入前恢复全部楼层:剖看藏住构件、碰撞数据却仍是整套,两者必须一致。
510
+ * 界面以场景的实际模式为准(场景自己也会在模式变化时通知一次),这里不写死方向;
511
+ * 进不去(入口站不住/没有行走面)由场景的 onWalkBlocked 说明,页面不再另报。 */
512
+ function onWalkToggle() {
513
+ if (!scene) return
514
+ if (walking) scene.exitWalk()
515
+ else if (!scene.enterWalk()) return
516
+ syncWalkUi(scene.getMode() === 'walk')
517
+ if (walking) $('floor').value = 'all'
518
+ // 读资料时点「进入探索」:模式可以切(进去看看房子也行),但人不许走——进探索会把输入
519
+ // 打开,这里立刻按阅读状态收回来,关掉面板再恢复;进探索还会把焦点给画布,面板仍开着时
520
+ // 要把焦点送回面板里。
521
+ if (walking && isReading()) {
522
+ pauseWalkForReading()
523
+ focusInsidePanel()
524
+ }
525
+ }
526
+
527
+ async function renderScene(present) {
528
+ const handle = await ensureScene()
529
+ if (handle) handle.show({ model: currentModel, present })
530
+ // show 内部机位与楼层会复位,这里把用户当前选择的楼层重新应用上。
531
+ handle?.setFloor($('floor').value || 'all')
532
+ }
533
+
534
+ /** 尚未有蓝图:如实给空状态(放哪里、是什么、管理页不会自动创建)。 */
535
+ function renderNoBlueprint(error) {
536
+ setStatus('还没有建筑蓝图', 'warn')
537
+ const box = $('guideBox')
538
+ box.hidden = false
539
+ box.textContent = ''
540
+ const h = el('h2')
541
+ h.textContent = '这个项目还没有建筑蓝图'
542
+ box.appendChild(h)
543
+ if (error.repo) {
544
+ document.title = pageTitle('还没有建筑蓝图', error.repo)
545
+ const w = el('p')
546
+ w.textContent = repoLineText(error.repo)
547
+ box.appendChild(w)
548
+ }
549
+ const p = el('p')
550
+ p.textContent = error.message
551
+ box.appendChild(p)
552
+ const what = el('p')
553
+ what.textContent = '蓝图是一份 JSON,描述楼层、房间、门窗与屋顶的空间安排;业务身份、介绍与资料仍以项目清单为准。'
554
+ box.appendChild(what)
555
+ const note = el('p')
556
+ note.textContent = '管理页只读不改,不会自动创建这份文件。'
557
+ box.appendChild(note)
558
+ }
559
+
560
+ /** 核对或几何有问题:列出全部诊断(error 在前),不建三维场景。 */
561
+ function renderDiagnostics(data) {
562
+ setStatus('不能生成建筑', 'bad')
563
+ const box = $('diagnosticsPanel')
564
+ box.hidden = false
565
+ const list = $('problemList')
566
+ list.textContent = ''
567
+ const ordered = [...data.problems].sort(
568
+ (a, b) => (a.severity === 'error' ? 0 : 1) - (b.severity === 'error' ? 0 : 1),
569
+ )
570
+ for (const problem of ordered) {
571
+ const item = el('li', 'problem-item')
572
+ item.dataset.severity = problem.severity
573
+ const code = el('span', 'problem-code')
574
+ code.textContent = `[${problem.code}]`
575
+ const message = el('span', 'problem-message')
576
+ message.textContent = problem.message
577
+ item.append(code, message)
578
+ if (problem.subject && problem.subject.path) {
579
+ const path = el('code', 'problem-path')
580
+ path.textContent = problem.subject.path
581
+ item.appendChild(path)
582
+ }
583
+ list.appendChild(item)
584
+ }
585
+ }
586
+
587
+ /** 生成成功但带 warning(楼梯舒适度、无屋顶建议等):目录面板里的可折叠提示。
588
+ * 数据整套换版时这里会重跑:新数据没有 warning 要把旧提示收起来,不能沿用上一版的。 */
589
+ function renderNotices(data) {
590
+ const warnings = data.problems.filter((problem) => problem.severity === 'warning')
591
+ $('notices').hidden = !warnings.length
592
+ if (!warnings.length) return
593
+ $('notices').hidden = false
594
+ $('noticeCount').textContent = `${warnings.length} 条`
595
+ const list = $('noticeList')
596
+ list.textContent = ''
597
+ for (const warning of warnings) {
598
+ const item = el('li')
599
+ item.textContent = warning.message
600
+ list.appendChild(item)
601
+ }
602
+ }
603
+
604
+ /** 房间目录:业务房按楼层自下而上排列;每间房的资料在房内面板里读(保留 workspace)。 */
605
+ function renderDirectory(data) {
606
+ const model = data.model
607
+ const floorOrder = new Map(model.floors.map((floor, index) => [floor.id, index]))
608
+ const floorName = new Map(model.floors.map((floor) => [floor.id, floor.name]))
609
+ // 楼层高低按实际标高判断:生成器输出的 floors 按 ID 字典序排列,数组下标不代表高度
610
+ // (底层叫 z-ground、二层叫 a-upper 时二层排在前面);场景过滤也按 y/elevation 比较。
611
+ const floorLevel = new Map(model.floors.map((floor) => [floor.id, floor.y ?? floor.elevation ?? 0]))
612
+ const catalogByBusiness = new Map(data.catalog.businesses.map((business) => [business.businessId, business]))
613
+ // 面板读的资料与目录卡同源:换版时这份清单整套换掉,旧资料在重建目录之后关掉
614
+ // (那时触发它的那枚旧按钮已经脱离文档,焦点交接会退到目录容器上,见 restorePanelFocus)。
615
+ currentCatalog = catalogByBusiness
616
+ const rooms = [...model.rooms].sort(
617
+ (a, b) => (floorOrder.get(a.floorId) ?? 0) - (floorOrder.get(b.floorId) ?? 0) || (a.id < b.id ? -1 : 1),
618
+ )
619
+ document.title = pageTitle(`${model.project.name} · 建筑总览`, data.repo)
620
+ $('projectName').textContent = model.project.name
621
+ $('projectDesc').textContent = model.project.description || '项目尚未填写介绍。'
622
+ const entryCount = data.catalog.businesses.reduce((sum, business) => sum + business.entries.length, 0)
623
+ $('metrics').textContent = `${rooms.length} 个业务房间 · ${model.floors.length} 层 · ${entryCount} 个资料入口`
624
+ const [phaseLabel, phaseNote] = PHASE_LABELS[model.construction.phase] || [model.construction.phase, '']
625
+ $('phaseLabel').textContent = `当前建造阶段:${phaseLabel}`
626
+ $('phaseNote').textContent = phaseNote
627
+ const list = $('rooms')
628
+ list.textContent = ''
629
+ for (const room of rooms) {
630
+ const card = el('div', 'room-card')
631
+ card.dataset.space = room.id
632
+ card.dataset.business = room.businessId ?? ''
633
+ const head = el('div', 'room-head')
634
+ const title = el('span', 'room-title')
635
+ title.textContent = room.name
636
+ const meta = el('span', 'room-meta')
637
+ const width = room.bounds.x1 - room.bounds.x0
638
+ const depth = room.bounds.z1 - room.bounds.z0
639
+ meta.textContent = `${floorName.get(room.floorId) || room.floorId} · ${width.toFixed(1)} × ${depth.toFixed(1)} m`
640
+ head.append(title, meta)
641
+ card.appendChild(head)
642
+ if (room.intro) {
643
+ const intro = el('p', 'room-intro')
644
+ intro.textContent = room.intro
645
+ card.appendChild(intro)
646
+ }
647
+ const catalog = catalogByBusiness.get(room.businessId ?? '')
648
+ const entries = catalog?.entries ?? []
649
+ if (entries.length) {
650
+ const box = el('ul', 'room-entries')
651
+ for (const entry of entries) {
652
+ const item = el('li')
653
+ // 清单里的每份资料都在房内面板里读(文档=正文,流程图=现有阅读页);
654
+ // 按钮而不是外链:资料不再甩到新标签,读的地方就是人正站着的地方。
655
+ const button = el('button', 'room-entry')
656
+ button.type = 'button'
657
+ button.dataset.kind = entry.kind
658
+ button.textContent = `${entry.kind === 'document' ? '文档' : '流程图'} · ${entry.title}`
659
+ button.addEventListener('click', (event) => {
660
+ event.stopPropagation()
661
+ openBusinessPanel(room.id, { material: entry })
662
+ })
663
+ item.appendChild(button)
664
+ box.appendChild(item)
665
+ }
666
+ card.appendChild(box)
667
+ } else {
668
+ const empty = el('p', 'room-empty')
669
+ empty.textContent = room.directory === false ? '资料目录已按蓝图关闭(directory: false)' : '本业务暂无登记的文档或流程图'
670
+ card.appendChild(empty)
671
+ }
672
+ card.addEventListener('click', (event) => {
673
+ if (event.target.closest('a, button')) return
674
+ // 探索中点目录卡:只把这间房的资料原地打开——不退出探索、不挪机位,关掉面板人还在
675
+ // 原地接着走。"定位房间"(机位挪到房间斜上方)是总览模式的单独操作:走动着点一下
676
+ // 卡片就被送回总览、还被迫重新进入探索,那不是"逛到哪里读到哪里"。
677
+ if (!walking) {
678
+ // 定位的房间必须看得见:当前楼层剖看把目标层藏住时("X层及以下"只显示到所选标高),
679
+ // 先把剖看切回全部楼层(下拉框与场景同步),再移机位——否则只有镜头动了、房间仍然隐藏。
680
+ // 高低用标高比较,与场景过滤同口径。
681
+ const selected = $('floor').value || 'all'
682
+ if (selected !== 'all' && (floorLevel.get(room.floorId) ?? 0) > (floorLevel.get(selected) ?? 0)) {
683
+ $('floor').value = 'all'
684
+ scene?.setFloor('all')
685
+ }
686
+ scene?.focusSpace(room.id)
687
+ }
688
+ openBusinessPanel(room.id)
689
+ })
690
+ list.appendChild(card)
691
+ }
692
+ const select = $('floor')
693
+ select.textContent = ''
694
+ const all = el('option')
695
+ all.value = 'all'
696
+ all.textContent = '全部楼层'
697
+ select.appendChild(all)
698
+ for (const floor of model.floors) {
699
+ const option = el('option')
700
+ option.value = floor.id
701
+ option.textContent = `${floor.name}及以下`
702
+ select.appendChild(option)
703
+ }
704
+ // 目录重建完再关旧资料:此时的触发按钮已不在文档里,焦点交接走"退到目录容器"那一路。
705
+ closeMaterialPanel()
706
+ }
707
+
708
+ let currentModel = null
709
+ let framePresent = null
710
+ let previewPresent = null
711
+ // 预览开关代次:等待接口返回期间开关又动过(代次对不上)的响应一律丢弃——
712
+ // 晚到的旧响应不许改写现状(勾选框已翻回去、横幅已隐藏时场景不能被覆盖成另一套表现)。
713
+ let previewToggleSeq = 0
714
+ // 当前页面所依据的数据指纹(model+catalog):预览接口每次都重新读蓝图与清单,
715
+ // 指纹对不上说明蓝图或清单已被修改,页面要整套换到新数据——不能把新表现拼在旧目录上。
716
+ let dataVersion = ''
717
+
718
+ /** 目录与三维共用一份数据:指纹取 model+catalog(楼层、门牌、目录、资料入口都出自它们)。 */
719
+ function dataFingerprint(payload) {
720
+ return JSON.stringify({ model: payload.model, catalog: payload.catalog })
721
+ }
722
+
723
+ async function main() {
724
+ if (workspaceParamEmpty) return renderEmptyWorkspaceParam()
725
+ let data
726
+ try {
727
+ data = await fetchJson(wsUrl('/specdev-workbench/api/building?view=frame'))
728
+ } catch (error) {
729
+ if (renderRepoState(error)) {
730
+ if (error.repo) renderRepoLine(error.repo)
731
+ return
732
+ }
733
+ if (error.code === 'no-building-blueprint') return renderNoBlueprint(error)
734
+ return showError(error.message || String(error))
735
+ }
736
+ if (data && data.code === 'repo-not-configured') return renderGuide(data)
737
+ if (!data.ok || !data.model) return renderDiagnostics(data)
738
+ if (data.repo) renderRepoLine(data.repo)
739
+
740
+ currentModel = data.model
741
+ framePresent = data.present
742
+ dataVersion = dataFingerprint(data)
743
+ renderDirectory(data)
744
+ renderNotices(data)
745
+ setStatus(`已生成 · ${modelPhaseLabel(data.model)}`, 'ok')
746
+ $('buildingStage').hidden = false
747
+ // 舞台现身=整页切到全窗口形态(CSS 认这个类),目录回到作者上次的选择。
748
+ document.body.classList.add('building-live')
749
+ setDirectoryOpen(directoryOpen, { persist: false })
750
+ syncLayoutMetrics()
751
+ // 操作行会随窗口宽度换行、随字号变高,横幅会随文字折行:都盯着,抽屉与卡片才不会压住它们。
752
+ if (typeof ResizeObserver === 'function') {
753
+ const observer = new ResizeObserver(syncLayoutMetrics)
754
+ observer.observe(document.querySelector('.view-toolbar'))
755
+ observer.observe($('previewBanner'))
756
+ }
757
+ await renderScene(data.present)
758
+
759
+ $('floor').addEventListener('change', (event) => {
760
+ // 剖看与漫游对不上(构件被藏住、碰撞仍是整套):先退出探索再剖看;
761
+ // 场景内部对非"全部楼层"也有同样的兜底,两层都留着,避免漏改一处就出现看得见走不过去。
762
+ if (walking) scene?.exitWalk()
763
+ scene?.setFloor(event.target.value || 'all')
764
+ })
765
+ $('resetView').addEventListener('click', () => scene?.resetView())
766
+ $('preview').addEventListener('change', onPreviewChange)
767
+ $('walkToggle').addEventListener('click', onWalkToggle)
768
+ $('directoryToggle').addEventListener('click', onDirectoryToggle)
769
+ $('materialClose').addEventListener('click', closeMaterialPanel)
770
+ // 面板里的焦点走到哪儿记一笔:进探索夺走焦点后要送回来(见 focusInsidePanel)。
771
+ $('materialPanel').addEventListener('focusin', (event) => { panelFocusInside = event.target })
772
+ // 焦点在面板里的阅读页时 ESC 到不了本页:由阅读页把"关面板"转过来(panel-link.js)。
773
+ listenMaterialClose(window, $('materialReaderFrame'), closeMaterialPanel)
774
+ // 本页自己这一层的 ESC 关面板(与节点详情弹层同一种收尾方式);面板没开时不抢 ESC。
775
+ document.addEventListener('keydown', (event) => {
776
+ if (event.key !== 'Escape' || $('materialPanel').hidden) return
777
+ event.preventDefault()
778
+ closeMaterialPanel()
779
+ })
780
+ // 阅读页导航成功会触发 load:收起"正在打开阅读页…"这条进行中的说明。
781
+ // 只收进行中的说明,不动读文档失败那类报错。
782
+ $('materialReaderFrame').addEventListener('load', () => {
783
+ if ($('materialReaderFrame').hidden) return
784
+ if ($('materialReaderStatus').dataset.kind === 'info') setReaderNote('', '')
785
+ })
786
+ // 屏幕方向按钮:按住即走;松开、取消或被系统收走指针都当松手。读资料时一律不生效
787
+ // (面板外的按钮还看得见,但不许让人物动起来;停用期间场景那一层也不收虚拟按键)。
788
+ for (const button of document.querySelectorAll('[data-walk-key]')) {
789
+ button.addEventListener('pointerdown', (event) => {
790
+ event.preventDefault()
791
+ if (isReading()) return
792
+ button.setPointerCapture?.(event.pointerId)
793
+ button.dataset.pressed = 'true'
794
+ scene?.walkKey(button.dataset.walkKey, true)
795
+ })
796
+ for (const name of ['pointerup', 'pointercancel', 'lostpointercapture']) {
797
+ button.addEventListener(name, () => {
798
+ delete button.dataset.pressed
799
+ scene?.walkKey(button.dataset.walkKey, false)
800
+ })
801
+ }
802
+ }
803
+ }
804
+
805
+ function modelPhaseLabel(model) {
806
+ return (PHASE_LABELS[model.construction.phase] || [model.construction.phase])[0]
807
+ }
808
+
809
+ /** 完整外观预览:明确标注、独立于建造状态;表现由服务端按 view 算好,页面只切换。
810
+ * 目标视图没有缓存时取接口;返回的数据与页面当前不是同一版(蓝图被改过)就整套换版;
811
+ * 取用失败把开关翻回实际显示着的那套视图。 */
812
+ async function onPreviewChange() {
813
+ const seq = ++previewToggleSeq
814
+ const checked = $('preview').checked
815
+ $('previewBanner').hidden = !checked
816
+ const rollback = () => {
817
+ $('preview').checked = !checked
818
+ $('previewBanner').hidden = checked
819
+ setStatus(checked ? '预览取用失败' : '当前阶段视图取用失败', 'bad')
820
+ }
821
+ if ((checked && !previewPresent) || (!checked && !framePresent)) {
822
+ let payload
823
+ try {
824
+ setStatus(checked ? '正在取完整外观…' : '正在重取当前阶段…')
825
+ payload = await fetchJson(wsUrl(checked ? '/specdev-workbench/api/building?view=preview' : '/specdev-workbench/api/building?view=frame'))
826
+ } catch (error) {
827
+ if (seq !== previewToggleSeq) return
828
+ rollback()
829
+ showError(error.message || String(error))
830
+ return
831
+ }
832
+ // 晚到的成功响应在动任何状态之前核对代次:换版会改写目录与两份缓存,事后退出撤不回
833
+ // 这些修改(等待预览→取消→响应带着新蓝图回来,就会目录已换版、场景停在旧版)。
834
+ if (seq !== previewToggleSeq) return
835
+ if (!payload || payload.code === 'repo-not-configured' || !payload.ok || !payload.model || !payload.present) {
836
+ if (seq !== previewToggleSeq) return
837
+ rollback()
838
+ showError(payload?.error || payload?.problems?.[0]?.message || '视图取用失败')
839
+ return
840
+ }
841
+ if (dataFingerprint(payload) !== dataVersion) {
842
+ // 蓝图或清单已更新:目录、楼层与两份表现缓存整套换到这份新数据,旧缓存一并作废。
843
+ currentModel = payload.model
844
+ dataVersion = dataFingerprint(payload)
845
+ renderDirectory(payload)
846
+ renderNotices(payload)
847
+ previewPresent = null
848
+ framePresent = null
849
+ }
850
+ if (checked) previewPresent = payload.present
851
+ else framePresent = payload.present
852
+ }
853
+ if (seq !== previewToggleSeq) return
854
+ await renderScene(checked ? previewPresent : framePresent)
855
+ if (seq !== previewToggleSeq) return
856
+ setStatus(checked ? `完整外观预览 · 不代表当前阶段(${modelPhaseLabel(currentModel)})` : `已生成 · ${modelPhaseLabel(currentModel)}`, checked ? 'warn' : 'ok')
857
+ }
858
+
859
+ main()