@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,386 @@
1
+ // 独立业务房间的三维场景(P1b-2 建骨架,P1c-2c-2 接行走):底座、灯光、镜头与
2
+ // 渲染参数自 E2b 实验(2026-09-27-桌面承载与物品跟随)提取为产品模块,原实验不改。
3
+ // 本模块不重复摆放校验——只消费页面交来的 template 与 validatePlacement 的 assembly:
4
+ // · 按 template.styleId 精确分派两套底座(ceramic/fairy),未知风格明确报错;
5
+ // · 底座显式传 doorOpen:false(关门开局);家具由页面先经 prepareRoomFurniture 异步
6
+ // 备好成批次(E4b-2),本模块同步整组挂入,调用瞬间接管批次的释放所有权;
7
+ // · 家具平铺到场景根组:装配结果已含世界变换(含承载面高度 Y),不再叠加父桌变换。
8
+ // 行走(P1c-2c-2):人物站位与门开合的唯一来源是 rooms/walk-session.js 的会话,
9
+ // 帧推进由 rooms/walk-frame.js 承担,输入复用原版 building-walk.js 的 createWalkInput。
10
+ // 两台相机分开——总览机位(OrbitControls)与行走视线互不覆盖;渲染、拾取、底座
11
+ // update(含反射)都用当前活动相机。白天为默认;昼夜经 setPeriod 接底座与灯光。
12
+ // 阅读暂停(E3b-1):rooms/reading-gate.js 统一闸住输入、帧推进与总览控制器,
13
+ // setReading(true) 期间切模式/开门为无操作,渲染循环只经 gate.advance 推进相机。
14
+ import * as THREE from 'three'
15
+ import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
16
+ import { createWalkInput } from './building-walk.js'
17
+ import { createCeramicRoom } from './rooms/ceramic-room.js'
18
+ import { createFairyRoom } from './rooms/fairy-room.js'
19
+ import { createRoomLighting } from './rooms/room-lighting.js'
20
+ import { createRoomWalkSession } from './rooms/walk-session.js'
21
+ import { createWalkFrame, lookYawToward, WALK_FOV } from './rooms/walk-frame.js'
22
+ import { createRoomReadingGate } from './rooms/reading-gate.js'
23
+ import { prepareRoomFurniture, findFurnitureInstance } from './rooms/furniture-assembly.js'
24
+
25
+ // 页面经本模块动态入口进入 Three.js(E4b-2):家具批次的准备与向上查找一并无须单独加载。
26
+ export { prepareRoomFurniture, findFurnitureInstance }
27
+
28
+ /** styleId → 底座工厂:两套已验收底座,其余风格不存在。 */
29
+ const ROOM_FACTORIES = { ceramic: createCeramicRoom, fairy: createFairyRoom }
30
+
31
+ /** 室外可见地面:低反光、顶面 Y=0、四块水平面拼出带室内空洞的连续外地面
32
+ * (北/南条贯穿外宽,东/西条填中间两侧,不重叠、不盖室内地面与门槛)。
33
+ * 只是视觉延伸——可走范围始终由会话里的隐形边界限制,这里不画任何边界线。 */
34
+ function buildOutdoorGround(styleId, template) {
35
+ const outer = 60
36
+ const hole = template.interiorBounds
37
+ const material = new THREE.MeshStandardMaterial({
38
+ name: styleId === 'fairy' ? '室外苔地' : '室外暖灰石地',
39
+ color: styleId === 'fairy' ? '#8a9b74' : '#b7b1a3',
40
+ roughness: 0.96,
41
+ metalness: 0,
42
+ })
43
+ const group = new THREE.Group()
44
+ group.name = 'room-outdoor-ground'
45
+ const strip = (name, minX, maxX, minZ, maxZ) => {
46
+ const mesh = new THREE.Mesh(new THREE.PlaneGeometry(maxX - minX, maxZ - minZ), material)
47
+ mesh.name = name
48
+ mesh.rotation.x = -Math.PI / 2
49
+ mesh.position.set((minX + maxX) / 2, 0, (minZ + maxZ) / 2)
50
+ mesh.receiveShadow = true
51
+ group.add(mesh)
52
+ }
53
+ strip('室外地面·北', -outer, outer, -outer, hole.minZ)
54
+ strip('室外地面·南', -outer, outer, hole.maxZ, outer)
55
+ strip('室外地面·西', -outer, hole.minX, hole.minZ, hole.maxZ)
56
+ strip('室外地面·东', hole.maxX, outer, hole.minZ, hole.maxZ)
57
+ return group
58
+ }
59
+
60
+ /**
61
+ * 拾取遮挡判定(纯函数,供测试直呼):hits 为按距离排序的命中描述——
62
+ * { kind:'furniture', visible, opacity, pick:{instanceId,assetId,name} } 或
63
+ * { kind:'occluder', visible, opacity }。口径沿用旧建筑页 pickSign:
64
+ * 隐藏祖先(含总览裁掉的墙)不参与;挡在前面的可见实体(不透明度 ≥0.6,
65
+ * 磨砂窗玻璃 0.19/童话圆窗 0.46 都不算)截停,隔墙点不到家具。
66
+ */
67
+ export function judgeRoomPick(hits) {
68
+ for (const hit of Array.isArray(hits) ? hits : []) {
69
+ if (!hit || hit.visible === false) continue
70
+ if (hit.kind === 'furniture') return hit.pick ?? null
71
+ if ((hit.opacity ?? 1) >= 0.6) return null
72
+ }
73
+ return null
74
+ }
75
+
76
+ /** 释放一组 Three.js 对象的几何与材质(含贴图);中途失败与正常销毁共用。 */
77
+ function disposeGroup(group) {
78
+ for (const child of [...group.children]) {
79
+ child.traverse((node) => {
80
+ node.geometry?.dispose?.()
81
+ const material = node.material
82
+ if (material) (Array.isArray(material) ? material : [material]).forEach((m) => { m.map?.dispose?.(); m.dispose?.() })
83
+ })
84
+ group.remove(child)
85
+ }
86
+ }
87
+
88
+ /**
89
+ * 建一个业务房间场景挂进 container。句柄:
90
+ * resetView() 切 overview 并恢复原总览机位;不改行走站位与视线;阅读期间无操作
91
+ * enterWalk() 切 walk,保留行走站位与视线;初次由场景初始化进入;阅读期间无操作
92
+ * getWalkState() 会话的新状态快照,页面用它同步按钮与提示
93
+ * setDoorOpen(open) 只转交会话事务并返回其结果,不改真实门;阅读期间返回
94
+ * { ok:false, code:'reading-active' },不触碰门事务
95
+ * setReading(bool) 阅读暂停闸门(E3b-1):true 停住全部场景操作(键盘、拖动、
96
+ * 总览控制器、拾取),false 后仍在原模式、原位置、原视线
97
+ * isReading() 闸门当前状态(销毁后恒 false)
98
+ * setPeriod('day'|'night')/dispose()
99
+ * 场景/会话初始化失败沿用"清理后抛错"路径(页面展示失败原因),不退回无碰撞行走。
100
+ */
101
+ export function createRoomScene({ container, template, assembly, furniture, onPick = () => {} } = {}) {
102
+ // styleId 取值必须在函数顶层:setPeriod 闭包引用它——不能随风格校验挪进 try 的块作用域
103
+ const styleId = template?.styleId
104
+ const reducedMotion = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches
105
+
106
+ // 已创建的资源引用:releaseAll 对每一项判空,中途失败与正常销毁共用同一份清理。
107
+ let renderer = null
108
+ let scene = null
109
+ let overviewCamera = null
110
+ let walkCamera = null
111
+ let controls = null
112
+ let lighting = null
113
+ let room = null
114
+ let furnitureBatch = null
115
+ let furnitureGroup = null
116
+ let outdoorGround = null
117
+ let session = null
118
+ let input = null
119
+ let walkFrame = null
120
+ let gate = null
121
+ let observer = null
122
+ let disposed = false
123
+ let mode = 'walk' // 初始即门外行走;切模式只经 enterWalk/resetView
124
+ const canvasListeners = []
125
+ const pageListeners = []
126
+
127
+ function releaseAll() {
128
+ if (disposed && !renderer) return
129
+ disposed = true
130
+ if (renderer) renderer.setAnimationLoop(null)
131
+ observer?.disconnect()
132
+ for (const { target, type, listener } of canvasListeners) target.removeEventListener(type, listener)
133
+ canvasListeners.length = 0
134
+ for (const { target, type, listener } of pageListeners) target.removeEventListener(type, listener)
135
+ pageListeners.length = 0
136
+ gate?.dispose() // 先让阅读闸门失效,再走既有释放路径(不会重新启用输入或连接监听)
137
+ input?.dispose()
138
+ // 家具批次所有权在调用 createRoomScene 时已整体转交本场景:任何失败路径都由这里释放
139
+ furnitureBatch?.dispose()
140
+ if (outdoorGround) disposeGroup(outdoorGround)
141
+ room?.dispose()
142
+ lighting?.dispose()
143
+ controls?.dispose()
144
+ renderer?.dispose()
145
+ if (renderer) renderer.domElement.remove()
146
+ renderer = null
147
+ scene = null
148
+ overviewCamera = null
149
+ walkCamera = null
150
+ controls = null
151
+ lighting = null
152
+ room = null
153
+ furnitureBatch = null
154
+ furnitureGroup = null
155
+ outdoorGround = null
156
+ session = null
157
+ input = null
158
+ walkFrame = null
159
+ gate = null
160
+ observer = null
161
+ }
162
+
163
+ function resize() {
164
+ if (disposed || !renderer) return
165
+ const width = Math.max(1, container.clientWidth)
166
+ const height = Math.max(1, container.clientHeight)
167
+ renderer.setSize(width, height, false)
168
+ for (const camera of [overviewCamera, walkCamera]) {
169
+ if (!camera) continue
170
+ camera.aspect = width / height
171
+ camera.updateProjectionMatrix()
172
+ }
173
+ }
174
+
175
+ /** 回到总览:恢复原总览机位;行走站位与视线原样保留在会话/帧推进器里。
176
+ * 阅读期间为无操作(输入启停统一交闸门的 syncMode,不在这里直写)。 */
177
+ function resetView() {
178
+ if (disposed || gate?.isReading()) return
179
+ session.setMode('overview')
180
+ mode = 'overview'
181
+ walkFrame.resetClock()
182
+ overviewCamera.position.set(9.5, 8.2, 11.5)
183
+ controls.target.set(0, 0.9, 0)
184
+ controls.update()
185
+ gate.syncMode()
186
+ }
187
+
188
+ /** 继续行走:保留站位与视线,只切输入与相机;切模式清空计时避免切回来补算。
189
+ * 阅读期间为无操作。 */
190
+ function enterWalk() {
191
+ if (disposed || gate?.isReading()) return
192
+ session.setMode('walk')
193
+ mode = 'walk'
194
+ walkFrame.resetClock()
195
+ renderer.domElement.focus({ preventScroll: true })
196
+ gate.syncMode()
197
+ }
198
+
199
+ /** 昼夜切换:底座自带动画(童话星夜的萤火/月亮)与公共灯光一起切;day 为默认。 */
200
+ function setPeriod(period) {
201
+ if (disposed) return
202
+ room.setPeriod?.(period)
203
+ lighting.set(styleId, period)
204
+ }
205
+
206
+ try {
207
+ // 参数与风格校验也在 try 内(E4b-2):批次所有权自调用瞬间转交——先验批次并接管,
208
+ // 风格未知、renderer 或行走会话创建失败同样走到 releaseAll 释放家具,不留隐藏的旧工厂路径。
209
+ if (!furniture || !furniture.group || typeof furniture.dispose !== 'function') {
210
+ throw new Error('缺少有效的家具批次(furniture:{ group, dispose })——请先经 prepareRoomFurniture 准备')
211
+ }
212
+ furnitureBatch = furniture
213
+ const roomFactory = ROOM_FACTORIES[styleId]
214
+ if (!roomFactory) throw new Error(`未知房间风格:${String(styleId)}(现有:ceramic、fairy)`)
215
+ renderer = new THREE.WebGLRenderer({ antialias: true })
216
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.75))
217
+ renderer.shadowMap.enabled = true
218
+ renderer.shadowMap.type = THREE.PCFSoftShadowMap
219
+ renderer.toneMapping = THREE.ACESFilmicToneMapping
220
+ renderer.outputColorSpace = THREE.SRGBColorSpace
221
+ renderer.domElement.tabIndex = 0
222
+ renderer.domElement.setAttribute('aria-label', '房间视图:WASD 或方向键行走、Shift 加速、拖动环视;回到总览后拖动旋转、滚轮缩放;点击家具查看身份')
223
+ container.append(renderer.domElement)
224
+
225
+ scene = new THREE.Scene()
226
+ overviewCamera = new THREE.PerspectiveCamera(40, 1, 0.1, 150)
227
+ controls = new OrbitControls(overviewCamera, renderer.domElement)
228
+ controls.enableDamping = true
229
+ controls.dampingFactor = 0.065
230
+ controls.minDistance = 6
231
+ controls.maxDistance = 24
232
+ controls.minPolarAngle = 0.22
233
+ controls.maxPolarAngle = Math.PI * 0.49
234
+ controls.enablePan = false
235
+ walkCamera = new THREE.PerspectiveCamera(WALK_FOV, 1, 0.1, 150)
236
+
237
+ lighting = createRoomLighting(renderer, scene)
238
+ room = roomFactory({ renderer, scene, reducedMotion, doorOpen: false })
239
+ scene.add(room.group)
240
+ room.setPeriod?.('day')
241
+ lighting.set(styleId, 'day')
242
+
243
+ // 家具批次(E4b-2):模型由页面先经 prepareRoomFurniture 异步备好,这里只整组挂进
244
+ // 场景——不再内置模型工厂循环;位置/朝向已在批次内按 assembly 世界坐标摆好。
245
+ furnitureGroup = furnitureBatch.group
246
+ scene.add(furnitureGroup)
247
+
248
+ outdoorGround = buildOutdoorGround(styleId, template)
249
+ scene.add(outdoorGround)
250
+
251
+ // 行走会话:人物站位、walk/overview 模式与门开合事务的唯一来源(P1c-2c-1 模块)。
252
+ session = createRoomWalkSession({ template, assembly, door: room.door })
253
+ input = createWalkInput(renderer.domElement)
254
+ const spawn = session.getState().position
255
+ const doorCenter = template.entry.door
256
+ walkFrame = createWalkFrame({
257
+ session,
258
+ input,
259
+ camera: walkCamera,
260
+ yaw: lookYawToward(spawn, { x: doorCenter.centerX, z: doorCenter.centerZ }),
261
+ })
262
+
263
+ // 阅读暂停闸门(E3b-1):input、walkFrame、controls 就绪后创建;getMode 只读
264
+ // 现有 mode,不另建一套 walk/overview 状态。未完成拾取记录放在 clearPick 可
265
+ // 访问的同一作用域,暂停时由闸门清掉——恢复后不把暂停前的按下与恢复后的
266
+ // 抬起拼成新点击;阅读中的按下/抬起在下方监听器里直接忽略。
267
+ let downAt = null
268
+ gate = createRoomReadingGate({
269
+ input,
270
+ controls,
271
+ walkFrame,
272
+ getMode: () => mode,
273
+ clearPick: () => { downAt = null },
274
+ })
275
+
276
+ // 点击家具:拖动超过 6px 不算点击;pointercancel 清掉本次按下记录。
277
+ // 候选=家具网格+四面墙组(含门框、门扇——门是墙组的活动子树),
278
+ // 由 judgeRoomPick 按距离与可见性裁决:总览裁掉的墙不遮挡,磨砂玻璃不遮挡。
279
+ const raycaster = new THREE.Raycaster()
280
+ const onPointerDown = (event) => {
281
+ if (gate.isReading()) return
282
+ downAt = [event.clientX, event.clientY]
283
+ }
284
+ const onPointerCancel = () => { downAt = null }
285
+ const effectivelyVisible = (object) => {
286
+ for (let node = object; node; node = node.parent) if (!node.visible) return false
287
+ return true
288
+ }
289
+ const onPointerUp = (event) => {
290
+ if (gate.isReading()) return
291
+ if (!downAt) return
292
+ const moved = Math.hypot(event.clientX - downAt[0], event.clientY - downAt[1])
293
+ downAt = null
294
+ if (moved > 6) return
295
+ const rect = renderer.domElement.getBoundingClientRect()
296
+ raycaster.setFromCamera(
297
+ new THREE.Vector2(((event.clientX - rect.left) / rect.width) * 2 - 1, -((event.clientY - rect.top) / rect.height) * 2 + 1),
298
+ mode === 'walk' ? walkCamera : overviewCamera,
299
+ )
300
+ const targets = [...furnitureGroup.children, ...room.walls.map((wall) => wall.group)]
301
+ const hits = raycaster.intersectObjects(targets, true).map((hit) => {
302
+ const material = Array.isArray(hit.object.material) ? hit.object.material[0] : hit.object.material
303
+ const opacity = material?.transparent ? material.opacity : 1
304
+ const owner = findFurnitureInstance(hit.object, furnitureGroup) // 书板/书脊/藤叶命中都归所属书架实例
305
+ return owner
306
+ ? { kind: 'furniture', visible: effectivelyVisible(hit.object), opacity, pick: { instanceId: owner.userData.instanceId, assetId: owner.userData.assetId, name: owner.userData.name } }
307
+ : { kind: 'occluder', visible: effectivelyVisible(hit.object), opacity }
308
+ })
309
+ const picked = judgeRoomPick(hits)
310
+ if (picked) onPick(picked)
311
+ }
312
+ renderer.domElement.addEventListener('pointerdown', onPointerDown)
313
+ renderer.domElement.addEventListener('pointerup', onPointerUp)
314
+ renderer.domElement.addEventListener('pointercancel', onPointerCancel)
315
+ canvasListeners.push(
316
+ { target: renderer.domElement, type: 'pointerdown', listener: onPointerDown },
317
+ { target: renderer.domElement, type: 'pointerup', listener: onPointerUp },
318
+ { target: renderer.domElement, type: 'pointercancel', listener: onPointerCancel },
319
+ )
320
+
321
+ // 页面隐藏/失焦时输入器自会清空;这里再把帧计时基线一并重置,
322
+ // 往返缓存(pagehide persisted=true 只冻结)恢复后首帧 dt=0,不把离开的时间算成位移。
323
+ const pauseForFreeze = () => {
324
+ input.clear()
325
+ walkFrame.resetClock()
326
+ }
327
+ const onVisibilityChange = () => pauseForFreeze()
328
+ const onPageHide = (event) => { if (event.persisted) pauseForFreeze() }
329
+ const onPageShow = () => walkFrame.resetClock()
330
+ document.addEventListener('visibilitychange', onVisibilityChange)
331
+ window.addEventListener('pagehide', onPageHide)
332
+ window.addEventListener('pageshow', onPageShow)
333
+ pageListeners.push(
334
+ { target: document, type: 'visibilitychange', listener: onVisibilityChange },
335
+ { target: window, type: 'pagehide', listener: onPageHide },
336
+ { target: window, type: 'pageshow', listener: onPageShow },
337
+ )
338
+
339
+ observer = new ResizeObserver(resize)
340
+ observer.observe(container)
341
+ resize()
342
+ overviewCamera.position.set(9.5, 8.2, 11.5)
343
+ controls.target.set(0, 0.9, 0)
344
+ controls.update()
345
+ enterWalk() // 门外开局:站位在会话出生点,视线正对门中心
346
+
347
+ // 朝向裁墙只在总览做(背对相机的墙临时隐藏);行走时四面墙与关闭的门真实可见。
348
+ // 相机推进只经闸门(E3b-1):阅读时两种推进都不做,不冻结渲染与底座动画。
349
+ const worldCamera = new THREE.Vector3()
350
+ const normal = new THREE.Vector3()
351
+ renderer.setAnimationLoop((now) => {
352
+ if (disposed || document.hidden) return
353
+ gate.advance(now)
354
+ if (mode === 'walk') {
355
+ for (const wall of room.walls) wall.group.visible = true
356
+ } else {
357
+ overviewCamera.getWorldPosition(worldCamera)
358
+ for (const wall of room.walls) {
359
+ normal.copy(wall.normal)
360
+ wall.group.visible = worldCamera.dot(normal) <= 0.1
361
+ }
362
+ }
363
+ const activeCamera = mode === 'walk' ? walkCamera : overviewCamera
364
+ room.update?.(now / 1000, activeCamera)
365
+ lighting.update(now / 1000)
366
+ renderer.render(scene, activeCamera)
367
+ })
368
+ } catch (error) {
369
+ releaseAll()
370
+ throw error
371
+ }
372
+
373
+ return {
374
+ resetView,
375
+ enterWalk,
376
+ getWalkState: () => session.getState(),
377
+ setDoorOpen: (open) => {
378
+ if (gate?.isReading()) return { ok: false, code: 'reading-active' }
379
+ return session.setDoorOpen(open)
380
+ },
381
+ setReading: (value) => gate?.setReading(value),
382
+ isReading: () => gate?.isReading() ?? false,
383
+ setPeriod,
384
+ dispose: releaseAll,
385
+ }
386
+ }
@@ -0,0 +1,287 @@
1
+ /* 房间页(body.room-page)独立样式:只作用于本页,不改业务页与建筑页。
2
+ 主题色、按钮与提示行的底子在 style.css,这里只补房间视口的布局与定位。 */
3
+
4
+ .room-stage { margin: 0 0 20px; }
5
+
6
+ /* 三维视图占页面主要面积:高度取视口的大部分并有下限,窄屏也能完整看到房间。 */
7
+ .room-viewport {
8
+ position: relative;
9
+ height: min(74vh, 760px);
10
+ min-height: 420px;
11
+ border-radius: 12px;
12
+ overflow: hidden;
13
+ background: var(--canvas);
14
+ border: 1px solid var(--border);
15
+ box-shadow: var(--shadow);
16
+ }
17
+ /* canvas 尺寸交给容器(setSize 的第三个参数 false 不写 style,须由 CSS 定显示尺寸):
18
+ 与建筑页同款规则;漏了这条画布会按属性像素(×像素比)放大裁切,房间中心偏出画面。 */
19
+ .room-viewport canvas { display: block; width: 100%; height: 100%; touch-action: none; }
20
+
21
+ /* 工具行浮在视口左上:返回业务页+回到总览+一句操作提示(按钮样式与共用工具条一致,只补定位)。 */
22
+ .room-toolbar {
23
+ position: absolute;
24
+ top: 10px;
25
+ left: 12px;
26
+ z-index: 2;
27
+ display: flex;
28
+ align-items: center;
29
+ gap: 12px;
30
+ flex-wrap: wrap;
31
+ }
32
+ /* 提示行在共用样式里是视口右下绝对定位;房间里它属于工具行,改回静态排布。
33
+ P1c-2c-2 起带 role=status:行走/总览操作提示与门结果的人话说明都走这一行。 */
34
+ .room-toolbar .scene-hint { position: static; margin: 0; color: #6c7d70; pointer-events: none; }
35
+
36
+ /* 行走/总览/门按钮:与"回到总览"同款;当前模式对应的按钮呈禁用态(浅显不抢焦点)。 */
37
+ .room-toolbar .reset-button:disabled { opacity: .55; cursor: default; }
38
+
39
+ /* 返回业务页:外观与工具条按钮同款(浅底描边),链接语义。 */
40
+ .room-back {
41
+ display: inline-flex;
42
+ align-items: center;
43
+ padding: 7px 12px;
44
+ border-radius: 8px;
45
+ font-size: 13px;
46
+ border: 1px solid #b9c6b2;
47
+ background: rgba(251, 250, 240, .92);
48
+ color: #29433a;
49
+ }
50
+ .room-back:hover { border-color: #487465; text-decoration: none; }
51
+
52
+ /* 点击家具后的身份一行:浮在视口左下,textContent 填充。 */
53
+ .pick-info {
54
+ position: absolute;
55
+ left: 12px;
56
+ bottom: 12px;
57
+ z-index: 2;
58
+ margin: 0;
59
+ padding: 6px 12px;
60
+ border-radius: 8px;
61
+ background: var(--surface);
62
+ border: 1px solid var(--border);
63
+ color: var(--text);
64
+ font-size: 13px;
65
+ }
66
+
67
+ /* 占位声明常驻视口右下:小字、不抢注意力。 */
68
+ .placeholder-note {
69
+ position: absolute;
70
+ right: 12px;
71
+ bottom: 12px;
72
+ z-index: 2;
73
+ margin: 0;
74
+ padding: 4px 10px;
75
+ border-radius: 8px;
76
+ background: color-mix(in srgb, var(--surface) 82%, transparent);
77
+ color: var(--muted);
78
+ font-size: 12px;
79
+ }
80
+
81
+ /* 底座字幕与昼夜面板(2026-09-28 作者令:照着抄旧底座页面):
82
+ 类名与取值逐项抄自 experiments/2026-09-27-空房间风格底座/style.css;
83
+ 仅去掉 eyebrow 的数字编号、top 下移让开本页工具条(返回业务页/回到总览)。
84
+ 变量随底座风格与昼夜切换(body[data-style]/[data-period] 由 room.js 设置),
85
+ 星夜换浅色字与深色面板,抄旧页原值。 */
86
+ .room-page { --ink:#394740; --subtle:#7c837d; --panel:rgba(255,255,248,.65); --border:rgba(70,80,60,.13); }
87
+ body[data-style="fairy"].room-page { --ink:#43523b; --subtle:#6b7e62; --panel:#fbffe5a8; --border:#6781572a; }
88
+ body[data-style="fairy"][data-period="night"].room-page { --ink:#f0ead8; --subtle:#b3b8c8; --panel:#151d35ad; --border:#c2c9ef21; }
89
+
90
+ .room-page .intro { position: absolute; pointer-events: none; top: 142px; left: 46px; color: var(--ink); }
91
+ .room-page .eyebrow { font-size: 10px; letter-spacing: 3px; color: var(--subtle); margin: 0 0 18px; }
92
+ .room-page .intro h1 { font-family: "STSong", "SimSun", serif; font-weight: 400; font-size: 43px; letter-spacing: 6px; margin: 0 0 20px; }
93
+ .room-page .intro > p:not(.eyebrow) { font-size: 12px; line-height: 2.1; letter-spacing: 2px; color: var(--subtle); margin: 0; }
94
+ .room-page .material-note { display: flex; align-items: center; gap: 9px; margin-top: 27px; font-size: 10px; color: var(--subtle); letter-spacing: 1px; }
95
+ .room-page .material-chip { width: 25px; height: 25px; background: linear-gradient(140deg, #fff, #c6cac5 65%, #faf9ee); border-radius: 6px; border: 1px solid #ffffff6b; box-shadow: 0 3px 8px #101e1515; }
96
+ body[data-style="fairy"].room-page .material-chip { background: linear-gradient(135deg, #d2bf94, #9b6e48 50%, #5f8248); }
97
+
98
+ /* 昼夜面板:抄旧页 .period-panel/.small-label/.period-switch。 */
99
+ .room-page .period-panel { position: absolute; right: 44px; bottom: 95px; padding: 16px 18px; border-radius: 16px; border: 1px solid var(--border); background: var(--panel); backdrop-filter: blur(18px); }
100
+ .room-page .small-label { display: block; font-size: 10px; letter-spacing: 2px; color: var(--subtle); margin-bottom: 12px; }
101
+ .room-page .period-switch { display: flex; gap: 6px; }
102
+ .room-page .period-switch button { font: inherit; color: var(--ink); font-size: 11px; letter-spacing: 1px; padding: 10px 18px; background: transparent; border: 0; border-radius: 9px; cursor: pointer; }
103
+ .room-page .period-switch button:hover { filter: brightness(.97); }
104
+ .room-page .period-switch button:focus-visible { outline: 2px solid #81966a; outline-offset: 5px; }
105
+ .room-page .period-switch button[aria-pressed="true"] { background: #f8f9ef; color: #3d4b3c; box-shadow: 0 2px 6px #0000000a; }
106
+ body[data-period="night"].room-page .period-switch button[aria-pressed="true"] { background: #4b536b; color: #fff1d2; }
107
+
108
+ /* 窄屏:抄旧页 850px 断点的字幕与面板项;工具条按钮变多,提示行整行换行避开字幕。 */
109
+ @media (max-width: 850px) {
110
+ .room-page .room-toolbar { left: 12px; right: 12px; }
111
+ .room-toolbar .scene-hint { flex: 1 1 100%; }
112
+ .room-page .intro { top: 150px; left: 24px; }
113
+ .room-page .intro h1 { font-size: 31px; }
114
+ .room-page .intro > p:not(.eyebrow) { font-size: 11px; }
115
+ .room-page .material-note { display: none; }
116
+ .room-page .period-panel { right: 22px; bottom: 75px; }
117
+ }
118
+
119
+ /* 场景建不起来:占位说明居中,返回链接照常可用。 */
120
+ .room-viewport .scene-fallback {
121
+ position: absolute;
122
+ inset: 0;
123
+ z-index: 3;
124
+ display: flex;
125
+ flex-direction: column;
126
+ align-items: center;
127
+ justify-content: center;
128
+ gap: 6px;
129
+ text-align: center;
130
+ }
131
+
132
+ /* 摆放诊断列表:code 行用等宽与主题色,原因行普通文字。 */
133
+ .room-diag .diag-code { margin: 0 0 2px; font-family: Consolas, "Courier New", monospace; color: var(--warning); }
134
+ .room-diag .problem-list li > p:last-child { margin: 0; color: var(--secondary); }
135
+
136
+ /* 业务页的"进入房间"入口:跟在业务介绍后面的一行链接,不抢文档与图卡的位置。 */
137
+ .business-page .room-entry-line { margin: 10px 0 0; }
138
+ .business-page .room-entry-link {
139
+ display: inline-flex;
140
+ align-items: center;
141
+ gap: 6px;
142
+ padding: 6px 14px;
143
+ border-radius: 8px;
144
+ background: var(--accent-bg);
145
+ border: 1px solid var(--accent-border);
146
+ color: var(--accent);
147
+ font-weight: 600;
148
+ }
149
+ .business-page .room-entry-link:hover { background: var(--accent-hover); text-decoration: none; }
150
+
151
+ /* E9b:房间页普通页头/空态可见区的"家具图鉴"入口,视觉同业务页进入房间入口;
152
+ 全窗口形态下页头隐藏,工具条里另有常驻入口,这里只管普通形态。 */
153
+ .room-page .furniture-entry-line { margin: 10px 0 0; }
154
+ .room-page .furniture-entry-link {
155
+ display: inline-flex;
156
+ align-items: center;
157
+ gap: 6px;
158
+ padding: 6px 14px;
159
+ border-radius: 8px;
160
+ background: var(--accent-bg);
161
+ border: 1px solid var(--accent-border);
162
+ color: var(--accent);
163
+ font-weight: 600;
164
+ }
165
+ .room-page .furniture-entry-link:hover { background: var(--accent-hover); text-decoration: none; }
166
+
167
+ /* 全窗口形态(作者令:进入房间后房间占满全屏,不留其他东西):
168
+ 场景创建成功后 room.js 给 body 加 room-live——顶栏与页头让位,视口铺满窗口;
169
+ 错误、空态与场景失败不加这个类,仍是普通滚动文档。 */
170
+ .room-page.room-live { overflow: hidden; }
171
+ .room-page.room-live .app-bar,
172
+ .room-page.room-live .page-content > .top { display: none; }
173
+ .room-page.room-live .room-stage { position: fixed; inset: 0; z-index: 5; margin: 0; }
174
+ .room-page.room-live .room-viewport { height: 100%; min-height: 0; border-radius: 0; border: none; }
175
+
176
+ /* ── 阅读面板覆盖层(E3b-2)────────────────────────────────────────────
177
+ root 在 viewport 外、roomStage 内:阅读时背景视口加 inert,面板不受波及。
178
+ 轻微遮罩+四周留出场景(沿用建筑页"面板是浮卡、能看出还在房子里"的做法);
179
+ 面板结构复用 style.css 的 .material-*,这里只改房间侧的定位与不透明度。 */
180
+ .room-reader-root {
181
+ position: absolute;
182
+ inset: 0;
183
+ z-index: 4;
184
+ display: flex;
185
+ align-items: center;
186
+ justify-content: center;
187
+ padding: clamp(18px, 4vh, 44px) clamp(18px, 4vw, 64px);
188
+ background: rgba(12, 18, 15, .28);
189
+ }
190
+ .room-reader-root[hidden] { display: none; }
191
+ /* 焦点边界需要可聚焦,不能 display:none;绝对定位避免占用阅读区。 */
192
+ .room-reader-root .reader-focus-guard { position: absolute; width: 1px; height: 1px; overflow: hidden; }
193
+ /* .material-panel 原是建筑视口内绝对定位的浮卡;这里由 root 居中托管,改回常规盒。 */
194
+ .room-page .room-reader-panel {
195
+ position: relative;
196
+ inset: auto;
197
+ width: min(920px, 100%);
198
+ height: min(640px, 100%);
199
+ max-width: none;
200
+ margin-inline: 0;
201
+ /* 正文保持足够不透明便于阅读;描边随底座风格变量(陶瓷浅/童话暖/星夜深)走。 */
202
+ background: color-mix(in srgb, var(--surface) 98%, transparent);
203
+ border-color: var(--border);
204
+ }
205
+ /* 重试行:失败时出现在正文区下方;隐藏时不占位。 */
206
+ .reader-retry-line { flex: none; margin: 10px 0 0; }
207
+ .reader-retry-line[hidden] { display: none; }
208
+ .room-page .reader-retry-line .reset-button { font-size: 13px; }
209
+
210
+ /* 图阅读页容器(E9c):reader.js 每次选图新建 iframe(复用 style.css 的
211
+ .material-reader-frame 外观),这里只管容器自身占满阅读区、隐藏时不占位;
212
+ iframe 保持可聚焦(不设 pointer-events/touch 屏障),键盘能进图也能离图。 */
213
+ .room-reader-root .material-reader-frame-host {
214
+ flex: 1;
215
+ display: flex;
216
+ min-height: 220px; /* 浅/深主题与窄屏都保留可视高度,关闭按钮不因此被顶出面板 */
217
+ min-width: 0;
218
+ }
219
+ .room-reader-root .material-reader-frame-host[hidden] { display: none; }
220
+ .room-reader-root .material-reader-frame-host .material-reader-frame { flex: 1; }
221
+
222
+ /* ── 清单收缩与面板放大(作者令 2026-10-01:看图时清单可收起、窗口可放大,
223
+ 四周留边仍看得出在房间里,不是跳转页面)─────────────────────────── */
224
+ /* 面板头里的两枚工具小按钮(清单收缩/放大还原),与关闭钮同一套轻量观感。
225
+ 关闭钮 DOM 序在前(焦点循环首位=打开焦点),视觉仍排最右(order)。 */
226
+ .room-reader-root .material-head .material-close { order: 3; }
227
+ .room-reader-root .material-tools { order: 2; flex: none; display: flex; gap: 8px; margin-left: auto; }
228
+ .room-reader-root .material-tool {
229
+ height: 28px; padding: 0 12px; border-radius: 6px; border: 1px solid var(--border);
230
+ background: var(--surface-soft); color: var(--secondary); font-size: 12.5px; line-height: 1;
231
+ cursor: pointer; white-space: nowrap;
232
+ }
233
+ .room-reader-root .material-tool:hover { background: var(--hover); }
234
+ .room-reader-root .material-tool[aria-pressed='true'] { color: var(--accent); border-color: var(--accent-border); }
235
+ /* 收起清单:侧栏整体让位,阅读区独占整行(覆盖 style.css 的两列网格)。 */
236
+ .room-reader-root[data-list='collapsed'] .material-body { grid-template-columns: minmax(0, 1fr); }
237
+ .room-reader-root[data-list='collapsed'] .material-side { display: none; }
238
+ /* 放大:面板放到接近全屏但四周留边(root 的半透明遮罩和边缘房间画面仍在视野里)。 */
239
+ .room-reader-root[data-size='large'] { padding: clamp(10px, 2vh, 20px) clamp(12px, 2vw, 28px); }
240
+ .room-reader-root[data-size='large'] .room-reader-panel { width: min(1560px, 100%); height: min(960px, 100%); }
241
+
242
+ /* 长路径按原执行单要能读全(E3b-2 验收补齐 R2):共用 .material-item 是单行省略号
243
+ (white-space:nowrap+ellipsis),登记路径长且前缀相同时在清单里被截断。这里在
244
+ 房间阅读面板局部覆盖——条目允许多行、长无空格片段也能折行,取消省略;清单仍限
245
+ 在自己的侧栏内滚动(.material-side 已有 overflow-y:auto)。不改共用 style.css。 */
246
+ .room-reader-root .material-item {
247
+ white-space: normal;
248
+ overflow: visible;
249
+ text-overflow: clip;
250
+ overflow-wrap: anywhere;
251
+ }
252
+ /* 家具标题(面板头)与文档标题不许被长路径撑宽挤走关闭按钮:flex 子项给 min-width:0,
253
+ 长词按 anywhere 折行;正文里的长路径与行内代码同理不撑出面板。 */
254
+ .room-reader-root .material-heading { min-width: 0; }
255
+ .room-reader-root .material-heading h2,
256
+ .room-reader-root .material-reader-title { overflow-wrap: anywhere; }
257
+ .room-reader-root .material-reader-body { min-width: 0; }
258
+ .room-reader-root .material-reader-body code { overflow-wrap: anywhere; }
259
+ .room-reader-root .material-doc-paragraph,
260
+ .room-reader-root .material-doc-heading { overflow-wrap: anywhere; }
261
+
262
+ /* ── 家具资料绑定提示(E3b-2):绑定有误但房间照常浏览时的可展开诊断 ── */
263
+ .binding-note {
264
+ position: absolute;
265
+ left: 12px;
266
+ bottom: 44px;
267
+ z-index: 2;
268
+ max-width: min(440px, calc(100% - 24px));
269
+ margin: 0;
270
+ padding: 8px 12px;
271
+ border-radius: 8px;
272
+ background: color-mix(in srgb, var(--surface) 92%, transparent);
273
+ border: 1px solid var(--border);
274
+ font-size: 12px;
275
+ color: var(--text);
276
+ }
277
+ .binding-note summary { cursor: pointer; color: var(--warning); }
278
+ .binding-note ol { margin: 6px 0 0; padding-left: 18px; }
279
+ .binding-note ol p { margin: 2px 0 6px; }
280
+ .binding-note .diag-code { font-family: Consolas, "Courier New", monospace; font-size: 11px; }
281
+
282
+ /* 窄屏:阅读面板收窄留边、绑定提示避开工具条换行区。 */
283
+ @media (max-width: 850px) {
284
+ .room-reader-root { padding: 12px; }
285
+ .room-page .room-reader-panel { width: 100%; height: 100%; }
286
+ .binding-note { bottom: 64px; }
287
+ }