@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,50 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "note": "两套底座共享同一份空间布局,仅风格身份不同。空间数据来自 E2a 任务单固定值,并须与两套底座 meta 比对。",
4
+ "templates": [
5
+ {
6
+ "schemaVersion": 1,
7
+ "templateId": "room-ceramic-01",
8
+ "styleId": "ceramic",
9
+ "name": "写实底座·项目介绍室",
10
+ "interiorBounds": { "minX": -4, "maxX": 4, "minY": 0, "maxY": 3.4, "minZ": -3, "maxZ": 3 },
11
+ "zones": [
12
+ { "zoneId": "floor-main", "name": "室内地面陈设区", "kind": "floor", "bounds": { "minX": -3.7, "maxX": 3.7, "minY": 0, "maxY": 3.0, "minZ": -2.7, "maxZ": 2.7 } }
13
+ ],
14
+ "entry": {
15
+ "wall": "north",
16
+ "door": { "centerX": 2.25, "centerZ": -3, "width": 1.4, "height": 2.55, "opens": "outward" },
17
+ "inward": "+Z",
18
+ "spawn": { "x": 2.25, "z": -2.35 }
19
+ },
20
+ "reservedVolumes": [
21
+ { "id": "entry-clear", "name": "入口禁放区", "purpose": "entry", "bounds": { "minX": 1.55, "maxX": 2.95, "minY": 0, "maxY": 2.1, "minZ": -3, "maxZ": -1.4 } },
22
+ { "id": "aisle-main", "name": "主通路", "purpose": "corridor", "bounds": { "minX": 1.65, "maxX": 2.85, "minY": 0, "maxY": 2.1, "minZ": -2, "maxZ": 2.4 } },
23
+ { "id": "aisle-display", "name": "展示通路", "purpose": "corridor", "bounds": { "minX": -3.6, "maxX": 2.85, "minY": 0, "maxY": 2.1, "minZ": 1, "maxZ": 2.05 } }
24
+ ],
25
+ "walkProfile": { "radius": 0.26, "eyeClearance": 1.9 }
26
+ },
27
+ {
28
+ "schemaVersion": 1,
29
+ "templateId": "room-fairy-01",
30
+ "styleId": "fairy",
31
+ "name": "童话底座·项目介绍室",
32
+ "interiorBounds": { "minX": -4, "maxX": 4, "minY": 0, "maxY": 3.4, "minZ": -3, "maxZ": 3 },
33
+ "zones": [
34
+ { "zoneId": "floor-main", "name": "室内地面陈设区", "kind": "floor", "bounds": { "minX": -3.7, "maxX": 3.7, "minY": 0, "maxY": 3.0, "minZ": -2.7, "maxZ": 2.7 } }
35
+ ],
36
+ "entry": {
37
+ "wall": "north",
38
+ "door": { "centerX": 2.25, "centerZ": -3, "width": 1.4, "height": 2.55, "opens": "outward" },
39
+ "inward": "+Z",
40
+ "spawn": { "x": 2.25, "z": -2.35 }
41
+ },
42
+ "reservedVolumes": [
43
+ { "id": "entry-clear", "name": "入口禁放区", "purpose": "entry", "bounds": { "minX": 1.55, "maxX": 2.95, "minY": 0, "maxY": 2.1, "minZ": -3, "maxZ": -1.4 } },
44
+ { "id": "aisle-main", "name": "主通路", "purpose": "corridor", "bounds": { "minX": 1.65, "maxX": 2.85, "minY": 0, "maxY": 2.1, "minZ": -2, "maxZ": 2.4 } },
45
+ { "id": "aisle-display", "name": "展示通路", "purpose": "corridor", "bounds": { "minX": -3.6, "maxX": 2.85, "minY": 0, "maxY": 2.1, "minZ": 1, "maxZ": 2.05 } }
46
+ ],
47
+ "walkProfile": { "radius": 0.26, "eyeClearance": 1.9 }
48
+ }
49
+ ]
50
+ }
@@ -0,0 +1,64 @@
1
+ // 独立房间的行走帧推进(P1c-2c-2):把已验收的行走会话(walk-session.js)、
2
+ // 原版输入器(building-walk.js 的 createWalkInput)与一台 Three 相机接成"每帧一步"。
3
+ // 只做状态推进与相机同步,不建动画循环、不建第二套会话、不碰 DOM——
4
+ // 渲染循环由 room-scene.js 的 renderer.setAnimationLoop 驱动,本模块只提供 frame()。
5
+ // 相机由调用方传入并直接读写其 position/rotation(YXZ 欧拉),本模块不导入 Three.js,
6
+ // Node 测试可直连。参数与旧建筑页手感一致:眼高 1.65、视场 66、步行 2.6 m/s、
7
+ // Shift 4.6 m/s、俯仰限 ±0.44π;帧间隔超过 0.05 秒按 0.05 截断,时钟重置后首帧 dt=0
8
+ // (页面隐藏/往返缓存/切模式回来,不把离开的时间算成位移)。
9
+
10
+ export const EYE_HEIGHT = 1.65
11
+ export const WALK_FOV = 66
12
+ export const PITCH_LIMIT = Math.PI * 0.44
13
+ export const WALK_SPEED = 2.6
14
+ export const WALK_RUN_SPEED = 4.6
15
+ export const MAX_FRAME_DT = 0.05
16
+
17
+ /** 从 from 看向 to 的 yaw(Three 相机面向局部 -Z):门外出生看向门中心应得 ±π。 */
18
+ export function lookYawToward(from, to) {
19
+ return Math.atan2(-(to.x - from.x), -(to.z - from.z))
20
+ }
21
+
22
+ /**
23
+ * 建一个行走帧推进器。session 是人物位置与门开合的唯一来源;input 只需提供
24
+ * 原版输入器的 axis()/takeLook() 两个读法(测试可用同形小桩)。
25
+ * 每帧:先应用环顾增量,再用新 yaw 算本帧位移交 session.move,最后把相机
26
+ * 同步到脚底+眼高。位移公式与旧建筑页一致,斜向输入按长度归一。
27
+ */
28
+ export function createWalkFrame({ session, input, camera, yaw = 0, pitch = 0 } = {}) {
29
+ let lastTime = null
30
+
31
+ function frame(timeMs) {
32
+ const seconds = Number(timeMs) / 1000
33
+ const dt = lastTime === null || !Number.isFinite(seconds)
34
+ ? 0
35
+ : Math.min(Math.max(seconds - lastTime, 0), MAX_FRAME_DT)
36
+ if (Number.isFinite(seconds)) lastTime = seconds
37
+
38
+ const lookDelta = input.takeLook()
39
+ yaw += lookDelta.yaw
40
+ pitch = Math.max(-PITCH_LIMIT, Math.min(PITCH_LIMIT, pitch + lookDelta.pitch))
41
+
42
+ const { forward, right, fast } = input.axis()
43
+ const length = Math.hypot(forward, right)
44
+ if (length > 0) {
45
+ const step = dt * (fast ? WALK_RUN_SPEED : WALK_SPEED)
46
+ session.move(
47
+ (-Math.sin(yaw) * forward + Math.cos(yaw) * right) / length * step,
48
+ (-Math.cos(yaw) * forward - Math.sin(yaw) * right) / length * step,
49
+ )
50
+ }
51
+
52
+ const state = session.getState()
53
+ camera.position.set(state.position.x, state.position.y + EYE_HEIGHT, state.position.z)
54
+ camera.rotation.set(pitch, yaw, 0, 'YXZ')
55
+ }
56
+
57
+ return {
58
+ frame,
59
+ /** 丢掉计时基线:下一帧 dt=0。页面隐藏/往返缓存恢复/切模式后调用。 */
60
+ resetClock() { lastTime = null },
61
+ /** 当前保存的行走视线(总览期间不被覆盖,恢复行走继续用)。 */
62
+ getLook() { return { yaw, pitch } },
63
+ }
64
+ }
@@ -0,0 +1,138 @@
1
+ // 独立房间 · 行走会话(P1c-2c-1):把真实门句柄、室外导航快照与人物脚底位置交给
2
+ // 一个小模块管理;页面按钮与渲染循环只与会话对话,不各自直改底座门。会话只做状态
3
+ // 与事务:不导入 Three.js、不访问 DOM、不建帧循环/定时器/事件监听,也不管相机
4
+ // 朝向、键盘输入、速度或 dt(下一单场景负责把输入换算成世界位移)。
5
+ // 导航与碰撞全部复用现有模块:buildRoomOutdoorWalkData 做数据翻译,原版
6
+ // building-walk 的 createNavigator/entryPoint 做判定,本模块零复制、零改造;
7
+ // 人物脚底位置来自导航入口,不把坐标再写死一份。
8
+ // 门开合是同步事务(无 await、无动画、无中途通知,渲染循环不会看见候选的临时门
9
+ // 姿态):先在内存把真实门切到目标端点、取真实世界包围盒重建候选导航,用候选
10
+ // canStand 检查人物当前站位(整体半径与躯干净高,不是点是否在盒内);站得住才
11
+ // 提交,站不住立即恢复门端点并继续用旧导航,人物位置全程不动。本轮门只在 0°/
12
+ // 90° 两端点瞬时切换、不做动画;将来加开门动画须另做扫掠碰撞,不能沿用端点检查。
13
+
14
+ import { buildRoomOutdoorWalkData } from './navigation.js'
15
+ import { createNavigator, entryPoint } from '../building-walk.js'
16
+
17
+ /** 门句柄检查:三个方法齐全且当前关闭;会话不擅自改变调用者已有的门姿态。 */
18
+ function assertDoorHandle(door) {
19
+ if (!door || typeof door !== 'object') {
20
+ throw new Error('房间行走会话失败:缺少 door——会话需要真实底座返回的门句柄(setOpen/isOpen/getBounds),不接受门盒快照或 Three.js 对象。')
21
+ }
22
+ for (const method of ['setOpen', 'isOpen', 'getBounds']) {
23
+ if (typeof door[method] !== 'function') {
24
+ throw new Error(`房间行走会话失败:门句柄缺少 ${method} 方法,不是完整的真实门接口,会话无法安全地开关门。`)
25
+ }
26
+ }
27
+ if (door.isOpen() !== false) {
28
+ throw new Error('房间行走会话失败:会话只能从关闭的门开始(当前 isOpen() 为 true)。请以 { doorOpen: false } 创建底座后传入;会话不擅自改变调用者已有的门姿态。')
29
+ }
30
+ }
31
+
32
+ /** 用门当前真实包围盒构建候选导航(数据适配+原版导航器);失败由调用方恢复门。 */
33
+ function buildNavFromDoor(template, assembly, door) {
34
+ const data = buildRoomOutdoorWalkData(template, assembly, { doorBounds: door.getBounds() })
35
+ return { data, nav: createNavigator(data.model, data.presentation) }
36
+ }
37
+
38
+ /**
39
+ * 建立一次行走会话。调用前提:template 与 assembly 来自成功的真实 validatePlacement,
40
+ * door 是以 { doorOpen: false } 创建的真实底座门句柄。返回:
41
+ * getState() → { mode, position:{x,y,z}, doorOpen, spaceId } 新快照
42
+ * setMode('walk'|'overview') → 切模式返回新状态;总览不移动,回行走不重新出生
43
+ * move(dx,dz) → 世界 X/Z 位移;仅 walk 模式生效,撞墙滑动由原导航决定
44
+ * setDoorOpen(boolean) → { ok, code, message, state };人物挡目标门板时
45
+ * code='door-blocked-by-player',非法参数抛中文错误
46
+ * 没有传送、任意 setPosition 或“撞墙重生”接口;底座资源释放仍归场景所有。
47
+ */
48
+ export function createRoomWalkSession({ template, assembly, door } = {}) {
49
+ if (!template || !assembly) {
50
+ throw new Error('房间行走会话失败:需要同时提供 template 与 assembly(来自成功的真实 validatePlacement),会话不重新推算家具、门盒或场地边界。')
51
+ }
52
+ assertDoorHandle(door)
53
+
54
+ let current = buildNavFromDoor(template, assembly, door)
55
+ const entry = entryPoint(current.data.model)
56
+ if (!entry || !current.nav.canStand({ x: entry.x, y: entry.y, z: entry.z })) {
57
+ throw new Error(`房间行走会话失败:门外出生点(${entry ? `${entry.x}, ${entry.y}, ${entry.z}` : '模型缺少 entry'})在当前门姿态下不可站立,会话拒绝在不成立的场地上开局。`)
58
+ }
59
+ let mode = 'walk'
60
+ let position = { x: entry.x, y: entry.y, z: entry.z }
61
+
62
+ /** 每次新构造的快照:外部修改快照不影响会话内部状态。 */
63
+ function snapshot() {
64
+ return {
65
+ mode,
66
+ position: { x: position.x, y: position.y, z: position.z },
67
+ doorOpen: door.isOpen(),
68
+ spaceId: current.nav.locate(position)?.id ?? null,
69
+ }
70
+ }
71
+
72
+ return {
73
+ getState: snapshot,
74
+
75
+ setMode(nextMode) {
76
+ if (nextMode !== 'walk' && nextMode !== 'overview') {
77
+ throw new Error(`房间行走会话失败:setMode 只接受 walk/overview,收到「${String(nextMode)}」;行走与总览的切换不改变人物站位。`)
78
+ }
79
+ mode = nextMode
80
+ return snapshot()
81
+ },
82
+
83
+ move(dx, dz) {
84
+ if (!Number.isFinite(dx) || !Number.isFinite(dz)) {
85
+ throw new Error(`房间行走会话失败:move 的位移必须是有限数(收到 dx=${String(dx)},dz=${String(dz)})。`)
86
+ }
87
+ const distance = Math.hypot(dx, dz)
88
+ if (distance > 30) {
89
+ throw new Error(`房间行走会话失败:move 的单次位移不得超过 30 米(收到 ${distance} 米);目录定位等跳转不归会话管。`)
90
+ }
91
+ if (mode === 'walk') {
92
+ const next = current.nav.move(position, dx, dz)
93
+ position = { x: next.x, y: next.y, z: next.z }
94
+ }
95
+ return snapshot()
96
+ },
97
+
98
+ setDoorOpen(open) {
99
+ if (typeof open !== 'boolean') {
100
+ throw new Error(`房间行走会话失败:setDoorOpen 只接受 boolean(收到「${String(open)}」)。`)
101
+ }
102
+ const previous = door.isOpen()
103
+ if (open === previous) {
104
+ return { ok: true, code: 'ok', message: `门已经是${open ? '打开' : '关闭'}状态,无需切换。`, state: snapshot() }
105
+ }
106
+ // 事务主体:真实门切到目标端点 → 真实包围盒重建候选导航 → 站位检查。
107
+ // 构造中途任何异常都先恢复门原端点、保留旧导航与人物状态,再抛中文错误,
108
+ // 不留下半次成功。开门与关门对称检查;总览模式同样检查保留的行走站位。
109
+ let candidate = null
110
+ try {
111
+ door.setOpen(open)
112
+ candidate = buildNavFromDoor(template, assembly, door)
113
+ } catch (error) {
114
+ let restoreFailure = null
115
+ try {
116
+ door.setOpen(previous)
117
+ } catch (restoreError) {
118
+ restoreFailure = restoreError
119
+ }
120
+ if (restoreFailure) {
121
+ throw new Error(`房间行走会话失败:门切到${open ? '打开' : '关闭'}端点后构造导航失败(${error.message}),且恢复门原端点也失败(${restoreFailure.message});人物位置与导航保持原状,请检查底座门状态。`)
122
+ }
123
+ throw new Error(`房间行走会话失败:门切到${open ? '打开' : '关闭'}端点后构造导航失败(${error.message});门已恢复${previous ? '打开' : '关闭'}端点,人物位置与导航保持原状。`)
124
+ }
125
+ if (candidate.nav.canStand({ x: position.x, y: position.y, z: position.z })) {
126
+ current = candidate
127
+ return { ok: true, code: 'ok', message: `门已${open ? '打开' : '关闭'},导航与碰撞已按新的门姿态重建。`, state: snapshot() }
128
+ }
129
+ door.setOpen(previous)
130
+ return {
131
+ ok: false,
132
+ code: 'door-blocked-by-player',
133
+ message: `人物当前位置与${open ? '打开' : '关闭'}后的门板重叠,请先挪开人物再${open ? '开' : '关'}门;门与导航保持原状。`,
134
+ state: snapshot(),
135
+ }
136
+ },
137
+ }
138
+ }
@@ -0,0 +1,89 @@
1
+ // 保存弹层的两条状态规则(纯逻辑:不碰 DOM、不发请求,可被 scripts/check-save.mjs 直接
2
+ // import 断言;页面里只剩"取数—渲染—发请求"的接线)。放这里的另一个原因:页面层没有
3
+ // 浏览器外的跑法,规则留在页面里就只能靠"源码里有没有这句话"来验,写在这里能真跑。
4
+ //
5
+ // ① 页面正展示的那份内容的摘要由本模块保管,只有"重新加载并展示了内容"才更新它。
6
+ // 保存成功后原地刷新版本条**不更新**——屏幕上还是原来那一份图与说明,过期检查就必须
7
+ // 仍按原来那一份算;否则后台在两次读取之间改了文件,页面会放行保存一份自己没展示过的内容。
8
+ // ② 一次保存请求的结果怎么算:ok 只认"状态成功 + 正文完整",缺一不可——状态成功但正文
9
+ // 读不出来时结果未知,不能当成功,更不能去读一个可能为空的正文。
10
+
11
+ /** 页面正展示内容的摘要(v 不是 current、或摘要取不出来时为 null)。 */
12
+ let shown = null
13
+
14
+ /** 内容真正被加载并展示时调用(main 里一次)。刷新版本条不走这里。 */
15
+ export function rememberShown(data) {
16
+ shown = data && typeof data.currentFingerprint === 'string' ? data.currentFingerprint : null
17
+ }
18
+
19
+ /** 页面正展示内容的摘要(保存自查与提交时送回服务端用)。 */
20
+ export function shownFingerprint() {
21
+ return shown
22
+ }
23
+
24
+ /**
25
+ * 弹层打开时的自查:把"这次读到的已提交内容"与"页面正展示的那一份"对一下。
26
+ * check 是 GET /api/snapshots 的结果;请求没回来时传 { failed: 原因 }。
27
+ * 返回 state:check-failed / no-repo / uncommitted / unknown / stale / ok(只有 ok 放行)。
28
+ */
29
+ export function saveGate(shownPrint, check) {
30
+ if (check && typeof check.failed === 'string') {
31
+ return { state: 'check-failed', text: `没能检查这一版能不能存:${check.failed}` }
32
+ }
33
+ if (check && check.code === 'repo-not-configured') {
34
+ return { state: 'no-repo', text: check.error }
35
+ }
36
+ if (!check || check.ok !== true) {
37
+ return {
38
+ state: 'uncommitted',
39
+ text: '请先提交,再保存版本:',
40
+ problems: Array.isArray(check?.problems) ? check.problems : [],
41
+ }
42
+ }
43
+ if (typeof shownPrint !== 'string' || !shownPrint) {
44
+ return {
45
+ state: 'unknown',
46
+ text: '这一版的内容摘要取不出来(有文件读不开),没法确认页面是不是最新的:请刷新页面后再保存。',
47
+ }
48
+ }
49
+ if (check.fingerprint !== shownPrint) {
50
+ return {
51
+ state: 'stale',
52
+ text: '内容已更新,请刷新后确认:页面上看到的这一版,和将要保存的内容已经不是同一份了。',
53
+ }
54
+ }
55
+ return { state: 'ok', text: `将保存为提交 ${String(check.head).slice(0, 8)} 的快照,内容与你正在看的一致。` }
56
+ }
57
+
58
+ /**
59
+ * 一次保存请求的结果 → 页面该做什么。responded/ok/status/body 是这次 fetch 的实况。
60
+ * 返回 kind:created / already / created-unverified / rejected / unknown。
61
+ */
62
+ export function saveOutcome({ responded, ok, status, body }) {
63
+ const snapshot = body && typeof body === 'object' ? body.snapshot : null
64
+ const label = snapshot && snapshot.meta && typeof snapshot.meta.name === 'string' ? snapshot.meta.name : ''
65
+ const reason = body && typeof body.error === 'string' ? body.error : ''
66
+ if (!responded) {
67
+ return {
68
+ kind: 'unknown',
69
+ text: '没能确认有没有保存成功:请求没有回应。请刷新页面看版本列表,确认这一版是否已经存上,再决定要不要重存。',
70
+ }
71
+ }
72
+ if (!ok) {
73
+ // 版本已经写上去了、只是没核完:这话不能说成"没能保存"(用户会以为没存上又存一遍)
74
+ if (body && body.code === 'save-created-unverified') {
75
+ return {
76
+ kind: 'created-unverified',
77
+ text: `${reason || '版本可能已经写上去了,但读回核对没做完。'} 先刷新版本条核对,不要急着重存。`,
78
+ }
79
+ }
80
+ return { kind: 'rejected', text: `没能保存:${reason || `服务端返回 ${status}`}` }
81
+ }
82
+ if (!label) {
83
+ return {
84
+ kind: 'unknown',
85
+ text: '没能确认有没有保存成功:服务端说成功了,但回来的内容不完整。请刷新页面看版本列表,确认这一版是否已经存上,再决定要不要重存。',
86
+ }
87
+ }
88
+ return { kind: body.alreadySaved === true ? 'already' : 'created', label }
89
+ }
@@ -0,0 +1,232 @@
1
+ // 展示页:项目级业务展示,列表/星图/球阵三种格式,切换器吸顶、选择记入本地存储。
2
+ // 星图与球阵都是自绘轻量实现(不引新依赖):星图=SVG 星座,球阵=斐波那契球面+自转投影。
3
+ // 沿用星图小样的产品口径:位置按业务 id 稳定分配不重叠;切走即停渲染;
4
+ // 减少动态效果偏好下先给静态布局,由用户点「开始旋转」明确启动。
5
+ // 不用占位星/占位球:次级节点用真实流程图数据,不伪造业务。
6
+ import { $, businessCard, el, fetchJson, pageTitle, renderEmptyWorkspaceParam, renderGuide, renderRepoLine, renderRepoState, setStatus, showError, workspaceParamEmpty, wsUrl } from './common.js'
7
+
8
+ const VIEW_KEY = 'archify-showcase-view'
9
+ const VIEWS = ['list', 'star', 'sphere']
10
+
11
+ function storedView() {
12
+ try {
13
+ const value = localStorage.getItem(VIEW_KEY)
14
+ return VIEWS.includes(value) ? value : 'list'
15
+ } catch {
16
+ return 'list'
17
+ }
18
+ }
19
+
20
+ function storeView(view) {
21
+ try {
22
+ localStorage.setItem(VIEW_KEY, view)
23
+ } catch {
24
+ // 存储受限(隐私模式等):只影响本次打开的默认格式,不报错。
25
+ }
26
+ }
27
+
28
+ const SVG_NS = 'http://www.w3.org/2000/svg'
29
+ function svgEl(tag, attrs = {}) {
30
+ const node = document.createElementNS(SVG_NS, tag)
31
+ for (const [key, value] of Object.entries(attrs)) node.setAttribute(key, value)
32
+ return node
33
+ }
34
+
35
+ /** 星图:主星按 id 排序后均布在大圆上(稳定、不重叠),次星(流程图)在主星外侧扇形散开,
36
+ * 连线只连真实归属(业务→它的图)。点主星进业务页,点次星进阅读页。 */
37
+ function buildStarMap(businesses) {
38
+ const svg = svgEl('svg', { viewBox: '0 0 1000 640', class: 'star-svg', role: 'img', 'aria-label': '业务星图' })
39
+ const sorted = [...businesses].sort((a, b) => a.id.localeCompare(b.id))
40
+ const cx = 500
41
+ const cy = 320
42
+ const radius = 225
43
+ sorted.forEach((business, index) => {
44
+ const angle = ((-90 + (index * 360) / sorted.length) * Math.PI) / 180
45
+ const bx = cx + radius * Math.cos(angle)
46
+ const by = cy + radius * Math.sin(angle)
47
+ const group = svgEl('g', { class: 'star-group' })
48
+ const charts = [...business.charts].sort((a, b) => a.id.localeCompare(b.id))
49
+ charts.forEach((chart, j) => {
50
+ const spread = ((j - (charts.length - 1) / 2) * 26 * Math.PI) / 180
51
+ const px = bx + 88 * Math.cos(angle + spread)
52
+ const py = by + 88 * Math.sin(angle + spread)
53
+ group.append(svgEl('line', { class: 'star-line', x1: bx, y1: by, x2: px, y2: py }))
54
+ const link = svgEl('a', { href: wsUrl(`/specdev-workbench/read/${encodeURIComponent(business.id)}/${encodeURIComponent(chart.id)}`) })
55
+ const title = svgEl('title')
56
+ title.textContent = chart.name
57
+ const dot = svgEl('circle', { class: 'star star-chart', cx: px, cy: py, r: 5, 'data-star': 'chart' })
58
+ link.append(title, dot)
59
+ group.append(link)
60
+ })
61
+ const bizLink = svgEl('a', { href: wsUrl(`/specdev-workbench/business/${encodeURIComponent(business.id)}`) })
62
+ const bizTitle = svgEl('title')
63
+ bizTitle.textContent = business.name
64
+ const glow = svgEl('circle', { class: 'star-glow', cx: bx, cy: by, r: 26 })
65
+ glow.style.animationDelay = `${index * 0.45}s`
66
+ const star = svgEl('circle', { class: 'star star-business', cx: bx, cy: by, r: 11, 'data-star': 'business', 'data-business': business.id })
67
+ // 名字放在主星靠圆心一侧:连线与次星都朝外,标签不会压到它们。
68
+ const label = svgEl('text', { class: 'star-label', x: bx - 42 * Math.cos(angle), y: by - 42 * Math.sin(angle), 'text-anchor': 'middle', 'dominant-baseline': 'middle' })
69
+ label.textContent = business.name
70
+ bizLink.append(bizTitle, glow, star, label)
71
+ group.append(bizLink)
72
+ svg.append(group)
73
+ })
74
+ return svg
75
+ }
76
+
77
+ /** 球阵:斐波那契球面均匀布点(按 key 排序,稳定),自转是自绘投影——深度决定大小、
78
+ * 透明度与层序。不做拖拽物理(小样的拖拽坐标坑不进产品),点球直接跳转。 */
79
+ function createSphere(box, nodes) {
80
+ const sorted = [...nodes].sort((a, b) => a.key.localeCompare(b.key))
81
+ const count = sorted.length
82
+ const els = sorted.map((node) => {
83
+ const link = el('a', 'sphere-node')
84
+ link.href = node.href
85
+ link.dataset.kind = node.kind
86
+ const dot = el('span', 'sphere-dot')
87
+ const label = el('span', 'sphere-label')
88
+ label.textContent = node.label
89
+ link.append(dot, label)
90
+ link.title = node.label
91
+ box.append(link)
92
+ return link
93
+ })
94
+ const golden = 2.399963229728653
95
+ const points = sorted.map((node, i) => {
96
+ const y = 1 - (2 * (i + 0.5)) / count
97
+ const ring = Math.sqrt(Math.max(0, 1 - y * y))
98
+ const theta = i * golden
99
+ return { x: Math.cos(theta) * ring, y, z: Math.sin(theta) * ring }
100
+ })
101
+ let angle = 0
102
+ let running = false
103
+ let handle = 0
104
+ let frames = 0
105
+ function project() {
106
+ const width = box.clientWidth || 960
107
+ const height = box.clientHeight || 520
108
+ const cx = width / 2
109
+ const cy = height / 2
110
+ const radius = Math.min(width, height) * 0.36
111
+ const cos = Math.cos(angle)
112
+ const sin = Math.sin(angle)
113
+ points.forEach((point, i) => {
114
+ const x = point.x * cos - point.z * sin
115
+ const z = point.x * sin + point.z * cos
116
+ const depth = (z + 1) / 2
117
+ const node = els[i]
118
+ node.style.left = `${(cx + x * radius).toFixed(1)}px`
119
+ node.style.top = `${(cy + point.y * radius * 0.92).toFixed(1)}px`
120
+ node.style.zIndex = String(Math.round(depth * 100))
121
+ node.style.opacity = (0.35 + depth * 0.65).toFixed(2)
122
+ node.style.transform = `translate(-50%, -50%) scale(${(0.55 + depth * 0.75).toFixed(3)})`
123
+ })
124
+ }
125
+ function loop() {
126
+ angle += 0.0045
127
+ project()
128
+ frames += 1
129
+ handle = requestAnimationFrame(loop)
130
+ }
131
+ function start() {
132
+ if (running || typeof requestAnimationFrame !== 'function') return
133
+ running = true
134
+ handle = requestAnimationFrame(loop)
135
+ }
136
+ function stop() {
137
+ if (!running) return
138
+ running = false
139
+ cancelAnimationFrame(handle)
140
+ }
141
+ project()
142
+ return {
143
+ start,
144
+ stop,
145
+ project,
146
+ isRunning: () => running,
147
+ frameCount: () => frames,
148
+ }
149
+ }
150
+
151
+ async function main() {
152
+ if (workspaceParamEmpty) return renderEmptyWorkspaceParam()
153
+ let inventory
154
+ try {
155
+ inventory = await fetchJson(wsUrl('/specdev-workbench/api/inventory'))
156
+ } catch (error) {
157
+ if (error.repo) renderRepoLine(error.repo)
158
+ if (!renderRepoState(error)) showError(error.message)
159
+ return
160
+ }
161
+ if (inventory.code === 'repo-not-configured') return renderGuide(inventory)
162
+ renderRepoLine(inventory.repo)
163
+ document.title = pageTitle('业务展示', inventory.repo)
164
+ $('intro').textContent = inventory.project.description || inventory.project.name
165
+ const businesses = inventory.businesses
166
+ setStatus(`共 ${businesses.length} 个业务`, 'ok')
167
+
168
+ const listView = $('viewList')
169
+ if (businesses.length === 0) {
170
+ const empty = el('div', 'empty-state')
171
+ const h = el('h3')
172
+ h.textContent = '还没有可展示的业务'
173
+ const p = el('p')
174
+ p.textContent = '项目清单里添加业务资料后,本页的列表、星图与球阵会一起亮起来。'
175
+ empty.append(h, p)
176
+ listView.append(empty)
177
+ return
178
+ }
179
+
180
+ for (const [index, business] of businesses.entries()) listView.append(businessCard(business, index))
181
+ $('starBox').append(buildStarMap(businesses))
182
+
183
+ const nodes = []
184
+ for (const business of businesses) {
185
+ nodes.push({ key: `b/${business.id}`, kind: 'business', label: business.name, href: wsUrl(`/specdev-workbench/business/${encodeURIComponent(business.id)}`) })
186
+ for (const chart of business.charts) {
187
+ nodes.push({ key: `c/${business.id}/${chart.id}`, kind: 'chart', label: chart.name, href: wsUrl(`/specdev-workbench/read/${encodeURIComponent(business.id)}/${encodeURIComponent(chart.id)}`) })
188
+ }
189
+ }
190
+ const sphere = createSphere($('sphereBox'), nodes)
191
+ const motionAllowed = !window.matchMedia('(prefers-reduced-motion: reduce)').matches
192
+
193
+ const switcher = $('viewSwitch')
194
+ const startButton = $('sphereStart')
195
+ let current = storedView()
196
+ function syncSphereButton() {
197
+ startButton.hidden = motionAllowed || current !== 'sphere' || sphere.isRunning()
198
+ }
199
+ function setView(view) {
200
+ current = view
201
+ for (const button of switcher.querySelectorAll('[data-view]')) {
202
+ button.setAttribute('aria-pressed', String(button.dataset.view === view))
203
+ }
204
+ $('viewList').hidden = view !== 'list'
205
+ $('viewStar').hidden = view !== 'star'
206
+ $('viewSphere').hidden = view !== 'sphere'
207
+ if (view === 'sphere') {
208
+ sphere.project()
209
+ if (motionAllowed) sphere.start()
210
+ } else {
211
+ sphere.stop() // 切走即停渲染(星图小样第 6 条口径)
212
+ }
213
+ syncSphereButton()
214
+ storeView(view)
215
+ }
216
+ switcher.querySelectorAll('[data-view]').forEach((button) => {
217
+ button.addEventListener('click', () => setView(button.dataset.view))
218
+ })
219
+ startButton.addEventListener('click', () => {
220
+ sphere.start()
221
+ syncSphereButton()
222
+ })
223
+ document.addEventListener('visibilitychange', () => {
224
+ if (document.hidden) sphere.stop()
225
+ else if (current === 'sphere' && motionAllowed) sphere.start()
226
+ syncSphereButton()
227
+ })
228
+ switcher.hidden = false
229
+ setView(current)
230
+ }
231
+
232
+ main()