@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,492 @@
1
+ import * as THREE from 'three';
2
+ import { createDoorHandle } from './door.js';
3
+
4
+ // 童话房间只负责外观:内部 8 × 6 × 3.4 米,所有装饰都跟随所属墙面。
5
+ // 主场景负责灯光、镜头和墙面剖看;这里不持有 renderer 的环境或输出设置。
6
+ export function createFairyRoom({ renderer, scene, reducedMotion = false, doorOpen } = {}) {
7
+ if (doorOpen !== undefined && typeof doorOpen !== 'boolean') {
8
+ throw new Error(`createFairyRoom 的 doorOpen 只接受 true、false 或不传,收到「${String(doorOpen)}」(${typeof doorOpen}),不能猜测门的初始状态。`);
9
+ }
10
+ // doorOpen 不传时保留历史展示角 π×0.58(算 isOpen=true);显式 true/false 才落到统一端点。
11
+ const doorAngle = doorOpen === undefined ? Math.PI * 0.58 : (doorOpen ? Math.PI / 2 : 0);
12
+ const group = new THREE.Group();
13
+ group.name = 'fairy-empty-room';
14
+ const owned = new Set();
15
+ const keep = (resource) => { owned.add(resource); return resource; };
16
+ const rng = seededRandom(92726);
17
+ const walls = [];
18
+ const nightMaterials = [];
19
+ const glowSprites = [];
20
+ const butterflyActors = [];
21
+ const fireflyActors = [];
22
+ let period = 'day';
23
+ let disposed = false;
24
+
25
+ const plasterMap = makeTexture(512, paintPlaster, keep, renderer);
26
+ const woodMap = makeTexture(512, paintWood, keep, renderer);
27
+ const plaster = keep(new THREE.MeshStandardMaterial({ color: '#e6d7b9', map: plasterMap, bumpMap: plasterMap, bumpScale: 0.028, roughness: 0.92 }));
28
+ const edgePlaster = keep(new THREE.MeshStandardMaterial({ color: '#d3c2a0', map: plasterMap, roughness: 0.93 }));
29
+ const timber = keep(new THREE.MeshStandardMaterial({ color: '#b88045', map: woodMap, roughness: 0.72 }));
30
+ const paleTimber = keep(new THREE.MeshStandardMaterial({ color: '#dfb675', map: woodMap, roughness: 0.78 }));
31
+ const bark = keep(new THREE.MeshStandardMaterial({ color: '#725435', roughness: 0.94 }));
32
+ const mortar = keep(new THREE.MeshStandardMaterial({ color: '#a99e80', roughness: 1 }));
33
+ const stoneColors = ['#d5c2a0', '#decbae', '#cbbf9f', '#d7c6ac', '#e0cbb0', '#c8b999'];
34
+ const stones = stoneColors.map(color => keep(new THREE.MeshStandardMaterial({ color, map: plasterMap, bumpMap: plasterMap, bumpScale: 0.012, roughness: 0.9 })));
35
+ const moss = keep(new THREE.MeshStandardMaterial({ color: '#657b45', roughness: 1 }));
36
+ const leafMaterials = ['#637f41', '#789546', '#a0ad58'].map(color => keep(new THREE.MeshStandardMaterial({ color, roughness: 0.9, side: THREE.DoubleSide })));
37
+ const tiles = ['#567c82', '#7687a7', '#9b7b99', '#b77565', '#86a58e'].map(color => keep(new THREE.MeshStandardMaterial({ color, roughness: 0.64, metalness: 0.03 })));
38
+ const brass = keep(new THREE.MeshStandardMaterial({ color: '#bc9650', metalness: 0.62, roughness: 0.36 }));
39
+ const cream = keep(new THREE.MeshStandardMaterial({ color: '#ecdbb0', roughness: 0.86 }));
40
+ const capMaterials = ['#b56e56', '#af696d', '#847b9b'].map(color => keep(new THREE.MeshStandardMaterial({ color, roughness: 0.58, metalness: 0.02 })));
41
+ const petalMaterials = ['#d89c94', '#d5bd78', '#9aa5c6'].map(color => keep(new THREE.MeshStandardMaterial({ color, roughness: 0.86, side: THREE.DoubleSide })));
42
+ const petalGeometry = keep(new THREE.SphereGeometry(1, 8, 5));
43
+ const leafGeometry = keep(makeLeafGeometry());
44
+ const mushroomCap = keep(makeMushroomGeometry());
45
+ const orbGeometry = keep(new THREE.SphereGeometry(1, 12, 8));
46
+ const glowTexture = makeTexture(128, paintGlow, keep, renderer);
47
+ const pollenGroup = new THREE.Group();
48
+ const fireflyGroup = new THREE.Group();
49
+ const butterflyGroup = new THREE.Group();
50
+ group.add(pollenGroup, fireflyGroup, butterflyGroup);
51
+
52
+ function mesh(geometry, material, parent, position, rotation) {
53
+ keep(geometry);
54
+ const item = new THREE.Mesh(geometry, material);
55
+ if (position) item.position.set(...position);
56
+ if (rotation) item.rotation.set(...rotation);
57
+ item.castShadow = true;
58
+ item.receiveShadow = true;
59
+ parent.add(item);
60
+ return item;
61
+ }
62
+
63
+ function box(w, h, d, material, parent, x, y, z, bevel = 0) {
64
+ const geo = bevel ? softBox(w, h, d, bevel) : new THREE.BoxGeometry(w, h, d);
65
+ return mesh(geo, material, parent, [x, y, z]);
66
+ }
67
+
68
+ function beam(parent, points, radius = 0.07, material = timber) {
69
+ const curve = new THREE.CatmullRomCurve3(points.map(p => new THREE.Vector3(...p)));
70
+ return mesh(new THREE.TubeGeometry(curve, Math.max(5, points.length * 4), radius, 8, false), material, parent);
71
+ }
72
+
73
+ // 内部地面保持水平;不规则轮廓仅存在于地台外缘。
74
+ const baseShape = new THREE.Shape();
75
+ const outline = [[-4.48,-3.48],[-2.95,-3.58],[-1.15,-3.51],[0.6,-3.6],[3.2,-3.52],[4.43,-3.39],[4.58,-1.2],[4.52,1.1],[4.41,3.47],[2.3,3.58],[-0.1,3.52],[-2.4,3.58],[-4.46,3.39],[-4.58,1.2],[-4.51,-1.3]];
76
+ outline.forEach(([x, z], i) => i ? baseShape.lineTo(x, z) : baseShape.moveTo(x, z));
77
+ baseShape.closePath();
78
+ const plinth = mesh(new THREE.ExtrudeGeometry(baseShape, { depth: 0.32, bevelEnabled: true, bevelSize: 0.08, bevelThickness: 0.055, bevelSegments: 2, steps: 1 }), edgePlaster, group, [0, -0.38, 0], [-Math.PI / 2, 0, 0]);
79
+ plinth.name = 'organic-exterior-plinth';
80
+ box(8.04, 0.11, 6.04, mortar, group, 0, -0.055, 0);
81
+
82
+ for (let row = 0; row < 8; row++) {
83
+ const rowWidth = row % 2 ? [0.5,1,1,1,1,1,1,1,0.5] : Array(8).fill(1);
84
+ let x = -4;
85
+ for (let c = 0; c < rowWidth.length; c++) {
86
+ const w = rowWidth[c];
87
+ const item = box(w - 0.023, 0.065, 0.728, stones[(row * 3 + c) % stones.length], group, x + w / 2, -0.029, -2.625 + row * 0.75, 0.018);
88
+ item.name = 'level-limestone-floor';
89
+ x += w;
90
+ }
91
+ }
92
+ // 门外的一小段门槛不侵占室内;门与地面同高,留给接入方继续延伸路径。
93
+ box(1.7, 0.14, 0.78, stones[1], group, 2.25, -0.066, -3.4, 0.035);
94
+
95
+ let doorHinge = null;
96
+ const wallDefs = [
97
+ { name: 'north-door-wall', width: 8.28, x: 0, z: -3.13, turn: 0, normal: [0,0,-1], door: true },
98
+ { name: 'east-wall', width: 6.28, x: 4.13, z: 0, turn: -Math.PI / 2, normal: [1,0,0], window: true },
99
+ { name: 'south-wall', width: 8.28, x: 0, z: 3.13, turn: Math.PI, normal: [0,0,1] },
100
+ { name: 'west-wall', width: 6.28, x: -4.13, z: 0, turn: Math.PI / 2, normal: [-1,0,0], window: true },
101
+ ];
102
+
103
+ for (let index = 0; index < wallDefs.length; index++) {
104
+ const def = wallDefs[index];
105
+ const wall = new THREE.Group();
106
+ wall.name = def.name;
107
+ wall.position.set(def.x, 0, def.z);
108
+ wall.rotation.y = def.turn;
109
+ group.add(wall);
110
+ walls.push({ group: wall, normal: new THREE.Vector3(...def.normal) });
111
+ const w = def.width;
112
+
113
+ if (def.door) {
114
+ const left = -w / 2, right = w / 2;
115
+ box(1.55 - left, 3.4, 0.26, plaster, wall, (left + 1.55) / 2, 1.7, 0);
116
+ box(right - 2.95, 3.4, 0.26, plaster, wall, (right + 2.95) / 2, 1.7, 0);
117
+ box(1.4, 0.85, 0.26, plaster, wall, 2.25, 2.975, 0);
118
+ doorHinge = addDoor(wall, doorAngle);
119
+ } else if (def.window) {
120
+ const shape = new THREE.Shape();
121
+ shape.moveTo(-w / 2, 0); shape.lineTo(w / 2, 0); shape.lineTo(w / 2, 3.4); shape.lineTo(-w / 2, 3.4); shape.closePath();
122
+ const opening = new THREE.Path();
123
+ opening.absarc(-0.68, 2.08, 0.52, 0, Math.PI * 2, true);
124
+ shape.holes.push(opening);
125
+ mesh(new THREE.ExtrudeGeometry(shape, { depth: 0.26, bevelEnabled: false, curveSegments: 32 }), plaster, wall, [0,0,-0.13]);
126
+ addWindow(wall, -0.68, 2.08);
127
+ } else box(w, 3.4, 0.26, plaster, wall, 0, 1.7, 0);
128
+
129
+ // 内侧收口平整,木梁的小幅弯曲只作为墙顶装饰,不改变房间容积。
130
+ beam(wall, [[-w/2,3.4,0],[-w/4,3.44,-0.01],[0,3.42,0],[w/4,3.46,-0.005],[w/2,3.41,0]], 0.125, paleTimber);
131
+ if (def.door) {
132
+ box(w/2 + 1.48, 0.12, 0.035, timber, wall, (-w/2+1.48)/2, 0.07, 0.145, 0.014);
133
+ box(w/2 - 3.02, 0.12, 0.035, timber, wall, (w/2+3.02)/2, 0.07, 0.145, 0.014);
134
+ } else box(w - 0.2, 0.12, 0.035, timber, wall, 0, 0.07, 0.145, 0.014);
135
+ for (const sign of [-1, 1]) {
136
+ beam(wall, [[sign*(w/2-0.1),0.04,-0.04],[sign*(w/2-0.12),1.1,-0.06],[sign*(w/2-0.16),2.2,-0.055],[sign*(w/2-0.1),3.48,-0.06]], 0.115, timber);
137
+ addMushroom(wall, sign*(w/2-0.27), 0.02, -0.35, 0.26 + rng()*0.12, index%3);
138
+ addMushroom(wall, sign*(w/2-0.51), 0.02, -0.3, 0.16 + rng()*0.07, (index+1)%3);
139
+ addFlowerClump(wall, sign*(w/2-0.7), -0.34, index%3);
140
+ }
141
+
142
+ // 外墙脚的低矮石块与苔藓;门洞宽度完全跳过。
143
+ for (let x = -w/2 + 0.23; x < w/2 - 0.1; x += 0.46) {
144
+ if (def.door && x > 1.28 && x < 3.22) continue;
145
+ const rock = box(0.4 + rng()*0.08, 0.17 + rng()*0.1, 0.17, stones[Math.floor(rng()*stones.length)], wall, x, 0.105, -0.21, 0.048);
146
+ rock.rotation.z = (rng()-0.5)*0.07;
147
+ if (rng()>0.38) {
148
+ const cushion = mesh(new THREE.SphereGeometry(1, 8, 5), moss, wall, [x,0.23,-0.22]);
149
+ cushion.scale.set(0.18+rng()*0.04,0.026,0.11);
150
+ }
151
+ }
152
+ // 藤条沿外墙两角向墙顶生长,最多绕进室内十几厘米,保持陈设墙面可用。
153
+ addVine(wall, [[-w/2+0.34,0.35,-0.22],[-w/2+0.46,1.2,-0.23],[-w/2+0.24,2.2,-0.24],[-w/2+0.58,3.32,-0.23],[-w/2+1.35,3.51,-0.03],[-w/2+2.1,3.39,0.03]], 0.96);
154
+ addVine(wall, [[w/2-0.26,1.15,-0.22],[w/2-0.45,2.1,-0.22],[w/2-0.33,2.92,-0.23],[w/2-0.75,3.44,-0.03],[w/2-1.45,3.5,0.02]], 0.85);
155
+ addVine(wall, [[-w/2+0.2,3.42,-0.08],[-w/4,3.55,-0.12],[0.15,3.47,-0.11],[w/4,3.57,-0.12],[w/2-0.16,3.43,-0.08]], 0.74);
156
+ if (!def.door) {
157
+ addMushroom(wall, -w/2+0.21, 1.38, -0.18, 0.25, 2, true);
158
+ addMushroom(wall, -w/2+0.25, 1.6, -0.19, 0.17, 2, true);
159
+ }
160
+ }
161
+
162
+ function addDoor(parent, doorAngle) {
163
+ for (const x of [1.45,3.05]) beam(parent, [[x,0.02,0.005],[x-0.008,1.3,0.012],[x,2.65,0]], 0.08, paleTimber);
164
+ beam(parent, [[1.4,2.65,0],[1.86,2.67,0.01],[2.4,2.66,0],[3.1,2.65,0]], 0.085, paleTimber);
165
+ const hinge = new THREE.Group();
166
+ hinge.name = 'outward-open-door';
167
+ hinge.position.set(1.55,0,-0.18);
168
+ hinge.rotation.y = doorAngle;
169
+ parent.add(hinge);
170
+ // 门板/横梁/门环是活动部件,随 hinge 开合转动;命名供门接口与后续拾取识别。
171
+ for(let i=0;i<6;i++) { const plank = box(0.221,2.48,0.074, i%2 ? timber : paleTimber, hinge, 0.112+i*0.227,1.255,0,0.016); plank.name = 'door-plank'; }
172
+ for(const y of [0.38,1.92]) { const cross = box(1.34,0.115,0.075,timber,hinge,0.68,y,0.057,0.014); cross.name = 'door-crossbeam'; }
173
+ const ring = mesh(new THREE.TorusGeometry(0.065,0.013,7,18),brass,hinge,[1.12,1.19,0.068]);
174
+ ring.name = 'door-ring';
175
+ ring.rotation.x = 0.15;
176
+ const pin = mesh(new THREE.SphereGeometry(0.032,10,6),brass,hinge,[1.12,1.26,0.068]);
177
+ pin.name = 'door-ring-pin';
178
+ // 门檐采用成排弧形彩釉瓦,清晰的小尺度手工轮廓。
179
+ for (const sign of [-1,1]) {
180
+ const slope = new THREE.Group();
181
+ slope.position.set(2.25+sign*0.56,2.89,-0.5);
182
+ slope.rotation.z = -sign*0.38;
183
+ parent.add(slope);
184
+ box(1.27,0.08,0.96,timber,slope,0,0,0,0.028);
185
+ for (let r=0;r<4;r++) for(let c=0;c<4;c++) {
186
+ const tile = mesh(softBox(0.325,0.052,0.27,0.035),tiles[(r+c+(sign>0?2:0))%tiles.length],slope,[-0.46+c*0.31,0.073+r*0.006,-0.35+r*0.225]);
187
+ tile.rotation.x = -0.09;
188
+ }
189
+ }
190
+ beam(parent, [[2.25,3.13,0.03],[2.25,3.14,-0.5],[2.25,3.09,-1.03]],0.073,paleTimber);
191
+ for(const sign of [-1,1]) beam(parent, [[2.25+sign*1.14,2.64,-0.08],[2.25+sign*1.04,2.68,-0.58],[2.25+sign*1.14,2.61,-1.02]],0.056,paleTimber);
192
+ addLantern(parent,1.17,2.25,-0.37);
193
+ return hinge;
194
+ }
195
+
196
+ function addWindow(parent, x, y) {
197
+ for(const z of [-0.17,0.17]) mesh(new THREE.TorusGeometry(0.53,0.066,10,48),paleTimber,parent,[x,y,z]);
198
+ const glass = keep(new THREE.MeshPhysicalMaterial({ color:'#d8cda6', transparent:true, opacity:0.46, roughness:0.16, metalness:0.04, emissive:'#dfad62', emissiveIntensity:0.08, side:THREE.DoubleSide, depthWrite:false }));
199
+ nightMaterials.push({ material:glass, day:0.08, night:0.85 });
200
+ mesh(new THREE.CircleGeometry(0.515,48),glass,parent,[x,y,-0.025]);
201
+ box(0.043,1.02,0.065,paleTimber,parent,x,y,0.034,0.012);
202
+ box(1.02,0.043,0.065,paleTimber,parent,x,y,0.034,0.012);
203
+ box(1.24,0.105,0.4,paleTimber,parent,x,y-0.58,-0.13,0.034);
204
+ addVine(parent,[[x-0.79,y-0.52,-0.19],[x-0.84,y+0.06,-0.2],[x-0.51,y+0.58,-0.2],[x+0.05,y+0.73,-0.2],[x+0.67,y+0.36,-0.21]],0.65);
205
+ }
206
+
207
+ function addLantern(parent,x,y,z) {
208
+ beam(parent,[[x,y+0.29,-0.15],[x,y+0.36,z-0.02],[x,y+0.25,z-0.05]],0.018,brass);
209
+ const glass = keep(new THREE.MeshStandardMaterial({color:'#ffd497',emissive:'#ffbf63',emissiveIntensity:0.18,roughness:0.35}));
210
+ nightMaterials.push({material:glass,day:0.18,night:3.5});
211
+ mesh(new THREE.CylinderGeometry(0.06,0.075,0.21,6),glass,parent,[x,y,z]);
212
+ mesh(new THREE.ConeGeometry(0.12,0.1,6),brass,parent,[x,y+0.145,z]);
213
+ mesh(new THREE.CylinderGeometry(0.105,0.065,0.05,6),brass,parent,[x,y-0.13,z]);
214
+ for(let a=0;a<4;a++) {
215
+ const angle=a*Math.PI/2;
216
+ box(0.012,0.245,0.012,brass,parent,x+Math.cos(angle)*0.072,y,z+Math.sin(angle)*0.072);
217
+ }
218
+ const glow = addGlow(parent,x,y,z,0.68,'#ffb66b',0.27);
219
+ glowSprites.push(glow);
220
+ const light = new THREE.PointLight('#ffc579',0,2.4,2);
221
+ light.position.set(x,y,z-0.11); parent.add(light);
222
+ light.userData.nightIntensity=1.25;
223
+ nightMaterials.push({light});
224
+ }
225
+
226
+ function addVine(parent, points, leafSize) {
227
+ const curve=new THREE.CatmullRomCurve3(points.map(p=>new THREE.Vector3(...p)));
228
+ mesh(new THREE.TubeGeometry(curve,40,0.012,5,false),bark,parent);
229
+ const counts=30;
230
+ const instanceSets=leafMaterials.map(mat=>{
231
+ const inst=new THREE.InstancedMesh(leafGeometry,mat,counts);
232
+ inst.castShadow=true; inst.receiveShadow=true; parent.add(inst); return inst;
233
+ });
234
+ const transform=new THREE.Object3D();
235
+ const materialCount=[0,0,0];
236
+ for(let i=0;i<counts;i++) {
237
+ const t=(i+0.2)/counts;
238
+ const p=curve.getPoint(t);
239
+ const side=i%2?-1:1;
240
+ const type=i%3;
241
+ const size=(0.085+rng()*0.048)*leafSize;
242
+ transform.position.copy(p).add(new THREE.Vector3(side*size*0.65,0.008,-0.035));
243
+ transform.rotation.set((rng()-0.5)*0.7,Math.PI+(rng()-0.5)*0.6,side*(0.65+rng()*0.45));
244
+ transform.scale.set(size*0.79,size*1.65,size);
245
+ transform.updateMatrix();
246
+ instanceSets[type].setMatrixAt(materialCount[type]++,transform.matrix);
247
+ }
248
+ instanceSets.forEach((inst,i)=>{inst.count=materialCount[i];inst.instanceMatrix.needsUpdate=true;});
249
+ }
250
+
251
+ function addMushroom(parent,x,y,z,size,type=0,bracket=false) {
252
+ const item=new THREE.Group(); item.position.set(x,y,z); parent.add(item);
253
+ const stemHeight=bracket ? size*0.2 : size*1.15;
254
+ if(!bracket) beam(item,[[0,0,0],[-size*0.08,stemHeight*0.55,0],[size*0.05,stemHeight,0]],size*0.15,cream);
255
+ const cap=mesh(mushroomCap,capMaterials[type],item,[0,stemHeight,0]);
256
+ cap.scale.set(size,size*0.76,bracket?size*0.69:size);
257
+ cap.rotation.z=(rng()-0.5)*0.18;
258
+ const under=mesh(new THREE.CylinderGeometry(size*0.91,size*0.82,size*0.045,24),cream,item,[0,stemHeight+size*0.035,0]);
259
+ if(bracket) under.scale.z=0.69;
260
+ for(let k=0;k<7;k++) {
261
+ const a=k*2.399+type;
262
+ const radius=(0.22+(k%3)*0.22)*size;
263
+ const px=Math.cos(a)*radius,pz=Math.sin(a)*radius;
264
+ const spot=mesh(orbGeometry,cream,item,[px,stemHeight+size*(0.32-Math.pow(radius/size,2)*0.22),pz*(bracket?0.69:1)]);
265
+ spot.scale.set(size*0.055,size*0.018,size*0.045);
266
+ }
267
+ if(!bracket&&size>0.3) {
268
+ const glow=addGlow(item,0,stemHeight*0.92,-size*0.03,size*1.8,'#ffd98b',0.2);
269
+ glowSprites.push(glow);
270
+ }
271
+ }
272
+
273
+ function addFlowerClump(parent,x,z,type) {
274
+ for(let i=0;i<5;i++) {
275
+ const px=x+(rng()-0.5)*0.3,pz=z+(rng()-0.5)*0.12;
276
+ const h=0.15+rng()*0.18;
277
+ beam(parent,[[px,0.025,pz],[px+0.025,h*0.6,pz],[px+0.012,h,pz]],0.004,leafMaterials[0]);
278
+ for(let j=0;j<5;j++) {
279
+ const a=j*Math.PI*2/5;
280
+ const petal=mesh(petalGeometry,petalMaterials[type],parent,[px+0.012+Math.cos(a)*0.033,h+Math.sin(a)*0.025,pz-0.008]);
281
+ petal.scale.set(0.027,0.018,0.01);petal.rotation.z=a;
282
+ }
283
+ const core=mesh(orbGeometry,cream,parent,[px+0.012,h,pz-0.021]);core.scale.setScalar(0.012);
284
+ }
285
+ }
286
+
287
+ function addGlow(parent,x,y,z,size,color,opacity) {
288
+ const mat=keep(new THREE.SpriteMaterial({map:glowTexture,color,transparent:true,opacity,depthWrite:false,blending:THREE.AdditiveBlending,toneMapped:false}));
289
+ const sprite=new THREE.Sprite(mat);sprite.position.set(x,y,z);sprite.scale.setScalar(size);parent.add(sprite);return sprite;
290
+ }
291
+
292
+ // 白天花粉是细小的慢动微粒;蝴蝶翼展约 12 厘米。
293
+ const pollenGeometry=keep(new THREE.BufferGeometry());
294
+ const pollenPositions=new Float32Array(54*3);
295
+ for(let i=0;i<54;i++) {pollenPositions[i*3]=(rng()-0.5)*10;pollenPositions[i*3+1]=0.3+rng()*4;pollenPositions[i*3+2]=(rng()-0.5)*8;}
296
+ pollenGeometry.setAttribute('position',new THREE.BufferAttribute(pollenPositions,3));
297
+ const pollenMaterial=keep(new THREE.PointsMaterial({color:'#fff0c3',map:glowTexture,size:0.037,transparent:true,opacity:0.53,depthWrite:false,blending:THREE.AdditiveBlending,sizeAttenuation:true}));
298
+ const pollen=new THREE.Points(pollenGeometry,pollenMaterial);pollenGroup.add(pollen);
299
+
300
+ const butterflyWing=keep(makeButterflyWing());
301
+ const butterflyMaterials=['#e2c67c','#bfd8ca','#d6a2ae'].map(color=>keep(new THREE.MeshStandardMaterial({color,roughness:0.6,side:THREE.DoubleSide,emissive:color,emissiveIntensity:0.08})));
302
+ for(let i=0;i<7;i++) {
303
+ const body=new THREE.Group();butterflyGroup.add(body);
304
+ const left=new THREE.Group(),right=new THREE.Group();body.add(left,right);
305
+ const wingL=mesh(butterflyWing,butterflyMaterials[i%3],left);wingL.scale.set(0.072,0.072,0.072);
306
+ const wingR=mesh(butterflyWing,butterflyMaterials[i%3],right);wingR.scale.set(-0.072,0.072,0.072);
307
+ const center=mesh(new THREE.CapsuleGeometry(0.005,0.035,2,5),bark,body);center.rotation.x=Math.PI/2;
308
+ butterflyActors.push({body,left,right,phase:rng()*6.28,base:new THREE.Vector3((rng()-0.5)*7.3,0.75+rng()*2.4,(rng()-0.5)*6.3)});
309
+ }
310
+ for(let i=0;i<38;i++) {
311
+ const side=i%4;
312
+ const p=new THREE.Vector3(side<2?(rng()-0.5)*8.9:(side===2?-4.35:4.35),0.3+rng()*3.55,side<2?(side===0?-3.42:3.42):(rng()-0.5)*6.8);
313
+ const sprite=addGlow(fireflyGroup,p.x,p.y,p.z,0.075+rng()*0.06,i%4===0?'#bae5b7':'#ffe8a4',0.72);
314
+ fireflyActors.push({sprite,base:p,phase:rng()*6.28,rate:0.3+rng()*0.5});
315
+ }
316
+
317
+ function setPeriod(next) {
318
+ period=next==='night'?'night':'day';
319
+ const night=period==='night';
320
+ pollenGroup.visible=!night;
321
+ butterflyGroup.visible=!night;
322
+ fireflyGroup.visible=night;
323
+ for(const glow of glowSprites) glow.visible=night;
324
+ for(const entry of nightMaterials) {
325
+ if(entry.light) entry.light.intensity=night?entry.light.userData.nightIntensity:0;
326
+ else entry.material.emissiveIntensity=night?entry.night:entry.day;
327
+ }
328
+ }
329
+
330
+ function update(time = 0) {
331
+ if(disposed)return;
332
+ const t=reducedMotion?0:time;
333
+ pollen.position.y=Math.sin(t*0.13)*0.15;
334
+ pollen.rotation.y=Math.sin(t*0.045)*0.07;
335
+ for(const a of butterflyActors) {
336
+ const q=t*0.32+a.phase;
337
+ a.body.position.copy(a.base).add(new THREE.Vector3(Math.sin(q)*0.44,Math.sin(q*1.7)*0.17,Math.cos(q*0.83)*0.33));
338
+ a.body.rotation.set(0.16,Math.atan2(Math.cos(q),-Math.sin(q*0.83)),Math.sin(q*2)*0.14);
339
+ const flap=reducedMotion?0.4:0.25+Math.sin(t*11+a.phase)*0.64;
340
+ a.left.rotation.z=flap;a.right.rotation.z=-flap;
341
+ }
342
+ for(const a of fireflyActors) {
343
+ const q=t*a.rate+a.phase;
344
+ a.sprite.position.copy(a.base).add(new THREE.Vector3(Math.sin(q)*0.22,Math.sin(q*0.7)*0.16,Math.cos(q*0.9)*0.19));
345
+ a.sprite.material.opacity=reducedMotion?0.65:0.25+(Math.sin(q*2)+1)*0.3;
346
+ }
347
+ }
348
+
349
+ function dispose() {
350
+ if(disposed)return;
351
+ disposed=true;
352
+ group.removeFromParent();
353
+ group.traverse(node=>{if(node.isInstancedMesh)node.dispose();});
354
+ for(const resource of owned)resource.dispose();
355
+ owned.clear();
356
+ group.clear();
357
+ }
358
+
359
+ // 静态细节按墙和材质合批,避免每片花瓣、每块小石都占一次绘制调用。
360
+ // 各面墙仍是独立整体,灯、发光精灵和实例叶片不参与合并;门扇活动子树也跳过,
361
+ // 否则门板会被烘进墙体,失去独立开合能力。
362
+ group.updateMatrixWorld(true);
363
+ for(const wall of walls) mergeStaticMeshes(wall.group,keep,true,doorHinge);
364
+ mergeStaticMeshes(group,keep,false,doorHinge);
365
+ setPeriod('day');
366
+ update(0);
367
+ return {
368
+ group,walls,door:createDoorHandle(doorHinge),update,setPeriod,dispose,
369
+ meta:{
370
+ id:'room-fairy-woodland-v1',name:'林间微光',style:'fairy',
371
+ dimensions:{width:8,depth:6,height:3.4},
372
+ door:{wall:'north',centerX:2.25,width:1.4,height:2.55,position:[2.25,0,-3],opens:'outward'},
373
+ floorBounds:{minX:-4,maxX:4,minZ:-3,maxZ:3,y:0},
374
+ periods:['day','night'],furniture:[],
375
+ description:'奶油灰泥、蜂蜜木梁与浅色石地;墙外藤蔓、彩瓦门檐和小蘑菇。白昼蝶与花粉,夜晚萤火与暖光。'
376
+ }
377
+ };
378
+ }
379
+
380
+ function mergeStaticMeshes(parent,keep,recursive,exclude) {
381
+ const batches=new Map();
382
+ const inExcluded=node=>{ for(let p=node;p;p=p.parent) if(p===exclude) return true; return false; };
383
+ const select=node=>{
384
+ if(exclude&&inExcluded(node))return;
385
+ if(!node.isMesh||node.isInstancedMesh||Array.isArray(node.material))return;
386
+ const list=batches.get(node.material)||[];list.push(node);batches.set(node.material,list);
387
+ };
388
+ if(recursive)parent.traverse(select);else parent.children.forEach(select);
389
+ const inverse=new THREE.Matrix4().copy(parent.matrixWorld).invert();
390
+ const position=new THREE.Vector3(),normal=new THREE.Vector3();
391
+ for(const [material,meshes] of batches) {
392
+ if(meshes.length<2)continue;
393
+ const total=meshes.reduce((sum,item)=>sum+(item.geometry.index?.count||item.geometry.attributes.position.count),0);
394
+ const positions=new Float32Array(total*3),normals=new Float32Array(total*3),uvs=new Float32Array(total*2);
395
+ let offset=0;
396
+ for(const item of meshes) {
397
+ const geo=item.geometry,idx=geo.index;
398
+ const p=geo.attributes.position,n=geo.attributes.normal,uv=geo.attributes.uv;
399
+ const matrix=new THREE.Matrix4().multiplyMatrices(inverse,item.matrixWorld);
400
+ const normalMatrix=new THREE.Matrix3().getNormalMatrix(matrix);
401
+ const count=idx?.count||p.count;
402
+ for(let i=0;i<count;i++) {
403
+ const k=idx?idx.getX(i):i;
404
+ position.fromBufferAttribute(p,k).applyMatrix4(matrix);position.toArray(positions,(offset+i)*3);
405
+ if(n)normal.fromBufferAttribute(n,k).applyNormalMatrix(normalMatrix);else normal.set(0,1,0);
406
+ normal.toArray(normals,(offset+i)*3);
407
+ if(uv){uvs[(offset+i)*2]=uv.getX(k);uvs[(offset+i)*2+1]=uv.getY(k);}
408
+ }
409
+ offset+=count;item.removeFromParent();
410
+ }
411
+ const geo=keep(new THREE.BufferGeometry());
412
+ geo.setAttribute('position',new THREE.BufferAttribute(positions,3));geo.setAttribute('normal',new THREE.BufferAttribute(normals,3));geo.setAttribute('uv',new THREE.BufferAttribute(uvs,2));
413
+ geo.computeBoundingSphere();
414
+ const combined=new THREE.Mesh(geo,material);combined.name='static-material-batch';combined.castShadow=true;combined.receiveShadow=true;parent.add(combined);
415
+ }
416
+ }
417
+
418
+ function seededRandom(seed) {
419
+ return()=>{seed|=0;seed=seed+0x6D2B79F5|0;let n=Math.imul(seed^seed>>>15,1|seed);n=n+Math.imul(n^n>>>7,61|n)^n;return((n^n>>>14)>>>0)/4294967296;};
420
+ }
421
+
422
+ function makeTexture(size,paint,keep,renderer) {
423
+ if(typeof document==='undefined')return null;
424
+ const canvas=document.createElement('canvas');canvas.width=canvas.height=size;
425
+ const ctx=canvas.getContext('2d');if(!ctx)return null;
426
+ paint(ctx,size);
427
+ const texture=keep(new THREE.CanvasTexture(canvas));
428
+ texture.colorSpace=THREE.SRGBColorSpace;
429
+ texture.wrapS=texture.wrapT=THREE.RepeatWrapping;
430
+ texture.anisotropy=Math.min(4,renderer?.capabilities?.getMaxAnisotropy?.()||1);
431
+ return texture;
432
+ }
433
+
434
+ function paintPlaster(ctx,size) {
435
+ const rng=seededRandom(8713);
436
+ ctx.fillStyle='#e8e1cd';ctx.fillRect(0,0,size,size);
437
+ for(let i=0;i<18000;i++) {
438
+ const v=Math.floor(125+rng()*95);
439
+ ctx.fillStyle=`rgba(${v},${v-4},${v-9},${0.025+rng()*0.08})`;
440
+ const s=0.3+rng()*1.5;ctx.fillRect(rng()*size,rng()*size,s,s);
441
+ }
442
+ for(let i=0;i<130;i++) {
443
+ ctx.strokeStyle='rgba(255,252,235,0.035)';ctx.lineWidth=3+rng()*15;
444
+ ctx.beginPath();const y=rng()*size;ctx.moveTo(-30,y);ctx.bezierCurveTo(size*.3,y-12,size*.7,y+13,size+30,y+2);ctx.stroke();
445
+ }
446
+ }
447
+
448
+ function paintWood(ctx,size) {
449
+ const rng=seededRandom(4711);ctx.fillStyle='#d9bd8b';ctx.fillRect(0,0,size,size);
450
+ for(let i=0;i<240;i++) {
451
+ const x=rng()*size;
452
+ ctx.strokeStyle=i%4?'rgba(101,69,32,0.07)':'rgba(255,235,190,0.14)';ctx.lineWidth=0.4+rng()*1.6;
453
+ ctx.beginPath();ctx.moveTo(x,-10);ctx.bezierCurveTo(x+Math.sin(x)*9,size*.3,x-5,size*.7,x+2,size+10);ctx.stroke();
454
+ }
455
+ for(let i=0;i<5;i++) {
456
+ const x=40+rng()*(size-80),y=40+rng()*(size-80);
457
+ for(let j=0;j<6;j++) {ctx.strokeStyle='rgba(101,65,30,0.06)';ctx.lineWidth=1;ctx.beginPath();ctx.ellipse(x,y,3+j*2.2,8+j*7.6,0,0,Math.PI*2);ctx.stroke();}
458
+ }
459
+ }
460
+
461
+ function paintGlow(ctx,size) {
462
+ const g=ctx.createRadialGradient(size/2,size/2,0,size/2,size/2,size/2);
463
+ g.addColorStop(0,'rgba(255,255,245,1)');g.addColorStop(0.08,'rgba(255,252,228,0.9)');g.addColorStop(0.23,'rgba(255,247,214,0.25)');g.addColorStop(0.55,'rgba(255,241,211,0.055)');g.addColorStop(1,'rgba(255,235,193,0)');
464
+ ctx.fillStyle=g;ctx.fillRect(0,0,size,size);
465
+ }
466
+
467
+ function softBox(w,h,d,r) {
468
+ r=Math.min(r,w/3,h/3,d/3);
469
+ const x=-w/2+r,y=-d/2+r,ww=w-2*r,hh=d-2*r;
470
+ const shape=new THREE.Shape();
471
+ shape.moveTo(x+r,y);shape.lineTo(x+ww-r,y);shape.quadraticCurveTo(x+ww,y,x+ww,y+r);
472
+ shape.lineTo(x+ww,y+hh-r);shape.quadraticCurveTo(x+ww,y+hh,x+ww-r,y+hh);
473
+ shape.lineTo(x+r,y+hh);shape.quadraticCurveTo(x,y+hh,x,y+hh-r);shape.lineTo(x,y+r);shape.quadraticCurveTo(x,y,x+r,y);
474
+ const geo=new THREE.ExtrudeGeometry(shape,{depth:Math.max(0.001,h-2*r),bevelEnabled:true,bevelSize:r,bevelThickness:r,bevelSegments:2,curveSegments:3,steps:1});
475
+ geo.rotateX(-Math.PI/2);geo.translate(0,-(h-2*r)/2,0);return geo;
476
+ }
477
+
478
+ function makeLeafGeometry() {
479
+ const geo=new THREE.BufferGeometry();
480
+ geo.setAttribute('position',new THREE.Float32BufferAttribute([0,-0.45,0, -.55,0,0, 0,0.24,0.17, .55,0,0, 0,0.9,0],3));
481
+ geo.setAttribute('uv',new THREE.Float32BufferAttribute([.5,0,0,.4,.5,.6,1,.4,.5,1],2));
482
+ geo.setIndex([0,1,2,0,2,3,1,4,2,2,4,3]);geo.computeVertexNormals();return geo;
483
+ }
484
+
485
+ function makeMushroomGeometry() {
486
+ return new THREE.LatheGeometry([new THREE.Vector2(0,0.34),new THREE.Vector2(.28,.32),new THREE.Vector2(.6,.25),new THREE.Vector2(.85,.14),new THREE.Vector2(.99,.035),new THREE.Vector2(1,0),new THREE.Vector2(.89,-.035),new THREE.Vector2(.28,-.05)],32);
487
+ }
488
+
489
+ function makeButterflyWing() {
490
+ const shape=new THREE.Shape();shape.moveTo(0,0);shape.bezierCurveTo(.28,.82,1.15,1.14,.92,.35);shape.bezierCurveTo(.8,.02,.39,.12,.18,0);shape.bezierCurveTo(.67,-.07,.87,-.56,.37,-.58);shape.bezierCurveTo(.09,-.48,.13,-.2,0,0);
491
+ const geo=new THREE.ShapeGeometry(shape,12);geo.rotateX(-Math.PI/2);return geo;
492
+ }
@@ -0,0 +1,113 @@
1
+ // 家具批次准备(E4b-2):把已通过 validatePlacement 的 assembly 逐件变成真实模型组。
2
+ // 设计前提是"先异步准备家具批次,再同步 createRoomScene"——WebGL 场景工厂保持同步,
3
+ // renderer/输入不会在等待模型期间半初始化。本模块只消费已通过校验的 assembly:
4
+ // 不重新推导摆放、不读 JSON、不访问业务资料;占位分发表用 Map(原型键不算工厂),
5
+ // 正式模型经真实 createFurnitureModel(registry 的固定字面量动态 import)创建。
6
+ // 释放纪律:占位组用适配句柄释放去重后的几何/材质/纹理;正式家具用其自带 dispose,
7
+ // 禁止再遍历正式模型重复释放;任一件失败释放此前全部句柄,不交付半批次。
8
+ import * as THREE from 'three'
9
+ import { createPlaceholderFurniture, createPlaceholderLamp } from './placeholder-furniture.js'
10
+ import { createFurnitureModel, hasFurnitureModel } from '../furniture/registry.js'
11
+
12
+ /** 三个占位 modelRef → 现有工厂;其余引用一律走正式注册表,不做风格/名称猜测。 */
13
+ const PLACEHOLDER_FACTORIES = new Map([
14
+ ['placeholder-box-desk', (size) => createPlaceholderFurniture(size)],
15
+ ['placeholder-box-shelf', (size) => createPlaceholderFurniture(size)],
16
+ ['placeholder-box-lamp', (size) => createPlaceholderLamp(size)],
17
+ ])
18
+
19
+ /** 从实际命中子节点向上找到带实例身份的家具根组(模型子节点不获得独立业务身份);找不到返回 null。 */
20
+ export function findFurnitureInstance(object, furnitureRoot) {
21
+ for (let node = object; node && node !== furnitureRoot; node = node.parent) {
22
+ if (node.userData?.instanceId) return node
23
+ }
24
+ return null
25
+ }
26
+
27
+ /** 占位组适配句柄:释放去重后的几何/材质/全部纹理槽位(不只 map),幂等。 */
28
+ function makePlaceholderHandle(modelGroup) {
29
+ let disposed = false
30
+ return {
31
+ group: modelGroup,
32
+ dispose() {
33
+ if (disposed) return
34
+ disposed = true
35
+ modelGroup.removeFromParent()
36
+ const resources = new Set()
37
+ modelGroup.traverse((node) => {
38
+ if (node.geometry) resources.add(node.geometry)
39
+ const material = node.material
40
+ if (material) {
41
+ for (const item of Array.isArray(material) ? material : [material]) {
42
+ resources.add(item)
43
+ for (const key of Object.keys(item)) {
44
+ if (item[key]?.isTexture) resources.add(item[key])
45
+ }
46
+ }
47
+ }
48
+ })
49
+ for (const resource of resources) resource.dispose?.()
50
+ modelGroup.clear()
51
+ },
52
+ }
53
+ }
54
+
55
+ /**
56
+ * 按 assembly 顺序逐件创建模型(当前规模逐件 await,不做并发),返回 { group, dispose }。
57
+ * group 内每件实例组带 userData{instanceId,assetId,name},位置/朝向直接用 assembly 的
58
+ * 世界坐标(桌面子件已是世界坐标,不挂桌子二次变换),根缩放保持 1。
59
+ * options.isActive(默认恒 true):每次创建前、await 返回后、整批返回前检查;变 false
60
+ * 时释放已有与刚返回的句柄,以 name='AbortError' 的错误结束——动态 import 无法取消,
61
+ * 晚到句柄也必须释放。dispose 幂等:移除批次组、释放全部持有句柄并清空记录。
62
+ */
63
+ export async function prepareRoomFurniture(assembly, { isActive = () => true } = {}) {
64
+ const group = new THREE.Group()
65
+ group.name = 'room-furniture'
66
+ const handles = []
67
+ const stop = () => {
68
+ const error = new Error('页面已离开,家具准备中止')
69
+ error.name = 'AbortError'
70
+ return error
71
+ }
72
+ let batchDisposed = false
73
+ const releaseAll = () => {
74
+ if (batchDisposed) return
75
+ batchDisposed = true
76
+ group.removeFromParent()
77
+ for (let index = handles.length - 1; index >= 0; index -= 1) {
78
+ const handle = handles[index]
79
+ try { handle.dispose() } catch { /* 释放尽力而为,单件失败不中断其余清理 */ }
80
+ }
81
+ handles.length = 0
82
+ group.clear()
83
+ }
84
+ try {
85
+ for (const item of assembly?.instances ?? []) {
86
+ if (!isActive()) throw stop()
87
+ const modelRef = item.mesh?.modelRef
88
+ let handle
89
+ if (typeof modelRef === 'string' && PLACEHOLDER_FACTORIES.has(modelRef)) {
90
+ const { width, height, depth } = item.mesh
91
+ handle = makePlaceholderHandle(PLACEHOLDER_FACTORIES.get(modelRef)({ width, height, depth }))
92
+ } else if (typeof modelRef === 'string' && hasFurnitureModel(modelRef)) {
93
+ handle = await createFurnitureModel(modelRef) // 每次调用创建独立实例,两件同资产互不共享
94
+ } else {
95
+ throw new Error(`家具「${item.instanceId}」引用了未登记的模型:${String(modelRef)}——不回退占位模型`)
96
+ }
97
+ handles.push(handle) // 一创建成功就登记所有权;位姿设置或后续创建失败也能释放
98
+ if (!isActive()) throw stop()
99
+ handle.group.position.set(item.position.x, item.position.y ?? 0, item.position.z)
100
+ handle.group.rotation.y = (item.yawDeg * Math.PI) / 180
101
+ handle.group.userData = { instanceId: item.instanceId, assetId: item.assetId, name: item.name }
102
+ group.add(handle.group)
103
+ }
104
+ if (!isActive()) throw stop()
105
+ return {
106
+ group,
107
+ dispose: releaseAll,
108
+ }
109
+ } catch (error) {
110
+ releaseAll()
111
+ throw error
112
+ }
113
+ }