@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,1157 @@
1
+ // 外观构件工具箱:把 building-plan.js 的规划与 present.parts 变成 Three.js 网格。
2
+ //
3
+ // 设计取舍(详见文档):
4
+ // · 连续感来自"世界坐标贴图":所有构件的 UV 按世界坐标(米)投影,
5
+ // 同一种材质在相邻构件之间纹理连续,不会一段一段地重新起头;
6
+ // · 倒角只倒整块构件(ExtrudeGeometry 的 1 段斜角),整面墙一次倒角,洞口得到斜面窗口;
7
+ // · 贴图全部在运行时用 canvas 程序化生成(确定性随机种子),离线可用,不引入任何图片资源;
8
+ // · 细节贴图只做"明度调制",底色一律来自数据颜色——换 style 或换状态色,颜色含义不丢。
9
+ // 所有 GPU 资源都经 own() 登记,由场景在换建筑、销毁时逐项 dispose。
10
+ import * as THREE from 'three'
11
+ import { colorRole, mixHex, shadeHex, normalizeHex } from './building-look.js'
12
+
13
+ // ── 确定性随机与可平铺噪声 ─────────────────────────────────────────
14
+
15
+ function mulberry32(seed) {
16
+ let a = seed >>> 0
17
+ return () => {
18
+ a = (a + 0x6d2b79f5) >>> 0
19
+ let t = a
20
+ t = Math.imul(t ^ (t >>> 15), t | 1)
21
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
22
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
23
+ }
24
+ }
25
+
26
+ /** 可平铺值噪声:u, v ∈ [0,1) 周期,gx×gy 个格点。 */
27
+ function tileNoise(rand, gx, gy = gx) {
28
+ const grid = new Float32Array(gx * gy)
29
+ for (let i = 0; i < grid.length; i += 1) grid[i] = rand()
30
+ const at = (i, j) => grid[((j % gy + gy) % gy) * gx + ((i % gx + gx) % gx)]
31
+ const smooth = (t) => t * t * (3 - 2 * t)
32
+ return (u, v) => {
33
+ const x = u * gx
34
+ const y = v * gy
35
+ const i = Math.floor(x)
36
+ const j = Math.floor(y)
37
+ const fx = smooth(x - i)
38
+ const fy = smooth(y - j)
39
+ const a = at(i, j) + (at(i + 1, j) - at(i, j)) * fx
40
+ const b = at(i, j + 1) + (at(i + 1, j + 1) - at(i, j + 1)) * fx
41
+ return a + (b - a) * fy
42
+ }
43
+ }
44
+
45
+ function fbm(rand, gx, gy = gx, octaves = 4) {
46
+ const layers = []
47
+ for (let o = 0; o < octaves; o += 1) layers.push(tileNoise(rand, gx * 2 ** o, gy * 2 ** o))
48
+ return (u, v) => {
49
+ let sum = 0
50
+ let norm = 0
51
+ let amp = 1
52
+ for (const layer of layers) {
53
+ sum += layer(u, v) * amp
54
+ norm += amp
55
+ amp *= 0.5
56
+ }
57
+ return sum / norm
58
+ }
59
+ }
60
+
61
+ const clamp01 = (value) => Math.max(0, Math.min(1, value))
62
+ const frac = (value) => value - Math.floor(value)
63
+
64
+ /** 逐像素画一张灰度图:fn(u, v) 返回 [明度, 高度],高度用作凹凸贴图。 */
65
+ function paintGray(width, height, fn) {
66
+ const color = document.createElement('canvas')
67
+ const bump = document.createElement('canvas')
68
+ color.width = bump.width = width
69
+ color.height = bump.height = height
70
+ const cctx = color.getContext('2d')
71
+ const bctx = bump.getContext('2d')
72
+ const cimg = cctx.createImageData(width, height)
73
+ const bimg = bctx.createImageData(width, height)
74
+ for (let y = 0; y < height; y += 1) {
75
+ for (let x = 0; x < width; x += 1) {
76
+ const [lum, h] = fn((x + 0.5) / width, (y + 0.5) / height)
77
+ const i = (y * width + x) * 4
78
+ const l = Math.round(clamp01(lum) * 255)
79
+ const b = Math.round(clamp01(h ?? lum) * 255)
80
+ cimg.data[i] = cimg.data[i + 1] = cimg.data[i + 2] = l
81
+ cimg.data[i + 3] = 255
82
+ bimg.data[i] = bimg.data[i + 1] = bimg.data[i + 2] = b
83
+ bimg.data[i + 3] = 255
84
+ }
85
+ }
86
+ cctx.putImageData(cimg, 0, 0)
87
+ bctx.putImageData(bimg, 0, 0)
88
+ return { color, bump }
89
+ }
90
+
91
+ /**
92
+ * 砌体/铺地通用图案:cols×rows 块,隔行错缝 offset,灰缝宽 grout(占块尺寸比例)。
93
+ * 每块有自己的明度偏移,块边略暗(像微缩模型里刻出来的缝)。
94
+ */
95
+ function masonry(rand, { cols, rows, offset = 0.5, grout = 0.03, tone = 0.06, noise = 0.05, groutLum = 0.62, base = 0.93, size = 512 }) {
96
+ const tones = []
97
+ for (let i = 0; i < cols * rows * 2; i += 1) tones.push((rand() - 0.5) * 2 * tone)
98
+ const n = fbm(rand, 6, 6, 3)
99
+ return paintGray(size, size, (u, v) => {
100
+ const row = Math.floor(v * rows)
101
+ const shift = (row % 2) * offset / cols
102
+ const cu = frac(u + shift) * cols
103
+ const col = Math.floor(cu)
104
+ const fu = cu - col
105
+ const fv = v * rows - row
106
+ const gu = grout * rows / cols
107
+ const edge = Math.min(fu, 1 - fu) / gu
108
+ const edgeV = Math.min(fv, 1 - fv) / grout
109
+ const k = Math.min(edge, edgeV)
110
+ const nv = n(u, v) - 0.5
111
+ if (k < 1) return [groutLum + nv * 0.05, 0.25]
112
+ const bevel = k < 2 ? 0.92 + 0.08 * (k - 1) : 1
113
+ const lum = (base + tones[(row * cols + col) % tones.length] + nv * noise * 2) * bevel
114
+ return [lum, 0.75 + nv * 0.3]
115
+ })
116
+ }
117
+
118
+ // ── 贴图工厂(每栋建筑一份,随建筑一起销毁)───────────────────────
119
+
120
+ /**
121
+ * 纹理登记:每种纹理只画一次 canvas;不同平铺尺寸/旋转用 clone(共享图像源)。
122
+ * 返回 { map, bump },UV 以米为单位,repeat = 1 / 平铺尺寸。
123
+ */
124
+ // 画好的 canvas 在模块级缓存(只占 CPU 内存,确定性种子保证每次一样):换建筑时只重新上传
125
+ // GPU 纹理,不重画像素。GPU 纹理仍随建筑登记、随建筑销毁。
126
+ const PAINTED = new Map()
127
+ export const paintTimings = {}
128
+
129
+ function createTextures(own, maxAnisotropy) {
130
+ const cache = new Map()
131
+
132
+ function source(name, painter) {
133
+ if (!PAINTED.has(name)) {
134
+ const start = globalThis.performance?.now?.() ?? 0
135
+ PAINTED.set(name, painter(mulberry32(hash(name))))
136
+ paintTimings[name] = Math.round((globalThis.performance?.now?.() ?? 0) - start)
137
+ }
138
+ return PAINTED.get(name)
139
+ }
140
+
141
+ function make(canvas, colorSpace, tileU, tileV, rotation) {
142
+ const texture = own(new THREE.CanvasTexture(canvas))
143
+ texture.colorSpace = colorSpace
144
+ texture.wrapS = texture.wrapT = THREE.RepeatWrapping
145
+ texture.repeat.set(1 / tileU, 1 / tileV)
146
+ texture.anisotropy = maxAnisotropy
147
+ if (rotation) {
148
+ texture.center.set(0.5, 0.5)
149
+ texture.rotation = rotation
150
+ }
151
+ return texture
152
+ }
153
+
154
+ /** name:图案名;tile:[米, 米];rotation:弧度(竖向木纹等)。 */
155
+ function get(name, tile, rotation = 0) {
156
+ const key = `${name}|${tile.join('x')}|${rotation}`
157
+ if (cache.has(key)) return cache.get(key)
158
+ const painter = PAINTERS[name]
159
+ if (!painter) throw new Error(`未知纹理:${name}`)
160
+ const canvas = source(name, painter)
161
+ const set = {
162
+ map: make(canvas.color, THREE.SRGBColorSpace, tile[0], tile[1], rotation),
163
+ bump: canvas.bump ? make(canvas.bump, THREE.NoColorSpace, tile[0], tile[1], rotation) : null,
164
+ }
165
+ cache.set(key, set)
166
+ return set
167
+ }
168
+
169
+ return { get }
170
+ }
171
+
172
+ function hash(text) {
173
+ let h = 2166136261
174
+ for (let i = 0; i < text.length; i += 1) h = Math.imul(h ^ text.charCodeAt(i), 16777619)
175
+ return h >>> 0
176
+ }
177
+
178
+ const PAINTERS = {
179
+ plaster: (rand) => {
180
+ const n = fbm(rand, 4, 4, 5)
181
+ const fine = tileNoise(rand, 96, 96)
182
+ return paintGray(256, 256, (u, v) => {
183
+ const a = n(u, v)
184
+ return [0.93 + (a - 0.5) * 0.07 + (fine(u, v) - 0.5) * 0.025, a * 0.8 + fine(u, v) * 0.2]
185
+ })
186
+ },
187
+ concrete: (rand) => {
188
+ const n = fbm(rand, 8, 8, 4)
189
+ const pores = tileNoise(rand, 140, 140)
190
+ return paintGray(256, 256, (u, v) => {
191
+ const pore = pores(u, v) > 0.93 ? -0.08 : 0
192
+ return [0.92 + (n(u, v) - 0.5) * 0.1 + pore, n(u, v) + pore * 2]
193
+ })
194
+ },
195
+ // 木纹沿 u 方向延伸(纹线平行于 u);竖向构件用旋转 90° 的克隆。
196
+ wood: (rand) => {
197
+ const warp = fbm(rand, 2, 12, 3)
198
+ const n = fbm(rand, 4, 24, 3)
199
+ return paintGray(256, 256, (u, v) => {
200
+ const ring = 0.5 + 0.5 * Math.sin((v * 22 + warp(u, v) * 5) * Math.PI * 2)
201
+ return [0.8 + ring * 0.12 + (n(u, v) - 0.5) * 0.08, 0.5 + ring * 0.3]
202
+ })
203
+ },
204
+ lacquer: (rand) => {
205
+ const warp = fbm(rand, 2, 12, 3)
206
+ const n = fbm(rand, 6, 6, 3)
207
+ return paintGray(256, 256, (u, v) => {
208
+ const ring = 0.5 + 0.5 * Math.sin((v * 18 + warp(u, v) * 4) * Math.PI * 2)
209
+ return [0.93 + ring * 0.05 + (n(u, v) - 0.5) * 0.04, 0.5 + ring * 0.1]
210
+ })
211
+ },
212
+ // 望板:顺坡的木板(板缝平行于 v),平铺 1m 含 5 块。
213
+ boards: (rand) => {
214
+ const warp = fbm(rand, 12, 2, 3)
215
+ const tones = Array.from({ length: 5 }, () => (rand() - 0.5) * 0.1)
216
+ return paintGray(256, 256, (u, v) => {
217
+ const board = Math.floor(u * 5)
218
+ const fu = u * 5 - board
219
+ if (fu < 0.03 || fu > 0.97) return [0.6, 0.2]
220
+ const ring = 0.5 + 0.5 * Math.sin((u * 60 + warp(u, v) * 5) * Math.PI * 2)
221
+ return [0.84 + tones[board] + ring * 0.08, 0.6]
222
+ })
223
+ },
224
+ // 青瓦:4 条筒瓦垄(沿 v 顺坡),4 道瓦搭接线。凹凸图给出圆垄的起伏。
225
+ tile: (rand) => {
226
+ const n = fbm(rand, 8, 8, 3)
227
+ const tones = Array.from({ length: 16 }, () => (rand() - 0.5) * 0.08)
228
+ return paintGray(512, 512, (u, v) => {
229
+ const col = Math.floor(u * 4)
230
+ const fu = u * 4 - col
231
+ const row = Math.floor(v * 4)
232
+ const fv = v * 4 - row
233
+ const tone = tones[(row * 4 + col) % 16] + (n(u, v) - 0.5) * 0.06
234
+ const d = (fu - 0.5) / 0.17
235
+ if (Math.abs(d) < 1) {
236
+ const h = Math.sqrt(1 - d * d)
237
+ const lit = 0.72 + 0.3 * h - 0.1 * Math.max(0, d)
238
+ const lap = fv > 0.88 ? 0.86 : 1
239
+ return [(lit + tone) * lap, 0.55 + 0.45 * h]
240
+ }
241
+ const g = Math.abs(fu - 0.5)
242
+ const trough = 0.8 - (g - 0.17) * 0.35
243
+ const lap = fv > 0.9 ? 0.72 : fv > 0.84 ? 0.85 : 1
244
+ return [(trough + tone) * lap, 0.25 + (fv > 0.9 ? -0.1 : 0)]
245
+ })
246
+ },
247
+ // 直立锁边:一张图 2 道锁边(u 周期),面板带极轻的"油罐效应"明暗。
248
+ seam: (rand) => {
249
+ const n = fbm(rand, 4, 8, 3)
250
+ return paintGray(256, 256, (u, v) => {
251
+ const fu = frac(u * 2)
252
+ if (fu < 0.035) return [1.02, 1]
253
+ if (fu < 0.07) return [0.74, 0.35]
254
+ const wave = 0.5 + 0.5 * Math.cos((fu - 0.5) * Math.PI * 2)
255
+ return [0.9 + wave * 0.04 + (n(u, v) - 0.5) * 0.03, 0.45]
256
+ })
257
+ },
258
+ terrazzo: (rand) => {
259
+ const n = fbm(rand, 6, 6, 4)
260
+ const size = 512
261
+ const lum = new Float32Array(size * size).fill(1)
262
+ const cover = new Float32Array(size * size)
263
+ for (let i = 0; i < 2600; i += 1) {
264
+ const cu = rand() * size
265
+ const cv = rand() * size
266
+ const radius = 0.8 + rand() ** 3 * 5
267
+ const tone = 0.7 + rand() * 0.34
268
+ const reach = Math.ceil(radius + 1)
269
+ for (let dy = -reach; dy <= reach; dy += 1) {
270
+ for (let dx = -reach; dx <= reach; dx += 1) {
271
+ const alpha = clamp01(radius - Math.hypot(dx, dy) + 0.5)
272
+ if (alpha <= 0) continue
273
+ const x = ((Math.floor(cu) + dx) % size + size) % size
274
+ const y = ((Math.floor(cv) + dy) % size + size) % size
275
+ const k = y * size + x
276
+ lum[k] = lum[k] * (1 - alpha) + tone * alpha
277
+ cover[k] = Math.max(cover[k], alpha)
278
+ }
279
+ }
280
+ }
281
+ return paintGray(size, size, (u, v) => {
282
+ const k = Math.min(size - 1, Math.floor(v * size)) * size + Math.min(size - 1, Math.floor(u * size))
283
+ const base = 0.93 + (n(u, v) - 0.5) * 0.05
284
+ return [base * lum[k], 0.5 + cover[k] * 0.08]
285
+ })
286
+ },
287
+ 'square-brick': (rand) => masonry(rand, { cols: 4, rows: 4, offset: 0, grout: 0.022, tone: 0.045, groutLum: 0.66 }),
288
+ stone: (rand) => masonry(rand, { cols: 2, rows: 2, offset: 0.5, grout: 0.012, tone: 0.035, groutLum: 0.72, base: 0.95 }),
289
+ paving: (rand) => masonry(rand, { cols: 2, rows: 4, offset: 0.5, grout: 0.02, tone: 0.05, groutLum: 0.7 }),
290
+ 'brick-paving': (rand) => masonry(rand, { cols: 4, rows: 4, offset: 0.5, grout: 0.05, tone: 0.06, groutLum: 0.66 }),
291
+ ashlar: (rand) => masonry(rand, { cols: 2, rows: 2, offset: 0.5, grout: 0.035, tone: 0.05, groutLum: 0.68, noise: 0.08 }),
292
+ brick: (rand) => masonry(rand, { cols: 4, rows: 4, offset: 0.5, grout: 0.13, tone: 0.07, groutLum: 0.74 }),
293
+ // 状态:斜纹(在建)与蓝图格(未实现)。只调明度,底色仍是数据给的状态色。
294
+ hatch: () => paintGray(128, 128, (u, v) => [frac((u + v) * 2) < 0.5 ? 1 : 0.8, 0.5]),
295
+ blueprint: () => paintGray(256, 256, (u, v) => {
296
+ const major = Math.min(frac(u * 2), 1 - frac(u * 2), frac(v * 2), 1 - frac(v * 2)) < 0.008
297
+ const minor = Math.min(frac(u * 10), 1 - frac(u * 10), frac(v * 10), 1 - frac(v * 10)) < 0.025
298
+ return [major ? 0.7 : minor ? 0.9 : 1, 0.5]
299
+ }),
300
+ plan: () => paintGray(256, 256, (u, v) => {
301
+ const major = Math.min(frac(u * 2), 1 - frac(u * 2), frac(v * 2), 1 - frac(v * 2)) < 0.01
302
+ const minor = Math.min(frac(u * 10), 1 - frac(u * 10), frac(v * 10), 1 - frac(v * 10)) < 0.03
303
+ return [major ? 0.62 : minor ? 0.86 : 1, 0.5]
304
+ }),
305
+ }
306
+
307
+ /** 窗棂:透明底上的方格木条(alphaTest 用),一格一张,UV 按窗洞等分。 */
308
+ function latticeCanvas(barRatio) {
309
+ const size = 64
310
+ const canvas = document.createElement('canvas')
311
+ canvas.width = canvas.height = size
312
+ const context = canvas.getContext('2d')
313
+ context.clearRect(0, 0, size, size)
314
+ context.fillStyle = '#ffffff'
315
+ const bar = Math.max(2, Math.round(size * barRatio / 2))
316
+ context.fillRect(0, 0, size, bar)
317
+ context.fillRect(0, size - bar, size, bar)
318
+ context.fillRect(0, 0, bar, size)
319
+ context.fillRect(size - bar, 0, bar, size)
320
+ return canvas
321
+ }
322
+
323
+ // ── 几何工具 ───────────────────────────────────────────────────
324
+
325
+ /**
326
+ * 倒角方盒:ExtrudeGeometry(1 段斜角=倒棱)。盖面(组 0)是上下两面,其余(组 1)是侧面与斜角,
327
+ * 楼板与台基据此给顶面和侧边不同材质。r 过小直接退回 BoxGeometry(并补齐同样的分组)。
328
+ */
329
+ export function chamferBox(sx, sy, sz, r, segments = 1) {
330
+ const radius = Math.min(r, sx * 0.45, sy * 0.45, sz * 0.45)
331
+ if (radius < 0.002) {
332
+ const geometry = new THREE.BoxGeometry(sx, sy, sz)
333
+ geometry.clearGroups()
334
+ // BoxGeometry 面序:+x −x +y −y +z −z,每面 6 个索引。
335
+ for (let face = 0; face < 6; face += 1) geometry.addGroup(face * 6, 6, face === 2 || face === 3 ? 0 : 1)
336
+ return geometry
337
+ }
338
+ const shape = new THREE.Shape()
339
+ const hx = sx / 2 - radius
340
+ const hz = sz / 2 - radius
341
+ shape.moveTo(-hx, -hz)
342
+ shape.lineTo(hx, -hz)
343
+ shape.lineTo(hx, hz)
344
+ shape.lineTo(-hx, hz)
345
+ shape.closePath()
346
+ const depth = sy - radius * 2
347
+ const geometry = new THREE.ExtrudeGeometry(shape, {
348
+ depth, steps: 1, curveSegments: 1,
349
+ bevelEnabled: true, bevelThickness: radius, bevelSize: radius, bevelSegments: segments,
350
+ })
351
+ geometry.translate(0, 0, -depth / 2)
352
+ geometry.rotateX(-Math.PI / 2)
353
+ return geometry
354
+ }
355
+
356
+ /** 按法线主方向把世界坐标(米)投成 UV:水平面取 (x, z),立面取 (x 或 z, y)。 */
357
+ export function projectUV(geometry) {
358
+ const position = geometry.attributes.position
359
+ const normal = geometry.attributes.normal
360
+ const uv = new Float32Array(position.count * 2)
361
+ for (let i = 0; i < position.count; i += 1) {
362
+ const nx = Math.abs(normal.getX(i))
363
+ const ny = Math.abs(normal.getY(i))
364
+ const nz = Math.abs(normal.getZ(i))
365
+ const x = position.getX(i)
366
+ const y = position.getY(i)
367
+ const z = position.getZ(i)
368
+ if (ny >= nx && ny >= nz) {
369
+ uv[i * 2] = x
370
+ uv[i * 2 + 1] = z
371
+ } else if (nx >= nz) {
372
+ uv[i * 2] = z
373
+ uv[i * 2 + 1] = y
374
+ } else {
375
+ uv[i * 2] = x
376
+ uv[i * 2 + 1] = y
377
+ }
378
+ }
379
+ geometry.setAttribute('uv', new THREE.BufferAttribute(uv, 2))
380
+ return geometry
381
+ }
382
+
383
+ /** 直角多边形向内偏移 r(ExtrudeGeometry 的倒角会再向外长 r,最终外形回到原尺寸)。 */
384
+ function offsetPolygon(points, r) {
385
+ let area = 0
386
+ for (let i = 0; i < points.length; i += 1) {
387
+ const [x0, y0] = points[i]
388
+ const [x1, y1] = points[(i + 1) % points.length]
389
+ area += x0 * y1 - x1 * y0
390
+ }
391
+ const sign = area > 0 ? 1 : -1
392
+ const count = points.length
393
+ const normals = points.map((point, i) => {
394
+ const next = points[(i + 1) % count]
395
+ const dx = next[0] - point[0]
396
+ const dy = next[1] - point[1]
397
+ const length = Math.hypot(dx, dy) || 1
398
+ return [(-dy / length) * sign, (dx / length) * sign]
399
+ })
400
+ return points.map((point, i) => {
401
+ const a = normals[(i - 1 + count) % count]
402
+ const b = normals[i]
403
+ if (a[0] * b[0] + a[1] * b[1] > 0.99) return [point[0] + b[0] * r, point[1] + b[1] * r]
404
+ return [point[0] + (a[0] + b[0]) * r, point[1] + (a[1] + b[1]) * r]
405
+ })
406
+ }
407
+
408
+ /**
409
+ * 一面墙(或山墙)的实体:墙面内的轮廓(u, y)→ 挤出为墙厚 → 放到世界坐标。
410
+ * axis='x':u 就是世界 x,墙厚沿 z;axis='z':u 是世界 z,墙厚沿 x。
411
+ */
412
+ function wallSolid(outline, holes, axis, at, thickness, r) {
413
+ const flip = axis === 'z' ? -1 : 1
414
+ const toShape = (points) => points.map(([u, y]) => new THREE.Vector2(u * flip, y))
415
+ const shape = new THREE.Shape(toShape(offsetPolygon(outline, r)))
416
+ for (const hole of holes) {
417
+ const grown = [[hole.u0 - r, hole.y0 - r], [hole.u1 + r, hole.y0 - r], [hole.u1 + r, hole.y1 + r], [hole.u0 - r, hole.y1 + r]]
418
+ shape.holes.push(new THREE.Path(toShape(grown)))
419
+ }
420
+ const depth = Math.max(0.001, thickness - r * 2)
421
+ const geometry = new THREE.ExtrudeGeometry(shape, {
422
+ depth, steps: 1, curveSegments: 1,
423
+ bevelEnabled: r > 0.001, bevelThickness: r, bevelSize: r, bevelSegments: 1,
424
+ })
425
+ geometry.translate(0, 0, -depth / 2)
426
+ if (axis === 'z') {
427
+ geometry.rotateY(Math.PI / 2)
428
+ geometry.translate(at, 0, 0)
429
+ } else geometry.translate(0, 0, at)
430
+ return projectUV(geometry)
431
+ }
432
+
433
+ /** 墙的外轮廓:外包矩形沿底边挖出门的缺口,逆时针。 */
434
+ function wallOutline(wall) {
435
+ const u0 = wall.u0 - wall.extendLo
436
+ const u1 = wall.u1 + wall.extendHi
437
+ const points = [[u0, wall.y0]]
438
+ for (const notch of wall.notches) {
439
+ points.push([notch.u0, wall.y0], [notch.u0, notch.y1], [notch.u1, notch.y1], [notch.u1, wall.y0])
440
+ }
441
+ points.push([u1, wall.y0], [u1, wall.y1], [u0, wall.y1])
442
+ return points
443
+ }
444
+
445
+ // ── 构件工厂 ───────────────────────────────────────────────────
446
+
447
+ /**
448
+ * 为一栋建筑创建工具箱。own(resource) 负责登记资源;返回 build(plan, model, present) → Object3D[]。
449
+ * 每个顶层对象的 userData 带 kind / spaceIds / lowY / opacity / cutAs,剖看与拾取都只读这些。
450
+ */
451
+ export function createKit({ look, own, maxAnisotropy = 4 }) {
452
+ const textures = createTextures(own, maxAnisotropy)
453
+ const materials = new Map()
454
+ const edge = look.edge
455
+
456
+ function material(key, factory) {
457
+ if (!materials.has(key)) materials.set(key, own(factory()))
458
+ return materials.get(key)
459
+ }
460
+
461
+ function standard(color, opacity, { texture = null, tile = [1, 1], rotation = 0, roughness = 0.9, metalness = 0, bumpScale = 0.5, extra = {} } = {}) {
462
+ const key = `std|${normalizeHex(color)}|${opacity}|${texture}|${tile}|${rotation}|${roughness}|${metalness}|${bumpScale}|${JSON.stringify(extra)}`
463
+ return material(key, () => {
464
+ const set = texture ? textures.get(texture, tile, rotation) : null
465
+ return new THREE.MeshStandardMaterial({
466
+ color: normalizeHex(color),
467
+ map: set?.map ?? null,
468
+ bumpMap: set?.bump ?? null,
469
+ bumpScale: set?.bump ? bumpScale : 1,
470
+ roughness,
471
+ metalness,
472
+ opacity,
473
+ transparent: opacity < 1,
474
+ depthWrite: opacity >= 0.6,
475
+ // 阴影图写正面(默认写背面):墙与墙相接处的受光点离背面只有几毫米,写背面会漏出一条亮缝;
476
+ // 写正面后遮挡深度落在墙的另一侧,缝就没了,代价是受光面需要 normalBias 防自阴影。
477
+ shadowSide: THREE.FrontSide,
478
+ ...extra,
479
+ })
480
+ })
481
+ }
482
+
483
+ /** 按颜色角色选材质:状态色走状态材质(两套共用),其余按饰面。 */
484
+ function surfaceMaterial(color, opacity, { finish = 'plaster', tile = [2, 2], roughness = 0.92, bumpScale = 0.6 } = {}) {
485
+ const role = colorRole(color)
486
+ if (role === 'status-in-progress') return standard(color, opacity, { texture: 'hatch', tile: [0.7, 0.7], roughness: look.status.inProgress.roughness, bumpScale: 0 })
487
+ if (role === 'status-not-built') return standard(color, opacity, { texture: 'blueprint', tile: [1, 1], roughness: look.status.notBuilt.roughness, bumpScale: 0 })
488
+ if (role === 'brick') return standard(color, opacity, { texture: 'brick', tile: [0.96, 0.24], roughness: 0.9, bumpScale: 1 })
489
+ if (role === 'timber') return standard(color, opacity, { texture: 'boards', tile: [1, 1], rotation: Math.PI / 2, roughness: 0.8, bumpScale: 0.6 })
490
+ return standard(color, opacity, { texture: finish, tile, roughness, bumpScale })
491
+ }
492
+
493
+ const isStatus = (color) => colorRole(color).startsWith('status')
494
+
495
+ function woodMaterial(color, opacity, vertical) {
496
+ const texture = look.frame.texture === 'lacquer' ? 'lacquer' : 'wood'
497
+ return standard(color, opacity, { texture, tile: [1.2, 1.2], rotation: vertical ? Math.PI / 2 : 0, roughness: look.frame.roughness, bumpScale: 0.3 })
498
+ }
499
+
500
+ function mesh(geometry, mat, { cast = true, receive = true } = {}) {
501
+ const object = new THREE.Mesh(own(geometry), mat)
502
+ object.castShadow = cast && (Array.isArray(mat) ? mat[0] : mat).opacity > 0.7
503
+ object.receiveShadow = receive
504
+ return object
505
+ }
506
+
507
+ function tag(object, { kind, spaceIds = [], lowY = 0, opacity = 1, cutAs = null, environment = false }) {
508
+ object.userData = { ...object.userData, kind, spaceIds, lowY, opacity, cutAs, environment }
509
+ return object
510
+ }
511
+
512
+ /** 轴对齐(或带旋转)的方盒构件:位置、尺寸、旋转都照搬数据。 */
513
+ function boxAt(size, position, rotation, r) {
514
+ const geometry = chamferBox(size.x, size.y, size.z, r)
515
+ if (rotation) geometry.applyMatrix4(new THREE.Matrix4().makeRotationFromEuler(new THREE.Euler(rotation.x || 0, rotation.y || 0, rotation.z || 0)))
516
+ geometry.translate(position.x, position.y, position.z)
517
+ return projectUV(geometry)
518
+ }
519
+
520
+ const partLowY = (part) => part.position.y - part.size.y / 2
521
+
522
+ // ── 墙 ──
523
+ function buildWall(wall) {
524
+ const opacity = wall.opacity
525
+ const face = surfaceMaterial(wall.color, opacity, { finish: look.wall.texture, tile: [2, 2], roughness: look.wall.roughness, bumpScale: look.wall.bumpScale })
526
+ const reveal = isStatus(wall.color) ? face : surfaceMaterial(shadeHex(wall.color, look.wall.revealShade), opacity, { finish: look.wall.texture, tile: [2, 2], roughness: look.wall.roughness, bumpScale: look.wall.bumpScale })
527
+ const group = new THREE.Group()
528
+ group.add(mesh(wallSolid(wallOutline(wall), wall.holes, wall.axis, wall.at, wall.thickness, edge.wall), [face, reveal]))
529
+ // 墙裙(踢脚/青砖裙墙):只给有饰面的墙,状态墙不加——"未完成"不配完工细部。
530
+ const base = look.wall.base
531
+ if (base && !isStatus(wall.color) && opacity >= 1) {
532
+ const lowestHole = Math.min(...wall.holes.map((hole) => hole.y0), Infinity)
533
+ const height = Math.min(base.height, lowestHole - wall.y0 - 0.06)
534
+ if (height > 0.04) {
535
+ const u0 = wall.u0 - wall.extendLo
536
+ const u1 = wall.u1 + wall.extendHi
537
+ const spans = []
538
+ let cursor = u0
539
+ for (const notch of wall.notches) {
540
+ spans.push([cursor, notch.u0])
541
+ cursor = notch.u1
542
+ }
543
+ spans.push([cursor, u1])
544
+ const baseMat = standard(base.color, 1, { texture: base.texture, tile: base.texture === 'brick' ? [0.96, 0.24] : [1.2, 1.2], roughness: 0.9, bumpScale: 0.8 })
545
+ for (const [a, b] of spans) {
546
+ if (b - a < 0.02) continue
547
+ const depth = wall.thickness + base.outset * 2
548
+ const size = wall.axis === 'x' ? { x: b - a, y: height, z: depth } : { x: depth, y: height, z: b - a }
549
+ const center = wall.axis === 'x'
550
+ ? { x: (a + b) / 2, y: wall.y0 + height / 2, z: wall.at }
551
+ : { x: wall.at, y: wall.y0 + height / 2, z: (a + b) / 2 }
552
+ group.add(mesh(boxAt(size, center, null, edge.trim), baseMat))
553
+ }
554
+ }
555
+ }
556
+ return tag(group, { kind: 'wall', spaceIds: wall.spaceIds, lowY: wall.y0, opacity })
557
+ }
558
+
559
+ function buildLooseWall(part) {
560
+ const mat = surfaceMaterial(part.color, part.opacity ?? 1, { finish: look.wall.texture, tile: [2, 2], roughness: look.wall.roughness, bumpScale: look.wall.bumpScale })
561
+ return tag(mesh(boxAt(part.size, part.position, part.rotation, edge.trim), mat), { kind: 'wall', spaceIds: part.spaceIds || [], lowY: partLowY(part), opacity: part.opacity ?? 1 })
562
+ }
563
+
564
+ function buildGable(gable) {
565
+ const face = surfaceMaterial(gable.color, gable.opacity, { finish: look.wall.texture, tile: [2, 2], roughness: look.wall.roughness, bumpScale: look.wall.bumpScale })
566
+ const reveal = isStatus(gable.color) ? face : surfaceMaterial(shadeHex(gable.color, look.wall.revealShade), gable.opacity, { finish: look.wall.texture, tile: [2, 2] })
567
+ const object = mesh(wallSolid(gable.polygon, [], gable.axis, gable.at, gable.thickness, 0), [face, reveal])
568
+ return tag(object, { kind: 'wall', spaceIds: gable.spaceIds, lowY: gable.lowY, opacity: gable.opacity, cutAs: 'roof' })
569
+ }
570
+
571
+ // ── 屋顶 ──
572
+ function roofEndsShared(roof, roofs) {
573
+ // 两个屋顶在山墙端头相接(院落里常见):那一端不装博风板和脊端,免得互相戳穿。
574
+ const shared = { lo: false, hi: false }
575
+ if (!roof.slopeAxis) return shared
576
+ for (const other of roofs) {
577
+ if (other === roof || other.slopeAxis !== roof.slopeAxis || !other.ridgeRange) continue
578
+ const overlap = Math.min(roof.slopeRange[1], other.slopeRange[1]) - Math.max(roof.slopeRange[0], other.slopeRange[0])
579
+ if (overlap < 0.3) continue
580
+ if (Math.abs(other.ridgeRange[1] - roof.ridgeRange[0]) < 0.02) shared.lo = true
581
+ if (Math.abs(other.ridgeRange[0] - roof.ridgeRange[1]) < 0.02) shared.hi = true
582
+ }
583
+ return shared
584
+ }
585
+
586
+ function buildRoof(roof, roofs) {
587
+ const group = new THREE.Group()
588
+ const status = isStatus(roof.color)
589
+ const opacity = roof.opacity
590
+ const spec = look.roof
591
+ const tileMat = status
592
+ ? surfaceMaterial(roof.color, opacity)
593
+ : standard(roof.color, opacity, {
594
+ texture: spec.finish, tile: spec.finish === 'tile' ? [0.96, 1.2] : [spec.seamSpacing * 2, 2],
595
+ roughness: spec.tileRoughness, metalness: spec.tileMetalness, bumpScale: spec.finish === 'tile' ? 2.2 : 0.8,
596
+ })
597
+ const soffitMat = status ? tileMat : standard(spec.soffit, opacity, { texture: spec.soffitTexture, tile: [1, 1], roughness: 0.85, bumpScale: 0.4 })
598
+ const trimColor = (shade) => shadeHex(roof.color, shade)
599
+ const shared = roofEndsShared(roof, roofs)
600
+ const ridgeAxisIndex = roof.slopeAxis === 'x' ? 2 : 0
601
+
602
+ for (const { part, frame } of roof.slabs) {
603
+ // 规范坐标:X 沿屋脊、Y 沿坡面法线、Z 沿上坡。保证右手系后得到一次刚体变换。
604
+ let ridge = new THREE.Vector3(...frame.ridgeDir)
605
+ const up = new THREE.Vector3(...frame.normal)
606
+ const dir = new THREE.Vector3(...frame.dir)
607
+ if (new THREE.Vector3().crossVectors(ridge, up).dot(dir) < 0) ridge = ridge.negate()
608
+ const basis = new THREE.Matrix4().makeBasis(ridge, up, dir)
609
+ basis.setPosition(frame.center[0], frame.center[1], frame.center[2])
610
+ const place = (geometry, localUV = true) => {
611
+ if (localUV) projectUV(geometry)
612
+ geometry.applyMatrix4(basis)
613
+ if (!localUV) projectUV(geometry)
614
+ return geometry
615
+ }
616
+ const extend = frame.slope ? roof.eaveExtend : 0
617
+ const L = frame.L + extend
618
+ const zMid = -extend / 2
619
+ const th = frame.th
620
+ const tileLayer = status ? th : Math.min(spec.tileLayer, th * 0.6)
621
+ // 以山墙那一端是否与别的屋顶相接,决定两端各放不放博风板(规范坐标 ±X 端对应世界里的低/高端)。
622
+ const endShared = (sign) => (sign * ridge.getComponent(ridgeAxisIndex) < 0 ? shared.lo : shared.hi)
623
+ // 瓦面层(略伸出下层,形成檐口的层次)
624
+ const tileGeometry = chamferBox(frame.W + (status ? 0 : 0.04), tileLayer, L + (status ? 0 : 0.05), edge.member)
625
+ tileGeometry.translate(0, th / 2 - tileLayer / 2, zMid - (status ? 0 : 0.025))
626
+ group.add(mesh(place(tileGeometry), tileMat))
627
+ if (!status) {
628
+ // 望板/檐底层
629
+ const sheath = chamferBox(frame.W, th - tileLayer, L, edge.member)
630
+ sheath.translate(0, -tileLayer / 2, zMid)
631
+ group.add(mesh(place(sheath), soffitMat))
632
+ // 檐口封檐板
633
+ const fascia = chamferBox(frame.W + 0.02, spec.fascia.height, spec.fascia.depth, edge.trim)
634
+ fascia.translate(0, th / 2 - spec.fascia.height / 2 + 0.01, -frame.L / 2 - extend - 0.03 - spec.fascia.depth / 2)
635
+ group.add(mesh(place(fascia, false), standard(trimColor(spec.fascia.shade), opacity, { texture: spec.fascia.texture, tile: [1.2, 1.2], roughness: 0.8, bumpScale: 0.2 })))
636
+ // 博风板(山墙端的斜边)
637
+ const bargeMat = standard(trimColor(spec.barge.shade), opacity, { texture: spec.fascia.texture, tile: [1.2, 1.2], roughness: 0.8, bumpScale: 0.2 })
638
+ for (const sign of [-1, 1]) {
639
+ if (endShared(sign)) continue
640
+ const barge = chamferBox(spec.barge.depth, spec.barge.height, L + 0.08, edge.trim)
641
+ barge.translate(sign * (frame.W / 2 + 0.02 + spec.barge.depth / 2), th / 2 - spec.barge.height / 2 + 0.03, zMid - 0.03)
642
+ group.add(mesh(place(barge, false), bargeMat))
643
+ }
644
+ }
645
+ }
646
+
647
+ if (roof.kind === 'gable' && roof.ridge && !status) {
648
+ const ridgeSpec = spec.ridge
649
+ const ridgeMat = standard(trimColor(ridgeSpec.shade), opacity, { texture: ridgeSpec.kind === 'ridge' ? 'brick' : 'concrete', tile: [0.96, 0.24], roughness: 0.85, bumpScale: 0.5 })
650
+ const dir = new THREE.Vector3(...roof.ridge.dir).normalize()
651
+ const center = new THREE.Vector3(...roof.ridge.center)
652
+ const quaternion = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(1, 0, 0), dir)
653
+ const put = (geometry, offsetAlong, y) => {
654
+ geometry.applyQuaternion(quaternion)
655
+ const position = center.clone().addScaledVector(dir, offsetAlong)
656
+ geometry.translate(position.x, y, position.z)
657
+ return projectUV(geometry)
658
+ }
659
+ const length = roof.ridge.length + 0.06
660
+ const bottom = roof.ridge.topY - (ridgeSpec.kind === 'ridge' ? 0.1 : 0.02)
661
+ group.add(mesh(put(chamferBox(length, ridgeSpec.height, ridgeSpec.width, edge.member), 0, bottom + ridgeSpec.height / 2), ridgeMat))
662
+ if (ridgeSpec.endBlock) {
663
+ for (const sign of [-1, 1]) {
664
+ if (sign * dir.getComponent(ridgeAxisIndex) < 0 ? shared.lo : shared.hi) continue
665
+ const block = chamferBox(ridgeSpec.endBlock.length, ridgeSpec.height + ridgeSpec.endBlock.height, ridgeSpec.width * 0.9, edge.member)
666
+ const along = sign * (length / 2 - ridgeSpec.endBlock.length / 2)
667
+ group.add(mesh(put(block, along, bottom + (ridgeSpec.height + ridgeSpec.endBlock.height) / 2), ridgeMat))
668
+ }
669
+ }
670
+ }
671
+ const lowY = Math.min(...roof.slabs.map(({ part }) => partLowY(part)))
672
+ return tag(group, { kind: 'roof', spaceIds: roof.spaceIds, lowY, opacity })
673
+ }
674
+
675
+ // ── 楼板、台基、地面 ──
676
+ function floorFinish(part, model) {
677
+ const space = model.spaces.find((item) => item.id === part.spaceIds?.[0])
678
+ const kind = space?.kind === 'room' ? 'room' : space?.kind === 'yard' ? 'yard' : 'circulation'
679
+ const name = look.floor[kind]
680
+ const tiles = { terrazzo: [1.4, 1.4], stone: [1.8, 1.2], paving: [2.4, 1.6], 'square-brick': [2.4, 2.4], 'brick-paving': [1.2, 0.6] }
681
+ return { texture: name, tile: tiles[name] || [2, 2] }
682
+ }
683
+
684
+ function buildFloor(part, model) {
685
+ const opacity = part.opacity ?? 1
686
+ const finish = floorFinish(part, model)
687
+ const status = isStatus(part.color)
688
+ // 地面色 = 数据色(房间是 space.accent)与饰面底色按 accentStrength 调和:色相仍可辨认。
689
+ const tint = status ? part.color : mixHex(look.floor.finishBase, part.color, look.floor.accentStrength)
690
+ const top = status ? surfaceMaterial(part.color, opacity) : standard(tint, opacity, { texture: finish.texture, tile: finish.tile, roughness: look.floor.roughness, bumpScale: 0.5 })
691
+ const side = status ? top : standard(look.floor.slabEdge, opacity, { texture: 'concrete', tile: [1.2, 1.2], roughness: 0.9, bumpScale: 0.3 })
692
+ const object = mesh(boxAt(part.size, part.position, part.rotation, edge.slab), [top, side])
693
+ object.castShadow = false
694
+ return tag(object, { kind: part.kind, spaceIds: part.spaceIds || [], lowY: partLowY(part), opacity })
695
+ }
696
+
697
+ function buildPlanFloor(part) {
698
+ const opacity = part.opacity ?? 1
699
+ const mat = standard(part.color, opacity, { texture: 'plan', tile: [1, 1], roughness: 0.95, bumpScale: 0 })
700
+ const object = mesh(boxAt(part.size, part.position, part.rotation, 0), mat, { cast: false })
701
+ const edges = new THREE.LineSegments(own(new THREE.EdgesGeometry(object.geometry, 30)), material(`line|${look.plan.line}`, () => new THREE.LineBasicMaterial({ color: look.plan.line, transparent: true, opacity: look.plan.edgeOpacity })))
702
+ object.add(edges)
703
+ return tag(object, { kind: part.kind, spaceIds: part.spaceIds || [], lowY: partLowY(part), opacity })
704
+ }
705
+
706
+ function buildPlinth(plinth, groundY) {
707
+ const spec = look.plinth
708
+ const height = plinth.top - groundY
709
+ if (height <= 0.01) return null
710
+ const group = new THREE.Group()
711
+ const sideMat = standard(spec.color, 1, { texture: spec.texture, tile: spec.texture === 'ashlar' ? [2.4, 0.45] : [1.2, 1.2], roughness: 0.92, bumpScale: 0.9 })
712
+ const topMat = standard(shadeHex(spec.color, 1.04), 1, { texture: 'concrete', tile: [1.2, 1.2], roughness: 0.9, bumpScale: 0.4 })
713
+ const size = { x: plinth.x1 - plinth.x0 + spec.outset * 2, y: height, z: plinth.z1 - plinth.z0 + spec.outset * 2 }
714
+ const center = { x: (plinth.x0 + plinth.x1) / 2, y: groundY + height / 2, z: (plinth.z0 + plinth.z1) / 2 }
715
+ group.add(mesh(boxAt(size, center, null, edge.slab), [topMat, sideMat]))
716
+ if (spec.topBand) {
717
+ const band = spec.topBand
718
+ const bandMat = standard(band.color, 1, { texture: 'ashlar', tile: [2.4, 0.45], roughness: 0.88, bumpScale: 0.6 })
719
+ const bandSize = { x: size.x + band.outset * 2, y: band.height, z: size.z + band.outset * 2 }
720
+ group.add(mesh(boxAt(bandSize, { x: center.x, y: plinth.top - band.height / 2, z: center.z }, null, edge.member), [bandMat, bandMat]))
721
+ }
722
+ return tag(group, { kind: 'plinth', spaceIds: plinth.spaceIds, lowY: groundY, opacity: 1 })
723
+ }
724
+
725
+ // ── 梁柱、门窗框、楼梯、层间带 ──
726
+ function buildFrame(part, envelope, wallThickness) {
727
+ const opacity = part.opacity ?? 1
728
+ const vertical = part.size.y > 2.5 * Math.max(part.size.x, part.size.z)
729
+ const minSection = Math.min(part.size.x, part.size.z, vertical ? Infinity : part.size.y)
730
+ const expose = look.frame.exposePosts && envelope && !part.rotation && minSection >= 0.1 && !isStatus(part.color)
731
+ const size = { ...part.size }
732
+ const position = { ...part.position }
733
+ const group = new THREE.Group()
734
+ if (expose) {
735
+ // 中式:柱与额枋比墙厚出一圈,立面上读得出木构架;柱脚落一个石柱础。
736
+ if (vertical) {
737
+ const section = Math.max(part.size.x, wallThickness + look.frame.postOutset * 2)
738
+ size.x = size.z = section
739
+ } else {
740
+ const alongX = part.size.x >= part.size.z
741
+ const depth = Math.max(alongX ? part.size.z : part.size.x, wallThickness + look.frame.beamOutset * 2)
742
+ if (alongX) size.z = depth
743
+ else size.x = depth
744
+ size.y = part.size.y + look.frame.beamDrop
745
+ position.y = part.position.y - look.frame.beamDrop / 2
746
+ }
747
+ }
748
+ group.add(mesh(boxAt(size, position, part.rotation, edge.member), woodMaterial(part.color, opacity, vertical)))
749
+ if (expose && vertical && look.frame.columnBase) {
750
+ const spec = look.frame.columnBase
751
+ const side = size.x * spec.size
752
+ const baseMat = standard(spec.color, 1, { texture: 'concrete', tile: [1.2, 1.2], roughness: 0.9, bumpScale: 0.5 })
753
+ group.add(mesh(boxAt({ x: side, y: spec.height, z: side }, { x: position.x, y: partLowY(part) + spec.height / 2, z: position.z }, null, 0.03), baseMat))
754
+ }
755
+ return tag(group, { kind: part.kind, spaceIds: part.spaceIds || [], lowY: partLowY(part), opacity })
756
+ }
757
+
758
+ function buildTrim(part) {
759
+ const opacity = part.opacity ?? 1
760
+ const vertical = part.size.y > Math.max(part.size.x, part.size.z)
761
+ return tag(mesh(boxAt(part.size, part.position, part.rotation, edge.trim), woodMaterial(part.color, opacity, vertical)), { kind: part.kind, spaceIds: part.spaceIds || [], lowY: partLowY(part), opacity })
762
+ }
763
+
764
+ function buildGlass(part) {
765
+ const opacity = part.opacity ?? 1
766
+ const color = look.glass.tint ? mixHex(part.color, look.glass.tint, 0.35) : part.color
767
+ const mat = material(`glass|${color}|${opacity}`, () => new THREE.MeshStandardMaterial({
768
+ color, opacity, transparent: opacity < 1, depthWrite: opacity >= 0.6,
769
+ roughness: look.glass.roughness, metalness: 0, envMapIntensity: look.glass.envBoost,
770
+ }))
771
+ const object = mesh(boxAt(part.size, part.position, part.rotation, 0), mat, { cast: false })
772
+ return tag(object, { kind: part.kind, spaceIds: part.spaceIds || [], lowY: partLowY(part), opacity })
773
+ }
774
+
775
+ function buildWindowDetail(window, trimColor) {
776
+ const group = new THREE.Group()
777
+ const spec = look.opening
778
+ const put = (along, y, width, height, depth) => (window.axis === 'x'
779
+ ? { size: { x: width, y: height, z: depth }, position: { x: along, y, z: window.at } }
780
+ : { size: { x: depth, y: height, z: width }, position: { x: window.at, y, z: along } })
781
+ if (spec.sill && !isStatus(window.wallColor)) {
782
+ const s = spec.sill
783
+ const box = put(window.uc, window.y0 - s.height / 2, window.width + s.overhang * 2, s.height, window.thickness + s.projection * 2)
784
+ group.add(mesh(boxAt(box.size, box.position, null, edge.trim), standard(s.color, 1, { texture: 'concrete', tile: [1.2, 1.2], roughness: 0.85, bumpScale: 0.3 })))
785
+ }
786
+ if (spec.mullion && window.width > 1.2) {
787
+ const box = put(window.uc, (window.y0 + window.y1) / 2, spec.mullion.width, window.y1 - window.y0, 0.06)
788
+ group.add(mesh(boxAt(box.size, box.position, null, edge.trim), woodMaterial(trimColor, 1, true)))
789
+ }
790
+ if (spec.lattice) {
791
+ const cellsU = Math.max(1, Math.round(window.width / spec.lattice.spacing))
792
+ const cellsV = Math.max(1, Math.round((window.y1 - window.y0) / spec.lattice.spacing))
793
+ const texture = material(`lattice|${spec.lattice.bar}`, () => {
794
+ const map = new THREE.CanvasTexture(latticeCanvas(spec.lattice.bar / spec.lattice.spacing))
795
+ map.colorSpace = THREE.SRGBColorSpace
796
+ map.wrapS = map.wrapT = THREE.RepeatWrapping
797
+ map.anisotropy = maxAnisotropy
798
+ return map
799
+ })
800
+ const box = put(window.uc, (window.y0 + window.y1) / 2, window.width, window.y1 - window.y0, 0.03)
801
+ const geometry = chamferBox(box.size.x, box.size.y, box.size.z, 0)
802
+ geometry.translate(box.position.x, box.position.y, box.position.z)
803
+ // 窗棂 UV 按窗洞等分:格子整齐落在窗框里,不从半格起头。
804
+ const position = geometry.attributes.position
805
+ const uv = new Float32Array(position.count * 2)
806
+ const start = window.uc - window.width / 2
807
+ for (let i = 0; i < position.count; i += 1) {
808
+ const along = window.axis === 'x' ? position.getX(i) : position.getZ(i)
809
+ uv[i * 2] = ((along - start) / window.width) * cellsU
810
+ uv[i * 2 + 1] = ((position.getY(i) - window.y0) / (window.y1 - window.y0)) * cellsV
811
+ }
812
+ geometry.setAttribute('uv', new THREE.BufferAttribute(uv, 2))
813
+ const mat = material(`latticeMat|${trimColor}`, () => new THREE.MeshStandardMaterial({
814
+ color: trimColor, map: texture, alphaTest: 0.5, roughness: 0.7, side: THREE.DoubleSide,
815
+ }))
816
+ const lattice = mesh(geometry, mat)
817
+ // 窗棂算作窗的一部分:拾取时和玻璃同口径,不挡门牌。
818
+ lattice.userData.opacity = window.part.opacity ?? 1
819
+ group.add(lattice)
820
+ }
821
+ return tag(group, { kind: 'window-detail', spaceIds: window.spaceIds, lowY: window.y0, opacity: window.part.opacity ?? 1 })
822
+ }
823
+
824
+ function buildThreshold(threshold) {
825
+ const box = threshold.axis === 'x'
826
+ ? { size: { x: threshold.u1 - threshold.u0, y: 0.012, z: threshold.thickness + 0.06 }, position: { x: (threshold.u0 + threshold.u1) / 2, y: threshold.y + 0.006, z: threshold.at } }
827
+ : { size: { x: threshold.thickness + 0.06, y: 0.012, z: threshold.u1 - threshold.u0 }, position: { x: threshold.at, y: threshold.y + 0.006, z: (threshold.u0 + threshold.u1) / 2 } }
828
+ const object = mesh(boxAt(box.size, box.position, null, 0.004), standard(look.opening.threshold, 1, { texture: 'concrete', tile: [1.2, 1.2], roughness: 0.8, bumpScale: 0.2 }), { cast: false })
829
+ return tag(object, { kind: 'threshold', spaceIds: threshold.spaceIds, lowY: threshold.y, opacity: 1 })
830
+ }
831
+
832
+ function buildStep(part) {
833
+ const opacity = part.opacity ?? 1
834
+ return tag(mesh(boxAt(part.size, part.position, part.rotation, edge.member), woodMaterial(part.color, opacity, false)), { kind: part.kind, spaceIds: part.spaceIds || [], lowY: partLowY(part), opacity })
835
+ }
836
+
837
+ function buildStringers(stair) {
838
+ const group = new THREE.Group()
839
+ const mat = woodMaterial(shadeHex(stair.color, 0.86), stair.opacity, false)
840
+ for (const stringer of stair.stringers) {
841
+ const geometry = chamferBox(stringer.length, stringer.height, stringer.depth, edge.member)
842
+ geometry.applyMatrix4(new THREE.Matrix4().makeRotationFromEuler(new THREE.Euler(0, stair.yaw, stair.pitch, 'YZX')))
843
+ geometry.translate(...stringer.center)
844
+ group.add(mesh(projectUV(geometry), mat))
845
+ }
846
+ return tag(group, { kind: 'stair', spaceIds: stair.spaceIds, lowY: stair.lowY, opacity: stair.opacity })
847
+ }
848
+
849
+ function buildBand(part) {
850
+ const opacity = part.opacity ?? 1
851
+ const color = shadeHex(part.color, look.band.shade)
852
+ const mat = isStatus(part.color) ? surfaceMaterial(part.color, opacity) : standard(color, opacity, { texture: look.band.texture, tile: [1.2, 1.2], roughness: 0.85, bumpScale: 0.4 })
853
+ return tag(mesh(boxAt(part.size, part.position, part.rotation, edge.member), mat), { kind: part.kind, spaceIds: part.spaceIds || [], lowY: partLowY(part), opacity })
854
+ }
855
+
856
+ function buildGeneric(part) {
857
+ const opacity = part.opacity ?? 1
858
+ const mat = isStatus(part.color) ? surfaceMaterial(part.color, opacity) : standard(part.color, opacity, { roughness: 0.85 })
859
+ return tag(mesh(boxAt(part.size, part.position, part.rotation, edge.trim), mat), { kind: part.kind, spaceIds: part.spaceIds || [], lowY: partLowY(part), opacity })
860
+ }
861
+
862
+ /** 主装配:规划 → 顶层对象数组。present.parts 每一块都有归宿(合并墙、屋顶组或逐块)。 */
863
+ function build(plan, model, present) {
864
+ const objects = []
865
+ const wallThickness = model?.defaults?.wallThickness ?? 0.18
866
+ const trimColor = present.parts.find((part) => part.kind === 'opening-frame')?.color
867
+ ?? present.parts.find((part) => part.kind === 'frame')?.color ?? '#a88059'
868
+ for (const wall of plan.walls.merged) objects.push(buildWall(wall))
869
+ for (const part of plan.walls.loose) objects.push(buildLooseWall(part))
870
+ for (const gable of plan.gables) objects.push(buildGable(gable))
871
+ for (const roof of plan.roofs) objects.push(buildRoof(roof, plan.roofs))
872
+ for (const plinth of plan.plinths) {
873
+ const object = buildPlinth(plinth, plan.groundY)
874
+ if (object) objects.push(object)
875
+ }
876
+ for (const window of plan.windows) objects.push(buildWindowDetail(window, trimColor))
877
+ for (const threshold of plan.thresholds) objects.push(buildThreshold(threshold))
878
+ for (const stair of plan.stairs) objects.push(buildStringers(stair))
879
+ for (const part of plan.rest) {
880
+ switch (part.kind) {
881
+ case 'floor': objects.push(buildFloor(part, model)); break
882
+ case 'plan-floor': objects.push(buildPlanFloor(part)); break
883
+ case 'frame': objects.push(buildFrame(part, plan.envelope, wallThickness)); break
884
+ case 'opening-frame':
885
+ case 'roof-frame':
886
+ case 'floor-joint-frame': objects.push(buildTrim(part)); break
887
+ case 'glass': objects.push(buildGlass(part)); break
888
+ case 'stair': objects.push(buildStep(part)); break
889
+ case 'floor-joint': objects.push(buildBand(part)); break
890
+ default: objects.push(buildGeneric(part))
891
+ }
892
+ }
893
+ return objects
894
+ }
895
+
896
+ return { build, standard, textures, tag, mesh, chamferBox, projectUV }
897
+ }
898
+
899
+ // ── 环境:底座、天空、环境光照 ────────────────────────────────────
900
+
901
+ let grain = null
902
+ function grainCanvas() {
903
+ if (grain) return grain
904
+ const size = 256
905
+ grain = document.createElement('canvas')
906
+ grain.width = grain.height = size
907
+ const context = grain.getContext('2d')
908
+ const image = context.createImageData(size, size)
909
+ const rand = mulberry32(7)
910
+ for (let i = 0; i < image.data.length; i += 4) {
911
+ const value = Math.round(rand() * 255)
912
+ image.data[i] = image.data[i + 1] = image.data[i + 2] = value
913
+ image.data[i + 3] = 255
914
+ }
915
+ context.putImageData(image, 0, 0)
916
+ return grain
917
+ }
918
+
919
+ /** 模型底座的顶面贴图:纸感底色+(可选)细网格+首层轮廓下的柔和接触阴影+内框线。 */
920
+ export function groundCanvas(look, rect, footprints) {
921
+ const width = 1024
922
+ const height = Math.max(64, Math.min(1024, Math.round(width * rect.depth / rect.width)))
923
+ const canvas = document.createElement('canvas')
924
+ canvas.width = width
925
+ canvas.height = height
926
+ const context = canvas.getContext('2d')
927
+ const sx = width / rect.width
928
+ const sz = height / rect.depth
929
+ context.fillStyle = look.ground.color
930
+ context.fillRect(0, 0, width, height)
931
+ // 纸感颗粒:用缓存的颗粒图平铺叠加(不读回像素——getImageData 会逼 GPU 同步,换建筑时卡顿)。
932
+ context.save()
933
+ context.globalAlpha = 0.05
934
+ context.fillStyle = context.createPattern(grainCanvas(), 'repeat')
935
+ context.fillRect(0, 0, width, height)
936
+ context.restore()
937
+ if (look.ground.grid) {
938
+ context.strokeStyle = look.ground.gridColor
939
+ context.globalAlpha = 0.55
940
+ context.lineWidth = 1
941
+ const step = look.ground.grid
942
+ for (let x = Math.ceil(rect.x0 / step) * step; x <= rect.x0 + rect.width; x += step) {
943
+ const px = Math.round((x - rect.x0) * sx) + 0.5
944
+ context.beginPath(); context.moveTo(px, 0); context.lineTo(px, height); context.stroke()
945
+ }
946
+ for (let z = Math.ceil(rect.z0 / step) * step; z <= rect.z0 + rect.depth; z += step) {
947
+ const pz = Math.round((z - rect.z0) * sz) + 0.5
948
+ context.beginPath(); context.moveTo(0, pz); context.lineTo(width, pz); context.stroke()
949
+ }
950
+ context.globalAlpha = 1
951
+ }
952
+ // 内框线:像模型底板上刻的一道边线。
953
+ context.strokeStyle = shadeHex(look.ground.color, 0.86)
954
+ context.lineWidth = 2
955
+ const inset = 0.45
956
+ context.strokeRect(inset * sx, inset * sz, width - inset * 2 * sx, height - inset * 2 * sz)
957
+ if (footprints.length && look.ground.aoStrength > 0) {
958
+ context.save()
959
+ context.filter = `blur(${Math.max(4, Math.round(0.5 * sx))}px)`
960
+ context.fillStyle = `rgba(40, 32, 20, ${look.ground.aoStrength})`
961
+ for (const f of footprints) {
962
+ const grow = 0.25
963
+ context.fillRect((f.x0 - grow - rect.x0) * sx, (f.z0 - grow - rect.z0) * sz, (f.x1 - f.x0 + grow * 2) * sx, (f.z1 - f.z0 + grow * 2) * sz)
964
+ }
965
+ context.restore()
966
+ }
967
+ return canvas
968
+ }
969
+
970
+ /** 圆角矩形底座(两层:面板+深色垫层),顶面 UV 覆盖整块板以贴底座贴图。 */
971
+ export function baseBoard(look, rect, top, own, groundTexture) {
972
+ const group = new THREE.Group()
973
+ const make = (x0, z0, w, d, thickness, radius, bevel) => {
974
+ const shape = new THREE.Shape()
975
+ const r = Math.min(radius, w / 2 - 0.01, d / 2 - 0.01)
976
+ shape.moveTo(x0 + r, z0)
977
+ shape.lineTo(x0 + w - r, z0)
978
+ shape.quadraticCurveTo(x0 + w, z0, x0 + w, z0 + r)
979
+ shape.lineTo(x0 + w, z0 + d - r)
980
+ shape.quadraticCurveTo(x0 + w, z0 + d, x0 + w - r, z0 + d)
981
+ shape.lineTo(x0 + r, z0 + d)
982
+ shape.quadraticCurveTo(x0, z0 + d, x0, z0 + d - r)
983
+ shape.lineTo(x0, z0 + r)
984
+ shape.quadraticCurveTo(x0, z0, x0 + r, z0)
985
+ const geometry = new THREE.ExtrudeGeometry(shape, {
986
+ depth: thickness - bevel * 2, curveSegments: 10, steps: 1,
987
+ bevelEnabled: bevel > 0, bevelThickness: bevel, bevelSize: bevel, bevelSegments: 2,
988
+ })
989
+ // 形状画在 XY(y 当作 z),挤出沿 Z;转成水平板:y' = z,z' = y(镜像)后再修正朝向。
990
+ geometry.rotateX(Math.PI / 2)
991
+ geometry.translate(0, thickness - bevel, 0)
992
+ const position = geometry.attributes.position
993
+ const uv = new Float32Array(position.count * 2)
994
+ for (let i = 0; i < position.count; i += 1) {
995
+ uv[i * 2] = (position.getX(i) - rect.x0) / rect.width
996
+ uv[i * 2 + 1] = (position.getZ(i) - rect.z0) / rect.depth
997
+ }
998
+ geometry.setAttribute('uv', new THREE.BufferAttribute(uv, 2))
999
+ return geometry
1000
+ }
1001
+ const bevel = 0.04
1002
+ const board = make(rect.x0 + bevel, rect.z0 + bevel, rect.width - bevel * 2, rect.depth - bevel * 2, look.base.thickness, look.base.radius, bevel)
1003
+ board.translate(0, top - look.base.thickness, 0)
1004
+ const topMat = own(new THREE.MeshStandardMaterial({ color: '#ffffff', map: groundTexture, roughness: 0.95 }))
1005
+ const sideMat = own(new THREE.MeshStandardMaterial({ color: look.ground.side, roughness: 0.9 }))
1006
+ const boardMesh = new THREE.Mesh(own(board), [topMat, sideMat])
1007
+ boardMesh.receiveShadow = true
1008
+ group.add(boardMesh)
1009
+ const inset = look.base.underInset
1010
+ const under = make(rect.x0 + inset, rect.z0 + inset, rect.width - inset * 2, rect.depth - inset * 2, look.base.underLayer, Math.max(0.1, look.base.radius - inset), 0.02)
1011
+ under.translate(0, top - look.base.thickness - look.base.underLayer, 0)
1012
+ const underMat = own(new THREE.MeshStandardMaterial({ color: look.ground.under, roughness: 0.8 }))
1013
+ group.add(new THREE.Mesh(own(under), underMat))
1014
+ return group
1015
+ }
1016
+
1017
+ /** 天空底色:竖向渐变(屏幕空间背景,不随相机旋转产生接缝)。 */
1018
+ export function skyTexture(look) {
1019
+ const canvas = document.createElement('canvas')
1020
+ canvas.width = 4
1021
+ canvas.height = 256
1022
+ const context = canvas.getContext('2d')
1023
+ const gradient = context.createLinearGradient(0, 0, 0, 256)
1024
+ gradient.addColorStop(0, look.sky.top)
1025
+ gradient.addColorStop(0.72, look.sky.horizon)
1026
+ gradient.addColorStop(1, look.sky.horizon)
1027
+ context.fillStyle = gradient
1028
+ context.fillRect(0, 0, 4, 256)
1029
+ const texture = new THREE.CanvasTexture(canvas)
1030
+ texture.colorSpace = THREE.SRGBColorSpace
1031
+ return texture
1032
+ }
1033
+
1034
+ /**
1035
+ * 环境光照(给玻璃与金属屋面一点反射):用一个小的渐变天球场景现场生成 PMREM,
1036
+ * 不需要 HDR 文件,也不需要 RoomEnvironment 插件。
1037
+ */
1038
+ export function environmentTexture(renderer, look) {
1039
+ const pmrem = new THREE.PMREMGenerator(renderer)
1040
+ const envScene = new THREE.Scene()
1041
+ const geometry = new THREE.SphereGeometry(10, 32, 16)
1042
+ const colors = []
1043
+ const top = new THREE.Color(look.sky.top)
1044
+ const horizon = new THREE.Color(look.sky.horizon)
1045
+ const ground = new THREE.Color(look.ground.color).multiplyScalar(0.7)
1046
+ const position = geometry.attributes.position
1047
+ for (let i = 0; i < position.count; i += 1) {
1048
+ const y = position.getY(i) / 10
1049
+ const color = y > 0 ? horizon.clone().lerp(top, Math.min(1, y * 1.4)) : horizon.clone().lerp(ground, Math.min(1, -y * 3))
1050
+ colors.push(color.r, color.g, color.b)
1051
+ }
1052
+ geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3))
1053
+ const sphereMat = new THREE.MeshBasicMaterial({ vertexColors: true, side: THREE.BackSide })
1054
+ envScene.add(new THREE.Mesh(geometry, sphereMat))
1055
+ const sunGeometry = new THREE.SphereGeometry(0.9, 16, 8)
1056
+ const sunMat = new THREE.MeshBasicMaterial({ color: new THREE.Color(look.light.sun).multiplyScalar(6) })
1057
+ const sun = new THREE.Mesh(sunGeometry, sunMat)
1058
+ const el = THREE.MathUtils.degToRad(look.light.sunElevation)
1059
+ const az = THREE.MathUtils.degToRad(look.light.sunAzimuth)
1060
+ sun.position.set(Math.cos(el) * Math.sin(az) * 8, Math.sin(el) * 8, Math.cos(el) * Math.cos(az) * 8)
1061
+ envScene.add(sun)
1062
+ const target = pmrem.fromScene(envScene, 0.04)
1063
+ geometry.dispose(); sphereMat.dispose(); sunGeometry.dispose(); sunMat.dispose()
1064
+ pmrem.dispose()
1065
+ return target
1066
+ }
1067
+
1068
+ // ── 门牌 ─────────────────────────────────────────────────────
1069
+
1070
+ const FONT = '"Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", "Source Han Sans SC", sans-serif'
1071
+
1072
+ function wrappedText(context, text, x, y, maxWidth, lineHeight, maxLines) {
1073
+ let line = ''
1074
+ let count = 0
1075
+ const chars = Array.from(String(text || ''))
1076
+ for (let i = 0; i < chars.length; i += 1) {
1077
+ const candidate = line + chars[i]
1078
+ if (context.measureText(candidate).width > maxWidth && line) {
1079
+ context.fillText(count === maxLines - 1 ? `${line.slice(0, -1)}…` : line, x, y + count * lineHeight)
1080
+ count += 1
1081
+ if (count >= maxLines) return
1082
+ line = chars[i]
1083
+ } else line = candidate
1084
+ }
1085
+ if (line && count < maxLines) context.fillText(line, x, y + count * lineHeight)
1086
+ }
1087
+
1088
+ /** 门牌画面:内容(标题、副标题)全部来自模型;版式两套相同,只换配色与边框做法。 */
1089
+ export function signCanvas(sign, { project, look }) {
1090
+ const spec = look.sign
1091
+ const canvas = document.createElement('canvas')
1092
+ canvas.width = 1024
1093
+ canvas.height = project ? 400 : 340
1094
+ const context = canvas.getContext('2d')
1095
+ context.fillStyle = spec.board
1096
+ context.fillRect(0, 0, canvas.width, canvas.height)
1097
+ if (look.id === 'chinese-timber') {
1098
+ context.strokeStyle = spec.accent
1099
+ context.lineWidth = 5
1100
+ context.strokeRect(22, 22, canvas.width - 44, canvas.height - 44)
1101
+ context.strokeStyle = spec.rule
1102
+ context.lineWidth = 2
1103
+ context.strokeRect(36, 36, canvas.width - 72, canvas.height - 72)
1104
+ } else {
1105
+ context.fillStyle = spec.accent
1106
+ context.fillRect(0, 0, 12, canvas.height)
1107
+ context.strokeStyle = spec.rule
1108
+ context.lineWidth = 2
1109
+ context.strokeRect(28, 28, canvas.width - 56, canvas.height - 56)
1110
+ }
1111
+ context.fillStyle = spec.accent
1112
+ context.font = `600 27px ${FONT}`
1113
+ context.fillText(project ? 'ARCHIFY · 项目牌' : '业务门牌', 66, 88)
1114
+ context.fillStyle = spec.ink
1115
+ context.font = `700 ${project ? 70 : 66}px ${FONT}`
1116
+ wrappedText(context, sign.title, 64, project ? 180 : 176, 896, 76, 1)
1117
+ context.fillStyle = spec.muted
1118
+ context.font = `500 28px ${FONT}`
1119
+ wrappedText(context, sign.subtitle || '', 66, project ? 262 : 250, 892, 38, project ? 3 : 2)
1120
+ return canvas
1121
+ }
1122
+
1123
+ /** 立一块门牌:面板+背板(带厚度的"牌匾"),都按模型给的贴墙位置与朝向。 */
1124
+ export function buildSign(sign, { project, look, own, maxAnisotropy }) {
1125
+ const spec = look.signLayout
1126
+ const canvas = signCanvas(sign, { project, look })
1127
+ const texture = own(new THREE.CanvasTexture(canvas))
1128
+ texture.colorSpace = THREE.SRGBColorSpace
1129
+ texture.anisotropy = maxAnisotropy
1130
+ const width = project ? spec.projectWidth : spec.width
1131
+ const height = width * (canvas.height / canvas.width)
1132
+ const group = new THREE.Group()
1133
+ const faceMat = own(new THREE.MeshStandardMaterial({
1134
+ map: texture, roughness: 0.92, emissive: '#ffffff', emissiveMap: texture, emissiveIntensity: look.sign.emissive,
1135
+ }))
1136
+ const face = new THREE.Mesh(own(new THREE.PlaneGeometry(width, height)), faceMat)
1137
+ face.position.z = 0.002
1138
+ group.add(face)
1139
+ const backing = new THREE.Mesh(
1140
+ own(chamferBox(width + spec.backingPad * 2, spec.backingDepth, height + spec.backingPad * 2, 0.012)),
1141
+ own(new THREE.MeshStandardMaterial({ color: look.sign.backing, roughness: 0.6 })),
1142
+ )
1143
+ backing.rotation.x = Math.PI / 2
1144
+ backing.position.z = -spec.backingDepth / 2
1145
+ backing.castShadow = true
1146
+ group.add(backing)
1147
+ const position = new THREE.Vector3(sign.position.x, sign.position.y, sign.position.z)
1148
+ group.position.copy(position)
1149
+ group.lookAt(position.clone().add(new THREE.Vector3(sign.normal.x, sign.normal.y, sign.normal.z)))
1150
+ group.userData = {
1151
+ kind: 'sign',
1152
+ spaceIds: sign.spaceId ? [sign.spaceId] : [],
1153
+ lowY: sign.position.y - height / 2,
1154
+ opacity: 1,
1155
+ }
1156
+ return group
1157
+ }